@danypops/pi-packed 0.19.9 → 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/approval/permission.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/tabs/security-tui.ts +1 -1
- package/extension/src/tool-output.ts +1 -1
- 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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import type { Server } from "bun";
|
|
3
|
+
import { buildSearchQuery, clampLimit } from "../src/packages/package.ts";
|
|
4
|
+
import { HttpRegistry } from "../src/registry/registry.ts";
|
|
5
|
+
import { TTLCache } from "../src/shared/cache.ts";
|
|
6
|
+
import { envMs } from "../src/shared/state.ts";
|
|
7
|
+
|
|
8
|
+
describe("buildSearchQuery", () => {
|
|
9
|
+
it("scopes to pi packages", () => {
|
|
10
|
+
expect(buildSearchQuery("lsp")).toBe("keywords:pi-package lsp");
|
|
11
|
+
expect(buildSearchQuery(" telegram ")).toBe("keywords:pi-package telegram");
|
|
12
|
+
expect(buildSearchQuery("")).toBe("keywords:pi-package");
|
|
13
|
+
expect(buildSearchQuery("keywords:pi-package lsp")).toBe("keywords:pi-package lsp");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("clampLimit", () => {
|
|
18
|
+
it("defaults and clamps", () => {
|
|
19
|
+
expect(clampLimit(0, 10, 50)).toBe(10);
|
|
20
|
+
expect(clampLimit(999, 10, 50)).toBe(50);
|
|
21
|
+
expect(clampLimit(7, 10, 50)).toBe(7);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("TTLCache", () => {
|
|
26
|
+
it("stores and expires", async () => {
|
|
27
|
+
const c = new TTLCache(20); // ms
|
|
28
|
+
c.set("k", "v");
|
|
29
|
+
expect(c.get("k")).toBe("v");
|
|
30
|
+
await Bun.sleep(30);
|
|
31
|
+
expect(c.get("k")).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Real HTTP adapter against a fake npm upstream (Bun.serve).
|
|
36
|
+
describe("HttpRegistry", () => {
|
|
37
|
+
let server: Server<undefined>;
|
|
38
|
+
let registry: HttpRegistry;
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
server = Bun.serve({
|
|
42
|
+
port: 0,
|
|
43
|
+
fetch(req) {
|
|
44
|
+
const url = new URL(req.url);
|
|
45
|
+
if (url.pathname === "/-/v1/search") {
|
|
46
|
+
const from = Number(url.searchParams.get("from") ?? 0);
|
|
47
|
+
const size = Number(url.searchParams.get("size") ?? 10);
|
|
48
|
+
if (url.searchParams.get("text") !== "keywords:pi-package lsp") {
|
|
49
|
+
return Response.json({ error: "bad query" }, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
const all = [
|
|
52
|
+
{ name: "pi-lsp", version: "0.3.0", description: "LSP", date: "2026-06-01" },
|
|
53
|
+
{ name: "@narumitw/pi-lsp", version: "0.9.1", description: "another" },
|
|
54
|
+
{ name: "pi-lsp-lite", version: "1.0.0", description: "lite" },
|
|
55
|
+
];
|
|
56
|
+
const page = all.slice(from, from + size);
|
|
57
|
+
return Response.json({ total: all.length, objects: page.map((p) => ({ package: p })) });
|
|
58
|
+
}
|
|
59
|
+
if (url.pathname === "/downloads/point/last-week/pi-lsp") return Response.json({ downloads: 12 });
|
|
60
|
+
if (url.pathname === "/downloads/point/last-month/pi-lsp") return Response.json({ downloads: 34 });
|
|
61
|
+
if (url.pathname === "/pi-lsp/latest") {
|
|
62
|
+
return Response.json({
|
|
63
|
+
name: "pi-lsp",
|
|
64
|
+
version: "0.3.0",
|
|
65
|
+
description: "LSP for pi",
|
|
66
|
+
homepage: "https://example.com",
|
|
67
|
+
repository: { type: "git", url: "git+https://github.com/x/pi-lsp.git" },
|
|
68
|
+
license: "MIT",
|
|
69
|
+
keywords: ["pi-package", "lsp"],
|
|
70
|
+
pi: { extensions: ["./src/index.ts"] },
|
|
71
|
+
dist: {
|
|
72
|
+
unpackedSize: 12345,
|
|
73
|
+
integrity: "sha512-test",
|
|
74
|
+
attestations: { url: "https://registry.example/provenance", provenance: { predicateType: "slsa" } },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return new Response("nf", { status: 404 });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
registry = new HttpRegistry(`http://127.0.0.1:${server.port}`, 2, 100, 1_000, `http://127.0.0.1:${server.port}`); // tiny page size
|
|
82
|
+
});
|
|
83
|
+
afterAll(() => server.stop(true));
|
|
84
|
+
|
|
85
|
+
it("search maps lean results", async () => {
|
|
86
|
+
const { results, total } = await registry.search("keywords:pi-package lsp", 2);
|
|
87
|
+
expect(total).toBe(3);
|
|
88
|
+
expect(results[0]).toMatchObject({ name: "pi-lsp", version: "0.3.0", packageEvidence: { shape: "keyword-only", verified: false } });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("searchAll paginates", async () => {
|
|
92
|
+
const all = await registry.searchAll("keywords:pi-package lsp");
|
|
93
|
+
expect(all.map((p) => p.name)).toEqual(["pi-lsp", "@narumitw/pi-lsp", "pi-lsp-lite"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("info maps latest version fields", async () => {
|
|
97
|
+
const info = await registry.info("pi-lsp");
|
|
98
|
+
expect(info).toMatchObject({
|
|
99
|
+
name: "pi-lsp",
|
|
100
|
+
version: "0.3.0",
|
|
101
|
+
description: "LSP for pi",
|
|
102
|
+
repository: "git+https://github.com/x/pi-lsp.git",
|
|
103
|
+
license: "MIT",
|
|
104
|
+
unpackedSize: 12345,
|
|
105
|
+
readmeAvailable: false,
|
|
106
|
+
});
|
|
107
|
+
expect(info.pi?.extensions).toBeDefined();
|
|
108
|
+
expect(info.packageEvidence).toMatchObject({ shape: "manifest", verified: false });
|
|
109
|
+
expect(info.publication).toEqual({
|
|
110
|
+
integrity: "sha512-test",
|
|
111
|
+
provenanceUrl: "https://registry.example/provenance",
|
|
112
|
+
trustedPublisher: "unknown",
|
|
113
|
+
});
|
|
114
|
+
expect(await registry.downloads("pi-lsp")).toMatchObject({ weekly: 12, monthly: 34 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("upstream errors surface", async () => {
|
|
118
|
+
expect(registry.info("missing-pkg")).rejects.toThrow(/404/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("envMs (service lifecycle knob)", () => {
|
|
123
|
+
it("default when unset or garbage", () => {
|
|
124
|
+
delete process.env.PI_PACKED_TEST_MS;
|
|
125
|
+
expect(envMs("PI_PACKED_TEST_MS", 5000)).toBe(5000);
|
|
126
|
+
process.env.PI_PACKED_TEST_MS = "banana";
|
|
127
|
+
expect(envMs("PI_PACKED_TEST_MS", 5000)).toBe(5000);
|
|
128
|
+
});
|
|
129
|
+
it("seconds → ms when set", () => {
|
|
130
|
+
process.env.PI_PACKED_TEST_MS = "30";
|
|
131
|
+
expect(envMs("PI_PACKED_TEST_MS", 5000)).toBe(30_000);
|
|
132
|
+
});
|
|
133
|
+
it("zero disables (systemd owns the lifecycle)", () => {
|
|
134
|
+
process.env.PI_PACKED_TEST_MS = "0";
|
|
135
|
+
expect(envMs("PI_PACKED_TEST_MS", 5000)).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("upstream etiquette (429 handling)", () => {
|
|
140
|
+
it("retries on 429 honoring Retry-After, then succeeds", async () => {
|
|
141
|
+
let attempts = 0;
|
|
142
|
+
await using server = Bun.serve({
|
|
143
|
+
port: 0,
|
|
144
|
+
fetch() {
|
|
145
|
+
attempts++;
|
|
146
|
+
if (attempts <= 2) {
|
|
147
|
+
return new Response("slow down", { status: 429, headers: { "retry-after": "0" } });
|
|
148
|
+
}
|
|
149
|
+
return Response.json({ total: 0, objects: [] });
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1);
|
|
153
|
+
const { total } = await reg.search("keywords:pi-package x", 10);
|
|
154
|
+
expect(total).toBe(0);
|
|
155
|
+
expect(attempts).toBe(3);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("gives up after max attempts", async () => {
|
|
159
|
+
let attempts = 0;
|
|
160
|
+
await using server = Bun.serve({
|
|
161
|
+
port: 0,
|
|
162
|
+
fetch() {
|
|
163
|
+
attempts++;
|
|
164
|
+
return new Response("slow down", { status: 429, headers: { "retry-after": "0" } });
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1);
|
|
168
|
+
expect(reg.search("keywords:pi-package x", 10)).rejects.toThrow(/429/);
|
|
169
|
+
expect(attempts).toBeGreaterThanOrEqual(3);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("searchAll pauses between pages", async () => {
|
|
173
|
+
const reg = new HttpRegistry("http://unused", 250, 40);
|
|
174
|
+
const t0 = Date.now();
|
|
175
|
+
// monkey-patch searchPage to avoid network
|
|
176
|
+
reg.searchPage = async (_q: string, from: number) =>
|
|
177
|
+
from === 0 ? { results: [{ name: "a", version: "1" }], total: 2 } : { results: [{ name: "b", version: "1" }], total: 2 };
|
|
178
|
+
await reg.searchAll("keywords:pi-package");
|
|
179
|
+
expect(Date.now() - t0).toBeGreaterThanOrEqual(40);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
|
|
6
|
+
import { readDaemonHandle } from "@danypops/vehicle-server/paths";
|
|
7
|
+
import { connectPackageDaemon } from "../src/daemon/client.ts";
|
|
8
|
+
import { startPackedDaemon } from "../src/daemon/daemon.ts";
|
|
9
|
+
import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
|
|
10
|
+
import type { Installer, Registry } from "../src/packages/package.ts";
|
|
11
|
+
import { migrateLegacyPackedState, resolvePackedPaths } from "../src/shared/paths.ts";
|
|
12
|
+
|
|
13
|
+
const roots: string[] = [];
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function tempRoot(name: string): string {
|
|
19
|
+
const root = join(tmpdir(), `${name}-${crypto.randomUUID()}`);
|
|
20
|
+
mkdirSync(root, { recursive: true });
|
|
21
|
+
roots.push(root);
|
|
22
|
+
return root;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function testPaths(root: string) {
|
|
26
|
+
return resolvePackedPaths({
|
|
27
|
+
env: {
|
|
28
|
+
XDG_DATA_HOME: join(root, "data"),
|
|
29
|
+
XDG_STATE_HOME: join(root, "state"),
|
|
30
|
+
XDG_RUNTIME_DIR: join(root, "runtime"),
|
|
31
|
+
XDG_CONFIG_HOME: join(root, "config"),
|
|
32
|
+
},
|
|
33
|
+
home: root,
|
|
34
|
+
uid: 1000,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const registry: Registry = {
|
|
39
|
+
async search(_query, _limit) {
|
|
40
|
+
return { total: 1, results: [{ name: "pi-test", version: "1.0.0" }] };
|
|
41
|
+
},
|
|
42
|
+
async searchPage(_query, _from, _size) {
|
|
43
|
+
return { total: 1, results: [{ name: "pi-test", version: "1.0.0" }] };
|
|
44
|
+
},
|
|
45
|
+
async searchAll() {
|
|
46
|
+
return [{ name: "pi-test", version: "1.0.0" }];
|
|
47
|
+
},
|
|
48
|
+
async info(name) {
|
|
49
|
+
return { name, version: "1.0.0" };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const installer: Installer = {
|
|
54
|
+
async install(source) {
|
|
55
|
+
return `installed ${source}`;
|
|
56
|
+
},
|
|
57
|
+
async remove(source) {
|
|
58
|
+
return `removed ${source}`;
|
|
59
|
+
},
|
|
60
|
+
async update(source) {
|
|
61
|
+
return { output: `updated ${source}`, reloadRequired: true, alreadyUpToDate: false, pinned: false };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe("daemon-kit migration", () => {
|
|
66
|
+
it("resolves XDG-split paths and keeps PI_PACKED_HOME as an explicit compatibility override", () => {
|
|
67
|
+
const root = tempRoot("packed-paths");
|
|
68
|
+
const paths = testPaths(root);
|
|
69
|
+
expect(paths.database).toBe(join(root, "data", "pi-packed", "packed.db"));
|
|
70
|
+
expect(paths.token).toBe(join(root, "state", "pi-packed", "token"));
|
|
71
|
+
expect(paths.handle).toBe(join(root, "runtime", "pi-packed", "handle.json"));
|
|
72
|
+
expect(paths.stateDirectory).toBe(join(root, "state", "pi-packed"));
|
|
73
|
+
|
|
74
|
+
const override = join(root, "override");
|
|
75
|
+
const compatible = resolvePackedPaths({ env: { PI_PACKED_HOME: override }, home: root, uid: 1000 });
|
|
76
|
+
expect(compatible.database).toBe(join(override, "packed.db"));
|
|
77
|
+
expect(compatible.token).toBe(join(override, "token"));
|
|
78
|
+
expect(compatible.handle).toBe(join(override, "handle.json"));
|
|
79
|
+
expect(compatible.stateDirectory).toBe(override);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("copies legacy state into missing XDG destinations without overwriting migrated files", () => {
|
|
83
|
+
const root = tempRoot("packed-legacy");
|
|
84
|
+
const legacy = join(root, "legacy");
|
|
85
|
+
mkdirSync(legacy, { recursive: true });
|
|
86
|
+
writeFileSync(join(legacy, "packed.db"), "legacy-db", { mode: 0o600 });
|
|
87
|
+
writeFileSync(join(legacy, "packed.db-wal"), "legacy-wal", { mode: 0o600 });
|
|
88
|
+
writeFileSync(join(legacy, "packed.db-shm"), "legacy-shm", { mode: 0o600 });
|
|
89
|
+
writeFileSync(join(legacy, "token"), `${"a".repeat(64)}\n`, { mode: 0o600 });
|
|
90
|
+
writeFileSync(join(legacy, "security.json"), '{"mutationApproval":"never"}\n', { mode: 0o600 });
|
|
91
|
+
const paths = testPaths(root);
|
|
92
|
+
|
|
93
|
+
migrateLegacyPackedState(paths, legacy);
|
|
94
|
+
expect(readFileSync(paths.database, "utf8")).toBe("legacy-db");
|
|
95
|
+
expect(readFileSync(`${paths.database}-wal`, "utf8")).toBe("legacy-wal");
|
|
96
|
+
expect(readFileSync(`${paths.database}-shm`, "utf8")).toBe("legacy-shm");
|
|
97
|
+
expect(readFileSync(paths.token, "utf8").trim()).toBe("a".repeat(64));
|
|
98
|
+
expect(readFileSync(join(paths.stateDirectory, "security.json"), "utf8")).toContain("never");
|
|
99
|
+
expect(statSync(paths.token).mode & 0o777).toBe(0o600);
|
|
100
|
+
|
|
101
|
+
writeFileSync(paths.database, "new-db");
|
|
102
|
+
migrateLegacyPackedState(paths, legacy);
|
|
103
|
+
expect(readFileSync(paths.database, "utf8")).toBe("new-db");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not carry the legacy 128-bit bearer token into daemon-kit state", () => {
|
|
107
|
+
const root = tempRoot("packed-token-rotation");
|
|
108
|
+
const legacy = join(root, "legacy");
|
|
109
|
+
mkdirSync(legacy, { recursive: true });
|
|
110
|
+
writeFileSync(join(legacy, "token"), `${"b".repeat(32)}\n`, { mode: 0o600 });
|
|
111
|
+
const paths = testPaths(root);
|
|
112
|
+
migrateLegacyPackedState(paths, legacy);
|
|
113
|
+
expect(existsSync(paths.token)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("serves the typed operation registry while preserving authenticated REST routes", async () => {
|
|
117
|
+
const root = tempRoot("packed-rpc");
|
|
118
|
+
const app = createApp({ reg: registry, inst: installer, token: "test-token", stateDir: root, dataDir: root, piHome: root });
|
|
119
|
+
const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
|
|
120
|
+
label: "Packed",
|
|
121
|
+
transport: (request) => app.fetch(request),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(await client.operations()).toEqual([
|
|
125
|
+
"package.search",
|
|
126
|
+
"package.info",
|
|
127
|
+
"package.installed",
|
|
128
|
+
"package.catalog",
|
|
129
|
+
"package.catalog.sync",
|
|
130
|
+
"package.index",
|
|
131
|
+
"package.index.build",
|
|
132
|
+
"package.updates",
|
|
133
|
+
"package.check",
|
|
134
|
+
"package.pack",
|
|
135
|
+
"package.score",
|
|
136
|
+
"setup.export",
|
|
137
|
+
"setup.update",
|
|
138
|
+
"setup.plan",
|
|
139
|
+
"setup.apply",
|
|
140
|
+
"package.security.get",
|
|
141
|
+
"package.security.set",
|
|
142
|
+
"package.install",
|
|
143
|
+
"package.install_service",
|
|
144
|
+
"package.restart_service",
|
|
145
|
+
"package.remove",
|
|
146
|
+
"package.update",
|
|
147
|
+
"resources.list",
|
|
148
|
+
"resources.toggle",
|
|
149
|
+
"pi.status",
|
|
150
|
+
"advisories.scan",
|
|
151
|
+
"doctor.run",
|
|
152
|
+
"package.updates.project",
|
|
153
|
+
]);
|
|
154
|
+
expect(await client.call("package.search", { query: "test", limit: 10, offline: false })).toMatchObject({ total: 1 });
|
|
155
|
+
expect(await client.call("package.info", { name: "pi-test" })).toEqual({ name: "pi-test", version: "1.0.0" });
|
|
156
|
+
expect(await client.call("package.catalog.sync", {})).toEqual({ synced: 1 });
|
|
157
|
+
expect((await client.call("package.catalog", {})).packages.map(({ name, version }) => ({ name, version }))).toEqual([
|
|
158
|
+
{ name: "pi-test", version: "1.0.0" },
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const rest = await app.fetch(new Request("http://packed.test/info?name=pi-test", { headers: { authorization: "Bearer test-token" } }));
|
|
162
|
+
expect(rest.status).toBe(200);
|
|
163
|
+
expect(await rest.json()).toEqual({ name: "pi-test", version: "1.0.0" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("starts and stops through daemon-kit with an atomic authenticated handle", async () => {
|
|
167
|
+
const root = tempRoot("packed-daemon");
|
|
168
|
+
const paths = testPaths(root);
|
|
169
|
+
const running = await startPackedDaemon({ paths, reg: registry, inst: installer, piHome: root, maintenanceTasks: [] });
|
|
170
|
+
try {
|
|
171
|
+
const handle = readDaemonHandle(paths.handle);
|
|
172
|
+
expect(handle).toMatchObject({ host: "127.0.0.1", port: running.port, pid: process.pid });
|
|
173
|
+
expect(existsSync(paths.token)).toBe(true);
|
|
174
|
+
expect(readFileSync(paths.token, "utf8").trim()).toMatch(/^[a-f0-9]{64}$/);
|
|
175
|
+
expect(await (await connectPackageDaemon(paths)).info("pi-test")).toEqual({ name: "pi-test", version: "1.0.0" });
|
|
176
|
+
} finally {
|
|
177
|
+
await running.stop();
|
|
178
|
+
}
|
|
179
|
+
expect(readDaemonHandle(paths.handle)).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ServiceInstallDeps } from "@danypops/vehicle-server/service";
|
|
6
|
+
import { detectVehicleDaemonService, resolveDaemonServiceSpec, restartUserService } from "../src/daemon/daemon-service.ts";
|
|
7
|
+
|
|
8
|
+
function fakePiHome(): string {
|
|
9
|
+
return mkdtempSync(join(tmpdir(), "packed-daemon-service-"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function writePackage(piHome: string, name: string, manifest: unknown): void {
|
|
13
|
+
const dir = join(piHome, "npm", "node_modules", name);
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(dir, "package.json"),
|
|
17
|
+
JSON.stringify({ name, version: "1.0.0", ...(manifest ? { packed: { daemonService: manifest } } : {}) }),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeRawPackage(dir: string, pkg: Record<string, unknown>): void {
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify(pkg));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("resolveDaemonServiceSpec", () => {
|
|
27
|
+
it("builds a ServiceSpec from a package's packed.daemonService manifest", () => {
|
|
28
|
+
const piHome = fakePiHome();
|
|
29
|
+
writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
|
|
30
|
+
|
|
31
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/web-spider-daemon");
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
ok: true,
|
|
34
|
+
spec: {
|
|
35
|
+
name: "web-spider-daemon",
|
|
36
|
+
displayName: undefined,
|
|
37
|
+
binPath: join(piHome, "npm", "node_modules", "@danypops/web-spider-daemon", "dist/cli.js"),
|
|
38
|
+
args: ["serve"],
|
|
39
|
+
descriptorPath: expect.stringContaining("web-spider-daemon") as unknown as string,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("honors an explicit name/displayName override instead of deriving one from the npm package name", () => {
|
|
45
|
+
const piHome = fakePiHome();
|
|
46
|
+
writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", name: "web-spider", displayName: "Web Spider" });
|
|
47
|
+
|
|
48
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/web-spider-daemon");
|
|
49
|
+
expect(result.ok).toBe(true);
|
|
50
|
+
if (!result.ok) throw new Error("expected ok");
|
|
51
|
+
expect(result.spec.name).toBe("web-spider");
|
|
52
|
+
expect(result.spec.displayName).toBe("Web Spider");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("fails closed for a package that declares no daemonService manifest", () => {
|
|
56
|
+
const piHome = fakePiHome();
|
|
57
|
+
writePackage(piHome, "some-pkg", undefined);
|
|
58
|
+
|
|
59
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:some-pkg");
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
ok: false,
|
|
62
|
+
notADaemon: true,
|
|
63
|
+
reason: expect.stringContaining("does not declare a packed.daemonService manifest") as unknown as string,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("fails closed for a package that was never actually installed", () => {
|
|
68
|
+
const piHome = fakePiHome();
|
|
69
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:never-installed");
|
|
70
|
+
expect(result.ok).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fails closed for a non-npm source -- git:/local resolution isn't supported yet", () => {
|
|
74
|
+
const piHome = fakePiHome();
|
|
75
|
+
const result = resolveDaemonServiceSpec(piHome, "git:github.com/u/r@v1");
|
|
76
|
+
expect(result).toEqual({ ok: false, reason: expect.stringContaining("only supports npm:") as unknown as string });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("falls back to detection when the package itself is the daemon (bin + real vehicle-server dependency, no manifest)", () => {
|
|
80
|
+
const piHome = fakePiHome();
|
|
81
|
+
const dir = join(piHome, "npm", "node_modules", "@danypops/papyrus");
|
|
82
|
+
writeRawPackage(dir, {
|
|
83
|
+
name: "@danypops/papyrus",
|
|
84
|
+
version: "1.0.0",
|
|
85
|
+
bin: { papyrus: "src/cli.ts" },
|
|
86
|
+
dependencies: { "@danypops/vehicle-server": "^0.1.0" },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/papyrus");
|
|
90
|
+
expect(result).toEqual({
|
|
91
|
+
ok: true,
|
|
92
|
+
spec: {
|
|
93
|
+
name: "papyrus",
|
|
94
|
+
displayName: undefined,
|
|
95
|
+
binPath: join(dir, "src/cli.ts"),
|
|
96
|
+
args: ["serve"],
|
|
97
|
+
descriptorPath: expect.stringContaining("papyrus") as unknown as string,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("falls back to detection one level into a real dependency -- a Pi extension with no daemon of its own", () => {
|
|
103
|
+
const piHome = fakePiHome();
|
|
104
|
+
const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
|
|
105
|
+
writeRawPackage(extDir, { name: "@danypops/pi-papyrus", version: "1.0.0", dependencies: { "@danypops/papyrus": "^0.38.0" } });
|
|
106
|
+
const depDir = join(extDir, "node_modules", "@danypops/papyrus");
|
|
107
|
+
writeRawPackage(depDir, {
|
|
108
|
+
name: "@danypops/papyrus",
|
|
109
|
+
version: "0.38.0",
|
|
110
|
+
bin: { papyrus: "src/cli.ts" },
|
|
111
|
+
dependencies: { "@danypops/vehicle-server": "^0.1.0" },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/pi-papyrus");
|
|
115
|
+
expect(result.ok).toBe(true);
|
|
116
|
+
if (!result.ok) throw new Error("expected ok");
|
|
117
|
+
expect(result.spec.binPath).toBe(join(depDir, "src/cli.ts"));
|
|
118
|
+
expect(result.spec.args).toEqual(["serve"]);
|
|
119
|
+
expect(result.spec.name).toBe("papyrus");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("falls back to detection via npm's hoisted top-level node_modules, not just a nested one -- the real on-disk layout for a flat npm install", () => {
|
|
123
|
+
const piHome = fakePiHome();
|
|
124
|
+
const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
|
|
125
|
+
writeRawPackage(extDir, { name: "@danypops/pi-papyrus", version: "1.0.0", dependencies: { "@danypops/papyrus": "^0.38.0" } });
|
|
126
|
+
// Hoisted to the SAME top-level node_modules as pi-papyrus itself, not nested under it --
|
|
127
|
+
// confirmed live as the real layout npm produces for a flat single-root install (Packed's
|
|
128
|
+
// own piHome/npm install target), as opposed to the nested-dependency layout the prior test
|
|
129
|
+
// exercises. Both are real, both must resolve.
|
|
130
|
+
const depDir = join(piHome, "npm", "node_modules", "@danypops/papyrus");
|
|
131
|
+
writeRawPackage(depDir, {
|
|
132
|
+
name: "@danypops/papyrus",
|
|
133
|
+
version: "0.38.0",
|
|
134
|
+
bin: { papyrus: "src/cli.ts" },
|
|
135
|
+
dependencies: { "@danypops/vehicle-server": "^0.1.0" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/pi-papyrus");
|
|
139
|
+
expect(result.ok).toBe(true);
|
|
140
|
+
if (!result.ok) throw new Error("expected ok");
|
|
141
|
+
expect(result.spec.binPath).toBe(join(depDir, "src/cli.ts"));
|
|
142
|
+
expect(result.spec.args).toEqual(["serve"]);
|
|
143
|
+
expect(result.spec.name).toBe("papyrus");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("also detects the legacy @danypops/daemon-kit dependency name, not just vehicle-server", () => {
|
|
147
|
+
const piHome = fakePiHome();
|
|
148
|
+
const dir = join(piHome, "npm", "node_modules", "@danypops/web-spider-daemon");
|
|
149
|
+
writeRawPackage(dir, {
|
|
150
|
+
name: "@danypops/web-spider-daemon",
|
|
151
|
+
version: "1.0.0",
|
|
152
|
+
bin: { "web-spider": "src/cli.ts" },
|
|
153
|
+
dependencies: { "@danypops/daemon-kit": "^0.18.0" },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/web-spider-daemon");
|
|
157
|
+
expect(result.ok).toBe(true);
|
|
158
|
+
if (!result.ok) throw new Error("expected ok");
|
|
159
|
+
expect(result.spec.binPath).toBe(join(dir, "src/cli.ts"));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("an explicit manifest still wins over detection when both are present", () => {
|
|
163
|
+
const piHome = fakePiHome();
|
|
164
|
+
const dir = join(piHome, "npm", "node_modules", "@danypops/papyrus");
|
|
165
|
+
writeRawPackage(dir, {
|
|
166
|
+
name: "@danypops/papyrus",
|
|
167
|
+
version: "1.0.0",
|
|
168
|
+
bin: { papyrus: "src/cli.ts" },
|
|
169
|
+
dependencies: { "@danypops/vehicle-server": "^0.1.0" },
|
|
170
|
+
packed: { daemonService: { binPath: "dist/cli.js", args: ["serve", "--custom"], name: "papyrus-custom" } },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/papyrus");
|
|
174
|
+
expect(result.ok).toBe(true);
|
|
175
|
+
if (!result.ok) throw new Error("expected ok");
|
|
176
|
+
expect(result.spec.binPath).toBe(join(dir, "dist/cli.js"));
|
|
177
|
+
expect(result.spec.args).toEqual(["serve", "--custom"]);
|
|
178
|
+
expect(result.spec.name).toBe("papyrus-custom");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("detects nothing for a package with a bin but no real vehicle-server/daemon-kit dependency", () => {
|
|
182
|
+
const piHome = fakePiHome();
|
|
183
|
+
const dir = join(piHome, "npm", "node_modules", "some-cli");
|
|
184
|
+
writeRawPackage(dir, { name: "some-cli", version: "1.0.0", bin: { "some-cli": "src/cli.ts" }, dependencies: { commander: "^12.0.0" } });
|
|
185
|
+
|
|
186
|
+
expect(detectVehicleDaemonService(dir, "some-cli")).toBeUndefined();
|
|
187
|
+
const result = resolveDaemonServiceSpec(piHome, "npm:some-cli");
|
|
188
|
+
expect(result.ok).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
function fakeInstallDeps(over: Partial<ServiceInstallDeps> = {}): ServiceInstallDeps {
|
|
193
|
+
return {
|
|
194
|
+
platform: "linux",
|
|
195
|
+
writeFile: () => {},
|
|
196
|
+
readFile: () => null,
|
|
197
|
+
removeFile: () => {},
|
|
198
|
+
fileExists: () => false,
|
|
199
|
+
mkdirp: () => {},
|
|
200
|
+
runCommand: () => ({ ok: true, output: "" }),
|
|
201
|
+
which: () => true,
|
|
202
|
+
...over,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
describe("restartUserService", () => {
|
|
207
|
+
const spec = { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/home/user/.config/systemd/user/probe.service" };
|
|
208
|
+
|
|
209
|
+
it("restarts via systemctl --user restart, using the descriptor's basename as the unit name", () => {
|
|
210
|
+
const calls: Array<{ command: string; args: string[] }> = [];
|
|
211
|
+
const deps = fakeInstallDeps({
|
|
212
|
+
runCommand: (command, args) => {
|
|
213
|
+
calls.push({ command, args });
|
|
214
|
+
return { ok: true, output: "" };
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const result = restartUserService(spec, deps);
|
|
219
|
+
expect(result).toEqual({ restarted: true });
|
|
220
|
+
expect(calls).toEqual([{ command: "systemctl", args: ["--user", "restart", "probe.service"] }]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("reports a real systemctl failure without throwing", () => {
|
|
224
|
+
const deps = fakeInstallDeps({ runCommand: () => ({ ok: false, output: "Unit probe.service not loaded" }) });
|
|
225
|
+
|
|
226
|
+
const result = restartUserService(spec, deps);
|
|
227
|
+
expect(result.restarted).toBe(false);
|
|
228
|
+
expect(result.reason).toContain("Unit probe.service not loaded");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("refuses on a platform with no supported restart mechanism, rather than guessing at one", () => {
|
|
232
|
+
const deps = fakeInstallDeps({ platform: "darwin" });
|
|
233
|
+
|
|
234
|
+
const result = restartUserService(spec, deps);
|
|
235
|
+
expect(result.restarted).toBe(false);
|
|
236
|
+
expect(result.reason).toContain('platform "darwin"');
|
|
237
|
+
});
|
|
238
|
+
});
|