@indigoai-us/hq-cloud 6.14.50 → 6.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +64 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/reindex-knowledge.d.ts +7 -0
  34. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  35. package/dist/cli/reindex-knowledge.js +389 -0
  36. package/dist/cli/reindex-knowledge.js.map +1 -0
  37. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  38. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  39. package/dist/cli/reindex-knowledge.test.js +260 -0
  40. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  41. package/dist/cli/reindex.d.ts.map +1 -1
  42. package/dist/cli/reindex.js +8 -0
  43. package/dist/cli/reindex.js.map +1 -1
  44. package/dist/cli/rescue-settings-reconcile.test.js +47 -0
  45. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -1
  46. package/dist/cli/sync.d.ts +27 -0
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +52 -0
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +31 -1
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/skill-telemetry.d.ts +6 -0
  57. package/dist/skill-telemetry.d.ts.map +1 -1
  58. package/dist/skill-telemetry.js +14 -2
  59. package/dist/skill-telemetry.js.map +1 -1
  60. package/dist/skill-telemetry.test.js +79 -0
  61. package/dist/skill-telemetry.test.js.map +1 -1
  62. package/dist/sync/candidate-uploader.d.ts +88 -0
  63. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  64. package/dist/sync/candidate-uploader.js +212 -0
  65. package/dist/sync/candidate-uploader.js.map +1 -0
  66. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  67. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  68. package/dist/sync/candidate-uploader.test.js +132 -0
  69. package/dist/sync/candidate-uploader.test.js.map +1 -0
  70. package/dist/sync/delta-client.d.ts +73 -0
  71. package/dist/sync/delta-client.d.ts.map +1 -0
  72. package/dist/sync/delta-client.js +201 -0
  73. package/dist/sync/delta-client.js.map +1 -0
  74. package/dist/sync/delta-client.test.d.ts +2 -0
  75. package/dist/sync/delta-client.test.d.ts.map +1 -0
  76. package/dist/sync/delta-client.test.js +97 -0
  77. package/dist/sync/delta-client.test.js.map +1 -0
  78. package/dist/sync/durable-apply.d.ts +76 -0
  79. package/dist/sync/durable-apply.d.ts.map +1 -0
  80. package/dist/sync/durable-apply.js +530 -0
  81. package/dist/sync/durable-apply.js.map +1 -0
  82. package/dist/sync/durable-apply.test.d.ts +2 -0
  83. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  84. package/dist/sync/durable-apply.test.js +180 -0
  85. package/dist/sync/durable-apply.test.js.map +1 -0
  86. package/dist/sync/event-sync.d.ts +33 -1
  87. package/dist/sync/event-sync.d.ts.map +1 -1
  88. package/dist/sync/event-sync.js +149 -1
  89. package/dist/sync/event-sync.js.map +1 -1
  90. package/dist/sync/event-sync.test.js +142 -1
  91. package/dist/sync/event-sync.test.js.map +1 -1
  92. package/dist/sync/index.d.ts +2 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/multipart-uploader.d.ts +99 -0
  97. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  98. package/dist/sync/multipart-uploader.js +447 -0
  99. package/dist/sync/multipart-uploader.js.map +1 -0
  100. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  101. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  102. package/dist/sync/multipart-uploader.test.js +119 -0
  103. package/dist/sync/multipart-uploader.test.js.map +1 -0
  104. package/dist/sync/mutation-client.d.ts +82 -0
  105. package/dist/sync/mutation-client.d.ts.map +1 -0
  106. package/dist/sync/mutation-client.js +221 -0
  107. package/dist/sync/mutation-client.js.map +1 -0
  108. package/dist/sync/mutation-client.test.d.ts +2 -0
  109. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  110. package/dist/sync/mutation-client.test.js +51 -0
  111. package/dist/sync/mutation-client.test.js.map +1 -0
  112. package/dist/sync/push-receiver.d.ts +45 -0
  113. package/dist/sync/push-receiver.d.ts.map +1 -1
  114. package/dist/sync/push-receiver.js +101 -0
  115. package/dist/sync/push-receiver.js.map +1 -1
  116. package/dist/sync/push-receiver.test.js +54 -2
  117. package/dist/sync/push-receiver.test.js.map +1 -1
  118. package/dist/sync/scope-inventory-client.d.ts +69 -0
  119. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  120. package/dist/sync/scope-inventory-client.js +210 -0
  121. package/dist/sync/scope-inventory-client.js.map +1 -0
  122. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  123. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  124. package/dist/sync/scope-inventory-client.test.js +94 -0
  125. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  126. package/dist/sync/snapshot-client.d.ts +98 -0
  127. package/dist/sync/snapshot-client.d.ts.map +1 -0
  128. package/dist/sync/snapshot-client.js +402 -0
  129. package/dist/sync/snapshot-client.js.map +1 -0
  130. package/dist/sync/snapshot-client.test.d.ts +2 -0
  131. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  132. package/dist/sync/snapshot-client.test.js +169 -0
  133. package/dist/sync/snapshot-client.test.js.map +1 -0
  134. package/dist/sync/uploader-finalization.d.ts +97 -0
  135. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  136. package/dist/sync/uploader-finalization.js +273 -0
  137. package/dist/sync/uploader-finalization.js.map +1 -0
  138. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  139. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  140. package/dist/sync/uploader-finalization.test.js +92 -0
  141. package/dist/sync/uploader-finalization.test.js.map +1 -0
  142. package/dist/telemetry.d.ts +11 -1
  143. package/dist/telemetry.d.ts.map +1 -1
  144. package/dist/telemetry.js +21 -2
  145. package/dist/telemetry.js.map +1 -1
  146. package/dist/telemetry.test.js +80 -0
  147. package/dist/telemetry.test.js.map +1 -1
  148. package/package.json +6 -1
  149. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  150. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  151. package/.github/workflows/ci.yml +0 -84
  152. package/.github/workflows/publish.yml +0 -56
  153. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  154. package/eslint.config.js +0 -67
  155. package/pnpm-workspace.yaml +0 -2
  156. package/scripts/presign-transport-e2e.mjs +0 -250
  157. package/scripts/vault-rebaseline.sh +0 -323
  158. package/scripts/vault-rescue.sh +0 -332
  159. package/src/active-company.test.ts +0 -188
  160. package/src/active-company.ts +0 -168
  161. package/src/agent-codex-instructions.test.ts +0 -332
  162. package/src/agent-codex-instructions.ts +0 -309
  163. package/src/auth.ts +0 -146
  164. package/src/backup-prune.test.ts +0 -98
  165. package/src/backup-prune.ts +0 -182
  166. package/src/bin/backup-prune-runner.ts +0 -33
  167. package/src/bin/rescue-runner.ts +0 -25
  168. package/src/bin/sync-runner-company.ts +0 -695
  169. package/src/bin/sync-runner-events.test.ts +0 -143
  170. package/src/bin/sync-runner-events.ts +0 -55
  171. package/src/bin/sync-runner-planning.test.ts +0 -311
  172. package/src/bin/sync-runner-planning.ts +0 -258
  173. package/src/bin/sync-runner-rollup.test.ts +0 -37
  174. package/src/bin/sync-runner-rollup.ts +0 -97
  175. package/src/bin/sync-runner-telemetry.ts +0 -15
  176. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  177. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  178. package/src/bin/sync-runner-watch-routes.ts +0 -184
  179. package/src/bin/sync-runner.test.ts +0 -8767
  180. package/src/bin/sync-runner.ts +0 -2190
  181. package/src/cli/accept.ts +0 -124
  182. package/src/cli/conflict.ts +0 -119
  183. package/src/cli/doctor.test.ts +0 -581
  184. package/src/cli/doctor.ts +0 -642
  185. package/src/cli/index.ts +0 -49
  186. package/src/cli/invite.test.ts +0 -250
  187. package/src/cli/invite.ts +0 -214
  188. package/src/cli/promote.ts +0 -157
  189. package/src/cli/reindex.test.ts +0 -957
  190. package/src/cli/reindex.ts +0 -970
  191. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  192. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  193. package/src/cli/rescue-core.ts +0 -3011
  194. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  195. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  196. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  197. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  198. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  199. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  200. package/src/cli/rescue-settings-reconcile.test.ts +0 -583
  201. package/src/cli/rescue-snapshot.test.ts +0 -57
  202. package/src/cli/rescue-snapshot.ts +0 -51
  203. package/src/cli/rescue.reindex.test.ts +0 -63
  204. package/src/cli/rescue.test.ts +0 -131
  205. package/src/cli/rescue.ts +0 -182
  206. package/src/cli/share.test.ts +0 -7843
  207. package/src/cli/share.ts +0 -3663
  208. package/src/cli/sync-scope.test.ts +0 -652
  209. package/src/cli/sync.test.ts +0 -5207
  210. package/src/cli/sync.ts +0 -3470
  211. package/src/cli/tombstones.ts +0 -106
  212. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  213. package/src/client-info.test.ts +0 -214
  214. package/src/client-info.ts +0 -121
  215. package/src/cognito-auth.test.ts +0 -712
  216. package/src/cognito-auth.ts +0 -1422
  217. package/src/company-resolver.test.ts +0 -618
  218. package/src/company-resolver.ts +0 -521
  219. package/src/context.test.ts +0 -583
  220. package/src/context.ts +0 -378
  221. package/src/daemon-worker.ts +0 -26
  222. package/src/daemon.ts +0 -99
  223. package/src/entity-resolver.test.ts +0 -315
  224. package/src/entity-resolver.ts +0 -180
  225. package/src/ignore.test.ts +0 -466
  226. package/src/ignore.ts +0 -469
  227. package/src/index.ts +0 -439
  228. package/src/journal.test.ts +0 -968
  229. package/src/journal.ts +0 -765
  230. package/src/lib/cloud-authoritative.test.ts +0 -45
  231. package/src/lib/cloud-authoritative.ts +0 -59
  232. package/src/lib/conflict-file.ts +0 -86
  233. package/src/lib/conflict-index.ts +0 -289
  234. package/src/lib/conflict.test.ts +0 -348
  235. package/src/lib/describe-error.test.ts +0 -100
  236. package/src/lib/describe-error.ts +0 -58
  237. package/src/lib/exit-codes.ts +0 -24
  238. package/src/lib/machine-id.test.ts +0 -231
  239. package/src/lib/machine-id.ts +0 -175
  240. package/src/lib/net-errors.test.ts +0 -65
  241. package/src/lib/net-errors.ts +0 -86
  242. package/src/lib/readlink-safe.test.ts +0 -43
  243. package/src/lib/readlink-safe.ts +0 -29
  244. package/src/local-path-codec.test.ts +0 -138
  245. package/src/local-path-codec.ts +0 -161
  246. package/src/machine-auth.test.ts +0 -1323
  247. package/src/manifest-reconcile.test.ts +0 -1123
  248. package/src/manifest-reconcile.ts +0 -518
  249. package/src/object-io.test.ts +0 -1221
  250. package/src/object-io.ts +0 -1306
  251. package/src/operation-lock.test.ts +0 -484
  252. package/src/operation-lock.ts +0 -680
  253. package/src/outcome-telemetry.test.ts +0 -498
  254. package/src/outcome-telemetry.ts +0 -639
  255. package/src/personal-vault-exclusions.test.ts +0 -308
  256. package/src/personal-vault-exclusions.ts +0 -354
  257. package/src/personal-vault.test.ts +0 -756
  258. package/src/personal-vault.ts +0 -496
  259. package/src/prefix-coalesce.test.ts +0 -240
  260. package/src/prefix-coalesce.ts +0 -273
  261. package/src/public-surface.test.ts +0 -117
  262. package/src/qmd-reindex.test.ts +0 -877
  263. package/src/qmd-reindex.ts +0 -842
  264. package/src/read-only-state-dir.test.ts +0 -188
  265. package/src/remote-pull.test.ts +0 -1130
  266. package/src/remote-pull.ts +0 -618
  267. package/src/s3.symlink-materialize.test.ts +0 -492
  268. package/src/s3.test.ts +0 -1789
  269. package/src/s3.ts +0 -1532
  270. package/src/schemas/signal-types.test.ts +0 -82
  271. package/src/schemas/signal-types.ts +0 -38
  272. package/src/schemas/source-channels.test.ts +0 -82
  273. package/src/schemas/source-channels.ts +0 -53
  274. package/src/scope-shrink.test.ts +0 -633
  275. package/src/scope-shrink.ts +0 -481
  276. package/src/signals/get.test.ts +0 -310
  277. package/src/signals/get.ts +0 -75
  278. package/src/signals/internals.ts +0 -195
  279. package/src/signals/list.test.ts +0 -420
  280. package/src/signals/list.ts +0 -79
  281. package/src/signals/parse.ts +0 -8
  282. package/src/signals/types.ts +0 -91
  283. package/src/skill-telemetry.test.ts +0 -1825
  284. package/src/skill-telemetry.ts +0 -1439
  285. package/src/sources/get.test.ts +0 -293
  286. package/src/sources/get.ts +0 -66
  287. package/src/sources/internals.ts +0 -198
  288. package/src/sources/list.test.ts +0 -402
  289. package/src/sources/list.ts +0 -84
  290. package/src/sources/parse.ts +0 -43
  291. package/src/sources/types.ts +0 -84
  292. package/src/sync/event-sync.test.ts +0 -594
  293. package/src/sync/event-sync.ts +0 -545
  294. package/src/sync/feature-flags.test.ts +0 -378
  295. package/src/sync/feature-flags.ts +0 -62
  296. package/src/sync/index.ts +0 -76
  297. package/src/sync/lease-client.test.ts +0 -128
  298. package/src/sync/lease-client.ts +0 -207
  299. package/src/sync/logger.test.ts +0 -242
  300. package/src/sync/logger.ts +0 -79
  301. package/src/sync/metrics.test.ts +0 -462
  302. package/src/sync/metrics.ts +0 -213
  303. package/src/sync/pull-scope.ts +0 -265
  304. package/src/sync/push-event.test.ts +0 -266
  305. package/src/sync/push-event.ts +0 -224
  306. package/src/sync/push-receiver.test.ts +0 -566
  307. package/src/sync/push-receiver.ts +0 -1048
  308. package/src/sync/push-transport.ts +0 -231
  309. package/src/sync/realtime-rollout.test.ts +0 -86
  310. package/src/sync/realtime-rollout.ts +0 -262
  311. package/src/sync/state-store.test.ts +0 -194
  312. package/src/sync/state-store.ts +0 -727
  313. package/src/sync-core.ts +0 -58
  314. package/src/sync-progress.test.ts +0 -94
  315. package/src/sync-progress.ts +0 -140
  316. package/src/telemetry-events.test.ts +0 -88
  317. package/src/telemetry-events.ts +0 -205
  318. package/src/telemetry.test.ts +0 -1280
  319. package/src/telemetry.ts +0 -1109
  320. package/src/types.ts +0 -314
  321. package/src/vault-client.test.ts +0 -1380
  322. package/src/vault-client.ts +0 -1694
  323. package/src/version.ts +0 -24
  324. package/src/watch-roots.test.ts +0 -278
  325. package/src/watch-roots.ts +0 -162
  326. package/src/watcher-event-gate.test.ts +0 -212
  327. package/src/watcher.test.ts +0 -1079
  328. package/src/watcher.ts +0 -1741
  329. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  330. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  331. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  332. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  333. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  334. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  335. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  336. package/test/invite-flow.integration.test.ts +0 -244
  337. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  338. package/test/share-sync.integration.test.ts +0 -213
  339. package/tsconfig.json +0 -19
  340. package/vitest.config.ts +0 -22
@@ -1,583 +0,0 @@
1
- /**
2
- * Regression tests for the `.claude/settings.json` reconcile in rescue-core.ts.
3
- *
4
- * `.claude/settings.json` is a machine-local, *generated* file: setup.sh step 3b
5
- * composes the machine's env.PATH into the release's PATH-less template. On an
6
- * existing install it therefore always differs from every release blob, so the
7
- * old rescue rescued it into personal/ and laid a PATH-less template on top —
8
- * dropping the machine PATH and (per drift/history state) failing to wire in a
9
- * newly-released hook registration even though the hook FILE synced in.
10
- *
11
- * The fix re-composes env.PATH onto the release baseline and relocates genuine
12
- * user drift (vs the last-sync floor) into settings.local.json. These tests
13
- * cover the pure drift/merge helpers and reproduce the real field case with a
14
- * real (non-dry-run) overlay against a local fixture "release" repo.
15
- */
16
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
17
- import { execFileSync } from "child_process";
18
- import * as fs from "fs";
19
- import * as os from "os";
20
- import * as path from "path";
21
- import {
22
- runRescue,
23
- settingsDrift,
24
- deepMergeSettings,
25
- applyArrayRemovals,
26
- jsonDeepEqual,
27
- } from "./rescue-core.js";
28
-
29
- // ── Unit: pure drift / merge helpers ────────────────────────────────────────
30
-
31
- describe("settingsDrift", () => {
32
- it("excludes env.PATH (machine-local) from drift", () => {
33
- const ours = { env: { PATH: "/machine/bin:/usr/bin", MAX: "1" } };
34
- const base = { env: { MAX: "1" } };
35
- // Only PATH differs, and PATH is excluded -> no drift.
36
- expect(settingsDrift(ours, base)).toBeUndefined();
37
- });
38
-
39
- it("captures an added scalar and a changed scalar", () => {
40
- const ours = { model: "opus", newKey: "x" };
41
- const base = { model: "sonnet" };
42
- expect(settingsDrift(ours, base)).toEqual({ model: "opus", newKey: "x" });
43
- });
44
-
45
- it("captures only the extra array elements (union-friendly)", () => {
46
- const ours = { permissions: { allow: ["Bash(git:*)", "Bash(mytool:*)"] } };
47
- const base = { permissions: { allow: ["Bash(git:*)"] } };
48
- expect(settingsDrift(ours, base)).toEqual({
49
- permissions: { allow: ["Bash(mytool:*)"] },
50
- });
51
- });
52
-
53
- it("returns undefined when ours equals base", () => {
54
- const v = { a: 1, b: [1, 2], c: { d: "e" } };
55
- expect(settingsDrift(v, structuredClone(v))).toBeUndefined();
56
- });
57
-
58
- it("treats a wholly-new subtree as drift but still honors env.PATH exclusion", () => {
59
- const ours = { env: { PATH: "/machine/bin" } };
60
- const base = {};
61
- // env is new, but its only member is the excluded PATH -> no drift.
62
- expect(settingsDrift(ours, base)).toBeUndefined();
63
-
64
- const ours2 = { env: { PATH: "/machine/bin", HQ_FOO: "1" } };
65
- expect(settingsDrift(ours2, {})).toEqual({ env: { HQ_FOO: "1" } });
66
- });
67
- });
68
-
69
- describe("deepMergeSettings", () => {
70
- it("unions arrays without duplicates and adds absent scalars", () => {
71
- const target = { permissions: { allow: ["Bash(git:*)"] } };
72
- const patch = { permissions: { allow: ["Bash(git:*)", "Bash(mytool:*)"] }, model: "opus" };
73
- deepMergeSettings(target, patch);
74
- expect(target.permissions.allow).toEqual(["Bash(git:*)", "Bash(mytool:*)"]);
75
- expect((target as { model?: string }).model).toBe("opus"); // absent -> added
76
- });
77
-
78
- it("does NOT overwrite a scalar already present in the target (Local wins)", () => {
79
- // Project drift `model: opus` must not clobber the higher-precedence Local
80
- // `model: sonnet` — the effective config must remain sonnet.
81
- const target = { model: "sonnet" };
82
- deepMergeSettings(target, { model: "opus" });
83
- expect(target.model).toBe("sonnet");
84
- });
85
-
86
- it("is idempotent on re-merge (no duplicate array entries)", () => {
87
- const target = { permissions: { allow: ["Bash(mytool:*)"] } };
88
- deepMergeSettings(target, { permissions: { allow: ["Bash(mytool:*)"] } });
89
- expect(target.permissions.allow).toEqual(["Bash(mytool:*)"]);
90
- });
91
- });
92
-
93
- describe("applyArrayRemovals", () => {
94
- it("removes an element the user deleted (present in base, absent in ours)", () => {
95
- const theirs = { permissions: { allow: ["Bash(git:*)", "Bash(rm:*)"] } };
96
- const ours = { permissions: { allow: ["Bash(git:*)"] } }; // user removed rm
97
- const base = { permissions: { allow: ["Bash(git:*)", "Bash(rm:*)"] } };
98
- applyArrayRemovals(theirs, ours, base);
99
- expect(theirs.permissions.allow).toEqual(["Bash(git:*)"]);
100
- });
101
-
102
- it("leaves release-only additions intact (base lacked them)", () => {
103
- const theirs = { permissions: { allow: ["Bash(git:*)", "Bash(new:*)"] } };
104
- const ours = { permissions: { allow: ["Bash(git:*)"] } };
105
- const base = { permissions: { allow: ["Bash(git:*)"] } }; // base never had new
106
- applyArrayRemovals(theirs, ours, base);
107
- // "Bash(new:*)" is a release addition, not a user removal — keep it.
108
- expect(theirs.permissions.allow).toEqual(["Bash(git:*)", "Bash(new:*)"]);
109
- });
110
- });
111
-
112
- describe("jsonDeepEqual", () => {
113
- it("compares nested structures", () => {
114
- expect(jsonDeepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true);
115
- expect(jsonDeepEqual({ a: [1] }, { a: [2] })).toBe(false);
116
- });
117
- });
118
-
119
- // ── Integration: real overlay reproduces the field case ─────────────────────
120
-
121
- function hasBin(bin: string, args: string[] = ["--version"]): boolean {
122
- try {
123
- execFileSync(bin, args, { stdio: "ignore" });
124
- return true;
125
- } catch {
126
- return false;
127
- }
128
- }
129
- const canRun = hasBin("git") && hasBin("rsync");
130
-
131
- function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
132
- let stdout = "";
133
- let stderr = "";
134
- const origOut = process.stdout.write.bind(process.stdout);
135
- const origErr = process.stderr.write.bind(process.stderr);
136
- process.stdout.write = ((chunk: unknown) => {
137
- stdout += String(chunk);
138
- return true;
139
- }) as typeof process.stdout.write;
140
- process.stderr.write = ((chunk: unknown) => {
141
- stderr += String(chunk);
142
- return true;
143
- }) as typeof process.stderr.write;
144
- let status: number;
145
- try {
146
- status = runRescue(argv, { env }).status;
147
- } finally {
148
- process.stdout.write = origOut;
149
- process.stderr.write = origErr;
150
- }
151
- return { status, stdout, stderr };
152
- }
153
-
154
- // A faithful copy of core/scripts/compose-settings-path.sh: prepend managed
155
- // toolchain bin dirs that exist on disk but are missing from the base PATH,
156
- // else pass the base through unchanged.
157
- const COMPOSER = `#!/usr/bin/env bash
158
- set -euo pipefail
159
- BASE="\${1:-$PATH}"
160
- TOOLCHAIN="\${HQ_TOOLCHAIN_DIR:-$HOME/Library/Application Support/Indigo HQ/toolchain}"
161
- path_contains() { case ":$1:" in *":$2:"*) return 0 ;; *) return 1 ;; esac; }
162
- COMPOSED="$BASE"
163
- for dir in "$TOOLCHAIN/git/bin" "$TOOLCHAIN/npm-global/bin" "$TOOLCHAIN/node/bin"; do
164
- if [[ -d "$dir" ]] && ! path_contains "$COMPOSED" "$dir"; then
165
- COMPOSED="$dir:$COMPOSED"
166
- fi
167
- done
168
- printf '%s\\n' "$COMPOSED"
169
- `;
170
-
171
- interface TestSettings {
172
- model?: string;
173
- permissions: { allow: string[]; deny: string[] };
174
- env: Record<string, string>;
175
- hooks: { PreToolUse: { hooks: { type?: string; command: string }[] }[] };
176
- }
177
-
178
- describe.skipIf(!canRun)("rescue .claude/settings.json reconcile (real overlay)", () => {
179
- let workDir: string;
180
- let upstream: string;
181
- let shimDir: string;
182
- let floorSha: string;
183
- let env: NodeJS.ProcessEnv;
184
-
185
- const MACHINE_PATH = "/opt/machine/bin:/usr/bin:/bin";
186
- const NEW_HOOK_CMD =
187
- 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/new-hook.sh"';
188
-
189
- const git = (cwd: string, ...args: string[]) =>
190
- execFileSync("git", args, {
191
- cwd,
192
- stdio: ["ignore", "pipe", "pipe"],
193
- env: {
194
- ...process.env,
195
- GIT_AUTHOR_NAME: "t",
196
- GIT_AUTHOR_EMAIL: "t@t",
197
- GIT_COMMITTER_NAME: "t",
198
- GIT_COMMITTER_EMAIL: "t@t",
199
- },
200
- })
201
- .toString()
202
- .trim();
203
-
204
- // Release template as of the floor: PATH-less, with a baseline permission and
205
- // a pre-existing hook, but WITHOUT the new-hook registration.
206
- const floorSettings: TestSettings = {
207
- model: "claude-opus-4-8",
208
- permissions: { allow: ["Bash(git:*)"], deny: [] as string[] },
209
- env: { MAX_THINKING_TOKENS: "31999" },
210
- hooks: {
211
- PreToolUse: [
212
- { hooks: [{ type: "command", command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/old-hook.sh"' }] },
213
- ],
214
- },
215
- };
216
-
217
- // New release (HEAD): adds the new-hook FILE + its REGISTRATION. Still no PATH.
218
- const headSettings: TestSettings = {
219
- model: "claude-opus-4-8",
220
- permissions: { allow: ["Bash(git:*)"], deny: [] as string[] },
221
- env: { MAX_THINKING_TOKENS: "31999" },
222
- hooks: {
223
- PreToolUse: [
224
- { hooks: [{ type: "command", command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/old-hook.sh"' }] },
225
- { hooks: [{ type: "command", command: NEW_HOOK_CMD }] },
226
- ],
227
- },
228
- };
229
-
230
- beforeAll(() => {
231
- workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-settings-"));
232
-
233
- // --- Fixture "release" repo: floor commit, then a HEAD commit that adds the
234
- // new hook file + registration. ---
235
- upstream = path.join(workDir, "upstream");
236
- fs.mkdirSync(path.join(upstream, ".claude", "hooks"), { recursive: true });
237
- fs.mkdirSync(path.join(upstream, "core", "scripts"), { recursive: true });
238
- git(workDir, "init", "-b", "main", "upstream");
239
-
240
- fs.writeFileSync(
241
- path.join(upstream, ".claude", "settings.json"),
242
- JSON.stringify(floorSettings, null, 2) + "\n",
243
- );
244
- fs.writeFileSync(path.join(upstream, ".claude", "hooks", "old-hook.sh"), "#!/usr/bin/env bash\n");
245
- fs.writeFileSync(path.join(upstream, "core", "scripts", "compose-settings-path.sh"), COMPOSER, { mode: 0o755 });
246
- fs.writeFileSync(path.join(upstream, "core", "keep.md"), "same\n");
247
- git(upstream, "add", "-A");
248
- git(upstream, "commit", "-m", "floor");
249
- floorSha = git(upstream, "rev-parse", "HEAD");
250
-
251
- // HEAD: new hook file + registration land in the release.
252
- fs.writeFileSync(
253
- path.join(upstream, ".claude", "settings.json"),
254
- JSON.stringify(headSettings, null, 2) + "\n",
255
- );
256
- fs.writeFileSync(path.join(upstream, ".claude", "hooks", "new-hook.sh"), "#!/usr/bin/env bash\n# new\n");
257
- git(upstream, "add", "-A");
258
- git(upstream, "commit", "-m", "add new-hook");
259
-
260
- // --- git shim: rewrite `git clone <github-url> dest` to the local fixture. ---
261
- const realGit =
262
- execFileSync("bash", ["-c", "command -v git"]).toString().trim() || "/usr/bin/git";
263
- shimDir = path.join(workDir, "shim");
264
- fs.mkdirSync(shimDir, { recursive: true });
265
- const shim = `#!/usr/bin/env bash
266
- if [ "$1" = "clone" ]; then
267
- args=()
268
- for a in "$@"; do
269
- case "$a" in
270
- https://github.com/*) a=${JSON.stringify(upstream)} ;;
271
- esac
272
- args+=("$a")
273
- done
274
- exec ${JSON.stringify(realGit)} "\${args[@]}"
275
- fi
276
- exec ${JSON.stringify(realGit)} "$@"
277
- `;
278
- fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
279
-
280
- env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
281
- }, 120_000);
282
-
283
- afterAll(() => {
284
- if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
285
- });
286
-
287
- let installSeq = 0;
288
- /**
289
- * Build a fresh fake install (an existing HQ root) with the given on-disk
290
- * settings.json (`ours`) and settings.local.json (`local`). The hook FILE the
291
- * earlier release added is present but unwired (mirrors the field box). A
292
- * composer script is on the local tree (the overlay refreshes it anyway).
293
- */
294
- function makeInstall(
295
- ours: TestSettings,
296
- local: Record<string, unknown>,
297
- ): string {
298
- installSeq += 1;
299
- const root = path.join(workDir, `hq-${installSeq}`);
300
- fs.mkdirSync(path.join(root, ".claude", "hooks"), { recursive: true });
301
- fs.mkdirSync(path.join(root, "core", "scripts"), { recursive: true });
302
- fs.mkdirSync(path.join(root, "personal"), { recursive: true });
303
- fs.mkdirSync(path.join(root, "companies"), { recursive: true });
304
- fs.writeFileSync(
305
- path.join(root, ".claude", "settings.json"),
306
- JSON.stringify(ours, null, 2) + "\n",
307
- );
308
- fs.writeFileSync(path.join(root, ".claude", "hooks", "old-hook.sh"), "#!/usr/bin/env bash\n");
309
- fs.writeFileSync(
310
- path.join(root, ".claude", "settings.local.json"),
311
- JSON.stringify(local, null, 2) + "\n",
312
- );
313
- fs.writeFileSync(path.join(root, "core", "scripts", "compose-settings-path.sh"), COMPOSER, { mode: 0o755 });
314
- fs.writeFileSync(path.join(root, "core", "keep.md"), "same\n");
315
- return root;
316
- }
317
-
318
- const rescueArgs = (root: string, floor?: string) => {
319
- const a = ["--hq-root", root, "--source", "test/repo", "--ref", "main", "--yes", "--no-backup"];
320
- if (floor) a.push("--floor-sha", floor);
321
- else a.push("--no-history-check");
322
- return a;
323
- };
324
-
325
- const readSettings = (root: string) =>
326
- JSON.parse(fs.readFileSync(path.join(root, ".claude", "settings.json"), "utf-8")) as TestSettings;
327
- const readLocal = (root: string) =>
328
- JSON.parse(fs.readFileSync(path.join(root, ".claude", "settings.local.json"), "utf-8")) as {
329
- permissions?: { allow?: string[] };
330
- env?: Record<string, string>;
331
- };
332
- const cmdsOf = (s: TestSettings) => s.hooks.PreToolUse.flatMap((g) => g.hooks.map((h) => h.command));
333
-
334
- it("wires in the new hook registration, preserves machine PATH, and relocates drift", () => {
335
- // ours = floor template + machine env.PATH + a user-drift permission, and
336
- // (crucially) WITHOUT the new-hook registration.
337
- const ours = structuredClone(floorSettings);
338
- ours.env.PATH = MACHINE_PATH;
339
- ours.permissions.allow.push("Bash(mytool:*)");
340
- const root = makeInstall(ours, { env: { HQ_FOO: "1" } });
341
-
342
- const r = runRescueCapture(rescueArgs(root, floorSha), env);
343
- const out = `${r.stdout}\n${r.stderr}`;
344
- expect(r.status, out).toBe(0);
345
- expect(out).toContain(`Baseline: ${floorSha}`);
346
- expect(out).toContain("Reconciled .claude/settings.json to release baseline");
347
-
348
- const settings = readSettings(root);
349
- const local = readLocal(root);
350
-
351
- // (1) The newly-released hook registration is now present in settings.json,
352
- // and the hook FILE landed too.
353
- expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD);
354
- expect(fs.existsSync(path.join(root, ".claude", "hooks", "new-hook.sh"))).toBe(true);
355
-
356
- // (3) Machine env.PATH is preserved/composed — never dropped, never the
357
- // release's (the release ships none), never hardcoded.
358
- expect(settings.env.PATH).toBe(MACHINE_PATH);
359
-
360
- // (2) The user-drift permission moved OUT of settings.json (back to the
361
- // shipped baseline) and INTO settings.local.json — not lost.
362
- expect(settings.permissions.allow).toEqual(["Bash(git:*)"]);
363
- expect(local.permissions?.allow).toContain("Bash(mytool:*)");
364
- // The pre-existing local override survives.
365
- expect(local.env?.HQ_FOO).toBe("1");
366
-
367
- // Effective merged settings (Project ⊕ Local, union of permissions) still
368
- // grant the user's tool.
369
- const effectiveAllow = new Set([
370
- ...settings.permissions.allow,
371
- ...(local.permissions?.allow ?? []),
372
- ]);
373
- expect(effectiveAllow.has("Bash(mytool:*)")).toBe(true);
374
- expect(effectiveAllow.has("Bash(git:*)")).toBe(true);
375
-
376
- // No junk personal/ copy of settings.json is minted.
377
- expect(fs.existsSync(path.join(root, "personal", ".claude", "settings.json"))).toBe(false);
378
- }, 120_000);
379
-
380
- it("relocates a user-added permission even with no reachable floor (head_compare fallback)", () => {
381
- // No --floor-sha, --no-history-check -> head_compare mode (base = release
382
- // HEAD). A user-added permission absent from the release must still be
383
- // preserved, the new hook registration must still wire in, and env.PATH must
384
- // still be composed.
385
- const ours = structuredClone(floorSettings);
386
- ours.env.PATH = MACHINE_PATH;
387
- ours.permissions.allow.push("Bash(mytool:*)");
388
- const root = makeInstall(ours, {});
389
-
390
- const r = runRescueCapture(rescueArgs(root), env);
391
- const out = `${r.stdout}\n${r.stderr}`;
392
- expect(r.status, out).toBe(0);
393
- expect(out).toContain("HEAD compare");
394
- expect(out).toContain("Reconciled .claude/settings.json to release baseline");
395
-
396
- const settings = readSettings(root);
397
- const local = readLocal(root);
398
-
399
- expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD); // release baseline wins
400
- expect(settings.env.PATH).toBe(MACHINE_PATH); // machine PATH composed
401
- // The user-added permission the release never had is relocated, not lost.
402
- expect(settings.permissions.allow).toEqual(["Bash(git:*)"]);
403
- expect(local.permissions?.allow).toContain("Bash(mytool:*)");
404
- expect(fs.existsSync(path.join(root, "personal", ".claude", "settings.json"))).toBe(false);
405
- }, 120_000);
406
-
407
- it("composes env.PATH through the release composer (toolchain dirs prepended, not hardcoded)", () => {
408
- // ours has a machine PATH but is MISSING the managed toolchain dirs. The
409
- // reconcile must run them through compose-settings-path.sh so the toolchain
410
- // is prepended — proving env.PATH is genuinely COMPOSED (deliverable 3),
411
- // not merely copied and not hardcoded from any release blob.
412
- const toolchain = path.join(workDir, `toolchain-${installSeq + 1}`);
413
- for (const d of ["node/bin", "npm-global/bin", "git/bin"]) {
414
- fs.mkdirSync(path.join(toolchain, d), { recursive: true });
415
- }
416
- const ours = structuredClone(floorSettings);
417
- ours.env.PATH = MACHINE_PATH; // no toolchain dirs present
418
- const root = makeInstall(ours, {});
419
-
420
- const r = runRescueCapture(rescueArgs(root, floorSha), {
421
- ...env,
422
- HQ_TOOLCHAIN_DIR: toolchain,
423
- });
424
- const out = `${r.stdout}\n${r.stderr}`;
425
- expect(r.status, out).toBe(0);
426
-
427
- const composed = readSettings(root).env.PATH;
428
- // installer ordering: node, npm-global, git prepended ahead of the base.
429
- expect(composed).toBe(
430
- `${toolchain}/node/bin:${toolchain}/npm-global/bin:${toolchain}/git/bin:${MACHINE_PATH}`,
431
- );
432
- // The machine base is still present (never dropped).
433
- expect(composed.endsWith(MACHINE_PATH)).toBe(true);
434
- }, 120_000);
435
-
436
- it("persists a user's array removal against the release baseline (thread 1)", () => {
437
- // ours REMOVES the shipped "Bash(git:*)" permission. A union Local overlay
438
- // cannot express a removal, so the reconcile must strip it from the Project
439
- // baseline (theirs) instead — otherwise the release re-introduces it.
440
- const ours = structuredClone(floorSettings);
441
- ours.env.PATH = MACHINE_PATH;
442
- ours.permissions.allow = []; // removed Bash(git:*)
443
- const root = makeInstall(ours, {});
444
-
445
- const r = runRescueCapture(rescueArgs(root, floorSha), env);
446
- expect(r.status, `${r.stdout}\n${r.stderr}`).toBe(0);
447
-
448
- const settings = readSettings(root);
449
- // The deliberately-removed permission stays gone, and the new hook still lands.
450
- expect(settings.permissions.allow).toEqual([]);
451
- expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD);
452
- }, 120_000);
453
-
454
- it("keeps the original settings.json when settings.local.json is unwritable (thread 3)", () => {
455
- // ours has a drift permission to relocate; make settings.local.json a
456
- // DIRECTORY so the relocation write fails. The reconcile must then preserve
457
- // ours rather than overwrite it with the drift-less release baseline.
458
- const ours = structuredClone(floorSettings);
459
- ours.env.PATH = MACHINE_PATH;
460
- ours.permissions.allow.push("Bash(mytool:*)");
461
- const root = makeInstall(ours, {});
462
- // Replace the local file with a directory (write will EISDIR). It is a
463
- // carve-out, so the shuttle restores it as a directory across the overlay.
464
- fs.rmSync(path.join(root, ".claude", "settings.local.json"));
465
- fs.mkdirSync(path.join(root, ".claude", "settings.local.json"));
466
-
467
- const r = runRescueCapture(rescueArgs(root, floorSha), env);
468
- const out = `${r.stdout}\n${r.stderr}`;
469
- expect(r.status, out).toBe(0);
470
- expect(out).toContain("settings update not applied");
471
-
472
- // ours is preserved verbatim — the drift permission is NOT lost.
473
- const settings = readSettings(root);
474
- expect(settings.permissions.allow).toContain("Bash(mytool:*)");
475
- expect(settings.env.PATH).toBe(MACHINE_PATH);
476
- }, 120_000);
477
-
478
- it("backs up a malformed pre-rescue settings.json instead of destroying it (thread 5)", () => {
479
- // Pre-rescue settings.json is invalid JSON. The overlay replaces it, so the
480
- // reconcile must preserve the raw bytes under personal/ and still leave a
481
- // working (composed) settings.json.
482
- const root = makeInstall(structuredClone(floorSettings), {});
483
- fs.writeFileSync(path.join(root, ".claude", "settings.json"), "{ this is : not json ");
484
-
485
- const r = runRescueCapture(rescueArgs(root, floorSha), env);
486
- const out = `${r.stdout}\n${r.stderr}`;
487
- expect(r.status, out).toBe(0);
488
-
489
- // Raw malformed content preserved under personal/.
490
- const backup = path.join(root, "personal", ".claude", "settings.json");
491
- expect(fs.existsSync(backup)).toBe(true);
492
- expect(fs.readFileSync(backup, "utf-8")).toBe("{ this is : not json ");
493
- // settings.json is valid again (release baseline + composed PATH + new hook).
494
- const settings = readSettings(root);
495
- expect(cmdsOf(settings)).toContain(NEW_HOOK_CMD);
496
- expect(typeof settings.env.PATH).toBe("string");
497
- }, 120_000);
498
-
499
- it("honors an explicit --preserve-subpath of settings.json (thread 6)", () => {
500
- // The caller asked to keep settings.json; the reconcile must not rewrite it,
501
- // and the (preserved) old file — without the new registration — stays as-is.
502
- const ours = structuredClone(floorSettings);
503
- ours.env.PATH = MACHINE_PATH;
504
- const root = makeInstall(ours, {});
505
-
506
- const r = runRescueCapture(
507
- [...rescueArgs(root, floorSha), "--preserve-subpath", ".claude/settings.json"],
508
- env,
509
- );
510
- const out = `${r.stdout}\n${r.stderr}`;
511
- expect(r.status, out).toBe(0);
512
- expect(out).toContain("explicitly preserved");
513
-
514
- const settings = readSettings(root);
515
- // Preserved verbatim: no reconcile, so the new registration did NOT land.
516
- expect(cmdsOf(settings)).not.toContain(NEW_HOOK_CMD);
517
- expect(settings.env.PATH).toBe(MACHINE_PATH);
518
- }, 120_000);
519
-
520
- it("restores the machine settings.json when a release drops the template (thread 4)", () => {
521
- // A dedicated fixture whose HEAD DELETES .claude/settings.json (floor had
522
- // it). Overwrite-safe deletes ours; the overlay lays nothing; the reconcile
523
- // must restore ours so PATH + customizations survive under --no-backup.
524
- const up2 = path.join(workDir, "upstream-drop");
525
- fs.mkdirSync(path.join(up2, ".claude"), { recursive: true });
526
- fs.mkdirSync(path.join(up2, "core", "scripts"), { recursive: true });
527
- git(workDir, "init", "-b", "main", "upstream-drop");
528
- fs.writeFileSync(
529
- path.join(up2, ".claude", "settings.json"),
530
- JSON.stringify(floorSettings, null, 2) + "\n",
531
- );
532
- fs.writeFileSync(path.join(up2, "core", "scripts", "compose-settings-path.sh"), COMPOSER, { mode: 0o755 });
533
- fs.writeFileSync(path.join(up2, "core", "keep.md"), "same\n");
534
- git(up2, "add", "-A");
535
- git(up2, "commit", "-m", "floor with settings");
536
- const floor2 = git(up2, "rev-parse", "HEAD");
537
- // HEAD removes the template.
538
- fs.rmSync(path.join(up2, ".claude", "settings.json"));
539
- git(up2, "add", "-A");
540
- git(up2, "commit", "-m", "drop settings.json");
541
-
542
- // A shim that points clones at up2 instead of the shared upstream.
543
- const realGit =
544
- execFileSync("bash", ["-c", "command -v git"]).toString().trim() || "/usr/bin/git";
545
- const shim2 = path.join(workDir, "shim-drop");
546
- fs.mkdirSync(shim2, { recursive: true });
547
- fs.writeFileSync(
548
- path.join(shim2, "git"),
549
- `#!/usr/bin/env bash
550
- if [ "$1" = "clone" ]; then
551
- args=()
552
- for a in "$@"; do
553
- case "$a" in
554
- https://github.com/*) a=${JSON.stringify(up2)} ;;
555
- esac
556
- args+=("$a")
557
- done
558
- exec ${JSON.stringify(realGit)} "\${args[@]}"
559
- fi
560
- exec ${JSON.stringify(realGit)} "$@"
561
- `,
562
- { mode: 0o755 },
563
- );
564
-
565
- const ours = structuredClone(floorSettings);
566
- ours.env.PATH = MACHINE_PATH;
567
- ours.permissions.allow.push("Bash(mytool:*)");
568
- const root = makeInstall(ours, {});
569
-
570
- const r = runRescueCapture(rescueArgs(root, floor2), {
571
- ...env,
572
- PATH: `${shim2}:${process.env.PATH ?? ""}`,
573
- });
574
- const out = `${r.stdout}\n${r.stderr}`;
575
- expect(r.status, out).toBe(0);
576
- expect(out).toContain("Release no longer ships .claude/settings.json");
577
-
578
- // ours restored — machine PATH and the user's customization survive.
579
- const settings = readSettings(root);
580
- expect(settings.env.PATH).toBe(MACHINE_PATH);
581
- expect(settings.permissions.allow).toContain("Bash(mytool:*)");
582
- }, 120_000);
583
- });
@@ -1,57 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- RescuePathChangedError,
4
- assertRescuePathTypeUnchanged,
5
- isRescueTransientDirectory,
6
- } from "./rescue-snapshot.js";
7
-
8
- describe("rescue transient path policy", () => {
9
- it.each([
10
- "worktrees",
11
- ".worktrees",
12
- ".claude/worktrees",
13
- ".claude/worktrees/agent/repo",
14
- ".sst-sandbox-home",
15
- ".tmp-codex-asar",
16
- ".claude/skills/demo/node_modules",
17
- ".claude/skills/demo/.pnpm-store",
18
- ".claude/skills/demo/.sst",
19
- ".claude/skills/demo/.next",
20
- ".claude/skills/demo/target",
21
- ])("excludes reproducible directory %s", (rel) => {
22
- expect(isRescueTransientDirectory(rel)).toBe(true);
23
- });
24
-
25
- it.each([
26
- "core/worktrees.md",
27
- "core/target-audience",
28
- "companies/acme/knowledge/.next-steps",
29
- "core/packages",
30
- ])("does not overmatch meaningful path %s", (rel) => {
31
- expect(isRescueTransientDirectory(rel)).toBe(false);
32
- });
33
- });
34
-
35
- describe("rescue apply path-type revalidation", () => {
36
- it("accepts an unchanged path type", () => {
37
- expect(() => assertRescuePathTypeUnchanged("core/a.md", "file", "file")).not.toThrow();
38
- });
39
-
40
- it("raises a typed state-change error before a classified symlink is treated as a directory", () => {
41
- let thrown: unknown;
42
- try {
43
- assertRescuePathTypeUnchanged(
44
- ".claude/skills/personal:foreman/tests",
45
- "symlink",
46
- "directory",
47
- );
48
- } catch (error) {
49
- thrown = error;
50
- }
51
-
52
- expect(thrown).toBeInstanceOf(RescuePathChangedError);
53
- expect(String(thrown)).toContain("changed after classification");
54
- expect(String(thrown)).toContain("expected symlink, found directory");
55
- expect(String(thrown)).not.toContain("EISDIR");
56
- });
57
- });