@namzu/cli 29.1.0 → 30.0.0

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/commands/exec.d.ts.map +1 -1
  3. package/dist/commands/exec.js +3 -0
  4. package/dist/commands/exec.js.map +1 -1
  5. package/dist/context/environment.d.ts +26 -3
  6. package/dist/context/environment.d.ts.map +1 -1
  7. package/dist/context/environment.js +68 -9
  8. package/dist/context/environment.js.map +1 -1
  9. package/dist/integrations/notifications/desktop.d.ts +2 -0
  10. package/dist/integrations/notifications/desktop.d.ts.map +1 -1
  11. package/dist/integrations/notifications/desktop.js +6 -5
  12. package/dist/integrations/notifications/desktop.js.map +1 -1
  13. package/dist/integrations/plugins/runtime.d.ts +8 -2
  14. package/dist/integrations/plugins/runtime.d.ts.map +1 -1
  15. package/dist/integrations/plugins/runtime.js +14 -8
  16. package/dist/integrations/plugins/runtime.js.map +1 -1
  17. package/dist/integrations/providers/harness-credentials.d.ts +9 -4
  18. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  19. package/dist/integrations/providers/harness-credentials.js +16 -8
  20. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  21. package/dist/integrations/web/open-url.d.ts +24 -0
  22. package/dist/integrations/web/open-url.d.ts.map +1 -0
  23. package/dist/integrations/web/open-url.js +73 -0
  24. package/dist/integrations/web/open-url.js.map +1 -0
  25. package/dist/schedule/build.d.ts +7 -0
  26. package/dist/schedule/build.d.ts.map +1 -1
  27. package/dist/schedule/build.js +21 -0
  28. package/dist/schedule/build.js.map +1 -1
  29. package/dist/schedule/changes.d.ts +6 -0
  30. package/dist/schedule/changes.d.ts.map +1 -1
  31. package/dist/schedule/changes.js +10 -0
  32. package/dist/schedule/changes.js.map +1 -1
  33. package/dist/schedule/commands/add.d.ts.map +1 -1
  34. package/dist/schedule/commands/add.js +2 -14
  35. package/dist/schedule/commands/add.js.map +1 -1
  36. package/dist/schedule/commands/lifecycle.d.ts.map +1 -1
  37. package/dist/schedule/commands/lifecycle.js +2 -1
  38. package/dist/schedule/commands/lifecycle.js.map +1 -1
  39. package/dist/schedule/commands/list.d.ts.map +1 -1
  40. package/dist/schedule/commands/list.js +6 -2
  41. package/dist/schedule/commands/list.js.map +1 -1
  42. package/dist/schedule/daemon/daemon.d.ts.map +1 -1
  43. package/dist/schedule/daemon/daemon.js +5 -0
  44. package/dist/schedule/daemon/daemon.js.map +1 -1
  45. package/dist/schedule/daemon/notify.d.ts +8 -1
  46. package/dist/schedule/daemon/notify.d.ts.map +1 -1
  47. package/dist/schedule/daemon/notify.js +28 -2
  48. package/dist/schedule/daemon/notify.js.map +1 -1
  49. package/dist/schedule/env.d.ts +8 -4
  50. package/dist/schedule/env.d.ts.map +1 -1
  51. package/dist/schedule/env.js +13 -6
  52. package/dist/schedule/env.js.map +1 -1
  53. package/dist/schedule/fire/calls.d.ts +49 -0
  54. package/dist/schedule/fire/calls.d.ts.map +1 -0
  55. package/dist/schedule/fire/calls.js +114 -0
  56. package/dist/schedule/fire/calls.js.map +1 -0
  57. package/dist/schedule/fire/fire.d.ts.map +1 -1
  58. package/dist/schedule/fire/fire.js +9 -1
  59. package/dist/schedule/fire/fire.js.map +1 -1
  60. package/dist/schedule/floor.d.ts +27 -7
  61. package/dist/schedule/floor.d.ts.map +1 -1
  62. package/dist/schedule/floor.js +339 -101
  63. package/dist/schedule/floor.js.map +1 -1
  64. package/dist/schedule/service/index.d.ts +1 -1
  65. package/dist/schedule/service/index.d.ts.map +1 -1
  66. package/dist/schedule/service/index.js +20 -11
  67. package/dist/schedule/service/index.js.map +1 -1
  68. package/dist/schedule/service/windows-task.d.ts +7 -0
  69. package/dist/schedule/service/windows-task.d.ts.map +1 -1
  70. package/dist/schedule/service/windows-task.js +65 -0
  71. package/dist/schedule/service/windows-task.js.map +1 -1
  72. package/dist/schedule/service/wsl.d.ts +13 -1
  73. package/dist/schedule/service/wsl.d.ts.map +1 -1
  74. package/dist/schedule/service/wsl.js +16 -6
  75. package/dist/schedule/service/wsl.js.map +1 -1
  76. package/dist/schedule/types.d.ts +27 -0
  77. package/dist/schedule/types.d.ts.map +1 -1
  78. package/dist/skills/directory.d.ts +38 -0
  79. package/dist/skills/directory.d.ts.map +1 -0
  80. package/dist/skills/directory.js +51 -0
  81. package/dist/skills/directory.js.map +1 -0
  82. package/dist/tui/App.d.ts.map +1 -1
  83. package/dist/tui/App.js +20 -4
  84. package/dist/tui/App.js.map +1 -1
  85. package/dist/tui/PermissionOverlay.d.ts +13 -1
  86. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  87. package/dist/tui/PermissionOverlay.js +26 -4
  88. package/dist/tui/PermissionOverlay.js.map +1 -1
  89. package/dist/tui/agent.d.ts +28 -6
  90. package/dist/tui/agent.d.ts.map +1 -1
  91. package/dist/tui/agent.js +58 -16
  92. package/dist/tui/agent.js.map +1 -1
  93. package/dist/tui/open-browser.d.ts +29 -1
  94. package/dist/tui/open-browser.d.ts.map +1 -1
  95. package/dist/tui/open-browser.js +83 -11
  96. package/dist/tui/open-browser.js.map +1 -1
  97. package/dist/tui/permission-review.d.ts +10 -1
  98. package/dist/tui/permission-review.d.ts.map +1 -1
  99. package/dist/tui/permission-review.js +175 -18
  100. package/dist/tui/permission-review.js.map +1 -1
  101. package/dist/tui/schedule/host-commands.d.ts.map +1 -1
  102. package/dist/tui/schedule/host-commands.js +2 -1
  103. package/dist/tui/schedule/host-commands.js.map +1 -1
  104. package/dist/tui/schedule/tool-host.d.ts +15 -1
  105. package/dist/tui/schedule/tool-host.d.ts.map +1 -1
  106. package/dist/tui/schedule/tool-host.js +259 -74
  107. package/dist/tui/schedule/tool-host.js.map +1 -1
  108. package/package.json +6 -6
  109. package/skills/README.md +5 -0
  110. package/skills/schedule-task/SKILL.md +8 -2
@@ -51,13 +51,19 @@
51
51
  * - **What the lexer cannot account for** — an opaque line (a command
52
52
  * substitution, `eval`-like constructs, a syntax error, a construct it does
53
53
  * not model), a command whose name expands, or a program that runs text as
54
- * code (`sh` reading its input, `sudo bash -c`, `python`, `xargs`, `watch`
55
- * …) — is denied when the line's text mentions anything the floor
56
- * protects: a scheduler word, `namzu`, NAMZU_HOME's name or a path into it.
57
- * That is the tripwire, deliberately textual and cheap. Text such a program
58
- * may run — an argument, a here-string, a here-document's body — is also
59
- * read as a command line of its own, so an escape only the inner shell
60
- * undoes does not hide it.
54
+ * code (`sh` reading its input, `sudo bash -c`, `powershell`, `python`,
55
+ * `xargs`, `watch` …) — is denied when the line's text holds something that
56
+ * could reach what the floor protects (see {@link Floor.tripwire}): a
57
+ * `schedule` subcommand that changes something with the CLI or an
58
+ * expansion in reach, the scheduler service's name, a service tool with a
59
+ * namzu name, a process killer, `NAMZU_HOME` by name or path, or the
60
+ * Windows browser's profile folder. That is the tripwire, deliberately
61
+ * textual and cheap. The product's name on its own is not in that list: a
62
+ * message box saying "Namzu: scheduled job running" reaches nothing, and a
63
+ * run that showed one was refused for it. Text such a program may run — an
64
+ * argument, a here-string, a here-document's body — is also read as a
65
+ * command line of its own, so an escape only the inner shell undoes does
66
+ * not hide it.
61
67
 
62
68
  * - **The Windows browser's profiles**, on the same words, spellings and
63
69
  * directories: a path whose segments run `AppData/Local/namzu` (any user,
@@ -77,13 +83,22 @@
77
83
  *
78
84
  * Everything here is linear in the input.
79
85
  */
80
- import { builtinCommandArguments, commandArgumentOf, lexShellCommandLine, } from '@namzu/sdk';
86
+ import { builtinCommandArguments, commandArgumentOf, lexShellCommandLine, nestedShellCommand, } from '@namzu/sdk';
81
87
  /** Scheduler verbs a run may use: they read, they change nothing. */
82
88
  export const READ_ONLY_VERBS = ['list', 'show', 'status', 'history', 'logs'];
83
89
  /** The floor's reason for denying a call, or null: what the rule below decides on. */
84
90
  export function scheduledRunFloorVerdict(options) {
85
91
  const floor = new Floor(options);
86
- return (call) => floor.verdict(call);
92
+ return (call) => floor.find(call)?.reason ?? null;
93
+ }
94
+ /** The floor's finding for a call, or null: the reason and what matched. */
95
+ export function scheduledRunFloorFinding(options) {
96
+ const floor = new Floor(options);
97
+ return (call) => floor.find(call);
98
+ }
99
+ /** What a refusal says: the rule that matched, and that another wording will not help. */
100
+ export function floorRefusal(finding) {
101
+ return `the scheduled-run floor refused this call: ${finding.detail}. It holds for every scheduled run, so rewording the call will not help`;
87
102
  }
88
103
  /**
89
104
  * Rules this module made, which decide `deny` or nothing: a rule here never
@@ -100,7 +115,13 @@ export function scheduledRunFloorRule(options) {
100
115
  const rule = {
101
116
  type: 'predicate',
102
117
  description: `the scheduled-run floor: a scheduled run may not stop, disable or remove the scheduler, run a \`namzu schedule\` subcommand other than ${READ_ONLY_VERBS.join(', ')}, or name NAMZU_HOME (${options.namzuHome}) or the Windows browser's profile folder (%LOCALAPPDATA%\\namzu) in any argument. It holds for every scheduled run, so rewording the call will not help`,
103
- decide: (call) => (floor.verdict(call) === null ? null : 'deny'),
118
+ decide: (call) => (floor.find(call) === null ? null : 'deny'),
119
+ // Asked only after `decide` denied, so the reading is done twice for a
120
+ // refused call and once for every other.
121
+ describe: (call) => {
122
+ const finding = floor.find(call);
123
+ return finding ? floorRefusal(finding) : null;
124
+ },
104
125
  };
105
126
  DENY_ONLY.add(rule);
106
127
  return rule;
@@ -164,19 +185,8 @@ const STRING_OPTIONS = {
164
185
  sudo: /^-(?:[a-z]*[si][a-z]*|-shell|-login)$/,
165
186
  env: /^-(?:[a-z]*s[a-z]*|-split-string(?:=.*)?)$/i,
166
187
  };
167
- /** Words that, mentioned anywhere in a line the lexer cannot account for, deny it. */
168
- const SENSITIVE = [
169
- 'namzu',
170
- 'schedul',
171
- 'systemctl',
172
- 'launchctl',
173
- 'schtasks',
174
- 'pkill',
175
- 'killall',
176
- 'busctl',
177
- 'dbus-send',
178
- 'gdbus',
179
- ];
188
+ /** What a refusal says about a `schedule` verb that is not read-only. */
189
+ const NOT_READ_ONLY = `a \`namzu schedule\` subcommand other than ${READ_ONLY_VERBS.join(', ')}`;
180
190
  const NAME_CHAR = /[a-z0-9._-]/;
181
191
  function lower(text) {
182
192
  return text.toLowerCase();
@@ -187,6 +197,12 @@ function commandName(value) {
187
197
  const base = lower(cut < 0 ? value : value.slice(cut + 1));
188
198
  return base.endsWith('.exe') ? base.slice(0, -4) : base;
189
199
  }
200
+ /** Text as a refusal quotes it: on one line, cut to `max` characters, in backticks. */
201
+ function shown(text, max = 80) {
202
+ const flat = text.replace(/\s*\n\s*/g, ' ⏎ ');
203
+ const cut = [...flat].length > max ? `${[...flat].slice(0, max - 1).join('')}…` : flat;
204
+ return cut.includes('`') ? `\`\` ${cut} \`\`` : `\`${cut}\``;
205
+ }
190
206
  /**
191
207
  * `$"…"` read as its text. The lexer leaves locale quoting as written and
192
208
  * flags the word, because a translation catalogue could change it; the text
@@ -214,9 +230,10 @@ function wordsOf(command, catalogue) {
214
230
  // ---------------------------------------------------------------------------
215
231
  // Scheduler commands
216
232
  /**
217
- * Whether one command's words, in order, reach the scheduler. Each role is
218
- * taken at its first place after the one before; for a question of "is
219
- * there such a subsequence" that loses nothing, and it is one pass.
233
+ * Whether one command's words, in order, reach the scheduler, and if so what
234
+ * they do, in words. Each role is taken at its first place after the one
235
+ * before; for a question of "is there such a subsequence" that loses
236
+ * nothing, and it is one pass.
220
237
  */
221
238
  function reachesScheduler(words, daemonCommandLine) {
222
239
  const n = words.length;
@@ -232,50 +249,51 @@ function reachesScheduler(words, daemonCommandLine) {
232
249
  const systemctl = find(0, named('systemctl'));
233
250
  if (systemctl >= 0) {
234
251
  if (find(systemctl + 1, (w) => !w.wild && SYSTEMCTL_VERBS_ALONE.has(w.text)) >= 0)
235
- return true;
252
+ return 'stops every service, the scheduler with them';
236
253
  const verb = find(systemctl + 1, (w) => w.wild || SYSTEMCTL_VERBS_WITH_UNIT.has(w.text));
237
254
  // systemctl matches unit names against a glob.
238
255
  if (verb >= 0 && find(verb + 1, (u) => namesNamzu(u) || /[*?[]/.test(u.text)) >= 0)
239
- return true;
256
+ return "stops or disables the scheduler's service";
240
257
  }
241
258
  // launchctl <verb> <label or plist>
242
259
  const launchctl = find(0, named('launchctl'));
243
260
  if (launchctl >= 0) {
244
261
  const verb = find(launchctl + 1, (w) => w.wild || LAUNCHCTL_VERBS.has(w.text));
245
262
  if (verb >= 0 && find(verb + 1, namesNamzu) >= 0)
246
- return true;
263
+ return "removes the scheduler's launchd agent";
247
264
  }
248
265
  // schtasks, whose switches come in any order.
249
266
  const schtasks = find(0, named('schtasks'));
250
267
  if (schtasks >= 0) {
251
268
  const rest = words.slice(schtasks + 1);
252
269
  if (rest.some((w) => w.wild || SCHTASKS_SWITCH.test(w.text)) && rest.some(namesNamzu))
253
- return true;
270
+ return "deletes, changes or ends the scheduler's task";
254
271
  }
255
272
  // pkill / killall: a pattern that could match the daemon's process.
256
273
  const killer = find(0, (w) => named('pkill')(w) || named('killall')(w));
257
274
  if (killer >= 0) {
275
+ const kills = "has a pattern that could match the scheduler's process";
258
276
  const full = words
259
277
  .slice(killer + 1)
260
278
  .some((w) => /^-[a-z]*f/.test(w.text) || w.text === '--full');
261
279
  for (let i = killer + 1; i < n; i++) {
262
280
  const w = words[i];
263
281
  if (w.wild)
264
- return true;
282
+ return kills;
265
283
  if (w.text.startsWith('-'))
266
284
  continue;
267
285
  if (/[.*+?^$|()[\]{}\\]/.test(w.text))
268
- return true;
286
+ return kills;
269
287
  if (w.text.includes('namzu') || w.text.includes('schedul'))
270
- return true;
288
+ return kills;
271
289
  if ('node'.includes(w.text) || (full && daemonCommandLine.includes(w.text)))
272
- return true;
290
+ return kills;
273
291
  }
274
292
  }
275
293
  // A D-Bus call to systemd naming the unit.
276
294
  const dbus = find(0, (w) => DBUS_TOOLS.has(commandName(w.text)));
277
295
  if (dbus >= 0 && find(dbus + 1, namesNamzu) >= 0)
278
- return true;
296
+ return 'calls systemd about a namzu unit';
279
297
  // The CLI, by name (`namzu`, a path to it, `npx @namzu/cli`) or through
280
298
  // its entry script or a JavaScript runtime: `schedule` and then anything
281
299
  // but a read-only verb.
@@ -283,7 +301,7 @@ function reachesScheduler(words, daemonCommandLine) {
283
301
  const weak = find(0, (w) => CLI_ENTRY.test(commandName(w.text)) || JS_RUNTIMES.has(commandName(w.text)));
284
302
  // After the CLI's own name, a word that expands may be `schedule stop`.
285
303
  if (strong >= 0 && find(strong + 1, (w) => w.wild) >= 0)
286
- return true;
304
+ return 'passes the CLI a word that expands at runtime, which could be a `schedule` subcommand';
287
305
  const cli = strong < 0 ? weak : weak < 0 ? strong : Math.min(strong, weak);
288
306
  if (cli >= 0) {
289
307
  for (let i = cli + 1; i < n; i++) {
@@ -292,40 +310,49 @@ function reachesScheduler(words, daemonCommandLine) {
292
310
  const verb = words[i + 1];
293
311
  if (verb === undefined)
294
312
  continue;
295
- if (verb.wild || !READ_ONLY_VERBS.includes(verb.text))
296
- return true;
313
+ if (verb.wild)
314
+ return 'runs a `schedule` subcommand that expands at runtime';
315
+ if (!READ_ONLY_VERBS.includes(verb.text))
316
+ return `runs \`schedule ${verb.text}\`, ${NOT_READ_ONLY}`;
297
317
  }
298
318
  }
299
- return false;
319
+ return null;
300
320
  }
301
321
  /**
302
- * Whether a command runs text the lexer did not read as commands: a shell
303
- * the lexer did not follow, an interpreter, `xargs`, `sudo -s`, `env -S`.
322
+ * Whether a command runs text the lexer did not read as commands — a shell
323
+ * the lexer did not follow, an interpreter, `xargs`, `sudo -s`, `env -S` —
324
+ * and if so which, in words.
304
325
  */
305
326
  function runsUnreadText(command) {
306
327
  const words = command.words;
307
328
  const head = command.assignments;
308
329
  const names = words.map((w) => (w.expands ? '' : commandName(w.value)));
309
- // The lexer reads the payload of a shell at the head of its command run
310
- // with `-c`. Anywhere else, or without `-c`, it did not.
311
- const dashAt = words.findIndex((w, i) => i > head && !w.expands && /^-[a-z]*c[a-z]*$/.test(w.value));
312
- // A payload that expands is not one the lexer could read.
313
- const dashC = dashAt > 0 && words[dashAt + 1] !== undefined && !words[dashAt + 1]?.expands;
330
+ // The shell at the head whose `-c` payload the lexer read, and `busybox`
331
+ // in front of it: exactly the lexer's own decision. Any other shell —
332
+ // `powershell -c`, `fish -c`, `bash.exe -c`, one given a script — runs
333
+ // text the lexer never read, whatever its options look like.
334
+ const nested = nestedShellCommand(words.slice(head));
335
+ const readTo = nested !== null && 'payload' in nested
336
+ ? head + (commandName(words[head].value) === 'busybox' ? 1 : 0)
337
+ : -1;
314
338
  for (let i = 0; i < words.length; i++) {
315
339
  const name = names[i];
316
340
  if (name === '')
317
341
  continue;
342
+ const program = shown(words[i].value);
318
343
  if (INTERPRETERS.test(name))
319
- return true;
344
+ return `${program} runs text as code`;
320
345
  if (i === head && name === '.')
321
- return true;
322
- if (SHELLS.has(name) && !(i === head && dashC))
323
- return true;
346
+ return '`.` runs a file as commands';
347
+ if (SHELLS.has(name) && !(i >= head && i <= readTo))
348
+ return `${program} runs commands the floor does not read`;
324
349
  }
325
- for (const [program, option] of Object.entries(STRING_OPTIONS))
326
- if (names.includes(program) && words.some((w) => !w.expands && option.test(w.value)))
327
- return true;
328
- return false;
350
+ for (const [program, option] of Object.entries(STRING_OPTIONS)) {
351
+ const flag = words.find((w) => !w.expands && option.test(w.value));
352
+ if (names.includes(program) && flag !== undefined)
353
+ return `\`${program} ${flag.value}\` runs a string as commands`;
354
+ }
355
+ return null;
329
356
  }
330
357
  // ---------------------------------------------------------------------------
331
358
  // Paths
@@ -459,24 +486,77 @@ function endsShortOfProfile(path) {
459
486
  last !== 'namzu' &&
460
487
  'namzu'.startsWith(last));
461
488
  }
489
+ /** `schedule` as a command word: not a path segment, not a longer word (`scheduled`). */
490
+ const SCHEDULE_WORD = /(?:^|[\s,;([{|&=])schedule(?=$|[\s,;)\]}|&])/g;
491
+ /** The CLI, the runtimes it can be started through, or its entry script, as a word. */
492
+ const CLI_IN_TEXT = /namzu|bin\.[cm]?js|(?:^|[^a-z0-9_-])(?:node|nodejs|npx|pnpx|bunx|bun|deno|tsx|ts-node|npm|pnpm|yarn)(?![a-z0-9_-])/;
493
+ /** An expansion that could hold the CLI's name or path: `$X`, a backtick, `%X%`. */
494
+ const EXPANSION_IN_TEXT = /[$`]|%[a-z_][a-z0-9_]*%/;
495
+ /** The scheduler service's names: the unit and task (`namzu-scheduler…`), the launchd label. */
496
+ const SERVICE_NAME_IN_TEXT = /namzu-scheduler[a-z0-9._-]*|com\.namzu\.[a-z0-9._-]*/;
497
+ /** Tools that stop, change or remove a service or a scheduled task. */
498
+ const SERVICE_TOOL_IN_TEXT = /(?:^|[^a-z0-9_-])(systemctl|launchctl|schtasks|busctl|dbus-send|gdbus|(?:stop|disable|unregister|set)-scheduledtask|schedule\.service)(?![a-z0-9_-])/;
499
+ /** `pkill` and `killall` match a process by pattern, and the scheduler is a `node` process. */
500
+ const PROCESS_KILLER_IN_TEXT = /(?:^|[^a-z0-9_-])(pkill|killall)(?![a-z0-9_-])/;
501
+ /** `NAMZU_HOME` by name, in any shell's or language's spelling of a variable. */
502
+ const HOME_VARIABLE_IN_TEXT = /(?:^|[^a-z0-9_])namzu_home(?![a-z0-9_])/;
503
+ /** The Windows profile root's parent, spelled as a variable or a known-folder name. */
504
+ const LOCAL_APP_DATA_IN_TEXT = /localappdata|localapplicationdata|(?:^|[^a-z0-9])appdata(?![a-z0-9])/;
505
+ /** `namzu` as a path segment or a string of its own. */
506
+ const NAMZU_SEGMENT = /(?:^|[^a-z0-9_.-])namzu(?![a-z0-9_.-])/;
507
+ function escapeRegExp(text) {
508
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
509
+ }
510
+ /** A `schedule` subcommand that is not read-only, in one text, or null. */
511
+ function scheduleSubcommand(text) {
512
+ for (const match of text.matchAll(SCHEDULE_WORD)) {
513
+ const after = text.slice((match.index ?? 0) + match[0].length);
514
+ const verb = /^[\s,]*([a-z0-9][a-z0-9_-]*)/.exec(after)?.[1];
515
+ if (verb !== undefined && READ_ONLY_VERBS.includes(verb))
516
+ continue;
517
+ return verb === undefined ? 'schedule' : `schedule ${verb}`;
518
+ }
519
+ return null;
520
+ }
462
521
  class Floor {
463
522
  home;
523
+ /** NAMZU_HOME as the operator wrote it, for a refusal. */
524
+ homeShown;
464
525
  homeCore;
526
+ /** NAMZU_HOME's last segment, and the default's (`.namzu`), as path segments in text. */
527
+ homeSegments;
465
528
  userHome;
466
529
  folders;
467
530
  daemonCommandLine;
468
531
  builtinCommands;
469
532
  constructor(options) {
470
533
  this.home = normalize(lower(options.namzuHome));
534
+ this.homeShown = options.namzuHome;
471
535
  this.userHome = normalize(lower(options.userHome));
472
536
  this.folders = (options.folders ?? []).map((f) => normalize(lower(f)));
473
537
  const last = this.home.split('/').at(-1) ?? '';
474
538
  this.homeCore = last.replace(/^\.+/, '');
539
+ // A dotted name (`.namzu`) is a path segment wherever it stands; an
540
+ // undotted one only after a slash, or the product's name would be one.
541
+ this.homeSegments = [...new Set(['.namzu', last])]
542
+ .filter((name) => name.replace(/^\.+/, '').length >= 3)
543
+ .map((name) => ({
544
+ name,
545
+ pattern: name.startsWith('.')
546
+ ? new RegExp(`(?:^|[^a-z0-9_.-])${escapeRegExp(name)}(?![a-z0-9_.-])`)
547
+ : new RegExp(`[/\\\\]${escapeRegExp(name)}(?![a-z0-9_.-])`),
548
+ }));
475
549
  this.builtinCommands = builtinCommandArguments();
476
550
  this.daemonCommandLine = lower(options.daemonCommandLine ?? `${process.execPath} ${process.argv[1] ?? ''} schedule daemon`);
477
551
  }
478
- /** Why the floor denies the call, or null when it does not. */
479
- verdict(call) {
552
+ /** What the floor protects, as a refusal names it. */
553
+ protectedWords(what) {
554
+ return what === 'NAMZU_HOME'
555
+ ? `NAMZU_HOME (${this.homeShown})`
556
+ : "the Windows browser's profile folder (%LOCALAPPDATA%\\namzu)";
557
+ }
558
+ /** Why the floor denies the call and what matched, or null when it does not. */
559
+ find(call) {
480
560
  const argument = call.toolDef
481
561
  ? commandArgumentOf(call.toolDef)
482
562
  : this.builtinCommands.get(call.toolName);
@@ -490,40 +570,55 @@ class Floor {
490
570
  try {
491
571
  const named = this.stringsName(input, line);
492
572
  if (named !== null)
493
- return `names ${named}`;
573
+ return {
574
+ reason: `names ${named.what}`,
575
+ detail: `${named.where} names ${this.protectedWords(named.what)}`,
576
+ };
494
577
  if (line !== undefined)
495
578
  return this.lineVerdict(line, call.commandDialect);
496
579
  return null;
497
580
  }
498
581
  catch (error) {
499
582
  if (error instanceof TooMany)
500
- return 'too many spellings';
583
+ return {
584
+ reason: 'too many spellings',
585
+ detail: `a word in the command can be spelled more than ${MAX_ALTERNATIVES} ways once its variables are filled in, and the floor refuses rather than try them all`,
586
+ };
501
587
  throw error;
502
588
  }
503
589
  }
504
590
  // ---- any tool -----------------------------------------------------------
505
591
  /** Every string in the input but the command line, searched as text. */
506
592
  stringsName(input, line) {
507
- const stack = [input];
593
+ const stack = [
594
+ { value: input, at: '' },
595
+ ];
508
596
  let skipped = false;
509
597
  while (stack.length > 0) {
510
- const value = stack.pop();
598
+ const { value, at, key } = stack.pop();
511
599
  if (typeof value === 'string') {
512
600
  if (!skipped && value === line) {
513
601
  skipped = true;
514
602
  continue;
515
603
  }
604
+ const where = key
605
+ ? `the argument name ${shown(value)}`
606
+ : at === ''
607
+ ? 'the input'
608
+ : `the \`${at}\` argument`;
516
609
  if (this.textNamesHome(value))
517
- return 'NAMZU_HOME';
610
+ return { what: 'NAMZU_HOME', where };
518
611
  if (textNamesProfile(value))
519
- return 'the browser profiles';
612
+ return { what: 'the browser profiles', where };
520
613
  }
521
614
  else if (Array.isArray(value)) {
522
- stack.push(...value);
615
+ value.forEach((each, i) => stack.push({ value: each, at: `${at}[${i}]` }));
523
616
  }
524
617
  else if (value !== null && typeof value === 'object') {
525
- for (const [key, each] of Object.entries(value))
526
- stack.push(key, each);
618
+ for (const [name, each] of Object.entries(value)) {
619
+ const path = at === '' ? name : `${at}.${name}`;
620
+ stack.push({ value: name, at: path, key: true }, { value: each, at: path });
621
+ }
527
622
  }
528
623
  }
529
624
  return null;
@@ -564,49 +659,149 @@ class Floor {
564
659
  const loops = /\b(?:while|until|for|select)\b/.test(line);
565
660
  let unaccounted = null;
566
661
  for (const reading of readings) {
567
- const reason = this.readingVerdict(reading, catalogue, loops);
568
- if (reason !== null)
569
- return reason;
570
- if (reading.opaque)
571
- unaccounted ??= 'opaque line mentions a protected name';
572
- else if (reading.commands.some(unreadCommand))
573
- unaccounted ??= 'unread text mentions a protected name';
662
+ const finding = this.readingVerdict(reading, catalogue, loops);
663
+ if (finding !== null)
664
+ return finding;
665
+ if (reading.opaque) {
666
+ unaccounted ??= {
667
+ reason: 'opaque line mentions a protected name',
668
+ why: `the floor cannot read the line (${reading.reasons[0] ?? 'a construct it does not model'})`,
669
+ };
670
+ continue;
671
+ }
672
+ for (const command of reading.commands) {
673
+ const unread = unreadCommand(command);
674
+ if (unread === null)
675
+ continue;
676
+ unaccounted ??= { reason: 'unread text mentions a protected name', why: unread };
677
+ break;
678
+ }
574
679
  }
575
680
  if (unaccounted === null)
576
681
  return null;
577
- if (this.mentionsProtected(line, readings))
578
- return unaccounted;
682
+ const trip = this.tripwire(line, readings);
683
+ if (trip !== null)
684
+ return {
685
+ reason: unaccounted.reason,
686
+ detail: `${unaccounted.why}, and it holds ${shown(trip.token)} (${trip.what})${whereIn(readings, trip.pattern ?? trip.token)}`,
687
+ };
579
688
  // Text a program may run as a command line — an argument (`echo '…' |
580
689
  // sh`, `sudo bash -c '…'`), a here-string, a here-document's body —
581
690
  // is read as one, decoded, so what the tripwire looks for cannot hide
582
691
  // behind escapes that only the inner shell undoes.
583
692
  if (depth < MAX_TEXT_DEPTH)
584
693
  for (const text of fedTexts(readings)) {
585
- if (this.lineVerdict(text, 'bash', depth + 1) !== null)
586
- return unaccounted;
694
+ const inner = this.lineVerdict(text, 'bash', depth + 1);
695
+ if (inner !== null)
696
+ return {
697
+ reason: unaccounted.reason,
698
+ detail: `${unaccounted.why}, and read as a command line, ${shown(text)}: ${inner.detail}`,
699
+ };
587
700
  }
588
701
  return null;
589
702
  }
590
- /** The tripwire: the line's text names something the floor protects. */
591
- mentionsProtected(line, readings) {
592
- const squashed = lower(line)
593
- .replace(/\\\n/g, '')
594
- .replace(/["'\\$`]/g, '');
703
+ /**
704
+ * The tripwire, for a line the lexer could not account for: something in
705
+ * its text that can reach what the floor protects, or null. Read in the
706
+ * line as written, with quotes and expansion marks dropped (and once more
707
+ * with backslashes dropped too), and in every word the lexer decoded:
708
+ *
709
+ * - `NAMZU_HOME` by name (`$NAMZU_HOME`, `%NAMZU_HOME%`,
710
+ * `os.environ['NAMZU_HOME']`), its last segment as a path segment
711
+ * (`.namzu`, always), or a path into it;
712
+ * - the Windows browser's profile folder, as a path, or `LOCALAPPDATA` (or
713
+ * `AppData`) with `namzu` as a segment or a string of its own;
714
+ * - the scheduler service's name (`namzu-scheduler…`, `com.namzu.…`);
715
+ * - a service tool (`systemctl`, `launchctl`, `schtasks`, the D-Bus tools,
716
+ * PowerShell's `*-ScheduledTask`) with `namzu` or a glob in the text,
717
+ * `systemctl isolate` or `exit`; `pkill` or `killall` anywhere, because
718
+ * a pattern can match the scheduler's `node` process without naming it;
719
+ * - `schedule` as a command word followed by anything but a read-only
720
+ * verb, when the text also names the CLI (`namzu`, `@namzu/cli`,
721
+ * `bin.js`, a JavaScript runtime) or holds an expansion that could.
722
+ *
723
+ * Nothing else: the product's name in a string (`MessageBox::Show('Namzu:
724
+ * scheduled job running')`) reaches none of them.
725
+ */
726
+ tripwire(line, readings) {
727
+ const plain = lower(line).replace(/\\\n/g, '');
728
+ const unquoted = plain.replace(/["'$`]/g, '');
729
+ const squashed = unquoted.replace(/\\/g, '');
595
730
  const decoded = readings
596
731
  .flatMap((r) => r.commands.flatMap((c) => c.words.map((w) => lower(w.value))))
597
732
  .join(' ');
598
- const tokens = [...SENSITIVE, 'namzu_home'];
599
- if (this.homeCore.length >= 3)
600
- tokens.push(this.homeCore);
601
- for (const text of [squashed, decoded])
602
- if (tokens.some((token) => text.includes(token)))
603
- return true;
604
- return this.textNamesHome(line);
733
+ const texts = [squashed, unquoted, decoded];
734
+ const first = (pattern) => {
735
+ for (const text of texts) {
736
+ const match = pattern.exec(text);
737
+ if (match)
738
+ return match[1] ?? match[0].replace(/^[^a-z0-9%.]+/, '');
739
+ }
740
+ return null;
741
+ };
742
+ // NAMZU_HOME.
743
+ if (HOME_VARIABLE_IN_TEXT.test(plain) || first(HOME_VARIABLE_IN_TEXT) !== null)
744
+ return { token: 'NAMZU_HOME', what: 'the NAMZU_HOME variable' };
745
+ for (const { name, pattern } of this.homeSegments)
746
+ if (texts.some((text) => pattern.test(text)))
747
+ return { token: name, what: "NAMZU_HOME's folder name", pattern };
748
+ if (this.textNamesHome(line) || this.textNamesHome(decoded))
749
+ return { token: this.homeShown, what: 'a path into NAMZU_HOME' };
750
+ // The Windows browser's profiles.
751
+ if (textNamesProfile(line) || textNamesProfile(decoded))
752
+ return {
753
+ token: '%LOCALAPPDATA%\\namzu',
754
+ what: "a path into the Windows browser's profile folder",
755
+ };
756
+ const appData = first(LOCAL_APP_DATA_IN_TEXT);
757
+ if (appData !== null && texts.some((text) => NAMZU_SEGMENT.test(text)))
758
+ return {
759
+ token: `${appData} … namzu`,
760
+ what: "together, the Windows browser's profile folder",
761
+ };
762
+ // The service.
763
+ const service = first(SERVICE_NAME_IN_TEXT);
764
+ if (service !== null)
765
+ return { token: service, what: "the scheduler service's name" };
766
+ const tool = first(SERVICE_TOOL_IN_TEXT);
767
+ if (tool !== null) {
768
+ if (texts.some((text) => text.includes('namzu')))
769
+ return { token: `${tool} … namzu`, what: 'a service tool with a namzu name' };
770
+ if (plain.includes('*'))
771
+ return { token: `${tool} … *`, what: 'a service tool with a pattern that can match it' };
772
+ if (tool === 'systemctl') {
773
+ const all = first(/(?:^|\s)(isolate|exit)(?![a-z0-9_-])/);
774
+ if (all !== null)
775
+ return { token: `systemctl ${all}`, what: 'which stops every service' };
776
+ }
777
+ }
778
+ const killer = first(PROCESS_KILLER_IN_TEXT);
779
+ if (killer !== null)
780
+ return {
781
+ token: killer,
782
+ what: 'which kills by pattern, and the scheduler is a node process a pattern can match',
783
+ };
784
+ // The CLI's `schedule` subcommands.
785
+ // `$'…'` and `$"…"` are quotes, not expansions.
786
+ const cli = texts.some((text) => CLI_IN_TEXT.test(text)) ||
787
+ EXPANSION_IN_TEXT.test(plain.replace(/\$(?=['"])/g, ''));
788
+ if (cli)
789
+ for (const text of [squashed, decoded]) {
790
+ const subcommand = scheduleSubcommand(text);
791
+ if (subcommand !== null)
792
+ return { token: subcommand, what: NOT_READ_ONLY };
793
+ }
794
+ return null;
605
795
  }
606
796
  readingVerdict(reading, catalogue, loops) {
607
- for (const command of reading.commands)
608
- if (reachesScheduler(wordsOf(command, catalogue), this.daemonCommandLine))
609
- return 'scheduler command';
797
+ for (const command of reading.commands) {
798
+ const does = reachesScheduler(wordsOf(command, catalogue), this.daemonCommandLine);
799
+ if (does !== null)
800
+ return {
801
+ reason: 'scheduler command',
802
+ detail: `${shown(command.words.map((w) => w.text).join(' '))} ${does}`,
803
+ };
804
+ }
610
805
  const variables = this.assignments(reading);
611
806
  const expanding = (w) => w.expands && !localeOnly(w.value, catalogue);
612
807
  const expands = reading.commands.some((c) => c.words.some(expanding)) ||
@@ -616,6 +811,7 @@ class Floor {
616
811
  // variable is spelled out with its value. One that is exported, or
617
812
  // could be, is checked where it stands.
618
813
  const exported = reading.commands.some((c) => c.words.some((w) => !w.expands && EXPORTING.has(commandName(w.value))));
814
+ const names = (what) => `names ${this.protectedWords(what)}`;
619
815
  const cwd = new Directories(this.folders);
620
816
  // In a loop, a `cd` late in the body applies to the next pass's first
621
817
  // command: every command gets every directory the line can reach.
@@ -632,18 +828,27 @@ class Floor {
632
828
  continue;
633
829
  const named = check(word);
634
830
  if (named !== null)
635
- return `word names ${named}`;
831
+ return {
832
+ reason: `word names ${named}`,
833
+ detail: `the argument ${shown(word.text)} ${names(named)}`,
834
+ };
636
835
  // `X=~/.namzu` as an argument, or exported: the value on its own.
637
836
  const assigned = assignedValue(word);
638
837
  const value = assigned ? check(assigned.word) : null;
639
838
  if (value !== null)
640
- return `assigned value names ${value}`;
839
+ return {
840
+ reason: `assigned value names ${value}`,
841
+ detail: `the value assigned in ${shown(word.text)} ${names(value)}`,
842
+ };
641
843
  }
642
844
  for (const redirection of command.redirections) {
643
845
  seen.add(redirection);
644
846
  const named = redirectsTo(redirection) ? check(redirection.target) : null;
645
847
  if (named !== null)
646
- return `redirection names ${named}`;
848
+ return {
849
+ reason: `redirection names ${named}`,
850
+ detail: `the redirection ${shown(`${redirection.operator} ${redirection.target.text}`)} ${names(named)}`,
851
+ };
647
852
  }
648
853
  cwd.after(command, (target) => this.spell(target, variables, cwd.current()), this.userHome);
649
854
  }
@@ -656,12 +861,18 @@ class Floor {
656
861
  continue;
657
862
  const named = check(redirection.target);
658
863
  if (named !== null)
659
- return `redirection names ${named}`;
864
+ return {
865
+ reason: `redirection names ${named}`,
866
+ detail: `the redirection ${shown(`${redirection.operator} ${redirection.target.text}`)} ${names(named)}`,
867
+ };
660
868
  }
661
869
  for (const word of reading.compoundWords) {
662
870
  const named = check(word);
663
871
  if (named !== null)
664
- return `loop or case word names ${named}`;
872
+ return {
873
+ reason: `loop or case word names ${named}`,
874
+ detail: `the loop or case word ${shown(word.text)} ${names(named)}`,
875
+ };
665
876
  }
666
877
  return null;
667
878
  }
@@ -847,6 +1058,18 @@ class Floor {
847
1058
  }
848
1059
  return false;
849
1060
  }
1061
+ /** Whether text after an unknown expansion holds NAMZU_HOME's last segment as a path segment. */
1062
+ restNamesHome(rest) {
1063
+ let at = rest.indexOf(this.homeCore);
1064
+ while (at >= 0) {
1065
+ const before = at === 0 ? '' : rest[at - 1];
1066
+ const after = rest[at + this.homeCore.length] ?? '';
1067
+ if (/^[./\\]?$/.test(before) && !NAME_CHAR.test(after))
1068
+ return true;
1069
+ at = rest.indexOf(this.homeCore, at + 1);
1070
+ }
1071
+ return false;
1072
+ }
850
1073
  wordNamesHome(word, variables, cwd, lineExpands) {
851
1074
  if (word.expands && /\$\{?[!#]?namzu_home(?![a-z0-9_])/i.test(word.value))
852
1075
  return true;
@@ -896,9 +1119,12 @@ class Floor {
896
1119
  if (next.startsWith(partial))
897
1120
  return true;
898
1121
  }
899
- if (spelling.stop !== 'end' &&
900
- this.homeCore.length >= 3 &&
901
- spelling.rest.includes(this.homeCore))
1122
+ // After the unknown part, NAMZU_HOME's last segment as a path
1123
+ // segment: right after the expansion (`${X}namzu`, where X may end
1124
+ // in `/.`), or after a `.`, `/` or `\` (`$X/.namzu/x`). The name
1125
+ // inside other text (`$(echo namzu)`, `"$USER: namzu done"`) is not
1126
+ // a path.
1127
+ if (spelling.stop !== 'end' && this.homeCore.length >= 3 && this.restNamesHome(spelling.rest))
902
1128
  return true;
903
1129
  }
904
1130
  return false;
@@ -1041,11 +1267,23 @@ function fedTexts(readings) {
1041
1267
  }
1042
1268
  return [...out];
1043
1269
  }
1044
- /** A command the lexer listed whose name or payload it could not read. */
1270
+ /** A command the lexer listed whose name or payload it could not read, in words, or null. */
1045
1271
  function unreadCommand(command) {
1046
1272
  const head = command.words[command.assignments];
1047
1273
  if (head?.expands)
1048
- return true;
1274
+ return `the command's name ${shown(head.text)} expands at runtime`;
1049
1275
  return runsUnreadText(command);
1050
1276
  }
1277
+ /** `, in the argument …` for the first word that holds `token`, or nothing. */
1278
+ function whereIn(readings, token) {
1279
+ const holds = typeof token === 'string'
1280
+ ? (text) => text.includes(lower(token))
1281
+ : (text) => token.test(text);
1282
+ for (const reading of readings)
1283
+ for (const command of reading.commands)
1284
+ for (const word of command.words)
1285
+ if (holds(lower(word.value)))
1286
+ return `, in the argument ${shown(word.value)}`;
1287
+ return '';
1288
+ }
1051
1289
  //# sourceMappingURL=floor.js.map