@loucompanion/forge-bridge 0.1.1-dev.03b8ba168599

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +64 -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 +353 -0
  6. package/dist/bridge/bridge.types.d.ts +83 -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 +66 -0
  27. package/dist/cli/cli.service.js +770 -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 +34 -0
  33. package/dist/cli/local-config.js +181 -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 +7 -0
  45. package/dist/session/provider-cli/provider-cli.base.js +1 -0
  46. package/dist/session/provider-cli/provider-cli.service.d.ts +58 -0
  47. package/dist/session/provider-cli/provider-cli.service.js +705 -0
  48. package/dist/session/provider-cli/provider-cli.types.d.ts +13 -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 +134 -0
  54. package/dist/session/session.types.d.ts +118 -0
  55. package/dist/session/session.types.js +197 -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/artifact-injector.d.ts +11 -0
  67. package/dist/worktree/artifact-injector.js +104 -0
  68. package/dist/worktree/dependency-warmer.d.ts +32 -0
  69. package/dist/worktree/dependency-warmer.js +297 -0
  70. package/dist/worktree/index.d.ts +4 -0
  71. package/dist/worktree/index.js +2 -0
  72. package/dist/worktree/internals.d.ts +3 -0
  73. package/dist/worktree/internals.js +1 -0
  74. package/dist/worktree/worktree.base.d.ts +5 -0
  75. package/dist/worktree/worktree.base.js +1 -0
  76. package/dist/worktree/worktree.service.d.ts +16 -0
  77. package/dist/worktree/worktree.service.js +301 -0
  78. package/dist/worktree/worktree.types.d.ts +19 -0
  79. package/dist/worktree/worktree.types.js +1 -0
  80. package/package.json +52 -0
@@ -0,0 +1,301 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { GitClient } from "../shared/git/git.js";
4
+ import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
5
+ import { injectWorktreeArtifacts } from "./artifact-injector.js";
6
+ import { warmWorktreeDependencies } from "./dependency-warmer.js";
7
+ export class GitWorktreeService {
8
+ git;
9
+ constructor(git = new GitClient()) {
10
+ this.git = git;
11
+ }
12
+ async prepare(request) {
13
+ const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
14
+ const baseBranch = normalizeBranchName(request.baseBranch, "develop");
15
+ const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
16
+ await this.ensureGitIdentity(repoPath);
17
+ await this.ensureBaseBranch(repoPath, baseBranch);
18
+ const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
19
+ const kind = safeSegment(request.workItem.kind, "work-item");
20
+ const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
21
+ const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
22
+ const worktreePath = path.join(worktreeRoot, kind, shortId);
23
+ const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
24
+ if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
25
+ throw new Error(`Worktree path must stay under ${allowedRoot}.`);
26
+ }
27
+ const exists = await directoryExists(worktreePath);
28
+ let resolvedWorktreePath;
29
+ if (!exists) {
30
+ await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseBranch]);
31
+ resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
32
+ }
33
+ else {
34
+ resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
35
+ if (!isSubPath(allowedRoot, resolvedWorktreePath)) {
36
+ throw new Error(`Worktree path must stay under ${allowedRoot}.`);
37
+ }
38
+ }
39
+ await this.ensureGitIdentity(resolvedWorktreePath);
40
+ if (request.artifacts.length > 0) {
41
+ await injectWorktreeArtifacts(resolvedWorktreePath, request.artifacts);
42
+ }
43
+ const dependencyWarm = await warmWorktreeDependencies({
44
+ baseCheckoutPath: repoPath,
45
+ worktreePath: resolvedWorktreePath,
46
+ allowedRoot,
47
+ warmPathOverrides: request.warmPathOverrides
48
+ });
49
+ return {
50
+ repoPath,
51
+ worktreePath: resolvedWorktreePath,
52
+ branchName,
53
+ baseBranch,
54
+ cleanupWorktrees: request.cleanupWorktrees,
55
+ dependencyWarm
56
+ };
57
+ }
58
+ async release(handle) {
59
+ if (!handle.cleanupWorktrees || !(await directoryExists(handle.worktreePath))) {
60
+ return;
61
+ }
62
+ try {
63
+ await this.git.run(handle.repoPath, ["worktree", "remove", "--force", handle.worktreePath]);
64
+ await this.git.run(handle.repoPath, ["worktree", "prune"]);
65
+ }
66
+ catch {
67
+ await fs.rm(handle.worktreePath, { recursive: true, force: true });
68
+ }
69
+ }
70
+ async resolveRepoPath(repo, allowedRoot, baseBranch) {
71
+ if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
72
+ const requestedPath = await resolveExistingDirectoryPath(repo.serverLocalPath);
73
+ if (!isSubPath(allowedRoot, requestedPath) || !(await gitMetadataExists(requestedPath, allowedRoot))) {
74
+ throw new Error(`Repo server-local path must be an existing Git repository under ${allowedRoot}.`);
75
+ }
76
+ return requestedPath;
77
+ }
78
+ if (!repo.cloneOnDemand) {
79
+ throw new Error("Repo has no server-local path and cloneOnDemand is disabled.");
80
+ }
81
+ const discovered = await this.findExistingRepoPath(repo, allowedRoot);
82
+ if (discovered) {
83
+ return discovered;
84
+ }
85
+ const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
86
+ if (!(await gitMetadataExists(repoPath, allowedRoot))) {
87
+ const repoPathExists = await pathExists(repoPath);
88
+ if (repoPathExists) {
89
+ throw new Error(`Repository path ${repoPath} exists but is not a git checkout.`);
90
+ }
91
+ if (isCloneableRemote(repo.remoteUrl)) {
92
+ await fs.mkdir(path.dirname(repoPath), { recursive: true });
93
+ try {
94
+ await this.git.run(path.dirname(repoPath), ["clone", "--origin", "origin", repo.remoteUrl.trim(), repoPath]);
95
+ }
96
+ catch (err) {
97
+ await fs.rm(repoPath, { recursive: true, force: true });
98
+ throw err;
99
+ }
100
+ }
101
+ else {
102
+ await fs.mkdir(repoPath, { recursive: true });
103
+ await this.git.run(repoPath, ["init", "-b", baseBranch]);
104
+ await this.ensureGitIdentity(repoPath);
105
+ await fs.writeFile(path.join(repoPath, "README.md"), `# ${repo.name}\n`, "utf8");
106
+ await this.git.run(repoPath, ["add", "README.md"]);
107
+ await this.git.run(repoPath, ["commit", "-m", "seed forge bridge repo"]);
108
+ }
109
+ }
110
+ const resolved = await resolveExistingDirectoryPath(repoPath);
111
+ if (!isSubPath(allowedRoot, resolved)) {
112
+ throw new Error(`Repo path must stay under ${allowedRoot}.`);
113
+ }
114
+ return resolved;
115
+ }
116
+ async findExistingRepoPath(repo, allowedRoot) {
117
+ const expectedRemote = normalizeRemote(repo.remoteUrl);
118
+ const rawName = repo.name.trim();
119
+ const nameCandidates = uniqueStrings([
120
+ isSinglePathSegment(rawName) ? path.join(allowedRoot, rawName) : "",
121
+ path.join(allowedRoot, safeSegment(repo.name, "repo"))
122
+ ]);
123
+ for (const candidate of nameCandidates) {
124
+ const resolved = await this.resolveMatchingRepo(candidate, allowedRoot, expectedRemote);
125
+ if (resolved) {
126
+ return resolved;
127
+ }
128
+ }
129
+ if (expectedRemote.length > 0) {
130
+ const entries = await fs.readdir(allowedRoot, { withFileTypes: true }).catch(() => []);
131
+ for (const entry of entries) {
132
+ if (!entry.isDirectory() || entry.name === ".forge-worktrees") {
133
+ continue;
134
+ }
135
+ const resolved = await this.resolveMatchingRepo(path.join(allowedRoot, entry.name), allowedRoot, expectedRemote);
136
+ if (resolved) {
137
+ return resolved;
138
+ }
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ async resolveMatchingRepo(candidate, allowedRoot, expectedRemote) {
144
+ if (!(await gitMetadataExists(candidate, allowedRoot))) {
145
+ return null;
146
+ }
147
+ if (expectedRemote.length > 0) {
148
+ const origin = await this.git.run(candidate, ["remote", "get-url", "origin"]).catch(() => "");
149
+ if (normalizeRemote(origin) !== expectedRemote) {
150
+ return null;
151
+ }
152
+ }
153
+ const resolved = await resolveExistingDirectoryPath(candidate);
154
+ return isSubPath(allowedRoot, resolved) ? resolved : null;
155
+ }
156
+ async ensureBaseBranch(repoPath, baseBranch) {
157
+ if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
158
+ return;
159
+ }
160
+ const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
161
+ if (origin.trim().length === 0) {
162
+ throw new Error(`Base branch '${baseBranch}' was not found in the local checkout.`);
163
+ }
164
+ await this.git.run(repoPath, ["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`]).catch(() => "");
165
+ if (await this.gitRefExists(repoPath, `refs/remotes/origin/${baseBranch}`)) {
166
+ await this.git.run(repoPath, ["branch", baseBranch, `refs/remotes/origin/${baseBranch}`]);
167
+ return;
168
+ }
169
+ throw new Error(`Base branch '${baseBranch}' was not found locally or on origin.`);
170
+ }
171
+ async gitRefExists(repoPath, gitRef) {
172
+ return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
173
+ }
174
+ async ensureGitIdentity(repoPath) {
175
+ await this.git.run(repoPath, ["config", "user.email", "forge-bridge@forge.local"]);
176
+ await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
177
+ }
178
+ }
179
+ function managedRepoRoot(allowedRoot) {
180
+ return path.join(allowedRoot, ".forge-worktrees", "_repos");
181
+ }
182
+ function resolveWorktreeRoot(allowedRoot, repoPath) {
183
+ const baseName = path.basename(repoPath);
184
+ return isSubPath(managedRepoRoot(allowedRoot), repoPath)
185
+ ? path.join(allowedRoot, ".forge-worktrees", baseName)
186
+ : path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
187
+ }
188
+ function isCloneableRemote(remoteUrl) {
189
+ const trimmed = remoteUrl?.trim() ?? "";
190
+ if (trimmed.length === 0 || trimmed.startsWith("local://")) {
191
+ return false;
192
+ }
193
+ return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
194
+ }
195
+ export function normalizeBranchName(value, fallback) {
196
+ const branch = (value?.trim() || fallback).trim();
197
+ if (branch.length === 0 ||
198
+ branch.startsWith("-") ||
199
+ branch === "@" ||
200
+ branch.includes("..") ||
201
+ branch.includes("//") ||
202
+ branch.includes("@{") ||
203
+ branch.endsWith("/") ||
204
+ branch.endsWith(".") ||
205
+ branch.toLowerCase().endsWith(".lock") ||
206
+ /\s/.test(branch) ||
207
+ /[\u0000-\u001f~^:?*[\\]/.test(branch) ||
208
+ branch.split("/").some((part) => part.length === 0 || part.startsWith(".") || part.toLowerCase().endsWith(".lock"))) {
209
+ throw new Error("Git branch name must be valid, without whitespace, and cannot start with '-'.");
210
+ }
211
+ return branch;
212
+ }
213
+ async function gitMetadataExists(repoPath, allowedRoot) {
214
+ const gitPath = path.join(repoPath, ".git");
215
+ const stat = await fs.lstat(gitPath).catch(() => null);
216
+ if (!stat) {
217
+ return false;
218
+ }
219
+ if (stat.isSymbolicLink()) {
220
+ const resolvedGitPath = await resolveExistingDirectoryPath(gitPath);
221
+ return isSubPath(allowedRoot, resolvedGitPath);
222
+ }
223
+ if (stat.isDirectory()) {
224
+ const resolvedGitPath = await resolveExistingDirectoryPath(gitPath);
225
+ return isSubPath(allowedRoot, resolvedGitPath);
226
+ }
227
+ if (!stat.isFile()) {
228
+ return false;
229
+ }
230
+ const content = (await fs.readFile(gitPath, "utf8")).trim();
231
+ const prefix = "gitdir:";
232
+ if (!content.toLowerCase().startsWith(prefix)) {
233
+ return false;
234
+ }
235
+ const gitDir = content.slice(prefix.length).trim();
236
+ if (gitDir.length === 0) {
237
+ return false;
238
+ }
239
+ const gitDirPath = path.isAbsolute(gitDir) ? gitDir : path.join(repoPath, gitDir);
240
+ const resolvedGitDir = await resolveExistingDirectoryPath(gitDirPath);
241
+ return isSubPath(allowedRoot, resolvedGitDir);
242
+ }
243
+ async function pathExists(inputPath) {
244
+ return fs.stat(inputPath).then(() => true).catch(() => false);
245
+ }
246
+ async function directoryExists(inputPath) {
247
+ const stat = await fs.stat(inputPath).catch(() => null);
248
+ return stat?.isDirectory() ?? false;
249
+ }
250
+ function uniqueStrings(values) {
251
+ const seen = new Set();
252
+ const result = [];
253
+ for (const value of values) {
254
+ if (!value || seen.has(value)) {
255
+ continue;
256
+ }
257
+ seen.add(value);
258
+ result.push(value);
259
+ }
260
+ return result;
261
+ }
262
+ function isSinglePathSegment(value) {
263
+ return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
264
+ }
265
+ function normalizeRemote(remoteUrl) {
266
+ const trimmed = remoteUrl?.trim() ?? "";
267
+ if (trimmed.length === 0) {
268
+ return "";
269
+ }
270
+ const gitHubSsh = /^git@github\.com:(.+)$/i.exec(trimmed);
271
+ if (gitHubSsh) {
272
+ return stripGitSuffix(`https://github.com/${gitHubSsh[1]}`).toLowerCase();
273
+ }
274
+ if (path.isAbsolute(trimmed)) {
275
+ return normalizeLocalPath(trimmed);
276
+ }
277
+ try {
278
+ const url = new URL(trimmed);
279
+ if (url.protocol === "file:") {
280
+ return normalizeLocalPath(normalizeFileUrlPath(url));
281
+ }
282
+ url.username = "";
283
+ url.password = "";
284
+ url.hash = "";
285
+ return stripGitSuffix(url.toString().replace(/\/$/, "")).toLowerCase();
286
+ }
287
+ catch {
288
+ return stripGitSuffix(trimmed.replace(/\\/g, "/")).toLowerCase();
289
+ }
290
+ }
291
+ function stripGitSuffix(value) {
292
+ return value.replace(/\.git$/i, "").replace(/\/$/, "");
293
+ }
294
+ function normalizeFileUrlPath(url) {
295
+ const pathname = decodeURIComponent(url.pathname);
296
+ return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
297
+ }
298
+ function normalizeLocalPath(value) {
299
+ const normalized = path.resolve(value).replace(/\\/g, "/");
300
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
301
+ }
@@ -0,0 +1,19 @@
1
+ import type { DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
2
+ import type { WarmDependenciesResult } from "./dependency-warmer.js";
3
+ export interface WorktreePrepareRequest {
4
+ readonly repo: DispatchRepo;
5
+ readonly workItem: DispatchWorkItem;
6
+ readonly baseBranch: string;
7
+ readonly allowedRepoRoot: string;
8
+ readonly cleanupWorktrees: boolean;
9
+ readonly artifacts: readonly DispatchResolvedArtifact[];
10
+ readonly warmPathOverrides?: readonly string[];
11
+ }
12
+ export interface WorktreeHandle {
13
+ readonly repoPath: string;
14
+ readonly worktreePath: string;
15
+ readonly branchName: string;
16
+ readonly baseBranch: string;
17
+ readonly cleanupWorktrees: boolean;
18
+ readonly dependencyWarm?: WarmDependenciesResult;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@loucompanion/forge-bridge",
3
+ "version": "0.1.1-dev.03b8ba168599",
4
+ "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": {
8
+ "forge-bridge": "dist/cli/bin.js"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+ssh://git@github.com/coleman399/Forge.git",
26
+ "directory": "tools/forge-bridge"
27
+ },
28
+ "homepage": "https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/coleman399/Forge/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.0.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "smoke:package": "npm run build && node scripts/smoke-package.mjs"
43
+ },
44
+ "dependencies": {
45
+ "@microsoft/signalr": "^8.0.17"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.15.32",
49
+ "typescript": "^5.8.3",
50
+ "vitest": "^3.2.3"
51
+ }
52
+ }