@devrouter/cli 0.0.43 → 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.43" : "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 [];
@@ -5964,6 +5985,9 @@ function parseCertificateDnsHosts(pem) {
5964
5985
  function isHostCoveredByCertificateHost(host, certificateHost) {
5965
5986
  const normalizedHost = normalizeHost(host);
5966
5987
  const normalizedCertificateHost = normalizeHost(certificateHost);
5988
+ if (normalizedHost === normalizedCertificateHost) {
5989
+ return true;
5990
+ }
5967
5991
  if (normalizedCertificateHost.startsWith("*.")) {
5968
5992
  if (normalizedCertificateHost === "*.localhost") {
5969
5993
  return false;
@@ -5975,7 +5999,7 @@ function isHostCoveredByCertificateHost(host, certificateHost) {
5975
5999
  const wildcardPart = normalizedHost.slice(0, normalizedHost.length - suffix.length);
5976
6000
  return wildcardPart.length > 0 && !wildcardPart.includes(".");
5977
6001
  }
5978
- return normalizedHost === normalizedCertificateHost;
6002
+ return false;
5979
6003
  }
5980
6004
  function findUncoveredCertificateHosts(requiredHosts, certificateHosts) {
5981
6005
  const normalizedRequired = normalizeUniqueHosts(requiredHosts);
@@ -5986,22 +6010,54 @@ function findUncoveredCertificateHosts(requiredHosts, certificateHosts) {
5986
6010
  )
5987
6011
  );
5988
6012
  }
5989
- function readCurrentCertificateHosts() {
6013
+ function compactTLSCertificateHosts(hosts) {
6014
+ const normalizedHosts = normalizeUniqueHosts(hosts);
6015
+ const wildcardHosts = new Set(normalizedHosts.filter((host) => host.startsWith("*.")));
6016
+ const siblingGroups = /* @__PURE__ */ new Map();
6017
+ const compacted = new Set(wildcardHosts);
6018
+ for (const host of normalizedHosts) {
6019
+ if (host.startsWith("*.")) {
6020
+ continue;
6021
+ }
6022
+ const labels = host.split(".");
6023
+ if (labels.length < 3 || labels.at(-1) !== "localhost") {
6024
+ compacted.add(host);
6025
+ continue;
6026
+ }
6027
+ const suffix = labels.slice(1).join(".");
6028
+ const siblings = siblingGroups.get(suffix) ?? [];
6029
+ siblings.push(host);
6030
+ siblingGroups.set(suffix, siblings);
6031
+ }
6032
+ for (const [suffix, siblings] of siblingGroups) {
6033
+ const wildcard = `*.${suffix}`;
6034
+ if (siblings.length > 1 || wildcardHosts.has(wildcard)) {
6035
+ compacted.add(wildcard);
6036
+ continue;
6037
+ }
6038
+ compacted.add(siblings[0]);
6039
+ }
6040
+ const selectedWildcards = Array.from(compacted).filter((host) => host.startsWith("*."));
6041
+ return normalizeUniqueHosts(Array.from(compacted)).filter(
6042
+ (host) => host.startsWith("*.") || !selectedWildcards.some((wildcard) => isHostCoveredByCertificateHost(host, wildcard))
6043
+ );
6044
+ }
6045
+ function readCurrentCertificateHosts(options = {}) {
5990
6046
  if (!import_node_fs16.default.existsSync(CERT_FILE)) {
5991
6047
  return [];
5992
6048
  }
5993
- const pem = import_node_fs16.default.readFileSync(CERT_FILE, "utf-8");
5994
- return parseCertificateDnsHosts(pem);
5995
- }
5996
- function currentCertificateHostsOrEmpty() {
5997
6049
  try {
5998
- return readCurrentCertificateHosts();
5999
- } catch {
6000
- return [];
6050
+ const pem = import_node_fs16.default.readFileSync(CERT_FILE, "utf-8");
6051
+ return parseCertificateDnsHosts(pem);
6052
+ } catch (error) {
6053
+ if (options.replaceMalformed) {
6054
+ return [];
6055
+ }
6056
+ throw error;
6001
6057
  }
6002
6058
  }
6003
6059
  function buildDesiredTLSCertificateHosts(requestedHosts, existingCertificateHosts) {
6004
- return normalizeUniqueHosts([
6060
+ return compactTLSCertificateHosts([
6005
6061
  ...DEFAULT_TLS_CERT_HOSTS,
6006
6062
  ...existingCertificateHosts,
6007
6063
  ...requestedHosts
@@ -6009,35 +6065,75 @@ function buildDesiredTLSCertificateHosts(requestedHosts, existingCertificateHost
6009
6065
  }
6010
6066
  function getTLSHostCoverage(hosts) {
6011
6067
  const requiredHosts = normalizeUniqueHosts([...DEFAULT_TLS_CERT_HOSTS, ...hosts]);
6012
- const certificateHosts = readCurrentCertificateHosts();
6013
- const uncoveredHosts = findUncoveredCertificateHosts(requiredHosts, certificateHosts);
6014
- return {
6015
- requiredHosts,
6016
- certificateHosts,
6017
- uncoveredHosts
6018
- };
6068
+ return withFileLockSync(
6069
+ TLS_CERTIFICATE_LOCK_FILE,
6070
+ { activity: "TLS certificate inspection", waitMs: TLS_CERTIFICATE_LOCK_WAIT_MS },
6071
+ () => {
6072
+ const certificateHosts = readCurrentCertificateHosts();
6073
+ const uncoveredHosts = findUncoveredCertificateHosts(requiredHosts, certificateHosts);
6074
+ return {
6075
+ requiredHosts,
6076
+ certificateHosts,
6077
+ uncoveredHosts
6078
+ };
6079
+ }
6080
+ );
6019
6081
  }
6020
6082
  async function applyTLSCertificate(options, installTrust) {
6021
6083
  ensureRouterFiles();
6022
- const alreadyEnabled = isTLSEnabled();
6023
- const desiredHosts = buildDesiredTLSCertificateHosts(
6024
- options.hosts ?? [],
6025
- currentCertificateHostsOrEmpty()
6026
- );
6027
- if (installTrust) {
6028
- ensureMkcert();
6029
- runOrThrow("mkcert", ["-install"]);
6030
- } else {
6031
- getMkcertRootCAPath({ repoPath: options.repoPath });
6032
- }
6033
- runOrThrow("mkcert", ["-cert-file", CERT_FILE, "-key-file", CERT_KEY_FILE, ...desiredHosts]);
6034
- setTLSEnabled(true);
6035
- refreshHostRoutesDynamicFile();
6084
+ const result = withFileLockSync(
6085
+ TLS_CERTIFICATE_LOCK_FILE,
6086
+ {
6087
+ activity: "TLS certificate refresh",
6088
+ target: options.repoPath,
6089
+ waitMs: TLS_CERTIFICATE_LOCK_WAIT_MS
6090
+ },
6091
+ () => {
6092
+ const alreadyEnabled = isTLSEnabled();
6093
+ if (installTrust) {
6094
+ ensureMkcert();
6095
+ runOrThrow("mkcert", ["-install"]);
6096
+ } else {
6097
+ getMkcertRootCAPath({ repoPath: options.repoPath });
6098
+ }
6099
+ const existingCertificateHosts = readCurrentCertificateHosts({
6100
+ replaceMalformed: installTrust
6101
+ });
6102
+ const desiredHosts = buildDesiredTLSCertificateHosts(
6103
+ options.hosts ?? [],
6104
+ existingCertificateHosts
6105
+ );
6106
+ let certificateHosts = [];
6107
+ let uncoveredHosts = [];
6108
+ for (let attempt = 1; attempt <= TLS_CERTIFICATE_WRITE_ATTEMPTS; attempt += 1) {
6109
+ runOrThrow("mkcert", [
6110
+ "-cert-file",
6111
+ CERT_FILE,
6112
+ "-key-file",
6113
+ CERT_KEY_FILE,
6114
+ ...desiredHosts
6115
+ ]);
6116
+ certificateHosts = readCurrentCertificateHosts();
6117
+ uncoveredHosts = findUncoveredCertificateHosts(desiredHosts, certificateHosts);
6118
+ if (uncoveredHosts.length === 0) {
6119
+ break;
6120
+ }
6121
+ }
6122
+ if (uncoveredHosts.length > 0) {
6123
+ throw new Error(
6124
+ `mkcert did not produce certificate coverage for host(s): ${uncoveredHosts.join(", ")} after ${TLS_CERTIFICATE_WRITE_ATTEMPTS} attempts`
6125
+ );
6126
+ }
6127
+ setTLSEnabled(true);
6128
+ refreshHostRoutesDynamicFile();
6129
+ return { alreadyEnabled, hosts: certificateHosts };
6130
+ }
6131
+ );
6036
6132
  const routerContainer = await findContainerByName("devrouter-traefik");
6037
6133
  if (routerContainer && await isContainerRunning("devrouter-traefik")) {
6038
6134
  startRouterStack();
6039
6135
  }
6040
- return { alreadyEnabled, hosts: desiredHosts };
6136
+ return result;
6041
6137
  }
6042
6138
  async function installTLS(options = {}) {
6043
6139
  return applyTLSCertificate(options, true);
@@ -6079,18 +6175,22 @@ Run: ${tlsSetupCommand(options.repoPath)}`
6079
6175
  );
6080
6176
  }
6081
6177
  }
6082
- var import_node_child_process8, import_node_crypto6, import_node_fs16, import_node_path15, DEFAULT_TLS_CERT_HOSTS;
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;
6083
6179
  var init_tls = __esm({
6084
6180
  "src/core/tls.ts"() {
6085
6181
  "use strict";
6086
6182
  import_node_child_process8 = require("child_process");
6087
- import_node_crypto6 = require("crypto");
6183
+ import_node_crypto7 = require("crypto");
6088
6184
  import_node_fs16 = __toESM(require("fs"));
6089
6185
  import_node_path15 = __toESM(require("path"));
6090
6186
  init_docker();
6187
+ init_file_lock();
6091
6188
  init_host_routes();
6092
6189
  init_router();
6093
6190
  DEFAULT_TLS_CERT_HOSTS = ["localhost", "*.localhost"];
6191
+ TLS_CERTIFICATE_LOCK_FILE = import_node_path15.default.join(DEVROUTER_HOME, "tls-certificate.lock");
6192
+ TLS_CERTIFICATE_LOCK_WAIT_MS = 6e4;
6193
+ TLS_CERTIFICATE_WRITE_ATTEMPTS = 2;
6094
6194
  }
6095
6195
  });
6096
6196
 
@@ -7376,12 +7476,16 @@ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
7376
7476
  import_node_fs21.default.rmSync(filePath);
7377
7477
  return "removed";
7378
7478
  }
7379
- function withWorkspaceOwnershipTransaction(repoPath, operation) {
7479
+ function withWorkspaceOwnershipTransaction(repoPath, operation, options = {}) {
7380
7480
  const directory = ownershipDirectory(repoPath);
7381
7481
  import_node_fs21.default.mkdirSync(directory, { recursive: true });
7382
7482
  return withFileLockSync(
7383
7483
  import_node_path20.default.join(directory, ".lock"),
7384
- { activity: "workspace ownership transaction", target: `'${repoPath}'`, waitMs: 5e3 },
7484
+ {
7485
+ activity: "workspace ownership transaction",
7486
+ target: `'${repoPath}'`,
7487
+ waitMs: options.waitMs ?? 5e3
7488
+ },
7385
7489
  () => operation({
7386
7490
  list: () => listWorkspaceOwnershipInDirectory(directory),
7387
7491
  write: (input2) => writeWorkspaceOwnershipInDirectory(directory, input2),
@@ -7390,8 +7494,169 @@ function withWorkspaceOwnershipTransaction(repoPath, operation) {
7390
7494
  })
7391
7495
  );
7392
7496
  }
7393
- function writeWorkspaceOwnership(repoPath, input2) {
7394
- 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
+ });
7395
7660
  }
7396
7661
  function removeWorkspaceOwnership(repoPath, workspace) {
7397
7662
  return withWorkspaceOwnershipTransaction(
@@ -8074,7 +8339,7 @@ async function buildDoctorReport(options = {}) {
8074
8339
  const config = runtimeConfig.config;
8075
8340
  loadedConfig = config;
8076
8341
  loadedWorkspace = runtimeConfig.workspace;
8077
- const cliVersion = true ? "0.0.43" : "0.0.0-dev";
8342
+ const cliVersion = true ? "0.0.45" : "0.0.0-dev";
8078
8343
  const configVersion = config.devrouter?.version;
8079
8344
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
8080
8345
  addCheck(checks, {
@@ -9150,25 +9415,30 @@ async function waitForHttpRoutes(repoPath, apps, timeoutMs) {
9150
9415
  );
9151
9416
  }
9152
9417
  function resolveLinkedTarget(repoPath) {
9153
- const devpods = listDevpodWorkspaces(repoPath);
9154
- const existingDevpod = selectDevpodWorkspace(devpods, repoPath);
9155
- const persisted = readPersistedWorkspace(repoPath);
9156
- const candidate = existingDevpod?.id ?? persisted ?? resolveWorktreeWorkspace(repoPath);
9157
- if (!candidate) {
9158
- throw new Error(`Could not resolve a workspace identity for '${repoPath}'.`);
9159
- }
9160
- const otherOwner = devpods.find(
9161
- (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)
9162
9437
  );
9163
- if (otherOwner) {
9164
- throw new Error(
9165
- `DevPod identity '${candidate}' already belongs to '${otherOwner.source.localFolder}'.`
9166
- );
9167
- }
9168
9438
  return {
9169
9439
  kind: "linked",
9170
- workspace: persistWorkspace(repoPath, candidate),
9171
- devpodId: candidate,
9440
+ workspace: claim.workspace,
9441
+ devpodId: claim.devpodId,
9172
9442
  hadExactDevpod: Boolean(existingDevpod),
9173
9443
  gitCommonDir: resolveGitCommonDir(repoPath)
9174
9444
  };
@@ -9315,15 +9585,6 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
9315
9585
  managedConfigWritten = true;
9316
9586
  }
9317
9587
  const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host);
9318
- const ownership = target.kind === "linked" ? {
9319
- workspace: target.workspace,
9320
- worktreePath: repoPath,
9321
- branch: currentBranch(repoPath),
9322
- devpodId: target.devpodId
9323
- } : void 0;
9324
- if (ownership) {
9325
- writeWorkspaceOwnership(repoPath, ownership);
9326
- }
9327
9588
  const currentTarget = () => target.kind === "linked" ? target : { ...target, devpodId };
9328
9589
  const startAndProveAttachment = (recreate = false) => {
9329
9590
  const requestedTarget = currentTarget();
@@ -9346,9 +9607,6 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
9346
9607
  if (error instanceof DevpodStartPostconditionError) environmentStarted = true;
9347
9608
  throw error;
9348
9609
  }
9349
- if (ownership) {
9350
- writeWorkspaceOwnership(repoPath, ownership);
9351
- }
9352
9610
  };
9353
9611
  const preflight = (timeoutMs) => waitForContainerPreflight(repoPath, currentTarget(), upstreamHosts, timeoutMs);
9354
9612
  const recreateAndPreflight = async () => {
@@ -9766,6 +10024,7 @@ var init_workspace_ensure = __esm({
9766
10024
  init_router();
9767
10025
  init_workspace();
9768
10026
  init_workspace_ownership();
10027
+ init_workspace_runtime();
9769
10028
  DEVCONTAINER_OVERLAY = "docker-compose.devrouter.yml";
9770
10029
  DEFAULT_READINESS_TIMEOUT_MS = 12e4;
9771
10030
  POLL_INTERVAL_MS = 1e3;
@@ -9822,6 +10081,11 @@ var init_ensure = __esm({
9822
10081
  });
9823
10082
 
9824
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
+ }
9825
10089
  function warnMissingWorkspaceOwnership(repoPath) {
9826
10090
  const missing = listMissingWorkspaceOwnership(repoPath);
9827
10091
  if (missing.length === 0) return;
@@ -9983,44 +10247,67 @@ async function workspaceUp(branch, opts = {}) {
9983
10247
  if (!ws) {
9984
10248
  throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
9985
10249
  }
9986
- const worktreePath = opts.path ? import_node_path24.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
9987
- if (import_node_fs24.default.existsSync(worktreePath)) {
9988
- const registered = listGitWorktrees(mainRepo).find(
9989
- (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)
9990
10261
  );
9991
- if (!registered || sameWorkspacePath(registered.path, mainRepo)) {
9992
- throw new Error(`Existing path '${worktreePath}' is not a linked worktree of '${mainRepo}'.`);
10262
+ const generatedPath = candidatePaths.find(
10263
+ (candidate) => !import_node_fs24.default.existsSync(candidate) && !worktrees.some((worktree) => sameWorkspacePath(worktree.path, candidate))
10264
+ );
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}'.`);
9993
10268
  }
9994
- if (registered.branch !== branch) {
10269
+ if (existingBranch && !sameWorkspacePath(existingBranch.path, selectedPath)) {
9995
10270
  throw new Error(
9996
- `Existing worktree '${worktreePath}' uses branch '${registered.branch ?? "detached"}', not '${branch}'.`
10271
+ `Branch '${branch}' already uses worktree '${existingBranch.path}', not '${selectedPath}'.`
9997
10272
  );
9998
10273
  }
9999
- 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}
10000
10289
  `);
10001
- } else {
10002
- if (!opts.path) {
10003
- assertDefaultWorktreeRootIgnored(mainRepo);
10290
+ return selectedPath;
10004
10291
  }
10005
- 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], {
10006
10294
  encoding: "utf-8"
10007
10295
  });
10008
10296
  if (add.status !== 0) {
10009
10297
  const addNew = (0, import_node_child_process20.spawnSync)(
10010
10298
  "git",
10011
- ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath],
10012
- {
10013
- encoding: "utf-8"
10014
- }
10299
+ ["-C", mainRepo, "worktree", "add", "-b", branch, selectedPath],
10300
+ { encoding: "utf-8" }
10015
10301
  );
10016
10302
  if (addNew.status !== 0) {
10017
- 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("; ");
10018
10304
  throw new Error(`git worktree add failed: ${detail || "unknown error"}`);
10019
10305
  }
10020
10306
  }
10021
- process.stdout.write(`Created worktree ${worktreePath} (workspace '${ws}')
10307
+ process.stdout.write(`Created worktree ${selectedPath}
10022
10308
  `);
10023
- }
10309
+ return selectedPath;
10310
+ });
10024
10311
  if (opts.noDevpod) {
10025
10312
  warnMissingWorkspaceOwnership(mainRepo);
10026
10313
  process.stdout.write("Skipped environment startup; no routes were changed.\n");
@@ -10163,7 +10450,7 @@ async function workspaceStop(target, opts = {}) {
10163
10450
  async function workspaceDown(target, opts = {}) {
10164
10451
  return runWorkspaceLifecycle("down", target, opts);
10165
10452
  }
10166
- 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;
10167
10454
  var init_workspace_lifecycle = __esm({
10168
10455
  "src/core/workspace-lifecycle.ts"() {
10169
10456
  "use strict";
@@ -10177,6 +10464,7 @@ var init_workspace_lifecycle = __esm({
10177
10464
  init_workspace();
10178
10465
  init_workspace_ensure();
10179
10466
  init_workspace_ownership();
10467
+ WORKSPACE_ALLOCATION_LOCK_WAIT_MS = 6e4;
10180
10468
  }
10181
10469
  });
10182
10470
 
@@ -10370,7 +10658,7 @@ async function devpodExec(repoPath, command) {
10370
10658
  }
10371
10659
  assertRunningDevpod(devpod.id, repoPath);
10372
10660
  const workspaceDirectory = resolveWorkspaceDirectory(repoPath);
10373
- const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto7.randomUUID)()}__:`;
10661
+ const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto8.randomUUID)()}__:`;
10374
10662
  const statusMarkerBytes = Buffer.from(statusMarker, "ascii");
10375
10663
  const literalCommand = command.map(quotePosixArg).join(" ");
10376
10664
  const wrappedCommand = `${literalCommand}; __devrouter_status=$?; printf '${statusMarker}%s\\n' "$__devrouter_status" >&2; exit 0`;
@@ -10447,12 +10735,12 @@ async function devpodExec(repoPath, command) {
10447
10735
  });
10448
10736
  });
10449
10737
  }
10450
- 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;
10451
10739
  var init_devpod_exec = __esm({
10452
10740
  "src/core/devpod-exec.ts"() {
10453
10741
  "use strict";
10454
10742
  import_node_child_process22 = require("child_process");
10455
- import_node_crypto7 = require("crypto");
10743
+ import_node_crypto8 = require("crypto");
10456
10744
  init_devpod_environment();
10457
10745
  init_devpod_workspaces();
10458
10746
  init_devsy_exec();
@@ -12277,7 +12565,7 @@ function sanitizeRouterId(value) {
12277
12565
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
12278
12566
  }
12279
12567
  function repoHash(repoPath) {
12280
- 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);
12281
12569
  }
12282
12570
  function asDockerApp(app) {
12283
12571
  return app.runtime === "docker";
@@ -12480,12 +12768,12 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
12480
12768
  const port = Number(match[1]);
12481
12769
  return Number.isInteger(port) && port > 0 ? port : void 0;
12482
12770
  }
12483
- 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;
12484
12772
  var init_docker_run = __esm({
12485
12773
  "src/core/docker-run.ts"() {
12486
12774
  "use strict";
12487
12775
  import_node_child_process25 = require("child_process");
12488
- import_node_crypto8 = require("crypto");
12776
+ import_node_crypto9 = require("crypto");
12489
12777
  import_node_fs28 = __toESM(require("fs"));
12490
12778
  import_node_path28 = __toESM(require("path"));
12491
12779
  import_yaml7 = __toESM(require("yaml"));
@@ -14280,7 +14568,7 @@ var init_version = __esm({
14280
14568
 
14281
14569
  // src/cli.ts
14282
14570
  var import_commander = require("commander");
14283
- var CLI_VERSION = true ? "0.0.43" : "0.0.0-dev";
14571
+ var CLI_VERSION = true ? "0.0.45" : "0.0.0-dev";
14284
14572
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
14285
14573
  function withErrorHandling(action2) {
14286
14574
  return async (...args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.43",
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,30 @@
1
+ # Upgrade to devrouter 0.0.44
2
+
3
+ Shared local TLS certificate refresh now serializes inspection, generation,
4
+ verification, and route-configuration refresh across parallel worktrees.
5
+
6
+ 1. Install `@devrouter/cli@0.0.44` on the host and bump `.devrouter.yml` to
7
+ `devrouter.version: 0.0.44`.
8
+ 2. Keep using `devrouter ensure <checkout>` for primary and linked checkouts.
9
+ Do not run `mkcert` manually for individual worktree hosts; Devrouter now
10
+ preserves and compacts shared certificate coverage under its own lock.
11
+ 3. Do not remove the shared certificate or lock as routine cleanup. Routine
12
+ refresh still fails closed on a malformed certificate; the explicit
13
+ repository-scoped setup command printed by Devrouter replaces that generated
14
+ certificate while holding the same machine-global lock.
15
+
16
+ Verification:
17
+
18
+ - Run `devrouter -V --repo <checkout>` and confirm the installed CLI and local
19
+ repository report `0.0.44`.
20
+ - Start two linked checkouts concurrently with `devrouter ensure`; require both
21
+ namespaced HTTPS routes to pass readiness and remain covered afterward.
22
+ - Run `devrouter doctor --repo <checkout>` and confirm no TLS host-coverage
23
+ warning remains.
24
+
25
+ Report template:
26
+
27
+ - CLI/config version: `0.0.44`
28
+ - Parallel worktree routes: `<passed or details>`
29
+ - TLS host coverage: `<passed or details>`
30
+ - Remaining blockers: `<none or details>`
@@ -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>`