@love-moon/conductor-cli 0.2.41 → 0.3.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,345 @@
1
+ /**
2
+ * Shared helpers for the entity-oriented subcommands (project / issue / task).
3
+ *
4
+ * Responsibilities:
5
+ * - Load the Conductor config file (delegate to conductor-sdk's loadConfig).
6
+ * - Resolve the active project via the SDK ProjectsApi.resolveProject().
7
+ * - Build write-request metadata with `actor: "cli"` audit fields.
8
+ * - Print --json or human-friendly output and translate errors into
9
+ * RFC-defined exit codes.
10
+ *
11
+ * The CLI deliberately does *not* statically import the SDK Api classes — they
12
+ * may not exist in the published SDK at the time this file is loaded. We
13
+ * dynamically import them so older SDKs still allow `conductor --help`, and
14
+ * tests can inject stub Api classes via the `deps` parameter.
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ // RFC §4 exit codes
24
+ export const EXIT = {
25
+ OK: 0,
26
+ GENERIC: 1,
27
+ ARGS: 2,
28
+ AUTH: 3,
29
+ NOT_FOUND: 4,
30
+ PROJECT_UNRESOLVED: 5,
31
+ };
32
+
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = path.dirname(__filename);
35
+ const PKG_ROOT = path.resolve(__dirname, "..");
36
+
37
+ let cachedPkgVersion = null;
38
+ export function getCliVersion() {
39
+ if (cachedPkgVersion !== null) {
40
+ return cachedPkgVersion;
41
+ }
42
+ try {
43
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
44
+ cachedPkgVersion = pkg.version || "0.0.0";
45
+ } catch {
46
+ cachedPkgVersion = "0.0.0";
47
+ }
48
+ return cachedPkgVersion;
49
+ }
50
+
51
+ const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".conductor", "config.yaml");
52
+
53
+ export function resolveConfigPath(configFile, env = process.env) {
54
+ if (configFile) {
55
+ return path.resolve(configFile);
56
+ }
57
+ const home = env.HOME || env.USERPROFILE || os.homedir();
58
+ return path.join(home, ".conductor", "config.yaml");
59
+ }
60
+
61
+ /**
62
+ * Load the Conductor config; tolerate missing files when env credentials are
63
+ * present (useful for tests). Returns the same shape `loadConfig` does
64
+ * (`{ agentToken, backendUrl }` at minimum).
65
+ */
66
+ export async function loadConductorConfig(options = {}) {
67
+ const env = options.env || process.env;
68
+ const configFile = options.configFile;
69
+ const sdk = await loadSdk(options);
70
+
71
+ const configPath = resolveConfigPath(configFile, env);
72
+ if (fs.existsSync(configPath)) {
73
+ return sdk.loadConfig(configPath, { env });
74
+ }
75
+
76
+ const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
77
+ const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
78
+ if (agentToken && backendUrl) {
79
+ if (sdk.ConductorConfig) {
80
+ return new sdk.ConductorConfig({ agentToken, backendUrl });
81
+ }
82
+ return { agentToken, backendUrl };
83
+ }
84
+
85
+ // Let the SDK raise a typed not-found error.
86
+ return sdk.loadConfig(configPath, { env });
87
+ }
88
+
89
+ /**
90
+ * Dynamic import of the SDK so the CLI can boot even if the SDK lacks the
91
+ * new ProjectsApi/IssuesApi/TasksApi exports yet (Agent A is implementing
92
+ * those in parallel). Tests can override the loader via `deps.sdk`.
93
+ */
94
+ export async function loadSdk(options = {}) {
95
+ if (options.sdk) {
96
+ return options.sdk;
97
+ }
98
+ return import("@love-moon/conductor-sdk");
99
+ }
100
+
101
+ /**
102
+ * Build the API surface (Projects / Issues / Tasks) on top of a single
103
+ * BackendApiClient. The Api classes come from the SDK; tests inject stubs.
104
+ */
105
+ export async function buildApis(options = {}) {
106
+ const sdk = await loadSdk(options);
107
+ const config = options.config || (await loadConductorConfig(options));
108
+ const fetchImpl = options.fetchImpl;
109
+ const ApiClient = sdk.BackendApiClient;
110
+ const apiClient = options.backendApi
111
+ ?? (ApiClient ? new ApiClient(config, fetchImpl ? { fetchImpl } : {}) : null);
112
+
113
+ if (!apiClient) {
114
+ throw new Error("BackendApiClient is unavailable from @love-moon/conductor-sdk");
115
+ }
116
+
117
+ const ProjectsApi = sdk.ProjectsApi;
118
+ const IssuesApi = sdk.IssuesApi;
119
+ const TasksApi = sdk.TasksApi;
120
+
121
+ return {
122
+ sdk,
123
+ config,
124
+ apiClient,
125
+ projects: ProjectsApi ? new ProjectsApi(apiClient) : null,
126
+ issues: IssuesApi ? new IssuesApi(apiClient) : null,
127
+ tasks: TasksApi ? new TasksApi(apiClient) : null,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Build the metadata blob for a CLI write request.
133
+ *
134
+ * Audit fields live under `metadata.audit` (RFC 0025 §5.2 + review M3/H1) so
135
+ * they can't be silently overridden by `--metadata-json '{"actor":"system"}'`
136
+ * top-level injections — the server strips top-level audit-shaped keys before
137
+ * persisting and only trusts what's inside the `audit` object.
138
+ *
139
+ * Merge order:
140
+ * - Caller's free-form metadata (`extraMetadata`) flows through verbatim
141
+ * except for any `audit` key, which is split out.
142
+ * - Caller's `audit` keys (if explicitly passed) are layered UNDER the CLI
143
+ * stamp (`actor: "cli"`, `cliVersion`, `invokedBy`) — i.e., the CLI value
144
+ * wins for keys it cares about, but a caller can still add extra audit
145
+ * fields (e.g., `audit.session: "abc123"`) without conflict.
146
+ */
147
+ export function buildAuditMetadata(env = process.env, extraMetadata = {}) {
148
+ const invokedBy = typeof env.CONDUCTOR_INVOKED_BY === "string" && env.CONDUCTOR_INVOKED_BY.trim()
149
+ ? env.CONDUCTOR_INVOKED_BY.trim()
150
+ : null;
151
+ const cliAudit = {
152
+ actor: "cli",
153
+ cliVersion: getCliVersion(),
154
+ invokedBy,
155
+ };
156
+ const safeExtra =
157
+ extraMetadata && typeof extraMetadata === "object" && !Array.isArray(extraMetadata)
158
+ ? { ...extraMetadata }
159
+ : {};
160
+ const callerAuditRaw = safeExtra.audit;
161
+ delete safeExtra.audit;
162
+ const callerAudit =
163
+ callerAuditRaw && typeof callerAuditRaw === "object" && !Array.isArray(callerAuditRaw)
164
+ ? callerAuditRaw
165
+ : {};
166
+ const audit = { ...callerAudit, ...cliAudit };
167
+ return { ...safeExtra, audit };
168
+ }
169
+
170
+ /**
171
+ * Errors translated into RFC §4 exit codes.
172
+ */
173
+ export function exitCodeForError(err) {
174
+ if (!err) return EXIT.GENERIC;
175
+ const name = err.name || "";
176
+ if (name === "ProjectNotResolvedError" || err.code === "PROJECT_UNRESOLVED") {
177
+ return EXIT.PROJECT_UNRESOLVED;
178
+ }
179
+ const status = err.statusCode ?? err.status ?? null;
180
+ if (status === 401 || status === 403) return EXIT.AUTH;
181
+ if (status === 404) return EXIT.NOT_FOUND;
182
+ if (status === 400 || err.code === "ARGS") return EXIT.ARGS;
183
+ return EXIT.GENERIC;
184
+ }
185
+
186
+ /**
187
+ * Resolve the project per the precedence in RFC §2:
188
+ * 1. explicit --project flag
189
+ * 2. CONDUCTOR_PROJECT_ID env var
190
+ * 3. cwd matching (delegated to SDK)
191
+ * 4. user defaultProject
192
+ * Returns whatever ProjectsApi.resolveProject returns ({ id, name, ... }).
193
+ */
194
+ export async function resolveProject(apis, options = {}) {
195
+ if (!apis.projects || typeof apis.projects.resolveProject !== "function") {
196
+ throw new Error("ProjectsApi.resolveProject is not available in conductor-sdk");
197
+ }
198
+ return apis.projects.resolveProject({
199
+ project: options.project,
200
+ env: options.env || process.env,
201
+ cwd: options.cwd || process.cwd(),
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Human-printable string for an entity (project/issue/task summary).
207
+ */
208
+ export function pad(value, width) {
209
+ const text = String(value ?? "");
210
+ if (text.length >= width) return text;
211
+ return text + " ".repeat(width - text.length);
212
+ }
213
+
214
+ export function printJson(stream, payload) {
215
+ stream.write(`${JSON.stringify(payload)}\n`);
216
+ }
217
+
218
+ export function printPretty(stream, text) {
219
+ stream.write(`${text}\n`);
220
+ }
221
+
222
+ export function readMessageInput({ positional, fromFile, useStdin, stdin }) {
223
+ const sources = [
224
+ positional !== undefined && positional !== "" ? "positional" : null,
225
+ fromFile ? "from-file" : null,
226
+ useStdin ? "stdin" : null,
227
+ ].filter(Boolean);
228
+ if (sources.length > 1) {
229
+ const err = new Error(`Provide only one of: positional message, --from-file, --stdin (got ${sources.join(", ")})`);
230
+ err.code = "ARGS";
231
+ throw err;
232
+ }
233
+ if (positional !== undefined && positional !== "") {
234
+ return String(positional);
235
+ }
236
+ if (fromFile) {
237
+ return fs.readFileSync(path.resolve(fromFile), "utf8");
238
+ }
239
+ if (useStdin) {
240
+ return readAllStdinSync(stdin || process.stdin);
241
+ }
242
+ const err = new Error("Empty message: pass a positional message, --from-file FILE, or --stdin");
243
+ err.code = "ARGS";
244
+ throw err;
245
+ }
246
+
247
+ function readAllStdinSync(stdin) {
248
+ // For tests we accept a string fixture or a Buffer/Readable handler shim.
249
+ if (typeof stdin === "string") return stdin;
250
+ if (stdin && Buffer.isBuffer(stdin)) return stdin.toString("utf8");
251
+ // Synchronous fallback using fs.readFileSync('/dev/stdin') style.
252
+ try {
253
+ const data = fs.readFileSync(0);
254
+ return data.toString("utf8");
255
+ } catch {
256
+ return "";
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Resolve a description from --description, --description-file, --description-stdin
262
+ * (mutually exclusive). Returns undefined if none provided.
263
+ */
264
+ export function readDescription({ description, descriptionFile, descriptionStdin, stdin }) {
265
+ const sources = [
266
+ typeof description === "string" && description !== "" ? "description" : null,
267
+ descriptionFile ? "description-file" : null,
268
+ descriptionStdin ? "description-stdin" : null,
269
+ ].filter(Boolean);
270
+ if (sources.length > 1) {
271
+ const err = new Error(`Provide only one of: --description, --description-file, --description-stdin (got ${sources.join(", ")})`);
272
+ err.code = "ARGS";
273
+ throw err;
274
+ }
275
+ if (typeof description === "string" && description !== "") {
276
+ return description;
277
+ }
278
+ if (descriptionFile) {
279
+ return fs.readFileSync(path.resolve(descriptionFile), "utf8");
280
+ }
281
+ if (descriptionStdin) {
282
+ return readAllStdinSync(stdin || process.stdin);
283
+ }
284
+ return undefined;
285
+ }
286
+
287
+ /**
288
+ * Resolve `--evidence` either as inline text or `@file/path`.
289
+ */
290
+ export function readEvidence(value) {
291
+ if (value === undefined || value === null || value === "") return undefined;
292
+ const str = String(value);
293
+ if (str.startsWith("@")) {
294
+ const filePath = str.slice(1);
295
+ return fs.readFileSync(path.resolve(filePath), "utf8");
296
+ }
297
+ return str;
298
+ }
299
+
300
+ /**
301
+ * Format the dry-run preview. Always returns a JSON-serializable object so
302
+ * callers can route through --json or render a human-friendly summary.
303
+ *
304
+ * Optional `note` lets callers flag preview imperfections — e.g. paths that
305
+ * round-trip server metadata (`issue done --evidence`) where the live PATCH
306
+ * body will merge existing keys the dry-run can't see (review L-NEW-2).
307
+ */
308
+ export function makeDryRunPayload(method, url, body, options = {}) {
309
+ const payload = {
310
+ dryRun: true,
311
+ request: {
312
+ method,
313
+ url,
314
+ body,
315
+ },
316
+ };
317
+ if (options.note) {
318
+ payload.note = options.note;
319
+ }
320
+ return payload;
321
+ }
322
+
323
+ export function emitDryRun(stream, json, payload) {
324
+ if (json) {
325
+ printJson(stream, payload);
326
+ return;
327
+ }
328
+ printPretty(stream, "[dry-run] would send:");
329
+ printPretty(stream, ` ${payload.request.method} ${payload.request.url}`);
330
+ if (payload.request.body !== undefined) {
331
+ printPretty(stream, ` body: ${JSON.stringify(payload.request.body)}`);
332
+ }
333
+ if (payload.note) {
334
+ printPretty(stream, ` note: ${payload.note}`);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Translate an unknown error into a printable + exit-coded form.
340
+ */
341
+ export function reportError(consoleErr, error) {
342
+ const message = error instanceof Error ? error.message : String(error);
343
+ consoleErr.error(`Error: ${message}`);
344
+ return exitCodeForError(error);
345
+ }