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