@devrouter/cli 0.0.51 → 0.0.52

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.
@@ -187,9 +187,9 @@ process_owned() {
187
187
  [ "$actual_pgid" = "$pgid" ] || return 1
188
188
  [ "$pgid" = "$pid" ] || return 1
189
189
  tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null |
190
- grep -Fqx "DEVROUTER_PROCESS_NAME=$name" || return 1
190
+ grep -Fx "DEVROUTER_PROCESS_NAME=$name" >/dev/null || return 1
191
191
  tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null |
192
- grep -Fqx "DEVROUTER_PROCESS_FINGERPRINT=$expected_fingerprint"
192
+ grep -Fx "DEVROUTER_PROCESS_FINGERPRINT=$expected_fingerprint" >/dev/null
193
193
  }
194
194
 
195
195
  process_group_alive() {
@@ -217,7 +217,7 @@ is_helper_process() {
217
217
  local candidate="$1"
218
218
 
219
219
  [ -r "/proc/$candidate/cmdline" ] || return 1
220
- tr '\0' '\n' <"/proc/$candidate/cmdline" 2>/dev/null | grep -Fqx "$0"
220
+ tr '\0' '\n' <"/proc/$candidate/cmdline" 2>/dev/null | grep -Fx "$0" >/dev/null
221
221
  }
222
222
 
223
223
  marked_process_exists() {
@@ -230,7 +230,7 @@ marked_process_exists() {
230
230
  pid="${pid%/environ}"
231
231
  process_alive "$pid" || continue
232
232
  is_self_or_ancestor "$pid" && continue
233
- if tr '\0' '\n' <"$environ" 2>/dev/null | grep -Fqx "DEVROUTER_PROCESS_NAME=$name"; then
233
+ if tr '\0' '\n' <"$environ" 2>/dev/null | grep -Fx "DEVROUTER_PROCESS_NAME=$name" >/dev/null; then
234
234
  return 0
235
235
  fi
236
236
  done
package/dist/devrouter.js CHANGED
@@ -400,7 +400,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
400
400
  - \`devrouter -V [--repo .]\`: show installed CLI version, local repo version, and next upgrade target
401
401
  - \`devrouter upgrade [version] [--repo .]\`: list upgrade targets or print target Agent Adaptation Prompt
402
402
  - \`devrouter setup --yes [--repo .] [--json] [--workspace-runtime <devpod|devsy>]\`: first-run machine setup plus structured diagnostics; explicit Devsy selection acquires its verified agent
403
- - \`devrouter ensure [path] [--profile <name>] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts; a managed profile selects its independent resource dimensions
403
+ - \`devrouter ensure [path] [--profile <name>] [--repair] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts; explicit repair uses the recorded degraded profile and unchanged retained configuration
404
404
  - \`devrouter profile resolve --repo <path> [--profile <selection>] [--json]\`: resolve exact profile resources for automation without starting or inspecting a runtime
405
405
  - \`devrouter profile plan --repo <path> [--profile <selection>] --contract <repo-relative-yaml> [--output <path>] [--json]\`: validate repository-owned resource policy and emit literal bindings without runtime access
406
406
  - \`devrouter stop [path] [--delete] [--json]\`: stop the exact workspace runtime and remove exact routes; \`--delete\` explicitly deletes its ownership-proven data without removing the checkout
@@ -425,7 +425,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
425
425
  - \`devrouter app exec <name> [--shell] [--env <env>] [--workspace <slug>] -- <cmd>\`: one-shot command with resolved dep env
426
426
  - \`devrouter app rm <name> [--keep-config]\`: remove app entry (\`--keep-config\` frees only the live route/hostname, leaves \`.devrouter.yml\` untouched)
427
427
  - \`devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open]\`: create a worktree and start/prove it unless create-only mode is requested
428
- - \`devrouter workspace ensure [path] [--profile <name>] [--open] [--json]\`: compatibility alias of \`devrouter ensure\`
428
+ - \`devrouter workspace ensure [path] [--profile <name>] [--repair] [--open] [--json]\`: compatibility alias of \`devrouter ensure\`
429
429
  - \`devrouter workspace ls [--json]\`: list ownership, Git, workspace runtime, route, path, and branch evidence
430
430
  - \`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
431
431
  - \`devrouter workspace stop <workspace|branch>\`: stop DevPod and routes; preserve checkout, owner record, and data
@@ -2691,7 +2691,7 @@ function loadRepoConfig(repoPath) {
2691
2691
  const config = parseConfig(parsed ?? {}, configPath);
2692
2692
  const requiredVersion = config.devrouter?.version;
2693
2693
  if (requiredVersion && !hasWarnedVersionMismatch) {
2694
- const cliVersion = true ? "0.0.51" : "0.0.0-dev";
2694
+ const cliVersion = true ? "0.0.52" : "0.0.0-dev";
2695
2695
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
2696
2696
  hasWarnedVersionMismatch = true;
2697
2697
  process.stderr.write(
@@ -3273,6 +3273,7 @@ function buildOnboardingPrompt(options = {}) {
3273
3273
  "- devrouter repo devcontainer write --repo <REPO_PATH> --yes",
3274
3274
  "- devrouter repo devcontainer verify --repo <REPO_PATH> --json",
3275
3275
  "- Normal primary or linked startup: devrouter ensure <REPO_PATH> --json",
3276
+ "- Degraded recovery: explicit devrouter ensure <REPO_PATH> --repair --json validates the recorded profile and unchanged retained configuration; stopped primary requires all project containers stopped and zero checkout routes. It starts only retained IDs, never provider bootstrap/recreation. Inspect failures before retrying.",
3276
3277
  "- Managed selective startup: devrouter ensure <REPO_PATH> --profile <name> --json",
3277
3278
  "- Container seed/migration: devrouter exec <REPO_PATH> -- <command...>",
3278
3279
  "- Compatibility verification only after ensure: devrouter repo devcontainer verify --repo <REPO_PATH> --live --yes --json",
@@ -3391,7 +3392,7 @@ var init_ai_prompt = __esm({
3391
3392
  purpose: "Write/update devrouter section in the repo's AGENTS.md and install the devrouter skill."
3392
3393
  },
3393
3394
  {
3394
- command: "devrouter ensure [path] [--profile <name>] [--open] [--json]",
3395
+ command: "devrouter ensure [path] [--profile <name>] [--repair] [--open] [--json]",
3395
3396
  purpose: "Canonical startup and proof for an exact primary or linked checkout; atomically publishes routes after readiness."
3396
3397
  },
3397
3398
  {
@@ -5691,19 +5692,12 @@ function assertSafeContainerId(containerId) {
5691
5692
  throw new Error(`Container '${containerId}' is not a safe exact identifier.`);
5692
5693
  }
5693
5694
  }
5694
- function startExactManagedServices(options) {
5695
- if (options.services.length === 0) return;
5696
- const project = safeComposeProject(options.composeProject);
5697
- const services = Array.from(new Set(options.services));
5698
- if (services.some((service) => !options.plan.composeServices.includes(service))) {
5699
- throw new Error("Exact Compose startup contains a service outside the validated model.");
5700
- }
5701
- const fileArgs = options.plan.composeFiles.flatMap((file) => ["-f", file]);
5695
+ function managedComposeEnvironment(workspace) {
5702
5696
  const env = { ...process.env };
5703
- if (options.workspace) {
5704
- env.WORKSPACE = options.workspace.token;
5705
- env.DEVROUTER_WORKSPACE = options.workspace.token;
5706
- env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
5697
+ if (workspace) {
5698
+ env.WORKSPACE = workspace.token;
5699
+ env.DEVROUTER_WORKSPACE = workspace.token;
5700
+ env.DEVROUTER_GIT_COMMON_DIR = workspace.gitCommonDir;
5707
5701
  env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
5708
5702
  } else {
5709
5703
  delete env.WORKSPACE;
@@ -5711,6 +5705,87 @@ function startExactManagedServices(options) {
5711
5705
  delete env.DEVROUTER_GIT_COMMON_DIR;
5712
5706
  delete env.DEVCONTAINER_COMPOSE_OVERLAY;
5713
5707
  }
5708
+ return env;
5709
+ }
5710
+ function assertReadableComposeFile(file) {
5711
+ try {
5712
+ if (!import_node_fs15.default.statSync(file).isFile()) throw new Error("not a regular file");
5713
+ import_node_fs15.default.accessSync(file, import_node_fs15.default.constants.R_OK);
5714
+ } catch {
5715
+ throw new Error("Managed container Compose configuration contains an unreadable file.");
5716
+ }
5717
+ }
5718
+ function assertManagedContainerConfigUnchanged(options) {
5719
+ if (options.containers.length === 0) {
5720
+ throw new Error("Managed container configuration cannot be verified without containers.");
5721
+ }
5722
+ for (const container of options.containers) {
5723
+ const project = container.labels["com.docker.compose.project"];
5724
+ const service = container.labels["com.docker.compose.service"];
5725
+ const workingDirectory = container.labels["com.docker.compose.project.working_dir"];
5726
+ const recordedConfigFiles = container.labels["com.docker.compose.project.config_files"];
5727
+ const expectedHash = container.labels["com.docker.compose.config-hash"];
5728
+ if (!project || !service || !workingDirectory || !recordedConfigFiles || !expectedHash || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(service) || !/^[a-f0-9]{64}$/.test(expectedHash)) {
5729
+ throw new Error("Managed container is missing valid Compose identity and hash labels.");
5730
+ }
5731
+ const composeProject = safeComposeProject(project);
5732
+ const configFiles = recordedConfigFiles.split(",").map((file) => file.trim());
5733
+ if (configFiles.some((file) => file.length === 0)) {
5734
+ throw new Error("Managed container has an invalid recorded Compose file list.");
5735
+ }
5736
+ if (options.plan.composeFiles.some(
5737
+ (expected) => !configFiles.some((actual) => sameWorkspacePath(actual, expected))
5738
+ )) {
5739
+ throw new Error("Managed container Compose files do not include the managed plan files.");
5740
+ }
5741
+ for (const file of configFiles) assertReadableComposeFile(file);
5742
+ const fileArgs = configFiles.flatMap((file) => ["-f", file]);
5743
+ const result = (() => {
5744
+ try {
5745
+ return (0, import_node_child_process7.spawnSync)(
5746
+ "docker",
5747
+ [
5748
+ "compose",
5749
+ "--project-name",
5750
+ composeProject,
5751
+ "--project-directory",
5752
+ workingDirectory,
5753
+ ...fileArgs,
5754
+ "config",
5755
+ "--hash",
5756
+ service
5757
+ ],
5758
+ {
5759
+ cwd: workingDirectory,
5760
+ encoding: "utf-8",
5761
+ env: managedComposeEnvironment(options.workspace),
5762
+ stdio: ["ignore", "pipe", "ignore"],
5763
+ timeout: COMPOSE_HASH_TIMEOUT_MS
5764
+ }
5765
+ );
5766
+ } catch {
5767
+ throw new Error(`Could not verify managed Compose configuration for service '${service}'.`);
5768
+ }
5769
+ })();
5770
+ if (result.status !== 0 || result.error) {
5771
+ throw new Error(`Could not verify managed Compose configuration for service '${service}'.`);
5772
+ }
5773
+ const output2 = typeof result.stdout === "string" ? result.stdout.trim() : "";
5774
+ const match = /^(\S+)\s+([a-f0-9]{64})$/.exec(output2);
5775
+ if (!match || match[1] !== service || match[2] !== expectedHash) {
5776
+ throw new Error(`Managed Compose configuration changed for service '${service}'.`);
5777
+ }
5778
+ }
5779
+ }
5780
+ function startExactManagedServices(options) {
5781
+ if (options.services.length === 0) return;
5782
+ const project = safeComposeProject(options.composeProject);
5783
+ const services = Array.from(new Set(options.services));
5784
+ if (services.some((service) => !options.plan.composeServices.includes(service))) {
5785
+ throw new Error("Exact Compose startup contains a service outside the validated model.");
5786
+ }
5787
+ const fileArgs = options.plan.composeFiles.flatMap((file) => ["-f", file]);
5788
+ const env = managedComposeEnvironment(options.workspace);
5714
5789
  const result = (0, import_node_child_process7.spawnSync)(
5715
5790
  "docker",
5716
5791
  [
@@ -5749,7 +5824,7 @@ function stopExactManagedService(containerId, service) {
5749
5824
  throw new Error(`Could not stop exact managed service '${service}' (${containerId}).`);
5750
5825
  }
5751
5826
  }
5752
- var import_node_child_process7, import_node_crypto7, import_node_fs15, import_node_path15, import_yaml4, MANAGED_DEVCONTAINER_PATH, MANAGED_DEVCONTAINER_MARKER;
5827
+ var import_node_child_process7, import_node_crypto7, import_node_fs15, import_node_path15, import_yaml4, MANAGED_DEVCONTAINER_PATH, MANAGED_DEVCONTAINER_MARKER, COMPOSE_HASH_TIMEOUT_MS;
5753
5828
  var init_devcontainer_profile = __esm({
5754
5829
  "src/core/devcontainer-profile.ts"() {
5755
5830
  "use strict";
@@ -5761,8 +5836,10 @@ var init_devcontainer_profile = __esm({
5761
5836
  init_atomic_file();
5762
5837
  init_devcontainer_config();
5763
5838
  init_paths();
5839
+ init_workspace();
5764
5840
  MANAGED_DEVCONTAINER_PATH = ".devcontainer/devcontainer.devrouter.json";
5765
5841
  MANAGED_DEVCONTAINER_MARKER = "// devrouter:managed devcontainer profile";
5842
+ COMPOSE_HASH_TIMEOUT_MS = 1e4;
5766
5843
  }
5767
5844
  });
5768
5845
 
@@ -5857,7 +5934,7 @@ var init_devpod_environment = __esm({
5857
5934
  import_node_child_process8 = require("child_process");
5858
5935
  import_node_path16 = __toESM(require("path"));
5859
5936
  init_workspace();
5860
- 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":{{json (index .Config.Labels "com.docker.compose.project")}},"com.docker.compose.service":{{json (index .Config.Labels "com.docker.compose.service")}},"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}}}';
5937
+ 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":{{json (index .Config.Labels "com.docker.compose.project")}},"com.docker.compose.service":{{json (index .Config.Labels "com.docker.compose.service")}},"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")}},"com.docker.compose.config-hash":{{json (index .Config.Labels "com.docker.compose.config-hash")}}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}}}';
5861
5938
  SIZE_INSPECT_TEMPLATE = SAFE_INSPECT_TEMPLATE.replace(
5862
5939
  /}$/,
5863
5940
  ',"sizeRw":{{json (index . "SizeRw")}},"sizeRootFs":{{json (index . "SizeRootFs")}}}'
@@ -6172,7 +6249,7 @@ function collectManagedRuntimeStatus(options) {
6172
6249
  let state;
6173
6250
  let stateReadFailed = false;
6174
6251
  try {
6175
- state = readManagedRuntimeState(options.repoPath, options.workspace);
6252
+ state = options.candidateState ?? readManagedRuntimeState(options.repoPath, options.workspace);
6176
6253
  } catch {
6177
6254
  stateReadFailed = true;
6178
6255
  }
@@ -8054,6 +8131,12 @@ function validateRecord(value, expectedWorkspace) {
8054
8131
  updatedAt: validateTimestamp(candidate.updatedAt, "updatedAt")
8055
8132
  };
8056
8133
  }
8134
+ function recordPath(repoPath, workspace) {
8135
+ return import_node_path22.default.join(
8136
+ ownershipDirectory(repoPath),
8137
+ `${validateWorkspace(workspace, "workspace")}.json`
8138
+ );
8139
+ }
8057
8140
  function readRecordFile(filePath, expectedWorkspace) {
8058
8141
  let parsed;
8059
8142
  try {
@@ -8066,6 +8149,15 @@ function readRecordFile(filePath, expectedWorkspace) {
8066
8149
  }
8067
8150
  return validateRecord(parsed, expectedWorkspace);
8068
8151
  }
8152
+ function readWorkspaceOwnership(repoPath, workspace) {
8153
+ const filePath = recordPath(repoPath, workspace);
8154
+ try {
8155
+ return readRecordFile(filePath, workspace);
8156
+ } catch (error) {
8157
+ if (error.code === "ENOENT") return void 0;
8158
+ throw error;
8159
+ }
8160
+ }
8069
8161
  function listWorkspaceOwnership(repoPath) {
8070
8162
  const directory = ownershipDirectory(repoPath);
8071
8163
  return listWorkspaceOwnershipInDirectory(directory);
@@ -9015,7 +9107,7 @@ async function buildDoctorReport(options = {}) {
9015
9107
  const config = runtimeConfig.config;
9016
9108
  loadedConfig = config;
9017
9109
  loadedWorkspace = runtimeConfig.workspace;
9018
- const cliVersion = true ? "0.0.51" : "0.0.0-dev";
9110
+ const cliVersion = true ? "0.0.52" : "0.0.0-dev";
9019
9111
  const configVersion = config.devrouter?.version;
9020
9112
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
9021
9113
  addCheck(checks, {
@@ -9727,9 +9819,9 @@ async function ensureRouteInfrastructure(apps, options = {}) {
9727
9819
  startRouterStack();
9728
9820
  return tlsCoverage;
9729
9821
  }
9730
- async function replacePublishedProxyRoutes(repoPath, config, workspace) {
9822
+ async function replacePublishedProxyRoutes(repoPath, config, workspace, options = {}) {
9731
9823
  const apps = proxyAppsFromConfig(config);
9732
- const tlsCoverage = await ensureRouteInfrastructure(apps, { repoPath });
9824
+ const tlsCoverage = options.prepareInfrastructure === false ? { refreshed: false } : await ensureRouteInfrastructure(apps, { repoPath });
9733
9825
  const routes = apps.map((app) => {
9734
9826
  const upstream = parseUpstream(app.upstream);
9735
9827
  return {
@@ -9769,7 +9861,7 @@ function inspectRouterApi(protocol) {
9769
9861
  "--show-error",
9770
9862
  "--fail",
9771
9863
  "--max-time",
9772
- "2",
9864
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS,
9773
9865
  `${ROUTER_API_BASE}/${protocol}/routers?per_page=${ROUTER_API_PAGE_SIZE}`
9774
9866
  ],
9775
9867
  { encoding: "utf-8" }
@@ -9801,6 +9893,161 @@ function inspectRouterApi(protocol) {
9801
9893
  )
9802
9894
  };
9803
9895
  }
9896
+ function isRecord2(value) {
9897
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9898
+ }
9899
+ function inspectNamedRouterApi(protocol, resource, name) {
9900
+ const result = (0, import_node_child_process20.spawnSync)(
9901
+ "curl",
9902
+ [
9903
+ "--silent",
9904
+ "--show-error",
9905
+ "--fail",
9906
+ "--max-time",
9907
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS,
9908
+ `${ROUTER_API_BASE}/${protocol}/${resource}/${encodeURIComponent(name)}`
9909
+ ],
9910
+ { encoding: "utf-8" }
9911
+ );
9912
+ if (result.status !== 0) {
9913
+ return {
9914
+ ok: false,
9915
+ details: result.stderr?.trim() || `curl exited with status ${result.status ?? "unknown"}`
9916
+ };
9917
+ }
9918
+ let value;
9919
+ try {
9920
+ value = JSON.parse(result.stdout);
9921
+ } catch {
9922
+ return {
9923
+ ok: false,
9924
+ details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned invalid JSON`
9925
+ };
9926
+ }
9927
+ if (!isRecord2(value)) {
9928
+ return {
9929
+ ok: false,
9930
+ details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned a non-object`
9931
+ };
9932
+ }
9933
+ return { ok: true, value };
9934
+ }
9935
+ function renderExpectedRoute(route) {
9936
+ const protocol = route.protocol ?? "http";
9937
+ const routerName = buildHostRouteRouterName(route.repoPath, route.name);
9938
+ const routeState = {
9939
+ id: buildHostRouteId(route.repoPath, route.name),
9940
+ name: route.name,
9941
+ host: route.host,
9942
+ protocol,
9943
+ tcpProtocol: route.tcpProtocol,
9944
+ repoPath: route.repoPath,
9945
+ port: route.port,
9946
+ mode: route.mode,
9947
+ upstreamHost: route.upstreamHost,
9948
+ pid: route.pid,
9949
+ command: route.command,
9950
+ workspace: route.workspace,
9951
+ createdAt: "",
9952
+ updatedAt: ""
9953
+ };
9954
+ const document = buildHostRoutesDocument([routeState], false);
9955
+ const protocolDocument = document[protocol];
9956
+ if (!isRecord2(protocolDocument)) return void 0;
9957
+ const routers = protocolDocument.routers;
9958
+ const services = protocolDocument.services;
9959
+ if (!isRecord2(routers) || !isRecord2(services)) return void 0;
9960
+ const routerKey = routerName.slice(0, routerName.lastIndexOf("@"));
9961
+ const router = routers[routerKey];
9962
+ const service = services[routerKey];
9963
+ if (!isRecord2(router) || !isRecord2(service)) return void 0;
9964
+ const rule = router.rule;
9965
+ const serviceReference = router.service;
9966
+ const loadBalancer = service.loadBalancer;
9967
+ const servers = isRecord2(loadBalancer) ? loadBalancer.servers : void 0;
9968
+ if (typeof rule !== "string" || serviceReference !== routerKey || !isRecord2(loadBalancer) || !Array.isArray(servers) || servers.length !== 1 || !isRecord2(servers[0])) {
9969
+ return void 0;
9970
+ }
9971
+ const serverField = protocol === "http" ? "url" : "address";
9972
+ const server = servers[0][serverField];
9973
+ if (typeof server !== "string") return void 0;
9974
+ const provider = routerName.slice(routerName.lastIndexOf("@")).trim();
9975
+ if (!provider.startsWith("@")) return void 0;
9976
+ return {
9977
+ protocol,
9978
+ routerName,
9979
+ serviceKey: routerKey,
9980
+ serviceName: `${routerKey}${provider}`,
9981
+ rule,
9982
+ serverField,
9983
+ server
9984
+ };
9985
+ }
9986
+ function inspectExpectedRouteMatch(route) {
9987
+ const expected = renderExpectedRoute(route);
9988
+ const routeName = buildHostRouteRouterName(route.repoPath, route.name);
9989
+ if (!expected) {
9990
+ return {
9991
+ ok: false,
9992
+ routes: [routeName],
9993
+ details: "host-route renderer returned an incomplete route configuration"
9994
+ };
9995
+ }
9996
+ const routerResult = inspectNamedRouterApi(expected.protocol, "routers", expected.routerName);
9997
+ if (!routerResult.ok) {
9998
+ return { ok: false, routes: [routeName], details: routerResult.details };
9999
+ }
10000
+ if (routerResult.value.status !== "enabled" || routerResult.value.name !== expected.routerName || routerResult.value.rule !== expected.rule || routerResult.value.service !== expected.serviceKey && routerResult.value.service !== expected.serviceName) {
10001
+ return {
10002
+ ok: false,
10003
+ routes: [routeName],
10004
+ details: "active Traefik router does not match the rendered route"
10005
+ };
10006
+ }
10007
+ const serviceResult = inspectNamedRouterApi(expected.protocol, "services", expected.serviceName);
10008
+ if (!serviceResult.ok) {
10009
+ return { ok: false, routes: [routeName], details: serviceResult.details };
10010
+ }
10011
+ const loadBalancer = serviceResult.value.loadBalancer;
10012
+ const servers = isRecord2(loadBalancer) ? loadBalancer.servers : void 0;
10013
+ const server = Array.isArray(servers) && servers.length === 1 ? servers[0] : void 0;
10014
+ if (serviceResult.value.status !== "enabled" || serviceResult.value.name !== expected.serviceName || !isRecord2(loadBalancer) || !Array.isArray(servers) || servers.length !== 1 || !isRecord2(server) || server[expected.serverField] !== expected.server) {
10015
+ return {
10016
+ ok: false,
10017
+ routes: [routeName],
10018
+ details: "active Traefik service does not match the rendered route"
10019
+ };
10020
+ }
10021
+ return { ok: true };
10022
+ }
10023
+ function inspectExpectedRouteMatches(routes) {
10024
+ const mismatched = [];
10025
+ const failures = [];
10026
+ for (const route of routes) {
10027
+ const result = inspectExpectedRouteMatch(route);
10028
+ if (!result.ok) {
10029
+ mismatched.push(...result.routes);
10030
+ failures.push(result.details);
10031
+ }
10032
+ }
10033
+ return mismatched.length === 0 ? { ok: true } : {
10034
+ ok: false,
10035
+ routes: mismatched,
10036
+ details: failures.join("; ")
10037
+ };
10038
+ }
10039
+ async function waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs) {
10040
+ const deadline = Date.now() + timeoutMs;
10041
+ let result;
10042
+ do {
10043
+ result = inspectExpectedRouteMatches(routes);
10044
+ if (result.ok) return result;
10045
+ if (Date.now() < deadline) {
10046
+ await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
10047
+ }
10048
+ } while (Date.now() < deadline);
10049
+ return result;
10050
+ }
9804
10051
  function inspectExpectedRoutes(routes, expectation) {
9805
10052
  const mismatched = [];
9806
10053
  const failures = [];
@@ -9857,6 +10104,10 @@ async function ensureTraefikRouteExpectation(routes, expectation, options) {
9857
10104
  pollIntervalMs
9858
10105
  );
9859
10106
  if (initial.ok) return { restarted: false };
10107
+ if (options.allowRestart === false)
10108
+ throw new Error(
10109
+ "Traefik routes did not reload; shared router restart is disabled for this operation."
10110
+ );
9860
10111
  import_node_fs24.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
9861
10112
  return withFileLock(
9862
10113
  ROUTER_RELOAD_LOCK_FILE,
@@ -9887,10 +10138,21 @@ async function ensureTraefikRouteExpectation(routes, expectation, options) {
9887
10138
  async function ensureTraefikRoutesLoaded(routes, options = {}) {
9888
10139
  return ensureTraefikRouteExpectation(routes, "loaded", options);
9889
10140
  }
10141
+ async function ensureTraefikRoutesMatch(routes, options = {}) {
10142
+ if (routes.length === 0) return;
10143
+ const timeoutMs = options.initialTimeoutMs ?? options.recoveryTimeoutMs ?? DEFAULT_INITIAL_TIMEOUT_MS;
10144
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
10145
+ const result = await waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs);
10146
+ if (!result.ok) {
10147
+ throw new Error(
10148
+ `Traefik route configuration did not match rendered routes: ${result.routes.join(", ")} (${result.details})`
10149
+ );
10150
+ }
10151
+ }
9890
10152
  async function ensureTraefikRoutesRemoved(routes, options = {}) {
9891
10153
  return ensureTraefikRouteExpectation(routes, "removed", options);
9892
10154
  }
9893
- var import_node_child_process20, import_node_fs24, import_node_path25, ROUTER_API_BASE, ROUTER_RELOAD_LOCK_FILE, DEFAULT_INITIAL_TIMEOUT_MS, DEFAULT_RECOVERY_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, ROUTER_API_PAGE_SIZE;
10155
+ var import_node_child_process20, import_node_fs24, import_node_path25, ROUTER_API_BASE, ROUTER_RELOAD_LOCK_FILE, ROUTER_API_REQUEST_TIMEOUT_SECONDS, DEFAULT_INITIAL_TIMEOUT_MS, DEFAULT_RECOVERY_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, ROUTER_API_PAGE_SIZE;
9894
10156
  var init_traefik_route_health = __esm({
9895
10157
  "src/core/traefik-route-health.ts"() {
9896
10158
  "use strict";
@@ -9902,6 +10164,7 @@ var init_traefik_route_health = __esm({
9902
10164
  init_router();
9903
10165
  ROUTER_API_BASE = "http://127.0.0.1:8080/api";
9904
10166
  ROUTER_RELOAD_LOCK_FILE = import_node_path25.default.join(DEVROUTER_HOME, "router-reload.lock");
10167
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS = "2";
9905
10168
  DEFAULT_INITIAL_TIMEOUT_MS = 3e3;
9906
10169
  DEFAULT_RECOVERY_TIMEOUT_MS = 1e4;
9907
10170
  DEFAULT_POLL_INTERVAL_MS = 250;
@@ -10165,7 +10428,7 @@ function validateWorkspaceContainers(containers, options) {
10165
10428
  if (options.target.kind === "linked") {
10166
10429
  assertOverlay(appContainer, options.repoPath);
10167
10430
  }
10168
- assertReady(appContainer, "Workspace app");
10431
+ if (!options.allowStopped) assertReady(appContainer, "Workspace app");
10169
10432
  if (options.target.kind === "linked") {
10170
10433
  const gitCommonDir = options.target.gitCommonDir;
10171
10434
  const gitMount = appContainer.mounts.find(
@@ -10317,6 +10580,142 @@ function resolveLinkedTarget(repoPath) {
10317
10580
  gitCommonDir: resolveGitCommonDir(repoPath)
10318
10581
  };
10319
10582
  }
10583
+ function resolveRepairTarget(repoPath, linked) {
10584
+ const workspace = linked ? readPersistedWorkspace(repoPath) : void 0;
10585
+ if (linked && !workspace) throw new Error("Repair requires an existing workspace identity.");
10586
+ const state = readManagedRuntimeState(repoPath, workspace);
10587
+ if (state?.status !== "degraded") {
10588
+ throw new Error("Repair requires a persisted degraded managed runtime.");
10589
+ }
10590
+ resetWorkspaceRuntimeCaches();
10591
+ const snapshots = getWorkspaceRegistrySnapshots();
10592
+ if (snapshots.unavailable.length) throw new Error("Repair cannot read every provider registry.");
10593
+ const matches = [...snapshots.devpod ?? [], ...snapshots.devsy ?? []].filter(
10594
+ (entry) => entry.id === state.devpodId || sameWorkspacePath(entry.source.localFolder, repoPath)
10595
+ );
10596
+ if (matches.length !== 1 || matches[0].id !== state.devpodId || !sameWorkspacePath(matches[0].source.localFolder, repoPath)) {
10597
+ throw new Error("Repair requires one exact retained provider registration.");
10598
+ }
10599
+ const selected = snapshots[resolveWorkspaceRuntimeOrDefault(repoPath)] ?? [];
10600
+ if (!selected.some(
10601
+ (entry) => entry.id === state.devpodId && sameWorkspacePath(entry.source.localFolder, repoPath)
10602
+ )) {
10603
+ throw new Error("Repair provider selection does not own the retained workspace.");
10604
+ }
10605
+ if (workspace) {
10606
+ const owner = readWorkspaceOwnership(repoPath, workspace);
10607
+ if (!owner || owner.devpodId !== state.devpodId || !sameWorkspacePath(owner.worktreePath, repoPath)) {
10608
+ throw new Error("Repair requires the existing exact workspace owner record.");
10609
+ }
10610
+ return {
10611
+ kind: "linked",
10612
+ workspace,
10613
+ devpodId: state.devpodId,
10614
+ hadExactDevpod: true,
10615
+ gitCommonDir: resolveGitCommonDir(repoPath)
10616
+ };
10617
+ }
10618
+ return { kind: "primary", devpodId: state.devpodId, hadExactDevpod: true };
10619
+ }
10620
+ function assertRepairBaseline(options) {
10621
+ const { repoPath, target, state, plan, config, profile, processes } = options;
10622
+ const desired = {
10623
+ apps: proxyAppsFromConfig(config).map((app) => app.name),
10624
+ services: plan.desiredProfileServices,
10625
+ processes
10626
+ };
10627
+ if (state.profile !== profile || state.sourceConfigSha256 !== plan.sourceConfigSha256 || state.effectiveConfigSha256 !== plan.effectiveConfigSha256 || Object.keys(desired).some((key) => {
10628
+ const resource = key;
10629
+ return JSON.stringify([...desired[resource]].sort()) !== JSON.stringify([...state.desired[resource]].sort());
10630
+ }) || inspectManagedDevcontainerGeneratedConfig(plan).status !== "valid") {
10631
+ throw new Error(
10632
+ "Repair requires the recorded profile, resource sets, and unchanged managed configuration."
10633
+ );
10634
+ }
10635
+ if (options.routes.some(
10636
+ (route) => route.workspace !== target.workspace || !desired.apps.includes(route.name)
10637
+ )) {
10638
+ throw new Error("Repair refuses unexpected active routes.");
10639
+ }
10640
+ const containers = inspectWorkspaceContainers();
10641
+ const retained = plan.desiredServices.map((service) => {
10642
+ const matches = exactWorkspaceServiceContainers(
10643
+ containers,
10644
+ repoPath,
10645
+ state.composeProject,
10646
+ service,
10647
+ plan.composeFiles
10648
+ );
10649
+ if (matches.length !== 1)
10650
+ throw new Error(`Repair requires one retained container for '${service}'.`);
10651
+ return matches[0];
10652
+ });
10653
+ const primary = retained.find(
10654
+ (c) => c.labels["com.docker.compose.service"] === plan.primaryService
10655
+ );
10656
+ if (!primary) throw new Error("Repair cannot identify the retained primary container.");
10657
+ if (!primary.state.Running && (options.routes.length > 0 || containers.some(
10658
+ (container) => container.labels["com.docker.compose.project"] === state.composeProject && container.state.Running !== false
10659
+ ))) {
10660
+ throw new Error(
10661
+ "Repair of a stopped primary requires all exact project containers stopped and no checkout routes."
10662
+ );
10663
+ }
10664
+ validateWorkspaceContainers(containers, {
10665
+ repoPath,
10666
+ target,
10667
+ upstreamHosts: [],
10668
+ allowStopped: !primary.state.Running
10669
+ });
10670
+ for (const container of containers) {
10671
+ if (container.labels["com.docker.compose.project"] !== state.composeProject) continue;
10672
+ const service = container.labels["com.docker.compose.service"] ?? "";
10673
+ if (typeof container.state.Running !== "boolean" || !hasExactComposeIdentity(container, {
10674
+ repoPath,
10675
+ service,
10676
+ composeProject: state.composeProject,
10677
+ composeFiles: plan.composeFiles
10678
+ }) || container.state.Running && !plan.desiredServices.includes(service)) {
10679
+ throw new Error("Repair refuses foreign or unexpected active Compose resources.");
10680
+ }
10681
+ }
10682
+ const workspacePath = primary.mounts.find(
10683
+ (m) => m.Type === "bind" && sameWorkspacePath(m.Source, repoPath)
10684
+ )?.Destination;
10685
+ if (!workspacePath) throw new Error("Repair cannot prove the primary workspace mount.");
10686
+ for (const name of primary.state.Running ? config.managedRuntime?.processes ?? [] : []) {
10687
+ if (!processes.includes(name) && runManagedProcessAction({
10688
+ container: { id: primary.id, workspacePath },
10689
+ name,
10690
+ action: "status",
10691
+ quiet: true
10692
+ }) !== "stopped") {
10693
+ throw new Error(`Repair refuses unexpected or unproven process '${name}'.`);
10694
+ }
10695
+ }
10696
+ assertManagedContainerConfigUnchanged({
10697
+ plan,
10698
+ containers: retained,
10699
+ workspace: options.workspace
10700
+ });
10701
+ return retained;
10702
+ }
10703
+ function assertRetainedRepairContainers(repoPath, plan, retained) {
10704
+ const current = inspectWorkspaceContainers();
10705
+ for (const previous of retained) {
10706
+ const service = previous.labels["com.docker.compose.service"] ?? "";
10707
+ const matches = exactWorkspaceServiceContainers(
10708
+ current,
10709
+ repoPath,
10710
+ previous.labels["com.docker.compose.project"] ?? "",
10711
+ service,
10712
+ plan.composeFiles
10713
+ );
10714
+ if (matches.length !== 1 || matches[0].id !== previous.id) {
10715
+ throw new Error(`Repair lost retained container identity for '${service}'.`);
10716
+ }
10717
+ }
10718
+ }
10320
10719
  function resolvePrimaryTarget(repoPath) {
10321
10720
  const existingDevpod = selectDevpodWorkspace(listDevpodWorkspaces(repoPath), repoPath);
10322
10721
  return {
@@ -10358,7 +10757,9 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10358
10757
  );
10359
10758
  }
10360
10759
  }
10361
- return withWorkspaceLifecycleLock(repoPath, async () => {
10760
+ const ensureLocked = async () => {
10761
+ let repairMutationStarted = false;
10762
+ let retainedRepairContainers = [];
10362
10763
  let environmentStarted = false;
10363
10764
  let managedPlan;
10364
10765
  let previousManagedState;
@@ -10376,10 +10777,11 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10376
10777
  let firstTransitionBaseline;
10377
10778
  const routeLoadOptions = {
10378
10779
  initialTimeoutMs: Math.min(options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, 3e3),
10379
- recoveryTimeoutMs: Math.min(options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, 1e4)
10780
+ recoveryTimeoutMs: Math.min(options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, 1e4),
10781
+ ...options.repair ? { allowRestart: false } : {}
10380
10782
  };
10381
10783
  try {
10382
- const target = linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
10784
+ const target = options.repair ? resolveRepairTarget(repoPath, linked) : linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
10383
10785
  let devpodId = target.devpodId;
10384
10786
  if (target.kind === "linked") {
10385
10787
  const overlayPath = import_node_path26.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
@@ -10389,12 +10791,16 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10389
10791
  }
10390
10792
  const managedPostStart = resolveManagedPostStartPlan(repoPath);
10391
10793
  managedPostStartPlan = managedPostStart;
10794
+ if (options.repair)
10795
+ previousManagedState = readManagedRuntimeState(repoPath, target.workspace);
10392
10796
  const runtime = loadRuntimeConfig(
10393
10797
  repoPath,
10394
10798
  target.kind === "primary" ? "" : target.workspace,
10395
- options.profile
10799
+ options.profile ?? (options.repair ? previousManagedState?.profile : void 0)
10396
10800
  );
10397
10801
  const managedRuntime = runtime.config.managedRuntime;
10802
+ if (options.repair && !managedRuntime)
10803
+ throw new Error("Repair requires managedRuntime configuration.");
10398
10804
  previousRoutes = listHostRouteState().filter(
10399
10805
  (route) => sameWorkspacePath(route.repoPath, repoPath)
10400
10806
  );
@@ -10403,12 +10809,12 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10403
10809
  const desiredProcesses = managedRuntime ? desiredManagedProcesses(managedRuntime, runtime.resolvedProfile) : [];
10404
10810
  if (managedRuntime) {
10405
10811
  previousManagedState = readManagedRuntimeState(repoPath, target.workspace);
10406
- if (previousManagedState && !hasExactManagedComposeProject(repoPath, previousManagedState)) {
10812
+ if (!options.repair && previousManagedState && !hasExactManagedComposeProject(repoPath, previousManagedState)) {
10407
10813
  previousManagedState = void 0;
10408
10814
  }
10409
- if (previousManagedState?.status === "degraded") {
10815
+ if (!options.repair && previousManagedState?.status === "degraded") {
10410
10816
  throw new Error(
10411
- "Managed runtime state is degraded; refusing a new profile transition until drift is repaired."
10817
+ "Managed runtime state is degraded; refusing a new profile transition until drift is repaired. Inspect status, then use ensure --repair for the recorded profile."
10412
10818
  );
10413
10819
  }
10414
10820
  if (previousManagedState && previousManagedState.devpodId !== target.devpodId) {
@@ -10462,7 +10868,20 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10462
10868
  );
10463
10869
  }
10464
10870
  }
10465
- if (managedPlan) {
10871
+ if (options.repair && managedPlan && previousManagedState) {
10872
+ retainedRepairContainers = assertRepairBaseline({
10873
+ repoPath,
10874
+ target,
10875
+ state: previousManagedState,
10876
+ plan: managedPlan,
10877
+ config: runtime.config,
10878
+ profile: runtime.profile,
10879
+ processes: desiredProcesses,
10880
+ routes: previousRoutes,
10881
+ workspace: managedWorkspaceEnv
10882
+ });
10883
+ }
10884
+ if (managedPlan && !options.repair) {
10466
10885
  writeManagedDevcontainerConfig(managedPlan);
10467
10886
  managedConfigWritten = true;
10468
10887
  }
@@ -10498,7 +10917,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10498
10917
  let recreated = false;
10499
10918
  let container;
10500
10919
  try {
10501
- await startAndProveAttachment();
10920
+ if (!options.repair) await startAndProveAttachment();
10502
10921
  } catch (error) {
10503
10922
  if (managedPlan || !target.hadExactDevpod) {
10504
10923
  throw error;
@@ -10508,7 +10927,45 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10508
10927
  }
10509
10928
  if (!container) {
10510
10929
  try {
10511
- container = await preflight(0);
10930
+ if (options.repair && managedPlan && previousManagedState) {
10931
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
10932
+ const primaryService = managedPlan.primaryService;
10933
+ const primary = retainedRepairContainers.find(
10934
+ (entry) => entry.labels["com.docker.compose.service"] === primaryService
10935
+ );
10936
+ if (!primary) throw new Error("Repair lost its retained primary container.");
10937
+ const stopped = retainedRepairContainers.filter((entry) => !entry.state.Running);
10938
+ for (const entry of stopped) {
10939
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(entry.id))
10940
+ throw new Error("Repair found an invalid retained container ID.");
10941
+ }
10942
+ if (stopped.length > 0) {
10943
+ repairMutationStarted = true;
10944
+ transitionPhase = "service-start";
10945
+ const result = (0, import_node_child_process21.spawnSync)("docker", ["start", ...stopped.map((entry) => entry.id)], {
10946
+ encoding: "utf-8",
10947
+ timeout: DEFAULT_READINESS_TIMEOUT_MS,
10948
+ stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit"
10949
+ });
10950
+ if (result.status !== 0)
10951
+ throw new Error("Could not start retained repair containers.");
10952
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
10953
+ }
10954
+ await waitForManagedServices(
10955
+ managedPlan,
10956
+ repoPath,
10957
+ previousManagedState.composeProject,
10958
+ managedPlan.desiredServices,
10959
+ primary.id,
10960
+ options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
10961
+ );
10962
+ }
10963
+ container = options.repair ? await waitForContainerPreflight(
10964
+ repoPath,
10965
+ currentTarget(),
10966
+ [],
10967
+ options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
10968
+ ) : await preflight(0);
10512
10969
  } catch (error) {
10513
10970
  if (managedPlan || !target.hadExactDevpod) {
10514
10971
  throw error;
@@ -10549,13 +11006,18 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10549
11006
  }
10550
11007
  if (!matches[0]?.state.Running) missingServices.push(service);
10551
11008
  }
10552
- startExactManagedServices({
10553
- plan: managedPlan,
10554
- composeProject: managedComposeProject,
10555
- services: missingServices,
10556
- quiet: options.quiet,
10557
- workspace: managedWorkspaceEnv
10558
- });
11009
+ if (options.repair) {
11010
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
11011
+ repairMutationStarted = true;
11012
+ }
11013
+ if (!options.repair)
11014
+ startExactManagedServices({
11015
+ plan: managedPlan,
11016
+ composeProject: managedComposeProject,
11017
+ services: missingServices,
11018
+ quiet: options.quiet,
11019
+ workspace: managedWorkspaceEnv
11020
+ });
10559
11021
  await waitForManagedServices(
10560
11022
  managedPlan,
10561
11023
  repoPath,
@@ -10567,6 +11029,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10567
11029
  container = await preflight(options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS);
10568
11030
  managedContainer = container;
10569
11031
  }
11032
+ if (options.repair && managedPlan)
11033
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
10570
11034
  transitionPhase = "process-start";
10571
11035
  runManagedPostStart({
10572
11036
  plan: managedPostStart,
@@ -10580,6 +11044,17 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10580
11044
  const desiredProcessSet = new Set(desiredProcesses);
10581
11045
  for (const process2 of processRegistry) {
10582
11046
  if (desiredProcessSet.has(process2)) continue;
11047
+ if (options.repair) {
11048
+ if (runManagedProcessAction({
11049
+ container,
11050
+ name: process2,
11051
+ action: "status",
11052
+ quiet: options.quiet
11053
+ }) !== "stopped") {
11054
+ throw new Error(`Repair found unexpected process '${process2}' after adapter replay.`);
11055
+ }
11056
+ continue;
11057
+ }
10583
11058
  runManagedProcessAction({
10584
11059
  container,
10585
11060
  name: process2,
@@ -10599,7 +11074,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10599
11074
  }
10600
11075
  }
10601
11076
  transitionPhase = "service-stop";
10602
- stopDroppedManagedServices(managedPlan, repoPath, managedComposeProject);
11077
+ if (!options.repair)
11078
+ stopDroppedManagedServices(managedPlan, repoPath, managedComposeProject);
10603
11079
  assertDroppedManagedServicesStopped(managedPlan, repoPath, managedComposeProject);
10604
11080
  await waitForManagedServices(
10605
11081
  managedPlan,
@@ -10614,11 +11090,13 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10614
11090
  const publication = await replacePublishedProxyRoutes(
10615
11091
  repoPath,
10616
11092
  runtime.config,
10617
- target.workspace
11093
+ target.workspace,
11094
+ options.repair ? { prepareInfrastructure: false } : void 0
10618
11095
  );
10619
11096
  candidateRoutes = publication.routes;
10620
11097
  candidateRoutesPublished = true;
10621
- await ensureTraefikRoutesLoaded(publication.routes, routeLoadOptions);
11098
+ if (options.repair) await ensureTraefikRoutesMatch(publication.routes, routeLoadOptions);
11099
+ else await ensureTraefikRoutesLoaded(publication.routes, routeLoadOptions);
10622
11100
  await ensureTraefikRoutesRemoved(
10623
11101
  removedRoutesForReplacement(previousRoutes.map(routeInputFromState), publication.routes),
10624
11102
  routeLoadOptions
@@ -10665,8 +11143,9 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10665
11143
  if (!devpodId) {
10666
11144
  throw new Error(`DevPod id for '${repoPath}' was not resolved after startup.`);
10667
11145
  }
11146
+ let candidateState;
10668
11147
  if (managedPlan && managedComposeProject) {
10669
- writeManagedRuntimeState({
11148
+ candidateState = {
10670
11149
  version: 1,
10671
11150
  repoPath,
10672
11151
  ...target.workspace !== void 0 ? { workspace: target.workspace } : {},
@@ -10682,15 +11161,24 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10682
11161
  effectiveConfigSha256: managedPlan.effectiveConfigSha256,
10683
11162
  status: "ready",
10684
11163
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10685
- });
11164
+ };
11165
+ if (!options.repair) writeManagedRuntimeState(candidateState);
10686
11166
  }
10687
11167
  const managedRuntimeStatus = managedPlan ? collectManagedRuntimeStatus({
10688
11168
  repoPath,
10689
11169
  workspace: target.workspace,
10690
11170
  config: runtime.config,
10691
11171
  profile: runtime.profile,
10692
- resolvedProfile: runtime.resolvedProfile
11172
+ resolvedProfile: runtime.resolvedProfile,
11173
+ ...options.repair ? { candidateState } : {}
10693
11174
  }) : void 0;
11175
+ if (options.repair && candidateState && managedPlan) {
11176
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
11177
+ resolveRepairTarget(repoPath, linked);
11178
+ if (managedRuntimeStatus?.status !== "ready")
11179
+ throw new Error("Repaired candidate runtime did not pass final readiness validation.");
11180
+ writeManagedRuntimeState(candidateState);
11181
+ }
10694
11182
  return {
10695
11183
  kind: target.kind,
10696
11184
  repoPath,
@@ -10703,6 +11191,37 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10703
11191
  ...managedRuntimeStatus ? { managedRuntime: managedRuntimeStatus } : {}
10704
11192
  };
10705
11193
  } catch (error) {
11194
+ if (options.repair && !repairMutationStarted) throw error;
11195
+ if (options.repair && repairMutationStarted && previousManagedState && managedPlan) {
11196
+ const failures = [];
11197
+ if (transitionPhase === "route-publication") {
11198
+ try {
11199
+ const currentRoutes = listHostRouteState().filter((route) => sameWorkspacePath(route.repoPath, repoPath)).map(routeInputFromState);
11200
+ const rollbackRoutes = previousRoutes.map(routeInputFromState);
11201
+ replaceHostRoutesForRepo(repoPath, rollbackRoutes);
11202
+ await ensureTraefikRoutesMatch(rollbackRoutes, routeLoadOptions);
11203
+ await ensureTraefikRoutesRemoved(
11204
+ removedRoutesForReplacement(currentRoutes, rollbackRoutes),
11205
+ routeLoadOptions
11206
+ );
11207
+ } catch (rollbackError) {
11208
+ failures.push(
11209
+ `routes: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
11210
+ );
11211
+ }
11212
+ }
11213
+ try {
11214
+ markManagedRuntimeDegraded(previousManagedState, transitionPhase);
11215
+ } catch (stateError) {
11216
+ failures.push(
11217
+ `state: ${stateError instanceof Error ? stateError.message : String(stateError)}`
11218
+ );
11219
+ }
11220
+ const original = error instanceof Error ? error.message : String(error);
11221
+ throw new Error(
11222
+ `${original} Repair failed; owned resources may remain running.${failures.length ? ` Recovery errors: ${failures.join("; ")}.` : " State remains degraded."}`
11223
+ );
11224
+ }
10706
11225
  if (managedPlan && managedContainer && managedComposeProject) {
10707
11226
  const rollbackErrors = [];
10708
11227
  const failedPhase = transitionPhase;
@@ -10904,6 +11423,22 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10904
11423
  }
10905
11424
  throw error;
10906
11425
  }
11426
+ };
11427
+ return withWorkspaceLifecycleLock(repoPath, async () => {
11428
+ if (!options.repair) return ensureLocked();
11429
+ const runtime = resolveWorkspaceRuntimeOrDefault(repoPath);
11430
+ import_node_fs25.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
11431
+ return withFileLock(
11432
+ import_node_path26.default.join(DEVROUTER_HOME, `${runtime}-mutation.lock`),
11433
+ {
11434
+ activity: "Managed runtime repair",
11435
+ target: repoPath,
11436
+ fair: true,
11437
+ waitMs: 18e5,
11438
+ onWait: createStderrWaitReporter("Managed runtime repair", repoPath)
11439
+ },
11440
+ ensureLocked
11441
+ );
10907
11442
  });
10908
11443
  }
10909
11444
  var import_node_child_process21, import_node_fs25, import_node_path26, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
@@ -10917,6 +11452,7 @@ var init_workspace_ensure = __esm({
10917
11452
  init_devpod_environment();
10918
11453
  init_devpod_mutation();
10919
11454
  init_devpod_workspaces();
11455
+ init_file_lock();
10920
11456
  init_host_routes();
10921
11457
  init_http_route_probe();
10922
11458
  init_managed_post_start();
@@ -10964,7 +11500,8 @@ async function runEnsureCommand(options) {
10964
11500
  const result = await workspaceEnsure(repoPath, {
10965
11501
  open: options.open,
10966
11502
  quiet: Boolean(options.json),
10967
- profile: options.profile
11503
+ profile: options.profile,
11504
+ ...options.repair ? { repair: true } : {}
10968
11505
  });
10969
11506
  if (options.json) {
10970
11507
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -13268,11 +13805,11 @@ var init_profile_resolution = __esm({
13268
13805
  function fail(message) {
13269
13806
  throw new Error(message);
13270
13807
  }
13271
- function isRecord2(value) {
13808
+ function isRecord3(value) {
13272
13809
  return typeof value === "object" && value !== null && !Array.isArray(value);
13273
13810
  }
13274
13811
  function requireRecord(value, label) {
13275
- if (!isRecord2(value)) fail(`${label} must be a mapping.`);
13812
+ if (!isRecord3(value)) fail(`${label} must be a mapping.`);
13276
13813
  return value;
13277
13814
  }
13278
13815
  function requireExactKeys(value, allowed, label) {
@@ -15052,7 +15589,7 @@ var init_workspace_consumption = __esm({
15052
15589
  });
15053
15590
 
15054
15591
  // src/core/workspace-cleanup.ts
15055
- function isRecord3(value) {
15592
+ function isRecord4(value) {
15056
15593
  return value !== null && typeof value === "object" && !Array.isArray(value);
15057
15594
  }
15058
15595
  function isSha(value) {
@@ -15196,14 +15733,14 @@ function parseGitHubChanges(value, project, branch) {
15196
15733
  if (!Array.isArray(value)) return void 0;
15197
15734
  const changes = [];
15198
15735
  for (const item of value) {
15199
- if (!isRecord3(item)) return void 0;
15200
- const repository = isRecord3(item.repository) ? item.repository.nameWithOwner : void 0;
15201
- const headRepository = isRecord3(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
15736
+ if (!isRecord4(item)) return void 0;
15737
+ const repository = isRecord4(item.repository) ? item.repository.nameWithOwner : void 0;
15738
+ const headRepository = isRecord4(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
15202
15739
  if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
15203
15740
  continue;
15204
15741
  }
15205
15742
  const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
15206
- const mergeCommit = isRecord3(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
15743
+ const mergeCommit = isRecord4(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
15207
15744
  const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
15208
15745
  changes.push({
15209
15746
  sourceBranch: branch,
@@ -15220,14 +15757,14 @@ function parseGitLabChanges(value, _project, branch) {
15220
15757
  if (!Array.isArray(value)) return void 0;
15221
15758
  const changes = [];
15222
15759
  for (const item of value) {
15223
- if (!isRecord3(item)) return void 0;
15760
+ if (!isRecord4(item)) return void 0;
15224
15761
  const sourceProjectId = item.source_project_id;
15225
15762
  const targetProjectId = item.target_project_id;
15226
15763
  const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
15227
15764
  if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
15228
15765
  continue;
15229
15766
  }
15230
- const diffRefs = isRecord3(item.diff_refs) ? item.diff_refs : void 0;
15767
+ const diffRefs = isRecord4(item.diff_refs) ? item.diff_refs : void 0;
15231
15768
  const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
15232
15769
  const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
15233
15770
  changes.push({
@@ -15901,7 +16438,7 @@ var init_version = __esm({
15901
16438
 
15902
16439
  // src/cli.ts
15903
16440
  var import_commander = require("commander");
15904
- var CLI_VERSION = true ? "0.0.51" : "0.0.0-dev";
16441
+ var CLI_VERSION = true ? "0.0.52" : "0.0.0-dev";
15905
16442
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
15906
16443
  function withErrorHandling(action2) {
15907
16444
  return async (...args) => {
@@ -15956,13 +16493,14 @@ program.command("up").description("Ensure devnet and start shared Traefik (reser
15956
16493
  await runUpCommand2();
15957
16494
  })
15958
16495
  );
15959
- program.command("ensure").description("Start and prove a primary or linked checkout's environment and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
16496
+ program.command("ensure").description("Start and prove a primary or linked checkout's environment and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--repair", "Repair a degraded managed runtime from its recorded profile").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
15960
16497
  withErrorHandling(async (repoPath, _options, command) => {
15961
16498
  const options = command.opts();
15962
16499
  const { runEnsureCommand: runEnsureCommand2 } = await Promise.resolve().then(() => (init_ensure(), ensure_exports));
15963
16500
  await runEnsureCommand2({
15964
16501
  path: repoPath,
15965
16502
  profile: options.profile,
16503
+ repair: options.repair,
15966
16504
  open: Boolean(options.open),
15967
16505
  json: Boolean(options.json)
15968
16506
  });
@@ -16184,7 +16722,7 @@ workspaceCommand2.command("up").description(
16184
16722
  );
16185
16723
  workspaceCommand2.command("ensure").description(
16186
16724
  "Start and prove a primary or linked checkout's workspace runtime, upstreams, and routes"
16187
- ).argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
16725
+ ).argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--repair", "Repair a degraded managed runtime from its recorded profile").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
16188
16726
  withErrorHandling(
16189
16727
  async (worktreePath, _options, command) => {
16190
16728
  const options = command.opts();
@@ -16192,6 +16730,7 @@ workspaceCommand2.command("ensure").description(
16192
16730
  await runEnsureCommand2({
16193
16731
  path: worktreePath,
16194
16732
  profile: options.profile,
16733
+ repair: options.repair,
16195
16734
  open: Boolean(options.open),
16196
16735
  json: Boolean(options.json)
16197
16736
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.51",
3
+ "version": "0.0.52",
4
4
  "description": "Local dev routing CLI with shared Traefik reverse proxy",
5
5
  "author": "Roland Schlaefli",
6
6
  "homepage": "https://github.com/rschlaefli/devrouter#readme",
@@ -0,0 +1,34 @@
1
+ # Upgrade to devrouter 0.0.52
2
+
3
+ Managed process ownership now handles large environments correctly. Explicit
4
+ `ensure --repair` recovers a retained degraded runtime without deleting its data
5
+ or invoking provider bootstrap. Ordinary `ensure` retains its degraded-state guard.
6
+
7
+ ## Task
8
+
9
+ 1. Install the published CLI with `npm install -g @devrouter/cli@0.0.52`.
10
+ 2. Bump `devrouter.version` in the consumer `.devrouter.yml` to `0.0.52`.
11
+ 3. For an approved exact workspace recovery, inspect status and run
12
+ `devrouter ensure <checkout> --repair --json`. Omit the profile to reuse the
13
+ recorded profile. Preserve required non-secret Compose interpolation settings.
14
+ 4. Stop at ownership or configuration drift. Repair requires retained containers;
15
+ it never creates missing resources or changes the recorded profile.
16
+
17
+ ## Validation
18
+
19
+ Verify installed/configured versions with `devrouter -V --repo <checkout>`.
20
+ Repair accepts a stopped primary only with every project container explicitly
21
+ stopped and no checkout routes. It starts retained IDs, including their existing
22
+ entrypoints, then replays the current repository adapter. Owned processes may
23
+ restart when fingerprints change. Failure can leave owned resources running.
24
+
25
+ Require zero command exit status, ready services/processes, exact active route
26
+ rules and targets, and no managed drift. Check the application separately.
27
+ No live recovery is implied by installing the CLI. No schema migration or manual
28
+ state-file edit is required.
29
+
30
+ ## Report
31
+
32
+ Report installed/configured versions, exact checkout/profile, repair exit status,
33
+ managed state, application check, and remaining blockers. Distinguish source
34
+ checks from live runtime proof. Data deletion remains a separate explicit action.