@svenroth-ai/shipwright 0.25.0 → 0.26.0

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 (134) hide show
  1. package/client/dist/assets/{EmbeddedTerminal-5QvKe3_a.js → EmbeddedTerminal-D3wpPNZP.js} +1 -1
  2. package/client/dist/assets/{abnfDiagram-VRR7QNED-CXi9HegQ.js → abnfDiagram-VRR7QNED-BkLIltB3.js} +1 -1
  3. package/client/dist/assets/{arc-DLh4GiqR.js → arc-Sw-Gh0nI.js} +1 -1
  4. package/client/dist/assets/{architectureDiagram-ZJ3FMSHR-DUypQ8dQ.js → architectureDiagram-ZJ3FMSHR-rdxUi9Ks.js} +1 -1
  5. package/client/dist/assets/{blockDiagram-677ZJIJ3-D2cVMKJG.js → blockDiagram-677ZJIJ3-Ch_9dj8a.js} +1 -1
  6. package/client/dist/assets/{c4Diagram-LMCZKHZV-DtruBGJw.js → c4Diagram-LMCZKHZV-CkFlO65x.js} +1 -1
  7. package/client/dist/assets/channel-BYPbLjRl.js +1 -0
  8. package/client/dist/assets/{chunk-2Q5K7J3B-CAfQkFw2.js → chunk-2Q5K7J3B-CypX83xu.js} +1 -1
  9. package/client/dist/assets/{chunk-32BRIVSS-CPWXoLnQ.js → chunk-32BRIVSS-DxVLYGdl.js} +1 -1
  10. package/client/dist/assets/{chunk-5VM5RSS4-xVc3oppQ.js → chunk-5VM5RSS4-Cxk1evhx.js} +1 -1
  11. package/client/dist/assets/{chunk-EX3LRPZG-CUpl6fRR.js → chunk-EX3LRPZG-n9q5S4yJ.js} +1 -1
  12. package/client/dist/assets/{chunk-JWPE2WC7-NejUp56x.js → chunk-JWPE2WC7-jeL4xvir.js} +1 -1
  13. package/client/dist/assets/{chunk-MOJQB5TN-C9-2lLg0.js → chunk-MOJQB5TN-DDSHN0cj.js} +1 -1
  14. package/client/dist/assets/{chunk-RYQCIY6F-BAPtwABy.js → chunk-RYQCIY6F-C0bo383g.js} +1 -1
  15. package/client/dist/assets/{chunk-V7JOEXUC-Dwzihw26.js → chunk-V7JOEXUC-C0jDXXAU.js} +1 -1
  16. package/client/dist/assets/{chunk-VR4S4FIN-9-iArFJM.js → chunk-VR4S4FIN-h-Rr4Pn2.js} +1 -1
  17. package/client/dist/assets/{chunk-XXDRQBXY-DiWHOrqe.js → chunk-XXDRQBXY-BexSo0-s.js} +1 -1
  18. package/client/dist/assets/classDiagram-OUVF2IWQ-CsFIeTju.js +1 -0
  19. package/client/dist/assets/classDiagram-v2-EOCWNBFH-CsFIeTju.js +1 -0
  20. package/client/dist/assets/{cose-bilkent-JH36ORCC-Cp04EOcH.js → cose-bilkent-JH36ORCC-BQXzaioj.js} +1 -1
  21. package/client/dist/assets/{cynefin-VYW2F7L2-D--GN1v8.js → cynefin-VYW2F7L2-BJIofhfe.js} +1 -1
  22. package/client/dist/assets/{cynefinDiagram-TSTJHNR4-BGH0o_qp.js → cynefinDiagram-TSTJHNR4-DmPqTM1T.js} +1 -1
  23. package/client/dist/assets/{dagre-VKFMJZFB-DSKkIKlj.js → dagre-VKFMJZFB-1xljvw6X.js} +1 -1
  24. package/client/dist/assets/{diagram-FQU43EPY-Cs_C4lb-.js → diagram-FQU43EPY-iyL9muCG.js} +1 -1
  25. package/client/dist/assets/{diagram-G47NLZAW-oDtAA8O_.js → diagram-G47NLZAW-DREp2Th6.js} +1 -1
  26. package/client/dist/assets/{diagram-NH7WQ7WH-BiFMjqSH.js → diagram-NH7WQ7WH-h0X3Is2S.js} +1 -1
  27. package/client/dist/assets/{diagram-OA4YK3LP-Ccs3thCj.js → diagram-OA4YK3LP-Bj8YZRFJ.js} +1 -1
  28. package/client/dist/assets/{diagram-WEI45ONY-CdZNqgO3.js → diagram-WEI45ONY-BZOf805d.js} +1 -1
  29. package/client/dist/assets/{ebnfDiagram-CCIWWBDH-CapTA0VL.js → ebnfDiagram-CCIWWBDH-DO5JnXda.js} +1 -1
  30. package/client/dist/assets/{erDiagram-Q63AITRT-BcRNEP8s.js → erDiagram-Q63AITRT-DRgcqg1T.js} +1 -1
  31. package/client/dist/assets/{flowDiagram-23GEKE2U-CJ_zF7e_.js → flowDiagram-23GEKE2U-C3J9QmrW.js} +1 -1
  32. package/client/dist/assets/{ganttDiagram-NO4QXBWP-CnJdcg9A.js → ganttDiagram-NO4QXBWP-BIEl3Tkm.js} +1 -1
  33. package/client/dist/assets/{gitGraphDiagram-IHSO6WYX-YTUQ_ELk.js → gitGraphDiagram-IHSO6WYX-BNF0tFWC.js} +1 -1
  34. package/client/dist/assets/{index-irQhGNpn.js → index-BnTOrFHb.js} +203 -202
  35. package/client/dist/assets/index-CYPc6qxw.css +1 -0
  36. package/client/dist/assets/{infoDiagram-FWYZ7A6U-CLOP_ZWD.js → infoDiagram-FWYZ7A6U-DEyFxiyY.js} +1 -1
  37. package/client/dist/assets/{ishikawaDiagram-FXEZZL3T-aFTIzr7o.js → ishikawaDiagram-FXEZZL3T-B_ehlg3I.js} +1 -1
  38. package/client/dist/assets/{journeyDiagram-5HDEW3XC-CbPJB4xd.js → journeyDiagram-5HDEW3XC-Dp00FyJS.js} +1 -1
  39. package/client/dist/assets/{kanban-definition-HUTT4EX6-BoyhytIS.js → kanban-definition-HUTT4EX6-CTAf16q8.js} +1 -1
  40. package/client/dist/assets/{linear-1VXA-uBg.js → linear-9vNUgmYd.js} +1 -1
  41. package/client/dist/assets/{mermaid.core-D_lO2yK9.js → mermaid.core-A6tjaEVD.js} +4 -4
  42. package/client/dist/assets/{mindmap-definition-LN4V7U3C-iFK7LXMx.js → mindmap-definition-LN4V7U3C-DRW3bmCY.js} +1 -1
  43. package/client/dist/assets/{pegDiagram-2B236MQR-DNmcmfA-.js → pegDiagram-2B236MQR-DJFCjHp2.js} +1 -1
  44. package/client/dist/assets/{pieDiagram-ENE6RG2P-ekx83O5b.js → pieDiagram-ENE6RG2P-KRd-sGvC.js} +1 -1
  45. package/client/dist/assets/{quadrantDiagram-ABIIQ3AL-DayyD1l8.js → quadrantDiagram-ABIIQ3AL-9TLZE3Hu.js} +1 -1
  46. package/client/dist/assets/{railroadDiagram-RFXS5EU6-Bdgz7S6G.js → railroadDiagram-RFXS5EU6-y4tLtjXg.js} +1 -1
  47. package/client/dist/assets/{requirementDiagram-TGXJPOKE-szP3Muld.js → requirementDiagram-TGXJPOKE-B9EOQDQM.js} +1 -1
  48. package/client/dist/assets/{sankeyDiagram-HTMAVEWB-Ct3rzgUj.js → sankeyDiagram-HTMAVEWB-zfwiDzkT.js} +1 -1
  49. package/client/dist/assets/{sequenceDiagram-DBY2YBRQ-_69EfmOU.js → sequenceDiagram-DBY2YBRQ-DW1LWepL.js} +1 -1
  50. package/client/dist/assets/{sizeCapture-X5ZJPWSS-BSOU06J2.js → sizeCapture-X5ZJPWSS-BM81Mik0.js} +1 -1
  51. package/client/dist/assets/{stateDiagram-2N3HPSRC-HmOmFwQm.js → stateDiagram-2N3HPSRC-D0EMIw1w.js} +1 -1
  52. package/client/dist/assets/stateDiagram-v2-6OUMAXLB-B3E4_xxT.js +1 -0
  53. package/client/dist/assets/{swimlanes-5IMT3BWC-CVg_4oT7.js → swimlanes-5IMT3BWC-CKXNCX6M.js} +2 -2
  54. package/client/dist/assets/swimlanesDiagram-G3AALYLV-BegQBqnC.js +8 -0
  55. package/client/dist/assets/{timeline-definition-FHXFAJF6-CBBaDKXm.js → timeline-definition-FHXFAJF6-ByzIfvL5.js} +1 -1
  56. package/client/dist/assets/{vennDiagram-L72KCM5P-CBXiWPMc.js → vennDiagram-L72KCM5P-Xep38xBH.js} +1 -1
  57. package/client/dist/assets/{wardleyDiagram-EHGQE667-uhBCZoW0.js → wardleyDiagram-EHGQE667-DKWDpx6g.js} +1 -1
  58. package/client/dist/assets/{xychartDiagram-FW5EYKEG-xxE_wY5O.js → xychartDiagram-FW5EYKEG-BGcwZTnu.js} +1 -1
  59. package/client/dist/index.html +27 -27
  60. package/lib/claude-cli.mjs +173 -173
  61. package/lib/preflight.mjs +252 -252
  62. package/lib/probe-path.mjs +84 -84
  63. package/lib/server.mjs +369 -369
  64. package/lib/util.mjs +157 -157
  65. package/lib/win32-spawn.mjs +253 -253
  66. package/package.json +68 -68
  67. package/scripts/deploy-procs.mjs +208 -208
  68. package/scripts/deploy-swap.mjs +276 -276
  69. package/scripts/kill-targets.js +140 -140
  70. package/scripts/repair-claude-json.mjs +295 -295
  71. package/server/dist/config/default-actions.json +90 -90
  72. package/server/dist/core/mission-context/association.d.ts +46 -0
  73. package/server/dist/core/mission-context/association.d.ts.map +1 -1
  74. package/server/dist/core/mission-context/association.js +69 -0
  75. package/server/dist/core/mission-context/association.js.map +1 -1
  76. package/server/dist/core/mission-context/resolver-iterate.d.ts +6 -0
  77. package/server/dist/core/mission-context/resolver-iterate.d.ts.map +1 -0
  78. package/server/dist/core/mission-context/resolver-iterate.js +202 -0
  79. package/server/dist/core/mission-context/resolver-iterate.js.map +1 -0
  80. package/server/dist/core/mission-context/resolver-parts.d.ts +20 -0
  81. package/server/dist/core/mission-context/resolver-parts.d.ts.map +1 -1
  82. package/server/dist/core/mission-context/resolver-parts.js +92 -0
  83. package/server/dist/core/mission-context/resolver-parts.js.map +1 -1
  84. package/server/dist/core/mission-context/resolver.d.ts +1 -1
  85. package/server/dist/core/mission-context/resolver.d.ts.map +1 -1
  86. package/server/dist/core/mission-context/resolver.js +17 -199
  87. package/server/dist/core/mission-context/resolver.js.map +1 -1
  88. package/server/dist/core/mission-context/run-id-recovery.d.ts +21 -5
  89. package/server/dist/core/mission-context/run-id-recovery.d.ts.map +1 -1
  90. package/server/dist/core/mission-context/run-id-recovery.js +32 -5
  91. package/server/dist/core/mission-context/run-id-recovery.js.map +1 -1
  92. package/server/dist/core/mission-context/scenario.d.ts +18 -11
  93. package/server/dist/core/mission-context/scenario.d.ts.map +1 -1
  94. package/server/dist/core/mission-context/scenario.js +40 -3
  95. package/server/dist/core/mission-context/scenario.js.map +1 -1
  96. package/server/dist/core/path-guard.d.ts +1 -1
  97. package/server/dist/core/path-guard.d.ts.map +1 -1
  98. package/server/dist/core/path-guard.js +9 -2
  99. package/server/dist/core/path-guard.js.map +1 -1
  100. package/server/dist/core/shipslog-docs-reader.d.ts +4 -0
  101. package/server/dist/core/shipslog-docs-reader.d.ts.map +1 -0
  102. package/server/dist/core/shipslog-docs-reader.js +181 -0
  103. package/server/dist/core/shipslog-docs-reader.js.map +1 -0
  104. package/server/dist/core/shipslog-docs-types.d.ts +15 -0
  105. package/server/dist/core/shipslog-docs-types.d.ts.map +1 -0
  106. package/server/dist/core/shipslog-docs-types.js +14 -0
  107. package/server/dist/core/shipslog-docs-types.js.map +1 -0
  108. package/server/dist/external/design-review/serve.js +27 -27
  109. package/server/dist/external/mission-context/association-write.d.ts +22 -0
  110. package/server/dist/external/mission-context/association-write.d.ts.map +1 -0
  111. package/server/dist/external/mission-context/association-write.js +90 -0
  112. package/server/dist/external/mission-context/association-write.js.map +1 -0
  113. package/server/dist/external/mission-context/routes.d.ts.map +1 -1
  114. package/server/dist/external/mission-context/routes.js +18 -31
  115. package/server/dist/external/mission-context/routes.js.map +1 -1
  116. package/server/dist/external/routes.d.ts.map +1 -1
  117. package/server/dist/external/routes.js +9 -6
  118. package/server/dist/external/routes.js.map +1 -1
  119. package/server/dist/external/shipslog-docs/routes.d.ts +10 -0
  120. package/server/dist/external/shipslog-docs/routes.d.ts.map +1 -0
  121. package/server/dist/external/shipslog-docs/routes.js +41 -0
  122. package/server/dist/external/shipslog-docs/routes.js.map +1 -0
  123. package/server/package.json +1 -1
  124. package/server/profiles/PROFILE_SCHEMA_VERSION +1 -1
  125. package/server/profiles/README.md +27 -27
  126. package/server/profiles/python-plugin-monorepo.json +35 -35
  127. package/server/profiles/supabase-nextjs.json +253 -253
  128. package/server/profiles/vite-hono.json +92 -92
  129. package/client/dist/assets/channel-C_k-9jsa.js +0 -1
  130. package/client/dist/assets/classDiagram-OUVF2IWQ-CsEPq9Ac.js +0 -1
  131. package/client/dist/assets/classDiagram-v2-EOCWNBFH-CsEPq9Ac.js +0 -1
  132. package/client/dist/assets/index-6nZ1bt0a.css +0 -1
  133. package/client/dist/assets/stateDiagram-v2-6OUMAXLB-BW8H33dv.js +0 -1
  134. package/client/dist/assets/swimlanesDiagram-G3AALYLV-BaRlxcPD.js +0 -8
@@ -1,295 +1,295 @@
1
- /**
2
- * repair-claude-json.mjs — deploy-time self-heal for a corrupt ~/.claude.json.
3
- *
4
- * WHY: the production deploy (start-server-production.ps1) force-kills the webui
5
- * server, which kills every embedded-terminal `claude` process at once. On
6
- * reload many `claude` CLIs start simultaneously and race on ~/.claude.json,
7
- * which the CLI writes NON-atomically and WITHOUT a lock. The losing write can
8
- * leave a valid, SHORTER object followed by the leftover tail of an older,
9
- * LONGER version (a "truncation tail") — every running `claude` then fails with
10
- * JSON.parse: "Unexpected non-whitespace character after JSON at position N".
11
- *
12
- * webui is a read-only observer of ~/.claude/ and never writes this file (that
13
- * is the upstream CLI's bug to fix). But the deploy can heal the file once,
14
- * up-front, before the embedded sessions restart — that is all this script does.
15
- *
16
- * Best effort: the deploy NEVER gates on this script's exit code. Server/build
17
- * do not depend on ~/.claude.json.
18
- *
19
- * Run (CLI): node scripts/repair-claude-json.mjs · Test: node --test <this>.test.mjs
20
- * Scan delimiters use char codes (not \u escapes) on purpose — literal
21
- * backslash/brace escapes in source have bitten this repo before.
22
- */
23
-
24
- import fs from 'node:fs';
25
- import os from 'node:os';
26
- import path from 'node:path';
27
- import { fileURLToPath } from 'node:url';
28
-
29
- const CH_OPEN = 0x7b; // {
30
- const CH_CLOSE = 0x7d; // }
31
- const CH_QUOTE = 0x22; // "
32
- const CH_BACKSLASH = 0x5c; // (reverse solidus)
33
-
34
- const MIN_TOP_LEVEL_KEYS = 3;
35
- const DEFAULT_BACKUPS_KEPT = 10;
36
-
37
- const PREFIX = '[repair-claude-json]';
38
- const log = (msg) => process.stdout.write(`${PREFIX} ${msg}\n`);
39
- const warn = (msg) => process.stderr.write(`${PREFIX} WARN: ${msg}\n`);
40
-
41
- /**
42
- * String/escape-aware scan for the first balanced top-level `{...}` object.
43
- * Braces and quotes inside JSON string values (incl. escaped quotes) are
44
- * ignored. Returns `{ prefix, tail }` or `null` when no balanced object exists.
45
- *
46
- * @param {string} text
47
- * @returns {{ prefix: string, tail: string } | null}
48
- */
49
- export function findFirstBalancedObject(text) {
50
- if (typeof text !== 'string') return null;
51
- const start = text.indexOf(String.fromCharCode(CH_OPEN));
52
- if (start < 0) return null;
53
-
54
- let depth = 0;
55
- let inString = false;
56
- let escaped = false;
57
-
58
- for (let i = start; i < text.length; i++) {
59
- const c = text.charCodeAt(i);
60
- if (inString) {
61
- if (escaped) escaped = false;
62
- else if (c === CH_BACKSLASH) escaped = true;
63
- else if (c === CH_QUOTE) inString = false;
64
- continue;
65
- }
66
- if (c === CH_QUOTE) {
67
- inString = true;
68
- } else if (c === CH_OPEN) {
69
- depth++;
70
- } else if (c === CH_CLOSE) {
71
- depth--;
72
- if (depth === 0) {
73
- return { prefix: text.slice(start, i + 1), tail: text.slice(i + 1) };
74
- }
75
- }
76
- }
77
- return null; // never returned to depth 0 — unbalanced
78
- }
79
-
80
- /**
81
- * Sanity guard: a repaired candidate is only trusted to overwrite the live file
82
- * when it parses AND looks like a real claude config (object, >= 3 top-level
83
- * keys, and carries "projects"). Stops us from clobbering the file with a tiny
84
- * coincidental `{...}` that happened to be balanced.
85
- *
86
- * @param {unknown} obj
87
- * @returns {boolean}
88
- */
89
- export function isPlausibleClaudeConfig(obj) {
90
- return (
91
- obj != null &&
92
- typeof obj === 'object' &&
93
- !Array.isArray(obj) &&
94
- Object.keys(obj).length >= MIN_TOP_LEVEL_KEYS &&
95
- Object.prototype.hasOwnProperty.call(obj, 'projects')
96
- );
97
- }
98
-
99
- /**
100
- * Classify + (logically) repair raw text. Pure: no I/O.
101
- *
102
- * @param {string|null|undefined} text
103
- * @returns {{ status: 'valid'|'empty'|'repaired'|'unrepairable',
104
- * repaired?: string, discardedTail?: string, reason?: string }}
105
- */
106
- export function repairJsonText(text) {
107
- if (text == null || text.trim().length === 0) return { status: 'empty' };
108
- try {
109
- JSON.parse(text);
110
- return { status: 'valid' };
111
- } catch {
112
- /* fall through to repair */
113
- }
114
- const found = findFirstBalancedObject(text);
115
- if (!found) return { status: 'unrepairable', reason: 'no_balanced_object' };
116
-
117
- let candidate;
118
- try {
119
- candidate = JSON.parse(found.prefix);
120
- } catch {
121
- return { status: 'unrepairable', reason: 'prefix_not_valid_json' };
122
- }
123
- if (!isPlausibleClaudeConfig(candidate)) {
124
- return { status: 'unrepairable', reason: 'implausible_structure' };
125
- }
126
- // Fail-safe ambiguity guard. A real truncation tail is a FRAGMENT (begins
127
- // mid-value) — it never starts a second complete config, and inner braces in
128
- // a fragment (`"tipsHistory":{...}`) are not plausible configs (no top-level
129
- // "projects"). But IF the tail itself begins another plausible config we have
130
- // two candidates and cannot know which is live — refuse rather than risk
131
- // overwriting the good config with the wrong one. (Overwriting a global
132
- // config must fail safe even though the diagnosed corruption never does this.)
133
- const tailObj = findFirstBalancedObject(found.tail);
134
- if (tailObj) {
135
- let tailCandidate;
136
- try {
137
- tailCandidate = JSON.parse(tailObj.prefix);
138
- } catch {
139
- tailCandidate = undefined;
140
- }
141
- if (isPlausibleClaudeConfig(tailCandidate)) {
142
- return { status: 'unrepairable', reason: 'multiple_candidates' };
143
- }
144
- }
145
- return { status: 'repaired', repaired: found.prefix, discardedTail: found.tail };
146
- }
147
-
148
- /**
149
- * Windows-safe filename timestamp (no `:` / `.`). FIXED-WIDTH by construction
150
- * (`toISOString()` is always `YYYY-MM-DDTHH-MM-SS-sssZ`), so lexicographic sort
151
- * === chronological — load-bearing for pruneBackups. A format-pinning test
152
- * guards against a future variable-width change (e.g. epoch millis) that would
153
- * silently mis-sort and prune the WRONG backups.
154
- */
155
- export function backupTimestamp() {
156
- return new Date().toISOString().replace(/[:.]/g, '-');
157
- }
158
-
159
- /**
160
- * Delete all but the newest `keep` `<base>.corrupt-<ts>.bak` files in `dir`.
161
- * Best effort — unlink failures are swallowed. Returns the deleted names.
162
- *
163
- * @param {string} dir
164
- * @param {string} base // e.g. ".claude.json"
165
- * @param {number} keep
166
- * @param {string} [exceptName] // a backup name to always keep (e.g. the one
167
- * // just created this run — protects it from a
168
- * // CONCURRENT run's prune deleting it).
169
- * @returns {string[]}
170
- */
171
- export function pruneBackups(dir, base, keep = DEFAULT_BACKUPS_KEPT, exceptName) {
172
- let entries;
173
- try {
174
- entries = fs.readdirSync(dir);
175
- } catch {
176
- return [];
177
- }
178
- const head = `${base}.corrupt-`;
179
- const baks = entries
180
- .filter((n) => n.startsWith(head) && n.endsWith('.bak') && n !== exceptName)
181
- .sort(); // ISO timestamp → lexicographic === chronological
182
- const excess = baks.slice(0, Math.max(0, baks.length - keep));
183
- for (const name of excess) {
184
- try {
185
- fs.unlinkSync(path.join(dir, name));
186
- } catch {
187
- /* best effort */
188
- }
189
- }
190
- return excess;
191
- }
192
-
193
- /**
194
- * Repair the file at `targetPath` in place (with backup + atomic write).
195
- * The target path is the SOLE I/O boundary — pass a temp path to test it.
196
- *
197
- * @param {string} targetPath
198
- * @param {{ keep?: number }} [options]
199
- * @returns {{ action: string, status: string, reason?: string,
200
- * backupPath?: string, discardedBytes?: number, message?: string }}
201
- */
202
- export function repairFile(targetPath, options = {}) {
203
- const keep = options.keep ?? DEFAULT_BACKUPS_KEPT;
204
- if (!fs.existsSync(targetPath)) {
205
- return { action: 'noop', status: 'absent' };
206
- }
207
-
208
- let raw;
209
- try {
210
- raw = fs.readFileSync(targetPath, 'utf8');
211
- } catch (err) {
212
- return { action: 'error', status: 'read_failed', message: String(err && err.message) };
213
- }
214
-
215
- const result = repairJsonText(raw);
216
- if (result.status === 'valid') return { action: 'noop', status: 'valid' };
217
- if (result.status === 'empty') return { action: 'noop', status: 'empty' };
218
- if (result.status === 'unrepairable') {
219
- return { action: 'skip', status: 'unrepairable', reason: result.reason };
220
- }
221
-
222
- const dir = path.dirname(targetPath);
223
- const base = path.basename(targetPath);
224
- const backupPath = path.join(dir, `${base}.corrupt-${backupTimestamp()}.bak`);
225
- const tmpPath = path.join(dir, `${base}.tmp-${process.pid}`);
226
- try {
227
- fs.copyFileSync(targetPath, backupPath); // preserve the corrupt original bytes
228
- fs.writeFileSync(tmpPath, result.repaired);
229
- fs.renameSync(tmpPath, targetPath); // atomic on same volume
230
- } catch (err) {
231
- // The original file is untouched, so the backup we just made is a redundant
232
- // copy of the still-corrupt file — clean both up so a failed repair (e.g. a
233
- // reader holding the file open on Windows) never litters the homedir with
234
- // an orphan temp + un-pruned backup.
235
- for (const p of [tmpPath, backupPath]) {
236
- try {
237
- fs.unlinkSync(p);
238
- } catch {
239
- /* best effort */
240
- }
241
- }
242
- return { action: 'error', status: 'write_failed', message: String(err && err.message) };
243
- }
244
- pruneBackups(dir, base, keep, path.basename(backupPath));
245
- return {
246
- action: 'repaired',
247
- status: 'repaired',
248
- backupPath,
249
- discardedBytes: Buffer.byteLength(result.discardedTail, 'utf8'),
250
- };
251
- }
252
-
253
- /**
254
- * CLI entry point. Returns a process exit code (0 = healthy/healed/benign,
255
- * 1 = corrupt-but-not-safely-repairable or an I/O error). The deploy must NOT
256
- * gate on this code.
257
- *
258
- * @returns {number}
259
- */
260
- export function main() {
261
- const targetPath = path.join(os.homedir(), '.claude.json');
262
- const res = repairFile(targetPath);
263
- switch (res.status) {
264
- case 'absent':
265
- log('~/.claude.json not found — nothing to repair.');
266
- return 0;
267
- case 'valid':
268
- log('~/.claude.json is valid — no repair needed.');
269
- return 0;
270
- case 'empty':
271
- log('~/.claude.json is empty — leaving it for the CLI to recreate.');
272
- return 0;
273
- case 'repaired':
274
- log(
275
- `Repaired ~/.claude.json (discarded ${res.discardedBytes} trailing byte(s)). ` +
276
- `Backup: ${path.basename(res.backupPath)}`,
277
- );
278
- return 0;
279
- case 'unrepairable':
280
- warn(
281
- `~/.claude.json is corrupt and could NOT be safely repaired (${res.reason}). ` +
282
- 'Left untouched — the Claude CLI may recreate it, or restore a backup manually.',
283
- );
284
- return 1;
285
- default:
286
- warn(`${res.status}: ${res.message ?? 'unknown error'}. ~/.claude.json left untouched.`);
287
- return 1;
288
- }
289
- }
290
-
291
- // Run only when invoked directly (not when imported by the test).
292
- const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
293
- if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) {
294
- process.exit(main());
295
- }
1
+ /**
2
+ * repair-claude-json.mjs — deploy-time self-heal for a corrupt ~/.claude.json.
3
+ *
4
+ * WHY: the production deploy (start-server-production.ps1) force-kills the webui
5
+ * server, which kills every embedded-terminal `claude` process at once. On
6
+ * reload many `claude` CLIs start simultaneously and race on ~/.claude.json,
7
+ * which the CLI writes NON-atomically and WITHOUT a lock. The losing write can
8
+ * leave a valid, SHORTER object followed by the leftover tail of an older,
9
+ * LONGER version (a "truncation tail") — every running `claude` then fails with
10
+ * JSON.parse: "Unexpected non-whitespace character after JSON at position N".
11
+ *
12
+ * webui is a read-only observer of ~/.claude/ and never writes this file (that
13
+ * is the upstream CLI's bug to fix). But the deploy can heal the file once,
14
+ * up-front, before the embedded sessions restart — that is all this script does.
15
+ *
16
+ * Best effort: the deploy NEVER gates on this script's exit code. Server/build
17
+ * do not depend on ~/.claude.json.
18
+ *
19
+ * Run (CLI): node scripts/repair-claude-json.mjs · Test: node --test <this>.test.mjs
20
+ * Scan delimiters use char codes (not \u escapes) on purpose — literal
21
+ * backslash/brace escapes in source have bitten this repo before.
22
+ */
23
+
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const CH_OPEN = 0x7b; // {
30
+ const CH_CLOSE = 0x7d; // }
31
+ const CH_QUOTE = 0x22; // "
32
+ const CH_BACKSLASH = 0x5c; // (reverse solidus)
33
+
34
+ const MIN_TOP_LEVEL_KEYS = 3;
35
+ const DEFAULT_BACKUPS_KEPT = 10;
36
+
37
+ const PREFIX = '[repair-claude-json]';
38
+ const log = (msg) => process.stdout.write(`${PREFIX} ${msg}\n`);
39
+ const warn = (msg) => process.stderr.write(`${PREFIX} WARN: ${msg}\n`);
40
+
41
+ /**
42
+ * String/escape-aware scan for the first balanced top-level `{...}` object.
43
+ * Braces and quotes inside JSON string values (incl. escaped quotes) are
44
+ * ignored. Returns `{ prefix, tail }` or `null` when no balanced object exists.
45
+ *
46
+ * @param {string} text
47
+ * @returns {{ prefix: string, tail: string } | null}
48
+ */
49
+ export function findFirstBalancedObject(text) {
50
+ if (typeof text !== 'string') return null;
51
+ const start = text.indexOf(String.fromCharCode(CH_OPEN));
52
+ if (start < 0) return null;
53
+
54
+ let depth = 0;
55
+ let inString = false;
56
+ let escaped = false;
57
+
58
+ for (let i = start; i < text.length; i++) {
59
+ const c = text.charCodeAt(i);
60
+ if (inString) {
61
+ if (escaped) escaped = false;
62
+ else if (c === CH_BACKSLASH) escaped = true;
63
+ else if (c === CH_QUOTE) inString = false;
64
+ continue;
65
+ }
66
+ if (c === CH_QUOTE) {
67
+ inString = true;
68
+ } else if (c === CH_OPEN) {
69
+ depth++;
70
+ } else if (c === CH_CLOSE) {
71
+ depth--;
72
+ if (depth === 0) {
73
+ return { prefix: text.slice(start, i + 1), tail: text.slice(i + 1) };
74
+ }
75
+ }
76
+ }
77
+ return null; // never returned to depth 0 — unbalanced
78
+ }
79
+
80
+ /**
81
+ * Sanity guard: a repaired candidate is only trusted to overwrite the live file
82
+ * when it parses AND looks like a real claude config (object, >= 3 top-level
83
+ * keys, and carries "projects"). Stops us from clobbering the file with a tiny
84
+ * coincidental `{...}` that happened to be balanced.
85
+ *
86
+ * @param {unknown} obj
87
+ * @returns {boolean}
88
+ */
89
+ export function isPlausibleClaudeConfig(obj) {
90
+ return (
91
+ obj != null &&
92
+ typeof obj === 'object' &&
93
+ !Array.isArray(obj) &&
94
+ Object.keys(obj).length >= MIN_TOP_LEVEL_KEYS &&
95
+ Object.prototype.hasOwnProperty.call(obj, 'projects')
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Classify + (logically) repair raw text. Pure: no I/O.
101
+ *
102
+ * @param {string|null|undefined} text
103
+ * @returns {{ status: 'valid'|'empty'|'repaired'|'unrepairable',
104
+ * repaired?: string, discardedTail?: string, reason?: string }}
105
+ */
106
+ export function repairJsonText(text) {
107
+ if (text == null || text.trim().length === 0) return { status: 'empty' };
108
+ try {
109
+ JSON.parse(text);
110
+ return { status: 'valid' };
111
+ } catch {
112
+ /* fall through to repair */
113
+ }
114
+ const found = findFirstBalancedObject(text);
115
+ if (!found) return { status: 'unrepairable', reason: 'no_balanced_object' };
116
+
117
+ let candidate;
118
+ try {
119
+ candidate = JSON.parse(found.prefix);
120
+ } catch {
121
+ return { status: 'unrepairable', reason: 'prefix_not_valid_json' };
122
+ }
123
+ if (!isPlausibleClaudeConfig(candidate)) {
124
+ return { status: 'unrepairable', reason: 'implausible_structure' };
125
+ }
126
+ // Fail-safe ambiguity guard. A real truncation tail is a FRAGMENT (begins
127
+ // mid-value) — it never starts a second complete config, and inner braces in
128
+ // a fragment (`"tipsHistory":{...}`) are not plausible configs (no top-level
129
+ // "projects"). But IF the tail itself begins another plausible config we have
130
+ // two candidates and cannot know which is live — refuse rather than risk
131
+ // overwriting the good config with the wrong one. (Overwriting a global
132
+ // config must fail safe even though the diagnosed corruption never does this.)
133
+ const tailObj = findFirstBalancedObject(found.tail);
134
+ if (tailObj) {
135
+ let tailCandidate;
136
+ try {
137
+ tailCandidate = JSON.parse(tailObj.prefix);
138
+ } catch {
139
+ tailCandidate = undefined;
140
+ }
141
+ if (isPlausibleClaudeConfig(tailCandidate)) {
142
+ return { status: 'unrepairable', reason: 'multiple_candidates' };
143
+ }
144
+ }
145
+ return { status: 'repaired', repaired: found.prefix, discardedTail: found.tail };
146
+ }
147
+
148
+ /**
149
+ * Windows-safe filename timestamp (no `:` / `.`). FIXED-WIDTH by construction
150
+ * (`toISOString()` is always `YYYY-MM-DDTHH-MM-SS-sssZ`), so lexicographic sort
151
+ * === chronological — load-bearing for pruneBackups. A format-pinning test
152
+ * guards against a future variable-width change (e.g. epoch millis) that would
153
+ * silently mis-sort and prune the WRONG backups.
154
+ */
155
+ export function backupTimestamp() {
156
+ return new Date().toISOString().replace(/[:.]/g, '-');
157
+ }
158
+
159
+ /**
160
+ * Delete all but the newest `keep` `<base>.corrupt-<ts>.bak` files in `dir`.
161
+ * Best effort — unlink failures are swallowed. Returns the deleted names.
162
+ *
163
+ * @param {string} dir
164
+ * @param {string} base // e.g. ".claude.json"
165
+ * @param {number} keep
166
+ * @param {string} [exceptName] // a backup name to always keep (e.g. the one
167
+ * // just created this run — protects it from a
168
+ * // CONCURRENT run's prune deleting it).
169
+ * @returns {string[]}
170
+ */
171
+ export function pruneBackups(dir, base, keep = DEFAULT_BACKUPS_KEPT, exceptName) {
172
+ let entries;
173
+ try {
174
+ entries = fs.readdirSync(dir);
175
+ } catch {
176
+ return [];
177
+ }
178
+ const head = `${base}.corrupt-`;
179
+ const baks = entries
180
+ .filter((n) => n.startsWith(head) && n.endsWith('.bak') && n !== exceptName)
181
+ .sort(); // ISO timestamp → lexicographic === chronological
182
+ const excess = baks.slice(0, Math.max(0, baks.length - keep));
183
+ for (const name of excess) {
184
+ try {
185
+ fs.unlinkSync(path.join(dir, name));
186
+ } catch {
187
+ /* best effort */
188
+ }
189
+ }
190
+ return excess;
191
+ }
192
+
193
+ /**
194
+ * Repair the file at `targetPath` in place (with backup + atomic write).
195
+ * The target path is the SOLE I/O boundary — pass a temp path to test it.
196
+ *
197
+ * @param {string} targetPath
198
+ * @param {{ keep?: number }} [options]
199
+ * @returns {{ action: string, status: string, reason?: string,
200
+ * backupPath?: string, discardedBytes?: number, message?: string }}
201
+ */
202
+ export function repairFile(targetPath, options = {}) {
203
+ const keep = options.keep ?? DEFAULT_BACKUPS_KEPT;
204
+ if (!fs.existsSync(targetPath)) {
205
+ return { action: 'noop', status: 'absent' };
206
+ }
207
+
208
+ let raw;
209
+ try {
210
+ raw = fs.readFileSync(targetPath, 'utf8');
211
+ } catch (err) {
212
+ return { action: 'error', status: 'read_failed', message: String(err && err.message) };
213
+ }
214
+
215
+ const result = repairJsonText(raw);
216
+ if (result.status === 'valid') return { action: 'noop', status: 'valid' };
217
+ if (result.status === 'empty') return { action: 'noop', status: 'empty' };
218
+ if (result.status === 'unrepairable') {
219
+ return { action: 'skip', status: 'unrepairable', reason: result.reason };
220
+ }
221
+
222
+ const dir = path.dirname(targetPath);
223
+ const base = path.basename(targetPath);
224
+ const backupPath = path.join(dir, `${base}.corrupt-${backupTimestamp()}.bak`);
225
+ const tmpPath = path.join(dir, `${base}.tmp-${process.pid}`);
226
+ try {
227
+ fs.copyFileSync(targetPath, backupPath); // preserve the corrupt original bytes
228
+ fs.writeFileSync(tmpPath, result.repaired);
229
+ fs.renameSync(tmpPath, targetPath); // atomic on same volume
230
+ } catch (err) {
231
+ // The original file is untouched, so the backup we just made is a redundant
232
+ // copy of the still-corrupt file — clean both up so a failed repair (e.g. a
233
+ // reader holding the file open on Windows) never litters the homedir with
234
+ // an orphan temp + un-pruned backup.
235
+ for (const p of [tmpPath, backupPath]) {
236
+ try {
237
+ fs.unlinkSync(p);
238
+ } catch {
239
+ /* best effort */
240
+ }
241
+ }
242
+ return { action: 'error', status: 'write_failed', message: String(err && err.message) };
243
+ }
244
+ pruneBackups(dir, base, keep, path.basename(backupPath));
245
+ return {
246
+ action: 'repaired',
247
+ status: 'repaired',
248
+ backupPath,
249
+ discardedBytes: Buffer.byteLength(result.discardedTail, 'utf8'),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * CLI entry point. Returns a process exit code (0 = healthy/healed/benign,
255
+ * 1 = corrupt-but-not-safely-repairable or an I/O error). The deploy must NOT
256
+ * gate on this code.
257
+ *
258
+ * @returns {number}
259
+ */
260
+ export function main() {
261
+ const targetPath = path.join(os.homedir(), '.claude.json');
262
+ const res = repairFile(targetPath);
263
+ switch (res.status) {
264
+ case 'absent':
265
+ log('~/.claude.json not found — nothing to repair.');
266
+ return 0;
267
+ case 'valid':
268
+ log('~/.claude.json is valid — no repair needed.');
269
+ return 0;
270
+ case 'empty':
271
+ log('~/.claude.json is empty — leaving it for the CLI to recreate.');
272
+ return 0;
273
+ case 'repaired':
274
+ log(
275
+ `Repaired ~/.claude.json (discarded ${res.discardedBytes} trailing byte(s)). ` +
276
+ `Backup: ${path.basename(res.backupPath)}`,
277
+ );
278
+ return 0;
279
+ case 'unrepairable':
280
+ warn(
281
+ `~/.claude.json is corrupt and could NOT be safely repaired (${res.reason}). ` +
282
+ 'Left untouched — the Claude CLI may recreate it, or restore a backup manually.',
283
+ );
284
+ return 1;
285
+ default:
286
+ warn(`${res.status}: ${res.message ?? 'unknown error'}. ~/.claude.json left untouched.`);
287
+ return 1;
288
+ }
289
+ }
290
+
291
+ // Run only when invoked directly (not when imported by the test).
292
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
293
+ if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) {
294
+ process.exit(main());
295
+ }