@loucompanion/forge-bridge 0.1.1-dev.32d4dd5874ee → 0.1.1-dev.54464ef723de
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/dist/bridge/bridge.service.d.ts +1 -0
- package/dist/bridge/bridge.service.js +151 -23
- package/dist/bridge/bridge.types.d.ts +13 -2
- package/dist/cli/cli.service.js +225 -10
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +22 -3
- package/dist/config.js +1 -1
- package/dist/session/internals.d.ts +1 -1
- package/dist/session/phase-completion.d.ts +65 -4
- package/dist/session/phase-completion.js +2160 -124
- package/dist/session/provider-cli/provider-cli.base.d.ts +4 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +13 -5
- package/dist/session/provider-cli/provider-cli.service.js +1913 -177
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.service.js +53 -15
- package/dist/session/session.types.d.ts +126 -0
- package/dist/session/session.types.js +261 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +1 -0
- package/dist/worktree/artifact-injector.js +78 -4
- package/dist/worktree/worktree.service.d.ts +1 -0
- package/dist/worktree/worktree.service.js +132 -6
- package/dist/worktree/worktree.types.d.ts +8 -1
- package/package.json +8 -3
|
@@ -15,6 +15,7 @@ export declare class ForgeBridgeService implements BridgeService {
|
|
|
15
15
|
run(signal?: AbortSignal): Promise<void>;
|
|
16
16
|
stop(): Promise<void>;
|
|
17
17
|
private handleDispatch;
|
|
18
|
+
private parseDispatchOrReport;
|
|
18
19
|
private handleCancel;
|
|
19
20
|
private abortActiveSessions;
|
|
20
21
|
private normalizeDispatch;
|
|
@@ -48,8 +48,14 @@ export class ForgeBridgeService {
|
|
|
48
48
|
while (!this.stopped && !signal?.aborted) {
|
|
49
49
|
try {
|
|
50
50
|
await this.connectOnce();
|
|
51
|
+
if (attempt > 0) {
|
|
52
|
+
logBridgeStatus("Connected to Forge. Waiting for dispatches...");
|
|
53
|
+
}
|
|
51
54
|
attempt = 0;
|
|
52
55
|
await this.waitForClose(signal);
|
|
56
|
+
if (!this.stopped && !signal?.aborted) {
|
|
57
|
+
logBridgeStatus("Connection closed. Reconnecting...");
|
|
58
|
+
}
|
|
53
59
|
}
|
|
54
60
|
catch (error) {
|
|
55
61
|
if (this.stopped || signal?.aborted) {
|
|
@@ -57,6 +63,7 @@ export class ForgeBridgeService {
|
|
|
57
63
|
}
|
|
58
64
|
const backoff = this.config.reconnectBackoffMs[Math.min(attempt, this.config.reconnectBackoffMs.length - 1)] ?? 5000;
|
|
59
65
|
attempt += 1;
|
|
66
|
+
logBridgeStatus(`Connect attempt ${attempt} failed: ${errorMessage(error)}. Retrying in ${formatDelay(backoff)}...`);
|
|
60
67
|
await delay(backoff, signal);
|
|
61
68
|
}
|
|
62
69
|
}
|
|
@@ -73,10 +80,14 @@ export class ForgeBridgeService {
|
|
|
73
80
|
this.abortActiveSessions("bridge stopping");
|
|
74
81
|
await this.stopConnection();
|
|
75
82
|
}
|
|
76
|
-
async handleDispatch(
|
|
83
|
+
async handleDispatch(rawDispatch) {
|
|
84
|
+
const dispatch = this.parseDispatchOrReport(rawDispatch);
|
|
85
|
+
if (!dispatch) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
77
88
|
const leaseId = dispatch.lease.id;
|
|
78
89
|
const fencingToken = dispatch.lease.fencingToken;
|
|
79
|
-
const redactionSecrets = exactSecrets(dispatch);
|
|
90
|
+
const redactionSecrets = exactSecrets(dispatch, this.config);
|
|
80
91
|
if (this.activeSessions.has(leaseId)) {
|
|
81
92
|
await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
|
|
82
93
|
return;
|
|
@@ -87,11 +98,20 @@ export class ForgeBridgeService {
|
|
|
87
98
|
const normalized = this.normalizeDispatch(dispatch);
|
|
88
99
|
const result = await this.sessions.runOnce(normalized, {
|
|
89
100
|
emit: async (event) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
try {
|
|
102
|
+
await this.invokeAck("DispatchEvent", {
|
|
103
|
+
leaseId,
|
|
104
|
+
fencingToken,
|
|
105
|
+
event: redactSecrets(event, redactionSecrets)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (isDispatchNoLongerPending(error)) {
|
|
110
|
+
controller.abort(error instanceof Error ? error.message : "Dispatch is no longer pending.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
95
115
|
}
|
|
96
116
|
}, controller.signal);
|
|
97
117
|
await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
|
|
@@ -106,6 +126,27 @@ export class ForgeBridgeService {
|
|
|
106
126
|
this.activeSessions.delete(leaseId);
|
|
107
127
|
}
|
|
108
128
|
}
|
|
129
|
+
parseDispatchOrReport(rawDispatch) {
|
|
130
|
+
try {
|
|
131
|
+
return parseRunOnceDispatchPacket(rawDispatch);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const identifiers = readDispatchIdentifiers(rawDispatch);
|
|
135
|
+
if (!identifiers) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const summary = invalidDispatchSummary(error);
|
|
139
|
+
void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
|
|
140
|
+
{
|
|
141
|
+
role: "runner",
|
|
142
|
+
kind: "error",
|
|
143
|
+
content: summary,
|
|
144
|
+
eventType: "invalid_dispatch"
|
|
145
|
+
}
|
|
146
|
+
])).catch(() => undefined);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
109
150
|
async handleCancel(request) {
|
|
110
151
|
const active = this.activeSessions.get(request.leaseId);
|
|
111
152
|
if (!active || active.fencingToken !== request.fencingToken) {
|
|
@@ -123,6 +164,7 @@ export class ForgeBridgeService {
|
|
|
123
164
|
const mapping = findRepoMapping(dispatch, this.config.repoMappings);
|
|
124
165
|
const localPath = mapping?.localPath?.trim();
|
|
125
166
|
const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
|
|
167
|
+
const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
|
|
126
168
|
return {
|
|
127
169
|
...dispatch,
|
|
128
170
|
repo: {
|
|
@@ -133,7 +175,13 @@ export class ForgeBridgeService {
|
|
|
133
175
|
serverLocal: {
|
|
134
176
|
repoRoot,
|
|
135
177
|
cleanupWorktrees: this.config.cleanupWorktrees
|
|
136
|
-
}
|
|
178
|
+
},
|
|
179
|
+
qaBrowser: dispatch.qaBrowser
|
|
180
|
+
? {
|
|
181
|
+
...dispatch.qaBrowser,
|
|
182
|
+
launchTarget: qaLaunchTarget
|
|
183
|
+
}
|
|
184
|
+
: dispatch.qaBrowser
|
|
137
185
|
};
|
|
138
186
|
}
|
|
139
187
|
async sendResult(leaseId, fencingToken, result) {
|
|
@@ -142,7 +190,15 @@ export class ForgeBridgeService {
|
|
|
142
190
|
fencingToken,
|
|
143
191
|
result
|
|
144
192
|
};
|
|
145
|
-
|
|
193
|
+
try {
|
|
194
|
+
await this.invokeAck("DispatchResult", payload);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (isDispatchNoLongerPending(error)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
146
202
|
}
|
|
147
203
|
startHeartbeat() {
|
|
148
204
|
this.stopHeartbeat();
|
|
@@ -179,7 +235,8 @@ export class ForgeBridgeService {
|
|
|
179
235
|
supportsApiSecretGrant: this.config.supportsApiSecretGrant,
|
|
180
236
|
supportsManagedWorktrees: this.config.supportsManagedWorktrees,
|
|
181
237
|
supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
|
|
182
|
-
supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
|
|
238
|
+
supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
|
|
239
|
+
supportsDraftRunOnce: this.config.supportsDraftRunOnce
|
|
183
240
|
};
|
|
184
241
|
}
|
|
185
242
|
async invokeAck(methodName, payload) {
|
|
@@ -189,7 +246,7 @@ export class ForgeBridgeService {
|
|
|
189
246
|
}
|
|
190
247
|
const ack = await connection.invoke(methodName, payload);
|
|
191
248
|
if (!ack.accepted) {
|
|
192
|
-
throw new
|
|
249
|
+
throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
|
|
193
250
|
}
|
|
194
251
|
return ack;
|
|
195
252
|
}
|
|
@@ -207,6 +264,20 @@ export class ForgeBridgeService {
|
|
|
207
264
|
}
|
|
208
265
|
}
|
|
209
266
|
}
|
|
267
|
+
class BridgeCallbackRejectedError extends Error {
|
|
268
|
+
code;
|
|
269
|
+
constructor(code, message) {
|
|
270
|
+
super(message);
|
|
271
|
+
this.code = code;
|
|
272
|
+
this.name = "BridgeCallbackRejectedError";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function isDispatchNoLongerPending(error) {
|
|
276
|
+
if (error instanceof BridgeCallbackRejectedError) {
|
|
277
|
+
return error.code === "bridge.dispatch_not_pending" || error.code === "bridge.stale_dispatch";
|
|
278
|
+
}
|
|
279
|
+
return error instanceof Error && error.message === "Dispatch is no longer pending.";
|
|
280
|
+
}
|
|
210
281
|
export class SignalRBridgeConnectionFactory {
|
|
211
282
|
create(config) {
|
|
212
283
|
return new SignalRBridgeConnection(config);
|
|
@@ -237,14 +308,7 @@ export class SignalRBridgeConnection {
|
|
|
237
308
|
}
|
|
238
309
|
onDispatch(handler) {
|
|
239
310
|
this.connection.on("Dispatch", (raw) => {
|
|
240
|
-
|
|
241
|
-
try {
|
|
242
|
-
dispatch = parseRunOnceDispatchPacket(raw);
|
|
243
|
-
}
|
|
244
|
-
catch {
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
void handler(dispatch).catch(() => undefined);
|
|
311
|
+
void handler(raw).catch(() => undefined);
|
|
248
312
|
});
|
|
249
313
|
}
|
|
250
314
|
onCancel(handler) {
|
|
@@ -271,11 +335,12 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
271
335
|
projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
|
|
272
336
|
repoMappings: [],
|
|
273
337
|
os: process.platform,
|
|
274
|
-
sandboxProfiles:
|
|
338
|
+
sandboxProfiles: overrides.sandboxProfiles,
|
|
275
339
|
supportsApiSecretGrant: true,
|
|
276
340
|
supportsManagedWorktrees: true,
|
|
277
341
|
supportsManagedForgeArtifacts: true,
|
|
278
342
|
supportsRunnerAuthoredPhaseArtifacts: true,
|
|
343
|
+
supportsDraftRunOnce: true,
|
|
279
344
|
version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
|
|
280
345
|
protocolVersion: 1,
|
|
281
346
|
minServerVersion: null,
|
|
@@ -285,6 +350,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
285
350
|
};
|
|
286
351
|
return {
|
|
287
352
|
...config,
|
|
353
|
+
sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
|
|
288
354
|
heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
|
|
289
355
|
};
|
|
290
356
|
}
|
|
@@ -303,6 +369,11 @@ function positiveFiniteNumber(value, fallback) {
|
|
|
303
369
|
function providerStatusesFor(providers) {
|
|
304
370
|
return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
|
|
305
371
|
}
|
|
372
|
+
function defaultSandboxProfiles(providers) {
|
|
373
|
+
return providers.some((provider) => provider.toLowerCase() === "codex")
|
|
374
|
+
? ["provider-workspace-write"]
|
|
375
|
+
: ["none"];
|
|
376
|
+
}
|
|
306
377
|
function configuredModelsFor(provider) {
|
|
307
378
|
const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
|
|
308
379
|
return splitCsv(process.env[envName] ?? "");
|
|
@@ -323,16 +394,70 @@ function findRepoMapping(dispatch, mappings) {
|
|
|
323
394
|
(!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
|
|
324
395
|
});
|
|
325
396
|
}
|
|
397
|
+
function normalizeQaAppLaunchTarget(target) {
|
|
398
|
+
if (!target) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
const routes = [...new Set((target.routes ?? []).map((route) => route.trim()).filter(Boolean))];
|
|
402
|
+
const normalized = {
|
|
403
|
+
url: cleanString(target.url),
|
|
404
|
+
command: cleanString(target.command),
|
|
405
|
+
working_directory: cleanString(target.working_directory),
|
|
406
|
+
ready_url: cleanString(target.ready_url),
|
|
407
|
+
routes,
|
|
408
|
+
notes: cleanString(target.notes)
|
|
409
|
+
};
|
|
410
|
+
return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
|
|
411
|
+
? normalized
|
|
412
|
+
: null;
|
|
413
|
+
}
|
|
414
|
+
function cleanString(value) {
|
|
415
|
+
const clean = value?.trim();
|
|
416
|
+
return clean ? clean : null;
|
|
417
|
+
}
|
|
326
418
|
function equalsIgnoreCase(left, right) {
|
|
327
419
|
return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
|
|
328
420
|
}
|
|
421
|
+
function readDispatchIdentifiers(rawDispatch) {
|
|
422
|
+
if (!rawDispatch || typeof rawDispatch !== "object" || Array.isArray(rawDispatch)) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const packet = rawDispatch;
|
|
426
|
+
const rawLease = packet.lease ?? packet.Lease;
|
|
427
|
+
if (!rawLease || typeof rawLease !== "object" || Array.isArray(rawLease)) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const lease = rawLease;
|
|
431
|
+
const leaseId = readString(lease, "id", "Id");
|
|
432
|
+
const fencingToken = readString(lease, "fencingToken", "FencingToken");
|
|
433
|
+
return leaseId && fencingToken ? { leaseId, fencingToken } : null;
|
|
434
|
+
}
|
|
435
|
+
function readString(source, camelKey, pascalKey) {
|
|
436
|
+
const value = source[camelKey] ?? source[pascalKey];
|
|
437
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
438
|
+
}
|
|
439
|
+
function invalidDispatchSummary(error) {
|
|
440
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
441
|
+
return `Invalid bridge dispatch packet: ${detail}`;
|
|
442
|
+
}
|
|
443
|
+
function errorMessage(error) {
|
|
444
|
+
return error instanceof Error ? error.message : String(error);
|
|
445
|
+
}
|
|
446
|
+
function formatDelay(ms) {
|
|
447
|
+
if (ms >= 1000 && ms % 1000 === 0) {
|
|
448
|
+
return `${ms / 1000}s`;
|
|
449
|
+
}
|
|
450
|
+
return `${ms}ms`;
|
|
451
|
+
}
|
|
452
|
+
function logBridgeStatus(message) {
|
|
453
|
+
process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
|
|
454
|
+
}
|
|
329
455
|
function delay(ms, signal) {
|
|
330
456
|
if (signal?.aborted) {
|
|
331
457
|
return Promise.resolve();
|
|
332
458
|
}
|
|
333
459
|
return new Promise((resolve) => {
|
|
334
460
|
const timeout = setTimeout(resolve, ms);
|
|
335
|
-
timeout.unref?.();
|
|
336
461
|
signal?.addEventListener("abort", () => {
|
|
337
462
|
clearTimeout(timeout);
|
|
338
463
|
resolve();
|
|
@@ -348,6 +473,9 @@ function abortPromise(signal) {
|
|
|
348
473
|
}
|
|
349
474
|
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
350
475
|
}
|
|
351
|
-
function exactSecrets(dispatch) {
|
|
352
|
-
return
|
|
476
|
+
function exactSecrets(dispatch, config) {
|
|
477
|
+
return [
|
|
478
|
+
dispatch.apiKeySecretGrant?.secret,
|
|
479
|
+
config.deviceToken
|
|
480
|
+
].filter((secret) => typeof secret === "string" && secret.length > 0);
|
|
353
481
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
|
|
2
2
|
export interface BridgeServiceConfig {
|
|
3
3
|
readonly serverUrl: string;
|
|
4
4
|
readonly runnerId: string;
|
|
@@ -16,6 +16,7 @@ export interface BridgeServiceConfig {
|
|
|
16
16
|
readonly supportsManagedWorktrees: boolean;
|
|
17
17
|
readonly supportsManagedForgeArtifacts: boolean;
|
|
18
18
|
readonly supportsRunnerAuthoredPhaseArtifacts: boolean;
|
|
19
|
+
readonly supportsDraftRunOnce: boolean;
|
|
19
20
|
readonly version: string;
|
|
20
21
|
readonly protocolVersion: number;
|
|
21
22
|
readonly minServerVersion?: string | null;
|
|
@@ -27,6 +28,15 @@ export interface BridgeRepoMapping {
|
|
|
27
28
|
readonly name?: string | null;
|
|
28
29
|
readonly remoteUrl?: string | null;
|
|
29
30
|
readonly localPath?: string | null;
|
|
31
|
+
readonly qaAppLaunchTarget?: QaAppLaunchTarget | null;
|
|
32
|
+
}
|
|
33
|
+
export interface QaAppLaunchTarget {
|
|
34
|
+
readonly url?: string | null;
|
|
35
|
+
readonly command?: string | null;
|
|
36
|
+
readonly working_directory?: string | null;
|
|
37
|
+
readonly ready_url?: string | null;
|
|
38
|
+
readonly routes?: readonly string[] | null;
|
|
39
|
+
readonly notes?: string | null;
|
|
30
40
|
}
|
|
31
41
|
export interface BridgeProviderStatus {
|
|
32
42
|
readonly signedIn?: boolean | null;
|
|
@@ -46,6 +56,7 @@ export interface BridgeCapabilities {
|
|
|
46
56
|
readonly supportsManagedWorktrees?: boolean | null;
|
|
47
57
|
readonly supportsManagedForgeArtifacts?: boolean | null;
|
|
48
58
|
readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
|
|
59
|
+
readonly supportsDraftRunOnce?: boolean | null;
|
|
49
60
|
}
|
|
50
61
|
export interface BridgeHelloRequest {
|
|
51
62
|
readonly version: string;
|
|
@@ -79,5 +90,5 @@ export interface BridgeCancelRequest {
|
|
|
79
90
|
readonly fencingToken: string;
|
|
80
91
|
readonly reason?: string | null;
|
|
81
92
|
}
|
|
82
|
-
export type BridgeDispatchHandler = (dispatch:
|
|
93
|
+
export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
|
|
83
94
|
export type BridgeCancelHandler = (request: BridgeCancelRequest) => Promise<void>;
|
package/dist/cli/cli.service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
|
|
|
7
7
|
import { resolveCursorAgentDirectLaunch } from "../shared/cursor-agent/cursor-agent.launch.js";
|
|
8
8
|
import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
|
|
9
9
|
import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
|
|
10
|
+
import { forgeBridgeVersion } from "../version.js";
|
|
10
11
|
import { addRepoMapping, applyLocalConfigDefaults, providerStatusesFor, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath, writeLocalConfig } from "./local-config.js";
|
|
11
12
|
import { BridgeSessionService } from "../session/session.service.js";
|
|
12
13
|
import { parseCompletionMcpArgs, runCompletionMcpServer } from "../session/phase-completion.js";
|
|
@@ -23,6 +24,22 @@ export class ForgeBridgeCliService {
|
|
|
23
24
|
this.pairRunner = pairRunner;
|
|
24
25
|
}
|
|
25
26
|
async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
27
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
28
|
+
io.stdout.write(helpText());
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
if (argv[0] === "help") {
|
|
32
|
+
io.stdout.write(argv[1] ? commandHelpText(argv[1]) : helpText());
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
if (argv[1] === "--help" || argv[1] === "-h") {
|
|
36
|
+
io.stdout.write(commandHelpText(argv[0]));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
|
|
40
|
+
io.stdout.write(`forge-bridge ${defaultBridgeServiceConfig().version ?? forgeBridgeVersion}\n`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
26
43
|
const args = parseArgs(argv);
|
|
27
44
|
if (!args.ok) {
|
|
28
45
|
io.stderr.write(`${args.error}\n`);
|
|
@@ -44,6 +61,7 @@ export class ForgeBridgeCliService {
|
|
|
44
61
|
process.once("SIGINT", stop);
|
|
45
62
|
process.once("SIGTERM", stop);
|
|
46
63
|
try {
|
|
64
|
+
io.stdout.write(connectStartupText(config));
|
|
47
65
|
await this.bridgeFactory(config).run(controller.signal);
|
|
48
66
|
return 0;
|
|
49
67
|
}
|
|
@@ -176,19 +194,23 @@ function parseEnvSecretGrant(raw) {
|
|
|
176
194
|
};
|
|
177
195
|
}
|
|
178
196
|
function exactSecrets(dispatch) {
|
|
179
|
-
return dispatch.apiKeySecretGrant?.secret
|
|
197
|
+
return exactSecretValues(dispatch.apiKeySecretGrant?.secret, process.env.FORGE_DEVICE_TOKEN);
|
|
180
198
|
}
|
|
181
199
|
function readEnvSecretValuesForRedaction() {
|
|
200
|
+
const secrets = [];
|
|
182
201
|
const raw = process.env[apiKeySecretGrantEnv];
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return [];
|
|
202
|
+
if (raw) {
|
|
203
|
+
try {
|
|
204
|
+
secrets.push(parseEnvSecretGrant(raw).secret);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Ignore malformed grant env here; command handling will report the parse error.
|
|
208
|
+
}
|
|
191
209
|
}
|
|
210
|
+
return exactSecretValues(...secrets, process.env.FORGE_DEVICE_TOKEN);
|
|
211
|
+
}
|
|
212
|
+
function exactSecretValues(...values) {
|
|
213
|
+
return values.filter((value) => typeof value === "string" && value.length > 0);
|
|
192
214
|
}
|
|
193
215
|
export function parseArgs(argv) {
|
|
194
216
|
if (argv[0] === "connect") {
|
|
@@ -474,6 +496,7 @@ function pairedConfig(current, args, claim) {
|
|
|
474
496
|
supportsManagedWorktrees: current.supportsManagedWorktrees ?? true,
|
|
475
497
|
supportsManagedForgeArtifacts: current.supportsManagedForgeArtifacts ?? true,
|
|
476
498
|
supportsRunnerAuthoredPhaseArtifacts: current.supportsRunnerAuthoredPhaseArtifacts ?? true,
|
|
499
|
+
supportsDraftRunOnce: current.supportsDraftRunOnce ?? true,
|
|
477
500
|
repoMappings: current.repoMappings ?? []
|
|
478
501
|
};
|
|
479
502
|
}
|
|
@@ -522,7 +545,199 @@ function normalizeServerUrl(serverUrl) {
|
|
|
522
545
|
return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
|
|
523
546
|
}
|
|
524
547
|
function usage() {
|
|
525
|
-
return
|
|
548
|
+
return helpText();
|
|
549
|
+
}
|
|
550
|
+
function helpText() {
|
|
551
|
+
const version = defaultBridgeServiceConfig().version ?? forgeBridgeVersion;
|
|
552
|
+
return [
|
|
553
|
+
`🛠 Forge Bridge ${version} — Local provider runners for Forge.`,
|
|
554
|
+
"",
|
|
555
|
+
"Usage: forge-bridge [options] [command]",
|
|
556
|
+
"",
|
|
557
|
+
"Options:",
|
|
558
|
+
" -h, --help Display help for command",
|
|
559
|
+
" -v, --version Output the version number",
|
|
560
|
+
"",
|
|
561
|
+
"Commands:",
|
|
562
|
+
" Hint: commands suffixed with * have detailed help. Run <command> --help for details.",
|
|
563
|
+
" connect * Start the runner bridge and wait for Forge dispatches",
|
|
564
|
+
" pair * Pair this machine with Forge and save runner credentials",
|
|
565
|
+
" status * Show config, runner identity, repos, provider CLIs, and updates",
|
|
566
|
+
" add-repo * Map a Forge repo label/name to a local checkout path",
|
|
567
|
+
" remove-repo * Remove a saved repo mapping",
|
|
568
|
+
" cleanup * Prune managed bridge worktrees",
|
|
569
|
+
" version * Print the installed bridge package version",
|
|
570
|
+
" help * Display help for command",
|
|
571
|
+
" run-once * Internal: run one dispatch packet from JSON",
|
|
572
|
+
" mcp * Internal: run the phase-completion MCP server",
|
|
573
|
+
"",
|
|
574
|
+
"Examples:",
|
|
575
|
+
" npm install -g @loucompanion/forge-bridge@next",
|
|
576
|
+
" forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
|
|
577
|
+
" forge-bridge connect",
|
|
578
|
+
" forge-bridge status --check-updates",
|
|
579
|
+
" forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
|
|
580
|
+
" forge-bridge cleanup --stale",
|
|
581
|
+
"",
|
|
582
|
+
"Troubleshooting:",
|
|
583
|
+
" If Forge says the bridge is old: npm install -g @loucompanion/forge-bridge@next",
|
|
584
|
+
" If connect shows 502/500: leave it open; it reconnects with backoff while dev restarts.",
|
|
585
|
+
" If a provider is missing: run forge-bridge status --check-updates and verify CLI login.",
|
|
586
|
+
"",
|
|
587
|
+
"Docs:",
|
|
588
|
+
" https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
|
|
589
|
+
"",
|
|
590
|
+
].join("\n");
|
|
591
|
+
}
|
|
592
|
+
function commandHelpText(command) {
|
|
593
|
+
switch (command) {
|
|
594
|
+
case "pair":
|
|
595
|
+
return [
|
|
596
|
+
"Forge Bridge — pair",
|
|
597
|
+
"Pair this machine with Forge and save runner credentials.",
|
|
598
|
+
"",
|
|
599
|
+
"Usage:",
|
|
600
|
+
" forge-bridge pair [code] [options]",
|
|
601
|
+
"",
|
|
602
|
+
"Options:",
|
|
603
|
+
" --server <url> Forge server URL.",
|
|
604
|
+
" --repo-root <path> Folder where bridge-managed repos/worktrees live.",
|
|
605
|
+
" --name <name> Display name for this runner.",
|
|
606
|
+
" --fingerprint <value> Stable device fingerprint override.",
|
|
607
|
+
" --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
|
|
608
|
+
" --cleanup-worktrees Enable managed worktree cleanup.",
|
|
609
|
+
" --no-cleanup-worktrees Disable managed worktree cleanup.",
|
|
610
|
+
"",
|
|
611
|
+
"Example:",
|
|
612
|
+
" forge-bridge pair 1234-5678 --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
|
|
613
|
+
"",
|
|
614
|
+
].join("\n");
|
|
615
|
+
case "connect":
|
|
616
|
+
return [
|
|
617
|
+
"Forge Bridge — connect",
|
|
618
|
+
"Start the runner bridge and keep it connected for Forge dispatches.",
|
|
619
|
+
"",
|
|
620
|
+
"Usage:",
|
|
621
|
+
" forge-bridge connect [options]",
|
|
622
|
+
"",
|
|
623
|
+
"Options:",
|
|
624
|
+
" --server <url> Forge server URL; saved config is used when omitted.",
|
|
625
|
+
" --runner-id <id> Runner id; saved config is used when omitted.",
|
|
626
|
+
" --device-token <token> Device token; saved config is used when omitted.",
|
|
627
|
+
" --repo-root <path> Override repo/worktree folder for this run.",
|
|
628
|
+
" --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
|
|
629
|
+
" --heartbeat-interval-ms <n> Heartbeat interval in milliseconds.",
|
|
630
|
+
" --cleanup-worktrees Enable managed worktree cleanup for this run.",
|
|
631
|
+
" --no-cleanup-worktrees Disable managed worktree cleanup for this run.",
|
|
632
|
+
"",
|
|
633
|
+
"Example:",
|
|
634
|
+
" forge-bridge connect",
|
|
635
|
+
"",
|
|
636
|
+
].join("\n");
|
|
637
|
+
case "status":
|
|
638
|
+
return [
|
|
639
|
+
"Forge Bridge — status",
|
|
640
|
+
"Show saved configuration, paired runner identity, repo mappings, detected provider CLIs, and optional update info.",
|
|
641
|
+
"",
|
|
642
|
+
"Usage:",
|
|
643
|
+
" forge-bridge status [--check-updates]",
|
|
644
|
+
"",
|
|
645
|
+
"Example:",
|
|
646
|
+
" forge-bridge status --check-updates",
|
|
647
|
+
"",
|
|
648
|
+
].join("\n");
|
|
649
|
+
case "add-repo":
|
|
650
|
+
return [
|
|
651
|
+
"Forge Bridge — add-repo",
|
|
652
|
+
"Map a Forge repo label/name to an existing local checkout.",
|
|
653
|
+
"",
|
|
654
|
+
"Usage:",
|
|
655
|
+
" forge-bridge add-repo <label> <path>",
|
|
656
|
+
"",
|
|
657
|
+
"Example:",
|
|
658
|
+
" forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
|
|
659
|
+
"",
|
|
660
|
+
].join("\n");
|
|
661
|
+
case "remove-repo":
|
|
662
|
+
return [
|
|
663
|
+
"Forge Bridge — remove-repo",
|
|
664
|
+
"Remove a saved local repo mapping.",
|
|
665
|
+
"",
|
|
666
|
+
"Usage:",
|
|
667
|
+
" forge-bridge remove-repo <label>",
|
|
668
|
+
"",
|
|
669
|
+
].join("\n");
|
|
670
|
+
case "cleanup":
|
|
671
|
+
return [
|
|
672
|
+
"Forge Bridge — cleanup",
|
|
673
|
+
"Prune managed bridge worktrees under the configured repo folder.",
|
|
674
|
+
"",
|
|
675
|
+
"Usage:",
|
|
676
|
+
" forge-bridge cleanup [--stale|--merged|--all] [--force]",
|
|
677
|
+
"",
|
|
678
|
+
"Examples:",
|
|
679
|
+
" forge-bridge cleanup --stale",
|
|
680
|
+
" forge-bridge cleanup --all --force",
|
|
681
|
+
"",
|
|
682
|
+
].join("\n");
|
|
683
|
+
case "run-once":
|
|
684
|
+
return [
|
|
685
|
+
"Forge Bridge — run-once",
|
|
686
|
+
"Internal command used by Forge to run a single dispatch packet.",
|
|
687
|
+
"",
|
|
688
|
+
"Usage:",
|
|
689
|
+
" forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path>",
|
|
690
|
+
"",
|
|
691
|
+
].join("\n");
|
|
692
|
+
case "mcp":
|
|
693
|
+
return [
|
|
694
|
+
"Forge Bridge — mcp",
|
|
695
|
+
"Internal MCP server used by provider CLIs to report Forge phase artifacts.",
|
|
696
|
+
"",
|
|
697
|
+
"Usage:",
|
|
698
|
+
" forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
|
|
699
|
+
"",
|
|
700
|
+
].join("\n");
|
|
701
|
+
case "version":
|
|
702
|
+
return [
|
|
703
|
+
"Forge Bridge — version",
|
|
704
|
+
"Print the installed bridge package version.",
|
|
705
|
+
"",
|
|
706
|
+
"Usage:",
|
|
707
|
+
" forge-bridge version",
|
|
708
|
+
"",
|
|
709
|
+
].join("\n");
|
|
710
|
+
default:
|
|
711
|
+
return [
|
|
712
|
+
`Unknown forge-bridge command: ${command}`,
|
|
713
|
+
"",
|
|
714
|
+
"Run `forge-bridge help` to see available commands.",
|
|
715
|
+
"",
|
|
716
|
+
].join("\n");
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function connectStartupText(config) {
|
|
720
|
+
const runner = config.runnerId
|
|
721
|
+
? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
|
|
722
|
+
: "not configured";
|
|
723
|
+
return [
|
|
724
|
+
`Forge Bridge ${config.version}`,
|
|
725
|
+
"Starting runner bridge...",
|
|
726
|
+
"",
|
|
727
|
+
"Startup:",
|
|
728
|
+
` server: ${config.serverUrl || "not configured"}`,
|
|
729
|
+
` runner: ${runner}`,
|
|
730
|
+
` config: ${resolveLocalConfigPath()}`,
|
|
731
|
+
` repo folder: ${config.defaultRepoRoot || "not configured"}`,
|
|
732
|
+
` providers: ${formatList(config.cliProviders)}`,
|
|
733
|
+
` cleanup worktrees: ${config.cleanupWorktrees ? "enabled" : "disabled"}`,
|
|
734
|
+
"",
|
|
735
|
+
"Press Ctrl+C to stop.",
|
|
736
|
+
"",
|
|
737
|
+
].join("\n");
|
|
738
|
+
}
|
|
739
|
+
function formatList(values) {
|
|
740
|
+
return values.length > 0 ? values.join(", ") : "none";
|
|
526
741
|
}
|
|
527
742
|
async function writeJsonAtomic(outputPath, value) {
|
|
528
743
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
@@ -16,6 +16,7 @@ export interface BridgeLocalConfig {
|
|
|
16
16
|
readonly supportsManagedWorktrees?: boolean;
|
|
17
17
|
readonly supportsManagedForgeArtifacts?: boolean;
|
|
18
18
|
readonly supportsRunnerAuthoredPhaseArtifacts?: boolean;
|
|
19
|
+
readonly supportsDraftRunOnce?: boolean;
|
|
19
20
|
}
|
|
20
21
|
export interface LocalRepoMapping extends BridgeRepoMapping {
|
|
21
22
|
readonly label: string;
|