@uxmaltech/collab-cli 0.1.9 → 0.1.10

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/README.md CHANGED
@@ -97,7 +97,7 @@ collab init --resume # resume from last failed stage
97
97
  | **Description** | Agents read `.md` files directly | Agents query NebulaGraph + Qdrant via MCP |
98
98
  | **Docker** | Not required | Required (Qdrant, NebulaGraph, MCP server) |
99
99
  | **MCP** | No | Yes — endpoint `http://127.0.0.1:7337/mcp` |
100
- | **Wizard stages** | 8 | 14 |
100
+ | **Wizard stages** | 8 | 15 |
101
101
  | **Use case** | Small projects, no Docker, quick start | Multi-repo ecosystems, large canons |
102
102
 
103
103
  **Transition heuristic:** Consider indexed mode when the canon exceeds ~50,000 tokens (~375 files).
@@ -114,6 +114,7 @@ collab init --resume # resume from last failed stage
114
114
  | `collab up` | Full startup pipeline (infra → MCP) |
115
115
  | `collab seed` | Preflight check for infrastructure before seeding |
116
116
  | `collab doctor` | System diagnostics: config, health, and versions |
117
+ | `collab end` | Finalize work: create PR with governance references and canon sync |
117
118
  | `collab update-canons` | Download/update canon from GitHub |
118
119
 
119
120
  ## Global options
@@ -152,27 +153,29 @@ collab init --resume # resume from last failed stage
152
153
  7. CI setup (GitHub Actions templates)
153
154
  8. Agent skills setup (skills and prompts registration)
154
155
 
155
- ### Indexed (14 stages)
156
+ ### Indexed (15 stages)
156
157
 
157
158
  **Phase A — Local setup (stages 1-8):** Same as file-only, but repo analysis uses AI.
158
159
 
159
- **Phase B — Infrastructure (stages 9-11):**
160
+ **Phase B — Infrastructure (stages 9-12):**
160
161
 
161
162
  9. Compose generation (docker-compose.yml or split files)
162
163
  10. Infra startup (Qdrant + NebulaGraph via Docker)
163
164
  11. MCP startup (MCP service + health checks)
165
+ 12. GitHub setup (branch model, protections, CI workflows)
164
166
 
165
- **Phase C — Ingestion (stages 12-14):**
167
+ **Phase C — Ingestion (stages 13-15):**
166
168
 
167
- 12. MCP client config (provider snippets)
168
- 13. Graph seeding (initialize graph with architecture data)
169
- 14. Canon ingest (ingest collab-architecture into Qdrant/Nebula)
169
+ 13. MCP client config (provider snippets)
170
+ 14. Graph seeding (initialize graph with architecture data)
171
+ 15. Canon ingest (ingest collab-architecture into Qdrant/Nebula)
170
172
 
171
173
  **Useful flags:**
172
174
  - `--resume` — resume from last incomplete stage
173
175
  - `--force` — overwrite existing config
174
176
  - `--skip-analysis` — skip code analysis
175
177
  - `--skip-ci` — skip CI generation
178
+ - `--skip-github-setup` — skip GitHub branch model and workflow configuration
176
179
  - `--providers codex,claude` — specify providers
177
180
 
178
181
  ## Workspace mode
@@ -185,6 +188,22 @@ collab init --repos repo-a,repo-b,repo-c
185
188
 
186
189
  When run from a directory containing multiple repos, the wizard presents repository selection interactively.
187
190
 
191
+ ## Finalizing work (`collab end`)
192
+
193
+ Create a pull request with governance references and optional canon sync:
194
+
195
+ ```bash
196
+ collab end # create PR from current branch to development
197
+ collab end --dry-run # preview PR without creating it
198
+ collab end --title "feat: add login" # override PR title
199
+ collab end --base development # specify target branch (default: development)
200
+ collab end --skip-canon-sync # skip canon sync PR generation
201
+ ```
202
+
203
+ **Context detection:** Automatically parses issue numbers from branch names (e.g., `feature/42-add-login` links to issue #42). In indexed mode, the PR body includes GOV-R-001 phase checklist and governance references.
204
+
205
+ **Canon sync (indexed mode):** When architecture changes are detected (`docs/architecture/`), a separate PR is created in the business-canon repo to complete Phase 5 (Canon Sync) of GOV-R-001.
206
+
188
207
  ## Local development
189
208
 
190
209
  | Script | Description |
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseIssueFromBranch = parseIssueFromBranch;
7
+ exports.registerEndCommand = registerEndCommand;
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const command_context_1 = require("../lib/command-context");
12
+ const errors_1 = require("../lib/errors");
13
+ const github_api_1 = require("../lib/github-api");
14
+ // ────────────────────────────────────────────────────────────────
15
+ // Context detection helpers
16
+ // ────────────────────────────────────────────────────────────────
17
+ /**
18
+ * Parses an issue number from a branch name following the convention:
19
+ * `feature/42-add-login`, `fix/88-align-flow`, `refactor/10-cleanup`, etc.
20
+ */
21
+ function parseIssueFromBranch(branch) {
22
+ const match = branch.match(/^(?:feature|fix|refactor|chore|docs|test)\/(\d+)/);
23
+ return match ? parseInt(match[1], 10) : null;
24
+ }
25
+ function getCurrentBranch(cwd) {
26
+ return (0, node_child_process_1.execFileSync)('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
27
+ cwd,
28
+ encoding: 'utf8',
29
+ stdio: ['ignore', 'pipe', 'ignore'],
30
+ }).trim();
31
+ }
32
+ function getCommitLog(cwd, base) {
33
+ try {
34
+ return (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `${base}..HEAD`], {
35
+ cwd,
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ }).trim();
39
+ }
40
+ catch {
41
+ return '';
42
+ }
43
+ }
44
+ function hasGhCli() {
45
+ try {
46
+ (0, node_child_process_1.execFileSync)('gh', ['--version'], {
47
+ encoding: 'utf8',
48
+ stdio: ['ignore', 'pipe', 'ignore'],
49
+ });
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ // ────────────────────────────────────────────────────────────────
57
+ // PR body generation
58
+ // ────────────────────────────────────────────────────────────────
59
+ function buildPrBody(opts) {
60
+ const lines = ['## Summary', ''];
61
+ if (opts.issueNumber) {
62
+ lines.push(`Resolves #${opts.issueNumber}`, '');
63
+ }
64
+ if (opts.isIndexed) {
65
+ lines.push('## Governance', '');
66
+ if (opts.issueNumber) {
67
+ lines.push(`- **Issue**: #${opts.issueNumber}`);
68
+ }
69
+ lines.push('- **Phase**: Implementation (GOV-R-002)', '');
70
+ lines.push('## GOV-R-001 Phase Checklist', '');
71
+ lines.push('- [x] Phase 1: Epic Definition');
72
+ lines.push('- [x] Phase 2: User Story Decomposition');
73
+ lines.push('- [x] Phase 3: Sub-issue Assignment');
74
+ lines.push('- [x] Phase 4: Implementation');
75
+ lines.push('- [ ] Phase 5: Canon Sync');
76
+ lines.push('');
77
+ }
78
+ if (opts.commitLog) {
79
+ lines.push('## Changes', '', '```', opts.commitLog, '```', '');
80
+ }
81
+ return lines.join('\n');
82
+ }
83
+ // ────────────────────────────────────────────────────────────────
84
+ // Canon sync PR generation (#94)
85
+ // ────────────────────────────────────────────────────────────────
86
+ function createCanonSyncPr(opts) {
87
+ const { cwd, canonSlug, repoSlug, issueNumber, dryRun, logger } = opts;
88
+ // Check if there are architecture changes
89
+ const archDir = node_path_1.default.join(cwd, 'docs', 'architecture');
90
+ if (!node_fs_1.default.existsSync(archDir)) {
91
+ logger.info('No docs/architecture directory found; skipping canon sync PR.');
92
+ return;
93
+ }
94
+ // Check for changes in architecture dir relative to base
95
+ let archChanges;
96
+ try {
97
+ archChanges = (0, node_child_process_1.execFileSync)('git', ['diff', '--name-only', 'development..HEAD', '--', 'docs/architecture/'], {
98
+ cwd,
99
+ encoding: 'utf8',
100
+ stdio: ['ignore', 'pipe', 'ignore'],
101
+ }).trim();
102
+ }
103
+ catch {
104
+ archChanges = '';
105
+ }
106
+ if (!archChanges) {
107
+ logger.info('No architecture changes detected; skipping canon sync PR.');
108
+ return;
109
+ }
110
+ const changedFiles = archChanges.split('\n').filter(Boolean);
111
+ const syncBranch = `canon-sync/${repoSlug.split('/')[1]}${issueNumber ? `-${issueNumber}` : ''}`;
112
+ const body = [
113
+ '## Canon Sync',
114
+ '',
115
+ `Source: ${repoSlug}${issueNumber ? `#${issueNumber}` : ''}`,
116
+ '',
117
+ '### Updated files',
118
+ '',
119
+ ...changedFiles.map((f) => `- \`${f}\``),
120
+ '',
121
+ '### Governance',
122
+ '',
123
+ `This PR completes Phase 5 (Canon Sync) of GOV-R-001.`,
124
+ '',
125
+ ].join('\n');
126
+ if (dryRun) {
127
+ logger.info(`[dry-run] Would create canon sync PR in ${canonSlug}:`);
128
+ logger.info(` Branch: ${syncBranch}`);
129
+ logger.info(` Changed files: ${changedFiles.length}`);
130
+ return;
131
+ }
132
+ // Create canon sync PR via gh CLI
133
+ try {
134
+ (0, node_child_process_1.execFileSync)('gh', [
135
+ 'pr', 'create',
136
+ '-R', canonSlug,
137
+ '--base', 'development',
138
+ '--head', syncBranch,
139
+ '--title', `Canon sync — ${repoSlug.split('/')[1]}${issueNumber ? ` #${issueNumber}` : ''}`,
140
+ '--body', body,
141
+ ], {
142
+ cwd,
143
+ encoding: 'utf8',
144
+ stdio: ['ignore', 'pipe', 'pipe'],
145
+ });
146
+ logger.info(`Canon sync PR created in ${canonSlug}.`);
147
+ }
148
+ catch {
149
+ logger.warn(`Could not create canon sync PR in ${canonSlug}.\n` +
150
+ `Create it manually with: gh pr create -R ${canonSlug} --base development`);
151
+ }
152
+ }
153
+ // ────────────────────────────────────────────────────────────────
154
+ // Command registration
155
+ // ────────────────────────────────────────────────────────────────
156
+ function registerEndCommand(program) {
157
+ program
158
+ .command('end')
159
+ .description('Finalize current work: create PR with governance references')
160
+ .option('--dry-run', 'Show what would be done without executing')
161
+ .option('--skip-canon-sync', 'Skip canon sync PR generation')
162
+ .option('--title <title>', 'Override PR title')
163
+ .option('--base <branch>', 'Target branch (default: development)')
164
+ .addHelpText('after', `
165
+ Examples:
166
+ collab end
167
+ collab end --dry-run
168
+ collab end --title "feat: add login page" --base development
169
+ collab end --skip-canon-sync
170
+ `)
171
+ .action((options, command) => {
172
+ const context = (0, command_context_1.createCommandContext)(command);
173
+ const cwd = context.config.workspaceDir;
174
+ const base = options.base ?? 'development';
175
+ // Validate: collab workspace exists
176
+ if (!node_fs_1.default.existsSync(context.config.configFile)) {
177
+ throw new errors_1.CliError('Not in a collab workspace. Run collab init first.');
178
+ }
179
+ // Validate: gh CLI available
180
+ if (!hasGhCli()) {
181
+ throw new errors_1.CliError('GitHub CLI (gh) is required for collab end.\n' +
182
+ 'Install it: https://cli.github.com/');
183
+ }
184
+ // Detect context
185
+ const branch = getCurrentBranch(cwd);
186
+ if (branch === 'development' || branch === 'main') {
187
+ throw new errors_1.CliError(`Cannot create PR from "${branch}". Switch to a feature branch first.\n` +
188
+ 'Example: git checkout -b feature/42-description');
189
+ }
190
+ const commitLog = getCommitLog(cwd, base);
191
+ if (!commitLog) {
192
+ throw new errors_1.CliError(`No commits ahead of "${base}". Nothing to create a PR for.`);
193
+ }
194
+ const issueNumber = parseIssueFromBranch(branch);
195
+ const identity = (0, github_api_1.resolveGitHubOwnerRepo)(cwd);
196
+ const canonSlug = context.config.canons?.business?.repo;
197
+ const isIndexed = context.config.mode === 'indexed';
198
+ // Build PR title
199
+ const prTitle = options.title ?? (issueNumber
200
+ ? `${branch.replace(/^(feature|fix|refactor|chore|docs|test)\/\d+-?/, '').replace(/-/g, ' ').trim() || `Resolve #${issueNumber}`}`
201
+ : branch.replace(/^(feature|fix|refactor|chore|docs|test)\//, '').replace(/-/g, ' ').trim());
202
+ // Build PR body
203
+ const prBody = buildPrBody({
204
+ issueNumber,
205
+ commitLog,
206
+ canonSlug,
207
+ isIndexed,
208
+ });
209
+ if (options.dryRun) {
210
+ context.logger.info(`[dry-run] Would create PR:`);
211
+ context.logger.info(` Repo: ${identity?.slug ?? cwd}`);
212
+ context.logger.info(` Branch: ${branch} → ${base}`);
213
+ context.logger.info(` Title: ${prTitle}`);
214
+ if (issueNumber)
215
+ context.logger.info(` Issue: #${issueNumber}`);
216
+ context.logger.info('');
217
+ context.logger.info('PR body:');
218
+ context.logger.info(prBody);
219
+ if (!options.skipCanonSync && isIndexed && canonSlug) {
220
+ createCanonSyncPr({
221
+ cwd,
222
+ canonSlug,
223
+ repoSlug: identity?.slug ?? '',
224
+ issueNumber,
225
+ branch,
226
+ dryRun: true,
227
+ logger: context.logger,
228
+ });
229
+ }
230
+ return;
231
+ }
232
+ // Create implementation PR
233
+ context.logger.info(`Creating PR: ${branch} → ${base}...`);
234
+ let prResult;
235
+ try {
236
+ prResult = (0, node_child_process_1.execFileSync)('gh', [
237
+ 'pr', 'create',
238
+ '--base', base,
239
+ '--title', prTitle,
240
+ '--body', prBody,
241
+ ], {
242
+ cwd,
243
+ encoding: 'utf8',
244
+ stdio: ['ignore', 'pipe', 'pipe'],
245
+ }).trim();
246
+ }
247
+ catch {
248
+ throw new errors_1.CliError(`Failed to create PR. Ensure you are authenticated with gh CLI.\n` +
249
+ `Run: gh auth login`);
250
+ }
251
+ context.logger.info(`PR created: ${prResult}`);
252
+ // Canon sync PR (Phase 5)
253
+ if (!options.skipCanonSync && isIndexed && canonSlug) {
254
+ createCanonSyncPr({
255
+ cwd,
256
+ canonSlug,
257
+ repoSlug: identity?.slug ?? '',
258
+ issueNumber,
259
+ branch,
260
+ dryRun: false,
261
+ logger: context.logger,
262
+ });
263
+ }
264
+ });
265
+ }
@@ -4,6 +4,7 @@ exports.registerCommands = registerCommands;
4
4
  const canon_1 = require("./canon");
5
5
  const compose_1 = require("./compose");
6
6
  const doctor_1 = require("./doctor");
7
+ const end_1 = require("./end");
7
8
  const infra_1 = require("./infra");
8
9
  const init_1 = require("./init");
9
10
  const mcp_1 = require("./mcp");
@@ -14,6 +15,7 @@ const update_canons_1 = require("./update-canons");
14
15
  const upgrade_1 = require("./upgrade");
15
16
  function registerCommands(program) {
16
17
  (0, init_1.registerInitCommand)(program);
18
+ (0, end_1.registerEndCommand)(program);
17
19
  (0, canon_1.registerCanonCommand)(program);
18
20
  (0, compose_1.registerComposeCommand)(program);
19
21
  (0, infra_1.registerInfraCommand)(program);
@@ -19,6 +19,7 @@ const mcp_contract_1 = require("../lib/mcp-contract");
19
19
  const mode_1 = require("../lib/mode");
20
20
  const parsers_1 = require("../lib/parsers");
21
21
  const orchestrator_1 = require("../lib/orchestrator");
22
+ const github_api_1 = require("../lib/github-api");
22
23
  const github_auth_1 = require("../lib/github-auth");
23
24
  const prompt_1 = require("../lib/prompt");
24
25
  const preflight_1 = require("../lib/preflight");
@@ -34,6 +35,7 @@ const repo_analysis_1 = require("../stages/repo-analysis");
34
35
  const repo_analysis_fileonly_1 = require("../stages/repo-analysis-fileonly");
35
36
  const agent_skills_setup_1 = require("../stages/agent-skills-setup");
36
37
  const ci_setup_1 = require("../stages/ci-setup");
38
+ const github_setup_1 = require("../stages/github-setup");
37
39
  const domain_gen_1 = require("../stages/domain-gen");
38
40
  const canon_resolver_1 = require("../lib/canon-resolver");
39
41
  const providers_1 = require("../lib/providers");
@@ -192,7 +194,10 @@ function buildGitHubAuthStage(effectiveConfig, logger, options) {
192
194
  'Run collab init --resume after fixing GitHub access.',
193
195
  ],
194
196
  run: async () => {
195
- // Skip if no business canon or local source — GitHub auth only needed for remote repos
197
+ // Skip GitHub auth when no GitHub canon is configured.
198
+ // In indexed mode, a GitHub canon is always required (enforced
199
+ // by parseBusinessCanonOption), so this check only triggers in
200
+ // file-only mode or when preserving an existing config.
196
201
  const canon = effectiveConfig.canons?.business;
197
202
  if (!canon || canon.source === 'local') {
198
203
  logger.info('No GitHub canon configured; skipping GitHub authorization.');
@@ -274,7 +279,15 @@ function resolveLocalCanonPath(rawPath) {
274
279
  }
275
280
  return resolved;
276
281
  }
277
- function parseBusinessCanonOption(value) {
282
+ function parseBusinessCanonOption(value, mode = 'file-only') {
283
+ if (mode === 'indexed') {
284
+ if (!value || value === 'none' || value === 'skip') {
285
+ throw new errors_1.CliError('Business canon is required for indexed mode. Use --business-canon owner/repo.');
286
+ }
287
+ if (LOCAL_PATH_RE.test(value)) {
288
+ throw new errors_1.CliError('Local business canon is not supported in indexed mode. Use --business-canon owner/repo (GitHub).');
289
+ }
290
+ }
278
291
  if (!value || value === 'none' || value === 'skip') {
279
292
  return undefined;
280
293
  }
@@ -305,15 +318,24 @@ function parseBusinessCanonOption(value) {
305
318
  };
306
319
  }
307
320
  async function resolveBusinessCanon(options, config, logger) {
321
+ const isIndexed = config.mode === 'indexed';
308
322
  // CLI flag takes priority
309
323
  if (options.businessCanon) {
310
- return parseBusinessCanonOption(options.businessCanon);
324
+ return parseBusinessCanonOption(options.businessCanon, config.mode);
311
325
  }
312
326
  // --yes without --business-canon: mandatory error
313
327
  if (options.yes) {
328
+ if (isIndexed) {
329
+ throw new errors_1.CliError('--business-canon owner/repo is required with --yes in indexed mode.');
330
+ }
314
331
  throw new errors_1.CliError('--business-canon is required with --yes. Use --business-canon owner/repo, --business-canon /local/path, or --business-canon none.');
315
332
  }
316
- // Interactive: choose source
333
+ // Interactive indexed: go straight to GitHub search (no local/skip options)
334
+ if (isIndexed) {
335
+ logger.info('Indexed mode requires a GitHub business canon.');
336
+ return resolveGitHubBusinessCanon(config, logger);
337
+ }
338
+ // Interactive file-only: choose source
317
339
  const source = await (0, prompt_1.promptChoice)('Business canon source:', [
318
340
  { value: 'github', label: 'GitHub repository (search and select)' },
319
341
  { value: 'local', label: 'Local directory' },
@@ -442,18 +464,26 @@ function parseRepos(value) {
442
464
  return null;
443
465
  return value.split(',').map((r) => r.trim()).filter(Boolean);
444
466
  }
445
- async function resolveWorkspace(workspaceDir, options, logger) {
467
+ async function resolveWorkspace(workspaceDir, options, logger, mode = 'file-only') {
446
468
  const name = (0, config_1.deriveWorkspaceName)(workspaceDir);
469
+ const isIndexed = mode === 'indexed';
447
470
  // Explicit --repos flag takes priority
448
471
  const explicit = parseRepos(options.repos);
449
472
  if (explicit && explicit.length > 0) {
450
- const type = explicit.length >= 2 ? 'multi-repo' : 'mono-repo';
473
+ // In indexed mode, always force multi-repo type
474
+ const type = isIndexed || explicit.length >= 2 ? 'multi-repo' : 'mono-repo';
451
475
  logger.info(`Workspace mode: ${explicit.length} repo(s) specified: ${explicit.join(', ')}`);
452
476
  return { name, type, repos: explicit };
453
477
  }
454
478
  // Auto-detect workspace layout
455
479
  const layout = (0, config_1.detectWorkspaceLayout)(workspaceDir);
456
480
  if (layout) {
481
+ // Indexed mode: reject mono-repo
482
+ if (isIndexed && layout.type === 'mono-repo') {
483
+ throw new errors_1.CliError('Indexed mode requires a multi-repo workspace (business-canon + at least 1 governed repo).\n' +
484
+ 'Current directory is detected as mono-repo. ' +
485
+ 'Run from a parent directory containing multiple git repositories.');
486
+ }
457
487
  if (options.yes) {
458
488
  logger.info(`Workspace auto-detected (${layout.type}): ${layout.repos.length} repo(s) found: ${layout.repos.join(', ')}`);
459
489
  return { name, type: layout.type, repos: layout.repos };
@@ -465,11 +495,16 @@ async function resolveWorkspace(workspaceDir, options, logger) {
465
495
  return null;
466
496
  return { name, type: 'multi-repo', repos: selected };
467
497
  }
468
- // mono-repo auto-detected
498
+ // mono-repo auto-detected (file-only only — indexed rejected above)
469
499
  logger.info(`Mono-repo workspace detected: ${layout.repos.join(', ')}`);
470
500
  return { name, type: 'mono-repo', repos: layout.repos };
471
501
  }
472
502
  // No repos found
503
+ if (isIndexed) {
504
+ throw new errors_1.CliError('Indexed mode requires a multi-repo workspace with at least 1 governed repo.\n' +
505
+ 'No git repositories found in the workspace directory.\n' +
506
+ 'Clone your repos from GitHub and re-run.');
507
+ }
473
508
  if (options.yes) {
474
509
  // Non-interactive with no repos → treat cwd as mono-repo
475
510
  logger.info('No repos discovered; initializing as mono-repo workspace.');
@@ -597,6 +632,7 @@ function buildInfraStages(effectiveConfig, executor, logger, options, composeMod
597
632
  },
598
633
  graph_seed_1.graphSeedStage,
599
634
  canon_ingest_1.canonIngestStage,
635
+ github_setup_1.githubSetupStage,
600
636
  ];
601
637
  }
602
638
  // ────────────────────────────────────────────────────────────────
@@ -661,6 +697,7 @@ function buildRemoteInfraStages(effectiveConfig, executor, logger, options, mcpU
661
697
  },
662
698
  graph_seed_1.graphSeedStage,
663
699
  canon_ingest_1.canonIngestStage,
700
+ github_setup_1.githubSetupStage,
664
701
  ];
665
702
  }
666
703
  // ────────────────────────────────────────────────────────────────
@@ -680,28 +717,6 @@ function buildFileOnlyPipeline(effectiveConfig, executor, logger, configExistedB
680
717
  ];
681
718
  }
682
719
  // ────────────────────────────────────────────────────────────────
683
- // Indexed pipeline (15 stages)
684
- // ────────────────────────────────────────────────────────────────
685
- function buildIndexedPipeline(effectiveConfig, executor, logger, configExistedBefore, options, composeMode, infraType = 'local', mcpUrl) {
686
- const infraStages = infraType === 'remote' && mcpUrl
687
- ? buildRemoteInfraStages(effectiveConfig, executor, logger, options, mcpUrl)
688
- : buildInfraStages(effectiveConfig, executor, logger, options, composeMode);
689
- return [
690
- // Phase A — Local setup (shared with file-only)
691
- buildPreflightStage(executor, logger, infraType === 'local' ? 'indexed' : undefined),
692
- buildConfigStage(effectiveConfig, executor, logger, configExistedBefore, options.force),
693
- buildGitHubAuthStage(effectiveConfig, logger, options),
694
- assistant_setup_1.assistantSetupStage,
695
- canon_sync_1.canonSyncStage,
696
- repo_scaffold_1.repoScaffoldStage,
697
- repo_analysis_1.repoAnalysisStage,
698
- ci_setup_1.ciSetupStage,
699
- agent_skills_setup_1.agentSkillsSetupStage,
700
- // Phase B — Infrastructure + Phase C — Ingestion
701
- ...infraStages,
702
- ];
703
- }
704
- // ────────────────────────────────────────────────────────────────
705
720
  // Standalone infra phase (collab init infra)
706
721
  // ────────────────────────────────────────────────────────────────
707
722
  async function runInfraOnly(context, options) {
@@ -816,8 +831,22 @@ async function runRepoDomainGeneration(context, options) {
816
831
  ...(0, config_1.defaultCollabConfig)(context.config.workspaceDir),
817
832
  ...context.config,
818
833
  };
834
+ // Resolve mode early so parseBusinessCanonOption gets the correct mode context
835
+ let mode;
836
+ if (options.mode) {
837
+ mode = (0, mode_1.parseMode)(options.mode);
838
+ }
839
+ else if (options.yes) {
840
+ mode = 'file-only';
841
+ }
842
+ else {
843
+ mode = await (0, prompt_1.promptChoice)('Select domain generation mode:', [
844
+ { value: 'file-only', label: 'file-only (write domain files to local repo only)' },
845
+ { value: 'indexed', label: 'indexed (write to business canon + ingest into MCP)' },
846
+ ], 'file-only');
847
+ }
819
848
  // Resolve business canon if passed via flag (but don't require it for file-only)
820
- const canons = options.businessCanon ? parseBusinessCanonOption(options.businessCanon) : undefined;
849
+ const canons = options.businessCanon ? parseBusinessCanonOption(options.businessCanon, mode) : undefined;
821
850
  if (canons) {
822
851
  effectiveConfig.canons = canons;
823
852
  }
@@ -831,20 +860,6 @@ async function runRepoDomainGeneration(context, options) {
831
860
  context.logger.info('GitHub token stored from --github-token flag.');
832
861
  }
833
862
  }
834
- // Resolve mode
835
- let mode;
836
- if (options.mode) {
837
- mode = (0, mode_1.parseMode)(options.mode);
838
- }
839
- else if (options.yes) {
840
- mode = 'file-only';
841
- }
842
- else {
843
- mode = await (0, prompt_1.promptChoice)('Select domain generation mode:', [
844
- { value: 'file-only', label: 'file-only (write domain files to local repo only)' },
845
- { value: 'indexed', label: 'indexed (write to business canon + ingest into MCP)' },
846
- ], 'file-only');
847
- }
848
863
  // Validate prerequisites
849
864
  if (mode === 'indexed' && !(0, canon_resolver_1.isBusinessCanonConfigured)(effectiveConfig)) {
850
865
  throw new errors_1.CliError('Business canon is required for indexed mode. ' +
@@ -901,6 +916,7 @@ function registerInitCommand(program) {
901
916
  .option('--skip-mcp-snippets', 'Skip MCP client config snippet generation')
902
917
  .option('--skip-analysis', 'Skip AI-powered repository analysis stage')
903
918
  .option('--skip-ci', 'Skip CI workflow generation')
919
+ .option('--skip-github-setup', 'Skip GitHub branch model and workflow configuration')
904
920
  .option('--providers <list>', 'Comma-separated AI provider list (codex,claude,gemini,copilot)')
905
921
  .option('--business-canon <value>', 'Business canon: owner/repo, /local/path, or "none" to skip')
906
922
  .option('--github-token <token>', 'GitHub token for non-interactive mode')
@@ -964,10 +980,14 @@ Examples:
964
980
  mcpUrl: preserveExisting ? context.config.mcpUrl : selections.mcpUrl,
965
981
  };
966
982
  // ── Step 2: Business canon configuration ──────────────────
967
- context.logger.phaseHeader('collab init', 'Business Canon');
968
- const canons = await resolveBusinessCanon(options, effectiveConfig, context.logger);
969
- if (canons) {
970
- effectiveConfig.canons = canons;
983
+ // When preserving an existing config (no --force), skip canon
984
+ // resolution the existing canon config is already merged.
985
+ if (!preserveExisting) {
986
+ context.logger.phaseHeader('collab init', 'Business Canon');
987
+ const canons = await resolveBusinessCanon(options, effectiveConfig, context.logger);
988
+ if (canons) {
989
+ effectiveConfig.canons = canons;
990
+ }
971
991
  }
972
992
  const stageOptions = {
973
993
  yes: options.yes,
@@ -975,13 +995,14 @@ Examples:
975
995
  outputDir: options.outputDir,
976
996
  skipAnalysis: options.skipAnalysis,
977
997
  skipCi: options.skipCi,
998
+ skipGithubSetup: options.skipGithubSetup,
978
999
  };
979
1000
  // ── Workspace detection ───────────────────────────────────
980
1001
  // Prefer persisted workspace config when it exists (unless
981
1002
  // --force or explicit --repos override is provided).
982
1003
  const ws = !options.force && !options.repos && context.config.workspace
983
1004
  ? context.config.workspace
984
- : await resolveWorkspace(context.config.workspaceDir, options, context.logger);
1005
+ : await resolveWorkspace(context.config.workspaceDir, options, context.logger, selections.mode);
985
1006
  if (ws) {
986
1007
  // ── WORKSPACE MODE ────────────────────────────────────
987
1008
  effectiveConfig.workspace = { name: ws.name, type: ws.type, repos: ws.repos };
@@ -989,7 +1010,6 @@ Examples:
989
1010
  ...effectiveConfig.compose,
990
1011
  projectName: `collab-${ws.name}`,
991
1012
  };
992
- const repoConfigs = (0, config_1.resolveRepoConfigs)(effectiveConfig);
993
1013
  // Phase W — workspace-level stages
994
1014
  context.logger.phaseHeader('Workspace Setup', `${ws.repos.length} repositories (${ws.type})`);
995
1015
  const workspaceStages = buildWorkspaceStages(effectiveConfig, context.executor, context.logger, configExistedBefore, options);
@@ -1002,7 +1022,22 @@ Examples:
1002
1022
  mode: `${selections.mode} (workspace)`,
1003
1023
  stageOptions,
1004
1024
  }, workspaceStages);
1025
+ // ── Indexed mode: validate all repos are GitHub repos with access ──
1026
+ if (selections.mode === 'indexed' && context.executor.dryRun) {
1027
+ context.logger.info('[dry-run] Would validate GitHub remotes and token access for workspace repos.');
1028
+ }
1029
+ else if (selections.mode === 'indexed') {
1030
+ context.logger.phaseHeader('Repository Validation', 'GitHub access');
1031
+ const auth = (0, github_auth_1.loadGitHubAuth)(effectiveConfig.collabDir);
1032
+ if (!auth) {
1033
+ throw new errors_1.CliError('GitHub authorization required but token not found after auth stage.');
1034
+ }
1035
+ const validRepos = await (0, github_api_1.validateWorkspaceRepos)(ws.repos, effectiveConfig.workspaceDir, auth.token, context.logger);
1036
+ effectiveConfig.workspace = { ...effectiveConfig.workspace, repos: validRepos };
1037
+ ws.repos = validRepos;
1038
+ }
1005
1039
  // Phase R — per-repo stages
1040
+ const repoConfigs = (0, config_1.resolveRepoConfigs)(effectiveConfig);
1006
1041
  context.logger.phaseHeader('Repository Analysis', `${selections.mode} mode`);
1007
1042
  const perRepoStages = buildPerRepoStages(selections.mode);
1008
1043
  for (const [i, rc] of repoConfigs.entries()) {
@@ -1037,11 +1072,13 @@ Examples:
1037
1072
  }
1038
1073
  }
1039
1074
  else {
1040
- // ── SINGLE-REPO MODE (unchanged) ──────────────────────
1075
+ // ── SINGLE-REPO MODE ─────────────────────────────────
1076
+ if (selections.mode === 'indexed') {
1077
+ throw new errors_1.CliError('Indexed mode requires a multi-repo workspace.\n' +
1078
+ 'Run from a workspace directory with multiple git repos, or use --repos to specify repos.');
1079
+ }
1041
1080
  context.logger.phaseHeader('Project Setup', selections.mode);
1042
- const stages = selections.mode === 'file-only'
1043
- ? buildFileOnlyPipeline(effectiveConfig, context.executor, context.logger, configExistedBefore, options)
1044
- : buildIndexedPipeline(effectiveConfig, context.executor, context.logger, configExistedBefore, options, selections.composeMode, selections.infraType, selections.mcpUrl);
1081
+ const stages = buildFileOnlyPipeline(effectiveConfig, context.executor, context.logger, configExistedBefore, options);
1045
1082
  await (0, orchestrator_1.runOrchestration)({
1046
1083
  workflowId: 'init',
1047
1084
  config: effectiveConfig,
@@ -38,6 +38,7 @@ class Executor {
38
38
  const result = (0, node_child_process_1.spawnSync)(commandName, args, {
39
39
  cwd: options.cwd ?? this.cwd,
40
40
  encoding: 'utf8',
41
+ ...(options.input !== undefined ? { input: options.input } : {}),
41
42
  });
42
43
  if (result.error) {
43
44
  const errorCode = result.error.code;
@@ -0,0 +1,375 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.normalizeGitHubRemote = normalizeGitHubRemote;
7
+ exports.resolveGitHubOwnerRepo = resolveGitHubOwnerRepo;
8
+ exports.verifyGitHubAccess = verifyGitHubAccess;
9
+ exports.validateWorkspaceRepos = validateWorkspaceRepos;
10
+ exports.getRepoInfo = getRepoInfo;
11
+ exports.getBranchRef = getBranchRef;
12
+ exports.createBranch = createBranch;
13
+ exports.setDefaultBranch = setDefaultBranch;
14
+ exports.setBranchProtection = setBranchProtection;
15
+ exports.setMergeStrategy = setMergeStrategy;
16
+ exports.configureRepo = configureRepo;
17
+ const node_child_process_1 = require("node:child_process");
18
+ const node_path_1 = __importDefault(require("node:path"));
19
+ const errors_1 = require("./errors");
20
+ const GITHUB_API_VERSION = '2022-11-28';
21
+ const ACCESS_CHECK_TIMEOUT_MS = 10_000;
22
+ // ────────────────────────────────────────────────────────────────
23
+ // Remote URL normalization
24
+ // ────────────────────────────────────────────────────────────────
25
+ /**
26
+ * Normalizes a git remote URL to a GitHub `owner/repo` slug.
27
+ * Handles HTTPS, SSH (`git@`), and `ssh://` URL formats.
28
+ * Returns `null` if the remote is not a `github.com` URL.
29
+ */
30
+ function normalizeGitHubRemote(remoteUrl) {
31
+ if (!/github\.com[:/]/i.test(remoteUrl)) {
32
+ return null;
33
+ }
34
+ const slug = remoteUrl
35
+ .trim()
36
+ .replace(/\/+$/, '')
37
+ .replace(/\.git$/, '')
38
+ .replace(/^https?:\/\/github\.com\//i, '')
39
+ .replace(/^git@github\.com:/i, '')
40
+ .replace(/^ssh:\/\/git@github\.com\//i, '');
41
+ const parts = slug.split('/').filter(Boolean);
42
+ if (parts.length < 2) {
43
+ return null;
44
+ }
45
+ return `${parts[0]}/${parts[1]}`;
46
+ }
47
+ /**
48
+ * Reads the `origin` remote URL of a local git repo and extracts
49
+ * the GitHub owner/repo identity.
50
+ * Returns `null` if the repo has no git origin, no remote, or a non-GitHub remote.
51
+ */
52
+ function resolveGitHubOwnerRepo(repoDir) {
53
+ let remoteUrl;
54
+ try {
55
+ remoteUrl = (0, node_child_process_1.execFileSync)('git', ['-C', repoDir, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ const slug = normalizeGitHubRemote(remoteUrl);
61
+ if (!slug) {
62
+ return null;
63
+ }
64
+ const [owner, repo] = slug.split('/');
65
+ return { owner, repo, slug };
66
+ }
67
+ // ────────────────────────────────────────────────────────────────
68
+ // GitHub access verification
69
+ // ────────────────────────────────────────────────────────────────
70
+ /**
71
+ * Verifies the GitHub token has read access to the given repo.
72
+ * Returns `true` if `GET /repos/{slug}` returns 200.
73
+ */
74
+ async function verifyGitHubAccess(slug, token) {
75
+ const url = `https://api.github.com/repos/${slug}`;
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), ACCESS_CHECK_TIMEOUT_MS);
78
+ try {
79
+ const response = await fetch(url, {
80
+ headers: {
81
+ Authorization: `Bearer ${token}`,
82
+ Accept: 'application/vnd.github+json',
83
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
84
+ },
85
+ signal: controller.signal,
86
+ });
87
+ return response.ok;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+ // ────────────────────────────────────────────────────────────────
97
+ // Workspace repo validation
98
+ // ────────────────────────────────────────────────────────────────
99
+ /**
100
+ * Validates that workspace repos have GitHub origin remotes with token access.
101
+ * Returns only the repos that pass. Throws `CliError` if zero repos pass.
102
+ */
103
+ async function validateWorkspaceRepos(repoNames, workspaceDir, token, logger) {
104
+ const valid = [];
105
+ const excluded = [];
106
+ for (const name of repoNames) {
107
+ const repoDir = node_path_1.default.join(workspaceDir, name);
108
+ const identity = resolveGitHubOwnerRepo(repoDir);
109
+ if (!identity) {
110
+ excluded.push({ name, reason: 'no GitHub remote' });
111
+ logger.warn(`Excluding "${name}": no GitHub origin remote found.`);
112
+ continue;
113
+ }
114
+ const hasAccess = await verifyGitHubAccess(identity.slug, token);
115
+ if (!hasAccess) {
116
+ excluded.push({ name, reason: `no access to ${identity.slug}` });
117
+ logger.warn(`Excluding "${name}" (${identity.slug}): GitHub token lacks access.`);
118
+ continue;
119
+ }
120
+ logger.info(`Repo "${name}" (${identity.slug}): GitHub access verified.`);
121
+ valid.push(name);
122
+ }
123
+ if (excluded.length > 0) {
124
+ logger.info(`Excluded ${excluded.length} repo(s): ${excluded.map((e) => `${e.name} (${e.reason})`).join(', ')}`);
125
+ }
126
+ if (valid.length === 0) {
127
+ throw new errors_1.CliError('No repos in the workspace have a valid GitHub remote with token access.\n' +
128
+ 'Indexed mode requires at least 1 governed GitHub repo in addition to the business canon.\n' +
129
+ 'Clone your repos from GitHub and re-run.');
130
+ }
131
+ logger.info(`Found ${valid.length} governed repo(s): ${valid.join(', ')}`);
132
+ return valid;
133
+ }
134
+ /**
135
+ * Fetches repository metadata from the GitHub API.
136
+ */
137
+ async function getRepoInfo(slug, token) {
138
+ const url = `https://api.github.com/repos/${slug}`;
139
+ const controller = new AbortController();
140
+ const timer = setTimeout(() => controller.abort(), ACCESS_CHECK_TIMEOUT_MS);
141
+ try {
142
+ const response = await fetch(url, {
143
+ headers: {
144
+ Authorization: `Bearer ${token}`,
145
+ Accept: 'application/vnd.github+json',
146
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
147
+ },
148
+ signal: controller.signal,
149
+ });
150
+ if (!response.ok) {
151
+ throw new errors_1.CliError(`GitHub API error ${response.status} for ${slug}: ${response.statusText}`);
152
+ }
153
+ const data = (await response.json());
154
+ return {
155
+ default_branch: data.default_branch,
156
+ allow_merge_commit: data.allow_merge_commit,
157
+ allow_squash_merge: data.allow_squash_merge,
158
+ allow_rebase_merge: data.allow_rebase_merge,
159
+ delete_branch_on_merge: data.delete_branch_on_merge,
160
+ };
161
+ }
162
+ finally {
163
+ clearTimeout(timer);
164
+ }
165
+ }
166
+ // ────────────────────────────────────────────────────────────────
167
+ // Branch operations
168
+ // ────────────────────────────────────────────────────────────────
169
+ /**
170
+ * Gets the SHA of a branch ref. Returns `null` if the branch does not exist (404).
171
+ */
172
+ async function getBranchRef(slug, branch, token) {
173
+ const url = `https://api.github.com/repos/${slug}/git/ref/heads/${branch}`;
174
+ const controller = new AbortController();
175
+ const timer = setTimeout(() => controller.abort(), ACCESS_CHECK_TIMEOUT_MS);
176
+ try {
177
+ const response = await fetch(url, {
178
+ headers: {
179
+ Authorization: `Bearer ${token}`,
180
+ Accept: 'application/vnd.github+json',
181
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
182
+ },
183
+ signal: controller.signal,
184
+ });
185
+ if (response.status === 404) {
186
+ return null;
187
+ }
188
+ if (!response.ok) {
189
+ throw new errors_1.CliError(`GitHub API error ${response.status} checking branch ${branch} for ${slug}`);
190
+ }
191
+ const data = (await response.json());
192
+ return data.object.sha;
193
+ }
194
+ finally {
195
+ clearTimeout(timer);
196
+ }
197
+ }
198
+ /**
199
+ * Creates a new branch from a given SHA. Idempotent: swallows 422 "Reference already exists".
200
+ */
201
+ async function createBranch(slug, branch, fromSha, token) {
202
+ const url = `https://api.github.com/repos/${slug}/git/refs`;
203
+ const controller = new AbortController();
204
+ const timer = setTimeout(() => controller.abort(), ACCESS_CHECK_TIMEOUT_MS);
205
+ try {
206
+ const response = await fetch(url, {
207
+ method: 'POST',
208
+ headers: {
209
+ Authorization: `Bearer ${token}`,
210
+ Accept: 'application/vnd.github+json',
211
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
212
+ 'Content-Type': 'application/json',
213
+ },
214
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: fromSha }),
215
+ signal: controller.signal,
216
+ });
217
+ // 422 = "Reference already exists" — idempotent, safe to ignore
218
+ if (response.status === 422) {
219
+ return;
220
+ }
221
+ if (!response.ok) {
222
+ throw new errors_1.CliError(`GitHub API error ${response.status} creating branch ${branch} for ${slug}`);
223
+ }
224
+ }
225
+ finally {
226
+ clearTimeout(timer);
227
+ }
228
+ }
229
+ // ────────────────────────────────────────────────────────────────
230
+ // Repo configuration
231
+ // ────────────────────────────────────────────────────────────────
232
+ /**
233
+ * Sets the default branch for a repository.
234
+ * Idempotent: skips if already set.
235
+ */
236
+ async function setDefaultBranch(slug, branch, token) {
237
+ const url = `https://api.github.com/repos/${slug}`;
238
+ const controller = new AbortController();
239
+ const timer = setTimeout(() => controller.abort(), ACCESS_CHECK_TIMEOUT_MS);
240
+ try {
241
+ const response = await fetch(url, {
242
+ method: 'PATCH',
243
+ headers: {
244
+ Authorization: `Bearer ${token}`,
245
+ Accept: 'application/vnd.github+json',
246
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
247
+ 'Content-Type': 'application/json',
248
+ },
249
+ body: JSON.stringify({ default_branch: branch }),
250
+ signal: controller.signal,
251
+ });
252
+ if (!response.ok) {
253
+ throw new errors_1.CliError(`GitHub API error ${response.status} setting default branch for ${slug}`);
254
+ }
255
+ }
256
+ finally {
257
+ clearTimeout(timer);
258
+ }
259
+ }
260
+ /**
261
+ * Applies branch protection rules. PUT is idempotent by HTTP spec.
262
+ */
263
+ async function setBranchProtection(slug, branch, token) {
264
+ const url = `https://api.github.com/repos/${slug}/branches/${branch}/protection`;
265
+ const controller = new AbortController();
266
+ const timer = setTimeout(() => controller.abort(), ACCESS_CHECK_TIMEOUT_MS);
267
+ try {
268
+ const response = await fetch(url, {
269
+ method: 'PUT',
270
+ headers: {
271
+ Authorization: `Bearer ${token}`,
272
+ Accept: 'application/vnd.github+json',
273
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
274
+ 'Content-Type': 'application/json',
275
+ },
276
+ body: JSON.stringify({
277
+ required_status_checks: null,
278
+ enforce_admins: false,
279
+ required_pull_request_reviews: {
280
+ required_approving_review_count: 1,
281
+ dismiss_stale_reviews: true,
282
+ },
283
+ restrictions: null,
284
+ }),
285
+ signal: controller.signal,
286
+ });
287
+ if (!response.ok) {
288
+ throw new errors_1.CliError(`GitHub API error ${response.status} setting branch protection on ${branch} for ${slug}`);
289
+ }
290
+ }
291
+ finally {
292
+ clearTimeout(timer);
293
+ }
294
+ }
295
+ /**
296
+ * Configures merge strategy: only merge commits, no squash/rebase, delete branch on merge.
297
+ * PATCH is safe to repeat.
298
+ */
299
+ async function setMergeStrategy(slug, token) {
300
+ const url = `https://api.github.com/repos/${slug}`;
301
+ const controller = new AbortController();
302
+ const timer = setTimeout(() => controller.abort(), ACCESS_CHECK_TIMEOUT_MS);
303
+ try {
304
+ const response = await fetch(url, {
305
+ method: 'PATCH',
306
+ headers: {
307
+ Authorization: `Bearer ${token}`,
308
+ Accept: 'application/vnd.github+json',
309
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
310
+ 'Content-Type': 'application/json',
311
+ },
312
+ body: JSON.stringify({
313
+ allow_merge_commit: true,
314
+ allow_squash_merge: false,
315
+ allow_rebase_merge: false,
316
+ delete_branch_on_merge: true,
317
+ }),
318
+ signal: controller.signal,
319
+ });
320
+ if (!response.ok) {
321
+ throw new errors_1.CliError(`GitHub API error ${response.status} setting merge strategy for ${slug}`);
322
+ }
323
+ }
324
+ finally {
325
+ clearTimeout(timer);
326
+ }
327
+ }
328
+ /**
329
+ * Orchestrates full GitHub configuration for a single repo:
330
+ * branch model → default branch → protection → merge strategy.
331
+ */
332
+ async function configureRepo(slug, token, logger) {
333
+ logger.info(`Configuring branch model for ${slug}...`);
334
+ // 1. Get current repo info
335
+ const info = await getRepoInfo(slug, token);
336
+ // 2. Ensure both branches exist
337
+ const mainSha = await getBranchRef(slug, 'main', token);
338
+ const devSha = await getBranchRef(slug, 'development', token);
339
+ if (!mainSha && !devSha) {
340
+ // Neither exists — get the current default branch SHA and create both
341
+ const defaultSha = await getBranchRef(slug, info.default_branch, token);
342
+ if (!defaultSha) {
343
+ throw new errors_1.CliError(`Cannot resolve SHA for default branch "${info.default_branch}" of ${slug}`);
344
+ }
345
+ await createBranch(slug, 'main', defaultSha, token);
346
+ logger.info(` Created branch "main" from "${info.default_branch}".`);
347
+ await createBranch(slug, 'development', defaultSha, token);
348
+ logger.info(` Created branch "development" from "${info.default_branch}".`);
349
+ }
350
+ else if (!mainSha && devSha) {
351
+ await createBranch(slug, 'main', devSha, token);
352
+ logger.info(` Created branch "main" from "development".`);
353
+ }
354
+ else if (mainSha && !devSha) {
355
+ await createBranch(slug, 'development', mainSha, token);
356
+ logger.info(` Created branch "development" from "main".`);
357
+ }
358
+ else {
359
+ logger.info(` Branches "main" and "development" already exist.`);
360
+ }
361
+ // 3. Set default branch to development
362
+ if (info.default_branch !== 'development') {
363
+ await setDefaultBranch(slug, 'development', token);
364
+ logger.info(` Set default branch to "development" (was "${info.default_branch}").`);
365
+ }
366
+ else {
367
+ logger.info(` Default branch is already "development".`);
368
+ }
369
+ // 4. Protect main
370
+ await setBranchProtection(slug, 'main', token);
371
+ logger.info(` Applied branch protection on "main" (1 review required).`);
372
+ // 5. Merge strategy
373
+ await setMergeStrategy(slug, token);
374
+ logger.info(` Merge strategy: merge-commit only, delete branch on merge.`);
375
+ }
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.githubSetupStage = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const config_1 = require("../lib/config");
10
+ const github_api_1 = require("../lib/github-api");
11
+ const github_auth_1 = require("../lib/github-auth");
12
+ const errors_1 = require("../lib/errors");
13
+ const guard_main_pr_1 = require("../templates/ci/guard-main-pr");
14
+ const canon_sync_trigger_1 = require("../templates/ci/canon-sync-trigger");
15
+ /**
16
+ * Configures GitHub repos: branch model, protection, merge strategy,
17
+ * guard-main-pr workflow, canon-sync-trigger workflow, and CANON_SYNC_PAT secret.
18
+ *
19
+ * Only runs in indexed mode. Skipped with `--skip-github-setup`.
20
+ */
21
+ exports.githubSetupStage = {
22
+ id: 'github-setup',
23
+ title: 'Configure GitHub branch model, protections, and automation workflows',
24
+ recovery: [
25
+ 'Ensure GitHub token has repo scope.',
26
+ 'Run collab init --resume to retry GitHub setup.',
27
+ 'Use --skip-github-setup to bypass this stage.',
28
+ ],
29
+ run: async (ctx) => {
30
+ if (ctx.options?.skipGithubSetup) {
31
+ ctx.logger.info('Skipping GitHub setup by user choice.');
32
+ return;
33
+ }
34
+ if (ctx.config.mode !== 'indexed') {
35
+ ctx.logger.info('GitHub setup is only available in indexed mode; skipping.');
36
+ return;
37
+ }
38
+ if (ctx.executor.dryRun) {
39
+ ctx.logger.info('[dry-run] Would configure GitHub branch model, protections, and workflows for workspace repos.');
40
+ return;
41
+ }
42
+ // Load GitHub token
43
+ const auth = (0, github_auth_1.loadGitHubAuth)(ctx.config.collabDir);
44
+ if (!auth) {
45
+ throw new errors_1.CliError('GitHub authorization required but token not found.\n' +
46
+ 'Run collab init --resume after authenticating with GitHub.');
47
+ }
48
+ const { token } = auth;
49
+ // Resolve business canon slug
50
+ const canonSlug = ctx.config.canons?.business?.repo;
51
+ // Configure governed repos
52
+ const repoConfigs = (0, config_1.resolveRepoConfigs)(ctx.config);
53
+ for (const rc of repoConfigs) {
54
+ const identity = (0, github_api_1.resolveGitHubOwnerRepo)(rc.repoDir);
55
+ if (!identity) {
56
+ ctx.logger.warn(`Skipping "${rc.name}": no GitHub origin remote.`);
57
+ continue;
58
+ }
59
+ // Branch model + protection + merge strategy
60
+ await (0, github_api_1.configureRepo)(identity.slug, token, ctx.logger);
61
+ // guard-main-pr.yml
62
+ const guardPath = node_path_1.default.join(rc.repoDir, '.github', 'workflows', 'guard-main-pr.yml');
63
+ if (!node_fs_1.default.existsSync(guardPath)) {
64
+ ctx.executor.writeFile(guardPath, guard_main_pr_1.guardMainPrTemplate, {
65
+ description: `write guard-main-pr workflow for ${rc.name}`,
66
+ });
67
+ ctx.logger.info(` Created guard-main-pr.yml for ${rc.name}.`);
68
+ }
69
+ else {
70
+ ctx.logger.info(` guard-main-pr.yml already exists for ${rc.name}; skipping.`);
71
+ }
72
+ // canon-sync-trigger.yml (only for governed repos, not the canon itself)
73
+ if (canonSlug && identity.slug !== canonSlug) {
74
+ const triggerPath = node_path_1.default.join(rc.repoDir, '.github', 'workflows', 'canon-sync-trigger.yml');
75
+ if (!node_fs_1.default.existsSync(triggerPath)) {
76
+ ctx.executor.writeFile(triggerPath, (0, canon_sync_trigger_1.canonSyncTriggerTemplate)(canonSlug), {
77
+ description: `write canon-sync-trigger workflow for ${rc.name}`,
78
+ });
79
+ ctx.logger.info(` Created canon-sync-trigger.yml for ${rc.name}.`);
80
+ }
81
+ else {
82
+ ctx.logger.info(` canon-sync-trigger.yml already exists for ${rc.name}; skipping.`);
83
+ }
84
+ // CANON_SYNC_PAT secret via gh CLI (passed via stdin for security)
85
+ try {
86
+ ctx.executor.run('gh', [
87
+ 'secret', 'set', 'CANON_SYNC_PAT',
88
+ '-R', identity.slug,
89
+ ], { check: true, input: token });
90
+ ctx.logger.info(` Set CANON_SYNC_PAT secret for ${identity.slug}.`);
91
+ }
92
+ catch {
93
+ ctx.logger.warn(` Could not set CANON_SYNC_PAT for ${identity.slug}.\n` +
94
+ ` Set it manually: gh secret set CANON_SYNC_PAT -R ${identity.slug}`);
95
+ }
96
+ }
97
+ }
98
+ // Configure business-canon repo (same branch model, but no canon-sync-trigger)
99
+ if (canonSlug) {
100
+ ctx.logger.info(`Configuring business-canon repo: ${canonSlug}...`);
101
+ await (0, github_api_1.configureRepo)(canonSlug, token, ctx.logger);
102
+ // guard-main-pr.yml for the canon repo (write to local clone if available)
103
+ const canonLocalDir = ctx.config.canons?.business?.localDir;
104
+ if (canonLocalDir) {
105
+ const canonRepoDir = node_path_1.default.join(ctx.config.architectureDir, canonLocalDir);
106
+ const guardPath = node_path_1.default.join(canonRepoDir, '.github', 'workflows', 'guard-main-pr.yml');
107
+ if (node_fs_1.default.existsSync(canonRepoDir) && !node_fs_1.default.existsSync(guardPath)) {
108
+ ctx.executor.writeFile(guardPath, guard_main_pr_1.guardMainPrTemplate, {
109
+ description: `write guard-main-pr workflow for business-canon`,
110
+ });
111
+ ctx.logger.info(` Created guard-main-pr.yml for business-canon.`);
112
+ }
113
+ }
114
+ }
115
+ ctx.logger.info('GitHub setup complete.');
116
+ },
117
+ };
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canonSyncTriggerTemplate = canonSyncTriggerTemplate;
4
+ /**
5
+ * Generates the canon-sync-trigger workflow template.
6
+ * On merge to main, creates an issue in the business-canon repo.
7
+ * Only generated for governed repos, NOT for the business-canon repo itself.
8
+ */
9
+ function canonSyncTriggerTemplate(canonOwnerRepo) {
10
+ return `# Generated by collab-cli — triggers canon sync on merge to main.
11
+ name: Canon Sync Trigger
12
+
13
+ on:
14
+ push:
15
+ branches: [main]
16
+
17
+ jobs:
18
+ trigger-canon-sync:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - name: Create canon sync issue
22
+ uses: actions/github-script@v7
23
+ with:
24
+ github-token: \${{ secrets.CANON_SYNC_PAT }}
25
+ script: |
26
+ const [owner, repo] = '${canonOwnerRepo}'.split('/');
27
+ await github.rest.issues.create({
28
+ owner, repo,
29
+ title: 'Canon sync required — ' + context.repo.repo,
30
+ body: '## Canon Sync Required\\n\\n'
31
+ + 'Merge to \\\`main\\\` in **' + context.repo.owner + '/' + context.repo.repo + '**.\\n\\n'
32
+ + '**Commit:** ' + context.sha + '\\n\\n'
33
+ + '> Created automatically by canon-sync-trigger.',
34
+ labels: ['canon-sync', 'automated'],
35
+ });
36
+ `;
37
+ }
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.guardMainPrTemplate = void 0;
4
+ exports.guardMainPrTemplate = `# Generated by collab-cli — blocks PRs to main that don't come from development.
5
+ name: Guard Main PR Source
6
+
7
+ on:
8
+ pull_request:
9
+ branches: [main]
10
+ types: [opened, synchronize, reopened]
11
+
12
+ jobs:
13
+ check-source-branch:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Verify PR comes from development
17
+ if: github.event.pull_request.head.ref != 'development'
18
+ run: |
19
+ echo "::error::PRs to main must come from the development branch."
20
+ exit 1
21
+ - name: PR source is valid
22
+ if: github.event.pull_request.head.ref == 'development'
23
+ run: echo "PR is from development branch — allowed."
24
+ `;
@@ -1,7 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.architectureMergeTemplate = exports.architecturePrTemplate = void 0;
3
+ exports.canonSyncTriggerTemplate = exports.guardMainPrTemplate = exports.architectureMergeTemplate = exports.architecturePrTemplate = void 0;
4
4
  var architecture_pr_1 = require("./architecture-pr");
5
5
  Object.defineProperty(exports, "architecturePrTemplate", { enumerable: true, get: function () { return architecture_pr_1.architecturePrTemplate; } });
6
6
  var architecture_merge_1 = require("./architecture-merge");
7
7
  Object.defineProperty(exports, "architectureMergeTemplate", { enumerable: true, get: function () { return architecture_merge_1.architectureMergeTemplate; } });
8
+ var guard_main_pr_1 = require("./guard-main-pr");
9
+ Object.defineProperty(exports, "guardMainPrTemplate", { enumerable: true, get: function () { return guard_main_pr_1.guardMainPrTemplate; } });
10
+ var canon_sync_trigger_1 = require("./canon-sync-trigger");
11
+ Object.defineProperty(exports, "canonSyncTriggerTemplate", { enumerable: true, get: function () { return canon_sync_trigger_1.canonSyncTriggerTemplate; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxmaltech/collab-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "CLI for collaborative architecture and delivery workflows.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",