@love-moon/conductor-cli 0.11.1 → 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.
@@ -1,32 +1,24 @@
1
- #!/usr/bin/env node
2
-
3
1
  /**
4
- * conductor remote-exec — run one command on another daemon's host.
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 { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
20
- import { envForExplicitConfigFile } from "../src/config-env.js";
21
- import { resolveConductorConfigPath } from "../src/conductor-paths.js";
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
- if (value === undefined || value === null || value === "") {
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-exec - run a command on another daemon's host
111
+ consoleImpl.log(`conductor remote exec - run a command on another daemon's host
190
112
 
191
113
  Usage:
192
- conductor remote-exec --target <daemon> [options] <command> [args...]
193
- conductor remote-exec --target <daemon> [options] -- <command> [args...]
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-exec -t ubuntu -- bash -lc "ls | wc -l"
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-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
215
- conductor remote-exec -t ubuntu -w /srv/app -- git log --oneline -5
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
- run = await callApi(config, "POST", basePath, {
274
- command: command[0],
285
+ let killError;
286
+ ({ run, pollError, killError, basePath } = await execRemote(config, target, command[0], {
275
287
  args: command.slice(1),
276
- ...(options.workspace ? { workspace: options.workspace } : {}),
277
- ...(Object.keys(options.env).length > 0 ? { env: options.env } : {}),
278
- // Deliberately short, and independent of `--timeout`: the overall deadline
279
- // is owned by the poll loop below. Handing the daemon the full deadline
280
- // would make one HTTP request block for it, and would leave the loop
281
- // unreachable because the POST alone would consume the whole budget.
282
- timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
283
- }, fetchImpl);
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
- }