@magnusekdahl/parallix 1.2.0 → 1.3.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.
Files changed (127) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +737 -838
  3. package/lib/agents/agents.ts +930 -0
  4. package/lib/agents/claude-telemetry.js +149 -154
  5. package/lib/agents/claude-telemetry.ts +233 -0
  6. package/lib/agents/claude.js +121 -131
  7. package/lib/agents/claude.ts +156 -0
  8. package/lib/agents/codex-telemetry.js +176 -164
  9. package/lib/agents/codex-telemetry.ts +205 -0
  10. package/lib/agents/codex.js +168 -201
  11. package/lib/agents/codex.ts +236 -0
  12. package/lib/agents/limit-hit.js +216 -226
  13. package/lib/agents/limit-hit.ts +262 -0
  14. package/lib/agents/mistral-telemetry.js +7 -10
  15. package/lib/agents/mistral-telemetry.ts +44 -0
  16. package/lib/agents/mistral.js +40 -58
  17. package/lib/agents/mistral.ts +84 -0
  18. package/lib/agents/opencode-export.js +126 -127
  19. package/lib/agents/opencode-export.ts +160 -0
  20. package/lib/agents/opencode-telemetry.js +282 -261
  21. package/lib/agents/opencode-telemetry.ts +350 -0
  22. package/lib/agents/opencode.js +263 -305
  23. package/lib/agents/opencode.ts +370 -0
  24. package/lib/agents/stage-telemetry.js +23 -24
  25. package/lib/agents/stage-telemetry.ts +47 -0
  26. package/lib/commands/active.js +502 -579
  27. package/lib/commands/active.ts +665 -0
  28. package/lib/commands/checkpoint.js +98 -74
  29. package/lib/commands/checkpoint.ts +79 -0
  30. package/lib/commands/config.js +61 -31
  31. package/lib/commands/config.ts +47 -0
  32. package/lib/commands/coverage-gate.js +271 -246
  33. package/lib/commands/coverage-gate.ts +362 -0
  34. package/lib/commands/diff.js +140 -119
  35. package/lib/commands/diff.ts +125 -0
  36. package/lib/commands/draft.js +770 -923
  37. package/lib/commands/draft.ts +1021 -0
  38. package/lib/commands/handoff.js +588 -608
  39. package/lib/commands/handoff.ts +694 -0
  40. package/lib/commands/integrate.js +1332 -1463
  41. package/lib/commands/integrate.ts +1688 -0
  42. package/lib/commands/mission-start.js +279 -237
  43. package/lib/commands/mission-start.ts +263 -0
  44. package/lib/commands/rebase.js +606 -582
  45. package/lib/commands/rebase.ts +635 -0
  46. package/lib/commands/repair-handoff.js +181 -179
  47. package/lib/commands/repair-handoff.ts +228 -0
  48. package/lib/commands/resolve-conflict.js +121 -102
  49. package/lib/commands/resolve-conflict.ts +112 -0
  50. package/lib/commands/review.js +3 -4
  51. package/lib/commands/review.ts +14 -0
  52. package/lib/commands/setup-review.js +4 -5
  53. package/lib/commands/setup-review.ts +14 -0
  54. package/lib/commands/setup.js +3 -3
  55. package/lib/commands/setup.ts +2 -0
  56. package/lib/commands/stats-backfill.js +394 -359
  57. package/lib/commands/stats-backfill.ts +418 -0
  58. package/lib/commands/stats.js +1424 -1665
  59. package/lib/commands/stats.ts +2186 -0
  60. package/lib/commands/status.js +219 -200
  61. package/lib/commands/status.ts +219 -0
  62. package/lib/commands/verify.js +3 -1
  63. package/lib/commands/verify.ts +2 -0
  64. package/lib/core/fmt.js +140 -211
  65. package/lib/core/fmt.ts +189 -0
  66. package/lib/core/git.js +121 -102
  67. package/lib/core/git.ts +146 -0
  68. package/lib/core/gitignore.js +59 -106
  69. package/lib/core/gitignore.ts +103 -0
  70. package/lib/core/mission-utils.js +833 -875
  71. package/lib/core/mission-utils.ts +998 -0
  72. package/lib/core/nels.js +112 -127
  73. package/lib/core/nels.ts +189 -0
  74. package/lib/core/persistent-data-migration.js +247 -205
  75. package/lib/core/persistent-data-migration.ts +255 -0
  76. package/lib/core/product-config.js +400 -476
  77. package/lib/core/product-config.ts +518 -0
  78. package/lib/core/runtime-matrix.js +41 -87
  79. package/lib/core/runtime-matrix.ts +84 -0
  80. package/lib/core/spawn-tee.js +152 -192
  81. package/lib/core/spawn-tee.ts +202 -0
  82. package/lib/core/state-map.js +92 -92
  83. package/lib/core/state-map.ts +115 -0
  84. package/lib/core/storage.js +101 -129
  85. package/lib/core/storage.ts +182 -0
  86. package/lib/core/subagent-limit.js +15 -16
  87. package/lib/core/subagent-limit.ts +25 -0
  88. package/lib/core/verification.js +148 -138
  89. package/lib/core/verification.ts +200 -0
  90. package/lib/index.js +142 -59
  91. package/lib/index.ts +134 -0
  92. package/lib/review/rebase.js +142 -145
  93. package/lib/review/rebase.ts +188 -0
  94. package/lib/review/review-adapter.js +99 -87
  95. package/lib/review/review-adapter.ts +198 -0
  96. package/lib/review/review-artifacts.js +486 -629
  97. package/lib/review/review-artifacts.ts +622 -0
  98. package/lib/review/review-commands.js +1220 -1373
  99. package/lib/review/review-commands.ts +1579 -0
  100. package/lib/review/review-events.js +544 -886
  101. package/lib/review/review-events.ts +954 -0
  102. package/lib/review/review-loop.js +945 -1020
  103. package/lib/review/review-loop.ts +1174 -0
  104. package/lib/review/review-polling.js +86 -115
  105. package/lib/review/review-polling.ts +194 -0
  106. package/lib/review/review-prompts.js +154 -189
  107. package/lib/review/review-prompts.ts +182 -0
  108. package/lib/review/review-state.js +246 -225
  109. package/lib/review/review-state.ts +353 -0
  110. package/lib/review/review.js +63 -84
  111. package/lib/review/review.ts +138 -0
  112. package/lib/tools/backlog.js +694 -680
  113. package/lib/tools/backlog.ts +835 -0
  114. package/lib/tools/forgejo.js +1270 -1405
  115. package/lib/tools/forgejo.ts +1793 -0
  116. package/lib/tools/gatekeeper.js +106 -96
  117. package/lib/tools/gatekeeper.ts +124 -0
  118. package/lib/tools/redgreen.js +200 -181
  119. package/lib/tools/redgreen.ts +221 -0
  120. package/lib/tools/sessions.js +58 -52
  121. package/lib/tools/sessions.ts +81 -0
  122. package/lib/tools/setup-review.js +902 -975
  123. package/lib/tools/setup-review.ts +1152 -0
  124. package/package.json +7 -5
  125. package/prompts/draft.md +1 -0
  126. package/prompts/portfolio.md +1 -1
  127. package/px.js +226 -210
@@ -0,0 +1,1152 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as readline from 'readline';
4
+ import _cp = require('child_process');
5
+ const { spawnSync } = _cp;
6
+ import * as fmt from '../core/fmt';
7
+ import { eligibleAgentsForStep } from '../agents/agents';
8
+ import { resolveReviewAdapter } from '../core/product-config';
9
+ import {
10
+ resolveForgejoHome,
11
+ resolveForgejoSettings,
12
+ reviewRemoteUrl,
13
+ } from './forgejo';
14
+ import { loadWorkflowConfig } from '../core/product-config';
15
+ import ensureWorkflowGitignore = require('../core/gitignore');
16
+
17
+ let tokenNameCounter = 0;
18
+ const REVIEW_TOKEN_SCOPES = ['write:user', 'write:repository', 'write:issue', 'write:organization'];
19
+ const PASSWORD_PROMPT_OPTIONS = { hidden: false };
20
+
21
+ /** @param {string} repo @returns {{owner: string, repo: string}|null} */
22
+ function parseRepoSlug(repo: string): { owner: string; repo: string } | null {
23
+ if (typeof repo !== 'string') {return null;}
24
+ const match = repo.trim().match(/^([^/\s]+)\/([^/\s]+)$/);
25
+ if (!match) {return null;}
26
+ return { owner: match[1], repo: match[2] };
27
+ }
28
+
29
+ /** @param {string[]} values @returns {string[]} */
30
+ function unique(values: string[]): string[] {
31
+ return [...new Set(values.filter(Boolean))];
32
+ }
33
+
34
+ /** @param {string|undefined} baseUrl @returns {string|undefined} */
35
+ function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
36
+ return typeof baseUrl === 'string' ? baseUrl.replace(/\/+$/, '') : baseUrl;
37
+ }
38
+
39
+ /** @param {string} repoName @param {string} user @param {string} [suffix] @returns {string} */
40
+ function buildTokenName(repoName: string, user: string, suffix: string = `${Date.now()}-${++tokenNameCounter}`): string {
41
+ return `workflow-${repoName}-${user}-${suffix}`;
42
+ }
43
+
44
+ /** @param {string} user @param {{allowBlank?: boolean}} [options] @returns {string} */
45
+ function passwordPrompt(user: string, options: { allowBlank?: boolean } = {}): string {
46
+ const { allowBlank = false } = options;
47
+ return allowBlank
48
+ ? `Password for ${user} (input visible, leave blank to skip token creation): `
49
+ : `Password for ${user} (input visible): `;
50
+ }
51
+
52
+ /** @param {string|undefined} value @param {boolean} [defaultValue] @returns {boolean} */
53
+ function parseYesNo(value: string | undefined, defaultValue?: boolean): boolean {
54
+ if (typeof value !== 'string' || value.trim() === '') {return defaultValue ?? true;}
55
+ const normalized = value.trim().toLowerCase();
56
+ if (['y', 'yes'].includes(normalized)) {return true;}
57
+ if (['n', 'no'].includes(normalized)) {return false;}
58
+ return defaultValue ?? true;
59
+ }
60
+
61
+ /** @param {string} [rootDir] @returns {string} */
62
+ function defaultProductName(rootDir?: string): string {
63
+ return path.basename(rootDir || process.cwd()) || 'Workflow Project';
64
+ }
65
+
66
+ /** @param {string} [rootDir] @param {string} [ownerLogin] @returns {string} */
67
+ function defaultRepoSlug(rootDir?: string, ownerLogin?: string): string {
68
+ return `${ownerLogin || 'human'}/${path.basename(rootDir || process.cwd()) || 'project'}`;
69
+ }
70
+
71
+ /**
72
+ * @param {string} rootDir
73
+ * @param {{spawnSyncFn?: Function, configuredPrimaryBranch?: string|null}} [options]
74
+ * @returns {string}
75
+ */
76
+ function detectPrimaryBranchDefault(rootDir?: string, options: { spawnSyncFn?: Function; configuredPrimaryBranch?: string | null } = {}): string {
77
+ const { spawnSyncFn = spawnSync, configuredPrimaryBranch = null } = options;
78
+ const listResult = spawnSyncFn(
79
+ 'git',
80
+ ['-C', rootDir, 'branch', '--list', '--format=%(refname:short)', 'main', 'master'],
81
+ { encoding: 'utf8' }
82
+ );
83
+ const branches = listResult.status === 0
84
+ ? (listResult.stdout || '').split('\n').map(/** @param {string} value */ (value: string) => value.trim()).filter(Boolean)
85
+ : [];
86
+
87
+ if (configuredPrimaryBranch && branches.includes(configuredPrimaryBranch)) {
88
+ return configuredPrimaryBranch;
89
+ }
90
+
91
+ const currentResult = spawnSyncFn('git', ['-C', rootDir, 'branch', '--show-current'], {
92
+ encoding: 'utf8',
93
+ });
94
+ const currentBranch = currentResult.status === 0 ? (currentResult.stdout || '').trim() : '';
95
+ if (currentBranch === 'main' || currentBranch === 'master') {
96
+ return currentBranch;
97
+ }
98
+
99
+ if (branches.includes('main')) {return 'main';}
100
+ if (branches.includes('master')) {return 'master';}
101
+ if (configuredPrimaryBranch) {return configuredPrimaryBranch;}
102
+ return 'main';
103
+ }
104
+
105
+ export function standardLayoutDescription(): string {
106
+ return [
107
+ 'standard = markdown task storage in `backlog/`',
108
+ 'missions in `docs/missions/`',
109
+ 'mission/* branches with auto-detected `main`/`master` primary',
110
+ 'worktrees in `../<repo>-<slug>`',
111
+ 'verification via `npm test` with default area `docs`',
112
+ ].join('; ');
113
+ }
114
+
115
+ /** @param {{reviewProvider?: string, baseUrl?: string, reviewRemote?: string, reviewRepo?: string}} answers @returns {{provider: string, baseUrl?: string, remote?: string, repo?: string}} */
116
+ function buildReviewAdapterConfig(answers: { reviewProvider?: string; baseUrl?: string; reviewRemote?: string; reviewRepo?: string }) {
117
+ const provider = answers.reviewProvider || 'forgejo';
118
+ if (provider !== 'forgejo') {
119
+ return { provider };
120
+ }
121
+ return {
122
+ provider: 'forgejo',
123
+ baseUrl: normalizeBaseUrl(answers.baseUrl),
124
+ remote: answers.reviewRemote,
125
+ repo: answers.reviewRepo,
126
+ };
127
+ }
128
+
129
+ /** @param {{missionsBaseDir?: string, branchPrefix?: string, worktreePattern?: string, primaryBranch?: string, productName?: string, tasksProvider?: string, tasksStorage?: string, verificationCommand?: string, verificationDefaultArea?: string, reviewProvider?: string, baseUrl?: string, reviewRemote?: string, reviewRepo?: string}} answers @returns {{product: {name: string, targetUser: string}, adapters: {tasks: {provider: string, storage: string}, missions: {baseDir: string, branchPrefix: string, worktreePattern: string, primaryBranch?: string}, verification: {command: string, defaultArea: string}, review: {provider: string, baseUrl?: string, remote?: string, repo?: string}, agents: {}}}} */
130
+ function buildWorkflowConfig(answers: any) {
131
+ /** @type {{baseDir: string, branchPrefix: string, worktreePattern: string, primaryBranch?: string}} */
132
+ const missions: { baseDir: string; branchPrefix: string; worktreePattern: string; primaryBranch?: string } = {
133
+ baseDir: /** @type {string} */ (answers.missionsBaseDir || ''),
134
+ branchPrefix: /** @type {string} */ (answers.branchPrefix || ''),
135
+ worktreePattern: /** @type {string} */ (answers.worktreePattern || ''),
136
+ };
137
+ if (
138
+ typeof answers.primaryBranch === 'string' &&
139
+ answers.primaryBranch.trim() &&
140
+ !['main', 'master'].includes(answers.primaryBranch.trim())
141
+ ) {
142
+ missions.primaryBranch = answers.primaryBranch.trim();
143
+ }
144
+
145
+ return {
146
+ product: {
147
+ name: /** @type {string} */ (answers.productName || ''),
148
+ targetUser: 'Engineering teams using git, task tracking, and code review',
149
+ },
150
+ adapters: {
151
+ tasks: {
152
+ provider: /** @type {string} */ (answers.tasksProvider || ''),
153
+ storage: /** @type {string} */ (answers.tasksStorage || ''),
154
+ },
155
+ missions,
156
+ verification: {
157
+ command: /** @type {string} */ (answers.verificationCommand || ''),
158
+ defaultArea: /** @type {string} */ (answers.verificationDefaultArea || ''),
159
+ },
160
+ review: buildReviewAdapterConfig(answers),
161
+ agents: {},
162
+ },
163
+ };
164
+ }
165
+
166
+ /** @param {string} rootDir @param {{[key: string]: any}} config @returns {string} */
167
+ function writeWorkflowConfig(rootDir: string, config: { [key: string]: any }): string {
168
+ const configPath = path.join(rootDir, 'workflow.config.json');
169
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
170
+ return configPath;
171
+ }
172
+
173
+ /** @returns {string[]} */
174
+ export function suggestedForgejoUsers(): string[] {
175
+ return unique([
176
+ ...eligibleAgentsForStep('active'),
177
+ ...eligibleAgentsForStep('review'),
178
+ ]);
179
+ }
180
+
181
+ /** @param {string} user @param {string} [forgejoHome] @returns {string} */
182
+ function tokenFilePath(user: string, forgejoHome?: string): string {
183
+ return path.join(forgejoHome || resolveForgejoHome(), 'tokens', user);
184
+ }
185
+
186
+ /** @param {string} [forgejoHome] @returns {string} */
187
+ function ensureTokenDir(forgejoHome?: string): string {
188
+ const dir = path.join(forgejoHome || resolveForgejoHome(), 'tokens');
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ return dir;
191
+ }
192
+
193
+ /** @param {string} [rootDir] @param {string} [explicitForgejoHome] @returns {string} */
194
+ function resolveBootstrapForgejoHome(rootDir?: string, explicitForgejoHome?: string): string {
195
+ if (explicitForgejoHome) {return explicitForgejoHome;}
196
+ return path.join(rootDir || process.cwd(), '.forgejo-local');
197
+ }
198
+
199
+ export function readConfiguredReviewRemote(rootDir?: string, remoteName?: string): string | null {
200
+ const result = spawnSync('git', ['-C', rootDir || process.cwd(), 'remote', 'get-url', remoteName || 'review'], {
201
+ encoding: 'utf8',
202
+ });
203
+ if (result.status !== 0) {return null;}
204
+ return (result.stdout || '').trim() || null;
205
+ }
206
+
207
+ /**
208
+ * @param {string} rootDir
209
+ * @param {{users?: string[], remoteName?: string, remoteUrlFn?: Function, getRemoteUrlFn?: Function, tokenPathFn?: Function, requestFn?: Function}} [options]
210
+ * @returns {{required: boolean, ok: boolean, issues: string[], steps: string[]}}
211
+ */
212
+ function evaluateReviewSetup(rootDir?: string, options: any = {}) {
213
+ const {
214
+ users = suggestedForgejoUsers(),
215
+ remoteName,
216
+ remoteUrlFn = reviewRemoteUrl,
217
+ getRemoteUrlFn = readConfiguredReviewRemote,
218
+ tokenPathFn = tokenFilePath,
219
+ requestFn = apiRequest,
220
+ } = options;
221
+ const reviewAdapter = resolveReviewAdapter(rootDir);
222
+ if (reviewAdapter.provider !== 'forgejo') {
223
+ return {
224
+ required: false,
225
+ ok: true,
226
+ issues: [],
227
+ steps: [],
228
+ };
229
+ }
230
+ const review = resolveForgejoSettings(rootDir);
231
+ if (review.repo === '' || !review.url) {
232
+ return {
233
+ required: false,
234
+ ok: true,
235
+ issues: [],
236
+ steps: [],
237
+ };
238
+ }
239
+
240
+ const expectedRemote = remoteUrlFn(rootDir);
241
+ const configuredRemoteName = remoteName || reviewAdapter.remote || 'review';
242
+ const issues = [];
243
+ const steps = [];
244
+ const missingUsers = users.filter(/** @param {string} user */ (user: string) => !fs.existsSync(tokenPathFn(user)));
245
+
246
+ if (missingUsers.length > 0) {
247
+ issues.push(`missing Forgejo tokens for: ${missingUsers.join(', ')}`);
248
+ steps.push('Run `px setup` and enter Forgejo passwords for the agent users you plan to run.');
249
+ }
250
+
251
+ const currentRemote = getRemoteUrlFn(rootDir, configuredRemoteName);
252
+ if (!currentRemote) {
253
+ issues.push(`git remote "${configuredRemoteName}" is missing`);
254
+ steps.push('Run `px setup` to create the review remote.');
255
+ } else if (expectedRemote && currentRemote !== expectedRemote) {
256
+ issues.push(`git remote "${configuredRemoteName}" points at ${currentRemote} instead of ${expectedRemote}`);
257
+ steps.push('Run `px setup` to update the review remote URL.');
258
+ }
259
+
260
+ if (missingUsers.length === 0) {
261
+ const normalizedBaseUrl = normalizeBaseUrl(review.url);
262
+ for (const user of users) {
263
+ const tokenPath = tokenPathFn(user);
264
+ const token = fs.readFileSync(tokenPath, 'utf8').trim();
265
+ if (!token) {
266
+ issues.push(`Forgejo token for ${user} is empty`);
267
+ steps.push('Run `px setup-review` to rotate the empty token file.');
268
+ continue;
269
+ }
270
+ const probe = requestFn('GET', `${normalizedBaseUrl}/api/v1/repos/${review.repo}`, { token });
271
+ if (isAuthFailure(probe)) {
272
+ issues.push(`Forgejo token for ${user} is invalid or expired (HTTP ${probe.statusCode})`);
273
+ steps.push('Run `px setup-review` and re-enter the Forgejo passwords to rotate local PATs.');
274
+ } else if (!probe.ok && probe.statusCode === 404) {
275
+ issues.push(`Forgejo review repo ${review.repo} is missing or inaccessible for ${user}`);
276
+ steps.push('Run `px setup` to recreate the review repo and refresh token/remote wiring.');
277
+ } else if (!probe.ok && probe.statusCode === null) {
278
+ issues.push(`Forgejo at ${normalizedBaseUrl} is unreachable while validating ${user}`);
279
+ steps.push('Start Forgejo and rerun `px verify-env` or `px setup-review`.');
280
+ }
281
+ }
282
+ }
283
+
284
+ if (issues.length === 0) {
285
+ return { required: true, ok: true, issues: [], steps: [] };
286
+ }
287
+
288
+ return { required: true, ok: false, issues, steps };
289
+ }
290
+
291
+ /**
292
+ * @param {string} method
293
+ * @param {string} requestUrl
294
+ * @param {{basicAuth?: {user: string, password: string}, token?: string, body?: {[key: string]: any}}} [options]
295
+ * @returns {{ok: boolean, statusCode: number|null, data: any, error: string|null}}
296
+ */
297
+ function apiRequest(method: string, requestUrl: string, options: any = {}) {
298
+ const args = [
299
+ '-s',
300
+ '-X', method,
301
+ '-H', 'Content-Type: application/json',
302
+ '-w', '\n%{http_code}',
303
+ ];
304
+ if (options.basicAuth) {
305
+ args.push('-u', `${options.basicAuth.user}:${options.basicAuth.password}`);
306
+ }
307
+ if (options.token) {
308
+ args.push('-H', `Authorization: token ${options.token}`);
309
+ }
310
+ if (options.body) {
311
+ args.push('--data-binary', '@-');
312
+ }
313
+ args.push(requestUrl);
314
+
315
+ const result = spawnSync('curl', args, {
316
+ encoding: 'utf8',
317
+ input: options.body ? JSON.stringify(options.body) : undefined,
318
+ });
319
+
320
+ if (result.status !== 0 || !result.stdout) {
321
+ return {
322
+ ok: false,
323
+ statusCode: null,
324
+ data: null,
325
+ error: (result.stderr || result.stdout || 'curl failed').trim(),
326
+ };
327
+ }
328
+
329
+ const output = result.stdout.trim();
330
+ const lastLineIndex = output.lastIndexOf('\n');
331
+ const statusCode = Number(lastLineIndex === -1 ? output : output.slice(lastLineIndex + 1));
332
+ const payload = lastLineIndex === -1 ? '' : output.slice(0, lastLineIndex).trim();
333
+
334
+ let data = null;
335
+ if (payload) {
336
+ try {
337
+ data = JSON.parse(payload);
338
+ } catch (_) {
339
+ data = payload;
340
+ }
341
+ }
342
+
343
+ return {
344
+ ok: statusCode >= 200 && statusCode < 300,
345
+ statusCode,
346
+ data,
347
+ error: null,
348
+ };
349
+ }
350
+
351
+ /** @param {string} baseUrl @param {string} user @param {string} password @param {string} tokenName @param {Function} [requestFn] @param {string[]} [scopes] @returns {{ok: boolean, token?: string, error?: string, statusCode?: number|null, response?: any}} */
352
+ function createToken(baseUrl: string, user: string, password: string, tokenName: string, requestFn: Function = apiRequest, scopes: string[] = REVIEW_TOKEN_SCOPES) {
353
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
354
+ const result = requestFn('POST', `${normalizedBaseUrl}/api/v1/users/${encodeURIComponent(user)}/tokens`, {
355
+ basicAuth: { user, password },
356
+ body: { name: tokenName, scopes },
357
+ });
358
+ if (!result.ok) {
359
+ return {
360
+ ok: false,
361
+ error: `token creation failed for ${user} (HTTP ${result.statusCode || 'n/a'})`,
362
+ statusCode: result.statusCode,
363
+ response: result.data,
364
+ };
365
+ }
366
+ const token = result.data && result.data.sha1;
367
+ if (!token) {
368
+ return {
369
+ ok: false,
370
+ error: `token creation for ${user} did not return a token`,
371
+ statusCode: result.statusCode,
372
+ response: result.data,
373
+ };
374
+ }
375
+ return { ok: true, token };
376
+ }
377
+
378
+ /** @param {string} baseUrl @param {string} repoSlug @param {string} ownerLogin @param {string} ownerToken @param {Function} [requestFn] @returns {{ok: boolean, created?: boolean, error?: string, response?: any}} */
379
+ function ensureRepo(baseUrl: string, repoSlug: string, ownerLogin: string, ownerToken: string, requestFn: Function = apiRequest) {
380
+ const repoInfo = parseRepoSlug(repoSlug);
381
+ if (!repoInfo) {
382
+ return { ok: false, error: `invalid review repo slug: ${repoSlug}` };
383
+ }
384
+
385
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
386
+ const existing = requestFn('GET', `${normalizedBaseUrl}/api/v1/repos/${repoInfo.owner}/${repoInfo.repo}`, {
387
+ token: ownerToken,
388
+ });
389
+ if (existing.ok) {
390
+ return { ok: true, created: false };
391
+ }
392
+ if (existing.statusCode && existing.statusCode !== 404) {
393
+ return {
394
+ ok: false,
395
+ error: `failed to check review repo ${repoSlug} (HTTP ${existing.statusCode})`,
396
+ response: existing.data,
397
+ };
398
+ }
399
+
400
+ const payload = { name: repoInfo.repo, private: true, auto_init: false, default_branch: 'main' };
401
+ const createUrl = repoInfo.owner === ownerLogin
402
+ ? `${normalizedBaseUrl}/api/v1/user/repos`
403
+ : `${normalizedBaseUrl}/api/v1/orgs/${encodeURIComponent(repoInfo.owner)}/repos`;
404
+ const created = requestFn('POST', createUrl, {
405
+ token: ownerToken,
406
+ body: payload,
407
+ });
408
+ if (!created.ok) {
409
+ return {
410
+ ok: false,
411
+ error: `failed to create review repo ${repoSlug} (HTTP ${created.statusCode || 'n/a'})`,
412
+ response: created.data,
413
+ };
414
+ }
415
+ return { ok: true, created: true };
416
+ }
417
+
418
+ /** @param {string} baseUrl @param {string} repoSlug @param {string} ownerToken @param {string} collaborator @param {string} [permission] @param {Function} [requestFn] @returns {{ok: boolean, created?: boolean, skipped?: boolean, error?: string, response?: any}} */
419
+ function ensureRepoCollaborator(baseUrl: string, repoSlug: string, ownerToken: string, collaborator: string, permission: string = 'write', requestFn: Function = apiRequest) {
420
+ const repoInfo = parseRepoSlug(repoSlug);
421
+ if (!repoInfo) {
422
+ return { ok: false, error: `invalid review repo slug: ${repoSlug}` };
423
+ }
424
+ if (!collaborator || collaborator === repoInfo.owner) {
425
+ return { ok: true, skipped: true };
426
+ }
427
+
428
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
429
+ const result = requestFn('PUT', `${normalizedBaseUrl}/api/v1/repos/${repoInfo.owner}/${repoInfo.repo}/collaborators/${encodeURIComponent(collaborator)}`, {
430
+ token: ownerToken,
431
+ body: { permission },
432
+ });
433
+ if (!result.ok) {
434
+ return {
435
+ ok: false,
436
+ error: `failed to add review collaborator ${collaborator} to ${repoSlug} (HTTP ${result.statusCode || 'n/a'})`,
437
+ response: result.data,
438
+ };
439
+ }
440
+ return { ok: true, created: true };
441
+ }
442
+
443
+ /** @param {string} baseUrl @param {string} repoSlug @param {string} ownerToken @param {string[]} [collaborators] @param {string} [permission] @param {Function} [requestFn] @returns {{ok: boolean, created: string[]}} */
444
+ function ensureRepoCollaborators(baseUrl: string, repoSlug: string, ownerToken: string, collaborators: string[] = [], permission: string = 'write', requestFn: Function = apiRequest) {
445
+ const uniqueCollaborators = unique(collaborators.map(/** @param {string|boolean} user */ (user: string | boolean) => (typeof user === 'string' ? user.trim() : '')).filter(Boolean));
446
+ const created = [];
447
+ for (const collaborator of uniqueCollaborators) {
448
+ const result = ensureRepoCollaborator(baseUrl, repoSlug, ownerToken, collaborator, permission, requestFn);
449
+ if (!result.ok) {
450
+ return { ok: false, created: [] };
451
+ }
452
+ if (result.created) {
453
+ created.push(collaborator);
454
+ }
455
+ }
456
+ return { ok: true, created };
457
+ }
458
+
459
+ /** @param {string} rootDir @param {string} remoteName @param {string} remoteUrl @returns {{ok: boolean, created?: boolean, updated?: boolean, error?: string}} */
460
+ function ensureReviewRemote(rootDir: string, remoteName: string, remoteUrl: string): { ok: boolean; created?: boolean; updated?: boolean; error?: string } {
461
+ const current = readConfiguredReviewRemote(rootDir, remoteName);
462
+ const args = current
463
+ ? ['-C', rootDir, 'remote', 'set-url', remoteName, remoteUrl]
464
+ : ['-C', rootDir, 'remote', 'add', remoteName, remoteUrl];
465
+ const result = spawnSync('git', args, { encoding: 'utf8' });
466
+ if (result.status !== 0) {
467
+ return {
468
+ ok: false,
469
+ error: (result.stderr || result.stdout || `git remote ${current ? 'set-url' : 'add'} failed`).trim(),
470
+ };
471
+ }
472
+ return { ok: true, created: !current, updated: Boolean(current && current !== remoteUrl) };
473
+ }
474
+
475
+ /**
476
+ * @param {string} prompt
477
+ * @param {{hidden?: boolean, input?: NodeJS.ReadStream, output?: NodeJS.WriteStream}} [options]
478
+ * @returns {Promise<string>}
479
+ */
480
+ async function promptLine(prompt: string, options: { hidden?: boolean; input?: NodeJS.ReadStream; output?: NodeJS.WriteStream } = {}): Promise<string> {
481
+ const { hidden = false, input = process.stdin, output = process.stdout } = options;
482
+ return await new Promise((resolve) => {
483
+ const rl = readline.createInterface({ input, output, terminal: true });
484
+ if (!hidden) {
485
+ rl.question(prompt, answer => {
486
+ rl.close();
487
+ resolve(answer.trim());
488
+ });
489
+ return;
490
+ }
491
+
492
+ const originalWrite = output.write.bind(output);
493
+ const maskChunk = /** @param {string|Buffer} chunk */ (chunk: string | Buffer) => {
494
+ const value = typeof chunk === 'string' ? chunk : String(chunk);
495
+ let masked = '';
496
+ for (const character of value) {
497
+ if (character === '\r' || character === '\n') {
498
+ continue;
499
+ }
500
+ if (character === '\b' || character === '\x7f') {
501
+ masked += '\b \b';
502
+ continue;
503
+ }
504
+ if (character < ' ' || character === '\u001b') {
505
+ continue;
506
+ }
507
+ masked += '*';
508
+ }
509
+ return masked;
510
+ };
511
+ const rlAny = /** @type {any} */ (rl);
512
+ /** @type {Function} */
513
+ // @ts-expect-error overriding Writable.write with custom signature
514
+ output.write = /** @type {any} */ ((chunk: string | Buffer, encoding: BufferEncoding | undefined, cb: (() => void) | undefined) => {
515
+ // @ts-expect-error stdoutMuted is dynamically added
516
+ if (rlAny.stdoutMuted) {
517
+ const masked = maskChunk(/** @type {string | Buffer} */ (chunk));
518
+ if (masked) {
519
+ return /** @type {Function} */ (originalWrite)(masked, encoding, cb);
520
+ }
521
+ if (typeof cb === 'function') {cb();}
522
+ return true;
523
+ }
524
+ return /** @type {Function} */ (originalWrite)(chunk, encoding, cb);
525
+ });
526
+ // @ts-expect-error stdoutMuted is dynamically added
527
+ rlAny.stdoutMuted = true;
528
+ originalWrite(prompt);
529
+ rl.question('', answer => {
530
+ output.write = originalWrite;
531
+ rl.close();
532
+ output.write('\n');
533
+ resolve(answer.trim());
534
+ });
535
+ });
536
+ }
537
+
538
+ /** @param {{statusCode?: number}} result @returns {boolean} */
539
+ function isAuthFailure(result: { statusCode?: number }): boolean {
540
+ return Boolean(result && (result.statusCode === 401 || result.statusCode === 403));
541
+ }
542
+
543
+ /**
544
+ * @param {{baseUrl: string, repo: string, ownerLogin: string, ownerPassword: string}} setup
545
+ * @param {string} user
546
+ * @param {string} initialPassword
547
+ * @param {{allowBlank?: boolean, log?: Function, promptFn?: Function, maxAttempts?: number, requestFn?: Function, scopes?: string[]}} [options]
548
+ * @returns {Promise<{ok: boolean, token?: string, error?: string, skipped?: boolean, statusCode?: number|null, response?: any}>}
549
+ */
550
+ async function createTokenWithRetries(setup: any, user: string, initialPassword: string, options: any = {}) {
551
+ const {
552
+ allowBlank = false,
553
+ log = fmt.log.info,
554
+ promptFn,
555
+ maxAttempts = 3,
556
+ requestFn = apiRequest,
557
+ scopes = REVIEW_TOKEN_SCOPES,
558
+ } = options;
559
+
560
+ const repoInfo = parseRepoSlug(setup.repo);
561
+ if (!repoInfo) {
562
+ return { ok: false, error: `invalid review repo slug: ${setup.repo}` };
563
+ }
564
+
565
+ let password = typeof initialPassword === 'string' ? initialPassword : '';
566
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
567
+ if (!password) {
568
+ if (typeof promptFn !== 'function') {
569
+ return allowBlank
570
+ ? { ok: true, skipped: true }
571
+ : { ok: false, error: `Password is required for ${user}.` };
572
+ }
573
+ password = await promptFn(
574
+ passwordPrompt(user, { allowBlank }),
575
+ PASSWORD_PROMPT_OPTIONS
576
+ );
577
+ }
578
+
579
+ if (!password) {
580
+ if (allowBlank) {
581
+ return { ok: true, skipped: true };
582
+ }
583
+ if (attempt === maxAttempts) {
584
+ return { ok: false, error: `Password is required for ${user}.` };
585
+ }
586
+ log(fmt.status('WARN', `Password is required for ${user}. Attempt ${attempt} of ${maxAttempts}.`));
587
+ continue;
588
+ }
589
+
590
+ const tokenResult = createToken(
591
+ setup.baseUrl,
592
+ user,
593
+ password,
594
+ buildTokenName(repoInfo.repo, user),
595
+ requestFn,
596
+ scopes
597
+ );
598
+ if (tokenResult.ok) {return tokenResult;}
599
+ if (!isAuthFailure(/** @type {{statusCode?: number}} */ (tokenResult)) || attempt === maxAttempts || typeof promptFn !== 'function') {
600
+ return tokenResult;
601
+ }
602
+
603
+ log(fmt.status('WARN', `Forgejo authentication failed for ${user}. Attempt ${attempt} of ${maxAttempts}; please try again.`));
604
+ password = '';
605
+ }
606
+
607
+ return { ok: false, error: `token creation failed for ${user}` };
608
+ }
609
+
610
+ /**
611
+ * @param {string} [rootDir]
612
+ * @param {{promptFn?: Function, log?: Function, users?: string[]}} [options]
613
+ * @returns {Promise<{baseUrl: string, repo: string, ownerLogin: string, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[]}>}
614
+ */
615
+ async function collectSetupAnswers(rootDir?: string, options: any = {}) {
616
+ const {
617
+ promptFn = promptLine,
618
+ log = fmt.log.info,
619
+ users = suggestedForgejoUsers(),
620
+ } = options;
621
+ const review = resolveForgejoSettings(rootDir);
622
+ const repoInfo = parseRepoSlug(review.repo || '');
623
+ const defaultOwnerLogin = repoInfo ? repoInfo.owner : 'human';
624
+ const defaultUsers = users.join(',');
625
+
626
+ log(fmt.status('INFO', `Forgejo setup target: ${review.url || 'unset'} / ${review.repo || 'unset'}`));
627
+ log(fmt.status('INFO', 'Forgejo review bootstrap will ask for one repo-creating login password and then optional passwords for each listed agent user.'));
628
+ log(fmt.status('INFO', 'Leave an agent password blank to skip writing a token for that user on this machine.'));
629
+
630
+ const ownerLogin = (await promptFn(`Forgejo login with repo-create access [${defaultOwnerLogin}]: `)) || defaultOwnerLogin;
631
+ const ownerPassword = await promptFn(passwordPrompt(ownerLogin), PASSWORD_PROMPT_OPTIONS);
632
+ const userList = (await promptFn(`Agent Forgejo users [${defaultUsers}]: `)) || defaultUsers;
633
+ const agentUsers = unique(userList.split(',').map(/** @param {string} value */ (value: string) => value.trim()).filter(Boolean));
634
+ const agentPasswords = [];
635
+
636
+ for (const user of agentUsers) {
637
+ const password = await promptFn(passwordPrompt(user, { allowBlank: true }), PASSWORD_PROMPT_OPTIONS);
638
+ if (password) {
639
+ agentPasswords.push({ user, password });
640
+ }
641
+ }
642
+
643
+ return {
644
+ baseUrl: review.url,
645
+ repo: review.repo,
646
+ ownerLogin,
647
+ ownerPassword,
648
+ agentPasswords,
649
+ agentUsers,
650
+ };
651
+ }
652
+
653
+ /**
654
+ * @param {string} [rootDir]
655
+ * @param {{promptFn?: Function, log?: Function, users?: string[], loadWorkflowConfigFn?: Function, detectPrimaryBranchDefaultFn?: Function}} [options]
656
+ * @returns {Promise<{productName: string, ownerLogin: string, baseUrl: string, reviewRepo: string, reviewRemote: string, bootstrapReview: boolean, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[], tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string}>}
657
+ */
658
+ async function collectWizardAnswers(rootDir?: string, options: any = {}) {
659
+ const {
660
+ promptFn = promptLine,
661
+ log = fmt.log.info,
662
+ users = suggestedForgejoUsers(),
663
+ loadWorkflowConfigFn = loadWorkflowConfig,
664
+ detectPrimaryBranchDefaultFn = detectPrimaryBranchDefault,
665
+ } = options;
666
+
667
+ const existing = loadWorkflowConfigFn(rootDir);
668
+ const existingConfig = existing.found ? existing.config : null;
669
+ const existingAdapters = existingConfig && existingConfig.adapters ? existingConfig.adapters : {};
670
+ const existingReview = existingAdapters.review || {};
671
+ const existingTasks = existingAdapters.tasks || {};
672
+ const existingMissions = existingAdapters.missions || {};
673
+ const existingVerification = existingAdapters.verification || {};
674
+ const defaultPrimaryBranch = detectPrimaryBranchDefaultFn(rootDir, {
675
+ configuredPrimaryBranch: existingMissions.primaryBranch || null,
676
+ });
677
+
678
+ const productName = (await promptFn(`Product name [${(existingConfig && existingConfig.product && existingConfig.product.name) || defaultProductName(rootDir)}]: `))
679
+ || (existingConfig && existingConfig.product && existingConfig.product.name)
680
+ || defaultProductName(rootDir);
681
+ log(fmt.status('INFO', `Standard workflow layout: ${standardLayoutDescription()}`));
682
+ const useDefaults = parseYesNo(
683
+ await promptFn('Use the standard markdown task workflow layout and defaults? [Y/n]: '),
684
+ true
685
+ );
686
+
687
+ const ownerLoginDefault = parseRepoSlug(existingReview.repo || '')?.owner || 'human';
688
+ const ownerLogin = (await promptFn(`Forgejo login with repo-create access [${ownerLoginDefault}]: `)) || ownerLoginDefault;
689
+ const repoDefault = existingReview.repo || defaultRepoSlug(rootDir, ownerLogin);
690
+ const reviewRepo = (await promptFn(`Forgejo review repo [${repoDefault}]: `)) || repoDefault;
691
+ const baseUrl = normalizeBaseUrl((await promptFn(`Forgejo base URL [${existingReview.baseUrl || 'http://localhost:3300'}]: `)) || existingReview.baseUrl || 'http://localhost:3300');
692
+ const reviewRemote = (await promptFn(`Git review remote name [${existingReview.remote || 'review'}]: `)) || existingReview.remote || 'review';
693
+
694
+ if (!useDefaults) {
695
+ log(fmt.status('INFO',
696
+ 'Verification command runs the primary gate. Put the {{area}} token where the detected ' +
697
+ 'changed-area name (docs, workflow, server, ...) should be substituted. Typical patterns: ' +
698
+ '"./scripts/verify-local.sh {{area}}" (shell script), "npm run verify -- {{area}}" (npm ' +
699
+ 'script), or "make verify-{{area}}" (make target). Omit {{area}} to always run one command; ' +
700
+ 'leave it as `npm test` for the no-op-friendly default.'));
701
+ }
702
+
703
+ const defaults = useDefaults ? {
704
+ tasksProvider: 'backlog-md',
705
+ tasksStorage: 'backlog',
706
+ missionsBaseDir: 'docs/missions',
707
+ branchPrefix: 'mission/',
708
+ primaryBranch: defaultPrimaryBranch,
709
+ worktreePattern: '../<repo>-<slug>',
710
+ verificationCommand: 'npm test',
711
+ verificationDefaultArea: 'docs',
712
+ } : {
713
+ tasksProvider: (await promptFn(`Task provider [${existingTasks.provider || 'backlog-md'}]: `)) || existingTasks.provider || 'backlog-md',
714
+ tasksStorage: (await promptFn(`Task storage path [${existingTasks.storage || 'backlog'}]: `)) || existingTasks.storage || 'backlog',
715
+ missionsBaseDir: (await promptFn(`Mission base dir [${existingMissions.baseDir || 'docs/missions'}]: `)) || existingMissions.baseDir || 'docs/missions',
716
+ branchPrefix: (await promptFn(`Mission branch prefix [${existingMissions.branchPrefix || 'mission/'}]: `)) || existingMissions.branchPrefix || 'mission/',
717
+ primaryBranch: (await promptFn(`Primary branch [${defaultPrimaryBranch}]: `)) || defaultPrimaryBranch,
718
+ worktreePattern: (await promptFn(`Worktree pattern [${existingMissions.worktreePattern || '../<repo>-<slug>'}]: `)) || existingMissions.worktreePattern || '../<repo>-<slug>',
719
+ verificationCommand: (await promptFn(`Verification command [${existingVerification.command || 'npm test'}]: `)) || existingVerification.command || 'npm test',
720
+ verificationDefaultArea: (await promptFn(`Default verification area [${existingVerification.defaultArea || 'docs'}]: `)) || existingVerification.defaultArea || 'docs',
721
+ };
722
+
723
+ const bootstrapReview = parseYesNo(
724
+ await promptFn('Create/update the Forgejo repo, token files, and review remote now? This will ask for the repo-owner password and optional agent passwords. [Y/n]: '),
725
+ true
726
+ );
727
+
728
+ log(fmt.status('INFO', `Setup target: ${baseUrl} / ${reviewRepo}`));
729
+
730
+ let ownerPassword = '';
731
+ const agentPasswords = [];
732
+ const defaultUsers = users.join(',');
733
+ let agentUsers = users;
734
+ if (bootstrapReview) {
735
+ log(fmt.status('INFO', 'Forgejo bootstrap is enabled. Hidden password prompts are next: first for the repo-creating login, then once per listed agent user.'));
736
+ ownerPassword = await promptFn(passwordPrompt(ownerLogin), PASSWORD_PROMPT_OPTIONS);
737
+ const userList = (await promptFn(`Agent Forgejo users [${defaultUsers}]: `)) || defaultUsers;
738
+ agentUsers = unique(userList.split(',').map(/** @param {string} value */ (value: string) => value.trim()).filter(Boolean));
739
+ for (const user of agentUsers) {
740
+ const password = await promptFn(passwordPrompt(user, { allowBlank: true }), PASSWORD_PROMPT_OPTIONS);
741
+ if (password) {
742
+ agentPasswords.push({ user, password });
743
+ }
744
+ }
745
+ }
746
+
747
+ return /** @type {{productName: string, ownerLogin: string, baseUrl: string, reviewRepo: string, reviewRemote: string, bootstrapReview: boolean, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[], tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string}} */ ({
748
+ productName,
749
+ ownerLogin,
750
+ baseUrl,
751
+ reviewRepo,
752
+ reviewRemote,
753
+ bootstrapReview,
754
+ ownerPassword,
755
+ agentPasswords,
756
+ agentUsers,
757
+ ...defaults,
758
+ });
759
+ }
760
+
761
+ /** @param {string} user @param {string} token @param {string} [forgejoHome] @returns {string} */
762
+ function writeToken(user: string, token: string, forgejoHome?: string): string {
763
+ ensureTokenDir(forgejoHome);
764
+ const file = tokenFilePath(user, forgejoHome);
765
+ fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
766
+ return file;
767
+ }
768
+
769
+ /**
770
+ * Create Forgejo tokens for agent users using an existing owner token.
771
+ * This is the non-interactive bootstrap path: the owner token (admin-level)
772
+ * is used to authenticate POST requests to the Forgejo user-token API.
773
+ *
774
+ * @param {string} baseUrl - Forgejo base URL
775
+ * @param {string} repoSlug - Repository slug for token naming
776
+ * @param {string} ownerToken - Owner's Forgejo PAT
777
+ * @param {Array<{user: string, password: string}>} agentPasswords - Array of {user, password} to create tokens for
778
+ * @param {{owner: string, repo: string}} repoInfo - Parsed repo slug {owner, repo}
779
+ * @param {{requestFn?: Function, writeTokenFn?: Function, buildTokenNameFn?: Function, forgejoHome?: string}} options - Injected dependencies
780
+ * @returns {{ ok: boolean, createdTokens: Array<{user: string, path: string}>, warnings?: Array<{user: string, error: string, response?: any}>, error?: string }}
781
+ */
782
+ /**
783
+ * @param {string} baseUrl
784
+ * @param {string} repoSlug
785
+ * @param {string} ownerToken
786
+ * @param {Array<{user: string, password: string}>} agentPasswords
787
+ * @param {{owner: string, repo: string}} repoInfo
788
+ * @param {{requestFn?: Function, writeTokenFn?: Function, buildTokenNameFn?: Function, forgejoHome?: string}} [options]
789
+ * @returns {{ok: boolean, createdTokens: Array<{user: string, path: string}>, warnings?: Array<{user: string, error: string, response?: any}>, error?: string}}
790
+ */
791
+ function tokenCreateViaOwnerToken(baseUrl: string, repoSlug: string, ownerToken: string, agentPasswords: Array<{ user: string; password: string }>, repoInfo: { owner: string; repo: string }, options: any = {}) {
792
+ const { requestFn = apiRequest, writeTokenFn = writeToken, buildTokenNameFn = buildTokenName, forgejoHome: injectedForgejoHome } = options;
793
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
794
+ const forgejoHome = typeof injectedForgejoHome === 'string' ? injectedForgejoHome : resolveForgejoHome();
795
+
796
+ const createdTokens = [];
797
+ const warnings = [];
798
+
799
+ for (const agent of agentPasswords) {
800
+ const tokenName = buildTokenNameFn(repoInfo.repo, agent.user);
801
+ const result = requestFn('POST', `${normalizedBaseUrl}/api/v1/users/${encodeURIComponent(agent.user)}/tokens`, {
802
+ token: ownerToken,
803
+ body: { name: tokenName, scopes: REVIEW_TOKEN_SCOPES },
804
+ });
805
+ if (!result.ok) {
806
+ warnings.push({
807
+ user: agent.user,
808
+ error: `token creation via owner token failed (HTTP ${result.statusCode || 'n/a'})`,
809
+ response: result.data,
810
+ });
811
+ continue;
812
+ }
813
+ const token = result.data && result.data.sha1;
814
+ if (!token) {
815
+ warnings.push({
816
+ user: agent.user,
817
+ error: 'token creation via owner token did not return a token',
818
+ response: result.data,
819
+ });
820
+ continue;
821
+ }
822
+ const filePath = writeTokenFn(agent.user, token, forgejoHome);
823
+ createdTokens.push({ user: agent.user, path: filePath });
824
+ }
825
+
826
+ if (createdTokens.length === 0) {
827
+ const firstWarning = warnings[0];
828
+ const detail = firstWarning
829
+ ? `${firstWarning.user}: ${firstWarning.error}`
830
+ : 'no requested agent token was created';
831
+ return { ok: false, createdTokens, warnings, error: detail };
832
+ }
833
+
834
+ return { ok: true, createdTokens, warnings };
835
+ }
836
+
837
+ /**
838
+ * @param {string} rootDir
839
+ * @param {{baseUrl: string, repo: string, ownerLogin: string, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers?: string[]}} setup
840
+ * @param {{log?: Function, promptFn?: Function, requestFn?: Function, maxPasswordAttempts?: number, interactive?: boolean, forgejoHome?: string}} [options]
841
+ * @returns {Promise<{ok: boolean, warnings?: Array<{user: string, error: string, response?: any}>, createdTokens?: Array<{user: string, path: string}>, error?: string, response?: any}>}
842
+ */
843
+ async function bootstrapReviewSurface(rootDir: string, setup: any, options: any = {}) {
844
+ const {
845
+ log = fmt.log.info,
846
+ promptFn,
847
+ requestFn = apiRequest,
848
+ maxPasswordAttempts = 3,
849
+ interactive = true,
850
+ } = options;
851
+ const forgejoHome = resolveBootstrapForgejoHome(rootDir, options.forgejoHome);
852
+ const repoInfo = parseRepoSlug(setup.repo);
853
+ if (!setup.baseUrl || !repoInfo) {
854
+ return { ok: false, error: 'workflow.config.json must define adapters.review.baseUrl and adapters.review.repo before setup can run.' };
855
+ }
856
+
857
+ const baseUrl = normalizeBaseUrl(setup.baseUrl);
858
+ const collaboratorUsers = unique([
859
+ ...(Array.isArray(setup.agentUsers) ? setup.agentUsers : []),
860
+ ...(Array.isArray(setup.agentPasswords) ? setup.agentPasswords.map((agent: any) => agent.user) : []),
861
+ ].filter(Boolean));
862
+
863
+ if (interactive) {
864
+ // Original interactive path: prompt for owner password, create tokens for all users
865
+ const ownerTokenResult = await createTokenWithRetries({
866
+ ...setup,
867
+ baseUrl: /** @type {string} */ (baseUrl),
868
+ }, setup.ownerLogin, setup.ownerPassword, {
869
+ allowBlank: false,
870
+ log,
871
+ promptFn,
872
+ maxAttempts: maxPasswordAttempts,
873
+ requestFn,
874
+ });
875
+ if (!ownerTokenResult.ok) {return ownerTokenResult;}
876
+ const ownerToken = /** @type {string} */ ((ownerTokenResult as any).token);
877
+
878
+ const ownerTokenPath = writeToken(setup.ownerLogin, ownerToken, forgejoHome);
879
+ // @ts-expect-error setup.repo may be undefined at runtime
880
+ const repoResult = ensureRepo(baseUrl, setup.repo, setup.ownerLogin, ownerToken, requestFn);
881
+ if (!repoResult.ok) {return repoResult;}
882
+
883
+ // @ts-expect-error setup.repo may be undefined at runtime
884
+ const collaboratorResult = ensureRepoCollaborators(baseUrl, setup.repo, ownerToken, collaboratorUsers, 'write', requestFn);
885
+ if (!collaboratorResult.ok) {return collaboratorResult;}
886
+ if (collaboratorResult.created.length > 0) {
887
+ log(fmt.status('PASS', `Granted write access on ${setup.repo || ''} to ${collaboratorResult.created.join(', ')}`));
888
+ }
889
+
890
+ const createdTokens = [{ user: setup.ownerLogin, path: ownerTokenPath }];
891
+ const warnings = /** @type {Array<{user: string, error: string, response?: any}>} */ ([]);
892
+ for (const agent of setup.agentPasswords) {
893
+ const tokenResult = await createTokenWithRetries({
894
+ ...setup,
895
+ baseUrl: /** @type {string} */ (baseUrl),
896
+ }, agent.user, agent.password, {
897
+ allowBlank: true,
898
+ log,
899
+ promptFn,
900
+ maxAttempts: maxPasswordAttempts,
901
+ requestFn,
902
+ });
903
+ if (!tokenResult.ok) {
904
+ warnings.push({
905
+ user: agent.user,
906
+ error: /** @type {string} */ ((tokenResult as any).error),
907
+ response: (tokenResult as any).response,
908
+ });
909
+ continue;
910
+ }
911
+ if ((tokenResult as any).skipped) {continue;}
912
+ createdTokens.push({ user: agent.user, path: writeToken(agent.user, /** @type {string} */ ((tokenResult as any).token), forgejoHome) });
913
+ }
914
+
915
+ const remoteUrl = reviewRemoteUrl(rootDir);
916
+ const remoteName = resolveReviewAdapter(rootDir).remote || 'review';
917
+ const remoteResult = remoteUrl ? ensureReviewRemote(rootDir, remoteName, remoteUrl) : { ok: true, created: false, updated: false };
918
+ if (!remoteResult.ok) {return remoteResult;}
919
+
920
+ log(fmt.status('PASS', `Forgejo repo ${setup.repo} ${repoResult.created ? 'created' : 'already exists'}.`));
921
+ for (const tokenInfo of createdTokens) {
922
+ log(fmt.status('PASS', `Wrote Forgejo token for ${tokenInfo.user} to ${tokenInfo.path}`));
923
+ }
924
+ for (const warning of warnings) {
925
+ log(fmt.status('WARN', `Skipping Forgejo token for ${warning.user}: ${warning.error}`));
926
+ if (warning.response) {
927
+ log(fmt.status('INFO', `Forgejo response for ${warning.user}: ${JSON.stringify(warning.response)}`));
928
+ }
929
+ }
930
+ log(fmt.status('PASS', `Review remote "${remoteName}" now points at ${remoteUrl}`));
931
+
932
+ return { ok: true, warnings };
933
+ }
934
+
935
+ // Non-interactive path: use an existing owner token (e.g. human) to create
936
+ // tokens for agent users via the Forgejo user-token creation API.
937
+ // This is the bootstrap path called from handoff when an agent user's token
938
+ // is missing but another user has a valid token file.
939
+ const ownerTokenPath = path.join(forgejoHome, 'tokens', setup.ownerLogin);
940
+ let ownerToken = null;
941
+ if (fs.existsSync(ownerTokenPath)) {
942
+ ownerToken = fs.readFileSync(ownerTokenPath, 'utf8').trim();
943
+ }
944
+ if (!ownerToken) {
945
+ return { ok: false, error: `No owner token found for ${/** @type {string} */ (setup.ownerLogin)} at ${ownerTokenPath}. A token file for an existing user (typically human) is required to bootstrap new agent tokens.` };
946
+ }
947
+
948
+ // @ts-expect-error setup.repo may be undefined at runtime
949
+ const repoResult = ensureRepo(baseUrl, setup.repo, setup.ownerLogin, ownerToken, requestFn);
950
+ if (!repoResult.ok) {return repoResult;}
951
+
952
+ // @ts-expect-error setup.repo may be undefined at runtime
953
+ const collaboratorResult = ensureRepoCollaborators(baseUrl, setup.repo, ownerToken, collaboratorUsers, 'write', requestFn);
954
+ if (!collaboratorResult.ok) {return collaboratorResult;}
955
+ if (collaboratorResult.created.length > 0) {
956
+ log(fmt.status('PASS', `Granted write access on ${setup.repo} to ${collaboratorResult.created.join(', ')}`));
957
+ }
958
+
959
+ // @ts-expect-error setup.repo may be undefined at runtime
960
+ const tokensResult = tokenCreateViaOwnerToken(baseUrl, setup.repo, ownerToken, setup.agentPasswords, repoInfo, { requestFn, writeTokenFn: writeToken, forgejoHome, log });
961
+ if (!tokensResult.ok) {return tokensResult;}
962
+
963
+ const { createdTokens, warnings = [] } = /** @type {{createdTokens: Array<{user: string, path: string}>, warnings: Array<{user: string, error: string, response?: any}>}} */ (tokensResult);
964
+ for (const tokenInfo of createdTokens) {
965
+ log(fmt.status('PASS', `Wrote Forgejo token for ${tokenInfo.user} to ${tokenInfo.path}`));
966
+ }
967
+ for (const warning of warnings) {
968
+ log(fmt.status('WARN', `Skipping Forgejo token for ${warning.user}: ${warning.error}`));
969
+ if (warning.response) {
970
+ log(fmt.status('INFO', `Forgejo response for ${warning.user}: ${JSON.stringify(warning.response)}`));
971
+ }
972
+ }
973
+
974
+ const remoteUrl = reviewRemoteUrl(rootDir);
975
+ const remoteName = resolveReviewAdapter(rootDir).remote || 'review';
976
+ const remoteResult = remoteUrl ? ensureReviewRemote(rootDir, remoteName, remoteUrl) : { ok: true, created: false, updated: false };
977
+ if (!remoteResult.ok) {return remoteResult;}
978
+
979
+ log(fmt.status('PASS', `Forgejo repo ${/** @type {string} */ (setup.repo)} ${repoResult.created ? 'created' : 'already exists'}.`));
980
+ log(fmt.status('PASS', `Review remote "${remoteName}" now points at ${remoteUrl}`));
981
+
982
+ return { ok: true, warnings, createdTokens };
983
+ }
984
+
985
+ /**
986
+ * @param {any[]} _args
987
+ * @param {{rootDir?: string, log?: Function, error?: Function, exit?: Function, promptFn?: Function, forgejoHome?: string}} [options]
988
+ * @returns {Promise<void>}
989
+ */
990
+ async function setupReview(_args: any[], options: any = {}) {
991
+ const rootDir = options.rootDir || process.cwd();
992
+ const log = options.log || fmt.log.plain;
993
+ const error = options.error || fmt.log.plainError;
994
+ const exit = options.exit || process.exit;
995
+ const promptFn = options.promptFn || promptLine;
996
+ const setup = await collectSetupAnswers(rootDir, { promptFn, log });
997
+ const result = await bootstrapReviewSurface(rootDir, setup, {
998
+ ...options,
999
+ forgejoHome: resolveBootstrapForgejoHome(rootDir, options.forgejoHome),
1000
+ });
1001
+ if (!result.ok) {
1002
+ error(fmt.status('FAIL', /** @type {string} */ ((result as any).error || '')));
1003
+ if ((result as any).response) {
1004
+ error(fmt.status('INFO', `Forgejo response: ${JSON.stringify((result as any).response)}`));
1005
+ }
1006
+ exit(1);
1007
+ return;
1008
+ }
1009
+ }
1010
+
1011
+ /** @param {string} rootDir @param {{verifyFn?: Function}} [options] @returns {Promise<{status: number}>} */
1012
+ async function runVerifyEnv(rootDir: string, options: { verifyFn?: Function } = {}) {
1013
+ const verifyFn = options.verifyFn || /** @param {string} cwd @returns {{status: number}} */ ((/** @type {string} */ cwd: string) => spawnSync('node', ['workflow', 'verify-env'], {
1014
+ cwd,
1015
+ stdio: 'inherit',
1016
+ encoding: 'utf8',
1017
+ }));
1018
+ return verifyFn(rootDir);
1019
+ }
1020
+
1021
+ /** @param {string} rootDir @param {{log?: Function}} [options] @returns {{productName: string, reviewProvider: string, tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string, bootstrapReview: boolean, ownerLogin?: string, baseUrl?: string, reviewRepo?: string, reviewRemote?: string, ownerPassword?: string, agentPasswords?: Array<{user: string, password: string}>, agentUsers?: string[]}} */
1022
+ function buildNonInteractiveAnswers(rootDir: string, options: any = {}) {
1023
+ const log = options.log || fmt.log.plain;
1024
+ const env = process.env;
1025
+ const productName = env.WORKFLOW_SETUP_PRODUCT_NAME || defaultProductName(rootDir);
1026
+ const reviewProvider = env.WORKFLOW_SETUP_REVIEW_PROVIDER || 'none';
1027
+ const primaryBranch = detectPrimaryBranchDefault(rootDir);
1028
+
1029
+ const answers = {
1030
+ productName,
1031
+ reviewProvider,
1032
+ tasksProvider: 'backlog-md',
1033
+ tasksStorage: 'backlog',
1034
+ missionsBaseDir: 'docs/missions',
1035
+ branchPrefix: 'mission/',
1036
+ primaryBranch,
1037
+ worktreePattern: '../<repo>-<slug>',
1038
+ verificationCommand: 'npm test',
1039
+ verificationDefaultArea: 'docs',
1040
+ bootstrapReview: false,
1041
+ };
1042
+
1043
+ if (reviewProvider === 'forgejo') {
1044
+ const ownerLogin = env.WORKFLOW_SETUP_OWNER_LOGIN || 'human';
1045
+ const baseUrl = normalizeBaseUrl(env.WORKFLOW_SETUP_FORGEJO_URL || 'http://localhost:3300');
1046
+ const reviewRepo = env.WORKFLOW_SETUP_FORGEJO_REPO || defaultRepoSlug(rootDir, ownerLogin);
1047
+ const reviewRemote = env.WORKFLOW_SETUP_REVIEW_REMOTE || 'review';
1048
+ const ownerPassword = env.WORKFLOW_SETUP_OWNER_PASSWORD || '';
1049
+ const agentUserList = env.WORKFLOW_SETUP_AGENT_USERS
1050
+ ? env.WORKFLOW_SETUP_AGENT_USERS.split(',').map(u => u.trim()).filter(Boolean)
1051
+ : suggestedForgejoUsers();
1052
+ const agentPassword = env.WORKFLOW_SETUP_AGENT_PASSWORD || '';
1053
+ const agentPasswords = agentPassword
1054
+ ? agentUserList.map(user => ({ user, password: agentPassword }))
1055
+ : [];
1056
+ Object.assign(answers, {
1057
+ ownerLogin, baseUrl, reviewRepo, reviewRemote,
1058
+ bootstrapReview: Boolean(ownerPassword),
1059
+ ownerPassword, agentPasswords, agentUsers: agentUserList,
1060
+ });
1061
+ log(fmt.status('INFO', `Non-interactive setup: product=${productName}, repo=${reviewRepo}, url=${baseUrl}`));
1062
+ } else {
1063
+ log(fmt.status('INFO', `Non-interactive setup: product=${productName}, review=none (no Forgejo)`));
1064
+ }
1065
+
1066
+ return answers;
1067
+ }
1068
+
1069
+ /**
1070
+ * @param {any[]} _args
1071
+ * @param {{rootDir?: string, log?: Function, error?: Function, exit?: Function, promptFn?: Function, forgejoHome?: string}} [options]
1072
+ * @returns {Promise<void>}
1073
+ */
1074
+ async function setupWizard(_args: any[], options: any = {}) {
1075
+ const rootDir = options.rootDir || process.cwd();
1076
+ const log = options.log || fmt.log.plain;
1077
+ const error = options.error || fmt.log.plainError;
1078
+ const exit = options.exit || process.exit;
1079
+ const promptFn = options.promptFn || promptLine;
1080
+ const forgejoHome = resolveBootstrapForgejoHome(rootDir, options.forgejoHome);
1081
+
1082
+ const nonInteractive = (_args && _args.includes('--non-interactive')) || process.env.WORKFLOW_SETUP_NON_INTERACTIVE === '1';
1083
+ /** @type {{productName: string, ownerLogin: string, baseUrl: string, reviewRepo: string, reviewRemote: string, bootstrapReview: boolean, ownerPassword: string, agentPasswords: Array<{user: string, password: string}>, agentUsers: string[], tasksProvider: string, tasksStorage: string, missionsBaseDir: string, branchPrefix: string, primaryBranch: string, worktreePattern: string, verificationCommand: string, verificationDefaultArea: string}} */
1084
+ const answers: any = nonInteractive
1085
+ ? /** @type {any} */ (buildNonInteractiveAnswers(rootDir, { log }))
1086
+ : await collectWizardAnswers(rootDir, { ...options, promptFn, log });
1087
+ const configPath = writeWorkflowConfig(rootDir, buildWorkflowConfig(answers));
1088
+ log(fmt.status('PASS', `Wrote ${configPath}`));
1089
+
1090
+ const gitignoreResult = ensureWorkflowGitignore(rootDir, { logFn: log });
1091
+ if (gitignoreResult.created) {
1092
+ log(fmt.status('PASS', `Created .gitignore with ${gitignoreResult.appended} workflow entries in ${fmt.path(rootDir)}`));
1093
+ } else if (gitignoreResult.appended > 0) {
1094
+ log(fmt.status('PASS', `Appended ${gitignoreResult.appended} workflow entries to .gitignore in ${fmt.path(rootDir)}`));
1095
+ } else if (gitignoreResult.skipped) {
1096
+ log(fmt.status('INFO', `.gitignore in ${fmt.path(rootDir)}: ${gitignoreResult.reason === 'symlink' ? 'symbolic link (skipped)' : 'not a git repo (skipped)'}`));
1097
+ } else {
1098
+ log(fmt.status('PASS', `.gitignore in ${fmt.path(rootDir)} already contains all workflow entries`));
1099
+ }
1100
+
1101
+ if (answers.bootstrapReview) {
1102
+ const result = await bootstrapReviewSurface(rootDir, {
1103
+ baseUrl: answers.baseUrl,
1104
+ repo: answers.reviewRepo,
1105
+ ownerLogin: answers.ownerLogin,
1106
+ ownerPassword: answers.ownerPassword,
1107
+ agentPasswords: answers.agentPasswords,
1108
+ agentUsers: answers.agentUsers,
1109
+ }, {
1110
+ ...options,
1111
+ forgejoHome,
1112
+ });
1113
+ if (!result.ok) {
1114
+ error(fmt.status('FAIL', /** @type {string} */ ((result as any).error || '')));
1115
+ if ((result as any).response) {
1116
+ error(fmt.status('INFO', `Forgejo response: ${JSON.stringify((result as any).response)}`));
1117
+ }
1118
+ exit(1);
1119
+ return;
1120
+ }
1121
+ } else {
1122
+ log(fmt.status('INFO', 'Skipped Forgejo bootstrap. You can run `px setup-review` later if needed.'));
1123
+ }
1124
+
1125
+ const verifyResult = await runVerifyEnv(rootDir, /** @type {{verifyFn?: Function}} */ (options));
1126
+ if (verifyResult && typeof verifyResult.status === 'number' && verifyResult.status !== 0) {
1127
+ exit(verifyResult.status);
1128
+ }
1129
+ }
1130
+
1131
+ export { setupReview };
1132
+ export { setupWizard };
1133
+ export { apiRequest };
1134
+ export { buildWorkflowConfig };
1135
+ export { collectWizardAnswers };
1136
+ export { defaultProductName };
1137
+ export { defaultRepoSlug };
1138
+ export { parseYesNo };
1139
+ export { runVerifyEnv };
1140
+ export { bootstrapReviewSurface };
1141
+ export { collectSetupAnswers };
1142
+ export { createToken };
1143
+ export { ensureRepo };
1144
+ export { ensureReviewRemote };
1145
+ export { evaluateReviewSetup };
1146
+ export { parseRepoSlug };
1147
+ export { promptLine };
1148
+ export { tokenFilePath };
1149
+ export { writeToken };
1150
+ export { writeWorkflowConfig };
1151
+ export { resolveBootstrapForgejoHome };
1152
+ export { buildNonInteractiveAnswers };