ccprofiles-core 0.1.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/LICENSE +21 -0
- package/dist/adopt.d.ts +4 -0
- package/dist/adopt.js +38 -0
- package/dist/apply.d.ts +28 -0
- package/dist/apply.js +102 -0
- package/dist/assets.d.ts +6 -0
- package/dist/assets.js +56 -0
- package/dist/bundle.d.ts +7 -0
- package/dist/bundle.js +16 -0
- package/dist/crypto.d.ts +20 -0
- package/dist/crypto.js +32 -0
- package/dist/devices.d.ts +12 -0
- package/dist/devices.js +22 -0
- package/dist/discovery.d.ts +10 -0
- package/dist/discovery.js +39 -0
- package/dist/fsutil.d.ts +2 -0
- package/dist/fsutil.js +22 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/manifest.d.ts +147 -0
- package/dist/manifest.js +95 -0
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +20 -0
- package/dist/rcblock.d.ts +6 -0
- package/dist/rcblock.js +52 -0
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +144 -0
- package/dist/syncclient.d.ts +8 -0
- package/dist/syncclient.js +37 -0
- package/dist/syncserver.d.ts +21 -0
- package/dist/syncserver.js +112 -0
- package/package.json +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ccprofiles contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/adopt.d.ts
ADDED
package/dist/adopt.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { toTemplate } from './platform.js';
|
|
2
|
+
function profileName(dirName) {
|
|
3
|
+
return dirName === '.claude' ? 'default' : dirName.slice('.claude-'.length);
|
|
4
|
+
}
|
|
5
|
+
export function buildManifest(live, platform) {
|
|
6
|
+
const mcpServers = {};
|
|
7
|
+
for (const lp of live)
|
|
8
|
+
for (const [name, def] of Object.entries(lp.mcpServers))
|
|
9
|
+
mcpServers[name] ??= def;
|
|
10
|
+
// hub = profile whose dir is the most common link target prefix
|
|
11
|
+
const linkVotes = new Map();
|
|
12
|
+
for (const lp of live)
|
|
13
|
+
for (const target of Object.values(lp.links)) {
|
|
14
|
+
const owner = live.find(o => o !== lp && target.startsWith(o.dir));
|
|
15
|
+
if (owner)
|
|
16
|
+
linkVotes.set(owner.dirName, (linkVotes.get(owner.dirName) ?? 0) + 1);
|
|
17
|
+
}
|
|
18
|
+
const hubDirName = [...linkVotes.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
19
|
+
const hub = hubDirName ? profileName(hubDirName) : null;
|
|
20
|
+
const profiles = live.map(lp => {
|
|
21
|
+
const name = profileName(lp.dirName);
|
|
22
|
+
const links = {};
|
|
23
|
+
for (const [entry, target] of Object.entries(lp.links)) {
|
|
24
|
+
const isHubLink = hubDirName && target.startsWith(live.find(o => o.dirName === hubDirName).dir);
|
|
25
|
+
links[entry] = isHubLink ? 'hub' : toTemplate(target, platform);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
dir: toTemplate(lp.dir, platform),
|
|
30
|
+
launcher: name === 'default' ? null : `cl-${name}`,
|
|
31
|
+
auth: lp.account ? 'oauth' : 'env',
|
|
32
|
+
env: {},
|
|
33
|
+
links,
|
|
34
|
+
mcp: Object.keys(lp.mcpServers).sort(),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
return { version: 1, hub, profiles, mcpServers };
|
|
38
|
+
}
|
package/dist/apply.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Manifest, McpServerDef } from './manifest.js';
|
|
2
|
+
import type { LiveProfile } from './discovery.js';
|
|
3
|
+
import { type Platform } from './platform.js';
|
|
4
|
+
export type ApplyAction = {
|
|
5
|
+
kind: 'set-mcp-servers';
|
|
6
|
+
configPath: string;
|
|
7
|
+
servers: Record<string, McpServerDef>;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'create-profile-dir';
|
|
10
|
+
dir: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'link';
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'rc-block';
|
|
17
|
+
rcFile: string;
|
|
18
|
+
block: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function planApply(m: Manifest, live: LiveProfile[], p: Platform): ApplyAction[];
|
|
21
|
+
export declare function executeApply(actions: ApplyAction[], opts: {
|
|
22
|
+
backupRoot: string;
|
|
23
|
+
stamp: string;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
backupDir: string | null;
|
|
27
|
+
performed: string[];
|
|
28
|
+
}>;
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, symlink, unlink } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { renderPath } from './platform.js';
|
|
5
|
+
import { renderRcBlock, upsertManagedBlock } from './rcblock.js';
|
|
6
|
+
import { atomicWrite, backupFiles } from './fsutil.js';
|
|
7
|
+
function configPathFor(dir, home) {
|
|
8
|
+
return dir === join(home, '.claude') ? join(home, '.claude.json') : join(dir, '.claude.json');
|
|
9
|
+
}
|
|
10
|
+
function sortKeys(o) {
|
|
11
|
+
return Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
|
|
12
|
+
}
|
|
13
|
+
export function planApply(m, live, p) {
|
|
14
|
+
const actions = [];
|
|
15
|
+
const hubProfile = m.profiles.find(x => x.name === m.hub) ?? null;
|
|
16
|
+
for (const pr of m.profiles) {
|
|
17
|
+
const dir = renderPath(pr.dir, p);
|
|
18
|
+
const lp = live.find(l => l.dir === dir) ?? null;
|
|
19
|
+
const desired = {};
|
|
20
|
+
for (const name of pr.mcp)
|
|
21
|
+
desired[name] = m.mcpServers[name];
|
|
22
|
+
if (!lp)
|
|
23
|
+
actions.push({ kind: 'create-profile-dir', dir });
|
|
24
|
+
const current = lp?.mcpServers ?? null;
|
|
25
|
+
if (!current || JSON.stringify(sortKeys(current)) !== JSON.stringify(sortKeys(desired))) {
|
|
26
|
+
actions.push({ kind: 'set-mcp-servers', configPath: configPathFor(dir, p.home), servers: desired });
|
|
27
|
+
}
|
|
28
|
+
for (const [entry, target] of Object.entries(pr.links)) {
|
|
29
|
+
const to = target === 'hub' && hubProfile
|
|
30
|
+
? join(renderPath(hubProfile.dir, p), entry)
|
|
31
|
+
: renderPath(target, p);
|
|
32
|
+
const from = join(dir, entry);
|
|
33
|
+
if (lp?.links[entry] === to)
|
|
34
|
+
continue;
|
|
35
|
+
actions.push({ kind: 'link', from, to });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const block = renderRcBlock(m, p);
|
|
39
|
+
let rcCurrent = '';
|
|
40
|
+
try {
|
|
41
|
+
rcCurrent = existsSync(p.rcFile) ? readFileSync(p.rcFile, 'utf8') : '';
|
|
42
|
+
}
|
|
43
|
+
catch { /* treat as empty */ }
|
|
44
|
+
if (!rcCurrent.includes(block))
|
|
45
|
+
actions.push({ kind: 'rc-block', rcFile: p.rcFile, block });
|
|
46
|
+
return actions;
|
|
47
|
+
}
|
|
48
|
+
export async function executeApply(actions, opts) {
|
|
49
|
+
const performed = actions.map(describe);
|
|
50
|
+
if (opts.dryRun)
|
|
51
|
+
return { backupDir: null, performed };
|
|
52
|
+
const touched = actions.flatMap(a => a.kind === 'set-mcp-servers' ? [a.configPath] : a.kind === 'rc-block' ? [a.rcFile] : []);
|
|
53
|
+
const backupDir = touched.length ? await backupFiles(touched, opts.backupRoot, opts.stamp) : null;
|
|
54
|
+
for (const a of actions) {
|
|
55
|
+
if (a.kind === 'create-profile-dir') {
|
|
56
|
+
await mkdir(a.dir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
else if (a.kind === 'set-mcp-servers') {
|
|
59
|
+
let cfg = {};
|
|
60
|
+
try {
|
|
61
|
+
cfg = JSON.parse(await readFile(a.configPath, 'utf8'));
|
|
62
|
+
}
|
|
63
|
+
catch { /* new file */ }
|
|
64
|
+
cfg.mcpServers = a.servers;
|
|
65
|
+
await mkdir(dirname(a.configPath), { recursive: true });
|
|
66
|
+
await atomicWrite(a.configPath, JSON.stringify(cfg, null, 2));
|
|
67
|
+
}
|
|
68
|
+
else if (a.kind === 'link') {
|
|
69
|
+
try {
|
|
70
|
+
const st = await lstat(a.from);
|
|
71
|
+
if (!st.isSymbolicLink())
|
|
72
|
+
throw new Error(`refusing to replace non-symlink: ${a.from}`);
|
|
73
|
+
await unlink(a.from);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
if (e.code !== 'ENOENT') {
|
|
77
|
+
if (typeof e.message === 'string' && e.message.startsWith('refusing'))
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
await symlink(a.to, a.from, process.platform === 'win32' ? 'junction' : 'dir');
|
|
82
|
+
}
|
|
83
|
+
else if (a.kind === 'rc-block') {
|
|
84
|
+
let rc = '';
|
|
85
|
+
try {
|
|
86
|
+
rc = await readFile(a.rcFile, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
catch { /* new file */ }
|
|
89
|
+
await mkdir(dirname(a.rcFile), { recursive: true });
|
|
90
|
+
await atomicWrite(a.rcFile, upsertManagedBlock(rc, a.block));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { backupDir, performed };
|
|
94
|
+
}
|
|
95
|
+
function describe(a) {
|
|
96
|
+
switch (a.kind) {
|
|
97
|
+
case 'set-mcp-servers': return `set mcpServers (${Object.keys(a.servers).length}) in ${a.configPath}`;
|
|
98
|
+
case 'create-profile-dir': return `create ${a.dir}`;
|
|
99
|
+
case 'link': return `link ${a.from} -> ${a.to}`;
|
|
100
|
+
case 'rc-block': return `update managed block in ${a.rcFile}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Manifest } from './manifest.js';
|
|
2
|
+
import { type Platform } from './platform.js';
|
|
3
|
+
/** Collect shareable assets as a relative-path → utf8-content map. */
|
|
4
|
+
export declare function collectAssets(m: Manifest, p: Platform): Promise<Record<string, string>>;
|
|
5
|
+
/** Write an asset map back to the live layout. */
|
|
6
|
+
export declare function writeAssets(assets: Record<string, string>, m: Manifest, p: Platform): Promise<void>;
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { renderPath } from './platform.js';
|
|
5
|
+
import { atomicWrite } from './fsutil.js';
|
|
6
|
+
const HUB_DIRS = ['skills', 'commands'];
|
|
7
|
+
async function walk(dir) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
10
|
+
const p = join(dir, e.name);
|
|
11
|
+
if (e.isSymbolicLink())
|
|
12
|
+
continue;
|
|
13
|
+
if (e.isDirectory())
|
|
14
|
+
out.push(...await walk(p));
|
|
15
|
+
else
|
|
16
|
+
out.push(p);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** Collect shareable assets as a relative-path → utf8-content map. */
|
|
21
|
+
export async function collectAssets(m, p) {
|
|
22
|
+
const assets = {};
|
|
23
|
+
const hub = m.profiles.find(x => x.name === m.hub);
|
|
24
|
+
if (hub) {
|
|
25
|
+
const hubDir = renderPath(hub.dir, p);
|
|
26
|
+
for (const sub of HUB_DIRS) {
|
|
27
|
+
const root = join(hubDir, sub);
|
|
28
|
+
if (!existsSync(root))
|
|
29
|
+
continue;
|
|
30
|
+
for (const f of await walk(root)) {
|
|
31
|
+
assets[`hub/${sub}/${relative(root, f).split('\\').join('/')}`] = await readFile(f, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const pr of m.profiles) {
|
|
36
|
+
const claudeMd = join(renderPath(pr.dir, p), 'CLAUDE.md');
|
|
37
|
+
if (existsSync(claudeMd))
|
|
38
|
+
assets[`profiles/${pr.name}/CLAUDE.md`] = await readFile(claudeMd, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
return assets;
|
|
41
|
+
}
|
|
42
|
+
/** Write an asset map back to the live layout. */
|
|
43
|
+
export async function writeAssets(assets, m, p) {
|
|
44
|
+
const hub = m.profiles.find(x => x.name === m.hub);
|
|
45
|
+
for (const [rel, content] of Object.entries(assets)) {
|
|
46
|
+
if (rel.startsWith('hub/') && hub) {
|
|
47
|
+
await atomicWrite(join(renderPath(hub.dir, p), rel.slice('hub/'.length)), content);
|
|
48
|
+
}
|
|
49
|
+
else if (rel.startsWith('profiles/')) {
|
|
50
|
+
const [, name, ...rest] = rel.split('/');
|
|
51
|
+
const pr = m.profiles.find(x => x.name === name);
|
|
52
|
+
if (pr)
|
|
53
|
+
await atomicWrite(join(renderPath(pr.dir, p), rest.join('/')), content);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/bundle.d.ts
ADDED
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
2
|
+
export function exportBundle(manifestYaml, assets) {
|
|
3
|
+
return gzipSync(JSON.stringify({ v: 1, manifestYaml, assets }));
|
|
4
|
+
}
|
|
5
|
+
export function importBundle(buf) {
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(gunzipSync(buf).toString('utf8'));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error('not a ccprofiles bundle (expected a file created by: ccp export)');
|
|
12
|
+
}
|
|
13
|
+
if (parsed?.v !== 1 || typeof parsed.manifestYaml !== 'string')
|
|
14
|
+
throw new Error('not a ccprofiles bundle (expected a file created by: ccp export)');
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type KeyObject } from 'node:crypto';
|
|
2
|
+
export interface Sealed {
|
|
3
|
+
iv: string;
|
|
4
|
+
tag: string;
|
|
5
|
+
data: string;
|
|
6
|
+
}
|
|
7
|
+
/** X25519 keypair for the pairing handshake. */
|
|
8
|
+
export declare function handshakeKeys(): {
|
|
9
|
+
privateKey: KeyObject;
|
|
10
|
+
publicRaw: string;
|
|
11
|
+
};
|
|
12
|
+
/** ECDH(private, peerPublic) → HKDF-SHA256 with salt → 32-byte key. */
|
|
13
|
+
export declare function deriveSharedKey(privateKey: KeyObject, peerPublicRaw: string, salt: string): Buffer;
|
|
14
|
+
export declare function sealJson(key: Buffer, obj: unknown): Sealed;
|
|
15
|
+
export declare function openJson<T = unknown>(key: Buffer, sealed: Sealed): T;
|
|
16
|
+
/** PIN-bound confirmation MAC over the handshake — proves both sides know the PIN. */
|
|
17
|
+
export declare function pinMac(key: Buffer, role: 'client' | 'server', pin: string): string;
|
|
18
|
+
export declare function newSalt(): string;
|
|
19
|
+
export declare function newToken(): string;
|
|
20
|
+
export declare function newPin(): string;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHmac, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, } from 'node:crypto';
|
|
2
|
+
/** X25519 keypair for the pairing handshake. */
|
|
3
|
+
export function handshakeKeys() {
|
|
4
|
+
const { privateKey, publicKey } = generateKeyPairSync('x25519');
|
|
5
|
+
const raw = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
6
|
+
return { privateKey, publicRaw: raw };
|
|
7
|
+
}
|
|
8
|
+
/** ECDH(private, peerPublic) → HKDF-SHA256 with salt → 32-byte key. */
|
|
9
|
+
export function deriveSharedKey(privateKey, peerPublicRaw, salt) {
|
|
10
|
+
const peer = createPublicKey({ key: Buffer.from(peerPublicRaw, 'base64'), type: 'spki', format: 'der' });
|
|
11
|
+
const secret = diffieHellman({ privateKey, publicKey: peer });
|
|
12
|
+
return Buffer.from(hkdfSync('sha256', secret, Buffer.from(salt, 'base64'), 'ccprofiles-pairing', 32));
|
|
13
|
+
}
|
|
14
|
+
export function sealJson(key, obj) {
|
|
15
|
+
const iv = randomBytes(12);
|
|
16
|
+
const c = createCipheriv('aes-256-gcm', key, iv);
|
|
17
|
+
const data = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]);
|
|
18
|
+
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64') };
|
|
19
|
+
}
|
|
20
|
+
export function openJson(key, sealed) {
|
|
21
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
|
|
22
|
+
d.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
|
23
|
+
const text = d.update(Buffer.from(sealed.data, 'base64')).toString('utf8') + d.final('utf8');
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
}
|
|
26
|
+
/** PIN-bound confirmation MAC over the handshake — proves both sides know the PIN. */
|
|
27
|
+
export function pinMac(key, role, pin) {
|
|
28
|
+
return createHmac('sha256', key).update(`ccprofiles:${role}:${pin}`).digest('base64');
|
|
29
|
+
}
|
|
30
|
+
export function newSalt() { return randomBytes(16).toString('base64'); }
|
|
31
|
+
export function newToken() { return randomBytes(32).toString('base64url'); }
|
|
32
|
+
export function newPin() { return String(Math.floor(100000 + Math.random() * 900000)); }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface DeviceEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
token: string;
|
|
6
|
+
/** base64 pairing key — encrypts all traffic with this peer */
|
|
7
|
+
key: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function loadDevices(root: string): Promise<DeviceEntry[]>;
|
|
10
|
+
export declare function saveDevices(root: string, list: DeviceEntry[]): Promise<void>;
|
|
11
|
+
/** Keep secret-bearing files out of the manifest repo's auto-commits. */
|
|
12
|
+
export declare function ensureRootGitignore(root: string): Promise<void>;
|
package/dist/devices.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { atomicWrite } from './fsutil.js';
|
|
5
|
+
const IGNORE = ['secrets.enc', 'secret-names.json', 'devices.json', 'backups/', '*.ccp-tmp'];
|
|
6
|
+
export async function loadDevices(root) {
|
|
7
|
+
const f = join(root, 'devices.json');
|
|
8
|
+
if (!existsSync(f))
|
|
9
|
+
return [];
|
|
10
|
+
return JSON.parse(await readFile(f, 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
export async function saveDevices(root, list) {
|
|
13
|
+
await atomicWrite(join(root, 'devices.json'), JSON.stringify(list, null, 2));
|
|
14
|
+
}
|
|
15
|
+
/** Keep secret-bearing files out of the manifest repo's auto-commits. */
|
|
16
|
+
export async function ensureRootGitignore(root) {
|
|
17
|
+
const f = join(root, '.gitignore');
|
|
18
|
+
const current = existsSync(f) ? await readFile(f, 'utf8') : '';
|
|
19
|
+
const missing = IGNORE.filter(l => !current.split('\n').includes(l));
|
|
20
|
+
if (missing.length)
|
|
21
|
+
await atomicWrite(f, current + (current && !current.endsWith('\n') ? '\n' : '') + missing.join('\n') + '\n');
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServerDef } from './manifest.js';
|
|
2
|
+
export interface LiveProfile {
|
|
3
|
+
dirName: string;
|
|
4
|
+
dir: string;
|
|
5
|
+
configPath: string;
|
|
6
|
+
account: string | null;
|
|
7
|
+
mcpServers: Record<string, McpServerDef>;
|
|
8
|
+
links: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
export declare function discoverProfiles(home: string): Promise<LiveProfile[]>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readdir, readFile, readlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export async function discoverProfiles(home) {
|
|
4
|
+
const entries = await readdir(home, { withFileTypes: true });
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const e of entries) {
|
|
7
|
+
if (!e.isDirectory() || !(e.name === '.claude' || e.name.startsWith('.claude-')))
|
|
8
|
+
continue;
|
|
9
|
+
const dir = join(home, e.name);
|
|
10
|
+
const configPath = e.name === '.claude' ? join(home, '.claude.json') : join(dir, '.claude.json');
|
|
11
|
+
let cfg;
|
|
12
|
+
try {
|
|
13
|
+
cfg = JSON.parse(await readFile(configPath, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const links = {};
|
|
19
|
+
for (const child of await readdir(dir, { withFileTypes: true })) {
|
|
20
|
+
if (child.isSymbolicLink()) {
|
|
21
|
+
try {
|
|
22
|
+
// windows junctions read back as \\?\C:\... with a trailing separator — normalize
|
|
23
|
+
links[child.name] = (await readlink(join(dir, child.name)))
|
|
24
|
+
.replace(/^\\\\\?\\/, '').replace(/([\\/])+$/, '');
|
|
25
|
+
}
|
|
26
|
+
catch { /* skip */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
out.push({
|
|
30
|
+
dirName: e.name,
|
|
31
|
+
dir,
|
|
32
|
+
configPath,
|
|
33
|
+
account: cfg?.oauthAccount?.emailAddress ?? null,
|
|
34
|
+
mcpServers: cfg?.mcpServers ?? {},
|
|
35
|
+
links,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
39
|
+
}
|
package/dist/fsutil.d.ts
ADDED
package/dist/fsutil.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export async function atomicWrite(filePath, content) {
|
|
5
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
6
|
+
const tmp = `${filePath}.ccp-tmp`;
|
|
7
|
+
await writeFile(tmp, content, 'utf8');
|
|
8
|
+
await rename(tmp, filePath);
|
|
9
|
+
}
|
|
10
|
+
function sanitize(absPath) {
|
|
11
|
+
return absPath.replace(/:/g, '').replace(/[\\/]+/g, '__').replace(/^__/, '');
|
|
12
|
+
}
|
|
13
|
+
export async function backupFiles(files, backupRoot, stamp) {
|
|
14
|
+
const dir = join(backupRoot, stamp);
|
|
15
|
+
await mkdir(dir, { recursive: true });
|
|
16
|
+
for (const f of files) {
|
|
17
|
+
if (!existsSync(f))
|
|
18
|
+
continue;
|
|
19
|
+
await copyFile(f, join(dir, sanitize(f)));
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './platform.js';
|
|
2
|
+
export * from './manifest.js';
|
|
3
|
+
export * from './discovery.js';
|
|
4
|
+
export * from './adopt.js';
|
|
5
|
+
export * from './rcblock.js';
|
|
6
|
+
export * from './fsutil.js';
|
|
7
|
+
export * from './secrets.js';
|
|
8
|
+
export * from './apply.js';
|
|
9
|
+
export * from './crypto.js';
|
|
10
|
+
export * from './assets.js';
|
|
11
|
+
export * from './devices.js';
|
|
12
|
+
export * from './syncserver.js';
|
|
13
|
+
export * from './syncclient.js';
|
|
14
|
+
export * from './bundle.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './platform.js';
|
|
2
|
+
export * from './manifest.js';
|
|
3
|
+
export * from './discovery.js';
|
|
4
|
+
export * from './adopt.js';
|
|
5
|
+
export * from './rcblock.js';
|
|
6
|
+
export * from './fsutil.js';
|
|
7
|
+
export * from './secrets.js';
|
|
8
|
+
export * from './apply.js';
|
|
9
|
+
export * from './crypto.js';
|
|
10
|
+
export * from './assets.js';
|
|
11
|
+
export * from './devices.js';
|
|
12
|
+
export * from './syncserver.js';
|
|
13
|
+
export * from './syncclient.js';
|
|
14
|
+
export * from './bundle.js';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare class ManifestError extends Error {
|
|
3
|
+
}
|
|
4
|
+
declare const McpServerSchema: z.ZodObject<{
|
|
5
|
+
command: z.ZodString;
|
|
6
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
7
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
8
|
+
type: z.ZodOptional<z.ZodString>;
|
|
9
|
+
url: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
11
|
+
command: z.ZodString;
|
|
12
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
13
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
14
|
+
type: z.ZodOptional<z.ZodString>;
|
|
15
|
+
url: z.ZodOptional<z.ZodString>;
|
|
16
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
17
|
+
command: z.ZodString;
|
|
18
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
19
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
20
|
+
type: z.ZodOptional<z.ZodString>;
|
|
21
|
+
url: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.ZodTypeAny, "passthrough">>;
|
|
23
|
+
declare const ProfileSchema: z.ZodObject<{
|
|
24
|
+
name: z.ZodString;
|
|
25
|
+
dir: z.ZodString;
|
|
26
|
+
launcher: z.ZodNullable<z.ZodString>;
|
|
27
|
+
auth: z.ZodEnum<["oauth", "api-key", "env"]>;
|
|
28
|
+
env: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
29
|
+
links: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
30
|
+
mcp: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
31
|
+
}, "strip", z.ZodTypeAny, {
|
|
32
|
+
env: Record<string, string>;
|
|
33
|
+
name: string;
|
|
34
|
+
dir: string;
|
|
35
|
+
launcher: string | null;
|
|
36
|
+
auth: "env" | "oauth" | "api-key";
|
|
37
|
+
links: Record<string, string>;
|
|
38
|
+
mcp: string[];
|
|
39
|
+
}, {
|
|
40
|
+
name: string;
|
|
41
|
+
dir: string;
|
|
42
|
+
launcher: string | null;
|
|
43
|
+
auth: "env" | "oauth" | "api-key";
|
|
44
|
+
env?: Record<string, string> | undefined;
|
|
45
|
+
links?: Record<string, string> | undefined;
|
|
46
|
+
mcp?: string[] | undefined;
|
|
47
|
+
}>;
|
|
48
|
+
declare const ManifestSchema: z.ZodObject<{
|
|
49
|
+
version: z.ZodLiteral<1>;
|
|
50
|
+
hub: z.ZodNullable<z.ZodString>;
|
|
51
|
+
profiles: z.ZodArray<z.ZodObject<{
|
|
52
|
+
name: z.ZodString;
|
|
53
|
+
dir: z.ZodString;
|
|
54
|
+
launcher: z.ZodNullable<z.ZodString>;
|
|
55
|
+
auth: z.ZodEnum<["oauth", "api-key", "env"]>;
|
|
56
|
+
env: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
57
|
+
links: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
58
|
+
mcp: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
59
|
+
}, "strip", z.ZodTypeAny, {
|
|
60
|
+
env: Record<string, string>;
|
|
61
|
+
name: string;
|
|
62
|
+
dir: string;
|
|
63
|
+
launcher: string | null;
|
|
64
|
+
auth: "env" | "oauth" | "api-key";
|
|
65
|
+
links: Record<string, string>;
|
|
66
|
+
mcp: string[];
|
|
67
|
+
}, {
|
|
68
|
+
name: string;
|
|
69
|
+
dir: string;
|
|
70
|
+
launcher: string | null;
|
|
71
|
+
auth: "env" | "oauth" | "api-key";
|
|
72
|
+
env?: Record<string, string> | undefined;
|
|
73
|
+
links?: Record<string, string> | undefined;
|
|
74
|
+
mcp?: string[] | undefined;
|
|
75
|
+
}>, "many">;
|
|
76
|
+
mcpServers: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
77
|
+
command: z.ZodString;
|
|
78
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
79
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
80
|
+
type: z.ZodOptional<z.ZodString>;
|
|
81
|
+
url: z.ZodOptional<z.ZodString>;
|
|
82
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
83
|
+
command: z.ZodString;
|
|
84
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
85
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
86
|
+
type: z.ZodOptional<z.ZodString>;
|
|
87
|
+
url: z.ZodOptional<z.ZodString>;
|
|
88
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
89
|
+
command: z.ZodString;
|
|
90
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
91
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
92
|
+
type: z.ZodOptional<z.ZodString>;
|
|
93
|
+
url: z.ZodOptional<z.ZodString>;
|
|
94
|
+
}, z.ZodTypeAny, "passthrough">>>;
|
|
95
|
+
}, "strip", z.ZodTypeAny, {
|
|
96
|
+
version: 1;
|
|
97
|
+
hub: string | null;
|
|
98
|
+
profiles: {
|
|
99
|
+
env: Record<string, string>;
|
|
100
|
+
name: string;
|
|
101
|
+
dir: string;
|
|
102
|
+
launcher: string | null;
|
|
103
|
+
auth: "env" | "oauth" | "api-key";
|
|
104
|
+
links: Record<string, string>;
|
|
105
|
+
mcp: string[];
|
|
106
|
+
}[];
|
|
107
|
+
mcpServers: Record<string, z.objectOutputType<{
|
|
108
|
+
command: z.ZodString;
|
|
109
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
110
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
111
|
+
type: z.ZodOptional<z.ZodString>;
|
|
112
|
+
url: z.ZodOptional<z.ZodString>;
|
|
113
|
+
}, z.ZodTypeAny, "passthrough">>;
|
|
114
|
+
}, {
|
|
115
|
+
version: 1;
|
|
116
|
+
hub: string | null;
|
|
117
|
+
profiles: {
|
|
118
|
+
name: string;
|
|
119
|
+
dir: string;
|
|
120
|
+
launcher: string | null;
|
|
121
|
+
auth: "env" | "oauth" | "api-key";
|
|
122
|
+
env?: Record<string, string> | undefined;
|
|
123
|
+
links?: Record<string, string> | undefined;
|
|
124
|
+
mcp?: string[] | undefined;
|
|
125
|
+
}[];
|
|
126
|
+
mcpServers: Record<string, z.objectInputType<{
|
|
127
|
+
command: z.ZodString;
|
|
128
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
129
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
130
|
+
type: z.ZodOptional<z.ZodString>;
|
|
131
|
+
url: z.ZodOptional<z.ZodString>;
|
|
132
|
+
}, z.ZodTypeAny, "passthrough">>;
|
|
133
|
+
}>;
|
|
134
|
+
export type McpServerDef = z.infer<typeof McpServerSchema>;
|
|
135
|
+
export type ProfileDecl = z.infer<typeof ProfileSchema>;
|
|
136
|
+
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
137
|
+
/**
|
|
138
|
+
* Reject manifests whose identifiers could break out of the shell launcher context.
|
|
139
|
+
* Applies to every manifest — an untrusted bundle/peer manifest reaches this before
|
|
140
|
+
* anything is written to your rc file or config.
|
|
141
|
+
*/
|
|
142
|
+
export declare function assertSafeManifest(m: Manifest): void;
|
|
143
|
+
export declare function parseManifest(text: string): Manifest;
|
|
144
|
+
export declare function serializeManifest(m: Manifest): string;
|
|
145
|
+
export declare function loadManifest(root: string): Promise<Manifest>;
|
|
146
|
+
export declare function saveManifest(root: string, m: Manifest): Promise<void>;
|
|
147
|
+
export {};
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import YAML from 'yaml';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
export class ManifestError extends Error {
|
|
9
|
+
}
|
|
10
|
+
const McpServerSchema = z.object({
|
|
11
|
+
command: z.string(),
|
|
12
|
+
args: z.array(z.string()).optional(),
|
|
13
|
+
env: z.record(z.string()).optional(),
|
|
14
|
+
type: z.string().optional(),
|
|
15
|
+
url: z.string().optional(),
|
|
16
|
+
}).passthrough();
|
|
17
|
+
const ProfileSchema = z.object({
|
|
18
|
+
name: z.string().min(1),
|
|
19
|
+
dir: z.string().min(1),
|
|
20
|
+
launcher: z.string().nullable(),
|
|
21
|
+
auth: z.enum(['oauth', 'api-key', 'env']),
|
|
22
|
+
env: z.record(z.string()).default({}),
|
|
23
|
+
links: z.record(z.string()).default({}),
|
|
24
|
+
mcp: z.array(z.string()).default([]),
|
|
25
|
+
});
|
|
26
|
+
const ManifestSchema = z.object({
|
|
27
|
+
version: z.literal(1),
|
|
28
|
+
hub: z.string().nullable(),
|
|
29
|
+
profiles: z.array(ProfileSchema),
|
|
30
|
+
mcpServers: z.record(McpServerSchema),
|
|
31
|
+
});
|
|
32
|
+
// identifiers that get interpolated into shell launcher code must be injection-safe
|
|
33
|
+
const SAFE_NAME = /^[A-Za-z0-9_-]+$/; // profile names, launcher names, secret refs
|
|
34
|
+
const SAFE_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; // POSIX/PowerShell env var names
|
|
35
|
+
const SHELL_META = /["`$;|&()\n\r<>]/; // chars that could break out of a quoted shell context
|
|
36
|
+
const SECRET_PREFIX = 'secret://';
|
|
37
|
+
/**
|
|
38
|
+
* Reject manifests whose identifiers could break out of the shell launcher context.
|
|
39
|
+
* Applies to every manifest — an untrusted bundle/peer manifest reaches this before
|
|
40
|
+
* anything is written to your rc file or config.
|
|
41
|
+
*/
|
|
42
|
+
export function assertSafeManifest(m) {
|
|
43
|
+
for (const p of m.profiles) {
|
|
44
|
+
if (!SAFE_NAME.test(p.name))
|
|
45
|
+
throw new ManifestError(`unsafe profile name: ${JSON.stringify(p.name)} (allowed: letters, digits, - _)`);
|
|
46
|
+
if (p.launcher !== null && !SAFE_NAME.test(p.launcher))
|
|
47
|
+
throw new ManifestError(`unsafe launcher name in profile "${p.name}": ${JSON.stringify(p.launcher)}`);
|
|
48
|
+
if (SHELL_META.test(p.dir))
|
|
49
|
+
throw new ManifestError(`unsafe profile dir in profile "${p.name}": ${JSON.stringify(p.dir)}`);
|
|
50
|
+
for (const [k, v] of Object.entries(p.env)) {
|
|
51
|
+
if (!SAFE_ENV_KEY.test(k))
|
|
52
|
+
throw new ManifestError(`unsafe env var name in profile "${p.name}": ${JSON.stringify(k)}`);
|
|
53
|
+
if (v.startsWith(SECRET_PREFIX)) {
|
|
54
|
+
const ref = v.slice(SECRET_PREFIX.length);
|
|
55
|
+
if (!SAFE_NAME.test(ref))
|
|
56
|
+
throw new ManifestError(`unsafe secret reference in profile "${p.name}": ${JSON.stringify(v)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function parseManifest(text) {
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = YAML.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
throw new ManifestError(`invalid yaml: ${e.message}`);
|
|
68
|
+
}
|
|
69
|
+
const res = ManifestSchema.safeParse(raw);
|
|
70
|
+
if (!res.success)
|
|
71
|
+
throw new ManifestError(res.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '));
|
|
72
|
+
const m = res.data;
|
|
73
|
+
for (const p of m.profiles)
|
|
74
|
+
for (const name of p.mcp)
|
|
75
|
+
if (!m.mcpServers[name])
|
|
76
|
+
throw new ManifestError(`profile "${p.name}" references undefined mcp server "${name}"`);
|
|
77
|
+
assertSafeManifest(m);
|
|
78
|
+
return m;
|
|
79
|
+
}
|
|
80
|
+
export function serializeManifest(m) {
|
|
81
|
+
return YAML.stringify(m);
|
|
82
|
+
}
|
|
83
|
+
export async function loadManifest(root) {
|
|
84
|
+
return parseManifest(await readFile(join(root, 'manifest.yaml'), 'utf8'));
|
|
85
|
+
}
|
|
86
|
+
export async function saveManifest(root, m) {
|
|
87
|
+
await mkdir(root, { recursive: true });
|
|
88
|
+
await writeFile(join(root, 'manifest.yaml'), serializeManifest(m), 'utf8');
|
|
89
|
+
try {
|
|
90
|
+
await exec('git', ['-C', root, 'rev-parse', '--git-dir']);
|
|
91
|
+
await exec('git', ['-C', root, 'add', '-A']);
|
|
92
|
+
await exec('git', ['-C', root, 'commit', '-m', 'ccprofiles: update manifest']);
|
|
93
|
+
}
|
|
94
|
+
catch { /* not a repo or nothing to commit — fine */ }
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type OsKind = 'darwin' | 'linux' | 'win32';
|
|
2
|
+
export interface Platform {
|
|
3
|
+
os: OsKind;
|
|
4
|
+
home: string;
|
|
5
|
+
rcFile: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectPlatform(opts?: {
|
|
8
|
+
osKind?: OsKind;
|
|
9
|
+
home?: string;
|
|
10
|
+
shell?: string;
|
|
11
|
+
}): Platform;
|
|
12
|
+
export declare function renderPath(template: string, p: Platform): string;
|
|
13
|
+
export declare function toTemplate(absPath: string, p: Platform): string;
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function detectPlatform(opts = {}) {
|
|
4
|
+
const osKind = opts.osKind ?? process.platform;
|
|
5
|
+
const home = opts.home ?? os.homedir();
|
|
6
|
+
const shell = opts.shell ?? process.env.SHELL ?? '';
|
|
7
|
+
const rcFile = osKind === 'win32'
|
|
8
|
+
? path.win32.join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1')
|
|
9
|
+
: path.posix.join(home, shell.endsWith('zsh') ? '.zshrc' : '.bashrc');
|
|
10
|
+
return { os: osKind, home, rcFile };
|
|
11
|
+
}
|
|
12
|
+
export function renderPath(template, p) {
|
|
13
|
+
const raw = template.replaceAll('{home}', p.home);
|
|
14
|
+
return p.os === 'win32' ? raw.replaceAll('/', '\\') : raw;
|
|
15
|
+
}
|
|
16
|
+
export function toTemplate(absPath, p) {
|
|
17
|
+
const norm = absPath.replaceAll('\\', '/');
|
|
18
|
+
const home = p.home.replaceAll('\\', '/');
|
|
19
|
+
return norm.startsWith(home) ? '{home}' + norm.slice(home.length) : norm;
|
|
20
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Manifest } from './manifest.js';
|
|
2
|
+
import type { Platform } from './platform.js';
|
|
3
|
+
export declare const BEGIN_MARK = "# >>> ccprofiles managed >>>";
|
|
4
|
+
export declare const END_MARK = "# <<< ccprofiles managed <<<";
|
|
5
|
+
export declare function renderRcBlock(m: Manifest, p: Platform): string;
|
|
6
|
+
export declare function upsertManagedBlock(content: string, block: string): string;
|
package/dist/rcblock.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const BEGIN_MARK = '# >>> ccprofiles managed >>>';
|
|
2
|
+
export const END_MARK = '# <<< ccprofiles managed <<<';
|
|
3
|
+
const SECRET_PREFIX = 'secret://';
|
|
4
|
+
// Defense in depth: manifest validation already rejects shell metacharacters in
|
|
5
|
+
// identifiers, but free-form env *values* are still escaped for their quoted context.
|
|
6
|
+
function escapePosix(v) {
|
|
7
|
+
return v.replace(/(["\\$`])/g, '\\$1'); // inside "..." — escape " \ $ `
|
|
8
|
+
}
|
|
9
|
+
function escapePwsh(v) {
|
|
10
|
+
return v.replace(/`/g, '``').replace(/"/g, '`"').replace(/\$/g, '`$'); // inside "..." — ` is PS escape
|
|
11
|
+
}
|
|
12
|
+
function homeVar(p) {
|
|
13
|
+
return p.os === 'win32' ? '$env:USERPROFILE' : '$HOME';
|
|
14
|
+
}
|
|
15
|
+
function profileDirExpr(pr, p) {
|
|
16
|
+
const suffix = pr.dir.replace('{home}', '');
|
|
17
|
+
return p.os === 'win32' ? homeVar(p) + suffix.replaceAll('/', '\\') : homeVar(p) + suffix;
|
|
18
|
+
}
|
|
19
|
+
function renderPosix(pr, p) {
|
|
20
|
+
const lines = [`${pr.launcher}() {`];
|
|
21
|
+
for (const [k, v] of Object.entries(pr.env)) {
|
|
22
|
+
lines.push(v.startsWith(SECRET_PREFIX)
|
|
23
|
+
? ` export ${k}="$(ccp secrets get ${v.slice(SECRET_PREFIX.length)})"`
|
|
24
|
+
: ` export ${k}="${escapePosix(v)}"`);
|
|
25
|
+
}
|
|
26
|
+
lines.push(` CLAUDE_CONFIG_DIR="${profileDirExpr(pr, p)}" claude "$@"`, '}');
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
function renderPwsh(pr, p) {
|
|
30
|
+
const lines = [`function ${pr.launcher} {`];
|
|
31
|
+
for (const [k, v] of Object.entries(pr.env)) {
|
|
32
|
+
lines.push(v.startsWith(SECRET_PREFIX)
|
|
33
|
+
? ` $env:${k} = (ccp secrets get ${v.slice(SECRET_PREFIX.length)})`
|
|
34
|
+
: ` $env:${k} = "${escapePwsh(v)}"`);
|
|
35
|
+
}
|
|
36
|
+
lines.push(` $env:CLAUDE_CONFIG_DIR = "${profileDirExpr(pr, p)}"`, ' claude @args', '}');
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
}
|
|
39
|
+
export function renderRcBlock(m, p) {
|
|
40
|
+
const fns = m.profiles
|
|
41
|
+
.filter(pr => pr.launcher)
|
|
42
|
+
.map(pr => (p.os === 'win32' ? renderPwsh(pr, p) : renderPosix(pr, p)));
|
|
43
|
+
return [BEGIN_MARK, ...fns, END_MARK].join('\n');
|
|
44
|
+
}
|
|
45
|
+
export function upsertManagedBlock(content, block) {
|
|
46
|
+
const start = content.indexOf(BEGIN_MARK);
|
|
47
|
+
const end = content.indexOf(END_MARK);
|
|
48
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
49
|
+
return content.slice(0, start) + block + content.slice(end + END_MARK.length);
|
|
50
|
+
}
|
|
51
|
+
return content.trimEnd() + (content.trim() ? '\n\n' : '') + block + '\n';
|
|
52
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Platform } from './platform.js';
|
|
2
|
+
export interface SecretsBackend {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
get(key: string): Promise<string | null>;
|
|
5
|
+
set(key: string, value: string): Promise<void>;
|
|
6
|
+
delete(key: string): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare class FileBackend implements SecretsBackend {
|
|
9
|
+
private filePath;
|
|
10
|
+
private passphrase;
|
|
11
|
+
readonly name = "encrypted-file";
|
|
12
|
+
constructor(filePath: string, passphrase: string);
|
|
13
|
+
private load;
|
|
14
|
+
private key;
|
|
15
|
+
get(key: string): Promise<string | null>;
|
|
16
|
+
set(key: string, value: string): Promise<void>;
|
|
17
|
+
delete(key: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
type ExecFn = (cmd: string, args: string[]) => Promise<{
|
|
20
|
+
stdout: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare class KeychainBackend implements SecretsBackend {
|
|
23
|
+
private exec;
|
|
24
|
+
readonly name = "macos-keychain";
|
|
25
|
+
constructor(exec?: ExecFn);
|
|
26
|
+
set(key: string, value: string): Promise<void>;
|
|
27
|
+
get(key: string): Promise<string | null>;
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare class SecretToolBackend implements SecretsBackend {
|
|
31
|
+
private exec;
|
|
32
|
+
readonly name = "libsecret";
|
|
33
|
+
constructor(exec?: ExecFn);
|
|
34
|
+
set(key: string, value: string): Promise<void>;
|
|
35
|
+
get(key: string): Promise<string | null>;
|
|
36
|
+
delete(key: string): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export declare class SecretsStore {
|
|
39
|
+
private backend;
|
|
40
|
+
private indexPath;
|
|
41
|
+
constructor(backend: SecretsBackend, indexPath: string);
|
|
42
|
+
private readIndex;
|
|
43
|
+
private writeIndex;
|
|
44
|
+
get backendName(): string;
|
|
45
|
+
get(key: string): Promise<string | null>;
|
|
46
|
+
set(key: string, value: string): Promise<void>;
|
|
47
|
+
delete(key: string): Promise<void>;
|
|
48
|
+
list(): Promise<string[]>;
|
|
49
|
+
}
|
|
50
|
+
export declare function defaultBackend(p: Platform, opts: {
|
|
51
|
+
filePath: string;
|
|
52
|
+
passphrase?: () => Promise<string>;
|
|
53
|
+
}): Promise<SecretsBackend>;
|
|
54
|
+
export {};
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { atomicWrite } from './fsutil.js';
|
|
7
|
+
export class FileBackend {
|
|
8
|
+
filePath;
|
|
9
|
+
passphrase;
|
|
10
|
+
name = 'encrypted-file';
|
|
11
|
+
constructor(filePath, passphrase) {
|
|
12
|
+
this.filePath = filePath;
|
|
13
|
+
this.passphrase = passphrase;
|
|
14
|
+
}
|
|
15
|
+
async load() {
|
|
16
|
+
if (!existsSync(this.filePath))
|
|
17
|
+
return { salt: randomBytes(16).toString('base64'), entries: {} };
|
|
18
|
+
return JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
key(salt) {
|
|
21
|
+
return scryptSync(this.passphrase, Buffer.from(salt, 'base64'), 32);
|
|
22
|
+
}
|
|
23
|
+
async get(key) {
|
|
24
|
+
const f = await this.load();
|
|
25
|
+
const e = f.entries[key];
|
|
26
|
+
if (!e)
|
|
27
|
+
return null;
|
|
28
|
+
const d = createDecipheriv('aes-256-gcm', this.key(f.salt), Buffer.from(e.iv, 'base64'));
|
|
29
|
+
d.setAuthTag(Buffer.from(e.tag, 'base64'));
|
|
30
|
+
return d.update(Buffer.from(e.data, 'base64')).toString('utf8') + d.final('utf8'); // throws on bad passphrase
|
|
31
|
+
}
|
|
32
|
+
async set(key, value) {
|
|
33
|
+
const f = await this.load();
|
|
34
|
+
const iv = randomBytes(12);
|
|
35
|
+
const c = createCipheriv('aes-256-gcm', this.key(f.salt), iv);
|
|
36
|
+
const data = Buffer.concat([c.update(value, 'utf8'), c.final()]);
|
|
37
|
+
f.entries[key] = { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64') };
|
|
38
|
+
await atomicWrite(this.filePath, JSON.stringify(f));
|
|
39
|
+
}
|
|
40
|
+
async delete(key) {
|
|
41
|
+
const f = await this.load();
|
|
42
|
+
delete f.entries[key];
|
|
43
|
+
await atomicWrite(this.filePath, JSON.stringify(f));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const realExec = promisify(execFile);
|
|
47
|
+
export class KeychainBackend {
|
|
48
|
+
exec;
|
|
49
|
+
name = 'macos-keychain';
|
|
50
|
+
constructor(exec = realExec) {
|
|
51
|
+
this.exec = exec;
|
|
52
|
+
}
|
|
53
|
+
async set(key, value) {
|
|
54
|
+
await this.exec('security', ['add-generic-password', '-U', '-s', 'ccprofiles', '-a', key, '-w', value]);
|
|
55
|
+
}
|
|
56
|
+
async get(key) {
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await this.exec('security', ['find-generic-password', '-s', 'ccprofiles', '-a', key, '-w']);
|
|
59
|
+
return stdout.replace(/\n$/, '');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async delete(key) {
|
|
66
|
+
try {
|
|
67
|
+
await this.exec('security', ['delete-generic-password', '-s', 'ccprofiles', '-a', key]);
|
|
68
|
+
}
|
|
69
|
+
catch { /* absent */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export class SecretToolBackend {
|
|
73
|
+
exec;
|
|
74
|
+
name = 'libsecret';
|
|
75
|
+
constructor(exec = realExec) {
|
|
76
|
+
this.exec = exec;
|
|
77
|
+
}
|
|
78
|
+
async set(key, value) {
|
|
79
|
+
const child = await import('node:child_process');
|
|
80
|
+
await new Promise((resolve, reject) => {
|
|
81
|
+
const proc = child.spawn('secret-tool', ['store', '--label=ccprofiles', 'service', 'ccprofiles', 'key', key], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
82
|
+
proc.on('error', reject);
|
|
83
|
+
proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`secret-tool exited ${code}`))));
|
|
84
|
+
proc.stdin.end(value);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async get(key) {
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await this.exec('secret-tool', ['lookup', 'service', 'ccprofiles', 'key', key]);
|
|
90
|
+
return stdout;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async delete(key) {
|
|
97
|
+
try {
|
|
98
|
+
await this.exec('secret-tool', ['clear', 'service', 'ccprofiles', 'key', key]);
|
|
99
|
+
}
|
|
100
|
+
catch { /* absent */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export class SecretsStore {
|
|
104
|
+
backend;
|
|
105
|
+
indexPath;
|
|
106
|
+
constructor(backend, indexPath) {
|
|
107
|
+
this.backend = backend;
|
|
108
|
+
this.indexPath = indexPath;
|
|
109
|
+
}
|
|
110
|
+
async readIndex() {
|
|
111
|
+
if (!existsSync(this.indexPath))
|
|
112
|
+
return [];
|
|
113
|
+
return JSON.parse(await readFile(this.indexPath, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
async writeIndex(names) {
|
|
116
|
+
await atomicWrite(this.indexPath, JSON.stringify([...new Set(names)].sort()));
|
|
117
|
+
}
|
|
118
|
+
get backendName() { return this.backend.name; }
|
|
119
|
+
async get(key) { return this.backend.get(key); }
|
|
120
|
+
async set(key, value) {
|
|
121
|
+
await this.backend.set(key, value);
|
|
122
|
+
await this.writeIndex([...(await this.readIndex()), key]);
|
|
123
|
+
}
|
|
124
|
+
async delete(key) {
|
|
125
|
+
await this.backend.delete(key);
|
|
126
|
+
await this.writeIndex((await this.readIndex()).filter(n => n !== key));
|
|
127
|
+
}
|
|
128
|
+
async list() { return this.readIndex(); }
|
|
129
|
+
}
|
|
130
|
+
export async function defaultBackend(p, opts) {
|
|
131
|
+
if (p.os === 'darwin')
|
|
132
|
+
return new KeychainBackend();
|
|
133
|
+
if (p.os === 'linux') {
|
|
134
|
+
try {
|
|
135
|
+
await realExec('secret-tool', ['--help']);
|
|
136
|
+
return new SecretToolBackend();
|
|
137
|
+
}
|
|
138
|
+
catch { /* fall through */ }
|
|
139
|
+
}
|
|
140
|
+
const pw = opts.passphrase ? await opts.passphrase() : '';
|
|
141
|
+
if (!pw)
|
|
142
|
+
throw new Error('encrypted-file backend requires a passphrase');
|
|
143
|
+
return new FileBackend(opts.filePath, pw);
|
|
144
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DeviceEntry } from './devices.js';
|
|
2
|
+
/** PIN-authenticated ECDH pairing. Throws if the server can't prove it knows the PIN. */
|
|
3
|
+
export declare function pairWithServer(host: string, port: number, pin: string, name: string): Promise<DeviceEntry>;
|
|
4
|
+
export declare function fetchRemote(device: DeviceEntry): Promise<{
|
|
5
|
+
manifestYaml: string;
|
|
6
|
+
assets: Record<string, string>;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function fetchSecrets(device: DeviceEntry, names: string[]): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { deriveSharedKey, handshakeKeys, openJson, pinMac, sealJson } from './crypto.js';
|
|
2
|
+
async function post(host, port, path, payload) {
|
|
3
|
+
let res;
|
|
4
|
+
try {
|
|
5
|
+
res = await fetch(`http://${host}:${port}${path}`, {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: { 'content-type': 'application/json' },
|
|
8
|
+
body: JSON.stringify(payload),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error(`cannot reach ${host}:${port} — is \`ccp serve\` running on the other device?`);
|
|
13
|
+
}
|
|
14
|
+
const data = await res.json();
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`${path}: ${data.error ?? res.status}`);
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
19
|
+
/** PIN-authenticated ECDH pairing. Throws if the server can't prove it knows the PIN. */
|
|
20
|
+
export async function pairWithServer(host, port, pin, name) {
|
|
21
|
+
const { privateKey, publicRaw } = handshakeKeys();
|
|
22
|
+
const { handshakeId, serverPub, salt } = await post(host, port, '/pair', { clientPub: publicRaw });
|
|
23
|
+
const key = deriveSharedKey(privateKey, serverPub, salt);
|
|
24
|
+
const { mac, token } = await post(host, port, '/pair/confirm', { handshakeId, mac: pinMac(key, 'client', pin), name });
|
|
25
|
+
if (mac !== pinMac(key, 'server', pin))
|
|
26
|
+
throw new Error('server failed PIN verification — possible MITM, aborting');
|
|
27
|
+
return { name, host, port, token, key: key.toString('base64') };
|
|
28
|
+
}
|
|
29
|
+
export async function fetchRemote(device) {
|
|
30
|
+
const { sealed } = await post(device.host, device.port, '/manifest', { token: device.token });
|
|
31
|
+
return openJson(Buffer.from(device.key, 'base64'), sealed);
|
|
32
|
+
}
|
|
33
|
+
export async function fetchSecrets(device, names) {
|
|
34
|
+
const key = Buffer.from(device.key, 'base64');
|
|
35
|
+
const { sealed } = await post(device.host, device.port, '/secrets', { token: device.token, sealed: sealJson(key, { names }) });
|
|
36
|
+
return openJson(key, sealed);
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Platform } from './platform.js';
|
|
2
|
+
/** After this many failed PIN confirmations the server refuses further pairing (brute-force guard). */
|
|
3
|
+
export declare const MAX_PIN_ATTEMPTS = 5;
|
|
4
|
+
export interface SyncServerDeps {
|
|
5
|
+
manifestRoot: string;
|
|
6
|
+
platform: Platform;
|
|
7
|
+
allowSecrets?: boolean;
|
|
8
|
+
/** resolve secret values by name (from the local store) when --allow-secrets */
|
|
9
|
+
secretValues?: (names: string[]) => Promise<Record<string, string>>;
|
|
10
|
+
port?: number;
|
|
11
|
+
/** interface to bind (default 0.0.0.0 — all interfaces, needed for LAN peers) */
|
|
12
|
+
host?: string;
|
|
13
|
+
/** override the generated PIN (tests) */
|
|
14
|
+
pin?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function startSyncServer(deps: SyncServerDeps): Promise<{
|
|
17
|
+
port: number;
|
|
18
|
+
host: string;
|
|
19
|
+
pin: string;
|
|
20
|
+
close: () => Promise<void>;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { deriveSharedKey, handshakeKeys, newPin, newSalt, newToken, openJson, pinMac, sealJson, } from './crypto.js';
|
|
6
|
+
import { parseManifest } from './manifest.js';
|
|
7
|
+
import { collectAssets } from './assets.js';
|
|
8
|
+
import { loadDevices, saveDevices } from './devices.js';
|
|
9
|
+
/** After this many failed PIN confirmations the server refuses further pairing (brute-force guard). */
|
|
10
|
+
export const MAX_PIN_ATTEMPTS = 5;
|
|
11
|
+
async function body(req) {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const c of req)
|
|
14
|
+
chunks.push(c);
|
|
15
|
+
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
|
|
16
|
+
}
|
|
17
|
+
function send(res, code, obj) {
|
|
18
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
19
|
+
res.end(JSON.stringify(obj));
|
|
20
|
+
}
|
|
21
|
+
/** Constant-time base64 comparison; false on length mismatch (never throws). */
|
|
22
|
+
function ctEqual(a, b) {
|
|
23
|
+
const ba = Buffer.from(String(a ?? ''), 'base64');
|
|
24
|
+
const bb = Buffer.from(String(b ?? ''), 'base64');
|
|
25
|
+
return ba.length === bb.length && ba.length > 0 && timingSafeEqual(ba, bb);
|
|
26
|
+
}
|
|
27
|
+
/** Constant-time comparison of the opaque device tokens (base64url strings). */
|
|
28
|
+
function ctEqualToken(a, b) {
|
|
29
|
+
const ba = Buffer.from(String(a ?? ''), 'utf8');
|
|
30
|
+
const bb = Buffer.from(String(b ?? ''), 'utf8');
|
|
31
|
+
return ba.length === bb.length && ba.length > 0 && timingSafeEqual(ba, bb);
|
|
32
|
+
}
|
|
33
|
+
export async function startSyncServer(deps) {
|
|
34
|
+
const pin = deps.pin ?? newPin();
|
|
35
|
+
const host = deps.host ?? '0.0.0.0';
|
|
36
|
+
// per-handshake state keyed by a server-issued id — no shared global, no cross-client clobber
|
|
37
|
+
const handshakes = new Map();
|
|
38
|
+
let failedAttempts = 0;
|
|
39
|
+
let locked = false;
|
|
40
|
+
const server = createServer(async (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
if (req.method !== 'POST')
|
|
43
|
+
return send(res, 405, { error: 'POST only' });
|
|
44
|
+
const payload = await body(req).catch(() => null);
|
|
45
|
+
if (payload === null)
|
|
46
|
+
return send(res, 400, { error: 'invalid request body' });
|
|
47
|
+
if (req.url === '/pair') {
|
|
48
|
+
if (locked)
|
|
49
|
+
return send(res, 429, { error: 'pairing locked after too many failed attempts — restart ccp serve' });
|
|
50
|
+
const { privateKey, publicRaw } = handshakeKeys();
|
|
51
|
+
const salt = newSalt();
|
|
52
|
+
const key = deriveSharedKey(privateKey, payload.clientPub, salt);
|
|
53
|
+
const handshakeId = newToken();
|
|
54
|
+
handshakes.set(handshakeId, { privateKey, salt, key });
|
|
55
|
+
return send(res, 200, { handshakeId, serverPub: publicRaw, salt });
|
|
56
|
+
}
|
|
57
|
+
if (req.url === '/pair/confirm') {
|
|
58
|
+
if (locked)
|
|
59
|
+
return send(res, 429, { error: 'pairing locked after too many failed attempts — restart ccp serve' });
|
|
60
|
+
const pending = typeof payload.handshakeId === 'string' ? handshakes.get(payload.handshakeId) : undefined;
|
|
61
|
+
if (!pending)
|
|
62
|
+
return send(res, 400, { error: 'no pending handshake (call /pair first)' });
|
|
63
|
+
handshakes.delete(payload.handshakeId); // one shot per handshake
|
|
64
|
+
if (!ctEqual(payload.mac, pinMac(pending.key, 'client', pin))) {
|
|
65
|
+
if (++failedAttempts >= MAX_PIN_ATTEMPTS)
|
|
66
|
+
locked = true;
|
|
67
|
+
return send(res, 403, { error: 'pin mismatch', attemptsLeft: Math.max(0, MAX_PIN_ATTEMPTS - failedAttempts) });
|
|
68
|
+
}
|
|
69
|
+
failedAttempts = 0; // successful pair resets the counter
|
|
70
|
+
const token = newToken();
|
|
71
|
+
const devices = await loadDevices(deps.manifestRoot);
|
|
72
|
+
devices.push({ name: String(payload.name ?? 'peer'), host: '-', port: 0, token, key: pending.key.toString('base64') });
|
|
73
|
+
await saveDevices(deps.manifestRoot, devices);
|
|
74
|
+
return send(res, 200, { mac: pinMac(pending.key, 'server', pin), token });
|
|
75
|
+
}
|
|
76
|
+
// authenticated endpoints: match device by constant-time token compare
|
|
77
|
+
const devices = await loadDevices(deps.manifestRoot);
|
|
78
|
+
const device = typeof payload.token === 'string'
|
|
79
|
+
? devices.find(d => ctEqualToken(d.token, payload.token))
|
|
80
|
+
: undefined;
|
|
81
|
+
if (!device)
|
|
82
|
+
return send(res, 401, { error: 'unknown token' });
|
|
83
|
+
const key = Buffer.from(device.key, 'base64');
|
|
84
|
+
if (req.url === '/manifest') {
|
|
85
|
+
const manifestYaml = await readFile(join(deps.manifestRoot, 'manifest.yaml'), 'utf8');
|
|
86
|
+
const assets = await collectAssets(parseManifest(manifestYaml), deps.platform);
|
|
87
|
+
return send(res, 200, { sealed: sealJson(key, { manifestYaml, assets }) });
|
|
88
|
+
}
|
|
89
|
+
if (req.url === '/secrets') {
|
|
90
|
+
if (!deps.allowSecrets || !deps.secretValues)
|
|
91
|
+
return send(res, 403, { error: 'secrets transfer not enabled (serve --allow-secrets)' });
|
|
92
|
+
const names = payload.sealed ? openJson(key, payload.sealed).names : [];
|
|
93
|
+
const values = await deps.secretValues(names);
|
|
94
|
+
return send(res, 200, { sealed: sealJson(key, values) });
|
|
95
|
+
}
|
|
96
|
+
return send(res, 404, { error: 'not found' });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// never leak internal error detail (paths, parse internals) to callers
|
|
100
|
+
return send(res, 500, { error: 'internal error' });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
await new Promise(resolve => server.listen(deps.port ?? 0, host, resolve));
|
|
104
|
+
const address = server.address();
|
|
105
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
106
|
+
return {
|
|
107
|
+
port,
|
|
108
|
+
host,
|
|
109
|
+
pin,
|
|
110
|
+
close: () => new Promise((resolve, reject) => server.close(e => (e ? reject(e) : resolve()))),
|
|
111
|
+
};
|
|
112
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ccprofiles-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core library for ccprofiles — discovery, manifest, secrets, apply, and LAN sync for Claude Code multi-account setups",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/tmfadhlul/claude-code-profile.git", "directory": "packages/core" },
|
|
11
|
+
"homepage": "https://github.com/tmfadhlul/claude-code-profile#readme",
|
|
12
|
+
"bugs": "https://github.com/tmfadhlul/claude-code-profile/issues",
|
|
13
|
+
"keywords": ["claude-code", "claude", "profiles", "mcp", "config-management"],
|
|
14
|
+
"engines": { "node": ">=20" },
|
|
15
|
+
"dependencies": { "yaml": "^2.5.0", "zod": "^3.23.0" }
|
|
16
|
+
}
|