@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.2
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/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -45,28 +45,76 @@ describe("listDirectories", () => {
|
|
|
45
45
|
expect(hidden).toEqual([]);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
// Hermetic, no host-coupling: build a tmpdir with three siblings (one
|
|
49
|
+
// with `.git`, one with `.pi`, one plain) and assert the flag fields
|
|
50
|
+
// on each. Detection is opt-in via `{ detect: true }` per
|
|
51
|
+
// change: split-browse-flags.
|
|
52
|
+
it("should detect isGit flag for git repos when detect=true", async () => {
|
|
53
|
+
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-flags-"));
|
|
54
|
+
try {
|
|
55
|
+
await fsp.mkdir(path.join(tmp, "git-repo"));
|
|
56
|
+
await fsp.mkdir(path.join(tmp, "git-repo", ".git"));
|
|
57
|
+
await fsp.mkdir(path.join(tmp, "plain-dir"));
|
|
58
|
+
|
|
59
|
+
const result = await listDirectories(tmp, undefined, { detect: true });
|
|
60
|
+
|
|
61
|
+
const gitEntry = result.entries.find((e) => e.name === "git-repo");
|
|
62
|
+
const plainEntry = result.entries.find((e) => e.name === "plain-dir");
|
|
63
|
+
expect(gitEntry).toBeDefined();
|
|
64
|
+
expect(plainEntry).toBeDefined();
|
|
65
|
+
expect(gitEntry!.isGit).toBe(true);
|
|
66
|
+
expect(gitEntry!.isPi).toBe(false);
|
|
67
|
+
expect(plainEntry!.isGit).toBe(false);
|
|
68
|
+
expect(plainEntry!.isPi).toBe(false);
|
|
69
|
+
} finally {
|
|
70
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
71
|
+
}
|
|
58
72
|
});
|
|
59
73
|
|
|
60
|
-
it("should detect isPi flag for pi projects", async () => {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
it("should detect isPi flag for pi projects when detect=true", async () => {
|
|
75
|
+
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-flags-"));
|
|
76
|
+
try {
|
|
77
|
+
await fsp.mkdir(path.join(tmp, "pi-project"));
|
|
78
|
+
await fsp.mkdir(path.join(tmp, "pi-project", ".pi"));
|
|
79
|
+
await fsp.mkdir(path.join(tmp, "plain-dir"));
|
|
80
|
+
|
|
81
|
+
const result = await listDirectories(tmp, undefined, { detect: true });
|
|
82
|
+
|
|
83
|
+
const piEntry = result.entries.find((e) => e.name === "pi-project");
|
|
84
|
+
const plainEntry = result.entries.find((e) => e.name === "plain-dir");
|
|
85
|
+
expect(piEntry).toBeDefined();
|
|
86
|
+
expect(plainEntry).toBeDefined();
|
|
87
|
+
expect(piEntry!.isPi).toBe(true);
|
|
88
|
+
expect(piEntry!.isGit).toBe(false);
|
|
89
|
+
expect(plainEntry!.isGit).toBe(false);
|
|
90
|
+
expect(plainEntry!.isPi).toBe(false);
|
|
91
|
+
} finally {
|
|
92
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
64
95
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
96
|
+
it("should omit isGit/isPi when detect is not set (default)", async () => {
|
|
97
|
+
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-no-detect-"));
|
|
98
|
+
try {
|
|
99
|
+
await fsp.mkdir(path.join(tmp, "git-repo"));
|
|
100
|
+
await fsp.mkdir(path.join(tmp, "git-repo", ".git"));
|
|
101
|
+
await fsp.mkdir(path.join(tmp, "pi-project"));
|
|
102
|
+
await fsp.mkdir(path.join(tmp, "pi-project", ".pi"));
|
|
103
|
+
|
|
104
|
+
const result = await listDirectories(tmp);
|
|
105
|
+
|
|
106
|
+
// Both entries surface, but flags are absent from the response shape.
|
|
107
|
+
const gitEntry = result.entries.find((e) => e.name === "git-repo");
|
|
108
|
+
const piEntry = result.entries.find((e) => e.name === "pi-project");
|
|
109
|
+
expect(gitEntry).toBeDefined();
|
|
110
|
+
expect(piEntry).toBeDefined();
|
|
111
|
+
expect(gitEntry!.isGit).toBeUndefined();
|
|
112
|
+
expect(gitEntry!.isPi).toBeUndefined();
|
|
113
|
+
expect(piEntry!.isGit).toBeUndefined();
|
|
114
|
+
expect(piEntry!.isPi).toBeUndefined();
|
|
115
|
+
} finally {
|
|
116
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
117
|
+
}
|
|
70
118
|
});
|
|
71
119
|
|
|
72
120
|
it("should return null parent for root directory", async () => {
|
|
@@ -338,3 +386,231 @@ describe("listDirectories word-boundary ranking", () => {
|
|
|
338
386
|
expect(names).toEqual(["foo-bar", "xx-foo"]);
|
|
339
387
|
});
|
|
340
388
|
});
|
|
389
|
+
|
|
390
|
+
// ─── classifyPaths + parseFlagsQuery (change: split-browse-flags) ────────────
|
|
391
|
+
|
|
392
|
+
import { classifyPaths, parseFlagsQuery, MAX_FLAG_PATHS } from "../browse.js";
|
|
393
|
+
|
|
394
|
+
describe("classifyPaths", () => {
|
|
395
|
+
let tmp: string;
|
|
396
|
+
|
|
397
|
+
beforeEach(async () => {
|
|
398
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "classify-"));
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
afterEach(async () => {
|
|
402
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("returns isGit/isPi for a mix of paths", async () => {
|
|
406
|
+
const gitDir = path.join(tmp, "git-repo");
|
|
407
|
+
const piDir = path.join(tmp, "pi-project");
|
|
408
|
+
const plain = path.join(tmp, "plain");
|
|
409
|
+
await fsp.mkdir(gitDir);
|
|
410
|
+
await fsp.mkdir(path.join(gitDir, ".git"));
|
|
411
|
+
await fsp.mkdir(piDir);
|
|
412
|
+
await fsp.mkdir(path.join(piDir, ".pi"));
|
|
413
|
+
await fsp.mkdir(plain);
|
|
414
|
+
|
|
415
|
+
const flags = await classifyPaths([gitDir, piDir, plain]);
|
|
416
|
+
expect(flags[gitDir]).toEqual({ isGit: true, isPi: false });
|
|
417
|
+
expect(flags[piDir]).toEqual({ isGit: false, isPi: true });
|
|
418
|
+
expect(flags[plain]).toEqual({ isGit: false, isPi: false });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("handles non-existent paths as { isGit: false, isPi: false }", async () => {
|
|
422
|
+
const missing = path.join(tmp, "does-not-exist");
|
|
423
|
+
const flags = await classifyPaths([missing]);
|
|
424
|
+
expect(flags[missing]).toEqual({ isGit: false, isPi: false });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("returns {} for an empty input", async () => {
|
|
428
|
+
const flags = await classifyPaths([]);
|
|
429
|
+
expect(flags).toEqual({});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("preserves the input key set exactly", async () => {
|
|
433
|
+
await fsp.mkdir(path.join(tmp, "a"));
|
|
434
|
+
await fsp.mkdir(path.join(tmp, "b"));
|
|
435
|
+
const inputs = [path.join(tmp, "a"), path.join(tmp, "b"), path.join(tmp, "missing")];
|
|
436
|
+
const flags = await classifyPaths(inputs);
|
|
437
|
+
expect(Object.keys(flags).sort()).toEqual([...inputs].sort());
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("parseFlagsQuery", () => {
|
|
442
|
+
it("rejects undefined", () => {
|
|
443
|
+
expect(parseFlagsQuery(undefined)).toEqual({ ok: false, error: "invalid paths" });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("rejects empty string", () => {
|
|
447
|
+
expect(parseFlagsQuery("")).toEqual({ ok: false, error: "invalid paths" });
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("rejects non-JSON", () => {
|
|
451
|
+
expect(parseFlagsQuery("not-json")).toEqual({ ok: false, error: "invalid paths" });
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("rejects non-array JSON", () => {
|
|
455
|
+
expect(parseFlagsQuery('{"foo": 1}')).toEqual({ ok: false, error: "invalid paths" });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("rejects array with non-string elements", () => {
|
|
459
|
+
expect(parseFlagsQuery('["/a", 42]')).toEqual({ ok: false, error: "invalid paths" });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("rejects over-cap arrays", () => {
|
|
463
|
+
const big = Array.from({ length: MAX_FLAG_PATHS + 1 }, (_, i) => `/p${i}`);
|
|
464
|
+
expect(parseFlagsQuery(JSON.stringify(big))).toEqual({ ok: false, error: "too many paths" });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("accepts a valid array", () => {
|
|
468
|
+
const r = parseFlagsQuery('["/a", "/b/c"]');
|
|
469
|
+
expect(r).toEqual({ ok: true, paths: ["/a", "/b/c"] });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("accepts an empty array (the route short-circuits to empty flags)", () => {
|
|
473
|
+
expect(parseFlagsQuery("[]")).toEqual({ ok: true, paths: [] });
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("accepts exactly MAX_FLAG_PATHS entries", () => {
|
|
477
|
+
const cap = Array.from({ length: MAX_FLAG_PATHS }, (_, i) => `/p${i}`);
|
|
478
|
+
const r = parseFlagsQuery(JSON.stringify(cap));
|
|
479
|
+
expect(r.ok).toBe(true);
|
|
480
|
+
if (r.ok) expect(r.paths.length).toBe(MAX_FLAG_PATHS);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ─── Route integration: GET /api/browse/flags ────────────────────────────────
|
|
485
|
+
|
|
486
|
+
import Fastify from "fastify";
|
|
487
|
+
import type { FastifyInstance } from "fastify";
|
|
488
|
+
import { registerFileRoutes } from "../routes/file-routes.js";
|
|
489
|
+
|
|
490
|
+
describe("GET /api/browse/flags route", () => {
|
|
491
|
+
let app: FastifyInstance;
|
|
492
|
+
let tmp: string;
|
|
493
|
+
|
|
494
|
+
beforeEach(async () => {
|
|
495
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-flags-route-"));
|
|
496
|
+
app = Fastify({ logger: false });
|
|
497
|
+
registerFileRoutes(app, {
|
|
498
|
+
sessionManager: { listAll: () => [] } as any,
|
|
499
|
+
preferencesStore: { getPinnedDirectories: () => [] } as any,
|
|
500
|
+
// Permit-all guard so we exercise the route logic, not the auth gate.
|
|
501
|
+
networkGuard: async () => undefined,
|
|
502
|
+
});
|
|
503
|
+
await app.ready();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
afterEach(async () => {
|
|
507
|
+
await app.close();
|
|
508
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("returns the flag map for valid input", async () => {
|
|
512
|
+
const gitDir = path.join(tmp, "git-repo");
|
|
513
|
+
const piDir = path.join(tmp, "pi-project");
|
|
514
|
+
await fsp.mkdir(gitDir);
|
|
515
|
+
await fsp.mkdir(path.join(gitDir, ".git"));
|
|
516
|
+
await fsp.mkdir(piDir);
|
|
517
|
+
await fsp.mkdir(path.join(piDir, ".pi"));
|
|
518
|
+
|
|
519
|
+
const paths = encodeURIComponent(JSON.stringify([gitDir, piDir]));
|
|
520
|
+
const res = await app.inject({ method: "GET", url: `/api/browse/flags?paths=${paths}` });
|
|
521
|
+
expect(res.statusCode).toBe(200);
|
|
522
|
+
const body = res.json();
|
|
523
|
+
expect(body.success).toBe(true);
|
|
524
|
+
expect(body.data.flags[gitDir]).toEqual({ isGit: true, isPi: false });
|
|
525
|
+
expect(body.data.flags[piDir]).toEqual({ isGit: false, isPi: true });
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("returns 400 with 'invalid paths' on malformed JSON", async () => {
|
|
529
|
+
const res = await app.inject({ method: "GET", url: "/api/browse/flags?paths=not-json" });
|
|
530
|
+
expect(res.statusCode).toBe(400);
|
|
531
|
+
expect(res.json()).toEqual({ success: false, error: "invalid paths" });
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("returns 400 with 'too many paths' when over cap", async () => {
|
|
535
|
+
const big = Array.from({ length: 101 }, (_, i) => `/p${i}`);
|
|
536
|
+
const paths = encodeURIComponent(JSON.stringify(big));
|
|
537
|
+
const res = await app.inject({ method: "GET", url: `/api/browse/flags?paths=${paths}` });
|
|
538
|
+
expect(res.statusCode).toBe(400);
|
|
539
|
+
expect(res.json()).toEqual({ success: false, error: "too many paths" });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("returns 200 with empty flags for an empty array", async () => {
|
|
543
|
+
const res = await app.inject({ method: "GET", url: "/api/browse/flags?paths=%5B%5D" });
|
|
544
|
+
expect(res.statusCode).toBe(200);
|
|
545
|
+
expect(res.json()).toEqual({ success: true, data: { flags: {} } });
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("returns 400 when the paths param is missing entirely", async () => {
|
|
549
|
+
const res = await app.inject({ method: "GET", url: "/api/browse/flags" });
|
|
550
|
+
expect(res.statusCode).toBe(400);
|
|
551
|
+
expect(res.json()).toEqual({ success: false, error: "invalid paths" });
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
describe("GET /api/browse with detect param", () => {
|
|
556
|
+
let app: FastifyInstance;
|
|
557
|
+
let tmp: string;
|
|
558
|
+
|
|
559
|
+
beforeEach(async () => {
|
|
560
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-detect-route-"));
|
|
561
|
+
app = Fastify({ logger: false });
|
|
562
|
+
registerFileRoutes(app, {
|
|
563
|
+
sessionManager: { listAll: () => [] } as any,
|
|
564
|
+
preferencesStore: { getPinnedDirectories: () => [] } as any,
|
|
565
|
+
networkGuard: async () => undefined,
|
|
566
|
+
});
|
|
567
|
+
await app.ready();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
afterEach(async () => {
|
|
571
|
+
await app.close();
|
|
572
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("populates isGit/isPi when detect=1", async () => {
|
|
576
|
+
await fsp.mkdir(path.join(tmp, "git-repo"));
|
|
577
|
+
await fsp.mkdir(path.join(tmp, "git-repo", ".git"));
|
|
578
|
+
const res = await app.inject({
|
|
579
|
+
method: "GET",
|
|
580
|
+
url: `/api/browse?path=${encodeURIComponent(tmp)}&detect=1`,
|
|
581
|
+
});
|
|
582
|
+
expect(res.statusCode).toBe(200);
|
|
583
|
+
const body = res.json();
|
|
584
|
+
const e = body.data.entries.find((x: any) => x.name === "git-repo");
|
|
585
|
+
expect(e).toBeDefined();
|
|
586
|
+
expect(e.isGit).toBe(true);
|
|
587
|
+
expect(e.isPi).toBe(false);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("omits isGit/isPi when detect is absent", async () => {
|
|
591
|
+
await fsp.mkdir(path.join(tmp, "git-repo"));
|
|
592
|
+
await fsp.mkdir(path.join(tmp, "git-repo", ".git"));
|
|
593
|
+
const res = await app.inject({
|
|
594
|
+
method: "GET",
|
|
595
|
+
url: `/api/browse?path=${encodeURIComponent(tmp)}`,
|
|
596
|
+
});
|
|
597
|
+
expect(res.statusCode).toBe(200);
|
|
598
|
+
const body = res.json();
|
|
599
|
+
const e = body.data.entries.find((x: any) => x.name === "git-repo");
|
|
600
|
+
expect(e).toBeDefined();
|
|
601
|
+
expect(e.isGit).toBeUndefined();
|
|
602
|
+
expect(e.isPi).toBeUndefined();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("treats detect=true (non-1) as falsy", async () => {
|
|
606
|
+
await fsp.mkdir(path.join(tmp, "git-repo"));
|
|
607
|
+
await fsp.mkdir(path.join(tmp, "git-repo", ".git"));
|
|
608
|
+
const res = await app.inject({
|
|
609
|
+
method: "GET",
|
|
610
|
+
url: `/api/browse?path=${encodeURIComponent(tmp)}&detect=true`,
|
|
611
|
+
});
|
|
612
|
+
const body = res.json();
|
|
613
|
+
const e = body.data.entries.find((x: any) => x.name === "git-repo");
|
|
614
|
+
expect(e.isGit).toBeUndefined();
|
|
615
|
+
});
|
|
616
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test: the local `registry.rescan(...)` call in
|
|
3
|
+
* `packages/server/src/cli.ts` was removed and ownership of the
|
|
4
|
+
* post-install rescan moved to the centralized
|
|
5
|
+
* `bootstrapState.subscribe` hook in `server.ts`.
|
|
6
|
+
*
|
|
7
|
+
* This test reads `cli.ts` as text and asserts no direct rescan call
|
|
8
|
+
* remains, plus a forwarding comment is present.
|
|
9
|
+
*
|
|
10
|
+
* See change: fix-openspec-buttons-after-bootstrap-install.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const cliPath = path.resolve(here, "..", "cli.ts");
|
|
19
|
+
|
|
20
|
+
describe("cli.ts post-install rescan ownership", () => {
|
|
21
|
+
const source = readFileSync(cliPath, "utf8");
|
|
22
|
+
|
|
23
|
+
it("does not contain a direct registry.rescan(...) call", () => {
|
|
24
|
+
// Allow comments mentioning rescan, but disallow real call expressions.
|
|
25
|
+
// Strip line comments and block comments first.
|
|
26
|
+
const stripped = source
|
|
27
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
28
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
29
|
+
expect(stripped).not.toMatch(/\.rescan\s*\(/);
|
|
30
|
+
expect(stripped).not.toMatch(/\bRescannable\b/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("contains a comment forwarding to the centralized server.ts hook", () => {
|
|
34
|
+
expect(source).toMatch(/fix-openspec-buttons-after-bootstrap-install/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Force-refresh contract tests.
|
|
3
|
+
*
|
|
4
|
+
* Per `fix-openspec-mtime-gate-toctou`:
|
|
5
|
+
* - User-initiated refresh (`refreshOpenSpec`) MUST bypass the change-detection gate.
|
|
6
|
+
* - Periodic poll (`pollDirectoryGated`) MUST honor the gate.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { createDirectoryService, type DirectoryService } from "../directory-service.js";
|
|
13
|
+
import type { PreferencesStore } from "../preferences-store.js";
|
|
14
|
+
import type { SessionManager } from "../memory-session-manager.js";
|
|
15
|
+
|
|
16
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js")>();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
pollOpenSpecAsync: vi.fn(async () => ({ initialized: false, changes: [] })),
|
|
21
|
+
runOpenSpecList: vi.fn(async () => null),
|
|
22
|
+
runOpenSpecStatus: vi.fn(async () => null),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
vi.mock("../pi-resource-scanner.js", () => ({
|
|
27
|
+
scanPiResources: vi.fn(async () => ({ local: { extensions: [], skills: [], prompts: [] }, global: { extensions: [], skills: [], prompts: [] }, packages: [] })),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/state-replay.js", () => ({
|
|
31
|
+
replayEntriesAsEvents: vi.fn(() => []),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("../session-discovery.js", () => ({
|
|
35
|
+
discoverSessionsForCwd: vi.fn(() => []),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("../session-file-reader.js", () => ({
|
|
39
|
+
loadSessionEntries: vi.fn(() => []),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
43
|
+
SessionManager: {
|
|
44
|
+
list: vi.fn(async () => []),
|
|
45
|
+
open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
function createMockPreferencesStore(): PreferencesStore {
|
|
50
|
+
return {
|
|
51
|
+
getPinnedDirectories: () => [],
|
|
52
|
+
getSessionOrder: () => ({}),
|
|
53
|
+
setSessionOrder: vi.fn(),
|
|
54
|
+
setPinnedDirectories: vi.fn(),
|
|
55
|
+
pinDirectory: vi.fn(),
|
|
56
|
+
unpinDirectory: vi.fn(),
|
|
57
|
+
reorderPinnedDirs: vi.fn(),
|
|
58
|
+
flush: vi.fn(),
|
|
59
|
+
dispose: vi.fn(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createMockSessionManager(): SessionManager {
|
|
64
|
+
return {
|
|
65
|
+
register: vi.fn(),
|
|
66
|
+
restore: vi.fn(),
|
|
67
|
+
unregister: vi.fn(),
|
|
68
|
+
update: vi.fn(),
|
|
69
|
+
get: () => undefined,
|
|
70
|
+
listActive: () => [],
|
|
71
|
+
listAll: () => [],
|
|
72
|
+
} as unknown as SessionManager;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("DirectoryService refresh-vs-gated contracts (fix-openspec-mtime-gate-toctou)", () => {
|
|
76
|
+
let tmpDir: string;
|
|
77
|
+
let cwd: string;
|
|
78
|
+
let changesDir: string;
|
|
79
|
+
let service: DirectoryService;
|
|
80
|
+
|
|
81
|
+
beforeEach(async () => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ds-refresh-"));
|
|
84
|
+
cwd = tmpDir;
|
|
85
|
+
changesDir = path.join(cwd, "openspec", "changes");
|
|
86
|
+
fs.mkdirSync(path.join(changesDir, "change-a"), { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(changesDir, "change-a", "tasks.md"), "- [ ] 1.1 a\n");
|
|
88
|
+
|
|
89
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
90
|
+
(runOpenSpecList as any).mockResolvedValue({ changes: [
|
|
91
|
+
{ name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 1 },
|
|
92
|
+
] });
|
|
93
|
+
(runOpenSpecStatus as any).mockResolvedValue({
|
|
94
|
+
artifacts: [
|
|
95
|
+
{ id: "proposal", status: "done" },
|
|
96
|
+
{ id: "design", status: "done" },
|
|
97
|
+
{ id: "specs", status: "done" },
|
|
98
|
+
{ id: "tasks", status: "done" },
|
|
99
|
+
],
|
|
100
|
+
isComplete: true,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
service?.stopPolling();
|
|
106
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("refreshOpenSpec re-spawns the CLI even when no file mtime changed", async () => {
|
|
110
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
111
|
+
service = createDirectoryService(createMockPreferencesStore(), createMockSessionManager());
|
|
112
|
+
|
|
113
|
+
// Seed the cache.
|
|
114
|
+
await service.pollDirectoryGated(cwd);
|
|
115
|
+
(runOpenSpecList as any).mockClear();
|
|
116
|
+
(runOpenSpecStatus as any).mockClear();
|
|
117
|
+
|
|
118
|
+
// No file changed. User clicks refresh.
|
|
119
|
+
await service.refreshOpenSpec(cwd);
|
|
120
|
+
|
|
121
|
+
// Force-mode → both list and status spawn.
|
|
122
|
+
expect(runOpenSpecList).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(runOpenSpecStatus).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect((runOpenSpecStatus as any).mock.calls[0][1]).toBe("change-a");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("onDirectoryAdded uses pollDirectoryGated, not the force-mode refreshOpenSpec (S1)", async () => {
|
|
128
|
+
// After fix-openspec-mtime-gate-toctou, refreshOpenSpec bypasses the
|
|
129
|
+
// gate (force=true). The internal `onDirectoryAdded` path must continue
|
|
130
|
+
// to use the gated variant so re-pinning a directory whose cache is
|
|
131
|
+
// already warm doesn't fan out into O(N) status spawns.
|
|
132
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
133
|
+
service = createDirectoryService(createMockPreferencesStore(), createMockSessionManager());
|
|
134
|
+
|
|
135
|
+
// Seed the cache.
|
|
136
|
+
await service.pollDirectoryGated(cwd);
|
|
137
|
+
(runOpenSpecList as any).mockClear();
|
|
138
|
+
(runOpenSpecStatus as any).mockClear();
|
|
139
|
+
|
|
140
|
+
// Re-pin (simulated): no file mtime moved since the seed.
|
|
141
|
+
await service.onDirectoryAdded(cwd);
|
|
142
|
+
|
|
143
|
+
// Force path would have spawned 1 list + 1 status. Gated path does
|
|
144
|
+
// neither because the file-aware effective mtime is unchanged.
|
|
145
|
+
expect(runOpenSpecList).not.toHaveBeenCalled();
|
|
146
|
+
expect(runOpenSpecStatus).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("pollDirectoryGated does NOT spawn the CLI when no file mtime changed (gate respected)", async () => {
|
|
150
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
|
|
151
|
+
service = createDirectoryService(createMockPreferencesStore(), createMockSessionManager());
|
|
152
|
+
|
|
153
|
+
// Seed the cache.
|
|
154
|
+
await service.pollDirectoryGated(cwd);
|
|
155
|
+
(runOpenSpecList as any).mockClear();
|
|
156
|
+
(runOpenSpecStatus as any).mockClear();
|
|
157
|
+
|
|
158
|
+
// Periodic tick — no file change.
|
|
159
|
+
await service.pollDirectoryGated(cwd);
|
|
160
|
+
expect(runOpenSpecList).not.toHaveBeenCalled();
|
|
161
|
+
expect(runOpenSpecStatus).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|