@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.
- package/CHANGELOG.md +94 -0
- package/bin/conductor-auth.js +162 -0
- package/bin/conductor-daemon-query.js +13 -17
- package/bin/conductor-daemon.js +6 -1
- package/bin/conductor-issue.js +215 -13
- package/bin/conductor-project.js +840 -27
- package/bin/conductor-search.js +150 -0
- package/bin/conductor-settings.js +608 -0
- package/bin/conductor-task.js +316 -9
- package/bin/conductor.js +29 -8
- package/package.json +5 -5
- package/src/backend-http.js +240 -0
- package/src/daemon-share-commands.js +528 -0
- package/src/daemon.js +2 -0
- package/src/task-commands.js +777 -0
package/bin/conductor-project.js
CHANGED
|
@@ -11,11 +11,30 @@
|
|
|
11
11
|
* set-default <id|name>
|
|
12
12
|
* hide <id|name>
|
|
13
13
|
* unhide <id|name>
|
|
14
|
+
* update <id|name> [--name <n>] [--merge-opt-out true|false]
|
|
15
|
+
* [--workspace-path <p> [--bind-daemon-host <h>]] [--json-body '{...}']
|
|
16
|
+
* refresh <id|name> (re-validate binding with the daemon)
|
|
17
|
+
* delete <id|name> --yes (merged: every daemon unless --daemon-host picks one)
|
|
18
|
+
* reorder <id|name>... (listed first, the rest keep their order)
|
|
19
|
+
* agents [<id|name>] (agents registered in .conductor/settings.yaml)
|
|
20
|
+
* collab invite [<id|name>]
|
|
21
|
+
* collab join <token|invite-url> (--into <id|name> | --create-project <name>)
|
|
22
|
+
* collab leave <id|name>
|
|
23
|
+
* collab show-invite <token|invite-url>
|
|
24
|
+
* labels list [<id|name>]
|
|
25
|
+
* labels add <name> (target project via --project)
|
|
26
|
+
* labels rename <label> <new-name>
|
|
27
|
+
* labels remove <label>
|
|
28
|
+
*
|
|
29
|
+
* Positional <id|name> commands accept --daemon-host to disambiguate
|
|
30
|
+
* same-name projects across daemons.
|
|
14
31
|
*
|
|
15
32
|
* Global flags supported on every write subcommand:
|
|
16
33
|
* --json, --dry-run, --project, --config-file
|
|
17
34
|
*/
|
|
18
35
|
|
|
36
|
+
import { randomUUID } from "node:crypto";
|
|
37
|
+
import os from "node:os";
|
|
19
38
|
import path from "node:path";
|
|
20
39
|
import process from "node:process";
|
|
21
40
|
import { fileURLToPath } from "node:url";
|
|
@@ -36,6 +55,7 @@ import {
|
|
|
36
55
|
reportError,
|
|
37
56
|
resolveProject,
|
|
38
57
|
} from "../src/entity-helpers.js";
|
|
58
|
+
import { apiPath, argsError, buildHttp, parseJsonOption, sendOrPreview } from "../src/backend-http.js";
|
|
39
59
|
|
|
40
60
|
const isMainModule = (() => {
|
|
41
61
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -191,7 +211,9 @@ async function handleCreate(argv, deps) {
|
|
|
191
211
|
const defaultName = argv.name && String(argv.name).trim()
|
|
192
212
|
? String(argv.name).trim()
|
|
193
213
|
: (path.basename(workspacePath) || undefined);
|
|
194
|
-
|
|
214
|
+
// workspacePath is a path on this machine, so bind to this machine's daemon
|
|
215
|
+
// unless told otherwise (the server needs daemonHost + workspacePath).
|
|
216
|
+
const daemonHost = argv.daemonHost ? String(argv.daemonHost) : localDaemonName(config, env);
|
|
195
217
|
const isDefault = Boolean(argv.default);
|
|
196
218
|
const metadata = buildAuditMetadata(env);
|
|
197
219
|
if (argv.clientRequestId) {
|
|
@@ -218,23 +240,14 @@ async function handleCreate(argv, deps) {
|
|
|
218
240
|
return EXIT.OK;
|
|
219
241
|
}
|
|
220
242
|
|
|
221
|
-
if (!isDefault) {
|
|
222
|
-
if (!daemonHost && !config?.daemonName) {
|
|
223
|
-
// Service-side will error when daemon is not reachable; we surface a
|
|
224
|
-
// clearer hint here per "Decisions on leftover RFC questions" §1.
|
|
225
|
-
// We don't proactively probe — but we *do* improve the wording when
|
|
226
|
-
// the server returns "daemon not reachable".
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
243
|
let created;
|
|
231
244
|
try {
|
|
232
245
|
created = await apis.projects.createProject(body);
|
|
233
246
|
} catch (err) {
|
|
234
247
|
if (isDaemonUnreachableError(err)) {
|
|
235
|
-
const
|
|
248
|
+
const serverMessage = String(err.details?.error || err.message || "").trim();
|
|
236
249
|
const friendly = new Error(
|
|
237
|
-
|
|
250
|
+
`${serverMessage.replace(/\.?$/, ".")} Start it with \`conductor daemon\`, or pass --daemon-host <h> to use another online daemon.`,
|
|
238
251
|
);
|
|
239
252
|
friendly.statusCode = err.statusCode;
|
|
240
253
|
throw friendly;
|
|
@@ -250,8 +263,21 @@ async function handleCreate(argv, deps) {
|
|
|
250
263
|
return EXIT.OK;
|
|
251
264
|
}
|
|
252
265
|
|
|
266
|
+
/** The daemon name `conductor daemon` registers with on this machine. */
|
|
267
|
+
function localDaemonName(config, env) {
|
|
268
|
+
const name =
|
|
269
|
+
(typeof config?.daemonName === "string" && config.daemonName.trim()) ||
|
|
270
|
+
(typeof env?.CONDUCTOR_DAEMON_NAME === "string" && env.CONDUCTOR_DAEMON_NAME.trim()) ||
|
|
271
|
+
os.hostname().trim();
|
|
272
|
+
return name || undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The server answers 409 with `code` daemon_offline / daemon_unreachable
|
|
276
|
+
// (web/src/lib/projects/daemon-binding.ts).
|
|
253
277
|
function isDaemonUnreachableError(err) {
|
|
254
278
|
if (!err) return false;
|
|
279
|
+
const code = err.details?.code;
|
|
280
|
+
if (code === "daemon_offline" || code === "daemon_unreachable") return true;
|
|
255
281
|
const message = err.message || "";
|
|
256
282
|
if (/daemon/i.test(message) && /(not reachable|unreachable|cannot reach|connection refused|ECONN)/i.test(message)) {
|
|
257
283
|
return true;
|
|
@@ -301,27 +327,662 @@ async function handleSetHidden(argv, deps, hidden) {
|
|
|
301
327
|
cwd: deps.cwd,
|
|
302
328
|
daemonHost: argv.daemonHost,
|
|
303
329
|
});
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
330
|
+
// Like the web project list, a cross-daemon merged project is hidden or
|
|
331
|
+
// restored on every daemon at once.
|
|
332
|
+
const members = displayedGroup(await apis.projects.listProjects({ includeHidden: true }), project);
|
|
333
|
+
// No `metadata` here: the project PATCH route replaces the whole metadata
|
|
334
|
+
// blob with whatever it is sent, so an audit-only object would erase the
|
|
335
|
+
// project's task labels, memos and binding data. The web UI sends `hidden` alone.
|
|
336
|
+
const body = { hidden };
|
|
307
337
|
if (argv.dryRun) {
|
|
308
|
-
|
|
338
|
+
for (const member of members) {
|
|
339
|
+
const url = `${buildBaseUrl(apis.config)}/api/projects?projectId=${encodeURIComponent(member.id)}`;
|
|
340
|
+
emitDryRun(deps.stdout, argv.json, makeDryRunPayload("PATCH", url, body));
|
|
341
|
+
}
|
|
309
342
|
return EXIT.OK;
|
|
310
343
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
344
|
+
const updated = [];
|
|
345
|
+
for (const [index, member] of members.entries()) {
|
|
346
|
+
try {
|
|
347
|
+
updated.push(await apis.projects.setProjectHidden(member.id, hidden));
|
|
348
|
+
} catch (err) {
|
|
349
|
+
// Like delete: say which copies changed before the failure.
|
|
350
|
+
if (members.length > 1) {
|
|
351
|
+
const ids = (list) => list.map((entry) => entry.id).join(", ") || "none";
|
|
352
|
+
const verb = hidden ? "hidden" : "unhidden";
|
|
353
|
+
err.message = `${verb[0].toUpperCase()}${verb.slice(1)}: ${ids(members.slice(0, index))}; ` +
|
|
354
|
+
`not ${verb}: ${ids(members.slice(index))}. Failed on ${member.id} (${err.message})`;
|
|
355
|
+
}
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
315
359
|
if (argv.json) {
|
|
316
|
-
|
|
360
|
+
// The target's object, as before merged groups; `ids` lists every member changed.
|
|
361
|
+
const target = projectAsObject(updated[members.findIndex((member) => member.id === project.id)] ?? updated[0]);
|
|
362
|
+
printJson(deps.stdout, members.length > 1 ? { ...target, ids: members.map((member) => member.id) } : target);
|
|
317
363
|
return EXIT.OK;
|
|
318
364
|
}
|
|
365
|
+
const what = members.length > 1
|
|
366
|
+
? `merged project ${project.name ?? project.id} on ${members.length} daemons`
|
|
367
|
+
: `project ${project.name ?? project.id}`;
|
|
368
|
+
printPretty(deps.stdout, hidden ? `Hid ${what}` : `Unhid ${what}`);
|
|
369
|
+
return EXIT.OK;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// HTTP-backed subcommands (routes the SDK does not wrap). See
|
|
374
|
+
// web/src/app/api/projects/** and web/src/features/projects/store.ts.
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
async function resolveTarget(argv, deps, selector = argv.idOrName ?? argv.project) {
|
|
378
|
+
const apis = await buildApis(deps);
|
|
379
|
+
return resolveProjectSelector(apis, selector, {
|
|
380
|
+
env: deps.env,
|
|
381
|
+
cwd: deps.cwd,
|
|
382
|
+
project: argv.project,
|
|
383
|
+
daemonHost: argv.daemonHost,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function listRawProjects(http) {
|
|
388
|
+
const raw = await http.get("/api/projects");
|
|
389
|
+
if (Array.isArray(raw)) return raw;
|
|
390
|
+
if (raw && Array.isArray(raw.projects)) return raw.projects;
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function fetchRawProject(http, projectId) {
|
|
395
|
+
const list = await listRawProjects(http);
|
|
396
|
+
const found = list.find((entry) => entry && entry.id === projectId);
|
|
397
|
+
if (!found) {
|
|
398
|
+
const err = new Error(`Project not found: ${projectId}`);
|
|
399
|
+
err.statusCode = 404;
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
return found;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function projectQuery(projectId) {
|
|
406
|
+
return { projectId };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function displayName(project) {
|
|
410
|
+
return `${project?.name ?? "(unnamed)"} (${project?.id})`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseBooleanFlag(value, flag) {
|
|
414
|
+
if (value === undefined) return undefined;
|
|
415
|
+
if (typeof value === "boolean") return value;
|
|
416
|
+
const text = String(value).trim().toLowerCase();
|
|
417
|
+
if (text === "true") return true;
|
|
418
|
+
if (text === "false") return false;
|
|
419
|
+
throw argsError(`${flag} must be true or false`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function handleUpdate(argv, deps) {
|
|
423
|
+
const project = await resolveTarget(argv, deps);
|
|
424
|
+
const http = await buildHttp(deps);
|
|
425
|
+
const body = {};
|
|
426
|
+
if (argv.name !== undefined) {
|
|
427
|
+
const name = String(argv.name).trim();
|
|
428
|
+
if (!name) throw argsError("--name cannot be empty");
|
|
429
|
+
body.name = name;
|
|
430
|
+
}
|
|
431
|
+
const mergeOptOut = parseBooleanFlag(argv.mergeOptOut, "--merge-opt-out");
|
|
432
|
+
if (mergeOptOut !== undefined) body.mergeOptOut = mergeOptOut;
|
|
433
|
+
if (argv.workspacePath !== undefined) {
|
|
434
|
+
// Binding fields: the server requires daemonHost + workspacePath together,
|
|
435
|
+
// plus bindingConfirmed, and refuses to change an existing binding (409).
|
|
436
|
+
const daemonHost = argv.bindDaemonHost || project.daemonHost || argv.daemonHost;
|
|
437
|
+
if (!daemonHost) {
|
|
438
|
+
throw argsError("--workspace-path needs a daemon: pass --bind-daemon-host <host>");
|
|
439
|
+
}
|
|
440
|
+
// The path lives on `daemonHost`, which may not be this machine, so a
|
|
441
|
+
// relative path cannot be resolved here.
|
|
442
|
+
const workspacePath = String(argv.workspacePath).trim();
|
|
443
|
+
if (!path.isAbsolute(workspacePath)) {
|
|
444
|
+
throw argsError(`--workspace-path must be an absolute path on daemon ${daemonHost}`);
|
|
445
|
+
}
|
|
446
|
+
body.daemonHost = String(daemonHost);
|
|
447
|
+
body.workspacePath = path.normalize(workspacePath);
|
|
448
|
+
body.bindingConfirmed = true;
|
|
449
|
+
}
|
|
450
|
+
const extra = parseJsonOption(argv.jsonBody, "--json-body");
|
|
451
|
+
if (extra !== undefined) {
|
|
452
|
+
if (!extra || typeof extra !== "object" || Array.isArray(extra)) {
|
|
453
|
+
throw argsError("--json-body must be a JSON object");
|
|
454
|
+
}
|
|
455
|
+
Object.assign(body, extra);
|
|
456
|
+
}
|
|
457
|
+
if (Object.keys(body).length === 0) {
|
|
458
|
+
throw argsError("Nothing to update: pass --name, --merge-opt-out, --workspace-path or --json-body");
|
|
459
|
+
}
|
|
460
|
+
// Same as the web Split/Merge toggle: splitting opts out every member of the
|
|
461
|
+
// merged group; merging clears the opt-out on every same-name project on
|
|
462
|
+
// another daemon, whichever side had opted out.
|
|
463
|
+
let peers = [];
|
|
464
|
+
if (mergeOptOut !== undefined) {
|
|
465
|
+
const apis = await buildApis(deps);
|
|
466
|
+
const all = await apis.projects.listProjects({ includeHidden: true });
|
|
467
|
+
peers = mergeOptOut
|
|
468
|
+
? displayedGroup(all, project).filter((entry) => entry.id !== project.id)
|
|
469
|
+
: all.filter((entry) =>
|
|
470
|
+
entry.id !== project.id &&
|
|
471
|
+
entry.name === project.name &&
|
|
472
|
+
String(project.daemonHost ?? "").trim() &&
|
|
473
|
+
String(entry.daemonHost ?? "").trim() &&
|
|
474
|
+
String(entry.daemonHost).trim() !== String(project.daemonHost).trim());
|
|
475
|
+
}
|
|
476
|
+
const result = await sendOrPreview(http, argv, deps, "PATCH", "/api/projects", body, {
|
|
477
|
+
query: projectQuery(project.id),
|
|
478
|
+
});
|
|
479
|
+
for (const peer of peers) {
|
|
480
|
+
await sendOrPreview(http, argv, deps, "PATCH", "/api/projects", { mergeOptOut }, {
|
|
481
|
+
query: projectQuery(peer.id),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (result.dryRun) return EXIT.OK;
|
|
485
|
+
if (argv.json) {
|
|
486
|
+
printJson(
|
|
487
|
+
deps.stdout,
|
|
488
|
+
peers.length > 0 ? { ...result.data, ids: [project.id, ...peers.map((peer) => peer.id)] } : result.data,
|
|
489
|
+
);
|
|
490
|
+
return EXIT.OK;
|
|
491
|
+
}
|
|
492
|
+
printPretty(deps.stdout, `Updated project ${displayName(result.data ?? project)}`);
|
|
493
|
+
if (peers.length > 0) {
|
|
494
|
+
printPretty(deps.stdout, `${mergeOptOut ? "Also split" : "Also re-merged"} ${peers.length} same-name project(s):`);
|
|
495
|
+
for (const peer of peers) printPretty(deps.stdout, ` ${displayName(peer)} on ${peer.daemonHost}`);
|
|
496
|
+
}
|
|
497
|
+
return EXIT.OK;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function handleRefresh(argv, deps) {
|
|
501
|
+
const project = await resolveTarget(argv, deps);
|
|
502
|
+
const http = await buildHttp(deps);
|
|
503
|
+
const result = await sendOrPreview(http, argv, deps, "PATCH", "/api/projects", { refresh: true }, {
|
|
504
|
+
query: projectQuery(project.id),
|
|
505
|
+
});
|
|
506
|
+
if (result.dryRun) return EXIT.OK;
|
|
507
|
+
if (argv.json) {
|
|
508
|
+
printJson(deps.stdout, result.data);
|
|
509
|
+
return EXIT.OK;
|
|
510
|
+
}
|
|
511
|
+
const updated = result.data ?? project;
|
|
512
|
+
const branch = updated.worktreeBranch ? ` branch=${updated.worktreeBranch}` : "";
|
|
513
|
+
const commit = updated.lastCommit ? ` commit=${String(updated.lastCommit).slice(0, 12)}` : "";
|
|
514
|
+
printPretty(deps.stdout, `Refreshed project ${displayName(updated)}${branch}${commit}`);
|
|
515
|
+
return EXIT.OK;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function handleDelete(argv, deps) {
|
|
519
|
+
if (!argv.yes && !argv.dryRun) {
|
|
520
|
+
throw argsError(
|
|
521
|
+
"Refusing to delete without --yes: this stops the project's running tasks and deletes its tasks and messages, " +
|
|
522
|
+
"including tasks filed under other projects; a cross-daemon merged project is deleted on every daemon " +
|
|
523
|
+
"unless --daemon-host picks one",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const { project, members: group } = await resolveTargetGroup(argv, deps);
|
|
527
|
+
// --daemon-host targets that one copy; otherwise the whole group, like the web.
|
|
528
|
+
const members = argv.daemonHost ? [project] : group;
|
|
529
|
+
const http = await buildHttp(deps);
|
|
530
|
+
// Sequential like the web: each daemon may clean up worktrees first. There is
|
|
531
|
+
// no rollback, so stop at the first failure and say what is left.
|
|
532
|
+
for (const [index, member] of members.entries()) {
|
|
533
|
+
try {
|
|
534
|
+
await sendOrPreview(http, argv, deps, "DELETE", "/api/projects", undefined, {
|
|
535
|
+
query: projectQuery(member.id),
|
|
536
|
+
});
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (members.length > 1) {
|
|
539
|
+
const ids = (list) => list.map((entry) => entry.id).join(", ") || "none";
|
|
540
|
+
err.message = `Deleted: ${ids(members.slice(0, index))}; not deleted: ${ids(members.slice(index))}. ` +
|
|
541
|
+
`Failed on ${member.id} (${err.message})`;
|
|
542
|
+
}
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (argv.dryRun) return EXIT.OK;
|
|
547
|
+
if (argv.json) {
|
|
548
|
+
printJson(
|
|
549
|
+
deps.stdout,
|
|
550
|
+
members.length > 1
|
|
551
|
+
? { deleted: true, id: project.id, ids: members.map((member) => member.id) }
|
|
552
|
+
: { deleted: true, id: project.id },
|
|
553
|
+
);
|
|
554
|
+
return EXIT.OK;
|
|
555
|
+
}
|
|
556
|
+
printPretty(deps.stdout, `Deleted ${describeGroup(project, members)}`);
|
|
557
|
+
return EXIT.OK;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function handleReorder(argv, deps) {
|
|
561
|
+
const selectors = (argv.idOrNames || []).map(String).filter(Boolean);
|
|
562
|
+
if (selectors.length === 0) throw argsError("Pass at least one project id or name");
|
|
563
|
+
const apis = await buildApis(deps);
|
|
564
|
+
const ordered = [];
|
|
565
|
+
for (const selector of selectors) {
|
|
566
|
+
const project = await resolveProjectSelector(apis, selector, {
|
|
567
|
+
env: deps.env,
|
|
568
|
+
cwd: deps.cwd,
|
|
569
|
+
daemonHost: argv.daemonHost,
|
|
570
|
+
});
|
|
571
|
+
if (ordered.includes(project.id)) throw argsError(`Project listed twice: ${selector}`);
|
|
572
|
+
ordered.push(project.id);
|
|
573
|
+
}
|
|
574
|
+
// The server requires every project exactly once; keep the unlisted ones in
|
|
575
|
+
// their current display order after the listed ones.
|
|
576
|
+
const all = await apis.projects.listProjects({ includeHidden: true });
|
|
577
|
+
const projectIds = [...ordered, ...all.map((entry) => entry.id).filter((id) => !ordered.includes(id))];
|
|
578
|
+
const http = await buildHttp(deps);
|
|
579
|
+
const result = await sendOrPreview(http, argv, deps, "POST", "/api/projects/reorder", { projectIds });
|
|
580
|
+
if (result.dryRun) return EXIT.OK;
|
|
581
|
+
if (argv.json) {
|
|
582
|
+
printJson(deps.stdout, result.data);
|
|
583
|
+
return EXIT.OK;
|
|
584
|
+
}
|
|
585
|
+
printPretty(deps.stdout, `Reordered ${projectIds.length} projects`);
|
|
586
|
+
return EXIT.OK;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function handleAgents(argv, deps) {
|
|
590
|
+
const project = await resolveTarget(argv, deps);
|
|
591
|
+
const http = await buildHttp(deps);
|
|
592
|
+
const data = await http.get(apiPath("projects", project.id, "agents"));
|
|
593
|
+
if (argv.json) {
|
|
594
|
+
printJson(deps.stdout, data);
|
|
595
|
+
return EXIT.OK;
|
|
596
|
+
}
|
|
597
|
+
const agents = Array.isArray(data?.agents) ? data.agents : [];
|
|
598
|
+
if (agents.length === 0) {
|
|
599
|
+
printPretty(deps.stdout, "(no agents registered)");
|
|
600
|
+
return EXIT.OK;
|
|
601
|
+
}
|
|
602
|
+
printPretty(deps.stdout, `${pad("NAME", 20)} ${pad("BACKEND", 12)} DESCRIPTION`);
|
|
603
|
+
for (const agent of agents) {
|
|
604
|
+
printPretty(deps.stdout, `${pad(agent.name, 20)} ${pad(agent.backend ?? "", 12)} ${agent.description ?? ""}`);
|
|
605
|
+
}
|
|
606
|
+
return EXIT.OK;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function handleCollabInvite(argv, deps) {
|
|
610
|
+
const project = await resolveTarget(argv, deps);
|
|
611
|
+
const http = await buildHttp(deps);
|
|
612
|
+
const result = await sendOrPreview(http, argv, deps, "POST", apiPath("projects", project.id, "collaboration"), undefined);
|
|
613
|
+
if (result.dryRun) return EXIT.OK;
|
|
614
|
+
if (argv.json) {
|
|
615
|
+
printJson(deps.stdout, result.data);
|
|
616
|
+
return EXIT.OK;
|
|
617
|
+
}
|
|
618
|
+
const data = result.data || {};
|
|
619
|
+
const inviteUrl = data.inviteUrl ?? data.invite_url ?? data.collaboration?.inviteUrl;
|
|
620
|
+
const inviteToken = data.inviteToken ?? data.invite_token;
|
|
621
|
+
const collaborationId = data.collaboration?.id;
|
|
319
622
|
printPretty(
|
|
320
623
|
deps.stdout,
|
|
321
|
-
|
|
322
|
-
? `Hid project ${project.name ?? project.id}`
|
|
323
|
-
: `Unhid project ${project.name ?? project.id}`,
|
|
624
|
+
`Collaboration ${collaborationId ? `${collaborationId} ` : ""}for project ${displayName(project)}`,
|
|
324
625
|
);
|
|
626
|
+
if (inviteUrl) printPretty(deps.stdout, `Invite URL: ${inviteUrl}`);
|
|
627
|
+
if (inviteToken) printPretty(deps.stdout, `Invite token: ${inviteToken}`);
|
|
628
|
+
return EXIT.OK;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Accept a bare invite token or an invite URL ending in /app/invite/<token>. */
|
|
632
|
+
/** Preview an invite (what the web /app/invite/<token> page shows) before joining. */
|
|
633
|
+
async function handleCollabShowInvite(argv, deps) {
|
|
634
|
+
const inviteToken = parseInviteToken(argv.token);
|
|
635
|
+
const http = await buildHttp(deps);
|
|
636
|
+
const data = await http.get(apiPath("invitations", inviteToken));
|
|
637
|
+
if (argv.json) {
|
|
638
|
+
printJson(deps.stdout, data);
|
|
639
|
+
return EXIT.OK;
|
|
640
|
+
}
|
|
641
|
+
const collaboration = data?.collaboration ?? {};
|
|
642
|
+
const members = Array.isArray(collaboration.members) ? collaboration.members : [];
|
|
643
|
+
printPretty(deps.stdout, `Collaboration ${collaboration.id ?? ""} (${members.length} member${members.length === 1 ? "" : "s"})`);
|
|
644
|
+
if (data?.alreadyJoined) printPretty(deps.stdout, "You have already joined this collaboration.");
|
|
645
|
+
if (data?.isFull) printPretty(deps.stdout, "This collaboration is full.");
|
|
646
|
+
if (data?.suggestedProjectName) {
|
|
647
|
+
const note = data.suggestedProjectNameHidden ? " (a hidden project already has this name; unhide it to join with it)" : "";
|
|
648
|
+
printPretty(deps.stdout, `Suggested project name: ${data.suggestedProjectName}${note}`);
|
|
649
|
+
}
|
|
650
|
+
const candidates = (Array.isArray(data?.candidateProjects) ? data.candidateProjects : []).filter((p) => p.canJoin);
|
|
651
|
+
if (candidates.length) {
|
|
652
|
+
printPretty(deps.stdout, "Projects you can join with (--into):");
|
|
653
|
+
for (const project of candidates) {
|
|
654
|
+
printPretty(deps.stdout, ` ${project.id} ${project.name}${project.daemonHost ? `@${project.daemonHost}` : ""}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
printPretty(deps.stdout, `Join with: conductor project collab join ${inviteToken} (--into <project> | --create-project <name>)`);
|
|
658
|
+
return EXIT.OK;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function parseInviteToken(input) {
|
|
662
|
+
const text = String(input ?? "").trim();
|
|
663
|
+
if (!text) return "";
|
|
664
|
+
if (/^https?:\/\//i.test(text)) {
|
|
665
|
+
try {
|
|
666
|
+
const url = new URL(text);
|
|
667
|
+
const match = url.pathname.match(/\/invite\/([^/]+)\/?$/);
|
|
668
|
+
if (match) return decodeURIComponent(match[1]);
|
|
669
|
+
const fromQuery = url.searchParams.get("token") || url.searchParams.get("inviteToken");
|
|
670
|
+
if (fromQuery) return fromQuery;
|
|
671
|
+
} catch {
|
|
672
|
+
// fall through to treating it as a token
|
|
673
|
+
}
|
|
674
|
+
throw argsError(`Could not find an invite token in URL: ${text}`);
|
|
675
|
+
}
|
|
676
|
+
return text;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function handleCollabJoin(argv, deps) {
|
|
680
|
+
const inviteToken = parseInviteToken(argv.token);
|
|
681
|
+
if (!inviteToken) throw argsError("Invite token is required");
|
|
682
|
+
const hasInto = argv.into !== undefined && String(argv.into).trim() !== "";
|
|
683
|
+
const hasCreate = argv.createProject !== undefined && String(argv.createProject).trim() !== "";
|
|
684
|
+
if (hasInto === hasCreate) {
|
|
685
|
+
throw argsError("Pass exactly one of --into <id|name> (join with an existing project) or --create-project <name>");
|
|
686
|
+
}
|
|
687
|
+
const body = { inviteToken };
|
|
688
|
+
if (hasInto) {
|
|
689
|
+
const project = await resolveTarget(argv, deps, String(argv.into));
|
|
690
|
+
body.projectId = project.id;
|
|
691
|
+
} else {
|
|
692
|
+
body.createProjectName = String(argv.createProject).trim();
|
|
693
|
+
}
|
|
694
|
+
const http = await buildHttp(deps);
|
|
695
|
+
const result = await sendOrPreview(http, argv, deps, "POST", "/api/collaboration/join", body);
|
|
696
|
+
if (result.dryRun) return EXIT.OK;
|
|
697
|
+
if (argv.json) {
|
|
698
|
+
printJson(deps.stdout, result.data);
|
|
699
|
+
return EXIT.OK;
|
|
700
|
+
}
|
|
701
|
+
const data = result.data || {};
|
|
702
|
+
printPretty(
|
|
703
|
+
deps.stdout,
|
|
704
|
+
`Joined collaboration ${data.collaborationId ?? data.collaboration_id ?? data.collaboration?.id ?? ""} with project ${data.projectId ?? data.project_id ?? body.projectId ?? ""}`,
|
|
705
|
+
);
|
|
706
|
+
return EXIT.OK;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function handleCollabLeave(argv, deps) {
|
|
710
|
+
const project = await resolveTarget(argv, deps);
|
|
711
|
+
const http = await buildHttp(deps);
|
|
712
|
+
const raw = await fetchRawProject(http, project.id);
|
|
713
|
+
const collaborationId = raw.collaborationId ?? raw.collaboration_id ?? raw.collaboration?.id ?? null;
|
|
714
|
+
if (!collaborationId) {
|
|
715
|
+
throw argsError(`Project ${displayName(project)} is not in a collaboration`);
|
|
716
|
+
}
|
|
717
|
+
const result = await sendOrPreview(
|
|
718
|
+
http, argv, deps, "DELETE", apiPath("collaboration", collaborationId, "members", "me"), undefined,
|
|
719
|
+
);
|
|
720
|
+
if (result.dryRun) return EXIT.OK;
|
|
721
|
+
if (argv.json) {
|
|
722
|
+
printJson(deps.stdout, { left: true, collaborationId, projectId: project.id });
|
|
723
|
+
return EXIT.OK;
|
|
724
|
+
}
|
|
725
|
+
printPretty(deps.stdout, `Left collaboration ${collaborationId} (project ${displayName(project)})`);
|
|
726
|
+
return EXIT.OK;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ---- task labels (project.metadata.taskLabels, see web/src/lib/projects/task-labels.ts)
|
|
730
|
+
|
|
731
|
+
const TASK_LABELS_KEY = "taskLabels";
|
|
732
|
+
const MAX_TASK_LABEL_NAME_CHARS = 32;
|
|
733
|
+
const MAX_TASK_LABELS_PER_PROJECT = 50;
|
|
734
|
+
|
|
735
|
+
function normalizeLabelName(value) {
|
|
736
|
+
if (typeof value !== "string") return "";
|
|
737
|
+
return value.trim().replace(/\s+/g, " ").slice(0, MAX_TASK_LABEL_NAME_CHARS);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function labelKey(name) {
|
|
741
|
+
return normalizeLabelName(name).toLowerCase();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function metadataRecord(project) {
|
|
745
|
+
const metadata = project?.metadata;
|
|
746
|
+
return metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function readLabels(project) {
|
|
750
|
+
const raw = metadataRecord(project)[TASK_LABELS_KEY];
|
|
751
|
+
if (!Array.isArray(raw)) return [];
|
|
752
|
+
const labels = [];
|
|
753
|
+
const seen = new Set();
|
|
754
|
+
for (const entry of raw) {
|
|
755
|
+
if (!entry || typeof entry !== "object" || typeof entry.id !== "string" || !entry.id) continue;
|
|
756
|
+
const name = normalizeLabelName(entry.name);
|
|
757
|
+
if (!name || seen.has(entry.id)) continue;
|
|
758
|
+
seen.add(entry.id);
|
|
759
|
+
labels.push({ id: entry.id, name });
|
|
760
|
+
}
|
|
761
|
+
return labels;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function canonicalRemote(value) {
|
|
765
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
766
|
+
if (!normalized) return "";
|
|
767
|
+
const slash = normalized.indexOf("/");
|
|
768
|
+
if (slash < 0) return normalized;
|
|
769
|
+
const host = normalized.slice(0, slash);
|
|
770
|
+
const canonicalHost = host === "github.com" || host.startsWith("github-") || host.startsWith("github.com-")
|
|
771
|
+
? "github.com"
|
|
772
|
+
: host;
|
|
773
|
+
return `${canonicalHost}${normalized.slice(slash)}`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Mirror of `canMergeProjectsByFields` in web/src/lib/projects/grouping.ts. */
|
|
777
|
+
function canMerge(a, b) {
|
|
778
|
+
if (a.name !== b.name) return false;
|
|
779
|
+
// SDK `Project`s keep these fields under `raw`; raw API rows have them inline.
|
|
780
|
+
if (groupField(a, "mergeOptOut") === true || groupField(b, "mergeOptOut") === true) return false;
|
|
781
|
+
const aHost = String(a.daemonHost ?? "").trim();
|
|
782
|
+
const bHost = String(b.daemonHost ?? "").trim();
|
|
783
|
+
if (!aHost || !bHost || aHost === bHost) return false;
|
|
784
|
+
const aUrl = canonicalRemote(groupField(a, "gitRemoteUrl"));
|
|
785
|
+
const bUrl = canonicalRemote(groupField(b, "gitRemoteUrl"));
|
|
786
|
+
return !(aUrl && bUrl && aUrl !== bUrl);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function groupField(project, key) {
|
|
790
|
+
return project[key] ?? project.raw?.[key];
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function isHiddenProject(project) {
|
|
794
|
+
return typeof project.hidden === "boolean" ? project.hidden : Boolean(project.hiddenAt ?? project.hidden_at);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The merged group the web project list shows `target` in: mirror of
|
|
799
|
+
* `computeProjectGroups` (web/src/features/projects/utils/project-groups.ts)
|
|
800
|
+
* over the visible projects, or over all of them when `target` is hidden
|
|
801
|
+
* (only listed once hidden projects are revealed).
|
|
802
|
+
*/
|
|
803
|
+
function displayedGroup(all, target) {
|
|
804
|
+
const hidden = isHiddenProject(target);
|
|
805
|
+
const list = all.filter((entry) => hidden || !isHiddenProject(entry) || entry.id === target.id);
|
|
806
|
+
const grouped = new Set();
|
|
807
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
808
|
+
const anchor = list[i];
|
|
809
|
+
if (grouped.has(anchor.id)) continue;
|
|
810
|
+
const members = [anchor];
|
|
811
|
+
for (const candidate of list.slice(i + 1)) {
|
|
812
|
+
if (!grouped.has(candidate.id) && canMerge(anchor, candidate)) members.push(candidate);
|
|
813
|
+
}
|
|
814
|
+
for (const member of members) grouped.add(member.id);
|
|
815
|
+
if (members.some((member) => member.id === target.id)) return members;
|
|
816
|
+
}
|
|
817
|
+
return [target];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Mirror of `expandMergedProjectGroup`: every project merging with any seed, hidden included. */
|
|
821
|
+
function expandGroup(seeds, all) {
|
|
822
|
+
const expanded = all.filter((candidate) =>
|
|
823
|
+
seeds.some((seed) => seed.id === candidate.id || canMerge(seed, candidate)),
|
|
824
|
+
);
|
|
825
|
+
const ids = new Set(expanded.map((entry) => entry.id));
|
|
826
|
+
return [...expanded, ...seeds.filter((seed) => !ids.has(seed.id))];
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function resolveTargetGroup(argv, deps) {
|
|
830
|
+
const project = await resolveTarget(argv, deps);
|
|
831
|
+
const apis = await buildApis(deps);
|
|
832
|
+
const all = await apis.projects.listProjects({ includeHidden: true });
|
|
833
|
+
return { project, all, members: displayedGroup(all, project) };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function describeGroup(project, members) {
|
|
837
|
+
return members.length > 1
|
|
838
|
+
? `merged project ${project.name ?? project.id} on ${members.length} daemons`
|
|
839
|
+
: `project ${displayName(project)}`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Labels are shared across a cross-daemon merged group: reads union every
|
|
844
|
+
* member and writes fan out to every member (including hidden ones), same as
|
|
845
|
+
* the web settings page.
|
|
846
|
+
*/
|
|
847
|
+
async function loadLabelGroup(argv, deps) {
|
|
848
|
+
const project = await resolveTarget(argv, deps, argv.project);
|
|
849
|
+
const http = await buildHttp(deps);
|
|
850
|
+
const all = await listRawProjects(http);
|
|
851
|
+
const seed = all.find((entry) => entry.id === project.id);
|
|
852
|
+
if (!seed) {
|
|
853
|
+
const err = new Error(`Project not found: ${project.id}`);
|
|
854
|
+
err.statusCode = 404;
|
|
855
|
+
throw err;
|
|
856
|
+
}
|
|
857
|
+
const expanded = expandGroup(displayedGroup(all, seed), all);
|
|
858
|
+
const members = [seed, ...expanded.filter((entry) => entry.id !== seed.id)];
|
|
859
|
+
const labels = [];
|
|
860
|
+
const seen = new Set();
|
|
861
|
+
for (const member of members) {
|
|
862
|
+
for (const label of readLabels(member)) {
|
|
863
|
+
if (seen.has(label.id)) continue;
|
|
864
|
+
seen.add(label.id);
|
|
865
|
+
labels.push(label);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return { http, seed, members, labels };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function writeLabels(argv, deps, group, next) {
|
|
872
|
+
const bodies = group.members.map((member) => ({
|
|
873
|
+
member,
|
|
874
|
+
body: {
|
|
875
|
+
metadata: {
|
|
876
|
+
...metadataRecord(member),
|
|
877
|
+
[TASK_LABELS_KEY]: next.map((label) => ({ id: label.id, name: label.name })),
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
}));
|
|
881
|
+
if (argv.dryRun) {
|
|
882
|
+
for (const { member, body } of bodies) {
|
|
883
|
+
await sendOrPreview(group.http, argv, deps, "PATCH", "/api/projects", body, { query: projectQuery(member.id) });
|
|
884
|
+
}
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
const failures = [];
|
|
888
|
+
for (const { member, body } of bodies) {
|
|
889
|
+
try {
|
|
890
|
+
await group.http.patch("/api/projects", body, { query: projectQuery(member.id) });
|
|
891
|
+
} catch (error) {
|
|
892
|
+
failures.push(error);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (failures.length > 0) {
|
|
896
|
+
if (failures.length === bodies.length) throw failures[0];
|
|
897
|
+
const err = new Error(
|
|
898
|
+
`Saved on ${bodies.length - failures.length} of ${bodies.length} merged projects: ${failures[0].message}`,
|
|
899
|
+
);
|
|
900
|
+
err.statusCode = failures[0].statusCode;
|
|
901
|
+
err.details = failures[0].details;
|
|
902
|
+
throw err;
|
|
903
|
+
}
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function findLabel(labels, selector) {
|
|
908
|
+
const text = String(selector ?? "");
|
|
909
|
+
const byId = labels.find((label) => label.id === text);
|
|
910
|
+
if (byId) return byId;
|
|
911
|
+
const byName = labels.find((label) => labelKey(label.name) === labelKey(text));
|
|
912
|
+
if (byName) return byName;
|
|
913
|
+
const err = new Error(`No label matching '${text}'`);
|
|
914
|
+
err.statusCode = 404;
|
|
915
|
+
throw err;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function printLabels(argv, deps, labels) {
|
|
919
|
+
if (argv.json) {
|
|
920
|
+
printJson(deps.stdout, labels);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (labels.length === 0) {
|
|
924
|
+
printPretty(deps.stdout, "(no labels)");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
printPretty(deps.stdout, `${pad("ID", 38)} NAME`);
|
|
928
|
+
for (const label of labels) printPretty(deps.stdout, `${pad(label.id, 38)} ${label.name}`);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function handleLabelsList(argv, deps) {
|
|
932
|
+
const group = await loadLabelGroup({ ...argv, project: argv.idOrName ?? argv.project }, deps);
|
|
933
|
+
printLabels(argv, deps, group.labels);
|
|
934
|
+
return EXIT.OK;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async function handleLabelsAdd(argv, deps) {
|
|
938
|
+
const name = normalizeLabelName(String(argv.name ?? ""));
|
|
939
|
+
if (!name) throw argsError("Label name cannot be empty");
|
|
940
|
+
const group = await loadLabelGroup(argv, deps);
|
|
941
|
+
if (group.labels.length >= MAX_TASK_LABELS_PER_PROJECT) {
|
|
942
|
+
throw argsError(`A project can have at most ${MAX_TASK_LABELS_PER_PROJECT} labels`);
|
|
943
|
+
}
|
|
944
|
+
if (group.labels.some((label) => labelKey(label.name) === labelKey(name))) {
|
|
945
|
+
throw argsError(`Label "${name}" already exists for this project`);
|
|
946
|
+
}
|
|
947
|
+
const label = { id: randomUUID(), name };
|
|
948
|
+
const next = [...group.labels, label];
|
|
949
|
+
if (!(await writeLabels(argv, deps, group, next))) return EXIT.OK;
|
|
950
|
+
if (argv.json) {
|
|
951
|
+
printJson(deps.stdout, label);
|
|
952
|
+
return EXIT.OK;
|
|
953
|
+
}
|
|
954
|
+
printPretty(deps.stdout, `Added label ${name} (${label.id})`);
|
|
955
|
+
return EXIT.OK;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function handleLabelsRename(argv, deps) {
|
|
959
|
+
const name = normalizeLabelName(String(argv.newName ?? ""));
|
|
960
|
+
if (!name) throw argsError("Label name cannot be empty");
|
|
961
|
+
const group = await loadLabelGroup(argv, deps);
|
|
962
|
+
const current = findLabel(group.labels, argv.label);
|
|
963
|
+
if (group.labels.some((label) => label.id !== current.id && labelKey(label.name) === labelKey(name))) {
|
|
964
|
+
throw argsError(`Label "${name}" already exists for this project`);
|
|
965
|
+
}
|
|
966
|
+
const next = group.labels.map((label) => (label.id === current.id ? { ...label, name } : label));
|
|
967
|
+
if (!(await writeLabels(argv, deps, group, next))) return EXIT.OK;
|
|
968
|
+
if (argv.json) {
|
|
969
|
+
printJson(deps.stdout, { id: current.id, name });
|
|
970
|
+
return EXIT.OK;
|
|
971
|
+
}
|
|
972
|
+
printPretty(deps.stdout, `Renamed label ${current.name} -> ${name}`);
|
|
973
|
+
return EXIT.OK;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function handleLabelsRemove(argv, deps) {
|
|
977
|
+
const group = await loadLabelGroup(argv, deps);
|
|
978
|
+
const current = findLabel(group.labels, argv.label);
|
|
979
|
+
const next = group.labels.filter((label) => label.id !== current.id);
|
|
980
|
+
if (!(await writeLabels(argv, deps, group, next))) return EXIT.OK;
|
|
981
|
+
if (argv.json) {
|
|
982
|
+
printJson(deps.stdout, { removed: true, ...current });
|
|
983
|
+
return EXIT.OK;
|
|
984
|
+
}
|
|
985
|
+
printPretty(deps.stdout, `Removed label ${current.name} (${current.id})`);
|
|
325
986
|
return EXIT.OK;
|
|
326
987
|
}
|
|
327
988
|
|
|
@@ -375,7 +1036,7 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
375
1036
|
(cmd) => cmd
|
|
376
1037
|
.option("name", { type: "string", describe: "Project name (defaults to basename of workspace path)" })
|
|
377
1038
|
.option("workspace-path", { type: "string", describe: "Workspace path (defaults to cwd)" })
|
|
378
|
-
.option("daemon-host", { type: "string", describe: "Daemon hostname for binding" })
|
|
1039
|
+
.option("daemon-host", { type: "string", describe: "Daemon hostname for binding (default: this machine's daemon name)" })
|
|
379
1040
|
.option("create-workspace", { type: "boolean", default: false, describe: "Create the workspace path on the daemon if it does not exist" })
|
|
380
1041
|
.option("default", { type: "boolean", default: false, describe: "Create the user's default project" })
|
|
381
1042
|
.option("client-request-id", { type: "string", describe: "Idempotency key" }),
|
|
@@ -395,7 +1056,7 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
395
1056
|
)
|
|
396
1057
|
.command(
|
|
397
1058
|
"hide <idOrName>",
|
|
398
|
-
"Hide a project from default listings",
|
|
1059
|
+
"Hide a project from default listings (on every daemon of a merged project)",
|
|
399
1060
|
(cmd) => cmd
|
|
400
1061
|
.positional("idOrName", { type: "string", demandOption: true })
|
|
401
1062
|
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
@@ -405,7 +1066,7 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
405
1066
|
)
|
|
406
1067
|
.command(
|
|
407
1068
|
"unhide <idOrName>",
|
|
408
|
-
"Unhide a previously hidden project",
|
|
1069
|
+
"Unhide a previously hidden project (on every daemon of a merged project)",
|
|
409
1070
|
(cmd) => cmd
|
|
410
1071
|
.positional("idOrName", { type: "string", demandOption: true })
|
|
411
1072
|
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
@@ -413,6 +1074,158 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
413
1074
|
exitCode = await handleSetHidden(argv, { ...handlerDeps, configFile: argv.configFile }, false);
|
|
414
1075
|
},
|
|
415
1076
|
)
|
|
1077
|
+
.command(
|
|
1078
|
+
"update <idOrName>",
|
|
1079
|
+
"Update a project's name, merge opt-out or (unbound projects only) workspace binding",
|
|
1080
|
+
(cmd) => cmd
|
|
1081
|
+
.positional("idOrName", { type: "string", demandOption: true })
|
|
1082
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" })
|
|
1083
|
+
.option("name", { type: "string", describe: "New project name" })
|
|
1084
|
+
.option("merge-opt-out", {
|
|
1085
|
+
type: "string",
|
|
1086
|
+
describe: "true splits its cross-daemon merged group (every member), false merges it with same-name projects on other daemons",
|
|
1087
|
+
})
|
|
1088
|
+
.option("workspace-path", { type: "string", describe: "Bind an unbound project to this workspace path" })
|
|
1089
|
+
.option("bind-daemon-host", { type: "string", describe: "Daemon for --workspace-path (defaults to the project's daemon)" })
|
|
1090
|
+
.option("json-body", { type: "string", describe: "Extra raw PATCH fields as a JSON object (merged last)" }),
|
|
1091
|
+
async (argv) => {
|
|
1092
|
+
exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1093
|
+
},
|
|
1094
|
+
)
|
|
1095
|
+
.command(
|
|
1096
|
+
"refresh <idOrName>",
|
|
1097
|
+
"Re-validate the project's binding with its daemon and refresh git snapshot fields",
|
|
1098
|
+
(cmd) => cmd
|
|
1099
|
+
.positional("idOrName", { type: "string", demandOption: true })
|
|
1100
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
1101
|
+
async (argv) => {
|
|
1102
|
+
exitCode = await handleRefresh(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1103
|
+
},
|
|
1104
|
+
)
|
|
1105
|
+
.command(
|
|
1106
|
+
"delete <idOrName>",
|
|
1107
|
+
"Delete a project, on every daemon if merged (stops its running tasks and deletes its tasks); requires --yes",
|
|
1108
|
+
(cmd) => cmd
|
|
1109
|
+
.positional("idOrName", { type: "string", demandOption: true })
|
|
1110
|
+
.option("daemon-host", { type: "string", describe: "Delete only the copy on this daemon, not the whole merged group" })
|
|
1111
|
+
.option("yes", { type: "boolean", default: false, describe: "Confirm the deletion" }),
|
|
1112
|
+
async (argv) => {
|
|
1113
|
+
exitCode = await handleDelete(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1114
|
+
},
|
|
1115
|
+
)
|
|
1116
|
+
.command(
|
|
1117
|
+
"reorder <idOrNames..>",
|
|
1118
|
+
"Reorder projects: the listed projects go first, the rest keep their current order",
|
|
1119
|
+
(cmd) => cmd
|
|
1120
|
+
.positional("idOrNames", { type: "string", array: true })
|
|
1121
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
1122
|
+
async (argv) => {
|
|
1123
|
+
exitCode = await handleReorder(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1124
|
+
},
|
|
1125
|
+
)
|
|
1126
|
+
.command(
|
|
1127
|
+
"agents [idOrName]",
|
|
1128
|
+
"List the agents a project registers (usable when creating multi-agent tasks)",
|
|
1129
|
+
(cmd) => cmd
|
|
1130
|
+
.positional("idOrName", { type: "string" })
|
|
1131
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
1132
|
+
async (argv) => {
|
|
1133
|
+
exitCode = await handleAgents(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1134
|
+
},
|
|
1135
|
+
)
|
|
1136
|
+
.command(
|
|
1137
|
+
"collab",
|
|
1138
|
+
"Project collaboration: invite, join, leave",
|
|
1139
|
+
(cmd) => cmd
|
|
1140
|
+
.command(
|
|
1141
|
+
"invite [idOrName]",
|
|
1142
|
+
"Start (or reuse) a collaboration for a project and print its invite link",
|
|
1143
|
+
(sub) => sub
|
|
1144
|
+
.positional("idOrName", { type: "string" })
|
|
1145
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
1146
|
+
async (argv) => {
|
|
1147
|
+
exitCode = await handleCollabInvite(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1148
|
+
},
|
|
1149
|
+
)
|
|
1150
|
+
.command(
|
|
1151
|
+
"join <token>",
|
|
1152
|
+
"Join a collaboration from an invite token or invite URL",
|
|
1153
|
+
(sub) => sub
|
|
1154
|
+
.positional("token", { type: "string", describe: "Invite token or invite URL" })
|
|
1155
|
+
.option("into", { type: "string", describe: "Existing project (id or name) to join with" })
|
|
1156
|
+
.option("create-project", { type: "string", describe: "Create a new project with this name to join with" })
|
|
1157
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate --into across daemons" }),
|
|
1158
|
+
async (argv) => {
|
|
1159
|
+
exitCode = await handleCollabJoin(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1160
|
+
},
|
|
1161
|
+
)
|
|
1162
|
+
.command(
|
|
1163
|
+
"show-invite <token>",
|
|
1164
|
+
"Preview a collaboration invite before joining",
|
|
1165
|
+
(sub) => sub.positional("token", { type: "string", describe: "Invite token or invite URL" }),
|
|
1166
|
+
async (argv) => {
|
|
1167
|
+
exitCode = await handleCollabShowInvite(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1168
|
+
},
|
|
1169
|
+
)
|
|
1170
|
+
.command(
|
|
1171
|
+
"leave <idOrName>",
|
|
1172
|
+
"Leave the collaboration a project belongs to",
|
|
1173
|
+
(sub) => sub
|
|
1174
|
+
.positional("idOrName", { type: "string", demandOption: true })
|
|
1175
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
1176
|
+
async (argv) => {
|
|
1177
|
+
exitCode = await handleCollabLeave(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1178
|
+
},
|
|
1179
|
+
)
|
|
1180
|
+
.demandCommand(1),
|
|
1181
|
+
)
|
|
1182
|
+
.command(
|
|
1183
|
+
"labels",
|
|
1184
|
+
"Task label definitions (shared across a cross-daemon merged project group)",
|
|
1185
|
+
(cmd) => cmd
|
|
1186
|
+
.command(
|
|
1187
|
+
"list [idOrName]",
|
|
1188
|
+
"List task labels",
|
|
1189
|
+
(sub) => sub
|
|
1190
|
+
.positional("idOrName", { type: "string" })
|
|
1191
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate same-name projects across daemons" }),
|
|
1192
|
+
async (argv) => {
|
|
1193
|
+
exitCode = await handleLabelsList(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1194
|
+
},
|
|
1195
|
+
)
|
|
1196
|
+
.command(
|
|
1197
|
+
"add <name>",
|
|
1198
|
+
"Add a task label (target project via --project)",
|
|
1199
|
+
(sub) => sub
|
|
1200
|
+
.positional("name", { type: "string" })
|
|
1201
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate --project across daemons" }),
|
|
1202
|
+
async (argv) => {
|
|
1203
|
+
exitCode = await handleLabelsAdd(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1204
|
+
},
|
|
1205
|
+
)
|
|
1206
|
+
.command(
|
|
1207
|
+
"rename <label> <newName>",
|
|
1208
|
+
"Rename a task label (label id or name)",
|
|
1209
|
+
(sub) => sub
|
|
1210
|
+
.positional("label", { type: "string" })
|
|
1211
|
+
.positional("newName", { type: "string" })
|
|
1212
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate --project across daemons" }),
|
|
1213
|
+
async (argv) => {
|
|
1214
|
+
exitCode = await handleLabelsRename(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1215
|
+
},
|
|
1216
|
+
)
|
|
1217
|
+
.command(
|
|
1218
|
+
"remove <label>",
|
|
1219
|
+
"Remove a task label (label id or name)",
|
|
1220
|
+
(sub) => sub
|
|
1221
|
+
.positional("label", { type: "string" })
|
|
1222
|
+
.option("daemon-host", { type: "string", describe: "Disambiguate --project across daemons" }),
|
|
1223
|
+
async (argv) => {
|
|
1224
|
+
exitCode = await handleLabelsRemove(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
1225
|
+
},
|
|
1226
|
+
)
|
|
1227
|
+
.demandCommand(1),
|
|
1228
|
+
)
|
|
416
1229
|
.demandCommand(1)
|
|
417
1230
|
.fail((msg, err) => {
|
|
418
1231
|
if (err) {
|