@loucompanion/forge-bridge 0.1.1-dev.03b8ba168599
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/README.md +64 -0
- package/dist/bridge/bridge.base.d.ts +17 -0
- package/dist/bridge/bridge.base.js +1 -0
- package/dist/bridge/bridge.service.d.ts +45 -0
- package/dist/bridge/bridge.service.js +353 -0
- package/dist/bridge/bridge.types.d.ts +83 -0
- package/dist/bridge/bridge.types.js +1 -0
- package/dist/bridge/index.d.ts +2 -0
- package/dist/bridge/index.js +1 -0
- package/dist/bridge/internals.d.ts +3 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/cleanup/cleanup.base.d.ts +4 -0
- package/dist/cleanup/cleanup.base.js +1 -0
- package/dist/cleanup/cleanup.service.d.ts +5 -0
- package/dist/cleanup/cleanup.service.js +5 -0
- package/dist/cleanup/cleanup.types.d.ts +6 -0
- package/dist/cleanup/cleanup.types.js +1 -0
- package/dist/cleanup/index.d.ts +2 -0
- package/dist/cleanup/index.js +1 -0
- package/dist/cleanup/internals.d.ts +3 -0
- package/dist/cleanup/internals.js +1 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +4 -0
- package/dist/cli/cli.base.d.ts +7 -0
- package/dist/cli/cli.base.js +1 -0
- package/dist/cli/cli.service.d.ts +66 -0
- package/dist/cli/cli.service.js +770 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/internals.d.ts +1 -0
- package/dist/cli/internals.js +1 -0
- package/dist/cli/local-config.d.ts +34 -0
- package/dist/cli/local-config.js +181 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +8 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +7 -0
- package/dist/session/internals.js +2 -0
- package/dist/session/provider-cli/index.d.ts +3 -0
- package/dist/session/provider-cli/index.js +1 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +7 -0
- package/dist/session/provider-cli/provider-cli.base.js +1 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +58 -0
- package/dist/session/provider-cli/provider-cli.service.js +705 -0
- package/dist/session/provider-cli/provider-cli.types.d.ts +13 -0
- package/dist/session/provider-cli/provider-cli.types.js +1 -0
- package/dist/session/session.base.d.ts +7 -0
- package/dist/session/session.base.js +1 -0
- package/dist/session/session.service.d.ts +10 -0
- package/dist/session/session.service.js +134 -0
- package/dist/session/session.types.d.ts +118 -0
- package/dist/session/session.types.js +197 -0
- package/dist/shared/git/git.d.ts +6 -0
- package/dist/shared/git/git.js +18 -0
- package/dist/shared/path/path.d.ts +4 -0
- package/dist/shared/path/path.js +29 -0
- package/dist/shared/process/process.d.ts +16 -0
- package/dist/shared/process/process.js +32 -0
- package/dist/shared/redaction/redaction.d.ts +2 -0
- package/dist/shared/redaction/redaction.js +30 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/dist/worktree/artifact-injector.d.ts +11 -0
- package/dist/worktree/artifact-injector.js +104 -0
- package/dist/worktree/dependency-warmer.d.ts +32 -0
- package/dist/worktree/dependency-warmer.js +297 -0
- package/dist/worktree/index.d.ts +4 -0
- package/dist/worktree/index.js +2 -0
- package/dist/worktree/internals.d.ts +3 -0
- package/dist/worktree/internals.js +1 -0
- package/dist/worktree/worktree.base.d.ts +5 -0
- package/dist/worktree/worktree.base.js +1 -0
- package/dist/worktree/worktree.service.d.ts +16 -0
- package/dist/worktree/worktree.service.js +301 -0
- package/dist/worktree/worktree.types.d.ts +19 -0
- package/dist/worktree/worktree.types.js +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
|
|
8
|
+
import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
|
|
9
|
+
import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
|
|
10
|
+
import { BridgeSessionService } from "../session/session.service.js";
|
|
11
|
+
import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
|
|
12
|
+
const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
export class ForgeBridgeCliService {
|
|
15
|
+
sessions;
|
|
16
|
+
bridgeFactory;
|
|
17
|
+
pairRunner;
|
|
18
|
+
constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config), pairRunner = claimBridgeRunner) {
|
|
19
|
+
this.sessions = sessions;
|
|
20
|
+
this.bridgeFactory = bridgeFactory;
|
|
21
|
+
this.pairRunner = pairRunner;
|
|
22
|
+
}
|
|
23
|
+
async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
24
|
+
const args = parseArgs(argv);
|
|
25
|
+
if (!args.ok) {
|
|
26
|
+
io.stderr.write(`${args.error}\n`);
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
if (args.command === "connect") {
|
|
30
|
+
const config = await withDetectedCliProviders(args.config);
|
|
31
|
+
const missing = [
|
|
32
|
+
["server", config.serverUrl],
|
|
33
|
+
["runner id", config.runnerId],
|
|
34
|
+
["device token", config.deviceToken]
|
|
35
|
+
].find(([, value]) => !value);
|
|
36
|
+
if (missing) {
|
|
37
|
+
io.stderr.write(`forge-bridge connect requires ${missing[0]}.\n`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const stop = () => controller.abort("signal received");
|
|
42
|
+
process.once("SIGINT", stop);
|
|
43
|
+
process.once("SIGTERM", stop);
|
|
44
|
+
try {
|
|
45
|
+
await this.bridgeFactory(config).run(controller.signal);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
process.off("SIGINT", stop);
|
|
50
|
+
process.off("SIGTERM", stop);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (args.command === "add-repo") {
|
|
54
|
+
try {
|
|
55
|
+
const config = await addRepoMapping(args.label, args.repoPath);
|
|
56
|
+
const normalizedLabel = args.label.trim();
|
|
57
|
+
const mapping = config.repoMappings?.find((entry) => entry.label.toLowerCase() === normalizedLabel.toLowerCase());
|
|
58
|
+
io.stdout.write(`Added repo ${mapping?.label ?? normalizedLabel}: ${mapping?.localPath ?? path.resolve(args.repoPath)}\n`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (args.command === "pair") {
|
|
67
|
+
try {
|
|
68
|
+
const current = readLocalConfigSync();
|
|
69
|
+
const code = await resolvePairingCode(args.code);
|
|
70
|
+
const repoRoot = await resolvePairRepoRoot(args.repoRoot, current.defaultRepoRoot);
|
|
71
|
+
await fs.mkdir(repoRoot, { recursive: true });
|
|
72
|
+
const pairArgs = await withDetectedPairCliProviders({ ...args, code, repoRoot });
|
|
73
|
+
const claim = await this.pairRunner(pairArgs);
|
|
74
|
+
const next = pairedConfig(current, pairArgs, claim);
|
|
75
|
+
await writeLocalConfig(next);
|
|
76
|
+
io.stdout.write(`Paired runner ${claim.runnerId}.\n`);
|
|
77
|
+
io.stdout.write(`Repo folder: ${repoRoot}\n`);
|
|
78
|
+
io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
|
|
79
|
+
io.stdout.write("Start the bridge with: forge-bridge connect\n");
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (args.command === "remove-repo") {
|
|
88
|
+
try {
|
|
89
|
+
const result = await removeRepoMapping(args.label);
|
|
90
|
+
io.stdout.write(result.removed ? `Removed repo ${args.label}.\n` : `Repo ${args.label} was not configured.\n`);
|
|
91
|
+
return result.removed ? 0 : 1;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (args.command === "status") {
|
|
99
|
+
await writeStatus(args.config, args.checkUpdates, io);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
if (args.command === "cleanup") {
|
|
103
|
+
return await cleanupRepos(args.config, args.mode, args.force, io);
|
|
104
|
+
}
|
|
105
|
+
let redactionSecrets = readEnvSecretValuesForRedaction();
|
|
106
|
+
try {
|
|
107
|
+
const dispatchJson = await fs.readFile(args.dispatchPath, "utf8");
|
|
108
|
+
const dispatch = attachEnvSecretGrant(parseRunOnceDispatchPacket(JSON.parse(dispatchJson)));
|
|
109
|
+
redactionSecrets = exactSecrets(dispatch);
|
|
110
|
+
const result = await this.sessions.runOnce(dispatch, {
|
|
111
|
+
emit: async (event) => {
|
|
112
|
+
const line = {
|
|
113
|
+
type: "runner_event",
|
|
114
|
+
event: redactSecrets(event, redactionSecrets)
|
|
115
|
+
};
|
|
116
|
+
io.stdout.write(`${JSON.stringify(line)}\n`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
await writeJsonAtomic(args.resultPath, redactSecrets(result, redactionSecrets));
|
|
120
|
+
return result.exitCode;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
const summary = redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
|
|
124
|
+
await writeJsonAtomic(args.resultPath, failureResult(summary)).catch(() => undefined);
|
|
125
|
+
io.stderr.write(`${summary}\n`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
delete process.env[apiKeySecretGrantEnv];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function attachEnvSecretGrant(dispatch) {
|
|
134
|
+
if (!dispatch.execution.requiresApiKeySecretGrant) {
|
|
135
|
+
delete process.env[apiKeySecretGrantEnv];
|
|
136
|
+
return dispatch;
|
|
137
|
+
}
|
|
138
|
+
const raw = process.env[apiKeySecretGrantEnv];
|
|
139
|
+
delete process.env[apiKeySecretGrantEnv];
|
|
140
|
+
if (!raw) {
|
|
141
|
+
return dispatch;
|
|
142
|
+
}
|
|
143
|
+
const grant = parseEnvSecretGrant(raw);
|
|
144
|
+
return {
|
|
145
|
+
...dispatch,
|
|
146
|
+
apiKeySecretGrant: grant
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function parseEnvSecretGrant(raw) {
|
|
150
|
+
const value = JSON.parse(raw);
|
|
151
|
+
if (!value || typeof value !== "object") {
|
|
152
|
+
throw new Error("API-key secret grant environment value must be an object.");
|
|
153
|
+
}
|
|
154
|
+
if (typeof value.grantId !== "string" ||
|
|
155
|
+
typeof value.provider !== "string" ||
|
|
156
|
+
typeof value.secret !== "string" ||
|
|
157
|
+
typeof value.expiresAt !== "string") {
|
|
158
|
+
throw new Error("API-key secret grant environment value is invalid.");
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
grantId: value.grantId,
|
|
162
|
+
provider: value.provider,
|
|
163
|
+
secret: value.secret,
|
|
164
|
+
expiresAt: value.expiresAt
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function exactSecrets(dispatch) {
|
|
168
|
+
return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
|
|
169
|
+
}
|
|
170
|
+
function readEnvSecretValuesForRedaction() {
|
|
171
|
+
const raw = process.env[apiKeySecretGrantEnv];
|
|
172
|
+
if (!raw) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
return [parseEnvSecretGrant(raw).secret];
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export function parseArgs(argv) {
|
|
183
|
+
if (argv[0] === "connect") {
|
|
184
|
+
return parseConnectArgs(argv);
|
|
185
|
+
}
|
|
186
|
+
if (argv[0] === "pair") {
|
|
187
|
+
return parsePairArgs(argv);
|
|
188
|
+
}
|
|
189
|
+
if (argv[0] === "add-repo") {
|
|
190
|
+
if (!argv[1] || !argv[2] || argv.length > 3) {
|
|
191
|
+
return { ok: false, error: "Usage: forge-bridge add-repo <label> <path>" };
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, command: "add-repo", label: argv[1], repoPath: argv[2] };
|
|
194
|
+
}
|
|
195
|
+
if (argv[0] === "remove-repo") {
|
|
196
|
+
if (!argv[1] || argv.length > 2) {
|
|
197
|
+
return { ok: false, error: "Usage: forge-bridge remove-repo <label>" };
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, command: "remove-repo", label: argv[1] };
|
|
200
|
+
}
|
|
201
|
+
if (argv[0] === "status") {
|
|
202
|
+
let checkUpdates = false;
|
|
203
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
204
|
+
const arg = argv[index];
|
|
205
|
+
if (arg === "--check-updates") {
|
|
206
|
+
checkUpdates = true;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
return { ok: false, error: `Unknown argument: ${arg}` };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { ok: true, command: "status", config: configuredBridgeServiceConfig(), checkUpdates };
|
|
213
|
+
}
|
|
214
|
+
if (argv[0] === "cleanup") {
|
|
215
|
+
return parseCleanupArgs(argv);
|
|
216
|
+
}
|
|
217
|
+
if (argv[0] !== "run-once") {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: usage()
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
let dispatchPath = null;
|
|
224
|
+
let resultPath = null;
|
|
225
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
226
|
+
const arg = argv[index];
|
|
227
|
+
if (arg === "--dispatch") {
|
|
228
|
+
dispatchPath = argv[++index] ?? null;
|
|
229
|
+
}
|
|
230
|
+
else if (arg === "--result") {
|
|
231
|
+
resultPath = argv[++index] ?? null;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
return { ok: false, error: `Unknown argument: ${arg}` };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!dispatchPath || !resultPath) {
|
|
238
|
+
return { ok: false, error: "run-once requires --dispatch and --result." };
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
command: "run-once",
|
|
243
|
+
dispatchPath: path.resolve(dispatchPath),
|
|
244
|
+
resultPath: path.resolve(resultPath)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function parseConnectArgs(argv) {
|
|
248
|
+
let serverUrl = null;
|
|
249
|
+
let runnerId = null;
|
|
250
|
+
let deviceToken = null;
|
|
251
|
+
let repoRoot = null;
|
|
252
|
+
let heartbeatIntervalMs = null;
|
|
253
|
+
const cliProviders = [];
|
|
254
|
+
const projectLabels = [];
|
|
255
|
+
let cleanupWorktrees = null;
|
|
256
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
257
|
+
const arg = argv[index];
|
|
258
|
+
if (arg === "--server") {
|
|
259
|
+
serverUrl = argv[++index] ?? null;
|
|
260
|
+
}
|
|
261
|
+
else if (arg === "--runner-id") {
|
|
262
|
+
runnerId = argv[++index] ?? null;
|
|
263
|
+
}
|
|
264
|
+
else if (arg === "--device-token") {
|
|
265
|
+
deviceToken = argv[++index] ?? null;
|
|
266
|
+
}
|
|
267
|
+
else if (arg === "--repo-root") {
|
|
268
|
+
repoRoot = argv[++index] ?? null;
|
|
269
|
+
}
|
|
270
|
+
else if (arg === "--cli-provider") {
|
|
271
|
+
cliProviders.push(...splitCsv(argv[++index] ?? ""));
|
|
272
|
+
}
|
|
273
|
+
else if (arg === "--project-label") {
|
|
274
|
+
projectLabels.push(...splitCsv(argv[++index] ?? ""));
|
|
275
|
+
}
|
|
276
|
+
else if (arg === "--heartbeat-interval-ms") {
|
|
277
|
+
heartbeatIntervalMs = Number(argv[++index] ?? "");
|
|
278
|
+
if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {
|
|
279
|
+
return { ok: false, error: "--heartbeat-interval-ms must be a positive number." };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (arg === "--no-cleanup-worktrees") {
|
|
283
|
+
cleanupWorktrees = false;
|
|
284
|
+
}
|
|
285
|
+
else if (arg === "--cleanup-worktrees") {
|
|
286
|
+
cleanupWorktrees = true;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
return { ok: false, error: `Unknown argument: ${arg}` };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const localConfig = readLocalConfigSync();
|
|
293
|
+
if (serverUrl === null && !process.env.FORGE_URL?.trim() && !localConfig.serverUrl?.trim()) {
|
|
294
|
+
return { ok: false, error: "forge-bridge connect requires --server or FORGE_URL." };
|
|
295
|
+
}
|
|
296
|
+
const overrides = {};
|
|
297
|
+
if (serverUrl !== null)
|
|
298
|
+
overrides.serverUrl = serverUrl;
|
|
299
|
+
if (runnerId !== null)
|
|
300
|
+
overrides.runnerId = runnerId;
|
|
301
|
+
if (deviceToken !== null)
|
|
302
|
+
overrides.deviceToken = deviceToken;
|
|
303
|
+
if (repoRoot !== null)
|
|
304
|
+
overrides.defaultRepoRoot = path.resolve(repoRoot);
|
|
305
|
+
if (cliProviders.length > 0)
|
|
306
|
+
overrides.cliProviders = cliProviders;
|
|
307
|
+
if (projectLabels.length > 0)
|
|
308
|
+
overrides.projectLabels = projectLabels;
|
|
309
|
+
if (heartbeatIntervalMs !== null)
|
|
310
|
+
overrides.heartbeatIntervalMs = heartbeatIntervalMs;
|
|
311
|
+
if (cleanupWorktrees !== null)
|
|
312
|
+
overrides.cleanupWorktrees = cleanupWorktrees;
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
command: "connect",
|
|
316
|
+
config: configuredBridgeServiceConfig(overrides, localConfig)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function parsePairArgs(argv) {
|
|
320
|
+
const localConfig = readLocalConfigSync();
|
|
321
|
+
const defaultConfig = configuredBridgeServiceConfig({}, localConfig);
|
|
322
|
+
let serverUrl = defaultConfig.serverUrl;
|
|
323
|
+
let code = null;
|
|
324
|
+
let name = null;
|
|
325
|
+
let fingerprint = null;
|
|
326
|
+
let repoRoot = null;
|
|
327
|
+
let cleanupWorktrees = null;
|
|
328
|
+
const cliProviders = [];
|
|
329
|
+
const projectLabels = [];
|
|
330
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
331
|
+
const arg = argv[index];
|
|
332
|
+
if (arg === "--server") {
|
|
333
|
+
serverUrl = argv[++index] ?? "";
|
|
334
|
+
}
|
|
335
|
+
else if (arg === "--code") {
|
|
336
|
+
code = argv[++index] ?? null;
|
|
337
|
+
}
|
|
338
|
+
else if (arg === "--name") {
|
|
339
|
+
name = argv[++index] ?? null;
|
|
340
|
+
}
|
|
341
|
+
else if (arg === "--fingerprint") {
|
|
342
|
+
fingerprint = argv[++index] ?? null;
|
|
343
|
+
}
|
|
344
|
+
else if (arg === "--repo-root") {
|
|
345
|
+
repoRoot = argv[++index] ?? null;
|
|
346
|
+
}
|
|
347
|
+
else if (arg === "--cli-provider") {
|
|
348
|
+
cliProviders.push(...splitCsv(argv[++index] ?? ""));
|
|
349
|
+
}
|
|
350
|
+
else if (arg === "--project-label") {
|
|
351
|
+
projectLabels.push(...splitCsv(argv[++index] ?? ""));
|
|
352
|
+
}
|
|
353
|
+
else if (arg === "--cleanup-worktrees") {
|
|
354
|
+
cleanupWorktrees = true;
|
|
355
|
+
}
|
|
356
|
+
else if (arg === "--no-cleanup-worktrees") {
|
|
357
|
+
cleanupWorktrees = false;
|
|
358
|
+
}
|
|
359
|
+
else if (!arg.startsWith("-") && code === null) {
|
|
360
|
+
code = arg;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
return { ok: false, error: `Unknown argument: ${arg}` };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (!serverUrl.trim()) {
|
|
367
|
+
return { ok: false, error: "forge-bridge pair requires --server or configured serverUrl." };
|
|
368
|
+
}
|
|
369
|
+
const defaultName = `Local bridge ${os.hostname()}`;
|
|
370
|
+
const user = os.userInfo().username;
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
command: "pair",
|
|
374
|
+
serverUrl,
|
|
375
|
+
code: normalizePairingCode(code),
|
|
376
|
+
name: name?.trim() || defaultName,
|
|
377
|
+
fingerprint: fingerprint?.trim() || `local-bridge:${os.hostname()}:${user}`,
|
|
378
|
+
repoRoot: repoRoot?.trim() ? path.resolve(repoRoot) : undefined,
|
|
379
|
+
cleanupWorktrees: cleanupWorktrees ?? localConfig.cleanupWorktrees ?? false,
|
|
380
|
+
cliProviders: cliProviders.length > 0 ? cliProviders : localConfig.cliProviders && localConfig.cliProviders.length > 0 ? [...localConfig.cliProviders] : ["codex"],
|
|
381
|
+
projectLabels: projectLabels.length > 0 ? projectLabels : localConfig.projectLabels ? [...localConfig.projectLabels] : []
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function parseCleanupArgs(argv) {
|
|
385
|
+
let mode = "stale";
|
|
386
|
+
let force = false;
|
|
387
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
388
|
+
const arg = argv[index];
|
|
389
|
+
if (arg === "--stale") {
|
|
390
|
+
mode = "stale";
|
|
391
|
+
}
|
|
392
|
+
else if (arg === "--merged") {
|
|
393
|
+
mode = "merged";
|
|
394
|
+
}
|
|
395
|
+
else if (arg === "--all") {
|
|
396
|
+
mode = "all";
|
|
397
|
+
}
|
|
398
|
+
else if (arg === "--force") {
|
|
399
|
+
force = true;
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
return { ok: false, error: `Unknown argument: ${arg}` };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { ok: true, command: "cleanup", config: configuredBridgeServiceConfig(), mode, force };
|
|
406
|
+
}
|
|
407
|
+
function configuredBridgeServiceConfig(overrides = {}, localConfig = readLocalConfigSync()) {
|
|
408
|
+
return {
|
|
409
|
+
...applyLocalConfigDefaults(defaultBridgeServiceConfig(), localConfig),
|
|
410
|
+
...overrides
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function splitCsv(value) {
|
|
414
|
+
return value
|
|
415
|
+
.split(",")
|
|
416
|
+
.map((entry) => entry.trim())
|
|
417
|
+
.filter((entry) => entry.length > 0);
|
|
418
|
+
}
|
|
419
|
+
async function claimBridgeRunner(args) {
|
|
420
|
+
if (!args.code) {
|
|
421
|
+
throw new Error("Pairing code is required.");
|
|
422
|
+
}
|
|
423
|
+
const response = await fetch(new URL("/api/runners/claim", normalizeServerUrl(args.serverUrl)), {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "Content-Type": "application/json" },
|
|
426
|
+
body: JSON.stringify({
|
|
427
|
+
code: args.code,
|
|
428
|
+
name: args.name,
|
|
429
|
+
fingerprint: args.fingerprint,
|
|
430
|
+
cliProviders: args.cliProviders,
|
|
431
|
+
projectLabels: args.projectLabels
|
|
432
|
+
})
|
|
433
|
+
});
|
|
434
|
+
if (!response.ok) {
|
|
435
|
+
throw new Error(`Pairing failed (${response.status}): ${await response.text()}`);
|
|
436
|
+
}
|
|
437
|
+
const value = await response.json();
|
|
438
|
+
if (!value.runnerId || !value.deviceToken) {
|
|
439
|
+
throw new Error("Pairing response did not include runner credentials.");
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
runnerId: value.runnerId,
|
|
443
|
+
deviceToken: value.deviceToken
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function pairedConfig(current, args, claim) {
|
|
447
|
+
return {
|
|
448
|
+
...current,
|
|
449
|
+
schemaVersion: 2,
|
|
450
|
+
serverUrl: args.serverUrl,
|
|
451
|
+
runnerId: claim.runnerId,
|
|
452
|
+
deviceToken: claim.deviceToken,
|
|
453
|
+
deviceName: args.name,
|
|
454
|
+
defaultRepoRoot: args.repoRoot ?? current.defaultRepoRoot,
|
|
455
|
+
cleanupWorktrees: args.cleanupWorktrees,
|
|
456
|
+
cliProviders: [...args.cliProviders],
|
|
457
|
+
providerStatuses: current.providerStatuses ?? providerStatusesFor(args.cliProviders),
|
|
458
|
+
projectLabels: [...args.projectLabels],
|
|
459
|
+
supportsApiSecretGrant: current.supportsApiSecretGrant ?? true,
|
|
460
|
+
supportsManagedWorktrees: current.supportsManagedWorktrees ?? true,
|
|
461
|
+
supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
|
|
462
|
+
supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
|
|
463
|
+
repoMappings: current.repoMappings ?? []
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
async function resolvePairingCode(code) {
|
|
467
|
+
const normalized = normalizePairingCode(code);
|
|
468
|
+
if (normalized) {
|
|
469
|
+
return normalized;
|
|
470
|
+
}
|
|
471
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
472
|
+
throw new Error("Pairing code is required. Run `forge-bridge pair <code>` or use an interactive terminal.");
|
|
473
|
+
}
|
|
474
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
475
|
+
try {
|
|
476
|
+
const answer = await prompt.question("Pairing code: ");
|
|
477
|
+
const clean = normalizePairingCode(answer);
|
|
478
|
+
if (!clean) {
|
|
479
|
+
throw new Error("Pairing code is required.");
|
|
480
|
+
}
|
|
481
|
+
return clean;
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
prompt.close();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async function resolvePairRepoRoot(repoRoot, configuredRoot) {
|
|
488
|
+
const fallback = path.resolve(repoRoot ?? configuredRoot ?? path.join(os.homedir(), "ForgeRepos"));
|
|
489
|
+
if (repoRoot?.trim()) {
|
|
490
|
+
return fallback;
|
|
491
|
+
}
|
|
492
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
493
|
+
return fallback;
|
|
494
|
+
}
|
|
495
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
496
|
+
try {
|
|
497
|
+
const answer = await prompt.question(`Repo folder [${fallback}]: `);
|
|
498
|
+
return path.resolve(answer.trim() || fallback);
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
prompt.close();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function normalizePairingCode(code) {
|
|
505
|
+
return code?.replace(/[\s-]/g, "").trim() ?? "";
|
|
506
|
+
}
|
|
507
|
+
function normalizeServerUrl(serverUrl) {
|
|
508
|
+
return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
|
|
509
|
+
}
|
|
510
|
+
function usage() {
|
|
511
|
+
return "Usage: forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path> | forge-bridge pair [code] [--server <url>] | forge-bridge connect --server <url> --runner-id <id> --device-token <token> | forge-bridge add-repo <label> <path> | forge-bridge remove-repo <label> | forge-bridge status [--check-updates] | forge-bridge cleanup [--stale|--merged|--all|--force]";
|
|
512
|
+
}
|
|
513
|
+
async function writeJsonAtomic(outputPath, value) {
|
|
514
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
515
|
+
const tempPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`;
|
|
516
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
517
|
+
await fs.rename(tempPath, outputPath);
|
|
518
|
+
}
|
|
519
|
+
async function writeStatus(config, checkUpdates, io) {
|
|
520
|
+
const detected = await detectCliProviders([...new Set([...config.cliProviders, "codex", "cursor"])]);
|
|
521
|
+
const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
|
|
522
|
+
io.stdout.write(`Forge bridge ${config.version}\n`);
|
|
523
|
+
io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
|
|
524
|
+
io.stdout.write(`Server: ${config.serverUrl || "not configured"}\n`);
|
|
525
|
+
io.stdout.write(`Device: ${config.runnerId || "not configured"}${config.deviceName ? ` (${config.deviceName})` : ""}\n`);
|
|
526
|
+
io.stdout.write(`Device token: ${config.deviceToken ? "configured" : "missing"}\n`);
|
|
527
|
+
io.stdout.write(`Active sessions: 0\n`);
|
|
528
|
+
io.stdout.write("Repos:\n");
|
|
529
|
+
if (repos.length === 0) {
|
|
530
|
+
io.stdout.write(" none\n");
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
for (const repo of repos) {
|
|
534
|
+
io.stdout.write(` ${repo.name ?? repo.repoId ?? "repo"} -> ${repo.localPath}\n`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
io.stdout.write("Detected CLIs:\n");
|
|
538
|
+
for (const cli of detected) {
|
|
539
|
+
const models = cli.models.length > 0 ? `, ${cli.models.length} models` : "";
|
|
540
|
+
io.stdout.write(` ${cli.provider}: ${cli.found ? `${cli.version || "found"}${models}` : "not found"}\n`);
|
|
541
|
+
}
|
|
542
|
+
if (checkUpdates) {
|
|
543
|
+
io.stdout.write("Update check: no update channel configured.\n");
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async function cleanupRepos(config, mode, force, io) {
|
|
547
|
+
const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
|
|
548
|
+
if (repos.length === 0) {
|
|
549
|
+
io.stdout.write("No repos configured.\n");
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
let failures = 0;
|
|
553
|
+
for (const repo of repos) {
|
|
554
|
+
const repoPath = path.resolve(repo.localPath);
|
|
555
|
+
const worktreeRoot = path.join(path.dirname(repoPath), ".forge-worktrees", path.basename(repoPath));
|
|
556
|
+
if (!force) {
|
|
557
|
+
const action = mode === "all" ? "remove" : "prune";
|
|
558
|
+
io.stdout.write(`[dry-run] Would ${action} ${worktreeRoot}\n`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
if (mode === "all") {
|
|
563
|
+
await fs.rm(worktreeRoot, { recursive: true, force: true });
|
|
564
|
+
await runGit(repoPath, ["worktree", "prune"]);
|
|
565
|
+
io.stdout.write(`Removed ${worktreeRoot}\n`);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
await runGit(repoPath, ["worktree", "prune"]);
|
|
569
|
+
io.stdout.write(`Pruned stale worktree metadata for ${repoPath}\n`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
failures += 1;
|
|
574
|
+
io.stderr.write(`${repoPath}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return failures === 0 ? 0 : 1;
|
|
578
|
+
}
|
|
579
|
+
async function detectCliProviders(providers) {
|
|
580
|
+
const results = [];
|
|
581
|
+
for (const provider of providers) {
|
|
582
|
+
let detected = null;
|
|
583
|
+
for (const probe of cliProbeCommandsFor(provider)) {
|
|
584
|
+
try {
|
|
585
|
+
detected = await execVersionCommand(probe);
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// Try the next provider-specific probe.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
results.push({
|
|
593
|
+
provider,
|
|
594
|
+
found: detected !== null,
|
|
595
|
+
version: detected ? versionLine(`${detected.stdout || detected.stderr}`) : "",
|
|
596
|
+
models: detected ? await detectCliModels(provider) : []
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return results;
|
|
600
|
+
}
|
|
601
|
+
function cliProbeCommandsFor(provider) {
|
|
602
|
+
const normalized = provider.toLowerCase();
|
|
603
|
+
if (normalized === "codex") {
|
|
604
|
+
return [
|
|
605
|
+
{ command: "codex", args: ["-V"] },
|
|
606
|
+
{ command: "codex", args: ["--version"] }
|
|
607
|
+
];
|
|
608
|
+
}
|
|
609
|
+
if (normalized === "cursor") {
|
|
610
|
+
return [
|
|
611
|
+
{ command: "cursor-agent", args: ["-v"] },
|
|
612
|
+
{ command: "agent", args: ["-v"] },
|
|
613
|
+
{ command: "cursor-agent", args: ["--version"] },
|
|
614
|
+
{ command: "agent", args: ["--version"] }
|
|
615
|
+
];
|
|
616
|
+
}
|
|
617
|
+
return [
|
|
618
|
+
{ command: provider, args: ["--version"] },
|
|
619
|
+
{ command: provider, args: ["-V"] }
|
|
620
|
+
];
|
|
621
|
+
}
|
|
622
|
+
async function execVersionCommand(probe, timeout = 2000) {
|
|
623
|
+
try {
|
|
624
|
+
return await execFileAsync(probe.command, probe.args, { timeout });
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
if (process.platform !== "win32") {
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
return await execFileAsync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", windowsCommandLine(probe)], { timeout });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function windowsCommandLine(probe) {
|
|
634
|
+
return [probe.command, ...probe.args].map(quoteWindowsCommandLineArg).join(" ");
|
|
635
|
+
}
|
|
636
|
+
function quoteWindowsCommandLineArg(value) {
|
|
637
|
+
if (value.length > 0 && !/[\s"]/.test(value)) {
|
|
638
|
+
return value;
|
|
639
|
+
}
|
|
640
|
+
return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, "$1$1")}"`;
|
|
641
|
+
}
|
|
642
|
+
function versionLine(output) {
|
|
643
|
+
const lines = output
|
|
644
|
+
.trim()
|
|
645
|
+
.split(/\r?\n/)
|
|
646
|
+
.map((line) => line.trim())
|
|
647
|
+
.filter((line) => line.length > 0);
|
|
648
|
+
return lines.at(-1) ?? "";
|
|
649
|
+
}
|
|
650
|
+
async function detectCliModels(provider) {
|
|
651
|
+
const normalized = provider.toLowerCase();
|
|
652
|
+
try {
|
|
653
|
+
if (normalized === "codex") {
|
|
654
|
+
const result = await execFileAsync("codex", ["debug", "models"], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 });
|
|
655
|
+
return parseCodexModelCatalog(result.stdout);
|
|
656
|
+
}
|
|
657
|
+
if (normalized === "cursor") {
|
|
658
|
+
const result = await execVersionCommand({ command: "cursor-agent", args: ["models"] }, 10_000);
|
|
659
|
+
return parseCursorModelList(`${result.stdout || result.stderr}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return [];
|
|
664
|
+
}
|
|
665
|
+
return [];
|
|
666
|
+
}
|
|
667
|
+
export function parseCodexModelCatalog(raw) {
|
|
668
|
+
try {
|
|
669
|
+
const parsed = JSON.parse(raw);
|
|
670
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.models)) {
|
|
671
|
+
return [];
|
|
672
|
+
}
|
|
673
|
+
const entries = parsed.models
|
|
674
|
+
.map((entry, index) => {
|
|
675
|
+
if (!isRecord(entry) || typeof entry.slug !== "string") {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
const visibility = typeof entry.visibility === "string" ? entry.visibility.toLowerCase() : "list";
|
|
679
|
+
if (visibility !== "list") {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
const priority = typeof entry.priority === "number" && Number.isFinite(entry.priority) ? entry.priority : index;
|
|
683
|
+
return { slug: entry.slug, priority, index };
|
|
684
|
+
})
|
|
685
|
+
.filter((entry) => entry !== null)
|
|
686
|
+
.sort((left, right) => left.priority - right.priority || left.index - right.index)
|
|
687
|
+
.map((entry) => entry.slug);
|
|
688
|
+
return uniqueModels(entries);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return [];
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
export function parseCursorModelList(raw) {
|
|
695
|
+
return uniqueModels(raw
|
|
696
|
+
.split(/\r?\n/)
|
|
697
|
+
.map((line) => line.trim())
|
|
698
|
+
.filter((line) => line.length > 0 && !line.toLowerCase().startsWith("available models"))
|
|
699
|
+
.map((line) => line.split(/\s+-\s+/, 1)[0]?.trim() ?? "")
|
|
700
|
+
.filter((model) => model.length > 0));
|
|
701
|
+
}
|
|
702
|
+
function uniqueModels(models) {
|
|
703
|
+
const seen = new Set();
|
|
704
|
+
const result = [];
|
|
705
|
+
for (const model of models) {
|
|
706
|
+
const trimmed = model.trim();
|
|
707
|
+
const key = trimmed.toLowerCase();
|
|
708
|
+
if (!trimmed || seen.has(key)) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
seen.add(key);
|
|
712
|
+
result.push(trimmed);
|
|
713
|
+
}
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
function isRecord(value) {
|
|
717
|
+
return typeof value === "object" && value !== null;
|
|
718
|
+
}
|
|
719
|
+
async function withDetectedCliProviders(config) {
|
|
720
|
+
const detected = await detectCliProviders(configuredProviderCandidates(config.cliProviders));
|
|
721
|
+
const found = detected.filter((result) => result.found);
|
|
722
|
+
if (found.length === 0) {
|
|
723
|
+
return config;
|
|
724
|
+
}
|
|
725
|
+
const cliProviders = uniqueProviders([...config.cliProviders, ...found.map((result) => result.provider)]);
|
|
726
|
+
const providerStatuses = { ...config.providerStatuses };
|
|
727
|
+
for (const result of found) {
|
|
728
|
+
const existing = providerStatuses[result.provider];
|
|
729
|
+
providerStatuses[result.provider] = {
|
|
730
|
+
signedIn: existing?.signedIn ?? true,
|
|
731
|
+
version: result.version || existing?.version || "",
|
|
732
|
+
models: result.models.length > 0
|
|
733
|
+
? result.models
|
|
734
|
+
: existing?.models ?? providerStatusesFor([result.provider])[result.provider]?.models ?? []
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
...config,
|
|
739
|
+
cliProviders,
|
|
740
|
+
providerStatuses
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
async function withDetectedPairCliProviders(args) {
|
|
744
|
+
const detectedConfig = await withDetectedCliProviders({
|
|
745
|
+
...defaultBridgeServiceConfig({
|
|
746
|
+
serverUrl: args.serverUrl,
|
|
747
|
+
cliProviders: [...args.cliProviders],
|
|
748
|
+
providerStatuses: providerStatusesFor(args.cliProviders),
|
|
749
|
+
projectLabels: [...args.projectLabels],
|
|
750
|
+
cleanupWorktrees: args.cleanupWorktrees,
|
|
751
|
+
defaultRepoRoot: args.repoRoot ?? defaultBridgeServiceConfig().defaultRepoRoot
|
|
752
|
+
})
|
|
753
|
+
});
|
|
754
|
+
return {
|
|
755
|
+
...args,
|
|
756
|
+
cliProviders: detectedConfig.cliProviders
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function configuredProviderCandidates(providers) {
|
|
760
|
+
return uniqueProviders([...providers, "codex", "cursor"]);
|
|
761
|
+
}
|
|
762
|
+
function uniqueProviders(providers) {
|
|
763
|
+
return providers
|
|
764
|
+
.map((provider) => provider.trim())
|
|
765
|
+
.filter((provider) => provider.length > 0)
|
|
766
|
+
.filter((provider, index, all) => all.findIndex((candidate) => candidate.toLowerCase() === provider.toLowerCase()) === index);
|
|
767
|
+
}
|
|
768
|
+
async function runGit(cwd, args) {
|
|
769
|
+
await execFileAsync("git", args, { cwd, timeout: 10_000 });
|
|
770
|
+
}
|