@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/status.js
CHANGED
|
@@ -1,32 +1,9 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { resolveRuntimeRoot,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
function dim(s) {
|
|
9
|
-
return `\x1b[2m${s}\x1b[0m`;
|
|
10
|
-
}
|
|
11
|
-
function green(s) {
|
|
12
|
-
return `\x1b[32m${s}\x1b[0m`;
|
|
13
|
-
}
|
|
14
|
-
function red(s) {
|
|
15
|
-
return `\x1b[31m${s}\x1b[0m`;
|
|
16
|
-
}
|
|
17
|
-
function yellow(s) {
|
|
18
|
-
return `\x1b[33m${s}\x1b[0m`;
|
|
19
|
-
}
|
|
20
|
-
function cyan(s) {
|
|
21
|
-
return `\x1b[36m${s}\x1b[0m`;
|
|
22
|
-
}
|
|
23
|
-
function white(s) {
|
|
24
|
-
return `\x1b[37m${s}\x1b[0m`;
|
|
25
|
-
}
|
|
26
|
-
function stripAnsi(s) {
|
|
27
|
-
// eslint-disable-next-line no-control-regex
|
|
28
|
-
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
29
|
-
}
|
|
3
|
+
import { resolveRuntimeRoot, resolveTenantConfig, syncTenantToRuntime, } from "../orchestrator-runtime.js";
|
|
4
|
+
import { bold, dim, green, red, yellow, cyan, stripAnsi } from "../ansi.js";
|
|
5
|
+
import { clearScreen, showCursor, hideCursor } from "../ansi.js";
|
|
6
|
+
import { renderDashboard } from "../dashboard/renderer.js";
|
|
30
7
|
function healthIcon(health) {
|
|
31
8
|
switch (health) {
|
|
32
9
|
case "idle":
|
|
@@ -54,7 +31,7 @@ function truncate(s, len) {
|
|
|
54
31
|
return s;
|
|
55
32
|
return s.slice(0, len - 3) + "...";
|
|
56
33
|
}
|
|
57
|
-
function
|
|
34
|
+
function renderLegacyStatus(snapshot, noColor) {
|
|
58
35
|
const apply = noColor ? (s) => stripAnsi(s) : (s) => s;
|
|
59
36
|
const lines = [];
|
|
60
37
|
// Header
|
|
@@ -83,16 +60,7 @@ function renderDashboard(snapshot, noColor) {
|
|
|
83
60
|
lines.push(" Active Runs:");
|
|
84
61
|
for (const run of snapshot.activeRuns) {
|
|
85
62
|
const runIdDisplay = truncate(run.runId, 12);
|
|
86
|
-
const
|
|
87
|
-
? cyan
|
|
88
|
-
: run.phase === "human-review"
|
|
89
|
-
? yellow
|
|
90
|
-
: run.phase === "implementation"
|
|
91
|
-
? cyan
|
|
92
|
-
: run.phase === "awaiting-merge"
|
|
93
|
-
? yellow
|
|
94
|
-
: white;
|
|
95
|
-
const phaseStr = apply(phaseColor(run.phase));
|
|
63
|
+
const stateStr = apply(cyan(run.issueState));
|
|
96
64
|
const statusColor = run.status === "running"
|
|
97
65
|
? green
|
|
98
66
|
: run.status === "failed"
|
|
@@ -101,7 +69,7 @@ function renderDashboard(snapshot, noColor) {
|
|
|
101
69
|
? green
|
|
102
70
|
: dim;
|
|
103
71
|
const statusStr = apply(statusColor(run.status));
|
|
104
|
-
lines.push(` ${runIdDisplay} ${run.issueIdentifier} ${
|
|
72
|
+
lines.push(` ${runIdDisplay} ${run.issueIdentifier} ${stateStr} ${statusStr}`);
|
|
105
73
|
}
|
|
106
74
|
lines.push("");
|
|
107
75
|
}
|
|
@@ -143,16 +111,16 @@ function parseStatusArgs(args) {
|
|
|
143
111
|
if (arg === "--watch" || arg === "-w") {
|
|
144
112
|
parsed.watch = true;
|
|
145
113
|
}
|
|
146
|
-
if (arg === "--
|
|
147
|
-
parsed.
|
|
114
|
+
if (arg === "--tenant" || arg === "--tenant-id") {
|
|
115
|
+
parsed.tenantId = args[i + 1];
|
|
148
116
|
i += 1;
|
|
149
117
|
}
|
|
150
118
|
}
|
|
151
119
|
return parsed;
|
|
152
120
|
}
|
|
153
|
-
async function readStatusSnapshot(runtimeRoot,
|
|
121
|
+
async function readStatusSnapshot(runtimeRoot, tenantId) {
|
|
154
122
|
try {
|
|
155
|
-
const statusPath = join(runtimeRoot, "orchestrator", "
|
|
123
|
+
const statusPath = join(runtimeRoot, "orchestrator", "tenants", tenantId, "status.json");
|
|
156
124
|
const content = await readFile(statusPath, "utf-8");
|
|
157
125
|
return JSON.parse(content);
|
|
158
126
|
}
|
|
@@ -160,54 +128,83 @@ async function readStatusSnapshot(runtimeRoot, workspaceId) {
|
|
|
160
128
|
return null;
|
|
161
129
|
}
|
|
162
130
|
}
|
|
131
|
+
async function readAllStatusSnapshots(runtimeRoot) {
|
|
132
|
+
try {
|
|
133
|
+
const tenantsDir = join(runtimeRoot, "orchestrator", "tenants");
|
|
134
|
+
const { readdir } = await import("node:fs/promises");
|
|
135
|
+
const entries = await readdir(tenantsDir, { withFileTypes: true });
|
|
136
|
+
const snapshots = [];
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (!entry.isDirectory())
|
|
139
|
+
continue;
|
|
140
|
+
const statusPath = join(tenantsDir, entry.name, "status.json");
|
|
141
|
+
try {
|
|
142
|
+
const content = await readFile(statusPath, "utf-8");
|
|
143
|
+
snapshots.push(JSON.parse(content));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// skip missing/invalid files
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return snapshots;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
163
155
|
const handler = async (args, options) => {
|
|
164
156
|
const parsed = parseStatusArgs(args);
|
|
165
|
-
const
|
|
166
|
-
if (!
|
|
167
|
-
process.stderr.write("No
|
|
157
|
+
const tenantConfig = await resolveTenantConfig(options.configDir, parsed.tenantId);
|
|
158
|
+
if (!tenantConfig) {
|
|
159
|
+
process.stderr.write("No tenant configured. Run 'gh-symphony init' first.\n");
|
|
168
160
|
process.exitCode = 1;
|
|
169
161
|
return;
|
|
170
162
|
}
|
|
171
163
|
const runtimeRoot = resolveRuntimeRoot(options.configDir);
|
|
172
|
-
const
|
|
173
|
-
await
|
|
164
|
+
const tenantId = tenantConfig.tenantId;
|
|
165
|
+
await syncTenantToRuntime(options.configDir, tenantConfig);
|
|
174
166
|
if (parsed.watch) {
|
|
175
|
-
|
|
176
|
-
|
|
167
|
+
const isTTY = process.stdout.isTTY === true;
|
|
168
|
+
let terminalWidth = process.stdout.columns ?? 115;
|
|
177
169
|
const run = async () => {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (options.json) {
|
|
182
|
-
process.stdout.write(JSON.stringify(snapshot, null, 2) + "\n");
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
process.stdout.write(renderDashboard(snapshot, options.noColor) + "\n");
|
|
186
|
-
}
|
|
170
|
+
const snapshots = await readAllStatusSnapshots(runtimeRoot);
|
|
171
|
+
if (options.json || !isTTY) {
|
|
172
|
+
process.stdout.write(JSON.stringify(snapshots, null, 2) + "\n");
|
|
187
173
|
}
|
|
188
174
|
else {
|
|
189
|
-
process.stdout.write(
|
|
175
|
+
process.stdout.write(clearScreen() +
|
|
176
|
+
renderDashboard(snapshots, {
|
|
177
|
+
terminalWidth,
|
|
178
|
+
noColor: options.noColor,
|
|
179
|
+
}) +
|
|
180
|
+
"\n");
|
|
190
181
|
}
|
|
191
182
|
};
|
|
183
|
+
if (isTTY) {
|
|
184
|
+
process.stdout.write(hideCursor());
|
|
185
|
+
}
|
|
192
186
|
await run();
|
|
193
187
|
const interval = setInterval(() => void run(), 2000);
|
|
188
|
+
process.on("SIGWINCH", () => {
|
|
189
|
+
terminalWidth = process.stdout.columns ?? terminalWidth;
|
|
190
|
+
});
|
|
194
191
|
const shutdown = () => {
|
|
195
192
|
clearInterval(interval);
|
|
193
|
+
process.stdout.write(showCursor() + "\n");
|
|
196
194
|
process.exit(0);
|
|
197
195
|
};
|
|
198
196
|
process.on("SIGINT", shutdown);
|
|
199
197
|
process.on("SIGTERM", shutdown);
|
|
200
|
-
// Keep alive
|
|
201
198
|
await new Promise(() => { });
|
|
202
199
|
}
|
|
203
200
|
// Single status query
|
|
204
|
-
const snapshot = await readStatusSnapshot(runtimeRoot,
|
|
201
|
+
const snapshot = await readStatusSnapshot(runtimeRoot, tenantId);
|
|
205
202
|
if (snapshot) {
|
|
206
203
|
if (options.json) {
|
|
207
204
|
process.stdout.write(JSON.stringify(snapshot, null, 2) + "\n");
|
|
208
205
|
}
|
|
209
206
|
else {
|
|
210
|
-
process.stdout.write(
|
|
207
|
+
process.stdout.write(renderLegacyStatus(snapshot, options.noColor) + "\n");
|
|
211
208
|
}
|
|
212
209
|
}
|
|
213
210
|
else {
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
|
|
3
|
+
import { ensureGhAuth, getGhToken, GhAuthError } from "../github/gh-auth.js";
|
|
4
|
+
import { inferAllStateRoles, toWorkflowLifecycleConfig, validateStateMapping, } from "../mapping/smart-defaults.js";
|
|
5
|
+
import { loadGlobalConfig, saveGlobalConfig, loadTenantConfig, tenantConfigDir, } from "../config.js";
|
|
6
|
+
import { writeConfig, generateTenantId, abortIfCancelled } from "./init.js";
|
|
7
|
+
// ── Scope error display ───────────────────────────────────────────────────────
|
|
8
|
+
const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
|
|
9
|
+
function displayScopeError(error, retryCommand) {
|
|
10
|
+
const plural = error.requiredScopes.length === 1 ? "" : "s";
|
|
11
|
+
p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
|
|
12
|
+
const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
|
|
13
|
+
const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
|
|
14
|
+
const scopeArg = scopesToAdd.length > 0
|
|
15
|
+
? scopesToAdd.join(",")
|
|
16
|
+
: error.requiredScopes.join(",");
|
|
17
|
+
p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
|
|
18
|
+
}
|
|
19
|
+
function parseTenantAddFlags(args) {
|
|
20
|
+
const flags = { nonInteractive: false };
|
|
21
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
const next = args[i + 1];
|
|
24
|
+
switch (arg) {
|
|
25
|
+
case "--non-interactive":
|
|
26
|
+
flags.nonInteractive = true;
|
|
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
|
+
// ── Tenant command handler ────────────────────────────────────────────────────
|
|
41
|
+
const handler = async (args, options) => {
|
|
42
|
+
const [subcommand, ...rest] = args;
|
|
43
|
+
switch (subcommand) {
|
|
44
|
+
case "add":
|
|
45
|
+
await tenantAdd(rest, options);
|
|
46
|
+
return;
|
|
47
|
+
case "list":
|
|
48
|
+
await tenantList(options);
|
|
49
|
+
return;
|
|
50
|
+
case "remove":
|
|
51
|
+
await tenantRemove(rest, options);
|
|
52
|
+
return;
|
|
53
|
+
default:
|
|
54
|
+
process.stdout.write("Usage: gh-symphony tenant <add|list|remove>\n");
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
export default handler;
|
|
58
|
+
// ── tenant add ───────────────────────────────────────────────────────────────
|
|
59
|
+
async function tenantAdd(args, options) {
|
|
60
|
+
const flags = parseTenantAddFlags(args);
|
|
61
|
+
if (flags.nonInteractive) {
|
|
62
|
+
await tenantAddNonInteractive(flags, options);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await tenantAddInteractive(options);
|
|
66
|
+
}
|
|
67
|
+
// ── Non-interactive tenant add ───────────────────────────────────────────────
|
|
68
|
+
async function tenantAddNonInteractive(flags, options) {
|
|
69
|
+
let token;
|
|
70
|
+
try {
|
|
71
|
+
token = getGhToken();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const client = createClient(token);
|
|
79
|
+
// Validate token
|
|
80
|
+
let viewer;
|
|
81
|
+
try {
|
|
82
|
+
viewer = await validateToken(client);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
process.stderr.write("Error: Invalid GitHub token.\n");
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const scopeCheck = checkRequiredScopes(viewer.scopes);
|
|
90
|
+
if (!scopeCheck.valid) {
|
|
91
|
+
process.stderr.write(`Error: Missing required PAT scopes: ${scopeCheck.missing.join(", ")}\n`);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Find project
|
|
96
|
+
const projects = await listUserProjects(client);
|
|
97
|
+
let project;
|
|
98
|
+
if (flags.project) {
|
|
99
|
+
const match = projects.find((p) => p.id === flags.project || p.url === flags.project);
|
|
100
|
+
if (!match) {
|
|
101
|
+
process.stderr.write(`Error: Project not found: ${flags.project}\n`);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
project = await getProjectDetail(client, match.id);
|
|
106
|
+
}
|
|
107
|
+
else if (projects.length === 1) {
|
|
108
|
+
project = await getProjectDetail(client, projects[0].id);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
process.stderr.write("Error: --project is required when multiple projects exist.\n");
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Auto-map with smart defaults
|
|
116
|
+
const statusField = project.statusFields.find((f) => f.name.toLowerCase() === "status") ??
|
|
117
|
+
project.statusFields[0];
|
|
118
|
+
if (!statusField) {
|
|
119
|
+
process.stderr.write("Error: No status field found on the project.\n");
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const columnNames = statusField.options.map((o) => o.name);
|
|
124
|
+
const inferred = inferAllStateRoles(columnNames);
|
|
125
|
+
const mappings = {};
|
|
126
|
+
for (const mapping of inferred) {
|
|
127
|
+
if (mapping.role) {
|
|
128
|
+
mappings[mapping.columnName] = { role: mapping.role };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const validation = validateStateMapping(mappings);
|
|
132
|
+
if (!validation.valid) {
|
|
133
|
+
process.stderr.write(`Error: Cannot auto-map columns. ${validation.errors.join("; ")}\nRun without --non-interactive for manual mapping.\n`);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const runtime = flags.runtime ?? "codex";
|
|
138
|
+
const tenantId = generateTenantId(project.title, project.id);
|
|
139
|
+
await writeConfig(options.configDir, {
|
|
140
|
+
tenantId,
|
|
141
|
+
project,
|
|
142
|
+
repos: project.linkedRepositories,
|
|
143
|
+
statusField,
|
|
144
|
+
mappings,
|
|
145
|
+
runtime,
|
|
146
|
+
});
|
|
147
|
+
if (options.json) {
|
|
148
|
+
process.stdout.write(JSON.stringify({ tenantId, status: "created" }) + "\n");
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
process.stdout.write(`Tenant created: ${tenantId}\n`);
|
|
152
|
+
process.stdout.write(`Run 'gh-symphony start' to begin orchestration.\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ── Interactive tenant add ───────────────────────────────────────────────────
|
|
156
|
+
async function tenantAddInteractive(options) {
|
|
157
|
+
p.intro("gh-symphony — Tenant Setup");
|
|
158
|
+
// Detect existing config
|
|
159
|
+
const existingConfig = await loadGlobalConfig(options.configDir);
|
|
160
|
+
if (existingConfig) {
|
|
161
|
+
const action = await abortIfCancelled(p.select({
|
|
162
|
+
message: "Existing configuration detected. What would you like to do?",
|
|
163
|
+
options: [
|
|
164
|
+
{ value: "add", label: "Add a new tenant" },
|
|
165
|
+
{ value: "overwrite", label: "Start fresh (overwrite)" },
|
|
166
|
+
],
|
|
167
|
+
}));
|
|
168
|
+
if (action === "overwrite") {
|
|
169
|
+
// Continue with fresh setup — will overwrite config
|
|
170
|
+
}
|
|
171
|
+
// "add" continues to create a new tenant alongside existing ones
|
|
172
|
+
}
|
|
173
|
+
// ── Step 1: gh CLI authentication ─────────────────────────────────────────────
|
|
174
|
+
const s1 = p.spinner();
|
|
175
|
+
s1.start("Checking gh CLI authentication...");
|
|
176
|
+
let login;
|
|
177
|
+
let client;
|
|
178
|
+
try {
|
|
179
|
+
const { login: ghLogin, token } = ensureGhAuth();
|
|
180
|
+
login = ghLogin;
|
|
181
|
+
client = createClient(token);
|
|
182
|
+
s1.stop(`Authenticated as ${login}`);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
s1.stop("Authentication failed.");
|
|
186
|
+
if (error instanceof GhAuthError) {
|
|
187
|
+
if (error.code === "not_installed") {
|
|
188
|
+
p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
|
|
189
|
+
}
|
|
190
|
+
else if (error.code === "not_authenticated") {
|
|
191
|
+
p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
|
|
192
|
+
}
|
|
193
|
+
else if (error.code === "missing_scopes") {
|
|
194
|
+
p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
p.log.error(error.message);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
202
|
+
}
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// ── Step 2: Project selection ───────────────────────────────────────────────
|
|
207
|
+
const s2 = p.spinner();
|
|
208
|
+
s2.start("Loading projects...");
|
|
209
|
+
let projects;
|
|
210
|
+
try {
|
|
211
|
+
projects = await listUserProjects(client);
|
|
212
|
+
s2.stop(`Found ${projects.length} project${projects.length === 1 ? "" : "s"}`);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
s2.stop("Failed to load projects.");
|
|
216
|
+
if (error instanceof GitHubScopeError) {
|
|
217
|
+
displayScopeError(error, "gh-symphony tenant add");
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
221
|
+
}
|
|
222
|
+
process.exitCode = 1;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (projects.length === 0) {
|
|
226
|
+
p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const selectedProjectId = await abortIfCancelled(p.select({
|
|
231
|
+
message: "Step 1/3 — Select a GitHub Project:",
|
|
232
|
+
options: projects.map((proj) => ({
|
|
233
|
+
value: proj.id,
|
|
234
|
+
label: `${proj.owner.login}/${proj.title}`,
|
|
235
|
+
hint: `${proj.openItemCount} items`,
|
|
236
|
+
})),
|
|
237
|
+
maxItems: 15,
|
|
238
|
+
}));
|
|
239
|
+
const s2d = p.spinner();
|
|
240
|
+
s2d.start("Loading project details...");
|
|
241
|
+
let projectDetail;
|
|
242
|
+
try {
|
|
243
|
+
projectDetail = await getProjectDetail(client, selectedProjectId);
|
|
244
|
+
s2d.stop(`Loaded: ${projectDetail.title}`);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
s2d.stop("Failed to load project details.");
|
|
248
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// ── Step 3: Repository selection ────────────────────────────────────────────
|
|
253
|
+
if (projectDetail.linkedRepositories.length === 0) {
|
|
254
|
+
p.log.warn("No linked repositories found in this project. Add issues from repositories to the project first.");
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const selectedRepos = await abortIfCancelled(p.multiselect({
|
|
259
|
+
message: "Step 2/3 — Select repositories to orchestrate:",
|
|
260
|
+
options: projectDetail.linkedRepositories.map((repo) => ({
|
|
261
|
+
value: repo,
|
|
262
|
+
label: `${repo.owner}/${repo.name}`,
|
|
263
|
+
})),
|
|
264
|
+
required: true,
|
|
265
|
+
}));
|
|
266
|
+
// ── Step 4: Status column auto-detection ─────────────────────────────────────
|
|
267
|
+
const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ??
|
|
268
|
+
projectDetail.statusFields[0];
|
|
269
|
+
if (!statusField) {
|
|
270
|
+
p.log.error("No status field found on the project. The project needs a single-select 'Status' field.");
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const columnNames = statusField.options.map((o) => o.name);
|
|
275
|
+
const inferred = inferAllStateRoles(columnNames);
|
|
276
|
+
const mappings = {};
|
|
277
|
+
for (const mapping of inferred) {
|
|
278
|
+
if (mapping.role) {
|
|
279
|
+
mappings[mapping.columnName] = { role: mapping.role };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const validation = validateStateMapping(mappings);
|
|
283
|
+
if (!validation.valid) {
|
|
284
|
+
p.log.error(`Cannot auto-map status columns: ${validation.errors.join("; ")}\nRun 'gh-symphony init' to manually configure WORKFLOW.md.`);
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
|
|
289
|
+
p.log.info(`Auto-detected workflow: Active=[${lifecycleConfig.activeStates.join(", ")}] Terminal=[${lifecycleConfig.terminalStates.join(", ")}]`);
|
|
290
|
+
// ── Step 4: Runtime selection ────────────────────────────────────────────────
|
|
291
|
+
const runtime = await abortIfCancelled(p.select({
|
|
292
|
+
message: "Step 3/3 — Select AI runtime:",
|
|
293
|
+
options: [
|
|
294
|
+
{ value: "codex", label: "OpenAI Codex", hint: "recommended" },
|
|
295
|
+
{ value: "claude-code", label: "Claude Code" },
|
|
296
|
+
{ value: "custom", label: "Custom command" },
|
|
297
|
+
],
|
|
298
|
+
}));
|
|
299
|
+
let agentCommand;
|
|
300
|
+
if (runtime === "custom") {
|
|
301
|
+
agentCommand = await abortIfCancelled(p.text({
|
|
302
|
+
message: "Custom agent command:",
|
|
303
|
+
placeholder: "bash -lc my-agent",
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
// ── Confirmation ─────────────────────────────────────────────────────────────
|
|
307
|
+
p.note([
|
|
308
|
+
`User: ${login}`,
|
|
309
|
+
`Project: ${projectDetail.title}`,
|
|
310
|
+
`Repos: ${selectedRepos.map((r) => `${r.owner}/${r.name}`).join(", ")}`,
|
|
311
|
+
`Runtime: ${runtime}`,
|
|
312
|
+
`Active: ${lifecycleConfig.activeStates.join(", ")}`,
|
|
313
|
+
`Terminal: ${lifecycleConfig.terminalStates.join(", ")}`,
|
|
314
|
+
].join("\n"), "Configuration Summary");
|
|
315
|
+
const confirmed = await abortIfCancelled(p.confirm({ message: "Apply this configuration?" }));
|
|
316
|
+
if (!confirmed) {
|
|
317
|
+
p.cancel("Setup cancelled.");
|
|
318
|
+
process.exitCode = 130;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// ── Write config files ────────────────────────────────────────────────────────
|
|
322
|
+
const tenantId = generateTenantId(projectDetail.title, projectDetail.id);
|
|
323
|
+
const s6 = p.spinner();
|
|
324
|
+
s6.start("Writing configuration...");
|
|
325
|
+
try {
|
|
326
|
+
await writeConfig(options.configDir, {
|
|
327
|
+
tenantId,
|
|
328
|
+
project: projectDetail,
|
|
329
|
+
repos: selectedRepos,
|
|
330
|
+
statusField: {
|
|
331
|
+
id: statusField.id,
|
|
332
|
+
name: statusField.name,
|
|
333
|
+
options: statusField.options,
|
|
334
|
+
},
|
|
335
|
+
mappings,
|
|
336
|
+
runtime,
|
|
337
|
+
agentCommand,
|
|
338
|
+
});
|
|
339
|
+
s6.stop("Configuration saved.");
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
s6.stop("Failed to write configuration.");
|
|
343
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
344
|
+
process.exitCode = 1;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
p.log.info(`WORKFLOW.md generated at ${tenantId}/WORKFLOW.md — edit it to customize your team policy.`);
|
|
348
|
+
p.outro(`Tenant "${tenantId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
|
|
349
|
+
}
|
|
350
|
+
// ── tenant list ───────────────────────────────────────────────────────────────
|
|
351
|
+
async function tenantList(options) {
|
|
352
|
+
const global = await loadGlobalConfig(options.configDir);
|
|
353
|
+
if (!global?.tenants?.length) {
|
|
354
|
+
process.stdout.write("No tenants configured.\n");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
process.stdout.write("Configured tenants:\n");
|
|
358
|
+
const configs = await Promise.all(global.tenants.map((id) => loadTenantConfig(options.configDir, id)));
|
|
359
|
+
for (let i = 0; i < global.tenants.length; i++) {
|
|
360
|
+
const tenantId = global.tenants[i];
|
|
361
|
+
const config = configs[i];
|
|
362
|
+
const active = global.activeTenant === tenantId ? " (active)" : "";
|
|
363
|
+
const slug = config?.slug ?? tenantId;
|
|
364
|
+
process.stdout.write(` ${slug}${active}\n`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// ── tenant remove ─────────────────────────────────────────────────────────────
|
|
368
|
+
async function tenantRemove(args, options) {
|
|
369
|
+
const tenantId = args[0];
|
|
370
|
+
if (!tenantId) {
|
|
371
|
+
process.stderr.write("Usage: gh-symphony tenant remove <tenant-id>\n");
|
|
372
|
+
process.exitCode = 1;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const global = await loadGlobalConfig(options.configDir);
|
|
376
|
+
if (!global) {
|
|
377
|
+
process.stderr.write("No configuration found.\n");
|
|
378
|
+
process.exitCode = 1;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const updatedTenants = (global.tenants ?? []).filter((t) => t !== tenantId);
|
|
382
|
+
if (updatedTenants.length === global.tenants.length) {
|
|
383
|
+
process.stderr.write(`Tenant "${tenantId}" not found.\n`);
|
|
384
|
+
process.exitCode = 1;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const updatedConfig = {
|
|
388
|
+
...global,
|
|
389
|
+
tenants: updatedTenants,
|
|
390
|
+
activeTenant: global.activeTenant === tenantId ? null : global.activeTenant,
|
|
391
|
+
};
|
|
392
|
+
await saveGlobalConfig(options.configDir, updatedConfig);
|
|
393
|
+
const { rm } = await import("node:fs/promises");
|
|
394
|
+
const dir = tenantConfigDir(options.configDir, tenantId);
|
|
395
|
+
try {
|
|
396
|
+
await rm(dir, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// Directory may not exist
|
|
400
|
+
}
|
|
401
|
+
process.stdout.write(`Tenant "${tenantId}" removed.\n`);
|
|
402
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OrchestratorTenantConfig, WorkflowLifecycleConfig } from "@gh-symphony/core";
|
|
2
2
|
export declare const DEFAULT_CONFIG_DIR: string;
|
|
3
3
|
export declare const CONFIG_FILE = "config.json";
|
|
4
4
|
export declare const DAEMON_PID_FILE = "daemon.pid";
|
|
5
5
|
export declare const LOGS_DIR = "logs";
|
|
6
6
|
export type CliGlobalConfig = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
workspaces: string[];
|
|
7
|
+
activeTenant: string | null;
|
|
8
|
+
tenants: string[];
|
|
10
9
|
};
|
|
11
|
-
export type
|
|
12
|
-
workflowMapping?:
|
|
10
|
+
export type CliTenantConfig = OrchestratorTenantConfig & {
|
|
11
|
+
workflowMapping?: WorkflowStateConfig;
|
|
13
12
|
};
|
|
14
|
-
export type
|
|
13
|
+
export type StateRole = "active" | "wait" | "terminal";
|
|
14
|
+
export type StateMapping = {
|
|
15
|
+
role: StateRole;
|
|
16
|
+
goal?: string;
|
|
17
|
+
};
|
|
18
|
+
export type WorkflowStateConfig = {
|
|
15
19
|
stateFieldName: string;
|
|
16
|
-
|
|
17
|
-
humanReviewMode: HumanReviewMode;
|
|
20
|
+
mappings: Record<string, StateMapping>;
|
|
18
21
|
lifecycle: WorkflowLifecycleConfig;
|
|
19
22
|
};
|
|
20
|
-
export type ColumnRole = "trigger" | "working" | "human-review" | "done" | "ignored";
|
|
21
|
-
export type HumanReviewMode = "plan-and-pr" | "plan-only" | "pr-only" | "none";
|
|
22
23
|
export declare function resolveConfigDir(override?: string): string;
|
|
23
24
|
export declare function configFilePath(configDir: string): string;
|
|
24
|
-
export declare function
|
|
25
|
-
export declare function
|
|
26
|
-
export declare function workflowMappingPath(configDir: string,
|
|
25
|
+
export declare function tenantConfigDir(configDir: string, tenantId: string): string;
|
|
26
|
+
export declare function tenantConfigPath(configDir: string, tenantId: string): string;
|
|
27
|
+
export declare function workflowMappingPath(configDir: string, tenantId: string): string;
|
|
27
28
|
export declare function daemonPidPath(configDir: string): string;
|
|
28
29
|
export declare function logsDir(configDir: string): string;
|
|
29
30
|
export declare function orchestratorLogPath(configDir: string): string;
|
|
30
31
|
export declare function loadGlobalConfig(configDir: string): Promise<CliGlobalConfig | null>;
|
|
31
32
|
export declare function saveGlobalConfig(configDir: string, config: CliGlobalConfig): Promise<void>;
|
|
32
|
-
export declare function
|
|
33
|
-
export declare function
|
|
34
|
-
export declare function loadWorkflowMapping(configDir: string,
|
|
35
|
-
export declare function saveWorkflowMapping(configDir: string,
|
|
36
|
-
export declare function
|
|
33
|
+
export declare function loadTenantConfig(configDir: string, tenantId: string): Promise<CliTenantConfig | null>;
|
|
34
|
+
export declare function saveTenantConfig(configDir: string, tenantId: string, config: CliTenantConfig): Promise<void>;
|
|
35
|
+
export declare function loadWorkflowMapping(configDir: string, tenantId: string): Promise<WorkflowStateConfig | null>;
|
|
36
|
+
export declare function saveWorkflowMapping(configDir: string, tenantId: string, mapping: WorkflowStateConfig): Promise<void>;
|
|
37
|
+
export declare function loadActiveTenantConfig(configDir: string): Promise<CliTenantConfig | null>;
|