This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The types of scripts/agent-format.mjs, for the test that reads its rule.
|
||||
|
||||
export function fileToFormat(input: unknown, root?: string): string | null
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
//
|
||||
// The format-on-edit hook of a coding agent (`.claude/settings.json`, PostToolUse on Edit and Write): it reads
|
||||
// the hook's JSON from stdin and formats the one file the agent changed with this repository's Biome, so the
|
||||
// gate reports real problems instead of whitespace. It never fails the agent's edit: a file Biome does not
|
||||
// handle, or a missing install, is skipped silently.
|
||||
//
|
||||
// echo '{"tool_input":{"file_path":"modules/notes/core/x.ts"}}' | node scripts/agent-format.mjs
|
||||
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { dirname, extname, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const FORMATTED = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".jsonc", ".css"])
|
||||
|
||||
/** The repository-relative path the hook names, or null when there is nothing for Biome to format. */
|
||||
export function fileToFormat(input, root = ROOT) {
|
||||
const path = input?.tool_input?.file_path
|
||||
if (typeof path !== "string" || path === "") return null
|
||||
const absolute = resolve(root, path)
|
||||
const rel = relative(root, absolute)
|
||||
if (rel.startsWith("..") || rel.split(/[\\/]/).includes("node_modules")) return null
|
||||
if (!FORMATTED.has(extname(rel)) || !existsSync(absolute)) return null
|
||||
return rel
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
let text = ""
|
||||
process.stdin.setEncoding("utf8")
|
||||
process.stdin.on("data", (chunk) => {
|
||||
text += chunk
|
||||
})
|
||||
process.stdin.on("end", () => {
|
||||
let input = null
|
||||
try {
|
||||
input = JSON.parse(text)
|
||||
} catch {
|
||||
process.exit(0)
|
||||
}
|
||||
const file = fileToFormat(input)
|
||||
const biome = join(ROOT, "node_modules", ".bin", "biome")
|
||||
if (file === null || !existsSync(biome)) process.exit(0)
|
||||
spawnSync(biome, ["format", "--write", "--no-errors-on-unmatched", file], { cwd: ROOT, stdio: "ignore" })
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex profile's generator entry (spec §5.4): `pnpm convex:gen`. Rebuilds the module
|
||||
// library (the registry is read from the manifests, not from a snapshot) and rewrites
|
||||
// convex/schema.ts and convex/generated/<module>.ts. The output is deterministic: regeneration
|
||||
// on an unchanged registry is byte-identical, and the profile gate asserts it.
|
||||
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { writeConvexFiles } from "@conusai-kit/data-convex"
|
||||
import { convexModules } from "../convex/lib/convexModules.mjs"
|
||||
// The registry is read from the modules' built library (`pnpm run build:libs` first — the
|
||||
// package.json script chains it), filtered to the modules the Convex runtime can deploy
|
||||
// (convex/lib/convexModules.mjs, one exclusion map shared with the runtime).
|
||||
import { appModules } from "../modules/dist/index.js"
|
||||
|
||||
const SPDX = "// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n"
|
||||
|
||||
// A module that declares its profiles and leaves out `convex` (knowledge, agent) is not deployed
|
||||
// here: the runtime's module list (convex/lib/runtime.ts) and test/convex-profile.test.ts apply the
|
||||
// same filter, so no wrapper is generated for a procedure the deployed registry does not hold.
|
||||
const convexBound = appModules.filter((module) => module.profiles === undefined || module.profiles.includes("convex"))
|
||||
const registry = buildRegistry([...convexModules(convexBound)])
|
||||
const files = await writeConvexFiles(
|
||||
registry,
|
||||
new URL("../convex", import.meta.url).pathname,
|
||||
{
|
||||
mkdir,
|
||||
writeFile,
|
||||
},
|
||||
{ header: SPDX },
|
||||
)
|
||||
const functionName = (name) => name.replace(/[^A-Za-z0-9_$]/g, "_")
|
||||
const procedures = [...registry.procedures.values()]
|
||||
.map(({ module, def }) => [def.name, { kind: def.kind, reference: `generated/${module}:${functionName(def.name)}` }])
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
const procedureMap = `${SPDX}// Generated by @conusai-kit/data-convex (pnpm convex:gen). Do not edit by hand.\nexport const CONVEX_PROCEDURES = ${JSON.stringify(Object.fromEntries(procedures), null, 2)} as const\n`
|
||||
await writeFile(new URL("../convex/generated/procedures.ts", import.meta.url), procedureMap)
|
||||
|
||||
// Convex bundles every exported .ts function it finds, whether or not another file imports it.
|
||||
// Leaving a wrapper for an excluded module deploys a public endpoint whose runtime registry does
|
||||
// not contain its procedure. Remove stale wrappers deterministically so generation is also the
|
||||
// profile's deployment allowlist.
|
||||
const expected = new Set([...files.map((file) => file.path.split("/").at(-1)), "procedures.ts"])
|
||||
for (const entry of await readdir(new URL("../convex/generated/", import.meta.url), { withFileTypes: true })) {
|
||||
if (entry.isFile() && entry.name.endsWith(".ts") && !expected.has(entry.name)) {
|
||||
await rm(new URL(`../convex/generated/${entry.name}`, import.meta.url))
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`convex:gen: ${files.length + 1} file(s) written (${files
|
||||
.map((f) => f.path)
|
||||
.concat("generated/procedures.ts")
|
||||
.join(", ")})`,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The layer rules of this repository, generated from the folder tree so a module added
|
||||
// tomorrow is covered without anybody editing a list (app-template spec §2). Read by
|
||||
// .dependency-cruiser.cjs and by test/boundaries.test.ts.
|
||||
"use strict"
|
||||
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
|
||||
/** The module ids under modules/, which is the only place a domain module lives. */
|
||||
function listModules(root) {
|
||||
const dir = path.join(root, "modules")
|
||||
if (!fs.existsSync(dir)) return []
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** What a module's core may never reach: an engine, an SDK or a framework (spec §2). */
|
||||
const DRIVERS_AND_FRAMEWORKS = [
|
||||
"^(kysely|pg|pg-boss|mongodb|hono)$",
|
||||
"^(react|react-dom|next)(/|$)",
|
||||
"^@conusai-kit/(data-kysely|data-mongo|data-convex|jobs-pgboss|platform|ui|i18n|auth)(/|$)",
|
||||
"^@conusai-sdk/",
|
||||
]
|
||||
|
||||
function buildForbidden(moduleIds) {
|
||||
const rules = [
|
||||
{
|
||||
name: "no-circular",
|
||||
severity: "error",
|
||||
comment: "A cycle between files makes the order they run in an accident. Move the shared part down a layer.",
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
{
|
||||
name: "worker-entry-is-the-workers-own",
|
||||
severity: "error",
|
||||
comment:
|
||||
"@conusai-kit/core/worker mints system actors. Only apps/worker may import it, so no transport can act as one (kit-core system-actor.ts).",
|
||||
from: { pathNot: "^apps/worker/" },
|
||||
// The resolved path, not the specifier: dependency-cruiser matches what a module resolves
|
||||
// to, and in a pnpm store that is .../@conusai-kit/core/dist/worker.js.
|
||||
to: { path: "@conusai-kit/core/dist/worker\\." },
|
||||
},
|
||||
{
|
||||
name: "core-imports-no-engine-sdk-or-framework",
|
||||
severity: "error",
|
||||
comment:
|
||||
"A use case under modules/<id>/core is pure TypeScript over the ports of @conusai-kit/core|data|tenancy, so it runs on every profile and needs no database to test (spec §2).",
|
||||
from: { path: "^modules/[^/]+/core/" },
|
||||
to: { path: DRIVERS_AND_FRAMEWORKS },
|
||||
},
|
||||
{
|
||||
name: "contract-is-contract-only",
|
||||
severity: "error",
|
||||
comment:
|
||||
"A module's contract/ is what other modules import, so it may not reach back into its own core, adapters or ui.",
|
||||
from: { path: "^modules/([^/]+)/contract/" },
|
||||
to: { path: "^modules/$1/(core|adapters|ui)/" },
|
||||
},
|
||||
{
|
||||
name: "ui-talks-to-contracts-not-to-core",
|
||||
severity: "error",
|
||||
comment:
|
||||
"A module's pages read and write through the data facade and its own contract, never its use cases (spec §6.3).",
|
||||
from: { path: "^modules/([^/]+)/ui/" },
|
||||
to: { path: "^modules/$1/(core|adapters)/" },
|
||||
},
|
||||
{
|
||||
name: "a-module-never-imports-an-app",
|
||||
severity: "error",
|
||||
comment: "apps/* are composition roots: they import modules, never the other way round.",
|
||||
from: { path: "^modules/" },
|
||||
to: { path: "^apps/" },
|
||||
},
|
||||
{
|
||||
name: "config-is-below-everything",
|
||||
severity: "error",
|
||||
comment: "config/ is read by apps and modules, so it may not import either of them.",
|
||||
from: { path: "^config/" },
|
||||
to: { path: "^(apps|modules)/" },
|
||||
},
|
||||
]
|
||||
|
||||
// One rule per module, from the folder tree: a module reaches another only through its
|
||||
// contract/, and the message names both modules.
|
||||
for (const id of moduleIds) {
|
||||
const others = moduleIds.filter((other) => other !== id)
|
||||
if (others.length === 0) continue
|
||||
rules.push({
|
||||
name: `module-${id}-crosses-only-contracts`,
|
||||
severity: "error",
|
||||
comment: `modules/${id} may import another module's contract/ and nothing else of it (spec §2, §4.4).`,
|
||||
from: { path: `^modules/${id}/` },
|
||||
to: { path: `^modules/(${others.join("|")})/(?!contract/)` },
|
||||
})
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
module.exports = { DRIVERS_AND_FRAMEWORKS, buildForbidden, listModules }
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
//
|
||||
// `pnpm dev`: the whole app on this machine, against the fake platform, with demo data.
|
||||
//
|
||||
// 1. Postgres with the three roles a linked managed database exposes, in Docker (compose.dev.yaml),
|
||||
// unless POSTGRES_URL, POSTGRES_READER_URL and POSTGRES_MIGRATE_URL are set in your shell
|
||||
// 2. the fake platform of @conusai-kit/testing -- Blob, OCR, the AI gateway, audit, usage -- in this process
|
||||
// 3. the libraries and the worker, built; then the migrate job, then the demo data (`pnpm seed`)
|
||||
// 4. the worker, and the web shell under `next dev`
|
||||
//
|
||||
// It reads no file: a value you want different is a variable in your shell (`DEV_WEB_PORT=3200 pnpm dev`,
|
||||
// `POSTGRES_URL=... pnpm dev`). The values it uses are printed. The fake platform is selected on purpose
|
||||
// (CONUSAI_FAKE_PLATFORM=1) and the processes run with NODE_ENV=development, which is the only mode
|
||||
// the app accepts that switch in (config/src/env.ts). Ctrl+C stops everything but Postgres, whose data
|
||||
// stays; `docker compose -f compose.yaml -f compose.dev.yaml down` stops that too (add -v to drop the data).
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { basename, dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { startFakePlatform, validReply } from "@conusai-kit/testing/fake-platform"
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const say = (line) => console.log(`dev: ${line}`)
|
||||
|
||||
const port = (name, fallback) => {
|
||||
const value = Number.parseInt(process.env[name] ?? "", 10)
|
||||
return Number.isInteger(value) && value > 0 && value < 65_536 ? value : fallback
|
||||
}
|
||||
const ports = {
|
||||
web: port("DEV_WEB_PORT", 3000),
|
||||
platform: port("DEV_PLATFORM_PORT", 4010),
|
||||
postgres: port("DEV_POSTGRES_PORT", 55_433),
|
||||
}
|
||||
|
||||
/** The session secret, made once and kept (`.dev/` is ignored), so a restart does not sign everybody out. */
|
||||
function sessionSecret() {
|
||||
if (process.env.AUTH_SESSION_SECRET) return process.env.AUTH_SESSION_SECRET
|
||||
const file = join(root, ".dev", "session-secret")
|
||||
if (existsSync(file)) return readFileSync(file, "utf8").trim()
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
const secret = randomBytes(32).toString("base64url")
|
||||
writeFileSync(file, `${secret}\n`, { mode: 0o600 })
|
||||
return secret
|
||||
}
|
||||
|
||||
const url = (role) => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${ports.postgres}/appdev`
|
||||
const ownDatabase = ["POSTGRES_URL", "POSTGRES_READER_URL", "POSTGRES_MIGRATE_URL"].every((name) => process.env[name])
|
||||
|
||||
const platformUrl = `http://127.0.0.1:${ports.platform}`
|
||||
const environment = {
|
||||
...process.env,
|
||||
NODE_ENV: "development",
|
||||
CONUSAI_FAKE_PLATFORM: "1",
|
||||
CONUSAI_API_URL: `${platformUrl}/api`,
|
||||
CONUSAI_API_TOKEN: "dev-token",
|
||||
CONUSAI_PROJECT_ID: "1",
|
||||
CONUSAI_ORG_ID: "dev",
|
||||
CONUSAI_PUBLIC_URL: `http://127.0.0.1:${ports.web}`,
|
||||
CONUSAI_RELEASE: "dev",
|
||||
POSTGRES_URL: process.env.POSTGRES_URL ?? url("app"),
|
||||
POSTGRES_READER_URL: process.env.POSTGRES_READER_URL ?? url("reader"),
|
||||
POSTGRES_MIGRATE_URL: process.env.POSTGRES_MIGRATE_URL ?? url("owner"),
|
||||
AUTH_SESSION_SECRET: sessionSecret(),
|
||||
AUTH_DEV_LOGIN: "1",
|
||||
AUTH_INSECURE_COOKIES: "1",
|
||||
DEV_POSTGRES_PORT: String(ports.postgres),
|
||||
// The development Postgres is the pgvector image (compose.dev.yaml), so the knowledge module's
|
||||
// vectors live there unless the shell chose otherwise.
|
||||
KNOWLEDGE_VECTOR_STORE: process.env.KNOWLEDGE_VECTOR_STORE ?? "pgvector",
|
||||
}
|
||||
|
||||
/** The environment of every process but the migrate job: the owner's URL is the migrate job's alone. */
|
||||
const { POSTGRES_MIGRATE_URL: ownerUrl, ...withoutOwner } = environment
|
||||
|
||||
/** Runs a command to its end, printing what it prints; stops the run when it fails. */
|
||||
function run(label, command, args, options = {}) {
|
||||
say(label)
|
||||
const result = spawnSync(command, args, { cwd: root, env: withoutOwner, stdio: "inherit", ...options })
|
||||
if (result.status !== 0) {
|
||||
console.error(`dev: ${label} failed${result.error ? `: ${result.error.message}` : ""}`)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const compose = [
|
||||
"compose",
|
||||
"-p",
|
||||
`${basename(root)}-dev-${createHash("sha1").update(root).digest("hex").slice(0, 6)}`,
|
||||
"-f",
|
||||
"compose.yaml",
|
||||
"-f",
|
||||
"compose.dev.yaml",
|
||||
]
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
async function startPostgres() {
|
||||
if (ownDatabase) {
|
||||
say("using the Postgres your POSTGRES_URL, POSTGRES_READER_URL and POSTGRES_MIGRATE_URL name")
|
||||
return
|
||||
}
|
||||
if (spawnSync("docker", ["info"], { stdio: "ignore" }).status !== 0) {
|
||||
console.error(
|
||||
"dev: Docker is not running, and Postgres is started in Docker. Start it, or set POSTGRES_URL, POSTGRES_READER_URL and POSTGRES_MIGRATE_URL to a database with the three roles of dev/postgres-roles.sql.",
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
run(`starting Postgres on 127.0.0.1:${ports.postgres}`, "docker", [...compose, "up", "-d", "--wait", "postgres"], {
|
||||
env: environment, // compose interpolates every service of the file, the migrate job's owner URL included
|
||||
})
|
||||
// Healthy means the server answers; the roles are created by the init script, which restarts the
|
||||
// server. "Ready" is the app's own login working.
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const probe = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
...compose,
|
||||
"exec",
|
||||
"-T",
|
||||
"postgres",
|
||||
"psql",
|
||||
"-U",
|
||||
"appdev_app",
|
||||
"-h",
|
||||
"127.0.0.1",
|
||||
"-d",
|
||||
"appdev",
|
||||
"-c",
|
||||
"select 1",
|
||||
],
|
||||
{ cwd: root, env: withoutOwner, stdio: "ignore" },
|
||||
)
|
||||
if (probe.status === 0) return
|
||||
if (attempt > 120) {
|
||||
console.error("dev: Postgres did not accept the app's login in a minute")
|
||||
process.exit(1)
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
const children = []
|
||||
function start(name, command, args, cwd) {
|
||||
const child = spawn(command, args, { cwd, env: withoutOwner, detached: true, stdio: ["ignore", "pipe", "pipe"] })
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
let pending = ""
|
||||
stream.on("data", (chunk) => {
|
||||
pending += chunk.toString("utf8")
|
||||
const lines = pending.split("\n")
|
||||
pending = lines.pop() ?? ""
|
||||
for (const line of lines) console.log(`[${name}] ${line}`)
|
||||
})
|
||||
}
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!stopping) {
|
||||
console.error(`dev: ${name} stopped (${signal ?? code}); stopping the rest`)
|
||||
void stop(1)
|
||||
}
|
||||
})
|
||||
children.push(child)
|
||||
}
|
||||
|
||||
let stopping = false
|
||||
let platform
|
||||
async function stop(code = 0) {
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
for (const child of children) {
|
||||
try {
|
||||
if (child.pid !== undefined) process.kill(-child.pid, "SIGTERM")
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
await platform?.close().catch(() => undefined)
|
||||
process.exit(code)
|
||||
}
|
||||
process.on("SIGINT", () => void stop(0))
|
||||
process.on("SIGTERM", () => void stop(0))
|
||||
|
||||
await startPostgres()
|
||||
|
||||
platform = await startFakePlatform(ports.platform)
|
||||
// Nothing scripts the gateway in a development run, so a structured request is answered with the
|
||||
// smallest value its schema accepts and what is built on a model runs to its end.
|
||||
platform.ai.respond(validReply)
|
||||
say(`the fake platform is at ${platform.url}`)
|
||||
|
||||
run("building the libraries and the worker", "pnpm", ["run", "build:libs"])
|
||||
run("building the worker", "pnpm", ["--filter", "@app/worker", "run", "build"])
|
||||
run("migrating the database", "node", ["apps/worker/dist/migrate.js"], {
|
||||
env: { ...withoutOwner, POSTGRES_MIGRATE_URL: ownerUrl },
|
||||
})
|
||||
run("writing the demo data", "node", ["apps/worker/dist/seed-main.js"])
|
||||
|
||||
start("worker", "node", ["apps/worker/dist/main.js"], root)
|
||||
start(
|
||||
"web",
|
||||
process.execPath,
|
||||
["node_modules/next/dist/bin/next", "dev", "-p", String(ports.web), "-H", "127.0.0.1"],
|
||||
join(root, "apps", "web"),
|
||||
)
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
if ((await fetch(`${environment.CONUSAI_PUBLIC_URL}/api/ready`)).ok) break
|
||||
} catch {
|
||||
// not yet
|
||||
}
|
||||
if (attempt > 240) {
|
||||
console.error("dev: the web shell was not ready in two minutes")
|
||||
await stop(1)
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
say("ready. Sign in as one of the demo people:")
|
||||
for (const [name, email] of [
|
||||
["Ada, owner of Acme Review and admin of Northwind", "ada@example.test"],
|
||||
["Grace, owner of Northwind", "grace@example.test"],
|
||||
["Linus, a viewer in Acme Review", "linus@example.test"],
|
||||
]) {
|
||||
say(` ${environment.CONUSAI_PUBLIC_URL}/auth/dev-login?email=${email} ${name}`)
|
||||
}
|
||||
say("Ctrl+C stops it.")
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The types of scripts/licence.mjs, for the tests that read its rules.
|
||||
|
||||
export const SPDX_ID: string
|
||||
export const SPDX_LINE: string
|
||||
export const LIST_FILE: string
|
||||
export function headerFor(path: string): string | null
|
||||
export function templateFiles(root: string): string[]
|
||||
export function listText(paths: readonly string[]): string
|
||||
export function parseList(text: string): string[]
|
||||
export function hasHeader(text: string, path: string): boolean
|
||||
export function withHeader(text: string, path: string): string
|
||||
export function problems(root: string): string[]
|
||||
export function write(root: string): string[]
|
||||
@@ -0,0 +1,207 @@
|
||||
// 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 `<!-- ${SPDX_LINE} -->`
|
||||
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/<name>/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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The types of scripts/sbom.mjs, for the tests that read its output.
|
||||
|
||||
export interface Component {
|
||||
type: string
|
||||
"bom-ref": string
|
||||
group?: string
|
||||
name: string
|
||||
version: string
|
||||
purl: string
|
||||
licenses: Array<{
|
||||
expression?: string
|
||||
license?: { id?: string; name?: string; text?: { contentType: string; content: string } }
|
||||
}>
|
||||
properties?: Array<{ name: string; value: string }>
|
||||
externalReferences?: Array<{ type: string; url: string }>
|
||||
}
|
||||
|
||||
export interface Bom {
|
||||
bomFormat: string
|
||||
specVersion: string
|
||||
version: number
|
||||
metadata?: { component?: { type: string; name: string; version: string } }
|
||||
components: Component[]
|
||||
}
|
||||
|
||||
export function npmComponents(root: string): Component[]
|
||||
export function dpkgComponents(statusFile: string, docRoot?: string): Component[]
|
||||
export function sbom(options: {
|
||||
root: string
|
||||
dpkg?: string
|
||||
name?: string
|
||||
version?: string
|
||||
nodeVersion?: string
|
||||
}): Bom
|
||||
@@ -0,0 +1,199 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
//
|
||||
// The software bill of materials of a built image, as CycloneDX 1.5 JSON (plan item AT5.1). It reads
|
||||
// what is installed, not what was asked for: every npm package in the image's pnpm store after
|
||||
// `pnpm prune --prod` (so a runtime dependency and nothing else), the Node.js runtime, and the
|
||||
// operating system packages of the base image. THIRD-PARTY-NOTICES is generated from its output
|
||||
// (scripts/third-party-notices.mjs).
|
||||
//
|
||||
// docker run --rm --entrypoint node <image> scripts/sbom.mjs > sbom.cdx.json
|
||||
// node scripts/sbom.mjs --root <dir> [--dpkg <status file>] [--name <app>] [--version <v>]
|
||||
//
|
||||
// No dependency: the script runs in the image, where only the runtime dependencies are installed.
|
||||
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
/** The most licence text kept per package. A licence longer than this is a file, not a notice. */
|
||||
const MAX_LICENCE_BYTES = 64 * 1024
|
||||
const LICENCE_FILE = /^(licen[cs]e|copying|unlicen[cs]e)(\.|-|$)/i
|
||||
/** The ConusAI packages: their licence is LICENSE-TEMPLATE, whatever their package.json says. */
|
||||
const CONUSAI_SCOPES = new Set(["@conusai-kit", "@conusai-sdk"])
|
||||
|
||||
const isRealDirectory = (path) => {
|
||||
const stat = lstatSync(path, { throwIfNoEntry: false })
|
||||
return stat?.isDirectory() === true && !stat.isSymbolicLink()
|
||||
}
|
||||
|
||||
/** The `[name, dir]` of each package a pnpm virtual-store entry holds: real directories, not the links to its dependencies. */
|
||||
function packagesInStoreEntry(entryNodeModules) {
|
||||
const found = []
|
||||
for (const name of readdirSync(entryNodeModules).sort()) {
|
||||
const dir = join(entryNodeModules, name)
|
||||
if (name.startsWith("@")) {
|
||||
if (!isRealDirectory(dir)) continue
|
||||
for (const inner of readdirSync(dir).sort()) {
|
||||
if (isRealDirectory(join(dir, inner))) found.push(join(dir, inner))
|
||||
}
|
||||
} else if (isRealDirectory(dir)) {
|
||||
found.push(dir)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** The licence a manifest declares, as a string, or null. */
|
||||
function declaredLicence(manifest) {
|
||||
const { license, licenses } = manifest
|
||||
if (typeof license === "string" && license !== "") return license
|
||||
if (license && typeof license === "object" && typeof license.type === "string") return license.type
|
||||
if (Array.isArray(licenses)) {
|
||||
const types = licenses.map((entry) => (typeof entry === "string" ? entry : entry?.type)).filter(Boolean)
|
||||
if (types.length > 0) return types.length === 1 ? types[0] : `(${types.join(" OR ")})`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function licenceText(dir) {
|
||||
const file = readdirSync(dir)
|
||||
.sort()
|
||||
.find((name) => LICENCE_FILE.test(name) && lstatSync(join(dir, name)).isFile())
|
||||
if (!file) return null
|
||||
const bytes = readFileSync(join(dir, file))
|
||||
if (bytes.length > MAX_LICENCE_BYTES) return null
|
||||
return { file, text: bytes.toString("utf8").replace(/\r\n/g, "\n").trim() }
|
||||
}
|
||||
|
||||
const repositoryUrl = (manifest) => {
|
||||
const repository = manifest.repository
|
||||
const url = typeof repository === "string" ? repository : repository?.url
|
||||
return typeof url === "string" ? url.replace(/^git\+/, "").replace(/\.git$/, "") : null
|
||||
}
|
||||
|
||||
const purlOf = (name, version) => `pkg:npm/${name.startsWith("@") ? `%40${name.slice(1)}` : name}@${version}`
|
||||
|
||||
/** One CycloneDX component for an installed npm package. */
|
||||
function npmComponent(dir) {
|
||||
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
|
||||
const { name, version } = manifest
|
||||
if (typeof name !== "string" || typeof version !== "string") return null
|
||||
const scope = name.startsWith("@") ? name.slice(0, name.indexOf("/")) : undefined
|
||||
const declared = declaredLicence(manifest)
|
||||
const evidence = licenceText(dir)
|
||||
const conusai = scope !== undefined && CONUSAI_SCOPES.has(scope)
|
||||
const component = {
|
||||
type: "library",
|
||||
"bom-ref": purlOf(name, version),
|
||||
...(scope === undefined ? {} : { group: scope }),
|
||||
name: scope === undefined ? name : name.slice(scope.length + 1),
|
||||
version,
|
||||
purl: purlOf(name, version),
|
||||
licenses:
|
||||
declared === null
|
||||
? []
|
||||
: /[\s()]/.test(declared)
|
||||
? [{ expression: declared }]
|
||||
: [
|
||||
{
|
||||
license: {
|
||||
...(declared.startsWith("LicenseRef-") ? { name: declared } : { id: declared }),
|
||||
...(evidence ? { text: { contentType: "text/plain", content: evidence.text } } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
...(conusai ? [{ name: "conusai:component", value: "true" }] : []),
|
||||
...(evidence ? [{ name: "licence-file", value: evidence.file }] : []),
|
||||
],
|
||||
}
|
||||
const url = repositoryUrl(manifest)
|
||||
if (url) component.externalReferences = [{ type: "vcs", url }]
|
||||
return component
|
||||
}
|
||||
|
||||
/** Every npm package installed under `root`'s pnpm store, once per name@version, sorted. */
|
||||
export function npmComponents(root) {
|
||||
const store = join(root, "node_modules", ".pnpm")
|
||||
const unique = new Map()
|
||||
if (!existsSync(store)) return []
|
||||
for (const entry of readdirSync(store).sort()) {
|
||||
const nodeModules = join(store, entry, "node_modules")
|
||||
if (!existsSync(nodeModules)) continue
|
||||
for (const dir of packagesInStoreEntry(nodeModules)) {
|
||||
if (!existsSync(join(dir, "package.json"))) continue
|
||||
const component = npmComponent(dir)
|
||||
if (component && !unique.has(component["bom-ref"])) unique.set(component["bom-ref"], component)
|
||||
}
|
||||
}
|
||||
return [...unique.values()].sort((a, b) => a["bom-ref"].localeCompare(b["bom-ref"]))
|
||||
}
|
||||
|
||||
/** The installed packages of a dpkg status file, as CycloneDX components. */
|
||||
export function dpkgComponents(statusFile, docRoot = "/usr/share/doc") {
|
||||
if (!existsSync(statusFile)) return []
|
||||
const found = []
|
||||
for (const stanza of readFileSync(statusFile, "utf8").split("\n\n")) {
|
||||
const field = (name) => new RegExp(`^${name}: (.*)$`, "m").exec(stanza)?.[1]
|
||||
const name = field("Package")
|
||||
const version = field("Version")
|
||||
if (!name || !version || !/install ok installed/.test(field("Status") ?? "")) continue
|
||||
const copyright = join(docRoot, name, "copyright")
|
||||
found.push({
|
||||
type: "library",
|
||||
"bom-ref": `pkg:deb/debian/${name}@${version}`,
|
||||
name,
|
||||
version,
|
||||
purl: `pkg:deb/debian/${name}@${version}`,
|
||||
licenses: [],
|
||||
properties: [
|
||||
{ name: "conusai:layer", value: "os" },
|
||||
...(existsSync(copyright) ? [{ name: "licence-file", value: copyright }] : []),
|
||||
],
|
||||
})
|
||||
}
|
||||
return found.sort((a, b) => a["bom-ref"].localeCompare(b["bom-ref"]))
|
||||
}
|
||||
|
||||
/** The CycloneDX document for the image whose application sits at `root`. */
|
||||
export function sbom({
|
||||
root,
|
||||
dpkg = "/var/lib/dpkg/status",
|
||||
name = "app",
|
||||
version = "0.0.0",
|
||||
nodeVersion = process.version,
|
||||
}) {
|
||||
const runtime = {
|
||||
type: "platform",
|
||||
"bom-ref": `pkg:generic/nodejs@${nodeVersion.replace(/^v/, "")}`,
|
||||
name: "node",
|
||||
version: nodeVersion.replace(/^v/, ""),
|
||||
purl: `pkg:generic/nodejs@${nodeVersion.replace(/^v/, "")}`,
|
||||
licenses: [{ license: { id: "MIT" } }],
|
||||
properties: [{ name: "licence-file", value: "/usr/local/LICENSE" }],
|
||||
}
|
||||
return {
|
||||
bomFormat: "CycloneDX",
|
||||
specVersion: "1.5",
|
||||
version: 1,
|
||||
metadata: { component: { type: "application", name, version } },
|
||||
components: [runtime, ...npmComponents(root), ...dpkgComponents(dpkg)],
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
const args = process.argv.slice(2)
|
||||
const option = (flag, fallback) => {
|
||||
const at = args.indexOf(flag)
|
||||
return at === -1 ? fallback : args[at + 1]
|
||||
}
|
||||
const root = option("--root", process.cwd())
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))
|
||||
const document = sbom({
|
||||
root,
|
||||
dpkg: option("--dpkg", "/var/lib/dpkg/status"),
|
||||
name: option("--name", manifest.name ?? "app"),
|
||||
version: option("--version", manifest.version ?? "0.0.0"),
|
||||
})
|
||||
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The types of scripts/third-party-notices.mjs, for the tests that read its output.
|
||||
|
||||
import type { Bom } from "./sbom.mjs"
|
||||
|
||||
export function notices(document: Bom): string
|
||||
export function unlisted(document: Bom, text: string, options?: { byName?: boolean }): string[]
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
//
|
||||
// THIRD-PARTY-NOTICES, generated from the software bill of materials of the built image (plan item
|
||||
// AT5.1; scripts/sbom.mjs writes it). It lists every component the image runs, with its licence and,
|
||||
// for an npm package, the text of the licence file the package ships, grouped so that one text that
|
||||
// 200 packages share is printed once.
|
||||
//
|
||||
// node scripts/third-party-notices.mjs <sbom.cdx.json> [--out THIRD-PARTY-NOTICES]
|
||||
// node scripts/third-party-notices.mjs <sbom.cdx.json> --check [--out THIRD-PARTY-NOTICES]
|
||||
//
|
||||
// `--check` fails when the file is not what the SBOM generates; `--complete` fails when a component of the
|
||||
// SBOM is not listed by name, which is what "lists every runtime dependency of the built image" is
|
||||
// checked against the bundle-specific lock and package versions used to build the image.
|
||||
// The output is a pure function of the SBOM: no date, no host name, no order that depends on a walk.
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const property = (component, name) => component.properties?.find((entry) => entry.name === name)?.value
|
||||
|
||||
const nameOf = (component) => (component.group ? `${component.group}/${component.name}` : component.name)
|
||||
const labelOf = (component) => `${nameOf(component)}@${component.version}`
|
||||
|
||||
/** The licence a component declares, as one string. */
|
||||
function licenceOf(component) {
|
||||
const entry = component.licenses?.[0]
|
||||
if (!entry) return "NOT DECLARED"
|
||||
if (entry.expression) return entry.expression
|
||||
return entry.license?.id ?? entry.license?.name ?? "NOT DECLARED"
|
||||
}
|
||||
|
||||
const textOf = (component) => component.licenses?.[0]?.license?.text?.content ?? null
|
||||
|
||||
const sha = (text) => createHash("sha256").update(text).digest("hex")
|
||||
|
||||
const rule = "=".repeat(78)
|
||||
|
||||
/** The notices text for an SBOM document. */
|
||||
export function notices(document) {
|
||||
const components = document.components ?? []
|
||||
const app = document.metadata?.component
|
||||
const conusai = components.filter((component) => property(component, "conusai:component") === "true")
|
||||
const os = components.filter((component) => property(component, "conusai:layer") === "os")
|
||||
const runtime = components.filter((component) => component.type === "platform")
|
||||
const npm = components.filter(
|
||||
(component) => !conusai.includes(component) && !os.includes(component) && !runtime.includes(component),
|
||||
)
|
||||
|
||||
const lines = [
|
||||
"THIRD-PARTY NOTICES",
|
||||
"",
|
||||
`This file lists the software in the image ${app ? `${app.name} ${app.version} ` : ""}is built into, and the licence of each part.`,
|
||||
"It is generated from the software bill of materials of the built image (CycloneDX, scripts/sbom.mjs) by",
|
||||
"scripts/third-party-notices.mjs; do not edit it by hand. Regenerate it when the dependencies change:",
|
||||
"",
|
||||
" docker run --rm --entrypoint node <image> scripts/sbom.mjs > sbom.cdx.json",
|
||||
" node scripts/third-party-notices.mjs sbom.cdx.json",
|
||||
"",
|
||||
`Components: ${components.length} (${conusai.length} ConusAI, ${npm.length} npm, ${runtime.length} runtime, ${os.length} operating system).`,
|
||||
"",
|
||||
rule,
|
||||
`1. ConusAI components (${conusai.length})`,
|
||||
rule,
|
||||
"",
|
||||
"Licensed under the ConusAI Template Licence 1.0 (LICENSE-TEMPLATE, still a placeholder) for the",
|
||||
"@conusai-kit packages, and under the Apache License 2.0 for the @conusai-sdk packages.",
|
||||
"",
|
||||
...conusai.map((component) => ` ${labelOf(component)} (${licenceOf(component)})`),
|
||||
"",
|
||||
rule,
|
||||
`2. Runtime (${runtime.length})`,
|
||||
rule,
|
||||
"",
|
||||
...runtime.map(
|
||||
(component) =>
|
||||
` ${labelOf(component)} (${licenceOf(component)}); the licence text is /usr/local/LICENSE in the image`,
|
||||
),
|
||||
"",
|
||||
rule,
|
||||
`3. npm packages (${npm.length})`,
|
||||
rule,
|
||||
"",
|
||||
]
|
||||
|
||||
// One group per licence text. A package that ships no licence file is grouped by what it declares.
|
||||
const groups = new Map()
|
||||
for (const component of npm) {
|
||||
const text = textOf(component)
|
||||
const declared = licenceOf(component)
|
||||
const key = text === null ? `declared:${declared}` : `text:${sha(text)}`
|
||||
if (!groups.has(key)) groups.set(key, { declared, text, members: [] })
|
||||
groups.get(key).members.push(component)
|
||||
}
|
||||
const ordered = [...groups.values()].sort(
|
||||
(a, b) => a.declared.localeCompare(b.declared) || labelOf(a.members[0]).localeCompare(labelOf(b.members[0])),
|
||||
)
|
||||
for (const group of ordered) {
|
||||
lines.push(`Licence: ${group.declared}`, "Packages:")
|
||||
for (const component of group.members) lines.push(` ${labelOf(component)}`)
|
||||
lines.push("")
|
||||
if (group.text === null) {
|
||||
lines.push(` (these packages ship no licence file; they declare ${group.declared} in their package.json)`)
|
||||
} else {
|
||||
// Tabs are layout only in licence files. Expand them before adding our two-space prefix so
|
||||
// generated notices stay readable and pass whitespace checks without changing licence text.
|
||||
lines.push(...group.text.split("\n").map((line) => (line === "" ? "" : ` ${line.replaceAll("\t", " ")}`)))
|
||||
}
|
||||
lines.push("", "-".repeat(78), "")
|
||||
}
|
||||
|
||||
lines.push(
|
||||
rule,
|
||||
`4. Operating system packages of the base image (${os.length})`,
|
||||
rule,
|
||||
"",
|
||||
"Each package's licence is in its copyright file in the image, /usr/share/doc/<package>/copyright.",
|
||||
"",
|
||||
...os.map((component) => ` ${component.name} ${component.version}`),
|
||||
"",
|
||||
)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* The components of an SBOM that a notices text does not list, by label. By name alone when `byName`
|
||||
* is set: a version that moved since the file was written is drift to refresh, not a component missing.
|
||||
*/
|
||||
export function unlisted(document, text, { byName = false } = {}) {
|
||||
return (document.components ?? [])
|
||||
.filter((component) => {
|
||||
if (property(component, "conusai:layer") === "os") {
|
||||
return !text.includes(byName ? ` ${component.name} ` : ` ${component.name} ${component.version}\n`)
|
||||
}
|
||||
return !text.includes(byName ? ` ${nameOf(component)}@` : ` ${labelOf(component)}`)
|
||||
})
|
||||
.map(labelOf)
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
const args = process.argv.slice(2)
|
||||
const flags = new Set(args.filter((arg) => arg.startsWith("--") && arg !== "--out"))
|
||||
const outAt = args.indexOf("--out")
|
||||
const out = outAt === -1 ? "THIRD-PARTY-NOTICES" : args[outAt + 1]
|
||||
const input = args.find((arg, at) => !arg.startsWith("--") && (outAt === -1 || at !== outAt + 1))
|
||||
if (!input) {
|
||||
console.error("usage: node scripts/third-party-notices.mjs <sbom.cdx.json> [--check | --complete] [--out FILE]")
|
||||
process.exit(2)
|
||||
}
|
||||
const document = JSON.parse(readFileSync(input, "utf8"))
|
||||
const text = notices(document)
|
||||
if (flags.has("--check") || flags.has("--complete")) {
|
||||
const present = existsSync(out) ? readFileSync(out, "utf8") : ""
|
||||
const missing = unlisted(document, present, { byName: flags.has("--complete") })
|
||||
for (const label of missing) console.error(`third-party-notices: ${out} does not list ${label}`)
|
||||
if (flags.has("--check") && present !== text && missing.length === 0) {
|
||||
console.error(`third-party-notices: ${out} is not what the SBOM generates`)
|
||||
}
|
||||
process.exit(missing.length === 0 && (flags.has("--complete") || present === text) ? 0 : 1)
|
||||
}
|
||||
writeFileSync(out, text)
|
||||
console.log(`third-party-notices: wrote ${out} (${document.components?.length ?? 0} components)`)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Read-only browser smoke for a built image or a live deployment. No account is needed.
|
||||
// pnpm exec playwright install chromium
|
||||
// node scripts/verify-shell.mjs https://your-app.example.test
|
||||
import { strict as assert } from "node:assert"
|
||||
import { chromium, expect } from "@playwright/test"
|
||||
|
||||
const base = new URL(process.argv[2] ?? "http://127.0.0.1:3000")
|
||||
assert(["http:", "https:"].includes(base.protocol), "expected an HTTP(S) app URL")
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const context = await browser.newContext({ locale: "en-US" })
|
||||
const page = await context.newPage()
|
||||
page.setDefaultTimeout(15_000)
|
||||
const errors = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
const response = await page.goto(new URL("/", base).href, { waitUntil: "networkidle" })
|
||||
assert.equal(response?.status(), 200)
|
||||
const root = page.locator("html")
|
||||
await page.getByRole("button", { name: "Theme: Dark", exact: true }).click()
|
||||
await expect(root).toHaveClass("light")
|
||||
await page.reload()
|
||||
await expect(root).toHaveClass("light")
|
||||
await page.getByRole("combobox", { name: /^Language/ }).selectOption("lt")
|
||||
await expect(root).toHaveAttribute("lang", "lt")
|
||||
await expect(page.getByRole("button", { name: "Tema: Šviesi", exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(root).toHaveAttribute("lang", "lt")
|
||||
await expect(root).toHaveClass("light")
|
||||
await page.getByRole("button", { name: "Tema: Šviesi", exact: true }).click()
|
||||
await expect(root).not.toHaveClass("light")
|
||||
await page.getByRole("combobox", { name: /^Kalba/ }).selectOption("en")
|
||||
await expect(root).toHaveAttribute("lang", "en")
|
||||
await expect(page.getByRole("button", { name: "Theme: Dark", exact: true })).toBeVisible()
|
||||
assert.deepEqual(errors, [], "browser runtime errors")
|
||||
console.log("verify-shell: theme and language switch both ways and persist across reloads")
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
Reference in New Issue
Block a user