@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.
Files changed (76) hide show
  1. package/README.md +63 -0
  2. package/dist/bridge/bridge.base.d.ts +17 -0
  3. package/dist/bridge/bridge.base.js +1 -0
  4. package/dist/bridge/bridge.service.d.ts +45 -0
  5. package/dist/bridge/bridge.service.js +340 -0
  6. package/dist/bridge/bridge.types.d.ts +76 -0
  7. package/dist/bridge/bridge.types.js +1 -0
  8. package/dist/bridge/index.d.ts +2 -0
  9. package/dist/bridge/index.js +1 -0
  10. package/dist/bridge/internals.d.ts +3 -0
  11. package/dist/bridge/internals.js +1 -0
  12. package/dist/cleanup/cleanup.base.d.ts +4 -0
  13. package/dist/cleanup/cleanup.base.js +1 -0
  14. package/dist/cleanup/cleanup.service.d.ts +5 -0
  15. package/dist/cleanup/cleanup.service.js +5 -0
  16. package/dist/cleanup/cleanup.types.d.ts +6 -0
  17. package/dist/cleanup/cleanup.types.js +1 -0
  18. package/dist/cleanup/index.d.ts +2 -0
  19. package/dist/cleanup/index.js +1 -0
  20. package/dist/cleanup/internals.d.ts +3 -0
  21. package/dist/cleanup/internals.js +1 -0
  22. package/dist/cli/bin.d.ts +2 -0
  23. package/dist/cli/bin.js +4 -0
  24. package/dist/cli/cli.base.d.ts +7 -0
  25. package/dist/cli/cli.base.js +1 -0
  26. package/dist/cli/cli.service.d.ts +45 -0
  27. package/dist/cli/cli.service.js +400 -0
  28. package/dist/cli/index.d.ts +2 -0
  29. package/dist/cli/index.js +1 -0
  30. package/dist/cli/internals.d.ts +1 -0
  31. package/dist/cli/internals.js +1 -0
  32. package/dist/cli/local-config.d.ts +31 -0
  33. package/dist/cli/local-config.js +146 -0
  34. package/dist/config.d.ts +5 -0
  35. package/dist/config.js +8 -0
  36. package/dist/index.d.ts +7 -0
  37. package/dist/index.js +7 -0
  38. package/dist/session/index.d.ts +2 -0
  39. package/dist/session/index.js +1 -0
  40. package/dist/session/internals.d.ts +7 -0
  41. package/dist/session/internals.js +2 -0
  42. package/dist/session/provider-cli/index.d.ts +3 -0
  43. package/dist/session/provider-cli/index.js +1 -0
  44. package/dist/session/provider-cli/provider-cli.base.d.ts +6 -0
  45. package/dist/session/provider-cli/provider-cli.base.js +1 -0
  46. package/dist/session/provider-cli/provider-cli.service.d.ts +16 -0
  47. package/dist/session/provider-cli/provider-cli.service.js +111 -0
  48. package/dist/session/provider-cli/provider-cli.types.d.ts +12 -0
  49. package/dist/session/provider-cli/provider-cli.types.js +1 -0
  50. package/dist/session/session.base.d.ts +7 -0
  51. package/dist/session/session.base.js +1 -0
  52. package/dist/session/session.service.d.ts +10 -0
  53. package/dist/session/session.service.js +92 -0
  54. package/dist/session/session.types.d.ts +107 -0
  55. package/dist/session/session.types.js +151 -0
  56. package/dist/shared/git/git.d.ts +6 -0
  57. package/dist/shared/git/git.js +18 -0
  58. package/dist/shared/path/path.d.ts +4 -0
  59. package/dist/shared/path/path.js +29 -0
  60. package/dist/shared/process/process.d.ts +16 -0
  61. package/dist/shared/process/process.js +32 -0
  62. package/dist/shared/redaction/redaction.d.ts +2 -0
  63. package/dist/shared/redaction/redaction.js +30 -0
  64. package/dist/version.d.ts +1 -0
  65. package/dist/version.js +1 -0
  66. package/dist/worktree/index.d.ts +2 -0
  67. package/dist/worktree/index.js +1 -0
  68. package/dist/worktree/internals.d.ts +3 -0
  69. package/dist/worktree/internals.js +1 -0
  70. package/dist/worktree/worktree.base.d.ts +5 -0
  71. package/dist/worktree/worktree.base.js +1 -0
  72. package/dist/worktree/worktree.service.d.ts +12 -0
  73. package/dist/worktree/worktree.service.js +139 -0
  74. package/dist/worktree/worktree.types.d.ts +15 -0
  75. package/dist/worktree/worktree.types.js +1 -0
  76. package/package.json +52 -0
@@ -0,0 +1,45 @@
1
+ import type { BridgeService } from "../bridge/bridge.base.js";
2
+ import type { BridgeServiceConfig } from "../bridge/bridge.types.js";
3
+ import type { SessionService } from "../session/session.base.js";
4
+ import type { CliIo, CliService } from "./cli.base.js";
5
+ export declare class ForgeBridgeCliService implements CliService {
6
+ private readonly sessions;
7
+ private readonly bridgeFactory;
8
+ constructor(sessions?: SessionService, bridgeFactory?: (config: BridgeServiceConfig) => BridgeService);
9
+ run(argv: readonly string[], io?: CliIo): Promise<number>;
10
+ }
11
+ export declare function parseArgs(argv: readonly string[]): {
12
+ readonly ok: true;
13
+ readonly command: "run-once";
14
+ readonly dispatchPath: string;
15
+ readonly resultPath: string;
16
+ } | {
17
+ readonly ok: true;
18
+ readonly command: "connect";
19
+ readonly config: BridgeServiceConfig;
20
+ } | {
21
+ readonly ok: true;
22
+ readonly command: "add-repo";
23
+ readonly label: string;
24
+ readonly repoPath: string;
25
+ } | {
26
+ readonly ok: true;
27
+ readonly command: "remove-repo";
28
+ readonly label: string;
29
+ } | {
30
+ readonly ok: true;
31
+ readonly command: "status";
32
+ readonly config: BridgeServiceConfig;
33
+ readonly checkUpdates: boolean;
34
+ } | {
35
+ readonly ok: true;
36
+ readonly command: "cleanup";
37
+ readonly config: BridgeServiceConfig;
38
+ readonly mode: CleanupMode;
39
+ readonly force: boolean;
40
+ } | {
41
+ readonly ok: false;
42
+ readonly error: string;
43
+ };
44
+ type CleanupMode = "stale" | "merged" | "all";
45
+ export {};
@@ -0,0 +1,400 @@
1
+ import fs from "node:fs/promises";
2
+ import { execFile } from "node:child_process";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
6
+ import { defaultBridgeServiceConfig, ForgeBridgeService } from "../bridge/bridge.service.js";
7
+ import { addRepoMapping, applyLocalConfigDefaults, readLocalConfigSync, removeRepoMapping, resolveLocalConfigPath } from "./local-config.js";
8
+ import { BridgeSessionService } from "../session/session.service.js";
9
+ import { failureResult, parseRunOnceDispatchPacket } from "../session/session.types.js";
10
+ const apiKeySecretGrantEnv = "FORGE_API_KEY_SECRET_GRANT";
11
+ const execFileAsync = promisify(execFile);
12
+ export class ForgeBridgeCliService {
13
+ sessions;
14
+ bridgeFactory;
15
+ constructor(sessions = new BridgeSessionService(), bridgeFactory = (config) => new ForgeBridgeService(config)) {
16
+ this.sessions = sessions;
17
+ this.bridgeFactory = bridgeFactory;
18
+ }
19
+ async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
20
+ const args = parseArgs(argv);
21
+ if (!args.ok) {
22
+ io.stderr.write(`${args.error}\n`);
23
+ return 2;
24
+ }
25
+ if (args.command === "connect") {
26
+ const missing = [
27
+ ["server", args.config.serverUrl],
28
+ ["runner id", args.config.runnerId],
29
+ ["device token", args.config.deviceToken]
30
+ ].find(([, value]) => !value);
31
+ if (missing) {
32
+ io.stderr.write(`forge-bridge connect requires ${missing[0]}.\n`);
33
+ return 2;
34
+ }
35
+ const controller = new AbortController();
36
+ const stop = () => controller.abort("signal received");
37
+ process.once("SIGINT", stop);
38
+ process.once("SIGTERM", stop);
39
+ try {
40
+ await this.bridgeFactory(args.config).run(controller.signal);
41
+ return 0;
42
+ }
43
+ finally {
44
+ process.off("SIGINT", stop);
45
+ process.off("SIGTERM", stop);
46
+ }
47
+ }
48
+ if (args.command === "add-repo") {
49
+ try {
50
+ const config = await addRepoMapping(args.label, args.repoPath);
51
+ const normalizedLabel = args.label.trim();
52
+ const mapping = config.repoMappings?.find((entry) => entry.label.toLowerCase() === normalizedLabel.toLowerCase());
53
+ io.stdout.write(`Added repo ${mapping?.label ?? normalizedLabel}: ${mapping?.localPath ?? path.resolve(args.repoPath)}\n`);
54
+ return 0;
55
+ }
56
+ catch (error) {
57
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
58
+ return 2;
59
+ }
60
+ }
61
+ if (args.command === "remove-repo") {
62
+ try {
63
+ const result = await removeRepoMapping(args.label);
64
+ io.stdout.write(result.removed ? `Removed repo ${args.label}.\n` : `Repo ${args.label} was not configured.\n`);
65
+ return result.removed ? 0 : 1;
66
+ }
67
+ catch (error) {
68
+ io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
69
+ return 2;
70
+ }
71
+ }
72
+ if (args.command === "status") {
73
+ await writeStatus(args.config, args.checkUpdates, io);
74
+ return 0;
75
+ }
76
+ if (args.command === "cleanup") {
77
+ return await cleanupRepos(args.config, args.mode, args.force, io);
78
+ }
79
+ let redactionSecrets = readEnvSecretValuesForRedaction();
80
+ try {
81
+ const dispatchJson = await fs.readFile(args.dispatchPath, "utf8");
82
+ const dispatch = attachEnvSecretGrant(parseRunOnceDispatchPacket(JSON.parse(dispatchJson)));
83
+ redactionSecrets = exactSecrets(dispatch);
84
+ const result = await this.sessions.runOnce(dispatch, {
85
+ emit: async (event) => {
86
+ const line = {
87
+ type: "runner_event",
88
+ event: redactSecrets(event, redactionSecrets)
89
+ };
90
+ io.stdout.write(`${JSON.stringify(line)}\n`);
91
+ }
92
+ });
93
+ await writeJsonAtomic(args.resultPath, redactSecrets(result, redactionSecrets));
94
+ return result.exitCode;
95
+ }
96
+ catch (error) {
97
+ const summary = redactText(error instanceof Error ? error.message : String(error), redactionSecrets);
98
+ await writeJsonAtomic(args.resultPath, failureResult(summary)).catch(() => undefined);
99
+ io.stderr.write(`${summary}\n`);
100
+ return 1;
101
+ }
102
+ finally {
103
+ delete process.env[apiKeySecretGrantEnv];
104
+ }
105
+ }
106
+ }
107
+ function attachEnvSecretGrant(dispatch) {
108
+ if (!dispatch.execution.requiresApiKeySecretGrant) {
109
+ delete process.env[apiKeySecretGrantEnv];
110
+ return dispatch;
111
+ }
112
+ const raw = process.env[apiKeySecretGrantEnv];
113
+ delete process.env[apiKeySecretGrantEnv];
114
+ if (!raw) {
115
+ return dispatch;
116
+ }
117
+ const grant = parseEnvSecretGrant(raw);
118
+ return {
119
+ ...dispatch,
120
+ apiKeySecretGrant: grant
121
+ };
122
+ }
123
+ function parseEnvSecretGrant(raw) {
124
+ const value = JSON.parse(raw);
125
+ if (!value || typeof value !== "object") {
126
+ throw new Error("API-key secret grant environment value must be an object.");
127
+ }
128
+ if (typeof value.grantId !== "string" ||
129
+ typeof value.provider !== "string" ||
130
+ typeof value.secret !== "string" ||
131
+ typeof value.expiresAt !== "string") {
132
+ throw new Error("API-key secret grant environment value is invalid.");
133
+ }
134
+ return {
135
+ grantId: value.grantId,
136
+ provider: value.provider,
137
+ secret: value.secret,
138
+ expiresAt: value.expiresAt
139
+ };
140
+ }
141
+ function exactSecrets(dispatch) {
142
+ return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
143
+ }
144
+ function readEnvSecretValuesForRedaction() {
145
+ const raw = process.env[apiKeySecretGrantEnv];
146
+ if (!raw) {
147
+ return [];
148
+ }
149
+ try {
150
+ return [parseEnvSecretGrant(raw).secret];
151
+ }
152
+ catch {
153
+ return [];
154
+ }
155
+ }
156
+ export function parseArgs(argv) {
157
+ if (argv[0] === "connect") {
158
+ return parseConnectArgs(argv);
159
+ }
160
+ if (argv[0] === "add-repo") {
161
+ if (!argv[1] || !argv[2] || argv.length > 3) {
162
+ return { ok: false, error: "Usage: forge-bridge add-repo <label> <path>" };
163
+ }
164
+ return { ok: true, command: "add-repo", label: argv[1], repoPath: argv[2] };
165
+ }
166
+ if (argv[0] === "remove-repo") {
167
+ if (!argv[1] || argv.length > 2) {
168
+ return { ok: false, error: "Usage: forge-bridge remove-repo <label>" };
169
+ }
170
+ return { ok: true, command: "remove-repo", label: argv[1] };
171
+ }
172
+ if (argv[0] === "status") {
173
+ let checkUpdates = false;
174
+ for (let index = 1; index < argv.length; index += 1) {
175
+ const arg = argv[index];
176
+ if (arg === "--check-updates") {
177
+ checkUpdates = true;
178
+ }
179
+ else {
180
+ return { ok: false, error: `Unknown argument: ${arg}` };
181
+ }
182
+ }
183
+ return { ok: true, command: "status", config: configuredBridgeServiceConfig(), checkUpdates };
184
+ }
185
+ if (argv[0] === "cleanup") {
186
+ return parseCleanupArgs(argv);
187
+ }
188
+ if (argv[0] !== "run-once") {
189
+ return {
190
+ ok: false,
191
+ error: "Usage: forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path> | 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]"
192
+ };
193
+ }
194
+ let dispatchPath = null;
195
+ let resultPath = null;
196
+ for (let index = 1; index < argv.length; index += 1) {
197
+ const arg = argv[index];
198
+ if (arg === "--dispatch") {
199
+ dispatchPath = argv[++index] ?? null;
200
+ }
201
+ else if (arg === "--result") {
202
+ resultPath = argv[++index] ?? null;
203
+ }
204
+ else {
205
+ return { ok: false, error: `Unknown argument: ${arg}` };
206
+ }
207
+ }
208
+ if (!dispatchPath || !resultPath) {
209
+ return { ok: false, error: "run-once requires --dispatch and --result." };
210
+ }
211
+ return {
212
+ ok: true,
213
+ command: "run-once",
214
+ dispatchPath: path.resolve(dispatchPath),
215
+ resultPath: path.resolve(resultPath)
216
+ };
217
+ }
218
+ function parseConnectArgs(argv) {
219
+ let serverUrl = null;
220
+ let runnerId = null;
221
+ let deviceToken = null;
222
+ let repoRoot = null;
223
+ let heartbeatIntervalMs = null;
224
+ const cliProviders = [];
225
+ const projectLabels = [];
226
+ let cleanupWorktrees = true;
227
+ for (let index = 1; index < argv.length; index += 1) {
228
+ const arg = argv[index];
229
+ if (arg === "--server") {
230
+ serverUrl = argv[++index] ?? null;
231
+ }
232
+ else if (arg === "--runner-id") {
233
+ runnerId = argv[++index] ?? null;
234
+ }
235
+ else if (arg === "--device-token") {
236
+ deviceToken = argv[++index] ?? null;
237
+ }
238
+ else if (arg === "--repo-root") {
239
+ repoRoot = argv[++index] ?? null;
240
+ }
241
+ else if (arg === "--cli-provider") {
242
+ cliProviders.push(...splitCsv(argv[++index] ?? ""));
243
+ }
244
+ else if (arg === "--project-label") {
245
+ projectLabels.push(...splitCsv(argv[++index] ?? ""));
246
+ }
247
+ else if (arg === "--heartbeat-interval-ms") {
248
+ heartbeatIntervalMs = Number(argv[++index] ?? "");
249
+ if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {
250
+ return { ok: false, error: "--heartbeat-interval-ms must be a positive number." };
251
+ }
252
+ }
253
+ else if (arg === "--no-cleanup-worktrees") {
254
+ cleanupWorktrees = false;
255
+ }
256
+ else {
257
+ return { ok: false, error: `Unknown argument: ${arg}` };
258
+ }
259
+ }
260
+ const localConfig = readLocalConfigSync();
261
+ if (serverUrl === null && !process.env.FORGE_URL?.trim() && !localConfig.serverUrl?.trim()) {
262
+ return { ok: false, error: "forge-bridge connect requires --server or FORGE_URL." };
263
+ }
264
+ const overrides = { cleanupWorktrees };
265
+ if (serverUrl !== null)
266
+ overrides.serverUrl = serverUrl;
267
+ if (runnerId !== null)
268
+ overrides.runnerId = runnerId;
269
+ if (deviceToken !== null)
270
+ overrides.deviceToken = deviceToken;
271
+ if (repoRoot !== null)
272
+ overrides.defaultRepoRoot = path.resolve(repoRoot);
273
+ if (cliProviders.length > 0)
274
+ overrides.cliProviders = cliProviders;
275
+ if (projectLabels.length > 0)
276
+ overrides.projectLabels = projectLabels;
277
+ if (heartbeatIntervalMs !== null)
278
+ overrides.heartbeatIntervalMs = heartbeatIntervalMs;
279
+ return {
280
+ ok: true,
281
+ command: "connect",
282
+ config: configuredBridgeServiceConfig(overrides, localConfig)
283
+ };
284
+ }
285
+ function parseCleanupArgs(argv) {
286
+ let mode = "stale";
287
+ let force = false;
288
+ for (let index = 1; index < argv.length; index += 1) {
289
+ const arg = argv[index];
290
+ if (arg === "--stale") {
291
+ mode = "stale";
292
+ }
293
+ else if (arg === "--merged") {
294
+ mode = "merged";
295
+ }
296
+ else if (arg === "--all") {
297
+ mode = "all";
298
+ }
299
+ else if (arg === "--force") {
300
+ force = true;
301
+ }
302
+ else {
303
+ return { ok: false, error: `Unknown argument: ${arg}` };
304
+ }
305
+ }
306
+ return { ok: true, command: "cleanup", config: configuredBridgeServiceConfig(), mode, force };
307
+ }
308
+ function configuredBridgeServiceConfig(overrides = {}, localConfig = readLocalConfigSync()) {
309
+ return {
310
+ ...applyLocalConfigDefaults(defaultBridgeServiceConfig(), localConfig),
311
+ ...overrides
312
+ };
313
+ }
314
+ function splitCsv(value) {
315
+ return value
316
+ .split(",")
317
+ .map((entry) => entry.trim())
318
+ .filter((entry) => entry.length > 0);
319
+ }
320
+ async function writeJsonAtomic(outputPath, value) {
321
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
322
+ const tempPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`;
323
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
324
+ await fs.rename(tempPath, outputPath);
325
+ }
326
+ async function writeStatus(config, checkUpdates, io) {
327
+ const detected = await detectCliProviders([...new Set([...config.cliProviders, "codex", "cursor"])]);
328
+ const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
329
+ io.stdout.write(`Forge bridge ${config.version}\n`);
330
+ io.stdout.write(`Config: ${resolveLocalConfigPath()}\n`);
331
+ io.stdout.write(`Server: ${config.serverUrl || "not configured"}\n`);
332
+ io.stdout.write(`Device: ${config.runnerId || "not configured"}${config.deviceName ? ` (${config.deviceName})` : ""}\n`);
333
+ io.stdout.write(`Device token: ${config.deviceToken ? "configured" : "missing"}\n`);
334
+ io.stdout.write(`Active sessions: 0\n`);
335
+ io.stdout.write("Repos:\n");
336
+ if (repos.length === 0) {
337
+ io.stdout.write(" none\n");
338
+ }
339
+ else {
340
+ for (const repo of repos) {
341
+ io.stdout.write(` ${repo.name ?? repo.repoId ?? "repo"} -> ${repo.localPath}\n`);
342
+ }
343
+ }
344
+ io.stdout.write("Detected CLIs:\n");
345
+ for (const cli of detected) {
346
+ io.stdout.write(` ${cli.provider}: ${cli.found ? cli.version || "found" : "not found"}\n`);
347
+ }
348
+ if (checkUpdates) {
349
+ io.stdout.write("Update check: no update channel configured.\n");
350
+ }
351
+ }
352
+ async function cleanupRepos(config, mode, force, io) {
353
+ const repos = config.repoMappings.filter((mapping) => mapping.localPath?.trim());
354
+ if (repos.length === 0) {
355
+ io.stdout.write("No repos configured.\n");
356
+ return 0;
357
+ }
358
+ let failures = 0;
359
+ for (const repo of repos) {
360
+ const repoPath = path.resolve(repo.localPath);
361
+ const worktreeRoot = path.join(path.dirname(repoPath), ".forge-worktrees", path.basename(repoPath));
362
+ if (!force) {
363
+ const action = mode === "all" ? "remove" : "prune";
364
+ io.stdout.write(`[dry-run] Would ${action} ${worktreeRoot}\n`);
365
+ continue;
366
+ }
367
+ try {
368
+ if (mode === "all") {
369
+ await fs.rm(worktreeRoot, { recursive: true, force: true });
370
+ await runGit(repoPath, ["worktree", "prune"]);
371
+ io.stdout.write(`Removed ${worktreeRoot}\n`);
372
+ }
373
+ else {
374
+ await runGit(repoPath, ["worktree", "prune"]);
375
+ io.stdout.write(`Pruned stale worktree metadata for ${repoPath}\n`);
376
+ }
377
+ }
378
+ catch (error) {
379
+ failures += 1;
380
+ io.stderr.write(`${repoPath}: ${error instanceof Error ? error.message : String(error)}\n`);
381
+ }
382
+ }
383
+ return failures === 0 ? 0 : 1;
384
+ }
385
+ async function detectCliProviders(providers) {
386
+ const results = [];
387
+ for (const provider of providers) {
388
+ try {
389
+ const result = await execFileAsync(provider, ["--version"], { timeout: 2000 });
390
+ results.push({ provider, found: true, version: `${result.stdout || result.stderr}`.trim().split(/\r?\n/, 1)[0] ?? "" });
391
+ }
392
+ catch {
393
+ results.push({ provider, found: false, version: "" });
394
+ }
395
+ }
396
+ return results;
397
+ }
398
+ async function runGit(cwd, args) {
399
+ await execFileAsync("git", args, { cwd, timeout: 10_000 });
400
+ }
@@ -0,0 +1,2 @@
1
+ export { ForgeBridgeCliService } from "./cli.service.js";
2
+ export type { CliIo, CliService } from "./cli.base.js";
@@ -0,0 +1 @@
1
+ export { ForgeBridgeCliService } from "./cli.service.js";
@@ -0,0 +1 @@
1
+ export { parseArgs } from "./cli.service.js";
@@ -0,0 +1 @@
1
+ export { parseArgs } from "./cli.service.js";
@@ -0,0 +1,31 @@
1
+ import type { BridgeProviderStatus, BridgeRepoMapping, BridgeServiceConfig } from "../bridge/bridge.types.js";
2
+ export interface BridgeLocalConfig {
3
+ readonly schemaVersion: 1;
4
+ readonly serverUrl?: string;
5
+ readonly runnerId?: string;
6
+ readonly deviceToken?: string;
7
+ readonly deviceName?: string;
8
+ readonly defaultRepoRoot?: string;
9
+ readonly cleanupWorktrees?: boolean;
10
+ readonly cliProviders?: readonly string[];
11
+ readonly providerStatuses?: Readonly<Record<string, BridgeProviderStatus>>;
12
+ readonly projectLabels?: readonly string[];
13
+ readonly repoMappings?: readonly LocalRepoMapping[];
14
+ readonly sandboxProfiles?: readonly string[];
15
+ readonly supportsApiSecretGrant?: boolean;
16
+ }
17
+ export interface LocalRepoMapping extends BridgeRepoMapping {
18
+ readonly label: string;
19
+ readonly localPath: string;
20
+ }
21
+ export declare function resolveLocalConfigPath(): string;
22
+ export declare function readLocalConfig(): Promise<BridgeLocalConfig>;
23
+ export declare function readLocalConfigSync(): BridgeLocalConfig;
24
+ export declare function writeLocalConfig(config: BridgeLocalConfig): Promise<void>;
25
+ export declare function applyLocalConfigDefaults(config: BridgeServiceConfig, local: BridgeLocalConfig): BridgeServiceConfig;
26
+ export declare function addRepoMapping(label: string, repoPath: string): Promise<BridgeLocalConfig>;
27
+ export declare function removeRepoMapping(label: string): Promise<{
28
+ readonly config: BridgeLocalConfig;
29
+ readonly removed: boolean;
30
+ }>;
31
+ export declare function providerStatusesFor(providers: readonly string[]): Readonly<Record<string, BridgeProviderStatus>>;
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs/promises";
2
+ import fsSync from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ export function resolveLocalConfigPath() {
6
+ if (process.env.FORGE_BRIDGE_CONFIG?.trim()) {
7
+ return path.resolve(process.env.FORGE_BRIDGE_CONFIG);
8
+ }
9
+ const base = process.env.XDG_CONFIG_HOME?.trim()
10
+ ? process.env.XDG_CONFIG_HOME
11
+ : process.platform === "win32" && process.env.APPDATA?.trim()
12
+ ? process.env.APPDATA
13
+ : path.join(os.homedir(), ".config");
14
+ return path.join(base, "forge", "bridge.json");
15
+ }
16
+ export async function readLocalConfig() {
17
+ return parseLocalConfig(await fs.readFile(resolveLocalConfigPath(), "utf8").catch((error) => {
18
+ if (error.code === "ENOENT") {
19
+ return "";
20
+ }
21
+ throw error;
22
+ }));
23
+ }
24
+ export function readLocalConfigSync() {
25
+ const configPath = resolveLocalConfigPath();
26
+ if (!fsSync.existsSync(configPath)) {
27
+ return emptyConfig();
28
+ }
29
+ return parseLocalConfig(fsSync.readFileSync(configPath, "utf8"));
30
+ }
31
+ export async function writeLocalConfig(config) {
32
+ const configPath = resolveLocalConfigPath();
33
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
34
+ const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
35
+ await fs.writeFile(tempPath, `${JSON.stringify(normalizeLocalConfig(config), null, 2)}\n`, "utf8");
36
+ await fs.rename(tempPath, configPath);
37
+ }
38
+ export function applyLocalConfigDefaults(config, local) {
39
+ const cliProviders = local.cliProviders && local.cliProviders.length > 0 ? [...local.cliProviders] : config.cliProviders;
40
+ return {
41
+ ...config,
42
+ serverUrl: process.env.FORGE_URL ?? local.serverUrl ?? config.serverUrl,
43
+ runnerId: process.env.FORGE_RUNNER_ID ?? local.runnerId ?? config.runnerId,
44
+ deviceToken: process.env.FORGE_DEVICE_TOKEN ?? local.deviceToken ?? config.deviceToken,
45
+ deviceName: process.env.FORGE_DEVICE_NAME ?? local.deviceName ?? config.deviceName,
46
+ defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? local.defaultRepoRoot ?? config.defaultRepoRoot,
47
+ cleanupWorktrees: local.cleanupWorktrees ?? config.cleanupWorktrees,
48
+ cliProviders,
49
+ providerStatuses: local.providerStatuses ?? providerStatusesFor(cliProviders),
50
+ projectLabels: local.projectLabels ?? config.projectLabels,
51
+ repoMappings: local.repoMappings ?? config.repoMappings,
52
+ sandboxProfiles: local.sandboxProfiles ?? config.sandboxProfiles,
53
+ supportsApiSecretGrant: local.supportsApiSecretGrant ?? config.supportsApiSecretGrant
54
+ };
55
+ }
56
+ export async function addRepoMapping(label, repoPath) {
57
+ const normalizedLabel = normalizeLabel(label);
58
+ const localPath = path.resolve(repoPath);
59
+ const stat = await fs.stat(localPath).catch(() => null);
60
+ if (!stat?.isDirectory()) {
61
+ throw new Error(`Repo path must be an existing directory: ${localPath}`);
62
+ }
63
+ const current = await readLocalConfig();
64
+ const mappings = [
65
+ ...((current.repoMappings ?? []).filter((mapping) => mapping.label.toLowerCase() !== normalizedLabel.toLowerCase())),
66
+ {
67
+ label: normalizedLabel,
68
+ name: normalizedLabel,
69
+ localPath
70
+ }
71
+ ].sort((left, right) => left.label.localeCompare(right.label));
72
+ const next = normalizeLocalConfig({ ...current, repoMappings: mappings });
73
+ await writeLocalConfig(next);
74
+ return next;
75
+ }
76
+ export async function removeRepoMapping(label) {
77
+ const normalizedLabel = normalizeLabel(label);
78
+ const current = await readLocalConfig();
79
+ const before = current.repoMappings ?? [];
80
+ const after = before.filter((mapping) => mapping.label.toLowerCase() !== normalizedLabel.toLowerCase());
81
+ const next = normalizeLocalConfig({ ...current, repoMappings: after });
82
+ await writeLocalConfig(next);
83
+ return { config: next, removed: after.length !== before.length };
84
+ }
85
+ export function providerStatusesFor(providers) {
86
+ return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "" }]));
87
+ }
88
+ function parseLocalConfig(raw) {
89
+ if (!raw.trim()) {
90
+ return emptyConfig();
91
+ }
92
+ return normalizeLocalConfig(JSON.parse(raw));
93
+ }
94
+ function normalizeLocalConfig(config) {
95
+ return {
96
+ schemaVersion: 1,
97
+ serverUrl: cleanString(config.serverUrl),
98
+ runnerId: cleanString(config.runnerId),
99
+ deviceToken: cleanString(config.deviceToken),
100
+ deviceName: cleanString(config.deviceName),
101
+ defaultRepoRoot: cleanString(config.defaultRepoRoot),
102
+ cleanupWorktrees: config.cleanupWorktrees,
103
+ cliProviders: cleanList(config.cliProviders),
104
+ providerStatuses: config.providerStatuses,
105
+ projectLabels: cleanList(config.projectLabels),
106
+ repoMappings: normalizeRepoMappings(config.repoMappings),
107
+ sandboxProfiles: cleanList(config.sandboxProfiles),
108
+ supportsApiSecretGrant: config.supportsApiSecretGrant
109
+ };
110
+ }
111
+ function emptyConfig() {
112
+ return {
113
+ schemaVersion: 1,
114
+ repoMappings: []
115
+ };
116
+ }
117
+ function normalizeRepoMappings(values) {
118
+ return (values ?? [])
119
+ .filter((mapping) => !!mapping.label?.trim() && !!mapping.localPath?.trim())
120
+ .map((mapping) => {
121
+ const label = normalizeLabel(mapping.label);
122
+ return {
123
+ label,
124
+ repoId: cleanString(mapping.repoId),
125
+ name: cleanString(mapping.name) ?? label,
126
+ remoteUrl: cleanString(mapping.remoteUrl),
127
+ localPath: path.resolve(mapping.localPath)
128
+ };
129
+ })
130
+ .sort((left, right) => left.label.localeCompare(right.label));
131
+ }
132
+ function normalizeLabel(value) {
133
+ const label = value.trim();
134
+ if (!label) {
135
+ throw new Error("Repo label is required.");
136
+ }
137
+ return label;
138
+ }
139
+ function cleanString(value) {
140
+ const clean = value?.trim();
141
+ return clean ? clean : undefined;
142
+ }
143
+ function cleanList(values) {
144
+ const clean = [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
145
+ return clean.length > 0 ? clean : undefined;
146
+ }
@@ -0,0 +1,5 @@
1
+ export interface ForgeBridgeConfig {
2
+ readonly defaultRepoRoot: string;
3
+ readonly cleanupWorktrees: boolean;
4
+ }
5
+ export declare function defaultForgeBridgeConfig(): ForgeBridgeConfig;
package/dist/config.js ADDED
@@ -0,0 +1,8 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export function defaultForgeBridgeConfig() {
4
+ return {
5
+ defaultRepoRoot: path.join(os.tmpdir(), "forge-phase5-repos"),
6
+ cleanupWorktrees: true
7
+ };
8
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./version.js";
2
+ export * from "./config.js";
3
+ export * from "./bridge/index.js";
4
+ export * from "./cli/index.js";
5
+ export * from "./session/index.js";
6
+ export * from "./worktree/index.js";
7
+ export * from "./cleanup/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./version.js";
2
+ export * from "./config.js";
3
+ export * from "./bridge/index.js";
4
+ export * from "./cli/index.js";
5
+ export * from "./session/index.js";
6
+ export * from "./worktree/index.js";
7
+ export * from "./cleanup/index.js";
@@ -0,0 +1,2 @@
1
+ export { BridgeSessionService } from "./session.service.js";
2
+ export type { SessionService } from "./session.base.js";
@@ -0,0 +1 @@
1
+ export { BridgeSessionService } from "./session.service.js";