@hcflabs/pi-deps 0.0.0-stage → 3.2.6
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 +22 -0
- package/README.md +71 -2
- package/docs/adr/0001-prepare-dependencies-at-worktree-creation.md +5 -0
- package/docs/dependency-preparation-flow.html +165 -0
- package/docs/dependency-preparation-flow.svg +165 -0
- package/extensions/deps.ts +183 -0
- package/hooks/post-checkout.mjs +118 -0
- package/package.json +49 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
4
|
+
Copyright (c) 2026 hcflabs
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@hcflabs/pi-deps`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Start locked Node and uv dependency installs when Git creates a worktree in an opted-in repository. See [the hook design decision](./docs/adr/0001-prepare-dependencies-at-worktree-creation.md).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@hcflabs/pi-deps
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
Run `/deps` in a trusted repository. Create a worktree through Git, then open Pi there in TUI mode to see install progress.
|
|
14
|
+
|
|
15
|
+
| Surface | Type | Purpose |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `/deps` | command | For humans: enable or disable dependency preparation for future worktrees. Works from any worktree in the repository. |
|
|
18
|
+
| Pi footer status | ui | For TUI users: show a dim spinner and elapsed install time, without command output. Success clears it silently; failures show errors and log paths. |
|
|
19
|
+
|
|
20
|
+
The shared `post-checkout` hook applies to every worktree. The extension never overwrites or removes hooks without its marker.
|
|
21
|
+
|
|
22
|
+
After package updates, run `/deps` twice in each opted-in repository to replace the copied hook.
|
|
23
|
+
|
|
24
|
+
## Flow
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
Synchronous lockfile validation immediately fails the worktree command for conflicting Node lockfiles, mismatched `packageManager`, and unsupported declarations.
|
|
29
|
+
|
|
30
|
+
After validation, detached frozen installs run. Node and uv both run when both lockfile types exist. Worktree creation and Pi startup do not wait: dependencies can still be unavailable when you use the worktree.
|
|
31
|
+
|
|
32
|
+
Only tools that run `post-checkout` prepare dependencies. `git worktree add --no-checkout` does not; `git worktree add --detach` with checkout does.
|
|
33
|
+
|
|
34
|
+
## State and storage
|
|
35
|
+
|
|
36
|
+
Results go to `<worktree gitdir>/pi-deps/status.json`; command output goes to adjacent `install.log`.
|
|
37
|
+
|
|
38
|
+
The first Pi TUI session to report the result removes the status file. RPC, JSON, and print sessions neither show nor consume it. Non-Pi consumers must read it themselves.
|
|
39
|
+
|
|
40
|
+
## Limits and recovery
|
|
41
|
+
|
|
42
|
+
Dependency installation can execute repository-controlled build and install scripts. Enable only repositories that you trust.
|
|
43
|
+
|
|
44
|
+
`/deps` refuses a configured `core.hooksPath` that replaces the shared hooks directory, where Git could ignore or share the hook.
|
|
45
|
+
|
|
46
|
+
Only Git-root lockfiles count. Package managers handle root npm and uv workspaces; Pi Deps does not scan nested independent projects.
|
|
47
|
+
|
|
48
|
+
| Existing path | Install skipped |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `node_modules` | Node |
|
|
51
|
+
| `.pnp.cjs` | Yarn only |
|
|
52
|
+
| `.venv` | uv |
|
|
53
|
+
|
|
54
|
+
After ten minutes of `running` status, the TUI watcher removes the status file, clears the footer, and reports an error with the log path. It stops watching, not the installer. Opening Pi again cannot restore removed status.
|
|
55
|
+
|
|
56
|
+
Closing Pi stops its watcher and animation, but does not cancel the installer or consume its pending result.
|
|
57
|
+
|
|
58
|
+
For failures, including missing executables, examine `<worktree gitdir>/pi-deps/install.log` for command output and `pi-deps:` messages. The log remains after status removal.
|
|
59
|
+
|
|
60
|
+
### Package managers
|
|
61
|
+
|
|
62
|
+
The root lockfile selects the command. A matching `packageManager` uses that command for all declared versions; the hook does not compare installed executable versions.
|
|
63
|
+
|
|
64
|
+
Explicit `yarn@1.x` fails immediately. With only `yarn.lock` and no `packageManager`, the hook assumes modern Yarn; a Yarn Classic executable fails in the background.
|
|
65
|
+
|
|
66
|
+
| Manager | Command | Lockfile |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| npm | `npm ci` | `package-lock.json`, `npm-shrinkwrap.json` |
|
|
69
|
+
| pnpm | `pnpm install --frozen-lockfile` | `pnpm-lock.yaml` |
|
|
70
|
+
| Yarn | `yarn install --immutable` | `yarn.lock` (no support for Yarn Classic 1.x) |
|
|
71
|
+
| Bun | `bun install --frozen-lockfile` | `bun.lock`, `bun.lockb` |
|
|
72
|
+
| uv | `uv sync --locked` | `uv.lock` |
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Prepare dependencies at worktree creation
|
|
2
|
+
|
|
3
|
+
Pi Deps installs through an explicitly enabled repository's shared Git `post-checkout` hook rather than Pi session startup. Git runs that hook when a worktree is created with a checkout, regardless of creator; `git worktree add --no-checkout` does not run it. The hook acts only on that initial checkout. Pi sessions pay no repeated startup cost. Global Git hooks and creator-specific integrations were rejected because they either override unrelated hooks or miss non-Pi creators.
|
|
4
|
+
|
|
5
|
+
The hook validates lockfiles synchronously, so conflicting lockfiles and malformed `packageManager` declarations still fail worktree creation fast. It then spawns a detached installer child and exits immediately. The installer records its outcome in `<worktree gitdir>/pi-deps/status.json` and appends output to `install.log` in the same directory. Install failures therefore do not fail worktree creation. A Pi TUI session opened in the worktree watches the status file and reports progress through a dim footer spinner with elapsed time. Success clears silently; failures remain visible with the installer log path. Keeping normal background work in the footer avoids distracting from the conversation while ensuring failures still demand attention. Blocking creation on installs was rejected because multi-minute frozen installs delayed every `git worktree add`.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Dependency preparation flow</title>
|
|
7
|
+
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&family=Noto+Sans+KR:wght@400;500;600&family=Noto+Serif+KR:wght@400&display=swap" rel="stylesheet">
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; min-height: 100%; background: #f0eee9; }
|
|
10
|
+
body { display: grid; place-items: center; }
|
|
11
|
+
figure { margin: 0; width: min(100%, 1280px); }
|
|
12
|
+
svg { display: block; width: 100%; height: auto; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<figure>
|
|
17
|
+
<svg width="1280" height="720" viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dependency-preparation-flow-title dependency-preparation-flow-desc">
|
|
18
|
+
<title id="dependency-preparation-flow-title">Dependency preparation flow</title>
|
|
19
|
+
<desc id="dependency-preparation-flow-desc">Swimlane diagram showing that the shared hook synchronously validates root lockfiles plus packageManager declarations. Conflicting lockfiles, mismatches, and unsupported declarations fail the worktree command. Valid metadata may start selected detached installs and status reporting; if no install is needed, the hook clears stale status and stops, but consumers may use the worktree before dependencies are ready.</desc>
|
|
20
|
+
<defs>
|
|
21
|
+
<style>
|
|
22
|
+
.lane-label { fill: #4c5665; font: 500 8px 'Geist Mono', monospace; letter-spacing: .16em; }
|
|
23
|
+
.node-name { fill: #101828; font: 600 12px 'Geist', sans-serif; text-anchor: middle; }
|
|
24
|
+
.node-sub { fill: #4c5665; font: 400 9px 'Geist Mono', monospace; text-anchor: middle; }
|
|
25
|
+
.node { fill: #fff; stroke: #101828; stroke-width: 1; }
|
|
26
|
+
.node-input { fill: rgba(76,86,101,.10); stroke: #6a7282; stroke-width: 1; }
|
|
27
|
+
.node-async { fill: rgba(16,24,40,.02); stroke: rgba(16,24,40,.40); stroke-width: 1; stroke-dasharray: 4 3; }
|
|
28
|
+
.node-state { fill: rgba(16,24,40,.05); stroke: #4c5665; stroke-width: 1; }
|
|
29
|
+
.node-failure { fill: rgba(16,24,40,.02); stroke: #6a7282; stroke-width: 1; stroke-dasharray: 4 4; }
|
|
30
|
+
.node-risk { fill: rgba(29,78,216,.08); stroke: #1d4ed8; stroke-width: 1.2; }
|
|
31
|
+
.edge { fill: none; stroke: #4c5665; stroke-width: 1.2; marker-end: url(#arrow); }
|
|
32
|
+
.edge-plain { fill: none; stroke: #4c5665; stroke-width: 1.2; }
|
|
33
|
+
.edge-async { fill: none; stroke: #4c5665; stroke-width: 1.2; stroke-dasharray: 5 4; marker-end: url(#arrow); }
|
|
34
|
+
.boundary { fill: none; stroke: #1d4ed8; stroke-width: 1.2; stroke-dasharray: 4 4; }
|
|
35
|
+
.boundary-label { fill: #1d4ed8; font: 500 8px 'Geist Mono', monospace; letter-spacing: .12em; }
|
|
36
|
+
.edge-label { fill: #4c5665; font: 500 8px 'Geist Mono', monospace; text-anchor: middle; letter-spacing: .08em; }
|
|
37
|
+
.legend-text { fill: #4c5665; font: 400 9px 'Geist', sans-serif; }
|
|
38
|
+
</style>
|
|
39
|
+
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
40
|
+
<polygon points="0 0,8 3,0 6" fill="#4c5665"/>
|
|
41
|
+
</marker>
|
|
42
|
+
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
43
|
+
<polygon points="0 0,8 3,0 6" fill="#1d4ed8"/>
|
|
44
|
+
</marker>
|
|
45
|
+
<marker id="arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
46
|
+
<polygon points="0 0,8 3,0 6" fill="#1d4ed8"/>
|
|
47
|
+
</marker>
|
|
48
|
+
</defs>
|
|
49
|
+
|
|
50
|
+
<rect width="1280" height="720" fill="#f0eee9"/>
|
|
51
|
+
|
|
52
|
+
<!-- Swimlanes -->
|
|
53
|
+
<rect x="40" y="40" width="160" height="580" fill="#e6e4de"/>
|
|
54
|
+
<line x1="40" y1="40" x2="1240" y2="40" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
55
|
+
<line x1="40" y1="112" x2="1240" y2="112" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
56
|
+
<line x1="40" y1="212" x2="1240" y2="212" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
57
|
+
<line x1="40" y1="412" x2="1240" y2="412" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
58
|
+
<line x1="40" y1="488" x2="1240" y2="488" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
59
|
+
<line x1="40" y1="620" x2="1240" y2="620" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
60
|
+
<line x1="200" y1="40" x2="200" y2="620" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
61
|
+
<text class="lane-label" x="56" y="80">TRUSTED USER</text>
|
|
62
|
+
<text class="lane-label" x="56" y="164">GIT</text>
|
|
63
|
+
<text class="lane-label" x="56" y="268">SHARED HOOK</text>
|
|
64
|
+
<text class="lane-label" x="56" y="440">DETACHED INSTALLER</text>
|
|
65
|
+
<text class="lane-label" x="56" y="556">STATUS CONSUMERS</text>
|
|
66
|
+
|
|
67
|
+
<!-- Accent 1: the asynchronous handoff -->
|
|
68
|
+
<line class="boundary" x1="888" y1="112" x2="888" y2="488"/>
|
|
69
|
+
<rect x="896" y="124" width="160" height="52" fill="#f0eee9"/>
|
|
70
|
+
<text class="boundary-label" x="900" y="140">ASYNC BOUNDARY</text>
|
|
71
|
+
<text class="boundary-label" x="900" y="156">WORKTREE RETURNS</text>
|
|
72
|
+
<text class="boundary-label" x="900" y="172">INSTALLS CONTINUE</text>
|
|
73
|
+
|
|
74
|
+
<!-- Connectors -->
|
|
75
|
+
<path class="edge" d="M368 76 H376 Q384 76 384 84 V156 Q384 164 392 164"/>
|
|
76
|
+
<path class="edge" d="M472 192 V200 Q472 208 464 208 H424 Q416 208 416 216"/>
|
|
77
|
+
<path class="edge" d="M416 344 V360"/>
|
|
78
|
+
<path class="edge" d="M568 280 H640"/>
|
|
79
|
+
<path class="edge" d="M752 320 V360"/>
|
|
80
|
+
<path class="edge-plain" d="M864 280 H884"/>
|
|
81
|
+
<path class="edge-async" d="M892 272 H976 Q984 272 984 280 V420"/>
|
|
82
|
+
<path class="edge-async" d="M892 288 Q900 288 900 296 V488 Q900 496 908 496 H972 Q980 496 980 504 V516"/>
|
|
83
|
+
<path class="edge" d="M1056 452 H1080"/>
|
|
84
|
+
<path class="edge" d="M1156 484 V500"/>
|
|
85
|
+
<path class="edge" d="M1104 484 V488 Q1104 496 1096 496 H1088 Q1072 496 1072 512 V584 Q1072 592 1080 592"/>
|
|
86
|
+
|
|
87
|
+
<!-- Connector labels -->
|
|
88
|
+
<rect x="424" y="344" width="48" height="12" fill="#f0eee9"/>
|
|
89
|
+
<text class="edge-label" x="448" y="352">INVALID</text>
|
|
90
|
+
<rect x="580" y="260" width="48" height="12" fill="#f0eee9"/>
|
|
91
|
+
<text class="edge-label" x="604" y="268">VALID</text>
|
|
92
|
+
<rect x="760" y="332" width="24" height="12" fill="#f0eee9"/>
|
|
93
|
+
<text class="edge-label" x="772" y="340">NO</text>
|
|
94
|
+
<rect x="864" y="288" width="24" height="12" fill="#f0eee9"/>
|
|
95
|
+
<text class="edge-label" x="876" y="296">YES</text>
|
|
96
|
+
|
|
97
|
+
<!-- Process nodes -->
|
|
98
|
+
<rect class="node-input" x="224" y="52" width="144" height="48" rx="6"/>
|
|
99
|
+
<text class="node-name" x="296" y="76">Enable /deps</text>
|
|
100
|
+
<text class="node-sub" x="296" y="92">TRUSTED REPOSITORY</text>
|
|
101
|
+
|
|
102
|
+
<rect class="node" x="392" y="136" width="160" height="56" rx="6"/>
|
|
103
|
+
<text class="node-name" x="472" y="160">Create worktree</text>
|
|
104
|
+
<text class="node-sub" x="472" y="180">CALL SHARED POST-CHECKOUT</text>
|
|
105
|
+
|
|
106
|
+
<polygon class="node" points="416,216 568,280 416,344 264,280"/>
|
|
107
|
+
<text class="node-name" x="416" y="264">Synchronous validation</text>
|
|
108
|
+
<text class="node-sub" x="416" y="280">ROOT LOCKFILES + packageManager DECLARATIONS</text>
|
|
109
|
+
<text class="node-sub" x="416" y="296">CONFLICTING LOCKFILES · MISMATCHES</text>
|
|
110
|
+
<text class="node-sub" x="416" y="308">UNSUPPORTED DECLARATIONS?</text>
|
|
111
|
+
|
|
112
|
+
<rect class="node-failure" x="328" y="360" width="176" height="44" rx="6"/>
|
|
113
|
+
<text class="node-name" x="416" y="380">Fail fast</text>
|
|
114
|
+
<text class="node-sub" x="416" y="396">WORKTREE COMMAND</text>
|
|
115
|
+
|
|
116
|
+
<polygon class="node" points="752,240 864,280 752,320 640,280"/>
|
|
117
|
+
<text class="node-name" x="752" y="284">Any install needed?</text>
|
|
118
|
+
|
|
119
|
+
<rect class="node" x="672" y="360" width="160" height="44" rx="6"/>
|
|
120
|
+
<text class="node-name" x="752" y="380">Stop</text>
|
|
121
|
+
<text class="node-sub" x="752" y="396">CLEAR STALE STATUS</text>
|
|
122
|
+
|
|
123
|
+
<rect x="884" y="268" width="8" height="24" rx="4" fill="#4c5665"/>
|
|
124
|
+
|
|
125
|
+
<rect class="node-async" x="912" y="420" width="144" height="64" rx="6"/>
|
|
126
|
+
<text class="node-name" x="984" y="444">Run selected installs</text>
|
|
127
|
+
<text class="node-sub" x="984" y="464">NODE AND/OR UV · LOCKED</text>
|
|
128
|
+
<text class="node-sub" x="984" y="476">DETACHED</text>
|
|
129
|
+
|
|
130
|
+
<rect class="node-state" x="1080" y="420" width="152" height="64" rx="6"/>
|
|
131
|
+
<text class="node-name" x="1156" y="444">Update status + log</text>
|
|
132
|
+
<text class="node-sub" x="1156" y="464">status.json</text>
|
|
133
|
+
<text class="node-sub" x="1156" y="476">install.log</text>
|
|
134
|
+
|
|
135
|
+
<!-- Accent 2: readiness risk -->
|
|
136
|
+
<rect class="node-risk" x="896" y="516" width="168" height="76" rx="6"/>
|
|
137
|
+
<text class="node-name" x="980" y="540">Worktree may be used</text>
|
|
138
|
+
<text class="node-name" x="980" y="556">before installs finish</text>
|
|
139
|
+
<text class="node-sub" x="980" y="576">READINESS NOT GUARANTEED</text>
|
|
140
|
+
|
|
141
|
+
<rect class="node" x="1080" y="500" width="152" height="48" rx="6"/>
|
|
142
|
+
<text class="node-name" x="1156" y="524">Pi reports status</text>
|
|
143
|
+
<text class="node-sub" x="1156" y="540">THEN REMOVES IT</text>
|
|
144
|
+
|
|
145
|
+
<rect class="node" x="1080" y="568" width="152" height="48" rx="6"/>
|
|
146
|
+
<text class="node-name" x="1156" y="592">Other consumers</text>
|
|
147
|
+
<text class="node-sub" x="1156" y="608">MUST READ IT THEMSELVES</text>
|
|
148
|
+
|
|
149
|
+
<!-- Legend -->
|
|
150
|
+
<line x1="40" y1="636" x2="1240" y2="636" stroke="rgba(16,24,40,.141)" stroke-width="0.8"/>
|
|
151
|
+
<text class="lane-label" x="40" y="664">LEGEND</text>
|
|
152
|
+
<rect class="node" x="124" y="652" width="24" height="16" rx="4"/>
|
|
153
|
+
<text class="legend-text" x="160" y="664">Step</text>
|
|
154
|
+
<polygon class="node" points="272,660 284,652 296,660 284,668"/>
|
|
155
|
+
<text class="legend-text" x="308" y="664">Decision</text>
|
|
156
|
+
<line x1="420" y1="660" x2="456" y2="660" stroke="#4c5665" stroke-width="1.2" stroke-dasharray="5 4" marker-end="url(#arrow)"/>
|
|
157
|
+
<text class="legend-text" x="468" y="664">Detached / async</text>
|
|
158
|
+
<rect class="node-state" x="612" y="652" width="24" height="16" rx="4"/>
|
|
159
|
+
<text class="legend-text" x="648" y="664">State files</text>
|
|
160
|
+
<rect class="node-failure" x="748" y="652" width="24" height="16" rx="4"/>
|
|
161
|
+
<text class="legend-text" x="784" y="664">Synchronous failure</text>
|
|
162
|
+
</svg>
|
|
163
|
+
</figure>
|
|
164
|
+
</body>
|
|
165
|
+
</html>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg width="1280" height="720" viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dependency-preparation-flow-title dependency-preparation-flow-desc" id="dependency-preparation-flow-root">
|
|
3
|
+
<title id="dependency-preparation-flow-title">Dependency preparation flow</title>
|
|
4
|
+
<desc id="dependency-preparation-flow-desc">Swimlane diagram showing that the shared hook synchronously validates root lockfiles plus packageManager declarations. Conflicting lockfiles, mismatches, and unsupported declarations fail the worktree command. Valid metadata may start selected detached installs and status reporting; if no install is needed, the hook clears stale status and stops, but consumers may use the worktree before dependencies are ready.</desc>
|
|
5
|
+
<defs>
|
|
6
|
+
<style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&family=Noto+Serif:ital@0;1&family=Noto+Sans+KR:wght@400;500;600&family=Noto+Serif+KR:wght@400&family=Noto+Sans+TC:wght@400;500;600&family=Noto+Serif+TC:wght@400&display=swap');
|
|
7
|
+
#dependency-preparation-flow-root figure { margin: 0; width: min(100%, 1280px); }
|
|
8
|
+
#dependency-preparation-flow-root .lane-label { fill: #4c5665; font: 500 8px 'Geist Mono', monospace; letter-spacing: .16em; }
|
|
9
|
+
#dependency-preparation-flow-root .node-name { fill: #101828; font: 600 12px 'Geist', sans-serif; text-anchor: middle; }
|
|
10
|
+
#dependency-preparation-flow-root .node-sub { fill: #4c5665; font: 400 9px 'Geist Mono', monospace; text-anchor: middle; }
|
|
11
|
+
#dependency-preparation-flow-root .node { fill: #fff; stroke: #101828; stroke-width: 1; }
|
|
12
|
+
#dependency-preparation-flow-root .node-input { fill: rgba(76,86,101,.10); stroke: #6a7282; stroke-width: 1; }
|
|
13
|
+
#dependency-preparation-flow-root .node-async { fill: rgba(16,24,40,.02); stroke: rgba(16,24,40,.40); stroke-width: 1; stroke-dasharray: 4 3; }
|
|
14
|
+
#dependency-preparation-flow-root .node-state { fill: rgba(16,24,40,.05); stroke: #4c5665; stroke-width: 1; }
|
|
15
|
+
#dependency-preparation-flow-root .node-failure { fill: rgba(16,24,40,.02); stroke: #6a7282; stroke-width: 1; stroke-dasharray: 4 4; }
|
|
16
|
+
#dependency-preparation-flow-root .node-risk { fill: rgba(29,78,216,.08); stroke: #1d4ed8; stroke-width: 1.2; }
|
|
17
|
+
#dependency-preparation-flow-root .edge { fill: none; stroke: #4c5665; stroke-width: 1.2; marker-end: url(#dependency-preparation-flow-arrow); }
|
|
18
|
+
#dependency-preparation-flow-root .edge-plain { fill: none; stroke: #4c5665; stroke-width: 1.2; }
|
|
19
|
+
#dependency-preparation-flow-root .edge-async { fill: none; stroke: #4c5665; stroke-width: 1.2; stroke-dasharray: 5 4; marker-end: url(#dependency-preparation-flow-arrow); }
|
|
20
|
+
#dependency-preparation-flow-root .boundary { fill: none; stroke: #1d4ed8; stroke-width: 1.2; stroke-dasharray: 4 4; }
|
|
21
|
+
#dependency-preparation-flow-root .boundary-label { fill: #1d4ed8; font: 500 8px 'Geist Mono', monospace; letter-spacing: .12em; }
|
|
22
|
+
#dependency-preparation-flow-root .edge-label { fill: #4c5665; font: 500 8px 'Geist Mono', monospace; text-anchor: middle; letter-spacing: .08em; }
|
|
23
|
+
#dependency-preparation-flow-root .legend-text { fill: #4c5665; font: 400 9px 'Geist', sans-serif; }</style>
|
|
24
|
+
<style>
|
|
25
|
+
.lane-label { fill: #4c5665; font: 500 8px 'Geist Mono', monospace; letter-spacing: .16em; }
|
|
26
|
+
.node-name { fill: #101828; font: 600 12px 'Geist', sans-serif; text-anchor: middle; }
|
|
27
|
+
.node-sub { fill: #4c5665; font: 400 9px 'Geist Mono', monospace; text-anchor: middle; }
|
|
28
|
+
.node { fill: #fff; stroke: #101828; stroke-width: 1; }
|
|
29
|
+
.node-input { fill: rgba(76,86,101,.10); stroke: #6a7282; stroke-width: 1; }
|
|
30
|
+
.node-async { fill: rgba(16,24,40,.02); stroke: rgba(16,24,40,.40); stroke-width: 1; stroke-dasharray: 4 3; }
|
|
31
|
+
.node-state { fill: rgba(16,24,40,.05); stroke: #4c5665; stroke-width: 1; }
|
|
32
|
+
.node-failure { fill: rgba(16,24,40,.02); stroke: #6a7282; stroke-width: 1; stroke-dasharray: 4 4; }
|
|
33
|
+
.node-risk { fill: rgba(29,78,216,.08); stroke: #1d4ed8; stroke-width: 1.2; }
|
|
34
|
+
.edge { fill: none; stroke: #4c5665; stroke-width: 1.2; marker-end: url(#dependency-preparation-flow-arrow); }
|
|
35
|
+
.edge-plain { fill: none; stroke: #4c5665; stroke-width: 1.2; }
|
|
36
|
+
.edge-async { fill: none; stroke: #4c5665; stroke-width: 1.2; stroke-dasharray: 5 4; marker-end: url(#dependency-preparation-flow-arrow); }
|
|
37
|
+
.boundary { fill: none; stroke: #1d4ed8; stroke-width: 1.2; stroke-dasharray: 4 4; }
|
|
38
|
+
.boundary-label { fill: #1d4ed8; font: 500 8px 'Geist Mono', monospace; letter-spacing: .12em; }
|
|
39
|
+
.edge-label { fill: #4c5665; font: 500 8px 'Geist Mono', monospace; text-anchor: middle; letter-spacing: .08em; }
|
|
40
|
+
.legend-text { fill: #4c5665; font: 400 9px 'Geist', sans-serif; }
|
|
41
|
+
</style>
|
|
42
|
+
<marker id="dependency-preparation-flow-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
43
|
+
<polygon points="0 0,8 3,0 6" fill="#4c5665"/>
|
|
44
|
+
</marker>
|
|
45
|
+
<marker id="dependency-preparation-flow-arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
46
|
+
<polygon points="0 0,8 3,0 6" fill="#1d4ed8"/>
|
|
47
|
+
</marker>
|
|
48
|
+
<marker id="dependency-preparation-flow-arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
49
|
+
<polygon points="0 0,8 3,0 6" fill="#1d4ed8"/>
|
|
50
|
+
</marker>
|
|
51
|
+
</defs>
|
|
52
|
+
|
|
53
|
+
<rect width="1280" height="720" fill="#f0eee9"/>
|
|
54
|
+
|
|
55
|
+
<!-- Swimlanes -->
|
|
56
|
+
<rect x="40" y="40" width="160" height="580" fill="#e6e4de"/>
|
|
57
|
+
<line x1="40" y1="40" x2="1240" y2="40" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
58
|
+
<line x1="40" y1="112" x2="1240" y2="112" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
59
|
+
<line x1="40" y1="212" x2="1240" y2="212" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
60
|
+
<line x1="40" y1="412" x2="1240" y2="412" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
61
|
+
<line x1="40" y1="488" x2="1240" y2="488" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
62
|
+
<line x1="40" y1="620" x2="1240" y2="620" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
63
|
+
<line x1="200" y1="40" x2="200" y2="620" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
64
|
+
<text class="lane-label" x="56" y="80">TRUSTED USER</text>
|
|
65
|
+
<text class="lane-label" x="56" y="164">GIT</text>
|
|
66
|
+
<text class="lane-label" x="56" y="268">SHARED HOOK</text>
|
|
67
|
+
<text class="lane-label" x="56" y="440">DETACHED INSTALLER</text>
|
|
68
|
+
<text class="lane-label" x="56" y="556">STATUS CONSUMERS</text>
|
|
69
|
+
|
|
70
|
+
<!-- Accent 1: the asynchronous handoff -->
|
|
71
|
+
<line class="boundary" x1="888" y1="112" x2="888" y2="488"/>
|
|
72
|
+
<rect x="896" y="124" width="160" height="52" fill="#f0eee9"/>
|
|
73
|
+
<text class="boundary-label" x="900" y="140">ASYNC BOUNDARY</text>
|
|
74
|
+
<text class="boundary-label" x="900" y="156">WORKTREE RETURNS</text>
|
|
75
|
+
<text class="boundary-label" x="900" y="172">INSTALLS CONTINUE</text>
|
|
76
|
+
|
|
77
|
+
<!-- Connectors -->
|
|
78
|
+
<path class="edge" d="M368 76 H376 Q384 76 384 84 V156 Q384 164 392 164"/>
|
|
79
|
+
<path class="edge" d="M472 192 V200 Q472 208 464 208 H424 Q416 208 416 216"/>
|
|
80
|
+
<path class="edge" d="M416 344 V360"/>
|
|
81
|
+
<path class="edge" d="M568 280 H640"/>
|
|
82
|
+
<path class="edge" d="M752 320 V360"/>
|
|
83
|
+
<path class="edge-plain" d="M864 280 H884"/>
|
|
84
|
+
<path class="edge-async" d="M892 272 H976 Q984 272 984 280 V420"/>
|
|
85
|
+
<path class="edge-async" d="M892 288 Q900 288 900 296 V488 Q900 496 908 496 H972 Q980 496 980 504 V516"/>
|
|
86
|
+
<path class="edge" d="M1056 452 H1080"/>
|
|
87
|
+
<path class="edge" d="M1156 484 V500"/>
|
|
88
|
+
<path class="edge" d="M1104 484 V488 Q1104 496 1096 496 H1088 Q1072 496 1072 512 V584 Q1072 592 1080 592"/>
|
|
89
|
+
|
|
90
|
+
<!-- Connector labels -->
|
|
91
|
+
<rect x="424" y="344" width="48" height="12" fill="#f0eee9"/>
|
|
92
|
+
<text class="edge-label" x="448" y="352">INVALID</text>
|
|
93
|
+
<rect x="580" y="260" width="48" height="12" fill="#f0eee9"/>
|
|
94
|
+
<text class="edge-label" x="604" y="268">VALID</text>
|
|
95
|
+
<rect x="760" y="332" width="24" height="12" fill="#f0eee9"/>
|
|
96
|
+
<text class="edge-label" x="772" y="340">NO</text>
|
|
97
|
+
<rect x="864" y="288" width="24" height="12" fill="#f0eee9"/>
|
|
98
|
+
<text class="edge-label" x="876" y="296">YES</text>
|
|
99
|
+
|
|
100
|
+
<!-- Process nodes -->
|
|
101
|
+
<rect class="node-input" x="224" y="52" width="144" height="48" rx="6"/>
|
|
102
|
+
<text class="node-name" x="296" y="76">Enable /deps</text>
|
|
103
|
+
<text class="node-sub" x="296" y="92">TRUSTED REPOSITORY</text>
|
|
104
|
+
|
|
105
|
+
<rect class="node" x="392" y="136" width="160" height="56" rx="6"/>
|
|
106
|
+
<text class="node-name" x="472" y="160">Create worktree</text>
|
|
107
|
+
<text class="node-sub" x="472" y="180">CALL SHARED POST-CHECKOUT</text>
|
|
108
|
+
|
|
109
|
+
<polygon class="node" points="416,216 568,280 416,344 264,280"/>
|
|
110
|
+
<text class="node-name" x="416" y="264">Synchronous validation</text>
|
|
111
|
+
<text class="node-sub" x="416" y="280">ROOT LOCKFILES + packageManager DECLARATIONS</text>
|
|
112
|
+
<text class="node-sub" x="416" y="296">CONFLICTING LOCKFILES · MISMATCHES</text>
|
|
113
|
+
<text class="node-sub" x="416" y="308">UNSUPPORTED DECLARATIONS?</text>
|
|
114
|
+
|
|
115
|
+
<rect class="node-failure" x="328" y="360" width="176" height="44" rx="6"/>
|
|
116
|
+
<text class="node-name" x="416" y="380">Fail fast</text>
|
|
117
|
+
<text class="node-sub" x="416" y="396">WORKTREE COMMAND</text>
|
|
118
|
+
|
|
119
|
+
<polygon class="node" points="752,240 864,280 752,320 640,280"/>
|
|
120
|
+
<text class="node-name" x="752" y="284">Any install needed?</text>
|
|
121
|
+
|
|
122
|
+
<rect class="node" x="672" y="360" width="160" height="44" rx="6"/>
|
|
123
|
+
<text class="node-name" x="752" y="380">Stop</text>
|
|
124
|
+
<text class="node-sub" x="752" y="396">CLEAR STALE STATUS</text>
|
|
125
|
+
|
|
126
|
+
<rect x="884" y="268" width="8" height="24" rx="4" fill="#4c5665"/>
|
|
127
|
+
|
|
128
|
+
<rect class="node-async" x="912" y="420" width="144" height="64" rx="6"/>
|
|
129
|
+
<text class="node-name" x="984" y="444">Run selected installs</text>
|
|
130
|
+
<text class="node-sub" x="984" y="464">NODE AND/OR UV · LOCKED</text>
|
|
131
|
+
<text class="node-sub" x="984" y="476">DETACHED</text>
|
|
132
|
+
|
|
133
|
+
<rect class="node-state" x="1080" y="420" width="152" height="64" rx="6"/>
|
|
134
|
+
<text class="node-name" x="1156" y="444">Update status + log</text>
|
|
135
|
+
<text class="node-sub" x="1156" y="464">status.json</text>
|
|
136
|
+
<text class="node-sub" x="1156" y="476">install.log</text>
|
|
137
|
+
|
|
138
|
+
<!-- Accent 2: readiness risk -->
|
|
139
|
+
<rect class="node-risk" x="896" y="516" width="168" height="76" rx="6"/>
|
|
140
|
+
<text class="node-name" x="980" y="540">Worktree may be used</text>
|
|
141
|
+
<text class="node-name" x="980" y="556">before installs finish</text>
|
|
142
|
+
<text class="node-sub" x="980" y="576">READINESS NOT GUARANTEED</text>
|
|
143
|
+
|
|
144
|
+
<rect class="node" x="1080" y="500" width="152" height="48" rx="6"/>
|
|
145
|
+
<text class="node-name" x="1156" y="524">Pi reports status</text>
|
|
146
|
+
<text class="node-sub" x="1156" y="540">THEN REMOVES IT</text>
|
|
147
|
+
|
|
148
|
+
<rect class="node" x="1080" y="568" width="152" height="48" rx="6"/>
|
|
149
|
+
<text class="node-name" x="1156" y="592">Other consumers</text>
|
|
150
|
+
<text class="node-sub" x="1156" y="608">MUST READ IT THEMSELVES</text>
|
|
151
|
+
|
|
152
|
+
<!-- Legend -->
|
|
153
|
+
<line x1="40" y1="636" x2="1240" y2="636" stroke="#101828" stroke-width="0.8" stroke-opacity=".141"/>
|
|
154
|
+
<text class="lane-label" x="40" y="664">LEGEND</text>
|
|
155
|
+
<rect class="node" x="124" y="652" width="24" height="16" rx="4"/>
|
|
156
|
+
<text class="legend-text" x="160" y="664">Step</text>
|
|
157
|
+
<polygon class="node" points="272,660 284,652 296,660 284,668"/>
|
|
158
|
+
<text class="legend-text" x="308" y="664">Decision</text>
|
|
159
|
+
<line x1="420" y1="660" x2="456" y2="660" stroke="#4c5665" stroke-width="1.2" stroke-dasharray="5 4" marker-end="url(#dependency-preparation-flow-arrow)"/>
|
|
160
|
+
<text class="legend-text" x="468" y="664">Detached / async</text>
|
|
161
|
+
<rect class="node-state" x="612" y="652" width="24" height="16" rx="4"/>
|
|
162
|
+
<text class="legend-text" x="648" y="664">State files</text>
|
|
163
|
+
<rect class="node-failure" x="748" y="652" width="24" height="16" rx="4"/>
|
|
164
|
+
<text class="legend-text" x="784" y="664">Synchronous failure</text>
|
|
165
|
+
</svg>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { link, mkdir, readFile, rename, rm, writeFile, chmod } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
const hookSourcePath = fileURLToPath(new URL("../hooks/post-checkout.mjs", import.meta.url));
|
|
9
|
+
const managedHookMarker = "pi-deps-managed-hook";
|
|
10
|
+
|
|
11
|
+
function errorMessage(error: unknown): string {
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function toggleDependencyHook(
|
|
16
|
+
commonGitDir: string,
|
|
17
|
+
moveAside = rename, // test seam for races between the marker check and the move
|
|
18
|
+
): Promise<{ enabled: boolean; path: string }> {
|
|
19
|
+
const source = await readFile(hookSourcePath, "utf8");
|
|
20
|
+
const path = join(commonGitDir, "hooks", "post-checkout");
|
|
21
|
+
let existing: string | undefined;
|
|
22
|
+
try {
|
|
23
|
+
existing = await readFile(path, "utf8");
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
26
|
+
}
|
|
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 moveAside(path, staging);
|
|
35
|
+
let managed = false;
|
|
36
|
+
try {
|
|
37
|
+
managed = (await readFile(staging, "utf8")).includes(managedHookMarker);
|
|
38
|
+
} finally {
|
|
39
|
+
if (!managed) {
|
|
40
|
+
// Restore unverified content without clobbering a hook created meanwhile.
|
|
41
|
+
try {
|
|
42
|
+
await link(staging, path);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`Cannot restore Git hook ${path}; its content remains at ${staging}`, { cause: error });
|
|
45
|
+
}
|
|
46
|
+
await rm(staging);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!managed) throw new Error(`Refusing to modify unmanaged Git hook: ${path}`);
|
|
50
|
+
await rm(staging);
|
|
51
|
+
return { enabled: false, path };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await mkdir(dirname(path), { recursive: true });
|
|
55
|
+
const temporary = `${path}.pi-deps-${process.pid}-${randomUUID()}`;
|
|
56
|
+
try {
|
|
57
|
+
await writeFile(temporary, source, { encoding: "utf8", flag: "wx" });
|
|
58
|
+
await chmod(temporary, 0o755);
|
|
59
|
+
await link(temporary, path);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
62
|
+
throw new Error(`Refusing to modify Git hook created concurrently: ${path}`, { cause: error });
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
} finally {
|
|
66
|
+
await rm(temporary, { force: true });
|
|
67
|
+
}
|
|
68
|
+
return { enabled: true, path };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const statusKey = "pi-deps";
|
|
72
|
+
const pollMs = 500;
|
|
73
|
+
const waitTimeoutMs = 10 * 60_000;
|
|
74
|
+
const spinnerIntervalMs = 100;
|
|
75
|
+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
76
|
+
|
|
77
|
+
function formatElapsed(startedAt: number): string {
|
|
78
|
+
const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1_000));
|
|
79
|
+
const minutes = Math.floor(seconds / 60);
|
|
80
|
+
return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface InstallStatus {
|
|
84
|
+
state?: string;
|
|
85
|
+
message?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Watches the status file written by the background installer spawned by the post-checkout hook.
|
|
89
|
+
// First consumer wins: the status file is removed once reported.
|
|
90
|
+
async function watchDependencyInstallation(
|
|
91
|
+
pi: ExtensionAPI,
|
|
92
|
+
ctx: ExtensionContext,
|
|
93
|
+
signal: AbortSignal,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (ctx.mode !== "tui") return;
|
|
96
|
+
const git = await pi.exec("git", ["rev-parse", "--path-format=absolute", "--git-dir"], { cwd: ctx.cwd });
|
|
97
|
+
if (git.code !== 0 || git.killed) return;
|
|
98
|
+
const stateDir = join(git.stdout.trim(), "pi-deps");
|
|
99
|
+
const statusPath = join(stateDir, "status.json");
|
|
100
|
+
const readStatus = async (): Promise<InstallStatus | undefined> => {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(await readFile(statusPath, "utf8")) as InstallStatus;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
105
|
+
return { state: "running" }; // partial write from installer or test; retry next poll
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
let status = await readStatus();
|
|
110
|
+
if (!status) return;
|
|
111
|
+
signal.throwIfAborted();
|
|
112
|
+
const startedAt = Date.now();
|
|
113
|
+
const update = () => {
|
|
114
|
+
const frame = spinnerFrames[Math.floor(Date.now() / spinnerIntervalMs) % spinnerFrames.length]!;
|
|
115
|
+
ctx.ui.setStatus(statusKey, ctx.ui.theme.fg("dim", `${frame} deps · installing ${formatElapsed(startedAt)}`));
|
|
116
|
+
};
|
|
117
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
118
|
+
try {
|
|
119
|
+
if (status.state === "running") {
|
|
120
|
+
update();
|
|
121
|
+
timer = setInterval(update, spinnerIntervalMs);
|
|
122
|
+
timer.unref();
|
|
123
|
+
}
|
|
124
|
+
const deadline = startedAt + waitTimeoutMs;
|
|
125
|
+
while (status.state === "running" && Date.now() < deadline) {
|
|
126
|
+
await delay(pollMs, undefined, { signal });
|
|
127
|
+
status = await readStatus();
|
|
128
|
+
if (!status) return;
|
|
129
|
+
}
|
|
130
|
+
signal.throwIfAborted();
|
|
131
|
+
await rm(statusPath, { force: true }); // consume so later sessions do not replay a stale outcome
|
|
132
|
+
if (status.state === "error" || status.state === "running") {
|
|
133
|
+
ctx.ui.setWidget(statusKey, [
|
|
134
|
+
status.state === "error"
|
|
135
|
+
? `pi-deps: install failed: ${status.message ?? "unknown error"}`
|
|
136
|
+
: "pi-deps: stopped waiting for dependency installation after ten minutes; check the log.",
|
|
137
|
+
`pi-deps: log: ${join(stateDir, "install.log")}`,
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
if (timer) clearInterval(timer);
|
|
142
|
+
ctx.ui.setStatus(statusKey, undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default function depsExtension(pi: ExtensionAPI): void {
|
|
147
|
+
let watcher: AbortController | undefined;
|
|
148
|
+
pi.on("session_shutdown", () => watcher?.abort());
|
|
149
|
+
pi.on("session_start", (_event, ctx) => {
|
|
150
|
+
watcher?.abort();
|
|
151
|
+
const controller = watcher = new AbortController();
|
|
152
|
+
void watchDependencyInstallation(pi, ctx, controller.signal).catch((error) => {
|
|
153
|
+
if (!controller.signal.aborted) ctx.ui.notify(`pi-deps: ${errorMessage(error)}`, "error");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
pi.registerCommand("deps", {
|
|
157
|
+
description: "Toggle dependency preparation for future Git worktrees",
|
|
158
|
+
handler: async (args, ctx) => {
|
|
159
|
+
if (args.trim()) throw new Error("Usage: /deps");
|
|
160
|
+
const result = await pi.exec("git", ["rev-parse", "--git-common-dir", "--git-path", "hooks"], { cwd: ctx.cwd });
|
|
161
|
+
if (result.code !== 0 || result.killed) {
|
|
162
|
+
throw new Error(`Cannot locate shared Git directory: ${result.stderr.trim() || `exit code ${result.code}`}`);
|
|
163
|
+
}
|
|
164
|
+
const [commonGitDir, hooksDir] = result.stdout.trim().split(/\r?\n/);
|
|
165
|
+
|
|
166
|
+
// core.hooksPath replaces <commonGitDir>/hooks; refuse instead of writing where Git ignores or shares the hook.
|
|
167
|
+
const defaultHooksDir = resolve(ctx.cwd, commonGitDir, "hooks");
|
|
168
|
+
if (resolve(ctx.cwd, hooksDir) !== defaultHooksDir) {
|
|
169
|
+
throw new Error(`Refusing non-default hooks directory (core.hooksPath?): ${hooksDir}; expected ${defaultHooksDir}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const toggled = await toggleDependencyHook(resolve(ctx.cwd, commonGitDir));
|
|
174
|
+
ctx.ui.notify(
|
|
175
|
+
`Dependency preparation ${toggled.enabled ? "enabled" : "disabled"}: ${toggled.path}`,
|
|
176
|
+
"info",
|
|
177
|
+
);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new Error(`Cannot toggle dependency preparation: ${errorMessage(error)}`, { cause: error });
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pi-deps-managed-hook
|
|
3
|
+
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const installMode = "--pi-deps-install";
|
|
10
|
+
const nodeInstallCommands = {
|
|
11
|
+
npm: ["npm", ["ci"]],
|
|
12
|
+
pnpm: ["pnpm", ["install", "--frozen-lockfile"]],
|
|
13
|
+
bun: ["bun", ["install", "--frozen-lockfile"]],
|
|
14
|
+
yarn: ["yarn", ["install", "--immutable"]],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function errorMessage(error) {
|
|
18
|
+
return error instanceof Error ? error.message : String(error);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeStatus(stateDir, status) {
|
|
22
|
+
writeFileSync(join(stateDir, "status.json"), `${JSON.stringify(status)}\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Installer child: runs the frozen installs detached and records the outcome for watchers.
|
|
26
|
+
if (process.argv[2] === installMode) {
|
|
27
|
+
const [, , , stateDir, commandsJson] = process.argv;
|
|
28
|
+
try {
|
|
29
|
+
for (const [command, args] of JSON.parse(commandsJson)) {
|
|
30
|
+
console.error(`pi-deps: ${command} ${args.join(" ")}`);
|
|
31
|
+
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
32
|
+
if (result.error) throw new Error(`failed to start ${command}: ${result.error.message}`);
|
|
33
|
+
if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? "signal"}`);
|
|
34
|
+
}
|
|
35
|
+
writeStatus(stateDir, { state: "ok" });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(`pi-deps: ${errorMessage(error)}`);
|
|
38
|
+
writeStatus(stateDir, { state: "error", message: errorMessage(error) });
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [, , oldHead, , checkoutKind] = process.argv;
|
|
45
|
+
if (!/^0+$/.test(oldHead ?? "") || checkoutKind !== "1") process.exit(0);
|
|
46
|
+
|
|
47
|
+
const isFile = (name) => {
|
|
48
|
+
try { return statSync(name).isFile(); } catch { return false; }
|
|
49
|
+
};
|
|
50
|
+
const isDirectory = (name) => {
|
|
51
|
+
try { return statSync(name).isDirectory(); } catch { return false; }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Validation throws synchronously so configuration mistakes still fail worktree creation fast.
|
|
55
|
+
function nodeCommands() {
|
|
56
|
+
const locks = [];
|
|
57
|
+
if (isFile("package-lock.json") || isFile("npm-shrinkwrap.json")) locks.push("npm");
|
|
58
|
+
if (isFile("pnpm-lock.yaml")) locks.push("pnpm");
|
|
59
|
+
if (isFile("yarn.lock")) locks.push("yarn");
|
|
60
|
+
if (isFile("bun.lock") || isFile("bun.lockb")) locks.push("bun");
|
|
61
|
+
if (isFile("bun.lock") && isFile("bun.lockb")) throw new Error("Conflicting Bun lockfiles: bun.lock and bun.lockb");
|
|
62
|
+
if (locks.length === 0) return [];
|
|
63
|
+
if (locks.length > 1) throw new Error(`Conflicting Node lockfiles: ${locks.join(", ")}`);
|
|
64
|
+
if (!isFile("package.json")) throw new Error("Node lockfile found without package.json");
|
|
65
|
+
|
|
66
|
+
let packageJson;
|
|
67
|
+
try {
|
|
68
|
+
packageJson = JSON.parse(readFileSync("package.json", "utf8"));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new Error(`Cannot read package.json: ${errorMessage(error)}`);
|
|
71
|
+
}
|
|
72
|
+
const declared = packageJson?.packageManager;
|
|
73
|
+
const manager = locks[0];
|
|
74
|
+
if (declared !== undefined) {
|
|
75
|
+
if (typeof declared !== "string" || !/^(npm|pnpm|yarn|bun)@.+$/.test(declared)) {
|
|
76
|
+
throw new Error(`Unsupported packageManager: ${JSON.stringify(declared)}`);
|
|
77
|
+
}
|
|
78
|
+
const declaredManager = declared.slice(0, declared.indexOf("@"));
|
|
79
|
+
if (/^yarn@1(?:\D|$)/.test(declared)) throw new Error("Yarn Classic 1.x is not supported");
|
|
80
|
+
if (declaredManager !== manager) {
|
|
81
|
+
throw new Error(`packageManager ${declaredManager} does not match ${manager} lockfile`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isDirectory("node_modules") || (manager === "yarn" && isFile(".pnp.cjs"))) return [];
|
|
86
|
+
return [nodeInstallCommands[manager]];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function installCommands() {
|
|
90
|
+
const commands = nodeCommands();
|
|
91
|
+
if (isFile("uv.lock") && !isDirectory(".venv")) commands.push(["uv", ["sync", "--locked"]]);
|
|
92
|
+
return commands;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const commands = installCommands();
|
|
97
|
+
const git = spawnSync("git", ["rev-parse", "--path-format=absolute", "--git-dir"], { encoding: "utf8" });
|
|
98
|
+
if (git.error) throw new Error(`Cannot locate Git directory: ${git.error.message}`);
|
|
99
|
+
if (git.status !== 0) {
|
|
100
|
+
throw new Error(`Cannot locate Git directory: ${(git.stderr || "").trim() || `exit code ${git.status}`}`);
|
|
101
|
+
}
|
|
102
|
+
const stateDir = join(git.stdout.trim(), "pi-deps");
|
|
103
|
+
const statusPath = join(stateDir, "status.json");
|
|
104
|
+
if (commands.length === 0) {
|
|
105
|
+
rmSync(statusPath, { force: true }); // stale outcome from an earlier run
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
mkdirSync(stateDir, { recursive: true });
|
|
109
|
+
writeStatus(stateDir, { state: "running" });
|
|
110
|
+
const log = openSync(join(stateDir, "install.log"), "a");
|
|
111
|
+
spawn(process.execPath, [fileURLToPath(import.meta.url), installMode, stateDir, JSON.stringify(commands)], {
|
|
112
|
+
detached: true,
|
|
113
|
+
stdio: ["ignore", log, log],
|
|
114
|
+
}).unref();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`pi-deps: ${errorMessage(error)}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hcflabs/pi-deps",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.2.6",
|
|
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
|
+
"LICENSE",
|
|
19
|
+
"docs",
|
|
20
|
+
"extensions",
|
|
21
|
+
"hooks",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/*.test.ts",
|
|
26
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/deps.ts",
|
|
27
|
+
"pack:check": "npm pack --dry-run"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": "^1.3.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/hcflabs/pi-harness.git",
|
|
35
|
+
"directory": "extensions/pi-deps"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/hcflabs/pi-harness/issues"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/deps.ts"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@earendil-works/pi-coding-agent": "1.3.0"
|
|
50
|
+
}
|
|
51
|
+
}
|