claude-account-sync 0.3.0 → 0.5.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.
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import { type CliContext } from '../context.js';
3
+ export declare function registerBundleCommands(program: Command, ctx: CliContext): void;
@@ -1,7 +1,8 @@
1
- import { exportBundle, importBundle, collectAssets, writeAssets, parseManifest, serializeManifest, saveManifest, discoverProfiles, planApply, executeApply, backupFiles, } from 'ccprofiles-core';
1
+ import { exportBundle, importBundle, collectAssets, writeAssets, parseManifest, serializeManifest, saveManifest, executeApply, backupFiles, } from 'ccprofiles-core';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { requireManifest } from '../context.js';
5
+ import { planActions, planActionsPreflight } from '../plan.js';
5
6
  function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
6
7
  export function registerBundleCommands(program, ctx) {
7
8
  program.command('export <file>').description('export manifest + assets as a portable bundle (no secrets)')
@@ -26,12 +27,13 @@ export function registerBundleCommands(program, ctx) {
26
27
  process.exitCode = 1;
27
28
  return;
28
29
  }
30
+ await planActionsPreflight(ctx, m); // abort before touching local state if the bundle has an unresolvable secret ref
29
31
  if (!opts.dryRun) {
30
32
  await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
31
33
  await saveManifest(ctx.manifestRoot, m);
32
34
  await writeAssets(assets, m, ctx.platform);
33
35
  }
34
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
36
+ const actions = await planActions(ctx, m);
35
37
  const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!opts.dryRun });
36
38
  for (const line of res.performed)
37
39
  console.log(`${opts.dryRun ? '[dry-run] ' : ''}${line}`);
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import { type CliContext } from '../context.js';
3
+ export declare function registerManifestCommands(program: Command, ctx: CliContext): void;
@@ -1,10 +1,14 @@
1
- import { discoverProfiles, buildManifest, saveManifest, planApply, executeApply, } from 'ccprofiles-core';
1
+ import { discoverProfiles, buildManifest, saveManifest, loadManifest, preserveSecretRefs, executeApply, } from 'ccprofiles-core';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { requireManifest } from '../context.js';
5
+ import { planActions } from '../plan.js';
6
+ import { secretsStore } from './secrets.js';
3
7
  function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
4
8
  export function registerManifestCommands(program, ctx) {
5
9
  program.command('status').description('show live-vs-manifest drift').action(async () => {
6
10
  const m = await requireManifest(ctx);
7
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
11
+ const actions = await planActions(ctx, m);
8
12
  if (actions.length === 0) {
9
13
  console.log('in sync');
10
14
  return;
@@ -17,7 +21,7 @@ export function registerManifestCommands(program, ctx) {
17
21
  .option('--dry-run')
18
22
  .action(async (opts) => {
19
23
  const m = await requireManifest(ctx);
20
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
24
+ const actions = await planActions(ctx, m);
21
25
  if (actions.length === 0) {
22
26
  console.log('in sync — nothing to do');
23
27
  return;
@@ -30,6 +34,11 @@ export function registerManifestCommands(program, ctx) {
30
34
  });
31
35
  program.command('snapshot').description('overwrite manifest from live state').action(async () => {
32
36
  const m = buildManifest(await discoverProfiles(ctx.home), ctx.platform);
37
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
38
+ const oldM = await loadManifest(ctx.manifestRoot);
39
+ let store = null;
40
+ await preserveSecretRefs(m, oldM, async (name) => { store ??= await secretsStore(ctx); return store.get(name); });
41
+ }
33
42
  await saveManifest(ctx.manifestRoot, m);
34
43
  console.log(`snapshot: ${m.profiles.length} profiles, ${Object.keys(m.mcpServers).length} mcp servers`);
35
44
  });
@@ -50,9 +59,10 @@ export function registerManifestCommands(program, ctx) {
50
59
  env: {},
51
60
  links: src ? { ...src.links } : (m.hub ? { skills: 'hub', commands: 'hub' } : {}),
52
61
  mcp: src ? [...src.mcp] : [],
62
+ settingsEnv: {},
53
63
  });
54
64
  await saveManifest(ctx.manifestRoot, m);
55
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
65
+ const actions = await planActions(ctx, m);
56
66
  await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp() });
57
67
  console.log(`profile "${name}" created — launcher: cl-${name} (restart your shell)`);
58
68
  });
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import { type CliContext } from '../context.js';
3
+ export declare function registerMcpCommands(program: Command, ctx: CliContext): void;
@@ -1,8 +1,9 @@
1
- import { discoverProfiles, saveManifest, planApply, executeApply, } from 'ccprofiles-core';
1
+ import { saveManifest, executeApply, } from 'ccprofiles-core';
2
2
  import { requireManifest } from '../context.js';
3
+ import { planActions } from '../plan.js';
3
4
  function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
4
5
  async function applyNow(ctx, m, dryRun) {
5
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
6
+ const actions = await planActions(ctx, m);
6
7
  const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun });
7
8
  for (const line of res.performed)
8
9
  console.log(`${dryRun ? '[dry-run] ' : ''}${line}`);
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { CliContext } from '../context.js';
3
+ export declare function registerProfileCommands(program: Command, ctx: CliContext): void;
@@ -1,7 +1,8 @@
1
- import { discoverProfiles, buildManifest, saveManifest, loadManifest, ensureRootGitignore } from 'ccprofiles-core';
1
+ import { discoverProfiles, buildManifest, saveManifest, loadManifest, preserveSecretRefs, ensureRootGitignore } from 'ccprofiles-core';
2
2
  import { existsSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
+ import { KEY_VARS, secretsStore } from './secrets.js';
5
6
  export function registerProfileCommands(program, ctx) {
6
7
  program.command('list').description('list Claude Code profiles').action(async () => {
7
8
  const live = await discoverProfiles(ctx.home);
@@ -24,6 +25,11 @@ export function registerProfileCommands(program, ctx) {
24
25
  console.log('Re-run with --yes to write the manifest.');
25
26
  return;
26
27
  }
28
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
29
+ const oldM = await loadManifest(ctx.manifestRoot);
30
+ let store = null;
31
+ await preserveSecretRefs(manifest, oldM, async (name) => { store ??= await secretsStore(ctx); return store.get(name); });
32
+ }
27
33
  if (!existsSync(join(ctx.manifestRoot, '.git'))) {
28
34
  try {
29
35
  execFileSync('git', ['init', ctx.manifestRoot], { stdio: 'ignore' });
@@ -37,7 +43,10 @@ export function registerProfileCommands(program, ctx) {
37
43
  program.command('doctor').description('check setup health').action(async () => {
38
44
  const problems = [];
39
45
  const live = await discoverProfiles(ctx.home);
40
- for (const lp of live)
46
+ let m = null;
47
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml')))
48
+ m = await loadManifest(ctx.manifestRoot);
49
+ for (const lp of live) {
41
50
  for (const name of readdirSync(lp.dir)) {
42
51
  const f = join(lp.dir, name);
43
52
  try {
@@ -46,20 +55,27 @@ export function registerProfileCommands(program, ctx) {
46
55
  }
47
56
  catch { /* ignore */ }
48
57
  }
58
+ const pname = lp.dirName === '.claude' ? 'default' : lp.dirName.slice('.claude-'.length);
59
+ const decl = m?.profiles.find(p => p.name === pname) ?? null;
60
+ for (const varName of KEY_VARS) {
61
+ if (lp.settingsEnv[varName] && !decl?.settingsEnv[varName])
62
+ problems.push(`plaintext token ${varName} in ${join(lp.dir, 'settings.json')} — adopt profile then run: secrets migrate`);
63
+ if (decl?.settingsEnv[varName] && !decl.settingsEnv[varName].startsWith('secret://'))
64
+ problems.push(`plaintext token ${varName} in manifest for profile "${pname}" — run: secrets migrate`);
65
+ }
66
+ }
49
67
  if (existsSync(ctx.platform.rcFile)) {
50
68
  const rc = readFileSync(ctx.platform.rcFile, 'utf8');
51
69
  const outsideBlock = rc.split('# >>> ccprofiles managed >>>')[0] + (rc.split('# <<< ccprofiles managed <<<')[1] ?? '');
52
70
  if (/sk-ant-/.test(outsideBlock))
53
71
  problems.push(`plaintext Anthropic key found in ${ctx.platform.rcFile} — run: ccprofiles secrets migrate`);
54
72
  }
55
- if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
56
- const m = await loadManifest(ctx.manifestRoot);
73
+ if (m)
57
74
  for (const pr of m.profiles) {
58
75
  const dir = pr.dir.replace('{home}', ctx.home);
59
76
  if (!existsSync(dir))
60
77
  problems.push(`manifest profile "${pr.name}" missing on disk: ${dir} — run: ccprofiles apply`);
61
78
  }
62
- }
63
79
  if (problems.length === 0) {
64
80
  console.log('ok: no problems found');
65
81
  return;
@@ -0,0 +1,14 @@
1
+ import type { Command } from 'commander';
2
+ import { SecretsStore } from 'ccprofiles-core';
3
+ import type { CliContext } from '../context.js';
4
+ export declare function secretsStore(ctx: CliContext): Promise<SecretsStore>;
5
+ export declare const KEY_VARS: string[];
6
+ /** Move plaintext keys out of the shell rc file into the secrets store. Shared by the CLI and the UI API. */
7
+ export declare function migrateRcSecrets(ctx: CliContext, opts?: {
8
+ dryRun?: boolean;
9
+ }): Promise<string[]>;
10
+ /** Move plaintext token values in manifest settingsEnv into the secrets store as secret:// refs. */
11
+ export declare function migrateSettingsSecrets(ctx: CliContext, opts?: {
12
+ dryRun?: boolean;
13
+ }): Promise<string[]>;
14
+ export declare function registerSecretsCommands(program: Command, ctx: CliContext): void;
@@ -1,6 +1,7 @@
1
- import { SecretsStore, FileBackend, defaultBackend, backupFiles, atomicWrite } from 'ccprofiles-core';
1
+ import { SecretsStore, FileBackend, defaultBackend, backupFiles, atomicWrite, loadManifest, saveManifest, executeApply } from 'ccprofiles-core';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
4
5
  export async function secretsStore(ctx) {
5
6
  const pw = ctx.env.CCPROFILES_PASSPHRASE;
6
7
  const backend = pw
@@ -11,7 +12,7 @@ export async function secretsStore(ctx) {
11
12
  });
12
13
  return new SecretsStore(backend, ctx.secretsIndexPath);
13
14
  }
14
- const KEY_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN'];
15
+ export const KEY_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN'];
15
16
  // posix: export VAR="sk-..." powershell: $env:VAR = "sk-..."
16
17
  const MIGRATE_RE_POSIX = new RegExp(`^(\\s*(?:export\\s+)?(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
17
18
  const MIGRATE_RE_PWSH = new RegExp(`^(\\s*\\$env:(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
@@ -47,6 +48,29 @@ export async function migrateRcSecrets(ctx, opts = {}) {
47
48
  }
48
49
  return migrated;
49
50
  }
51
+ /** Move plaintext token values in manifest settingsEnv into the secrets store as secret:// refs. */
52
+ export async function migrateSettingsSecrets(ctx, opts = {}) {
53
+ if (!existsSync(join(ctx.manifestRoot, 'manifest.yaml')))
54
+ return [];
55
+ const m = await loadManifest(ctx.manifestRoot);
56
+ const store = await secretsStore(ctx);
57
+ const migrated = [];
58
+ for (const pr of m.profiles) {
59
+ for (const varName of KEY_VARS) {
60
+ const v = pr.settingsEnv[varName];
61
+ if (!v || v.startsWith('secret://'))
62
+ continue;
63
+ const secretName = `${varName.toLowerCase().replaceAll('_', '-')}-${pr.name}`;
64
+ if (!opts.dryRun)
65
+ await store.set(secretName, v);
66
+ pr.settingsEnv[varName] = `secret://${secretName}`;
67
+ migrated.push(secretName);
68
+ }
69
+ }
70
+ if (migrated.length && !opts.dryRun)
71
+ await saveManifest(ctx.manifestRoot, m);
72
+ return migrated;
73
+ }
50
74
  export function registerSecretsCommands(program, ctx) {
51
75
  const sec = program.command('secrets').description('manage secrets (values never stored in configs)');
52
76
  sec.command('set <name> [value]').action(async (name, value) => {
@@ -76,12 +100,22 @@ export function registerSecretsCommands(program, ctx) {
76
100
  console.log(`removed ${name}`);
77
101
  });
78
102
  sec.command('migrate').option('--dry-run').action(async (opts) => {
79
- const migrated = await migrateRcSecrets(ctx, opts);
103
+ const migrated = [...await migrateRcSecrets(ctx, opts), ...await migrateSettingsSecrets(ctx, opts)];
80
104
  if (migrated.length === 0) {
81
105
  console.log('no plaintext keys found');
82
106
  return;
83
107
  }
84
108
  for (const n of migrated)
85
109
  console.log(`${opts.dryRun ? '[dry-run] ' : ''}migrated ${n}`);
110
+ // Apply so the manifest's newly-minted secret:// refs are (re-)resolved and written back
111
+ // out — dynamic import avoids a static circular import (plan.ts imports this module for secretsStore).
112
+ if (!opts.dryRun && existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
113
+ const { planActions } = await import('../plan.js');
114
+ const m = await loadManifest(ctx.manifestRoot);
115
+ const actions = await planActions(ctx, m);
116
+ const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: new Date().toISOString().replace(/[:.]/g, '-') });
117
+ for (const line of res.performed)
118
+ console.log(`applied: ${line}`);
119
+ }
86
120
  });
87
121
  }
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { CliContext } from '../context.js';
3
+ export declare function registerSyncCommands(program: Command, ctx: CliContext): void;
@@ -1,6 +1,7 @@
1
- import { startSyncServer, pairWithServer, fetchRemote, fetchSecrets, loadDevices, saveDevices, parseManifest, saveManifest, writeAssets, discoverProfiles, planApply, executeApply, backupFiles, } from 'ccprofiles-core';
1
+ import { startSyncServer, pairWithServer, fetchRemote, fetchSecrets, loadDevices, saveDevices, parseManifest, saveManifest, writeAssets, executeApply, backupFiles, } from 'ccprofiles-core';
2
2
  import { join } from 'node:path';
3
3
  import { secretsStore } from './secrets.js';
4
+ import { planActions, planActionsPreflight } from '../plan.js';
4
5
  function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
5
6
  export function registerSyncCommands(program, ctx) {
6
7
  program.command('serve').description('serve this device for pairing/sync')
@@ -62,22 +63,30 @@ export function registerSyncCommands(program, ctx) {
62
63
  const { manifestYaml, assets } = await fetchRemote(device);
63
64
  const m = parseManifest(manifestYaml); // validate before touching anything
64
65
  console.log(`pulled manifest: ${m.profiles.length} profiles, ${Object.keys(m.mcpServers).length} mcp servers, ${Object.keys(assets).length} asset files`);
66
+ // Fetch+store secrets BEFORE touching local state, so a preflight below that needs a
67
+ // just-transferred secret succeeds instead of failing after the manifest is overwritten.
68
+ let values = {};
69
+ if (opts.withSecrets) {
70
+ values = await fetchSecrets(device, []);
71
+ if (!opts.dryRun) {
72
+ const store = await secretsStore(ctx);
73
+ for (const [k, v] of Object.entries(values))
74
+ await store.set(k, v);
75
+ }
76
+ }
77
+ // Validate the pulled manifest resolves cleanly before saving/applying anything — abort
78
+ // (propagating the Error) rather than leave the local manifest overwritten and broken.
79
+ await planActionsPreflight(ctx, m);
65
80
  if (!opts.dryRun) {
66
81
  await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
67
82
  await saveManifest(ctx.manifestRoot, m);
68
83
  await writeAssets(assets, m, ctx.platform);
69
84
  }
70
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
85
+ const actions = await planActions(ctx, m);
71
86
  const res = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!opts.dryRun });
72
87
  for (const line of res.performed)
73
88
  console.log(`${opts.dryRun ? '[dry-run] ' : ''}${line}`);
74
89
  if (opts.withSecrets) {
75
- const values = await fetchSecrets(device, []);
76
- if (!opts.dryRun) {
77
- const store = await secretsStore(ctx);
78
- for (const [k, v] of Object.entries(values))
79
- await store.set(k, v);
80
- }
81
90
  console.log(`${opts.dryRun ? '[dry-run] ' : ''}secrets transferred: ${Object.keys(values).join(', ') || 'none'}`);
82
91
  }
83
92
  });
@@ -0,0 +1,15 @@
1
+ import { Command } from 'commander';
2
+ import { type Manifest, type Platform } from 'ccprofiles-core';
3
+ export interface CliContext {
4
+ home: string;
5
+ platform: Platform;
6
+ manifestRoot: string;
7
+ secretsIndexPath: string;
8
+ secretsFilePath: string;
9
+ backupRoot: string;
10
+ env: NodeJS.ProcessEnv;
11
+ }
12
+ export declare function makeContext(env?: NodeJS.ProcessEnv): CliContext;
13
+ /** Load the manifest, or explain how to create one — never a raw ENOENT. */
14
+ export declare function requireManifest(ctx: CliContext): Promise<Manifest>;
15
+ export declare function buildProgram(ctx: CliContext): Command;
package/dist/context.js CHANGED
@@ -11,7 +11,17 @@ import { registerBundleCommands } from './commands/bundle.js';
11
11
  import { registerUiCommand } from './ui/command.js';
12
12
  export function makeContext(env = process.env) {
13
13
  const testHome = env.CCPROFILES_TEST_HOME;
14
- const platform = detectPlatform(testHome ? { home: testHome, shell: env.SHELL } : {});
14
+ // CCPROFILES_FORCE_OS is a test-only seam for simulating other platforms' secrets-backend
15
+ // selection from a single dev machine — never set this outside tests. It intentionally
16
+ // accepts any sentinel string (not just OsKind): detectPlatform/defaultBackend only ever
17
+ // compare it against 'darwin'/'linux'/'win32' at runtime, so a value like 'none' safely
18
+ // flows through as a deterministic "none of the above" platform for tests. Gated behind
19
+ // CCPROFILES_TEST_HOME (the existing test-only signal) so it can never affect production.
20
+ const forcedOs = testHome ? env.CCPROFILES_FORCE_OS : undefined;
21
+ const platform = detectPlatform({
22
+ ...(testHome ? { home: testHome, shell: env.SHELL } : {}),
23
+ ...(forcedOs ? { osKind: forcedOs } : {}),
24
+ });
15
25
  const manifestRoot = env.CCPROFILES_HOME ?? join(platform.home, '.ccprofiles');
16
26
  return {
17
27
  home: platform.home,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/plan.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type ApplyAction, type Manifest } from 'ccprofiles-core';
2
+ import type { CliContext } from './context.js';
3
+ /** Plan apply actions with settingsEnv secret refs resolved from the secrets store (lazily opened). */
4
+ export declare function planActions(ctx: CliContext, m: Manifest): Promise<ApplyAction[]>;
5
+ /** Resolve settingsEnv secrets without planning — cheap validation that refs exist. */
6
+ export declare function planActionsPreflight(ctx: CliContext, m: Manifest): Promise<void>;
package/dist/plan.js ADDED
@@ -0,0 +1,19 @@
1
+ import { discoverProfiles, planApply, resolveSettingsEnv } from 'ccprofiles-core';
2
+ import { secretsStore } from './commands/secrets.js';
3
+ /** Plan apply actions with settingsEnv secret refs resolved from the secrets store (lazily opened). */
4
+ export async function planActions(ctx, m) {
5
+ let store = null;
6
+ const resolved = await resolveSettingsEnv(m, async (name) => {
7
+ store ??= await secretsStore(ctx);
8
+ return store.get(name);
9
+ });
10
+ return planApply(m, await discoverProfiles(ctx.home), ctx.platform, resolved);
11
+ }
12
+ /** Resolve settingsEnv secrets without planning — cheap validation that refs exist. */
13
+ export async function planActionsPreflight(ctx, m) {
14
+ let store = null;
15
+ await resolveSettingsEnv(m, async (name) => {
16
+ store ??= await secretsStore(ctx);
17
+ return store.get(name);
18
+ });
19
+ }
@@ -0,0 +1,3 @@
1
+ import { type CliContext } from '../context.js';
2
+ import { type Route } from './http.js';
3
+ export declare function buildRoutes(ctx: CliContext): Route[];
package/dist/ui/api.js CHANGED
@@ -1,10 +1,11 @@
1
- import { discoverProfiles, buildManifest, saveManifest, planApply, executeApply, ensureRootGitignore, loadManifest, loadDevices, fetchRemote, fetchSecrets, writeAssets, parseManifest, backupFiles, renderRcBlock, upsertManagedBlock, atomicWrite, BEGIN_MARK, END_MARK, assertSafeManifest, } from 'ccprofiles-core';
1
+ import { discoverProfiles, buildManifest, saveManifest, executeApply, ensureRootGitignore, loadManifest, loadDevices, fetchRemote, fetchSecrets, writeAssets, parseManifest, backupFiles, renderRcBlock, upsertManagedBlock, atomicWrite, BEGIN_MARK, END_MARK, assertSafeManifest, preserveSecretRefs, } from 'ccprofiles-core';
2
2
  import { existsSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { requireManifest } from '../context.js';
6
- import { secretsStore, migrateRcSecrets } from '../commands/secrets.js';
6
+ import { secretsStore, migrateRcSecrets, migrateSettingsSecrets, KEY_VARS } from '../commands/secrets.js';
7
7
  import { sendJson, readJson, HttpError } from './http.js';
8
+ import { planActions, planActionsPreflight } from '../plan.js';
8
9
  function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
9
10
  function profileName(dirName) {
10
11
  return dirName === '.claude' ? 'default' : dirName.slice('.claude-'.length);
@@ -12,8 +13,20 @@ function profileName(dirName) {
12
13
  export function buildRoutes(ctx) {
13
14
  const routes = [];
14
15
  const add = (method, pattern, handler) => routes.push({ method, pattern, handler });
16
+ // A missing secret ref is a recoverable/user-facing state (not a server error) — surface as 409.
17
+ async function planActionsOr409(m) {
18
+ try {
19
+ return await planActions(ctx, m);
20
+ }
21
+ catch (e) {
22
+ const msg = e.message;
23
+ if (/secret not found/.test(msg))
24
+ throw new HttpError(409, msg);
25
+ throw e;
26
+ }
27
+ }
15
28
  async function applyAndReport(m) {
16
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
29
+ const actions = await planActionsOr409(m);
17
30
  return executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp() });
18
31
  }
19
32
  // requireManifest throws a plain Error; surface "no manifest yet" as a 409 the UI can detect.
@@ -45,6 +58,11 @@ export function buildRoutes(ctx) {
45
58
  // ── adopt / profiles / status / apply / doctor ──────────────────────────────
46
59
  add('POST', /^\/api\/adopt$/, async (_m, _req, res) => {
47
60
  const manifest = buildManifest(await discoverProfiles(ctx.home), ctx.platform);
61
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
62
+ const oldM = await loadManifest(ctx.manifestRoot);
63
+ let store = null;
64
+ await preserveSecretRefs(manifest, oldM, async (name) => { store ??= await secretsStore(ctx); return store.get(name); });
65
+ }
48
66
  if (!existsSync(join(ctx.manifestRoot, '.git'))) {
49
67
  try {
50
68
  execFileSync('git', ['init', ctx.manifestRoot], { stdio: 'ignore' });
@@ -69,6 +87,7 @@ export function buildRoutes(ctx) {
69
87
  launcher: decl?.launcher ?? (name === 'default' ? null : `cl-${name}`),
70
88
  adopted: !!decl,
71
89
  env: decl?.env ?? {}, links: decl?.links ?? {}, mcpNames: decl?.mcp ?? [],
90
+ settingsEnv: decl?.settingsEnv ?? {}, liveSettingsEnv: lp.settingsEnv,
72
91
  };
73
92
  });
74
93
  sendJson(res, 200, rows);
@@ -87,6 +106,7 @@ export function buildRoutes(ctx) {
87
106
  name, dir: `{home}/.claude-${name}`, launcher: `cl-${name}`, auth: 'env', env: {},
88
107
  links: src ? { ...src.links } : (m.hub ? { skills: 'hub', commands: 'hub' } : {}),
89
108
  mcp: src ? [...src.mcp] : [],
109
+ settingsEnv: {},
90
110
  });
91
111
  assertSafe(m);
92
112
  await saveManifest(ctx.manifestRoot, m);
@@ -112,7 +132,19 @@ export function buildRoutes(ctx) {
112
132
  }
113
133
  if (body.launcher !== undefined)
114
134
  pr.launcher = body.launcher;
135
+ if (body.settingsEnv) {
136
+ for (const v of Object.values(body.settingsEnv))
137
+ if (typeof v !== 'string')
138
+ throw new HttpError(400, 'settingsEnv values must be strings');
139
+ pr.settingsEnv = body.settingsEnv;
140
+ }
115
141
  assertSafe(m);
142
+ try {
143
+ await planActionsPreflight(ctx, m);
144
+ }
145
+ catch (e) {
146
+ throw new HttpError(400, e.message);
147
+ }
116
148
  await saveManifest(ctx.manifestRoot, m);
117
149
  await applyAndReport(m);
118
150
  sendJson(res, 200, { ok: true });
@@ -136,7 +168,7 @@ export function buildRoutes(ctx) {
136
168
  });
137
169
  add('GET', /^\/api\/status$/, async (_m, _req, res) => {
138
170
  const m = await mustManifest();
139
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
171
+ const actions = await planActionsOr409(m);
140
172
  const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: true });
141
173
  sendJson(res, 200, { inSync: actions.length === 0, pending: r.performed });
142
174
  });
@@ -148,7 +180,10 @@ export function buildRoutes(ctx) {
148
180
  add('GET', /^\/api\/doctor$/, async (_m, _req, res) => {
149
181
  const problems = [];
150
182
  const live = await discoverProfiles(ctx.home);
151
- for (const lp of live)
183
+ let man = null;
184
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml')))
185
+ man = await loadManifest(ctx.manifestRoot);
186
+ for (const lp of live) {
152
187
  for (const nm of readdirSync(lp.dir)) {
153
188
  const f = join(lp.dir, nm);
154
189
  try {
@@ -157,20 +192,26 @@ export function buildRoutes(ctx) {
157
192
  }
158
193
  catch { /* skip */ }
159
194
  }
195
+ const decl = man?.profiles.find(p => p.name === profileName(lp.dirName)) ?? null;
196
+ for (const varName of KEY_VARS) {
197
+ if (lp.settingsEnv[varName] && !decl?.settingsEnv[varName])
198
+ problems.push(`plaintext token ${varName} in ${join(lp.dir, 'settings.json')} — adopt profile then run: secrets migrate`);
199
+ if (decl?.settingsEnv[varName] && !decl.settingsEnv[varName].startsWith('secret://'))
200
+ problems.push(`plaintext token ${varName} in manifest for profile "${profileName(lp.dirName)}" — run: secrets migrate`);
201
+ }
202
+ }
160
203
  if (existsSync(ctx.platform.rcFile)) {
161
204
  const rc = readFileSync(ctx.platform.rcFile, 'utf8');
162
205
  const outside = rc.split('# >>> ccprofiles managed >>>')[0] + (rc.split('# <<< ccprofiles managed <<<')[1] ?? '');
163
206
  if (/sk-ant-/.test(outside))
164
207
  problems.push(`plaintext Anthropic key in ${ctx.platform.rcFile} — run secrets migrate`);
165
208
  }
166
- if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
167
- const m = await loadManifest(ctx.manifestRoot);
168
- for (const pr of m.profiles) {
209
+ if (man)
210
+ for (const pr of man.profiles) {
169
211
  const dir = pr.dir.replace('{home}', ctx.home);
170
212
  if (!existsSync(dir))
171
213
  problems.push(`manifest profile "${pr.name}" missing on disk: ${dir} — run apply`);
172
214
  }
173
- }
174
215
  sendJson(res, 200, { problems });
175
216
  });
176
217
  // ── shell rc ────────────────────────────────────────────────────────────────
@@ -263,7 +304,13 @@ export function buildRoutes(ctx) {
263
304
  });
264
305
  // ── secrets ─────────────────────────────────────────────────────────────────
265
306
  add('GET', /^\/api\/secrets$/, async (_m, _req, res) => {
266
- const store = await secretsStore(ctx);
307
+ let store;
308
+ try {
309
+ store = await secretsStore(ctx);
310
+ }
311
+ catch (e) {
312
+ return sendJson(res, 200, { names: [], backend: 'unavailable', error: e.message });
313
+ }
267
314
  sendJson(res, 200, { names: await store.list(), backend: store.backendName });
268
315
  });
269
316
  add('GET', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
@@ -282,12 +329,31 @@ export function buildRoutes(ctx) {
282
329
  sendJson(res, 200, { ok: true });
283
330
  });
284
331
  add('DELETE', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
332
+ const name = decodeURIComponent(mtch[1]);
333
+ const ref = `secret://${name}`;
334
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
335
+ const m = await loadManifest(ctx.manifestRoot);
336
+ for (const pr of m.profiles) {
337
+ for (const [k, v] of Object.entries(pr.env))
338
+ if (v === ref)
339
+ throw new HttpError(409, `secret "${name}" is referenced by profile "${pr.name}" (${k}) — detach it first`);
340
+ for (const [k, v] of Object.entries(pr.settingsEnv))
341
+ if (v === ref)
342
+ throw new HttpError(409, `secret "${name}" is referenced by profile "${pr.name}" (${k}) — detach it first`);
343
+ }
344
+ }
285
345
  const store = await secretsStore(ctx);
286
- await store.delete(decodeURIComponent(mtch[1]));
346
+ await store.delete(name);
287
347
  sendJson(res, 200, { ok: true });
288
348
  });
289
349
  add('POST', /^\/api\/secrets\/migrate$/, async (_m, _req, res) => {
290
- sendJson(res, 200, { migrated: await migrateRcSecrets(ctx) });
350
+ const migrated = [...await migrateRcSecrets(ctx), ...await migrateSettingsSecrets(ctx)];
351
+ // Apply so the manifest's newly-minted secret:// refs are (re-)resolved and written back out.
352
+ if (migrated.length && existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
353
+ const m = await loadManifest(ctx.manifestRoot);
354
+ await applyAndReport(m);
355
+ }
356
+ sendJson(res, 200, { migrated });
291
357
  });
292
358
  // ── devices / sync ──────────────────────────────────────────────────────────
293
359
  add('GET', /^\/api\/devices$/, async (_m, _req, res) => {
@@ -300,13 +366,8 @@ export function buildRoutes(ctx) {
300
366
  throw new HttpError(400, `unknown device: ${from}`);
301
367
  const { manifestYaml, assets } = await fetchRemote(device);
302
368
  const m = parseManifest(manifestYaml);
303
- if (!dryRun) {
304
- await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
305
- await saveManifest(ctx.manifestRoot, m);
306
- await writeAssets(assets, m, ctx.platform);
307
- }
308
- const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
309
- const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!dryRun });
369
+ // Fetch+store secrets BEFORE touching local state, so a preflight below that needs
370
+ // a just-transferred secret succeeds instead of failing after the manifest is overwritten.
310
371
  let secrets = [];
311
372
  if (withSecrets) {
312
373
  const values = await fetchSecrets(device, []);
@@ -317,6 +378,21 @@ export function buildRoutes(ctx) {
317
378
  }
318
379
  secrets = Object.keys(values);
319
380
  }
381
+ // Validate the pulled manifest resolves cleanly before saving/applying anything — a peer
382
+ // manifest with a settingsEnv secret ref we don't have must not overwrite local state.
383
+ try {
384
+ await planActionsPreflight(ctx, m);
385
+ }
386
+ catch (e) {
387
+ throw new HttpError(409, e.message);
388
+ }
389
+ if (!dryRun) {
390
+ await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
391
+ await saveManifest(ctx.manifestRoot, m);
392
+ await writeAssets(assets, m, ctx.platform);
393
+ }
394
+ const actions = await planActions(ctx, m);
395
+ const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!dryRun });
320
396
  sendJson(res, 200, { performed: r.performed, secrets });
321
397
  });
322
398
  return routes;
@@ -0,0 +1,3 @@
1
+ import type { Command } from 'commander';
2
+ import type { CliContext } from '../context.js';
3
+ export declare function registerUiCommand(program: Command, ctx: CliContext): void;
@@ -0,0 +1,20 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ export declare class HttpError extends Error {
3
+ status: number;
4
+ constructor(status: number, message: string);
5
+ }
6
+ export declare class BadRequest extends HttpError {
7
+ constructor(message?: string);
8
+ }
9
+ export declare function readBody(req: IncomingMessage): Promise<string>;
10
+ export declare function readJson<T = any>(req: IncomingMessage): Promise<T>;
11
+ export declare function sendJson(res: ServerResponse, code: number, body: unknown): void;
12
+ export type Route = {
13
+ method: string;
14
+ pattern: RegExp;
15
+ handler: (m: RegExpMatchArray, req: IncomingMessage, res: ServerResponse) => Promise<void>;
16
+ };
17
+ export declare function matchRoute(routes: Route[], method: string, path: string): {
18
+ route: Route;
19
+ match: RegExpMatchArray;
20
+ } | null;