@danypops/pi-packed 0.27.14 → 0.28.1
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/adoption/smoke.d.ts.map +1 -1
- package/extension/src/bounded-poll.ts +19 -0
- package/extension/src/doctor-overlay.ts +156 -0
- package/extension/src/doctor-widget.ts +86 -0
- package/extension/src/index.ts +9 -0
- package/package.json +2 -2
- package/service/src/adoption/smoke.ts +41 -6
- package/service/test/smoke.test.ts +29 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"smoke.d.ts","sourceRoot":"","sources":["../../service/src/adoption/smoke.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,GAAG,mBAAmB,GAAG,cAAc,GAAG,qBAAqB,CAAC;AAEpH,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,kBAAkB,CAAC;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;
|
|
1
|
+
{"version":3,"file":"smoke.d.ts","sourceRoot":"","sources":["../../service/src/adoption/smoke.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,GAAG,mBAAmB,GAAG,cAAc,GAAG,qBAAqB,CAAC;AAEpH,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,kBAAkB,CAAC;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAuDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAO7F;AAmJD,wBAAsB,iBAAiB,CACtC,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,YAAiB,GACxB,OAAO,CAAC,oBAAoB,CAAC,CAoF/B"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent start/stop wrapper over setInterval -- a second start() is a no-op rather than a
|
|
3
|
+
* competing timer, and stop() is safe to call even if never started. Same shape as pi-papyrus's
|
|
4
|
+
* and pi-pipes' own BoundedPoll.
|
|
5
|
+
*/
|
|
6
|
+
export class BoundedPoll {
|
|
7
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
8
|
+
|
|
9
|
+
start(intervalMs: number, tick: () => void): void {
|
|
10
|
+
if (this.timer) return;
|
|
11
|
+
this.timer = setInterval(tick, intervalMs);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
stop(): void {
|
|
15
|
+
if (!this.timer) return;
|
|
16
|
+
clearInterval(this.timer);
|
|
17
|
+
this.timer = undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent above-editor widget for real doctor.run issues -- mirrors pi-papyrus's own
|
|
3
|
+
* TaskOverlay/NoteOverlay and pi-pipes' own JobsOverlay: factory-form ctx.ui.setWidget
|
|
4
|
+
* registration, requestRender on refresh, hides the widget entirely (setWidget(key, undefined))
|
|
5
|
+
* rather than an empty box once the report is clean.
|
|
6
|
+
*
|
|
7
|
+
* doctor.run lives exclusively on the daemon's Vehicle protocol surface (/vehicle/*), not the
|
|
8
|
+
* legacy PackedExtensionClient RPC surface packed.ts's Natives wraps (see ExtensionOperationName
|
|
9
|
+
* in protocol.ts -- "doctor.run" is not one of them) -- this overlay builds its own reconnecting
|
|
10
|
+
* VehicleClient the same way vehicle-tools.ts's registerPackedVehicle does.
|
|
11
|
+
*
|
|
12
|
+
* Polled at a conservative interval (see DOCTOR_WIDGET_POLL_INTERVAL_MS): unlike Tasks/Notes/Jobs,
|
|
13
|
+
* doctor.run is a real daemon-wide scan (extension smoke tests, npm-tree walks, systemd
|
|
14
|
+
* shell-outs), not a cheap row read.
|
|
15
|
+
*/
|
|
16
|
+
import type { DoctorReport } from "@danypops/pi-packed/doctor-format";
|
|
17
|
+
import { createReconnectingVehicleClient } from "@danypops/vehicle-client/daemon-client";
|
|
18
|
+
import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
|
|
19
|
+
import type { VehicleClient } from "@danypops/vehicle-core";
|
|
20
|
+
import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { AutoRotatingWindow } from "malevich-tui-components";
|
|
22
|
+
import { BoundedPoll } from "./bounded-poll.js";
|
|
23
|
+
import {
|
|
24
|
+
buildDoctorWidgetProjection,
|
|
25
|
+
type DoctorWidgetProjection,
|
|
26
|
+
PACKED_DOCTOR_WIDGET_VISIBLE_ROWS,
|
|
27
|
+
renderDoctorWidgetLines,
|
|
28
|
+
} from "./doctor-widget.js";
|
|
29
|
+
import { currentVehicleClientTarget } from "./vehicle-target.js";
|
|
30
|
+
|
|
31
|
+
const WIDGET_KEY = "pi-packed-doctor";
|
|
32
|
+
|
|
33
|
+
/** doctor.run is a real daemon-wide scan, not a cheap row read -- a much longer interval than
|
|
34
|
+
* Tasks/Notes/Jobs' own 15-20s cadence. */
|
|
35
|
+
export const DOCTOR_WIDGET_POLL_INTERVAL_MS = 90_000;
|
|
36
|
+
|
|
37
|
+
/** How often the widget's own auto-rotating overflow page advances. */
|
|
38
|
+
export const DOCTOR_WIDGET_ROTATION_INTERVAL_MS = 6_000;
|
|
39
|
+
|
|
40
|
+
const EMPTY_PROJECTION: DoctorWidgetProjection = { issues: [], total: 0 };
|
|
41
|
+
|
|
42
|
+
function defaultClient(): VehicleClient {
|
|
43
|
+
// connectRetry:true (vehicle-client's own bounded background retry budget) covers a daemon
|
|
44
|
+
// that crashed and is mid systemd-restart -- matches registerPackedVehicle's own construction.
|
|
45
|
+
return createReconnectingVehicleClient(
|
|
46
|
+
async () => {
|
|
47
|
+
const resolved = currentVehicleClientTarget();
|
|
48
|
+
if (!resolved) throw new Error("Packed daemon is not running");
|
|
49
|
+
return new RemoteVehicleClient({ baseUrl: resolved.baseUrl, token: resolved.token });
|
|
50
|
+
},
|
|
51
|
+
{ connectRetry: true },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class DoctorOverlay {
|
|
56
|
+
private uiCtx: ExtensionUIContext | undefined;
|
|
57
|
+
private registered = false;
|
|
58
|
+
// biome-ignore lint/suspicious/noExplicitAny: same TUI-handle shape every other overlay in this ecosystem keeps untyped (requestRender is all that's used).
|
|
59
|
+
private tui: any | undefined;
|
|
60
|
+
private projection: DoctorWidgetProjection = EMPTY_PROJECTION;
|
|
61
|
+
private readonly poll = new BoundedPoll();
|
|
62
|
+
/** Repaint-only ticker (no data refetch) so the widget's own auto-rotating page visibly
|
|
63
|
+
* advances even when nothing else has changed. */
|
|
64
|
+
private readonly rotationPoll = new BoundedPoll();
|
|
65
|
+
private readonly rotation = new AutoRotatingWindow({
|
|
66
|
+
totalRows: 0,
|
|
67
|
+
pageSize: PACKED_DOCTOR_WIDGET_VISIBLE_ROWS,
|
|
68
|
+
intervalMs: DOCTOR_WIDGET_ROTATION_INTERVAL_MS,
|
|
69
|
+
});
|
|
70
|
+
private readonly client: VehicleClient;
|
|
71
|
+
|
|
72
|
+
constructor(client: VehicleClient = defaultClient()) {
|
|
73
|
+
this.client = client;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setUI(ctx: ExtensionUIContext): void {
|
|
77
|
+
if (ctx !== this.uiCtx) {
|
|
78
|
+
this.uiCtx = ctx;
|
|
79
|
+
this.registered = false;
|
|
80
|
+
this.tui = undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Never throws: called from a poll timer and from session_start, neither of which should turn
|
|
85
|
+
* a best-effort status widget into a crashed extension host over a daemon that isn't running
|
|
86
|
+
* yet or a rendering bug. */
|
|
87
|
+
async refresh(): Promise<void> {
|
|
88
|
+
try {
|
|
89
|
+
const report = await this.client.invoke<DoctorReport>("doctor.run", 1, {}, { permissions: ["packed:read"] });
|
|
90
|
+
this.projection = buildDoctorWidgetProjection(report);
|
|
91
|
+
} catch {
|
|
92
|
+
this.projection = EMPTY_PROJECTION;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
this.render();
|
|
96
|
+
} catch {
|
|
97
|
+
// A rendering bug must not crash the extension host over a best-effort status widget.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private render(): void {
|
|
102
|
+
if (!this.uiCtx) return;
|
|
103
|
+
|
|
104
|
+
if (this.projection.total === 0) {
|
|
105
|
+
if (this.registered) {
|
|
106
|
+
this.uiCtx.setWidget(WIDGET_KEY, undefined);
|
|
107
|
+
this.registered = false;
|
|
108
|
+
this.tui = undefined;
|
|
109
|
+
this.rotationPoll.stop();
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!this.registered) {
|
|
115
|
+
this.uiCtx.setWidget(
|
|
116
|
+
WIDGET_KEY,
|
|
117
|
+
// biome-ignore lint/suspicious/noExplicitAny: tui is only ever used for requestRender(), matching every other overlay in this ecosystem.
|
|
118
|
+
(tui: any, theme: Theme) => {
|
|
119
|
+
this.tui = tui;
|
|
120
|
+
return {
|
|
121
|
+
render: (width: number) => renderDoctorWidgetLines(theme, this.projection, width, this.rotation),
|
|
122
|
+
invalidate: () => {
|
|
123
|
+
// Theme changed -- force re-registration, matching every other overlay in this ecosystem.
|
|
124
|
+
this.registered = false;
|
|
125
|
+
this.tui = undefined;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
{ placement: "aboveEditor" },
|
|
130
|
+
);
|
|
131
|
+
this.registered = true;
|
|
132
|
+
this.rotationPoll.start(DOCTOR_WIDGET_ROTATION_INTERVAL_MS, () => this.tui?.requestRender?.());
|
|
133
|
+
} else {
|
|
134
|
+
this.tui?.requestRender?.();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
startPolling(intervalMs: number = DOCTOR_WIDGET_POLL_INTERVAL_MS): void {
|
|
139
|
+
this.poll.start(intervalMs, () => {
|
|
140
|
+
void this.refresh();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
stopPolling(): void {
|
|
145
|
+
this.poll.stop();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
dispose(): void {
|
|
149
|
+
this.stopPolling();
|
|
150
|
+
this.rotationPoll.stop();
|
|
151
|
+
this.uiCtx?.setWidget(WIDGET_KEY, undefined);
|
|
152
|
+
this.registered = false;
|
|
153
|
+
this.tui = undefined;
|
|
154
|
+
this.uiCtx = undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure projection/render pair for the Doctor status widget -- mirrors pi-papyrus's own
|
|
3
|
+
* task-widget.ts / pi-pipes' own jobs-widget.ts split: a DoctorReport in, a bounded intermediate
|
|
4
|
+
* shape out, no I/O, no TUI, fully unit-testable without a real daemon or terminal. See
|
|
5
|
+
* doctor-overlay.ts for the stateful ctx.ui.setWidget-registered class that drives these from a
|
|
6
|
+
* live doctor.run poll.
|
|
7
|
+
*/
|
|
8
|
+
import type { DoctorReport } from "@danypops/pi-packed/doctor-format";
|
|
9
|
+
import { vehicleWidgetTitle } from "@danypops/vehicle-client-pi/widget-header";
|
|
10
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
11
|
+
import { type AutoRotatingWindow, renderCardRow, type TextMeasure } from "malevich-tui-components";
|
|
12
|
+
|
|
13
|
+
const measure: TextMeasure = { visibleWidth, truncateToWidth, wrapTextWithAnsi };
|
|
14
|
+
|
|
15
|
+
/** The daemon's own manifest name (see service/src/daemon/service.ts's `new VehicleRegistry({ name: "packed" })`) -- vehicleWidgetTitle capitalizes it for display. */
|
|
16
|
+
const VEHICLE_NAME = "packed";
|
|
17
|
+
|
|
18
|
+
/** Visible issue rows per page before the auto-rotating overflow hint pages to the next. */
|
|
19
|
+
export const PACKED_DOCTOR_WIDGET_VISIBLE_ROWS = 5;
|
|
20
|
+
|
|
21
|
+
export interface DoctorWidgetIssue {
|
|
22
|
+
label: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DoctorWidgetProjection {
|
|
26
|
+
issues: DoctorWidgetIssue[];
|
|
27
|
+
total: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mirrors doctor.run's own `ok` computation (conflicts + non-ok extensions + serviceUnits), plus
|
|
32
|
+
* stale module-cache entries (directly actionable: "restart the daemon"). Deliberately excludes
|
|
33
|
+
* duplicateDependencies -- doctor.run's own `ok` boolean excludes them too (see doctor-format.ts's
|
|
34
|
+
* own doc comment on why), and they're common enough in this ecosystem's own staggered version
|
|
35
|
+
* rollout to be low-signal for a persistent glance-at-a-widget summary.
|
|
36
|
+
*/
|
|
37
|
+
export function buildDoctorWidgetProjection(report: DoctorReport): DoctorWidgetProjection {
|
|
38
|
+
const issues: DoctorWidgetIssue[] = [];
|
|
39
|
+
for (const conflict of report.conflicts) {
|
|
40
|
+
issues.push({ label: `${conflict.kind} "${conflict.name}" claimed by ${conflict.claimants.length} extensions` });
|
|
41
|
+
}
|
|
42
|
+
for (const extension of report.extensions) {
|
|
43
|
+
if (extension.status === "ok") continue;
|
|
44
|
+
issues.push({ label: `${extension.status} ${extension.name}${extension.message ? `: ${extension.message}` : ""}` });
|
|
45
|
+
}
|
|
46
|
+
for (const unit of report.serviceUnits) {
|
|
47
|
+
issues.push({ label: `${unit.package} (${unit.unitName}): ${unit.message}` });
|
|
48
|
+
}
|
|
49
|
+
for (const module of report.moduleFreshness ?? []) {
|
|
50
|
+
if (!module.stale) continue;
|
|
51
|
+
issues.push({ label: `${module.name} loaded ${module.loadedVersion ?? "?"}, disk has ${module.currentVersion ?? "?"}` });
|
|
52
|
+
}
|
|
53
|
+
return { issues, total: issues.length };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "Packed · Doctor · <N> issue(s)", plus a "page/total ⟳" suffix once genuinely paging. */
|
|
57
|
+
function doctorCardLabel(projection: DoctorWidgetProjection, rotation?: AutoRotatingWindow): string {
|
|
58
|
+
const base = vehicleWidgetTitle(VEHICLE_NAME, "Doctor", `${projection.total} issue${projection.total === 1 ? "" : "s"}`);
|
|
59
|
+
return rotation?.isPaging ? `${base} · ${rotation.pageIndex + 1}/${rotation.pageCount} ⟳` : base;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Renders the widget as a single bordered card -- `[]` (hide the whole widget) when there are no
|
|
63
|
+
* real issues, matching every other overlay's own "hide when nothing to show" convention. */
|
|
64
|
+
export function renderDoctorWidgetLines(
|
|
65
|
+
theme: { fg(color: string, text: string): string },
|
|
66
|
+
projection: DoctorWidgetProjection,
|
|
67
|
+
width: number,
|
|
68
|
+
rotation?: AutoRotatingWindow,
|
|
69
|
+
): string[] {
|
|
70
|
+
if (projection.total === 0) return [];
|
|
71
|
+
rotation?.setTotalRows(projection.issues.length);
|
|
72
|
+
const { start, end } = rotation?.currentPageBounds() ?? { start: 0, end: projection.issues.length };
|
|
73
|
+
const visibleIssues = projection.issues.slice(start, end);
|
|
74
|
+
|
|
75
|
+
return renderCardRow(
|
|
76
|
+
[
|
|
77
|
+
{
|
|
78
|
+
label: doctorCardLabel(projection, rotation),
|
|
79
|
+
render: (innerWidth: number) =>
|
|
80
|
+
visibleIssues.map((issue) => truncateToWidth(`${theme.fg("warning", "!")} ${issue.label}`, innerWidth, "…")),
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
width,
|
|
84
|
+
{ measure, frameStyle: (s) => theme.fg("borderMuted", s) },
|
|
85
|
+
);
|
|
86
|
+
}
|
package/extension/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Install: pi install git:github.com/DanyPops/pi-packed
|
|
10
10
|
*/
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { DoctorOverlay } from "./doctor-overlay.js";
|
|
12
13
|
import { formatUpdateNotice } from "./model.js";
|
|
13
14
|
import { createNatives } from "./packed.js";
|
|
14
15
|
import { registerProfiles } from "./profile.js";
|
|
@@ -42,6 +43,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
42
43
|
// the identical pi-papyrus/pi-tickets bug). session_start fires only after that initialization
|
|
43
44
|
// completes, and Pi awaits every session_start handler before the model's first turn, so
|
|
44
45
|
// registering here is both safe and still visible on turn one.
|
|
46
|
+
let doctorOverlay: DoctorOverlay | undefined;
|
|
45
47
|
pi.on("session_start", async (_event, ctx) => {
|
|
46
48
|
await registerPackedVehicle(pi);
|
|
47
49
|
if (!ctx.hasUI) return;
|
|
@@ -53,5 +55,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
53
55
|
} catch {
|
|
54
56
|
// mirror missing or unreadable — stay silent, never block startup.
|
|
55
57
|
}
|
|
58
|
+
doctorOverlay ??= new DoctorOverlay();
|
|
59
|
+
doctorOverlay.setUI(ctx.ui);
|
|
60
|
+
await doctorOverlay.refresh();
|
|
61
|
+
doctorOverlay.startPolling();
|
|
62
|
+
});
|
|
63
|
+
pi.on("session_shutdown", async () => {
|
|
64
|
+
doctorOverlay?.dispose();
|
|
56
65
|
});
|
|
57
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@danypops/vehicle-core": "^0.17.1",
|
|
35
35
|
"@danypops/vehicle-server": "^0.25.2",
|
|
36
36
|
"jiti": "^2.7.0",
|
|
37
|
-
"malevich-tui-components": "^0.
|
|
37
|
+
"malevich-tui-components": "^0.32.1",
|
|
38
38
|
"publint": "0.3.22",
|
|
39
39
|
"semver": "^7.8.5"
|
|
40
40
|
},
|
|
@@ -54,6 +54,34 @@ function addReadOnlyBind(args: string[], path: string): void {
|
|
|
54
54
|
if (existsSync(path)) args.push("--ro-bind", path, path);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const MAX_ANCESTOR_WALK = 32;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every ancestor directory's own node_modules from packageRoot's parent up to
|
|
61
|
+
* the filesystem root -- mirrors Node's real module-resolution walk
|
|
62
|
+
* (require.resolve checks <dir>/node_modules at every level up to /).
|
|
63
|
+
* Confirmed live: packed doctor's own widget reported real extensions
|
|
64
|
+
* (pi-lector, pi-pipes, pi-tickets, pi-jittor, pi-web-spider) as "crash" on a
|
|
65
|
+
* plain Cannot-find-module for a sibling dependency npm/bun hoisted to a
|
|
66
|
+
* shared ancestor node_modules, though Pi's own real, unsandboxed process
|
|
67
|
+
* resolves the exact same import fine -- the sandbox only ever bound the
|
|
68
|
+
* package's own root, never the hoisted siblings one or more levels above it.
|
|
69
|
+
* Bounded to MAX_ANCESTOR_WALK levels as a defensive cap; a real filesystem
|
|
70
|
+
* hierarchy never gets remotely close to that before reaching /.
|
|
71
|
+
*/
|
|
72
|
+
function collectAncestorNodeModulesDirs(packageRoot: string): string[] {
|
|
73
|
+
const dirs: string[] = [];
|
|
74
|
+
let current = dirname(packageRoot);
|
|
75
|
+
for (let i = 0; i < MAX_ANCESTOR_WALK; i++) {
|
|
76
|
+
const candidate = join(current, "node_modules");
|
|
77
|
+
if (existsSync(candidate)) dirs.push(realpathSync(candidate));
|
|
78
|
+
const parent = dirname(current);
|
|
79
|
+
if (parent === current) break;
|
|
80
|
+
current = parent;
|
|
81
|
+
}
|
|
82
|
+
return dirs;
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
/**
|
|
58
86
|
* Resolves an installed dependency's real on-disk package directory via
|
|
59
87
|
* Node's own module-resolution algorithm (require.resolve against its
|
|
@@ -85,7 +113,6 @@ function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses
|
|
|
85
113
|
const jitiModules = resolveDependencyModulesDir(packageDirectory, "jiti");
|
|
86
114
|
if (!jitiModules) return undefined;
|
|
87
115
|
const bun = realpathSync(process.execPath);
|
|
88
|
-
const relativeExtension = relative(packageRoot, extensionPath).split(sep).join("/");
|
|
89
116
|
const args = [
|
|
90
117
|
"/usr/bin/bwrap",
|
|
91
118
|
"--unshare-user",
|
|
@@ -116,9 +143,6 @@ function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses
|
|
|
116
143
|
jitiModules,
|
|
117
144
|
"/runner/node_modules/jiti",
|
|
118
145
|
"--ro-bind",
|
|
119
|
-
packageRoot,
|
|
120
|
-
"/package",
|
|
121
|
-
"--ro-bind",
|
|
122
146
|
bun,
|
|
123
147
|
"/bun",
|
|
124
148
|
"--dev",
|
|
@@ -131,6 +155,17 @@ function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses
|
|
|
131
155
|
"/tmp",
|
|
132
156
|
"--dir",
|
|
133
157
|
"/tmp/home",
|
|
158
|
+
);
|
|
159
|
+
// Package-related identity binds go AFTER the virtual/tmpfs mounts above --
|
|
160
|
+
// bwrap applies mounts in argument order, so a bind whose real path happens
|
|
161
|
+
// to sit under one of those special mounts (e.g. a test fixture under
|
|
162
|
+
// /tmp, which --tmpfs /tmp would otherwise shadow) must come later to win.
|
|
163
|
+
addReadOnlyBind(args, packageRoot);
|
|
164
|
+
// Real-path identity binds so a hoisted sibling dependency resolves exactly
|
|
165
|
+
// as it would in Pi's own real, unsandboxed process -- see
|
|
166
|
+
// collectAncestorNodeModulesDirs's own doc comment for why this is needed.
|
|
167
|
+
for (const dir of collectAncestorNodeModulesDirs(packageRoot)) addReadOnlyBind(args, dir);
|
|
168
|
+
args.push(
|
|
134
169
|
"--remount-ro",
|
|
135
170
|
"/",
|
|
136
171
|
"--clearenv",
|
|
@@ -147,7 +182,7 @@ function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses
|
|
|
147
182
|
"NODE_PATH",
|
|
148
183
|
"/runner/node_modules",
|
|
149
184
|
"--chdir",
|
|
150
|
-
|
|
185
|
+
packageRoot,
|
|
151
186
|
"--",
|
|
152
187
|
"/usr/bin/prlimit",
|
|
153
188
|
`--nproc=${maxProcesses}`,
|
|
@@ -158,7 +193,7 @@ function sandboxCommand(packageRoot: string, extensionPath: string, maxProcesses
|
|
|
158
193
|
"--",
|
|
159
194
|
"/bun",
|
|
160
195
|
"/runner/src/smoke-child.ts",
|
|
161
|
-
|
|
196
|
+
extensionPath,
|
|
162
197
|
);
|
|
163
198
|
return args;
|
|
164
199
|
}
|
|
@@ -151,6 +151,35 @@ describeIfSandboxed("isolated extension smoke runner", () => {
|
|
|
151
151
|
expect((await runExtensionSmoke(output.root, output.path, { maxOutputBytes: 4_096 })).status).toBe("output-limit");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
it("resolves a dependency hoisted to a shared ancestor node_modules -- confirmed live bug: packed doctor's own widget reported 5/8 real extensions as crashing on a plain 'Cannot find module' for a sibling dependency npm/bun hoisted above the package's own node_modules, though Pi's own real, unsandboxed process resolves the exact same import fine", async () => {
|
|
155
|
+
const workspaceRoot = mkdtempSync(join(tmpdir(), "packed-smoke-hoisted-"));
|
|
156
|
+
roots.push(workspaceRoot);
|
|
157
|
+
const sharedNodeModules = join(workspaceRoot, "node_modules");
|
|
158
|
+
writeFakeDependency(sharedNodeModules, "hoisted-sibling");
|
|
159
|
+
const packageDir = join(sharedNodeModules, "@fixture", "pi-smoke-hoisted");
|
|
160
|
+
mkdirSync(join(packageDir, "extension"), { recursive: true });
|
|
161
|
+
const extensionPath = join(packageDir, "extension", "index.ts");
|
|
162
|
+
writeFileSync(
|
|
163
|
+
extensionPath,
|
|
164
|
+
'import pkg from "hoisted-sibling/package.json" with { type: "json" };\nexport default function (pi: any) { pi.registerCommand(pkg.name, { handler() {} }); }\n',
|
|
165
|
+
);
|
|
166
|
+
writeFileSync(
|
|
167
|
+
join(packageDir, "package.json"),
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
name: "@fixture/pi-smoke-hoisted",
|
|
170
|
+
version: "1.0.0",
|
|
171
|
+
keywords: ["pi-package"],
|
|
172
|
+
files: ["extension"],
|
|
173
|
+
pi: { extensions: ["extension/index.ts"] },
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const result = await runExtensionSmoke(packageDir, extensionPath);
|
|
178
|
+
|
|
179
|
+
expect(result.status).toBe("ok");
|
|
180
|
+
expect(result.registrations.commands).toEqual(["hoisted-sibling"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
154
183
|
it("keeps default package checks static and adds smoke results only when requested", async () => {
|
|
155
184
|
const fixture = extension(
|
|
156
185
|
'import { writeFileSync } from "node:fs"; export default function (pi: any) { writeFileSync("executed", "yes"); pi.registerCommand("x", { handler() {} }); }',
|