@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mergeInstallableList, readInstallableList, writeInstallableList } from "../installable-list.js";
|
|
3
|
+
import type { InstallableList } from "../installable-list.js";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
// ── mergeInstallableList tests ──────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
describe("mergeInstallableList", () => {
|
|
11
|
+
it("keep-user-pin: user version wins when it differs from bundled", () => {
|
|
12
|
+
const existing: InstallableList = {
|
|
13
|
+
version: "1",
|
|
14
|
+
packages: [{ name: "tsx", version: "^4.0.0", required: true, kind: "npm" }],
|
|
15
|
+
};
|
|
16
|
+
const bundled: InstallableList = {
|
|
17
|
+
version: "2",
|
|
18
|
+
packages: [{ name: "tsx", version: "^5.0.0", required: true, kind: "npm" }],
|
|
19
|
+
};
|
|
20
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
21
|
+
const found = list.packages.find((p) => p.name === "tsx");
|
|
22
|
+
expect(found?.version).toBe("^4.0.0");
|
|
23
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
24
|
+
expect(warnings[0]).toContain("tsx");
|
|
25
|
+
expect(warnings[0]).toContain("^4.0.0");
|
|
26
|
+
expect(warnings[0]).toContain("^5.0.0");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("drop-pin-warn: package in existing but not in bundled gets deprecated=true + warning", () => {
|
|
30
|
+
const existing: InstallableList = {
|
|
31
|
+
version: "1",
|
|
32
|
+
packages: [{ name: "old-tool", version: "^1.0.0", required: false, kind: "npm" }],
|
|
33
|
+
};
|
|
34
|
+
const bundled: InstallableList = { version: "2", packages: [] };
|
|
35
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
36
|
+
const found = list.packages.find((p) => p.name === "old-tool");
|
|
37
|
+
expect(found?.deprecated).toBe(true);
|
|
38
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
39
|
+
expect(warnings[0]).toContain("old-tool");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("add-new-required: new required package from bundled is added as-is", () => {
|
|
43
|
+
const existing: InstallableList = { version: "1", packages: [] };
|
|
44
|
+
const bundled: InstallableList = {
|
|
45
|
+
version: "2",
|
|
46
|
+
packages: [{ name: "pi", version: "*", required: true, kind: "npm" }],
|
|
47
|
+
};
|
|
48
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
49
|
+
const found = list.packages.find((p) => p.name === "pi");
|
|
50
|
+
expect(found).toBeDefined();
|
|
51
|
+
expect(found?.required).toBe(true);
|
|
52
|
+
expect(found?.defaultOff).toBeFalsy();
|
|
53
|
+
expect(warnings).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("add-new-optional: new optional package from bundled is added with defaultOff=true", () => {
|
|
57
|
+
const existing: InstallableList = { version: "1", packages: [] };
|
|
58
|
+
const bundled: InstallableList = {
|
|
59
|
+
version: "2",
|
|
60
|
+
packages: [{ name: "openspec", version: "*", required: false, kind: "npm" }],
|
|
61
|
+
};
|
|
62
|
+
const { list, warnings } = mergeInstallableList(existing, bundled);
|
|
63
|
+
const found = list.packages.find((p) => p.name === "openspec");
|
|
64
|
+
expect(found).toBeDefined();
|
|
65
|
+
expect(found?.defaultOff).toBe(true);
|
|
66
|
+
expect(warnings).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("version marker in result comes from bundled", () => {
|
|
70
|
+
const existing: InstallableList = { version: "1", packages: [] };
|
|
71
|
+
const bundled: InstallableList = { version: "42", packages: [] };
|
|
72
|
+
const { list } = mergeInstallableList(existing, bundled);
|
|
73
|
+
expect(list.version).toBe("42");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── readInstallableList tests ───────────────────────────────────────────────
|
|
78
|
+
// Use a real temp directory (HOME is already ephemeral in the test runner).
|
|
79
|
+
|
|
80
|
+
describe("readInstallableList", () => {
|
|
81
|
+
let tmpDir: string;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "installable-test-"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null when file is absent", async () => {
|
|
93
|
+
const result = await readInstallableList(tmpDir);
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drops entries with invalid kind and warns", async () => {
|
|
98
|
+
const list: InstallableList = {
|
|
99
|
+
version: "1",
|
|
100
|
+
packages: [
|
|
101
|
+
{ name: "good-pkg", version: "*", required: true, kind: "npm" },
|
|
102
|
+
{ name: "bad-pkg", version: "*", required: true, kind: "unknown-kind" as any },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
// Write via writeInstallableList (bypasses the drop-invalid-kind guard).
|
|
106
|
+
const filePath = path.join(tmpDir, "installable.json");
|
|
107
|
+
fs.writeFileSync(filePath, JSON.stringify(list), "utf-8");
|
|
108
|
+
|
|
109
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
110
|
+
const result = await readInstallableList(tmpDir);
|
|
111
|
+
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(result!.packages.map((p) => p.name)).toEqual(["good-pkg"]);
|
|
114
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
115
|
+
expect(warnSpy.mock.calls[0]![0]).toContain("bad-pkg");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("reads a valid file and returns the list", async () => {
|
|
119
|
+
const list: InstallableList = {
|
|
120
|
+
version: "3",
|
|
121
|
+
packages: [{ name: "tsx", version: "^5.0.0", required: true, kind: "npm" }],
|
|
122
|
+
};
|
|
123
|
+
await writeInstallableList(list, tmpDir);
|
|
124
|
+
const result = await readInstallableList(tmpDir);
|
|
125
|
+
expect(result).not.toBeNull();
|
|
126
|
+
expect(result!.version).toBe("3");
|
|
127
|
+
expect(result!.packages).toHaveLength(1);
|
|
128
|
+
expect(result!.packages[0]!.name).toBe("tsx");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `prependManagedNodeToPath` and friends.
|
|
3
|
+
*
|
|
4
|
+
* Real tmp HOME (per setup-home tripwire) so `getManagedDir()` resolves
|
|
5
|
+
* under the tmp tree. We create / remove the managed Node binary on
|
|
6
|
+
* disk to flip the present/absent branches.
|
|
7
|
+
*
|
|
8
|
+
* See change: embed-managed-node-runtime (task 2.5).
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
getManagedNodeBinDir,
|
|
16
|
+
getManagedNodeBinary,
|
|
17
|
+
isManagedNodePresent,
|
|
18
|
+
prependManagedNodeToPath,
|
|
19
|
+
} from "../platform/managed-node-path.js";
|
|
20
|
+
import { getManagedDir } from "../managed-paths.js";
|
|
21
|
+
|
|
22
|
+
const isWin = process.platform === "win32";
|
|
23
|
+
|
|
24
|
+
describe("getManagedNodeBinDir", () => {
|
|
25
|
+
it("returns <managedDir>/node on Windows", () => {
|
|
26
|
+
const env = { homedir: "/fake/home" };
|
|
27
|
+
expect(getManagedNodeBinDir(env, "win32")).toBe(
|
|
28
|
+
path.join("/fake/home", ".pi-dashboard", "node"),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns <managedDir>/node/bin on Unix", () => {
|
|
33
|
+
const env = { homedir: "/fake/home" };
|
|
34
|
+
expect(getManagedNodeBinDir(env, "linux")).toBe(
|
|
35
|
+
path.join("/fake/home", ".pi-dashboard", "node", "bin"),
|
|
36
|
+
);
|
|
37
|
+
expect(getManagedNodeBinDir(env, "darwin")).toBe(
|
|
38
|
+
path.join("/fake/home", ".pi-dashboard", "node", "bin"),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("getManagedNodeBinary", () => {
|
|
44
|
+
it("uses node.exe on Windows", () => {
|
|
45
|
+
expect(getManagedNodeBinary({ homedir: "/h" }, "win32")).toBe(
|
|
46
|
+
path.join("/h", ".pi-dashboard", "node", "node.exe"),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses bin/node on Unix", () => {
|
|
51
|
+
expect(getManagedNodeBinary({ homedir: "/h" }, "linux")).toBe(
|
|
52
|
+
path.join("/h", ".pi-dashboard", "node", "bin", "node"),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("prependManagedNodeToPath", () => {
|
|
58
|
+
let tmpHome: string;
|
|
59
|
+
let origHome: string | undefined;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "managed-node-path-"));
|
|
63
|
+
origHome = process.env.HOME;
|
|
64
|
+
process.env.HOME = tmpHome;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
69
|
+
else process.env.HOME = origHome;
|
|
70
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function installFakeManagedNode(): string {
|
|
74
|
+
// os.homedir() reads HOME on Unix and USERPROFILE on Windows. The
|
|
75
|
+
// setup-home tripwire only sets HOME, so on Windows tests we set
|
|
76
|
+
// USERPROFILE here too.
|
|
77
|
+
if (isWin) process.env.USERPROFILE = tmpHome;
|
|
78
|
+
const dir = getManagedNodeBinDir();
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
const binary = getManagedNodeBinary();
|
|
81
|
+
fs.writeFileSync(binary, "fake");
|
|
82
|
+
return dir;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it("no-op (returns clone) when managed runtime is absent", () => {
|
|
86
|
+
const base = { PATH: "/usr/bin:/bin", FOO: "bar" };
|
|
87
|
+
const out = prependManagedNodeToPath(base);
|
|
88
|
+
expect(out).not.toBe(base);
|
|
89
|
+
expect(out.PATH).toBe("/usr/bin:/bin");
|
|
90
|
+
expect(out.FOO).toBe("bar");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not mutate process.env", () => {
|
|
94
|
+
const beforePath = process.env.PATH;
|
|
95
|
+
prependManagedNodeToPath();
|
|
96
|
+
expect(process.env.PATH).toBe(beforePath);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("prepends managed Node bin dir when present", () => {
|
|
100
|
+
const dir = installFakeManagedNode();
|
|
101
|
+
const base = { PATH: "/usr/bin:/bin", X: "y" };
|
|
102
|
+
const out = prependManagedNodeToPath(base);
|
|
103
|
+
expect(out).not.toBe(base);
|
|
104
|
+
expect(out.PATH?.startsWith(dir)).toBe(true);
|
|
105
|
+
expect(out.PATH).toBe(`${dir}${path.delimiter}/usr/bin:/bin`);
|
|
106
|
+
expect(out.X).toBe("y");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not double-prepend when dir already at head", () => {
|
|
110
|
+
const dir = installFakeManagedNode();
|
|
111
|
+
const initial = `${dir}${path.delimiter}/usr/bin`;
|
|
112
|
+
const base = { PATH: initial };
|
|
113
|
+
const out = prependManagedNodeToPath(base);
|
|
114
|
+
expect(out.PATH).toBe(initial);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("isManagedNodePresent flips with the binary on disk", () => {
|
|
118
|
+
expect(isManagedNodePresent()).toBe(false);
|
|
119
|
+
installFakeManagedNode();
|
|
120
|
+
expect(isManagedNodePresent()).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `managedRuntimeStrategy` in isolation.
|
|
3
|
+
*
|
|
4
|
+
* Strategy receives a `StrategyCtx` (overrides + platform + env). We
|
|
5
|
+
* inject a fake `exists` so no real filesystem is touched.
|
|
6
|
+
*
|
|
7
|
+
* See change: embed-managed-node-runtime (task 2.6).
|
|
8
|
+
*/
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { managedRuntimeStrategy } from "../tool-registry/strategies.js";
|
|
12
|
+
import type { StrategyCtx } from "../tool-registry/types.js";
|
|
13
|
+
|
|
14
|
+
function ctx(opts: Partial<StrategyCtx> = {}): StrategyCtx {
|
|
15
|
+
return {
|
|
16
|
+
overrides: {},
|
|
17
|
+
platform: opts.platform ?? "linux",
|
|
18
|
+
env: opts.env ?? { homedir: "/fake/home" },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("managedRuntimeStrategy", () => {
|
|
23
|
+
it("Unix node: returns <managedDir>/node/bin/node when present", () => {
|
|
24
|
+
const expected = path.join("/fake/home", ".pi-dashboard", "node", "bin", "node");
|
|
25
|
+
const s = managedRuntimeStrategy("node", { exists: (p) => p === expected });
|
|
26
|
+
const r = s.run(ctx({ platform: "linux" }));
|
|
27
|
+
expect(r.ok).toBe(true);
|
|
28
|
+
if (r.ok) expect(r.path).toBe(expected);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("Unix npm: returns <managedDir>/node/bin/npm when present", () => {
|
|
32
|
+
const expected = path.join("/fake/home", ".pi-dashboard", "node", "bin", "npm");
|
|
33
|
+
const s = managedRuntimeStrategy("npm", { exists: (p) => p === expected });
|
|
34
|
+
const r = s.run(ctx({ platform: "linux" }));
|
|
35
|
+
expect(r.ok).toBe(true);
|
|
36
|
+
if (r.ok) expect(r.path).toBe(expected);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("Windows node: returns <managedDir>/node/node.exe when present", () => {
|
|
40
|
+
const expected = path.join("/fake/home", ".pi-dashboard", "node", "node.exe");
|
|
41
|
+
const s = managedRuntimeStrategy("node", { exists: (p) => p === expected });
|
|
42
|
+
const r = s.run(ctx({ platform: "win32" }));
|
|
43
|
+
expect(r.ok).toBe(true);
|
|
44
|
+
if (r.ok) expect(r.path).toBe(expected);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("Windows npm: returns <managedDir>/node/npm.cmd when present", () => {
|
|
48
|
+
const expected = path.join("/fake/home", ".pi-dashboard", "node", "npm.cmd");
|
|
49
|
+
const s = managedRuntimeStrategy("npm", { exists: (p) => p === expected });
|
|
50
|
+
const r = s.run(ctx({ platform: "win32" }));
|
|
51
|
+
expect(r.ok).toBe(true);
|
|
52
|
+
if (r.ok) expect(r.path).toBe(expected);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("Windows npx: returns <managedDir>/node/npx.cmd when present", () => {
|
|
56
|
+
const expected = path.join("/fake/home", ".pi-dashboard", "node", "npx.cmd");
|
|
57
|
+
const s = managedRuntimeStrategy("npx", { exists: (p) => p === expected });
|
|
58
|
+
const r = s.run(ctx({ platform: "win32" }));
|
|
59
|
+
expect(r.ok).toBe(true);
|
|
60
|
+
if (r.ok) expect(r.path).toBe(expected);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns failure with reason when binary absent", () => {
|
|
64
|
+
const s = managedRuntimeStrategy("node", { exists: () => false });
|
|
65
|
+
const r = s.run(ctx({ platform: "linux" }));
|
|
66
|
+
expect(r.ok).toBe(false);
|
|
67
|
+
if (!r.ok) expect(r.reason).toMatch(/missing:/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("strategy is named 'managed' (classifies as managed source)", () => {
|
|
71
|
+
const s = managedRuntimeStrategy("node");
|
|
72
|
+
expect(s.name).toBe("managed");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test: the bridge auto-spawn code path (server-launcher.ts) must
|
|
3
|
+
* NOT import from `installable-list`. Only Electron seeds `installable.json`
|
|
4
|
+
* on first run; Bridge and Standalone starters must not produce or consume
|
|
5
|
+
* that file.
|
|
6
|
+
*
|
|
7
|
+
* This is a static source scan — no runtime execution. If this test fails,
|
|
8
|
+
* a dependency on installable-list was accidentally added to the bridge
|
|
9
|
+
* launcher which would break the "file-absent is a no-op" contract on
|
|
10
|
+
* Bridge/Standalone bootstraps.
|
|
11
|
+
*
|
|
12
|
+
* See change: simplify-electron-bootstrap-derived-state (task 5.7).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import url from "node:url";
|
|
18
|
+
|
|
19
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
20
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
21
|
+
|
|
22
|
+
/** Files that form the bridge auto-spawn contract. */
|
|
23
|
+
const BRIDGE_SPAWN_FILES: readonly string[] = [
|
|
24
|
+
"packages/extension/src/server-launcher.ts",
|
|
25
|
+
"packages/extension/src/server-auto-start.ts",
|
|
26
|
+
"packages/extension/src/connection.ts",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe("bridge auto-spawn does not reference installable-list", () => {
|
|
30
|
+
for (const rel of BRIDGE_SPAWN_FILES) {
|
|
31
|
+
it(`${rel} does not import from installable-list`, () => {
|
|
32
|
+
const file = path.resolve(repoRoot, rel);
|
|
33
|
+
if (!fs.existsSync(file)) {
|
|
34
|
+
// File absent (optional extension file) — contract trivially satisfied.
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const source = fs.readFileSync(file, "utf-8");
|
|
38
|
+
|
|
39
|
+
// Strip line comments before checking so a commented-out import
|
|
40
|
+
// does not trigger a false positive.
|
|
41
|
+
const stripped = source
|
|
42
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
43
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
stripped,
|
|
47
|
+
`${rel} must not import from "installable-list" — only Electron seeds installable.json. ` +
|
|
48
|
+
`Bridge/Standalone starters must not produce or consume that file.`,
|
|
49
|
+
).not.toMatch(/installable-list/);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -38,7 +38,12 @@ const GOVERNED_SKILLS = [
|
|
|
38
38
|
];
|
|
39
39
|
|
|
40
40
|
describe("OpenSpec workflow skills must use effective-status.sh", () => {
|
|
41
|
-
|
|
41
|
+
// SKIPPED: the .pi/skills/openspec-*/SKILL.md files this lint targets are
|
|
42
|
+
// vendored from upstream and gitignored (see .pi/.gitignore) — they get
|
|
43
|
+
// overwritten on every install, so we can't enforce dashboard-local
|
|
44
|
+
// semantics on them from here. Re-enable if/when those skills are
|
|
45
|
+
// forked into the repo, or move the wrapper-enforcement upstream.
|
|
46
|
+
it.skip("no raw `openspec status --json` outside the wrapper script", async () => {
|
|
42
47
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
43
48
|
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
44
49
|
const skillsRoot = path.resolve(repoRoot, ".pi", "skills");
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseSkillBlock, buildSkillBlock } from "../skill-block-parser.js";
|
|
3
|
+
|
|
4
|
+
describe("parseSkillBlock", () => {
|
|
5
|
+
it("matches a well-formed wrapper with args", () => {
|
|
6
|
+
const text =
|
|
7
|
+
`<skill name="foo" location="/x/SKILL.md">\nReferences are relative to /x.\n\nbody\n</skill>\n\nargs here`;
|
|
8
|
+
const block = parseSkillBlock(text);
|
|
9
|
+
expect(block).not.toBeNull();
|
|
10
|
+
expect(block!.name).toBe("foo");
|
|
11
|
+
expect(block!.location).toBe("/x/SKILL.md");
|
|
12
|
+
expect(block!.body).toBe("body");
|
|
13
|
+
expect(block!.args).toBe("args here");
|
|
14
|
+
expect(block!.condensed).toBe("/skill:foo args here");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("strips the 'References are relative to <baseDir>.\\n\\n' preamble from body", () => {
|
|
18
|
+
const text =
|
|
19
|
+
`<skill name="foo" location="/x/SKILL.md">\nReferences are relative to /x.\n\nfirst body line\nsecond body line\n</skill>`;
|
|
20
|
+
const block = parseSkillBlock(text);
|
|
21
|
+
expect(block!.body).toBe("first body line\nsecond body line");
|
|
22
|
+
expect(block!.body.startsWith("References are relative")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("falls back to verbatim content when preamble is absent", () => {
|
|
26
|
+
// Hand-crafted wrapper without the standard preamble (defensive: pi may evolve).
|
|
27
|
+
const text = `<skill name="foo" location="/x">\nbody only\n</skill>`;
|
|
28
|
+
expect(parseSkillBlock(text)!.body).toBe("body only");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("matches a wrapper without args (no preamble)", () => {
|
|
32
|
+
const text = `<skill name="foo" location="/x">\nbody\n</skill>`;
|
|
33
|
+
const block = parseSkillBlock(text);
|
|
34
|
+
expect(block).not.toBeNull();
|
|
35
|
+
expect(block!.args).toBeUndefined();
|
|
36
|
+
expect(block!.condensed).toBe("/skill:foo");
|
|
37
|
+
expect(block!.body).toBe("body");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preserves multi-line user args verbatim", () => {
|
|
41
|
+
const text =
|
|
42
|
+
`<skill name="foo" location="/x">\nbody\n</skill>\n\nline1\nline2\nline3`;
|
|
43
|
+
const block = parseSkillBlock(text);
|
|
44
|
+
expect(block!.args).toBe("line1\nline2\nline3");
|
|
45
|
+
expect(block!.condensed).toBe("/skill:foo line1\nline2\nline3");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns null for plain text", () => {
|
|
49
|
+
expect(parseSkillBlock("Hello, this is just text.")).toBeNull();
|
|
50
|
+
expect(parseSkillBlock("")).toBeNull();
|
|
51
|
+
expect(parseSkillBlock("/skill:foo args (unexpanded)")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null for mid-document <skill> (anchor enforcement)", () => {
|
|
55
|
+
const text = `prefix\n<skill name="foo" location="/x">\nbody\n</skill>`;
|
|
56
|
+
expect(parseSkillBlock(text)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null for trailing whitespace after </skill>", () => {
|
|
60
|
+
// Anchor end-of-string: a stray newline after </skill> with no args fails the optional-args group.
|
|
61
|
+
const text = `<skill name="foo" location="/x">\nbody\n</skill>\n`;
|
|
62
|
+
expect(parseSkillBlock(text)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not terminate prematurely on body containing literal <skill> text", () => {
|
|
66
|
+
const text =
|
|
67
|
+
`<skill name="real" location="/x">\nReferences are relative to /x.\n\nDocumented like: <skill name="example">…</skill>\nThat ended.\n</skill>`;
|
|
68
|
+
const block = parseSkillBlock(text);
|
|
69
|
+
expect(block).not.toBeNull();
|
|
70
|
+
expect(block!.name).toBe("real");
|
|
71
|
+
expect(block!.body).toContain('Documented like: <skill name="example">…</skill>');
|
|
72
|
+
expect(block!.body).toContain("That ended.");
|
|
73
|
+
// preamble was stripped — body starts with the user-visible content
|
|
74
|
+
expect(block!.body.startsWith("Documented")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles an empty body (no preamble)", () => {
|
|
78
|
+
const text = `<skill name="empty" location="/x">\n\n</skill>`;
|
|
79
|
+
const block = parseSkillBlock(text);
|
|
80
|
+
expect(block).not.toBeNull();
|
|
81
|
+
expect(block!.body).toBe("");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("condensed form has a single space between name and args", () => {
|
|
85
|
+
const text = `<skill name="x" location="/p">\nb\n</skill>\n\nfoo`;
|
|
86
|
+
expect(parseSkillBlock(text)!.condensed).toBe("/skill:x foo");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("condensed form has no trailing space when args is absent", () => {
|
|
90
|
+
const text = `<skill name="x" location="/p">\nb\n</skill>`;
|
|
91
|
+
expect(parseSkillBlock(text)!.condensed).toBe("/skill:x");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("buildSkillBlock", () => {
|
|
96
|
+
it("emits pi's exact wrapper format with args", () => {
|
|
97
|
+
const out = buildSkillBlock({
|
|
98
|
+
name: "openspec-explore",
|
|
99
|
+
filePath: "/x/openspec-explore/SKILL.md",
|
|
100
|
+
baseDir: "/x/openspec-explore",
|
|
101
|
+
body: "Enter explore mode.",
|
|
102
|
+
userArgs: "continue with X",
|
|
103
|
+
});
|
|
104
|
+
expect(out).toBe(
|
|
105
|
+
`<skill name="openspec-explore" location="/x/openspec-explore/SKILL.md">\n` +
|
|
106
|
+
`References are relative to /x/openspec-explore.\n\n` +
|
|
107
|
+
`Enter explore mode.\n` +
|
|
108
|
+
`</skill>\n\n` +
|
|
109
|
+
`continue with X`,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("emits wrapper without trailing args block when userArgs is absent", () => {
|
|
114
|
+
const out = buildSkillBlock({
|
|
115
|
+
name: "foo",
|
|
116
|
+
filePath: "/x/foo/SKILL.md",
|
|
117
|
+
baseDir: "/x/foo",
|
|
118
|
+
body: "body line",
|
|
119
|
+
});
|
|
120
|
+
expect(out.endsWith("</skill>")).toBe(true);
|
|
121
|
+
expect(out).not.toContain("</skill>\n\n");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("buildSkillBlock + parseSkillBlock round-trip", () => {
|
|
126
|
+
it("round-trips name / body / args (with args)", () => {
|
|
127
|
+
const built = buildSkillBlock({
|
|
128
|
+
name: "round",
|
|
129
|
+
filePath: "/p/SKILL.md",
|
|
130
|
+
baseDir: "/p",
|
|
131
|
+
body: "Some body\nwith multiple lines",
|
|
132
|
+
userArgs: "the args here",
|
|
133
|
+
});
|
|
134
|
+
const parsed = parseSkillBlock(built);
|
|
135
|
+
expect(parsed).not.toBeNull();
|
|
136
|
+
expect(parsed!.name).toBe("round");
|
|
137
|
+
expect(parsed!.location).toBe("/p/SKILL.md");
|
|
138
|
+
expect(parsed!.body).toBe("Some body\nwith multiple lines");
|
|
139
|
+
expect(parsed!.args).toBe("the args here");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("round-trips with no args", () => {
|
|
143
|
+
const built = buildSkillBlock({
|
|
144
|
+
name: "noargs",
|
|
145
|
+
filePath: "/p/SKILL.md",
|
|
146
|
+
baseDir: "/p",
|
|
147
|
+
body: "Body only",
|
|
148
|
+
});
|
|
149
|
+
const parsed = parseSkillBlock(built);
|
|
150
|
+
expect(parsed!.args).toBeUndefined();
|
|
151
|
+
expect(parsed!.condensed).toBe("/skill:noargs");
|
|
152
|
+
});
|
|
153
|
+
});
|