mikser-io 10.2.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.
- package/docs/configuration.md +117 -13
- package/package.json +1 -1
- package/src/cli.js +25 -6
- package/src/database/index.js +74 -10
- package/src/plugins/commands.js +233 -1
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
|
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,
|
|
72
|
-
|
|
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
|
|
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()
|
package/src/database/index.js
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
import path from 'node:path'
|
|
46
46
|
import { mkdirSync, unlinkSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
47
|
+
import { createHash } from 'node:crypto'
|
|
47
48
|
import Database from 'better-sqlite3'
|
|
48
49
|
import runtime from '../runtime.js'
|
|
49
50
|
import { isReportOnlyRun } from '../tools.js'
|
|
@@ -232,6 +233,43 @@ export function useDatabase() {
|
|
|
232
233
|
return db
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
// What the cache's SHAPE is, as a value that changes only when the cache
|
|
237
|
+
// stops being reusable.
|
|
238
|
+
//
|
|
239
|
+
// It used to be the package version, so every release wiped every
|
|
240
|
+
// deployment's cache and rebuilt from cold — a patch that touched a README
|
|
241
|
+
// discarded 14k entities and re-rendered a site. The version was standing in
|
|
242
|
+
// for "something might have changed", which is true of every release and
|
|
243
|
+
// therefore says nothing.
|
|
244
|
+
//
|
|
245
|
+
// Two things actually invalidate a cache, and both are in here:
|
|
246
|
+
//
|
|
247
|
+
// the SQL — a column, index or table that moved. Hashed from the
|
|
248
|
+
// registered scripts themselves, so it cannot drift from what
|
|
249
|
+
// is applied: the fingerprint and the schema come from one
|
|
250
|
+
// string.
|
|
251
|
+
// the SHAPE — what the rows MEAN, when the SQL is untouched. A change to
|
|
252
|
+
// how inputHash is computed, to the edge kinds in a
|
|
253
|
+
// refClosure, to what a destination is relative to. No parser
|
|
254
|
+
// can see these, so they are a number someone bumps.
|
|
255
|
+
//
|
|
256
|
+
// BUMP DERIVED_SHAPE when a release changes the meaning of anything already
|
|
257
|
+
// stored. Getting that wrong is a silent stale cache, which is the failure
|
|
258
|
+
// this whole subsystem keeps producing — so when in doubt, bump. A needless
|
|
259
|
+
// wipe costs one cold rebuild; a missed one costs a site serving wrong
|
|
260
|
+
// output with every signal green.
|
|
261
|
+
const DERIVED_SHAPE = 1
|
|
262
|
+
|
|
263
|
+
// `<shape>:<sql-hash>` rather than a bare hash: the stored value is read by
|
|
264
|
+
// a human when a wipe happens, and the two halves say WHICH moved.
|
|
265
|
+
function cacheFingerprint(registered) {
|
|
266
|
+
const sql = [...registered.entries()]
|
|
267
|
+
.map(([name, value]) => `${name}\n${schemaEntry(value).sql}`)
|
|
268
|
+
.sort()
|
|
269
|
+
.join('\n')
|
|
270
|
+
return `${DERIVED_SHAPE}:${createHash('sha1').update(sql).digest('hex').slice(0, 12)}`
|
|
271
|
+
}
|
|
272
|
+
|
|
235
273
|
// Build a sqlite-backed database handle. Exported so tests can exercise
|
|
236
274
|
// the lifecycle and transaction semantics in isolation (without driving
|
|
237
275
|
// the full onLoaded chain). The runtime path uses this internally from
|
|
@@ -296,6 +334,11 @@ export function createSqliteDatabase({
|
|
|
296
334
|
|
|
297
335
|
const stmtMeta = handle.prepare('SELECT value FROM mikser_meta WHERE key = ?')
|
|
298
336
|
const recorded = stmtMeta.get('schema_version')?.value
|
|
337
|
+
// The identity of the cache's shape, not of the release that wrote
|
|
338
|
+
// it. `version` is still carried — into `built_by_version` at stamp
|
|
339
|
+
// time — because "which mikser built this" is worth knowing when
|
|
340
|
+
// reading a cache; it is simply not a reason to throw one away.
|
|
341
|
+
const fingerprint = cacheFingerprint(schemas)
|
|
299
342
|
|
|
300
343
|
// A config change invalidates the cache for the same reason a version
|
|
301
344
|
// change does: the derived state was computed under different rules.
|
|
@@ -327,13 +370,13 @@ export function createSqliteDatabase({
|
|
|
327
370
|
const reportOnly = isReportOnlyRun()
|
|
328
371
|
|
|
329
372
|
let upgradedFromVersion = null
|
|
330
|
-
if (reportOnly && !forceWipe && ((recorded && recorded !==
|
|
373
|
+
if (reportOnly && !forceWipe && ((recorded && recorded !== fingerprint) || configChanged)) {
|
|
331
374
|
logger?.warn(
|
|
332
375
|
'The cache is stale (%s changed since it was written) and this is a read-only run, '
|
|
333
376
|
+ 'so it was NOT wiped — the answer below describes the last build, which may not '
|
|
334
377
|
+ 'match your sources. Run a build to refresh it.',
|
|
335
378
|
configChanged ? 'config' : 'schema version')
|
|
336
|
-
} else if (configChanged && !(recorded && recorded !==
|
|
379
|
+
} else if (configChanged && !(recorded && recorded !== fingerprint)) {
|
|
337
380
|
logger?.warn(
|
|
338
381
|
'Config changed since the last run. Wiping the cache and rebuilding from sources '
|
|
339
382
|
+ '(files are the source of truth — no source data is affected). The stamp covers %s and '
|
|
@@ -341,7 +384,7 @@ export function createSqliteDatabase({
|
|
|
341
384
|
runtime.options.config,
|
|
342
385
|
)
|
|
343
386
|
}
|
|
344
|
-
if (!reportOnly && (forceWipe || (recorded && recorded !==
|
|
387
|
+
if (!reportOnly && (forceWipe || (recorded && recorded !== fingerprint) || configChanged)) {
|
|
345
388
|
// Schema mismatch on upgrade or downgrade. Per ADR-0002 the
|
|
346
389
|
// files on disk are the source of truth and this database
|
|
347
390
|
// is a derived cache, so the right behavior is to wipe the
|
|
@@ -352,10 +395,27 @@ export function createSqliteDatabase({
|
|
|
352
395
|
// expect a cold-start rebuild on this run. No data loss
|
|
353
396
|
// beyond the cache itself; everything in mikser.sqlite is
|
|
354
397
|
// recoverable from the working folder.
|
|
355
|
-
if (recorded && recorded !==
|
|
398
|
+
if (recorded && recorded !== fingerprint) {
|
|
399
|
+
// Names WHICH half moved, because the two mean different
|
|
400
|
+
// things to whoever is reading. A shape bump is a deliberate
|
|
401
|
+
// decision someone made in this release; a SQL change is a
|
|
402
|
+
// table that moved. "stored=10.0.1, current=10.1.0" said
|
|
403
|
+
// neither, and could not — it only ever meant "a release
|
|
404
|
+
// happened".
|
|
405
|
+
const [storedShape, storedSql] = String(recorded).split(':')
|
|
406
|
+
const [shape, sql] = fingerprint.split(':')
|
|
407
|
+
const cause = storedSql === undefined
|
|
408
|
+
? 'the cache predates schema fingerprints'
|
|
409
|
+
: storedShape !== shape && storedSql !== sql
|
|
410
|
+
? 'the schema tables AND the meaning of what is stored both changed'
|
|
411
|
+
: storedShape !== shape
|
|
412
|
+
? 'this release changed the meaning of what is already stored'
|
|
413
|
+
: 'the schema tables changed'
|
|
356
414
|
logger?.warn(
|
|
357
|
-
'
|
|
358
|
-
|
|
415
|
+
'Cache shape changed — %s (stored=%s, current=%s, this is mikser %s). Wiping the cache '
|
|
416
|
+
+ 'and rebuilding from sources (files are the source of truth — no source data is '
|
|
417
|
+
+ 'affected). A release that changes neither reuses the cache.',
|
|
418
|
+
cause, recorded, fingerprint, version,
|
|
359
419
|
)
|
|
360
420
|
} else if (forceWipe) {
|
|
361
421
|
logger?.info('Clearing the cache and rebuilding from sources.')
|
|
@@ -365,8 +425,8 @@ export function createSqliteDatabase({
|
|
|
365
425
|
// the same output whether the version moved, the config moved, or
|
|
366
426
|
// someone passed --clear.
|
|
367
427
|
reportWipe(
|
|
368
|
-
recorded && recorded !==
|
|
369
|
-
recorded && recorded !==
|
|
428
|
+
recorded && recorded !== fingerprint ? 'version' : forceWipe ? 'clear' : 'config',
|
|
429
|
+
recorded && recorded !== fingerprint ? { from: recorded, to: fingerprint } : {},
|
|
370
430
|
)
|
|
371
431
|
|
|
372
432
|
// Unlink, rather than dropping table by table.
|
|
@@ -412,7 +472,7 @@ export function createSqliteDatabase({
|
|
|
412
472
|
// no stamp, so the next start sees a mismatch and rebuilds properly.
|
|
413
473
|
// config_checksum goes with it for the same reason — a config change
|
|
414
474
|
// that half-applied must not look applied.
|
|
415
|
-
pendingStamp = { version, config: currentConfig }
|
|
475
|
+
pendingStamp = { fingerprint, version, config: currentConfig }
|
|
416
476
|
|
|
417
477
|
// Build provisioning context. firstRun is true when the file
|
|
418
478
|
// didn't exist before this open OR when the schema mismatch
|
|
@@ -528,7 +588,11 @@ export function createSqliteDatabase({
|
|
|
528
588
|
commitStamp() {
|
|
529
589
|
if (!pendingStamp || !handle) return false
|
|
530
590
|
const stamp = handle.prepare('INSERT OR REPLACE INTO mikser_meta (key, value) VALUES (?, ?)')
|
|
531
|
-
stamp.run('schema_version', pendingStamp.
|
|
591
|
+
stamp.run('schema_version', pendingStamp.fingerprint)
|
|
592
|
+
// Which release wrote this cache. Recorded because it is the
|
|
593
|
+
// first thing a person wants when reading one, and deliberately
|
|
594
|
+
// NOT compared against anything — that was the bug.
|
|
595
|
+
stamp.run('built_by_version', pendingStamp.version)
|
|
532
596
|
if (pendingStamp.config) stamp.run('config_checksum', pendingStamp.config)
|
|
533
597
|
pendingStamp = null
|
|
534
598
|
runtime.options.cacheRebuildInterrupted = false
|
package/src/plugins/commands.js
CHANGED
|
@@ -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 () =>
|
|
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
|
}
|