@ricsam/r5d-worker 0.0.37 → 0.0.38

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/README.md CHANGED
@@ -10,7 +10,7 @@ r5d-worker --version
10
10
  The worker uses the existing `r5dctl` config file and accepts `R5D_WORKER_TOKEN` or `R5D_API_KEY`. Project checkouts are managed under:
11
11
 
12
12
  ```text
13
- ~/.r5d/projects/<namespace-repo>/<branch-name>
13
+ ~/.r5d/projects/<namespace>/<project>/<branch-name>
14
14
  ```
15
15
 
16
16
  Web shells and agent shell commands receive a server-issued `r5dctl` credential automatically. The credential is scoped to the signed-in user and revoked when the shell or command finishes, so commands such as `r5dctl auth status` do not require a separate login on the worker host.
@@ -19,6 +19,10 @@ When the user has signed in with GitHub, web shells and agent commands also rece
19
19
 
20
20
  Visible branch checkouts are the user/agent workbench. Their `origin` remains the connected GitHub repository for manual Git work. r5d internal sync git metadata lives separately under `~/.r5d/sync`; when a worker connects, every idle registered branch is force-synchronized from that internal remote. The same destructive sync can be requested for a live worker from User Settings.
21
21
 
22
+ Branch names managed by r5d use lowercase letters, numbers, internal hyphens, and slash-separated segments, for example `ft/esm-support`. They may contain at most 45 characters. On the first start after upgrading to the nested checkout layout, the worker atomically moves each unambiguous legacy `<namespace>-<project>` directory to `<namespace>/<project>`. If both paths exist, it preserves both and stops with recovery instructions.
23
+
24
+ The nested-layout release is a breaking worker/server cutover. Run `bun run workspace:checkout-preflight` against the production database, stop and upgrade workers to the required version, deploy the matching server, and then reconnect workers. Older workers are rejected before WebSocket upgrade rather than receiving an incompatible manifest.
25
+
22
26
  Worker labels are mandatory and unique per user. Choose labels that describe host capabilities, such as `macos`, `linux`, `ios`, or `ec2-build`.
23
27
 
24
28
  `r5d-worker start` keeps a lightweight supervisor process attached to the launching terminal or service. When both r5d CLIs are updated from User Settings, the connected runtime verifies the installed versions, exits with a reload signal, and the supervisor reconnects using the new worker package. Updates only run while the worker has no active commands or shells.
package/dist/cjs/main.cjs CHANGED
@@ -56,12 +56,12 @@ var import_git_identity = require("./git-identity.cjs");
56
56
  var import_heartbeat = require("./heartbeat.cjs");
57
57
  var import_process_tree = require("./process-tree.cjs");
58
58
  var import_supervisor = require("./supervisor.cjs");
59
+ var import_managed_paths = require("./managed-paths.cjs");
59
60
  var import_workspace_sync = require("./workspace-sync.cjs");
60
61
  const import_meta = {};
61
62
  const DEFAULT_BASE_URL = "https://r5d.dev";
62
63
  const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
63
64
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
64
- const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
65
65
  const DEFAULT_READ_LIMIT = 2e3;
66
66
  const DEFAULT_READ_MAX_BYTES = 5e4;
67
67
  const MAX_LINE_LENGTH = 2e3;
@@ -604,9 +604,7 @@ function validateProjectId(projectId) {
604
604
  }
605
605
  }
606
606
  function validateBranchName(branchName) {
607
- if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
608
- throw new Error(`Invalid branch name from server: ${branchName}`);
609
- }
607
+ (0, import_managed_paths.validateManagedBranchName)(branchName);
610
608
  }
611
609
  function validatePlanId(planId) {
612
610
  if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
@@ -2088,10 +2086,13 @@ function websocketUrl(baseUrl, label) {
2088
2086
  const url = new URL("/worker/ws", baseUrl);
2089
2087
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
2090
2088
  url.searchParams.set("label", label);
2089
+ url.searchParams.set("version", getWorkerVersion());
2091
2090
  return url.toString();
2092
2091
  }
2093
2092
  function projectRootFor(projectsRoot, projectId, manifestByProjectId) {
2094
- return import_node_path.default.join(projectsRoot, manifestByProjectId.get(projectId)?.repoSlug ?? projectId);
2093
+ const manifest = manifestByProjectId.get(projectId);
2094
+ if (!manifest) throw new Error(`Project ${projectId} is missing from the worker workspace manifest`);
2095
+ return (0, import_managed_paths.managedProjectRoot)(projectsRoot, manifest.checkoutPathSegments);
2095
2096
  }
2096
2097
  async function startWorker(options) {
2097
2098
  const config = readConfig(options.configPath);
@@ -2285,10 +2286,13 @@ async function startWorker(options) {
2285
2286
  configureGitHubAuth(message.githubCredential);
2286
2287
  visibleGitIdentity = message.gitIdentity;
2287
2288
  workspaceRemoteUrl = message.workspaceRemoteUrl;
2289
+ const migratedProjectIds = (0, import_managed_paths.migrateLegacyProjectRoots)(projectsRoot, message.projects);
2288
2290
  manifestByProjectId.clear();
2289
2291
  for (const project of message.projects) manifestByProjectId.set(project.projectId, project);
2290
- process.stdout.write(`[r5d-worker] workspace manifest: ${message.projects.length} projects
2291
- `);
2292
+ process.stdout.write(
2293
+ `[r5d-worker] workspace manifest: ${message.projects.length} projects${migratedProjectIds.length > 0 ? `; migrated ${migratedProjectIds.length} project checkout root(s)` : ""}
2294
+ `
2295
+ );
2292
2296
  if (!periodicWorkspaceScan) {
2293
2297
  const emptyFingerprint = (0, import_node_crypto.createHash)("sha256").update("").digest("hex");
2294
2298
  periodicWorkspaceScan = setInterval(() => {
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var managed_paths_exports = {};
30
+ __export(managed_paths_exports, {
31
+ BRANCH_NAME_MAX_LENGTH: () => BRANCH_NAME_MAX_LENGTH,
32
+ BRANCH_NAME_PATTERN: () => BRANCH_NAME_PATTERN,
33
+ assertPathInside: () => assertPathInside,
34
+ legacyProjectSlug: () => legacyProjectSlug,
35
+ managedBranchPath: () => managedBranchPath,
36
+ managedProjectRoot: () => managedProjectRoot,
37
+ migrateLegacyProjectRoots: () => migrateLegacyProjectRoots,
38
+ validateCheckoutPathSegments: () => validateCheckoutPathSegments,
39
+ validateManagedBranchName: () => validateManagedBranchName
40
+ });
41
+ module.exports = __toCommonJS(managed_paths_exports);
42
+ var import_node_fs = __toESM(require("node:fs"), 1);
43
+ var import_node_path = __toESM(require("node:path"), 1);
44
+ const BRANCH_NAME_MAX_LENGTH = 45;
45
+ const BRANCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
46
+ const PROJECT_SEGMENT_PATTERN = /^[a-z0-9._-]+$/;
47
+ const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
48
+ function validateManagedBranchName(branchName) {
49
+ if (typeof branchName !== "string" || branchName.length === 0) {
50
+ throw new Error("Branch name is required");
51
+ }
52
+ if (branchName.length > BRANCH_NAME_MAX_LENGTH) {
53
+ throw new Error(`Branch name must be ${BRANCH_NAME_MAX_LENGTH} characters or fewer`);
54
+ }
55
+ if (!BRANCH_NAME_PATTERN.test(branchName)) {
56
+ throw new Error(`Invalid branch name from server: ${branchName}`);
57
+ }
58
+ const reserved = branchName.split("/").find((segment) => WINDOWS_RESERVED_PATH_SEGMENT.test(segment));
59
+ if (reserved) {
60
+ throw new Error(`Invalid branch name from server: reserved path segment '${reserved}'`);
61
+ }
62
+ }
63
+ function validateCheckoutPathSegments(value) {
64
+ if (!Array.isArray(value) || value.length !== 2) {
65
+ throw new Error("Invalid checkout path from server: expected namespace and project segments");
66
+ }
67
+ const segments = value;
68
+ for (const segment of segments) {
69
+ if (typeof segment !== "string" || !PROJECT_SEGMENT_PATTERN.test(segment) || segment !== segment.toLowerCase() || segment === "." || segment === ".." || segment === ".git" || segment.endsWith(".") || WINDOWS_RESERVED_PATH_SEGMENT.test(segment)) {
70
+ throw new Error(`Invalid checkout path segment from server: ${String(segment)}`);
71
+ }
72
+ }
73
+ return [segments[0], segments[1]];
74
+ }
75
+ function assertPathInside(root, candidate, label) {
76
+ const resolvedRoot = import_node_path.default.resolve(root);
77
+ const resolvedCandidate = import_node_path.default.resolve(candidate);
78
+ const relative = import_node_path.default.relative(resolvedRoot, resolvedCandidate);
79
+ if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
80
+ throw new Error(`${label} escapes its managed root: ${candidate}`);
81
+ }
82
+ }
83
+ function assertNoSymlinkComponents(root, candidate) {
84
+ assertPathInside(root, candidate, "Checkout path");
85
+ const relative = import_node_path.default.relative(import_node_path.default.resolve(root), import_node_path.default.resolve(candidate));
86
+ let current = import_node_path.default.resolve(root);
87
+ for (const component of relative.split(import_node_path.default.sep).filter(Boolean)) {
88
+ current = import_node_path.default.join(current, component);
89
+ if (!import_node_fs.default.existsSync(current)) break;
90
+ if (import_node_fs.default.lstatSync(current).isSymbolicLink()) {
91
+ throw new Error(`Checkout path contains a symbolic link: ${current}`);
92
+ }
93
+ }
94
+ }
95
+ function managedProjectRoot(projectsRoot, checkoutPathSegments) {
96
+ const segments = validateCheckoutPathSegments(checkoutPathSegments);
97
+ const result = import_node_path.default.join(projectsRoot, ...segments);
98
+ assertNoSymlinkComponents(projectsRoot, result);
99
+ return result;
100
+ }
101
+ function managedBranchPath(projectsRoot, checkoutPathSegments, branchName) {
102
+ validateManagedBranchName(branchName);
103
+ const projectRoot = managedProjectRoot(projectsRoot, checkoutPathSegments);
104
+ const result = import_node_path.default.join(projectRoot, ...branchName.split("/"));
105
+ assertNoSymlinkComponents(projectRoot, result);
106
+ return result;
107
+ }
108
+ function legacyProjectSlug(projectPath, projectId) {
109
+ return (projectPath || projectId).trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || projectId;
110
+ }
111
+ function migrateLegacyProjectRoots(projectsRoot, projects) {
112
+ const resolvedRoot = import_node_path.default.resolve(projectsRoot);
113
+ const legacyOwners = /* @__PURE__ */ new Map();
114
+ const nestedOwners = /* @__PURE__ */ new Map();
115
+ const moves = [];
116
+ for (const project of projects) {
117
+ const nestedRoot = managedProjectRoot(resolvedRoot, project.checkoutPathSegments);
118
+ const legacySlug = legacyProjectSlug(project.projectPath, project.projectId);
119
+ const legacyRoot = import_node_path.default.join(resolvedRoot, legacySlug);
120
+ assertNoSymlinkComponents(resolvedRoot, legacyRoot);
121
+ const existingLegacyOwner = legacyOwners.get(legacyRoot);
122
+ if (existingLegacyOwner) {
123
+ throw new Error(`Projects ${existingLegacyOwner} and ${project.projectId} collide at legacy checkout path '${legacyRoot}'`);
124
+ }
125
+ legacyOwners.set(legacyRoot, project.projectId);
126
+ const existingNestedOwner = nestedOwners.get(nestedRoot);
127
+ if (existingNestedOwner) {
128
+ throw new Error(`Projects ${existingNestedOwner} and ${project.projectId} collide at checkout path '${nestedRoot}'`);
129
+ }
130
+ nestedOwners.set(nestedRoot, project.projectId);
131
+ const legacyExists = import_node_fs.default.existsSync(legacyRoot);
132
+ const nestedExists = import_node_fs.default.existsSync(nestedRoot);
133
+ if (legacyExists && nestedExists) {
134
+ throw new Error(
135
+ `Checkout migration is ambiguous for ${project.projectPath}: both '${legacyRoot}' and '${nestedRoot}' exist. Preserve the desired checkout and remove or relocate the other path before reconnecting the worker.`
136
+ );
137
+ }
138
+ if (legacyExists) moves.push({ projectId: project.projectId, legacyRoot, nestedRoot });
139
+ }
140
+ for (const move of moves) {
141
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(move.nestedRoot), { recursive: true });
142
+ assertNoSymlinkComponents(resolvedRoot, import_node_path.default.dirname(move.nestedRoot));
143
+ import_node_fs.default.renameSync(move.legacyRoot, move.nestedRoot);
144
+ }
145
+ return moves.map((move) => move.projectId);
146
+ }
147
+ // Annotate the CommonJS export names for ESM import in node:
148
+ 0 && (module.exports = {
149
+ BRANCH_NAME_MAX_LENGTH,
150
+ BRANCH_NAME_PATTERN,
151
+ assertPathInside,
152
+ legacyProjectSlug,
153
+ managedBranchPath,
154
+ managedProjectRoot,
155
+ migrateLegacyProjectRoots,
156
+ validateCheckoutPathSegments,
157
+ validateManagedBranchName
158
+ });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "type": "commonjs"
5
5
  }
@@ -45,6 +45,7 @@ module.exports = __toCommonJS(workspace_sync_exports);
45
45
  var import_node_crypto = require("node:crypto");
46
46
  var import_node_fs = __toESM(require("node:fs"), 1);
47
47
  var import_node_path = __toESM(require("node:path"), 1);
48
+ var import_managed_paths = require("./managed-paths.cjs");
48
49
  const WORKSPACE_BRANCH = "main";
49
50
  const MAX_WORKSPACE_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
50
51
  const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5e3;
@@ -92,7 +93,7 @@ function workspacePlansRelativePath(projectId, branchName) {
92
93
  return import_node_path.default.posix.join("plans", projectId, encodeWorkspaceBranch(branchName));
93
94
  }
94
95
  function visibleProjectBranchPath(projectsRoot, manifest, branchName) {
95
- return import_node_path.default.join(projectsRoot, manifest.repoSlug || manifest.projectId, branchName);
96
+ return (0, import_managed_paths.managedBranchPath)(projectsRoot, manifest.checkoutPathSegments, branchName);
96
97
  }
97
98
  function localPlansBranchPath(plansRoot, projectId, branchName) {
98
99
  return import_node_path.default.join(plansRoot, projectId, branchName);
package/dist/mjs/main.mjs CHANGED
@@ -9,6 +9,7 @@ import { configureVisibleGitIdentity } from "./git-identity.mjs";
9
9
  import { hasWorkerHeartbeatTimedOut, WORKER_HEARTBEAT_INTERVAL_MS } from "./heartbeat.mjs";
10
10
  import { terminateProcessTree } from "./process-tree.mjs";
11
11
  import { superviseWorkerRuntime, WORKER_RELOAD_EXIT_CODE, WORKER_RUNTIME_ENV } from "./supervisor.mjs";
12
+ import { managedProjectRoot, migrateLegacyProjectRoots, validateManagedBranchName } from "./managed-paths.mjs";
12
13
  import {
13
14
  WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
14
15
  WorkspaceSyncSingleFlight,
@@ -18,7 +19,6 @@ import {
18
19
  const DEFAULT_BASE_URL = "https://r5d.dev";
19
20
  const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
20
21
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
21
- const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
22
22
  const DEFAULT_READ_LIMIT = 2e3;
23
23
  const DEFAULT_READ_MAX_BYTES = 5e4;
24
24
  const MAX_LINE_LENGTH = 2e3;
@@ -561,9 +561,7 @@ function validateProjectId(projectId) {
561
561
  }
562
562
  }
563
563
  function validateBranchName(branchName) {
564
- if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
565
- throw new Error(`Invalid branch name from server: ${branchName}`);
566
- }
564
+ validateManagedBranchName(branchName);
567
565
  }
568
566
  function validatePlanId(planId) {
569
567
  if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
@@ -2045,10 +2043,13 @@ function websocketUrl(baseUrl, label) {
2045
2043
  const url = new URL("/worker/ws", baseUrl);
2046
2044
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
2047
2045
  url.searchParams.set("label", label);
2046
+ url.searchParams.set("version", getWorkerVersion());
2048
2047
  return url.toString();
2049
2048
  }
2050
2049
  function projectRootFor(projectsRoot, projectId, manifestByProjectId) {
2051
- return path.join(projectsRoot, manifestByProjectId.get(projectId)?.repoSlug ?? projectId);
2050
+ const manifest = manifestByProjectId.get(projectId);
2051
+ if (!manifest) throw new Error(`Project ${projectId} is missing from the worker workspace manifest`);
2052
+ return managedProjectRoot(projectsRoot, manifest.checkoutPathSegments);
2052
2053
  }
2053
2054
  async function startWorker(options) {
2054
2055
  const config = readConfig(options.configPath);
@@ -2242,10 +2243,13 @@ async function startWorker(options) {
2242
2243
  configureGitHubAuth(message.githubCredential);
2243
2244
  visibleGitIdentity = message.gitIdentity;
2244
2245
  workspaceRemoteUrl = message.workspaceRemoteUrl;
2246
+ const migratedProjectIds = migrateLegacyProjectRoots(projectsRoot, message.projects);
2245
2247
  manifestByProjectId.clear();
2246
2248
  for (const project of message.projects) manifestByProjectId.set(project.projectId, project);
2247
- process.stdout.write(`[r5d-worker] workspace manifest: ${message.projects.length} projects
2248
- `);
2249
+ process.stdout.write(
2250
+ `[r5d-worker] workspace manifest: ${message.projects.length} projects${migratedProjectIds.length > 0 ? `; migrated ${migratedProjectIds.length} project checkout root(s)` : ""}
2251
+ `
2252
+ );
2249
2253
  if (!periodicWorkspaceScan) {
2250
2254
  const emptyFingerprint = createHash("sha256").update("").digest("hex");
2251
2255
  periodicWorkspaceScan = setInterval(() => {
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const BRANCH_NAME_MAX_LENGTH = 45;
4
+ const BRANCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
5
+ const PROJECT_SEGMENT_PATTERN = /^[a-z0-9._-]+$/;
6
+ const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
7
+ function validateManagedBranchName(branchName) {
8
+ if (typeof branchName !== "string" || branchName.length === 0) {
9
+ throw new Error("Branch name is required");
10
+ }
11
+ if (branchName.length > BRANCH_NAME_MAX_LENGTH) {
12
+ throw new Error(`Branch name must be ${BRANCH_NAME_MAX_LENGTH} characters or fewer`);
13
+ }
14
+ if (!BRANCH_NAME_PATTERN.test(branchName)) {
15
+ throw new Error(`Invalid branch name from server: ${branchName}`);
16
+ }
17
+ const reserved = branchName.split("/").find((segment) => WINDOWS_RESERVED_PATH_SEGMENT.test(segment));
18
+ if (reserved) {
19
+ throw new Error(`Invalid branch name from server: reserved path segment '${reserved}'`);
20
+ }
21
+ }
22
+ function validateCheckoutPathSegments(value) {
23
+ if (!Array.isArray(value) || value.length !== 2) {
24
+ throw new Error("Invalid checkout path from server: expected namespace and project segments");
25
+ }
26
+ const segments = value;
27
+ for (const segment of segments) {
28
+ if (typeof segment !== "string" || !PROJECT_SEGMENT_PATTERN.test(segment) || segment !== segment.toLowerCase() || segment === "." || segment === ".." || segment === ".git" || segment.endsWith(".") || WINDOWS_RESERVED_PATH_SEGMENT.test(segment)) {
29
+ throw new Error(`Invalid checkout path segment from server: ${String(segment)}`);
30
+ }
31
+ }
32
+ return [segments[0], segments[1]];
33
+ }
34
+ function assertPathInside(root, candidate, label) {
35
+ const resolvedRoot = path.resolve(root);
36
+ const resolvedCandidate = path.resolve(candidate);
37
+ const relative = path.relative(resolvedRoot, resolvedCandidate);
38
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
39
+ throw new Error(`${label} escapes its managed root: ${candidate}`);
40
+ }
41
+ }
42
+ function assertNoSymlinkComponents(root, candidate) {
43
+ assertPathInside(root, candidate, "Checkout path");
44
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
45
+ let current = path.resolve(root);
46
+ for (const component of relative.split(path.sep).filter(Boolean)) {
47
+ current = path.join(current, component);
48
+ if (!fs.existsSync(current)) break;
49
+ if (fs.lstatSync(current).isSymbolicLink()) {
50
+ throw new Error(`Checkout path contains a symbolic link: ${current}`);
51
+ }
52
+ }
53
+ }
54
+ function managedProjectRoot(projectsRoot, checkoutPathSegments) {
55
+ const segments = validateCheckoutPathSegments(checkoutPathSegments);
56
+ const result = path.join(projectsRoot, ...segments);
57
+ assertNoSymlinkComponents(projectsRoot, result);
58
+ return result;
59
+ }
60
+ function managedBranchPath(projectsRoot, checkoutPathSegments, branchName) {
61
+ validateManagedBranchName(branchName);
62
+ const projectRoot = managedProjectRoot(projectsRoot, checkoutPathSegments);
63
+ const result = path.join(projectRoot, ...branchName.split("/"));
64
+ assertNoSymlinkComponents(projectRoot, result);
65
+ return result;
66
+ }
67
+ function legacyProjectSlug(projectPath, projectId) {
68
+ return (projectPath || projectId).trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || projectId;
69
+ }
70
+ function migrateLegacyProjectRoots(projectsRoot, projects) {
71
+ const resolvedRoot = path.resolve(projectsRoot);
72
+ const legacyOwners = /* @__PURE__ */ new Map();
73
+ const nestedOwners = /* @__PURE__ */ new Map();
74
+ const moves = [];
75
+ for (const project of projects) {
76
+ const nestedRoot = managedProjectRoot(resolvedRoot, project.checkoutPathSegments);
77
+ const legacySlug = legacyProjectSlug(project.projectPath, project.projectId);
78
+ const legacyRoot = path.join(resolvedRoot, legacySlug);
79
+ assertNoSymlinkComponents(resolvedRoot, legacyRoot);
80
+ const existingLegacyOwner = legacyOwners.get(legacyRoot);
81
+ if (existingLegacyOwner) {
82
+ throw new Error(`Projects ${existingLegacyOwner} and ${project.projectId} collide at legacy checkout path '${legacyRoot}'`);
83
+ }
84
+ legacyOwners.set(legacyRoot, project.projectId);
85
+ const existingNestedOwner = nestedOwners.get(nestedRoot);
86
+ if (existingNestedOwner) {
87
+ throw new Error(`Projects ${existingNestedOwner} and ${project.projectId} collide at checkout path '${nestedRoot}'`);
88
+ }
89
+ nestedOwners.set(nestedRoot, project.projectId);
90
+ const legacyExists = fs.existsSync(legacyRoot);
91
+ const nestedExists = fs.existsSync(nestedRoot);
92
+ if (legacyExists && nestedExists) {
93
+ throw new Error(
94
+ `Checkout migration is ambiguous for ${project.projectPath}: both '${legacyRoot}' and '${nestedRoot}' exist. Preserve the desired checkout and remove or relocate the other path before reconnecting the worker.`
95
+ );
96
+ }
97
+ if (legacyExists) moves.push({ projectId: project.projectId, legacyRoot, nestedRoot });
98
+ }
99
+ for (const move of moves) {
100
+ fs.mkdirSync(path.dirname(move.nestedRoot), { recursive: true });
101
+ assertNoSymlinkComponents(resolvedRoot, path.dirname(move.nestedRoot));
102
+ fs.renameSync(move.legacyRoot, move.nestedRoot);
103
+ }
104
+ return moves.map((move) => move.projectId);
105
+ }
106
+ export {
107
+ BRANCH_NAME_MAX_LENGTH,
108
+ BRANCH_NAME_PATTERN,
109
+ assertPathInside,
110
+ legacyProjectSlug,
111
+ managedBranchPath,
112
+ managedProjectRoot,
113
+ migrateLegacyProjectRoots,
114
+ validateCheckoutPathSegments,
115
+ validateManagedBranchName
116
+ };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "type": "module"
5
5
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { managedBranchPath } from "./managed-paths.mjs";
4
5
  const WORKSPACE_BRANCH = "main";
5
6
  const MAX_WORKSPACE_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
6
7
  const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5e3;
@@ -48,7 +49,7 @@ function workspacePlansRelativePath(projectId, branchName) {
48
49
  return path.posix.join("plans", projectId, encodeWorkspaceBranch(branchName));
49
50
  }
50
51
  function visibleProjectBranchPath(projectsRoot, manifest, branchName) {
51
- return path.join(projectsRoot, manifest.repoSlug || manifest.projectId, branchName);
52
+ return managedBranchPath(projectsRoot, manifest.checkoutPathSegments, branchName);
52
53
  }
53
54
  function localPlansBranchPath(plansRoot, projectId, branchName) {
54
55
  return path.join(plansRoot, projectId, branchName);
@@ -0,0 +1,15 @@
1
+ export declare const BRANCH_NAME_MAX_LENGTH = 45;
2
+ export declare const BRANCH_NAME_PATTERN: RegExp;
3
+ export type CheckoutPathSegments = [namespace: string, project: string];
4
+ export type ManagedProjectManifestPath = {
5
+ projectId: string;
6
+ projectPath: string;
7
+ checkoutPathSegments: CheckoutPathSegments;
8
+ };
9
+ export declare function validateManagedBranchName(branchName: string): void;
10
+ export declare function validateCheckoutPathSegments(value: unknown): CheckoutPathSegments;
11
+ export declare function assertPathInside(root: string, candidate: string, label: string): void;
12
+ export declare function managedProjectRoot(projectsRoot: string, checkoutPathSegments: unknown): string;
13
+ export declare function managedBranchPath(projectsRoot: string, checkoutPathSegments: unknown, branchName: string): string;
14
+ export declare function legacyProjectSlug(projectPath: string, projectId: string): string;
15
+ export declare function migrateLegacyProjectRoots(projectsRoot: string, projects: ManagedProjectManifestPath[]): string[];
@@ -1,9 +1,10 @@
1
+ import { type CheckoutPathSegments } from "./managed-paths";
1
2
  export declare const WORKSPACE_BRANCH = "main";
2
3
  export declare const MAX_WORKSPACE_SYNC_DIFF_BYTES: number;
3
4
  export declare const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5000;
4
5
  export type WorkspaceProjectManifestEntry = {
5
6
  projectId: string;
6
- repoSlug: string;
7
+ checkoutPathSegments: CheckoutPathSegments;
7
8
  projectPath: string;
8
9
  repoHttpUrl: string | null;
9
10
  repoAuthHeader: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",