diff --git a/.oxlintrc.json b/.oxlintrc.json
index 90fce46..1732b44 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -29,6 +29,7 @@
"eslint/no-eq-null": "off",
"eslint/no-magic-numbers": "warn",
"eslint/no-ternary": "off",
+ "eslint/no-nested-ternary": "off",
"eslint/no-undefined": "off",
"eslint/no-void": "off",
"eslint/no-warning-comments": ["warn", { "terms": ["TODO"] }],
diff --git a/mise.toml b/mise.toml
index d213465..e806049 100644
--- a/mise.toml
+++ b/mise.toml
@@ -46,6 +46,13 @@ run = "hongdown --check"
description = "Check mise.toml formatting"
run = "mise fmt --check"
+[tasks."check:license"]
+description = "Check the license comments of files in the whole codebase."
+run = """
+#!/usr/bin/env nu
+node scripts/add-license/main.mts --check
+"""
+
[tasks."check:versions"]
description = "Check that all package versions are in sync"
run = "node scripts/check-versions.mts"
@@ -63,6 +70,13 @@ run = "hongdown --write"
description = "Format mise.toml"
run = "mise fmt"
+[tasks."fmt:license"]
+description = "Add license comments to the files that don't have them."
+run = """
+#!/usr/bin/env nu
+node scripts/add-license/main.mts
+"""
+
[tasks.build]
description = "Build the project"
run = "pnpm run --parallel --recursive build"
diff --git a/scripts/add-license/gpl.mts b/scripts/add-license/gpl.mts
new file mode 100644
index 0000000..12f8463
--- /dev/null
+++ b/scripts/add-license/gpl.mts
@@ -0,0 +1,41 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const GPL_TXT_PATH = join(dirname(fileURLToPath(import.meta.url)), "gpl.txt");
+const GPL = (await readFile(GPL_TXT_PATH, { encoding: "utf-8" }))
+ .trimEnd()
+ .split("\n");
+const JS_GPL = GPL.map((line) => (line === "" ? "//" : `// ${line}`)).join(
+ "\n",
+);
+const CSS_GPL = ["/*", ...GPL, "*/"].join("\n");
+const GPL_BY_EXT = {
+ ts: JS_GPL,
+ tsx: JS_GPL,
+ mts: JS_GPL,
+ cts: JS_GPL,
+ js: JS_GPL,
+ jsx: JS_GPL,
+ mjs: JS_GPL,
+ cjs: JS_GPL,
+ css: CSS_GPL,
+};
+
+export default GPL_BY_EXT;
diff --git a/scripts/add-license/gpl.txt b/scripts/add-license/gpl.txt
new file mode 100644
index 0000000..3521ade
--- /dev/null
+++ b/scripts/add-license/gpl.txt
@@ -0,0 +1,15 @@
+DrFed: A web-based platform for developing and debugging ActivityPub apps
+Copyright (C) 2026 DrFed team
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
diff --git a/scripts/add-license/main.mts b/scripts/add-license/main.mts
new file mode 100644
index 0000000..1946ae2
--- /dev/null
+++ b/scripts/add-license/main.mts
@@ -0,0 +1,131 @@
+// DrFed: A web-based platform for developing and debugging ActivityPub apps
+// Copyright (C) 2026 DrFed team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// oxlint-disable no-console no-magic-numbers
+import { glob, readFile, writeFile } from "node:fs/promises";
+import { dirname, extname, join, relative } from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+import GPL from "./gpl.mts";
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+const INCLUDED = ["scripts/", "packages/*/src/", "packages/*/bin/"];
+const EXCLUDED = ["**/dist/"];
+
+type Extension = keyof typeof GPL;
+
+interface AddLicenseProps {
+ check?: true | undefined;
+}
+
+interface LicenseTarget {
+ readonly path: string;
+ readonly ext: Extension;
+}
+
+interface LicenseCheck extends LicenseTarget {
+ readonly hasLicense: boolean;
+}
+
+export default async function addLicense(opt: AddLicenseProps) {
+ const files = await findTargets().then(filterLicense);
+ if (files.length === 0) {
+ process.exit(0);
+ }
+ if (opt.check) {
+ console.warn("Licenses are missing from some files:");
+ listFiles(files);
+ process.exit(1);
+ }
+ console.warn("Added licenses to the files that were missing them:");
+ listFiles(files);
+
+ await Promise.all(files.map(addLicenseHeader));
+}
+
+const findTargets = async (): Promise =>
+ (await Array.fromAsync(glob(INCLUDED.map(addAsteriskIfDir), GLOB_OPTION)))
+ .filter((entry) => entry.isFile())
+ .map((entry) => join(entry.parentPath, entry.name))
+ .map((path) => ({ path, ext: extensionOf(path) }))
+ .filter(isLicenseTarget);
+
+const GLOB_OPTION = {
+ cwd: ROOT,
+ exclude: EXCLUDED,
+ withFileTypes: true,
+} as const;
+
+const addAsteriskIfDir = (dir: string) =>
+ dir.endsWith("/") ? `${dir}**/*` : dir;
+
+const filterLicense = async (files: LicenseTarget[]): Promise =>
+ (await Array.fromAsync(files, checkLicense)).filter(
+ (check) => !check.hasLicense,
+ );
+
+const extensionOf = (path: string): string => extname(path).slice(1);
+
+const isLicenseTarget = (
+ file: Readonly<{ path: string; ext: string }>,
+): file is LicenseTarget => Object.hasOwn(GPL, file.ext);
+
+async function checkLicense(target: LicenseTarget): Promise {
+ const header = GPL[target.ext];
+ const lines = await readLines(target.path);
+ const offset = shebangOffset(lines);
+ const headerLineCount = header.split("\n").length;
+ const actualHeader = lines.slice(offset, offset + headerLineCount).join("\n");
+ return { ...target, hasLicense: actualHeader === header };
+}
+
+async function addLicenseHeader(target: LicenseTarget): Promise {
+ const added = updateLines(GPL[target.ext], await readLines(target.path));
+ await writeFile(target.path, added, { encoding: "utf-8" });
+}
+
+const readLines = async (path: string): Promise =>
+ (await readFile(path, { encoding: "utf-8" })).split("\n");
+
+const shebangOffset = (lines: readonly string[]): number =>
+ lines[0]?.startsWith("#!") ? (lines[1]?.trim() === "" ? 2 : 1) : 0;
+
+const updateLines = (header: string, lines: string[]): string =>
+ Array.from(genLines(header, lines)).join("\n");
+
+function* genLines(header: string, lines: string[]): Generator {
+ const offset = shebangOffset(lines);
+ yield* lines.slice(0, offset);
+ yield header;
+ const next = lines.at(offset);
+ if (next != null && next.trim() !== "") {
+ yield "";
+ }
+ yield* lines.slice(offset);
+}
+
+function listFiles(files: LicenseTarget[]) {
+ for (const file of files) {
+ console.warn(` - ${relative(ROOT, file.path)}`);
+ }
+}
+
+if (import.meta.main) {
+ await addLicense({
+ check: process.argv.includes("--check") ? true : undefined,
+ });
+}