@henryqw/pi-deps 0.1.2 → 0.2.0
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/README.md +1 -1
- package/extensions/deps.ts +64 -0
- package/hooks/post-checkout.mjs +68 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Run `/deps` once from any worktree to enable preparation through repository's sh
|
|
|
32
32
|
|
|
33
33
|
Only Git-root lockfiles are inspected. npm, pnpm, Yarn, and Bun use frozen installs; uv uses `uv sync --locked`. Node and uv both run when both lockfile types exist. Root npm and uv workspaces remain package-manager concerns; nested independent projects are not scanned.
|
|
34
34
|
|
|
35
|
-
Creation
|
|
35
|
+
Creation returns immediately. The hook validates lockfiles synchronously — conflicting Node lockfiles, `packageManager` mismatches, and unsupported declarations still fail the worktree command fast — then a detached installer runs the frozen installs in the background. Its outcome lands in `<worktree gitdir>/pi-deps/status.json` with command output in the adjacent `install.log`; the file is removed once a Pi session reports it. A Pi session opened in the worktree shows an editor widget while installing, auto-dismisses success after five seconds, and keeps install failures visible (including missing executables). Other tools creating worktrees get the same background install but must consume the status file themselves. Already-present `node_modules`, `.pnp.cjs`, or `.venv` are skipped. Worktrees created with `git worktree add --no-checkout` never run `post-checkout`, so they are not prepared. Because installs finish after creation returns, a consumer may start using a worktree before dependencies are ready.
|
|
36
36
|
|
|
37
37
|
Dependency installation may execute repository-controlled build and install scripts. Enable only repositories you trust.
|
|
38
38
|
|
package/extensions/deps.ts
CHANGED
|
@@ -63,7 +63,71 @@ export async function toggleDependencyHook(commonGitDir: string): Promise<{ enab
|
|
|
63
63
|
return { enabled: true, path };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
const widgetKey = "pi-deps";
|
|
67
|
+
const pollMs = 500;
|
|
68
|
+
const successTtlMs = 5000;
|
|
69
|
+
const waitTimeoutMs = 10 * 60_000;
|
|
70
|
+
|
|
71
|
+
interface InstallStatus {
|
|
72
|
+
state?: string;
|
|
73
|
+
message?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface InstallWatchContext {
|
|
77
|
+
cwd: string;
|
|
78
|
+
mode?: string;
|
|
79
|
+
ui: { setWidget(key: string, content: string[] | undefined): void };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Watches the status file written by the background installer spawned by the post-checkout hook.
|
|
83
|
+
// First consumer wins: the status file is removed once reported.
|
|
84
|
+
export async function watchDependencyInstallation(
|
|
85
|
+
exec: ExtensionAPI["exec"],
|
|
86
|
+
ctx: InstallWatchContext,
|
|
87
|
+
timing: { pollMs?: number; successTtlMs?: number } = {},
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (ctx.mode && ctx.mode !== "tui") return;
|
|
90
|
+
const git = await exec("git", ["rev-parse", "--path-format=absolute", "--git-dir"], { cwd: ctx.cwd });
|
|
91
|
+
if (git.code !== 0 || git.killed) return;
|
|
92
|
+
const stateDir = join(git.stdout.trim(), "pi-deps");
|
|
93
|
+
const statusPath = join(stateDir, "status.json");
|
|
94
|
+
const readStatus = async (): Promise<InstallStatus | undefined> => {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(await readFile(statusPath, "utf8")) as InstallStatus;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
99
|
+
return { state: "running" }; // partial write from installer or test; retry next poll
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let status = await readStatus();
|
|
104
|
+
if (!status) return;
|
|
105
|
+
if (status.state === "running") ctx.ui.setWidget(widgetKey, ["pi-deps: installing dependencies…"]);
|
|
106
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
107
|
+
while (status.state === "running" && Date.now() < deadline) {
|
|
108
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, timing.pollMs ?? pollMs));
|
|
109
|
+
status = await readStatus();
|
|
110
|
+
if (!status) {
|
|
111
|
+
ctx.ui.setWidget(widgetKey, undefined);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await rm(statusPath, { force: true }); // consume so later sessions do not replay a stale outcome
|
|
116
|
+
if (status.state === "ok") {
|
|
117
|
+
ctx.ui.setWidget(widgetKey, ["pi-deps: dependencies installed"]);
|
|
118
|
+
setTimeout(() => ctx.ui.setWidget(widgetKey, undefined), timing.successTtlMs ?? successTtlMs);
|
|
119
|
+
} else if (status.state === "error") {
|
|
120
|
+
ctx.ui.setWidget(widgetKey, [
|
|
121
|
+
`pi-deps: install failed: ${status.message ?? "unknown error"}`,
|
|
122
|
+
`pi-deps: log: ${join(stateDir, "install.log")}`,
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
66
127
|
export default function depsExtension(pi: ExtensionAPI): void {
|
|
128
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
129
|
+
await watchDependencyInstallation(pi.exec.bind(pi), ctx).catch(() => {});
|
|
130
|
+
});
|
|
67
131
|
pi.registerCommand("deps", {
|
|
68
132
|
description: "Toggle dependency preparation for future Git worktrees",
|
|
69
133
|
handler: async (args, ctx) => {
|
package/hooks/post-checkout.mjs
CHANGED
|
@@ -1,9 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// pi-deps-managed-hook
|
|
3
3
|
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const installMode = "--pi-deps-install";
|
|
10
|
+
|
|
11
|
+
function errorMessage(error) {
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeStatus(stateDir, status) {
|
|
16
|
+
writeFileSync(join(stateDir, "status.json"), `${JSON.stringify(status)}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Installer child: runs the frozen installs detached and records the outcome for watchers.
|
|
20
|
+
if (process.argv[2] === installMode) {
|
|
21
|
+
const [, , , root, stateDir, commandsJson] = process.argv;
|
|
22
|
+
try {
|
|
23
|
+
process.chdir(root);
|
|
24
|
+
for (const [command, args] of JSON.parse(commandsJson)) {
|
|
25
|
+
console.error(`pi-deps: ${command} ${args.join(" ")}`);
|
|
26
|
+
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
27
|
+
if (result.error) throw new Error(`failed to start ${command}: ${result.error.message}`);
|
|
28
|
+
if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? "signal"}`);
|
|
29
|
+
}
|
|
30
|
+
writeStatus(stateDir, { state: "ok" });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`pi-deps: ${errorMessage(error)}`);
|
|
33
|
+
writeStatus(stateDir, { state: "error", message: errorMessage(error) });
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
7
38
|
|
|
8
39
|
const [, , oldHead, , checkoutKind] = process.argv;
|
|
9
40
|
if (!/^0+$/.test(oldHead ?? "") || checkoutKind !== "1") process.exit(0);
|
|
@@ -17,24 +48,15 @@ const isDirectory = (name) => {
|
|
|
17
48
|
try { return statSync(path(name)).isDirectory(); } catch { return false; }
|
|
18
49
|
};
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const result = spawnSync(command, args, { cwd: root, stdio: "inherit" });
|
|
23
|
-
if (result.error) {
|
|
24
|
-
console.error(`pi-deps: failed to start ${command}: ${result.error.message}`);
|
|
25
|
-
process.exit(result.error.code === "ENOENT" ? 127 : 1);
|
|
26
|
-
}
|
|
27
|
-
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function nodeInstall() {
|
|
51
|
+
// Validation throws synchronously so configuration mistakes still fail worktree creation fast.
|
|
52
|
+
function nodeCommands() {
|
|
31
53
|
const locks = [];
|
|
32
54
|
if (isFile("package-lock.json") || isFile("npm-shrinkwrap.json")) locks.push("npm");
|
|
33
55
|
if (isFile("pnpm-lock.yaml")) locks.push("pnpm");
|
|
34
56
|
if (isFile("yarn.lock")) locks.push("yarn");
|
|
35
57
|
if (isFile("bun.lock") || isFile("bun.lockb")) locks.push("bun");
|
|
36
58
|
if (isFile("bun.lock") && isFile("bun.lockb")) throw new Error("Conflicting Bun lockfiles: bun.lock and bun.lockb");
|
|
37
|
-
if (locks.length === 0) return;
|
|
59
|
+
if (locks.length === 0) return [];
|
|
38
60
|
if (locks.length > 1) throw new Error(`Conflicting Node lockfiles: ${locks.join(", ")}`);
|
|
39
61
|
if (!isFile("package.json")) throw new Error("Node lockfile found without package.json");
|
|
40
62
|
|
|
@@ -42,7 +64,7 @@ function nodeInstall() {
|
|
|
42
64
|
try {
|
|
43
65
|
packageJson = JSON.parse(readFileSync(path("package.json"), "utf8"));
|
|
44
66
|
} catch (error) {
|
|
45
|
-
throw new Error(`Cannot read package.json: ${
|
|
67
|
+
throw new Error(`Cannot read package.json: ${errorMessage(error)}`);
|
|
46
68
|
}
|
|
47
69
|
const declared = packageJson?.packageManager;
|
|
48
70
|
let manager = locks[0];
|
|
@@ -56,17 +78,40 @@ function nodeInstall() {
|
|
|
56
78
|
}
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
if (isDirectory("node_modules") || (manager === "yarn" && isFile(".pnp.cjs"))) return;
|
|
60
|
-
if (manager === "npm")
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
if (isDirectory("node_modules") || (manager === "yarn" && isFile(".pnp.cjs"))) return [];
|
|
82
|
+
if (manager === "npm") return [["npm", ["ci"]]];
|
|
83
|
+
if (manager === "pnpm") return [["pnpm", ["install", "--frozen-lockfile"]]];
|
|
84
|
+
if (manager === "bun") return [["bun", ["install", "--frozen-lockfile"]]];
|
|
85
|
+
return [["yarn", ["install", "--immutable"]]];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function installCommands() {
|
|
89
|
+
const commands = nodeCommands();
|
|
90
|
+
if (isFile("uv.lock") && !isDirectory(".venv")) commands.push(["uv", ["sync", "--locked"]]);
|
|
91
|
+
return commands;
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
95
|
+
const commands = installCommands();
|
|
96
|
+
const git = spawnSync("git", ["rev-parse", "--path-format=absolute", "--git-dir"], { cwd: root, encoding: "utf8" });
|
|
97
|
+
if (git.error) throw new Error(`Cannot locate Git directory: ${git.error.message}`);
|
|
98
|
+
if (git.status !== 0) {
|
|
99
|
+
throw new Error(`Cannot locate Git directory: ${(git.stderr || "").trim() || `exit code ${git.status}`}`);
|
|
100
|
+
}
|
|
101
|
+
const stateDir = join(git.stdout.trim(), "pi-deps");
|
|
102
|
+
const statusPath = join(stateDir, "status.json");
|
|
103
|
+
if (commands.length === 0) {
|
|
104
|
+
rmSync(statusPath, { force: true }); // stale outcome from an earlier run
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
mkdirSync(stateDir, { recursive: true });
|
|
108
|
+
writeStatus(stateDir, { state: "running" });
|
|
109
|
+
const log = openSync(join(stateDir, "install.log"), "a");
|
|
110
|
+
spawn(process.execPath, [fileURLToPath(import.meta.url), installMode, root, stateDir, JSON.stringify(commands)], {
|
|
111
|
+
detached: true,
|
|
112
|
+
stdio: ["ignore", log, log],
|
|
113
|
+
}).unref();
|
|
69
114
|
} catch (error) {
|
|
70
|
-
console.error(`pi-deps: ${
|
|
115
|
+
console.error(`pi-deps: ${errorMessage(error)}`);
|
|
71
116
|
process.exit(1);
|
|
72
117
|
}
|