@hachej/boring-ui-plugin-cli 0.1.31 → 0.1.33

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.
@@ -0,0 +1,1096 @@
1
+ // src/server/index.ts
2
+ import { join as join4, resolve as resolve4 } from "path";
3
+
4
+ // src/server/createPlugin.ts
5
+ import {
6
+ cpSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ renameSync,
12
+ statSync,
13
+ writeFileSync
14
+ } from "fs";
15
+ import { dirname, join, relative, resolve } from "path";
16
+ import { fileURLToPath } from "url";
17
+ var KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
18
+ function findRepoRoot(from) {
19
+ let current = from;
20
+ while (true) {
21
+ if (existsSync(join(current, "pnpm-workspace.yaml"))) return current;
22
+ const parent = dirname(current);
23
+ if (parent === current) return null;
24
+ current = parent;
25
+ }
26
+ }
27
+ function walkDir(dir, base, out = []) {
28
+ for (const entry of readdirSync(dir)) {
29
+ if (entry === "node_modules" || entry === ".git" || entry === "dist") continue;
30
+ const fullPath = join(dir, entry);
31
+ const stat = statSync(fullPath);
32
+ if (stat.isDirectory()) {
33
+ walkDir(fullPath, base, out);
34
+ continue;
35
+ }
36
+ out.push(relative(base, fullPath));
37
+ }
38
+ return out;
39
+ }
40
+ function replaceInFile(filePath, replacements) {
41
+ let content = readFileSync(filePath, "utf8");
42
+ for (const [from, to] of Object.entries(replacements)) {
43
+ content = content.replaceAll(from, to);
44
+ }
45
+ writeFileSync(filePath, content, "utf8");
46
+ }
47
+ function packageRoot() {
48
+ let current = dirname(fileURLToPath(import.meta.url));
49
+ while (true) {
50
+ const pkgPath = join(current, "package.json");
51
+ if (existsSync(pkgPath)) {
52
+ try {
53
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
54
+ if (pkg.name === "@hachej/boring-ui-plugin-cli") return current;
55
+ } catch {
56
+ }
57
+ }
58
+ const parent = dirname(current);
59
+ if (parent === current) break;
60
+ current = parent;
61
+ }
62
+ throw new Error("Could not locate @hachej/boring-ui-plugin-cli package root");
63
+ }
64
+ function createPlugin(options) {
65
+ const cwd = options.cwd ?? process.cwd();
66
+ const name = options.name;
67
+ const templateDir = join(packageRoot(), "templates", "plugin");
68
+ if (!existsSync(templateDir)) {
69
+ throw new Error(
70
+ `Plugin template not found at ${templateDir}.
71
+ This build may not include the plugin template.`
72
+ );
73
+ }
74
+ if (!KEBAB_RE.test(name)) {
75
+ throw new Error(
76
+ `invalid plugin name "${name}" \u2014 must be kebab-case (e.g. "my-plugin")`
77
+ );
78
+ }
79
+ const repoRoot = findRepoRoot(cwd);
80
+ const targetParent = options.path ? resolve(cwd, options.path) : join(repoRoot ?? cwd, "plugins");
81
+ const targetDir = join(targetParent, name);
82
+ if (existsSync(targetDir)) throw new Error(`Directory already exists: ${targetDir}`);
83
+ const id = name;
84
+ const symbolBase = id.replace(/-plugin$/, "") || id;
85
+ const pascalBase = symbolBase.split(/[-_]+/).map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1)).join("");
86
+ const camelBase = pascalBase.charAt(0).toLowerCase() + pascalBase.slice(1);
87
+ const upperBase = symbolBase.replace(/-/g, "_").toUpperCase();
88
+ const packageName = `@hachej/boring-${id}`;
89
+ mkdirSync(targetParent, { recursive: true });
90
+ cpSync(templateDir, targetDir, { recursive: true });
91
+ const files = walkDir(targetDir, targetDir);
92
+ for (const file of files) {
93
+ const fullPath = join(targetDir, file);
94
+ replaceInFile(fullPath, {
95
+ "@hachej/boring-plugin-template": packageName,
96
+ "sample-plugin": id,
97
+ "sample-panel": `${id}-panel`,
98
+ "sample.open": `${id}.open`,
99
+ "sample:": `${id}:`,
100
+ '"sample"': `"${id}"`,
101
+ SAMPLE: upperBase,
102
+ Sample: pascalBase,
103
+ sampleSurfaceResolver: `${camelBase}SurfaceResolver`,
104
+ samplePanel: `${camelBase}Panel`
105
+ });
106
+ if (file.includes("samplePlugin")) {
107
+ const newFile = file.replace(/samplePlugin/g, `${camelBase}Plugin`);
108
+ const oldPath = join(targetDir, file);
109
+ const newPath = join(targetDir, newFile);
110
+ if (oldPath !== newPath) {
111
+ mkdirSync(dirname(newPath), { recursive: true });
112
+ renameSync(oldPath, newPath);
113
+ }
114
+ }
115
+ }
116
+ return {
117
+ id,
118
+ pluginDir: targetDir,
119
+ packageName,
120
+ filesCreated: walkDir(targetDir, targetDir)
121
+ };
122
+ }
123
+
124
+ // src/server/scaffoldPlugin.ts
125
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
126
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
127
+ import { fileURLToPath as fileURLToPath2 } from "url";
128
+ var KEBAB_RE2 = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
129
+ function scaffoldPlugin(opts) {
130
+ if (!KEBAB_RE2.test(opts.name)) {
131
+ throw new Error(
132
+ `invalid plugin name "${opts.name}" \u2014 must be kebab-case (e.g. "my-plugin")`
133
+ );
134
+ }
135
+ const workspaceRoot = resolve2(opts.workspaceRoot);
136
+ const pluginDir = join2(workspaceRoot, ".pi", "extensions", opts.name);
137
+ mkdirSync2(join2(workspaceRoot, ".pi", "extensions"), { recursive: true });
138
+ try {
139
+ mkdirSync2(pluginDir, { recursive: false });
140
+ } catch (error) {
141
+ const code = error.code;
142
+ if (code === "EEXIST") {
143
+ throw new Error(`plugin already exists at ${pluginDir}`);
144
+ }
145
+ throw error;
146
+ }
147
+ const templatesDir = opts.templatesDir ?? resolveBundledTemplatesDir();
148
+ if (!templatesDir) {
149
+ throw new Error(
150
+ "could not locate bundled plugin templates \u2014 pass `templatesDir` explicitly. (this usually indicates a broken @hachej/boring-ui-plugin-cli install)"
151
+ );
152
+ }
153
+ const tplFront = join2(templatesDir, "front-canonical.tsx");
154
+ const tplPackage = join2(templatesDir, "package-canonical.json");
155
+ for (const tpl of [tplFront, tplPackage]) {
156
+ if (!existsSync2(tpl)) {
157
+ throw new Error(`canonical template missing: ${tpl}`);
158
+ }
159
+ }
160
+ const label = labelFromName(opts.name);
161
+ const filesCreated = [];
162
+ const write = (relPath, contents) => {
163
+ const target = join2(pluginDir, relPath);
164
+ mkdirSync2(join2(target, ".."), { recursive: true });
165
+ writeFileSync2(target, contents, "utf8");
166
+ filesCreated.push(target);
167
+ };
168
+ const pkgRaw = JSON.parse(readFileSync2(tplPackage, "utf8"));
169
+ delete pkgRaw._doc_;
170
+ const pkgJson = substitute(JSON.stringify(pkgRaw, null, 2), opts.name, label);
171
+ write("package.json", `${pkgJson}
172
+ `);
173
+ const frontSource = substitute(readFileSync2(tplFront, "utf8"), opts.name, label);
174
+ write("front/index.tsx", frontSource);
175
+ write(".gitignore", "# Machine-managed sidecars written by the boring-ui plugin runtime.\n.boring-signature.json\n");
176
+ return { pluginDir, filesCreated };
177
+ }
178
+ function labelFromName(name) {
179
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
180
+ }
181
+ function substitute(source, name, label) {
182
+ const paneName = `${pascalCase(name)}Pane`;
183
+ return source.replaceAll("<kebab-name>", name).replaceAll("&lt;kebab-name&gt;", name).replaceAll("<Label>", label).replaceAll(/\bMyPane\b/g, paneName);
184
+ }
185
+ function pascalCase(name) {
186
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
187
+ }
188
+ function resolveBundledTemplatesDir() {
189
+ const here = dirname2(fileURLToPath2(import.meta.url));
190
+ const candidates = [
191
+ resolve2(here, "..", "templates"),
192
+ resolve2(here, "..", "..", "templates")
193
+ ];
194
+ return candidates.find((candidate) => existsSync2(candidate));
195
+ }
196
+
197
+ // src/server/verifyPlugin.ts
198
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync, statSync as statSync2 } from "fs";
199
+ import { builtinModules, createRequire } from "module";
200
+ import { extname, isAbsolute, join as join3, relative as relative2, resolve as resolve3 } from "path";
201
+
202
+ // src/manifest.ts
203
+ var SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
204
+ var PLUGIN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
205
+ function isValidBoringPluginId(id) {
206
+ return typeof id === "string" && id.length > 0 && PLUGIN_ID_RE.test(id);
207
+ }
208
+ function isSafePluginRelativePath(value) {
209
+ return typeof value === "string" && value.length > 0 && value !== "." && !value.includes("\0") && !value.includes("\\") && !value.startsWith("/") && !value.startsWith("//") && !/^[A-Za-z]:[\\/]/.test(value) && !value.split("/").includes("..");
210
+ }
211
+ function issue(code, field, message) {
212
+ return { code, field, message };
213
+ }
214
+ function isRecord(value) {
215
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
216
+ }
217
+ function validateStringArray(issues, value, field, pathLike) {
218
+ if (value === void 0) return;
219
+ if (!Array.isArray(value)) {
220
+ issues.push(issue("INVALID_FIELD", field, `${field} must be an array`));
221
+ return;
222
+ }
223
+ value.forEach((entry, index) => {
224
+ const itemField = `${field}[${index}]`;
225
+ if (typeof entry !== "string" || entry.length === 0) {
226
+ issues.push(issue("INVALID_FIELD", itemField, `${itemField} must be a non-empty string`));
227
+ return;
228
+ }
229
+ if (pathLike && !isSafePluginRelativePath(entry)) {
230
+ issues.push(issue("INVALID_PATH", itemField, `${itemField} must be a safe relative path`));
231
+ }
232
+ });
233
+ }
234
+ var REMOVED_BORING_UI_FIELDS = ["outputs", "panels", "commands", "leftTabs", "surfaceResolvers", "providers", "bindings", "catalogs"];
235
+ function validateBoringField(issues, boring) {
236
+ if (boring === void 0) return void 0;
237
+ if (!isRecord(boring)) {
238
+ issues.push(issue("INVALID_FIELD", "boring", "boring must be an object when provided"));
239
+ return void 0;
240
+ }
241
+ for (const field of REMOVED_BORING_UI_FIELDS) {
242
+ if (boring[field] !== void 0) {
243
+ issues.push(issue(
244
+ "INVALID_FIELD",
245
+ `boring.${field}`,
246
+ `boring.${field} is not supported; declare front contributions in boring.front via definePlugin({ ... })`
247
+ ));
248
+ }
249
+ }
250
+ if (boring.id !== void 0 && (typeof boring.id !== "string" || !isValidBoringPluginId(boring.id))) {
251
+ issues.push(issue("INVALID_ID", "boring.id", "boring.id must start with a letter or number and use only letters, numbers, dot, underscore, colon, or dash"));
252
+ }
253
+ const front = boring.front;
254
+ if (front !== void 0 && (typeof front !== "string" || !isSafePluginRelativePath(front))) {
255
+ issues.push(issue("INVALID_PATH", "boring.front", "boring.front must be a safe relative path"));
256
+ }
257
+ const server = boring.server;
258
+ if (server !== void 0 && server !== false && (typeof server !== "string" || !isSafePluginRelativePath(server))) {
259
+ issues.push(issue("INVALID_PATH", "boring.server", "boring.server must be a safe relative path or false"));
260
+ }
261
+ if (boring.label !== void 0 && typeof boring.label !== "string") {
262
+ issues.push(issue("INVALID_FIELD", "boring.label", "boring.label must be a string when provided"));
263
+ }
264
+ return {
265
+ ...typeof boring.id === "string" ? { id: boring.id } : {},
266
+ ...typeof boring.front === "string" ? { front: boring.front } : {},
267
+ ...typeof boring.server === "string" || boring.server === false ? { server: boring.server } : {},
268
+ ...typeof boring.label === "string" ? { label: boring.label } : {}
269
+ };
270
+ }
271
+ var REMOTE_PI_PACKAGE_PREFIXES = ["npm:", "git:", "github:", "http:", "https:", "ssh:"];
272
+ function isRemotePiPackageSource(value) {
273
+ return REMOTE_PI_PACKAGE_PREFIXES.some((prefix) => value.startsWith(prefix));
274
+ }
275
+ function isSafePiPackageSource(value) {
276
+ if (value.length === 0) return false;
277
+ if (isRemotePiPackageSource(value)) return true;
278
+ const path = value.startsWith("file:") ? value.slice("file:".length) : value;
279
+ if (path === "." || path === "./") return true;
280
+ const normalized = path.startsWith("./") ? path.slice(2) : path;
281
+ return isSafePluginRelativePath(normalized);
282
+ }
283
+ function validatePiPackages(issues, value) {
284
+ if (value === void 0) return;
285
+ if (!Array.isArray(value)) {
286
+ issues.push(issue("INVALID_FIELD", "pi.packages", "pi.packages must be an array when provided"));
287
+ return;
288
+ }
289
+ value.forEach((entry, index) => {
290
+ const field = `pi.packages[${index}]`;
291
+ if (typeof entry === "string") {
292
+ if (!isSafePiPackageSource(entry)) {
293
+ issues.push(issue("INVALID_PATH", field, `${field} must be a safe package source`));
294
+ }
295
+ return;
296
+ }
297
+ if (!isRecord(entry)) {
298
+ issues.push(issue("INVALID_FIELD", field, `${field} must be a string or package source object`));
299
+ return;
300
+ }
301
+ if (typeof entry.source !== "string" || entry.source.length === 0) {
302
+ issues.push(issue("INVALID_FIELD", `${field}.source`, `${field}.source must be a non-empty string`));
303
+ } else if (!isSafePiPackageSource(entry.source)) {
304
+ issues.push(issue("INVALID_PATH", `${field}.source`, `${field}.source must be a safe package source`));
305
+ }
306
+ });
307
+ }
308
+ function validatePiField(issues, pi) {
309
+ if (pi === void 0) return void 0;
310
+ if (!isRecord(pi)) {
311
+ issues.push(issue("INVALID_FIELD", "pi", "pi must be an object when provided"));
312
+ return void 0;
313
+ }
314
+ validateStringArray(issues, pi.extensions, "pi.extensions", true);
315
+ validateStringArray(issues, pi.skills, "pi.skills", true);
316
+ validatePiPackages(issues, pi.packages);
317
+ if (pi.systemPrompt !== void 0 && typeof pi.systemPrompt !== "string") {
318
+ issues.push(issue("INVALID_FIELD", "pi.systemPrompt", "pi.systemPrompt must be a string when provided"));
319
+ }
320
+ return pi;
321
+ }
322
+ function validateBoringPluginManifest(raw) {
323
+ const issues = [];
324
+ if (!isRecord(raw)) {
325
+ return {
326
+ valid: false,
327
+ issues: [issue("INVALID_FIELD", "<root>", "package.json manifest must be an object")]
328
+ };
329
+ }
330
+ if (raw.name !== void 0 && typeof raw.name !== "string") {
331
+ issues.push(issue("INVALID_FIELD", "name", "name must be a string when provided"));
332
+ }
333
+ if (raw.version !== void 0 && typeof raw.version !== "string") {
334
+ issues.push(issue("INVALID_VERSION", "version", "version must be a string when provided"));
335
+ } else if (typeof raw.version === "string" && raw.version.length > 0 && !SEMVER_RE.test(raw.version)) {
336
+ issues.push(issue("INVALID_VERSION", "version", "version must be a valid semver string"));
337
+ }
338
+ const boring = validateBoringField(issues, raw.boring);
339
+ const pi = validatePiField(issues, raw.pi);
340
+ if (!boring && !pi) {
341
+ issues.push(issue("MISSING_REQUIRED_FIELD", "boring|pi", "package.json must include boring and/or pi plugin metadata"));
342
+ }
343
+ if (issues.length > 0) return { valid: false, issues };
344
+ return {
345
+ valid: true,
346
+ packageJson: {
347
+ ...typeof raw.name === "string" ? { name: raw.name } : {},
348
+ ...typeof raw.version === "string" ? { version: raw.version } : {},
349
+ ...boring ? { boring } : {},
350
+ ...pi ? { pi } : {}
351
+ }
352
+ };
353
+ }
354
+
355
+ // src/server/verifyPlugin.ts
356
+ function pluginFileSignature(path) {
357
+ if (!path || !existsSync3(path)) return "missing";
358
+ const stat = statSync2(path);
359
+ return `${stat.mtimeMs}:${stat.size}`;
360
+ }
361
+ function readPluginSignatureCache(pluginRootDir) {
362
+ const path = join3(pluginRootDir, ".boring-signature.json");
363
+ if (!existsSync3(path)) return null;
364
+ let parsed;
365
+ try {
366
+ parsed = JSON.parse(readFileSync3(path, "utf8"));
367
+ } catch {
368
+ return null;
369
+ }
370
+ if (!parsed || typeof parsed !== "object") return null;
371
+ const obj = parsed;
372
+ if (obj.version !== 1) return null;
373
+ const sig = obj.serverSignature;
374
+ if (sig !== null && typeof sig !== "string") return null;
375
+ const loadedAt = typeof obj.loadedAt === "number" ? obj.loadedAt : 0;
376
+ return { version: 1, serverSignature: sig, loadedAt };
377
+ }
378
+ var HOST_PROVIDED_IMPORTS = /* @__PURE__ */ new Set([
379
+ "react",
380
+ "react-dom",
381
+ "react-dom/client",
382
+ "react/jsx-runtime",
383
+ "react/jsx-dev-runtime",
384
+ "@hachej/boring-workspace",
385
+ "@hachej/boring-workspace/plugin",
386
+ "@hachej/boring-workspace/events",
387
+ "@hachej/boring-ui-kit"
388
+ ]);
389
+ var HOST_PROVIDED_DEPENDENCIES = /* @__PURE__ */ new Set([
390
+ "react",
391
+ "react-dom",
392
+ "@hachej/boring-workspace",
393
+ "@hachej/boring-ui-kit"
394
+ ]);
395
+ var FRONT_SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css"]);
396
+ var NODE_BUILTIN_IMPORTS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]));
397
+ function verifyPlugin(opts) {
398
+ const workspaceRoot = resolve3(opts.workspaceRoot);
399
+ const extensionsDir = join3(workspaceRoot, ".pi", "extensions");
400
+ if (!existsSync3(extensionsDir)) {
401
+ return {
402
+ outcomes: [],
403
+ ok: true,
404
+ extensionsDir,
405
+ extensionsDirMissing: true
406
+ };
407
+ }
408
+ const targets = [];
409
+ if (opts.name) {
410
+ const dir = join3(extensionsDir, opts.name);
411
+ if (!existsSync3(dir)) {
412
+ return {
413
+ outcomes: [
414
+ { id: opts.name, dir, ok: false, errors: [`plugin directory not found: ${dir}`], warnings: [] }
415
+ ],
416
+ ok: false,
417
+ extensionsDir,
418
+ extensionsDirMissing: false
419
+ };
420
+ }
421
+ targets.push(dir);
422
+ } else {
423
+ for (const entry of readdirSync2(extensionsDir, { withFileTypes: true })) {
424
+ if (!entry.isDirectory()) continue;
425
+ if (entry.name.startsWith(".") || entry.name.startsWith("preflight-")) continue;
426
+ targets.push(join3(extensionsDir, entry.name));
427
+ }
428
+ }
429
+ const outcomes = targets.map((dir) => verifySinglePlugin(dir));
430
+ return {
431
+ outcomes,
432
+ ok: outcomes.every((o) => o.ok),
433
+ extensionsDir,
434
+ extensionsDirMissing: false
435
+ };
436
+ }
437
+ function isInsideRoot(rootReal, targetReal) {
438
+ const rel = relative2(rootReal, targetReal);
439
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
440
+ }
441
+ function resolveExistingContainedPath(pluginDir, value, field) {
442
+ if (!isSafePluginRelativePath(value)) {
443
+ return { path: null, error: `${field} must be a safe relative path inside the plugin root: ${JSON.stringify(value)}` };
444
+ }
445
+ const abs = resolve3(pluginDir, value);
446
+ if (!existsSync3(abs)) return { path: null, error: `${field} points at "${value}" but that file does not exist (looked at ${abs})` };
447
+ const rootReal = realpathSync(pluginDir);
448
+ const targetReal = realpathSync(abs);
449
+ if (!isInsideRoot(rootReal, targetReal)) {
450
+ return { path: null, error: `${field} points at "${value}" but resolves outside the plugin root (looked at ${abs})` };
451
+ }
452
+ return { path: targetReal };
453
+ }
454
+ function isObject(value) {
455
+ return typeof value === "object" && value !== null && !Array.isArray(value);
456
+ }
457
+ function packageNameFromSpecifier(specifier) {
458
+ const parts = specifier.split("/");
459
+ return specifier.startsWith("@") && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
460
+ }
461
+ function dependencyDir(pluginDir, packageName) {
462
+ return packageName.startsWith("@") ? join3(pluginDir, "node_modules", ...packageName.split("/")) : join3(pluginDir, "node_modules", packageName);
463
+ }
464
+ function readDependencySection(pkg, section) {
465
+ const value = pkg[section];
466
+ if (!isObject(value)) return [];
467
+ return Object.keys(value);
468
+ }
469
+ function validateDependencyManifest(pluginDir, pkg, errors) {
470
+ for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
471
+ for (const dep of readDependencySection(pkg, section)) {
472
+ if (HOST_PROVIDED_DEPENDENCIES.has(dep)) {
473
+ errors.push(`${section}.${dep}: do not list host-provided package "${dep}" as a plugin dependency; import it from the host runtime instead.`);
474
+ }
475
+ }
476
+ }
477
+ for (const dep of readDependencySection(pkg, "dependencies")) {
478
+ if (HOST_PROVIDED_DEPENDENCIES.has(dep)) continue;
479
+ if (!existsSync3(dependencyDir(pluginDir, dep))) {
480
+ errors.push(`dependency "${dep}" is declared but not installed. Run: cd ${pluginDir} && npm install`);
481
+ }
482
+ }
483
+ }
484
+ function isBareImport(specifier) {
485
+ return !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("file://");
486
+ }
487
+ function isUnsafeFrontSpecifier(specifier) {
488
+ return specifier.startsWith("/@fs/") || specifier.startsWith("//") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(specifier) || isAbsolute(specifier);
489
+ }
490
+ function stripCommentsForImportScan(source) {
491
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
492
+ }
493
+ function collectImportSpecifiers(source, path) {
494
+ const specs = [];
495
+ const extension = extname(path).toLowerCase();
496
+ if (extension === ".css") {
497
+ const cssImportPattern = /^\s*@import\s+(?:url\(\s*(?:["']([^"']+)["']|([^\s)"']+))\s*\)|["']([^"']+)["'])/gm;
498
+ const cssUrlPattern = /\burl\(\s*(?:["']([^"']+)["']|([^\s)"']+))\s*\)/gm;
499
+ let match;
500
+ while ((match = cssImportPattern.exec(source)) !== null) specs.push(match[1] ?? match[2] ?? match[3] ?? "");
501
+ while ((match = cssUrlPattern.exec(source)) !== null) specs.push(match[1] ?? match[2] ?? "");
502
+ return specs.filter(Boolean);
503
+ }
504
+ const code = stripCommentsForImportScan(source);
505
+ const patterns = [
506
+ /\bimport\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']/g,
507
+ /\bexport\s+[^"']*?\s+from\s+["']([^"']+)["']/g,
508
+ /\bimport\(\s*["']([^"']+)["']\s*\)/g
509
+ ];
510
+ for (const pattern of patterns) {
511
+ let match;
512
+ while ((match = pattern.exec(code)) !== null) specs.push(match[1]);
513
+ }
514
+ return specs;
515
+ }
516
+ function visitSourceFiles(root, check) {
517
+ if (!existsSync3(root)) return;
518
+ for (const entry of readdirSync2(root, { withFileTypes: true })) {
519
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
520
+ const path = join3(root, entry.name);
521
+ if (entry.isDirectory()) {
522
+ visitSourceFiles(path, check);
523
+ } else if (entry.isFile() && FRONT_SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
524
+ check(path);
525
+ }
526
+ }
527
+ }
528
+ function validateFrontImports(pluginDir, errors) {
529
+ const req = createRequire(join3(pluginDir, "package.json"));
530
+ const nodeModulesReal = existsSync3(join3(pluginDir, "node_modules")) ? realpathSync(join3(pluginDir, "node_modules")) : null;
531
+ const check = (path) => {
532
+ const source = readFileSync3(path, "utf8");
533
+ for (const specifier of collectImportSpecifiers(source, path)) {
534
+ if (specifier.startsWith("node:") || NODE_BUILTIN_IMPORTS.has(specifier)) {
535
+ errors.push(`${path}: front import "${specifier}" is not browser-safe; Node built-ins are not available in runtime plugin fronts.`);
536
+ continue;
537
+ }
538
+ if (isUnsafeFrontSpecifier(specifier)) {
539
+ errors.push(`${path}: front import "${specifier}" bypasses the host runtime URL space.`);
540
+ continue;
541
+ }
542
+ if (!isBareImport(specifier)) continue;
543
+ const packageName = packageNameFromSpecifier(specifier);
544
+ if (HOST_PROVIDED_IMPORTS.has(specifier)) continue;
545
+ if (HOST_PROVIDED_DEPENDENCIES.has(packageName)) {
546
+ errors.push(`${path}: front import "${specifier}" targets an unsupported host-provided package subpath; use documented front imports only.`);
547
+ continue;
548
+ }
549
+ try {
550
+ const resolved = req.resolve(specifier);
551
+ if (!nodeModulesReal || !isInsideRoot(nodeModulesReal, realpathSync(resolved))) {
552
+ errors.push(`${path}: front import "${specifier}" must resolve from this plugin's node_modules. Run: cd ${pluginDir} && npm install ${packageName}`);
553
+ }
554
+ } catch {
555
+ errors.push(`${path}: missing dependency for front import "${specifier}". Run: cd ${pluginDir} && npm install ${packageName}`);
556
+ }
557
+ }
558
+ };
559
+ visitSourceFiles(join3(pluginDir, "front"), check);
560
+ visitSourceFiles(join3(pluginDir, "shared"), check);
561
+ }
562
+ function verifySinglePlugin(pluginDir) {
563
+ const id = pluginDir.split(/[\\/]/).pop() ?? "<unknown>";
564
+ const errors = [];
565
+ const warnings = [];
566
+ const pkgPath = join3(pluginDir, "package.json");
567
+ if (!existsSync3(pkgPath)) {
568
+ return { id, dir: pluginDir, ok: false, errors: ["package.json missing"], warnings };
569
+ }
570
+ let parsed;
571
+ try {
572
+ parsed = JSON.parse(readFileSync3(pkgPath, "utf8"));
573
+ } catch (error) {
574
+ const message = error instanceof Error ? error.message : String(error);
575
+ return { id, dir: pluginDir, ok: false, errors: [`package.json is not valid JSON: ${message}`], warnings };
576
+ }
577
+ if (isObject(parsed)) validateDependencyManifest(pluginDir, parsed, errors);
578
+ const result = validateBoringPluginManifest(parsed);
579
+ if (!result.valid) {
580
+ for (const issue2 of result.issues) errors.push(formatIssue(issue2));
581
+ return { id, dir: pluginDir, ok: false, errors, warnings };
582
+ }
583
+ const manifest = result.packageJson;
584
+ const boring = manifest.boring;
585
+ if (boring?.front) {
586
+ const resolved = resolveExistingContainedPath(pluginDir, boring.front, "boring.front");
587
+ if (resolved.error) errors.push(resolved.error);
588
+ }
589
+ let serverPathAbs = null;
590
+ if (typeof boring?.server === "string") {
591
+ const resolved = resolveExistingContainedPath(pluginDir, boring.server, "boring.server");
592
+ if (resolved.error) {
593
+ errors.push(resolved.error);
594
+ } else {
595
+ serverPathAbs = resolved.path;
596
+ warnings.push(
597
+ "boring.server file is valid, but workspace server entries are boot-time/static composition only. /reload does not import or register .pi/extensions server routes/agentTools; restart the workspace with this package passed via defaultPluginPackages or explicit plugins to activate server code."
598
+ );
599
+ }
600
+ }
601
+ const cache = readPluginSignatureCache(pluginDir);
602
+ if (cache) {
603
+ const currentServerSig = serverPathAbs ? pluginFileSignature(serverPathAbs) : null;
604
+ const cachedSig = cache.serverSignature;
605
+ if (cachedSig !== currentServerSig) {
606
+ warnings.push(
607
+ "server file changed since the workspace last loaded this plugin \u2014 /reload will hot-swap the front, but routes and agent tools stay on the previously loaded code. Stop and restart the workspace process (Ctrl-C, then re-run your dev command) to pick up the new code."
608
+ );
609
+ }
610
+ }
611
+ const piExt = manifest.pi?.extensions;
612
+ if (Array.isArray(piExt)) {
613
+ for (const ext of piExt) {
614
+ if (typeof ext !== "string") continue;
615
+ const resolved = resolveExistingContainedPath(pluginDir, ext, "pi.extensions entry");
616
+ if (resolved.error) errors.push(resolved.error);
617
+ }
618
+ }
619
+ validateFrontImports(pluginDir, errors);
620
+ const piSkills = manifest.pi?.skills;
621
+ if (Array.isArray(piSkills)) {
622
+ for (const skill of piSkills) {
623
+ if (typeof skill !== "string") continue;
624
+ const resolved = resolveExistingContainedPath(pluginDir, skill, "pi.skills entry");
625
+ if (resolved.error) errors.push(resolved.error);
626
+ }
627
+ }
628
+ return { id, dir: pluginDir, ok: errors.length === 0, errors, warnings };
629
+ }
630
+ function formatIssue(issue2) {
631
+ return `${issue2.code}: ${issue2.field}: ${issue2.message}`;
632
+ }
633
+ function formatVerifyResult(result) {
634
+ if (result.extensionsDirMissing) {
635
+ return [
636
+ `WARNING \u2014 no plugins to verify. Scanned: ${result.extensionsDir} (directory does not exist).`,
637
+ "",
638
+ "If you just wrote a plugin: check that you put it under THIS workspace's `.pi/extensions/` and not a different cwd. The scanned path above is `<cwd>/.pi/extensions/`."
639
+ ].join("\n");
640
+ }
641
+ if (result.outcomes.length === 0) {
642
+ return [
643
+ `WARNING \u2014 scanned ${result.extensionsDir} but found NO plugin directories.`,
644
+ "",
645
+ "If you just wrote a plugin: it is NOT in this dir. Either you wrote to a different `.pi/extensions/` (check your cwd), or the dir name starts with `.` / `preflight-` (skipped)."
646
+ ].join("\n");
647
+ }
648
+ const lines = [];
649
+ const failures = result.outcomes.filter((o) => !o.ok);
650
+ const withWarnings = result.outcomes.filter((o) => o.warnings.length > 0);
651
+ if (failures.length === 0) {
652
+ lines.push(`OK \u2014 ${result.outcomes.length} plugin(s) have valid manifests + present files. (scanned ${result.extensionsDir})`);
653
+ for (const outcome of result.outcomes) {
654
+ const tag = outcome.warnings.length > 0 ? "\u26A0" : "\u2713";
655
+ lines.push(` ${tag} ${outcome.id}`);
656
+ for (const w of outcome.warnings) {
657
+ lines.push(` WARN: ${w}`);
658
+ }
659
+ }
660
+ lines.push("");
661
+ if (withWarnings.length > 0) {
662
+ const n = withWarnings.length;
663
+ lines.push(`Manifests are valid, but ${n} plugin${n === 1 ? "" : "s"} need a workspace restart (NOT just /reload) to pick up server-side changes \u2014 see WARN lines above.`);
664
+ lines.push("");
665
+ }
666
+ lines.push("Note: this validator does NOT execute plugin code. Front/Pi syntax / type / runtime errors surface on /reload; boring.server files require static composition plus a process restart.");
667
+ } else {
668
+ lines.push(`FAILED \u2014 ${failures.length} of ${result.outcomes.length} plugin(s) have errors. (scanned ${result.extensionsDir})`);
669
+ lines.push("");
670
+ for (const outcome of result.outcomes) {
671
+ if (outcome.ok) {
672
+ const tag = outcome.warnings.length > 0 ? "\u26A0" : "\u2713";
673
+ lines.push(` ${tag} ${outcome.id}`);
674
+ for (const w of outcome.warnings) {
675
+ lines.push(` WARN: ${w}`);
676
+ }
677
+ } else {
678
+ lines.push(` \u2717 ${outcome.id} (${outcome.dir})`);
679
+ for (const err of outcome.errors) {
680
+ lines.push(` ${err}`);
681
+ }
682
+ for (const w of outcome.warnings) {
683
+ lines.push(` WARN: ${w}`);
684
+ }
685
+ }
686
+ }
687
+ lines.push("");
688
+ lines.push("Fix the errors above and run `boring-ui-plugin verify` again. Once it reports OK, ask the user to run /reload for front/Pi assets; boring.server changes require static composition plus restart.");
689
+ }
690
+ return lines.join("\n");
691
+ }
692
+ var COMMON_MISTAKE_HINTS = [
693
+ {
694
+ pattern: /boring\.server must be a safe relative path or false/i,
695
+ hint: 'Set `boring.server` to a string path like "server/index.ts", or to `false` (or omit the field). It is NOT a boolean true. For hot-reloadable agent behavior in .pi/extensions, prefer pi.extensions instead.'
696
+ },
697
+ {
698
+ pattern: /package\.json manifest must be an object/i,
699
+ hint: "package.json must parse as a JSON object \u2014 check for trailing commas or unquoted keys."
700
+ }
701
+ ];
702
+ function findHintForError(message) {
703
+ for (const m of COMMON_MISTAKE_HINTS) {
704
+ if (m.pattern.test(message)) return m.hint;
705
+ }
706
+ return void 0;
707
+ }
708
+
709
+ // src/server/index.ts
710
+ function defaultWorkspaceRoot() {
711
+ return process.env.BORING_AGENT_WORKSPACE_ROOT ?? process.cwd();
712
+ }
713
+ function workspaceLocalPluginRootsEnabled() {
714
+ const raw = process.env.BORING_AGENT_WORKSPACE_LOCAL_PLUGIN_ROOTS;
715
+ if (raw == null || raw.trim() === "") return true;
716
+ return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
717
+ }
718
+ function buildPluginStatus(workspaceRoot = defaultWorkspaceRoot()) {
719
+ const resolvedRoot = resolve4(workspaceRoot);
720
+ const enabled = workspaceLocalPluginRootsEnabled();
721
+ return {
722
+ workspaceLocalPluginRoots: enabled,
723
+ workspaceRoot: resolvedRoot,
724
+ extensionsDir: join4(resolvedRoot, ".pi", "extensions"),
725
+ reloadSupported: enabled,
726
+ ...enabled ? {} : {
727
+ reason: "This runtime writes to a remote sandbox; host-side plugin discovery cannot load .pi/extensions from there."
728
+ }
729
+ };
730
+ }
731
+ function parseVerifyArgs(positionals, workspaceRoot = defaultWorkspaceRoot()) {
732
+ const maybeName = positionals[0];
733
+ const maybeWorkspace = positionals[1];
734
+ const looksLikePath = maybeName && (maybeName.includes("/") || maybeName.startsWith("."));
735
+ const name = looksLikePath ? void 0 : maybeName;
736
+ return {
737
+ ...name ? { name } : {},
738
+ workspaceRoot: resolve4(maybeWorkspace ?? (looksLikePath ? maybeName : workspaceRoot))
739
+ };
740
+ }
741
+ function parseScaffoldArgs(positionals, workspaceRoot = defaultWorkspaceRoot()) {
742
+ const name = positionals[0];
743
+ if (!name) throw new Error("usage: boring-ui-plugin scaffold <name> [workspace]");
744
+ return { name, workspaceRoot: resolve4(positionals[1] ?? workspaceRoot) };
745
+ }
746
+ function parseCreateArgs(positionals) {
747
+ const name = positionals[0];
748
+ if (!name) throw new Error("usage: boring-ui-plugin create <name> [--path <dir>]");
749
+ return { name };
750
+ }
751
+
752
+ // src/server/testPlugin.ts
753
+ var DEFAULT_TIMEOUT_MS = 1e4;
754
+ var POLL_MS = 500;
755
+ function inferSelfTestUrl(explicitUrl, env = process.env) {
756
+ const envUrl = env.BORING_UI_SELF_TEST_URL ?? env.BORING_UI_URL ?? env.BORING_WORKSPACE_URL;
757
+ const portUrl = env.PORT ? `http://127.0.0.1:${env.PORT}` : void 0;
758
+ return explicitUrl?.trim() || envUrl?.trim() || portUrl || "http://127.0.0.1:5200";
759
+ }
760
+ function inferSelfTestWorkspaceId(explicitWorkspaceId, env = process.env) {
761
+ return explicitWorkspaceId?.trim() || env.BORING_UI_WORKSPACE_ID?.trim() || env.BORING_WORKSPACE_ID?.trim() || env.BORING_AGENT_WORKSPACE_ID?.trim() || void 0;
762
+ }
763
+ function buildPanelId(pluginId, panelId) {
764
+ return panelId?.trim() || `${pluginId}.panel`;
765
+ }
766
+ function buildPanelInstanceId(pluginId, panelId) {
767
+ return `self-test:${pluginId}:${panelId}`;
768
+ }
769
+ function normalizeBaseUrl(rawUrl) {
770
+ const url = new URL(rawUrl);
771
+ url.hash = "";
772
+ return url.toString().replace(/\/+$/, "");
773
+ }
774
+ function apiUrl(baseUrl, path) {
775
+ return `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
776
+ }
777
+ function workspaceHeaders(workspaceId) {
778
+ return workspaceId ? { "x-boring-workspace-id": workspaceId } : {};
779
+ }
780
+ function truncateMessage(message) {
781
+ return message.replace(/\s+/g, " ").trim().slice(0, 1e3);
782
+ }
783
+ function event(code, message) {
784
+ return { code, message: truncateMessage(message) };
785
+ }
786
+ async function readJson(response) {
787
+ const text = await response.text();
788
+ if (!text.trim()) return void 0;
789
+ try {
790
+ return JSON.parse(text);
791
+ } catch {
792
+ return text;
793
+ }
794
+ }
795
+ async function fetchJson(url, init) {
796
+ const response = await fetch(url, init);
797
+ return { status: response.status, body: await readJson(response) };
798
+ }
799
+ function isObject2(value) {
800
+ return typeof value === "object" && value !== null;
801
+ }
802
+ function collectReloadDiagnostics(pluginId, body) {
803
+ if (!isObject2(body) || !Array.isArray(body.diagnostics)) return [];
804
+ const events = [];
805
+ for (const diagnostic of body.diagnostics) {
806
+ if (!isObject2(diagnostic)) continue;
807
+ const diagnosticPlugin = typeof diagnostic.pluginId === "string" ? diagnostic.pluginId : void 0;
808
+ if (diagnosticPlugin && diagnosticPlugin !== pluginId) continue;
809
+ const message = typeof diagnostic.message === "string" ? diagnostic.message : "reload diagnostic";
810
+ events.push(event("RELOAD_DIAGNOSTIC", message));
811
+ }
812
+ return events;
813
+ }
814
+ function collectRuntimeDiagnostics(pluginId, body) {
815
+ const events = [];
816
+ let revision;
817
+ if (!isObject2(body) || !Array.isArray(body.plugins)) return { events };
818
+ const plugin = body.plugins.find((entry) => isObject2(entry) && entry.id === pluginId);
819
+ if (!plugin) return { events };
820
+ if (typeof plugin.serverLoadedRevision === "number") revision = plugin.serverLoadedRevision;
821
+ if (typeof plugin.serverError === "string") events.push(event("PLUGIN_SERVER_ERROR", plugin.serverError));
822
+ if (isObject2(plugin.host)) {
823
+ if (typeof plugin.host.revision === "number") revision = plugin.host.revision;
824
+ const code = typeof plugin.host.lastErrorCode === "string" ? plugin.host.lastErrorCode : "PLUGIN_RUNTIME_HOST_ERROR";
825
+ const message = typeof plugin.host.lastErrorMessage === "string" ? plugin.host.lastErrorMessage : typeof plugin.host.lastErrorStage === "string" ? plugin.host.lastErrorStage : void 0;
826
+ if (message) events.push(event(code, message));
827
+ }
828
+ return { events, revision };
829
+ }
830
+ async function inferWorkspaceIdFromMeta(baseUrl) {
831
+ try {
832
+ const meta = await fetchJson(apiUrl(baseUrl, "/api/v1/workspace/meta"));
833
+ if (meta.status < 200 || meta.status >= 300 || !isObject2(meta.body)) return void 0;
834
+ return typeof meta.body.workspaceId === "string" ? meta.body.workspaceId : void 0;
835
+ } catch {
836
+ return void 0;
837
+ }
838
+ }
839
+ async function maybeReadPluginError(baseUrl, pluginId, headers) {
840
+ try {
841
+ const response = await fetch(apiUrl(baseUrl, `/api/v1/agent-plugins/${encodeURIComponent(pluginId)}/error`), { headers });
842
+ if (response.status === 404) return [];
843
+ if (!response.ok) return [];
844
+ const text = await response.text();
845
+ return text.trim() ? [event("PLUGIN_LOAD_ERROR", text)] : [];
846
+ } catch {
847
+ return [];
848
+ }
849
+ }
850
+ async function pollPaneStatus(args) {
851
+ const url = new URL(apiUrl(args.baseUrl, "/api/v1/ui/panels/status"));
852
+ url.searchParams.set("panelInstanceId", args.panelInstanceId);
853
+ url.searchParams.set("pluginId", args.pluginId);
854
+ url.searchParams.set("panelId", args.panelId);
855
+ if (args.workspaceId) url.searchParams.set("workspaceId", args.workspaceId);
856
+ const result = await fetchJson(url.toString(), { headers: args.headers });
857
+ if (result.status < 200 || result.status >= 300) return { state: "missing" };
858
+ const body = isObject2(result.body) ? result.body : {};
859
+ const status = isObject2(body.status) ? body.status : void 0;
860
+ const reportedAt = typeof status?.reportedAt === "string" ? Date.parse(status.reportedAt) : void 0;
861
+ if (reportedAt !== void 0 && Number.isFinite(reportedAt) && reportedAt < args.minReportedAtMs) {
862
+ return { state: "missing" };
863
+ }
864
+ const state = typeof body.state === "string" ? body.state : "missing";
865
+ return { state, status };
866
+ }
867
+ async function openPanel(args) {
868
+ try {
869
+ const result = await fetchJson(apiUrl(args.baseUrl, "/api/v1/ui/commands"), {
870
+ method: "POST",
871
+ headers: { "content-type": "application/json", ...args.headers },
872
+ body: JSON.stringify({
873
+ kind: "openPanel",
874
+ params: {
875
+ id: args.panelInstanceId,
876
+ component: args.panelId,
877
+ title: `Self-test: ${args.pluginId}`
878
+ }
879
+ })
880
+ });
881
+ if (result.status < 200 || result.status >= 300) {
882
+ return [event("OPEN_PANEL_HTTP_ERROR", `/api/v1/ui/commands returned ${result.status}`)];
883
+ }
884
+ } catch (error) {
885
+ return [event("OPEN_PANEL_FAILED", error instanceof Error ? error.message : String(error))];
886
+ }
887
+ return [];
888
+ }
889
+ async function runPluginSelfTest(options) {
890
+ const pluginId = options.pluginId.trim();
891
+ if (!pluginId) throw new Error("plugin id is required");
892
+ const baseUrl = normalizeBaseUrl(inferSelfTestUrl(options.url));
893
+ const workspaceId = inferSelfTestWorkspaceId(options.workspaceId) ?? await inferWorkspaceIdFromMeta(baseUrl);
894
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
895
+ const panelId = buildPanelId(pluginId, options.panelId);
896
+ const panelInstanceId = buildPanelInstanceId(pluginId, panelId);
897
+ const headers = workspaceHeaders(workspaceId);
898
+ const reloadErrors = [];
899
+ let revision;
900
+ try {
901
+ const reload = await fetchJson(apiUrl(baseUrl, "/api/v1/agent/reload"), {
902
+ method: "POST",
903
+ headers: { "content-type": "application/json", ...headers },
904
+ body: JSON.stringify({ ...workspaceId ? { sessionId: workspaceId } : {} })
905
+ });
906
+ if (reload.status < 200 || reload.status >= 300) reloadErrors.push(event("RELOAD_HTTP_ERROR", `/api/v1/agent/reload returned ${reload.status}`));
907
+ reloadErrors.push(...collectReloadDiagnostics(pluginId, reload.body));
908
+ } catch (error) {
909
+ reloadErrors.push(event("RELOAD_FAILED", error instanceof Error ? error.message : String(error)));
910
+ }
911
+ try {
912
+ const diagnostics = await fetchJson(apiUrl(baseUrl, "/api/v1/runtime-plugin-diagnostics"), { headers });
913
+ if (diagnostics.status >= 200 && diagnostics.status < 300) {
914
+ const normalized = collectRuntimeDiagnostics(pluginId, diagnostics.body);
915
+ reloadErrors.push(...normalized.events);
916
+ revision = normalized.revision;
917
+ } else if (diagnostics.status !== 404) {
918
+ reloadErrors.push(event("DIAGNOSTICS_HTTP_ERROR", `/api/v1/runtime-plugin-diagnostics returned ${diagnostics.status}`));
919
+ }
920
+ } catch {
921
+ }
922
+ reloadErrors.push(...await maybeReadPluginError(baseUrl, pluginId, headers));
923
+ const start = Date.now();
924
+ let lastOpenAt = 0;
925
+ let lastState = "missing";
926
+ let lastStatus;
927
+ let openErrors = [];
928
+ while (Date.now() - start <= timeoutMs) {
929
+ const status = await pollPaneStatus({ baseUrl, headers, workspaceId, pluginId, panelId, panelInstanceId, minReportedAtMs: start });
930
+ lastState = status.state;
931
+ lastStatus = status.status;
932
+ if (status.state === "no-browser-connected") {
933
+ return buildResult({ pluginId, workspaceId, revision, reloadErrors, panelId, panelInstanceId, state: "no-browser-connected", status: lastStatus });
934
+ }
935
+ if (status.state === "ready" || status.state === "error") break;
936
+ if (Date.now() - lastOpenAt >= POLL_MS) {
937
+ openErrors = await openPanel({ baseUrl, headers, pluginId, panelId, panelInstanceId });
938
+ lastOpenAt = Date.now();
939
+ }
940
+ await new Promise((resolve5) => setTimeout(resolve5, POLL_MS));
941
+ }
942
+ if (openErrors.length > 0) reloadErrors.push(...openErrors);
943
+ const finalState = lastState === "ready" || lastState === "error" ? lastState : "timeout";
944
+ return buildResult({ pluginId, workspaceId, revision, reloadErrors, panelId, panelInstanceId, state: finalState, status: lastStatus });
945
+ }
946
+ function buildResult(args) {
947
+ const error = isObject2(args.status?.error) ? event(typeof args.status.error.code === "string" ? args.status.error.code : "PANE_ERROR", typeof args.status.error.message === "string" ? args.status.error.message : "pane error") : void 0;
948
+ const lastReportedAt = typeof args.status?.reportedAt === "string" ? args.status.reportedAt : void 0;
949
+ const revision = typeof args.status?.revision === "number" ? args.status.revision : args.revision;
950
+ return {
951
+ ok: args.reloadErrors.length === 0 && args.state === "ready",
952
+ ...args.workspaceId ? { workspaceId: args.workspaceId } : {},
953
+ pluginId: args.pluginId,
954
+ ...revision !== void 0 ? { revision } : {},
955
+ reloadErrors: args.reloadErrors,
956
+ pane: {
957
+ found: args.state === "ready" || args.state === "error",
958
+ state: args.state,
959
+ panelId: args.panelId,
960
+ panelInstanceId: args.panelInstanceId,
961
+ ...error ? { error } : {},
962
+ ...lastReportedAt ? { lastReportedAt } : {}
963
+ }
964
+ };
965
+ }
966
+ function formatSelfTestResult(result) {
967
+ const lines = [
968
+ result.ok ? `OK ${result.pluginId}` : `FAIL ${result.pluginId}`,
969
+ ` pane ${result.pane.state}${result.pane.found ? "" : " (not found)"}`
970
+ ];
971
+ if (result.revision !== void 0) lines.push(` revision ${result.revision}`);
972
+ for (const item of result.reloadErrors) lines.push(` reload ${item.code}: ${item.message}`);
973
+ if (result.pane.error) lines.push(` pane ${result.pane.error.code}: ${result.pane.error.message}`);
974
+ if (result.pane.state === "no-browser-connected") {
975
+ lines.push(` hint open the workspace UI, then rerun boring-ui-plugin test ${result.pluginId}`);
976
+ }
977
+ return lines.join("\n");
978
+ }
979
+
980
+ // src/index.ts
981
+ function pluginCommandUsage() {
982
+ return [
983
+ "usage: boring-ui-plugin <command>",
984
+ "",
985
+ "Commands:",
986
+ " boring-ui-plugin status [--json]",
987
+ " boring-ui-plugin create <name> [--path <dir>]",
988
+ " boring-ui-plugin scaffold <name> [workspace]",
989
+ " boring-ui-plugin verify [name] [workspace]",
990
+ " boring-ui-plugin test <name> [--url <url>] [--workspace <id>] [--panel-id <id>] [--timeout-ms <ms>] [--json]"
991
+ ].join("\n");
992
+ }
993
+ function handleStatus(json) {
994
+ const status = buildPluginStatus();
995
+ if (json) {
996
+ console.log(JSON.stringify(status, null, 2));
997
+ return;
998
+ }
999
+ console.log(status.workspaceLocalPluginRoots ? `workspace-local plugin roots enabled: ${status.extensionsDir}` : `workspace-local plugin roots disabled: ${status.reason}`);
1000
+ }
1001
+ function handleCreate(argv, positionals) {
1002
+ const args = parseCreateArgs(positionals);
1003
+ const result = createPlugin({
1004
+ name: args.name,
1005
+ ...readOption(argv, "--path") ? { path: readOption(argv, "--path") } : {}
1006
+ });
1007
+ console.log(`created ${result.id}`);
1008
+ console.log(` dir ${result.pluginDir}`);
1009
+ console.log("");
1010
+ console.log("Next steps:");
1011
+ console.log(` 1. cd ${result.pluginDir}`);
1012
+ console.log(" 2. pnpm install");
1013
+ console.log(` 3. pnpm --filter ${result.packageName} typecheck`);
1014
+ console.log(` 4. pnpm --filter ${result.packageName} test`);
1015
+ }
1016
+ function handleScaffold(positionals) {
1017
+ const args = parseScaffoldArgs(positionals);
1018
+ const status = buildPluginStatus(args.workspaceRoot);
1019
+ if (!workspaceLocalPluginRootsEnabled()) {
1020
+ throw new Error(`${status.reason} Do not scaffold into .pi/extensions in this runtime.`);
1021
+ }
1022
+ const result = scaffoldPlugin(args);
1023
+ console.log(`scaffolded ${args.name}`);
1024
+ console.log(` dir ${result.pluginDir}`);
1025
+ for (const file of result.filesCreated) console.log(` + ${file}`);
1026
+ console.log("");
1027
+ console.log("Next steps:");
1028
+ console.log(" 1. edit front/index.tsx for UI panels/commands/resolvers");
1029
+ console.log(" 2. add pi.extensions / skills for hot-reloadable agent behavior");
1030
+ console.log(" 3. bash `boring-ui-plugin verify` \u2014 confirms manifests + files are valid");
1031
+ console.log(" 4. if the UI is open, bash `boring-ui-plugin test <name>` \u2014 catches panel render failures");
1032
+ console.log(" 5. ask the user: /reload");
1033
+ }
1034
+ function handleVerify(positionals) {
1035
+ const result = verifyPlugin(parseVerifyArgs(positionals));
1036
+ console.log(formatVerifyResult(result));
1037
+ if (result.ok) return;
1038
+ const hints = [];
1039
+ for (const outcome of result.outcomes) {
1040
+ for (const err of outcome.errors) {
1041
+ const hint = findHintForError(err);
1042
+ if (hint) hints.push(` hint (${outcome.id}): ${hint}`);
1043
+ }
1044
+ }
1045
+ if (hints.length > 0) {
1046
+ console.log("");
1047
+ console.log("Suggestions:");
1048
+ for (const hint of hints) console.log(hint);
1049
+ }
1050
+ process.exit(1);
1051
+ }
1052
+ function readOption(argv, name) {
1053
+ const index = argv.indexOf(name);
1054
+ if (index === -1) return void 0;
1055
+ return argv[index + 1];
1056
+ }
1057
+ async function handleTest(argv, positionals, json) {
1058
+ const name = positionals[0];
1059
+ if (!name) throw new Error("usage: boring-ui-plugin test <name> [--url <local-server-url>] [--workspace <id>] [--panel-id <id>] [--timeout-ms <ms>] [--json]");
1060
+ const timeoutRaw = readOption(argv, "--timeout-ms");
1061
+ const timeoutMs = timeoutRaw ? Number(timeoutRaw) : void 0;
1062
+ if (timeoutRaw && (timeoutMs == null || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) throw new Error("--timeout-ms must be a positive number");
1063
+ const result = await runPluginSelfTest({
1064
+ pluginId: name,
1065
+ ...readOption(argv, "--url") ? { url: readOption(argv, "--url") } : {},
1066
+ ...readOption(argv, "--workspace") ? { workspaceId: readOption(argv, "--workspace") } : {},
1067
+ ...readOption(argv, "--panel-id") ? { panelId: readOption(argv, "--panel-id") } : {},
1068
+ ...timeoutMs == null ? {} : { timeoutMs }
1069
+ });
1070
+ console.log(json ? JSON.stringify(result, null, 2) : formatSelfTestResult(result));
1071
+ if (!result.ok) process.exit(1);
1072
+ }
1073
+ async function runBoringUiPluginCli(argv = process.argv.slice(2)) {
1074
+ const positionals = argv.filter((arg) => !arg.startsWith("-"));
1075
+ const json = argv.includes("--json");
1076
+ const command = positionals[0];
1077
+ const rest = positionals.slice(1);
1078
+ if (command === "status") return handleStatus(json);
1079
+ if (command === "create") return handleCreate(argv, rest);
1080
+ if (command === "scaffold") return handleScaffold(rest);
1081
+ if (command === "verify") return handleVerify(rest);
1082
+ if (command === "test") return await handleTest(argv, rest, json);
1083
+ console.log(pluginCommandUsage());
1084
+ }
1085
+
1086
+ export {
1087
+ createPlugin,
1088
+ scaffoldPlugin,
1089
+ verifyPlugin,
1090
+ formatVerifyResult,
1091
+ findHintForError,
1092
+ runPluginSelfTest,
1093
+ formatSelfTestResult,
1094
+ pluginCommandUsage,
1095
+ runBoringUiPluginCli
1096
+ };