@ricsam/r5d-worker 0.0.2 → 0.0.4

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/cjs/main.cjs CHANGED
@@ -27,7 +27,7 @@ var import_node_os = __toESM(require("node:os"), 1);
27
27
  var import_node_path = __toESM(require("node:path"), 1);
28
28
  var import_node_crypto = require("node:crypto");
29
29
  var import_node_os2 = require("node:os");
30
- var nodePty = __toESM(require("node-pty"), 1);
30
+ var import_node_child_process = require("node:child_process");
31
31
  const DEFAULT_BASE_URL = "https://r5d.dev";
32
32
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
33
33
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
@@ -37,23 +37,28 @@ const MAX_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
37
37
  const activeProcesses = /* @__PURE__ */ new Map();
38
38
  const activePtys = /* @__PURE__ */ new Map();
39
39
  const branchLocks = /* @__PURE__ */ new Map();
40
+ let containerRegistryCredential = null;
40
41
  function defaultConfigPath() {
41
42
  return import_node_path.default.join(import_node_os.default.homedir(), ".config", "r5d", "r5dctl", "config.json");
42
43
  }
43
- function defaultProjectRoot(projectId) {
44
- return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "projects", projectId);
44
+ function defaultProjectsRoot() {
45
+ return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "projects");
46
+ }
47
+ function defaultSyncRoot() {
48
+ return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "sync");
45
49
  }
46
50
  function printHelp() {
47
51
  process.stdout.write(`Usage:
48
- r5d-worker start <project-id> --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
52
+ r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
49
53
 
50
54
  Options:
51
- --label <label> Required unique worker label for this project, e.g. macos or ec2-build
55
+ --label <label> Required unique worker label for this user, e.g. macos or ec2-build
52
56
  --base-url <url> r5d.dev base URL (default from r5d config, R5D_BASE_URL, or ${DEFAULT_BASE_URL})
53
57
  --token <token> r5d worker token
54
58
  --api-key <key> r5d API key
55
59
  --config <path> Config path (default ~/.config/r5d/r5dctl/config.json)
56
- --project-root <dir> Override checkout root (default ~/.r5d/projects/<project-id>)
60
+ --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
61
+ --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
57
62
  -h, --help Show this help
58
63
  `);
59
64
  }
@@ -116,6 +121,11 @@ function parseArgs(argv) {
116
121
  options.projectRoot = projectRoot;
117
122
  continue;
118
123
  }
124
+ const syncRoot = readOption("--sync-root");
125
+ if (syncRoot !== void 0) {
126
+ options.syncRoot = syncRoot;
127
+ continue;
128
+ }
119
129
  rest.push(arg);
120
130
  }
121
131
  if (options.help || rest.length === 0) {
@@ -127,7 +137,6 @@ function parseArgs(argv) {
127
137
  }
128
138
  return {
129
139
  command: "start",
130
- projectId: requireValue(rest[1], "Missing project id"),
131
140
  options
132
141
  };
133
142
  }
@@ -205,17 +214,14 @@ function validateBranchName(branchName) {
205
214
  throw new Error(`Invalid branch name from server: ${branchName}`);
206
215
  }
207
216
  }
208
- function gitRemoteUrl(baseUrl, projectId) {
209
- return `${baseUrl}/git/${projectId}.git`;
210
- }
211
- function gitExtraHeaderConfigKey(baseUrl) {
212
- return `http.${normalizeBaseUrl(baseUrl)}/.extraHeader`;
217
+ function gitExtraHeaderConfigKey(extraHeaderUrl) {
218
+ return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
213
219
  }
214
220
  function gitAuthArgs(auth) {
215
221
  if (!auth) {
216
222
  return [];
217
223
  }
218
- return ["-c", `${gitExtraHeaderConfigKey(auth.baseUrl)}=Authorization: Bearer ${auth.token}`];
224
+ return ["-c", `${gitExtraHeaderConfigKey(auth.extraHeaderUrl)}=${auth.header}`];
219
225
  }
220
226
  function runGit(args, options = {}) {
221
227
  const command = ["git", ...gitAuthArgs(options.auth), ...args];
@@ -243,8 +249,22 @@ function tryGit(args, options = {}) {
243
249
  return false;
244
250
  }
245
251
  }
246
- function getCurrentCommitHash(cwd) {
247
- return runGit(["rev-parse", "HEAD"], { cwd });
252
+ function runInternalGit(context, args) {
253
+ return runGit(["--git-dir", context.gitDir, "--work-tree", context.workTree, ...args], { auth: context.auth });
254
+ }
255
+ function runInternalGitDir(context, args) {
256
+ return runGit(["--git-dir", context.gitDir, ...args], { auth: context.auth });
257
+ }
258
+ function tryInternalGit(context, args) {
259
+ try {
260
+ runInternalGit(context, args);
261
+ return true;
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+ function getInternalCommitHash(context) {
267
+ return runInternalGit(context, ["rev-parse", "HEAD"]);
248
268
  }
249
269
  function getGitBlobHashForContent(content) {
250
270
  const buffer = typeof content === "string" ? Buffer.from(content, "utf8") : content;
@@ -253,6 +273,12 @@ function getGitBlobHashForContent(content) {
253
273
  function hasWorktreeChanges(cwd) {
254
274
  return runGit(["status", "--porcelain"], { cwd }).trim().length > 0;
255
275
  }
276
+ function getInternalStatus(context) {
277
+ return runInternalGit(context, ["status", "--porcelain", "--", ".", ":(exclude).git"]);
278
+ }
279
+ function hasInternalWorktreeChanges(context) {
280
+ return getInternalStatus(context).trim().length > 0;
281
+ }
256
282
  function assertInsideBranch(branchPath, filePath) {
257
283
  const relative = import_node_path.default.relative(branchPath, filePath);
258
284
  if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
@@ -275,11 +301,11 @@ function resolveProjectFilePath(branchPath, virtualPath) {
275
301
  assertInsideBranch(branchPath, absolutePath);
276
302
  return { absolutePath, virtualPath: `/${repoRelativePath}`, repoRelativePath };
277
303
  }
278
- function branchLockKey(projectRoot, branchName) {
279
- return `${projectRoot}:${branchName}`;
304
+ function branchLockKey(projectId, branchName) {
305
+ return `${projectId}:${branchName}`;
280
306
  }
281
- async function withBranchLock(projectRoot, branchName, fn) {
282
- const key = branchLockKey(projectRoot, branchName);
307
+ async function withBranchLock(projectId, branchName, fn) {
308
+ const key = branchLockKey(projectId, branchName);
283
309
  const previous = branchLocks.get(key) ?? Promise.resolve();
284
310
  let release = () => {
285
311
  };
@@ -300,60 +326,283 @@ async function withBranchLock(projectRoot, branchName, fn) {
300
326
  }
301
327
  }
302
328
  }
303
- function configureRepo(pathName, auth) {
304
- const authHeaderKey = gitExtraHeaderConfigKey(auth.baseUrl);
305
- tryGit(["config", "--unset-all", "http.extraHeader"], { cwd: pathName });
306
- tryGit(["config", "--unset-all", authHeaderKey], { cwd: pathName });
307
- runGit(["config", "--add", authHeaderKey, `Authorization: Bearer ${auth.token}`], { cwd: pathName });
308
- runGit(["config", "user.name", "r5d.dev Worker"], { cwd: pathName });
309
- runGit(["config", "user.email", "worker@r5d.dev"], { cwd: pathName });
329
+ function resolveRemoteUrl(baseUrl, remoteUrl) {
330
+ if (/^https?:\/\//i.test(remoteUrl)) {
331
+ return remoteUrl;
332
+ }
333
+ return new URL(remoteUrl, baseUrl).toString();
334
+ }
335
+ function fallbackInternalRemoteUrl(baseUrl, projectId) {
336
+ return new URL(`/git/${projectId}.git`, baseUrl).toString();
310
337
  }
311
- function ensureMainClone(input) {
312
- const mainPath = import_node_path.default.join(input.projectRoot, "main");
313
- const remoteUrl = gitRemoteUrl(input.baseUrl, input.projectId);
314
- import_node_fs.default.mkdirSync(input.projectRoot, { recursive: true });
315
- if (!import_node_fs.default.existsSync(import_node_path.default.join(mainPath, ".git"))) {
316
- import_node_fs.default.rmSync(mainPath, { recursive: true, force: true });
317
- runGit(["clone", "--origin", "build-it-now", remoteUrl, mainPath], { auth: input });
338
+ function internalGitAuth(baseUrl, token) {
339
+ return {
340
+ extraHeaderUrl: baseUrl,
341
+ header: `Authorization: Bearer ${token}`
342
+ };
343
+ }
344
+ function internalRemoteUrlFor(baseUrl, projectId, manifest) {
345
+ return resolveRemoteUrl(baseUrl, manifest?.internalRemoteUrl ?? fallbackInternalRemoteUrl(baseUrl, projectId));
346
+ }
347
+ function githubRemoteUrlFor(baseUrl, projectId, manifest) {
348
+ return manifest?.repoHttpUrl ?? internalRemoteUrlFor(baseUrl, projectId, manifest);
349
+ }
350
+ function gitExtraHeaderUrlForRemote(remoteUrl) {
351
+ try {
352
+ return new URL(remoteUrl).origin;
353
+ } catch {
354
+ return remoteUrl;
318
355
  }
319
- configureRepo(mainPath, input);
320
- runGit(["remote", "set-url", "build-it-now", remoteUrl], { cwd: mainPath });
321
- runGit(["fetch", "build-it-now", "--prune"], { cwd: mainPath });
322
- runGit(["checkout", "main"], { cwd: mainPath });
323
- if (!hasWorktreeChanges(mainPath)) {
324
- tryGit(["pull", "--ff-only", "build-it-now", "main"], { cwd: mainPath });
356
+ }
357
+ function gitAuthForRemote(remoteUrl, authHeader) {
358
+ if (!authHeader) {
359
+ return void 0;
325
360
  }
326
- return mainPath;
361
+ return {
362
+ extraHeaderUrl: gitExtraHeaderUrlForRemote(remoteUrl),
363
+ header: authHeader
364
+ };
327
365
  }
328
- function ensureBranchWorkspace(input) {
329
- validateBranchName(input.branchName);
330
- const mainPath = ensureMainClone(input);
331
- if (input.branchName === "main") {
332
- return mainPath;
366
+ function branchGitDirName(branchName) {
367
+ return `${encodeURIComponent(branchName)}.git`;
368
+ }
369
+ function internalGitDirFor(syncRoot, projectId, branchName) {
370
+ return import_node_path.default.join(syncRoot, projectId, branchGitDirName(branchName));
371
+ }
372
+ function hasNormalVisibleGitDir(branchPath) {
373
+ const gitPath = import_node_path.default.join(branchPath, ".git");
374
+ return import_node_fs.default.existsSync(gitPath) && import_node_fs.default.statSync(gitPath).isDirectory();
375
+ }
376
+ function ensureOriginRemote(branchPath, remoteUrl) {
377
+ if (tryGit(["remote", "get-url", "origin"], { cwd: branchPath })) {
378
+ runGit(["remote", "set-url", "origin", remoteUrl], { cwd: branchPath });
379
+ } else {
380
+ runGit(["remote", "add", "origin", remoteUrl], { cwd: branchPath });
333
381
  }
334
- const branchPath = import_node_path.default.join(input.projectRoot, input.branchName);
335
- runGit(["fetch", "build-it-now", "--prune"], { cwd: mainPath });
336
- const hasRemoteBranch = tryGit(["show-ref", "--verify", "--quiet", `refs/remotes/build-it-now/${input.branchName}`], {
337
- cwd: mainPath
382
+ }
383
+ function shellQuote(value) {
384
+ return `'${value.replace(/'/g, "'\\''")}'`;
385
+ }
386
+ function githubCredentialsPath() {
387
+ return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "github-credentials");
388
+ }
389
+ function decodeBasicAuthHeader(authHeader) {
390
+ const match = /^Authorization:\s*Basic\s+(.+)$/i.exec(authHeader.trim());
391
+ if (!match) {
392
+ return null;
393
+ }
394
+ const decoded = Buffer.from(match[1], "base64").toString("utf8");
395
+ const separatorIndex = decoded.indexOf(":");
396
+ if (separatorIndex <= 0) {
397
+ return null;
398
+ }
399
+ return {
400
+ username: decoded.slice(0, separatorIndex),
401
+ password: decoded.slice(separatorIndex + 1)
402
+ };
403
+ }
404
+ function writeCredentialStoreEntry(remoteUrl, authHeader) {
405
+ const credentials = decodeBasicAuthHeader(authHeader);
406
+ if (!credentials) {
407
+ return null;
408
+ }
409
+ let remote;
410
+ try {
411
+ remote = new URL(remoteUrl);
412
+ } catch {
413
+ return null;
414
+ }
415
+ if (remote.protocol !== "https:") {
416
+ return null;
417
+ }
418
+ const storePath = githubCredentialsPath();
419
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(storePath), { recursive: true });
420
+ const hostKey = `${remote.protocol}//${remote.host}`;
421
+ const existing = import_node_fs.default.existsSync(storePath) ? import_node_fs.default.readFileSync(storePath, "utf8").split(/\r?\n/).filter(Boolean) : [];
422
+ const next = existing.filter((line) => {
423
+ try {
424
+ const parsed = new URL(line);
425
+ return `${parsed.protocol}//${parsed.host}` !== hostKey;
426
+ } catch {
427
+ return true;
428
+ }
338
429
  });
339
- if (!import_node_fs.default.existsSync(import_node_path.default.join(branchPath, ".git"))) {
430
+ const credentialUrl = new URL(hostKey);
431
+ credentialUrl.username = credentials.username;
432
+ credentialUrl.password = credentials.password;
433
+ next.push(credentialUrl.toString());
434
+ import_node_fs.default.writeFileSync(storePath, `${next.join("\n")}
435
+ `, { mode: 384 });
436
+ import_node_fs.default.chmodSync(storePath, 384);
437
+ return storePath;
438
+ }
439
+ function configureVisibleGitHubAuth(branchPath, remoteUrl, authHeader) {
440
+ if (!authHeader) {
441
+ return;
442
+ }
443
+ const credentialStore = writeCredentialStoreEntry(remoteUrl, authHeader);
444
+ if (credentialStore) {
445
+ runGit(["config", "credential.helper", `store --file=${shellQuote(credentialStore)}`], { cwd: branchPath });
446
+ runGit(["config", "credential.useHttpPath", "false"], { cwd: branchPath });
447
+ return;
448
+ }
449
+ runGit(["config", `${gitExtraHeaderConfigKey(gitExtraHeaderUrlForRemote(remoteUrl))}`, authHeader], { cwd: branchPath });
450
+ }
451
+ function dockerConfigPath() {
452
+ return import_node_path.default.join(import_node_os.default.homedir(), ".docker", "config.json");
453
+ }
454
+ function containersAuthPath() {
455
+ return import_node_path.default.join(import_node_os.default.homedir(), ".config", "containers", "auth.json");
456
+ }
457
+ function readJsonFile(filePath) {
458
+ if (!import_node_fs.default.existsSync(filePath)) {
459
+ return {};
460
+ }
461
+ try {
462
+ const parsed = JSON.parse(import_node_fs.default.readFileSync(filePath, "utf8"));
463
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
464
+ } catch {
465
+ return {};
466
+ }
467
+ }
468
+ function writeRegistryAuthFile(filePath, credential) {
469
+ const config = readJsonFile(filePath);
470
+ const existingAuths = config.auths && typeof config.auths === "object" && !Array.isArray(config.auths) ? config.auths : {};
471
+ const auths = existingAuths;
472
+ auths[credential.registry] = {
473
+ username: credential.username,
474
+ password: credential.password,
475
+ auth: Buffer.from(`${credential.username}:${credential.password}`).toString("base64")
476
+ };
477
+ config.auths = auths;
478
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(filePath), { recursive: true });
479
+ import_node_fs.default.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}
480
+ `, { mode: 384 });
481
+ import_node_fs.default.chmodSync(filePath, 384);
482
+ }
483
+ function configureContainerRegistryAuth(credential) {
484
+ containerRegistryCredential = credential;
485
+ if (!credential) {
486
+ return;
487
+ }
488
+ writeRegistryAuthFile(dockerConfigPath(), credential);
489
+ writeRegistryAuthFile(containersAuthPath(), credential);
490
+ if (credential.missingScopes.length > 0) {
491
+ process.stderr.write(
492
+ `[r5d-worker] GitHub token is missing GHCR scope(s): ${credential.missingScopes.join(", ")}. Sign in with GitHub again if GHCR push/pull fails.
493
+ `
494
+ );
495
+ } else {
496
+ process.stdout.write(`[r5d-worker] configured GHCR auth for ${credential.username}
497
+ `);
498
+ }
499
+ }
500
+ function containerRegistryEnv() {
501
+ if (!containerRegistryCredential) {
502
+ return {};
503
+ }
504
+ return {
505
+ R5D_GHCR_REGISTRY: containerRegistryCredential.registry,
506
+ R5D_GHCR_NAMESPACE: containerRegistryCredential.username,
507
+ R5D_GHCR_AUTH_FILE: containersAuthPath(),
508
+ REGISTRY_AUTH_FILE: containersAuthPath(),
509
+ GHCR_REGISTRY: containerRegistryCredential.registry,
510
+ GHCR_NAMESPACE: containerRegistryCredential.username
511
+ };
512
+ }
513
+ function checkoutVisibleBranch(input) {
514
+ const branchExists = tryGit(["show-ref", "--verify", "--quiet", `refs/heads/${input.branchName}`], { cwd: input.branchPath });
515
+ const remoteBranchExists = tryGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${input.branchName}`], {
516
+ cwd: input.branchPath
517
+ });
518
+ const defaultRemoteExists = tryGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${input.defaultBranch}`], {
519
+ cwd: input.branchPath
520
+ });
521
+ const clean = !hasWorktreeChanges(input.branchPath);
522
+ if (remoteBranchExists && clean) {
523
+ runGit(["checkout", "-B", input.branchName, `origin/${input.branchName}`], { cwd: input.branchPath });
524
+ return;
525
+ }
526
+ if (branchExists) {
527
+ runGit(["checkout", input.branchName], { cwd: input.branchPath });
528
+ return;
529
+ }
530
+ if (defaultRemoteExists) {
531
+ runGit(["checkout", "-B", input.branchName, `origin/${input.defaultBranch}`], { cwd: input.branchPath });
532
+ return;
533
+ }
534
+ runGit(["checkout", "-B", input.branchName], { cwd: input.branchPath });
535
+ }
536
+ function ensureVisibleGitCheckout(input) {
537
+ validateBranchName(input.branchName);
538
+ import_node_fs.default.mkdirSync(input.projectRoot, { recursive: true });
539
+ const branchPath = import_node_path.default.join(input.projectRoot, input.branchName);
540
+ if (!hasNormalVisibleGitDir(branchPath)) {
340
541
  import_node_fs.default.rmSync(branchPath, { recursive: true, force: true });
341
- if (hasRemoteBranch) {
342
- runGit(["worktree", "add", "-B", input.branchName, branchPath, `build-it-now/${input.branchName}`], {
343
- cwd: mainPath
344
- });
345
- } else {
346
- runGit(["worktree", "add", "-b", input.branchName, branchPath, "build-it-now/main"], {
347
- cwd: mainPath
348
- });
542
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(branchPath), { recursive: true });
543
+ if (!tryGit(["clone", "--origin", "origin", input.githubRemoteUrl, branchPath], { auth: input.githubAuth })) {
544
+ import_node_fs.default.mkdirSync(branchPath, { recursive: true });
545
+ runGit(["init"], { cwd: branchPath });
349
546
  }
350
547
  }
351
- configureRepo(branchPath, input);
352
- if (hasRemoteBranch && !hasWorktreeChanges(branchPath)) {
353
- tryGit(["pull", "--ff-only", "build-it-now", input.branchName], { cwd: branchPath });
354
- }
548
+ ensureOriginRemote(branchPath, input.githubRemoteUrl);
549
+ configureVisibleGitHubAuth(branchPath, input.githubRemoteUrl, input.githubAuthHeader);
550
+ tryGit(["fetch", "origin", "--prune"], { cwd: branchPath, auth: input.githubAuth });
551
+ checkoutVisibleBranch({ branchPath, branchName: input.branchName, defaultBranch: input.defaultBranch });
355
552
  return branchPath;
356
553
  }
554
+ function configureInternalRepo(context, remoteUrl) {
555
+ if (tryInternalGit(context, ["remote", "get-url", "build-it-now"])) {
556
+ runInternalGit(context, ["remote", "set-url", "build-it-now", remoteUrl]);
557
+ } else {
558
+ runInternalGit(context, ["remote", "add", "build-it-now", remoteUrl]);
559
+ }
560
+ runInternalGitDir(context, ["config", "user.name", "r5d.dev Worker"]);
561
+ runInternalGitDir(context, ["config", "user.email", "worker@r5d.dev"]);
562
+ }
563
+ function ensureInternalSyncGit(input) {
564
+ validateBranchName(input.branchName);
565
+ const gitDir = internalGitDirFor(input.syncRoot, input.projectId, input.branchName);
566
+ import_node_fs.default.mkdirSync(import_node_path.default.dirname(gitDir), { recursive: true });
567
+ if (!import_node_fs.default.existsSync(gitDir)) {
568
+ runGit(["init", "--bare", gitDir]);
569
+ }
570
+ const auth = internalGitAuth(input.baseUrl, input.token);
571
+ const context = { gitDir, workTree: input.branchPath, auth };
572
+ const remoteUrl = internalRemoteUrlFor(input.baseUrl, input.projectId, input.manifest);
573
+ configureInternalRepo(context, remoteUrl);
574
+ runInternalGit(context, ["fetch", "build-it-now", "--prune", "+refs/heads/*:refs/remotes/build-it-now/*"]);
575
+ const hasRemoteBranch = tryInternalGit(context, ["show-ref", "--verify", "--quiet", `refs/remotes/build-it-now/${input.branchName}`]);
576
+ const target = hasRemoteBranch ? `refs/remotes/build-it-now/${input.branchName}` : "refs/remotes/build-it-now/main";
577
+ const hasHead = tryInternalGit(context, ["rev-parse", "--verify", "HEAD"]);
578
+ if (!hasHead || !hasInternalWorktreeChanges(context)) {
579
+ runInternalGit(context, ["checkout", "-f", "-B", input.branchName, target]);
580
+ }
581
+ return context;
582
+ }
583
+ function ensureBranchWorkspace(input) {
584
+ const defaultBranch = input.manifest?.defaultBranch || "main";
585
+ const githubRemoteUrl = githubRemoteUrlFor(input.baseUrl, input.projectId, input.manifest);
586
+ const githubAuth = gitAuthForRemote(githubRemoteUrl, input.manifest?.repoAuthHeader);
587
+ const branchPath = ensureVisibleGitCheckout({
588
+ projectRoot: input.projectRoot,
589
+ branchName: input.branchName,
590
+ githubRemoteUrl,
591
+ githubAuth,
592
+ githubAuthHeader: input.manifest?.repoAuthHeader ?? null,
593
+ defaultBranch
594
+ });
595
+ const internalGit = ensureInternalSyncGit({
596
+ projectId: input.projectId,
597
+ baseUrl: input.baseUrl,
598
+ token: input.token,
599
+ syncRoot: input.syncRoot,
600
+ branchPath,
601
+ branchName: input.branchName,
602
+ manifest: input.manifest
603
+ });
604
+ return { branchPath, internalGit };
605
+ }
357
606
  function resolveCommandCwd(branchPath, cwd) {
358
607
  if (!cwd) {
359
608
  return branchPath;
@@ -462,8 +711,8 @@ function readImageDimensions(bytes, mediaType) {
462
711
  return dimensions;
463
712
  }
464
713
  async function executeReadFileOperation(input) {
465
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
466
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
714
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
715
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
467
716
  if (!import_node_fs.default.existsSync(resolved.absolutePath) || !import_node_fs.default.statSync(resolved.absolutePath).isFile()) {
468
717
  throw new Error(`File not found: ${resolved.virtualPath}`);
469
718
  }
@@ -477,8 +726,8 @@ async function executeReadFileOperation(input) {
477
726
  };
478
727
  }
479
728
  async function executeCreateFileOperation(input) {
480
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
481
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
729
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
730
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
482
731
  if (import_node_fs.default.existsSync(resolved.absolutePath)) {
483
732
  throw new Error(`File "${resolved.virtualPath}" already exists. Use edit_file to modify existing files.`);
484
733
  }
@@ -491,8 +740,8 @@ async function executeCreateFileOperation(input) {
491
740
  };
492
741
  }
493
742
  async function executeEditFileOperation(input) {
494
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
495
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
743
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
744
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
496
745
  if (input.message.oldString === input.message.newString) {
497
746
  throw new Error("old_string and new_string must be different");
498
747
  }
@@ -521,8 +770,8 @@ async function executeEditFileOperation(input) {
521
770
  };
522
771
  }
523
772
  async function executeViewFileBytesOperation(input) {
524
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
525
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
773
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
774
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
526
775
  if (!import_node_fs.default.existsSync(resolved.absolutePath) || !import_node_fs.default.statSync(resolved.absolutePath).isFile()) {
527
776
  throw new Error(`File not found: ${resolved.virtualPath}`);
528
777
  }
@@ -543,12 +792,12 @@ async function executeViewFileBytesOperation(input) {
543
792
  height: dimensions.height
544
793
  };
545
794
  }
546
- function stagedBlobSizeBytes(cwd) {
547
- const names = runGit(["diff", "--cached", "--name-only", "-z"], { cwd }).split("\0").filter(Boolean);
795
+ function stagedBlobSizeBytes(context) {
796
+ const names = runInternalGit(context, ["diff", "--cached", "--name-only", "-z", "--", ".", ":(exclude).git"]).split("\0").filter(Boolean);
548
797
  let total = 0;
549
798
  for (const name of names) {
550
799
  try {
551
- const sizeText = runGit(["cat-file", "-s", `:${name}`], { cwd });
800
+ const sizeText = runInternalGit(context, ["cat-file", "-s", `:${name}`]);
552
801
  total += Number.parseInt(sizeText, 10) || 0;
553
802
  } catch {
554
803
  }
@@ -559,11 +808,11 @@ function stagedBlobSizeBytes(cwd) {
559
808
  return total;
560
809
  }
561
810
  function syncBranch(input) {
562
- const branchPath = ensureOperationBranch(input);
563
- runGit(["add", "-A"], { cwd: branchPath });
564
- const hasStagedChanges = !tryGit(["diff", "--cached", "--quiet"], { cwd: branchPath });
565
- const status = runGit(["status", "--porcelain"], { cwd: branchPath });
566
- const currentCommit = getCurrentCommitHash(branchPath);
811
+ const workspace = ensureOperationBranch(input);
812
+ runInternalGit(workspace.internalGit, ["add", "-A", "--", ".", ":(exclude).git"]);
813
+ const hasStagedChanges = !tryInternalGit(workspace.internalGit, ["diff", "--cached", "--quiet", "--", ".", ":(exclude).git"]);
814
+ const status = getInternalStatus(workspace.internalGit);
815
+ const currentCommit = getInternalCommitHash(workspace.internalGit);
567
816
  if (!hasStagedChanges) {
568
817
  return {
569
818
  type: "sync",
@@ -573,7 +822,7 @@ function syncBranch(input) {
573
822
  status
574
823
  };
575
824
  }
576
- const diffSizeBytes = stagedBlobSizeBytes(branchPath);
825
+ const diffSizeBytes = stagedBlobSizeBytes(workspace.internalGit);
577
826
  if (diffSizeBytes > MAX_SYNC_DIFF_BYTES && !input.confirmedLargeDiff) {
578
827
  return {
579
828
  type: "sync",
@@ -591,15 +840,15 @@ function syncBranch(input) {
591
840
  parentNodeId: input.parentNodeId,
592
841
  ...input.confirmedLargeDiff ? { confirmedLargeDiff: true, confirmationReason: input.confirmationReason } : {}
593
842
  });
594
- runGit(["commit", "-m", message], { cwd: branchPath });
595
- const commitHash = getCurrentCommitHash(branchPath);
596
- runGit(["push", "build-it-now", `HEAD:refs/heads/${input.branchName}`], { cwd: branchPath });
843
+ runInternalGit(workspace.internalGit, ["commit", "-m", message]);
844
+ const commitHash = getInternalCommitHash(workspace.internalGit);
845
+ runInternalGit(workspace.internalGit, ["push", "build-it-now", `HEAD:refs/heads/${input.branchName}`]);
597
846
  return {
598
847
  type: "sync",
599
848
  changed: true,
600
849
  commitHash,
601
850
  diffSizeBytes,
602
- status: runGit(["status", "--porcelain"], { cwd: branchPath }),
851
+ status: getInternalStatus(workspace.internalGit),
603
852
  trigger: input.trigger
604
853
  };
605
854
  }
@@ -623,14 +872,14 @@ function confirmLargeDiff(input) {
623
872
  };
624
873
  }
625
874
  function pullBranch(input) {
626
- const branchPath = ensureOperationBranch(input);
627
- runGit(["fetch", "build-it-now", "--prune"], { cwd: branchPath });
875
+ const workspace = ensureOperationBranch(input);
876
+ runInternalGit(workspace.internalGit, ["fetch", "build-it-now", "--prune", "+refs/heads/*:refs/remotes/build-it-now/*"]);
628
877
  const target = input.commitHash ?? `build-it-now/${input.branchName}`;
629
- runGit(["reset", "--hard", target], { cwd: branchPath });
630
- runGit(["clean", "-fd"], { cwd: branchPath });
878
+ runInternalGit(workspace.internalGit, ["reset", "--hard", target]);
879
+ runInternalGit(workspace.internalGit, ["clean", "-fd", "--", ".", ":(exclude).git"]);
631
880
  return {
632
881
  type: "pull_branch",
633
- commitHash: getCurrentCommitHash(branchPath)
882
+ commitHash: getInternalCommitHash(workspace.internalGit)
634
883
  };
635
884
  }
636
885
  async function executeOperation(input) {
@@ -649,10 +898,12 @@ async function executeOperation(input) {
649
898
  baseUrl: input.baseUrl,
650
899
  token: input.token,
651
900
  projectRoot: input.projectRoot,
901
+ syncRoot: input.syncRoot,
652
902
  branchName: input.message.branchName,
653
903
  sessionId: input.message.sessionId,
654
904
  parentNodeId: input.message.parentNodeId,
655
- trigger: input.message.trigger
905
+ trigger: input.message.trigger,
906
+ manifest: input.manifest
656
907
  });
657
908
  case "confirm_large_diff":
658
909
  return confirmLargeDiff({
@@ -660,10 +911,12 @@ async function executeOperation(input) {
660
911
  baseUrl: input.baseUrl,
661
912
  token: input.token,
662
913
  projectRoot: input.projectRoot,
914
+ syncRoot: input.syncRoot,
663
915
  branchName: input.message.branchName,
664
916
  sessionId: input.message.sessionId,
665
917
  parentNodeId: input.message.parentNodeId,
666
- reason: input.message.reason
918
+ reason: input.message.reason,
919
+ manifest: input.manifest
667
920
  });
668
921
  case "pull_branch":
669
922
  return pullBranch({
@@ -671,8 +924,10 @@ async function executeOperation(input) {
671
924
  baseUrl: input.baseUrl,
672
925
  token: input.token,
673
926
  projectRoot: input.projectRoot,
927
+ syncRoot: input.syncRoot,
674
928
  branchName: input.message.branchName,
675
- commitHash: input.message.commitHash
929
+ commitHash: input.message.commitHash,
930
+ manifest: input.manifest
676
931
  });
677
932
  }
678
933
  }
@@ -685,15 +940,18 @@ async function executeCommand(input) {
685
940
  baseUrl: input.baseUrl,
686
941
  token: input.token,
687
942
  projectRoot: input.projectRoot,
688
- branchName: input.message.branchName
943
+ syncRoot: input.syncRoot,
944
+ branchName: input.message.branchName,
945
+ manifest: input.manifest
689
946
  });
690
- const cwd = resolveCommandCwd(branchPath, input.message.cwd);
947
+ const cwd = resolveCommandCwd(branchPath.branchPath, input.message.cwd);
691
948
  const subprocess = Bun.spawn(input.message.argv, {
692
949
  cwd,
693
950
  stdout: "pipe",
694
951
  stderr: "pipe",
695
952
  env: {
696
953
  ...process.env,
954
+ ...containerRegistryEnv(),
697
955
  ...input.message.env ?? {}
698
956
  }
699
957
  });
@@ -736,6 +994,182 @@ async function executeCommand(input) {
736
994
  function sendWorkerMessage(ws, message) {
737
995
  ws.send(JSON.stringify(message));
738
996
  }
997
+ const PTY_BRIDGE_SCRIPT = String.raw`
998
+ const readline = require("node:readline");
999
+ const nodePty = require("node-pty");
1000
+
1001
+ let ptyProcess = null;
1002
+
1003
+ function send(message, callback) {
1004
+ process.stdout.write(JSON.stringify(message) + "\n", callback);
1005
+ }
1006
+
1007
+ function decode(data) {
1008
+ return Buffer.from(data, "base64").toString("utf8");
1009
+ }
1010
+
1011
+ const rl = readline.createInterface({ input: process.stdin });
1012
+
1013
+ rl.on("line", (line) => {
1014
+ let message;
1015
+ try {
1016
+ message = JSON.parse(line);
1017
+ } catch (error) {
1018
+ send({ type: "error", error: error instanceof Error ? error.message : String(error) });
1019
+ return;
1020
+ }
1021
+
1022
+ try {
1023
+ if (message.type === "open") {
1024
+ ptyProcess = nodePty.spawn(message.file, message.args, message.options);
1025
+ ptyProcess.onData((data) => {
1026
+ send({ type: "output", data: Buffer.from(data, "utf8").toString("base64") });
1027
+ });
1028
+ ptyProcess.onExit((event) => {
1029
+ send({ type: "exit", exitCode: event.exitCode, signal: event.signal }, () => process.exit(0));
1030
+ });
1031
+ send({ type: "opened" });
1032
+ return;
1033
+ }
1034
+
1035
+ if (!ptyProcess) {
1036
+ send({ type: "error", error: "PTY has not been opened" });
1037
+ return;
1038
+ }
1039
+
1040
+ if (message.type === "input") {
1041
+ ptyProcess.write(decode(message.data));
1042
+ return;
1043
+ }
1044
+
1045
+ if (message.type === "resize") {
1046
+ ptyProcess.resize(message.cols, message.rows);
1047
+ return;
1048
+ }
1049
+
1050
+ if (message.type === "close") {
1051
+ ptyProcess.kill();
1052
+ }
1053
+ } catch (error) {
1054
+ send({ type: "error", error: error instanceof Error ? error.message : String(error) });
1055
+ }
1056
+ });
1057
+ `;
1058
+ function nodePtyModulePaths() {
1059
+ const candidates = [];
1060
+ const entrypoint = process.argv[1];
1061
+ if (entrypoint) {
1062
+ try {
1063
+ let current = import_node_path.default.dirname(import_node_fs.default.realpathSync(entrypoint));
1064
+ for (let index = 0; index < 8; index += 1) {
1065
+ candidates.push(import_node_path.default.join(current, "node_modules"));
1066
+ const parent = import_node_path.default.dirname(current);
1067
+ if (parent === current) {
1068
+ break;
1069
+ }
1070
+ current = parent;
1071
+ }
1072
+ } catch {
1073
+ }
1074
+ }
1075
+ candidates.push(import_node_path.default.join(process.cwd(), "node_modules"), "/usr/local/lib/node_modules", "/usr/lib/node_modules");
1076
+ if (process.env.NODE_PATH) {
1077
+ candidates.push(...process.env.NODE_PATH.split(import_node_path.default.delimiter));
1078
+ }
1079
+ return [...new Set(candidates.filter(Boolean))].join(import_node_path.default.delimiter);
1080
+ }
1081
+ function createNodePtyBridge(options) {
1082
+ const child = (0, import_node_child_process.spawn)("node", ["-e", PTY_BRIDGE_SCRIPT], {
1083
+ env: {
1084
+ ...process.env,
1085
+ NODE_PATH: nodePtyModulePaths()
1086
+ }
1087
+ });
1088
+ let lineBuffer = "";
1089
+ let emittedTerminalEvent = false;
1090
+ let stderr = "";
1091
+ const sendToChild = (message) => {
1092
+ if (child.stdin.writable) {
1093
+ child.stdin.write(`${JSON.stringify(message)}
1094
+ `);
1095
+ }
1096
+ };
1097
+ const handleEvent = (event) => {
1098
+ if (event.type === "opened") {
1099
+ options.onOpened();
1100
+ return;
1101
+ }
1102
+ if (event.type === "output") {
1103
+ options.onOutput(Buffer.from(event.data, "base64").toString("utf8"));
1104
+ return;
1105
+ }
1106
+ if (event.type === "exit") {
1107
+ emittedTerminalEvent = true;
1108
+ options.onExit({ exitCode: event.exitCode, signal: event.signal });
1109
+ return;
1110
+ }
1111
+ if (event.type === "error") {
1112
+ emittedTerminalEvent = true;
1113
+ options.onError(new Error(event.error));
1114
+ }
1115
+ };
1116
+ child.stdout.setEncoding("utf8");
1117
+ child.stdout.on("data", (chunk) => {
1118
+ lineBuffer += chunk;
1119
+ let newlineIndex = lineBuffer.indexOf("\n");
1120
+ while (newlineIndex !== -1) {
1121
+ const line = lineBuffer.slice(0, newlineIndex).trim();
1122
+ lineBuffer = lineBuffer.slice(newlineIndex + 1);
1123
+ if (line) {
1124
+ try {
1125
+ handleEvent(JSON.parse(line));
1126
+ } catch (error) {
1127
+ emittedTerminalEvent = true;
1128
+ options.onError(new Error(error instanceof Error ? error.message : String(error)));
1129
+ }
1130
+ }
1131
+ newlineIndex = lineBuffer.indexOf("\n");
1132
+ }
1133
+ });
1134
+ child.stderr.setEncoding("utf8");
1135
+ child.stderr.on("data", (chunk) => {
1136
+ stderr += chunk;
1137
+ process.stderr.write(`[r5d-worker] pty helper: ${chunk}`);
1138
+ });
1139
+ child.on("error", (error) => {
1140
+ emittedTerminalEvent = true;
1141
+ options.onError(error);
1142
+ });
1143
+ child.on("exit", (code, signal) => {
1144
+ if (emittedTerminalEvent) {
1145
+ return;
1146
+ }
1147
+ const detail = stderr.trim() || `node pty helper exited with code ${code ?? "unknown"}${signal ? ` signal ${signal}` : ""}`;
1148
+ options.onError(new Error(detail));
1149
+ });
1150
+ sendToChild({
1151
+ type: "open",
1152
+ file: options.file,
1153
+ args: options.args,
1154
+ options: options.ptyOptions
1155
+ });
1156
+ return {
1157
+ write(data) {
1158
+ sendToChild({ type: "input", data: Buffer.from(data, "utf8").toString("base64") });
1159
+ },
1160
+ resize(cols, rows) {
1161
+ sendToChild({ type: "resize", cols, rows });
1162
+ },
1163
+ kill() {
1164
+ sendToChild({ type: "close" });
1165
+ setTimeout(() => {
1166
+ if (!child.killed) {
1167
+ child.kill();
1168
+ }
1169
+ }, 500);
1170
+ }
1171
+ };
1172
+ }
739
1173
  function resolveHostShell() {
740
1174
  if (process.platform === "win32") {
741
1175
  return { file: process.env.COMSPEC || "powershell.exe", args: [] };
@@ -749,43 +1183,61 @@ function openPty(input) {
749
1183
  baseUrl: input.baseUrl,
750
1184
  token: input.token,
751
1185
  projectRoot: input.projectRoot,
752
- branchName: input.message.branchName
1186
+ syncRoot: input.syncRoot,
1187
+ branchName: input.message.branchName,
1188
+ manifest: input.manifest
753
1189
  });
754
1190
  const shell = resolveHostShell();
755
- const ptyProcess = nodePty.spawn(shell.file, shell.args, {
756
- name: "xterm-256color",
757
- cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
758
- rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
759
- cwd: branchPath,
760
- env: {
761
- ...process.env,
762
- ...input.message.env ?? {},
763
- BUILD_IT_NOW_PROJECT_ID: input.projectId,
764
- BUILD_IT_NOW_BRANCH_NAME: input.message.branchName
1191
+ const ptyProcess = createNodePtyBridge({
1192
+ file: shell.file,
1193
+ args: shell.args,
1194
+ ptyOptions: {
1195
+ name: "xterm-256color",
1196
+ cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
1197
+ rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
1198
+ cwd: branchPath.branchPath,
1199
+ env: {
1200
+ ...process.env,
1201
+ ...containerRegistryEnv(),
1202
+ ...input.message.env ?? {},
1203
+ BUILD_IT_NOW_PROJECT_ID: input.projectId,
1204
+ BUILD_IT_NOW_BRANCH_NAME: input.message.branchName
1205
+ }
1206
+ },
1207
+ onOpened: () => {
1208
+ sendWorkerMessage(input.ws, {
1209
+ type: "pty_opened",
1210
+ requestId: input.message.requestId,
1211
+ ptyId: input.message.ptyId
1212
+ });
1213
+ },
1214
+ onOutput: (data) => {
1215
+ sendWorkerMessage(input.ws, {
1216
+ type: "pty_output",
1217
+ ptyId: input.message.ptyId,
1218
+ data
1219
+ });
1220
+ },
1221
+ onExit: (event) => {
1222
+ activePtys.delete(input.message.ptyId);
1223
+ sendWorkerMessage(input.ws, {
1224
+ type: "pty_exit",
1225
+ ptyId: input.message.ptyId,
1226
+ exitCode: event.exitCode,
1227
+ signal: event.signal
1228
+ });
1229
+ },
1230
+ onError: (error) => {
1231
+ activePtys.delete(input.message.ptyId);
1232
+ sendWorkerMessage(input.ws, {
1233
+ type: "pty_error",
1234
+ requestId: input.message.requestId,
1235
+ ptyId: input.message.ptyId,
1236
+ error: error.message
1237
+ });
765
1238
  }
766
1239
  });
767
1240
  activePtys.set(input.message.ptyId, ptyProcess);
768
- ptyProcess.onData((data) => {
769
- sendWorkerMessage(input.ws, {
770
- type: "pty_output",
771
- ptyId: input.message.ptyId,
772
- data
773
- });
774
- });
775
- ptyProcess.onExit((event) => {
776
- activePtys.delete(input.message.ptyId);
777
- sendWorkerMessage(input.ws, {
778
- type: "pty_exit",
779
- ptyId: input.message.ptyId,
780
- exitCode: event.exitCode,
781
- signal: event.signal
782
- });
783
- });
784
- sendWorkerMessage(input.ws, {
785
- type: "pty_opened",
786
- requestId: input.message.requestId,
787
- ptyId: input.message.ptyId
788
- });
789
1241
  } catch (error) {
790
1242
  sendWorkerMessage(input.ws, {
791
1243
  type: "pty_error",
@@ -831,32 +1283,36 @@ function closeAllPtys() {
831
1283
  }
832
1284
  activePtys.clear();
833
1285
  }
834
- function websocketUrl(baseUrl, projectId, label) {
1286
+ function websocketUrl(baseUrl, label) {
835
1287
  const url = new URL("/worker/ws", baseUrl);
836
1288
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
837
- url.searchParams.set("projectId", projectId);
838
1289
  url.searchParams.set("label", label);
839
1290
  return url.toString();
840
1291
  }
841
- async function startWorker(projectId, options) {
1292
+ function projectRootFor(projectsRoot, projectId, manifestByProjectId) {
1293
+ return import_node_path.default.join(projectsRoot, manifestByProjectId.get(projectId)?.repoSlug ?? projectId);
1294
+ }
1295
+ async function startWorker(options) {
842
1296
  const config = readConfig(options.configPath);
843
1297
  const { baseUrl, token } = resolveCredentials(options, config);
844
1298
  const label = requireValue(options.label, "Missing required --label <label>");
845
1299
  validateLabel(label);
846
- const projectRoot = import_node_path.default.resolve(options.projectRoot ?? defaultProjectRoot(projectId));
847
- process.stdout.write(`[r5d-worker] project: ${projectId}
848
- `);
1300
+ const projectsRoot = import_node_path.default.resolve(options.projectRoot ?? defaultProjectsRoot());
1301
+ const syncRoot = import_node_path.default.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
849
1302
  process.stdout.write(`[r5d-worker] label: ${label}
850
1303
  `);
851
- process.stdout.write(`[r5d-worker] root: ${projectRoot}
1304
+ process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
1305
+ `);
1306
+ process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
852
1307
  `);
853
1308
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
854
1309
  `);
855
1310
  runGit(["--version"]);
856
1311
  await verifyBinctlAuth(baseUrl, token);
857
- process.stdout.write("[r5d-worker] preparing local checkout...\n");
858
- ensureMainClone({ projectId, baseUrl, token, projectRoot });
859
- const ws = new WebSocket(websocketUrl(baseUrl, projectId, label), {
1312
+ import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
1313
+ import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
1314
+ const manifestByProjectId = /* @__PURE__ */ new Map();
1315
+ const ws = new WebSocket(websocketUrl(baseUrl, label), {
860
1316
  headers: {
861
1317
  Authorization: `Bearer ${token}`
862
1318
  }
@@ -870,7 +1326,7 @@ async function startWorker(projectId, options) {
870
1326
  arch: process.arch,
871
1327
  pid: process.pid,
872
1328
  version: "0.1.0",
873
- projectRoot
1329
+ projectRoot: projectsRoot
874
1330
  }
875
1331
  };
876
1332
  ws.send(JSON.stringify(hello));
@@ -882,6 +1338,40 @@ async function startWorker(projectId, options) {
882
1338
  if (message.type === "connected") {
883
1339
  return;
884
1340
  }
1341
+ if (message.type === "project_manifest") {
1342
+ configureContainerRegistryAuth(message.githubContainerRegistry);
1343
+ manifestByProjectId.clear();
1344
+ for (const project of message.projects) {
1345
+ manifestByProjectId.set(project.projectId, project);
1346
+ }
1347
+ process.stdout.write(`[r5d-worker] project manifest: ${message.projects.length} projects
1348
+ `);
1349
+ for (const project of message.projects) {
1350
+ const projectRoot = projectRootFor(projectsRoot, project.projectId, manifestByProjectId);
1351
+ const branches = project.branches.length > 0 ? project.branches : [project.defaultBranch || "main"];
1352
+ for (const branchName of branches) {
1353
+ try {
1354
+ await withBranchLock(project.projectId, branchName, async () => {
1355
+ ensureBranchWorkspace({
1356
+ projectId: project.projectId,
1357
+ baseUrl,
1358
+ token,
1359
+ projectRoot,
1360
+ syncRoot,
1361
+ branchName,
1362
+ manifest: project
1363
+ });
1364
+ });
1365
+ } catch (error) {
1366
+ process.stderr.write(
1367
+ `[r5d-worker] failed to sync ${project.repoSlug}/${branchName}: ${error instanceof Error ? error.message : String(error)}
1368
+ `
1369
+ );
1370
+ }
1371
+ }
1372
+ }
1373
+ return;
1374
+ }
885
1375
  if (message.type === "ping") {
886
1376
  ws.send(JSON.stringify({ type: "pong" }));
887
1377
  return;
@@ -903,9 +1393,11 @@ async function startWorker(projectId, options) {
903
1393
  return;
904
1394
  }
905
1395
  if (message.type === "pty_open") {
906
- process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.branchName}
1396
+ const manifest = manifestByProjectId.get(message.projectId);
1397
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
1398
+ process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
907
1399
  `);
908
- openPty({ ws, message, projectId, baseUrl, token, projectRoot });
1400
+ openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
909
1401
  return;
910
1402
  }
911
1403
  if (message.type === "pty_input") {
@@ -921,22 +1413,26 @@ async function startWorker(projectId, options) {
921
1413
  return;
922
1414
  }
923
1415
  if (message.type === "exec") {
1416
+ const manifest = manifestByProjectId.get(message.projectId);
1417
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
924
1418
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
925
1419
  `);
926
1420
  const result = await withBranchLock(
927
- projectRoot,
1421
+ message.projectId,
928
1422
  message.branchName,
929
- () => executeCommand({ message, projectId, baseUrl, token, projectRoot })
1423
+ () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
930
1424
  );
931
1425
  ws.send(JSON.stringify(result));
932
1426
  return;
933
1427
  }
934
- if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "pull_branch") {
1428
+ if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
935
1429
  try {
1430
+ const manifest = manifestByProjectId.get(message.projectId);
1431
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
936
1432
  const result = await withBranchLock(
937
- projectRoot,
1433
+ message.projectId,
938
1434
  message.branchName,
939
- () => executeOperation({ message, projectId, baseUrl, token, projectRoot })
1435
+ () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
940
1436
  );
941
1437
  ws.send(
942
1438
  JSON.stringify({
@@ -981,7 +1477,7 @@ async function main() {
981
1477
  printHelp();
982
1478
  return;
983
1479
  }
984
- await startWorker(parsed.projectId, parsed.options);
1480
+ await startWorker(parsed.options);
985
1481
  }
986
1482
  main().catch((error) => {
987
1483
  process.stderr.write(`[r5d-worker] ${error instanceof Error ? error.message : String(error)}