@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.
- package/CHANGELOG.md +79 -0
- package/dist/commands/exec.d.ts.map +1 -1
- package/dist/commands/exec.js +3 -0
- package/dist/commands/exec.js.map +1 -1
- package/dist/context/environment.d.ts +26 -3
- package/dist/context/environment.d.ts.map +1 -1
- package/dist/context/environment.js +68 -9
- package/dist/context/environment.js.map +1 -1
- package/dist/integrations/notifications/desktop.d.ts +2 -0
- package/dist/integrations/notifications/desktop.d.ts.map +1 -1
- package/dist/integrations/notifications/desktop.js +6 -5
- package/dist/integrations/notifications/desktop.js.map +1 -1
- package/dist/integrations/plugins/runtime.d.ts +8 -2
- package/dist/integrations/plugins/runtime.d.ts.map +1 -1
- package/dist/integrations/plugins/runtime.js +14 -8
- package/dist/integrations/plugins/runtime.js.map +1 -1
- package/dist/integrations/providers/harness-credentials.d.ts +9 -4
- package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
- package/dist/integrations/providers/harness-credentials.js +16 -8
- package/dist/integrations/providers/harness-credentials.js.map +1 -1
- package/dist/integrations/web/open-url.d.ts +24 -0
- package/dist/integrations/web/open-url.d.ts.map +1 -0
- package/dist/integrations/web/open-url.js +73 -0
- package/dist/integrations/web/open-url.js.map +1 -0
- package/dist/schedule/build.d.ts +7 -0
- package/dist/schedule/build.d.ts.map +1 -1
- package/dist/schedule/build.js +21 -0
- package/dist/schedule/build.js.map +1 -1
- package/dist/schedule/changes.d.ts +6 -0
- package/dist/schedule/changes.d.ts.map +1 -1
- package/dist/schedule/changes.js +10 -0
- package/dist/schedule/changes.js.map +1 -1
- package/dist/schedule/commands/add.d.ts.map +1 -1
- package/dist/schedule/commands/add.js +2 -14
- package/dist/schedule/commands/add.js.map +1 -1
- package/dist/schedule/commands/lifecycle.d.ts.map +1 -1
- package/dist/schedule/commands/lifecycle.js +2 -1
- package/dist/schedule/commands/lifecycle.js.map +1 -1
- package/dist/schedule/commands/list.d.ts.map +1 -1
- package/dist/schedule/commands/list.js +6 -2
- package/dist/schedule/commands/list.js.map +1 -1
- package/dist/schedule/daemon/daemon.d.ts.map +1 -1
- package/dist/schedule/daemon/daemon.js +5 -0
- package/dist/schedule/daemon/daemon.js.map +1 -1
- package/dist/schedule/daemon/notify.d.ts +8 -1
- package/dist/schedule/daemon/notify.d.ts.map +1 -1
- package/dist/schedule/daemon/notify.js +28 -2
- package/dist/schedule/daemon/notify.js.map +1 -1
- package/dist/schedule/env.d.ts +8 -4
- package/dist/schedule/env.d.ts.map +1 -1
- package/dist/schedule/env.js +13 -6
- package/dist/schedule/env.js.map +1 -1
- package/dist/schedule/fire/calls.d.ts +49 -0
- package/dist/schedule/fire/calls.d.ts.map +1 -0
- package/dist/schedule/fire/calls.js +114 -0
- package/dist/schedule/fire/calls.js.map +1 -0
- package/dist/schedule/fire/fire.d.ts.map +1 -1
- package/dist/schedule/fire/fire.js +9 -1
- package/dist/schedule/fire/fire.js.map +1 -1
- package/dist/schedule/floor.d.ts +27 -7
- package/dist/schedule/floor.d.ts.map +1 -1
- package/dist/schedule/floor.js +339 -101
- package/dist/schedule/floor.js.map +1 -1
- package/dist/schedule/service/index.d.ts +1 -1
- package/dist/schedule/service/index.d.ts.map +1 -1
- package/dist/schedule/service/index.js +20 -11
- package/dist/schedule/service/index.js.map +1 -1
- package/dist/schedule/service/windows-task.d.ts +7 -0
- package/dist/schedule/service/windows-task.d.ts.map +1 -1
- package/dist/schedule/service/windows-task.js +65 -0
- package/dist/schedule/service/windows-task.js.map +1 -1
- package/dist/schedule/service/wsl.d.ts +13 -1
- package/dist/schedule/service/wsl.d.ts.map +1 -1
- package/dist/schedule/service/wsl.js +16 -6
- package/dist/schedule/service/wsl.js.map +1 -1
- package/dist/schedule/types.d.ts +27 -0
- package/dist/schedule/types.d.ts.map +1 -1
- package/dist/skills/directory.d.ts +38 -0
- package/dist/skills/directory.d.ts.map +1 -0
- package/dist/skills/directory.js +51 -0
- package/dist/skills/directory.js.map +1 -0
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +20 -4
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/PermissionOverlay.d.ts +13 -1
- package/dist/tui/PermissionOverlay.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.js +26 -4
- package/dist/tui/PermissionOverlay.js.map +1 -1
- package/dist/tui/agent.d.ts +28 -6
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +58 -16
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/open-browser.d.ts +29 -1
- package/dist/tui/open-browser.d.ts.map +1 -1
- package/dist/tui/open-browser.js +83 -11
- package/dist/tui/open-browser.js.map +1 -1
- package/dist/tui/permission-review.d.ts +10 -1
- package/dist/tui/permission-review.d.ts.map +1 -1
- package/dist/tui/permission-review.js +175 -18
- package/dist/tui/permission-review.js.map +1 -1
- package/dist/tui/schedule/host-commands.d.ts.map +1 -1
- package/dist/tui/schedule/host-commands.js +2 -1
- package/dist/tui/schedule/host-commands.js.map +1 -1
- package/dist/tui/schedule/tool-host.d.ts +15 -1
- package/dist/tui/schedule/tool-host.d.ts.map +1 -1
- package/dist/tui/schedule/tool-host.js +259 -74
- package/dist/tui/schedule/tool-host.js.map +1 -1
- package/package.json +6 -6
- package/skills/README.md +5 -0
- package/skills/schedule-task/SKILL.md +8 -2
package/dist/schedule/floor.js
CHANGED
|
@@ -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`, `
|
|
55
|
-
* …) — is denied when the line's text
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
168
|
-
const
|
|
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
|
|
218
|
-
* taken at its first place after the one
|
|
219
|
-
* there such a subsequence" that loses
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
282
|
+
return kills;
|
|
265
283
|
if (w.text.startsWith('-'))
|
|
266
284
|
continue;
|
|
267
285
|
if (/[.*+?^$|()[\]{}\\]/.test(w.text))
|
|
268
|
-
return
|
|
286
|
+
return kills;
|
|
269
287
|
if (w.text.includes('namzu') || w.text.includes('schedul'))
|
|
270
|
-
return
|
|
288
|
+
return kills;
|
|
271
289
|
if ('node'.includes(w.text) || (full && daemonCommandLine.includes(w.text)))
|
|
272
|
-
return
|
|
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
|
|
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
|
|
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
|
|
296
|
-
return
|
|
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
|
|
319
|
+
return null;
|
|
300
320
|
}
|
|
301
321
|
/**
|
|
302
|
-
* Whether a command runs text the lexer did not read as commands
|
|
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
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
const
|
|
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
|
|
344
|
+
return `${program} runs text as code`;
|
|
320
345
|
if (i === head && name === '.')
|
|
321
|
-
return
|
|
322
|
-
if (SHELLS.has(name) && !(i
|
|
323
|
-
return
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
/**
|
|
479
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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(
|
|
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 [
|
|
526
|
-
|
|
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
|
|
568
|
-
if (
|
|
569
|
-
return
|
|
570
|
-
if (reading.opaque)
|
|
571
|
-
unaccounted ??=
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
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
|