@devrouter/cli 0.0.33 → 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
@@ -43,6 +43,7 @@ function buildDevrouterSection() {
43
43
  `\`${DEVROUTER_SKILL_REL_PATH}\``,
44
44
  "",
45
45
  "Quick validation sequence:",
46
+ "- Managed devcontainer consumer images contain no devrouter package or helper; `devrouter ensure` delivers the matching helper at runtime.",
46
47
  "- `devrouter up`",
47
48
  "- `devrouter tls install` (required when repo defines tcp/postgres apps)",
48
49
  "- `devrouter app ls --repo .`",
@@ -155,6 +156,8 @@ apps:
155
156
  # to target a per-workspace devcontainer alias \u2014 substituted with the resolved
156
157
  # workspace token at runtime. See "Workspace isolation" below. Do NOT put
157
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.
158
161
  #
159
162
  # proxy + tcp (front a DB in an externally-managed container, e.g. a
160
163
  # devcontainer's Postgres on devnet) \u2014 no per-DB host port:
@@ -238,10 +241,12 @@ Config-level \`envMap\` on dependency references aliases per-dep vars to app-exp
238
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.
239
242
 
240
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.
241
- - **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.
242
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.
243
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>\`.
244
- - **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.
245
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.
246
251
  - **Boundary**: workspace commands require Git. Normal config, app, status, and doctor flows remain usable from a \`.devrouter.yml\` folder without \`.git\`.
247
252
 
@@ -294,7 +299,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
294
299
  - \`devrouter upgrade [version] [--repo .]\`: list upgrade targets or print target Agent Adaptation Prompt
295
300
  - \`devrouter setup --yes [--repo .] [--json]\`: first-run machine setup plus structured diagnostics
296
301
  - \`devrouter ensure [path] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts
297
- - \`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
298
303
  - \`devrouter exec [path] -- <command...>\`: literal one-shot command inside the exact running DevPod
299
304
  - \`devrouter up\` / \`devrouter down\`: start/stop shared Traefik router
300
305
  - \`devrouter status\`: router/container/network/TLS health
@@ -347,6 +352,7 @@ For host/docker runtime apps only:
347
352
 
348
353
  ## Runtime behavior notes
349
354
 
355
+ - Managed devcontainer images contain no devrouter package or helper. \`devrouter ensure\` delivers its matching process helper to the exact running container and invokes the repository-owned \`.devcontainer/post-start.sh\`; keep \`.devrouter.yml\` as the only consumer-side devrouter version pin.
350
356
  - \`devrouter app run\` auto-starts Docker dependencies and waits for health. Host app runs stop auto-started docker deps on exit; docker app runs leave target services running until explicit cleanup.
351
357
  - Host-runtime dependencies are NOT auto-started (v1).
352
358
  - \`kind=dependency\` entries do not create routes and cannot be direct targets for \`devrouter app run\`, \`devrouter app exec\`, or \`devrouter open\`.
@@ -719,6 +725,51 @@ var init_capabilities = __esm({
719
725
  }
720
726
  });
721
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
+
722
773
  // src/core/file-lock.ts
723
774
  function sleepSync(ms) {
724
775
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -730,9 +781,48 @@ function isProcessAlive(pid) {
730
781
  try {
731
782
  process.kill(pid, 0);
732
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
+ }
733
797
  } catch {
734
- return false;
735
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 };
819
+ }
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;
736
826
  }
737
827
  function sameFile(left, right) {
738
828
  return left.dev === right.dev && left.ino === right.ino;
@@ -740,7 +830,7 @@ function sameFile(left, right) {
740
830
  function tryReclaimStaleLock(lockPath, staleLinkPath) {
741
831
  let fd;
742
832
  try {
743
- fd = import_node_fs3.default.openSync(lockPath, "r");
833
+ fd = import_node_fs4.default.openSync(lockPath, "r");
744
834
  } catch (error) {
745
835
  if (error.code === "ENOENT") {
746
836
  return { kind: "retry" };
@@ -748,17 +838,16 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
748
838
  throw error;
749
839
  }
750
840
  try {
751
- const owner = import_node_fs3.default.readFileSync(fd, "utf-8").trim();
752
- const ownerPid = Number(owner.split(":", 1)[0]);
753
- if (isProcessAlive(ownerPid)) {
754
- 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 };
755
844
  }
756
- const staleStat = import_node_fs3.default.fstatSync(fd);
845
+ const staleStat = import_node_fs4.default.fstatSync(fd);
757
846
  if (staleStat.nlink !== 1) {
758
847
  return { kind: "retry" };
759
848
  }
760
849
  try {
761
- import_node_fs3.default.linkSync(lockPath, staleLinkPath);
850
+ import_node_fs4.default.linkSync(lockPath, staleLinkPath);
762
851
  } catch (error) {
763
852
  if (error.code === "ENOENT") {
764
853
  return { kind: "retry" };
@@ -768,7 +857,7 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
768
857
  try {
769
858
  let currentStat;
770
859
  try {
771
- currentStat = import_node_fs3.default.statSync(lockPath);
860
+ currentStat = import_node_fs4.default.statSync(lockPath);
772
861
  } catch (error) {
773
862
  if (error.code === "ENOENT") {
774
863
  return { kind: "retry" };
@@ -778,27 +867,32 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
778
867
  if (!sameFile(staleStat, currentStat) || currentStat.nlink !== 2) {
779
868
  return { kind: "retry" };
780
869
  }
781
- import_node_fs3.default.rmSync(lockPath);
870
+ import_node_fs4.default.rmSync(lockPath);
782
871
  return { kind: "reclaimed" };
783
872
  } finally {
784
- import_node_fs3.default.rmSync(staleLinkPath, { force: true });
873
+ import_node_fs4.default.rmSync(staleLinkPath, { force: true });
785
874
  }
786
875
  } finally {
787
- import_node_fs3.default.closeSync(fd);
876
+ import_node_fs4.default.closeSync(fd);
788
877
  }
789
878
  }
790
879
  function acquireFileLock(lockPath, options) {
791
- const owner = `${process.pid}:${(0, import_node_crypto.randomUUID)()}`;
792
- 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`;
793
887
  const staleLinkPath = `${candidatePath}.stale`;
794
888
  const deadline = Date.now() + (options.waitMs ?? 0);
795
889
  let reclaimAttempts = 0;
796
- import_node_fs3.default.writeFileSync(candidatePath, `${owner}
890
+ import_node_fs4.default.writeFileSync(candidatePath, `${owner}
797
891
  `, { encoding: "utf-8", flag: "wx" });
798
892
  try {
799
893
  for (; ; ) {
800
894
  try {
801
- import_node_fs3.default.linkSync(candidatePath, lockPath);
895
+ import_node_fs4.default.linkSync(candidatePath, lockPath);
802
896
  return owner;
803
897
  } catch (error) {
804
898
  if (error.code !== "EEXIST") {
@@ -826,14 +920,14 @@ function acquireFileLock(lockPath, options) {
826
920
  }
827
921
  }
828
922
  } finally {
829
- import_node_fs3.default.rmSync(candidatePath, { force: true });
830
- 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 });
831
925
  }
832
926
  }
833
927
  function releaseFileLock(lockPath, owner) {
834
928
  try {
835
- if (import_node_fs3.default.readFileSync(lockPath, "utf-8").trim() === owner) {
836
- 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);
837
931
  }
838
932
  } catch (error) {
839
933
  if (error.code !== "ENOENT") {
@@ -857,20 +951,21 @@ async function withFileLock(lockPath, options, operation) {
857
951
  releaseFileLock(lockPath, owner);
858
952
  }
859
953
  }
860
- var import_node_crypto, import_node_fs3;
954
+ var import_node_child_process2, import_node_crypto2, import_node_fs4;
861
955
  var init_file_lock = __esm({
862
956
  "src/core/file-lock.ts"() {
863
957
  "use strict";
864
- import_node_crypto = require("crypto");
865
- 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"));
866
961
  }
867
962
  });
868
963
 
869
964
  // src/core/workspace.ts
870
965
  function comparableWorkspacePath(filePath) {
871
- const resolved = import_node_path3.default.resolve(filePath);
966
+ const resolved = import_node_path4.default.resolve(filePath);
872
967
  try {
873
- return import_node_fs4.default.realpathSync.native(resolved);
968
+ return import_node_fs5.default.realpathSync.native(resolved);
874
969
  } catch {
875
970
  return resolved;
876
971
  }
@@ -883,10 +978,10 @@ function wsFromBranch(branch) {
883
978
  return slug.length > 0 ? slug : void 0;
884
979
  }
885
980
  function isLinkedWorktree(repoPath) {
886
- const gitPath = import_node_path3.default.join(repoPath, ".git");
981
+ const gitPath = import_node_path4.default.join(repoPath, ".git");
887
982
  let stat;
888
983
  try {
889
- stat = import_node_fs4.default.statSync(gitPath);
984
+ stat = import_node_fs5.default.statSync(gitPath);
890
985
  } catch {
891
986
  return false;
892
987
  }
@@ -895,7 +990,7 @@ function isLinkedWorktree(repoPath) {
895
990
  }
896
991
  let content;
897
992
  try {
898
- content = import_node_fs4.default.readFileSync(gitPath, "utf-8");
993
+ content = import_node_fs5.default.readFileSync(gitPath, "utf-8");
899
994
  } catch {
900
995
  return false;
901
996
  }
@@ -907,10 +1002,10 @@ function isLinkedWorktree(repoPath) {
907
1002
  return /(^|\/)worktrees\//.test(gitdir);
908
1003
  }
909
1004
  function gitMetadataDir(repoPath) {
910
- const gitPath = import_node_path3.default.join(repoPath, ".git");
1005
+ const gitPath = import_node_path4.default.join(repoPath, ".git");
911
1006
  let stat;
912
1007
  try {
913
- stat = import_node_fs4.default.statSync(gitPath);
1008
+ stat = import_node_fs5.default.statSync(gitPath);
914
1009
  } catch {
915
1010
  return void 0;
916
1011
  }
@@ -920,11 +1015,11 @@ function gitMetadataDir(repoPath) {
920
1015
  if (!stat.isFile()) {
921
1016
  return void 0;
922
1017
  }
923
- 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);
924
1019
  if (!match) {
925
1020
  return void 0;
926
1021
  }
927
- return import_node_path3.default.resolve(repoPath, match[1].trim());
1022
+ return import_node_path4.default.resolve(repoPath, match[1].trim());
928
1023
  }
929
1024
  function validatePersistedWorkspace(value) {
930
1025
  const workspace = value.trim();
@@ -938,9 +1033,9 @@ function readPersistedWorkspace(repoPath) {
938
1033
  if (!gitDir) {
939
1034
  return void 0;
940
1035
  }
941
- const metadataPath = import_node_path3.default.join(gitDir, WORKSPACE_METADATA_FILE);
1036
+ const metadataPath = import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE);
942
1037
  try {
943
- return validatePersistedWorkspace(import_node_fs4.default.readFileSync(metadataPath, "utf-8"));
1038
+ return validatePersistedWorkspace(import_node_fs5.default.readFileSync(metadataPath, "utf-8"));
944
1039
  } catch (error) {
945
1040
  if (error.code === "ENOENT") {
946
1041
  return void 0;
@@ -963,7 +1058,7 @@ function persistWorkspace(repoPath, value) {
963
1058
  if (!gitDir) {
964
1059
  throw new Error(`cannot persist workspace identity: '${repoPath}' is not a Git checkout`);
965
1060
  }
966
- 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}
967
1062
  `, "utf-8");
968
1063
  return workspace;
969
1064
  }
@@ -972,7 +1067,7 @@ async function withWorkspaceLifecycleLock(repoPath, operation) {
972
1067
  if (!gitDir) {
973
1068
  throw new Error(`cannot lock workspace lifecycle: '${repoPath}' is not a Git checkout`);
974
1069
  }
975
- const lockPath = import_node_path3.default.join(gitDir, WORKSPACE_LOCK_FILE);
1070
+ const lockPath = import_node_path4.default.join(gitDir, WORKSPACE_LOCK_FILE);
976
1071
  return withFileLock(
977
1072
  lockPath,
978
1073
  { activity: "workspace lifecycle", target: `'${repoPath}'` },
@@ -980,7 +1075,7 @@ async function withWorkspaceLifecycleLock(repoPath, operation) {
980
1075
  );
981
1076
  }
982
1077
  function currentBranch(repoPath) {
983
- 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"], {
984
1079
  encoding: "utf-8"
985
1080
  });
986
1081
  if (result.status !== 0) {
@@ -1000,7 +1095,7 @@ function deriveLinkedWorktreeWorkspace(repoPath, linkedWorktreeBranch) {
1000
1095
  return void 0;
1001
1096
  }
1002
1097
  const branch = currentBranch(repoPath);
1003
- 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)));
1004
1099
  }
1005
1100
  function resolveWorktreeWorkspace(repoPath, linkedWorktreeBranch) {
1006
1101
  return readPersistedWorkspace(repoPath) ?? deriveLinkedWorktreeWorkspace(repoPath, linkedWorktreeBranch);
@@ -1023,13 +1118,13 @@ function resolveWorkspace(repoPath, override) {
1023
1118
  }
1024
1119
  return deriveLinkedWorktreeWorkspace(repoPath);
1025
1120
  }
1026
- 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;
1027
1122
  var init_workspace = __esm({
1028
1123
  "src/core/workspace.ts"() {
1029
1124
  "use strict";
1030
- import_node_child_process2 = require("child_process");
1031
- import_node_fs4 = __toESM(require("fs"));
1032
- 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"));
1033
1128
  init_file_lock();
1034
1129
  MAX_WORKSPACE_LENGTH = 32;
1035
1130
  WORKSPACE_METADATA_FILE = "devrouter-workspace";
@@ -1149,64 +1244,199 @@ function buildHostRoutesDocument(routes, tlsEnabled) {
1149
1244
  }
1150
1245
  return document;
1151
1246
  }
1152
- 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");
1153
1307
  const document = buildHostRoutesDocument(routes, tlsEnabled);
1154
- 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 })}`;
1155
1310
  }
1156
- function ensureHostRouteStorage() {
1157
- import_node_fs5.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
1158
- import_node_fs5.default.mkdirSync(TRAEFIK_DYNAMIC_DIR, { recursive: true });
1159
- if (!import_node_fs5.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1160
- import_node_fs5.default.writeFileSync(
1161
- TRAEFIK_HOST_ROUTES_FILE,
1162
- import_yaml.default.stringify({ http: { routers: {}, services: {} } }, { lineWidth: 0 }),
1163
- "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}`
1164
1322
  );
1165
1323
  }
1166
- if (!import_node_fs5.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1167
- 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}`);
1328
+ }
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.");
1168
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 });
1169
1380
  }
1170
1381
  function writeState(routes) {
1171
1382
  ensureHostRouteStorage();
1172
- import_node_fs5.default.writeFileSync(HOST_ROUTES_STATE_FILE, `${JSON.stringify(routes, null, 2)}
1173
- `, "utf-8");
1174
- writeHostRoutesDynamicFile(routes, isTLSEnabled());
1383
+ writeRouteGeneration(routes, isTLSEnabled());
1175
1384
  }
1176
1385
  function withStateLock(fn) {
1177
1386
  ensureHostRouteStorage();
1178
1387
  return withFileLockSync(STATE_LOCK_FILE, { activity: "host route update", waitMs: 5e3 }, fn);
1179
1388
  }
1180
1389
  function refreshHostRoutesDynamicFile() {
1181
- const routes = listHostRouteState();
1182
- writeHostRoutesDynamicFile(routes, isTLSEnabled());
1390
+ withStateLock(() => {
1391
+ const routes = readHostRouteStateLocked();
1392
+ writeState(routes);
1393
+ });
1183
1394
  }
1184
- function listHostRouteState() {
1395
+ function readHostRouteStateLocked() {
1185
1396
  ensureHostRouteStorage();
1186
- 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());
1187
1404
  return [];
1188
1405
  }
1189
- try {
1190
- const raw = import_node_fs5.default.readFileSync(HOST_ROUTES_STATE_FILE, "utf-8");
1191
- const parsed = JSON.parse(raw);
1192
- if (!Array.isArray(parsed)) {
1193
- return [];
1194
- }
1195
- return parsed.filter((item) => item && typeof item === "object").map((item) => item);
1196
- } catch (err) {
1197
- const error = err;
1198
- if (error.code !== "ENOENT") {
1199
- process.stderr.write(
1200
- `Warning: devrouter host routes state file is corrupted or unreadable (${error.message}). Recreating route state.
1201
- `
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."
1202
1412
  );
1203
1413
  }
1204
- return [];
1414
+ const routes = readCompatibilityState();
1415
+ writeRouteGeneration(routes, isTLSEnabled());
1416
+ return routes;
1205
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
+ );
1431
+ }
1432
+ return canonical.metadata.routes;
1433
+ }
1434
+ function listHostRouteState() {
1435
+ return withStateLock(readHostRouteStateLocked);
1206
1436
  }
1207
1437
  function upsertHostRoute(input2) {
1208
1438
  return withStateLock(() => {
1209
- const routes = listHostRouteState();
1439
+ const routes = readHostRouteStateLocked();
1210
1440
  const id = buildHostRouteId(input2.repoPath, input2.name);
1211
1441
  const existing = routes.find((route) => route.id === id);
1212
1442
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1223,7 +1453,7 @@ function replaceHostRoutesForRepo(repoPath, inputs) {
1223
1453
  if (inputs.some((input2) => !sameWorkspacePath(input2.repoPath, repoPath))) {
1224
1454
  throw new Error(`Route replacement contains an entry outside '${repoPath}'.`);
1225
1455
  }
1226
- const routes = listHostRouteState();
1456
+ const routes = readHostRouteStateLocked();
1227
1457
  const remaining = routes.filter((route) => !sameWorkspacePath(route.repoPath, repoPath));
1228
1458
  const names = /* @__PURE__ */ new Set();
1229
1459
  const hosts = /* @__PURE__ */ new Set();
@@ -1261,7 +1491,7 @@ function replaceHostRoutesForRepo(repoPath, inputs) {
1261
1491
  }
1262
1492
  function removeHostRouteById(id) {
1263
1493
  return withStateLock(() => {
1264
- const routes = listHostRouteState();
1494
+ const routes = readHostRouteStateLocked();
1265
1495
  const next = routes.filter((route) => route.id !== id);
1266
1496
  if (next.length === routes.length) {
1267
1497
  return false;
@@ -1272,7 +1502,7 @@ function removeHostRouteById(id) {
1272
1502
  }
1273
1503
  function removeHostRoutesWhere(predicate) {
1274
1504
  return withStateLock(() => {
1275
- const routes = listHostRouteState();
1505
+ const routes = readHostRouteStateLocked();
1276
1506
  const removed = routes.filter(predicate);
1277
1507
  if (removed.length === 0) {
1278
1508
  return [];
@@ -1294,7 +1524,7 @@ function listHostRoutes(tlsEnabled) {
1294
1524
  protocol: isTcp ? `tcp/${tcpProtocol}` : "http",
1295
1525
  appName: route.name,
1296
1526
  serviceName: route.name,
1297
- projectName: import_node_path4.default.basename(route.repoPath),
1527
+ projectName: import_node_path5.default.basename(route.repoPath),
1298
1528
  hosts: [route.host],
1299
1529
  // TCP routes are reached via an SNI-aware client on the shared protocol
1300
1530
  // port; surface a protocol-scheme URL rather than http(s)://.
@@ -1309,13 +1539,15 @@ function listHostRoutes(tlsEnabled) {
1309
1539
  };
1310
1540
  });
1311
1541
  }
1312
- 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;
1313
1543
  var init_host_routes = __esm({
1314
1544
  "src/core/host-routes.ts"() {
1315
1545
  "use strict";
1316
- import_node_fs5 = __toESM(require("fs"));
1317
- 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");
1318
1549
  import_yaml = __toESM(require("yaml"));
1550
+ init_atomic_file();
1319
1551
  init_file_lock();
1320
1552
  init_router();
1321
1553
  init_workspace();
@@ -1324,6 +1556,8 @@ var init_host_routes = __esm({
1324
1556
  TCP_TLS_ALPN_PROTOCOLS = {
1325
1557
  postgres: ["postgresql"]
1326
1558
  };
1559
+ ROUTE_METADATA_PREFIX = "# devrouter-routes-v1: ";
1560
+ ROUTE_METADATA_FAMILY_PREFIX = "# devrouter-routes-";
1327
1561
  STATE_LOCK_FILE = `${HOST_ROUTES_STATE_FILE}.lock`;
1328
1562
  }
1329
1563
  });
@@ -1751,10 +1985,10 @@ function renderConfig(config) {
1751
1985
  return import_yaml2.default.stringify(config, { lineWidth: 0 });
1752
1986
  }
1753
1987
  function readConfigDocument(configPath) {
1754
- 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"));
1755
1989
  }
1756
1990
  function writeConfigDocument(configPath, doc) {
1757
- 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");
1758
1992
  }
1759
1993
  function getOrCreateAppsSeq(doc) {
1760
1994
  const apps = doc.get("apps", true);
@@ -1776,25 +2010,25 @@ function appToNodeValue(app) {
1776
2010
  return value;
1777
2011
  }
1778
2012
  function resolveRepoPath(repoPath) {
1779
- return import_node_path5.default.resolve(repoPath ?? process.cwd());
2013
+ return import_node_path6.default.resolve(repoPath ?? process.cwd());
1780
2014
  }
1781
2015
  function getRepoConfigPath(repoPath) {
1782
- return import_node_path5.default.join(resolveRepoPath(repoPath), CONFIG_FILE_NAME);
2016
+ return import_node_path6.default.join(resolveRepoPath(repoPath), CONFIG_FILE_NAME);
1783
2017
  }
1784
2018
  function loadRepoConfig(repoPath) {
1785
2019
  const resolvedRepoPath = resolveRepoPath(repoPath);
1786
2020
  const configPath = getRepoConfigPath(resolvedRepoPath);
1787
- if (!import_node_fs6.default.existsSync(configPath)) {
2021
+ if (!import_node_fs7.default.existsSync(configPath)) {
1788
2022
  throw new Error(
1789
2023
  `Missing ${CONFIG_FILE_NAME} in ${resolvedRepoPath}. Run 'dev repo init --repo ${resolvedRepoPath}' first.`
1790
2024
  );
1791
2025
  }
1792
- const raw = import_node_fs6.default.readFileSync(configPath, "utf-8");
2026
+ const raw = import_node_fs7.default.readFileSync(configPath, "utf-8");
1793
2027
  const parsed = import_yaml2.default.parse(raw);
1794
2028
  const config = parseConfig(parsed ?? {}, configPath);
1795
2029
  const requiredVersion = config.devrouter?.version;
1796
2030
  if (requiredVersion && !hasWarnedVersionMismatch) {
1797
- const cliVersion = true ? "0.0.33" : "0.0.0-dev";
2031
+ const cliVersion = true ? "0.0.35" : "0.0.0-dev";
1798
2032
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
1799
2033
  hasWarnedVersionMismatch = true;
1800
2034
  process.stderr.write(
@@ -1811,7 +2045,7 @@ function loadRepoConfig(repoPath) {
1811
2045
  function initRepoConfig(repoPath, options = {}) {
1812
2046
  const resolvedRepoPath = resolveRepoPath(repoPath);
1813
2047
  const configPath = getRepoConfigPath(resolvedRepoPath);
1814
- if (import_node_fs6.default.existsSync(configPath)) {
2048
+ if (import_node_fs7.default.existsSync(configPath)) {
1815
2049
  return { repoPath: resolvedRepoPath, configPath, created: false };
1816
2050
  }
1817
2051
  if (options.devrouterVersion !== void 0 && !DEVROUTER_VERSION_RE.test(options.devrouterVersion)) {
@@ -1827,11 +2061,11 @@ function initRepoConfig(repoPath, options = {}) {
1827
2061
  }
1828
2062
  } : {},
1829
2063
  project: {
1830
- name: import_node_path5.default.basename(resolvedRepoPath)
2064
+ name: import_node_path6.default.basename(resolvedRepoPath)
1831
2065
  },
1832
2066
  apps: []
1833
2067
  };
1834
- import_node_fs6.default.writeFileSync(configPath, renderConfig(initialConfig), "utf-8");
2068
+ import_node_fs7.default.writeFileSync(configPath, renderConfig(initialConfig), "utf-8");
1835
2069
  return { repoPath: resolvedRepoPath, configPath, created: true };
1836
2070
  }
1837
2071
  function buildAppFromOptions(options) {
@@ -2056,7 +2290,7 @@ function namespaceHost(host, workspace) {
2056
2290
  return `${base}.${workspace}${suffix}`;
2057
2291
  }
2058
2292
  function applyWorkspace(config, workspace, repoPath) {
2059
- 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";
2060
2294
  const substitutionToken = workspace ?? defaultToken;
2061
2295
  const next = structuredClone(config);
2062
2296
  for (const app of next.apps) {
@@ -2131,12 +2365,12 @@ function resolveAppDependencies(config, app) {
2131
2365
  }
2132
2366
  return results;
2133
2367
  }
2134
- 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;
2135
2369
  var init_repo_config = __esm({
2136
2370
  "src/core/repo-config.ts"() {
2137
2371
  "use strict";
2138
- import_node_fs6 = __toESM(require("fs"));
2139
- import_node_path5 = __toESM(require("path"));
2372
+ import_node_fs7 = __toESM(require("fs"));
2373
+ import_node_path6 = __toESM(require("path"));
2140
2374
  import_yaml2 = __toESM(require("yaml"));
2141
2375
  init_capabilities();
2142
2376
  init_host_routes();
@@ -2203,6 +2437,7 @@ function buildOnboardingPrompt(options = {}) {
2203
2437
  "- Shared Traefik router owns host ports 80 (HTTP), 443 (HTTPS), and 5432 (Postgres TCP).",
2204
2438
  "- Per-repo source of truth is REPO_PATH/.devrouter.yml only.",
2205
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.",
2206
2441
  "",
2207
2442
  "Inputs:",
2208
2443
  `- REPO_PATH=${repoPath}`,
@@ -2246,6 +2481,7 @@ function buildOnboardingPrompt(options = {}) {
2246
2481
  "- if kind=app and runtime=proxy:",
2247
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`)',
2248
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",
2249
2485
  " - protocol=http registers an HTTP route; protocol=tcp registers a TLS-SNI TCP route and additionally requires tcpProtocol",
2250
2486
  " - do not set hostRun/docker/dependencies (proxy only registers a route to the upstream)",
2251
2487
  " - loopback hosts (localhost/127.0.0.1/0.0.0.0) are rewritten to host.docker.internal for Traefik",
@@ -2296,9 +2532,10 @@ function buildOnboardingPrompt(options = {}) {
2296
2532
  "Workspace isolation (parallel git worktrees / agents):",
2297
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.`,
2298
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.",
2299
- `- 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.`,
2300
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.",
2301
- "- 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. 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.",
2302
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.",
2303
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.",
2304
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.",
@@ -2463,8 +2700,8 @@ var init_ai_prompt = __esm({
2463
2700
  purpose: "Canonical startup and proof for an exact primary or linked checkout; atomically publishes routes after readiness."
2464
2701
  },
2465
2702
  {
2466
- command: "devrouter stop [path] [--json]",
2467
- 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."
2468
2705
  },
2469
2706
  {
2470
2707
  command: "devrouter exec [path] -- <command...>",
@@ -2881,11 +3118,11 @@ function extractCurrentVersionFromRepoConfig(repoPath) {
2881
3118
  };
2882
3119
  }
2883
3120
  function readPromptDirectory(promptDirPath) {
2884
- if (!import_node_fs7.default.existsSync(promptDirPath)) {
3121
+ if (!import_node_fs8.default.existsSync(promptDirPath)) {
2885
3122
  throw new Error(`Missing ${UPGRADE_PROMPTS_DIR} directory at ${promptDirPath}.`);
2886
3123
  }
2887
3124
  const releases = [];
2888
- const files = import_node_fs7.default.readdirSync(promptDirPath, { withFileTypes: true });
3125
+ const files = import_node_fs8.default.readdirSync(promptDirPath, { withFileTypes: true });
2889
3126
  for (const file of files) {
2890
3127
  if (!file.isFile() || !file.name.endsWith(".md")) {
2891
3128
  continue;
@@ -2895,8 +3132,8 @@ function readPromptDirectory(promptDirPath) {
2895
3132
  continue;
2896
3133
  }
2897
3134
  const version = normalizeVersion(basename);
2898
- const promptPath = import_node_path6.default.join(promptDirPath, file.name);
2899
- 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();
2900
3137
  releases.push({ version, prompt, promptPath });
2901
3138
  }
2902
3139
  const deduped = /* @__PURE__ */ new Map();
@@ -2917,19 +3154,19 @@ function listAvailableUpgradeTargets(currentVersion, releases) {
2917
3154
  }
2918
3155
  function resolvePromptDirectory(explicitPath) {
2919
3156
  if (explicitPath) {
2920
- return import_node_path6.default.resolve(explicitPath);
3157
+ return import_node_path7.default.resolve(explicitPath);
2921
3158
  }
2922
- const entryFile = process.argv[1] ? import_node_path6.default.resolve(process.argv[1]) : __filename;
2923
- const entryDir = import_node_path6.default.dirname(entryFile);
2924
- 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;
2925
3162
  const candidates = [
2926
- import_node_path6.default.resolve(resolvedEntryDir, "..", UPGRADE_PROMPTS_DIR),
2927
- import_node_path6.default.resolve(entryDir, "..", UPGRADE_PROMPTS_DIR),
2928
- import_node_path6.default.resolve(__dirname, "..", UPGRADE_PROMPTS_DIR),
2929
- import_node_path6.default.resolve(__dirname, "..", "..", UPGRADE_PROMPTS_DIR),
2930
- 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)
2931
3168
  ];
2932
- const existing = candidates.find((candidate) => import_node_fs7.default.existsSync(candidate));
3169
+ const existing = candidates.find((candidate) => import_node_fs8.default.existsSync(candidate));
2933
3170
  return existing ?? candidates[0];
2934
3171
  }
2935
3172
  function loadUpgradeCatalog(options = {}) {
@@ -2945,12 +3182,12 @@ function loadUpgradeCatalog(options = {}) {
2945
3182
  releases
2946
3183
  };
2947
3184
  }
2948
- 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;
2949
3186
  var init_upgrade = __esm({
2950
3187
  "src/core/upgrade.ts"() {
2951
3188
  "use strict";
2952
- import_node_fs7 = __toESM(require("fs"));
2953
- import_node_path6 = __toESM(require("path"));
3189
+ import_node_fs8 = __toESM(require("fs"));
3190
+ import_node_path7 = __toESM(require("path"));
2954
3191
  init_repo_config();
2955
3192
  SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
2956
3193
  UPGRADE_PROMPTS_DIR = "upgrade-prompts";
@@ -3050,7 +3287,7 @@ async function getDockerodeConstructor() {
3050
3287
  return dockerodeConstructorPromise;
3051
3288
  }
3052
3289
  function runDockerContextCommand(args) {
3053
- const result = (0, import_node_child_process3.spawnSync)("docker", ["context", ...args], {
3290
+ const result = (0, import_node_child_process4.spawnSync)("docker", ["context", ...args], {
3054
3291
  encoding: "utf-8"
3055
3292
  });
3056
3293
  if (result.status !== 0) {
@@ -3063,7 +3300,7 @@ function getCurrentDockerContext() {
3063
3300
  return runDockerContextCommand(["show"]);
3064
3301
  }
3065
3302
  function getDockerHostFromContext(context) {
3066
- const result = (0, import_node_child_process3.spawnSync)(
3303
+ const result = (0, import_node_child_process4.spawnSync)(
3067
3304
  "docker",
3068
3305
  ["context", "inspect", context, "--format", "{{ .Endpoints.docker.Host }}"],
3069
3306
  { encoding: "utf-8" }
@@ -3131,15 +3368,177 @@ async function networkExists(name) {
3131
3368
  return false;
3132
3369
  }
3133
3370
  }
3134
- var import_node_child_process3, dockerodeConstructorPromise;
3371
+ var import_node_child_process4, dockerodeConstructorPromise;
3135
3372
  var init_docker = __esm({
3136
3373
  "src/core/docker.ts"() {
3137
3374
  "use strict";
3138
- import_node_child_process3 = require("child_process");
3375
+ import_node_child_process4 = require("child_process");
3139
3376
  dockerodeConstructorPromise = null;
3140
3377
  }
3141
3378
  });
3142
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
+
3143
3542
  // src/core/devcontainer-diagnostics.ts
3144
3543
  function asRecord(value) {
3145
3544
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -3194,7 +3593,7 @@ function isLoopbackHost(host) {
3194
3593
  return host === "127.0.0.1" || host === "localhost" || host === "host.docker.internal";
3195
3594
  }
3196
3595
  function inspectCompose(composeFile, _workspace) {
3197
- if (!import_node_fs8.default.existsSync(composeFile)) {
3596
+ if (!import_node_fs10.default.existsSync(composeFile)) {
3198
3597
  return {
3199
3598
  aliases: [],
3200
3599
  publishedPorts: [],
@@ -3203,7 +3602,7 @@ function inspectCompose(composeFile, _workspace) {
3203
3602
  };
3204
3603
  }
3205
3604
  try {
3206
- const raw = import_node_fs8.default.readFileSync(composeFile, "utf-8");
3605
+ const raw = import_node_fs10.default.readFileSync(composeFile, "utf-8");
3207
3606
  const parsed = import_yaml3.default.parse(raw);
3208
3607
  const root = asRecord(parsed);
3209
3608
  const services = asRecord(root?.services);
@@ -3260,11 +3659,11 @@ function routedProxyApps(config) {
3260
3659
  );
3261
3660
  }
3262
3661
  function buildDevcontainerChecks(repoPath, config, workspace) {
3263
- const devcontainerDir = import_node_path7.default.join(repoPath, ".devcontainer");
3264
- 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)) {
3265
3664
  return [];
3266
3665
  }
3267
- 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);
3268
3667
  const checks = [];
3269
3668
  if (compose.parseError) {
3270
3669
  checks.push({
@@ -3291,6 +3690,36 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3291
3690
  details: compose.publishedPorts.length > 0 ? compose.publishedPorts.join(", ") : void 0,
3292
3691
  suggestion: compose.parseError || compose.publishedPorts.length > 0 ? "Remove published ports and route services through devnet aliases with devrouter proxy apps." : void 0
3293
3692
  });
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") : "";
3696
+ const devrouterArtifacts = ["@devrouter/cli", "devrouter-process"].filter(
3697
+ (artifact) => dockerfile.includes(artifact)
3698
+ );
3699
+ checks.push({
3700
+ id: "repo.devcontainer.no-devrouter-image-install",
3701
+ level: !dockerfileExists ? "warn" : devrouterArtifacts.length === 0 ? "ok" : "error",
3702
+ summary: !dockerfileExists ? "No .devcontainer/Dockerfile found to inspect for devrouter artifacts." : devrouterArtifacts.length === 0 ? "Consumer image does not install or extract devrouter artifacts." : "Consumer image installs or extracts devrouter artifacts.",
3703
+ details: devrouterArtifacts.length > 0 ? `artifacts=${devrouterArtifacts.join(", ")}` : void 0,
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
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
+ }
3294
3723
  if (!config) {
3295
3724
  checks.push({
3296
3725
  id: "repo.devcontainer.upstream-alias-match",
@@ -3318,30 +3747,31 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3318
3747
  });
3319
3748
  return checks;
3320
3749
  }
3321
- var import_node_fs8, import_node_path7, import_yaml3;
3750
+ var import_node_fs10, import_node_path9, import_yaml3;
3322
3751
  var init_devcontainer_diagnostics = __esm({
3323
3752
  "src/core/devcontainer-diagnostics.ts"() {
3324
3753
  "use strict";
3325
- import_node_fs8 = __toESM(require("fs"));
3326
- import_node_path7 = __toESM(require("path"));
3754
+ import_node_fs10 = __toESM(require("fs"));
3755
+ import_node_path9 = __toESM(require("path"));
3327
3756
  import_yaml3 = __toESM(require("yaml"));
3757
+ init_managed_post_start();
3328
3758
  }
3329
3759
  });
3330
3760
 
3331
3761
  // src/core/paths.ts
3332
3762
  function assertPathWithinRepo(filePath, repoRoot, label) {
3333
- const resolvedRoot = import_node_path8.default.resolve(repoRoot);
3334
- const resolved = import_node_path8.default.resolve(repoRoot, filePath);
3335
- 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)) {
3336
3766
  throw new Error(`${label} path '${filePath}' escapes the repository root.`);
3337
3767
  }
3338
3768
  return resolved;
3339
3769
  }
3340
- var import_node_path8;
3770
+ var import_node_path10;
3341
3771
  var init_paths = __esm({
3342
3772
  "src/core/paths.ts"() {
3343
3773
  "use strict";
3344
- import_node_path8 = __toESM(require("path"));
3774
+ import_node_path10 = __toESM(require("path"));
3345
3775
  }
3346
3776
  });
3347
3777
 
@@ -3426,11 +3856,11 @@ function reconcileRouteRunConflict(repoPath, app) {
3426
3856
  }
3427
3857
  }
3428
3858
  }
3429
- var import_node_fs9;
3859
+ var import_node_fs11;
3430
3860
  var init_route_state = __esm({
3431
3861
  "src/core/route-state.ts"() {
3432
3862
  "use strict";
3433
- import_node_fs9 = __toESM(require("fs"));
3863
+ import_node_fs11 = __toESM(require("fs"));
3434
3864
  init_host_routes();
3435
3865
  init_workspace();
3436
3866
  }
@@ -3605,7 +4035,7 @@ function toRepoStatus(repoPath) {
3605
4035
  const resolvedRepoPath = resolveRepoPath(repoPath);
3606
4036
  const configPath = getRepoConfigPath(resolvedRepoPath);
3607
4037
  const explicitRepo = typeof repoPath === "string" && repoPath.trim().length > 0;
3608
- const configExists = import_node_fs10.default.existsSync(configPath);
4038
+ const configExists = import_node_fs12.default.existsSync(configPath);
3609
4039
  if (!explicitRepo && !configExists) {
3610
4040
  return void 0;
3611
4041
  }
@@ -3732,11 +4162,11 @@ async function collectRouterStatus(repoPath) {
3732
4162
  }
3733
4163
  };
3734
4164
  }
3735
- var import_node_fs10;
4165
+ var import_node_fs12;
3736
4166
  var init_status = __esm({
3737
4167
  "src/core/status.ts"() {
3738
4168
  "use strict";
3739
- import_node_fs10 = __toESM(require("fs"));
4169
+ import_node_fs12 = __toESM(require("fs"));
3740
4170
  init_docker();
3741
4171
  init_repo_config();
3742
4172
  init_router();
@@ -3745,7 +4175,7 @@ var init_status = __esm({
3745
4175
 
3746
4176
  // src/core/tls.ts
3747
4177
  function runOrThrow(command, args) {
3748
- const result = (0, import_node_child_process4.spawnSync)(command, args, {
4178
+ const result = (0, import_node_child_process6.spawnSync)(command, args, {
3749
4179
  encoding: "utf-8"
3750
4180
  });
3751
4181
  if (result.status !== 0) {
@@ -3755,7 +4185,7 @@ function runOrThrow(command, args) {
3755
4185
  return result.stdout.trim();
3756
4186
  }
3757
4187
  function commandExists(command) {
3758
- 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" });
3759
4189
  return result.status === 0;
3760
4190
  }
3761
4191
  function ensureMkcert() {
@@ -3773,8 +4203,8 @@ function tlsSetupCommand(repoPath) {
3773
4203
  }
3774
4204
  function getMkcertRootCAPath(options = {}) {
3775
4205
  ensureMkcert();
3776
- const rootCAPath = import_node_path9.default.join(runOrThrow("mkcert", ["-CAROOT"]), "rootCA.pem");
3777
- 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)) {
3778
4208
  throw new Error(
3779
4209
  `mkcert root CA was not found at '${rootCAPath}'. Run: ${tlsSetupCommand(options.repoPath)}`
3780
4210
  );
@@ -3807,7 +4237,7 @@ function parseDnsHostsFromSubjectAltName(subjectAltName) {
3807
4237
  return normalizeUniqueHosts(names);
3808
4238
  }
3809
4239
  function parseCertificateDnsHosts(pem) {
3810
- const certificate = new import_node_crypto2.X509Certificate(pem);
4240
+ const certificate = new import_node_crypto4.X509Certificate(pem);
3811
4241
  const subjectAltName = certificate.subjectAltName ?? "";
3812
4242
  if (subjectAltName.length === 0) {
3813
4243
  return [];
@@ -3840,10 +4270,10 @@ function findUncoveredCertificateHosts(requiredHosts, certificateHosts) {
3840
4270
  );
3841
4271
  }
3842
4272
  function readCurrentCertificateHosts() {
3843
- if (!import_node_fs11.default.existsSync(CERT_FILE)) {
4273
+ if (!import_node_fs13.default.existsSync(CERT_FILE)) {
3844
4274
  return [];
3845
4275
  }
3846
- const pem = import_node_fs11.default.readFileSync(CERT_FILE, "utf-8");
4276
+ const pem = import_node_fs13.default.readFileSync(CERT_FILE, "utf-8");
3847
4277
  return parseCertificateDnsHosts(pem);
3848
4278
  }
3849
4279
  function currentCertificateHostsOrEmpty() {
@@ -3932,14 +4362,14 @@ Run: ${tlsSetupCommand(options.repoPath)}`
3932
4362
  );
3933
4363
  }
3934
4364
  }
3935
- 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;
3936
4366
  var init_tls = __esm({
3937
4367
  "src/core/tls.ts"() {
3938
4368
  "use strict";
3939
- import_node_child_process4 = require("child_process");
3940
- import_node_crypto2 = require("crypto");
3941
- import_node_fs11 = __toESM(require("fs"));
3942
- 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"));
3943
4373
  init_docker();
3944
4374
  init_host_routes();
3945
4375
  init_router();
@@ -3955,7 +4385,7 @@ function outputFromResult(result) {
3955
4385
  return output2.length > 0 ? output2 : void 0;
3956
4386
  }
3957
4387
  function runTool(command, args = []) {
3958
- const result = (0, import_node_child_process5.spawnSync)(command, args, {
4388
+ const result = (0, import_node_child_process7.spawnSync)(command, args, {
3959
4389
  encoding: "utf-8"
3960
4390
  });
3961
4391
  if (result.error) {
@@ -4012,12 +4442,12 @@ function parseMinimumNodeMajor(value) {
4012
4442
  return Number(match[1]);
4013
4443
  }
4014
4444
  function readPackageJson(repoPath) {
4015
- const packagePath = import_node_path10.default.join(repoPath, "package.json");
4016
- 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)) {
4017
4447
  return void 0;
4018
4448
  }
4019
4449
  try {
4020
- return JSON.parse(import_node_fs12.default.readFileSync(packagePath, "utf-8"));
4450
+ return JSON.parse(import_node_fs14.default.readFileSync(packagePath, "utf-8"));
4021
4451
  } catch {
4022
4452
  return void 0;
4023
4453
  }
@@ -4112,19 +4542,19 @@ function buildGlobalToolChecks(repoPath) {
4112
4542
  checks.push(nodeToolchainCheck(repoPath));
4113
4543
  return checks;
4114
4544
  }
4115
- var import_node_child_process5, import_node_fs12, import_node_path10;
4545
+ var import_node_child_process7, import_node_fs14, import_node_path12;
4116
4546
  var init_tool_diagnostics = __esm({
4117
4547
  "src/core/tool-diagnostics.ts"() {
4118
4548
  "use strict";
4119
- import_node_child_process5 = require("child_process");
4120
- import_node_fs12 = __toESM(require("fs"));
4121
- 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"));
4122
4552
  }
4123
4553
  });
4124
4554
 
4125
4555
  // src/core/devpod-workspaces.ts
4126
4556
  function listDevpodWorkspaces() {
4127
- 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" });
4128
4558
  if (result.status !== 0) {
4129
4559
  const details = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4130
4560
  throw new Error(`devpod list failed: ${details || "devpod is not installed or unavailable"}`);
@@ -4173,20 +4603,138 @@ function selectDevpodWorkspace(workspaces, repoPath) {
4173
4603
  }
4174
4604
  return matches[0];
4175
4605
  }
4176
- 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) {
4177
4628
  const args = action2 === "delete" ? [action2, devpodId, "--ignore-not-found"] : [action2, devpodId];
4178
- 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" });
4179
4630
  if (result.status !== 0) {
4180
- const detail = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4181
- 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
+ );
4182
4634
  }
4183
4635
  }
4184
- var import_node_child_process6;
4185
- var init_devpod_workspaces = __esm({
4186
- "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"() {
4187
4727
  "use strict";
4188
- import_node_child_process6 = require("child_process");
4189
- 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
+ };
4190
4738
  }
4191
4739
  });
4192
4740
 
@@ -4197,17 +4745,17 @@ function commandError(command, repoPath, stderr) {
4197
4745
  );
4198
4746
  }
4199
4747
  function resolveGitCommonDir(repoPath) {
4200
- 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"], {
4201
4749
  encoding: "utf-8"
4202
4750
  });
4203
4751
  const output2 = result.stdout.trim();
4204
4752
  if (result.status !== 0 || !output2) {
4205
4753
  throw commandError("Could not resolve the Git common directory", repoPath, result.stderr);
4206
4754
  }
4207
- 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));
4208
4756
  }
4209
4757
  function resolveGitTopLevel(repoPath) {
4210
- 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"], {
4211
4759
  encoding: "utf-8"
4212
4760
  });
4213
4761
  const output2 = result.stdout.trim();
@@ -4217,7 +4765,7 @@ function resolveGitTopLevel(repoPath) {
4217
4765
  return comparableWorkspacePath(output2);
4218
4766
  }
4219
4767
  function listGitWorktrees(repoPath) {
4220
- 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"], {
4221
4769
  encoding: "utf-8"
4222
4770
  });
4223
4771
  if (result.status !== 0) {
@@ -4253,7 +4801,7 @@ function listGitWorktrees(repoPath) {
4253
4801
  return worktrees;
4254
4802
  }
4255
4803
  function ownershipDirectory(repoPath) {
4256
- return import_node_path11.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
4804
+ return import_node_path14.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
4257
4805
  }
4258
4806
  function validateWorkspace(value, label) {
4259
4807
  if (typeof value !== "string" || wsFromBranch(value) !== value) {
@@ -4281,7 +4829,7 @@ function validateRecord(value, expectedWorkspace) {
4281
4829
  `workspace ownership file '${expectedWorkspace}' contains identity '${workspace}'`
4282
4830
  );
4283
4831
  }
4284
- 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)) {
4285
4833
  throw new Error("invalid workspace ownership worktreePath");
4286
4834
  }
4287
4835
  if (candidate.branch !== null && typeof candidate.branch !== "string") {
@@ -4301,7 +4849,7 @@ function validateRecord(value, expectedWorkspace) {
4301
4849
  function readRecordFile(filePath, expectedWorkspace) {
4302
4850
  let parsed;
4303
4851
  try {
4304
- parsed = JSON.parse(import_node_fs13.default.readFileSync(filePath, "utf-8"));
4852
+ parsed = JSON.parse(import_node_fs16.default.readFileSync(filePath, "utf-8"));
4305
4853
  } catch (error) {
4306
4854
  if (error instanceof SyntaxError) {
4307
4855
  throw new Error(`invalid workspace ownership JSON at '${filePath}'`);
@@ -4317,7 +4865,7 @@ function listWorkspaceOwnership(repoPath) {
4317
4865
  function listWorkspaceOwnershipInDirectory(directory) {
4318
4866
  let entries;
4319
4867
  try {
4320
- entries = import_node_fs13.default.readdirSync(directory, { withFileTypes: true });
4868
+ entries = import_node_fs16.default.readdirSync(directory, { withFileTypes: true });
4321
4869
  } catch (error) {
4322
4870
  if (error.code === "ENOENT") return [];
4323
4871
  throw error;
@@ -4325,14 +4873,14 @@ function listWorkspaceOwnershipInDirectory(directory) {
4325
4873
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
4326
4874
  const workspace = entry.name.slice(0, -".json".length);
4327
4875
  validateWorkspace(workspace, "filename");
4328
- return readRecordFile(import_node_path11.default.join(directory, entry.name), workspace);
4876
+ return readRecordFile(import_node_path14.default.join(directory, entry.name), workspace);
4329
4877
  });
4330
4878
  }
4331
4879
  function writeWorkspaceOwnershipInDirectory(directory, input2) {
4332
4880
  const workspace = validateWorkspace(input2.workspace, "workspace");
4333
4881
  const devpodId = validateWorkspace(input2.devpodId, "devpodId");
4334
4882
  const worktreePath = comparableWorkspacePath(input2.worktreePath);
4335
- const filePath = import_node_path11.default.join(directory, `${workspace}.json`);
4883
+ const filePath = import_node_path14.default.join(directory, `${workspace}.json`);
4336
4884
  const now = (/* @__PURE__ */ new Date()).toISOString();
4337
4885
  const records = listWorkspaceOwnershipInDirectory(directory);
4338
4886
  const existing = records.find((record2) => record2.workspace === workspace);
@@ -4363,23 +4911,14 @@ function writeWorkspaceOwnershipInDirectory(directory, input2) {
4363
4911
  createdAt: existing?.createdAt ?? validateTimestamp(now, "createdAt"),
4364
4912
  updatedAt: validateTimestamp(now, "updatedAt")
4365
4913
  };
4366
- const tempPath = `${filePath}.${process.pid}.${(0, import_node_crypto3.randomUUID)()}.tmp`;
4367
- try {
4368
- import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify(record, null, 2)}
4369
- `, {
4370
- encoding: "utf-8",
4371
- flag: "wx"
4372
- });
4373
- import_node_fs13.default.renameSync(tempPath, filePath);
4374
- } finally {
4375
- import_node_fs13.default.rmSync(tempPath, { force: true });
4376
- }
4914
+ writeFileAtomically(filePath, `${JSON.stringify(record, null, 2)}
4915
+ `);
4377
4916
  return record;
4378
4917
  }
4379
4918
  function removeWorkspaceOwnershipInDirectory(directory, workspace) {
4380
- 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`);
4381
4920
  try {
4382
- import_node_fs13.default.rmSync(filePath);
4921
+ import_node_fs16.default.rmSync(filePath);
4383
4922
  return true;
4384
4923
  } catch (error) {
4385
4924
  if (error.code === "ENOENT") return false;
@@ -4390,7 +4929,7 @@ function sameOwnershipRecord(left, right) {
4390
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;
4391
4930
  }
4392
4931
  function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
4393
- const filePath = import_node_path11.default.join(
4932
+ const filePath = import_node_path14.default.join(
4394
4933
  directory,
4395
4934
  `${validateWorkspace(expected.workspace, "workspace")}.json`
4396
4935
  );
@@ -4402,14 +4941,14 @@ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
4402
4941
  throw error;
4403
4942
  }
4404
4943
  if (!sameOwnershipRecord(current, expected)) return "changed";
4405
- import_node_fs13.default.rmSync(filePath);
4944
+ import_node_fs16.default.rmSync(filePath);
4406
4945
  return "removed";
4407
4946
  }
4408
4947
  function withWorkspaceOwnershipTransaction(repoPath, operation) {
4409
4948
  const directory = ownershipDirectory(repoPath);
4410
- import_node_fs13.default.mkdirSync(directory, { recursive: true });
4949
+ import_node_fs16.default.mkdirSync(directory, { recursive: true });
4411
4950
  return withFileLockSync(
4412
- import_node_path11.default.join(directory, ".lock"),
4951
+ import_node_path14.default.join(directory, ".lock"),
4413
4952
  { activity: "workspace ownership transaction", target: `'${repoPath}'`, waitMs: 5e3 },
4414
4953
  () => operation({
4415
4954
  list: () => listWorkspaceOwnershipInDirectory(directory),
@@ -4440,7 +4979,7 @@ function inspectWorkspaceOwnership(record, worktrees, devpods) {
4440
4979
  if (worktree?.locked) {
4441
4980
  return { ownerStatus: "locked", devpodStatus, worktree };
4442
4981
  }
4443
- if ((!worktree || worktree.prunable) && import_node_fs13.default.existsSync(record.worktreePath)) {
4982
+ if ((!worktree || worktree.prunable) && import_node_fs16.default.existsSync(record.worktreePath)) {
4444
4983
  return { ownerStatus: "conflict", devpodStatus, worktree };
4445
4984
  }
4446
4985
  if (!worktree || worktree.prunable) {
@@ -4464,19 +5003,19 @@ function listMissingWorkspaceOwnership(repoPath) {
4464
5003
  (record) => inspectWorkspaceOwnership(record, worktrees, void 0).ownerStatus === "missing"
4465
5004
  );
4466
5005
  }
4467
- 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;
4468
5007
  var init_workspace_ownership = __esm({
4469
5008
  "src/core/workspace-ownership.ts"() {
4470
5009
  "use strict";
4471
- import_node_child_process7 = require("child_process");
4472
- import_node_crypto3 = require("crypto");
4473
- import_node_fs13 = __toESM(require("fs"));
4474
- 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();
4475
5014
  init_devpod_workspaces();
4476
5015
  init_file_lock();
4477
5016
  init_workspace();
4478
5017
  OWNERSHIP_VERSION = 1;
4479
- OWNERSHIP_DIR = import_node_path11.default.join("devrouter", "workspaces");
5018
+ OWNERSHIP_DIR = import_node_path14.default.join("devrouter", "workspaces");
4480
5019
  }
4481
5020
  });
4482
5021
 
@@ -4490,10 +5029,10 @@ function inRepositoryWorkspaceScope(repoPath, worktreePath, livePaths) {
4490
5029
  if (livePaths.some((candidate) => sameWorkspacePath(candidate, worktreePath))) return true;
4491
5030
  const comparableRepo = comparableWorkspacePath(repoPath);
4492
5031
  const comparableWorktree = comparableWorkspacePath(worktreePath);
4493
- 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;
4494
5033
  if (comparableWorktree.startsWith(localRoot)) return true;
4495
- const legacyPrefix = `${import_node_path12.default.basename(comparableRepo)}-`;
4496
- 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);
4497
5036
  }
4498
5037
  function previewActions(devpodStatus, routeCount, includeRecord) {
4499
5038
  const actions = [
@@ -4621,17 +5160,6 @@ function revalidateCandidate(candidate, repoPath, transaction) {
4621
5160
  }
4622
5161
  };
4623
5162
  }
4624
- function recheckDevpodOwnership(record) {
4625
- const ownership = inspectDevpodWorkspaceOwnership(
4626
- listDevpodWorkspaces(),
4627
- record.devpodId,
4628
- record.worktreePath
4629
- );
4630
- if (ownership.status === "conflict") {
4631
- throw new Error(ownership.reason);
4632
- }
4633
- return ownership.status;
4634
- }
4635
5163
  function failedAction(resource, error) {
4636
5164
  return {
4637
5165
  resource,
@@ -4661,8 +5189,8 @@ function applyCandidate(candidate, repoPath) {
4661
5189
  const actions = [];
4662
5190
  let devpodStatus;
4663
5191
  try {
4664
- devpodStatus = recheckDevpodOwnership(record);
4665
- if (devpodStatus === "owned") runDevpodWorkspaceAction("delete", record.devpodId);
5192
+ const mutation = deleteOwnedDevpodWorkspace(record.devpodId, record.worktreePath);
5193
+ devpodStatus = mutation.status === "changed" ? "owned" : "absent";
4666
5194
  } catch (error) {
4667
5195
  return { ...fresh, actions: [failedAction("devpod", error)] };
4668
5196
  }
@@ -4747,11 +5275,12 @@ function applyWorkspaceGc(plan) {
4747
5275
  candidates
4748
5276
  };
4749
5277
  }
4750
- var import_node_path12;
5278
+ var import_node_path15;
4751
5279
  var init_workspace_gc = __esm({
4752
5280
  "src/core/workspace-gc.ts"() {
4753
5281
  "use strict";
4754
- import_node_path12 = __toESM(require("path"));
5282
+ import_node_path15 = __toESM(require("path"));
5283
+ init_devpod_mutation();
4755
5284
  init_devpod_workspaces();
4756
5285
  init_host_routes();
4757
5286
  init_repo_config();
@@ -4820,11 +5349,11 @@ function inspectPostgresCredentials(repoPath, config) {
4820
5349
  parseErrors.push(`${app.name}: ${composeFile} (${message})`);
4821
5350
  continue;
4822
5351
  }
4823
- if (!import_node_fs14.default.existsSync(absolutePath)) {
5352
+ if (!import_node_fs17.default.existsSync(absolutePath)) {
4824
5353
  continue;
4825
5354
  }
4826
5355
  try {
4827
- const raw = import_node_fs14.default.readFileSync(absolutePath, "utf-8");
5356
+ const raw = import_node_fs17.default.readFileSync(absolutePath, "utf-8");
4828
5357
  const parsed = import_yaml4.default.parse(raw);
4829
5358
  const root = asRecord2(parsed);
4830
5359
  const services = asRecord2(root?.services);
@@ -5087,7 +5616,7 @@ async function buildDoctorReport(options = {}) {
5087
5616
  const config = runtimeConfig.config;
5088
5617
  loadedConfig = config;
5089
5618
  loadedWorkspace = runtimeConfig.workspace;
5090
- const cliVersion = true ? "0.0.33" : "0.0.0-dev";
5619
+ const cliVersion = true ? "0.0.35" : "0.0.0-dev";
5091
5620
  const configVersion = config.devrouter?.version;
5092
5621
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
5093
5622
  addCheck(checks, {
@@ -5117,9 +5646,9 @@ async function buildDoctorReport(options = {}) {
5117
5646
  (app) => app.docker.composeFiles.map((filePath) => ({
5118
5647
  app: app.name,
5119
5648
  filePath,
5120
- absolutePath: import_node_path13.default.resolve(repo.path, filePath)
5649
+ absolutePath: import_node_path16.default.resolve(repo.path, filePath)
5121
5650
  }))
5122
- ).filter((entry) => !import_node_fs14.default.existsSync(entry.absolutePath));
5651
+ ).filter((entry) => !import_node_fs17.default.existsSync(entry.absolutePath));
5123
5652
  addCheck(checks, {
5124
5653
  id: "repo.compose-files",
5125
5654
  level: missingComposeFiles.length === 0 ? "ok" : "error",
@@ -5162,8 +5691,8 @@ async function buildDoctorReport(options = {}) {
5162
5691
  const missingHostCwds = config.apps.filter((app) => app.runtime === "host").map((app) => ({
5163
5692
  app: app.name,
5164
5693
  cwd: app.hostRun.cwd,
5165
- absolutePath: import_node_path13.default.resolve(repo.path, app.hostRun.cwd)
5166
- })).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));
5167
5696
  addCheck(checks, {
5168
5697
  id: "repo.host-cwd",
5169
5698
  level: missingHostCwds.length === 0 ? "ok" : "error",
@@ -5307,12 +5836,12 @@ async function buildDoctorReport(options = {}) {
5307
5836
  nextSteps
5308
5837
  };
5309
5838
  }
5310
- var import_node_fs14, import_node_path13, import_yaml4, POSTGRES_DEFAULTS;
5839
+ var import_node_fs17, import_node_path16, import_yaml4, POSTGRES_DEFAULTS;
5311
5840
  var init_doctor = __esm({
5312
5841
  "src/core/doctor.ts"() {
5313
5842
  "use strict";
5314
- import_node_fs14 = __toESM(require("fs"));
5315
- import_node_path13 = __toESM(require("path"));
5843
+ import_node_fs17 = __toESM(require("fs"));
5844
+ import_node_path16 = __toESM(require("path"));
5316
5845
  import_yaml4 = __toESM(require("yaml"));
5317
5846
  init_docker();
5318
5847
  init_host_routes();
@@ -5570,12 +6099,12 @@ function parseSsPortListeners(stdout, port) {
5570
6099
  return listeners;
5571
6100
  }
5572
6101
  function findPortListeners(port) {
5573
- 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"], {
5574
6103
  encoding: "utf-8"
5575
6104
  });
5576
6105
  if (result.error && result.error.code === "ENOENT") {
5577
6106
  if (process.platform === "linux") {
5578
- 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" });
5579
6108
  if (ssResult.status === 0 && ssResult.stdout) {
5580
6109
  return parseSsPortListeners(ssResult.stdout, port);
5581
6110
  }
@@ -5604,11 +6133,11 @@ function findPortListeners(port) {
5604
6133
  };
5605
6134
  });
5606
6135
  }
5607
- var import_node_child_process8;
6136
+ var import_node_child_process11;
5608
6137
  var init_ports = __esm({
5609
6138
  "src/util/ports.ts"() {
5610
6139
  "use strict";
5611
- import_node_child_process8 = require("child_process");
6140
+ import_node_child_process11 = require("child_process");
5612
6141
  }
5613
6142
  });
5614
6143
 
@@ -5665,7 +6194,7 @@ var init_up = __esm({
5665
6194
 
5666
6195
  // src/core/devpod-environment.ts
5667
6196
  function inspectWorkspaceContainers() {
5668
- 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}}"], {
5669
6198
  encoding: "utf-8"
5670
6199
  });
5671
6200
  if (listed.status !== 0) {
@@ -5675,7 +6204,7 @@ function inspectWorkspaceContainers() {
5675
6204
  }
5676
6205
  const ids = listed.stdout.split(/\r?\n/).map((id) => id.trim()).filter(Boolean);
5677
6206
  if (ids.length === 0) return [];
5678
- 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], {
5679
6208
  encoding: "utf-8"
5680
6209
  });
5681
6210
  if (inspected.status !== 0) {
@@ -5688,7 +6217,7 @@ function inspectWorkspaceContainers() {
5688
6217
  function workspaceAppContainers(containers, repoPath) {
5689
6218
  return containers.filter((container) => {
5690
6219
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
5691
- 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(
5692
6221
  (mount) => mount.Type === "bind" && sameWorkspacePath(mount.Source, repoPath)
5693
6222
  );
5694
6223
  });
@@ -5711,12 +6240,12 @@ function resolveRunningWorkspaceContainer(repoPath) {
5711
6240
  }
5712
6241
  return { id: container.id, workspacePath: repoMount.Destination };
5713
6242
  }
5714
- var import_node_child_process9, import_node_path14, SAFE_INSPECT_TEMPLATE;
6243
+ var import_node_child_process12, import_node_path17, SAFE_INSPECT_TEMPLATE;
5715
6244
  var init_devpod_environment = __esm({
5716
6245
  "src/core/devpod-environment.ts"() {
5717
6246
  "use strict";
5718
- import_node_child_process9 = require("child_process");
5719
- import_node_path14 = __toESM(require("path"));
6247
+ import_node_child_process12 = require("child_process");
6248
+ import_node_path17 = __toESM(require("path"));
5720
6249
  init_workspace();
5721
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}}}';
5722
6251
  }
@@ -5743,18 +6272,18 @@ function probeHttpRoute(host, options = {}) {
5743
6272
  args.push("--cacert", getMkcertRootCAPath({ repoPath: options.repoPath }));
5744
6273
  }
5745
6274
  args.push(url);
5746
- 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" });
5747
6276
  const stdout = result.stdout?.trim() ?? "";
5748
6277
  const status = /^\d{3}$/.test(stdout) ? Number(stdout) : void 0;
5749
6278
  const ok = result.status === 0 && status !== void 0 && status >= 100 && status < 500;
5750
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"}`;
5751
6280
  return { ok, status, details };
5752
6281
  }
5753
- var import_node_child_process10;
6282
+ var import_node_child_process13;
5754
6283
  var init_http_route_probe = __esm({
5755
6284
  "src/core/http-route-probe.ts"() {
5756
6285
  "use strict";
5757
- import_node_child_process10 = require("child_process");
6286
+ import_node_child_process13 = require("child_process");
5758
6287
  init_router();
5759
6288
  init_tls();
5760
6289
  }
@@ -5833,11 +6362,11 @@ var init_route_publication = __esm({
5833
6362
  // src/core/workspace-ensure.ts
5834
6363
  function assertOverlay(container, repoPath) {
5835
6364
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
5836
- if (!workingDir || !sameWorkspacePath(workingDir, import_node_path15.default.join(repoPath, ".devcontainer"))) {
6365
+ if (!workingDir || !sameWorkspacePath(workingDir, import_node_path18.default.join(repoPath, ".devcontainer"))) {
5837
6366
  throw new Error(`Container '${container.id}' does not belong to the exact worktree.`);
5838
6367
  }
5839
6368
  const configFiles = (container.labels["com.docker.compose.project.config_files"] ?? "").split(",").filter(Boolean);
5840
- const expectedOverlay = import_node_path15.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6369
+ const expectedOverlay = import_node_path18.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
5841
6370
  if (!configFiles.some((configFile) => sameWorkspacePath(configFile, expectedOverlay))) {
5842
6371
  throw new Error(`Container '${container.id}' was not started with ${DEVCONTAINER_OVERLAY}.`);
5843
6372
  }
@@ -5911,7 +6440,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
5911
6440
  target
5912
6441
  });
5913
6442
  if (target.kind === "linked") {
5914
- const workspaceEnv = (0, import_node_child_process11.spawnSync)(
6443
+ const workspaceEnv = (0, import_node_child_process14.spawnSync)(
5915
6444
  "docker",
5916
6445
  ["exec", appContainer.id, "printenv", "WORKSPACE"],
5917
6446
  { encoding: "utf-8" }
@@ -5921,7 +6450,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
5921
6450
  `Workspace app container must expose WORKSPACE='${target.workspace}' (got '${workspaceEnv.stdout.trim() || "(empty)"}').`
5922
6451
  );
5923
6452
  }
5924
- const devrouterWorkspaceEnv = (0, import_node_child_process11.spawnSync)(
6453
+ const devrouterWorkspaceEnv = (0, import_node_child_process14.spawnSync)(
5925
6454
  "docker",
5926
6455
  ["exec", appContainer.id, "printenv", "DEVROUTER_WORKSPACE"],
5927
6456
  { encoding: "utf-8" }
@@ -5932,7 +6461,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
5932
6461
  );
5933
6462
  }
5934
6463
  }
5935
- const gitCheck = (0, import_node_child_process11.spawnSync)(
6464
+ const gitCheck = (0, import_node_child_process14.spawnSync)(
5936
6465
  "docker",
5937
6466
  [
5938
6467
  "exec",
@@ -5948,7 +6477,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
5948
6477
  if (gitCheck.status !== 0 || !sameWorkspacePath(gitCheck.stdout.trim(), appContainer.workspacePath)) {
5949
6478
  throw new Error("Git does not resolve the expected checkout inside the app container.");
5950
6479
  }
5951
- return;
6480
+ return appContainer;
5952
6481
  } catch (error) {
5953
6482
  lastError = error;
5954
6483
  }
@@ -6019,63 +6548,14 @@ function resolvePrimaryTarget(repoPath) {
6019
6548
  }
6020
6549
  function isPrimaryCheckout(repoPath) {
6021
6550
  try {
6022
- return import_node_fs15.default.statSync(import_node_path15.default.join(repoPath, ".git")).isDirectory();
6551
+ return import_node_fs18.default.statSync(import_node_path18.default.join(repoPath, ".git")).isDirectory();
6023
6552
  } catch {
6024
6553
  return false;
6025
6554
  }
6026
6555
  }
6027
- function startDevpod(repoPath, target, recreate = false, quiet = false) {
6028
- const args = ["up", repoPath];
6029
- if (target.devpodId) {
6030
- args.push("--id", target.devpodId);
6031
- }
6032
- args.push("--open-ide=false");
6033
- if (target.kind === "linked") {
6034
- args.push(
6035
- "--workspace-env",
6036
- `WORKSPACE=${target.workspace}`,
6037
- "--workspace-env",
6038
- `DEVROUTER_WORKSPACE=${target.workspace}`
6039
- );
6040
- }
6041
- if (recreate) {
6042
- if (!target.devpodId) {
6043
- throw new Error("Cannot recreate a DevPod before its exact id is known.");
6044
- }
6045
- args.push("--recreate");
6046
- }
6047
- const env = { ...process.env };
6048
- if (target.kind === "linked") {
6049
- env.WORKSPACE = target.workspace;
6050
- env.DEVROUTER_WORKSPACE = target.workspace;
6051
- env.DEVROUTER_GIT_COMMON_DIR = target.gitCommonDir;
6052
- env.DEVCONTAINER_COMPOSE_OVERLAY = DEVCONTAINER_OVERLAY;
6053
- } else {
6054
- delete env.WORKSPACE;
6055
- delete env.DEVROUTER_WORKSPACE;
6056
- delete env.DEVROUTER_GIT_COMMON_DIR;
6057
- delete env.DEVCONTAINER_COMPOSE_OVERLAY;
6058
- }
6059
- const result = (0, import_node_child_process11.spawnSync)("devpod", args, {
6060
- stdio: quiet ? ["inherit", 2, "inherit"] : "inherit",
6061
- env
6062
- });
6063
- if (result.status !== 0) {
6064
- throw new Error(`devpod up failed for '${target.devpodId ?? repoPath}'.`);
6065
- }
6066
- }
6067
- function assertDevpodAttachment(repoPath, expectedId) {
6068
- const attached = selectDevpodWorkspace(listDevpodWorkspaces(), repoPath);
6069
- if (!attached || expectedId && attached.id !== expectedId) {
6070
- throw new Error(
6071
- `DevPod did not attach '${repoPath}'${expectedId ? ` as '${expectedId}'` : ""} after startup.`
6072
- );
6073
- }
6074
- return attached.id;
6075
- }
6076
6556
  function openUrls(urls) {
6077
6557
  for (const url of urls) {
6078
- const opened = (0, import_node_child_process11.spawnSync)("open", [url], { encoding: "utf-8" });
6558
+ const opened = (0, import_node_child_process14.spawnSync)("open", [url], { encoding: "utf-8" });
6079
6559
  if (opened.status !== 0) {
6080
6560
  process.stderr.write(`Warning: could not open '${url}'.
6081
6561
  `);
@@ -6105,27 +6585,28 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6105
6585
  const target = linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
6106
6586
  let devpodId = target.devpodId;
6107
6587
  if (target.kind === "linked") {
6108
- const overlayPath = import_node_path15.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6109
- if (!import_node_fs15.default.existsSync(overlayPath)) {
6588
+ const overlayPath = import_node_path18.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6589
+ if (!import_node_fs18.default.existsSync(overlayPath)) {
6110
6590
  throw new Error(`Missing required DevPod compose overlay: ${overlayPath}`);
6111
6591
  }
6112
6592
  }
6593
+ const managedPostStart = resolveManagedPostStartPlan(repoPath);
6113
6594
  const runtime = loadRuntimeConfig(
6114
6595
  repoPath,
6115
6596
  target.kind === "primary" ? "" : target.workspace
6116
6597
  );
6117
6598
  const apps = proxyAppsFromConfig(runtime.config);
6118
6599
  const parsedUpstreams = apps.map((app) => parseUpstream(app.upstream));
6119
- const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path15.default.basename(repoPath)) ?? "app";
6600
+ const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path18.default.basename(repoPath)) ?? "app";
6120
6601
  for (const [index, app] of apps.entries()) {
6121
- if (app.protocol === "tcp" && !parsedUpstreams[index].host.startsWith(`${aliasPrefix}-`)) {
6602
+ if (!parsedUpstreams[index].host.startsWith(`${aliasPrefix}-`)) {
6122
6603
  const owner = target.kind === "linked" ? "workspace" : "checkout";
6123
6604
  throw new Error(
6124
- `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}-'.`
6125
6606
  );
6126
6607
  }
6127
6608
  }
6128
- const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host).filter((host) => host.startsWith(`${aliasPrefix}-`));
6609
+ const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host);
6129
6610
  const ownership = target.kind === "linked" ? {
6130
6611
  workspace: target.workspace,
6131
6612
  worktreePath: repoPath,
@@ -6137,43 +6618,61 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6137
6618
  }
6138
6619
  const currentTarget = () => target.kind === "linked" ? target : { ...target, devpodId };
6139
6620
  const startAndProveAttachment = (recreate = false) => {
6140
- startDevpod(repoPath, currentTarget(), recreate, options.quiet);
6141
- environmentStarted = true;
6142
- 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
+ }
6143
6640
  if (ownership) {
6144
6641
  writeWorkspaceOwnership(repoPath, ownership);
6145
6642
  }
6146
6643
  };
6147
- const recreateAndWait = async () => {
6644
+ const preflight = (timeoutMs) => waitForContainerPreflight(repoPath, currentTarget(), upstreamHosts, timeoutMs);
6645
+ const recreateAndPreflight = async () => {
6148
6646
  startAndProveAttachment(true);
6149
- await waitForContainerPreflight(
6150
- repoPath,
6151
- currentTarget(),
6152
- upstreamHosts,
6153
- options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
6154
- );
6647
+ return preflight(options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS);
6155
6648
  };
6156
6649
  let recreated = false;
6650
+ let container;
6157
6651
  try {
6158
6652
  startAndProveAttachment();
6159
6653
  } catch (error) {
6160
6654
  if (!target.hadExactDevpod) {
6161
6655
  throw error;
6162
6656
  }
6163
- await recreateAndWait();
6657
+ container = await recreateAndPreflight();
6164
6658
  recreated = true;
6165
6659
  }
6166
- if (!recreated) {
6660
+ if (!container) {
6167
6661
  try {
6168
- await waitForContainerPreflight(repoPath, currentTarget(), upstreamHosts, 0);
6662
+ container = await preflight(0);
6169
6663
  } catch (error) {
6170
6664
  if (!target.hadExactDevpod) {
6171
6665
  throw error;
6172
6666
  }
6173
- await recreateAndWait();
6667
+ container = await recreateAndPreflight();
6174
6668
  recreated = true;
6175
6669
  }
6176
6670
  }
6671
+ runManagedPostStart({
6672
+ plan: managedPostStart,
6673
+ container,
6674
+ quiet: options.quiet
6675
+ });
6177
6676
  const publication = await replacePublishedProxyRoutes(
6178
6677
  repoPath,
6179
6678
  runtime.config,
@@ -6190,7 +6689,12 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6190
6689
  if (!target.hadExactDevpod || recreated) {
6191
6690
  throw error;
6192
6691
  }
6193
- await recreateAndWait();
6692
+ const recoveredContainer = await recreateAndPreflight();
6693
+ runManagedPostStart({
6694
+ plan: managedPostStart,
6695
+ container: recoveredContainer,
6696
+ quiet: options.quiet
6697
+ });
6194
6698
  recreated = true;
6195
6699
  replaceHostRoutesForRepo(repoPath, publication.routes);
6196
6700
  await waitForHttpRoutes(
@@ -6233,17 +6737,19 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6233
6737
  }
6234
6738
  });
6235
6739
  }
6236
- var import_node_child_process11, import_node_fs15, import_node_path15, 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;
6237
6741
  var init_workspace_ensure = __esm({
6238
6742
  "src/core/workspace-ensure.ts"() {
6239
6743
  "use strict";
6240
- import_node_child_process11 = require("child_process");
6241
- import_node_fs15 = __toESM(require("fs"));
6242
- import_node_path15 = __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"));
6243
6747
  init_devpod_environment();
6748
+ init_devpod_mutation();
6244
6749
  init_devpod_workspaces();
6245
6750
  init_host_routes();
6246
6751
  init_http_route_probe();
6752
+ init_managed_post_start();
6247
6753
  init_repo_config();
6248
6754
  init_route_publication();
6249
6755
  init_router();
@@ -6313,20 +6819,20 @@ function warnMissingWorkspaceOwnership(repoPath) {
6313
6819
  );
6314
6820
  }
6315
6821
  function defaultWorktreePath(mainRepo, ws) {
6316
- return import_node_path16.default.join(mainRepo, "trees", ws);
6822
+ return import_node_path19.default.join(mainRepo, "trees", ws);
6317
6823
  }
6318
6824
  function assertDefaultWorktreeRootIgnored(mainRepo) {
6319
- const ignored = (0, import_node_child_process12.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/"], {
6320
6826
  encoding: "utf-8"
6321
6827
  });
6322
6828
  if (ignored.status !== 0) {
6323
6829
  throw new Error(
6324
- `Default worktree root '${import_node_path16.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path16.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.`
6325
6831
  );
6326
6832
  }
6327
6833
  }
6328
6834
  function legacyDefaultWorktreePath(mainRepo, ws) {
6329
- return import_node_path16.default.join(import_node_path16.default.dirname(mainRepo), `${import_node_path16.default.basename(mainRepo)}-${ws}`);
6835
+ return import_node_path19.default.join(import_node_path19.default.dirname(mainRepo), `${import_node_path19.default.basename(mainRepo)}-${ws}`);
6330
6836
  }
6331
6837
  function teardownFallbackPath(mainRepo, workspace) {
6332
6838
  const candidates = [
@@ -6418,7 +6924,7 @@ function resolveWorkspaceTarget(mainRepo, target, worktrees, records) {
6418
6924
  function worktreeForRecord(worktrees, record) {
6419
6925
  return worktrees.find((candidate) => sameWorkspacePath(candidate.path, record.worktreePath));
6420
6926
  }
6421
- function devpodForTarget(target, worktrees, devpods) {
6927
+ function assertDevpodTargetSafe(target, worktrees, devpods) {
6422
6928
  if (target.record) {
6423
6929
  const status = inspectWorkspaceOwnership(target.record, worktrees, devpods);
6424
6930
  if (status.ownerStatus === "conflict") {
@@ -6426,26 +6932,25 @@ function devpodForTarget(target, worktrees, devpods) {
6426
6932
  `Workspace '${target.workspace}' ownership conflicts with live Git or DevPod evidence; no resources were changed.`
6427
6933
  );
6428
6934
  }
6429
- return status.devpodStatus === "owned" ? devpods.find((devpod) => devpod.id === target.record?.devpodId) : void 0;
6935
+ return;
6430
6936
  }
6431
6937
  const devpodId = target.workspace;
6432
6938
  const ownership = inspectDevpodWorkspaceOwnership(devpods, devpodId, target.worktreePath);
6433
6939
  if (ownership.status === "conflict") {
6434
6940
  throw new Error(ownership.reason);
6435
6941
  }
6436
- return ownership.status === "owned" ? ownership.workspace : void 0;
6437
6942
  }
6438
6943
  function assertFullDownPreflight(mainRepo, target) {
6439
6944
  if (sameWorkspacePath(target.worktreePath, mainRepo)) {
6440
6945
  throw new Error("Refusing to remove the primary Git checkout.");
6441
6946
  }
6442
- if (!target.worktree || target.worktree.prunable || !import_node_fs16.default.existsSync(target.worktreePath)) return;
6947
+ if (!target.worktree || target.worktree.prunable || !import_node_fs19.default.existsSync(target.worktreePath)) return;
6443
6948
  if (target.worktree.locked) {
6444
6949
  throw new Error(
6445
6950
  `Worktree '${target.worktreePath}' is locked; unlock it before workspace down.`
6446
6951
  );
6447
6952
  }
6448
- const status = (0, import_node_child_process12.spawnSync)(
6953
+ const status = (0, import_node_child_process15.spawnSync)(
6449
6954
  "git",
6450
6955
  ["-C", target.worktreePath, "status", "--porcelain", "--untracked-files=normal"],
6451
6956
  { encoding: "utf-8" }
@@ -6466,8 +6971,8 @@ async function workspaceUp(branch, opts = {}) {
6466
6971
  if (!ws) {
6467
6972
  throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
6468
6973
  }
6469
- const worktreePath = opts.path ? import_node_path16.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
6470
- if (import_node_fs16.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)) {
6471
6976
  const registered = listGitWorktrees(mainRepo).find(
6472
6977
  (worktree) => sameWorkspacePath(worktree.path, worktreePath)
6473
6978
  );
@@ -6485,11 +6990,11 @@ async function workspaceUp(branch, opts = {}) {
6485
6990
  if (!opts.path) {
6486
6991
  assertDefaultWorktreeRootIgnored(mainRepo);
6487
6992
  }
6488
- const add = (0, import_node_child_process12.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
6993
+ const add = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
6489
6994
  encoding: "utf-8"
6490
6995
  });
6491
6996
  if (add.status !== 0) {
6492
- const addNew = (0, import_node_child_process12.spawnSync)(
6997
+ const addNew = (0, import_node_child_process15.spawnSync)(
6493
6998
  "git",
6494
6999
  ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath],
6495
7000
  {
@@ -6560,10 +7065,9 @@ function workspaceLs(repoPath) {
6560
7065
  }
6561
7066
  function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
6562
7067
  const devpods = listDevpodWorkspaces();
6563
- const devpod = devpodForTarget(resolved, worktrees, devpods);
6564
- if (devpod) {
6565
- runDevpodWorkspaceAction(action2 === "stop" ? "stop" : "delete", devpod.id);
6566
- }
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);
6567
7071
  const routes = removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
6568
7072
  if (!quiet) {
6569
7073
  process.stdout.write(
@@ -6572,9 +7076,9 @@ function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
6572
7076
  );
6573
7077
  }
6574
7078
  return {
6575
- ...devpod ? { devpodId: devpod.id } : {},
7079
+ ...mutation.status === "changed" ? { devpodId } : {},
6576
7080
  freedRoutes: routes.length,
6577
- providerChanged: Boolean(devpod),
7081
+ providerChanged: mutation.status === "changed",
6578
7082
  workspace: resolved.workspace
6579
7083
  };
6580
7084
  }
@@ -6588,10 +7092,15 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
6588
7092
  if (removeWorktree) {
6589
7093
  assertFullDownPreflight(mainRepo, resolved);
6590
7094
  }
6591
- 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
+ );
6592
7101
  if (removeWorktree) {
6593
- if (resolved.worktree && !resolved.worktree.prunable && import_node_fs16.default.existsSync(resolved.worktreePath)) {
6594
- const rm = (0, import_node_child_process12.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], {
6595
7104
  encoding: "utf-8"
6596
7105
  });
6597
7106
  if (rm.status !== 0) {
@@ -6609,9 +7118,9 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
6609
7118
  }
6610
7119
  return result;
6611
7120
  };
6612
- return resolved.worktree && !resolved.worktree.prunable && import_node_fs16.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();
6613
7122
  }
6614
- async function workspaceStopOwnedPath(worktreePath, opts = {}) {
7123
+ async function mutateWorkspaceOwnedPath(action2, worktreePath, opts = {}) {
6615
7124
  const mainRepo = resolveRepoPath(opts.repoPath);
6616
7125
  return withWorkspaceLifecycleLock(worktreePath, async () => {
6617
7126
  const worktrees = identifyGitWorktrees(mainRepo);
@@ -6629,22 +7138,29 @@ async function workspaceStopOwnedPath(worktreePath, opts = {}) {
6629
7138
  worktree: worktreeForRecord(worktrees, record),
6630
7139
  record
6631
7140
  };
6632
- return mutateWorkspaceRuntime("stop", resolved, worktrees, opts.quiet);
7141
+ return mutateWorkspaceRuntime(action2, resolved, worktrees, opts.quiet);
6633
7142
  });
6634
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
+ }
6635
7150
  async function workspaceStop(target, opts = {}) {
6636
7151
  return runWorkspaceLifecycle("stop", target, opts);
6637
7152
  }
6638
7153
  async function workspaceDown(target, opts = {}) {
6639
7154
  return runWorkspaceLifecycle("down", target, opts);
6640
7155
  }
6641
- var import_node_child_process12, import_node_fs16, import_node_path16;
7156
+ var import_node_child_process15, import_node_fs19, import_node_path19;
6642
7157
  var init_workspace_lifecycle = __esm({
6643
7158
  "src/core/workspace-lifecycle.ts"() {
6644
7159
  "use strict";
6645
- import_node_child_process12 = require("child_process");
6646
- import_node_fs16 = __toESM(require("fs"));
6647
- import_node_path16 = __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();
6648
7164
  init_devpod_workspaces();
6649
7165
  init_repo_config();
6650
7166
  init_route_state();
@@ -6655,7 +7171,7 @@ var init_workspace_lifecycle = __esm({
6655
7171
  });
6656
7172
 
6657
7173
  // src/core/environment-stop.ts
6658
- async function environmentStop(repoPath) {
7174
+ async function environmentStop(repoPath, options = {}) {
6659
7175
  const linked = isLinkedWorktree(repoPath);
6660
7176
  const workspace = linked ? resolveWorktreeWorkspace(repoPath) : void 0;
6661
7177
  if (linked && !workspace) {
@@ -6674,25 +7190,27 @@ async function environmentStop(repoPath) {
6674
7190
  if (!mainRepo) {
6675
7191
  throw new Error(`Could not resolve the primary checkout for '${repoPath}'.`);
6676
7192
  }
6677
- const result = await workspaceStopOwnedPath(record.worktreePath, {
6678
- quiet: true,
6679
- repoPath: mainRepo
6680
- });
7193
+ const result = await (options.delete ? workspaceDeleteOwnedPath : workspaceStopOwnedPath)(
7194
+ record.worktreePath,
7195
+ {
7196
+ quiet: true,
7197
+ repoPath: mainRepo
7198
+ }
7199
+ );
6681
7200
  return {
6682
7201
  kind: "linked",
6683
7202
  repoPath,
6684
7203
  workspace: result.workspace,
6685
7204
  ...result.devpodId ? { devpodId: result.devpodId } : {},
6686
- stopped: result.providerChanged,
7205
+ stopped: !options.delete && result.providerChanged,
7206
+ ...options.delete ? { deleted: result.providerChanged } : {},
6687
7207
  freedRoutes: result.freedRoutes
6688
7208
  };
6689
7209
  }
6690
7210
  }
6691
7211
  return withWorkspaceLifecycleLock(repoPath, async () => {
6692
7212
  const devpod = selectDevpodWorkspace(listDevpodWorkspaces(), repoPath);
6693
- if (devpod) {
6694
- runDevpodWorkspaceAction("stop", devpod.id);
6695
- }
7213
+ const mutation = devpod ? options.delete ? deleteOwnedDevpodWorkspace(devpod.id, repoPath) : stopOwnedDevpodWorkspace(devpod.id, repoPath) : { status: "absent" };
6696
7214
  const removedRoutes = removeHostRoutesWhere(
6697
7215
  (route) => sameWorkspacePath(route.repoPath, repoPath)
6698
7216
  );
@@ -6700,8 +7218,9 @@ async function environmentStop(repoPath) {
6700
7218
  kind: linked ? "linked" : "primary",
6701
7219
  repoPath,
6702
7220
  ...workspace ? { workspace } : {},
6703
- ...devpod ? { devpodId: devpod.id } : {},
6704
- 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" } : {},
6705
7224
  freedRoutes: removedRoutes.length
6706
7225
  };
6707
7226
  });
@@ -6709,6 +7228,7 @@ async function environmentStop(repoPath) {
6709
7228
  var init_environment_stop = __esm({
6710
7229
  "src/core/environment-stop.ts"() {
6711
7230
  "use strict";
7231
+ init_devpod_mutation();
6712
7232
  init_devpod_workspaces();
6713
7233
  init_host_routes();
6714
7234
  init_workspace();
@@ -6724,19 +7244,19 @@ __export(stop_exports, {
6724
7244
  });
6725
7245
  async function runStopCommand(options) {
6726
7246
  const repoPath = resolveGitCheckoutPath(options.path);
6727
- const result = await environmentStop(repoPath);
7247
+ const result = await environmentStop(repoPath, { delete: options.delete });
6728
7248
  if (options.json) {
6729
7249
  process.stdout.write(`${JSON.stringify(result, null, 2)}
6730
7250
  `);
6731
7251
  return;
6732
7252
  }
6733
7253
  const label = result.kind === "primary" ? "Primary checkout" : `Workspace '${result.workspace}'`;
6734
- if (!result.stopped && result.freedRoutes === 0) {
7254
+ if (!result.stopped && !result.deleted && result.freedRoutes === 0) {
6735
7255
  process.stdout.write(`${label} is already stopped; no routes needed removal.
6736
7256
  `);
6737
7257
  return;
6738
7258
  }
6739
- const provider = result.stopped ? `Stopped DevPod '${result.devpodId}'. ` : "";
7259
+ const provider = result.deleted ? `Deleted DevPod '${result.devpodId}'. ` : result.stopped ? `Stopped DevPod '${result.devpodId}'. ` : "";
6740
7260
  process.stdout.write(
6741
7261
  `${provider}Freed ${result.freedRoutes} route(s) for ${label.toLowerCase()}.
6742
7262
  `
@@ -6766,7 +7286,7 @@ function ensureGuidance(repoPath) {
6766
7286
  return `Run 'devrouter ensure ${repoPath}' first.`;
6767
7287
  }
6768
7288
  function assertRunningDevpod(devpodId, repoPath) {
6769
- const result = (0, import_node_child_process13.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
7289
+ const result = (0, import_node_child_process16.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
6770
7290
  encoding: "utf-8"
6771
7291
  });
6772
7292
  if (result.status !== 0) {
@@ -6804,7 +7324,7 @@ async function devpodExec(repoPath, command) {
6804
7324
  }
6805
7325
  assertRunningDevpod(devpod.id, repoPath);
6806
7326
  const workspaceDirectory = resolveWorkspaceDirectory(repoPath);
6807
- const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto4.randomUUID)()}__:`;
7327
+ const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto5.randomUUID)()}__:`;
6808
7328
  const statusMarkerBytes = Buffer.from(statusMarker, "ascii");
6809
7329
  const literalCommand = command.map(quotePosixArg).join(" ");
6810
7330
  const wrappedCommand = `${literalCommand}; __devrouter_status=$?; printf '${statusMarker}%s\\n' "$__devrouter_status" >&2; exit 0`;
@@ -6822,7 +7342,7 @@ async function devpodExec(repoPath, command) {
6822
7342
  wrappedCommand
6823
7343
  ];
6824
7344
  return new Promise((resolve, reject) => {
6825
- const child = (0, import_node_child_process13.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
7345
+ const child = (0, import_node_child_process16.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
6826
7346
  let pending = Buffer.alloc(0);
6827
7347
  let statusBytes = Buffer.alloc(0);
6828
7348
  let readingStatus = false;
@@ -6881,12 +7401,12 @@ async function devpodExec(repoPath, command) {
6881
7401
  });
6882
7402
  });
6883
7403
  }
6884
- var import_node_child_process13, import_node_crypto4, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
7404
+ var import_node_child_process16, import_node_crypto5, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
6885
7405
  var init_devpod_exec = __esm({
6886
7406
  "src/core/devpod-exec.ts"() {
6887
7407
  "use strict";
6888
- import_node_child_process13 = require("child_process");
6889
- import_node_crypto4 = require("crypto");
7408
+ import_node_child_process16 = require("child_process");
7409
+ import_node_crypto5 = require("crypto");
6890
7410
  init_devpod_environment();
6891
7411
  init_devpod_workspaces();
6892
7412
  init_workspace();
@@ -7070,7 +7590,7 @@ async function runOpenCommand(name) {
7070
7590
  );
7071
7591
  return;
7072
7592
  }
7073
- const result = (0, import_node_child_process14.spawnSync)("open", [url], { encoding: "utf-8" });
7593
+ const result = (0, import_node_child_process17.spawnSync)("open", [url], { encoding: "utf-8" });
7074
7594
  if (result.status !== 0) {
7075
7595
  const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
7076
7596
  throw new Error(`Unable to open '${url}': ${details || "unknown error"}`);
@@ -7078,11 +7598,11 @@ async function runOpenCommand(name) {
7078
7598
  process.stdout.write(`Opened ${url}
7079
7599
  `);
7080
7600
  }
7081
- var import_node_child_process14;
7601
+ var import_node_child_process17;
7082
7602
  var init_open = __esm({
7083
7603
  "src/commands/open.ts"() {
7084
7604
  "use strict";
7085
- import_node_child_process14 = require("child_process");
7605
+ import_node_child_process17 = require("child_process");
7086
7606
  init_docker();
7087
7607
  init_host_routes();
7088
7608
  init_repo_config();
@@ -7105,7 +7625,7 @@ async function runLogsCommand(options) {
7105
7625
  const args = ["logs", "--tail", tail, ROUTER_CONTAINER_NAME];
7106
7626
  if (options.follow) {
7107
7627
  args.splice(1, 0, "-f");
7108
- const child = (0, import_node_child_process15.spawn)("docker", args, { stdio: "inherit" });
7628
+ const child = (0, import_node_child_process18.spawn)("docker", args, { stdio: "inherit" });
7109
7629
  const onSignal = () => {
7110
7630
  child.kill("SIGTERM");
7111
7631
  };
@@ -7119,17 +7639,17 @@ async function runLogsCommand(options) {
7119
7639
  });
7120
7640
  });
7121
7641
  } else {
7122
- const result = (0, import_node_child_process15.spawnSync)("docker", args, { stdio: "inherit" });
7642
+ const result = (0, import_node_child_process18.spawnSync)("docker", args, { stdio: "inherit" });
7123
7643
  if (result.status !== 0) {
7124
7644
  throw new Error("Failed to retrieve router logs.");
7125
7645
  }
7126
7646
  }
7127
7647
  }
7128
- var import_node_child_process15;
7648
+ var import_node_child_process18;
7129
7649
  var init_logs = __esm({
7130
7650
  "src/commands/logs.ts"() {
7131
7651
  "use strict";
7132
- import_node_child_process15 = require("child_process");
7652
+ import_node_child_process18 = require("child_process");
7133
7653
  init_docker();
7134
7654
  init_router();
7135
7655
  }
@@ -7169,17 +7689,17 @@ function asRecord3(value) {
7169
7689
  return value;
7170
7690
  }
7171
7691
  function readJson(filePath) {
7172
- if (!import_node_fs17.default.existsSync(filePath)) {
7692
+ if (!import_node_fs20.default.existsSync(filePath)) {
7173
7693
  return void 0;
7174
7694
  }
7175
7695
  try {
7176
- return JSON.parse(import_node_fs17.default.readFileSync(filePath, "utf-8"));
7696
+ return JSON.parse(import_node_fs20.default.readFileSync(filePath, "utf-8"));
7177
7697
  } catch {
7178
7698
  return void 0;
7179
7699
  }
7180
7700
  }
7181
7701
  function relative(repoPath, filePath) {
7182
- return import_node_path17.default.relative(repoPath, filePath) || ".";
7702
+ return import_node_path20.default.relative(repoPath, filePath) || ".";
7183
7703
  }
7184
7704
  function redactEnvAssignments(value) {
7185
7705
  return value.replace(
@@ -7220,7 +7740,7 @@ function inspectPackageManager(repoPath, pkg) {
7220
7740
  ["bun.lock", "bun"]
7221
7741
  ];
7222
7742
  for (const [fileName, name] of lockfiles) {
7223
- if (import_node_fs17.default.existsSync(import_node_path17.default.join(repoPath, fileName))) {
7743
+ if (import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))) {
7224
7744
  return { name, source: fileName };
7225
7745
  }
7226
7746
  }
@@ -7235,9 +7755,9 @@ function inspectNode(repoPath, pkg) {
7235
7755
  if (typeof engines?.node === "string") {
7236
7756
  return { version: engines.node, source: "package.json:engines.node" };
7237
7757
  }
7238
- const nvmrc = import_node_path17.default.join(repoPath, ".nvmrc");
7239
- if (import_node_fs17.default.existsSync(nvmrc)) {
7240
- const version = import_node_fs17.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();
7241
7761
  return { version, source: ".nvmrc" };
7242
7762
  }
7243
7763
  return void 0;
@@ -7298,7 +7818,7 @@ function configuredComposeFiles(repoPath) {
7298
7818
  const files = config.apps.filter(
7299
7819
  (app) => app.runtime === "docker"
7300
7820
  ).flatMap((app) => app.docker.composeFiles).filter(
7301
- (fileName) => !import_node_path17.default.isAbsolute(fileName) && !import_node_path17.default.normalize(fileName).startsWith("..")
7821
+ (fileName) => !import_node_path20.default.isAbsolute(fileName) && !import_node_path20.default.normalize(fileName).startsWith("..")
7302
7822
  );
7303
7823
  return Array.from(new Set(files));
7304
7824
  } catch {
@@ -7316,7 +7836,7 @@ function composeFiles(repoPath) {
7316
7836
  ...configuredComposeFiles(repoPath)
7317
7837
  ];
7318
7838
  return Array.from(new Set(candidates)).filter(
7319
- (fileName) => import_node_fs17.default.existsSync(import_node_path17.default.join(repoPath, fileName))
7839
+ (fileName) => import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))
7320
7840
  );
7321
7841
  }
7322
7842
  function stringArray(value) {
@@ -7354,7 +7874,7 @@ function inspectServices(repoPath) {
7354
7874
  const services = [];
7355
7875
  for (const fileName of composeFiles(repoPath)) {
7356
7876
  try {
7357
- const parsed = import_yaml5.default.parse(import_node_fs17.default.readFileSync(import_node_path17.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"));
7358
7878
  const serviceMap = asRecord3(asRecord3(parsed)?.services);
7359
7879
  for (const [name, value] of Object.entries(serviceMap ?? {})) {
7360
7880
  const service = asRecord3(value);
@@ -7389,8 +7909,8 @@ function inspectServices(repoPath) {
7389
7909
  return services;
7390
7910
  }
7391
7911
  function inspectEnvFiles(repoPath) {
7392
- const files = import_node_fs17.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
7393
- const content = import_node_fs17.default.readFileSync(import_node_path17.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");
7394
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();
7395
7915
  return { path: fileName, names };
7396
7916
  });
@@ -7404,18 +7924,18 @@ function inspectEnvFiles(repoPath) {
7404
7924
  };
7405
7925
  }
7406
7926
  function inspectDevcontainer(repoPath) {
7407
- const dir = import_node_path17.default.join(repoPath, ".devcontainer");
7408
- if (!import_node_fs17.default.existsSync(dir)) {
7927
+ const dir = import_node_path20.default.join(repoPath, ".devcontainer");
7928
+ if (!import_node_fs20.default.existsSync(dir)) {
7409
7929
  return { exists: false, files: [] };
7410
7930
  }
7411
7931
  return {
7412
7932
  exists: true,
7413
- files: import_node_fs17.default.readdirSync(dir).filter((fileName) => import_node_fs17.default.statSync(import_node_path17.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}`)
7414
7934
  };
7415
7935
  }
7416
7936
  function inspectDevrouter(repoPath) {
7417
7937
  const configPath = getRepoConfigPath(repoPath);
7418
- if (!import_node_fs17.default.existsSync(configPath)) {
7938
+ if (!import_node_fs20.default.existsSync(configPath)) {
7419
7939
  return {
7420
7940
  exists: false,
7421
7941
  configPath,
@@ -7459,15 +7979,15 @@ function inspectAgentGuidance(repoPath) {
7459
7979
  ["AGENTS.md", "agents"],
7460
7980
  ["CLAUDE.md", "claude"]
7461
7981
  ]) {
7462
- if (import_node_fs17.default.existsSync(import_node_path17.default.join(repoPath, fileName))) {
7982
+ if (import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))) {
7463
7983
  results.push({ path: fileName, kind });
7464
7984
  }
7465
7985
  }
7466
- const skillsDir = import_node_path17.default.join(repoPath, ".agents", "skills");
7467
- if (import_node_fs17.default.existsSync(skillsDir)) {
7468
- for (const name of import_node_fs17.default.readdirSync(skillsDir).sort()) {
7469
- const skillPath = import_node_path17.default.join(skillsDir, name, "SKILL.md");
7470
- if (import_node_fs17.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)) {
7471
7991
  results.push({ path: relative(repoPath, skillPath), kind: "skill" });
7472
7992
  }
7473
7993
  }
@@ -7510,7 +8030,7 @@ function buildIssues(report) {
7510
8030
  }
7511
8031
  function inspectRepo(options = {}) {
7512
8032
  const repoPath = resolveRepoPath(options.repo);
7513
- const pkg = readJson(import_node_path17.default.join(repoPath, "package.json"));
8033
+ const pkg = readJson(import_node_path20.default.join(repoPath, "package.json"));
7514
8034
  const scripts = inspectScripts(pkg);
7515
8035
  const reportWithoutIssues = {
7516
8036
  repoPath,
@@ -7529,12 +8049,12 @@ function inspectRepo(options = {}) {
7529
8049
  issues: buildIssues(reportWithoutIssues)
7530
8050
  };
7531
8051
  }
7532
- var import_node_fs17, import_node_path17, import_yaml5;
8052
+ var import_node_fs20, import_node_path20, import_yaml5;
7533
8053
  var init_repo_inspect = __esm({
7534
8054
  "src/core/repo-inspect.ts"() {
7535
8055
  "use strict";
7536
- import_node_fs17 = __toESM(require("fs"));
7537
- import_node_path17 = __toESM(require("path"));
8056
+ import_node_fs20 = __toESM(require("fs"));
8057
+ import_node_path20 = __toESM(require("path"));
7538
8058
  import_yaml5 = __toESM(require("yaml"));
7539
8059
  init_repo_config();
7540
8060
  }
@@ -7639,7 +8159,7 @@ function requiredFileChecks(repoPath) {
7639
8159
  ".devcontainer/docker-compose.yml",
7640
8160
  ".devrouter.yml"
7641
8161
  ];
7642
- const missing = required.filter((fileName) => !import_node_fs18.default.existsSync(import_node_path18.default.join(repoPath, fileName)));
8162
+ const missing = required.filter((fileName) => !import_node_fs21.default.existsSync(import_node_path21.default.join(repoPath, fileName)));
7643
8163
  return {
7644
8164
  id: "repo.devcontainer.verify-files",
7645
8165
  level: missing.length === 0 ? "ok" : "error",
@@ -7858,12 +8378,12 @@ async function verifyDevcontainer(options = {}) {
7858
8378
  nextSteps: collectNextSteps3(checks)
7859
8379
  };
7860
8380
  }
7861
- var import_node_fs18, import_node_path18;
8381
+ var import_node_fs21, import_node_path21;
7862
8382
  var init_devcontainer_verify = __esm({
7863
8383
  "src/core/devcontainer-verify.ts"() {
7864
8384
  "use strict";
7865
- import_node_fs18 = __toESM(require("fs"));
7866
- import_node_path18 = __toESM(require("path"));
8385
+ import_node_fs21 = __toESM(require("fs"));
8386
+ import_node_path21 = __toESM(require("path"));
7867
8387
  init_capabilities();
7868
8388
  init_doctor();
7869
8389
  init_host_routes();
@@ -7901,30 +8421,22 @@ function inferDevProcess(repo) {
7901
8421
  function inferPort2(repo) {
7902
8422
  return repo.apps.find((app) => app.port)?.port ?? 3e3;
7903
8423
  }
7904
- function renderDockerfile(nodeMajor, pnpmVersion, version) {
8424
+ function renderDockerfile(nodeMajor, pnpmVersion) {
7905
8425
  const pnpmPackageSpec = `pnpm@${pnpmVersion}`;
7906
- const devrouterPackageSpec = `@devrouter/cli@${version}`;
7907
- const devrouterTarball = `devrouter-cli-${version}.tgz`;
7908
- return `# ${MANAGED_MARKER}
8426
+ return `# ${MANAGED_MARKER2}
7909
8427
  FROM node:${nodeMajor}-bookworm-slim
7910
8428
 
7911
8429
  RUN apt-get update \\
7912
- && apt-get install -y --no-install-recommends git ca-certificates curl procps openssl tar util-linux \\
8430
+ && apt-get install -y --no-install-recommends git ca-certificates curl procps openssl util-linux \\
7913
8431
  && rm -rf /var/lib/apt/lists/*
7914
8432
 
7915
8433
  RUN npm install -g ${shellSingleQuote(pnpmPackageSpec)}
7916
8434
 
7917
- RUN npm pack --silent ${shellSingleQuote(devrouterPackageSpec)} \\
7918
- && tar -xzf ${shellSingleQuote(devrouterTarball)} --strip-components=2 \\
7919
- -C /usr/local/bin package/bin/devrouter-process \\
7920
- && chmod +x /usr/local/bin/devrouter-process \\
7921
- && rm ${shellSingleQuote(devrouterTarball)}
7922
-
7923
8435
  WORKDIR /workspaces/app
7924
8436
  `;
7925
8437
  }
7926
8438
  function renderCompose(projectName) {
7927
- return `# ${MANAGED_MARKER}
8439
+ return `# ${MANAGED_MARKER2}
7928
8440
  services:
7929
8441
  postgres:
7930
8442
  image: postgres:16-alpine
@@ -7977,12 +8489,12 @@ volumes:
7977
8489
  `;
7978
8490
  }
7979
8491
  function renderDefaultComposeOverlay() {
7980
- return `# ${MANAGED_MARKER}
8492
+ return `# ${MANAGED_MARKER2}
7981
8493
  services: {}
7982
8494
  `;
7983
8495
  }
7984
8496
  function renderDevrouterComposeOverlay() {
7985
- return `# ${MANAGED_MARKER}
8497
+ return `# ${MANAGED_MARKER2}
7986
8498
  # A linked worktree's .git file points at the host repository's common Git
7987
8499
  # directory. workspace ensure sets this absolute path before DevPod starts.
7988
8500
  services:
@@ -7998,7 +8510,7 @@ services:
7998
8510
  }
7999
8511
  function renderInitDb() {
8000
8512
  return `#!/usr/bin/env bash
8001
- # ${MANAGED_MARKER}
8513
+ # ${MANAGED_MARKER2}
8002
8514
  set -euo pipefail
8003
8515
 
8004
8516
  psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<'SQL'
@@ -8017,10 +8529,9 @@ function renderDevcontainerJson(projectName) {
8017
8529
  "service": "app",
8018
8530
  "workspaceFolder": "/workspaces/${projectName}",
8019
8531
  "postCreateCommand": "bash .devcontainer/post-create.sh",
8020
- "postStartCommand": "bash .devcontainer/post-start.sh",
8021
8532
  "customizations": {
8022
8533
  "devrouter": {
8023
- "managed": "${MANAGED_MARKER}"
8534
+ "managed": "${MANAGED_MARKER2}"
8024
8535
  },
8025
8536
  "vscode": {
8026
8537
  "extensions": []
@@ -8030,7 +8541,7 @@ function renderDevcontainerJson(projectName) {
8030
8541
  `;
8031
8542
  }
8032
8543
  function renderEnv(projectName, port) {
8033
- return `# ${MANAGED_MARKER}
8544
+ return `# ${MANAGED_MARKER2}
8034
8545
  WORKSPACE=${projectName}
8035
8546
  HOST=0.0.0.0
8036
8547
  HOSTNAME=0.0.0.0
@@ -8041,7 +8552,7 @@ SHADOW_DATABASE_URL=postgres://prisma:prisma@postgres:5432/shadow
8041
8552
  }
8042
8553
  function renderPostCreate() {
8043
8554
  return `#!/usr/bin/env bash
8044
- # ${MANAGED_MARKER}
8555
+ # ${MANAGED_MARKER2}
8045
8556
  set -euo pipefail
8046
8557
 
8047
8558
  export CI=true
@@ -8057,7 +8568,7 @@ function shellSingleQuote(value) {
8057
8568
  }
8058
8569
  function renderPostStart(devProcess) {
8059
8570
  return `#!/usr/bin/env bash
8060
- # ${MANAGED_MARKER}
8571
+ # ${MANAGED_MARKER2}
8061
8572
  set -euo pipefail
8062
8573
 
8063
8574
  export CI=true
@@ -8066,7 +8577,9 @@ set -a
8066
8577
  . .devcontainer/devcontainer.env
8067
8578
  set +a
8068
8579
 
8069
- devrouter-process ensure \\
8580
+ : "\${DEVROUTER_PROCESS_HELPER:?Run devrouter ensure to start this managed application process.}"
8581
+
8582
+ "$DEVROUTER_PROCESS_HELPER" ensure \\
8070
8583
  --name app \\
8071
8584
  --match ${shellSingleQuote(devProcess.match)} \\
8072
8585
  --log /tmp/devrouter-app.log \\
@@ -8074,7 +8587,7 @@ devrouter-process ensure \\
8074
8587
  `;
8075
8588
  }
8076
8589
  function renderDevrouter(projectName, port, version) {
8077
- return `# ${MANAGED_MARKER}
8590
+ return `# ${MANAGED_MARKER2}
8078
8591
  version: 1
8079
8592
  devrouter:
8080
8593
  version: ${version}
@@ -8096,24 +8609,16 @@ apps:
8096
8609
  `;
8097
8610
  }
8098
8611
  function renderReadme(projectName) {
8099
- return `<!-- ${MANAGED_MARKER} -->
8612
+ return `<!-- ${MANAGED_MARKER2} -->
8100
8613
  # Devcontainer
8101
8614
 
8102
8615
  Use this repo through the devcontainer, with devrouter providing stable local routes.
8103
8616
 
8104
- Primary checkout:
8617
+ Primary or linked checkout:
8105
8618
 
8106
8619
  \`\`\`bash
8107
8620
  devrouter setup --yes
8108
- devpod up .
8109
- devrouter app run app --repo . --yes
8110
- devrouter app run db --repo . --yes
8111
- \`\`\`
8112
-
8113
- Existing linked worktree:
8114
-
8115
- \`\`\`bash
8116
- devrouter workspace ensure .
8621
+ devrouter ensure .
8117
8622
  \`\`\`
8118
8623
 
8119
8624
  - App: https://${projectName}.localhost
@@ -8122,18 +8627,9 @@ devrouter workspace ensure .
8122
8627
  }
8123
8628
  function postWriteNextSteps(repoPath) {
8124
8629
  const quotedRepoPath = shellSingleQuote(repoPath);
8125
- if (isLinkedWorktree(repoPath)) {
8126
- return [
8127
- `Run: devrouter setup --repo ${quotedRepoPath} --yes`,
8128
- `Run: devrouter workspace ensure ${quotedRepoPath}`,
8129
- `Optional: devrouter repo agents --repo ${quotedRepoPath}`
8130
- ];
8131
- }
8132
8630
  return [
8133
8631
  `Run: devrouter setup --repo ${quotedRepoPath} --yes`,
8134
- `Run: cd ${quotedRepoPath} && devpod up .`,
8135
- `Run: devrouter app run app --repo ${quotedRepoPath} --yes`,
8136
- `Run: devrouter app run db --repo ${quotedRepoPath} --yes`,
8632
+ `Run: devrouter ensure ${quotedRepoPath}`,
8137
8633
  `Optional: devrouter repo agents --repo ${quotedRepoPath}`
8138
8634
  ];
8139
8635
  }
@@ -8183,7 +8679,7 @@ function packageManagerIssues(repo) {
8183
8679
  }
8184
8680
  function plannedFiles(repoPath, version) {
8185
8681
  const repo = inspectRepo({ repo: repoPath });
8186
- const projectName = sanitizeProjectName(import_node_path19.default.basename(repo.repoPath));
8682
+ const projectName = sanitizeProjectName(import_node_path22.default.basename(repo.repoPath));
8187
8683
  const nodeMajor = majorVersion(repo.node?.version, "24");
8188
8684
  const pnpmVersion = repo.packageManager?.name === "pnpm" && repo.packageManager.version ? repo.packageManager.version : DEFAULT_PNPM_VERSION;
8189
8685
  const port = inferPort2(repo);
@@ -8195,7 +8691,7 @@ function plannedFiles(repoPath, version) {
8195
8691
  files: [
8196
8692
  {
8197
8693
  relativePath: ".devcontainer/Dockerfile",
8198
- content: renderDockerfile(nodeMajor, pnpmVersion, version)
8694
+ content: renderDockerfile(nodeMajor, pnpmVersion)
8199
8695
  },
8200
8696
  { relativePath: ".devcontainer/docker-compose.yml", content: renderCompose(projectName) },
8201
8697
  {
@@ -8228,8 +8724,8 @@ function plannedFiles(repoPath, version) {
8228
8724
  };
8229
8725
  }
8230
8726
  function classifyFile(repoPath, file) {
8231
- const absolutePath = import_node_path19.default.join(repoPath, file.relativePath);
8232
- if (!import_node_fs19.default.existsSync(absolutePath)) {
8727
+ const absolutePath = import_node_path22.default.join(repoPath, file.relativePath);
8728
+ if (!import_node_fs22.default.existsSync(absolutePath)) {
8233
8729
  return {
8234
8730
  path: file.relativePath,
8235
8731
  action: "create",
@@ -8237,8 +8733,8 @@ function classifyFile(repoPath, file) {
8237
8733
  bytes: Buffer.byteLength(file.content)
8238
8734
  };
8239
8735
  }
8240
- const current = import_node_fs19.default.readFileSync(absolutePath, "utf-8");
8241
- if (!current.includes(MANAGED_MARKER)) {
8736
+ const current = import_node_fs22.default.readFileSync(absolutePath, "utf-8");
8737
+ if (!current.includes(MANAGED_MARKER2)) {
8242
8738
  return {
8243
8739
  path: file.relativePath,
8244
8740
  action: "conflict",
@@ -8294,11 +8790,11 @@ function buildPlan(repoPath, dryRun, version) {
8294
8790
  };
8295
8791
  }
8296
8792
  function writeFile(repoPath, file) {
8297
- const absolutePath = import_node_path19.default.join(repoPath, file.relativePath);
8298
- import_node_fs19.default.mkdirSync(import_node_path19.default.dirname(absolutePath), { recursive: true });
8299
- import_node_fs19.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");
8300
8796
  if (file.executable) {
8301
- import_node_fs19.default.chmodSync(absolutePath, 493);
8797
+ import_node_fs22.default.chmodSync(absolutePath, 493);
8302
8798
  }
8303
8799
  }
8304
8800
  function writeDevcontainer(options = {}) {
@@ -8336,16 +8832,15 @@ function writeDevcontainer(options = {}) {
8336
8832
  nextSteps: postWriteNextSteps(repoPath)
8337
8833
  };
8338
8834
  }
8339
- var import_node_fs19, import_node_path19, MANAGED_MARKER, 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;
8340
8836
  var init_devcontainer_write = __esm({
8341
8837
  "src/core/devcontainer-write.ts"() {
8342
8838
  "use strict";
8343
- import_node_fs19 = __toESM(require("fs"));
8344
- import_node_path19 = __toESM(require("path"));
8839
+ import_node_fs22 = __toESM(require("fs"));
8840
+ import_node_path22 = __toESM(require("path"));
8345
8841
  init_repo_config();
8346
8842
  init_repo_inspect();
8347
- init_workspace();
8348
- MANAGED_MARKER = "devrouter:managed devcontainer";
8843
+ MANAGED_MARKER2 = "devrouter:managed devcontainer";
8349
8844
  DEFAULT_DEVROUTER_VERSION = "0.0.0";
8350
8845
  DEFAULT_PNPM_VERSION = "11.6.0";
8351
8846
  VALID_PACKAGE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
@@ -8733,7 +9228,7 @@ function sanitizeRouterId(value) {
8733
9228
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
8734
9229
  }
8735
9230
  function repoHash(repoPath) {
8736
- return (0, import_node_crypto5.createHash)("sha1").update(import_node_path20.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);
8737
9232
  }
8738
9233
  function asDockerApp(app) {
8739
9234
  return app.runtime === "docker";
@@ -8812,11 +9307,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
8812
9307
  if (dockerApps.length === 0) {
8813
9308
  throw new Error("No docker apps selected to prepare compose overlay.");
8814
9309
  }
8815
- const cachePath = import_node_path20.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
8816
- import_node_fs20.default.mkdirSync(cachePath, { recursive: true });
8817
- const overlayPath = import_node_path20.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");
8818
9313
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
8819
- import_node_fs20.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");
8820
9315
  return {
8821
9316
  overlayPath,
8822
9317
  composeFiles: ensureComposeFiles(dockerApps),
@@ -8830,7 +9325,7 @@ function runDockerComposeUp(repoPath, composeFiles2, overlayPath, services) {
8830
9325
  fileArgs.push("-f", resolved);
8831
9326
  }
8832
9327
  const args = ["compose", ...fileArgs, "-f", overlayPath, "up", "-d", "--wait", ...services];
8833
- const result = (0, import_node_child_process16.spawnSync)("docker", args, {
9328
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8834
9329
  encoding: "utf-8",
8835
9330
  cwd: repoPath
8836
9331
  });
@@ -8858,7 +9353,7 @@ function queryRunningComposeServices(repoPath, composeFiles2, overlayPath, servi
8858
9353
  "--services",
8859
9354
  ...services
8860
9355
  ];
8861
- const result = (0, import_node_child_process16.spawnSync)("docker", args, {
9356
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8862
9357
  encoding: "utf-8",
8863
9358
  cwd: repoPath
8864
9359
  });
@@ -8884,7 +9379,7 @@ function runDockerComposeStop(repoPath, composeFiles2, overlayPath, services) {
8884
9379
  fileArgs.push("-f", resolved);
8885
9380
  }
8886
9381
  const args = ["compose", ...fileArgs, "-f", overlayPath, "stop", ...services];
8887
- const result = (0, import_node_child_process16.spawnSync)("docker", args, {
9382
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8888
9383
  encoding: "utf-8",
8889
9384
  cwd: repoPath
8890
9385
  });
@@ -8910,7 +9405,7 @@ function runDockerComposeLogs(repoPath, composeFiles2, overlayPath, services, ta
8910
9405
  String(tail),
8911
9406
  ...services
8912
9407
  ];
8913
- (0, import_node_child_process16.spawnSync)("docker", args, {
9408
+ (0, import_node_child_process19.spawnSync)("docker", args, {
8914
9409
  stdio: "inherit",
8915
9410
  cwd: repoPath
8916
9411
  });
@@ -8922,7 +9417,7 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
8922
9417
  fileArgs.push("-f", resolved);
8923
9418
  }
8924
9419
  const args = ["compose", ...fileArgs, "-f", overlayPath, "port", service, String(internalPort)];
8925
- const result = (0, import_node_child_process16.spawnSync)("docker", args, {
9420
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8926
9421
  encoding: "utf-8",
8927
9422
  cwd: repoPath
8928
9423
  });
@@ -8936,14 +9431,14 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
8936
9431
  const port = Number(match[1]);
8937
9432
  return Number.isInteger(port) && port > 0 ? port : void 0;
8938
9433
  }
8939
- var import_node_child_process16, import_node_crypto5, import_node_fs20, import_node_path20, import_yaml6;
9434
+ var import_node_child_process19, import_node_crypto6, import_node_fs23, import_node_path23, import_yaml6;
8940
9435
  var init_docker_run = __esm({
8941
9436
  "src/core/docker-run.ts"() {
8942
9437
  "use strict";
8943
- import_node_child_process16 = require("child_process");
8944
- import_node_crypto5 = require("crypto");
8945
- import_node_fs20 = __toESM(require("fs"));
8946
- import_node_path20 = __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"));
8947
9442
  import_yaml6 = __toESM(require("yaml"));
8948
9443
  init_docker_error_guidance();
8949
9444
  init_paths();
@@ -9033,7 +9528,7 @@ function isProcessRunning(pid) {
9033
9528
  }
9034
9529
  }
9035
9530
  function readProcessTree(rootPid) {
9036
- const result = (0, import_node_child_process17.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" });
9037
9532
  if (result.status !== 0) {
9038
9533
  return [rootPid];
9039
9534
  }
@@ -9144,12 +9639,12 @@ function detectListeningPorts(pids) {
9144
9639
  if (pids.length === 0) {
9145
9640
  return [];
9146
9641
  }
9147
- const result = (0, import_node_child_process17.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(",")], {
9148
9643
  encoding: "utf-8"
9149
9644
  });
9150
9645
  if (result.error && result.error.code === "ENOENT") {
9151
9646
  if (process.platform === "linux") {
9152
- const ssResult = (0, import_node_child_process17.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
9647
+ const ssResult = (0, import_node_child_process20.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
9153
9648
  if (ssResult.status === 0 && ssResult.stdout) {
9154
9649
  return parseSsListeningPorts(ssResult.stdout, new Set(pids));
9155
9650
  }
@@ -9208,7 +9703,7 @@ async function runHostApp(repoPath, app, extraEnv = {}, secretManager, env, work
9208
9703
  app.hostRun.command,
9209
9704
  true
9210
9705
  ) : app.hostRun.command;
9211
- const child = (0, import_node_child_process17.spawn)(spawnCommand, {
9706
+ const child = (0, import_node_child_process20.spawn)(spawnCommand, {
9212
9707
  cwd: commandCwd,
9213
9708
  stdio: "inherit",
9214
9709
  shell: true,
@@ -9576,7 +10071,7 @@ async function execWithAppEnv(options) {
9576
10071
  options.command[0],
9577
10072
  true
9578
10073
  );
9579
- child = (0, import_node_child_process17.spawn)(wrapped, {
10074
+ child = (0, import_node_child_process20.spawn)(wrapped, {
9580
10075
  cwd: deps.repoPath,
9581
10076
  stdio: "inherit",
9582
10077
  shell: true,
@@ -9590,7 +10085,7 @@ async function execWithAppEnv(options) {
9590
10085
  false
9591
10086
  );
9592
10087
  const [cmd, ...wrappedArgs] = wrapped;
9593
- child = (0, import_node_child_process17.spawn)(cmd, wrappedArgs, {
10088
+ child = (0, import_node_child_process20.spawn)(cmd, wrappedArgs, {
9594
10089
  cwd: deps.repoPath,
9595
10090
  stdio: "inherit",
9596
10091
  shell: false,
@@ -9598,7 +10093,7 @@ async function execWithAppEnv(options) {
9598
10093
  });
9599
10094
  }
9600
10095
  } else if (options.shell) {
9601
- child = (0, import_node_child_process17.spawn)(options.command[0], {
10096
+ child = (0, import_node_child_process20.spawn)(options.command[0], {
9602
10097
  cwd: deps.repoPath,
9603
10098
  stdio: "inherit",
9604
10099
  shell: true,
@@ -9606,7 +10101,7 @@ async function execWithAppEnv(options) {
9606
10101
  });
9607
10102
  } else {
9608
10103
  const [command, ...args] = options.command;
9609
- child = (0, import_node_child_process17.spawn)(command, args, {
10104
+ child = (0, import_node_child_process20.spawn)(command, args, {
9610
10105
  cwd: deps.repoPath,
9611
10106
  stdio: "inherit",
9612
10107
  shell: false,
@@ -9627,11 +10122,11 @@ async function execWithAppEnv(options) {
9627
10122
  deps.stopDeps();
9628
10123
  }
9629
10124
  }
9630
- var import_node_child_process17, 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;
9631
10126
  var init_app_run = __esm({
9632
10127
  "src/core/app-run.ts"() {
9633
10128
  "use strict";
9634
- import_node_child_process17 = require("child_process");
10129
+ import_node_child_process20 = require("child_process");
9635
10130
  import_node_net = __toESM(require("net"));
9636
10131
  import_node_process = require("process");
9637
10132
  import_promises = require("readline/promises");
@@ -9892,7 +10387,7 @@ var init_version = __esm({
9892
10387
 
9893
10388
  // src/cli.ts
9894
10389
  var import_commander = require("commander");
9895
- var CLI_VERSION = true ? "0.0.33" : "0.0.0-dev";
10390
+ var CLI_VERSION = true ? "0.0.35" : "0.0.0-dev";
9896
10391
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
9897
10392
  function withErrorHandling(action2) {
9898
10393
  return async (...args) => {
@@ -9953,11 +10448,15 @@ program.command("ensure").description("Start and prove a primary or linked check
9953
10448
  });
9954
10449
  })
9955
10450
  );
9956
- 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(
9957
10452
  withErrorHandling(async (repoPath, _options, command) => {
9958
10453
  const options = command.opts();
9959
10454
  const { runStopCommand: runStopCommand2 } = await Promise.resolve().then(() => (init_stop(), stop_exports));
9960
- 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
+ });
9961
10460
  })
9962
10461
  );
9963
10462
  program.command("exec").description("Run one literal command inside the exact checkout's running DevPod").argument("[args...]", "[path] -- <command...>").allowUnknownOption(true).action(