@shardflux/cli 0.15.2 → 0.17.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/CHANGELOG.md +144 -14
- package/README.md +92 -7
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +15 -1
- package/dist/agent-commands.js +84 -23
- package/dist/agent-models.d.ts +61 -0
- package/dist/agent-models.js +79 -0
- package/dist/agent-state.d.ts +10 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +13 -3
- package/dist/agent-target-commands.js +129 -23
- package/dist/agent-targets.d.ts +5 -0
- package/dist/agent-targets.js +14 -5
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +5 -1
- package/dist/args.js +74 -11
- package/dist/cli-proxy-forward.d.ts +45 -0
- package/dist/cli-proxy-forward.js +432 -0
- package/dist/cli-proxy-hub.d.ts +273 -0
- package/dist/cli-proxy-hub.js +749 -0
- package/dist/cli-proxy.d.ts +325 -0
- package/dist/cli-proxy.js +1016 -0
- package/dist/codex-cloud.d.ts +3 -0
- package/dist/codex-cloud.js +17 -5
- package/dist/command-center-actions.js +25 -1
- package/dist/commands.d.ts +22 -0
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- package/dist/conversation-local.js +2 -2
- package/dist/conversation-stash.js +4 -3
- package/dist/conversation-terminal.d.ts +5 -0
- package/dist/conversation-terminal.js +54 -6
- package/dist/conversations.d.ts +31 -3
- package/dist/conversations.js +53 -10
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +73 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +14 -4
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -2
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +5 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/linux-helper.d.ts +12 -0
- package/dist/linux-helper.js +88 -0
- package/dist/linux-terminal.d.ts +11 -0
- package/dist/linux-terminal.js +42 -0
- package/dist/main.js +38 -8
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +53 -5
- package/dist/registry.d.ts +2 -42
- package/dist/registry.js +6 -90
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-mode.d.ts +121 -0
- package/dist/t3-mode.js +654 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-thread-script.d.ts +18 -0
- package/dist/t3-thread-script.js +245 -0
- package/dist/terminal-onboarding.d.ts +11 -0
- package/dist/terminal-onboarding.js +75 -0
- package/dist/thread-on-computer.d.ts +35 -0
- package/dist/thread-on-computer.js +206 -0
- package/dist/update.d.ts +1 -1
- package/dist/update.js +12 -1
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +125 -0
- package/dist/worktree-twin-engine.js +615 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +313 -0
- package/dist/worktree-twin.js +843 -0
- package/package.json +3 -3
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shard cli-proxy` (status), `shard cli-proxy import` and the hidden `shard cli-proxy forward`: one hosted
|
|
3
|
+
* CLIProxyAPI per account (cli-proxy-hub.ts), reached at http://127.0.0.1:8317 on this computer (a launchd agent runs
|
|
4
|
+
* the forwarder, cli-proxy-forward.ts) and in every VM (the hub info in the machine template; sf-agent forwards there).
|
|
5
|
+
*
|
|
6
|
+
* Import moves the user's CLIProxyAPI from this computer: the hub is made from their config, the local copy stops
|
|
7
|
+
* (only one copy may refresh a login), their auth files go to the hub (never printed), the forwarder takes 127.0.0.1:8317
|
|
8
|
+
* and the machine template carries the hub info. Onboarding calls `detectLocalCliProxy()` and `importCliProxy(ctx)`.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from 'node:child_process';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { connect } from 'node:net';
|
|
14
|
+
import { homedir, userInfo } from 'node:os';
|
|
15
|
+
import { basename, dirname, join } from 'node:path';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
17
|
+
import { parse as parseYaml } from 'yaml';
|
|
18
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
19
|
+
import { syncAgentConfig } from "./agent-config-sync.js";
|
|
20
|
+
import { CliProxyForwarder, IDENTITY_PATH, hubInfoSource } from "./cli-proxy-forward.js";
|
|
21
|
+
import { ADD_AUTH_SCRIPT, CLI_PROXY_PORT, CLI_PROXY_VM_PORT, HUB_AUTH_DIR, HUB_KEY, HubError, SET_ASIDE_SCRIPT, STOP_SCRIPT, ensureCliProxyHub, hubCallback, readHubInfo, sdkHubCloud, vmHubInfoText, writeHubInfo } from "./cli-proxy-hub.js";
|
|
22
|
+
import { CLI_VERSION } from "./args.js";
|
|
23
|
+
import { UsageError } from "./errors.js";
|
|
24
|
+
import { json } from "./format.js";
|
|
25
|
+
import { progress } from "./progress.js";
|
|
26
|
+
const execute = promisify(execFile);
|
|
27
|
+
export const runProgram = async (program, args) => {
|
|
28
|
+
try {
|
|
29
|
+
return (await execute(program, args, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 })).stdout;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The programs and probes detection, stop and the launchd install use by default. The CLI test support replaces them,
|
|
37
|
+
* so no test inspects, stops or replaces a CLIProxyAPI or forwarder running on the developer's computer.
|
|
38
|
+
*/
|
|
39
|
+
export const cliProxyTools = {
|
|
40
|
+
run: runProgram,
|
|
41
|
+
answers: (port) => portAnswers(port),
|
|
42
|
+
identity: (port) => forwarderIdentity(port),
|
|
43
|
+
inVm: () => inShardfluxVm(),
|
|
44
|
+
kill: (pid) => { process.kill(pid, 'SIGTERM'); },
|
|
45
|
+
};
|
|
46
|
+
/** The launchd agent that keeps the forwarder on 127.0.0.1:8317 on a Mac. */
|
|
47
|
+
export const FORWARD_LABEL = 'dev.shardflux.cli-proxy-forward';
|
|
48
|
+
/** Where every VM finds the hub info (the machine template carries it). */
|
|
49
|
+
// Outside ~/.config/shardflux, which machines and templates never capture (the platform's per-VM state).
|
|
50
|
+
export const VM_HUB_INFO_PATH = '/home/user/.config/shardflux-cli-proxy.json';
|
|
51
|
+
const expandHome = (path, home) => (path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path);
|
|
52
|
+
const isFile = (path) => { try {
|
|
53
|
+
return lstatSync(path).isFile();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
} };
|
|
58
|
+
const CLI_PROXY_EXE = /^(cli-proxy-api|cliproxyapi)$/i;
|
|
59
|
+
const FORWARDER_COMMAND = /\bcli-proxy\s+forward\b|\bsf-agent\s+cli-proxy-forward\b/;
|
|
60
|
+
/** The container that publishes `port` (docker ps), when Docker answers. */
|
|
61
|
+
async function findContainer(run, port) {
|
|
62
|
+
const out = await run('docker', ['ps', '--format', '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Ports}}']);
|
|
63
|
+
for (const line of (out ?? '').split('\n')) {
|
|
64
|
+
const [id, name, image, ports] = line.split('\t');
|
|
65
|
+
if (!id || !name || !image || !ports || !new RegExp(`:${port}->`).test(ports))
|
|
66
|
+
continue;
|
|
67
|
+
return { id, name, image, cliProxy: /cli-?proxy-?api/i.test(image) || /cli-?proxy-?api/i.test(name) };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** The listening process: pid, command line, executable, and what it is (lsof, ps, docker; null without them). */
|
|
72
|
+
async function findListener(run, port) {
|
|
73
|
+
const out = await run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp']);
|
|
74
|
+
const pid = Number(/^p(\d+)$/m.exec(out ?? '')?.[1]);
|
|
75
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
76
|
+
return null;
|
|
77
|
+
const command = (await run('ps', ['-o', 'args=', '-p', String(pid)]))?.trim() ?? '';
|
|
78
|
+
const executable = (await run('ps', ['-o', 'comm=', '-p', String(pid)]))?.trim() || command.split(/\s+/)[0] || '';
|
|
79
|
+
const forwarder = FORWARDER_COMMAND.test(command);
|
|
80
|
+
const cliProxy = !forwarder && (CLI_PROXY_EXE.test(basename(executable)) || CLI_PROXY_EXE.test(basename(command.split(/\s+/)[0] ?? '')));
|
|
81
|
+
const container = forwarder || cliProxy ? null : await findContainer(run, port);
|
|
82
|
+
return { pid, command, executable, forwarder, cliProxy, container };
|
|
83
|
+
}
|
|
84
|
+
/** The host folders a CLIProxyAPI container mounts as its auth directory and config (docker inspect). */
|
|
85
|
+
async function containerMounts(run, id) {
|
|
86
|
+
let mounts = [];
|
|
87
|
+
try {
|
|
88
|
+
mounts = JSON.parse((await run('docker', ['inspect', '-f', '{{json .Mounts}}', id])) ?? '[]');
|
|
89
|
+
}
|
|
90
|
+
catch { /* none */ }
|
|
91
|
+
const source = (test) => mounts.find((m) => typeof m.Destination === 'string' && typeof m.Source === 'string' && test(m.Destination))?.Source;
|
|
92
|
+
return { authDir: source((d) => /\/\.cli-proxy-api\/?$/.test(d)) ?? null, config: source((d) => /config\.ya?ml$/.test(d)) ?? null };
|
|
93
|
+
}
|
|
94
|
+
function configFromArgs(command) {
|
|
95
|
+
return /(?:^|\s)--?config(?:=|\s+)("[^"]+"|'[^']+'|\S+)/.exec(command)?.[1]?.replace(/^["']|["']$/g, '') ?? null;
|
|
96
|
+
}
|
|
97
|
+
const BREW_PREFIXES = ['/opt/homebrew', '/usr/local', '/home/linuxbrew/.linuxbrew'];
|
|
98
|
+
/** CLIProxyAPI's usual config locations: Homebrew's etc (cliproxyapi.conf, cliproxyapi*.yaml), then the home folders. */
|
|
99
|
+
function configCandidates(home, service) {
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const prefix of BREW_PREFIXES) {
|
|
102
|
+
if (service?.kind === 'brew')
|
|
103
|
+
out.push(join(prefix, 'etc', `${service.formula}.conf`));
|
|
104
|
+
out.push(join(prefix, 'etc', 'cliproxyapi.conf'));
|
|
105
|
+
try {
|
|
106
|
+
for (const name of readdirSync(join(prefix, 'etc')).sort())
|
|
107
|
+
if (/^cli-?proxy-?api.*\.(ya?ml|conf)$/i.test(name))
|
|
108
|
+
out.push(join(prefix, 'etc', name));
|
|
109
|
+
}
|
|
110
|
+
catch { /* no Homebrew there */ }
|
|
111
|
+
}
|
|
112
|
+
out.push(join(home, '.cli-proxy-api', 'config.yaml'), join(home, '.config', 'cli-proxy-api', 'config.yaml'), join(home, 'cli-proxy-api', 'config.yaml'), join(home, 'CLIProxyAPI', 'config.yaml'));
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** The Homebrew service or launchd agent whose program is CLIProxyAPI (macOS; never Shardflux's own forwarder). */
|
|
116
|
+
async function findService(run, home, listener) {
|
|
117
|
+
const dir = join(home, 'Library', 'LaunchAgents');
|
|
118
|
+
let names;
|
|
119
|
+
try {
|
|
120
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.plist') && n !== `${FORWARD_LABEL}.plist`);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const executable = listener?.command.split(/\s+/)[0] ?? '';
|
|
126
|
+
for (const name of names.sort()) {
|
|
127
|
+
const file = join(dir, name);
|
|
128
|
+
let raw;
|
|
129
|
+
try {
|
|
130
|
+
raw = readFileSync(file, 'latin1');
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!/cli-?proxy/i.test(raw))
|
|
136
|
+
continue;
|
|
137
|
+
const text = await run('plutil', ['-convert', 'json', '-o', '-', file]);
|
|
138
|
+
let plist;
|
|
139
|
+
try {
|
|
140
|
+
plist = JSON.parse(text ?? 'null') ?? {};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const argv = Array.isArray(plist.ProgramArguments) ? plist.ProgramArguments.map(String) : typeof plist.Program === 'string' ? [plist.Program] : [];
|
|
146
|
+
const program = argv[0] ?? '';
|
|
147
|
+
const matches = (executable && (program === executable || basename(program) === basename(executable))) || /^(cliproxyapi|cli-proxy-api)$/i.test(basename(program));
|
|
148
|
+
if (!matches || typeof plist.Label !== 'string')
|
|
149
|
+
continue;
|
|
150
|
+
const formula = /^homebrew\.mxcl\.(.+)$/.exec(plist.Label)?.[1];
|
|
151
|
+
return formula ? { kind: 'brew', formula, label: plist.Label } : { kind: 'launchd', label: plist.Label, plist: file };
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/** The auth files of a directory and its subdirectories (CLIProxyAPI walks them all), as paths relative to it. */
|
|
156
|
+
function authFiles(dir) {
|
|
157
|
+
const out = [];
|
|
158
|
+
const walk = (rel, depth) => {
|
|
159
|
+
let names;
|
|
160
|
+
try {
|
|
161
|
+
names = readdirSync(join(dir, rel));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
for (const name of names.sort()) {
|
|
167
|
+
const path = rel ? `${rel}/${name}` : name;
|
|
168
|
+
let st;
|
|
169
|
+
try {
|
|
170
|
+
st = lstatSync(join(dir, path));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (st.isDirectory() && depth < 4)
|
|
176
|
+
walk(path, depth + 1);
|
|
177
|
+
else if (st.isFile() && name.endsWith('.json') && st.size <= 1024 * 1024)
|
|
178
|
+
out.push(path);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
walk('', 0);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
function readLogins(authDir) {
|
|
185
|
+
return authFiles(authDir).map((file) => {
|
|
186
|
+
try {
|
|
187
|
+
const doc = JSON.parse(readFileSync(join(authDir, file), 'utf8'));
|
|
188
|
+
return { file, provider: typeof doc.type === 'string' ? doc.type : null, email: typeof doc.email === 'string' ? doc.email : null };
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return { file, provider: null, email: null };
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/** The listening port of a CLIProxyAPI config (either layout), or null when it names none. */
|
|
196
|
+
export function configPort(doc) {
|
|
197
|
+
const o = (doc && typeof doc === 'object' ? doc : {});
|
|
198
|
+
const server = (o.server && typeof o.server === 'object' ? o.server : {});
|
|
199
|
+
const port = Number(server.port ?? o.port);
|
|
200
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* CLIProxyAPI on this computer: its auth files (provider and email of each), its config and port, the process that
|
|
204
|
+
* listens there and the service that runs it. Null when there is none of these.
|
|
205
|
+
*/
|
|
206
|
+
export async function detectLocalCliProxy(opts = {}) {
|
|
207
|
+
const env = opts.env ?? process.env;
|
|
208
|
+
const home = opts.home ?? env.HOME ?? homedir();
|
|
209
|
+
const run = opts.run ?? cliProxyTools.run;
|
|
210
|
+
const onDefault = await findListener(run, CLI_PROXY_PORT);
|
|
211
|
+
let configPath = opts.configPath ? expandHome(opts.configPath, home) : null;
|
|
212
|
+
if (configPath && !isFile(configPath))
|
|
213
|
+
throw new UsageError(`No CLIProxyAPI config at ${configPath}`);
|
|
214
|
+
let mountedAuthDir = null;
|
|
215
|
+
if (onDefault?.container?.cliProxy) {
|
|
216
|
+
const mounts = await containerMounts(run, onDefault.container.id);
|
|
217
|
+
if (!configPath && mounts.config && isFile(mounts.config))
|
|
218
|
+
configPath = mounts.config;
|
|
219
|
+
mountedAuthDir = mounts.authDir;
|
|
220
|
+
}
|
|
221
|
+
if (!configPath && onDefault?.cliProxy) {
|
|
222
|
+
const fromArgs = configFromArgs(onDefault.command);
|
|
223
|
+
if (fromArgs) {
|
|
224
|
+
const cwd = /^n(.+)$/m.exec((await run('lsof', ['-a', '-p', String(onDefault.pid), '-d', 'cwd', '-Fn'])) ?? '')?.[1];
|
|
225
|
+
const path = fromArgs.startsWith('/') || !cwd ? expandHome(fromArgs, home) : join(cwd, fromArgs);
|
|
226
|
+
if (isFile(path))
|
|
227
|
+
configPath = path;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const darwin = (opts.platform ?? process.platform) === 'darwin';
|
|
231
|
+
let service = darwin ? await findService(run, home, onDefault) : null;
|
|
232
|
+
configPath ??= configCandidates(home, service).find(isFile) ?? null;
|
|
233
|
+
let authDir = join(home, '.cli-proxy-api');
|
|
234
|
+
let port = CLI_PROXY_PORT;
|
|
235
|
+
if (configPath) {
|
|
236
|
+
try {
|
|
237
|
+
const doc = parseYaml(readFileSync(configPath, 'utf8'));
|
|
238
|
+
const dir = doc?.['auth-dir'] ?? doc?.oauth?.['auth-dir'];
|
|
239
|
+
if (typeof dir === 'string' && dir.trim())
|
|
240
|
+
authDir = expandHome(dir.trim(), home);
|
|
241
|
+
port = configPort(doc) ?? CLI_PROXY_PORT;
|
|
242
|
+
}
|
|
243
|
+
catch { /* the import reports an unreadable config */ }
|
|
244
|
+
}
|
|
245
|
+
// A CLIProxyAPI container reads the folder it mounts, whatever its config says inside the container.
|
|
246
|
+
if (mountedAuthDir && existsSync(mountedAuthDir))
|
|
247
|
+
authDir = mountedAuthDir;
|
|
248
|
+
const listener = port === CLI_PROXY_PORT ? onDefault : await findListener(run, port);
|
|
249
|
+
if (port !== CLI_PROXY_PORT && darwin)
|
|
250
|
+
service = await findService(run, home, listener) ?? service;
|
|
251
|
+
const fallback = join(home, '.cli-proxy-api');
|
|
252
|
+
const authDirs = [authDir, ...(fallback !== authDir && authFiles(fallback).length ? [fallback] : [])];
|
|
253
|
+
const logins = readLogins(authDir);
|
|
254
|
+
if (logins.length === 0 && !configPath && !listener?.cliProxy && !listener?.container?.cliProxy)
|
|
255
|
+
return null;
|
|
256
|
+
return { authDir, authDirs, logins, configPath, port, listener, service };
|
|
257
|
+
}
|
|
258
|
+
/** The fields of an auth file that say which account, workspace or project it signs in to (CLIProxyAPI 8.0.23). */
|
|
259
|
+
const IDENTITY_FIELDS = ['email', 'user_email', 'account_id', 'chatgpt_account_id', 'account_uuid', 'account.id', 'organization_uuid', 'organization_id', 'org_id', 'workspace_id', 'team_id', 'user_id', 'project_id'];
|
|
260
|
+
/**
|
|
261
|
+
* Who an auth file signs in as: its provider (`type`) and every identifying field it has (email, account, organization,
|
|
262
|
+
* workspace, user and project ids), from its JSON. Two files are the same login only when they have the same provider
|
|
263
|
+
* and the same fields with the same values: one email can sign in to several accounts or workspaces, so a file that
|
|
264
|
+
* names a different (or one more) account is another login. A file without any is only itself (its path).
|
|
265
|
+
*/
|
|
266
|
+
export function loginIdentity(text, file) {
|
|
267
|
+
let doc = {};
|
|
268
|
+
try {
|
|
269
|
+
const v = JSON.parse(text);
|
|
270
|
+
if (v && typeof v === 'object')
|
|
271
|
+
doc = v;
|
|
272
|
+
}
|
|
273
|
+
catch { /* not JSON */ }
|
|
274
|
+
const s = (v) => (typeof v === 'string' && v.trim() ? v.trim().toLowerCase() : typeof v === 'number' && Number.isFinite(v) ? String(v) : null);
|
|
275
|
+
const account = doc.account && typeof doc.account === 'object' ? doc.account : {};
|
|
276
|
+
const fields = IDENTITY_FIELDS.flatMap((f) => {
|
|
277
|
+
const v = s(f === 'account.id' ? account.id : doc[f]);
|
|
278
|
+
return v ? [`${f}=${v}`] : [];
|
|
279
|
+
});
|
|
280
|
+
const type = s(doc.type) ?? s(doc.provider);
|
|
281
|
+
return type && fields.length ? JSON.stringify([type, ...fields]) : `file:${file}`;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* After the hub has the logins: moves every auth file out of each directory a local CLIProxyAPI reads, into a sibling
|
|
285
|
+
* `<dir>.moved-to-shardflux` (0700; files keep their 0600), so a local copy started again has no tokens to refresh.
|
|
286
|
+
* Other files (a config.yaml, logs) stay where they are.
|
|
287
|
+
*/
|
|
288
|
+
export function moveAuthFilesAside(dirs, now = new Date()) {
|
|
289
|
+
const moved = [];
|
|
290
|
+
for (const dir of dirs) {
|
|
291
|
+
const files = authFiles(dir);
|
|
292
|
+
if (!files.length)
|
|
293
|
+
continue;
|
|
294
|
+
let to = `${dir}.moved-to-shardflux`;
|
|
295
|
+
if (existsSync(to))
|
|
296
|
+
to = `${to}-${now.toISOString().replace(/[-:]/g, '').slice(0, 15)}`;
|
|
297
|
+
mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
298
|
+
chmodSync(to, 0o700);
|
|
299
|
+
for (const file of files) {
|
|
300
|
+
const target = join(to, file);
|
|
301
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
302
|
+
renameSync(join(dir, file), target);
|
|
303
|
+
}
|
|
304
|
+
moved.push({ from: dir, to, files: files.length });
|
|
305
|
+
}
|
|
306
|
+
return moved;
|
|
307
|
+
}
|
|
308
|
+
// ---- stopping it ---------------------------------------------------------------------------------------------------
|
|
309
|
+
const shellWord = (s) => (/^[\w./:@%+=-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`);
|
|
310
|
+
const shellLine = (argv) => argv.map(shellWord).join(' ');
|
|
311
|
+
/** Whether something accepts connections on 127.0.0.1:port (also a listener on all interfaces). */
|
|
312
|
+
export function portAnswers(port, host = '127.0.0.1') {
|
|
313
|
+
return new Promise((resolve) => {
|
|
314
|
+
const socket = connect({ port, host });
|
|
315
|
+
const done = (v) => { socket.destroy(); resolve(v); };
|
|
316
|
+
socket.setTimeout(1000, () => done(false));
|
|
317
|
+
socket.once('connect', () => done(true));
|
|
318
|
+
socket.once('error', () => done(false));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A Homebrew service: `brew services stop` (it also leaves login). Another launchd agent: bootout, disable, and its plist
|
|
323
|
+
* moved aside, so it cannot come back at login. A plain process: ending it.
|
|
324
|
+
*/
|
|
325
|
+
export function stopPlan(local, uid = userInfo().uid) {
|
|
326
|
+
const s = local.service;
|
|
327
|
+
if (s?.kind === 'brew')
|
|
328
|
+
return { line: shellLine(['brew', 'services', 'stop', s.formula]), steps: [{ run: ['brew', 'services', 'stop', s.formula] }] };
|
|
329
|
+
if (s?.kind === 'launchd') {
|
|
330
|
+
const target = `gui/${uid}/${s.label}`;
|
|
331
|
+
const aside = `${s.plist}.disabled-by-shardflux`;
|
|
332
|
+
const steps = [{ run: ['launchctl', 'bootout', target] }, { run: ['launchctl', 'disable', target] }, { move: [s.plist, aside] }];
|
|
333
|
+
return { line: `${shellLine(['launchctl', 'bootout', target])} && ${shellLine(['launchctl', 'disable', target])} && ${shellLine(['mv', s.plist, aside])}`, steps };
|
|
334
|
+
}
|
|
335
|
+
const l = local.listener;
|
|
336
|
+
// Only what is verified to be CLIProxyAPI is ever offered: a container by its image or name, a process by its executable.
|
|
337
|
+
if (l?.container?.cliProxy)
|
|
338
|
+
return { line: shellLine(['docker', 'stop', l.container.name]), steps: [{ run: ['docker', 'stop', l.container.name] }] };
|
|
339
|
+
if (l?.cliProxy)
|
|
340
|
+
return { line: shellLine(['kill', String(l.pid)]), steps: [{ kill: l.pid }] };
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
/** Why nothing is offered for what listens on the port, and what the user can do. */
|
|
344
|
+
function unverified(local) {
|
|
345
|
+
const l = local.listener;
|
|
346
|
+
const port = local.port;
|
|
347
|
+
if (l?.container) {
|
|
348
|
+
return new UsageError(`Container ${l.container.name} (${l.container.image}) publishes 127.0.0.1:${port} and is not recognizably CLIProxyAPI, so it was left running.`, `if it is your CLIProxyAPI, run: ${shellLine(['docker', 'stop', l.container.name])} && ${AGAIN}`);
|
|
349
|
+
}
|
|
350
|
+
if (l)
|
|
351
|
+
return new UsageError(`${basename(l.executable) || 'A program'} (pid ${l.pid}) listens on 127.0.0.1:${port} and is not CLIProxyAPI, so it was left running.`, `stop it yourself if it is your CLIProxyAPI, then run: ${AGAIN}`);
|
|
352
|
+
return new UsageError(`Another program answers on 127.0.0.1:${port}.`, `stop the CLIProxyAPI that runs on this computer, then run: ${AGAIN}`);
|
|
353
|
+
}
|
|
354
|
+
const AGAIN = 'shard cli-proxy import';
|
|
355
|
+
/**
|
|
356
|
+
* Stops the local CLIProxyAPI on its own port so only the hosted copy refreshes the logins. A Homebrew service is
|
|
357
|
+
* stopped with `brew services stop` (said first); a process or another launchd agent only with the user's consent
|
|
358
|
+
* ([Y/n] on a terminal, defaulting to yes; --yes otherwise): declined, the exact command is shown and the port is
|
|
359
|
+
* watched until it is free. Refuses when something starts it again.
|
|
360
|
+
*/
|
|
361
|
+
export async function stopLocalCliProxy(ctx, local, values = {}, deps = {}) {
|
|
362
|
+
const port = local.port;
|
|
363
|
+
const answers = deps.answers ?? cliProxyTools.answers;
|
|
364
|
+
const ours = deps.ours ?? (async () => (await cliProxyTools.identity(port)) !== null);
|
|
365
|
+
const run = deps.run ?? cliProxyTools.run;
|
|
366
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
367
|
+
const plan = stopPlan(local, deps.uid);
|
|
368
|
+
if (local.listener?.forwarder || !(await answers(port)) || (await ours()))
|
|
369
|
+
return { result: 'none', plan: null };
|
|
370
|
+
const l = local.listener;
|
|
371
|
+
if (!plan || (l && !l.cliProxy && !l.container?.cliProxy))
|
|
372
|
+
throw unverified(local);
|
|
373
|
+
const who = local.listener ? `pid ${local.listener.pid}` : local.service?.label ?? 'a local process';
|
|
374
|
+
const execute = async () => {
|
|
375
|
+
for (const step of plan.steps) {
|
|
376
|
+
if ('kill' in step)
|
|
377
|
+
(deps.kill ?? cliProxyTools.kill)(step.kill);
|
|
378
|
+
else if ('move' in step)
|
|
379
|
+
renameSync(step.move[0], step.move[1]);
|
|
380
|
+
else if ((await run(step.run[0], step.run.slice(1))) === null)
|
|
381
|
+
throw new UsageError(`Could not stop it (${shellLine(step.run)} failed).`, `run: ${plan.line} && ${AGAIN}`);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
let stoppedByUs = false;
|
|
385
|
+
if (local.service?.kind === 'brew') {
|
|
386
|
+
progress(ctx, `Stopping your local CLIProxyAPI (${plan.line}) so only the hosted copy refreshes your logins…`);
|
|
387
|
+
await execute();
|
|
388
|
+
stoppedByUs = true;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
if (values.yes !== true && !ctx.input.interactive) {
|
|
392
|
+
throw new UsageError(`CLIProxyAPI still runs on this computer (${who}).`, `run: ${plan.line} && ${AGAIN} (or pass --yes)`);
|
|
393
|
+
}
|
|
394
|
+
let yes = values.yes === true;
|
|
395
|
+
if (!yes) {
|
|
396
|
+
const answer = (await ctx.input.answer(`CLIProxyAPI runs on this computer (${who}); only one copy may refresh your logins. Stop it now (${plan.line})? [Y/n] `))?.trim() ?? '';
|
|
397
|
+
yes = answer === '' || /^y(es)?$/i.test(answer);
|
|
398
|
+
}
|
|
399
|
+
if (yes) {
|
|
400
|
+
await execute();
|
|
401
|
+
stoppedByUs = true;
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
progress(ctx, `Stop it yourself with: ${plan.line}\nWaiting for 127.0.0.1:${port} to be free (Ctrl-C to cancel)…`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const deadline = Date.now() + (stoppedByUs ? 15_000 : (deps.waitMs ?? 300_000));
|
|
408
|
+
while (await answers(port)) {
|
|
409
|
+
ctx.signal.throwIfAborted();
|
|
410
|
+
if (Date.now() > deadline)
|
|
411
|
+
throw new UsageError(`CLIProxyAPI still answers on 127.0.0.1:${port}.`, `run: ${plan.line} && ${AGAIN}`);
|
|
412
|
+
await sleep(250);
|
|
413
|
+
}
|
|
414
|
+
// Something that starts it again (an app, a KeepAlive agent) would bring back a second copy.
|
|
415
|
+
await sleep(1500);
|
|
416
|
+
if (await answers(port)) {
|
|
417
|
+
throw new UsageError(`CLIProxyAPI started again on 127.0.0.1:${port}: an app or agent keeps it running.`, `quit that app or agent, then run: ${AGAIN}`);
|
|
418
|
+
}
|
|
419
|
+
return { result: 'stopped', plan };
|
|
420
|
+
}
|
|
421
|
+
/** How the user starts the local CLIProxyAPI again (`shard cli-proxy export` names it). */
|
|
422
|
+
export function startLine(local, uid = userInfo().uid) {
|
|
423
|
+
const s = local.service;
|
|
424
|
+
if (s?.kind === 'brew')
|
|
425
|
+
return shellLine(['brew', 'services', 'start', s.formula]);
|
|
426
|
+
if (s?.kind === 'launchd') {
|
|
427
|
+
const target = `gui/${uid}/${s.label}`;
|
|
428
|
+
return `${shellLine(['mv', `${s.plist}.disabled-by-shardflux`, s.plist])} && ${shellLine(['launchctl', 'enable', target])} && ${shellLine(['launchctl', 'bootstrap', `gui/${uid}`, s.plist])}`;
|
|
429
|
+
}
|
|
430
|
+
const c = local.listener?.container;
|
|
431
|
+
if (c?.cliProxy)
|
|
432
|
+
return shellLine(['docker', 'start', c.name]);
|
|
433
|
+
if (local.listener?.cliProxy && local.listener.command)
|
|
434
|
+
return local.listener.command;
|
|
435
|
+
return local.configPath ? shellLine(['cli-proxy-api', '-config', local.configPath]) : 'cli-proxy-api';
|
|
436
|
+
}
|
|
437
|
+
// ---- the Mac forwarder (launchd) -----------------------------------------------------------------------------------
|
|
438
|
+
const xml = (s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
439
|
+
/** The launchd agent: `shard cli-proxy forward` at login, started again whenever it exits. */
|
|
440
|
+
export function forwarderPlist(o) {
|
|
441
|
+
const env = { SHARDFLUX_CONFIG_DIR: o.configDir, SHARDFLUX_API_URL: o.apiUrl, SHARDFLUX_NO_UPDATE_CHECK: '1', ...(o.path ? { PATH: o.path } : {}) };
|
|
442
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
443
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
444
|
+
<plist version="1.0">
|
|
445
|
+
<dict>
|
|
446
|
+
<key>Label</key>
|
|
447
|
+
<string>${FORWARD_LABEL}</string>
|
|
448
|
+
<key>ProgramArguments</key>
|
|
449
|
+
<array>
|
|
450
|
+
${[o.node, o.script, 'cli-proxy', 'forward'].map((a) => ` <string>${xml(a)}</string>`).join('\n')}
|
|
451
|
+
</array>
|
|
452
|
+
<key>EnvironmentVariables</key>
|
|
453
|
+
<dict>
|
|
454
|
+
${Object.entries(env).map(([k, v]) => ` <key>${k}</key>\n <string>${xml(v)}</string>`).join('\n')}
|
|
455
|
+
</dict>
|
|
456
|
+
<key>RunAtLoad</key>
|
|
457
|
+
<true/>
|
|
458
|
+
<key>KeepAlive</key>
|
|
459
|
+
<true/>
|
|
460
|
+
<key>ProcessType</key>
|
|
461
|
+
<string>Interactive</string>
|
|
462
|
+
<key>StandardOutPath</key>
|
|
463
|
+
<string>${xml(o.logFile)}</string>
|
|
464
|
+
<key>StandardErrorPath</key>
|
|
465
|
+
<string>${xml(o.logFile)}</string>
|
|
466
|
+
</dict>
|
|
467
|
+
</plist>
|
|
468
|
+
`;
|
|
469
|
+
}
|
|
470
|
+
export function forwarderPlistPath(home) {
|
|
471
|
+
return join(home, 'Library', 'LaunchAgents', `${FORWARD_LABEL}.plist`);
|
|
472
|
+
}
|
|
473
|
+
/** Whether Shardflux's forwarder (this computer's, or a VM's sf-agent) answers on 127.0.0.1:port, and for which hub. */
|
|
474
|
+
export async function forwarderIdentity(port = CLI_PROXY_PORT) {
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch(`http://127.0.0.1:${port}${IDENTITY_PATH}`, { signal: AbortSignal.timeout(2000) });
|
|
477
|
+
const body = (await res.json());
|
|
478
|
+
if (body.forwarder !== 'shardflux')
|
|
479
|
+
return null;
|
|
480
|
+
return { hub: typeof body.hub === 'string' ? body.hub : null, kind: body.kind === 'vm' ? 'vm' : 'mac', version: typeof body.version === 'string' ? body.version : null };
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** A Shardflux VM (its guest agent is installed): the hub is set up from the computer where CLIProxyAPI runs. */
|
|
487
|
+
export function inShardfluxVm() {
|
|
488
|
+
return existsSync('/usr/local/sbin/sf-guest-agent') || existsSync('/run/sf-guest-agent');
|
|
489
|
+
}
|
|
490
|
+
/** The forwarder that answers is this shard's (an update replaces the program, not the running process). */
|
|
491
|
+
const current = (id) => id !== null && id.kind === 'mac' && id.version === CLI_VERSION;
|
|
492
|
+
/** The shard version the launchd forwarder runs (refreshForwarderAgent compares it with this one). */
|
|
493
|
+
function recordForwarder(dir) {
|
|
494
|
+
new AgentStateStore(dir).update((doc) => { doc.cli_proxy_forwarder = { version: CLI_VERSION }; });
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* After `shard` is updated, the launchd forwarder still runs the program it started with. Called on every shard run
|
|
498
|
+
* (one file read on macOS): an installed forwarder agent recorded for another shard version is started again, on this
|
|
499
|
+
* one. Returns whether it was.
|
|
500
|
+
*/
|
|
501
|
+
export async function refreshForwarderAgent(env, dir, deps = {}) {
|
|
502
|
+
if ((deps.platform ?? process.platform) !== 'darwin')
|
|
503
|
+
return false;
|
|
504
|
+
if (!existsSync(forwarderPlistPath(env.HOME || homedir())))
|
|
505
|
+
return false;
|
|
506
|
+
const store = new AgentStateStore(dir);
|
|
507
|
+
const record = store.read().cli_proxy_forwarder;
|
|
508
|
+
if (record?.version === CLI_VERSION)
|
|
509
|
+
return false;
|
|
510
|
+
// Recorded first: a failed restart is not retried on every command (the next import installs the agent again).
|
|
511
|
+
recordForwarder(dir);
|
|
512
|
+
await (deps.run ?? cliProxyTools.run)('launchctl', ['kickstart', '-k', `gui/${deps.uid ?? userInfo().uid}/${FORWARD_LABEL}`]);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
/** Installs (or refreshes) the launchd agent and waits until the forwarder answers. macOS only. */
|
|
516
|
+
export async function installForwarderAgent(ctx, apiUrl, deps = {}) {
|
|
517
|
+
const runtime = ctx.runtime;
|
|
518
|
+
if (!runtime)
|
|
519
|
+
throw new UsageError('The forwarder needs an installed shard.', `run: npm i -g shardflux && ${AGAIN}`);
|
|
520
|
+
let script = runtime.script;
|
|
521
|
+
try {
|
|
522
|
+
script = realpathSync(script);
|
|
523
|
+
}
|
|
524
|
+
catch { /* keep the path */ }
|
|
525
|
+
if (/[\\/]_npx[\\/]/.test(script))
|
|
526
|
+
throw new UsageError('The forwarder needs an installed shard (npx removes its copy).', `run: npm i -g shardflux && ${AGAIN}`);
|
|
527
|
+
const home = ctx.io.env.HOME || homedir();
|
|
528
|
+
const file = forwarderPlistPath(home);
|
|
529
|
+
const text = forwarderPlist({ node: runtime.node, script, configDir: ctx.store.dir, apiUrl, logFile: join(ctx.store.dir, 'cli-proxy-forward.log'), ...(ctx.io.env.PATH ? { path: ctx.io.env.PATH } : {}) });
|
|
530
|
+
const run = deps.run ?? cliProxyTools.run;
|
|
531
|
+
const domain = `gui/${deps.uid ?? userInfo().uid}`;
|
|
532
|
+
let installed = null;
|
|
533
|
+
try {
|
|
534
|
+
installed = readFileSync(file, 'utf8');
|
|
535
|
+
}
|
|
536
|
+
catch { /* first install */ }
|
|
537
|
+
const identity = deps.identity ?? (() => cliProxyTools.identity());
|
|
538
|
+
if (installed === text && current(await identity())) {
|
|
539
|
+
recordForwarder(ctx.store.dir);
|
|
540
|
+
return file;
|
|
541
|
+
}
|
|
542
|
+
mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true });
|
|
543
|
+
writeFileSync(file, text, { mode: 0o644 });
|
|
544
|
+
await run('launchctl', ['bootout', `${domain}/${FORWARD_LABEL}`]);
|
|
545
|
+
if ((await run('launchctl', ['bootstrap', domain, file])) === null) {
|
|
546
|
+
throw new UsageError('Could not start the forwarder.', `run: launchctl bootstrap ${domain} ${shellWord(file)}`);
|
|
547
|
+
}
|
|
548
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
549
|
+
for (let i = 0; i < 40; i++) {
|
|
550
|
+
if (current(await identity())) {
|
|
551
|
+
recordForwarder(ctx.store.dir);
|
|
552
|
+
return file;
|
|
553
|
+
}
|
|
554
|
+
await sleep(250);
|
|
555
|
+
}
|
|
556
|
+
throw new UsageError('The forwarder did not answer on 127.0.0.1:8317.', `see: tail ${shellWord(join(ctx.store.dir, 'cli-proxy-forward.log'))}`);
|
|
557
|
+
}
|
|
558
|
+
// ---- the machine template ------------------------------------------------------------------------------------------
|
|
559
|
+
const digest = (text) => createHash('sha256').update(text).digest('hex');
|
|
560
|
+
/**
|
|
561
|
+
* Puts the hub info into the machine template (opening the machine VM like `shard machine`, then saving it), so every
|
|
562
|
+
* new VM's forwarder reaches the hub. Skipped when the saved machine already carries this hub info.
|
|
563
|
+
*/
|
|
564
|
+
export async function saveHubInfoToMachine(ctx, info, opts = {}) {
|
|
565
|
+
// VMs get what their forwarder needs (the callback URL), never the management key. `current` reads the hub's
|
|
566
|
+
// stored callback again (under its lock), so a URL another device replaced is never what VMs receive.
|
|
567
|
+
const latest = opts.current ?? (() => Promise.resolve(info));
|
|
568
|
+
const vmText = async () => {
|
|
569
|
+
const t = vmHubInfoText(await latest());
|
|
570
|
+
if (!t)
|
|
571
|
+
throw new HubError('The hub’s VM address comes with its next update.', `then connect your VMs: ${AGAIN}`);
|
|
572
|
+
return t;
|
|
573
|
+
};
|
|
574
|
+
let text = await vmText();
|
|
575
|
+
const store = new AgentStateStore(ctx.store.dir);
|
|
576
|
+
const record = store.read().cli_proxy_machine;
|
|
577
|
+
const machine = opts.machine ?? sdkMachine(ctx);
|
|
578
|
+
const current = await machine.version();
|
|
579
|
+
// The record names the file too: a machine saved with the earlier path gets the hub info where VMs read it now.
|
|
580
|
+
if (record?.hash === digest(VM_HUB_INFO_PATH + '\n' + text) && record.api_url === ctx.apiUrl && typeof record.version === 'number' && current >= record.version)
|
|
581
|
+
return { version: record.version, saved: false };
|
|
582
|
+
progress(ctx, 'Adding the hub to your machine, so every new VM reaches it…');
|
|
583
|
+
const vm = await machine.open();
|
|
584
|
+
// Opening the machine VM takes a while: what is written is the callback that is valid now.
|
|
585
|
+
text = await vmText();
|
|
586
|
+
const hash = digest(VM_HUB_INFO_PATH + '\n' + text);
|
|
587
|
+
await vm.write(VM_HUB_INFO_PATH, text);
|
|
588
|
+
const version = await vm.save();
|
|
589
|
+
store.update((doc) => { doc.cli_proxy_machine = { hash, version, api_url: ctx.apiUrl }; });
|
|
590
|
+
return { version, saved: true };
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The hub's home: the organization's first project (oldest), listed with the user session. Null when signed out, so a
|
|
594
|
+
* new hub is never created by guesswork in another project.
|
|
595
|
+
*/
|
|
596
|
+
export async function homeProject(ctx, organizationId) {
|
|
597
|
+
if (ctx.sessionSource() === null)
|
|
598
|
+
return null;
|
|
599
|
+
const all = [];
|
|
600
|
+
for await (const p of ctx.account().projects.listAll(organizationId))
|
|
601
|
+
all.push(p);
|
|
602
|
+
all.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
603
|
+
return all[0] ? { id: all[0].id, slug: all[0].slug } : null;
|
|
604
|
+
}
|
|
605
|
+
export function sdkMachine(ctx) {
|
|
606
|
+
const cloud = ctx.cloud();
|
|
607
|
+
return {
|
|
608
|
+
version: async () => (await cloud.machine.get({ signal: ctx.signal })).version,
|
|
609
|
+
async open() {
|
|
610
|
+
const state = await cloud.machine.get({ signal: ctx.signal });
|
|
611
|
+
const key = `machine/${state.organization_id}/v${state.version}`;
|
|
612
|
+
const existing = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
|
|
613
|
+
const ws = await cloud.workspaces.open({
|
|
614
|
+
key, template: state.template ? `${state.template.slug}@${state.template.version}` : 'coding',
|
|
615
|
+
labels: { ...existing?.labels, shard_layer: 'machine', shard_machine: state.organization_id, repo_path: '/home/user', app: 'claude', agent: 'machine' },
|
|
616
|
+
wait: { timeoutMs: Math.max(ctx.wakeTimeoutMs, 180_000), signal: ctx.signal },
|
|
617
|
+
});
|
|
618
|
+
if (state.version === 0)
|
|
619
|
+
await syncAgentConfig(ctx, ws);
|
|
620
|
+
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
621
|
+
return {
|
|
622
|
+
write: async (path, text) => { await cell.files.write(path, text, { mode: '0600', createParents: true }); },
|
|
623
|
+
save: async () => { const saved = await cloud.machine.save({ workspaceId: ws.id }, { signal: ctx.signal }); return saved.pending?.version ?? saved.version; },
|
|
624
|
+
};
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/** The hub's logins from its management API (wakes the hub). */
|
|
629
|
+
export async function hubAccounts(info, timeoutMs = 45_000) {
|
|
630
|
+
const res = await fetch(new URL('v0/management/auth-files', info.callback_url), {
|
|
631
|
+
headers: { authorization: `Bearer ${info.management_key}`, accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs),
|
|
632
|
+
});
|
|
633
|
+
if (!res.ok) {
|
|
634
|
+
const reason = res.headers.get('x-shardflux-reason');
|
|
635
|
+
throw new HubError(`The hub did not list its accounts (HTTP ${res.status}${reason ? `, ${reason}` : ''})`);
|
|
636
|
+
}
|
|
637
|
+
const body = (await res.json());
|
|
638
|
+
return (body.files ?? []).map((f) => ({
|
|
639
|
+
provider: [f.provider, f.type].find((v) => typeof v === 'string' && v !== '') ?? 'unknown',
|
|
640
|
+
email: typeof f.email === 'string' && f.email ? f.email : null,
|
|
641
|
+
status: typeof f.status === 'string' && f.status ? f.status : null,
|
|
642
|
+
disabled: f.disabled === true,
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
/** Whether the hub answers /healthz through its callback URL, and the edge's reason when it does not. */
|
|
646
|
+
export async function hubHealth(info, timeoutMs = 45_000) {
|
|
647
|
+
try {
|
|
648
|
+
const res = await fetch(new URL('healthz', info.callback_url), { signal: AbortSignal.timeout(timeoutMs) });
|
|
649
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
650
|
+
return { ok: res.ok, reason: res.headers.get('x-shardflux-reason') ?? (res.ok ? null : `HTTP ${res.status}`) };
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
return { ok: false, reason: err.name === 'TimeoutError' ? 'timeout' : 'unreachable' };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const accountLine = (a) => ` ${a.provider.padEnd(8)} ${a.email ?? '(no email)'}${a.disabled ? ' disabled' : a.status && a.status !== 'active' ? ` ${a.status}` : ''}`;
|
|
657
|
+
/**
|
|
658
|
+
* Moves CLIProxyAPI from this computer into the account (or connects this computer to the account's hub): ensure the
|
|
659
|
+
* hub from the user's config, stop the local copy, upload the auth files the hub does not have yet (never overwriting
|
|
660
|
+
* one: the hub's copy may hold a newer refresh token), start the forwarder on 127.0.0.1:8317 and put the hub info into
|
|
661
|
+
* the machine template.
|
|
662
|
+
*/
|
|
663
|
+
export async function importCliProxy(ctx, opts = {}) {
|
|
664
|
+
if (cliProxyTools.inVm()) {
|
|
665
|
+
throw new UsageError('shard cli-proxy import runs on the computer where CLIProxyAPI runs; this is a Shardflux VM, which reaches the hub at http://127.0.0.1:8317 already.', 'run it on the computer where cli-proxy runs: shard cli-proxy import');
|
|
666
|
+
}
|
|
667
|
+
const values = opts.values ?? {};
|
|
668
|
+
const platform = opts.platform ?? process.platform;
|
|
669
|
+
const local = opts.local !== undefined ? opts.local : await detectLocalCliProxy({ env: ctx.io.env, ...(typeof values.config === 'string' ? { configPath: values.config } : {}) });
|
|
670
|
+
const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel, homeProject: (org) => homeProject(ctx, org) });
|
|
671
|
+
const known = readHubInfo(ctx.store.dir, ctx.apiUrl);
|
|
672
|
+
if (!local?.logins.length && !known && !(await hub.open(false))) {
|
|
673
|
+
throw new UsageError(`No CLIProxyAPI logins on this computer (looked in ${local?.authDir ?? '~/.cli-proxy-api'}).`, 'name the config of yours: shard cli-proxy import --config <path>');
|
|
674
|
+
}
|
|
675
|
+
const userConfig = local?.configPath ? readFileSync(local.configPath, 'utf8') : undefined;
|
|
676
|
+
const ensured = await ensureCliProxyHub(hub, { dir: ctx.store.dir, apiUrl: ctx.apiUrl, ...(userConfig !== undefined ? { userConfig } : {}), progress: (m) => progress(ctx, m) });
|
|
677
|
+
for (const value of [ensured.info.callback_url, ensured.info.management_key])
|
|
678
|
+
ctx.addSecret(value);
|
|
679
|
+
// The forwarder takes 127.0.0.1:8317: refuse before stopping anything when another program holds it.
|
|
680
|
+
if (local && local.port !== CLI_PROXY_PORT) {
|
|
681
|
+
const answers = opts.stop?.answers ?? cliProxyTools.answers;
|
|
682
|
+
const ours = opts.stop?.ours ?? (async () => (await cliProxyTools.identity(CLI_PROXY_PORT)) !== null);
|
|
683
|
+
if ((await answers(CLI_PROXY_PORT)) && !(await ours())) {
|
|
684
|
+
throw new UsageError(`Another program answers on 127.0.0.1:${CLI_PROXY_PORT}, where this computer’s forwarder listens; nothing was changed.`, `stop it, then run: ${AGAIN}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const stop = local ? await stopLocalCliProxy(ctx, local, values, opts.stop) : { result: 'none', plan: null };
|
|
688
|
+
// Auth files: read after the local copy stopped (its last refresh is in them), uploaded only when the hub has none.
|
|
689
|
+
const uploaded = [], kept = [];
|
|
690
|
+
const added = [], already = [];
|
|
691
|
+
let movedAside = [];
|
|
692
|
+
if (local?.logins.length) {
|
|
693
|
+
const opened = await hub.open(false);
|
|
694
|
+
if (!opened)
|
|
695
|
+
throw new HubError('The hub was deleted during the import.', `run: ${AGAIN}`);
|
|
696
|
+
// Only accounts the hub does not have: compared by identity (provider type + email or account id, read from each
|
|
697
|
+
// auth file's JSON, the hub's and this computer's), under the hub's logins lock, so two computers importing at
|
|
698
|
+
// once never add one account twice and an account the hub holds keeps its newer tokens, whatever the file names.
|
|
699
|
+
await opened.ws.withLock('logins', async () => {
|
|
700
|
+
const inHub = new Set();
|
|
701
|
+
const present = await opened.ws.authFiles();
|
|
702
|
+
for (const rel of present) {
|
|
703
|
+
const text = await opened.ws.read(`${HUB_AUTH_DIR}/${rel}`);
|
|
704
|
+
inHub.add(text === null ? `file:${rel}` : loginIdentity(text, rel));
|
|
705
|
+
}
|
|
706
|
+
const names = new Set(present);
|
|
707
|
+
for (const login of local.logins) {
|
|
708
|
+
const text = readFileSync(join(local.authDir, login.file), 'utf8');
|
|
709
|
+
const account = loginIdentity(text, login.file);
|
|
710
|
+
const r = inHub.has(account) || names.has(login.file) ? null : await opened.ws.sh(ADD_AUTH_SCRIPT, [login.file], { stdin: text });
|
|
711
|
+
if (r && r.code !== 0)
|
|
712
|
+
throw new HubError(`Could not add ${login.file} to the hub: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, `run: ${AGAIN}`);
|
|
713
|
+
if (!r || r.stdout.trim() === 'exists') {
|
|
714
|
+
kept.push(login.file);
|
|
715
|
+
already.push({ provider: login.provider, email: login.email });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
uploaded.push(login.file);
|
|
719
|
+
added.push({ provider: login.provider, email: login.email });
|
|
720
|
+
inHub.add(account);
|
|
721
|
+
names.add(login.file);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
if (uploaded.length)
|
|
725
|
+
progress(ctx, `Moved ${uploaded.length} login${uploaded.length === 1 ? '' : 's'} to the hub`);
|
|
726
|
+
// The hub holds every login now: no local copy keeps tokens it could refresh.
|
|
727
|
+
movedAside = moveAuthFilesAside(local.authDirs);
|
|
728
|
+
}
|
|
729
|
+
if (local) {
|
|
730
|
+
new AgentStateStore(ctx.store.dir).update((doc) => {
|
|
731
|
+
doc.cli_proxy_local = { api_url: ctx.apiUrl, auth_dir: local.authDir, port: local.port, config: local.configPath, service: local.service, stopped: stop.plan?.line ?? null, start: startLine(local), moved: movedAside };
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
let launchd = null;
|
|
735
|
+
if (platform === 'darwin' && opts.launchd !== false)
|
|
736
|
+
launchd = await installForwarderAgent(ctx, ctx.apiUrl, opts.launchd || {});
|
|
737
|
+
// Everything above already works for this computer; the machine step is reported, never undone by its failure.
|
|
738
|
+
let machine = null;
|
|
739
|
+
if (opts.machine !== false) {
|
|
740
|
+
const current = async () => {
|
|
741
|
+
const opened = await hub.open(false);
|
|
742
|
+
if (!opened)
|
|
743
|
+
throw new HubError('The hub was deleted during the import.', `run: ${AGAIN}`);
|
|
744
|
+
if (!ensured.info.vm_callback_url)
|
|
745
|
+
return ensured.info;
|
|
746
|
+
// What VMs get is the VM port's URL, read again under the hub's lock right before it is written.
|
|
747
|
+
const vm = (await hubCallback(opened.ws, false, CLI_PROXY_VM_PORT)).callback;
|
|
748
|
+
const info = { ...ensured.info, vm_callback_url: vm.callback_url, vm_callback_created_at: vm.callback_created_at };
|
|
749
|
+
if (info.vm_callback_url !== ensured.info.vm_callback_url)
|
|
750
|
+
writeHubInfo(ctx.store.dir, info);
|
|
751
|
+
return info;
|
|
752
|
+
};
|
|
753
|
+
try {
|
|
754
|
+
machine = await saveHubInfoToMachine(ctx, ensured.info, { current });
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
machine = { error: err instanceof Error ? err.message : String(err) };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const accounts = await hubAccounts(ensured.info).catch(() => null);
|
|
761
|
+
return {
|
|
762
|
+
hub: { workspace_id: ensured.info.workspace_id, created: ensured.created, process: ensured.process },
|
|
763
|
+
logins: { uploaded, kept, added, already, moved_aside: movedAside.map((m) => m.to) }, config: ensured.config ? 'from_this_computer' : 'kept', local: stop.result, port: local?.port ?? CLI_PROXY_PORT, forwarder: { launchd }, machine, dropped: ensured.config?.dropped ?? [], accounts,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Brings the hub's logins back to this computer: the hub stops first (from then on only this computer refreshes
|
|
768
|
+
* them), its auth files as last refreshed are written to the local auth directory (0600), the hub sets its copies
|
|
769
|
+
* aside (a later import uploads this computer's), this computer's forwarder agent goes, and the hub is suspended.
|
|
770
|
+
* Undone by `shard cli-proxy import`.
|
|
771
|
+
*/
|
|
772
|
+
export async function exportCliProxy(ctx, opts = {}) {
|
|
773
|
+
if (cliProxyTools.inVm())
|
|
774
|
+
throw new UsageError('shard cli-proxy export runs on the computer that should hold your logins, not in a VM.', 'run it on that computer: shard cli-proxy export');
|
|
775
|
+
const values = opts.values ?? {};
|
|
776
|
+
const info = readHubInfo(ctx.store.dir, ctx.apiUrl);
|
|
777
|
+
if (!info)
|
|
778
|
+
throw new UsageError('This computer is not connected to a hosted CLIProxyAPI.', `to move yours into your account: ${AGAIN}`);
|
|
779
|
+
for (const value of [info.callback_url, info.management_key])
|
|
780
|
+
ctx.addSecret(value);
|
|
781
|
+
if (values.yes !== true) {
|
|
782
|
+
if (!ctx.input.interactive)
|
|
783
|
+
throw new UsageError('Export stops the hosted CLIProxyAPI for every device and VM and brings its logins to this computer.', 'run: shard cli-proxy export --yes');
|
|
784
|
+
const answer = (await ctx.input.answer('Bring your logins back to this computer and stop the hosted CLIProxyAPI for every device and VM? [Y/n] '))?.trim() ?? '';
|
|
785
|
+
if (!(answer === '' || /^y(es)?$/i.test(answer)))
|
|
786
|
+
throw new UsageError('Nothing was changed.');
|
|
787
|
+
}
|
|
788
|
+
const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
|
|
789
|
+
const opened = await hub.open(false);
|
|
790
|
+
if (!opened)
|
|
791
|
+
throw new HubError('The hosted CLIProxyAPI no longer exists.', 'nothing to bring back');
|
|
792
|
+
progress(ctx, 'Stopping the hosted CLIProxyAPI…');
|
|
793
|
+
const stopped = await opened.ws.sh(STOP_SCRIPT, [], { timeoutMs: 120_000 });
|
|
794
|
+
if (stopped.code !== 0 || !stopped.stdout.includes('stopped')) {
|
|
795
|
+
throw new HubError(`The hosted CLIProxyAPI did not stop: ${stopped.stderr.trim().split('\n').at(-1) || `exit ${stopped.code}`}`, 'run: shard cli-proxy export --yes');
|
|
796
|
+
}
|
|
797
|
+
const store = new AgentStateStore(ctx.store.dir);
|
|
798
|
+
const record = store.read().cli_proxy_local;
|
|
799
|
+
const mine = record?.api_url === ctx.apiUrl ? record : undefined;
|
|
800
|
+
const home = ctx.io.env.HOME || homedir();
|
|
801
|
+
const authDir = typeof mine?.auth_dir === 'string' ? mine.auth_dir : join(home, '.cli-proxy-api');
|
|
802
|
+
mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
|
803
|
+
const logins = [];
|
|
804
|
+
for (const rel of await opened.ws.authFiles()) {
|
|
805
|
+
const text = await opened.ws.read(`${HUB_AUTH_DIR}/${rel}`);
|
|
806
|
+
if (text === null)
|
|
807
|
+
continue;
|
|
808
|
+
const target = join(authDir, rel);
|
|
809
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
810
|
+
writeFileSync(target, text, { mode: 0o600 });
|
|
811
|
+
chmodSync(target, 0o600);
|
|
812
|
+
logins.push(rel);
|
|
813
|
+
}
|
|
814
|
+
const aside = await opened.ws.sh(SET_ASIDE_SCRIPT);
|
|
815
|
+
if (aside.code !== 0)
|
|
816
|
+
throw new HubError('The hub did not set its logins aside.', 'run: shard cli-proxy export --yes');
|
|
817
|
+
let removed = false;
|
|
818
|
+
if ((opts.platform ?? process.platform) === 'darwin' && opts.launchd !== false) {
|
|
819
|
+
await removeForwarderAgent(home, opts.launchd?.run ?? cliProxyTools.run, opts.launchd?.uid);
|
|
820
|
+
removed = true;
|
|
821
|
+
}
|
|
822
|
+
await hub.suspend(opened.ws.id).catch(() => undefined);
|
|
823
|
+
store.update((doc) => { doc.cli_proxy_exported = { api_url: ctx.apiUrl, at: new Date().toISOString(), auth_dir: authDir }; });
|
|
824
|
+
return {
|
|
825
|
+
auth_dir: authDir, logins, hub: { workspace_id: opened.ws.id, state: 'stopped', logins_set_aside: aside.stdout.trim() || null },
|
|
826
|
+
forwarder: { removed }, start: typeof mine?.start === 'string' && mine.start ? mine.start : 'cli-proxy-api',
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
async function exportHandler(ctx, values) {
|
|
830
|
+
const r = await exportCliProxy(ctx, { values });
|
|
831
|
+
if (ctx.json) {
|
|
832
|
+
ctx.io.stdout.write(json(r));
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
ctx.io.stdout.write([
|
|
836
|
+
`Your logins are on this computer again: ${r.logins.length} in ${r.auth_dir}.`,
|
|
837
|
+
`The hosted CLIProxyAPI is stopped${r.forwarder.removed ? ', and this computer no longer forwards 127.0.0.1:8317' : ''}.`,
|
|
838
|
+
`Start your local CLIProxyAPI: ${r.start}`,
|
|
839
|
+
`Move it back into your account any time: ${AGAIN}`,
|
|
840
|
+
].join('\n') + '\n');
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
// ---- handlers ------------------------------------------------------------------------------------------------------
|
|
844
|
+
function statusLines(state, accounts, forwarding, launchd, machineVersion) {
|
|
845
|
+
const hub = state === null ? 'missing (run shard cli-proxy import)' : state === 'running' ? 'running' : ['suspended', 'parked', 'stopped'].includes(state) ? 'sleeping (wakes on the next request)' : state;
|
|
846
|
+
const lines = [`CLIProxyAPI hub: ${hub} · ${HUB_KEY}`];
|
|
847
|
+
if (accounts)
|
|
848
|
+
lines.push(`Accounts: ${accounts.length}`, ...accounts.map(accountLine));
|
|
849
|
+
lines.push(`This computer: ${forwarding ? `http://127.0.0.1:${CLI_PROXY_PORT}${launchd ? ` (launchd ${FORWARD_LABEL})` : ''}` : `not forwarding; connect it: shard cli-proxy import`}`);
|
|
850
|
+
lines.push(`VMs: ${machineVersion !== null ? `every new VM reaches it at http://127.0.0.1:${CLI_PROXY_PORT} (machine version ${machineVersion})` : 'run shard cli-proxy import to add the hub to your machine'}`);
|
|
851
|
+
return `${lines.join('\n')}\n`;
|
|
852
|
+
}
|
|
853
|
+
async function status(ctx, values) {
|
|
854
|
+
if (cliProxyTools.inVm()) {
|
|
855
|
+
// In a VM: sf-agent forwards 127.0.0.1:8317 to the hub; the hub is managed from the computer that set it up.
|
|
856
|
+
const identity = await cliProxyTools.identity();
|
|
857
|
+
const forwarding = identity?.kind === 'vm' ? identity : null;
|
|
858
|
+
if (ctx.json) {
|
|
859
|
+
ctx.io.stdout.write(json({ vm: true, forwarder: forwarding ? { kind: 'vm', hub: forwarding.hub, version: forwarding.version } : null }));
|
|
860
|
+
return 0;
|
|
861
|
+
}
|
|
862
|
+
ctx.io.stdout.write(forwarding
|
|
863
|
+
? `This VM reaches your account’s hosted CLIProxyAPI at http://127.0.0.1:${CLI_PROXY_PORT} (sf-agent forwarder${forwarding.version ? ` ${forwarding.version}` : ''}).\nIts logins and settings are managed from the computer that set it up: shard cli-proxy\n`
|
|
864
|
+
: `This VM has no hosted CLIProxyAPI yet. Set it up on the computer where CLIProxyAPI runs: shard cli-proxy import\n`);
|
|
865
|
+
return 0;
|
|
866
|
+
}
|
|
867
|
+
let info = readHubInfo(ctx.store.dir, ctx.apiUrl);
|
|
868
|
+
if (!info) {
|
|
869
|
+
const local = await detectLocalCliProxy({ env: ctx.io.env, ...(typeof values.config === 'string' ? { configPath: values.config } : {}) }).catch(() => null);
|
|
870
|
+
const found = local?.logins.length ? local.logins : [];
|
|
871
|
+
if (ctx.json) {
|
|
872
|
+
ctx.io.stdout.write(json({ hub: null, local: local ? { auth_dir: local.authDir, logins: found.map(({ provider, email }) => ({ provider, email })), config: local.configPath } : null }));
|
|
873
|
+
return 0;
|
|
874
|
+
}
|
|
875
|
+
ctx.io.stdout.write(found.length
|
|
876
|
+
? `CLIProxyAPI on this computer: ${found.length} login${found.length === 1 ? '' : 's'} (${found.map((l) => `${l.provider ?? 'unknown'} ${l.email ?? ''}`.trim()).join(', ')}).\nMove it to your account, so every device and VM uses one copy at http://127.0.0.1:${CLI_PROXY_PORT}: shard cli-proxy import\n`
|
|
877
|
+
: `No hosted CLIProxyAPI yet. Run CLIProxyAPI on this computer, then: shard cli-proxy import\n`);
|
|
878
|
+
return 0;
|
|
879
|
+
}
|
|
880
|
+
for (const value of [info.callback_url, info.management_key])
|
|
881
|
+
ctx.addSecret(value);
|
|
882
|
+
const hub = sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
|
|
883
|
+
const state = await hub.state(info.workspace_id);
|
|
884
|
+
let accounts = null;
|
|
885
|
+
let repaired = false;
|
|
886
|
+
let renewed = false;
|
|
887
|
+
let note = null;
|
|
888
|
+
if (state !== null) {
|
|
889
|
+
let health = await hubHealth(info);
|
|
890
|
+
if (!health.ok && health.reason === 'callback_invalid') {
|
|
891
|
+
// The API key that created the hub's address was revoked: a new address for every device and VM.
|
|
892
|
+
try {
|
|
893
|
+
progress(ctx, 'Renewing the hub’s address…');
|
|
894
|
+
const r = await renewHubCallbacks(ctx, { hub });
|
|
895
|
+
info = r.info;
|
|
896
|
+
renewed = true;
|
|
897
|
+
const v = await r.vms;
|
|
898
|
+
note = v.machine !== null
|
|
899
|
+
? `The hub has a new address (the API key that created the old one was revoked); VMs get it with machine version ${v.machine}.`
|
|
900
|
+
: `The hub has a new address (the API key that created the old one was revoked). VMs: ${v.note ?? 'not updated'}; run: ${AGAIN}`;
|
|
901
|
+
health = await hubHealth(info);
|
|
902
|
+
}
|
|
903
|
+
catch (err) {
|
|
904
|
+
if (!(err instanceof HubError))
|
|
905
|
+
throw err;
|
|
906
|
+
note = `${err.message}${err.hint ? ` (${err.hint})` : ''}`;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (!health.ok && health.reason === 'port_not_listening') {
|
|
910
|
+
try {
|
|
911
|
+
progress(ctx, 'Starting CLIProxyAPI in the hub again…');
|
|
912
|
+
await ensureCliProxyHub(hub, { dir: ctx.store.dir, apiUrl: ctx.apiUrl, repairOnly: true });
|
|
913
|
+
repaired = true;
|
|
914
|
+
health = await hubHealth(info);
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
if (!(err instanceof HubError))
|
|
918
|
+
throw err;
|
|
919
|
+
note = `${err.message}${err.hint ? ` (${err.hint})` : ''}`;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (health.ok)
|
|
923
|
+
accounts = await hubAccounts(info).catch(() => null);
|
|
924
|
+
}
|
|
925
|
+
const identity = await cliProxyTools.identity();
|
|
926
|
+
const launchd = existsSync(forwarderPlistPath(ctx.io.env.HOME || homedir()));
|
|
927
|
+
const record = new AgentStateStore(ctx.store.dir).read().cli_proxy_machine;
|
|
928
|
+
const machineVersion = record?.api_url === ctx.apiUrl && typeof record.version === 'number' ? record.version : null;
|
|
929
|
+
const forwarding = identity?.hub === info.workspace_id;
|
|
930
|
+
if (ctx.json) {
|
|
931
|
+
ctx.io.stdout.write(json({ hub: { workspace_id: info.workspace_id, key: HUB_KEY, state, restarted: repaired, renewed, note }, accounts, forwarder: { local: forwarding, launchd }, machine: machineVersion === null ? null : { version: machineVersion } }));
|
|
932
|
+
return 0;
|
|
933
|
+
}
|
|
934
|
+
ctx.io.stdout.write(statusLines(state, accounts, forwarding, launchd, machineVersion) + (note ? `${note}\n` : ''));
|
|
935
|
+
return 0;
|
|
936
|
+
}
|
|
937
|
+
const accountName = (a) => [a.provider ?? 'login', a.email].filter(Boolean).join(' ');
|
|
938
|
+
async function importHandler(ctx, values) {
|
|
939
|
+
const r = await importCliProxy(ctx, { values });
|
|
940
|
+
if (ctx.json) {
|
|
941
|
+
ctx.io.stdout.write(json(r));
|
|
942
|
+
return 0;
|
|
943
|
+
}
|
|
944
|
+
const lines = [
|
|
945
|
+
`CLIProxyAPI runs in your account (${HUB_KEY}); it sleeps between requests and wakes on the next one.`,
|
|
946
|
+
...(r.logins.added.length ? [`Added to the hub: ${r.logins.added.map(accountName).join(', ')}.`] : []),
|
|
947
|
+
...(r.logins.already.length ? [`Already in the hub (its sign-ins stay): ${r.logins.already.map(accountName).join(', ')}.`] : []),
|
|
948
|
+
...(r.config === 'kept' ? ['The hub keeps its settings (client keys, routing, upstream keys).'] : []),
|
|
949
|
+
...r.logins.moved_aside.map((dir) => `This computer’s auth files are in ${dir} now, where no CLIProxyAPI reads them.`),
|
|
950
|
+
...(r.port !== CLI_PROXY_PORT ? [`Point tools that used http://127.0.0.1:${r.port} at http://127.0.0.1:${CLI_PROXY_PORT}.`] : []),
|
|
951
|
+
...(r.accounts ? [`Accounts: ${r.accounts.length}`, ...r.accounts.map(accountLine)] : []),
|
|
952
|
+
r.forwarder.launchd ? `This computer: http://127.0.0.1:${CLI_PROXY_PORT} forwards to it (starts at login).` : `This computer: run shard cli-proxy forward to reach it at http://127.0.0.1:${CLI_PROXY_PORT}.`,
|
|
953
|
+
r.machine && 'version' in r.machine ? `VMs: every new VM reaches it at http://127.0.0.1:${CLI_PROXY_PORT} (machine version ${r.machine.version}).` : '',
|
|
954
|
+
r.machine && 'error' in r.machine ? `VMs: ${r.machine.error} Connect them with: ${AGAIN}` : '',
|
|
955
|
+
...(r.dropped.length ? [`Kept on this computer only: ${r.dropped.join('; ')}.`] : []),
|
|
956
|
+
'Claude Code, Codex and T3 Code keep their settings.',
|
|
957
|
+
].filter(Boolean);
|
|
958
|
+
ctx.io.stdout.write(`${lines.join('\n')}\n`);
|
|
959
|
+
return 0;
|
|
960
|
+
}
|
|
961
|
+
async function forwardHandler(ctx) {
|
|
962
|
+
const source = hubInfoSource(ctx.store.dir);
|
|
963
|
+
const log = (line) => void ctx.io.stderr.write(`${new Date().toISOString()} ${line}\n`);
|
|
964
|
+
const forwarder = new CliProxyForwarder({
|
|
965
|
+
hub: source,
|
|
966
|
+
log,
|
|
967
|
+
repair: async () => {
|
|
968
|
+
const info = source();
|
|
969
|
+
if (!info || info.api_url !== ctx.apiUrl)
|
|
970
|
+
throw new Error('the hub belongs to another API URL');
|
|
971
|
+
await ensureCliProxyHub(sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel }), { dir: ctx.store.dir, apiUrl: info.api_url, repairOnly: true });
|
|
972
|
+
},
|
|
973
|
+
renew: async () => {
|
|
974
|
+
const info = source();
|
|
975
|
+
if (!info || info.api_url !== ctx.apiUrl)
|
|
976
|
+
throw new Error('the hub belongs to another API URL');
|
|
977
|
+
const r = await renewHubCallbacks(ctx);
|
|
978
|
+
void r.vms.then((v) => log(v.machine !== null ? `VMs get the hub’s new address with machine version ${v.machine}` : `VMs: ${v.note ?? 'not updated'}; run: ${AGAIN}`));
|
|
979
|
+
},
|
|
980
|
+
});
|
|
981
|
+
if (!source())
|
|
982
|
+
log('not connected to a hosted CLIProxyAPI yet; answering 503 until shard cli-proxy import connects this computer');
|
|
983
|
+
await forwarder.listen();
|
|
984
|
+
await new Promise((resolve) => {
|
|
985
|
+
if (ctx.signal.aborted)
|
|
986
|
+
return resolve();
|
|
987
|
+
ctx.signal.addEventListener('abort', () => resolve(), { once: true });
|
|
988
|
+
});
|
|
989
|
+
await forwarder.close();
|
|
990
|
+
return 0;
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* After the edge refused the hub's callback URL (`callback_invalid`: the API key that created it was revoked):
|
|
994
|
+
* replaces the URLs the edge refuses (under the hub's lock), saves them locally and gives VMs the new VM address.
|
|
995
|
+
*/
|
|
996
|
+
export async function renewHubCallbacks(ctx, opts = {}) {
|
|
997
|
+
const hub = opts.hub ?? sdkHubCloud(ctx.cloud(), { signal: ctx.signal, wakeTimeoutMs: ctx.wakeTimeoutMs, agentLabel: ctx.agentLabel });
|
|
998
|
+
const r = await ensureCliProxyHub(hub, { dir: ctx.store.dir, apiUrl: ctx.apiUrl, repairOnly: true, renewInvalid: true });
|
|
999
|
+
for (const v of [r.info.callback_url, r.info.vm_callback_url ?? ''])
|
|
1000
|
+
if (v)
|
|
1001
|
+
ctx.addSecret(v);
|
|
1002
|
+
// This computer uses the new address at once; the machine (VMs) follows.
|
|
1003
|
+
const vms = saveHubInfoToMachine(ctx, r.info, opts.machine ? { machine: opts.machine } : {}).then((saved) => ({ machine: saved.version, note: null }), (err) => ({ machine: null, note: err instanceof Error ? err.message : String(err) }));
|
|
1004
|
+
return { info: r.info, vms };
|
|
1005
|
+
}
|
|
1006
|
+
/** Removes the launchd agent (tests and a future `shard cli-proxy forward --stop`). */
|
|
1007
|
+
export async function removeForwarderAgent(home, run = runProgram, uid = userInfo().uid) {
|
|
1008
|
+
await run('launchctl', ['bootout', `gui/${uid}/${FORWARD_LABEL}`]);
|
|
1009
|
+
rmSync(forwarderPlistPath(home), { force: true });
|
|
1010
|
+
}
|
|
1011
|
+
export const CLI_PROXY_HANDLERS = {
|
|
1012
|
+
'cli-proxy': status,
|
|
1013
|
+
'cli-proxy import': importHandler,
|
|
1014
|
+
'cli-proxy export': exportHandler,
|
|
1015
|
+
'cli-proxy forward': forwardHandler,
|
|
1016
|
+
};
|