@blackbelt-technology/pi-agent-dashboard 0.4.0 → 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 +104 -35
- package/README.md +390 -494
- package/docs/architecture.md +423 -20
- package/package.json +11 -8
- package/packages/extension/package.json +11 -4
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -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 +170 -61
- package/packages/extension/src/bridge.ts +199 -19
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +73 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +11 -5
- 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__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -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__/pi-version-skew.test.ts +72 -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__/restart-helper.test.ts +34 -6
- 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 +61 -15
- 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/pi-version-skew.ts +12 -1
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/restart-helper.ts +13 -2
- 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-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -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__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -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/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +79 -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 +20 -1
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -11,13 +11,21 @@ import type {
|
|
|
11
11
|
BrowserPromptRequestMessage,
|
|
12
12
|
BrowserPromptDismissMessage,
|
|
13
13
|
BrowserPromptCancelMessage,
|
|
14
|
+
BrowserExtUiDecoratorMessage,
|
|
14
15
|
} from "../browser-protocol.js";
|
|
16
|
+
import type { ExtensionToServerMessage, ExtUiDecoratorMessage } from "../protocol.js";
|
|
17
|
+
import type { DecoratorDescriptor } from "../types.js";
|
|
15
18
|
|
|
16
19
|
// Type-level assertion: if these types are NOT in the union, this will fail to compile.
|
|
17
20
|
type AssertExtends<T, U> = T extends U ? true : never;
|
|
18
21
|
type _PromptRequestInUnion = AssertExtends<BrowserPromptRequestMessage, ServerToBrowserMessage>;
|
|
19
22
|
type _PromptDismissInUnion = AssertExtends<BrowserPromptDismissMessage, ServerToBrowserMessage>;
|
|
20
23
|
type _PromptCancelInUnion = AssertExtends<BrowserPromptCancelMessage, ServerToBrowserMessage>;
|
|
24
|
+
// Phase-2 (add-extension-ui-decorations): ext_ui_decorator must be a member of
|
|
25
|
+
// BOTH the extension→server union and the server→browser union, otherwise
|
|
26
|
+
// esbuild strips the switch arms in production builds.
|
|
27
|
+
type _ExtUiDecoratorInExtensionUnion = AssertExtends<ExtUiDecoratorMessage, ExtensionToServerMessage>;
|
|
28
|
+
type _ExtUiDecoratorInBrowserUnion = AssertExtends<BrowserExtUiDecoratorMessage, ServerToBrowserMessage>;
|
|
21
29
|
|
|
22
30
|
// Runtime verification that the type discriminants are reachable in a switch
|
|
23
31
|
function extractPromptType(msg: ServerToBrowserMessage): string | null {
|
|
@@ -60,3 +68,54 @@ describe("ServerToBrowserMessage includes PromptBus messages", () => {
|
|
|
60
68
|
expect(extractPromptType(msg)).toBe("p1");
|
|
61
69
|
});
|
|
62
70
|
});
|
|
71
|
+
|
|
72
|
+
// Phase-2: ext_ui_decorator switch-arm reachability.
|
|
73
|
+
function extractDecoratorKey(msg: ServerToBrowserMessage): string | null {
|
|
74
|
+
switch (msg.type) {
|
|
75
|
+
case "ext_ui_decorator":
|
|
76
|
+
return `${msg.descriptor.kind}:${msg.descriptor.namespace}:${msg.descriptor.id}`;
|
|
77
|
+
default:
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("ext_ui_decorator is a member of both protocol unions", () => {
|
|
83
|
+
const sample: DecoratorDescriptor = {
|
|
84
|
+
kind: "footer-segment",
|
|
85
|
+
namespace: "judo",
|
|
86
|
+
id: "model-state",
|
|
87
|
+
payload: { text: "3 mut" },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
it("server→browser ext_ui_decorator is a valid discriminant", () => {
|
|
91
|
+
const msg: BrowserExtUiDecoratorMessage = {
|
|
92
|
+
type: "ext_ui_decorator",
|
|
93
|
+
sessionId: "s1",
|
|
94
|
+
descriptor: sample,
|
|
95
|
+
};
|
|
96
|
+
expect(extractDecoratorKey(msg)).toBe("footer-segment:judo:model-state");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("removed flag round-trips through the union", () => {
|
|
100
|
+
const msg: BrowserExtUiDecoratorMessage = {
|
|
101
|
+
type: "ext_ui_decorator",
|
|
102
|
+
sessionId: "s1",
|
|
103
|
+
descriptor: sample,
|
|
104
|
+
removed: true,
|
|
105
|
+
};
|
|
106
|
+
expect(extractDecoratorKey(msg)).toBe("footer-segment:judo:model-state");
|
|
107
|
+
// Round-trip via JSON to confirm `removed` survives serialization.
|
|
108
|
+
const parsed = JSON.parse(JSON.stringify(msg)) as BrowserExtUiDecoratorMessage;
|
|
109
|
+
expect(parsed.removed).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("extension→server ext_ui_decorator carries the same shape", () => {
|
|
113
|
+
const msg: ExtUiDecoratorMessage = {
|
|
114
|
+
type: "ext_ui_decorator",
|
|
115
|
+
sessionId: "s1",
|
|
116
|
+
descriptor: sample,
|
|
117
|
+
};
|
|
118
|
+
expect(msg.type).toBe("ext_ui_decorator");
|
|
119
|
+
expect(msg.descriptor.kind).toBe("footer-segment");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for plugins config namespace in DashboardConfig.
|
|
3
|
+
* Verifies round-trip preservation of all keys.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { loadConfig, getPluginsConfig, getPluginConfig } from "../config.js";
|
|
10
|
+
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let origHome: string | undefined;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-plugins-test-"));
|
|
16
|
+
origHome = process.env.HOME;
|
|
17
|
+
process.env.HOME = tmpDir;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.env.HOME = origHome;
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function writeConfig(data: Record<string, unknown>) {
|
|
26
|
+
const dir = path.join(tmpDir, ".pi", "dashboard");
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify(data));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("plugins config round-trip", () => {
|
|
32
|
+
it("preserves all top-level keys including plugins namespace", () => {
|
|
33
|
+
writeConfig({
|
|
34
|
+
port: 9000,
|
|
35
|
+
auth: undefined,
|
|
36
|
+
openspec: { pollIntervalSeconds: 30 },
|
|
37
|
+
plugins: { demo: { foo: 1 } },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
expect(config.port).toBe(9000);
|
|
42
|
+
// loadConfig normalizes openspec to full object with defaults
|
|
43
|
+
expect(config.openspec.pollIntervalSeconds).toBe(30);
|
|
44
|
+
expect(getPluginConfig(config, "demo")).toEqual({ foo: 1 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns empty object for unknown plugin id", () => {
|
|
48
|
+
writeConfig({ plugins: { demo: { foo: 1 } } });
|
|
49
|
+
const config = loadConfig();
|
|
50
|
+
expect(getPluginConfig(config, "nonexistent")).toEqual({});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns empty plugins config when plugins key is absent", () => {
|
|
54
|
+
writeConfig({ port: 8000 });
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
expect(getPluginsConfig(config)).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("getPluginsConfig returns all plugin namespaces", () => {
|
|
60
|
+
writeConfig({
|
|
61
|
+
plugins: { demo: { foo: 1 }, openspec: { pollIntervalSeconds: 60 } },
|
|
62
|
+
});
|
|
63
|
+
const config = loadConfig();
|
|
64
|
+
const plugins = getPluginsConfig(config);
|
|
65
|
+
expect(plugins.demo).toEqual({ foo: 1 });
|
|
66
|
+
expect(plugins.openspec).toEqual({ pollIntervalSeconds: 60 });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-shape and union-membership tests for the Extension UI System Phase-1
|
|
3
|
+
* schema and protocol additions. See change: add-extension-ui-modal.
|
|
4
|
+
*
|
|
5
|
+
* The point of these tests is twofold:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Compile-time shape validation.** Constructing concrete
|
|
8
|
+
* `ExtensionUiModule` instances with the canonical view kinds (`table`,
|
|
9
|
+
* `grid`, `form`) and `UiAction` confirm-polish flag forces the type
|
|
10
|
+
* system to enforce the field shapes from the design.
|
|
11
|
+
*
|
|
12
|
+
* 2. **Union membership.** The new wire-protocol messages
|
|
13
|
+
* (`ui_modules_list`, `ui_data_list`, `ui_management`) are dropped by
|
|
14
|
+
* esbuild in production if they are not members of the
|
|
15
|
+
* `ServerToBrowserMessage` / `BrowserToServerMessage` /
|
|
16
|
+
* `ExtensionToServerMessage` / `ServerToExtensionMessage` unions. The
|
|
17
|
+
* `AssertExtends` guards below fail at compile time if any union loses
|
|
18
|
+
* its new member.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from "vitest";
|
|
21
|
+
import type {
|
|
22
|
+
ExtensionUiModule,
|
|
23
|
+
UiAction,
|
|
24
|
+
UiField,
|
|
25
|
+
UiSection,
|
|
26
|
+
UiView,
|
|
27
|
+
} from "../types.js";
|
|
28
|
+
import type {
|
|
29
|
+
ExtensionToServerMessage,
|
|
30
|
+
ServerToExtensionMessage,
|
|
31
|
+
UiModulesListMessage,
|
|
32
|
+
UiDataListMessage,
|
|
33
|
+
UiManagementMessage,
|
|
34
|
+
} from "../protocol.js";
|
|
35
|
+
import type {
|
|
36
|
+
ServerToBrowserMessage,
|
|
37
|
+
BrowserToServerMessage,
|
|
38
|
+
BrowserUiModulesListMessage,
|
|
39
|
+
BrowserUiDataListMessage,
|
|
40
|
+
UiManagementBrowserMessage,
|
|
41
|
+
} from "../browser-protocol.js";
|
|
42
|
+
|
|
43
|
+
// ── Compile-time union-membership assertions ───────────────────────
|
|
44
|
+
type AssertExtends<T, U> = T extends U ? true : never;
|
|
45
|
+
|
|
46
|
+
// Bridge ↔ Server leg
|
|
47
|
+
type _UiModulesListInExt = AssertExtends<UiModulesListMessage, ExtensionToServerMessage>;
|
|
48
|
+
type _UiDataListInExt = AssertExtends<UiDataListMessage, ExtensionToServerMessage>;
|
|
49
|
+
type _UiManagementInServer = AssertExtends<UiManagementMessage, ServerToExtensionMessage>;
|
|
50
|
+
|
|
51
|
+
// Server ↔ Browser leg
|
|
52
|
+
type _BrowserUiModulesInUnion = AssertExtends<BrowserUiModulesListMessage, ServerToBrowserMessage>;
|
|
53
|
+
type _BrowserUiDataInUnion = AssertExtends<BrowserUiDataListMessage, ServerToBrowserMessage>;
|
|
54
|
+
type _UiManagementInBrowser = AssertExtends<UiManagementBrowserMessage, BrowserToServerMessage>;
|
|
55
|
+
|
|
56
|
+
// Sentinel — referenced so tsc keeps the assertions live.
|
|
57
|
+
const _typeSentinel: Array<true> = [
|
|
58
|
+
true as _UiModulesListInExt,
|
|
59
|
+
true as _UiDataListInExt,
|
|
60
|
+
true as _UiManagementInServer,
|
|
61
|
+
true as _BrowserUiModulesInUnion,
|
|
62
|
+
true as _BrowserUiDataInUnion,
|
|
63
|
+
true as _UiManagementInBrowser,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
describe("ExtensionUiModule shape", () => {
|
|
67
|
+
it("accepts a table view with dataEvent and rowActions", () => {
|
|
68
|
+
const tableModule: ExtensionUiModule = {
|
|
69
|
+
kind: "management-modal",
|
|
70
|
+
id: "judo-status",
|
|
71
|
+
command: "/judo:status",
|
|
72
|
+
title: "Judo Status",
|
|
73
|
+
description: "Show pending status rows",
|
|
74
|
+
icon: "mdiTableLarge",
|
|
75
|
+
category: "judo",
|
|
76
|
+
view: {
|
|
77
|
+
kind: "table",
|
|
78
|
+
dataEvent: "judo:status-rows",
|
|
79
|
+
rowKey: "id",
|
|
80
|
+
fields: [
|
|
81
|
+
{ key: "id", label: "ID", kind: "text", width: 80 },
|
|
82
|
+
{ key: "name", label: "Name", kind: "text" },
|
|
83
|
+
{ key: "score", label: "Score", kind: "number" },
|
|
84
|
+
],
|
|
85
|
+
rowActions: [
|
|
86
|
+
{
|
|
87
|
+
id: "delete",
|
|
88
|
+
label: "Delete",
|
|
89
|
+
icon: "mdiDelete",
|
|
90
|
+
variant: "danger",
|
|
91
|
+
event: "judo:delete-row",
|
|
92
|
+
confirm: "Delete this entry?",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
emptyState: "No rows yet.",
|
|
96
|
+
actions: [
|
|
97
|
+
{
|
|
98
|
+
id: "refresh",
|
|
99
|
+
label: "Refresh",
|
|
100
|
+
icon: "mdiRefresh",
|
|
101
|
+
variant: "secondary",
|
|
102
|
+
event: "judo:refresh",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
expect(tableModule.kind).toBe("management-modal");
|
|
109
|
+
expect(tableModule.view.kind).toBe("table");
|
|
110
|
+
expect(tableModule.view.dataEvent).toBe("judo:status-rows");
|
|
111
|
+
expect(tableModule.view.rowActions?.[0]?.confirm).toBe("Delete this entry?");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("accepts a form view with sections grouping fields", () => {
|
|
115
|
+
const sections: UiSection[] = [
|
|
116
|
+
{
|
|
117
|
+
id: "general",
|
|
118
|
+
title: "General",
|
|
119
|
+
fields: [
|
|
120
|
+
{ key: "name", label: "Name", kind: "text", required: true },
|
|
121
|
+
{ key: "enabled", label: "Enabled", kind: "boolean" },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "advanced",
|
|
126
|
+
title: "Advanced",
|
|
127
|
+
description: "Optional knobs.",
|
|
128
|
+
fields: [
|
|
129
|
+
{ key: "code", label: "Hook", kind: "code", language: "javascript" },
|
|
130
|
+
{ key: "notes", label: "Notes", kind: "textarea" },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const formModule: ExtensionUiModule = {
|
|
136
|
+
kind: "management-modal",
|
|
137
|
+
id: "judo-config",
|
|
138
|
+
command: "/judo:config",
|
|
139
|
+
title: "Judo Config",
|
|
140
|
+
view: {
|
|
141
|
+
kind: "form",
|
|
142
|
+
sections,
|
|
143
|
+
actions: [
|
|
144
|
+
{
|
|
145
|
+
id: "save",
|
|
146
|
+
label: "Save",
|
|
147
|
+
icon: "mdiContentSave",
|
|
148
|
+
variant: "primary",
|
|
149
|
+
event: "judo:save-config",
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
expect(formModule.view.kind).toBe("form");
|
|
156
|
+
expect(formModule.view.sections).toHaveLength(2);
|
|
157
|
+
expect(formModule.view.sections?.[1]?.fields[0]?.kind).toBe("code");
|
|
158
|
+
expect(formModule.view.sections?.[1]?.fields[0]?.language).toBe("javascript");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("accepts a grid view (same lifecycle as table) and select-kind fields", () => {
|
|
162
|
+
const fields: UiField[] = [
|
|
163
|
+
{ key: "id", label: "ID", kind: "text" },
|
|
164
|
+
{ key: "tier", label: "Tier", kind: "select", options: ["bronze", "silver", "gold"] },
|
|
165
|
+
{ key: "joinedAt", label: "Joined", kind: "datetime" },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const gridModule: ExtensionUiModule = {
|
|
169
|
+
kind: "management-modal",
|
|
170
|
+
id: "judo-members",
|
|
171
|
+
command: "/judo:members",
|
|
172
|
+
title: "Members",
|
|
173
|
+
view: {
|
|
174
|
+
kind: "grid",
|
|
175
|
+
dataEvent: "judo:members-list",
|
|
176
|
+
fields,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
expect(gridModule.view.kind).toBe("grid");
|
|
181
|
+
expect(gridModule.view.fields?.[1]?.options).toEqual(["bronze", "silver", "gold"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("UiAction supports confirm polish for destructive actions", () => {
|
|
185
|
+
const dangerAction: UiAction = {
|
|
186
|
+
id: "wipe",
|
|
187
|
+
label: "Wipe All",
|
|
188
|
+
variant: "danger",
|
|
189
|
+
event: "judo:wipe",
|
|
190
|
+
confirm: "Wipe all members? This cannot be undone.",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
expect(dangerAction.confirm).toContain("cannot be undone");
|
|
194
|
+
expect(dangerAction.variant).toBe("danger");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("UiView with table kind requires neither sections nor actions", () => {
|
|
198
|
+
const minimal: UiView = {
|
|
199
|
+
kind: "table",
|
|
200
|
+
dataEvent: "x:list",
|
|
201
|
+
fields: [{ key: "id", label: "ID", kind: "text" }],
|
|
202
|
+
};
|
|
203
|
+
expect(minimal.kind).toBe("table");
|
|
204
|
+
expect(minimal.actions).toBeUndefined();
|
|
205
|
+
expect(minimal.sections).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("Phase-1 wire protocol", () => {
|
|
210
|
+
it("constructs each message with discriminated literal types", () => {
|
|
211
|
+
const modulesList: UiModulesListMessage = {
|
|
212
|
+
type: "ui_modules_list",
|
|
213
|
+
sessionId: "s1",
|
|
214
|
+
modules: [],
|
|
215
|
+
};
|
|
216
|
+
const dataList: UiDataListMessage = {
|
|
217
|
+
type: "ui_data_list",
|
|
218
|
+
sessionId: "s1",
|
|
219
|
+
event: "judo:status-rows",
|
|
220
|
+
items: [{ id: 1 }, { id: 2 }],
|
|
221
|
+
};
|
|
222
|
+
const browserModules: BrowserUiModulesListMessage = {
|
|
223
|
+
type: "ui_modules_list",
|
|
224
|
+
sessionId: "s1",
|
|
225
|
+
modules: [],
|
|
226
|
+
};
|
|
227
|
+
const browserData: BrowserUiDataListMessage = {
|
|
228
|
+
type: "ui_data_list",
|
|
229
|
+
sessionId: "s1",
|
|
230
|
+
event: "judo:status-rows",
|
|
231
|
+
items: [],
|
|
232
|
+
};
|
|
233
|
+
const mgmt: UiManagementBrowserMessage = {
|
|
234
|
+
type: "ui_management",
|
|
235
|
+
sessionId: "s1",
|
|
236
|
+
action: "list",
|
|
237
|
+
event: "judo:status-rows",
|
|
238
|
+
params: { since: 0 },
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
expect(modulesList.type).toBe("ui_modules_list");
|
|
242
|
+
expect(dataList.type).toBe("ui_data_list");
|
|
243
|
+
expect(browserModules.type).toBe("ui_modules_list");
|
|
244
|
+
expect(browserData.type).toBe("ui_data_list");
|
|
245
|
+
expect(mgmt.type).toBe("ui_management");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("type discriminants are reachable in a switch (esbuild safety)", () => {
|
|
249
|
+
function classify(msg: ServerToBrowserMessage): string | null {
|
|
250
|
+
switch (msg.type) {
|
|
251
|
+
case "ui_modules_list":
|
|
252
|
+
return `modules:${msg.modules.length}`;
|
|
253
|
+
case "ui_data_list":
|
|
254
|
+
return `data:${msg.event}:${msg.items.length}`;
|
|
255
|
+
default:
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
expect(classify({ type: "ui_modules_list", sessionId: "s", modules: [] })).toBe("modules:0");
|
|
261
|
+
expect(
|
|
262
|
+
classify({ type: "ui_data_list", sessionId: "s", event: "x", items: [1, 2, 3] }),
|
|
263
|
+
).toBe("data:x:3");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-level invariant: build-time scripts (CI workflows, Dockerfiles,
|
|
3
|
+
* shell scripts, root-level CJS helpers) MUST NOT hardcode
|
|
4
|
+
* `node_modules/electron` or `node_modules/node-pty` paths. Instead, they
|
|
5
|
+
* MUST resolve through the tool registry — either via the shared shell
|
|
6
|
+
* wrapper at `packages/shared/bin/pi-dashboard-resolve-tool.cjs`, or
|
|
7
|
+
* (for postinstall paths that run before the shared package is built)
|
|
8
|
+
* via `require.resolve("<pkg>/package.json")` matching the registry's
|
|
9
|
+
* `bare-import` strategy semantics.
|
|
10
|
+
*
|
|
11
|
+
* This invariant exists because npm workspace hoisting moves these
|
|
12
|
+
* packages between `packages/<workspace>/node_modules/<pkg>/` (nested)
|
|
13
|
+
* and `<repoRoot>/node_modules/<pkg>/` (hoisted) depending on the
|
|
14
|
+
* workspaces config and npm version. The v0.4.0 release crisis was
|
|
15
|
+
* caused exactly by this: `cd packages/electron/node_modules/electron`
|
|
16
|
+
* stopped working after `f51e352` switched workspace cross-refs to
|
|
17
|
+
* plain semver.
|
|
18
|
+
*
|
|
19
|
+
* If this test fails, replace the offending substring with one of:
|
|
20
|
+
*
|
|
21
|
+
* # Shell / YAML / Dockerfile (build-time, has access to repo source):
|
|
22
|
+
* ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)
|
|
23
|
+
* cd "$ELECTRON_DIR" && ...
|
|
24
|
+
*
|
|
25
|
+
* # CJS root postinstall (runs DURING npm install — must inline):
|
|
26
|
+
* const ptyPkg = require.resolve("node-pty/package.json");
|
|
27
|
+
* const prebuildsDir = path.join(path.dirname(ptyPkg), "prebuilds");
|
|
28
|
+
*
|
|
29
|
+
* See change: register-build-time-tools.
|
|
30
|
+
*/
|
|
31
|
+
import { describe, expect, it } from "vitest";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import url from "node:url";
|
|
35
|
+
|
|
36
|
+
/** Banned substrings (after comment-stripping). */
|
|
37
|
+
const PATTERNS: readonly { re: RegExp; suggestion: string }[] = [
|
|
38
|
+
{
|
|
39
|
+
re: /node_modules\/electron(?:\/|\b)/,
|
|
40
|
+
suggestion:
|
|
41
|
+
"Use `node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron`",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
re: /node_modules\/node-pty(?:\/|\b)/,
|
|
45
|
+
suggestion:
|
|
46
|
+
'Use `require.resolve("node-pty/package.json")` (mirrors the registry\'s bare-import strategy)',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Files explicitly allowed to contain the banned substrings. Each entry
|
|
52
|
+
* is a repo-relative path matched exactly. Add an entry only when the
|
|
53
|
+
* substring appears as a non-path token (e.g. an argument to
|
|
54
|
+
* `require.resolve`, a comment quoting an example, or this lint file
|
|
55
|
+
* itself). Document the reason inline.
|
|
56
|
+
*/
|
|
57
|
+
const ALLOWLIST: readonly string[] = [
|
|
58
|
+
// The lint file itself contains every banned substring as test data.
|
|
59
|
+
"packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts",
|
|
60
|
+
// Root postinstall — uses `require.resolve("node-pty/package.json")`,
|
|
61
|
+
// which contains "node-pty" as an argument string but not as a
|
|
62
|
+
// hardcoded path. Allowlisted because it must run before the shared
|
|
63
|
+
// package is unpacked. See file header for full reasoning.
|
|
64
|
+
"scripts/fix-pty-permissions.cjs",
|
|
65
|
+
// Sister postinstall script (workspace-scoped) — same rationale.
|
|
66
|
+
"packages/server/scripts/fix-pty-permissions.cjs",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Repo-relative file list to scan.
|
|
71
|
+
*
|
|
72
|
+
* The scope is intentionally narrow: only the build-time sites that the
|
|
73
|
+
* `register-build-time-tools` change migrated, plus the postinstall
|
|
74
|
+
* scripts that mirror the registry's `bare-import` semantics. Bundle /
|
|
75
|
+
* Docker entrypoint scripts (`bundle-server.sh`, `docker-make.sh`,
|
|
76
|
+
* `test-electron-install-inner.sh`, etc.) are NOT in scope: those
|
|
77
|
+
* operate on a known WORKDIR with deterministic node_modules layout
|
|
78
|
+
* inside the build image and are not affected by host-side hoisting.
|
|
79
|
+
*/
|
|
80
|
+
const SCAN_FILES: readonly string[] = [
|
|
81
|
+
".github/workflows/publish.yml",
|
|
82
|
+
".github/workflows/ci.yml",
|
|
83
|
+
"packages/electron/scripts/Dockerfile.build",
|
|
84
|
+
"scripts/fix-pty-permissions.cjs",
|
|
85
|
+
"packages/server/scripts/fix-pty-permissions.cjs",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
interface Violation {
|
|
89
|
+
file: string;
|
|
90
|
+
line: number;
|
|
91
|
+
col: number;
|
|
92
|
+
text: string;
|
|
93
|
+
suggestion: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Strip a single line's trailing comment for YAML / shell / JS-style
|
|
98
|
+
* line comments. Preserves substring matches inside strings as actual
|
|
99
|
+
* content (we don't try to parse string literals — keeping it simple).
|
|
100
|
+
*
|
|
101
|
+
* Specifically:
|
|
102
|
+
* - `# ...` (YAML, shell): everything from a `#` not preceded by a
|
|
103
|
+
* non-space alphanumeric is dropped. Matches GitHub Actions /
|
|
104
|
+
* bash conventions.
|
|
105
|
+
* - `// ...` (JS): everything from `//` to end of line is dropped.
|
|
106
|
+
*
|
|
107
|
+
* This is intentionally simple. False positives only matter if a banned
|
|
108
|
+
* pattern appears INSIDE a string literal (which would still be the
|
|
109
|
+
* bug we want to catch); false negatives only matter for inline
|
|
110
|
+
* comments (`echo foo # comment node_modules/electron`), which we
|
|
111
|
+
* exclude correctly.
|
|
112
|
+
*/
|
|
113
|
+
function stripLineComment(line: string): string {
|
|
114
|
+
// JS-style first.
|
|
115
|
+
const jsIdx = line.indexOf("//");
|
|
116
|
+
if (jsIdx >= 0) line = line.slice(0, jsIdx);
|
|
117
|
+
// Shell/YAML `#` — only when preceded by whitespace or start-of-line.
|
|
118
|
+
const hashMatch = line.match(/(^|\s)#/);
|
|
119
|
+
if (hashMatch && typeof hashMatch.index === "number") {
|
|
120
|
+
line = line.slice(0, hashMatch.index);
|
|
121
|
+
}
|
|
122
|
+
return line;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("no hardcoded node_modules/<dep> paths in build-time files", () => {
|
|
126
|
+
it("only allowlisted files reference node_modules/electron or node_modules/node-pty", () => {
|
|
127
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
128
|
+
const repoRoot = path.resolve(here, "..", "..", "..", "..");
|
|
129
|
+
|
|
130
|
+
const violations: Violation[] = [];
|
|
131
|
+
const allowSet = new Set(
|
|
132
|
+
ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
for (const rel of SCAN_FILES) {
|
|
136
|
+
const file = path.resolve(repoRoot, rel);
|
|
137
|
+
if (!fs.existsSync(file)) continue;
|
|
138
|
+
const normalized = file.replace(/\\/g, "/");
|
|
139
|
+
if (allowSet.has(normalized)) continue;
|
|
140
|
+
|
|
141
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
142
|
+
const lines = content.split(/\r?\n/);
|
|
143
|
+
|
|
144
|
+
lines.forEach((rawLine, idx) => {
|
|
145
|
+
const stripped = stripLineComment(rawLine);
|
|
146
|
+
for (const { re, suggestion } of PATTERNS) {
|
|
147
|
+
const m = stripped.match(re);
|
|
148
|
+
if (!m) continue;
|
|
149
|
+
const col = rawLine.indexOf(m[0]);
|
|
150
|
+
violations.push({
|
|
151
|
+
file: path.relative(repoRoot, file),
|
|
152
|
+
line: idx + 1,
|
|
153
|
+
col: col >= 0 ? col + 1 : 1,
|
|
154
|
+
text: rawLine.trim(),
|
|
155
|
+
suggestion,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (violations.length > 0) {
|
|
162
|
+
const msg =
|
|
163
|
+
`Hardcoded \`node_modules/<dep>\` path(s) found in build-time files.\n` +
|
|
164
|
+
`These break under npm workspace hoisting changes (see v0.4.0 release crisis).\n` +
|
|
165
|
+
`Use the tool registry instead. See change: register-build-time-tools.\n\n` +
|
|
166
|
+
`Offenders (${violations.length}):\n` +
|
|
167
|
+
violations
|
|
168
|
+
.map(
|
|
169
|
+
(v) =>
|
|
170
|
+
` ${v.file}:${v.line}:${v.col} ${v.text}\n → ${v.suggestion}`,
|
|
171
|
+
)
|
|
172
|
+
.join("\n");
|
|
173
|
+
expect(violations, msg).toEqual([]);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|