@indigoai-us/hq-cloud 6.15.0 → 6.15.2

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