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/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/n
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 = CONFLICT_VARS.filter(k => process.env[k]);
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 cc use', cmd, apply: null,
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 = process.platform === 'win32' ? ';' : ':';
79
- const exts = process.platform === 'win32'
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 = process.platform === 'win32' ? full.toLowerCase() : full;
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
- function checkCcShadow() {
100
- const all = findAllOnPath('cc');
101
- if (all.length <= 1) {
102
- return mk('Environment', 'cc on PATH', 'ok', all[0] || 'not found yet (expected after npm install -g)');
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', 'cc on PATH', 'warn', `multiple: ${all.join(', ')}`, {
105
- safe: false, description: 'Remove duplicate cc entries to avoid version confusion', cmd: null, apply: null,
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
- process.stdout.write(` Created ${base}\n`);
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
- process.stdout.write(` Backed up to ${path.basename(bak)}, reset to {}\n`);
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
- process.stdout.write(` Linked: ${pp} -> ${expected}\n`);
273
+ write(` Linked: ${pp} -> ${expected}\n`);
181
274
  } else if (r) {
182
- process.stdout.write(` Could not link ${pp}: ${r.message || r.status}\n`);
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
- process.stdout.write(` Recreated: ${pp} -> ${expected}\n`);
299
+ write(` Recreated: ${pp} -> ${expected}\n`);
207
300
  } else if (r) {
208
- process.stdout.write(` Could not recreate ${pp}: ${r.message || r.status}\n`);
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
- process.stdout.write(` Linked: ${pp} -> ${expected}\n`);
336
+ write(` Linked: ${pp} -> ${expected}\n`);
244
337
  } else if (r) {
245
- process.stdout.write(` Could not link ${pp}: ${r.message || r.status}\n`);
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 cc link',
257
- cmd: `cc copy default ${prof.name} # if you want to merge it the other way; ` +
258
- `or manually move ${pp} aside, then: cc link ${prof.name}`,
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); process.stdout.write(` Removed ${legacy}\n`); } catch (e) {
320
- process.stdout.write(` Could not remove ${legacy}: ${e.message}\n`);
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) => process.stdout.write(` ${line}\n`) });
331
- process.stdout.write(
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) => process.stdout.write(`${line}\n`),
475
+ log: (line) => write(`${line}\n`),
383
476
  });
384
- process.stdout.write(
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
- process.stdout.write(` Failure (${f.kind}): ${f.folder ? f.folder + '/' : ''}${f.name || ''} ${f.message}\n`);
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) => process.stdout.write(` ${line}\n`),
517
+ log: (line) => write(` ${line}\n`),
425
518
  });
426
- process.stdout.write(
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
- process.stdout.write(` Failure (${f.kind}): ${f.name || ''} ${f.message}\n`);
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 cc`, {
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 cc`, {
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 ? 'cc doctor --fix' : 'cc doctor --fix --force'}`);
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
- process.stdout.write('Nothing auto-fixable found.\n');
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
- process.stdout.write(`Fixing: ${r.name}\n`);
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
- process.stdout.write(' Skipped.\n');
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
- const results = runChecks();
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
- } else {
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
- if (!json) process.stdout.write('Applying fixes...\n');
608
- await applyFixes(results, force, promptFn);
609
- } else if (!json) {
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: cc doctor --fix\n');
622
- else if (hasForce) process.stdout.write('Run: cc doctor --fix --force\n');
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
- if (results.some(r => r.status === 'error')) return 2;
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 `cc add <profile> --custom`;
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
- // Permissions are locked down after the rename: restricting the temp file's
133
- // ACL first makes the subsequent rename fail with EPERM on Windows.
134
- fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n');
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 `cc list`. Returns the base-url host, 'subscription'
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 `cc add <profile> --custom`
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: cc add ${name}`);
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
- ' cc add <new-name> --custom',
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
- 'cc add --custom requires either endpoint flags or an interactive terminal.',
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,