mikser-io 10.3.0 → 10.5.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/app.js +15 -6
- package/docs/configuration.md +120 -15
- package/docs/diagnostics.md +4 -2
- package/docs/getting-started.md +5 -2
- package/docs/lifecycle.md +1 -1
- package/package.json +1 -1
- package/src/cli.js +25 -6
- package/src/engine.js +29 -11
- package/src/instance.js +19 -0
- package/src/logger.js +141 -1
- package/src/plugins/commands.js +233 -1
- package/src/report.js +22 -0
package/app.js
CHANGED
|
@@ -54,11 +54,11 @@ function locate(argv) {
|
|
|
54
54
|
// cycle reports drift that is not there.
|
|
55
55
|
const tool = value('--tool')
|
|
56
56
|
const explain = value('--explain')
|
|
57
|
-
const request = has('--tools') ? { type: 'report', tools: true, json: has('--json') }
|
|
58
|
-
: tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
|
|
59
|
-
: explain ? { type: 'report', explain, json: has('--json') }
|
|
60
|
-
: has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json') }
|
|
61
|
-
: has('--fingerprint') ? { type: 'report', fingerprint: true, json: has('--json') }
|
|
57
|
+
const request = has('--tools') ? { type: 'report', tools: true, json: has('--json'), log: value('--log') ?? value('-l') }
|
|
58
|
+
: tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json'), log: value('--log') ?? value('-l') }
|
|
59
|
+
: explain ? { type: 'report', explain, json: has('--json'), log: value('--log') ?? value('-l') }
|
|
60
|
+
: has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json'), log: value('--log') ?? value('-l') }
|
|
61
|
+
: has('--fingerprint') ? { type: 'report', fingerprint: true, json: has('--json'), log: value('--log') ?? value('-l') }
|
|
62
62
|
: { type: 'build',
|
|
63
63
|
clear: has('--clear'),
|
|
64
64
|
// Not a flag that happens to be set — the client's OUTPUT
|
|
@@ -74,7 +74,16 @@ function locate(argv) {
|
|
|
74
74
|
// rebuilt whatever the gates let through, which on a settled tree
|
|
75
75
|
// is nothing, and a caller asking for a full re-render got a no-op
|
|
76
76
|
// reported as success.
|
|
77
|
-
force: has('--force', '-f')
|
|
77
|
+
force: has('--force', '-f'),
|
|
78
|
+
// The level travels for the same reason --json does: it is an
|
|
79
|
+
// OUTPUT CONTRACT, and the process doing the writing is the
|
|
80
|
+
// instance, which was started without it. A forwarded --log
|
|
81
|
+
// otherwise changed the level of a process that prints nothing.
|
|
82
|
+
log: value('--log') ?? value('-l'),
|
|
83
|
+
// Instance state rather than a contract: these outlive the request
|
|
84
|
+
// on purpose, which is the case a per-request flag cannot serve.
|
|
85
|
+
logInstall: value('--log-install'),
|
|
86
|
+
logReset: has('--log-reset') }
|
|
78
87
|
|
|
79
88
|
return {
|
|
80
89
|
longRunning,
|
package/docs/configuration.md
CHANGED
|
@@ -54,8 +54,9 @@ These options are part of `runtime.options` and apply to the engine itself.
|
|
|
54
54
|
| `force` | `-f, --force` | boolean | `false` | Rebuild everything; disable incremental dispatch. |
|
|
55
55
|
| `resume` | `-R, --resume` | boolean | `false` | Continue from journal entries left by a previous interrupted run; skip the initial filesystem scan. The journal table survives crashes, so an interrupted cycle can be picked up by re-running with `--resume`. |
|
|
56
56
|
| `verify` | `--audit-output` | boolean | `false` | Verify the output folder against the manifest snapshot — report drift instead of building. |
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
57
|
+
| `log` | `-l, --log` | string | — | Log level for this run: `trace`, `debug`, `info`, `notice`, `warn`, `error`, `fatal`, `silent`. Replaces the old `--debug` / `--trace` booleans, which could not express "warnings only" and, in `--debug`'s case, did nothing at all — it moved the logger's level while the terminal stream kept the one it was built with. |
|
|
58
|
+
| `logInstall` | `--log-install` | string | — | Set the level on a **running** instance, so its own rebuilds are verbose too — the case a per-request flag cannot serve. Expires after 30 minutes, dies with the process, and is disclosed in the build report under `logLevel`. |
|
|
59
|
+
| `logReset` | `--log-reset` | boolean | `false` | Return a running instance to its configured level. |
|
|
59
60
|
| `threads` | — | number | `4` | Worker thread count for the Piscina pools (`renderWorkers`, `postprocessWorkers`). Both pools are lazy (`minThreads: 0` + `idleTimeout: 30_000`) so INLINE-only workloads spin up zero workers. |
|
|
60
61
|
| `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port (default `3001`) after all plugins have mounted their routes. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
|
|
61
62
|
| `junk` | — | array\|false | built-in list | OS and file-manager litter, filtered out of both the scan and the watcher. The dot-prefixed files (`.DS_Store`, `._*`) were already invisible — globby defaults to `dot: false` and the watcher ignores leading dots — but the Windows ones are **not** dotfiles: `Thumbs.db` and `desktop.ini` were measurably scanned *and* watched, and became entities. The list is deliberately conservative (OS/file-manager artifacts and application lock files only, no `*.tmp`, `*.bak` or editor backups), because a filter that silently drops content is worse than the litter it prevents. `false` disables it; an array replaces it. See `isJunkPath` / `JUNK_IGNORE` in `src/utils.js`. Plugins that write metadata next to content add their own patterns with `registerJunk({ ignore, match })` — the engine provides the mechanism and the plugin the knowledge of what its files are called (`mikser-io-drive` registers `*.nephelemeta`). Plugin registrations survive an array override, since narrowing the OS list is not a request to start importing a library's sidecars. |
|
|
@@ -360,24 +361,128 @@ export default {
|
|
|
360
361
|
|
|
361
362
|
### `commands`
|
|
362
363
|
|
|
364
|
+
Runs shell commands at lifecycle hooks. A plugin, so it goes in `plugins` and
|
|
365
|
+
takes its options as factory arguments:
|
|
366
|
+
|
|
363
367
|
```js
|
|
368
|
+
import { commands } from 'mikser-io'
|
|
369
|
+
|
|
364
370
|
export default {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return 'npm run optimize'
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
371
|
+
plugins: [
|
|
372
|
+
commands({
|
|
373
|
+
load: 'echo Loading...',
|
|
374
|
+
finalized: ['npm run compress', 'npm run deploy'],
|
|
375
|
+
// Also an async function, resolved when the hook fires
|
|
376
|
+
processed: async () => process.env.NODE_ENV === 'production' && 'npm run optimize',
|
|
377
|
+
}),
|
|
378
|
+
],
|
|
377
379
|
}
|
|
378
380
|
```
|
|
379
381
|
|
|
380
|
-
|
|
382
|
+
Hook names: `load`, `loaded`, `import`, `imported`, `process`, `processed`,
|
|
383
|
+
`persist`, `persisted`, `beforeRender`, `render`, `afterRender`, `cancel`,
|
|
384
|
+
`cancelled`, `finalize`, `finalized`.
|
|
385
|
+
|
|
386
|
+
#### From the command line
|
|
387
|
+
|
|
388
|
+
One flag names a hook, so a one-off side effect needs no config edit:
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
mikser --command finalized="node deploy/publish.mjs"
|
|
392
|
+
|
|
393
|
+
# repeatable
|
|
394
|
+
mikser --command loaded="node probe.mjs" --command finalized="node publish.mjs"
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
The same shape as `--tool <name>`: one word of CLI namespace for an
|
|
398
|
+
open-ended set, rather than one flag per hook. It runs *in addition to*
|
|
399
|
+
whatever the config declares for that hook, and only for that run. Forwarded
|
|
400
|
+
to a running `--watch` instance it **replaces** the previous request's
|
|
401
|
+
commands rather than adding to them, and the instance's own rebuilds run
|
|
402
|
+
none — one hook is registered at load and reads a value the instance swaps
|
|
403
|
+
per request. The flag exists only when `commands()` is in the config, like
|
|
404
|
+
every plugin option, and an unknown hook name is refused before anything is
|
|
405
|
+
built.
|
|
406
|
+
|
|
407
|
+
#### Installing on a running instance
|
|
408
|
+
|
|
409
|
+
`--command` spends itself on one build. A watcher's **own** rebuilds — the
|
|
410
|
+
ones a file save triggers, which is the point of watching — run nothing, so a
|
|
411
|
+
probe fires only when you forward a build by hand. `--command-install` puts it
|
|
412
|
+
on the instance instead:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
mikser --command-install finalized="node probe.mjs" # installs, and runs now
|
|
416
|
+
# ...edit a file, the watcher rebuilds → probe runs
|
|
417
|
+
# ...edit again → probe runs
|
|
418
|
+
mikser --command-reset # clears all of them
|
|
419
|
+
mikser --command-reset finalized # or just one hook
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Installed commands are announced on **every** cycle, not once — you may have
|
|
423
|
+
set one an hour ago, and each build's report has to carry the fact that it was
|
|
424
|
+
not a function of the repository alone. Per-request commands are announced
|
|
425
|
+
once per process, because they are in the invocation you just typed.
|
|
426
|
+
|
|
427
|
+
On a one-shot build there is no instance to install on, so `--command-install`
|
|
428
|
+
runs once and exits with the process exactly like `--command` — reported under
|
|
429
|
+
`command-install-without-instance` rather than left to look persistent.
|
|
430
|
+
|
|
431
|
+
To see what is attached, read the last build: every installed command is
|
|
432
|
+
announced on **every** cycle under `command-from-cli`, so the log or the
|
|
433
|
+
`--json` report names each one. There is deliberately no `--tool commands` —
|
|
434
|
+
the tool registry is mirrored into MCP over HTTP with an allow-all default,
|
|
435
|
+
and a command string routinely carries a path, a host or a token. Listing
|
|
436
|
+
them there would hand an authenticated web client a map of the build box, and
|
|
437
|
+
tools have no per-tool scope to gate it with.
|
|
438
|
+
|
|
439
|
+
Two hooks behave differently from the rest, and both say so rather than
|
|
440
|
+
failing quietly:
|
|
441
|
+
|
|
442
|
+
- **`load` is refused.** Options are declared *during* the load phase and the
|
|
443
|
+
table is parsed after it, so a `--command load=` could never fire. It is
|
|
444
|
+
named and refused rather than left out of the list, because "no hook named
|
|
445
|
+
load" would be untrue and would send you looking for a typo. Declare it in
|
|
446
|
+
the config instead.
|
|
447
|
+
- **`loaded` does not fire for a forwarded build.** A running instance loaded
|
|
448
|
+
at startup and a rebuild does not repeat the load phase, so a load-phase
|
|
449
|
+
hook belongs to the instance rather than to the request. Asking for one
|
|
450
|
+
warns under `command-hook-not-reached`. Stop the instance, or use a
|
|
451
|
+
per-cycle hook.
|
|
452
|
+
|
|
453
|
+
Two hooks behave differently from the rest, and both say so rather than
|
|
454
|
+
failing quietly:
|
|
455
|
+
|
|
456
|
+
- **`load` is refused.** Options are declared *during* the load phase and the
|
|
457
|
+
table is parsed after it, so a `--command load=` could never fire. Declare
|
|
458
|
+
it in the config instead.
|
|
459
|
+
- **`loaded` does not fire for a forwarded build.** A running instance loaded
|
|
460
|
+
at startup and a rebuild does not repeat the load phase, so a load-phase
|
|
461
|
+
hook belongs to the instance rather than to the request. Asking for one
|
|
462
|
+
warns under `command-hook-not-reached`. Stop the instance, or use a
|
|
463
|
+
per-cycle hook.
|
|
464
|
+
|
|
465
|
+
Two things to know, and they are the reason this is a flag rather than a
|
|
466
|
+
convenience:
|
|
467
|
+
|
|
468
|
+
**It is reported, under `command-from-cli`.** A build is otherwise a function
|
|
469
|
+
of the repository — same commit, same bytes, and `--fingerprint` can prove it.
|
|
470
|
+
A command from argv makes it a function of the repo *and* how it was invoked,
|
|
471
|
+
so an agent, a person and CI can all run "the same build" and get different
|
|
472
|
+
output. The warning carries the hook and the command string into `--json`
|
|
473
|
+
`warnings`, so a fingerprint taken from that build stays interpretable instead
|
|
474
|
+
of quietly meaning something else.
|
|
475
|
+
|
|
476
|
+
**A command that writes into the output folder fails `--audit-output`.** Not a
|
|
477
|
+
limitation to work around — mikser hashes each file as it writes it, so
|
|
478
|
+
rewriting one afterwards is indistinguishable from tampering, and the audit
|
|
479
|
+
reports it as `Mismatched` and exits 2. Post-build minification through a hook
|
|
480
|
+
is therefore the wrong shape; it belongs in a renderer or a postprocessor,
|
|
481
|
+
where the hash is taken over what is actually deployed. The honest use for
|
|
482
|
+
hooks is side effects that do not touch `out/` — publishing, notifying,
|
|
483
|
+
syncing — which is also the case where a flag beats config, because deploy
|
|
484
|
+
steps are environment-specific and do not belong in a repository's build
|
|
485
|
+
config.
|
|
381
486
|
|
|
382
487
|
### `shares`
|
|
383
488
|
|
package/docs/diagnostics.md
CHANGED
|
@@ -328,7 +328,7 @@ compare against. It never appears alongside `matched` or `dependency`
|
|
|
328
328
|
either: a consumer switches on `reason` and reads one field, so a stray
|
|
329
329
|
key from another branch would make that switch wrong.
|
|
330
330
|
|
|
331
|
-
The same detail appears at `--debug` for a watch run, one line per render.
|
|
331
|
+
The same detail appears at `--log debug` for a watch run, one line per render.
|
|
332
332
|
It is deliberately not in the build's normal output — the counts are the
|
|
333
333
|
summary and `--json` is the record — but when you are watching one page
|
|
334
334
|
misbehave, the trigger is the point.
|
|
@@ -419,7 +419,9 @@ thing `find out -type f` cannot do.
|
|
|
419
419
|
| `-f, --force` | ignore all three gates (import checksum, dispatch, manifest) and re-render everything |
|
|
420
420
|
| `-R, --resume` | continue from a previous interrupted run's journal; skips the filesystem scan |
|
|
421
421
|
| `-r, --clear` | clear state before running |
|
|
422
|
-
| `-
|
|
422
|
+
| `-l, --log <level>` | set the level for this run; `trace` includes per-entity catalog writes |
|
|
423
|
+
| `--log-install <level>` | raise the level on a RUNNING instance without restarting it — restarting drops every connected MCP and drive session, so the tool you need should not require the risky act you are diagnosing. Expires after 30 minutes, dies with the process, and appears in the build report under `logLevel` so a level left on production is findable. |
|
|
424
|
+
| `--log-reset` | put a running instance back to its configured level |
|
|
423
425
|
| `--tools` | list the registered tools, then exit; `--json` for full schemas |
|
|
424
426
|
| `--tool <name>` | run one tool and print its result, then exit. `--tool-args '<json>'` supplies arguments |
|
|
425
427
|
|
package/docs/getting-started.md
CHANGED
|
@@ -114,8 +114,11 @@ mikser [options]
|
|
|
114
114
|
interrupted run; skip the initial filesystem scan
|
|
115
115
|
--audit-output Audit output against recorded snapshots; report
|
|
116
116
|
drift instead of building
|
|
117
|
-
-
|
|
118
|
-
|
|
117
|
+
-l, --log <level> Log level for this run: trace, debug, info,
|
|
118
|
+
notice, warn, error, fatal, silent
|
|
119
|
+
--log-install <level> Set the level on a RUNNING instance, so its
|
|
120
|
+
own rebuilds are verbose too. Expires.
|
|
121
|
+
--log-reset Return an instance to its configured level
|
|
119
122
|
-e, --runtime-folder <folder> Runtime/temp folder (default: runtime)
|
|
120
123
|
```
|
|
121
124
|
|
package/docs/lifecycle.md
CHANGED
|
@@ -91,7 +91,7 @@ onInitialized(async () => {
|
|
|
91
91
|
|
|
92
92
|
**What Mikser does here:**
|
|
93
93
|
- Parses CLI arguments and merges into `runtime.options`
|
|
94
|
-
- Sets logger level
|
|
94
|
+
- Sets the logger level from `--log <level>`, moving the terminal stream with it
|
|
95
95
|
- Resolves absolute paths for `workingFolder`, `outputFolder`, `runtimeFolder`
|
|
96
96
|
- Creates the `runtimeFolder` directory
|
|
97
97
|
- Clears `outputFolder` and `runtimeFolder` if `--clear` was set
|
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/engine.js
CHANGED
|
@@ -24,7 +24,10 @@ import map from 'p-map'
|
|
|
24
24
|
import Queue from 'p-queue'
|
|
25
25
|
import packageInfo from '../package.json' with { type: 'json' }
|
|
26
26
|
import { attachServerCliOptions, setupServer } from './server.js'
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
createMikserLogger, setLogLevel, rememberBaseLevel, installLogLevel, resetLogLevel,
|
|
29
|
+
applyInstalledLogLevel, installedLogLevel, LOG_LEVELS, INSTALLED_LOG_TTL_MS,
|
|
30
|
+
} from './logger.js'
|
|
28
31
|
import { inputHashOf } from './utils.js'
|
|
29
32
|
import { createTrack, mergeTrack } from './track.js'
|
|
30
33
|
import { queryContext } from './database/query-context.js'
|
|
@@ -552,8 +555,19 @@ export async function setup(options) {
|
|
|
552
555
|
.option('--fingerprint', 'hash everything this build wrote — including what it wrote through a '
|
|
553
556
|
+ 'symlink, which `find` does not descend into — and exit. One comparable number per output '
|
|
554
557
|
+ 'tree, plus one per asset preset, for proving an upgrade moved no bytes.', false)
|
|
555
|
-
|
|
556
|
-
|
|
558
|
+
// One level, not two booleans.
|
|
559
|
+
//
|
|
560
|
+
// `--debug` and `--trace` could not say "warnings only on this
|
|
561
|
+
// build" or "trace this one thing", and --debug did not work at
|
|
562
|
+
// all: it moved the logger's level while the terminal stream kept
|
|
563
|
+
// the one it was built with, so debug records were accepted and
|
|
564
|
+
// discarded. Both are gone rather than aliased — a flag that lies
|
|
565
|
+
// is worse than a flag that is missing.
|
|
566
|
+
.option('-l --log <level>', `log level for this run: ${LOG_LEVELS.join(', ')}`)
|
|
567
|
+
.option('--log-install <level>', 'set the log level on a RUNNING instance, so its own '
|
|
568
|
+
+ `rebuilds are verbose too. Expires after ${INSTALLED_LOG_TTL_MS / 60000} minutes and `
|
|
569
|
+
+ 'dies with the process. Levels as above.')
|
|
570
|
+
.option('--log-reset', 'return a running instance to its configured log level', false)
|
|
557
571
|
.option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
|
|
558
572
|
attachServerCliOptions(runtime.engine.commander)
|
|
559
573
|
|
|
@@ -666,16 +680,20 @@ The full version, with what each code means: docs/diagnostics.md`)
|
|
|
666
680
|
// runtime.options.info gates the progress bar — gauge stays
|
|
667
681
|
// silent in --debug/--trace modes because logs are voluminous
|
|
668
682
|
// there and a bar on top would just be noise.
|
|
683
|
+
//
|
|
684
|
+
// Applied through setLogLevel so the terminal STREAM moves with the
|
|
685
|
+
// logger — the whole reason --debug did nothing.
|
|
669
686
|
runtime.options.info = true
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
runtime.options.info = false
|
|
687
|
+
const asked = runtime.options.log
|
|
688
|
+
if (asked) {
|
|
689
|
+
if (!LOG_LEVELS.includes(asked)) {
|
|
690
|
+
throw new Error(`--log ${asked}: no such level. Levels: ${LOG_LEVELS.join(', ')}`)
|
|
691
|
+
}
|
|
692
|
+
setLogLevel(asked)
|
|
693
|
+
// A bar on top of debug output is noise, and silent means silent.
|
|
694
|
+
if (asked === 'trace' || asked === 'debug' || asked === 'silent') runtime.options.info = false
|
|
678
695
|
}
|
|
696
|
+
rememberBaseLevel(asked && LOG_LEVELS.includes(asked) ? asked : 'info')
|
|
679
697
|
|
|
680
698
|
// Resolve folders inside onInitialize so journal.js and
|
|
681
699
|
// catalog.js (which initialize in onInitialized) see absolute
|
package/src/instance.js
CHANGED
|
@@ -37,6 +37,9 @@ import runtime from './runtime.js'
|
|
|
37
37
|
import { onLoaded } from './lifecycle.js'
|
|
38
38
|
import { renderErrorCount } from './report.js'
|
|
39
39
|
import { runReportOnly } from './engine.js'
|
|
40
|
+
import {
|
|
41
|
+
setLogLevel, installLogLevel, resetLogLevel, restingLogLevel,
|
|
42
|
+
} from './logger.js'
|
|
40
43
|
import { emitReport } from './report.js'
|
|
41
44
|
import { pluginOptionsFrom } from './cli.js'
|
|
42
45
|
|
|
@@ -418,6 +421,17 @@ async function withRequestOutput(request, run) {
|
|
|
418
421
|
// Set for the duration of the request and restored with the rest, so a
|
|
419
422
|
// watcher's own cycle before or after is unaffected.
|
|
420
423
|
request = { ...request, requested: true }
|
|
424
|
+
|
|
425
|
+
// The level the CLIENT asked for, for this request only.
|
|
426
|
+
//
|
|
427
|
+
// Restored in the finally below like every other part of the contract, so
|
|
428
|
+
// a watcher's own cycle before or after keeps the level it was running at.
|
|
429
|
+
// An installed level is different and deliberately not touched here — it
|
|
430
|
+
// outlives the request, which is its whole point.
|
|
431
|
+
if (request.logReset) resetLogLevel()
|
|
432
|
+
if (request.logInstall) installLogLevel(request.logInstall)
|
|
433
|
+
if (request.log) setLogLevel(request.log)
|
|
434
|
+
|
|
421
435
|
for (const key of ['json', 'tool', 'tools', 'requested']) {
|
|
422
436
|
prior[key] = runtime.options[key]
|
|
423
437
|
if (request[key]) runtime.options[key] = request[key]
|
|
@@ -437,6 +451,11 @@ async function withRequestOutput(request, run) {
|
|
|
437
451
|
return await run()
|
|
438
452
|
} finally {
|
|
439
453
|
Object.assign(runtime.options, prior)
|
|
454
|
+
// Back to where the level RESTS, not to what it was a moment ago.
|
|
455
|
+
// An installed level survives the request; a per-request --log does
|
|
456
|
+
// not; and a --log-reset in this same request has already moved the
|
|
457
|
+
// resting point, so restoring "the previous level" would undo it.
|
|
458
|
+
setLogLevel(restingLogLevel())
|
|
440
459
|
}
|
|
441
460
|
}
|
|
442
461
|
|
package/src/logger.js
CHANGED
|
@@ -30,7 +30,7 @@ import Gauge from 'gauge'
|
|
|
30
30
|
import { Writable } from 'node:stream'
|
|
31
31
|
import runtime from './runtime.js'
|
|
32
32
|
import { useLogger } from './engine.js'
|
|
33
|
-
import { onLoad } from './lifecycle.js'
|
|
33
|
+
import { onLoad, onFinalized } from './lifecycle.js'
|
|
34
34
|
import { captureWarning, captureFault } from './report.js'
|
|
35
35
|
|
|
36
36
|
// Custom level — `notice` slots between info and warn. Used by mikser
|
|
@@ -355,6 +355,111 @@ export function addLogTransport(entry) {
|
|
|
355
355
|
return true
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
// The levels a person can ask for, in pino's order.
|
|
359
|
+
export const LOG_LEVELS = ['trace', 'debug', 'info', 'notice', 'warn', 'error', 'fatal', 'silent']
|
|
360
|
+
|
|
361
|
+
// What the run was configured with, so a reset has something to return to.
|
|
362
|
+
let baseLevel = 'info'
|
|
363
|
+
// { level, expiresAt } — set by --log-install, survives the request that set it.
|
|
364
|
+
let installedLevel = null
|
|
365
|
+
|
|
366
|
+
// Move the level, and mean it.
|
|
367
|
+
//
|
|
368
|
+
// `--debug` used to set runtime.engine.logger.level and stop there, which did
|
|
369
|
+
// nothing observable: the pino INSTANCE accepted debug records while the
|
|
370
|
+
// terminal stream still filtered them at the level it was constructed with,
|
|
371
|
+
// so they were accepted and discarded. It only ever worked when a logging
|
|
372
|
+
// transport happened to be configured, because that is the one path that
|
|
373
|
+
// rebuilt the logger. Measured on 10.4.0: identical output with and without
|
|
374
|
+
// the flag, down to the line count.
|
|
375
|
+
//
|
|
376
|
+
// So the stream entry moves too, and the instance is rebuilt over the live
|
|
377
|
+
// stream list the way addLogTransport already does — same swap, so a transport
|
|
378
|
+
// added earlier survives.
|
|
379
|
+
//
|
|
380
|
+
// TRANSPORTS KEEP THEIR OWN LEVEL. They were built with the level they
|
|
381
|
+
// declared, and `--log debug` is a statement about what the operator wants to
|
|
382
|
+
// SEE, not an instruction to flood Better Stack. A transport that wants more
|
|
383
|
+
// says so in its own entry.
|
|
384
|
+
export function setLogLevel(level) {
|
|
385
|
+
if (!LOG_LEVELS.includes(level)) return false
|
|
386
|
+
if (currentStreams === null || !runtime.engine) return false
|
|
387
|
+
// Index 0 is the terminal entry — see createMikserLogger, where the list
|
|
388
|
+
// is seeded with it before any transport is appended.
|
|
389
|
+
currentStreams[0] = { ...currentStreams[0], level }
|
|
390
|
+
currentLevel = level
|
|
391
|
+
runtime.engine.logger = pino(
|
|
392
|
+
{ level: 'trace', customLevels: CUSTOM_LEVELS },
|
|
393
|
+
pino.multistream(currentStreams),
|
|
394
|
+
)
|
|
395
|
+
return true
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The level a run starts at, remembered so --log-reset has a target.
|
|
399
|
+
export function rememberBaseLevel(level) {
|
|
400
|
+
if (LOG_LEVELS.includes(level)) baseLevel = level
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Raise the level on a RUNNING instance, until it expires.
|
|
404
|
+
//
|
|
405
|
+
// The case a per-request flag structurally cannot serve: a watcher's own
|
|
406
|
+
// rebuilds. Today the only way to make a misbehaving production instance
|
|
407
|
+
// verbose is to restart it — which drops every connected MCP and drive
|
|
408
|
+
// session, and is the incident path, so the tool you need is available only by
|
|
409
|
+
// performing the risky act you are trying to diagnose.
|
|
410
|
+
//
|
|
411
|
+
// EXPIRES, because the failure mode is a full disk weeks later with nobody
|
|
412
|
+
// remembering who asked. It also dies with the process, so a restart is a
|
|
413
|
+
// second guarantee rather than the only one.
|
|
414
|
+
export function installLogLevel(level, ttlMs = INSTALLED_LOG_TTL_MS) {
|
|
415
|
+
if (!LOG_LEVELS.includes(level)) return false
|
|
416
|
+
installedLevel = { level, expiresAt: Date.now() + ttlMs }
|
|
417
|
+
return setLogLevel(level)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function resetLogLevel() {
|
|
421
|
+
installedLevel = null
|
|
422
|
+
return setLogLevel(baseLevel)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// What is in force, and whether an installed level has run out. Called at the
|
|
426
|
+
// top of a cycle so expiry lands on a build boundary rather than mid-render.
|
|
427
|
+
export function applyInstalledLogLevel() {
|
|
428
|
+
if (!installedLevel) return null
|
|
429
|
+
if (Date.now() >= installedLevel.expiresAt) {
|
|
430
|
+
const expired = installedLevel.level
|
|
431
|
+
installedLevel = null
|
|
432
|
+
setLogLevel(baseLevel)
|
|
433
|
+
return { expired, level: baseLevel }
|
|
434
|
+
}
|
|
435
|
+
if (currentLevel !== installedLevel.level) setLogLevel(installedLevel.level)
|
|
436
|
+
return { level: installedLevel.level, expiresAt: installedLevel.expiresAt }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Where the level RESTS between requests: an installed one if there is one,
|
|
440
|
+
// otherwise what the run was configured with.
|
|
441
|
+
//
|
|
442
|
+
// The single rule the restore needs. Putting back "the level before this
|
|
443
|
+
// request" instead was wrong for exactly one case and it was the important
|
|
444
|
+
// one: --log-reset captured debug, cleared it, and the restore put debug back,
|
|
445
|
+
// so the reset appeared to do nothing.
|
|
446
|
+
export function restingLogLevel() {
|
|
447
|
+
return installedLevel?.level ?? baseLevel
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// What is in force right now, so a caller can put it back.
|
|
451
|
+
export function currentLogLevel() {
|
|
452
|
+
return currentLevel ?? baseLevel
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function installedLogLevel() {
|
|
456
|
+
return installedLevel ? { ...installedLevel } : null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Thirty minutes: long enough to reproduce something on a live instance,
|
|
460
|
+
// short enough that forgetting costs a log file rather than a disk.
|
|
461
|
+
export const INSTALLED_LOG_TTL_MS = 30 * 60 * 1000
|
|
462
|
+
|
|
358
463
|
// Replace the bootstrap logger (built by engine.setup() with the
|
|
359
464
|
// terminal-only stream) with one that includes any third-party
|
|
360
465
|
// transports from runtime.config.logging.transports. Runs at onLoad
|
|
@@ -422,3 +527,38 @@ export function updateProgressDetails(details) {
|
|
|
422
527
|
currentBar.value / currentBar.total,
|
|
423
528
|
)
|
|
424
529
|
}
|
|
530
|
+
|
|
531
|
+
// An installed level, disclosed and expired, once per cycle.
|
|
532
|
+
//
|
|
533
|
+
// Disclosed for the reason an installed command is: it is state left on a live
|
|
534
|
+
// instance that changes what the process does, and the person who finds it
|
|
535
|
+
// weeks later is not the person who set it. A level at debug is quieter to
|
|
536
|
+
// leave behind than a probe and louder in effect — the deployment's out log is
|
|
537
|
+
// already 1.6MB, and a watcher rebuilding on every editor save at debug grows
|
|
538
|
+
// it fast. The failure lands as a full disk with nobody remembering who asked.
|
|
539
|
+
//
|
|
540
|
+
// Expiry is checked here rather than on a timer so it lands on a build
|
|
541
|
+
// boundary instead of mid-render, and the level also dies with the process, so
|
|
542
|
+
// a restart is a second guarantee rather than the only one.
|
|
543
|
+
//
|
|
544
|
+
// onFinalized, matching where the commands plugin announces an installed
|
|
545
|
+
// command — the report is reset at the top of a cycle, and a warning raised
|
|
546
|
+
// anywhere earlier than the last hook did not survive into the document a
|
|
547
|
+
// forwarded --json emits. Measured, not assumed: on onImport and on onFinalize
|
|
548
|
+
// the line reached the instance's log and the report stayed empty.
|
|
549
|
+
onFinalized(() => {
|
|
550
|
+
const state = applyInstalledLogLevel()
|
|
551
|
+
if (!state) return
|
|
552
|
+
const logger = useLogger()
|
|
553
|
+
if (state.expired) {
|
|
554
|
+
logger?.info('Log level installed with --log-install has expired; back to %s', state.level)
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
const minutes = Math.max(0, Math.round((state.expiresAt - Date.now()) / 60000))
|
|
558
|
+
logger?.warn(
|
|
559
|
+
{ code: 'log-level-installed', level: state.level, expiresIn: `${minutes}m` },
|
|
560
|
+
'This instance is running at log level %s, installed with --log-install — it is not the '
|
|
561
|
+
+ 'configured level and it is not this build asking for it. Expires in %dm, or on --log-reset, '
|
|
562
|
+
+ 'or when the process restarts.',
|
|
563
|
+
state.level, minutes)
|
|
564
|
+
})
|
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
|
}
|
package/src/report.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// reworded — which is exactly the kind of assertion that should not break
|
|
11
11
|
// when someone improves the wording.
|
|
12
12
|
import runtime from './runtime.js'
|
|
13
|
+
import { installedLogLevel } from './logger.js'
|
|
13
14
|
import { isReportOnlyRun } from './tools.js'
|
|
14
15
|
|
|
15
16
|
// A transport that can serve the build report declares itself here, at
|
|
@@ -529,6 +530,27 @@ export function buildReport() {
|
|
|
529
530
|
cycleId: cycle?.id ?? null,
|
|
530
531
|
startedAt: cycle?.startedAt ?? null,
|
|
531
532
|
finishedAt: cycle?.finishedAt ?? null,
|
|
533
|
+
// A log level installed on this instance with --log-install.
|
|
534
|
+
//
|
|
535
|
+
// Read HERE, at emit time, rather than raised as a warning during the
|
|
536
|
+
// cycle. A warning was the obvious shape — it is how an installed
|
|
537
|
+
// command is disclosed — and it does not survive into the document a
|
|
538
|
+
// forwarded --json emits: the line reached the instance's log from
|
|
539
|
+
// onImport, onFinalize and onFinalized alike, and `warnings` stayed
|
|
540
|
+
// empty every time. Reading state at emit has no ordering to get
|
|
541
|
+
// wrong, and this is a FACT ABOUT THE INSTANCE rather than an event in
|
|
542
|
+
// the cycle, so it reads better as a field than as an entry anyway.
|
|
543
|
+
//
|
|
544
|
+
// Null is the normal case, and its absence is what tells a reader the
|
|
545
|
+
// level is the configured one.
|
|
546
|
+
logLevel: installedLogLevel()
|
|
547
|
+
? {
|
|
548
|
+
level: installedLogLevel().level,
|
|
549
|
+
installed: true,
|
|
550
|
+
expiresInMinutes: Math.max(0,
|
|
551
|
+
Math.round((installedLogLevel().expiresAt - Date.now()) / 60000)),
|
|
552
|
+
}
|
|
553
|
+
: null,
|
|
532
554
|
// What the run COST, per phase, in milliseconds.
|
|
533
555
|
//
|
|
534
556
|
// `finishedAt - startedAt` is not this. It spans the processing cycle
|