@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.
- package/client/dist/assets/{EmbeddedTerminal-5QvKe3_a.js → EmbeddedTerminal-D3wpPNZP.js} +1 -1
- package/client/dist/assets/{abnfDiagram-VRR7QNED-CXi9HegQ.js → abnfDiagram-VRR7QNED-BkLIltB3.js} +1 -1
- package/client/dist/assets/{arc-DLh4GiqR.js → arc-Sw-Gh0nI.js} +1 -1
- package/client/dist/assets/{architectureDiagram-ZJ3FMSHR-DUypQ8dQ.js → architectureDiagram-ZJ3FMSHR-rdxUi9Ks.js} +1 -1
- package/client/dist/assets/{blockDiagram-677ZJIJ3-D2cVMKJG.js → blockDiagram-677ZJIJ3-Ch_9dj8a.js} +1 -1
- package/client/dist/assets/{c4Diagram-LMCZKHZV-DtruBGJw.js → c4Diagram-LMCZKHZV-CkFlO65x.js} +1 -1
- package/client/dist/assets/channel-BYPbLjRl.js +1 -0
- package/client/dist/assets/{chunk-2Q5K7J3B-CAfQkFw2.js → chunk-2Q5K7J3B-CypX83xu.js} +1 -1
- package/client/dist/assets/{chunk-32BRIVSS-CPWXoLnQ.js → chunk-32BRIVSS-DxVLYGdl.js} +1 -1
- package/client/dist/assets/{chunk-5VM5RSS4-xVc3oppQ.js → chunk-5VM5RSS4-Cxk1evhx.js} +1 -1
- package/client/dist/assets/{chunk-EX3LRPZG-CUpl6fRR.js → chunk-EX3LRPZG-n9q5S4yJ.js} +1 -1
- package/client/dist/assets/{chunk-JWPE2WC7-NejUp56x.js → chunk-JWPE2WC7-jeL4xvir.js} +1 -1
- package/client/dist/assets/{chunk-MOJQB5TN-C9-2lLg0.js → chunk-MOJQB5TN-DDSHN0cj.js} +1 -1
- package/client/dist/assets/{chunk-RYQCIY6F-BAPtwABy.js → chunk-RYQCIY6F-C0bo383g.js} +1 -1
- package/client/dist/assets/{chunk-V7JOEXUC-Dwzihw26.js → chunk-V7JOEXUC-C0jDXXAU.js} +1 -1
- package/client/dist/assets/{chunk-VR4S4FIN-9-iArFJM.js → chunk-VR4S4FIN-h-Rr4Pn2.js} +1 -1
- package/client/dist/assets/{chunk-XXDRQBXY-DiWHOrqe.js → chunk-XXDRQBXY-BexSo0-s.js} +1 -1
- package/client/dist/assets/classDiagram-OUVF2IWQ-CsFIeTju.js +1 -0
- package/client/dist/assets/classDiagram-v2-EOCWNBFH-CsFIeTju.js +1 -0
- package/client/dist/assets/{cose-bilkent-JH36ORCC-Cp04EOcH.js → cose-bilkent-JH36ORCC-BQXzaioj.js} +1 -1
- package/client/dist/assets/{cynefin-VYW2F7L2-D--GN1v8.js → cynefin-VYW2F7L2-BJIofhfe.js} +1 -1
- package/client/dist/assets/{cynefinDiagram-TSTJHNR4-BGH0o_qp.js → cynefinDiagram-TSTJHNR4-DmPqTM1T.js} +1 -1
- package/client/dist/assets/{dagre-VKFMJZFB-DSKkIKlj.js → dagre-VKFMJZFB-1xljvw6X.js} +1 -1
- package/client/dist/assets/{diagram-FQU43EPY-Cs_C4lb-.js → diagram-FQU43EPY-iyL9muCG.js} +1 -1
- package/client/dist/assets/{diagram-G47NLZAW-oDtAA8O_.js → diagram-G47NLZAW-DREp2Th6.js} +1 -1
- package/client/dist/assets/{diagram-NH7WQ7WH-BiFMjqSH.js → diagram-NH7WQ7WH-h0X3Is2S.js} +1 -1
- package/client/dist/assets/{diagram-OA4YK3LP-Ccs3thCj.js → diagram-OA4YK3LP-Bj8YZRFJ.js} +1 -1
- package/client/dist/assets/{diagram-WEI45ONY-CdZNqgO3.js → diagram-WEI45ONY-BZOf805d.js} +1 -1
- package/client/dist/assets/{ebnfDiagram-CCIWWBDH-CapTA0VL.js → ebnfDiagram-CCIWWBDH-DO5JnXda.js} +1 -1
- package/client/dist/assets/{erDiagram-Q63AITRT-BcRNEP8s.js → erDiagram-Q63AITRT-DRgcqg1T.js} +1 -1
- package/client/dist/assets/{flowDiagram-23GEKE2U-CJ_zF7e_.js → flowDiagram-23GEKE2U-C3J9QmrW.js} +1 -1
- package/client/dist/assets/{ganttDiagram-NO4QXBWP-CnJdcg9A.js → ganttDiagram-NO4QXBWP-BIEl3Tkm.js} +1 -1
- package/client/dist/assets/{gitGraphDiagram-IHSO6WYX-YTUQ_ELk.js → gitGraphDiagram-IHSO6WYX-BNF0tFWC.js} +1 -1
- package/client/dist/assets/{index-irQhGNpn.js → index-BnTOrFHb.js} +203 -202
- package/client/dist/assets/index-CYPc6qxw.css +1 -0
- package/client/dist/assets/{infoDiagram-FWYZ7A6U-CLOP_ZWD.js → infoDiagram-FWYZ7A6U-DEyFxiyY.js} +1 -1
- package/client/dist/assets/{ishikawaDiagram-FXEZZL3T-aFTIzr7o.js → ishikawaDiagram-FXEZZL3T-B_ehlg3I.js} +1 -1
- package/client/dist/assets/{journeyDiagram-5HDEW3XC-CbPJB4xd.js → journeyDiagram-5HDEW3XC-Dp00FyJS.js} +1 -1
- package/client/dist/assets/{kanban-definition-HUTT4EX6-BoyhytIS.js → kanban-definition-HUTT4EX6-CTAf16q8.js} +1 -1
- package/client/dist/assets/{linear-1VXA-uBg.js → linear-9vNUgmYd.js} +1 -1
- package/client/dist/assets/{mermaid.core-D_lO2yK9.js → mermaid.core-A6tjaEVD.js} +4 -4
- package/client/dist/assets/{mindmap-definition-LN4V7U3C-iFK7LXMx.js → mindmap-definition-LN4V7U3C-DRW3bmCY.js} +1 -1
- package/client/dist/assets/{pegDiagram-2B236MQR-DNmcmfA-.js → pegDiagram-2B236MQR-DJFCjHp2.js} +1 -1
- package/client/dist/assets/{pieDiagram-ENE6RG2P-ekx83O5b.js → pieDiagram-ENE6RG2P-KRd-sGvC.js} +1 -1
- package/client/dist/assets/{quadrantDiagram-ABIIQ3AL-DayyD1l8.js → quadrantDiagram-ABIIQ3AL-9TLZE3Hu.js} +1 -1
- package/client/dist/assets/{railroadDiagram-RFXS5EU6-Bdgz7S6G.js → railroadDiagram-RFXS5EU6-y4tLtjXg.js} +1 -1
- package/client/dist/assets/{requirementDiagram-TGXJPOKE-szP3Muld.js → requirementDiagram-TGXJPOKE-B9EOQDQM.js} +1 -1
- package/client/dist/assets/{sankeyDiagram-HTMAVEWB-Ct3rzgUj.js → sankeyDiagram-HTMAVEWB-zfwiDzkT.js} +1 -1
- package/client/dist/assets/{sequenceDiagram-DBY2YBRQ-_69EfmOU.js → sequenceDiagram-DBY2YBRQ-DW1LWepL.js} +1 -1
- package/client/dist/assets/{sizeCapture-X5ZJPWSS-BSOU06J2.js → sizeCapture-X5ZJPWSS-BM81Mik0.js} +1 -1
- package/client/dist/assets/{stateDiagram-2N3HPSRC-HmOmFwQm.js → stateDiagram-2N3HPSRC-D0EMIw1w.js} +1 -1
- package/client/dist/assets/stateDiagram-v2-6OUMAXLB-B3E4_xxT.js +1 -0
- package/client/dist/assets/{swimlanes-5IMT3BWC-CVg_4oT7.js → swimlanes-5IMT3BWC-CKXNCX6M.js} +2 -2
- package/client/dist/assets/swimlanesDiagram-G3AALYLV-BegQBqnC.js +8 -0
- package/client/dist/assets/{timeline-definition-FHXFAJF6-CBBaDKXm.js → timeline-definition-FHXFAJF6-ByzIfvL5.js} +1 -1
- package/client/dist/assets/{vennDiagram-L72KCM5P-CBXiWPMc.js → vennDiagram-L72KCM5P-Xep38xBH.js} +1 -1
- package/client/dist/assets/{wardleyDiagram-EHGQE667-uhBCZoW0.js → wardleyDiagram-EHGQE667-DKWDpx6g.js} +1 -1
- package/client/dist/assets/{xychartDiagram-FW5EYKEG-xxE_wY5O.js → xychartDiagram-FW5EYKEG-BGcwZTnu.js} +1 -1
- package/client/dist/index.html +27 -27
- package/lib/claude-cli.mjs +173 -173
- package/lib/preflight.mjs +252 -252
- package/lib/probe-path.mjs +84 -84
- package/lib/server.mjs +369 -369
- package/lib/util.mjs +157 -157
- package/lib/win32-spawn.mjs +253 -253
- package/package.json +68 -68
- package/scripts/deploy-procs.mjs +208 -208
- package/scripts/deploy-swap.mjs +276 -276
- package/scripts/kill-targets.js +140 -140
- package/scripts/repair-claude-json.mjs +295 -295
- package/server/dist/config/default-actions.json +90 -90
- package/server/dist/core/mission-context/association.d.ts +46 -0
- package/server/dist/core/mission-context/association.d.ts.map +1 -1
- package/server/dist/core/mission-context/association.js +69 -0
- package/server/dist/core/mission-context/association.js.map +1 -1
- package/server/dist/core/mission-context/resolver-iterate.d.ts +6 -0
- package/server/dist/core/mission-context/resolver-iterate.d.ts.map +1 -0
- package/server/dist/core/mission-context/resolver-iterate.js +202 -0
- package/server/dist/core/mission-context/resolver-iterate.js.map +1 -0
- package/server/dist/core/mission-context/resolver-parts.d.ts +20 -0
- package/server/dist/core/mission-context/resolver-parts.d.ts.map +1 -1
- package/server/dist/core/mission-context/resolver-parts.js +92 -0
- package/server/dist/core/mission-context/resolver-parts.js.map +1 -1
- package/server/dist/core/mission-context/resolver.d.ts +1 -1
- package/server/dist/core/mission-context/resolver.d.ts.map +1 -1
- package/server/dist/core/mission-context/resolver.js +17 -199
- package/server/dist/core/mission-context/resolver.js.map +1 -1
- package/server/dist/core/mission-context/run-id-recovery.d.ts +21 -5
- package/server/dist/core/mission-context/run-id-recovery.d.ts.map +1 -1
- package/server/dist/core/mission-context/run-id-recovery.js +32 -5
- package/server/dist/core/mission-context/run-id-recovery.js.map +1 -1
- package/server/dist/core/mission-context/scenario.d.ts +18 -11
- package/server/dist/core/mission-context/scenario.d.ts.map +1 -1
- package/server/dist/core/mission-context/scenario.js +40 -3
- package/server/dist/core/mission-context/scenario.js.map +1 -1
- package/server/dist/core/path-guard.d.ts +1 -1
- package/server/dist/core/path-guard.d.ts.map +1 -1
- package/server/dist/core/path-guard.js +9 -2
- package/server/dist/core/path-guard.js.map +1 -1
- package/server/dist/core/shipslog-docs-reader.d.ts +4 -0
- package/server/dist/core/shipslog-docs-reader.d.ts.map +1 -0
- package/server/dist/core/shipslog-docs-reader.js +181 -0
- package/server/dist/core/shipslog-docs-reader.js.map +1 -0
- package/server/dist/core/shipslog-docs-types.d.ts +15 -0
- package/server/dist/core/shipslog-docs-types.d.ts.map +1 -0
- package/server/dist/core/shipslog-docs-types.js +14 -0
- package/server/dist/core/shipslog-docs-types.js.map +1 -0
- package/server/dist/external/design-review/serve.js +27 -27
- package/server/dist/external/mission-context/association-write.d.ts +22 -0
- package/server/dist/external/mission-context/association-write.d.ts.map +1 -0
- package/server/dist/external/mission-context/association-write.js +90 -0
- package/server/dist/external/mission-context/association-write.js.map +1 -0
- package/server/dist/external/mission-context/routes.d.ts.map +1 -1
- package/server/dist/external/mission-context/routes.js +18 -31
- package/server/dist/external/mission-context/routes.js.map +1 -1
- package/server/dist/external/routes.d.ts.map +1 -1
- package/server/dist/external/routes.js +9 -6
- package/server/dist/external/routes.js.map +1 -1
- package/server/dist/external/shipslog-docs/routes.d.ts +10 -0
- package/server/dist/external/shipslog-docs/routes.d.ts.map +1 -0
- package/server/dist/external/shipslog-docs/routes.js +41 -0
- package/server/dist/external/shipslog-docs/routes.js.map +1 -0
- package/server/package.json +1 -1
- package/server/profiles/PROFILE_SCHEMA_VERSION +1 -1
- package/server/profiles/README.md +27 -27
- package/server/profiles/python-plugin-monorepo.json +35 -35
- package/server/profiles/supabase-nextjs.json +253 -253
- package/server/profiles/vite-hono.json +92 -92
- package/client/dist/assets/channel-C_k-9jsa.js +0 -1
- package/client/dist/assets/classDiagram-OUVF2IWQ-CsEPq9Ac.js +0 -1
- package/client/dist/assets/classDiagram-v2-EOCWNBFH-CsEPq9Ac.js +0 -1
- package/client/dist/assets/index-6nZ1bt0a.css +0 -1
- package/client/dist/assets/stateDiagram-v2-6OUMAXLB-BW8H33dv.js +0 -1
- 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
|
+
}
|