@henryqw/pi-deps 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # `@henryqw/pi-deps`
2
+
3
+ Prepare locked Node and uv dependencies whenever Git creates a new worktree for an opted-in repository.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@henryqw/pi-deps
9
+ ```
10
+
11
+ Node 22.19 or newer, Git, and each selected package manager must be available on `PATH` used by Git.
12
+
13
+ ## Supported managers
14
+
15
+ Only current major versions are supported; older majors are not handled and fall back to the lockfile default below.
16
+
17
+ | Manager | Command | Lockfile |
18
+ | --- | --- | --- |
19
+ | npm | `npm ci` | `package-lock.json`, `npm-shrinkwrap.json` |
20
+ | pnpm | `pnpm install --frozen-lockfile` | `pnpm-lock.yaml` |
21
+ | Yarn | `yarn install --immutable` | `yarn.lock` (Yarn Classic 1.x is not supported) |
22
+ | Bun | `bun install --frozen-lockfile` | `bun.lock`, `bun.lockb` |
23
+ | uv | `uv sync --locked` | `uv.lock` |
24
+
25
+ ## Use
26
+
27
+ | Surface | Type | Purpose |
28
+ | --- | --- | --- |
29
+ | `/deps` | command | Toggle dependency preparation for future worktrees in current repository. |
30
+
31
+ Run `/deps` once from any worktree to enable preparation through repository's shared `post-checkout` hook. Run it again to disable. Hooks without this package's marker are never overwritten or removed. After updating the package, run `/deps` twice in each opted-in repository to replace the copied hook with the current version. A configured `core.hooksPath` replaces the shared hooks directory, so `/deps` refuses instead of installing where Git would ignore or share the hook.
32
+
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
+
35
+ Creation waits for installs. Missing executables, conflicting Node lockfiles, `packageManager` mismatches, and install failures make worktree command fail while leaving created worktree available for inspection. Unsupported repositories and 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.
36
+
37
+ Dependency installation may execute repository-controlled build and install scripts. Enable only repositories you trust.
38
+
39
+ ## Remove
40
+
41
+ Disable each opted-in repository before removing package because copied Git hook is self-contained:
42
+
43
+ ```text
44
+ /deps
45
+ ```
46
+
47
+ ```bash
48
+ pi remove npm:@henryqw/pi-deps
49
+ ```
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ npm test --workspace @henryqw/pi-deps
55
+ npm run typecheck --workspace @henryqw/pi-deps
56
+ npm run pack:check --workspace @henryqw/pi-deps
57
+ ```
@@ -0,0 +1,100 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, mkdir, readFile, rename, rm, writeFile, chmod } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ const hookSourcePath = fileURLToPath(new URL("../hooks/post-checkout.mjs", import.meta.url));
8
+ const managedHookMarker = "pi-deps-managed-hook";
9
+
10
+ function errorMessage(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+
14
+ async function existingHook(path: string): Promise<string | undefined> {
15
+ try {
16
+ return await readFile(path, "utf8");
17
+ } catch (error) {
18
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
19
+ throw error;
20
+ }
21
+ }
22
+
23
+ export async function toggleDependencyHook(commonGitDir: string): Promise<{ enabled: boolean; path: string }> {
24
+ const source = await readFile(hookSourcePath, "utf8");
25
+ const path = join(commonGitDir, "hooks", "post-checkout");
26
+ const existing = await existingHook(path);
27
+
28
+ if (existing !== undefined) {
29
+ if (!existing.includes(managedHookMarker)) {
30
+ throw new Error(`Refusing to modify unmanaged Git hook: ${path}`);
31
+ }
32
+ // Rename first so the content check happens on the exact inode being removed.
33
+ const staging = `${path}.pi-deps-${process.pid}-${randomUUID()}`;
34
+ await rename(path, staging);
35
+ try {
36
+ const removed = await readFile(staging, "utf8");
37
+ if (!removed.includes(managedHookMarker)) {
38
+ await rename(staging, path);
39
+ throw new Error(`Refusing to modify unmanaged Git hook: ${path}`);
40
+ }
41
+ await rm(staging);
42
+ } catch (error) {
43
+ await rm(staging, { force: true });
44
+ throw error;
45
+ }
46
+ return { enabled: false, path };
47
+ }
48
+
49
+ await mkdir(dirname(path), { recursive: true });
50
+ const temporary = `${path}.pi-deps-${process.pid}-${randomUUID()}`;
51
+ try {
52
+ await writeFile(temporary, source, { encoding: "utf8", mode: 0o755, flag: "wx" });
53
+ await chmod(temporary, 0o755);
54
+ await link(temporary, path);
55
+ } catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
57
+ throw new Error(`Refusing to modify Git hook created concurrently: ${path}`, { cause: error });
58
+ }
59
+ throw error;
60
+ } finally {
61
+ await rm(temporary, { force: true });
62
+ }
63
+ return { enabled: true, path };
64
+ }
65
+
66
+ export default function depsExtension(pi: ExtensionAPI): void {
67
+ pi.registerCommand("deps", {
68
+ description: "Toggle dependency preparation for future Git worktrees",
69
+ handler: async (args, ctx) => {
70
+ if (args.trim()) throw new Error("Usage: /deps");
71
+ const git = (args2: string[]) => pi.exec("git", args2, { cwd: ctx.cwd });
72
+ const result = await git(["rev-parse", "--git-common-dir"]);
73
+ if (result.code !== 0 || result.killed) {
74
+ throw new Error(`Cannot locate shared Git directory: ${result.stderr.trim() || `exit code ${result.code}`}`);
75
+ }
76
+ const commonGitDir = result.stdout.trim();
77
+ if (!commonGitDir) throw new Error("Cannot locate shared Git directory: git returned an empty path");
78
+
79
+ // core.hooksPath replaces <commonGitDir>/hooks; refuse instead of writing where Git ignores or shares the hook.
80
+ const hooksDir = await git(["rev-parse", "--git-path", "hooks"]);
81
+ if (hooksDir.code !== 0 || hooksDir.killed) {
82
+ throw new Error(`Cannot locate effective hooks directory: ${hooksDir.stderr.trim() || `exit code ${hooksDir.code}`}`);
83
+ }
84
+ const defaultHooksDir = resolve(ctx.cwd, commonGitDir, "hooks");
85
+ if (resolve(ctx.cwd, hooksDir.stdout.trim()) !== defaultHooksDir) {
86
+ throw new Error(`Refusing non-default hooks directory (core.hooksPath?): ${hooksDir.stdout.trim()}; expected ${defaultHooksDir}`);
87
+ }
88
+
89
+ try {
90
+ const toggled = await toggleDependencyHook(resolve(ctx.cwd, commonGitDir));
91
+ ctx.ui.notify(
92
+ `Dependency preparation ${toggled.enabled ? "enabled" : "disabled"}: ${toggled.path}`,
93
+ "info",
94
+ );
95
+ } catch (error) {
96
+ throw new Error(`Cannot toggle dependency preparation: ${errorMessage(error)}`, { cause: error });
97
+ }
98
+ },
99
+ });
100
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // pi-deps-managed-hook
3
+
4
+ import { spawnSync } from "node:child_process";
5
+ import { readFileSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ const [, , oldHead, , checkoutKind] = process.argv;
9
+ if (!/^0+$/.test(oldHead ?? "") || checkoutKind !== "1") process.exit(0);
10
+
11
+ const root = process.cwd();
12
+ const path = (name) => join(root, name);
13
+ const isFile = (name) => {
14
+ try { return statSync(path(name)).isFile(); } catch { return false; }
15
+ };
16
+ const isDirectory = (name) => {
17
+ try { return statSync(path(name)).isDirectory(); } catch { return false; }
18
+ };
19
+
20
+ function run(command, args) {
21
+ console.error(`pi-deps: ${command} ${args.join(" ")}`);
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() {
31
+ const locks = [];
32
+ if (isFile("package-lock.json") || isFile("npm-shrinkwrap.json")) locks.push("npm");
33
+ if (isFile("pnpm-lock.yaml")) locks.push("pnpm");
34
+ if (isFile("yarn.lock")) locks.push("yarn");
35
+ if (isFile("bun.lock") || isFile("bun.lockb")) locks.push("bun");
36
+ if (isFile("bun.lock") && isFile("bun.lockb")) throw new Error("Conflicting Bun lockfiles: bun.lock and bun.lockb");
37
+ if (locks.length === 0) return;
38
+ if (locks.length > 1) throw new Error(`Conflicting Node lockfiles: ${locks.join(", ")}`);
39
+ if (!isFile("package.json")) throw new Error("Node lockfile found without package.json");
40
+
41
+ let packageJson;
42
+ try {
43
+ packageJson = JSON.parse(readFileSync(path("package.json"), "utf8"));
44
+ } catch (error) {
45
+ throw new Error(`Cannot read package.json: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ const declared = packageJson?.packageManager;
48
+ let manager = locks[0];
49
+ if (declared !== undefined) {
50
+ if (typeof declared !== "string" || !/^(npm|pnpm|yarn|bun)@.+$/.test(declared)) {
51
+ throw new Error(`Unsupported packageManager: ${JSON.stringify(declared)}`);
52
+ }
53
+ manager = declared.slice(0, declared.indexOf("@"));
54
+ if (manager !== locks[0]) {
55
+ throw new Error(`packageManager ${manager} does not match ${locks[0]} lockfile`);
56
+ }
57
+ }
58
+
59
+ if (isDirectory("node_modules") || (manager === "yarn" && isFile(".pnp.cjs"))) return;
60
+ if (manager === "npm") run("npm", ["ci"]);
61
+ else if (manager === "pnpm") run("pnpm", ["install", "--frozen-lockfile"]);
62
+ else if (manager === "bun") run("bun", ["install", "--frozen-lockfile"]);
63
+ else run("yarn", ["install", "--immutable"]);
64
+ }
65
+
66
+ try {
67
+ nodeInstall();
68
+ if (isFile("uv.lock") && !isDirectory(".venv")) run("uv", ["sync", "--locked"]);
69
+ } catch (error) {
70
+ console.error(`pi-deps: ${error instanceof Error ? error.message : String(error)}`);
71
+ process.exit(1);
72
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@henryqw/pi-deps",
3
+ "version": "0.1.2",
4
+ "description": "Prepare Node and uv dependencies when opted-in Git worktrees are created.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "git",
9
+ "worktree",
10
+ "dependencies"
11
+ ],
12
+ "type": "module",
13
+ "engines": {
14
+ "node": ">=22.19.0"
15
+ },
16
+ "license": "MIT",
17
+ "files": [
18
+ "extensions",
19
+ "hooks",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test test/*.test.ts",
25
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/deps.ts test/*.test.ts",
26
+ "pack:check": "npm pack --dry-run"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "*"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/HenryQW/pi-packages.git",
34
+ "directory": "packages/pi-deps"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/HenryQW/pi-packages/issues"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "pi": {
43
+ "extensions": [
44
+ "./extensions/deps.ts"
45
+ ]
46
+ }
47
+ }