@danypops/pi-packed 0.21.7 → 0.21.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.21.7",
3
+ "version": "0.21.9",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  /** install.ts — driven adapter: pi CLI mutations via Bun.spawn. */
2
2
 
3
+ import { join } from "node:path";
3
4
  import { HeadlessInstallValidator, type InstallValidator } from "../adoption/install-validation.ts";
4
5
  import { defaultPiHome, isPinnedNpmSource, readResolvedVersion } from "./installed.ts";
5
6
  import type { Installer, UpdateOutcome } from "./package.ts";
@@ -11,11 +12,16 @@ export function defaultPiBin(): string {
11
12
  return process.env.PI_PACKED_PI_BIN ?? process.env.PI_BIN ?? "pi";
12
13
  }
13
14
 
15
+ export function defaultNpmBin(): string {
16
+ return process.env.PI_PACKED_NPM_BIN ?? process.env.NPM_BIN ?? "npm";
17
+ }
18
+
14
19
  export class ExecInstaller implements Installer {
15
20
  constructor(
16
21
  private bin = defaultPiBin(),
17
22
  private piHome = defaultPiHome(),
18
23
  private validator: InstallValidator = new HeadlessInstallValidator(),
24
+ private npmBin = defaultNpmBin(),
19
25
  ) {}
20
26
 
21
27
  private async run(args: string[]): Promise<string> {
@@ -27,6 +33,26 @@ export class ExecInstaller implements Installer {
27
33
  return out;
28
34
  }
29
35
 
36
+ /**
37
+ * `pi install`/`pi update --extension <source>` only resolve the target
38
+ * package's own subtree. npm's own `dedupe` docs (confirmed by
39
+ * npm/cli#5307 and npm/cli#7277) describe rearranging already-resolved
40
+ * versions, never installing a newer one -- so a sibling package's own
41
+ * declared range can go unsatisfied at the shared root and node's
42
+ * resolution silently walks up to a stale copy nothing wanted. A full
43
+ * `npm install` (no args) at piHome/npm is npm's own documented reliable
44
+ * fix: it re-resolves the whole tree, not just the last-touched package.
45
+ */
46
+ private async reresolveDependencyTree(): Promise<string> {
47
+ const cwd = join(this.piHome, "npm");
48
+ const proc = Bun.spawn([this.npmBin, "install"], { cwd, stdout: "pipe", stderr: "pipe" });
49
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
50
+ const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
51
+ const code = await proc.exited;
52
+ if (code !== 0) throw new Error(`npm install failed to re-resolve the dependency tree at ${cwd} (exit ${code}): ${out || "no output"}`);
53
+ return out;
54
+ }
55
+
30
56
  async install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
31
57
  const validation = await this.validator.validate(source);
32
58
  if (!validation.ok) {
@@ -36,7 +62,9 @@ export class ExecInstaller implements Installer {
36
62
  .join("; ");
37
63
  throw new Error(`install refused -- ${detail || validation.message || "extension failed a headless load check"}`);
38
64
  }
39
- return this.run(["install", ...(options?.local ? ["-l"] : []), source]);
65
+ const output = await this.run(["install", ...(options?.local ? ["-l"] : []), source]);
66
+ await this.reresolveDependencyTree();
67
+ return output;
40
68
  }
41
69
 
42
70
  remove(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
@@ -47,6 +75,7 @@ export class ExecInstaller implements Installer {
47
75
  const pinned = isPinnedNpmSource(source);
48
76
  const previousVersion = readResolvedVersion(this.piHome, source);
49
77
  const output = await this.run(["update", "--extension", source]);
78
+ await this.reresolveDependencyTree();
50
79
  const currentVersion = readResolvedVersion(this.piHome, source);
51
80
  // Only trust a "nothing changed" conclusion when we actually read a
52
81
  // real version both before and after (npm source, resolvable in
@@ -16,6 +16,7 @@ import { createLogger } from "../shared/log.ts";
16
16
 
17
17
  const log = createLogger("registry");
18
18
  const README_MAX_CHARS = 50_000;
19
+ const README_DOCUMENT_MAX_BYTES = 1024 * 1024;
19
20
 
20
21
  /** Upstream etiquette: honor Retry-After on 429, exponential backoff
21
22
  * otherwise, give up after RETRY_MAX_ATTEMPTS. */
@@ -126,11 +127,10 @@ export class HttpRegistry implements Registry {
126
127
  pi?: Record<string, unknown>;
127
128
  peerDependencies?: Record<string, string>;
128
129
  scripts?: Record<string, string>;
129
- readme?: unknown;
130
130
  dist?: { unpackedSize?: number; integrity?: string; attestations?: { url?: string; provenance?: unknown } };
131
131
  };
132
132
  const manifestFields = v.pi ? Object.keys(v.pi).filter((key) => ["extensions", "skills", "prompts", "themes"].includes(key)) : [];
133
- const readme = boundedString(v.readme, README_MAX_CHARS);
133
+ const readme = await this.publishedReadme(encoded);
134
134
  return {
135
135
  name: boundedString(v.name, 214) ?? boundedString(name, 214)!,
136
136
  version: boundedString(v.version, 128) ?? "",
@@ -166,6 +166,22 @@ export class HttpRegistry implements Registry {
166
166
  };
167
167
  }
168
168
 
169
+ private async publishedReadme(encodedName: string): Promise<string | undefined> {
170
+ try {
171
+ const response = await fetchWithRetry(
172
+ `${this.base}/${encodedName}`,
173
+ { headers: { accept: "application/json" } },
174
+ this.retryBaseDelayMs,
175
+ );
176
+ if (!response.ok) return undefined;
177
+ const document = (await boundedJson(response, README_DOCUMENT_MAX_BYTES)) as { readme?: unknown };
178
+ return boundedString(document.readme, README_MAX_CHARS);
179
+ } catch (error) {
180
+ log.debug("published README unavailable", { error: error instanceof Error ? error.message : String(error) });
181
+ return undefined;
182
+ }
183
+ }
184
+
169
185
  /** Uses npm's abbreviated multi-version doc, not `/latest` (which carries
170
186
  * no `time`/`modified` field at all -- confirmed against the live
171
187
  * registry). Still bounded well under the full unabbreviated document's
@@ -69,7 +69,6 @@ describe("HttpRegistry", () => {
69
69
  license: "MIT",
70
70
  keywords: ["pi-package", "lsp"],
71
71
  pi: { extensions: ["./src/index.ts"] },
72
- readme: `# pi-lsp\n\n${"r".repeat(60_000)}`,
73
72
  dist: {
74
73
  unpackedSize: 12345,
75
74
  integrity: "sha512-test",
@@ -77,6 +76,7 @@ describe("HttpRegistry", () => {
77
76
  },
78
77
  });
79
78
  }
79
+ if (url.pathname === "/pi-lsp") return Response.json({ readme: `# pi-lsp\n\n${"r".repeat(60_000)}` });
80
80
  return new Response("nf", { status: 404 });
81
81
  },
82
82
  });
@@ -8,6 +8,8 @@
8
8
  * pass.
9
9
  */
10
10
  import { describe, expect, it } from "bun:test";
11
+ import { mkdirSync, mkdtempSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
11
13
  import { dirname, join } from "node:path";
12
14
  import { fileURLToPath } from "node:url";
13
15
  import type { InstallValidationResult, InstallValidator } from "../src/adoption/install-validation.ts";
@@ -94,13 +96,18 @@ describe("ExecInstaller.install() -- refuses before ever spawning the real pi bi
94
96
  await expect(installer.install("npm:broken-pkg")).rejects.toThrow(/install refused.*extension\/index\.ts: boom/);
95
97
  });
96
98
 
97
- it("proceeds to the real install when the validator approves", async () => {
98
- // /bin/true always exits 0 -- proves the real spawn path was reached
99
- // (a refused install never gets this far to find out).
99
+ it("proceeds to the real install when the validator approves, then re-resolves the whole tree", async () => {
100
+ // A real piHome/npm dir must exist for the post-install re-resolution
101
+ // step to cwd into -- /bin/true always exits 0 regardless, proving both
102
+ // the real install spawn *and* the re-resolution spawn were reached (a
103
+ // refused install never gets this far to find out).
104
+ const piHome = mkdtempSync(join(tmpdir(), "packed-install-validation-pihome-"));
105
+ mkdirSync(join(piHome, "npm"), { recursive: true });
100
106
  const installer = new ExecInstaller(
101
107
  "/bin/true",
102
- "/tmp/unused-pihome",
108
+ piHome,
103
109
  fakeValidator({ ok: true, source: "npm:good-pkg", extensions: [] }),
110
+ "/bin/true",
104
111
  );
105
112
 
106
113
  await expect(installer.install("npm:good-pkg")).resolves.toBeDefined();
@@ -40,6 +40,7 @@ function writeFakePi(dir: string, rewrite?: { piHome: string; name: string; newV
40
40
 
41
41
  function writePiHome(nodeModules: Record<string, string> = {}): string {
42
42
  const dir = mkdtempSync(join(tmpdir(), "packed-exec-pihome-"));
43
+ mkdirSync(join(dir, "npm"), { recursive: true });
43
44
  for (const [name, version] of Object.entries(nodeModules)) {
44
45
  const pkgDir = join(dir, "npm", "node_modules", name);
45
46
  mkdirSync(pkgDir, { recursive: true });
@@ -48,12 +49,35 @@ function writePiHome(nodeModules: Record<string, string> = {}): string {
48
49
  return dir;
49
50
  }
50
51
 
52
+ /**
53
+ * A fake `npm` binary standing in for ExecInstaller's post-install/update
54
+ * full-tree re-resolution step. Writes every invocation's cwd to `logFile`
55
+ * (so a test can assert it actually ran against piHome/npm, not some other
56
+ * directory) and, when `rewrite` is given, writes a fresh version for a
57
+ * *different* package than the one the fake `pi` binary touched --
58
+ * reproducing the real defect: only a real `npm install` (no args, whole
59
+ * tree) reaches a stale sibling that a targeted `pi update` never does.
60
+ */
61
+ function writeFakeNpm(dir: string, logFile: string, rewrite?: { piHome: string; name: string; newVersion: string }): string {
62
+ const script = join(dir, "fake-npm");
63
+ const rewriteLine = rewrite
64
+ ? `mkdir -p '${join(rewrite.piHome, "npm", "node_modules", rewrite.name)}' && printf '{"version":"%s"}' '${rewrite.newVersion}' > '${join(rewrite.piHome, "npm", "node_modules", rewrite.name, "package.json")}'`
65
+ : "true";
66
+ writeFileSync(
67
+ script,
68
+ ["#!/usr/bin/env bash", "set -euo pipefail", `pwd >> '${logFile}'`, `echo "$@" >> '${logFile}'`, rewriteLine, "exit 0"].join("\n"),
69
+ );
70
+ chmodSync(script, 0o755);
71
+ return script;
72
+ }
73
+
51
74
  describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguous exit-0 text", () => {
52
75
  it("pinned source, version genuinely unchanged: alreadyUpToDate, reloadRequired false", async () => {
53
76
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
54
77
  const bin = writeFakePi(scriptDir);
78
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
55
79
  const piHome = writePiHome({ "@scope/pkg": "1.2.3" });
56
- const installer = new ExecInstaller(bin, piHome);
80
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
57
81
 
58
82
  const outcome = await installer.update("npm:@scope/pkg@1.2.3");
59
83
 
@@ -68,8 +92,9 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
68
92
  it('unpinned source, already latest (pi still exits 0 and says "Updated"): alreadyUpToDate', async () => {
69
93
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
70
94
  const bin = writeFakePi(scriptDir);
95
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
71
96
  const piHome = writePiHome({ plain: "0.5.0" });
72
- const installer = new ExecInstaller(bin, piHome);
97
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
73
98
 
74
99
  const outcome = await installer.update("npm:plain");
75
100
 
@@ -84,7 +109,8 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
84
109
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
85
110
  const piHome = writePiHome({ plain: "0.5.0" });
86
111
  const bin = writeFakePi(scriptDir, { piHome, name: "plain", newVersion: "0.6.0" });
87
- const installer = new ExecInstaller(bin, piHome);
112
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
113
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
88
114
 
89
115
  const outcome = await installer.update("npm:plain");
90
116
 
@@ -98,8 +124,9 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
98
124
  it("git: source (no npm resolution possible either side): conservatively assumes it may have changed", async () => {
99
125
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
100
126
  const bin = writeFakePi(scriptDir);
127
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
101
128
  const piHome = writePiHome();
102
- const installer = new ExecInstaller(bin, piHome);
129
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
103
130
 
104
131
  const outcome = await installer.update("git:github.com/u/r@main");
105
132
 
@@ -111,3 +138,61 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
111
138
  expect(outcome.reloadRequired).toBe(true);
112
139
  });
113
140
  });
141
+
142
+ describe("ExecInstaller — forces full dependency re-resolution, not just the target's own subtree", () => {
143
+ it("update() fixes a stale root-level sibling that the targeted pi update never touched", async () => {
144
+ const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
145
+ // Simulates the confirmed live defect: after `pi update npm:@scope/leaf`,
146
+ // the leaf's own version bumps, but a root-level sibling
147
+ // (@scope/shared) that the freshly-updated leaf now needs a newer
148
+ // range of stays pinned at its old, now-unsatisfied version --
149
+ // `pi update --extension` only ever resolved the leaf's own subtree.
150
+ const piHome = writePiHome({ "@scope/leaf": "1.0.0", "@scope/shared": "1.0.0" });
151
+ const bin = writeFakePi(scriptDir, { piHome, name: "@scope/leaf", newVersion: "2.0.0" });
152
+ // A real `npm install` (no args, whole-tree) is the only thing that
153
+ // actually reaches @scope/shared -- reproduced here as the fake npm
154
+ // binary rewriting it to the version the leaf's new range needs.
155
+ const npmLog = join(scriptDir, "npm.log");
156
+ const npmBin = writeFakeNpm(scriptDir, npmLog, { piHome, name: "@scope/shared", newVersion: "2.0.0" });
157
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
158
+
159
+ const outcome = await installer.update("npm:@scope/leaf");
160
+
161
+ expect(outcome.currentVersion).toBe("2.0.0");
162
+ // The real assertion: a package `pi update` never named is resolved
163
+ // too, everywhere in the tree -- not just the target's own package.json.
164
+ expect(readFileSync(join(piHome, "npm", "node_modules", "@scope/shared", "package.json"), "utf8")).toContain("2.0.0");
165
+ // And it ran the re-resolution against piHome/npm specifically, with
166
+ // no target argument -- a full-tree resolve, not another targeted op.
167
+ const log = readFileSync(npmLog, "utf8").trim().split("\n");
168
+ expect(log[0]).toBe(join(piHome, "npm"));
169
+ expect(log[1]).toBe("install");
170
+ });
171
+
172
+ it("install() also forces a full re-resolution after a successful pi install", async () => {
173
+ const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
174
+ const piHome = writePiHome({ "@scope/shared": "1.0.0" });
175
+ const bin = writeFakePi(scriptDir);
176
+ const npmLog = join(scriptDir, "npm.log");
177
+ const npmBin = writeFakeNpm(scriptDir, npmLog, { piHome, name: "@scope/shared", newVersion: "2.0.0" });
178
+ const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin);
179
+
180
+ await installer.install("npm:@scope/new-pkg");
181
+
182
+ expect(readFileSync(join(piHome, "npm", "node_modules", "@scope/shared", "package.json"), "utf8")).toContain("2.0.0");
183
+ const log = readFileSync(npmLog, "utf8").trim().split("\n");
184
+ expect(log[0]).toBe(join(piHome, "npm"));
185
+ });
186
+
187
+ it("surfaces a failed re-resolution instead of silently reporting success", async () => {
188
+ const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
189
+ const piHome = writePiHome({ plain: "0.5.0" });
190
+ const bin = writeFakePi(scriptDir, { piHome, name: "plain", newVersion: "0.6.0" });
191
+ const failingNpm = join(scriptDir, "fake-npm-fail");
192
+ writeFileSync(failingNpm, ["#!/usr/bin/env bash", "echo 'ERESOLVE unable to resolve dependency tree' >&2", "exit 1"].join("\n"));
193
+ chmodSync(failingNpm, 0o755);
194
+ const installer = new ExecInstaller(bin, piHome, undefined, failingNpm);
195
+
196
+ await expect(installer.update("npm:plain")).rejects.toThrow(/npm install failed to re-resolve/);
197
+ });
198
+ });
@@ -52,10 +52,16 @@ describe("published npm metadata E2E", () => {
52
52
  description: "Encrypted credential vault daemon",
53
53
  license: "MIT",
54
54
  repository: { type: "git", url: "git+https://github.com/DanyPops/enigma.git" },
55
- readme: PUBLISHED_README,
56
55
  dist: { integrity: "sha512-published-fixture" },
57
56
  });
58
57
  }
58
+ if (url.pathname === "/%40danypops/enigma") {
59
+ return Response.json({
60
+ name: "@danypops/enigma",
61
+ "dist-tags": { latest: "0.22.1" },
62
+ readme: PUBLISHED_README,
63
+ });
64
+ }
59
65
  return new Response("not found", { status: 404 });
60
66
  },
61
67
  });
@@ -78,7 +84,13 @@ describe("published npm metadata E2E", () => {
78
84
  const client = new PackedClient(`http://127.0.0.1:${daemonServer.port}`, TOKEN);
79
85
  const info = await client.info("@danypops/enigma");
80
86
 
81
- expect(npmRequests).toEqual([{ path: "/%40danypops/enigma/latest", accept: "application/json" }]);
87
+ expect(npmRequests).toHaveLength(2);
88
+ expect(npmRequests).toEqual(
89
+ expect.arrayContaining([
90
+ { path: "/%40danypops/enigma/latest", accept: "application/json" },
91
+ { path: "/%40danypops/enigma", accept: "application/json" },
92
+ ]),
93
+ );
82
94
  expect(info).toMatchObject({
83
95
  name: "@danypops/enigma",
84
96
  version: "0.22.1",