@junghanacs/entwurf 0.12.5 → 0.12.7

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.
Files changed (93) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +44 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +33 -38
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -6
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +974 -248
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +47 -9
  79. package/scripts/meta-bridge-install.sh +20 -17
  80. package/scripts/meta-bridge-state.py +40 -2
  81. package/scripts/meta-bridge-uninstall.sh +23 -12
  82. package/scripts/register-pi-package.py +183 -0
  83. package/scripts/register-pi-provider.py +287 -0
  84. package/scripts/smoke-agy-hooks-state.sh +172 -0
  85. package/scripts/smoke-agy-install-state.sh +660 -0
  86. package/scripts/smoke-agy-native-push-live.ts +243 -0
  87. package/scripts/smoke-agy-statusline-state.sh +300 -0
  88. package/scripts/smoke-meta-async-drift.sh +9 -2
  89. package/scripts/smoke-meta-install-state.sh +178 -8
  90. package/scripts/smoke-pi-provider-state.sh +182 -0
  91. package/scripts/smoke-user-scope-citizen.sh +191 -0
  92. package/scripts/with-dist-lock.sh +81 -0
  93. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-user-scope-citizen — deterministic gate for pi packages[] citizen
3
+ # registration/removal (register-pi-package.py). Offline/hermetic: a fake
4
+ # settings file + a fake REPO_DIR under $TMP, no pi, no network, no ~/.pi touched.
5
+ #
6
+ # Guards the wiring that dropped when `pi install` was removed from setup
7
+ # (2026-07-03: `--entwurf-control` / `--emacs-agent-socket` unknown in a foreign
8
+ # cwd because entwurf was absent from ~/.pi/agent/settings.json packages[]).
9
+ # statusline `?` had a tripwire; THIS omission had none and hid until GLG hit it
10
+ # in another repo. This gate is that missing tripwire: if the registration ever
11
+ # regresses, `pnpm check` goes red.
12
+ set -euo pipefail
13
+
14
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ REPO="$(cd "$HERE/.." && pwd)"
16
+ REG="$REPO/scripts/register-pi-package.py"
17
+
18
+ fail=0
19
+ ok() { echo " ok $*"; }
20
+ bad() { echo " FAIL $*"; fail=1; }
21
+
22
+ command -v python3 >/dev/null || { echo "FAIL: python3 not on PATH"; exit 1; }
23
+
24
+ TMP="$(mktemp -d -t psa-user-citizen.XXXXXX)"
25
+ trap 'rm -rf "$TMP"' EXIT
26
+
27
+ # Isolation is a property of every root run.sh writes through, not of the one this gate happens
28
+ # to override. `remove-user-scope` today reads PI_CODING_AGENT_DIR (settings) and XDG_DATA_HOME
29
+ # (ownership state) — passing the operator's real XDG_DATA_HOME is what let this gate delete the
30
+ # live MCP key during `pnpm check` (2026-07-14). It does not touch HOME *today*; sandbox HOME and
31
+ # the whole XDG trio anyway, so the next root run.sh reaches for is already fenced (AGENTS rule 11).
32
+ export HOME="$TMP/home"
33
+ export XDG_DATA_HOME="$TMP/xdg"
34
+ export XDG_STATE_HOME="$TMP/state"
35
+ export XDG_CACHE_HOME="$TMP/cache"
36
+ mkdir -p "$HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME"
37
+
38
+ S="$TMP/settings.json"
39
+ FAKE_REPO="$TMP/fake/entwurf"; mkdir -p "$FAKE_REPO"
40
+ RESOLVED="$(cd "$FAKE_REPO" && pwd)"
41
+
42
+ has_pkg() { python3 -c "import json,sys;p=json.load(open('$S'))['packages'];sys.exit(0 if '$1' in p else 1)"; }
43
+ count_entwurf() { python3 -c "import json;p=json.load(open('$S'))['packages'];print(sum(1 for x in p if isinstance((x if isinstance(x,str) else x.get('source')),str) and (x if isinstance(x,str) else x.get('source')).rstrip('/').split('/')[-1]=='entwurf' or (x if isinstance(x,str) else x.get('source'))=='$RESOLVED'))"; }
44
+
45
+ # 1. absent settings file → created with entwurf registered
46
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
47
+ if [ -f "$S" ] && has_pkg "$RESOLVED"; then ok "absent settings → entwurf registered (file created)"; else bad "entwurf not registered into a fresh settings file"; fi
48
+
49
+ # 2. idempotent: second run is a no-op AND does not even rewrite the file (mtime stable)
50
+ MT1="$(stat -c %Y "$S")"
51
+ sleep 1
52
+ OUT2="$(python3 "$REG" "$S" "$FAKE_REPO")"
53
+ MT2="$(stat -c %Y "$S")"
54
+ if printf '%s' "$OUT2" | grep -q 'no-op'; then ok "second run is a no-op (idempotent)"; else bad "second run was not a no-op: $OUT2"; fi
55
+ if [ "$MT1" = "$MT2" ]; then ok "no-op does NOT rewrite the file (mtime stable — strongest idempotence)"; else bad "no-op rewrote the file (mtime changed $MT1 -> $MT2)"; fi
56
+ if [ "$(count_entwurf)" = "1" ]; then ok "no duplicate entwurf entry after idempotent re-run"; else bad "duplicate entwurf entries: $(count_entwurf)"; fi
57
+
58
+ # 3. preserves unrelated packages AND unrelated keys — including look-alike repos
59
+ # whose path merely CONTAINS 'entwurf' and remote repos whose last segment is
60
+ # 'entwurf' but are not THIS package.
61
+ cat > "$S" <<'JSON'
62
+ {
63
+ "defaultProvider": "openai-codex",
64
+ "theme": "glg-dark",
65
+ "packages": ["../../repos/gh/andenken", "git:github.com/badlogic/pi-telegram",
66
+ "/home/me/entwurf-notes", "/x/openclaw-entwurf",
67
+ "git:github.com/someone/entwurf", "https://github.com/someone/entwurf"]
68
+ }
69
+ JSON
70
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
71
+ if python3 -c "
72
+ import json
73
+ d=json.load(open('$S')); p=d['packages']
74
+ assert '../../repos/gh/andenken' in p, 'andenken dropped'
75
+ assert 'git:github.com/badlogic/pi-telegram' in p, 'pi-telegram dropped'
76
+ assert '/home/me/entwurf-notes' in p, 'entwurf-notes wrongly filtered (substring false-positive)'
77
+ assert '/x/openclaw-entwurf' in p, 'openclaw-entwurf wrongly filtered (substring false-positive)'
78
+ assert 'git:github.com/someone/entwurf' in p, 'foreign git repo named entwurf wrongly filtered'
79
+ assert 'https://github.com/someone/entwurf' in p, 'foreign URL repo named entwurf wrongly filtered'
80
+ assert '$RESOLVED' in p, 'entwurf missing'
81
+ assert d['defaultProvider']=='openai-codex' and d['theme']=='glg-dark', 'unrelated key dropped'
82
+ " 2>/dev/null; then ok "preserves unrelated packages incl. look-alikes/foreign entwurf repos and keys"; else bad "clobbered unrelated packages/keys or a look-alike repo"; fi
83
+
84
+ # 4. stale entwurf paths (bare local 'entwurf' dir, npm install path, explicit npm
85
+ # package source) normalize to one canonical REPO_DIR.
86
+ cat > "$S" <<'JSON'
87
+ {"packages": ["/old/moved/entwurf", "/some/root/node_modules/@junghanacs/entwurf",
88
+ "npm:@junghanacs/entwurf@0.12.4", "../../repos/gh/andenken"]}
89
+ JSON
90
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
91
+ if python3 -c "
92
+ import json
93
+ p=json.load(open('$S'))['packages']
94
+ assert '/old/moved/entwurf' not in p, 'stale bare-entwurf path not dropped'
95
+ assert '/some/root/node_modules/@junghanacs/entwurf' not in p, 'stale npm path not dropped'
96
+ assert 'npm:@junghanacs/entwurf@0.12.4' not in p, 'stale explicit npm source not dropped'
97
+ assert '$RESOLVED' in p, 'entwurf not normalized to REPO_DIR'
98
+ assert '../../repos/gh/andenken' in p, 'andenken dropped'
99
+ ent=[x for x in p if isinstance((x if isinstance(x,str) else x.get('source')),str) and ((x if isinstance(x,str) else x.get('source')).rstrip('/').split('/')[-1]=='entwurf' or (x if isinstance(x,str) else x.get('source')).endswith('/node_modules/@junghanacs/entwurf') or (x if isinstance(x,str) else x.get('source'))=='$RESOLVED')]
100
+ assert len(ent)==1, f'expected exactly 1 entwurf entry, got {ent}'
101
+ " 2>/dev/null; then ok "stale entwurf paths/sources (bare + npm) normalized to a single REPO_DIR"; else bad "stale entwurf normalization failed"; fi
102
+
103
+ # 5. object-form entwurf entries collapse to one canonical string (no duplicate)
104
+ cat > "$S" <<JSON
105
+ {"packages": [{"source": "$RESOLVED"}, "../../repos/gh/andenken"]}
106
+ JSON
107
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null
108
+ python3 "$REG" "$S" "$FAKE_REPO" >/dev/null # twice → still one
109
+ if python3 -c "
110
+ import json
111
+ p=json.load(open('$S'))['packages']
112
+ srcs=[(x if isinstance(x,str) else x.get('source')) for x in p]
113
+ assert srcs.count('$RESOLVED')==1, f'object-form produced duplicate/none: {p}'
114
+ assert all(not isinstance(x,dict) or x.get('source')!='$RESOLVED' for x in p), 'object-form entwurf survived (should collapse to string)'
115
+ assert '../../repos/gh/andenken' in p, 'andenken dropped'
116
+ " 2>/dev/null; then ok "object-form entwurf collapses to a single canonical string (no dup across re-runs)"; else bad "object-form entwurf caused a duplicate or survived as object"; fi
117
+
118
+ # 6. a non-object settings file fails loud (never silently wipes operator config)
119
+ echo '[]' > "$S"
120
+ if python3 "$REG" "$S" "$FAKE_REPO" >/dev/null 2>&1; then bad "non-object settings should fail, not overwrite"; else ok "non-object settings fails loud (no silent clobber)"; fi
121
+
122
+ # 7. a non-array packages value fails loud (never silently coerced to [])
123
+ echo '{"packages": {"broken": true}}' > "$S"
124
+ if python3 "$REG" "$S" "$FAKE_REPO" >/dev/null 2>&1; then bad "non-array packages should fail, not coerce to []"; else ok "non-array packages fails loud (no silent drop of operator packages)"; fi
125
+
126
+ # 8. --remove drops every entwurf entry but preserves unrelated + look-alikes
127
+ # (symmetric with install: same is_entwurf_source predicate, no over-delete).
128
+ cat > "$S" <<JSON
129
+ {"packages": ["$RESOLVED", "../../repos/gh/andenken", "/home/me/entwurf-notes"]}
130
+ JSON
131
+ python3 "$REG" "$S" "$FAKE_REPO" --remove >/dev/null
132
+ if python3 -c "
133
+ import json
134
+ p=json.load(open('$S'))['packages']
135
+ assert '$RESOLVED' not in p, 'entwurf not removed'
136
+ assert '../../repos/gh/andenken' in p, 'andenken over-removed'
137
+ assert '/home/me/entwurf-notes' in p, 'entwurf-notes over-removed (substring over-delete)'
138
+ " 2>/dev/null; then ok "--remove drops entwurf, preserves unrelated + look-alikes (symmetric with install)"; else bad "--remove over-deleted a look-alike or missed entwurf"; fi
139
+
140
+ # 9. --dry-run remove REPORTS the count and writes NOTHING (backs run.sh's project
141
+ # `remove` pointer note that suggests the global inverse only when relevant).
142
+ cat > "$S" <<JSON
143
+ {"packages": ["$RESOLVED", "../../repos/gh/andenken"]}
144
+ JSON
145
+ MT_DR="$(stat -c %Y "$S")"; sleep 1
146
+ OUT_DR="$(python3 "$REG" "$S" "$FAKE_REPO" --remove --dry-run)"
147
+ MT_DR2="$(stat -c %Y "$S")"
148
+ if printf '%s' "$OUT_DR" | grep -q 'would remove 1'; then ok "--dry-run reports 'would remove' count"; else bad "--dry-run did not report the would-remove count: $OUT_DR"; fi
149
+ if [ "$MT_DR" = "$MT_DR2" ] && has_pkg "$RESOLVED"; then ok "--dry-run writes nothing (entry intact, mtime stable)"; else bad "--dry-run mutated settings (mtime $MT_DR -> $MT_DR2)"; fi
150
+ OUT_DR_NONE="$(python3 "$REG" "$TMP/absent.json" "$FAKE_REPO" --remove --dry-run)"
151
+ if printf '%s' "$OUT_DR_NONE" | grep -q 'no entwurf'; then ok "--dry-run on an entwurf-free file reports nothing to remove"; else bad "--dry-run false-positive on absent entry: $OUT_DR_NONE"; fi
152
+
153
+ # 9b. --dry-run WITHOUT --remove must FAIL LOUD and write nothing. A flag named
154
+ # "dry-run" that falls through to the register write path and mutates settings
155
+ # is an install-hygiene footgun (GPT blocker, 2026-07-03).
156
+ cat > "$S" <<'JSON'
157
+ {"packages": ["../../repos/gh/andenken"]}
158
+ JSON
159
+ MT_DRR="$(stat -c %Y "$S")"; sleep 1
160
+ if python3 "$REG" "$S" "$FAKE_REPO" --dry-run >/dev/null 2>&1; then bad "--dry-run without --remove should fail loud, not register"; else ok "--dry-run without --remove fails loud (never a silent write)"; fi
161
+ MT_DRR2="$(stat -c %Y "$S")"
162
+ if [ "$MT_DRR" = "$MT_DRR2" ] && ! has_pkg "$RESOLVED"; then ok "--dry-run without --remove wrote nothing (mtime stable, no entwurf entry added)"; else bad "--dry-run without --remove mutated settings (mtime $MT_DRR -> $MT_DRR2)"; fi
163
+ if python3 "$REG" "$S" "$FAKE_REPO" --bogus >/dev/null 2>&1; then bad "unknown flag should be rejected"; else ok "unknown flag rejected (fail-loud parser)"; fi
164
+
165
+ # 10. the run.sh SHELL path `remove-user-scope` reaches the SAME SSOT against a
166
+ # PI_CODING_AGENT_DIR-overridden ~/.pi/agent — proves install's user-scope
167
+ # inverse is REACHABLE from run.sh, not only from the python SSOT (the gap GPT
168
+ # flagged: remove symmetry existed at the SSOT but no run.sh path exercised it).
169
+ # REPO_DIR (run.sh's own dir) is the real checkout, so seed THAT as the entry.
170
+ RUN="$REPO/run.sh"
171
+ AGENT_DIR="$TMP/agent"; mkdir -p "$AGENT_DIR"
172
+ US="$AGENT_DIR/settings.json"
173
+ cat > "$US" <<JSON
174
+ {"defaultProvider": "openai-codex", "packages": ["$REPO", "../../repos/gh/andenken"]}
175
+ JSON
176
+ # remove-user-scope uses BOTH PI_CODING_AGENT_DIR (settings target) and XDG_DATA_HOME
177
+ # (ownership-state authority). Isolating only the former lets the fake inverse consume the
178
+ # operator's real state, follow its recorded managedSettingsPath, and remove the live MCP key.
179
+ XDG_DATA_HOME="$TMP/xdg" PI_CODING_AGENT_DIR="$AGENT_DIR" bash "$RUN" remove-user-scope >/dev/null 2>&1 || bad "run.sh remove-user-scope exited non-zero"
180
+ if python3 -c "
181
+ import json
182
+ d=json.load(open('$US')); p=d['packages']
183
+ assert '$REPO' not in p, 'run.sh remove-user-scope did not drop the global entwurf entry'
184
+ assert '../../repos/gh/andenken' in p, 'run.sh remove-user-scope over-removed an unrelated package'
185
+ assert d['defaultProvider']=='openai-codex', 'run.sh remove-user-scope dropped an unrelated key'
186
+ " 2>/dev/null; then ok "run.sh remove-user-scope drops the global citizen, preserves unrelated (SSOT reached via shell)"; else bad "run.sh remove-user-scope path failed"; fi
187
+ # idempotent: a second remove-user-scope is a clean no-op (no crash on absent entry)
188
+ if XDG_DATA_HOME="$TMP/xdg" PI_CODING_AGENT_DIR="$AGENT_DIR" bash "$RUN" remove-user-scope >/dev/null 2>&1; then ok "run.sh remove-user-scope is idempotent (no-op second run)"; else bad "run.sh remove-user-scope second run crashed"; fi
189
+
190
+ echo
191
+ if [ "$fail" -eq 0 ]; then echo "smoke-user-scope-citizen: PASS"; else echo "smoke-user-scope-citizen: FAIL (see above)"; exit 1; fi
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Serialize a command that rebuilds OR reads mcp/entwurf-bridge/dist against every
4
+ # other such command: `with-dist-lock.sh <cmd...>` runs <cmd> holding an atomic
5
+ # lock over the shared dist dir.
6
+ #
7
+ # Why the lock has to wrap the WHOLE command, not just the emit:
8
+ # build-bridge does `rm -rf dist` then a tsc emit; `npm pack` runs build-bridge as
9
+ # its prepack hook and then, AFTER prepack returns, walks the tree to read dist into
10
+ # the tarball. So the window where dist must stay intact is [prepack build ‥ npm's
11
+ # post-build read] — the entire pack, not the emit alone. A lock scoped to just the
12
+ # emit (released when build-bridge exits) is not merely insufficient, it BACKFIRES:
13
+ # it staggers concurrent packs so each run's lock-free read lands squarely inside
14
+ # the next run's `rm -rf dist`, turning an occasional race into a near-deterministic
15
+ # phantom "dist missing" pack failure (measured: 7/8 packs MISSING). Wrapping the
16
+ # whole pack fixes it — the read is now inside the lock (2026-07-03).
17
+ #
18
+ # Reentrancy: the wrapped `npm pack` re-invokes build-bridge as its prepack, which
19
+ # is itself wrapped by this script (package.json). ENTWURF_BUILD_LOCK_HELD, exported
20
+ # once the outer wrapper acquires, tells the nested invocation to run through
21
+ # without re-acquiring, so the prepack never self-deadlocks against its own gate.
22
+ #
23
+ # mkdir is the atomic primitive (POSIX, no `flock` dependency — portable across the
24
+ # npm/pnpm lifecycle on the Linux publish host). Mirrors the entwurf-v2 dispatch
25
+ # lock discipline: atomic acquire, holder pid recorded for human cleanup, same-host
26
+ # stale reclaim (a SIGKILL'd holder never wedges every future build), and a
27
+ # bounded wait that fails loud rather than hanging or building into a half-wiped dir.
28
+ set -euo pipefail
29
+
30
+ if [ "$#" -eq 0 ]; then
31
+ echo "usage: with-dist-lock.sh <command> [args...]" >&2
32
+ exit 2
33
+ fi
34
+
35
+ # An ancestor already holds the dist lock (e.g. this is `npm pack`'s prepack
36
+ # build-bridge under a gate that wraps the whole pack) — run reentrantly.
37
+ if [ -n "${ENTWURF_BUILD_LOCK_HELD:-}" ]; then
38
+ exec "$@"
39
+ fi
40
+
41
+ REPO_DIR=$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
42
+ LOCK_DIR="$REPO_DIR/.tmp-verify/dist.lock"
43
+ mkdir -p "$REPO_DIR/.tmp-verify"
44
+
45
+ try_acquire() {
46
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
47
+ return 0
48
+ fi
49
+ # Same-host stale reclaim: a holder killed with SIGKILL leaves the dir behind.
50
+ # If its recorded pid is gone, the lock is dead — reclaim it so a crashed build
51
+ # never wedges every future one. (Builds/packs always run on the local host, so
52
+ # a same-host pid probe is authoritative.)
53
+ local holder
54
+ holder=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo "")
55
+ if [ -n "$holder" ] && ! kill -0 "$holder" 2>/dev/null; then
56
+ rm -rf "$LOCK_DIR"
57
+ mkdir "$LOCK_DIR" 2>/dev/null && return 0
58
+ fi
59
+ return 1
60
+ }
61
+
62
+ # Bounded wait (~120s): a full pack (prepack tsc emit + npm tree walk) is a few
63
+ # seconds, so this clears several serialized contenders with headroom, then fails
64
+ # loud rather than hanging forever.
65
+ acquired=0
66
+ for _ in $(seq 1 1200); do
67
+ if try_acquire; then acquired=1; break; fi
68
+ sleep 0.1
69
+ done
70
+ if [ "$acquired" != 1 ]; then
71
+ echo "[with-dist-lock] could not acquire dist lock after ~120s: $LOCK_DIR" >&2
72
+ echo "[with-dist-lock] if no other build/pack is running, remove it: rm -rf \"$LOCK_DIR\"" >&2
73
+ exit 1
74
+ fi
75
+ echo "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
76
+ # Release on ANY exit (incl. the wrapped command failing) so a red run never wedges
77
+ # the next. The nested prepack build-bridge sees this and skips re-acquiring.
78
+ export ENTWURF_BUILD_LOCK_HELD=$$
79
+ trap 'rm -rf "$LOCK_DIR"' EXIT
80
+
81
+ "$@"