@gh-symphony/cli 0.0.1 → 0.0.2
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 +30 -7
- package/dist/commands/init.js +421 -284
- 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,33 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
|
+
import { parseWorkflowMarkdown } from "@gh-symphony/core";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createClient, validateToken, checkRequiredScopes, listUserProjects, getProjectDetail, GitHubScopeError, } from "../github/client.js";
|
|
8
|
+
import { inferAllStateRoles, toWorkflowLifecycleConfig, validateStateMapping, } from "../mapping/smart-defaults.js";
|
|
9
|
+
import { generateWorkflowMarkdown } from "../workflow/generate-workflow-md.js";
|
|
10
|
+
import { loadGlobalConfig, loadTenantConfig, saveGlobalConfig, saveTenantConfig, saveWorkflowMapping, } from "../config.js";
|
|
11
|
+
import { getGhToken, ensureGhAuth, GhAuthError } from "../github/gh-auth.js";
|
|
12
|
+
import { detectEnvironment } from "../detection/environment-detector.js";
|
|
13
|
+
import { buildContextYaml, writeContextYaml, } from "../context/generate-context-yaml.js";
|
|
14
|
+
import { generateReferenceWorkflow } from "../workflow/generate-reference-workflow.js";
|
|
15
|
+
import { resolveSkillsDir, writeAllSkills } from "../skills/skill-writer.js";
|
|
16
|
+
import { ALL_SKILL_TEMPLATES } from "../skills/templates/index.js";
|
|
17
|
+
// ── Scope error display ───────────────────────────────────────────────────────
|
|
18
|
+
const KNOWN_REQUIRED_SCOPES = ["repo", "read:org", "project"];
|
|
19
|
+
function displayScopeError(error, retryCommand) {
|
|
20
|
+
const plural = error.requiredScopes.length === 1 ? "" : "s";
|
|
21
|
+
p.log.error(`Token is missing required scope${plural}: ${error.requiredScopes.join(", ")}`);
|
|
22
|
+
const currentSet = new Set(error.currentScopes.map((s) => s.toLowerCase()));
|
|
23
|
+
const scopesToAdd = KNOWN_REQUIRED_SCOPES.filter((s) => !currentSet.has(s));
|
|
24
|
+
const scopeArg = scopesToAdd.length > 0
|
|
25
|
+
? scopesToAdd.join(",")
|
|
26
|
+
: error.requiredScopes.join(",");
|
|
27
|
+
p.note(`gh auth refresh --scopes ${scopeArg}\n\nThen re-run: ${retryCommand}`, "Fix missing scope");
|
|
28
|
+
}
|
|
6
29
|
// ── Cancellation utility ─────────────────────────────────────────────────────
|
|
7
|
-
async function abortIfCancelled(input) {
|
|
30
|
+
export async function abortIfCancelled(input) {
|
|
8
31
|
const result = await input;
|
|
9
32
|
if (p.isCancel(result)) {
|
|
10
33
|
p.cancel("Setup cancelled.");
|
|
@@ -13,7 +36,11 @@ async function abortIfCancelled(input) {
|
|
|
13
36
|
return result;
|
|
14
37
|
}
|
|
15
38
|
function parseInitFlags(args) {
|
|
16
|
-
const flags = {
|
|
39
|
+
const flags = {
|
|
40
|
+
nonInteractive: false,
|
|
41
|
+
skipSkills: false,
|
|
42
|
+
skipContext: false,
|
|
43
|
+
};
|
|
17
44
|
for (let i = 0; i < args.length; i += 1) {
|
|
18
45
|
const arg = args[i];
|
|
19
46
|
const next = args[i + 1];
|
|
@@ -21,18 +48,20 @@ function parseInitFlags(args) {
|
|
|
21
48
|
case "--non-interactive":
|
|
22
49
|
flags.nonInteractive = true;
|
|
23
50
|
break;
|
|
24
|
-
case "--token":
|
|
25
|
-
flags.token = next;
|
|
26
|
-
i += 1;
|
|
27
|
-
break;
|
|
28
51
|
case "--project":
|
|
29
52
|
flags.project = next;
|
|
30
53
|
i += 1;
|
|
31
54
|
break;
|
|
32
|
-
case "--
|
|
33
|
-
flags.
|
|
55
|
+
case "--output":
|
|
56
|
+
flags.output = next;
|
|
34
57
|
i += 1;
|
|
35
58
|
break;
|
|
59
|
+
case "--skip-skills":
|
|
60
|
+
flags.skipSkills = true;
|
|
61
|
+
break;
|
|
62
|
+
case "--skip-context":
|
|
63
|
+
flags.skipContext = true;
|
|
64
|
+
break;
|
|
36
65
|
}
|
|
37
66
|
}
|
|
38
67
|
return flags;
|
|
@@ -47,14 +76,168 @@ const handler = async (args, options) => {
|
|
|
47
76
|
await runInteractive(options);
|
|
48
77
|
};
|
|
49
78
|
export default handler;
|
|
50
|
-
|
|
79
|
+
function inferAgentRuntimeFromCommand(command) {
|
|
80
|
+
if (!command) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (command.includes("claude-code")) {
|
|
84
|
+
return "claude-code";
|
|
85
|
+
}
|
|
86
|
+
if (command.includes("codex")) {
|
|
87
|
+
return "codex";
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function isWorkerBootstrapCommand(command) {
|
|
92
|
+
return (command.includes("@gh-symphony/worker/dist/index.js") ||
|
|
93
|
+
command.includes("packages/worker/dist/index.js"));
|
|
94
|
+
}
|
|
95
|
+
function isMissingAgentEnvError(error) {
|
|
96
|
+
return (error instanceof Error &&
|
|
97
|
+
error.message.includes("Workflow front matter requires environment variable"));
|
|
98
|
+
}
|
|
99
|
+
export async function resolveTenantRuntime(configDir, tenantId, tenantWorkerCommand) {
|
|
100
|
+
const workflowPath = join(configDir, "tenants", tenantId, "WORKFLOW.md");
|
|
101
|
+
try {
|
|
102
|
+
const workflowMarkdown = await readFile(workflowPath, "utf8");
|
|
103
|
+
const agentCommand = parseWorkflowMarkdown(workflowMarkdown, {}).agentCommand;
|
|
104
|
+
if (!isWorkerBootstrapCommand(agentCommand)) {
|
|
105
|
+
return agentCommand;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const err = error;
|
|
110
|
+
if (err.code !== "ENOENT" && !isMissingAgentEnvError(error)) {
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return inferAgentRuntimeFromCommand(tenantWorkerCommand) ?? "codex";
|
|
115
|
+
}
|
|
116
|
+
export async function writeEcosystem(opts) {
|
|
117
|
+
const { cwd, projectDetail, statusField, runtime, skipSkills, skipContext } = opts;
|
|
118
|
+
const ghSymphonyDir = join(cwd, ".gh-symphony");
|
|
119
|
+
await mkdir(ghSymphonyDir, { recursive: true });
|
|
120
|
+
// 1. Detect environment
|
|
121
|
+
const env = await detectEnvironment(cwd);
|
|
122
|
+
// 2. Write context.yaml (unless --skip-context)
|
|
123
|
+
let contextYamlWritten = false;
|
|
124
|
+
if (!skipContext) {
|
|
125
|
+
const contextYaml = buildContextYaml({
|
|
126
|
+
projectDetail,
|
|
127
|
+
statusField,
|
|
128
|
+
detectedEnvironment: env,
|
|
129
|
+
runtime: {
|
|
130
|
+
agent: runtime,
|
|
131
|
+
agent_command: runtime === "codex"
|
|
132
|
+
? "bash -lc codex app-server"
|
|
133
|
+
: runtime === "claude-code"
|
|
134
|
+
? "bash -lc claude-code"
|
|
135
|
+
: runtime,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
await writeContextYaml(cwd, contextYaml);
|
|
139
|
+
contextYamlWritten = true;
|
|
140
|
+
}
|
|
141
|
+
// 3. Write reference-workflow.md
|
|
142
|
+
const refWorkflow = generateReferenceWorkflow({
|
|
143
|
+
runtime,
|
|
144
|
+
statusColumns: statusField.options.map((o) => ({
|
|
145
|
+
name: o.name,
|
|
146
|
+
role: null,
|
|
147
|
+
})),
|
|
148
|
+
projectId: projectDetail.id,
|
|
149
|
+
});
|
|
150
|
+
const refPath = join(ghSymphonyDir, "reference-workflow.md");
|
|
151
|
+
const tmpRef = refPath + ".tmp";
|
|
152
|
+
await writeFile(tmpRef, refWorkflow, "utf8");
|
|
153
|
+
await rename(tmpRef, refPath);
|
|
154
|
+
// 4. Write skills (unless --skip-skills)
|
|
155
|
+
const skillsDir = resolveSkillsDir(cwd, runtime);
|
|
156
|
+
let skillsWritten = [];
|
|
157
|
+
let skillsSkipped = [];
|
|
158
|
+
if (!skipSkills && skillsDir) {
|
|
159
|
+
const result = await writeAllSkills(cwd, runtime, ALL_SKILL_TEMPLATES, {
|
|
160
|
+
runtime,
|
|
161
|
+
projectId: projectDetail.id,
|
|
162
|
+
projectTitle: projectDetail.title,
|
|
163
|
+
repositories: projectDetail.linkedRepositories.map((r) => ({
|
|
164
|
+
owner: r.owner,
|
|
165
|
+
name: r.name,
|
|
166
|
+
})),
|
|
167
|
+
statusColumns: statusField.options.map((o) => ({
|
|
168
|
+
id: o.id,
|
|
169
|
+
name: o.name,
|
|
170
|
+
role: null,
|
|
171
|
+
})),
|
|
172
|
+
statusFieldId: statusField.id,
|
|
173
|
+
contextYamlPath: ".gh-symphony/context.yaml",
|
|
174
|
+
referenceWorkflowPath: ".gh-symphony/reference-workflow.md",
|
|
175
|
+
});
|
|
176
|
+
skillsWritten = result.written.map((p) => basename(dirname(p)));
|
|
177
|
+
skillsSkipped = result.skipped.map((p) => basename(dirname(p)));
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
projectId: projectDetail.id,
|
|
181
|
+
projectTitle: projectDetail.title,
|
|
182
|
+
runtime,
|
|
183
|
+
skillsDir,
|
|
184
|
+
contextYamlWritten,
|
|
185
|
+
referenceWorkflowWritten: true,
|
|
186
|
+
skillsWritten,
|
|
187
|
+
skillsSkipped,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// ── Ecosystem summary output ─────────────────────────────────────────────────
|
|
191
|
+
function printEcosystemSummary(result, workflowPath, opts) {
|
|
192
|
+
const cwd = process.cwd();
|
|
193
|
+
const relWorkflow = relative(cwd, workflowPath) || "WORKFLOW.md";
|
|
194
|
+
const lines = [];
|
|
195
|
+
lines.push(`Project ${result.projectTitle} (${result.projectId})`);
|
|
196
|
+
lines.push(`Runtime ${result.runtime}`);
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push("Generated files");
|
|
199
|
+
lines.push(` ✓ WORKFLOW.md ${relWorkflow}`);
|
|
200
|
+
if (result.contextYamlWritten) {
|
|
201
|
+
lines.push(" ✓ Context metadata .gh-symphony/context.yaml");
|
|
202
|
+
}
|
|
203
|
+
if (result.referenceWorkflowWritten) {
|
|
204
|
+
lines.push(" ✓ Reference workflow .gh-symphony/reference-workflow.md");
|
|
205
|
+
}
|
|
206
|
+
if (result.skillsDir) {
|
|
207
|
+
const relSkillsDir = relative(cwd, result.skillsDir);
|
|
208
|
+
lines.push("");
|
|
209
|
+
lines.push(`Skills → ${relSkillsDir}/`);
|
|
210
|
+
for (const name of result.skillsWritten) {
|
|
211
|
+
lines.push(` ✓ ${name}`);
|
|
212
|
+
}
|
|
213
|
+
for (const name of result.skillsSkipped) {
|
|
214
|
+
lines.push(` – ${name} (already exists, skipped)`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (result.runtime !== "codex" && result.runtime !== "claude-code") {
|
|
218
|
+
lines.push("");
|
|
219
|
+
lines.push("Skills → (skipped — custom runtime)");
|
|
220
|
+
}
|
|
221
|
+
if (opts.interactive) {
|
|
222
|
+
p.note(lines.join("\n"), "Setup complete");
|
|
223
|
+
p.outro(opts.nextSteps ?? "Ready.");
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
process.stdout.write(lines.map((l) => ` ${l}`).join("\n") + "\n");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ── Non-interactive mode: WORKFLOW.md only ───────────────────────────────────
|
|
51
230
|
async function runNonInteractive(flags, options) {
|
|
52
|
-
|
|
53
|
-
|
|
231
|
+
let token;
|
|
232
|
+
try {
|
|
233
|
+
token = getGhToken();
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
process.stderr.write("Error: GitHub token not found. Run 'gh auth login --scopes repo,read:org,project' or set GITHUB_GRAPHQL_TOKEN.\n");
|
|
54
237
|
process.exitCode = 1;
|
|
55
238
|
return;
|
|
56
239
|
}
|
|
57
|
-
const client = createClient(
|
|
240
|
+
const client = createClient(token);
|
|
58
241
|
// Validate token
|
|
59
242
|
let viewer;
|
|
60
243
|
try {
|
|
@@ -75,7 +258,7 @@ async function runNonInteractive(flags, options) {
|
|
|
75
258
|
const projects = await listUserProjects(client);
|
|
76
259
|
let project;
|
|
77
260
|
if (flags.project) {
|
|
78
|
-
const match = projects.find((
|
|
261
|
+
const match = projects.find((proj) => proj.id === flags.project || proj.url === flags.project);
|
|
79
262
|
if (!match) {
|
|
80
263
|
process.stderr.write(`Error: Project not found: ${flags.project}\n`);
|
|
81
264
|
process.exitCode = 1;
|
|
@@ -100,92 +283,179 @@ async function runNonInteractive(flags, options) {
|
|
|
100
283
|
return;
|
|
101
284
|
}
|
|
102
285
|
const columnNames = statusField.options.map((o) => o.name);
|
|
103
|
-
const inferred =
|
|
104
|
-
const
|
|
286
|
+
const inferred = inferAllStateRoles(columnNames);
|
|
287
|
+
const mappings = {};
|
|
105
288
|
for (const mapping of inferred) {
|
|
106
289
|
if (mapping.role) {
|
|
107
|
-
|
|
290
|
+
mappings[mapping.columnName] = { role: mapping.role };
|
|
108
291
|
}
|
|
109
292
|
}
|
|
110
|
-
const validation =
|
|
293
|
+
const validation = validateStateMapping(mappings);
|
|
111
294
|
if (!validation.valid) {
|
|
112
295
|
process.stderr.write(`Error: Cannot auto-map columns. ${validation.errors.join("; ")}\nRun without --non-interactive for manual mapping.\n`);
|
|
113
296
|
process.exitCode = 1;
|
|
114
297
|
return;
|
|
115
298
|
}
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
299
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
|
|
300
|
+
const outputPath = resolve(flags.output ?? "WORKFLOW.md");
|
|
301
|
+
const workflowMd = generateWorkflowMarkdown({
|
|
302
|
+
projectId: project.id,
|
|
303
|
+
stateFieldName: statusField.name,
|
|
304
|
+
mappings,
|
|
305
|
+
lifecycle: lifecycleConfig,
|
|
306
|
+
runtime: "codex",
|
|
307
|
+
});
|
|
308
|
+
await writeFile(outputPath, workflowMd, "utf8");
|
|
309
|
+
const ecosystemResult = await writeEcosystem({
|
|
310
|
+
cwd: process.cwd(),
|
|
311
|
+
projectDetail: project,
|
|
123
312
|
statusField,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
313
|
+
runtime: "codex",
|
|
314
|
+
skipSkills: flags.skipSkills,
|
|
315
|
+
skipContext: flags.skipContext,
|
|
127
316
|
});
|
|
128
317
|
if (options.json) {
|
|
129
|
-
process.stdout.write(JSON.stringify({
|
|
318
|
+
process.stdout.write(JSON.stringify({ output: outputPath, status: "created" }) + "\n");
|
|
130
319
|
}
|
|
131
320
|
else {
|
|
132
|
-
|
|
133
|
-
|
|
321
|
+
printEcosystemSummary(ecosystemResult, outputPath, {
|
|
322
|
+
interactive: false,
|
|
323
|
+
nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
|
|
324
|
+
});
|
|
134
325
|
}
|
|
135
326
|
}
|
|
136
|
-
// ── Interactive mode
|
|
327
|
+
// ── Interactive mode: WORKFLOW.md generation ─────────────────────────────────
|
|
137
328
|
async function runInteractive(options) {
|
|
138
|
-
p.intro("gh-symphony —
|
|
139
|
-
//
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
329
|
+
p.intro("gh-symphony — WORKFLOW.md Setup");
|
|
330
|
+
// Case A: tenant(s) already configured
|
|
331
|
+
const globalConfig = await loadGlobalConfig(options.configDir);
|
|
332
|
+
if (globalConfig?.tenants?.length) {
|
|
333
|
+
await runInteractiveFromTenant(globalConfig, options);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Case B: no tenants — standalone WORKFLOW.md generation
|
|
337
|
+
await runInteractiveStandalone(options);
|
|
338
|
+
}
|
|
339
|
+
// ── Case A: Generate WORKFLOW.md from existing tenant config ─────────────────
|
|
340
|
+
async function runInteractiveFromTenant(globalConfig, options) {
|
|
341
|
+
const tenants = globalConfig.tenants;
|
|
342
|
+
let tenantId;
|
|
343
|
+
if (tenants.length === 1) {
|
|
344
|
+
tenantId = tenants[0];
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
// Multiple tenants: ask which one to base WORKFLOW.md on
|
|
348
|
+
const tenantConfigs = await Promise.all(tenants.map(async (id) => {
|
|
349
|
+
const cfg = await loadTenantConfig(options.configDir, id);
|
|
350
|
+
return { id, label: cfg?.slug ?? id };
|
|
148
351
|
}));
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
352
|
+
tenantId = await abortIfCancelled(p.select({
|
|
353
|
+
message: "Select a tenant to base WORKFLOW.md on:",
|
|
354
|
+
options: tenantConfigs.map((t) => ({
|
|
355
|
+
value: t.id,
|
|
356
|
+
label: t.label,
|
|
357
|
+
hint: globalConfig.activeTenant === t.id ? "active" : undefined,
|
|
358
|
+
})),
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
const tenantConfig = await loadTenantConfig(options.configDir, tenantId);
|
|
362
|
+
if (!tenantConfig) {
|
|
363
|
+
p.log.error(`Tenant config not found for "${tenantId}".`);
|
|
364
|
+
process.exitCode = 1;
|
|
365
|
+
return;
|
|
153
366
|
}
|
|
154
|
-
|
|
367
|
+
const lifecycle = tenantConfig.workflowMapping?.lifecycle;
|
|
368
|
+
if (!lifecycle) {
|
|
369
|
+
p.log.error(`Tenant "${tenantId}" has no workflow lifecycle config. Run 'gh-symphony tenant add' first.`);
|
|
370
|
+
process.exitCode = 1;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const mappings = {};
|
|
374
|
+
const workflowMapping = tenantConfig.workflowMapping;
|
|
375
|
+
if (workflowMapping) {
|
|
376
|
+
Object.assign(mappings, workflowMapping.mappings);
|
|
377
|
+
}
|
|
378
|
+
const projectId = tenantConfig.tracker.settings?.projectId;
|
|
379
|
+
const stateFieldName = workflowMapping?.stateFieldName ?? lifecycle.stateFieldName;
|
|
380
|
+
const runtime = await resolveTenantRuntime(options.configDir, tenantId, tenantConfig.runtime.workerCommand);
|
|
381
|
+
const workflowMd = generateWorkflowMarkdown({
|
|
382
|
+
projectId: projectId ?? "",
|
|
383
|
+
stateFieldName,
|
|
384
|
+
mappings,
|
|
385
|
+
lifecycle,
|
|
386
|
+
runtime,
|
|
387
|
+
});
|
|
388
|
+
const outputPath = resolve("WORKFLOW.md");
|
|
389
|
+
await writeFile(outputPath, workflowMd, "utf8");
|
|
390
|
+
const projId = tenantConfig.tracker.settings?.projectId;
|
|
391
|
+
let ecosystemResult = null;
|
|
155
392
|
let token;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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...");
|
|
393
|
+
try {
|
|
394
|
+
token = getGhToken();
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// getGhToken failed — token stays undefined; ecosystem write proceeds best-effort
|
|
398
|
+
}
|
|
399
|
+
if (token && projId) {
|
|
171
400
|
try {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
401
|
+
const client = createClient(token);
|
|
402
|
+
const fullProject = await getProjectDetail(client, projId);
|
|
403
|
+
const sf = fullProject.statusFields.find((f) => f.name.toLowerCase() === stateFieldName.toLowerCase()) ?? fullProject.statusFields[0];
|
|
404
|
+
if (sf) {
|
|
405
|
+
ecosystemResult = await writeEcosystem({
|
|
406
|
+
cwd: process.cwd(),
|
|
407
|
+
projectDetail: fullProject,
|
|
408
|
+
statusField: sf,
|
|
409
|
+
runtime,
|
|
410
|
+
skipSkills: false,
|
|
411
|
+
skipContext: false,
|
|
412
|
+
});
|
|
178
413
|
}
|
|
179
|
-
s.stop(`Authenticated as ${viewer.login}${viewer.name ? ` (${viewer.name})` : ""}`);
|
|
180
|
-
token = rawToken;
|
|
181
|
-
break;
|
|
182
414
|
}
|
|
183
|
-
catch
|
|
184
|
-
|
|
185
|
-
p.log.warn("Please try a different token.");
|
|
415
|
+
catch {
|
|
416
|
+
// best-effort: don't fail init if GitHub API is unreachable
|
|
186
417
|
}
|
|
187
418
|
}
|
|
188
|
-
|
|
419
|
+
if (ecosystemResult) {
|
|
420
|
+
printEcosystemSummary(ecosystemResult, outputPath, { interactive: true });
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
p.outro(`WORKFLOW.md generated at ${outputPath}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// ── Case B: Standalone WORKFLOW.md generation (no tenant) ────────────────────
|
|
427
|
+
async function runInteractiveStandalone(_options) {
|
|
428
|
+
const s1 = p.spinner();
|
|
429
|
+
s1.start("Checking gh CLI authentication...");
|
|
430
|
+
let client;
|
|
431
|
+
try {
|
|
432
|
+
const { token } = ensureGhAuth();
|
|
433
|
+
client = createClient(token);
|
|
434
|
+
s1.stop("Authenticated via gh CLI");
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
s1.stop("Authentication failed.");
|
|
438
|
+
if (error instanceof GhAuthError) {
|
|
439
|
+
if (error.code === "not_installed") {
|
|
440
|
+
p.log.error("gh CLI가 설치되어 있지 않습니다. https://cli.github.com 에서 설치하세요.");
|
|
441
|
+
}
|
|
442
|
+
else if (error.code === "not_authenticated") {
|
|
443
|
+
p.log.error("gh auth login --scopes repo,read:org,project 를 실행하세요.");
|
|
444
|
+
}
|
|
445
|
+
else if (error.code === "missing_scopes") {
|
|
446
|
+
p.log.error("gh auth refresh --scopes repo,read:org,project 를 실행하세요.");
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
p.log.error(error.message);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
454
|
+
}
|
|
455
|
+
process.exitCode = 1;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Step 1/2: Project selection
|
|
189
459
|
const s2 = p.spinner();
|
|
190
460
|
s2.start("Loading projects...");
|
|
191
461
|
let projects;
|
|
@@ -195,17 +465,22 @@ async function runInteractive(options) {
|
|
|
195
465
|
}
|
|
196
466
|
catch (error) {
|
|
197
467
|
s2.stop("Failed to load projects.");
|
|
198
|
-
|
|
468
|
+
if (error instanceof GitHubScopeError) {
|
|
469
|
+
displayScopeError(error, "gh-symphony init");
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
473
|
+
}
|
|
199
474
|
process.exitCode = 1;
|
|
200
475
|
return;
|
|
201
476
|
}
|
|
202
477
|
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
|
|
478
|
+
p.log.error("No GitHub Projects found. Create a project at https://github.com/orgs/YOUR_ORG/projects and re-run.");
|
|
204
479
|
process.exitCode = 1;
|
|
205
480
|
return;
|
|
206
481
|
}
|
|
207
482
|
const selectedProjectId = await abortIfCancelled(p.select({
|
|
208
|
-
message: "Step 2
|
|
483
|
+
message: "Step 1/2 — Select a GitHub Project:",
|
|
209
484
|
options: projects.map((proj) => ({
|
|
210
485
|
value: proj.id,
|
|
211
486
|
label: `${proj.owner.login}/${proj.title}`,
|
|
@@ -226,21 +501,7 @@ async function runInteractive(options) {
|
|
|
226
501
|
process.exitCode = 1;
|
|
227
502
|
return;
|
|
228
503
|
}
|
|
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) ────────────────────────────────────
|
|
504
|
+
// Step 3: Status column mapping
|
|
244
505
|
const statusField = projectDetail.statusFields.find((f) => f.name.toLowerCase() === "status") ??
|
|
245
506
|
projectDetail.statusFields[0];
|
|
246
507
|
if (!statusField) {
|
|
@@ -249,33 +510,29 @@ async function runInteractive(options) {
|
|
|
249
510
|
return;
|
|
250
511
|
}
|
|
251
512
|
const columnNames = statusField.options.map((o) => o.name);
|
|
252
|
-
const inferred =
|
|
513
|
+
const inferred = inferAllStateRoles(columnNames);
|
|
253
514
|
p.log.info(`Found ${columnNames.length} status columns on field "${statusField.name}".`);
|
|
254
|
-
|
|
255
|
-
const roles = {};
|
|
515
|
+
const mappings = {};
|
|
256
516
|
for (const mapping of inferred) {
|
|
257
517
|
const roleOptions = [
|
|
258
|
-
{ value: "
|
|
259
|
-
{ value: "
|
|
260
|
-
{ value: "
|
|
261
|
-
{ value: "done", label: "Done (completed)" },
|
|
262
|
-
{ value: "ignored", label: "Ignored (skip)" },
|
|
518
|
+
{ value: "active", label: "Active (agent works on this)" },
|
|
519
|
+
{ value: "wait", label: "Wait (human review / hold)" },
|
|
520
|
+
{ value: "terminal", label: "Terminal (completed)" },
|
|
263
521
|
];
|
|
264
|
-
const defaultRole = mapping.role ?? "
|
|
265
|
-
// Put default first
|
|
522
|
+
const defaultRole = mapping.role ?? "wait";
|
|
266
523
|
const sortedOptions = [
|
|
267
524
|
roleOptions.find((o) => o.value === defaultRole),
|
|
268
525
|
...roleOptions.filter((o) => o.value !== defaultRole),
|
|
269
526
|
];
|
|
270
527
|
const selectedRole = await abortIfCancelled(p.select({
|
|
271
|
-
message: `Step
|
|
528
|
+
message: `Step 2/2 — Map column "${mapping.columnName}":${mapping.confidence === "high" ? " (auto-detected)" : ""}`,
|
|
272
529
|
options: sortedOptions,
|
|
273
530
|
}));
|
|
274
531
|
if (selectedRole !== "skip") {
|
|
275
|
-
|
|
532
|
+
mappings[mapping.columnName] = { role: selectedRole };
|
|
276
533
|
}
|
|
277
534
|
}
|
|
278
|
-
const validation =
|
|
535
|
+
const validation = validateStateMapping(mappings);
|
|
279
536
|
if (!validation.valid) {
|
|
280
537
|
p.log.error("Mapping validation failed:");
|
|
281
538
|
for (const err of validation.errors) {
|
|
@@ -287,170 +544,53 @@ async function runInteractive(options) {
|
|
|
287
544
|
for (const warn of validation.warnings) {
|
|
288
545
|
p.log.warn(` ⚠ ${warn}`);
|
|
289
546
|
}
|
|
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...");
|
|
547
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(statusField.name, mappings);
|
|
548
|
+
// Generate WORKFLOW.md only — no config files written
|
|
549
|
+
const workflowMd = generateWorkflowMarkdown({
|
|
550
|
+
projectId: projectDetail.id,
|
|
551
|
+
stateFieldName: statusField.name,
|
|
552
|
+
mappings,
|
|
553
|
+
lifecycle: lifecycleConfig,
|
|
554
|
+
runtime: "codex",
|
|
555
|
+
});
|
|
556
|
+
const outputPath = resolve("WORKFLOW.md");
|
|
557
|
+
await writeFile(outputPath, workflowMd, "utf8");
|
|
558
|
+
const ecosystemResult = await writeEcosystem({
|
|
559
|
+
cwd: process.cwd(),
|
|
560
|
+
projectDetail,
|
|
561
|
+
statusField,
|
|
562
|
+
runtime: "codex",
|
|
563
|
+
skipSkills: false,
|
|
564
|
+
skipContext: false,
|
|
565
|
+
});
|
|
566
|
+
printEcosystemSummary(ecosystemResult, outputPath, {
|
|
567
|
+
interactive: true,
|
|
568
|
+
nextSteps: "Run 'gh-symphony tenant add' to register a tenant.",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function resolveWorkerCommand() {
|
|
410
572
|
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.");
|
|
573
|
+
const url = import.meta.resolve("@gh-symphony/worker/dist/index.js");
|
|
574
|
+
return `node ${fileURLToPath(url)}`;
|
|
429
575
|
}
|
|
430
|
-
catch
|
|
431
|
-
|
|
432
|
-
p.log.error(error instanceof Error ? error.message : "Unknown error");
|
|
433
|
-
process.exitCode = 1;
|
|
434
|
-
return;
|
|
576
|
+
catch {
|
|
577
|
+
return undefined;
|
|
435
578
|
}
|
|
436
|
-
p.outro(`Workspace "${workspaceId}" created!\n Run 'gh-symphony start' to begin orchestration.`);
|
|
437
579
|
}
|
|
438
580
|
export async function writeConfig(configDir, input) {
|
|
439
|
-
const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.
|
|
581
|
+
const lifecycleConfig = toWorkflowLifecycleConfig(input.statusField.name, input.mappings);
|
|
440
582
|
// Save workflow mapping
|
|
441
583
|
const mappingConfig = {
|
|
442
584
|
stateFieldName: input.statusField.name,
|
|
443
|
-
|
|
444
|
-
humanReviewMode: input.humanReviewMode,
|
|
585
|
+
mappings: input.mappings,
|
|
445
586
|
lifecycle: lifecycleConfig,
|
|
446
587
|
};
|
|
447
|
-
await saveWorkflowMapping(configDir, input.
|
|
448
|
-
// Save
|
|
449
|
-
const runtimeDir = `${configDir}/
|
|
450
|
-
await
|
|
451
|
-
|
|
452
|
-
slug: input.
|
|
453
|
-
promptGuidelines: "",
|
|
588
|
+
await saveWorkflowMapping(configDir, input.tenantId, mappingConfig);
|
|
589
|
+
// Save tenant config (OrchestratorTenantConfig shape)
|
|
590
|
+
const runtimeDir = `${configDir}/tenants/${input.tenantId}/runtime`;
|
|
591
|
+
await saveTenantConfig(configDir, input.tenantId, {
|
|
592
|
+
tenantId: input.tenantId,
|
|
593
|
+
slug: input.tenantId,
|
|
454
594
|
repositories: input.repos.map((r) => ({
|
|
455
595
|
owner: r.owner,
|
|
456
596
|
name: r.name,
|
|
@@ -461,48 +601,45 @@ export async function writeConfig(configDir, input) {
|
|
|
461
601
|
bindingId: input.project.id,
|
|
462
602
|
settings: {
|
|
463
603
|
projectId: input.project.id,
|
|
464
|
-
token: input.token,
|
|
465
604
|
},
|
|
466
605
|
},
|
|
467
606
|
runtime: {
|
|
468
607
|
driver: "local",
|
|
469
608
|
workspaceRuntimeDir: runtimeDir,
|
|
470
609
|
projectRoot: process.cwd(),
|
|
471
|
-
workerCommand: input.workerCommand,
|
|
472
|
-
},
|
|
473
|
-
workflow: buildWorkflowOverrides(lifecycleConfig, input),
|
|
474
|
-
orchestrator: {
|
|
475
|
-
concurrency: input.concurrency,
|
|
476
|
-
maxAttempts: input.maxAttempts,
|
|
610
|
+
workerCommand: input.workerCommand ?? resolveWorkerCommand(),
|
|
477
611
|
},
|
|
478
612
|
workflowMapping: mappingConfig,
|
|
479
613
|
});
|
|
480
614
|
// Save/update global config
|
|
481
615
|
const existing = await loadGlobalConfig(configDir);
|
|
482
616
|
const globalConfig = {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
input.workspaceId,
|
|
617
|
+
activeTenant: input.tenantId,
|
|
618
|
+
tenants: [
|
|
619
|
+
...(existing?.tenants ?? []).filter((t) => t !== input.tenantId),
|
|
620
|
+
input.tenantId,
|
|
488
621
|
],
|
|
489
622
|
};
|
|
490
623
|
await saveGlobalConfig(configDir, globalConfig);
|
|
624
|
+
// Generate WORKFLOW.md for tenant-level fallback
|
|
625
|
+
const workflowMd = generateWorkflowMarkdown({
|
|
626
|
+
projectId: input.project.id,
|
|
627
|
+
stateFieldName: input.statusField.name,
|
|
628
|
+
mappings: input.mappings,
|
|
629
|
+
lifecycle: lifecycleConfig,
|
|
630
|
+
runtime: input.agentCommand ?? input.runtime,
|
|
631
|
+
pollIntervalMs: input.pollIntervalMs,
|
|
632
|
+
concurrency: input.concurrency,
|
|
633
|
+
});
|
|
634
|
+
const workflowMdPath = join(configDir, "tenants", input.tenantId, "WORKFLOW.md");
|
|
635
|
+
await writeFile(workflowMdPath, workflowMd, "utf8");
|
|
491
636
|
}
|
|
492
|
-
function
|
|
493
|
-
return {
|
|
494
|
-
lifecycle,
|
|
495
|
-
scheduler: {
|
|
496
|
-
pollIntervalMs: input.pollIntervalMs ?? 30_000,
|
|
497
|
-
},
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
export function generateWorkspaceId(projectTitle, uniqueKey) {
|
|
637
|
+
export function generateTenantId(projectTitle, uniqueKey) {
|
|
501
638
|
const slug = projectTitle
|
|
502
639
|
.toLowerCase()
|
|
503
640
|
.replace(/[^a-z0-9]+/g, "-")
|
|
504
641
|
.replace(/^-|-$/g, "")
|
|
505
642
|
.slice(0, 32);
|
|
506
643
|
const suffix = createHash("sha1").update(uniqueKey).digest("hex").slice(0, 8);
|
|
507
|
-
return [slug || "
|
|
644
|
+
return [slug || "tenant", suffix].join("-");
|
|
508
645
|
}
|