@love-moon/conductor-cli 0.11.2 → 0.13.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 +65 -0
- package/bin/conductor-config.js +4 -0
- package/bin/conductor-remote.js +98 -0
- package/bin/conductor-send-file.js +38 -16
- package/bin/conductor.js +6 -5
- package/package.json +5 -5
- package/src/daemon.js +200 -25
- package/src/fire-session-registry.js +19 -0
- package/src/remote/archive.js +129 -0
- package/src/remote/client.js +204 -0
- package/src/remote/cp.js +796 -0
- package/{bin/conductor-remote-exec.js → src/remote/exec.js} +222 -179
- package/src/remote/wait.js +133 -0
- package/src/remote-file-handlers.js +703 -0
|
@@ -134,3 +134,22 @@ export function pruneFireSessionRecords(dir, liveSessionNames, { fsImpl = fs } =
|
|
|
134
134
|
}
|
|
135
135
|
return pruned;
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
// Task ids a previous daemon left a tmux Fire running for. This is the only
|
|
139
|
+
// evidence available to a successor that has no `tmux` binary to enumerate
|
|
140
|
+
// sessions with, so it is what scopes that daemon's refusal to kill.
|
|
141
|
+
export function listFireSessionTaskIds(dir, { fsImpl = fs } = {}) {
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = fsImpl.readdirSync(dir);
|
|
145
|
+
} catch {
|
|
146
|
+
return new Set();
|
|
147
|
+
}
|
|
148
|
+
const taskIds = new Set();
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (!entry.endsWith(".json")) continue;
|
|
151
|
+
const record = readFireSessionRecord(dir, entry.slice(0, -".json".length), { fsImpl });
|
|
152
|
+
if (record?.taskId) taskIds.add(record.taskId);
|
|
153
|
+
}
|
|
154
|
+
return taskIds;
|
|
155
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory transfer, built on the single-file path plus `tar`.
|
|
3
|
+
*
|
|
4
|
+
* `remote cp -r` packs a directory locally (or remotely), moves the one
|
|
5
|
+
* resulting tarball through the ordinary transfer, then unpacks it on the other
|
|
6
|
+
* side. No new protocol: the wire still only ever carries one file.
|
|
7
|
+
*
|
|
8
|
+
* The remote halves run through `sh -c` with the paths passed as **positional
|
|
9
|
+
* arguments**, never interpolated into the script text. A directory literally
|
|
10
|
+
* named `; rm -rf /` is therefore just an awkward directory name.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
/** Where the target stages its tarball. Overridable for hosts without /tmp. */
|
|
20
|
+
export function remoteTempPath(env = process.env) {
|
|
21
|
+
const root = (env.CONDUCTOR_REMOTE_TMP || "/tmp").replace(/\/+$/, "") || "/tmp";
|
|
22
|
+
return `${root}/.conductor-rcp-${randomUUID()}.tgz`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function localTempPath() {
|
|
26
|
+
return path.join(os.tmpdir(), `.conductor-rcp-${randomUUID()}.tgz`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Unpack `$1` into `$2`, following scp: an existing destination directory
|
|
31
|
+
* receives the source *inside* it; a missing one becomes the copy itself.
|
|
32
|
+
* `$3` is the source's basename, which is the top-level entry in the tarball.
|
|
33
|
+
*/
|
|
34
|
+
export const EXTRACT_SCRIPT = `
|
|
35
|
+
set -e
|
|
36
|
+
tmp=$1; dest=$2; base=$3
|
|
37
|
+
if [ -d "$dest" ]; then
|
|
38
|
+
tar xzf "$tmp" -C "$dest"
|
|
39
|
+
else
|
|
40
|
+
parent=$(dirname "$dest")
|
|
41
|
+
mkdir -p "$parent"
|
|
42
|
+
# Unpack into a private staging dir, never straight into the parent: the
|
|
43
|
+
# tarball's top entry is the SOURCE's name, and an unrelated neighbour of
|
|
44
|
+
# the destination could already have it. Extracting in place would clobber
|
|
45
|
+
# that neighbour, and the rename below would then carry it off.
|
|
46
|
+
# Staging inside the parent keeps the final mv on one filesystem.
|
|
47
|
+
stage=$(mktemp -d "$parent/.conductor-rcp-XXXXXX")
|
|
48
|
+
tar xzf "$tmp" -C "$stage"
|
|
49
|
+
rm -rf "$dest"
|
|
50
|
+
mv "$stage/$base" "$dest"
|
|
51
|
+
rmdir "$stage"
|
|
52
|
+
fi
|
|
53
|
+
`.trim();
|
|
54
|
+
|
|
55
|
+
/** Pack directory `$1` into tarball `$2`, keeping `$1`'s own name inside. */
|
|
56
|
+
export const PACK_SCRIPT = `
|
|
57
|
+
set -e
|
|
58
|
+
src=$1; tmp=$2
|
|
59
|
+
if [ ! -d "$src" ]; then
|
|
60
|
+
echo "not a directory: $src" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
|
|
64
|
+
`.trim();
|
|
65
|
+
|
|
66
|
+
/** Remove the staged tarball. Never fails the transfer. */
|
|
67
|
+
export const CLEANUP_SCRIPT = 'rm -f "$1"';
|
|
68
|
+
|
|
69
|
+
/** `sh -c <script> sh <args...>` — the `sh` placeholder becomes $0. */
|
|
70
|
+
export function shellArgs(script, ...args) {
|
|
71
|
+
return ["-c", script, "sh", ...args];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runLocal(command, args, { spawnFn = spawn } = {}) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawnFn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
77
|
+
let stderr = "";
|
|
78
|
+
child.stderr?.on("data", (chunk) => {
|
|
79
|
+
// Bounded: a pathological tar could otherwise stream forever.
|
|
80
|
+
if (stderr.length < 8192) stderr += String(chunk);
|
|
81
|
+
});
|
|
82
|
+
child.on("error", (error) =>
|
|
83
|
+
reject(new Error(`failed to run ${command}: ${error.message}`)));
|
|
84
|
+
child.on("close", (code) => {
|
|
85
|
+
if (code === 0) return resolve();
|
|
86
|
+
reject(new Error(`${command} exited ${code}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Pack a local directory, preserving its own name as the top-level entry. */
|
|
92
|
+
export async function packLocalDirectory(sourceDir, tarball, deps = {}) {
|
|
93
|
+
await runLocal(
|
|
94
|
+
"tar",
|
|
95
|
+
["czf", tarball, "-C", path.dirname(sourceDir), path.basename(sourceDir)],
|
|
96
|
+
deps,
|
|
97
|
+
);
|
|
98
|
+
return tarball;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Unpack locally with the same scp semantics as `EXTRACT_SCRIPT`. */
|
|
102
|
+
export async function unpackLocalDirectory(tarball, destination, baseName, deps = {}) {
|
|
103
|
+
const fsp = deps.fsp || fs.promises;
|
|
104
|
+
let destStat = null;
|
|
105
|
+
try {
|
|
106
|
+
destStat = await fsp.stat(destination);
|
|
107
|
+
} catch {
|
|
108
|
+
destStat = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (destStat?.isDirectory()) {
|
|
112
|
+
await runLocal("tar", ["xzf", tarball, "-C", destination], deps);
|
|
113
|
+
return path.join(destination, baseName);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parent = path.dirname(destination);
|
|
117
|
+
await fsp.mkdir(parent, { recursive: true });
|
|
118
|
+
// Same reasoning as EXTRACT_SCRIPT: stage in a private directory so an
|
|
119
|
+
// existing neighbour that happens to share the source's name survives.
|
|
120
|
+
const stage = await fsp.mkdtemp(path.join(parent, ".conductor-rcp-"));
|
|
121
|
+
try {
|
|
122
|
+
await runLocal("tar", ["xzf", tarball, "-C", stage], deps);
|
|
123
|
+
await fsp.rm(destination, { recursive: true, force: true });
|
|
124
|
+
await fsp.rename(path.join(stage, baseName), destination);
|
|
125
|
+
} finally {
|
|
126
|
+
await fsp.rm(stage, { recursive: true, force: true }).catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
return destination;
|
|
129
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the `conductor remote` subcommands.
|
|
3
|
+
*
|
|
4
|
+
* `exec` and `cp` talk to the same backend, authenticate with the same token,
|
|
5
|
+
* resolve the same config file and report failures the same way, so all of that
|
|
6
|
+
* lives here rather than being copied per verb.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
|
|
12
|
+
import { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
|
|
13
|
+
import { envForExplicitConfigFile } from "../config-env.js";
|
|
14
|
+
import { resolveConductorConfigPath } from "../conductor-paths.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Following ssh: the remote command's exit code is passed through verbatim
|
|
18
|
+
* (0-254) and 255 is reserved for this CLI's own failures. Reusing 1/2/4 for
|
|
19
|
+
* local errors would make `grep` finding nothing (1) or `ls` on a missing path
|
|
20
|
+
* (2) indistinguishable from a network error or a usage mistake.
|
|
21
|
+
*/
|
|
22
|
+
export const EXIT = { OK: 0, CLI_ERROR: 255 };
|
|
23
|
+
|
|
24
|
+
export class UsageError extends Error {}
|
|
25
|
+
|
|
26
|
+
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
|
|
28
|
+
/** Accepts `500ms`, `30s`, `2m`, or a bare number of seconds. */
|
|
29
|
+
export function parseTimeoutMs(value, defaultMs) {
|
|
30
|
+
if (value === undefined || value === null || value === "") {
|
|
31
|
+
return defaultMs;
|
|
32
|
+
}
|
|
33
|
+
const raw = String(value).trim().toLowerCase();
|
|
34
|
+
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
|
|
35
|
+
if (!match) {
|
|
36
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
37
|
+
}
|
|
38
|
+
const amount = Number.parseFloat(match[1]);
|
|
39
|
+
const unit = match[2] || "s";
|
|
40
|
+
const multiplier = unit === "ms" ? 1 : unit === "m" ? 60_000 : 1_000;
|
|
41
|
+
const ms = Math.round(amount * multiplier);
|
|
42
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
43
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
44
|
+
}
|
|
45
|
+
return ms;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadCliConfig(configFile, env = process.env) {
|
|
49
|
+
const configPath = resolveConductorConfigPath(configFile, env);
|
|
50
|
+
const configEnv = envForExplicitConfigFile(configFile, env);
|
|
51
|
+
if (fs.existsSync(configPath)) {
|
|
52
|
+
return loadConfig(configPath, { env: configEnv });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
56
|
+
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
57
|
+
if (agentToken && backendUrl) {
|
|
58
|
+
return new ConductorConfig({ agentToken, backendUrl });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return loadConfig(configPath, { env: configEnv });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function authHeaders(config) {
|
|
65
|
+
return { Authorization: `Bearer ${config.agentToken}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function toApiError(response) {
|
|
69
|
+
const text = await response.text();
|
|
70
|
+
let payload = null;
|
|
71
|
+
try {
|
|
72
|
+
payload = text ? JSON.parse(text) : null;
|
|
73
|
+
} catch {
|
|
74
|
+
payload = null;
|
|
75
|
+
}
|
|
76
|
+
const message = payload?.error || text.trim() || `HTTP ${response.status}`;
|
|
77
|
+
const error = new Error(message);
|
|
78
|
+
error.status = response.status;
|
|
79
|
+
// A 409 on a chunked upload means "wrong offset, here is the real one".
|
|
80
|
+
// Carrying it on the error is what lets the caller resync instead of failing.
|
|
81
|
+
if (typeof payload?.receivedBytes === "number") {
|
|
82
|
+
error.receivedBytes = payload.receivedBytes;
|
|
83
|
+
}
|
|
84
|
+
return error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a failed request is worth retrying.
|
|
89
|
+
*
|
|
90
|
+
* Network-level failures have no `status` at all; those are the blips resume
|
|
91
|
+
* exists for. Among HTTP statuses only 5xx and 429 can plausibly succeed on a
|
|
92
|
+
* second try — retrying a 403 or a 413 just wastes the user's time.
|
|
93
|
+
*/
|
|
94
|
+
export function isRetryable(error) {
|
|
95
|
+
const status = error?.status;
|
|
96
|
+
if (status === undefined || status === null) return true;
|
|
97
|
+
return status >= 500 || status === 429;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Retry `fn` on transient failures, handing it the attempt number. `retryable`
|
|
102
|
+
* narrows what counts as transient: a caller whose request is not idempotent
|
|
103
|
+
* (starting a command) must not retry a 5xx that may already have taken effect.
|
|
104
|
+
*/
|
|
105
|
+
export async function withRetry(fn, {
|
|
106
|
+
attempts = 4, baseDelayMs = 500, sleep = delay, retryable = isRetryable,
|
|
107
|
+
} = {}) {
|
|
108
|
+
let lastError;
|
|
109
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
110
|
+
try {
|
|
111
|
+
return await fn(attempt);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
lastError = error;
|
|
114
|
+
// An aborted transfer is the caller's own deadline firing, not a blip.
|
|
115
|
+
if (error?.name === "AbortError" || !retryable(error)) throw error;
|
|
116
|
+
if (attempt === attempts - 1) break;
|
|
117
|
+
await sleep(baseDelayMs * 2 ** attempt);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw lastError;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function callApi(config, method, pathname, body, fetchImpl) {
|
|
124
|
+
const url = new URL(pathname, config.backendUrl);
|
|
125
|
+
const response = await fetchImpl(url.toString(), {
|
|
126
|
+
method,
|
|
127
|
+
headers: {
|
|
128
|
+
...authHeaders(config),
|
|
129
|
+
Accept: "application/json",
|
|
130
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
131
|
+
},
|
|
132
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const text = await response.text();
|
|
136
|
+
let payload = null;
|
|
137
|
+
try {
|
|
138
|
+
payload = text ? JSON.parse(text) : null;
|
|
139
|
+
} catch {
|
|
140
|
+
payload = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const message = payload?.error || text.trim() || `HTTP ${response.status}`;
|
|
145
|
+
const error = new Error(message);
|
|
146
|
+
error.status = response.status;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return payload;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stream a body up to the backend. Kept separate from `callApi` because the
|
|
154
|
+
* body is a stream, not JSON: buffering a multi-hundred-MB file just to reuse
|
|
155
|
+
* one helper would defeat the point of the whole transfer design.
|
|
156
|
+
*/
|
|
157
|
+
export async function putStream(config, pathname, stream, {
|
|
158
|
+
sizeBytes, fetchImpl, signal, contentRange,
|
|
159
|
+
}) {
|
|
160
|
+
const url = new URL(pathname, config.backendUrl);
|
|
161
|
+
const response = await fetchImpl(url.toString(), {
|
|
162
|
+
method: "PUT",
|
|
163
|
+
headers: {
|
|
164
|
+
...authHeaders(config),
|
|
165
|
+
Accept: "application/json",
|
|
166
|
+
"Content-Type": "application/octet-stream",
|
|
167
|
+
...(Number.isFinite(sizeBytes) ? { "Content-Length": String(sizeBytes) } : {}),
|
|
168
|
+
...(contentRange ? { "Content-Range": contentRange } : {}),
|
|
169
|
+
},
|
|
170
|
+
body: stream,
|
|
171
|
+
// Node's fetch requires this for a streaming request body.
|
|
172
|
+
duplex: "half",
|
|
173
|
+
...(signal ? { signal } : {}),
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
throw await toApiError(response);
|
|
177
|
+
}
|
|
178
|
+
const text = await response.text();
|
|
179
|
+
try {
|
|
180
|
+
return text ? JSON.parse(text) : null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Open a streaming download. The caller owns consuming `response.body`. */
|
|
187
|
+
export async function getStream(config, pathname, { fetchImpl, signal, rangeFrom }) {
|
|
188
|
+
const url = new URL(pathname, config.backendUrl);
|
|
189
|
+
const response = await fetchImpl(url.toString(), {
|
|
190
|
+
method: "GET",
|
|
191
|
+
headers: {
|
|
192
|
+
...authHeaders(config),
|
|
193
|
+
Accept: "application/octet-stream",
|
|
194
|
+
...(rangeFrom > 0 ? { Range: `bytes=${rangeFrom}-` } : {}),
|
|
195
|
+
},
|
|
196
|
+
...(signal ? { signal } : {}),
|
|
197
|
+
});
|
|
198
|
+
// 416 is not a failure here: it means the local `.part` is already at or past
|
|
199
|
+
// the full size, so there is nothing left to fetch.
|
|
200
|
+
if (!response.ok && response.status !== 416) {
|
|
201
|
+
throw await toApiError(response);
|
|
202
|
+
}
|
|
203
|
+
return response;
|
|
204
|
+
}
|