@loucompanion/forge-bridge 0.1.1-dev.242bb53ef13f → 0.1.1-dev.2ac58b6f94ce

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 (40) hide show
  1. package/README.md +1 -0
  2. package/dist/bridge/bridge.service.js +83 -11
  3. package/dist/bridge/bridge.types.d.ts +16 -0
  4. package/dist/cli/cli.service.d.ts +27 -2
  5. package/dist/cli/cli.service.js +406 -16
  6. package/dist/cli/local-config.d.ts +4 -1
  7. package/dist/cli/local-config.js +60 -8
  8. package/dist/session/index.d.ts +2 -0
  9. package/dist/session/index.js +1 -0
  10. package/dist/session/internals.d.ts +5 -3
  11. package/dist/session/internals.js +2 -1
  12. package/dist/session/phase-completion.d.ts +101 -0
  13. package/dist/session/phase-completion.js +1985 -0
  14. package/dist/session/provider-cli/index.d.ts +2 -2
  15. package/dist/session/provider-cli/index.js +1 -1
  16. package/dist/session/provider-cli/provider-cli.base.d.ts +2 -2
  17. package/dist/session/provider-cli/provider-cli.service.d.ts +48 -3
  18. package/dist/session/provider-cli/provider-cli.service.js +1778 -45
  19. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  20. package/dist/session/provider-cli/provider-usage.d.ts +25 -0
  21. package/dist/session/provider-cli/provider-usage.js +64 -0
  22. package/dist/session/qa-sidecar-validation.d.ts +3 -0
  23. package/dist/session/qa-sidecar-validation.js +44 -0
  24. package/dist/session/session.service.js +55 -12
  25. package/dist/session/session.types.d.ts +66 -0
  26. package/dist/session/session.types.js +165 -2
  27. package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
  28. package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/dist/worktree/artifact-injector.d.ts +11 -0
  32. package/dist/worktree/artifact-injector.js +104 -0
  33. package/dist/worktree/dependency-warmer.d.ts +32 -0
  34. package/dist/worktree/dependency-warmer.js +297 -0
  35. package/dist/worktree/index.d.ts +2 -0
  36. package/dist/worktree/index.js +1 -0
  37. package/dist/worktree/worktree.service.d.ts +4 -0
  38. package/dist/worktree/worktree.service.js +173 -11
  39. package/dist/worktree/worktree.types.d.ts +5 -1
  40. package/package.json +2 -2
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { GitClient } from "../shared/git/git.js";
4
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";
5
7
  export class GitWorktreeService {
6
8
  git;
7
9
  constructor(git = new GitClient()) {
@@ -9,13 +11,14 @@ export class GitWorktreeService {
9
11
  }
10
12
  async prepare(request) {
11
13
  const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
12
- const repoPath = await this.resolveRepoPath(request.repo, allowedRoot);
13
14
  const baseBranch = normalizeBranchName(request.baseBranch, "develop");
15
+ const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
14
16
  await this.ensureGitIdentity(repoPath);
17
+ await this.ensureBaseBranch(repoPath, baseBranch);
15
18
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
16
19
  const kind = safeSegment(request.workItem.kind, "work-item");
17
20
  const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
18
- const worktreeRoot = path.join(path.dirname(repoPath), ".forge-worktrees", path.basename(repoPath));
21
+ const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
19
22
  const worktreePath = path.join(worktreeRoot, kind, shortId);
20
23
  const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
21
24
  if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
@@ -34,12 +37,22 @@ export class GitWorktreeService {
34
37
  }
35
38
  }
36
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
+ });
37
49
  return {
38
50
  repoPath,
39
51
  worktreePath: resolvedWorktreePath,
40
52
  branchName,
41
53
  baseBranch,
42
- cleanupWorktrees: request.cleanupWorktrees
54
+ cleanupWorktrees: request.cleanupWorktrees,
55
+ dependencyWarm
43
56
  };
44
57
  }
45
58
  async release(handle) {
@@ -54,7 +67,7 @@ export class GitWorktreeService {
54
67
  await fs.rm(handle.worktreePath, { recursive: true, force: true });
55
68
  }
56
69
  }
57
- async resolveRepoPath(repo, allowedRoot) {
70
+ async resolveRepoPath(repo, allowedRoot, baseBranch) {
58
71
  if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
59
72
  const requestedPath = await resolveExistingDirectoryPath(repo.serverLocalPath);
60
73
  if (!isSubPath(allowedRoot, requestedPath) || !(await gitMetadataExists(requestedPath, allowedRoot))) {
@@ -65,14 +78,34 @@ export class GitWorktreeService {
65
78
  if (!repo.cloneOnDemand) {
66
79
  throw new Error("Repo has no server-local path and cloneOnDemand is disabled.");
67
80
  }
68
- const repoPath = path.join(allowedRoot, `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
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, "")}`);
69
86
  if (!(await gitMetadataExists(repoPath, allowedRoot))) {
70
- await fs.mkdir(repoPath, { recursive: true });
71
- await this.git.run(repoPath, ["init", "-b", normalizeBranchName(repo.defaultBranch, "develop")]);
72
- await this.ensureGitIdentity(repoPath);
73
- await fs.writeFile(path.join(repoPath, "README.md"), `# ${repo.name}\n`, "utf8");
74
- await this.git.run(repoPath, ["add", "README.md"]);
75
- await this.git.run(repoPath, ["commit", "-m", "seed forge bridge repo"]);
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
+ }
76
109
  }
77
110
  const resolved = await resolveExistingDirectoryPath(repoPath);
78
111
  if (!isSubPath(allowedRoot, resolved)) {
@@ -80,11 +113,85 @@ export class GitWorktreeService {
80
113
  }
81
114
  return resolved;
82
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
+ }
83
174
  async ensureGitIdentity(repoPath) {
84
175
  await this.git.run(repoPath, ["config", "user.email", "forge-bridge@forge.local"]);
85
176
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
86
177
  }
87
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
+ }
88
195
  export function normalizeBranchName(value, fallback) {
89
196
  const branch = (value?.trim() || fallback).trim();
90
197
  if (branch.length === 0 ||
@@ -133,7 +240,62 @@ async function gitMetadataExists(repoPath, allowedRoot) {
133
240
  const resolvedGitDir = await resolveExistingDirectoryPath(gitDirPath);
134
241
  return isSubPath(allowedRoot, resolvedGitDir);
135
242
  }
243
+ async function pathExists(inputPath) {
244
+ return fs.stat(inputPath).then(() => true).catch(() => false);
245
+ }
136
246
  async function directoryExists(inputPath) {
137
247
  const stat = await fs.stat(inputPath).catch(() => null);
138
248
  return stat?.isDirectory() ?? false;
139
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
+ }
@@ -1,10 +1,13 @@
1
- import type { DispatchRepo, DispatchWorkItem } from "../session/session.types.js";
1
+ import type { DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
2
+ import type { WarmDependenciesResult } from "./dependency-warmer.js";
2
3
  export interface WorktreePrepareRequest {
3
4
  readonly repo: DispatchRepo;
4
5
  readonly workItem: DispatchWorkItem;
5
6
  readonly baseBranch: string;
6
7
  readonly allowedRepoRoot: string;
7
8
  readonly cleanupWorktrees: boolean;
9
+ readonly artifacts: readonly DispatchResolvedArtifact[];
10
+ readonly warmPathOverrides?: readonly string[];
8
11
  }
9
12
  export interface WorktreeHandle {
10
13
  readonly repoPath: string;
@@ -12,4 +15,5 @@ export interface WorktreeHandle {
12
15
  readonly branchName: string;
13
16
  readonly baseBranch: string;
14
17
  readonly cleanupWorktrees: boolean;
18
+ readonly dependencyWarm?: WarmDependenciesResult;
15
19
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.242bb53ef13f",
3
+ "version": "0.1.1-dev.2ac58b6f94ce",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {
8
- "forge-bridge": "./dist/cli/bin.js"
8
+ "forge-bridge": "dist/cli/bin.js"
9
9
  },
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",