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.
- package/dist/commands/bundle.d.ts +3 -0
- package/dist/commands/bundle.js +4 -2
- package/dist/commands/manifest.d.ts +3 -0
- package/dist/commands/manifest.js +14 -4
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.js +3 -2
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +21 -5
- package/dist/commands/secrets.d.ts +14 -0
- package/dist/commands/secrets.js +37 -3
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.js +17 -8
- package/dist/context.d.ts +15 -0
- package/dist/context.js +11 -1
- package/dist/index.d.ts +2 -0
- package/dist/plan.d.ts +6 -0
- package/dist/plan.js +19 -0
- package/dist/ui/api.d.ts +3 -0
- package/dist/ui/api.js +95 -19
- package/dist/ui/command.d.ts +3 -0
- package/dist/ui/http.d.ts +20 -0
- package/dist/ui/server.d.ts +9 -0
- package/dist/ui/static.d.ts +2 -0
- package/dist/ui/token.d.ts +3 -0
- package/dist/webui/assets/index-B9Ee16GZ.css +1 -0
- package/dist/webui/assets/index-DEiJLy_b.js +143 -0
- package/dist/webui/index.html +2 -2
- package/package.json +32 -7
- package/dist/webui/assets/index-BDxkKXQV.css +0 -1
- package/dist/webui/assets/index-XneMINpm.js +0 -143
package/dist/commands/bundle.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { exportBundle, importBundle, collectAssets, writeAssets, parseManifest, serializeManifest, saveManifest,
|
|
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 =
|
|
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}`);
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { discoverProfiles, buildManifest, saveManifest,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
});
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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}`);
|
|
@@ -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
|
-
|
|
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 (
|
|
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;
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { startSyncServer, pairWithServer, fetchRemote, fetchSecrets, loadDevices, saveDevices, parseManifest, saveManifest, writeAssets,
|
|
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 =
|
|
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
|
-
|
|
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,
|
package/dist/index.d.ts
ADDED
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
|
+
}
|
package/dist/ui/api.d.ts
ADDED
package/dist/ui/api.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { discoverProfiles, buildManifest, saveManifest,
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
167
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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,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;
|