nerdsystem 0.0.27 → 0.0.28

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.
@@ -16,7 +16,12 @@ export interface BuildOutput {
16
16
  * never uploads it, and it is rewritten by every build, so the copy is removed here instead of being
17
17
  * reported on every run with nothing for anyone to do about it. The file in the app's own folder stays.
18
18
  */
19
- export declare function dropLocalSecretCopies(build: BuildOutput): void;
19
+ export declare function dropLocalSecretCopies(build: BuildOutput): number;
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.
23
+ */
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`.";
20
25
  export declare function readBuildOutput(root: string): BuildOutput;
21
26
  export declare function applyRemoteMigrations(root: string, api: ApiClient): Promise<{
22
27
  applied: string[];
@@ -32,11 +32,21 @@ function walk(dir, base = dir) {
32
32
  */
33
33
  export function dropLocalSecretCopies(build) {
34
34
  if (!existsSync(build.workerDir))
35
- return;
36
- for (const name of readdirSync(build.workerDir))
37
- if (ENV_FILES.test(name))
38
- rmSync(path.join(build.workerDir, name), { force: true });
35
+ return 0;
36
+ let dropped = 0;
37
+ for (const name of readdirSync(build.workerDir)) {
38
+ if (!ENV_FILES.test(name))
39
+ continue;
40
+ rmSync(path.join(build.workerDir, name), { force: true });
41
+ dropped++;
42
+ }
43
+ return dropped;
39
44
  }
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.
48
+ */
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`.";
40
50
  export function readBuildOutput(root) {
41
51
  const dist = path.join(root, "dist");
42
52
  if (!existsSync(dist))
@@ -238,7 +248,8 @@ export async function deploy(opts) {
238
248
  await runOrThrow("npx", ["vite", "build"], { cwd: root, stdoutToStderr: true });
239
249
  }
240
250
  const build = readBuildOutput(root);
241
- dropLocalSecretCopies(build);
251
+ if (dropLocalSecretCopies(build))
252
+ log.info(SECRET_COPY_NOTE);
242
253
  const built = await runGates(BUILD_GATES, { root, build, knownSecrets, log: (l) => log.info(l) }, { stopOnFail: true });
243
254
  if (!built.ok)
244
255
  throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
@@ -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.27";
195
+ const SDK_VERSION = "0.0.28";
@@ -20,7 +20,7 @@ export declare function reviewFiles(root: string): Hashes;
20
20
  /** Records the code as it stands as reviewed. */
21
21
  export declare function writeReviewed(root: string, files: Hashes): void;
22
22
  export interface ReviewDue {
23
- /** Whether code a review reads has changed since the last report from this folder (styles alone do not count). */
23
+ /** Whether the app's own code under `src/` has changed since the last report from this folder (a stylesheet, the README, or package.json alone does not count). */
24
24
  due: boolean;
25
25
  /** No report has been made from this folder: everything is unreviewed as far as this computer knows. */
26
26
  first: boolean;
@@ -64,7 +64,10 @@ export function reviewDue(root) {
64
64
  return { due: Object.keys(now).length > 0, first: true, changed: Object.keys(now).sort() };
65
65
  const changed = Object.keys(now).filter((p) => before[p] !== now[p]).sort();
66
66
  const removed = Object.keys(before).filter((p) => !(p in now));
67
- return { due: [...changed, ...removed].some((p) => !p.endsWith(".css")), first: false, changed };
67
+ // What asks for a review is the app's own code. A stylesheet, the README, or package.json alone (which
68
+ // `nerd upgrade` itself rewrites when it moves the SDK's version) is read with the next review and does not ask for one.
69
+ const code = (p) => p.startsWith("src/") && !p.endsWith(".css");
70
+ return { due: [...changed, ...removed].some(code), first: false, changed };
68
71
  }
69
72
  /** What is open from earlier reviews, as rule ids and places only. Empty when the platform cannot be asked. */
70
73
  async function openFindings(api, base) {
@@ -15,6 +15,12 @@ export interface Attention {
15
15
  items: AttentionItem[];
16
16
  /** When the review the findings come from was made, so a finding already fixed since is not a surprise. */
17
17
  reviewedAt?: string;
18
+ /**
19
+ * The app's own code has changed since the last review reported from this folder, so `nerd check` will
20
+ * ask for one before the next deploy. Not something to raise with the person, and never part of what a
21
+ * new session is shown: work in progress is the normal state of a folder.
22
+ */
23
+ reviewDue?: boolean;
18
24
  }
19
25
  /** What is open from the latest review of this app (its agent's own, or the platform's read), mapped through this CLI's own catalog. */
20
26
  export declare function reviewItems(api: ApiClient, base: string): Promise<{
@@ -32,6 +38,8 @@ export declare function reviewItems(api: ApiClient, base: string): Promise<{
32
38
  * that any of it exists, let alone open a dashboard to find it.
33
39
  */
34
40
  export declare function gatherAttention(root: string): Promise<Attention>;
41
+ /** Said by `nerd status` when asked directly, so it does not answer "nothing" a moment before `nerd check` asks for a review. */
42
+ export declare const REVIEW_DUE_NOTE = "The app's code as it is now has not been reviewed from this folder. Nothing to raise with the person: `nerd check` will ask for the review (`nerd review checklist`) before the next deploy.";
35
43
  /** Short enough to sit at the top of a session: what, where, and one line on what to do about each kind. */
36
44
  export declare function attentionText(a: Attention): string;
37
45
  /**
@@ -5,6 +5,7 @@ import { RULES } from "../gates/rules.js";
5
5
  import { currentVersions, pinned, versionFindings } from "../gates/versions.js";
6
6
  import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
7
7
  import { localDay, log } from "../util.js";
8
+ import { reviewDue } from "./session-review.js";
8
9
  import { cliVersion } from "./source.js";
9
10
  const within = (p, ms) => Promise.race([p.catch(() => null), new Promise((r) => setTimeout(() => r(null), ms))]);
10
11
  const item = (rule_id, extra = {}) => {
@@ -56,11 +57,19 @@ export async function gatherAttention(root) {
56
57
  items.push(it);
57
58
  }
58
59
  let reviewedAt;
60
+ let due = false;
59
61
  try {
60
62
  const nerd = readNerdJson(root);
61
63
  app = `${nerd.org}/${nerd.project}`;
62
64
  const api = ApiClient.fromConfig();
63
65
  const base = projectPath(nerd);
66
+ try {
67
+ // The same question the `review` check asks, under the same conditions (linked and signed in), so the two agree.
68
+ due = reviewDue(root).due;
69
+ }
70
+ catch {
71
+ // a folder that cannot be listed has nothing to say here
72
+ }
64
73
  const [review, project] = await Promise.all([reviewItems(api, base), within(api.get(base), 5000)]);
65
74
  items.push(...review.items);
66
75
  reviewedAt = review.reviewedAt;
@@ -78,8 +87,10 @@ export async function gatherAttention(root) {
78
87
  catch {
79
88
  // not linked, or not signed in: the versions above are all there is to say
80
89
  }
81
- return { app, items, ...(reviewedAt ? { reviewedAt } : {}) };
90
+ return { app, items, ...(reviewedAt ? { reviewedAt } : {}), ...(due ? { reviewDue: true } : {}) };
82
91
  }
92
+ /** Said by `nerd status` when asked directly, so it does not answer "nothing" a moment before `nerd check` asks for a review. */
93
+ export const REVIEW_DUE_NOTE = "The app's code as it is now has not been reviewed from this folder. Nothing to raise with the person: `nerd check` will ask for the review (`nerd review checklist`) before the next deploy.";
83
94
  /** Short enough to sit at the top of a session: what, where, and one line on what to do about each kind. */
84
95
  export function attentionText(a) {
85
96
  if (!a.items.length)
@@ -120,6 +131,9 @@ export async function status(opts) {
120
131
  process.stdout.write(`${text}\n`);
121
132
  else if (!opts.hook)
122
133
  log.ok(`Nothing needs attention${a.app ? ` for ${a.app}` : ""}.`);
134
+ // A new session is not told: unreviewed work in progress is not news, and the check asks at the right moment.
135
+ if (!opts.hook && a.reviewDue)
136
+ process.stdout.write(`${text ? "\n" : ""}${REVIEW_DUE_NOTE}\n`);
123
137
  }
124
138
  /** `nerd review`: only what is open from the latest review, with every fix written out. */
125
139
  export async function review(opts) {
@@ -43,6 +43,8 @@ export function upgradeProject(root, tpl, version) {
43
43
  // A workspace or file link is someone developing the platform itself; leave it alone.
44
44
  if (!current || !/^(workspace|file|link):/.test(current))
45
45
  pkg.dependencies["@nerdsystem/sdk"] = `^${version}`;
46
+ // Whether moving the SDK's version is all that happens to package.json: nothing below adds to it.
47
+ const versionOnly = JSON.stringify(pkg) !== before && Boolean(pkg.dependencies.zod && pkg.devDependencies.vitest && pkg.scripts.test);
46
48
  if (!pkg.dependencies.zod && tplPkg.dependencies?.zod)
47
49
  pkg.dependencies.zod = tplPkg.dependencies.zod;
48
50
  if (!pkg.devDependencies.vitest && tplPkg.devDependencies?.vitest)
@@ -116,6 +118,22 @@ export function upgradeProject(root, tpl, version) {
116
118
  const ignore = read(path.join(root, ".gitignore")) ?? "";
117
119
  if (!/^\.nerd\/?$/m.test(ignore))
118
120
  write(".gitignore", `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}.nerd/\n`);
121
+ // Nothing to do after an upgrade that found everything current. When the only change is the SDK's version
122
+ // in package.json, the steps say so: the longer wording reads as if the app's code were behind.
123
+ if (!changed.length)
124
+ return { from, to: version, changed, next: [] };
125
+ if (versionOnly && changed.length === 1 && changed[0] === "package.json") {
126
+ return {
127
+ from,
128
+ to: version,
129
+ changed,
130
+ next: [
131
+ "Run `pnpm install`.",
132
+ "Run `nerd check`. Only the SDK's version changed, so expect what it reported before. Anything new is something the newer version checks that the older one did not: work through it as usual.",
133
+ "Run `nerd deploy` when the check passes, and tell the person in a sentence that the app was brought up to date.",
134
+ ],
135
+ };
136
+ }
119
137
  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)."];
120
138
  if (startersWritten.includes("test/access.test.ts"))
121
139
  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.");
@@ -134,9 +152,8 @@ export async function upgrade(opts) {
134
152
  return;
135
153
  }
136
154
  if (!r.changed.length)
137
- log.ok(`Already current with nerd ${r.to}. Nothing was changed.`);
138
- else
139
- 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(", ")}`);
155
+ return log.ok(`Already current with nerd ${r.to}. Nothing was changed, and there is nothing to do.`);
156
+ 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(", ")}`);
140
157
  log.info("Your app's own code was not touched. Next:");
141
158
  r.next.forEach((n, i) => log.info(` ${i + 1}. ${n}`));
142
159
  }
@@ -13,5 +13,13 @@ export declare const typecheckGate: Gate;
13
13
  export declare function depsRuleFor(severity: AdvisorySeverity): string;
14
14
  export declare const depsGate: Gate;
15
15
  export declare const policyGate: Gate;
16
+ /**
17
+ * Whether the code at `index` sits in a route that only people with a given role can call: the route
18
+ * names `requireRole("admin")` itself, or its app does with `.use(...)` before the route is declared.
19
+ * Reading every user's rows is what such a route is for (an admin's list, a count across everyone), so
20
+ * a query there with no user filter is not reported. A route that asks only for the role every new
21
+ * user gets is open to everyone signed in and does not count. `text` must have its comments blanked.
22
+ */
23
+ export declare function inRoleOnlyRoute(text: string, index: number): boolean;
16
24
  /** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
17
25
  export declare const piiGate: Gate;
@@ -2,13 +2,15 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { staticChecks } from "../commands/check.js";
5
- import { lineOf, sourceFiles } from "./fs.js";
5
+ import { shellCommand } from "../util.js";
6
+ import { lineOf, sourceFiles, withoutComments } from "./fs.js";
6
7
  import { finding } from "./secrets.js";
7
8
  import { BLOCKED_PACKAGES, MINING_POOLS, normalizeSeverity, osvSeverity } from "./text.js";
8
9
  import { counts, result, verdict } from "./types.js";
9
10
  export function exec(cmd, args, cwd) {
10
11
  return new Promise((resolve) => {
11
- const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
12
+ const how = shellCommand(cmd, args);
13
+ const child = spawn(how.file, how.args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: how.shell });
12
14
  let stdout = "";
13
15
  let stderr = "";
14
16
  child.stdout.on("data", (d) => (stdout += d));
@@ -165,6 +167,50 @@ export const policyGate = {
165
167
  return result(this, verdict(findings), findings.length ? counts(findings) : "No policy violations", findings, t);
166
168
  },
167
169
  };
170
+ /** Roles named in a `requireRole(...)` call that not every signed-in person has. "member" is what a new user gets. */
171
+ function restrictedRoles(args) {
172
+ if (!/^\s*(["'`][\w-]+["'`]\s*,?\s*)+$/.test(args))
173
+ return false;
174
+ return ![...args.matchAll(/["'`]([\w-]+)["'`]/g)].some((m) => m[1] === "member");
175
+ }
176
+ /**
177
+ * Whether the code at `index` sits in a route that only people with a given role can call: the route
178
+ * names `requireRole("admin")` itself, or its app does with `.use(...)` before the route is declared.
179
+ * Reading every user's rows is what such a route is for (an admin's list, a count across everyone), so
180
+ * a query there with no user filter is not reported. A route that asks only for the role every new
181
+ * user gets is open to everyone signed in and does not count. `text` must have its comments blanked.
182
+ */
183
+ export function inRoleOnlyRoute(text, index) {
184
+ const routeRe = /\b(\w+)\.(get|post|put|patch|delete|all)\(\s*(["'`])(\/[^"'`\n]*|\*)\3/g;
185
+ let route = null;
186
+ for (let m = routeRe.exec(text); m && m.index < index; m = routeRe.exec(text))
187
+ route = { app: m[1], path: m[4], index: m.index, open: m.index + m[0].indexOf("(") };
188
+ if (!route)
189
+ return false;
190
+ // The code has to be inside that route's own call, not in a function written after it.
191
+ let depth = 0;
192
+ for (let i = route.open; i < index; i++) {
193
+ const ch = text.charCodeAt(i);
194
+ if (ch === 40)
195
+ depth++;
196
+ else if (ch === 41 && --depth === 0)
197
+ return false;
198
+ }
199
+ const inline = /\brequireRole\(([^)]*)\)/.exec(text.slice(route.open, index));
200
+ if (inline && restrictedRoles(inline[1]))
201
+ return true;
202
+ const useRe = /\b(\w+)\.use\(\s*(?:(["'`])([^"'`\n]*)\2\s*,\s*)?requireRole\(([^)]*)\)/g;
203
+ for (let u = useRe.exec(text); u && u.index < route.index; u = useRe.exec(text)) {
204
+ if (u[1] !== route.app || !restrictedRoles(u[4]))
205
+ continue;
206
+ const pattern = u[3];
207
+ if (pattern === undefined || pattern === "*" || pattern === "/*" || pattern === route.path)
208
+ return true;
209
+ if (pattern.endsWith("/*") && (route.path === pattern.slice(0, -2) || route.path.startsWith(pattern.slice(0, -1))))
210
+ return true;
211
+ }
212
+ return false;
213
+ }
168
214
  /** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
169
215
  export const piiGate = {
170
216
  name: "pii",
@@ -185,6 +231,7 @@ export const piiGate = {
185
231
  for (const f of sourceFiles(ctx.root)) {
186
232
  if (!/\.(ts|tsx|js|jsx|mjs)$/.test(f.rel) || !f.rel.startsWith("src/"))
187
233
  continue;
234
+ let bare;
188
235
  for (const c of checks) {
189
236
  if (c.files && !c.files.test(f.rel))
190
237
  continue;
@@ -193,6 +240,9 @@ export const piiGate = {
193
240
  while ((m = c.re.exec(f.text))) {
194
241
  if (c.rule === "pii/unscoped-user-table" && !/user_id|userId/.test(f.text))
195
242
  continue;
243
+ // An admin's view across everyone is meant to have no user filter.
244
+ if (c.rule === "pii/unscoped-user-table" && inRoleOnlyRoute((bare ??= withoutComments(f.text)), m.index))
245
+ continue;
196
246
  findings.push(finding(c.rule, { file: f.rel, line: lineOf(f.text, m.index) }));
197
247
  }
198
248
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { lineOf, sourceFiles } from "./fs.js";
3
+ import { lineOf, sourceFiles, withoutComments } from "./fs.js";
4
4
  import { finding } from "./secrets.js";
5
5
  import { counts, result, verdict } from "./types.js";
6
6
  /**
@@ -16,10 +16,6 @@ import { counts, result, verdict } from "./types.js";
16
16
  */
17
17
  const CODE = /\.(ts|tsx|js|jsx|mjs)$/;
18
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
19
  /** "/api/todos/:id" → a pattern; ":id" and "*" match one or more characters of a segment. */
24
20
  function routePattern(p) {
25
21
  return new RegExp(`^${p.split("/").map((seg) => (seg.startsWith(":") ? "[^/]+" : seg === "*" ? ".*" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("/")}/?$`);
@@ -4,4 +4,6 @@ export interface SourceFile {
4
4
  }
5
5
  /** Text files under root, skipping dependencies and build output. */
6
6
  export declare function sourceFiles(root: string): SourceFile[];
7
+ /** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
8
+ export declare function withoutComments(text: string): string;
7
9
  export declare function lineOf(text: string, index: number): number;
package/dist/gates/fs.js CHANGED
@@ -26,6 +26,10 @@ export function sourceFiles(root) {
26
26
  walk(root);
27
27
  return out;
28
28
  }
29
+ /** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
30
+ export function withoutComments(text) {
31
+ return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
32
+ }
29
33
  export function lineOf(text, index) {
30
34
  let line = 1;
31
35
  for (let i = 0; i < index && i < text.length; i++)
@@ -87,7 +87,7 @@ export const RULES = {
87
87
  "pii/log-request-body": { title: "Logging a request body", severity: "warn", audience: "agent", risk: "Request bodies often carry personal data; logging them keeps copies you did not mean to keep.", fix: "Log only the fields you need, never the whole body." },
88
88
  "pii/email-in-query-string": { title: "Email address in a URL", severity: "warn", audience: "agent", risk: "URLs end up in browser history, logs, and referrer headers.", fix: "Send it in the request body or use an id." },
89
89
  "pii/plaintext-password-column": { title: "A password column in the schema", severity: "warn", audience: "agent", risk: "Storing passwords yourself is unnecessary here and easy to get wrong.", fix: "Remove the column; the platform handles passwords. If you must store a secret, store a hash." },
90
- "pii/unscoped-user-table": { title: "Query on user data without a user filter", severity: "warn", audience: "agent", risk: "One user could see or change another user's rows.", fix: "Add .where(eq(table.userId, c.var.user.id)) to every read and write on tables with a user_id column.", example: { wrong: "db.select().from(schema.notes).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, c.var.user!.id)).all()" } },
90
+ "pii/unscoped-user-table": { title: "Query on user data without a user filter", severity: "warn", audience: "agent", risk: "One user could see or change another user's rows.", fix: "Add .where(eq(table.userId, c.var.user.id)) to every read and write on tables with a user_id column. A route that only a role such as admin can call (`requireRole(\"admin\")` on the route or on its app) is expected to read across users and is not reported.", example: { wrong: "db.select().from(schema.notes).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, c.var.user!.id)).all()" } },
91
91
  "pii/wildcard-cors": { title: "Wildcard CORS with cookies", severity: "warn", audience: "agent", risk: "Any website could call your API with the visitor's cookies.", fix: "Remove the wildcard. Same-origin requests need no CORS headers." },
92
92
  "suppressions/ignore-comment": { title: "A scanner ignore comment was added", severity: "block", audience: "human", risk: "Ignore comments turn off a check for that line. They are sometimes right and sometimes a way to hide a problem.", fix: "Review the line in the dashboard and approve the suppression if it is correct; the agent must not add or approve suppressions itself." },
93
93
  "suppressions/ignore-file": { title: "A scanner ignore file was changed", severity: "block", audience: "human", risk: "Ignore files silence checks across the whole project.", fix: "Review the change in the dashboard and approve it if it is correct." },
@@ -1,4 +1,4 @@
1
- import { lineOf, sourceFiles } from "./fs.js";
1
+ import { lineOf, sourceFiles, withoutComments } from "./fs.js";
2
2
  import { finding } from "./secrets.js";
3
3
  import { counts, result, verdict } from "./types.js";
4
4
  const SERVER = /^src\/server\//;
@@ -21,10 +21,6 @@ const CHECKS = [
21
21
  // An unchecked body: c.req.json() used directly instead of readBody(c, schema).
22
22
  { rule: "code/unvalidated-body", re: /\bc\.req\.(json|parseBody|formData)\(\)/g, files: SERVER },
23
23
  ];
24
- /** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
25
- function withoutComments(text) {
26
- return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
27
- }
28
24
  export const staticGate = {
29
25
  name: "static",
30
26
  title: "Unsafe code patterns",
package/dist/util.d.ts CHANGED
@@ -23,6 +23,17 @@ export interface RunOptions {
23
23
  /** Send the child's stdout to our stderr, so stdout stays reserved for `--json` output. */
24
24
  stdoutToStderr?: boolean;
25
25
  }
26
+ /**
27
+ * How a command is started. On Windows the tools an app uses (npx, pnpm) are `.cmd` files, which only
28
+ * start through the shell, and a shell takes one line. Handed a separate list of arguments, Node joins
29
+ * them with spaces and no quotes (and prints the DEP0190 warning about it), so a folder with a space in
30
+ * its name arrives as two arguments. The line is built here instead, with quotes round whatever needs them.
31
+ */
32
+ export declare function shellCommand(cmd: string, args: string[], platform?: NodeJS.Platform): {
33
+ file: string;
34
+ args: string[];
35
+ shell: boolean;
36
+ };
26
37
  /** Runs a command, streaming output. Resolves with the exit code. */
27
38
  export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<number>;
28
39
  export declare function runOrThrow(cmd: string, args: string[], opts?: RunOptions): Promise<void>;
package/dist/util.js CHANGED
@@ -22,10 +22,24 @@ export const log = {
22
22
  ok: (msg) => console.error(`\x1b[32m✓\x1b[0m ${msg}`),
23
23
  warn: (msg) => console.error(`\x1b[33m!\x1b[0m ${msg}`),
24
24
  };
25
+ /**
26
+ * How a command is started. On Windows the tools an app uses (npx, pnpm) are `.cmd` files, which only
27
+ * start through the shell, and a shell takes one line. Handed a separate list of arguments, Node joins
28
+ * them with spaces and no quotes (and prints the DEP0190 warning about it), so a folder with a space in
29
+ * its name arrives as two arguments. The line is built here instead, with quotes round whatever needs them.
30
+ */
31
+ export function shellCommand(cmd, args, platform = process.platform) {
32
+ if (platform !== "win32")
33
+ return { file: cmd, args, shell: false };
34
+ // Inside quotes, a program's own argument parser reads backslashes literally unless a quote follows them.
35
+ const quote = (a) => (/^[\w./:=@\\,+-]+$/.test(a) ? a : `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`);
36
+ return { file: [cmd, ...args].map(quote).join(" "), args: [], shell: true };
37
+ }
25
38
  /** Runs a command, streaming output. Resolves with the exit code. */
26
39
  export function run(cmd, args, opts = {}) {
27
40
  return new Promise((resolve, reject) => {
28
- const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env }, stdio: ["inherit", opts.stdoutToStderr ? "pipe" : "inherit", "inherit"], shell: process.platform === "win32" });
41
+ const how = shellCommand(cmd, args);
42
+ 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 });
29
43
  child.stdout?.pipe(process.stderr);
30
44
  child.on("error", reject);
31
45
  child.on("exit", (code) => resolve(code ?? 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nerdsystem",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
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": {