@gh-symphony/cli 0.0.6 → 0.0.8

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.
@@ -1,101 +1,631 @@
1
1
  import * as p from "@clack/prompts";
2
- import { loadGlobalConfig, saveGlobalConfig, loadTenantConfig, } from "../config.js";
2
+ import { execFile as execFileCallback } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { stripAnsi } from "../ansi.js";
7
+ import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
8
+ import { ensureGhAuth, getGhToken, GhAuthError } from "../github/gh-auth.js";
9
+ import { loadGlobalConfig, saveGlobalConfig, loadProjectConfig, projectConfigDir, daemonPidPath, } from "../config.js";
10
+ import { writeConfig, generateProjectId, abortIfCancelled } from "./init.js";
11
+ import startCommand from "./start.js";
12
+ import statusCommand from "./status.js";
13
+ import stopCommand from "./stop.js";
14
+ import { resolveProjectOrchestratorStatusBaseUrl, } from "../orchestrator-status-endpoint.js";
15
+ import { resolveRuntimeRoot } from "../orchestrator-runtime.js";
16
+ const execFile = promisify(execFileCallback);
17
+ const STATUS_REQUEST_TIMEOUT_MS = 1_500;
18
+ const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
19
+ function displayScopeError(error, retryCommand) {
20
+ const plural = error.requiredScopes.length === 1 ? "" : "s";
21
+ p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
22
+ const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
23
+ const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
24
+ const scopeArg = scopesToAdd.length > 0
25
+ ? scopesToAdd.join(",")
26
+ : error.requiredScopes.join(",");
27
+ p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
28
+ }
29
+ function parseProjectAddFlags(args) {
30
+ const flags = { nonInteractive: false, assignedOnly: false };
31
+ for (let i = 0; i < args.length; i += 1) {
32
+ const arg = args[i];
33
+ const next = args[i + 1];
34
+ switch (arg) {
35
+ case "--non-interactive":
36
+ flags.nonInteractive = true;
37
+ break;
38
+ case "--project":
39
+ flags.project = next;
40
+ i += 1;
41
+ break;
42
+ case "--workspace-dir":
43
+ flags.workspaceDir = next;
44
+ i += 1;
45
+ break;
46
+ case "--assigned-only":
47
+ flags.assignedOnly = true;
48
+ break;
49
+ }
50
+ }
51
+ return flags;
52
+ }
3
53
  const handler = async (args, options) => {
4
- const [subcommand] = args;
54
+ const [subcommand, ...rest] = args;
5
55
  switch (subcommand) {
56
+ case "add":
57
+ await projectAdd(rest, options);
58
+ return;
6
59
  case "list":
7
60
  await projectList(options);
8
- break;
61
+ return;
62
+ case "remove":
63
+ await projectRemove(rest, options);
64
+ return;
65
+ case "start":
66
+ await startCommand(rest, options);
67
+ return;
68
+ case "stop":
69
+ await stopCommand(rest, options);
70
+ return;
9
71
  case "switch":
10
72
  await projectSwitch(options);
11
- break;
73
+ return;
12
74
  case "status":
13
- await projectStatus(options);
14
- break;
75
+ await statusCommand(rest, options);
76
+ return;
15
77
  default:
16
- process.stderr.write("Usage: gh-symphony project <list|switch|status>\n");
17
- process.exitCode = 2;
78
+ process.stdout.write("Usage: gh-symphony project <add|list|remove|start|stop|switch|status>\n");
18
79
  }
19
80
  };
20
81
  export default handler;
21
- // ── 6.1: project list ────────────────────────────────────────────────────────
22
- async function projectList(options) {
23
- const global = await loadGlobalConfig(options.configDir);
24
- if (!global || global.tenants.length === 0) {
25
- process.stdout.write("No tenants configured. Run 'gh-symphony init'.\n");
82
+ function relativeTimeFromNow(isoString, now = new Date()) {
83
+ const then = new Date(isoString);
84
+ if (!Number.isFinite(then.getTime())) {
85
+ return "-";
86
+ }
87
+ const diffMs = Math.max(0, now.getTime() - then.getTime());
88
+ const diffS = Math.floor(diffMs / 1000);
89
+ const diffM = Math.floor(diffS / 60);
90
+ const diffH = Math.floor(diffM / 60);
91
+ const diffD = Math.floor(diffH / 24);
92
+ if (diffS < 60)
93
+ return `${diffS}s ago`;
94
+ if (diffM < 60)
95
+ return `${diffM}m ago`;
96
+ if (diffH < 24)
97
+ return `${diffH}h ago`;
98
+ return `${diffD}d ago`;
99
+ }
100
+ function formatDuration(seconds) {
101
+ if (seconds < 60)
102
+ return `${seconds}s`;
103
+ const days = Math.floor(seconds / 86_400);
104
+ const hours = Math.floor((seconds % 86_400) / 3_600);
105
+ const minutes = Math.floor((seconds % 3_600) / 60);
106
+ if (days > 0) {
107
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
108
+ }
109
+ if (hours > 0) {
110
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
111
+ }
112
+ return `${minutes}m`;
113
+ }
114
+ function parsePsElapsedTime(raw) {
115
+ const value = raw.trim();
116
+ if (value.length === 0) {
117
+ return null;
118
+ }
119
+ const [dayPart, timePart] = value.includes("-")
120
+ ? value.split("-", 2)
121
+ : [null, value];
122
+ const timeSegments = timePart
123
+ .split(":")
124
+ .map((segment) => Number.parseInt(segment, 10));
125
+ if (timeSegments.some((segment) => !Number.isFinite(segment))) {
126
+ return null;
127
+ }
128
+ let seconds = 0;
129
+ if (timeSegments.length === 3) {
130
+ seconds += timeSegments[0] * 3_600;
131
+ seconds += timeSegments[1] * 60;
132
+ seconds += timeSegments[2];
133
+ }
134
+ else if (timeSegments.length === 2) {
135
+ seconds += timeSegments[0] * 60;
136
+ seconds += timeSegments[1];
137
+ }
138
+ else {
139
+ return null;
140
+ }
141
+ if (dayPart !== null) {
142
+ const days = Number.parseInt(dayPart, 10);
143
+ if (!Number.isFinite(days)) {
144
+ return null;
145
+ }
146
+ seconds += days * 86_400;
147
+ }
148
+ return seconds;
149
+ }
150
+ async function readPid(configDir, projectId) {
151
+ try {
152
+ const raw = await readFile(daemonPidPath(configDir, projectId), "utf8");
153
+ const pid = Number.parseInt(raw.trim(), 10);
154
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ function isProcessRunning(pid) {
161
+ try {
162
+ process.kill(pid, 0);
163
+ return true;
164
+ }
165
+ catch (error) {
166
+ if (error instanceof Error &&
167
+ "code" in error &&
168
+ error.code === "EPERM") {
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ }
174
+ async function readPersistedSnapshot(configDir, projectId) {
175
+ try {
176
+ const runtimeRoot = resolveRuntimeRoot(configDir);
177
+ const content = await readFile(join(runtimeRoot, "orchestrator", "projects", projectId, "status.json"), "utf8");
178
+ return JSON.parse(content);
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ async function fetchProjectSnapshot(configDir, projectId, baseUrl) {
185
+ if (!baseUrl) {
186
+ return readPersistedSnapshot(configDir, projectId);
187
+ }
188
+ const controller = new AbortController();
189
+ const timeout = setTimeout(() => controller.abort(), STATUS_REQUEST_TIMEOUT_MS);
190
+ try {
191
+ const response = await fetch(`${baseUrl}/api/v1/status`, {
192
+ signal: controller.signal,
193
+ });
194
+ if (!response.ok) {
195
+ return readPersistedSnapshot(configDir, projectId);
196
+ }
197
+ return (await response.json());
198
+ }
199
+ catch {
200
+ return readPersistedSnapshot(configDir, projectId);
201
+ }
202
+ finally {
203
+ clearTimeout(timeout);
204
+ }
205
+ }
206
+ async function readProcessUptime(pid) {
207
+ if (process.platform === "win32") {
208
+ return "-";
209
+ }
210
+ try {
211
+ const { stdout } = await execFile("ps", ["-o", "etime=", "-p", String(pid)]);
212
+ const seconds = parsePsElapsedTime(stdout);
213
+ return seconds === null ? "-" : formatDuration(seconds);
214
+ }
215
+ catch {
216
+ return "-";
217
+ }
218
+ }
219
+ function defaultProjectName(config, projectId) {
220
+ return config?.displayName ?? config?.slug ?? projectId;
221
+ }
222
+ function isCombiningCodePoint(codePoint) {
223
+ return ((codePoint >= 0x0300 && codePoint <= 0x036f) ||
224
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
225
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
226
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
227
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f));
228
+ }
229
+ function isWideCodePoint(codePoint) {
230
+ return (codePoint >= 0x1100 &&
231
+ (codePoint <= 0x115f ||
232
+ codePoint === 0x2329 ||
233
+ codePoint === 0x232a ||
234
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
235
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
236
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
237
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
238
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
239
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
240
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
241
+ (codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
242
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
243
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)));
244
+ }
245
+ function stringDisplayWidth(value) {
246
+ const visible = stripAnsi(value);
247
+ let width = 0;
248
+ for (const char of visible) {
249
+ const codePoint = char.codePointAt(0);
250
+ if (codePoint === undefined) {
251
+ continue;
252
+ }
253
+ if (codePoint === 0 ||
254
+ codePoint < 0x20 ||
255
+ (codePoint >= 0x7f && codePoint < 0xa0) ||
256
+ isCombiningCodePoint(codePoint)) {
257
+ continue;
258
+ }
259
+ width += isWideCodePoint(codePoint) ? 2 : 1;
260
+ }
261
+ return width;
262
+ }
263
+ async function collectProjectListRows(configDir, global) {
264
+ return Promise.all(global.projects.map(async (projectId) => {
265
+ const config = await loadProjectConfig(configDir, projectId);
266
+ const pid = await readPid(configDir, projectId);
267
+ const running = pid !== null && isProcessRunning(pid);
268
+ const endpointBaseUrl = running
269
+ ? await resolveProjectOrchestratorStatusBaseUrl({
270
+ configDir,
271
+ projectId,
272
+ })
273
+ : null;
274
+ const endpoint = endpointBaseUrl ?? "-";
275
+ const snapshot = running
276
+ ? await fetchProjectSnapshot(configDir, projectId, endpointBaseUrl)
277
+ : null;
278
+ return {
279
+ id: projectId,
280
+ name: defaultProjectName(config, projectId),
281
+ status: running ? "running" : "stopped",
282
+ endpoint,
283
+ health: snapshot?.health ?? "-",
284
+ activeRuns: snapshot?.summary.activeRuns ?? null,
285
+ lastTick: snapshot?.lastTickAt
286
+ ? relativeTimeFromNow(snapshot.lastTickAt)
287
+ : "-",
288
+ uptime: pid !== null && running ? await readProcessUptime(pid) : "-",
289
+ active: global.activeProject === projectId,
290
+ };
291
+ }));
292
+ }
293
+ function renderTable(headers, rows) {
294
+ const widths = headers.map((header, index) => Math.max(stringDisplayWidth(header), ...rows.map((row) => stringDisplayWidth(row[index] ?? ""))));
295
+ const formatRow = (left, sep, right, values) => left +
296
+ values
297
+ .map((value, index) => {
298
+ const width = widths[index];
299
+ const displayWidth = stringDisplayWidth(value);
300
+ return ` ${value}${" ".repeat(width - displayWidth)} `;
301
+ })
302
+ .join(sep) +
303
+ right;
304
+ const border = (left, middle, right) => left + widths.map((width) => "─".repeat(width + 2)).join(middle) + right;
305
+ return [
306
+ border("┌", "┬", "┐"),
307
+ formatRow("│", "│", "│", headers),
308
+ border("├", "┼", "┤"),
309
+ ...rows.map((row) => formatRow("│", "│", "│", row)),
310
+ border("└", "┴", "┘"),
311
+ ].join("\n");
312
+ }
313
+ async function projectAdd(args, options) {
314
+ const flags = parseProjectAddFlags(args);
315
+ if (flags.nonInteractive) {
316
+ await projectAddNonInteractive(flags, options);
26
317
  return;
27
318
  }
28
- if (options.json) {
29
- const configs = [];
30
- for (const tId of global.tenants) {
31
- const t = await loadTenantConfig(options.configDir, tId);
32
- configs.push({
33
- id: tId,
34
- active: tId === global.activeTenant,
35
- repos: t?.repositories.length ?? 0,
36
- });
319
+ await projectAddInteractive(options);
320
+ }
321
+ async function projectAddNonInteractive(flags, options) {
322
+ let token;
323
+ try {
324
+ token = getGhToken();
325
+ }
326
+ catch {
327
+ process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
328
+ process.exitCode = 1;
329
+ return;
330
+ }
331
+ const client = createClient(token);
332
+ let viewer;
333
+ try {
334
+ viewer = await validateToken(client);
335
+ }
336
+ catch {
337
+ process.stderr.write("Error: Invalid GitHub token.\n");
338
+ process.exitCode = 1;
339
+ return;
340
+ }
341
+ const scopeCheck = checkRequiredScopes(viewer.scopes);
342
+ if (!scopeCheck.valid) {
343
+ process.stderr.write(`Error: Missing required PAT scopes: ${scopeCheck.missing.join(", ")}\n`);
344
+ process.exitCode = 1;
345
+ return;
346
+ }
347
+ const projects = await listUserProjects(client);
348
+ let project;
349
+ if (flags.project) {
350
+ const match = projects.find((entry) => entry.id === flags.project || entry.url === flags.project);
351
+ if (!match) {
352
+ process.stderr.write(`Error: Project not found: ${flags.project}\n`);
353
+ process.exitCode = 1;
354
+ return;
37
355
  }
38
- process.stdout.write(JSON.stringify(configs, null, 2) + "\n");
356
+ project = await getProjectDetail(client, match.id);
357
+ }
358
+ else if (projects.length === 1) {
359
+ project = await getProjectDetail(client, projects[0].id);
360
+ }
361
+ else {
362
+ process.stderr.write("Error: --project is required when multiple projects exist.\n");
363
+ process.exitCode = 1;
39
364
  return;
40
365
  }
41
- process.stdout.write("Tenants:\n\n");
42
- for (const tId of global.tenants) {
43
- const t = await loadTenantConfig(options.configDir, tId);
44
- const active = tId === global.activeTenant ? " (active)" : "";
45
- const repos = t?.repositories.length ?? 0;
46
- process.stdout.write(` ${tId}${active} — ${repos} repo${repos === 1 ? "" : "s"}\n`);
366
+ const projectId = generateProjectId(project.title, project.id);
367
+ const workspaceDir = flags.workspaceDir ?? `${options.configDir}/workspaces`;
368
+ await writeConfig(options.configDir, {
369
+ projectId,
370
+ project,
371
+ repos: project.linkedRepositories,
372
+ workspaceDir,
373
+ assignedOnly: flags.assignedOnly,
374
+ });
375
+ if (options.json) {
376
+ process.stdout.write(JSON.stringify({ projectId, status: "created" }) + "\n");
377
+ }
378
+ else {
379
+ process.stdout.write(`Project created: ${projectId}\n`);
380
+ process.stdout.write(`Run 'gh-symphony start' to begin orchestration.\n`);
47
381
  }
48
382
  }
49
- // ── 6.2: project switch ──────────────────────────────────────────────────────
50
- async function projectSwitch(options) {
51
- const global = await loadGlobalConfig(options.configDir);
52
- if (!global || global.tenants.length === 0) {
53
- process.stderr.write("No tenants configured. Run 'gh-symphony init'.\n");
383
+ async function projectAddInteractive(options) {
384
+ p.intro("gh-symphony - Project Setup");
385
+ const existingConfig = await loadGlobalConfig(options.configDir);
386
+ if (existingConfig) {
387
+ const action = await abortIfCancelled(p.select({
388
+ message: "Existing configuration detected. What would you like to do?",
389
+ options: [
390
+ { value: "add", label: "Add a new project" },
391
+ { value: "overwrite", label: "Start fresh (overwrite)" },
392
+ ],
393
+ }));
394
+ if (action === "overwrite") {
395
+ // Continue with fresh setup and overwrite the active config.
396
+ }
397
+ }
398
+ const s1 = p.spinner();
399
+ s1.start("Checking gh CLI authentication...");
400
+ let login;
401
+ let client;
402
+ try {
403
+ const { login: ghLogin, token } = ensureGhAuth();
404
+ login = ghLogin;
405
+ client = createClient(token);
406
+ s1.stop(`Authenticated as ${login}`);
407
+ }
408
+ catch (error) {
409
+ s1.stop("Authentication failed.");
410
+ if (error instanceof GhAuthError) {
411
+ if (error.code === "not_installed") {
412
+ p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
413
+ }
414
+ else if (error.code === "not_authenticated") {
415
+ p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
416
+ }
417
+ else if (error.code === "missing_scopes") {
418
+ p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
419
+ }
420
+ else {
421
+ p.log.error(error.message);
422
+ }
423
+ }
424
+ else {
425
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
426
+ }
54
427
  process.exitCode = 1;
55
428
  return;
56
429
  }
57
- if (global.tenants.length === 1) {
58
- process.stdout.write(`Only one tenant exists: ${global.tenants[0]}\n`);
430
+ const s2 = p.spinner();
431
+ s2.start("Loading GitHub Project boards...");
432
+ let projects;
433
+ try {
434
+ projects = await listUserProjects(client);
435
+ s2.stop(`Found ${projects.length} project${projects.length === 1 ? "" : "s"}`);
436
+ }
437
+ catch (error) {
438
+ s2.stop("Failed to load projects.");
439
+ if (error instanceof GitHubScopeError) {
440
+ displayScopeError(error, "gh-symphony project add");
441
+ }
442
+ else {
443
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
444
+ }
445
+ process.exitCode = 1;
59
446
  return;
60
447
  }
61
- const selected = await p.select({
62
- message: "Select tenant to activate:",
63
- options: global.tenants.map((tId) => ({
64
- value: tId,
65
- label: tId,
66
- hint: tId === global.activeTenant ? "current" : undefined,
448
+ if (projects.length === 0) {
449
+ p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
450
+ process.exitCode = 1;
451
+ return;
452
+ }
453
+ const selectedProjectId = await abortIfCancelled(p.select({
454
+ message: "Step 1/4 - Select a GitHub Project board:",
455
+ options: projects.map((project) => ({
456
+ value: project.id,
457
+ label: `${project.owner.login}/${project.title}`,
458
+ hint: `${project.openItemCount} items`,
67
459
  })),
68
- });
69
- if (p.isCancel(selected)) {
70
- p.cancel("Cancelled.");
460
+ maxItems: 15,
461
+ }));
462
+ const s2d = p.spinner();
463
+ s2d.start("Loading project details...");
464
+ let projectDetail;
465
+ try {
466
+ projectDetail = await getProjectDetail(client, selectedProjectId);
467
+ s2d.stop(`Loaded: ${projectDetail.title}`);
468
+ }
469
+ catch (error) {
470
+ s2d.stop("Failed to load project details.");
471
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
472
+ process.exitCode = 1;
71
473
  return;
72
474
  }
73
- global.activeTenant = selected;
74
- await saveGlobalConfig(options.configDir, global);
75
- process.stdout.write(`Switched to tenant: ${selected}\n`);
475
+ if (projectDetail.linkedRepositories.length === 0) {
476
+ p.log.warn("No linked repositories found in this project. Add issues from repositories to the project first.");
477
+ process.exitCode = 1;
478
+ return;
479
+ }
480
+ const selectedRepos = await abortIfCancelled(p.multiselect({
481
+ message: "Step 2/4 - Select repositories to orchestrate:",
482
+ options: projectDetail.linkedRepositories.map((repo) => ({
483
+ value: repo,
484
+ label: `${repo.owner}/${repo.name}`,
485
+ })),
486
+ required: true,
487
+ }));
488
+ const assignedOnly = await abortIfCancelled(p.confirm({
489
+ message: "Step 3/4 - Only process issues assigned to the authenticated GitHub user?",
490
+ initialValue: false,
491
+ }));
492
+ const workspaceDir = await abortIfCancelled(p.text({
493
+ message: "Step 4/4 - Workspace root directory:",
494
+ placeholder: `${options.configDir}/workspaces`,
495
+ defaultValue: `${options.configDir}/workspaces`,
496
+ validate(value) {
497
+ return value.trim().length > 0
498
+ ? undefined
499
+ : "Workspace directory is required.";
500
+ },
501
+ }));
502
+ p.note([
503
+ `User: ${login}`,
504
+ `Project: ${projectDetail.title}`,
505
+ `Repos: ${selectedRepos.map((repo) => `${repo.owner}/${repo.name}`).join(", ")}`,
506
+ `Assigned: ${assignedOnly ? `Only issues assigned to ${login}` : "All project issues"}`,
507
+ `Workspace: ${workspaceDir}`,
508
+ ].join("\n"), "Configuration Summary");
509
+ const confirmed = await abortIfCancelled(p.confirm({ message: "Apply this configuration?" }));
510
+ if (!confirmed) {
511
+ p.cancel("Setup cancelled.");
512
+ process.exitCode = 130;
513
+ return;
514
+ }
515
+ const projectId = generateProjectId(projectDetail.title, projectDetail.id);
516
+ const s6 = p.spinner();
517
+ s6.start("Writing configuration...");
518
+ try {
519
+ await writeConfig(options.configDir, {
520
+ projectId,
521
+ project: projectDetail,
522
+ repos: selectedRepos,
523
+ workspaceDir,
524
+ assignedOnly,
525
+ });
526
+ s6.stop("Configuration saved.");
527
+ }
528
+ catch (error) {
529
+ s6.stop("Failed to write configuration.");
530
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
531
+ process.exitCode = 1;
532
+ return;
533
+ }
534
+ p.outro(`Project "${projectId}" created.\n Run 'gh-symphony start' to begin orchestration.`);
76
535
  }
77
- // ── 6.3: project status ──────────────────────────────────────────────────────
78
- async function projectStatus(options) {
536
+ async function projectList(options) {
79
537
  const global = await loadGlobalConfig(options.configDir);
80
- if (!global?.activeTenant) {
81
- process.stderr.write("No active tenant.\n");
538
+ if (!global?.projects?.length) {
539
+ process.stdout.write("No projects configured.\n");
540
+ return;
541
+ }
542
+ const rows = await collectProjectListRows(options.configDir, global);
543
+ if (options.json) {
544
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
545
+ return;
546
+ }
547
+ const table = renderTable([
548
+ "ID",
549
+ "Name",
550
+ "Status",
551
+ "Endpoint",
552
+ "Health",
553
+ "Active Runs",
554
+ "Last Tick",
555
+ "Uptime",
556
+ ], rows.map((row) => [
557
+ row.id,
558
+ row.name,
559
+ row.status,
560
+ row.endpoint,
561
+ row.health,
562
+ row.activeRuns === null ? "-" : String(row.activeRuns),
563
+ row.lastTick,
564
+ row.uptime,
565
+ ]));
566
+ process.stdout.write(`${table}\n`);
567
+ }
568
+ async function projectRemove(args, options) {
569
+ const projectId = args[0];
570
+ if (!projectId) {
571
+ process.stderr.write("Usage: gh-symphony project remove <project-id>\n");
82
572
  process.exitCode = 1;
83
573
  return;
84
574
  }
85
- const t = await loadTenantConfig(options.configDir, global.activeTenant);
86
- if (!t) {
87
- process.stderr.write(`Tenant config missing: ${global.activeTenant}\n`);
575
+ const global = await loadGlobalConfig(options.configDir);
576
+ if (!global) {
577
+ process.stderr.write("No configuration found.\n");
88
578
  process.exitCode = 1;
89
579
  return;
90
580
  }
91
- if (options.json) {
92
- process.stdout.write(JSON.stringify(t, null, 2) + "\n");
581
+ const updatedProjects = global.projects.filter((entry) => entry !== projectId);
582
+ if (updatedProjects.length === global.projects.length) {
583
+ process.stderr.write(`Project "${projectId}" not found.\n`);
584
+ process.exitCode = 1;
93
585
  return;
94
586
  }
95
- process.stdout.write(`Tenant: ${t.tenantId}\n`);
96
- process.stdout.write(`Tracker: ${t.tracker.adapter} (${t.tracker.bindingId})\n`);
97
- process.stdout.write(`Repositories:\n`);
98
- for (const repo of t.repositories) {
99
- process.stdout.write(` - ${repo.owner}/${repo.name}\n`);
587
+ const updatedConfig = {
588
+ ...global,
589
+ projects: updatedProjects,
590
+ activeProject: global.activeProject === projectId ? null : global.activeProject,
591
+ };
592
+ await saveGlobalConfig(options.configDir, updatedConfig);
593
+ const { rm } = await import("node:fs/promises");
594
+ try {
595
+ await rm(projectConfigDir(options.configDir, projectId), {
596
+ recursive: true,
597
+ force: true,
598
+ });
100
599
  }
600
+ catch {
601
+ // Directory may not exist.
602
+ }
603
+ process.stdout.write(`Project "${projectId}" removed.\n`);
604
+ }
605
+ async function projectSwitch(options) {
606
+ const global = await loadGlobalConfig(options.configDir);
607
+ if (!global || global.projects.length === 0) {
608
+ process.stderr.write("No projects configured. Run 'gh-symphony init'.\n");
609
+ process.exitCode = 1;
610
+ return;
611
+ }
612
+ if (global.projects.length === 1) {
613
+ process.stdout.write(`Only one project exists: ${global.projects[0]}\n`);
614
+ return;
615
+ }
616
+ const selected = await p.select({
617
+ message: "Select project to activate:",
618
+ options: global.projects.map((projectId) => ({
619
+ value: projectId,
620
+ label: projectId,
621
+ hint: projectId === global.activeProject ? "current" : undefined,
622
+ })),
623
+ });
624
+ if (p.isCancel(selected)) {
625
+ p.cancel("Cancelled.");
626
+ return;
627
+ }
628
+ global.activeProject = selected;
629
+ await saveGlobalConfig(options.configDir, global);
630
+ process.stdout.write(`Switched to project: ${selected}\n`);
101
631
  }