nerdsystem 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { doctor } from "./commands/doctor.js";
8
8
  import { cron, health, rollback, secretList, secretRemove, secretSet, transfer, waitlist } from "./commands/ops.js";
9
9
  import { adminHelp, adminSuspend, adminUnsuspend } from "./commands/admin.js";
10
10
  import { agentsSync } from "./commands/agents.js";
11
+ import { upgrade } from "./commands/upgrade.js";
11
12
  import { billingPortal, billingShow, billingUpgrade } from "./commands/billing.js";
12
13
  import { auditExport, clientsCreate, clientsList, clientsRelease, domainsAdd, domainsList, domainsRemove, logs, membersInvite, membersList, membersRemove } from "./commands/org.js";
13
14
  import { buildsList, buildsShow, buildsTrigger } from "./commands/builds.js";
@@ -57,6 +58,7 @@ const HELP = `nerd: start, check, and deploy a web app on nerdsystem
57
58
  nerd builds show <id> Build details and log
58
59
  nerd builds trigger [--branch b] [--commit sha]
59
60
  nerd agents sync Regenerate AGENTS.md, Cursor, Copilot, Gemini files from the skill
61
+ nerd upgrade Bring this app up to the current SDK, agent instructions, and test harness
60
62
  nerd billing [--org slug] Plan, limits, and usage for the org
61
63
  nerd billing upgrade --plan pro|studio [--annual] Start checkout
62
64
  nerd billing portal Manage cards, invoices, and plan
@@ -133,6 +135,10 @@ export async function main(argv) {
133
135
  const { values } = parse({ "skip-build": BOOL, "skip-probe": BOOL, force: BOOL, note: STR });
134
136
  return deploy(values);
135
137
  }
138
+ case "upgrade": {
139
+ const { values } = parse({}, false);
140
+ return upgrade({ json: Boolean(values.json) });
141
+ }
136
142
  case "save": {
137
143
  const { values } = parse({}, false);
138
144
  return save({ json: Boolean(values.json) });
@@ -8,6 +8,7 @@ import { CliError, log, printJson, runOrThrow, table, dashboardUrl, ensureDeps }
8
8
  import { runChecks } from "./check.js";
9
9
  import { appendLineage, cliVersion, LOCAL_ONLY_NOTE, packProject, readLineage, uploadSnapshot, uploadStopMessage } from "./source.js";
10
10
  import { lockedGetPaths } from "../gates/tests.js";
11
+ import { platformScan } from "./platform-scan.js";
11
12
  import { BUILD_GATES, gateSummary, knownSecretsFrom, probeDeployment, runGates, skippedProbe, SOURCE_GATES, waitForDeployment } from "../gates/index.js";
12
13
  const MODULE_EXT = new Set([".js", ".mjs", ".wasm"]);
13
14
  function walk(dir, base = dir) {
@@ -155,6 +156,8 @@ export async function shipBuild(args) {
155
156
  appendLineage(root, source.id);
156
157
  // "Stored", not "saved": the one line that says "saved" is the final one, which also says the work after it is only on this computer (invariant 7).
157
158
  log.ok(`Stored ${pack.fileCount} files (${formatMb(pack.bytes)}) as ${source.id}`);
159
+ // The scanners this computer lacks run on the platform against the copy just stored, before migrations or upload.
160
+ args.results = await platformScan(api, `${projectPath(nerd)}/sources/${source.id}`, args.results);
158
161
  }
159
162
  log.step("Applying migrations to the production database");
160
163
  const mig = await applyRemoteMigrations(root, api);
@@ -1,4 +1,5 @@
1
1
  import { type Project } from "../api.js";
2
+ export declare function templateDir(): string;
2
3
  /** Creates the remote project and writes nerd.json. Shared by init and link. */
3
4
  export declare function linkProject(root: string, args: {
4
5
  org?: string;
@@ -6,7 +6,7 @@ import { ApiClient } from "../api.js";
6
6
  import { projectPath, writeNerdJson } from "../project.js";
7
7
  import { CliError, log, printJson } from "../util.js";
8
8
  const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/;
9
- function templateDir() {
9
+ export function templateDir() {
10
10
  const here = path.dirname(fileURLToPath(import.meta.url));
11
11
  const candidates = [
12
12
  process.env.NERD_TEMPLATE_DIR,
@@ -192,4 +192,4 @@ export async function init(dir, args) {
192
192
  log.ok(`Ready. Next:\n cd ${dir}\n pnpm install\n nerd dev\n\nThen open the folder in your coding agent and ask it to build the first feature; the nerdsystem skill walks it through.\nDeploys go to ${project.url}`);
193
193
  }
194
194
  // Kept in sync with packages/sdk/package.json by the release script.
195
- const SDK_VERSION = "0.0.18";
195
+ const SDK_VERSION = "0.0.20";
@@ -0,0 +1,18 @@
1
+ import type { ApiClient } from "../api.js";
2
+ import type { GateResult } from "../gates/index.js";
3
+ export interface PlatformScanOptions {
4
+ /** How long the deploy waits for the answer. */
5
+ timeoutMs?: number;
6
+ pollMs?: number;
7
+ sleep?: (ms: number) => Promise<void>;
8
+ }
9
+ /**
10
+ * When gitleaks or semgrep did not run here because they are not installed, has the platform run them
11
+ * on the copy of the code that was just stored, and waits for the answer before anything goes live.
12
+ * Returns the gate results with the platform's answers in place of the skipped ones.
13
+ *
14
+ * A blocking finding stops the deploy, exactly as it would have locally. Anything else that goes wrong
15
+ * (the scanners are busy or down, the hourly allowance is used, the answer is late) does not: the two
16
+ * results stay "skip" with the reason, because a platform hiccup must not be what keeps an app offline.
17
+ */
18
+ export declare function platformScan(api: ApiClient, sourcePath: string, results: GateResult[], opts?: PlatformScanOptions): Promise<GateResult[]>;
@@ -0,0 +1,58 @@
1
+ import { CliError, log } from "../util.js";
2
+ /** The two scanners most computers do not have. The platform's builder has both. */
3
+ const SCANNERS = ["gitleaks", "semgrep"];
4
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
5
+ /**
6
+ * When gitleaks or semgrep did not run here because they are not installed, has the platform run them
7
+ * on the copy of the code that was just stored, and waits for the answer before anything goes live.
8
+ * Returns the gate results with the platform's answers in place of the skipped ones.
9
+ *
10
+ * A blocking finding stops the deploy, exactly as it would have locally. Anything else that goes wrong
11
+ * (the scanners are busy or down, the hourly allowance is used, the answer is late) does not: the two
12
+ * results stay "skip" with the reason, because a platform hiccup must not be what keeps an app offline.
13
+ */
14
+ export async function platformScan(api, sourcePath, results, opts = {}) {
15
+ const missing = results.filter((r) => SCANNERS.includes(r.name) && r.status === "skip");
16
+ if (!missing.length)
17
+ return results;
18
+ const sleep = opts.sleep ?? defaultSleep;
19
+ const pollMs = opts.pollMs ?? 2500;
20
+ const deadline = Date.now() + (opts.timeoutMs ?? 150_000);
21
+ const skipped = (reason) => results.map((r) => (missing.includes(r) ? { ...r, summary: `${r.name} is not installed here, and the platform's scan ${reason}` } : r));
22
+ log.step(`Scanning the stored code on the platform (${missing.map((m) => m.name).join(" and ")} are not installed here)`);
23
+ let scan;
24
+ try {
25
+ scan = (await api.post(`${sourcePath}/scan`)).scan;
26
+ while ((scan.status === "queued" || scan.status === "running") && Date.now() < deadline) {
27
+ await sleep(pollMs);
28
+ const next = (await api.get(`${sourcePath}/scan`)).scan;
29
+ if (next)
30
+ scan = next;
31
+ }
32
+ }
33
+ catch (err) {
34
+ const why = err instanceof CliError && err.status === 429 ? "was not run: this organization has used its scans for the hour" : "could not be started";
35
+ log.warn(`The platform's scan ${why}. Continuing with the checks that ran here.`);
36
+ return skipped(why);
37
+ }
38
+ if (scan.status !== "done" || !scan.results) {
39
+ const why = scan.status === "failed" ? "failed" : "did not answer in time";
40
+ log.warn(`The platform's scan ${why}. Continuing with the checks that ran here.`);
41
+ return skipped(why);
42
+ }
43
+ const answered = new Map(scan.results.filter((r) => SCANNERS.includes(r.name)).map((r) => [r.name, r]));
44
+ const merged = results.map((r) => (missing.includes(r) && answered.has(r.name) ? { ...answered.get(r.name), summary: `${answered.get(r.name).summary} (scanned on the platform)` } : r));
45
+ const ran = merged.filter((r) => missing.some((m) => m.name === r.name));
46
+ for (const r of ran) {
47
+ log.info(` ${r.status === "pass" ? "✓" : r.status === "fail" ? "✗" : r.status === "warn" ? "!" : "–"} ${r.title}: ${r.summary}`);
48
+ for (const f of r.findings.slice(0, 20)) {
49
+ log.info(` ${f.severity === "block" ? "✗" : "!"} ${f.rule_id}${f.file ? ` ${f.file}${f.line ? `:${f.line}` : ""}` : ""}${f.masked ? ` (${f.masked})` : ""}`);
50
+ log.info(` ${f.risk}`);
51
+ log.info(` Fix: ${f.fix}`);
52
+ }
53
+ }
54
+ const blocked = ran.filter((r) => r.status === "fail");
55
+ if (blocked.length)
56
+ throw new CliError(`Blocked by: ${blocked.map((g) => g.title).join(", ")}. Nothing went live. Fix the findings above, then deploy again.`, 2, { code: "gate_failed" });
57
+ return merged;
58
+ }
@@ -0,0 +1,16 @@
1
+ export interface UpgradeResult {
2
+ from: string | null;
3
+ to: string;
4
+ changed: string[];
5
+ next: string[];
6
+ }
7
+ /**
8
+ * Brings an existing app up to the current platform: the SDK version, the instructions the agent reads,
9
+ * and the test harness. It replaces only what the platform owns and never touches the app's own code;
10
+ * what the app's code should adopt is then reported by `nerd check`, as findings the agent works through.
11
+ * Nothing is installed, deployed, or sent anywhere.
12
+ */
13
+ export declare function upgradeProject(root: string, tpl: string, version: string): UpgradeResult;
14
+ export declare function upgrade(opts: {
15
+ json: boolean;
16
+ }): Promise<void>;
@@ -0,0 +1,106 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { findProjectRoot } from "../project.js";
4
+ import { log } from "../util.js";
5
+ import { renderAgentFiles } from "./agents.js";
6
+ import { templateDir } from "./init.js";
7
+ import { cliVersion } from "./source.js";
8
+ /** Files that are the platform's, not the app's: replaced with the current version every time. */
9
+ const OURS = [".claude/skills/nerdsystem/SKILL.md", "test/setup.ts", "test/global-setup.ts", "vitest.config.ts"];
10
+ /** Files an app is expected to change: written only when the app has none. */
11
+ const STARTERS = [
12
+ ["test/access.test.ts", "test/access.test.ts"],
13
+ ["pnpm-workspace.app.yaml", "pnpm-workspace.yaml"],
14
+ ];
15
+ /**
16
+ * Brings an existing app up to the current platform: the SDK version, the instructions the agent reads,
17
+ * and the test harness. It replaces only what the platform owns and never touches the app's own code;
18
+ * what the app's code should adopt is then reported by `nerd check`, as findings the agent works through.
19
+ * Nothing is installed, deployed, or sent anywhere.
20
+ */
21
+ export function upgradeProject(root, tpl, version) {
22
+ const changed = [];
23
+ const read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);
24
+ const write = (rel, content) => {
25
+ const p = path.join(root, rel);
26
+ if (read(p) === content)
27
+ return;
28
+ mkdirSync(path.dirname(p), { recursive: true });
29
+ writeFileSync(p, content);
30
+ changed.push(rel);
31
+ };
32
+ const pkgPath = path.join(root, "package.json");
33
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
34
+ const tplPkg = JSON.parse(readFileSync(path.join(tpl, "package.json"), "utf8"));
35
+ const before = JSON.stringify(pkg);
36
+ const current = pkg.dependencies?.["@nerdsystem/sdk"];
37
+ const from = /(\d+\.\d+\.\d+)/.exec(current ?? "")?.[1] ?? null;
38
+ pkg.dependencies ??= {};
39
+ pkg.devDependencies ??= {};
40
+ pkg.scripts ??= {};
41
+ // A workspace or file link is someone developing the platform itself; leave it alone.
42
+ if (!current || !/^(workspace|file|link):/.test(current))
43
+ pkg.dependencies["@nerdsystem/sdk"] = `^${version}`;
44
+ if (!pkg.dependencies.zod && tplPkg.dependencies?.zod)
45
+ pkg.dependencies.zod = tplPkg.dependencies.zod;
46
+ if (!pkg.devDependencies.vitest && tplPkg.devDependencies?.vitest)
47
+ pkg.devDependencies.vitest = tplPkg.devDependencies.vitest;
48
+ if (!pkg.scripts.test)
49
+ pkg.scripts.test = "vitest run";
50
+ if (JSON.stringify(pkg) !== before) {
51
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
52
+ changed.push("package.json");
53
+ }
54
+ for (const rel of OURS) {
55
+ const content = read(path.join(tpl, rel));
56
+ if (content !== null)
57
+ write(rel, content);
58
+ }
59
+ const startersWritten = [];
60
+ for (const [fromRel, toRel] of STARTERS) {
61
+ const content = read(path.join(tpl, fromRel));
62
+ if (content !== null && !existsSync(path.join(root, toRel))) {
63
+ write(toRel, content);
64
+ startersWritten.push(toRel);
65
+ }
66
+ }
67
+ // CLAUDE.md is the platform's text, but people and agents add notes to it. A changed one is kept beside the new one.
68
+ const newClaude = read(path.join(tpl, "CLAUDE.md"));
69
+ const oldClaude = read(path.join(root, "CLAUDE.md"));
70
+ let keptOld = false;
71
+ if (newClaude !== null && oldClaude !== newClaude) {
72
+ if (oldClaude !== null && !existsSync(path.join(root, "CLAUDE.previous.md"))) {
73
+ renameSync(path.join(root, "CLAUDE.md"), path.join(root, "CLAUDE.previous.md"));
74
+ changed.push("CLAUDE.previous.md");
75
+ keptOld = true;
76
+ }
77
+ write("CLAUDE.md", newClaude);
78
+ }
79
+ // The files other agents read are generated from the skill that was just replaced.
80
+ for (const [rel, content] of Object.entries(renderAgentFiles(root).files))
81
+ write(rel, content);
82
+ const ignore = read(path.join(root, ".gitignore")) ?? "";
83
+ if (!/^\.nerd\/?$/m.test(ignore))
84
+ write(".gitignore", `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}.nerd/\n`);
85
+ const next = ["Run `pnpm install`.", "Run `nerd check` and work through what it reports. It names what this app's own code should adopt (for example sign-in for every route, checked input, access tests)."];
86
+ if (startersWritten.includes("test/access.test.ts"))
87
+ next.push("`test/access.test.ts` is the starter's: keep the first test, and rewrite the owner test for this app's own routes. Then set `\"tests\": \"required\"` in nerd.json.");
88
+ if (keptOld)
89
+ next.push("Your earlier `CLAUDE.md` is now `CLAUDE.previous.md`. Move anything the person added into the new `CLAUDE.md`, then delete the old file.");
90
+ next.push("Tell the person in plain words what changed, then run `nerd deploy` when the check passes.");
91
+ return { from, to: version, changed, next };
92
+ }
93
+ export async function upgrade(opts) {
94
+ const root = findProjectRoot();
95
+ const r = upgradeProject(root, templateDir(), cliVersion());
96
+ if (opts.json) {
97
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
98
+ return;
99
+ }
100
+ if (!r.changed.length)
101
+ log.ok(`Already current with nerd ${r.to}. Nothing was changed.`);
102
+ else
103
+ log.ok(`Updated ${r.changed.length} file(s) for nerd ${r.to}${r.from && r.from !== r.to ? ` (the app was on SDK ${r.from})` : ""}: ${r.changed.join(", ")}`);
104
+ log.info("Your app's own code was not touched. Next:");
105
+ r.next.forEach((n, i) => log.info(` ${i + 1}. ${n}`));
106
+ }
@@ -4,6 +4,7 @@ import { gitleaksGate, semgrepGate } from "./tools.js";
4
4
  import { suppressionsGate } from "./suppressions.js";
5
5
  import { staticGate } from "./static.js";
6
6
  import { testsGate } from "./tests.js";
7
+ import { versionsGate } from "./versions.js";
7
8
  import { applyAttempts, MAX_ATTEMPTS } from "./attempts.js";
8
9
  export { gateSummary, skippedGates } from "./types.js";
9
10
  export { RULES, ruleFor } from "./rules.js";
@@ -11,7 +12,7 @@ export { mask } from "./secrets.js";
11
12
  export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
12
13
  export { probeDeployment, skippedProbe, waitForDeployment } from "./smoke.js";
13
14
  /** Gates that run on source, before the build. Suppressions first so a human sees them before anything else. */
14
- export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, staticGate, semgrepGate, policyGate, piiGate, typecheckGate, testsGate];
15
+ export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, versionsGate, staticGate, semgrepGate, policyGate, piiGate, typecheckGate, testsGate];
15
16
  /** Gates that need the built output. */
16
17
  export const BUILD_GATES = [bundleGate, policyGate];
17
18
  function selectGates(gates, onlyRules) {
@@ -36,6 +36,9 @@ export const RULES = {
36
36
  "rules/advisory": { title: "Platform advisory", severity: "warn", audience: "agent", risk: "Not blocking, but the project is missing something the platform expects.", fix: "Follow the instruction in the finding." },
37
37
  "rules/shape": { title: "Not in the nerdsystem shape", severity: "warn", audience: "human", risk: "Every app on nerdsystem is one shape: a Vite client, a Hono server on @nerdsystem/sdk, SQLite on D1, and a bucket. This repository is not in that shape. A clean scan says nothing about whether it would deploy here.", fix: "Start a project with `nerd init` and have the agent port the screens into it. The server and the login are rebuilt on the platform's sign-in and database. The compare pages on nerdsystem.com say what carries over." },
38
38
  "typecheck/error": { title: "TypeScript error", severity: "block", audience: "agent", risk: "The code would not compile reliably; the build may fail or behave unexpectedly.", fix: "Fix the type error at the file and line shown. Run `npx tsc -b` for the full message." },
39
+ "versions/cli-outdated": { title: "The nerd command is behind", severity: "warn", audience: "agent", risk: "An older nerd runs fewer checks and may not know about newer platform features.", fix: "Run `npm install -g nerdsystem@latest`." },
40
+ "versions/sdk-outdated": { title: "The app's SDK is behind", severity: "warn", audience: "agent", risk: "The app is missing fixes and safeguards that newer versions of the platform's library and instructions carry.", fix: "Run `nerd upgrade`, then `pnpm install` and `nerd check`." },
41
+ "versions/sdk-unsupported": { title: "The app's SDK is too old for the platform", severity: "block", audience: "agent", risk: "This version of the platform's library lacks something the platform now depends on to keep apps safe, so it is no longer accepted.", fix: "Run `nerd upgrade`, then `pnpm install` and `nerd check`." },
39
42
  "code/raw-sql": { title: "SQL built from values", severity: "block", audience: "agent", risk: "A value from a request placed straight into SQL lets a visitor rewrite the query and read or delete other people's data.", fix: "Use the Drizzle query builder, or bind values as parameters (`.prepare(\"... where id = ?\").bind(id)`). Never put a variable inside the SQL text.", example: { wrong: "c.env.DB.prepare(`select * from notes where id = ${id}`)", right: "db(c.env.DB).select().from(schema.notes).where(eq(schema.notes.id, id))" } },
40
43
  "code/eval": { title: "eval or new Function", severity: "block", audience: "agent", risk: "Running text as code lets anyone who can influence that text run their own code in the app.", fix: "Remove it. Parse data with JSON.parse and choose behaviour with ordinary code." },
41
44
  "code/password-in-app": { title: "Password handling in app code", severity: "block", audience: "agent", risk: "Storing or comparing passwords yourself is unnecessary here and easy to get wrong.", fix: "Remove it. Sign-in is provided by the platform: read the signed-in user from `c.var.user` and protect routes with `requireAuth()`.", example: { wrong: "if (user.password === body.password) { ... }", right: "app.get(\"/api/me\", requireAuth(), (c) => c.json(c.var.user));" } },
@@ -53,6 +53,10 @@ export const semgrepGate = {
53
53
  const findings = [];
54
54
  try {
55
55
  const json = JSON.parse(r.stdout);
56
+ // Semgrep exits 0 and reports nothing when its rules file does not load, which reads exactly like a clean
57
+ // scan. That hid a broken rules file from 2026-11-02 to 2026-11-10: a run that could not use the rules is not a pass.
58
+ if ((json.errors ?? []).some((e) => e.level === "error" && /SemgrepError|InvalidRule|config/i.test(e.type ?? "")))
59
+ return result(this, "warn", "semgrep could not load its rules", [], t, "semgrep");
56
60
  for (const x of json.results.slice(0, 100)) {
57
61
  const short = x.check_id.split(".").pop() ?? x.check_id;
58
62
  const rule_id = `semgrep/${short}`;
@@ -0,0 +1,32 @@
1
+ import { type Finding, type Gate } from "./types.js";
2
+ export interface Versions {
3
+ latest: string | null;
4
+ minSdk: string;
5
+ }
6
+ /** -1, 0, or 1; anything that is not three numbers compares as equal, so a strange value never produces a finding. */
7
+ export declare function compareVersions(a: string, b: string): number;
8
+ /** The first x.y.z in a dependency range: "^0.0.13" → "0.0.13". Null for workspace:, file:, and tags. */
9
+ export declare function pinned(range: string | undefined): string | null;
10
+ /**
11
+ * What the platform says is current, asked at most once a day and remembered in between. Never throws
12
+ * and never takes long: without an answer there is simply nothing to say about versions.
13
+ */
14
+ export declare function currentVersions(opts?: {
15
+ apiUrl?: string;
16
+ now?: number;
17
+ fetchImpl?: typeof fetch;
18
+ cache?: string;
19
+ }): Promise<Versions | null>;
20
+ /** The findings for a given state of the world. Separate from the gate so it can be tested without a network. */
21
+ export declare function versionFindings(input: {
22
+ cli: string;
23
+ sdk: string | null;
24
+ versions: Versions;
25
+ inBuilder?: boolean;
26
+ }): Finding[];
27
+ /**
28
+ * Says when the `nerd` command or the app's SDK has fallen behind. Advice only, with one exception:
29
+ * an SDK older than the platform still accepts blocks the deploy, because that floor is only raised
30
+ * for a safety reason. Version numbers in the text come from the platform, never from the app.
31
+ */
32
+ export declare const versionsGate: Gate;
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { finding } from "./secrets.js";
6
+ import { result, verdict } from "./types.js";
7
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
8
+ const DAY = 24 * 60 * 60 * 1000;
9
+ /** -1, 0, or 1; anything that is not three numbers compares as equal, so a strange value never produces a finding. */
10
+ export function compareVersions(a, b) {
11
+ const x = SEMVER.exec(a);
12
+ const y = SEMVER.exec(b);
13
+ if (!x || !y)
14
+ return 0;
15
+ for (let i = 1; i <= 3; i++) {
16
+ const d = Number(x[i]) - Number(y[i]);
17
+ if (d)
18
+ return d < 0 ? -1 : 1;
19
+ }
20
+ return 0;
21
+ }
22
+ /** The first x.y.z in a dependency range: "^0.0.13" → "0.0.13". Null for workspace:, file:, and tags. */
23
+ export function pinned(range) {
24
+ const m = /(\d+\.\d+\.\d+)/.exec(range ?? "");
25
+ return m && !/^(workspace|file|link|npm):/.test(range ?? "") ? m[1] : null;
26
+ }
27
+ function cacheFile() {
28
+ return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "nerdsystem", "versions.json");
29
+ }
30
+ /**
31
+ * What the platform says is current, asked at most once a day and remembered in between. Never throws
32
+ * and never takes long: without an answer there is simply nothing to say about versions.
33
+ */
34
+ export async function currentVersions(opts = {}) {
35
+ const now = opts.now ?? Date.now();
36
+ const file = opts.cache ?? cacheFile();
37
+ try {
38
+ if (existsSync(file)) {
39
+ const c = JSON.parse(readFileSync(file, "utf8"));
40
+ if (now - c.checkedAt < DAY && typeof c.minSdk === "string")
41
+ return { latest: c.latest, minSdk: c.minSdk };
42
+ }
43
+ }
44
+ catch {
45
+ // unreadable cache: ask again
46
+ }
47
+ try {
48
+ const base = (opts.apiUrl ?? process.env.NERD_API_URL ?? "https://api.nerdsystem.com").replace(/\/$/, "");
49
+ const res = await (opts.fetchImpl ?? fetch)(`${base}/public/versions`, { signal: AbortSignal.timeout(4000) });
50
+ if (!res.ok)
51
+ return null;
52
+ const v = (await res.json());
53
+ if (typeof v.minSdk !== "string" || !SEMVER.test(v.minSdk))
54
+ return null;
55
+ const out = { latest: typeof v.latest === "string" && SEMVER.test(v.latest) ? v.latest : null, minSdk: v.minSdk };
56
+ try {
57
+ mkdirSync(path.dirname(file), { recursive: true });
58
+ writeFileSync(file, `${JSON.stringify({ ...out, checkedAt: now })}\n`);
59
+ }
60
+ catch {
61
+ // a read-only home is fine; it just asks again next time
62
+ }
63
+ return out;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function installedCli() {
70
+ return createRequire(import.meta.url)("../../package.json").version;
71
+ }
72
+ /** The findings for a given state of the world. Separate from the gate so it can be tested without a network. */
73
+ export function versionFindings(input) {
74
+ const { cli, sdk, versions } = input;
75
+ const out = [];
76
+ if (sdk && compareVersions(sdk, versions.minSdk) < 0) {
77
+ out.push(finding("versions/sdk-unsupported", { file: "package.json", fix: `Run \`nerd upgrade\`, then \`pnpm install\` and \`nerd check\`. This app is on @nerdsystem/sdk ${sdk}; the platform needs ${versions.minSdk} or newer.` }));
78
+ }
79
+ const latest = versions.latest;
80
+ if (!latest)
81
+ return out;
82
+ // The builder's own copy of the command is the platform's business, not the app's.
83
+ if (!input.inBuilder && compareVersions(cli, latest) < 0) {
84
+ out.push(finding("versions/cli-outdated", { fix: `Run \`npm install -g nerdsystem@latest\` (nerd ${cli} is installed; ${latest} is current), then run \`nerd check\` again. If the global install is refused, use \`npx nerdsystem@latest\` in place of \`nerd\`.` }));
85
+ }
86
+ if (sdk && compareVersions(sdk, latest) < 0 && compareVersions(sdk, versions.minSdk) >= 0) {
87
+ out.push(finding("versions/sdk-outdated", { file: "package.json", fix: `Run \`nerd upgrade\` (this app is on @nerdsystem/sdk ${sdk}; ${latest} is current), then \`pnpm install\` and \`nerd check\`, and follow what the check reports. Tell the person what changed before deploying.` }));
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Says when the `nerd` command or the app's SDK has fallen behind. Advice only, with one exception:
93
+ * an SDK older than the platform still accepts blocks the deploy, because that floor is only raised
94
+ * for a safety reason. Version numbers in the text come from the platform, never from the app.
95
+ */
96
+ export const versionsGate = {
97
+ name: "versions",
98
+ title: "Up to date",
99
+ blocking: true,
100
+ rulePrefixes: ["versions/"],
101
+ async run(ctx) {
102
+ const t = Date.now();
103
+ if (process.env.NERD_NO_VERSION_CHECK)
104
+ return result(this, "skip", "Version check turned off", [], t);
105
+ const versions = await currentVersions();
106
+ if (!versions)
107
+ return result(this, "skip", "Could not ask the platform what is current", [], t);
108
+ let sdk = null;
109
+ try {
110
+ const pkg = JSON.parse(readFileSync(path.join(ctx.root, "package.json"), "utf8"));
111
+ sdk = pinned(pkg.dependencies?.["@nerdsystem/sdk"]);
112
+ }
113
+ catch {
114
+ // no package.json: the rules gate says so
115
+ }
116
+ const findings = versionFindings({ cli: installedCli(), sdk, versions, inBuilder: Boolean(ctx.committed) });
117
+ const summary = findings.length ? findings.map((f) => f.rule_id.split("/")[1]).join(", ") : versions.latest ? `nerd and the SDK are current (${versions.latest})` : "Nothing older than the platform accepts";
118
+ return result(this, verdict(findings), summary, findings, t);
119
+ },
120
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nerdsystem",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "nerd: start, check, and deploy a web app on nerdsystem from the terminal or a coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/semgrep.yml CHANGED
@@ -22,8 +22,9 @@ rules:
22
22
  severity: WARNING
23
23
  message: dangerouslySetInnerHTML with a non-literal value can inject scripts. Sanitize or avoid.
24
24
  patterns:
25
- - pattern: dangerouslySetInnerHTML={{ __html: $X }}
26
- - pattern-not: dangerouslySetInnerHTML={{ __html: "..." }}
25
+ # Quoted: an unquoted "__html: $X" reads as a YAML mapping and makes the whole file invalid.
26
+ - pattern: '<$EL dangerouslySetInnerHTML={{ __html: $X }} />'
27
+ - pattern-not: '<$EL dangerouslySetInnerHTML={{ __html: "..." }} />'
27
28
  - id: nerd-innerhtml-assignment
28
29
  languages: [typescript, javascript]
29
30
  severity: WARNING
@@ -39,7 +39,9 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
39
39
  4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
40
40
  5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
41
41
 
42
- Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
42
+ `nerd check` also says when the `nerd` command or this app's SDK has fallen behind (the `versions/` rules; it asks the platform once a day). For the command, run `npm install -g nerdsystem@latest`. For the app, run `nerd upgrade`: it updates the SDK version, these instructions, and the test harness, and leaves the app's own code alone; then `pnpm install`, `nerd check`, and work through what it reports. Tell the person what changed before you deploy. Never update the app's dependencies or instructions on your own initiative in the middle of other work; do it when the check asks or the person does.
43
+
44
+ Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed. `nerd deploy` has the platform run whichever is missing on the stored copy of the code before anything goes live (a finding from it stops the deploy like any other), and git builds always run both in the builder.
43
45
 
44
46
  ## Saving and restoring the code
45
47
 
@@ -2,7 +2,7 @@
2
2
  description: How to build, run, and deploy this app on nerdsystem. Applies to server, database, auth, and deploy work.
3
3
  alwaysApply: true
4
4
  ---
5
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
5
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (2b225a0c3327); edit the skill, then run: nerd agents sync -->
6
6
 
7
7
  # Building on nerdsystem
8
8
 
@@ -40,7 +40,9 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
40
40
  4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
41
41
  5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
42
42
 
43
- Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
43
+ `nerd check` also says when the `nerd` command or this app's SDK has fallen behind (the `versions/` rules; it asks the platform once a day). For the command, run `npm install -g nerdsystem@latest`. For the app, run `nerd upgrade`: it updates the SDK version, these instructions, and the test harness, and leaves the app's own code alone; then `pnpm install`, `nerd check`, and work through what it reports. Tell the person what changed before you deploy. Never update the app's dependencies or instructions on your own initiative in the middle of other work; do it when the check asks or the person does.
44
+
45
+ Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed. `nerd deploy` has the platform run whichever is missing on the stored copy of the code before anything goes live (a finding from it stops the deploy like any other), and git builds always run both in the builder.
44
46
 
45
47
  ## Saving and restoring the code
46
48
 
@@ -1,4 +1,4 @@
1
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
1
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (2b225a0c3327); edit the skill, then run: nerd agents sync -->
2
2
 
3
3
  # Building on nerdsystem
4
4
 
@@ -36,7 +36,9 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
36
36
  4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
37
37
  5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
38
38
 
39
- Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
39
+ `nerd check` also says when the `nerd` command or this app's SDK has fallen behind (the `versions/` rules; it asks the platform once a day). For the command, run `npm install -g nerdsystem@latest`. For the app, run `nerd upgrade`: it updates the SDK version, these instructions, and the test harness, and leaves the app's own code alone; then `pnpm install`, `nerd check`, and work through what it reports. Tell the person what changed before you deploy. Never update the app's dependencies or instructions on your own initiative in the middle of other work; do it when the check asks or the person does.
40
+
41
+ Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed. `nerd deploy` has the platform run whichever is missing on the stored copy of the code before anything goes live (a finding from it stops the deploy like any other), and git builds always run both in the builder.
40
42
 
41
43
  ## Saving and restoring the code
42
44
 
@@ -1,4 +1,4 @@
1
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
1
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (2b225a0c3327); edit the skill, then run: nerd agents sync -->
2
2
 
3
3
  # Building on nerdsystem
4
4
 
@@ -36,7 +36,9 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
36
36
  4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
37
37
  5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
38
38
 
39
- Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
39
+ `nerd check` also says when the `nerd` command or this app's SDK has fallen behind (the `versions/` rules; it asks the platform once a day). For the command, run `npm install -g nerdsystem@latest`. For the app, run `nerd upgrade`: it updates the SDK version, these instructions, and the test harness, and leaves the app's own code alone; then `pnpm install`, `nerd check`, and work through what it reports. Tell the person what changed before you deploy. Never update the app's dependencies or instructions on your own initiative in the middle of other work; do it when the check asks or the person does.
40
+
41
+ Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed. `nerd deploy` has the platform run whichever is missing on the stored copy of the code before anything goes live (a finding from it stops the deploy like any other), and git builds always run both in the builder.
40
42
 
41
43
  ## Saving and restoring the code
42
44
 
@@ -1,4 +1,4 @@
1
- <!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
1
+ <!-- generated from .claude/skills/nerdsystem/SKILL.md (2b225a0c3327); edit the skill, then run: nerd agents sync -->
2
2
 
3
3
  # Building on nerdsystem
4
4
 
@@ -36,7 +36,9 @@ Tell the person plainly what does not carry. Their users sign up again on the ne
36
36
  4. `nerd deploy` when it is ready. It runs the checks, builds, stores a copy of the code, applies pending migrations, uploads, prints the URL, and probes the live URL (headers, the sign-in routes, and a signed-out GET of every route the tests found locked; a `smoke/route-open` finding means a locked route is answering strangers on the live app, so run `nerd rollback` first and investigate second). With `--json` the output carries `probes`, the same shape as a check result, with `status: "skip"` and the reason when it did not run; probe findings never fail the deploy.
37
37
  5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
38
38
 
39
- Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
39
+ `nerd check` also says when the `nerd` command or this app's SDK has fallen behind (the `versions/` rules; it asks the platform once a day). For the command, run `npm install -g nerdsystem@latest`. For the app, run `nerd upgrade`: it updates the SDK version, these instructions, and the test harness, and leaves the app's own code alone; then `pnpm install`, `nerd check`, and work through what it reports. Tell the person what changed before you deploy. Never update the app's dependencies or instructions on your own initiative in the middle of other work; do it when the check asks or the person does.
40
+
41
+ Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed. `nerd deploy` has the platform run whichever is missing on the stored copy of the code before anything goes live (a finding from it stops the deploy like any other), and git builds always run both in the builder.
40
42
 
41
43
  ## Saving and restoring the code
42
44