@devrouter/cli 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/devrouter.js CHANGED
@@ -156,6 +156,8 @@ apps:
156
156
  # to target a per-workspace devcontainer alias \u2014 substituted with the resolved
157
157
  # workspace token at runtime. See "Workspace isolation" below. Do NOT put
158
158
  # \${WORKSPACE} in \`host\` (rejected); the host is auto-namespaced.
159
+ # Managed \`ensure\` requires every HTTP/TCP upstream to begin with the exact
160
+ # resolved workspace/project alias prefix before DevPod or route mutation.
159
161
  #
160
162
  # proxy + tcp (front a DB in an externally-managed container, e.g. a
161
163
  # devcontainer's Postgres on devnet) \u2014 no per-DB host port:
@@ -239,11 +241,14 @@ Config-level \`envMap\` on dependency references aliases per-dep vars to app-exp
239
241
  Run several worktrees of one repo in parallel without host/route collisions. A **workspace token** spans the DevPod id, devrouter routes, \`\${WORKSPACE}\` proxy upstreams, and devcontainer aliases.
240
242
 
241
243
  - **Identity**: each managed linked worktree stores a local token in Git metadata plus a durable owner record in the repository's Git common directory. The record survives linked-worktree removal and binds the exact path to its DevPod ID. First use reuses an exact-path DevPod or derives a sanitized branch/path slug. Later flags or \`DEVROUTER_WORKSPACE\` may repeat the identity but cannot rename it. Ambiguous identities fail closed. The primary checkout remains non-namespaced.
242
- - **When active**: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`\${WORKSPACE}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.
244
+ - **When active**: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`\${WORKSPACE}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. Managed \`ensure\` rejects every HTTP/TCP proxy upstream outside that exact alias namespace before it mutates DevPod or routes. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.
243
245
  - **TLS**: namespaced hosts (\`web.<ws>.localhost\`) are not covered by the \`*.localhost\` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.
244
246
  - **devcontainer integration**: managed scaffolds list the base compose file, then \`\${localEnv:DEVCONTAINER_COMPOSE_OVERLAY:docker-compose.default.yml}\`; custom repositories may keep another default overlay. Selecting \`.devcontainer/docker-compose.devrouter.yml\` for linked worktrees must pass \`WORKSPACE\` and \`DEVROUTER_WORKSPACE\` across the combined base/overlay config and bind-mount \`\${DEVROUTER_GIT_COMMON_DIR}\` to the same absolute app-container path. The app exposes \`\${WORKSPACE}-app\`; the proxy uses \`upstream: \${WORKSPACE}-app:<port>\`.
245
- - **Lifecycle**: after one-time \`setup\`, use \`ensure .\` for both primary and linked checkouts; never branch manually on checkout kind or use live verify as startup. \`stop .\` is non-destructive and \`exec . -- <command...>\` runs one-shot commands only in the exact running DevPod. \`workspace up\` creates linked worktrees; destructive \`workspace down/gc\` remains ledger-scoped. Dirty or locked full down fails before side effects.
246
- - **Cleanup**: owner status is \`present\`, \`missing\`, \`locked\`, or \`conflict\`. \`workspace gc\` is a dry-run report; \`--yes\` deletes only exact ledger-owned missing/prunable resources and their records. GC never removes Git worktrees, branches, or prune state. Git has no worktree-removal hook.
247
+ - **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.
250
+ - **Cleanup**: \`workspace cleanup --repo . --inactive-for 30d --json\` is a report-only, no-\`--yes\` report for managed linked workspaces. It joins ownership (\`present|missing|locked|conflict\`), DevPod registration, runtime (\`running|stopped|busy|not-found|absent|unknown\`), checkout, route, advisory activity, and integration evidence without mutating DevPod, routes, ownership, Git, Docker, applications, worktrees, or branches. Local DevPod list/status checks always run; \`--check-merged\` alone enables read-only origin and matching GitHub/GitLab checks. Treat \`not-found\` as stale runtime after Docker pruning; busy, unavailable, or conflicting evidence suppresses destructive suggestions. Explicit \`gc\`/\`down\` can remove exact stale registration only after expected-ID \`NotFound\` proof and ownership revalidation. GC never removes Git worktrees, branches, or prune state. Git has no worktree-removal hook.
251
+ - **Sizing**: \`--measure-size\` adds per-workspace storage consumption to that report and stays read-only, but it walks each worktree and runs \`docker ps\` / \`docker inspect --size\`, so leave it off when you only need the evidence states. Each row reports reclaimable \`worktree\` and \`containerWritable\` bytes, non-reclaimable \`imageShared\` bytes, and a \`reclaimable\` total of the first two. \`imageShared\` covers image layers shared with other containers and overlaps across rows, so never sum it. Attribution is the workspace's own app container; sibling compose services such as a database are excluded. Any untrustworthy figure reports \`unknown\` with a reason rather than zero, and a workspace with no container reports a measured \`0\`.
247
252
  - **Boundary**: workspace commands require Git. Normal config, app, status, and doctor flows remain usable from a \`.devrouter.yml\` folder without \`.git\`.
248
253
 
249
254
  ## Secret manager interop (Infisical/Doppler)
@@ -295,7 +300,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
295
300
  - \`devrouter upgrade [version] [--repo .]\`: list upgrade targets or print target Agent Adaptation Prompt
296
301
  - \`devrouter setup --yes [--repo .] [--json]\`: first-run machine setup plus structured diagnostics
297
302
  - \`devrouter ensure [path] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts
298
- - \`devrouter stop [path] [--json]\`: stop the exact DevPod and remove exact routes without deleting data
303
+ - \`devrouter stop [path] [--delete] [--json]\`: stop the exact DevPod and remove exact routes; \`--delete\` explicitly deletes its ownership-proven data without removing the checkout
299
304
  - \`devrouter exec [path] -- <command...>\`: literal one-shot command inside the exact running DevPod
300
305
  - \`devrouter up\` / \`devrouter down\`: start/stop shared Traefik router
301
306
  - \`devrouter status\`: router/container/network/TLS health
@@ -319,6 +324,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
319
324
  - \`devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open]\`: create a worktree and start/prove it unless create-only mode is requested
320
325
  - \`devrouter workspace ensure [path] [--open] [--json]\`: compatibility alias of \`devrouter ensure\`
321
326
  - \`devrouter workspace ls [--json]\`: list ownership, Git, DevPod, route, path, and branch evidence
327
+ - \`devrouter workspace cleanup [--repo .] [--inactive-for 30d] [--check-merged] [--measure-size] [--json]\`: report-only cleanup evidence and exact guarded suggestions; no \`--yes\` or apply mode
322
328
  - \`devrouter workspace stop <workspace|branch>\`: stop DevPod and routes; preserve checkout, owner record, and data
323
329
  - \`devrouter workspace down <workspace|branch> [--keep-worktree]\`: delete runtime/routes and optionally remove the clean worktree and record
324
330
  - \`devrouter workspace gc [--json] [--yes]\`: report missing owners by default; apply exact eligible cleanup with \`--yes\`
@@ -721,6 +727,51 @@ var init_capabilities = __esm({
721
727
  }
722
728
  });
723
729
 
730
+ // src/core/atomic-file.ts
731
+ function fsyncDirectory(directory) {
732
+ const handle = import_node_fs3.default.openSync(directory, "r");
733
+ try {
734
+ import_node_fs3.default.fsyncSync(handle);
735
+ } finally {
736
+ import_node_fs3.default.closeSync(handle);
737
+ }
738
+ }
739
+ function writeFileAtomically(filePath, contents) {
740
+ const directory = import_node_path3.default.dirname(filePath);
741
+ import_node_fs3.default.mkdirSync(directory, { recursive: true });
742
+ const temporaryPath = import_node_path3.default.join(
743
+ directory,
744
+ `.${import_node_path3.default.basename(filePath)}.${process.pid}.${(0, import_node_crypto.randomUUID)()}.tmp`
745
+ );
746
+ let temporaryHandle;
747
+ try {
748
+ temporaryHandle = import_node_fs3.default.openSync(temporaryPath, "wx", 384);
749
+ import_node_fs3.default.writeFileSync(temporaryHandle, contents, "utf-8");
750
+ import_node_fs3.default.fsyncSync(temporaryHandle);
751
+ import_node_fs3.default.closeSync(temporaryHandle);
752
+ temporaryHandle = void 0;
753
+ import_node_fs3.default.renameSync(temporaryPath, filePath);
754
+ fsyncDirectory(directory);
755
+ } catch (error) {
756
+ if (temporaryHandle !== void 0) import_node_fs3.default.closeSync(temporaryHandle);
757
+ try {
758
+ import_node_fs3.default.unlinkSync(temporaryPath);
759
+ } catch (cleanupError) {
760
+ if (cleanupError.code !== "ENOENT") throw cleanupError;
761
+ }
762
+ throw error;
763
+ }
764
+ }
765
+ var import_node_crypto, import_node_fs3, import_node_path3;
766
+ var init_atomic_file = __esm({
767
+ "src/core/atomic-file.ts"() {
768
+ "use strict";
769
+ import_node_crypto = require("crypto");
770
+ import_node_fs3 = __toESM(require("fs"));
771
+ import_node_path3 = __toESM(require("path"));
772
+ }
773
+ });
774
+
724
775
  // src/core/file-lock.ts
725
776
  function sleepSync(ms) {
726
777
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -732,17 +783,56 @@ function isProcessAlive(pid) {
732
783
  try {
733
784
  process.kill(pid, 0);
734
785
  return true;
786
+ } catch (error) {
787
+ return error.code === "EPERM";
788
+ }
789
+ }
790
+ function processBirthIdentity(pid) {
791
+ try {
792
+ const stat = import_node_fs4.default.readFileSync(`/proc/${pid}/stat`, "utf-8");
793
+ const commandEnd = stat.lastIndexOf(")");
794
+ if (commandEnd >= 0) {
795
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
796
+ const startTime = fields[19];
797
+ if (startTime) return `proc:${startTime}`;
798
+ }
735
799
  } catch {
736
- return false;
800
+ }
801
+ const result = (0, import_node_child_process2.spawnSync)("ps", ["-o", "lstart=", "-o", "command=", "-p", String(pid)], {
802
+ encoding: "utf-8",
803
+ env: { ...process.env, LC_ALL: "C" }
804
+ });
805
+ const startedAt = result.status === 0 ? result.stdout.trim().replace(/\s+/g, " ") : "";
806
+ return startedAt ? `ps:${(0, import_node_crypto2.createHash)("sha256").update(startedAt).digest("hex")}` : void 0;
807
+ }
808
+ function parseLockOwner(value) {
809
+ const fields = value.split(":");
810
+ const pid = Number(fields[0]);
811
+ if (!Number.isInteger(pid) || pid <= 0) return void 0;
812
+ 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])) {
813
+ return { pid };
814
+ }
815
+ try {
816
+ const processBirth = Buffer.from(fields[1], "base64url").toString("utf-8");
817
+ const canonical = Buffer.from(processBirth).toString("base64url");
818
+ return canonical === fields[1] && /^(proc|ps):/.test(processBirth) ? { pid, processBirth } : { pid };
819
+ } catch {
820
+ return { pid };
737
821
  }
738
822
  }
823
+ function isLockOwnerLive(owner) {
824
+ if (!isProcessAlive(owner.pid)) return false;
825
+ if (!owner.processBirth) return true;
826
+ const currentBirth = processBirthIdentity(owner.pid);
827
+ return currentBirth === void 0 || currentBirth === owner.processBirth;
828
+ }
739
829
  function sameFile(left, right) {
740
830
  return left.dev === right.dev && left.ino === right.ino;
741
831
  }
742
832
  function tryReclaimStaleLock(lockPath, staleLinkPath) {
743
833
  let fd;
744
834
  try {
745
- fd = import_node_fs3.default.openSync(lockPath, "r");
835
+ fd = import_node_fs4.default.openSync(lockPath, "r");
746
836
  } catch (error) {
747
837
  if (error.code === "ENOENT") {
748
838
  return { kind: "retry" };
@@ -750,17 +840,16 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
750
840
  throw error;
751
841
  }
752
842
  try {
753
- const owner = import_node_fs3.default.readFileSync(fd, "utf-8").trim();
754
- const ownerPid = Number(owner.split(":", 1)[0]);
755
- if (isProcessAlive(ownerPid)) {
756
- return { kind: "live", pid: ownerPid };
843
+ const owner = parseLockOwner(import_node_fs4.default.readFileSync(fd, "utf-8").trim());
844
+ if (owner && isLockOwnerLive(owner)) {
845
+ return { kind: "live", pid: owner.pid };
757
846
  }
758
- const staleStat = import_node_fs3.default.fstatSync(fd);
847
+ const staleStat = import_node_fs4.default.fstatSync(fd);
759
848
  if (staleStat.nlink !== 1) {
760
849
  return { kind: "retry" };
761
850
  }
762
851
  try {
763
- import_node_fs3.default.linkSync(lockPath, staleLinkPath);
852
+ import_node_fs4.default.linkSync(lockPath, staleLinkPath);
764
853
  } catch (error) {
765
854
  if (error.code === "ENOENT") {
766
855
  return { kind: "retry" };
@@ -770,7 +859,7 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
770
859
  try {
771
860
  let currentStat;
772
861
  try {
773
- currentStat = import_node_fs3.default.statSync(lockPath);
862
+ currentStat = import_node_fs4.default.statSync(lockPath);
774
863
  } catch (error) {
775
864
  if (error.code === "ENOENT") {
776
865
  return { kind: "retry" };
@@ -780,27 +869,32 @@ function tryReclaimStaleLock(lockPath, staleLinkPath) {
780
869
  if (!sameFile(staleStat, currentStat) || currentStat.nlink !== 2) {
781
870
  return { kind: "retry" };
782
871
  }
783
- import_node_fs3.default.rmSync(lockPath);
872
+ import_node_fs4.default.rmSync(lockPath);
784
873
  return { kind: "reclaimed" };
785
874
  } finally {
786
- import_node_fs3.default.rmSync(staleLinkPath, { force: true });
875
+ import_node_fs4.default.rmSync(staleLinkPath, { force: true });
787
876
  }
788
877
  } finally {
789
- import_node_fs3.default.closeSync(fd);
878
+ import_node_fs4.default.closeSync(fd);
790
879
  }
791
880
  }
792
881
  function acquireFileLock(lockPath, options) {
793
- const owner = `${process.pid}:${(0, import_node_crypto.randomUUID)()}`;
794
- const candidatePath = `${lockPath}.${owner}.candidate`;
882
+ const processBirth = processBirthIdentity(process.pid);
883
+ if (!processBirth) {
884
+ throw new Error(`could not determine process identity for ${options.activity} lock`);
885
+ }
886
+ const ownerId = (0, import_node_crypto2.randomUUID)();
887
+ const owner = `${process.pid}:${Buffer.from(processBirth).toString("base64url")}:${ownerId}`;
888
+ const candidatePath = `${lockPath}.${process.pid}.${ownerId}.candidate`;
795
889
  const staleLinkPath = `${candidatePath}.stale`;
796
890
  const deadline = Date.now() + (options.waitMs ?? 0);
797
891
  let reclaimAttempts = 0;
798
- import_node_fs3.default.writeFileSync(candidatePath, `${owner}
892
+ import_node_fs4.default.writeFileSync(candidatePath, `${owner}
799
893
  `, { encoding: "utf-8", flag: "wx" });
800
894
  try {
801
895
  for (; ; ) {
802
896
  try {
803
- import_node_fs3.default.linkSync(candidatePath, lockPath);
897
+ import_node_fs4.default.linkSync(candidatePath, lockPath);
804
898
  return owner;
805
899
  } catch (error) {
806
900
  if (error.code !== "EEXIST") {
@@ -828,14 +922,14 @@ function acquireFileLock(lockPath, options) {
828
922
  }
829
923
  }
830
924
  } finally {
831
- import_node_fs3.default.rmSync(candidatePath, { force: true });
832
- import_node_fs3.default.rmSync(staleLinkPath, { force: true });
925
+ import_node_fs4.default.rmSync(candidatePath, { force: true });
926
+ import_node_fs4.default.rmSync(staleLinkPath, { force: true });
833
927
  }
834
928
  }
835
929
  function releaseFileLock(lockPath, owner) {
836
930
  try {
837
- if (import_node_fs3.default.readFileSync(lockPath, "utf-8").trim() === owner) {
838
- import_node_fs3.default.rmSync(lockPath);
931
+ if (import_node_fs4.default.readFileSync(lockPath, "utf-8").trim() === owner) {
932
+ import_node_fs4.default.rmSync(lockPath);
839
933
  }
840
934
  } catch (error) {
841
935
  if (error.code !== "ENOENT") {
@@ -859,20 +953,21 @@ async function withFileLock(lockPath, options, operation) {
859
953
  releaseFileLock(lockPath, owner);
860
954
  }
861
955
  }
862
- var import_node_crypto, import_node_fs3;
956
+ var import_node_child_process2, import_node_crypto2, import_node_fs4;
863
957
  var init_file_lock = __esm({
864
958
  "src/core/file-lock.ts"() {
865
959
  "use strict";
866
- import_node_crypto = require("crypto");
867
- import_node_fs3 = __toESM(require("fs"));
960
+ import_node_child_process2 = require("child_process");
961
+ import_node_crypto2 = require("crypto");
962
+ import_node_fs4 = __toESM(require("fs"));
868
963
  }
869
964
  });
870
965
 
871
966
  // src/core/workspace.ts
872
967
  function comparableWorkspacePath(filePath) {
873
- const resolved = import_node_path3.default.resolve(filePath);
968
+ const resolved = import_node_path4.default.resolve(filePath);
874
969
  try {
875
- return import_node_fs4.default.realpathSync.native(resolved);
970
+ return import_node_fs5.default.realpathSync.native(resolved);
876
971
  } catch {
877
972
  return resolved;
878
973
  }
@@ -885,10 +980,10 @@ function wsFromBranch(branch) {
885
980
  return slug.length > 0 ? slug : void 0;
886
981
  }
887
982
  function isLinkedWorktree(repoPath) {
888
- const gitPath = import_node_path3.default.join(repoPath, ".git");
983
+ const gitPath = import_node_path4.default.join(repoPath, ".git");
889
984
  let stat;
890
985
  try {
891
- stat = import_node_fs4.default.statSync(gitPath);
986
+ stat = import_node_fs5.default.statSync(gitPath);
892
987
  } catch {
893
988
  return false;
894
989
  }
@@ -897,7 +992,7 @@ function isLinkedWorktree(repoPath) {
897
992
  }
898
993
  let content;
899
994
  try {
900
- content = import_node_fs4.default.readFileSync(gitPath, "utf-8");
995
+ content = import_node_fs5.default.readFileSync(gitPath, "utf-8");
901
996
  } catch {
902
997
  return false;
903
998
  }
@@ -909,10 +1004,10 @@ function isLinkedWorktree(repoPath) {
909
1004
  return /(^|\/)worktrees\//.test(gitdir);
910
1005
  }
911
1006
  function gitMetadataDir(repoPath) {
912
- const gitPath = import_node_path3.default.join(repoPath, ".git");
1007
+ const gitPath = import_node_path4.default.join(repoPath, ".git");
913
1008
  let stat;
914
1009
  try {
915
- stat = import_node_fs4.default.statSync(gitPath);
1010
+ stat = import_node_fs5.default.statSync(gitPath);
916
1011
  } catch {
917
1012
  return void 0;
918
1013
  }
@@ -922,11 +1017,11 @@ function gitMetadataDir(repoPath) {
922
1017
  if (!stat.isFile()) {
923
1018
  return void 0;
924
1019
  }
925
- const match = import_node_fs4.default.readFileSync(gitPath, "utf-8").match(/^gitdir:\s*(.+)$/m);
1020
+ const match = import_node_fs5.default.readFileSync(gitPath, "utf-8").match(/^gitdir:\s*(.+)$/m);
926
1021
  if (!match) {
927
1022
  return void 0;
928
1023
  }
929
- return import_node_path3.default.resolve(repoPath, match[1].trim());
1024
+ return import_node_path4.default.resolve(repoPath, match[1].trim());
930
1025
  }
931
1026
  function validatePersistedWorkspace(value) {
932
1027
  const workspace = value.trim();
@@ -940,9 +1035,9 @@ function readPersistedWorkspace(repoPath) {
940
1035
  if (!gitDir) {
941
1036
  return void 0;
942
1037
  }
943
- const metadataPath = import_node_path3.default.join(gitDir, WORKSPACE_METADATA_FILE);
1038
+ const metadataPath = import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE);
944
1039
  try {
945
- return validatePersistedWorkspace(import_node_fs4.default.readFileSync(metadataPath, "utf-8"));
1040
+ return validatePersistedWorkspace(import_node_fs5.default.readFileSync(metadataPath, "utf-8"));
946
1041
  } catch (error) {
947
1042
  if (error.code === "ENOENT") {
948
1043
  return void 0;
@@ -965,7 +1060,7 @@ function persistWorkspace(repoPath, value) {
965
1060
  if (!gitDir) {
966
1061
  throw new Error(`cannot persist workspace identity: '${repoPath}' is not a Git checkout`);
967
1062
  }
968
- import_node_fs4.default.writeFileSync(import_node_path3.default.join(gitDir, WORKSPACE_METADATA_FILE), `${workspace}
1063
+ import_node_fs5.default.writeFileSync(import_node_path4.default.join(gitDir, WORKSPACE_METADATA_FILE), `${workspace}
969
1064
  `, "utf-8");
970
1065
  return workspace;
971
1066
  }
@@ -974,7 +1069,7 @@ async function withWorkspaceLifecycleLock(repoPath, operation) {
974
1069
  if (!gitDir) {
975
1070
  throw new Error(`cannot lock workspace lifecycle: '${repoPath}' is not a Git checkout`);
976
1071
  }
977
- const lockPath = import_node_path3.default.join(gitDir, WORKSPACE_LOCK_FILE);
1072
+ const lockPath = import_node_path4.default.join(gitDir, WORKSPACE_LOCK_FILE);
978
1073
  return withFileLock(
979
1074
  lockPath,
980
1075
  { activity: "workspace lifecycle", target: `'${repoPath}'` },
@@ -982,7 +1077,7 @@ async function withWorkspaceLifecycleLock(repoPath, operation) {
982
1077
  );
983
1078
  }
984
1079
  function currentBranch(repoPath) {
985
- const result = (0, import_node_child_process2.spawnSync)("git", ["-C", repoPath, "rev-parse", "--abbrev-ref", "HEAD"], {
1080
+ const result = (0, import_node_child_process3.spawnSync)("git", ["-C", repoPath, "rev-parse", "--abbrev-ref", "HEAD"], {
986
1081
  encoding: "utf-8"
987
1082
  });
988
1083
  if (result.status !== 0) {
@@ -1002,7 +1097,7 @@ function deriveLinkedWorktreeWorkspace(repoPath, linkedWorktreeBranch) {
1002
1097
  return void 0;
1003
1098
  }
1004
1099
  const branch = currentBranch(repoPath);
1005
- return wsFromBranch(branch ?? import_node_path3.default.basename(import_node_path3.default.resolve(repoPath)));
1100
+ return wsFromBranch(branch ?? import_node_path4.default.basename(import_node_path4.default.resolve(repoPath)));
1006
1101
  }
1007
1102
  function resolveWorktreeWorkspace(repoPath, linkedWorktreeBranch) {
1008
1103
  return readPersistedWorkspace(repoPath) ?? deriveLinkedWorktreeWorkspace(repoPath, linkedWorktreeBranch);
@@ -1025,13 +1120,13 @@ function resolveWorkspace(repoPath, override) {
1025
1120
  }
1026
1121
  return deriveLinkedWorktreeWorkspace(repoPath);
1027
1122
  }
1028
- var import_node_child_process2, import_node_fs4, import_node_path3, MAX_WORKSPACE_LENGTH, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
1123
+ var import_node_child_process3, import_node_fs5, import_node_path4, MAX_WORKSPACE_LENGTH, WORKSPACE_METADATA_FILE, WORKSPACE_LOCK_FILE;
1029
1124
  var init_workspace = __esm({
1030
1125
  "src/core/workspace.ts"() {
1031
1126
  "use strict";
1032
- import_node_child_process2 = require("child_process");
1033
- import_node_fs4 = __toESM(require("fs"));
1034
- import_node_path3 = __toESM(require("path"));
1127
+ import_node_child_process3 = require("child_process");
1128
+ import_node_fs5 = __toESM(require("fs"));
1129
+ import_node_path4 = __toESM(require("path"));
1035
1130
  init_file_lock();
1036
1131
  MAX_WORKSPACE_LENGTH = 32;
1037
1132
  WORKSPACE_METADATA_FILE = "devrouter-workspace";
@@ -1151,64 +1246,218 @@ function buildHostRoutesDocument(routes, tlsEnabled) {
1151
1246
  }
1152
1247
  return document;
1153
1248
  }
1154
- function writeHostRoutesDynamicFile(routes, tlsEnabled) {
1249
+ function isRecord(value) {
1250
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1251
+ }
1252
+ function validateRouteState(value, source) {
1253
+ if (!Array.isArray(value)) {
1254
+ throw new Error(`${source} must contain a route array.`);
1255
+ }
1256
+ const routes = value.map((item, index) => {
1257
+ if (!isRecord(item)) {
1258
+ throw new Error(`${source} route ${index} must be an object.`);
1259
+ }
1260
+ for (const key of ["id", "name", "host", "repoPath", "createdAt", "updatedAt"]) {
1261
+ if (typeof item[key] !== "string" || item[key].length === 0) {
1262
+ throw new Error(`${source} route ${index} has an invalid '${key}'.`);
1263
+ }
1264
+ }
1265
+ if (!Number.isInteger(item.port) || Number(item.port) < 1 || Number(item.port) > 65535) {
1266
+ throw new Error(`${source} route ${index} has an invalid 'port'.`);
1267
+ }
1268
+ if (item.mode !== "run" && item.mode !== "attach" && item.mode !== "proxy") {
1269
+ throw new Error(`${source} route ${index} has an invalid 'mode'.`);
1270
+ }
1271
+ if (item.protocol !== void 0 && item.protocol !== "http" && item.protocol !== "tcp") {
1272
+ throw new Error(`${source} route ${index} has an invalid 'protocol'.`);
1273
+ }
1274
+ for (const key of ["tcpProtocol", "upstreamHost", "command", "workspace"]) {
1275
+ if (item[key] !== void 0 && typeof item[key] !== "string") {
1276
+ throw new Error(`${source} route ${index} has an invalid '${key}'.`);
1277
+ }
1278
+ }
1279
+ if (item.pid !== void 0 && (!Number.isInteger(item.pid) || Number(item.pid) <= 0)) {
1280
+ throw new Error(`${source} route ${index} has an invalid 'pid'.`);
1281
+ }
1282
+ const route = item;
1283
+ if (route.id !== buildHostRouteId(route.repoPath, route.name)) {
1284
+ throw new Error(`${source} route ${index} has an inconsistent 'id'.`);
1285
+ }
1286
+ return route;
1287
+ });
1288
+ const ids = /* @__PURE__ */ new Set();
1289
+ const hosts = /* @__PURE__ */ new Set();
1290
+ for (const route of routes) {
1291
+ if (ids.has(route.id)) {
1292
+ throw new Error(`${source} contains duplicate route id '${route.id}'.`);
1293
+ }
1294
+ if (hosts.has(route.host)) {
1295
+ throw new Error(`${source} contains duplicate route host '${route.host}'.`);
1296
+ }
1297
+ ids.add(route.id);
1298
+ hosts.add(route.host);
1299
+ }
1300
+ return routes;
1301
+ }
1302
+ function renderCompatibilityState(routes) {
1303
+ return `${JSON.stringify(routes, null, 2)}
1304
+ `;
1305
+ }
1306
+ function renderCanonicalState(routes, tlsEnabled) {
1307
+ const metadata = { version: 1, tlsEnabled, routes };
1308
+ const encoded = Buffer.from(JSON.stringify(metadata), "utf-8").toString("base64url");
1155
1309
  const document = buildHostRoutesDocument(routes, tlsEnabled);
1156
- import_node_fs5.default.writeFileSync(TRAEFIK_HOST_ROUTES_FILE, import_yaml.default.stringify(document, { lineWidth: 0 }), "utf-8");
1310
+ return `${ROUTE_METADATA_PREFIX}${encoded}
1311
+ ${import_yaml.default.stringify(document, { lineWidth: 0 })}`;
1157
1312
  }
1158
- function ensureHostRouteStorage() {
1159
- import_node_fs5.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
1160
- import_node_fs5.default.mkdirSync(TRAEFIK_DYNAMIC_DIR, { recursive: true });
1161
- if (!import_node_fs5.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1162
- import_node_fs5.default.writeFileSync(
1163
- TRAEFIK_HOST_ROUTES_FILE,
1164
- import_yaml.default.stringify({ http: { routers: {}, services: {} } }, { lineWidth: 0 }),
1165
- "utf-8"
1313
+ function writeRouteGeneration(routes, tlsEnabled) {
1314
+ writeFileAtomically(HOST_ROUTES_STATE_FILE, renderCompatibilityState(routes));
1315
+ writeFileAtomically(TRAEFIK_HOST_ROUTES_FILE, renderCanonicalState(routes, tlsEnabled));
1316
+ }
1317
+ function readCompatibilityState() {
1318
+ let raw;
1319
+ try {
1320
+ raw = import_node_fs6.default.readFileSync(HOST_ROUTES_STATE_FILE, "utf-8");
1321
+ } catch (error) {
1322
+ throw new Error(
1323
+ `Could not read the compatibility host-route state: ${error.message}`
1166
1324
  );
1167
1325
  }
1168
- if (!import_node_fs5.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1169
- import_node_fs5.default.writeFileSync(HOST_ROUTES_STATE_FILE, "[]\n", "utf-8");
1326
+ try {
1327
+ return validateRouteState(JSON.parse(raw), "Compatibility host-route state");
1328
+ } catch (error) {
1329
+ throw new Error(`Invalid compatibility host-route state: ${error.message}`);
1330
+ }
1331
+ }
1332
+ function parseCanonicalState(raw) {
1333
+ const firstNewline = raw.indexOf("\n");
1334
+ const firstLine2 = firstNewline === -1 ? raw : raw.slice(0, firstNewline);
1335
+ if (!firstLine2.startsWith(ROUTE_METADATA_PREFIX)) {
1336
+ if (firstLine2.startsWith(ROUTE_METADATA_FAMILY_PREFIX)) {
1337
+ throw new Error(`Unsupported or malformed host-route metadata header '${firstLine2}'.`);
1338
+ }
1339
+ return { kind: "legacy" };
1340
+ }
1341
+ const encoded = firstLine2.slice(ROUTE_METADATA_PREFIX.length);
1342
+ if (!/^[a-zA-Z0-9_-]+$/.test(encoded)) {
1343
+ throw new Error("Host-route metadata header is not valid base64url.");
1344
+ }
1345
+ let metadataValue;
1346
+ try {
1347
+ const decoded = Buffer.from(encoded, "base64url");
1348
+ if (decoded.toString("base64url") !== encoded) {
1349
+ throw new Error("non-canonical base64url encoding");
1350
+ }
1351
+ metadataValue = JSON.parse(decoded.toString("utf-8"));
1352
+ } catch (error) {
1353
+ throw new Error(`Host-route metadata header is invalid: ${error.message}`);
1354
+ }
1355
+ if (!isRecord(metadataValue) || metadataValue.version !== 1) {
1356
+ throw new Error("Host-route metadata header has an unsupported version.");
1357
+ }
1358
+ if (typeof metadataValue.tlsEnabled !== "boolean") {
1359
+ throw new Error("Host-route metadata header has an invalid TLS state.");
1360
+ }
1361
+ const routes = validateRouteState(metadataValue.routes, "Canonical host-route metadata");
1362
+ const metadata = {
1363
+ version: 1,
1364
+ tlsEnabled: metadataValue.tlsEnabled,
1365
+ routes
1366
+ };
1367
+ let document;
1368
+ try {
1369
+ document = import_yaml.default.parse(firstNewline === -1 ? "" : raw.slice(firstNewline + 1));
1370
+ } catch (error) {
1371
+ throw new Error(`Canonical host-route document is invalid: ${error.message}`);
1372
+ }
1373
+ const expectedDocument = buildHostRoutesDocument(routes, metadata.tlsEnabled);
1374
+ if (!(0, import_node_util.isDeepStrictEqual)(document, expectedDocument)) {
1375
+ throw new Error("Canonical host-route document does not match its metadata.");
1170
1376
  }
1377
+ return { kind: "canonical", metadata };
1378
+ }
1379
+ function ensureHostRouteStorage() {
1380
+ import_node_fs6.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
1381
+ import_node_fs6.default.mkdirSync(TRAEFIK_DYNAMIC_DIR, { recursive: true });
1171
1382
  }
1172
1383
  function writeState(routes) {
1173
1384
  ensureHostRouteStorage();
1174
- import_node_fs5.default.writeFileSync(HOST_ROUTES_STATE_FILE, `${JSON.stringify(routes, null, 2)}
1175
- `, "utf-8");
1176
- writeHostRoutesDynamicFile(routes, isTLSEnabled());
1385
+ writeRouteGeneration(routes, isTLSEnabled());
1177
1386
  }
1178
1387
  function withStateLock(fn) {
1179
1388
  ensureHostRouteStorage();
1180
1389
  return withFileLockSync(STATE_LOCK_FILE, { activity: "host route update", waitMs: 5e3 }, fn);
1181
1390
  }
1182
1391
  function refreshHostRoutesDynamicFile() {
1183
- const routes = listHostRouteState();
1184
- writeHostRoutesDynamicFile(routes, isTLSEnabled());
1392
+ withStateLock(() => {
1393
+ const routes = readHostRouteStateLocked();
1394
+ writeState(routes);
1395
+ });
1185
1396
  }
1186
- function listHostRouteState() {
1397
+ function readHostRouteStateLocked() {
1187
1398
  ensureHostRouteStorage();
1188
- if (!import_node_fs5.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1399
+ if (!import_node_fs6.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1400
+ if (import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1401
+ const routes = readCompatibilityState();
1402
+ writeRouteGeneration(routes, isTLSEnabled());
1403
+ return routes;
1404
+ }
1405
+ writeRouteGeneration([], isTLSEnabled());
1189
1406
  return [];
1190
1407
  }
1191
- try {
1192
- const raw = import_node_fs5.default.readFileSync(HOST_ROUTES_STATE_FILE, "utf-8");
1193
- const parsed = JSON.parse(raw);
1194
- if (!Array.isArray(parsed)) {
1408
+ const raw = import_node_fs6.default.readFileSync(TRAEFIK_HOST_ROUTES_FILE, "utf-8");
1409
+ const canonical = parseCanonicalState(raw);
1410
+ if (canonical.kind === "legacy") {
1411
+ if (!import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1412
+ throw new Error(
1413
+ "Headerless host-route document requires a valid compatibility state file for migration."
1414
+ );
1415
+ }
1416
+ const routes = readCompatibilityState();
1417
+ writeRouteGeneration(routes, isTLSEnabled());
1418
+ return routes;
1419
+ }
1420
+ let mirrorMatches = false;
1421
+ if (import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1422
+ try {
1423
+ mirrorMatches = (0, import_node_util.isDeepStrictEqual)(readCompatibilityState(), canonical.metadata.routes);
1424
+ } catch {
1425
+ mirrorMatches = false;
1426
+ }
1427
+ }
1428
+ if (!mirrorMatches) {
1429
+ writeFileAtomically(
1430
+ HOST_ROUTES_STATE_FILE,
1431
+ renderCompatibilityState(canonical.metadata.routes)
1432
+ );
1433
+ }
1434
+ return canonical.metadata.routes;
1435
+ }
1436
+ function readHostRouteStateReadOnly() {
1437
+ if (!import_node_fs6.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1438
+ if (!import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1195
1439
  return [];
1196
1440
  }
1197
- return parsed.filter((item) => item && typeof item === "object").map((item) => item);
1198
- } catch (err) {
1199
- const error = err;
1200
- if (error.code !== "ENOENT") {
1201
- process.stderr.write(
1202
- `Warning: devrouter host routes state file is corrupted or unreadable (${error.message}). Recreating route state.
1203
- `
1441
+ return readCompatibilityState();
1442
+ }
1443
+ const raw = import_node_fs6.default.readFileSync(TRAEFIK_HOST_ROUTES_FILE, "utf-8");
1444
+ const canonical = parseCanonicalState(raw);
1445
+ if (canonical.kind === "legacy") {
1446
+ if (!import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1447
+ throw new Error(
1448
+ "Headerless host-route document requires a valid compatibility state file for inspection."
1204
1449
  );
1205
1450
  }
1206
- return [];
1451
+ return readCompatibilityState();
1207
1452
  }
1453
+ return canonical.metadata.routes;
1454
+ }
1455
+ function listHostRouteState() {
1456
+ return withStateLock(readHostRouteStateLocked);
1208
1457
  }
1209
1458
  function upsertHostRoute(input2) {
1210
1459
  return withStateLock(() => {
1211
- const routes = listHostRouteState();
1460
+ const routes = readHostRouteStateLocked();
1212
1461
  const id = buildHostRouteId(input2.repoPath, input2.name);
1213
1462
  const existing = routes.find((route) => route.id === id);
1214
1463
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1225,7 +1474,7 @@ function replaceHostRoutesForRepo(repoPath, inputs) {
1225
1474
  if (inputs.some((input2) => !sameWorkspacePath(input2.repoPath, repoPath))) {
1226
1475
  throw new Error(`Route replacement contains an entry outside '${repoPath}'.`);
1227
1476
  }
1228
- const routes = listHostRouteState();
1477
+ const routes = readHostRouteStateLocked();
1229
1478
  const remaining = routes.filter((route) => !sameWorkspacePath(route.repoPath, repoPath));
1230
1479
  const names = /* @__PURE__ */ new Set();
1231
1480
  const hosts = /* @__PURE__ */ new Set();
@@ -1263,7 +1512,7 @@ function replaceHostRoutesForRepo(repoPath, inputs) {
1263
1512
  }
1264
1513
  function removeHostRouteById(id) {
1265
1514
  return withStateLock(() => {
1266
- const routes = listHostRouteState();
1515
+ const routes = readHostRouteStateLocked();
1267
1516
  const next = routes.filter((route) => route.id !== id);
1268
1517
  if (next.length === routes.length) {
1269
1518
  return false;
@@ -1274,7 +1523,7 @@ function removeHostRouteById(id) {
1274
1523
  }
1275
1524
  function removeHostRoutesWhere(predicate) {
1276
1525
  return withStateLock(() => {
1277
- const routes = listHostRouteState();
1526
+ const routes = readHostRouteStateLocked();
1278
1527
  const removed = routes.filter(predicate);
1279
1528
  if (removed.length === 0) {
1280
1529
  return [];
@@ -1296,7 +1545,7 @@ function listHostRoutes(tlsEnabled) {
1296
1545
  protocol: isTcp ? `tcp/${tcpProtocol}` : "http",
1297
1546
  appName: route.name,
1298
1547
  serviceName: route.name,
1299
- projectName: import_node_path4.default.basename(route.repoPath),
1548
+ projectName: import_node_path5.default.basename(route.repoPath),
1300
1549
  hosts: [route.host],
1301
1550
  // TCP routes are reached via an SNI-aware client on the shared protocol
1302
1551
  // port; surface a protocol-scheme URL rather than http(s)://.
@@ -1311,13 +1560,15 @@ function listHostRoutes(tlsEnabled) {
1311
1560
  };
1312
1561
  });
1313
1562
  }
1314
- var import_node_fs5, import_node_path4, import_yaml, LOOPBACK_HOSTS, UPSTREAM_RE, TCP_TLS_ALPN_PROTOCOLS, STATE_LOCK_FILE;
1563
+ var import_node_fs6, import_node_path5, import_node_util, import_yaml, LOOPBACK_HOSTS, UPSTREAM_RE, TCP_TLS_ALPN_PROTOCOLS, ROUTE_METADATA_PREFIX, ROUTE_METADATA_FAMILY_PREFIX, STATE_LOCK_FILE;
1315
1564
  var init_host_routes = __esm({
1316
1565
  "src/core/host-routes.ts"() {
1317
1566
  "use strict";
1318
- import_node_fs5 = __toESM(require("fs"));
1319
- import_node_path4 = __toESM(require("path"));
1567
+ import_node_fs6 = __toESM(require("fs"));
1568
+ import_node_path5 = __toESM(require("path"));
1569
+ import_node_util = require("util");
1320
1570
  import_yaml = __toESM(require("yaml"));
1571
+ init_atomic_file();
1321
1572
  init_file_lock();
1322
1573
  init_router();
1323
1574
  init_workspace();
@@ -1326,6 +1577,8 @@ var init_host_routes = __esm({
1326
1577
  TCP_TLS_ALPN_PROTOCOLS = {
1327
1578
  postgres: ["postgresql"]
1328
1579
  };
1580
+ ROUTE_METADATA_PREFIX = "# devrouter-routes-v1: ";
1581
+ ROUTE_METADATA_FAMILY_PREFIX = "# devrouter-routes-";
1329
1582
  STATE_LOCK_FILE = `${HOST_ROUTES_STATE_FILE}.lock`;
1330
1583
  }
1331
1584
  });
@@ -1753,10 +2006,10 @@ function renderConfig(config) {
1753
2006
  return import_yaml2.default.stringify(config, { lineWidth: 0 });
1754
2007
  }
1755
2008
  function readConfigDocument(configPath) {
1756
- return (0, import_yaml2.parseDocument)(import_node_fs6.default.readFileSync(configPath, "utf-8"));
2009
+ return (0, import_yaml2.parseDocument)(import_node_fs7.default.readFileSync(configPath, "utf-8"));
1757
2010
  }
1758
2011
  function writeConfigDocument(configPath, doc) {
1759
- import_node_fs6.default.writeFileSync(configPath, doc.toString({ lineWidth: 0 }), "utf-8");
2012
+ import_node_fs7.default.writeFileSync(configPath, doc.toString({ lineWidth: 0 }), "utf-8");
1760
2013
  }
1761
2014
  function getOrCreateAppsSeq(doc) {
1762
2015
  const apps = doc.get("apps", true);
@@ -1778,25 +2031,25 @@ function appToNodeValue(app) {
1778
2031
  return value;
1779
2032
  }
1780
2033
  function resolveRepoPath(repoPath) {
1781
- return import_node_path5.default.resolve(repoPath ?? process.cwd());
2034
+ return import_node_path6.default.resolve(repoPath ?? process.cwd());
1782
2035
  }
1783
2036
  function getRepoConfigPath(repoPath) {
1784
- return import_node_path5.default.join(resolveRepoPath(repoPath), CONFIG_FILE_NAME);
2037
+ return import_node_path6.default.join(resolveRepoPath(repoPath), CONFIG_FILE_NAME);
1785
2038
  }
1786
2039
  function loadRepoConfig(repoPath) {
1787
2040
  const resolvedRepoPath = resolveRepoPath(repoPath);
1788
2041
  const configPath = getRepoConfigPath(resolvedRepoPath);
1789
- if (!import_node_fs6.default.existsSync(configPath)) {
2042
+ if (!import_node_fs7.default.existsSync(configPath)) {
1790
2043
  throw new Error(
1791
2044
  `Missing ${CONFIG_FILE_NAME} in ${resolvedRepoPath}. Run 'dev repo init --repo ${resolvedRepoPath}' first.`
1792
2045
  );
1793
2046
  }
1794
- const raw = import_node_fs6.default.readFileSync(configPath, "utf-8");
2047
+ const raw = import_node_fs7.default.readFileSync(configPath, "utf-8");
1795
2048
  const parsed = import_yaml2.default.parse(raw);
1796
2049
  const config = parseConfig(parsed ?? {}, configPath);
1797
2050
  const requiredVersion = config.devrouter?.version;
1798
2051
  if (requiredVersion && !hasWarnedVersionMismatch) {
1799
- const cliVersion = true ? "0.0.34" : "0.0.0-dev";
2052
+ const cliVersion = true ? "0.0.36" : "0.0.0-dev";
1800
2053
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
1801
2054
  hasWarnedVersionMismatch = true;
1802
2055
  process.stderr.write(
@@ -1813,7 +2066,7 @@ function loadRepoConfig(repoPath) {
1813
2066
  function initRepoConfig(repoPath, options = {}) {
1814
2067
  const resolvedRepoPath = resolveRepoPath(repoPath);
1815
2068
  const configPath = getRepoConfigPath(resolvedRepoPath);
1816
- if (import_node_fs6.default.existsSync(configPath)) {
2069
+ if (import_node_fs7.default.existsSync(configPath)) {
1817
2070
  return { repoPath: resolvedRepoPath, configPath, created: false };
1818
2071
  }
1819
2072
  if (options.devrouterVersion !== void 0 && !DEVROUTER_VERSION_RE.test(options.devrouterVersion)) {
@@ -1829,11 +2082,11 @@ function initRepoConfig(repoPath, options = {}) {
1829
2082
  }
1830
2083
  } : {},
1831
2084
  project: {
1832
- name: import_node_path5.default.basename(resolvedRepoPath)
2085
+ name: import_node_path6.default.basename(resolvedRepoPath)
1833
2086
  },
1834
2087
  apps: []
1835
2088
  };
1836
- import_node_fs6.default.writeFileSync(configPath, renderConfig(initialConfig), "utf-8");
2089
+ import_node_fs7.default.writeFileSync(configPath, renderConfig(initialConfig), "utf-8");
1837
2090
  return { repoPath: resolvedRepoPath, configPath, created: true };
1838
2091
  }
1839
2092
  function buildAppFromOptions(options) {
@@ -2058,7 +2311,7 @@ function namespaceHost(host, workspace) {
2058
2311
  return `${base}.${workspace}${suffix}`;
2059
2312
  }
2060
2313
  function applyWorkspace(config, workspace, repoPath) {
2061
- const defaultToken = wsFromBranch(config.project?.name ?? import_node_path5.default.basename(import_node_path5.default.resolve(repoPath))) ?? "app";
2314
+ const defaultToken = wsFromBranch(config.project?.name ?? import_node_path6.default.basename(import_node_path6.default.resolve(repoPath))) ?? "app";
2062
2315
  const substitutionToken = workspace ?? defaultToken;
2063
2316
  const next = structuredClone(config);
2064
2317
  for (const app of next.apps) {
@@ -2133,12 +2386,12 @@ function resolveAppDependencies(config, app) {
2133
2386
  }
2134
2387
  return results;
2135
2388
  }
2136
- var import_node_fs6, import_node_path5, import_yaml2, hasWarnedVersionMismatch, CONFIG_FILE_NAME, DEFAULT_TCP_PROTOCOL, VALID_HOSTNAME_RE, DEVROUTER_VERSION_RE, VALID_ENV_NAME_RE, VALID_ENV_VAR_RE, UPSTREAM_TEMPLATE_RE, MAX_COMMAND_LENGTH, DEFAULT_HOST_STRATEGY;
2389
+ var import_node_fs7, import_node_path6, import_yaml2, hasWarnedVersionMismatch, CONFIG_FILE_NAME, DEFAULT_TCP_PROTOCOL, VALID_HOSTNAME_RE, DEVROUTER_VERSION_RE, VALID_ENV_NAME_RE, VALID_ENV_VAR_RE, UPSTREAM_TEMPLATE_RE, MAX_COMMAND_LENGTH, DEFAULT_HOST_STRATEGY;
2137
2390
  var init_repo_config = __esm({
2138
2391
  "src/core/repo-config.ts"() {
2139
2392
  "use strict";
2140
- import_node_fs6 = __toESM(require("fs"));
2141
- import_node_path5 = __toESM(require("path"));
2393
+ import_node_fs7 = __toESM(require("fs"));
2394
+ import_node_path6 = __toESM(require("path"));
2142
2395
  import_yaml2 = __toESM(require("yaml"));
2143
2396
  init_capabilities();
2144
2397
  init_host_routes();
@@ -2205,6 +2458,7 @@ function buildOnboardingPrompt(options = {}) {
2205
2458
  "- Shared Traefik router owns host ports 80 (HTTP), 443 (HTTPS), and 5432 (Postgres TCP).",
2206
2459
  "- Per-repo source of truth is REPO_PATH/.devrouter.yml only.",
2207
2460
  "- Global generated/runtime artifacts are managed under ~/.config/devrouter (do not edit these manually).",
2461
+ "- Managed DevPod lifecycle uses devrouter ensure/stop/workspace commands; raw devpod mutations bypass exact checkout ownership checks.",
2208
2462
  "",
2209
2463
  "Inputs:",
2210
2464
  `- REPO_PATH=${repoPath}`,
@@ -2248,6 +2502,7 @@ function buildOnboardingPrompt(options = {}) {
2248
2502
  "- if kind=app and runtime=proxy:",
2249
2503
  ' - upstream: "host:port" (an already-running port, e.g. a devcontainer published on 127.0.0.1:3000, or a container reachable by name on a shared Docker network such as `derivatives-db:5432`)',
2250
2504
  ` - 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.`,
2505
+ " - managed `devrouter ensure` requires every HTTP/TCP proxy upstream to begin with the exact resolved workspace/project alias prefix before it mutates DevPod or routes",
2251
2506
  " - protocol=http registers an HTTP route; protocol=tcp registers a TLS-SNI TCP route and additionally requires tcpProtocol",
2252
2507
  " - do not set hostRun/docker/dependencies (proxy only registers a route to the upstream)",
2253
2508
  " - loopback hosts (localhost/127.0.0.1/0.0.0.0) are rewritten to host.docker.internal for Traefik",
@@ -2298,10 +2553,13 @@ function buildOnboardingPrompt(options = {}) {
2298
2553
  "Workspace isolation (parallel git worktrees / agents):",
2299
2554
  `- A "workspace token" lets several worktrees of one repo run in parallel without host/route collisions. Each managed linked worktree has a local Git token plus a durable owner record in the repository's Git common directory spanning the DevPod id, devrouter routes, the \`${WORKSPACE_PLACEHOLDER}\` proxy upstream, and devcontainer aliases.`,
2300
2555
  "- The owner record survives linked-worktree removal and binds the exact path to its DevPod ID. First use reuses an exact-path DevPod or derives a sanitized branch/path identity. Later flags or `DEVROUTER_WORKSPACE` may repeat but cannot rename it. Ambiguous identities fail closed. The primary checkout stays non-namespaced.",
2301
- `- When a workspace is active: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`${WORKSPACE_PLACEHOLDER}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.`,
2556
+ `- When a workspace is active: hosts auto-namespace (\`web.localhost\` \u2192 \`web.<ws>.localhost\`), \`${WORKSPACE_PLACEHOLDER}\` in \`upstream\` is substituted with the token, and the docker \`router\` key is suffixed per workspace. Managed ensure rejects every HTTP/TCP upstream outside that exact alias namespace before mutation. The runtime config is computed in memory only \u2014 the committed \`.devrouter.yml\` is never rewritten.`,
2302
2557
  "- TLS: namespaced hosts (`web.<ws>.localhost`) are not covered by the `*.localhost` wildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled.",
2303
- "- Lifecycle: after one-time setup, use `devrouter ensure .` for both primary and linked checkouts; never branch on checkout kind or use live verify as startup. Managed consumer images contain no devrouter package/helper: ensure delivers its matching helper at runtime and invokes the repository-owned post-start adapter. Keep `.devrouter.yml` as the only consumer-side version pin. Use `devrouter stop .` for a non-destructive pause and `devrouter exec . -- <command...>` for container commands. `workspace up` creates linked worktrees; destructive down/GC remains ledger-scoped.",
2558
+ "- 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.",
2559
+ "- Managed process reuse fingerprints command argv, workspace identity, and the exact adapter snapshot. If a non-secret runtime value also affects reuse, set `DEVROUTER_PROCESS_FINGERPRINT_ENV` to its comma-separated environment names; secret-like names are rejected and raw values are never persisted.",
2304
2560
  "- 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.",
2561
+ "- `devrouter workspace cleanup --repo . --inactive-for 30d --check-merged --json` is report-only and never mutates DevPod, routes, ownership, Git, Docker, applications, worktrees, or branches. It reports ownership, DevPod registration, runtime (`running|stopped|busy|not-found|absent|unknown`), checkout, advisory activity, route, and integration evidence. Local DevPod list/status checks always run; `--check-merged` alone enables read-only origin and matching GitHub/GitLab checks. Treat `not-found` as stale runtime after Docker pruning; busy, unavailable, or conflicting evidence suppresses destructive suggestions. Explicit `gc`/`down` can remove exact stale registration only after expected-ID `NotFound` proof and ownership revalidation. DevPod `lastUsed` remains advisory.",
2562
+ '- `--measure-size` adds per-workspace storage consumption and stays read-only, but walks each worktree and runs `docker ps` / `docker inspect --size`, so leave it off for a quick report. Each row reports `worktree` and `containerWritable` bytes, which are reclaimable; `imageShared` bytes, which are not, because those image layers are shared with other containers and overlap across rows; and `reclaimable`, the sum of the reclaimable fields. Never add `imageShared` across rows. Any figure that cannot be trusted reports `{"status": "unknown", "reason": ...}` rather than zero, and a workspace with no container reports a measured `0`.',
2305
2563
  "- Workspace commands require Git. Normal config, app, status, and doctor flows work from a `.devrouter.yml` folder without `.git`. Git has no worktree-removal hook; use `workspace ls`, doctor, or dry-run GC after out-of-band removal.",
2306
2564
  "- 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.",
2307
2565
  "",
@@ -2465,8 +2723,8 @@ var init_ai_prompt = __esm({
2465
2723
  purpose: "Canonical startup and proof for an exact primary or linked checkout; atomically publishes routes after readiness."
2466
2724
  },
2467
2725
  {
2468
- command: "devrouter stop [path] [--json]",
2469
- purpose: "Stop the exact checkout DevPod and routes without deleting checkout data."
2726
+ command: "devrouter stop [path] [--delete] [--json]",
2727
+ purpose: "Stop the exact checkout DevPod and routes; --delete explicitly deletes the ownership-proven DevPod without removing the checkout."
2470
2728
  },
2471
2729
  {
2472
2730
  command: "devrouter exec [path] -- <command...>",
@@ -2484,6 +2742,10 @@ var init_ai_prompt = __esm({
2484
2742
  command: "devrouter workspace ls [--json]",
2485
2743
  purpose: "List owner, Git, DevPod, route, path, and branch evidence for managed workspaces."
2486
2744
  },
2745
+ {
2746
+ command: "devrouter workspace cleanup [--repo <path>] [--inactive-for 30d] [--check-merged] [--measure-size] [--json]",
2747
+ purpose: "Report-only evidence for managed linked workspaces; activity is advisory, --check-merged alone enables read-only origin/forge checks, and no --yes/apply path exists."
2748
+ },
2487
2749
  {
2488
2750
  command: "devrouter workspace stop <workspace|branch>",
2489
2751
  purpose: "Stop the exact DevPod and remove exact routes while preserving the worktree, owner record, and data."
@@ -2567,6 +2829,90 @@ function printJSON(value) {
2567
2829
  process.stdout.write(`${JSON.stringify(value, null, 2)}
2568
2830
  `);
2569
2831
  }
2832
+ function printWorkspaceCleanupReport(report) {
2833
+ process.stdout.write("Workspace cleanup report (read-only)\n");
2834
+ process.stdout.write(
2835
+ `${renderTable(
2836
+ ["FIELD", "VALUE"],
2837
+ [
2838
+ ["Repo path", report.repoPath],
2839
+ ["Generated", report.generatedAt],
2840
+ ["Inactive threshold", report.inactiveFor],
2841
+ ["Cutoff", report.cutoff],
2842
+ ["Merged check", report.checkMerged ? "enabled" : "disabled"],
2843
+ ["Size measurement", report.measureSize ? "enabled" : "disabled"],
2844
+ ["Managed workspaces", String(report.workspaces.length)]
2845
+ ]
2846
+ )}
2847
+ `
2848
+ );
2849
+ if (report.workspaces.length === 0) {
2850
+ process.stdout.write("\nNo managed linked workspaces found.\n");
2851
+ return;
2852
+ }
2853
+ for (const row of report.workspaces) {
2854
+ process.stdout.write(`
2855
+ Workspace: ${row.workspace}
2856
+ `);
2857
+ process.stdout.write(
2858
+ `${renderTable(
2859
+ ["FIELD", "VALUE"],
2860
+ [
2861
+ ["Branch", row.branch ?? "-"],
2862
+ ["Repo", row.repo],
2863
+ ["Worktree", row.worktreePath],
2864
+ ["DevPod", `${row.devpodId} (${row.provider})`],
2865
+ ["Runtime", row.runtime],
2866
+ ["Ownership", row.ownership],
2867
+ ["Checkout", row.checkout],
2868
+ ["Route", row.route],
2869
+ ["Activity", row.activity],
2870
+ ["Cutoff", row.cutoff],
2871
+ ["Latest activity", row.latestTimestamp ?? "-"],
2872
+ ["Contributing evidence", row.contributingEvidence.join(", ") || "-"],
2873
+ [
2874
+ "Activity evidence",
2875
+ row.activityEvidence.map(
2876
+ (entry) => `${entry.source}=${entry.status}${entry.timestamp ? `@${entry.timestamp}` : ""}`
2877
+ ).join(", ")
2878
+ ],
2879
+ ["Integration", row.integration],
2880
+ ["Eligible actions", row.eligibleActions.join("; ") || "none"],
2881
+ [
2882
+ "Suggestions",
2883
+ row.suggestions.map((suggestion) => `${suggestion.command} (${suggestion.reason})`).join("; ") || "none"
2884
+ ],
2885
+ ["Reasons", row.reasons.join(" | ") || "none"],
2886
+ ...row.consumption ? [
2887
+ ["Reclaimable total", formatCleanupSize(row.consumption.reclaimable)],
2888
+ [" worktree files", formatCleanupSize(row.consumption.worktree)],
2889
+ [" container writable", formatCleanupSize(row.consumption.containerWritable)],
2890
+ [
2891
+ "Shared image layers",
2892
+ // The qualifier describes a size, so it is dropped when there
2893
+ // is no size to qualify: "unknown (reason) (not reclaimable)"
2894
+ // reads as two competing answers to the same question.
2895
+ row.consumption.imageShared.status === "measured" ? `${formatCleanupSize(row.consumption.imageShared)} (not reclaimable)` : formatCleanupSize(row.consumption.imageShared)
2896
+ ]
2897
+ ] : []
2898
+ ]
2899
+ )}
2900
+ `
2901
+ );
2902
+ }
2903
+ }
2904
+ function formatCleanupSize(size) {
2905
+ if (size.status === "unknown") {
2906
+ return `unknown (${size.reason})`;
2907
+ }
2908
+ let value = size.bytes;
2909
+ let unit = 0;
2910
+ while (value >= 1024 && unit < BINARY_SIZE_UNITS.length - 1) {
2911
+ value /= 1024;
2912
+ unit += 1;
2913
+ }
2914
+ return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${BINARY_SIZE_UNITS[unit]}`;
2915
+ }
2570
2916
  function printStatus(status) {
2571
2917
  const rows = [
2572
2918
  ["Docker context", status.dockerContext],
@@ -2770,11 +3116,13 @@ function printSetupReport(report) {
2770
3116
  }
2771
3117
  }
2772
3118
  }
3119
+ var BINARY_SIZE_UNITS;
2773
3120
  var init_output = __esm({
2774
3121
  "src/core/output.ts"() {
2775
3122
  "use strict";
2776
3123
  init_table();
2777
3124
  init_timeago();
3125
+ BINARY_SIZE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"];
2778
3126
  }
2779
3127
  });
2780
3128
 
@@ -2883,11 +3231,11 @@ function extractCurrentVersionFromRepoConfig(repoPath) {
2883
3231
  };
2884
3232
  }
2885
3233
  function readPromptDirectory(promptDirPath) {
2886
- if (!import_node_fs7.default.existsSync(promptDirPath)) {
3234
+ if (!import_node_fs8.default.existsSync(promptDirPath)) {
2887
3235
  throw new Error(`Missing ${UPGRADE_PROMPTS_DIR} directory at ${promptDirPath}.`);
2888
3236
  }
2889
3237
  const releases = [];
2890
- const files = import_node_fs7.default.readdirSync(promptDirPath, { withFileTypes: true });
3238
+ const files = import_node_fs8.default.readdirSync(promptDirPath, { withFileTypes: true });
2891
3239
  for (const file of files) {
2892
3240
  if (!file.isFile() || !file.name.endsWith(".md")) {
2893
3241
  continue;
@@ -2897,8 +3245,8 @@ function readPromptDirectory(promptDirPath) {
2897
3245
  continue;
2898
3246
  }
2899
3247
  const version = normalizeVersion(basename);
2900
- const promptPath = import_node_path6.default.join(promptDirPath, file.name);
2901
- const prompt = import_node_fs7.default.readFileSync(promptPath, "utf-8").trim();
3248
+ const promptPath = import_node_path7.default.join(promptDirPath, file.name);
3249
+ const prompt = import_node_fs8.default.readFileSync(promptPath, "utf-8").trim();
2902
3250
  releases.push({ version, prompt, promptPath });
2903
3251
  }
2904
3252
  const deduped = /* @__PURE__ */ new Map();
@@ -2919,19 +3267,19 @@ function listAvailableUpgradeTargets(currentVersion, releases) {
2919
3267
  }
2920
3268
  function resolvePromptDirectory(explicitPath) {
2921
3269
  if (explicitPath) {
2922
- return import_node_path6.default.resolve(explicitPath);
3270
+ return import_node_path7.default.resolve(explicitPath);
2923
3271
  }
2924
- const entryFile = process.argv[1] ? import_node_path6.default.resolve(process.argv[1]) : __filename;
2925
- const entryDir = import_node_path6.default.dirname(entryFile);
2926
- const resolvedEntryDir = import_node_fs7.default.existsSync(entryFile) ? import_node_path6.default.dirname(import_node_fs7.default.realpathSync(entryFile)) : entryDir;
3272
+ const entryFile = process.argv[1] ? import_node_path7.default.resolve(process.argv[1]) : __filename;
3273
+ const entryDir = import_node_path7.default.dirname(entryFile);
3274
+ const resolvedEntryDir = import_node_fs8.default.existsSync(entryFile) ? import_node_path7.default.dirname(import_node_fs8.default.realpathSync(entryFile)) : entryDir;
2927
3275
  const candidates = [
2928
- import_node_path6.default.resolve(resolvedEntryDir, "..", UPGRADE_PROMPTS_DIR),
2929
- import_node_path6.default.resolve(entryDir, "..", UPGRADE_PROMPTS_DIR),
2930
- import_node_path6.default.resolve(__dirname, "..", UPGRADE_PROMPTS_DIR),
2931
- import_node_path6.default.resolve(__dirname, "..", "..", UPGRADE_PROMPTS_DIR),
2932
- import_node_path6.default.resolve(process.cwd(), UPGRADE_PROMPTS_DIR)
3276
+ import_node_path7.default.resolve(resolvedEntryDir, "..", UPGRADE_PROMPTS_DIR),
3277
+ import_node_path7.default.resolve(entryDir, "..", UPGRADE_PROMPTS_DIR),
3278
+ import_node_path7.default.resolve(__dirname, "..", UPGRADE_PROMPTS_DIR),
3279
+ import_node_path7.default.resolve(__dirname, "..", "..", UPGRADE_PROMPTS_DIR),
3280
+ import_node_path7.default.resolve(process.cwd(), UPGRADE_PROMPTS_DIR)
2933
3281
  ];
2934
- const existing = candidates.find((candidate) => import_node_fs7.default.existsSync(candidate));
3282
+ const existing = candidates.find((candidate) => import_node_fs8.default.existsSync(candidate));
2935
3283
  return existing ?? candidates[0];
2936
3284
  }
2937
3285
  function loadUpgradeCatalog(options = {}) {
@@ -2947,12 +3295,12 @@ function loadUpgradeCatalog(options = {}) {
2947
3295
  releases
2948
3296
  };
2949
3297
  }
2950
- var import_node_fs7, import_node_path6, SEMVER_RE, UPGRADE_PROMPTS_DIR;
3298
+ var import_node_fs8, import_node_path7, SEMVER_RE, UPGRADE_PROMPTS_DIR;
2951
3299
  var init_upgrade = __esm({
2952
3300
  "src/core/upgrade.ts"() {
2953
3301
  "use strict";
2954
- import_node_fs7 = __toESM(require("fs"));
2955
- import_node_path6 = __toESM(require("path"));
3302
+ import_node_fs8 = __toESM(require("fs"));
3303
+ import_node_path7 = __toESM(require("path"));
2956
3304
  init_repo_config();
2957
3305
  SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
2958
3306
  UPGRADE_PROMPTS_DIR = "upgrade-prompts";
@@ -3052,7 +3400,7 @@ async function getDockerodeConstructor() {
3052
3400
  return dockerodeConstructorPromise;
3053
3401
  }
3054
3402
  function runDockerContextCommand(args) {
3055
- const result = (0, import_node_child_process3.spawnSync)("docker", ["context", ...args], {
3403
+ const result = (0, import_node_child_process4.spawnSync)("docker", ["context", ...args], {
3056
3404
  encoding: "utf-8"
3057
3405
  });
3058
3406
  if (result.status !== 0) {
@@ -3065,7 +3413,7 @@ function getCurrentDockerContext() {
3065
3413
  return runDockerContextCommand(["show"]);
3066
3414
  }
3067
3415
  function getDockerHostFromContext(context) {
3068
- const result = (0, import_node_child_process3.spawnSync)(
3416
+ const result = (0, import_node_child_process4.spawnSync)(
3069
3417
  "docker",
3070
3418
  ["context", "inspect", context, "--format", "{{ .Endpoints.docker.Host }}"],
3071
3419
  { encoding: "utf-8" }
@@ -3133,15 +3481,177 @@ async function networkExists(name) {
3133
3481
  return false;
3134
3482
  }
3135
3483
  }
3136
- var import_node_child_process3, dockerodeConstructorPromise;
3484
+ var import_node_child_process4, dockerodeConstructorPromise;
3137
3485
  var init_docker = __esm({
3138
3486
  "src/core/docker.ts"() {
3139
3487
  "use strict";
3140
- import_node_child_process3 = require("child_process");
3488
+ import_node_child_process4 = require("child_process");
3141
3489
  dockerodeConstructorPromise = null;
3142
3490
  }
3143
3491
  });
3144
3492
 
3493
+ // src/core/managed-post-start.ts
3494
+ function renderDeliveryScript(targetPath) {
3495
+ if (!/^\/tmp\/devrouter\/bin\/[a-zA-Z0-9._-]+$/.test(targetPath)) {
3496
+ throw new Error(`Unsafe managed runtime delivery path: ${targetPath}`);
3497
+ }
3498
+ const temporaryPrefix = import_node_path8.default.posix.basename(targetPath);
3499
+ return `set -eu
3500
+ umask 077
3501
+ runtime_root=/tmp/devrouter
3502
+ runtime_bin="$runtime_root/bin"
3503
+ target="${targetPath}"
3504
+ if [ -L "$runtime_root" ] || [ -L "$runtime_bin" ]; then
3505
+ echo "Refusing symlinked devrouter runtime path." >&2
3506
+ exit 1
3507
+ fi
3508
+ mkdir -p "$runtime_bin"
3509
+ chmod 700 "$runtime_root" "$runtime_bin"
3510
+ temporary="$(mktemp "$runtime_bin/.${temporaryPrefix}.XXXXXX")"
3511
+ trap 'rm -f "$temporary"' EXIT
3512
+ cat > "$temporary"
3513
+ chmod 700 "$temporary"
3514
+ mv -f "$temporary" "$target"
3515
+ trap - EXIT
3516
+ `;
3517
+ }
3518
+ function commandFailure(result) {
3519
+ return [result.error?.message, result.stderr, result.stdout].filter(Boolean).map((value) => String(value).trim()).filter(Boolean).join("\n");
3520
+ }
3521
+ function deliverRuntimeFile(containerId, targetPath, contents, label) {
3522
+ const result = (0, import_node_child_process5.spawnSync)(
3523
+ "docker",
3524
+ ["exec", "-i", containerId, "sh", "-c", renderDeliveryScript(targetPath)],
3525
+ { input: contents, encoding: "utf-8" }
3526
+ );
3527
+ if (result.status !== 0) {
3528
+ const details = commandFailure(result);
3529
+ throw new Error(`Could not deliver ${label}${details ? `: ${details}` : "."}`);
3530
+ }
3531
+ }
3532
+ function readRegularFileBytes(filePath) {
3533
+ if (!import_node_fs9.default.existsSync(filePath) || !import_node_fs9.default.lstatSync(filePath).isFile()) return void 0;
3534
+ return import_node_fs9.default.readFileSync(filePath);
3535
+ }
3536
+ function readRegularFile(filePath) {
3537
+ return readRegularFileBytes(filePath)?.toString("utf-8");
3538
+ }
3539
+ function adapterFingerprint(adapter) {
3540
+ return (0, import_node_crypto3.createHash)("sha256").update(adapter).digest("hex");
3541
+ }
3542
+ function resolveProcessHelperPath() {
3543
+ const candidates = [
3544
+ import_node_path8.default.resolve(__dirname, "..", "bin", "devrouter-process"),
3545
+ import_node_path8.default.resolve(__dirname, "..", "..", "bin", "devrouter-process")
3546
+ ];
3547
+ const helperPath = candidates.find((candidate) => import_node_fs9.default.existsSync(candidate));
3548
+ if (!helperPath) {
3549
+ throw new Error("Could not locate the packaged devrouter-process helper.");
3550
+ }
3551
+ return helperPath;
3552
+ }
3553
+ function resolveManagedPostStartPlan(repoPath) {
3554
+ const adapterPath = import_node_path8.default.join(repoPath, MANAGED_ADAPTER_PATH);
3555
+ const adapterBytes = readRegularFileBytes(adapterPath);
3556
+ const adapterText = adapterBytes?.toString("utf-8");
3557
+ const dockerfilePath = import_node_path8.default.join(repoPath, ".devcontainer", "Dockerfile");
3558
+ const devcontainerPath = import_node_path8.default.join(repoPath, ".devcontainer", "devcontainer.json");
3559
+ const dockerfile = readRegularFile(dockerfilePath) ?? "";
3560
+ const devcontainer = readRegularFile(devcontainerPath) ?? "";
3561
+ const devrouterPattern = [adapterText, dockerfile, devcontainer].filter((value) => value !== void 0).some(
3562
+ (value) => value.includes("DEVROUTER_PROCESS_HELPER") || value.includes("devrouter-process")
3563
+ );
3564
+ if (adapterBytes === void 0) {
3565
+ if (devrouterPattern) {
3566
+ throw new Error(
3567
+ "Devrouter lifecycle wiring is incomplete: add the managed post-start adapter or remove the stale devrouter-process references."
3568
+ );
3569
+ }
3570
+ return { kind: "unmanaged" };
3571
+ }
3572
+ const adapter = adapterBytes.toString("utf-8");
3573
+ if (!adapter.includes(MANAGED_MARKER)) {
3574
+ if (devrouterPattern) {
3575
+ throw new Error(
3576
+ "Devrouter-looking post-start adapter is missing the 'devrouter:managed devcontainer' marker. Regenerate or migrate the devcontainer, then retry devrouter ensure."
3577
+ );
3578
+ }
3579
+ return { kind: "unmanaged" };
3580
+ }
3581
+ if (adapter.includes("DEVROUTER_PROCESS_HELPER")) {
3582
+ return {
3583
+ kind: "runtime",
3584
+ adapterPath: MANAGED_ADAPTER_PATH,
3585
+ adapterSha256: adapterFingerprint(adapterBytes),
3586
+ adapterContents: adapterBytes
3587
+ };
3588
+ }
3589
+ if (dockerfile.includes("devrouter-process") && devcontainer.includes("postStartCommand")) {
3590
+ return { kind: "legacy" };
3591
+ }
3592
+ throw new Error(
3593
+ "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."
3594
+ );
3595
+ }
3596
+ function runManagedPostStart(options) {
3597
+ if (options.plan.kind !== "runtime") return;
3598
+ const helper = import_node_fs9.default.readFileSync(resolveProcessHelperPath());
3599
+ deliverRuntimeFile(
3600
+ options.container.id,
3601
+ RUNTIME_HELPER_PATH,
3602
+ helper,
3603
+ "the managed process helper"
3604
+ );
3605
+ const runtimeAdapterPath = `/tmp/devrouter/bin/managed-post-start-${options.plan.adapterSha256}`;
3606
+ deliverRuntimeFile(
3607
+ options.container.id,
3608
+ runtimeAdapterPath,
3609
+ options.plan.adapterContents,
3610
+ "the managed post-start adapter"
3611
+ );
3612
+ const started = (0, import_node_child_process5.spawnSync)(
3613
+ "docker",
3614
+ [
3615
+ "exec",
3616
+ "--workdir",
3617
+ options.container.workspacePath,
3618
+ "--env",
3619
+ `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
3620
+ "--env",
3621
+ `DEVROUTER_PROCESS_ADAPTER_SHA256=${options.plan.adapterSha256}`,
3622
+ options.container.id,
3623
+ "bash",
3624
+ "-c",
3625
+ ADAPTER_WRAPPER,
3626
+ options.plan.adapterPath,
3627
+ runtimeAdapterPath
3628
+ ],
3629
+ { stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit" }
3630
+ );
3631
+ if (started.status !== 0) {
3632
+ const details = commandFailure(started);
3633
+ throw new Error(`Managed post-start failed${details ? `: ${details}` : "."}`);
3634
+ }
3635
+ }
3636
+ var import_node_child_process5, import_node_crypto3, import_node_fs9, import_node_path8, MANAGED_MARKER, MANAGED_ADAPTER_PATH, RUNTIME_HELPER_PATH, ADAPTER_WRAPPER;
3637
+ var init_managed_post_start = __esm({
3638
+ "src/core/managed-post-start.ts"() {
3639
+ "use strict";
3640
+ import_node_child_process5 = require("child_process");
3641
+ import_node_crypto3 = require("crypto");
3642
+ import_node_fs9 = __toESM(require("fs"));
3643
+ import_node_path8 = __toESM(require("path"));
3644
+ MANAGED_MARKER = "devrouter:managed devcontainer";
3645
+ MANAGED_ADAPTER_PATH = ".devcontainer/post-start.sh";
3646
+ RUNTIME_HELPER_PATH = "/tmp/devrouter/bin/devrouter-process";
3647
+ ADAPTER_WRAPPER = `adapter_snapshot="$1"
3648
+ shift
3649
+ readonly DEVROUTER_PROCESS_HELPER DEVROUTER_PROCESS_ADAPTER_SHA256
3650
+ source "$adapter_snapshot"
3651
+ `;
3652
+ }
3653
+ });
3654
+
3145
3655
  // src/core/devcontainer-diagnostics.ts
3146
3656
  function asRecord(value) {
3147
3657
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -3196,7 +3706,7 @@ function isLoopbackHost(host) {
3196
3706
  return host === "127.0.0.1" || host === "localhost" || host === "host.docker.internal";
3197
3707
  }
3198
3708
  function inspectCompose(composeFile, _workspace) {
3199
- if (!import_node_fs8.default.existsSync(composeFile)) {
3709
+ if (!import_node_fs10.default.existsSync(composeFile)) {
3200
3710
  return {
3201
3711
  aliases: [],
3202
3712
  publishedPorts: [],
@@ -3205,7 +3715,7 @@ function inspectCompose(composeFile, _workspace) {
3205
3715
  };
3206
3716
  }
3207
3717
  try {
3208
- const raw = import_node_fs8.default.readFileSync(composeFile, "utf-8");
3718
+ const raw = import_node_fs10.default.readFileSync(composeFile, "utf-8");
3209
3719
  const parsed = import_yaml3.default.parse(raw);
3210
3720
  const root = asRecord(parsed);
3211
3721
  const services = asRecord(root?.services);
@@ -3262,11 +3772,11 @@ function routedProxyApps(config) {
3262
3772
  );
3263
3773
  }
3264
3774
  function buildDevcontainerChecks(repoPath, config, workspace) {
3265
- const devcontainerDir = import_node_path7.default.join(repoPath, ".devcontainer");
3266
- if (!import_node_fs8.default.existsSync(devcontainerDir)) {
3775
+ const devcontainerDir = import_node_path9.default.join(repoPath, ".devcontainer");
3776
+ if (!import_node_fs10.default.existsSync(devcontainerDir)) {
3267
3777
  return [];
3268
3778
  }
3269
- const compose = inspectCompose(import_node_path7.default.join(devcontainerDir, "docker-compose.yml"), workspace);
3779
+ const compose = inspectCompose(import_node_path9.default.join(devcontainerDir, "docker-compose.yml"), workspace);
3270
3780
  const checks = [];
3271
3781
  if (compose.parseError) {
3272
3782
  checks.push({
@@ -3293,9 +3803,9 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3293
3803
  details: compose.publishedPorts.length > 0 ? compose.publishedPorts.join(", ") : void 0,
3294
3804
  suggestion: compose.parseError || compose.publishedPorts.length > 0 ? "Remove published ports and route services through devnet aliases with devrouter proxy apps." : void 0
3295
3805
  });
3296
- const dockerfilePath = import_node_path7.default.join(devcontainerDir, "Dockerfile");
3297
- const dockerfileExists = import_node_fs8.default.existsSync(dockerfilePath);
3298
- const dockerfile = dockerfileExists ? import_node_fs8.default.readFileSync(dockerfilePath, "utf-8") : "";
3806
+ const dockerfilePath = import_node_path9.default.join(devcontainerDir, "Dockerfile");
3807
+ const dockerfileExists = import_node_fs10.default.existsSync(dockerfilePath);
3808
+ const dockerfile = dockerfileExists ? import_node_fs10.default.readFileSync(dockerfilePath, "utf-8") : "";
3299
3809
  const devrouterArtifacts = ["@devrouter/cli", "devrouter-process"].filter(
3300
3810
  (artifact) => dockerfile.includes(artifact)
3301
3811
  );
@@ -3306,6 +3816,23 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3306
3816
  details: devrouterArtifacts.length > 0 ? `artifacts=${devrouterArtifacts.join(", ")}` : void 0,
3307
3817
  suggestion: !dockerfileExists ? "Inspect the consumer image definition and keep devrouter package/helper installation out of it." : devrouterArtifacts.length > 0 ? "Remove devrouter package/helper installation from the Dockerfile; devrouter ensure delivers the helper at runtime." : void 0
3308
3818
  });
3819
+ try {
3820
+ const managedPostStart = resolveManagedPostStartPlan(repoPath);
3821
+ checks.push({
3822
+ id: "repo.devcontainer.managed-post-start",
3823
+ level: managedPostStart.kind === "legacy" ? "warn" : "ok",
3824
+ 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.",
3825
+ suggestion: managedPostStart.kind === "legacy" ? "Regenerate or migrate the devcontainer to runtime helper delivery." : void 0
3826
+ });
3827
+ } catch (error) {
3828
+ checks.push({
3829
+ id: "repo.devcontainer.managed-post-start",
3830
+ level: "error",
3831
+ summary: "Devrouter lifecycle wiring is incomplete or ambiguous.",
3832
+ details: error instanceof Error ? error.message : String(error),
3833
+ suggestion: "Regenerate or migrate the managed devcontainer, or remove devrouter-specific wiring from a truly custom devcontainer."
3834
+ });
3835
+ }
3309
3836
  if (!config) {
3310
3837
  checks.push({
3311
3838
  id: "repo.devcontainer.upstream-alias-match",
@@ -3333,30 +3860,31 @@ function buildDevcontainerChecks(repoPath, config, workspace) {
3333
3860
  });
3334
3861
  return checks;
3335
3862
  }
3336
- var import_node_fs8, import_node_path7, import_yaml3;
3863
+ var import_node_fs10, import_node_path9, import_yaml3;
3337
3864
  var init_devcontainer_diagnostics = __esm({
3338
3865
  "src/core/devcontainer-diagnostics.ts"() {
3339
3866
  "use strict";
3340
- import_node_fs8 = __toESM(require("fs"));
3341
- import_node_path7 = __toESM(require("path"));
3867
+ import_node_fs10 = __toESM(require("fs"));
3868
+ import_node_path9 = __toESM(require("path"));
3342
3869
  import_yaml3 = __toESM(require("yaml"));
3870
+ init_managed_post_start();
3343
3871
  }
3344
3872
  });
3345
3873
 
3346
3874
  // src/core/paths.ts
3347
3875
  function assertPathWithinRepo(filePath, repoRoot, label) {
3348
- const resolvedRoot = import_node_path8.default.resolve(repoRoot);
3349
- const resolved = import_node_path8.default.resolve(repoRoot, filePath);
3350
- if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + import_node_path8.default.sep)) {
3876
+ const resolvedRoot = import_node_path10.default.resolve(repoRoot);
3877
+ const resolved = import_node_path10.default.resolve(repoRoot, filePath);
3878
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + import_node_path10.default.sep)) {
3351
3879
  throw new Error(`${label} path '${filePath}' escapes the repository root.`);
3352
3880
  }
3353
3881
  return resolved;
3354
3882
  }
3355
- var import_node_path8;
3883
+ var import_node_path10;
3356
3884
  var init_paths = __esm({
3357
3885
  "src/core/paths.ts"() {
3358
3886
  "use strict";
3359
- import_node_path8 = __toESM(require("path"));
3887
+ import_node_path10 = __toESM(require("path"));
3360
3888
  }
3361
3889
  });
3362
3890
 
@@ -3441,11 +3969,11 @@ function reconcileRouteRunConflict(repoPath, app) {
3441
3969
  }
3442
3970
  }
3443
3971
  }
3444
- var import_node_fs9;
3972
+ var import_node_fs11;
3445
3973
  var init_route_state = __esm({
3446
3974
  "src/core/route-state.ts"() {
3447
3975
  "use strict";
3448
- import_node_fs9 = __toESM(require("fs"));
3976
+ import_node_fs11 = __toESM(require("fs"));
3449
3977
  init_host_routes();
3450
3978
  init_workspace();
3451
3979
  }
@@ -3620,7 +4148,7 @@ function toRepoStatus(repoPath) {
3620
4148
  const resolvedRepoPath = resolveRepoPath(repoPath);
3621
4149
  const configPath = getRepoConfigPath(resolvedRepoPath);
3622
4150
  const explicitRepo = typeof repoPath === "string" && repoPath.trim().length > 0;
3623
- const configExists = import_node_fs10.default.existsSync(configPath);
4151
+ const configExists = import_node_fs12.default.existsSync(configPath);
3624
4152
  if (!explicitRepo && !configExists) {
3625
4153
  return void 0;
3626
4154
  }
@@ -3747,11 +4275,11 @@ async function collectRouterStatus(repoPath) {
3747
4275
  }
3748
4276
  };
3749
4277
  }
3750
- var import_node_fs10;
4278
+ var import_node_fs12;
3751
4279
  var init_status = __esm({
3752
4280
  "src/core/status.ts"() {
3753
4281
  "use strict";
3754
- import_node_fs10 = __toESM(require("fs"));
4282
+ import_node_fs12 = __toESM(require("fs"));
3755
4283
  init_docker();
3756
4284
  init_repo_config();
3757
4285
  init_router();
@@ -3760,7 +4288,7 @@ var init_status = __esm({
3760
4288
 
3761
4289
  // src/core/tls.ts
3762
4290
  function runOrThrow(command, args) {
3763
- const result = (0, import_node_child_process4.spawnSync)(command, args, {
4291
+ const result = (0, import_node_child_process6.spawnSync)(command, args, {
3764
4292
  encoding: "utf-8"
3765
4293
  });
3766
4294
  if (result.status !== 0) {
@@ -3770,7 +4298,7 @@ function runOrThrow(command, args) {
3770
4298
  return result.stdout.trim();
3771
4299
  }
3772
4300
  function commandExists(command) {
3773
- const result = (0, import_node_child_process4.spawnSync)("sh", ["-c", `command -v ${command}`], { encoding: "utf-8" });
4301
+ const result = (0, import_node_child_process6.spawnSync)("sh", ["-c", `command -v ${command}`], { encoding: "utf-8" });
3774
4302
  return result.status === 0;
3775
4303
  }
3776
4304
  function ensureMkcert() {
@@ -3788,8 +4316,8 @@ function tlsSetupCommand(repoPath) {
3788
4316
  }
3789
4317
  function getMkcertRootCAPath(options = {}) {
3790
4318
  ensureMkcert();
3791
- const rootCAPath = import_node_path9.default.join(runOrThrow("mkcert", ["-CAROOT"]), "rootCA.pem");
3792
- if (!import_node_fs11.default.existsSync(rootCAPath)) {
4319
+ const rootCAPath = import_node_path11.default.join(runOrThrow("mkcert", ["-CAROOT"]), "rootCA.pem");
4320
+ if (!import_node_fs13.default.existsSync(rootCAPath)) {
3793
4321
  throw new Error(
3794
4322
  `mkcert root CA was not found at '${rootCAPath}'. Run: ${tlsSetupCommand(options.repoPath)}`
3795
4323
  );
@@ -3822,7 +4350,7 @@ function parseDnsHostsFromSubjectAltName(subjectAltName) {
3822
4350
  return normalizeUniqueHosts(names);
3823
4351
  }
3824
4352
  function parseCertificateDnsHosts(pem) {
3825
- const certificate = new import_node_crypto2.X509Certificate(pem);
4353
+ const certificate = new import_node_crypto4.X509Certificate(pem);
3826
4354
  const subjectAltName = certificate.subjectAltName ?? "";
3827
4355
  if (subjectAltName.length === 0) {
3828
4356
  return [];
@@ -3855,10 +4383,10 @@ function findUncoveredCertificateHosts(requiredHosts, certificateHosts) {
3855
4383
  );
3856
4384
  }
3857
4385
  function readCurrentCertificateHosts() {
3858
- if (!import_node_fs11.default.existsSync(CERT_FILE)) {
4386
+ if (!import_node_fs13.default.existsSync(CERT_FILE)) {
3859
4387
  return [];
3860
4388
  }
3861
- const pem = import_node_fs11.default.readFileSync(CERT_FILE, "utf-8");
4389
+ const pem = import_node_fs13.default.readFileSync(CERT_FILE, "utf-8");
3862
4390
  return parseCertificateDnsHosts(pem);
3863
4391
  }
3864
4392
  function currentCertificateHostsOrEmpty() {
@@ -3947,14 +4475,14 @@ Run: ${tlsSetupCommand(options.repoPath)}`
3947
4475
  );
3948
4476
  }
3949
4477
  }
3950
- var import_node_child_process4, import_node_crypto2, import_node_fs11, import_node_path9, DEFAULT_TLS_CERT_HOSTS;
4478
+ var import_node_child_process6, import_node_crypto4, import_node_fs13, import_node_path11, DEFAULT_TLS_CERT_HOSTS;
3951
4479
  var init_tls = __esm({
3952
4480
  "src/core/tls.ts"() {
3953
4481
  "use strict";
3954
- import_node_child_process4 = require("child_process");
3955
- import_node_crypto2 = require("crypto");
3956
- import_node_fs11 = __toESM(require("fs"));
3957
- import_node_path9 = __toESM(require("path"));
4482
+ import_node_child_process6 = require("child_process");
4483
+ import_node_crypto4 = require("crypto");
4484
+ import_node_fs13 = __toESM(require("fs"));
4485
+ import_node_path11 = __toESM(require("path"));
3958
4486
  init_docker();
3959
4487
  init_host_routes();
3960
4488
  init_router();
@@ -3970,7 +4498,7 @@ function outputFromResult(result) {
3970
4498
  return output2.length > 0 ? output2 : void 0;
3971
4499
  }
3972
4500
  function runTool(command, args = []) {
3973
- const result = (0, import_node_child_process5.spawnSync)(command, args, {
4501
+ const result = (0, import_node_child_process7.spawnSync)(command, args, {
3974
4502
  encoding: "utf-8"
3975
4503
  });
3976
4504
  if (result.error) {
@@ -4027,12 +4555,12 @@ function parseMinimumNodeMajor(value) {
4027
4555
  return Number(match[1]);
4028
4556
  }
4029
4557
  function readPackageJson(repoPath) {
4030
- const packagePath = import_node_path10.default.join(repoPath, "package.json");
4031
- if (!import_node_fs12.default.existsSync(packagePath)) {
4558
+ const packagePath = import_node_path12.default.join(repoPath, "package.json");
4559
+ if (!import_node_fs14.default.existsSync(packagePath)) {
4032
4560
  return void 0;
4033
4561
  }
4034
4562
  try {
4035
- return JSON.parse(import_node_fs12.default.readFileSync(packagePath, "utf-8"));
4563
+ return JSON.parse(import_node_fs14.default.readFileSync(packagePath, "utf-8"));
4036
4564
  } catch {
4037
4565
  return void 0;
4038
4566
  }
@@ -4127,19 +4655,21 @@ function buildGlobalToolChecks(repoPath) {
4127
4655
  checks.push(nodeToolchainCheck(repoPath));
4128
4656
  return checks;
4129
4657
  }
4130
- var import_node_child_process5, import_node_fs12, import_node_path10;
4658
+ var import_node_child_process7, import_node_fs14, import_node_path12;
4131
4659
  var init_tool_diagnostics = __esm({
4132
4660
  "src/core/tool-diagnostics.ts"() {
4133
4661
  "use strict";
4134
- import_node_child_process5 = require("child_process");
4135
- import_node_fs12 = __toESM(require("fs"));
4136
- import_node_path10 = __toESM(require("path"));
4662
+ import_node_child_process7 = require("child_process");
4663
+ import_node_fs14 = __toESM(require("fs"));
4664
+ import_node_path12 = __toESM(require("path"));
4137
4665
  }
4138
4666
  });
4139
4667
 
4140
4668
  // src/core/devpod-workspaces.ts
4141
4669
  function listDevpodWorkspaces() {
4142
- const result = (0, import_node_child_process6.spawnSync)("devpod", ["list", "--output", "json"], { encoding: "utf-8" });
4670
+ const result = (0, import_node_child_process8.spawnSync)("devpod", ["list", "--output", "json", "--skip-pro"], {
4671
+ encoding: "utf-8"
4672
+ });
4143
4673
  if (result.status !== 0) {
4144
4674
  const details = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4145
4675
  throw new Error(`devpod list failed: ${details || "devpod is not installed or unavailable"}`);
@@ -4158,8 +4688,49 @@ function listDevpodWorkspaces() {
4158
4688
  if (typeof candidate.id !== "string" || !candidate.source || typeof candidate.source.localFolder !== "string") {
4159
4689
  throw new Error("devpod list returned a workspace without id/source.localFolder.");
4160
4690
  }
4161
- return candidate;
4691
+ const workspace = {
4692
+ id: candidate.id,
4693
+ source: { localFolder: candidate.source.localFolder }
4694
+ };
4695
+ if ("lastUsed" in candidate) {
4696
+ if (typeof candidate.lastUsed === "string") {
4697
+ workspace.lastUsed = candidate.lastUsed;
4698
+ } else {
4699
+ workspace.lastUsedMalformed = true;
4700
+ }
4701
+ }
4702
+ return workspace;
4703
+ });
4704
+ }
4705
+ function parseDevpodRuntimeStatus(output2, expectedId) {
4706
+ let parsed;
4707
+ try {
4708
+ parsed = JSON.parse(output2);
4709
+ } catch {
4710
+ return "unknown";
4711
+ }
4712
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.id !== expectedId) {
4713
+ return "unknown";
4714
+ }
4715
+ switch (parsed.state) {
4716
+ case "Running":
4717
+ return "running";
4718
+ case "Stopped":
4719
+ return "stopped";
4720
+ case "Busy":
4721
+ return "busy";
4722
+ case "NotFound":
4723
+ return "not-found";
4724
+ default:
4725
+ return "unknown";
4726
+ }
4727
+ }
4728
+ function inspectDevpodRuntimeStatus(devpodId) {
4729
+ const result = (0, import_node_child_process8.spawnSync)("devpod", ["status", devpodId, "--output", "json", "--timeout", "5s"], {
4730
+ encoding: "utf-8"
4162
4731
  });
4732
+ if (result.status !== 0 || result.error) return "unknown";
4733
+ return parseDevpodRuntimeStatus(result.stdout, devpodId);
4163
4734
  }
4164
4735
  function inspectDevpodWorkspaceOwnership(workspaces, devpodId, worktreePath) {
4165
4736
  const idOwners = workspaces.filter((workspace) => workspace.id === devpodId);
@@ -4188,23 +4759,159 @@ function selectDevpodWorkspace(workspaces, repoPath) {
4188
4759
  }
4189
4760
  return matches[0];
4190
4761
  }
4191
- function runDevpodWorkspaceAction(action2, devpodId) {
4192
- const args = action2 === "delete" ? [action2, devpodId, "--ignore-not-found"] : [action2, devpodId];
4193
- const result = (0, import_node_child_process6.spawnSync)("devpod", args, { encoding: "utf-8" });
4194
- if (result.status !== 0) {
4195
- const detail = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4196
- throw new Error(`devpod ${action2} failed for '${devpodId}': ${detail || "unknown error"}`);
4197
- }
4198
- }
4199
- var import_node_child_process6;
4762
+ var import_node_child_process8;
4200
4763
  var init_devpod_workspaces = __esm({
4201
4764
  "src/core/devpod-workspaces.ts"() {
4202
4765
  "use strict";
4203
- import_node_child_process6 = require("child_process");
4766
+ import_node_child_process8 = require("child_process");
4204
4767
  init_workspace();
4205
4768
  }
4206
4769
  });
4207
4770
 
4771
+ // src/core/devpod-mutation.ts
4772
+ function withMutationLock(activity, target, operation) {
4773
+ import_node_fs15.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
4774
+ return withFileLockSync(
4775
+ DEVPOD_MUTATION_LOCK_FILE,
4776
+ { activity, target: `'${target}'`, waitMs: DEVPOD_MUTATION_WAIT_MS },
4777
+ operation
4778
+ );
4779
+ }
4780
+ function commandFailure2(result) {
4781
+ return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4782
+ }
4783
+ function runDevpodAction(action2, devpodId, force = false) {
4784
+ const args = action2 === "delete" ? [action2, devpodId, ...force ? ["--force"] : [], "--ignore-not-found"] : [action2, devpodId];
4785
+ const result = (0, import_node_child_process9.spawnSync)("devpod", args, { encoding: "utf-8" });
4786
+ if (result.status !== 0) {
4787
+ throw new Error(
4788
+ `devpod ${action2}${force ? " --force" : ""} failed for '${devpodId}': ${commandFailure2(result) || "unknown error"}`
4789
+ );
4790
+ }
4791
+ }
4792
+ function inspectExactOwnership(devpodId, worktreePath) {
4793
+ const ownership = inspectDevpodWorkspaceOwnership(listDevpodWorkspaces(), devpodId, worktreePath);
4794
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
4795
+ return ownership;
4796
+ }
4797
+ function mutateOwnedDevpodWorkspace(action2, devpodId, worktreePath) {
4798
+ return withMutationLock(`DevPod ${action2}`, worktreePath, () => {
4799
+ const before = inspectExactOwnership(devpodId, worktreePath);
4800
+ if (before.status === "absent") return { status: "absent" };
4801
+ runDevpodAction(action2, devpodId);
4802
+ let after = inspectExactOwnership(devpodId, worktreePath);
4803
+ if (action2 === "stop" && after.status !== "owned") {
4804
+ throw new Error(`DevPod '${devpodId}' no longer owns '${worktreePath}' after provider stop.`);
4805
+ }
4806
+ if (action2 === "delete" && after.status === "owned") {
4807
+ const runtime = inspectDevpodRuntimeStatus(devpodId);
4808
+ if (runtime !== "not-found") {
4809
+ throw new Error(
4810
+ `DevPod '${devpodId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
4811
+ );
4812
+ }
4813
+ after = inspectExactOwnership(devpodId, worktreePath);
4814
+ if (after.status === "owned") {
4815
+ runDevpodAction("delete", devpodId, true);
4816
+ after = inspectExactOwnership(devpodId, worktreePath);
4817
+ }
4818
+ if (after.status !== "absent") {
4819
+ throw new Error(
4820
+ `DevPod '${devpodId}' still owns '${worktreePath}' after forced provider delete.`
4821
+ );
4822
+ }
4823
+ }
4824
+ return { status: "changed" };
4825
+ });
4826
+ }
4827
+ function stopOwnedDevpodWorkspace(devpodId, worktreePath) {
4828
+ return mutateOwnedDevpodWorkspace("stop", devpodId, worktreePath);
4829
+ }
4830
+ function deleteOwnedDevpodWorkspace(devpodId, worktreePath) {
4831
+ return mutateOwnedDevpodWorkspace("delete", devpodId, worktreePath);
4832
+ }
4833
+ function startDevpodWorkspace(options) {
4834
+ const activity = options.recreate ? "DevPod recreate" : "DevPod start";
4835
+ return withMutationLock(activity, options.repoPath, () => {
4836
+ const workspaces = listDevpodWorkspaces();
4837
+ let devpodId = options.devpodId ?? selectDevpodWorkspace(workspaces, options.repoPath)?.id;
4838
+ if (devpodId) {
4839
+ const before = inspectDevpodWorkspaceOwnership(workspaces, devpodId, options.repoPath);
4840
+ if (before.status === "conflict") throw new Error(before.reason);
4841
+ if (options.recreate && before.status !== "owned") {
4842
+ throw new Error(`Cannot recreate DevPod '${devpodId}' without one exact owner.`);
4843
+ }
4844
+ } else if (options.recreate) {
4845
+ throw new Error("Cannot recreate a DevPod before its exact id is known.");
4846
+ }
4847
+ const args = ["up", options.repoPath];
4848
+ if (devpodId) args.push("--id", devpodId);
4849
+ args.push("--open-ide=false");
4850
+ if (options.workspace) {
4851
+ args.push(
4852
+ "--workspace-env",
4853
+ `WORKSPACE=${options.workspace.token}`,
4854
+ "--workspace-env",
4855
+ `DEVROUTER_WORKSPACE=${options.workspace.token}`
4856
+ );
4857
+ }
4858
+ if (options.recreate) args.push("--recreate");
4859
+ const env = { ...process.env };
4860
+ if (options.workspace) {
4861
+ env.WORKSPACE = options.workspace.token;
4862
+ env.DEVROUTER_WORKSPACE = options.workspace.token;
4863
+ env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
4864
+ env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
4865
+ } else {
4866
+ delete env.WORKSPACE;
4867
+ delete env.DEVROUTER_WORKSPACE;
4868
+ delete env.DEVROUTER_GIT_COMMON_DIR;
4869
+ delete env.DEVCONTAINER_COMPOSE_OVERLAY;
4870
+ }
4871
+ const result = (0, import_node_child_process9.spawnSync)("devpod", args, {
4872
+ stdio: options.quiet ? ["inherit", 2, "inherit"] : "inherit",
4873
+ env
4874
+ });
4875
+ if (result.status !== 0) {
4876
+ throw new Error(`devpod up failed for '${devpodId ?? options.repoPath}'.`);
4877
+ }
4878
+ try {
4879
+ const attached = listDevpodWorkspaces();
4880
+ devpodId ??= selectDevpodWorkspace(attached, options.repoPath)?.id;
4881
+ if (!devpodId) {
4882
+ throw new Error(`DevPod did not attach '${options.repoPath}' after startup.`);
4883
+ }
4884
+ const ownership = inspectDevpodWorkspaceOwnership(attached, devpodId, options.repoPath);
4885
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
4886
+ if (ownership.status !== "owned") {
4887
+ throw new Error(
4888
+ `DevPod did not attach '${options.repoPath}' as '${devpodId}' after startup.`
4889
+ );
4890
+ }
4891
+ return devpodId;
4892
+ } catch (error) {
4893
+ const message = error instanceof Error ? error.message : String(error);
4894
+ throw new DevpodStartPostconditionError(message);
4895
+ }
4896
+ });
4897
+ }
4898
+ var import_node_child_process9, import_node_fs15, import_node_path13, DEVPOD_MUTATION_LOCK_FILE, DEVPOD_MUTATION_WAIT_MS, DevpodStartPostconditionError;
4899
+ var init_devpod_mutation = __esm({
4900
+ "src/core/devpod-mutation.ts"() {
4901
+ "use strict";
4902
+ import_node_child_process9 = require("child_process");
4903
+ import_node_fs15 = __toESM(require("fs"));
4904
+ import_node_path13 = __toESM(require("path"));
4905
+ init_devpod_workspaces();
4906
+ init_file_lock();
4907
+ init_router();
4908
+ DEVPOD_MUTATION_LOCK_FILE = import_node_path13.default.join(DEVROUTER_HOME, "devpod-mutation.lock");
4909
+ DEVPOD_MUTATION_WAIT_MS = 6e4;
4910
+ DevpodStartPostconditionError = class extends Error {
4911
+ };
4912
+ }
4913
+ });
4914
+
4208
4915
  // src/core/workspace-ownership.ts
4209
4916
  function commandError(command, repoPath, stderr) {
4210
4917
  return new Error(
@@ -4212,18 +4919,20 @@ function commandError(command, repoPath, stderr) {
4212
4919
  );
4213
4920
  }
4214
4921
  function resolveGitCommonDir(repoPath) {
4215
- const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
4216
- encoding: "utf-8"
4922
+ const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
4923
+ encoding: "utf-8",
4924
+ env: READ_ONLY_GIT_ENV
4217
4925
  });
4218
4926
  const output2 = result.stdout.trim();
4219
4927
  if (result.status !== 0 || !output2) {
4220
4928
  throw commandError("Could not resolve the Git common directory", repoPath, result.stderr);
4221
4929
  }
4222
- return comparableWorkspacePath(import_node_path11.default.isAbsolute(output2) ? output2 : import_node_path11.default.resolve(repoPath, output2));
4930
+ return comparableWorkspacePath(import_node_path14.default.isAbsolute(output2) ? output2 : import_node_path14.default.resolve(repoPath, output2));
4223
4931
  }
4224
4932
  function resolveGitTopLevel(repoPath) {
4225
- const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], {
4226
- encoding: "utf-8"
4933
+ const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], {
4934
+ encoding: "utf-8",
4935
+ env: READ_ONLY_GIT_ENV
4227
4936
  });
4228
4937
  const output2 = result.stdout.trim();
4229
4938
  if (result.status !== 0 || !output2) {
@@ -4232,8 +4941,9 @@ function resolveGitTopLevel(repoPath) {
4232
4941
  return comparableWorkspacePath(output2);
4233
4942
  }
4234
4943
  function listGitWorktrees(repoPath) {
4235
- const result = (0, import_node_child_process7.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
4236
- encoding: "utf-8"
4944
+ const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
4945
+ encoding: "utf-8",
4946
+ env: READ_ONLY_GIT_ENV
4237
4947
  });
4238
4948
  if (result.status !== 0) {
4239
4949
  throw commandError("git worktree list", repoPath, result.stderr);
@@ -4268,7 +4978,7 @@ function listGitWorktrees(repoPath) {
4268
4978
  return worktrees;
4269
4979
  }
4270
4980
  function ownershipDirectory(repoPath) {
4271
- return import_node_path11.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
4981
+ return import_node_path14.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
4272
4982
  }
4273
4983
  function validateWorkspace(value, label) {
4274
4984
  if (typeof value !== "string" || wsFromBranch(value) !== value) {
@@ -4296,7 +5006,7 @@ function validateRecord(value, expectedWorkspace) {
4296
5006
  `workspace ownership file '${expectedWorkspace}' contains identity '${workspace}'`
4297
5007
  );
4298
5008
  }
4299
- if (typeof candidate.worktreePath !== "string" || !import_node_path11.default.isAbsolute(candidate.worktreePath)) {
5009
+ if (typeof candidate.worktreePath !== "string" || !import_node_path14.default.isAbsolute(candidate.worktreePath)) {
4300
5010
  throw new Error("invalid workspace ownership worktreePath");
4301
5011
  }
4302
5012
  if (candidate.branch !== null && typeof candidate.branch !== "string") {
@@ -4316,7 +5026,7 @@ function validateRecord(value, expectedWorkspace) {
4316
5026
  function readRecordFile(filePath, expectedWorkspace) {
4317
5027
  let parsed;
4318
5028
  try {
4319
- parsed = JSON.parse(import_node_fs13.default.readFileSync(filePath, "utf-8"));
5029
+ parsed = JSON.parse(import_node_fs16.default.readFileSync(filePath, "utf-8"));
4320
5030
  } catch (error) {
4321
5031
  if (error instanceof SyntaxError) {
4322
5032
  throw new Error(`invalid workspace ownership JSON at '${filePath}'`);
@@ -4332,7 +5042,7 @@ function listWorkspaceOwnership(repoPath) {
4332
5042
  function listWorkspaceOwnershipInDirectory(directory) {
4333
5043
  let entries;
4334
5044
  try {
4335
- entries = import_node_fs13.default.readdirSync(directory, { withFileTypes: true });
5045
+ entries = import_node_fs16.default.readdirSync(directory, { withFileTypes: true });
4336
5046
  } catch (error) {
4337
5047
  if (error.code === "ENOENT") return [];
4338
5048
  throw error;
@@ -4340,14 +5050,14 @@ function listWorkspaceOwnershipInDirectory(directory) {
4340
5050
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
4341
5051
  const workspace = entry.name.slice(0, -".json".length);
4342
5052
  validateWorkspace(workspace, "filename");
4343
- return readRecordFile(import_node_path11.default.join(directory, entry.name), workspace);
5053
+ return readRecordFile(import_node_path14.default.join(directory, entry.name), workspace);
4344
5054
  });
4345
5055
  }
4346
5056
  function writeWorkspaceOwnershipInDirectory(directory, input2) {
4347
5057
  const workspace = validateWorkspace(input2.workspace, "workspace");
4348
5058
  const devpodId = validateWorkspace(input2.devpodId, "devpodId");
4349
5059
  const worktreePath = comparableWorkspacePath(input2.worktreePath);
4350
- const filePath = import_node_path11.default.join(directory, `${workspace}.json`);
5060
+ const filePath = import_node_path14.default.join(directory, `${workspace}.json`);
4351
5061
  const now = (/* @__PURE__ */ new Date()).toISOString();
4352
5062
  const records = listWorkspaceOwnershipInDirectory(directory);
4353
5063
  const existing = records.find((record2) => record2.workspace === workspace);
@@ -4378,23 +5088,14 @@ function writeWorkspaceOwnershipInDirectory(directory, input2) {
4378
5088
  createdAt: existing?.createdAt ?? validateTimestamp(now, "createdAt"),
4379
5089
  updatedAt: validateTimestamp(now, "updatedAt")
4380
5090
  };
4381
- const tempPath = `${filePath}.${process.pid}.${(0, import_node_crypto3.randomUUID)()}.tmp`;
4382
- try {
4383
- import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify(record, null, 2)}
4384
- `, {
4385
- encoding: "utf-8",
4386
- flag: "wx"
4387
- });
4388
- import_node_fs13.default.renameSync(tempPath, filePath);
4389
- } finally {
4390
- import_node_fs13.default.rmSync(tempPath, { force: true });
4391
- }
5091
+ writeFileAtomically(filePath, `${JSON.stringify(record, null, 2)}
5092
+ `);
4392
5093
  return record;
4393
5094
  }
4394
5095
  function removeWorkspaceOwnershipInDirectory(directory, workspace) {
4395
- const filePath = import_node_path11.default.join(directory, `${validateWorkspace(workspace, "workspace")}.json`);
5096
+ const filePath = import_node_path14.default.join(directory, `${validateWorkspace(workspace, "workspace")}.json`);
4396
5097
  try {
4397
- import_node_fs13.default.rmSync(filePath);
5098
+ import_node_fs16.default.rmSync(filePath);
4398
5099
  return true;
4399
5100
  } catch (error) {
4400
5101
  if (error.code === "ENOENT") return false;
@@ -4405,7 +5106,7 @@ function sameOwnershipRecord(left, right) {
4405
5106
  return left.version === right.version && left.workspace === right.workspace && sameWorkspacePath(left.worktreePath, right.worktreePath) && left.branch === right.branch && left.devpodId === right.devpodId && left.createdAt === right.createdAt && left.updatedAt === right.updatedAt;
4406
5107
  }
4407
5108
  function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
4408
- const filePath = import_node_path11.default.join(
5109
+ const filePath = import_node_path14.default.join(
4409
5110
  directory,
4410
5111
  `${validateWorkspace(expected.workspace, "workspace")}.json`
4411
5112
  );
@@ -4417,14 +5118,14 @@ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
4417
5118
  throw error;
4418
5119
  }
4419
5120
  if (!sameOwnershipRecord(current, expected)) return "changed";
4420
- import_node_fs13.default.rmSync(filePath);
5121
+ import_node_fs16.default.rmSync(filePath);
4421
5122
  return "removed";
4422
5123
  }
4423
5124
  function withWorkspaceOwnershipTransaction(repoPath, operation) {
4424
5125
  const directory = ownershipDirectory(repoPath);
4425
- import_node_fs13.default.mkdirSync(directory, { recursive: true });
5126
+ import_node_fs16.default.mkdirSync(directory, { recursive: true });
4426
5127
  return withFileLockSync(
4427
- import_node_path11.default.join(directory, ".lock"),
5128
+ import_node_path14.default.join(directory, ".lock"),
4428
5129
  { activity: "workspace ownership transaction", target: `'${repoPath}'`, waitMs: 5e3 },
4429
5130
  () => operation({
4430
5131
  list: () => listWorkspaceOwnershipInDirectory(directory),
@@ -4455,7 +5156,7 @@ function inspectWorkspaceOwnership(record, worktrees, devpods) {
4455
5156
  if (worktree?.locked) {
4456
5157
  return { ownerStatus: "locked", devpodStatus, worktree };
4457
5158
  }
4458
- if ((!worktree || worktree.prunable) && import_node_fs13.default.existsSync(record.worktreePath)) {
5159
+ if ((!worktree || worktree.prunable) && import_node_fs16.default.existsSync(record.worktreePath)) {
4459
5160
  return { ownerStatus: "conflict", devpodStatus, worktree };
4460
5161
  }
4461
5162
  if (!worktree || worktree.prunable) {
@@ -4479,19 +5180,20 @@ function listMissingWorkspaceOwnership(repoPath) {
4479
5180
  (record) => inspectWorkspaceOwnership(record, worktrees, void 0).ownerStatus === "missing"
4480
5181
  );
4481
5182
  }
4482
- var import_node_child_process7, import_node_crypto3, import_node_fs13, import_node_path11, OWNERSHIP_VERSION, OWNERSHIP_DIR;
5183
+ var import_node_child_process10, import_node_fs16, import_node_path14, READ_ONLY_GIT_ENV, OWNERSHIP_VERSION, OWNERSHIP_DIR;
4483
5184
  var init_workspace_ownership = __esm({
4484
5185
  "src/core/workspace-ownership.ts"() {
4485
5186
  "use strict";
4486
- import_node_child_process7 = require("child_process");
4487
- import_node_crypto3 = require("crypto");
4488
- import_node_fs13 = __toESM(require("fs"));
4489
- import_node_path11 = __toESM(require("path"));
5187
+ import_node_child_process10 = require("child_process");
5188
+ import_node_fs16 = __toESM(require("fs"));
5189
+ import_node_path14 = __toESM(require("path"));
5190
+ init_atomic_file();
4490
5191
  init_devpod_workspaces();
4491
5192
  init_file_lock();
4492
5193
  init_workspace();
5194
+ READ_ONLY_GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
4493
5195
  OWNERSHIP_VERSION = 1;
4494
- OWNERSHIP_DIR = import_node_path11.default.join("devrouter", "workspaces");
5196
+ OWNERSHIP_DIR = import_node_path14.default.join("devrouter", "workspaces");
4495
5197
  }
4496
5198
  });
4497
5199
 
@@ -4505,10 +5207,10 @@ function inRepositoryWorkspaceScope(repoPath, worktreePath, livePaths) {
4505
5207
  if (livePaths.some((candidate) => sameWorkspacePath(candidate, worktreePath))) return true;
4506
5208
  const comparableRepo = comparableWorkspacePath(repoPath);
4507
5209
  const comparableWorktree = comparableWorkspacePath(worktreePath);
4508
- const localRoot = import_node_path12.default.join(comparableRepo, "trees") + import_node_path12.default.sep;
5210
+ const localRoot = import_node_path15.default.join(comparableRepo, "trees") + import_node_path15.default.sep;
4509
5211
  if (comparableWorktree.startsWith(localRoot)) return true;
4510
- const legacyPrefix = `${import_node_path12.default.basename(comparableRepo)}-`;
4511
- return import_node_path12.default.dirname(comparableWorktree) === import_node_path12.default.dirname(comparableRepo) && import_node_path12.default.basename(comparableWorktree).startsWith(legacyPrefix);
5212
+ const legacyPrefix = `${import_node_path15.default.basename(comparableRepo)}-`;
5213
+ return import_node_path15.default.dirname(comparableWorktree) === import_node_path15.default.dirname(comparableRepo) && import_node_path15.default.basename(comparableWorktree).startsWith(legacyPrefix);
4512
5214
  }
4513
5215
  function previewActions(devpodStatus, routeCount, includeRecord) {
4514
5216
  const actions = [
@@ -4636,17 +5338,6 @@ function revalidateCandidate(candidate, repoPath, transaction) {
4636
5338
  }
4637
5339
  };
4638
5340
  }
4639
- function recheckDevpodOwnership(record) {
4640
- const ownership = inspectDevpodWorkspaceOwnership(
4641
- listDevpodWorkspaces(),
4642
- record.devpodId,
4643
- record.worktreePath
4644
- );
4645
- if (ownership.status === "conflict") {
4646
- throw new Error(ownership.reason);
4647
- }
4648
- return ownership.status;
4649
- }
4650
5341
  function failedAction(resource, error) {
4651
5342
  return {
4652
5343
  resource,
@@ -4676,8 +5367,8 @@ function applyCandidate(candidate, repoPath) {
4676
5367
  const actions = [];
4677
5368
  let devpodStatus;
4678
5369
  try {
4679
- devpodStatus = recheckDevpodOwnership(record);
4680
- if (devpodStatus === "owned") runDevpodWorkspaceAction("delete", record.devpodId);
5370
+ const mutation = deleteOwnedDevpodWorkspace(record.devpodId, record.worktreePath);
5371
+ devpodStatus = mutation.status === "changed" ? "owned" : "absent";
4681
5372
  } catch (error) {
4682
5373
  return { ...fresh, actions: [failedAction("devpod", error)] };
4683
5374
  }
@@ -4762,11 +5453,12 @@ function applyWorkspaceGc(plan) {
4762
5453
  candidates
4763
5454
  };
4764
5455
  }
4765
- var import_node_path12;
5456
+ var import_node_path15;
4766
5457
  var init_workspace_gc = __esm({
4767
5458
  "src/core/workspace-gc.ts"() {
4768
5459
  "use strict";
4769
- import_node_path12 = __toESM(require("path"));
5460
+ import_node_path15 = __toESM(require("path"));
5461
+ init_devpod_mutation();
4770
5462
  init_devpod_workspaces();
4771
5463
  init_host_routes();
4772
5464
  init_repo_config();
@@ -4835,11 +5527,11 @@ function inspectPostgresCredentials(repoPath, config) {
4835
5527
  parseErrors.push(`${app.name}: ${composeFile} (${message})`);
4836
5528
  continue;
4837
5529
  }
4838
- if (!import_node_fs14.default.existsSync(absolutePath)) {
5530
+ if (!import_node_fs17.default.existsSync(absolutePath)) {
4839
5531
  continue;
4840
5532
  }
4841
5533
  try {
4842
- const raw = import_node_fs14.default.readFileSync(absolutePath, "utf-8");
5534
+ const raw = import_node_fs17.default.readFileSync(absolutePath, "utf-8");
4843
5535
  const parsed = import_yaml4.default.parse(raw);
4844
5536
  const root = asRecord2(parsed);
4845
5537
  const services = asRecord2(root?.services);
@@ -5102,7 +5794,7 @@ async function buildDoctorReport(options = {}) {
5102
5794
  const config = runtimeConfig.config;
5103
5795
  loadedConfig = config;
5104
5796
  loadedWorkspace = runtimeConfig.workspace;
5105
- const cliVersion = true ? "0.0.34" : "0.0.0-dev";
5797
+ const cliVersion = true ? "0.0.36" : "0.0.0-dev";
5106
5798
  const configVersion = config.devrouter?.version;
5107
5799
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
5108
5800
  addCheck(checks, {
@@ -5132,9 +5824,9 @@ async function buildDoctorReport(options = {}) {
5132
5824
  (app) => app.docker.composeFiles.map((filePath) => ({
5133
5825
  app: app.name,
5134
5826
  filePath,
5135
- absolutePath: import_node_path13.default.resolve(repo.path, filePath)
5827
+ absolutePath: import_node_path16.default.resolve(repo.path, filePath)
5136
5828
  }))
5137
- ).filter((entry) => !import_node_fs14.default.existsSync(entry.absolutePath));
5829
+ ).filter((entry) => !import_node_fs17.default.existsSync(entry.absolutePath));
5138
5830
  addCheck(checks, {
5139
5831
  id: "repo.compose-files",
5140
5832
  level: missingComposeFiles.length === 0 ? "ok" : "error",
@@ -5177,8 +5869,8 @@ async function buildDoctorReport(options = {}) {
5177
5869
  const missingHostCwds = config.apps.filter((app) => app.runtime === "host").map((app) => ({
5178
5870
  app: app.name,
5179
5871
  cwd: app.hostRun.cwd,
5180
- absolutePath: import_node_path13.default.resolve(repo.path, app.hostRun.cwd)
5181
- })).filter((entry) => !import_node_fs14.default.existsSync(entry.absolutePath));
5872
+ absolutePath: import_node_path16.default.resolve(repo.path, app.hostRun.cwd)
5873
+ })).filter((entry) => !import_node_fs17.default.existsSync(entry.absolutePath));
5182
5874
  addCheck(checks, {
5183
5875
  id: "repo.host-cwd",
5184
5876
  level: missingHostCwds.length === 0 ? "ok" : "error",
@@ -5322,12 +6014,12 @@ async function buildDoctorReport(options = {}) {
5322
6014
  nextSteps
5323
6015
  };
5324
6016
  }
5325
- var import_node_fs14, import_node_path13, import_yaml4, POSTGRES_DEFAULTS;
6017
+ var import_node_fs17, import_node_path16, import_yaml4, POSTGRES_DEFAULTS;
5326
6018
  var init_doctor = __esm({
5327
6019
  "src/core/doctor.ts"() {
5328
6020
  "use strict";
5329
- import_node_fs14 = __toESM(require("fs"));
5330
- import_node_path13 = __toESM(require("path"));
6021
+ import_node_fs17 = __toESM(require("fs"));
6022
+ import_node_path16 = __toESM(require("path"));
5331
6023
  import_yaml4 = __toESM(require("yaml"));
5332
6024
  init_docker();
5333
6025
  init_host_routes();
@@ -5585,12 +6277,12 @@ function parseSsPortListeners(stdout, port) {
5585
6277
  return listeners;
5586
6278
  }
5587
6279
  function findPortListeners(port) {
5588
- const result = (0, import_node_child_process8.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
6280
+ const result = (0, import_node_child_process11.spawnSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], {
5589
6281
  encoding: "utf-8"
5590
6282
  });
5591
6283
  if (result.error && result.error.code === "ENOENT") {
5592
6284
  if (process.platform === "linux") {
5593
- const ssResult = (0, import_node_child_process8.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
6285
+ const ssResult = (0, import_node_child_process11.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
5594
6286
  if (ssResult.status === 0 && ssResult.stdout) {
5595
6287
  return parseSsPortListeners(ssResult.stdout, port);
5596
6288
  }
@@ -5619,11 +6311,11 @@ function findPortListeners(port) {
5619
6311
  };
5620
6312
  });
5621
6313
  }
5622
- var import_node_child_process8;
6314
+ var import_node_child_process11;
5623
6315
  var init_ports = __esm({
5624
6316
  "src/util/ports.ts"() {
5625
6317
  "use strict";
5626
- import_node_child_process8 = require("child_process");
6318
+ import_node_child_process11 = require("child_process");
5627
6319
  }
5628
6320
  });
5629
6321
 
@@ -5679,20 +6371,26 @@ var init_up = __esm({
5679
6371
  });
5680
6372
 
5681
6373
  // src/core/devpod-environment.ts
5682
- function inspectWorkspaceContainers() {
5683
- const listed = (0, import_node_child_process9.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
5684
- encoding: "utf-8"
5685
- });
5686
- if (listed.status !== 0) {
5687
- throw new Error(
5688
- `docker ps failed: ${(listed.stderr || listed.stdout || "unknown error").trim()}`
5689
- );
6374
+ function inspectWorkspaceContainers(options) {
6375
+ let ids = options?.ids;
6376
+ if (!ids) {
6377
+ const listed = (0, import_node_child_process12.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
6378
+ encoding: "utf-8"
6379
+ });
6380
+ if (listed.status !== 0) {
6381
+ throw new Error(
6382
+ `docker ps failed: ${(listed.stderr || listed.stdout || listed.error?.message || "unknown error").trim()}`
6383
+ );
6384
+ }
6385
+ ids = listed.stdout.split(/\r?\n/).map((id) => id.trim()).filter(Boolean);
5690
6386
  }
5691
- const ids = listed.stdout.split(/\r?\n/).map((id) => id.trim()).filter(Boolean);
5692
6387
  if (ids.length === 0) return [];
5693
- const inspected = (0, import_node_child_process9.spawnSync)("docker", ["inspect", "--format", SAFE_INSPECT_TEMPLATE, ...ids], {
5694
- encoding: "utf-8"
5695
- });
6388
+ const template = options?.withSize ? SIZE_INSPECT_TEMPLATE : SAFE_INSPECT_TEMPLATE;
6389
+ const inspected = (0, import_node_child_process12.spawnSync)(
6390
+ "docker",
6391
+ ["inspect", ...options?.withSize ? ["--size"] : [], "--format", template, ...ids],
6392
+ { encoding: "utf-8" }
6393
+ );
5696
6394
  if (inspected.status !== 0) {
5697
6395
  throw new Error(
5698
6396
  `docker inspect failed: ${(inspected.stderr || inspected.stdout || "unknown error").trim()}`
@@ -5703,7 +6401,7 @@ function inspectWorkspaceContainers() {
5703
6401
  function workspaceAppContainers(containers, repoPath) {
5704
6402
  return containers.filter((container) => {
5705
6403
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
5706
- return Boolean(workingDir && sameWorkspacePath(workingDir, import_node_path14.default.join(repoPath, ".devcontainer"))) && container.mounts.some(
6404
+ return Boolean(workingDir && sameWorkspacePath(workingDir, import_node_path17.default.join(repoPath, ".devcontainer"))) && container.mounts.some(
5707
6405
  (mount) => mount.Type === "bind" && sameWorkspacePath(mount.Source, repoPath)
5708
6406
  );
5709
6407
  });
@@ -5726,14 +6424,18 @@ function resolveRunningWorkspaceContainer(repoPath) {
5726
6424
  }
5727
6425
  return { id: container.id, workspacePath: repoMount.Destination };
5728
6426
  }
5729
- var import_node_child_process9, import_node_path14, SAFE_INSPECT_TEMPLATE;
6427
+ var import_node_child_process12, import_node_path17, SAFE_INSPECT_TEMPLATE, SIZE_INSPECT_TEMPLATE;
5730
6428
  var init_devpod_environment = __esm({
5731
6429
  "src/core/devpod-environment.ts"() {
5732
6430
  "use strict";
5733
- import_node_child_process9 = require("child_process");
5734
- import_node_path14 = __toESM(require("path"));
6431
+ import_node_child_process12 = require("child_process");
6432
+ import_node_path17 = __toESM(require("path"));
5735
6433
  init_workspace();
5736
6434
  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}}}';
6435
+ SIZE_INSPECT_TEMPLATE = SAFE_INSPECT_TEMPLATE.replace(
6436
+ /}$/,
6437
+ ',"sizeRw":{{json (index . "SizeRw")}},"sizeRootFs":{{json (index . "SizeRootFs")}}}'
6438
+ );
5737
6439
  }
5738
6440
  });
5739
6441
 
@@ -5758,124 +6460,23 @@ function probeHttpRoute(host, options = {}) {
5758
6460
  args.push("--cacert", getMkcertRootCAPath({ repoPath: options.repoPath }));
5759
6461
  }
5760
6462
  args.push(url);
5761
- const result = (0, import_node_child_process10.spawnSync)("curl", args, { encoding: "utf-8" });
6463
+ const result = (0, import_node_child_process13.spawnSync)("curl", args, { encoding: "utf-8" });
5762
6464
  const stdout = result.stdout?.trim() ?? "";
5763
6465
  const status = /^\d{3}$/.test(stdout) ? Number(stdout) : void 0;
5764
6466
  const ok = result.status === 0 && status !== void 0 && status >= 100 && status < 500;
5765
6467
  const details = ok ? `HTTP ${status}` : [status === void 0 ? void 0 : `HTTP ${status}`, result.stderr?.trim()].filter(Boolean).join(": ") || `curl exited with status ${result.status ?? "unknown"}`;
5766
6468
  return { ok, status, details };
5767
6469
  }
5768
- var import_node_child_process10;
6470
+ var import_node_child_process13;
5769
6471
  var init_http_route_probe = __esm({
5770
6472
  "src/core/http-route-probe.ts"() {
5771
6473
  "use strict";
5772
- import_node_child_process10 = require("child_process");
6474
+ import_node_child_process13 = require("child_process");
5773
6475
  init_router();
5774
6476
  init_tls();
5775
6477
  }
5776
6478
  });
5777
6479
 
5778
- // src/core/managed-post-start.ts
5779
- function commandFailure(result) {
5780
- return [result.error?.message, result.stderr, result.stdout].filter(Boolean).map((value) => String(value).trim()).filter(Boolean).join("\n");
5781
- }
5782
- function readRegularFile(filePath) {
5783
- if (!import_node_fs15.default.existsSync(filePath) || !import_node_fs15.default.lstatSync(filePath).isFile()) return void 0;
5784
- return import_node_fs15.default.readFileSync(filePath, "utf-8");
5785
- }
5786
- function resolveProcessHelperPath() {
5787
- const candidates = [
5788
- import_node_path15.default.resolve(__dirname, "..", "bin", "devrouter-process"),
5789
- import_node_path15.default.resolve(__dirname, "..", "..", "bin", "devrouter-process")
5790
- ];
5791
- const helperPath = candidates.find((candidate) => import_node_fs15.default.existsSync(candidate));
5792
- if (!helperPath) {
5793
- throw new Error("Could not locate the packaged devrouter-process helper.");
5794
- }
5795
- return helperPath;
5796
- }
5797
- function resolveManagedPostStartPlan(repoPath) {
5798
- const adapterPath = import_node_path15.default.join(repoPath, MANAGED_ADAPTER_PATH);
5799
- const adapter = readRegularFile(adapterPath);
5800
- if (adapter === void 0) return { kind: "unmanaged" };
5801
- if (!adapter.includes(MANAGED_MARKER)) return { kind: "unmanaged" };
5802
- if (adapter.includes("DEVROUTER_PROCESS_HELPER")) {
5803
- return { kind: "runtime", adapterPath: MANAGED_ADAPTER_PATH };
5804
- }
5805
- const dockerfilePath = import_node_path15.default.join(repoPath, ".devcontainer", "Dockerfile");
5806
- const devcontainerPath = import_node_path15.default.join(repoPath, ".devcontainer", "devcontainer.json");
5807
- const dockerfile = readRegularFile(dockerfilePath) ?? "";
5808
- const devcontainer = readRegularFile(devcontainerPath) ?? "";
5809
- if (dockerfile.includes("devrouter-process") && devcontainer.includes("postStartCommand")) {
5810
- return { kind: "legacy" };
5811
- }
5812
- throw new Error(
5813
- "Managed post-start must use DEVROUTER_PROCESS_HELPER before removing the legacy image helper and postStartCommand. Regenerate or migrate the devcontainer, then retry devrouter ensure."
5814
- );
5815
- }
5816
- function runManagedPostStart(options) {
5817
- if (options.plan.kind !== "runtime") return;
5818
- const helper = import_node_fs15.default.readFileSync(resolveProcessHelperPath());
5819
- const delivered = (0, import_node_child_process11.spawnSync)(
5820
- "docker",
5821
- ["exec", "-i", options.container.id, "sh", "-c", DELIVERY_SCRIPT],
5822
- { input: helper, encoding: "utf-8" }
5823
- );
5824
- if (delivered.status !== 0) {
5825
- const details = commandFailure(delivered);
5826
- throw new Error(
5827
- `Could not deliver the managed process helper${details ? `: ${details}` : "."}`
5828
- );
5829
- }
5830
- const started = (0, import_node_child_process11.spawnSync)(
5831
- "docker",
5832
- [
5833
- "exec",
5834
- "--workdir",
5835
- options.container.workspacePath,
5836
- "--env",
5837
- `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
5838
- options.container.id,
5839
- "bash",
5840
- options.plan.adapterPath
5841
- ],
5842
- { stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit" }
5843
- );
5844
- if (started.status !== 0) {
5845
- const details = commandFailure(started);
5846
- throw new Error(`Managed post-start failed${details ? `: ${details}` : "."}`);
5847
- }
5848
- }
5849
- var import_node_child_process11, import_node_fs15, import_node_path15, MANAGED_MARKER, MANAGED_ADAPTER_PATH, RUNTIME_HELPER_PATH, DELIVERY_SCRIPT;
5850
- var init_managed_post_start = __esm({
5851
- "src/core/managed-post-start.ts"() {
5852
- "use strict";
5853
- import_node_child_process11 = require("child_process");
5854
- import_node_fs15 = __toESM(require("fs"));
5855
- import_node_path15 = __toESM(require("path"));
5856
- MANAGED_MARKER = "devrouter:managed devcontainer";
5857
- MANAGED_ADAPTER_PATH = ".devcontainer/post-start.sh";
5858
- RUNTIME_HELPER_PATH = "/tmp/devrouter/bin/devrouter-process";
5859
- DELIVERY_SCRIPT = `set -eu
5860
- umask 077
5861
- runtime_root=/tmp/devrouter
5862
- runtime_bin="$runtime_root/bin"
5863
- if [ -L "$runtime_root" ] || [ -L "$runtime_bin" ]; then
5864
- echo "Refusing symlinked devrouter runtime path." >&2
5865
- exit 1
5866
- fi
5867
- mkdir -p "$runtime_bin"
5868
- chmod 700 "$runtime_root" "$runtime_bin"
5869
- temporary="$(mktemp "$runtime_bin/.devrouter-process.XXXXXX")"
5870
- trap 'rm -f "$temporary"' EXIT
5871
- cat > "$temporary"
5872
- chmod 700 "$temporary"
5873
- mv -f "$temporary" "${RUNTIME_HELPER_PATH}"
5874
- trap - EXIT
5875
- `;
5876
- }
5877
- });
5878
-
5879
6480
  // src/core/route-publication.ts
5880
6481
  function routedAppsFromConfig(config) {
5881
6482
  return config.apps.filter((app) => app.kind !== "dependency");
@@ -5949,11 +6550,11 @@ var init_route_publication = __esm({
5949
6550
  // src/core/workspace-ensure.ts
5950
6551
  function assertOverlay(container, repoPath) {
5951
6552
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
5952
- if (!workingDir || !sameWorkspacePath(workingDir, import_node_path16.default.join(repoPath, ".devcontainer"))) {
6553
+ if (!workingDir || !sameWorkspacePath(workingDir, import_node_path18.default.join(repoPath, ".devcontainer"))) {
5953
6554
  throw new Error(`Container '${container.id}' does not belong to the exact worktree.`);
5954
6555
  }
5955
6556
  const configFiles = (container.labels["com.docker.compose.project.config_files"] ?? "").split(",").filter(Boolean);
5956
- const expectedOverlay = import_node_path16.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6557
+ const expectedOverlay = import_node_path18.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
5957
6558
  if (!configFiles.some((configFile) => sameWorkspacePath(configFile, expectedOverlay))) {
5958
6559
  throw new Error(`Container '${container.id}' was not started with ${DEVCONTAINER_OVERLAY}.`);
5959
6560
  }
@@ -6027,7 +6628,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
6027
6628
  target
6028
6629
  });
6029
6630
  if (target.kind === "linked") {
6030
- const workspaceEnv = (0, import_node_child_process12.spawnSync)(
6631
+ const workspaceEnv = (0, import_node_child_process14.spawnSync)(
6031
6632
  "docker",
6032
6633
  ["exec", appContainer.id, "printenv", "WORKSPACE"],
6033
6634
  { encoding: "utf-8" }
@@ -6037,7 +6638,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
6037
6638
  `Workspace app container must expose WORKSPACE='${target.workspace}' (got '${workspaceEnv.stdout.trim() || "(empty)"}').`
6038
6639
  );
6039
6640
  }
6040
- const devrouterWorkspaceEnv = (0, import_node_child_process12.spawnSync)(
6641
+ const devrouterWorkspaceEnv = (0, import_node_child_process14.spawnSync)(
6041
6642
  "docker",
6042
6643
  ["exec", appContainer.id, "printenv", "DEVROUTER_WORKSPACE"],
6043
6644
  { encoding: "utf-8" }
@@ -6048,7 +6649,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
6048
6649
  );
6049
6650
  }
6050
6651
  }
6051
- const gitCheck = (0, import_node_child_process12.spawnSync)(
6652
+ const gitCheck = (0, import_node_child_process14.spawnSync)(
6052
6653
  "docker",
6053
6654
  [
6054
6655
  "exec",
@@ -6135,63 +6736,14 @@ function resolvePrimaryTarget(repoPath) {
6135
6736
  }
6136
6737
  function isPrimaryCheckout(repoPath) {
6137
6738
  try {
6138
- return import_node_fs16.default.statSync(import_node_path16.default.join(repoPath, ".git")).isDirectory();
6739
+ return import_node_fs18.default.statSync(import_node_path18.default.join(repoPath, ".git")).isDirectory();
6139
6740
  } catch {
6140
6741
  return false;
6141
6742
  }
6142
6743
  }
6143
- function startDevpod(repoPath, target, recreate = false, quiet = false) {
6144
- const args = ["up", repoPath];
6145
- if (target.devpodId) {
6146
- args.push("--id", target.devpodId);
6147
- }
6148
- args.push("--open-ide=false");
6149
- if (target.kind === "linked") {
6150
- args.push(
6151
- "--workspace-env",
6152
- `WORKSPACE=${target.workspace}`,
6153
- "--workspace-env",
6154
- `DEVROUTER_WORKSPACE=${target.workspace}`
6155
- );
6156
- }
6157
- if (recreate) {
6158
- if (!target.devpodId) {
6159
- throw new Error("Cannot recreate a DevPod before its exact id is known.");
6160
- }
6161
- args.push("--recreate");
6162
- }
6163
- const env = { ...process.env };
6164
- if (target.kind === "linked") {
6165
- env.WORKSPACE = target.workspace;
6166
- env.DEVROUTER_WORKSPACE = target.workspace;
6167
- env.DEVROUTER_GIT_COMMON_DIR = target.gitCommonDir;
6168
- env.DEVCONTAINER_COMPOSE_OVERLAY = DEVCONTAINER_OVERLAY;
6169
- } else {
6170
- delete env.WORKSPACE;
6171
- delete env.DEVROUTER_WORKSPACE;
6172
- delete env.DEVROUTER_GIT_COMMON_DIR;
6173
- delete env.DEVCONTAINER_COMPOSE_OVERLAY;
6174
- }
6175
- const result = (0, import_node_child_process12.spawnSync)("devpod", args, {
6176
- stdio: quiet ? ["inherit", 2, "inherit"] : "inherit",
6177
- env
6178
- });
6179
- if (result.status !== 0) {
6180
- throw new Error(`devpod up failed for '${target.devpodId ?? repoPath}'.`);
6181
- }
6182
- }
6183
- function assertDevpodAttachment(repoPath, expectedId) {
6184
- const attached = selectDevpodWorkspace(listDevpodWorkspaces(), repoPath);
6185
- if (!attached || expectedId && attached.id !== expectedId) {
6186
- throw new Error(
6187
- `DevPod did not attach '${repoPath}'${expectedId ? ` as '${expectedId}'` : ""} after startup.`
6188
- );
6189
- }
6190
- return attached.id;
6191
- }
6192
6744
  function openUrls(urls) {
6193
6745
  for (const url of urls) {
6194
- const opened = (0, import_node_child_process12.spawnSync)("open", [url], { encoding: "utf-8" });
6746
+ const opened = (0, import_node_child_process14.spawnSync)("open", [url], { encoding: "utf-8" });
6195
6747
  if (opened.status !== 0) {
6196
6748
  process.stderr.write(`Warning: could not open '${url}'.
6197
6749
  `);
@@ -6221,8 +6773,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6221
6773
  const target = linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
6222
6774
  let devpodId = target.devpodId;
6223
6775
  if (target.kind === "linked") {
6224
- const overlayPath = import_node_path16.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6225
- if (!import_node_fs16.default.existsSync(overlayPath)) {
6776
+ const overlayPath = import_node_path18.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
6777
+ if (!import_node_fs18.default.existsSync(overlayPath)) {
6226
6778
  throw new Error(`Missing required DevPod compose overlay: ${overlayPath}`);
6227
6779
  }
6228
6780
  }
@@ -6233,16 +6785,16 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6233
6785
  );
6234
6786
  const apps = proxyAppsFromConfig(runtime.config);
6235
6787
  const parsedUpstreams = apps.map((app) => parseUpstream(app.upstream));
6236
- const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path16.default.basename(repoPath)) ?? "app";
6788
+ const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path18.default.basename(repoPath)) ?? "app";
6237
6789
  for (const [index, app] of apps.entries()) {
6238
- if (app.protocol === "tcp" && !parsedUpstreams[index].host.startsWith(`${aliasPrefix}-`)) {
6790
+ if (!parsedUpstreams[index].host.startsWith(`${aliasPrefix}-`)) {
6239
6791
  const owner = target.kind === "linked" ? "workspace" : "checkout";
6240
6792
  throw new Error(
6241
- `TCP app '${app.name}' must use a ${owner}-owned upstream beginning with '${aliasPrefix}-'.`
6793
+ `Proxy app '${app.name}' must use a ${owner}-owned upstream beginning with '${aliasPrefix}-'.`
6242
6794
  );
6243
6795
  }
6244
6796
  }
6245
- const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host).filter((host) => host.startsWith(`${aliasPrefix}-`));
6797
+ const upstreamHosts = parsedUpstreams.map((upstream) => upstream.host);
6246
6798
  const ownership = target.kind === "linked" ? {
6247
6799
  workspace: target.workspace,
6248
6800
  worktreePath: repoPath,
@@ -6254,9 +6806,25 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6254
6806
  }
6255
6807
  const currentTarget = () => target.kind === "linked" ? target : { ...target, devpodId };
6256
6808
  const startAndProveAttachment = (recreate = false) => {
6257
- startDevpod(repoPath, currentTarget(), recreate, options.quiet);
6258
- environmentStarted = true;
6259
- devpodId = assertDevpodAttachment(repoPath, devpodId);
6809
+ const requestedTarget = currentTarget();
6810
+ try {
6811
+ devpodId = startDevpodWorkspace({
6812
+ repoPath,
6813
+ devpodId: requestedTarget.devpodId,
6814
+ recreate,
6815
+ quiet: options.quiet,
6816
+ ...requestedTarget.kind === "linked" ? {
6817
+ workspace: {
6818
+ token: requestedTarget.workspace,
6819
+ gitCommonDir: requestedTarget.gitCommonDir
6820
+ }
6821
+ } : {}
6822
+ });
6823
+ environmentStarted = true;
6824
+ } catch (error) {
6825
+ if (error instanceof DevpodStartPostconditionError) environmentStarted = true;
6826
+ throw error;
6827
+ }
6260
6828
  if (ownership) {
6261
6829
  writeWorkspaceOwnership(repoPath, ownership);
6262
6830
  }
@@ -6357,14 +6925,15 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
6357
6925
  }
6358
6926
  });
6359
6927
  }
6360
- var import_node_child_process12, import_node_fs16, import_node_path16, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
6928
+ var import_node_child_process14, import_node_fs18, import_node_path18, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
6361
6929
  var init_workspace_ensure = __esm({
6362
6930
  "src/core/workspace-ensure.ts"() {
6363
6931
  "use strict";
6364
- import_node_child_process12 = require("child_process");
6365
- import_node_fs16 = __toESM(require("fs"));
6366
- import_node_path16 = __toESM(require("path"));
6932
+ import_node_child_process14 = require("child_process");
6933
+ import_node_fs18 = __toESM(require("fs"));
6934
+ import_node_path18 = __toESM(require("path"));
6367
6935
  init_devpod_environment();
6936
+ init_devpod_mutation();
6368
6937
  init_devpod_workspaces();
6369
6938
  init_host_routes();
6370
6939
  init_http_route_probe();
@@ -6438,20 +7007,20 @@ function warnMissingWorkspaceOwnership(repoPath) {
6438
7007
  );
6439
7008
  }
6440
7009
  function defaultWorktreePath(mainRepo, ws) {
6441
- return import_node_path17.default.join(mainRepo, "trees", ws);
7010
+ return import_node_path19.default.join(mainRepo, "trees", ws);
6442
7011
  }
6443
7012
  function assertDefaultWorktreeRootIgnored(mainRepo) {
6444
- const ignored = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "check-ignore", "-q", "--no-index", "trees/"], {
7013
+ const ignored = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "check-ignore", "-q", "--no-index", "trees/"], {
6445
7014
  encoding: "utf-8"
6446
7015
  });
6447
7016
  if (ignored.status !== 0) {
6448
7017
  throw new Error(
6449
- `Default worktree root '${import_node_path17.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path17.default.join(mainRepo, ".gitignore")}' or use --path.`
7018
+ `Default worktree root '${import_node_path19.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path19.default.join(mainRepo, ".gitignore")}' or use --path.`
6450
7019
  );
6451
7020
  }
6452
7021
  }
6453
7022
  function legacyDefaultWorktreePath(mainRepo, ws) {
6454
- return import_node_path17.default.join(import_node_path17.default.dirname(mainRepo), `${import_node_path17.default.basename(mainRepo)}-${ws}`);
7023
+ return import_node_path19.default.join(import_node_path19.default.dirname(mainRepo), `${import_node_path19.default.basename(mainRepo)}-${ws}`);
6455
7024
  }
6456
7025
  function teardownFallbackPath(mainRepo, workspace) {
6457
7026
  const candidates = [
@@ -6543,7 +7112,7 @@ function resolveWorkspaceTarget(mainRepo, target, worktrees, records) {
6543
7112
  function worktreeForRecord(worktrees, record) {
6544
7113
  return worktrees.find((candidate) => sameWorkspacePath(candidate.path, record.worktreePath));
6545
7114
  }
6546
- function devpodForTarget(target, worktrees, devpods) {
7115
+ function assertDevpodTargetSafe(target, worktrees, devpods) {
6547
7116
  if (target.record) {
6548
7117
  const status = inspectWorkspaceOwnership(target.record, worktrees, devpods);
6549
7118
  if (status.ownerStatus === "conflict") {
@@ -6551,26 +7120,25 @@ function devpodForTarget(target, worktrees, devpods) {
6551
7120
  `Workspace '${target.workspace}' ownership conflicts with live Git or DevPod evidence; no resources were changed.`
6552
7121
  );
6553
7122
  }
6554
- return status.devpodStatus === "owned" ? devpods.find((devpod) => devpod.id === target.record?.devpodId) : void 0;
7123
+ return;
6555
7124
  }
6556
7125
  const devpodId = target.workspace;
6557
7126
  const ownership = inspectDevpodWorkspaceOwnership(devpods, devpodId, target.worktreePath);
6558
7127
  if (ownership.status === "conflict") {
6559
7128
  throw new Error(ownership.reason);
6560
7129
  }
6561
- return ownership.status === "owned" ? ownership.workspace : void 0;
6562
7130
  }
6563
7131
  function assertFullDownPreflight(mainRepo, target) {
6564
7132
  if (sameWorkspacePath(target.worktreePath, mainRepo)) {
6565
7133
  throw new Error("Refusing to remove the primary Git checkout.");
6566
7134
  }
6567
- if (!target.worktree || target.worktree.prunable || !import_node_fs17.default.existsSync(target.worktreePath)) return;
7135
+ if (!target.worktree || target.worktree.prunable || !import_node_fs19.default.existsSync(target.worktreePath)) return;
6568
7136
  if (target.worktree.locked) {
6569
7137
  throw new Error(
6570
7138
  `Worktree '${target.worktreePath}' is locked; unlock it before workspace down.`
6571
7139
  );
6572
7140
  }
6573
- const status = (0, import_node_child_process13.spawnSync)(
7141
+ const status = (0, import_node_child_process15.spawnSync)(
6574
7142
  "git",
6575
7143
  ["-C", target.worktreePath, "status", "--porcelain", "--untracked-files=normal"],
6576
7144
  { encoding: "utf-8" }
@@ -6591,8 +7159,8 @@ async function workspaceUp(branch, opts = {}) {
6591
7159
  if (!ws) {
6592
7160
  throw new Error(`Branch '${branch}' does not yield a valid workspace token.`);
6593
7161
  }
6594
- const worktreePath = opts.path ? import_node_path17.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
6595
- if (import_node_fs17.default.existsSync(worktreePath)) {
7162
+ const worktreePath = opts.path ? import_node_path19.default.resolve(opts.path) : defaultWorktreePath(mainRepo, ws);
7163
+ if (import_node_fs19.default.existsSync(worktreePath)) {
6596
7164
  const registered = listGitWorktrees(mainRepo).find(
6597
7165
  (worktree) => sameWorkspacePath(worktree.path, worktreePath)
6598
7166
  );
@@ -6610,11 +7178,11 @@ async function workspaceUp(branch, opts = {}) {
6610
7178
  if (!opts.path) {
6611
7179
  assertDefaultWorktreeRootIgnored(mainRepo);
6612
7180
  }
6613
- const add = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
7181
+ const add = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "worktree", "add", worktreePath, branch], {
6614
7182
  encoding: "utf-8"
6615
7183
  });
6616
7184
  if (add.status !== 0) {
6617
- const addNew = (0, import_node_child_process13.spawnSync)(
7185
+ const addNew = (0, import_node_child_process15.spawnSync)(
6618
7186
  "git",
6619
7187
  ["-C", mainRepo, "worktree", "add", "-b", branch, worktreePath],
6620
7188
  {
@@ -6685,10 +7253,9 @@ function workspaceLs(repoPath) {
6685
7253
  }
6686
7254
  function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
6687
7255
  const devpods = listDevpodWorkspaces();
6688
- const devpod = devpodForTarget(resolved, worktrees, devpods);
6689
- if (devpod) {
6690
- runDevpodWorkspaceAction(action2 === "stop" ? "stop" : "delete", devpod.id);
6691
- }
7256
+ assertDevpodTargetSafe(resolved, worktrees, devpods);
7257
+ const devpodId = resolved.record?.devpodId ?? resolved.workspace;
7258
+ const mutation = action2 === "stop" ? stopOwnedDevpodWorkspace(devpodId, resolved.worktreePath) : deleteOwnedDevpodWorkspace(devpodId, resolved.worktreePath);
6692
7259
  const routes = removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
6693
7260
  if (!quiet) {
6694
7261
  process.stdout.write(
@@ -6697,9 +7264,9 @@ function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
6697
7264
  );
6698
7265
  }
6699
7266
  return {
6700
- ...devpod ? { devpodId: devpod.id } : {},
7267
+ ...mutation.status === "changed" ? { devpodId } : {},
6701
7268
  freedRoutes: routes.length,
6702
- providerChanged: Boolean(devpod),
7269
+ providerChanged: mutation.status === "changed",
6703
7270
  workspace: resolved.workspace
6704
7271
  };
6705
7272
  }
@@ -6713,10 +7280,15 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
6713
7280
  if (removeWorktree) {
6714
7281
  assertFullDownPreflight(mainRepo, resolved);
6715
7282
  }
6716
- const result = mutateWorkspaceRuntime(action2, resolved, worktrees, opts.quiet);
7283
+ const result = mutateWorkspaceRuntime(
7284
+ action2 === "stop" ? "stop" : "delete",
7285
+ resolved,
7286
+ worktrees,
7287
+ opts.quiet
7288
+ );
6717
7289
  if (removeWorktree) {
6718
- if (resolved.worktree && !resolved.worktree.prunable && import_node_fs17.default.existsSync(resolved.worktreePath)) {
6719
- const rm = (0, import_node_child_process13.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", resolved.worktreePath], {
7290
+ if (resolved.worktree && !resolved.worktree.prunable && import_node_fs19.default.existsSync(resolved.worktreePath)) {
7291
+ const rm = (0, import_node_child_process15.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", resolved.worktreePath], {
6720
7292
  encoding: "utf-8"
6721
7293
  });
6722
7294
  if (rm.status !== 0) {
@@ -6734,9 +7306,9 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
6734
7306
  }
6735
7307
  return result;
6736
7308
  };
6737
- return resolved.worktree && !resolved.worktree.prunable && import_node_fs17.default.existsSync(resolved.worktreePath) ? withWorkspaceLifecycleLock(resolved.worktreePath, operation) : operation();
7309
+ return resolved.worktree && !resolved.worktree.prunable && import_node_fs19.default.existsSync(resolved.worktreePath) ? withWorkspaceLifecycleLock(resolved.worktreePath, operation) : operation();
6738
7310
  }
6739
- async function workspaceStopOwnedPath(worktreePath, opts = {}) {
7311
+ async function mutateWorkspaceOwnedPath(action2, worktreePath, opts = {}) {
6740
7312
  const mainRepo = resolveRepoPath(opts.repoPath);
6741
7313
  return withWorkspaceLifecycleLock(worktreePath, async () => {
6742
7314
  const worktrees = identifyGitWorktrees(mainRepo);
@@ -6754,22 +7326,29 @@ async function workspaceStopOwnedPath(worktreePath, opts = {}) {
6754
7326
  worktree: worktreeForRecord(worktrees, record),
6755
7327
  record
6756
7328
  };
6757
- return mutateWorkspaceRuntime("stop", resolved, worktrees, opts.quiet);
7329
+ return mutateWorkspaceRuntime(action2, resolved, worktrees, opts.quiet);
6758
7330
  });
6759
7331
  }
7332
+ async function workspaceStopOwnedPath(worktreePath, opts = {}) {
7333
+ return mutateWorkspaceOwnedPath("stop", worktreePath, opts);
7334
+ }
7335
+ async function workspaceDeleteOwnedPath(worktreePath, opts = {}) {
7336
+ return mutateWorkspaceOwnedPath("delete", worktreePath, opts);
7337
+ }
6760
7338
  async function workspaceStop(target, opts = {}) {
6761
7339
  return runWorkspaceLifecycle("stop", target, opts);
6762
7340
  }
6763
7341
  async function workspaceDown(target, opts = {}) {
6764
7342
  return runWorkspaceLifecycle("down", target, opts);
6765
7343
  }
6766
- var import_node_child_process13, import_node_fs17, import_node_path17;
7344
+ var import_node_child_process15, import_node_fs19, import_node_path19;
6767
7345
  var init_workspace_lifecycle = __esm({
6768
7346
  "src/core/workspace-lifecycle.ts"() {
6769
7347
  "use strict";
6770
- import_node_child_process13 = require("child_process");
6771
- import_node_fs17 = __toESM(require("fs"));
6772
- import_node_path17 = __toESM(require("path"));
7348
+ import_node_child_process15 = require("child_process");
7349
+ import_node_fs19 = __toESM(require("fs"));
7350
+ import_node_path19 = __toESM(require("path"));
7351
+ init_devpod_mutation();
6773
7352
  init_devpod_workspaces();
6774
7353
  init_repo_config();
6775
7354
  init_route_state();
@@ -6780,7 +7359,7 @@ var init_workspace_lifecycle = __esm({
6780
7359
  });
6781
7360
 
6782
7361
  // src/core/environment-stop.ts
6783
- async function environmentStop(repoPath) {
7362
+ async function environmentStop(repoPath, options = {}) {
6784
7363
  const linked = isLinkedWorktree(repoPath);
6785
7364
  const workspace = linked ? resolveWorktreeWorkspace(repoPath) : void 0;
6786
7365
  if (linked && !workspace) {
@@ -6799,25 +7378,27 @@ async function environmentStop(repoPath) {
6799
7378
  if (!mainRepo) {
6800
7379
  throw new Error(`Could not resolve the primary checkout for '${repoPath}'.`);
6801
7380
  }
6802
- const result = await workspaceStopOwnedPath(record.worktreePath, {
6803
- quiet: true,
6804
- repoPath: mainRepo
6805
- });
7381
+ const result = await (options.delete ? workspaceDeleteOwnedPath : workspaceStopOwnedPath)(
7382
+ record.worktreePath,
7383
+ {
7384
+ quiet: true,
7385
+ repoPath: mainRepo
7386
+ }
7387
+ );
6806
7388
  return {
6807
7389
  kind: "linked",
6808
7390
  repoPath,
6809
7391
  workspace: result.workspace,
6810
7392
  ...result.devpodId ? { devpodId: result.devpodId } : {},
6811
- stopped: result.providerChanged,
7393
+ stopped: !options.delete && result.providerChanged,
7394
+ ...options.delete ? { deleted: result.providerChanged } : {},
6812
7395
  freedRoutes: result.freedRoutes
6813
7396
  };
6814
7397
  }
6815
7398
  }
6816
7399
  return withWorkspaceLifecycleLock(repoPath, async () => {
6817
7400
  const devpod = selectDevpodWorkspace(listDevpodWorkspaces(), repoPath);
6818
- if (devpod) {
6819
- runDevpodWorkspaceAction("stop", devpod.id);
6820
- }
7401
+ const mutation = devpod ? options.delete ? deleteOwnedDevpodWorkspace(devpod.id, repoPath) : stopOwnedDevpodWorkspace(devpod.id, repoPath) : { status: "absent" };
6821
7402
  const removedRoutes = removeHostRoutesWhere(
6822
7403
  (route) => sameWorkspacePath(route.repoPath, repoPath)
6823
7404
  );
@@ -6825,8 +7406,9 @@ async function environmentStop(repoPath) {
6825
7406
  kind: linked ? "linked" : "primary",
6826
7407
  repoPath,
6827
7408
  ...workspace ? { workspace } : {},
6828
- ...devpod ? { devpodId: devpod.id } : {},
6829
- stopped: Boolean(devpod),
7409
+ ...mutation.status === "changed" && devpod ? { devpodId: devpod.id } : {},
7410
+ stopped: !options.delete && mutation.status === "changed",
7411
+ ...options.delete ? { deleted: mutation.status === "changed" } : {},
6830
7412
  freedRoutes: removedRoutes.length
6831
7413
  };
6832
7414
  });
@@ -6834,6 +7416,7 @@ async function environmentStop(repoPath) {
6834
7416
  var init_environment_stop = __esm({
6835
7417
  "src/core/environment-stop.ts"() {
6836
7418
  "use strict";
7419
+ init_devpod_mutation();
6837
7420
  init_devpod_workspaces();
6838
7421
  init_host_routes();
6839
7422
  init_workspace();
@@ -6849,19 +7432,19 @@ __export(stop_exports, {
6849
7432
  });
6850
7433
  async function runStopCommand(options) {
6851
7434
  const repoPath = resolveGitCheckoutPath(options.path);
6852
- const result = await environmentStop(repoPath);
7435
+ const result = await environmentStop(repoPath, { delete: options.delete });
6853
7436
  if (options.json) {
6854
7437
  process.stdout.write(`${JSON.stringify(result, null, 2)}
6855
7438
  `);
6856
7439
  return;
6857
7440
  }
6858
7441
  const label = result.kind === "primary" ? "Primary checkout" : `Workspace '${result.workspace}'`;
6859
- if (!result.stopped && result.freedRoutes === 0) {
7442
+ if (!result.stopped && !result.deleted && result.freedRoutes === 0) {
6860
7443
  process.stdout.write(`${label} is already stopped; no routes needed removal.
6861
7444
  `);
6862
7445
  return;
6863
7446
  }
6864
- const provider = result.stopped ? `Stopped DevPod '${result.devpodId}'. ` : "";
7447
+ const provider = result.deleted ? `Deleted DevPod '${result.devpodId}'. ` : result.stopped ? `Stopped DevPod '${result.devpodId}'. ` : "";
6865
7448
  process.stdout.write(
6866
7449
  `${provider}Freed ${result.freedRoutes} route(s) for ${label.toLowerCase()}.
6867
7450
  `
@@ -6891,7 +7474,7 @@ function ensureGuidance(repoPath) {
6891
7474
  return `Run 'devrouter ensure ${repoPath}' first.`;
6892
7475
  }
6893
7476
  function assertRunningDevpod(devpodId, repoPath) {
6894
- const result = (0, import_node_child_process14.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
7477
+ const result = (0, import_node_child_process16.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
6895
7478
  encoding: "utf-8"
6896
7479
  });
6897
7480
  if (result.status !== 0) {
@@ -6929,7 +7512,7 @@ async function devpodExec(repoPath, command) {
6929
7512
  }
6930
7513
  assertRunningDevpod(devpod.id, repoPath);
6931
7514
  const workspaceDirectory = resolveWorkspaceDirectory(repoPath);
6932
- const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto4.randomUUID)()}__:`;
7515
+ const statusMarker = `__DEVROUTER_EXIT_${(0, import_node_crypto5.randomUUID)()}__:`;
6933
7516
  const statusMarkerBytes = Buffer.from(statusMarker, "ascii");
6934
7517
  const literalCommand = command.map(quotePosixArg).join(" ");
6935
7518
  const wrappedCommand = `${literalCommand}; __devrouter_status=$?; printf '${statusMarker}%s\\n' "$__devrouter_status" >&2; exit 0`;
@@ -6947,7 +7530,7 @@ async function devpodExec(repoPath, command) {
6947
7530
  wrappedCommand
6948
7531
  ];
6949
7532
  return new Promise((resolve, reject) => {
6950
- const child = (0, import_node_child_process14.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
7533
+ const child = (0, import_node_child_process16.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
6951
7534
  let pending = Buffer.alloc(0);
6952
7535
  let statusBytes = Buffer.alloc(0);
6953
7536
  let readingStatus = false;
@@ -7006,12 +7589,12 @@ async function devpodExec(repoPath, command) {
7006
7589
  });
7007
7590
  });
7008
7591
  }
7009
- var import_node_child_process14, import_node_crypto4, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
7592
+ var import_node_child_process16, import_node_crypto5, DEVPOD_MISSING_EXIT_STATUS_DIAGNOSTIC;
7010
7593
  var init_devpod_exec = __esm({
7011
7594
  "src/core/devpod-exec.ts"() {
7012
7595
  "use strict";
7013
- import_node_child_process14 = require("child_process");
7014
- import_node_crypto4 = require("crypto");
7596
+ import_node_child_process16 = require("child_process");
7597
+ import_node_crypto5 = require("crypto");
7015
7598
  init_devpod_environment();
7016
7599
  init_devpod_workspaces();
7017
7600
  init_workspace();
@@ -7195,7 +7778,7 @@ async function runOpenCommand(name) {
7195
7778
  );
7196
7779
  return;
7197
7780
  }
7198
- const result = (0, import_node_child_process15.spawnSync)("open", [url], { encoding: "utf-8" });
7781
+ const result = (0, import_node_child_process17.spawnSync)("open", [url], { encoding: "utf-8" });
7199
7782
  if (result.status !== 0) {
7200
7783
  const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
7201
7784
  throw new Error(`Unable to open '${url}': ${details || "unknown error"}`);
@@ -7203,11 +7786,11 @@ async function runOpenCommand(name) {
7203
7786
  process.stdout.write(`Opened ${url}
7204
7787
  `);
7205
7788
  }
7206
- var import_node_child_process15;
7789
+ var import_node_child_process17;
7207
7790
  var init_open = __esm({
7208
7791
  "src/commands/open.ts"() {
7209
7792
  "use strict";
7210
- import_node_child_process15 = require("child_process");
7793
+ import_node_child_process17 = require("child_process");
7211
7794
  init_docker();
7212
7795
  init_host_routes();
7213
7796
  init_repo_config();
@@ -7230,7 +7813,7 @@ async function runLogsCommand(options) {
7230
7813
  const args = ["logs", "--tail", tail, ROUTER_CONTAINER_NAME];
7231
7814
  if (options.follow) {
7232
7815
  args.splice(1, 0, "-f");
7233
- const child = (0, import_node_child_process16.spawn)("docker", args, { stdio: "inherit" });
7816
+ const child = (0, import_node_child_process18.spawn)("docker", args, { stdio: "inherit" });
7234
7817
  const onSignal = () => {
7235
7818
  child.kill("SIGTERM");
7236
7819
  };
@@ -7244,17 +7827,17 @@ async function runLogsCommand(options) {
7244
7827
  });
7245
7828
  });
7246
7829
  } else {
7247
- const result = (0, import_node_child_process16.spawnSync)("docker", args, { stdio: "inherit" });
7830
+ const result = (0, import_node_child_process18.spawnSync)("docker", args, { stdio: "inherit" });
7248
7831
  if (result.status !== 0) {
7249
7832
  throw new Error("Failed to retrieve router logs.");
7250
7833
  }
7251
7834
  }
7252
7835
  }
7253
- var import_node_child_process16;
7836
+ var import_node_child_process18;
7254
7837
  var init_logs = __esm({
7255
7838
  "src/commands/logs.ts"() {
7256
7839
  "use strict";
7257
- import_node_child_process16 = require("child_process");
7840
+ import_node_child_process18 = require("child_process");
7258
7841
  init_docker();
7259
7842
  init_router();
7260
7843
  }
@@ -7294,17 +7877,17 @@ function asRecord3(value) {
7294
7877
  return value;
7295
7878
  }
7296
7879
  function readJson(filePath) {
7297
- if (!import_node_fs18.default.existsSync(filePath)) {
7880
+ if (!import_node_fs20.default.existsSync(filePath)) {
7298
7881
  return void 0;
7299
7882
  }
7300
7883
  try {
7301
- return JSON.parse(import_node_fs18.default.readFileSync(filePath, "utf-8"));
7884
+ return JSON.parse(import_node_fs20.default.readFileSync(filePath, "utf-8"));
7302
7885
  } catch {
7303
7886
  return void 0;
7304
7887
  }
7305
7888
  }
7306
7889
  function relative(repoPath, filePath) {
7307
- return import_node_path18.default.relative(repoPath, filePath) || ".";
7890
+ return import_node_path20.default.relative(repoPath, filePath) || ".";
7308
7891
  }
7309
7892
  function redactEnvAssignments(value) {
7310
7893
  return value.replace(
@@ -7345,7 +7928,7 @@ function inspectPackageManager(repoPath, pkg) {
7345
7928
  ["bun.lock", "bun"]
7346
7929
  ];
7347
7930
  for (const [fileName, name] of lockfiles) {
7348
- if (import_node_fs18.default.existsSync(import_node_path18.default.join(repoPath, fileName))) {
7931
+ if (import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))) {
7349
7932
  return { name, source: fileName };
7350
7933
  }
7351
7934
  }
@@ -7360,9 +7943,9 @@ function inspectNode(repoPath, pkg) {
7360
7943
  if (typeof engines?.node === "string") {
7361
7944
  return { version: engines.node, source: "package.json:engines.node" };
7362
7945
  }
7363
- const nvmrc = import_node_path18.default.join(repoPath, ".nvmrc");
7364
- if (import_node_fs18.default.existsSync(nvmrc)) {
7365
- const version = import_node_fs18.default.readFileSync(nvmrc, "utf-8").trim();
7946
+ const nvmrc = import_node_path20.default.join(repoPath, ".nvmrc");
7947
+ if (import_node_fs20.default.existsSync(nvmrc)) {
7948
+ const version = import_node_fs20.default.readFileSync(nvmrc, "utf-8").trim();
7366
7949
  return { version, source: ".nvmrc" };
7367
7950
  }
7368
7951
  return void 0;
@@ -7423,7 +8006,7 @@ function configuredComposeFiles(repoPath) {
7423
8006
  const files = config.apps.filter(
7424
8007
  (app) => app.runtime === "docker"
7425
8008
  ).flatMap((app) => app.docker.composeFiles).filter(
7426
- (fileName) => !import_node_path18.default.isAbsolute(fileName) && !import_node_path18.default.normalize(fileName).startsWith("..")
8009
+ (fileName) => !import_node_path20.default.isAbsolute(fileName) && !import_node_path20.default.normalize(fileName).startsWith("..")
7427
8010
  );
7428
8011
  return Array.from(new Set(files));
7429
8012
  } catch {
@@ -7441,7 +8024,7 @@ function composeFiles(repoPath) {
7441
8024
  ...configuredComposeFiles(repoPath)
7442
8025
  ];
7443
8026
  return Array.from(new Set(candidates)).filter(
7444
- (fileName) => import_node_fs18.default.existsSync(import_node_path18.default.join(repoPath, fileName))
8027
+ (fileName) => import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))
7445
8028
  );
7446
8029
  }
7447
8030
  function stringArray(value) {
@@ -7479,7 +8062,7 @@ function inspectServices(repoPath) {
7479
8062
  const services = [];
7480
8063
  for (const fileName of composeFiles(repoPath)) {
7481
8064
  try {
7482
- const parsed = import_yaml5.default.parse(import_node_fs18.default.readFileSync(import_node_path18.default.join(repoPath, fileName), "utf-8"));
8065
+ const parsed = import_yaml5.default.parse(import_node_fs20.default.readFileSync(import_node_path20.default.join(repoPath, fileName), "utf-8"));
7483
8066
  const serviceMap = asRecord3(asRecord3(parsed)?.services);
7484
8067
  for (const [name, value] of Object.entries(serviceMap ?? {})) {
7485
8068
  const service = asRecord3(value);
@@ -7514,8 +8097,8 @@ function inspectServices(repoPath) {
7514
8097
  return services;
7515
8098
  }
7516
8099
  function inspectEnvFiles(repoPath) {
7517
- const files = import_node_fs18.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
7518
- const content = import_node_fs18.default.readFileSync(import_node_path18.default.join(repoPath, fileName), "utf-8");
8100
+ const files = import_node_fs20.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
8101
+ const content = import_node_fs20.default.readFileSync(import_node_path20.default.join(repoPath, fileName), "utf-8");
7519
8102
  const names = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#") && line.includes("=")).map((line) => line.split("=")[0]?.trim()).filter((name) => Boolean(name)).sort();
7520
8103
  return { path: fileName, names };
7521
8104
  });
@@ -7529,18 +8112,18 @@ function inspectEnvFiles(repoPath) {
7529
8112
  };
7530
8113
  }
7531
8114
  function inspectDevcontainer(repoPath) {
7532
- const dir = import_node_path18.default.join(repoPath, ".devcontainer");
7533
- if (!import_node_fs18.default.existsSync(dir)) {
8115
+ const dir = import_node_path20.default.join(repoPath, ".devcontainer");
8116
+ if (!import_node_fs20.default.existsSync(dir)) {
7534
8117
  return { exists: false, files: [] };
7535
8118
  }
7536
8119
  return {
7537
8120
  exists: true,
7538
- files: import_node_fs18.default.readdirSync(dir).filter((fileName) => import_node_fs18.default.statSync(import_node_path18.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
8121
+ files: import_node_fs20.default.readdirSync(dir).filter((fileName) => import_node_fs20.default.statSync(import_node_path20.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
7539
8122
  };
7540
8123
  }
7541
8124
  function inspectDevrouter(repoPath) {
7542
8125
  const configPath = getRepoConfigPath(repoPath);
7543
- if (!import_node_fs18.default.existsSync(configPath)) {
8126
+ if (!import_node_fs20.default.existsSync(configPath)) {
7544
8127
  return {
7545
8128
  exists: false,
7546
8129
  configPath,
@@ -7584,15 +8167,15 @@ function inspectAgentGuidance(repoPath) {
7584
8167
  ["AGENTS.md", "agents"],
7585
8168
  ["CLAUDE.md", "claude"]
7586
8169
  ]) {
7587
- if (import_node_fs18.default.existsSync(import_node_path18.default.join(repoPath, fileName))) {
8170
+ if (import_node_fs20.default.existsSync(import_node_path20.default.join(repoPath, fileName))) {
7588
8171
  results.push({ path: fileName, kind });
7589
8172
  }
7590
8173
  }
7591
- const skillsDir = import_node_path18.default.join(repoPath, ".agents", "skills");
7592
- if (import_node_fs18.default.existsSync(skillsDir)) {
7593
- for (const name of import_node_fs18.default.readdirSync(skillsDir).sort()) {
7594
- const skillPath = import_node_path18.default.join(skillsDir, name, "SKILL.md");
7595
- if (import_node_fs18.default.existsSync(skillPath)) {
8174
+ const skillsDir = import_node_path20.default.join(repoPath, ".agents", "skills");
8175
+ if (import_node_fs20.default.existsSync(skillsDir)) {
8176
+ for (const name of import_node_fs20.default.readdirSync(skillsDir).sort()) {
8177
+ const skillPath = import_node_path20.default.join(skillsDir, name, "SKILL.md");
8178
+ if (import_node_fs20.default.existsSync(skillPath)) {
7596
8179
  results.push({ path: relative(repoPath, skillPath), kind: "skill" });
7597
8180
  }
7598
8181
  }
@@ -7635,7 +8218,7 @@ function buildIssues(report) {
7635
8218
  }
7636
8219
  function inspectRepo(options = {}) {
7637
8220
  const repoPath = resolveRepoPath(options.repo);
7638
- const pkg = readJson(import_node_path18.default.join(repoPath, "package.json"));
8221
+ const pkg = readJson(import_node_path20.default.join(repoPath, "package.json"));
7639
8222
  const scripts = inspectScripts(pkg);
7640
8223
  const reportWithoutIssues = {
7641
8224
  repoPath,
@@ -7654,12 +8237,12 @@ function inspectRepo(options = {}) {
7654
8237
  issues: buildIssues(reportWithoutIssues)
7655
8238
  };
7656
8239
  }
7657
- var import_node_fs18, import_node_path18, import_yaml5;
8240
+ var import_node_fs20, import_node_path20, import_yaml5;
7658
8241
  var init_repo_inspect = __esm({
7659
8242
  "src/core/repo-inspect.ts"() {
7660
8243
  "use strict";
7661
- import_node_fs18 = __toESM(require("fs"));
7662
- import_node_path18 = __toESM(require("path"));
8244
+ import_node_fs20 = __toESM(require("fs"));
8245
+ import_node_path20 = __toESM(require("path"));
7663
8246
  import_yaml5 = __toESM(require("yaml"));
7664
8247
  init_repo_config();
7665
8248
  }
@@ -7764,7 +8347,7 @@ function requiredFileChecks(repoPath) {
7764
8347
  ".devcontainer/docker-compose.yml",
7765
8348
  ".devrouter.yml"
7766
8349
  ];
7767
- const missing = required.filter((fileName) => !import_node_fs19.default.existsSync(import_node_path19.default.join(repoPath, fileName)));
8350
+ const missing = required.filter((fileName) => !import_node_fs21.default.existsSync(import_node_path21.default.join(repoPath, fileName)));
7768
8351
  return {
7769
8352
  id: "repo.devcontainer.verify-files",
7770
8353
  level: missing.length === 0 ? "ok" : "error",
@@ -7983,12 +8566,12 @@ async function verifyDevcontainer(options = {}) {
7983
8566
  nextSteps: collectNextSteps3(checks)
7984
8567
  };
7985
8568
  }
7986
- var import_node_fs19, import_node_path19;
8569
+ var import_node_fs21, import_node_path21;
7987
8570
  var init_devcontainer_verify = __esm({
7988
8571
  "src/core/devcontainer-verify.ts"() {
7989
8572
  "use strict";
7990
- import_node_fs19 = __toESM(require("fs"));
7991
- import_node_path19 = __toESM(require("path"));
8573
+ import_node_fs21 = __toESM(require("fs"));
8574
+ import_node_path21 = __toESM(require("path"));
7992
8575
  init_capabilities();
7993
8576
  init_doctor();
7994
8577
  init_host_routes();
@@ -8284,7 +8867,7 @@ function packageManagerIssues(repo) {
8284
8867
  }
8285
8868
  function plannedFiles(repoPath, version) {
8286
8869
  const repo = inspectRepo({ repo: repoPath });
8287
- const projectName = sanitizeProjectName(import_node_path20.default.basename(repo.repoPath));
8870
+ const projectName = sanitizeProjectName(import_node_path22.default.basename(repo.repoPath));
8288
8871
  const nodeMajor = majorVersion(repo.node?.version, "24");
8289
8872
  const pnpmVersion = repo.packageManager?.name === "pnpm" && repo.packageManager.version ? repo.packageManager.version : DEFAULT_PNPM_VERSION;
8290
8873
  const port = inferPort2(repo);
@@ -8329,8 +8912,8 @@ function plannedFiles(repoPath, version) {
8329
8912
  };
8330
8913
  }
8331
8914
  function classifyFile(repoPath, file) {
8332
- const absolutePath = import_node_path20.default.join(repoPath, file.relativePath);
8333
- if (!import_node_fs20.default.existsSync(absolutePath)) {
8915
+ const absolutePath = import_node_path22.default.join(repoPath, file.relativePath);
8916
+ if (!import_node_fs22.default.existsSync(absolutePath)) {
8334
8917
  return {
8335
8918
  path: file.relativePath,
8336
8919
  action: "create",
@@ -8338,7 +8921,7 @@ function classifyFile(repoPath, file) {
8338
8921
  bytes: Buffer.byteLength(file.content)
8339
8922
  };
8340
8923
  }
8341
- const current = import_node_fs20.default.readFileSync(absolutePath, "utf-8");
8924
+ const current = import_node_fs22.default.readFileSync(absolutePath, "utf-8");
8342
8925
  if (!current.includes(MANAGED_MARKER2)) {
8343
8926
  return {
8344
8927
  path: file.relativePath,
@@ -8395,11 +8978,11 @@ function buildPlan(repoPath, dryRun, version) {
8395
8978
  };
8396
8979
  }
8397
8980
  function writeFile(repoPath, file) {
8398
- const absolutePath = import_node_path20.default.join(repoPath, file.relativePath);
8399
- import_node_fs20.default.mkdirSync(import_node_path20.default.dirname(absolutePath), { recursive: true });
8400
- import_node_fs20.default.writeFileSync(absolutePath, file.content, "utf-8");
8981
+ const absolutePath = import_node_path22.default.join(repoPath, file.relativePath);
8982
+ import_node_fs22.default.mkdirSync(import_node_path22.default.dirname(absolutePath), { recursive: true });
8983
+ import_node_fs22.default.writeFileSync(absolutePath, file.content, "utf-8");
8401
8984
  if (file.executable) {
8402
- import_node_fs20.default.chmodSync(absolutePath, 493);
8985
+ import_node_fs22.default.chmodSync(absolutePath, 493);
8403
8986
  }
8404
8987
  }
8405
8988
  function writeDevcontainer(options = {}) {
@@ -8437,12 +9020,12 @@ function writeDevcontainer(options = {}) {
8437
9020
  nextSteps: postWriteNextSteps(repoPath)
8438
9021
  };
8439
9022
  }
8440
- var import_node_fs20, import_node_path20, MANAGED_MARKER2, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
9023
+ var import_node_fs22, import_node_path22, MANAGED_MARKER2, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
8441
9024
  var init_devcontainer_write = __esm({
8442
9025
  "src/core/devcontainer-write.ts"() {
8443
9026
  "use strict";
8444
- import_node_fs20 = __toESM(require("fs"));
8445
- import_node_path20 = __toESM(require("path"));
9027
+ import_node_fs22 = __toESM(require("fs"));
9028
+ import_node_path22 = __toESM(require("path"));
8446
9029
  init_repo_config();
8447
9030
  init_repo_inspect();
8448
9031
  MANAGED_MARKER2 = "devrouter:managed devcontainer";
@@ -8833,7 +9416,7 @@ function sanitizeRouterId(value) {
8833
9416
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
8834
9417
  }
8835
9418
  function repoHash(repoPath) {
8836
- return (0, import_node_crypto5.createHash)("sha1").update(import_node_path21.default.resolve(repoPath)).digest("hex").slice(0, 12);
9419
+ return (0, import_node_crypto6.createHash)("sha1").update(import_node_path23.default.resolve(repoPath)).digest("hex").slice(0, 12);
8837
9420
  }
8838
9421
  function asDockerApp(app) {
8839
9422
  return app.runtime === "docker";
@@ -8912,11 +9495,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
8912
9495
  if (dockerApps.length === 0) {
8913
9496
  throw new Error("No docker apps selected to prepare compose overlay.");
8914
9497
  }
8915
- const cachePath = import_node_path21.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
8916
- import_node_fs21.default.mkdirSync(cachePath, { recursive: true });
8917
- const overlayPath = import_node_path21.default.join(cachePath, "compose.devrouter.yml");
9498
+ const cachePath = import_node_path23.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
9499
+ import_node_fs23.default.mkdirSync(cachePath, { recursive: true });
9500
+ const overlayPath = import_node_path23.default.join(cachePath, "compose.devrouter.yml");
8918
9501
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
8919
- import_node_fs21.default.writeFileSync(overlayPath, import_yaml6.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
9502
+ import_node_fs23.default.writeFileSync(overlayPath, import_yaml6.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
8920
9503
  return {
8921
9504
  overlayPath,
8922
9505
  composeFiles: ensureComposeFiles(dockerApps),
@@ -8930,7 +9513,7 @@ function runDockerComposeUp(repoPath, composeFiles2, overlayPath, services) {
8930
9513
  fileArgs.push("-f", resolved);
8931
9514
  }
8932
9515
  const args = ["compose", ...fileArgs, "-f", overlayPath, "up", "-d", "--wait", ...services];
8933
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9516
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8934
9517
  encoding: "utf-8",
8935
9518
  cwd: repoPath
8936
9519
  });
@@ -8958,7 +9541,7 @@ function queryRunningComposeServices(repoPath, composeFiles2, overlayPath, servi
8958
9541
  "--services",
8959
9542
  ...services
8960
9543
  ];
8961
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9544
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8962
9545
  encoding: "utf-8",
8963
9546
  cwd: repoPath
8964
9547
  });
@@ -8984,7 +9567,7 @@ function runDockerComposeStop(repoPath, composeFiles2, overlayPath, services) {
8984
9567
  fileArgs.push("-f", resolved);
8985
9568
  }
8986
9569
  const args = ["compose", ...fileArgs, "-f", overlayPath, "stop", ...services];
8987
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9570
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
8988
9571
  encoding: "utf-8",
8989
9572
  cwd: repoPath
8990
9573
  });
@@ -9010,7 +9593,7 @@ function runDockerComposeLogs(repoPath, composeFiles2, overlayPath, services, ta
9010
9593
  String(tail),
9011
9594
  ...services
9012
9595
  ];
9013
- (0, import_node_child_process17.spawnSync)("docker", args, {
9596
+ (0, import_node_child_process19.spawnSync)("docker", args, {
9014
9597
  stdio: "inherit",
9015
9598
  cwd: repoPath
9016
9599
  });
@@ -9022,7 +9605,7 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
9022
9605
  fileArgs.push("-f", resolved);
9023
9606
  }
9024
9607
  const args = ["compose", ...fileArgs, "-f", overlayPath, "port", service, String(internalPort)];
9025
- const result = (0, import_node_child_process17.spawnSync)("docker", args, {
9608
+ const result = (0, import_node_child_process19.spawnSync)("docker", args, {
9026
9609
  encoding: "utf-8",
9027
9610
  cwd: repoPath
9028
9611
  });
@@ -9036,14 +9619,14 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
9036
9619
  const port = Number(match[1]);
9037
9620
  return Number.isInteger(port) && port > 0 ? port : void 0;
9038
9621
  }
9039
- var import_node_child_process17, import_node_crypto5, import_node_fs21, import_node_path21, import_yaml6;
9622
+ var import_node_child_process19, import_node_crypto6, import_node_fs23, import_node_path23, import_yaml6;
9040
9623
  var init_docker_run = __esm({
9041
9624
  "src/core/docker-run.ts"() {
9042
9625
  "use strict";
9043
- import_node_child_process17 = require("child_process");
9044
- import_node_crypto5 = require("crypto");
9045
- import_node_fs21 = __toESM(require("fs"));
9046
- import_node_path21 = __toESM(require("path"));
9626
+ import_node_child_process19 = require("child_process");
9627
+ import_node_crypto6 = require("crypto");
9628
+ import_node_fs23 = __toESM(require("fs"));
9629
+ import_node_path23 = __toESM(require("path"));
9047
9630
  import_yaml6 = __toESM(require("yaml"));
9048
9631
  init_docker_error_guidance();
9049
9632
  init_paths();
@@ -9133,7 +9716,7 @@ function isProcessRunning(pid) {
9133
9716
  }
9134
9717
  }
9135
9718
  function readProcessTree(rootPid) {
9136
- const result = (0, import_node_child_process18.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
9719
+ const result = (0, import_node_child_process20.spawnSync)("ps", ["-ax", "-o", "pid=,ppid="], { encoding: "utf-8" });
9137
9720
  if (result.status !== 0) {
9138
9721
  return [rootPid];
9139
9722
  }
@@ -9244,12 +9827,12 @@ function detectListeningPorts(pids) {
9244
9827
  if (pids.length === 0) {
9245
9828
  return [];
9246
9829
  }
9247
- const result = (0, import_node_child_process18.spawnSync)("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], {
9830
+ const result = (0, import_node_child_process20.spawnSync)("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", pids.join(",")], {
9248
9831
  encoding: "utf-8"
9249
9832
  });
9250
9833
  if (result.error && result.error.code === "ENOENT") {
9251
9834
  if (process.platform === "linux") {
9252
- const ssResult = (0, import_node_child_process18.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
9835
+ const ssResult = (0, import_node_child_process20.spawnSync)("ss", ["-H", "-lntp", "-p"], { encoding: "utf-8" });
9253
9836
  if (ssResult.status === 0 && ssResult.stdout) {
9254
9837
  return parseSsListeningPorts(ssResult.stdout, new Set(pids));
9255
9838
  }
@@ -9308,7 +9891,7 @@ async function runHostApp(repoPath, app, extraEnv = {}, secretManager, env, work
9308
9891
  app.hostRun.command,
9309
9892
  true
9310
9893
  ) : app.hostRun.command;
9311
- const child = (0, import_node_child_process18.spawn)(spawnCommand, {
9894
+ const child = (0, import_node_child_process20.spawn)(spawnCommand, {
9312
9895
  cwd: commandCwd,
9313
9896
  stdio: "inherit",
9314
9897
  shell: true,
@@ -9676,7 +10259,7 @@ async function execWithAppEnv(options) {
9676
10259
  options.command[0],
9677
10260
  true
9678
10261
  );
9679
- child = (0, import_node_child_process18.spawn)(wrapped, {
10262
+ child = (0, import_node_child_process20.spawn)(wrapped, {
9680
10263
  cwd: deps.repoPath,
9681
10264
  stdio: "inherit",
9682
10265
  shell: true,
@@ -9690,7 +10273,7 @@ async function execWithAppEnv(options) {
9690
10273
  false
9691
10274
  );
9692
10275
  const [cmd, ...wrappedArgs] = wrapped;
9693
- child = (0, import_node_child_process18.spawn)(cmd, wrappedArgs, {
10276
+ child = (0, import_node_child_process20.spawn)(cmd, wrappedArgs, {
9694
10277
  cwd: deps.repoPath,
9695
10278
  stdio: "inherit",
9696
10279
  shell: false,
@@ -9698,7 +10281,7 @@ async function execWithAppEnv(options) {
9698
10281
  });
9699
10282
  }
9700
10283
  } else if (options.shell) {
9701
- child = (0, import_node_child_process18.spawn)(options.command[0], {
10284
+ child = (0, import_node_child_process20.spawn)(options.command[0], {
9702
10285
  cwd: deps.repoPath,
9703
10286
  stdio: "inherit",
9704
10287
  shell: true,
@@ -9706,7 +10289,7 @@ async function execWithAppEnv(options) {
9706
10289
  });
9707
10290
  } else {
9708
10291
  const [command, ...args] = options.command;
9709
- child = (0, import_node_child_process18.spawn)(command, args, {
10292
+ child = (0, import_node_child_process20.spawn)(command, args, {
9710
10293
  cwd: deps.repoPath,
9711
10294
  stdio: "inherit",
9712
10295
  shell: false,
@@ -9727,11 +10310,11 @@ async function execWithAppEnv(options) {
9727
10310
  deps.stopDeps();
9728
10311
  }
9729
10312
  }
9730
- var import_node_child_process18, import_node_net, import_node_process, import_promises, POLL_INTERVAL_MS2, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
10313
+ var import_node_child_process20, import_node_net, import_node_process, import_promises, POLL_INTERVAL_MS2, DEFAULT_PORT_TIMEOUT_MS, PROCESS_TERMINATION_GRACE_MS;
9731
10314
  var init_app_run = __esm({
9732
10315
  "src/core/app-run.ts"() {
9733
10316
  "use strict";
9734
- import_node_child_process18 = require("child_process");
10317
+ import_node_child_process20 = require("child_process");
9735
10318
  import_node_net = __toESM(require("net"));
9736
10319
  import_node_process = require("process");
9737
10320
  import_promises = require("readline/promises");
@@ -9859,9 +10442,847 @@ var init_tls2 = __esm({
9859
10442
  }
9860
10443
  });
9861
10444
 
10445
+ // src/core/workspace-consumption.ts
10446
+ function measureWorktreeConsumption(worktreePath, options) {
10447
+ const deadlineMs = options?.deadlineMs ?? DEFAULT_DEADLINE_MS;
10448
+ const startedAt = Date.now();
10449
+ let rootStat;
10450
+ try {
10451
+ rootStat = import_node_fs24.default.lstatSync(worktreePath);
10452
+ } catch (error) {
10453
+ return { status: "unknown", reason: describeError(error, worktreePath) };
10454
+ }
10455
+ let rootEntries;
10456
+ try {
10457
+ rootEntries = import_node_fs24.default.readdirSync(worktreePath, { withFileTypes: true });
10458
+ } catch (error) {
10459
+ return { status: "unknown", reason: describeError(error, worktreePath) };
10460
+ }
10461
+ const seenInodes = /* @__PURE__ */ new Set();
10462
+ let bytes = 0;
10463
+ let timedOut = false;
10464
+ let unreadableReason = null;
10465
+ const accumulate = (stat) => {
10466
+ if (stat.nlink > 1) {
10467
+ const key = `${stat.dev}:${stat.ino}`;
10468
+ if (seenInodes.has(key)) return;
10469
+ seenInodes.add(key);
10470
+ }
10471
+ bytes += stat.blocks * BLOCK_SIZE_BYTES;
10472
+ };
10473
+ const deadlineExceeded = () => Date.now() - startedAt >= deadlineMs;
10474
+ accumulate(rootStat);
10475
+ const stack = [{ dirPath: worktreePath, entries: rootEntries }];
10476
+ while (stack.length > 0 && !timedOut && !unreadableReason) {
10477
+ const { dirPath, entries } = stack.pop();
10478
+ for (const entry of entries) {
10479
+ if (deadlineExceeded()) {
10480
+ timedOut = true;
10481
+ break;
10482
+ }
10483
+ const entryPath = import_node_path24.default.join(dirPath, entry.name);
10484
+ let entryStat;
10485
+ try {
10486
+ entryStat = import_node_fs24.default.lstatSync(entryPath);
10487
+ } catch (error) {
10488
+ if (error?.code === "ENOENT") continue;
10489
+ unreadableReason = describeIncompleteWalk(error);
10490
+ break;
10491
+ }
10492
+ accumulate(entryStat);
10493
+ if (!entryStat.isDirectory()) continue;
10494
+ let childEntries;
10495
+ try {
10496
+ childEntries = import_node_fs24.default.readdirSync(entryPath, { withFileTypes: true });
10497
+ } catch (error) {
10498
+ unreadableReason = describeIncompleteWalk(error);
10499
+ break;
10500
+ }
10501
+ stack.push({ dirPath: entryPath, entries: childEntries });
10502
+ }
10503
+ }
10504
+ if (timedOut) {
10505
+ return { status: "unknown", reason: `exceeded deadline of ${deadlineMs}ms` };
10506
+ }
10507
+ if (unreadableReason) {
10508
+ return { status: "unknown", reason: unreadableReason };
10509
+ }
10510
+ return { status: "measured", bytes };
10511
+ }
10512
+ function describeIncompleteWalk(error) {
10513
+ return `could not read every path inside the worktree: ${error?.message ?? String(error)}`;
10514
+ }
10515
+ function measureContainerConsumption(worktreePaths, dependencies) {
10516
+ const byWorktree = /* @__PURE__ */ new Map();
10517
+ if (worktreePaths.length === 0) return byWorktree;
10518
+ const inspect = dependencies?.inspect ?? inspectWorkspaceContainers;
10519
+ const containers = inspect();
10520
+ const attributedIds = /* @__PURE__ */ new Set();
10521
+ for (const worktreePath of worktreePaths) {
10522
+ for (const container of workspaceAppContainers(containers, worktreePath)) {
10523
+ attributedIds.add(container.id);
10524
+ }
10525
+ }
10526
+ const sized = attributedIds.size === 0 ? [] : inspect({ withSize: true, ids: Array.from(attributedIds) });
10527
+ for (const worktreePath of worktreePaths) {
10528
+ byWorktree.set(worktreePath, summarizeContainers(workspaceAppContainers(sized, worktreePath)));
10529
+ }
10530
+ return byWorktree;
10531
+ }
10532
+ function summarizeContainers(containers) {
10533
+ let writable = 0;
10534
+ let shared = 0;
10535
+ for (const container of containers) {
10536
+ const { sizeRw, sizeRootFs } = container;
10537
+ if (typeof sizeRw !== "number" || typeof sizeRootFs !== "number" || !Number.isFinite(sizeRw) || !Number.isFinite(sizeRootFs) || sizeRootFs < sizeRw) {
10538
+ const unknown = {
10539
+ status: "unknown",
10540
+ reason: `container ${container.id.slice(0, 12)} reported no usable size`
10541
+ };
10542
+ return { containerWritable: unknown, imageShared: unknown };
10543
+ }
10544
+ writable += sizeRw;
10545
+ shared += sizeRootFs - sizeRw;
10546
+ }
10547
+ return {
10548
+ containerWritable: { status: "measured", bytes: writable },
10549
+ imageShared: { status: "measured", bytes: shared }
10550
+ };
10551
+ }
10552
+ function describeError(error, worktreePath) {
10553
+ const code = error?.code;
10554
+ if (code === "ENOENT") return `worktree path '${worktreePath}' does not exist`;
10555
+ if (code === "EACCES" || code === "EPERM") {
10556
+ return `permission denied reading worktree path '${worktreePath}'`;
10557
+ }
10558
+ return `could not stat worktree path '${worktreePath}': ${error?.message ?? String(error)}`;
10559
+ }
10560
+ var import_node_fs24, import_node_path24, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
10561
+ var init_workspace_consumption = __esm({
10562
+ "src/core/workspace-consumption.ts"() {
10563
+ "use strict";
10564
+ import_node_fs24 = __toESM(require("fs"));
10565
+ import_node_path24 = __toESM(require("path"));
10566
+ init_devpod_environment();
10567
+ DEFAULT_DEADLINE_MS = 1e4;
10568
+ BLOCK_SIZE_BYTES = 512;
10569
+ }
10570
+ });
10571
+
10572
+ // src/core/workspace-cleanup.ts
10573
+ function isRecord2(value) {
10574
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10575
+ }
10576
+ function isSha(value) {
10577
+ return typeof value === "string" && /^[0-9a-f]{40,64}$/i.test(value);
10578
+ }
10579
+ function parseTimestamp(value) {
10580
+ if (typeof value !== "string") return void 0;
10581
+ const timestamp = Date.parse(value);
10582
+ return Number.isFinite(timestamp) ? timestamp : void 0;
10583
+ }
10584
+ function validTimestamp(value) {
10585
+ return parseTimestamp(value) !== void 0;
10586
+ }
10587
+ function successfulOutput(result) {
10588
+ if (result.status !== 0 || result.error) return void 0;
10589
+ const output2 = result.stdout.trim();
10590
+ return output2.length > 0 ? output2 : void 0;
10591
+ }
10592
+ function gitOutput(repoPath, args, commandRunner) {
10593
+ return successfulOutput(commandRunner("git", ["-C", repoPath, ...args]));
10594
+ }
10595
+ function quoteCommandArg(value) {
10596
+ return /^[a-zA-Z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
10597
+ }
10598
+ function workspaceCommand(repoPath, args, beforeRepo = [], afterRepo = []) {
10599
+ return [
10600
+ "devrouter",
10601
+ "workspace",
10602
+ ...args,
10603
+ ...beforeRepo,
10604
+ "--repo",
10605
+ quoteCommandArg(repoPath),
10606
+ ...afterRepo
10607
+ ].join(" ");
10608
+ }
10609
+ function parseInactiveFor(value = DEFAULT_INACTIVE_FOR) {
10610
+ const match = /^(?<amount>[1-9]\d*)(?<unit>[smhdw])$/.exec(value);
10611
+ if (!match?.groups) {
10612
+ throw new Error(
10613
+ "--inactive-for must be a positive integer followed by s, m, h, d, or w (for example 30d)."
10614
+ );
10615
+ }
10616
+ const amount = Number(match.groups.amount);
10617
+ const multiplier = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[match.groups.unit];
10618
+ const seconds = amount * multiplier;
10619
+ if (!Number.isSafeInteger(seconds)) {
10620
+ throw new Error("--inactive-for is too large.");
10621
+ }
10622
+ return { input: value, seconds };
10623
+ }
10624
+ function evaluateWorkspaceActivity(evidence, cutoff) {
10625
+ const cutoffTime = parseTimestamp(cutoff);
10626
+ if (cutoffTime === void 0) {
10627
+ throw new Error(`Invalid activity cutoff '${cutoff}'.`);
10628
+ }
10629
+ const validEvidence = evidence.filter(
10630
+ (entry) => entry.status === "valid" && validTimestamp(entry.timestamp)
10631
+ );
10632
+ const latestTime = validEvidence.reduce(
10633
+ (latest, entry) => Math.max(
10634
+ latest ?? Number.NEGATIVE_INFINITY,
10635
+ parseTimestamp(entry.timestamp) ?? Number.NEGATIVE_INFINITY
10636
+ ),
10637
+ void 0
10638
+ );
10639
+ const latestTimestamp = latestTime === void 0 ? null : validEvidence.find((entry) => parseTimestamp(entry.timestamp) === latestTime)?.timestamp ?? null;
10640
+ const contributingEvidence = ACTIVITY_SOURCES.filter(
10641
+ (source) => validEvidence.some(
10642
+ (entry) => entry.source === source && parseTimestamp(entry.timestamp) === latestTime
10643
+ )
10644
+ );
10645
+ let status = "unknown";
10646
+ if (latestTime !== void 0) {
10647
+ if (latestTime >= cutoffTime) {
10648
+ status = "recent";
10649
+ } else if (validEvidence.length > 0 && evidence.every(
10650
+ (entry) => entry.status === "valid" || entry.status === "not-applicable" || entry.status === "missing"
10651
+ )) {
10652
+ status = "quiet";
10653
+ }
10654
+ }
10655
+ return {
10656
+ status,
10657
+ latestTimestamp,
10658
+ contributingEvidence,
10659
+ evidence: evidence.slice().sort(
10660
+ (left, right) => ACTIVITY_SOURCES.indexOf(left.source) - ACTIVITY_SOURCES.indexOf(right.source)
10661
+ )
10662
+ };
10663
+ }
10664
+ function readGitSnapshot(worktree, commandRunner) {
10665
+ const comparablePath = comparableWorkspacePath(worktree.path);
10666
+ if (worktree.prunable || !import_node_fs25.default.existsSync(comparablePath)) {
10667
+ return { worktree, checkout: "missing", head: null, committerDate: null };
10668
+ }
10669
+ const head = gitOutput(comparablePath, ["rev-parse", "--verify", "HEAD"], commandRunner);
10670
+ if (!head) {
10671
+ return { worktree, checkout: "unknown", head: null, committerDate: null };
10672
+ }
10673
+ const committerDate = gitOutput(comparablePath, ["show", "-s", "--format=%cI", "HEAD"], commandRunner) ?? null;
10674
+ const statusResult = commandRunner("git", [
10675
+ "-C",
10676
+ comparablePath,
10677
+ "status",
10678
+ "--porcelain=v1",
10679
+ "--untracked-files=normal"
10680
+ ]);
10681
+ const checkout = worktree.branch === void 0 ? "detached" : statusResult.status !== 0 || statusResult.error ? "unknown" : statusResult.stdout.trim().length > 0 ? "dirty" : "clean";
10682
+ return { worktree, checkout, head, committerDate };
10683
+ }
10684
+ function parseRemoteIdentity(value) {
10685
+ const normalized = value.trim();
10686
+ let host;
10687
+ let project;
10688
+ const scp = /^git@([^:]+):(.+)$/.exec(normalized);
10689
+ if (scp) {
10690
+ host = scp[1];
10691
+ project = scp[2];
10692
+ } else {
10693
+ try {
10694
+ const url = new URL(normalized);
10695
+ if (url.protocol !== "https:" && url.protocol !== "ssh:") return void 0;
10696
+ host = url.hostname;
10697
+ project = url.pathname.replace(/^\/+/, "");
10698
+ } catch {
10699
+ return void 0;
10700
+ }
10701
+ }
10702
+ project = project.replace(/\.git$/, "").replace(/\/+$/, "");
10703
+ if (!host || !project || project.includes("..") || project.includes(" ")) return void 0;
10704
+ const lowerHost = host.toLowerCase();
10705
+ if (lowerHost === "github.com" && /^[^/]+\/[^/]+$/.test(project)) {
10706
+ return { provider: "github", host: lowerHost, project };
10707
+ }
10708
+ if (lowerHost === "gitlab.com") {
10709
+ return { provider: "gitlab", host: lowerHost, project };
10710
+ }
10711
+ return void 0;
10712
+ }
10713
+ function parseGitHubChanges(value, project, branch) {
10714
+ if (!Array.isArray(value)) return void 0;
10715
+ const changes = [];
10716
+ for (const item of value) {
10717
+ if (!isRecord2(item)) return void 0;
10718
+ const repository = isRecord2(item.repository) ? item.repository.nameWithOwner : void 0;
10719
+ const headRepository = isRecord2(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
10720
+ if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
10721
+ continue;
10722
+ }
10723
+ const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
10724
+ const mergeCommit = isRecord2(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
10725
+ const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
10726
+ changes.push({
10727
+ sourceBranch: branch,
10728
+ sourceHeadSha: item.headRefOid,
10729
+ targetBranch: item.baseRefName,
10730
+ ...baseSha ? { baseSha } : {},
10731
+ ...mergeCommit ? { mergeCommitSha: mergeCommit } : {},
10732
+ merged
10733
+ });
10734
+ }
10735
+ return changes;
10736
+ }
10737
+ function parseGitLabChanges(value, _project, branch) {
10738
+ if (!Array.isArray(value)) return void 0;
10739
+ const changes = [];
10740
+ for (const item of value) {
10741
+ if (!isRecord2(item)) return void 0;
10742
+ const sourceProjectId = item.source_project_id;
10743
+ const targetProjectId = item.target_project_id;
10744
+ const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
10745
+ if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
10746
+ continue;
10747
+ }
10748
+ const diffRefs = isRecord2(item.diff_refs) ? item.diff_refs : void 0;
10749
+ const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
10750
+ const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
10751
+ changes.push({
10752
+ sourceBranch: branch,
10753
+ sourceHeadSha: item.sha,
10754
+ targetBranch: item.target_branch,
10755
+ ...baseSha ? { baseSha } : {},
10756
+ ...mergeCommitSha ? { mergeCommitSha } : {},
10757
+ merged: item.state === "merged" && validTimestamp(item.merged_at)
10758
+ });
10759
+ }
10760
+ return changes;
10761
+ }
10762
+ function patchIdForRange(repoPath, baseSha, headSha, commandRunner) {
10763
+ const diff = commandRunner("git", [
10764
+ "-C",
10765
+ repoPath,
10766
+ "diff",
10767
+ "--no-ext-diff",
10768
+ "--binary",
10769
+ baseSha,
10770
+ headSha
10771
+ ]);
10772
+ if (diff.status !== 0 || diff.error || diff.stdout.length === 0) return void 0;
10773
+ const result = commandRunner("git", ["patch-id", "--stable"], diff.stdout);
10774
+ if (result.status !== 0 || result.error) return void 0;
10775
+ const match = /^([0-9a-f]{40,64})\s+(?:-|[0-9a-f]{40,64})$/i.exec(result.stdout.trim());
10776
+ return match?.[1];
10777
+ }
10778
+ function hasUniqueSourceCommit(repoPath, baseSha, headSha, commandRunner) {
10779
+ const result = gitOutput(
10780
+ repoPath,
10781
+ ["rev-list", "--no-merges", "--reverse", `${baseSha}..${headSha}`],
10782
+ commandRunner
10783
+ );
10784
+ if (!result) return false;
10785
+ const commits = result.split(/\r?\n/).map((sha) => sha.trim()).filter((sha) => sha.length > 0);
10786
+ return commits.length > 0 && commits.every(isSha) && new Set(commits).size > 0;
10787
+ }
10788
+ function hasVerifiedCommonBase(repoPath, baseSha, headSha, commandRunner) {
10789
+ const result = commandRunner("git", [
10790
+ "-C",
10791
+ repoPath,
10792
+ "merge-base",
10793
+ "--is-ancestor",
10794
+ baseSha,
10795
+ headSha
10796
+ ]);
10797
+ return result.status === 0 && !result.error;
10798
+ }
10799
+ function parseTargetBranch(output2) {
10800
+ if (!output2) return void 0;
10801
+ const value = output2.trim();
10802
+ return value.startsWith("origin/") && value.length > "origin/".length ? value.slice("origin/".length) : void 0;
10803
+ }
10804
+ function parseRemoteDefaultTarget(output2) {
10805
+ if (!output2) return void 0;
10806
+ const match = /^ref:\s+refs\/heads\/([^\s]+)\s+HEAD$/m.exec(output2);
10807
+ return match?.[1];
10808
+ }
10809
+ function inspectWorkspaceIntegration(repoPath, snapshot, branch, checkMerged, commandRunner) {
10810
+ if (!checkMerged) return { status: "not-verified", reason: "Merged checks were not requested." };
10811
+ if (!snapshot.head || !branch || snapshot.checkout === "missing" || snapshot.checkout === "unknown") {
10812
+ return { status: "unknown", reason: "Current HEAD or branch evidence is unavailable." };
10813
+ }
10814
+ const originUrl = gitOutput(repoPath, ["config", "--get", "remote.origin.url"], commandRunner);
10815
+ const identity = originUrl ? parseRemoteIdentity(originUrl) : void 0;
10816
+ if (!identity) {
10817
+ return { status: "unknown", reason: "The origin is missing or uses an unsupported forge." };
10818
+ }
10819
+ const localTargetBranch = parseTargetBranch(
10820
+ gitOutput(
10821
+ repoPath,
10822
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
10823
+ commandRunner
10824
+ )
10825
+ );
10826
+ const remoteTargetBranch = parseRemoteDefaultTarget(
10827
+ successfulOutput(
10828
+ commandRunner("git", ["-C", repoPath, "ls-remote", "--symref", "origin", "HEAD"])
10829
+ )
10830
+ );
10831
+ if (!remoteTargetBranch || localTargetBranch && localTargetBranch !== remoteTargetBranch) {
10832
+ return {
10833
+ status: "unknown",
10834
+ reason: "The origin default target is missing, stale, or unavailable."
10835
+ };
10836
+ }
10837
+ const targetBranch = remoteTargetBranch;
10838
+ const localTargetSha = gitOutput(
10839
+ repoPath,
10840
+ ["rev-parse", "--verify", `refs/remotes/origin/${targetBranch}`],
10841
+ commandRunner
10842
+ );
10843
+ const remoteTargetOutput = successfulOutput(
10844
+ commandRunner("git", ["-C", repoPath, "ls-remote", "origin", `refs/heads/${targetBranch}`])
10845
+ );
10846
+ const remoteTargetSha = remoteTargetOutput?.split(/\s+/)[0];
10847
+ if (!isSha(localTargetSha) || !isSha(remoteTargetSha) || localTargetSha !== remoteTargetSha) {
10848
+ return { status: "unknown", reason: "The origin target is missing, stale, or unavailable." };
10849
+ }
10850
+ const sourceRemoteOutput = successfulOutput(
10851
+ commandRunner("git", ["-C", repoPath, "ls-remote", "origin", `refs/heads/${branch}`])
10852
+ );
10853
+ const sourceRemoteSha = sourceRemoteOutput?.split(/\s+/)[0];
10854
+ if (!isSha(sourceRemoteSha)) {
10855
+ return {
10856
+ status: "unknown",
10857
+ reason: "The workspace source branch is missing or unavailable on origin."
10858
+ };
10859
+ }
10860
+ const forgeChanges = [];
10861
+ const forgeCommand = identity.provider === "github" ? [
10862
+ "pr",
10863
+ "list",
10864
+ "--repo",
10865
+ identity.project,
10866
+ "--state",
10867
+ "all",
10868
+ "--head",
10869
+ branch,
10870
+ "--json",
10871
+ "headRefName,headRefOid,baseRefName,baseRefOid,state,mergedAt,repository,headRepository,mergeCommit"
10872
+ ] : [
10873
+ "mr",
10874
+ "list",
10875
+ "--repo",
10876
+ identity.project,
10877
+ "--all",
10878
+ "--source-branch",
10879
+ branch,
10880
+ "--output",
10881
+ "json"
10882
+ ];
10883
+ const forge = commandRunner(identity.provider === "github" ? "gh" : "glab", forgeCommand);
10884
+ if (forge.status !== 0 || forge.error) {
10885
+ return { status: "unknown", reason: "The forge query was unavailable or unauthenticated." };
10886
+ }
10887
+ try {
10888
+ const parsed = JSON.parse(forge.stdout);
10889
+ const changes = identity.provider === "github" ? parseGitHubChanges(parsed, identity.project, branch) : parseGitLabChanges(parsed, identity.project, branch);
10890
+ if (!changes) {
10891
+ return { status: "unknown", reason: "The forge response was malformed." };
10892
+ }
10893
+ forgeChanges.push(...changes);
10894
+ } catch {
10895
+ return { status: "unknown", reason: "The forge response was malformed." };
10896
+ }
10897
+ const mergedExact = forgeChanges.find(
10898
+ (change) => change.merged && change.targetBranch === targetBranch && change.sourceHeadSha.toLowerCase() === snapshot.head?.toLowerCase() && change.sourceHeadSha.toLowerCase() === sourceRemoteSha.toLowerCase()
10899
+ );
10900
+ if (mergedExact)
10901
+ return {
10902
+ status: "merged-exact",
10903
+ headSha: snapshot.head,
10904
+ reason: "Merged source head exactly matches current HEAD."
10905
+ };
10906
+ const worktreePath = snapshot.worktree.path;
10907
+ const ancestry = commandRunner("git", [
10908
+ "-C",
10909
+ worktreePath,
10910
+ "merge-base",
10911
+ "--is-ancestor",
10912
+ snapshot.head,
10913
+ remoteTargetSha
10914
+ ]);
10915
+ if (ancestry.status === 0 && !ancestry.error) {
10916
+ return {
10917
+ status: "on-target",
10918
+ headSha: snapshot.head,
10919
+ reason: "Current HEAD is an ancestor of the verified-fresh origin target."
10920
+ };
10921
+ }
10922
+ if (ancestry.status !== 1 || ancestry.error) {
10923
+ return { status: "unknown", reason: "Target ancestry could not be verified." };
10924
+ }
10925
+ for (const change of forgeChanges.filter(
10926
+ (candidate) => candidate.merged && candidate.targetBranch === targetBranch && candidate.sourceHeadSha.toLowerCase() !== snapshot.head?.toLowerCase()
10927
+ )) {
10928
+ if (change.sourceHeadSha.toLowerCase() !== sourceRemoteSha?.toLowerCase()) continue;
10929
+ if (change.baseSha && change.mergeCommitSha && hasVerifiedCommonBase(worktreePath, change.baseSha, snapshot.head, commandRunner) && hasVerifiedCommonBase(worktreePath, change.baseSha, change.mergeCommitSha, commandRunner) && hasUniqueSourceCommit(worktreePath, change.baseSha, snapshot.head, commandRunner)) {
10930
+ const currentPatchId = patchIdForRange(
10931
+ worktreePath,
10932
+ change.baseSha,
10933
+ snapshot.head,
10934
+ commandRunner
10935
+ );
10936
+ const mergedPatchId = patchIdForRange(
10937
+ worktreePath,
10938
+ change.baseSha,
10939
+ change.mergeCommitSha,
10940
+ commandRunner
10941
+ );
10942
+ if (!currentPatchId || !mergedPatchId || currentPatchId !== mergedPatchId) continue;
10943
+ return {
10944
+ status: "patch-equivalent",
10945
+ headSha: snapshot.head,
10946
+ reason: "All source changes have an equivalent merged patch."
10947
+ };
10948
+ }
10949
+ }
10950
+ return {
10951
+ status: "not-verified",
10952
+ reason: "Fresh target and forge evidence did not prove integration."
10953
+ };
10954
+ }
10955
+ function routeStatus(record, routes) {
10956
+ if (!routes) return "unknown";
10957
+ const matchingPath = routes.filter(
10958
+ (route) => sameWorkspacePath(route.repoPath, record.worktreePath)
10959
+ );
10960
+ if (matchingPath.length === 0) return "absent";
10961
+ return matchingPath.every((route) => route.workspace === record.workspace) ? "owned" : "conflict";
10962
+ }
10963
+ function buildActivityEvidence(record, providerStatus, provider, routeEntries, git) {
10964
+ const providerEvidence = providerStatus === "unknown" || providerStatus === "conflict" ? { source: "devpod.lastUsed", status: "unavailable" } : providerStatus === "absent" ? { source: "devpod.lastUsed", status: "not-applicable" } : provider?.lastUsedMalformed ? { source: "devpod.lastUsed", status: "malformed" } : provider?.lastUsed ? validTimestamp(provider.lastUsed) ? { source: "devpod.lastUsed", status: "valid", timestamp: provider.lastUsed } : { source: "devpod.lastUsed", status: "malformed" } : { source: "devpod.lastUsed", status: "missing" };
10965
+ const routeTimestamps = routeEntries?.map((route) => route.updatedAt) ?? [];
10966
+ const malformedRoute = routeTimestamps.some((timestamp) => !validTimestamp(timestamp));
10967
+ const newestRoute = routeTimestamps.filter(validTimestamp).sort((left, right) => (parseTimestamp(right) ?? 0) - (parseTimestamp(left) ?? 0))[0];
10968
+ const routeEvidence = !routeEntries ? { source: "route.updatedAt", status: "unavailable" } : routeEntries.length === 0 ? { source: "route.updatedAt", status: "not-applicable" } : malformedRoute ? { source: "route.updatedAt", status: "malformed" } : newestRoute ? { source: "route.updatedAt", status: "valid", timestamp: newestRoute } : { source: "route.updatedAt", status: "malformed" };
10969
+ const ownerEvidence = validTimestamp(record.updatedAt) ? { source: "ownership.updatedAt", status: "valid", timestamp: record.updatedAt } : { source: "ownership.updatedAt", status: "malformed" };
10970
+ const gitEvidence = git.checkout === "missing" ? { source: "git.headCommitterDate", status: "not-applicable" } : git.committerDate && validTimestamp(git.committerDate) ? { source: "git.headCommitterDate", status: "valid", timestamp: git.committerDate } : git.checkout === "unknown" ? { source: "git.headCommitterDate", status: "unavailable" } : { source: "git.headCommitterDate", status: "malformed" };
10971
+ return [providerEvidence, routeEvidence, ownerEvidence, gitEvidence];
10972
+ }
10973
+ function buildSuggestions(repoPath, record, ownership, provider, runtime, checkout, route, activity, integration, worktree, checkMerged) {
10974
+ const reasons = [];
10975
+ const eligibleActions = [];
10976
+ const suggestions = [];
10977
+ const gcCommand = workspaceCommand(repoPath, ["gc"], [], ["--yes"]);
10978
+ const keepCommand = workspaceCommand(repoPath, ["down", record.workspace], ["--keep-worktree"]);
10979
+ const downCommand = workspaceCommand(repoPath, ["down", record.workspace]);
10980
+ const routeSafe = route === "owned" || route === "absent";
10981
+ const checkoutSafe = checkout === "clean" && !worktree?.locked;
10982
+ const providerSafe = provider === "owned";
10983
+ const runtimeSafe = runtime === "running" || runtime === "stopped" || runtime === "not-found";
10984
+ if (ownership === "missing") {
10985
+ if ((provider === "absent" || provider === "owned" && runtimeSafe) && routeSafe) {
10986
+ eligibleActions.push(gcCommand);
10987
+ suggestions.push({
10988
+ command: gcCommand,
10989
+ reason: "The exact ownership record is missing from live Git registration; GC will revalidate before deleting eligible runtime evidence."
10990
+ });
10991
+ } else {
10992
+ reasons.push(
10993
+ `GC suggestion suppressed because provider=${provider}, runtime=${runtime}, or route=${route} is not independently safe.`
10994
+ );
10995
+ }
10996
+ return { eligibleActions, suggestions, reasons };
10997
+ }
10998
+ if (ownership !== "present")
10999
+ reasons.push(`Destructive suggestions require ownership=present (found ${ownership}).`);
11000
+ if (!providerSafe)
11001
+ reasons.push(
11002
+ `Destructive suggestions require an exact owned DevPod (found provider=${provider}).`
11003
+ );
11004
+ if (!runtimeSafe)
11005
+ reasons.push(
11006
+ `Destructive suggestions require an actionable DevPod runtime (found runtime=${runtime}).`
11007
+ );
11008
+ if (!routeSafe)
11009
+ reasons.push(
11010
+ `Destructive suggestions require non-conflicting route evidence (found route=${route}).`
11011
+ );
11012
+ if (checkout === "dirty")
11013
+ reasons.push("Checkout is dirty; destructive workspace down is blocked.");
11014
+ if (checkout === "missing")
11015
+ reasons.push("Checkout is missing; the report cannot authorize full down.");
11016
+ if (checkout === "detached")
11017
+ reasons.push("Checkout is detached; branch identity is not safe for destructive advice.");
11018
+ if (checkout === "unknown")
11019
+ reasons.push("Checkout state is unknown; destructive advice is suppressed.");
11020
+ if (worktree?.locked)
11021
+ reasons.push("Git worktree is locked; destructive workspace down is blocked.");
11022
+ if (!providerSafe || !runtimeSafe || !routeSafe || !checkoutSafe || ownership !== "present") {
11023
+ return { eligibleActions, suggestions, reasons };
11024
+ }
11025
+ if (integration.status === "merged-exact" && integration.headSha) {
11026
+ eligibleActions.push(downCommand);
11027
+ suggestions.push({
11028
+ command: downCommand,
11029
+ reason: "Current HEAD is the exact source head of a same-repository merged change; workspace down still revalidates cleanliness, locks, and ownership before deleting runtime and the linked worktree."
11030
+ });
11031
+ return { eligibleActions, suggestions, reasons };
11032
+ }
11033
+ if (integration.status === "patch-equivalent")
11034
+ reasons.push("Patch-equivalent integration is advisory and never authorizes full removal.");
11035
+ if (integration.status === "unknown" || integration.status === "not-verified")
11036
+ reasons.push(`Integration is ${integration.status}; full removal is not suggested.`);
11037
+ const activityCleanupAllowed = !checkMerged || integration.status === "on-target" || integration.status === "merged-exact";
11038
+ if (!activityCleanupAllowed) {
11039
+ reasons.push(
11040
+ "Cleanup is not suggested because the requested integration check did not provide a verified target or exact merge."
11041
+ );
11042
+ return { eligibleActions, suggestions, reasons };
11043
+ }
11044
+ if (activity === "quiet") {
11045
+ eligibleActions.push(keepCommand);
11046
+ suggestions.push({
11047
+ command: keepCommand,
11048
+ reason: "Managed workspace has no recent trustworthy activity; this deletes DevPod/runtime data and preserves the worktree and owner record."
11049
+ });
11050
+ } else if (activity === "recent") {
11051
+ reasons.push("Recent trustworthy activity vetoes a quiet-workspace suggestion.");
11052
+ } else {
11053
+ reasons.push("Activity is unknown; a quiet-workspace suggestion is suppressed.");
11054
+ }
11055
+ return { eligibleActions, suggestions, reasons };
11056
+ }
11057
+ function buildRow(repoPath, record, worktrees, devpods, routes, snapshot, cutoff, checkMerged, commandRunner, inspectOwnershipFn, inspectIntegrationFn, inspectRuntimeFn) {
11058
+ const ownershipEvidence = inspectOwnershipFn(record, worktrees, devpods);
11059
+ const providerOwnership = devpods?.find(
11060
+ (devpod) => devpod.id === record.devpodId && sameWorkspacePath(devpod.source.localFolder, record.worktreePath)
11061
+ );
11062
+ const matchingRoutes = routes?.filter(
11063
+ (route) => sameWorkspacePath(route.repoPath, record.worktreePath)
11064
+ );
11065
+ const activity = evaluateWorkspaceActivity(
11066
+ buildActivityEvidence(
11067
+ record,
11068
+ ownershipEvidence.devpodStatus,
11069
+ providerOwnership,
11070
+ matchingRoutes,
11071
+ snapshot
11072
+ ),
11073
+ cutoff
11074
+ );
11075
+ const branch = snapshot.checkout === "detached" ? null : snapshot.worktree.branch ?? record.branch;
11076
+ const integration = inspectIntegrationFn?.(repoPath, snapshot, branch, checkMerged) ?? inspectWorkspaceIntegration(repoPath, snapshot, branch, checkMerged, commandRunner);
11077
+ const runtime = ownershipEvidence.devpodStatus === "absent" ? "absent" : ownershipEvidence.devpodStatus === "owned" ? (() => {
11078
+ try {
11079
+ return inspectRuntimeFn(record.devpodId);
11080
+ } catch {
11081
+ return "unknown";
11082
+ }
11083
+ })() : "unknown";
11084
+ const suggestions = buildSuggestions(
11085
+ repoPath,
11086
+ record,
11087
+ ownershipEvidence.ownerStatus,
11088
+ ownershipEvidence.devpodStatus,
11089
+ runtime,
11090
+ snapshot.checkout,
11091
+ routeStatus(record, routes),
11092
+ activity.status,
11093
+ integration,
11094
+ snapshot.worktree,
11095
+ checkMerged
11096
+ );
11097
+ const reasons = [
11098
+ `ownership=${ownershipEvidence.ownerStatus}`,
11099
+ `provider=${ownershipEvidence.devpodStatus}`,
11100
+ `runtime=${runtime}`,
11101
+ `checkout=${snapshot.checkout}`,
11102
+ `route=${routeStatus(record, routes)}`,
11103
+ `activity=${activity.status}`,
11104
+ `integration=${integration.status}`,
11105
+ ...integration.reason ? [integration.reason] : [],
11106
+ ...suggestions.reasons
11107
+ ];
11108
+ return {
11109
+ schemaVersion: 2,
11110
+ workspace: record.workspace,
11111
+ branch,
11112
+ repo: repoPath,
11113
+ worktreePath: record.worktreePath,
11114
+ devpodId: record.devpodId,
11115
+ ownership: ownershipEvidence.ownerStatus,
11116
+ provider: ownershipEvidence.devpodStatus,
11117
+ runtime,
11118
+ checkout: snapshot.checkout,
11119
+ route: routeStatus(record, routes),
11120
+ activity: activity.status,
11121
+ cutoff,
11122
+ latestTimestamp: activity.latestTimestamp,
11123
+ contributingEvidence: activity.contributingEvidence,
11124
+ activityEvidence: activity.evidence,
11125
+ integration: integration.status,
11126
+ eligibleActions: suggestions.eligibleActions,
11127
+ suggestions: suggestions.suggestions,
11128
+ reasons: Array.from(new Set(reasons))
11129
+ };
11130
+ }
11131
+ function deriveReclaimable(worktree, containerWritable) {
11132
+ if (worktree.status === "unknown") {
11133
+ return worktree;
11134
+ }
11135
+ if (containerWritable.status === "unknown") {
11136
+ return containerWritable;
11137
+ }
11138
+ return { status: "measured", bytes: worktree.bytes + containerWritable.bytes };
11139
+ }
11140
+ function collectConsumption(worktreePath, measureWorktreeFn, containers) {
11141
+ let worktree;
11142
+ try {
11143
+ worktree = measureWorktreeFn(worktreePath);
11144
+ } catch (error) {
11145
+ worktree = {
11146
+ status: "unknown",
11147
+ reason: `worktree measurement failed: ${describeCause(error)}`
11148
+ };
11149
+ }
11150
+ const docker = containers.get(worktreePath) ?? unknownContainers("attribution not collected");
11151
+ return {
11152
+ worktree,
11153
+ containerWritable: docker.containerWritable,
11154
+ imageShared: docker.imageShared,
11155
+ reclaimable: deriveReclaimable(worktree, docker.containerWritable)
11156
+ };
11157
+ }
11158
+ function unknownContainers(reason) {
11159
+ return {
11160
+ containerWritable: { status: "unknown", reason },
11161
+ imageShared: { status: "unknown", reason }
11162
+ };
11163
+ }
11164
+ function describeCause(error) {
11165
+ return error instanceof Error ? error.message : String(error);
11166
+ }
11167
+ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
11168
+ const repoPath = resolveRepoPath(options.repo);
11169
+ const commandRunner = dependencies.commandRunner ?? defaultCommandRunner;
11170
+ const duration = parseInactiveFor(options.inactiveFor);
11171
+ const now = options.now ?? /* @__PURE__ */ new Date();
11172
+ const cutoff = new Date(now.getTime() - duration.seconds * 1e3).toISOString();
11173
+ const worktrees = (dependencies.listWorktrees ?? listGitWorktrees)(repoPath);
11174
+ const records = (dependencies.listOwnership ?? listWorkspaceOwnership)(repoPath);
11175
+ let devpods;
11176
+ try {
11177
+ devpods = (dependencies.listDevpods ?? listDevpodWorkspaces)();
11178
+ } catch {
11179
+ devpods = void 0;
11180
+ }
11181
+ let routes;
11182
+ try {
11183
+ routes = (dependencies.readRoutes ?? readHostRouteStateReadOnly)();
11184
+ } catch {
11185
+ routes = void 0;
11186
+ }
11187
+ const measureSize = Boolean(options.measureSize);
11188
+ const measureWorktreeFn = dependencies.measureWorktree ?? measureWorktreeConsumption;
11189
+ const measureContainersFn = dependencies.measureContainers ?? measureContainerConsumption;
11190
+ let containers = /* @__PURE__ */ new Map();
11191
+ if (measureSize) {
11192
+ const worktreePaths = records.map((record) => record.worktreePath);
11193
+ try {
11194
+ containers = measureContainersFn(worktreePaths);
11195
+ } catch (error) {
11196
+ const reason = `container measurement failed: ${describeCause(error)}`;
11197
+ containers = new Map(worktreePaths.map((path24) => [path24, unknownContainers(reason)]));
11198
+ }
11199
+ }
11200
+ const rows = records.map((record) => {
11201
+ const worktree = worktrees.find(
11202
+ (candidate) => sameWorkspacePath(candidate.path, record.worktreePath)
11203
+ );
11204
+ const snapshot = (dependencies.readGitSnapshot ?? ((candidate) => readGitSnapshot(candidate, commandRunner)))(
11205
+ worktree ?? {
11206
+ path: record.worktreePath,
11207
+ branch: record.branch ?? void 0,
11208
+ locked: false,
11209
+ prunable: true
11210
+ }
11211
+ );
11212
+ const row = buildRow(
11213
+ repoPath,
11214
+ record,
11215
+ worktrees,
11216
+ devpods,
11217
+ routes,
11218
+ snapshot,
11219
+ cutoff,
11220
+ Boolean(options.checkMerged),
11221
+ commandRunner,
11222
+ dependencies.inspectOwnership ?? ((recordValue, worktreeValues, devpodValues) => {
11223
+ const status = inspectWorkspaceOwnership(recordValue, worktreeValues, devpodValues);
11224
+ return { ownerStatus: status.ownerStatus, devpodStatus: status.devpodStatus };
11225
+ }),
11226
+ dependencies.inspectIntegration,
11227
+ dependencies.inspectDevpodRuntime ?? inspectDevpodRuntimeStatus
11228
+ );
11229
+ return measureSize ? {
11230
+ ...row,
11231
+ consumption: collectConsumption(record.worktreePath, measureWorktreeFn, containers)
11232
+ } : row;
11233
+ }).sort((left, right) => left.workspace.localeCompare(right.workspace));
11234
+ return {
11235
+ schemaVersion: 2,
11236
+ generatedAt: now.toISOString(),
11237
+ repoPath,
11238
+ inactiveFor: duration.input,
11239
+ cutoff,
11240
+ checkMerged: Boolean(options.checkMerged),
11241
+ measureSize,
11242
+ workspaces: rows
11243
+ };
11244
+ }
11245
+ var import_node_child_process21, import_node_fs25, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
11246
+ var init_workspace_cleanup = __esm({
11247
+ "src/core/workspace-cleanup.ts"() {
11248
+ "use strict";
11249
+ import_node_child_process21 = require("child_process");
11250
+ import_node_fs25 = __toESM(require("fs"));
11251
+ init_devpod_workspaces();
11252
+ init_host_routes();
11253
+ init_repo_config();
11254
+ init_workspace();
11255
+ init_workspace_consumption();
11256
+ init_workspace_ownership();
11257
+ DEFAULT_INACTIVE_FOR = "30d";
11258
+ READ_ONLY_GIT_ENV2 = { ...process.env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" };
11259
+ ACTIVITY_SOURCES = [
11260
+ "devpod.lastUsed",
11261
+ "route.updatedAt",
11262
+ "ownership.updatedAt",
11263
+ "git.headCommitterDate"
11264
+ ];
11265
+ defaultCommandRunner = (command, args, input2) => {
11266
+ const environment = command === "git" ? READ_ONLY_GIT_ENV2 : { ...process.env, LC_ALL: "C" };
11267
+ const result = (0, import_node_child_process21.spawnSync)(command, args, {
11268
+ encoding: "utf-8",
11269
+ env: environment,
11270
+ ...input2 === void 0 ? {} : { input: input2 }
11271
+ });
11272
+ return {
11273
+ status: result.status,
11274
+ stdout: result.stdout ?? "",
11275
+ stderr: result.stderr ?? "",
11276
+ ...result.error ? { error: result.error } : {}
11277
+ };
11278
+ };
11279
+ }
11280
+ });
11281
+
9862
11282
  // src/commands/workspace.ts
9863
11283
  var workspace_exports = {};
9864
11284
  __export(workspace_exports, {
11285
+ runWorkspaceCleanupCommand: () => runWorkspaceCleanupCommand,
9865
11286
  runWorkspaceDownCommand: () => runWorkspaceDownCommand,
9866
11287
  runWorkspaceGcCommand: () => runWorkspaceGcCommand,
9867
11288
  runWorkspaceLsCommand: () => runWorkspaceLsCommand,
@@ -9909,6 +11330,15 @@ function runWorkspaceLsCommand(options) {
9909
11330
  );
9910
11331
  }
9911
11332
  }
11333
+ function runWorkspaceCleanupCommand(options) {
11334
+ const repoPath = resolveGitWorkspaceRepo(options.repo);
11335
+ const report = buildWorkspaceCleanupReport({ ...options, repo: repoPath });
11336
+ if (options.json) {
11337
+ printJSON(report);
11338
+ return;
11339
+ }
11340
+ printWorkspaceCleanupReport(report);
11341
+ }
9912
11342
  async function runWorkspaceDownCommand(target, options) {
9913
11343
  const repoPath = resolveGitWorkspaceRepo(options.repo);
9914
11344
  await workspaceDown(target, {
@@ -9947,7 +11377,9 @@ function runWorkspaceGcCommand(options) {
9947
11377
  var init_workspace2 = __esm({
9948
11378
  "src/commands/workspace.ts"() {
9949
11379
  "use strict";
11380
+ init_output();
9950
11381
  init_repo_config();
11382
+ init_workspace_cleanup();
9951
11383
  init_workspace_gc();
9952
11384
  init_workspace_lifecycle();
9953
11385
  init_workspace_ownership();
@@ -9992,7 +11424,7 @@ var init_version = __esm({
9992
11424
 
9993
11425
  // src/cli.ts
9994
11426
  var import_commander = require("commander");
9995
- var CLI_VERSION = true ? "0.0.34" : "0.0.0-dev";
11427
+ var CLI_VERSION = true ? "0.0.36" : "0.0.0-dev";
9996
11428
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
9997
11429
  function withErrorHandling(action2) {
9998
11430
  return async (...args) => {
@@ -10053,11 +11485,15 @@ program.command("ensure").description("Start and prove a primary or linked check
10053
11485
  });
10054
11486
  })
10055
11487
  );
10056
- program.command("stop").description("Stop one checkout's exact DevPod and free its routes without deleting data").argument("[path]", "Git checkout path (defaults to current directory)").option("--json", "Output JSON").action(
11488
+ program.command("stop").description("Stop one checkout's exact DevPod and free its routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--json", "Output JSON").option("--delete", "Delete the exact DevPod instead of preserving its data").action(
10057
11489
  withErrorHandling(async (repoPath, _options, command) => {
10058
11490
  const options = command.opts();
10059
11491
  const { runStopCommand: runStopCommand2 } = await Promise.resolve().then(() => (init_stop(), stop_exports));
10060
- await runStopCommand2({ path: repoPath, json: Boolean(options.json) });
11492
+ await runStopCommand2({
11493
+ path: repoPath,
11494
+ delete: Boolean(options.delete),
11495
+ json: Boolean(options.json)
11496
+ });
10061
11497
  })
10062
11498
  );
10063
11499
  program.command("exec").description("Run one literal command inside the exact checkout's running DevPod").argument("[args...]", "[path] -- <command...>").allowUnknownOption(true).action(
@@ -10227,8 +11663,8 @@ tlsCommand.command("install").description("Install mkcert certs and enable HTTPS
10227
11663
  await runTLSInstallCommand2();
10228
11664
  })
10229
11665
  );
10230
- var workspaceCommand = program.command("workspace").description("Spin up / list / tear down isolated worktree+devcontainer workspaces");
10231
- workspaceCommand.command("up").description(
11666
+ var workspaceCommand2 = program.command("workspace").description("Spin up / list / tear down isolated worktree+devcontainer workspaces");
11667
+ workspaceCommand2.command("up").description(
10232
11668
  "Create a worktree for <branch>, bring up its devpod, and register namespaced routes"
10233
11669
  ).argument("<branch>", "Git branch to base the workspace on").option("--path <dir>", "Worktree directory (default: <repo>/trees/<workspace>)").option("--no-devpod", "Only create the worktree; do not start the environment or change routes").option("--open", "Open the namespaced routes after registering").option("--repo <path>", "Main repository path (defaults to current directory)").action(
10234
11670
  withErrorHandling(async (branch, _options, command) => {
@@ -10242,7 +11678,7 @@ workspaceCommand.command("up").description(
10242
11678
  });
10243
11679
  })
10244
11680
  );
10245
- workspaceCommand.command("ensure").description("Start and prove a primary or linked checkout's DevPod, upstreams, and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
11681
+ workspaceCommand2.command("ensure").description("Start and prove a primary or linked checkout's DevPod, upstreams, and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
10246
11682
  withErrorHandling(
10247
11683
  async (worktreePath, _options, command) => {
10248
11684
  const options = command.opts();
@@ -10255,28 +11691,41 @@ workspaceCommand.command("ensure").description("Start and prove a primary or lin
10255
11691
  }
10256
11692
  )
10257
11693
  );
10258
- workspaceCommand.command("ls").description("List git worktrees with their workspace token and active route count").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").action(
11694
+ workspaceCommand2.command("ls").description("List git worktrees with their workspace token and active route count").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").action(
10259
11695
  withErrorHandling(async (_options, command) => {
10260
11696
  const options = command.opts();
10261
11697
  const { runWorkspaceLsCommand: runWorkspaceLsCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
10262
11698
  runWorkspaceLsCommand2(options);
10263
11699
  })
10264
11700
  );
10265
- workspaceCommand.command("gc").description("Report or clean missing ledger-owned workspace resources").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").option("--yes", "Delete eligible DevPod, route, and ownership resources").action(
11701
+ workspaceCommand2.command("cleanup").description("Report managed workspace activity, identity, checkout, and integration evidence").option("--repo <path>", "Main repository path (defaults to current directory)").option("--inactive-for <duration>", "Inactive threshold using Ns, Nm, Nh, Nd, or Nw", "30d").option("--check-merged", "Enable read-only origin and GitHub/GitLab integration checks").option("--measure-size", "Measure per-workspace storage consumption (slower, still read-only)").option("--json", "Output the stable cleanup report as JSON").action(
11702
+ withErrorHandling(async (_options, command) => {
11703
+ const options = command.opts();
11704
+ const { runWorkspaceCleanupCommand: runWorkspaceCleanupCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
11705
+ runWorkspaceCleanupCommand2({
11706
+ repo: options.repo,
11707
+ inactiveFor: options.inactiveFor,
11708
+ checkMerged: Boolean(options.checkMerged),
11709
+ measureSize: Boolean(options.measureSize),
11710
+ json: Boolean(options.json)
11711
+ });
11712
+ })
11713
+ );
11714
+ workspaceCommand2.command("gc").description("Report or clean missing ledger-owned workspace resources").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").option("--yes", "Delete eligible DevPod, route, and ownership resources").action(
10266
11715
  withErrorHandling(async (_options, command) => {
10267
11716
  const options = command.opts();
10268
11717
  const { runWorkspaceGcCommand: runWorkspaceGcCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
10269
11718
  runWorkspaceGcCommand2(options);
10270
11719
  })
10271
11720
  );
10272
- workspaceCommand.command("stop").description("Stop a workspace's DevPod and free routes while preserving its worktree and data").argument("<workspace>", "Workspace token or live branch name").option("--repo <path>", "Main repository path (defaults to current directory)").action(
11721
+ workspaceCommand2.command("stop").description("Stop a workspace's DevPod and free routes while preserving its worktree and data").argument("<workspace>", "Workspace token or live branch name").option("--repo <path>", "Main repository path (defaults to current directory)").action(
10273
11722
  withErrorHandling(async (target, _options, command) => {
10274
11723
  const options = command.opts();
10275
11724
  const { runWorkspaceStopCommand: runWorkspaceStopCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
10276
11725
  await runWorkspaceStopCommand2(target, options);
10277
11726
  })
10278
11727
  );
10279
- workspaceCommand.command("down").description("Delete a workspace's DevPod and routes, then remove its clean Git worktree").argument("<workspace>", "Workspace token or live branch name").option("--keep-worktree", "Delete runtime resources but preserve the Git worktree and record").option("--repo <path>", "Main repository path (defaults to current directory)").action(
11728
+ workspaceCommand2.command("down").description("Delete a workspace's DevPod and routes, then remove its clean Git worktree").argument("<workspace>", "Workspace token or live branch name").option("--keep-worktree", "Delete runtime resources but preserve the Git worktree and record").option("--repo <path>", "Main repository path (defaults to current directory)").action(
10280
11729
  withErrorHandling(async (target, _options, command) => {
10281
11730
  const options = command.opts();
10282
11731
  const { runWorkspaceDownCommand: runWorkspaceDownCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));