@loucompanion/forge-bridge 0.1.1-dev.8dcbc6245e51 → 0.1.1-dev.9219f8477b8a

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 (37) hide show
  1. package/dist/bridge/bridge.service.d.ts +1 -0
  2. package/dist/bridge/bridge.service.js +219 -25
  3. package/dist/bridge/bridge.types.d.ts +19 -2
  4. package/dist/cli/cli.service.d.ts +27 -2
  5. package/dist/cli/cli.service.js +630 -25
  6. package/dist/cli/local-config.d.ts +4 -0
  7. package/dist/cli/local-config.js +42 -4
  8. package/dist/config.js +1 -1
  9. package/dist/session/index.d.ts +2 -0
  10. package/dist/session/index.js +1 -0
  11. package/dist/session/internals.d.ts +5 -3
  12. package/dist/session/internals.js +2 -1
  13. package/dist/session/phase-completion.d.ts +126 -0
  14. package/dist/session/phase-completion.js +3233 -0
  15. package/dist/session/provider-cli/index.d.ts +2 -2
  16. package/dist/session/provider-cli/index.js +1 -1
  17. package/dist/session/provider-cli/provider-cli.base.d.ts +5 -3
  18. package/dist/session/provider-cli/provider-cli.service.d.ts +64 -4
  19. package/dist/session/provider-cli/provider-cli.service.js +2787 -51
  20. package/dist/session/provider-cli/provider-cli.types.d.ts +7 -0
  21. package/dist/session/provider-cli/provider-usage.d.ts +25 -0
  22. package/dist/session/provider-cli/provider-usage.js +64 -0
  23. package/dist/session/qa-sidecar-validation.d.ts +3 -0
  24. package/dist/session/qa-sidecar-validation.js +44 -0
  25. package/dist/session/session.service.js +62 -11
  26. package/dist/session/session.types.d.ts +132 -0
  27. package/dist/session/session.types.js +261 -2
  28. package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
  29. package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/dist/worktree/artifact-injector.d.ts +2 -1
  33. package/dist/worktree/artifact-injector.js +125 -9
  34. package/dist/worktree/worktree.service.d.ts +3 -0
  35. package/dist/worktree/worktree.service.js +239 -8
  36. package/dist/worktree/worktree.types.d.ts +8 -1
  37. package/package.json +8 -3
@@ -2,7 +2,7 @@ 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";
5
+ import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
6
6
  import { warmWorktreeDependencies } from "./dependency-warmer.js";
7
7
  export class GitWorktreeService {
8
8
  git;
@@ -17,9 +17,12 @@ export class GitWorktreeService {
17
17
  await this.ensureBaseBranch(repoPath, baseBranch);
18
18
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
19
19
  const kind = safeSegment(request.workItem.kind, "work-item");
20
- const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
21
- const worktreeRoot = path.join(path.dirname(repoPath), ".forge-worktrees", path.basename(repoPath));
22
- const worktreePath = path.join(worktreeRoot, kind, shortId);
20
+ const fallbackWorktreeKey = `${kind}/${shortId}`;
21
+ const worktreeKey = request.worktreeKey?.trim() || fallbackWorktreeKey;
22
+ const worktreeSegments = worktreeKeySegments(worktreeKey, kind, shortId);
23
+ const branchName = normalizeBranchName(worktreeSegments.join("/"), "work-item/unknown");
24
+ const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
25
+ const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
23
26
  const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
24
27
  if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
25
28
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
@@ -35,10 +38,17 @@ export class GitWorktreeService {
35
38
  if (!isSubPath(allowedRoot, resolvedWorktreePath)) {
36
39
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
37
40
  }
41
+ const currentBranch = await this.currentBranch(resolvedWorktreePath);
42
+ if (currentBranch !== branchName) {
43
+ throw new Error(`Existing worktree ${resolvedWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
44
+ }
38
45
  }
39
46
  await this.ensureGitIdentity(resolvedWorktreePath);
40
- if (request.artifacts.length > 0) {
41
- await injectWorktreeArtifacts(resolvedWorktreePath, request.artifacts);
47
+ await clearRunArtifactRoots(resolvedWorktreePath, request);
48
+ const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
49
+ const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
50
+ if (artifacts.length > 0) {
51
+ await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
42
52
  }
43
53
  const dependencyWarm = await warmWorktreeDependencies({
44
54
  baseCheckoutPath: repoPath,
@@ -52,7 +62,8 @@ export class GitWorktreeService {
52
62
  branchName,
53
63
  baseBranch,
54
64
  cleanupWorktrees: request.cleanupWorktrees,
55
- dependencyWarm
65
+ dependencyWarm,
66
+ qaReworkPacketPath
56
67
  };
57
68
  }
58
69
  async release(handle) {
@@ -78,7 +89,11 @@ export class GitWorktreeService {
78
89
  if (!repo.cloneOnDemand) {
79
90
  throw new Error("Repo has no server-local path and cloneOnDemand is disabled.");
80
91
  }
81
- const repoPath = path.join(allowedRoot, `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
92
+ const discovered = await this.findExistingRepoPath(repo, allowedRoot);
93
+ if (discovered) {
94
+ return discovered;
95
+ }
96
+ const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
82
97
  if (!(await gitMetadataExists(repoPath, allowedRoot))) {
83
98
  const repoPathExists = await pathExists(repoPath);
84
99
  if (repoPathExists) {
@@ -109,6 +124,46 @@ export class GitWorktreeService {
109
124
  }
110
125
  return resolved;
111
126
  }
127
+ async findExistingRepoPath(repo, allowedRoot) {
128
+ const expectedRemote = normalizeRemote(repo.remoteUrl);
129
+ const rawName = repo.name.trim();
130
+ const nameCandidates = uniqueStrings([
131
+ isSinglePathSegment(rawName) ? path.join(allowedRoot, rawName) : "",
132
+ path.join(allowedRoot, safeSegment(repo.name, "repo"))
133
+ ]);
134
+ for (const candidate of nameCandidates) {
135
+ const resolved = await this.resolveMatchingRepo(candidate, allowedRoot, expectedRemote);
136
+ if (resolved) {
137
+ return resolved;
138
+ }
139
+ }
140
+ if (expectedRemote.length > 0) {
141
+ const entries = await fs.readdir(allowedRoot, { withFileTypes: true }).catch(() => []);
142
+ for (const entry of entries) {
143
+ if (!entry.isDirectory() || entry.name === ".forge-worktrees") {
144
+ continue;
145
+ }
146
+ const resolved = await this.resolveMatchingRepo(path.join(allowedRoot, entry.name), allowedRoot, expectedRemote);
147
+ if (resolved) {
148
+ return resolved;
149
+ }
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+ async resolveMatchingRepo(candidate, allowedRoot, expectedRemote) {
155
+ if (!(await gitMetadataExists(candidate, allowedRoot))) {
156
+ return null;
157
+ }
158
+ if (expectedRemote.length > 0) {
159
+ const origin = await this.git.run(candidate, ["remote", "get-url", "origin"]).catch(() => "");
160
+ if (normalizeRemote(origin) !== expectedRemote) {
161
+ return null;
162
+ }
163
+ }
164
+ const resolved = await resolveExistingDirectoryPath(candidate);
165
+ return isSubPath(allowedRoot, resolved) ? resolved : null;
166
+ }
112
167
  async ensureBaseBranch(repoPath, baseBranch) {
113
168
  if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
114
169
  return;
@@ -127,11 +182,23 @@ export class GitWorktreeService {
127
182
  async gitRefExists(repoPath, gitRef) {
128
183
  return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
129
184
  }
185
+ async currentBranch(repoPath) {
186
+ return (await this.git.run(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
187
+ }
130
188
  async ensureGitIdentity(repoPath) {
131
189
  await this.git.run(repoPath, ["config", "user.email", "forge-bridge@forge.local"]);
132
190
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
133
191
  }
134
192
  }
193
+ function managedRepoRoot(allowedRoot) {
194
+ return path.join(allowedRoot, ".forge-worktrees", "_repos");
195
+ }
196
+ function resolveWorktreeRoot(allowedRoot, repoPath) {
197
+ const baseName = path.basename(repoPath);
198
+ return isSubPath(managedRepoRoot(allowedRoot), repoPath)
199
+ ? path.join(allowedRoot, ".forge-worktrees", baseName)
200
+ : path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
201
+ }
135
202
  function isCloneableRemote(remoteUrl) {
136
203
  const trimmed = remoteUrl?.trim() ?? "";
137
204
  if (trimmed.length === 0 || trimmed.startsWith("local://")) {
@@ -139,6 +206,76 @@ function isCloneableRemote(remoteUrl) {
139
206
  }
140
207
  return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
141
208
  }
209
+ async function clearRunArtifactRoots(worktreePath, request) {
210
+ const roots = new Set();
211
+ roots.add(workItemArtifactRoot(worktreePath, request.workItem.id));
212
+ const ownerType = normalizeOwnerType(request.ownerType);
213
+ const ownerId = request.ownerId?.trim();
214
+ if (ownerType && ownerId) {
215
+ roots.add(ownerArtifactRoot(worktreePath, ownerType, ownerId));
216
+ }
217
+ for (const root of roots) {
218
+ const resolvedRoot = path.resolve(root);
219
+ if (!isSubPath(worktreePath, resolvedRoot)) {
220
+ throw new Error(`Artifact root must stay under ${worktreePath}.`);
221
+ }
222
+ await ensureNoSymlinkPathSegments(worktreePath, resolvedRoot, "Artifact root must not traverse symlinked path segments.");
223
+ await fs.rm(resolvedRoot, { recursive: true, force: true });
224
+ await fs.mkdir(resolvedRoot, { recursive: true });
225
+ }
226
+ }
227
+ async function materializeQaReworkPacket(worktreePath, request) {
228
+ const qaRework = request.qaRework;
229
+ if (!qaRework) {
230
+ return undefined;
231
+ }
232
+ const artifactRoot = artifactRootForRequest(worktreePath, request);
233
+ const packetFileName = qaReworkPacketFileName(qaRework.packetPath);
234
+ const packetPath = path.join(artifactRoot, packetFileName);
235
+ const paths = new Set([packetPath, path.join(artifactRoot, "qa-rework-packet.json")]);
236
+ const packetJson = qaRework.packetJson.endsWith("\n") ? qaRework.packetJson : `${qaRework.packetJson}\n`;
237
+ for (const target of paths) {
238
+ const resolvedTarget = path.resolve(target);
239
+ if (!isSubPath(worktreePath, resolvedTarget)) {
240
+ throw new Error(`QA rework packet path must stay under ${worktreePath}.`);
241
+ }
242
+ await ensureNoSymlinkPathSegments(worktreePath, path.dirname(resolvedTarget), "QA rework packet path must not traverse symlinked path segments.");
243
+ await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
244
+ await fs.writeFile(resolvedTarget, packetJson, "utf8");
245
+ }
246
+ return path.resolve(packetPath);
247
+ }
248
+ function artifactRootForRequest(worktreePath, request) {
249
+ const ownerType = normalizeOwnerType(request.ownerType);
250
+ const ownerId = request.ownerId?.trim();
251
+ if (ownerType && ownerId) {
252
+ return ownerArtifactRoot(worktreePath, ownerType, ownerId);
253
+ }
254
+ return workItemArtifactRoot(worktreePath, request.workItem.id);
255
+ }
256
+ function workItemArtifactRoot(worktreePath, workItemId) {
257
+ return path.join(worktreePath, ".forge", "work-items", safeSegment(workItemId, "work item id"), "artifacts");
258
+ }
259
+ function ownerArtifactRoot(worktreePath, ownerType, ownerId) {
260
+ return path.join(worktreePath, ".forge", ownerType === "story" ? "stories" : "work-items", safeSegment(ownerId, "owner id"), "artifacts");
261
+ }
262
+ function normalizeOwnerType(value) {
263
+ const normalized = (value ?? "").trim().toLowerCase().replace(/-/g, "_");
264
+ if (normalized === "story") {
265
+ return "story";
266
+ }
267
+ if (normalized === "work_item" || normalized === "workitem" || normalized === "work item") {
268
+ return "work_item";
269
+ }
270
+ return null;
271
+ }
272
+ function qaReworkPacketFileName(packetPath) {
273
+ const normalized = packetPath.trim().replace(/\\/g, "/");
274
+ const fileName = normalized.split("/").filter(Boolean).at(-1) ?? "";
275
+ return /^qa-rework-packet(?:\.attempt-\d+)?\.json$/i.test(fileName)
276
+ ? fileName
277
+ : "qa-rework-packet.json";
278
+ }
142
279
  export function normalizeBranchName(value, fallback) {
143
280
  const branch = (value?.trim() || fallback).trim();
144
281
  if (branch.length === 0 ||
@@ -151,6 +288,7 @@ export function normalizeBranchName(value, fallback) {
151
288
  branch.endsWith(".") ||
152
289
  branch.toLowerCase().endsWith(".lock") ||
153
290
  /\s/.test(branch) ||
291
+ // eslint-disable-next-line no-control-regex -- Git branch validation must reject ASCII control characters.
154
292
  /[\u0000-\u001f~^:?*[\\]/.test(branch) ||
155
293
  branch.split("/").some((part) => part.length === 0 || part.startsWith(".") || part.toLowerCase().endsWith(".lock"))) {
156
294
  throw new Error("Git branch name must be valid, without whitespace, and cannot start with '-'.");
@@ -187,6 +325,39 @@ async function gitMetadataExists(repoPath, allowedRoot) {
187
325
  const resolvedGitDir = await resolveExistingDirectoryPath(gitDirPath);
188
326
  return isSubPath(allowedRoot, resolvedGitDir);
189
327
  }
328
+ async function ensureNoSymlinkPathSegments(root, targetPath, errorMessage) {
329
+ const resolvedRoot = path.resolve(root);
330
+ const resolvedTarget = path.resolve(targetPath);
331
+ const relative = path.relative(resolvedRoot, resolvedTarget);
332
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
333
+ throw new Error(errorMessage);
334
+ }
335
+ let current = resolvedRoot;
336
+ const rootStat = await fs.lstat(current);
337
+ if (rootStat.isSymbolicLink()) {
338
+ throw new Error(errorMessage);
339
+ }
340
+ if (!relative) {
341
+ return;
342
+ }
343
+ for (const segment of relative.split(path.sep)) {
344
+ if (!segment) {
345
+ continue;
346
+ }
347
+ current = path.join(current, segment);
348
+ const stat = await fs.lstat(current).catch((error) => {
349
+ if (error.code === "ENOENT")
350
+ return null;
351
+ throw error;
352
+ });
353
+ if (!stat) {
354
+ return;
355
+ }
356
+ if (stat.isSymbolicLink()) {
357
+ throw new Error(errorMessage);
358
+ }
359
+ }
360
+ }
190
361
  async function pathExists(inputPath) {
191
362
  return fs.stat(inputPath).then(() => true).catch(() => false);
192
363
  }
@@ -194,3 +365,63 @@ async function directoryExists(inputPath) {
194
365
  const stat = await fs.stat(inputPath).catch(() => null);
195
366
  return stat?.isDirectory() ?? false;
196
367
  }
368
+ function uniqueStrings(values) {
369
+ const seen = new Set();
370
+ const result = [];
371
+ for (const value of values) {
372
+ if (!value || seen.has(value)) {
373
+ continue;
374
+ }
375
+ seen.add(value);
376
+ result.push(value);
377
+ }
378
+ return result;
379
+ }
380
+ function worktreeKeySegments(worktreeKey, fallbackKind, fallbackShortId) {
381
+ const segments = worktreeKey
382
+ .split("/")
383
+ .map((segment) => segment.trim())
384
+ .filter((segment) => segment.length > 0)
385
+ .map((segment) => safeSegment(segment, "worktree"));
386
+ return segments.length > 0 ? segments : [fallbackKind, fallbackShortId];
387
+ }
388
+ function isSinglePathSegment(value) {
389
+ return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
390
+ }
391
+ function normalizeRemote(remoteUrl) {
392
+ const trimmed = remoteUrl?.trim() ?? "";
393
+ if (trimmed.length === 0) {
394
+ return "";
395
+ }
396
+ const gitHubSsh = /^git@github\.com:(.+)$/i.exec(trimmed);
397
+ if (gitHubSsh) {
398
+ return stripGitSuffix(`https://github.com/${gitHubSsh[1]}`).toLowerCase();
399
+ }
400
+ if (path.isAbsolute(trimmed)) {
401
+ return normalizeLocalPath(trimmed);
402
+ }
403
+ try {
404
+ const url = new URL(trimmed);
405
+ if (url.protocol === "file:") {
406
+ return normalizeLocalPath(normalizeFileUrlPath(url));
407
+ }
408
+ url.username = "";
409
+ url.password = "";
410
+ url.hash = "";
411
+ return stripGitSuffix(url.toString().replace(/\/$/, "")).toLowerCase();
412
+ }
413
+ catch {
414
+ return stripGitSuffix(trimmed.replace(/\\/g, "/")).toLowerCase();
415
+ }
416
+ }
417
+ function stripGitSuffix(value) {
418
+ return value.replace(/\.git$/i, "").replace(/\/$/, "");
419
+ }
420
+ function normalizeFileUrlPath(url) {
421
+ const pathname = decodeURIComponent(url.pathname);
422
+ return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
423
+ }
424
+ function normalizeLocalPath(value) {
425
+ const normalized = path.resolve(value).replace(/\\/g, "/");
426
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
427
+ }
@@ -1,12 +1,18 @@
1
- import type { DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
1
+ import type { DispatchQaReworkContext, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
2
2
  import type { WarmDependenciesResult } from "./dependency-warmer.js";
3
3
  export interface WorktreePrepareRequest {
4
4
  readonly repo: DispatchRepo;
5
5
  readonly workItem: DispatchWorkItem;
6
6
  readonly baseBranch: string;
7
+ readonly worktreeKey?: string | null;
8
+ readonly ownerType?: string | null;
9
+ readonly ownerId?: string | null;
10
+ readonly lane: string;
11
+ readonly phase: string;
7
12
  readonly allowedRepoRoot: string;
8
13
  readonly cleanupWorktrees: boolean;
9
14
  readonly artifacts: readonly DispatchResolvedArtifact[];
15
+ readonly qaRework?: DispatchQaReworkContext | null;
10
16
  readonly warmPathOverrides?: readonly string[];
11
17
  }
12
18
  export interface WorktreeHandle {
@@ -16,4 +22,5 @@ export interface WorktreeHandle {
16
22
  readonly baseBranch: string;
17
23
  readonly cleanupWorktrees: boolean;
18
24
  readonly dependencyWarm?: WarmDependenciesResult;
25
+ readonly qaReworkPacketPath?: string;
19
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.8dcbc6245e51",
3
+ "version": "0.1.1-dev.9219f8477b8a",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "url": "https://github.com/coleman399/Forge/issues"
31
31
  },
32
32
  "engines": {
33
- "node": ">=22.0.0"
33
+ "node": ">=22.13.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
@@ -39,14 +39,19 @@
39
39
  "build": "tsc -p tsconfig.json",
40
40
  "test": "vitest run",
41
41
  "test:watch": "vitest",
42
- "smoke:package": "npm run build && node scripts/smoke-package.mjs"
42
+ "smoke:package": "npm run build && node scripts/smoke-package.mjs",
43
+ "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.mjs\" \"vitest.config.ts\""
43
44
  },
44
45
  "dependencies": {
45
46
  "@microsoft/signalr": "^8.0.17"
46
47
  },
47
48
  "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
48
50
  "@types/node": "^22.15.32",
51
+ "eslint": "^10.5.0",
52
+ "globals": "^17.6.0",
49
53
  "typescript": "^5.8.3",
54
+ "typescript-eslint": "^8.61.1",
50
55
  "vitest": "^3.2.3"
51
56
  }
52
57
  }