// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // // The licence layout of this template (plan item AT5.1, spec section 14.3), as a tool and as the // rules `kit check` and the tests read. // // node scripts/licence.mjs --check fail, naming each file, when the layout is not whole // node scripts/licence.mjs --write add the missing headers and rewrite TEMPLATE-FILES // // What is the template's and what is the customer's: // // - Template files (this tool's `templateFiles`) carry the SPDX header `LicenseRef-ConusAI-Template-1.0` // and are listed in TEMPLATE-FILES: every file of the scaffold except `modules/**` and `test/modules/**` (the // customer's domain code and its app-level tests, which carry the customer's licence), `LICENSE` (the customer's own file, a stub // until they fill it in), `pnpm-lock.yaml` (generated per bundle and then maintained by the // customer) and `.conusai/template.json` (the upgrade record, written by the bundle). // - A file that cannot hold a comment (JSON, an image, llms.txt) is listed and exempt from // the header: `headerFor` answers null for it. // // `kit check` of @conusai-kit/cli reads the same two facts, TEMPLATE-FILES and the header, so a file a // customer or an agent copies out of the scaffold without its header fails the gate. import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs" import { dirname, join, relative, sep } from "node:path" import { fileURLToPath } from "node:url" export const SPDX_ID = "LicenseRef-ConusAI-Template-1.0" export const SPDX_LINE = `SPDX-License-Identifier: ${SPDX_ID}` export const LIST_FILE = "TEMPLATE-FILES" /** Directories that are never part of the shipped template, at any depth. */ // `_generated`: Convex codegen output, written by a deploy and git-ignored. const IGNORED_DIRS = new Set([ "node_modules", ".next", "dist", "test-results", "playwright-report", ".logs", ".dev", "_generated", ]) /** Files that are the customer's, or written by the bundle, and so are not the template's. */ const NOT_THE_TEMPLATES = new Set(["LICENSE", "pnpm-lock.yaml", ".conusai/template.json", ".env", ".env.local"]) const CUSTOMER_PREFIXES = ["modules/", "test/modules/"] /** Files a tool writes: Next.js rewrites next-env.d.ts, TypeScript writes build info. They are not shipped. */ const GENERATED = (name) => name === "next-env.d.ts" || name.endsWith(".tsbuildinfo") /** `//`-style comment types, by extension. */ const SLASH = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"]) const HASH = new Set([".yml", ".yaml", ".sh", ".npmrc", ".gitignore", ".dockerignore", ".example"]) const HASH_NAMES = new Set(["Dockerfile", ".npmrc", ".gitignore", ".dockerignore"]) const extensionOf = (path) => { const name = path.slice(path.lastIndexOf("/") + 1) const dot = name.lastIndexOf(".") return dot <= 0 ? "" : name.slice(dot) } /** * The comment that carries the header in this file, or null when the file cannot hold one. Plain text * the licence layout itself writes (LICENSE-TEMPLATE, NOTICE, THIRD-PARTY-NOTICES, TEMPLATE-FILES) states * its licence in its own words, so it is exempt too. */ export function headerFor(path) { const name = path.slice(path.lastIndexOf("/") + 1) const ext = extensionOf(path) if (SLASH.has(ext)) return `// ${SPDX_LINE}` if (ext === ".css") return `/* ${SPDX_LINE} */` if (ext === ".sql") return `-- ${SPDX_LINE}` if (ext === ".md") return `` if (HASH.has(ext) || HASH_NAMES.has(name) || name.startsWith(".env")) return `# ${SPDX_LINE}` return null } /** Every file the template ships that is the template's, as sorted POSIX paths relative to `root`. */ export function templateFiles(root) { const found = [] const walk = (dir) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { if (!IGNORED_DIRS.has(entry.name)) walk(join(dir, entry.name)) } else if (entry.isFile() && !GENERATED(entry.name)) { const rel = relative(root, join(dir, entry.name)).split(sep).join("/") if (NOT_THE_TEMPLATES.has(rel) || CUSTOMER_PREFIXES.some((prefix) => rel.startsWith(prefix))) continue found.push(rel) } } } walk(root) return found.sort() } /** The text TEMPLATE-FILES holds for a set of paths. */ export function listText(paths) { return [ "# The files of this template that ConusAI owns: the scaffold `kit template:upgrade` merges, and the", `# files that carry the header \`${SPDX_ID}\` (LICENSE-TEMPLATE). One path per line.`, "#", "# Not listed, because they are yours: `modules/**` (your domain code), `test/modules/**` (its tests that", "# need the whole app), `LICENSE` (your own licence), `pnpm-lock.yaml` (your own lock) and", "# `.conusai/template.json` (the upgrade record). A file that", "# cannot hold a comment (JSON, llms.txt) is listed and has no header. Regenerate with", "# `node scripts/licence.mjs --write`.", ...paths, "", ].join("\n") } /** The paths a TEMPLATE-FILES text lists. */ export function parseList(text) { return text .split("\n") .map((line) => line.trim()) .filter((line) => line !== "" && !line.startsWith("#")) } /** * The line the header sits on: after a shebang, and after the YAML frontmatter of a Markdown file that has one * (an agent skill or command, `.claude/skills//SKILL.md`, must open with `---` for its tool to read it). A * BOM or a directive's line is not skipped: the header leads. Keep frontmatter short: `kit check` looks for the * header in a file's first eight lines. */ function headerLine(lines, path) { if (lines[0]?.startsWith("#!")) return 1 if (extensionOf(path) === ".md" && lines[0]?.trim() === "---") { const close = lines.indexOf("---", 1) if (close > 0) return close + 1 } return 0 } /** Whether `text` carries the header on its header line (see `headerLine`). */ export function hasHeader(text, path) { const header = headerFor(path) if (header === null) return true const lines = text.replace(/^/, "").split("\n") return lines[headerLine(lines, path)]?.trim() === header } /** The text with the header added on its header line (see `headerLine`). */ export function withHeader(text, path) { const header = headerFor(path) if (header === null || hasHeader(text, path)) return text const lines = text.split("\n") lines.splice(headerLine(lines, path), 0, header) return lines.join("\n") } /** * Everything wrong with the licence layout under `root`, as one sentence each. Empty when whole. */ export function problems(root) { const found = [] for (const required of ["LICENSE-TEMPLATE", "LICENSE", "NOTICE", LIST_FILE, "THIRD-PARTY-NOTICES"]) { if (!existsSync(join(root, required))) found.push(`${required} is missing`) } const actual = templateFiles(root) if (existsSync(join(root, LIST_FILE))) { const listed = new Set(parseList(readFileSync(join(root, LIST_FILE), "utf8"))) for (const path of actual) if (!listed.has(path)) found.push(`${LIST_FILE} does not list ${path}`) for (const path of listed) if (!actual.includes(path)) found.push(`${LIST_FILE} lists ${path}, which is not a template file`) } for (const path of actual) { const text = readFileSync(join(root, path), "utf8") if (!hasHeader(text, path)) found.push(`${path} has no "${SPDX_LINE}" header`) } return found } /** Adds every missing header and rewrites TEMPLATE-FILES; answers the paths it changed. */ export function write(root) { const changed = [] // The list first: it is itself one of the files. const paths = [...new Set([...templateFiles(root), LIST_FILE])].sort() writeFileSync(join(root, LIST_FILE), listText(paths)) for (const path of paths) { const file = join(root, path) if (!statSync(file).isFile()) continue const before = readFileSync(file, "utf8") const after = withHeader(before, path) if (after !== before) { writeFileSync(file, after) changed.push(path) } } return changed } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { const root = join(dirname(fileURLToPath(import.meta.url)), "..") const mode = process.argv[2] if (mode === "--write") { const changed = write(root) console.log( `licence: ${changed.length} file(s) changed; ${LIST_FILE} lists ${parseList(readFileSync(join(root, LIST_FILE), "utf8")).length}`, ) } else if (mode === "--check") { const found = problems(root) for (const problem of found) console.error(`licence: ${problem}`) if (found.length > 0) process.exit(1) console.log("licence: the layout is whole") } else { console.error("usage: node scripts/licence.mjs --check | --write") process.exit(2) } }