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/profiles.js CHANGED
@@ -102,20 +102,30 @@ function addProfile(name) {
102
102
  return { dir };
103
103
  }
104
104
 
105
- function readEmail(dir) {
105
+ // Single read/parse of a profile's .claude.json. Missing or malformed JSON is
106
+ // treated as absent state so one bad profile cannot break `cchost list`.
107
+ function readClaudeJson(dir) {
106
108
  try {
107
109
  const raw = fs.readFileSync(path.join(dir, '.claude.json'), 'utf8');
108
- if (!raw.trim()) return '';
110
+ if (!raw.trim()) return null;
109
111
  const data = JSON.parse(raw);
110
- if (data && data.oauthAccount && typeof data.oauthAccount.emailAddress === 'string') {
111
- return data.oauthAccount.emailAddress;
112
- }
113
- return '';
112
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
114
113
  } catch {
115
- return '';
114
+ return null;
116
115
  }
117
116
  }
118
117
 
118
+ function emailFromClaude(data) {
119
+ if (data && data.oauthAccount && typeof data.oauthAccount.emailAddress === 'string') {
120
+ return data.oauthAccount.emailAddress;
121
+ }
122
+ return '';
123
+ }
124
+
125
+ function readEmail(dir) {
126
+ return emailFromClaude(readClaudeJson(dir));
127
+ }
128
+
119
129
  function hasCredentials(dir) {
120
130
  try {
121
131
  return fs.existsSync(path.join(dir, '.credentials.json'));
@@ -124,7 +134,33 @@ function hasCredentials(dir) {
124
134
  }
125
135
  }
126
136
 
127
- function listProfiles() {
137
+ // Where this profile's Claude Code credentials live:
138
+ // 'file' — .credentials.json in the profile (Windows/Linux)
139
+ // 'keychain' — inferred from .claude.json on macOS; the token is in a
140
+ // Keychain entry namespaced by the profile's exact path
141
+ // 'none' — no sign of a login
142
+ function credentialSourceFrom(hasFile, data, platform) {
143
+ if (hasFile) return 'file';
144
+ if (platform === 'darwin' && data && data.oauthAccount != null) return 'keychain';
145
+ return 'none';
146
+ }
147
+
148
+ function credentialSource(dir, platform = process.platform) {
149
+ return credentialSourceFrom(hasCredentials(dir), readClaudeJson(dir), platform);
150
+ }
151
+
152
+ // Everything `cchost list` needs about one profile, read from one parse.
153
+ function readProfile(dir, platform = process.platform) {
154
+ const data = readClaudeJson(dir);
155
+ const credentialSource = credentialSourceFrom(hasCredentials(dir), data, platform);
156
+ return {
157
+ loggedIn: credentialSource !== 'none',
158
+ credentialSource,
159
+ email: emailFromClaude(data),
160
+ };
161
+ }
162
+
163
+ function listProfiles(platform = process.platform) {
128
164
  const base = baseDir();
129
165
  if (!fs.existsSync(base)) return [];
130
166
  let entries;
@@ -138,12 +174,7 @@ function listProfiles() {
138
174
  if (!ent.isDirectory()) continue;
139
175
  const name = ent.name;
140
176
  const dir = path.join(base, name);
141
- out.push({
142
- name,
143
- dir,
144
- loggedIn: hasCredentials(dir),
145
- email: readEmail(dir),
146
- });
177
+ out.push({ name, dir, ...readProfile(dir, platform) });
147
178
  }
148
179
  out.sort((a, b) => a.name.localeCompare(b.name));
149
180
  return out;
@@ -159,6 +190,8 @@ module.exports = {
159
190
  ensureProfileDir,
160
191
  readEmail,
161
192
  hasCredentials,
193
+ credentialSource,
194
+ readProfile,
162
195
  listProfiles,
163
196
  addProfile,
164
197
  };
package/lib/share.js CHANGED
@@ -180,6 +180,76 @@ function createResourceLink(target, link, resource) {
180
180
  }
181
181
  }
182
182
 
183
+ // <profile>/.cc-private.json records resources the user made private on
184
+ // purpose (`cchost add --private`, `cchost unlink`), so an empty or absent
185
+ // private resource is not mistaken for a missing link and re-shared by
186
+ // `cchost doctor --fix`. `cchost link` removes entries. Never shared.
187
+ const PRIVATE_FILE = '.cc-private.json';
188
+
189
+ function readPrivate(profileDir) {
190
+ try {
191
+ const data = JSON.parse(fs.readFileSync(path.join(profileDir, PRIVATE_FILE), 'utf8'));
192
+ const list = data && Array.isArray(data.private) ? data.private : [];
193
+ return new Set(list.filter((r) => SHARED_RESOURCES.includes(r)));
194
+ } catch {
195
+ return new Set();
196
+ }
197
+ }
198
+
199
+ function writePrivate(profileDir, set) {
200
+ const p = path.join(profileDir, PRIVATE_FILE);
201
+ const list = SHARED_RESOURCES.filter((r) => set.has(r));
202
+ if (list.length === 0) {
203
+ fs.rmSync(p, { force: true });
204
+ return;
205
+ }
206
+ fs.writeFileSync(p, JSON.stringify({ private: list }, null, 2) + '\n');
207
+ }
208
+
209
+ function markPrivate(profileDir, resources) {
210
+ const set = readPrivate(profileDir);
211
+ const before = set.size;
212
+ for (const r of resources) set.add(r);
213
+ if (set.size !== before) writePrivate(profileDir, set);
214
+ }
215
+
216
+ function unmarkPrivate(profileDir, resources) {
217
+ const set = readPrivate(profileDir);
218
+ let changed = false;
219
+ for (const r of resources) changed = set.delete(r) || changed;
220
+ if (changed) writePrivate(profileDir, set);
221
+ }
222
+
223
+ function isMarkedPrivate(profileDir, resource) {
224
+ return readPrivate(profileDir).has(resource);
225
+ }
226
+
227
+ // Where one resource of a profile currently stands. Returns one of:
228
+ // 'shared' — linked to ~/.claude/ (symlink, junction, or hard link)
229
+ // 'private' — a real file/dir with content, or one recorded as private
230
+ // on purpose in .cc-private.json (even if empty or absent)
231
+ // 'empty' — a real but empty file/dir (link would replace it)
232
+ // 'missing' — nothing at the path
233
+ // 'foreign-link' — a symlink pointing somewhere other than ~/.claude/
234
+ function resourceState(profileDir, resource) {
235
+ const p = path.join(profileDir, resource);
236
+ if (isSharedResource(profileDir, resource)) return 'shared';
237
+ if (isLink(p)) return 'foreign-link';
238
+ if (isMarkedPrivate(profileDir, resource)) return 'private';
239
+ let st;
240
+ try {
241
+ st = fs.statSync(p);
242
+ } catch {
243
+ return 'missing';
244
+ }
245
+ try {
246
+ const hasContent = st.isDirectory() ? fs.readdirSync(p).length > 0 : st.size > 0;
247
+ return hasContent ? 'private' : 'empty';
248
+ } catch {
249
+ return 'private';
250
+ }
251
+ }
252
+
183
253
  // Link each requested resource of a profile into the shared pool (~/.claude/).
184
254
  // Each resource is attempted independently — one failure doesn't abort the
185
255
  // rest. Returns [{ resource, status, ... }]; status ∈
@@ -188,18 +258,21 @@ function createResourceLink(target, link, resource) {
188
258
  // 'refused' is the v0.4.1 behavior when the profile holds private content at
189
259
  // the resource path: we do NOT auto-move it into the pool (was the v0.4.0
190
260
  // behavior for dirs). The user must move/remove it manually.
261
+ //
262
+ // opts.dryRun reports the same statuses without touching the filesystem.
191
263
  function shareLink(profileName, opts = {}) {
264
+ const dryRun = Boolean(opts.dryRun);
192
265
  profiles.validateProfileName(profileName);
193
266
  const pdir = profiles.profileDir(profileName);
194
267
  if (!fs.existsSync(pdir)) {
195
268
  const err = new Error(
196
- `Profile "${profileName}" does not exist. Create it with \`cc add ${profileName}\` first.`,
269
+ `Profile "${profileName}" does not exist. Create it with \`cchost add ${profileName}\` first.`,
197
270
  );
198
271
  err.exitCode = 1;
199
272
  throw err;
200
273
  }
201
274
  const subset = resolveSubset(opts);
202
- ensurePoolResources(subset);
275
+ if (!dryRun) ensurePoolResources(subset);
203
276
  const pool = poolDir();
204
277
  const legacyPool = profiles.sharedDir();
205
278
  const results = [];
@@ -217,13 +290,22 @@ function shareLink(profileName, opts = {}) {
217
290
  }
218
291
  if (resolved === path.join(legacyPool, resource)) {
219
292
  // Legacy v0.3 pointer — silently relink to the new pool location.
293
+ if (dryRun) {
294
+ results.push({ resource, status: 'linked', relinkedFromLegacy: true });
295
+ continue;
296
+ }
220
297
  removeLink(link);
221
298
  const r = createResourceLink(target, link, resource);
222
299
  results.push({ resource, status: 'linked', relinkedFromLegacy: true, kind: r.kind });
223
300
  continue;
224
301
  }
225
- // Points somewhere unexpected — don't touch it.
226
- results.push({ resource, status: 'already-linked' });
302
+ // Points somewhere unexpected — don't touch it, but report it as not
303
+ // shared so callers don't claim the resource is linked.
304
+ results.push({
305
+ resource,
306
+ status: 'foreign-link',
307
+ message: `symlink points to ${resolved}, not ${target} — left untouched`,
308
+ });
227
309
  continue;
228
310
  }
229
311
 
@@ -246,20 +328,34 @@ function shareLink(profileName, opts = {}) {
246
328
  status: 'refused',
247
329
  message:
248
330
  `private content exists at ${link} — move or remove it manually, ` +
249
- `then re-run \`cc link ${profileName}\``,
331
+ `then re-run \`cchost link ${profileName}\``,
250
332
  });
251
333
  continue;
252
334
  }
253
335
  // Empty private dir — safe to remove and replace with a symlink.
254
- try { fs.rmdirSync(link); } catch { /* fall through; create will fail clearly */ }
336
+ if (!dryRun) {
337
+ try { fs.rmdirSync(link); } catch { /* fall through; create will fail clearly */ }
338
+ }
255
339
  }
256
340
 
341
+ if (dryRun) {
342
+ results.push({ resource, status: 'linked' });
343
+ continue;
344
+ }
257
345
  const r = createResourceLink(target, link, resource);
258
346
  results.push({ resource, status: 'linked', kind: r.kind });
259
347
  } catch (err) {
260
348
  results.push({ resource, status: 'failed', message: err.message });
261
349
  }
262
350
  }
351
+ if (!dryRun) {
352
+ unmarkPrivate(
353
+ pdir,
354
+ results
355
+ .filter((r) => r.status === 'linked' || r.status === 'already-linked')
356
+ .map((r) => r.resource),
357
+ );
358
+ }
263
359
  return results;
264
360
  }
265
361
 
@@ -268,7 +364,12 @@ function shareLink(profileName, opts = {}) {
268
364
  // collisions, defensive) so the profile keeps its current view.
269
365
  // Returns [{ resource, status, ... }]; status ∈
270
366
  // 'unlinked' | 'not-linked' | 'failed'
367
+ //
368
+ // opts.dryRun reports the same statuses without touching the filesystem; dir
369
+ // results then carry `items`, the number of top-level entries that would be
370
+ // copied into the profile.
271
371
  function shareUnlink(profileName, opts = {}) {
372
+ const dryRun = Boolean(opts.dryRun);
272
373
  profiles.validateProfileName(profileName);
273
374
  const pdir = profiles.profileDir(profileName);
274
375
  if (!fs.existsSync(pdir)) {
@@ -290,6 +391,15 @@ function shareUnlink(profileName, opts = {}) {
290
391
  continue;
291
392
  }
292
393
 
394
+ if (dryRun) {
395
+ const r = { resource, status: 'unlinked' };
396
+ if (!isFileResource(resource)) {
397
+ try { r.items = fs.readdirSync(target).length; } catch { r.items = 0; }
398
+ }
399
+ results.push(r);
400
+ continue;
401
+ }
402
+
293
403
  // Read pool content BEFORE removing the link (in case the link is the
294
404
  // only path to it... well, not really — target is reachable directly —
295
405
  // but reading first means we can fail without leaving a deleted link).
@@ -329,6 +439,9 @@ function shareUnlink(profileName, opts = {}) {
329
439
  results.push({ resource, status: 'failed', message: err.message });
330
440
  }
331
441
  }
442
+ if (!dryRun) {
443
+ markPrivate(pdir, results.filter((r) => r.status === 'unlinked').map((r) => r.resource));
444
+ }
332
445
  return results;
333
446
  }
334
447
 
@@ -783,6 +896,11 @@ module.exports = {
783
896
  isFileResource,
784
897
  isLink,
785
898
  isSharedResource,
899
+ resourceState,
900
+ PRIVATE_FILE,
901
+ markPrivate,
902
+ unmarkPrivate,
903
+ isMarkedPrivate,
786
904
  removeLink,
787
905
  shareLink,
788
906
  shareUnlink,
package/lib/spawn.js CHANGED
@@ -155,7 +155,7 @@ function platformSpawn(bin, args, opts) {
155
155
  return spawn(bin, args, opts);
156
156
  }
157
157
 
158
- function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
158
+ function spawnClaude({ args = [], profileDir = null, extraEnv = {}, unsetEnv = [] } = {}) {
159
159
  const bin = findClaude();
160
160
  if (!bin) {
161
161
  const err = new Error(
@@ -169,6 +169,7 @@ function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
169
169
  warnIfOld(bin);
170
170
 
171
171
  const childEnv = { ...process.env, ...extraEnv };
172
+ for (const name of unsetEnv) delete childEnv[name];
172
173
  if (profileDir) {
173
174
  childEnv.CLAUDE_CONFIG_DIR = profileDir;
174
175
  } else {
@@ -0,0 +1,117 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <link rel="icon" href="data:,">
8
+ <title>Sign in · cchost</title>
9
+ <style nonce="__NONCE__">
10
+ /*__TOKENS__*/
11
+ * { box-sizing: border-box; }
12
+ html, body { margin: 0; min-height: 100%; }
13
+ body {
14
+ min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
15
+ background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans);
16
+ -webkit-font-smoothing: antialiased;
17
+ }
18
+ ::selection { background: color-mix(in srgb, var(--primary) 28%, transparent); }
19
+ .card {
20
+ width: min(380px, 100%); display: grid; gap: 20px; padding: 28px;
21
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm);
22
+ animation: enter 420ms var(--ease) both;
23
+ }
24
+ @keyframes enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
25
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 15px; }
26
+ .brand svg { width: 22px; height: 22px; }
27
+ .brand .mark { fill: var(--primary); opacity: 0.9; }
28
+ .brand small { margin-left: auto; color: var(--text-3); font-weight: 500; font-size: 12px; }
29
+ h1 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
30
+ .lede { margin: 4px 0 0; color: var(--text-2); font-size: 13px; }
31
+ form { display: grid; gap: 14px; }
32
+ label { display: grid; gap: 6px; font-size: 13px; font-weight: 550; }
33
+ .input {
34
+ height: 36px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--border-strong);
35
+ background: var(--surface); color: var(--text); font: inherit; font-weight: 400;
36
+ transition: border-color 120ms, box-shadow 120ms;
37
+ }
38
+ .input:focus-visible { outline: none; border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent); }
39
+ .input[aria-invalid="true"] { border-color: var(--err); }
40
+ .btn {
41
+ height: 36px; border-radius: 6px; border: 1px solid var(--primary); background: var(--primary); color: var(--primary-fg);
42
+ font: inherit; font-weight: 550; cursor: pointer; transition: background 120ms, transform 120ms;
43
+ }
44
+ .btn:hover { background: var(--primary-hover); }
45
+ .btn:active { transform: translateY(1px); }
46
+ .btn:disabled { opacity: 0.6; cursor: progress; }
47
+ .btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
48
+ .err { min-height: 20px; margin: 0; color: var(--err-fg); font-size: 13px; }
49
+ .err.shake { animation: shake 320ms var(--ease); }
50
+ @keyframes shake { 0%, 100% { transform: none; } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
51
+ .hint { margin: 0; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-3); font-size: 12.5px; }
52
+ code { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
53
+ @media (prefers-reduced-motion: reduce) {
54
+ .card, .err.shake { animation: none; }
55
+ }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <main class="card">
60
+ <div class="brand">
61
+ <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="6.5" width="12" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.75"/><rect class="mark" x="9.5" y="2.5" width="12" height="12" rx="3"/></svg>
62
+ cchost <small>v__VERSION__</small>
63
+ </div>
64
+ <div>
65
+ <h1>Sign in</h1>
66
+ <p class="lede" id="lede">Use the username and password printed in the terminal where you ran <code>cchost ui</code>.</p>
67
+ </div>
68
+ <form id="form" novalidate>
69
+ <label>Username<input class="input" id="user" name="user" autocomplete="username" spellcheck="false" value="cchost" required></label>
70
+ <label>Password<input class="input" id="pass" name="pass" type="password" autocomplete="current-password" spellcheck="false" required autofocus aria-describedby="err"></label>
71
+ <p class="err" id="err" role="alert"></p>
72
+ <button class="btn" id="go" type="submit">Sign in</button>
73
+ </form>
74
+ <p class="hint">The password changes every time <code>cchost ui</code> starts, and only this machine can reach this page.</p>
75
+ </main>
76
+ <script nonce="__NONCE__">
77
+ (() => {
78
+ 'use strict';
79
+ const $ = (id) => document.getElementById(id);
80
+ try {
81
+ const t = localStorage.getItem('cchost-theme');
82
+ if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
83
+ } catch { /* storage blocked */ }
84
+ if (new URLSearchParams(location.search).has('expired')) {
85
+ $('lede').textContent = 'That sign-in link was already used or has expired. Sign in with the password from the terminal.';
86
+ history.replaceState(null, '', '/');
87
+ }
88
+ $('form').addEventListener('submit', async (ev) => {
89
+ ev.preventDefault();
90
+ const err = $('err');
91
+ err.textContent = '';
92
+ err.classList.remove('shake');
93
+ $('go').disabled = true;
94
+ $('go').textContent = 'Signing in…';
95
+ try {
96
+ const res = await fetch('/api/login', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json', 'X-CCHost': '1' },
99
+ body: JSON.stringify({ user: $('user').value, pass: $('pass').value }),
100
+ });
101
+ if (res.ok) { location.replace('/'); return; }
102
+ const json = await res.json().catch(() => ({}));
103
+ err.textContent = json.error || `Sign-in failed (${res.status}).`;
104
+ } catch {
105
+ err.textContent = 'Cannot reach cchost ui — is it still running?';
106
+ }
107
+ void err.offsetWidth;
108
+ err.classList.add('shake');
109
+ $('pass').setAttribute('aria-invalid', 'true');
110
+ $('pass').select();
111
+ $('go').disabled = false;
112
+ $('go').textContent = 'Sign in';
113
+ });
114
+ })();
115
+ </script>
116
+ </body>
117
+ </html>