claude-account-sync 0.3.0 → 0.4.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;
@@ -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 ────────────────────────────────────────────────────────────────
@@ -282,12 +323,31 @@ export function buildRoutes(ctx) {
282
323
  sendJson(res, 200, { ok: true });
283
324
  });
284
325
  add('DELETE', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
326
+ const name = decodeURIComponent(mtch[1]);
327
+ const ref = `secret://${name}`;
328
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
329
+ const m = await loadManifest(ctx.manifestRoot);
330
+ for (const pr of m.profiles) {
331
+ for (const [k, v] of Object.entries(pr.env))
332
+ if (v === ref)
333
+ throw new HttpError(409, `secret "${name}" is referenced by profile "${pr.name}" (${k}) — detach it first`);
334
+ for (const [k, v] of Object.entries(pr.settingsEnv))
335
+ if (v === ref)
336
+ throw new HttpError(409, `secret "${name}" is referenced by profile "${pr.name}" (${k}) — detach it first`);
337
+ }
338
+ }
285
339
  const store = await secretsStore(ctx);
286
- await store.delete(decodeURIComponent(mtch[1]));
340
+ await store.delete(name);
287
341
  sendJson(res, 200, { ok: true });
288
342
  });
289
343
  add('POST', /^\/api\/secrets\/migrate$/, async (_m, _req, res) => {
290
- sendJson(res, 200, { migrated: await migrateRcSecrets(ctx) });
344
+ const migrated = [...await migrateRcSecrets(ctx), ...await migrateSettingsSecrets(ctx)];
345
+ // Apply so the manifest's newly-minted secret:// refs are (re-)resolved and written back out.
346
+ if (migrated.length && existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
347
+ const m = await loadManifest(ctx.manifestRoot);
348
+ await applyAndReport(m);
349
+ }
350
+ sendJson(res, 200, { migrated });
291
351
  });
292
352
  // ── devices / sync ──────────────────────────────────────────────────────────
293
353
  add('GET', /^\/api\/devices$/, async (_m, _req, res) => {
@@ -300,13 +360,8 @@ export function buildRoutes(ctx) {
300
360
  throw new HttpError(400, `unknown device: ${from}`);
301
361
  const { manifestYaml, assets } = await fetchRemote(device);
302
362
  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 });
363
+ // Fetch+store secrets BEFORE touching local state, so a preflight below that needs
364
+ // a just-transferred secret succeeds instead of failing after the manifest is overwritten.
310
365
  let secrets = [];
311
366
  if (withSecrets) {
312
367
  const values = await fetchSecrets(device, []);
@@ -317,6 +372,21 @@ export function buildRoutes(ctx) {
317
372
  }
318
373
  secrets = Object.keys(values);
319
374
  }
375
+ // Validate the pulled manifest resolves cleanly before saving/applying anything — a peer
376
+ // manifest with a settingsEnv secret ref we don't have must not overwrite local state.
377
+ try {
378
+ await planActionsPreflight(ctx, m);
379
+ }
380
+ catch (e) {
381
+ throw new HttpError(409, e.message);
382
+ }
383
+ if (!dryRun) {
384
+ await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
385
+ await saveManifest(ctx.manifestRoot, m);
386
+ await writeAssets(assets, m, ctx.platform);
387
+ }
388
+ const actions = await planActions(ctx, m);
389
+ const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!dryRun });
320
390
  sendJson(res, 200, { performed: r.performed, secrets });
321
391
  });
322
392
  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;
@@ -0,0 +1,9 @@
1
+ import type { CliContext } from '../context.js';
2
+ export declare function startUiServer(ctx: CliContext, opts: {
3
+ port?: number;
4
+ token: string;
5
+ uiDir: string;
6
+ }): Promise<{
7
+ port: number;
8
+ close: () => Promise<void>;
9
+ }>;
@@ -0,0 +1,2 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ export declare function serveStatic(res: ServerResponse, urlPath: string, uiDir: string): void;
@@ -0,0 +1,3 @@
1
+ export declare function newUiToken(): string;
2
+ export declare function tokenOk(provided: string | undefined, expected: string): boolean;
3
+ export declare function originOk(origin: string | undefined, port: number): boolean;