cchost 0.4.4 → 0.6.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/CHANGELOG.md +112 -0
- package/README.md +114 -44
- package/bin/cc.js +6 -1
- package/lib/actions.js +290 -0
- package/lib/banner.js +3 -1
- package/lib/ccforward.js +77 -0
- package/lib/cli.js +363 -104
- package/lib/color.js +3 -1
- package/lib/copy.js +43 -41
- package/lib/doctor.js +211 -68
- package/lib/env.js +65 -9
- package/lib/profiles.js +47 -14
- package/lib/share.js +124 -6
- package/lib/spawn.js +2 -1
- package/lib/ui/login.html +117 -0
- package/lib/ui/page.html +1171 -0
- package/lib/ui/server.js +384 -0
- package/lib/ui/tokens.css +80 -0
- package/package.json +3 -2
package/lib/doctor.js
CHANGED
|
@@ -6,16 +6,20 @@ const os = require('os');
|
|
|
6
6
|
|
|
7
7
|
const profiles = require('./profiles');
|
|
8
8
|
const share = require('./share');
|
|
9
|
+
const envLib = require('./env');
|
|
9
10
|
const { findClaude, getClaudeVersion, compareVersion, MIN_VERSION } = require('./spawn');
|
|
10
11
|
const color = require('./color');
|
|
11
12
|
|
|
12
13
|
const GROUP_ORDER = ['Environment', 'Storage', 'Platform', 'Sanity'];
|
|
13
14
|
|
|
15
|
+
// Default sink for fix progress. JSON mode passes a stderr writer instead.
|
|
16
|
+
const stdoutWrite = (text) => process.stdout.write(text);
|
|
17
|
+
|
|
14
18
|
// fix: { safe, description, cmd, apply }
|
|
15
19
|
// safe=true → auto-applied by --fix
|
|
16
|
-
// safe=false → needs --fix --force, prompted y/
|
|
20
|
+
// safe=false → needs --fix --force, prompted y/N
|
|
17
21
|
// cmd → printed when fix cannot be auto-applied
|
|
18
|
-
// apply → async fn to execute
|
|
22
|
+
// apply → async fn to execute, receives the progress writer
|
|
19
23
|
|
|
20
24
|
function mk(group, name, status, message, fix) {
|
|
21
25
|
return { group, name, status, message, fix: fix || null };
|
|
@@ -60,23 +64,21 @@ function checkClaudeVersion() {
|
|
|
60
64
|
});
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
const CONFLICT_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
|
64
|
-
|
|
65
67
|
function checkConflictingEnv() {
|
|
66
|
-
const set =
|
|
68
|
+
const set = envLib.authEnvConflicts();
|
|
67
69
|
if (set.length === 0) return mk('Environment', 'conflicting env vars', 'ok', 'none set');
|
|
68
70
|
const cmd = process.platform === 'win32'
|
|
69
71
|
? set.map(k => `Remove-Item Env:${k}`).join('; ')
|
|
70
72
|
: set.map(k => `unset ${k}`).join('; ');
|
|
71
73
|
return mk('Environment', 'conflicting env vars', 'warn', `set: ${set.join(', ')}`, {
|
|
72
|
-
safe: false, description: 'Unset to avoid auth interference with
|
|
74
|
+
safe: false, description: 'Unset to avoid auth interference with cchost use', cmd, apply: null,
|
|
73
75
|
});
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
function findAllOnPath(name) {
|
|
78
|
+
function findAllOnPath(name, platform = process.platform) {
|
|
77
79
|
const PATH = process.env.PATH || process.env.Path || '';
|
|
78
|
-
const sep =
|
|
79
|
-
const exts =
|
|
80
|
+
const sep = platform === 'win32' ? ';' : ':';
|
|
81
|
+
const exts = platform === 'win32'
|
|
80
82
|
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map(s => s.trim()).filter(Boolean)
|
|
81
83
|
: [''];
|
|
82
84
|
const found = [];
|
|
@@ -86,7 +88,7 @@ function findAllOnPath(name) {
|
|
|
86
88
|
try {
|
|
87
89
|
const full = path.join(dir, name + ext);
|
|
88
90
|
if (fs.statSync(full).isFile()) {
|
|
89
|
-
const key =
|
|
91
|
+
const key = platform === 'win32' ? full.toLowerCase() : full;
|
|
90
92
|
if (!seen.has(key)) { seen.add(key); found.push(full); }
|
|
91
93
|
break;
|
|
92
94
|
}
|
|
@@ -96,13 +98,94 @@ function findAllOnPath(name) {
|
|
|
96
98
|
return found;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return
|
|
101
|
+
// Resolve a path to the object it points at.
|
|
102
|
+
function pathIdentity(p) {
|
|
103
|
+
try {
|
|
104
|
+
return fs.realpathSync(p);
|
|
105
|
+
} catch {
|
|
106
|
+
return path.resolve(p);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// True only when `p` really is the cchost CLI. A bare `cc` may be a compiler
|
|
111
|
+
// (clang, gcc, ccache, conda) and must not be mistaken for the legacy alias.
|
|
112
|
+
function isCchostBinary(p) {
|
|
113
|
+
const target = pathIdentity(p);
|
|
114
|
+
if (/[\\/]cchost[\\/]bin[\\/]cc\.js$/i.test(target)) return true;
|
|
115
|
+
try {
|
|
116
|
+
const st = fs.statSync(target);
|
|
117
|
+
if (!st.isFile() || st.size > 256 * 1024) return false;
|
|
118
|
+
const text = fs.readFileSync(target, 'utf8');
|
|
119
|
+
return /cchost/i.test(text) && /(node_modules|cc\.js)/i.test(text);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Windows npm writes separate cc.cmd and cchost.cmd shims. Extract the
|
|
126
|
+
// bin/cc.js they invoke so shims from the same install share an identity while
|
|
127
|
+
// a stale shim pointing elsewhere still counts as a separate install.
|
|
128
|
+
function shimTarget(p) {
|
|
129
|
+
if (!/\.(cmd|bat|ps1)$/i.test(p)) return null;
|
|
130
|
+
try {
|
|
131
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
132
|
+
const m = text.match(
|
|
133
|
+
/(?:[A-Za-z]:[\\/]|%dp0%[\\/]|%~dp0[\\/]|(?:\.\.?[\\/]))[^"\r\n]*?node_modules[\\/]+cchost[\\/]+bin[\\/]+cc\.js/i,
|
|
134
|
+
);
|
|
135
|
+
if (!m) return null;
|
|
136
|
+
const raw = m[0];
|
|
137
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
138
|
+
return raw.replace(/[\\/]/g, '\\').toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
const relative = raw
|
|
141
|
+
.replace(/^%dp0%|^%~dp0/i, '')
|
|
142
|
+
.replace(/^[\\/]+/, '')
|
|
143
|
+
.replace(/[\\/]/g, path.sep);
|
|
144
|
+
return path.resolve(path.dirname(p), relative).replace(/[\\/]/g, '\\').toLowerCase();
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shadowIdentity(p, platform = process.platform) {
|
|
151
|
+
if (platform === 'win32') {
|
|
152
|
+
const target = shimTarget(p);
|
|
153
|
+
if (target) return `shim:${target}`;
|
|
154
|
+
}
|
|
155
|
+
return pathIdentity(p);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function checkCcShadow(platform = process.platform) {
|
|
159
|
+
const found = [];
|
|
160
|
+
for (const name of ['cchost', 'cc']) {
|
|
161
|
+
for (const p of findAllOnPath(name, platform)) {
|
|
162
|
+
if (name === 'cc' && !isCchostBinary(p)) continue;
|
|
163
|
+
found.push({ name, path: p });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const unique = [];
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
for (const entry of found) {
|
|
170
|
+
const key = shadowIdentity(entry.path, platform);
|
|
171
|
+
if (seen.has(key)) continue;
|
|
172
|
+
seen.add(key);
|
|
173
|
+
unique.push(entry);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (unique.length <= 1) {
|
|
177
|
+
return mk(
|
|
178
|
+
'Environment',
|
|
179
|
+
'cchost on PATH',
|
|
180
|
+
'ok',
|
|
181
|
+
unique.length ? unique[0].path : 'not found yet (expected after npm install -g cchost)',
|
|
182
|
+
);
|
|
103
183
|
}
|
|
104
|
-
return mk('Environment', '
|
|
105
|
-
safe: false,
|
|
184
|
+
return mk('Environment', 'cchost on PATH', 'warn', `multiple: ${unique.map((e) => e.path).join(', ')}`, {
|
|
185
|
+
safe: false,
|
|
186
|
+
description: 'Remove duplicate cchost/cc entries to avoid version confusion',
|
|
187
|
+
cmd: null,
|
|
188
|
+
apply: null,
|
|
106
189
|
});
|
|
107
190
|
}
|
|
108
191
|
|
|
@@ -115,9 +198,9 @@ function checkProfilesDirWritable() {
|
|
|
115
198
|
safe: true,
|
|
116
199
|
description: `Create ${base}`,
|
|
117
200
|
cmd: null,
|
|
118
|
-
apply: async () => {
|
|
201
|
+
apply: async (write = stdoutWrite) => {
|
|
119
202
|
fs.mkdirSync(base, { recursive: true });
|
|
120
|
-
|
|
203
|
+
write(` Created ${base}\n`);
|
|
121
204
|
},
|
|
122
205
|
});
|
|
123
206
|
}
|
|
@@ -146,11 +229,11 @@ function checkProfileClaudeJson(prof) {
|
|
|
146
229
|
safe: false,
|
|
147
230
|
description: `Back up ${path.basename(p)} and reset to {}`,
|
|
148
231
|
cmd: null,
|
|
149
|
-
apply: async () => {
|
|
232
|
+
apply: async (write = stdoutWrite) => {
|
|
150
233
|
const bak = `${p}.bak.${Date.now()}`;
|
|
151
234
|
fs.renameSync(p, bak);
|
|
152
235
|
fs.writeFileSync(p, '{}');
|
|
153
|
-
|
|
236
|
+
write(` Backed up to ${path.basename(bak)}, reset to {}\n`);
|
|
154
237
|
},
|
|
155
238
|
});
|
|
156
239
|
}
|
|
@@ -167,6 +250,16 @@ function checkProfileLinks(prof) {
|
|
|
167
250
|
let stat;
|
|
168
251
|
try { stat = fs.lstatSync(pp); } catch { stat = null; }
|
|
169
252
|
|
|
253
|
+
// Private on purpose (add --private / unlink): never offer to re-share it.
|
|
254
|
+
if (
|
|
255
|
+
(!stat || !stat.isSymbolicLink()) &&
|
|
256
|
+
!share.isSharedResource(prof.dir, resource) &&
|
|
257
|
+
share.isMarkedPrivate(prof.dir, resource)
|
|
258
|
+
) {
|
|
259
|
+
results.push(mk('Storage', `"${prof.name}" ${label}`, 'ok', 'private by choice'));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
170
263
|
if (!stat) {
|
|
171
264
|
// Resource missing entirely — safe to auto-create the link.
|
|
172
265
|
results.push(
|
|
@@ -174,12 +267,12 @@ function checkProfileLinks(prof) {
|
|
|
174
267
|
safe: true,
|
|
175
268
|
description: `Create link to ~/.claude/${resource}`,
|
|
176
269
|
cmd: null,
|
|
177
|
-
apply: async () => {
|
|
270
|
+
apply: async (write = stdoutWrite) => {
|
|
178
271
|
const r = share.shareLink(prof.name, subsetForResource(resource))[0];
|
|
179
272
|
if (r && r.status === 'linked') {
|
|
180
|
-
|
|
273
|
+
write(` Linked: ${pp} -> ${expected}\n`);
|
|
181
274
|
} else if (r) {
|
|
182
|
-
|
|
275
|
+
write(` Could not link ${pp}: ${r.message || r.status}\n`);
|
|
183
276
|
}
|
|
184
277
|
},
|
|
185
278
|
}),
|
|
@@ -199,13 +292,13 @@ function checkProfileLinks(prof) {
|
|
|
199
292
|
safe: true,
|
|
200
293
|
description: `Recreate link to ${expected}`,
|
|
201
294
|
cmd: null,
|
|
202
|
-
apply: async () => {
|
|
295
|
+
apply: async (write = stdoutWrite) => {
|
|
203
296
|
share.removeLink(pp);
|
|
204
297
|
const r = share.shareLink(prof.name, subsetForResource(resource))[0];
|
|
205
298
|
if (r && (r.status === 'linked' || r.status === 'already-linked')) {
|
|
206
|
-
|
|
299
|
+
write(` Recreated: ${pp} -> ${expected}\n`);
|
|
207
300
|
} else if (r) {
|
|
208
|
-
|
|
301
|
+
write(` Could not recreate ${pp}: ${r.message || r.status}\n`);
|
|
209
302
|
}
|
|
210
303
|
},
|
|
211
304
|
}),
|
|
@@ -233,16 +326,16 @@ function checkProfileLinks(prof) {
|
|
|
233
326
|
safe: true,
|
|
234
327
|
description: `Replace with link to ~/.claude/${resource}`,
|
|
235
328
|
cmd: null,
|
|
236
|
-
apply: async () => {
|
|
329
|
+
apply: async (write = stdoutWrite) => {
|
|
237
330
|
try {
|
|
238
331
|
if (isFile) fs.unlinkSync(pp);
|
|
239
332
|
else fs.rmdirSync(pp);
|
|
240
333
|
} catch { /* fall through */ }
|
|
241
334
|
const r = share.shareLink(prof.name, subsetForResource(resource))[0];
|
|
242
335
|
if (r && r.status === 'linked') {
|
|
243
|
-
|
|
336
|
+
write(` Linked: ${pp} -> ${expected}\n`);
|
|
244
337
|
} else if (r) {
|
|
245
|
-
|
|
338
|
+
write(` Could not link ${pp}: ${r.message || r.status}\n`);
|
|
246
339
|
}
|
|
247
340
|
},
|
|
248
341
|
}),
|
|
@@ -253,9 +346,9 @@ function checkProfileLinks(prof) {
|
|
|
253
346
|
results.push(
|
|
254
347
|
mk('Storage', `"${prof.name}" ${label}`, 'warn', 'private (has content) — not linked', {
|
|
255
348
|
safe: false,
|
|
256
|
-
description: 'Move or remove the private content, then re-run
|
|
257
|
-
cmd: `
|
|
258
|
-
`or manually move ${pp} aside, then:
|
|
349
|
+
description: 'Move or remove the private content, then re-run cchost link',
|
|
350
|
+
cmd: `cchost copy default ${prof.name} # if you want to merge it the other way; ` +
|
|
351
|
+
`or manually move ${pp} aside, then: cchost link ${prof.name}`,
|
|
259
352
|
apply: null,
|
|
260
353
|
}),
|
|
261
354
|
);
|
|
@@ -315,9 +408,9 @@ function checkLegacyPool() {
|
|
|
315
408
|
safe: true,
|
|
316
409
|
description: 'Remove the empty legacy pool',
|
|
317
410
|
cmd: null,
|
|
318
|
-
apply: async () => {
|
|
319
|
-
try { fs.rmdirSync(legacy);
|
|
320
|
-
|
|
411
|
+
apply: async (write = stdoutWrite) => {
|
|
412
|
+
try { fs.rmdirSync(legacy); write(` Removed ${legacy}\n`); } catch (e) {
|
|
413
|
+
write(` Could not remove ${legacy}: ${e.message}\n`);
|
|
321
414
|
}
|
|
322
415
|
},
|
|
323
416
|
});
|
|
@@ -326,9 +419,9 @@ function checkLegacyPool() {
|
|
|
326
419
|
safe: true,
|
|
327
420
|
description: 'Move legacy pool contents into ~/.claude/ and re-point profile symlinks',
|
|
328
421
|
cmd: null,
|
|
329
|
-
apply: async () => {
|
|
330
|
-
const result = share.migrateLegacyPool({ log: (line) =>
|
|
331
|
-
|
|
422
|
+
apply: async (write = stdoutWrite) => {
|
|
423
|
+
const result = share.migrateLegacyPool({ log: (line) => write(` ${line}\n`) });
|
|
424
|
+
write(
|
|
332
425
|
` Done: ${result.moves.length} item(s) moved, ${result.relinked.length} link(s) repointed.\n`,
|
|
333
426
|
);
|
|
334
427
|
},
|
|
@@ -377,16 +470,16 @@ function checkDamagedSessionFilenames() {
|
|
|
377
470
|
safe: true,
|
|
378
471
|
description: 'Restore <uuid>.jsonl filenames (reissue a fresh UUID when canonical is taken)',
|
|
379
472
|
cmd: null,
|
|
380
|
-
apply: async () => {
|
|
473
|
+
apply: async (write = stdoutWrite) => {
|
|
381
474
|
const result = share.repairProjectFilenames({
|
|
382
|
-
log: (line) =>
|
|
475
|
+
log: (line) => write(`${line}\n`),
|
|
383
476
|
});
|
|
384
|
-
|
|
477
|
+
write(
|
|
385
478
|
` Done: ${result.repaired.length} filename${result.repaired.length === 1 ? '' : 's'} restored.\n`,
|
|
386
479
|
);
|
|
387
480
|
if (result.failures.length) {
|
|
388
481
|
for (const f of result.failures) {
|
|
389
|
-
|
|
482
|
+
write(` Failure (${f.kind}): ${f.folder ? f.folder + '/' : ''}${f.name || ''} ${f.message}\n`);
|
|
390
483
|
}
|
|
391
484
|
}
|
|
392
485
|
},
|
|
@@ -419,16 +512,16 @@ function checkLegacyProjectFolders() {
|
|
|
419
512
|
safe: true,
|
|
420
513
|
description: 'Merge each legacy projects/ folder into its canonical sibling',
|
|
421
514
|
cmd: null,
|
|
422
|
-
apply: async () => {
|
|
515
|
+
apply: async (write = stdoutWrite) => {
|
|
423
516
|
const result = share.healProjectsLegacy({
|
|
424
|
-
log: (line) =>
|
|
517
|
+
log: (line) => write(` ${line}\n`),
|
|
425
518
|
});
|
|
426
|
-
|
|
519
|
+
write(
|
|
427
520
|
` Done: ${result.healed.length} folder${result.healed.length === 1 ? '' : 's'} healed.\n`,
|
|
428
521
|
);
|
|
429
522
|
if (result.failures.length) {
|
|
430
523
|
for (const f of result.failures) {
|
|
431
|
-
|
|
524
|
+
write(` Failure (${f.kind}): ${f.name || ''} ${f.message}\n`);
|
|
432
525
|
}
|
|
433
526
|
}
|
|
434
527
|
},
|
|
@@ -453,7 +546,7 @@ function checkWindowsAppsCc() {
|
|
|
453
546
|
if (process.platform !== 'win32') return null;
|
|
454
547
|
const p = path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'cc.cmd');
|
|
455
548
|
if (!fs.existsSync(p)) return mk('Platform', 'WindowsApps cc.cmd', 'ok', 'not present');
|
|
456
|
-
return mk('Platform', 'WindowsApps cc.cmd', 'warn', `${p} may shadow npm-installed
|
|
549
|
+
return mk('Platform', 'WindowsApps cc.cmd', 'warn', `${p} may shadow npm-installed cchost`, {
|
|
457
550
|
safe: false, description: 'Delete the stale stub', cmd: `del "${p}"`, apply: null,
|
|
458
551
|
});
|
|
459
552
|
}
|
|
@@ -462,7 +555,7 @@ function checkStaleBinCc() {
|
|
|
462
555
|
if (process.platform !== 'win32') return null;
|
|
463
556
|
const p = path.join(os.homedir(), 'bin', 'cc');
|
|
464
557
|
if (!fs.existsSync(p)) return null;
|
|
465
|
-
return mk('Platform', '~/bin/cc', 'warn', `${p} may be an old version shadowing npm-installed
|
|
558
|
+
return mk('Platform', '~/bin/cc', 'warn', `${p} may be an old version shadowing npm-installed cchost`, {
|
|
466
559
|
safe: false, description: 'Remove the old script', cmd: `Remove-Item "${p}"`, apply: null,
|
|
467
560
|
});
|
|
468
561
|
}
|
|
@@ -476,6 +569,31 @@ function checkCygpath() {
|
|
|
476
569
|
});
|
|
477
570
|
}
|
|
478
571
|
|
|
572
|
+
// macOS keeps OAuth tokens in the login Keychain rather than in the profile
|
|
573
|
+
// directory. Current Claude Code versions namespace the entry by the exact
|
|
574
|
+
// CLAUDE_CONFIG_DIR path, so profiles get separate logins; renaming or moving
|
|
575
|
+
// a profile changes the path and requires a fresh login.
|
|
576
|
+
function checkMacKeychain(platform = process.platform) {
|
|
577
|
+
if (platform !== 'darwin') return null;
|
|
578
|
+
let profilesList = [];
|
|
579
|
+
try {
|
|
580
|
+
profilesList = profiles.listProfiles(platform);
|
|
581
|
+
} catch {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const keychainProfiles = profilesList.filter(
|
|
585
|
+
(p) => !envLib.hasEnvConfig(p.dir) && p.credentialSource === 'keychain',
|
|
586
|
+
);
|
|
587
|
+
// Nothing to explain until some profile actually logs in through the Keychain.
|
|
588
|
+
if (keychainProfiles.length === 0) return null;
|
|
589
|
+
return mk(
|
|
590
|
+
'Platform',
|
|
591
|
+
'macOS credentials (Keychain)',
|
|
592
|
+
'ok',
|
|
593
|
+
`${keychainProfiles.length} profile(s) with a Keychain login; entries are namespaced by CLAUDE_CONFIG_DIR path (renaming or moving a profile requires a new login)`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
479
597
|
// --- Sanity ---
|
|
480
598
|
|
|
481
599
|
function checkSpawnClaude() {
|
|
@@ -507,6 +625,7 @@ function runChecks() {
|
|
|
507
625
|
results.push(...checkProfileLinks(prof));
|
|
508
626
|
}
|
|
509
627
|
results.push(
|
|
628
|
+
checkMacKeychain(),
|
|
510
629
|
checkOneDrive(),
|
|
511
630
|
checkWindowsAppsCc(),
|
|
512
631
|
checkStaleBinCc(),
|
|
@@ -543,7 +662,7 @@ function formatText(results) {
|
|
|
543
662
|
if (r.fix.cmd) {
|
|
544
663
|
lines.push(` → Run: ${r.fix.cmd}`);
|
|
545
664
|
} else if (r.fix.apply) {
|
|
546
|
-
lines.push(` → Run: ${r.fix.safe ? '
|
|
665
|
+
lines.push(` → Run: ${r.fix.safe ? 'cchost doctor --fix' : 'cchost doctor --fix --force'}`);
|
|
547
666
|
}
|
|
548
667
|
}
|
|
549
668
|
}
|
|
@@ -571,42 +690,68 @@ function formatJson(results) {
|
|
|
571
690
|
|
|
572
691
|
// --- Fix runner ---
|
|
573
692
|
|
|
574
|
-
async function applyFixes(results, force, promptFn) {
|
|
693
|
+
async function applyFixes(results, force, promptFn, write = stdoutWrite) {
|
|
575
694
|
const toFix = results.filter(r => r.fix && r.fix.apply && (r.fix.safe || force));
|
|
576
695
|
if (toFix.length === 0) {
|
|
577
|
-
|
|
578
|
-
return;
|
|
696
|
+
write('Nothing auto-fixable found.\n');
|
|
697
|
+
return 0;
|
|
579
698
|
}
|
|
699
|
+
let applied = 0;
|
|
580
700
|
for (const r of toFix) {
|
|
581
701
|
if (r.fix.safe) {
|
|
582
|
-
|
|
583
|
-
await r.fix.apply();
|
|
702
|
+
write(`Fixing: ${r.name}\n`);
|
|
703
|
+
await r.fix.apply(write);
|
|
704
|
+
applied += 1;
|
|
584
705
|
} else {
|
|
585
706
|
const ans = await promptFn(`Fix "${r.name}" — ${r.fix.description || r.name}? [y/N]: `);
|
|
586
707
|
if (ans.trim().toLowerCase() === 'y') {
|
|
587
|
-
await r.fix.apply();
|
|
708
|
+
await r.fix.apply(write);
|
|
709
|
+
applied += 1;
|
|
588
710
|
} else {
|
|
589
|
-
|
|
711
|
+
write(' Skipped.\n');
|
|
590
712
|
}
|
|
591
713
|
}
|
|
592
714
|
}
|
|
715
|
+
return applied;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function doctorExitCode(results) {
|
|
719
|
+
if (results.some(r => r.status === 'error')) return 2;
|
|
720
|
+
if (results.some(r => r.status === 'warn')) return 1;
|
|
721
|
+
return 0;
|
|
593
722
|
}
|
|
594
723
|
|
|
595
724
|
// --- Entry point ---
|
|
596
725
|
|
|
597
726
|
async function runDoctor({ fix, force, json, prompt: promptFn }) {
|
|
598
|
-
|
|
727
|
+
let results = runChecks();
|
|
728
|
+
|
|
729
|
+
if (json && fix) {
|
|
730
|
+
// Fix progress must not corrupt the JSON document: send it (and any
|
|
731
|
+
// prompts) to stderr, then emit one report of the post-fix state.
|
|
732
|
+
const stderrWrite = (text) => process.stderr.write(text);
|
|
733
|
+
const promptOnStderr = (question) => promptFn(question, process.stderr);
|
|
734
|
+
const applied = await applyFixes(results, force, promptOnStderr, stderrWrite);
|
|
735
|
+
if (applied > 0) results = runChecks();
|
|
736
|
+
process.stdout.write(formatJson(results) + '\n');
|
|
737
|
+
return doctorExitCode(results);
|
|
738
|
+
}
|
|
599
739
|
|
|
600
740
|
if (json) {
|
|
601
741
|
process.stdout.write(formatJson(results) + '\n');
|
|
602
|
-
|
|
603
|
-
process.stdout.write(formatText(results));
|
|
742
|
+
return doctorExitCode(results);
|
|
604
743
|
}
|
|
605
744
|
|
|
745
|
+
process.stdout.write(formatText(results));
|
|
746
|
+
|
|
606
747
|
if (fix) {
|
|
607
|
-
|
|
608
|
-
await applyFixes(results, force, promptFn);
|
|
609
|
-
|
|
748
|
+
process.stdout.write('Applying fixes...\n');
|
|
749
|
+
const applied = await applyFixes(results, force, promptFn);
|
|
750
|
+
if (applied > 0) {
|
|
751
|
+
results = runChecks();
|
|
752
|
+
process.stdout.write('\nAfter fixes:\n' + formatText(results));
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
610
755
|
const errors = results.filter(r => r.status === 'error');
|
|
611
756
|
const warns = results.filter(r => r.status === 'warn');
|
|
612
757
|
if (errors.length === 0 && warns.length === 0) {
|
|
@@ -618,14 +763,12 @@ async function runDoctor({ fix, force, json, prompt: promptFn }) {
|
|
|
618
763
|
process.stdout.write(`${parts.join(', ')}.\n`);
|
|
619
764
|
const hasAutoFix = results.some(r => r.fix && r.fix.apply && r.fix.safe);
|
|
620
765
|
const hasForce = results.some(r => r.fix && r.fix.apply && !r.fix.safe);
|
|
621
|
-
if (hasAutoFix) process.stdout.write('Run:
|
|
622
|
-
else if (hasForce) process.stdout.write('Run:
|
|
766
|
+
if (hasAutoFix) process.stdout.write('Run: cchost doctor --fix\n');
|
|
767
|
+
else if (hasForce) process.stdout.write('Run: cchost doctor --fix --force\n');
|
|
623
768
|
}
|
|
624
769
|
}
|
|
625
770
|
|
|
626
|
-
|
|
627
|
-
if (results.some(r => r.status === 'warn')) return 1;
|
|
628
|
-
return 0;
|
|
771
|
+
return doctorExitCode(results);
|
|
629
772
|
}
|
|
630
773
|
|
|
631
|
-
module.exports = { runChecks, runDoctor };
|
|
774
|
+
module.exports = { runChecks, runDoctor, applyFixes, checkCcShadow, checkMacKeychain };
|
package/lib/env.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// an API key and is treated with the same care as `.credentials.json`: it is
|
|
9
9
|
// never migrated, never linked, and written with user-only file permissions.
|
|
10
10
|
//
|
|
11
|
-
// Endpoint profiles are created and edited through `
|
|
11
|
+
// Endpoint profiles are created and edited through `cchost add <profile> --custom`;
|
|
12
12
|
// this module supplies the configuration logic that command delegates to.
|
|
13
13
|
|
|
14
14
|
const fs = require('fs');
|
|
@@ -21,6 +21,26 @@ const ENV_FILE = '.cc-env.json';
|
|
|
21
21
|
// Persisted fields, in canonical serialization order.
|
|
22
22
|
const FIELDS = ['base_url', 'auth_token', 'model', 'opus', 'sonnet', 'haiku', 'subagent'];
|
|
23
23
|
const TIER_FIELDS = ['opus', 'sonnet', 'haiku', 'subagent'];
|
|
24
|
+
// Fields whose value must be a string if present. Optional fields are ignored
|
|
25
|
+
// (treated as absent) when hand-edited to a non-string value.
|
|
26
|
+
const STRICT_STRING_FIELDS = ['base_url', 'auth_token'];
|
|
27
|
+
|
|
28
|
+
// Environment variables Claude Code reads for authentication. If any of these
|
|
29
|
+
// are inherited by a subscription profile, they can silently override the
|
|
30
|
+
// profile's own OAuth login (and billing).
|
|
31
|
+
const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
|
32
|
+
|
|
33
|
+
function authEnvConflicts(env = process.env) {
|
|
34
|
+
return AUTH_ENV_VARS.filter((key) => env[key]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Endpoint profiles must not inherit the parent's Anthropic auth/endpoint
|
|
38
|
+
// settings. Keep only the values buildEnvVars() set for this profile.
|
|
39
|
+
const ENDPOINT_ENV_VARS = [...AUTH_ENV_VARS, 'ANTHROPIC_BASE_URL'];
|
|
40
|
+
|
|
41
|
+
function endpointEnvToStrip(profileEnv, env = process.env) {
|
|
42
|
+
return ENDPOINT_ENV_VARS.filter((key) => env[key] && !profileEnv[key]);
|
|
43
|
+
}
|
|
24
44
|
|
|
25
45
|
// File field -> env var(s) injected into the spawned child.
|
|
26
46
|
const TIER_ENV = {
|
|
@@ -80,6 +100,20 @@ function readEnvConfig(profileDir) {
|
|
|
80
100
|
e.malformed = true;
|
|
81
101
|
throw e;
|
|
82
102
|
}
|
|
103
|
+
for (const key of STRICT_STRING_FIELDS) {
|
|
104
|
+
if (data[key] == null) continue;
|
|
105
|
+
if (typeof data[key] !== 'string') {
|
|
106
|
+
const e = stateError(`${ENV_FILE} is malformed: field "${key}" must be a string.`);
|
|
107
|
+
e.malformed = true;
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Optional fields are best-effort: a hand-edited non-string value must not
|
|
112
|
+
// make the whole profile unusable. Treat it as absent instead.
|
|
113
|
+
for (const key of FIELDS) {
|
|
114
|
+
if (STRICT_STRING_FIELDS.includes(key)) continue;
|
|
115
|
+
if (data[key] != null && typeof data[key] !== 'string') delete data[key];
|
|
116
|
+
}
|
|
83
117
|
return data;
|
|
84
118
|
}
|
|
85
119
|
|
|
@@ -129,9 +163,27 @@ function writeEnvConfig(profileDir, config) {
|
|
|
129
163
|
const target = envFilePath(profileDir);
|
|
130
164
|
const tmp = path.join(profileDir, `${ENV_FILE}.tmp-${process.pid}`);
|
|
131
165
|
// Write to a temp file then rename, so a crash never leaves a partial file.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
166
|
+
// The temp file is created 0600 so an orphaned temp cannot expose the token.
|
|
167
|
+
// Permissions are locked down again after the rename: restricting the temp
|
|
168
|
+
// file's ACL first makes the subsequent rename fail with EPERM on Windows.
|
|
169
|
+
// Remove any stale temp left by pid reuse, then create with O_EXCL so a race
|
|
170
|
+
// cannot make us write the token into a file with an inherited, looser mode.
|
|
171
|
+
fs.rmSync(tmp, { force: true });
|
|
172
|
+
try {
|
|
173
|
+
fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', {
|
|
174
|
+
mode: 0o600,
|
|
175
|
+
flag: 'wx',
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err.code !== 'EEXIST') {
|
|
179
|
+
try {
|
|
180
|
+
fs.unlinkSync(tmp);
|
|
181
|
+
} catch {
|
|
182
|
+
// best effort cleanup
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
135
187
|
try {
|
|
136
188
|
fs.renameSync(tmp, target);
|
|
137
189
|
} catch (err) {
|
|
@@ -173,7 +225,7 @@ function hostOf(url) {
|
|
|
173
225
|
}
|
|
174
226
|
}
|
|
175
227
|
|
|
176
|
-
// Endpoint summary for `
|
|
228
|
+
// Endpoint summary for `cchost list`. Returns the base-url host, 'subscription'
|
|
177
229
|
// (no endpoint config), or '(invalid config)' for a malformed file — never
|
|
178
230
|
// throws, so a single bad profile cannot break the whole table.
|
|
179
231
|
function endpointSummary(profileDir) {
|
|
@@ -382,7 +434,7 @@ async function runWizard(name, existing, io) {
|
|
|
382
434
|
// Command orchestration (called from cli.js)
|
|
383
435
|
// ---------------------------------------------------------------------------
|
|
384
436
|
|
|
385
|
-
// Configures a profile's custom endpoint. Called by `
|
|
437
|
+
// Configures a profile's custom endpoint. Called by `cchost add <profile> --custom`
|
|
386
438
|
// (both for first-time creation and for editing an existing endpoint profile).
|
|
387
439
|
// `provided` is a partial config of fields supplied via flags.
|
|
388
440
|
// `opts.io` injects wizard IO (tests); `opts.isTTY` overrides TTY detection.
|
|
@@ -391,14 +443,14 @@ async function runWizard(name, existing, io) {
|
|
|
391
443
|
// wizard was cancelled, so the caller can clean up a freshly-created profile.
|
|
392
444
|
async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
393
445
|
if (!fs.existsSync(profileDir)) {
|
|
394
|
-
throw stateError(`Profile "${name}" does not exist. Create it with:
|
|
446
|
+
throw stateError(`Profile "${name}" does not exist. Create it with: cchost add ${name}`);
|
|
395
447
|
}
|
|
396
448
|
if (hasOAuthState(profileDir)) {
|
|
397
449
|
throw stateError(
|
|
398
450
|
`Profile '${name}' has OAuth credentials and cannot be converted to a custom endpoint.\n` +
|
|
399
451
|
'A profile is born subscription or endpoint and stays that way.\n' +
|
|
400
452
|
'Create a fresh endpoint profile instead:\n' +
|
|
401
|
-
'
|
|
453
|
+
' cchost add <new-name> --custom',
|
|
402
454
|
);
|
|
403
455
|
}
|
|
404
456
|
|
|
@@ -434,7 +486,7 @@ async function runConfigure(name, profileDir, provided, opts = {}) {
|
|
|
434
486
|
const isTTY = opts.isTTY != null ? opts.isTTY : process.stdin.isTTY;
|
|
435
487
|
if (!isTTY) {
|
|
436
488
|
throw usageError(
|
|
437
|
-
'
|
|
489
|
+
'cchost add --custom requires either endpoint flags or an interactive terminal.',
|
|
438
490
|
);
|
|
439
491
|
}
|
|
440
492
|
io = defaultWizardIo();
|
|
@@ -455,6 +507,10 @@ module.exports = {
|
|
|
455
507
|
ENV_FILE,
|
|
456
508
|
FIELDS,
|
|
457
509
|
TIER_FIELDS,
|
|
510
|
+
AUTH_ENV_VARS,
|
|
511
|
+
authEnvConflicts,
|
|
512
|
+
ENDPOINT_ENV_VARS,
|
|
513
|
+
endpointEnvToStrip,
|
|
458
514
|
envFilePath,
|
|
459
515
|
hasEnvConfig,
|
|
460
516
|
readEnvConfig,
|