cchost 0.5.0 → 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/cli.js CHANGED
@@ -15,12 +15,13 @@ const { renderBanner } = require('./banner');
15
15
 
16
16
  const HELP = `cchost - Multi-profile launcher for Claude Code
17
17
 
18
- Installed as both 'cchost' (preferred) and 'cc' (legacy alias). The examples
19
- below use cchost; prefer it in scripts because 'cc' shadows the system C
20
- compiler on macOS and Linux.
18
+ Installed as both 'cchost' (preferred) and 'cc' (legacy alias). 'cc' is also
19
+ the C compiler on macOS and Linux: when 'cc' is called with anything other than
20
+ a cchost command (e.g. 'cc -c foo.c', 'cc --version'), it runs the real
21
+ compiler further down PATH. Use 'cchost' in scripts.
21
22
 
22
23
  Usage:
23
- cchost add <profile> [--custom [...]] Create a profile (auto-links all 7 resources to ~/.claude/)
24
+ cchost add <profile> [--private] [--custom [...]] Create a profile (shares all 7 resources, or --private ones)
24
25
  cchost use <profile> [...args] Launch Claude under a named profile
25
26
  cchost list [<profile>] [--json] [--reveal] List profiles, or show one in detail
26
27
  cchost copy default <profile> [--force] [--dry-run] Copy ~/.claude/ resources into a profile (only when unlinked)
@@ -28,6 +29,7 @@ Usage:
28
29
  cchost unlink <profile> [--projects|--tools|--config] Restore a profile's private resources (non-destructive)
29
30
  cchost remove <profile> [--force] Delete a profile and all its data
30
31
  cchost doctor [--fix [--force]] [--json] Diagnose environment, storage, and platform issues
32
+ cchost ui [--port=N] [--no-open] [--session=8h] Open the web view (prints a sign-in password, like rclone)
31
33
  cchost help [command] Show usage (optionally for a single command)
32
34
  cchost help sharing Task guide: what is shared, and how to change it
33
35
  cchost --version Print the installed cchost version
@@ -47,7 +49,7 @@ for that launch. Args after the profile name are forwarded verbatim to claude.
47
49
  `;
48
50
 
49
51
  const HELP_BY_CMD = {
50
- add: `cchost add <profile> [--custom [--base-url=URL --token=TOKEN ...]]
52
+ add: `cchost add <profile> [--private[=projects,tools,config]] [--custom [--base-url=URL --token=TOKEN ...]]
51
53
 
52
54
  Create a new profile directory without launching Claude. All 7 shared
53
55
  resources (projects/, skills/, agents/, commands/, mcp.json, settings.json,
@@ -58,6 +60,15 @@ profile (and to bare \`claude\`) from the moment the profile is created.
58
60
  Create a regular profile. It logs in with your Anthropic
59
61
  subscription (OAuth) on the first 'cchost use'.
60
62
 
63
+ cchost add <profile> --private
64
+ cchost add <profile> --private=projects,config
65
+ Start some or all resources private and empty instead of shared:
66
+ nothing from ~/.claude/ is copied in, and nothing this profile writes
67
+ there is seen by other profiles. Groups: projects (session history),
68
+ tools (skills/agents/commands), config (mcp.json/settings.json/CLAUDE.md).
69
+ Combine with --custom for an isolated endpoint profile. Share a group
70
+ later with 'cchost link <profile> --<group>'.
71
+
61
72
  cchost add <profile> --custom
62
73
  Create a profile routed through a custom, Anthropic-compatible API
63
74
  endpoint — Moonshot/Kimi, OpenRouter, a self-hosted vLLM, a corporate
@@ -81,6 +92,7 @@ user-only permissions. It is never linked and never copied.
81
92
 
82
93
  Examples:
83
94
  cchost add work
95
+ cchost add client --private=projects,config
84
96
  cchost add kimi --custom
85
97
  cchost add kimi --custom --base-url=https://api.moonshot.ai/anthropic --token=sk-... --model=kimi-k2.5
86
98
  `,
@@ -231,7 +243,11 @@ See where a profile stands:
231
243
  cchost list Shared column: 7/7 = all shared
232
244
  cchost list work state of each resource
233
245
 
234
- Make something private to one profile:
246
+ Start a new profile private (empty, nothing copied in):
247
+ cchost add client --private all 7 private
248
+ cchost add client --private=projects own session history, shared tools/config
249
+
250
+ Make something private to an existing profile:
235
251
  cchost unlink work --config own mcp.json / settings.json / CLAUDE.md
236
252
  cchost unlink work all 7 private
237
253
  The current shared content is copied in, so nothing disappears; from then
@@ -278,6 +294,26 @@ Exit codes: 0 = all ok, 1 = warnings, 2 = errors.
278
294
  --fix --force Also apply confirmation-required fixes (e.g. reset corrupt
279
295
  .claude.json)
280
296
  --json Machine-readable output for CI / scripting
297
+ `,
298
+ ui: `cchost ui [--port=N] [--no-open] [--user=NAME] [--pass=PASSWORD] [--session=8h]
299
+
300
+ Open a local web page that shows every profile, what each one shares with
301
+ ~/.claude/, logins, endpoints and doctor results, and lets you add and remove
302
+ profiles, share or unshare resources, copy from ~/.claude/ and apply doctor
303
+ fixes. Every change shows a preview first and lists each step it took.
304
+
305
+ Sign-in works like rclone's web GUI: the terminal prints a username and a
306
+ random password that is new every run, and your browser opens with a
307
+ one-time sign-in link (valid 2 minutes). The browser then keeps a session
308
+ that ends after --session or when cchost ui stops. The page is served on
309
+ 127.0.0.1 only; endpoint tokens are never sent to it. Ctrl+C stops it.
310
+
311
+ --port=N Listen on port N (default: a free port)
312
+ --no-open Print the one-time link instead of opening a browser
313
+ --user=NAME Sign-in username (default: cchost)
314
+ --pass=PASSWORD Use this password instead of a random one (8+ characters;
315
+ visible in your shell history — prefer the random one)
316
+ --session=8h How long a sign-in lasts: 30m, 8h, 2d (default: 8h)
281
317
  `,
282
318
  help: `cchost help [command]
283
319
 
@@ -380,7 +416,7 @@ function resourceLabel(resource) {
380
416
  const RESOURCE_STATE_NOTES = {
381
417
  private: 'only this profile sees it',
382
418
  empty: 'private, but empty',
383
- missing: 'not set up; cchost link creates it',
419
+ missing: 'not present; cchost link would share it',
384
420
  'foreign-link': 'points outside ~/.claude/; cchost leaves it alone',
385
421
  };
386
422
 
@@ -405,19 +441,45 @@ const ENDPOINT_VALUE_FLAGS = {
405
441
  '--subagent': 'subagent',
406
442
  };
407
443
 
444
+ // `--private` → all groups; `--private=projects,tools,config` → those groups.
445
+ // Returns the share.resolveSubset() options for the private groups.
446
+ const PRIVATE_GROUPS = ['projects', 'tools', 'config'];
447
+
448
+ function parsePrivateFlag(value) {
449
+ if (value === null) return { projects: true, tools: true, config: true };
450
+ const groups = value.split(',').map((g) => g.trim()).filter(Boolean);
451
+ if (groups.length === 0) {
452
+ throw usageError(`--private= needs at least one of: ${PRIVATE_GROUPS.join(', ')}`);
453
+ }
454
+ const opts = {};
455
+ for (const g of groups) {
456
+ if (!PRIVATE_GROUPS.includes(g)) {
457
+ throw usageError(`Unknown --private group "${g}". Use: ${PRIVATE_GROUPS.join(', ')}`);
458
+ }
459
+ opts[g] = true;
460
+ }
461
+ return opts;
462
+ }
463
+
408
464
  async function cmdAdd(rest) {
409
465
  const positional = [];
410
466
  const provided = {};
411
467
  let custom = false;
468
+ let privateOpts = null;
412
469
 
413
470
  for (const a of rest) {
414
471
  if (a.startsWith('-')) {
415
472
  const eq = a.indexOf('=');
416
473
  const flagName = eq === -1 ? a : a.slice(0, eq);
417
474
  if (flagName === '--custom') {
475
+ if (eq !== -1) throw usageError('--custom takes no value');
418
476
  custom = true;
419
477
  continue;
420
478
  }
479
+ if (flagName === '--private') {
480
+ privateOpts = parsePrivateFlag(eq === -1 ? null : a.slice(eq + 1));
481
+ continue;
482
+ }
421
483
  if (Object.prototype.hasOwnProperty.call(ENDPOINT_VALUE_FLAGS, flagName)) {
422
484
  if (eq === -1) {
423
485
  throw usageError(`Flag ${flagName} requires a value: ${flagName}=VALUE`);
@@ -432,7 +494,10 @@ async function cmdAdd(rest) {
432
494
 
433
495
  const [name, ...extra] = positional;
434
496
  if (!name) {
435
- throw usageError('Usage: cchost add <profile> [--custom [--base-url=URL --token=TOKEN ...]]');
497
+ throw usageError(
498
+ 'Usage: cchost add <profile> [--private[=projects,tools,config]] ' +
499
+ '[--custom [--base-url=URL --token=TOKEN ...]]',
500
+ );
436
501
  }
437
502
  if (extra.length > 0) throw usageError(`cchost add takes no extra arguments: ${extra[0]}`);
438
503
  profiles.validateProfileName(name);
@@ -450,7 +515,7 @@ async function cmdAdd(rest) {
450
515
  if (!custom) {
451
516
  const { dir } = profiles.addProfile(name);
452
517
  console.log(`Created profile "${name}" at ${dir}.`);
453
- autoLinkArtifacts(name);
518
+ setUpResources(name, dir, privateOpts);
454
519
  console.log(`Next: cchost use ${name}`);
455
520
  return 0;
456
521
  }
@@ -458,9 +523,15 @@ async function cmdAdd(rest) {
458
523
  // Custom endpoint profile.
459
524
  const dir = profiles.profileDir(name);
460
525
  const existed = fs.existsSync(dir);
526
+ if (existed && privateOpts) {
527
+ throw usageError(
528
+ `--private only applies when creating a profile; "${name}" already exists. ` +
529
+ `Use: cchost unlink ${name} [--projects|--tools|--config]`,
530
+ );
531
+ }
461
532
  if (!existed) {
462
533
  profiles.addProfile(name);
463
- autoLinkArtifacts(name);
534
+ setUpResources(name, dir, privateOpts);
464
535
  }
465
536
 
466
537
  let result;
@@ -515,13 +586,32 @@ function runLegacyPoolMigration() {
515
586
  }
516
587
  }
517
588
 
518
- // On profile creation, symlink all 7 resources into ~/.claude/. Failures emit
519
- // a warning but do NOT abort cchost add — the profile is still usable, and the
520
- // user can re-run `cchost link <name>` to retry.
521
- function autoLinkArtifacts(name) {
589
+ // On profile creation, link every resource that isn't private into
590
+ // ~/.claude/, and start each private dir empty. Private config files are left
591
+ // absent so Claude Code creates them on first use.
592
+ function setUpResources(name, dir, privateOpts) {
593
+ if (!privateOpts) {
594
+ autoLinkArtifacts(name);
595
+ return;
596
+ }
597
+ const privateSet = share.resolveSubset(privateOpts);
598
+ const sharedGroups = {};
599
+ for (const g of PRIVATE_GROUPS) if (!privateOpts[g]) sharedGroups[g] = true;
600
+ if (Object.keys(sharedGroups).length > 0) autoLinkArtifacts(name, sharedGroups);
601
+ for (const r of privateSet) {
602
+ if (!share.isFileResource(r)) fs.mkdirSync(path.join(dir, r), { recursive: true });
603
+ }
604
+ share.markPrivate(dir, privateSet);
605
+ console.log(` Private (not shared with other profiles): ${privateSet.map(resourceLabel).join(', ')}`);
606
+ }
607
+
608
+ // Symlink resources into ~/.claude/ (all 7, or the groups in `opts`). Failures
609
+ // emit a warning but do NOT abort cchost add — the profile is still usable,
610
+ // and the user can re-run `cchost link <name>` to retry.
611
+ function autoLinkArtifacts(name, opts = {}) {
522
612
  let results;
523
613
  try {
524
- results = share.shareLink(name);
614
+ results = share.shareLink(name, opts);
525
615
  } catch (err) {
526
616
  process.stderr.write(
527
617
  color.stderr.yellow(
@@ -939,6 +1029,51 @@ async function cmdUnlink(rest) {
939
1029
  return anyFailure ? 1 : 0;
940
1030
  }
941
1031
 
1032
+ // `cchost ui` runs until Ctrl+C, so it never resolves.
1033
+ // '30m', '8h', '2d' -> milliseconds.
1034
+ function parseDuration(text) {
1035
+ const m = /^(\d+)(m|h|d)$/.exec(text);
1036
+ if (!m || Number(m[1]) === 0) return null;
1037
+ return Number(m[1]) * { m: 60e3, h: 3600e3, d: 86400e3 }[m[2]];
1038
+ }
1039
+
1040
+ async function cmdUi(rest) {
1041
+ let port = 0;
1042
+ let open = true;
1043
+ let user = 'cchost';
1044
+ let pass = null;
1045
+ let sessionTtlMs;
1046
+ for (const a of rest) {
1047
+ if (a === '--no-open') { open = false; continue; }
1048
+ const eq = a.indexOf('=');
1049
+ const flag = eq === -1 ? a : a.slice(0, eq);
1050
+ const value = eq === -1 ? null : a.slice(eq + 1);
1051
+ if (['--port', '--user', '--pass', '--session'].includes(flag) && !value) {
1052
+ throw usageError(`Flag ${flag} requires a value: ${flag}=VALUE`);
1053
+ }
1054
+ if (flag === '--port') {
1055
+ port = Number(value);
1056
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw usageError(`Invalid port: ${a}`);
1057
+ continue;
1058
+ }
1059
+ if (flag === '--user') { user = value; continue; }
1060
+ if (flag === '--pass') {
1061
+ if (value.length < 8) throw usageError('--pass must be at least 8 characters.');
1062
+ pass = value;
1063
+ continue;
1064
+ }
1065
+ if (flag === '--session') {
1066
+ sessionTtlMs = parseDuration(value);
1067
+ if (!sessionTtlMs) throw usageError(`Invalid --session "${value}". Use e.g. 30m, 8h or 2d.`);
1068
+ continue;
1069
+ }
1070
+ throw usageError(a.startsWith('-') ? `Unknown flag: ${flag}` : `Unexpected argument: ${a}`);
1071
+ }
1072
+ const { startUi } = require('./ui/server');
1073
+ await startUi({ port, open, user, pass, sessionTtlMs });
1074
+ return new Promise(() => {});
1075
+ }
1076
+
942
1077
  async function cmdDoctor(rest) {
943
1078
  const { flags } = parseFlagsStrict(rest, ['--fix', '--force', '--json']);
944
1079
  const fix = flags.has('--fix');
@@ -1023,6 +1158,8 @@ async function run(argv) {
1023
1158
  return await cmdRemove(rest);
1024
1159
  case 'doctor':
1025
1160
  return await cmdDoctor(rest);
1161
+ case 'ui':
1162
+ return await cmdUi(rest);
1026
1163
  default: {
1027
1164
  process.stderr.write(color.stderr.red(`Unknown command: ${action}`) + '\n\n');
1028
1165
  printHelp();
package/lib/copy.js CHANGED
@@ -131,7 +131,7 @@ function formatResourceLabel(resource) {
131
131
  // the v0.4 symlink semantics. Logs per-resource progress and a closing
132
132
  // summary line. Returns a structured result for tests. With dryRun the same
133
133
  // report is printed in the conditional ("would copy") and nothing is written.
134
- function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
134
+ function copy(srcArg, destArg, { force = false, dryRun = false, log = console.log } = {}) {
135
135
  const src = resolveSource(srcArg);
136
136
  const dest = resolveDest(destArg);
137
137
 
@@ -142,7 +142,7 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
142
142
  if (!dryRun) profiles.ensureProfileDir(dest.name);
143
143
  const copiedWord = dryRun ? 'would copy' : 'copied';
144
144
  if (!fs.existsSync(src.dir)) {
145
- console.log(`Source ${src.dir} does not exist. Nothing to copy.`);
145
+ log(`Source ${src.dir} does not exist. Nothing to copy.`);
146
146
  return { copied: 0, skipped: 0, shared: 0, results: [] };
147
147
  }
148
148
 
@@ -165,14 +165,14 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
165
165
 
166
166
  // Already-shared check: same on-disk target via symlink or hard link.
167
167
  if (fs.existsSync(dstPath) && fs.existsSync(srcPath) && sameOnDisk(srcPath, dstPath)) {
168
- console.log(` ${label} shared via symlink — skipped`);
168
+ log(` ${label} shared via symlink — skipped`);
169
169
  results.push({ resource, status: 'shared' });
170
170
  totalShared += 1;
171
171
  continue;
172
172
  }
173
173
 
174
174
  if (!fs.existsSync(srcPath)) {
175
- console.log(` ${label} not present at source`);
175
+ log(` ${label} not present at source`);
176
176
  results.push({ resource, status: 'absent' });
177
177
  continue;
178
178
  }
@@ -180,13 +180,13 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
180
180
  if (isFile) {
181
181
  const r = copySingleFile(srcPath, dstPath, { force, dryRun });
182
182
  if (r === 'copied') {
183
- console.log(` ${label} ${copiedWord}`);
183
+ log(` ${label} ${copiedWord}`);
184
184
  totalCopied += 1;
185
185
  } else if (r === 'skipped') {
186
- console.log(` ${label} skipped (already present — pass --force to overwrite)`);
186
+ log(` ${label} skipped (already present — pass --force to overwrite)`);
187
187
  totalSkipped += 1;
188
188
  } else {
189
- console.log(` ${label} not present at source`);
189
+ log(` ${label} not present at source`);
190
190
  }
191
191
  results.push({ resource, status: r });
192
192
  continue;
@@ -194,12 +194,12 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
194
194
 
195
195
  const { copied, skipped } = copyDirContents(srcPath, dstPath, { force, dryRun });
196
196
  if (copied === 0 && skipped === 0) {
197
- console.log(` ${label} empty at source`);
197
+ log(` ${label} empty at source`);
198
198
  } else if (copied > 0) {
199
199
  const more = skipped > 0 ? `, skipped ${skipped} (already present)` : '';
200
- console.log(` ${label} ${copiedWord} ${copied} item(s)${more}`);
200
+ log(` ${label} ${copiedWord} ${copied} item(s)${more}`);
201
201
  } else {
202
- console.log(
202
+ log(
203
203
  ` ${label} skipped ${skipped} item(s) (all already present — pass --force to overwrite)`,
204
204
  );
205
205
  }
@@ -208,21 +208,21 @@ function copy(srcArg, destArg, { force = false, dryRun = false } = {}) {
208
208
  results.push({ resource, status: 'dir', copied, skipped });
209
209
  }
210
210
 
211
- console.log('');
211
+ log('');
212
212
  if (totalCopied === 0 && totalShared === share.SHARED_RESOURCES.length) {
213
- console.log(
213
+ log(
214
214
  `Nothing to copy — all ${share.SHARED_RESOURCES.length} resources are already shared via symlinks.`,
215
215
  );
216
- console.log(
216
+ log(
217
217
  `Tip: run \`cchost unlink ${dest.name}\` first if you want private copies in this profile.`,
218
218
  );
219
219
  } else if (totalCopied === 0) {
220
- console.log(
220
+ log(
221
221
  `${dryRun ? 'Nothing would be copied' : 'Nothing copied'}. ` +
222
222
  `${totalShared} shared via symlink, ${totalSkipped} skipped (already present).`,
223
223
  );
224
224
  } else {
225
- console.log(
225
+ log(
226
226
  `${dryRun ? 'Dry run: would copy' : 'Done. Copied'} ${totalCopied} item(s), ` +
227
227
  `${totalShared} already shared via symlinks` +
228
228
  (totalSkipped > 0 ? `, ${totalSkipped} skipped (already present)` : '') +
package/lib/doctor.js CHANGED
@@ -250,6 +250,16 @@ function checkProfileLinks(prof) {
250
250
  let stat;
251
251
  try { stat = fs.lstatSync(pp); } catch { stat = null; }
252
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
+
253
263
  if (!stat) {
254
264
  // Resource missing entirely — safe to auto-create the link.
255
265
  results.push(
@@ -761,4 +771,4 @@ async function runDoctor({ fix, force, json, prompt: promptFn }) {
761
771
  return doctorExitCode(results);
762
772
  }
763
773
 
764
- module.exports = { runChecks, runDoctor, checkCcShadow, checkMacKeychain };
774
+ module.exports = { runChecks, runDoctor, applyFixes, checkCcShadow, checkMacKeychain };
package/lib/share.js CHANGED
@@ -180,9 +180,54 @@ 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
+
183
227
  // Where one resource of a profile currently stands. Returns one of:
184
228
  // 'shared' — linked to ~/.claude/ (symlink, junction, or hard link)
185
- // 'private' — a real file/dir with content, unique to this profile
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)
186
231
  // 'empty' — a real but empty file/dir (link would replace it)
187
232
  // 'missing' — nothing at the path
188
233
  // 'foreign-link' — a symlink pointing somewhere other than ~/.claude/
@@ -190,6 +235,7 @@ function resourceState(profileDir, resource) {
190
235
  const p = path.join(profileDir, resource);
191
236
  if (isSharedResource(profileDir, resource)) return 'shared';
192
237
  if (isLink(p)) return 'foreign-link';
238
+ if (isMarkedPrivate(profileDir, resource)) return 'private';
193
239
  let st;
194
240
  try {
195
241
  st = fs.statSync(p);
@@ -302,6 +348,14 @@ function shareLink(profileName, opts = {}) {
302
348
  results.push({ resource, status: 'failed', message: err.message });
303
349
  }
304
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
+ }
305
359
  return results;
306
360
  }
307
361
 
@@ -385,6 +439,9 @@ function shareUnlink(profileName, opts = {}) {
385
439
  results.push({ resource, status: 'failed', message: err.message });
386
440
  }
387
441
  }
442
+ if (!dryRun) {
443
+ markPrivate(pdir, results.filter((r) => r.status === 'unlinked').map((r) => r.resource));
444
+ }
388
445
  return results;
389
446
  }
390
447
 
@@ -840,6 +897,10 @@ module.exports = {
840
897
  isLink,
841
898
  isSharedResource,
842
899
  resourceState,
900
+ PRIVATE_FILE,
901
+ markPrivate,
902
+ unmarkPrivate,
903
+ isMarkedPrivate,
843
904
  removeLink,
844
905
  shareLink,
845
906
  shareUnlink,
@@ -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>