nerdsystem 0.0.28 → 0.0.30
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 +2 -2
- package/dist/commands/deploy.d.ts +15 -3
- package/dist/commands/deploy.js +32 -6
- package/dist/commands/init.js +1 -1
- package/dist/commands/login.d.ts +6 -1
- package/dist/commands/login.js +12 -2
- package/dist/gates/review.d.ts +7 -0
- package/dist/gates/review.js +20 -7
- package/dist/util.d.ts +5 -0
- package/dist/util.js +13 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -25,7 +25,7 @@ import { findProjectRoot } from "./project.js";
|
|
|
25
25
|
import { CliError, log } from "./util.js";
|
|
26
26
|
const HELP = `nerd: start, check, and deploy a web app on nerdsystem
|
|
27
27
|
|
|
28
|
-
nerd login [--email you@x.com] Sign in or sign up: prints a link to approve in the browser (--email sends it; --token nsk_... for scripts)
|
|
28
|
+
nerd login [--email you@x.com] Sign in or sign up: prints a link to approve in the browser (--email sends it; --token nsk_... for scripts; --ref <code> records the partner who sent you)
|
|
29
29
|
nerd whoami Show the current user and orgs
|
|
30
30
|
nerd doctor Check Node, pnpm, git, login, and the project
|
|
31
31
|
nerd init <dir> [--org] [--name] [--describe "what it does"] Create an app and a project for it
|
|
@@ -100,7 +100,7 @@ export async function main(argv) {
|
|
|
100
100
|
const parse = (options, allowPositionals = true) => parseArgs({ args: rest, options: { json: BOOL, ...options }, allowPositionals, strict: cmd !== "dev" });
|
|
101
101
|
switch (cmd) {
|
|
102
102
|
case "login": {
|
|
103
|
-
const { values } = parse({ token: STR, "api-url": STR, email: STR });
|
|
103
|
+
const { values } = parse({ token: STR, "api-url": STR, email: STR, ref: STR });
|
|
104
104
|
return login(values);
|
|
105
105
|
}
|
|
106
106
|
case "doctor": {
|
|
@@ -18,10 +18,22 @@ export interface BuildOutput {
|
|
|
18
18
|
*/
|
|
19
19
|
export declare function dropLocalSecretCopies(build: BuildOutput): number;
|
|
20
20
|
/**
|
|
21
|
-
* The build's own output
|
|
22
|
-
* it wrote), which reads as if local secrets were about to be uploaded.
|
|
21
|
+
* The build's own output names that copy twice ("Using secrets defined in .dev.vars", then the file among
|
|
22
|
+
* what it wrote), which reads as if local secrets were about to be uploaded. This sentence is printed in
|
|
23
|
+
* place of those lines, so the explanation is where the alarm would have been. True before and after the copy is deleted.
|
|
23
24
|
*/
|
|
24
|
-
export declare const SECRET_COPY_NOTE = "The build tool
|
|
25
|
+
export declare const SECRET_COPY_NOTE = "The build tool makes a copy of your local secrets file (.dev.vars) in its output folder, for local previews. nerd deletes that copy when the build ends and never uploads it: the live app gets its secrets from `nerd secret set`.";
|
|
26
|
+
/**
|
|
27
|
+
* Every check of a deploy as one list, so its closing count is the one `nerd check` gives for the same
|
|
28
|
+
* code: the scan of the built output is in it, and a check that ran on the code and again on the built
|
|
29
|
+
* output (the policy's size limits) is one check, with the later answer.
|
|
30
|
+
*/
|
|
31
|
+
export declare function wholeRun(source: GateResult[], built: GateResult[]): GateResult[];
|
|
32
|
+
/** For the build's output: the lines that name the local secrets file become the one sentence above, said once. */
|
|
33
|
+
export declare function secretCopyLines(): {
|
|
34
|
+
line: (text: string) => string | null;
|
|
35
|
+
said: () => boolean;
|
|
36
|
+
};
|
|
25
37
|
export declare function readBuildOutput(root: string): BuildOutput;
|
|
26
38
|
export declare function applyRemoteMigrations(root: string, api: ApiClient): Promise<{
|
|
27
39
|
applied: string[];
|
package/dist/commands/deploy.js
CHANGED
|
@@ -43,10 +43,34 @@ export function dropLocalSecretCopies(build) {
|
|
|
43
43
|
return dropped;
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
|
-
* The build's own output
|
|
47
|
-
* it wrote), which reads as if local secrets were about to be uploaded.
|
|
46
|
+
* The build's own output names that copy twice ("Using secrets defined in .dev.vars", then the file among
|
|
47
|
+
* what it wrote), which reads as if local secrets were about to be uploaded. This sentence is printed in
|
|
48
|
+
* place of those lines, so the explanation is where the alarm would have been. True before and after the copy is deleted.
|
|
48
49
|
*/
|
|
49
|
-
export const SECRET_COPY_NOTE = "The build tool
|
|
50
|
+
export const SECRET_COPY_NOTE = "The build tool makes a copy of your local secrets file (.dev.vars) in its output folder, for local previews. nerd deletes that copy when the build ends and never uploads it: the live app gets its secrets from `nerd secret set`.";
|
|
51
|
+
/**
|
|
52
|
+
* Every check of a deploy as one list, so its closing count is the one `nerd check` gives for the same
|
|
53
|
+
* code: the scan of the built output is in it, and a check that ran on the code and again on the built
|
|
54
|
+
* output (the policy's size limits) is one check, with the later answer.
|
|
55
|
+
*/
|
|
56
|
+
export function wholeRun(source, built) {
|
|
57
|
+
return [...source.filter((r) => !built.some((b) => b.name === r.name)), ...built];
|
|
58
|
+
}
|
|
59
|
+
/** For the build's output: the lines that name the local secrets file become the one sentence above, said once. */
|
|
60
|
+
export function secretCopyLines() {
|
|
61
|
+
let said = false;
|
|
62
|
+
return {
|
|
63
|
+
line: (text) => {
|
|
64
|
+
if (!/\.dev\.vars\b/.test(text))
|
|
65
|
+
return text;
|
|
66
|
+
if (said)
|
|
67
|
+
return null;
|
|
68
|
+
said = true;
|
|
69
|
+
return SECRET_COPY_NOTE;
|
|
70
|
+
},
|
|
71
|
+
said: () => said,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
50
74
|
export function readBuildOutput(root) {
|
|
51
75
|
const dist = path.join(root, "dist");
|
|
52
76
|
if (!existsSync(dist))
|
|
@@ -241,18 +265,20 @@ export async function deploy(opts) {
|
|
|
241
265
|
const gates = await runGates(SOURCE_GATES, { root, knownSecrets, approvedSuppressions: await approvedSuppressions(root), log: (l) => log.info(l) }, { stopOnFail: true });
|
|
242
266
|
if (!gates.ok)
|
|
243
267
|
throw new CliError(`Blocked by: ${gates.failed.map((g) => g.title).join(", ")}. Fix the findings above, then deploy again.`, 2);
|
|
244
|
-
|
|
268
|
+
const secretCopy = secretCopyLines();
|
|
245
269
|
if (!opts["skip-build"]) {
|
|
246
270
|
log.step("Building with vite");
|
|
247
271
|
ensureDeps(root);
|
|
248
|
-
await runOrThrow("npx", ["vite", "build"], { cwd: root, stdoutToStderr: true });
|
|
272
|
+
await runOrThrow("npx", ["vite", "build"], { cwd: root, stdoutToStderr: true, stdoutLine: secretCopy.line });
|
|
249
273
|
}
|
|
250
274
|
const build = readBuildOutput(root);
|
|
251
|
-
|
|
275
|
+
// Said here only when the build's output did not carry it (a build made earlier, or a tool that words it differently).
|
|
276
|
+
if (dropLocalSecretCopies(build) && !secretCopy.said())
|
|
252
277
|
log.info(SECRET_COPY_NOTE);
|
|
253
278
|
const built = await runGates(BUILD_GATES, { root, build, knownSecrets, log: (l) => log.info(l) }, { stopOnFail: true });
|
|
254
279
|
if (!built.ok)
|
|
255
280
|
throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
|
|
281
|
+
log.ok(gateSummary(wholeRun(gates.results, built.results)));
|
|
256
282
|
const r = await shipBuild({ root, nerd, api, build, results: [...gates.results, ...built.results], opts });
|
|
257
283
|
// The app is live at this point. The platform says what the review on file holds, and now and then reads the code itself.
|
|
258
284
|
const reviewPage = `${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/review`;
|
package/dist/commands/init.js
CHANGED
|
@@ -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.30";
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
* dashboard (or from the email when --email is given), and the token arrives here. `--token` still
|
|
4
4
|
* works for scripts. A pending login is remembered, so running `nerd login` again after approving
|
|
5
5
|
* picks it up instead of starting over. `--email` with a different address than the pending login's
|
|
6
|
-
* starts a fresh one, so a typo does not lock the person out until the code expires.
|
|
6
|
+
* starts a fresh one, so a typo does not lock the person out until the code expires. `--ref` is the code
|
|
7
|
+
* of the partner who sent the person: it is recorded on the account this sign-in creates, and a code that
|
|
8
|
+
* is not one is left out with a line saying so, because nobody should fail to sign in over it.
|
|
7
9
|
*/
|
|
8
10
|
export declare function login(args: {
|
|
9
11
|
token?: string;
|
|
10
12
|
"api-url"?: string;
|
|
11
13
|
email?: string;
|
|
14
|
+
ref?: string;
|
|
12
15
|
json?: boolean;
|
|
13
16
|
}): Promise<void>;
|
|
17
|
+
/** The same shape the platform accepts (`lib/partners.ts` in the API); anything else is no code. */
|
|
18
|
+
export declare function partnerRef(value: string | undefined): string | null;
|
|
14
19
|
export declare function whoami(opts: {
|
|
15
20
|
json: boolean;
|
|
16
21
|
}): Promise<void>;
|
package/dist/commands/login.js
CHANGED
|
@@ -9,7 +9,9 @@ const WAIT_S = 90;
|
|
|
9
9
|
* dashboard (or from the email when --email is given), and the token arrives here. `--token` still
|
|
10
10
|
* works for scripts. A pending login is remembered, so running `nerd login` again after approving
|
|
11
11
|
* picks it up instead of starting over. `--email` with a different address than the pending login's
|
|
12
|
-
* starts a fresh one, so a typo does not lock the person out until the code expires.
|
|
12
|
+
* starts a fresh one, so a typo does not lock the person out until the code expires. `--ref` is the code
|
|
13
|
+
* of the partner who sent the person: it is recorded on the account this sign-in creates, and a code that
|
|
14
|
+
* is not one is left out with a line saying so, because nobody should fail to sign in over it.
|
|
13
15
|
*/
|
|
14
16
|
export async function login(args) {
|
|
15
17
|
const cfg = loadConfig();
|
|
@@ -25,7 +27,10 @@ export async function login(args) {
|
|
|
25
27
|
let pending = resumable ? saved : undefined;
|
|
26
28
|
if (!pending) {
|
|
27
29
|
const client = `nerd CLI on ${os.hostname()} (${os.platform()})`;
|
|
28
|
-
const
|
|
30
|
+
const ref = partnerRef(args.ref);
|
|
31
|
+
if (args.ref && !ref && !args.json)
|
|
32
|
+
log.warn("Leaving out --ref: a partner's code is 2 to 32 lowercase letters, digits, or hyphens. Signing in works the same without it.");
|
|
33
|
+
const start = await anon.post("/session/device", { client, ...(email ? { email } : {}), ...(ref ? { ref } : {}) });
|
|
29
34
|
pending = { apiUrl, deviceCode: start.deviceCode, userCode: start.userCode, verificationUrl: start.verificationUrl, interval: start.interval, expiresAt: Date.now() + start.expiresIn * 1000, ...(email ? { email } : {}) };
|
|
30
35
|
saveConfig({ ...cfg, pendingLogin: pending });
|
|
31
36
|
if (args.json)
|
|
@@ -70,6 +75,11 @@ export async function login(args) {
|
|
|
70
75
|
else
|
|
71
76
|
log.warn(msg);
|
|
72
77
|
}
|
|
78
|
+
/** The same shape the platform accepts (`lib/partners.ts` in the API); anything else is no code. */
|
|
79
|
+
export function partnerRef(value) {
|
|
80
|
+
const v = value?.trim().toLowerCase() ?? "";
|
|
81
|
+
return /^[a-z0-9][a-z0-9-]{1,31}$/.test(v) ? v : null;
|
|
82
|
+
}
|
|
73
83
|
function clearPending() {
|
|
74
84
|
const { pendingLogin: _drop, ...rest } = loadConfig();
|
|
75
85
|
saveConfig(rest);
|
package/dist/gates/review.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReviewDue } from "../commands/session-review.js";
|
|
1
2
|
import { type Gate } from "./types.js";
|
|
2
3
|
/**
|
|
3
4
|
* Brings the review of this app into the same list as every other check, so an agent meets it in the
|
|
@@ -8,3 +9,9 @@ import { type Gate } from "./types.js";
|
|
|
8
9
|
* Skipped without comment when there is no sign-in, no linked project, or no network.
|
|
9
10
|
*/
|
|
10
11
|
export declare const reviewGate: Gate;
|
|
12
|
+
/**
|
|
13
|
+
* The check's one line about the review. It says whether one is due and, when none is, why not: an agent
|
|
14
|
+
* that has just changed a build or config file cannot otherwise tell a review that was not needed from one
|
|
15
|
+
* that was forgotten.
|
|
16
|
+
*/
|
|
17
|
+
export declare function reviewSaid(open: number, when: string | undefined, due: ReviewDue | null): string;
|
package/dist/gates/review.js
CHANGED
|
@@ -35,18 +35,31 @@ export const reviewGate = {
|
|
|
35
35
|
return { rule_id: i.rule_id, severity: i.severity, audience: i.audience, file, line: Number(line) || undefined, risk: i.risk, fix: i.fix, verify: "nerd review report (say what you fixed), then nerd deploy", attempt: 1, fingerprint: `${i.rule_id}:${i.where}` };
|
|
36
36
|
});
|
|
37
37
|
const open = findings.length;
|
|
38
|
-
let due =
|
|
39
|
-
let first = false;
|
|
38
|
+
let due = null;
|
|
40
39
|
try {
|
|
41
|
-
|
|
40
|
+
due = reviewDue(ctx.root);
|
|
42
41
|
}
|
|
43
42
|
catch {
|
|
44
43
|
// a folder that cannot be listed has nothing to say here
|
|
45
44
|
}
|
|
46
|
-
if (due)
|
|
45
|
+
if (due?.due)
|
|
47
46
|
findings.push(finding("session/review-due", { verify: "nerd review report" }));
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
return result(this, verdict(findings), findings.length ? `${counts(findings)}: ${said.join("; ")}` : said.join("; "), findings, t);
|
|
47
|
+
const said = reviewSaid(open, got.reviewedAt ? localDay(got.reviewedAt) : undefined, due);
|
|
48
|
+
return result(this, verdict(findings), findings.length ? `${counts(findings)}: ${said}` : said, findings, t);
|
|
51
49
|
},
|
|
52
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* The check's one line about the review. It says whether one is due and, when none is, why not: an agent
|
|
53
|
+
* that has just changed a build or config file cannot otherwise tell a review that was not needed from one
|
|
54
|
+
* that was forgotten.
|
|
55
|
+
*/
|
|
56
|
+
export function reviewSaid(open, when, due) {
|
|
57
|
+
const findings = open ? `${open} open from the review of ${when}; a second opinion that can be wrong, so read the code first` : when ? `Nothing open from the review of ${when}` : "No review yet";
|
|
58
|
+
if (!due)
|
|
59
|
+
return findings;
|
|
60
|
+
if (due.due)
|
|
61
|
+
return `${findings}; ${due.first ? "no review has been reported from this folder: run nerd review checklist" : "code has changed since the last review: run nerd review checklist"}`;
|
|
62
|
+
if (due.first)
|
|
63
|
+
return findings;
|
|
64
|
+
return `${findings}; no review is due: the app's code under src/ is as it was last reviewed here (build and config files, tests, styles, the README and package.json do not ask for one)`;
|
|
65
|
+
}
|
package/dist/util.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface RunOptions {
|
|
|
22
22
|
env?: NodeJS.ProcessEnv;
|
|
23
23
|
/** Send the child's stdout to our stderr, so stdout stays reserved for `--json` output. */
|
|
24
24
|
stdoutToStderr?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* With `stdoutToStderr`: each line the child prints is passed through this first. Return the line to
|
|
27
|
+
* print it, another line to print that instead, or null to print nothing.
|
|
28
|
+
*/
|
|
29
|
+
stdoutLine?: (line: string) => string | null;
|
|
25
30
|
}
|
|
26
31
|
/**
|
|
27
32
|
* How a command is started. On Windows the tools an app uses (npx, pnpm) are `.cmd` files, which only
|
package/dist/util.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
4
5
|
export class CliError extends Error {
|
|
5
6
|
exitCode;
|
|
6
7
|
/** The API's error code (e.g. "not_found") when the error came from a response; callers branch on this, never on the message. */
|
|
@@ -40,9 +41,19 @@ export function run(cmd, args, opts = {}) {
|
|
|
40
41
|
return new Promise((resolve, reject) => {
|
|
41
42
|
const how = shellCommand(cmd, args);
|
|
42
43
|
const child = spawn(how.file, how.args, { cwd: opts.cwd, env: { ...process.env, ...opts.env }, stdio: ["inherit", opts.stdoutToStderr ? "pipe" : "inherit", "inherit"], shell: how.shell });
|
|
43
|
-
child.stdout
|
|
44
|
+
const perLine = child.stdout && opts.stdoutLine ? opts.stdoutLine : null;
|
|
45
|
+
if (perLine) {
|
|
46
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
47
|
+
const out = perLine(line);
|
|
48
|
+
if (out !== null)
|
|
49
|
+
process.stderr.write(`${out}\n`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
child.stdout?.pipe(process.stderr);
|
|
44
54
|
child.on("error", reject);
|
|
45
|
-
|
|
55
|
+
// "close" comes after the child's output has ended, so every line has been through `stdoutLine` by then.
|
|
56
|
+
child.on(perLine ? "close" : "exit", (code) => resolve(code ?? 1));
|
|
46
57
|
});
|
|
47
58
|
}
|
|
48
59
|
export async function runOrThrow(cmd, args, opts = {}) {
|