@loucompanion/forge-bridge 0.1.1-dev.242bb53ef13f
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 +63 -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 +340 -0
- package/dist/bridge/bridge.types.d.ts +76 -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 +45 -0
- package/dist/cli/cli.service.js +400 -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 +31 -0
- package/dist/cli/local-config.js +146 -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 +6 -0
- package/dist/session/provider-cli/provider-cli.base.js +1 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.service.js +111 -0
- package/dist/session/provider-cli/provider-cli.types.d.ts +12 -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 +92 -0
- package/dist/session/session.types.d.ts +107 -0
- package/dist/session/session.types.js +151 -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/index.d.ts +2 -0
- package/dist/worktree/index.js +1 -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 +12 -0
- package/dist/worktree/worktree.service.js +139 -0
- package/dist/worktree/worktree.types.d.ts +15 -0
- package/dist/worktree/worktree.types.js +1 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Forge Bridge CLI
|
|
2
|
+
|
|
3
|
+
`@loucompanion/forge-bridge` connects a local runner machine to a Forge server so Forge can dispatch work to approved local tooling and repositories.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Stable production channel:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @loucompanion/forge-bridge@latest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Tailnet dev channel:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @loucompanion/forge-bridge@next
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Basic usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
forge-bridge status
|
|
23
|
+
forge-bridge connect --server <forge-url> --runner-id <runner-id> --device-token <device-token>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The bridge can also read connection settings from environment variables:
|
|
27
|
+
|
|
28
|
+
- `FORGE_URL`
|
|
29
|
+
- `FORGE_RUNNER_ID`
|
|
30
|
+
- `FORGE_DEVICE_TOKEN`
|
|
31
|
+
- `FORGE_DEVICE_NAME`
|
|
32
|
+
- `FORGE_REPO_ROOT`
|
|
33
|
+
- `FORGE_CLI_PROVIDERS`
|
|
34
|
+
|
|
35
|
+
## Repo-local development
|
|
36
|
+
|
|
37
|
+
For active Forge branch development, run the bridge directly from the checkout instead of publishing a package:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
scripts/run-bridge-dev.sh status
|
|
41
|
+
scripts/run-bridge-dev.sh connect --server <forge-url> --runner-id <runner-id> --device-token <device-token>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The tailnet dev server advertises the installable `@next` prerelease version. Repo-local execution is for contributors testing the current checkout.
|
|
45
|
+
|
|
46
|
+
## Repository mappings
|
|
47
|
+
|
|
48
|
+
Map Forge project labels to local repository paths:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
forge-bridge add-repo Forge /path/to/Forge
|
|
52
|
+
forge-bridge remove-repo Forge
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Package smoke check
|
|
56
|
+
|
|
57
|
+
From this package directory:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm run smoke:package
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The smoke check builds the CLI, packs the package, installs the tarball into a temporary prefix, and verifies the installed `forge-bridge` binary can run `status`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BridgeCancelHandler, BridgeDispatchHandler, BridgeServiceConfig } from "./bridge.types.js";
|
|
2
|
+
export interface BridgeService {
|
|
3
|
+
connectOnce(): Promise<void>;
|
|
4
|
+
run(signal?: AbortSignal): Promise<void>;
|
|
5
|
+
stop(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface BridgeConnection {
|
|
8
|
+
start(): Promise<void>;
|
|
9
|
+
stop(): Promise<void>;
|
|
10
|
+
onClose(handler: (error?: Error) => void): void;
|
|
11
|
+
onDispatch(handler: BridgeDispatchHandler): void;
|
|
12
|
+
onCancel(handler: BridgeCancelHandler): void;
|
|
13
|
+
invoke<TResponse>(methodName: string, payload: unknown): Promise<TResponse>;
|
|
14
|
+
}
|
|
15
|
+
export interface BridgeConnectionFactory {
|
|
16
|
+
create(config: BridgeServiceConfig): BridgeConnection;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SessionService } from "../session/session.base.js";
|
|
2
|
+
import type { BridgeConnection, BridgeConnectionFactory, BridgeService } from "./bridge.base.js";
|
|
3
|
+
import type { BridgeCancelHandler, BridgeDispatchHandler, BridgeServiceConfig } from "./bridge.types.js";
|
|
4
|
+
export declare class ForgeBridgeService implements BridgeService {
|
|
5
|
+
private readonly config;
|
|
6
|
+
private readonly sessions;
|
|
7
|
+
private readonly connections;
|
|
8
|
+
private connection;
|
|
9
|
+
private heartbeatTimer;
|
|
10
|
+
private closed;
|
|
11
|
+
private stopped;
|
|
12
|
+
private readonly activeSessions;
|
|
13
|
+
constructor(config: BridgeServiceConfig, sessions?: SessionService, connections?: BridgeConnectionFactory);
|
|
14
|
+
connectOnce(): Promise<void>;
|
|
15
|
+
run(signal?: AbortSignal): Promise<void>;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
private handleDispatch;
|
|
18
|
+
private handleCancel;
|
|
19
|
+
private abortActiveSessions;
|
|
20
|
+
private normalizeDispatch;
|
|
21
|
+
private sendResult;
|
|
22
|
+
private startHeartbeat;
|
|
23
|
+
private stopHeartbeat;
|
|
24
|
+
private sendHeartbeat;
|
|
25
|
+
private capabilities;
|
|
26
|
+
private invokeAck;
|
|
27
|
+
private waitForClose;
|
|
28
|
+
private stopConnection;
|
|
29
|
+
}
|
|
30
|
+
export declare class SignalRBridgeConnectionFactory implements BridgeConnectionFactory {
|
|
31
|
+
create(config: BridgeServiceConfig): BridgeConnection;
|
|
32
|
+
}
|
|
33
|
+
export declare class SignalRBridgeConnection implements BridgeConnection {
|
|
34
|
+
private readonly config;
|
|
35
|
+
private readonly connection;
|
|
36
|
+
constructor(config: BridgeServiceConfig);
|
|
37
|
+
start(): Promise<void>;
|
|
38
|
+
stop(): Promise<void>;
|
|
39
|
+
onClose(handler: (error?: Error) => void): void;
|
|
40
|
+
onDispatch(handler: BridgeDispatchHandler): void;
|
|
41
|
+
onCancel(handler: BridgeCancelHandler): void;
|
|
42
|
+
invoke<TResponse>(methodName: string, payload: unknown): Promise<TResponse>;
|
|
43
|
+
}
|
|
44
|
+
export declare function defaultBridgeServiceConfig(overrides?: Partial<BridgeServiceConfig>): BridgeServiceConfig;
|
|
45
|
+
export declare function resolveBridgeRunnerHubUrl(serverUrl: string): URL;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultForgeBridgeConfig } from "../config.js";
|
|
4
|
+
import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
|
|
5
|
+
import { BridgeSessionService } from "../session/session.service.js";
|
|
6
|
+
import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
|
|
7
|
+
import { forgeBridgeVersion } from "../version.js";
|
|
8
|
+
export class ForgeBridgeService {
|
|
9
|
+
config;
|
|
10
|
+
sessions;
|
|
11
|
+
connections;
|
|
12
|
+
connection = null;
|
|
13
|
+
heartbeatTimer = null;
|
|
14
|
+
closed = null;
|
|
15
|
+
stopped = false;
|
|
16
|
+
activeSessions = new Map();
|
|
17
|
+
constructor(config, sessions = new BridgeSessionService(), connections = new SignalRBridgeConnectionFactory()) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.sessions = sessions;
|
|
20
|
+
this.connections = connections;
|
|
21
|
+
}
|
|
22
|
+
async connectOnce() {
|
|
23
|
+
await this.stopConnection();
|
|
24
|
+
this.stopped = false;
|
|
25
|
+
const connection = this.connections.create(this.config);
|
|
26
|
+
this.connection = connection;
|
|
27
|
+
this.closed = new Promise((resolve) => {
|
|
28
|
+
connection.onClose(() => {
|
|
29
|
+
this.stopHeartbeat();
|
|
30
|
+
this.abortActiveSessions("bridge connection closed");
|
|
31
|
+
resolve();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
connection.onDispatch((dispatch) => this.handleDispatch(dispatch));
|
|
35
|
+
connection.onCancel((request) => this.handleCancel(request));
|
|
36
|
+
await connection.start();
|
|
37
|
+
await this.invokeAck("Hello", {
|
|
38
|
+
version: this.config.version,
|
|
39
|
+
protocolVersion: this.config.protocolVersion,
|
|
40
|
+
minServerVersion: this.config.minServerVersion ?? null,
|
|
41
|
+
capabilities: this.capabilities()
|
|
42
|
+
});
|
|
43
|
+
this.startHeartbeat();
|
|
44
|
+
}
|
|
45
|
+
async run(signal) {
|
|
46
|
+
let attempt = 0;
|
|
47
|
+
try {
|
|
48
|
+
while (!this.stopped && !signal?.aborted) {
|
|
49
|
+
try {
|
|
50
|
+
await this.connectOnce();
|
|
51
|
+
attempt = 0;
|
|
52
|
+
await this.waitForClose(signal);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (this.stopped || signal?.aborted) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
const backoff = this.config.reconnectBackoffMs[Math.min(attempt, this.config.reconnectBackoffMs.length - 1)] ?? 5000;
|
|
59
|
+
attempt += 1;
|
|
60
|
+
await delay(backoff, signal);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (signal?.aborted) {
|
|
66
|
+
this.abortActiveSessions(String(signal.reason ?? "bridge stopping"));
|
|
67
|
+
}
|
|
68
|
+
await this.stopConnection();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async stop() {
|
|
72
|
+
this.stopped = true;
|
|
73
|
+
this.abortActiveSessions("bridge stopping");
|
|
74
|
+
await this.stopConnection();
|
|
75
|
+
}
|
|
76
|
+
async handleDispatch(dispatch) {
|
|
77
|
+
const leaseId = dispatch.lease.id;
|
|
78
|
+
const fencingToken = dispatch.lease.fencingToken;
|
|
79
|
+
const redactionSecrets = exactSecrets(dispatch);
|
|
80
|
+
if (this.activeSessions.has(leaseId)) {
|
|
81
|
+
await this.sendResult(leaseId, fencingToken, failureResult("Dispatch is already active on this bridge.")).catch(() => undefined);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
this.activeSessions.set(leaseId, { fencingToken, controller });
|
|
86
|
+
try {
|
|
87
|
+
const normalized = this.normalizeDispatch(dispatch);
|
|
88
|
+
const result = await this.sessions.runOnce(normalized, {
|
|
89
|
+
emit: async (event) => {
|
|
90
|
+
await this.invokeAck("DispatchEvent", {
|
|
91
|
+
leaseId,
|
|
92
|
+
fencingToken,
|
|
93
|
+
event: redactSecrets(event, redactionSecrets)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}, controller.signal);
|
|
97
|
+
await this.sendResult(leaseId, fencingToken, redactSecrets(result, redactionSecrets));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const summary = controller.signal.aborted
|
|
101
|
+
? `Dispatch cancelled: ${String(controller.signal.reason ?? "cancelled")}`
|
|
102
|
+
: redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
|
|
103
|
+
await this.sendResult(leaseId, fencingToken, failureResult(summary)).catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
this.activeSessions.delete(leaseId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async handleCancel(request) {
|
|
110
|
+
const active = this.activeSessions.get(request.leaseId);
|
|
111
|
+
if (!active || active.fencingToken !== request.fencingToken) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
active.controller.abort(request.reason ?? "cancelled by server");
|
|
115
|
+
}
|
|
116
|
+
abortActiveSessions(reason) {
|
|
117
|
+
for (const active of this.activeSessions.values()) {
|
|
118
|
+
active.controller.abort(reason);
|
|
119
|
+
}
|
|
120
|
+
this.activeSessions.clear();
|
|
121
|
+
}
|
|
122
|
+
normalizeDispatch(dispatch) {
|
|
123
|
+
const mapping = findRepoMapping(dispatch, this.config.repoMappings);
|
|
124
|
+
const localPath = mapping?.localPath?.trim();
|
|
125
|
+
const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
|
|
126
|
+
return {
|
|
127
|
+
...dispatch,
|
|
128
|
+
repo: {
|
|
129
|
+
...dispatch.repo,
|
|
130
|
+
serverLocalPath: localPath ? path.resolve(localPath) : null,
|
|
131
|
+
cloneOnDemand: localPath ? false : dispatch.repo.cloneOnDemand
|
|
132
|
+
},
|
|
133
|
+
serverLocal: {
|
|
134
|
+
repoRoot,
|
|
135
|
+
cleanupWorktrees: this.config.cleanupWorktrees
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async sendResult(leaseId, fencingToken, result) {
|
|
140
|
+
const payload = {
|
|
141
|
+
leaseId,
|
|
142
|
+
fencingToken,
|
|
143
|
+
result
|
|
144
|
+
};
|
|
145
|
+
await this.invokeAck("DispatchResult", payload);
|
|
146
|
+
}
|
|
147
|
+
startHeartbeat() {
|
|
148
|
+
this.stopHeartbeat();
|
|
149
|
+
const intervalMs = positiveFiniteNumber(this.config.heartbeatIntervalMs, 30_000);
|
|
150
|
+
this.heartbeatTimer = setInterval(() => {
|
|
151
|
+
this.sendHeartbeat().catch(() => undefined);
|
|
152
|
+
}, intervalMs);
|
|
153
|
+
this.heartbeatTimer.unref?.();
|
|
154
|
+
}
|
|
155
|
+
stopHeartbeat() {
|
|
156
|
+
if (this.heartbeatTimer) {
|
|
157
|
+
clearInterval(this.heartbeatTimer);
|
|
158
|
+
this.heartbeatTimer = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async sendHeartbeat() {
|
|
162
|
+
await this.invokeAck("Heartbeat", {
|
|
163
|
+
status: "online",
|
|
164
|
+
capabilities: this.capabilities(),
|
|
165
|
+
version: this.config.version,
|
|
166
|
+
protocolVersion: this.config.protocolVersion
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
capabilities() {
|
|
170
|
+
return {
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
activeSessionCount: this.activeSessions.size,
|
|
173
|
+
cliProviders: this.config.cliProviders,
|
|
174
|
+
providerStatuses: this.config.providerStatuses,
|
|
175
|
+
projectLabels: this.config.projectLabels,
|
|
176
|
+
repoMappings: this.config.repoMappings,
|
|
177
|
+
os: this.config.os,
|
|
178
|
+
sandboxProfiles: this.config.sandboxProfiles,
|
|
179
|
+
supportsApiSecretGrant: this.config.supportsApiSecretGrant
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async invokeAck(methodName, payload) {
|
|
183
|
+
const connection = this.connection;
|
|
184
|
+
if (!connection) {
|
|
185
|
+
throw new Error("Bridge is not connected.");
|
|
186
|
+
}
|
|
187
|
+
const ack = await connection.invoke(methodName, payload);
|
|
188
|
+
if (!ack.accepted) {
|
|
189
|
+
throw new Error(ack.message ?? ack.code ?? `${methodName} callback was rejected.`);
|
|
190
|
+
}
|
|
191
|
+
return ack;
|
|
192
|
+
}
|
|
193
|
+
async waitForClose(signal) {
|
|
194
|
+
if (!this.closed) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
await Promise.race([this.closed, abortPromise(signal)]);
|
|
198
|
+
}
|
|
199
|
+
async stopConnection() {
|
|
200
|
+
this.stopHeartbeat();
|
|
201
|
+
if (this.connection) {
|
|
202
|
+
await this.connection.stop().catch(() => undefined);
|
|
203
|
+
this.connection = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export class SignalRBridgeConnectionFactory {
|
|
208
|
+
create(config) {
|
|
209
|
+
return new SignalRBridgeConnection(config);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export class SignalRBridgeConnection {
|
|
213
|
+
config;
|
|
214
|
+
connection;
|
|
215
|
+
constructor(config) {
|
|
216
|
+
this.config = config;
|
|
217
|
+
const url = resolveBridgeRunnerHubUrl(config.serverUrl);
|
|
218
|
+
url.searchParams.set("runnerId", config.runnerId);
|
|
219
|
+
this.connection = new HubConnectionBuilder()
|
|
220
|
+
.withUrl(url.toString(), {
|
|
221
|
+
accessTokenFactory: () => config.deviceToken
|
|
222
|
+
})
|
|
223
|
+
.configureLogging(LogLevel.Warning)
|
|
224
|
+
.build();
|
|
225
|
+
}
|
|
226
|
+
start() {
|
|
227
|
+
return this.connection.start();
|
|
228
|
+
}
|
|
229
|
+
stop() {
|
|
230
|
+
return this.connection.stop();
|
|
231
|
+
}
|
|
232
|
+
onClose(handler) {
|
|
233
|
+
this.connection.onclose(handler);
|
|
234
|
+
}
|
|
235
|
+
onDispatch(handler) {
|
|
236
|
+
this.connection.on("Dispatch", (raw) => {
|
|
237
|
+
let dispatch;
|
|
238
|
+
try {
|
|
239
|
+
dispatch = parseRunOnceDispatchPacket(raw);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
void handler(dispatch).catch(() => undefined);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
onCancel(handler) {
|
|
248
|
+
this.connection.on("Cancel", (request) => {
|
|
249
|
+
void handler(request).catch(() => undefined);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
invoke(methodName, payload) {
|
|
253
|
+
return this.connection.invoke(methodName, payload);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export function defaultBridgeServiceConfig(overrides = {}) {
|
|
257
|
+
const defaults = defaultForgeBridgeConfig();
|
|
258
|
+
const cliProviders = splitCsv(process.env.FORGE_CLI_PROVIDERS ?? "codex");
|
|
259
|
+
const config = {
|
|
260
|
+
serverUrl: process.env.FORGE_URL ?? "http://localhost:5208",
|
|
261
|
+
runnerId: process.env.FORGE_RUNNER_ID ?? "",
|
|
262
|
+
deviceToken: process.env.FORGE_DEVICE_TOKEN ?? "",
|
|
263
|
+
deviceName: process.env.FORGE_DEVICE_NAME ?? "",
|
|
264
|
+
defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
|
|
265
|
+
cleanupWorktrees: process.env.FORGE_CLEANUP_WORKTREES !== "false" && defaults.cleanupWorktrees,
|
|
266
|
+
cliProviders,
|
|
267
|
+
providerStatuses: Object.fromEntries(cliProviders.map((provider) => [provider, { signedIn: true, version: "" }])),
|
|
268
|
+
projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
|
|
269
|
+
repoMappings: [],
|
|
270
|
+
os: process.platform,
|
|
271
|
+
sandboxProfiles: ["default"],
|
|
272
|
+
supportsApiSecretGrant: true,
|
|
273
|
+
version: process.env.FORGE_BRIDGE_VERSION ?? forgeBridgeVersion,
|
|
274
|
+
protocolVersion: 1,
|
|
275
|
+
minServerVersion: null,
|
|
276
|
+
heartbeatIntervalMs: positiveFiniteNumber(Number(process.env.FORGE_HEARTBEAT_INTERVAL_MS ?? 30_000), 30_000),
|
|
277
|
+
reconnectBackoffMs: [1000, 2000, 5000, 10000, 30000],
|
|
278
|
+
...overrides
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
...config,
|
|
282
|
+
heartbeatIntervalMs: positiveFiniteNumber(config.heartbeatIntervalMs, 30_000)
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
export function resolveBridgeRunnerHubUrl(serverUrl) {
|
|
286
|
+
return new URL("hubs/bridge-runners", normalizeServerUrl(serverUrl));
|
|
287
|
+
}
|
|
288
|
+
function normalizeServerUrl(serverUrl) {
|
|
289
|
+
if (!serverUrl.trim()) {
|
|
290
|
+
throw new Error("Forge server URL is required.");
|
|
291
|
+
}
|
|
292
|
+
return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
|
|
293
|
+
}
|
|
294
|
+
function positiveFiniteNumber(value, fallback) {
|
|
295
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
296
|
+
}
|
|
297
|
+
function splitCsv(value) {
|
|
298
|
+
return value
|
|
299
|
+
.split(",")
|
|
300
|
+
.map((entry) => entry.trim())
|
|
301
|
+
.filter((entry) => entry.length > 0);
|
|
302
|
+
}
|
|
303
|
+
function findRepoMapping(dispatch, mappings) {
|
|
304
|
+
return mappings.find((mapping) => {
|
|
305
|
+
if (!mapping.localPath?.trim()) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
return (equalsIgnoreCase(mapping.repoId, dispatch.repo.id) ||
|
|
309
|
+
equalsIgnoreCase(mapping.name, dispatch.repo.name) ||
|
|
310
|
+
(!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
function equalsIgnoreCase(left, right) {
|
|
314
|
+
return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
|
|
315
|
+
}
|
|
316
|
+
function delay(ms, signal) {
|
|
317
|
+
if (signal?.aborted) {
|
|
318
|
+
return Promise.resolve();
|
|
319
|
+
}
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
const timeout = setTimeout(resolve, ms);
|
|
322
|
+
timeout.unref?.();
|
|
323
|
+
signal?.addEventListener("abort", () => {
|
|
324
|
+
clearTimeout(timeout);
|
|
325
|
+
resolve();
|
|
326
|
+
}, { once: true });
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
function abortPromise(signal) {
|
|
330
|
+
if (!signal) {
|
|
331
|
+
return new Promise(() => undefined);
|
|
332
|
+
}
|
|
333
|
+
if (signal.aborted) {
|
|
334
|
+
return Promise.resolve();
|
|
335
|
+
}
|
|
336
|
+
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
337
|
+
}
|
|
338
|
+
function exactSecrets(dispatch) {
|
|
339
|
+
return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
|
|
340
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RunOnceDispatchPacket, RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
|
|
2
|
+
export interface BridgeServiceConfig {
|
|
3
|
+
readonly serverUrl: string;
|
|
4
|
+
readonly runnerId: string;
|
|
5
|
+
readonly deviceToken: string;
|
|
6
|
+
readonly deviceName: string;
|
|
7
|
+
readonly defaultRepoRoot: string;
|
|
8
|
+
readonly cleanupWorktrees: boolean;
|
|
9
|
+
readonly cliProviders: readonly string[];
|
|
10
|
+
readonly providerStatuses: Readonly<Record<string, BridgeProviderStatus>>;
|
|
11
|
+
readonly projectLabels: readonly string[];
|
|
12
|
+
readonly repoMappings: readonly BridgeRepoMapping[];
|
|
13
|
+
readonly os: string;
|
|
14
|
+
readonly sandboxProfiles: readonly string[];
|
|
15
|
+
readonly supportsApiSecretGrant: boolean;
|
|
16
|
+
readonly version: string;
|
|
17
|
+
readonly protocolVersion: number;
|
|
18
|
+
readonly minServerVersion?: string | null;
|
|
19
|
+
readonly heartbeatIntervalMs: number;
|
|
20
|
+
readonly reconnectBackoffMs: readonly number[];
|
|
21
|
+
}
|
|
22
|
+
export interface BridgeRepoMapping {
|
|
23
|
+
readonly repoId?: string | null;
|
|
24
|
+
readonly name?: string | null;
|
|
25
|
+
readonly remoteUrl?: string | null;
|
|
26
|
+
readonly localPath?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface BridgeProviderStatus {
|
|
29
|
+
readonly signedIn?: boolean | null;
|
|
30
|
+
readonly version?: string | null;
|
|
31
|
+
}
|
|
32
|
+
export interface BridgeCapabilities {
|
|
33
|
+
readonly schemaVersion?: number | null;
|
|
34
|
+
readonly activeSessionCount?: number | null;
|
|
35
|
+
readonly cliProviders?: readonly string[] | null;
|
|
36
|
+
readonly providerStatuses?: Readonly<Record<string, BridgeProviderStatus>> | null;
|
|
37
|
+
readonly projectLabels?: readonly string[] | null;
|
|
38
|
+
readonly repoMappings?: readonly BridgeRepoMapping[] | null;
|
|
39
|
+
readonly os?: string | null;
|
|
40
|
+
readonly sandboxProfiles?: readonly string[] | null;
|
|
41
|
+
readonly supportsApiSecretGrant?: boolean | null;
|
|
42
|
+
}
|
|
43
|
+
export interface BridgeHelloRequest {
|
|
44
|
+
readonly version: string;
|
|
45
|
+
readonly protocolVersion: number;
|
|
46
|
+
readonly capabilities: BridgeCapabilities;
|
|
47
|
+
readonly minServerVersion?: string | null;
|
|
48
|
+
}
|
|
49
|
+
export interface BridgeHeartbeatRequest {
|
|
50
|
+
readonly status?: string | null;
|
|
51
|
+
readonly capabilities?: BridgeCapabilities | null;
|
|
52
|
+
readonly version?: string | null;
|
|
53
|
+
readonly protocolVersion?: number | null;
|
|
54
|
+
}
|
|
55
|
+
export interface BridgeCallbackAck {
|
|
56
|
+
readonly accepted: boolean;
|
|
57
|
+
readonly code?: string | null;
|
|
58
|
+
readonly message?: string | null;
|
|
59
|
+
}
|
|
60
|
+
export interface BridgeDispatchEventCallback {
|
|
61
|
+
readonly leaseId: string;
|
|
62
|
+
readonly fencingToken: string;
|
|
63
|
+
readonly event: RunnerDispatchEvent;
|
|
64
|
+
}
|
|
65
|
+
export interface BridgeDispatchResultCallback {
|
|
66
|
+
readonly leaseId: string;
|
|
67
|
+
readonly fencingToken: string;
|
|
68
|
+
readonly result: RunOnceResultPacket;
|
|
69
|
+
}
|
|
70
|
+
export interface BridgeCancelRequest {
|
|
71
|
+
readonly leaseId: string;
|
|
72
|
+
readonly fencingToken: string;
|
|
73
|
+
readonly reason?: string | null;
|
|
74
|
+
}
|
|
75
|
+
export type BridgeDispatchHandler = (dispatch: RunOnceDispatchPacket) => Promise<void>;
|
|
76
|
+
export type BridgeCancelHandler = (request: BridgeCancelRequest) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ForgeBridgeService } from "./bridge.service.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { SignalRBridgeConnection, SignalRBridgeConnectionFactory, defaultBridgeServiceConfig } from "./bridge.service.js";
|
|
2
|
+
export type { BridgeConnection, BridgeConnectionFactory } from "./bridge.base.js";
|
|
3
|
+
export type { BridgeCallbackAck, BridgeCancelHandler, BridgeCancelRequest, BridgeCapabilities, BridgeDispatchEventCallback, BridgeDispatchHandler, BridgeDispatchResultCallback, BridgeHeartbeatRequest, BridgeHelloRequest, BridgeRepoMapping, BridgeServiceConfig } from "./bridge.types.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SignalRBridgeConnection, SignalRBridgeConnectionFactory, defaultBridgeServiceConfig } from "./bridge.service.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NoopCleanupService } from "./cleanup.service.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { NoopCleanupService } from "./cleanup.service.js";
|
package/dist/cli/bin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|