@love-moon/conductor-cli 0.15.1 → 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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Thin authenticated JSON client for the web API routes the SDK does not wrap.
3
+ *
4
+ * The entity subcommands (`task`, `project`, `issue`, `daemon`, `settings`,
5
+ * `search`, `auth`) mirror what the web frontend can do. Most of those routes
6
+ * have no SDK method, and wrapping each one in `@love-moon/conductor-sdk` would
7
+ * tie every new CLI verb to an SDK release. This helper talks to the same
8
+ * `/api/...` routes the frontend calls, with the same Bearer token the SDK uses.
9
+ *
10
+ * Errors carry `statusCode` and `details` (the parsed error body), the same shape
11
+ * as the SDK's `BackendApiError`, so `reportError` / `exitCodeForError` in
12
+ * `entity-helpers.js` print the server's reason and map 401/403/404/400 to the
13
+ * RFC exit codes without any per-command handling.
14
+ */
15
+
16
+ import process from "node:process";
17
+
18
+ import { emitDryRun, loadConductorConfig, makeDryRunPayload } from "./entity-helpers.js";
19
+
20
+ const DEFAULT_TIMEOUT_MS = 30_000;
21
+
22
+ export class BackendHttpError extends Error {
23
+ constructor(message, statusCode, details) {
24
+ super(message);
25
+ this.name = "BackendApiError";
26
+ this.statusCode = statusCode;
27
+ this.details = details;
28
+ }
29
+ }
30
+
31
+ function baseUrlOf(config) {
32
+ const raw = String(config?.backendUrl || "").trim().replace(/\/+$/, "");
33
+ if (!raw) {
34
+ throw new Error("Conductor backendUrl is not configured; run `conductor config` first");
35
+ }
36
+ // Every route lives under /api; tolerate a backendUrl that already ends in it.
37
+ return raw.endsWith("/api") ? raw.slice(0, -4) : raw;
38
+ }
39
+
40
+ /** Build `/api/<segments>` with every dynamic segment URL-encoded. */
41
+ export function apiPath(...segments) {
42
+ return `/api/${segments.map((segment) => encodeURIComponent(String(segment))).join("/")}`;
43
+ }
44
+
45
+ function appendQuery(url, query) {
46
+ if (!query) return;
47
+ for (const [key, value] of Object.entries(query)) {
48
+ if (value === undefined || value === null || value === "") continue;
49
+ url.searchParams.set(key, String(value));
50
+ }
51
+ }
52
+
53
+ async function readBody(response) {
54
+ const text = await response.text();
55
+ if (!text) return null;
56
+ try {
57
+ return JSON.parse(text);
58
+ } catch {
59
+ return text;
60
+ }
61
+ }
62
+
63
+ export function createBackendHttp({ config, fetchImpl, env = process.env }) {
64
+ const doFetch = fetchImpl ?? (typeof fetch === "function" ? fetch.bind(globalThis) : null);
65
+ if (!doFetch) {
66
+ throw new Error("Global fetch is not available; provide fetchImpl");
67
+ }
68
+ const base = baseUrlOf(config);
69
+
70
+ function buildUrl(pathname, query) {
71
+ const url = new URL(`${base}${pathname}`);
72
+ appendQuery(url, query);
73
+ return url.toString();
74
+ }
75
+
76
+ async function request(method, pathname, options = {}) {
77
+ const url = buildUrl(pathname, options.query);
78
+ const hasBody = options.body !== undefined;
79
+ const launchedByDaemon = env?.CONDUCTOR_LAUNCHED_BY_DAEMON === "1";
80
+ const headers = {
81
+ Authorization: `Bearer ${config.agentToken}`,
82
+ Accept: "application/json",
83
+ ...(hasBody ? { "Content-Type": "application/json" } : {}),
84
+ // Same rule as the SDK: an agent launched by a daemon identifies itself so
85
+ // agent-gated routes (scheduled messages) apply their agent policy.
86
+ ...(options.agentActor && launchedByDaemon ? { "X-Conductor-Actor": "agent" } : {}),
87
+ ...(options.headers || {}),
88
+ };
89
+ const controller = new AbortController();
90
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
91
+ let response;
92
+ try {
93
+ response = await doFetch(url, {
94
+ method,
95
+ headers,
96
+ ...(hasBody ? { body: JSON.stringify(options.body) } : {}),
97
+ signal: controller.signal,
98
+ });
99
+ } catch (error) {
100
+ const reason = error?.name === "AbortError" ? "timed out" : (error?.message || String(error));
101
+ throw new BackendHttpError(`Backend request failed: ${reason}`, undefined, undefined);
102
+ } finally {
103
+ clearTimeout(timer);
104
+ }
105
+ const payload = await readBody(response);
106
+ if (!response.ok) {
107
+ throw new BackendHttpError(`Backend responded with ${response.status}`, response.status, payload);
108
+ }
109
+ return payload;
110
+ }
111
+
112
+ /** POST a multipart `FormData` body (fetch sets the boundary header itself). */
113
+ async function upload(pathname, formData, options = {}) {
114
+ const controller = new AbortController();
115
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 120_000);
116
+ let response;
117
+ try {
118
+ response = await doFetch(buildUrl(pathname, options.query), {
119
+ method: "POST",
120
+ headers: { Authorization: `Bearer ${config.agentToken}`, Accept: "application/json" },
121
+ body: formData,
122
+ signal: controller.signal,
123
+ });
124
+ } catch (error) {
125
+ const reason = error?.name === "AbortError" ? "timed out" : (error?.message || String(error));
126
+ throw new BackendHttpError(`Upload failed: ${reason}`, undefined, undefined);
127
+ } finally {
128
+ clearTimeout(timer);
129
+ }
130
+ const payload = await readBody(response);
131
+ if (!response.ok) {
132
+ throw new BackendHttpError(`Backend responded with ${response.status}`, response.status, payload);
133
+ }
134
+ return payload;
135
+ }
136
+
137
+ /** GET a binary body; resolves to the raw fetch Response (caller streams it). */
138
+ async function download(pathname, options = {}) {
139
+ let response;
140
+ try {
141
+ response = await doFetch(buildUrl(pathname, options.query), {
142
+ method: "GET",
143
+ headers: { Authorization: `Bearer ${config.agentToken}`, Accept: "*/*" },
144
+ });
145
+ } catch (error) {
146
+ throw new BackendHttpError(`Download failed: ${error?.message || String(error)}`, undefined, undefined);
147
+ }
148
+ if (!response.ok) {
149
+ throw new BackendHttpError(`Backend responded with ${response.status}`, response.status, await readBody(response));
150
+ }
151
+ return response;
152
+ }
153
+
154
+ return {
155
+ baseUrl: base,
156
+ url: buildUrl,
157
+ request,
158
+ upload,
159
+ download,
160
+ get: (pathname, options) => request("GET", pathname, options),
161
+ post: (pathname, body, options = {}) => request("POST", pathname, { ...options, body }),
162
+ put: (pathname, body, options = {}) => request("PUT", pathname, { ...options, body }),
163
+ patch: (pathname, body, options = {}) => request("PATCH", pathname, { ...options, body }),
164
+ delete: (pathname, options) => request("DELETE", pathname, options),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Resolve config the same way `buildApis` does (tests inject `deps.config` and
170
+ * `deps.fetchImpl`) and return a client.
171
+ */
172
+ export async function buildHttp(deps = {}) {
173
+ const config = deps.config || (await loadConductorConfig(deps));
174
+ return createBackendHttp({ config, fetchImpl: deps.fetchImpl, env: deps.env });
175
+ }
176
+
177
+ /**
178
+ * Run a write request, or print it instead when `--dry-run` is set.
179
+ * Returns `{ dryRun: true }` for a dry run, otherwise `{ dryRun: false, data }`.
180
+ */
181
+ export async function sendOrPreview(http, argv, deps, method, pathname, body, options = {}) {
182
+ if (argv.dryRun) {
183
+ emitDryRun(
184
+ deps.stdout,
185
+ argv.json,
186
+ makeDryRunPayload(method, http.url(pathname, options.query), body, options.note ? { note: options.note } : {}),
187
+ );
188
+ return { dryRun: true, data: null };
189
+ }
190
+ const data = await http.request(method, pathname, { ...options, ...(body !== undefined ? { body } : {}) });
191
+ return { dryRun: false, data };
192
+ }
193
+
194
+ /** Parse a JSON option value (object or array), raising an ARGS error on bad input. */
195
+ export function parseJsonOption(value, flag) {
196
+ if (value === undefined || value === null || value === "") return undefined;
197
+ try {
198
+ return JSON.parse(String(value));
199
+ } catch (error) {
200
+ const err = new Error(`${flag} must be valid JSON: ${error.message}`);
201
+ err.code = "ARGS";
202
+ throw err;
203
+ }
204
+ }
205
+
206
+ export function argsError(message) {
207
+ const err = new Error(message);
208
+ err.code = "ARGS";
209
+ return err;
210
+ }
211
+
212
+ /**
213
+ * Map project id → display label ("name@daemon" when bound to a daemon), for
214
+ * cross-project listings whose rows only carry a project id. Best effort: a
215
+ * failed lookup falls back to showing ids.
216
+ */
217
+ export async function projectLabels(http) {
218
+ const labels = new Map();
219
+ try {
220
+ const list = await http.get(apiPath("projects"));
221
+ const projects = Array.isArray(list) ? list : (Array.isArray(list?.projects) ? list.projects : []);
222
+ for (const project of projects) {
223
+ if (!project?.id) continue;
224
+ const name = project.name ?? project.id;
225
+ const host = project.daemonHost ?? project.daemon_host;
226
+ labels.set(project.id, host ? `${name}@${host}` : name);
227
+ }
228
+ } catch {
229
+ // Keep ids.
230
+ }
231
+ return labels;
232
+ }
233
+
234
+ /** Pad column widths to the longest cell so long UUIDs never break the table. */
235
+ export function formatTable(header, rows) {
236
+ const widths = header.slice(0, -1).map((title, i) => Math.max(String(title).length, ...rows.map((row) => String(row[i] ?? "").length)));
237
+ return [header, ...rows].map((row) => row
238
+ .map((cell, i) => (i < widths.length ? String(cell ?? "").padEnd(widths[i]) : String(cell ?? "")))
239
+ .join(" "));
240
+ }