@gh-symphony/cli 0.0.1 → 0.0.3
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/dist/ansi.d.ts +15 -0
- package/dist/ansi.js +53 -0
- package/dist/commands/config-cmd.js +11 -27
- package/dist/commands/help.js +14 -6
- package/dist/commands/init.d.ts +29 -7
- package/dist/commands/init.js +292 -287
- package/dist/commands/logs.js +4 -4
- package/dist/commands/project.js +34 -34
- package/dist/commands/recover.js +14 -14
- package/dist/commands/repo.js +13 -13
- package/dist/commands/run.js +16 -16
- package/dist/commands/start.js +61 -37
- package/dist/commands/status.js +60 -63
- package/dist/commands/tenant.d.ts +3 -0
- package/dist/commands/tenant.js +402 -0
- package/dist/config.d.ts +20 -19
- package/dist/config.js +17 -17
- package/dist/context/context-types.d.ts +36 -0
- package/dist/context/context-types.js +1 -0
- package/dist/context/generate-context-yaml.d.ts +15 -0
- package/dist/context/generate-context-yaml.js +129 -0
- package/dist/dashboard/renderer.d.ts +9 -0
- package/dist/dashboard/renderer.js +220 -0
- package/dist/detection/environment-detector.d.ts +11 -0
- package/dist/detection/environment-detector.js +140 -0
- package/dist/github/client.d.ts +11 -0
- package/dist/github/client.js +59 -11
- package/dist/github/gh-auth.d.ts +34 -0
- package/dist/github/gh-auth.js +110 -0
- package/dist/index.js +1 -0
- package/dist/mapping/smart-defaults.d.ts +9 -25
- package/dist/mapping/smart-defaults.js +52 -125
- package/dist/orchestrator-runtime.d.ts +4 -4
- package/dist/orchestrator-runtime.js +27 -12
- package/dist/skills/skill-writer.d.ts +14 -0
- package/dist/skills/skill-writer.js +62 -0
- package/dist/skills/templates/commit.d.ts +2 -0
- package/dist/skills/templates/commit.js +45 -0
- package/dist/skills/templates/document.d.ts +7 -0
- package/dist/skills/templates/document.js +16 -0
- package/dist/skills/templates/gh-project.d.ts +2 -0
- package/dist/skills/templates/gh-project.js +88 -0
- package/dist/skills/templates/gh-symphony.d.ts +2 -0
- package/dist/skills/templates/gh-symphony.js +125 -0
- package/dist/skills/templates/index.d.ts +8 -0
- package/dist/skills/templates/index.js +28 -0
- package/dist/skills/templates/land.d.ts +2 -0
- package/dist/skills/templates/land.js +59 -0
- package/dist/skills/templates/pull.d.ts +2 -0
- package/dist/skills/templates/pull.js +41 -0
- package/dist/skills/templates/push.d.ts +2 -0
- package/dist/skills/templates/push.js +36 -0
- package/dist/skills/types.d.ts +23 -0
- package/dist/skills/types.js +1 -0
- package/dist/workflow/generate-reference-workflow.d.ts +9 -0
- package/dist/workflow/generate-reference-workflow.js +261 -0
- package/dist/workflow/generate-workflow-md.d.ts +12 -0
- package/dist/workflow/generate-workflow-md.js +134 -0
- package/package.json +5 -4
package/dist/commands/init.js
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
|
|
7
|
+
import { inferAllStateRoles, toWorkflowLifecycleConfig, validateStateMapping, } from "../mapping/smart-defaults.js";
|
|
8
|
+
import { generateWorkflowMarkdown } from "../workflow/generate-workflow-md.js";
|
|
9
|
+
import { loadGlobalConfig, saveGlobalConfig, saveTenantConfig, saveWorkflowMapping, } from "../config.js";
|
|
10
|
+
import { getGhToken, ensureGhAuth, GhAuthError } from "../github/gh-auth.js";
|
|
11
|
+
import { detectEnvironment } from "../detection/environment-detector.js";
|
|
12
|
+
import { buildContextYaml, writeContextYaml, } from "../context/generate-context-yaml.js";
|
|
13
|
+
import { generateReferenceWorkflow } from "../workflow/generate-reference-workflow.js";
|
|
14
|
+
import { resolveSkillsDir, writeAllSkills } from "../skills/skill-writer.js";
|
|
15
|
+
import { ALL_SKILL_TEMPLATES } from "../skills/templates/index.js";
|
|
16
|
+
// ── Scope error display ───────────────────────────────────────────────────────
|
|
17
|
+
const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
|
|
18
|
+
function displayScopeError(error, retryCommand) {
|
|
19
|
+
const plural = error.requiredScopes.length === 1 ? "" : "s";
|
|
20
|
+
p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
|
|
21
|
+
const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
|
|
22
|
+
const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
|
|
23
|
+
const scopeArg = scopesToAdd.length > 0
|
|
24
|
+
? scopesToAdd.join(",")
|
|
25
|
+
: error.requiredScopes.join(",");
|
|
26
|
+
p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
|
|
27
|
+
}
|
|
6
28
|
// ── Cancellation utility ─────────────────────────────────────────────────────
|
|
7
|
-
async function abortIfCancelled(input) {
|
|
29
|
+
export async function abortIfCancelled(input) {
|
|
8
30
|
const result = await input;
|
|
9
31
|
if (p.isCancel(result)) {
|
|
10
32
|
p.cancel("Setup cancelled.");
|
|
@@ -13,7 +35,11 @@ async function abortIfCancelled(input) {
|
|
|
13
35
|
return result;
|
|
14
36
|
}
|
|
15
37
|
function parseInitFlags(args) {
|
|
16
|
-
const flags = {
|
|
38
|
+
const flags = {
|
|
39
|
+
nonInteractive: false,
|
|
40
|
+
skipSkills: false,
|
|
41
|
+
skipContext: false,
|
|
42
|
+
};
|
|
17
43
|
for (let i = 0; i < args.length; i += 1) {
|
|
18
44
|
const arg = args[i];
|
|
19
45
|
const next = args[i + 1];
|
|
@@ -21,18 +47,20 @@ function parseInitFlags(args) {
|
|
|
21
47
|
case "--non-interactive":
|
|
22
48
|
flags.nonInteractive = true;
|
|
23
49
|
break;
|
|
24
|
-
case "--token":
|
|
25
|
-
flags.token = next;
|
|
26
|
-
i += 1;
|
|
27
|
-
break;
|
|
28
50
|
case "--project":
|
|
29
51
|
flags.project = next;
|
|
30
52
|
i += 1;
|
|
31
53
|
break;
|
|
32
|
-
case "--
|
|
33
|
-
flags.
|
|
54
|
+
case "--output":
|
|
55
|
+
flags.output = next;
|
|
34
56
|
i += 1;
|
|
35
57
|
break;
|
|
58
|
+
case "--skip-skills":
|
|
59
|
+
flags.skipSkills = true;
|
|
60
|
+
break;
|
|
61
|
+
case "--skip-context":
|
|
62
|
+
flags.skipContext = true;
|
|
63
|
+
break;
|
|
36
64
|
}
|
|
37
65
|
}
|
|
38
66
|
return flags;
|
|
@@ -47,14 +75,131 @@ const handler = async (args, options) => {
|
|
|
47
75
|
await runInteractive(options);
|
|
48
76
|
};
|
|
49
77
|
export default handler;
|
|
50
|
-
|
|
78
|
+
export async function writeEcosystem(opts) {
|
|
79
|
+
const { cwd, projectDetail, statusField, runtime, skipSkills, skipContext } = opts;
|
|
80
|
+
const ghSymphonyDir = join(cwd, ".gh-symphony");
|
|
81
|
+
await mkdir(ghSymphonyDir, { recursive: true });
|
|
82
|
+
// 1. Detect environment
|
|
83
|
+
const env = await detectEnvironment(cwd);
|
|
84
|
+
// 2. Write context.yaml (unless --skip-context)
|
|
85
|
+
let contextYamlWritten = false;
|
|
86
|
+
if (!skipContext) {
|
|
87
|
+
const contextYaml = buildContextYaml({
|
|
88
|
+
projectDetail,
|
|
89
|
+
statusField,
|
|
90
|
+
detectedEnvironment: env,
|
|
91
|
+
runtime: {
|
|
92
|
+
agent: runtime,
|
|
93
|
+
agent_command: runtime === "codex"
|
|
94
|
+
? "bash -lc codex app-server"
|
|
95
|
+
: runtime === "claude-code"
|
|
96
|
+
? "bash -lc claude-code"
|
|
97
|
+
: runtime,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
await writeContextYaml(cwd, contextYaml);
|
|
101
|
+
contextYamlWritten = true;
|
|
102
|
+
}
|
|
103
|
+
// 3. Write reference-workflow.md
|
|
104
|
+
const refWorkflow = generateReferenceWorkflow({
|
|
105
|
+
runtime,
|
|
106
|
+
statusColumns: statusField.options.map((o) => ({
|
|
107
|
+
name: o.name,
|
|
108
|
+
role: null,
|
|
109
|
+
})),
|
|
110
|
+
projectId: projectDetail.id,
|
|
111
|
+
});
|
|
112
|
+
const refPath = join(ghSymphonyDir, "reference-workflow.md");
|
|
113
|
+
const tmpRef = refPath + ".tmp";
|
|
114
|
+
await writeFile(tmpRef, refWorkflow, "utf8");
|
|
115
|
+
await rename(tmpRef, refPath);
|
|
116
|
+
// 4. Write skills (unless --skip-skills)
|
|
117
|
+
const skillsDir = resolveSkillsDir(cwd, runtime);
|
|
118
|
+
let skillsWritten = [];
|
|
119
|
+
let skillsSkipped = [];
|
|
120
|
+
if (!skipSkills && skillsDir) {
|
|
121
|
+
const result = await writeAllSkills(cwd, runtime, ALL_SKILL_TEMPLATES, {
|
|
122
|
+
runtime,
|
|
123
|
+
projectId: projectDetail.id,
|
|
124
|
+
projectTitle: projectDetail.title,
|
|
125
|
+
repositories: projectDetail.linkedRepositories.map((r) => ({
|
|
126
|
+
owner: r.owner,
|
|
127
|
+
name: r.name,
|
|
128
|
+
})),
|
|
129
|
+
statusColumns: statusField.options.map((o) => ({
|
|
130
|
+
id: o.id,
|
|
131
|
+
name: o.name,
|
|
132
|
+
role: null,
|
|
133
|
+
})),
|
|
134
|
+
statusFieldId: statusField.id,
|
|
135
|
+
contextYamlPath: ".gh-symphony/context.yaml",
|
|
136
|
+
referenceWorkflowPath: ".gh-symphony/reference-workflow.md",
|
|
137
|
+
});
|
|
138
|
+
skillsWritten = result.written.map((p) => basename(dirname(p)));
|
|
139
|
+
skillsSkipped = result.skipped.map((p) => basename(dirname(p)));
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
projectId: projectDetail.id,
|
|
143
|
+
projectTitle: projectDetail.title,
|
|
144
|
+
runtime,
|
|
145
|
+
skillsDir,
|
|
146
|
+
contextYamlWritten,
|
|
147
|
+
referenceWorkflowWritten: true,
|
|
148
|
+
skillsWritten,
|
|
149
|
+
skillsSkipped,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ── Ecosystem summary output ─────────────────────────────────────────────────
|
|
153
|
+
function printEcosystemSummary(result, workflowPath, opts) {
|
|
154
|
+
const cwd = process.cwd();
|
|
155
|
+
const relWorkflow = relative(cwd, workflowPath) || "WORKFLOW.md";
|
|
156
|
+
const lines = [];
|
|
157
|
+
lines.push(`Project ${result.projectTitle} (${result.projectId})`);
|
|
158
|
+
lines.push(`Runtime ${result.runtime}`);
|
|
159
|
+
lines.push("");
|
|
160
|
+
lines.push("Generated files");
|
|
161
|
+
lines.push(` ✓ WORKFLOW.md ${relWorkflow}`);
|
|
162
|
+
if (result.contextYamlWritten) {
|
|
163
|
+
lines.push(" ✓ Context metadata .gh-symphony/context.yaml");
|
|
164
|
+
}
|
|
165
|
+
if (result.referenceWorkflowWritten) {
|
|
166
|
+
lines.push(" ✓ Reference workflow .gh-symphony/reference-workflow.md");
|
|
167
|
+
}
|
|
168
|
+
if (result.skillsDir) {
|
|
169
|
+
const relSkillsDir = relative(cwd, result.skillsDir);
|
|
170
|
+
lines.push("");
|
|
171
|
+
lines.push(`Skills → ${relSkillsDir}/`);
|
|
172
|
+
for (const name of result.skillsWritten) {
|
|
173
|
+
lines.push(` ✓ ${name}`);
|
|
174
|
+
}
|
|
175
|
+
for (const name of result.skillsSkipped) {
|
|
176
|
+
lines.push(` – ${name} (already exists, skipped)`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else if (result.runtime !== "codex" && result.runtime !== "claude-code") {
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push("Skills → (skipped — custom runtime)");
|
|
182
|
+
}
|
|
183
|
+
if (opts.interactive) {
|
|
184
|
+
p.note(lines.join("\n"), "Setup complete");
|
|
185
|
+
p.outro(opts.nextSteps ?? "Ready.");
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
process.stdout.write(lines.map((l) => ` ${l}`).join("\n") + "\n");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ── Non-interactive mode: WORKFLOW.md only ───────────────────────────────────
|
|
51
192
|
async function runNonInteractive(flags, options) {
|
|
52
|
-
|
|
53
|
-
|
|
193
|
+
let token;
|
|
194
|
+
try {
|
|
195
|
+
token = getGhToken();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
|
|
54
199
|
process.exitCode = 1;
|
|
55
200
|
return;
|
|
56
201
|
}
|
|
57
|
-
const client = createClient(
|
|
202
|
+
const client = createClient(token);
|
|
58
203
|
// Validate token
|
|
59
204
|
let viewer;
|
|
60
205
|
try {
|
|
@@ -75,7 +220,7 @@ async function runNonInteractive(flags, options) {
|
|
|
75
220
|
const projects = await listUserProjects(client);
|
|
76
221
|
let project;
|
|
77
222
|
if (flags.project) {
|
|
78
|
-
const match = projects.find((
|
|
223
|
+
const match = projects.find((proj) => proj.id === flags.project || proj.url === flags.project);
|
|
79
224
|
if (!match) {
|
|
80
225
|
process.stderr.write(`Error: Project not found: ${flags.project}\n`);
|
|
81
226
|
process.exitCode = 1;
|
|
@@ -100,92 +245,85 @@ async function runNonInteractive(flags, options) {
|
|
|
100
245
|
return;
|
|
101
246
|
}
|
|
102
247
|
const columnNames = statusField.options.map((o) => o.name);
|
|
103
|
-
const inferred =
|
|
104
|
-
const
|
|
248
|
+
const inferred = inferAllStateRoles(columnNames);
|
|
249
|
+
const mappings = {};
|
|
105
250
|
for (const mapping of inferred) {
|
|
106
251
|
if (mapping.role) {
|
|
107
|
-
|
|
252
|
+
mappings[mapping.columnName] = { role: mapping.role };
|
|
108
253
|
}
|
|
109
254
|
}
|
|
110
|
-
const validation =
|
|
255
|
+
const validation = validateStateMapping(mappings);
|
|
111
256
|
if (!validation.valid) {
|
|
112
257
|
process.stderr.write(`Error: Cannot auto-map columns. ${validation.errors.join("; ")}\nRun without --non-interactive for manual mapping.\n`);
|
|
113
258
|
process.exitCode = 1;
|
|
114
259
|
return;
|
|
115
260
|
}
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
261
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
|
|
262
|
+
const outputPath = resolve(flags.output ?? "WORKFLOW.md");
|
|
263
|
+
const workflowMd = generateWorkflowMarkdown({
|
|
264
|
+
projectId: project.id,
|
|
265
|
+
stateFieldName: statusField.name,
|
|
266
|
+
mappings,
|
|
267
|
+
lifecycle: lifecycleConfig,
|
|
268
|
+
runtime: "codex",
|
|
269
|
+
});
|
|
270
|
+
await writeFile(outputPath, workflowMd, "utf8");
|
|
271
|
+
const ecosystemResult = await writeEcosystem({
|
|
272
|
+
cwd: process.cwd(),
|
|
273
|
+
projectDetail: project,
|
|
123
274
|
statusField,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
275
|
+
runtime: "codex",
|
|
276
|
+
skipSkills: flags.skipSkills,
|
|
277
|
+
skipContext: flags.skipContext,
|
|
127
278
|
});
|
|
128
279
|
if (options.json) {
|
|
129
|
-
process.stdout.write(JSON.stringify({
|
|
280
|
+
process.stdout.write(JSON.stringify({ output: outputPath, status: "created" }) + "\n");
|
|
130
281
|
}
|
|
131
282
|
else {
|
|
132
|
-
|
|
133
|
-
|
|
283
|
+
printEcosystemSummary(ecosystemResult, outputPath, {
|
|
284
|
+
interactive: false,
|
|
285
|
+
nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
|
|
286
|
+
});
|
|
134
287
|
}
|
|
135
288
|
}
|
|
136
|
-
// ── Interactive mode
|
|
289
|
+
// ── Interactive mode: WORKFLOW.md generation ─────────────────────────────────
|
|
137
290
|
async function runInteractive(options) {
|
|
138
|
-
p.intro("gh-symphony —
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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;
|
|
291
|
+
p.intro("gh-symphony — WORKFLOW.md Setup");
|
|
292
|
+
await runInteractiveStandalone(options);
|
|
293
|
+
}
|
|
294
|
+
// ── Interactive WORKFLOW.md generation ────────────────────────────────────────
|
|
295
|
+
async function runInteractiveStandalone(_options) {
|
|
296
|
+
const s1 = p.spinner();
|
|
297
|
+
s1.start("Checking gh CLI authentication...");
|
|
157
298
|
let client;
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
p.log.
|
|
177
|
-
continue;
|
|
299
|
+
try {
|
|
300
|
+
const { token } = ensureGhAuth();
|
|
301
|
+
client = createClient(token);
|
|
302
|
+
s1.stop("Authenticated via gh CLI");
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
s1.stop("Authentication failed.");
|
|
306
|
+
if (error instanceof GhAuthError) {
|
|
307
|
+
if (error.code === "not_installed") {
|
|
308
|
+
p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
|
|
309
|
+
}
|
|
310
|
+
else if (error.code === "not_authenticated") {
|
|
311
|
+
p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
|
|
312
|
+
}
|
|
313
|
+
else if (error.code === "missing_scopes") {
|
|
314
|
+
p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
p.log.error(error.message);
|
|
178
318
|
}
|
|
179
|
-
s.stop(`Authenticated as ${viewer.login}${viewer.name ? ` (${viewer.name})` : ""}`);
|
|
180
|
-
token = rawToken;
|
|
181
|
-
break;
|
|
182
319
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
p.log.warn("Please try a different token.");
|
|
320
|
+
else {
|
|
321
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
186
322
|
}
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
return;
|
|
187
325
|
}
|
|
188
|
-
//
|
|
326
|
+
// Step 1/2: Project selection
|
|
189
327
|
const s2 = p.spinner();
|
|
190
328
|
s2.start("Loading projects...");
|
|
191
329
|
let projects;
|
|
@@ -195,17 +333,22 @@ async function runInteractive(options) {
|
|
|
195
333
|
}
|
|
196
334
|
catch (error) {
|
|
197
335
|
s2.stop("Failed to load projects.");
|
|
198
|
-
|
|
336
|
+
if (error instanceof GitHubScopeError) {
|
|
337
|
+
displayScopeError(error, "gh-symphony init");
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
341
|
+
}
|
|
199
342
|
process.exitCode = 1;
|
|
200
343
|
return;
|
|
201
344
|
}
|
|
202
345
|
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
|
|
346
|
+
p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
|
|
204
347
|
process.exitCode = 1;
|
|
205
348
|
return;
|
|
206
349
|
}
|
|
207
350
|
const selectedProjectId = await abortIfCancelled(p.select({
|
|
208
|
-
message: "Step 2
|
|
351
|
+
message: "Step 1/2 — Select a GitHub Project:",
|
|
209
352
|
options: projects.map((proj) => ({
|
|
210
353
|
value: proj.id,
|
|
211
354
|
label: `${proj.owner.login}/${proj.title}`,
|
|
@@ -226,21 +369,7 @@ async function runInteractive(options) {
|
|
|
226
369
|
process.exitCode = 1;
|
|
227
370
|
return;
|
|
228
371
|
}
|
|
229
|
-
//
|
|
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) ────────────────────────────────────
|
|
372
|
+
// Step 3: Status column mapping
|
|
244
373
|
const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ??
|
|
245
374
|
projectDetail.statusFields[0];
|
|
246
375
|
if (!statusField) {
|
|
@@ -249,33 +378,29 @@ async function runInteractive(options) {
|
|
|
249
378
|
return;
|
|
250
379
|
}
|
|
251
380
|
const columnNames = statusField.options.map((o) => o.name);
|
|
252
|
-
const inferred =
|
|
381
|
+
const inferred = inferAllStateRoles(columnNames);
|
|
253
382
|
p.log.info(`Found ${columnNames.length} status columns on field "${statusField.name}".`);
|
|
254
|
-
|
|
255
|
-
const roles = {};
|
|
383
|
+
const mappings = {};
|
|
256
384
|
for (const mapping of inferred) {
|
|
257
385
|
const roleOptions = [
|
|
258
|
-
{ value: "
|
|
259
|
-
{ value: "
|
|
260
|
-
{ value: "
|
|
261
|
-
{ value: "done", label: "Done (completed)" },
|
|
262
|
-
{ value: "ignored", label: "Ignored (skip)" },
|
|
386
|
+
{ value: "active", label: "Active (agent works on this)" },
|
|
387
|
+
{ value: "wait", label: "Wait (human review / hold)" },
|
|
388
|
+
{ value: "terminal", label: "Terminal (completed)" },
|
|
263
389
|
];
|
|
264
|
-
const defaultRole = mapping.role ?? "
|
|
265
|
-
// Put default first
|
|
390
|
+
const defaultRole = mapping.role ?? "wait";
|
|
266
391
|
const sortedOptions = [
|
|
267
392
|
roleOptions.find((o) => o.value === defaultRole),
|
|
268
393
|
...roleOptions.filter((o) => o.value !== defaultRole),
|
|
269
394
|
];
|
|
270
395
|
const selectedRole = await abortIfCancelled(p.select({
|
|
271
|
-
message: `Step
|
|
396
|
+
message: `Step 2/2 — Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
|
|
272
397
|
options: sortedOptions,
|
|
273
398
|
}));
|
|
274
399
|
if (selectedRole !== "skip") {
|
|
275
|
-
|
|
400
|
+
mappings[mapping.columnName] = { role: selectedRole };
|
|
276
401
|
}
|
|
277
402
|
}
|
|
278
|
-
const validation =
|
|
403
|
+
const validation = validateStateMapping(mappings);
|
|
279
404
|
if (!validation.valid) {
|
|
280
405
|
p.log.error("Mapping validation failed:");
|
|
281
406
|
for (const err of validation.errors) {
|
|
@@ -287,170 +412,53 @@ async function runInteractive(options) {
|
|
|
287
412
|
for (const warn of validation.warnings) {
|
|
288
413
|
p.log.warn(` ⚠ ${warn}`);
|
|
289
414
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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...");
|
|
415
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
|
|
416
|
+
// Generate WORKFLOW.md only — no config files written
|
|
417
|
+
const workflowMd = generateWorkflowMarkdown({
|
|
418
|
+
projectId: projectDetail.id,
|
|
419
|
+
stateFieldName: statusField.name,
|
|
420
|
+
mappings,
|
|
421
|
+
lifecycle: lifecycleConfig,
|
|
422
|
+
runtime: "codex",
|
|
423
|
+
});
|
|
424
|
+
const outputPath = resolve("WORKFLOW.md");
|
|
425
|
+
await writeFile(outputPath, workflowMd, "utf8");
|
|
426
|
+
const ecosystemResult = await writeEcosystem({
|
|
427
|
+
cwd: process.cwd(),
|
|
428
|
+
projectDetail,
|
|
429
|
+
statusField,
|
|
430
|
+
runtime: "codex",
|
|
431
|
+
skipSkills: false,
|
|
432
|
+
skipContext: false,
|
|
433
|
+
});
|
|
434
|
+
printEcosystemSummary(ecosystemResult, outputPath, {
|
|
435
|
+
interactive: true,
|
|
436
|
+
nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function resolveWorkerCommand() {
|
|
410
440
|
try {
|
|
411
|
-
|
|
412
|
-
|
|
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.");
|
|
441
|
+
const url = import.meta.resolve("@gh-symphony/worker/dist/index.js");
|
|
442
|
+
return `node ${fileURLToPath(url)}`;
|
|
429
443
|
}
|
|
430
|
-
catch
|
|
431
|
-
|
|
432
|
-
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
433
|
-
process.exitCode = 1;
|
|
434
|
-
return;
|
|
444
|
+
catch {
|
|
445
|
+
return undefined;
|
|
435
446
|
}
|
|
436
|
-
p.outro(`Workspace "${workspaceId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
|
|
437
447
|
}
|
|
438
448
|
export async function writeConfig(configDir, input) {
|
|
439
|
-
const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.
|
|
449
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.mappings);
|
|
440
450
|
// Save workflow mapping
|
|
441
451
|
const mappingConfig = {
|
|
442
452
|
stateFieldName: input.statusField.name,
|
|
443
|
-
|
|
444
|
-
humanReviewMode: input.humanReviewMode,
|
|
453
|
+
mappings: input.mappings,
|
|
445
454
|
lifecycle: lifecycleConfig,
|
|
446
455
|
};
|
|
447
|
-
await saveWorkflowMapping(configDir, input.
|
|
448
|
-
// Save
|
|
449
|
-
const runtimeDir = `${configDir}/
|
|
450
|
-
await
|
|
451
|
-
|
|
452
|
-
slug: input.
|
|
453
|
-
promptGuidelines: "",
|
|
456
|
+
await saveWorkflowMapping(configDir, input.tenantId, mappingConfig);
|
|
457
|
+
// Save tenant config (OrchestratorTenantConfig shape)
|
|
458
|
+
const runtimeDir = `${configDir}/tenants/${input.tenantId}/runtime`;
|
|
459
|
+
await saveTenantConfig(configDir, input.tenantId, {
|
|
460
|
+
tenantId: input.tenantId,
|
|
461
|
+
slug: input.tenantId,
|
|
454
462
|
repositories: input.repos.map((r) => ({
|
|
455
463
|
owner: r.owner,
|
|
456
464
|
name: r.name,
|
|
@@ -461,48 +469,45 @@ export async function writeConfig(configDir, input) {
|
|
|
461
469
|
bindingId: input.project.id,
|
|
462
470
|
settings: {
|
|
463
471
|
projectId: input.project.id,
|
|
464
|
-
token: input.token,
|
|
465
472
|
},
|
|
466
473
|
},
|
|
467
474
|
runtime: {
|
|
468
475
|
driver: "local",
|
|
469
476
|
workspaceRuntimeDir: runtimeDir,
|
|
470
477
|
projectRoot: process.cwd(),
|
|
471
|
-
workerCommand: input.workerCommand,
|
|
472
|
-
},
|
|
473
|
-
workflow: buildWorkflowOverrides(lifecycleConfig, input),
|
|
474
|
-
orchestrator: {
|
|
475
|
-
concurrency: input.concurrency,
|
|
476
|
-
maxAttempts: input.maxAttempts,
|
|
478
|
+
workerCommand: input.workerCommand ?? resolveWorkerCommand(),
|
|
477
479
|
},
|
|
478
480
|
workflowMapping: mappingConfig,
|
|
479
481
|
});
|
|
480
482
|
// Save/update global config
|
|
481
483
|
const existing = await loadGlobalConfig(configDir);
|
|
482
484
|
const globalConfig = {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
input.workspaceId,
|
|
485
|
+
activeTenant: input.tenantId,
|
|
486
|
+
tenants: [
|
|
487
|
+
...(existing?.tenants ?? []).filter((t) => t !== input.tenantId),
|
|
488
|
+
input.tenantId,
|
|
488
489
|
],
|
|
489
490
|
};
|
|
490
491
|
await saveGlobalConfig(configDir, globalConfig);
|
|
492
|
+
// Generate WORKFLOW.md for tenant-level fallback
|
|
493
|
+
const workflowMd = generateWorkflowMarkdown({
|
|
494
|
+
projectId: input.project.id,
|
|
495
|
+
stateFieldName: input.statusField.name,
|
|
496
|
+
mappings: input.mappings,
|
|
497
|
+
lifecycle: lifecycleConfig,
|
|
498
|
+
runtime: input.agentCommand ?? input.runtime,
|
|
499
|
+
pollIntervalMs: input.pollIntervalMs,
|
|
500
|
+
concurrency: input.concurrency,
|
|
501
|
+
});
|
|
502
|
+
const workflowMdPath = join(configDir, "tenants", input.tenantId, "WORKFLOW.md");
|
|
503
|
+
await writeFile(workflowMdPath, workflowMd, "utf8");
|
|
491
504
|
}
|
|
492
|
-
function
|
|
493
|
-
return {
|
|
494
|
-
lifecycle,
|
|
495
|
-
scheduler: {
|
|
496
|
-
pollIntervalMs: input.pollIntervalMs ?? 30_000,
|
|
497
|
-
},
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
export function generateWorkspaceId(projectTitle, uniqueKey) {
|
|
505
|
+
export function generateTenantId(projectTitle, uniqueKey) {
|
|
501
506
|
const slug = projectTitle
|
|
502
507
|
.toLowerCase()
|
|
503
508
|
.replace(/[^a-z0-9]+/g, "-")
|
|
504
509
|
.replace(/^-|-$/g, "")
|
|
505
510
|
.slice(0, 32);
|
|
506
511
|
const suffix = createHash("sha1").update(uniqueKey).digest("hex").slice(0, 8);
|
|
507
|
-
return [slug || "
|
|
512
|
+
return [slug || "tenant", suffix].join("-");
|
|
508
513
|
}
|