@phnx-labs/agents-cli 1.20.16 → 1.20.17
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/CHANGELOG.md +4 -0
- package/dist/commands/sync.d.ts +10 -3
- package/dist/commands/sync.js +72 -9
- package/dist/lib/hooks.js +12 -0
- package/dist/lib/plugin-marketplace.js +16 -6
- package/dist/lib/secrets/drivers/rush.d.ts +14 -0
- package/dist/lib/secrets/drivers/rush.js +84 -0
- package/dist/lib/secrets/linux.js +88 -10
- package/dist/lib/secrets/sync-backend.d.ts +48 -0
- package/dist/lib/secrets/sync-backend.js +13 -0
- package/dist/lib/secrets/sync.d.ts +15 -23
- package/dist/lib/secrets/sync.js +31 -66
- package/dist/lib/sync-umbrella.d.ts +76 -0
- package/dist/lib/sync-umbrella.js +125 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
**Headless Linux: `agents secrets` works out of the box when the keyring is locked**
|
|
6
|
+
|
|
7
|
+
- On a headless server the libsecret/GNOME-keyring collection is locked, so the encrypted-file fallback is the only option — but it previously hard-failed unless `AGENTS_SECRETS_PASSPHRASE` was set, leaving `agents secrets` silently unusable. Now, on a headless run with no passphrase set, a random machine-local passphrase is auto-provisioned once at `~/.agents/.cache/secrets/.passphrase` (mode 0600) so the encrypted-file store just works. `AGENTS_SECRETS_PASSPHRASE` still takes precedence (off-disk key), an existing `.passphrase` is reused for stable interactive/headless behavior, and interactive TTY sessions are still prompted. Security model + resolution order documented in `docs/secrets.md`. (#371)
|
|
8
|
+
|
|
5
9
|
**`agents secrets get/set <item>`: raw, cross-platform keychain access for hooks**
|
|
6
10
|
|
|
7
11
|
- New `agents secrets get <item>` / `agents secrets set <item>` read and write a single keychain item **by bare name** (outside the bundle namespace), so shell hooks and automation have one platform-agnostic credential primitive to call instead of hardcoding `/usr/bin/security` (macOS-only) or `secret-tool` (Linux-only). `get` prints the value to stdout (newline-terminated for clean `$(…)` capture), sends diagnostics to stderr, and exits 1 with empty stdout when the item is missing — exactly what a `SessionStart` hook needs to probe-and-fallback quietly. Routing goes through the existing cross-platform keychain layer: macOS via `/usr/bin/security`, Linux via `secret-tool` with the encrypted-file fallback.
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
* `agents sync` — synchronize central resources into an installed agent version.
|
|
3
3
|
*
|
|
4
4
|
* Forms:
|
|
5
|
-
* agents sync
|
|
6
|
-
* agents sync
|
|
7
|
-
* agents sync
|
|
5
|
+
* agents sync # umbrella: fetch remote (repos+secrets+sessions) -> reconcile all
|
|
6
|
+
* agents sync --repos|--secrets|--sessions # umbrella: fetch only those, then reconcile
|
|
7
|
+
* agents sync --cloud # umbrella: fetch all, skip reconcile
|
|
8
|
+
* agents sync --local # umbrella: reconcile all, no fetch
|
|
9
|
+
* agents sync claude # one agent: uses default/sole installed version
|
|
10
|
+
* agents sync claude@2.1.142 # one agent: explicit version
|
|
11
|
+
* agents sync claude@latest # one agent: newest installed
|
|
8
12
|
* agents sync --agent claude --agent-version 2.1.142 # legacy form, still supported
|
|
9
13
|
*
|
|
14
|
+
* The umbrella stages live in lib/sync-umbrella.ts; this file dispatches to them
|
|
15
|
+
* when no agent is given.
|
|
16
|
+
*
|
|
10
17
|
* In a TTY the command previews available/new resources and lets the user
|
|
11
18
|
* select what to sync (same prompts shown after `agents add`). Pass
|
|
12
19
|
* --yes for non-interactive auto-sync, --force to re-sync when nothing
|
package/dist/commands/sync.js
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
* `agents sync` — synchronize central resources into an installed agent version.
|
|
3
3
|
*
|
|
4
4
|
* Forms:
|
|
5
|
-
* agents sync
|
|
6
|
-
* agents sync
|
|
7
|
-
* agents sync
|
|
5
|
+
* agents sync # umbrella: fetch remote (repos+secrets+sessions) -> reconcile all
|
|
6
|
+
* agents sync --repos|--secrets|--sessions # umbrella: fetch only those, then reconcile
|
|
7
|
+
* agents sync --cloud # umbrella: fetch all, skip reconcile
|
|
8
|
+
* agents sync --local # umbrella: reconcile all, no fetch
|
|
9
|
+
* agents sync claude # one agent: uses default/sole installed version
|
|
10
|
+
* agents sync claude@2.1.142 # one agent: explicit version
|
|
11
|
+
* agents sync claude@latest # one agent: newest installed
|
|
8
12
|
* agents sync --agent claude --agent-version 2.1.142 # legacy form, still supported
|
|
9
13
|
*
|
|
14
|
+
* The umbrella stages live in lib/sync-umbrella.ts; this file dispatches to them
|
|
15
|
+
* when no agent is given.
|
|
16
|
+
*
|
|
10
17
|
* In a TTY the command previews available/new resources and lets the user
|
|
11
18
|
* select what to sync (same prompts shown after `agents add`). Pass
|
|
12
19
|
* --yes for non-interactive auto-sync, --force to re-sync when nothing
|
|
@@ -25,12 +32,13 @@ import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVers
|
|
|
25
32
|
import { compileRulesForProject } from '../lib/rules/compile.js';
|
|
26
33
|
import { runLaunchSync } from '../lib/project-launch.js';
|
|
27
34
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
35
|
+
import { runUmbrellaSync } from '../lib/sync-umbrella.js';
|
|
28
36
|
/** Register the `agents sync` command. */
|
|
29
37
|
export function registerSyncCommand(program) {
|
|
30
38
|
program
|
|
31
39
|
.command('sync [agentSpec]')
|
|
32
|
-
.summary('
|
|
33
|
-
.description('
|
|
40
|
+
.summary('Make this machine current, or sync resources into one agent')
|
|
41
|
+
.description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude" or "claude@2.1.142".\n\nWith NO agent, runs the umbrella verb: fetch remote state (config repos + secrets + sessions) then reconcile it into every installed agent. Scope it with --repos / --secrets / --sessions, --cloud (fetch only), or --local (reconcile only).')
|
|
34
42
|
.option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
|
|
35
43
|
.option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version")')
|
|
36
44
|
.option('--project-dir <path>', 'Path to project-level .agents/ directory containing project-scoped resources')
|
|
@@ -39,10 +47,66 @@ export function registerSyncCommand(program) {
|
|
|
39
47
|
.option('-y, --yes', 'Skip the interactive preview and auto-sync all detected resources', false)
|
|
40
48
|
.option('--force', 'Re-sync even if no changes are detected since the last sync', false)
|
|
41
49
|
.option('--quiet', 'Suppress all output (exit code indicates success)', false)
|
|
50
|
+
// Umbrella verb (no agent given): make this machine current.
|
|
51
|
+
.option('--repos', 'Umbrella: git-pull ~/.agents + enabled ~/.agents-* extras', false)
|
|
52
|
+
.option('--secrets', 'Umbrella: pull encrypted secret bundles from the remote', false)
|
|
53
|
+
.option('--sessions', 'Umbrella: sync session transcripts across machines', false)
|
|
54
|
+
.option('--cloud', 'Umbrella: fetch all remote state but skip the local reconcile', false)
|
|
55
|
+
.option('--local', "Umbrella: reconcile resources into installed agents only (no fetch)", false)
|
|
42
56
|
.action(async (agentSpec, opts) => {
|
|
43
57
|
await runSync(agentSpec, opts);
|
|
44
58
|
});
|
|
45
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The umbrella verb: bare `agents sync` (no agent) makes this machine current.
|
|
62
|
+
* Resolves the flags + a secrets passphrase (env-only for now; tokenized auth
|
|
63
|
+
* arrives with `agents login`) and runs the fetch+reconcile stages, then prints
|
|
64
|
+
* a one-line summary. Stage failures are non-fatal and surfaced as warnings.
|
|
65
|
+
*/
|
|
66
|
+
async function runUmbrella(opts, quiet, outLog, errLog) {
|
|
67
|
+
const flags = {
|
|
68
|
+
repos: opts.repos,
|
|
69
|
+
secrets: opts.secrets,
|
|
70
|
+
sessions: opts.sessions,
|
|
71
|
+
cloud: opts.cloud,
|
|
72
|
+
local: opts.local,
|
|
73
|
+
};
|
|
74
|
+
const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE || undefined;
|
|
75
|
+
if (!quiet)
|
|
76
|
+
outLog(chalk.bold('Syncing this machine…'));
|
|
77
|
+
try {
|
|
78
|
+
const result = await runUmbrellaSync({
|
|
79
|
+
flags,
|
|
80
|
+
yes: !!opts.yes,
|
|
81
|
+
passphrase,
|
|
82
|
+
log: (msg) => { if (!quiet)
|
|
83
|
+
outLog(chalk.gray(` ${msg}`)); },
|
|
84
|
+
});
|
|
85
|
+
if (!quiet) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
if (result.repos) {
|
|
88
|
+
parts.push(`repos ${result.repos.pulled} pulled` +
|
|
89
|
+
(result.repos.errors.length ? `, ${result.repos.errors.length} failed` : ''));
|
|
90
|
+
}
|
|
91
|
+
if (result.secrets) {
|
|
92
|
+
parts.push(result.secrets.skipped ? 'secrets skipped' : `secrets ${result.secrets.pulled} pulled`);
|
|
93
|
+
}
|
|
94
|
+
if (result.sessions) {
|
|
95
|
+
parts.push(result.sessions.ran ? `sessions ${result.sessions.merged} merged` : 'sessions off');
|
|
96
|
+
}
|
|
97
|
+
if (result.reconciled)
|
|
98
|
+
parts.push('reconciled');
|
|
99
|
+
outLog(chalk.green(`✓ sync: ${parts.join(' · ') || 'nothing to do'}`));
|
|
100
|
+
const errs = [...(result.repos?.errors ?? []), ...(result.secrets?.errors ?? [])];
|
|
101
|
+
for (const e of errs)
|
|
102
|
+
errLog(chalk.yellow(` ! ${e}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
errLog(chalk.red(`sync failed: ${err.message}`));
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
46
110
|
async function runSync(agentSpec, opts) {
|
|
47
111
|
const quiet = !!opts.quiet;
|
|
48
112
|
const errLog = (msg) => { if (!quiet)
|
|
@@ -77,10 +141,9 @@ async function runSync(agentSpec, opts) {
|
|
|
77
141
|
version = opts.agentVersion;
|
|
78
142
|
}
|
|
79
143
|
if (!agentId) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
process.exitCode = 1;
|
|
144
|
+
// No agent specified → the umbrella verb: make this machine current
|
|
145
|
+
// (fetch repos + secrets + sessions, then reconcile all installed agents).
|
|
146
|
+
await runUmbrella(opts, quiet, outLog, errLog);
|
|
84
147
|
return;
|
|
85
148
|
}
|
|
86
149
|
// ---------- 2. Resolve version (project pin → global default → sole installed) ----------
|
package/dist/lib/hooks.js
CHANGED
|
@@ -90,6 +90,18 @@ function isManagedHookCommand(command, prefixes) {
|
|
|
90
90
|
for (const prefix of prefixes) {
|
|
91
91
|
if (resolved.startsWith(prefix))
|
|
92
92
|
return true;
|
|
93
|
+
// The command dir above is realpath-resolved, but a raw prefix may still
|
|
94
|
+
// point through a symlink (macOS TMPDIR /var -> /private/var, or a
|
|
95
|
+
// symlinked ~/.agents). Compare against a realpath-normalized prefix too
|
|
96
|
+
// so the two sides match. Strip the trailing sep, resolve the dir, re-add.
|
|
97
|
+
const rawPrefixDir = prefix.endsWith(path.sep) ? prefix.slice(0, -path.sep.length) : prefix;
|
|
98
|
+
let resolvedPrefix = prefix;
|
|
99
|
+
try {
|
|
100
|
+
resolvedPrefix = fs.realpathSync(rawPrefixDir) + path.sep;
|
|
101
|
+
}
|
|
102
|
+
catch { /* absent or broken link */ }
|
|
103
|
+
if (resolvedPrefix !== prefix && resolved.startsWith(resolvedPrefix))
|
|
104
|
+
return true;
|
|
93
105
|
}
|
|
94
106
|
return false;
|
|
95
107
|
}
|
|
@@ -209,17 +209,24 @@ export function validateClaudePluginManifest(manifest) {
|
|
|
209
209
|
const value = m[field];
|
|
210
210
|
if (value === undefined || value === null)
|
|
211
211
|
continue;
|
|
212
|
+
// How-to-fix written so a human OR a coding agent reading stderr can act
|
|
213
|
+
// without further investigation. Deleting the field is the recommended fix:
|
|
214
|
+
// Claude auto-discovers skills/commands/agents from their directories, which
|
|
215
|
+
// is why every well-formed plugin omits these fields entirely.
|
|
216
|
+
const fix = `Fix: delete the "${field}" field from plugin.json (recommended — Claude ` +
|
|
217
|
+
`auto-discovers from the ${field}/ directory), or rewrite every entry as a ` +
|
|
218
|
+
`"./"-relative path (e.g. "./${field}/<name>").`;
|
|
212
219
|
const entries = Array.isArray(value) ? value : [value];
|
|
213
220
|
for (const entry of entries) {
|
|
214
221
|
if (typeof entry !== 'string') {
|
|
215
|
-
warnings.push(`
|
|
216
|
-
`
|
|
222
|
+
warnings.push(`field "${field}" must be a "./"-relative path string or an array of them; ` +
|
|
223
|
+
`found a non-string entry. Claude Code silently rejects the ENTIRE plugin. ${fix}`);
|
|
217
224
|
break;
|
|
218
225
|
}
|
|
219
226
|
if (!entry.startsWith('./')) {
|
|
220
|
-
warnings.push(`
|
|
221
|
-
`(e.g. "./${field}/${entry}"). Claude Code rejects the
|
|
222
|
-
`
|
|
227
|
+
warnings.push(`field "${field}" entry "${entry}" must be a relative path starting with "./" ` +
|
|
228
|
+
`(e.g. "./${field}/${entry}"), not a bare name. Claude Code silently rejects the ` +
|
|
229
|
+
`ENTIRE plugin — no commands or skills load. ${fix}`);
|
|
223
230
|
break;
|
|
224
231
|
}
|
|
225
232
|
}
|
|
@@ -268,7 +275,10 @@ export function syncMarketplaceManifest(spec, agent, versionHome) {
|
|
|
268
275
|
continue;
|
|
269
276
|
}
|
|
270
277
|
for (const warning of validateClaudePluginManifest(manifest)) {
|
|
271
|
-
|
|
278
|
+
// Reference the plugin by name, not the marketplace-copy path: that copy is
|
|
279
|
+
// regenerated from source on every sync, so editing it gets stomped. The fix
|
|
280
|
+
// belongs in the plugin's SOURCE .claude-plugin/plugin.json.
|
|
281
|
+
process.stderr.write(`agents-cli: plugin '${manifest.name ?? entry.name}' has a Claude-invalid manifest — ${warning}\n`);
|
|
272
282
|
}
|
|
273
283
|
entries.push({
|
|
274
284
|
name: manifest.name,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rush `SyncBackend` driver — the original (and currently default) transport
|
|
3
|
+
* for `agents secrets push/pull`. Talks to api.prix.dev and authenticates with
|
|
4
|
+
* the session token written by `rush login` (`~/.rush/user.yaml`).
|
|
5
|
+
*
|
|
6
|
+
* This is the ONE place in the secrets module allowed to reference Rush
|
|
7
|
+
* (api.prix.dev / ~/.rush). It is an opt-in driver kept for backwards
|
|
8
|
+
* compatibility with bundles already pushed to Rush; `sync.ts` selects it as
|
|
9
|
+
* the default but the transport seam (`SyncBackend`) lets other backends drop
|
|
10
|
+
* in without touching the crypto or push/pull logic.
|
|
11
|
+
*/
|
|
12
|
+
import type { SyncBackend } from '../sync-backend.js';
|
|
13
|
+
/** The Rush transport. Plaintext never reaches here — only ciphertext envelopes. */
|
|
14
|
+
export declare const rushSyncBackend: SyncBackend;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rush `SyncBackend` driver — the original (and currently default) transport
|
|
3
|
+
* for `agents secrets push/pull`. Talks to api.prix.dev and authenticates with
|
|
4
|
+
* the session token written by `rush login` (`~/.rush/user.yaml`).
|
|
5
|
+
*
|
|
6
|
+
* This is the ONE place in the secrets module allowed to reference Rush
|
|
7
|
+
* (api.prix.dev / ~/.rush). It is an opt-in driver kept for backwards
|
|
8
|
+
* compatibility with bundles already pushed to Rush; `sync.ts` selects it as
|
|
9
|
+
* the default but the transport seam (`SyncBackend`) lets other backends drop
|
|
10
|
+
* in without touching the crypto or push/pull logic.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import * as yaml from 'yaml';
|
|
16
|
+
const PROXY_BASE = 'https://api.prix.dev';
|
|
17
|
+
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
18
|
+
const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
|
|
19
|
+
function readRushToken() {
|
|
20
|
+
if (!fs.existsSync(USER_YAML)) {
|
|
21
|
+
throw new Error('Not logged in to Rush. Run `rush login` first.');
|
|
22
|
+
}
|
|
23
|
+
const raw = fs.readFileSync(USER_YAML, 'utf-8');
|
|
24
|
+
const data = yaml.parse(raw);
|
|
25
|
+
const token = data?.session?.access_token;
|
|
26
|
+
if (!token) {
|
|
27
|
+
throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
|
|
28
|
+
}
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
async function api(method, endpoint, body) {
|
|
32
|
+
const token = readRushToken();
|
|
33
|
+
const url = endpoint.startsWith('http') ? endpoint : `${PROXY_BASE}${endpoint}`;
|
|
34
|
+
return fetch(url, {
|
|
35
|
+
method,
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${token}`,
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
},
|
|
40
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function bundlePath(name) {
|
|
44
|
+
return `${BUNDLE_ENDPOINT}/${encodeURIComponent(name)}`;
|
|
45
|
+
}
|
|
46
|
+
/** The Rush transport. Plaintext never reaches here — only ciphertext envelopes. */
|
|
47
|
+
export const rushSyncBackend = {
|
|
48
|
+
async putEnvelope(name, payload) {
|
|
49
|
+
const res = await api('PUT', bundlePath(name), payload);
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const body = await res.text().catch(() => '');
|
|
52
|
+
throw new Error(`Push failed (${res.status} ${res.statusText}): ${body}`);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async getEnvelope(name) {
|
|
56
|
+
const res = await api('GET', bundlePath(name));
|
|
57
|
+
if (res.status === 404)
|
|
58
|
+
return null;
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const body = await res.text().catch(() => '');
|
|
61
|
+
throw new Error(`Pull failed (${res.status} ${res.statusText}): ${body}`);
|
|
62
|
+
}
|
|
63
|
+
return await res.json();
|
|
64
|
+
},
|
|
65
|
+
async deleteEnvelope(name) {
|
|
66
|
+
const res = await api('DELETE', bundlePath(name));
|
|
67
|
+
if (res.status === 404)
|
|
68
|
+
return false;
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
const body = await res.text().catch(() => '');
|
|
71
|
+
throw new Error(`Delete failed (${res.status} ${res.statusText}): ${body}`);
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
async listEnvelopes() {
|
|
76
|
+
const res = await api('GET', BUNDLE_ENDPOINT);
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const body = await res.text().catch(() => '');
|
|
79
|
+
throw new Error(`List failed (${res.status} ${res.statusText}): ${body}`);
|
|
80
|
+
}
|
|
81
|
+
const data = await res.json();
|
|
82
|
+
return data.bundles ?? [];
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -38,6 +38,7 @@ let isAvailable = false;
|
|
|
38
38
|
// ---------- file fallback state ----------
|
|
39
39
|
let useFileFallback = false;
|
|
40
40
|
let warnedFallback = false;
|
|
41
|
+
let warnedAutoPassphrase = false;
|
|
41
42
|
let fileDirOverride = null;
|
|
42
43
|
let cachedPassphrase = null;
|
|
43
44
|
function fileDir() {
|
|
@@ -87,7 +88,13 @@ function preflight() {
|
|
|
87
88
|
checkedAvailability = true;
|
|
88
89
|
}
|
|
89
90
|
if (!isAvailable) {
|
|
90
|
-
|
|
91
|
+
// No secret-tool. Route to the encrypted-file fallback whenever a passphrase
|
|
92
|
+
// source exists or can be auto-provisioned: an explicit
|
|
93
|
+
// AGENTS_SECRETS_PASSPHRASE, an already-provisioned machine-local passphrase,
|
|
94
|
+
// or a headless context (no TTY) where getPassphrase() auto-provisions one.
|
|
95
|
+
// Only an INTERACTIVE session with none of these gets the install hint —
|
|
96
|
+
// installing libsecret is the better fix when someone is at the keyboard.
|
|
97
|
+
if (process.env.AGENTS_SECRETS_PASSPHRASE || machinePassphraseExists() || !process.stdin.isTTY) {
|
|
91
98
|
activateFileFallback();
|
|
92
99
|
return 'file';
|
|
93
100
|
}
|
|
@@ -141,6 +148,67 @@ function readPassphraseFromTty() {
|
|
|
141
148
|
fs.closeSync(fd);
|
|
142
149
|
}
|
|
143
150
|
}
|
|
151
|
+
/** Path of the auto-provisioned machine-local passphrase. Lives alongside the
|
|
152
|
+
* encrypted items but is never itself an item (no `.enc` suffix, so it's
|
|
153
|
+
* excluded from list/has/get and from fileFallbackPreviouslyActivated). */
|
|
154
|
+
function passphraseFilePath() {
|
|
155
|
+
return path.join(fileDir(), '.passphrase');
|
|
156
|
+
}
|
|
157
|
+
/** True if a machine-local passphrase has already been provisioned. */
|
|
158
|
+
function machinePassphraseExists() {
|
|
159
|
+
try {
|
|
160
|
+
return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function readMachinePassphrase() {
|
|
167
|
+
try {
|
|
168
|
+
const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
|
|
169
|
+
return p.length > 0 ? p : null;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Provision (or read back) a stable machine-local passphrase for the encrypted
|
|
177
|
+
* file store, so `agents secrets` works out of the box on a headless box where
|
|
178
|
+
* the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
|
|
179
|
+
*
|
|
180
|
+
* Security model: this is encryption-at-rest with the key held in a 0600 file —
|
|
181
|
+
* the same posture as an SSH private key, and identical to the common
|
|
182
|
+
* "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
|
|
183
|
+
* keyring (key in a daemon's locked memory) is stronger but is unavailable
|
|
184
|
+
* without a graphical/unlocked session. For an off-disk key, set
|
|
185
|
+
* AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
|
|
186
|
+
*/
|
|
187
|
+
function provisionMachinePassphrase() {
|
|
188
|
+
const existing = readMachinePassphrase();
|
|
189
|
+
if (existing)
|
|
190
|
+
return existing;
|
|
191
|
+
ensureFileDir();
|
|
192
|
+
const generated = randomBytes(32).toString('base64');
|
|
193
|
+
const fp = passphraseFilePath();
|
|
194
|
+
try {
|
|
195
|
+
// wx: fail if a concurrent process created it first (then we read theirs).
|
|
196
|
+
fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
const raced = readMachinePassphrase();
|
|
200
|
+
if (raced)
|
|
201
|
+
return raced;
|
|
202
|
+
throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
|
|
203
|
+
}
|
|
204
|
+
if (!warnedAutoPassphrase) {
|
|
205
|
+
warnedAutoPassphrase = true;
|
|
206
|
+
process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
|
|
207
|
+
`machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
|
|
208
|
+
`for a key held off disk.\n`);
|
|
209
|
+
}
|
|
210
|
+
return generated;
|
|
211
|
+
}
|
|
144
212
|
function getPassphrase() {
|
|
145
213
|
if (cachedPassphrase !== null)
|
|
146
214
|
return cachedPassphrase;
|
|
@@ -149,16 +217,25 @@ function getPassphrase() {
|
|
|
149
217
|
cachedPassphrase = env;
|
|
150
218
|
return env;
|
|
151
219
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
220
|
+
// A previously-provisioned machine-local passphrase is this machine's stable
|
|
221
|
+
// file-store key — prefer it for both interactive and headless runs so they
|
|
222
|
+
// always agree (a TTY run won't re-prompt once the file exists).
|
|
223
|
+
const onDisk = readMachinePassphrase();
|
|
224
|
+
if (onDisk) {
|
|
225
|
+
cachedPassphrase = onDisk;
|
|
226
|
+
return onDisk;
|
|
227
|
+
}
|
|
228
|
+
// First run, no env, no provisioned key: prompt when interactive, otherwise
|
|
229
|
+
// (headless — the reported bug) auto-provision instead of hard-failing.
|
|
230
|
+
if (process.stdin.isTTY) {
|
|
231
|
+
const p = readPassphraseFromTty();
|
|
232
|
+
if (!p)
|
|
233
|
+
throw new Error('No passphrase entered.');
|
|
234
|
+
cachedPassphrase = p;
|
|
235
|
+
return p;
|
|
156
236
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
throw new Error('No passphrase entered.');
|
|
160
|
-
cachedPassphrase = p;
|
|
161
|
-
return p;
|
|
237
|
+
cachedPassphrase = provisionMachinePassphrase();
|
|
238
|
+
return cachedPassphrase;
|
|
162
239
|
}
|
|
163
240
|
function deriveKey(passphrase, salt) {
|
|
164
241
|
return scryptSync(passphrase, salt, 32);
|
|
@@ -423,6 +500,7 @@ export function _resetForTest(opts = {}) {
|
|
|
423
500
|
fileDirOverride = opts.fileDir ?? null;
|
|
424
501
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
425
502
|
warnedFallback = false;
|
|
503
|
+
warnedAutoPassphrase = false;
|
|
426
504
|
cachedPassphrase = opts.passphrase ?? null;
|
|
427
505
|
checkedAvailability = false;
|
|
428
506
|
isAvailable = false;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seam for encrypted secrets-bundle sync.
|
|
3
|
+
*
|
|
4
|
+
* A `SyncBackend` moves opaque ciphertext envelopes to and from some remote.
|
|
5
|
+
* It NEVER sees plaintext: encryption (`encryptBlob`) happens in `sync.ts`
|
|
6
|
+
* before `putEnvelope`, and decryption (`decryptBlob`) happens after
|
|
7
|
+
* `getEnvelope`. This mirrors the `KeychainBackend` storage seam
|
|
8
|
+
* (`src/lib/secrets/index.ts`) but abstracts *transport* rather than at-rest
|
|
9
|
+
* storage — so the high-level push/pull logic in `sync.ts` is decoupled from
|
|
10
|
+
* any specific backend (Rush's api.prix.dev, a future Supabase driver, an
|
|
11
|
+
* in-memory test double, …).
|
|
12
|
+
*/
|
|
13
|
+
/** Encrypted bundle envelope (AES-256-GCM, key via PBKDF2-SHA256). All byte
|
|
14
|
+
* fields are base64. The server only ever stores/returns this — never plaintext. */
|
|
15
|
+
export interface EncryptedEnvelope {
|
|
16
|
+
v: 1;
|
|
17
|
+
kdf: 'pbkdf2-sha256';
|
|
18
|
+
iter: number;
|
|
19
|
+
salt: string;
|
|
20
|
+
iv: string;
|
|
21
|
+
ct: string;
|
|
22
|
+
tag: string;
|
|
23
|
+
}
|
|
24
|
+
/** A stored bundle object: the ciphertext envelope plus a last-updated stamp. */
|
|
25
|
+
export interface SyncEnvelope {
|
|
26
|
+
envelope: EncryptedEnvelope;
|
|
27
|
+
updated_at: string;
|
|
28
|
+
}
|
|
29
|
+
/** Lightweight listing entry returned by `listEnvelopes`. */
|
|
30
|
+
export interface RemoteBundleSummary {
|
|
31
|
+
name: string;
|
|
32
|
+
updated_at: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pluggable transport for encrypted bundles. Implementations handle only the
|
|
36
|
+
* wire/storage; the crypto and bundle snapshot/restore stay backend-agnostic
|
|
37
|
+
* in `sync.ts`.
|
|
38
|
+
*/
|
|
39
|
+
export interface SyncBackend {
|
|
40
|
+
/** Store (create or overwrite) the envelope for `name`. */
|
|
41
|
+
putEnvelope(name: string, payload: SyncEnvelope): Promise<void>;
|
|
42
|
+
/** Fetch the envelope for `name`, or `null` if the remote has none. */
|
|
43
|
+
getEnvelope(name: string): Promise<SyncEnvelope | null>;
|
|
44
|
+
/** Delete `name` on the remote. Returns false if it didn't exist. */
|
|
45
|
+
deleteEnvelope(name: string): Promise<boolean>;
|
|
46
|
+
/** List every bundle the authenticated user has on the remote. */
|
|
47
|
+
listEnvelopes(): Promise<RemoteBundleSummary[]>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seam for encrypted secrets-bundle sync.
|
|
3
|
+
*
|
|
4
|
+
* A `SyncBackend` moves opaque ciphertext envelopes to and from some remote.
|
|
5
|
+
* It NEVER sees plaintext: encryption (`encryptBlob`) happens in `sync.ts`
|
|
6
|
+
* before `putEnvelope`, and decryption (`decryptBlob`) happens after
|
|
7
|
+
* `getEnvelope`. This mirrors the `KeychainBackend` storage seam
|
|
8
|
+
* (`src/lib/secrets/index.ts`) but abstracts *transport* rather than at-rest
|
|
9
|
+
* storage — so the high-level push/pull logic in `sync.ts` is decoupled from
|
|
10
|
+
* any specific backend (Rush's api.prix.dev, a future Supabase driver, an
|
|
11
|
+
* in-memory test double, …).
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -1,28 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote sync
|
|
2
|
+
* Remote sync for secrets bundles — backend-agnostic.
|
|
3
3
|
*
|
|
4
4
|
* Replaces the previous "leave it to iCloud Keychain" model with explicit
|
|
5
|
-
* push/pull
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* push/pull. Bundle contents (vars + secret values) are encrypted client-side
|
|
6
|
+
* with AES-256-GCM under a key derived from a user-supplied passphrase via
|
|
7
|
+
* PBKDF2-SHA256; only the resulting ciphertext envelope is handed to the
|
|
8
|
+
* transport. The transport itself is a pluggable `SyncBackend` (see
|
|
9
|
+
* `sync-backend.ts`) — the Rush driver (`drivers/rush.ts`, api.prix.dev) is the
|
|
10
|
+
* default for backwards compatibility, swappable via `setSyncBackend`. Plaintext
|
|
11
|
+
* never leaves this module; the backend only ever sees ciphertext + KDF params.
|
|
9
12
|
*/
|
|
10
13
|
import { type SecretsBundle } from './bundles.js';
|
|
14
|
+
import type { SyncBackend, RemoteBundleSummary, EncryptedEnvelope } from './sync-backend.js';
|
|
15
|
+
export type { EncryptedEnvelope, RemoteBundleSummary } from './sync-backend.js';
|
|
11
16
|
export declare const MIN_PASSPHRASE_LEN = 12;
|
|
12
|
-
/**
|
|
13
|
-
export
|
|
14
|
-
v: 1;
|
|
15
|
-
kdf: 'pbkdf2-sha256';
|
|
16
|
-
iter: number;
|
|
17
|
-
salt: string;
|
|
18
|
-
iv: string;
|
|
19
|
-
ct: string;
|
|
20
|
-
tag: string;
|
|
21
|
-
}
|
|
22
|
-
interface RemoteBundleSummary {
|
|
23
|
-
name: string;
|
|
24
|
-
updated_at: string;
|
|
25
|
-
}
|
|
17
|
+
/** Override the sync transport. Returns the previous backend (restore in tests). */
|
|
18
|
+
export declare function setSyncBackend(next: SyncBackend): SyncBackend;
|
|
26
19
|
/** Encrypt a JSON-serializable payload with a passphrase. */
|
|
27
20
|
export declare function encryptBlob(plaintext: string, passphrase: string): EncryptedEnvelope;
|
|
28
21
|
/** Decrypt an envelope. Throws on bad passphrase (auth tag mismatch). */
|
|
@@ -37,7 +30,7 @@ export interface BundleSnapshot {
|
|
|
37
30
|
export interface PushOptions {
|
|
38
31
|
passphrase: string;
|
|
39
32
|
}
|
|
40
|
-
/** Push a local bundle to
|
|
33
|
+
/** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
|
|
41
34
|
export declare function pushBundle(name: string, opts: PushOptions): Promise<{
|
|
42
35
|
updated_at: string;
|
|
43
36
|
}>;
|
|
@@ -47,10 +40,9 @@ export interface PullOptions {
|
|
|
47
40
|
/** When true, overwrite an existing local bundle. */
|
|
48
41
|
force?: boolean;
|
|
49
42
|
}
|
|
50
|
-
/** Pull a bundle by name from
|
|
43
|
+
/** Pull a bundle by name from the remote and materialize it locally. */
|
|
51
44
|
export declare function pullBundle(name: string, opts: PullOptions): Promise<SecretsBundle>;
|
|
52
45
|
/** Delete a bundle on the remote. */
|
|
53
46
|
export declare function deleteRemoteBundle(name: string): Promise<boolean>;
|
|
54
|
-
/** List bundles currently stored on
|
|
47
|
+
/** List bundles currently stored on the remote for this user. */
|
|
55
48
|
export declare function listRemoteBundles(): Promise<RemoteBundleSummary[]>;
|
|
56
|
-
export {};
|
package/dist/lib/secrets/sync.js
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote sync
|
|
2
|
+
* Remote sync for secrets bundles — backend-agnostic.
|
|
3
3
|
*
|
|
4
4
|
* Replaces the previous "leave it to iCloud Keychain" model with explicit
|
|
5
|
-
* push/pull
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* push/pull. Bundle contents (vars + secret values) are encrypted client-side
|
|
6
|
+
* with AES-256-GCM under a key derived from a user-supplied passphrase via
|
|
7
|
+
* PBKDF2-SHA256; only the resulting ciphertext envelope is handed to the
|
|
8
|
+
* transport. The transport itself is a pluggable `SyncBackend` (see
|
|
9
|
+
* `sync-backend.ts`) — the Rush driver (`drivers/rush.ts`, api.prix.dev) is the
|
|
10
|
+
* default for backwards compatibility, swappable via `setSyncBackend`. Plaintext
|
|
11
|
+
* never leaves this module; the backend only ever sees ciphertext + KDF params.
|
|
9
12
|
*/
|
|
10
13
|
import * as crypto from 'crypto';
|
|
11
|
-
import * as fs from 'fs';
|
|
12
|
-
import * as os from 'os';
|
|
13
|
-
import * as path from 'path';
|
|
14
|
-
import * as yaml from 'yaml';
|
|
15
14
|
import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
16
15
|
import { readBundle, writeBundle, keychainItemsForBundle, validateBundleName, } from './bundles.js';
|
|
17
|
-
|
|
18
|
-
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
19
|
-
const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
|
|
16
|
+
import { rushSyncBackend } from './drivers/rush.js';
|
|
20
17
|
// PBKDF2 cost. 600k SHA-256 iters matches OWASP 2023+ guidance and keeps a
|
|
21
18
|
// passphrase prompt under a second on the hardware the CLI targets.
|
|
22
19
|
const PBKDF2_ITER = 600_000;
|
|
@@ -24,29 +21,19 @@ export const MIN_PASSPHRASE_LEN = 12;
|
|
|
24
21
|
const KEY_LEN = 32;
|
|
25
22
|
const SALT_LEN = 16;
|
|
26
23
|
const IV_LEN = 12;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const token = readRushToken();
|
|
41
|
-
const url = endpoint.startsWith('http') ? endpoint : `${PROXY_BASE}${endpoint}`;
|
|
42
|
-
return fetch(url, {
|
|
43
|
-
method,
|
|
44
|
-
headers: {
|
|
45
|
-
Authorization: `Bearer ${token}`,
|
|
46
|
-
'Content-Type': 'application/json',
|
|
47
|
-
},
|
|
48
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
49
|
-
});
|
|
24
|
+
/**
|
|
25
|
+
* Active transport backend. Defaults to the Rush driver for backwards
|
|
26
|
+
* compatibility with bundles already pushed to api.prix.dev; `setSyncBackend`
|
|
27
|
+
* swaps it (a future Supabase driver, or an in-memory double in tests). The
|
|
28
|
+
* crypto + snapshot/restore below stay backend-agnostic — the backend only
|
|
29
|
+
* ever moves ciphertext envelopes, never plaintext.
|
|
30
|
+
*/
|
|
31
|
+
let backend = rushSyncBackend;
|
|
32
|
+
/** Override the sync transport. Returns the previous backend (restore in tests). */
|
|
33
|
+
export function setSyncBackend(next) {
|
|
34
|
+
const prev = backend;
|
|
35
|
+
backend = next;
|
|
36
|
+
return prev;
|
|
50
37
|
}
|
|
51
38
|
function deriveKey(passphrase, salt) {
|
|
52
39
|
return crypto.pbkdf2Sync(passphrase, salt, PBKDF2_ITER, KEY_LEN, 'sha256');
|
|
@@ -115,32 +102,23 @@ function restoreSnapshot(snap) {
|
|
|
115
102
|
}
|
|
116
103
|
writeBundle(bundle);
|
|
117
104
|
}
|
|
118
|
-
/** Push a local bundle to
|
|
105
|
+
/** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
|
|
119
106
|
export async function pushBundle(name, opts) {
|
|
120
107
|
validateBundleName(name);
|
|
121
108
|
const snap = snapshotBundle(name);
|
|
122
109
|
const envelope = encryptBlob(JSON.stringify(snap), opts.passphrase);
|
|
123
110
|
const updated_at = new Date().toISOString();
|
|
124
111
|
const payload = { envelope, updated_at };
|
|
125
|
-
|
|
126
|
-
if (!res.ok) {
|
|
127
|
-
const body = await res.text().catch(() => '');
|
|
128
|
-
throw new Error(`Push failed (${res.status} ${res.statusText}): ${body}`);
|
|
129
|
-
}
|
|
112
|
+
await backend.putEnvelope(name, payload);
|
|
130
113
|
return { updated_at };
|
|
131
114
|
}
|
|
132
|
-
/** Pull a bundle by name from
|
|
115
|
+
/** Pull a bundle by name from the remote and materialize it locally. */
|
|
133
116
|
export async function pullBundle(name, opts) {
|
|
134
117
|
validateBundleName(name);
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
throw new Error(`Remote bundle '${name}' not found
|
|
138
|
-
}
|
|
139
|
-
if (!res.ok) {
|
|
140
|
-
const body = await res.text().catch(() => '');
|
|
141
|
-
throw new Error(`Pull failed (${res.status} ${res.statusText}): ${body}`);
|
|
118
|
+
const data = await backend.getEnvelope(name);
|
|
119
|
+
if (!data) {
|
|
120
|
+
throw new Error(`Remote bundle '${name}' not found.`);
|
|
142
121
|
}
|
|
143
|
-
const data = await res.json();
|
|
144
122
|
const plaintext = decryptBlob(data.envelope, opts.passphrase);
|
|
145
123
|
const snap = JSON.parse(plaintext);
|
|
146
124
|
if (!snap || !snap.bundle || snap.bundle.name !== name) {
|
|
@@ -159,22 +137,9 @@ export async function pullBundle(name, opts) {
|
|
|
159
137
|
/** Delete a bundle on the remote. */
|
|
160
138
|
export async function deleteRemoteBundle(name) {
|
|
161
139
|
validateBundleName(name);
|
|
162
|
-
|
|
163
|
-
if (res.status === 404)
|
|
164
|
-
return false;
|
|
165
|
-
if (!res.ok) {
|
|
166
|
-
const body = await res.text().catch(() => '');
|
|
167
|
-
throw new Error(`Delete failed (${res.status} ${res.statusText}): ${body}`);
|
|
168
|
-
}
|
|
169
|
-
return true;
|
|
140
|
+
return backend.deleteEnvelope(name);
|
|
170
141
|
}
|
|
171
|
-
/** List bundles currently stored on
|
|
142
|
+
/** List bundles currently stored on the remote for this user. */
|
|
172
143
|
export async function listRemoteBundles() {
|
|
173
|
-
|
|
174
|
-
if (!res.ok) {
|
|
175
|
-
const body = await res.text().catch(() => '');
|
|
176
|
-
throw new Error(`List failed (${res.status} ${res.statusText}): ${body}`);
|
|
177
|
-
}
|
|
178
|
-
const data = await res.json();
|
|
179
|
-
return data.bundles ?? [];
|
|
144
|
+
return backend.listEnvelopes();
|
|
180
145
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbrella `agents sync` orchestration — "make this machine current".
|
|
3
|
+
*
|
|
4
|
+
* Bare `agents sync` fetches remote state (config repos + secrets + sessions)
|
|
5
|
+
* then reconciles it into every installed agent's version home. Each stage is
|
|
6
|
+
* an existing exported library function; this module only sequences them and
|
|
7
|
+
* decides — from the flags — which stages run (`planUmbrellaStages`). The
|
|
8
|
+
* planner is pure so the flag matrix is unit-tested without any I/O.
|
|
9
|
+
*
|
|
10
|
+
* Stage backends:
|
|
11
|
+
* repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
|
|
12
|
+
* secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
|
|
13
|
+
* cleanly when none is available — tokenized non-interactive auth
|
|
14
|
+
* arrives with `agents login`, #366/#367)
|
|
15
|
+
* sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
|
|
16
|
+
* reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
|
|
17
|
+
*/
|
|
18
|
+
/** The five umbrella flags off `agents sync`. */
|
|
19
|
+
export interface UmbrellaFlags {
|
|
20
|
+
repos?: boolean;
|
|
21
|
+
secrets?: boolean;
|
|
22
|
+
sessions?: boolean;
|
|
23
|
+
cloud?: boolean;
|
|
24
|
+
local?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Which stages a given flag combination runs. */
|
|
27
|
+
export interface UmbrellaPlan {
|
|
28
|
+
fetchRepos: boolean;
|
|
29
|
+
fetchSecrets: boolean;
|
|
30
|
+
fetchSessions: boolean;
|
|
31
|
+
reconcile: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Decide which stages run. Pure — no I/O. Semantics:
|
|
35
|
+
* bare (no flags) fetch all three, then reconcile
|
|
36
|
+
* --local reconcile only, no fetch
|
|
37
|
+
* --cloud fetch (all, or the selected subset), skip reconcile
|
|
38
|
+
* --repos/--secrets/... fetch only the selected types, then reconcile
|
|
39
|
+
* `--local` wins over everything; `--cloud` suppresses reconcile.
|
|
40
|
+
*/
|
|
41
|
+
export declare function planUmbrellaStages(f: UmbrellaFlags): UmbrellaPlan;
|
|
42
|
+
export interface UmbrellaResult {
|
|
43
|
+
plan: UmbrellaPlan;
|
|
44
|
+
repos?: {
|
|
45
|
+
pulled: number;
|
|
46
|
+
errors: string[];
|
|
47
|
+
};
|
|
48
|
+
secrets?: {
|
|
49
|
+
pulled: number;
|
|
50
|
+
skipped: boolean;
|
|
51
|
+
reason?: string;
|
|
52
|
+
errors: string[];
|
|
53
|
+
};
|
|
54
|
+
sessions?: {
|
|
55
|
+
ran: boolean;
|
|
56
|
+
pushed: number;
|
|
57
|
+
pulled: number;
|
|
58
|
+
merged: number;
|
|
59
|
+
};
|
|
60
|
+
reconciled: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface RunUmbrellaArgs {
|
|
63
|
+
flags: UmbrellaFlags;
|
|
64
|
+
/** Progress sink (already quiet-aware in the caller). */
|
|
65
|
+
log: (msg: string) => void;
|
|
66
|
+
/** Pass `skipPrompts` through to reconcile / non-interactive behavior. */
|
|
67
|
+
yes: boolean;
|
|
68
|
+
/** Secrets passphrase, if available (env var or prompt). Undefined => skip secrets. */
|
|
69
|
+
passphrase?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
|
|
73
|
+
* A failure in one fetch stage is recorded and does not abort the others or the
|
|
74
|
+
* reconcile — `agents sync` should make as much current as it can in one pass.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runUmbrellaSync(args: RunUmbrellaArgs): Promise<UmbrellaResult>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbrella `agents sync` orchestration — "make this machine current".
|
|
3
|
+
*
|
|
4
|
+
* Bare `agents sync` fetches remote state (config repos + secrets + sessions)
|
|
5
|
+
* then reconciles it into every installed agent's version home. Each stage is
|
|
6
|
+
* an existing exported library function; this module only sequences them and
|
|
7
|
+
* decides — from the flags — which stages run (`planUmbrellaStages`). The
|
|
8
|
+
* planner is pure so the flag matrix is unit-tested without any I/O.
|
|
9
|
+
*
|
|
10
|
+
* Stage backends:
|
|
11
|
+
* repos -> git pull of ~/.agents + enabled ~/.agents-* extras (pullRepo)
|
|
12
|
+
* secrets -> listRemoteBundles + pullBundle (needs a passphrase; skipped
|
|
13
|
+
* cleanly when none is available — tokenized non-interactive auth
|
|
14
|
+
* arrives with `agents login`, #366/#367)
|
|
15
|
+
* sessions -> syncSessions(), gated by isSyncConfigured() exactly like the daemon
|
|
16
|
+
* reconcile-> refresh({ skipPrompts }) — re-materialize resources into homes
|
|
17
|
+
*/
|
|
18
|
+
import { pullRepo } from './git.js';
|
|
19
|
+
import { getUserAgentsDir, getEnabledExtraRepos } from './state.js';
|
|
20
|
+
import { listRemoteBundles, pullBundle } from './secrets/sync.js';
|
|
21
|
+
/**
|
|
22
|
+
* Decide which stages run. Pure — no I/O. Semantics:
|
|
23
|
+
* bare (no flags) fetch all three, then reconcile
|
|
24
|
+
* --local reconcile only, no fetch
|
|
25
|
+
* --cloud fetch (all, or the selected subset), skip reconcile
|
|
26
|
+
* --repos/--secrets/... fetch only the selected types, then reconcile
|
|
27
|
+
* `--local` wins over everything; `--cloud` suppresses reconcile.
|
|
28
|
+
*/
|
|
29
|
+
export function planUmbrellaStages(f) {
|
|
30
|
+
if (f.local) {
|
|
31
|
+
return { fetchRepos: false, fetchSecrets: false, fetchSessions: false, reconcile: true };
|
|
32
|
+
}
|
|
33
|
+
const anySelector = !!(f.repos || f.secrets || f.sessions);
|
|
34
|
+
if (anySelector) {
|
|
35
|
+
return {
|
|
36
|
+
fetchRepos: !!f.repos,
|
|
37
|
+
fetchSecrets: !!f.secrets,
|
|
38
|
+
fetchSessions: !!f.sessions,
|
|
39
|
+
reconcile: !f.cloud,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// No per-type selector: bare = all + reconcile; --cloud = all, no reconcile.
|
|
43
|
+
return { fetchRepos: true, fetchSecrets: true, fetchSessions: true, reconcile: !f.cloud };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Execute the planned stages in order: repos -> secrets -> sessions -> reconcile.
|
|
47
|
+
* A failure in one fetch stage is recorded and does not abort the others or the
|
|
48
|
+
* reconcile — `agents sync` should make as much current as it can in one pass.
|
|
49
|
+
*/
|
|
50
|
+
export async function runUmbrellaSync(args) {
|
|
51
|
+
const { flags, log, yes, passphrase } = args;
|
|
52
|
+
const plan = planUmbrellaStages(flags);
|
|
53
|
+
const result = { plan, reconciled: false };
|
|
54
|
+
if (plan.fetchRepos) {
|
|
55
|
+
const dirs = [
|
|
56
|
+
{ alias: 'user', dir: getUserAgentsDir() },
|
|
57
|
+
...getEnabledExtraRepos().map((e) => ({ alias: e.alias, dir: e.dir })),
|
|
58
|
+
];
|
|
59
|
+
let pulled = 0;
|
|
60
|
+
const errors = [];
|
|
61
|
+
for (const { alias, dir } of dirs) {
|
|
62
|
+
const r = await pullRepo(dir);
|
|
63
|
+
if (r.success) {
|
|
64
|
+
pulled++;
|
|
65
|
+
log(`repos: ${alias} → ${r.commit}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
errors.push(`${alias}: ${r.error ?? 'unknown error'}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
result.repos = { pulled, errors };
|
|
72
|
+
}
|
|
73
|
+
if (plan.fetchSecrets) {
|
|
74
|
+
if (!passphrase) {
|
|
75
|
+
result.secrets = {
|
|
76
|
+
pulled: 0,
|
|
77
|
+
skipped: true,
|
|
78
|
+
reason: 'no passphrase — set AGENTS_SECRETS_PASSPHRASE or run `agents login` (#366)',
|
|
79
|
+
errors: [],
|
|
80
|
+
};
|
|
81
|
+
log('secrets: skipped (no passphrase available)');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
let pulled = 0;
|
|
85
|
+
const errors = [];
|
|
86
|
+
try {
|
|
87
|
+
const remote = await listRemoteBundles();
|
|
88
|
+
for (const b of remote) {
|
|
89
|
+
try {
|
|
90
|
+
await pullBundle(b.name, { passphrase, force: true });
|
|
91
|
+
pulled++;
|
|
92
|
+
log(`secrets: ${b.name}`);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
errors.push(`${b.name}: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
errors.push(err.message);
|
|
101
|
+
}
|
|
102
|
+
result.secrets = { pulled, skipped: false, errors };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (plan.fetchSessions) {
|
|
106
|
+
// Gate exactly like the daemon: a missing r2.backups bundle is a clean no-op,
|
|
107
|
+
// not an error that fails the whole sync.
|
|
108
|
+
const { isSyncConfigured } = await import('./session/sync/config.js');
|
|
109
|
+
if (isSyncConfigured()) {
|
|
110
|
+
const { syncSessions } = await import('./session/sync/sync.js');
|
|
111
|
+
const r = await syncSessions();
|
|
112
|
+
result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
|
|
113
|
+
log(`sessions: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
result.sessions = { ran: false, pushed: 0, pulled: 0, merged: 0 };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (plan.reconcile) {
|
|
120
|
+
const { refresh } = await import('./refresh.js');
|
|
121
|
+
await refresh({ skipPrompts: yes });
|
|
122
|
+
result.reconciled = true;
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.17",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|