@danypops/pi-packed 0.19.7 → 0.19.10
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/dist/client.d.ts +109 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1 -0
- package/dist/protocol.d.ts +221 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/extension/src/{permission.ts → approval/permission.ts} +1 -1
- package/extension/src/index.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
- package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
- package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
- package/extension/src/tool-output.ts +1 -1
- package/extension/src/tools.ts +2 -2
- package/extension/src/tui.ts +4 -4
- package/package.json +31 -8
- package/service/schema/pi-setup-v1.schema.json +70 -0
- package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
- package/service/src/adoption/advisories.ts +268 -0
- package/service/src/adoption/check.ts +872 -0
- package/service/src/adoption/commit-freshness.ts +167 -0
- package/service/src/adoption/doctor.ts +135 -0
- package/service/src/adoption/install-validation.ts +187 -0
- package/service/src/adoption/pack.ts +291 -0
- package/service/src/adoption/score.ts +466 -0
- package/service/src/adoption/smoke-child.ts +113 -0
- package/service/src/adoption/smoke.ts +282 -0
- package/service/src/cli/cli.ts +926 -0
- package/service/src/daemon/cleanup.ts +76 -0
- package/service/src/daemon/client.ts +412 -0
- package/service/src/daemon/daemon-service.ts +249 -0
- package/service/src/daemon/daemon.ts +110 -0
- package/service/src/daemon/service.ts +664 -0
- package/service/src/daemon/watcher.ts +92 -0
- package/service/src/index/build-index.ts +256 -0
- package/service/src/packages/catalog.ts +61 -0
- package/service/src/packages/db.ts +224 -0
- package/service/src/packages/install.ts +60 -0
- package/service/src/packages/installed.ts +123 -0
- package/service/src/packages/package.ts +141 -0
- package/service/src/packages/resources.ts +203 -0
- package/service/src/pi/pi-version.ts +171 -0
- package/service/src/public/atomic-json.ts +32 -0
- package/service/src/public/client.ts +277 -0
- package/service/src/public/protocol.ts +169 -0
- package/service/src/publish/publish.ts +855 -0
- package/service/src/registry/registry.ts +246 -0
- package/service/src/security/security.ts +128 -0
- package/service/src/self-update/self-update.ts +148 -0
- package/service/src/setup/setup.ts +761 -0
- package/service/src/shared/atomic-json.ts +33 -0
- package/service/src/shared/cache.ts +21 -0
- package/service/src/shared/constants.ts +73 -0
- package/service/src/shared/log.ts +21 -0
- package/service/src/shared/paths.ts +88 -0
- package/service/src/shared/state.ts +15 -0
- package/service/src/shared/version.ts +46 -0
- package/service/test/advisories.test.ts +287 -0
- package/service/test/check.test.ts +368 -0
- package/service/test/cleanup.test.ts +220 -0
- package/service/test/cli.test.ts +1303 -0
- package/service/test/core.test.ts +181 -0
- package/service/test/daemon-kit-migration.test.ts +181 -0
- package/service/test/daemon-service.test.ts +238 -0
- package/service/test/db.test.ts +178 -0
- package/service/test/doctor.test.ts +234 -0
- package/service/test/domain.test.ts +291 -0
- package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
- package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
- package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
- package/service/test/index.test.ts +353 -0
- package/service/test/install-validation.test.ts +114 -0
- package/service/test/install.test.ts +113 -0
- package/service/test/log.test.ts +42 -0
- package/service/test/pack-score.test.ts +513 -0
- package/service/test/pi-version.test.ts +318 -0
- package/service/test/public-boundary.test.ts +54 -0
- package/service/test/public-client.test.ts +127 -0
- package/service/test/public-consumer.ts +8 -0
- package/service/test/publish.test.ts +333 -0
- package/service/test/registry-contract.test.ts +148 -0
- package/service/test/resources.test.ts +255 -0
- package/service/test/security.test.ts +89 -0
- package/service/test/self-update.test.ts +257 -0
- package/service/test/service.test.ts +555 -0
- package/service/test/setup.test.ts +375 -0
- package/service/test/smoke.test.ts +118 -0
- package/service/test/version.test.ts +37 -0
- package/service/tsconfig.consumer.json +13 -0
- package/service/tsconfig.public.json +12 -0
- /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
- /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ServiceSpec } from "@danypops/vehicle-server/service";
|
|
6
|
+
import type { DaemonServiceInstaller } from "../src/daemon/daemon-service.ts";
|
|
7
|
+
import { createApp, type Deps } from "../src/daemon/service.ts";
|
|
8
|
+
import { saveUpdates } from "../src/daemon/watcher.ts";
|
|
9
|
+
import { dbPath, openDb, replaceAll } from "../src/packages/db.ts";
|
|
10
|
+
import type { Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
|
|
11
|
+
|
|
12
|
+
class FakeRegistry implements Registry {
|
|
13
|
+
searchCalls = 0;
|
|
14
|
+
lastQuery = "";
|
|
15
|
+
lastLimit = 0;
|
|
16
|
+
constructor(
|
|
17
|
+
private results: Pkg[] = [],
|
|
18
|
+
private total = 0,
|
|
19
|
+
private failWith?: string,
|
|
20
|
+
) {}
|
|
21
|
+
async search(query: string, limit: number): Promise<SearchPage> {
|
|
22
|
+
this.searchCalls++;
|
|
23
|
+
this.lastQuery = query;
|
|
24
|
+
this.lastLimit = limit;
|
|
25
|
+
if (this.failWith) throw new Error(this.failWith);
|
|
26
|
+
return { results: this.results, total: this.total };
|
|
27
|
+
}
|
|
28
|
+
async searchPage(): Promise<SearchPage> {
|
|
29
|
+
return { results: this.results, total: this.total };
|
|
30
|
+
}
|
|
31
|
+
async searchAll(): Promise<import("../src/packages/package.ts").Pkg[]> {
|
|
32
|
+
return this.results;
|
|
33
|
+
}
|
|
34
|
+
async info(name: string): Promise<PkgInfo> {
|
|
35
|
+
return { name, version: "1.0.0" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class FakeInstaller implements Installer {
|
|
40
|
+
gotSource = "";
|
|
41
|
+
removed = "";
|
|
42
|
+
updated = "";
|
|
43
|
+
output = "ok";
|
|
44
|
+
fail = false;
|
|
45
|
+
async install(source: string): Promise<string> {
|
|
46
|
+
this.gotSource = source;
|
|
47
|
+
if (this.fail) throw new Error("exit 1\nnpm ERR! 404");
|
|
48
|
+
return this.output;
|
|
49
|
+
}
|
|
50
|
+
async remove(source: string): Promise<string> {
|
|
51
|
+
this.removed = source;
|
|
52
|
+
return this.output;
|
|
53
|
+
}
|
|
54
|
+
updateOutcome: Partial<UpdateOutcome> = {};
|
|
55
|
+
async update(source: string): Promise<UpdateOutcome> {
|
|
56
|
+
this.updated = source;
|
|
57
|
+
return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateOutcome };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class FakeDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
62
|
+
gotPiHome = "";
|
|
63
|
+
gotSource = "";
|
|
64
|
+
resolveFailure: string | undefined;
|
|
65
|
+
installFailure: string | undefined;
|
|
66
|
+
restartGotPiHome = "";
|
|
67
|
+
restartGotSource = "";
|
|
68
|
+
restartResolveFailure: string | undefined;
|
|
69
|
+
restartReason: string | undefined;
|
|
70
|
+
restarted = true;
|
|
71
|
+
spec: ServiceSpec = { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" };
|
|
72
|
+
install(
|
|
73
|
+
piHome: string,
|
|
74
|
+
source: string,
|
|
75
|
+
): { ok: true; result: { installed: true } | { installed: false; reason: string }; spec: ServiceSpec } | { ok: false; reason: string } {
|
|
76
|
+
this.gotPiHome = piHome;
|
|
77
|
+
this.gotSource = source;
|
|
78
|
+
if (this.resolveFailure) return { ok: false, reason: this.resolveFailure };
|
|
79
|
+
if (this.installFailure) return { ok: true, result: { installed: false, reason: this.installFailure }, spec: this.spec };
|
|
80
|
+
return { ok: true, result: { installed: true }, spec: this.spec };
|
|
81
|
+
}
|
|
82
|
+
restart(
|
|
83
|
+
piHome: string,
|
|
84
|
+
source: string,
|
|
85
|
+
): { ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean } {
|
|
86
|
+
this.restartGotPiHome = piHome;
|
|
87
|
+
this.restartGotSource = source;
|
|
88
|
+
if (this.restartResolveFailure) return { ok: false, reason: this.restartResolveFailure };
|
|
89
|
+
return { ok: true, restarted: this.restarted, reason: this.restartReason, spec: this.spec };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deps(over: Partial<Deps> = {}): Deps {
|
|
94
|
+
return {
|
|
95
|
+
reg: new FakeRegistry(),
|
|
96
|
+
inst: new FakeInstaller(),
|
|
97
|
+
token: "test-token",
|
|
98
|
+
stateDir: mkdtempSync(join(tmpdir(), "packed-")),
|
|
99
|
+
...over,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const auth = { authorization: "Bearer test-token" };
|
|
104
|
+
|
|
105
|
+
describe("service app", () => {
|
|
106
|
+
it("GET /health", async () => {
|
|
107
|
+
const app = createApp(deps());
|
|
108
|
+
const res = await app.fetch(new Request("http://x/health", { headers: auth }));
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect(((await res.json()) as any).ok).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("requires bearer token", async () => {
|
|
114
|
+
const app = createApp(deps());
|
|
115
|
+
for (const headers of [{}, { authorization: "Bearer wrong" }] as Record<string, string>[]) {
|
|
116
|
+
const res = await app.fetch(new Request("http://x/health", { headers }));
|
|
117
|
+
expect(res.status).toBe(401);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reads and updates mutation approval with a secure default", async () => {
|
|
122
|
+
const app = createApp(deps());
|
|
123
|
+
const initial = await app.fetch(new Request("http://x/security", { headers: auth }));
|
|
124
|
+
expect(await initial.json()).toEqual({ mutationApproval: "always" });
|
|
125
|
+
const denied = await app.fetch(
|
|
126
|
+
new Request("http://x/security", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify({ mutationApproval: "never" }),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
expect(denied.status).toBe(403);
|
|
133
|
+
expect(await denied.json()).toMatchObject({ code: "approval_required", operation: "security.write" });
|
|
134
|
+
const updated = await app.fetch(
|
|
135
|
+
new Request("http://x/security", {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
138
|
+
body: JSON.stringify({ mutationApproval: "never", approved: true }),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
expect(await updated.json()).toEqual({ mutationApproval: "never" });
|
|
142
|
+
const invalid = await app.fetch(
|
|
143
|
+
new Request("http://x/security", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
146
|
+
body: JSON.stringify({ mutationApproval: "sometimes", approved: true }),
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
expect(invalid.status).toBe(400);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("GET /search scopes query and clamps limit", async () => {
|
|
153
|
+
const reg = new FakeRegistry([{ name: "pi-lsp", version: "0.3.0" }], 42);
|
|
154
|
+
const app = createApp(deps({ reg }));
|
|
155
|
+
const res = await app.fetch(new Request("http://x/search?q=lsp&limit=999", { headers: auth }));
|
|
156
|
+
expect(res.status).toBe(200);
|
|
157
|
+
expect(reg.lastQuery).toBe("keywords:pi-package lsp");
|
|
158
|
+
expect(reg.lastLimit).toBe(50);
|
|
159
|
+
const body = (await res.json()) as any;
|
|
160
|
+
expect(body.total).toBe(42);
|
|
161
|
+
expect(body.results[0].name).toBe("pi-lsp");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("caches GET responses (second call skips upstream)", async () => {
|
|
165
|
+
const reg = new FakeRegistry([{ name: "x", version: "1" }], 1);
|
|
166
|
+
const app = createApp(deps({ reg }));
|
|
167
|
+
for (let i = 0; i < 2; i++) {
|
|
168
|
+
await app.fetch(new Request("http://x/search?q=cache", { headers: auth }));
|
|
169
|
+
}
|
|
170
|
+
expect(reg.searchCalls).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("upstream error → 502 with message", async () => {
|
|
174
|
+
const reg = new FakeRegistry([], 0, "registry down");
|
|
175
|
+
const app = createApp(deps({ reg }));
|
|
176
|
+
const res = await app.fetch(new Request("http://x/search?q=boom", { headers: auth }));
|
|
177
|
+
expect(res.status).toBe(502);
|
|
178
|
+
expect(await res.text()).toContain("registry down");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("GET /info", async () => {
|
|
182
|
+
const app = createApp(deps());
|
|
183
|
+
const res = await app.fetch(new Request("http://x/info?name=pi-lsp", { headers: auth }));
|
|
184
|
+
expect(res.status).toBe(200);
|
|
185
|
+
expect(((await res.json()) as any).name).toBe("pi-lsp");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("POST /install rejects invalid sources", async () => {
|
|
189
|
+
const inst = new FakeInstaller();
|
|
190
|
+
const app = createApp(deps({ inst }));
|
|
191
|
+
for (const source of ["foo; rm -rf ~", "npm:foo && curl x|sh", "$(whoami)", "", "npm:"]) {
|
|
192
|
+
const res = await app.fetch(
|
|
193
|
+
new Request("http://x/install", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
196
|
+
body: JSON.stringify({ source }),
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
expect(res.status).toBe(400);
|
|
200
|
+
}
|
|
201
|
+
expect(inst.gotSource).toBe("");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("guards install and remove at the authenticated daemon boundary", async () => {
|
|
205
|
+
const inst = new FakeInstaller();
|
|
206
|
+
const app = createApp(deps({ inst }));
|
|
207
|
+
for (const [path, body] of [
|
|
208
|
+
["/install", { source: "npm:foo" }],
|
|
209
|
+
["/remove", { name: "foo" }],
|
|
210
|
+
] as const) {
|
|
211
|
+
const response = await app.fetch(
|
|
212
|
+
new Request(`http://x${path}`, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
215
|
+
body: JSON.stringify(body),
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
expect(response.status).toBe(403);
|
|
219
|
+
expect(await response.json()).toMatchObject({ code: "approval_required" });
|
|
220
|
+
}
|
|
221
|
+
expect(inst.gotSource).toBe("");
|
|
222
|
+
expect(inst.removed).toBe("");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("POST /install accepts valid sources, reports failures in-band", async () => {
|
|
226
|
+
const inst = new FakeInstaller();
|
|
227
|
+
const app = createApp(deps({ inst }));
|
|
228
|
+
for (const source of ["npm:foo", "npm:@scope/pkg@1.2.3", "git:github.com/u/r@v1", "https://github.com/u/r"]) {
|
|
229
|
+
const res = await app.fetch(
|
|
230
|
+
new Request("http://x/install", {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
233
|
+
body: JSON.stringify({ source, approved: true }),
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
expect(res.status).toBe(200);
|
|
237
|
+
expect(((await res.json()) as any).ok).toBe(true);
|
|
238
|
+
}
|
|
239
|
+
expect(inst.gotSource).toBe("https://github.com/u/r");
|
|
240
|
+
|
|
241
|
+
inst.fail = true;
|
|
242
|
+
const res = await app.fetch(
|
|
243
|
+
new Request("http://x/install", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
246
|
+
body: JSON.stringify({ source: "npm:missing", approved: true }),
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
expect(res.status).toBe(200);
|
|
250
|
+
const body = (await res.json()) as any;
|
|
251
|
+
expect(body.ok).toBe(false);
|
|
252
|
+
expect(body.output).toContain("npm ERR! 404");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("POST /install-service rejects invalid sources and requires approval, matching /install's own guard", async () => {
|
|
256
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
257
|
+
const app = createApp(deps({ daemonServiceInstaller: svc }));
|
|
258
|
+
|
|
259
|
+
const invalid = await app.fetch(
|
|
260
|
+
new Request("http://x/install-service", {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
263
|
+
body: JSON.stringify({ source: "npm:foo && curl x|sh" }),
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
expect(invalid.status).toBe(400);
|
|
267
|
+
expect(svc.gotSource).toBe("");
|
|
268
|
+
|
|
269
|
+
const unapproved = await app.fetch(
|
|
270
|
+
new Request("http://x/install-service", {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
273
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon" }),
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
expect(unapproved.status).toBe(403);
|
|
277
|
+
expect(await unapproved.json()).toMatchObject({ code: "approval_required" });
|
|
278
|
+
expect(svc.gotSource).toBe("");
|
|
279
|
+
|
|
280
|
+
// A structurally valid but non-npm source is approval-gated the same as
|
|
281
|
+
// any other source -- it fails closed only once the resolver itself runs
|
|
282
|
+
// (a resolveDaemonServiceSpec responsibility, not this route's own regex).
|
|
283
|
+
const gitApproved = await app.fetch(
|
|
284
|
+
new Request("http://x/install-service", {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
287
|
+
body: JSON.stringify({ source: "git:github.com/u/r@v1", approved: true }),
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
expect(gitApproved.status).toBe(200);
|
|
291
|
+
expect(((await gitApproved.json()) as any).ok).toBe(true); // FakeDaemonServiceInstaller doesn't itself enforce the npm-only rule; resolveDaemonServiceSpec's own unit tests cover that.
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("POST /install-service installs a real service once approved, reporting the resolved spec", async () => {
|
|
295
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
296
|
+
const piHome = mkdtempSync(join(tmpdir(), "packed-pi-"));
|
|
297
|
+
const app = createApp(deps({ daemonServiceInstaller: svc, piHome }));
|
|
298
|
+
|
|
299
|
+
const res = await app.fetch(
|
|
300
|
+
new Request("http://x/install-service", {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
303
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon", approved: true }),
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
expect(res.status).toBe(200);
|
|
307
|
+
const body = (await res.json()) as any;
|
|
308
|
+
expect(body.ok).toBe(true);
|
|
309
|
+
expect(body.output).toContain("probe");
|
|
310
|
+
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" });
|
|
311
|
+
expect(svc.gotPiHome).toBe(piHome);
|
|
312
|
+
expect(svc.gotSource).toBe("npm:web-spider-daemon");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("POST /install-service reports a resolution failure (no manifest) or an install failure (e.g. unsupported init system) in-band, not as an HTTP error", async () => {
|
|
316
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
317
|
+
svc.resolveFailure = "web-spider-daemon does not declare a packed.daemonService manifest";
|
|
318
|
+
const app = createApp(deps({ daemonServiceInstaller: svc }));
|
|
319
|
+
|
|
320
|
+
const resolutionFailure = await app.fetch(
|
|
321
|
+
new Request("http://x/install-service", {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
324
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon", approved: true }),
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
expect(resolutionFailure.status).toBe(200);
|
|
328
|
+
expect(((await resolutionFailure.json()) as any).ok).toBe(false);
|
|
329
|
+
|
|
330
|
+
svc.resolveFailure = undefined;
|
|
331
|
+
svc.installFailure = "no supported Linux init system was detected";
|
|
332
|
+
const installFailure = await app.fetch(
|
|
333
|
+
new Request("http://x/install-service", {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon", approved: true }),
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
expect(installFailure.status).toBe(200);
|
|
340
|
+
const body = (await installFailure.json()) as any;
|
|
341
|
+
expect(body.ok).toBe(false);
|
|
342
|
+
expect(body.output).toContain("no supported Linux init system");
|
|
343
|
+
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("POST /restart-service rejects invalid sources and requires approval, matching /install-service's own guard", async () => {
|
|
347
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
348
|
+
const app = createApp(deps({ daemonServiceInstaller: svc }));
|
|
349
|
+
|
|
350
|
+
const invalid = await app.fetch(
|
|
351
|
+
new Request("http://x/restart-service", {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
354
|
+
body: JSON.stringify({ source: "npm:foo && curl x|sh" }),
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
expect(invalid.status).toBe(400);
|
|
358
|
+
expect(svc.restartGotSource).toBe("");
|
|
359
|
+
|
|
360
|
+
const unapproved = await app.fetch(
|
|
361
|
+
new Request("http://x/restart-service", {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
364
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon" }),
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
expect(unapproved.status).toBe(403);
|
|
368
|
+
expect(await unapproved.json()).toMatchObject({ code: "approval_required" });
|
|
369
|
+
expect(svc.restartGotSource).toBe("");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("POST /restart-service restarts a real service once approved, reporting the resolved spec", async () => {
|
|
373
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
374
|
+
const piHome = mkdtempSync(join(tmpdir(), "packed-pi-"));
|
|
375
|
+
const app = createApp(deps({ daemonServiceInstaller: svc, piHome }));
|
|
376
|
+
|
|
377
|
+
const res = await app.fetch(
|
|
378
|
+
new Request("http://x/restart-service", {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
381
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon", approved: true }),
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
expect(res.status).toBe(200);
|
|
385
|
+
const body = (await res.json()) as any;
|
|
386
|
+
expect(body.ok).toBe(true);
|
|
387
|
+
expect(body.restarted).toBe(true);
|
|
388
|
+
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" });
|
|
389
|
+
expect(svc.restartGotPiHome).toBe(piHome);
|
|
390
|
+
expect(svc.restartGotSource).toBe("npm:web-spider-daemon");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("POST /restart-service reports a resolution failure or a no-op (no registered service) in-band, not as an HTTP error", async () => {
|
|
394
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
395
|
+
svc.restartResolveFailure = "web-spider-daemon does not declare a packed.daemonService manifest";
|
|
396
|
+
const app = createApp(deps({ daemonServiceInstaller: svc }));
|
|
397
|
+
|
|
398
|
+
const resolutionFailure = await app.fetch(
|
|
399
|
+
new Request("http://x/restart-service", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
402
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon", approved: true }),
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
expect(resolutionFailure.status).toBe(200);
|
|
406
|
+
expect(((await resolutionFailure.json()) as any).ok).toBe(false);
|
|
407
|
+
|
|
408
|
+
svc.restartResolveFailure = undefined;
|
|
409
|
+
svc.restarted = false;
|
|
410
|
+
svc.restartReason = "no persistent service is registered for web-spider-daemon";
|
|
411
|
+
const noop = await app.fetch(
|
|
412
|
+
new Request("http://x/restart-service", {
|
|
413
|
+
method: "POST",
|
|
414
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
415
|
+
body: JSON.stringify({ source: "npm:web-spider-daemon", approved: true }),
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
expect(noop.status).toBe(200);
|
|
419
|
+
const body = (await noop.json()) as any;
|
|
420
|
+
expect(body.ok).toBe(true);
|
|
421
|
+
expect(body.restarted).toBe(false);
|
|
422
|
+
expect(body.output).toContain("no persistent service is registered");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("POST /update validates, authorizes, and delegates one Pi package source", async () => {
|
|
426
|
+
const inst = new FakeInstaller();
|
|
427
|
+
const app = createApp(deps({ inst }));
|
|
428
|
+
const denied = await app.fetch(
|
|
429
|
+
new Request("http://x/update", {
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
432
|
+
body: JSON.stringify({ source: "npm:pi-lsp" }),
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
expect(denied.status).toBe(403);
|
|
436
|
+
const allowed = await app.fetch(
|
|
437
|
+
new Request("http://x/update", {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
440
|
+
body: JSON.stringify({ source: "npm:pi-lsp", approved: true }),
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
expect(allowed.status).toBe(200);
|
|
444
|
+
expect(await allowed.json()).toEqual({
|
|
445
|
+
ok: true,
|
|
446
|
+
source: "npm:pi-lsp",
|
|
447
|
+
output: "ok",
|
|
448
|
+
reloadRequired: true,
|
|
449
|
+
alreadyUpToDate: false,
|
|
450
|
+
pinned: false,
|
|
451
|
+
});
|
|
452
|
+
expect(inst.updated).toBe("npm:pi-lsp");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("POST /update reports an honest no-op instead of trusting pi's always-0-exit-code text", async () => {
|
|
456
|
+
const inst = new FakeInstaller();
|
|
457
|
+
inst.updateOutcome = { reloadRequired: false, alreadyUpToDate: true, pinned: true, previousVersion: "1.0.0", currentVersion: "1.0.0" };
|
|
458
|
+
const app = createApp(deps({ inst }));
|
|
459
|
+
const res = await app.fetch(
|
|
460
|
+
new Request("http://x/update", {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
463
|
+
body: JSON.stringify({ source: "npm:pi-lsp@1.0.0", approved: true }),
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
expect(await res.json()).toEqual({
|
|
467
|
+
ok: true,
|
|
468
|
+
source: "npm:pi-lsp@1.0.0",
|
|
469
|
+
output: "ok",
|
|
470
|
+
reloadRequired: false,
|
|
471
|
+
alreadyUpToDate: true,
|
|
472
|
+
pinned: true,
|
|
473
|
+
previousVersion: "1.0.0",
|
|
474
|
+
currentVersion: "1.0.0",
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("GET /updates serves the watcher snapshot", async () => {
|
|
479
|
+
const d = deps();
|
|
480
|
+
await saveUpdates(d.stateDir, {
|
|
481
|
+
checkedAt: new Date().toISOString(),
|
|
482
|
+
updates: [{ name: "a", installed: "1", latest: "2", detectedAt: "" }],
|
|
483
|
+
});
|
|
484
|
+
const app = createApp(d);
|
|
485
|
+
const res = await app.fetch(new Request("http://x/updates", { headers: auth }));
|
|
486
|
+
expect(res.status).toBe(200);
|
|
487
|
+
expect(((await res.json()) as any).updates[0].latest).toBe("2");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("GET /installed lists packages from pi settings", async () => {
|
|
491
|
+
const piHome = mkdtempSync(join(tmpdir(), "packed-pi-"));
|
|
492
|
+
writeFileSync(
|
|
493
|
+
join(piHome, "settings.json"),
|
|
494
|
+
JSON.stringify({ packages: ["npm:pi-extension-manager@0.8.2", { source: "npm:obj@2.0.0" }] }),
|
|
495
|
+
);
|
|
496
|
+
const d = deps({ piHome });
|
|
497
|
+
const app = createApp(d);
|
|
498
|
+
const res = await app.fetch(new Request("http://x/installed", { headers: auth }));
|
|
499
|
+
expect(res.status).toBe(200);
|
|
500
|
+
const body = (await res.json()) as any;
|
|
501
|
+
expect(body.map((p: { name: string }) => p.name)).toEqual(["pi-extension-manager", "obj"]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("POST /remove validates bare names and reports in-band", async () => {
|
|
505
|
+
const inst = new FakeInstaller();
|
|
506
|
+
const app = createApp(deps({ inst }));
|
|
507
|
+
for (const name of ["npm:foo", "foo; rm -rf ~", ""]) {
|
|
508
|
+
const res = await app.fetch(
|
|
509
|
+
new Request("http://x/remove", {
|
|
510
|
+
method: "POST",
|
|
511
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
512
|
+
body: JSON.stringify({ name, approved: true }),
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
expect(res.status).toBe(400);
|
|
516
|
+
}
|
|
517
|
+
expect(inst.removed).toBe("");
|
|
518
|
+
|
|
519
|
+
const res = await app.fetch(
|
|
520
|
+
new Request("http://x/remove", {
|
|
521
|
+
method: "POST",
|
|
522
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
523
|
+
body: JSON.stringify({ name: "pi-lsp", approved: true }),
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
expect(res.status).toBe(200);
|
|
527
|
+
expect(inst.removed).toBe("npm:pi-lsp");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("GET /catalog serves the SQLite mirror", async () => {
|
|
531
|
+
const d = deps();
|
|
532
|
+
const db = openDb(dbPath(d.stateDir));
|
|
533
|
+
replaceAll(db, [{ name: "a", version: "1" }], "test");
|
|
534
|
+
db.close();
|
|
535
|
+
const app = createApp(d);
|
|
536
|
+
const res = await app.fetch(new Request("http://x/catalog", { headers: auth }));
|
|
537
|
+
expect(res.status).toBe(200);
|
|
538
|
+
const body = (await res.json()) as any;
|
|
539
|
+
expect(body.packages[0].name).toBe("a");
|
|
540
|
+
expect(body.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("GET /search?offline=1 queries the mirror", async () => {
|
|
544
|
+
const d = deps();
|
|
545
|
+
const db = openDb(dbPath(d.stateDir));
|
|
546
|
+
replaceAll(db, [{ name: "pi-lsp", version: "1", description: "LSP tools" }], "test");
|
|
547
|
+
db.close();
|
|
548
|
+
const app = createApp(d);
|
|
549
|
+
const res = await app.fetch(new Request("http://x/search?q=lsp&offline=1", { headers: auth }));
|
|
550
|
+
expect(res.status).toBe(200);
|
|
551
|
+
const body = (await res.json()) as any;
|
|
552
|
+
expect(body.offline).toBe(true);
|
|
553
|
+
expect(body.results[0].name).toBe("pi-lsp");
|
|
554
|
+
});
|
|
555
|
+
});
|