@devrouter/cli 0.0.44 → 0.0.45

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/devrouter.js CHANGED
@@ -316,9 +316,9 @@ Config-level \`envMap\` on dependency references aliases per-dep vars to app-exp
316
316
 
317
317
  ## Workspace isolation (parallel git worktrees / agents)
318
318
 
319
- Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** spans the DevPod id, devrouter routes, \`\${WORKSPACE}\` proxy upstreams, and devcontainer aliases.
319
+ Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** spans the workspace-runtime id, devrouter routes, \`\${WORKSPACE}\` proxy upstreams, and devcontainer aliases.
320
320
 
321
- - **Identity**: each managed linked worktree stores a local token in Git metadata plus a durable owner record in the repository's Git common directory. The record survives linked-worktree removal and binds the exact path to its DevPod ID. First use reuses an exact-path DevPod or derives a sanitized branch/path slug. Later flags or \`DEVROUTER_WORKSPACE\` may repeat the identity but cannot rename it. Ambiguous identities fail closed. The primary checkout remains non-namespaced.
321
+ - **Identity**: each managed linked worktree stores a local token in Git metadata plus a durable owner record in the repository's Git common directory. The record survives linked-worktree removal and binds the exact path to its workspace-runtime ID. First use reconciles persisted metadata, the exact-path owner record, and both DevPod and Devsy registries. It reuses an established agreement, keeps the readable sanitized branch/path slug when free, or claims a deterministic hash-suffixed fallback on collision before provider or route mutation. Later flags or \`DEVROUTER_WORKSPACE\` may repeat the identity but cannot rename it. Unreadable or conflicting evidence fails closed. The primary checkout remains non-namespaced.
322
322
  - **When active**: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`\${WORKSPACE}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. Managed \`ensure\` rejects every HTTP/TCP proxy upstream outside that exact alias namespace before it mutates DevPod or routes. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.
323
323
  - **TLS**: namespaced hosts (\`web.<ws>.localhost\`) are not covered by the \`*.localhost\` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.
324
324
  - **devcontainer integration**: managed scaffolds list the base compose file, then \`\${localEnv:DEVCONTAINER_COMPOSE_OVERLAY:docker-compose.default.yml}\`; custom repositories may keep another default overlay. Selecting \`.devcontainer/docker-compose.devrouter.yml\` for linked worktrees must pass \`WORKSPACE\` and \`DEVROUTER_WORKSPACE\` across the combined base/overlay config and bind-mount \`\${DEVROUTER_GIT_COMMON_DIR}\` to the same absolute app-container path. The app exposes \`\${WORKSPACE}-app\`; the proxy uses \`upstream: \${WORKSPACE}-app:<port>\`.
@@ -1057,6 +1057,21 @@ function wsFromBranch(branch) {
1057
1057
  const slug = branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").slice(0, MAX_WORKSPACE_LENGTH).replace(/-+$/, "");
1058
1058
  return slug.length > 0 ? slug : void 0;
1059
1059
  }
1060
+ function workspaceIdentityFallback(source, attempt) {
1061
+ const readablePrefix = (wsFromBranch(source) ?? "workspace").slice(0, WORKSPACE_IDENTITY_PREFIX_LENGTH).replace(/-+$/, "");
1062
+ const prefix = readablePrefix || "workspace";
1063
+ const hash = (0, import_node_crypto3.createHash)("sha256").update(WORKSPACE_IDENTITY_HASH_DOMAIN, "utf-8").update("\0", "utf-8").update(source, "utf-8").update("\0", "utf-8").update(String(attempt), "utf-8").digest("hex").slice(0, WORKSPACE_IDENTITY_HASH_LENGTH);
1064
+ return `${prefix}-${hash}`;
1065
+ }
1066
+ function workspaceIdentityCandidates(source) {
1067
+ const candidates = [];
1068
+ const legacy = wsFromBranch(source);
1069
+ if (legacy) candidates.push(legacy);
1070
+ for (let attempt = 0; candidates.length < WORKSPACE_IDENTITY_CANDIDATE_LIMIT; attempt += 1) {
1071
+ candidates.push(workspaceIdentityFallback(source, attempt));
1072
+ }
1073
+ return candidates;
1074
+ }
1060
1075
  function isLinkedWorktree(repoPath) {
1061
1076
  const gitPath = import_node_path4.default.join(repoPath, ".git");
1062
1077
  let stat;
@@ -1138,8 +1153,8 @@ function persistWorkspace(repoPath, value) {
1138
1153
  if (!gitDir) {
1139
1154
  throw new Error(`cannot persist workspace identity: '${repoPath}' is not a Git checkout`);
1140
1155
  }
1141
- import_node_fs5.default.writeFileSync(import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE), `${workspace}
1142
- `, "utf-8");
1156
+ writeFileAtomically(import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE), `${workspace}
1157
+ `);
1143
1158
  return workspace;
1144
1159
  }
1145
1160
  async function withWorkspaceLifecycleLock(repoPath, operation) {
@@ -1198,15 +1213,21 @@ function resolveWorkspace(repoPath, override) {
1198
1213
  }
1199
1214
  return deriveLinkedWorktreeWorkspace(repoPath);
1200
1215
  }
1201
- var import_node_child_process3, import_node_fs5, import_node_path4, MAX_WORKSPACE_LENGTH, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
1216
+ var import_node_child_process3, import_node_crypto3, import_node_fs5, import_node_path4, MAX_WORKSPACE_LENGTH, WORKSPACE_IDENTITY_CANDIDATE_LIMIT, WORKSPACE_IDENTITY_HASH_LENGTH, WORKSPACE_IDENTITY_PREFIX_LENGTH, WORKSPACE_IDENTITY_HASH_DOMAIN, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
1202
1217
  var init_workspace = __esm({
1203
1218
  "src/core/workspace.ts"() {
1204
1219
  "use strict";
1205
1220
  import_node_child_process3 = require("child_process");
1221
+ import_node_crypto3 = require("crypto");
1206
1222
  import_node_fs5 = __toESM(require("fs"));
1207
1223
  import_node_path4 = __toESM(require("path"));
1224
+ init_atomic_file();
1208
1225
  init_file_lock();
1209
1226
  MAX_WORKSPACE_LENGTH = 32;
1227
+ WORKSPACE_IDENTITY_CANDIDATE_LIMIT = 16;
1228
+ WORKSPACE_IDENTITY_HASH_LENGTH = 8;
1229
+ WORKSPACE_IDENTITY_PREFIX_LENGTH = MAX_WORKSPACE_LENGTH - WORKSPACE_IDENTITY_HASH_LENGTH - 1;
1230
+ WORKSPACE_IDENTITY_HASH_DOMAIN = "devrouter-workspace-identity-v1";
1210
1231
  WORKSPACE_METADATA_FILE = "devrouter-workspace";
1211
1232
  WORKSPACE_LOCK_FILE = "devrouter-workspace.lock";
1212
1233
  }
@@ -2509,7 +2530,7 @@ function loadRepoConfig(repoPath) {
2509
2530
  const config = parseConfig(parsed ?? {}, configPath);
2510
2531
  const requiredVersion = config.devrouter?.version;
2511
2532
  if (requiredVersion && !hasWarnedVersionMismatch) {
2512
- const cliVersion = true ? "0.0.44" : "0.0.0-dev";
2533
+ const cliVersion = true ? "0.0.45" : "0.0.0-dev";
2513
2534
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
2514
2535
  hasWarnedVersionMismatch = true;
2515
2536
  process.stderr.write(
@@ -3035,8 +3056,8 @@ function buildOnboardingPrompt(options = {}) {
3035
3056
  "- Use `devrouter status --repo <REPO_PATH> --json` and `devrouter doctor --repo <REPO_PATH> --json` to inspect managed desired/active resources, fingerprints, transition state, and values-free drift.",
3036
3057
  "",
3037
3058
  "Workspace isolation (parallel git worktrees / agents):",
3038
- `- A "workspace token" lets several worktrees of one repo run in parallel without host/route collisions. Each managed linked worktree has a local Git token plus a durable owner record in the repository's Git common directory spanning the DevPod id, devrouter routes, the \`${WORKSPACE_PLACEHOLDER}\` proxy upstream, and devcontainer aliases.`,
3039
- "- The owner record survives linked-worktree removal and binds the exact path to its DevPod ID. First use reuses an exact-path DevPod or derives a sanitized branch/path identity. Later flags or `DEVROUTER_WORKSPACE` may repeat but cannot rename it. Ambiguous identities fail closed. The primary checkout stays non-namespaced.",
3059
+ `- A "workspace token" lets several worktrees of one repo run in parallel without host/route collisions. Each managed linked worktree has a local Git token plus a durable owner record in the repository's Git common directory spanning the workspace-runtime id, devrouter routes, the \`${WORKSPACE_PLACEHOLDER}\` proxy upstream, and devcontainer aliases.`,
3060
+ "- The owner record survives linked-worktree removal and binds the exact path to its workspace-runtime ID. First use reconciles persisted metadata, the exact-path owner record, and both DevPod and Devsy registries. It preserves an established agreement, uses the readable sanitized identity when free, or claims a deterministic hash-suffixed fallback on collision before provider or route mutation. Later flags or `DEVROUTER_WORKSPACE` may repeat but cannot rename it. Unreadable or conflicting evidence fails closed. The primary checkout stays non-namespaced.",
3040
3061
  `- When a workspace is active: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`${WORKSPACE_PLACEHOLDER}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. Managed ensure rejects every HTTP/TCP upstream outside that exact alias namespace before mutation. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.`,
3041
3062
  "- TLS: namespaced hosts (`web.<ws>.localhost`) are not covered by the `*.localhost` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.",
3042
3063
  "- Lifecycle: after one-time setup, use `devrouter ensure .` for both primary and linked checkouts; never branch on checkout kind or use live verify as startup. Managed consumer images contain no devrouter package/helper: ensure delivers its matching helper at runtime and invokes an exact captured snapshot of the repository-owned post-start adapter. Keep `.devrouter.yml` as the only consumer-side version pin. Use `devrouter stop .` for a non-destructive pause, `devrouter stop . --delete` only for explicit exact-owner cleanup without removing the checkout, and `devrouter exec . -- <command...>` for container commands. Never substitute raw DevPod/Devsy mutations; they bypass the machine-global ownership lock. `workspace up` creates linked worktrees; destructive worktree removal and GC remain ledger-scoped.",
@@ -4110,7 +4131,7 @@ function readRegularFile(filePath) {
4110
4131
  return readRegularFileBytes(filePath)?.toString("utf-8");
4111
4132
  }
4112
4133
  function adapterFingerprint(adapter) {
4113
- return (0, import_node_crypto3.createHash)("sha256").update(adapter).digest("hex");
4134
+ return (0, import_node_crypto4.createHash)("sha256").update(adapter).digest("hex");
4114
4135
  }
4115
4136
  function resolveProcessHelperPath() {
4116
4137
  const candidates = [
@@ -4257,12 +4278,12 @@ function runManagedProcessAction(options) {
4257
4278
  }
4258
4279
  return "drifted";
4259
4280
  }
4260
- var import_node_child_process5, import_node_crypto3, import_node_fs10, import_node_path9, MANAGED_MARKER, MANAGED_ADAPTER_PATH, RUNTIME_HELPER_PATH, ADAPTER_WRAPPER;
4281
+ var import_node_child_process5, import_node_crypto4, import_node_fs10, import_node_path9, MANAGED_MARKER, MANAGED_ADAPTER_PATH, RUNTIME_HELPER_PATH, ADAPTER_WRAPPER;
4261
4282
  var init_managed_post_start = __esm({
4262
4283
  "src/core/managed-post-start.ts"() {
4263
4284
  "use strict";
4264
4285
  import_node_child_process5 = require("child_process");
4265
- import_node_crypto3 = require("crypto");
4286
+ import_node_crypto4 = require("crypto");
4266
4287
  import_node_fs10 = __toESM(require("fs"));
4267
4288
  import_node_path9 = __toESM(require("path"));
4268
4289
  init_devcontainer_config();
@@ -4764,7 +4785,7 @@ var init_routes = __esm({
4764
4785
 
4765
4786
  // src/core/managed-runtime-state.ts
4766
4787
  function stateKey(repoPath, workspace) {
4767
- return (0, import_node_crypto4.createHash)("sha256").update(`${repoPath}\0${workspace ?? ""}`, "utf-8").digest("hex");
4788
+ return (0, import_node_crypto5.createHash)("sha256").update(`${repoPath}\0${workspace ?? ""}`, "utf-8").digest("hex");
4768
4789
  }
4769
4790
  function managedRuntimeStatePath(repoPath, workspace) {
4770
4791
  return import_node_path12.default.join(DEVROUTER_HOME, "managed-runtime", `${stateKey(repoPath, workspace)}.json`);
@@ -4855,11 +4876,11 @@ function markManagedRuntimeDegraded(state, transitionPhase) {
4855
4876
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4856
4877
  });
4857
4878
  }
4858
- var import_node_crypto4, import_node_fs13, import_node_path12;
4879
+ var import_node_crypto5, import_node_fs13, import_node_path12;
4859
4880
  var init_managed_runtime_state = __esm({
4860
4881
  "src/core/managed-runtime-state.ts"() {
4861
4882
  "use strict";
4862
- import_node_crypto4 = require("crypto");
4883
+ import_node_crypto5 = require("crypto");
4863
4884
  import_node_fs13 = __toESM(require("fs"));
4864
4885
  import_node_path12 = __toESM(require("path"));
4865
4886
  init_atomic_file();
@@ -4992,7 +5013,7 @@ function isWildcard(values) {
4992
5013
  return values?.length === 1 && values[0] === "*";
4993
5014
  }
4994
5015
  function sha256(contents) {
4995
- return (0, import_node_crypto5.createHash)("sha256").update(contents, "utf-8").digest("hex");
5016
+ return (0, import_node_crypto6.createHash)("sha256").update(contents, "utf-8").digest("hex");
4996
5017
  }
4997
5018
  function safeComposeProject(value) {
4998
5019
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
@@ -5177,12 +5198,12 @@ function stopExactManagedService(containerId, service) {
5177
5198
  throw new Error(`Could not stop exact managed service '${service}' (${containerId}).`);
5178
5199
  }
5179
5200
  }
5180
- var import_node_child_process6, import_node_crypto5, import_node_fs14, import_node_path13, import_yaml4, MANAGED_DEVCONTAINER_PATH, MANAGED_DEVCONTAINER_MARKER;
5201
+ var import_node_child_process6, import_node_crypto6, import_node_fs14, import_node_path13, import_yaml4, MANAGED_DEVCONTAINER_PATH, MANAGED_DEVCONTAINER_MARKER;
5181
5202
  var init_devcontainer_profile = __esm({
5182
5203
  "src/core/devcontainer-profile.ts"() {
5183
5204
  "use strict";
5184
5205
  import_node_child_process6 = require("child_process");
5185
- import_node_crypto5 = require("crypto");
5206
+ import_node_crypto6 = require("crypto");
5186
5207
  import_node_fs14 = __toESM(require("fs"));
5187
5208
  import_node_path13 = __toESM(require("path"));
5188
5209
  import_yaml4 = __toESM(require("yaml"));
@@ -5954,7 +5975,7 @@ function parseDnsHostsFromSubjectAltName(subjectAltName) {
5954
5975
  return normalizeUniqueHosts(names);
5955
5976
  }
5956
5977
  function parseCertificateDnsHosts(pem) {
5957
- const certificate = new import_node_crypto6.X509Certificate(pem);
5978
+ const certificate = new import_node_crypto7.X509Certificate(pem);
5958
5979
  const subjectAltName = certificate.subjectAltName ?? "";
5959
5980
  if (subjectAltName.length === 0) {
5960
5981
  return [];
@@ -6154,12 +6175,12 @@ Run: ${tlsSetupCommand(options.repoPath)}`
6154
6175
  );
6155
6176
  }
6156
6177
  }
6157
- var import_node_child_process8, import_node_crypto6, import_node_fs16, import_node_path15, DEFAULT_TLS_CERT_HOSTS, TLS_CERTIFICATE_LOCK_FILE, TLS_CERTIFICATE_LOCK_WAIT_MS, TLS_CERTIFICATE_WRITE_ATTEMPTS;
6178
+ var import_node_child_process8, import_node_crypto7, import_node_fs16, import_node_path15, DEFAULT_TLS_CERT_HOSTS, TLS_CERTIFICATE_LOCK_FILE, TLS_CERTIFICATE_LOCK_WAIT_MS, TLS_CERTIFICATE_WRITE_ATTEMPTS;
6158
6179
  var init_tls = __esm({
6159
6180
  "src/core/tls.ts"() {
6160
6181
  "use strict";
6161
6182
  import_node_child_process8 = require("child_process");
6162
- import_node_crypto6 = require("crypto");
6183
+ import_node_crypto7 = require("crypto");
6163
6184
  import_node_fs16 = __toESM(require("fs"));
6164
6185
  import_node_path15 = __toESM(require("path"));
6165
6186
  init_docker();
@@ -7455,12 +7476,16 @@ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
7455
7476
  import_node_fs21.default.rmSync(filePath);
7456
7477
  return "removed";
7457
7478
  }
7458
- function withWorkspaceOwnershipTransaction(repoPath, operation) {
7479
+ function withWorkspaceOwnershipTransaction(repoPath, operation, options = {}) {
7459
7480
  const directory = ownershipDirectory(repoPath);
7460
7481
  import_node_fs21.default.mkdirSync(directory, { recursive: true });
7461
7482
  return withFileLockSync(
7462
7483
  import_node_path20.default.join(directory, ".lock"),
7463
- { activity: "workspace ownership transaction", target: `'${repoPath}'`, waitMs: 5e3 },
7484
+ {
7485
+ activity: "workspace ownership transaction",
7486
+ target: `'${repoPath}'`,
7487
+ waitMs: options.waitMs ?? 5e3
7488
+ },
7464
7489
  () => operation({
7465
7490
  list: () => listWorkspaceOwnershipInDirectory(directory),
7466
7491
  write: (input2) => writeWorkspaceOwnershipInDirectory(directory, input2),
@@ -7469,8 +7494,169 @@ function withWorkspaceOwnershipTransaction(repoPath, operation) {
7469
7494
  })
7470
7495
  );
7471
7496
  }
7472
- function writeWorkspaceOwnership(repoPath, input2) {
7473
- return withWorkspaceOwnershipTransaction(repoPath, (transaction) => transaction.write(input2));
7497
+ function providerPathOwner(providerWorkspaces, worktreePath) {
7498
+ const owners = providerWorkspaces.filter(
7499
+ (workspace) => sameWorkspacePath(workspace.source.localFolder, worktreePath)
7500
+ );
7501
+ if (owners.length > 1) {
7502
+ throw new Error(
7503
+ `Worktree '${worktreePath}' is registered by multiple workspace runtimes; no identity was claimed.`
7504
+ );
7505
+ }
7506
+ return owners[0];
7507
+ }
7508
+ function persistedWorkspaceOwners(repoPath, worktreePath) {
7509
+ const owners = /* @__PURE__ */ new Map();
7510
+ for (const worktree of listGitWorktrees(repoPath)) {
7511
+ if (sameWorkspacePath(worktree.path, worktreePath)) continue;
7512
+ let workspace;
7513
+ try {
7514
+ workspace = readPersistedWorkspace(worktree.path);
7515
+ } catch (error) {
7516
+ if (!import_node_fs21.default.existsSync(worktree.path)) continue;
7517
+ throw error;
7518
+ }
7519
+ if (!workspace) continue;
7520
+ const existing = owners.get(workspace);
7521
+ if (existing && !sameWorkspacePath(existing, worktree.path)) {
7522
+ throw new Error(`Persisted workspace identity '${workspace}' belongs to multiple worktrees.`);
7523
+ }
7524
+ owners.set(workspace, worktree.path);
7525
+ }
7526
+ return owners;
7527
+ }
7528
+ function claimConflict(workspace, devpodId, worktreePath, records, providerWorkspaces, persistedOwners, exactRecord) {
7529
+ const recordOwner = records.find(
7530
+ (record) => record !== exactRecord && (record.workspace === workspace || record.devpodId === devpodId)
7531
+ );
7532
+ if (recordOwner) {
7533
+ return `workspace owner record '${recordOwner.workspace}' for '${recordOwner.worktreePath}'`;
7534
+ }
7535
+ const providerOwner = providerWorkspaces.find(
7536
+ (providerWorkspace) => providerWorkspace.id === devpodId && !sameWorkspacePath(providerWorkspace.source.localFolder, worktreePath)
7537
+ );
7538
+ if (providerOwner) {
7539
+ return `workspace runtime identity '${providerOwner.id}' already belongs to '${providerOwner.source.localFolder}'`;
7540
+ }
7541
+ const persistedOwner = persistedOwners.get(workspace);
7542
+ if (persistedOwner) {
7543
+ return `persisted checkout metadata for '${persistedOwner}'`;
7544
+ }
7545
+ return void 0;
7546
+ }
7547
+ function claimWorkspaceIdentity(repoPath, input2) {
7548
+ const worktreePath = comparableWorkspacePath(repoPath);
7549
+ const exactProvider = providerPathOwner(input2.providerWorkspaces, worktreePath);
7550
+ return withWorkspaceOwnershipTransaction(repoPath, (transaction) => {
7551
+ const records = transaction.list();
7552
+ const exactRecords = records.filter(
7553
+ (record) => sameWorkspacePath(record.worktreePath, worktreePath)
7554
+ );
7555
+ if (exactRecords.length > 1) {
7556
+ throw new Error(
7557
+ `Worktree '${worktreePath}' has multiple workspace owner records; no identity was claimed.`
7558
+ );
7559
+ }
7560
+ const exactRecord = exactRecords[0];
7561
+ const persisted = readPersistedWorkspace(worktreePath);
7562
+ const persistedOwners = persistedWorkspaceOwners(repoPath, worktreePath);
7563
+ if (exactRecord) {
7564
+ if (persisted && persisted !== exactRecord.workspace) {
7565
+ throw new Error(
7566
+ `Persisted workspace identity '${persisted}' disagrees with owner record '${exactRecord.workspace}'.`
7567
+ );
7568
+ }
7569
+ if (exactProvider && exactProvider.id !== exactRecord.devpodId) {
7570
+ throw new Error(
7571
+ `Workspace runtime '${exactProvider.id}' disagrees with owner record '${exactRecord.devpodId}'.`
7572
+ );
7573
+ }
7574
+ const conflict2 = claimConflict(
7575
+ exactRecord.workspace,
7576
+ exactRecord.devpodId,
7577
+ worktreePath,
7578
+ records,
7579
+ input2.providerWorkspaces,
7580
+ persistedOwners,
7581
+ exactRecord
7582
+ );
7583
+ if (conflict2) {
7584
+ throw new Error(
7585
+ `Workspace '${exactRecord.workspace}' conflicts with ${conflict2}; no identity was claimed.`
7586
+ );
7587
+ }
7588
+ if (!persisted) persistWorkspace(worktreePath, exactRecord.workspace);
7589
+ return transaction.write({
7590
+ workspace: exactRecord.workspace,
7591
+ worktreePath,
7592
+ branch: input2.branch ?? null,
7593
+ devpodId: exactRecord.devpodId
7594
+ });
7595
+ }
7596
+ if (persisted && exactProvider && persisted !== exactProvider.id) {
7597
+ throw new Error(
7598
+ `Persisted workspace identity '${persisted}' disagrees with workspace runtime '${exactProvider.id}'.`
7599
+ );
7600
+ }
7601
+ let workspace = persisted ?? exactProvider?.id;
7602
+ let devpodId = exactProvider?.id ?? persisted;
7603
+ if (!workspace || !devpodId) {
7604
+ if (input2.unavailableRuntimes.length > 0) {
7605
+ throw new Error(
7606
+ `Cannot claim a new workspace identity because these runtime registries are unavailable: ${input2.unavailableRuntimes.join(", ")}.`
7607
+ );
7608
+ }
7609
+ const candidate = workspaceIdentityCandidates(input2.source).find(
7610
+ (next) => !claimConflict(
7611
+ next,
7612
+ next,
7613
+ worktreePath,
7614
+ records,
7615
+ input2.providerWorkspaces,
7616
+ persistedOwners
7617
+ )
7618
+ );
7619
+ if (!candidate) {
7620
+ throw new Error(
7621
+ `Could not allocate a collision-safe workspace identity for '${worktreePath}'.`
7622
+ );
7623
+ }
7624
+ workspace = candidate;
7625
+ devpodId = candidate;
7626
+ }
7627
+ const conflict = claimConflict(
7628
+ workspace,
7629
+ devpodId,
7630
+ worktreePath,
7631
+ records,
7632
+ input2.providerWorkspaces,
7633
+ persistedOwners
7634
+ );
7635
+ if (conflict) {
7636
+ throw new Error(
7637
+ `Workspace '${workspace}' conflicts with ${conflict}; no identity was claimed.`
7638
+ );
7639
+ }
7640
+ const written = transaction.write({
7641
+ workspace,
7642
+ worktreePath,
7643
+ branch: input2.branch ?? null,
7644
+ devpodId
7645
+ });
7646
+ try {
7647
+ persistWorkspace(worktreePath, workspace);
7648
+ } catch (error) {
7649
+ const cleanup = transaction.removeIfMatches(written);
7650
+ if (cleanup !== "removed") {
7651
+ const detail = error instanceof Error ? error.message : String(error);
7652
+ throw new Error(
7653
+ `Could not persist workspace identity and owner-record rollback was '${cleanup}': ${detail}`
7654
+ );
7655
+ }
7656
+ throw error;
7657
+ }
7658
+ return written;
7659
+ });
7474
7660
  }
7475
7661
  function removeWorkspaceOwnership(repoPath, workspace) {
7476
7662
  return withWorkspaceOwnershipTransaction(
@@ -8153,7 +8339,7 @@ async function buildDoctorReport(options = {}) {
8153
8339
  const config = runtimeConfig.config;
8154
8340
  loadedConfig = config;
8155
8341
  loadedWorkspace = runtimeConfig.workspace;
8156
- const cliVersion = true ? "0.0.44" : "0.0.0-dev";
8342
+ const cliVersion = true ? "0.0.45" : "0.0.0-dev";
8157
8343
  const configVersion = config.devrouter?.version;
8158
8344
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
8159
8345
  addCheck(checks, {
@@ -9229,25 +9415,30 @@ async function waitForHttpRoutes(repoPath, apps, timeoutMs) {
9229
9415
  );
9230
9416
  }
9231
9417
  function resolveLinkedTarget(repoPath) {
9232
- const devpods = listDevpodWorkspaces(repoPath);
9233
- const existingDevpod = selectDevpodWorkspace(devpods, repoPath);
9234
- const persisted = readPersistedWorkspace(repoPath);
9235
- const candidate = existingDevpod?.id ?? persisted ?? resolveWorktreeWorkspace(repoPath);
9236
- if (!candidate) {
9237
- throw new Error(`Could not resolve a workspace identity for '${repoPath}'.`);
9238
- }
9239
- const otherOwner = devpods.find(
9240
- (devpod) => devpod.id === candidate && !sameWorkspacePath(devpod.source.localFolder, repoPath)
9418
+ const snapshots = getWorkspaceRegistrySnapshots();
9419
+ const providerWorkspaces = [
9420
+ ...snapshots.devpod ?? [],
9421
+ ...snapshots.devsy?.map((workspace) => ({
9422
+ id: workspace.id,
9423
+ source: workspace.source,
9424
+ ...workspace.lastUsed ? { lastUsed: workspace.lastUsed } : {},
9425
+ ...workspace.lastUsedMalformed ? { lastUsedMalformed: true } : {}
9426
+ })) ?? []
9427
+ ];
9428
+ const branch = currentBranch(repoPath);
9429
+ const claim = claimWorkspaceIdentity(repoPath, {
9430
+ source: branch ?? repoPath,
9431
+ branch: branch ?? null,
9432
+ providerWorkspaces,
9433
+ unavailableRuntimes: snapshots.unavailable
9434
+ });
9435
+ const existingDevpod = providerWorkspaces.find(
9436
+ (workspace) => workspace.id === claim.devpodId && sameWorkspacePath(workspace.source.localFolder, repoPath)
9241
9437
  );
9242
- if (otherOwner) {
9243
- throw new Error(
9244
- `DevPod identity '${candidate}' already belongs to '${otherOwner.source.localFolder}'.`
9245
- );
9246
- }
9247
9438
  return {
9248
9439
  kind: "linked",
9249
- workspace: persistWorkspace(repoPath, candidate),
9250
- devpodId: candidate,
9440
+ workspace: claim.workspace,
9441
+ devpodId: claim.devpodId,
9251
9442
  hadExactDevpod: Boolean(existingDevpod),
9252
9443
  gitCommonDir: resolveGitCommonDir(repoPath)
9253
9444
  };
@@ -9394,15 +9585,6 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
9394
9585
  managedConfigWritten = true;
9395
9586
  }
9396
9587
  const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host);
9397
- const ownership = target.kind === "linked" ? {
9398
- workspace: target.workspace,
9399
- worktreePath: repoPath,
9400
- branch: currentBranch(repoPath),
9401
- devpodId: target.devpodId
9402
- } : void 0;
9403
- if (ownership) {
9404
- writeWorkspaceOwnership(repoPath, ownership);
9405
- }
9406
9588
  const currentTarget = () => target.kind === "linked" ? target : { ...target, devpodId };
9407
9589
  const startAndProveAttachment = (recreate = false) => {
9408
9590
  const requestedTarget = currentTarget();
@@ -9425,9 +9607,6 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
9425
9607
  if (error instanceof DevpodStartPostconditionError) environmentStarted = true;
9426
9608
  throw error;
9427
9609
  }
9428
- if (ownership) {
9429
- writeWorkspaceOwnership(repoPath, ownership);
9430
- }
9431
9610
  };
9432
9611
  const preflight = (timeoutMs) => waitForContainerPreflight(repoPath, currentTarget(), upstreamHosts, timeoutMs);
9433
9612
  const recreateAndPreflight = async () => {
@@ -9845,6 +10024,7 @@ var init_workspace_ensure = __esm({
9845
10024
  init_router();
9846
10025
  init_workspace();
9847
10026
  init_workspace_ownership();
10027
+ init_workspace_runtime();
9848
10028
  DEVCONTAINER_OVERLAY = "docker-compose.devrouter.yml";
9849
10029
  DEFAULT_READINESS_TIMEOUT_MS = 12e4;
9850
10030
  POLL_INTERVAL_MS = 1e3;
@@ -9901,6 +10081,11 @@ var init_ensure = __esm({
9901
10081
  });
9902
10082
 
9903
10083
  // src/core/workspace-lifecycle.ts
10084
+ function withWorkspaceAllocationTransaction(repoPath, operation) {
10085
+ return withWorkspaceOwnershipTransaction(repoPath, operation, {
10086
+ waitMs: WORKSPACE_ALLOCATION_LOCK_WAIT_MS
10087
+ });
10088
+ }
9904
10089
  function warnMissingWorkspaceOwnership(repoPath) {
9905
10090
  const missing = listMissingWorkspaceOwnership(repoPath);
9906
10091
  if (missing.length === 0) return;
@@ -10062,44 +10247,67 @@ async function workspaceUp(branch, opts = {}) {
10062
10247
  if (!ws) {
10063
10248
  throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
10064
10249
  }
10065
- const worktreePath = opts.path ? import_node_path24.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
10066
- if (import_node_fs24.default.existsSync(worktreePath)) {
10067
- const registered = listGitWorktrees(mainRepo).find(
10068
- (worktree) => sameWorkspacePath(worktree.path, worktreePath)
10250
+ const worktreePath = withWorkspaceAllocationTransaction(mainRepo, () => {
10251
+ const worktrees = listGitWorktrees(mainRepo);
10252
+ const branchWorktrees = worktrees.filter((worktree) => worktree.branch === branch);
10253
+ if (branchWorktrees.length > 1) {
10254
+ throw new Error(
10255
+ `Branch '${branch}' is checked out in multiple worktrees: ${branchWorktrees.map((worktree) => worktree.path).join(", ")}`
10256
+ );
10257
+ }
10258
+ const existingBranch = branchWorktrees[0];
10259
+ const candidatePaths = workspaceIdentityCandidates(branch).map(
10260
+ (candidate) => defaultWorktreePath(mainRepo, candidate)
10261
+ );
10262
+ const generatedPath = candidatePaths.find(
10263
+ (candidate) => !import_node_fs24.default.existsSync(candidate) && !worktrees.some((worktree) => sameWorkspacePath(worktree.path, candidate))
10069
10264
  );
10070
- if (!registered || sameWorkspacePath(registered.path, mainRepo)) {
10071
- throw new Error(`Existing path '${worktreePath}' is not a linked worktree of '${mainRepo}'.`);
10265
+ const selectedPath = opts.path ? import_node_path24.default.resolve(opts.path) : existingBranch?.path ?? generatedPath;
10266
+ if (!selectedPath) {
10267
+ throw new Error(`Could not allocate a collision-safe worktree path for branch '${branch}'.`);
10072
10268
  }
10073
- if (registered.branch !== branch) {
10269
+ if (existingBranch && !sameWorkspacePath(existingBranch.path, selectedPath)) {
10074
10270
  throw new Error(
10075
- `Existing worktree '${worktreePath}' uses branch '${registered.branch ?? "detached"}', not '${branch}'.`
10271
+ `Branch '${branch}' already uses worktree '${existingBranch.path}', not '${selectedPath}'.`
10076
10272
  );
10077
10273
  }
10078
- process.stdout.write(`Worktree already exists: ${worktreePath}
10274
+ if (import_node_fs24.default.existsSync(selectedPath)) {
10275
+ const registered = worktrees.find(
10276
+ (worktree) => sameWorkspacePath(worktree.path, selectedPath)
10277
+ );
10278
+ if (!registered || sameWorkspacePath(registered.path, mainRepo)) {
10279
+ throw new Error(
10280
+ `Existing path '${selectedPath}' is not a linked worktree of '${mainRepo}'.`
10281
+ );
10282
+ }
10283
+ if (registered.branch !== branch) {
10284
+ throw new Error(
10285
+ `Existing worktree '${selectedPath}' uses branch '${registered.branch ?? "detached"}', not '${branch}'.`
10286
+ );
10287
+ }
10288
+ process.stdout.write(`Worktree already exists: ${selectedPath}
10079
10289
  `);
10080
- } else {
10081
- if (!opts.path) {
10082
- assertDefaultWorktreeRootIgnored(mainRepo);
10290
+ return selectedPath;
10083
10291
  }
10084
- const add = (0, import_node_child_process20.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
10292
+ if (!opts.path) assertDefaultWorktreeRootIgnored(mainRepo);
10293
+ const add = (0, import_node_child_process20.spawnSync)("git", ["-C", mainRepo, "worktree", "add", selectedPath, branch], {
10085
10294
  encoding: "utf-8"
10086
10295
  });
10087
10296
  if (add.status !== 0) {
10088
10297
  const addNew = (0, import_node_child_process20.spawnSync)(
10089
10298
  "git",
10090
- ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath],
10091
- {
10092
- encoding: "utf-8"
10093
- }
10299
+ ["-C", mainRepo, "worktree", "add", "-b", branch, selectedPath],
10300
+ { encoding: "utf-8" }
10094
10301
  );
10095
10302
  if (addNew.status !== 0) {
10096
- const detail = [add.stderr, addNew.stderr].map((s) => s?.trim()).filter(Boolean).join("; ");
10303
+ const detail = [add.stderr, addNew.stderr].map((stderr) => stderr?.trim()).filter(Boolean).join("; ");
10097
10304
  throw new Error(`git worktree add failed: ${detail || "unknown error"}`);
10098
10305
  }
10099
10306
  }
10100
- process.stdout.write(`Created worktree ${worktreePath} (workspace '${ws}')
10307
+ process.stdout.write(`Created worktree ${selectedPath}
10101
10308
  `);
10102
- }
10309
+ return selectedPath;
10310
+ });
10103
10311
  if (opts.noDevpod) {
10104
10312
  warnMissingWorkspaceOwnership(mainRepo);
10105
10313
  process.stdout.write("Skipped environment startup; no routes were changed.\n");
@@ -10242,7 +10450,7 @@ async function workspaceStop(target, opts = {}) {
10242
10450
  async function workspaceDown(target, opts = {}) {
10243
10451
  return runWorkspaceLifecycle("down", target, opts);
10244
10452
  }
10245
- var import_node_child_process20, import_node_fs24, import_node_path24;
10453
+ var import_node_child_process20, import_node_fs24, import_node_path24, WORKSPACE_ALLOCATION_LOCK_WAIT_MS;
10246
10454
  var init_workspace_lifecycle = __esm({
10247
10455
  "src/core/workspace-lifecycle.ts"() {
10248
10456
  "use strict";
@@ -10256,6 +10464,7 @@ var init_workspace_lifecycle = __esm({
10256
10464
  init_workspace();
10257
10465
  init_workspace_ensure();
10258
10466
  init_workspace_ownership();
10467
+ WORKSPACE_ALLOCATION_LOCK_WAIT_MS = 6e4;
10259
10468
  }
10260
10469
  });
10261
10470
 
@@ -10449,7 +10658,7 @@ async function devpodExec(repoPath, command) {
10449
10658
  }
10450
10659
  assertRunningDevpod(devpod.id, repoPath);
10451
10660
  const workspaceDirectory = resolveWorkspaceDirectory(repoPath);
10452
- const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto7.randomUUID)()}__:`;
10661
+ const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto8.randomUUID)()}__:`;
10453
10662
  const statusMarkerBytes = Buffer.from(statusMarker, "ascii");
10454
10663
  const literalCommand = command.map(quotePosixArg).join(" ");
10455
10664
  const wrappedCommand = `${literalCommand}; __devrouter_status=$?; printf '${statusMarker}%s\\n' "$__devrouter_status" >&2; exit 0`;
@@ -10526,12 +10735,12 @@ async function devpodExec(repoPath, command) {
10526
10735
  });
10527
10736
  });
10528
10737
  }
10529
- var import_node_child_process22, import_node_crypto7, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
10738
+ var import_node_child_process22, import_node_crypto8, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
10530
10739
  var init_devpod_exec = __esm({
10531
10740
  "src/core/devpod-exec.ts"() {
10532
10741
  "use strict";
10533
10742
  import_node_child_process22 = require("child_process");
10534
- import_node_crypto7 = require("crypto");
10743
+ import_node_crypto8 = require("crypto");
10535
10744
  init_devpod_environment();
10536
10745
  init_devpod_workspaces();
10537
10746
  init_devsy_exec();
@@ -12356,7 +12565,7 @@ function sanitizeRouterId(value) {
12356
12565
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
12357
12566
  }
12358
12567
  function repoHash(repoPath) {
12359
- return (0, import_node_crypto8.createHash)("sha1").update(import_node_path28.default.resolve(repoPath)).digest("hex").slice(0, 12);
12568
+ return (0, import_node_crypto9.createHash)("sha1").update(import_node_path28.default.resolve(repoPath)).digest("hex").slice(0, 12);
12360
12569
  }
12361
12570
  function asDockerApp(app) {
12362
12571
  return app.runtime === "docker";
@@ -12559,12 +12768,12 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
12559
12768
  const port = Number(match[1]);
12560
12769
  return Number.isInteger(port) && port > 0 ? port : void 0;
12561
12770
  }
12562
- var import_node_child_process25, import_node_crypto8, import_node_fs28, import_node_path28, import_yaml7;
12771
+ var import_node_child_process25, import_node_crypto9, import_node_fs28, import_node_path28, import_yaml7;
12563
12772
  var init_docker_run = __esm({
12564
12773
  "src/core/docker-run.ts"() {
12565
12774
  "use strict";
12566
12775
  import_node_child_process25 = require("child_process");
12567
- import_node_crypto8 = require("crypto");
12776
+ import_node_crypto9 = require("crypto");
12568
12777
  import_node_fs28 = __toESM(require("fs"));
12569
12778
  import_node_path28 = __toESM(require("path"));
12570
12779
  import_yaml7 = __toESM(require("yaml"));
@@ -14359,7 +14568,7 @@ var init_version = __esm({
14359
14568
 
14360
14569
  // src/cli.ts
14361
14570
  var import_commander = require("commander");
14362
- var CLI_VERSION = true ? "0.0.44" : "0.0.0-dev";
14571
+ var CLI_VERSION = true ? "0.0.45" : "0.0.0-dev";
14363
14572
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
14364
14573
  function withErrorHandling(action2) {
14365
14574
  return async (...args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.44",
3
+ "version": "0.0.45",
4
4
  "description": "Local dev routing CLI with shared Traefik reverse proxy",
5
5
  "author": "Roland Schlaefli",
6
6
  "homepage": "https://github.com/rschlaefli/devrouter#readme",
@@ -0,0 +1,33 @@
1
+ # Upgrade to devrouter 0.0.45
2
+
3
+ Parallel linked worktrees now claim collision-safe workspace identities across
4
+ mixed DevPod and Devsy fleets.
5
+
6
+ 1. Install `@devrouter/cli@0.0.45` on the host and bump `.devrouter.yml` to
7
+ `devrouter.version: 0.0.45`.
8
+ 2. Keep using `devrouter ensure <checkout>` or `devrouter workspace up
9
+ <branch>`. Do not derive workspace ids or worktree paths in wrapper scripts.
10
+ 3. Existing persisted workspace identities stay unchanged. A genuinely new
11
+ checkout keeps its readable branch/path identity when free and receives a
12
+ deterministic hash-suffixed fallback only when another checkout or provider
13
+ already occupies that identity.
14
+ 4. Treat an unreadable DevPod or Devsy registry as a startup blocker for a new
15
+ claim. Devrouter fails before provider, process, service, or route mutation.
16
+
17
+ Verification:
18
+
19
+ - Run `devrouter -V --repo <checkout>` and confirm the installed CLI and local
20
+ repository report `0.0.45`.
21
+ - Start two new linked checkouts whose branch names share the first 32
22
+ sanitized characters, one with DevPod and one with Devsy.
23
+ - Require distinct persisted workspace ids, exact provider registrations, and
24
+ namespaced routes. Stop each checkout with `devrouter stop <checkout>` and
25
+ require both route sets to disappear.
26
+
27
+ Report template:
28
+
29
+ - CLI/config version: `0.0.45`
30
+ - Colliding-prefix identities: `<passed or details>`
31
+ - DevPod/Devsy ownership: `<passed or details>`
32
+ - Exact stop and route release: `<passed or details>`
33
+ - Remaining blockers: `<none or details>`