@devrouter/cli 0.0.34 → 0.0.35

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
@@ -156,6 +156,8 @@ apps:
156
156
  # to target a per-workspace devcontainer alias \u2014 substituted with the resolved
157
157
  # workspace token at runtime. See "Workspace isolation" below. Do NOT put
158
158
  # \${WORKSPACE} in \`host\` (rejected); the host is auto-namespaced.
159
+ # Managed \`ensure\` requires every HTTP/TCP upstream to begin with the exact
160
+ # resolved workspace/project alias prefix before DevPod or route mutation.
159
161
  #
160
162
  # proxy + tcp (front a DB in an externally-managed container, e.g. a
161
163
  # devcontainer's Postgres on devnet) \u2014 no per-DB host port:
@@ -239,10 +241,12 @@ Config-level \`envMap\` on dependency references aliases per-dep vars to app-exp
239
241
  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.
240
242
 
241
243
  - **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.
242
- - **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. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.
244
+ - **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.
243
245
  - **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.
244
246
  - **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>\`.
245
- - **Lifecycle**: after one-time \`setup\`, use \`ensure .\` for both primary and linked checkouts; never branch manually on checkout kind or use live verify as startup. \`stop .\` is non-destructive and \`exec . -- <command...>\` runs one-shot commands only in the exact running DevPod. \`workspace up\` creates linked worktrees; destructive \`workspace down/gc\` remains ledger-scoped. Dirty or locked full down fails before side effects.
247
+ - **Lifecycle**: after one-time \`setup\`, use \`ensure .\` for both primary and linked checkouts; never branch manually on checkout kind or use live verify as startup. \`stop .\` is non-destructive; \`stop . --delete\` is explicit exact-owner cleanup without worktree removal; and \`exec . -- <command...>\` runs one-shot commands only in the exact running DevPod. Never substitute raw \`devpod up\`, \`stop\`, or \`delete\`: they bypass devrouter's machine-global ownership lock. \`workspace up\` creates linked worktrees; destructive worktree removal and GC remain ledger-scoped. Dirty or locked full down fails before side effects.
248
+ - **Managed process identity**: \`ensure\` executes an exact captured adapter snapshot. Default reuse includes command argv, workspace, and adapter SHA-256. Set \`DEVROUTER_PROCESS_FINGERPRINT_ENV\` only to comma-separated non-secret environment names whose values affect runtime identity; secret-like names are rejected and raw values are never persisted.
249
+ - **Route state**: the versioned Traefik dynamic file is authoritative for both metadata and rendering. JSON is a compatibility mirror; valid headerless generations migrate automatically, while corrupt canonical metadata fails closed.
246
250
  - **Cleanup**: owner status is \`present\`, \`missing\`, \`locked\`, or \`conflict\`. \`workspace gc\` is a dry-run report; \`--yes\` deletes only exact ledger-owned missing/prunable resources and their records. GC never removes Git worktrees, branches, or prune state. Git has no worktree-removal hook.
247
251
  - **Boundary**: workspace commands require Git. Normal config, app, status, and doctor flows remain usable from a \`.devrouter.yml\` folder without \`.git\`.
248
252
 
@@ -295,7 +299,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
295
299
  - \`devrouter upgrade [version] [--repo .]\`: list upgrade targets or print target Agent Adaptation Prompt
296
300
  - \`devrouter setup --yes [--repo .] [--json]\`: first-run machine setup plus structured diagnostics
297
301
  - \`devrouter ensure [path] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts
298
- - \`devrouter stop [path] [--json]\`: stop the exact DevPod and remove exact routes without deleting data
302
+ - \`devrouter stop [path] [--delete] [--json]\`: stop the exact DevPod and remove exact routes; \`--delete\` explicitly deletes its ownership-proven data without removing the checkout
299
303
  - \`devrouter exec [path] -- <command...>\`: literal one-shot command inside the exact running DevPod
300
304
  - \`devrouter up\` / \`devrouter down\`: start/stop shared Traefik router
301
305
  - \`devrouter status\`: router/container/network/TLS health
@@ -721,6 +725,51 @@ var init_capabilities = __esm({
721
725
  }
722
726
  });
723
727
 
728
+ // src/core/atomic-file.ts
729
+ function fsyncDirectory(directory) {
730
+ const handle = import_node_fs3.default.openSync(directory, "r");
731
+ try {
732
+ import_node_fs3.default.fsyncSync(handle);
733
+ } finally {
734
+ import_node_fs3.default.closeSync(handle);
735
+ }
736
+ }
737
+ function writeFileAtomically(filePath, contents) {
738
+ const directory = import_node_path3.default.dirname(filePath);
739
+ import_node_fs3.default.mkdirSync(directory, { recursive: true });
740
+ const temporaryPath = import_node_path3.default.join(
741
+ directory,
742
+ `.${import_node_path3.default.basename(filePath)}.${process.pid}.${(0, import_node_crypto.randomUUID)()}.tmp`
743
+ );
744
+ let temporaryHandle;
745
+ try {
746
+ temporaryHandle = import_node_fs3.default.openSync(temporaryPath, "wx", 384);
747
+ import_node_fs3.default.writeFileSync(temporaryHandle, contents, "utf-8");
748
+ import_node_fs3.default.fsyncSync(temporaryHandle);
749
+ import_node_fs3.default.closeSync(temporaryHandle);
750
+ temporaryHandle = void 0;
751
+ import_node_fs3.default.renameSync(temporaryPath, filePath);
752
+ fsyncDirectory(directory);
753
+ } catch (error) {
754
+ if (temporaryHandle !== void 0) import_node_fs3.default.closeSync(temporaryHandle);
755
+ try {
756
+ import_node_fs3.default.unlinkSync(temporaryPath);
757
+ } catch (cleanupError) {
758
+ if (cleanupError.code !== "ENOENT") throw cleanupError;
759
+ }
760
+ throw error;
761
+ }
762
+ }
763
+ var import_node_crypto, import_node_fs3, import_node_path3;
764
+ var init_atomic_file = __esm({
765
+ "src/core/atomic-file.ts"() {
766
+ "use strict";
767
+ import_node_crypto = require("crypto");
768
+ import_node_fs3 = __toESM(require("fs"));
769
+ import_node_path3 = __toESM(require("path"));
770
+ }
771
+ });
772
+
724
773
  // src/core/file-lock.ts
725
774
  function sleepSync(ms) {
726
775
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -732,17 +781,56 @@ function isProcessAlive(pid) {
732
781
  try {
733
782
  process.kill(pid, 0);
734
783
  return true;
784
+ } catch (error) {
785
+ return error.code === "EPERM";
786
+ }
787
+ }
788
+ function processBirthIdentity(pid) {
789
+ try {
790
+ const stat = import_node_fs4.default.readFileSync(`/proc/${pid}/stat`, "utf-8");
791
+ const commandEnd = stat.lastIndexOf(")");
792
+ if (commandEnd >= 0) {
793
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
794
+ const startTime = fields[19];
795
+ if (startTime) return `proc:${startTime}`;
796
+ }
735
797
  } catch {
736
- return false;
798
+ }
799
+ const result = (0, import_node_child_process2.spawnSync)("ps", ["-o", "lstart=", "-o", "command=", "-p", String(pid)], {
800
+ encoding: "utf-8",
801
+ env: { ...process.env, LC_ALL: "C" }
802
+ });
803
+ const startedAt = result.status === 0 ? result.stdout.trim().replace(/\s+/g, " ") : "";
804
+ return startedAt ? `ps:${(0, import_node_crypto2.createHash)("sha256").update(startedAt).digest("hex")}` : void 0;
805
+ }
806
+ function parseLockOwner(value) {
807
+ const fields = value.split(":");
808
+ const pid = Number(fields[0]);
809
+ if (!Number.isInteger(pid) || pid <= 0) return void 0;
810
+ if (fields.length !== 3 || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(fields[2])) {
811
+ return { pid };
812
+ }
813
+ try {
814
+ const processBirth = Buffer.from(fields[1], "base64url").toString("utf-8");
815
+ const canonical = Buffer.from(processBirth).toString("base64url");
816
+ return canonical === fields[1] && /^(proc|ps):/.test(processBirth) ? { pid, processBirth } : { pid };
817
+ } catch {
818
+ return { pid };
737
819
  }
738
820
  }
821
+ function isLockOwnerLive(owner) {
822
+ if (!isProcessAlive(owner.pid)) return false;
823
+ if (!owner.processBirth) return true;
824
+ const currentBirth = processBirthIdentity(owner.pid);
825
+ return currentBirth === void 0 || currentBirth === owner.processBirth;
826
+ }
739
827
  function sameFile(left, right) {
740
828
  return left.dev === right.dev && left.ino === right.ino;
741
829
  }
742
830
  function tryReclaimStaleLock(lockPath, staleLinkPath) {
743
831
  let fd;
744
832
  try {
745
- fd = import_node_fs3.default.openSync(lockPath, "r");
833
+ fd = import_node_fs4.default.openSync(lockPath, "r");
746
834
  } catch (error) {
747
835
  if (error.code === "ENOENT") {
748
836
  return { kind: "retry" };
@@ -750,17 +838,16 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
750
838
  throw error;
751
839
  }
752
840
  try {
753
- const owner = import_node_fs3.default.readFileSync(fd, "utf-8").trim();
754
- const ownerPid = Number(owner.split(":", 1)[0]);
755
- if (isProcessAlive(ownerPid)) {
756
- return { kind: "live", pid: ownerPid };
841
+ const owner = parseLockOwner(import_node_fs4.default.readFileSync(fd, "utf-8").trim());
842
+ if (owner && isLockOwnerLive(owner)) {
843
+ return { kind: "live", pid: owner.pid };
757
844
  }
758
- const staleStat = import_node_fs3.default.fstatSync(fd);
845
+ const staleStat = import_node_fs4.default.fstatSync(fd);
759
846
  if (staleStat.nlink !== 1) {
760
847
  return { kind: "retry" };
761
848
  }
762
849
  try {
763
- import_node_fs3.default.linkSync(lockPath, staleLinkPath);
850
+ import_node_fs4.default.linkSync(lockPath, staleLinkPath);
764
851
  } catch (error) {
765
852
  if (error.code === "ENOENT") {
766
853
  return { kind: "retry" };
@@ -770,7 +857,7 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
770
857
  try {
771
858
  let currentStat;
772
859
  try {
773
- currentStat = import_node_fs3.default.statSync(lockPath);
860
+ currentStat = import_node_fs4.default.statSync(lockPath);
774
861
  } catch (error) {
775
862
  if (error.code === "ENOENT") {
776
863
  return { kind: "retry" };
@@ -780,27 +867,32 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
780
867
  if (!sameFile(staleStat, currentStat) || currentStat.nlink !== 2) {
781
868
  return { kind: "retry" };
782
869
  }
783
- import_node_fs3.default.rmSync(lockPath);
870
+ import_node_fs4.default.rmSync(lockPath);
784
871
  return { kind: "reclaimed" };
785
872
  } finally {
786
- import_node_fs3.default.rmSync(staleLinkPath, { force: true });
873
+ import_node_fs4.default.rmSync(staleLinkPath, { force: true });
787
874
  }
788
875
  } finally {
789
- import_node_fs3.default.closeSync(fd);
876
+ import_node_fs4.default.closeSync(fd);
790
877
  }
791
878
  }
792
879
  function acquireFileLock(lockPath, options) {
793
- const owner = `${process.pid}:${(0, import_node_crypto.randomUUID)()}`;
794
- const candidatePath = `${lockPath}.${owner}.candidate`;
880
+ const processBirth = processBirthIdentity(process.pid);
881
+ if (!processBirth) {
882
+ throw new Error(`could not determine process identity for ${options.activity} lock`);
883
+ }
884
+ const ownerId = (0, import_node_crypto2.randomUUID)();
885
+ const owner = `${process.pid}:${Buffer.from(processBirth).toString("base64url")}:${ownerId}`;
886
+ const candidatePath = `${lockPath}.${process.pid}.${ownerId}.candidate`;
795
887
  const staleLinkPath = `${candidatePath}.stale`;
796
888
  const deadline = Date.now() + (options.waitMs ?? 0);
797
889
  let reclaimAttempts = 0;
798
- import_node_fs3.default.writeFileSync(candidatePath, `${owner}
890
+ import_node_fs4.default.writeFileSync(candidatePath, `${owner}
799
891
  `, { encoding: "utf-8", flag: "wx" });
800
892
  try {
801
893
  for (; ; ) {
802
894
  try {
803
- import_node_fs3.default.linkSync(candidatePath, lockPath);
895
+ import_node_fs4.default.linkSync(candidatePath, lockPath);
804
896
  return owner;
805
897
  } catch (error) {
806
898
  if (error.code !== "EEXIST") {
@@ -828,14 +920,14 @@ function acquireFileLock(lockPath, options) {
828
920
  }
829
921
  }
830
922
  } finally {
831
- import_node_fs3.default.rmSync(candidatePath, { force: true });
832
- import_node_fs3.default.rmSync(staleLinkPath, { force: true });
923
+ import_node_fs4.default.rmSync(candidatePath, { force: true });
924
+ import_node_fs4.default.rmSync(staleLinkPath, { force: true });
833
925
  }
834
926
  }
835
927
  function releaseFileLock(lockPath, owner) {
836
928
  try {
837
- if (import_node_fs3.default.readFileSync(lockPath, "utf-8").trim() === owner) {
838
- import_node_fs3.default.rmSync(lockPath);
929
+ if (import_node_fs4.default.readFileSync(lockPath, "utf-8").trim() === owner) {
930
+ import_node_fs4.default.rmSync(lockPath);
839
931
  }
840
932
  } catch (error) {
841
933
  if (error.code !== "ENOENT") {
@@ -859,20 +951,21 @@ async function withFileLock(lockPath, options, operation) {
859
951
  releaseFileLock(lockPath, owner);
860
952
  }
861
953
  }
862
- var import_node_crypto, import_node_fs3;
954
+ var import_node_child_process2, import_node_crypto2, import_node_fs4;
863
955
  var init_file_lock = __esm({
864
956
  "src/core/file-lock.ts"() {
865
957
  "use strict";
866
- import_node_crypto = require("crypto");
867
- import_node_fs3 = __toESM(require("fs"));
958
+ import_node_child_process2 = require("child_process");
959
+ import_node_crypto2 = require("crypto");
960
+ import_node_fs4 = __toESM(require("fs"));
868
961
  }
869
962
  });
870
963
 
871
964
  // src/core/workspace.ts
872
965
  function comparableWorkspacePath(filePath) {
873
- const resolved = import_node_path3.default.resolve(filePath);
966
+ const resolved = import_node_path4.default.resolve(filePath);
874
967
  try {
875
- return import_node_fs4.default.realpathSync.native(resolved);
968
+ return import_node_fs5.default.realpathSync.native(resolved);
876
969
  } catch {
877
970
  return resolved;
878
971
  }
@@ -885,10 +978,10 @@ function wsFromBranch(branch) {
885
978
  return slug.length > 0 ? slug : void 0;
886
979
  }
887
980
  function isLinkedWorktree(repoPath) {
888
- const gitPath = import_node_path3.default.join(repoPath, ".git");
981
+ const gitPath = import_node_path4.default.join(repoPath, ".git");
889
982
  let stat;
890
983
  try {
891
- stat = import_node_fs4.default.statSync(gitPath);
984
+ stat = import_node_fs5.default.statSync(gitPath);
892
985
  } catch {
893
986
  return false;
894
987
  }
@@ -897,7 +990,7 @@ function isLinkedWorktree(repoPath) {
897
990
  }
898
991
  let content;
899
992
  try {
900
- content = import_node_fs4.default.readFileSync(gitPath, "utf-8");
993
+ content = import_node_fs5.default.readFileSync(gitPath, "utf-8");
901
994
  } catch {
902
995
  return false;
903
996
  }
@@ -909,10 +1002,10 @@ function isLinkedWorktree(repoPath) {
909
1002
  return /(^|\/)worktrees\//.test(gitdir);
910
1003
  }
911
1004
  function gitMetadataDir(repoPath) {
912
- const gitPath = import_node_path3.default.join(repoPath, ".git");
1005
+ const gitPath = import_node_path4.default.join(repoPath, ".git");
913
1006
  let stat;
914
1007
  try {
915
- stat = import_node_fs4.default.statSync(gitPath);
1008
+ stat = import_node_fs5.default.statSync(gitPath);
916
1009
  } catch {
917
1010
  return void 0;
918
1011
  }
@@ -922,11 +1015,11 @@ function gitMetadataDir(repoPath) {
922
1015
  if (!stat.isFile()) {
923
1016
  return void 0;
924
1017
  }
925
- const match = import_node_fs4.default.readFileSync(gitPath, "utf-8").match(/^gitdir:\s*(.+)$/m);
1018
+ const match = import_node_fs5.default.readFileSync(gitPath, "utf-8").match(/^gitdir:\s*(.+)$/m);
926
1019
  if (!match) {
927
1020
  return void 0;
928
1021
  }
929
- return import_node_path3.default.resolve(repoPath, match[1].trim());
1022
+ return import_node_path4.default.resolve(repoPath, match[1].trim());
930
1023
  }
931
1024
  function validatePersistedWorkspace(value) {
932
1025
  const workspace = value.trim();
@@ -940,9 +1033,9 @@ function readPersistedWorkspace(repoPath) {
940
1033
  if (!gitDir) {
941
1034
  return void 0;
942
1035
  }
943
- const metadataPath = import_node_path3.default.join(gitDir, WORKSPACE_METADATA_FILE);
1036
+ const metadataPath = import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE);
944
1037
  try {
945
- return validatePersistedWorkspace(import_node_fs4.default.readFileSync(metadataPath, "utf-8"));
1038
+ return validatePersistedWorkspace(import_node_fs5.default.readFileSync(metadataPath, "utf-8"));
946
1039
  } catch (error) {
947
1040
  if (error.code === "ENOENT") {
948
1041
  return void 0;
@@ -965,7 +1058,7 @@ function persistWorkspace(repoPath, value) {
965
1058
  if (!gitDir) {
966
1059
  throw new Error(`cannot persist workspace identity: '${repoPath}' is not a Git checkout`);
967
1060
  }
968
- import_node_fs4.default.writeFileSync(import_node_path3.default.join(gitDir, WORKSPACE_METADATA_FILE), `${workspace}
1061
+ import_node_fs5.default.writeFileSync(import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE), `${workspace}
969
1062
  `, "utf-8");
970
1063
  return workspace;
971
1064
  }
@@ -974,7 +1067,7 @@ async function withWorkspaceLifecycleLock(repoPath, operation) {
974
1067
  if (!gitDir) {
975
1068
  throw new Error(`cannot lock workspace lifecycle: '${repoPath}' is not a Git checkout`);
976
1069
  }
977
- const lockPath = import_node_path3.default.join(gitDir, WORKSPACE_LOCK_FILE);
1070
+ const lockPath = import_node_path4.default.join(gitDir, WORKSPACE_LOCK_FILE);
978
1071
  return withFileLock(
979
1072
  lockPath,
980
1073
  { activity: "workspace lifecycle", target: `'${repoPath}'` },
@@ -982,7 +1075,7 @@ async function withWorkspaceLifecycleLock(repoPath, operation) {
982
1075
  );
983
1076
  }
984
1077
  function currentBranch(repoPath) {
985
- const result = (0, import_node_child_process2.spawnSync)("git", ["-C", repoPath, "rev-parse", "--abbrev-ref", "HEAD"], {
1078
+ const result = (0, import_node_child_process3.spawnSync)("git", ["-C", repoPath, "rev-parse", "--abbrev-ref", "HEAD"], {
986
1079
  encoding: "utf-8"
987
1080
  });
988
1081
  if (result.status !== 0) {
@@ -1002,7 +1095,7 @@ function deriveLinkedWorktreeWorkspace(repoPath, linkedWorktreeBranch) {
1002
1095
  return void 0;
1003
1096
  }
1004
1097
  const branch = currentBranch(repoPath);
1005
- return wsFromBranch(branch ?? import_node_path3.default.basename(import_node_path3.default.resolve(repoPath)));
1098
+ return wsFromBranch(branch ?? import_node_path4.default.basename(import_node_path4.default.resolve(repoPath)));
1006
1099
  }
1007
1100
  function resolveWorktreeWorkspace(repoPath, linkedWorktreeBranch) {
1008
1101
  return readPersistedWorkspace(repoPath) ?? deriveLinkedWorktreeWorkspace(repoPath, linkedWorktreeBranch);
@@ -1025,13 +1118,13 @@ function resolveWorkspace(repoPath, override) {
1025
1118
  }
1026
1119
  return deriveLinkedWorktreeWorkspace(repoPath);
1027
1120
  }
1028
- var import_node_child_process2, import_node_fs4, import_node_path3, MAX_WORKSPACE_LENGTH, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
1121
+ var import_node_child_process3, import_node_fs5, import_node_path4, MAX_WORKSPACE_LENGTH, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
1029
1122
  var init_workspace = __esm({
1030
1123
  "src/core/workspace.ts"() {
1031
1124
  "use strict";
1032
- import_node_child_process2 = require("child_process");
1033
- import_node_fs4 = __toESM(require("fs"));
1034
- import_node_path3 = __toESM(require("path"));
1125
+ import_node_child_process3 = require("child_process");
1126
+ import_node_fs5 = __toESM(require("fs"));
1127
+ import_node_path4 = __toESM(require("path"));
1035
1128
  init_file_lock();
1036
1129
  MAX_WORKSPACE_LENGTH = 32;
1037
1130
  WORKSPACE_METADATA_FILE = "devrouter-workspace";
@@ -1151,64 +1244,199 @@ function buildHostRoutesDocument(routes, tlsEnabled) {
1151
1244
  }
1152
1245
  return document;
1153
1246
  }
1154
- function writeHostRoutesDynamicFile(routes, tlsEnabled) {
1247
+ function isRecord(value) {
1248
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1249
+ }
1250
+ function validateRouteState(value, source) {
1251
+ if (!Array.isArray(value)) {
1252
+ throw new Error(`${source} must contain a route array.`);
1253
+ }
1254
+ const routes = value.map((item, index) => {
1255
+ if (!isRecord(item)) {
1256
+ throw new Error(`${source} route ${index} must be an object.`);
1257
+ }
1258
+ for (const key of ["id", "name", "host", "repoPath", "createdAt", "updatedAt"]) {
1259
+ if (typeof item[key] !== "string" || item[key].length === 0) {
1260
+ throw new Error(`${source} route ${index} has an invalid '${key}'.`);
1261
+ }
1262
+ }
1263
+ if (!Number.isInteger(item.port) || Number(item.port) < 1 || Number(item.port) > 65535) {
1264
+ throw new Error(`${source} route ${index} has an invalid 'port'.`);
1265
+ }
1266
+ if (item.mode !== "run" && item.mode !== "attach" && item.mode !== "proxy") {
1267
+ throw new Error(`${source} route ${index} has an invalid 'mode'.`);
1268
+ }
1269
+ if (item.protocol !== void 0 && item.protocol !== "http" && item.protocol !== "tcp") {
1270
+ throw new Error(`${source} route ${index} has an invalid 'protocol'.`);
1271
+ }
1272
+ for (const key of ["tcpProtocol", "upstreamHost", "command", "workspace"]) {
1273
+ if (item[key] !== void 0 && typeof item[key] !== "string") {
1274
+ throw new Error(`${source} route ${index} has an invalid '${key}'.`);
1275
+ }
1276
+ }
1277
+ if (item.pid !== void 0 && (!Number.isInteger(item.pid) || Number(item.pid) <= 0)) {
1278
+ throw new Error(`${source} route ${index} has an invalid 'pid'.`);
1279
+ }
1280
+ const route = item;
1281
+ if (route.id !== buildHostRouteId(route.repoPath, route.name)) {
1282
+ throw new Error(`${source} route ${index} has an inconsistent 'id'.`);
1283
+ }
1284
+ return route;
1285
+ });
1286
+ const ids = /* @__PURE__ */ new Set();
1287
+ const hosts = /* @__PURE__ */ new Set();
1288
+ for (const route of routes) {
1289
+ if (ids.has(route.id)) {
1290
+ throw new Error(`${source} contains duplicate route id '${route.id}'.`);
1291
+ }
1292
+ if (hosts.has(route.host)) {
1293
+ throw new Error(`${source} contains duplicate route host '${route.host}'.`);
1294
+ }
1295
+ ids.add(route.id);
1296
+ hosts.add(route.host);
1297
+ }
1298
+ return routes;
1299
+ }
1300
+ function renderCompatibilityState(routes) {
1301
+ return `${JSON.stringify(routes, null, 2)}
1302
+ `;
1303
+ }
1304
+ function renderCanonicalState(routes, tlsEnabled) {
1305
+ const metadata = { version: 1, tlsEnabled, routes };
1306
+ const encoded = Buffer.from(JSON.stringify(metadata), "utf-8").toString("base64url");
1155
1307
  const document = buildHostRoutesDocument(routes, tlsEnabled);
1156
- import_node_fs5.default.writeFileSync(TRAEFIK_HOST_ROUTES_FILE, import_yaml.default.stringify(document, { lineWidth: 0 }), "utf-8");
1308
+ return `${ROUTE_METADATA_PREFIX}${encoded}
1309
+ ${import_yaml.default.stringify(document, { lineWidth: 0 })}`;
1157
1310
  }
1158
- function ensureHostRouteStorage() {
1159
- import_node_fs5.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
1160
- import_node_fs5.default.mkdirSync(TRAEFIK_DYNAMIC_DIR, { recursive: true });
1161
- if (!import_node_fs5.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1162
- import_node_fs5.default.writeFileSync(
1163
- TRAEFIK_HOST_ROUTES_FILE,
1164
- import_yaml.default.stringify({ http: { routers: {}, services: {} } }, { lineWidth: 0 }),
1165
- "utf-8"
1311
+ function writeRouteGeneration(routes, tlsEnabled) {
1312
+ writeFileAtomically(HOST_ROUTES_STATE_FILE, renderCompatibilityState(routes));
1313
+ writeFileAtomically(TRAEFIK_HOST_ROUTES_FILE, renderCanonicalState(routes, tlsEnabled));
1314
+ }
1315
+ function readCompatibilityState() {
1316
+ let raw;
1317
+ try {
1318
+ raw = import_node_fs6.default.readFileSync(HOST_ROUTES_STATE_FILE, "utf-8");
1319
+ } catch (error) {
1320
+ throw new Error(
1321
+ `Could not read the compatibility host-route state: ${error.message}`
1166
1322
  );
1167
1323
  }
1168
- if (!import_node_fs5.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1169
- import_node_fs5.default.writeFileSync(HOST_ROUTES_STATE_FILE, "[]\n", "utf-8");
1324
+ try {
1325
+ return validateRouteState(JSON.parse(raw), "Compatibility host-route state");
1326
+ } catch (error) {
1327
+ throw new Error(`Invalid compatibility host-route state: ${error.message}`);
1170
1328
  }
1171
1329
  }
1330
+ function parseCanonicalState(raw) {
1331
+ const firstNewline = raw.indexOf("\n");
1332
+ const firstLine2 = firstNewline === -1 ? raw : raw.slice(0, firstNewline);
1333
+ if (!firstLine2.startsWith(ROUTE_METADATA_PREFIX)) {
1334
+ if (firstLine2.startsWith(ROUTE_METADATA_FAMILY_PREFIX)) {
1335
+ throw new Error(`Unsupported or malformed host-route metadata header '${firstLine2}'.`);
1336
+ }
1337
+ return { kind: "legacy" };
1338
+ }
1339
+ const encoded = firstLine2.slice(ROUTE_METADATA_PREFIX.length);
1340
+ if (!/^[a-zA-Z0-9_-]+$/.test(encoded)) {
1341
+ throw new Error("Host-route metadata header is not valid base64url.");
1342
+ }
1343
+ let metadataValue;
1344
+ try {
1345
+ const decoded = Buffer.from(encoded, "base64url");
1346
+ if (decoded.toString("base64url") !== encoded) {
1347
+ throw new Error("non-canonical base64url encoding");
1348
+ }
1349
+ metadataValue = JSON.parse(decoded.toString("utf-8"));
1350
+ } catch (error) {
1351
+ throw new Error(`Host-route metadata header is invalid: ${error.message}`);
1352
+ }
1353
+ if (!isRecord(metadataValue) || metadataValue.version !== 1) {
1354
+ throw new Error("Host-route metadata header has an unsupported version.");
1355
+ }
1356
+ if (typeof metadataValue.tlsEnabled !== "boolean") {
1357
+ throw new Error("Host-route metadata header has an invalid TLS state.");
1358
+ }
1359
+ const routes = validateRouteState(metadataValue.routes, "Canonical host-route metadata");
1360
+ const metadata = {
1361
+ version: 1,
1362
+ tlsEnabled: metadataValue.tlsEnabled,
1363
+ routes
1364
+ };
1365
+ let document;
1366
+ try {
1367
+ document = import_yaml.default.parse(firstNewline === -1 ? "" : raw.slice(firstNewline + 1));
1368
+ } catch (error) {
1369
+ throw new Error(`Canonical host-route document is invalid: ${error.message}`);
1370
+ }
1371
+ const expectedDocument = buildHostRoutesDocument(routes, metadata.tlsEnabled);
1372
+ if (!(0, import_node_util.isDeepStrictEqual)(document, expectedDocument)) {
1373
+ throw new Error("Canonical host-route document does not match its metadata.");
1374
+ }
1375
+ return { kind: "canonical", metadata };
1376
+ }
1377
+ function ensureHostRouteStorage() {
1378
+ import_node_fs6.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
1379
+ import_node_fs6.default.mkdirSync(TRAEFIK_DYNAMIC_DIR, { recursive: true });
1380
+ }
1172
1381
  function writeState(routes) {
1173
1382
  ensureHostRouteStorage();
1174
- import_node_fs5.default.writeFileSync(HOST_ROUTES_STATE_FILE, `${JSON.stringify(routes, null, 2)}
1175
- `, "utf-8");
1176
- writeHostRoutesDynamicFile(routes, isTLSEnabled());
1383
+ writeRouteGeneration(routes, isTLSEnabled());
1177
1384
  }
1178
1385
  function withStateLock(fn) {
1179
1386
  ensureHostRouteStorage();
1180
1387
  return withFileLockSync(STATE_LOCK_FILE, { activity: "host route update", waitMs: 5e3 }, fn);
1181
1388
  }
1182
1389
  function refreshHostRoutesDynamicFile() {
1183
- const routes = listHostRouteState();
1184
- writeHostRoutesDynamicFile(routes, isTLSEnabled());
1390
+ withStateLock(() => {
1391
+ const routes = readHostRouteStateLocked();
1392
+ writeState(routes);
1393
+ });
1185
1394
  }
1186
- function listHostRouteState() {
1395
+ function readHostRouteStateLocked() {
1187
1396
  ensureHostRouteStorage();
1188
- if (!import_node_fs5.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1397
+ if (!import_node_fs6.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1398
+ if (import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1399
+ const routes = readCompatibilityState();
1400
+ writeRouteGeneration(routes, isTLSEnabled());
1401
+ return routes;
1402
+ }
1403
+ writeRouteGeneration([], isTLSEnabled());
1189
1404
  return [];
1190
1405
  }
1191
- try {
1192
- const raw = import_node_fs5.default.readFileSync(HOST_ROUTES_STATE_FILE, "utf-8");
1193
- const parsed = JSON.parse(raw);
1194
- if (!Array.isArray(parsed)) {
1195
- return [];
1196
- }
1197
- return parsed.filter((item) => item && typeof item === "object").map((item) => item);
1198
- } catch (err) {
1199
- const error = err;
1200
- if (error.code !== "ENOENT") {
1201
- process.stderr.write(
1202
- `Warning: devrouter host routes state file is corrupted or unreadable (${error.message}). Recreating route state.
1203
- `
1406
+ const raw = import_node_fs6.default.readFileSync(TRAEFIK_HOST_ROUTES_FILE, "utf-8");
1407
+ const canonical = parseCanonicalState(raw);
1408
+ if (canonical.kind === "legacy") {
1409
+ if (!import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1410
+ throw new Error(
1411
+ "Headerless host-route document requires a valid compatibility state file for migration."
1204
1412
  );
1205
1413
  }
1206
- return [];
1414
+ const routes = readCompatibilityState();
1415
+ writeRouteGeneration(routes, isTLSEnabled());
1416
+ return routes;
1417
+ }
1418
+ let mirrorMatches = false;
1419
+ if (import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1420
+ try {
1421
+ mirrorMatches = (0, import_node_util.isDeepStrictEqual)(readCompatibilityState(), canonical.metadata.routes);
1422
+ } catch {
1423
+ mirrorMatches = false;
1424
+ }
1425
+ }
1426
+ if (!mirrorMatches) {
1427
+ writeFileAtomically(
1428
+ HOST_ROUTES_STATE_FILE,
1429
+ renderCompatibilityState(canonical.metadata.routes)
1430
+ );
1207
1431
  }
1432
+ return canonical.metadata.routes;
1433
+ }
1434
+ function listHostRouteState() {
1435
+ return withStateLock(readHostRouteStateLocked);
1208
1436
  }
1209
1437
  function upsertHostRoute(input2) {
1210
1438
  return withStateLock(() => {
1211
- const routes = listHostRouteState();
1439
+ const routes = readHostRouteStateLocked();
1212
1440
  const id = buildHostRouteId(input2.repoPath, input2.name);
1213
1441
  const existing = routes.find((route) => route.id === id);
1214
1442
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1225,7 +1453,7 @@ function replaceHostRoutesForRepo(repoPath, inputs) {
1225
1453
  if (inputs.some((input2) => !sameWorkspacePath(input2.repoPath, repoPath))) {
1226
1454
  throw new Error(`Route replacement contains an entry outside '${repoPath}'.`);
1227
1455
  }
1228
- const routes = listHostRouteState();
1456
+ const routes = readHostRouteStateLocked();
1229
1457
  const remaining = routes.filter((route) => !sameWorkspacePath(route.repoPath, repoPath));
1230
1458
  const names = /* @__PURE__ */ new Set();
1231
1459
  const hosts = /* @__PURE__ */ new Set();
@@ -1263,7 +1491,7 @@ function replaceHostRoutesForRepo(repoPath, inputs) {
1263
1491
  }
1264
1492
  function removeHostRouteById(id) {
1265
1493
  return withStateLock(() => {
1266
- const routes = listHostRouteState();
1494
+ const routes = readHostRouteStateLocked();
1267
1495
  const next = routes.filter((route) => route.id !== id);
1268
1496
  if (next.length === routes.length) {
1269
1497
  return false;
@@ -1274,7 +1502,7 @@ function removeHostRouteById(id) {
1274
1502
  }
1275
1503
  function removeHostRoutesWhere(predicate) {
1276
1504
  return withStateLock(() => {
1277
- const routes = listHostRouteState();
1505
+ const routes = readHostRouteStateLocked();
1278
1506
  const removed = routes.filter(predicate);
1279
1507
  if (removed.length === 0) {
1280
1508
  return [];
@@ -1296,7 +1524,7 @@ function listHostRoutes(tlsEnabled) {
1296
1524
  protocol: isTcp ? `tcp/${tcpProtocol}` : "http",
1297
1525
  appName: route.name,
1298
1526
  serviceName: route.name,
1299
- projectName: import_node_path4.default.basename(route.repoPath),
1527
+ projectName: import_node_path5.default.basename(route.repoPath),
1300
1528
  hosts: [route.host],
1301
1529
  // TCP routes are reached via an SNI-aware client on the shared protocol
1302
1530
  // port; surface a protocol-scheme URL rather than http(s)://.
@@ -1311,13 +1539,15 @@ function listHostRoutes(tlsEnabled) {
1311
1539
  };
1312
1540
  });
1313
1541
  }
1314
- var import_node_fs5, import_node_path4, import_yaml, LOOPBACK_HOSTS, UPSTREAM_RE, TCP_TLS_ALPN_PROTOCOLS, STATE_LOCK_FILE;
1542
+ var import_node_fs6, import_node_path5, import_node_util, import_yaml, LOOPBACK_HOSTS, UPSTREAM_RE, TCP_TLS_ALPN_PROTOCOLS, ROUTE_METADATA_PREFIX, ROUTE_METADATA_FAMILY_PREFIX, STATE_LOCK_FILE;
1315
1543
  var init_host_routes = __esm({
1316
1544
  "src/core/host-routes.ts"() {
1317
1545
  "use strict";
1318
- import_node_fs5 = __toESM(require("fs"));
1319
- import_node_path4 = __toESM(require("path"));
1546
+ import_node_fs6 = __toESM(require("fs"));
1547
+ import_node_path5 = __toESM(require("path"));
1548
+ import_node_util = require("util");
1320
1549
  import_yaml = __toESM(require("yaml"));
1550
+ init_atomic_file();
1321
1551
  init_file_lock();
1322
1552
  init_router();
1323
1553
  init_workspace();
@@ -1326,6 +1556,8 @@ var init_host_routes = __esm({
1326
1556
  TCP_TLS_ALPN_PROTOCOLS = {
1327
1557
  postgres: ["postgresql"]
1328
1558
  };
1559
+ ROUTE_METADATA_PREFIX = "# devrouter-routes-v1: ";
1560
+ ROUTE_METADATA_FAMILY_PREFIX = "# devrouter-routes-";
1329
1561
  STATE_LOCK_FILE = `${HOST_ROUTES_STATE_FILE}.lock`;
1330
1562
  }
1331
1563
  });
@@ -1753,10 +1985,10 @@ function renderConfig(config) {
1753
1985
  return import_yaml2.default.stringify(config, { lineWidth: 0 });
1754
1986
  }
1755
1987
  function readConfigDocument(configPath) {
1756
- return (0, import_yaml2.parseDocument)(import_node_fs6.default.readFileSync(configPath, "utf-8"));
1988
+ return (0, import_yaml2.parseDocument)(import_node_fs7.default.readFileSync(configPath, "utf-8"));
1757
1989
  }
1758
1990
  function writeConfigDocument(configPath, doc) {
1759
- import_node_fs6.default.writeFileSync(configPath, doc.toString({ lineWidth: 0 }), "utf-8");
1991
+ import_node_fs7.default.writeFileSync(configPath, doc.toString({ lineWidth: 0 }), "utf-8");
1760
1992
  }
1761
1993
  function getOrCreateAppsSeq(doc) {
1762
1994
  const apps = doc.get("apps", true);
@@ -1778,25 +2010,25 @@ function appToNodeValue(app) {
1778
2010
  return value;
1779
2011
  }
1780
2012
  function resolveRepoPath(repoPath) {
1781
- return import_node_path5.default.resolve(repoPath ?? process.cwd());
2013
+ return import_node_path6.default.resolve(repoPath ?? process.cwd());
1782
2014
  }
1783
2015
  function getRepoConfigPath(repoPath) {
1784
- return import_node_path5.default.join(resolveRepoPath(repoPath), CONFIG_FILE_NAME);
2016
+ return import_node_path6.default.join(resolveRepoPath(repoPath), CONFIG_FILE_NAME);
1785
2017
  }
1786
2018
  function loadRepoConfig(repoPath) {
1787
2019
  const resolvedRepoPath = resolveRepoPath(repoPath);
1788
2020
  const configPath = getRepoConfigPath(resolvedRepoPath);
1789
- if (!import_node_fs6.default.existsSync(configPath)) {
2021
+ if (!import_node_fs7.default.existsSync(configPath)) {
1790
2022
  throw new Error(
1791
2023
  `Missing ${CONFIG_FILE_NAME} in ${resolvedRepoPath}. Run 'dev repo init --repo ${resolvedRepoPath}' first.`
1792
2024
  );
1793
2025
  }
1794
- const raw = import_node_fs6.default.readFileSync(configPath, "utf-8");
2026
+ const raw = import_node_fs7.default.readFileSync(configPath, "utf-8");
1795
2027
  const parsed = import_yaml2.default.parse(raw);
1796
2028
  const config = parseConfig(parsed ?? {}, configPath);
1797
2029
  const requiredVersion = config.devrouter?.version;
1798
2030
  if (requiredVersion && !hasWarnedVersionMismatch) {
1799
- const cliVersion = true ? "0.0.34" : "0.0.0-dev";
2031
+ const cliVersion = true ? "0.0.35" : "0.0.0-dev";
1800
2032
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
1801
2033
  hasWarnedVersionMismatch = true;
1802
2034
  process.stderr.write(
@@ -1813,7 +2045,7 @@ function loadRepoConfig(repoPath) {
1813
2045
  function initRepoConfig(repoPath, options = {}) {
1814
2046
  const resolvedRepoPath = resolveRepoPath(repoPath);
1815
2047
  const configPath = getRepoConfigPath(resolvedRepoPath);
1816
- if (import_node_fs6.default.existsSync(configPath)) {
2048
+ if (import_node_fs7.default.existsSync(configPath)) {
1817
2049
  return { repoPath: resolvedRepoPath, configPath, created: false };
1818
2050
  }
1819
2051
  if (options.devrouterVersion !== void 0 && !DEVROUTER_VERSION_RE.test(options.devrouterVersion)) {
@@ -1829,11 +2061,11 @@ function initRepoConfig(repoPath, options = {}) {
1829
2061
  }
1830
2062
  } : {},
1831
2063
  project: {
1832
- name: import_node_path5.default.basename(resolvedRepoPath)
2064
+ name: import_node_path6.default.basename(resolvedRepoPath)
1833
2065
  },
1834
2066
  apps: []
1835
2067
  };
1836
- import_node_fs6.default.writeFileSync(configPath, renderConfig(initialConfig), "utf-8");
2068
+ import_node_fs7.default.writeFileSync(configPath, renderConfig(initialConfig), "utf-8");
1837
2069
  return { repoPath: resolvedRepoPath, configPath, created: true };
1838
2070
  }
1839
2071
  function buildAppFromOptions(options) {
@@ -2058,7 +2290,7 @@ function namespaceHost(host, workspace) {
2058
2290
  return `${base}.${workspace}${suffix}`;
2059
2291
  }
2060
2292
  function applyWorkspace(config, workspace, repoPath) {
2061
- const defaultToken = wsFromBranch(config.project?.name ?? import_node_path5.default.basename(import_node_path5.default.resolve(repoPath))) ?? "app";
2293
+ const defaultToken = wsFromBranch(config.project?.name ?? import_node_path6.default.basename(import_node_path6.default.resolve(repoPath))) ?? "app";
2062
2294
  const substitutionToken = workspace ?? defaultToken;
2063
2295
  const next = structuredClone(config);
2064
2296
  for (const app of next.apps) {
@@ -2133,12 +2365,12 @@ function resolveAppDependencies(config, app) {
2133
2365
  }
2134
2366
  return results;
2135
2367
  }
2136
- var import_node_fs6, import_node_path5, import_yaml2, hasWarnedVersionMismatch, CONFIG_FILE_NAME, DEFAULT_TCP_PROTOCOL, VALID_HOSTNAME_RE, DEVROUTER_VERSION_RE, VALID_ENV_NAME_RE, VALID_ENV_VAR_RE, UPSTREAM_TEMPLATE_RE, MAX_COMMAND_LENGTH, DEFAULT_HOST_STRATEGY;
2368
+ var import_node_fs7, import_node_path6, import_yaml2, hasWarnedVersionMismatch, CONFIG_FILE_NAME, DEFAULT_TCP_PROTOCOL, VALID_HOSTNAME_RE, DEVROUTER_VERSION_RE, VALID_ENV_NAME_RE, VALID_ENV_VAR_RE, UPSTREAM_TEMPLATE_RE, MAX_COMMAND_LENGTH, DEFAULT_HOST_STRATEGY;
2137
2369
  var init_repo_config = __esm({
2138
2370
  "src/core/repo-config.ts"() {
2139
2371
  "use strict";
2140
- import_node_fs6 = __toESM(require("fs"));
2141
- import_node_path5 = __toESM(require("path"));
2372
+ import_node_fs7 = __toESM(require("fs"));
2373
+ import_node_path6 = __toESM(require("path"));
2142
2374
  import_yaml2 = __toESM(require("yaml"));
2143
2375
  init_capabilities();
2144
2376
  init_host_routes();
@@ -2205,6 +2437,7 @@ function buildOnboardingPrompt(options = {}) {
2205
2437
  "- Shared Traefik router owns host ports 80 (HTTP), 443 (HTTPS), and 5432 (Postgres TCP).",
2206
2438
  "- Per-repo source of truth is REPO_PATH/.devrouter.yml only.",
2207
2439
  "- Global generated/runtime artifacts are managed under ~/.config/devrouter (do not edit these manually).",
2440
+ "- Managed DevPod lifecycle uses devrouter ensure/stop/workspace commands; raw devpod mutations bypass exact checkout ownership checks.",
2208
2441
  "",
2209
2442
  "Inputs:",
2210
2443
  `- REPO_PATH=${repoPath}`,
@@ -2248,6 +2481,7 @@ function buildOnboardingPrompt(options = {}) {
2248
2481
  "- if kind=app and runtime=proxy:",
2249
2482
  ' - upstream: "host:port" (an already-running port, e.g. a devcontainer published on 127.0.0.1:3000, or a container reachable by name on a shared Docker network such as `derivatives-db:5432`)',
2250
2483
  ` - upstream may use the \`${WORKSPACE_PLACEHOLDER}\` placeholder (e.g. \`${WORKSPACE_PLACEHOLDER}-app:3000\`) to target a per-workspace devcontainer alias; it is substituted with the resolved workspace token at runtime and re-validated. Do NOT put \`${WORKSPACE_PLACEHOLDER}\` in \`host\` (rejected) \u2014 the host is auto-namespaced.`,
2484
+ " - managed `devrouter ensure` requires every HTTP/TCP proxy upstream to begin with the exact resolved workspace/project alias prefix before it mutates DevPod or routes",
2251
2485
  " - protocol=http registers an HTTP route; protocol=tcp registers a TLS-SNI TCP route and additionally requires tcpProtocol",
2252
2486
  " - do not set hostRun/docker/dependencies (proxy only registers a route to the upstream)",
2253
2487
  " - loopback hosts (localhost/127.0.0.1/0.0.0.0) are rewritten to host.docker.internal for Traefik",
@@ -2298,9 +2532,10 @@ function buildOnboardingPrompt(options = {}) {
2298
2532
  "Workspace isolation (parallel git worktrees / agents):",
2299
2533
  `- 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.`,
2300
2534
  "- 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.",
2301
- `- 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. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.`,
2535
+ `- 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.`,
2302
2536
  "- 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.",
2303
- "- 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 the repository-owned post-start adapter. Keep `.devrouter.yml` as the only consumer-side version pin. Use `devrouter stop .` for a non-destructive pause and `devrouter exec . -- <command...>` for container commands. `workspace up` creates linked worktrees; destructive down/GC remains ledger-scoped.",
2537
+ "- 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 mutations; they bypass the machine-global ownership lock. `workspace up` creates linked worktrees; destructive worktree removal and GC remain ledger-scoped.",
2538
+ "- Managed process reuse fingerprints command argv, workspace identity, and the exact adapter snapshot. If a non-secret runtime value also affects reuse, set `DEVROUTER_PROCESS_FINGERPRINT_ENV` to its comma-separated environment names; secret-like names are rejected and raw values are never persisted.",
2304
2539
  "- Owner status is `present`, `missing`, `locked`, or `conflict`. Dirty or locked full down fails before side effects. `workspace gc` is a dry run; only `--yes` deletes exact eligible missing resources and records, never Git worktrees, branches, or prune state.",
2305
2540
  "- Workspace commands require Git. Normal config, app, status, and doctor flows work from a `.devrouter.yml` folder without `.git`. Git has no worktree-removal hook; use `workspace ls`, doctor, or dry-run GC after out-of-band removal.",
2306
2541
  "- devcontainer integration: `devcontainer.json` lists the base compose file then `${localEnv:DEVCONTAINER_COMPOSE_OVERLAY:docker-compose.default.yml}`. The default overlay contains `services: {}`; `.devcontainer/docker-compose.devrouter.yml` passes `WORKSPACE` and `DEVROUTER_WORKSPACE` into the app and bind-mounts `${DEVROUTER_GIT_COMMON_DIR}` to the same absolute app-container path. Ensure proves exact DevPod ownership, overlay/Git mounts, env, aliases, health, Git, HTTP route reachability, and unique running TCP upstream ownership before success.",
@@ -2465,8 +2700,8 @@ var init_ai_prompt = __esm({
2465
2700
  purpose: "Canonical startup and proof for an exact primary or linked checkout; atomically publishes routes after readiness."
2466
2701
  },
2467
2702
  {
2468
- command: "devrouter stop [path] [--json]",
2469
- purpose: "Stop the exact checkout DevPod and routes without deleting checkout data."
2703
+ command: "devrouter stop [path] [--delete] [--json]",
2704
+ purpose: "Stop the exact checkout DevPod and routes; --delete explicitly deletes the ownership-proven DevPod without removing the checkout."
2470
2705
  },
2471
2706
  {
2472
2707
  command: "devrouter exec [path] -- <command...>",
@@ -2883,11 +3118,11 @@ function extractCurrentVersionFromRepoConfig(repoPath) {
2883
3118
  };
2884
3119
  }
2885
3120
  function readPromptDirectory(promptDirPath) {
2886
- if (!import_node_fs7.default.existsSync(promptDirPath)) {
3121
+ if (!import_node_fs8.default.existsSync(promptDirPath)) {
2887
3122
  throw new Error(`Missing ${UPGRADE_PROMPTS_DIR} directory at ${promptDirPath}.`);
2888
3123
  }
2889
3124
  const releases = [];
2890
- const files = import_node_fs7.default.readdirSync(promptDirPath, { withFileTypes: true });
3125
+ const files = import_node_fs8.default.readdirSync(promptDirPath, { withFileTypes: true });
2891
3126
  for (const file of files) {
2892
3127
  if (!file.isFile() || !file.name.endsWith(".md")) {
2893
3128
  continue;
@@ -2897,8 +3132,8 @@ function readPromptDirectory(promptDirPath) {
2897
3132
  continue;
2898
3133
  }
2899
3134
  const version = normalizeVersion(basename);
2900
- const promptPath = import_node_path6.default.join(promptDirPath, file.name);
2901
- const prompt = import_node_fs7.default.readFileSync(promptPath, "utf-8").trim();
3135
+ const promptPath = import_node_path7.default.join(promptDirPath, file.name);
3136
+ const prompt = import_node_fs8.default.readFileSync(promptPath, "utf-8").trim();
2902
3137
  releases.push({ version, prompt, promptPath });
2903
3138
  }
2904
3139
  const deduped = /* @__PURE__ */ new Map();
@@ -2919,19 +3154,19 @@ function listAvailableUpgradeTargets(currentVersion, releases) {
2919
3154
  }
2920
3155
  function resolvePromptDirectory(explicitPath) {
2921
3156
  if (explicitPath) {
2922
- return import_node_path6.default.resolve(explicitPath);
3157
+ return import_node_path7.default.resolve(explicitPath);
2923
3158
  }
2924
- const entryFile = process.argv[1] ? import_node_path6.default.resolve(process.argv[1]) : __filename;
2925
- const entryDir = import_node_path6.default.dirname(entryFile);
2926
- const resolvedEntryDir = import_node_fs7.default.existsSync(entryFile) ? import_node_path6.default.dirname(import_node_fs7.default.realpathSync(entryFile)) : entryDir;
3159
+ const entryFile = process.argv[1] ? import_node_path7.default.resolve(process.argv[1]) : __filename;
3160
+ const entryDir = import_node_path7.default.dirname(entryFile);
3161
+ const resolvedEntryDir = import_node_fs8.default.existsSync(entryFile) ? import_node_path7.default.dirname(import_node_fs8.default.realpathSync(entryFile)) : entryDir;
2927
3162
  const candidates = [
2928
- import_node_path6.default.resolve(resolvedEntryDir, "..", UPGRADE_PROMPTS_DIR),
2929
- import_node_path6.default.resolve(entryDir, "..", UPGRADE_PROMPTS_DIR),
2930
- import_node_path6.default.resolve(__dirname, "..", UPGRADE_PROMPTS_DIR),
2931
- import_node_path6.default.resolve(__dirname, "..", "..", UPGRADE_PROMPTS_DIR),
2932
- import_node_path6.default.resolve(process.cwd(), UPGRADE_PROMPTS_DIR)
3163
+ import_node_path7.default.resolve(resolvedEntryDir, "..", UPGRADE_PROMPTS_DIR),
3164
+ import_node_path7.default.resolve(entryDir, "..", UPGRADE_PROMPTS_DIR),
3165
+ import_node_path7.default.resolve(__dirname, "..", UPGRADE_PROMPTS_DIR),
3166
+ import_node_path7.default.resolve(__dirname, "..", "..", UPGRADE_PROMPTS_DIR),
3167
+ import_node_path7.default.resolve(process.cwd(), UPGRADE_PROMPTS_DIR)
2933
3168
  ];
2934
- const existing = candidates.find((candidate) => import_node_fs7.default.existsSync(candidate));
3169
+ const existing = candidates.find((candidate) => import_node_fs8.default.existsSync(candidate));
2935
3170
  return existing ?? candidates[0];
2936
3171
  }
2937
3172
  function loadUpgradeCatalog(options = {}) {
@@ -2947,12 +3182,12 @@ function loadUpgradeCatalog(options = {}) {
2947
3182
  releases
2948
3183
  };
2949
3184
  }
2950
- var import_node_fs7, import_node_path6, SEMVER_RE, UPGRADE_PROMPTS_DIR;
3185
+ var import_node_fs8, import_node_path7, SEMVER_RE, UPGRADE_PROMPTS_DIR;
2951
3186
  var init_upgrade = __esm({
2952
3187
  "src/core/upgrade.ts"() {
2953
3188
  "use strict";
2954
- import_node_fs7 = __toESM(require("fs"));
2955
- import_node_path6 = __toESM(require("path"));
3189
+ import_node_fs8 = __toESM(require("fs"));
3190
+ import_node_path7 = __toESM(require("path"));
2956
3191
  init_repo_config();
2957
3192
  SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
2958
3193
  UPGRADE_PROMPTS_DIR = "upgrade-prompts";
@@ -3052,7 +3287,7 @@ async function getDockerodeConstructor() {
3052
3287
  return dockerodeConstructorPromise;
3053
3288
  }
3054
3289
  function runDockerContextCommand(args) {
3055
- const result = (0, import_node_child_process3.spawnSync)("docker", ["context", ...args], {
3290
+ const result = (0, import_node_child_process4.spawnSync)("docker", ["context", ...args], {
3056
3291
  encoding: "utf-8"
3057
3292
  });
3058
3293
  if (result.status !== 0) {
@@ -3065,7 +3300,7 @@ function getCurrentDockerContext() {
3065
3300
  return runDockerContextCommand(["show"]);
3066
3301
  }
3067
3302
  function getDockerHostFromContext(context) {
3068
- const result = (0, import_node_child_process3.spawnSync)(
3303
+ const result = (0, import_node_child_process4.spawnSync)(
3069
3304
  "docker",
3070
3305
  ["context", "inspect", context, "--format", "{{ .Endpoints.docker.Host }}"],
3071
3306
  { encoding: "utf-8" }
@@ -3133,15 +3368,177 @@ async function networkExists(name) {
3133
3368
  return false;
3134
3369
  }
3135
3370
  }
3136
- var import_node_child_process3, dockerodeConstructorPromise;
3371
+ var import_node_child_process4, dockerodeConstructorPromise;
3137
3372
  var init_docker = __esm({
3138
3373
  "src/core/docker.ts"() {
3139
3374
  "use strict";
3140
- import_node_child_process3 = require("child_process");
3375
+ import_node_child_process4 = require("child_process");
3141
3376
  dockerodeConstructorPromise = null;
3142
3377
  }
3143
3378
  });
3144
3379
 
3380
+ // src/core/managed-post-start.ts
3381
+ function renderDeliveryScript(targetPath) {
3382
+ if (!/^\/tmp\/devrouter\/bin\/[a-zA-Z0-9._-]+$/.test(targetPath)) {
3383
+ throw new Error(`Unsafe managed runtime delivery path: ${targetPath}`);
3384
+ }
3385
+ const temporaryPrefix = import_node_path8.default.posix.basename(targetPath);
3386
+ return `set -eu
3387
+ umask 077
3388
+ runtime_root=/tmp/devrouter
3389
+ runtime_bin="$runtime_root/bin"
3390
+ target="${targetPath}"
3391
+ if [ -L "$runtime_root" ] || [ -L "$runtime_bin" ]; then
3392
+ echo "Refusing symlinked devrouter runtime path." >&2
3393
+ exit 1
3394
+ fi
3395
+ mkdir -p "$runtime_bin"
3396
+ chmod 700 "$runtime_root" "$runtime_bin"
3397
+ temporary="$(mktemp "$runtime_bin/.${temporaryPrefix}.XXXXXX")"
3398
+ trap 'rm -f "$temporary"' EXIT
3399
+ cat > "$temporary"
3400
+ chmod 700 "$temporary"
3401
+ mv -f "$temporary" "$target"
3402
+ trap - EXIT
3403
+ `;
3404
+ }
3405
+ function commandFailure(result) {
3406
+ return [result.error?.message, result.stderr, result.stdout].filter(Boolean).map((value) => String(value).trim()).filter(Boolean).join("\n");
3407
+ }
3408
+ function deliverRuntimeFile(containerId, targetPath, contents, label) {
3409
+ const result = (0, import_node_child_process5.spawnSync)(
3410
+ "docker",
3411
+ ["exec", "-i", containerId, "sh", "-c", renderDeliveryScript(targetPath)],
3412
+ { input: contents, encoding: "utf-8" }
3413
+ );
3414
+ if (result.status !== 0) {
3415
+ const details = commandFailure(result);
3416
+ throw new Error(`Could not deliver ${label}${details ? `: ${details}` : "."}`);
3417
+ }
3418
+ }
3419
+ function readRegularFileBytes(filePath) {
3420
+ if (!import_node_fs9.default.existsSync(filePath) || !import_node_fs9.default.lstatSync(filePath).isFile()) return void 0;
3421
+ return import_node_fs9.default.readFileSync(filePath);
3422
+ }
3423
+ function readRegularFile(filePath) {
3424
+ return readRegularFileBytes(filePath)?.toString("utf-8");
3425
+ }
3426
+ function adapterFingerprint(adapter) {
3427
+ return (0, import_node_crypto3.createHash)("sha256").update(adapter).digest("hex");
3428
+ }
3429
+ function resolveProcessHelperPath() {
3430
+ const candidates = [
3431
+ import_node_path8.default.resolve(__dirname, "..", "bin", "devrouter-process"),
3432
+ import_node_path8.default.resolve(__dirname, "..", "..", "bin", "devrouter-process")
3433
+ ];
3434
+ const helperPath = candidates.find((candidate) => import_node_fs9.default.existsSync(candidate));
3435
+ if (!helperPath) {
3436
+ throw new Error("Could not locate the packaged devrouter-process helper.");
3437
+ }
3438
+ return helperPath;
3439
+ }
3440
+ function resolveManagedPostStartPlan(repoPath) {
3441
+ const adapterPath = import_node_path8.default.join(repoPath, MANAGED_ADAPTER_PATH);
3442
+ const adapterBytes = readRegularFileBytes(adapterPath);
3443
+ const adapterText = adapterBytes?.toString("utf-8");
3444
+ const dockerfilePath = import_node_path8.default.join(repoPath, ".devcontainer", "Dockerfile");
3445
+ const devcontainerPath = import_node_path8.default.join(repoPath, ".devcontainer", "devcontainer.json");
3446
+ const dockerfile = readRegularFile(dockerfilePath) ?? "";
3447
+ const devcontainer = readRegularFile(devcontainerPath) ?? "";
3448
+ const devrouterPattern = [adapterText, dockerfile, devcontainer].filter((value) => value !== void 0).some(
3449
+ (value) => value.includes("DEVROUTER_PROCESS_HELPER") || value.includes("devrouter-process")
3450
+ );
3451
+ if (adapterBytes === void 0) {
3452
+ if (devrouterPattern) {
3453
+ throw new Error(
3454
+ "Devrouter lifecycle wiring is incomplete: add the managed post-start adapter or remove the stale devrouter-process references."
3455
+ );
3456
+ }
3457
+ return { kind: "unmanaged" };
3458
+ }
3459
+ const adapter = adapterBytes.toString("utf-8");
3460
+ if (!adapter.includes(MANAGED_MARKER)) {
3461
+ if (devrouterPattern) {
3462
+ throw new Error(
3463
+ "Devrouter-looking post-start adapter is missing the 'devrouter:managed devcontainer' marker. Regenerate or migrate the devcontainer, then retry devrouter ensure."
3464
+ );
3465
+ }
3466
+ return { kind: "unmanaged" };
3467
+ }
3468
+ if (adapter.includes("DEVROUTER_PROCESS_HELPER")) {
3469
+ return {
3470
+ kind: "runtime",
3471
+ adapterPath: MANAGED_ADAPTER_PATH,
3472
+ adapterSha256: adapterFingerprint(adapterBytes),
3473
+ adapterContents: adapterBytes
3474
+ };
3475
+ }
3476
+ if (dockerfile.includes("devrouter-process") && devcontainer.includes("postStartCommand")) {
3477
+ return { kind: "legacy" };
3478
+ }
3479
+ throw new Error(
3480
+ "Managed post-start must use DEVROUTER_PROCESS_HELPER before removing the legacy image helper and postStartCommand. Regenerate or migrate the devcontainer, then retry devrouter ensure."
3481
+ );
3482
+ }
3483
+ function runManagedPostStart(options) {
3484
+ if (options.plan.kind !== "runtime") return;
3485
+ const helper = import_node_fs9.default.readFileSync(resolveProcessHelperPath());
3486
+ deliverRuntimeFile(
3487
+ options.container.id,
3488
+ RUNTIME_HELPER_PATH,
3489
+ helper,
3490
+ "the managed process helper"
3491
+ );
3492
+ const runtimeAdapterPath = `/tmp/devrouter/bin/managed-post-start-${options.plan.adapterSha256}`;
3493
+ deliverRuntimeFile(
3494
+ options.container.id,
3495
+ runtimeAdapterPath,
3496
+ options.plan.adapterContents,
3497
+ "the managed post-start adapter"
3498
+ );
3499
+ const started = (0, import_node_child_process5.spawnSync)(
3500
+ "docker",
3501
+ [
3502
+ "exec",
3503
+ "--workdir",
3504
+ options.container.workspacePath,
3505
+ "--env",
3506
+ `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
3507
+ "--env",
3508
+ `DEVROUTER_PROCESS_ADAPTER_SHA256=${options.plan.adapterSha256}`,
3509
+ options.container.id,
3510
+ "bash",
3511
+ "-c",
3512
+ ADAPTER_WRAPPER,
3513
+ options.plan.adapterPath,
3514
+ runtimeAdapterPath
3515
+ ],
3516
+ { stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit" }
3517
+ );
3518
+ if (started.status !== 0) {
3519
+ const details = commandFailure(started);
3520
+ throw new Error(`Managed post-start failed${details ? `: ${details}` : "."}`);
3521
+ }
3522
+ }
3523
+ var import_node_child_process5, import_node_crypto3, import_node_fs9, import_node_path8, MANAGED_MARKER, MANAGED_ADAPTER_PATH, RUNTIME_HELPER_PATH, ADAPTER_WRAPPER;
3524
+ var init_managed_post_start = __esm({
3525
+ "src/core/managed-post-start.ts"() {
3526
+ "use strict";
3527
+ import_node_child_process5 = require("child_process");
3528
+ import_node_crypto3 = require("crypto");
3529
+ import_node_fs9 = __toESM(require("fs"));
3530
+ import_node_path8 = __toESM(require("path"));
3531
+ MANAGED_MARKER = "devrouter:managed devcontainer";
3532
+ MANAGED_ADAPTER_PATH = ".devcontainer/post-start.sh";
3533
+ RUNTIME_HELPER_PATH = "/tmp/devrouter/bin/devrouter-process";
3534
+ ADAPTER_WRAPPER = `adapter_snapshot="$1"
3535
+ shift
3536
+ readonly DEVROUTER_PROCESS_HELPER DEVROUTER_PROCESS_ADAPTER_SHA256
3537
+ source "$adapter_snapshot"
3538
+ `;
3539
+ }
3540
+ });
3541
+
3145
3542
  // src/core/devcontainer-diagnostics.ts
3146
3543
  function asRecord(value) {
3147
3544
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -3196,7 +3593,7 @@ function isLoopbackHost(host) {
3196
3593
  return host === "127.0.0.1" || host === "localhost" || host === "host.docker.internal";
3197
3594
  }
3198
3595
  function inspectCompose(composeFile, _workspace) {
3199
- if (!import_node_fs8.default.existsSync(composeFile)) {
3596
+ if (!import_node_fs10.default.existsSync(composeFile)) {
3200
3597
  return {
3201
3598
  aliases: [],
3202
3599
  publishedPorts: [],
@@ -3205,7 +3602,7 @@ function inspectCompose(composeFile, _workspace) {
3205
3602
  };
3206
3603
  }
3207
3604
  try {
3208
- const raw = import_node_fs8.default.readFileSync(composeFile, "utf-8");
3605
+ const raw = import_node_fs10.default.readFileSync(composeFile, "utf-8");
3209
3606
  const parsed = import_yaml3.default.parse(raw);
3210
3607
  const root = asRecord(parsed);
3211
3608
  const services = asRecord(root?.services);
@@ -3262,11 +3659,11 @@ function routedProxyApps(config) {
3262
3659
  );
3263
3660
  }
3264
3661
  function buildDevcontainerChecks(repoPath, config, workspace) {
3265
- const devcontainerDir = import_node_path7.default.join(repoPath, ".devcontainer");
3266
- if (!import_node_fs8.default.existsSync(devcontainerDir)) {
3662
+ const devcontainerDir = import_node_path9.default.join(repoPath, ".devcontainer");
3663
+ if (!import_node_fs10.default.existsSync(devcontainerDir)) {
3267
3664
  return [];
3268
3665
  }
3269
- const compose = inspectCompose(import_node_path7.default.join(devcontainerDir, "docker-compose.yml"), workspace);
3666
+ const compose = inspectCompose(import_node_path9.default.join(devcontainerDir, "docker-compose.yml"), workspace);
3270
3667
  const checks = [];
3271
3668
  if (compose.parseError) {
3272
3669
  checks.push({
@@ -3293,9 +3690,9 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3293
3690
  details: compose.publishedPorts.length > 0 ? compose.publishedPorts.join(", ") : void 0,
3294
3691
  suggestion: compose.parseError || compose.publishedPorts.length > 0 ? "Remove published ports and route services through devnet aliases with devrouter proxy apps." : void 0
3295
3692
  });
3296
- const dockerfilePath = import_node_path7.default.join(devcontainerDir, "Dockerfile");
3297
- const dockerfileExists = import_node_fs8.default.existsSync(dockerfilePath);
3298
- const dockerfile = dockerfileExists ? import_node_fs8.default.readFileSync(dockerfilePath, "utf-8") : "";
3693
+ const dockerfilePath = import_node_path9.default.join(devcontainerDir, "Dockerfile");
3694
+ const dockerfileExists = import_node_fs10.default.existsSync(dockerfilePath);
3695
+ const dockerfile = dockerfileExists ? import_node_fs10.default.readFileSync(dockerfilePath, "utf-8") : "";
3299
3696
  const devrouterArtifacts = ["@devrouter/cli", "devrouter-process"].filter(
3300
3697
  (artifact) => dockerfile.includes(artifact)
3301
3698
  );
@@ -3306,6 +3703,23 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3306
3703
  details: devrouterArtifacts.length > 0 ? `artifacts=${devrouterArtifacts.join(", ")}` : void 0,
3307
3704
  suggestion: !dockerfileExists ? "Inspect the consumer image definition and keep devrouter package/helper installation out of it." : devrouterArtifacts.length > 0 ? "Remove devrouter package/helper installation from the Dockerfile; devrouter ensure delivers the helper at runtime." : void 0
3308
3705
  });
3706
+ try {
3707
+ const managedPostStart = resolveManagedPostStartPlan(repoPath);
3708
+ checks.push({
3709
+ id: "repo.devcontainer.managed-post-start",
3710
+ level: managedPostStart.kind === "legacy" ? "warn" : "ok",
3711
+ summary: managedPostStart.kind === "runtime" ? "Managed post-start uses runtime helper delivery with an exact adapter fingerprint." : managedPostStart.kind === "legacy" ? "Managed post-start still uses the legacy image-installed helper contract." : "No devrouter-managed post-start contract is configured.",
3712
+ suggestion: managedPostStart.kind === "legacy" ? "Regenerate or migrate the devcontainer to runtime helper delivery." : void 0
3713
+ });
3714
+ } catch (error) {
3715
+ checks.push({
3716
+ id: "repo.devcontainer.managed-post-start",
3717
+ level: "error",
3718
+ summary: "Devrouter lifecycle wiring is incomplete or ambiguous.",
3719
+ details: error instanceof Error ? error.message : String(error),
3720
+ suggestion: "Regenerate or migrate the managed devcontainer, or remove devrouter-specific wiring from a truly custom devcontainer."
3721
+ });
3722
+ }
3309
3723
  if (!config) {
3310
3724
  checks.push({
3311
3725
  id: "repo.devcontainer.upstream-alias-match",
@@ -3333,30 +3747,31 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3333
3747
  });
3334
3748
  return checks;
3335
3749
  }
3336
- var import_node_fs8, import_node_path7, import_yaml3;
3750
+ var import_node_fs10, import_node_path9, import_yaml3;
3337
3751
  var init_devcontainer_diagnostics = __esm({
3338
3752
  "src/core/devcontainer-diagnostics.ts"() {
3339
3753
  "use strict";
3340
- import_node_fs8 = __toESM(require("fs"));
3341
- import_node_path7 = __toESM(require("path"));
3754
+ import_node_fs10 = __toESM(require("fs"));
3755
+ import_node_path9 = __toESM(require("path"));
3342
3756
  import_yaml3 = __toESM(require("yaml"));
3757
+ init_managed_post_start();
3343
3758
  }
3344
3759
  });
3345
3760
 
3346
3761
  // src/core/paths.ts
3347
3762
  function assertPathWithinRepo(filePath, repoRoot, label) {
3348
- const resolvedRoot = import_node_path8.default.resolve(repoRoot);
3349
- const resolved = import_node_path8.default.resolve(repoRoot, filePath);
3350
- if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + import_node_path8.default.sep)) {
3763
+ const resolvedRoot = import_node_path10.default.resolve(repoRoot);
3764
+ const resolved = import_node_path10.default.resolve(repoRoot, filePath);
3765
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + import_node_path10.default.sep)) {
3351
3766
  throw new Error(`${label} path '${filePath}' escapes the repository root.`);
3352
3767
  }
3353
3768
  return resolved;
3354
3769
  }
3355
- var import_node_path8;
3770
+ var import_node_path10;
3356
3771
  var init_paths = __esm({
3357
3772
  "src/core/paths.ts"() {
3358
3773
  "use strict";
3359
- import_node_path8 = __toESM(require("path"));
3774
+ import_node_path10 = __toESM(require("path"));
3360
3775
  }
3361
3776
  });
3362
3777
 
@@ -3441,11 +3856,11 @@ function reconcileRouteRunConflict(repoPath, app) {
3441
3856
  }
3442
3857
  }
3443
3858
  }
3444
- var import_node_fs9;
3859
+ var import_node_fs11;
3445
3860
  var init_route_state = __esm({
3446
3861
  "src/core/route-state.ts"() {
3447
3862
  "use strict";
3448
- import_node_fs9 = __toESM(require("fs"));
3863
+ import_node_fs11 = __toESM(require("fs"));
3449
3864
  init_host_routes();
3450
3865
  init_workspace();
3451
3866
  }
@@ -3620,7 +4035,7 @@ function toRepoStatus(repoPath) {
3620
4035
  const resolvedRepoPath = resolveRepoPath(repoPath);
3621
4036
  const configPath = getRepoConfigPath(resolvedRepoPath);
3622
4037
  const explicitRepo = typeof repoPath === "string" && repoPath.trim().length > 0;
3623
- const configExists = import_node_fs10.default.existsSync(configPath);
4038
+ const configExists = import_node_fs12.default.existsSync(configPath);
3624
4039
  if (!explicitRepo && !configExists) {
3625
4040
  return void 0;
3626
4041
  }
@@ -3747,11 +4162,11 @@ async function collectRouterStatus(repoPath) {
3747
4162
  }
3748
4163
  };
3749
4164
  }
3750
- var import_node_fs10;
4165
+ var import_node_fs12;
3751
4166
  var init_status = __esm({
3752
4167
  "src/core/status.ts"() {
3753
4168
  "use strict";
3754
- import_node_fs10 = __toESM(require("fs"));
4169
+ import_node_fs12 = __toESM(require("fs"));
3755
4170
  init_docker();
3756
4171
  init_repo_config();
3757
4172
  init_router();
@@ -3760,7 +4175,7 @@ var init_status = __esm({
3760
4175
 
3761
4176
  // src/core/tls.ts
3762
4177
  function runOrThrow(command, args) {
3763
- const result = (0, import_node_child_process4.spawnSync)(command, args, {
4178
+ const result = (0, import_node_child_process6.spawnSync)(command, args, {
3764
4179
  encoding: "utf-8"
3765
4180
  });
3766
4181
  if (result.status !== 0) {
@@ -3770,7 +4185,7 @@ function runOrThrow(command, args) {
3770
4185
  return result.stdout.trim();
3771
4186
  }
3772
4187
  function commandExists(command) {
3773
- const result = (0, import_node_child_process4.spawnSync)("sh", ["-c", `command -v ${command}`], { encoding: "utf-8" });
4188
+ const result = (0, import_node_child_process6.spawnSync)("sh", ["-c", `command -v ${command}`], { encoding: "utf-8" });
3774
4189
  return result.status === 0;
3775
4190
  }
3776
4191
  function ensureMkcert() {
@@ -3788,8 +4203,8 @@ function tlsSetupCommand(repoPath) {
3788
4203
  }
3789
4204
  function getMkcertRootCAPath(options = {}) {
3790
4205
  ensureMkcert();
3791
- const rootCAPath = import_node_path9.default.join(runOrThrow("mkcert", ["-CAROOT"]), "rootCA.pem");
3792
- if (!import_node_fs11.default.existsSync(rootCAPath)) {
4206
+ const rootCAPath = import_node_path11.default.join(runOrThrow("mkcert", ["-CAROOT"]), "rootCA.pem");
4207
+ if (!import_node_fs13.default.existsSync(rootCAPath)) {
3793
4208
  throw new Error(
3794
4209
  `mkcert root CA was not found at '${rootCAPath}'. Run: ${tlsSetupCommand(options.repoPath)}`
3795
4210
  );
@@ -3822,7 +4237,7 @@ function parseDnsHostsFromSubjectAltName(subjectAltName) {
3822
4237
  return normalizeUniqueHosts(names);
3823
4238
  }
3824
4239
  function parseCertificateDnsHosts(pem) {
3825
- const certificate = new import_node_crypto2.X509Certificate(pem);
4240
+ const certificate = new import_node_crypto4.X509Certificate(pem);
3826
4241
  const subjectAltName = certificate.subjectAltName ?? "";
3827
4242
  if (subjectAltName.length === 0) {
3828
4243
  return [];
@@ -3855,10 +4270,10 @@ function findUncoveredCertificateHosts(requiredHosts, certificateHosts) {
3855
4270
  );
3856
4271
  }
3857
4272
  function readCurrentCertificateHosts() {
3858
- if (!import_node_fs11.default.existsSync(CERT_FILE)) {
4273
+ if (!import_node_fs13.default.existsSync(CERT_FILE)) {
3859
4274
  return [];
3860
4275
  }
3861
- const pem = import_node_fs11.default.readFileSync(CERT_FILE, "utf-8");
4276
+ const pem = import_node_fs13.default.readFileSync(CERT_FILE, "utf-8");
3862
4277
  return parseCertificateDnsHosts(pem);
3863
4278
  }
3864
4279
  function currentCertificateHostsOrEmpty() {
@@ -3947,14 +4362,14 @@ Run: ${tlsSetupCommand(options.repoPath)}`
3947
4362
  );
3948
4363
  }
3949
4364
  }
3950
- var import_node_child_process4, import_node_crypto2, import_node_fs11, import_node_path9, DEFAULT_TLS_CERT_HOSTS;
4365
+ var import_node_child_process6, import_node_crypto4, import_node_fs13, import_node_path11, DEFAULT_TLS_CERT_HOSTS;
3951
4366
  var init_tls = __esm({
3952
4367
  "src/core/tls.ts"() {
3953
4368
  "use strict";
3954
- import_node_child_process4 = require("child_process");
3955
- import_node_crypto2 = require("crypto");
3956
- import_node_fs11 = __toESM(require("fs"));
3957
- import_node_path9 = __toESM(require("path"));
4369
+ import_node_child_process6 = require("child_process");
4370
+ import_node_crypto4 = require("crypto");
4371
+ import_node_fs13 = __toESM(require("fs"));
4372
+ import_node_path11 = __toESM(require("path"));
3958
4373
  init_docker();
3959
4374
  init_host_routes();
3960
4375
  init_router();
@@ -3970,7 +4385,7 @@ function outputFromResult(result) {
3970
4385
  return output2.length > 0 ? output2 : void 0;
3971
4386
  }
3972
4387
  function runTool(command, args = []) {
3973
- const result = (0, import_node_child_process5.spawnSync)(command, args, {
4388
+ const result = (0, import_node_child_process7.spawnSync)(command, args, {
3974
4389
  encoding: "utf-8"
3975
4390
  });
3976
4391
  if (result.error) {
@@ -4027,12 +4442,12 @@ function parseMinimumNodeMajor(value) {
4027
4442
  return Number(match[1]);
4028
4443
  }
4029
4444
  function readPackageJson(repoPath) {
4030
- const packagePath = import_node_path10.default.join(repoPath, "package.json");
4031
- if (!import_node_fs12.default.existsSync(packagePath)) {
4445
+ const packagePath = import_node_path12.default.join(repoPath, "package.json");
4446
+ if (!import_node_fs14.default.existsSync(packagePath)) {
4032
4447
  return void 0;
4033
4448
  }
4034
4449
  try {
4035
- return JSON.parse(import_node_fs12.default.readFileSync(packagePath, "utf-8"));
4450
+ return JSON.parse(import_node_fs14.default.readFileSync(packagePath, "utf-8"));
4036
4451
  } catch {
4037
4452
  return void 0;
4038
4453
  }
@@ -4127,19 +4542,19 @@ function buildGlobalToolChecks(repoPath) {
4127
4542
  checks.push(nodeToolchainCheck(repoPath));
4128
4543
  return checks;
4129
4544
  }
4130
- var import_node_child_process5, import_node_fs12, import_node_path10;
4545
+ var import_node_child_process7, import_node_fs14, import_node_path12;
4131
4546
  var init_tool_diagnostics = __esm({
4132
4547
  "src/core/tool-diagnostics.ts"() {
4133
4548
  "use strict";
4134
- import_node_child_process5 = require("child_process");
4135
- import_node_fs12 = __toESM(require("fs"));
4136
- import_node_path10 = __toESM(require("path"));
4549
+ import_node_child_process7 = require("child_process");
4550
+ import_node_fs14 = __toESM(require("fs"));
4551
+ import_node_path12 = __toESM(require("path"));
4137
4552
  }
4138
4553
  });
4139
4554
 
4140
4555
  // src/core/devpod-workspaces.ts
4141
4556
  function listDevpodWorkspaces() {
4142
- const result = (0, import_node_child_process6.spawnSync)("devpod", ["list", "--output", "json"], { encoding: "utf-8" });
4557
+ const result = (0, import_node_child_process8.spawnSync)("devpod", ["list", "--output", "json"], { encoding: "utf-8" });
4143
4558
  if (result.status !== 0) {
4144
4559
  const details = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4145
4560
  throw new Error(`devpod list failed: ${details || "devpod is not installed or unavailable"}`);
@@ -4188,20 +4603,138 @@ function selectDevpodWorkspace(workspaces, repoPath) {
4188
4603
  }
4189
4604
  return matches[0];
4190
4605
  }
4191
- function runDevpodWorkspaceAction(action2, devpodId) {
4606
+ var import_node_child_process8;
4607
+ var init_devpod_workspaces = __esm({
4608
+ "src/core/devpod-workspaces.ts"() {
4609
+ "use strict";
4610
+ import_node_child_process8 = require("child_process");
4611
+ init_workspace();
4612
+ }
4613
+ });
4614
+
4615
+ // src/core/devpod-mutation.ts
4616
+ function withMutationLock(activity, target, operation) {
4617
+ import_node_fs15.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
4618
+ return withFileLockSync(
4619
+ DEVPOD_MUTATION_LOCK_FILE,
4620
+ { activity, target: `'${target}'`, waitMs: DEVPOD_MUTATION_WAIT_MS },
4621
+ operation
4622
+ );
4623
+ }
4624
+ function commandFailure2(result) {
4625
+ return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4626
+ }
4627
+ function runDevpodAction(action2, devpodId) {
4192
4628
  const args = action2 === "delete" ? [action2, devpodId, "--ignore-not-found"] : [action2, devpodId];
4193
- const result = (0, import_node_child_process6.spawnSync)("devpod", args, { encoding: "utf-8" });
4629
+ const result = (0, import_node_child_process9.spawnSync)("devpod", args, { encoding: "utf-8" });
4194
4630
  if (result.status !== 0) {
4195
- const detail = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4196
- throw new Error(`devpod ${action2} failed for '${devpodId}': ${detail || "unknown error"}`);
4631
+ throw new Error(
4632
+ `devpod ${action2} failed for '${devpodId}': ${commandFailure2(result) || "unknown error"}`
4633
+ );
4197
4634
  }
4198
4635
  }
4199
- var import_node_child_process6;
4200
- var init_devpod_workspaces = __esm({
4201
- "src/core/devpod-workspaces.ts"() {
4636
+ function mutateOwnedDevpodWorkspace(action2, devpodId, worktreePath) {
4637
+ return withMutationLock(`DevPod ${action2}`, worktreePath, () => {
4638
+ const before = inspectDevpodWorkspaceOwnership(listDevpodWorkspaces(), devpodId, worktreePath);
4639
+ if (before.status === "conflict") throw new Error(before.reason);
4640
+ if (before.status === "absent") return { status: "absent" };
4641
+ runDevpodAction(action2, devpodId);
4642
+ const after = inspectDevpodWorkspaceOwnership(listDevpodWorkspaces(), devpodId, worktreePath);
4643
+ if (after.status === "conflict") throw new Error(after.reason);
4644
+ if (action2 === "stop" && after.status !== "owned") {
4645
+ throw new Error(`DevPod '${devpodId}' no longer owns '${worktreePath}' after provider stop.`);
4646
+ }
4647
+ if (action2 === "delete" && after.status !== "absent") {
4648
+ throw new Error(`DevPod '${devpodId}' still owns '${worktreePath}' after provider delete.`);
4649
+ }
4650
+ return { status: "changed" };
4651
+ });
4652
+ }
4653
+ function stopOwnedDevpodWorkspace(devpodId, worktreePath) {
4654
+ return mutateOwnedDevpodWorkspace("stop", devpodId, worktreePath);
4655
+ }
4656
+ function deleteOwnedDevpodWorkspace(devpodId, worktreePath) {
4657
+ return mutateOwnedDevpodWorkspace("delete", devpodId, worktreePath);
4658
+ }
4659
+ function startDevpodWorkspace(options) {
4660
+ const activity = options.recreate ? "DevPod recreate" : "DevPod start";
4661
+ return withMutationLock(activity, options.repoPath, () => {
4662
+ const workspaces = listDevpodWorkspaces();
4663
+ let devpodId = options.devpodId ?? selectDevpodWorkspace(workspaces, options.repoPath)?.id;
4664
+ if (devpodId) {
4665
+ const before = inspectDevpodWorkspaceOwnership(workspaces, devpodId, options.repoPath);
4666
+ if (before.status === "conflict") throw new Error(before.reason);
4667
+ if (options.recreate && before.status !== "owned") {
4668
+ throw new Error(`Cannot recreate DevPod '${devpodId}' without one exact owner.`);
4669
+ }
4670
+ } else if (options.recreate) {
4671
+ throw new Error("Cannot recreate a DevPod before its exact id is known.");
4672
+ }
4673
+ const args = ["up", options.repoPath];
4674
+ if (devpodId) args.push("--id", devpodId);
4675
+ args.push("--open-ide=false");
4676
+ if (options.workspace) {
4677
+ args.push(
4678
+ "--workspace-env",
4679
+ `WORKSPACE=${options.workspace.token}`,
4680
+ "--workspace-env",
4681
+ `DEVROUTER_WORKSPACE=${options.workspace.token}`
4682
+ );
4683
+ }
4684
+ if (options.recreate) args.push("--recreate");
4685
+ const env = { ...process.env };
4686
+ if (options.workspace) {
4687
+ env.WORKSPACE = options.workspace.token;
4688
+ env.DEVROUTER_WORKSPACE = options.workspace.token;
4689
+ env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
4690
+ env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
4691
+ } else {
4692
+ delete env.WORKSPACE;
4693
+ delete env.DEVROUTER_WORKSPACE;
4694
+ delete env.DEVROUTER_GIT_COMMON_DIR;
4695
+ delete env.DEVCONTAINER_COMPOSE_OVERLAY;
4696
+ }
4697
+ const result = (0, import_node_child_process9.spawnSync)("devpod", args, {
4698
+ stdio: options.quiet ? ["inherit", 2, "inherit"] : "inherit",
4699
+ env
4700
+ });
4701
+ if (result.status !== 0) {
4702
+ throw new Error(`devpod up failed for '${devpodId ?? options.repoPath}'.`);
4703
+ }
4704
+ try {
4705
+ const attached = listDevpodWorkspaces();
4706
+ devpodId ??= selectDevpodWorkspace(attached, options.repoPath)?.id;
4707
+ if (!devpodId) {
4708
+ throw new Error(`DevPod did not attach '${options.repoPath}' after startup.`);
4709
+ }
4710
+ const ownership = inspectDevpodWorkspaceOwnership(attached, devpodId, options.repoPath);
4711
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
4712
+ if (ownership.status !== "owned") {
4713
+ throw new Error(
4714
+ `DevPod did not attach '${options.repoPath}' as '${devpodId}' after startup.`
4715
+ );
4716
+ }
4717
+ return devpodId;
4718
+ } catch (error) {
4719
+ const message = error instanceof Error ? error.message : String(error);
4720
+ throw new DevpodStartPostconditionError(message);
4721
+ }
4722
+ });
4723
+ }
4724
+ var import_node_child_process9, import_node_fs15, import_node_path13, DEVPOD_MUTATION_LOCK_FILE, DEVPOD_MUTATION_WAIT_MS, DevpodStartPostconditionError;
4725
+ var init_devpod_mutation = __esm({
4726
+ "src/core/devpod-mutation.ts"() {
4202
4727
  "use strict";
4203
- import_node_child_process6 = require("child_process");
4204
- init_workspace();
4728
+ import_node_child_process9 = require("child_process");
4729
+ import_node_fs15 = __toESM(require("fs"));
4730
+ import_node_path13 = __toESM(require("path"));
4731
+ init_devpod_workspaces();
4732
+ init_file_lock();
4733
+ init_router();
4734
+ DEVPOD_MUTATION_LOCK_FILE = import_node_path13.default.join(DEVROUTER_HOME, "devpod-mutation.lock");
4735
+ DEVPOD_MUTATION_WAIT_MS = 6e4;
4736
+ DevpodStartPostconditionError = class extends Error {
4737
+ };
4205
4738
  }
4206
4739
  });
4207
4740
 
@@ -4212,17 +4745,17 @@ function commandError(command, repoPath, stderr) {
4212
4745
  );
4213
4746
  }
4214
4747
  function resolveGitCommonDir(repoPath) {
4215
- const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
4748
+ const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
4216
4749
  encoding: "utf-8"
4217
4750
  });
4218
4751
  const output2 = result.stdout.trim();
4219
4752
  if (result.status !== 0 || !output2) {
4220
4753
  throw commandError("Could not resolve the Git common directory", repoPath, result.stderr);
4221
4754
  }
4222
- return comparableWorkspacePath(import_node_path11.default.isAbsolute(output2) ? output2 : import_node_path11.default.resolve(repoPath, output2));
4755
+ return comparableWorkspacePath(import_node_path14.default.isAbsolute(output2) ? output2 : import_node_path14.default.resolve(repoPath, output2));
4223
4756
  }
4224
4757
  function resolveGitTopLevel(repoPath) {
4225
- const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], {
4758
+ const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], {
4226
4759
  encoding: "utf-8"
4227
4760
  });
4228
4761
  const output2 = result.stdout.trim();
@@ -4232,7 +4765,7 @@ function resolveGitTopLevel(repoPath) {
4232
4765
  return comparableWorkspacePath(output2);
4233
4766
  }
4234
4767
  function listGitWorktrees(repoPath) {
4235
- const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
4768
+ const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
4236
4769
  encoding: "utf-8"
4237
4770
  });
4238
4771
  if (result.status !== 0) {
@@ -4268,7 +4801,7 @@ function listGitWorktrees(repoPath) {
4268
4801
  return worktrees;
4269
4802
  }
4270
4803
  function ownershipDirectory(repoPath) {
4271
- return import_node_path11.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
4804
+ return import_node_path14.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
4272
4805
  }
4273
4806
  function validateWorkspace(value, label) {
4274
4807
  if (typeof value !== "string" || wsFromBranch(value) !== value) {
@@ -4296,7 +4829,7 @@ function validateRecord(value, expectedWorkspace) {
4296
4829
  `workspace ownership file '${expectedWorkspace}' contains identity '${workspace}'`
4297
4830
  );
4298
4831
  }
4299
- if (typeof candidate.worktreePath !== "string" || !import_node_path11.default.isAbsolute(candidate.worktreePath)) {
4832
+ if (typeof candidate.worktreePath !== "string" || !import_node_path14.default.isAbsolute(candidate.worktreePath)) {
4300
4833
  throw new Error("invalid workspace ownership worktreePath");
4301
4834
  }
4302
4835
  if (candidate.branch !== null && typeof candidate.branch !== "string") {
@@ -4316,7 +4849,7 @@ function validateRecord(value, expectedWorkspace) {
4316
4849
  function readRecordFile(filePath, expectedWorkspace) {
4317
4850
  let parsed;
4318
4851
  try {
4319
- parsed = JSON.parse(import_node_fs13.default.readFileSync(filePath, "utf-8"));
4852
+ parsed = JSON.parse(import_node_fs16.default.readFileSync(filePath, "utf-8"));
4320
4853
  } catch (error) {
4321
4854
  if (error instanceof SyntaxError) {
4322
4855
  throw new Error(`invalid workspace ownership JSON at '${filePath}'`);
@@ -4332,7 +4865,7 @@ function listWorkspaceOwnership(repoPath) {
4332
4865
  function listWorkspaceOwnershipInDirectory(directory) {
4333
4866
  let entries;
4334
4867
  try {
4335
- entries = import_node_fs13.default.readdirSync(directory, { withFileTypes: true });
4868
+ entries = import_node_fs16.default.readdirSync(directory, { withFileTypes: true });
4336
4869
  } catch (error) {
4337
4870
  if (error.code === "ENOENT") return [];
4338
4871
  throw error;
@@ -4340,14 +4873,14 @@ function listWorkspaceOwnershipInDirectory(directory) {
4340
4873
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
4341
4874
  const workspace = entry.name.slice(0, -".json".length);
4342
4875
  validateWorkspace(workspace, "filename");
4343
- return readRecordFile(import_node_path11.default.join(directory, entry.name), workspace);
4876
+ return readRecordFile(import_node_path14.default.join(directory, entry.name), workspace);
4344
4877
  });
4345
4878
  }
4346
4879
  function writeWorkspaceOwnershipInDirectory(directory, input2) {
4347
4880
  const workspace = validateWorkspace(input2.workspace, "workspace");
4348
4881
  const devpodId = validateWorkspace(input2.devpodId, "devpodId");
4349
4882
  const worktreePath = comparableWorkspacePath(input2.worktreePath);
4350
- const filePath = import_node_path11.default.join(directory, `${workspace}.json`);
4883
+ const filePath = import_node_path14.default.join(directory, `${workspace}.json`);
4351
4884
  const now = (/* @__PURE__ */ new Date()).toISOString();
4352
4885
  const records = listWorkspaceOwnershipInDirectory(directory);
4353
4886
  const existing = records.find((record2) => record2.workspace === workspace);
@@ -4378,23 +4911,14 @@ function writeWorkspaceOwnershipInDirectory(directory, input2) {
4378
4911
  createdAt: existing?.createdAt ?? validateTimestamp(now, "createdAt"),
4379
4912
  updatedAt: validateTimestamp(now, "updatedAt")
4380
4913
  };
4381
- const tempPath = `${filePath}.${process.pid}.${(0, import_node_crypto3.randomUUID)()}.tmp`;
4382
- try {
4383
- import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify(record, null, 2)}
4384
- `, {
4385
- encoding: "utf-8",
4386
- flag: "wx"
4387
- });
4388
- import_node_fs13.default.renameSync(tempPath, filePath);
4389
- } finally {
4390
- import_node_fs13.default.rmSync(tempPath, { force: true });
4391
- }
4914
+ writeFileAtomically(filePath, `${JSON.stringify(record, null, 2)}
4915
+ `);
4392
4916
  return record;
4393
4917
  }
4394
4918
  function removeWorkspaceOwnershipInDirectory(directory, workspace) {
4395
- const filePath = import_node_path11.default.join(directory, `${validateWorkspace(workspace, "workspace")}.json`);
4919
+ const filePath = import_node_path14.default.join(directory, `${validateWorkspace(workspace, "workspace")}.json`);
4396
4920
  try {
4397
- import_node_fs13.default.rmSync(filePath);
4921
+ import_node_fs16.default.rmSync(filePath);
4398
4922
  return true;
4399
4923
  } catch (error) {
4400
4924
  if (error.code === "ENOENT") return false;
@@ -4405,7 +4929,7 @@ function sameOwnershipRecord(left, right) {
4405
4929
  return left.version === right.version && left.workspace === right.workspace && sameWorkspacePath(left.worktreePath, right.worktreePath) && left.branch === right.branch && left.devpodId === right.devpodId && left.createdAt === right.createdAt && left.updatedAt === right.updatedAt;
4406
4930
  }
4407
4931
  function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
4408
- const filePath = import_node_path11.default.join(
4932
+ const filePath = import_node_path14.default.join(
4409
4933
  directory,
4410
4934
  `${validateWorkspace(expected.workspace, "workspace")}.json`
4411
4935
  );
@@ -4417,14 +4941,14 @@ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
4417
4941
  throw error;
4418
4942
  }
4419
4943
  if (!sameOwnershipRecord(current, expected)) return "changed";
4420
- import_node_fs13.default.rmSync(filePath);
4944
+ import_node_fs16.default.rmSync(filePath);
4421
4945
  return "removed";
4422
4946
  }
4423
4947
  function withWorkspaceOwnershipTransaction(repoPath, operation) {
4424
4948
  const directory = ownershipDirectory(repoPath);
4425
- import_node_fs13.default.mkdirSync(directory, { recursive: true });
4949
+ import_node_fs16.default.mkdirSync(directory, { recursive: true });
4426
4950
  return withFileLockSync(
4427
- import_node_path11.default.join(directory, ".lock"),
4951
+ import_node_path14.default.join(directory, ".lock"),
4428
4952
  { activity: "workspace ownership transaction", target: `'${repoPath}'`, waitMs: 5e3 },
4429
4953
  () => operation({
4430
4954
  list: () => listWorkspaceOwnershipInDirectory(directory),
@@ -4455,7 +4979,7 @@ function inspectWorkspaceOwnership(record, worktrees, devpods) {
4455
4979
  if (worktree?.locked) {
4456
4980
  return { ownerStatus: "locked", devpodStatus, worktree };
4457
4981
  }
4458
- if ((!worktree || worktree.prunable) && import_node_fs13.default.existsSync(record.worktreePath)) {
4982
+ if ((!worktree || worktree.prunable) && import_node_fs16.default.existsSync(record.worktreePath)) {
4459
4983
  return { ownerStatus: "conflict", devpodStatus, worktree };
4460
4984
  }
4461
4985
  if (!worktree || worktree.prunable) {
@@ -4479,19 +5003,19 @@ function listMissingWorkspaceOwnership(repoPath) {
4479
5003
  (record) => inspectWorkspaceOwnership(record, worktrees, void 0).ownerStatus === "missing"
4480
5004
  );
4481
5005
  }
4482
- var import_node_child_process7, import_node_crypto3, import_node_fs13, import_node_path11, OWNERSHIP_VERSION, OWNERSHIP_DIR;
5006
+ var import_node_child_process10, import_node_fs16, import_node_path14, OWNERSHIP_VERSION, OWNERSHIP_DIR;
4483
5007
  var init_workspace_ownership = __esm({
4484
5008
  "src/core/workspace-ownership.ts"() {
4485
5009
  "use strict";
4486
- import_node_child_process7 = require("child_process");
4487
- import_node_crypto3 = require("crypto");
4488
- import_node_fs13 = __toESM(require("fs"));
4489
- import_node_path11 = __toESM(require("path"));
5010
+ import_node_child_process10 = require("child_process");
5011
+ import_node_fs16 = __toESM(require("fs"));
5012
+ import_node_path14 = __toESM(require("path"));
5013
+ init_atomic_file();
4490
5014
  init_devpod_workspaces();
4491
5015
  init_file_lock();
4492
5016
  init_workspace();
4493
5017
  OWNERSHIP_VERSION = 1;
4494
- OWNERSHIP_DIR = import_node_path11.default.join("devrouter", "workspaces");
5018
+ OWNERSHIP_DIR = import_node_path14.default.join("devrouter", "workspaces");
4495
5019
  }
4496
5020
  });
4497
5021
 
@@ -4505,10 +5029,10 @@ function inRepositoryWorkspaceScope(repoPath, worktreePath, livePaths) {
4505
5029
  if (livePaths.some((candidate) => sameWorkspacePath(candidate, worktreePath))) return true;
4506
5030
  const comparableRepo = comparableWorkspacePath(repoPath);
4507
5031
  const comparableWorktree = comparableWorkspacePath(worktreePath);
4508
- const localRoot = import_node_path12.default.join(comparableRepo, "trees") + import_node_path12.default.sep;
5032
+ const localRoot = import_node_path15.default.join(comparableRepo, "trees") + import_node_path15.default.sep;
4509
5033
  if (comparableWorktree.startsWith(localRoot)) return true;
4510
- const legacyPrefix = `${import_node_path12.default.basename(comparableRepo)}-`;
4511
- return import_node_path12.default.dirname(comparableWorktree) === import_node_path12.default.dirname(comparableRepo) && import_node_path12.default.basename(comparableWorktree).startsWith(legacyPrefix);
5034
+ const legacyPrefix = `${import_node_path15.default.basename(comparableRepo)}-`;
5035
+ return import_node_path15.default.dirname(comparableWorktree) === import_node_path15.default.dirname(comparableRepo) && import_node_path15.default.basename(comparableWorktree).startsWith(legacyPrefix);
4512
5036
  }
4513
5037
  function previewActions(devpodStatus, routeCount, includeRecord) {
4514
5038
  const actions = [
@@ -4636,17 +5160,6 @@ function revalidateCandidate(candidate, repoPath, transaction) {
4636
5160
  }
4637
5161
  };
4638
5162
  }
4639
- function recheckDevpodOwnership(record) {
4640
- const ownership = inspectDevpodWorkspaceOwnership(
4641
- listDevpodWorkspaces(),
4642
- record.devpodId,
4643
- record.worktreePath
4644
- );
4645
- if (ownership.status === "conflict") {
4646
- throw new Error(ownership.reason);
4647
- }
4648
- return ownership.status;
4649
- }
4650
5163
  function failedAction(resource, error) {
4651
5164
  return {
4652
5165
  resource,
@@ -4676,8 +5189,8 @@ function applyCandidate(candidate, repoPath) {
4676
5189
  const actions = [];
4677
5190
  let devpodStatus;
4678
5191
  try {
4679
- devpodStatus = recheckDevpodOwnership(record);
4680
- if (devpodStatus === "owned") runDevpodWorkspaceAction("delete", record.devpodId);
5192
+ const mutation = deleteOwnedDevpodWorkspace(record.devpodId, record.worktreePath);
5193
+ devpodStatus = mutation.status === "changed" ? "owned" : "absent";
4681
5194
  } catch (error) {
4682
5195
  return { ...fresh, actions: [failedAction("devpod", error)] };
4683
5196
  }
@@ -4762,11 +5275,12 @@ function applyWorkspaceGc(plan) {
4762
5275
  candidates
4763
5276
  };
4764
5277
  }
4765
- var import_node_path12;
5278
+ var import_node_path15;
4766
5279
  var init_workspace_gc = __esm({
4767
5280
  "src/core/workspace-gc.ts"() {
4768
5281
  "use strict";
4769
- import_node_path12 = __toESM(require("path"));
5282
+ import_node_path15 = __toESM(require("path"));
5283
+ init_devpod_mutation();
4770
5284
  init_devpod_workspaces();
4771
5285
  init_host_routes();
4772
5286
  init_repo_config();
@@ -4835,11 +5349,11 @@ function inspectPostgresCredentials(repoPath, config) {
4835
5349
  parseErrors.push(`${app.name}: ${composeFile} (${message})`);
4836
5350
  continue;
4837
5351
  }
4838
- if (!import_node_fs14.default.existsSync(absolutePath)) {
5352
+ if (!import_node_fs17.default.existsSync(absolutePath)) {
4839
5353
  continue;
4840
5354
  }
4841
5355
  try {
4842
- const raw = import_node_fs14.default.readFileSync(absolutePath, "utf-8");
5356
+ const raw = import_node_fs17.default.readFileSync(absolutePath, "utf-8");
4843
5357
  const parsed = import_yaml4.default.parse(raw);
4844
5358
  const root = asRecord2(parsed);
4845
5359
  const services = asRecord2(root?.services);
@@ -5102,7 +5616,7 @@ async function buildDoctorReport(options = {}) {
5102
5616
  const config = runtimeConfig.config;
5103
5617
  loadedConfig = config;
5104
5618
  loadedWorkspace = runtimeConfig.workspace;
5105
- const cliVersion = true ? "0.0.34" : "0.0.0-dev";
5619
+ const cliVersion = true ? "0.0.35" : "0.0.0-dev";
5106
5620
  const configVersion = config.devrouter?.version;
5107
5621
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
5108
5622
  addCheck(checks, {
@@ -5132,9 +5646,9 @@ async function buildDoctorReport(options = {}) {
5132
5646
  (app) => app.docker.composeFiles.map((filePath) => ({
5133
5647
  app: app.name,
5134
5648
  filePath,
5135
- absolutePath: import_node_path13.default.resolve(repo.path, filePath)
5649
+ absolutePath: import_node_path16.default.resolve(repo.path, filePath)
5136
5650
  }))
5137
- ).filter((entry) => !import_node_fs14.default.existsSync(entry.absolutePath));
5651
+ ).filter((entry) => !import_node_fs17.default.existsSync(entry.absolutePath));
5138
5652
  addCheck(checks, {
5139
5653
  id: "repo.compose-files",
5140
5654
  level: missingComposeFiles.length === 0 ? "ok" : "error",
@@ -5177,8 +5691,8 @@ async function buildDoctorReport(options = {}) {
5177
5691
  const missingHostCwds = config.apps.filter((app) => app.runtime === "host").map((app) => ({
5178
5692
  app: app.name,
5179
5693
  cwd: app.hostRun.cwd,
5180
- absolutePath: import_node_path13.default.resolve(repo.path, app.hostRun.cwd)
5181
- })).filter((entry) => !import_node_fs14.default.existsSync(entry.absolutePath));
5694
+ absolutePath: import_node_path16.default.resolve(repo.path, app.hostRun.cwd)
5695
+ })).filter((entry) => !import_node_fs17.default.existsSync(entry.absolutePath));
5182
5696
  addCheck(checks, {
5183
5697
  id: "repo.host-cwd",
5184
5698
  level: missingHostCwds.length === 0 ? "ok" : "error",
@@ -5322,12 +5836,12 @@ async function buildDoctorReport(options = {}) {
5322
5836
  nextSteps
5323
5837
  };
5324
5838
  }
5325
- var import_node_fs14, import_node_path13, import_yaml4, POSTGRES_DEFAULTS;
5839
+ var import_node_fs17, import_node_path16, import_yaml4, POSTGRES_DEFAULTS;
5326
5840
  var init_doctor = __esm({
5327
5841
  "src/core/doctor.ts"() {
5328
5842
  "use strict";
5329
- import_node_fs14 = __toESM(require("fs"));
5330
- import_node_path13 = __toESM(require("path"));
5843
+ import_node_fs17 = __toESM(require("fs"));
5844
+ import_node_path16 = __toESM(require("path"));
5331
5845
  import_yaml4 = __toESM(require("yaml"));
5332
5846
  init_docker();
5333
5847
  init_host_routes();
@@ -5585,12 +6099,12 @@ function parseSsPortListeners(stdout, port) {
5585
6099
  return listeners;
5586
6100
  }
5587
6101
  function findPortListeners(port) {
5588
- const result = (0, import_node_child_process8.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
6102
+ const result = (0, import_node_child_process11.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
5589
6103
  encoding: "utf-8"
5590
6104
  });
5591
6105
  if (result.error && result.error.code === "ENOENT") {
5592
6106
  if (process.platform === "linux") {
5593
- const ssResult = (0, import_node_child_process8.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
6107
+ const ssResult = (0, import_node_child_process11.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
5594
6108
  if (ssResult.status === 0 && ssResult.stdout) {
5595
6109
  return parseSsPortListeners(ssResult.stdout, port);
5596
6110
  }
@@ -5619,11 +6133,11 @@ function findPortListeners(port) {
5619
6133
  };
5620
6134
  });
5621
6135
  }
5622
- var import_node_child_process8;
6136
+ var import_node_child_process11;
5623
6137
  var init_ports = __esm({
5624
6138
  "src/util/ports.ts"() {
5625
6139
  "use strict";
5626
- import_node_child_process8 = require("child_process");
6140
+ import_node_child_process11 = require("child_process");
5627
6141
  }
5628
6142
  });
5629
6143
 
@@ -5680,7 +6194,7 @@ var init_up = __esm({
5680
6194
 
5681
6195
  // src/core/devpod-environment.ts
5682
6196
  function inspectWorkspaceContainers() {
5683
- const listed = (0, import_node_child_process9.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
6197
+ const listed = (0, import_node_child_process12.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
5684
6198
  encoding: "utf-8"
5685
6199
  });
5686
6200
  if (listed.status !== 0) {
@@ -5690,7 +6204,7 @@ function inspectWorkspaceContainers() {
5690
6204
  }
5691
6205
  const ids = listed.stdout.split(/\r?\n/).map((id) => id.trim()).filter(Boolean);
5692
6206
  if (ids.length === 0) return [];
5693
- const inspected = (0, import_node_child_process9.spawnSync)("docker", ["inspect", "--format", SAFE_INSPECT_TEMPLATE, ...ids], {
6207
+ const inspected = (0, import_node_child_process12.spawnSync)("docker", ["inspect", "--format", SAFE_INSPECT_TEMPLATE, ...ids], {
5694
6208
  encoding: "utf-8"
5695
6209
  });
5696
6210
  if (inspected.status !== 0) {
@@ -5703,7 +6217,7 @@ function inspectWorkspaceContainers() {
5703
6217
  function workspaceAppContainers(containers, repoPath) {
5704
6218
  return containers.filter((container) => {
5705
6219
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
5706
- return Boolean(workingDir && sameWorkspacePath(workingDir, import_node_path14.default.join(repoPath, ".devcontainer"))) && container.mounts.some(
6220
+ return Boolean(workingDir && sameWorkspacePath(workingDir, import_node_path17.default.join(repoPath, ".devcontainer"))) && container.mounts.some(
5707
6221
  (mount) => mount.Type === "bind" && sameWorkspacePath(mount.Source, repoPath)
5708
6222
  );
5709
6223
  });
@@ -5726,12 +6240,12 @@ function resolveRunningWorkspaceContainer(repoPath) {
5726
6240
  }
5727
6241
  return { id: container.id, workspacePath: repoMount.Destination };
5728
6242
  }
5729
- var import_node_child_process9, import_node_path14, SAFE_INSPECT_TEMPLATE;
6243
+ var import_node_child_process12, import_node_path17, SAFE_INSPECT_TEMPLATE;
5730
6244
  var init_devpod_environment = __esm({
5731
6245
  "src/core/devpod-environment.ts"() {
5732
6246
  "use strict";
5733
- import_node_child_process9 = require("child_process");
5734
- import_node_path14 = __toESM(require("path"));
6247
+ import_node_child_process12 = require("child_process");
6248
+ import_node_path17 = __toESM(require("path"));
5735
6249
  init_workspace();
5736
6250
  SAFE_INSPECT_TEMPLATE = '{"id":{{json .Id}},"state":{"Running":{{json .State.Running}},"Health":{{with (index .State "Health")}}{"Status":{{json .Status}}}{{else}}null{{end}}},"labels":{"com.docker.compose.project.working_dir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"com.docker.compose.project.config_files":{{json (index .Config.Labels "com.docker.compose.project.config_files")}}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}}}';
5737
6251
  }
@@ -5758,124 +6272,23 @@ function probeHttpRoute(host, options = {}) {
5758
6272
  args.push("--cacert", getMkcertRootCAPath({ repoPath: options.repoPath }));
5759
6273
  }
5760
6274
  args.push(url);
5761
- const result = (0, import_node_child_process10.spawnSync)("curl", args, { encoding: "utf-8" });
6275
+ const result = (0, import_node_child_process13.spawnSync)("curl", args, { encoding: "utf-8" });
5762
6276
  const stdout = result.stdout?.trim() ?? "";
5763
6277
  const status = /^\d{3}$/.test(stdout) ? Number(stdout) : void 0;
5764
6278
  const ok = result.status === 0 && status !== void 0 && status >= 100 && status < 500;
5765
6279
  const details = ok ? `HTTP ${status}` : [status === void 0 ? void 0 : `HTTP ${status}`, result.stderr?.trim()].filter(Boolean).join(": ") || `curl exited with status ${result.status ?? "unknown"}`;
5766
6280
  return { ok, status, details };
5767
6281
  }
5768
- var import_node_child_process10;
6282
+ var import_node_child_process13;
5769
6283
  var init_http_route_probe = __esm({
5770
6284
  "src/core/http-route-probe.ts"() {
5771
6285
  "use strict";
5772
- import_node_child_process10 = require("child_process");
6286
+ import_node_child_process13 = require("child_process");
5773
6287
  init_router();
5774
6288
  init_tls();
5775
6289
  }
5776
6290
  });
5777
6291
 
5778
- // src/core/managed-post-start.ts
5779
- function commandFailure(result) {
5780
- return [result.error?.message, result.stderr, result.stdout].filter(Boolean).map((value) => String(value).trim()).filter(Boolean).join("\n");
5781
- }
5782
- function readRegularFile(filePath) {
5783
- if (!import_node_fs15.default.existsSync(filePath) || !import_node_fs15.default.lstatSync(filePath).isFile()) return void 0;
5784
- return import_node_fs15.default.readFileSync(filePath, "utf-8");
5785
- }
5786
- function resolveProcessHelperPath() {
5787
- const candidates = [
5788
- import_node_path15.default.resolve(__dirname, "..", "bin", "devrouter-process"),
5789
- import_node_path15.default.resolve(__dirname, "..", "..", "bin", "devrouter-process")
5790
- ];
5791
- const helperPath = candidates.find((candidate) => import_node_fs15.default.existsSync(candidate));
5792
- if (!helperPath) {
5793
- throw new Error("Could not locate the packaged devrouter-process helper.");
5794
- }
5795
- return helperPath;
5796
- }
5797
- function resolveManagedPostStartPlan(repoPath) {
5798
- const adapterPath = import_node_path15.default.join(repoPath, MANAGED_ADAPTER_PATH);
5799
- const adapter = readRegularFile(adapterPath);
5800
- if (adapter === void 0) return { kind: "unmanaged" };
5801
- if (!adapter.includes(MANAGED_MARKER)) return { kind: "unmanaged" };
5802
- if (adapter.includes("DEVROUTER_PROCESS_HELPER")) {
5803
- return { kind: "runtime", adapterPath: MANAGED_ADAPTER_PATH };
5804
- }
5805
- const dockerfilePath = import_node_path15.default.join(repoPath, ".devcontainer", "Dockerfile");
5806
- const devcontainerPath = import_node_path15.default.join(repoPath, ".devcontainer", "devcontainer.json");
5807
- const dockerfile = readRegularFile(dockerfilePath) ?? "";
5808
- const devcontainer = readRegularFile(devcontainerPath) ?? "";
5809
- if (dockerfile.includes("devrouter-process") && devcontainer.includes("postStartCommand")) {
5810
- return { kind: "legacy" };
5811
- }
5812
- throw new Error(
5813
- "Managed post-start must use DEVROUTER_PROCESS_HELPER before removing the legacy image helper and postStartCommand. Regenerate or migrate the devcontainer, then retry devrouter ensure."
5814
- );
5815
- }
5816
- function runManagedPostStart(options) {
5817
- if (options.plan.kind !== "runtime") return;
5818
- const helper = import_node_fs15.default.readFileSync(resolveProcessHelperPath());
5819
- const delivered = (0, import_node_child_process11.spawnSync)(
5820
- "docker",
5821
- ["exec", "-i", options.container.id, "sh", "-c", DELIVERY_SCRIPT],
5822
- { input: helper, encoding: "utf-8" }
5823
- );
5824
- if (delivered.status !== 0) {
5825
- const details = commandFailure(delivered);
5826
- throw new Error(
5827
- `Could not deliver the managed process helper${details ? `: ${details}` : "."}`
5828
- );
5829
- }
5830
- const started = (0, import_node_child_process11.spawnSync)(
5831
- "docker",
5832
- [
5833
- "exec",
5834
- "--workdir",
5835
- options.container.workspacePath,
5836
- "--env",
5837
- `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
5838
- options.container.id,
5839
- "bash",
5840
- options.plan.adapterPath
5841
- ],
5842
- { stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit" }
5843
- );
5844
- if (started.status !== 0) {
5845
- const details = commandFailure(started);
5846
- throw new Error(`Managed post-start failed${details ? `: ${details}` : "."}`);
5847
- }
5848
- }
5849
- var import_node_child_process11, import_node_fs15, import_node_path15, MANAGED_MARKER, MANAGED_ADAPTER_PATH, RUNTIME_HELPER_PATH, DELIVERY_SCRIPT;
5850
- var init_managed_post_start = __esm({
5851
- "src/core/managed-post-start.ts"() {
5852
- "use strict";
5853
- import_node_child_process11 = require("child_process");
5854
- import_node_fs15 = __toESM(require("fs"));
5855
- import_node_path15 = __toESM(require("path"));
5856
- MANAGED_MARKER = "devrouter:managed devcontainer";
5857
- MANAGED_ADAPTER_PATH = ".devcontainer/post-start.sh";
5858
- RUNTIME_HELPER_PATH = "/tmp/devrouter/bin/devrouter-process";
5859
- DELIVERY_SCRIPT = `set -eu
5860
- umask 077
5861
- runtime_root=/tmp/devrouter
5862
- runtime_bin="$runtime_root/bin"
5863
- if [ -L "$runtime_root" ] || [ -L "$runtime_bin" ]; then
5864
- echo "Refusing symlinked devrouter runtime path." >&2
5865
- exit 1
5866
- fi
5867
- mkdir -p "$runtime_bin"
5868
- chmod 700 "$runtime_root" "$runtime_bin"
5869
- temporary="$(mktemp "$runtime_bin/.devrouter-process.XXXXXX")"
5870
- trap 'rm -f "$temporary"' EXIT
5871
- cat > "$temporary"
5872
- chmod 700 "$temporary"
5873
- mv -f "$temporary" "${RUNTIME_HELPER_PATH}"
5874
- trap - EXIT
5875
- `;
5876
- }
5877
- });
5878
-
5879
6292
  // src/core/route-publication.ts
5880
6293
  function routedAppsFromConfig(config) {
5881
6294
  return config.apps.filter((app) => app.kind !== "dependency");
@@ -5949,11 +6362,11 @@ var init_route_publication = __esm({
5949
6362
  // src/core/workspace-ensure.ts
5950
6363
  function assertOverlay(container, repoPath) {
5951
6364
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
5952
- if (!workingDir || !sameWorkspacePath(workingDir, import_node_path16.default.join(repoPath, ".devcontainer"))) {
6365
+ if (!workingDir || !sameWorkspacePath(workingDir, import_node_path18.default.join(repoPath, ".devcontainer"))) {
5953
6366
  throw new Error(`Container '${container.id}' does not belong to the exact worktree.`);
5954
6367
  }
5955
6368
  const configFiles = (container.labels["com.docker.compose.project.config_files"] ?? "").split(",").filter(Boolean);
5956
- const expectedOverlay = import_node_path16.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6369
+ const expectedOverlay = import_node_path18.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
5957
6370
  if (!configFiles.some((configFile) => sameWorkspacePath(configFile, expectedOverlay))) {
5958
6371
  throw new Error(`Container '${container.id}' was not started with ${DEVCONTAINER_OVERLAY}.`);
5959
6372
  }
@@ -6027,7 +6440,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
6027
6440
  target
6028
6441
  });
6029
6442
  if (target.kind === "linked") {
6030
- const workspaceEnv = (0, import_node_child_process12.spawnSync)(
6443
+ const workspaceEnv = (0, import_node_child_process14.spawnSync)(
6031
6444
  "docker",
6032
6445
  ["exec", appContainer.id, "printenv", "WORKSPACE"],
6033
6446
  { encoding: "utf-8" }
@@ -6037,7 +6450,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
6037
6450
  `Workspace app container must expose WORKSPACE='${target.workspace}' (got '${workspaceEnv.stdout.trim() || "(empty)"}').`
6038
6451
  );
6039
6452
  }
6040
- const devrouterWorkspaceEnv = (0, import_node_child_process12.spawnSync)(
6453
+ const devrouterWorkspaceEnv = (0, import_node_child_process14.spawnSync)(
6041
6454
  "docker",
6042
6455
  ["exec", appContainer.id, "printenv", "DEVROUTER_WORKSPACE"],
6043
6456
  { encoding: "utf-8" }
@@ -6048,7 +6461,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
6048
6461
  );
6049
6462
  }
6050
6463
  }
6051
- const gitCheck = (0, import_node_child_process12.spawnSync)(
6464
+ const gitCheck = (0, import_node_child_process14.spawnSync)(
6052
6465
  "docker",
6053
6466
  [
6054
6467
  "exec",
@@ -6135,63 +6548,14 @@ function resolvePrimaryTarget(repoPath) {
6135
6548
  }
6136
6549
  function isPrimaryCheckout(repoPath) {
6137
6550
  try {
6138
- return import_node_fs16.default.statSync(import_node_path16.default.join(repoPath, ".git")).isDirectory();
6551
+ return import_node_fs18.default.statSync(import_node_path18.default.join(repoPath, ".git")).isDirectory();
6139
6552
  } catch {
6140
6553
  return false;
6141
6554
  }
6142
6555
  }
6143
- function startDevpod(repoPath, target, recreate = false, quiet = false) {
6144
- const args = ["up", repoPath];
6145
- if (target.devpodId) {
6146
- args.push("--id", target.devpodId);
6147
- }
6148
- args.push("--open-ide=false");
6149
- if (target.kind === "linked") {
6150
- args.push(
6151
- "--workspace-env",
6152
- `WORKSPACE=${target.workspace}`,
6153
- "--workspace-env",
6154
- `DEVROUTER_WORKSPACE=${target.workspace}`
6155
- );
6156
- }
6157
- if (recreate) {
6158
- if (!target.devpodId) {
6159
- throw new Error("Cannot recreate a DevPod before its exact id is known.");
6160
- }
6161
- args.push("--recreate");
6162
- }
6163
- const env = { ...process.env };
6164
- if (target.kind === "linked") {
6165
- env.WORKSPACE = target.workspace;
6166
- env.DEVROUTER_WORKSPACE = target.workspace;
6167
- env.DEVROUTER_GIT_COMMON_DIR = target.gitCommonDir;
6168
- env.DEVCONTAINER_COMPOSE_OVERLAY = DEVCONTAINER_OVERLAY;
6169
- } else {
6170
- delete env.WORKSPACE;
6171
- delete env.DEVROUTER_WORKSPACE;
6172
- delete env.DEVROUTER_GIT_COMMON_DIR;
6173
- delete env.DEVCONTAINER_COMPOSE_OVERLAY;
6174
- }
6175
- const result = (0, import_node_child_process12.spawnSync)("devpod", args, {
6176
- stdio: quiet ? ["inherit", 2, "inherit"] : "inherit",
6177
- env
6178
- });
6179
- if (result.status !== 0) {
6180
- throw new Error(`devpod up failed for '${target.devpodId ?? repoPath}'.`);
6181
- }
6182
- }
6183
- function assertDevpodAttachment(repoPath, expectedId) {
6184
- const attached = selectDevpodWorkspace(listDevpodWorkspaces(), repoPath);
6185
- if (!attached || expectedId && attached.id !== expectedId) {
6186
- throw new Error(
6187
- `DevPod did not attach '${repoPath}'${expectedId ? ` as '${expectedId}'` : ""} after startup.`
6188
- );
6189
- }
6190
- return attached.id;
6191
- }
6192
6556
  function openUrls(urls) {
6193
6557
  for (const url of urls) {
6194
- const opened = (0, import_node_child_process12.spawnSync)("open", [url], { encoding: "utf-8" });
6558
+ const opened = (0, import_node_child_process14.spawnSync)("open", [url], { encoding: "utf-8" });
6195
6559
  if (opened.status !== 0) {
6196
6560
  process.stderr.write(`Warning: could not open '${url}'.
6197
6561
  `);
@@ -6221,8 +6585,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6221
6585
  const target = linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
6222
6586
  let devpodId = target.devpodId;
6223
6587
  if (target.kind === "linked") {
6224
- const overlayPath = import_node_path16.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6225
- if (!import_node_fs16.default.existsSync(overlayPath)) {
6588
+ const overlayPath = import_node_path18.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6589
+ if (!import_node_fs18.default.existsSync(overlayPath)) {
6226
6590
  throw new Error(`Missing required DevPod compose overlay: ${overlayPath}`);
6227
6591
  }
6228
6592
  }
@@ -6233,16 +6597,16 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6233
6597
  );
6234
6598
  const apps = proxyAppsFromConfig(runtime.config);
6235
6599
  const parsedUpstreams = apps.map((app) => parseUpstream(app.upstream));
6236
- const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path16.default.basename(repoPath)) ?? "app";
6600
+ const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path18.default.basename(repoPath)) ?? "app";
6237
6601
  for (const [index, app] of apps.entries()) {
6238
- if (app.protocol === "tcp" && !parsedUpstreams[index].host.startsWith(`${aliasPrefix}-`)) {
6602
+ if (!parsedUpstreams[index].host.startsWith(`${aliasPrefix}-`)) {
6239
6603
  const owner = target.kind === "linked" ? "workspace" : "checkout";
6240
6604
  throw new Error(
6241
- `TCP app '${app.name}' must use a ${owner}-owned upstream beginning with '${aliasPrefix}-'.`
6605
+ `Proxy app '${app.name}' must use a ${owner}-owned upstream beginning with '${aliasPrefix}-'.`
6242
6606
  );
6243
6607
  }
6244
6608
  }
6245
- const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host).filter((host) => host.startsWith(`${aliasPrefix}-`));
6609
+ const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host);
6246
6610
  const ownership = target.kind === "linked" ? {
6247
6611
  workspace: target.workspace,
6248
6612
  worktreePath: repoPath,
@@ -6254,9 +6618,25 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6254
6618
  }
6255
6619
  const currentTarget = () => target.kind === "linked" ? target : { ...target, devpodId };
6256
6620
  const startAndProveAttachment = (recreate = false) => {
6257
- startDevpod(repoPath, currentTarget(), recreate, options.quiet);
6258
- environmentStarted = true;
6259
- devpodId = assertDevpodAttachment(repoPath, devpodId);
6621
+ const requestedTarget = currentTarget();
6622
+ try {
6623
+ devpodId = startDevpodWorkspace({
6624
+ repoPath,
6625
+ devpodId: requestedTarget.devpodId,
6626
+ recreate,
6627
+ quiet: options.quiet,
6628
+ ...requestedTarget.kind === "linked" ? {
6629
+ workspace: {
6630
+ token: requestedTarget.workspace,
6631
+ gitCommonDir: requestedTarget.gitCommonDir
6632
+ }
6633
+ } : {}
6634
+ });
6635
+ environmentStarted = true;
6636
+ } catch (error) {
6637
+ if (error instanceof DevpodStartPostconditionError) environmentStarted = true;
6638
+ throw error;
6639
+ }
6260
6640
  if (ownership) {
6261
6641
  writeWorkspaceOwnership(repoPath, ownership);
6262
6642
  }
@@ -6357,14 +6737,15 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6357
6737
  }
6358
6738
  });
6359
6739
  }
6360
- var import_node_child_process12, import_node_fs16, import_node_path16, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
6740
+ var import_node_child_process14, import_node_fs18, import_node_path18, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
6361
6741
  var init_workspace_ensure = __esm({
6362
6742
  "src/core/workspace-ensure.ts"() {
6363
6743
  "use strict";
6364
- import_node_child_process12 = require("child_process");
6365
- import_node_fs16 = __toESM(require("fs"));
6366
- import_node_path16 = __toESM(require("path"));
6744
+ import_node_child_process14 = require("child_process");
6745
+ import_node_fs18 = __toESM(require("fs"));
6746
+ import_node_path18 = __toESM(require("path"));
6367
6747
  init_devpod_environment();
6748
+ init_devpod_mutation();
6368
6749
  init_devpod_workspaces();
6369
6750
  init_host_routes();
6370
6751
  init_http_route_probe();
@@ -6438,20 +6819,20 @@ function warnMissingWorkspaceOwnership(repoPath) {
6438
6819
  );
6439
6820
  }
6440
6821
  function defaultWorktreePath(mainRepo, ws) {
6441
- return import_node_path17.default.join(mainRepo, "trees", ws);
6822
+ return import_node_path19.default.join(mainRepo, "trees", ws);
6442
6823
  }
6443
6824
  function assertDefaultWorktreeRootIgnored(mainRepo) {
6444
- const ignored = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "check-ignore", "-q", "--no-index", "trees/"], {
6825
+ const ignored = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "check-ignore", "-q", "--no-index", "trees/"], {
6445
6826
  encoding: "utf-8"
6446
6827
  });
6447
6828
  if (ignored.status !== 0) {
6448
6829
  throw new Error(
6449
- `Default worktree root '${import_node_path17.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path17.default.join(mainRepo, ".gitignore")}' or use --path.`
6830
+ `Default worktree root '${import_node_path19.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path19.default.join(mainRepo, ".gitignore")}' or use --path.`
6450
6831
  );
6451
6832
  }
6452
6833
  }
6453
6834
  function legacyDefaultWorktreePath(mainRepo, ws) {
6454
- return import_node_path17.default.join(import_node_path17.default.dirname(mainRepo), `${import_node_path17.default.basename(mainRepo)}-${ws}`);
6835
+ return import_node_path19.default.join(import_node_path19.default.dirname(mainRepo), `${import_node_path19.default.basename(mainRepo)}-${ws}`);
6455
6836
  }
6456
6837
  function teardownFallbackPath(mainRepo, workspace) {
6457
6838
  const candidates = [
@@ -6543,7 +6924,7 @@ function resolveWorkspaceTarget(mainRepo, target, worktrees, records) {
6543
6924
  function worktreeForRecord(worktrees, record) {
6544
6925
  return worktrees.find((candidate) => sameWorkspacePath(candidate.path, record.worktreePath));
6545
6926
  }
6546
- function devpodForTarget(target, worktrees, devpods) {
6927
+ function assertDevpodTargetSafe(target, worktrees, devpods) {
6547
6928
  if (target.record) {
6548
6929
  const status = inspectWorkspaceOwnership(target.record, worktrees, devpods);
6549
6930
  if (status.ownerStatus === "conflict") {
@@ -6551,26 +6932,25 @@ function devpodForTarget(target, worktrees, devpods) {
6551
6932
  `Workspace '${target.workspace}' ownership conflicts with live Git or DevPod evidence; no resources were changed.`
6552
6933
  );
6553
6934
  }
6554
- return status.devpodStatus === "owned" ? devpods.find((devpod) => devpod.id === target.record?.devpodId) : void 0;
6935
+ return;
6555
6936
  }
6556
6937
  const devpodId = target.workspace;
6557
6938
  const ownership = inspectDevpodWorkspaceOwnership(devpods, devpodId, target.worktreePath);
6558
6939
  if (ownership.status === "conflict") {
6559
6940
  throw new Error(ownership.reason);
6560
6941
  }
6561
- return ownership.status === "owned" ? ownership.workspace : void 0;
6562
6942
  }
6563
6943
  function assertFullDownPreflight(mainRepo, target) {
6564
6944
  if (sameWorkspacePath(target.worktreePath, mainRepo)) {
6565
6945
  throw new Error("Refusing to remove the primary Git checkout.");
6566
6946
  }
6567
- if (!target.worktree || target.worktree.prunable || !import_node_fs17.default.existsSync(target.worktreePath)) return;
6947
+ if (!target.worktree || target.worktree.prunable || !import_node_fs19.default.existsSync(target.worktreePath)) return;
6568
6948
  if (target.worktree.locked) {
6569
6949
  throw new Error(
6570
6950
  `Worktree '${target.worktreePath}' is locked; unlock it before workspace down.`
6571
6951
  );
6572
6952
  }
6573
- const status = (0, import_node_child_process13.spawnSync)(
6953
+ const status = (0, import_node_child_process15.spawnSync)(
6574
6954
  "git",
6575
6955
  ["-C", target.worktreePath, "status", "--porcelain", "--untracked-files=normal"],
6576
6956
  { encoding: "utf-8" }
@@ -6591,8 +6971,8 @@ async function workspaceUp(branch, opts = {}) {
6591
6971
  if (!ws) {
6592
6972
  throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
6593
6973
  }
6594
- const worktreePath = opts.path ? import_node_path17.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
6595
- if (import_node_fs17.default.existsSync(worktreePath)) {
6974
+ const worktreePath = opts.path ? import_node_path19.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
6975
+ if (import_node_fs19.default.existsSync(worktreePath)) {
6596
6976
  const registered = listGitWorktrees(mainRepo).find(
6597
6977
  (worktree) => sameWorkspacePath(worktree.path, worktreePath)
6598
6978
  );
@@ -6610,11 +6990,11 @@ async function workspaceUp(branch, opts = {}) {
6610
6990
  if (!opts.path) {
6611
6991
  assertDefaultWorktreeRootIgnored(mainRepo);
6612
6992
  }
6613
- const add = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
6993
+ const add = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
6614
6994
  encoding: "utf-8"
6615
6995
  });
6616
6996
  if (add.status !== 0) {
6617
- const addNew = (0, import_node_child_process13.spawnSync)(
6997
+ const addNew = (0, import_node_child_process15.spawnSync)(
6618
6998
  "git",
6619
6999
  ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath],
6620
7000
  {
@@ -6685,10 +7065,9 @@ function workspaceLs(repoPath) {
6685
7065
  }
6686
7066
  function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
6687
7067
  const devpods = listDevpodWorkspaces();
6688
- const devpod = devpodForTarget(resolved, worktrees, devpods);
6689
- if (devpod) {
6690
- runDevpodWorkspaceAction(action2 === "stop" ? "stop" : "delete", devpod.id);
6691
- }
7068
+ assertDevpodTargetSafe(resolved, worktrees, devpods);
7069
+ const devpodId = resolved.record?.devpodId ?? resolved.workspace;
7070
+ const mutation = action2 === "stop" ? stopOwnedDevpodWorkspace(devpodId, resolved.worktreePath) : deleteOwnedDevpodWorkspace(devpodId, resolved.worktreePath);
6692
7071
  const routes = removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
6693
7072
  if (!quiet) {
6694
7073
  process.stdout.write(
@@ -6697,9 +7076,9 @@ function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
6697
7076
  );
6698
7077
  }
6699
7078
  return {
6700
- ...devpod ? { devpodId: devpod.id } : {},
7079
+ ...mutation.status === "changed" ? { devpodId } : {},
6701
7080
  freedRoutes: routes.length,
6702
- providerChanged: Boolean(devpod),
7081
+ providerChanged: mutation.status === "changed",
6703
7082
  workspace: resolved.workspace
6704
7083
  };
6705
7084
  }
@@ -6713,10 +7092,15 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
6713
7092
  if (removeWorktree) {
6714
7093
  assertFullDownPreflight(mainRepo, resolved);
6715
7094
  }
6716
- const result = mutateWorkspaceRuntime(action2, resolved, worktrees, opts.quiet);
7095
+ const result = mutateWorkspaceRuntime(
7096
+ action2 === "stop" ? "stop" : "delete",
7097
+ resolved,
7098
+ worktrees,
7099
+ opts.quiet
7100
+ );
6717
7101
  if (removeWorktree) {
6718
- if (resolved.worktree && !resolved.worktree.prunable && import_node_fs17.default.existsSync(resolved.worktreePath)) {
6719
- const rm = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", resolved.worktreePath], {
7102
+ if (resolved.worktree && !resolved.worktree.prunable && import_node_fs19.default.existsSync(resolved.worktreePath)) {
7103
+ const rm = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", resolved.worktreePath], {
6720
7104
  encoding: "utf-8"
6721
7105
  });
6722
7106
  if (rm.status !== 0) {
@@ -6734,9 +7118,9 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
6734
7118
  }
6735
7119
  return result;
6736
7120
  };
6737
- return resolved.worktree && !resolved.worktree.prunable && import_node_fs17.default.existsSync(resolved.worktreePath) ? withWorkspaceLifecycleLock(resolved.worktreePath, operation) : operation();
7121
+ return resolved.worktree && !resolved.worktree.prunable && import_node_fs19.default.existsSync(resolved.worktreePath) ? withWorkspaceLifecycleLock(resolved.worktreePath, operation) : operation();
6738
7122
  }
6739
- async function workspaceStopOwnedPath(worktreePath, opts = {}) {
7123
+ async function mutateWorkspaceOwnedPath(action2, worktreePath, opts = {}) {
6740
7124
  const mainRepo = resolveRepoPath(opts.repoPath);
6741
7125
  return withWorkspaceLifecycleLock(worktreePath, async () => {
6742
7126
  const worktrees = identifyGitWorktrees(mainRepo);
@@ -6754,22 +7138,29 @@ async function workspaceStopOwnedPath(worktreePath, opts = {}) {
6754
7138
  worktree: worktreeForRecord(worktrees, record),
6755
7139
  record
6756
7140
  };
6757
- return mutateWorkspaceRuntime("stop", resolved, worktrees, opts.quiet);
7141
+ return mutateWorkspaceRuntime(action2, resolved, worktrees, opts.quiet);
6758
7142
  });
6759
7143
  }
7144
+ async function workspaceStopOwnedPath(worktreePath, opts = {}) {
7145
+ return mutateWorkspaceOwnedPath("stop", worktreePath, opts);
7146
+ }
7147
+ async function workspaceDeleteOwnedPath(worktreePath, opts = {}) {
7148
+ return mutateWorkspaceOwnedPath("delete", worktreePath, opts);
7149
+ }
6760
7150
  async function workspaceStop(target, opts = {}) {
6761
7151
  return runWorkspaceLifecycle("stop", target, opts);
6762
7152
  }
6763
7153
  async function workspaceDown(target, opts = {}) {
6764
7154
  return runWorkspaceLifecycle("down", target, opts);
6765
7155
  }
6766
- var import_node_child_process13, import_node_fs17, import_node_path17;
7156
+ var import_node_child_process15, import_node_fs19, import_node_path19;
6767
7157
  var init_workspace_lifecycle = __esm({
6768
7158
  "src/core/workspace-lifecycle.ts"() {
6769
7159
  "use strict";
6770
- import_node_child_process13 = require("child_process");
6771
- import_node_fs17 = __toESM(require("fs"));
6772
- import_node_path17 = __toESM(require("path"));
7160
+ import_node_child_process15 = require("child_process");
7161
+ import_node_fs19 = __toESM(require("fs"));
7162
+ import_node_path19 = __toESM(require("path"));
7163
+ init_devpod_mutation();
6773
7164
  init_devpod_workspaces();
6774
7165
  init_repo_config();
6775
7166
  init_route_state();
@@ -6780,7 +7171,7 @@ var init_workspace_lifecycle = __esm({
6780
7171
  });
6781
7172
 
6782
7173
  // src/core/environment-stop.ts
6783
- async function environmentStop(repoPath) {
7174
+ async function environmentStop(repoPath, options = {}) {
6784
7175
  const linked = isLinkedWorktree(repoPath);
6785
7176
  const workspace = linked ? resolveWorktreeWorkspace(repoPath) : void 0;
6786
7177
  if (linked && !workspace) {
@@ -6799,25 +7190,27 @@ async function environmentStop(repoPath) {
6799
7190
  if (!mainRepo) {
6800
7191
  throw new Error(`Could not resolve the primary checkout for '${repoPath}'.`);
6801
7192
  }
6802
- const result = await workspaceStopOwnedPath(record.worktreePath, {
6803
- quiet: true,
6804
- repoPath: mainRepo
6805
- });
7193
+ const result = await (options.delete ? workspaceDeleteOwnedPath : workspaceStopOwnedPath)(
7194
+ record.worktreePath,
7195
+ {
7196
+ quiet: true,
7197
+ repoPath: mainRepo
7198
+ }
7199
+ );
6806
7200
  return {
6807
7201
  kind: "linked",
6808
7202
  repoPath,
6809
7203
  workspace: result.workspace,
6810
7204
  ...result.devpodId ? { devpodId: result.devpodId } : {},
6811
- stopped: result.providerChanged,
7205
+ stopped: !options.delete && result.providerChanged,
7206
+ ...options.delete ? { deleted: result.providerChanged } : {},
6812
7207
  freedRoutes: result.freedRoutes
6813
7208
  };
6814
7209
  }
6815
7210
  }
6816
7211
  return withWorkspaceLifecycleLock(repoPath, async () => {
6817
7212
  const devpod = selectDevpodWorkspace(listDevpodWorkspaces(), repoPath);
6818
- if (devpod) {
6819
- runDevpodWorkspaceAction("stop", devpod.id);
6820
- }
7213
+ const mutation = devpod ? options.delete ? deleteOwnedDevpodWorkspace(devpod.id, repoPath) : stopOwnedDevpodWorkspace(devpod.id, repoPath) : { status: "absent" };
6821
7214
  const removedRoutes = removeHostRoutesWhere(
6822
7215
  (route) => sameWorkspacePath(route.repoPath, repoPath)
6823
7216
  );
@@ -6825,8 +7218,9 @@ async function environmentStop(repoPath) {
6825
7218
  kind: linked ? "linked" : "primary",
6826
7219
  repoPath,
6827
7220
  ...workspace ? { workspace } : {},
6828
- ...devpod ? { devpodId: devpod.id } : {},
6829
- stopped: Boolean(devpod),
7221
+ ...mutation.status === "changed" && devpod ? { devpodId: devpod.id } : {},
7222
+ stopped: !options.delete && mutation.status === "changed",
7223
+ ...options.delete ? { deleted: mutation.status === "changed" } : {},
6830
7224
  freedRoutes: removedRoutes.length
6831
7225
  };
6832
7226
  });
@@ -6834,6 +7228,7 @@ async function environmentStop(repoPath) {
6834
7228
  var init_environment_stop = __esm({
6835
7229
  "src/core/environment-stop.ts"() {
6836
7230
  "use strict";
7231
+ init_devpod_mutation();
6837
7232
  init_devpod_workspaces();
6838
7233
  init_host_routes();
6839
7234
  init_workspace();
@@ -6849,19 +7244,19 @@ __export(stop_exports, {
6849
7244
  });
6850
7245
  async function runStopCommand(options) {
6851
7246
  const repoPath = resolveGitCheckoutPath(options.path);
6852
- const result = await environmentStop(repoPath);
7247
+ const result = await environmentStop(repoPath, { delete: options.delete });
6853
7248
  if (options.json) {
6854
7249
  process.stdout.write(`${JSON.stringify(result, null, 2)}
6855
7250
  `);
6856
7251
  return;
6857
7252
  }
6858
7253
  const label = result.kind === "primary" ? "Primary checkout" : `Workspace '${result.workspace}'`;
6859
- if (!result.stopped && result.freedRoutes === 0) {
7254
+ if (!result.stopped && !result.deleted && result.freedRoutes === 0) {
6860
7255
  process.stdout.write(`${label} is already stopped; no routes needed removal.
6861
7256
  `);
6862
7257
  return;
6863
7258
  }
6864
- const provider = result.stopped ? `Stopped DevPod '${result.devpodId}'. ` : "";
7259
+ const provider = result.deleted ? `Deleted DevPod '${result.devpodId}'. ` : result.stopped ? `Stopped DevPod '${result.devpodId}'. ` : "";
6865
7260
  process.stdout.write(
6866
7261
  `${provider}Freed ${result.freedRoutes} route(s) for ${label.toLowerCase()}.
6867
7262
  `
@@ -6891,7 +7286,7 @@ function ensureGuidance(repoPath) {
6891
7286
  return `Run 'devrouter ensure ${repoPath}' first.`;
6892
7287
  }
6893
7288
  function assertRunningDevpod(devpodId, repoPath) {
6894
- const result = (0, import_node_child_process14.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
7289
+ const result = (0, import_node_child_process16.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
6895
7290
  encoding: "utf-8"
6896
7291
  });
6897
7292
  if (result.status !== 0) {
@@ -6929,7 +7324,7 @@ async function devpodExec(repoPath, command) {
6929
7324
  }
6930
7325
  assertRunningDevpod(devpod.id, repoPath);
6931
7326
  const workspaceDirectory = resolveWorkspaceDirectory(repoPath);
6932
- const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto4.randomUUID)()}__:`;
7327
+ const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto5.randomUUID)()}__:`;
6933
7328
  const statusMarkerBytes = Buffer.from(statusMarker, "ascii");
6934
7329
  const literalCommand = command.map(quotePosixArg).join(" ");
6935
7330
  const wrappedCommand = `${literalCommand}; __devrouter_status=$?; printf '${statusMarker}%s\\n' "$__devrouter_status" >&2; exit 0`;
@@ -6947,7 +7342,7 @@ async function devpodExec(repoPath, command) {
6947
7342
  wrappedCommand
6948
7343
  ];
6949
7344
  return new Promise((resolve, reject) => {
6950
- const child = (0, import_node_child_process14.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
7345
+ const child = (0, import_node_child_process16.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
6951
7346
  let pending = Buffer.alloc(0);
6952
7347
  let statusBytes = Buffer.alloc(0);
6953
7348
  let readingStatus = false;
@@ -7006,12 +7401,12 @@ async function devpodExec(repoPath, command) {
7006
7401
  });
7007
7402
  });
7008
7403
  }
7009
- var import_node_child_process14, import_node_crypto4, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
7404
+ var import_node_child_process16, import_node_crypto5, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
7010
7405
  var init_devpod_exec = __esm({
7011
7406
  "src/core/devpod-exec.ts"() {
7012
7407
  "use strict";
7013
- import_node_child_process14 = require("child_process");
7014
- import_node_crypto4 = require("crypto");
7408
+ import_node_child_process16 = require("child_process");
7409
+ import_node_crypto5 = require("crypto");
7015
7410
  init_devpod_environment();
7016
7411
  init_devpod_workspaces();
7017
7412
  init_workspace();
@@ -7195,7 +7590,7 @@ async function runOpenCommand(name) {
7195
7590
  );
7196
7591
  return;
7197
7592
  }
7198
- const result = (0, import_node_child_process15.spawnSync)("open", [url], { encoding: "utf-8" });
7593
+ const result = (0, import_node_child_process17.spawnSync)("open", [url], { encoding: "utf-8" });
7199
7594
  if (result.status !== 0) {
7200
7595
  const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
7201
7596
  throw new Error(`Unable to open '${url}': ${details || "unknown error"}`);
@@ -7203,11 +7598,11 @@ async function runOpenCommand(name) {
7203
7598
  process.stdout.write(`Opened ${url}
7204
7599
  `);
7205
7600
  }
7206
- var import_node_child_process15;
7601
+ var import_node_child_process17;
7207
7602
  var init_open = __esm({
7208
7603
  "src/commands/open.ts"() {
7209
7604
  "use strict";
7210
- import_node_child_process15 = require("child_process");
7605
+ import_node_child_process17 = require("child_process");
7211
7606
  init_docker();
7212
7607
  init_host_routes();
7213
7608
  init_repo_config();
@@ -7230,7 +7625,7 @@ async function runLogsCommand(options) {
7230
7625
  const args = ["logs", "--tail", tail, ROUTER_CONTAINER_NAME];
7231
7626
  if (options.follow) {
7232
7627
  args.splice(1, 0, "-f");
7233
- const child = (0, import_node_child_process16.spawn)("docker", args, { stdio: "inherit" });
7628
+ const child = (0, import_node_child_process18.spawn)("docker", args, { stdio: "inherit" });
7234
7629
  const onSignal = () => {
7235
7630
  child.kill("SIGTERM");
7236
7631
  };
@@ -7244,17 +7639,17 @@ async function runLogsCommand(options) {
7244
7639
  });
7245
7640
  });
7246
7641
  } else {
7247
- const result = (0, import_node_child_process16.spawnSync)("docker", args, { stdio: "inherit" });
7642
+ const result = (0, import_node_child_process18.spawnSync)("docker", args, { stdio: "inherit" });
7248
7643
  if (result.status !== 0) {
7249
7644
  throw new Error("Failed to retrieve router logs.");
7250
7645
  }
7251
7646
  }
7252
7647
  }
7253
- var import_node_child_process16;
7648
+ var import_node_child_process18;
7254
7649
  var init_logs = __esm({
7255
7650
  "src/commands/logs.ts"() {
7256
7651
  "use strict";
7257
- import_node_child_process16 = require("child_process");
7652
+ import_node_child_process18 = require("child_process");
7258
7653
  init_docker();
7259
7654
  init_router();
7260
7655
  }
@@ -7294,17 +7689,17 @@ function asRecord3(value) {
7294
7689
  return value;
7295
7690
  }
7296
7691
  function readJson(filePath) {
7297
- if (!import_node_fs18.default.existsSync(filePath)) {
7692
+ if (!import_node_fs20.default.existsSync(filePath)) {
7298
7693
  return void 0;
7299
7694
  }
7300
7695
  try {
7301
- return JSON.parse(import_node_fs18.default.readFileSync(filePath, "utf-8"));
7696
+ return JSON.parse(import_node_fs20.default.readFileSync(filePath, "utf-8"));
7302
7697
  } catch {
7303
7698
  return void 0;
7304
7699
  }
7305
7700
  }
7306
7701
  function relative(repoPath, filePath) {
7307
- return import_node_path18.default.relative(repoPath, filePath) || ".";
7702
+ return import_node_path20.default.relative(repoPath, filePath) || ".";
7308
7703
  }
7309
7704
  function redactEnvAssignments(value) {
7310
7705
  return value.replace(
@@ -7345,7 +7740,7 @@ function inspectPackageManager(repoPath, pkg) {
7345
7740
  ["bun.lock", "bun"]
7346
7741
  ];
7347
7742
  for (const [fileName, name] of lockfiles) {
7348
- if (import_node_fs18.default.existsSync(import_node_path18.default.join(repoPath, fileName))) {
7743
+ if (import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))) {
7349
7744
  return { name, source: fileName };
7350
7745
  }
7351
7746
  }
@@ -7360,9 +7755,9 @@ function inspectNode(repoPath, pkg) {
7360
7755
  if (typeof engines?.node === "string") {
7361
7756
  return { version: engines.node, source: "package.json:engines.node" };
7362
7757
  }
7363
- const nvmrc = import_node_path18.default.join(repoPath, ".nvmrc");
7364
- if (import_node_fs18.default.existsSync(nvmrc)) {
7365
- const version = import_node_fs18.default.readFileSync(nvmrc, "utf-8").trim();
7758
+ const nvmrc = import_node_path20.default.join(repoPath, ".nvmrc");
7759
+ if (import_node_fs20.default.existsSync(nvmrc)) {
7760
+ const version = import_node_fs20.default.readFileSync(nvmrc, "utf-8").trim();
7366
7761
  return { version, source: ".nvmrc" };
7367
7762
  }
7368
7763
  return void 0;
@@ -7423,7 +7818,7 @@ function configuredComposeFiles(repoPath) {
7423
7818
  const files = config.apps.filter(
7424
7819
  (app) => app.runtime === "docker"
7425
7820
  ).flatMap((app) => app.docker.composeFiles).filter(
7426
- (fileName) => !import_node_path18.default.isAbsolute(fileName) && !import_node_path18.default.normalize(fileName).startsWith("..")
7821
+ (fileName) => !import_node_path20.default.isAbsolute(fileName) && !import_node_path20.default.normalize(fileName).startsWith("..")
7427
7822
  );
7428
7823
  return Array.from(new Set(files));
7429
7824
  } catch {
@@ -7441,7 +7836,7 @@ function composeFiles(repoPath) {
7441
7836
  ...configuredComposeFiles(repoPath)
7442
7837
  ];
7443
7838
  return Array.from(new Set(candidates)).filter(
7444
- (fileName) => import_node_fs18.default.existsSync(import_node_path18.default.join(repoPath, fileName))
7839
+ (fileName) => import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))
7445
7840
  );
7446
7841
  }
7447
7842
  function stringArray(value) {
@@ -7479,7 +7874,7 @@ function inspectServices(repoPath) {
7479
7874
  const services = [];
7480
7875
  for (const fileName of composeFiles(repoPath)) {
7481
7876
  try {
7482
- const parsed = import_yaml5.default.parse(import_node_fs18.default.readFileSync(import_node_path18.default.join(repoPath, fileName), "utf-8"));
7877
+ const parsed = import_yaml5.default.parse(import_node_fs20.default.readFileSync(import_node_path20.default.join(repoPath, fileName), "utf-8"));
7483
7878
  const serviceMap = asRecord3(asRecord3(parsed)?.services);
7484
7879
  for (const [name, value] of Object.entries(serviceMap ?? {})) {
7485
7880
  const service = asRecord3(value);
@@ -7514,8 +7909,8 @@ function inspectServices(repoPath) {
7514
7909
  return services;
7515
7910
  }
7516
7911
  function inspectEnvFiles(repoPath) {
7517
- const files = import_node_fs18.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
7518
- const content = import_node_fs18.default.readFileSync(import_node_path18.default.join(repoPath, fileName), "utf-8");
7912
+ const files = import_node_fs20.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
7913
+ const content = import_node_fs20.default.readFileSync(import_node_path20.default.join(repoPath, fileName), "utf-8");
7519
7914
  const names = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#") && line.includes("=")).map((line) => line.split("=")[0]?.trim()).filter((name) => Boolean(name)).sort();
7520
7915
  return { path: fileName, names };
7521
7916
  });
@@ -7529,18 +7924,18 @@ function inspectEnvFiles(repoPath) {
7529
7924
  };
7530
7925
  }
7531
7926
  function inspectDevcontainer(repoPath) {
7532
- const dir = import_node_path18.default.join(repoPath, ".devcontainer");
7533
- if (!import_node_fs18.default.existsSync(dir)) {
7927
+ const dir = import_node_path20.default.join(repoPath, ".devcontainer");
7928
+ if (!import_node_fs20.default.existsSync(dir)) {
7534
7929
  return { exists: false, files: [] };
7535
7930
  }
7536
7931
  return {
7537
7932
  exists: true,
7538
- files: import_node_fs18.default.readdirSync(dir).filter((fileName) => import_node_fs18.default.statSync(import_node_path18.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
7933
+ files: import_node_fs20.default.readdirSync(dir).filter((fileName) => import_node_fs20.default.statSync(import_node_path20.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
7539
7934
  };
7540
7935
  }
7541
7936
  function inspectDevrouter(repoPath) {
7542
7937
  const configPath = getRepoConfigPath(repoPath);
7543
- if (!import_node_fs18.default.existsSync(configPath)) {
7938
+ if (!import_node_fs20.default.existsSync(configPath)) {
7544
7939
  return {
7545
7940
  exists: false,
7546
7941
  configPath,
@@ -7584,15 +7979,15 @@ function inspectAgentGuidance(repoPath) {
7584
7979
  ["AGENTS.md", "agents"],
7585
7980
  ["CLAUDE.md", "claude"]
7586
7981
  ]) {
7587
- if (import_node_fs18.default.existsSync(import_node_path18.default.join(repoPath, fileName))) {
7982
+ if (import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))) {
7588
7983
  results.push({ path: fileName, kind });
7589
7984
  }
7590
7985
  }
7591
- const skillsDir = import_node_path18.default.join(repoPath, ".agents", "skills");
7592
- if (import_node_fs18.default.existsSync(skillsDir)) {
7593
- for (const name of import_node_fs18.default.readdirSync(skillsDir).sort()) {
7594
- const skillPath = import_node_path18.default.join(skillsDir, name, "SKILL.md");
7595
- if (import_node_fs18.default.existsSync(skillPath)) {
7986
+ const skillsDir = import_node_path20.default.join(repoPath, ".agents", "skills");
7987
+ if (import_node_fs20.default.existsSync(skillsDir)) {
7988
+ for (const name of import_node_fs20.default.readdirSync(skillsDir).sort()) {
7989
+ const skillPath = import_node_path20.default.join(skillsDir, name, "SKILL.md");
7990
+ if (import_node_fs20.default.existsSync(skillPath)) {
7596
7991
  results.push({ path: relative(repoPath, skillPath), kind: "skill" });
7597
7992
  }
7598
7993
  }
@@ -7635,7 +8030,7 @@ function buildIssues(report) {
7635
8030
  }
7636
8031
  function inspectRepo(options = {}) {
7637
8032
  const repoPath = resolveRepoPath(options.repo);
7638
- const pkg = readJson(import_node_path18.default.join(repoPath, "package.json"));
8033
+ const pkg = readJson(import_node_path20.default.join(repoPath, "package.json"));
7639
8034
  const scripts = inspectScripts(pkg);
7640
8035
  const reportWithoutIssues = {
7641
8036
  repoPath,
@@ -7654,12 +8049,12 @@ function inspectRepo(options = {}) {
7654
8049
  issues: buildIssues(reportWithoutIssues)
7655
8050
  };
7656
8051
  }
7657
- var import_node_fs18, import_node_path18, import_yaml5;
8052
+ var import_node_fs20, import_node_path20, import_yaml5;
7658
8053
  var init_repo_inspect = __esm({
7659
8054
  "src/core/repo-inspect.ts"() {
7660
8055
  "use strict";
7661
- import_node_fs18 = __toESM(require("fs"));
7662
- import_node_path18 = __toESM(require("path"));
8056
+ import_node_fs20 = __toESM(require("fs"));
8057
+ import_node_path20 = __toESM(require("path"));
7663
8058
  import_yaml5 = __toESM(require("yaml"));
7664
8059
  init_repo_config();
7665
8060
  }
@@ -7764,7 +8159,7 @@ function requiredFileChecks(repoPath) {
7764
8159
  ".devcontainer/docker-compose.yml",
7765
8160
  ".devrouter.yml"
7766
8161
  ];
7767
- const missing = required.filter((fileName) => !import_node_fs19.default.existsSync(import_node_path19.default.join(repoPath, fileName)));
8162
+ const missing = required.filter((fileName) => !import_node_fs21.default.existsSync(import_node_path21.default.join(repoPath, fileName)));
7768
8163
  return {
7769
8164
  id: "repo.devcontainer.verify-files",
7770
8165
  level: missing.length === 0 ? "ok" : "error",
@@ -7983,12 +8378,12 @@ async function verifyDevcontainer(options = {}) {
7983
8378
  nextSteps: collectNextSteps3(checks)
7984
8379
  };
7985
8380
  }
7986
- var import_node_fs19, import_node_path19;
8381
+ var import_node_fs21, import_node_path21;
7987
8382
  var init_devcontainer_verify = __esm({
7988
8383
  "src/core/devcontainer-verify.ts"() {
7989
8384
  "use strict";
7990
- import_node_fs19 = __toESM(require("fs"));
7991
- import_node_path19 = __toESM(require("path"));
8385
+ import_node_fs21 = __toESM(require("fs"));
8386
+ import_node_path21 = __toESM(require("path"));
7992
8387
  init_capabilities();
7993
8388
  init_doctor();
7994
8389
  init_host_routes();
@@ -8284,7 +8679,7 @@ function packageManagerIssues(repo) {
8284
8679
  }
8285
8680
  function plannedFiles(repoPath, version) {
8286
8681
  const repo = inspectRepo({ repo: repoPath });
8287
- const projectName = sanitizeProjectName(import_node_path20.default.basename(repo.repoPath));
8682
+ const projectName = sanitizeProjectName(import_node_path22.default.basename(repo.repoPath));
8288
8683
  const nodeMajor = majorVersion(repo.node?.version, "24");
8289
8684
  const pnpmVersion = repo.packageManager?.name === "pnpm" && repo.packageManager.version ? repo.packageManager.version : DEFAULT_PNPM_VERSION;
8290
8685
  const port = inferPort2(repo);
@@ -8329,8 +8724,8 @@ function plannedFiles(repoPath, version) {
8329
8724
  };
8330
8725
  }
8331
8726
  function classifyFile(repoPath, file) {
8332
- const absolutePath = import_node_path20.default.join(repoPath, file.relativePath);
8333
- if (!import_node_fs20.default.existsSync(absolutePath)) {
8727
+ const absolutePath = import_node_path22.default.join(repoPath, file.relativePath);
8728
+ if (!import_node_fs22.default.existsSync(absolutePath)) {
8334
8729
  return {
8335
8730
  path: file.relativePath,
8336
8731
  action: "create",
@@ -8338,7 +8733,7 @@ function classifyFile(repoPath, file) {
8338
8733
  bytes: Buffer.byteLength(file.content)
8339
8734
  };
8340
8735
  }
8341
- const current = import_node_fs20.default.readFileSync(absolutePath, "utf-8");
8736
+ const current = import_node_fs22.default.readFileSync(absolutePath, "utf-8");
8342
8737
  if (!current.includes(MANAGED_MARKER2)) {
8343
8738
  return {
8344
8739
  path: file.relativePath,
@@ -8395,11 +8790,11 @@ function buildPlan(repoPath, dryRun, version) {
8395
8790
  };
8396
8791
  }
8397
8792
  function writeFile(repoPath, file) {
8398
- const absolutePath = import_node_path20.default.join(repoPath, file.relativePath);
8399
- import_node_fs20.default.mkdirSync(import_node_path20.default.dirname(absolutePath), { recursive: true });
8400
- import_node_fs20.default.writeFileSync(absolutePath, file.content, "utf-8");
8793
+ const absolutePath = import_node_path22.default.join(repoPath, file.relativePath);
8794
+ import_node_fs22.default.mkdirSync(import_node_path22.default.dirname(absolutePath), { recursive: true });
8795
+ import_node_fs22.default.writeFileSync(absolutePath, file.content, "utf-8");
8401
8796
  if (file.executable) {
8402
- import_node_fs20.default.chmodSync(absolutePath, 493);
8797
+ import_node_fs22.default.chmodSync(absolutePath, 493);
8403
8798
  }
8404
8799
  }
8405
8800
  function writeDevcontainer(options = {}) {
@@ -8437,12 +8832,12 @@ function writeDevcontainer(options = {}) {
8437
8832
  nextSteps: postWriteNextSteps(repoPath)
8438
8833
  };
8439
8834
  }
8440
- var import_node_fs20, import_node_path20, MANAGED_MARKER2, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
8835
+ var import_node_fs22, import_node_path22, MANAGED_MARKER2, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
8441
8836
  var init_devcontainer_write = __esm({
8442
8837
  "src/core/devcontainer-write.ts"() {
8443
8838
  "use strict";
8444
- import_node_fs20 = __toESM(require("fs"));
8445
- import_node_path20 = __toESM(require("path"));
8839
+ import_node_fs22 = __toESM(require("fs"));
8840
+ import_node_path22 = __toESM(require("path"));
8446
8841
  init_repo_config();
8447
8842
  init_repo_inspect();
8448
8843
  MANAGED_MARKER2 = "devrouter:managed devcontainer";
@@ -8833,7 +9228,7 @@ function sanitizeRouterId(value) {
8833
9228
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
8834
9229
  }
8835
9230
  function repoHash(repoPath) {
8836
- return (0, import_node_crypto5.createHash)("sha1").update(import_node_path21.default.resolve(repoPath)).digest("hex").slice(0, 12);
9231
+ return (0, import_node_crypto6.createHash)("sha1").update(import_node_path23.default.resolve(repoPath)).digest("hex").slice(0, 12);
8837
9232
  }
8838
9233
  function asDockerApp(app) {
8839
9234
  return app.runtime === "docker";
@@ -8912,11 +9307,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
8912
9307
  if (dockerApps.length === 0) {
8913
9308
  throw new Error("No docker apps selected to prepare compose overlay.");
8914
9309
  }
8915
- const cachePath = import_node_path21.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
8916
- import_node_fs21.default.mkdirSync(cachePath, { recursive: true });
8917
- const overlayPath = import_node_path21.default.join(cachePath, "compose.devrouter.yml");
9310
+ const cachePath = import_node_path23.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
9311
+ import_node_fs23.default.mkdirSync(cachePath, { recursive: true });
9312
+ const overlayPath = import_node_path23.default.join(cachePath, "compose.devrouter.yml");
8918
9313
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
8919
- import_node_fs21.default.writeFileSync(overlayPath, import_yaml6.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
9314
+ import_node_fs23.default.writeFileSync(overlayPath, import_yaml6.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
8920
9315
  return {
8921
9316
  overlayPath,
8922
9317
  composeFiles: ensureComposeFiles(dockerApps),
@@ -8930,7 +9325,7 @@ function runDockerComposeUp(repoPath, composeFiles2, overlayPath, services) {
8930
9325
  fileArgs.push("-f", resolved);
8931
9326
  }
8932
9327
  const args = ["compose", ...fileArgs, "-f", overlayPath, "up", "-d", "--wait", ...services];
8933
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9328
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8934
9329
  encoding: "utf-8",
8935
9330
  cwd: repoPath
8936
9331
  });
@@ -8958,7 +9353,7 @@ function queryRunningComposeServices(repoPath, composeFiles2, overlayPath, servi
8958
9353
  "--services",
8959
9354
  ...services
8960
9355
  ];
8961
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9356
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8962
9357
  encoding: "utf-8",
8963
9358
  cwd: repoPath
8964
9359
  });
@@ -8984,7 +9379,7 @@ function runDockerComposeStop(repoPath, composeFiles2, overlayPath, services) {
8984
9379
  fileArgs.push("-f", resolved);
8985
9380
  }
8986
9381
  const args = ["compose", ...fileArgs, "-f", overlayPath, "stop", ...services];
8987
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9382
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8988
9383
  encoding: "utf-8",
8989
9384
  cwd: repoPath
8990
9385
  });
@@ -9010,7 +9405,7 @@ function runDockerComposeLogs(repoPath, composeFiles2, overlayPath, services, ta
9010
9405
  String(tail),
9011
9406
  ...services
9012
9407
  ];
9013
- (0, import_node_child_process17.spawnSync)("docker", args, {
9408
+ (0, import_node_child_process19.spawnSync)("docker", args, {
9014
9409
  stdio: "inherit",
9015
9410
  cwd: repoPath
9016
9411
  });
@@ -9022,7 +9417,7 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
9022
9417
  fileArgs.push("-f", resolved);
9023
9418
  }
9024
9419
  const args = ["compose", ...fileArgs, "-f", overlayPath, "port", service, String(internalPort)];
9025
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9420
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
9026
9421
  encoding: "utf-8",
9027
9422
  cwd: repoPath
9028
9423
  });
@@ -9036,14 +9431,14 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
9036
9431
  const port = Number(match[1]);
9037
9432
  return Number.isInteger(port) && port > 0 ? port : void 0;
9038
9433
  }
9039
- var import_node_child_process17, import_node_crypto5, import_node_fs21, import_node_path21, import_yaml6;
9434
+ var import_node_child_process19, import_node_crypto6, import_node_fs23, import_node_path23, import_yaml6;
9040
9435
  var init_docker_run = __esm({
9041
9436
  "src/core/docker-run.ts"() {
9042
9437
  "use strict";
9043
- import_node_child_process17 = require("child_process");
9044
- import_node_crypto5 = require("crypto");
9045
- import_node_fs21 = __toESM(require("fs"));
9046
- import_node_path21 = __toESM(require("path"));
9438
+ import_node_child_process19 = require("child_process");
9439
+ import_node_crypto6 = require("crypto");
9440
+ import_node_fs23 = __toESM(require("fs"));
9441
+ import_node_path23 = __toESM(require("path"));
9047
9442
  import_yaml6 = __toESM(require("yaml"));
9048
9443
  init_docker_error_guidance();
9049
9444
  init_paths();
@@ -9133,7 +9528,7 @@ function isProcessRunning(pid) {
9133
9528
  }
9134
9529
  }
9135
9530
  function readProcessTree(rootPid) {
9136
- const result = (0, import_node_child_process18.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
9531
+ const result = (0, import_node_child_process20.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
9137
9532
  if (result.status !== 0) {
9138
9533
  return [rootPid];
9139
9534
  }
@@ -9244,12 +9639,12 @@ function detectListeningPorts(pids) {
9244
9639
  if (pids.length === 0) {
9245
9640
  return [];
9246
9641
  }
9247
- const result = (0, import_node_child_process18.spawnSync)("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], {
9642
+ const result = (0, import_node_child_process20.spawnSync)("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], {
9248
9643
  encoding: "utf-8"
9249
9644
  });
9250
9645
  if (result.error && result.error.code === "ENOENT") {
9251
9646
  if (process.platform === "linux") {
9252
- const ssResult = (0, import_node_child_process18.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
9647
+ const ssResult = (0, import_node_child_process20.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
9253
9648
  if (ssResult.status === 0 && ssResult.stdout) {
9254
9649
  return parseSsListeningPorts(ssResult.stdout, new Set(pids));
9255
9650
  }
@@ -9308,7 +9703,7 @@ async function runHostApp(repoPath, app, extraEnv = {}, secretManager, env, work
9308
9703
  app.hostRun.command,
9309
9704
  true
9310
9705
  ) : app.hostRun.command;
9311
- const child = (0, import_node_child_process18.spawn)(spawnCommand, {
9706
+ const child = (0, import_node_child_process20.spawn)(spawnCommand, {
9312
9707
  cwd: commandCwd,
9313
9708
  stdio: "inherit",
9314
9709
  shell: true,
@@ -9676,7 +10071,7 @@ async function execWithAppEnv(options) {
9676
10071
  options.command[0],
9677
10072
  true
9678
10073
  );
9679
- child = (0, import_node_child_process18.spawn)(wrapped, {
10074
+ child = (0, import_node_child_process20.spawn)(wrapped, {
9680
10075
  cwd: deps.repoPath,
9681
10076
  stdio: "inherit",
9682
10077
  shell: true,
@@ -9690,7 +10085,7 @@ async function execWithAppEnv(options) {
9690
10085
  false
9691
10086
  );
9692
10087
  const [cmd, ...wrappedArgs] = wrapped;
9693
- child = (0, import_node_child_process18.spawn)(cmd, wrappedArgs, {
10088
+ child = (0, import_node_child_process20.spawn)(cmd, wrappedArgs, {
9694
10089
  cwd: deps.repoPath,
9695
10090
  stdio: "inherit",
9696
10091
  shell: false,
@@ -9698,7 +10093,7 @@ async function execWithAppEnv(options) {
9698
10093
  });
9699
10094
  }
9700
10095
  } else if (options.shell) {
9701
- child = (0, import_node_child_process18.spawn)(options.command[0], {
10096
+ child = (0, import_node_child_process20.spawn)(options.command[0], {
9702
10097
  cwd: deps.repoPath,
9703
10098
  stdio: "inherit",
9704
10099
  shell: true,
@@ -9706,7 +10101,7 @@ async function execWithAppEnv(options) {
9706
10101
  });
9707
10102
  } else {
9708
10103
  const [command, ...args] = options.command;
9709
- child = (0, import_node_child_process18.spawn)(command, args, {
10104
+ child = (0, import_node_child_process20.spawn)(command, args, {
9710
10105
  cwd: deps.repoPath,
9711
10106
  stdio: "inherit",
9712
10107
  shell: false,
@@ -9727,11 +10122,11 @@ async function execWithAppEnv(options) {
9727
10122
  deps.stopDeps();
9728
10123
  }
9729
10124
  }
9730
- var import_node_child_process18, import_node_net, import_node_process, import_promises, POLL_INTERVAL_MS2, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
10125
+ var import_node_child_process20, import_node_net, import_node_process, import_promises, POLL_INTERVAL_MS2, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
9731
10126
  var init_app_run = __esm({
9732
10127
  "src/core/app-run.ts"() {
9733
10128
  "use strict";
9734
- import_node_child_process18 = require("child_process");
10129
+ import_node_child_process20 = require("child_process");
9735
10130
  import_node_net = __toESM(require("net"));
9736
10131
  import_node_process = require("process");
9737
10132
  import_promises = require("readline/promises");
@@ -9992,7 +10387,7 @@ var init_version = __esm({
9992
10387
 
9993
10388
  // src/cli.ts
9994
10389
  var import_commander = require("commander");
9995
- var CLI_VERSION = true ? "0.0.34" : "0.0.0-dev";
10390
+ var CLI_VERSION = true ? "0.0.35" : "0.0.0-dev";
9996
10391
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
9997
10392
  function withErrorHandling(action2) {
9998
10393
  return async (...args) => {
@@ -10053,11 +10448,15 @@ program.command("ensure").description("Start and prove a primary or linked check
10053
10448
  });
10054
10449
  })
10055
10450
  );
10056
- program.command("stop").description("Stop one checkout's exact DevPod and free its routes without deleting data").argument("[path]", "Git checkout path (defaults to current directory)").option("--json", "Output JSON").action(
10451
+ program.command("stop").description("Stop one checkout's exact DevPod and free its routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--json", "Output JSON").option("--delete", "Delete the exact DevPod instead of preserving its data").action(
10057
10452
  withErrorHandling(async (repoPath, _options, command) => {
10058
10453
  const options = command.opts();
10059
10454
  const { runStopCommand: runStopCommand2 } = await Promise.resolve().then(() => (init_stop(), stop_exports));
10060
- await runStopCommand2({ path: repoPath, json: Boolean(options.json) });
10455
+ await runStopCommand2({
10456
+ path: repoPath,
10457
+ delete: Boolean(options.delete),
10458
+ json: Boolean(options.json)
10459
+ });
10061
10460
  })
10062
10461
  );
10063
10462
  program.command("exec").description("Run one literal command inside the exact checkout's running DevPod").argument("[args...]", "[path] -- <command...>").allowUnknownOption(true).action(