@hoardodile/cli 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +37 -0
- package/bin/hoardodile.mjs +7 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.js +1684 -0
- package/dist/main.js.map +1 -0
- package/package.json +60 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,1684 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, existsSync, statSync, readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, watch } from 'fs';
|
|
2
|
+
import os, { tmpdir } from 'os';
|
|
3
|
+
import { join, dirname, resolve, sep, extname } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import { createProbeCache, HOOK_NAMES, createPluginSandbox, DEFAULT_SANDBOX_CONFIG, buildRegistry, createPluginHooks, createPluginResourceAPI, createDirectoryContainer } from '@hoardodile/host';
|
|
6
|
+
import { ok, err } from '@hoardodile/sdk-types';
|
|
7
|
+
import { defineCommand } from 'citty';
|
|
8
|
+
import { mediaProbes } from '@hoardodile/host/probe';
|
|
9
|
+
import { pluginManifest } from '@hoardodile/sdk-types/schema';
|
|
10
|
+
import babel from '@rolldown/plugin-babel';
|
|
11
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
12
|
+
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
|
|
13
|
+
import { build } from 'vite';
|
|
14
|
+
import { parseTemplateFragments, tokeniseExpression, parseTemplateExpression } from '@hoardodile/sdk-types/template';
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
import { createRequire } from 'module';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
19
|
+
import { parseImageVariantQuery, normalizeImageVariantSpec, imageVariantCanonical } from '@hoardodile/sdk-types/image-variant';
|
|
20
|
+
import { RESOURCE_PREVIEW_MAX_AREA, RESOURCE_COVER_MAX_AREA } from '@hoardodile/sdk-types/resource';
|
|
21
|
+
import { createStoragePaths } from '@hoardodile/host/hoard';
|
|
22
|
+
|
|
23
|
+
// src/main.ts
|
|
24
|
+
var EXIT_PASS = 0;
|
|
25
|
+
var EXIT_REGRESSION = 1;
|
|
26
|
+
var EXIT_ERROR = 2;
|
|
27
|
+
var CliError = class extends Error {
|
|
28
|
+
};
|
|
29
|
+
var FALLBACK_ID = "00000000-0000-4000-8000-000000000000";
|
|
30
|
+
function resolvePluginTarget(opts) {
|
|
31
|
+
const dirPath = opts.pluginDir !== void 0 ? resolve(opts.pluginDir) : opts.main !== void 0 ? dirname(resolve(opts.main)) : void 0;
|
|
32
|
+
if (dirPath === void 0) {
|
|
33
|
+
throw new CliError("provide --plugin-dir <dir> or --main <main.js>");
|
|
34
|
+
}
|
|
35
|
+
const mainPath = opts.main !== void 0 ? resolve(opts.main) : join(dirPath, "main.js");
|
|
36
|
+
if (!existsSync(mainPath)) {
|
|
37
|
+
throw new CliError(`plugin main.js not found: ${mainPath}`);
|
|
38
|
+
}
|
|
39
|
+
const manifestPath = join(dirPath, "manifest.json");
|
|
40
|
+
let manifest;
|
|
41
|
+
if (existsSync(manifestPath)) {
|
|
42
|
+
const parsed = pluginManifest.safeParse(
|
|
43
|
+
JSON.parse(readFileSync(manifestPath, "utf-8"))
|
|
44
|
+
);
|
|
45
|
+
if (!parsed.success) {
|
|
46
|
+
throw new CliError(
|
|
47
|
+
`invalid manifest.json at ${manifestPath}: ${parsed.error.message}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
manifest = parsed.data;
|
|
51
|
+
} else {
|
|
52
|
+
manifest = {
|
|
53
|
+
id: FALLBACK_ID,
|
|
54
|
+
name: "cli-plugin",
|
|
55
|
+
description: "CLI-invoked plugin without a manifest.json",
|
|
56
|
+
version: "0.0.0",
|
|
57
|
+
permissions: {
|
|
58
|
+
sourceMeta: false,
|
|
59
|
+
searchMeta: false,
|
|
60
|
+
danmaku: false,
|
|
61
|
+
message: false,
|
|
62
|
+
imageHashes: false,
|
|
63
|
+
container: false,
|
|
64
|
+
download: false
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { id: manifest.id, manifest, mainPath, dirPath };
|
|
69
|
+
}
|
|
70
|
+
var cliProbeCache = createProbeCache();
|
|
71
|
+
function buildCliResourceAPI(dir, opts = {}) {
|
|
72
|
+
return createPluginResourceAPI({
|
|
73
|
+
view: createDirectoryContainer(dir),
|
|
74
|
+
...mediaProbes,
|
|
75
|
+
extractCacheDir: opts.extractCacheDir,
|
|
76
|
+
probeCache: cliProbeCache,
|
|
77
|
+
cacheScope: `cli:${dir}`
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function buildArchiveResourceAPI(resourceDir, extractCacheDir) {
|
|
81
|
+
return createPluginResourceAPI({
|
|
82
|
+
view: createDirectoryContainer(resourceDir),
|
|
83
|
+
...mediaProbes,
|
|
84
|
+
extractCacheDir,
|
|
85
|
+
probeCache: cliProbeCache,
|
|
86
|
+
cacheScope: `cli:${resourceDir}`
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function createCliHooks(target, sandbox) {
|
|
90
|
+
const plugin = await sandbox.loadPlugin({
|
|
91
|
+
id: target.id,
|
|
92
|
+
mainPath: target.mainPath,
|
|
93
|
+
eager: true
|
|
94
|
+
});
|
|
95
|
+
if (plugin === void 0) {
|
|
96
|
+
throw new CliError(`plugin failed to load: ${target.mainPath}`);
|
|
97
|
+
}
|
|
98
|
+
const registry = buildRegistry([
|
|
99
|
+
{
|
|
100
|
+
id: target.id,
|
|
101
|
+
manifest: target.manifest,
|
|
102
|
+
enabled: true,
|
|
103
|
+
priority: 0,
|
|
104
|
+
pinned: false,
|
|
105
|
+
color: "",
|
|
106
|
+
missing: false,
|
|
107
|
+
builtin: false,
|
|
108
|
+
dev: false,
|
|
109
|
+
plugin,
|
|
110
|
+
diskPath: target.dirPath
|
|
111
|
+
}
|
|
112
|
+
]);
|
|
113
|
+
return createPluginHooks({ getRegistry: () => registry });
|
|
114
|
+
}
|
|
115
|
+
async function runCliHook(opts) {
|
|
116
|
+
const api = buildCliResourceAPI(opts.dir, {
|
|
117
|
+
extractCacheDir: opts.extractCacheDir
|
|
118
|
+
});
|
|
119
|
+
const started = performance.now();
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
switch (opts.hook) {
|
|
123
|
+
case "detect":
|
|
124
|
+
result = await opts.hooks.detectForPlugin(api, opts.id);
|
|
125
|
+
break;
|
|
126
|
+
case "sourceMeta":
|
|
127
|
+
result = (await opts.hooks.runMetaHooks(api, opts.id)).sourceMeta?.value;
|
|
128
|
+
break;
|
|
129
|
+
case "searchMeta":
|
|
130
|
+
result = (await opts.hooks.runMetaHooks(api, opts.id)).searchMeta?.value;
|
|
131
|
+
break;
|
|
132
|
+
case "coverLocal":
|
|
133
|
+
result = await opts.hooks.resolveLocalCoverSource(api, opts.id);
|
|
134
|
+
break;
|
|
135
|
+
case "listFiles":
|
|
136
|
+
result = await opts.hooks.buildFileList(api, opts.id);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
} catch (caught) {
|
|
140
|
+
return err({
|
|
141
|
+
result: caught instanceof Error ? caught.message : String(caught),
|
|
142
|
+
durationMs: performance.now() - started
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return ok({ result, durationMs: performance.now() - started });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/bench.ts
|
|
149
|
+
function machineInfo() {
|
|
150
|
+
return {
|
|
151
|
+
platform: os.platform(),
|
|
152
|
+
arch: os.arch(),
|
|
153
|
+
cpus: os.cpus().length,
|
|
154
|
+
cpuModel: os.cpus()[0]?.model ?? "unknown",
|
|
155
|
+
node: process.version
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function fingerprint(machine) {
|
|
159
|
+
return [
|
|
160
|
+
machine.platform,
|
|
161
|
+
machine.arch,
|
|
162
|
+
machine.cpus,
|
|
163
|
+
machine.cpuModel,
|
|
164
|
+
machine.node
|
|
165
|
+
].join("|");
|
|
166
|
+
}
|
|
167
|
+
function computeBenchReport(opts) {
|
|
168
|
+
return (async () => {
|
|
169
|
+
for (let i = 0; i < opts.warmupRuns; i++) {
|
|
170
|
+
await opts.run();
|
|
171
|
+
}
|
|
172
|
+
const samples = [];
|
|
173
|
+
let peakRss = 0;
|
|
174
|
+
for (let i = 0; i < opts.repeat; i++) {
|
|
175
|
+
const outcome = await opts.run();
|
|
176
|
+
samples.push(outcome.durationMs);
|
|
177
|
+
peakRss = Math.max(peakRss, process.memoryUsage().rss);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
schema: 1,
|
|
181
|
+
kind: "plugin-hook",
|
|
182
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
183
|
+
config: {
|
|
184
|
+
pluginId: opts.pluginId,
|
|
185
|
+
hook: opts.hook,
|
|
186
|
+
dir: opts.dir,
|
|
187
|
+
repeat: opts.repeat,
|
|
188
|
+
warmupRuns: opts.warmupRuns
|
|
189
|
+
},
|
|
190
|
+
machine: machineInfo(),
|
|
191
|
+
caveats: [
|
|
192
|
+
"Baselines are only valid on the same machine and environment \u2014 absolute sandbox timings (worker + protocol + probes) are not comparable across machines."
|
|
193
|
+
],
|
|
194
|
+
memoryPeakMb: Math.round(peakRss / 1024 / 1024 * 10) / 10,
|
|
195
|
+
samplesMs: samples,
|
|
196
|
+
stats: computeStats(samples)
|
|
197
|
+
};
|
|
198
|
+
})();
|
|
199
|
+
}
|
|
200
|
+
function computeStats(samples) {
|
|
201
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
202
|
+
const median = sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
203
|
+
const mean = samples.reduce((a, b) => a + b, 0) / Math.max(1, samples.length);
|
|
204
|
+
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))] ?? 0;
|
|
205
|
+
return {
|
|
206
|
+
medianMs: round1(median),
|
|
207
|
+
meanMs: round1(mean),
|
|
208
|
+
p95Ms: round1(p95),
|
|
209
|
+
minMs: round1(sorted[0] ?? 0),
|
|
210
|
+
maxMs: round1(sorted[sorted.length - 1] ?? 0)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function loadBaseline(path) {
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
217
|
+
} catch (err2) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`cannot read baseline file ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!isBenchReport(parsed)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`baseline file ${path} is not a bench report (kind plugin-hook)`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return parsed;
|
|
228
|
+
}
|
|
229
|
+
function isBenchReport(value) {
|
|
230
|
+
if (typeof value !== "object" || value === null) return false;
|
|
231
|
+
const v = value;
|
|
232
|
+
if (v.schema !== 1) return false;
|
|
233
|
+
if (v.kind !== "plugin-hook") return false;
|
|
234
|
+
if (typeof v.timestamp !== "string") return false;
|
|
235
|
+
const config = v.config;
|
|
236
|
+
if (typeof config !== "object" || config === null || typeof config.pluginId !== "string" || typeof config.hook !== "string") {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const machine = v.machine;
|
|
240
|
+
if (typeof machine !== "object" || machine === null || typeof machine.node !== "string") {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const stats = v.stats;
|
|
244
|
+
return typeof stats === "object" && stats !== null && typeof stats.medianMs === "number";
|
|
245
|
+
}
|
|
246
|
+
function compareBaseline(report, baseline, thresholdPercent) {
|
|
247
|
+
if (fingerprint(report.machine) !== fingerprint(baseline.machine)) {
|
|
248
|
+
console.warn(
|
|
249
|
+
`WARNING: baseline machine differs (fresh: ${fingerprint(report.machine)} vs baseline: ${fingerprint(baseline.machine)}) \u2014 sandbox timings are not comparable across machines.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const ratio = report.stats.medianMs / Math.max(1e-4, baseline.stats.medianMs);
|
|
253
|
+
const regressed = report.stats.medianMs > baseline.stats.medianMs * (1 + thresholdPercent / 100);
|
|
254
|
+
const delta = ((ratio - 1) * 100).toFixed(1);
|
|
255
|
+
const message = regressed ? `REGRESSION: median ${report.stats.medianMs}ms vs baseline ${baseline.stats.medianMs}ms (${delta}%) exceeds the ${thresholdPercent}% threshold. Baselines are only valid on the same machine and environment \u2014 absolute sandbox timings (worker + protocol + probes) are not comparable across machines.` : `median ${report.stats.medianMs}ms vs baseline ${baseline.stats.medianMs}ms (${delta}%) \u2014 within the ${thresholdPercent}% threshold.`;
|
|
256
|
+
return { baseline, ratio, regressed, message };
|
|
257
|
+
}
|
|
258
|
+
function writeReport(path, report) {
|
|
259
|
+
writeFileSync(path, `${JSON.stringify(report, null, 2)}
|
|
260
|
+
`);
|
|
261
|
+
}
|
|
262
|
+
function formatBenchSummary(report) {
|
|
263
|
+
const lines = [
|
|
264
|
+
`hook ${report.config.hook} \xD7 ${report.config.repeat} (${report.config.warmupRuns} warmup)`,
|
|
265
|
+
`plugin ${report.config.pluginId}`,
|
|
266
|
+
`machine ${report.machine.platform}/${report.machine.arch} \xB7 ${report.machine.cpus} cpus (${report.machine.cpuModel}) \xB7 node ${report.machine.node} \xB7 peak rss ${report.memoryPeakMb}MB`,
|
|
267
|
+
`samples: ${report.samplesMs.map((ms) => `${ms.toFixed(1)}ms`).join(" ")}`,
|
|
268
|
+
`median ${report.stats.medianMs}ms \xB7 mean ${report.stats.meanMs}ms \xB7 p95 ${report.stats.p95Ms}ms \xB7 min ${report.stats.minMs}ms \xB7 max ${report.stats.maxMs}ms`
|
|
269
|
+
];
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
function benchExitCode(compare) {
|
|
273
|
+
if (compare === void 0) return EXIT_PASS;
|
|
274
|
+
return compare.regressed ? EXIT_REGRESSION : EXIT_PASS;
|
|
275
|
+
}
|
|
276
|
+
function round1(value) {
|
|
277
|
+
return Math.round(value * 10) / 10;
|
|
278
|
+
}
|
|
279
|
+
var KNOWN_FUNCTIONS = /* @__PURE__ */ new Set([
|
|
280
|
+
"asset",
|
|
281
|
+
"bytes",
|
|
282
|
+
"duration",
|
|
283
|
+
"eq",
|
|
284
|
+
"gt",
|
|
285
|
+
"gte",
|
|
286
|
+
"icon",
|
|
287
|
+
"if",
|
|
288
|
+
"inc",
|
|
289
|
+
"join",
|
|
290
|
+
"kind",
|
|
291
|
+
"lt",
|
|
292
|
+
"lte",
|
|
293
|
+
"ne",
|
|
294
|
+
"number",
|
|
295
|
+
"searchKindIcons",
|
|
296
|
+
"t"
|
|
297
|
+
]);
|
|
298
|
+
function lintTemplate(template, i18nKeys) {
|
|
299
|
+
const issues = [];
|
|
300
|
+
const open = (template.match(/\{\{/g) ?? []).length;
|
|
301
|
+
const close = (template.match(/\}\}/g) ?? []).length;
|
|
302
|
+
if (open !== close) {
|
|
303
|
+
return [
|
|
304
|
+
{
|
|
305
|
+
template,
|
|
306
|
+
message: `unbalanced {{ }} braces (${open} open, ${close} close)`
|
|
307
|
+
}
|
|
308
|
+
];
|
|
309
|
+
}
|
|
310
|
+
for (const fragment of parseTemplateFragments(template)) {
|
|
311
|
+
if (fragment.kind !== "expr") continue;
|
|
312
|
+
lintExpression(fragment.source, template, i18nKeys, issues);
|
|
313
|
+
}
|
|
314
|
+
return issues;
|
|
315
|
+
}
|
|
316
|
+
function lintExpression(source, template, i18nKeys, issues) {
|
|
317
|
+
const tokens = tokeniseExpression(source);
|
|
318
|
+
const opens = tokens.filter((t) => t.kind === "lparen").length;
|
|
319
|
+
const closes = tokens.filter((t) => t.kind === "rparen").length;
|
|
320
|
+
if (opens !== closes) {
|
|
321
|
+
issues.push({
|
|
322
|
+
template,
|
|
323
|
+
message: `unbalanced parentheses in {{${source}}} (${opens} open, ${closes} close)`
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const expr = parseTemplateExpression(source);
|
|
328
|
+
if (expr === void 0) {
|
|
329
|
+
issues.push({
|
|
330
|
+
template,
|
|
331
|
+
message: `cannot parse expression "{{${source}}}" (expected a call or path)`
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
walkExpr(expr, (call) => {
|
|
336
|
+
if (!KNOWN_FUNCTIONS.has(call.name)) {
|
|
337
|
+
issues.push({
|
|
338
|
+
template,
|
|
339
|
+
message: `unknown function "${call.name}" (known: ${[...KNOWN_FUNCTIONS].join(", ")})`
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (call.name === "t") {
|
|
344
|
+
const key = firstStringArg(call.args);
|
|
345
|
+
if (key !== void 0 && !i18nKeys.has(key)) {
|
|
346
|
+
issues.push({
|
|
347
|
+
template,
|
|
348
|
+
message: `t('${key}') references an i18n key the manifest does not declare`
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function walkExpr(expr, visit) {
|
|
355
|
+
if (expr.kind !== "call") return;
|
|
356
|
+
visit(expr);
|
|
357
|
+
for (const arg of expr.args) {
|
|
358
|
+
if (arg.kind === "expr") walkExpr(arg.expr, visit);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function firstStringArg(args) {
|
|
362
|
+
const first = args[0];
|
|
363
|
+
if (first?.kind === "string") return first.value;
|
|
364
|
+
return void 0;
|
|
365
|
+
}
|
|
366
|
+
function manifestTemplates(manifest) {
|
|
367
|
+
const templates = [];
|
|
368
|
+
for (const block of Object.values(manifest.ui?.card ?? {})) {
|
|
369
|
+
if (block === void 0) continue;
|
|
370
|
+
for (const corner of [block.tl, block.tr, block.bl, block.br]) {
|
|
371
|
+
if (corner !== void 0) templates.push(...corner);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const kind of manifest.ui?.search?.kinds ?? []) {
|
|
375
|
+
templates.push(kind.label);
|
|
376
|
+
if (kind.icon !== void 0) templates.push(kind.icon);
|
|
377
|
+
}
|
|
378
|
+
if (manifest.ui?.message?.anchor !== void 0) {
|
|
379
|
+
templates.push(manifest.ui.message.anchor);
|
|
380
|
+
}
|
|
381
|
+
return templates;
|
|
382
|
+
}
|
|
383
|
+
function lintManifestTemplates(manifest) {
|
|
384
|
+
const templates = manifestTemplates(manifest);
|
|
385
|
+
const i18nKeys = new Set(Object.keys(manifest.i18n ?? {}));
|
|
386
|
+
const issues = [];
|
|
387
|
+
for (const template of templates) {
|
|
388
|
+
issues.push(...lintTemplate(template, i18nKeys));
|
|
389
|
+
}
|
|
390
|
+
return { templates, issues };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/build.ts
|
|
394
|
+
function isWatcher(result) {
|
|
395
|
+
return "on" in result;
|
|
396
|
+
}
|
|
397
|
+
async function buildPlugin(dir, opts) {
|
|
398
|
+
const watchMode = opts.watch;
|
|
399
|
+
const manifestPath = join(dir, "manifest.json");
|
|
400
|
+
if (!existsSync(manifestPath)) {
|
|
401
|
+
throw new Error(`No manifest.json found in ${dir}`);
|
|
402
|
+
}
|
|
403
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
404
|
+
if (typeof manifest.id !== "string" || manifest.id.length === 0) {
|
|
405
|
+
throw new Error("manifest.json missing id field");
|
|
406
|
+
}
|
|
407
|
+
lintTemplates(manifest);
|
|
408
|
+
const outDir = join(dir, "dist");
|
|
409
|
+
if (!watchMode) {
|
|
410
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
411
|
+
}
|
|
412
|
+
mkdirSync(outDir, { recursive: true });
|
|
413
|
+
const htmlEntry = join(dir, "index.html");
|
|
414
|
+
const mainEntry = join(dir, "src", "main.ts");
|
|
415
|
+
if (existsSync(htmlEntry)) {
|
|
416
|
+
const result = await build({
|
|
417
|
+
root: dir,
|
|
418
|
+
base: "./",
|
|
419
|
+
// React Compiler only on the iframe client build; the server
|
|
420
|
+
// (SSR) bundle runs in a worker sandbox with no rendering.
|
|
421
|
+
plugins: [
|
|
422
|
+
react(),
|
|
423
|
+
babel({ presets: [reactCompilerPreset()] }),
|
|
424
|
+
tailwindcss()
|
|
425
|
+
],
|
|
426
|
+
build: {
|
|
427
|
+
outDir,
|
|
428
|
+
emptyOutDir: false,
|
|
429
|
+
chunkSizeWarningLimit: Infinity,
|
|
430
|
+
rollupOptions: {
|
|
431
|
+
input: htmlEntry
|
|
432
|
+
},
|
|
433
|
+
watch: watchMode ? {} : null
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
if (watchMode && isWatcher(result)) {
|
|
437
|
+
result.on("event", (event) => {
|
|
438
|
+
if (event.code === "END") {
|
|
439
|
+
console.log(`[watch] ${manifest.id} client rebuilt`);
|
|
440
|
+
} else if (event.code === "ERROR") {
|
|
441
|
+
console.error(`[watch] ${manifest.id} client error:`, event.error);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (existsSync(mainEntry)) {
|
|
447
|
+
const result = await build({
|
|
448
|
+
root: dir,
|
|
449
|
+
plugins: [react()],
|
|
450
|
+
// The server bundle runs inside the permission-model sandbox,
|
|
451
|
+
// which grants fs-read to the plugin dir only and lets the module
|
|
452
|
+
// policy gate load nothing outside it — the bundle must be one
|
|
453
|
+
// self-contained ESM file. Vite's default externalizes
|
|
454
|
+
// node_modules dependencies, which is exactly what an installed
|
|
455
|
+
// (tarball/npm) plugin hits: its `main.js` would keep bare
|
|
456
|
+
// `@hoardodile/*` imports whose resolution the sandbox denies.
|
|
457
|
+
// Inline everything instead (workspace plugins already ended up
|
|
458
|
+
// inlined via source resolution — this makes both cases uniform).
|
|
459
|
+
ssr: { noExternal: true },
|
|
460
|
+
build: {
|
|
461
|
+
ssr: mainEntry,
|
|
462
|
+
outDir,
|
|
463
|
+
emptyOutDir: false,
|
|
464
|
+
target: "node24",
|
|
465
|
+
rollupOptions: {
|
|
466
|
+
output: {
|
|
467
|
+
entryFileNames: "main.js",
|
|
468
|
+
chunkFileNames: "[name].js"
|
|
469
|
+
},
|
|
470
|
+
external: []
|
|
471
|
+
},
|
|
472
|
+
watch: watchMode ? {} : null
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
assertSelfContainedServerBundle(join(outDir));
|
|
476
|
+
if (watchMode && isWatcher(result)) {
|
|
477
|
+
result.on("event", (event) => {
|
|
478
|
+
if (event.code === "END") {
|
|
479
|
+
assertSelfContainedServerBundle(join(outDir));
|
|
480
|
+
console.log(`[watch] ${manifest.id} server rebuilt`);
|
|
481
|
+
} else if (event.code === "ERROR") {
|
|
482
|
+
console.error(`[watch] ${manifest.id} server error:`, event.error);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
copyFileSync(manifestPath, join(outDir, "manifest.json"));
|
|
488
|
+
if (watchMode) {
|
|
489
|
+
watch(manifestPath, () => {
|
|
490
|
+
copyFileSync(manifestPath, join(outDir, "manifest.json"));
|
|
491
|
+
console.log(`[watch] ${manifest.id} manifest updated`);
|
|
492
|
+
});
|
|
493
|
+
console.log(`[watch] ${manifest.id} watching for changes...`);
|
|
494
|
+
await new Promise(() => {
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
console.log(`${manifest.id} \u2192 ${outDir}`);
|
|
498
|
+
}
|
|
499
|
+
function lintTemplates(manifest) {
|
|
500
|
+
const { templates, issues } = lintManifestTemplates(manifest);
|
|
501
|
+
if (templates.length === 0) return;
|
|
502
|
+
const errors = issues.filter((issue) => !issue.message.includes("i18n key"));
|
|
503
|
+
if (errors.length > 0) {
|
|
504
|
+
const detail = errors.map((issue) => ` - ${issue.message}
|
|
505
|
+
${issue.template}`).join("\n");
|
|
506
|
+
throw new Error(`manifest template validation failed:
|
|
507
|
+
${detail}`);
|
|
508
|
+
}
|
|
509
|
+
for (const issue of issues.filter(
|
|
510
|
+
(issue2) => issue2.message.includes("i18n key")
|
|
511
|
+
)) {
|
|
512
|
+
console.warn(`[build] warning: ${issue.message}
|
|
513
|
+
${issue.template}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function assertSelfContainedServerBundle(outDir) {
|
|
517
|
+
for (const file of readdirSync(outDir, { withFileTypes: true })) {
|
|
518
|
+
if (!file.isFile() || !file.name.endsWith(".js")) continue;
|
|
519
|
+
const source = readFileSync(join(outDir, file.name), "utf-8");
|
|
520
|
+
if (/(?:from\s*["']node:|import\s*\(\s*["']node:)/.test(source)) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`plugin main bundle (${file.name}) imports a Node builtin \u2014 the plugin main process cannot use node:fs/net/child_process/\u2026; read files and probe metadata through the ResourceAPI instead`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (/\brequire\s*\(/.test(source)) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`plugin main bundle (${file.name}) calls require() \u2014 the plugin main process is self-contained; use the ResourceAPI instead`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (/(?:from\s*["']|import\s*\(\s*["'])(?![./])/.test(source)) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`plugin main bundle (${file.name}) leaves a bare import unresolved \u2014 the SDK closure must be inlined; the plugin sandbox cannot load node_modules`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function resolveCreateArgs(opts) {
|
|
538
|
+
const args = ["dlx", "--yes", "create-hoardodile-plugin"];
|
|
539
|
+
if (opts.name !== void 0) args.push(opts.name);
|
|
540
|
+
if (opts.tarballs !== void 0) args.push("--tarballs", opts.tarballs);
|
|
541
|
+
return args;
|
|
542
|
+
}
|
|
543
|
+
async function executeCreate(opts) {
|
|
544
|
+
return new Promise((resolveExit) => {
|
|
545
|
+
const child = spawn("pnpm", resolveCreateArgs(opts), {
|
|
546
|
+
stdio: "inherit",
|
|
547
|
+
shell: process.platform === "win32"
|
|
548
|
+
});
|
|
549
|
+
child.on("error", (err2) => {
|
|
550
|
+
console.error(
|
|
551
|
+
`[hoardodile] failed to start create-hoardodile-plugin: ${err2.message}`
|
|
552
|
+
);
|
|
553
|
+
resolveExit(EXIT_ERROR);
|
|
554
|
+
});
|
|
555
|
+
child.on("exit", (code) => {
|
|
556
|
+
resolveExit(code ?? EXIT_ERROR);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
function snapshotDetect(detection) {
|
|
561
|
+
return detection.ok ? { ok: true } : { ok: false, reasons: detection.reasons };
|
|
562
|
+
}
|
|
563
|
+
function resolveBuiltPluginTarget(pluginDir) {
|
|
564
|
+
const manifestPath = join(pluginDir, "manifest.json");
|
|
565
|
+
if (!existsSync(manifestPath)) {
|
|
566
|
+
throw new CliError(`no manifest.json found in ${pluginDir}`);
|
|
567
|
+
}
|
|
568
|
+
const mainPath = join(pluginDir, "dist", "main.js");
|
|
569
|
+
if (!existsSync(mainPath)) {
|
|
570
|
+
throw new CliError(`plugin bundle not built: ${mainPath}`);
|
|
571
|
+
}
|
|
572
|
+
const manifest = pluginManifest.parse(
|
|
573
|
+
JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
574
|
+
);
|
|
575
|
+
return { id: manifest.id, manifest, mainPath, dirPath: pluginDir };
|
|
576
|
+
}
|
|
577
|
+
async function captureHookSnapshot(opts) {
|
|
578
|
+
const { target, api } = opts;
|
|
579
|
+
const sandbox = createPluginSandbox(DEFAULT_SANDBOX_CONFIG);
|
|
580
|
+
try {
|
|
581
|
+
const hooks = await createCliHooks(target, sandbox);
|
|
582
|
+
return await collectHookResults({ target, api, hooks });
|
|
583
|
+
} finally {
|
|
584
|
+
await sandbox.disposeAll();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function collectHookResults(opts) {
|
|
588
|
+
const { target, api, hooks } = opts;
|
|
589
|
+
const errors = {};
|
|
590
|
+
async function attempt(hook, run) {
|
|
591
|
+
try {
|
|
592
|
+
return await run();
|
|
593
|
+
} catch (err2) {
|
|
594
|
+
errors[hook] = err2 instanceof Error ? err2.message : String(err2);
|
|
595
|
+
return void 0;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const detect = snapshotDetect(
|
|
599
|
+
await attempt("detect", () => hooks.detectForPlugin(api, target.id)) ?? {
|
|
600
|
+
ok: false,
|
|
601
|
+
reasons: ["detect threw an exception"]
|
|
602
|
+
}
|
|
603
|
+
);
|
|
604
|
+
const meta = await attempt("meta", () => hooks.runMetaHooks(api, target.id));
|
|
605
|
+
const files = await attempt(
|
|
606
|
+
"listFiles",
|
|
607
|
+
() => hooks.buildFileList(api, target.id)
|
|
608
|
+
);
|
|
609
|
+
const coverLocal = await attempt(
|
|
610
|
+
"coverLocal",
|
|
611
|
+
() => hooks.resolveLocalCoverSource(api, target.id)
|
|
612
|
+
);
|
|
613
|
+
const hashes = hooks.supportsImageHashes(target.id) ? await attempt("imageHashes", () => hooks.runImageHashes(api, target.id)) : void 0;
|
|
614
|
+
const fileStats = await attempt("fileStats", () => measureFileStats(api)) ?? {};
|
|
615
|
+
return {
|
|
616
|
+
pluginId: target.id,
|
|
617
|
+
detect,
|
|
618
|
+
sourceMeta: meta?.sourceMeta?.value,
|
|
619
|
+
searchMeta: meta?.searchMeta?.value,
|
|
620
|
+
coverLocal,
|
|
621
|
+
files,
|
|
622
|
+
fileStats,
|
|
623
|
+
imageHashes: hashes?.hashes,
|
|
624
|
+
errors,
|
|
625
|
+
capturedAt: Date.now()
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
async function measureFileStats(api) {
|
|
629
|
+
const names = await api.listFileNames();
|
|
630
|
+
const stats = await api.statFiles(names);
|
|
631
|
+
let sizeBytes = 0;
|
|
632
|
+
for (const stat of stats) sizeBytes += stat?.sizeBytes ?? 0;
|
|
633
|
+
return { count: names.length, sizeBytes };
|
|
634
|
+
}
|
|
635
|
+
function formatHookSnapshot(snapshot) {
|
|
636
|
+
const parts = [
|
|
637
|
+
snapshot.detect.ok ? "detect ok" : `detect miss (${(snapshot.detect.reasons ?? []).join(", ")})`,
|
|
638
|
+
`${snapshot.fileStats.count ?? 0} files`
|
|
639
|
+
];
|
|
640
|
+
if (snapshot.files !== void 0) {
|
|
641
|
+
parts.push(`listFiles ${snapshot.files.length}`);
|
|
642
|
+
}
|
|
643
|
+
if (snapshot.sourceMeta !== void 0) parts.push("sourceMeta");
|
|
644
|
+
if (snapshot.searchMeta !== void 0) parts.push("searchMeta");
|
|
645
|
+
if (snapshot.coverLocal !== void 0) {
|
|
646
|
+
parts.push(`cover ${snapshot.coverLocal}`);
|
|
647
|
+
}
|
|
648
|
+
for (const [hook, message] of Object.entries(snapshot.errors)) {
|
|
649
|
+
parts.push(`${hook} failed: ${message}`);
|
|
650
|
+
}
|
|
651
|
+
return parts.join(" \xB7 ");
|
|
652
|
+
}
|
|
653
|
+
function cacheKey(resId, path, suffix = "") {
|
|
654
|
+
return createHash("sha256").update(`${resId}\0${path}${suffix}`).digest("hex").slice(0, 32);
|
|
655
|
+
}
|
|
656
|
+
function contentTypeOf(format) {
|
|
657
|
+
return format === "webp" ? "image/webp" : "image/avif";
|
|
658
|
+
}
|
|
659
|
+
function createRenderProviders(opts) {
|
|
660
|
+
let renderPromise;
|
|
661
|
+
function loadRender() {
|
|
662
|
+
renderPromise ??= import('@hoardodile/host/render').catch(
|
|
663
|
+
(err2) => {
|
|
664
|
+
console.warn(
|
|
665
|
+
`[hoardodile] preview and frame rendering are unavailable \u2014 install "sharp" (and an ffmpeg binary for video) to enable them: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
666
|
+
);
|
|
667
|
+
return void 0;
|
|
668
|
+
}
|
|
669
|
+
);
|
|
670
|
+
return renderPromise;
|
|
671
|
+
}
|
|
672
|
+
async function readEntry(api, path) {
|
|
673
|
+
try {
|
|
674
|
+
return await api.readFile(path);
|
|
675
|
+
} catch {
|
|
676
|
+
return void 0;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function cached(base) {
|
|
680
|
+
for (const format of ["avif", "webp"]) {
|
|
681
|
+
const path = `${base}.${format}`;
|
|
682
|
+
if (existsSync(path)) return { path, contentType: contentTypeOf(format) };
|
|
683
|
+
}
|
|
684
|
+
return void 0;
|
|
685
|
+
}
|
|
686
|
+
async function cover(resId) {
|
|
687
|
+
if (opts.resolveCoverSource === void 0) return void 0;
|
|
688
|
+
const source = await opts.resolveCoverSource(resId);
|
|
689
|
+
if (source === void 0) return void 0;
|
|
690
|
+
const api = await opts.resolveApi(resId);
|
|
691
|
+
if (api === void 0) return void 0;
|
|
692
|
+
const type = await api.sniff(source);
|
|
693
|
+
const kind = type?.kind;
|
|
694
|
+
if (kind !== "image" && kind !== "video" && kind !== "audio") {
|
|
695
|
+
return void 0;
|
|
696
|
+
}
|
|
697
|
+
const ext = type?.ext ?? extname(source).toLowerCase();
|
|
698
|
+
const base = join(opts.cacheDir, `cover-${cacheKey(resId, source)}`);
|
|
699
|
+
const hit = cached(base);
|
|
700
|
+
if (hit !== void 0) return hit;
|
|
701
|
+
const render = await loadRender();
|
|
702
|
+
if (render === void 0) return void 0;
|
|
703
|
+
await mkdir(opts.cacheDir, { recursive: true });
|
|
704
|
+
try {
|
|
705
|
+
if (kind === "image") {
|
|
706
|
+
const bytes = await readEntry(api, source);
|
|
707
|
+
if (bytes === void 0) return void 0;
|
|
708
|
+
const rendered = await render.renderImageThumbOnce({
|
|
709
|
+
input: Buffer.from(bytes),
|
|
710
|
+
ext,
|
|
711
|
+
resolveDest: (format) => `${base}.${format}`,
|
|
712
|
+
variant: {
|
|
713
|
+
format: "avif",
|
|
714
|
+
fit: "inside",
|
|
715
|
+
maxArea: RESOURCE_COVER_MAX_AREA,
|
|
716
|
+
webpQuality: render.WEBP_QUALITY,
|
|
717
|
+
avifQuality: render.AVIF_QUALITY
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
return {
|
|
721
|
+
path: rendered.path,
|
|
722
|
+
contentType: contentTypeOf(rendered.format)
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const sourcePath = join(
|
|
726
|
+
opts.cacheDir,
|
|
727
|
+
`source-${cacheKey(resId, source, "cover")}${ext}`
|
|
728
|
+
);
|
|
729
|
+
if (!existsSync(sourcePath)) {
|
|
730
|
+
const bytes = await readEntry(api, source);
|
|
731
|
+
if (bytes === void 0) return void 0;
|
|
732
|
+
await writeFile(sourcePath, bytes);
|
|
733
|
+
}
|
|
734
|
+
const destPath = `${base}.avif`;
|
|
735
|
+
if (kind === "video") {
|
|
736
|
+
await render.renderVideoFrame({
|
|
737
|
+
source: sourcePath,
|
|
738
|
+
destPath,
|
|
739
|
+
ffmpeg: render.resolveFfmpegPaths(),
|
|
740
|
+
maxArea: RESOURCE_COVER_MAX_AREA,
|
|
741
|
+
quality: render.AVIF_QUALITY,
|
|
742
|
+
format: "avif",
|
|
743
|
+
timeSeconds: 0,
|
|
744
|
+
ext
|
|
745
|
+
});
|
|
746
|
+
} else {
|
|
747
|
+
await render.renderAudioCoverArt({
|
|
748
|
+
source: sourcePath,
|
|
749
|
+
destPath,
|
|
750
|
+
ffmpeg: render.resolveFfmpegPaths(),
|
|
751
|
+
maxArea: RESOURCE_COVER_MAX_AREA,
|
|
752
|
+
quality: render.AVIF_QUALITY,
|
|
753
|
+
format: "avif",
|
|
754
|
+
ext
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
return { path: destPath, contentType: "image/avif" };
|
|
758
|
+
} catch (err2) {
|
|
759
|
+
console.warn(
|
|
760
|
+
`[hoardodile] cover render failed for ${source}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
761
|
+
);
|
|
762
|
+
return void 0;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
async function preview(resId, path, variant) {
|
|
766
|
+
const api = await opts.resolveApi(resId);
|
|
767
|
+
if (api === void 0) return void 0;
|
|
768
|
+
if ((await api.sniff(path))?.kind !== "image") return void 0;
|
|
769
|
+
const parsed = parseImageVariantQuery(variant ?? {});
|
|
770
|
+
if (parsed.kind !== "variant") return void 0;
|
|
771
|
+
const render = await loadRender();
|
|
772
|
+
if (render === void 0) return void 0;
|
|
773
|
+
const resolved = normalizeImageVariantSpec(parsed.spec, {
|
|
774
|
+
avifQuality: render.PREVIEW_AVIF_QUALITY,
|
|
775
|
+
webpQuality: render.PREVIEW_WEBP_QUALITY
|
|
776
|
+
});
|
|
777
|
+
const base = join(
|
|
778
|
+
opts.cacheDir,
|
|
779
|
+
`preview-${cacheKey(resId, path, imageVariantCanonical(resolved))}`
|
|
780
|
+
);
|
|
781
|
+
const hit = cached(base);
|
|
782
|
+
if (hit !== void 0) return hit;
|
|
783
|
+
const bytes = await readEntry(api, path);
|
|
784
|
+
if (bytes === void 0) return void 0;
|
|
785
|
+
await mkdir(opts.cacheDir, { recursive: true });
|
|
786
|
+
try {
|
|
787
|
+
const rendered = await render.renderImageThumbOnce({
|
|
788
|
+
input: Buffer.from(bytes),
|
|
789
|
+
resolveDest: (format) => `${base}.${format}`,
|
|
790
|
+
variant: resolved
|
|
791
|
+
});
|
|
792
|
+
return {
|
|
793
|
+
path: rendered.path,
|
|
794
|
+
contentType: contentTypeOf(rendered.format)
|
|
795
|
+
};
|
|
796
|
+
} catch (err2) {
|
|
797
|
+
console.warn(
|
|
798
|
+
`[hoardodile] preview render failed for ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
799
|
+
);
|
|
800
|
+
return void 0;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async function frame(resId, path, timeMs) {
|
|
804
|
+
const api = await opts.resolveApi(resId);
|
|
805
|
+
if (api === void 0) return void 0;
|
|
806
|
+
const type = await api.sniff(path);
|
|
807
|
+
if (type?.kind !== "video") return void 0;
|
|
808
|
+
const destPath = join(
|
|
809
|
+
opts.cacheDir,
|
|
810
|
+
`frame-${cacheKey(resId, path, `@${timeMs}`)}.avif`
|
|
811
|
+
);
|
|
812
|
+
if (existsSync(destPath)) {
|
|
813
|
+
return { path: destPath, contentType: "image/avif" };
|
|
814
|
+
}
|
|
815
|
+
const render = await loadRender();
|
|
816
|
+
if (render === void 0) return void 0;
|
|
817
|
+
const sourcePath = join(
|
|
818
|
+
opts.cacheDir,
|
|
819
|
+
`source-${cacheKey(resId, path)}${type.ext}`
|
|
820
|
+
);
|
|
821
|
+
if (!existsSync(sourcePath)) {
|
|
822
|
+
const bytes = await readEntry(api, path);
|
|
823
|
+
if (bytes === void 0) return void 0;
|
|
824
|
+
await mkdir(opts.cacheDir, { recursive: true });
|
|
825
|
+
await writeFile(sourcePath, bytes);
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
await render.renderVideoFrame({
|
|
829
|
+
source: sourcePath,
|
|
830
|
+
destPath,
|
|
831
|
+
ffmpeg: render.resolveFfmpegPaths(),
|
|
832
|
+
maxArea: RESOURCE_PREVIEW_MAX_AREA,
|
|
833
|
+
quality: render.PREVIEW_AVIF_QUALITY,
|
|
834
|
+
format: "avif",
|
|
835
|
+
timeSeconds: timeMs / 1e3,
|
|
836
|
+
ext: type.ext
|
|
837
|
+
});
|
|
838
|
+
return { path: destPath, contentType: "image/avif" };
|
|
839
|
+
} catch (err2) {
|
|
840
|
+
console.warn(
|
|
841
|
+
`[hoardodile] frame render failed for ${path}@${timeMs}ms: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
842
|
+
);
|
|
843
|
+
return void 0;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return { cover, preview, frame };
|
|
847
|
+
}
|
|
848
|
+
var requireBuiltin = createRequire(import.meta.url);
|
|
849
|
+
function parseJson(value) {
|
|
850
|
+
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
851
|
+
try {
|
|
852
|
+
return JSON.parse(value);
|
|
853
|
+
} catch {
|
|
854
|
+
return void 0;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function asString(value) {
|
|
858
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
859
|
+
}
|
|
860
|
+
function openReadOnly(dbPath) {
|
|
861
|
+
const { DatabaseSync: Database } = requireBuiltin("node:sqlite");
|
|
862
|
+
try {
|
|
863
|
+
return new Database(dbPath, { readOnly: true });
|
|
864
|
+
} catch (err2) {
|
|
865
|
+
const snapshotDir = mkdtempSync(join(tmpdir(), "hoardodile-wb-"));
|
|
866
|
+
const dir = resolve(dbPath, "..");
|
|
867
|
+
const base = dbPath.slice(dir.length + 1);
|
|
868
|
+
let copied = false;
|
|
869
|
+
for (const entry of readdirSync(dir)) {
|
|
870
|
+
if (!entry.startsWith(base)) continue;
|
|
871
|
+
copyFileSync(join(dir, entry), join(snapshotDir, entry));
|
|
872
|
+
copied = entry === base || copied;
|
|
873
|
+
}
|
|
874
|
+
if (!copied) throw err2;
|
|
875
|
+
console.log(
|
|
876
|
+
`[hoardodile] database is busy \u2014 reading a temporary copy (${snapshotDir})`
|
|
877
|
+
);
|
|
878
|
+
return new Database(join(snapshotDir, base), { readOnly: true });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function openStorage(rootDir) {
|
|
882
|
+
const root = resolve(rootDir);
|
|
883
|
+
const dbPath = join(root, "app.sqlite");
|
|
884
|
+
if (!existsSync(dbPath)) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
`no app.sqlite in ${root} \u2014 point --storage at a hoardodile data root`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
const db = openReadOnly(dbPath);
|
|
890
|
+
const paths = createStoragePaths({ root });
|
|
891
|
+
function rowToResource(row) {
|
|
892
|
+
const stats = parseJson(row.file_stats);
|
|
893
|
+
return {
|
|
894
|
+
id: String(row.id),
|
|
895
|
+
name: String(row.name ?? row.id),
|
|
896
|
+
contentPluginId: asString(row.content_plugin_id),
|
|
897
|
+
fileVersion: Number(row.file_version ?? 1),
|
|
898
|
+
sourceMeta: parseJson(row.source_meta),
|
|
899
|
+
searchMeta: parseJson(row.search_meta),
|
|
900
|
+
fileStats: typeof stats === "object" && stats !== null ? stats : void 0
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const listStmt = db.prepare(
|
|
904
|
+
`SELECT r.id, r.name, r.content_plugin_id, r.file_version,
|
|
905
|
+
m.source_meta, m.search_meta, m.file_stats
|
|
906
|
+
FROM resources r
|
|
907
|
+
LEFT JOIN resource_meta m ON m.resource_id = r.id
|
|
908
|
+
WHERE r.deleted_at IS NULL
|
|
909
|
+
ORDER BY r.created_at DESC`
|
|
910
|
+
);
|
|
911
|
+
const findStmt = db.prepare(
|
|
912
|
+
`SELECT r.id, r.name, r.content_plugin_id, r.file_version,
|
|
913
|
+
m.source_meta, m.search_meta, m.file_stats
|
|
914
|
+
FROM resources r
|
|
915
|
+
LEFT JOIN resource_meta m ON m.resource_id = r.id
|
|
916
|
+
WHERE r.id = ?`
|
|
917
|
+
);
|
|
918
|
+
const commentsStmt = db.prepare(
|
|
919
|
+
`SELECT c.id, c.body, c.created_at, c.floor, c.anchor_data
|
|
920
|
+
FROM comments c
|
|
921
|
+
JOIN comment_resources cr ON cr.comment_id = c.id
|
|
922
|
+
WHERE cr.resource_id = ? AND c.deleted_at IS NULL
|
|
923
|
+
ORDER BY c.created_at ASC`
|
|
924
|
+
);
|
|
925
|
+
const danmakuStmt = db.prepare(
|
|
926
|
+
`SELECT id, anchor_data, text, color, mode, created_at
|
|
927
|
+
FROM danmakus
|
|
928
|
+
WHERE anchor_resource_id = ?
|
|
929
|
+
ORDER BY created_at ASC`
|
|
930
|
+
);
|
|
931
|
+
const prefsStmt = db.prepare(
|
|
932
|
+
`SELECT key, value FROM plugin_preferences WHERE plugin_id = ?`
|
|
933
|
+
);
|
|
934
|
+
const cacheStmt = db.prepare(
|
|
935
|
+
`SELECT key, value FROM plugin_cache WHERE plugin_id = ? AND res_id = ?`
|
|
936
|
+
);
|
|
937
|
+
function keyValues(rows) {
|
|
938
|
+
const out = {};
|
|
939
|
+
for (const row of rows) out[String(row.key)] = String(row.value);
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
listResources() {
|
|
944
|
+
return listStmt.all().map(rowToResource);
|
|
945
|
+
},
|
|
946
|
+
findResource(resId) {
|
|
947
|
+
const row = findStmt.get(resId);
|
|
948
|
+
return row === void 0 ? void 0 : rowToResource(row);
|
|
949
|
+
},
|
|
950
|
+
readState(resId, pluginId) {
|
|
951
|
+
const resource = this.findResource(resId);
|
|
952
|
+
return {
|
|
953
|
+
name: resource?.name ?? resId,
|
|
954
|
+
messages: commentsStmt.all(resId).map((row) => ({
|
|
955
|
+
id: String(row.id),
|
|
956
|
+
body: String(row.body ?? ""),
|
|
957
|
+
createdAt: Number(row.created_at ?? 0),
|
|
958
|
+
charIds: [],
|
|
959
|
+
resIds: [resId],
|
|
960
|
+
likeCount: 0,
|
|
961
|
+
dislikeCount: 0,
|
|
962
|
+
replyCount: 0,
|
|
963
|
+
floor: row.floor === null ? void 0 : Number(row.floor),
|
|
964
|
+
anchor: {
|
|
965
|
+
resId,
|
|
966
|
+
data: parseJson(row.anchor_data)?.data
|
|
967
|
+
}
|
|
968
|
+
})),
|
|
969
|
+
danmaku: danmakuStmt.all(resId).map((row) => ({
|
|
970
|
+
id: String(row.id),
|
|
971
|
+
anchor: {
|
|
972
|
+
resId,
|
|
973
|
+
data: parseJson(row.anchor_data)?.data
|
|
974
|
+
},
|
|
975
|
+
text: String(row.text ?? ""),
|
|
976
|
+
color: String(row.color ?? ""),
|
|
977
|
+
mode: String(row.mode ?? "scroll"),
|
|
978
|
+
createdAt: Number(row.created_at ?? 0)
|
|
979
|
+
})),
|
|
980
|
+
prefs: keyValues(prefsStmt.all(pluginId)),
|
|
981
|
+
cache: keyValues(cacheStmt.all(pluginId, resId))
|
|
982
|
+
};
|
|
983
|
+
},
|
|
984
|
+
archivePath(resource) {
|
|
985
|
+
return paths.atVersion(resource.fileVersion).resource(resource.id);
|
|
986
|
+
},
|
|
987
|
+
close() {
|
|
988
|
+
db.close();
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// src/dev.ts
|
|
994
|
+
var RECAPTURE_DEBOUNCE_MS = 300;
|
|
995
|
+
var FIRST_BUILD_TIMEOUT_MS = 6e4;
|
|
996
|
+
var RESOURCE_LIST_LIMIT = 200;
|
|
997
|
+
var DIRECTORY_RES_ID = "workbench";
|
|
998
|
+
function resolvePluginDir(value) {
|
|
999
|
+
const dir = resolve(value ?? ".");
|
|
1000
|
+
if (!existsSync(join(dir, "manifest.json"))) {
|
|
1001
|
+
throw new Error(
|
|
1002
|
+
`no manifest.json found in ${dir} \u2014 run this from a plugin directory or pass --plugin-dir`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
return dir;
|
|
1006
|
+
}
|
|
1007
|
+
function hasWatchScript(pluginDir) {
|
|
1008
|
+
try {
|
|
1009
|
+
const pkg = JSON.parse(
|
|
1010
|
+
readFileSync(join(pluginDir, "package.json"), "utf8")
|
|
1011
|
+
);
|
|
1012
|
+
return typeof pkg?.scripts?.watch === "string";
|
|
1013
|
+
} catch {
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async function loadWorkbenchServe(pluginDir) {
|
|
1018
|
+
const requireFromPlugin = createRequire(join(pluginDir, "package.json"));
|
|
1019
|
+
let workbenchEntry;
|
|
1020
|
+
try {
|
|
1021
|
+
workbenchEntry = requireFromPlugin.resolve("@hoardodile/workbench");
|
|
1022
|
+
} catch {
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
"@hoardodile/workbench not found in this plugin's dependencies \u2014 add it to devDependencies (the template and `create-hoardodile-plugin` include it) and run `pnpm install`."
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
return import(pathToFileURL(workbenchEntry).href);
|
|
1028
|
+
}
|
|
1029
|
+
function spawnWatcher(opts) {
|
|
1030
|
+
console.log(`[hoardodile] starting ${opts.label}`);
|
|
1031
|
+
return spawn(opts.command, [...opts.args], {
|
|
1032
|
+
cwd: opts.cwd,
|
|
1033
|
+
stdio: "inherit",
|
|
1034
|
+
shell: process.platform === "win32"
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
function startBuildWatcher(pluginDir) {
|
|
1038
|
+
const watcher = hasWatchScript(pluginDir) ? spawnWatcher({
|
|
1039
|
+
command: "pnpm",
|
|
1040
|
+
args: ["watch"],
|
|
1041
|
+
cwd: pluginDir,
|
|
1042
|
+
label: `\`pnpm watch\` in ${pluginDir}`
|
|
1043
|
+
}) : spawnWatcher({
|
|
1044
|
+
command: process.execPath,
|
|
1045
|
+
args: [process.argv[1] ?? "", "plugin", "build", "--watch"],
|
|
1046
|
+
cwd: pluginDir,
|
|
1047
|
+
label: "`plugin build --watch` (no watch script found)"
|
|
1048
|
+
});
|
|
1049
|
+
watcher.on("error", (err2) => {
|
|
1050
|
+
console.error(`[hoardodile] build watcher failed: ${err2.message}`);
|
|
1051
|
+
process.exitCode = EXIT_ERROR;
|
|
1052
|
+
});
|
|
1053
|
+
watcher.on("exit", (code) => {
|
|
1054
|
+
if (code !== null && code !== 0) {
|
|
1055
|
+
console.error(`[hoardodile] build watcher exited with code ${code}`);
|
|
1056
|
+
process.exitCode = EXIT_ERROR;
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
return watcher;
|
|
1060
|
+
}
|
|
1061
|
+
async function waitForBuild(distDir, timeoutMs) {
|
|
1062
|
+
const started = Date.now();
|
|
1063
|
+
while (Date.now() - started < timeoutMs) {
|
|
1064
|
+
if (existsSync(join(distDir, "main.js"))) return true;
|
|
1065
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1066
|
+
}
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
function directorySource(dataDir, extractCacheDir) {
|
|
1070
|
+
const api = buildCliResourceAPI(dataDir, { extractCacheDir });
|
|
1071
|
+
return {
|
|
1072
|
+
list: () => [{ id: DIRECTORY_RES_ID, name: "Workbench" }],
|
|
1073
|
+
apiFor: (resId) => resId === DIRECTORY_RES_ID ? api : void 0,
|
|
1074
|
+
stateFor: () => void 0,
|
|
1075
|
+
extractCacheDir
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
function storageSource(storage, extractCacheDir) {
|
|
1079
|
+
const apis = /* @__PURE__ */ new Map();
|
|
1080
|
+
return {
|
|
1081
|
+
list: () => storage.listResources().slice(0, RESOURCE_LIST_LIMIT).map((resource) => ({
|
|
1082
|
+
id: resource.id,
|
|
1083
|
+
name: resource.name,
|
|
1084
|
+
contentPluginId: resource.contentPluginId
|
|
1085
|
+
})),
|
|
1086
|
+
apiFor(resId) {
|
|
1087
|
+
if (apis.has(resId)) return apis.get(resId);
|
|
1088
|
+
const resource = storage.findResource(resId);
|
|
1089
|
+
const archive = resource === void 0 ? void 0 : storage.archivePath(resource);
|
|
1090
|
+
const api = archive !== void 0 && existsSync(archive) ? buildArchiveResourceAPI(archive, extractCacheDir) : void 0;
|
|
1091
|
+
if (api === void 0 && resource !== void 0) {
|
|
1092
|
+
console.warn(`[hoardodile] no source archive for resource ${resId}`);
|
|
1093
|
+
}
|
|
1094
|
+
apis.set(resId, api);
|
|
1095
|
+
return api;
|
|
1096
|
+
},
|
|
1097
|
+
stateFor: (resId, pluginId) => storage.readState(resId, pluginId),
|
|
1098
|
+
extractCacheDir,
|
|
1099
|
+
close: () => storage.close()
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function createSnapshotStore(opts) {
|
|
1103
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
1104
|
+
const running = /* @__PURE__ */ new Map();
|
|
1105
|
+
async function capture(resId) {
|
|
1106
|
+
const api = opts.source.apiFor(resId);
|
|
1107
|
+
if (api === void 0) return;
|
|
1108
|
+
try {
|
|
1109
|
+
const target = resolveBuiltPluginTarget(opts.pluginDir);
|
|
1110
|
+
const snapshot = await captureHookSnapshot({ target, api });
|
|
1111
|
+
snapshots.set(resId, snapshot);
|
|
1112
|
+
console.log(`[hoardodile] ${resId}: ${formatHookSnapshot(snapshot)}`);
|
|
1113
|
+
if (!snapshot.detect.ok) {
|
|
1114
|
+
console.error(
|
|
1115
|
+
"[hoardodile] detect failed \u2014 the plugin may not match this resource."
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
} catch (err2) {
|
|
1119
|
+
console.error(
|
|
1120
|
+
`[hoardodile] hook snapshot failed: ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function refresh(resId) {
|
|
1125
|
+
const inFlight = running.get(resId);
|
|
1126
|
+
if (inFlight !== void 0) return inFlight;
|
|
1127
|
+
const run = capture(resId).finally(() => running.delete(resId));
|
|
1128
|
+
running.set(resId, run);
|
|
1129
|
+
return run;
|
|
1130
|
+
}
|
|
1131
|
+
return {
|
|
1132
|
+
/** Capture on demand, then serve the cached result. */
|
|
1133
|
+
async read(resId) {
|
|
1134
|
+
if (!snapshots.has(resId)) await refresh(resId);
|
|
1135
|
+
return snapshots.get(resId);
|
|
1136
|
+
},
|
|
1137
|
+
/** Drop every capture so the next read re-runs against the new build. */
|
|
1138
|
+
invalidate() {
|
|
1139
|
+
snapshots.clear();
|
|
1140
|
+
},
|
|
1141
|
+
captured: () => [...snapshots.keys()],
|
|
1142
|
+
refresh
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function watchDistForRebuilds(distDir, onRebuild) {
|
|
1146
|
+
let timer;
|
|
1147
|
+
const watcher = watch(distDir, () => {
|
|
1148
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1149
|
+
timer = setTimeout(onRebuild, RECAPTURE_DEBOUNCE_MS);
|
|
1150
|
+
});
|
|
1151
|
+
return () => {
|
|
1152
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1153
|
+
watcher.close();
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
function resolveSource(opts) {
|
|
1157
|
+
const extractCacheDir = join(opts.pluginDir, ".hoardodile", "extract");
|
|
1158
|
+
if (opts.storageDir !== void 0) {
|
|
1159
|
+
const root = resolve(opts.storageDir);
|
|
1160
|
+
const storage = openStorage(root);
|
|
1161
|
+
const count = storage.listResources().length;
|
|
1162
|
+
console.log(`[hoardodile] storage: ${root} (${count} resources, read-only)`);
|
|
1163
|
+
return storageSource(storage, extractCacheDir);
|
|
1164
|
+
}
|
|
1165
|
+
const dataDir = opts.dataDir !== void 0 ? resolve(opts.dataDir) : join(resolve(opts.pluginDir ?? "."), "testdata");
|
|
1166
|
+
if (!existsSync(dataDir)) {
|
|
1167
|
+
console.warn(
|
|
1168
|
+
`[hoardodile] no data dir found at ${dataDir} \u2014 mount one with --data (or --storage <hoardodile-root>).`
|
|
1169
|
+
);
|
|
1170
|
+
return void 0;
|
|
1171
|
+
}
|
|
1172
|
+
console.log(`[hoardodile] data: ${dataDir}`);
|
|
1173
|
+
return directorySource(dataDir, extractCacheDir);
|
|
1174
|
+
}
|
|
1175
|
+
async function executeDev(opts) {
|
|
1176
|
+
const pluginDir = resolvePluginDir(opts.pluginDir);
|
|
1177
|
+
const distDir = join(pluginDir, "dist");
|
|
1178
|
+
const source = resolveSource({ ...opts, pluginDir });
|
|
1179
|
+
const target = existsSync(join(distDir, "main.js")) ? resolveBuiltPluginTarget(pluginDir) : void 0;
|
|
1180
|
+
const watcher = startBuildWatcher(pluginDir);
|
|
1181
|
+
const snapshots = source === void 0 ? void 0 : createSnapshotStore({ pluginDir, source });
|
|
1182
|
+
const render = source === void 0 ? void 0 : createRenderProviders({
|
|
1183
|
+
resolveApi: async (resId) => source.apiFor(resId),
|
|
1184
|
+
// The plugin's coverLocal pick is captured in the hook
|
|
1185
|
+
// snapshot; the cover render feeds the workbench picker.
|
|
1186
|
+
resolveCoverSource: async (resId) => (await snapshots?.read(resId))?.coverLocal,
|
|
1187
|
+
// Rendered artifacts live with the plugin, never inside
|
|
1188
|
+
// the user's storage root.
|
|
1189
|
+
cacheDir: join(pluginDir, ".hoardodile", "cache")
|
|
1190
|
+
});
|
|
1191
|
+
const { serveWorkbench } = await loadWorkbenchServe(pluginDir);
|
|
1192
|
+
const server = await serveWorkbench({
|
|
1193
|
+
pluginDir: distDir,
|
|
1194
|
+
port: opts.port,
|
|
1195
|
+
// User-consented dev downloads land in the plugin's own scratch
|
|
1196
|
+
// vault, next to the extraction cache — never in the read-only
|
|
1197
|
+
// storage root or the data dir.
|
|
1198
|
+
vaultRoot: join(pluginDir, ".hoardodile", "vault"),
|
|
1199
|
+
providers: {
|
|
1200
|
+
resources: () => source?.list() ?? [],
|
|
1201
|
+
files: source === void 0 ? void 0 : createFileProvider(source),
|
|
1202
|
+
snapshot: (resId) => snapshots?.read(resId),
|
|
1203
|
+
state: (resId) => source?.stateFor(resId, target?.id ?? ""),
|
|
1204
|
+
preview: render?.preview,
|
|
1205
|
+
frame: render?.frame,
|
|
1206
|
+
cover: render?.cover
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
let stopDistWatch;
|
|
1210
|
+
if (await waitForBuild(distDir, FIRST_BUILD_TIMEOUT_MS)) {
|
|
1211
|
+
const first = opts.resId ?? source?.list()[0]?.id;
|
|
1212
|
+
if (first !== void 0) await snapshots?.refresh(first);
|
|
1213
|
+
stopDistWatch = watchDistForRebuilds(distDir, () => {
|
|
1214
|
+
const captured = snapshots?.captured() ?? [];
|
|
1215
|
+
snapshots?.invalidate();
|
|
1216
|
+
for (const resId of captured) void snapshots?.refresh(resId);
|
|
1217
|
+
});
|
|
1218
|
+
} else {
|
|
1219
|
+
console.error(
|
|
1220
|
+
"[hoardodile] no dist/main.js appeared within 60s \u2014 is the build working?"
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
await new Promise((resolveStop) => {
|
|
1224
|
+
const stop = () => {
|
|
1225
|
+
stopDistWatch?.();
|
|
1226
|
+
watcher.kill();
|
|
1227
|
+
source?.close?.();
|
|
1228
|
+
server.close(() => resolveStop());
|
|
1229
|
+
};
|
|
1230
|
+
process.once("SIGINT", stop);
|
|
1231
|
+
process.once("SIGTERM", stop);
|
|
1232
|
+
});
|
|
1233
|
+
return EXIT_PASS;
|
|
1234
|
+
}
|
|
1235
|
+
function createFileProvider(source) {
|
|
1236
|
+
return {
|
|
1237
|
+
async list(resId) {
|
|
1238
|
+
return await source.apiFor(resId)?.listFileNames() ?? [];
|
|
1239
|
+
},
|
|
1240
|
+
async stat(resId, path) {
|
|
1241
|
+
return source.apiFor(resId)?.statFile(path);
|
|
1242
|
+
},
|
|
1243
|
+
async read(resId, path) {
|
|
1244
|
+
try {
|
|
1245
|
+
return await source.apiFor(resId)?.readFile(path);
|
|
1246
|
+
} catch {
|
|
1247
|
+
return void 0;
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
async extracted(_path, path) {
|
|
1251
|
+
const root = source.extractCacheDir;
|
|
1252
|
+
if (root === void 0) return void 0;
|
|
1253
|
+
const abs = resolve(root, path);
|
|
1254
|
+
if (abs !== resolve(root) && !abs.startsWith(resolve(root) + sep)) {
|
|
1255
|
+
return void 0;
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
return readFileSync(abs);
|
|
1259
|
+
} catch {
|
|
1260
|
+
return void 0;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/main.ts
|
|
1267
|
+
function parseRepeat(value) {
|
|
1268
|
+
if (value === void 0) return 5;
|
|
1269
|
+
const n = Number.parseInt(value, 10);
|
|
1270
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
1271
|
+
throw new CliError(`--repeat must be a positive integer, got "${value}"`);
|
|
1272
|
+
}
|
|
1273
|
+
return n;
|
|
1274
|
+
}
|
|
1275
|
+
function parseWarmup(value) {
|
|
1276
|
+
if (value === void 0) return 1;
|
|
1277
|
+
const n = Number.parseInt(value, 10);
|
|
1278
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
1279
|
+
throw new CliError(
|
|
1280
|
+
`--warmup must be a non-negative integer, got "${value}"`
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
return n;
|
|
1284
|
+
}
|
|
1285
|
+
function parseThreshold(value) {
|
|
1286
|
+
if (value === void 0) return 20;
|
|
1287
|
+
const n = Number.parseFloat(value);
|
|
1288
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
1289
|
+
throw new CliError(
|
|
1290
|
+
`--threshold must be a non-negative number, got "${value}"`
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
return n;
|
|
1294
|
+
}
|
|
1295
|
+
function parseHook(value) {
|
|
1296
|
+
if (!HOOK_NAMES.includes(value)) {
|
|
1297
|
+
throw new CliError(
|
|
1298
|
+
`unknown hook "${value}" \u2014 expected one of: ${HOOK_NAMES.join(", ")}`
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
return value;
|
|
1302
|
+
}
|
|
1303
|
+
function parsePort(value) {
|
|
1304
|
+
const n = Number.parseInt(value, 10);
|
|
1305
|
+
if (!Number.isFinite(n) || n < 1 || n > 65535) {
|
|
1306
|
+
throw new CliError(`--port must be a valid port number, got "${value}"`);
|
|
1307
|
+
}
|
|
1308
|
+
return n;
|
|
1309
|
+
}
|
|
1310
|
+
var main = defineCommand({
|
|
1311
|
+
meta: {
|
|
1312
|
+
name: "hoardodile",
|
|
1313
|
+
description: "hoardodile developer CLI \u2014 content-plugin create, build, run, bench and dev commands."
|
|
1314
|
+
},
|
|
1315
|
+
subCommands: {
|
|
1316
|
+
plugin: defineCommand({
|
|
1317
|
+
meta: {
|
|
1318
|
+
name: "plugin",
|
|
1319
|
+
description: "Content-plugin developer commands."
|
|
1320
|
+
},
|
|
1321
|
+
subCommands: {
|
|
1322
|
+
create: defineCommand({
|
|
1323
|
+
meta: {
|
|
1324
|
+
name: "create",
|
|
1325
|
+
description: "Scaffold a new content plugin (runs create-hoardodile-plugin via pnpm dlx)."
|
|
1326
|
+
},
|
|
1327
|
+
args: {
|
|
1328
|
+
name: {
|
|
1329
|
+
type: "positional",
|
|
1330
|
+
description: "Plugin directory name (npm-style, lowercase letters, digits and dashes). Defaults to an interactive prompt."
|
|
1331
|
+
},
|
|
1332
|
+
tarballs: {
|
|
1333
|
+
type: "string",
|
|
1334
|
+
description: "Directory of packed SDK tarballs; rewires the scaffolded plugin's SDK deps to them."
|
|
1335
|
+
}
|
|
1336
|
+
},
|
|
1337
|
+
async run({ args }) {
|
|
1338
|
+
return executeCreate({
|
|
1339
|
+
name: args.name,
|
|
1340
|
+
tarballs: args.tarballs
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
}),
|
|
1344
|
+
run: defineCommand({
|
|
1345
|
+
meta: {
|
|
1346
|
+
name: "run",
|
|
1347
|
+
description: "Run one plugin hook against a directory and print the JSON result."
|
|
1348
|
+
},
|
|
1349
|
+
args: {
|
|
1350
|
+
hook: {
|
|
1351
|
+
type: "positional",
|
|
1352
|
+
required: true,
|
|
1353
|
+
description: `Hook to run: ${HOOK_NAMES.join(", ")}`
|
|
1354
|
+
},
|
|
1355
|
+
dir: {
|
|
1356
|
+
type: "positional",
|
|
1357
|
+
required: true,
|
|
1358
|
+
description: "Data directory the hook reads from."
|
|
1359
|
+
},
|
|
1360
|
+
pluginDir: {
|
|
1361
|
+
type: "string",
|
|
1362
|
+
description: "Plugin directory (manifest.json + main.js). Defaults to the --main parent."
|
|
1363
|
+
},
|
|
1364
|
+
main: {
|
|
1365
|
+
type: "string",
|
|
1366
|
+
description: "Path to the built plugin main.js. Defaults to <plugin-dir>/main.js."
|
|
1367
|
+
},
|
|
1368
|
+
inProcess: {
|
|
1369
|
+
type: "boolean",
|
|
1370
|
+
description: "Run the hook in-process instead of through the worker sandbox. Debugging only \u2014 bypasses the production execution path."
|
|
1371
|
+
},
|
|
1372
|
+
pretty: {
|
|
1373
|
+
type: "boolean",
|
|
1374
|
+
description: "Pretty-print the JSON result (indented)."
|
|
1375
|
+
}
|
|
1376
|
+
},
|
|
1377
|
+
async run({ args }) {
|
|
1378
|
+
return executeRun({
|
|
1379
|
+
hook: parseHook(args.hook ?? ""),
|
|
1380
|
+
dir: args.dir ?? "",
|
|
1381
|
+
pluginDir: args.pluginDir,
|
|
1382
|
+
main: args.main,
|
|
1383
|
+
inProcess: args.inProcess === true,
|
|
1384
|
+
pretty: args.pretty === true
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}),
|
|
1388
|
+
bench: defineCommand({
|
|
1389
|
+
meta: {
|
|
1390
|
+
name: "bench",
|
|
1391
|
+
description: "Measure hook duration (sandboxed, real probes) and compare against a baseline."
|
|
1392
|
+
},
|
|
1393
|
+
args: {
|
|
1394
|
+
hook: {
|
|
1395
|
+
type: "positional",
|
|
1396
|
+
required: true,
|
|
1397
|
+
description: `Hook to benchmark: ${HOOK_NAMES.join(", ")}`
|
|
1398
|
+
},
|
|
1399
|
+
dir: {
|
|
1400
|
+
type: "positional",
|
|
1401
|
+
required: true,
|
|
1402
|
+
description: "Data directory the hook reads from."
|
|
1403
|
+
},
|
|
1404
|
+
pluginDir: {
|
|
1405
|
+
type: "string",
|
|
1406
|
+
description: "Plugin directory (manifest.json + main.js). Defaults to the --main parent."
|
|
1407
|
+
},
|
|
1408
|
+
main: {
|
|
1409
|
+
type: "string",
|
|
1410
|
+
description: "Path to the built plugin main.js. Defaults to <plugin-dir>/main.js."
|
|
1411
|
+
},
|
|
1412
|
+
inProcess: {
|
|
1413
|
+
type: "boolean",
|
|
1414
|
+
description: "Run in-process instead of through the worker sandbox. Debugging only."
|
|
1415
|
+
},
|
|
1416
|
+
repeat: {
|
|
1417
|
+
type: "string",
|
|
1418
|
+
description: "Measured iterations after the warmup runs. Default 5."
|
|
1419
|
+
},
|
|
1420
|
+
warmup: {
|
|
1421
|
+
type: "string",
|
|
1422
|
+
description: "Discarded warmup runs before the measured samples. Default 1."
|
|
1423
|
+
},
|
|
1424
|
+
json: {
|
|
1425
|
+
type: "string",
|
|
1426
|
+
description: "Write the JSON report to this file."
|
|
1427
|
+
},
|
|
1428
|
+
save: {
|
|
1429
|
+
type: "string",
|
|
1430
|
+
description: "Save a fresh baseline report to this file."
|
|
1431
|
+
},
|
|
1432
|
+
compare: {
|
|
1433
|
+
type: "string",
|
|
1434
|
+
description: "Compare against a previous report file; exit 1 when the median regresses past --threshold."
|
|
1435
|
+
},
|
|
1436
|
+
threshold: {
|
|
1437
|
+
type: "string",
|
|
1438
|
+
description: "Regression threshold in percent of the baseline median. Default 20."
|
|
1439
|
+
}
|
|
1440
|
+
},
|
|
1441
|
+
async run({ args }) {
|
|
1442
|
+
return executeBench({
|
|
1443
|
+
hook: parseHook(args.hook ?? ""),
|
|
1444
|
+
dir: args.dir ?? "",
|
|
1445
|
+
pluginDir: args.pluginDir,
|
|
1446
|
+
main: args.main,
|
|
1447
|
+
inProcess: args.inProcess === true,
|
|
1448
|
+
repeat: parseRepeat(args.repeat),
|
|
1449
|
+
warmupRuns: parseWarmup(args.warmup),
|
|
1450
|
+
thresholdPercent: parseThreshold(args.threshold),
|
|
1451
|
+
jsonPath: args.json,
|
|
1452
|
+
savePath: args.save,
|
|
1453
|
+
comparePath: args.compare
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
}),
|
|
1457
|
+
dev: defineCommand({
|
|
1458
|
+
meta: {
|
|
1459
|
+
name: "dev",
|
|
1460
|
+
description: "Build a plugin in watch mode and serve it in the workbench (default http://127.0.0.1:5199), with sandboxed hooks, preview variants and video frames."
|
|
1461
|
+
},
|
|
1462
|
+
args: {
|
|
1463
|
+
pluginDir: {
|
|
1464
|
+
type: "string",
|
|
1465
|
+
description: "Plugin directory (manifest.json + package.json). Defaults to the current directory."
|
|
1466
|
+
},
|
|
1467
|
+
data: {
|
|
1468
|
+
type: "string",
|
|
1469
|
+
description: "Data directory served as a single resource. Defaults to <plugin-dir>/testdata when it exists."
|
|
1470
|
+
},
|
|
1471
|
+
storage: {
|
|
1472
|
+
type: "string",
|
|
1473
|
+
description: "hoardodile storage root to develop against. Opened READ-ONLY: resources come from app.sqlite and their bare-file folders, and plugin writes stay in the workbench's in-memory mock."
|
|
1474
|
+
},
|
|
1475
|
+
res: {
|
|
1476
|
+
type: "string",
|
|
1477
|
+
description: "Resource id to capture first when serving a storage root."
|
|
1478
|
+
},
|
|
1479
|
+
port: {
|
|
1480
|
+
type: "string",
|
|
1481
|
+
description: "Workbench port. Default 5199."
|
|
1482
|
+
}
|
|
1483
|
+
},
|
|
1484
|
+
async run({ args }) {
|
|
1485
|
+
return executeDev({
|
|
1486
|
+
pluginDir: args.pluginDir ?? "",
|
|
1487
|
+
dataDir: args.data,
|
|
1488
|
+
storageDir: args.storage,
|
|
1489
|
+
resId: args.res,
|
|
1490
|
+
port: args.port === void 0 ? 5199 : parsePort(args.port)
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
}),
|
|
1494
|
+
build: defineCommand({
|
|
1495
|
+
meta: {
|
|
1496
|
+
name: "build",
|
|
1497
|
+
description: "Build a plugin in the current directory (manifest.json + src/ + index.html) into dist/. Pass --watch to rebuild on change."
|
|
1498
|
+
},
|
|
1499
|
+
args: {
|
|
1500
|
+
watch: {
|
|
1501
|
+
type: "boolean",
|
|
1502
|
+
description: "Rebuild on file changes instead of exiting."
|
|
1503
|
+
}
|
|
1504
|
+
},
|
|
1505
|
+
async run({ args }) {
|
|
1506
|
+
return executeBuild({
|
|
1507
|
+
dir: process.cwd(),
|
|
1508
|
+
watch: args.watch === true
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
})
|
|
1512
|
+
}
|
|
1513
|
+
})
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
async function executeRun(opts) {
|
|
1517
|
+
const exitCode = await executeRunInner(opts);
|
|
1518
|
+
process.exitCode = exitCode;
|
|
1519
|
+
return exitCode;
|
|
1520
|
+
}
|
|
1521
|
+
async function executeRunInner(opts) {
|
|
1522
|
+
try {
|
|
1523
|
+
const target = resolvePluginTarget({
|
|
1524
|
+
pluginDir: opts.pluginDir,
|
|
1525
|
+
main: opts.main
|
|
1526
|
+
});
|
|
1527
|
+
await ensureFreshBuild(target);
|
|
1528
|
+
const extractCacheDir = mkdtempSync(join(tmpdir(), "hoard-cli-extract-"));
|
|
1529
|
+
try {
|
|
1530
|
+
const outcome = opts.inProcess ? await runInProcess(
|
|
1531
|
+
target.mainPath,
|
|
1532
|
+
opts.hook,
|
|
1533
|
+
opts.dir,
|
|
1534
|
+
extractCacheDir
|
|
1535
|
+
) : await withSandboxHooks(
|
|
1536
|
+
target,
|
|
1537
|
+
(hooks) => runCliHook({
|
|
1538
|
+
id: target.id,
|
|
1539
|
+
hooks,
|
|
1540
|
+
hook: opts.hook,
|
|
1541
|
+
dir: opts.dir,
|
|
1542
|
+
extractCacheDir
|
|
1543
|
+
})
|
|
1544
|
+
);
|
|
1545
|
+
console.log(JSON.stringify(outcome, null, opts.pretty ? 2 : 0));
|
|
1546
|
+
} finally {
|
|
1547
|
+
rmSync(extractCacheDir, { recursive: true, force: true });
|
|
1548
|
+
}
|
|
1549
|
+
return EXIT_PASS;
|
|
1550
|
+
} catch (err2) {
|
|
1551
|
+
console.error(
|
|
1552
|
+
`[hoardodile] ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
1553
|
+
);
|
|
1554
|
+
return EXIT_ERROR;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
async function executeBench(opts) {
|
|
1558
|
+
const exitCode = await executeBenchInner(opts);
|
|
1559
|
+
process.exitCode = exitCode;
|
|
1560
|
+
return exitCode;
|
|
1561
|
+
}
|
|
1562
|
+
async function executeBenchInner(opts) {
|
|
1563
|
+
try {
|
|
1564
|
+
const target = resolvePluginTarget({
|
|
1565
|
+
pluginDir: opts.pluginDir,
|
|
1566
|
+
main: opts.main
|
|
1567
|
+
});
|
|
1568
|
+
await ensureFreshBuild(target);
|
|
1569
|
+
if (opts.inProcess) {
|
|
1570
|
+
const report = await computeBenchReport({
|
|
1571
|
+
pluginId: target.id,
|
|
1572
|
+
hook: opts.hook,
|
|
1573
|
+
dir: opts.dir,
|
|
1574
|
+
repeat: opts.repeat,
|
|
1575
|
+
warmupRuns: opts.warmupRuns,
|
|
1576
|
+
run: () => runInProcess(target.mainPath, opts.hook, opts.dir)
|
|
1577
|
+
});
|
|
1578
|
+
return finishBench(report, opts);
|
|
1579
|
+
}
|
|
1580
|
+
const sandbox = createPluginSandbox(DEFAULT_SANDBOX_CONFIG);
|
|
1581
|
+
try {
|
|
1582
|
+
const hooks = await createCliHooks(target, sandbox);
|
|
1583
|
+
const report = await computeBenchReport({
|
|
1584
|
+
pluginId: target.id,
|
|
1585
|
+
hook: opts.hook,
|
|
1586
|
+
dir: opts.dir,
|
|
1587
|
+
repeat: opts.repeat,
|
|
1588
|
+
warmupRuns: opts.warmupRuns,
|
|
1589
|
+
run: () => runCliHook({ id: target.id, hooks, hook: opts.hook, dir: opts.dir })
|
|
1590
|
+
});
|
|
1591
|
+
return finishBench(report, opts);
|
|
1592
|
+
} finally {
|
|
1593
|
+
await sandbox.disposeAll();
|
|
1594
|
+
}
|
|
1595
|
+
} catch (err2) {
|
|
1596
|
+
console.error(
|
|
1597
|
+
`[hoardodile] ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
1598
|
+
);
|
|
1599
|
+
return EXIT_ERROR;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
function finishBench(report, opts) {
|
|
1603
|
+
console.log(formatBenchSummary(report));
|
|
1604
|
+
let compare;
|
|
1605
|
+
if (opts.comparePath !== void 0) {
|
|
1606
|
+
const baseline = loadBaseline(opts.comparePath);
|
|
1607
|
+
compare = compareBaseline(report, baseline, opts.thresholdPercent);
|
|
1608
|
+
console.log(compare.message);
|
|
1609
|
+
}
|
|
1610
|
+
if (opts.savePath !== void 0) writeReport(opts.savePath, report);
|
|
1611
|
+
if (opts.jsonPath !== void 0) writeReport(opts.jsonPath, report);
|
|
1612
|
+
return benchExitCode(compare);
|
|
1613
|
+
}
|
|
1614
|
+
async function executeBuild(opts) {
|
|
1615
|
+
try {
|
|
1616
|
+
await buildPlugin(opts.dir, { watch: opts.watch });
|
|
1617
|
+
return EXIT_PASS;
|
|
1618
|
+
} catch (err2) {
|
|
1619
|
+
console.error(
|
|
1620
|
+
`[hoardodile] ${err2 instanceof Error ? err2.message : String(err2)}`
|
|
1621
|
+
);
|
|
1622
|
+
return EXIT_ERROR;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
async function withSandboxHooks(target, fn) {
|
|
1626
|
+
const sandbox = createPluginSandbox(DEFAULT_SANDBOX_CONFIG);
|
|
1627
|
+
try {
|
|
1628
|
+
const hooks = await createCliHooks(target, sandbox);
|
|
1629
|
+
return await fn(hooks);
|
|
1630
|
+
} finally {
|
|
1631
|
+
await sandbox.disposeAll();
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
async function ensureFreshBuild(target) {
|
|
1635
|
+
const distDir = target.dirPath;
|
|
1636
|
+
const pluginDir = dirname(distDir);
|
|
1637
|
+
const srcDir = join(pluginDir, "src");
|
|
1638
|
+
const manifestPath = join(pluginDir, "manifest.json");
|
|
1639
|
+
if (!existsSync(srcDir) || !existsSync(manifestPath)) return;
|
|
1640
|
+
if (!existsSync(join(distDir, "main.js"))) return;
|
|
1641
|
+
const distMtime = newestFileMtime(distDir);
|
|
1642
|
+
const newestSource = Math.max(
|
|
1643
|
+
newestFileMtime(srcDir),
|
|
1644
|
+
statSync(manifestPath).mtimeMs
|
|
1645
|
+
);
|
|
1646
|
+
const REBUILD_TOLERANCE_MS = 2e3;
|
|
1647
|
+
if (distMtime + REBUILD_TOLERANCE_MS <= newestSource) {
|
|
1648
|
+
console.log(`[hoardodile] dist/ is stale \u2014 rebuilding ${pluginDir}`);
|
|
1649
|
+
await buildPlugin(pluginDir, { watch: false });
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
function newestFileMtime(dir) {
|
|
1653
|
+
let newest = 0;
|
|
1654
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1655
|
+
const full = join(dir, entry.name);
|
|
1656
|
+
if (entry.isDirectory()) {
|
|
1657
|
+
newest = Math.max(newest, newestFileMtime(full));
|
|
1658
|
+
} else if (entry.isFile()) {
|
|
1659
|
+
newest = Math.max(newest, statSync(full).mtimeMs);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
return newest;
|
|
1663
|
+
}
|
|
1664
|
+
async function runInProcess(mainPath, hook, dir, extractCacheDir) {
|
|
1665
|
+
const mod = await import(pathToFileURL(mainPath).href);
|
|
1666
|
+
if (typeof mod !== "object" || mod === null || !("default" in mod)) {
|
|
1667
|
+
throw new CliError(
|
|
1668
|
+
`plugin at ${mainPath} must default-export a plugin definition`
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
const def = mod.default;
|
|
1672
|
+
const fn = def[hook];
|
|
1673
|
+
if (typeof fn !== "function") {
|
|
1674
|
+
throw new CliError(`plugin has no ${hook} hook`);
|
|
1675
|
+
}
|
|
1676
|
+
const api = buildCliResourceAPI(dir, { extractCacheDir });
|
|
1677
|
+
const started = performance.now();
|
|
1678
|
+
const result = await fn(api);
|
|
1679
|
+
return ok({ result, durationMs: performance.now() - started });
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
export { main };
|
|
1683
|
+
//# sourceMappingURL=main.js.map
|
|
1684
|
+
//# sourceMappingURL=main.js.map
|