claude-account-sync 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -4,13 +4,14 @@
4
4
 
5
5
  Run Claude Code with several accounts — personal subscription, work OAuth, API key, alternative providers — each in its own `CLAUDE_CONFIG_DIR`? Then you know the pain: MCP server lists drift apart, skills get shared via hand-made symlinks, API keys end up in plaintext in your `.zshrc`, and setting up a second machine means an afternoon of copy-paste.
6
6
 
7
- The `ccprofiles` command (short alias `clp`) fixes that:
7
+ The `clp` command (also available as `ccprofiles`) fixes that:
8
8
 
9
9
  - 🔎 **Adopt** your existing `.claude*` directories into a declarative manifest — zero manual config
10
10
  - 🧩 **Manage MCP servers** across profiles: drift matrix, add/remove everywhere at once, sync one profile's set to others
11
- - 🔐 **Secrets out of your rc files** — macOS Keychain / libsecret / encrypted file, with `ccprofiles secrets migrate` to clean up existing plaintext keys
11
+ - 🔐 **Secrets out of your rc files** — macOS Keychain / libsecret / encrypted file, with `clp secrets migrate` to clean up existing plaintext keys
12
12
  - 🖥️ **Replicate to another machine over LAN** — PIN pairing, end-to-end encrypted, no cloud, works macOS ↔ Windows ↔ Linux ↔ WSL
13
- - 📦 **Offline bundles** for the no-network case (`ccprofiles export setup.ccb`)
13
+ - 🖼️ **Web dashboard** `clp ui` opens a local browser panel to manage everything visually
14
+ - 📦 **Offline bundles** for the no-network case (`clp export setup.ccb`)
14
15
  - 🛟 **Safe by design**: surgical config edits only, automatic backups, `--dry-run` everywhere, never touches your sessions/history
15
16
 
16
17
  ## Install
@@ -21,7 +22,7 @@ Requires **Node ≥ 20** (you have it — Claude Code needs it too).
21
22
  npm install -g claude-account-sync
22
23
  ```
23
24
 
24
- This installs two equivalent commands: `ccprofiles` (full) and `clp` (short). Use whichever you like.
25
+ This installs two equivalent commands: `clp` (short) and `ccprofiles` (full). The docs below use `clp`.
25
26
 
26
27
  <details><summary>Install from source instead</summary>
27
28
 
@@ -37,26 +38,28 @@ cd packages/cli && npm link
37
38
  ## Quickstart
38
39
 
39
40
  ```bash
40
- ccprofiles adopt --yes # REQUIRED FIRST: scan ~/.claude* and build the manifest
41
- ccprofiles list # see all your profiles
42
- ccprofiles doctor # find broken links & plaintext keys
43
- ccprofiles secrets migrate # move API keys from .zshrc into the OS keychain
41
+ clp adopt --yes # REQUIRED FIRST: scan ~/.claude* and build the manifest
42
+ clp list # see all your profiles
43
+ clp doctor # find broken links & plaintext keys
44
+ clp secrets migrate # move API keys from .zshrc into the OS keychain
44
45
  ```
45
46
 
46
- Everything except `list`, `adopt`, and `doctor` needs the manifest, so `ccprofiles adopt --yes` is always step one — commands will remind you if you skip it.
47
+ Everything except `list`, `adopt`, and `doctor` needs the manifest, so `clp adopt --yes` is always step one — commands will remind you if you skip it.
48
+
49
+ Prefer a UI? `clp ui` opens the whole thing in your browser (see below).
47
50
 
48
51
  ### Manage MCP servers
49
52
 
50
53
  ```bash
51
- ccprofiles mcp list # server × profile drift matrix
52
- ccprofiles mcp add shadcn --all --command npx --args "shadcn@latest,mcp"
53
- ccprofiles mcp sync --from oauth --to office,z # make profiles match
54
+ clp mcp list # server × profile drift matrix
55
+ clp mcp add shadcn --all --command npx --args "shadcn@latest,mcp"
56
+ clp mcp sync --from oauth --to office,z # make profiles match
54
57
  ```
55
58
 
56
59
  ### New profile for a new account
57
60
 
58
61
  ```bash
59
- ccprofiles create work --from oauth # dir + launcher fn + copied MCP set
62
+ clp create work --from oauth # dir + launcher fn + copied MCP set
60
63
  # restart shell, then:
61
64
  cl-work # launches claude with CLAUDE_CONFIG_DIR=~/.claude-work
62
65
  ```
@@ -65,13 +68,13 @@ cl-work # launches claude with CLAUDE_CONFIG_DIR=~/.cla
65
68
 
66
69
  ```bash
67
70
  # machine A (source of truth)
68
- ccprofiles serve --allow-secrets
71
+ clp serve --allow-secrets
69
72
  # → ccprofiles sync server on port 51234
70
73
  # → pairing PIN: 123456
71
74
 
72
75
  # machine B
73
- ccprofiles pair 192.168.1.10 --port 51234 --pin 123456 --name mac
74
- ccprofiles sync --from mac --with-secrets
76
+ clp pair 192.168.1.10 --port 51234 --pin 123456 --name mac
77
+ clp sync --from mac --with-secrets
75
78
  ```
76
79
 
77
80
  Manifest, MCP servers, skills, commands, launcher functions, and (opt-in) secrets all arrive — rendered for the local OS: PowerShell profile functions and junctions on Windows, `.zshrc`/`.bashrc` functions and symlinks elsewhere.
@@ -81,21 +84,29 @@ Two things intentionally don't travel:
81
84
  - **OAuth sessions** — you still run `/login` once per account on the new machine (Anthropic session state is machine-bound; syncing it would be wrong).
82
85
  - The **`default` profile has no `cl-*` launcher** — it's what plain `claude` already launches; only the named profiles get launcher functions.
83
86
 
87
+ ## Web dashboard
88
+
89
+ ```bash
90
+ clp ui # opens http://127.0.0.1:<port>/?t=<token> in your browser
91
+ ```
92
+
93
+ A local panel to manage profiles, MCP servers (interactive drift matrix), secrets (add / reveal / delete / migrate), sync, and doctor — everything the CLI does. It's **localhost-only** and guarded by a per-launch session token plus an Origin check, so nothing off your machine (and no website in your browser) can reach the API. Pass `--no-open` to just print the URL, or `--port <n>` to pin the port.
94
+
84
95
  ## How it works
85
96
 
86
97
  Three layers of state:
87
98
 
88
- 1. **Live state** — your actual `.claude*` dirs and shell rc files. Claude Code owns these; ccprofiles edits only the keys it manages (`mcpServers`, its marked rc block, its links).
99
+ 1. **Live state** — your actual `.claude*` dirs and shell rc files. Claude Code owns these; the tool edits only the keys it manages (`mcpServers`, its marked rc block, its links).
89
100
  2. **Manifest** — `~/.ccprofiles/manifest.yaml`, a platform-neutral declaration (paths templated as `{home}`, secrets referenced as `secret://name`). Versioned with local git commits; safe to share.
90
- 3. **Secrets store** — per-machine keychain: macOS Keychain, Linux `secret-tool` (libsecret), or an AES-256-GCM encrypted file as fallback (native Windows and headless Linux — set `CCPROFILES_PASSPHRASE` in your environment for it). Values never appear in the manifest, bundles, or rc files; launchers resolve them at run time via `ccprofiles secrets get`.
101
+ 3. **Secrets store** — per-machine keychain: macOS Keychain, Linux `secret-tool` (libsecret), or an AES-256-GCM encrypted file as fallback (native Windows and headless Linux — set `CCPROFILES_PASSPHRASE` in your environment for it). Values never appear in the manifest, bundles, or rc files; launcher functions resolve them at run time by calling the CLI.
91
102
 
92
- > ⚠️ `ccprofiles secrets set <name> <value>` takes the value as an argument, which lands in your shell history — prefer `ccprofiles secrets migrate` (reads from rc files) or clear history after. Interactive prompting is on the roadmap.
103
+ > ⚠️ `clp secrets set <name> <value>` takes the value as an argument, which lands in your shell history — prefer `clp secrets migrate` (reads from rc files) or clear history after. Interactive prompting is on the roadmap.
93
104
 
94
- `ccprofiles status` shows drift between manifest and live; `ccprofiles apply` reconciles (with backups under `~/.ccprofiles/backups/`); `ccprofiles snapshot` goes the other way (live → manifest).
105
+ `clp status` shows drift between manifest and live; `clp apply` reconciles (with backups under `~/.ccprofiles/backups/`); `clp snapshot` goes the other way (live → manifest).
95
106
 
96
107
  ### Sync security model
97
108
 
98
- Pairing performs an X25519 ECDH key exchange authenticated by the 6-digit PIN shown on the serving device (HMAC confirmation both ways — a MITM on your network cannot complete pairing without the PIN, and the client verifies the server too). All subsequent payloads are AES-256-GCM encrypted with the pairing key. Secrets transfer additionally requires the server to opt in with `--allow-secrets`, and values go straight into the receiving machine's keychain.
109
+ Pairing performs an X25519 ECDH key exchange authenticated by the 6-digit PIN shown on the serving device (HMAC confirmation both ways — a MITM on your network cannot complete pairing without the PIN, and the client verifies the server too). All subsequent payloads are AES-256-GCM encrypted with the pairing key. Secrets transfer additionally requires the server to opt in with `--allow-secrets`, and values go straight into the receiving machine's keychain. Pairing locks after 5 wrong PINs.
99
110
 
100
111
  ## Commands
101
112
 
@@ -107,6 +118,7 @@ Pairing performs an X25519 ECDH key exchange authenticated by the 6-digit PIN sh
107
118
  | Manifest | `status` · `apply` · `snapshot` |
108
119
  | Sync | `serve [--allow-secrets]` · `pair <host> --port n --pin p` · `devices` · `sync --from dev [--with-secrets]` |
109
120
  | Bundle | `export <file>` · `import <file>` |
121
+ | Dashboard | `ui [--port n] [--no-open]` |
110
122
 
111
123
  All mutating commands support `--dry-run`. Every mutation backs up the files it touches to `~/.ccprofiles/backups/<timestamp>/` first.
112
124
 
@@ -114,33 +126,33 @@ All mutating commands support `--dry-run`. Every mutation backs up the files it
114
126
 
115
127
  | Symptom | Fix |
116
128
  |---|---|
117
- | `error: no manifest yet` | Run `ccprofiles adopt --yes` first — it builds the manifest from your existing profiles |
118
- | `zsh: command not found: ccp` | Not linked/installed — see Install; if just linked, run `rehash` |
119
- | ``cannot reach <host> — is `ccprofiles serve` running?`` | Start `ccprofiles serve` on the other device; check you're on the same network and the port matches |
129
+ | `error: no manifest yet` | Run `clp adopt --yes` first — it builds the manifest from your existing profiles |
130
+ | `zsh: command not found: clp` | Not linked/installed — see Install; if just linked, run `rehash` |
131
+ | ``cannot reach <host> — is `ccprofiles serve` running?`` | Start `clp serve` on the other device; check you're on the same network and the port matches |
120
132
  | `encrypted-file backend requires a passphrase` | Set `CCPROFILES_PASSPHRASE` (Windows / Linux without libsecret) |
121
133
  | Profile shows account `-` after sync | Expected — run `/login` inside that profile once; OAuth sessions don't sync |
122
134
  | Something went wrong after `apply` | Restore from `~/.ccprofiles/backups/<latest>/` |
123
135
 
124
136
  ## Roadmap
125
137
 
126
- - Web dashboard on top of `ccprofiles-core`
127
- - mDNS auto-discovery for `ccprofiles devices`
138
+ - mDNS auto-discovery for `clp devices`
139
+ - Pair devices from the dashboard (currently CLI-only)
128
140
  - Windows Credential Manager (DPAPI) secrets backend
129
141
  - Interactive prompts (`secrets set` without echoing, `adopt` confirmation)
130
142
 
131
143
  ## Development
132
144
 
133
- npm-workspaces monorepo: `packages/core` (library) + `packages/cli` (commander wrapper).
145
+ npm-workspaces monorepo: `packages/core` (library) + `packages/cli` (commander wrapper) + `packages/ui` (dashboard). The published CLI is [`claude-account-sync`](https://www.npmjs.com/package/claude-account-sync); the library is [`ccprofiles-core`](https://www.npmjs.com/package/ccprofiles-core).
134
146
 
135
147
  ```bash
136
148
  npm install
137
149
  npm test # vitest — unit + e2e (incl. an in-process two-machine sync test)
138
- npm run build
150
+ npm run build # builds core + cli + the dashboard, bundled into the CLI
139
151
  ```
140
152
 
141
153
  ## Related projects
142
154
 
143
- Not to be confused with [samhvw8/claude-code-profile](https://github.com/samhvw8/claude-code-profile) (the `ccp` command) — a great tool for a **different** job: a central hub of reusable skills/agents/hooks/commands, with profiles for different *workflows*. It explicitly does **not** handle MCP servers, secrets, LAN sync, or multiple *accounts* — which are exactly this tool's focus. The two are complementary; this one deliberately uses the `ccprofiles`/`clp` commands to avoid clashing with its `ccp`.
155
+ Not to be confused with [samhvw8/claude-code-profile](https://github.com/samhvw8/claude-code-profile) (the `ccp` command) — a great tool for a **different** job: a central hub of reusable skills/agents/hooks/commands, with profiles for different *workflows*. It explicitly does **not** handle MCP servers, secrets, LAN sync, or multiple *accounts* — which are exactly this tool's focus. The two are complementary; this one deliberately uses the `clp` / `ccprofiles` commands to avoid clashing with its `ccp`.
144
156
 
145
157
  ## License
146
158
 
@@ -15,6 +15,38 @@ const KEY_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUT
15
15
  // posix: export VAR="sk-..." powershell: $env:VAR = "sk-..."
16
16
  const MIGRATE_RE_POSIX = new RegExp(`^(\\s*(?:export\\s+)?(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
17
17
  const MIGRATE_RE_PWSH = new RegExp(`^(\\s*\\$env:(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
18
+ /** Move plaintext keys out of the shell rc file into the secrets store. Shared by the CLI and the UI API. */
19
+ export async function migrateRcSecrets(ctx, opts = {}) {
20
+ const rcFile = ctx.platform.rcFile;
21
+ if (!existsSync(rcFile))
22
+ return [];
23
+ const store = await secretsStore(ctx);
24
+ const lines = (await readFile(rcFile, 'utf8')).split('\n');
25
+ const migrated = [];
26
+ const out = [];
27
+ for (const line of lines) {
28
+ const pwsh = line.match(MIGRATE_RE_PWSH);
29
+ const posix = pwsh ? null : line.match(MIGRATE_RE_POSIX);
30
+ const match = pwsh ?? posix;
31
+ if (!match) {
32
+ out.push(line);
33
+ continue;
34
+ }
35
+ const [, prefix, varName, secretValue, suffix] = match;
36
+ const secretName = varName.toLowerCase().replaceAll('_', '-');
37
+ if (!opts.dryRun)
38
+ await store.set(secretName, secretValue);
39
+ out.push(pwsh
40
+ ? `${prefix}(ccprofiles secrets get ${secretName})${suffix}`
41
+ : `${prefix}"$(ccprofiles secrets get ${secretName})"${suffix}`);
42
+ migrated.push(secretName);
43
+ }
44
+ if (migrated.length && !opts.dryRun) {
45
+ await backupFiles([rcFile], ctx.backupRoot, new Date().toISOString().replace(/[:.]/g, '-'));
46
+ await atomicWrite(rcFile, out.join('\n'));
47
+ }
48
+ return migrated;
49
+ }
18
50
  export function registerSecretsCommands(program, ctx) {
19
51
  const sec = program.command('secrets').description('manage secrets (values never stored in configs)');
20
52
  sec.command('set <name> [value]').action(async (name, value) => {
@@ -44,40 +76,11 @@ export function registerSecretsCommands(program, ctx) {
44
76
  console.log(`removed ${name}`);
45
77
  });
46
78
  sec.command('migrate').option('--dry-run').action(async (opts) => {
47
- const rcFile = ctx.platform.rcFile;
48
- if (!existsSync(rcFile)) {
49
- console.log('no rc file found');
50
- return;
51
- }
52
- const store = await secretsStore(ctx);
53
- const lines = (await readFile(rcFile, 'utf8')).split('\n');
54
- const migrated = [];
55
- const out = [];
56
- for (const line of lines) {
57
- const pwsh = line.match(MIGRATE_RE_PWSH);
58
- const posix = pwsh ? null : line.match(MIGRATE_RE_POSIX);
59
- const match = pwsh ?? posix;
60
- if (!match) {
61
- out.push(line);
62
- continue;
63
- }
64
- const [, prefix, varName, secretValue, suffix] = match;
65
- const secretName = varName.toLowerCase().replaceAll('_', '-');
66
- if (!opts.dryRun)
67
- await store.set(secretName, secretValue);
68
- out.push(pwsh
69
- ? `${prefix}(ccprofiles secrets get ${secretName})${suffix}`
70
- : `${prefix}"$(ccprofiles secrets get ${secretName})"${suffix}`);
71
- migrated.push(secretName);
72
- }
79
+ const migrated = await migrateRcSecrets(ctx, opts);
73
80
  if (migrated.length === 0) {
74
81
  console.log('no plaintext keys found');
75
82
  return;
76
83
  }
77
- if (!opts.dryRun) {
78
- await backupFiles([rcFile], ctx.backupRoot, new Date().toISOString().replace(/[:.]/g, '-'));
79
- await atomicWrite(rcFile, out.join('\n'));
80
- }
81
84
  for (const n of migrated)
82
85
  console.log(`${opts.dryRun ? '[dry-run] ' : ''}migrated ${n}`);
83
86
  });
package/dist/context.js CHANGED
@@ -8,6 +8,7 @@ import { registerSecretsCommands } from './commands/secrets.js';
8
8
  import { registerManifestCommands } from './commands/manifest.js';
9
9
  import { registerSyncCommands } from './commands/sync.js';
10
10
  import { registerBundleCommands } from './commands/bundle.js';
11
+ import { registerUiCommand } from './ui/command.js';
11
12
  export function makeContext(env = process.env) {
12
13
  const testHome = env.CCPROFILES_TEST_HOME;
13
14
  const platform = detectPlatform(testHome ? { home: testHome, shell: env.SHELL } : {});
@@ -38,5 +39,6 @@ export function buildProgram(ctx) {
38
39
  registerManifestCommands(program, ctx);
39
40
  registerSyncCommands(program, ctx);
40
41
  registerBundleCommands(program, ctx);
42
+ registerUiCommand(program, ctx);
41
43
  return program;
42
44
  }
package/dist/index.js CHANGED
File without changes
package/dist/ui/api.js ADDED
@@ -0,0 +1,255 @@
1
+ import { discoverProfiles, buildManifest, saveManifest, planApply, executeApply, ensureRootGitignore, loadManifest, loadDevices, fetchRemote, fetchSecrets, writeAssets, parseManifest, backupFiles, } from 'ccprofiles-core';
2
+ import { existsSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { requireManifest } from '../context.js';
6
+ import { secretsStore, migrateRcSecrets } from '../commands/secrets.js';
7
+ import { sendJson, readJson, HttpError } from './http.js';
8
+ function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
9
+ function profileName(dirName) {
10
+ return dirName === '.claude' ? 'default' : dirName.slice('.claude-'.length);
11
+ }
12
+ export function buildRoutes(ctx) {
13
+ const routes = [];
14
+ const add = (method, pattern, handler) => routes.push({ method, pattern, handler });
15
+ async function applyAndReport(m) {
16
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
17
+ return executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp() });
18
+ }
19
+ // requireManifest throws a plain Error; surface "no manifest yet" as a 409 the UI can detect.
20
+ async function mustManifest() {
21
+ try {
22
+ return await requireManifest(ctx);
23
+ }
24
+ catch (e) {
25
+ throw new HttpError(409, e.message);
26
+ }
27
+ }
28
+ function targetsOf(m, targets) {
29
+ if (targets === 'all')
30
+ return m.profiles.map(p => p.name);
31
+ for (const t of targets)
32
+ if (!m.profiles.some(p => p.name === t))
33
+ throw new HttpError(400, `unknown profile: ${t}`);
34
+ return targets;
35
+ }
36
+ // ── adopt / profiles / status / apply / doctor ──────────────────────────────
37
+ add('POST', /^\/api\/adopt$/, async (_m, _req, res) => {
38
+ const manifest = buildManifest(await discoverProfiles(ctx.home), ctx.platform);
39
+ if (!existsSync(join(ctx.manifestRoot, '.git'))) {
40
+ try {
41
+ execFileSync('git', ['init', ctx.manifestRoot], { stdio: 'ignore' });
42
+ }
43
+ catch { /* git optional */ }
44
+ }
45
+ await ensureRootGitignore(ctx.manifestRoot);
46
+ await saveManifest(ctx.manifestRoot, manifest);
47
+ sendJson(res, 200, { profiles: manifest.profiles.length, mcpServers: Object.keys(manifest.mcpServers).length });
48
+ });
49
+ add('GET', /^\/api\/profiles$/, async (_m, _req, res) => {
50
+ const live = await discoverProfiles(ctx.home);
51
+ let manifest = null;
52
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml')))
53
+ manifest = await loadManifest(ctx.manifestRoot);
54
+ const rows = live.map(lp => {
55
+ const name = profileName(lp.dirName);
56
+ const decl = manifest?.profiles.find(p => p.name === name) ?? null;
57
+ return {
58
+ name, dir: lp.dir, auth: decl?.auth ?? (lp.account ? 'oauth' : 'env'),
59
+ account: lp.account, mcp: Object.keys(lp.mcpServers).length,
60
+ launcher: decl?.launcher ?? (name === 'default' ? null : `cl-${name}`),
61
+ adopted: !!decl,
62
+ };
63
+ });
64
+ sendJson(res, 200, rows);
65
+ });
66
+ add('POST', /^\/api\/profiles$/, async (_m, req, res) => {
67
+ const { name, from } = await readJson(req);
68
+ if (!name)
69
+ throw new HttpError(400, 'name required');
70
+ const m = await mustManifest();
71
+ if (m.profiles.some(p => p.name === name))
72
+ throw new HttpError(409, `profile exists: ${name}`);
73
+ const src = from ? m.profiles.find(p => p.name === from) : null;
74
+ if (from && !src)
75
+ throw new HttpError(400, `unknown profile: ${from}`);
76
+ m.profiles.push({
77
+ name, dir: `{home}/.claude-${name}`, launcher: `cl-${name}`, auth: 'env', env: {},
78
+ links: src ? { ...src.links } : (m.hub ? { skills: 'hub', commands: 'hub' } : {}),
79
+ mcp: src ? [...src.mcp] : [],
80
+ });
81
+ await saveManifest(ctx.manifestRoot, m);
82
+ await applyAndReport(m);
83
+ sendJson(res, 200, { ok: true });
84
+ });
85
+ add('PATCH', /^\/api\/profiles\/([^/]+)$/, async (mtch, req, res) => {
86
+ const m = await mustManifest();
87
+ const pr = m.profiles.find(p => p.name === mtch[1]);
88
+ if (!pr)
89
+ throw new HttpError(404, `unknown profile: ${mtch[1]}`);
90
+ const body = await readJson(req);
91
+ if (body.env)
92
+ pr.env = body.env;
93
+ if (body.links)
94
+ pr.links = body.links;
95
+ if (body.launcher !== undefined)
96
+ pr.launcher = body.launcher;
97
+ await saveManifest(ctx.manifestRoot, m);
98
+ await applyAndReport(m);
99
+ sendJson(res, 200, { ok: true });
100
+ });
101
+ add('GET', /^\/api\/status$/, async (_m, _req, res) => {
102
+ const m = await mustManifest();
103
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
104
+ const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: true });
105
+ sendJson(res, 200, { inSync: actions.length === 0, pending: r.performed });
106
+ });
107
+ add('POST', /^\/api\/apply$/, async (_m, _req, res) => {
108
+ const m = await mustManifest();
109
+ const r = await applyAndReport(m);
110
+ sendJson(res, 200, { performed: r.performed, backupDir: r.backupDir });
111
+ });
112
+ add('GET', /^\/api\/doctor$/, async (_m, _req, res) => {
113
+ const problems = [];
114
+ const live = await discoverProfiles(ctx.home);
115
+ for (const lp of live)
116
+ for (const nm of readdirSync(lp.dir)) {
117
+ const f = join(lp.dir, nm);
118
+ try {
119
+ if (lstatSync(f).isSymbolicLink() && !existsSync(f))
120
+ problems.push(`broken symlink: ${f} -> ${readlinkSync(f)}`);
121
+ }
122
+ catch { /* skip */ }
123
+ }
124
+ if (existsSync(ctx.platform.rcFile)) {
125
+ const rc = readFileSync(ctx.platform.rcFile, 'utf8');
126
+ const outside = rc.split('# >>> ccprofiles managed >>>')[0] + (rc.split('# <<< ccprofiles managed <<<')[1] ?? '');
127
+ if (/sk-ant-/.test(outside))
128
+ problems.push(`plaintext Anthropic key in ${ctx.platform.rcFile} — run secrets migrate`);
129
+ }
130
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
131
+ const m = await loadManifest(ctx.manifestRoot);
132
+ for (const pr of m.profiles) {
133
+ const dir = pr.dir.replace('{home}', ctx.home);
134
+ if (!existsSync(dir))
135
+ problems.push(`manifest profile "${pr.name}" missing on disk: ${dir} — run apply`);
136
+ }
137
+ }
138
+ sendJson(res, 200, { problems });
139
+ });
140
+ // ── mcp ─────────────────────────────────────────────────────────────────────
141
+ add('GET', /^\/api\/mcp$/, async (_m, _req, res) => {
142
+ const m = await mustManifest();
143
+ sendJson(res, 200, {
144
+ servers: Object.keys(m.mcpServers).sort(),
145
+ profiles: m.profiles.map(p => ({ name: p.name, has: p.mcp })),
146
+ });
147
+ });
148
+ add('POST', /^\/api\/mcp$/, async (_m, req, res) => {
149
+ const { name, command, args, targets } = await readJson(req);
150
+ if (!name)
151
+ throw new HttpError(400, 'name required');
152
+ const m = await mustManifest();
153
+ if (!m.mcpServers[name]) {
154
+ if (!command)
155
+ throw new HttpError(400, `unknown server "${name}" — command required to define it`);
156
+ m.mcpServers[name] = { command, ...(args && args.length ? { args } : {}) };
157
+ }
158
+ for (const t of targetsOf(m, targets)) {
159
+ const pr = m.profiles.find(p => p.name === t);
160
+ if (!pr.mcp.includes(name))
161
+ pr.mcp.push(name);
162
+ }
163
+ await saveManifest(ctx.manifestRoot, m);
164
+ await applyAndReport(m);
165
+ sendJson(res, 200, { ok: true });
166
+ });
167
+ add('DELETE', /^\/api\/mcp\/([^/]+)$/, async (mtch, req, res) => {
168
+ const { targets } = await readJson(req);
169
+ const m = await mustManifest();
170
+ const name = decodeURIComponent(mtch[1]);
171
+ for (const t of targetsOf(m, targets)) {
172
+ const pr = m.profiles.find(p => p.name === t);
173
+ pr.mcp = pr.mcp.filter(x => x !== name);
174
+ }
175
+ if (!m.profiles.some(p => p.mcp.includes(name)))
176
+ delete m.mcpServers[name];
177
+ await saveManifest(ctx.manifestRoot, m);
178
+ await applyAndReport(m);
179
+ sendJson(res, 200, { ok: true });
180
+ });
181
+ add('POST', /^\/api\/mcp\/sync$/, async (_m, req, res) => {
182
+ const { from, to } = await readJson(req);
183
+ const m = await mustManifest();
184
+ const src = m.profiles.find(p => p.name === from);
185
+ if (!src)
186
+ throw new HttpError(400, `unknown profile: ${from}`);
187
+ for (const t of targetsOf(m, to)) {
188
+ if (t === src.name)
189
+ continue;
190
+ m.profiles.find(p => p.name === t).mcp = [...src.mcp];
191
+ }
192
+ await saveManifest(ctx.manifestRoot, m);
193
+ await applyAndReport(m);
194
+ sendJson(res, 200, { ok: true });
195
+ });
196
+ // ── secrets ─────────────────────────────────────────────────────────────────
197
+ add('GET', /^\/api\/secrets$/, async (_m, _req, res) => {
198
+ const store = await secretsStore(ctx);
199
+ sendJson(res, 200, { names: await store.list(), backend: store.backendName });
200
+ });
201
+ add('GET', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
202
+ const store = await secretsStore(ctx);
203
+ const value = await store.get(decodeURIComponent(mtch[1]));
204
+ if (value === null)
205
+ throw new HttpError(404, 'not found');
206
+ sendJson(res, 200, { value });
207
+ });
208
+ add('PUT', /^\/api\/secrets\/([^/]+)$/, async (mtch, req, res) => {
209
+ const { value } = await readJson(req);
210
+ if (typeof value !== 'string')
211
+ throw new HttpError(400, 'value required');
212
+ const store = await secretsStore(ctx);
213
+ await store.set(decodeURIComponent(mtch[1]), value);
214
+ sendJson(res, 200, { ok: true });
215
+ });
216
+ add('DELETE', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
217
+ const store = await secretsStore(ctx);
218
+ await store.delete(decodeURIComponent(mtch[1]));
219
+ sendJson(res, 200, { ok: true });
220
+ });
221
+ add('POST', /^\/api\/secrets\/migrate$/, async (_m, _req, res) => {
222
+ sendJson(res, 200, { migrated: await migrateRcSecrets(ctx) });
223
+ });
224
+ // ── devices / sync ──────────────────────────────────────────────────────────
225
+ add('GET', /^\/api\/devices$/, async (_m, _req, res) => {
226
+ sendJson(res, 200, await loadDevices(ctx.manifestRoot));
227
+ });
228
+ add('POST', /^\/api\/sync$/, async (_m, req, res) => {
229
+ const { from, withSecrets, dryRun } = await readJson(req);
230
+ const device = (await loadDevices(ctx.manifestRoot)).find(d => d.name === from);
231
+ if (!device)
232
+ throw new HttpError(400, `unknown device: ${from}`);
233
+ const { manifestYaml, assets } = await fetchRemote(device);
234
+ const m = parseManifest(manifestYaml);
235
+ if (!dryRun) {
236
+ await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
237
+ await saveManifest(ctx.manifestRoot, m);
238
+ await writeAssets(assets, m, ctx.platform);
239
+ }
240
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
241
+ const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!dryRun });
242
+ let secrets = [];
243
+ if (withSecrets) {
244
+ const values = await fetchSecrets(device, []);
245
+ if (!dryRun) {
246
+ const store = await secretsStore(ctx);
247
+ for (const [k, v] of Object.entries(values))
248
+ await store.set(k, v);
249
+ }
250
+ secrets = Object.keys(values);
251
+ }
252
+ sendJson(res, 200, { performed: r.performed, secrets });
253
+ });
254
+ return routes;
255
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn } from 'node:child_process';
5
+ import { startUiServer } from './server.js';
6
+ import { newUiToken } from './token.js';
7
+ function defaultUiDir() {
8
+ // this file compiles to dist/ui/command.js; the built frontend is copied to dist/webui
9
+ const here = dirname(fileURLToPath(import.meta.url)); // .../dist/ui
10
+ return join(dirname(here), 'webui'); // .../dist/webui
11
+ }
12
+ function openBrowser(url) {
13
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
14
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
15
+ try {
16
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
17
+ }
18
+ catch { /* non-fatal */ }
19
+ }
20
+ export function registerUiCommand(program, ctx) {
21
+ program.command('ui').description('open the web dashboard (localhost)')
22
+ .option('--port <n>', 'port (default: random)', v => parseInt(v, 10))
23
+ .option('--no-open', 'do not open the browser automatically')
24
+ .action(async (opts) => {
25
+ const uiDir = ctx.env.CCPROFILES_UI_DIR ?? defaultUiDir();
26
+ if (!existsSync(join(uiDir, 'index.html'))) {
27
+ console.log(`dashboard assets not found at ${uiDir} — build first: npm run build`);
28
+ return;
29
+ }
30
+ const token = newUiToken();
31
+ const srv = await startUiServer(ctx, { port: opts.port, token, uiDir });
32
+ const url = `http://127.0.0.1:${srv.port}/?t=${token}`;
33
+ console.log(`ccprofiles dashboard: ${url}`);
34
+ console.log('(localhost only · Ctrl-C to stop)');
35
+ if (opts.open !== false)
36
+ openBrowser(url);
37
+ globalThis.__uiServerClose = srv.close;
38
+ globalThis.__uiOnListening?.();
39
+ if (!ctx.env.CCPROFILES_UI_DIR)
40
+ await new Promise(() => { }); // run until Ctrl-C in real use
41
+ });
42
+ }
@@ -0,0 +1,42 @@
1
+ export class HttpError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
8
+ export class BadRequest extends HttpError {
9
+ constructor(message = 'invalid request') { super(400, message); }
10
+ }
11
+ export async function readBody(req) {
12
+ const chunks = [];
13
+ for await (const c of req)
14
+ chunks.push(c);
15
+ return Buffer.concat(chunks).toString('utf8');
16
+ }
17
+ export async function readJson(req) {
18
+ const raw = await readBody(req);
19
+ if (!raw)
20
+ return {};
21
+ try {
22
+ return JSON.parse(raw);
23
+ }
24
+ catch {
25
+ throw new BadRequest('invalid JSON body');
26
+ }
27
+ }
28
+ export function sendJson(res, code, body) {
29
+ const text = JSON.stringify(body);
30
+ res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
31
+ res.end(text);
32
+ }
33
+ export function matchRoute(routes, method, path) {
34
+ for (const route of routes) {
35
+ if (route.method !== method)
36
+ continue;
37
+ const match = path.match(route.pattern);
38
+ if (match)
39
+ return { route, match };
40
+ }
41
+ return null;
42
+ }
@@ -0,0 +1,36 @@
1
+ import { createServer } from 'node:http';
2
+ import { buildRoutes } from './api.js';
3
+ import { matchRoute, sendJson, HttpError } from './http.js';
4
+ import { tokenOk, originOk } from './token.js';
5
+ import { serveStatic } from './static.js';
6
+ export async function startUiServer(ctx, opts) {
7
+ const routes = buildRoutes(ctx);
8
+ const server = createServer(async (req, res) => {
9
+ const url = req.url ?? '/';
10
+ const path = url.split('?')[0];
11
+ if (!path.startsWith('/api/'))
12
+ return serveStatic(res, url, opts.uiDir);
13
+ const address = server.address();
14
+ const port = typeof address === 'object' && address ? address.port : 0;
15
+ if (!originOk(req.headers.origin, port))
16
+ return sendJson(res, 403, { error: 'bad origin' });
17
+ if (!tokenOk(req.headers['x-ccp-token'], opts.token))
18
+ return sendJson(res, 401, { error: 'unauthorized' });
19
+ const m = matchRoute(routes, req.method ?? 'GET', path);
20
+ if (!m)
21
+ return sendJson(res, 404, { error: 'not found' });
22
+ try {
23
+ await m.route.handler(m.match, req, res);
24
+ }
25
+ catch (e) {
26
+ if (e instanceof HttpError)
27
+ return sendJson(res, e.status, { error: e.message });
28
+ process.stderr.write(`ui api error: ${e.stack ?? e}\n`);
29
+ return sendJson(res, 500, { error: 'internal error' });
30
+ }
31
+ });
32
+ await new Promise(resolve => server.listen(opts.port ?? 0, '127.0.0.1', resolve));
33
+ const address = server.address();
34
+ const port = typeof address === 'object' && address ? address.port : 0;
35
+ return { port, close: () => new Promise((resolve, reject) => server.close(e => (e ? reject(e) : resolve()))) };
36
+ }