@loucompanion/forge-bridge 0.1.1-dev.8dcbc6245e51 → 0.1.1-dev.ac486debe210
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/dist/bridge/bridge.service.js +7 -1
- package/dist/bridge/bridge.types.d.ts +6 -0
- package/dist/cli/cli.service.d.ts +27 -2
- package/dist/cli/cli.service.js +406 -16
- package/dist/cli/local-config.d.ts +3 -0
- package/dist/cli/local-config.js +8 -2
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +4 -2
- package/dist/session/internals.js +2 -1
- package/dist/session/phase-completion.d.ts +86 -0
- package/dist/session/phase-completion.js +1018 -0
- package/dist/session/provider-cli/index.d.ts +2 -2
- package/dist/session/provider-cli/index.js +1 -1
- package/dist/session/provider-cli/provider-cli.base.d.ts +2 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +48 -3
- package/dist/session/provider-cli/provider-cli.service.js +1226 -38
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/session.service.js +19 -10
- package/dist/session/session.types.d.ts +7 -0
- package/dist/session/session.types.js +11 -0
- package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
- package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +1 -1
- package/dist/worktree/artifact-injector.js +9 -9
- package/dist/worktree/worktree.service.d.ts +2 -0
- package/dist/worktree/worktree.service.js +107 -2
- package/package.json +1 -1
|
@@ -3,10 +3,12 @@ import type { WorktreeHandle } from "../../worktree/worktree.types.js";
|
|
|
3
3
|
export interface ProviderCliRunContext {
|
|
4
4
|
readonly dispatch: RunOnceDispatchPacket;
|
|
5
5
|
readonly worktree: WorktreeHandle;
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
6
7
|
emit(event: RunnerDispatchEvent): Promise<void>;
|
|
7
8
|
}
|
|
8
9
|
export interface ProviderCliRunResult {
|
|
9
10
|
readonly summary: string;
|
|
10
11
|
readonly spec?: RunnerDispatchSpec | null;
|
|
11
12
|
readonly artifact?: RunnerDispatchArtifact | null;
|
|
13
|
+
readonly artifacts?: readonly RunnerDispatchArtifact[] | null;
|
|
12
14
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RunOnceDispatchPacket, RunnerDispatchEvent } from "../session.types.js";
|
|
2
|
+
export type UsageSource = "provider_reported" | "cli_reported" | "estimated" | "unknown";
|
|
3
|
+
/**
|
|
4
|
+
* Normalized, provider-agnostic token usage accumulated for a dispatch. Cost is never computed
|
|
5
|
+
* here; the API recomputes cost from these tokens x model rates (ADR-0006).
|
|
6
|
+
*/
|
|
7
|
+
export interface ProviderUsage {
|
|
8
|
+
readonly inputTokens: number;
|
|
9
|
+
readonly outputTokens: number;
|
|
10
|
+
readonly cacheReadTokens: number;
|
|
11
|
+
readonly cacheCreationTokens: number;
|
|
12
|
+
readonly reasoningOutputTokens: number;
|
|
13
|
+
readonly providerDurationMs: number | null;
|
|
14
|
+
readonly source: UsageSource;
|
|
15
|
+
}
|
|
16
|
+
export declare function emptyUsage(source?: UsageSource): ProviderUsage;
|
|
17
|
+
export declare function addUsage(base: ProviderUsage, next: Partial<ProviderUsage>): ProviderUsage;
|
|
18
|
+
export declare function totalTokens(usage: ProviderUsage): number;
|
|
19
|
+
export declare function hasUsage(usage: ProviderUsage | null): usage is ProviderUsage;
|
|
20
|
+
/**
|
|
21
|
+
* Builds the durable `provider_usage` event. Token numbers travel as a structured `data` JSON
|
|
22
|
+
* string (snake_case) that the API parses into a usage_event row.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildProviderUsageEvent(dispatch: RunOnceDispatchPacket, usage: ProviderUsage): RunnerDispatchEvent;
|
|
25
|
+
export declare function readUsageNumber(source: Record<string, unknown>, key: string): number;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function emptyUsage(source = "unknown") {
|
|
2
|
+
return {
|
|
3
|
+
inputTokens: 0,
|
|
4
|
+
outputTokens: 0,
|
|
5
|
+
cacheReadTokens: 0,
|
|
6
|
+
cacheCreationTokens: 0,
|
|
7
|
+
reasoningOutputTokens: 0,
|
|
8
|
+
providerDurationMs: null,
|
|
9
|
+
source
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function addUsage(base, next) {
|
|
13
|
+
return {
|
|
14
|
+
inputTokens: base.inputTokens + nonNegative(next.inputTokens),
|
|
15
|
+
outputTokens: base.outputTokens + nonNegative(next.outputTokens),
|
|
16
|
+
cacheReadTokens: base.cacheReadTokens + nonNegative(next.cacheReadTokens),
|
|
17
|
+
cacheCreationTokens: base.cacheCreationTokens + nonNegative(next.cacheCreationTokens),
|
|
18
|
+
reasoningOutputTokens: base.reasoningOutputTokens + nonNegative(next.reasoningOutputTokens),
|
|
19
|
+
providerDurationMs: next.providerDurationMs ?? base.providerDurationMs,
|
|
20
|
+
source: next.source ?? base.source
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function totalTokens(usage) {
|
|
24
|
+
return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheCreationTokens;
|
|
25
|
+
}
|
|
26
|
+
export function hasUsage(usage) {
|
|
27
|
+
return usage !== null && totalTokens(usage) > 0;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Builds the durable `provider_usage` event. Token numbers travel as a structured `data` JSON
|
|
31
|
+
* string (snake_case) that the API parses into a usage_event row.
|
|
32
|
+
*/
|
|
33
|
+
export function buildProviderUsageEvent(dispatch, usage) {
|
|
34
|
+
const data = {
|
|
35
|
+
provider: dispatch.execution.provider,
|
|
36
|
+
model: dispatch.execution.model,
|
|
37
|
+
lane: dispatch.execution.lane,
|
|
38
|
+
phase: dispatch.cycleRun.phase,
|
|
39
|
+
attempt: dispatch.cycleRun.attempt,
|
|
40
|
+
input_tokens: usage.inputTokens,
|
|
41
|
+
output_tokens: usage.outputTokens,
|
|
42
|
+
cache_read_tokens: usage.cacheReadTokens,
|
|
43
|
+
cache_creation_tokens: usage.cacheCreationTokens,
|
|
44
|
+
reasoning_output_tokens: usage.reasoningOutputTokens,
|
|
45
|
+
metered: dispatch.execution.authKind === "api_key",
|
|
46
|
+
usage_source: usage.source,
|
|
47
|
+
provider_duration_ms: usage.providerDurationMs
|
|
48
|
+
};
|
|
49
|
+
const total = totalTokens(usage);
|
|
50
|
+
return {
|
|
51
|
+
role: "runner",
|
|
52
|
+
kind: "checkpoint",
|
|
53
|
+
content: `Provider usage: ${total} tokens (${usage.inputTokens} in / ${usage.outputTokens} out) for ${dispatch.execution.model}.`,
|
|
54
|
+
eventType: "provider_usage",
|
|
55
|
+
data: JSON.stringify(data)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function readUsageNumber(source, key) {
|
|
59
|
+
const value = source[key];
|
|
60
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
61
|
+
}
|
|
62
|
+
function nonNegative(value) {
|
|
63
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
64
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
2
|
-
import {
|
|
2
|
+
import { RoutingProviderCliService } from "./provider-cli/provider-cli.service.js";
|
|
3
3
|
import { GitWorktreeService } from "../worktree/worktree.service.js";
|
|
4
4
|
export class BridgeSessionService {
|
|
5
5
|
worktrees;
|
|
6
6
|
providerCli;
|
|
7
|
-
constructor(worktrees = new GitWorktreeService(), providerCli = new
|
|
7
|
+
constructor(worktrees = new GitWorktreeService(), providerCli = new RoutingProviderCliService()) {
|
|
8
8
|
this.worktrees = worktrees;
|
|
9
9
|
this.providerCli = providerCli;
|
|
10
10
|
}
|
|
@@ -46,21 +46,30 @@ export class BridgeSessionService {
|
|
|
46
46
|
}
|
|
47
47
|
try {
|
|
48
48
|
throwIfAborted(signal);
|
|
49
|
-
const
|
|
49
|
+
const lane = dispatch.execution.lane.toLowerCase();
|
|
50
|
+
const role = dispatch.execution.role.toLowerCase();
|
|
51
|
+
const isQa = role === "qa" || lane === "qa";
|
|
52
|
+
const isConflictResolve = role === "conflict_resolve" || lane === "conflict_resolve";
|
|
53
|
+
const context = {
|
|
54
|
+
dispatch,
|
|
55
|
+
worktree,
|
|
56
|
+
emit,
|
|
57
|
+
signal
|
|
58
|
+
};
|
|
50
59
|
const run = isQa
|
|
51
|
-
? await this.providerCli.runQa(
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
60
|
+
? await this.providerCli.runQa(context)
|
|
61
|
+
: isConflictResolve
|
|
62
|
+
? await this.providerCli.runConflictResolve(context)
|
|
63
|
+
: await this.providerCli.runImplementation(context);
|
|
64
|
+
const phaseArtifacts = run.artifacts?.length ? run.artifacts : run.artifact ? [run.artifact] : [];
|
|
57
65
|
return redactSecrets({
|
|
58
66
|
schemaVersion: 1,
|
|
59
67
|
exitCode: 0,
|
|
60
68
|
summary: run.summary,
|
|
61
69
|
events,
|
|
62
70
|
spec: run.spec ?? null,
|
|
63
|
-
artifact: run.artifact ?? null
|
|
71
|
+
artifact: run.artifact ?? null,
|
|
72
|
+
artifacts: phaseArtifacts
|
|
64
73
|
}, redactionSecrets);
|
|
65
74
|
}
|
|
66
75
|
finally {
|
|
@@ -18,6 +18,7 @@ export interface DispatchResolvedArtifact {
|
|
|
18
18
|
readonly scope: string;
|
|
19
19
|
readonly body: string;
|
|
20
20
|
readonly contentHash: string;
|
|
21
|
+
readonly version: number;
|
|
21
22
|
readonly lane?: string | null;
|
|
22
23
|
readonly event?: string | null;
|
|
23
24
|
}
|
|
@@ -86,6 +87,10 @@ export interface RunnerDispatchEvent {
|
|
|
86
87
|
readonly kind: string;
|
|
87
88
|
readonly content: string;
|
|
88
89
|
readonly eventType: string;
|
|
90
|
+
/** Provider/runner-reported event time (ISO-8601) when available, preserved for accurate durations. */
|
|
91
|
+
readonly occurredAt?: string | null;
|
|
92
|
+
/** Optional structured telemetry payload (JSON string), e.g. token usage for provider_usage events. */
|
|
93
|
+
readonly data?: string | null;
|
|
89
94
|
}
|
|
90
95
|
export interface RunnerDispatchSpec {
|
|
91
96
|
readonly repoPath: string;
|
|
@@ -101,6 +106,7 @@ export interface RunnerDispatchArtifact {
|
|
|
101
106
|
readonly report: string;
|
|
102
107
|
readonly producedBy: string;
|
|
103
108
|
readonly status: string;
|
|
109
|
+
readonly phase?: string | null;
|
|
104
110
|
}
|
|
105
111
|
export interface RunOnceResultPacket {
|
|
106
112
|
readonly schemaVersion: 1;
|
|
@@ -109,6 +115,7 @@ export interface RunOnceResultPacket {
|
|
|
109
115
|
readonly events: readonly RunnerDispatchEvent[];
|
|
110
116
|
readonly spec?: RunnerDispatchSpec | null;
|
|
111
117
|
readonly artifact?: RunnerDispatchArtifact | null;
|
|
118
|
+
readonly artifacts?: readonly RunnerDispatchArtifact[] | null;
|
|
112
119
|
}
|
|
113
120
|
export interface RunnerEventLine {
|
|
114
121
|
readonly type: "runner_event";
|
|
@@ -153,6 +153,7 @@ function parseArtifacts(value) {
|
|
|
153
153
|
scope: requiredString(artifact, "scope"),
|
|
154
154
|
body: optionalString(artifact, "body") ?? "",
|
|
155
155
|
contentHash: requiredString(artifact, "contentHash"),
|
|
156
|
+
version: optionalPositiveInteger(artifact, "version") ?? 1,
|
|
156
157
|
lane: optionalString(artifact, "lane"),
|
|
157
158
|
event: optionalString(artifact, "event")
|
|
158
159
|
};
|
|
@@ -188,6 +189,16 @@ function requiredBoolean(source, key) {
|
|
|
188
189
|
}
|
|
189
190
|
return value;
|
|
190
191
|
}
|
|
192
|
+
function optionalPositiveInteger(source, key) {
|
|
193
|
+
const value = source[key];
|
|
194
|
+
if (value === undefined || value === null) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
198
|
+
throw new Error(`${key} must be a positive integer when provided.`);
|
|
199
|
+
}
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
191
202
|
function requiredNumber(source, key) {
|
|
192
203
|
const value = source[key];
|
|
193
204
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface CursorAgentLaunch {
|
|
2
|
+
readonly command: string;
|
|
3
|
+
readonly args: readonly string[];
|
|
4
|
+
}
|
|
5
|
+
export declare function isCursorAgentCommand(command: string): boolean;
|
|
6
|
+
export declare function resolveCursorAgentDirectLaunch(command: string, args: readonly string[]): Promise<CursorAgentLaunch | null>;
|
|
7
|
+
export declare function findLatestCursorAgentVersionDir(versionsRoot: string): Promise<string | null>;
|
|
8
|
+
export declare function compareCursorAgentVersionDirNames(left: string, right: string): number;
|
|
9
|
+
export declare function parseCursorAgentVersionSortKey(name: string): number;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const cursorAgentVersionDirPattern = /^\d{4}\.\d{1,2}\.\d{1,2}-.+/;
|
|
4
|
+
export function isCursorAgentCommand(command) {
|
|
5
|
+
const normalized = command.trim().toLowerCase();
|
|
6
|
+
return normalized === "cursor-agent" || normalized === "agent";
|
|
7
|
+
}
|
|
8
|
+
export async function resolveCursorAgentDirectLaunch(command, args) {
|
|
9
|
+
if (!isCursorAgentCommand(command)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
13
|
+
if (!localAppData) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const versionDir = await findLatestCursorAgentVersionDir(path.join(localAppData, "cursor-agent", "versions"));
|
|
17
|
+
if (!versionDir) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const nodePath = path.join(versionDir, "node.exe");
|
|
21
|
+
const indexPath = path.join(versionDir, "index.js");
|
|
22
|
+
if (!(await fileExists(nodePath)) || !(await fileExists(indexPath))) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
command: nodePath,
|
|
27
|
+
args: [indexPath, ...args]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function findLatestCursorAgentVersionDir(versionsRoot) {
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = await fs.readdir(versionsRoot);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const latestName = entries
|
|
39
|
+
.filter((name) => cursorAgentVersionDirPattern.test(name))
|
|
40
|
+
.sort(compareCursorAgentVersionDirNames)
|
|
41
|
+
.at(-1);
|
|
42
|
+
return latestName ? path.join(versionsRoot, latestName) : null;
|
|
43
|
+
}
|
|
44
|
+
export function compareCursorAgentVersionDirNames(left, right) {
|
|
45
|
+
const leftKey = parseCursorAgentVersionSortKey(left);
|
|
46
|
+
const rightKey = parseCursorAgentVersionSortKey(right);
|
|
47
|
+
if (leftKey !== rightKey) {
|
|
48
|
+
return leftKey - rightKey;
|
|
49
|
+
}
|
|
50
|
+
return left.localeCompare(right);
|
|
51
|
+
}
|
|
52
|
+
export function parseCursorAgentVersionSortKey(name) {
|
|
53
|
+
const datePart = name.split("-")[0] ?? "";
|
|
54
|
+
const parts = datePart.split(".");
|
|
55
|
+
if (parts.length !== 3) {
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
const year = parts[0] ?? "";
|
|
59
|
+
const month = (parts[1] ?? "").padStart(2, "0");
|
|
60
|
+
const day = (parts[2] ?? "").padStart(2, "0");
|
|
61
|
+
const numeric = Number(`${year}${month}${day}`);
|
|
62
|
+
return Number.isFinite(numeric) ? numeric : 0;
|
|
63
|
+
}
|
|
64
|
+
async function fileExists(inputPath) {
|
|
65
|
+
return fs.stat(inputPath).then((stat) => stat.isFile()).catch(() => false);
|
|
66
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const forgeBridgeVersion = "0.1.1-dev.
|
|
1
|
+
export declare const forgeBridgeVersion = "0.1.1-dev.ac486debe210";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const forgeBridgeVersion = "0.1.1-dev.
|
|
1
|
+
export const forgeBridgeVersion = "0.1.1-dev.ac486debe210";
|
|
@@ -15,11 +15,13 @@ export async function writeArtifact(worktreePath, artifact) {
|
|
|
15
15
|
const scope = safeArtifactSegment("scope", artifact.scope);
|
|
16
16
|
const slug = safeArtifactSegment("slug", artifact.slug);
|
|
17
17
|
const forgeRoot = path.resolve(worktreePath, ".forge");
|
|
18
|
-
const
|
|
19
|
-
assertInside(forgeRoot,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const scopeDir = path.resolve(forgeRoot, kindFolder, scope);
|
|
19
|
+
assertInside(forgeRoot, scopeDir, "Artifact directory");
|
|
20
|
+
const filePath = normalizedArtifactKind(artifact.kind) === "skill"
|
|
21
|
+
? path.resolve(scopeDir, slug, "SKILL.md")
|
|
22
|
+
: path.resolve(scopeDir, `${slug}.md`);
|
|
22
23
|
assertInside(forgeRoot, filePath, "Artifact file");
|
|
24
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
23
25
|
await fs.writeFile(filePath, artifact.body, "utf8");
|
|
24
26
|
}
|
|
25
27
|
export function selectLaneCommand(artifacts, lane, phase) {
|
|
@@ -27,10 +29,7 @@ export function selectLaneCommand(artifacts, lane, phase) {
|
|
|
27
29
|
return artifacts.find((artifact) => normalizedArtifactKind(artifact.kind) === "command" && artifact.slug.trim().toLowerCase() === slug);
|
|
28
30
|
}
|
|
29
31
|
export function resolveCommandSlug(lane, phase) {
|
|
30
|
-
const normalizedPhase = phase.trim().toLowerCase();
|
|
31
|
-
if (normalizedPhase === "self_review") {
|
|
32
|
-
return "self-review-prompt";
|
|
33
|
-
}
|
|
32
|
+
const normalizedPhase = phase.trim().toLowerCase().replace(/-/g, "_");
|
|
34
33
|
if (normalizedPhase === "rework") {
|
|
35
34
|
return "rework-prompt";
|
|
36
35
|
}
|
|
@@ -43,12 +42,13 @@ export function resolveCommandSlug(lane, phase) {
|
|
|
43
42
|
return "dev-prompt";
|
|
44
43
|
}
|
|
45
44
|
}
|
|
46
|
-
export function buildSpecBody(laneCommand, workItem) {
|
|
45
|
+
export function buildSpecBody(laneCommand, workItem, phase = "implementation") {
|
|
47
46
|
const prompt = laneCommand.body.replaceAll("{workItemId}", workItem.id);
|
|
48
47
|
return `---
|
|
49
48
|
work_item_id: ${workItem.id}
|
|
50
49
|
status: approved
|
|
51
50
|
lane: ${laneCommand.lane ?? "develop"}
|
|
51
|
+
phase: ${phase}
|
|
52
52
|
command_slug: ${laneCommand.slug}
|
|
53
53
|
---
|
|
54
54
|
|
|
@@ -7,6 +7,8 @@ export declare class GitWorktreeService implements WorktreeService {
|
|
|
7
7
|
prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
|
|
8
8
|
release(handle: WorktreeHandle): Promise<void>;
|
|
9
9
|
private resolveRepoPath;
|
|
10
|
+
private findExistingRepoPath;
|
|
11
|
+
private resolveMatchingRepo;
|
|
10
12
|
private ensureBaseBranch;
|
|
11
13
|
private gitRefExists;
|
|
12
14
|
private ensureGitIdentity;
|
|
@@ -18,7 +18,7 @@ export class GitWorktreeService {
|
|
|
18
18
|
const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
|
|
19
19
|
const kind = safeSegment(request.workItem.kind, "work-item");
|
|
20
20
|
const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
|
|
21
|
-
const worktreeRoot =
|
|
21
|
+
const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
|
|
22
22
|
const worktreePath = path.join(worktreeRoot, kind, shortId);
|
|
23
23
|
const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
|
|
24
24
|
if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
|
|
@@ -78,7 +78,11 @@ export class GitWorktreeService {
|
|
|
78
78
|
if (!repo.cloneOnDemand) {
|
|
79
79
|
throw new Error("Repo has no server-local path and cloneOnDemand is disabled.");
|
|
80
80
|
}
|
|
81
|
-
const
|
|
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, "")}`);
|
|
82
86
|
if (!(await gitMetadataExists(repoPath, allowedRoot))) {
|
|
83
87
|
const repoPathExists = await pathExists(repoPath);
|
|
84
88
|
if (repoPathExists) {
|
|
@@ -109,6 +113,46 @@ export class GitWorktreeService {
|
|
|
109
113
|
}
|
|
110
114
|
return resolved;
|
|
111
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
|
+
}
|
|
112
156
|
async ensureBaseBranch(repoPath, baseBranch) {
|
|
113
157
|
if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
|
|
114
158
|
return;
|
|
@@ -132,6 +176,15 @@ export class GitWorktreeService {
|
|
|
132
176
|
await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
|
|
133
177
|
}
|
|
134
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
|
+
}
|
|
135
188
|
function isCloneableRemote(remoteUrl) {
|
|
136
189
|
const trimmed = remoteUrl?.trim() ?? "";
|
|
137
190
|
if (trimmed.length === 0 || trimmed.startsWith("local://")) {
|
|
@@ -194,3 +247,55 @@ async function directoryExists(inputPath) {
|
|
|
194
247
|
const stat = await fs.stat(inputPath).catch(() => null);
|
|
195
248
|
return stat?.isDirectory() ?? false;
|
|
196
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loucompanion/forge-bridge",
|
|
3
|
-
"version": "0.1.1-dev.
|
|
3
|
+
"version": "0.1.1-dev.ac486debe210",
|
|
4
4
|
"description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|