mikser-io 9.96.0 → 9.97.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 +1 -0
- package/package.json +1 -1
- package/src/engine.js +44 -0
- package/src/fingerprint.js +144 -0
- package/src/instance.js +1 -1
- package/src/tools.js +2 -1
package/app.js
CHANGED
|
@@ -58,6 +58,7 @@ function locate(argv) {
|
|
|
58
58
|
: tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
|
|
59
59
|
: explain ? { type: 'report', explain, json: has('--json') }
|
|
60
60
|
: has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json') }
|
|
61
|
+
: has('--fingerprint') ? { type: 'report', fingerprint: true, json: has('--json') }
|
|
61
62
|
: { type: 'build',
|
|
62
63
|
clear: has('--clear'),
|
|
63
64
|
// Not a flag that happens to be set — the client's OUTPUT
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -13,6 +13,7 @@ import { OPERATION, TASKS } from './constants.js'
|
|
|
13
13
|
import { changeExtension, formatErrorContext, projectMeta, lookupKeys, siteRootFor } from './utils.js'
|
|
14
14
|
import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle, reportAssetUse, assetUse } from './report.js'
|
|
15
15
|
import { checkReferences } from './references.js'
|
|
16
|
+
import { fingerprintOutputs } from './fingerprint.js'
|
|
16
17
|
import { toolSchemas, invokeTool, toolResultText, toolResultFailed } from './tools.js'
|
|
17
18
|
import { registerBuiltinTools } from './builtin-tools.js'
|
|
18
19
|
import { useDatabase } from './database/index.js'
|
|
@@ -294,6 +295,17 @@ async function reportMissingAssets(logger, alreadyReported = new Set()) {
|
|
|
294
295
|
// `request` carries the CLIENT's arguments. Reading runtime.options here would
|
|
295
296
|
// answer with the instance's own flags, which are whatever it happened to be
|
|
296
297
|
// started with.
|
|
298
|
+
// Bytes at a size a person reads. Not in the document — that carries the
|
|
299
|
+
// integer, because a caller comparing two builds subtracts.
|
|
300
|
+
function formatBytes(bytes) {
|
|
301
|
+
if (bytes < 1024) return `${bytes} B`
|
|
302
|
+
const units = ['kB', 'MB', 'GB']
|
|
303
|
+
let value = bytes / 1024
|
|
304
|
+
let unit = 0
|
|
305
|
+
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
|
|
306
|
+
return `${value.toFixed(1)} ${units[unit]}`
|
|
307
|
+
}
|
|
308
|
+
|
|
297
309
|
export async function runReportOnly(request = {}) {
|
|
298
310
|
const logger = useLogger()
|
|
299
311
|
const {
|
|
@@ -303,6 +315,7 @@ export async function runReportOnly(request = {}) {
|
|
|
303
315
|
json = runtime.options.json,
|
|
304
316
|
explain = runtime.options.explain,
|
|
305
317
|
auditOutput = runtime.options.auditOutput,
|
|
318
|
+
fingerprint = runtime.options.fingerprint,
|
|
306
319
|
} = request
|
|
307
320
|
|
|
308
321
|
if (tools) {
|
|
@@ -372,6 +385,25 @@ export async function runReportOnly(request = {}) {
|
|
|
372
385
|
return report.found ? 0 : 3
|
|
373
386
|
}
|
|
374
387
|
|
|
388
|
+
if (fingerprint) {
|
|
389
|
+
const result = await fingerprintOutputs()
|
|
390
|
+
if (!result) {
|
|
391
|
+
logger.error('No output folder — nothing to fingerprint.')
|
|
392
|
+
return 2
|
|
393
|
+
}
|
|
394
|
+
if (json) {
|
|
395
|
+
process.stdout.write(JSON.stringify({ version: packageInfo.version, ...result }, null, 2) + '\n')
|
|
396
|
+
} else {
|
|
397
|
+
logger.notice('Output %s — %d file(s), %s',
|
|
398
|
+
result.output.hash, result.output.files, formatBytes(result.output.bytes))
|
|
399
|
+
for (const [name, group] of Object.entries(result.trees)) {
|
|
400
|
+
logger.info(' %s: %s — %d file(s), %s',
|
|
401
|
+
name, group.hash, group.files, formatBytes(group.bytes))
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return 0
|
|
405
|
+
}
|
|
406
|
+
|
|
375
407
|
if (auditOutput) {
|
|
376
408
|
if (!runtime.manifest) {
|
|
377
409
|
logger.error('Verify: no manifest available — nothing to check against')
|
|
@@ -517,6 +549,9 @@ export async function setup(options) {
|
|
|
517
549
|
.option('--tools', 'list the tools this build exposes, then exit', false)
|
|
518
550
|
.option('--tool <name>', 'run one tool and print its result, then exit. The same tools an MCP client sees, so an agent reading CLI output and an agent speaking MCP ask the engine the same questions.')
|
|
519
551
|
.option('--tool-args <json>', 'JSON arguments for --tool (e.g. \'{"destination":"/bg/index.html"}\')')
|
|
552
|
+
.option('--fingerprint', 'hash everything this build wrote — including what it wrote through a '
|
|
553
|
+
+ 'symlink, which `find` does not descend into — and exit. One comparable number per output '
|
|
554
|
+
+ 'tree, plus one per asset preset, for proving an upgrade moved no bytes.', false)
|
|
520
555
|
.option('-d --debug', 'display debug statements')
|
|
521
556
|
.option('-t --trace', 'display trace statements')
|
|
522
557
|
.option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
|
|
@@ -566,6 +601,15 @@ Which check answers which question:
|
|
|
566
601
|
A boot operation: it is refused while an instance
|
|
567
602
|
is running in the same folder.
|
|
568
603
|
|
|
604
|
+
Did an upgrade move any bytes?
|
|
605
|
+
--fingerprint hash everything the build wrote, including what it
|
|
606
|
+
wrote THROUGH A SYMLINK — files() emits by
|
|
607
|
+
symlinking and assets links the derivatives tree
|
|
608
|
+
in, so \`find out -type f\` descends into neither.
|
|
609
|
+
One number for the whole output and one per shared
|
|
610
|
+
tree, stable across runs. Take it before and after
|
|
611
|
+
an upgrade and compare.
|
|
612
|
+
|
|
569
613
|
What did this build do, and cost?
|
|
570
614
|
--json the whole report as one document on stdout, with
|
|
571
615
|
every warning carrying a stable code, and per-phase
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// What this build actually wrote, as one comparable number.
|
|
2
|
+
//
|
|
3
|
+
// Proving an upgrade moved no bytes is the check every mikser project needs,
|
|
4
|
+
// and it is the one thing a shell script cannot compute correctly from
|
|
5
|
+
// outside. A real one, written twice and wrong both times, hit all of this:
|
|
6
|
+
//
|
|
7
|
+
// - `find out -type f` does NOT descend into a symlink, and files() emits by
|
|
8
|
+
// symlinking while assets symlinks the whole derivatives tree in. Every
|
|
9
|
+
// "byte-identical" it printed was a statement about html, css and js only,
|
|
10
|
+
// with the derivatives silently excluded.
|
|
11
|
+
// - the derivatives live wherever `assetsFolder` says, which is config the
|
|
12
|
+
// script had to re-derive.
|
|
13
|
+
// - hashing per directory block meant the order depended on the order the
|
|
14
|
+
// blocks came back, and two runs over byte-identical trees hashed
|
|
15
|
+
// differently — a false CHANGED, which sends someone hunting a regression
|
|
16
|
+
// that never happened.
|
|
17
|
+
// - which presets are cheap to re-render (sharp, an npm dependency that an
|
|
18
|
+
// upgrade CAN change) and which are not (ffmpeg, a host binary it cannot)
|
|
19
|
+
// had to be inferred by grepping the preset sources.
|
|
20
|
+
//
|
|
21
|
+
// The engine knows all four without inferring anything. So it answers, and the
|
|
22
|
+
// script that orchestrates the upgrade keeps only the part that is genuinely
|
|
23
|
+
// its own: talking to npm.
|
|
24
|
+
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
import { createReadStream } from 'node:fs'
|
|
28
|
+
import { stat, lstat } from 'node:fs/promises'
|
|
29
|
+
import { globby } from 'globby'
|
|
30
|
+
import runtime from './runtime.js'
|
|
31
|
+
|
|
32
|
+
function sha256(value) {
|
|
33
|
+
return createHash('sha256').update(value).digest('hex')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Streamed, not read into memory. A derivatives tree is the largest thing in
|
|
37
|
+
// the output and the reason to fingerprint at all; loading a 40MB video to
|
|
38
|
+
// hash it would make the check cost more than the build.
|
|
39
|
+
function hashFile(file) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const hash = createHash('sha256')
|
|
42
|
+
createReadStream(file)
|
|
43
|
+
.on('error', reject)
|
|
44
|
+
.on('data', chunk => hash.update(chunk))
|
|
45
|
+
.on('end', () => resolve(hash.digest('hex')))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// One hash over a sorted list of `path\0contentHash` lines.
|
|
50
|
+
//
|
|
51
|
+
// The sort lives HERE and nowhere else. It used to be applied to the file list
|
|
52
|
+
// as well, which made this one redundant and therefore untestable — and a
|
|
53
|
+
// redundant guard is one that gets removed later by someone who checks that
|
|
54
|
+
// the tests still pass. Sorting where the hash is computed also makes each
|
|
55
|
+
// tree group order-independent on its own, rather than only by inheritance
|
|
56
|
+
// from the walk.
|
|
57
|
+
//
|
|
58
|
+
// The path is part of the input: a file that moved is a change, and a hash of
|
|
59
|
+
// contents alone would call a rename identical.
|
|
60
|
+
// Exported because these two properties — order independence and path
|
|
61
|
+
// sensitivity — are properties of the HASH, not of a build, and a scenario
|
|
62
|
+
// cannot force globby to return files in a hostile order to check them.
|
|
63
|
+
export function combineEntries(entries) {
|
|
64
|
+
const lines = entries
|
|
65
|
+
.map(({ file, hash }) => `${file}\0${hash}`)
|
|
66
|
+
.sort()
|
|
67
|
+
.join('\n')
|
|
68
|
+
return sha256(lines)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
// Everything the build wrote, including what it wrote through a symlink.
|
|
73
|
+
//
|
|
74
|
+
// `followSymbolicLinks: true` is the whole point — see above. `onlyFiles`
|
|
75
|
+
// keeps directory symlinks from being listed as entries in their own right
|
|
76
|
+
// while still descending through them.
|
|
77
|
+
export async function fingerprintOutputs() {
|
|
78
|
+
const outputFolder = runtime.options?.outputFolder
|
|
79
|
+
if (!outputFolder) return null
|
|
80
|
+
|
|
81
|
+
const files = await globby('**/*', {
|
|
82
|
+
cwd: outputFolder,
|
|
83
|
+
followSymbolicLinks: true,
|
|
84
|
+
onlyFiles: true,
|
|
85
|
+
suppressErrors: true,
|
|
86
|
+
dot: true,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const entries = []
|
|
90
|
+
let bytes = 0
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const absolute = path.join(outputFolder, file)
|
|
93
|
+
try {
|
|
94
|
+
const info = await stat(absolute)
|
|
95
|
+
bytes += info.size
|
|
96
|
+
entries.push({ file, hash: await hashFile(absolute), size: info.size })
|
|
97
|
+
} catch { /* vanished mid-walk — a concurrent build, not our business */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Broken out per shared tree, because the parts of an output answer
|
|
101
|
+
// different questions about an upgrade. A preset rendering through an npm
|
|
102
|
+
// dependency (sharp) can move when that dependency does; one shelling out
|
|
103
|
+
// to a host binary (ffmpeg) cannot, and re-rendering it to find that out
|
|
104
|
+
// costs minutes. A caller comparing releases can hash the whole output, or
|
|
105
|
+
// just the groups whose renderer an upgrade could have touched.
|
|
106
|
+
//
|
|
107
|
+
// The trees are found by asking the OUTPUT, not the plugin that made them.
|
|
108
|
+
// A report-only command runs in an onLoaded registered when the engine is
|
|
109
|
+
// imported, which is before every plugin's — so runtime.options.assets is
|
|
110
|
+
// not set yet and reading it grouped nothing at all. The filesystem knows
|
|
111
|
+
// the same fact and knows it in every phase: a top-level entry that is a
|
|
112
|
+
// symlink to a directory is a tree emitted from elsewhere, which is what
|
|
113
|
+
// both assets and resources produce and what `find` refuses to descend
|
|
114
|
+
// into.
|
|
115
|
+
const groups = {}
|
|
116
|
+
for (const name of new Set(entries.map(e => e.file.split('/')[0]))) {
|
|
117
|
+
const top = path.join(outputFolder, name)
|
|
118
|
+
let linked = false
|
|
119
|
+
try { linked = (await lstat(top)).isSymbolicLink() } catch { continue }
|
|
120
|
+
if (!linked) continue
|
|
121
|
+
const prefix = `${name}/`
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.file.startsWith(prefix)) continue
|
|
124
|
+
// `<tree>/<group>/…` — the preset, for an assets tree. A tree
|
|
125
|
+
// holding files directly is reported under its own name.
|
|
126
|
+
const rest = entry.file.slice(prefix.length).split('/')
|
|
127
|
+
const label = rest.length > 1 ? `${name}/${rest[0]}` : name
|
|
128
|
+
const group = (groups[label] ??= { files: [], bytes: 0 })
|
|
129
|
+
group.files.push(entry)
|
|
130
|
+
group.bytes += entry.size
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const trees = groups
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
output: { hash: combineEntries(entries), files: entries.length, bytes },
|
|
137
|
+
trees: Object.fromEntries(
|
|
138
|
+
Object.entries(trees)
|
|
139
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
140
|
+
.map(([name, group]) => [name, {
|
|
141
|
+
hash: combineEntries(group.files), files: group.files.length, bytes: group.bytes,
|
|
142
|
+
}])),
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/instance.js
CHANGED
|
@@ -71,7 +71,7 @@ export function socketPath(workingFolder) {
|
|
|
71
71
|
// ship together, so there is nothing to negotiate and no version to carry.
|
|
72
72
|
//
|
|
73
73
|
// → { type: 'build', config, clear, renderPresets }
|
|
74
|
-
// → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, json }
|
|
74
|
+
// → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, fingerprint, json }
|
|
75
75
|
// ← { type: 'log', chunk } (zero or more, in order)
|
|
76
76
|
// ← { type: 'done', code }
|
|
77
77
|
// ← { type: 'refused', reason, detail }
|
package/src/tools.js
CHANGED
|
@@ -129,5 +129,6 @@ export function toolResultFailed(result) {
|
|
|
129
129
|
// settle and Node drained the loop and left.
|
|
130
130
|
export function isReportOnlyRun() {
|
|
131
131
|
const options = runtime.options ?? {}
|
|
132
|
-
return Boolean(options.explain || options.auditOutput || options.tool || options.tools
|
|
132
|
+
return Boolean(options.explain || options.auditOutput || options.tool || options.tools
|
|
133
|
+
|| options.fingerprint)
|
|
133
134
|
}
|