@gh-symphony/cli 0.0.1
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/LICENSE +21 -0
- package/dist/commands/config-cmd.d.ts +3 -0
- package/dist/commands/config-cmd.js +106 -0
- package/dist/commands/help.d.ts +3 -0
- package/dist/commands/help.js +47 -0
- package/dist/commands/init.d.ts +26 -0
- package/dist/commands/init.js +508 -0
- package/dist/commands/logs.d.ts +3 -0
- package/dist/commands/logs.js +123 -0
- package/dist/commands/project.d.ts +3 -0
- package/dist/commands/project.js +101 -0
- package/dist/commands/recover.d.ts +3 -0
- package/dist/commands/recover.js +117 -0
- package/dist/commands/repo.d.ts +3 -0
- package/dist/commands/repo.js +103 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +69 -0
- package/dist/commands/start.d.ts +3 -0
- package/dist/commands/start.js +210 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.js +218 -0
- package/dist/commands/stop.d.ts +3 -0
- package/dist/commands/stop.js +46 -0
- package/dist/commands/version.d.ts +3 -0
- package/dist/commands/version.js +21 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +81 -0
- package/dist/github/client.d.ts +60 -0
- package/dist/github/client.js +300 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +88 -0
- package/dist/mapping/smart-defaults.d.ts +33 -0
- package/dist/mapping/smart-defaults.js +159 -0
- package/dist/orchestrator-runtime.d.ts +5 -0
- package/dist/orchestrator-runtime.js +26 -0
- package/package.json +49 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, } from "../github/client.js";
|
|
4
|
+
import { inferAllColumnRoles, toWorkflowLifecycleConfig, validateMapping, } from "../mapping/smart-defaults.js";
|
|
5
|
+
import { loadGlobalConfig, saveGlobalConfig, saveWorkspaceConfig, saveWorkflowMapping, } from "../config.js";
|
|
6
|
+
// ── Cancellation utility ─────────────────────────────────────────────────────
|
|
7
|
+
async function abortIfCancelled(input) {
|
|
8
|
+
const result = await input;
|
|
9
|
+
if (p.isCancel(result)) {
|
|
10
|
+
p.cancel("Setup cancelled.");
|
|
11
|
+
process.exit(130);
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
function parseInitFlags(args) {
|
|
16
|
+
const flags = { nonInteractive: false };
|
|
17
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
18
|
+
const arg = args[i];
|
|
19
|
+
const next = args[i + 1];
|
|
20
|
+
switch (arg) {
|
|
21
|
+
case "--non-interactive":
|
|
22
|
+
flags.nonInteractive = true;
|
|
23
|
+
break;
|
|
24
|
+
case "--token":
|
|
25
|
+
flags.token = next;
|
|
26
|
+
i += 1;
|
|
27
|
+
break;
|
|
28
|
+
case "--project":
|
|
29
|
+
flags.project = next;
|
|
30
|
+
i += 1;
|
|
31
|
+
break;
|
|
32
|
+
case "--runtime":
|
|
33
|
+
flags.runtime = next;
|
|
34
|
+
i += 1;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return flags;
|
|
39
|
+
}
|
|
40
|
+
// ── Init command handler ─────────────────────────────────────────────────────
|
|
41
|
+
const handler = async (args, options) => {
|
|
42
|
+
const flags = parseInitFlags(args);
|
|
43
|
+
if (flags.nonInteractive) {
|
|
44
|
+
await runNonInteractive(flags, options);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await runInteractive(options);
|
|
48
|
+
};
|
|
49
|
+
export default handler;
|
|
50
|
+
// ── 4.8: Non-interactive mode ────────────────────────────────────────────────
|
|
51
|
+
async function runNonInteractive(flags, options) {
|
|
52
|
+
if (!flags.token) {
|
|
53
|
+
process.stderr.write("Error: --token is required in non-interactive mode.\n");
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const client = createClient(flags.token);
|
|
58
|
+
// Validate token
|
|
59
|
+
let viewer;
|
|
60
|
+
try {
|
|
61
|
+
viewer = await validateToken(client);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
process.stderr.write("Error: Invalid GitHub token.\n");
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const scopeCheck = checkRequiredScopes(viewer.scopes);
|
|
69
|
+
if (!scopeCheck.valid) {
|
|
70
|
+
process.stderr.write(`Error: Missing required PAT scopes: ${scopeCheck.missing.join(", ")}\n`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Find project
|
|
75
|
+
const projects = await listUserProjects(client);
|
|
76
|
+
let project;
|
|
77
|
+
if (flags.project) {
|
|
78
|
+
const match = projects.find((p) => p.id === flags.project || p.url === flags.project);
|
|
79
|
+
if (!match) {
|
|
80
|
+
process.stderr.write(`Error: Project not found: ${flags.project}\n`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
project = await getProjectDetail(client, match.id);
|
|
85
|
+
}
|
|
86
|
+
else if (projects.length === 1) {
|
|
87
|
+
project = await getProjectDetail(client, projects[0].id);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
process.stderr.write("Error: --project is required when multiple projects exist.\n");
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Auto-map with smart defaults
|
|
95
|
+
const statusField = project.statusFields.find((f) => f.name.toLowerCase() === "status") ??
|
|
96
|
+
project.statusFields[0];
|
|
97
|
+
if (!statusField) {
|
|
98
|
+
process.stderr.write("Error: No status field found on the project.\n");
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const columnNames = statusField.options.map((o) => o.name);
|
|
103
|
+
const inferred = inferAllColumnRoles(columnNames);
|
|
104
|
+
const roles = {};
|
|
105
|
+
for (const mapping of inferred) {
|
|
106
|
+
if (mapping.role) {
|
|
107
|
+
roles[mapping.columnName] = mapping.role;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const validation = validateMapping(roles);
|
|
111
|
+
if (!validation.valid) {
|
|
112
|
+
process.stderr.write(`Error: Cannot auto-map columns. ${validation.errors.join("; ")}\nRun without --non-interactive for manual mapping.\n`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const runtime = flags.runtime ?? "codex";
|
|
117
|
+
const workspaceId = generateWorkspaceId(project.title, project.id);
|
|
118
|
+
await writeConfig(options.configDir, {
|
|
119
|
+
workspaceId,
|
|
120
|
+
token: flags.token,
|
|
121
|
+
project,
|
|
122
|
+
repos: project.linkedRepositories,
|
|
123
|
+
statusField,
|
|
124
|
+
roles,
|
|
125
|
+
humanReviewMode: "plan-and-pr",
|
|
126
|
+
runtime,
|
|
127
|
+
});
|
|
128
|
+
if (options.json) {
|
|
129
|
+
process.stdout.write(JSON.stringify({ workspaceId, status: "created" }) + "\n");
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
process.stdout.write(`Workspace created: ${workspaceId}\n`);
|
|
133
|
+
process.stdout.write(`Run 'gh-symphony start' to begin orchestration.\n`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ── Interactive mode ─────────────────────────────────────────────────────────
|
|
137
|
+
async function runInteractive(options) {
|
|
138
|
+
p.intro("gh-symphony — Workspace Setup");
|
|
139
|
+
// 4.7: Detect existing config
|
|
140
|
+
const existingConfig = await loadGlobalConfig(options.configDir);
|
|
141
|
+
if (existingConfig) {
|
|
142
|
+
const action = await abortIfCancelled(p.select({
|
|
143
|
+
message: "Existing configuration detected. What would you like to do?",
|
|
144
|
+
options: [
|
|
145
|
+
{ value: "add", label: "Add a new workspace" },
|
|
146
|
+
{ value: "overwrite", label: "Start fresh (overwrite)" },
|
|
147
|
+
],
|
|
148
|
+
}));
|
|
149
|
+
if (action === "overwrite") {
|
|
150
|
+
// Continue with fresh setup — will overwrite config
|
|
151
|
+
}
|
|
152
|
+
// "add" continues to create a new workspace alongside existing ones
|
|
153
|
+
}
|
|
154
|
+
// ── Step 1: PAT input with async validation (4.1) ─────────────────────────
|
|
155
|
+
let token;
|
|
156
|
+
let viewer;
|
|
157
|
+
let client;
|
|
158
|
+
while (true) {
|
|
159
|
+
const rawToken = await abortIfCancelled(p.password({
|
|
160
|
+
message: "Step 1/6 — Enter your GitHub Personal Access Token:",
|
|
161
|
+
validate: (v) => {
|
|
162
|
+
if (!v)
|
|
163
|
+
return "Token is required.";
|
|
164
|
+
if (v.length < 40)
|
|
165
|
+
return "Token too short.";
|
|
166
|
+
},
|
|
167
|
+
}));
|
|
168
|
+
client = createClient(rawToken);
|
|
169
|
+
const s = p.spinner();
|
|
170
|
+
s.start("Validating token...");
|
|
171
|
+
try {
|
|
172
|
+
viewer = await validateToken(client);
|
|
173
|
+
const scopeCheck = checkRequiredScopes(viewer.scopes);
|
|
174
|
+
if (!scopeCheck.valid) {
|
|
175
|
+
s.stop(`Token valid (${viewer.login}), but missing scopes: ${scopeCheck.missing.join(", ")}`);
|
|
176
|
+
p.log.warn("Required scopes: repo, read:org, project. Please create a new token with these scopes.");
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
s.stop(`Authenticated as ${viewer.login}${viewer.name ? ` (${viewer.name})` : ""}`);
|
|
180
|
+
token = rawToken;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
s.stop(`Token invalid: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
185
|
+
p.log.warn("Please try a different token.");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// ── Step 2: Project selection (4.2) ────────────────────────────────────────
|
|
189
|
+
const s2 = p.spinner();
|
|
190
|
+
s2.start("Loading projects...");
|
|
191
|
+
let projects;
|
|
192
|
+
try {
|
|
193
|
+
projects = await listUserProjects(client);
|
|
194
|
+
s2.stop(`Found ${projects.length} project${projects.length === 1 ? "" : "s"}`);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
s2.stop("Failed to load projects.");
|
|
198
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (projects.length === 0) {
|
|
203
|
+
p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run init.");
|
|
204
|
+
process.exitCode = 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const selectedProjectId = await abortIfCancelled(p.select({
|
|
208
|
+
message: "Step 2/6 — Select a GitHub Project:",
|
|
209
|
+
options: projects.map((proj) => ({
|
|
210
|
+
value: proj.id,
|
|
211
|
+
label: `${proj.owner.login}/${proj.title}`,
|
|
212
|
+
hint: `${proj.openItemCount} items`,
|
|
213
|
+
})),
|
|
214
|
+
maxItems: 15,
|
|
215
|
+
}));
|
|
216
|
+
const s2d = p.spinner();
|
|
217
|
+
s2d.start("Loading project details...");
|
|
218
|
+
let projectDetail;
|
|
219
|
+
try {
|
|
220
|
+
projectDetail = await getProjectDetail(client, selectedProjectId);
|
|
221
|
+
s2d.stop(`Loaded: ${projectDetail.title}`);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
s2d.stop("Failed to load project details.");
|
|
225
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// ── Step 3: Repository selection (4.3) ─────────────────────────────────────
|
|
230
|
+
if (projectDetail.linkedRepositories.length === 0) {
|
|
231
|
+
p.log.warn("No linked repositories found in this project. Add issues from repositories to the project first.");
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const selectedRepos = await abortIfCancelled(p.multiselect({
|
|
236
|
+
message: "Step 3/6 — Select repositories to orchestrate:",
|
|
237
|
+
options: projectDetail.linkedRepositories.map((repo) => ({
|
|
238
|
+
value: repo,
|
|
239
|
+
label: `${repo.owner}/${repo.name}`,
|
|
240
|
+
})),
|
|
241
|
+
required: true,
|
|
242
|
+
}));
|
|
243
|
+
// ── Step 4: Status column mapping (4.4) ────────────────────────────────────
|
|
244
|
+
const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ??
|
|
245
|
+
projectDetail.statusFields[0];
|
|
246
|
+
if (!statusField) {
|
|
247
|
+
p.log.error("No status field found on the project. The project needs a single-select 'Status' field.");
|
|
248
|
+
process.exitCode = 1;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const columnNames = statusField.options.map((o) => o.name);
|
|
252
|
+
const inferred = inferAllColumnRoles(columnNames);
|
|
253
|
+
p.log.info(`Found ${columnNames.length} status columns on field "${statusField.name}".`);
|
|
254
|
+
// Show smart defaults and let user adjust
|
|
255
|
+
const roles = {};
|
|
256
|
+
for (const mapping of inferred) {
|
|
257
|
+
const roleOptions = [
|
|
258
|
+
{ value: "trigger", label: "Trigger (starts work)" },
|
|
259
|
+
{ value: "working", label: "Working (implementation)" },
|
|
260
|
+
{ value: "human-review", label: "Review (human approval)" },
|
|
261
|
+
{ value: "done", label: "Done (completed)" },
|
|
262
|
+
{ value: "ignored", label: "Ignored (skip)" },
|
|
263
|
+
];
|
|
264
|
+
const defaultRole = mapping.role ?? "ignored";
|
|
265
|
+
// Put default first
|
|
266
|
+
const sortedOptions = [
|
|
267
|
+
roleOptions.find((o) => o.value === defaultRole),
|
|
268
|
+
...roleOptions.filter((o) => o.value !== defaultRole),
|
|
269
|
+
];
|
|
270
|
+
const selectedRole = await abortIfCancelled(p.select({
|
|
271
|
+
message: `Step 4/6 — Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
|
|
272
|
+
options: sortedOptions,
|
|
273
|
+
}));
|
|
274
|
+
if (selectedRole !== "skip") {
|
|
275
|
+
roles[mapping.columnName] = selectedRole;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const validation = validateMapping(roles);
|
|
279
|
+
if (!validation.valid) {
|
|
280
|
+
p.log.error("Mapping validation failed:");
|
|
281
|
+
for (const err of validation.errors) {
|
|
282
|
+
p.log.error(` • ${err}`);
|
|
283
|
+
}
|
|
284
|
+
process.exitCode = 1;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const warn of validation.warnings) {
|
|
288
|
+
p.log.warn(` ⚠ ${warn}`);
|
|
289
|
+
}
|
|
290
|
+
// Human review mode selection
|
|
291
|
+
const humanReviewMode = await abortIfCancelled(p.select({
|
|
292
|
+
message: "Human review mode:",
|
|
293
|
+
options: [
|
|
294
|
+
{
|
|
295
|
+
value: "plan-and-pr",
|
|
296
|
+
label: "Plan & PR review",
|
|
297
|
+
hint: "Human reviews both plans and PRs",
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
value: "plan-only",
|
|
301
|
+
label: "Plan review only",
|
|
302
|
+
hint: "Human reviews plans, PRs auto-merge",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
value: "pr-only",
|
|
306
|
+
label: "PR review only",
|
|
307
|
+
hint: "No plan review, human reviews PRs",
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
value: "none",
|
|
311
|
+
label: "None (full auto)",
|
|
312
|
+
hint: "No human review at all",
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
}));
|
|
316
|
+
// Show visual flow summary
|
|
317
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, roles, humanReviewMode);
|
|
318
|
+
const flowParts = [];
|
|
319
|
+
if (lifecycleConfig.planningStates.length)
|
|
320
|
+
flowParts.push(`[Planning: ${lifecycleConfig.planningStates.join(", ")}]`);
|
|
321
|
+
if (lifecycleConfig.humanReviewStates.length)
|
|
322
|
+
flowParts.push(`[Review: ${lifecycleConfig.humanReviewStates.join(", ")}]`);
|
|
323
|
+
if (lifecycleConfig.implementationStates.length)
|
|
324
|
+
flowParts.push(`[Implementation: ${lifecycleConfig.implementationStates.join(", ")}]`);
|
|
325
|
+
if (lifecycleConfig.awaitingMergeStates.length)
|
|
326
|
+
flowParts.push(`[Awaiting Merge: ${lifecycleConfig.awaitingMergeStates.join(", ")}]`);
|
|
327
|
+
if (lifecycleConfig.completedStates.length)
|
|
328
|
+
flowParts.push(`[Done: ${lifecycleConfig.completedStates.join(", ")}]`);
|
|
329
|
+
p.note(flowParts.join(" → "), "Workflow Flow");
|
|
330
|
+
// ── Step 5: Runtime selection (4.5) ────────────────────────────────────────
|
|
331
|
+
const runtime = await abortIfCancelled(p.select({
|
|
332
|
+
message: "Step 5/6 — Select AI runtime:",
|
|
333
|
+
options: [
|
|
334
|
+
{ value: "codex", label: "OpenAI Codex", hint: "recommended" },
|
|
335
|
+
{ value: "claude-code", label: "Claude Code" },
|
|
336
|
+
{ value: "custom", label: "Custom command" },
|
|
337
|
+
],
|
|
338
|
+
}));
|
|
339
|
+
let workerCommand;
|
|
340
|
+
if (runtime === "custom") {
|
|
341
|
+
workerCommand = await abortIfCancelled(p.text({
|
|
342
|
+
message: "Custom worker command:",
|
|
343
|
+
placeholder: "node packages/worker/dist/index.js",
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
// ── Step 6: Options (4.5) ──────────────────────────────────────────────────
|
|
347
|
+
const advancedOptions = await abortIfCancelled(p.confirm({
|
|
348
|
+
message: "Step 6/6 — Configure advanced options? (poll interval, concurrency)",
|
|
349
|
+
initialValue: false,
|
|
350
|
+
}));
|
|
351
|
+
let pollIntervalMs = 30_000;
|
|
352
|
+
let concurrency = 3;
|
|
353
|
+
let maxAttempts = 3;
|
|
354
|
+
if (advancedOptions) {
|
|
355
|
+
const pollStr = await abortIfCancelled(p.text({
|
|
356
|
+
message: "Poll interval (seconds):",
|
|
357
|
+
placeholder: "30",
|
|
358
|
+
initialValue: "30",
|
|
359
|
+
validate: (v) => {
|
|
360
|
+
const n = Number(v);
|
|
361
|
+
if (!v || isNaN(n) || n < 5)
|
|
362
|
+
return "Must be at least 5 seconds.";
|
|
363
|
+
},
|
|
364
|
+
}));
|
|
365
|
+
pollIntervalMs = Number(pollStr) * 1000;
|
|
366
|
+
const concurrencyStr = await abortIfCancelled(p.text({
|
|
367
|
+
message: "Max concurrent workers:",
|
|
368
|
+
placeholder: "3",
|
|
369
|
+
initialValue: "3",
|
|
370
|
+
validate: (v) => {
|
|
371
|
+
const n = Number(v);
|
|
372
|
+
if (!v || isNaN(n) || n < 1)
|
|
373
|
+
return "Must be at least 1.";
|
|
374
|
+
},
|
|
375
|
+
}));
|
|
376
|
+
concurrency = Number(concurrencyStr);
|
|
377
|
+
const attemptsStr = await abortIfCancelled(p.text({
|
|
378
|
+
message: "Max retry attempts per issue:",
|
|
379
|
+
placeholder: "3",
|
|
380
|
+
initialValue: "3",
|
|
381
|
+
validate: (v) => {
|
|
382
|
+
const n = Number(v);
|
|
383
|
+
if (!v || isNaN(n) || n < 1)
|
|
384
|
+
return "Must be at least 1.";
|
|
385
|
+
},
|
|
386
|
+
}));
|
|
387
|
+
maxAttempts = Number(attemptsStr);
|
|
388
|
+
}
|
|
389
|
+
// ── Confirmation ───────────────────────────────────────────────────────────
|
|
390
|
+
p.note([
|
|
391
|
+
`User: ${viewer.login}`,
|
|
392
|
+
`Project: ${projectDetail.title}`,
|
|
393
|
+
`Repos: ${selectedRepos.map((r) => `${r.owner}/${r.name}`).join(", ")}`,
|
|
394
|
+
`Runtime: ${runtime}`,
|
|
395
|
+
`Review: ${humanReviewMode}`,
|
|
396
|
+
`Poll: ${pollIntervalMs / 1000}s`,
|
|
397
|
+
`Concurrency: ${concurrency}`,
|
|
398
|
+
`Max retries: ${maxAttempts}`,
|
|
399
|
+
].join("\n"), "Configuration Summary");
|
|
400
|
+
const confirmed = await abortIfCancelled(p.confirm({ message: "Apply this configuration?" }));
|
|
401
|
+
if (!confirmed) {
|
|
402
|
+
p.cancel("Setup cancelled.");
|
|
403
|
+
process.exitCode = 130;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// ── Write config files (4.6) ───────────────────────────────────────────────
|
|
407
|
+
const workspaceId = generateWorkspaceId(projectDetail.title, projectDetail.id);
|
|
408
|
+
const s6 = p.spinner();
|
|
409
|
+
s6.start("Writing configuration...");
|
|
410
|
+
try {
|
|
411
|
+
await writeConfig(options.configDir, {
|
|
412
|
+
workspaceId,
|
|
413
|
+
token,
|
|
414
|
+
project: projectDetail,
|
|
415
|
+
repos: selectedRepos,
|
|
416
|
+
statusField: {
|
|
417
|
+
name: statusField.name,
|
|
418
|
+
options: statusField.options,
|
|
419
|
+
},
|
|
420
|
+
roles,
|
|
421
|
+
humanReviewMode,
|
|
422
|
+
runtime,
|
|
423
|
+
workerCommand,
|
|
424
|
+
pollIntervalMs,
|
|
425
|
+
concurrency,
|
|
426
|
+
maxAttempts,
|
|
427
|
+
});
|
|
428
|
+
s6.stop("Configuration saved.");
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
s6.stop("Failed to write configuration.");
|
|
432
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
433
|
+
process.exitCode = 1;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
p.outro(`Workspace "${workspaceId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
|
|
437
|
+
}
|
|
438
|
+
export async function writeConfig(configDir, input) {
|
|
439
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.roles, input.humanReviewMode);
|
|
440
|
+
// Save workflow mapping
|
|
441
|
+
const mappingConfig = {
|
|
442
|
+
stateFieldName: input.statusField.name,
|
|
443
|
+
columnRoles: input.roles,
|
|
444
|
+
humanReviewMode: input.humanReviewMode,
|
|
445
|
+
lifecycle: lifecycleConfig,
|
|
446
|
+
};
|
|
447
|
+
await saveWorkflowMapping(configDir, input.workspaceId, mappingConfig);
|
|
448
|
+
// Save workspace config (OrchestratorWorkspaceConfig shape)
|
|
449
|
+
const runtimeDir = `${configDir}/workspaces/${input.workspaceId}/runtime`;
|
|
450
|
+
await saveWorkspaceConfig(configDir, input.workspaceId, {
|
|
451
|
+
workspaceId: input.workspaceId,
|
|
452
|
+
slug: input.workspaceId,
|
|
453
|
+
promptGuidelines: "",
|
|
454
|
+
repositories: input.repos.map((r) => ({
|
|
455
|
+
owner: r.owner,
|
|
456
|
+
name: r.name,
|
|
457
|
+
cloneUrl: r.cloneUrl,
|
|
458
|
+
})),
|
|
459
|
+
tracker: {
|
|
460
|
+
adapter: "github-project",
|
|
461
|
+
bindingId: input.project.id,
|
|
462
|
+
settings: {
|
|
463
|
+
projectId: input.project.id,
|
|
464
|
+
token: input.token,
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
runtime: {
|
|
468
|
+
driver: "local",
|
|
469
|
+
workspaceRuntimeDir: runtimeDir,
|
|
470
|
+
projectRoot: process.cwd(),
|
|
471
|
+
workerCommand: input.workerCommand,
|
|
472
|
+
},
|
|
473
|
+
workflow: buildWorkflowOverrides(lifecycleConfig, input),
|
|
474
|
+
orchestrator: {
|
|
475
|
+
concurrency: input.concurrency,
|
|
476
|
+
maxAttempts: input.maxAttempts,
|
|
477
|
+
},
|
|
478
|
+
workflowMapping: mappingConfig,
|
|
479
|
+
});
|
|
480
|
+
// Save/update global config
|
|
481
|
+
const existing = await loadGlobalConfig(configDir);
|
|
482
|
+
const globalConfig = {
|
|
483
|
+
activeWorkspace: input.workspaceId,
|
|
484
|
+
token: input.token,
|
|
485
|
+
workspaces: [
|
|
486
|
+
...(existing?.workspaces ?? []).filter((w) => w !== input.workspaceId),
|
|
487
|
+
input.workspaceId,
|
|
488
|
+
],
|
|
489
|
+
};
|
|
490
|
+
await saveGlobalConfig(configDir, globalConfig);
|
|
491
|
+
}
|
|
492
|
+
function buildWorkflowOverrides(lifecycle, input) {
|
|
493
|
+
return {
|
|
494
|
+
lifecycle,
|
|
495
|
+
scheduler: {
|
|
496
|
+
pollIntervalMs: input.pollIntervalMs ?? 30_000,
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
export function generateWorkspaceId(projectTitle, uniqueKey) {
|
|
501
|
+
const slug = projectTitle
|
|
502
|
+
.toLowerCase()
|
|
503
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
504
|
+
.replace(/^-|-$/g, "")
|
|
505
|
+
.slice(0, 32);
|
|
506
|
+
const suffix = createHash("sha1").update(uniqueKey).digest("hex").slice(0, 8);
|
|
507
|
+
return [slug || "workspace", suffix].join("-");
|
|
508
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { loadActiveWorkspaceConfig, orchestratorLogPath } from "../config.js";
|
|
6
|
+
function parseLogsArgs(args) {
|
|
7
|
+
const parsed = { follow: false };
|
|
8
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
9
|
+
const arg = args[i];
|
|
10
|
+
if (arg === "--follow" || arg === "-f") {
|
|
11
|
+
parsed.follow = true;
|
|
12
|
+
}
|
|
13
|
+
if (arg === "--issue") {
|
|
14
|
+
parsed.issue = args[i + 1];
|
|
15
|
+
i += 1;
|
|
16
|
+
}
|
|
17
|
+
if (arg === "--run") {
|
|
18
|
+
parsed.run = args[i + 1];
|
|
19
|
+
i += 1;
|
|
20
|
+
}
|
|
21
|
+
if (arg === "--level") {
|
|
22
|
+
parsed.level = args[i + 1];
|
|
23
|
+
i += 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
const handler = async (args, options) => {
|
|
29
|
+
const parsed = parseLogsArgs(args);
|
|
30
|
+
const wsConfig = await loadActiveWorkspaceConfig(options.configDir);
|
|
31
|
+
if (!wsConfig) {
|
|
32
|
+
process.stderr.write("No workspace configured. Run 'gh-symphony init' first.\n");
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// If --run is specified, read that run's events
|
|
37
|
+
if (parsed.run) {
|
|
38
|
+
const eventsPath = join(resolve(options.configDir), "orchestrator", "runs", parsed.run, "events.ndjson");
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile(eventsPath, "utf8");
|
|
41
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const event = JSON.parse(line);
|
|
44
|
+
if (parsed.level && event.level !== parsed.level)
|
|
45
|
+
continue;
|
|
46
|
+
if (parsed.issue && event.issueIdentifier !== parsed.issue)
|
|
47
|
+
continue;
|
|
48
|
+
process.stdout.write(formatEvent(event) + "\n");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
process.stderr.write(`No events found for run: ${parsed.run}\n`);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Default: read orchestrator log or scan all events
|
|
58
|
+
if (parsed.follow) {
|
|
59
|
+
const logPath = orchestratorLogPath(options.configDir);
|
|
60
|
+
try {
|
|
61
|
+
const stream = createReadStream(logPath, { encoding: "utf8" });
|
|
62
|
+
const rl = createInterface({ input: stream });
|
|
63
|
+
for await (const line of rl) {
|
|
64
|
+
process.stdout.write(line + "\n");
|
|
65
|
+
}
|
|
66
|
+
// Follow mode: watch for new lines
|
|
67
|
+
const { watchFile } = await import("node:fs");
|
|
68
|
+
let lastSize = 0;
|
|
69
|
+
watchFile(logPath, { interval: 1000 }, async (curr) => {
|
|
70
|
+
if (curr.size > lastSize) {
|
|
71
|
+
const fd = await import("node:fs/promises");
|
|
72
|
+
const handle = await fd.open(logPath, "r");
|
|
73
|
+
const buf = Buffer.alloc(curr.size - lastSize);
|
|
74
|
+
await handle.read(buf, 0, buf.length, lastSize);
|
|
75
|
+
await handle.close();
|
|
76
|
+
process.stdout.write(buf.toString("utf8"));
|
|
77
|
+
lastSize = curr.size;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
// Keep alive
|
|
81
|
+
await new Promise(() => { });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
process.stderr.write("No log file found. Start the orchestrator first.\n");
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Scan all run events
|
|
90
|
+
const runsDir = join(resolve(options.configDir), "orchestrator", "runs");
|
|
91
|
+
try {
|
|
92
|
+
const entries = await readdir(runsDir);
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const eventsPath = join(runsDir, entry, "events.ndjson");
|
|
95
|
+
try {
|
|
96
|
+
const content = await readFile(eventsPath, "utf8");
|
|
97
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const event = JSON.parse(line);
|
|
100
|
+
if (parsed.level && event.level !== parsed.level)
|
|
101
|
+
continue;
|
|
102
|
+
if (parsed.issue && event.issueIdentifier !== parsed.issue)
|
|
103
|
+
continue;
|
|
104
|
+
process.stdout.write(formatEvent(event) + "\n");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Skip runs without events
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
process.stderr.write("No runs found. Start the orchestrator first.\n");
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
export default handler;
|
|
117
|
+
function formatEvent(event) {
|
|
118
|
+
const at = event.at ?? "";
|
|
119
|
+
const eventType = event.event ?? "unknown";
|
|
120
|
+
const issue = event.issueIdentifier ?? "";
|
|
121
|
+
const extra = event.error ? ` error=${event.error}` : "";
|
|
122
|
+
return `[${at}] ${eventType} ${issue}${extra}`;
|
|
123
|
+
}
|