@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.
@@ -0,0 +1,198 @@
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
+ /** Retry `fn` on transient failures, handing it the attempt number. */
101
+ export async function withRetry(fn, { attempts = 4, baseDelayMs = 500, sleep = delay } = {}) {
102
+ let lastError;
103
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
104
+ try {
105
+ return await fn(attempt);
106
+ } catch (error) {
107
+ lastError = error;
108
+ // An aborted transfer is the caller's own deadline firing, not a blip.
109
+ if (error?.name === "AbortError" || !isRetryable(error)) throw error;
110
+ if (attempt === attempts - 1) break;
111
+ await sleep(baseDelayMs * 2 ** attempt);
112
+ }
113
+ }
114
+ throw lastError;
115
+ }
116
+
117
+ export async function callApi(config, method, pathname, body, fetchImpl) {
118
+ const url = new URL(pathname, config.backendUrl);
119
+ const response = await fetchImpl(url.toString(), {
120
+ method,
121
+ headers: {
122
+ ...authHeaders(config),
123
+ Accept: "application/json",
124
+ ...(body ? { "Content-Type": "application/json" } : {}),
125
+ },
126
+ ...(body ? { body: JSON.stringify(body) } : {}),
127
+ });
128
+
129
+ const text = await response.text();
130
+ let payload = null;
131
+ try {
132
+ payload = text ? JSON.parse(text) : null;
133
+ } catch {
134
+ payload = null;
135
+ }
136
+
137
+ if (!response.ok) {
138
+ const message = payload?.error || text.trim() || `HTTP ${response.status}`;
139
+ const error = new Error(message);
140
+ error.status = response.status;
141
+ throw error;
142
+ }
143
+ return payload;
144
+ }
145
+
146
+ /**
147
+ * Stream a body up to the backend. Kept separate from `callApi` because the
148
+ * body is a stream, not JSON: buffering a multi-hundred-MB file just to reuse
149
+ * one helper would defeat the point of the whole transfer design.
150
+ */
151
+ export async function putStream(config, pathname, stream, {
152
+ sizeBytes, fetchImpl, signal, contentRange,
153
+ }) {
154
+ const url = new URL(pathname, config.backendUrl);
155
+ const response = await fetchImpl(url.toString(), {
156
+ method: "PUT",
157
+ headers: {
158
+ ...authHeaders(config),
159
+ Accept: "application/json",
160
+ "Content-Type": "application/octet-stream",
161
+ ...(Number.isFinite(sizeBytes) ? { "Content-Length": String(sizeBytes) } : {}),
162
+ ...(contentRange ? { "Content-Range": contentRange } : {}),
163
+ },
164
+ body: stream,
165
+ // Node's fetch requires this for a streaming request body.
166
+ duplex: "half",
167
+ ...(signal ? { signal } : {}),
168
+ });
169
+ if (!response.ok) {
170
+ throw await toApiError(response);
171
+ }
172
+ const text = await response.text();
173
+ try {
174
+ return text ? JSON.parse(text) : null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /** Open a streaming download. The caller owns consuming `response.body`. */
181
+ export async function getStream(config, pathname, { fetchImpl, signal, rangeFrom }) {
182
+ const url = new URL(pathname, config.backendUrl);
183
+ const response = await fetchImpl(url.toString(), {
184
+ method: "GET",
185
+ headers: {
186
+ ...authHeaders(config),
187
+ Accept: "application/octet-stream",
188
+ ...(rangeFrom > 0 ? { Range: `bytes=${rangeFrom}-` } : {}),
189
+ },
190
+ ...(signal ? { signal } : {}),
191
+ });
192
+ // 416 is not a failure here: it means the local `.part` is already at or past
193
+ // the full size, so there is nothing left to fetch.
194
+ if (!response.ok && response.status !== 416) {
195
+ throw await toApiError(response);
196
+ }
197
+ return response;
198
+ }