forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8
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/CHANGELOG.md +24 -0
- package/bin/forge-gh-proxy.js +15 -0
- package/bin/forge-github-credential.js +21 -0
- package/bin/forge.js +16 -1
- package/docs/guides/MIGRATION.md +4 -3
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/VALIDATION.md +14 -0
- package/docs/reference/github-accounts.md +114 -38
- package/lib/bun-workflow-pins.js +129 -3
- package/lib/commands/github.js +234 -49
- package/lib/commands/push.js +298 -46
- package/lib/commands/release.js +18 -1
- package/lib/commands/test.js +8 -0
- package/lib/commands/validate.js +209 -69
- package/lib/gh-proxy.js +309 -0
- package/lib/github-context.js +160 -25
- package/lib/github-credential.js +47 -0
- package/lib/github-router.js +774 -0
- package/lib/native-gh.js +177 -0
- package/lib/npm-publish-workflow.js +5 -5
- package/lib/pr-monitor/flow-monitor.js +125 -26
- package/lib/protected-state-authority.js +214 -13
- package/lib/release-readiness.js +57 -41
- package/lib/shell-utils.js +41 -15
- package/lib/test-workflow.js +389 -0
- package/lib/validation-receipt.js +127 -12
- package/lib/workflow-templates/test.yml +654 -0
- package/package.json +7 -5
- package/scripts/check-forge-token.js +85 -67
- package/scripts/gen-embedded-assets.mjs +31 -5
- package/scripts/test-full-suite.js +69 -15
- package/scripts/test.js +10 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { randomUUID } = require('node:crypto');
|
|
7
|
+
const { execFileSync } = require('node:child_process');
|
|
8
|
+
const { isCompiledBinary } = require('./package-root');
|
|
9
|
+
|
|
10
|
+
const MARKER = 'forge-gh-router-v1';
|
|
11
|
+
const HELPER_NAME = 'forge-github-credential-v1';
|
|
12
|
+
const LOCK_NAME = '.forge-github-router.lock';
|
|
13
|
+
const REGISTRY_LOCK_NAME = '.forge-github-router-registry.lock';
|
|
14
|
+
const LOCK_OWNER = 'owner.json';
|
|
15
|
+
const LOCK_RECOVERY = 'recovery';
|
|
16
|
+
const REGISTRY_MARKER = 'forge-github-router-registry-v1';
|
|
17
|
+
const REGISTRY_NAME = 'github-router-clones.json';
|
|
18
|
+
const SHELL_SINGLE_QUOTE = String.raw`'\''`;
|
|
19
|
+
|
|
20
|
+
function isPackageRunnerBin(directory) {
|
|
21
|
+
return path.basename(directory).toLowerCase() === '.bin'
|
|
22
|
+
&& path.basename(path.dirname(directory)).toLowerCase() === 'node_modules';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shellQuote(value) {
|
|
26
|
+
return `'${String(value).replaceAll("'", SHELL_SINGLE_QUOTE)}'`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shellPath(value) {
|
|
30
|
+
return /^[A-Za-z]:[\\/]/.test(value) ? value.replaceAll('\\', '/') : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pathDirectories(value) {
|
|
34
|
+
return String(value).split(path.delimiter).map(directory => directory || '.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isCurrentForgeLauncher(candidate, options) {
|
|
38
|
+
const platformPath = (options.platform || process.platform) === 'win32' ? path.win32 : path.posix;
|
|
39
|
+
const entrypoint = platformPath.resolve(options.entrypointPath || process.argv[1]);
|
|
40
|
+
try {
|
|
41
|
+
if (fs.realpathSync(candidate) === fs.realpathSync(entrypoint)) return true;
|
|
42
|
+
} catch { /* Windows package shims are wrappers rather than links */ }
|
|
43
|
+
try {
|
|
44
|
+
const stat = fs.statSync(candidate);
|
|
45
|
+
if (!stat.isFile() || stat.size > 64 * 1024) return false;
|
|
46
|
+
const directory = path.dirname(candidate).replaceAll('\\', '/');
|
|
47
|
+
const text = fs.readFileSync(candidate, 'utf8').replaceAll('\\', '/')
|
|
48
|
+
.replaceAll(/%dp0%/gi, directory).replaceAll(/\$basedir\b/g, directory).toLowerCase();
|
|
49
|
+
return text.includes(entrypoint.replaceAll('\\', '/').toLowerCase());
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findForgeLauncher(options = {}) {
|
|
54
|
+
const platform = options.platform || process.platform;
|
|
55
|
+
const pathValue = options.pathEnv ?? process.env.PATH ?? '';
|
|
56
|
+
const names = platform === 'win32'
|
|
57
|
+
? ['forge-workflow.exe', 'forge-workflow.com', 'forge-workflow.bat', 'forge-workflow.cmd', 'forge.exe', 'forge.com', 'forge.bat', 'forge.cmd']
|
|
58
|
+
: ['forge-workflow', 'forge'];
|
|
59
|
+
for (const directory of pathDirectories(pathValue)) {
|
|
60
|
+
const name = names.find(candidate => {
|
|
61
|
+
try {
|
|
62
|
+
const launcher = path.join(directory, candidate);
|
|
63
|
+
fs.accessSync(launcher, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
64
|
+
return (options.isForgeLauncher || isCurrentForgeLauncher)(launcher, options);
|
|
65
|
+
} catch { return false; }
|
|
66
|
+
});
|
|
67
|
+
if (name && isPackageRunnerBin(directory)) {
|
|
68
|
+
const error = new Error('Install Forge on a stable PATH before enabling automatic GitHub routing; npx and bunx launchers are temporary.');
|
|
69
|
+
error.code = 'GITHUB_ROUTER_UNAVAILABLE';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
if (name) return path.resolve(directory, name);
|
|
73
|
+
}
|
|
74
|
+
const error = new Error('Forge must be installed on PATH before automatic GitHub routing can be enabled.');
|
|
75
|
+
error.code = 'GITHUB_ROUTER_UNAVAILABLE';
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findForgeBinDir(options = {}) {
|
|
80
|
+
if (options.binDir) return path.resolve(options.binDir);
|
|
81
|
+
if (options.compiled ?? isCompiledBinary()) return path.dirname(options.executablePath || process.execPath);
|
|
82
|
+
return path.dirname(findForgeLauncher(options));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function runtimeCommands(options = {}) {
|
|
86
|
+
const compiled = options.compiled ?? isCompiledBinary();
|
|
87
|
+
if (!options.runtimeCommand && !compiled) {
|
|
88
|
+
return {
|
|
89
|
+
credential: [process.execPath, options.credentialEntrypointPath || path.resolve(__dirname, '../bin/forge-github-credential.js')],
|
|
90
|
+
proxy: [process.execPath, options.proxyEntrypointPath || path.resolve(__dirname, '../bin/forge-gh-proxy.js')],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const forge = options.runtimeCommand || [options.executablePath || process.execPath];
|
|
94
|
+
return {
|
|
95
|
+
credential: [...forge, 'github', 'credential'],
|
|
96
|
+
proxy: [...forge, 'github', 'proxy'],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shellCommand(command) {
|
|
101
|
+
return command.map(value => shellQuote(shellPath(value))).join(' ');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cmdCommand(command) {
|
|
105
|
+
return command.map(value => `"${String(value).replaceAll('%', '%%').replaceAll('"', '""')}"`).join(' ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function powershellCommand(command) {
|
|
109
|
+
return command.map(value => `'${String(value).replaceAll("'", "''")}'`).join(' ');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function contentsFor(platform, commands) {
|
|
113
|
+
const proxyShell = shellCommand(commands.proxy);
|
|
114
|
+
const credentialShell = shellCommand(commands.credential);
|
|
115
|
+
const files = new Map([
|
|
116
|
+
['gh', `#!/bin/sh\n# ${MARKER}\nif [ "\${FORGE_GH_PROXY_ACTIVE:-}" = "1" ]; then echo "Forge GitHub router recursion blocked." >&2; exit 1; fi\nexport FORGE_GH_PROXY_ACTIVE=1\nexec ${proxyShell} -- "$@"\n`],
|
|
117
|
+
[HELPER_NAME, `#!/bin/sh\n# ${MARKER}\nexec ${credentialShell} "$@"\n`],
|
|
118
|
+
]);
|
|
119
|
+
if (platform === 'win32') {
|
|
120
|
+
const proxyCmd = cmdCommand(commands.proxy);
|
|
121
|
+
const proxyPs = powershellCommand(commands.proxy);
|
|
122
|
+
files.set('gh.cmd', `@echo off\r\nsetlocal\r\nrem ${MARKER}\r\nif "%FORGE_GH_PROXY_ACTIVE%"=="1" (echo Forge GitHub router recursion blocked. 1>&2 & exit /b 1)\r\nset "FORGE_GH_PROXY_ACTIVE=1"\r\n${proxyCmd} -- %*\r\nexit /b %ERRORLEVEL%\r\n`);
|
|
123
|
+
files.set('gh.ps1', `# ${MARKER}\nif ($env:FORGE_GH_PROXY_ACTIVE -eq '1') { Write-Error 'Forge GitHub router recursion blocked.'; exit 1 }\n$previousForgeGhProxyActive = $env:FORGE_GH_PROXY_ACTIVE\n$forgeGhProxyExitCode = 1\ntry {\n $env:FORGE_GH_PROXY_ACTIVE = '1'\n & ${proxyPs} -- @args\n $forgeGhProxyExitCode = $LASTEXITCODE\n} finally {\n if ($null -eq $previousForgeGhProxyActive) { Remove-Item Env:FORGE_GH_PROXY_ACTIVE -ErrorAction SilentlyContinue }\n else { $env:FORGE_GH_PROXY_ACTIVE = $previousForgeGhProxyActive }\n}\nexit $forgeGhProxyExitCode\n`);
|
|
124
|
+
}
|
|
125
|
+
return files;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isOwnedFile(file, fileSystem = fs) {
|
|
129
|
+
try { return fileSystem.readFileSync(file, 'utf8').includes(MARKER); } catch { return false; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isCurrentGeneratedFile(file, name, options = {}) {
|
|
133
|
+
const platform = options.platform || process.platform;
|
|
134
|
+
const fileSystem = options.fs || fs;
|
|
135
|
+
try {
|
|
136
|
+
const commands = runtimeCommands(options);
|
|
137
|
+
const command = name === HELPER_NAME ? commands.credential : commands.proxy;
|
|
138
|
+
if (!command?.length || !(path.posix.isAbsolute(command[0]) || path.win32.isAbsolute(command[0]))) return false;
|
|
139
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
140
|
+
const target = command[index];
|
|
141
|
+
if (!path.posix.isAbsolute(target) && !path.win32.isAbsolute(target)) continue;
|
|
142
|
+
if (!fileSystem.statSync(target).isFile()) return false;
|
|
143
|
+
const mode = index === 0 && platform !== 'win32' ? fs.constants.X_OK : fs.constants.R_OK;
|
|
144
|
+
fileSystem.accessSync(target, mode);
|
|
145
|
+
}
|
|
146
|
+
return fileSystem.readFileSync(file, 'utf8') === contentsFor(platform, commands).get(name);
|
|
147
|
+
} catch { return false; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function helperPathFromValue(value) {
|
|
151
|
+
const match = typeof value === 'string' ? /^!'(.*)'$/.exec(value) : null;
|
|
152
|
+
if (!match) return null;
|
|
153
|
+
const helperPath = match[1].replaceAll(String.raw`'\''`, "'");
|
|
154
|
+
const absolute = path.isAbsolute(helperPath) || path.win32.isAbsolute(helperPath);
|
|
155
|
+
const basename = path.basename(helperPath) === HELPER_NAME || path.win32.basename(helperPath) === HELPER_NAME;
|
|
156
|
+
return absolute && basename ? helperPath : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isOwnedCredentialHelperValue(value) {
|
|
160
|
+
const helperPath = helperPathFromValue(value);
|
|
161
|
+
return Boolean(helperPath && isOwnedFile(helperPath));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isManagedCredentialHelperValue(value, options = {}) {
|
|
165
|
+
const helperPath = helperPathFromValue(value);
|
|
166
|
+
const fileSystem = options.fs || fs;
|
|
167
|
+
if (!helperPath) return false;
|
|
168
|
+
if (isOwnedFile(helperPath, fileSystem)) return true;
|
|
169
|
+
try {
|
|
170
|
+
fileSystem.statSync(helperPath);
|
|
171
|
+
return false;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error?.code !== 'ENOENT') return false;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const platform = options.platform || process.platform;
|
|
177
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
|
178
|
+
const normalize = target => platform === 'win32' ? pathImpl.resolve(target).toLowerCase() : pathImpl.resolve(target);
|
|
179
|
+
const binDir = options.binDir ? pathImpl.resolve(options.binDir) : findForgeBinDir(options);
|
|
180
|
+
return normalize(helperPath) === normalize(pathImpl.join(binDir, HELPER_NAME));
|
|
181
|
+
} catch { return false; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isExecutableFile(file, platform, fileSystem) {
|
|
185
|
+
if (platform === 'win32') return true;
|
|
186
|
+
try { fileSystem.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isUsableCredentialHelperValue(value, options = {}) {
|
|
190
|
+
const helperPath = helperPathFromValue(value);
|
|
191
|
+
const fileSystem = options.fs || fs;
|
|
192
|
+
return Boolean(helperPath && isOwnedFile(helperPath, fileSystem)
|
|
193
|
+
&& isExecutableFile(helperPath, options.platform || process.platform, fileSystem)
|
|
194
|
+
&& (options.isCurrentRouterFile || isCurrentGeneratedFile)(helperPath, HELPER_NAME, options));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function assertRouterPrecedence(binDir, options) {
|
|
198
|
+
if (options.binDir) return;
|
|
199
|
+
const platform = options.platform || process.platform;
|
|
200
|
+
const fileSystem = options.fs || fs;
|
|
201
|
+
const names = platform === 'win32' ? ['gh.exe', 'gh.com', 'gh.bat', 'gh.cmd', 'gh.ps1', 'gh'] : ['gh'];
|
|
202
|
+
const directories = pathDirectories(options.pathEnv ?? process.env.PATH ?? '');
|
|
203
|
+
const normalize = value => platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
204
|
+
for (const directory of directories) {
|
|
205
|
+
if (normalize(directory) === normalize(binDir)) return;
|
|
206
|
+
if (names.some(name => {
|
|
207
|
+
const candidate = path.join(directory, name);
|
|
208
|
+
return fileSystem.existsSync(candidate) && isExecutableFile(candidate, platform, fileSystem);
|
|
209
|
+
})) {
|
|
210
|
+
const error = new Error('A GitHub CLI executable appears before Forge on PATH.');
|
|
211
|
+
error.code = 'GITHUB_ROUTER_SHADOWED';
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const error = new Error('Forge launcher directory is not on PATH.');
|
|
216
|
+
error.code = 'GITHUB_ROUTER_UNAVAILABLE';
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getGithubRouterStatus(options = {}) {
|
|
221
|
+
const platform = options.platform || process.platform;
|
|
222
|
+
const fileSystem = options.fs || fs;
|
|
223
|
+
const names = platform === 'win32' ? ['gh.exe', 'gh.com', 'gh.bat', 'gh.cmd', 'gh.ps1', 'gh'] : ['gh'];
|
|
224
|
+
const directories = pathDirectories(options.pathEnv ?? process.env.PATH ?? '');
|
|
225
|
+
let ownedUnusable = false;
|
|
226
|
+
for (const directory of directories) {
|
|
227
|
+
for (const name of names) {
|
|
228
|
+
const candidate = path.join(directory, name);
|
|
229
|
+
if (fileSystem.existsSync(candidate)) {
|
|
230
|
+
if (!isExecutableFile(candidate, platform, fileSystem)) {
|
|
231
|
+
ownedUnusable ||= isOwnedFile(candidate, fileSystem);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!isOwnedFile(candidate, fileSystem)) return 'shadowed';
|
|
235
|
+
return (options.isCurrentRouterFile || isCurrentGeneratedFile)(candidate, name, options) ? 'ready' : 'unusable';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return ownedUnusable ? 'unusable' : 'missing';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertInstallTargetsAvailable(platform, binDir, files) {
|
|
243
|
+
const conflicts = platform === 'win32' ? ['gh.exe', 'gh.com', 'gh.bat'] : [];
|
|
244
|
+
for (const name of conflicts) {
|
|
245
|
+
if (fs.existsSync(path.join(binDir, name))) {
|
|
246
|
+
const error = new Error(`A GitHub CLI launcher already exists beside Forge: ${name}`);
|
|
247
|
+
error.code = 'GITHUB_ROUTER_CONFLICT';
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const name of files.keys()) {
|
|
252
|
+
const target = path.join(binDir, name);
|
|
253
|
+
if (fs.existsSync(target) && !isOwnedFile(target)) {
|
|
254
|
+
const error = new Error(`Refusing to replace existing GitHub CLI launcher: ${target}`);
|
|
255
|
+
error.code = 'GITHUB_ROUTER_CONFLICT';
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function processIsRunning(pid, probe = process.kill.bind(process)) {
|
|
262
|
+
try { probe(pid, 0); return true; } catch (error) { return error?.code !== 'ESRCH'; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readLockOwner(lock, fileSystem) {
|
|
266
|
+
try {
|
|
267
|
+
const owner = JSON.parse(fileSystem.readFileSync(path.join(lock, LOCK_OWNER), 'utf8'));
|
|
268
|
+
return owner?.marker === MARKER && Number.isInteger(owner.pid) && owner.pid > 0 && typeof owner.token === 'string'
|
|
269
|
+
? owner : null;
|
|
270
|
+
} catch { return null; }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function reclaimStaleRouterLock(lock, fileSystem, isRunning) {
|
|
274
|
+
const owner = readLockOwner(lock, fileSystem);
|
|
275
|
+
if (!owner || isRunning(owner.pid)) return false;
|
|
276
|
+
const recovery = path.join(lock, LOCK_RECOVERY);
|
|
277
|
+
try { fileSystem.mkdirSync(recovery); } catch { return false; }
|
|
278
|
+
try {
|
|
279
|
+
const current = readLockOwner(lock, fileSystem);
|
|
280
|
+
if (!current || current.token !== owner.token || isRunning(current.pid)) return false;
|
|
281
|
+
fileSystem.unlinkSync(path.join(lock, LOCK_OWNER));
|
|
282
|
+
fileSystem.rmdirSync(recovery);
|
|
283
|
+
fileSystem.rmdirSync(lock);
|
|
284
|
+
return true;
|
|
285
|
+
} finally {
|
|
286
|
+
try { if (fileSystem.existsSync(recovery)) fileSystem.rmdirSync(recovery); } catch { /* another operation owns recovery */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function publishRouterLock(lock, owner, fileSystem) {
|
|
291
|
+
const candidate = `${lock}.${owner.pid}.${owner.token}.pending`;
|
|
292
|
+
fileSystem.mkdirSync(candidate);
|
|
293
|
+
try {
|
|
294
|
+
fileSystem.writeFileSync(path.join(candidate, LOCK_OWNER), JSON.stringify(owner), { mode: 0o600, flag: 'wx' });
|
|
295
|
+
if (fileSystem.existsSync(lock)) {
|
|
296
|
+
const error = new Error('Router lock already exists.');
|
|
297
|
+
error.code = 'EEXIST';
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
fileSystem.renameSync(candidate, lock);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
try { if (fileSystem.existsSync(path.join(candidate, LOCK_OWNER))) fileSystem.unlinkSync(path.join(candidate, LOCK_OWNER)); } catch { /* preserve acquisition failure */ }
|
|
303
|
+
try { if (fileSystem.existsSync(candidate)) fileSystem.rmdirSync(candidate); } catch { /* preserve acquisition failure */ }
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function acquireRouterLock(binDir, fileSystem = fs, isRunning = processIsRunning, lockName = LOCK_NAME) {
|
|
309
|
+
const lock = path.join(binDir, lockName);
|
|
310
|
+
const owner = { marker: MARKER, pid: process.pid, token: randomUUID() };
|
|
311
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
312
|
+
try {
|
|
313
|
+
publishRouterLock(lock, owner, fileSystem);
|
|
314
|
+
break;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (!fileSystem.existsSync(lock)) throw error;
|
|
317
|
+
if (attempt === 0 && reclaimStaleRouterLock(lock, fileSystem, isRunning)) continue;
|
|
318
|
+
const failure = new Error('Another Forge GitHub router operation is in progress. Retry after it finishes; remove the lock only after confirming its owner is gone.');
|
|
319
|
+
failure.code = 'GITHUB_ROUTER_BUSY';
|
|
320
|
+
throw failure;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const currentOwner = readLockOwner(lock, fileSystem);
|
|
324
|
+
if (!currentOwner || currentOwner.token !== owner.token) {
|
|
325
|
+
const failure = new Error('Another Forge GitHub router operation is in progress. Retry after it finishes; remove the lock only after confirming its owner is gone.');
|
|
326
|
+
failure.code = 'GITHUB_ROUTER_BUSY';
|
|
327
|
+
throw failure;
|
|
328
|
+
}
|
|
329
|
+
let held = true;
|
|
330
|
+
return () => {
|
|
331
|
+
if (!held) return;
|
|
332
|
+
const current = readLockOwner(lock, fileSystem);
|
|
333
|
+
if (!current || current.token !== owner.token) return;
|
|
334
|
+
const released = `${lock}.${owner.token}.released`;
|
|
335
|
+
fileSystem.renameSync(lock, released);
|
|
336
|
+
held = false;
|
|
337
|
+
try { fileSystem.unlinkSync(path.join(released, LOCK_OWNER)); } catch { /* lock is already released */ }
|
|
338
|
+
try { fileSystem.rmdirSync(released); } catch { /* lock is already released */ }
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function releaseRouterLocks(releases) {
|
|
343
|
+
let failure;
|
|
344
|
+
for (const release of releases.toReversed()) {
|
|
345
|
+
try { release(); } catch (error) { failure ||= error; }
|
|
346
|
+
}
|
|
347
|
+
if (failure) throw failure;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function stageRouterFile(target, content, mode, fileSystem = fs) {
|
|
351
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
352
|
+
fileSystem.writeFileSync(temporary, content, { mode, flag: 'wx' });
|
|
353
|
+
fileSystem.chmodSync(temporary, mode);
|
|
354
|
+
return temporary;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function replaceRouterFile(target, content, mode, fileSystem = fs) {
|
|
358
|
+
const temporary = stageRouterFile(target, content, mode, fileSystem);
|
|
359
|
+
try { fileSystem.renameSync(temporary, target); } finally {
|
|
360
|
+
try { if (fileSystem.existsSync(temporary)) fileSystem.unlinkSync(temporary); } catch { /* preserve the publication failure */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function registryPath(options = {}) {
|
|
365
|
+
return options.registryPath || path.join(options.stateDir || path.join(os.homedir(), '.forge'), REGISTRY_NAME);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function acquireRegistryLock(options = {}) {
|
|
369
|
+
const fileSystem = options.fileSystem || fs;
|
|
370
|
+
const directory = path.dirname(registryPath(options));
|
|
371
|
+
fileSystem.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
372
|
+
return acquireRouterLock(directory, fileSystem, options.isProcessRunning, REGISTRY_LOCK_NAME);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function registryFailure(message = 'Forge cannot verify its automatic-routing registry.') {
|
|
376
|
+
const error = new Error(message);
|
|
377
|
+
error.code = 'GITHUB_ROUTER_REGISTRY_INVALID';
|
|
378
|
+
return error;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function pathImplementation(options) {
|
|
382
|
+
return (options.registryPlatform || process.platform) === 'win32' ? path.win32 : path.posix;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function canonicalPath(value, options = {}) {
|
|
386
|
+
if (typeof value !== 'string' || !value) throw registryFailure();
|
|
387
|
+
const pathImpl = pathImplementation(options);
|
|
388
|
+
if (!pathImpl.isAbsolute(value)) throw registryFailure();
|
|
389
|
+
const fileSystem = options.fileSystem || fs;
|
|
390
|
+
const absolute = pathImpl.resolve(value);
|
|
391
|
+
try {
|
|
392
|
+
const realpath = fileSystem.realpathSync.native || fileSystem.realpathSync;
|
|
393
|
+
return realpath(absolute);
|
|
394
|
+
} catch { return absolute; }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function registryKey(value, options = {}) {
|
|
398
|
+
const normalized = value.replaceAll('\\', '/');
|
|
399
|
+
return (options.registryPlatform || process.platform) === 'win32' ? normalized.toLowerCase() : normalized;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function runRegistryGit(args, options = {}, preserveTrailing = false) {
|
|
403
|
+
const run = options.registryRunner || execFileSync;
|
|
404
|
+
const output = String(run('git', args, {
|
|
405
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 3000,
|
|
406
|
+
}) || '');
|
|
407
|
+
return preserveTrailing ? output : output.trimEnd();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function resolveGitCommonDir(projectRoot, options = {}) {
|
|
411
|
+
if (options.gitCommonDir) return canonicalPath(options.gitCommonDir, options);
|
|
412
|
+
try {
|
|
413
|
+
const value = runRegistryGit(['-C', projectRoot, 'rev-parse', '--path-format=absolute', '--git-common-dir'], options);
|
|
414
|
+
return canonicalPath(value, options);
|
|
415
|
+
} catch { throw registryFailure('Forge cannot resolve this clone\'s Git common directory.'); }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function resolveCloneIdentity(projectRoot, options = {}) {
|
|
419
|
+
let identity;
|
|
420
|
+
try {
|
|
421
|
+
identity = options.resolveCloneIdentity
|
|
422
|
+
? options.resolveCloneIdentity(projectRoot)
|
|
423
|
+
: {
|
|
424
|
+
root: runRegistryGit(['-C', projectRoot, 'rev-parse', '--show-toplevel'], options),
|
|
425
|
+
gitCommonDir: resolveGitCommonDir(projectRoot, options),
|
|
426
|
+
};
|
|
427
|
+
return {
|
|
428
|
+
root: canonicalPath(identity.root, options),
|
|
429
|
+
gitCommonDir: canonicalPath(identity.gitCommonDir, options),
|
|
430
|
+
};
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (error?.code === 'GITHUB_ROUTER_REGISTRY_INVALID') throw error;
|
|
433
|
+
throw registryFailure('Forge cannot resolve this clone for automatic routing.');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseRegistry(content, options = {}) {
|
|
438
|
+
let parsed;
|
|
439
|
+
try { parsed = JSON.parse(content); } catch { throw registryFailure(); }
|
|
440
|
+
if (parsed?.marker !== REGISTRY_MARKER || !Array.isArray(parsed.clones)) throw registryFailure();
|
|
441
|
+
const seen = new Set();
|
|
442
|
+
const clones = parsed.clones.map(entry => {
|
|
443
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)
|
|
444
|
+
|| Object.keys(entry).some(key => !['root', 'gitCommonDir'].includes(key))) throw registryFailure();
|
|
445
|
+
const clone = {
|
|
446
|
+
root: canonicalPath(entry.root, options),
|
|
447
|
+
gitCommonDir: canonicalPath(entry.gitCommonDir, options),
|
|
448
|
+
};
|
|
449
|
+
const key = registryKey(clone.gitCommonDir, options);
|
|
450
|
+
if (seen.has(key)) throw registryFailure();
|
|
451
|
+
seen.add(key);
|
|
452
|
+
return clone;
|
|
453
|
+
});
|
|
454
|
+
return { marker: REGISTRY_MARKER, clones };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function readRegistry(options = {}, allowMissing = false) {
|
|
458
|
+
const fileSystem = options.fileSystem || fs;
|
|
459
|
+
try { return parseRegistry(fileSystem.readFileSync(registryPath(options), 'utf8'), options); } catch (error) {
|
|
460
|
+
if (error?.code === 'ENOENT' && allowMissing) return null;
|
|
461
|
+
if (error?.code === 'GITHUB_ROUTER_REGISTRY_INVALID') throw error;
|
|
462
|
+
throw registryFailure();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function writeRegistry(registry, options = {}) {
|
|
467
|
+
const fileSystem = options.fileSystem || fs;
|
|
468
|
+
const target = registryPath(options);
|
|
469
|
+
fileSystem.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
470
|
+
const clones = [...registry.clones].sort((left, right) => registryKey(left.gitCommonDir, options)
|
|
471
|
+
.localeCompare(registryKey(right.gitCommonDir, options)));
|
|
472
|
+
replaceRouterFile(target, `${JSON.stringify({ marker: REGISTRY_MARKER, clones }, null, 2)}\n`, 0o600, fileSystem);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function snapshotRegistry(options = {}) {
|
|
476
|
+
const fileSystem = options.fileSystem || fs;
|
|
477
|
+
const target = registryPath(options);
|
|
478
|
+
try {
|
|
479
|
+
const stat = fileSystem.statSync(target);
|
|
480
|
+
return { content: fileSystem.readFileSync(target), mode: stat.mode };
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (error?.code === 'ENOENT') return null;
|
|
483
|
+
throw registryFailure();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function restoreRegistry(snapshot, options = {}) {
|
|
488
|
+
const fileSystem = options.fileSystem || fs;
|
|
489
|
+
const target = registryPath(options);
|
|
490
|
+
if (snapshot) replaceRouterFile(target, snapshot.content, snapshot.mode, fileSystem);
|
|
491
|
+
else {
|
|
492
|
+
try { fileSystem.unlinkSync(target); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function registerClone(registry, identity, options = {}) {
|
|
497
|
+
const key = registryKey(identity.gitCommonDir, options);
|
|
498
|
+
return { marker: REGISTRY_MARKER,
|
|
499
|
+
clones: [...registry.clones.filter(entry => registryKey(entry.gitCommonDir, options) !== key), identity] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function registryForRegistration(binDir, options = {}) {
|
|
503
|
+
const registry = readRegistry(options, true);
|
|
504
|
+
if (registry) return registry;
|
|
505
|
+
const platform = options.platform || process.platform;
|
|
506
|
+
const names = platform === 'win32' ? ['gh', 'gh.cmd', 'gh.ps1', HELPER_NAME] : ['gh', HELPER_NAME];
|
|
507
|
+
const directories = [binDir];
|
|
508
|
+
if (options.pathEnv !== undefined || !options.binDir) {
|
|
509
|
+
directories.push(...pathDirectories(options.pathEnv ?? process.env.PATH ?? ''));
|
|
510
|
+
}
|
|
511
|
+
if (ownedRouterTargets([...new Set(directories.map(directory => path.resolve(directory)))], names,
|
|
512
|
+
options.fileSystem || fs).length) {
|
|
513
|
+
throw registryFailure('Forge router files exist but the automatic-routing registry is missing.');
|
|
514
|
+
}
|
|
515
|
+
return { marker: REGISTRY_MARKER, clones: [] };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function unregisterClone(registry, gitCommonDir, options = {}) {
|
|
519
|
+
const key = registryKey(gitCommonDir, options);
|
|
520
|
+
return { marker: REGISTRY_MARKER,
|
|
521
|
+
clones: registry.clones.filter(entry => registryKey(entry.gitCommonDir, options) !== key) };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function releaseWithWarning(release, message) {
|
|
525
|
+
try { release(); return {}; } catch { return { warning: message }; }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function registerGithubRouterClone(projectRoot, options = {}) {
|
|
529
|
+
const binDir = findForgeBinDir(options);
|
|
530
|
+
const releases = [];
|
|
531
|
+
let warning;
|
|
532
|
+
try {
|
|
533
|
+
releases.push(acquireRouterLock(binDir, options.fileSystem || fs, options.isProcessRunning));
|
|
534
|
+
releases.push(acquireRegistryLock(options));
|
|
535
|
+
const registry = registryForRegistration(binDir, options);
|
|
536
|
+
writeRegistry(registerClone(registry, resolveCloneIdentity(projectRoot, options), options), options);
|
|
537
|
+
} finally {
|
|
538
|
+
warning = releaseWithWarning(() => releaseRouterLocks(releases), 'Clone registered, but router lock cleanup failed.').warning;
|
|
539
|
+
}
|
|
540
|
+
return warning ? { warning } : {};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function unregisterGithubRouterClone(projectRoot, options = {}) {
|
|
544
|
+
const binDir = findForgeBinDir(options);
|
|
545
|
+
const releases = [];
|
|
546
|
+
let warning;
|
|
547
|
+
try {
|
|
548
|
+
releases.push(acquireRouterLock(binDir, options.fileSystem || fs, options.isProcessRunning));
|
|
549
|
+
releases.push(acquireRegistryLock(options));
|
|
550
|
+
const registry = readRegistry(options, true);
|
|
551
|
+
if (registry) writeRegistry(unregisterClone(registry, resolveGitCommonDir(projectRoot, options), options), options);
|
|
552
|
+
} finally {
|
|
553
|
+
warning = releaseWithWarning(() => releaseRouterLocks(releases), 'Clone unregistered, but router lock cleanup failed.').warning;
|
|
554
|
+
}
|
|
555
|
+
return warning ? { warning } : {};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function assertGithubRouterCloneRegistered(projectRoot, options = {}) {
|
|
559
|
+
const gitCommonDir = resolveGitCommonDir(projectRoot, options);
|
|
560
|
+
const key = registryKey(gitCommonDir, options);
|
|
561
|
+
const registered = readRegistry(options).clones.some(entry => registryKey(entry.gitCommonDir, options) === key);
|
|
562
|
+
if (!registered) throw registryFailure('This clone is not registered for automatic GitHub routing.');
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function directoryState(directory, options) {
|
|
567
|
+
try { return (options.fileSystem || fs).statSync(directory).isDirectory() ? 'present' : 'missing'; } catch (error) {
|
|
568
|
+
if (error?.code === 'ENOENT') return 'missing';
|
|
569
|
+
throw registryFailure();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function registeredAutoState(entry, options) {
|
|
574
|
+
if (directoryState(entry.gitCommonDir, options) === 'missing') {
|
|
575
|
+
throw registryFailure('Forge cannot verify a registered clone because its Git directory is unavailable.');
|
|
576
|
+
}
|
|
577
|
+
let output;
|
|
578
|
+
try {
|
|
579
|
+
output = runRegistryGit(['--git-dir', entry.gitCommonDir, 'config', '--local', '--get-all', 'github.auto'], options, true);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if (error?.status === 1) return 'disabled';
|
|
582
|
+
throw registryFailure('Forge cannot verify whether a registered clone still uses automatic routing.');
|
|
583
|
+
}
|
|
584
|
+
const values = output.replace(/\r?\n$/, '').split(/\r?\n/);
|
|
585
|
+
if (values.length === 1 && values[0].toLowerCase() === 'true') return 'enabled';
|
|
586
|
+
if (values.length === 1 && values[0].toLowerCase() === 'false') return 'disabled';
|
|
587
|
+
throw registryFailure('A registered clone has invalid automatic-routing state.');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function usableRoot(entry, options) {
|
|
591
|
+
if (directoryState(entry.root, options) === 'present') return canonicalPath(entry.root, options);
|
|
592
|
+
const pathImpl = pathImplementation(options);
|
|
593
|
+
const mainRoot = pathImpl.basename(entry.gitCommonDir).toLowerCase() === '.git'
|
|
594
|
+
? pathImpl.dirname(entry.gitCommonDir) : null;
|
|
595
|
+
return mainRoot && directoryState(mainRoot, options) === 'present' ? canonicalPath(mainRoot, options) : entry.root;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function pruneRegistry(options = {}, allowMissing = false) {
|
|
599
|
+
const registry = readRegistry(options, allowMissing);
|
|
600
|
+
if (!registry) return { marker: REGISTRY_MARKER, clones: [] };
|
|
601
|
+
const active = [];
|
|
602
|
+
for (const entry of registry.clones) {
|
|
603
|
+
if (registeredAutoState(entry, options) === 'enabled') active.push({ ...entry, root: usableRoot(entry, options) });
|
|
604
|
+
}
|
|
605
|
+
const pruned = { marker: REGISTRY_MARKER, clones: active };
|
|
606
|
+
writeRegistry(pruned, options);
|
|
607
|
+
return pruned;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function installGithubRouter(options = {}) {
|
|
611
|
+
const platform = options.platform || process.platform;
|
|
612
|
+
const binDir = findForgeBinDir(options);
|
|
613
|
+
assertRouterPrecedence(binDir, options);
|
|
614
|
+
const files = contentsFor(platform, runtimeCommands(options));
|
|
615
|
+
const releaseLocks = [];
|
|
616
|
+
let finalized = false;
|
|
617
|
+
let registrySnapshot;
|
|
618
|
+
let registryTouched = false;
|
|
619
|
+
const previous = new Map();
|
|
620
|
+
const touched = new Set();
|
|
621
|
+
const rollback = () => {
|
|
622
|
+
if (finalized) return;
|
|
623
|
+
finalized = true;
|
|
624
|
+
try {
|
|
625
|
+
for (const target of touched) {
|
|
626
|
+
try {
|
|
627
|
+
if (previous.has(target)) {
|
|
628
|
+
const snapshot = previous.get(target);
|
|
629
|
+
replaceRouterFile(target, snapshot.content, snapshot.mode);
|
|
630
|
+
} else if (fs.existsSync(target)) fs.unlinkSync(target);
|
|
631
|
+
} catch { /* best effort rollback */ }
|
|
632
|
+
}
|
|
633
|
+
if (registryTouched) {
|
|
634
|
+
try { restoreRegistry(registrySnapshot, options); } catch { /* best effort rollback */ }
|
|
635
|
+
}
|
|
636
|
+
} finally {
|
|
637
|
+
releaseRouterLocks(releaseLocks);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
try {
|
|
641
|
+
releaseLocks.push(acquireRouterLock(binDir, fs, options.isProcessRunning));
|
|
642
|
+
if (options.projectRoot) {
|
|
643
|
+
releaseLocks.push(acquireRegistryLock(options));
|
|
644
|
+
registrySnapshot = snapshotRegistry(options);
|
|
645
|
+
const registry = registryForRegistration(binDir, options);
|
|
646
|
+
writeRegistry(registerClone(registry, resolveCloneIdentity(options.projectRoot, options), options), options);
|
|
647
|
+
registryTouched = true;
|
|
648
|
+
}
|
|
649
|
+
assertInstallTargetsAvailable(platform, binDir, files);
|
|
650
|
+
const staged = [];
|
|
651
|
+
try {
|
|
652
|
+
for (const [name, content] of files) {
|
|
653
|
+
const target = path.join(binDir, name);
|
|
654
|
+
if (fs.existsSync(target)) {
|
|
655
|
+
const stat = fs.statSync(target);
|
|
656
|
+
previous.set(target, { content: fs.readFileSync(target), mode: stat.mode });
|
|
657
|
+
}
|
|
658
|
+
const mode = name.includes('.') ? 0o600 : 0o700;
|
|
659
|
+
staged.push({ target, temporary: stageRouterFile(target, content, mode) });
|
|
660
|
+
}
|
|
661
|
+
for (const item of staged) {
|
|
662
|
+
fs.renameSync(item.temporary, item.target);
|
|
663
|
+
touched.add(item.target);
|
|
664
|
+
}
|
|
665
|
+
} finally {
|
|
666
|
+
for (const item of staged) {
|
|
667
|
+
try { if (fs.existsSync(item.temporary)) fs.unlinkSync(item.temporary); } catch { /* preserve the publication failure */ }
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
} catch (error) {
|
|
671
|
+
rollback();
|
|
672
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
|
|
673
|
+
const failure = new Error('Forge cannot write its launcher directory.');
|
|
674
|
+
failure.code = 'GITHUB_ROUTER_PERMISSION';
|
|
675
|
+
throw failure;
|
|
676
|
+
}
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
const helperPath = shellPath(path.join(binDir, HELPER_NAME));
|
|
680
|
+
const result = { binDir, credentialHelperValue: `!${shellQuote(helperPath)}` };
|
|
681
|
+
Object.defineProperty(result, 'rollback', { value: rollback, enumerable: false });
|
|
682
|
+
Object.defineProperty(result, 'commit', { value: () => {
|
|
683
|
+
if (finalized) return {};
|
|
684
|
+
finalized = true;
|
|
685
|
+
try { releaseRouterLocks(releaseLocks); return {}; } catch {
|
|
686
|
+
return { warning: 'Configuration applied; router lock cleanup failed.' };
|
|
687
|
+
}
|
|
688
|
+
}, enumerable: false });
|
|
689
|
+
return result;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function ownedRouterTargets(binDirs, names, fileSystem) {
|
|
693
|
+
const targets = [];
|
|
694
|
+
for (const binDir of binDirs) {
|
|
695
|
+
for (const name of names) {
|
|
696
|
+
const target = path.join(binDir, name);
|
|
697
|
+
if (!fileSystem.existsSync(target)) continue;
|
|
698
|
+
const content = fileSystem.readFileSync(target);
|
|
699
|
+
if (content.includes(MARKER)) targets.push({ name, target, content, mode: fileSystem.statSync(target).mode });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return targets;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function ownedRouterDirectories(directories, names, fileSystem, platform) {
|
|
706
|
+
const owned = new Map();
|
|
707
|
+
for (const directory of directories) {
|
|
708
|
+
let canonical;
|
|
709
|
+
try { canonical = fileSystem.realpathSync(directory); } catch { continue; }
|
|
710
|
+
if (!names.some(name => isOwnedFile(path.join(canonical, name), fileSystem))) continue;
|
|
711
|
+
const key = platform === 'win32' ? canonical.toLowerCase() : canonical;
|
|
712
|
+
owned.set(key, canonical);
|
|
713
|
+
}
|
|
714
|
+
return [...owned.values()].sort((left, right) => left.localeCompare(right));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function restoreRouterTargets(removed, fileSystem) {
|
|
718
|
+
for (const item of removed.toReversed()) {
|
|
719
|
+
try {
|
|
720
|
+
if (!fileSystem.existsSync(item.target)) {
|
|
721
|
+
replaceRouterFile(item.target, item.content, item.mode, fileSystem);
|
|
722
|
+
}
|
|
723
|
+
} catch { /* preserve the original removal failure */ }
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function uninstallGithubRouter(options = {}) {
|
|
728
|
+
const platform = options.platform || process.platform;
|
|
729
|
+
const fileSystem = options.fileSystem || fs;
|
|
730
|
+
const names = platform === 'win32' ? ['gh', 'gh.cmd', 'gh.ps1', HELPER_NAME] : ['gh', HELPER_NAME];
|
|
731
|
+
const directories = options.binDir
|
|
732
|
+
? [path.resolve(options.binDir)]
|
|
733
|
+
: pathDirectories(options.pathEnv ?? process.env.PATH ?? '').map(directory => path.resolve(directory));
|
|
734
|
+
const releaseLocks = [];
|
|
735
|
+
let warning;
|
|
736
|
+
let targets;
|
|
737
|
+
const removed = [];
|
|
738
|
+
try {
|
|
739
|
+
let binDirs = ownedRouterDirectories(directories, names, fileSystem, platform);
|
|
740
|
+
if (binDirs.length === 0) {
|
|
741
|
+
const fallback = options.binDir ? path.resolve(options.binDir) : findForgeBinDir(options);
|
|
742
|
+
binDirs = [fileSystem.realpathSync(fallback)];
|
|
743
|
+
}
|
|
744
|
+
for (const binDir of binDirs) releaseLocks.push(acquireRouterLock(binDir, fileSystem, options.isProcessRunning));
|
|
745
|
+
targets = ownedRouterTargets(binDirs, names, fileSystem);
|
|
746
|
+
releaseLocks.push(acquireRegistryLock({ ...options, fileSystem }));
|
|
747
|
+
const registry = pruneRegistry({ ...options, fileSystem }, targets.length === 0);
|
|
748
|
+
if (registry.clones.length) {
|
|
749
|
+
const failure = new Error('Automatic routing remains enabled in one or more registered clones.');
|
|
750
|
+
failure.code = 'GITHUB_ROUTER_IN_USE';
|
|
751
|
+
throw failure;
|
|
752
|
+
}
|
|
753
|
+
for (const item of targets) {
|
|
754
|
+
fileSystem.unlinkSync(item.target);
|
|
755
|
+
removed.push(item);
|
|
756
|
+
}
|
|
757
|
+
} catch (error) {
|
|
758
|
+
restoreRouterTargets(removed, fileSystem);
|
|
759
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
|
|
760
|
+
const failure = new Error('Forge cannot remove its router files.');
|
|
761
|
+
failure.code = 'GITHUB_ROUTER_PERMISSION';
|
|
762
|
+
throw failure;
|
|
763
|
+
}
|
|
764
|
+
throw error;
|
|
765
|
+
} finally {
|
|
766
|
+
try { releaseRouterLocks(releaseLocks); } catch { warning = 'Router files were processed, but lock cleanup failed.'; }
|
|
767
|
+
}
|
|
768
|
+
return { removed: targets.map(item => item.name), ...(warning ? { warning } : {}) };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
module.exports = { HELPER_NAME, MARKER, assertGithubRouterCloneRegistered, findForgeBinDir, findForgeLauncher,
|
|
772
|
+
helperPathFromValue, getGithubRouterStatus, installGithubRouter, isOwnedCredentialHelperValue, isOwnedFile,
|
|
773
|
+
isManagedCredentialHelperValue, isUsableCredentialHelperValue,
|
|
774
|
+
registerGithubRouterClone, unregisterGithubRouterClone, uninstallGithubRouter };
|