@love-moon/conductor-cli 0.11.2 → 0.12.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 +41 -0
- package/bin/conductor-config.js +4 -0
- package/bin/conductor-remote.js +93 -0
- package/bin/conductor-send-file.js +38 -16
- package/bin/conductor.js +6 -5
- package/package.json +5 -5
- package/src/daemon.js +149 -24
- package/src/remote/archive.js +129 -0
- package/src/remote/client.js +198 -0
- package/src/remote/cp.js +796 -0
- package/{bin/conductor-remote-exec.js → src/remote/exec.js} +126 -154
- package/src/remote-file-handlers.js +703 -0
|
@@ -1,32 +1,24 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* conductor remote
|
|
5
|
-
*
|
|
6
|
-
* conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
7
|
-
* conductor remote-exec --target ubuntu -- bash -lc "pnpm build 2>&1 | tail -20"
|
|
2
|
+
* `conductor remote exec` — run one command on another daemon's host.
|
|
8
3
|
*
|
|
9
4
|
* The command is sent as argv and spawned without a shell on the target, so
|
|
10
5
|
* quoting is not re-interpreted remotely. Pass `-- bash -lc "..."` when pipes
|
|
11
6
|
* or globs are actually wanted.
|
|
12
7
|
*/
|
|
13
8
|
|
|
14
|
-
import fs from "node:fs";
|
|
15
|
-
import path from "node:path";
|
|
16
9
|
import process from "node:process";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
10
|
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
import {
|
|
12
|
+
EXIT,
|
|
13
|
+
UsageError,
|
|
14
|
+
callApi,
|
|
15
|
+
delay,
|
|
16
|
+
loadCliConfig,
|
|
17
|
+
parseTimeoutMs as parseSharedTimeoutMs,
|
|
18
|
+
} from "./client.js";
|
|
19
|
+
|
|
20
|
+
export { EXIT, UsageError };
|
|
22
21
|
|
|
23
|
-
/**
|
|
24
|
-
* Following ssh: the remote command's exit code is passed through verbatim
|
|
25
|
-
* (0-254) and 255 is reserved for this CLI's own failures. Reusing 1/2/4 for
|
|
26
|
-
* local errors would make `grep` finding nothing (1) or `ls` on a missing path
|
|
27
|
-
* (2) indistinguishable from a network error or a usage mistake.
|
|
28
|
-
*/
|
|
29
|
-
const EXIT = { OK: 0, CLI_ERROR: 255 };
|
|
30
22
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
31
23
|
const POLL_INTERVAL_MS = 1_000;
|
|
32
24
|
/** How long a single request may block server-side before we switch to polling. */
|
|
@@ -50,12 +42,6 @@ const BOOL_FLAGS = new Map([
|
|
|
50
42
|
["-h", "help"],
|
|
51
43
|
]);
|
|
52
44
|
|
|
53
|
-
const isMainModule = (() => {
|
|
54
|
-
const currentFile = fileURLToPath(import.meta.url);
|
|
55
|
-
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
56
|
-
return entryFile === currentFile;
|
|
57
|
-
})();
|
|
58
|
-
|
|
59
45
|
/**
|
|
60
46
|
* Split argv into flags and the remote command.
|
|
61
47
|
*
|
|
@@ -116,81 +102,17 @@ export function parseArgs(argv) {
|
|
|
116
102
|
return { options, command };
|
|
117
103
|
}
|
|
118
104
|
|
|
119
|
-
export class UsageError extends Error {}
|
|
120
|
-
|
|
121
105
|
/** Accepts `500ms`, `30s`, `2m`, or a bare number of seconds. */
|
|
122
106
|
export function parseTimeoutMs(value) {
|
|
123
|
-
|
|
124
|
-
return DEFAULT_TIMEOUT_MS;
|
|
125
|
-
}
|
|
126
|
-
const raw = String(value).trim().toLowerCase();
|
|
127
|
-
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
|
|
128
|
-
if (!match) {
|
|
129
|
-
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
130
|
-
}
|
|
131
|
-
const amount = Number.parseFloat(match[1]);
|
|
132
|
-
const unit = match[2] || "s";
|
|
133
|
-
const multiplier = unit === "ms" ? 1 : unit === "m" ? 60_000 : 1_000;
|
|
134
|
-
const ms = Math.round(amount * multiplier);
|
|
135
|
-
if (!Number.isFinite(ms) || ms <= 0) {
|
|
136
|
-
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
137
|
-
}
|
|
138
|
-
return ms;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function loadCliConfig(configFile, env = process.env) {
|
|
142
|
-
const configPath = resolveConductorConfigPath(configFile, env);
|
|
143
|
-
const configEnv = envForExplicitConfigFile(configFile, env);
|
|
144
|
-
if (fs.existsSync(configPath)) {
|
|
145
|
-
return loadConfig(configPath, { env: configEnv });
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
149
|
-
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
150
|
-
if (agentToken && backendUrl) {
|
|
151
|
-
return new ConductorConfig({ agentToken, backendUrl });
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return loadConfig(configPath, { env: configEnv });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function callApi(config, method, pathname, body, fetchImpl) {
|
|
158
|
-
const url = new URL(pathname, config.backendUrl);
|
|
159
|
-
const response = await fetchImpl(url.toString(), {
|
|
160
|
-
method,
|
|
161
|
-
headers: {
|
|
162
|
-
Authorization: `Bearer ${config.agentToken}`,
|
|
163
|
-
Accept: "application/json",
|
|
164
|
-
...(body ? { "Content-Type": "application/json" } : {}),
|
|
165
|
-
},
|
|
166
|
-
...(body ? { body: JSON.stringify(body) } : {}),
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
const text = await response.text();
|
|
170
|
-
let payload = null;
|
|
171
|
-
try {
|
|
172
|
-
payload = text ? JSON.parse(text) : null;
|
|
173
|
-
} catch {
|
|
174
|
-
payload = null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (!response.ok) {
|
|
178
|
-
const message = payload?.error || text.trim() || `HTTP ${response.status}`;
|
|
179
|
-
const error = new Error(message);
|
|
180
|
-
error.status = response.status;
|
|
181
|
-
throw error;
|
|
182
|
-
}
|
|
183
|
-
return payload;
|
|
107
|
+
return parseSharedTimeoutMs(value, DEFAULT_TIMEOUT_MS);
|
|
184
108
|
}
|
|
185
109
|
|
|
186
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
187
|
-
|
|
188
110
|
export function showHelp(consoleImpl = console) {
|
|
189
|
-
consoleImpl.log(`conductor remote
|
|
111
|
+
consoleImpl.log(`conductor remote exec - run a command on another daemon's host
|
|
190
112
|
|
|
191
113
|
Usage:
|
|
192
|
-
conductor remote
|
|
193
|
-
conductor remote
|
|
114
|
+
conductor remote exec --target <daemon> [options] <command> [args...]
|
|
115
|
+
conductor remote exec --target <daemon> [options] -- <command> [args...]
|
|
194
116
|
|
|
195
117
|
Options:
|
|
196
118
|
-t, --target <daemon> Daemon name to run on (required)
|
|
@@ -205,17 +127,108 @@ Options:
|
|
|
205
127
|
|
|
206
128
|
Notes:
|
|
207
129
|
The command is spawned without a shell. For pipes, globs or redirection use:
|
|
208
|
-
conductor remote
|
|
130
|
+
conductor remote exec -t ubuntu -- bash -lc "ls | wc -l"
|
|
209
131
|
|
|
210
132
|
Exit codes follow ssh: the remote command's own code is passed through, and
|
|
211
133
|
255 means this CLI failed (bad usage, daemon offline, network error).
|
|
212
134
|
|
|
213
135
|
Examples:
|
|
214
|
-
conductor remote
|
|
215
|
-
conductor remote
|
|
136
|
+
conductor remote exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
137
|
+
conductor remote exec -t ubuntu -w /srv/app -- git log --oneline -5
|
|
216
138
|
`);
|
|
217
139
|
}
|
|
218
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Run one command on `target` and return the final run record.
|
|
143
|
+
*
|
|
144
|
+
* This is the programmatic half of `runRemoteExec`: same two-phase POST-then-poll
|
|
145
|
+
* behaviour, no argv parsing and no printing. `remote cp -r` uses it to drive
|
|
146
|
+
* `tar` on the target.
|
|
147
|
+
*/
|
|
148
|
+
export async function execRemote(config, target, command, options = {}) {
|
|
149
|
+
const {
|
|
150
|
+
args = [],
|
|
151
|
+
workspace,
|
|
152
|
+
env,
|
|
153
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
154
|
+
fetchImpl = globalThis.fetch,
|
|
155
|
+
sleep = delay,
|
|
156
|
+
now = () => Date.now(),
|
|
157
|
+
killOnTimeout = false,
|
|
158
|
+
} = options;
|
|
159
|
+
|
|
160
|
+
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
161
|
+
const deadline = now() + timeoutMs;
|
|
162
|
+
|
|
163
|
+
let run = await callApi(config, "POST", basePath, {
|
|
164
|
+
command,
|
|
165
|
+
args,
|
|
166
|
+
...(workspace ? { workspace } : {}),
|
|
167
|
+
...(env && Object.keys(env).length > 0 ? { env } : {}),
|
|
168
|
+
// Deliberately short, and independent of the overall deadline: that is
|
|
169
|
+
// owned by the poll loop below. Handing the daemon the full budget would
|
|
170
|
+
// make one HTTP request block for it and leave the loop unreachable.
|
|
171
|
+
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
172
|
+
}, fetchImpl);
|
|
173
|
+
|
|
174
|
+
let pollError = null;
|
|
175
|
+
while (run?.status === "running" && now() < deadline) {
|
|
176
|
+
if (!run.runId) {
|
|
177
|
+
pollError = new Error("daemon reported a running command but returned no runId");
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
await sleep(POLL_INTERVAL_MS);
|
|
181
|
+
try {
|
|
182
|
+
run = await callApi(
|
|
183
|
+
config,
|
|
184
|
+
"GET",
|
|
185
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
186
|
+
null,
|
|
187
|
+
fetchImpl,
|
|
188
|
+
);
|
|
189
|
+
pollError = null;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// A saturated or briefly unreachable daemon can fail one status poll.
|
|
192
|
+
// Keep waiting until the caller's own deadline rather than aborting a
|
|
193
|
+
// long-running command that is still perfectly healthy.
|
|
194
|
+
pollError = error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let killError = null;
|
|
199
|
+
if (run?.status === "running" && killOnTimeout && run.runId) {
|
|
200
|
+
try {
|
|
201
|
+
run = await callApi(
|
|
202
|
+
config,
|
|
203
|
+
"DELETE",
|
|
204
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
205
|
+
null,
|
|
206
|
+
fetchImpl,
|
|
207
|
+
);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// Failing to stop it must not cost the caller the output we already have.
|
|
210
|
+
killError = error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { run, pollError, killError, basePath };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `execRemote` for callers that only care whether it worked. Throws with the
|
|
219
|
+
* remote's own stderr, which is what the user needs to see.
|
|
220
|
+
*/
|
|
221
|
+
export async function execRemoteOrThrow(config, target, command, options = {}) {
|
|
222
|
+
const { run } = await execRemote(config, target, command, options);
|
|
223
|
+
if (run?.status === "completed" && run.exitCode === 0) return run;
|
|
224
|
+
|
|
225
|
+
const detail = (run?.stderrTail || run?.stdoutTail || run?.error || "").trim();
|
|
226
|
+
const what = run?.status === "running"
|
|
227
|
+
? `timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`
|
|
228
|
+
: `exited ${run?.exitCode ?? "?"}`;
|
|
229
|
+
throw new Error(detail ? `${what} on ${target}: ${detail}` : `${what} on ${target}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
219
232
|
export async function runRemoteExec(argv, deps = {}) {
|
|
220
233
|
const consoleImpl = deps.console || console;
|
|
221
234
|
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
@@ -265,66 +278,31 @@ export async function runRemoteExec(argv, deps = {}) {
|
|
|
265
278
|
return EXIT.CLI_ERROR;
|
|
266
279
|
}
|
|
267
280
|
|
|
268
|
-
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
269
|
-
const deadline = now() + timeoutMs;
|
|
270
|
-
|
|
271
281
|
let run;
|
|
282
|
+
let pollError = null;
|
|
283
|
+
let basePath;
|
|
272
284
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
285
|
+
let killError;
|
|
286
|
+
({ run, pollError, killError, basePath } = await execRemote(config, target, command[0], {
|
|
275
287
|
args: command.slice(1),
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
288
|
+
workspace: options.workspace,
|
|
289
|
+
env: options.env,
|
|
290
|
+
timeoutMs,
|
|
291
|
+
fetchImpl,
|
|
292
|
+
sleep,
|
|
293
|
+
now,
|
|
294
|
+
killOnTimeout: options.killOnTimeout,
|
|
295
|
+
}));
|
|
296
|
+
if (killError) {
|
|
297
|
+
consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${killError.message}`);
|
|
298
|
+
} else if (options.killOnTimeout && run?.status === "cancelled") {
|
|
299
|
+
consoleImpl.error(`[conductor] deadline reached; stopped the command on ${target}`);
|
|
300
|
+
}
|
|
284
301
|
} catch (error) {
|
|
285
302
|
consoleImpl.error(`Error: ${error.message}`);
|
|
286
303
|
return EXIT.CLI_ERROR;
|
|
287
304
|
}
|
|
288
305
|
|
|
289
|
-
let pollError = null;
|
|
290
|
-
while (run?.status === "running" && now() < deadline) {
|
|
291
|
-
if (!run.runId) {
|
|
292
|
-
pollError = new Error("daemon reported a running command but returned no runId");
|
|
293
|
-
break;
|
|
294
|
-
}
|
|
295
|
-
await sleep(POLL_INTERVAL_MS);
|
|
296
|
-
try {
|
|
297
|
-
run = await callApi(
|
|
298
|
-
config,
|
|
299
|
-
"GET",
|
|
300
|
-
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
301
|
-
null,
|
|
302
|
-
fetchImpl,
|
|
303
|
-
);
|
|
304
|
-
pollError = null;
|
|
305
|
-
} catch (error) {
|
|
306
|
-
// A saturated or briefly unreachable daemon can fail one status poll.
|
|
307
|
-
// Keep waiting until the caller's own deadline rather than aborting a
|
|
308
|
-
// long-running command that is still perfectly healthy.
|
|
309
|
-
pollError = error;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (run?.status === "running" && options.killOnTimeout && run.runId) {
|
|
314
|
-
try {
|
|
315
|
-
run = await callApi(
|
|
316
|
-
config,
|
|
317
|
-
"DELETE",
|
|
318
|
-
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
319
|
-
null,
|
|
320
|
-
fetchImpl,
|
|
321
|
-
);
|
|
322
|
-
consoleImpl.error(`[conductor] deadline reached; stopped the command on ${target}`);
|
|
323
|
-
} catch (error) {
|
|
324
|
-
consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${error.message}`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
306
|
if (options.json) {
|
|
329
307
|
consoleImpl.log(JSON.stringify(run, null, 2));
|
|
330
308
|
} else {
|
|
@@ -357,9 +335,3 @@ export async function runRemoteExec(argv, deps = {}) {
|
|
|
357
335
|
}
|
|
358
336
|
return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
|
|
359
337
|
}
|
|
360
|
-
|
|
361
|
-
if (isMainModule) {
|
|
362
|
-
// `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
|
|
363
|
-
// and exiting outright truncates them. Let the loop drain and end naturally.
|
|
364
|
-
process.exitCode = await runRemoteExec(process.argv.slice(2));
|
|
365
|
-
}
|