mikser-io 10.3.0 → 10.4.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.
@@ -360,24 +360,128 @@ export default {
360
360
 
361
361
  ### `commands`
362
362
 
363
+ Runs shell commands at lifecycle hooks. A plugin, so it goes in `plugins` and
364
+ takes its options as factory arguments:
365
+
363
366
  ```js
367
+ import { commands } from 'mikser-io'
368
+
364
369
  export default {
365
- commands: {
366
- // Run shell commands at any lifecycle hook
367
- load: 'echo Loading...',
368
- finalized: ['npm run compress', 'npm run deploy'],
369
-
370
- // Commands can also be async functions
371
- processed: async (runtime) => {
372
- if (runtime.options.mode === 'production') {
373
- return 'npm run optimize'
374
- }
375
- }
376
- }
370
+ plugins: [
371
+ commands({
372
+ load: 'echo Loading...',
373
+ finalized: ['npm run compress', 'npm run deploy'],
374
+ // Also an async function, resolved when the hook fires
375
+ processed: async () => process.env.NODE_ENV === 'production' && 'npm run optimize',
376
+ }),
377
+ ],
377
378
  }
378
379
  ```
379
380
 
380
- Available hook names: `load`, `loaded`, `import`, `imported`, `process`, `processed`, `persist`, `persisted`, `beforeRender`, `render`, `afterRender`, `cancel`, `cancelled`, `finalize`, `finalized`.
381
+ Hook names: `load`, `loaded`, `import`, `imported`, `process`, `processed`,
382
+ `persist`, `persisted`, `beforeRender`, `render`, `afterRender`, `cancel`,
383
+ `cancelled`, `finalize`, `finalized`.
384
+
385
+ #### From the command line
386
+
387
+ One flag names a hook, so a one-off side effect needs no config edit:
388
+
389
+ ```bash
390
+ mikser --command finalized="node deploy/publish.mjs"
391
+
392
+ # repeatable
393
+ mikser --command loaded="node probe.mjs" --command finalized="node publish.mjs"
394
+ ```
395
+
396
+ The same shape as `--tool <name>`: one word of CLI namespace for an
397
+ open-ended set, rather than one flag per hook. It runs *in addition to*
398
+ whatever the config declares for that hook, and only for that run. Forwarded
399
+ to a running `--watch` instance it **replaces** the previous request's
400
+ commands rather than adding to them, and the instance's own rebuilds run
401
+ none — one hook is registered at load and reads a value the instance swaps
402
+ per request. The flag exists only when `commands()` is in the config, like
403
+ every plugin option, and an unknown hook name is refused before anything is
404
+ built.
405
+
406
+ #### Installing on a running instance
407
+
408
+ `--command` spends itself on one build. A watcher's **own** rebuilds — the
409
+ ones a file save triggers, which is the point of watching — run nothing, so a
410
+ probe fires only when you forward a build by hand. `--command-install` puts it
411
+ on the instance instead:
412
+
413
+ ```bash
414
+ mikser --command-install finalized="node probe.mjs" # installs, and runs now
415
+ # ...edit a file, the watcher rebuilds → probe runs
416
+ # ...edit again → probe runs
417
+ mikser --command-reset # clears all of them
418
+ mikser --command-reset finalized # or just one hook
419
+ ```
420
+
421
+ Installed commands are announced on **every** cycle, not once — you may have
422
+ set one an hour ago, and each build's report has to carry the fact that it was
423
+ not a function of the repository alone. Per-request commands are announced
424
+ once per process, because they are in the invocation you just typed.
425
+
426
+ On a one-shot build there is no instance to install on, so `--command-install`
427
+ runs once and exits with the process exactly like `--command` — reported under
428
+ `command-install-without-instance` rather than left to look persistent.
429
+
430
+ To see what is attached, read the last build: every installed command is
431
+ announced on **every** cycle under `command-from-cli`, so the log or the
432
+ `--json` report names each one. There is deliberately no `--tool commands` —
433
+ the tool registry is mirrored into MCP over HTTP with an allow-all default,
434
+ and a command string routinely carries a path, a host or a token. Listing
435
+ them there would hand an authenticated web client a map of the build box, and
436
+ tools have no per-tool scope to gate it with.
437
+
438
+ Two hooks behave differently from the rest, and both say so rather than
439
+ failing quietly:
440
+
441
+ - **`load` is refused.** Options are declared *during* the load phase and the
442
+ table is parsed after it, so a `--command load=` could never fire. It is
443
+ named and refused rather than left out of the list, because "no hook named
444
+ load" would be untrue and would send you looking for a typo. Declare it in
445
+ the config instead.
446
+ - **`loaded` does not fire for a forwarded build.** A running instance loaded
447
+ at startup and a rebuild does not repeat the load phase, so a load-phase
448
+ hook belongs to the instance rather than to the request. Asking for one
449
+ warns under `command-hook-not-reached`. Stop the instance, or use a
450
+ per-cycle hook.
451
+
452
+ Two hooks behave differently from the rest, and both say so rather than
453
+ failing quietly:
454
+
455
+ - **`load` is refused.** Options are declared *during* the load phase and the
456
+ table is parsed after it, so a `--command load=` could never fire. Declare
457
+ it in the config instead.
458
+ - **`loaded` does not fire for a forwarded build.** A running instance loaded
459
+ at startup and a rebuild does not repeat the load phase, so a load-phase
460
+ hook belongs to the instance rather than to the request. Asking for one
461
+ warns under `command-hook-not-reached`. Stop the instance, or use a
462
+ per-cycle hook.
463
+
464
+ Two things to know, and they are the reason this is a flag rather than a
465
+ convenience:
466
+
467
+ **It is reported, under `command-from-cli`.** A build is otherwise a function
468
+ of the repository — same commit, same bytes, and `--fingerprint` can prove it.
469
+ A command from argv makes it a function of the repo *and* how it was invoked,
470
+ so an agent, a person and CI can all run "the same build" and get different
471
+ output. The warning carries the hook and the command string into `--json`
472
+ `warnings`, so a fingerprint taken from that build stays interpretable instead
473
+ of quietly meaning something else.
474
+
475
+ **A command that writes into the output folder fails `--audit-output`.** Not a
476
+ limitation to work around — mikser hashes each file as it writes it, so
477
+ rewriting one afterwards is indistinguishable from tampering, and the audit
478
+ reports it as `Mismatched` and exits 2. Post-build minification through a hook
479
+ is therefore the wrong shape; it belongs in a renderer or a postprocessor,
480
+ where the hash is taken over what is actually deployed. The honest use for
481
+ hooks is side effects that do not touch `out/` — publishing, notifying,
482
+ syncing — which is also the case where a flag beats config, because deploy
483
+ steps are environment-specific and do not belong in a repository's build
484
+ config.
381
485
 
382
486
  ### `shares`
383
487
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.3.0",
3
+ "version": "10.4.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/cli.js CHANGED
@@ -46,7 +46,13 @@ const declared = new Map()
46
46
  // is being built is always undefined. Read it in a hook: onLoaded and later
47
47
  // all run after stage two. documents() read its folder at construction first,
48
48
  // and `--documents content` silently did nothing.
49
- export function cliOption(flags, description, defaultValue) {
49
+ // `parseArg` and `defaultValue` mirror commander's own signature, so a plugin
50
+ // that wants a REPEATABLE option can pass a collector — which is what a flag
51
+ // naming one of several things needs, and what `--command hook=cmd` is. Kept
52
+ // commander-compatible rather than inventing a shape: the third argument is a
53
+ // coercion function when it is callable and a default otherwise, which is
54
+ // exactly how commander reads it.
55
+ export function cliOption(flags, description, parseArg, defaultValue) {
50
56
  // No CLI at all — a plugin constructed by a test harness, or embedded
51
57
  // programmatically through setup({ ... }) rather than run from a terminal.
52
58
  // There is nothing to register the option on and nothing about that is a
@@ -65,11 +71,20 @@ export function cliOption(flags, description, defaultValue) {
65
71
  + 'Declare options while the plugin is constructed (the load phase), not afterwards — '
66
72
  + 'a later one would never be read and the flag would look ignored.')
67
73
  }
68
- if (declared.has(flags)) return declared.get(flags)
69
- const option = defaultValue === undefined
74
+ if (declared.has(flags)) return declared.get(flags).option
75
+ const coerce = typeof parseArg === 'function' ? parseArg : undefined
76
+ const fallback = coerce ? defaultValue : parseArg
77
+ const option = coerce
78
+ ? commander.option(flags, description, coerce, fallback)
79
+ : fallback === undefined
70
80
  ? commander.option(flags, description)
71
- : commander.option(flags, description, defaultValue)
72
- declared.set(flags, option)
81
+ : commander.option(flags, description, fallback)
82
+ // The coercion is remembered, not just applied. pluginOptionsFrom rebuilds
83
+ // a throwaway parser to read a forwarded client's argv, and a collector
84
+ // left out there would hand back the last value where the local run got an
85
+ // array — the forwarded path quietly behaving differently from the local
86
+ // one, which is the failure the instance surface exists to remove.
87
+ declared.set(flags, { option, coerce, fallback })
73
88
  return option
74
89
  }
75
90
 
@@ -154,7 +169,11 @@ export function pluginOptionsFrom(argv) {
154
169
  let parsed
155
170
  try {
156
171
  const probe = commander.createCommand()
157
- for (const flags of declared.keys()) probe.option(flags, '')
172
+ for (const [flags, { coerce, fallback }] of declared) {
173
+ if (coerce) probe.option(flags, '', coerce, fallback)
174
+ else if (fallback === undefined) probe.option(flags, '')
175
+ else probe.option(flags, '', fallback)
176
+ }
158
177
  probe.allowUnknownOption(true).allowExcessArguments(true)
159
178
  probe.parse(argv, { from: 'user' })
160
179
  parsed = probe.opts()
@@ -1,8 +1,60 @@
1
1
  import { execaCommand } from 'execa'
2
+ import { cliOption } from '../cli.js'
2
3
  import lineReader from 'line-reader'
3
4
  import { promisify } from 'util'
4
5
  import _ from 'lodash'
5
6
 
7
+ // The hooks a command can hang off.
8
+ //
9
+ // ONE flag naming a hook, not fifteen flags named after hooks. The same shape
10
+ // as `--tool <name>`: a plugin claims one word for an open-ended registry
11
+ // rather than one word per entry. Fifteen would have reserved `--render`,
12
+ // `--process`, `--load`, `--import`, `--persist` and `--cancel` at the top
13
+ // level for one plugin — words core plausibly wants (a `--render <entity>` is
14
+ // not hard to imagine) — and put fifteen near-identical rows in `--help`.
15
+ const HOOKS = new Set([
16
+ 'load', 'loaded', 'import', 'imported', 'process', 'processed',
17
+ 'persist', 'persisted', 'beforeRender', 'render', 'afterRender',
18
+ 'cancel', 'canceled', 'finalize', 'finalized',
19
+ ])
20
+
21
+ // `load` is declarable in config and unreachable from the CLI: options are
22
+ // declared DURING the load phase and the table is not parsed until after it,
23
+ // so runtime.options is still empty when onLoad fires.
24
+ //
25
+ // Named and REFUSED rather than left out of the hook list. Omitting it would
26
+ // answer `--command load=...` with "no hook named load", which is untrue —
27
+ // there is such a hook, it is simply not one the command line can reach, and a
28
+ // wrong reason sends someone looking for a typo. The refusal says what to do
29
+ // instead.
30
+ const CLI_UNREACHABLE = new Map([
31
+ ['load', 'the option table is parsed after the load phase, so a --command on it could never fire. '
32
+ + 'Declare it in the config instead: commands({ load: ... }).'],
33
+ ])
34
+
35
+ // Never expected on a successful build, so their absence is not a finding.
36
+ const CONDITIONAL = new Set(['cancel', 'canceled'])
37
+
38
+ // `hook=command`, split on the FIRST `=` so a command may contain its own.
39
+ function parseHookCommand(value) {
40
+ const at = value.indexOf('=')
41
+ if (at < 1) {
42
+ throw new Error(`--command expects <hook>=<command>, got ${JSON.stringify(value)}. `
43
+ + `Hooks: ${[...HOOKS].join(', ')}`)
44
+ }
45
+ const hook = value.slice(0, at).trim()
46
+ const command = value.slice(at + 1).trim()
47
+ if (!HOOKS.has(hook)) {
48
+ throw new Error(`--command: no hook named ${JSON.stringify(hook)}. `
49
+ + `Hooks: ${[...HOOKS].join(', ')}`)
50
+ }
51
+ if (CLI_UNREACHABLE.has(hook)) {
52
+ throw new Error(`--command ${hook}=: ${CLI_UNREACHABLE.get(hook)}`)
53
+ }
54
+ if (!command) throw new Error(`--command ${hook}=: no command given`)
55
+ return { hook, command }
56
+ }
57
+
6
58
  export function commands(options = {}) {
7
59
  return ({
8
60
  runtime,
@@ -26,6 +78,117 @@ export function commands(options = {}) {
26
78
  const eachLine = promisify(lineReader.eachLine)
27
79
  const running = {}
28
80
 
81
+ // Declared here, in the load phase, and READ in the hook below.
82
+ // Reading at construction is always undefined — the option table is
83
+ // not parsed until after this runs.
84
+ //
85
+ // Repeatable, so several hooks can be driven in one invocation. The
86
+ // collector is remembered by cliOption and replayed when an instance
87
+ // re-parses a forwarded client's argv, so the forwarded path sees the
88
+ // same array the local one does.
89
+ const collect = (value, previous = []) => [...previous, parseHookCommand(value)]
90
+ cliOption('--command <hook=command>',
91
+ 'run a command at a lifecycle hook for THIS run only, e.g. '
92
+ + '--command finalized="node deploy/publish.mjs". Repeatable. '
93
+ + `Hooks: ${[...HOOKS].join(', ')}`,
94
+ collect, [])
95
+ // Installed on the instance rather than spent on one request.
96
+ //
97
+ // --command alone cannot serve the case it was asked for. A watcher's
98
+ // OWN rebuilds — the ones a file save triggers, which is the whole
99
+ // point of watching — run nothing, so a probe fires only when a build
100
+ // is forwarded by hand. Installing puts it on the instance, where
101
+ // every cycle sees it until it is cleared.
102
+ cliOption('--command-install <hook=command>',
103
+ 'install a command on the running instance, so its own rebuilds run it too. '
104
+ + 'Repeatable. Cleared with --command-reset.',
105
+ collect, [])
106
+ cliOption('--command-reset [hook]',
107
+ 'clear commands installed with --command-install: all of them, or one hook\'s.')
108
+
109
+ // Said once per hook per process, not once per cycle: a watcher would
110
+ // otherwise repeat it on every rebuild.
111
+ const announced = new Set()
112
+
113
+ // Commands installed on THIS process by a forwarded --command-install.
114
+ //
115
+ // Lives in the plugin's closure rather than runtime.options because
116
+ // options are swapped per request and restored after — which is
117
+ // exactly right for --command and exactly wrong for something whose
118
+ // point is to outlive the request that set it.
119
+ const installed = new Map()
120
+
121
+ // Applied at the top of every hook: idempotent, so it does not matter
122
+ // which hook of the cycle sees the request first, and by the next
123
+ // cycle the request's options are gone while `installed` remains.
124
+ function applyInstallRequest() {
125
+ const reset = runtime.options?.commandReset
126
+ if (reset !== undefined && reset !== false) {
127
+ const cleared = reset === true
128
+ ? [...installed.keys()]
129
+ : installed.has(reset) ? [reset] : []
130
+ if (reset === true) installed.clear()
131
+ else installed.delete(reset)
132
+ if (cleared.length) {
133
+ useLogger()?.info('Cleared installed command(s) at %s', cleared.join(', '))
134
+ }
135
+ }
136
+ const requested = runtime.options?.commandInstall
137
+ if (!Array.isArray(requested) || !requested.length) return
138
+ // An instance is what there is to install ON. A one-shot exits
139
+ // with the process, so installing is the same as --command and
140
+ // saying nothing would let someone believe it persisted.
141
+ if (!runtime.options?.watch && !runtime.options?.server) {
142
+ if (!announced.has('no-instance')) {
143
+ announced.add('no-instance')
144
+ useLogger()?.warn({ code: 'command-install-without-instance' },
145
+ 'Nothing to install on — this is a one-shot build, not a watcher, so '
146
+ + '--command-install ran once and exits with the process, exactly like --command.')
147
+ }
148
+ }
149
+ for (const { hook, command } of requested) {
150
+ const list = installed.get(hook) ?? []
151
+ if (!list.includes(command)) installed.set(hook, [...list, command])
152
+ }
153
+ }
154
+
155
+ // NOT registered as a tool, deliberately.
156
+ //
157
+ // Listing what is attached is worth having, and `--tool commands` was
158
+ // the obvious shape — the registry forwards report-only runs to the
159
+ // instance, so it would answer from the process that holds the state.
160
+ // But the registry is mirrored into MCP over HTTP, its endpoint filter
161
+ // defaults to allow-all, and a command string routinely carries a
162
+ // path, a host or a token. That turns "list the hooks" into handing an
163
+ // authenticated web client a map of the build box.
164
+ //
165
+ // There is no per-tool scope to lean on: routes declare reachability
166
+ // (public / token / loopback), tools declare only `mutates`. A
167
+ // `scope: 'admin'` key here would be decorative — nothing enforces it
168
+ // — and a decorative guard is worse than none, because it reads like
169
+ // one.
170
+ //
171
+ // Installing is not the exposure: it needs the unix socket, which is
172
+ // chmod 0600, so anyone who can reach it already runs as this user and
173
+ // could run the command directly. Reading over HTTP is a different
174
+ // boundary, which is why this half is the half that had to go.
175
+ //
176
+ // What answers the question meanwhile: every installed command is
177
+ // announced on every cycle under `command-from-cli`, so the last
178
+ // build's log or its --json report names each one. A standalone
179
+ // listing wants per-tool scoping in the registry first.
180
+
181
+ // Which requested hooks actually fired this cycle.
182
+ //
183
+ // `loaded` is the case that matters: it fires for a local build and
184
+ // NOT for one forwarded to a running instance, because that instance
185
+ // loaded at startup and a rebuild does not repeat the load phase. So
186
+ // the same command does different things depending on whether a
187
+ // watcher happens to be up, and until now it did so in silence.
188
+ // Checked generically rather than special-casing that one hook, since
189
+ // the interesting cases are the ones nobody predicted.
190
+ const fired = new Set()
191
+
29
192
  async function executeCommand(command) {
30
193
  const logger = useLogger()
31
194
  if (_.endsWith(command, '&')) {
@@ -51,9 +214,74 @@ export function commands(options = {}) {
51
214
  if (typeof cmds == 'function') cmds = await cmds()
52
215
  if (typeof cmds == 'string') cmds = [cmds]
53
216
 
217
+ // From argv, read HERE rather than merged into `options` at
218
+ // construction — that is the whole rule for plugin CLI options,
219
+ // and it is also what makes a forwarded request overwrite instead
220
+ // of accumulate. The instance applies a client's flags onto
221
+ // runtime.options for one cycle and restores them after, so this
222
+ // reads whatever THIS request asked for. Registering a hook per
223
+ // request would have made two clients' commands add up; there is
224
+ // one hook, registered once, reading a value that changes.
225
+ applyInstallRequest()
226
+
227
+ const requested = runtime.options?.command
228
+ const perRequest = (Array.isArray(requested) ? requested : [])
229
+ .filter(entry => entry?.hook === hook)
230
+ .map(entry => entry.command)
231
+ // Installed ones outlive the request that set them, so they run
232
+ // for the instance's OWN rebuilds too — which is the case
233
+ // --command alone cannot serve.
234
+ const fromInstalled = installed.get(hook) ?? []
235
+ const fromCli = [...perRequest, ...fromInstalled]
236
+ for (const command of fromCli) {
237
+ // Under a code, in the report, with the command.
238
+ //
239
+ // A build is otherwise a function of the repository — same
240
+ // commit, same bytes, and --fingerprint can prove it. A
241
+ // command from argv makes it a function of the repo AND how it
242
+ // was invoked, so an agent, a person and CI can run "the same
243
+ // build" and get different output. Warn rather than log:
244
+ // warnings are a view of logger.warn in the report, so a
245
+ // fingerprint taken from this build stays interpretable
246
+ // instead of quietly meaning something else.
247
+ // A per-request command is announced once per process: it is
248
+ // in the invocation you just typed. An INSTALLED one is
249
+ // announced every cycle, because you may have set it an hour
250
+ // ago and each build's report has to carry the fact that this
251
+ // build was not a function of the repository alone.
252
+ const key = `${hook}:${command}`
253
+ if (fromInstalled.includes(command) || !announced.has(key)) {
254
+ announced.add(key)
255
+ useLogger()?.warn({ code: 'command-from-cli', hook, command },
256
+ 'Running a command from the command line at the %s hook: %s. This build is a '
257
+ + 'function of how it was invoked as well as of the repository — the same commit '
258
+ + 'built without this flag can differ. Commands that write into the output folder '
259
+ + 'also fail --audit-output, which hashes each file as it is written.',
260
+ hook, command)
261
+ }
262
+ }
263
+ cmds = [...cmds, ...fromCli]
264
+
54
265
  for (let command of cmds) {
55
266
  await executeCommand(command)
56
267
  }
268
+ if (fromCli.length) fired.add(hook)
269
+ }
270
+
271
+ // Anything asked for that never ran, said at the end of the cycle.
272
+ function reportUnfired() {
273
+ const requested = runtime.options?.command
274
+ if (!Array.isArray(requested)) return
275
+ const missed = [...new Set(requested.map(entry => entry?.hook))]
276
+ .filter(hook => hook && !fired.has(hook) && !CONDITIONAL.has(hook))
277
+ fired.clear()
278
+ if (!missed.length) return
279
+ useLogger()?.warn({ code: 'command-hook-not-reached', hooks: missed },
280
+ 'Asked to run a command at %s, and that hook did not fire this cycle. A build forwarded '
281
+ + 'to a running instance reuses one that loaded at startup, so load-phase hooks belong '
282
+ + 'to the instance rather than to the request. Stop the instance to run the command, or '
283
+ + 'move it to a per-cycle hook.',
284
+ missed.join(', '))
57
285
  }
58
286
 
59
287
  onLoad(async () => await executeCommands('load'))
@@ -70,7 +298,11 @@ export function commands(options = {}) {
70
298
  onCancel(async () => await executeCommands('cancel'))
71
299
  onCancelled(async () => await executeCommands('canceled'))
72
300
  onFinalize(async () => await executeCommands('finalize'))
73
- onFinalized(async () => await executeCommands('finalized'))
301
+ onFinalized(async () => {
302
+ await executeCommands('finalized')
303
+ // Last, so every other hook has had its chance.
304
+ reportUnfired()
305
+ })
74
306
 
75
307
  return { executeCommand }
76
308
  }