@love-moon/conductor-cli 0.15.2 → 0.16.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 +81 -0
- package/bin/conductor-auth.js +162 -0
- package/bin/conductor-daemon-query.js +13 -17
- package/bin/conductor-daemon.js +6 -1
- package/bin/conductor-issue.js +215 -13
- package/bin/conductor-project.js +840 -27
- package/bin/conductor-search.js +150 -0
- package/bin/conductor-settings.js +608 -0
- package/bin/conductor-task.js +316 -9
- package/bin/conductor.js +29 -8
- package/package.json +5 -5
- package/src/backend-http.js +240 -0
- package/src/daemon-share-commands.js +528 -0
- package/src/task-commands.js +777 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write / remote-control verbs for `conductor daemon`, mirroring the web
|
|
3
|
+
* frontend's daemon panel (AI manager, custom commands, update button, resume
|
|
4
|
+
* sessions) and daemon sharing (DaemonSharingCard, /app/daemon-share/[token]).
|
|
5
|
+
*
|
|
6
|
+
* restart <host> POST /api/agents/:host/restart
|
|
7
|
+
* upgrade <host> [--status] [--wait] POST|GET /api/agents/:host/update
|
|
8
|
+
* sessions <host> GET /api/agents/:host/sessions
|
|
9
|
+
* accounts <host> GET /api/ai-manager/accounts?agentHost=
|
|
10
|
+
* switch-account <host> <name> POST /api/ai-manager/switch
|
|
11
|
+
* commands list <host> GET /api/agents/:host/custom-commands
|
|
12
|
+
* commands run <host> <key> [--wait] POST /api/agents/:host/custom-commands/run
|
|
13
|
+
* commands status <host> <runId> GET /api/agents/:host/custom-commands/runs/:runId
|
|
14
|
+
* share create <host> POST /api/daemon-shares
|
|
15
|
+
* share list [--host h] GET /api/daemon-shares
|
|
16
|
+
* share revoke <id> DELETE /api/daemon-shares/:id
|
|
17
|
+
* share show-invite <token|url> GET /api/daemon-shares/invitations/:token
|
|
18
|
+
* share accept <token|url> POST /api/daemon-shares/accept/:token
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { apiPath, argsError, buildHttp, sendOrPreview } from "./backend-http.js";
|
|
22
|
+
import { EXIT, pad, printJson, printPretty } from "./entity-helpers.js";
|
|
23
|
+
|
|
24
|
+
export const DAEMON_REMOTE_VERBS = [
|
|
25
|
+
"restart",
|
|
26
|
+
"upgrade",
|
|
27
|
+
"sessions",
|
|
28
|
+
"accounts",
|
|
29
|
+
"switch-account",
|
|
30
|
+
"commands",
|
|
31
|
+
"share",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// The frontend's SDK client gives ai-manager round trips 45s (the server waits
|
|
35
|
+
// up to 30s for the daemon on a switch).
|
|
36
|
+
const AI_MANAGER_TIMEOUT_MS = 45_000;
|
|
37
|
+
// Same cadence as UpdateDaemonButton / CustomCommandsPanel.
|
|
38
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
39
|
+
// UpdateDaemonButton gives install + verify + restart 15 minutes.
|
|
40
|
+
const UPGRADE_WAIT_TIMEOUT_MS = 15 * 60_000;
|
|
41
|
+
const COMMAND_WAIT_TIMEOUT_MS = 60 * 60_000;
|
|
42
|
+
|
|
43
|
+
export function printTable(stream, header, rows) {
|
|
44
|
+
const widths = header
|
|
45
|
+
.slice(0, -1)
|
|
46
|
+
.map((title, i) => Math.max(title.length, ...rows.map((row) => String(row[i] ?? "").length)));
|
|
47
|
+
for (const row of [header, ...rows]) {
|
|
48
|
+
printPretty(stream, row.map((cell, i) => (i < widths.length ? pad(cell, widths[i]) : (cell ?? ""))).join(" "));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
|
|
54
|
+
function oneLine(text, max = 80) {
|
|
55
|
+
const flat = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
56
|
+
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Accept a bare invite token or a full `.../app/daemon-share/<token>` URL. */
|
|
60
|
+
export function extractInviteToken(input) {
|
|
61
|
+
const raw = String(input ?? "").trim();
|
|
62
|
+
if (!raw) throw argsError("invite token or URL is required");
|
|
63
|
+
const match = raw.match(/\/daemon-share\/([^/?#]+)/);
|
|
64
|
+
if (match) return decodeURIComponent(match[1]);
|
|
65
|
+
if (/^[a-z]+:\/\//i.test(raw)) throw argsError(`not a daemon share invite URL: ${raw}`);
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------- restart
|
|
70
|
+
|
|
71
|
+
async function handleRestart(argv, deps) {
|
|
72
|
+
const http = await buildHttp(deps);
|
|
73
|
+
const body = argv.targetVersion ? { targetVersion: argv.targetVersion } : {};
|
|
74
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", apiPath("agents", argv.host, "restart"), body);
|
|
75
|
+
if (dryRun) return EXIT.OK;
|
|
76
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
77
|
+
else printPretty(deps.stdout, `Restart requested for ${argv.host}`);
|
|
78
|
+
return EXIT.OK;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- upgrade
|
|
82
|
+
|
|
83
|
+
function describeUpgrade(status) {
|
|
84
|
+
if (!status || typeof status !== "object") return "(no update status)";
|
|
85
|
+
const parts = [`status: ${status.status ?? "unknown"}`];
|
|
86
|
+
if (status.phase) parts.push(`phase: ${status.phase}`);
|
|
87
|
+
if (status.fromVersion || status.toVersion) {
|
|
88
|
+
parts.push(`version: ${status.fromVersion ?? "?"} -> ${status.toVersion ?? "?"}`);
|
|
89
|
+
}
|
|
90
|
+
if (status.message) parts.push(`message: ${status.message}`);
|
|
91
|
+
if (status.error) parts.push(`error: ${status.error}`);
|
|
92
|
+
if (status.logPath) parts.push(`log: ${status.logPath}`);
|
|
93
|
+
return parts.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function upgradeExitCode(status) {
|
|
97
|
+
return status?.status === "failed" ? EXIT.GENERIC : EXIT.OK;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function waitForUpgrade(http, argv, deps, initial) {
|
|
101
|
+
const sleep = deps.sleep || defaultSleep;
|
|
102
|
+
const now = deps.now || Date.now;
|
|
103
|
+
const path = apiPath("agents", argv.host, "update");
|
|
104
|
+
const deadline = now() + (argv.waitTimeout ?? UPGRADE_WAIT_TIMEOUT_MS / 1000) * 1000;
|
|
105
|
+
let last = initial;
|
|
106
|
+
let lastLine = "";
|
|
107
|
+
const progress = (status) => {
|
|
108
|
+
if (argv.json) return;
|
|
109
|
+
const line = [status?.status, status?.phase, status?.message].filter(Boolean).join(" · ");
|
|
110
|
+
if (line && line !== lastLine) {
|
|
111
|
+
deps.stderr.write(`${line}\n`);
|
|
112
|
+
lastLine = line;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if (last) progress(last);
|
|
116
|
+
// The daemon restarts itself midway through a successful update, so a failed
|
|
117
|
+
// poll means "not answering right now", not "the update failed".
|
|
118
|
+
while (!last || last.status === "running") {
|
|
119
|
+
if (now() > deadline) {
|
|
120
|
+
throw new Error(`Lost track of the daemon update: ${argv.host} did not reach a final state in time`);
|
|
121
|
+
}
|
|
122
|
+
await sleep(POLL_INTERVAL_MS);
|
|
123
|
+
try {
|
|
124
|
+
last = await http.get(path);
|
|
125
|
+
progress(last);
|
|
126
|
+
} catch {
|
|
127
|
+
if (!argv.json && lastLine !== "(daemon not answering, likely restarting)") {
|
|
128
|
+
lastLine = "(daemon not answering, likely restarting)";
|
|
129
|
+
deps.stderr.write(`${lastLine}\n`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return last;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function handleUpgrade(argv, deps) {
|
|
137
|
+
const http = await buildHttp(deps);
|
|
138
|
+
const path = apiPath("agents", argv.host, "update");
|
|
139
|
+
let status;
|
|
140
|
+
if (argv.status) {
|
|
141
|
+
if (argv.dryRun) throw argsError("--dry-run cannot be combined with --status");
|
|
142
|
+
status = await http.get(path);
|
|
143
|
+
} else {
|
|
144
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", path, undefined);
|
|
145
|
+
if (dryRun) return EXIT.OK;
|
|
146
|
+
status = data;
|
|
147
|
+
}
|
|
148
|
+
if (argv.wait) status = await waitForUpgrade(http, argv, deps, status);
|
|
149
|
+
if (argv.json) printJson(deps.stdout, status);
|
|
150
|
+
else printPretty(deps.stdout, describeUpgrade(status));
|
|
151
|
+
return argv.wait || argv.status ? upgradeExitCode(status) : EXIT.OK;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------- sessions
|
|
155
|
+
|
|
156
|
+
async function handleSessions(argv, deps) {
|
|
157
|
+
const http = await buildHttp(deps);
|
|
158
|
+
const backends = (argv.backend ?? [])
|
|
159
|
+
.flatMap((entry) => String(entry).split(","))
|
|
160
|
+
.map((entry) => entry.trim())
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
const data = await http.get(apiPath("agents", argv.host, "sessions"), {
|
|
163
|
+
query: {
|
|
164
|
+
backends: backends.length > 0 ? backends.join(",") : undefined,
|
|
165
|
+
limit: argv.limit,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (argv.json) {
|
|
169
|
+
printJson(deps.stdout, data);
|
|
170
|
+
return EXIT.OK;
|
|
171
|
+
}
|
|
172
|
+
const sessions = data?.sessions ?? [];
|
|
173
|
+
if (sessions.length === 0) {
|
|
174
|
+
printPretty(deps.stdout, "(no resumable sessions)");
|
|
175
|
+
} else {
|
|
176
|
+
printTable(deps.stdout, ["BACKEND", "SESSION", "UPDATED", "TASK", "CWD", "TITLE"], sessions.map((s) => [
|
|
177
|
+
s.backend ?? "",
|
|
178
|
+
s.session_id ?? "",
|
|
179
|
+
s.updated_at ?? "",
|
|
180
|
+
s.linked_task_id ?? "",
|
|
181
|
+
s.cwd ?? "",
|
|
182
|
+
oneLine(s.title || s.first_user_message || ""),
|
|
183
|
+
]));
|
|
184
|
+
}
|
|
185
|
+
for (const error of data?.errors ?? []) {
|
|
186
|
+
deps.stderr.write(`warning: ${error.backend}: ${error.message}\n`);
|
|
187
|
+
}
|
|
188
|
+
return EXIT.OK;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------- accounts
|
|
192
|
+
|
|
193
|
+
async function handleAccounts(argv, deps) {
|
|
194
|
+
const http = await buildHttp(deps);
|
|
195
|
+
const data = await http.get("/api/ai-manager/accounts", {
|
|
196
|
+
query: { agentHost: argv.host },
|
|
197
|
+
timeoutMs: AI_MANAGER_TIMEOUT_MS,
|
|
198
|
+
});
|
|
199
|
+
if (argv.json) {
|
|
200
|
+
printJson(deps.stdout, data);
|
|
201
|
+
return EXIT.OK;
|
|
202
|
+
}
|
|
203
|
+
const accounts = data?.accounts ?? [];
|
|
204
|
+
if (accounts.length === 0) {
|
|
205
|
+
printPretty(deps.stdout, "(no codex accounts)");
|
|
206
|
+
return EXIT.OK;
|
|
207
|
+
}
|
|
208
|
+
printTable(deps.stdout, ["", "NAME", "EMAIL", "PLAN", "LAST REFRESH"], accounts.map((a) => [
|
|
209
|
+
a.isCurrent ? "*" : "",
|
|
210
|
+
a.name ?? "",
|
|
211
|
+
a.email ?? "",
|
|
212
|
+
a.planType ?? "",
|
|
213
|
+
a.lastRefresh ?? "",
|
|
214
|
+
]));
|
|
215
|
+
return EXIT.OK;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleSwitchAccount(argv, deps) {
|
|
219
|
+
const name = argv.account ?? argv.name;
|
|
220
|
+
if (!name) throw argsError("account name is required (positional <name> or --account)");
|
|
221
|
+
const http = await buildHttp(deps);
|
|
222
|
+
const { dryRun, data } = await sendOrPreview(
|
|
223
|
+
http,
|
|
224
|
+
argv,
|
|
225
|
+
deps,
|
|
226
|
+
"POST",
|
|
227
|
+
"/api/ai-manager/switch",
|
|
228
|
+
{ agentHost: argv.host, name },
|
|
229
|
+
{ timeoutMs: AI_MANAGER_TIMEOUT_MS },
|
|
230
|
+
);
|
|
231
|
+
if (dryRun) return EXIT.OK;
|
|
232
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
233
|
+
else {
|
|
234
|
+
const from = data?.previousName ? ` (was ${data.previousName})` : "";
|
|
235
|
+
printPretty(deps.stdout, `Switched codex account on ${argv.host} to ${data?.newName ?? name}${from}`);
|
|
236
|
+
}
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------- custom commands
|
|
241
|
+
|
|
242
|
+
async function handleCommandsList(argv, deps) {
|
|
243
|
+
const http = await buildHttp(deps);
|
|
244
|
+
const data = await http.get(apiPath("agents", argv.host, "custom-commands"));
|
|
245
|
+
if (argv.json) {
|
|
246
|
+
printJson(deps.stdout, data);
|
|
247
|
+
return EXIT.OK;
|
|
248
|
+
}
|
|
249
|
+
const commands = data?.commands ?? [];
|
|
250
|
+
if (commands.length === 0) {
|
|
251
|
+
printPretty(deps.stdout, "(no custom commands configured)");
|
|
252
|
+
return EXIT.OK;
|
|
253
|
+
}
|
|
254
|
+
printTable(deps.stdout, ["KEY", "STATE", "RUN ID"], commands.map((c) => [
|
|
255
|
+
c.key,
|
|
256
|
+
c.running ? "running" : "idle",
|
|
257
|
+
c.runId ?? "",
|
|
258
|
+
]));
|
|
259
|
+
return EXIT.OK;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function printRunStatus(stream, run) {
|
|
263
|
+
const lines = [`run: ${run?.runId ?? ""}`, `command: ${run?.key ?? ""}`, `status: ${run?.status ?? "unknown"}`];
|
|
264
|
+
if (run?.exitCode != null) lines.push(`exit code: ${run.exitCode}`);
|
|
265
|
+
if (run?.signal) lines.push(`signal: ${run.signal}`);
|
|
266
|
+
if (run?.error) lines.push(`error: ${run.error}`);
|
|
267
|
+
printPretty(stream, lines.join("\n"));
|
|
268
|
+
if (run?.stdoutTail) printPretty(stream, `--- stdout (tail) ---\n${run.stdoutTail.replace(/\n$/, "")}`);
|
|
269
|
+
if (run?.stderrTail) printPretty(stream, `--- stderr (tail) ---\n${run.stderrTail.replace(/\n$/, "")}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function runExitCode(run) {
|
|
273
|
+
return run?.status === "failed" ? EXIT.GENERIC : EXIT.OK;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function handleCommandsRun(argv, deps) {
|
|
277
|
+
const http = await buildHttp(deps);
|
|
278
|
+
const { dryRun, data } = await sendOrPreview(
|
|
279
|
+
http,
|
|
280
|
+
argv,
|
|
281
|
+
deps,
|
|
282
|
+
"POST",
|
|
283
|
+
apiPath("agents", argv.host, "custom-commands", "run"),
|
|
284
|
+
{ key: argv.key },
|
|
285
|
+
);
|
|
286
|
+
if (dryRun) return EXIT.OK;
|
|
287
|
+
if (!argv.wait) {
|
|
288
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
289
|
+
else {
|
|
290
|
+
printPretty(deps.stdout, `Started ${data?.key ?? argv.key} (run ${data?.runId ?? "?"}, ${data?.status ?? "running"})`);
|
|
291
|
+
if (data?.runId) printPretty(deps.stdout, `Follow with: conductor daemon commands status ${argv.host} ${data.runId}`);
|
|
292
|
+
}
|
|
293
|
+
return runExitCode(data);
|
|
294
|
+
}
|
|
295
|
+
const sleep = deps.sleep || defaultSleep;
|
|
296
|
+
const now = deps.now || Date.now;
|
|
297
|
+
const deadline = now() + (argv.waitTimeout ?? COMMAND_WAIT_TIMEOUT_MS / 1000) * 1000;
|
|
298
|
+
let run = data;
|
|
299
|
+
let pollWarned = false;
|
|
300
|
+
const statusPath = apiPath("agents", argv.host, "custom-commands", "runs", data?.runId ?? "");
|
|
301
|
+
while (run?.status === "running") {
|
|
302
|
+
if (!data?.runId) throw new Error("server did not return a runId to wait on");
|
|
303
|
+
if (now() > deadline) throw new Error(`timed out waiting for run ${data.runId}`);
|
|
304
|
+
await sleep(POLL_INTERVAL_MS);
|
|
305
|
+
try {
|
|
306
|
+
run = await http.get(statusPath);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// A 5xx / network blip on the status route says nothing about the run,
|
|
309
|
+
// which keeps going on the daemon: keep polling until the deadline.
|
|
310
|
+
// 4xx (unknown run, lost access) will not heal, so surface it.
|
|
311
|
+
const status = error?.statusCode;
|
|
312
|
+
if (typeof status === "number" && status < 500) throw error;
|
|
313
|
+
if (!argv.json && !pollWarned) {
|
|
314
|
+
deps.stderr.write(`(status poll failed: ${error?.message ?? error}; retrying)\n`);
|
|
315
|
+
pollWarned = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (argv.json) printJson(deps.stdout, run);
|
|
320
|
+
else printRunStatus(deps.stdout, run);
|
|
321
|
+
return runExitCode(run);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function handleCommandsStatus(argv, deps) {
|
|
325
|
+
const http = await buildHttp(deps);
|
|
326
|
+
const run = await http.get(apiPath("agents", argv.host, "custom-commands", "runs", argv.runId));
|
|
327
|
+
if (argv.json) printJson(deps.stdout, run);
|
|
328
|
+
else printRunStatus(deps.stdout, run);
|
|
329
|
+
return EXIT.OK;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------- sharing
|
|
333
|
+
|
|
334
|
+
async function handleShareCreate(argv, deps) {
|
|
335
|
+
const http = await buildHttp(deps);
|
|
336
|
+
const body = { daemonHost: argv.host };
|
|
337
|
+
if (argv.workspaceRoot) body.workspaceRoot = argv.workspaceRoot;
|
|
338
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", "/api/daemon-shares", body);
|
|
339
|
+
if (dryRun) return EXIT.OK;
|
|
340
|
+
if (argv.json) {
|
|
341
|
+
printJson(deps.stdout, data);
|
|
342
|
+
return EXIT.OK;
|
|
343
|
+
}
|
|
344
|
+
printPretty(deps.stdout, `Created share ${data?.id} for ${data?.ownerDaemonHost ?? argv.host}`);
|
|
345
|
+
if (data?.expiresAt) printPretty(deps.stdout, `Invite expires: ${data.expiresAt}`);
|
|
346
|
+
printPretty(deps.stdout, `Invite link: ${data?.inviteUrl ?? ""}`);
|
|
347
|
+
return EXIT.OK;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function handleShareList(argv, deps) {
|
|
351
|
+
const http = await buildHttp(deps);
|
|
352
|
+
const data = await http.get("/api/daemon-shares", { query: { daemonHost: argv.host } });
|
|
353
|
+
if (argv.json) {
|
|
354
|
+
printJson(deps.stdout, data);
|
|
355
|
+
return EXIT.OK;
|
|
356
|
+
}
|
|
357
|
+
const shares = data?.shares ?? [];
|
|
358
|
+
if (shares.length === 0) {
|
|
359
|
+
printPretty(deps.stdout, "(no active shares)");
|
|
360
|
+
return EXIT.OK;
|
|
361
|
+
}
|
|
362
|
+
printTable(deps.stdout, ["ID", "DAEMON", "STATUS", "GRANTEE", "GUEST HOST", "EXPIRES", "INVITE"], shares.map((s) => [
|
|
363
|
+
s.id,
|
|
364
|
+
s.ownerDaemonHost ?? "",
|
|
365
|
+
s.status ?? "",
|
|
366
|
+
s.granteeLabel ?? "",
|
|
367
|
+
s.guestHost ?? "",
|
|
368
|
+
s.status === "pending" ? (s.expiresAt ?? "") : "",
|
|
369
|
+
s.status === "pending" ? (s.inviteUrl ?? "") : "",
|
|
370
|
+
]));
|
|
371
|
+
return EXIT.OK;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function handleShareRevoke(argv, deps) {
|
|
375
|
+
const http = await buildHttp(deps);
|
|
376
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "DELETE", apiPath("daemon-shares", argv.id), undefined);
|
|
377
|
+
if (dryRun) return EXIT.OK;
|
|
378
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
379
|
+
else printPretty(deps.stdout, data?.alreadyRevoked ? `Share ${argv.id} was already revoked` : `Revoked share ${argv.id}`);
|
|
380
|
+
return EXIT.OK;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function handleShareShowInvite(argv, deps) {
|
|
384
|
+
const token = extractInviteToken(argv.token);
|
|
385
|
+
const http = await buildHttp(deps);
|
|
386
|
+
const data = await http.get(apiPath("daemon-shares", "invitations", token));
|
|
387
|
+
if (argv.json) {
|
|
388
|
+
printJson(deps.stdout, data);
|
|
389
|
+
return EXIT.OK;
|
|
390
|
+
}
|
|
391
|
+
const lines = [
|
|
392
|
+
`owner: ${data?.ownerLabel ?? ""}`,
|
|
393
|
+
`daemon: ${data?.ownerDaemonHost ?? ""}`,
|
|
394
|
+
`status: ${data?.status ?? ""}${data?.isSelf ? " (your own daemon)" : ""}`,
|
|
395
|
+
];
|
|
396
|
+
if (data?.workspaceRoot) lines.push(`workspace root: ${data.workspaceRoot}`);
|
|
397
|
+
if (data?.expiresAt) lines.push(`expires: ${data.expiresAt}`);
|
|
398
|
+
if (data?.guestHost) lines.push(`guest host: ${data.guestHost}`);
|
|
399
|
+
printPretty(deps.stdout, lines.join("\n"));
|
|
400
|
+
return EXIT.OK;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function handleShareAccept(argv, deps) {
|
|
404
|
+
const token = extractInviteToken(argv.token);
|
|
405
|
+
const http = await buildHttp(deps);
|
|
406
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", apiPath("daemon-shares", "accept", token), {});
|
|
407
|
+
if (dryRun) return EXIT.OK;
|
|
408
|
+
if (argv.json) printJson(deps.stdout, data);
|
|
409
|
+
else printPretty(deps.stdout, `Accepted. The shared machine appears as ${data?.guestHost ?? "(unknown)"}`);
|
|
410
|
+
return EXIT.OK;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------- registration
|
|
414
|
+
|
|
415
|
+
const hostPositional = (cmd) => cmd.positional("host", { type: "string", describe: "Daemon host name" });
|
|
416
|
+
|
|
417
|
+
/** Register the verbs on the `conductor daemon` yargs instance. */
|
|
418
|
+
/**
|
|
419
|
+
* `--dry-run` is a global daemon option but only write verbs can preview a
|
|
420
|
+
* request; reject it on read-only verbs instead of silently ignoring it.
|
|
421
|
+
*/
|
|
422
|
+
export function readOnly(handler) {
|
|
423
|
+
return (argv, deps) => {
|
|
424
|
+
if (argv.dryRun) throw argsError("--dry-run only applies to verbs that change something; this one only reads");
|
|
425
|
+
return handler(argv, deps);
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function registerDaemonRemoteCommands(y, run) {
|
|
430
|
+
return y
|
|
431
|
+
.command(
|
|
432
|
+
"restart <host>",
|
|
433
|
+
"Restart a daemon (it reconnects on its own)",
|
|
434
|
+
(cmd) => hostPositional(cmd)
|
|
435
|
+
.option("target-version", { type: "string", describe: "'latest' or a semver to restart onto" }),
|
|
436
|
+
run(handleRestart),
|
|
437
|
+
)
|
|
438
|
+
.command(
|
|
439
|
+
"upgrade <host>",
|
|
440
|
+
"Upgrade the conductor CLI on a daemon and restart it",
|
|
441
|
+
(cmd) => hostPositional(cmd)
|
|
442
|
+
.option("status", { type: "boolean", default: false, describe: "Only show the current/last upgrade run" })
|
|
443
|
+
.option("wait", { type: "boolean", default: false, describe: "Poll until the upgrade completes or fails" })
|
|
444
|
+
.option("wait-timeout", { type: "number", describe: "Seconds to wait with --wait (default 900)" }),
|
|
445
|
+
run(handleUpgrade),
|
|
446
|
+
)
|
|
447
|
+
.command(
|
|
448
|
+
"sessions <host>",
|
|
449
|
+
"List resumable AI sessions (claude/codex/...) on a daemon",
|
|
450
|
+
(cmd) => hostPositional(cmd)
|
|
451
|
+
.option("backend", { type: "array", string: true, describe: "Only these backends (repeat or comma-separate)" })
|
|
452
|
+
.option("limit", { type: "number", describe: "Max sessions (1-200)" }),
|
|
453
|
+
run(readOnly(handleSessions)),
|
|
454
|
+
)
|
|
455
|
+
.command(
|
|
456
|
+
"accounts <host>",
|
|
457
|
+
"List the codex accounts stored on a daemon (* = current)",
|
|
458
|
+
hostPositional,
|
|
459
|
+
run(readOnly(handleAccounts)),
|
|
460
|
+
)
|
|
461
|
+
.command(
|
|
462
|
+
"switch-account <host> [name]",
|
|
463
|
+
"Switch the active codex account on a daemon",
|
|
464
|
+
(cmd) => hostPositional(cmd)
|
|
465
|
+
.positional("name", { type: "string", describe: "Account name (see `daemon accounts`)" })
|
|
466
|
+
.option("account", { type: "string", describe: "Account name (alternative to the positional)" }),
|
|
467
|
+
run(handleSwitchAccount),
|
|
468
|
+
)
|
|
469
|
+
.command(
|
|
470
|
+
"commands",
|
|
471
|
+
"List, run and inspect a daemon's custom commands",
|
|
472
|
+
(cmd) => cmd
|
|
473
|
+
.command("list <host>", "List custom commands configured on a daemon", hostPositional, run(readOnly(handleCommandsList)))
|
|
474
|
+
.command(
|
|
475
|
+
"run <host> <key>",
|
|
476
|
+
"Start a custom command",
|
|
477
|
+
(sub) => hostPositional(sub)
|
|
478
|
+
.positional("key", { type: "string", describe: "Command key (see `commands list`)" })
|
|
479
|
+
.option("wait", { type: "boolean", default: false, describe: "Poll until the run finishes and print its output" })
|
|
480
|
+
.option("wait-timeout", { type: "number", describe: "Seconds to wait with --wait (default 3600)" }),
|
|
481
|
+
run(handleCommandsRun),
|
|
482
|
+
)
|
|
483
|
+
.command(
|
|
484
|
+
"status <host> <runId>",
|
|
485
|
+
"Show a custom command run's status, exit code and output tail",
|
|
486
|
+
(sub) => hostPositional(sub).positional("runId", { type: "string" }),
|
|
487
|
+
run(readOnly(handleCommandsStatus)),
|
|
488
|
+
)
|
|
489
|
+
.demandCommand(1),
|
|
490
|
+
)
|
|
491
|
+
.command(
|
|
492
|
+
"share",
|
|
493
|
+
"Share a daemon with another user, or accept a share invite",
|
|
494
|
+
(cmd) => cmd
|
|
495
|
+
.command(
|
|
496
|
+
"create <host>",
|
|
497
|
+
"Create an invite link for one of your online daemons",
|
|
498
|
+
(sub) => hostPositional(sub)
|
|
499
|
+
.option("workspace-root", { type: "string", describe: "Restrict the guest to this directory" }),
|
|
500
|
+
run(handleShareCreate),
|
|
501
|
+
)
|
|
502
|
+
.command(
|
|
503
|
+
"list",
|
|
504
|
+
"List the live shares of your daemons",
|
|
505
|
+
(sub) => sub.option("host", { type: "string", describe: "Only shares of this daemon" }),
|
|
506
|
+
run(readOnly(handleShareList)),
|
|
507
|
+
)
|
|
508
|
+
.command(
|
|
509
|
+
"revoke <id>",
|
|
510
|
+
"Revoke a share (as owner or grantee)",
|
|
511
|
+
(sub) => sub.positional("id", { type: "string" }),
|
|
512
|
+
run(handleShareRevoke),
|
|
513
|
+
)
|
|
514
|
+
.command(
|
|
515
|
+
"show-invite <token>",
|
|
516
|
+
"Show what an invite token/link grants",
|
|
517
|
+
(sub) => sub.positional("token", { type: "string", describe: "Invite token or full invite URL" }),
|
|
518
|
+
run(readOnly(handleShareShowInvite)),
|
|
519
|
+
)
|
|
520
|
+
.command(
|
|
521
|
+
"accept <token>",
|
|
522
|
+
"Accept a daemon share invite",
|
|
523
|
+
(sub) => sub.positional("token", { type: "string", describe: "Invite token or full invite URL" }),
|
|
524
|
+
run(handleShareAccept),
|
|
525
|
+
)
|
|
526
|
+
.demandCommand(1),
|
|
527
|
+
);
|
|
528
|
+
}
|