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 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 lists that copy ("Using secrets defined in .dev.vars", then the file among what
22
- * it wrote), which reads as if local secrets were about to be uploaded. Said once, right after the build.
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 listed a copy of your local secrets file (.dev.vars) in its output folder; it makes one for local previews. nerd has deleted that copy. It is never uploaded: the live app gets its secrets from `nerd secret set`.";
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[];
@@ -43,10 +43,34 @@ export function dropLocalSecretCopies(build) {
43
43
  return dropped;
44
44
  }
45
45
  /**
46
- * The build's own output lists that copy ("Using secrets defined in .dev.vars", then the file among what
47
- * it wrote), which reads as if local secrets were about to be uploaded. Said once, right after the build.
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 listed a copy of your local secrets file (.dev.vars) in its output folder; it makes one for local previews. nerd has deleted that copy. It is never uploaded: the live app gets its secrets from `nerd secret set`.";
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
- log.ok(gateSummary(gates.results));
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
- if (dropLocalSecretCopies(build))
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`;
@@ -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.28";
195
+ const SDK_VERSION = "0.0.30";
@@ -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>;
@@ -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 start = await anon.post("/session/device", { client, ...(email ? { email } : {}) });
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);
@@ -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;
@@ -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 = false;
39
- let first = false;
38
+ let due = null;
40
39
  try {
41
- ({ due, first } = reviewDue(ctx.root));
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 when = got.reviewedAt ? localDay(got.reviewedAt) : undefined;
49
- const said = [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", ...(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"] : [])];
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?.pipe(process.stderr);
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
- child.on("exit", (code) => resolve(code ?? 1));
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 = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nerdsystem",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "description": "nerd: start, check, and deploy a web app on nerdsystem from the terminal or a coding agent.",
5
5
  "type": "module",
6
6
  "bin": {