Skip to content

Commit 476eac5

Browse files
committed
fix(cli): profile apply honors package scope; network abort timers don't leak (audit)
- profile apply dropped the recorded scope, installing project-scoped packages into user scope. Scope is now threaded through the plan (pi's -l passed for project-scoped entries), and a user↔project scope move is reported as a change rather than silently kept. - The abort timer in the three network helpers (pi.ts x2, catalog.ts) leaked on a settled/rejected fetch, keeping the event loop alive for the full timeout. clearTimeout now runs in a finally. plan-builder scope regression tests. 25 pass, typecheck clean.
1 parent d2fb71d commit 476eac5

5 files changed

Lines changed: 86 additions & 19 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pify/cli",
3-
"version": "0.4.6",
3+
"version": "0.4.7",
44
"description": "Front door for the Pify suite: install and update the pi coding agent, manage @pify packages, and scaffold Pi Packages",
55
"keywords": [
66
"pify",

‎src/catalog.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,19 @@ export async function refreshCatalog(): Promise<{ ok: boolean; catalog: Catalog
170170
async function fetchRemoteCatalog(defaultUrl: string, timeoutMs: number): Promise<Catalog | null> {
171171
if (isOffline()) return null;
172172
const url = process.env.PIFY_CATALOG_URL || defaultUrl;
173+
const controller = new AbortController();
174+
// Cleared in finally so a settled request (resolved or rejected) never keeps
175+
// the event loop alive for the full timeout.
176+
const timer = setTimeout(() => controller.abort(), timeoutMs);
173177
try {
174-
const controller = new AbortController();
175-
const timer = setTimeout(() => controller.abort(), timeoutMs);
176178
const res = await fetch(url, { signal: controller.signal });
177-
clearTimeout(timer);
178179
if (!res.ok) return null;
179180
const data: unknown = await res.json();
180181
return validateCatalog(data) ? data : null;
181182
} catch {
182183
return null;
184+
} finally {
185+
clearTimeout(timer);
183186
}
184187
}
185188

‎src/commands/profile.ts‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ export interface PlanRow {
8686
action: PlanAction;
8787
from: string | null;
8888
to: string | null;
89+
/**
90+
* Where the package should end up. For install/change/keep this is the
91+
* profile entry's recorded scope, so applying installs into the same scope it
92+
* was saved from (project scope needs pi's `-l`). For `extra` rows there is
93+
* no profile entry, so it reflects where the package is currently installed.
94+
*/
95+
scope: "user" | "project";
8996
}
9097

9198
/**
@@ -99,19 +106,23 @@ export function planApply(profile: Profile, installedNow: Map<string, { scope: "
99106
for (const entry of profile.packages) {
100107
const current = installedNow.get(entry.name);
101108
if (!current) {
102-
rows.push({ name: entry.name, action: "install", from: null, to: entry.version });
109+
rows.push({ name: entry.name, action: "install", from: null, to: entry.version, scope: entry.scope });
103110
continue;
104111
}
105112
const onDisk = installedVersionOnDisk(entry.name, current.scope).version;
106-
if (entry.version && onDisk && entry.version !== onDisk) {
107-
rows.push({ name: entry.name, action: "change", from: onDisk, to: entry.version });
113+
// A user↔project mismatch is a change even at the same version: the package
114+
// has to be reinstalled into the scope the profile recorded.
115+
const scopeMismatch = current.scope !== entry.scope;
116+
const versionMismatch = Boolean(entry.version && onDisk && entry.version !== onDisk);
117+
if (scopeMismatch || versionMismatch) {
118+
rows.push({ name: entry.name, action: "change", from: onDisk, to: entry.version, scope: entry.scope });
108119
} else {
109-
rows.push({ name: entry.name, action: "keep", from: onDisk, to: entry.version });
120+
rows.push({ name: entry.name, action: "keep", from: onDisk, to: entry.version, scope: entry.scope });
110121
}
111122
}
112-
for (const [name] of installedNow) {
123+
for (const [name, cur] of installedNow) {
113124
if (!profile.packages.some((p) => p.name === name)) {
114-
rows.push({ name, action: "extra", from: installedVersionOnDisk(name, "user").version, to: null });
125+
rows.push({ name, action: "extra", from: installedVersionOnDisk(name, cur.scope).version, to: null, scope: cur.scope });
115126
}
116127
}
117128
return rows.sort((a, b) => a.name.localeCompare(b.name));
@@ -123,13 +134,14 @@ export interface ProfileOptions {
123134
}
124135

125136
function describe(row: PlanRow): string {
137+
const scope = row.scope === "project" ? " [project]" : "";
126138
switch (row.action) {
127139
case "install":
128-
return `install ${row.to ? `@ ${row.to}` : "(latest)"}`;
140+
return `install ${row.to ? `@ ${row.to}` : "(latest)"}${scope}`;
129141
case "change":
130-
return `${row.from} → ${row.to}`;
142+
return `${row.from ?? "unknown"} → ${row.to ?? "latest"}${scope}`;
131143
case "keep":
132-
return `already ${row.from ?? "installed"}`;
144+
return `already ${row.from ?? "installed"}${scope}`;
133145
case "extra":
134146
return `installed here, not in the profile (left alone)`;
135147
}
@@ -189,6 +201,9 @@ export async function profile(args: string[], opts: ProfileOptions): Promise<num
189201
for (const row of work) {
190202
const spec = row.to ? `npm:@pify/${row.name}@${row.to}` : `npm:@pify/${row.name}`;
191203
const argv = ["install", spec];
204+
// Mirror install.ts's piArgs: project-scoped entries need pi's `-l`, or
205+
// they land in the user scope instead of the one the profile recorded.
206+
if (row.scope === "project") argv.push("-l");
192207
step(`pi ${argv.join(" ")}`);
193208
if ((await delegate(argv)) !== 0) failed.push(row.name);
194209
}

‎src/pi.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,22 @@ export function piStatus(): PiStatus {
3636
/** Latest pi version from pi's own release endpoint. Null offline/on error. */
3737
export async function fetchLatestPiVersion(): Promise<string | null> {
3838
if (isOffline()) return null;
39+
const controller = new AbortController();
40+
// Cleared in finally so a settled request (resolved or rejected) never keeps
41+
// the event loop alive for the full timeout.
42+
const timer = setTimeout(() => controller.abort(), 5000);
3943
try {
40-
const controller = new AbortController();
41-
const timer = setTimeout(() => controller.abort(), 5000);
4244
const res = await fetch(LATEST_VERSION_URL, {
4345
signal: controller.signal,
4446
headers: { "user-agent": `pify/${VERSION}` },
4547
});
46-
clearTimeout(timer);
4748
if (!res.ok) return null;
4849
const data = (await res.json()) as { version?: unknown };
4950
return typeof data.version === "string" ? data.version : null;
5051
} catch {
5152
return null;
53+
} finally {
54+
clearTimeout(timer);
5255
}
5356
}
5457

@@ -392,20 +395,23 @@ export interface OnDiskState {
392395
*/
393396
export async function fetchLatestPackageVersion(name: string): Promise<string | null> {
394397
if (isOffline()) return null;
398+
const controller = new AbortController();
399+
// Cleared in finally so a settled request (resolved or rejected) never keeps
400+
// the event loop alive for the full timeout.
401+
const timer = setTimeout(() => controller.abort(), 5000);
395402
try {
396-
const controller = new AbortController();
397-
const timer = setTimeout(() => controller.abort(), 5000);
398403
const res = await fetch(`https://registry.npmjs.org/@pify/${encodeURIComponent(name)}/latest`, {
399404
signal: controller.signal,
400405
headers: { "user-agent": `pify/${VERSION}`, accept: "application/json" },
401406
redirect: "error",
402407
});
403-
clearTimeout(timer);
404408
if (!res.ok) return null;
405409
const data = (await res.json()) as { version?: unknown };
406410
return typeof data.version === "string" ? data.version : null;
407411
} catch {
408412
return null;
413+
} finally {
414+
clearTimeout(timer);
409415
}
410416
}
411417

‎test/unit.test.js‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,49 @@ test("v0.4 planApply says what would change and never proposes a removal", () =>
326326
);
327327
});
328328

329+
test("v0.4 planApply threads the recorded scope through every row", () => {
330+
// The scope on install/change/keep rows is the profile entry's scope — it
331+
// never depends on disk state, so this is deterministic. Regression: apply
332+
// used to drop the scope and install project packages into the user scope.
333+
const profile = {
334+
version: 1,
335+
createdAt: "",
336+
packages: [
337+
{ name: "goal", version: null, scope: "project" },
338+
{ name: "btw", version: null, scope: "user" },
339+
],
340+
};
341+
const rows = planApply(profile, new Map());
342+
const byName = new Map(rows.map((r) => [r.name, r]));
343+
344+
// Absent packages install into the scope the profile recorded.
345+
assert.equal(byName.get("goal").action, "install");
346+
assert.equal(byName.get("goal").scope, "project", "project scope must survive to the plan row");
347+
assert.equal(byName.get("btw").action, "install");
348+
assert.equal(byName.get("btw").scope, "user");
349+
});
350+
351+
test("v0.4 planApply reports a user↔project scope move as a change", () => {
352+
// A package installed in one scope but recorded in the other must be a
353+
// "change" (reinstall into the recorded scope), even at the same version.
354+
const profile = {
355+
version: 1,
356+
createdAt: "",
357+
packages: [{ name: "goal", version: null, scope: "project" }],
358+
};
359+
// Installed in the user scope; the profile wants it in the project scope.
360+
const installed = new Map([["goal", { scope: "user" }]]);
361+
const rows = planApply(profile, installed);
362+
const goal = rows.find((r) => r.name === "goal");
363+
364+
assert.equal(goal.action, "change", "a scope mismatch is a change, not a keep");
365+
assert.equal(goal.scope, "project", "the change targets the recorded scope");
366+
367+
// Same package, same scope on both sides, no version pin -> nothing to move.
368+
const matched = planApply(profile, new Map([["goal", { scope: "project" }]]));
369+
assert.equal(matched.find((r) => r.name === "goal").action, "keep");
370+
});
371+
329372
test("v0.4 the catalog declares conflicts, and only npm-shaped names", () => {
330373
const catalog = loadBundledCatalog();
331374
const withConflicts = catalog.packages.filter((p) => p.conflicts?.length);

0 commit comments

Comments
 (0)