@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.
- package/index.js +190 -185
- package/lib/agents/agents.js +737 -838
- package/lib/agents/agents.ts +930 -0
- package/lib/agents/claude-telemetry.js +149 -154
- package/lib/agents/claude-telemetry.ts +233 -0
- package/lib/agents/claude.js +121 -131
- package/lib/agents/claude.ts +156 -0
- package/lib/agents/codex-telemetry.js +176 -164
- package/lib/agents/codex-telemetry.ts +205 -0
- package/lib/agents/codex.js +168 -201
- package/lib/agents/codex.ts +236 -0
- package/lib/agents/limit-hit.js +216 -226
- package/lib/agents/limit-hit.ts +262 -0
- package/lib/agents/mistral-telemetry.js +7 -10
- package/lib/agents/mistral-telemetry.ts +44 -0
- package/lib/agents/mistral.js +40 -58
- package/lib/agents/mistral.ts +84 -0
- package/lib/agents/opencode-export.js +126 -127
- package/lib/agents/opencode-export.ts +160 -0
- package/lib/agents/opencode-telemetry.js +282 -261
- package/lib/agents/opencode-telemetry.ts +350 -0
- package/lib/agents/opencode.js +263 -305
- package/lib/agents/opencode.ts +370 -0
- package/lib/agents/stage-telemetry.js +23 -24
- package/lib/agents/stage-telemetry.ts +47 -0
- package/lib/commands/active.js +502 -579
- package/lib/commands/active.ts +665 -0
- package/lib/commands/checkpoint.js +98 -74
- package/lib/commands/checkpoint.ts +79 -0
- package/lib/commands/config.js +61 -31
- package/lib/commands/config.ts +47 -0
- package/lib/commands/coverage-gate.js +271 -246
- package/lib/commands/coverage-gate.ts +362 -0
- package/lib/commands/diff.js +140 -119
- package/lib/commands/diff.ts +125 -0
- package/lib/commands/draft.js +770 -923
- package/lib/commands/draft.ts +1021 -0
- package/lib/commands/handoff.js +588 -608
- package/lib/commands/handoff.ts +694 -0
- package/lib/commands/integrate.js +1332 -1463
- package/lib/commands/integrate.ts +1688 -0
- package/lib/commands/mission-start.js +279 -237
- package/lib/commands/mission-start.ts +263 -0
- package/lib/commands/rebase.js +606 -582
- package/lib/commands/rebase.ts +635 -0
- package/lib/commands/repair-handoff.js +181 -179
- package/lib/commands/repair-handoff.ts +228 -0
- package/lib/commands/resolve-conflict.js +121 -102
- package/lib/commands/resolve-conflict.ts +112 -0
- package/lib/commands/review.js +3 -4
- package/lib/commands/review.ts +14 -0
- package/lib/commands/setup-review.js +4 -5
- package/lib/commands/setup-review.ts +14 -0
- package/lib/commands/setup.js +3 -3
- package/lib/commands/setup.ts +2 -0
- package/lib/commands/stats-backfill.js +394 -359
- package/lib/commands/stats-backfill.ts +418 -0
- package/lib/commands/stats.js +1424 -1665
- package/lib/commands/stats.ts +2186 -0
- package/lib/commands/status.js +219 -200
- package/lib/commands/status.ts +219 -0
- package/lib/commands/verify.js +3 -1
- package/lib/commands/verify.ts +2 -0
- package/lib/core/fmt.js +140 -211
- package/lib/core/fmt.ts +189 -0
- package/lib/core/git.js +121 -102
- package/lib/core/git.ts +146 -0
- package/lib/core/gitignore.js +59 -106
- package/lib/core/gitignore.ts +103 -0
- package/lib/core/mission-utils.js +833 -875
- package/lib/core/mission-utils.ts +998 -0
- package/lib/core/nels.js +112 -127
- package/lib/core/nels.ts +189 -0
- package/lib/core/persistent-data-migration.js +247 -205
- package/lib/core/persistent-data-migration.ts +255 -0
- package/lib/core/product-config.js +400 -476
- package/lib/core/product-config.ts +518 -0
- package/lib/core/runtime-matrix.js +41 -87
- package/lib/core/runtime-matrix.ts +84 -0
- package/lib/core/spawn-tee.js +152 -192
- package/lib/core/spawn-tee.ts +202 -0
- package/lib/core/state-map.js +92 -92
- package/lib/core/state-map.ts +115 -0
- package/lib/core/storage.js +101 -129
- package/lib/core/storage.ts +182 -0
- package/lib/core/subagent-limit.js +15 -16
- package/lib/core/subagent-limit.ts +25 -0
- package/lib/core/verification.js +148 -138
- package/lib/core/verification.ts +200 -0
- package/lib/index.js +142 -59
- package/lib/index.ts +134 -0
- package/lib/review/rebase.js +142 -145
- package/lib/review/rebase.ts +188 -0
- package/lib/review/review-adapter.js +99 -87
- package/lib/review/review-adapter.ts +198 -0
- package/lib/review/review-artifacts.js +486 -629
- package/lib/review/review-artifacts.ts +622 -0
- package/lib/review/review-commands.js +1220 -1373
- package/lib/review/review-commands.ts +1579 -0
- package/lib/review/review-events.js +544 -886
- package/lib/review/review-events.ts +954 -0
- package/lib/review/review-loop.js +945 -1020
- package/lib/review/review-loop.ts +1174 -0
- package/lib/review/review-polling.js +86 -115
- package/lib/review/review-polling.ts +194 -0
- package/lib/review/review-prompts.js +154 -189
- package/lib/review/review-prompts.ts +182 -0
- package/lib/review/review-state.js +246 -225
- package/lib/review/review-state.ts +353 -0
- package/lib/review/review.js +63 -84
- package/lib/review/review.ts +138 -0
- package/lib/tools/backlog.js +694 -680
- package/lib/tools/backlog.ts +835 -0
- package/lib/tools/forgejo.js +1270 -1405
- package/lib/tools/forgejo.ts +1793 -0
- package/lib/tools/gatekeeper.js +106 -96
- package/lib/tools/gatekeeper.ts +124 -0
- package/lib/tools/redgreen.js +200 -181
- package/lib/tools/redgreen.ts +221 -0
- package/lib/tools/sessions.js +58 -52
- package/lib/tools/sessions.ts +81 -0
- package/lib/tools/setup-review.js +902 -975
- package/lib/tools/setup-review.ts +1152 -0
- package/package.json +7 -5
- package/prompts/draft.md +1 -0
- package/prompts/portfolio.md +1 -1
- package/px.js +226 -210
|
@@ -1,292 +1,328 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
67
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
182
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
||
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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) {
|
|
880
|
-
|
|
881
|
-
|
|
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) {
|
|
910
|
+
if (!collaboratorResult.ok) {
|
|
911
|
+
return collaboratorResult;
|
|
912
|
+
}
|
|
884
913
|
if (collaboratorResult.created.length > 0) {
|
|
885
|
-
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
923
|
+
log(fmt.status('PASS', `Wrote Forgejo token for ${tokenInfo.user} to ${tokenInfo.path}`));
|
|
921
924
|
}
|
|
922
925
|
for (const warning of warnings) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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;
|