@loucompanion/forge-bridge 0.1.1-dev.e52b249f07a8 → 0.1.1-dev.e646c8fd6b9b
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 +184 -24
- package/dist/bridge/bridge.types.d.ts +4 -2
- package/dist/cli/cli.service.js +225 -10
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +18 -3
- package/dist/config.js +1 -1
- package/dist/session/internals.d.ts +1 -1
- package/dist/session/phase-completion.d.ts +30 -5
- package/dist/session/phase-completion.js +1337 -28
- package/dist/session/provider-cli/provider-cli.base.d.ts +3 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +9 -1
- package/dist/session/provider-cli/provider-cli.service.js +1198 -236
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/session.service.js +47 -5
- package/dist/session/session.types.d.ts +92 -0
- package/dist/session/session.types.js +166 -1
- 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 -1
- 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;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { defaultForgeBridgeConfig } from "../config.js";
|
|
4
5
|
import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
|
|
@@ -48,8 +49,14 @@ export class ForgeBridgeService {
|
|
|
48
49
|
while (!this.stopped && !signal?.aborted) {
|
|
49
50
|
try {
|
|
50
51
|
await this.connectOnce();
|
|
52
|
+
if (attempt > 0) {
|
|
53
|
+
logBridgeStatus("Connected to Forge. Waiting for dispatches...");
|
|
54
|
+
}
|
|
51
55
|
attempt = 0;
|
|
52
56
|
await this.waitForClose(signal);
|
|
57
|
+
if (!this.stopped && !signal?.aborted) {
|
|
58
|
+
logBridgeStatus("Connection closed. Reconnecting...");
|
|
59
|
+
}
|
|
53
60
|
}
|
|
54
61
|
catch (error) {
|
|
55
62
|
if (this.stopped || signal?.aborted) {
|
|
@@ -57,6 +64,7 @@ export class ForgeBridgeService {
|
|
|
57
64
|
}
|
|
58
65
|
const backoff = this.config.reconnectBackoffMs[Math.min(attempt, this.config.reconnectBackoffMs.length - 1)] ?? 5000;
|
|
59
66
|
attempt += 1;
|
|
67
|
+
logBridgeStatus(`Connect attempt ${attempt} failed: ${errorMessage(error)}. Retrying in ${formatDelay(backoff)}...`);
|
|
60
68
|
await delay(backoff, signal);
|
|
61
69
|
}
|
|
62
70
|
}
|
|
@@ -73,10 +81,14 @@ export class ForgeBridgeService {
|
|
|
73
81
|
this.abortActiveSessions("bridge stopping");
|
|
74
82
|
await this.stopConnection();
|
|
75
83
|
}
|
|
76
|
-
async handleDispatch(
|
|
84
|
+
async handleDispatch(rawDispatch) {
|
|
85
|
+
const dispatch = this.parseDispatchOrReport(rawDispatch);
|
|
86
|
+
if (!dispatch) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
77
89
|
const leaseId = dispatch.lease.id;
|
|
78
90
|
const fencingToken = dispatch.lease.fencingToken;
|
|
79
|
-
const redactionSecrets = exactSecrets(dispatch);
|
|
91
|
+
const redactionSecrets = exactSecrets(dispatch, this.config);
|
|
80
92
|
if (this.activeSessions.has(leaseId)) {
|
|
81
93
|
await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
|
|
82
94
|
return;
|
|
@@ -87,11 +99,20 @@ export class ForgeBridgeService {
|
|
|
87
99
|
const normalized = this.normalizeDispatch(dispatch);
|
|
88
100
|
const result = await this.sessions.runOnce(normalized, {
|
|
89
101
|
emit: async (event) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
try {
|
|
103
|
+
await this.invokeAck("DispatchEvent", {
|
|
104
|
+
leaseId,
|
|
105
|
+
fencingToken,
|
|
106
|
+
event: redactSecrets(event, redactionSecrets)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (isDispatchNoLongerPending(error)) {
|
|
111
|
+
controller.abort(error instanceof Error ? error.message : "Dispatch is no longer pending.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
95
116
|
}
|
|
96
117
|
}, controller.signal);
|
|
97
118
|
await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
|
|
@@ -106,6 +127,27 @@ export class ForgeBridgeService {
|
|
|
106
127
|
this.activeSessions.delete(leaseId);
|
|
107
128
|
}
|
|
108
129
|
}
|
|
130
|
+
parseDispatchOrReport(rawDispatch) {
|
|
131
|
+
try {
|
|
132
|
+
return parseRunOnceDispatchPacket(rawDispatch);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const identifiers = readDispatchIdentifiers(rawDispatch);
|
|
136
|
+
if (!identifiers) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const summary = invalidDispatchSummary(error);
|
|
140
|
+
void this.sendResult(identifiers.leaseId, identifiers.fencingToken, failureResult(summary, [
|
|
141
|
+
{
|
|
142
|
+
role: "runner",
|
|
143
|
+
kind: "error",
|
|
144
|
+
content: summary,
|
|
145
|
+
eventType: "invalid_dispatch"
|
|
146
|
+
}
|
|
147
|
+
])).catch(() => undefined);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
109
151
|
async handleCancel(request) {
|
|
110
152
|
const active = this.activeSessions.get(request.leaseId);
|
|
111
153
|
if (!active || active.fencingToken !== request.fencingToken) {
|
|
@@ -120,7 +162,7 @@ export class ForgeBridgeService {
|
|
|
120
162
|
this.activeSessions.clear();
|
|
121
163
|
}
|
|
122
164
|
normalizeDispatch(dispatch) {
|
|
123
|
-
const mapping = findRepoMapping(dispatch, this.config
|
|
165
|
+
const mapping = findRepoMapping(dispatch, repoMappingsFor(this.config));
|
|
124
166
|
const localPath = mapping?.localPath?.trim();
|
|
125
167
|
const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
|
|
126
168
|
const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
|
|
@@ -149,7 +191,15 @@ export class ForgeBridgeService {
|
|
|
149
191
|
fencingToken,
|
|
150
192
|
result
|
|
151
193
|
};
|
|
152
|
-
|
|
194
|
+
try {
|
|
195
|
+
await this.invokeAck("DispatchResult", payload);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (isDispatchNoLongerPending(error)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
153
203
|
}
|
|
154
204
|
startHeartbeat() {
|
|
155
205
|
this.stopHeartbeat();
|
|
@@ -180,13 +230,14 @@ export class ForgeBridgeService {
|
|
|
180
230
|
cliProviders: this.config.cliProviders,
|
|
181
231
|
providerStatuses: this.config.providerStatuses,
|
|
182
232
|
projectLabels: this.config.projectLabels,
|
|
183
|
-
repoMappings: this.config
|
|
233
|
+
repoMappings: repoMappingsFor(this.config),
|
|
184
234
|
os: this.config.os,
|
|
185
235
|
sandboxProfiles: this.config.sandboxProfiles,
|
|
186
236
|
supportsApiSecretGrant: this.config.supportsApiSecretGrant,
|
|
187
237
|
supportsManagedWorktrees: this.config.supportsManagedWorktrees,
|
|
188
238
|
supportsManagedForgeArtifacts: this.config.supportsManagedForgeArtifacts,
|
|
189
|
-
supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts
|
|
239
|
+
supportsRunnerAuthoredPhaseArtifacts: this.config.supportsRunnerAuthoredPhaseArtifacts,
|
|
240
|
+
supportsDraftRunOnce: this.config.supportsDraftRunOnce
|
|
190
241
|
};
|
|
191
242
|
}
|
|
192
243
|
async invokeAck(methodName, payload) {
|
|
@@ -196,7 +247,7 @@ export class ForgeBridgeService {
|
|
|
196
247
|
}
|
|
197
248
|
const ack = await connection.invoke(methodName, payload);
|
|
198
249
|
if (!ack.accepted) {
|
|
199
|
-
throw new
|
|
250
|
+
throw new BridgeCallbackRejectedError(ack.code ?? null, ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
|
|
200
251
|
}
|
|
201
252
|
return ack;
|
|
202
253
|
}
|
|
@@ -214,6 +265,20 @@ export class ForgeBridgeService {
|
|
|
214
265
|
}
|
|
215
266
|
}
|
|
216
267
|
}
|
|
268
|
+
class BridgeCallbackRejectedError extends Error {
|
|
269
|
+
code;
|
|
270
|
+
constructor(code, message) {
|
|
271
|
+
super(message);
|
|
272
|
+
this.code = code;
|
|
273
|
+
this.name = "BridgeCallbackRejectedError";
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function isDispatchNoLongerPending(error) {
|
|
277
|
+
if (error instanceof BridgeCallbackRejectedError) {
|
|
278
|
+
return error.code === "bridge.dispatch_not_pending" || error.code === "bridge.stale_dispatch";
|
|
279
|
+
}
|
|
280
|
+
return error instanceof Error && error.message === "Dispatch is no longer pending.";
|
|
281
|
+
}
|
|
217
282
|
export class SignalRBridgeConnectionFactory {
|
|
218
283
|
create(config) {
|
|
219
284
|
return new SignalRBridgeConnection(config);
|
|
@@ -244,14 +309,7 @@ export class SignalRBridgeConnection {
|
|
|
244
309
|
}
|
|
245
310
|
onDispatch(handler) {
|
|
246
311
|
this.connection.on("Dispatch", (raw) => {
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
dispatch = parseRunOnceDispatchPacket(raw);
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
void handler(dispatch).catch(() => undefined);
|
|
312
|
+
void handler(raw).catch(() => undefined);
|
|
255
313
|
});
|
|
256
314
|
}
|
|
257
315
|
onCancel(handler) {
|
|
@@ -278,11 +336,12 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
278
336
|
projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
|
|
279
337
|
repoMappings: [],
|
|
280
338
|
os: process.platform,
|
|
281
|
-
sandboxProfiles:
|
|
339
|
+
sandboxProfiles: overrides.sandboxProfiles,
|
|
282
340
|
supportsApiSecretGrant: true,
|
|
283
341
|
supportsManagedWorktrees: true,
|
|
284
342
|
supportsManagedForgeArtifacts: true,
|
|
285
343
|
supportsRunnerAuthoredPhaseArtifacts: true,
|
|
344
|
+
supportsDraftRunOnce: true,
|
|
286
345
|
version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
|
|
287
346
|
protocolVersion: 1,
|
|
288
347
|
minServerVersion: null,
|
|
@@ -292,6 +351,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
|
|
|
292
351
|
};
|
|
293
352
|
return {
|
|
294
353
|
...config,
|
|
354
|
+
sandboxProfiles: config.sandboxProfiles ?? defaultSandboxProfiles(config.cliProviders),
|
|
295
355
|
heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
|
|
296
356
|
};
|
|
297
357
|
}
|
|
@@ -310,6 +370,14 @@ function positiveFiniteNumber(value, fallback) {
|
|
|
310
370
|
function providerStatusesFor(providers) {
|
|
311
371
|
return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
|
|
312
372
|
}
|
|
373
|
+
function defaultSandboxProfiles(providers, platform = process.platform) {
|
|
374
|
+
if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
|
|
375
|
+
return ["none"];
|
|
376
|
+
}
|
|
377
|
+
return platform === "win32"
|
|
378
|
+
? ["danger-full-access"]
|
|
379
|
+
: ["provider-workspace-write"];
|
|
380
|
+
}
|
|
313
381
|
function configuredModelsFor(provider) {
|
|
314
382
|
const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
|
|
315
383
|
return splitCsv(process.env[envName] ?? "");
|
|
@@ -320,6 +388,62 @@ function splitCsv(value) {
|
|
|
320
388
|
.map((entry) => entry.trim())
|
|
321
389
|
.filter((entry) => entry.length > 0);
|
|
322
390
|
}
|
|
391
|
+
function repoMappingsFor(config) {
|
|
392
|
+
const explicit = [...config.repoMappings];
|
|
393
|
+
const discovered = discoverRepoRootMappings(config.defaultRepoRoot);
|
|
394
|
+
if (explicit.length === 0) {
|
|
395
|
+
return discovered;
|
|
396
|
+
}
|
|
397
|
+
const explicitKeys = new Set(explicit.flatMap(mappingKeys));
|
|
398
|
+
return [
|
|
399
|
+
...explicit,
|
|
400
|
+
...discovered.filter((mapping) => !mappingKeys(mapping).some((key) => explicitKeys.has(key)))
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
function discoverRepoRootMappings(defaultRepoRoot) {
|
|
404
|
+
const root = defaultRepoRoot.trim();
|
|
405
|
+
if (!root) {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
const resolvedRoot = path.resolve(root);
|
|
409
|
+
const mappings = [];
|
|
410
|
+
if (isGitCheckout(resolvedRoot)) {
|
|
411
|
+
mappings.push({ name: path.basename(resolvedRoot), localPath: resolvedRoot });
|
|
412
|
+
}
|
|
413
|
+
let entries;
|
|
414
|
+
try {
|
|
415
|
+
entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return mappings;
|
|
419
|
+
}
|
|
420
|
+
for (const entry of entries) {
|
|
421
|
+
if (!entry.isDirectory()) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const localPath = path.join(resolvedRoot, entry.name);
|
|
425
|
+
if (isGitCheckout(localPath)) {
|
|
426
|
+
mappings.push({ name: entry.name, localPath });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return mappings.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
|
430
|
+
}
|
|
431
|
+
function isGitCheckout(localPath) {
|
|
432
|
+
try {
|
|
433
|
+
return fs.existsSync(path.join(localPath, ".git"));
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function mappingKeys(mapping) {
|
|
440
|
+
return [
|
|
441
|
+
mapping.repoId ? `id:${mapping.repoId.toLowerCase()}` : null,
|
|
442
|
+
mapping.name ? `name:${mapping.name.trim().toLowerCase()}` : null,
|
|
443
|
+
mapping.remoteUrl ? `remote:${mapping.remoteUrl.trim().toLowerCase()}` : null,
|
|
444
|
+
mapping.localPath ? `path:${path.resolve(mapping.localPath).toLowerCase()}` : null
|
|
445
|
+
].filter((key) => Boolean(key));
|
|
446
|
+
}
|
|
323
447
|
function findRepoMapping(dispatch, mappings) {
|
|
324
448
|
return mappings.find((mapping) => {
|
|
325
449
|
if (!mapping.localPath?.trim()) {
|
|
@@ -354,13 +478,46 @@ function cleanString(value) {
|
|
|
354
478
|
function equalsIgnoreCase(left, right) {
|
|
355
479
|
return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
|
|
356
480
|
}
|
|
481
|
+
function readDispatchIdentifiers(rawDispatch) {
|
|
482
|
+
if (!rawDispatch || typeof rawDispatch !== "object" || Array.isArray(rawDispatch)) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
const packet = rawDispatch;
|
|
486
|
+
const rawLease = packet.lease ?? packet.Lease;
|
|
487
|
+
if (!rawLease || typeof rawLease !== "object" || Array.isArray(rawLease)) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
const lease = rawLease;
|
|
491
|
+
const leaseId = readString(lease, "id", "Id");
|
|
492
|
+
const fencingToken = readString(lease, "fencingToken", "FencingToken");
|
|
493
|
+
return leaseId && fencingToken ? { leaseId, fencingToken } : null;
|
|
494
|
+
}
|
|
495
|
+
function readString(source, camelKey, pascalKey) {
|
|
496
|
+
const value = source[camelKey] ?? source[pascalKey];
|
|
497
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
498
|
+
}
|
|
499
|
+
function invalidDispatchSummary(error) {
|
|
500
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
501
|
+
return `Invalid bridge dispatch packet: ${detail}`;
|
|
502
|
+
}
|
|
503
|
+
function errorMessage(error) {
|
|
504
|
+
return error instanceof Error ? error.message : String(error);
|
|
505
|
+
}
|
|
506
|
+
function formatDelay(ms) {
|
|
507
|
+
if (ms >= 1000 && ms % 1000 === 0) {
|
|
508
|
+
return `${ms / 1000}s`;
|
|
509
|
+
}
|
|
510
|
+
return `${ms}ms`;
|
|
511
|
+
}
|
|
512
|
+
function logBridgeStatus(message) {
|
|
513
|
+
process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
|
|
514
|
+
}
|
|
357
515
|
function delay(ms, signal) {
|
|
358
516
|
if (signal?.aborted) {
|
|
359
517
|
return Promise.resolve();
|
|
360
518
|
}
|
|
361
519
|
return new Promise((resolve) => {
|
|
362
520
|
const timeout = setTimeout(resolve, ms);
|
|
363
|
-
timeout.unref?.();
|
|
364
521
|
signal?.addEventListener("abort", () => {
|
|
365
522
|
clearTimeout(timeout);
|
|
366
523
|
resolve();
|
|
@@ -376,6 +533,9 @@ function abortPromise(signal) {
|
|
|
376
533
|
}
|
|
377
534
|
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
378
535
|
}
|
|
379
|
-
function exactSecrets(dispatch) {
|
|
380
|
-
return
|
|
536
|
+
function exactSecrets(dispatch, config) {
|
|
537
|
+
return [
|
|
538
|
+
dispatch.apiKeySecretGrant?.secret,
|
|
539
|
+
config.deviceToken
|
|
540
|
+
].filter((secret) => typeof secret === "string" && secret.length > 0);
|
|
381
541
|
}
|
|
@@ -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;
|
|
@@ -55,6 +56,7 @@ export interface BridgeCapabilities {
|
|
|
55
56
|
readonly supportsManagedWorktrees?: boolean | null;
|
|
56
57
|
readonly supportsManagedForgeArtifacts?: boolean | null;
|
|
57
58
|
readonly supportsRunnerAuthoredPhaseArtifacts?: boolean | null;
|
|
59
|
+
readonly supportsDraftRunOnce?: boolean | null;
|
|
58
60
|
}
|
|
59
61
|
export interface BridgeHelloRequest {
|
|
60
62
|
readonly version: string;
|
|
@@ -88,5 +90,5 @@ export interface BridgeCancelRequest {
|
|
|
88
90
|
readonly fencingToken: string;
|
|
89
91
|
readonly reason?: string | null;
|
|
90
92
|
}
|
|
91
|
-
export type BridgeDispatchHandler = (dispatch:
|
|
93
|
+
export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
|
|
92
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;
|