diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 57b3bfa1..18fcc6cc 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -146,6 +146,8 @@ jobs: run: npm run lint:knip - name: Oxlint run: npm run lint:ox + - name: Documented npm commands exist + run: npm run lint:docs core-postgres: name: Core Postgres tests diff --git a/fly/README.md b/fly/README.md index 10a4b645..bf311cae 100644 --- a/fly/README.md +++ b/fly/README.md @@ -177,27 +177,6 @@ For resident X tooling use the same prefix for native tool env, for example ## Smoke test -```bash -FLY_API_TOKEN="$(fly tokens create deploy -a "$FLY_SANDBOX_APP_NAME")" npm run smoke:fly -``` - -For image-resident X helper readiness: - -```bash -FLY_API_TOKEN=... npm run smoke:x -``` - -This verifies `x-api` is on PATH and reports `missing_auth=auth_missing` when no -resident X token is installed. Add `X_SMOKE_REQUIRE_AUTH=1` after configuring -`X_BEARER_TOKEN` / `X_ACCESS_TOKEN`, or `X_SMOKE_REQUIRE_FIREHOSE=1` when a vendored -`x-firehose` binary should be present. - -The smoke test uses a timestamped personal smoke-test scope, writes -workspace and resident-home state, backs it up, deletes the Fly machine, recreates it, -restores the backup, verifies `.aws/*` stayed excluded, then deletes the smoke -machine. Add `SNAPSHOT_STORE=s3 S3_BUCKET=...` to exercise the real S3 object store; -without those vars it uses the same backup-store code over an in-memory blob store. - For GitHub/GitLab resident CLI readiness: ```bash diff --git a/package.json b/package.json index 1cdc313d..2dd75e06 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "lint": "eslint .", "lint:ox": "oxlint --deny-warnings src plugins scripts cli test", "dev-instance:doctor": "bash scripts/dev-instance.sh doctor", - "lint:knip": "knip" + "lint:knip": "knip", + "lint:docs": "node scripts/check-doc-npm-scripts.mjs" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.211", diff --git a/scripts/check-doc-npm-scripts.mjs b/scripts/check-doc-npm-scripts.mjs new file mode 100644 index 00000000..040b1bea --- /dev/null +++ b/scripts/check-doc-npm-scripts.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; + +const REPO_ROOT = new URL("../", import.meta.url); +const DOC_COMMAND = /npm run ([A-Za-z][\w:.-]*)/g; + +function trackedFiles(pattern) { + const out = execFileSync("git", ["ls-files", pattern], { cwd: REPO_ROOT, encoding: "utf8" }); + return out.split("\n").filter(Boolean); +} + +function definedScripts() { + const byPackage = new Map(); + for (const file of trackedFiles("*package.json")) { + let manifest; + try { + manifest = JSON.parse(readFileSync(new URL(file, REPO_ROOT), "utf8")); + } catch (error) { + throw new Error(`${file} is not valid JSON`, { cause: error }); + } + byPackage.set(dirname(file), Object.keys(manifest.scripts ?? {})); + } + if (byPackage.size === 0) throw new Error("No package.json files are tracked — cannot resolve documented commands"); + return byPackage; +} + +function documentedCommands() { + const references = []; + for (const file of trackedFiles("*.md")) { + const lines = readFileSync(new URL(file, REPO_ROOT), "utf8").split("\n"); + for (const [index, line] of lines.entries()) { + for (const match of line.matchAll(DOC_COMMAND)) { + references.push({ script: match[1], where: `${file}:${index + 1}` }); + } + } + } + return references; +} + +function main() { + const { values: flags } = parseArgs({ options: { list: { type: "boolean" } } }); + const byPackage = definedScripts(); + const known = new Set([...byPackage.values()].flat()); + const references = documentedCommands(); + + if (flags.list) { + for (const { script, where } of references) { + console.log(`${known.has(script) ? "ok " : "unknown"} npm run ${script} ${where}`); + } + return; + } + + const unknown = references.filter(({ script }) => !known.has(script)); + if (unknown.length > 0) { + console.error("Documented npm commands that no package.json defines:\n"); + for (const { script, where } of unknown) console.error(` ${where}: npm run ${script}`); + console.error("\nEither add the script, correct the docs, or drop the command from the docs."); + throw new Error(`${unknown.length} documented npm command(s) do not exist`); + } + + const scripts = new Set(references.map((r) => r.script)); + console.log( + `Checked ${references.length} documented npm command(s) (${scripts.size} distinct) across ${byPackage.size} packages — all defined.`, + ); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}