@motivation-labs/crosscheck 0.7.0 → 0.7.1-beta.07ebaac.0

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.
@@ -1,285 +1,376 @@
1
- import { existsSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
- import { createInterface } from 'readline';
5
4
  import chalk from 'chalk';
6
- import { loadConfig, resolveConfigPath, getGithubToken, detectGitHubLogin, writeOnboardConfig, } from '../config/loader.js';
7
- import { listUserOrgs, fetchActiveRepos } from '../github/client.js';
8
- import { promptRepoPicker, promptOrgPicker } from '../lib/repo-picker.js';
9
- import { runChecks } from './init.js';
10
- // ── Prompt helpers ────────────────────────────────────────────────────────────
11
- async function ask(question) {
12
- if (!process.stdin.isTTY)
13
- return '';
5
+ import { createInterface } from 'readline';
6
+ import yaml from 'js-yaml';
7
+ import { getGithubToken, loadConfig, resolveConfigPath, detectGitHubLogin, promptDeploymentMode, patchDeploymentConfig, } from '../config/loader.js';
8
+ import { listUserRepos, listUserOrgs, listOrgRepos } from '../github/client.js';
9
+ import { checkCodexAuth } from '../reviewers/codex.js';
10
+ import { checkClaudeAuth } from '../reviewers/claude.js';
11
+ import { execSync } from 'child_process';
12
+ import { promptRepoPicker } from '../lib/repo-picker.js';
13
+ function ask(question) {
14
14
  return new Promise(resolve => {
15
15
  const rl = createInterface({ input: process.stdin, output: process.stdout });
16
16
  rl.question(question, answer => { rl.close(); resolve(answer.trim()); });
17
17
  });
18
18
  }
19
- // Print numbered choices and return the chosen 1-based index (defaultIdx if user presses Enter).
20
- async function pickOne(prompt, options, defaultIdx) {
21
- console.log(`\n${prompt}\n`);
22
- options.forEach(o => console.log(o));
23
- console.log();
24
- const answer = await ask(chalk.dim(` Choice [${defaultIdx}]: `));
25
- const n = parseInt(answer, 10);
26
- return Number.isInteger(n) && n >= 1 && n <= options.length ? n : defaultIdx;
27
- }
28
- async function confirmWrite() {
29
- const answer = await ask(' Write config? [Y/n]: ');
30
- return answer === '' || /^y/i.test(answer);
31
- }
32
- async function confirmOptIn(question) {
33
- const answer = await ask(`${question} [y/N]: `);
34
- return /^y/i.test(answer);
19
+ async function checkEnv() {
20
+ let codexOk = false;
21
+ let claudeOk = false;
22
+ try {
23
+ execSync('codex --version 2>&1', { encoding: 'utf8' });
24
+ const auth = await checkCodexAuth();
25
+ codexOk = auth.ok;
26
+ const icon = auth.ok ? chalk.green('✓') : chalk.red('✗');
27
+ console.log(` ${icon} ${'codex CLI'.padEnd(20)} ${auth.detail}`);
28
+ if (!auth.ok)
29
+ console.log(` ${chalk.dim('→')} ${chalk.yellow('Run: codex login --device-auth')}`);
30
+ }
31
+ catch {
32
+ console.log(` ${chalk.red('✗')} ${'codex CLI'.padEnd(20)} not found`);
33
+ console.log(` ${chalk.dim('→')} ${chalk.yellow('Install: npm install -g @openai/codex')}`);
34
+ }
35
+ try {
36
+ const auth = await checkClaudeAuth();
37
+ claudeOk = auth.ok;
38
+ const icon = auth.ok ? chalk.green('✓') : chalk.red('✗');
39
+ console.log(` ${icon} ${'claude CLI'.padEnd(20)} ${auth.detail}`);
40
+ if (!auth.ok)
41
+ console.log(` ${chalk.dim('→')} ${chalk.yellow('Run: claude auth login')}`);
42
+ }
43
+ catch {
44
+ console.log(` ${chalk.red('✗')} ${'claude CLI'.padEnd(20)} not found`);
45
+ console.log(` ${chalk.dim('→')} ${chalk.yellow('Install: npm install -g @anthropic-ai/claude-code')}`);
46
+ }
47
+ const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
48
+ let ghAuthed = false;
49
+ try {
50
+ execSync('gh --version 2>&1', { encoding: 'utf8' });
51
+ let authOutput = '';
52
+ try {
53
+ authOutput = execSync('gh auth status 2>&1', { encoding: 'utf8' });
54
+ }
55
+ catch { /* GITHUB_TOKEN in use */ }
56
+ ghAuthed = authOutput.includes('Logged in') || !!envToken;
57
+ const icon = ghAuthed ? chalk.green('✓') : chalk.red('✗');
58
+ console.log(` ${icon} ${'gh CLI'.padEnd(20)} ${ghAuthed ? 'authenticated' : 'not authenticated'}`);
59
+ if (!ghAuthed)
60
+ console.log(` ${chalk.dim('→')} ${chalk.yellow('Run: gh auth login')}`);
61
+ }
62
+ catch {
63
+ console.log(` ${chalk.red('✗')} ${'gh CLI'.padEnd(20)} not found`);
64
+ console.log(` ${chalk.dim('→')} ${chalk.yellow('Install: brew install gh && gh auth login')}`);
65
+ }
66
+ if (!claudeOk && !codexOk) {
67
+ console.log(chalk.red('\nAt least one AI CLI (codex or claude) must be authenticated.\n'));
68
+ return { ok: false, claudeOk, codexOk };
69
+ }
70
+ if (!ghAuthed) {
71
+ console.log(chalk.red('\nGitHub auth is required to fetch repos and register webhooks.\n'));
72
+ return { ok: false, claudeOk, codexOk };
73
+ }
74
+ return { ok: true, claudeOk, codexOk };
35
75
  }
36
- // ── Fast-mode: skip all questions ────────────────────────────────────────────
37
- async function runFastMode(deployment, configPath, login, orgs) {
38
- const users = deployment === 'personal' && login ? [login] : [];
39
- const allowedAuthors = deployment === 'personal' && login ? [login] : [];
40
- const authorRoutes = deployment === 'personal' && login ? { [login]: 'claude' } : {};
41
- writeOnboardConfig(configPath, {
42
- deployment, login, orgs, users, repos: [],
43
- allowedAuthors, authorRoutes,
44
- autoFix: false, deliveryMode: 'pull_request',
45
- brand: { service_name: 'crosscheck', comment_header: '', comment_footer: '', reviewer_attribution: '' },
46
- });
47
- console.log(chalk.green(`\n ✓ config written (${deployment} mode, auto-detected scopes)\n`));
48
- if (orgs.length)
49
- console.log(` ${'orgs'.padEnd(12)}${orgs.join(', ')}`);
50
- if (users.length)
51
- console.log(` ${'users'.padEnd(12)}${users.join(', ')}`);
52
- console.log(chalk.dim(`\n config ${configPath}`));
53
- console.log(chalk.dim(' Run crosscheck watch to start.\n'));
76
+ async function promptVendorMode(claudeOk, codexOk, currentMode, currentClaudeEnabled, currentCodexEnabled, opts) {
77
+ const bothAvailable = claudeOk && codexOk;
78
+ if (!bothAvailable) {
79
+ // Only one CLI is available auto-select single-vendor
80
+ const vendor = claudeOk ? 'claude' : 'codex';
81
+ console.log(` Mode: ${chalk.cyan('single-vendor')} (only ${chalk.bold(vendor)} is available)`);
82
+ return { mode: 'single-vendor', claudeEnabled: claudeOk, codexEnabled: codexOk };
83
+ }
84
+ if (opts.yes && currentMode) {
85
+ console.log(` Using existing mode: ${chalk.cyan(currentMode)}`);
86
+ return {
87
+ mode: currentMode,
88
+ claudeEnabled: currentClaudeEnabled,
89
+ codexEnabled: currentCodexEnabled,
90
+ };
91
+ }
92
+ if (currentMode && !opts.yes) {
93
+ const keep = await ask(` Current mode: ${chalk.cyan(currentMode)}. Keep this? [Y/n]: `);
94
+ if (keep.toLowerCase() !== 'n') {
95
+ return {
96
+ mode: currentMode,
97
+ claudeEnabled: currentClaudeEnabled,
98
+ codexEnabled: currentCodexEnabled,
99
+ };
100
+ }
101
+ console.log();
102
+ }
103
+ console.log(' How should reviews be assigned?\n');
104
+ console.log(` [1] cross-vendor — ${chalk.dim('Claude reviews Codex PRs; Codex reviews Claude PRs')}`);
105
+ console.log(` [2] single-vendor — ${chalk.dim('one AI reviews all PRs')}`);
106
+ console.log();
107
+ const choice = await ask(' Choice [1]: ');
108
+ console.log();
109
+ if (choice === '2') {
110
+ console.log(' Which AI should review all PRs?\n');
111
+ console.log(` [1] claude`);
112
+ console.log(` [2] codex`);
113
+ console.log();
114
+ const vendorChoice = await ask(' Choice [1]: ');
115
+ console.log();
116
+ const vendor = vendorChoice === '2' ? 'codex' : 'claude';
117
+ return {
118
+ mode: 'single-vendor',
119
+ claudeEnabled: vendor === 'claude',
120
+ codexEnabled: vendor === 'codex',
121
+ };
122
+ }
123
+ return { mode: 'cross-vendor', claudeEnabled: true, codexEnabled: true };
54
124
  }
55
- // ── Branding step (shared by all personas) ────────────────────────────────────
56
- async function askBranding(existingBrand) {
57
- const brand = {
58
- service_name: existingBrand?.service_name ?? 'crosscheck',
59
- comment_header: existingBrand?.comment_header ?? '',
60
- comment_footer: existingBrand?.comment_footer ?? '',
61
- reviewer_attribution: existingBrand?.reviewer_attribution ?? '',
62
- };
63
- const wantBranding = await confirmOptIn('\nAdd custom branding to review comments? (for teams, services, or personal flair)');
64
- if (!wantBranding)
65
- return brand;
66
- const name = await ask(` Name or label shown in comments (${brand.service_name}): `);
67
- if (name)
68
- brand.service_name = name;
69
- const header = await ask(' Comment header (prepended to every review, Enter to skip): ');
70
- brand.comment_header = header;
71
- const footer = await ask(' Comment footer (appended to every review, Enter to skip): ');
72
- brand.comment_footer = footer;
73
- const attr = await ask(' Reviewer attribution line (replaces "Reviewed by {vendor}", Enter to skip): ');
74
- brand.reviewer_attribution = attr;
75
- return brand;
125
+ async function promptWorkflowPipeline(currentAutoFixEnabled, opts) {
126
+ if (opts.yes) {
127
+ const preset = currentAutoFixEnabled === true ? 'review-fix' : 'review-only';
128
+ console.log(` Using existing pipeline: ${chalk.cyan(preset)}`);
129
+ return preset;
130
+ }
131
+ console.log(' What should happen after a review?\n');
132
+ console.log(` [1] review only — ${chalk.dim('AI posts a comment; you handle fixes')}`);
133
+ console.log(` [2] review fix — ${chalk.dim('AI reviews, then auto-applies fixes')} ${chalk.green('(recommended)')}`);
134
+ console.log(` [3] review → fix → re-check — ${chalk.dim('full loop: review, fix, re-review to confirm')}`);
135
+ console.log();
136
+ const choice = await ask(' Choice [2]: ');
137
+ console.log();
138
+ if (choice === '1')
139
+ return 'review-only';
140
+ if (choice === '3')
141
+ return 'review-fix-recheck';
142
+ return 'review-fix';
76
143
  }
77
- // ── Main ──────────────────────────────────────────────────────────────────────
78
- export async function runOnboard(opts) {
144
+ // Workflow YAML for the recheck preset — written to ~/.crosscheck/workflow.yml
145
+ const WORKFLOW_RECHECK_YAML = `# crosscheck workflow — generated by crosscheck onboard
146
+ # Place a .crosscheck/workflow.yml in your project root to override this global file.
147
+
148
+ on: [opened, synchronize]
149
+ steps:
150
+ - name: review
151
+ type: review
152
+ reviewer: auto
153
+ max_rounds: 1
154
+
155
+ - name: fix
156
+ type: fix
157
+ reviewer: origin
158
+ when: "review.verdict != 'APPROVE'"
159
+ max_rounds: 1
160
+
161
+ - name: recheck
162
+ type: recheck
163
+ reviewer: auto
164
+ when: "fix.applied_count > 0"
165
+ max_rounds: 1
166
+ `;
167
+ export async function runOnboard(opts = {}) {
168
+ if (!process.stdin.isTTY) {
169
+ console.error(chalk.red('onboard requires an interactive terminal.'));
170
+ console.error(chalk.dim('Run crosscheck init and edit crosscheck.config.yml manually.'));
171
+ process.exit(1);
172
+ }
79
173
  console.log(chalk.bold('\ncrosscheck onboard\n'));
80
- // Resolve config path prefer explicit, then project-local, then global default
81
- const configPath = resolveConfigPath(opts.config) ?? join(homedir(), '.crosscheck', 'config.yml');
82
- const hasFile = existsSync(configPath);
83
- const existing = hasFile ? loadConfig(configPath) : null;
84
- // If already fully configured and user didn't request reconfigure or fast-mode, show summary
85
- if (hasFile && existing?.deployment && !opts.reconfigure && !opts.personal && !opts.team) {
86
- console.log(chalk.dim(` Found existing config at ${configPath}\n`));
87
- console.log(` ${'deployment'.padEnd(14)}${existing.deployment}`);
88
- if (existing.orgs.length)
89
- console.log(` ${'orgs'.padEnd(14)}${existing.orgs.join(', ')}`);
90
- if (existing.users.length)
91
- console.log(` ${'users'.padEnd(14)}${existing.users.join(', ')}`);
92
- if (existing.repos.length)
93
- console.log(` ${'repos'.padEnd(14)}${existing.repos.length} configured`);
94
- console.log();
95
- console.log(chalk.dim(' Already configured. Run crosscheck watch to start.'));
96
- console.log(chalk.dim(' Use --reconfigure to change settings.\n'));
97
- return;
98
- }
99
- // ── Step 0: Environment checks (compact) ──────────────────────────────────
100
- console.log(chalk.dim('Checking environment...\n'));
101
- const { results: checks } = await runChecks();
102
- for (const c of checks) {
103
- const icon = c.ok ? chalk.green('✓') : chalk.red('✗');
104
- const detail = c.ok ? chalk.dim(c.detail) : chalk.yellow(c.detail);
105
- console.log(` ${icon} ${c.label.padEnd(22)} ${detail}`);
106
- if (!c.ok && c.fix)
107
- console.log(` ${chalk.dim('→')} ${chalk.dim(c.fix)}`);
108
- }
109
- const failures = checks.filter(c => !c.ok && c.fix);
110
- console.log(failures.length === 0
111
- ? chalk.green('\n ✓ environment ready — proceeding to setup\n')
112
- : chalk.yellow(`\n ⚠ ${failures.length} issue(s) to address — see above; continuing setup\n`));
113
- // ── Resolve GitHub identity ──────────────────────────────────────────────
114
- let token = '';
115
- let login = '';
116
- let detectedOrgs = [];
174
+ // ── Step 1: Auth check ─────────────────────────────────────────────────────
175
+ console.log(chalk.bold('Step 1 environment check'));
176
+ const env = await checkEnv();
177
+ if (!env.ok)
178
+ process.exit(1);
179
+ console.log();
180
+ // ── Step 2: Deployment mode ────────────────────────────────────────────────
181
+ console.log(chalk.bold('Step 2 — deployment mode'));
182
+ const configPath = opts.config ?? resolveConfigPath() ?? join(homedir(), '.crosscheck', 'config.yml');
183
+ const existingConfig = existsSync(configPath) ? loadConfig(configPath) : null;
184
+ const currentDeployment = existingConfig?.deployment;
185
+ let deployment;
186
+ if (opts.personal) {
187
+ deployment = 'personal';
188
+ console.log(` Mode: ${chalk.cyan('personal')} (--personal flag)`);
189
+ }
190
+ else if (opts.team) {
191
+ deployment = 'team';
192
+ console.log(` Mode: ${chalk.cyan('team')} (--team flag)`);
193
+ }
194
+ else if (currentDeployment && !opts.yes) {
195
+ const keep = await ask(` Current mode: ${chalk.cyan(currentDeployment)}. Keep this? [Y/n]: `);
196
+ deployment = keep.toLowerCase() === 'n' ? await promptDeploymentMode() : currentDeployment;
197
+ }
198
+ else if (currentDeployment && opts.yes) {
199
+ deployment = currentDeployment;
200
+ console.log(` Using existing mode: ${chalk.cyan(deployment)}`);
201
+ }
202
+ else {
203
+ deployment = await promptDeploymentMode();
204
+ }
205
+ console.log();
206
+ // ── Step 3: Repo selection ─────────────────────────────────────────────────
207
+ console.log(chalk.bold('Step 3 select repos to monitor'));
208
+ let token;
117
209
  try {
118
210
  token = getGithubToken();
119
- login = detectGitHubLogin() ?? '';
120
- detectedOrgs = await listUserOrgs(token);
121
211
  }
122
- catch {
123
- console.log(chalk.dim(' (GitHub not authenticated scope detection skipped)\n'));
212
+ catch (err) {
213
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
214
+ process.exit(1);
124
215
  }
125
- // ── Fast mode: skip questionnaire, write immediately ─────────────────────
126
- if (opts.personal || opts.team) {
127
- const deployment = opts.personal ? 'personal' : 'team';
128
- mkdirSync(join(homedir(), '.crosscheck'), { recursive: true });
129
- await runFastMode(deployment, configPath, login, detectedOrgs);
130
- return;
131
- }
132
- // ── Step 1: Persona ───────────────────────────────────────────────────────
133
- const currentPersonaDefault = existing?.deployment === 'team' ? 2 : 1;
134
- const personaIdx = await pickOne('How will you use crosscheck?', [
135
- ` ${chalk.bold('[1] personal')} — I author PRs; review only my own work across my repos and orgs`,
136
- ` ${chalk.bold('[2] team')} — shared CR workflow; review PRs from multiple authors in org repos`,
137
- ], currentPersonaDefault);
138
- const deployment = personaIdx === 2 ? 'team' : 'personal';
139
- let orgs = [];
140
- let users = [];
141
- let repos = [];
142
- let allowedAuthors = [];
143
- let authorRoutes = {};
144
- let autoFix = false;
145
- let deliveryMode = 'pull_request';
146
- // ── Personal path ─────────────────────────────────────────────────────────
147
- if (deployment === 'personal') {
148
- // Step 2: Scope
149
- const orgPreview = detectedOrgs.slice(0, 2).map(o => `github.com/${o}/*`).join(', ') || 'your org repos';
150
- const orgSuffix = detectedOrgs.length ? ` (detected: ${detectedOrgs.join(', ')})` : '';
151
- const existingScope = existing
152
- ? (existing.users.length > 0 && existing.orgs.length > 0 ? 3
153
- : existing.users.length > 0 ? 1
154
- : existing.orgs.length > 0 ? 2
155
- : existing.repos.length > 0 ? 1 // curated repos-only = personal scope
156
- : 3)
157
- : 3;
158
- const scopeIdx = await pickOne(`What should crosscheck monitor?${orgSuffix}`, [
159
- ` ${chalk.bold('[1] My personal repos only')} — github.com/${login || 'you'}/* — side projects you own directly`,
160
- ` ${chalk.bold('[2] My org repos only')} — ${orgPreview}`,
161
- ` ${chalk.bold('[3] Both personal repos + orgs')} — everything across your GitHub account ← recommended`,
162
- ], existingScope);
163
- const includePersonal = scopeIdx !== 2;
164
- const includeOrgs = scopeIdx !== 1;
165
- if (includeOrgs && detectedOrgs.length > 0) {
166
- orgs = await promptOrgPicker(detectedOrgs, existing?.orgs.length ? existing.orgs : undefined);
167
- }
168
- if (includePersonal && token && login) {
169
- if (scopeIdx === 1) {
170
- // Curated mode (UC-01): pick specific repos
171
- console.log(chalk.dim('\n Fetching your repos...'));
172
- try {
173
- const repoList = await fetchActiveRepos(login, token);
174
- const existingNames = existing?.repos.map(r => `${r.owner}/${r.name}`) ?? [];
175
- const picked = await promptRepoPicker(repoList, existingNames.length ? existingNames : undefined);
176
- repos = picked.map(full => {
177
- const [owner, name] = full.split('/');
178
- return { owner: owner ?? login, name: name ?? full };
179
- });
180
- }
181
- catch {
182
- console.log(chalk.dim(' (Could not fetch repos — add them manually to config.repos)\n'));
183
- }
184
- }
185
- else {
186
- // "Both" mode (UC-02): monitor all personal repos via users field
187
- users = [login];
188
- }
189
- }
190
- else if (includePersonal) {
191
- users = login ? [login] : [];
216
+ const login = detectGitHubLogin() ?? '';
217
+ console.log(chalk.dim(` Fetching repos for ${login || 'your account'}...`));
218
+ const [personalRepos, orgs] = await Promise.all([
219
+ login ? listUserRepos(login, token, true).catch(() => []) : Promise.resolve([]),
220
+ listUserOrgs(token).catch(() => []),
221
+ ]);
222
+ const orgRepoLists = await Promise.all(orgs.map(org => listOrgRepos(org, token).catch(() => [])));
223
+ const allRepos = [];
224
+ for (const r of personalRepos)
225
+ allRepos.push(`${r.owner}/${r.name}`);
226
+ for (let i = 0; i < orgs.length; i++) {
227
+ for (const r of orgRepoLists[i])
228
+ allRepos.push(`${r.owner}/${r.name}`);
229
+ }
230
+ const currentRepoKeys = new Set((existingConfig?.repos ?? []).map(r => `${r.owner}/${r.name}`));
231
+ const currentOrgs = new Set(existingConfig?.orgs ?? []);
232
+ let selectedRepos;
233
+ let selectedOrgs;
234
+ if (opts.yes && existingConfig) {
235
+ selectedRepos = [...currentRepoKeys];
236
+ selectedOrgs = [...currentOrgs];
237
+ console.log(` Using existing repo selection (${selectedRepos.length} repos, ${selectedOrgs.length} orgs)`);
238
+ }
239
+ else {
240
+ if (allRepos.length === 0) {
241
+ console.log(chalk.yellow(' No repos found. You can add repos manually in your config file.'));
242
+ selectedRepos = [];
243
+ selectedOrgs = [];
192
244
  }
193
- // Step 3: Author filter
194
- const currentFilterDefault = existing?.routing?.allowed_authors?.length === 0 ? 2 : 1;
195
- const filterIdx = await pickOne('Whose PRs should be reviewed?', [
196
- ` ${chalk.bold('[1] Only mine')} (author = ${login || 'you'}) ← recommended`,
197
- ` ${chalk.bold('[2] Everyone')} in the monitored scope`,
198
- ], currentFilterDefault);
199
- if (filterIdx === 1) {
200
- if (login) {
201
- allowedAuthors = [login];
202
- authorRoutes = { [login]: 'claude' };
245
+ else {
246
+ const sorted = [
247
+ ...allRepos.filter(r => currentRepoKeys.has(r)),
248
+ ...allRepos.filter(r => !currentRepoKeys.has(r)),
249
+ ];
250
+ console.log(chalk.dim(` Found ${sorted.length} repos. Use arrows + space to select, enter to confirm.\n`));
251
+ const picked = await promptRepoPicker(sorted, {
252
+ title: 'Select repos to monitor:',
253
+ initialSelected: [...currentRepoKeys],
254
+ });
255
+ console.log();
256
+ const orgSet = new Set(orgs);
257
+ const orgCounts = {};
258
+ for (const r of picked) {
259
+ const owner = r.split('/')[0];
260
+ if (orgSet.has(owner))
261
+ orgCounts[owner] = (orgCounts[owner] ?? 0) + 1;
203
262
  }
204
- else {
205
- // Login undetectable — writing an empty allowed_authors would disable filtering entirely.
206
- // Prompt so the user can provide their handle manually.
207
- const manual = await ask(' GitHub login (needed for author filter, Enter to skip): ');
208
- if (manual) {
209
- allowedAuthors = [manual];
210
- authorRoutes = { [manual]: 'claude' };
211
- }
212
- else {
213
- console.log(chalk.yellow(' ⚠ allowed_authors left empty — all authors in scope will be reviewed.'));
214
- console.log(chalk.dim(' Add routing.allowed_authors to your config to restrict later.\n'));
263
+ const orgOffers = Object.entries(orgCounts).filter(([, count]) => count >= 3).map(([org]) => org);
264
+ selectedOrgs = [...currentOrgs];
265
+ selectedRepos = picked;
266
+ for (const org of orgOffers) {
267
+ if (currentOrgs.has(org))
268
+ continue;
269
+ const answer = opts.yes ? 'n' : await ask(` Monitor all of ${chalk.cyan(org)} instead of individual repos? [y/N]: `);
270
+ if (answer.toLowerCase() === 'y') {
271
+ selectedOrgs.push(org);
272
+ selectedRepos = selectedRepos.filter(r => !r.startsWith(`${org}/`));
215
273
  }
216
274
  }
217
275
  }
218
- // ── Team path ─────────────────────────────────────────────────────────────
219
276
  }
220
- else {
221
- // Step 2: Org picker
222
- if (detectedOrgs.length > 0) {
223
- orgs = await promptOrgPicker(detectedOrgs, existing?.orgs.length ? existing.orgs : undefined);
224
- }
225
- else {
226
- const manual = await ask(' Enter org names (comma-separated, or Enter to skip): ');
227
- orgs = manual ? manual.split(',').map(s => s.trim()).filter(Boolean) : [];
228
- }
229
- // Step 3: Author filter
230
- const authorIdx = await pickOne('Whose PRs should be reviewed?', [
231
- ` ${chalk.bold('[1] All authors')} (no filter) — review every PR in the org`,
232
- ` ${chalk.bold('[2] Specific logins')} — restrict to listed team members`,
233
- ], 1);
234
- if (authorIdx === 2) {
235
- const raw = await ask(' Enter logins (comma-separated): ');
236
- allowedAuthors = raw.split(',').map(s => s.trim()).filter(Boolean);
237
- }
238
- // Step 4: Review depth
239
- const depthIdx = await pickOne('How deep should the CR workflow go?', [
240
- ` ${chalk.bold('[1] CR only')} — post review comments; humans apply fixes`,
241
- ` ${chalk.bold('[2] CR + Auto-fix')} — crosscheck also proposes and commits fixes`,
242
- ], 1);
243
- if (depthIdx === 2) {
244
- autoFix = true;
245
- const deliveryIdx = await pickOne('How should auto-fixes be delivered?', [
246
- ` ${chalk.bold('[1] Open a fix PR')} (human reviews and merges before merge) ← recommended`,
247
- ` ${chalk.bold('[2] Push directly')} onto the PR branch`,
248
- ], 1);
249
- deliveryMode = deliveryIdx === 2 ? 'commit' : 'pull_request';
250
- }
277
+ console.log();
278
+ // ── Step 4: Review mode (cross-vendor vs single-vendor) ───────────────────
279
+ console.log(chalk.bold('Step 4 — review mode'));
280
+ const vendorConfig = await promptVendorMode(env.claudeOk, env.codexOk, existingConfig?.mode, existingConfig?.vendors?.claude?.enabled ?? true, existingConfig?.vendors?.codex?.enabled ?? true, opts);
281
+ console.log();
282
+ // ── Step 5: Workflow pipeline ──────────────────────────────────────────────
283
+ console.log(chalk.bold('Step 5 workflow pipeline'));
284
+ const pipelinePreset = await promptWorkflowPipeline(existingConfig?.post_review?.auto_fix?.enabled, opts);
285
+ console.log();
286
+ // ── Step 6: Confirm and write ──────────────────────────────────────────────
287
+ console.log(chalk.bold('Step 6 review and write config'));
288
+ console.log();
289
+ console.log(` deployment ${chalk.cyan(deployment)}`);
290
+ console.log(` mode ${chalk.cyan(vendorConfig.mode)}`);
291
+ if (vendorConfig.mode === 'single-vendor') {
292
+ const activeVendor = vendorConfig.claudeEnabled ? 'claude' : 'codex';
293
+ console.log(` vendor ${chalk.cyan(activeVendor)}`);
294
+ }
295
+ console.log(` pipeline ${chalk.cyan(pipelinePreset)}`);
296
+ if (selectedOrgs.length > 0) {
297
+ console.log(` orgs ${selectedOrgs.map(o => chalk.cyan(o)).join(', ')}`);
298
+ }
299
+ if (selectedRepos.length > 0) {
300
+ console.log(` repos ${selectedRepos.slice(0, 5).map(r => chalk.cyan(r)).join(', ')}${selectedRepos.length > 5 ? chalk.dim(` +${selectedRepos.length - 5} more`) : ''}`);
301
+ }
302
+ if (selectedOrgs.length === 0 && selectedRepos.length === 0) {
303
+ console.log(` ${chalk.yellow('No repos or orgs selected. Config will have empty scope.')}`);
304
+ }
305
+ console.log(` config ${chalk.dim(configPath)}`);
306
+ if (pipelinePreset === 'review-fix-recheck') {
307
+ const workflowPath = join(homedir(), '.crosscheck', 'workflow.yml');
308
+ console.log(` workflow ${chalk.dim(workflowPath)}`);
251
309
  }
252
- // ── Optional branding (all paths) ─────────────────────────────────────────
253
- const brand = await askBranding(existing?.brand);
254
- // ── Confirmation preview ──────────────────────────────────────────────────
255
- const COL = 14;
256
- console.log(chalk.bold('\n Your crosscheck config:\n'));
257
- console.log(` ${'persona'.padEnd(COL)}${deployment}`);
258
- if (orgs.length)
259
- console.log(` ${'orgs'.padEnd(COL)}${orgs.join(', ')}`);
260
- if (users.length)
261
- console.log(` ${'users'.padEnd(COL)}${users.join(', ')}`);
262
- if (repos.length)
263
- console.log(` ${'repos'.padEnd(COL)}${repos.map(r => `${r.owner}/${r.name}`).join(', ')}`);
264
- console.log(` ${'filter'.padEnd(COL)}${allowedAuthors.length ? `author = ${allowedAuthors.join(', ')}` : 'all authors'}`);
265
- if (autoFix)
266
- console.log(` ${'auto-fix'.padEnd(COL)}${deliveryMode}`);
267
- if (brand.service_name !== 'crosscheck')
268
- console.log(` ${'brand'.padEnd(COL)}${brand.service_name}`);
269
- console.log(` ${'config'.padEnd(COL)}${configPath}`);
270
310
  console.log();
271
- const ok = await confirmWrite();
272
- if (!ok) {
273
- console.log(chalk.dim(' Cancelled. No files written.\n'));
274
- return;
275
- }
276
- // ── Write ─────────────────────────────────────────────────────────────────
277
- writeOnboardConfig(configPath, {
278
- deployment, login, orgs, users, repos,
279
- allowedAuthors, authorRoutes,
280
- autoFix, deliveryMode, brand,
311
+ if (!opts.yes) {
312
+ const confirm = await ask(` Write to config? [Y/n]: `);
313
+ if (confirm.toLowerCase() === 'n') {
314
+ console.log(chalk.dim(' Aborted — no changes written.'));
315
+ return;
316
+ }
317
+ }
318
+ mkdirSync(dirname(configPath), { recursive: true });
319
+ patchDeploymentConfig(configPath, deployment, login, selectedOrgs, true);
320
+ // Patch all fields after deployment config is written
321
+ const raw = (yaml.load(readFileSync(configPath, 'utf8')) ?? {});
322
+ // Repos
323
+ raw.repos = selectedRepos.map(r => {
324
+ const [owner, name] = r.split('/');
325
+ return { owner, name };
281
326
  });
282
- console.log(chalk.green(`\n ✓ config written ${configPath}`));
283
- console.log(chalk.dim(' Run crosscheck watch to start.\n'));
327
+ if (selectedRepos.length > 0 || selectedOrgs.length > 0)
328
+ delete raw.users;
329
+ // Vendor mode
330
+ raw.mode = vendorConfig.mode;
331
+ if (!raw.vendors || typeof raw.vendors !== 'object')
332
+ raw.vendors = {};
333
+ const vendors = raw.vendors;
334
+ if (!vendors.claude)
335
+ vendors.claude = {};
336
+ if (!vendors.codex)
337
+ vendors.codex = {};
338
+ vendors.claude.enabled = vendorConfig.claudeEnabled;
339
+ vendors.codex.enabled = vendorConfig.codexEnabled;
340
+ // Workflow pipeline
341
+ if (!raw.post_review || typeof raw.post_review !== 'object')
342
+ raw.post_review = {};
343
+ const postReview = raw.post_review;
344
+ if (!postReview.auto_fix || typeof postReview.auto_fix !== 'object')
345
+ postReview.auto_fix = {};
346
+ const autoFix = postReview.auto_fix;
347
+ if (pipelinePreset === 'review-only') {
348
+ autoFix.enabled = false;
349
+ }
350
+ else {
351
+ autoFix.enabled = true;
352
+ autoFix.trigger = 'on_issues';
353
+ if (!autoFix.delivery || typeof autoFix.delivery !== 'object')
354
+ autoFix.delivery = {};
355
+ const delivery = autoFix.delivery;
356
+ if (!delivery.mode)
357
+ delivery.mode = 'commit';
358
+ }
359
+ writeFileSync(configPath, yaml.dump(raw, { lineWidth: -1, noRefs: true }));
360
+ console.log(chalk.green(` ✓ config written to ${configPath}`));
361
+ // Manage global workflow.yml — write for recheck preset, remove stale file otherwise
362
+ const globalWorkflowPath = join(homedir(), '.crosscheck', 'workflow.yml');
363
+ if (pipelinePreset === 'review-fix-recheck') {
364
+ mkdirSync(join(homedir(), '.crosscheck'), { recursive: true });
365
+ writeFileSync(globalWorkflowPath, WORKFLOW_RECHECK_YAML);
366
+ console.log(chalk.green(` ✓ workflow written to ${globalWorkflowPath}`));
367
+ }
368
+ else if (existsSync(globalWorkflowPath)) {
369
+ unlinkSync(globalWorkflowPath);
370
+ console.log(chalk.dim(` ✓ stale global workflow removed (pipeline changed to ${pipelinePreset})`));
371
+ }
372
+ console.log();
373
+ // ── Step 7: Next step hint ────────────────────────────────────────────────
374
+ console.log(chalk.dim(' Run crosscheck watch to start monitoring.\n'));
284
375
  }
285
376
  //# sourceMappingURL=onboard.js.map