@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,375 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Installer, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
|
|
6
|
+
import {
|
|
7
|
+
bundledEcosystemManifestPath,
|
|
8
|
+
decodeSetupManifest,
|
|
9
|
+
type GitResolutionPort,
|
|
10
|
+
SetupManager,
|
|
11
|
+
type SetupManifest,
|
|
12
|
+
} from "../src/setup/setup.ts";
|
|
13
|
+
|
|
14
|
+
class RegistryFixture implements Registry {
|
|
15
|
+
async search(): Promise<SearchPage> {
|
|
16
|
+
return { results: [], total: 0 };
|
|
17
|
+
}
|
|
18
|
+
async searchPage(): Promise<SearchPage> {
|
|
19
|
+
return { results: [], total: 0 };
|
|
20
|
+
}
|
|
21
|
+
async searchAll() {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
async info(name: string): Promise<PkgInfo> {
|
|
25
|
+
return { name, version: "1.2.3", publication: { integrity: "sha512-demo", trustedPublisher: "unknown" } };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class InstallerFixture implements Installer {
|
|
30
|
+
installed: string[] = [];
|
|
31
|
+
projectScoped: string[] = [];
|
|
32
|
+
removed: string[] = [];
|
|
33
|
+
fail = false;
|
|
34
|
+
async install(source: string, options?: { local?: boolean }) {
|
|
35
|
+
this.installed.push(source);
|
|
36
|
+
if (options?.local) this.projectScoped.push(source);
|
|
37
|
+
if (this.fail) throw new Error("install failed");
|
|
38
|
+
return `Installed ${source}`;
|
|
39
|
+
}
|
|
40
|
+
async update(source: string): Promise<UpdateOutcome> {
|
|
41
|
+
this.installed.push(source);
|
|
42
|
+
return { output: `Updated ${source}`, reloadRequired: true, alreadyUpToDate: false, pinned: true };
|
|
43
|
+
}
|
|
44
|
+
async remove(source: string) {
|
|
45
|
+
this.removed.push(source);
|
|
46
|
+
return `Removed ${source}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class GitFixture implements GitResolutionPort {
|
|
51
|
+
constructor(private commit = "a".repeat(40)) {}
|
|
52
|
+
async resolve(source: string) {
|
|
53
|
+
return { commit: this.commit, source: `${source.replace(/@[^@/]+$/, "")}@${this.commit}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function piHome(withPackage = true): string {
|
|
58
|
+
const root = mkdtempSync(join(tmpdir(), "packed-setup-home-"));
|
|
59
|
+
const packages = withPackage ? ["npm:pi-demo"] : [];
|
|
60
|
+
writeFileSync(join(root, "settings.json"), JSON.stringify({ packages }));
|
|
61
|
+
if (withPackage) {
|
|
62
|
+
mkdirSync(join(root, "npm/node_modules/pi-demo"), { recursive: true });
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(root, "npm/node_modules/pi-demo/package.json"),
|
|
65
|
+
JSON.stringify({ name: "pi-demo", version: "1.2.3", scripts: { postinstall: "touch setup-export-ran-code" } }),
|
|
66
|
+
);
|
|
67
|
+
writeFileSync(
|
|
68
|
+
join(root, "npm/package-lock.json"),
|
|
69
|
+
JSON.stringify({ packages: { "node_modules/pi-demo": { integrity: "sha512-demo" } } }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return root;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function project(): string {
|
|
76
|
+
const root = mkdtempSync(join(tmpdir(), "packed-setup-project-"));
|
|
77
|
+
mkdirSync(join(root, ".pi"), { recursive: true });
|
|
78
|
+
return root;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const manifest: SetupManifest = {
|
|
82
|
+
$schema: "./schema/pi-setup-v1.schema.json",
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
packages: [{ kind: "npm", scope: "global", source: "npm:pi-demo@1.2.3", resolved: "1.2.3", integrity: "sha512-demo" }],
|
|
85
|
+
profiles: {
|
|
86
|
+
work: {
|
|
87
|
+
scope: "global",
|
|
88
|
+
provider: "openai",
|
|
89
|
+
model: "gpt",
|
|
90
|
+
thinkingLevel: "high",
|
|
91
|
+
tools: ["read"],
|
|
92
|
+
instructions: "Keep work usage scoped.",
|
|
93
|
+
theme: "dark",
|
|
94
|
+
allowedModels: ["openai/*"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
defaultProfile: "work",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
describe("Pi setup manifest walking skeleton", () => {
|
|
101
|
+
it("ships the versioned JSON Schema in the npm package", () => {
|
|
102
|
+
const serviceRoot = new URL("..", import.meta.url);
|
|
103
|
+
const packageRoot = new URL("../..", import.meta.url);
|
|
104
|
+
const schema = JSON.parse(readFileSync(new URL("schema/pi-setup-v1.schema.json", serviceRoot), "utf8"));
|
|
105
|
+
const packageJson = JSON.parse(readFileSync(new URL("package.json", packageRoot), "utf8"));
|
|
106
|
+
expect(schema.properties.schemaVersion.const).toBe(1);
|
|
107
|
+
expect(packageJson.files).toContain("service");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("ships a curated, schema-valid @danypops ecosystem starter manifest, resolvable without a network fetch", () => {
|
|
111
|
+
const path = bundledEcosystemManifestPath();
|
|
112
|
+
expect(existsSync(path)).toBe(true);
|
|
113
|
+
// decodeSetupManifest enforces the same schema every real manifest is held to --
|
|
114
|
+
// this is not a hand-waved fixture, it's the actual file `packed setup apply
|
|
115
|
+
// --ecosystem` reads.
|
|
116
|
+
const manifest = decodeSetupManifest(readFileSync(path, "utf8"));
|
|
117
|
+
expect(manifest.packages.length).toBeGreaterThan(0);
|
|
118
|
+
expect(manifest.packages.every((pkg) => pkg.kind === "npm" && pkg.scope === "global")).toBe(true);
|
|
119
|
+
// Every entry is a real, independently-verifiable npm-published package --
|
|
120
|
+
// not a fabricated placeholder version/integrity pair.
|
|
121
|
+
const names = manifest.packages.map((pkg) => (pkg.kind === "npm" ? pkg.source : ""));
|
|
122
|
+
expect(names).toContain("npm:@danypops/pi-packed");
|
|
123
|
+
expect(new Set(names).size).toBe(names.length);
|
|
124
|
+
const packageJson = JSON.parse(readFileSync(new URL("package.json", new URL("../..", import.meta.url)), "utf8"));
|
|
125
|
+
expect(packageJson.files).toContain("service");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("plans real operations against the bundled ecosystem manifest through the exact same SetupManager path any other manifest uses", async () => {
|
|
129
|
+
const home = piHome(false);
|
|
130
|
+
const manager = new SetupManager(new RegistryFixture(), new InstallerFixture(), home);
|
|
131
|
+
const plan = await manager.plan(bundledEcosystemManifestPath());
|
|
132
|
+
expect(plan.ok).toBe(true);
|
|
133
|
+
// Nothing installed yet in this fake piHome -- every curated package should plan an install.
|
|
134
|
+
expect(plan.operations.length).toBeGreaterThan(0);
|
|
135
|
+
expect(plan.operations.every((operation) => operation.kind === "install-package")).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("exports one locked npm package and scoped profile canonically", async () => {
|
|
139
|
+
const home = piHome();
|
|
140
|
+
writeFileSync(join(home, "profiles.json"), JSON.stringify({ work: { provider: "openai", model: "gpt", tools: ["read"] } }));
|
|
141
|
+
const root = project();
|
|
142
|
+
const manager = new SetupManager(new RegistryFixture(), new InstallerFixture(), home);
|
|
143
|
+
const report = await manager.export(root);
|
|
144
|
+
expect(report.ok).toBe(true);
|
|
145
|
+
expect(report.manifest.packages).toEqual([
|
|
146
|
+
{ kind: "npm", scope: "global", source: "npm:pi-demo@1.2.3", resolved: "1.2.3", integrity: "sha512-demo" },
|
|
147
|
+
]);
|
|
148
|
+
expect(report.manifest.profiles.work).toMatchObject({ scope: "global", provider: "openai", model: "gpt" });
|
|
149
|
+
expect(report.path).toBe(join(root, "pi-setup.json"));
|
|
150
|
+
expect(existsSync(join(root, "schema/pi-setup-v1.schema.json"))).toBe(true);
|
|
151
|
+
const first = readFileSync(report.path, "utf8");
|
|
152
|
+
await manager.export(root, { force: true });
|
|
153
|
+
expect(readFileSync(report.path, "utf8")).toBe(first);
|
|
154
|
+
expect(first.endsWith("\n")).toBe(true);
|
|
155
|
+
expect(existsSync(join(home, "setup-export-ran-code"))).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("plans without mutation and applies locked packages before atomic profile merge", async () => {
|
|
159
|
+
const home = piHome(false);
|
|
160
|
+
writeFileSync(join(home, "profiles.json"), JSON.stringify({ personal: { provider: "openai", model: "mini" } }));
|
|
161
|
+
const root = project();
|
|
162
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(manifest));
|
|
163
|
+
const installer = new InstallerFixture();
|
|
164
|
+
const manager = new SetupManager(new RegistryFixture(), installer, home);
|
|
165
|
+
const plan = await manager.plan(join(root, "pi-setup.json"));
|
|
166
|
+
expect(plan.operations.map((item) => item.kind)).toEqual(["install-package", "write-profile"]);
|
|
167
|
+
expect(installer.installed).toEqual([]);
|
|
168
|
+
const applied = await manager.apply(join(root, "pi-setup.json"));
|
|
169
|
+
expect(applied.ok).toBe(true);
|
|
170
|
+
expect(applied.reloadRequired).toBe(true);
|
|
171
|
+
expect(installer.installed).toEqual(["npm:pi-demo@1.2.3"]);
|
|
172
|
+
expect(JSON.parse(readFileSync(join(home, "profiles.json"), "utf8"))).toEqual({
|
|
173
|
+
personal: { provider: "openai", model: "mini" },
|
|
174
|
+
work: expect.objectContaining({ provider: "openai", model: "gpt" }),
|
|
175
|
+
});
|
|
176
|
+
expect(readdirSync(home).some((name) => name.endsWith(".tmp"))).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("produces an empty plan when locked package and profile state already match", async () => {
|
|
180
|
+
const home = piHome();
|
|
181
|
+
writeFileSync(
|
|
182
|
+
join(home, "profiles.json"),
|
|
183
|
+
JSON.stringify({
|
|
184
|
+
work: {
|
|
185
|
+
provider: "openai",
|
|
186
|
+
model: "gpt",
|
|
187
|
+
thinkingLevel: "high",
|
|
188
|
+
tools: ["read"],
|
|
189
|
+
instructions: "Keep work usage scoped.",
|
|
190
|
+
theme: "dark",
|
|
191
|
+
allowedModels: ["openai/*"],
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
const root = project();
|
|
196
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(manifest));
|
|
197
|
+
const plan = await new SetupManager(new RegistryFixture(), new InstallerFixture(), home).plan(join(root, "pi-setup.json"));
|
|
198
|
+
expect(plan).toMatchObject({ ok: true, operations: [] });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("rejects unknown fields, unsupported versions, secret-like instructions, and credential sources", () => {
|
|
202
|
+
for (const value of [
|
|
203
|
+
{ ...manifest, schemaVersion: 2 },
|
|
204
|
+
{ ...manifest, token: "x" },
|
|
205
|
+
{ ...manifest, profiles: { work: { scope: "global", instructions: "API_KEY=secret-value" } } },
|
|
206
|
+
{ ...manifest, packages: [{ source: "https://user:secret@example.test/pkg.tgz", resolved: "1" }] },
|
|
207
|
+
])
|
|
208
|
+
expect(() => decodeSetupManifest(JSON.stringify(value))).toThrow();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does not write profiles after a package operation fails", async () => {
|
|
212
|
+
const home = piHome(false);
|
|
213
|
+
const root = project();
|
|
214
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(manifest));
|
|
215
|
+
const installer = new InstallerFixture();
|
|
216
|
+
installer.fail = true;
|
|
217
|
+
const result = await new SetupManager(new RegistryFixture(), installer, home).apply(join(root, "pi-setup.json"));
|
|
218
|
+
expect(result.ok).toBe(false);
|
|
219
|
+
expect(result.diagnostics[0]?.code).toBe("SETUP_APPLY_FAILED");
|
|
220
|
+
expect(existsSync(join(home, "profiles.json"))).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("resolves git and HTTPS sources immutably and requires explicit machine-local export", async () => {
|
|
224
|
+
const home = piHome(false);
|
|
225
|
+
writeFileSync(
|
|
226
|
+
join(home, "settings.json"),
|
|
227
|
+
JSON.stringify({ packages: ["git:github.com/example/tool@main", "https://github.com/example/other", "./local-tool"] }),
|
|
228
|
+
);
|
|
229
|
+
const root = project();
|
|
230
|
+
const manager = new SetupManager(new RegistryFixture(), new InstallerFixture(), home, new GitFixture());
|
|
231
|
+
const rejected = await manager.export(root);
|
|
232
|
+
expect(rejected.ok).toBe(false);
|
|
233
|
+
expect(rejected.diagnostics.some((item) => item.message.includes("--machine-local"))).toBe(true);
|
|
234
|
+
const report = await manager.export(root, { machineLocal: true });
|
|
235
|
+
expect(report.ok).toBe(true);
|
|
236
|
+
expect(report.manifest.packages.map((pkg) => pkg.kind).sort()).toEqual(["git", "https", "local"]);
|
|
237
|
+
expect(report.manifest.packages.filter((pkg) => pkg.kind !== "local").every((pkg) => pkg.source.endsWith("a".repeat(40)))).toBe(true);
|
|
238
|
+
expect(report.manifest.packages.find((pkg) => pkg.kind === "local")).toMatchObject({
|
|
239
|
+
machineLocal: true,
|
|
240
|
+
resolved: join(home, "local-tool"),
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("updates immutable resolutions without applying packages", async () => {
|
|
245
|
+
const home = piHome(false);
|
|
246
|
+
const root = project();
|
|
247
|
+
const old = "a".repeat(40),
|
|
248
|
+
next = "b".repeat(40);
|
|
249
|
+
const changing: SetupManifest = {
|
|
250
|
+
...manifest,
|
|
251
|
+
packages: [
|
|
252
|
+
{
|
|
253
|
+
kind: "git",
|
|
254
|
+
scope: "global",
|
|
255
|
+
requested: "git:github.com/example/tool@main",
|
|
256
|
+
source: `git:github.com/example/tool@${old}`,
|
|
257
|
+
resolved: old,
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(changing));
|
|
262
|
+
const installer = new InstallerFixture();
|
|
263
|
+
const result = await new SetupManager(new RegistryFixture(), installer, home, new GitFixture(next)).update(root);
|
|
264
|
+
expect(result).toMatchObject({ ok: true, wrote: true, updated: 1 });
|
|
265
|
+
expect(result.manifest.packages[0]).toMatchObject({
|
|
266
|
+
requested: "git:github.com/example/tool@main",
|
|
267
|
+
source: `git:github.com/example/tool@${next}`,
|
|
268
|
+
resolved: next,
|
|
269
|
+
});
|
|
270
|
+
expect(installer.installed).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("plans deterministic prune last and applies removals after desired profile state", async () => {
|
|
274
|
+
const home = piHome();
|
|
275
|
+
writeFileSync(join(home, "settings.json"), JSON.stringify({ packages: ["npm:pi-demo", "npm:old-tool"] }));
|
|
276
|
+
writeFileSync(join(home, "profiles.json"), JSON.stringify({ work: { model: "old" }, obsolete: { model: "old" } }));
|
|
277
|
+
const root = project();
|
|
278
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(manifest));
|
|
279
|
+
const installer = new InstallerFixture();
|
|
280
|
+
const manager = new SetupManager(new RegistryFixture(), installer, home);
|
|
281
|
+
const plan = await manager.plan(root, { prune: true });
|
|
282
|
+
expect(plan.operations.map((item) => item.kind)).toEqual(["write-profile", "remove-profile", "remove-package"]);
|
|
283
|
+
const result = await manager.apply(root, { prune: true });
|
|
284
|
+
expect(result.ok).toBe(true);
|
|
285
|
+
expect(installer.removed).toEqual(["npm:old-tool"]);
|
|
286
|
+
expect(JSON.parse(readFileSync(join(home, "profiles.json"), "utf8"))).toEqual({
|
|
287
|
+
work: {
|
|
288
|
+
provider: "openai",
|
|
289
|
+
model: "gpt",
|
|
290
|
+
thinkingLevel: "high",
|
|
291
|
+
tools: ["read"],
|
|
292
|
+
instructions: "Keep work usage scoped.",
|
|
293
|
+
theme: "dark",
|
|
294
|
+
allowedModels: ["openai/*"],
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("repairs an installed npm package whose lock integrity differs", async () => {
|
|
300
|
+
const home = piHome();
|
|
301
|
+
writeFileSync(
|
|
302
|
+
join(home, "npm/package-lock.json"),
|
|
303
|
+
JSON.stringify({ packages: { "node_modules/pi-demo": { integrity: "sha512-other" } } }),
|
|
304
|
+
);
|
|
305
|
+
const root = project();
|
|
306
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(manifest));
|
|
307
|
+
const plan = await new SetupManager(new RegistryFixture(), new InstallerFixture(), home).plan(root);
|
|
308
|
+
expect(plan.operations[0]).toMatchObject({ kind: "update-package", packageName: "pi-demo" });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("retries a failed package operation and preserves project package scope", async () => {
|
|
312
|
+
const home = piHome(false);
|
|
313
|
+
const root = project();
|
|
314
|
+
const scoped: SetupManifest = { ...manifest, packages: [{ ...manifest.packages[0]!, scope: "project" }] };
|
|
315
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(scoped));
|
|
316
|
+
const installer = new InstallerFixture();
|
|
317
|
+
installer.fail = true;
|
|
318
|
+
const manager = new SetupManager(new RegistryFixture(), installer, home);
|
|
319
|
+
expect((await manager.apply(root)).ok).toBe(false);
|
|
320
|
+
installer.fail = false;
|
|
321
|
+
expect((await manager.apply(root)).ok).toBe(true);
|
|
322
|
+
expect(installer.projectScoped).toEqual(["npm:pi-demo@1.2.3", "npm:pi-demo@1.2.3"]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("rejects mutable git manifests and credential-bearing URLs", () => {
|
|
326
|
+
const commit = "a".repeat(40);
|
|
327
|
+
expect(() =>
|
|
328
|
+
decodeSetupManifest(
|
|
329
|
+
JSON.stringify({
|
|
330
|
+
...manifest,
|
|
331
|
+
packages: [
|
|
332
|
+
{
|
|
333
|
+
kind: "git",
|
|
334
|
+
scope: "global",
|
|
335
|
+
requested: "git:github.com/example/tool@main",
|
|
336
|
+
source: "git:github.com/example/tool@main",
|
|
337
|
+
resolved: commit,
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
}),
|
|
341
|
+
),
|
|
342
|
+
).toThrow("SETUP_GIT_NOT_IMMUTABLE");
|
|
343
|
+
expect(() =>
|
|
344
|
+
decodeSetupManifest(
|
|
345
|
+
JSON.stringify({
|
|
346
|
+
...manifest,
|
|
347
|
+
packages: [
|
|
348
|
+
{
|
|
349
|
+
kind: "https",
|
|
350
|
+
scope: "global",
|
|
351
|
+
requested: "https://github.com/example/tool",
|
|
352
|
+
source: `https://user:token@github.com/example/tool@${commit}`,
|
|
353
|
+
resolved: commit,
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
}),
|
|
357
|
+
),
|
|
358
|
+
).toThrow("SETUP_CREDENTIAL_SOURCE");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("keeps project profiles project-scoped", async () => {
|
|
362
|
+
const home = piHome(false);
|
|
363
|
+
const root = project();
|
|
364
|
+
const value: SetupManifest = {
|
|
365
|
+
...manifest,
|
|
366
|
+
packages: [],
|
|
367
|
+
profiles: { project: { scope: "project", theme: "dark" } },
|
|
368
|
+
defaultProfile: "project",
|
|
369
|
+
};
|
|
370
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(value));
|
|
371
|
+
await new SetupManager(new RegistryFixture(), new InstallerFixture(), home).apply(join(root, "pi-setup.json"));
|
|
372
|
+
expect(JSON.parse(readFileSync(join(root, ".pi/profiles.json"), "utf8"))).toEqual({ project: { theme: "dark" } });
|
|
373
|
+
expect(existsSync(join(home, "profiles.json"))).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { checkPackage } from "../src/adoption/check.ts";
|
|
7
|
+
import { runExtensionSmoke } from "../src/adoption/smoke.ts";
|
|
8
|
+
|
|
9
|
+
const roots: string[] = [];
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Binary presence alone doesn't prove bwrap actually works -- some container
|
|
16
|
+
* runtimes (seen live in GitHub's hosted ubuntu-latest runner) ship the
|
|
17
|
+
* bubblewrap package but block the unprivileged user namespaces it needs,
|
|
18
|
+
* so every real invocation fails at runtime with no way to tell from just
|
|
19
|
+
* `existsSync`. Probing for real is the only way to know, and it mirrors
|
|
20
|
+
* src/adoption/smoke.ts's own fail-closed philosophy: skip the suite rather than
|
|
21
|
+
* assert against a sandbox this host can't actually provide.
|
|
22
|
+
*/
|
|
23
|
+
function bwrapUsable(): boolean {
|
|
24
|
+
if (!existsSync("/usr/bin/bwrap")) return false;
|
|
25
|
+
const probe = spawnSync("/usr/bin/bwrap", ["--ro-bind", "/", "/", "--unshare-all", "--", "/bin/true"], { timeout: 5_000 });
|
|
26
|
+
return probe.status === 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const describeIfSandboxed = bwrapUsable() ? describe : describe.skip;
|
|
30
|
+
|
|
31
|
+
function extension(source: string): { root: string; path: string } {
|
|
32
|
+
const root = mkdtempSync(join(tmpdir(), "packed-smoke-"));
|
|
33
|
+
roots.push(root);
|
|
34
|
+
mkdirSync(join(root, "extensions"));
|
|
35
|
+
const path = join(root, "extensions/index.ts");
|
|
36
|
+
writeFileSync(path, source);
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(root, "package.json"),
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
name: "pi-smoke",
|
|
41
|
+
version: "1.0.0",
|
|
42
|
+
keywords: ["pi-package"],
|
|
43
|
+
files: ["extensions"],
|
|
44
|
+
pi: { extensions: ["extensions/index.ts"] },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
return { root, path };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describeIfSandboxed("isolated extension smoke runner", () => {
|
|
51
|
+
it("captures bounded registrations without a model", async () => {
|
|
52
|
+
const fixture = extension(`export default function (pi: any) {
|
|
53
|
+
pi.registerTool({ name: "hello", description: "hello" });
|
|
54
|
+
pi.registerCommand("hello", { handler() {} });
|
|
55
|
+
pi.registerShortcut("ctrl+h", { handler() {} });
|
|
56
|
+
pi.registerFlag("hello", { type: "boolean" });
|
|
57
|
+
pi.registerProvider("local", { models: [] });
|
|
58
|
+
pi.on("session_start", () => {});
|
|
59
|
+
}`);
|
|
60
|
+
const result = await runExtensionSmoke(fixture.root, fixture.path);
|
|
61
|
+
expect(result.status).toBe("ok");
|
|
62
|
+
expect(result.registrations).toEqual({
|
|
63
|
+
tools: ["hello"],
|
|
64
|
+
commands: ["hello"],
|
|
65
|
+
shortcuts: ["ctrl+h"],
|
|
66
|
+
flags: ["hello"],
|
|
67
|
+
providers: ["local"],
|
|
68
|
+
events: ["session_start"],
|
|
69
|
+
renderers: [],
|
|
70
|
+
});
|
|
71
|
+
expect(result.durationMs).toBeLessThan(2_000);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("kills a factory that exceeds the time bound", async () => {
|
|
75
|
+
const fixture = extension("export default async function () { await new Promise(() => {}); }");
|
|
76
|
+
const result = await runExtensionSmoke(fixture.root, fixture.path, { timeoutMs: 150 });
|
|
77
|
+
expect(result.status).toBe("timeout");
|
|
78
|
+
expect(result.durationMs).toBeLessThan(2_000);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reports a startup crash without leaking an unbounded stack", async () => {
|
|
82
|
+
const fixture = extension('export default function () { throw new Error("startup failed"); }');
|
|
83
|
+
const result = await runExtensionSmoke(fixture.root, fixture.path);
|
|
84
|
+
expect(result.status).toBe("crash");
|
|
85
|
+
expect(result.message).toContain("startup failed");
|
|
86
|
+
expect(result.message!.length).toBeLessThanOrEqual(1_000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("denies filesystem and network capabilities", async () => {
|
|
90
|
+
const filesystem = extension(
|
|
91
|
+
'import { writeFileSync } from "node:fs"; export default function () { writeFileSync("/forbidden", "x"); }',
|
|
92
|
+
);
|
|
93
|
+
const network = extension('export default async function () { await fetch("https://example.com"); }');
|
|
94
|
+
expect((await runExtensionSmoke(filesystem.root, filesystem.path)).status).toBe("capability-denied");
|
|
95
|
+
expect((await runExtensionSmoke(network.root, network.path)).status).toBe("capability-denied");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("bounds subprocess creation and captured output", async () => {
|
|
99
|
+
const processes = extension(
|
|
100
|
+
'export default async function () { const children = []; for (let i = 0; i < 100; i++) children.push(Bun.spawn(["/usr/bin/sleep", "2"])); await Promise.all(children.map((child) => child.exited)); }',
|
|
101
|
+
);
|
|
102
|
+
const output = extension('export default function () { process.stdout.write("x".repeat(200000)); }');
|
|
103
|
+
expect((await runExtensionSmoke(processes.root, processes.path)).status).toBe("capability-denied");
|
|
104
|
+
expect((await runExtensionSmoke(output.root, output.path, { maxOutputBytes: 4_096 })).status).toBe("output-limit");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps default package checks static and adds smoke results only when requested", async () => {
|
|
108
|
+
const fixture = extension(
|
|
109
|
+
'import { writeFileSync } from "node:fs"; export default function (pi: any) { writeFileSync("executed", "yes"); pi.registerCommand("x", { handler() {} }); }',
|
|
110
|
+
);
|
|
111
|
+
const staticReport = await checkPackage(fixture.root, { generic: false });
|
|
112
|
+
expect(existsSync(join(fixture.root, "executed"))).toBe(false);
|
|
113
|
+
expect(staticReport.smoke).toBeUndefined();
|
|
114
|
+
const smokeReport = await checkPackage(fixture.root, { generic: false, smoke: true });
|
|
115
|
+
expect(smokeReport.smoke?.extensions[0]?.status).toBe("capability-denied");
|
|
116
|
+
expect(smokeReport.diagnostics.map((diagnostic) => diagnostic.code)).toContain("PI_EXTENSION_SMOKE_CAPABILITY_DENIED");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { buildVersionReport, formatVersionReport } from "../src/shared/version.ts";
|
|
3
|
+
|
|
4
|
+
describe("buildVersionReport (daemon-vs-installed drift detection)", () => {
|
|
5
|
+
it("reports no daemon section at all when none is reachable -- not a drift concern", () => {
|
|
6
|
+
expect(buildVersionReport("0.3.1", undefined)).toEqual({ installed: "0.3.1" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("reports a matching daemon as not stale", () => {
|
|
10
|
+
expect(buildVersionReport("0.3.1", "0.3.1")).toEqual({ installed: "0.3.1", daemon: { version: "0.3.1", stale: false } });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("reports a mismatched daemon as stale, regardless of direction", () => {
|
|
14
|
+
expect(buildVersionReport("0.3.1", "0.3.0")).toEqual({ installed: "0.3.1", daemon: { version: "0.3.0", stale: true } });
|
|
15
|
+
expect(buildVersionReport("0.3.0", "0.3.1")).toEqual({ installed: "0.3.0", daemon: { version: "0.3.1", stale: true } });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("formatVersionReport", () => {
|
|
20
|
+
it("prints just the installed version when no daemon is reachable", () => {
|
|
21
|
+
expect(formatVersionReport({ installed: "0.3.1" })).toBe("packed 0.3.1 (installed)\n");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("prints a clear up-to-date line when the daemon matches", () => {
|
|
25
|
+
const report = buildVersionReport("0.3.1", "0.3.1");
|
|
26
|
+
expect(formatVersionReport(report)).toBe("packed 0.3.1 (installed)\ndaemon running v0.3.1 -- up to date\n");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("prints an explicit STALE warning with a concrete restart suggestion when the daemon predates the installed version", () => {
|
|
30
|
+
const report = buildVersionReport("0.3.1", "0.3.0");
|
|
31
|
+
const output = formatVersionReport(report);
|
|
32
|
+
expect(output).toContain("STALE");
|
|
33
|
+
expect(output).toContain("daemon running v0.3.0");
|
|
34
|
+
expect(output).toContain("restart required to pick up v0.3.1");
|
|
35
|
+
expect(output).toContain("systemctl --user restart pi-packed.service");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ES2022", "DOM"],
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": false
|
|
11
|
+
},
|
|
12
|
+
"include": ["test/public-consumer.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": false,
|
|
5
|
+
"emitDeclarationOnly": true,
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"declarationDir": "../dist",
|
|
9
|
+
"rootDir": "src/public"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/public/client.ts", "src/public/protocol.ts"]
|
|
12
|
+
}
|
|
File without changes
|
|
File without changes
|