@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,291 @@
|
|
|
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 { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
|
|
6
|
+
import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
|
|
7
|
+
import { checkUpdates, loadUpdates, saveUpdates, startWatcher } from "../src/daemon/watcher.ts";
|
|
8
|
+
import { catalogStatus } from "../src/packages/catalog.ts";
|
|
9
|
+
import { dbPath, openDb, replaceAll } from "../src/packages/db.ts";
|
|
10
|
+
import {
|
|
11
|
+
isPinnedNpmSource,
|
|
12
|
+
npmPackageName,
|
|
13
|
+
readInstalledPackages,
|
|
14
|
+
readInstalledPackagesAcrossScopes,
|
|
15
|
+
readResolvedVersion,
|
|
16
|
+
splitNpmSource,
|
|
17
|
+
} from "../src/packages/installed.ts";
|
|
18
|
+
import type { Installer, PkgInfo, Registry, SearchPage, UpdateOutcome, UpdatesSnapshot } from "../src/packages/package.ts";
|
|
19
|
+
|
|
20
|
+
function writePiHome(settings: unknown, nodeModules: Record<string, string> = {}): string {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-pihome-"));
|
|
22
|
+
writeFileSync(join(dir, "settings.json"), JSON.stringify(settings));
|
|
23
|
+
for (const [name, version] of Object.entries(nodeModules)) {
|
|
24
|
+
const pkgDir = join(dir, "npm", "node_modules", name);
|
|
25
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
26
|
+
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ version }));
|
|
27
|
+
}
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("splitNpmSource", () => {
|
|
32
|
+
it("splits on last non-scope @", () => {
|
|
33
|
+
expect(splitNpmSource("foo@1.2.3")).toEqual(["foo", "1.2.3"]);
|
|
34
|
+
expect(splitNpmSource("@scope/pkg@1.0.0")).toEqual(["@scope/pkg", "1.0.0"]);
|
|
35
|
+
expect(splitNpmSource("foo")).toEqual(["foo", ""]);
|
|
36
|
+
expect(splitNpmSource("@scope/pkg")).toEqual(["@scope/pkg", ""]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("isPinnedNpmSource", () => {
|
|
41
|
+
it("true only for an npm: source with an exact version suffix", () => {
|
|
42
|
+
expect(isPinnedNpmSource("npm:foo@1.2.3")).toBe(true);
|
|
43
|
+
expect(isPinnedNpmSource("npm:@scope/pkg@1.2.3")).toBe(true);
|
|
44
|
+
expect(isPinnedNpmSource("npm:foo")).toBe(false);
|
|
45
|
+
expect(isPinnedNpmSource("npm:@scope/pkg")).toBe(false);
|
|
46
|
+
expect(isPinnedNpmSource("git:github.com/u/r@main")).toBe(false);
|
|
47
|
+
expect(isPinnedNpmSource("https://example.com/pkg.tgz")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("npmPackageName", () => {
|
|
52
|
+
it("extracts the bare name for npm: sources, pinned or not", () => {
|
|
53
|
+
expect(npmPackageName("npm:foo@1.2.3")).toBe("foo");
|
|
54
|
+
expect(npmPackageName("npm:@scope/pkg@1.2.3")).toBe("@scope/pkg");
|
|
55
|
+
expect(npmPackageName("npm:@scope/pkg")).toBe("@scope/pkg");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("undefined for non-npm sources", () => {
|
|
59
|
+
expect(npmPackageName("git:github.com/u/r@main")).toBeUndefined();
|
|
60
|
+
expect(npmPackageName("https://example.com/pkg.tgz")).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("readResolvedVersion", () => {
|
|
65
|
+
it("reads the real on-disk version regardless of pinning", () => {
|
|
66
|
+
const home = writePiHome({ packages: [] }, { "@scope/pkg": "3.4.5", plain: "1.0.0" });
|
|
67
|
+
expect(readResolvedVersion(home, "npm:@scope/pkg@9.9.9")).toBe("3.4.5");
|
|
68
|
+
expect(readResolvedVersion(home, "npm:@scope/pkg")).toBe("3.4.5");
|
|
69
|
+
expect(readResolvedVersion(home, "npm:plain")).toBe("1.0.0");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("undefined for a non-npm source or a package missing from node_modules", () => {
|
|
73
|
+
const home = writePiHome({ packages: [] });
|
|
74
|
+
expect(readResolvedVersion(home, "git:github.com/u/r@main")).toBeUndefined();
|
|
75
|
+
expect(readResolvedVersion(home, "npm:never-installed")).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("readInstalledPackages", () => {
|
|
80
|
+
it("parses string and object forms, resolves unpinned from node_modules", () => {
|
|
81
|
+
const home = writePiHome(
|
|
82
|
+
{
|
|
83
|
+
packages: [
|
|
84
|
+
"npm:pi-extension-manager@0.8.2",
|
|
85
|
+
"npm:@scope/pinned@1.0.0",
|
|
86
|
+
"npm:unpinned",
|
|
87
|
+
"git:github.com/u/r",
|
|
88
|
+
{ source: "npm:obj-form@2.0.0" },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{ unpinned: "0.5.0" },
|
|
92
|
+
);
|
|
93
|
+
expect(readInstalledPackages(home)).toEqual([
|
|
94
|
+
{ name: "pi-extension-manager", pinned: "0.8.2", installed: undefined },
|
|
95
|
+
{ name: "@scope/pinned", pinned: "1.0.0", installed: undefined },
|
|
96
|
+
{ name: "unpinned", pinned: undefined, installed: "0.5.0" },
|
|
97
|
+
{ name: "obj-form", pinned: "2.0.0", installed: undefined },
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("missing settings → empty", () => {
|
|
102
|
+
expect(readInstalledPackages(mkdtempSync(join(tmpdir(), "packed-")))).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("readInstalledPackagesAcrossScopes", () => {
|
|
107
|
+
it("tags every entry with its scope and is global-only without a projectRoot", () => {
|
|
108
|
+
const home = writePiHome({ packages: ["npm:pi-global@1.0.0"] });
|
|
109
|
+
expect(readInstalledPackagesAcrossScopes(home)).toEqual([
|
|
110
|
+
{ name: "pi-global", pinned: "1.0.0", installed: undefined, scope: "global" },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("reproduces the real jittor gap: a stale project-scoped pin is invisible to a global-only read, visible once project scope is included", () => {
|
|
115
|
+
const home = writePiHome({ packages: ["npm:pi-global@1.0.0"] });
|
|
116
|
+
const projectRoot = mkdtempSync(join(tmpdir(), "packed-project-"));
|
|
117
|
+
const projectHome = join(projectRoot, ".pi");
|
|
118
|
+
mkdirSync(projectHome, { recursive: true });
|
|
119
|
+
writeFileSync(join(projectHome, "settings.json"), JSON.stringify({ packages: ["npm:papyrus@0.21.2"] }));
|
|
120
|
+
|
|
121
|
+
expect(readInstalledPackages(home).map((pkg) => pkg.name)).not.toContain("papyrus");
|
|
122
|
+
const merged = readInstalledPackagesAcrossScopes(home, projectRoot);
|
|
123
|
+
expect(merged).toEqual([
|
|
124
|
+
{ name: "pi-global", pinned: "1.0.0", installed: undefined, scope: "global" },
|
|
125
|
+
{ name: "papyrus", pinned: "0.21.2", installed: undefined, scope: "project" },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("is unaffected by a missing project settings file", () => {
|
|
130
|
+
const home = writePiHome({ packages: ["npm:pi-global@1.0.0"] });
|
|
131
|
+
const projectRoot = mkdtempSync(join(tmpdir(), "packed-project-"));
|
|
132
|
+
expect(readInstalledPackagesAcrossScopes(home, projectRoot)).toEqual([
|
|
133
|
+
{ name: "pi-global", pinned: "1.0.0", installed: undefined, scope: "global" },
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("checkUpdates (mirror-based)", () => {
|
|
139
|
+
it("flags drift only", () => {
|
|
140
|
+
const latest = (name: string) => ({ "pi-extension-manager": "0.9.0", "pi-lsp": "1.0.0" })[name];
|
|
141
|
+
const updates = checkUpdates(latest, [
|
|
142
|
+
{ name: "pi-extension-manager", pinned: "0.8.2" },
|
|
143
|
+
{ name: "pi-lsp", installed: "1.0.0" },
|
|
144
|
+
]);
|
|
145
|
+
expect(updates).toHaveLength(1);
|
|
146
|
+
expect(updates[0]).toMatchObject({ name: "pi-extension-manager", installed: "0.8.2", latest: "0.9.0" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("packages missing from the mirror are skipped", () => {
|
|
150
|
+
const updates = checkUpdates(() => undefined, [{ name: "gone", pinned: "1.0.0" }]);
|
|
151
|
+
expect(updates).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("carries scope through so a project-scoped drift entry is distinguishable from a global one", () => {
|
|
155
|
+
const latest = (name: string) => ({ papyrus: "0.38.1" })[name];
|
|
156
|
+
const updates = checkUpdates(latest, [{ name: "papyrus", pinned: "0.21.2", scope: "project" }]);
|
|
157
|
+
expect(updates).toEqual([
|
|
158
|
+
{ name: "papyrus", installed: "0.21.2", latest: "0.38.1", detectedAt: updates[0]!.detectedAt, scope: "project" },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Real, screenshot-confirmed bug: the mirror's own "latest" can lag
|
|
163
|
+
// behind what's actually installed (a stale sync, or the installed
|
|
164
|
+
// package genuinely jumped ahead). A plain !== check can't tell that
|
|
165
|
+
// apart from real drift and flags a permanent, un-clearable "update"
|
|
166
|
+
// pointing at an OLDER version than what's already installed.
|
|
167
|
+
it("never flags a package whose installed version is already ahead of the mirrored latest", () => {
|
|
168
|
+
const latest = (name: string) => ({ "pi-papyrus": "0.38.4" })[name];
|
|
169
|
+
const updates = checkUpdates(latest, [{ name: "pi-papyrus", installed: "0.41.0" }]);
|
|
170
|
+
expect(updates).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("still flags a genuine downgrade-to-latest scenario correctly using real semver ordering, not string equality", () => {
|
|
174
|
+
// 0.9.0 vs 0.10.0 -- string comparison would call these "different"
|
|
175
|
+
// either way, but semver must recognize 0.10.0 as newer, not just "not equal".
|
|
176
|
+
const latest = (name: string) => ({ "pi-lsp": "0.10.0" })[name];
|
|
177
|
+
const updates = checkUpdates(latest, [{ name: "pi-lsp", installed: "0.9.0" }]);
|
|
178
|
+
expect(updates).toEqual([{ name: "pi-lsp", installed: "0.9.0", latest: "0.10.0", detectedAt: updates[0]!.detectedAt }]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("falls back to inequality for non-semver values (a git ref or a literal dist-tag) since they aren't comparable at all", () => {
|
|
182
|
+
const latest = (name: string) => ({ "pi-git-pkg": "latest" })[name];
|
|
183
|
+
const updates = checkUpdates(latest, [{ name: "pi-git-pkg", installed: "latest" }]);
|
|
184
|
+
expect(updates).toEqual([]); // same non-semver string -- not different, not flagged
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
class NoopRegistry implements Registry {
|
|
189
|
+
async search(): Promise<SearchPage> {
|
|
190
|
+
return { results: [], total: 0 };
|
|
191
|
+
}
|
|
192
|
+
async searchPage(): Promise<SearchPage> {
|
|
193
|
+
return { results: [], total: 0 };
|
|
194
|
+
}
|
|
195
|
+
async searchAll() {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
async info(name: string): Promise<PkgInfo> {
|
|
199
|
+
return { name, version: "1.0.0" };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
class NoopInstaller implements Installer {
|
|
204
|
+
async install() {
|
|
205
|
+
return "ok";
|
|
206
|
+
}
|
|
207
|
+
async remove() {
|
|
208
|
+
return "ok";
|
|
209
|
+
}
|
|
210
|
+
async update(): Promise<UpdateOutcome> {
|
|
211
|
+
return { output: "ok", reloadRequired: false, alreadyUpToDate: true, pinned: false };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
describe("package.updates.project (daemon RPC wiring)", () => {
|
|
216
|
+
it("computes a live, cross-scope drift check on demand, distinct from package.updates' own persisted global-only snapshot", async () => {
|
|
217
|
+
const home = mkdtempSync(join(tmpdir(), "packed-updates-project-"));
|
|
218
|
+
writeFileSync(join(home, "settings.json"), JSON.stringify({ packages: ["npm:pi-global@1.0.0"] }));
|
|
219
|
+
const projectRoot = mkdtempSync(join(tmpdir(), "packed-updates-project-root-"));
|
|
220
|
+
const projectHome = join(projectRoot, ".pi");
|
|
221
|
+
mkdirSync(projectHome, { recursive: true });
|
|
222
|
+
writeFileSync(join(projectHome, "settings.json"), JSON.stringify({ packages: ["npm:papyrus@0.21.2"] }));
|
|
223
|
+
const stateDir = mkdtempSync(join(tmpdir(), "packed-updates-project-state-"));
|
|
224
|
+
const db = openDb(dbPath(stateDir));
|
|
225
|
+
replaceAll(
|
|
226
|
+
db,
|
|
227
|
+
[
|
|
228
|
+
{ name: "pi-global", version: "1.0.0" },
|
|
229
|
+
{ name: "papyrus", version: "0.38.1" },
|
|
230
|
+
],
|
|
231
|
+
"test",
|
|
232
|
+
);
|
|
233
|
+
db.close();
|
|
234
|
+
const app = createApp({ reg: new NoopRegistry(), inst: new NoopInstaller(), token: "test-token", stateDir, piHome: home });
|
|
235
|
+
const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
|
|
236
|
+
label: "Packed",
|
|
237
|
+
transport: (request) => app.fetch(request),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const globalSnapshot = await client.call("package.updates", {});
|
|
241
|
+
expect(globalSnapshot.updates).toEqual([]);
|
|
242
|
+
|
|
243
|
+
const result = await client.call("package.updates.project", { projectRoot });
|
|
244
|
+
expect(result.updates).toEqual([
|
|
245
|
+
{ name: "papyrus", installed: "0.21.2", latest: "0.38.1", detectedAt: result.updates[0]!.detectedAt, scope: "project" },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("updates store", () => {
|
|
251
|
+
it("roundtrips", async () => {
|
|
252
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-"));
|
|
253
|
+
const snap = { checkedAt: new Date().toISOString(), updates: [{ name: "a", installed: "1", latest: "2", detectedAt: "" }] };
|
|
254
|
+
await saveUpdates(dir, snap);
|
|
255
|
+
expect(await loadUpdates(dir)).toEqual(snap);
|
|
256
|
+
expect(await loadUpdates(join(dir, "nope"))).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("watcher producer", () => {
|
|
261
|
+
it("writes a snapshot on tick", async () => {
|
|
262
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-"));
|
|
263
|
+
const stop = startWatcher(
|
|
264
|
+
() => "0.9.0",
|
|
265
|
+
dir,
|
|
266
|
+
() => [{ name: "pi-extension-manager", pinned: "0.8.2" }],
|
|
267
|
+
{ intervalMs: 60_000 },
|
|
268
|
+
);
|
|
269
|
+
const deadline = Date.now() + 2000;
|
|
270
|
+
let snap: UpdatesSnapshot | undefined;
|
|
271
|
+
while (Date.now() < deadline) {
|
|
272
|
+
snap = await loadUpdates(dir);
|
|
273
|
+
if (snap?.updates.length) break;
|
|
274
|
+
await Bun.sleep(25);
|
|
275
|
+
}
|
|
276
|
+
stop();
|
|
277
|
+
expect(snap?.updates[0]?.latest).toBe("0.9.0");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("catalog status", () => {
|
|
282
|
+
it("stale when unsynced, fresh after sync", () => {
|
|
283
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-"));
|
|
284
|
+
expect(catalogStatus(dir, 6 * 3_600_000).stale).toBe(true);
|
|
285
|
+
const db = openDb(`${dir}/packed.db`);
|
|
286
|
+
replaceAll(db, [{ name: "a", version: "1" }], "test");
|
|
287
|
+
db.close();
|
|
288
|
+
expect(catalogStatus(dir, 6 * 3_600_000).stale).toBe(false);
|
|
289
|
+
expect(catalogStatus(dir, -1).stale).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Server } from "bun";
|
|
6
|
+
import { buildIndex, generateIndex, indexPath, indexStatus, readIndex, writeIndex } from "../src/index/build-index.ts";
|
|
7
|
+
import { dbPath, openDb, replaceAll } from "../src/packages/db.ts";
|
|
8
|
+
import { HttpRegistry } from "../src/registry/registry.ts";
|
|
9
|
+
|
|
10
|
+
// Guards the "never bulk GitHub calls" constraint against regression: a
|
|
11
|
+
// future edit could easily start threading a GitHub-commit fetcher through
|
|
12
|
+
// buildIndex the same way scoreTarget does. A source-level check catches
|
|
13
|
+
// that before it ships, independent of whatever fixture happens to be
|
|
14
|
+
// running in the behavioral tests below.
|
|
15
|
+
describe("index/build-index.ts never touches GitHub", () => {
|
|
16
|
+
it("does not import commit-freshness.ts's GitHub-facing exports", () => {
|
|
17
|
+
const source = readFileSync(new URL("../src/index/build-index.ts", import.meta.url), "utf8");
|
|
18
|
+
// Matches a real import specifier or a real call, not prose mentioning
|
|
19
|
+
// the file/function names in a comment (this file's own doc comment
|
|
20
|
+
// legitimately explains *why* it avoids commit-freshness.ts).
|
|
21
|
+
expect(source).not.toMatch(/from\s+["'][^"']*commit-freshness(?:\.ts)?["']|\b(?:githubLastCommitAt|createGithubLastCommitAt)\s*\(/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Real HTTP adapter against a fake npm upstream (Bun.serve) -- same pattern
|
|
26
|
+
// core.test.ts's HttpRegistry suite uses. No mocked fetch, no fake
|
|
27
|
+
// registry class: buildIndex runs against the real HttpRegistry.
|
|
28
|
+
describe("buildIndex", () => {
|
|
29
|
+
let server: Server<undefined>;
|
|
30
|
+
let registry: HttpRegistry;
|
|
31
|
+
let catalogDir: string;
|
|
32
|
+
let fetchedUrls: string[];
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
catalogDir = mkdtempSync(join(tmpdir(), "packed-index-catalog-"));
|
|
36
|
+
const db = openDb(dbPath(catalogDir));
|
|
37
|
+
replaceAll(
|
|
38
|
+
db,
|
|
39
|
+
[
|
|
40
|
+
{ name: "pi-alpha", version: "1.0.0" },
|
|
41
|
+
{ name: "pi-beta", version: "2.0.0" },
|
|
42
|
+
{ name: "pi-missing", version: "0.0.1" }, // 404s -- must be skipped, not fatal
|
|
43
|
+
],
|
|
44
|
+
"npm:keywords:pi-package",
|
|
45
|
+
);
|
|
46
|
+
db.close();
|
|
47
|
+
|
|
48
|
+
const infoDocs: Record<string, unknown> = {
|
|
49
|
+
"pi-alpha": {
|
|
50
|
+
name: "pi-alpha",
|
|
51
|
+
version: "1.0.0",
|
|
52
|
+
description: "alpha package for pi",
|
|
53
|
+
license: "MIT",
|
|
54
|
+
repository: { type: "git", url: "git+https://github.com/x/pi-alpha.git" },
|
|
55
|
+
keywords: ["pi-package"],
|
|
56
|
+
pi: { extensions: ["./src/index.ts"] },
|
|
57
|
+
dist: { integrity: "sha512-a" },
|
|
58
|
+
},
|
|
59
|
+
"pi-beta": {
|
|
60
|
+
name: "pi-beta",
|
|
61
|
+
version: "2.0.0",
|
|
62
|
+
description: "beta package for pi",
|
|
63
|
+
license: "MIT",
|
|
64
|
+
keywords: ["pi-package"],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const modifiedDocs: Record<string, string> = {
|
|
68
|
+
"pi-alpha": "2026-01-01T00:00:00.000Z",
|
|
69
|
+
"pi-beta": "2026-02-01T00:00:00.000Z",
|
|
70
|
+
"@earendil-works/pi-coding-agent": "2026-03-01T00:00:00.000Z",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
server = Bun.serve({
|
|
74
|
+
port: 0,
|
|
75
|
+
fetch(req) {
|
|
76
|
+
const url = new URL(req.url);
|
|
77
|
+
const name = decodeURIComponent(url.pathname.replace(/^\//, "").replace(/\/latest$/, ""));
|
|
78
|
+
if (url.pathname.endsWith("/latest")) {
|
|
79
|
+
const doc = infoDocs[name];
|
|
80
|
+
return doc ? Response.json(doc) : new Response("not found", { status: 404 });
|
|
81
|
+
}
|
|
82
|
+
if (url.pathname.startsWith("/downloads/point/")) {
|
|
83
|
+
return Response.json({ downloads: 5 });
|
|
84
|
+
}
|
|
85
|
+
const modified = modifiedDocs[name];
|
|
86
|
+
return modified ? Response.json({ modified }) : new Response("not found", { status: 404 });
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
registry = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1, `http://127.0.0.1:${server.port}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(() => server.stop(true));
|
|
93
|
+
|
|
94
|
+
it("builds one entry per cataloged package, skipping a lookup failure rather than failing the whole run", async () => {
|
|
95
|
+
const index = await buildIndex(registry, catalogDir, { delayMs: 0, currentPiVersion: async () => "0.83.0" });
|
|
96
|
+
expect(index.packages.map((p) => p.name).sort()).toEqual(["pi-alpha", "pi-beta"]); // pi-missing skipped
|
|
97
|
+
expect(typeof index.generatedAt).toBe("string");
|
|
98
|
+
const alpha = index.packages.find((p) => p.name === "pi-alpha")!;
|
|
99
|
+
expect(alpha.dimensions.trust.status).not.toBe("unknown");
|
|
100
|
+
expect(alpha.dimensions.discoverability.evidence).toContain("pi-package keyword");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("never calls GitHub, even for a candidate declaring a github.com repository -- freshness always falls back to the npm publish-date proxy", async () => {
|
|
104
|
+
const realFetch = globalThis.fetch;
|
|
105
|
+
fetchedUrls = [];
|
|
106
|
+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
107
|
+
fetchedUrls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
108
|
+
return realFetch(input, init);
|
|
109
|
+
}) as typeof fetch;
|
|
110
|
+
try {
|
|
111
|
+
const index = await buildIndex(registry, catalogDir, { delayMs: 0, currentPiVersion: async () => "0.83.0" });
|
|
112
|
+
expect(fetchedUrls.some((u) => u.includes("github"))).toBe(false);
|
|
113
|
+
const alpha = index.packages.find((p) => p.name === "pi-alpha")!;
|
|
114
|
+
// pi-alpha declares a real github.com repository, yet freshness must
|
|
115
|
+
// still be sourced from the npm publish-date proxy, never a commit.
|
|
116
|
+
expect(alpha.dimensions.freshness.evidence.some((e) => e.includes("npm publish date"))).toBe(true);
|
|
117
|
+
expect(alpha.dimensions.freshness.evidence.some((e) => e.includes("last real commit"))).toBe(false);
|
|
118
|
+
} finally {
|
|
119
|
+
globalThis.fetch = realFetch;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("buildIndex bounds", () => {
|
|
125
|
+
it("never calls downloads() -- confirmed live to trigger npm's 429s at real catalog scale -- and truncates past maxPackages, marking the result", async () => {
|
|
126
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-index-bounds-"));
|
|
127
|
+
const db = openDb(dbPath(dir));
|
|
128
|
+
replaceAll(
|
|
129
|
+
db,
|
|
130
|
+
[
|
|
131
|
+
{ name: "pi-one", version: "1.0.0" },
|
|
132
|
+
{ name: "pi-two", version: "1.0.0" },
|
|
133
|
+
{ name: "pi-three", version: "1.0.0" },
|
|
134
|
+
],
|
|
135
|
+
"test",
|
|
136
|
+
);
|
|
137
|
+
db.close();
|
|
138
|
+
let downloadsCalled = false;
|
|
139
|
+
const server = Bun.serve({
|
|
140
|
+
port: 0,
|
|
141
|
+
fetch(req) {
|
|
142
|
+
const url = new URL(req.url);
|
|
143
|
+
if (url.pathname.startsWith("/downloads/")) {
|
|
144
|
+
downloadsCalled = true;
|
|
145
|
+
return Response.json({ downloads: 1 });
|
|
146
|
+
}
|
|
147
|
+
if (url.pathname.endsWith("/latest")) return Response.json({ name: url.pathname.split("/")[1], version: "1.0.0" });
|
|
148
|
+
return new Response("not found", { status: 404 });
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1, `http://127.0.0.1:${server.port}`);
|
|
153
|
+
const index = await buildIndex(reg, dir, { delayMs: 0, maxPackages: 2 });
|
|
154
|
+
expect(index.packages).toHaveLength(2); // 3 cataloged, bounded to 2
|
|
155
|
+
expect(index.truncated).toBe(true);
|
|
156
|
+
expect(downloadsCalled).toBe(false);
|
|
157
|
+
} finally {
|
|
158
|
+
server.stop(true);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("collapses two concurrent callers into one run -- confirmed live to matter: the daemon's own maintenance tick and an on-demand CLI build overlapping compounded into a shutdown the daemon's SIGTERM grace period couldn't outlast", async () => {
|
|
163
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-index-concurrent-"));
|
|
164
|
+
const db = openDb(dbPath(dir));
|
|
165
|
+
replaceAll(db, [{ name: "pi-one", version: "1.0.0" }], "test");
|
|
166
|
+
db.close();
|
|
167
|
+
let infoCalls = 0;
|
|
168
|
+
const server = Bun.serve({
|
|
169
|
+
port: 0,
|
|
170
|
+
async fetch(req) {
|
|
171
|
+
const url = new URL(req.url);
|
|
172
|
+
if (url.pathname.endsWith("/latest")) {
|
|
173
|
+
infoCalls++;
|
|
174
|
+
await Bun.sleep(30);
|
|
175
|
+
return Response.json({ name: "pi-one", version: "1.0.0" });
|
|
176
|
+
}
|
|
177
|
+
return new Response("not found", { status: 404 });
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1, `http://127.0.0.1:${server.port}`);
|
|
182
|
+
const [a, b] = await Promise.all([buildIndex(reg, dir, { delayMs: 0 }), buildIndex(reg, dir, { delayMs: 0 })]);
|
|
183
|
+
expect(a).toBe(b); // the very same result object -- one real run, not two
|
|
184
|
+
expect(infoCalls).toBe(1); // never doubled
|
|
185
|
+
} finally {
|
|
186
|
+
server.stop(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("buildIndex incremental delta scanning", () => {
|
|
192
|
+
it("partitions the catalog into New/Changed/Unchanged against the prior index -- Unchanged makes zero live registry calls, New and Changed do", async () => {
|
|
193
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-index-delta-"));
|
|
194
|
+
const db = openDb(dbPath(dir));
|
|
195
|
+
replaceAll(
|
|
196
|
+
db,
|
|
197
|
+
[
|
|
198
|
+
{ name: "pi-new", version: "1.0.0", date: "2026-03-01T00:00:00.000Z" }, // absent from prior index
|
|
199
|
+
{ name: "pi-changed", version: "2.0.0", date: "2026-04-01T00:00:00.000Z" }, // date advanced since last scan
|
|
200
|
+
{ name: "pi-unchanged", version: "1.5.0", date: "2026-01-01T00:00:00.000Z" }, // date identical to last scan
|
|
201
|
+
],
|
|
202
|
+
"test",
|
|
203
|
+
);
|
|
204
|
+
db.close();
|
|
205
|
+
|
|
206
|
+
const priorIndex = {
|
|
207
|
+
generatedAt: "2026-02-01T00:00:00.000Z",
|
|
208
|
+
packages: [
|
|
209
|
+
{
|
|
210
|
+
name: "pi-changed",
|
|
211
|
+
version: "1.9.0",
|
|
212
|
+
date: "2026-01-01T00:00:00.000Z",
|
|
213
|
+
dimensions: { discoverability: { status: "ready" as const, met: 1, total: 1, evidence: ["stale"], actions: [] } } as never,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "pi-unchanged",
|
|
217
|
+
version: "1.5.0",
|
|
218
|
+
date: "2026-01-01T00:00:00.000Z",
|
|
219
|
+
dimensions: {
|
|
220
|
+
discoverability: { status: "ready" as const, met: 1, total: 1, evidence: ["carried forward"], actions: [] },
|
|
221
|
+
} as never,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
truncated: false,
|
|
225
|
+
};
|
|
226
|
+
await writeIndex(indexPath(dir), priorIndex);
|
|
227
|
+
|
|
228
|
+
const calledInfo: string[] = [];
|
|
229
|
+
const server = Bun.serve({
|
|
230
|
+
port: 0,
|
|
231
|
+
fetch(req) {
|
|
232
|
+
const url = new URL(req.url);
|
|
233
|
+
if (url.pathname.endsWith("/latest")) {
|
|
234
|
+
const name = decodeURIComponent(url.pathname.replace(/^\//, "").replace(/\/latest$/, ""));
|
|
235
|
+
calledInfo.push(name);
|
|
236
|
+
return Response.json({ name, version: name === "pi-changed" ? "2.0.0" : "1.0.0", description: "real fixture" });
|
|
237
|
+
}
|
|
238
|
+
return new Response("not found", { status: 404 });
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
try {
|
|
242
|
+
const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1, `http://127.0.0.1:${server.port}`);
|
|
243
|
+
const index = await buildIndex(reg, dir, { delayMs: 0 });
|
|
244
|
+
|
|
245
|
+
// Unchanged never triggered a live info() call, and its prior
|
|
246
|
+
// dimensions/date are preserved verbatim.
|
|
247
|
+
expect(calledInfo.sort()).toEqual(["pi-changed", "pi-new"]);
|
|
248
|
+
const unchanged = index.packages.find((p) => p.name === "pi-unchanged")!;
|
|
249
|
+
expect(unchanged.dimensions.discoverability.evidence).toEqual(["carried forward"]);
|
|
250
|
+
expect(unchanged.date).toBe("2026-01-01T00:00:00.000Z");
|
|
251
|
+
|
|
252
|
+
// Changed and New both got a real, fresh score.
|
|
253
|
+
const changed = index.packages.find((p) => p.name === "pi-changed")!;
|
|
254
|
+
expect(changed.version).toBe("2.0.0");
|
|
255
|
+
expect(changed.date).toBe("2026-04-01T00:00:00.000Z");
|
|
256
|
+
const fresh = index.packages.find((p) => p.name === "pi-new")!;
|
|
257
|
+
expect(fresh.date).toBe("2026-03-01T00:00:00.000Z");
|
|
258
|
+
expect(index.packages).toHaveLength(3);
|
|
259
|
+
} finally {
|
|
260
|
+
server.stop(true);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("maxPackages bounds only the New+Changed live-call queue -- Unchanged entries beyond that count still complete", async () => {
|
|
265
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-index-delta-bound-"));
|
|
266
|
+
const db = openDb(dbPath(dir));
|
|
267
|
+
// 5 unchanged entries alone already exceed maxPackages: 1 below.
|
|
268
|
+
replaceAll(
|
|
269
|
+
db,
|
|
270
|
+
[
|
|
271
|
+
{ name: "pi-a", version: "1.0.0", date: "2026-01-01T00:00:00.000Z" },
|
|
272
|
+
{ name: "pi-b", version: "1.0.0", date: "2026-01-01T00:00:00.000Z" },
|
|
273
|
+
{ name: "pi-c", version: "1.0.0", date: "2026-01-01T00:00:00.000Z" },
|
|
274
|
+
{ name: "pi-d", version: "1.0.0", date: "2026-01-01T00:00:00.000Z" },
|
|
275
|
+
{ name: "pi-e", version: "1.0.0", date: "2026-01-01T00:00:00.000Z" },
|
|
276
|
+
],
|
|
277
|
+
"test",
|
|
278
|
+
);
|
|
279
|
+
db.close();
|
|
280
|
+
const priorIndex = {
|
|
281
|
+
generatedAt: "2026-01-01T00:00:00.000Z",
|
|
282
|
+
packages: ["pi-a", "pi-b", "pi-c", "pi-d", "pi-e"].map((name) => ({
|
|
283
|
+
name,
|
|
284
|
+
version: "1.0.0",
|
|
285
|
+
date: "2026-01-01T00:00:00.000Z",
|
|
286
|
+
dimensions: {} as never,
|
|
287
|
+
})),
|
|
288
|
+
truncated: false,
|
|
289
|
+
};
|
|
290
|
+
await writeIndex(indexPath(dir), priorIndex);
|
|
291
|
+
let liveCalls = 0;
|
|
292
|
+
const server = Bun.serve({
|
|
293
|
+
port: 0,
|
|
294
|
+
fetch() {
|
|
295
|
+
liveCalls++;
|
|
296
|
+
return new Response("not found", { status: 404 });
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
try {
|
|
300
|
+
const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1, `http://127.0.0.1:${server.port}`);
|
|
301
|
+
const index = await buildIndex(reg, dir, { delayMs: 0, maxPackages: 1 });
|
|
302
|
+
expect(index.packages).toHaveLength(5); // all 5 Unchanged carried forward despite maxPackages: 1
|
|
303
|
+
expect(index.truncated).toBe(false); // nothing needed live scoring, so nothing was truncated
|
|
304
|
+
expect(liveCalls).toBe(0);
|
|
305
|
+
} finally {
|
|
306
|
+
server.stop(true);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("index persistence", () => {
|
|
312
|
+
it("writes, reads, and reports staleness", async () => {
|
|
313
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-index-store-"));
|
|
314
|
+
const path = indexPath(dir);
|
|
315
|
+
expect(readIndex(path)).toBeUndefined();
|
|
316
|
+
expect(indexStatus(path, 1_000).stale).toBe(true);
|
|
317
|
+
|
|
318
|
+
const index = {
|
|
319
|
+
generatedAt: new Date().toISOString(),
|
|
320
|
+
packages: [{ name: "pi-alpha", version: "1.0.0", dimensions: {} as never }],
|
|
321
|
+
truncated: false,
|
|
322
|
+
};
|
|
323
|
+
await writeIndex(path, index);
|
|
324
|
+
expect(readIndex(path)).toEqual(index);
|
|
325
|
+
expect(indexStatus(path, 60_000)).toMatchObject({ stale: false, packageCount: 1 });
|
|
326
|
+
|
|
327
|
+
const old = { generatedAt: new Date(Date.now() - 100_000).toISOString(), packages: [], truncated: false };
|
|
328
|
+
await writeIndex(path, old);
|
|
329
|
+
expect(indexStatus(path, 1_000).stale).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("generateIndex builds and writes in one call", async () => {
|
|
333
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-index-generate-"));
|
|
334
|
+
const db = openDb(dbPath(dir));
|
|
335
|
+
replaceAll(db, [{ name: "pi-alpha", version: "1.0.0" }], "npm:keywords:pi-package");
|
|
336
|
+
db.close();
|
|
337
|
+
const server = Bun.serve({
|
|
338
|
+
port: 0,
|
|
339
|
+
fetch(req) {
|
|
340
|
+
const url = new URL(req.url);
|
|
341
|
+
if (url.pathname === "/pi-alpha/latest") return Response.json({ name: "pi-alpha", version: "1.0.0" });
|
|
342
|
+
return new Response("not found", { status: 404 });
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
try {
|
|
346
|
+
const reg = new HttpRegistry(`http://127.0.0.1:${server.port}`, 250, 0, 1, `http://127.0.0.1:${server.port}`);
|
|
347
|
+
const index = await generateIndex(reg, dir, indexPath(dir), { delayMs: 0 });
|
|
348
|
+
expect(readIndex(indexPath(dir))).toEqual(index);
|
|
349
|
+
} finally {
|
|
350
|
+
server.stop(true);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|