bopodev-api 0.1.15 → 0.1.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,9 +17,9 @@
17
17
  "nanoid": "^5.1.5",
18
18
  "ws": "^8.19.0",
19
19
  "zod": "^4.1.5",
20
- "bopodev-agent-sdk": "0.1.15",
21
- "bopodev-contracts": "0.1.15",
22
- "bopodev-db": "0.1.15"
20
+ "bopodev-agent-sdk": "0.1.16",
21
+ "bopodev-contracts": "0.1.16",
22
+ "bopodev-db": "0.1.16"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
package/src/app.ts CHANGED
@@ -6,6 +6,7 @@ import { RepositoryValidationError } from "bopodev-db";
6
6
  import { nanoid } from "nanoid";
7
7
  import type { AppContext } from "./context";
8
8
  import { createAgentsRouter } from "./routes/agents";
9
+ import { createAuthRouter } from "./routes/auth";
9
10
  import { createCompaniesRouter } from "./routes/companies";
10
11
  import { createGoalsRouter } from "./routes/goals";
11
12
  import { createGovernanceRouter } from "./routes/governance";
@@ -14,12 +15,40 @@ import { createIssuesRouter } from "./routes/issues";
14
15
  import { createObservabilityRouter } from "./routes/observability";
15
16
  import { createProjectsRouter } from "./routes/projects";
16
17
  import { createPluginsRouter } from "./routes/plugins";
18
+ import { createTemplatesRouter } from "./routes/templates";
17
19
  import { sendError } from "./http";
18
20
  import { attachRequestActor } from "./middleware/request-actor";
21
+ import { resolveAllowedOrigins, resolveDeploymentMode } from "./security/deployment-mode";
19
22
 
20
23
  export function createApp(ctx: AppContext) {
21
24
  const app = express();
22
- app.use(cors());
25
+ const deploymentMode = ctx.deploymentMode ?? resolveDeploymentMode();
26
+ const allowedOrigins = ctx.allowedOrigins ?? resolveAllowedOrigins(deploymentMode);
27
+ app.use(
28
+ cors({
29
+ origin(origin, callback) {
30
+ if (!origin) {
31
+ callback(null, true);
32
+ return;
33
+ }
34
+ if (
35
+ deploymentMode === "local" &&
36
+ (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:"))
37
+ ) {
38
+ callback(null, true);
39
+ return;
40
+ }
41
+ if (allowedOrigins.includes(origin)) {
42
+ callback(null, true);
43
+ return;
44
+ }
45
+ callback(new Error(`CORS origin denied: ${origin}`));
46
+ },
47
+ credentials: true,
48
+ methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
49
+ allowedHeaders: ["content-type", "x-company-id", "authorization", "x-client-trace-id", "x-bopo-actor-token"]
50
+ })
51
+ );
23
52
  app.use(express.json());
24
53
  app.use(attachRequestActor);
25
54
 
@@ -29,6 +58,27 @@ export function createApp(ctx: AppContext) {
29
58
  res.setHeader("x-request-id", requestId);
30
59
  next();
31
60
  });
61
+ const logApiRequests = process.env.BOPO_LOG_API_REQUESTS !== "0";
62
+ if (logApiRequests) {
63
+ app.use((req, res, next) => {
64
+ if (req.path === "/health") {
65
+ next();
66
+ return;
67
+ }
68
+ const method = req.method.toUpperCase();
69
+ if (!isCrudMethod(method)) {
70
+ next();
71
+ return;
72
+ }
73
+ const startedAt = Date.now();
74
+ res.on("finish", () => {
75
+ const elapsedMs = Date.now() - startedAt;
76
+ const timestamp = new Date().toTimeString().slice(0, 8);
77
+ process.stderr.write(`[${timestamp}] INFO: ${method} ${req.originalUrl} ${res.statusCode} ${elapsedMs}ms\n`);
78
+ });
79
+ next();
80
+ });
81
+ }
32
82
 
33
83
  app.get("/health", async (_req, res) => {
34
84
  let dbReady = false;
@@ -55,6 +105,7 @@ export function createApp(ctx: AppContext) {
55
105
  });
56
106
  });
57
107
 
108
+ app.use("/auth", createAuthRouter(ctx));
58
109
  app.use("/companies", createCompaniesRouter(ctx));
59
110
  app.use("/projects", createProjectsRouter(ctx));
60
111
  app.use("/issues", createIssuesRouter(ctx));
@@ -64,6 +115,7 @@ export function createApp(ctx: AppContext) {
64
115
  app.use("/heartbeats", createHeartbeatRouter(ctx));
65
116
  app.use("/observability", createObservabilityRouter(ctx));
66
117
  app.use("/plugins", createPluginsRouter(ctx));
118
+ app.use("/templates", createTemplatesRouter(ctx));
67
119
 
68
120
  app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
69
121
  if (error instanceof RepositoryValidationError) {
@@ -76,3 +128,7 @@ export function createApp(ctx: AppContext) {
76
128
 
77
129
  return app;
78
130
  }
131
+
132
+ function isCrudMethod(method: string) {
133
+ return method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
134
+ }
package/src/context.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { BopoDb } from "bopodev-db";
2
2
  import type { RealtimeHub } from "./realtime/hub";
3
+ import type { DeploymentMode } from "./security/deployment-mode";
3
4
 
4
5
  export interface AppContext {
5
6
  db: BopoDb;
7
+ deploymentMode?: DeploymentMode;
8
+ allowedOrigins?: string[];
6
9
  getRuntimeHealth?: () => Promise<Record<string, unknown>>;
7
10
  realtimeHub?: RealtimeHub;
8
11
  }
@@ -1,4 +1,5 @@
1
1
  import { AgentRuntimeConfigSchema, type AgentRuntimeConfig, type ThinkingEffort } from "bopodev-contracts";
2
+ import { normalizeAbsolutePath } from "./instance-paths";
2
3
 
3
4
  export type LegacyRuntimeFields = {
4
5
  runtimeCommand?: string;
@@ -120,7 +121,7 @@ export function normalizeRuntimeConfig(input: {
120
121
  return {
121
122
  runtimeCommand: parsed.runtimeCommand?.trim() || undefined,
122
123
  runtimeArgs: parsed.runtimeArgs ?? [],
123
- runtimeCwd: parsed.runtimeCwd?.trim() || input.defaultRuntimeCwd || undefined,
124
+ runtimeCwd: normalizeRuntimeCwd(parsed.runtimeCwd, input.defaultRuntimeCwd),
124
125
  runtimeEnv: parsed.runtimeEnv ?? {},
125
126
  runtimeModel: parsed.runtimeModel?.trim() || undefined,
126
127
  runtimeThinkingEffort: parsed.runtimeThinkingEffort ?? "auto",
@@ -134,6 +135,14 @@ export function normalizeRuntimeConfig(input: {
134
135
  };
135
136
  }
136
137
 
138
+ function normalizeRuntimeCwd(runtimeCwd: string | undefined, defaultRuntimeCwd: string | undefined) {
139
+ const selected = runtimeCwd?.trim() || defaultRuntimeCwd || undefined;
140
+ if (!selected) {
141
+ return undefined;
142
+ }
143
+ return normalizeAbsolutePath(selected, { requireAbsoluteInput: true });
144
+ }
145
+
137
146
  export function parseRuntimeConfigFromAgentRow(agent: Record<string, unknown>): NormalizedRuntimeConfig {
138
147
  const fallback = parseRuntimeFromStateBlob(agent.stateBlob);
139
148
  const runtimeArgs = parseStringArray(agent.runtimeArgsJson) ?? fallback.args ?? [];
@@ -0,0 +1,447 @@
1
+ import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import {
5
+ assertPathInsideCompanyWorkspaceRoot,
6
+ assertPathInsidePath,
7
+ isInsidePath,
8
+ normalizeAbsolutePath,
9
+ resolveAgentProjectWorktreeRootPath,
10
+ resolveProjectWorkspacePath
11
+ } from "./instance-paths";
12
+ import type { ProjectExecutionWorkspacePolicy } from "./workspace-policy";
13
+
14
+ const DEFAULT_GIT_TIMEOUT_MS = 30_000;
15
+ const MAX_OUTPUT_CHARS = 4_096;
16
+
17
+ export class GitRuntimeError extends Error {
18
+ readonly code:
19
+ | "git_unavailable"
20
+ | "git_failed"
21
+ | "auth_missing"
22
+ | "policy_violation"
23
+ | "invalid_repo_url"
24
+ | "timeout";
25
+ readonly details: Record<string, unknown>;
26
+
27
+ constructor(
28
+ code:
29
+ | "git_unavailable"
30
+ | "git_failed"
31
+ | "auth_missing"
32
+ | "policy_violation"
33
+ | "invalid_repo_url"
34
+ | "timeout",
35
+ message: string,
36
+ details: Record<string, unknown> = {}
37
+ ) {
38
+ super(message);
39
+ this.name = "GitRuntimeError";
40
+ this.code = code;
41
+ this.details = details;
42
+ }
43
+ }
44
+
45
+ type GitAuthResolution = {
46
+ mode: "host" | "env_token";
47
+ extraArgs: string[];
48
+ };
49
+
50
+ type GitCommandResult = {
51
+ stdout: string;
52
+ stderr: string;
53
+ };
54
+
55
+ export async function bootstrapRepositoryWorkspace(input: {
56
+ companyId: string;
57
+ projectId: string;
58
+ cwd: string | null | undefined;
59
+ repoUrl: string;
60
+ repoRef?: string | null;
61
+ policy?: ProjectExecutionWorkspacePolicy | null;
62
+ runtimeEnv?: Record<string, string>;
63
+ timeoutMs?: number;
64
+ }) {
65
+ const repoUrl = input.repoUrl.trim();
66
+ if (!repoUrl) {
67
+ throw new GitRuntimeError("invalid_repo_url", "Project workspace repoUrl is empty.");
68
+ }
69
+ enforceRemoteAllowlist(repoUrl, input.policy);
70
+ const targetCwd = normalizeAbsolutePath(
71
+ input.cwd?.trim() || resolveProjectWorkspacePath(input.companyId, input.projectId)
72
+ );
73
+ assertPathInsideCompanyWorkspaceRoot(input.companyId, targetCwd, "project workspace cwd");
74
+ await mkdir(targetCwd, { recursive: true });
75
+ const auth = resolveGitAuth({
76
+ policy: input.policy,
77
+ runtimeEnv: input.runtimeEnv ?? {},
78
+ repoUrl
79
+ });
80
+ const timeoutMs = sanitizeTimeoutMs(input.timeoutMs);
81
+ const gitDirPath = join(targetCwd, ".git");
82
+ const hasGitDir = await pathExists(gitDirPath);
83
+ const actions: string[] = [];
84
+ if (!hasGitDir) {
85
+ await runGit(["clone", repoUrl, targetCwd], {
86
+ extraArgs: auth.extraArgs,
87
+ timeoutMs
88
+ });
89
+ actions.push("clone");
90
+ } else {
91
+ actions.push("reuse");
92
+ }
93
+ await runGit(["remote", "set-url", "origin", repoUrl], {
94
+ cwd: targetCwd,
95
+ extraArgs: auth.extraArgs,
96
+ timeoutMs
97
+ });
98
+ await runGit(["fetch", "origin", "--prune"], {
99
+ cwd: targetCwd,
100
+ extraArgs: auth.extraArgs,
101
+ timeoutMs
102
+ });
103
+ actions.push("fetch");
104
+ const targetRef = (input.repoRef ?? "").trim() || (await resolveDefaultRemoteHead(targetCwd, auth.extraArgs, timeoutMs));
105
+ if (targetRef) {
106
+ await checkoutRef(targetCwd, targetRef, auth.extraArgs, timeoutMs);
107
+ actions.push(`checkout:${targetRef}`);
108
+ }
109
+ return {
110
+ cwd: targetCwd,
111
+ authMode: auth.mode,
112
+ resolvedRef: targetRef || null,
113
+ actions
114
+ };
115
+ }
116
+
117
+ export async function ensureIsolatedGitWorktree(input: {
118
+ companyId: string;
119
+ repoCwd: string;
120
+ projectId: string;
121
+ agentId: string;
122
+ issueId?: string | null;
123
+ repoRef?: string | null;
124
+ policy?: ProjectExecutionWorkspacePolicy | null;
125
+ timeoutMs?: number;
126
+ }) {
127
+ const timeoutMs = sanitizeTimeoutMs(input.timeoutMs);
128
+ const normalizedRepoCwd = normalizeAbsolutePath(input.repoCwd, { requireAbsoluteInput: true });
129
+ assertPathInsideCompanyWorkspaceRoot(input.companyId, normalizedRepoCwd, "repo workspace cwd");
130
+ const strategy = input.policy?.strategy;
131
+ const branchPrefix = (strategy?.branchPrefix?.trim() || "bopo").replace(/[^a-zA-Z0-9/_-]/g, "-");
132
+ enforceBranchPrefixAllowlist(branchPrefix, input.policy);
133
+ const issuePart = (input.issueId?.trim() || "run").replace(/[^a-zA-Z0-9_-]/g, "-");
134
+ const agentPart = input.agentId.replace(/[^a-zA-Z0-9_-]/g, "-");
135
+ const projectPart = input.projectId.replace(/[^a-zA-Z0-9_-]/g, "-");
136
+ const branch = `${branchPrefix}/${projectPart}/${agentPart}/${issuePart}`;
137
+ const rootDir = strategy?.rootDir?.trim()
138
+ ? assertPathInsideCompanyWorkspaceRoot(
139
+ input.companyId,
140
+ normalizeAbsolutePath(strategy.rootDir, { requireAbsoluteInput: true }),
141
+ "execution workspace strategy.rootDir"
142
+ )
143
+ : resolveAgentProjectWorktreeRootPath(input.companyId, input.agentId, input.projectId);
144
+ await mkdir(rootDir, { recursive: true });
145
+ await cleanupStaleWorktrees({ rootDir, ttlMs: resolveWorktreeTtlMs() });
146
+ const targetPath = join(rootDir, `${projectPart}-${agentPart}-${issuePart}`);
147
+ assertPathInsidePath(rootDir, targetPath, "isolated worktree path");
148
+ const hasWorktree = await pathExists(targetPath);
149
+ if (!hasWorktree) {
150
+ const baseRef = (input.repoRef ?? "").trim() || "HEAD";
151
+ await runGit(["worktree", "add", "-B", branch, targetPath, baseRef], {
152
+ cwd: normalizedRepoCwd,
153
+ timeoutMs
154
+ });
155
+ } else {
156
+ await runGit(["-C", targetPath, "checkout", branch], { timeoutMs });
157
+ }
158
+ return {
159
+ cwd: targetPath,
160
+ branch,
161
+ rootDir
162
+ };
163
+ }
164
+
165
+ export async function cleanupStaleWorktrees(input: { rootDir: string; ttlMs: number }) {
166
+ if (input.ttlMs <= 0) {
167
+ return { removed: 0 };
168
+ }
169
+ const now = Date.now();
170
+ const rootDir = normalizeAbsolutePath(input.rootDir, { requireAbsoluteInput: true });
171
+ let removed = 0;
172
+ let entries: string[] = [];
173
+ try {
174
+ entries = await readdir(rootDir);
175
+ } catch {
176
+ return { removed: 0 };
177
+ }
178
+ for (const entry of entries) {
179
+ const absolute = join(rootDir, entry);
180
+ if (!isInsidePath(rootDir, absolute)) {
181
+ continue;
182
+ }
183
+ try {
184
+ const entryStat = await stat(absolute);
185
+ if (!entryStat.isDirectory()) {
186
+ continue;
187
+ }
188
+ if (now - entryStat.mtimeMs < input.ttlMs) {
189
+ continue;
190
+ }
191
+ await rm(absolute, { recursive: true, force: true });
192
+ removed += 1;
193
+ } catch {
194
+ // Best effort cleanup only.
195
+ }
196
+ }
197
+ return { removed };
198
+ }
199
+
200
+ function resolveGitAuth(input: {
201
+ policy?: ProjectExecutionWorkspacePolicy | null;
202
+ runtimeEnv: Record<string, string>;
203
+ repoUrl: string;
204
+ }): GitAuthResolution {
205
+ const configured = input.policy?.credentials;
206
+ const mode = configured?.mode ?? "host";
207
+ if (mode === "host") {
208
+ return { mode: "host", extraArgs: [] };
209
+ }
210
+ const tokenEnvVar = configured?.tokenEnvVar?.trim();
211
+ if (!tokenEnvVar) {
212
+ throw new GitRuntimeError("auth_missing", "Workspace git auth policy requires credentials.tokenEnvVar.");
213
+ }
214
+ const token = input.runtimeEnv[tokenEnvVar] ?? process.env[tokenEnvVar];
215
+ const normalizedToken = token?.trim();
216
+ if (!normalizedToken) {
217
+ throw new GitRuntimeError("auth_missing", `Git token env var '${tokenEnvVar}' is missing for repository access.`);
218
+ }
219
+ const username = configured?.username?.trim() || "x-access-token";
220
+ if (!isHttpsRepoUrl(input.repoUrl)) {
221
+ return { mode: "env_token", extraArgs: [] };
222
+ }
223
+ const basicHeader = Buffer.from(`${username}:${normalizedToken}`, "utf8").toString("base64");
224
+ return {
225
+ mode: "env_token",
226
+ extraArgs: ["-c", `http.extraHeader=Authorization: Basic ${basicHeader}`]
227
+ };
228
+ }
229
+
230
+ function enforceRemoteAllowlist(repoUrl: string, policy?: ProjectExecutionWorkspacePolicy | null) {
231
+ const allowRemotes = policy?.allowRemotes ?? null;
232
+ if (!allowRemotes || allowRemotes.length === 0) {
233
+ return;
234
+ }
235
+ const identity = parseRepositoryIdentity(repoUrl);
236
+ if (!identity) {
237
+ throw new GitRuntimeError("invalid_repo_url", `Repository '${repoUrl}' is not a recognized git URL.`);
238
+ }
239
+ const allowed = allowRemotes.some((candidate) => {
240
+ return matchesAllowRemoteCandidate(identity, candidate);
241
+ });
242
+ if (!allowed) {
243
+ throw new GitRuntimeError("policy_violation", `Repository '${repoUrl}' is not in execution allowRemotes policy.`);
244
+ }
245
+ }
246
+
247
+ function enforceBranchPrefixAllowlist(prefix: string, policy?: ProjectExecutionWorkspacePolicy | null) {
248
+ const allowBranchPrefixes = policy?.allowBranchPrefixes ?? null;
249
+ if (!allowBranchPrefixes || allowBranchPrefixes.length === 0) {
250
+ return;
251
+ }
252
+ const allowed = allowBranchPrefixes.some((candidate) => prefix.startsWith(candidate.trim()));
253
+ if (!allowed) {
254
+ throw new GitRuntimeError(
255
+ "policy_violation",
256
+ `Branch prefix '${prefix}' is not allowed by execution policy allowBranchPrefixes.`
257
+ );
258
+ }
259
+ }
260
+
261
+ async function checkoutRef(cwd: string, ref: string, extraArgs: string[], timeoutMs: number) {
262
+ await runGit(["checkout", ref], { cwd, extraArgs, timeoutMs });
263
+ }
264
+
265
+ async function resolveDefaultRemoteHead(cwd: string, extraArgs: string[], timeoutMs: number) {
266
+ const symbolic = await runGit(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
267
+ cwd,
268
+ extraArgs,
269
+ timeoutMs,
270
+ allowFailure: true
271
+ });
272
+ const symbolicRef = symbolic.stdout.trim();
273
+ if (symbolicRef.startsWith("origin/")) {
274
+ return symbolicRef.slice("origin/".length);
275
+ }
276
+ for (const fallbackRef of ["main", "master"]) {
277
+ const probe = await runGit(["rev-parse", "--verify", `origin/${fallbackRef}`], {
278
+ cwd,
279
+ extraArgs,
280
+ timeoutMs,
281
+ allowFailure: true
282
+ });
283
+ if (probe.exitCode === 0) {
284
+ return fallbackRef;
285
+ }
286
+ }
287
+ return "";
288
+ }
289
+
290
+ async function runGit(
291
+ args: string[],
292
+ options?: {
293
+ cwd?: string;
294
+ extraArgs?: string[];
295
+ timeoutMs?: number;
296
+ allowFailure?: boolean;
297
+ }
298
+ ): Promise<GitCommandResult & { exitCode: number }> {
299
+ const timeoutMs = sanitizeTimeoutMs(options?.timeoutMs);
300
+ const fullArgs = [...(options?.extraArgs ?? []), ...args];
301
+ return new Promise((resolve, reject) => {
302
+ const child = spawn("git", fullArgs, {
303
+ cwd: options?.cwd,
304
+ env: process.env
305
+ });
306
+ let stdout = "";
307
+ let stderr = "";
308
+ let timeout: NodeJS.Timeout | null = setTimeout(() => {
309
+ child.kill("SIGTERM");
310
+ timeout = null;
311
+ reject(new GitRuntimeError("timeout", `git command timed out after ${timeoutMs}ms`, { args: redactArgs(fullArgs) }));
312
+ }, timeoutMs);
313
+ child.stdout.on("data", (chunk) => {
314
+ stdout = truncateOutput(stdout + String(chunk));
315
+ });
316
+ child.stderr.on("data", (chunk) => {
317
+ stderr = truncateOutput(stderr + String(chunk));
318
+ });
319
+ child.on("error", (error) => {
320
+ if (timeout) {
321
+ clearTimeout(timeout);
322
+ }
323
+ const message = String(error);
324
+ reject(new GitRuntimeError("git_unavailable", "Unable to execute git binary.", { message }));
325
+ });
326
+ child.on("close", (code) => {
327
+ if (timeout) {
328
+ clearTimeout(timeout);
329
+ }
330
+ const exitCode = code ?? 1;
331
+ if (exitCode !== 0 && !options?.allowFailure) {
332
+ reject(
333
+ new GitRuntimeError("git_failed", "git command failed.", {
334
+ exitCode,
335
+ args: redactArgs(fullArgs),
336
+ stderr: redactSensitive(stderr)
337
+ })
338
+ );
339
+ return;
340
+ }
341
+ resolve({ stdout, stderr, exitCode });
342
+ });
343
+ });
344
+ }
345
+
346
+ function sanitizeTimeoutMs(timeoutMs: number | undefined) {
347
+ const parsed = Number(timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS);
348
+ if (!Number.isFinite(parsed) || parsed < 1_000) {
349
+ return DEFAULT_GIT_TIMEOUT_MS;
350
+ }
351
+ return Math.floor(parsed);
352
+ }
353
+
354
+ function resolveWorktreeTtlMs() {
355
+ const parsedMinutes = Number(process.env.BOPO_GIT_WORKTREE_TTL_MINUTES ?? "240");
356
+ if (!Number.isFinite(parsedMinutes) || parsedMinutes < 5) {
357
+ return 240 * 60_000;
358
+ }
359
+ return Math.floor(parsedMinutes * 60_000);
360
+ }
361
+
362
+ function truncateOutput(value: string) {
363
+ return value.length > MAX_OUTPUT_CHARS ? value.slice(value.length - MAX_OUTPUT_CHARS) : value;
364
+ }
365
+
366
+ function redactArgs(args: string[]) {
367
+ return args.map((value) => redactSensitive(value));
368
+ }
369
+
370
+ function redactSensitive(value: string) {
371
+ return value
372
+ .replace(/(authorization:\s*basic\s+)[a-z0-9+/=]+/gi, "$1[REDACTED]")
373
+ .replace(/(authorization:\s*bearer\s+)[^\s]+/gi, "$1[REDACTED]");
374
+ }
375
+
376
+ function isHttpsRepoUrl(value: string) {
377
+ try {
378
+ const parsed = new URL(value);
379
+ return parsed.protocol === "https:";
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ type RepositoryIdentity = {
386
+ host: string;
387
+ path: string;
388
+ };
389
+
390
+ function parseRepositoryIdentity(repoUrl: string): RepositoryIdentity | null {
391
+ const trimmed = repoUrl.trim();
392
+ if (!trimmed) {
393
+ return null;
394
+ }
395
+ const scpLike = trimmed.match(/^(?:[^@]+@)?([^:\/]+):(.+)$/);
396
+ if (scpLike) {
397
+ return {
398
+ host: scpLike[1]!.toLowerCase(),
399
+ path: normalizeRepoPath(scpLike[2]!)
400
+ };
401
+ }
402
+ try {
403
+ const parsed = new URL(trimmed);
404
+ const host = parsed.hostname.toLowerCase();
405
+ if (!host) {
406
+ return null;
407
+ }
408
+ const path = normalizeRepoPath(parsed.pathname);
409
+ return { host, path };
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ function normalizeRepoPath(path: string) {
416
+ return path
417
+ .replace(/^\/+/, "")
418
+ .replace(/\.git$/i, "")
419
+ .toLowerCase();
420
+ }
421
+
422
+ function matchesAllowRemoteCandidate(identity: RepositoryIdentity, candidate: string) {
423
+ const normalizedCandidate = candidate.trim().toLowerCase();
424
+ if (!normalizedCandidate) {
425
+ return false;
426
+ }
427
+ const hostPathMatch = normalizedCandidate.match(/^([^/]+)\/(.+)$/);
428
+ if (hostPathMatch) {
429
+ const candidateHost = hostPathMatch[1]!;
430
+ const candidatePath = normalizeRepoPath(hostPathMatch[2]!);
431
+ return identity.host === candidateHost && (identity.path === candidatePath || identity.path.startsWith(`${candidatePath}/`));
432
+ }
433
+ if (normalizedCandidate.includes(".") || normalizedCandidate.includes(":")) {
434
+ return identity.host === normalizedCandidate;
435
+ }
436
+ const candidatePathOnly = normalizeRepoPath(normalizedCandidate);
437
+ return identity.path === candidatePathOnly || identity.path.startsWith(`${candidatePathOnly}/`);
438
+ }
439
+
440
+ async function pathExists(path: string) {
441
+ try {
442
+ await stat(path);
443
+ return true;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }