@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
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
PackageManagerWrapper,
|
|
4
|
+
AlreadyAtDestinationError,
|
|
5
|
+
InvalidMoveRequestError,
|
|
6
|
+
UnsupportedSourceForDestinationError,
|
|
7
|
+
} from "../package-manager-wrapper.js";
|
|
8
|
+
import {
|
|
9
|
+
ToolRegistry,
|
|
10
|
+
OverridesStore,
|
|
11
|
+
} from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
|
|
12
|
+
import { registerDefaultTools } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/definitions.js";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
// ──────────────────────────────────────────────────────────────────
|
|
18
|
+
// Fake pi.DefaultPackageManager + fake settingsManager that share
|
|
19
|
+
// in-memory state so we can verify the wrapper's `move()` end-to-end.
|
|
20
|
+
// ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface FakeState {
|
|
23
|
+
globalPackages: any[];
|
|
24
|
+
projectPackages: any[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeFakePm(state: FakeState) {
|
|
28
|
+
const settingsManager = {
|
|
29
|
+
getGlobalSettings: () => ({ packages: [...state.globalPackages] }),
|
|
30
|
+
getProjectSettings: () => ({ packages: [...state.projectPackages] }),
|
|
31
|
+
setPackages: vi.fn((p: any[]) => {
|
|
32
|
+
state.globalPackages = [...p];
|
|
33
|
+
}),
|
|
34
|
+
setProjectPackages: vi.fn((p: any[]) => {
|
|
35
|
+
state.projectPackages = [...p];
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
const pm: any = {
|
|
39
|
+
settingsManager,
|
|
40
|
+
setProgressCallback: vi.fn(),
|
|
41
|
+
installAndPersist: vi.fn(async (source: string, { local }: { local: boolean }) => {
|
|
42
|
+
// Simulate pi: append a bare-string entry to the relevant array.
|
|
43
|
+
if (local) state.projectPackages.push(source);
|
|
44
|
+
else state.globalPackages.push(source);
|
|
45
|
+
}),
|
|
46
|
+
removeAndPersist: vi.fn(async (source: string, { local }: { local: boolean }) => {
|
|
47
|
+
const arr = local ? state.projectPackages : state.globalPackages;
|
|
48
|
+
const idx = arr.findIndex((e: any) =>
|
|
49
|
+
typeof e === "string" ? e === source : e?.source === source,
|
|
50
|
+
);
|
|
51
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
52
|
+
return idx >= 0;
|
|
53
|
+
}),
|
|
54
|
+
update: vi.fn(async () => {}),
|
|
55
|
+
listConfiguredPackages: () => [
|
|
56
|
+
...state.globalPackages.map((s) => ({
|
|
57
|
+
source: typeof s === "string" ? s : s.source,
|
|
58
|
+
scope: "user",
|
|
59
|
+
filtered: false,
|
|
60
|
+
})),
|
|
61
|
+
...state.projectPackages.map((s) => ({
|
|
62
|
+
source: typeof s === "string" ? s : s.source,
|
|
63
|
+
scope: "project",
|
|
64
|
+
filtered: false,
|
|
65
|
+
})),
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
return { pm, settingsManager };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let currentState: FakeState;
|
|
72
|
+
let currentFakePm: ReturnType<typeof makeFakePm>;
|
|
73
|
+
|
|
74
|
+
function makeFakePiModule() {
|
|
75
|
+
return {
|
|
76
|
+
DefaultPackageManager: function () {
|
|
77
|
+
// Return the SAME shared fake pm so test assertions can inspect it.
|
|
78
|
+
return currentFakePm.pm;
|
|
79
|
+
},
|
|
80
|
+
SettingsManager: { create: () => ({}) },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function makeTestRegistry(): ToolRegistry {
|
|
85
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "pmw-move-"));
|
|
86
|
+
const overrides = new OverridesStore({
|
|
87
|
+
filePath: path.join(tmpDir, "tool-overrides.json"),
|
|
88
|
+
});
|
|
89
|
+
const stubDir = path.join(tmpDir, "pi-coding-agent", "dist");
|
|
90
|
+
mkdirSync(stubDir, { recursive: true });
|
|
91
|
+
const stubPath = path.join(stubDir, "index.js");
|
|
92
|
+
writeFileSync(stubPath, "// test stub\n");
|
|
93
|
+
overrides.set("pi-coding-agent", stubPath);
|
|
94
|
+
|
|
95
|
+
const registry = new ToolRegistry({
|
|
96
|
+
overrides,
|
|
97
|
+
importModule: async () => makeFakePiModule(),
|
|
98
|
+
});
|
|
99
|
+
registerDefaultTools(registry);
|
|
100
|
+
return registry;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("PackageManagerWrapper.move()", () => {
|
|
104
|
+
let wrapper: PackageManagerWrapper;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
currentState = { globalPackages: [], projectPackages: [] };
|
|
108
|
+
currentFakePm = makeFakePm(currentState);
|
|
109
|
+
wrapper = new PackageManagerWrapper(makeTestRegistry());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── Synchronous validation throws ──────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
it("throws InvalidMoveRequestError when fromScope === toScope", async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
wrapper.move({
|
|
117
|
+
entry: "npm:foo",
|
|
118
|
+
fromScope: "global",
|
|
119
|
+
toScope: "global",
|
|
120
|
+
}),
|
|
121
|
+
).rejects.toThrow(InvalidMoveRequestError);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("throws InvalidMoveRequestError when fromCwd missing for local fromScope", async () => {
|
|
125
|
+
await expect(
|
|
126
|
+
wrapper.move({
|
|
127
|
+
entry: "npm:foo",
|
|
128
|
+
fromScope: "local",
|
|
129
|
+
toScope: "global",
|
|
130
|
+
}),
|
|
131
|
+
).rejects.toThrow(InvalidMoveRequestError);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("throws InvalidMoveRequestError when toCwd missing for local toScope", async () => {
|
|
135
|
+
await expect(
|
|
136
|
+
wrapper.move({
|
|
137
|
+
entry: "npm:foo",
|
|
138
|
+
fromScope: "global",
|
|
139
|
+
toScope: "local",
|
|
140
|
+
}),
|
|
141
|
+
).rejects.toThrow(InvalidMoveRequestError);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("throws InvalidMoveRequestError on empty entry source", async () => {
|
|
145
|
+
await expect(
|
|
146
|
+
wrapper.move({
|
|
147
|
+
entry: "",
|
|
148
|
+
fromScope: "global",
|
|
149
|
+
toScope: "local",
|
|
150
|
+
toCwd: "/p",
|
|
151
|
+
}),
|
|
152
|
+
).rejects.toThrow(InvalidMoveRequestError);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("throws UnsupportedSourceForDestinationError for relative path without fromCwd (local origin)", async () => {
|
|
156
|
+
// fromScope=local needs fromCwd anyway, but the rel-path check
|
|
157
|
+
// adds a more specific error code. The wrapper checks
|
|
158
|
+
// InvalidMoveRequestError first; this guards the secondary check.
|
|
159
|
+
// The current invariant is: rel-path without fromCwd while origin
|
|
160
|
+
// is local. We can validate that path explicitly when local
|
|
161
|
+
// fromScope+fromCwd is missing — let's just confirm the local
|
|
162
|
+
// invariant fires.
|
|
163
|
+
await expect(
|
|
164
|
+
wrapper.move({
|
|
165
|
+
entry: "..",
|
|
166
|
+
fromScope: "local",
|
|
167
|
+
toScope: "global",
|
|
168
|
+
}),
|
|
169
|
+
).rejects.toThrow(InvalidMoveRequestError);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Happy path: npm move (global → local) ──────────────────────────────
|
|
173
|
+
|
|
174
|
+
it("moves npm package from global to local: install + remove with shared moveId", async () => {
|
|
175
|
+
currentState.globalPackages = ["npm:pi-flows"];
|
|
176
|
+
|
|
177
|
+
const completions: any[] = [];
|
|
178
|
+
const reloadFn = vi.fn().mockResolvedValue(2);
|
|
179
|
+
wrapper.setCompleteListener((r) => completions.push(r));
|
|
180
|
+
wrapper.setReloadSessions(reloadFn);
|
|
181
|
+
|
|
182
|
+
const moveId = await wrapper.move({
|
|
183
|
+
entry: "npm:pi-flows",
|
|
184
|
+
fromScope: "global",
|
|
185
|
+
toScope: "local",
|
|
186
|
+
toCwd: "/proj",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(moveId).toMatch(/^[0-9a-f-]+$/);
|
|
190
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
191
|
+
|
|
192
|
+
// Two pi calls (install at dest, remove from origin).
|
|
193
|
+
expect(currentFakePm.pm.installAndPersist).toHaveBeenCalledWith("npm:pi-flows", { local: true });
|
|
194
|
+
expect(currentFakePm.pm.removeAndPersist).toHaveBeenCalledWith("npm:pi-flows", { local: false });
|
|
195
|
+
|
|
196
|
+
// Final state: removed from global, present in local.
|
|
197
|
+
expect(currentState.globalPackages).toEqual([]);
|
|
198
|
+
expect(currentState.projectPackages).toEqual(["npm:pi-flows"]);
|
|
199
|
+
|
|
200
|
+
// Exactly ONE reload (coalesced), not two.
|
|
201
|
+
expect(reloadFn).toHaveBeenCalledOnce();
|
|
202
|
+
|
|
203
|
+
// Exactly ONE complete event, action=move, with moveId.
|
|
204
|
+
expect(completions).toHaveLength(1);
|
|
205
|
+
expect(completions[0].action).toBe("move");
|
|
206
|
+
expect(completions[0].success).toBe(true);
|
|
207
|
+
expect(completions[0].moveId).toBe(moveId);
|
|
208
|
+
expect(completions[0].sessionsReloaded).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── Happy path: git move (local → global) ──────────────────────────────
|
|
212
|
+
|
|
213
|
+
it("moves git package preserving pin in source string", async () => {
|
|
214
|
+
currentState.projectPackages = ["git:github.com/x/y@v1.2.3"];
|
|
215
|
+
|
|
216
|
+
await wrapper.move({
|
|
217
|
+
entry: "git:github.com/x/y@v1.2.3",
|
|
218
|
+
fromScope: "local",
|
|
219
|
+
fromCwd: "/proj",
|
|
220
|
+
toScope: "global",
|
|
221
|
+
});
|
|
222
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
223
|
+
|
|
224
|
+
// Install at global with the pinned source verbatim.
|
|
225
|
+
expect(currentFakePm.pm.installAndPersist).toHaveBeenCalledWith(
|
|
226
|
+
"git:github.com/x/y@v1.2.3",
|
|
227
|
+
{ local: false },
|
|
228
|
+
);
|
|
229
|
+
expect(currentState.globalPackages).toContain("git:github.com/x/y@v1.2.3");
|
|
230
|
+
expect(currentState.projectPackages).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── Identity preflight ──────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
it("AlreadyAtDestinationError surfaces via complete listener (already at target identity)", async () => {
|
|
236
|
+
// Already installed in BOTH scopes — different version pins, but
|
|
237
|
+
// pi dedup identity is the bare name.
|
|
238
|
+
currentState.globalPackages = ["npm:pi-flows@1.0.0"];
|
|
239
|
+
currentState.projectPackages = ["npm:pi-flows@2.0.0"];
|
|
240
|
+
|
|
241
|
+
const completions: any[] = [];
|
|
242
|
+
wrapper.setCompleteListener((r) => completions.push(r));
|
|
243
|
+
|
|
244
|
+
await wrapper.move({
|
|
245
|
+
entry: "npm:pi-flows@1.0.0",
|
|
246
|
+
fromScope: "global",
|
|
247
|
+
toScope: "local",
|
|
248
|
+
toCwd: "/proj",
|
|
249
|
+
});
|
|
250
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
251
|
+
|
|
252
|
+
expect(completions).toHaveLength(1);
|
|
253
|
+
expect(completions[0].success).toBe(false);
|
|
254
|
+
expect(completions[0].error).toMatch(/already installed/i);
|
|
255
|
+
// State unchanged.
|
|
256
|
+
expect(currentState.globalPackages).toEqual(["npm:pi-flows@1.0.0"]);
|
|
257
|
+
expect(currentState.projectPackages).toEqual(["npm:pi-flows@2.0.0"]);
|
|
258
|
+
// pi was NOT called.
|
|
259
|
+
expect(currentFakePm.pm.installAndPersist).not.toHaveBeenCalled();
|
|
260
|
+
expect(currentFakePm.pm.removeAndPersist).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ── Path arm: settings-only edit (rel-path → global) ───────────────────
|
|
264
|
+
|
|
265
|
+
it("path source moves are settings-only — no install/remove called", async () => {
|
|
266
|
+
// Origin: local with relative path "..".
|
|
267
|
+
currentState.projectPackages = [".."];
|
|
268
|
+
|
|
269
|
+
await wrapper.move({
|
|
270
|
+
entry: "..",
|
|
271
|
+
fromScope: "local",
|
|
272
|
+
fromCwd: "/proj",
|
|
273
|
+
toScope: "global",
|
|
274
|
+
});
|
|
275
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
276
|
+
|
|
277
|
+
// pi was NOT called (settings-edit arm).
|
|
278
|
+
expect(currentFakePm.pm.installAndPersist).not.toHaveBeenCalled();
|
|
279
|
+
expect(currentFakePm.pm.removeAndPersist).not.toHaveBeenCalled();
|
|
280
|
+
|
|
281
|
+
// Origin scope cleared; destination has the resolved abs path.
|
|
282
|
+
expect(currentState.projectPackages).toEqual([]);
|
|
283
|
+
expect(currentState.globalPackages.length).toBe(1);
|
|
284
|
+
const newSource = currentState.globalPackages[0];
|
|
285
|
+
// ".." resolved against /proj/.pi → /proj
|
|
286
|
+
expect(typeof newSource === "string" ? newSource : newSource.source).toBe("/proj");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── Filter preservation ─────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
it("preserves filter object when moving npm package with filters", async () => {
|
|
292
|
+
const entry = {
|
|
293
|
+
source: "npm:pi-flows",
|
|
294
|
+
extensions: ["a.ts"],
|
|
295
|
+
skills: [],
|
|
296
|
+
};
|
|
297
|
+
currentState.globalPackages = [entry];
|
|
298
|
+
|
|
299
|
+
await wrapper.move({
|
|
300
|
+
entry,
|
|
301
|
+
fromScope: "global",
|
|
302
|
+
toScope: "local",
|
|
303
|
+
toCwd: "/proj",
|
|
304
|
+
});
|
|
305
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
306
|
+
|
|
307
|
+
// Destination should have the FULL object form, not just the bare string
|
|
308
|
+
// pi's installer wrote.
|
|
309
|
+
const destEntry = currentState.projectPackages[0];
|
|
310
|
+
expect(typeof destEntry).toBe("object");
|
|
311
|
+
expect(destEntry).toMatchObject({
|
|
312
|
+
source: "npm:pi-flows",
|
|
313
|
+
extensions: ["a.ts"],
|
|
314
|
+
skills: [],
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("preserves filter object on path-source move", async () => {
|
|
319
|
+
const entry = { source: "..", extensions: ["foo.ts"] };
|
|
320
|
+
currentState.projectPackages = [entry];
|
|
321
|
+
|
|
322
|
+
await wrapper.move({
|
|
323
|
+
entry,
|
|
324
|
+
fromScope: "local",
|
|
325
|
+
fromCwd: "/proj",
|
|
326
|
+
toScope: "global",
|
|
327
|
+
});
|
|
328
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
329
|
+
|
|
330
|
+
const destEntry = currentState.globalPackages[0];
|
|
331
|
+
expect(destEntry).toMatchObject({
|
|
332
|
+
source: "/proj",
|
|
333
|
+
extensions: ["foo.ts"],
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── Partial-success: install OK, remove fails ──────────────────────────
|
|
338
|
+
|
|
339
|
+
it("partial success when install succeeds but remove from origin throws", async () => {
|
|
340
|
+
currentState.globalPackages = ["npm:pi-flows"];
|
|
341
|
+
currentFakePm.pm.removeAndPersist.mockRejectedValueOnce(new Error("remove blew up"));
|
|
342
|
+
|
|
343
|
+
const completions: any[] = [];
|
|
344
|
+
wrapper.setCompleteListener((r) => completions.push(r));
|
|
345
|
+
|
|
346
|
+
await wrapper.move({
|
|
347
|
+
entry: "npm:pi-flows",
|
|
348
|
+
fromScope: "global",
|
|
349
|
+
toScope: "local",
|
|
350
|
+
toCwd: "/proj",
|
|
351
|
+
});
|
|
352
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
353
|
+
|
|
354
|
+
const result = completions[0];
|
|
355
|
+
expect(result.success).toBe(true);
|
|
356
|
+
expect(result.action).toBe("move");
|
|
357
|
+
expect(result.partialSuccess).toBeDefined();
|
|
358
|
+
expect(result.partialSuccess.installed).toBe(true);
|
|
359
|
+
expect(result.partialSuccess.removed).toBe(false);
|
|
360
|
+
expect(result.partialSuccess.removeError).toMatch(/remove blew up/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ── Task 3.4: moveId propagation through progress events ───────────────
|
|
364
|
+
|
|
365
|
+
it("emits progress events tagged with the same moveId across both phases", async () => {
|
|
366
|
+
currentState.globalPackages = ["npm:pi-flows"];
|
|
367
|
+
|
|
368
|
+
const progressEvents: Array<{ opId: string; event: any; moveId?: string }> = [];
|
|
369
|
+
wrapper.setProgressListener((opId, event, moveId) => {
|
|
370
|
+
progressEvents.push({ opId, event, moveId });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// pi's setProgressCallback captures the wrapper's callback. Fire some
|
|
374
|
+
// progress events from the install phase via the captured callback.
|
|
375
|
+
let progressCb: any;
|
|
376
|
+
currentFakePm.pm.setProgressCallback.mockImplementation((cb: any) => {
|
|
377
|
+
progressCb = cb;
|
|
378
|
+
});
|
|
379
|
+
currentFakePm.pm.installAndPersist.mockImplementation(
|
|
380
|
+
async (source: string, { local }: { local: boolean }) => {
|
|
381
|
+
progressCb?.({ type: "start", action: "install", source });
|
|
382
|
+
progressCb?.({ type: "complete", action: "install", source });
|
|
383
|
+
if (local) currentState.projectPackages.push(source);
|
|
384
|
+
else currentState.globalPackages.push(source);
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
currentFakePm.pm.removeAndPersist.mockImplementation(
|
|
388
|
+
async (source: string, { local }: { local: boolean }) => {
|
|
389
|
+
progressCb?.({ type: "start", action: "remove", source });
|
|
390
|
+
progressCb?.({ type: "complete", action: "remove", source });
|
|
391
|
+
const arr = local ? currentState.projectPackages : currentState.globalPackages;
|
|
392
|
+
const idx = arr.findIndex((e: any) =>
|
|
393
|
+
typeof e === "string" ? e === source : e?.source === source,
|
|
394
|
+
);
|
|
395
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
396
|
+
return idx >= 0;
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const moveId = await wrapper.move({
|
|
401
|
+
entry: "npm:pi-flows",
|
|
402
|
+
fromScope: "global",
|
|
403
|
+
toScope: "local",
|
|
404
|
+
toCwd: "/proj",
|
|
405
|
+
});
|
|
406
|
+
await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
|
|
407
|
+
|
|
408
|
+
// All emitted progress events must carry the same moveId.
|
|
409
|
+
expect(progressEvents.length).toBeGreaterThanOrEqual(2);
|
|
410
|
+
for (const ev of progressEvents) {
|
|
411
|
+
expect(ev.moveId).toBe(moveId);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
@@ -5,7 +5,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
5
5
|
import Fastify from "fastify";
|
|
6
6
|
import type { FastifyInstance } from "fastify";
|
|
7
7
|
import { registerPackageRoutes } from "../routes/package-routes.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
PackageOperationBusyError,
|
|
10
|
+
AlreadyAtDestinationError,
|
|
11
|
+
InvalidMoveRequestError,
|
|
12
|
+
UnsupportedSourceForDestinationError,
|
|
13
|
+
} from "../package-manager-wrapper.js";
|
|
9
14
|
|
|
10
15
|
// Mock pi dependency (pulled transitively by package-manager-wrapper)
|
|
11
16
|
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
@@ -27,6 +32,7 @@ import { searchPackages, fetchReadme, PackageNotFoundError } from "../npm-search
|
|
|
27
32
|
function createMockWrapper() {
|
|
28
33
|
return {
|
|
29
34
|
run: vi.fn().mockResolvedValue("op-123"),
|
|
35
|
+
move: vi.fn().mockResolvedValue("move-456"),
|
|
30
36
|
listInstalled: vi.fn().mockReturnValue([{ source: "npm:pi-doom", scope: "user", filtered: false }]),
|
|
31
37
|
checkUpdates: vi.fn().mockResolvedValue([]),
|
|
32
38
|
isBusy: vi.fn().mockReturnValue(false),
|
|
@@ -88,11 +94,43 @@ describe("package-routes", () => {
|
|
|
88
94
|
});
|
|
89
95
|
|
|
90
96
|
describe("GET /api/packages/installed", () => {
|
|
91
|
-
it("returns installed packages", async () => {
|
|
97
|
+
it("returns installed packages with enrichment fields", async () => {
|
|
92
98
|
const res = await app.inject({ method: "GET", url: "/api/packages/installed?scope=global" });
|
|
93
99
|
expect(res.statusCode).toBe(200);
|
|
94
100
|
const body = JSON.parse(res.body);
|
|
95
|
-
|
|
101
|
+
const row = body.data[0];
|
|
102
|
+
expect(row.source).toBe("npm:pi-doom");
|
|
103
|
+
// Enrichment fields are present on every row.
|
|
104
|
+
expect(row).toHaveProperty("displayName");
|
|
105
|
+
expect(row).toHaveProperty("isRecommended");
|
|
106
|
+
expect(row).toHaveProperty("isBundled");
|
|
107
|
+
// pi-doom is not in RECOMMENDED_EXTENSIONS — falls back to basename.
|
|
108
|
+
expect(row.displayName).toBe("pi-doom");
|
|
109
|
+
expect(row.isRecommended).toBe(false);
|
|
110
|
+
expect(row.isBundled).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("matches a row to RECOMMENDED_EXTENSIONS by source", async () => {
|
|
114
|
+
wrapper.listInstalled.mockReturnValueOnce([
|
|
115
|
+
{ source: "npm:@tintinweb/pi-subagents", scope: "user", filtered: false },
|
|
116
|
+
]);
|
|
117
|
+
const res = await app.inject({ method: "GET", url: "/api/packages/installed?scope=global" });
|
|
118
|
+
const body = JSON.parse(res.body);
|
|
119
|
+
const row = body.data[0];
|
|
120
|
+
expect(row.isRecommended).toBe(true);
|
|
121
|
+
// displayName comes from the recommended manifest.
|
|
122
|
+
expect(row.displayName).toBe("@tintinweb/pi-subagents");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("missing installedPath does not break enrichment", async () => {
|
|
126
|
+
wrapper.listInstalled.mockReturnValueOnce([
|
|
127
|
+
{ source: "npm:weirdpkg", scope: "user", filtered: false },
|
|
128
|
+
]);
|
|
129
|
+
const res = await app.inject({ method: "GET", url: "/api/packages/installed?scope=global" });
|
|
130
|
+
expect(res.statusCode).toBe(200);
|
|
131
|
+
const body = JSON.parse(res.body);
|
|
132
|
+
expect(body.data[0].version).toBeUndefined();
|
|
133
|
+
expect(body.data[0].displayName).toBe("weirdpkg");
|
|
96
134
|
});
|
|
97
135
|
});
|
|
98
136
|
|
|
@@ -169,4 +207,99 @@ describe("package-routes", () => {
|
|
|
169
207
|
expect(body.data[0].source).toBe("npm:pi-doom");
|
|
170
208
|
});
|
|
171
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("POST /api/packages/move", () => {
|
|
212
|
+
it("returns 202 with moveId + phases for npm source", async () => {
|
|
213
|
+
const res = await app.inject({
|
|
214
|
+
method: "POST",
|
|
215
|
+
url: "/api/packages/move",
|
|
216
|
+
payload: { entry: "npm:pi-doom", fromScope: "global", toScope: "local", toCwd: "/proj" },
|
|
217
|
+
});
|
|
218
|
+
expect(res.statusCode).toBe(202);
|
|
219
|
+
const body = JSON.parse(res.body);
|
|
220
|
+
expect(body.success).toBe(true);
|
|
221
|
+
expect(body.data.moveId).toBe("move-456");
|
|
222
|
+
expect(body.data.phases).toEqual(["install", "remove"]);
|
|
223
|
+
expect(wrapper.move).toHaveBeenCalledWith({
|
|
224
|
+
entry: "npm:pi-doom",
|
|
225
|
+
fromScope: "global",
|
|
226
|
+
fromCwd: undefined,
|
|
227
|
+
toScope: "local",
|
|
228
|
+
toCwd: "/proj",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("returns settings-edit phase for relative-path source", async () => {
|
|
233
|
+
const res = await app.inject({
|
|
234
|
+
method: "POST",
|
|
235
|
+
url: "/api/packages/move",
|
|
236
|
+
payload: { entry: { source: "./vendor/x" }, fromScope: "local", fromCwd: "/proj", toScope: "global" },
|
|
237
|
+
});
|
|
238
|
+
expect(res.statusCode).toBe(202);
|
|
239
|
+
expect(JSON.parse(res.body).data.phases).toEqual(["settings-edit"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("returns 400 when entry is missing", async () => {
|
|
243
|
+
const res = await app.inject({
|
|
244
|
+
method: "POST",
|
|
245
|
+
url: "/api/packages/move",
|
|
246
|
+
payload: { fromScope: "global", toScope: "local", toCwd: "/proj" },
|
|
247
|
+
});
|
|
248
|
+
expect(res.statusCode).toBe(400);
|
|
249
|
+
expect(JSON.parse(res.body).error).toMatch(/entry is required/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("returns 400 when fromScope/toScope missing", async () => {
|
|
253
|
+
const res = await app.inject({
|
|
254
|
+
method: "POST",
|
|
255
|
+
url: "/api/packages/move",
|
|
256
|
+
payload: { entry: "npm:foo" },
|
|
257
|
+
});
|
|
258
|
+
expect(res.statusCode).toBe(400);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("returns 400 invalid_request from InvalidMoveRequestError", async () => {
|
|
262
|
+
wrapper.move = vi.fn().mockRejectedValue(new InvalidMoveRequestError("same scope"));
|
|
263
|
+
const res = await app.inject({
|
|
264
|
+
method: "POST",
|
|
265
|
+
url: "/api/packages/move",
|
|
266
|
+
payload: { entry: "npm:foo", fromScope: "global", toScope: "global" },
|
|
267
|
+
});
|
|
268
|
+
expect(res.statusCode).toBe(400);
|
|
269
|
+
expect(JSON.parse(res.body).code).toBe("invalid_request");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("returns 400 unsupported_source_for_destination", async () => {
|
|
273
|
+
wrapper.move = vi.fn().mockRejectedValue(new UnsupportedSourceForDestinationError("need fromCwd"));
|
|
274
|
+
const res = await app.inject({
|
|
275
|
+
method: "POST",
|
|
276
|
+
url: "/api/packages/move",
|
|
277
|
+
payload: { entry: "..", fromScope: "local", toScope: "global" },
|
|
278
|
+
});
|
|
279
|
+
expect(res.statusCode).toBe(400);
|
|
280
|
+
expect(JSON.parse(res.body).code).toBe("unsupported_source_for_destination");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns 409 already_at_destination", async () => {
|
|
284
|
+
wrapper.move = vi.fn().mockRejectedValue(new AlreadyAtDestinationError("npm:foo", "global"));
|
|
285
|
+
const res = await app.inject({
|
|
286
|
+
method: "POST",
|
|
287
|
+
url: "/api/packages/move",
|
|
288
|
+
payload: { entry: "npm:foo", fromScope: "local", fromCwd: "/p", toScope: "global" },
|
|
289
|
+
});
|
|
290
|
+
expect(res.statusCode).toBe(409);
|
|
291
|
+
expect(JSON.parse(res.body).code).toBe("already_at_destination");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("returns 409 operation_in_flight", async () => {
|
|
295
|
+
wrapper.move = vi.fn().mockRejectedValue(new PackageOperationBusyError());
|
|
296
|
+
const res = await app.inject({
|
|
297
|
+
method: "POST",
|
|
298
|
+
url: "/api/packages/move",
|
|
299
|
+
payload: { entry: "npm:foo", fromScope: "global", toScope: "local", toCwd: "/p" },
|
|
300
|
+
});
|
|
301
|
+
expect(res.statusCode).toBe(409);
|
|
302
|
+
expect(JSON.parse(res.body).code).toBe("operation_in_flight");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
172
305
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseSourceKind, computeIdentity } from "../package-source-helpers.js";
|
|
4
|
+
|
|
5
|
+
describe("parseSourceKind", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
["npm:foo", "npm"],
|
|
8
|
+
["npm:@scope/pkg", "npm"],
|
|
9
|
+
["npm:@scope/pkg@1.2.3", "npm"],
|
|
10
|
+
["npm:foo@^1.0.0", "npm"],
|
|
11
|
+
])("npm:* → npm (%s)", (s, expected) => {
|
|
12
|
+
expect(parseSourceKind(s)).toBe(expected);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it.each([
|
|
16
|
+
["git:github.com/x/y", "git"],
|
|
17
|
+
["git:github.com/x/y@v1", "git"],
|
|
18
|
+
["git:git@github.com:x/y", "git"],
|
|
19
|
+
["git:git@github.com:x/y@v1.0.0", "git"],
|
|
20
|
+
])("git:* → git (%s)", (s, expected) => {
|
|
21
|
+
expect(parseSourceKind(s)).toBe(expected);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it.each([
|
|
25
|
+
["https://github.com/x/y", "https"],
|
|
26
|
+
["https://github.com/x/y@v1", "https"],
|
|
27
|
+
["http://example.com/repo", "https"],
|
|
28
|
+
["ssh://git@github.com/x/y", "https"],
|
|
29
|
+
["git://github.com/x/y", "https"],
|
|
30
|
+
])("protocol url → https (%s)", (s, expected) => {
|
|
31
|
+
expect(parseSourceKind(s)).toBe(expected);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("/abs → abs-path", () => {
|
|
35
|
+
expect(parseSourceKind("/abs/path")).toBe("abs-path");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it.each([".", "..", "./foo", "../foo", "./foo/bar"])(
|
|
39
|
+
"%s → rel-path",
|
|
40
|
+
(s) => {
|
|
41
|
+
expect(parseSourceKind(s)).toBe("rel-path");
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
it("Windows abs path → abs-path", () => {
|
|
46
|
+
expect(parseSourceKind("C:\\abs\\path")).toBe("abs-path");
|
|
47
|
+
expect(parseSourceKind("C:/abs/path")).toBe("abs-path");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("computeIdentity", () => {
|
|
52
|
+
it("npm: identity = bare package name without version", () => {
|
|
53
|
+
expect(computeIdentity("npm:foo")).toBe("npm:foo");
|
|
54
|
+
expect(computeIdentity("npm:foo@1.2.3")).toBe("npm:foo");
|
|
55
|
+
expect(computeIdentity("npm:@scope/pkg")).toBe("npm:@scope/pkg");
|
|
56
|
+
expect(computeIdentity("npm:@scope/pkg@1.2.3")).toBe("npm:@scope/pkg");
|
|
57
|
+
expect(computeIdentity("npm:@scope/pkg@^1.0.0")).toBe("npm:@scope/pkg");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("git: identity = repo url without ref", () => {
|
|
61
|
+
expect(computeIdentity("git:github.com/x/y")).toBe(
|
|
62
|
+
"git:github.com/x/y",
|
|
63
|
+
);
|
|
64
|
+
expect(computeIdentity("git:github.com/x/y@v1.2.3")).toBe(
|
|
65
|
+
"git:github.com/x/y",
|
|
66
|
+
);
|
|
67
|
+
expect(computeIdentity("git:git@github.com:x/y@v1")).toBe(
|
|
68
|
+
"git:git@github.com:x/y",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("https/ssh url identity strips trailing @ref", () => {
|
|
73
|
+
expect(computeIdentity("https://github.com/x/y")).toBe(
|
|
74
|
+
"https://github.com/x/y",
|
|
75
|
+
);
|
|
76
|
+
expect(computeIdentity("https://github.com/x/y@v1")).toBe(
|
|
77
|
+
"https://github.com/x/y",
|
|
78
|
+
);
|
|
79
|
+
expect(computeIdentity("ssh://git@github.com/x/y@v1")).toBe(
|
|
80
|
+
"ssh://git@github.com/x/y",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("absolute-path identity = the path itself", () => {
|
|
85
|
+
expect(computeIdentity("/abs/path")).toBe("/abs/path");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("relative-path identity = path resolved against settingsDir", () => {
|
|
89
|
+
const cwd = "/proj/.pi";
|
|
90
|
+
expect(computeIdentity("..", cwd)).toBe(path.resolve("/proj/.pi/.."));
|
|
91
|
+
expect(computeIdentity("./foo", cwd)).toBe(
|
|
92
|
+
path.resolve("/proj/.pi/foo"),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("relative-path without settingsDir falls back to literal", () => {
|
|
97
|
+
// Defensive: if we don't know the anchor, return a stable string
|
|
98
|
+
// that won't accidentally match any other entry's identity.
|
|
99
|
+
expect(computeIdentity("..")).toBe("..");
|
|
100
|
+
});
|
|
101
|
+
});
|