nerdsystem 0.0.19 → 0.0.21
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 +6 -0
- package/dist/commands/deploy.js +12 -2
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/platform-review.d.ts +29 -0
- package/dist/commands/platform-review.js +58 -0
- package/dist/commands/upgrade.d.ts +16 -0
- package/dist/commands/upgrade.js +114 -0
- package/dist/gates/design.d.ts +20 -0
- package/dist/gates/design.js +176 -0
- package/dist/gates/index.js +3 -1
- package/dist/gates/rules.d.ts +2 -0
- package/dist/gates/rules.js +23 -0
- package/dist/gates/versions.d.ts +32 -0
- package/dist/gates/versions.js +120 -0
- package/package.json +1 -1
- package/template/.claude/skills/nerdsystem/SKILL.md +7 -1
- package/template/.cursor/rules/nerdsystem.mdc +8 -2
- package/template/.github/copilot-instructions.md +8 -2
- package/template/AGENTS.md +8 -2
- package/template/GEMINI.md +8 -2
- package/template/migrations/0000_init.sql +2 -0
- package/template/migrations/meta/0000_snapshot.json +10 -2
- package/template/migrations/meta/_journal.json +1 -1
- package/template/src/server/db/schema.ts +16 -11
- package/template/src/server/routes/todos.ts +2 -0
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) });
|
package/dist/commands/deploy.js
CHANGED
|
@@ -9,6 +9,7 @@ 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
11
|
import { platformScan } from "./platform-scan.js";
|
|
12
|
+
import { platformReview, printReview } from "./platform-review.js";
|
|
12
13
|
import { BUILD_GATES, gateSummary, knownSecretsFrom, probeDeployment, runGates, skippedProbe, SOURCE_GATES, waitForDeployment } from "../gates/index.js";
|
|
13
14
|
const MODULE_EXT = new Set([".js", ".mjs", ".wasm"]);
|
|
14
15
|
function walk(dir, base = dir) {
|
|
@@ -224,8 +225,13 @@ export async function deploy(opts) {
|
|
|
224
225
|
if (!built.ok)
|
|
225
226
|
throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
|
|
226
227
|
const r = await shipBuild({ root, nerd, api, build, results: [...gates.results, ...built.results], opts });
|
|
227
|
-
|
|
228
|
-
|
|
228
|
+
// The app is live at this point. The model review reads the stored code and is advice for the next change.
|
|
229
|
+
const reviewPage = `${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/review`;
|
|
230
|
+
const wantReview = r.deployment.status === "live" && Boolean(r.source);
|
|
231
|
+
if (opts.json) {
|
|
232
|
+
const review = wantReview ? await platformReview(api, `${projectPath(nerd)}/sources/${r.source.id}`, reviewPage) : null;
|
|
233
|
+
return printJson({ deployment: r.deployment, url: r.url, migrations: r.migrations, probes: r.probes, source: r.source, sourceSkipped: r.sourceSkipped, lineage: r.lineage.passed, warnings: r.warnings, review });
|
|
234
|
+
}
|
|
229
235
|
printProbe(r.url, r.probes);
|
|
230
236
|
if (r.deployment.status === "live") {
|
|
231
237
|
if (r.deployment.sourceId)
|
|
@@ -233,6 +239,10 @@ export async function deploy(opts) {
|
|
|
233
239
|
else
|
|
234
240
|
log.warn(sourceLine(r));
|
|
235
241
|
}
|
|
242
|
+
if (wantReview) {
|
|
243
|
+
log.step("Your app is live. Asking for a model review of how it is put together (up to a minute or two; safe to stop waiting)");
|
|
244
|
+
printReview(await platformReview(api, `${projectPath(nerd)}/sources/${r.source.id}`, reviewPage));
|
|
245
|
+
}
|
|
236
246
|
log.info(`First time? Sign up on your app at ${r.url}, then make yourself admin in the dashboard's Auth tab: ${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/auth`);
|
|
237
247
|
}
|
|
238
248
|
export async function deployments(opts) {
|
package/dist/commands/init.d.ts
CHANGED
package/dist/commands/init.js
CHANGED
|
@@ -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.
|
|
195
|
+
const SDK_VERSION = "0.0.21";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ApiClient } from "../api.js";
|
|
2
|
+
export interface ReviewFindingOut {
|
|
3
|
+
rule_id: string;
|
|
4
|
+
severity: "block" | "warn" | "info";
|
|
5
|
+
audience: "agent" | "human";
|
|
6
|
+
file: string;
|
|
7
|
+
line: number;
|
|
8
|
+
risk: string;
|
|
9
|
+
fix: string;
|
|
10
|
+
}
|
|
11
|
+
export type ReviewOutcome = {
|
|
12
|
+
status: "done" | "declined";
|
|
13
|
+
findings: ReviewFindingOut[];
|
|
14
|
+
dashboard: string;
|
|
15
|
+
} | {
|
|
16
|
+
status: "skipped";
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* After a deploy is live, asks the platform for the model's review of the stored code and waits for it.
|
|
21
|
+
*
|
|
22
|
+
* Only the findings come back into the terminal, and only as a rule id with a file and a line: the words
|
|
23
|
+
* printed are this CLI's own catalog, looked up by id, so nothing the model wrote is ever put in front of
|
|
24
|
+
* the agent reading this output. The model's plain-language summary is for the person and stays in the
|
|
25
|
+
* dashboard. Any failure, limit, or missing configuration is a skip with a reason; a review never changes
|
|
26
|
+
* whether the deploy succeeded.
|
|
27
|
+
*/
|
|
28
|
+
export declare function platformReview(api: ApiClient, sourcePath: string, dashboard: string): Promise<ReviewOutcome>;
|
|
29
|
+
export declare function printReview(r: ReviewOutcome): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { RULES } from "../gates/rules.js";
|
|
2
|
+
import { CliError, log } from "../util.js";
|
|
3
|
+
/**
|
|
4
|
+
* After a deploy is live, asks the platform for the model's review of the stored code and waits for it.
|
|
5
|
+
*
|
|
6
|
+
* Only the findings come back into the terminal, and only as a rule id with a file and a line: the words
|
|
7
|
+
* printed are this CLI's own catalog, looked up by id, so nothing the model wrote is ever put in front of
|
|
8
|
+
* the agent reading this output. The model's plain-language summary is for the person and stays in the
|
|
9
|
+
* dashboard. Any failure, limit, or missing configuration is a skip with a reason; a review never changes
|
|
10
|
+
* whether the deploy succeeded.
|
|
11
|
+
*/
|
|
12
|
+
export async function platformReview(api, sourcePath, dashboard) {
|
|
13
|
+
if (process.env.NERD_NO_REVIEW)
|
|
14
|
+
return { status: "skipped", reason: "turned off with NERD_NO_REVIEW" };
|
|
15
|
+
let review;
|
|
16
|
+
try {
|
|
17
|
+
review = (await api.post(`${sourcePath}/review`)).review;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const status = err instanceof CliError ? err.status : undefined;
|
|
21
|
+
// The 402's wording is the platform's own (plan and allowance), safe to repeat.
|
|
22
|
+
if (status === 402 && err instanceof Error)
|
|
23
|
+
return { status: "skipped", reason: err.message.replace(/\s*\[[^\]]*\]\s*$/, "") };
|
|
24
|
+
return { status: "skipped", reason: status === 503 ? "the model review is not available on this platform yet" : "the review could not be completed this time" };
|
|
25
|
+
}
|
|
26
|
+
if (review.status !== "done")
|
|
27
|
+
return { status: "declined", findings: [], dashboard };
|
|
28
|
+
const findings = [];
|
|
29
|
+
for (const f of review.findings ?? []) {
|
|
30
|
+
const rule = typeof f.rule_id === "string" && f.rule_id.startsWith("review/") ? RULES[f.rule_id] : undefined;
|
|
31
|
+
// A type this version of nerd does not know is left out rather than shown with words from elsewhere.
|
|
32
|
+
if (!rule || typeof f.file !== "string" || !/^[\w./@-]{1,200}$/.test(f.file) || !Number.isInteger(f.line))
|
|
33
|
+
continue;
|
|
34
|
+
findings.push({ rule_id: f.rule_id, severity: rule.severity, audience: rule.audience, file: f.file, line: f.line, risk: rule.risk, fix: rule.fix });
|
|
35
|
+
}
|
|
36
|
+
return { status: "done", findings, dashboard };
|
|
37
|
+
}
|
|
38
|
+
export function printReview(r) {
|
|
39
|
+
if (r.status === "skipped") {
|
|
40
|
+
log.info(`Model review: not run (${r.reason}).`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (r.status === "declined") {
|
|
44
|
+
log.info("Model review: the reviewer gave no answer for this app.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!r.findings.length) {
|
|
48
|
+
log.ok(`Model review: nothing to raise. A plain-language summary for the person is at ${r.dashboard}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
log.warn(`Model review: ${r.findings.length} thing(s) to look at. This is a second opinion from a model reading the code, so check each one against the code before changing anything; it can be wrong.`);
|
|
52
|
+
for (const f of r.findings) {
|
|
53
|
+
log.info(` ${f.severity === "info" ? "·" : "!"} ${f.rule_id} ${f.file}:${f.line}${f.audience === "human" ? " (for the person to decide)" : ""}`);
|
|
54
|
+
log.info(` ${f.risk}`);
|
|
55
|
+
log.info(` Fix: ${f.fix}`);
|
|
56
|
+
}
|
|
57
|
+
log.info(`A plain-language summary for the person is at ${r.dashboard}`);
|
|
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,114 @@
|
|
|
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
|
+
// Line endings are made the same first: a checkout on Windows may carry CRLF, and the heading match below
|
|
69
|
+
// and the "is it already current" comparison must not depend on that.
|
|
70
|
+
const lf = (t) => (t === null ? null : t.replace(/\r\n/g, "\n"));
|
|
71
|
+
const tplClaude = lf(read(path.join(tpl, "CLAUDE.md")));
|
|
72
|
+
const oldClaude = lf(read(path.join(root, "CLAUDE.md")));
|
|
73
|
+
// `nerd init` puts the app's name and the owner's description above the platform's text and demotes the
|
|
74
|
+
// platform's heading. That opening is the owner's, and it is what the model review reads as the app's
|
|
75
|
+
// purpose, so it is carried over word for word.
|
|
76
|
+
const seeded = oldClaude ? /^(# [^\n]*\n\n[\s\S]*?\n\n)## This app runs on nerdsystem\n/.exec(oldClaude) : null;
|
|
77
|
+
const newClaude = tplClaude !== null && seeded ? seeded[1] + tplClaude.replace(/^# This app runs on nerdsystem\n/, "## This app runs on nerdsystem\n") : tplClaude;
|
|
78
|
+
let keptOld = false;
|
|
79
|
+
if (newClaude !== null && oldClaude !== newClaude) {
|
|
80
|
+
if (oldClaude !== null && !existsSync(path.join(root, "CLAUDE.previous.md"))) {
|
|
81
|
+
renameSync(path.join(root, "CLAUDE.md"), path.join(root, "CLAUDE.previous.md"));
|
|
82
|
+
changed.push("CLAUDE.previous.md");
|
|
83
|
+
keptOld = true;
|
|
84
|
+
}
|
|
85
|
+
write("CLAUDE.md", newClaude);
|
|
86
|
+
}
|
|
87
|
+
// The files other agents read are generated from the skill that was just replaced.
|
|
88
|
+
for (const [rel, content] of Object.entries(renderAgentFiles(root).files))
|
|
89
|
+
write(rel, content);
|
|
90
|
+
const ignore = read(path.join(root, ".gitignore")) ?? "";
|
|
91
|
+
if (!/^\.nerd\/?$/m.test(ignore))
|
|
92
|
+
write(".gitignore", `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}.nerd/\n`);
|
|
93
|
+
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)."];
|
|
94
|
+
if (startersWritten.includes("test/access.test.ts"))
|
|
95
|
+
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.");
|
|
96
|
+
if (keptOld)
|
|
97
|
+
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.");
|
|
98
|
+
next.push("Tell the person in plain words what changed, then run `nerd deploy` when the check passes.");
|
|
99
|
+
return { from, to: version, changed, next };
|
|
100
|
+
}
|
|
101
|
+
export async function upgrade(opts) {
|
|
102
|
+
const root = findProjectRoot();
|
|
103
|
+
const r = upgradeProject(root, templateDir(), cliVersion());
|
|
104
|
+
if (opts.json) {
|
|
105
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!r.changed.length)
|
|
109
|
+
log.ok(`Already current with nerd ${r.to}. Nothing was changed.`);
|
|
110
|
+
else
|
|
111
|
+
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(", ")}`);
|
|
112
|
+
log.info("Your app's own code was not touched. Next:");
|
|
113
|
+
r.next.forEach((n, i) => log.info(` ${i + 1}. ${n}`));
|
|
114
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type SourceFile } from "./fs.js";
|
|
2
|
+
import { type Finding, type Gate } from "./types.js";
|
|
3
|
+
/** The app's routes as the last test run saw them (exact, from the running app), or null without a report. */
|
|
4
|
+
export declare function declaredRoutes(root: string): {
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
}[] | null;
|
|
8
|
+
/** Every "/api/..." the client names: a plain string, or a template whose ${...} parts stand for one segment each. */
|
|
9
|
+
export declare function clientCalls(files: SourceFile[]): {
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
path: string;
|
|
13
|
+
}[];
|
|
14
|
+
/** The literal endings of addresses the client builds from a variable: "`${base}/ticks`" gives "/ticks". */
|
|
15
|
+
export declare function clientEndings(files: SourceFile[]): string[];
|
|
16
|
+
export declare function designFindings(root: string, files: SourceFile[], routes: {
|
|
17
|
+
method: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}[] | null, authPath?: string): Finding[];
|
|
20
|
+
export declare const designGate: Gate;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lineOf, sourceFiles } from "./fs.js";
|
|
4
|
+
import { finding } from "./secrets.js";
|
|
5
|
+
import { counts, result, verdict } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* How the app is put together: the things that work today, pass every test, and still go wrong later.
|
|
8
|
+
* A screen calling a route that is not there, a list that returns every row forever, money stored as a
|
|
9
|
+
* fraction, a table nothing uses. None of it can be seen from the screen, and the person who owns the
|
|
10
|
+
* app cannot read the code.
|
|
11
|
+
*
|
|
12
|
+
* Every check here leans on the one shape every app has (routes under src/server, the Drizzle schema in
|
|
13
|
+
* src/server/db/schema.ts, the client under src/client), which is what makes plain text analysis precise
|
|
14
|
+
* enough to be worth reading. All of it is advice: it never blocks a deploy. Findings carry a file and a
|
|
15
|
+
* line and words from the catalog only; nothing from the app's code is repeated to the agent.
|
|
16
|
+
*/
|
|
17
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
18
|
+
const LONG_FILE_LINES = 400;
|
|
19
|
+
/** Blanks out comments, keeping every character position. */
|
|
20
|
+
function withoutComments(text) {
|
|
21
|
+
return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
|
|
22
|
+
}
|
|
23
|
+
/** "/api/todos/:id" → a pattern; ":id" and "*" match one or more characters of a segment. */
|
|
24
|
+
function routePattern(p) {
|
|
25
|
+
return new RegExp(`^${p.split("/").map((seg) => (seg.startsWith(":") ? "[^/]+" : seg === "*" ? ".*" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("/")}/?$`);
|
|
26
|
+
}
|
|
27
|
+
/** The app's routes as the last test run saw them (exact, from the running app), or null without a report. */
|
|
28
|
+
export function declaredRoutes(root) {
|
|
29
|
+
try {
|
|
30
|
+
const report = JSON.parse(readFileSync(path.join(root, ".nerd/access-report.json"), "utf8"));
|
|
31
|
+
if (!Array.isArray(report.routes))
|
|
32
|
+
return null;
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const r of report.routes) {
|
|
35
|
+
const m = typeof r === "string" ? /^(GET|POST|PUT|PATCH|DELETE) (\/[A-Za-z0-9/:_.*-]{0,120})$/.exec(r) : null;
|
|
36
|
+
if (m)
|
|
37
|
+
out.push({ method: m[1], path: m[2] });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Every "/api/..." the client names: a plain string, or a template whose ${...} parts stand for one segment each. */
|
|
46
|
+
export function clientCalls(files) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const re = /(["'`])(\/api\/[^"'`\s?#]*)/g;
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
if (!f.rel.startsWith("src/client/") || !CODE.test(f.rel))
|
|
51
|
+
continue;
|
|
52
|
+
const text = withoutComments(f.text);
|
|
53
|
+
re.lastIndex = 0;
|
|
54
|
+
let m;
|
|
55
|
+
while ((m = re.exec(text))) {
|
|
56
|
+
// A template part becomes a made-up segment; a path that is nothing but a prefix ("/api/") is someone building a URL, not a call.
|
|
57
|
+
const p = m[2].replace(/\$\{[^}]*\}/g, "x").replace(/\/$/, "");
|
|
58
|
+
if (p.split("/").length >= 3 && p !== "/api")
|
|
59
|
+
out.push({ file: f.rel, line: lineOf(text, m.index), path: p });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** The literal endings of addresses the client builds from a variable: "`${base}/ticks`" gives "/ticks". */
|
|
65
|
+
export function clientEndings(files) {
|
|
66
|
+
const out = new Set();
|
|
67
|
+
const re = /\$\{[^}]+\}((?:\/[A-Za-z0-9_-]+)+)\/?[`"'?]/g;
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
if (!f.rel.startsWith("src/client/") || !CODE.test(f.rel))
|
|
70
|
+
continue;
|
|
71
|
+
const text = withoutComments(f.text);
|
|
72
|
+
re.lastIndex = 0;
|
|
73
|
+
let m;
|
|
74
|
+
while ((m = re.exec(text)))
|
|
75
|
+
out.add(m[1]);
|
|
76
|
+
}
|
|
77
|
+
return [...out];
|
|
78
|
+
}
|
|
79
|
+
/** Tables in the schema: name of the export, where it starts, and its text. */
|
|
80
|
+
function tables(schema) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const re = /export\s+const\s+(\w+)\s*=\s*sqliteTable\s*\(/g;
|
|
83
|
+
let m;
|
|
84
|
+
while ((m = re.exec(schema))) {
|
|
85
|
+
// Up to the next table or the end: good enough to ask what one table contains.
|
|
86
|
+
const next = schema.slice(m.index + 1).search(/export\s+const\s+\w+\s*=\s*sqliteTable\s*\(/);
|
|
87
|
+
out.push({ name: m[1], index: m.index, body: schema.slice(m.index, next < 0 ? undefined : m.index + 1 + next) });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
export function designFindings(root, files, routes, authPath = "/api/auth") {
|
|
92
|
+
const findings = [];
|
|
93
|
+
const add = (rule, file, line) => findings.push(finding(rule, { file, ...(line ? { line } : {}), fingerprint: `${rule}:${file}:${line ?? 0}` }));
|
|
94
|
+
// 1 and 2. What the screens call against what the server has.
|
|
95
|
+
if (routes) {
|
|
96
|
+
const patterns = routes.map((r) => ({ ...r, re: routePattern(r.path) }));
|
|
97
|
+
const calls = clientCalls(files);
|
|
98
|
+
for (const c of calls) {
|
|
99
|
+
if (c.path === authPath || c.path.startsWith(`${authPath}/`))
|
|
100
|
+
continue;
|
|
101
|
+
if (!patterns.some((p) => p.re.test(c.path)))
|
|
102
|
+
add("design/unknown-route", c.file, c.line);
|
|
103
|
+
}
|
|
104
|
+
const SYSTEM = new Set(["/api/health", "/api/cron", "/api/admin/ping"]);
|
|
105
|
+
// A screen may build the address from a variable (`${base}/ticks`). The ending it names counts as a use of
|
|
106
|
+
// any route that ends the same way, so only routes no screen mentions in any form are reported.
|
|
107
|
+
const endings = clientEndings(files);
|
|
108
|
+
for (const p of patterns) {
|
|
109
|
+
if (SYSTEM.has(p.path))
|
|
110
|
+
continue;
|
|
111
|
+
if (endings.some((e) => p.path.endsWith(e)))
|
|
112
|
+
continue;
|
|
113
|
+
// The route's name passed the same strict pattern as every other route name read from the report.
|
|
114
|
+
if (!calls.some((c) => p.re.test(c.path)))
|
|
115
|
+
findings.push(finding("design/unused-route", { file: "src/server", fix: `Nothing under src/client calls ${p.method} ${p.path}. If a screen was meant to use it, connect it; if it is left over, remove the route and its tests. If something outside the app calls it on purpose, leave it and tell the person it exists.`, fingerprint: `design/unused-route:${p.method} ${p.path}` }));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 3. A list with no end. Each statement from .select( to .all() that never says .limit(.
|
|
119
|
+
for (const f of files) {
|
|
120
|
+
if (!f.rel.startsWith("src/server/") || !CODE.test(f.rel))
|
|
121
|
+
continue;
|
|
122
|
+
const text = withoutComments(f.text);
|
|
123
|
+
const re = /\.select\s*\(/g;
|
|
124
|
+
let m;
|
|
125
|
+
while ((m = re.exec(text))) {
|
|
126
|
+
const end = text.indexOf(";", m.index);
|
|
127
|
+
const stmt = text.slice(m.index, end < 0 ? m.index + 600 : end);
|
|
128
|
+
if (/\.all\s*\(\s*\)/.test(stmt) && !/\.limit\s*\(/.test(stmt))
|
|
129
|
+
add("design/unbounded-list", f.rel, lineOf(text, m.index));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 4, 5, 6. The data model.
|
|
133
|
+
const schemaFile = files.find((f) => f.rel === "src/server/db/schema.ts");
|
|
134
|
+
if (schemaFile) {
|
|
135
|
+
const schema = withoutComments(schemaFile.text);
|
|
136
|
+
const serverText = files.filter((f) => f.rel.startsWith("src/server/") && f.rel !== schemaFile.rel && CODE.test(f.rel)).map((f) => withoutComments(f.text)).join("\n");
|
|
137
|
+
for (const t of tables(schema)) {
|
|
138
|
+
const line = lineOf(schema, t.index);
|
|
139
|
+
const money = /\b(\w*(price|amount|cost|total|balance|fee|salary|payment)\w*)\s*:\s*real\s*\(/i.exec(t.body);
|
|
140
|
+
if (money)
|
|
141
|
+
add("design/money-as-fraction", schemaFile.rel, lineOf(schema, t.index + money.index));
|
|
142
|
+
if (/\buserId\s*:/.test(t.body) && !/index\s*\([^)]*\)\s*\.on\s*\([^)]*\buserId\b/.test(t.body) && !/\buserId\s*:[^,\n]*\.(primaryKey|unique)\s*\(/.test(t.body))
|
|
143
|
+
add("design/no-index-on-owner", schemaFile.rel, line);
|
|
144
|
+
if (!new RegExp(`\\bschema\\.${t.name}\\b|\\b${t.name}\\b`).test(serverText))
|
|
145
|
+
add("design/unused-table", schemaFile.rel, line);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 7 and 8. Files an agent will struggle to change safely, and words left for later.
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
if (!f.rel.startsWith("src/") || !CODE.test(f.rel))
|
|
151
|
+
continue;
|
|
152
|
+
if (f.text.split("\n").length > LONG_FILE_LINES)
|
|
153
|
+
add("design/long-file", f.rel);
|
|
154
|
+
const text = f.text;
|
|
155
|
+
// The markers are capitals only: a to-do app says "todo" on every other line.
|
|
156
|
+
const todo = /\b(TODO|FIXME|XXX)\b[:( ]|[Ll]orem ipsum|[Cc]oming soon/.exec(text);
|
|
157
|
+
if (todo)
|
|
158
|
+
add("design/unfinished", f.rel, lineOf(text, todo.index));
|
|
159
|
+
}
|
|
160
|
+
return findings;
|
|
161
|
+
}
|
|
162
|
+
export const designGate = {
|
|
163
|
+
name: "design",
|
|
164
|
+
title: "How the app is put together",
|
|
165
|
+
blocking: false,
|
|
166
|
+
rulePrefixes: ["design/"],
|
|
167
|
+
async run(ctx) {
|
|
168
|
+
const t = Date.now();
|
|
169
|
+
if (!existsSync(path.join(ctx.root, "src/server")))
|
|
170
|
+
return result(this, "skip", "No src/server folder", [], t);
|
|
171
|
+
const routes = declaredRoutes(ctx.root);
|
|
172
|
+
const findings = designFindings(ctx.root, sourceFiles(ctx.root), routes);
|
|
173
|
+
const note = routes ? "" : " (screens were not matched to routes: that needs the app's access tests)";
|
|
174
|
+
return result(this, verdict(findings), findings.length ? `${counts(findings)}${note}` : `Nothing to raise${note}`, findings, t);
|
|
175
|
+
},
|
|
176
|
+
};
|
package/dist/gates/index.js
CHANGED
|
@@ -3,7 +3,9 @@ import { depsGate, piiGate, policyGate, rulesGate, typecheckGate } from "./code.
|
|
|
3
3
|
import { gitleaksGate, semgrepGate } from "./tools.js";
|
|
4
4
|
import { suppressionsGate } from "./suppressions.js";
|
|
5
5
|
import { staticGate } from "./static.js";
|
|
6
|
+
import { designGate } from "./design.js";
|
|
6
7
|
import { testsGate } from "./tests.js";
|
|
8
|
+
import { versionsGate } from "./versions.js";
|
|
7
9
|
import { applyAttempts, MAX_ATTEMPTS } from "./attempts.js";
|
|
8
10
|
export { gateSummary, skippedGates } from "./types.js";
|
|
9
11
|
export { RULES, ruleFor } from "./rules.js";
|
|
@@ -11,7 +13,7 @@ export { mask } from "./secrets.js";
|
|
|
11
13
|
export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
|
|
12
14
|
export { probeDeployment, skippedProbe, waitForDeployment } from "./smoke.js";
|
|
13
15
|
/** 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];
|
|
16
|
+
export const SOURCE_GATES = [suppressionsGate, secretsGate, gitleaksGate, depsGate, rulesGate, versionsGate, staticGate, semgrepGate, policyGate, piiGate, typecheckGate, testsGate, designGate];
|
|
15
17
|
/** Gates that need the built output. */
|
|
16
18
|
export const BUILD_GATES = [bundleGate, policyGate];
|
|
17
19
|
function selectGates(gates, onlyRules) {
|
package/dist/gates/rules.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface RuleDef {
|
|
|
13
13
|
wrong: string;
|
|
14
14
|
right: string;
|
|
15
15
|
};
|
|
16
|
+
/** For the `review/` rules only: what the model reviewer is told to look for. Written by us, like everything else here. */
|
|
17
|
+
when?: string;
|
|
16
18
|
}
|
|
17
19
|
export declare const RULES: Record<string, RuleDef>;
|
|
18
20
|
/** Fallback for scanner rule ids not in the catalog (semgrep and gitleaks have their own). */
|
package/dist/gates/rules.js
CHANGED
|
@@ -36,6 +36,29 @@ 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
|
+
"review/trusts-client-value": { title: "The server trusts a value the browser sent", severity: "warn", audience: "agent", when: "A route uses a price, total, discount, role, status, owner, or permission taken from the request body or query to make a decision or to store it, where the server should look the value up or compute it itself.", risk: "Anyone can change what their browser sends, so they can set their own price, role, or status.", fix: "At the file and line shown, stop using the value from the request. Look it up from the database or compute it on the server, and keep only the identifiers the person is allowed to choose." },
|
|
40
|
+
"review/missing-permission-rule": { title: "Signed-in people can do something only some should", severity: "warn", audience: "agent", when: "A route is limited to signed-in users, and the data is scoped correctly, but the action itself should be limited further by the app's own logic and is not: for example any user can approve, confirm, publish, refund, or change something that only its owner, a staff member, or an admin should.", risk: "A normal user can perform an action meant for the owner or for staff.", fix: "Decide who may do this and enforce it on the server at the file and line shown: `requireRole(...)` for staff actions, or a check that the signed-in user is the one the row belongs to. Ask the person who should be allowed if it is not obvious, and add a test for it." },
|
|
41
|
+
"review/no-duplicate-guard": { title: "Nothing stops the same thing happening twice", severity: "warn", audience: "agent", when: "The feature has something that must be unique or limited (one booking per slot, one vote per person, one membership per user and group, stock that can run out) and neither the database nor the route prevents a second one, including when two requests arrive at the same moment.", risk: "Two people can take the same slot, or one person can do twice what should happen once, and the data ends up contradicting itself.", fix: "Add a unique index for the combination that must not repeat (then `pnpm db:generate`), and have the route answer 409 with a clear message when it is taken. A check in code alone is not enough when two requests arrive together." },
|
|
42
|
+
"review/dead-end": { title: "Something can be started but not finished or undone", severity: "warn", audience: "agent", when: "A person can create or begin something through the app and there is no way in the app to complete, change, cancel, or remove it, where the feature plainly needs one.", risk: "People get stuck with something they cannot fix, and the owner has to change the database by hand.", fix: "Add the missing step (edit, cancel, delete, or complete) as a locked route with its tests and a control on the screen, or tell the person it is not there yet so they can decide." },
|
|
43
|
+
"review/unhandled-state": { title: "A screen has no answer for empty, slow, or failed", severity: "info", audience: "agent", when: "A screen loads or saves data and shows nothing useful while it loads, when there is nothing to show yet, or when the request fails; a failed save looks the same as a successful one.", risk: "People see a blank page or believe something saved when it did not.", fix: "At the file and line shown, add the three states: a short message while loading, a plain sentence and a next step when there is nothing yet, and a visible message when a request fails." },
|
|
44
|
+
"review/destructive-without-confirm": { title: "Something is deleted with one click and no way back", severity: "info", audience: "agent", when: "A delete or other irreversible action runs immediately from a single click or tap, with no confirmation and no undo.", risk: "A slip of the finger destroys someone's work.", fix: "Ask for confirmation in words that say what will be lost, or keep the row and mark it deleted so it can be restored." },
|
|
45
|
+
"review/promised-but-missing": { title: "The app does not do something its description says it does", severity: "warn", audience: "human", when: "The app's own description (the opening lines of README.md) states a capability, and nothing in the code provides it. Only for capabilities stated plainly in the description, not ones you imagine it should have.", risk: "The person who asked for the app may believe it does something it does not.", fix: "Tell the person, in plain words, which part of what they asked for is not built yet, and ask whether they want it now. Do not build it without asking." },
|
|
46
|
+
"review/logic-in-the-browser-only": { title: "A rule is enforced only on the screen", severity: "warn", audience: "agent", when: "A limit, validation, or permission exists in the client code (a hidden button, a disabled field, a check before sending) and the matching route on the server does not enforce it.", risk: "The screen is a convenience. Anyone can call the server directly and skip the rule.", fix: "Enforce the same rule in the route on the server at the file and line shown, and keep the screen's version only as a courtesy." },
|
|
47
|
+
"review/duplicated-logic": { title: "The same logic is written in several places", severity: "info", audience: "agent", when: "The same non-trivial rule or calculation (a price, an eligibility check, a status transition) is written out in two or more places that would have to change together.", risk: "The next change updates one copy and not the others, and the app starts disagreeing with itself.", fix: "Move the rule into one function the next time you change it, and have every caller use that. Do not reorganise working code only for this." },
|
|
48
|
+
"review/inconsistent-pattern": { title: "The same job is done in different ways", severity: "info", audience: "agent", when: "Similar routes or screens in the app solve the same problem in clearly different ways (some check ownership one way and some another, some validate input with the schema helper and some by hand, some return errors in a different shape).", risk: "The odd one out is usually the one with the bug, and every future change has to work out which way is right.", fix: "Pick the way the rest of the app and the platform's instructions use, and bring the odd one into line." },
|
|
49
|
+
"review/time-without-zone": { title: "Dates or times that will mean different things to different people", severity: "info", audience: "agent", when: "The feature depends on dates or times of day (bookings, reminders, deadlines, opening hours) and stores or compares them without a clear time zone, or mixes local time with UTC.", risk: "A booking or reminder lands an hour or a day off for someone in another time zone or across a clock change.", fix: "Store instants in UTC as ISO strings, store the place's time zone where one matters, and convert only when showing a time to a person." },
|
|
50
|
+
"review/collects-more-than-needed": { title: "The app stores personal details it does not use", severity: "info", audience: "human", when: "The app asks for or stores personal information (date of birth, address, phone number, identity numbers, health details) that no feature in the code uses.", risk: "Personal data you hold is data you have to protect and may have to hand over or delete on request.", fix: "Tell the person which details are collected and not used, and ask whether to stop collecting them. Removing a column that holds data is their decision." },
|
|
51
|
+
"design/unknown-route": { title: "A screen calls a route the server does not have", severity: "warn", audience: "agent", risk: "That part of the app fails for everyone who uses it, with an error only they see.", fix: "At the file and line shown, the client asks for a path no route answers. Add the route on the server (locked, with its tests), or correct the path to the route that was meant." },
|
|
52
|
+
"design/unused-route": { title: "A route no screen uses", severity: "info", audience: "agent", risk: "Code nobody reaches still has to be kept safe and correct, and usually means a feature was half removed or half built.", fix: "Connect it to the screen that was meant to use it, or remove the route and its tests." },
|
|
53
|
+
"design/unbounded-list": { title: "A list that returns every row", severity: "warn", audience: "agent", risk: "It works with ten rows. With ten thousand the page is slow or fails, and nothing warns anyone on the way there.", fix: "Add `.limit(...)` to the query (200 is a reasonable page) and, where the list can grow, a way to ask for the next page.", example: { wrong: "db.select().from(schema.notes).where(eq(schema.notes.userId, id)).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, id)).orderBy(desc(schema.notes.createdAt)).limit(200).all()" } },
|
|
54
|
+
"design/money-as-fraction": { title: "Money stored as a fraction", severity: "warn", audience: "agent", risk: "Fractions cannot hold amounts like 0.10 exactly, so totals drift by a cent and never match a receipt.", fix: "Store money as a whole number of the smallest unit (cents) in an `integer` column, and format it only when showing it. Changing an existing column is a migration: tell the person before you do it.", example: { wrong: 'price: real("price")', right: 'priceCents: integer("price_cents").notNull()' } },
|
|
55
|
+
"design/no-index-on-owner": { title: "A table of users' rows with no index on the owner", severity: "warn", audience: "agent", risk: "Every page that lists someone's rows reads the whole table to find them, which gets slower for every user as the app grows.", fix: "Add an index on the owner column in the table's definition, then `pnpm db:generate` for the migration.", example: { wrong: 'sqliteTable("notes", { userId: text("user_id").notNull() })', right: 'sqliteTable("notes", { userId: text("user_id").notNull() }, (t) => [index("notes_user_idx").on(t.userId)])' } },
|
|
56
|
+
"design/unused-table": { title: "A table nothing uses", severity: "warn", audience: "agent", risk: "A table left over from the starter or from a removed feature confuses the next change and may still hold people's data.", fix: "If the feature is gone, remove the table from the schema and write a migration that drops it; tell the person first if it may hold data they want. If it is meant to be used, connect it." },
|
|
57
|
+
"design/long-file": { title: "A very long file", severity: "info", audience: "agent", risk: "Changes to a file this long are more likely to break something elsewhere in it, for a person or an agent.", fix: "Split it along its natural parts (one screen, one group of routes per file) the next time you change it. Do not reorganise working code just for this." },
|
|
58
|
+
"design/unfinished": { title: "Something marked as unfinished", severity: "info", audience: "agent", risk: "A note to finish something later, or placeholder text, is about to go live.", fix: "Finish it, remove it, or tell the person it is not done yet so they can decide." },
|
|
59
|
+
"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`." },
|
|
60
|
+
"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`." },
|
|
61
|
+
"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
62
|
"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
63
|
"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
64
|
"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));" } },
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
|
@@ -164,6 +166,10 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
164
166
|
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
165
167
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
166
168
|
|
|
169
|
+
It also reviews how the app is put together and reports, as advice that never blocks (the `design/` rules): a screen calling a route the server does not have, a route no screen uses, a list query with no `.limit(...)`, money in a `real` column (use whole cents in an `integer`), a table with a `userId` and no index on it, a table nothing uses, a file over 400 lines, and `TODO` or placeholder text. Fix these as you go: give every list a limit and every user-owned table an index when you write them, and remove the starter's `todos` table, route, and tests once the app's own feature replaces them. When a fix would change or drop a column that may hold people's data, tell the person first.
|
|
170
|
+
|
|
171
|
+
After a deploy is live, `nerd deploy` asks the platform for a model review of the stored code and prints what it found (the `review/` rules): a value trusted from the browser, an action more people can do than should, nothing stopping the same thing happening twice, something that can be started and not finished, a rule enforced only on the screen, and a few more. It is a second opinion and can be wrong: read the code at the file and line before changing anything, fix the ones that are real, and say which you did not act on and why. Findings marked "for the person to decide" are theirs: put the question to them in plain words and wait. The person's own summary of the review is in the dashboard under Review; point them to it. Free plans include a few reviews a month, so a deploy may say it was not run.
|
|
172
|
+
|
|
167
173
|
## Scheduled jobs and email
|
|
168
174
|
|
|
169
175
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
@@ -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 (
|
|
5
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (df4afc8243ef); 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
|
-
|
|
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
|
|
|
@@ -165,6 +167,10 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
165
167
|
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
166
168
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
167
169
|
|
|
170
|
+
It also reviews how the app is put together and reports, as advice that never blocks (the `design/` rules): a screen calling a route the server does not have, a route no screen uses, a list query with no `.limit(...)`, money in a `real` column (use whole cents in an `integer`), a table with a `userId` and no index on it, a table nothing uses, a file over 400 lines, and `TODO` or placeholder text. Fix these as you go: give every list a limit and every user-owned table an index when you write them, and remove the starter's `todos` table, route, and tests once the app's own feature replaces them. When a fix would change or drop a column that may hold people's data, tell the person first.
|
|
171
|
+
|
|
172
|
+
After a deploy is live, `nerd deploy` asks the platform for a model review of the stored code and prints what it found (the `review/` rules): a value trusted from the browser, an action more people can do than should, nothing stopping the same thing happening twice, something that can be started and not finished, a rule enforced only on the screen, and a few more. It is a second opinion and can be wrong: read the code at the file and line before changing anything, fix the ones that are real, and say which you did not act on and why. Findings marked "for the person to decide" are theirs: put the question to them in plain words and wait. The person's own summary of the review is in the dashboard under Review; point them to it. Free plans include a few reviews a month, so a deploy may say it was not run.
|
|
173
|
+
|
|
168
174
|
## Scheduled jobs and email
|
|
169
175
|
|
|
170
176
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (df4afc8243ef); 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
|
-
|
|
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
|
|
|
@@ -161,6 +163,10 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
161
163
|
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
162
164
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
163
165
|
|
|
166
|
+
It also reviews how the app is put together and reports, as advice that never blocks (the `design/` rules): a screen calling a route the server does not have, a route no screen uses, a list query with no `.limit(...)`, money in a `real` column (use whole cents in an `integer`), a table with a `userId` and no index on it, a table nothing uses, a file over 400 lines, and `TODO` or placeholder text. Fix these as you go: give every list a limit and every user-owned table an index when you write them, and remove the starter's `todos` table, route, and tests once the app's own feature replaces them. When a fix would change or drop a column that may hold people's data, tell the person first.
|
|
167
|
+
|
|
168
|
+
After a deploy is live, `nerd deploy` asks the platform for a model review of the stored code and prints what it found (the `review/` rules): a value trusted from the browser, an action more people can do than should, nothing stopping the same thing happening twice, something that can be started and not finished, a rule enforced only on the screen, and a few more. It is a second opinion and can be wrong: read the code at the file and line before changing anything, fix the ones that are real, and say which you did not act on and why. Findings marked "for the person to decide" are theirs: put the question to them in plain words and wait. The person's own summary of the review is in the dashboard under Review; point them to it. Free plans include a few reviews a month, so a deploy may say it was not run.
|
|
169
|
+
|
|
164
170
|
## Scheduled jobs and email
|
|
165
171
|
|
|
166
172
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
package/template/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (df4afc8243ef); 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
|
-
|
|
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
|
|
|
@@ -161,6 +163,10 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
161
163
|
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
162
164
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
163
165
|
|
|
166
|
+
It also reviews how the app is put together and reports, as advice that never blocks (the `design/` rules): a screen calling a route the server does not have, a route no screen uses, a list query with no `.limit(...)`, money in a `real` column (use whole cents in an `integer`), a table with a `userId` and no index on it, a table nothing uses, a file over 400 lines, and `TODO` or placeholder text. Fix these as you go: give every list a limit and every user-owned table an index when you write them, and remove the starter's `todos` table, route, and tests once the app's own feature replaces them. When a fix would change or drop a column that may hold people's data, tell the person first.
|
|
167
|
+
|
|
168
|
+
After a deploy is live, `nerd deploy` asks the platform for a model review of the stored code and prints what it found (the `review/` rules): a value trusted from the browser, an action more people can do than should, nothing stopping the same thing happening twice, something that can be started and not finished, a rule enforced only on the screen, and a few more. It is a second opinion and can be wrong: read the code at the file and line before changing anything, fix the ones that are real, and say which you did not act on and why. Findings marked "for the person to decide" are theirs: put the question to them in plain words and wait. The person's own summary of the review is in the dashboard under Review; point them to it. Free plans include a few reviews a month, so a deploy may say it was not run.
|
|
169
|
+
|
|
164
170
|
## Scheduled jobs and email
|
|
165
171
|
|
|
166
172
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
package/template/GEMINI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (df4afc8243ef); 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
|
-
|
|
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
|
|
|
@@ -161,6 +163,10 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
161
163
|
- SQL built from values, `eval` or `new Function`, and password handling in server code (the `code/` rules; they block). It also flags, without blocking: tokens signed in app code, HTML built from data, redirects and fetches to an address from the request, `Math.random` for a secret, a body read without `readBody`, and an app that has not turned on `public` in `createNerdApp`.
|
|
162
164
|
- A failing test, a missing access sweep, or a route with an id that no second user was tried against (the `tests/` rules above).
|
|
163
165
|
|
|
166
|
+
It also reviews how the app is put together and reports, as advice that never blocks (the `design/` rules): a screen calling a route the server does not have, a route no screen uses, a list query with no `.limit(...)`, money in a `real` column (use whole cents in an `integer`), a table with a `userId` and no index on it, a table nothing uses, a file over 400 lines, and `TODO` or placeholder text. Fix these as you go: give every list a limit and every user-owned table an index when you write them, and remove the starter's `todos` table, route, and tests once the app's own feature replaces them. When a fix would change or drop a column that may hold people's data, tell the person first.
|
|
167
|
+
|
|
168
|
+
After a deploy is live, `nerd deploy` asks the platform for a model review of the stored code and prints what it found (the `review/` rules): a value trusted from the browser, an action more people can do than should, nothing stopping the same thing happening twice, something that can be started and not finished, a rule enforced only on the screen, and a few more. It is a second opinion and can be wrong: read the code at the file and line before changing anything, fix the ones that are real, and say which you did not act on and why. Findings marked "for the person to decide" are theirs: put the question to them in plain words and wait. The person's own summary of the review is in the dashboard under Review; point them to it. Free plans include a few reviews a month, so a deploy may say it was not run.
|
|
169
|
+
|
|
164
170
|
## Scheduled jobs and email
|
|
165
171
|
|
|
166
172
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "6",
|
|
3
3
|
"dialect": "sqlite",
|
|
4
|
-
"id": "
|
|
4
|
+
"id": "b47c99fc-b1b2-4e2c-97a9-6ac84d8b3947",
|
|
5
5
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
6
6
|
"tables": {
|
|
7
7
|
"todos": {
|
|
@@ -44,7 +44,15 @@
|
|
|
44
44
|
"autoincrement": false
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
|
-
"indexes": {
|
|
47
|
+
"indexes": {
|
|
48
|
+
"todos_user_idx": {
|
|
49
|
+
"name": "todos_user_idx",
|
|
50
|
+
"columns": [
|
|
51
|
+
"user_id"
|
|
52
|
+
],
|
|
53
|
+
"isUnique": false
|
|
54
|
+
}
|
|
55
|
+
},
|
|
48
56
|
"foreignKeys": {},
|
|
49
57
|
"compositePrimaryKeys": {},
|
|
50
58
|
"uniqueConstraints": {},
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
1
|
+
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
2
|
|
|
3
3
|
// Add your tables here, then run `pnpm db:generate` to create a migration.
|
|
4
|
-
// Rows that belong to a user should carry their id from c.var.user
|
|
4
|
+
// Rows that belong to a user should carry their id from c.var.user, with an index on it:
|
|
5
|
+
// every page that lists someone's rows looks them up by that column.
|
|
5
6
|
|
|
6
|
-
export const todos = sqliteTable(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
.notNull()
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
export const todos = sqliteTable(
|
|
8
|
+
"todos",
|
|
9
|
+
{
|
|
10
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
11
|
+
userId: text("user_id").notNull(),
|
|
12
|
+
title: text("title").notNull(),
|
|
13
|
+
done: integer("done", { mode: "boolean" }).notNull().default(false),
|
|
14
|
+
createdAt: text("created_at")
|
|
15
|
+
.notNull()
|
|
16
|
+
.$defaultFn(() => new Date().toISOString()),
|
|
17
|
+
},
|
|
18
|
+
(t) => [index("todos_user_idx").on(t.userId)],
|
|
19
|
+
);
|
|
@@ -18,6 +18,8 @@ todos.get("/", async (c) => {
|
|
|
18
18
|
.from(schema.todos)
|
|
19
19
|
.where(eq(schema.todos.userId, c.var.user!.id))
|
|
20
20
|
.orderBy(desc(schema.todos.createdAt))
|
|
21
|
+
// A list always has an end. Raise this, or add paging, when someone really has more.
|
|
22
|
+
.limit(200)
|
|
21
23
|
.all();
|
|
22
24
|
return c.json({ todos: rows });
|
|
23
25
|
});
|