@devrouter/cli 0.0.50 → 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.
package/dist/devrouter.js CHANGED
@@ -50,6 +50,7 @@ function buildDevrouterSection() {
50
50
  "- `devrouter app ls --repo .`",
51
51
  "- Primary or linked devcontainer checkout: `devrouter ensure . --json`",
52
52
  "- Managed selective profile: `devrouter ensure . --profile <name> --json`",
53
+ "- Side-effect-free automation: `devrouter profile resolve --repo . --profile <name> --json`; add `profile plan --contract <repo-relative-yaml>` when the repository needs literal bindings.",
53
54
  "- Host/docker runtime app only: `devrouter app run <host-app> --repo . --yes`",
54
55
  "- `devrouter ls`",
55
56
  "- Managed devcontainer source configs with `postCreateCommand` and a managed post-start adapter must set `waitFor` exactly to `postCreateCommand` or `postStartCommand`; generated managed configs preserve lifecycle fields and change only `runServices`.",
@@ -300,6 +301,27 @@ profiles:
300
301
  selective configuration preserves lifecycle fields and changes only
301
302
  \`runServices\`.
302
303
 
304
+ ## Automation profile plans
305
+
306
+ Use \`devrouter profile resolve --json\` when automation only needs exact selected
307
+ resources. Use \`devrouter profile plan\` when a repository-owned contract must
308
+ bind selected app names to build arguments, readiness URLs, or other literals:
309
+
310
+ \`\`\`bash
311
+ devrouter profile plan --repo . --profile <selection> \\
312
+ --contract <repo-relative-yaml> --output <plan.json> --json
313
+ \`\`\`
314
+
315
+ The version-1 contract is separate from \`.devrouter.yml\`. It maps every allowed
316
+ app to named non-empty string arrays, constrains dependencies and managed
317
+ services to allowed sets, and requires an exact managed-process set. All
318
+ resource names must exist in \`.devrouter.yml\`. The path must be a regular,
319
+ non-symlink file inside the repository. Devrouter emits canonical, deduplicated
320
+ literal arrays and atomically writes mode \`0600\`; it never interprets binding
321
+ names, expands values, or runs a shell or runtime. Consumers validate expected
322
+ keys and pass values as literal data. Keep separate contracts for workloads
323
+ with different exact process policies.
324
+
303
325
  ## Env var injection
304
326
 
305
327
  When a host app depends on a TCP Docker service, \`devrouter app run\` and \`devrouter app exec\` inject per-dep deterministic vars (where \`{PREFIX} = dep.name.toUpperCase().replace(/-/g, "_")\`):
@@ -378,8 +400,9 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
378
400
  - \`devrouter -V [--repo .]\`: show installed CLI version, local repo version, and next upgrade target
379
401
  - \`devrouter upgrade [version] [--repo .]\`: list upgrade targets or print target Agent Adaptation Prompt
380
402
  - \`devrouter setup --yes [--repo .] [--json] [--workspace-runtime <devpod|devsy>]\`: first-run machine setup plus structured diagnostics; explicit Devsy selection acquires its verified agent
381
- - \`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
382
404
  - \`devrouter profile resolve --repo <path> [--profile <selection>] [--json]\`: resolve exact profile resources for automation without starting or inspecting a runtime
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
383
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
384
407
  - \`devrouter exec [path] -- <command...>\`: literal one-shot command inside the exact running workspace runtime
385
408
  - \`devrouter up\` / \`devrouter down\`: start/stop shared Traefik router
@@ -402,7 +425,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
402
425
  - \`devrouter app exec <name> [--shell] [--env <env>] [--workspace <slug>] -- <cmd>\`: one-shot command with resolved dep env
403
426
  - \`devrouter app rm <name> [--keep-config]\`: remove app entry (\`--keep-config\` frees only the live route/hostname, leaves \`.devrouter.yml\` untouched)
404
427
  - \`devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open]\`: create a worktree and start/prove it unless create-only mode is requested
405
- - \`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\`
406
429
  - \`devrouter workspace ls [--json]\`: list ownership, Git, workspace runtime, route, path, and branch evidence
407
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
408
431
  - \`devrouter workspace stop <workspace|branch>\`: stop DevPod and routes; preserve checkout, owner record, and data
@@ -2668,7 +2691,7 @@ function loadRepoConfig(repoPath) {
2668
2691
  const config = parseConfig(parsed ?? {}, configPath);
2669
2692
  const requiredVersion = config.devrouter?.version;
2670
2693
  if (requiredVersion && !hasWarnedVersionMismatch) {
2671
- const cliVersion = true ? "0.0.50" : "0.0.0-dev";
2694
+ const cliVersion = true ? "0.0.52" : "0.0.0-dev";
2672
2695
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
2673
2696
  hasWarnedVersionMismatch = true;
2674
2697
  process.stderr.write(
@@ -3153,6 +3176,8 @@ function buildOnboardingPrompt(options = {}) {
3153
3176
  "- managedRuntime (optional) registers devcontainer baseServices, profileServices, and managed process markers",
3154
3177
  "- managed profiles may add devcontainerServices and processes independently; with managedRuntime, apps may be omitted for a route-free capability profile",
3155
3178
  "- an omitted optional managed dimension selects nothing; use `*` or a full profile to select every registered service/process; baseServices and the primary service are always retained",
3179
+ "- automation may use a separate strict version-1 contract with `devrouter profile plan`; the contract maps apps to named literal arrays, constrains dependencies and services to allowed registries, requires an exact managed-process set, and remains repository-owned",
3180
+ "- profile plan values are data: Devrouter never interprets binding names, expands values, runs a shell, or starts/inspects a runtime; consumers validate expected keys and pass values literally",
3156
3181
  "- native Dev Container clients continue to use the source configuration's full service set; managed ensure derives an ignored sibling and changes only runServices",
3157
3182
  "",
3158
3183
  "Validation rules to enforce:",
@@ -3248,6 +3273,7 @@ function buildOnboardingPrompt(options = {}) {
3248
3273
  "- devrouter repo devcontainer write --repo <REPO_PATH> --yes",
3249
3274
  "- devrouter repo devcontainer verify --repo <REPO_PATH> --json",
3250
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.",
3251
3277
  "- Managed selective startup: devrouter ensure <REPO_PATH> --profile <name> --json",
3252
3278
  "- Container seed/migration: devrouter exec <REPO_PATH> -- <command...>",
3253
3279
  "- Compatibility verification only after ensure: devrouter repo devcontainer verify --repo <REPO_PATH> --live --yes --json",
@@ -3366,13 +3392,17 @@ var init_ai_prompt = __esm({
3366
3392
  purpose: "Write/update devrouter section in the repo's AGENTS.md and install the devrouter skill."
3367
3393
  },
3368
3394
  {
3369
- command: "devrouter ensure [path] [--profile <name>] [--open] [--json]",
3395
+ command: "devrouter ensure [path] [--profile <name>] [--repair] [--open] [--json]",
3370
3396
  purpose: "Canonical startup and proof for an exact primary or linked checkout; atomically publishes routes after readiness."
3371
3397
  },
3372
3398
  {
3373
3399
  command: "devrouter profile resolve --repo <path> [--profile <selection>] [--json]",
3374
3400
  purpose: "Resolve exact profile resources without starting or inspecting a runtime; fail closed on invalid selections or unknown report schemas."
3375
3401
  },
3402
+ {
3403
+ command: "devrouter profile plan --repo <path> [--profile <selection>] --contract <repo-relative-yaml> [--output <path>] [--json]",
3404
+ purpose: "Validate exact profile resources against a repository-owned contract and emit named literal arrays without interpreting them or touching a runtime."
3405
+ },
3376
3406
  {
3377
3407
  command: "devrouter stop [path] [--delete] [--json]",
3378
3408
  purpose: "Stop the exact checkout workspace runtime and routes; --delete explicitly deletes the ownership-proven runtime without removing the checkout."
@@ -5662,19 +5692,12 @@ function assertSafeContainerId(containerId) {
5662
5692
  throw new Error(`Container '${containerId}' is not a safe exact identifier.`);
5663
5693
  }
5664
5694
  }
5665
- function startExactManagedServices(options) {
5666
- if (options.services.length === 0) return;
5667
- const project = safeComposeProject(options.composeProject);
5668
- const services = Array.from(new Set(options.services));
5669
- if (services.some((service) => !options.plan.composeServices.includes(service))) {
5670
- throw new Error("Exact Compose startup contains a service outside the validated model.");
5671
- }
5672
- const fileArgs = options.plan.composeFiles.flatMap((file) => ["-f", file]);
5695
+ function managedComposeEnvironment(workspace) {
5673
5696
  const env = { ...process.env };
5674
- if (options.workspace) {
5675
- env.WORKSPACE = options.workspace.token;
5676
- env.DEVROUTER_WORKSPACE = options.workspace.token;
5677
- 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;
5678
5701
  env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
5679
5702
  } else {
5680
5703
  delete env.WORKSPACE;
@@ -5682,6 +5705,87 @@ function startExactManagedServices(options) {
5682
5705
  delete env.DEVROUTER_GIT_COMMON_DIR;
5683
5706
  delete env.DEVCONTAINER_COMPOSE_OVERLAY;
5684
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);
5685
5789
  const result = (0, import_node_child_process7.spawnSync)(
5686
5790
  "docker",
5687
5791
  [
@@ -5720,7 +5824,7 @@ function stopExactManagedService(containerId, service) {
5720
5824
  throw new Error(`Could not stop exact managed service '${service}' (${containerId}).`);
5721
5825
  }
5722
5826
  }
5723
- 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;
5724
5828
  var init_devcontainer_profile = __esm({
5725
5829
  "src/core/devcontainer-profile.ts"() {
5726
5830
  "use strict";
@@ -5732,8 +5836,10 @@ var init_devcontainer_profile = __esm({
5732
5836
  init_atomic_file();
5733
5837
  init_devcontainer_config();
5734
5838
  init_paths();
5839
+ init_workspace();
5735
5840
  MANAGED_DEVCONTAINER_PATH = ".devcontainer/devcontainer.devrouter.json";
5736
5841
  MANAGED_DEVCONTAINER_MARKER = "// devrouter:managed devcontainer profile";
5842
+ COMPOSE_HASH_TIMEOUT_MS = 1e4;
5737
5843
  }
5738
5844
  });
5739
5845
 
@@ -5828,7 +5934,7 @@ var init_devpod_environment = __esm({
5828
5934
  import_node_child_process8 = require("child_process");
5829
5935
  import_node_path16 = __toESM(require("path"));
5830
5936
  init_workspace();
5831
- 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}}}';
5832
5938
  SIZE_INSPECT_TEMPLATE = SAFE_INSPECT_TEMPLATE.replace(
5833
5939
  /}$/,
5834
5940
  ',"sizeRw":{{json (index . "SizeRw")}},"sizeRootFs":{{json (index . "SizeRootFs")}}}'
@@ -6143,7 +6249,7 @@ function collectManagedRuntimeStatus(options) {
6143
6249
  let state;
6144
6250
  let stateReadFailed = false;
6145
6251
  try {
6146
- state = readManagedRuntimeState(options.repoPath, options.workspace);
6252
+ state = options.candidateState ?? readManagedRuntimeState(options.repoPath, options.workspace);
6147
6253
  } catch {
6148
6254
  stateReadFailed = true;
6149
6255
  }
@@ -8025,6 +8131,12 @@ function validateRecord(value, expectedWorkspace) {
8025
8131
  updatedAt: validateTimestamp(candidate.updatedAt, "updatedAt")
8026
8132
  };
8027
8133
  }
8134
+ function recordPath(repoPath, workspace) {
8135
+ return import_node_path22.default.join(
8136
+ ownershipDirectory(repoPath),
8137
+ `${validateWorkspace(workspace, "workspace")}.json`
8138
+ );
8139
+ }
8028
8140
  function readRecordFile(filePath, expectedWorkspace) {
8029
8141
  let parsed;
8030
8142
  try {
@@ -8037,6 +8149,15 @@ function readRecordFile(filePath, expectedWorkspace) {
8037
8149
  }
8038
8150
  return validateRecord(parsed, expectedWorkspace);
8039
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
+ }
8040
8161
  function listWorkspaceOwnership(repoPath) {
8041
8162
  const directory = ownershipDirectory(repoPath);
8042
8163
  return listWorkspaceOwnershipInDirectory(directory);
@@ -8986,7 +9107,7 @@ async function buildDoctorReport(options = {}) {
8986
9107
  const config = runtimeConfig.config;
8987
9108
  loadedConfig = config;
8988
9109
  loadedWorkspace = runtimeConfig.workspace;
8989
- const cliVersion = true ? "0.0.50" : "0.0.0-dev";
9110
+ const cliVersion = true ? "0.0.52" : "0.0.0-dev";
8990
9111
  const configVersion = config.devrouter?.version;
8991
9112
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
8992
9113
  addCheck(checks, {
@@ -9698,9 +9819,9 @@ async function ensureRouteInfrastructure(apps, options = {}) {
9698
9819
  startRouterStack();
9699
9820
  return tlsCoverage;
9700
9821
  }
9701
- async function replacePublishedProxyRoutes(repoPath, config, workspace) {
9822
+ async function replacePublishedProxyRoutes(repoPath, config, workspace, options = {}) {
9702
9823
  const apps = proxyAppsFromConfig(config);
9703
- const tlsCoverage = await ensureRouteInfrastructure(apps, { repoPath });
9824
+ const tlsCoverage = options.prepareInfrastructure === false ? { refreshed: false } : await ensureRouteInfrastructure(apps, { repoPath });
9704
9825
  const routes = apps.map((app) => {
9705
9826
  const upstream = parseUpstream(app.upstream);
9706
9827
  return {
@@ -9740,7 +9861,7 @@ function inspectRouterApi(protocol) {
9740
9861
  "--show-error",
9741
9862
  "--fail",
9742
9863
  "--max-time",
9743
- "2",
9864
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS,
9744
9865
  `${ROUTER_API_BASE}/${protocol}/routers?per_page=${ROUTER_API_PAGE_SIZE}`
9745
9866
  ],
9746
9867
  { encoding: "utf-8" }
@@ -9772,6 +9893,161 @@ function inspectRouterApi(protocol) {
9772
9893
  )
9773
9894
  };
9774
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
+ }
9775
10051
  function inspectExpectedRoutes(routes, expectation) {
9776
10052
  const mismatched = [];
9777
10053
  const failures = [];
@@ -9828,6 +10104,10 @@ async function ensureTraefikRouteExpectation(routes, expectation, options) {
9828
10104
  pollIntervalMs
9829
10105
  );
9830
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
+ );
9831
10111
  import_node_fs24.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
9832
10112
  return withFileLock(
9833
10113
  ROUTER_RELOAD_LOCK_FILE,
@@ -9858,10 +10138,21 @@ async function ensureTraefikRouteExpectation(routes, expectation, options) {
9858
10138
  async function ensureTraefikRoutesLoaded(routes, options = {}) {
9859
10139
  return ensureTraefikRouteExpectation(routes, "loaded", options);
9860
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
+ }
9861
10152
  async function ensureTraefikRoutesRemoved(routes, options = {}) {
9862
10153
  return ensureTraefikRouteExpectation(routes, "removed", options);
9863
10154
  }
9864
- 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;
9865
10156
  var init_traefik_route_health = __esm({
9866
10157
  "src/core/traefik-route-health.ts"() {
9867
10158
  "use strict";
@@ -9873,6 +10164,7 @@ var init_traefik_route_health = __esm({
9873
10164
  init_router();
9874
10165
  ROUTER_API_BASE = "http://127.0.0.1:8080/api";
9875
10166
  ROUTER_RELOAD_LOCK_FILE = import_node_path25.default.join(DEVROUTER_HOME, "router-reload.lock");
10167
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS = "2";
9876
10168
  DEFAULT_INITIAL_TIMEOUT_MS = 3e3;
9877
10169
  DEFAULT_RECOVERY_TIMEOUT_MS = 1e4;
9878
10170
  DEFAULT_POLL_INTERVAL_MS = 250;
@@ -10136,7 +10428,7 @@ function validateWorkspaceContainers(containers, options) {
10136
10428
  if (options.target.kind === "linked") {
10137
10429
  assertOverlay(appContainer, options.repoPath);
10138
10430
  }
10139
- assertReady(appContainer, "Workspace app");
10431
+ if (!options.allowStopped) assertReady(appContainer, "Workspace app");
10140
10432
  if (options.target.kind === "linked") {
10141
10433
  const gitCommonDir = options.target.gitCommonDir;
10142
10434
  const gitMount = appContainer.mounts.find(
@@ -10288,6 +10580,142 @@ function resolveLinkedTarget(repoPath) {
10288
10580
  gitCommonDir: resolveGitCommonDir(repoPath)
10289
10581
  };
10290
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
+ }
10291
10719
  function resolvePrimaryTarget(repoPath) {
10292
10720
  const existingDevpod = selectDevpodWorkspace(listDevpodWorkspaces(repoPath), repoPath);
10293
10721
  return {
@@ -10329,7 +10757,9 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10329
10757
  );
10330
10758
  }
10331
10759
  }
10332
- return withWorkspaceLifecycleLock(repoPath, async () => {
10760
+ const ensureLocked = async () => {
10761
+ let repairMutationStarted = false;
10762
+ let retainedRepairContainers = [];
10333
10763
  let environmentStarted = false;
10334
10764
  let managedPlan;
10335
10765
  let previousManagedState;
@@ -10347,10 +10777,11 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10347
10777
  let firstTransitionBaseline;
10348
10778
  const routeLoadOptions = {
10349
10779
  initialTimeoutMs: Math.min(options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, 3e3),
10350
- 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 } : {}
10351
10782
  };
10352
10783
  try {
10353
- const target = linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
10784
+ const target = options.repair ? resolveRepairTarget(repoPath, linked) : linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
10354
10785
  let devpodId = target.devpodId;
10355
10786
  if (target.kind === "linked") {
10356
10787
  const overlayPath = import_node_path26.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
@@ -10360,12 +10791,16 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10360
10791
  }
10361
10792
  const managedPostStart = resolveManagedPostStartPlan(repoPath);
10362
10793
  managedPostStartPlan = managedPostStart;
10794
+ if (options.repair)
10795
+ previousManagedState = readManagedRuntimeState(repoPath, target.workspace);
10363
10796
  const runtime = loadRuntimeConfig(
10364
10797
  repoPath,
10365
10798
  target.kind === "primary" ? "" : target.workspace,
10366
- options.profile
10799
+ options.profile ?? (options.repair ? previousManagedState?.profile : void 0)
10367
10800
  );
10368
10801
  const managedRuntime = runtime.config.managedRuntime;
10802
+ if (options.repair && !managedRuntime)
10803
+ throw new Error("Repair requires managedRuntime configuration.");
10369
10804
  previousRoutes = listHostRouteState().filter(
10370
10805
  (route) => sameWorkspacePath(route.repoPath, repoPath)
10371
10806
  );
@@ -10374,12 +10809,12 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10374
10809
  const desiredProcesses = managedRuntime ? desiredManagedProcesses(managedRuntime, runtime.resolvedProfile) : [];
10375
10810
  if (managedRuntime) {
10376
10811
  previousManagedState = readManagedRuntimeState(repoPath, target.workspace);
10377
- if (previousManagedState && !hasExactManagedComposeProject(repoPath, previousManagedState)) {
10812
+ if (!options.repair && previousManagedState && !hasExactManagedComposeProject(repoPath, previousManagedState)) {
10378
10813
  previousManagedState = void 0;
10379
10814
  }
10380
- if (previousManagedState?.status === "degraded") {
10815
+ if (!options.repair && previousManagedState?.status === "degraded") {
10381
10816
  throw new Error(
10382
- "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."
10383
10818
  );
10384
10819
  }
10385
10820
  if (previousManagedState && previousManagedState.devpodId !== target.devpodId) {
@@ -10433,7 +10868,20 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10433
10868
  );
10434
10869
  }
10435
10870
  }
10436
- 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) {
10437
10885
  writeManagedDevcontainerConfig(managedPlan);
10438
10886
  managedConfigWritten = true;
10439
10887
  }
@@ -10469,7 +10917,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10469
10917
  let recreated = false;
10470
10918
  let container;
10471
10919
  try {
10472
- await startAndProveAttachment();
10920
+ if (!options.repair) await startAndProveAttachment();
10473
10921
  } catch (error) {
10474
10922
  if (managedPlan || !target.hadExactDevpod) {
10475
10923
  throw error;
@@ -10479,7 +10927,45 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10479
10927
  }
10480
10928
  if (!container) {
10481
10929
  try {
10482
- 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);
10483
10969
  } catch (error) {
10484
10970
  if (managedPlan || !target.hadExactDevpod) {
10485
10971
  throw error;
@@ -10520,13 +11006,18 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10520
11006
  }
10521
11007
  if (!matches[0]?.state.Running) missingServices.push(service);
10522
11008
  }
10523
- startExactManagedServices({
10524
- plan: managedPlan,
10525
- composeProject: managedComposeProject,
10526
- services: missingServices,
10527
- quiet: options.quiet,
10528
- workspace: managedWorkspaceEnv
10529
- });
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
+ });
10530
11021
  await waitForManagedServices(
10531
11022
  managedPlan,
10532
11023
  repoPath,
@@ -10538,6 +11029,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10538
11029
  container = await preflight(options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS);
10539
11030
  managedContainer = container;
10540
11031
  }
11032
+ if (options.repair && managedPlan)
11033
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
10541
11034
  transitionPhase = "process-start";
10542
11035
  runManagedPostStart({
10543
11036
  plan: managedPostStart,
@@ -10551,6 +11044,17 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10551
11044
  const desiredProcessSet = new Set(desiredProcesses);
10552
11045
  for (const process2 of processRegistry) {
10553
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
+ }
10554
11058
  runManagedProcessAction({
10555
11059
  container,
10556
11060
  name: process2,
@@ -10570,7 +11074,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10570
11074
  }
10571
11075
  }
10572
11076
  transitionPhase = "service-stop";
10573
- stopDroppedManagedServices(managedPlan, repoPath, managedComposeProject);
11077
+ if (!options.repair)
11078
+ stopDroppedManagedServices(managedPlan, repoPath, managedComposeProject);
10574
11079
  assertDroppedManagedServicesStopped(managedPlan, repoPath, managedComposeProject);
10575
11080
  await waitForManagedServices(
10576
11081
  managedPlan,
@@ -10585,11 +11090,13 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10585
11090
  const publication = await replacePublishedProxyRoutes(
10586
11091
  repoPath,
10587
11092
  runtime.config,
10588
- target.workspace
11093
+ target.workspace,
11094
+ options.repair ? { prepareInfrastructure: false } : void 0
10589
11095
  );
10590
11096
  candidateRoutes = publication.routes;
10591
11097
  candidateRoutesPublished = true;
10592
- await ensureTraefikRoutesLoaded(publication.routes, routeLoadOptions);
11098
+ if (options.repair) await ensureTraefikRoutesMatch(publication.routes, routeLoadOptions);
11099
+ else await ensureTraefikRoutesLoaded(publication.routes, routeLoadOptions);
10593
11100
  await ensureTraefikRoutesRemoved(
10594
11101
  removedRoutesForReplacement(previousRoutes.map(routeInputFromState), publication.routes),
10595
11102
  routeLoadOptions
@@ -10636,8 +11143,9 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10636
11143
  if (!devpodId) {
10637
11144
  throw new Error(`DevPod id for '${repoPath}' was not resolved after startup.`);
10638
11145
  }
11146
+ let candidateState;
10639
11147
  if (managedPlan && managedComposeProject) {
10640
- writeManagedRuntimeState({
11148
+ candidateState = {
10641
11149
  version: 1,
10642
11150
  repoPath,
10643
11151
  ...target.workspace !== void 0 ? { workspace: target.workspace } : {},
@@ -10653,15 +11161,24 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10653
11161
  effectiveConfigSha256: managedPlan.effectiveConfigSha256,
10654
11162
  status: "ready",
10655
11163
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10656
- });
11164
+ };
11165
+ if (!options.repair) writeManagedRuntimeState(candidateState);
10657
11166
  }
10658
11167
  const managedRuntimeStatus = managedPlan ? collectManagedRuntimeStatus({
10659
11168
  repoPath,
10660
11169
  workspace: target.workspace,
10661
11170
  config: runtime.config,
10662
11171
  profile: runtime.profile,
10663
- resolvedProfile: runtime.resolvedProfile
11172
+ resolvedProfile: runtime.resolvedProfile,
11173
+ ...options.repair ? { candidateState } : {}
10664
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
+ }
10665
11182
  return {
10666
11183
  kind: target.kind,
10667
11184
  repoPath,
@@ -10674,6 +11191,37 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10674
11191
  ...managedRuntimeStatus ? { managedRuntime: managedRuntimeStatus } : {}
10675
11192
  };
10676
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
+ }
10677
11225
  if (managedPlan && managedContainer && managedComposeProject) {
10678
11226
  const rollbackErrors = [];
10679
11227
  const failedPhase = transitionPhase;
@@ -10875,6 +11423,22 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10875
11423
  }
10876
11424
  throw error;
10877
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
+ );
10878
11442
  });
10879
11443
  }
10880
11444
  var import_node_child_process21, import_node_fs25, import_node_path26, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
@@ -10888,6 +11452,7 @@ var init_workspace_ensure = __esm({
10888
11452
  init_devpod_environment();
10889
11453
  init_devpod_mutation();
10890
11454
  init_devpod_workspaces();
11455
+ init_file_lock();
10891
11456
  init_host_routes();
10892
11457
  init_http_route_probe();
10893
11458
  init_managed_post_start();
@@ -10935,7 +11500,8 @@ async function runEnsureCommand(options) {
10935
11500
  const result = await workspaceEnsure(repoPath, {
10936
11501
  open: options.open,
10937
11502
  quiet: Boolean(options.json),
10938
- profile: options.profile
11503
+ profile: options.profile,
11504
+ ...options.repair ? { repair: true } : {}
10939
11505
  });
10940
11506
  if (options.json) {
10941
11507
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -13235,9 +13801,294 @@ var init_profile_resolution = __esm({
13235
13801
  }
13236
13802
  });
13237
13803
 
13804
+ // src/core/profile-plan.ts
13805
+ function fail(message) {
13806
+ throw new Error(message);
13807
+ }
13808
+ function isRecord3(value) {
13809
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13810
+ }
13811
+ function requireRecord(value, label) {
13812
+ if (!isRecord3(value)) fail(`${label} must be a mapping.`);
13813
+ return value;
13814
+ }
13815
+ function requireExactKeys(value, allowed, label) {
13816
+ const allowedSet = new Set(allowed);
13817
+ const unknown = Object.keys(value).filter((key) => !allowedSet.has(key));
13818
+ if (unknown.length > 0) fail(`${label} has unsupported keys: ${unknown.sort().join(", ")}.`);
13819
+ const missing = allowed.filter((key) => !Object.hasOwn(value, key));
13820
+ if (missing.length > 0) fail(`${label} is missing keys: ${missing.join(", ")}.`);
13821
+ }
13822
+ function requireBoolean(value, label) {
13823
+ if (typeof value !== "boolean") fail(`${label} must be a boolean.`);
13824
+ return value;
13825
+ }
13826
+ function requireStringList(value, label, options = {}) {
13827
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
13828
+ fail(`${label} must be an array of strings.`);
13829
+ }
13830
+ if (options.allowEmpty === false && value.length === 0) fail(`${label} must not be empty.`);
13831
+ for (const entry of value) {
13832
+ if (entry.length === 0) fail(`${label} must not contain empty strings.`);
13833
+ if (entry.length > MAX_LITERAL_LENGTH) {
13834
+ fail(`${label} contains a value longer than ${MAX_LITERAL_LENGTH} characters.`);
13835
+ }
13836
+ }
13837
+ if (new Set(value).size !== value.length) fail(`${label} must not contain duplicates.`);
13838
+ return [...value];
13839
+ }
13840
+ function parseMappings(value) {
13841
+ const input2 = requireRecord(value, "apps.mappings");
13842
+ const entries = Object.entries(input2);
13843
+ if (entries.length > MAX_APP_MAPPINGS) {
13844
+ fail(`apps.mappings exceeds the limit of ${MAX_APP_MAPPINGS}.`);
13845
+ }
13846
+ const mappings = [];
13847
+ const allBindingKeys = /* @__PURE__ */ new Set();
13848
+ let bindingValues = 0;
13849
+ for (const [appName, rawMapping] of entries) {
13850
+ if (appName.length === 0) fail("apps.mappings must not contain an empty app name.");
13851
+ const mapping = requireRecord(rawMapping, `apps.mappings.${appName}`);
13852
+ requireExactKeys(mapping, ["bindings"], `apps.mappings.${appName}`);
13853
+ const rawBindings = requireRecord(mapping.bindings, `apps.mappings.${appName}.bindings`);
13854
+ if (Object.keys(rawBindings).length === 0) {
13855
+ fail(`apps.mappings.${appName}.bindings must not be empty.`);
13856
+ }
13857
+ const bindings = [];
13858
+ for (const [key, rawValues] of Object.entries(rawBindings)) {
13859
+ if (!BINDING_KEY.test(key)) {
13860
+ fail(`binding key '${key}' must match ${BINDING_KEY.source}.`);
13861
+ }
13862
+ const values = requireStringList(rawValues, `apps.mappings.${appName}.bindings.${key}`, {
13863
+ allowEmpty: false
13864
+ });
13865
+ allBindingKeys.add(key);
13866
+ bindingValues += values.length;
13867
+ bindings.push([key, values]);
13868
+ }
13869
+ mappings.push([appName, { bindings: Object.fromEntries(bindings) }]);
13870
+ }
13871
+ if (allBindingKeys.size > MAX_BINDING_KEYS) {
13872
+ fail(`contract exceeds the limit of ${MAX_BINDING_KEYS} binding keys.`);
13873
+ }
13874
+ if (bindingValues > MAX_BINDING_VALUES) {
13875
+ fail(`contract exceeds the limit of ${MAX_BINDING_VALUES} binding values.`);
13876
+ }
13877
+ return Object.fromEntries(mappings);
13878
+ }
13879
+ function parseProfilePlanContract(source) {
13880
+ const document = (0, import_yaml7.parseDocument)(source, {
13881
+ merge: false,
13882
+ schema: "core",
13883
+ uniqueKeys: true
13884
+ });
13885
+ if (document.errors.length > 0) {
13886
+ fail(`profile plan contract is invalid YAML: ${document.errors[0]?.message}`);
13887
+ }
13888
+ if (document.warnings.length > 0) {
13889
+ fail(`profile plan contract has unsupported YAML: ${document.warnings[0]?.message}`);
13890
+ }
13891
+ let raw;
13892
+ try {
13893
+ raw = document.toJS({ maxAliasCount: 0 });
13894
+ } catch (error) {
13895
+ fail(`profile plan contract could not be converted: ${error.message}`);
13896
+ }
13897
+ const contract = requireRecord(raw, "profile plan contract");
13898
+ requireExactKeys(
13899
+ contract,
13900
+ ["version", "apps", "dependencies", "managedRuntime"],
13901
+ "profile plan contract"
13902
+ );
13903
+ if (contract.version !== CONTRACT_VERSION) {
13904
+ fail(`unsupported profile plan contract version ${String(contract.version)}.`);
13905
+ }
13906
+ const apps = requireRecord(contract.apps, "apps");
13907
+ requireExactKeys(apps, ["requireNonEmpty", "mappings"], "apps");
13908
+ const dependencies = requireRecord(contract.dependencies, "dependencies");
13909
+ requireExactKeys(dependencies, ["allowed"], "dependencies");
13910
+ const managedRuntime = requireRecord(contract.managedRuntime, "managedRuntime");
13911
+ requireExactKeys(managedRuntime, ["services", "processes"], "managedRuntime");
13912
+ const services = requireRecord(managedRuntime.services, "managedRuntime.services");
13913
+ requireExactKeys(services, ["allowed"], "managedRuntime.services");
13914
+ const processes = requireRecord(managedRuntime.processes, "managedRuntime.processes");
13915
+ requireExactKeys(processes, ["exact"], "managedRuntime.processes");
13916
+ return {
13917
+ version: CONTRACT_VERSION,
13918
+ apps: {
13919
+ requireNonEmpty: requireBoolean(apps.requireNonEmpty, "apps.requireNonEmpty"),
13920
+ mappings: parseMappings(apps.mappings)
13921
+ },
13922
+ dependencies: {
13923
+ allowed: requireStringList(dependencies.allowed, "dependencies.allowed")
13924
+ },
13925
+ managedRuntime: {
13926
+ services: {
13927
+ allowed: requireStringList(services.allowed, "managedRuntime.services.allowed")
13928
+ },
13929
+ processes: {
13930
+ exact: requireStringList(processes.exact, "managedRuntime.processes.exact")
13931
+ }
13932
+ }
13933
+ };
13934
+ }
13935
+ function sortedUnique3(values) {
13936
+ return Array.from(new Set(values)).sort();
13937
+ }
13938
+ function registryFor(config) {
13939
+ const managedRuntime = config.managedRuntime;
13940
+ return {
13941
+ apps: sortedUnique3(
13942
+ config.apps.filter((app) => app.kind !== "dependency").map((app) => app.name)
13943
+ ),
13944
+ dependencies: sortedUnique3(
13945
+ config.apps.filter((app) => app.kind === "dependency").map((app) => app.name)
13946
+ ),
13947
+ managedServices: sortedUnique3([
13948
+ ...managedRuntime?.devcontainer.baseServices ?? [],
13949
+ ...managedRuntime?.devcontainer.profileServices ?? []
13950
+ ]),
13951
+ managedProcesses: sortedUnique3(managedRuntime?.processes ?? [])
13952
+ };
13953
+ }
13954
+ function assertKnown(values, registry, label) {
13955
+ const known = new Set(registry);
13956
+ const unknown = values.filter((value) => !known.has(value));
13957
+ if (unknown.length > 0) fail(`${label} contains unknown resources: ${unknown.join(", ")}.`);
13958
+ }
13959
+ function assertAllowed(selected, allowed, label) {
13960
+ const allowedSet = new Set(allowed);
13961
+ const unsupported = selected.filter((value) => !allowedSet.has(value));
13962
+ if (unsupported.length > 0) {
13963
+ fail(`${label} selects unsupported resources: ${unsupported.join(", ")}.`);
13964
+ }
13965
+ }
13966
+ function assertExact(selected, expected, label) {
13967
+ const left = sortedUnique3(selected);
13968
+ const right = sortedUnique3(expected);
13969
+ if (left.length !== right.length || left.some((value, index) => value !== right[index])) {
13970
+ fail(`${label} must equal [${right.join(", ")}], received [${left.join(", ")}].`);
13971
+ }
13972
+ }
13973
+ function buildProfilePlanReport(options) {
13974
+ const { report, contract, registry, contractPath } = options;
13975
+ assertKnown(Object.keys(contract.apps.mappings), registry.apps, "apps.mappings");
13976
+ assertKnown(contract.dependencies.allowed, registry.dependencies, "dependencies.allowed");
13977
+ assertKnown(
13978
+ contract.managedRuntime.services.allowed,
13979
+ registry.managedServices,
13980
+ "managedRuntime.services.allowed"
13981
+ );
13982
+ assertKnown(
13983
+ contract.managedRuntime.processes.exact,
13984
+ registry.managedProcesses,
13985
+ "managedRuntime.processes.exact"
13986
+ );
13987
+ if (contract.apps.requireNonEmpty && report.apps.length === 0) {
13988
+ fail("selected profile must contain at least one app for this contract.");
13989
+ }
13990
+ const unmapped = report.apps.filter((app) => !Object.hasOwn(contract.apps.mappings, app));
13991
+ if (unmapped.length > 0) fail(`selected apps have no contract mapping: ${unmapped.join(", ")}.`);
13992
+ assertAllowed(report.dependencies, contract.dependencies.allowed, "dependencies");
13993
+ assertAllowed(
13994
+ report.managedRuntime.services,
13995
+ contract.managedRuntime.services.allowed,
13996
+ "managedRuntime.services"
13997
+ );
13998
+ assertExact(
13999
+ report.managedRuntime.processes,
14000
+ contract.managedRuntime.processes.exact,
14001
+ "managedRuntime.processes"
14002
+ );
14003
+ const bindingValues = /* @__PURE__ */ new Map();
14004
+ const bindingSeen = /* @__PURE__ */ new Map();
14005
+ for (const app of report.apps) {
14006
+ if (!Object.hasOwn(contract.apps.mappings, app)) {
14007
+ fail(`selected app '${app}' has no contract mapping.`);
14008
+ }
14009
+ const mapping = contract.apps.mappings[app];
14010
+ if (!mapping) fail(`selected app '${app}' has no contract mapping.`);
14011
+ for (const key of Object.keys(mapping.bindings).sort()) {
14012
+ const values = bindingValues.get(key) ?? [];
14013
+ const seen = bindingSeen.get(key) ?? /* @__PURE__ */ new Set();
14014
+ for (const value of mapping.bindings[key] ?? []) {
14015
+ if (seen.has(value)) continue;
14016
+ values.push(value);
14017
+ seen.add(value);
14018
+ }
14019
+ bindingValues.set(key, values);
14020
+ bindingSeen.set(key, seen);
14021
+ }
14022
+ }
14023
+ const bindings = Object.fromEntries(
14024
+ Array.from(bindingValues.keys()).sort().map((key) => [key, bindingValues.get(key) ?? []])
14025
+ );
14026
+ return { ...report, contractPath, bindings };
14027
+ }
14028
+ function loadContract(repoPath, contractPath) {
14029
+ if (import_node_path31.default.isAbsolute(contractPath)) {
14030
+ fail("profile plan contract path must be relative to the repository.");
14031
+ }
14032
+ const absolutePath = assertPathWithinRepo(contractPath, repoPath, "Profile plan contract");
14033
+ let metadata;
14034
+ try {
14035
+ metadata = import_node_fs30.default.lstatSync(absolutePath);
14036
+ } catch (error) {
14037
+ fail(`could not inspect profile plan contract '${contractPath}': ${error.message}`);
14038
+ }
14039
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
14040
+ fail(`profile plan contract '${contractPath}' must be a regular file, not a symlink.`);
14041
+ }
14042
+ if (metadata.size > MAX_CONTRACT_BYTES) {
14043
+ fail(`profile plan contract exceeds the ${MAX_CONTRACT_BYTES}-byte limit.`);
14044
+ }
14045
+ const realRepo = import_node_fs30.default.realpathSync(repoPath);
14046
+ const realContract = import_node_fs30.default.realpathSync(absolutePath);
14047
+ assertPathWithinRepo(realContract, realRepo, "Profile plan contract");
14048
+ const source = import_node_fs30.default.readFileSync(absolutePath, "utf-8");
14049
+ if (Buffer.byteLength(source, "utf-8") > MAX_CONTRACT_BYTES) {
14050
+ fail(`profile plan contract exceeds the ${MAX_CONTRACT_BYTES}-byte limit.`);
14051
+ }
14052
+ return {
14053
+ contract: parseProfilePlanContract(source),
14054
+ contractPath: import_node_path31.default.relative(repoPath, absolutePath).split(import_node_path31.default.sep).join("/")
14055
+ };
14056
+ }
14057
+ function resolveProfilePlan(options) {
14058
+ const repoPath = resolveRepoPath(options.repo);
14059
+ const config = loadRepoConfig(repoPath);
14060
+ const loaded = loadContract(repoPath, options.contract);
14061
+ return buildProfilePlanReport({
14062
+ report: buildProfileResolutionReport(config, repoPath, options.profile),
14063
+ contract: loaded.contract,
14064
+ registry: registryFor(config),
14065
+ contractPath: loaded.contractPath
14066
+ });
14067
+ }
14068
+ var import_node_fs30, import_node_path31, import_yaml7, CONTRACT_VERSION, MAX_CONTRACT_BYTES, MAX_APP_MAPPINGS, MAX_BINDING_KEYS, MAX_BINDING_VALUES, MAX_LITERAL_LENGTH, BINDING_KEY;
14069
+ var init_profile_plan = __esm({
14070
+ "src/core/profile-plan.ts"() {
14071
+ "use strict";
14072
+ import_node_fs30 = __toESM(require("fs"));
14073
+ import_node_path31 = __toESM(require("path"));
14074
+ import_yaml7 = require("yaml");
14075
+ init_paths();
14076
+ init_profile_resolution();
14077
+ init_repo_config();
14078
+ CONTRACT_VERSION = 1;
14079
+ MAX_CONTRACT_BYTES = 1024 * 1024;
14080
+ MAX_APP_MAPPINGS = 256;
14081
+ MAX_BINDING_KEYS = 64;
14082
+ MAX_BINDING_VALUES = 4096;
14083
+ MAX_LITERAL_LENGTH = 4096;
14084
+ BINDING_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
14085
+ }
14086
+ });
14087
+
13238
14088
  // src/commands/profile.ts
13239
14089
  var profile_exports = {};
13240
14090
  __export(profile_exports, {
14091
+ runProfilePlanCommand: () => runProfilePlanCommand,
13241
14092
  runProfileResolveCommand: () => runProfileResolveCommand
13242
14093
  });
13243
14094
  async function runProfileResolveCommand(options) {
@@ -13248,6 +14099,18 @@ async function runProfileResolveCommand(options) {
13248
14099
  }
13249
14100
  printProfileResolutionSummary(report);
13250
14101
  }
14102
+ async function runProfilePlanCommand(options) {
14103
+ const report = resolveProfilePlan(options);
14104
+ if (options.output) {
14105
+ writeFileAtomically(import_node_path32.default.resolve(options.output), `${JSON.stringify(report, null, 2)}
14106
+ `);
14107
+ }
14108
+ if (options.json) {
14109
+ printJSON(report);
14110
+ return;
14111
+ }
14112
+ printProfilePlanSummary(report, options.output);
14113
+ }
13251
14114
  function renderValues(values) {
13252
14115
  return values.join(", ") || "-";
13253
14116
  }
@@ -13267,10 +14130,25 @@ function printProfileResolutionSummary(report) {
13267
14130
  process.stdout.write(`Managed processes: ${renderValues(report.managedRuntime.processes)}
13268
14131
  `);
13269
14132
  }
14133
+ function printProfilePlanSummary(report, output2) {
14134
+ printProfileResolutionSummary(report);
14135
+ process.stdout.write(`Contract: ${report.contractPath}
14136
+ `);
14137
+ for (const key of Object.keys(report.bindings).sort()) {
14138
+ process.stdout.write(`Binding ${key}: ${renderValues(report.bindings[key] ?? [])}
14139
+ `);
14140
+ }
14141
+ if (output2) process.stdout.write(`Output: ${import_node_path32.default.resolve(output2)}
14142
+ `);
14143
+ }
14144
+ var import_node_path32;
13270
14145
  var init_profile = __esm({
13271
14146
  "src/commands/profile.ts"() {
13272
14147
  "use strict";
14148
+ import_node_path32 = __toESM(require("path"));
14149
+ init_atomic_file();
13273
14150
  init_output();
14151
+ init_profile_plan();
13274
14152
  init_profile_resolution();
13275
14153
  }
13276
14154
  });
@@ -13557,7 +14435,7 @@ function sanitizeRouterId(value) {
13557
14435
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
13558
14436
  }
13559
14437
  function repoHash(repoPath) {
13560
- return (0, import_node_crypto10.createHash)("sha1").update(import_node_path31.default.resolve(repoPath)).digest("hex").slice(0, 12);
14438
+ return (0, import_node_crypto10.createHash)("sha1").update(import_node_path33.default.resolve(repoPath)).digest("hex").slice(0, 12);
13561
14439
  }
13562
14440
  function asDockerApp(app) {
13563
14441
  return app.runtime === "docker";
@@ -13636,11 +14514,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
13636
14514
  if (dockerApps.length === 0) {
13637
14515
  throw new Error("No docker apps selected to prepare compose overlay.");
13638
14516
  }
13639
- const cachePath = import_node_path31.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
13640
- import_node_fs30.default.mkdirSync(cachePath, { recursive: true });
13641
- const overlayPath = import_node_path31.default.join(cachePath, "compose.devrouter.yml");
14517
+ const cachePath = import_node_path33.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
14518
+ import_node_fs31.default.mkdirSync(cachePath, { recursive: true });
14519
+ const overlayPath = import_node_path33.default.join(cachePath, "compose.devrouter.yml");
13642
14520
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
13643
- import_node_fs30.default.writeFileSync(overlayPath, import_yaml7.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
14521
+ import_node_fs31.default.writeFileSync(overlayPath, import_yaml8.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
13644
14522
  return {
13645
14523
  overlayPath,
13646
14524
  composeFiles: ensureComposeFiles(dockerApps),
@@ -13760,15 +14638,15 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
13760
14638
  const port = Number(match[1]);
13761
14639
  return Number.isInteger(port) && port > 0 ? port : void 0;
13762
14640
  }
13763
- var import_node_child_process27, import_node_crypto10, import_node_fs30, import_node_path31, import_yaml7;
14641
+ var import_node_child_process27, import_node_crypto10, import_node_fs31, import_node_path33, import_yaml8;
13764
14642
  var init_docker_run = __esm({
13765
14643
  "src/core/docker-run.ts"() {
13766
14644
  "use strict";
13767
14645
  import_node_child_process27 = require("child_process");
13768
14646
  import_node_crypto10 = require("crypto");
13769
- import_node_fs30 = __toESM(require("fs"));
13770
- import_node_path31 = __toESM(require("path"));
13771
- import_yaml7 = __toESM(require("yaml"));
14647
+ import_node_fs31 = __toESM(require("fs"));
14648
+ import_node_path33 = __toESM(require("path"));
14649
+ import_yaml8 = __toESM(require("yaml"));
13772
14650
  init_docker_error_guidance();
13773
14651
  init_paths();
13774
14652
  init_router();
@@ -14589,13 +15467,13 @@ function measureWorktreeConsumption(worktreePath, options) {
14589
15467
  const startedAt = Date.now();
14590
15468
  let rootStat;
14591
15469
  try {
14592
- rootStat = import_node_fs31.default.lstatSync(worktreePath);
15470
+ rootStat = import_node_fs32.default.lstatSync(worktreePath);
14593
15471
  } catch (error) {
14594
15472
  return { status: "unknown", reason: describeError(error, worktreePath) };
14595
15473
  }
14596
15474
  let rootEntries;
14597
15475
  try {
14598
- rootEntries = import_node_fs31.default.readdirSync(worktreePath, { withFileTypes: true });
15476
+ rootEntries = import_node_fs32.default.readdirSync(worktreePath, { withFileTypes: true });
14599
15477
  } catch (error) {
14600
15478
  return { status: "unknown", reason: describeError(error, worktreePath) };
14601
15479
  }
@@ -14621,10 +15499,10 @@ function measureWorktreeConsumption(worktreePath, options) {
14621
15499
  timedOut = true;
14622
15500
  break;
14623
15501
  }
14624
- const entryPath = import_node_path32.default.join(dirPath, entry.name);
15502
+ const entryPath = import_node_path34.default.join(dirPath, entry.name);
14625
15503
  let entryStat;
14626
15504
  try {
14627
- entryStat = import_node_fs31.default.lstatSync(entryPath);
15505
+ entryStat = import_node_fs32.default.lstatSync(entryPath);
14628
15506
  } catch (error) {
14629
15507
  if (error?.code === "ENOENT") continue;
14630
15508
  unreadableReason = describeIncompleteWalk(error);
@@ -14634,7 +15512,7 @@ function measureWorktreeConsumption(worktreePath, options) {
14634
15512
  if (!entryStat.isDirectory()) continue;
14635
15513
  let childEntries;
14636
15514
  try {
14637
- childEntries = import_node_fs31.default.readdirSync(entryPath, { withFileTypes: true });
15515
+ childEntries = import_node_fs32.default.readdirSync(entryPath, { withFileTypes: true });
14638
15516
  } catch (error) {
14639
15517
  unreadableReason = describeIncompleteWalk(error);
14640
15518
  break;
@@ -14698,12 +15576,12 @@ function describeError(error, worktreePath) {
14698
15576
  }
14699
15577
  return `could not stat worktree path '${worktreePath}': ${error?.message ?? String(error)}`;
14700
15578
  }
14701
- var import_node_fs31, import_node_path32, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
15579
+ var import_node_fs32, import_node_path34, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
14702
15580
  var init_workspace_consumption = __esm({
14703
15581
  "src/core/workspace-consumption.ts"() {
14704
15582
  "use strict";
14705
- import_node_fs31 = __toESM(require("fs"));
14706
- import_node_path32 = __toESM(require("path"));
15583
+ import_node_fs32 = __toESM(require("fs"));
15584
+ import_node_path34 = __toESM(require("path"));
14707
15585
  init_devpod_environment();
14708
15586
  DEFAULT_DEADLINE_MS = 1e4;
14709
15587
  BLOCK_SIZE_BYTES = 512;
@@ -14711,7 +15589,7 @@ var init_workspace_consumption = __esm({
14711
15589
  });
14712
15590
 
14713
15591
  // src/core/workspace-cleanup.ts
14714
- function isRecord2(value) {
15592
+ function isRecord4(value) {
14715
15593
  return value !== null && typeof value === "object" && !Array.isArray(value);
14716
15594
  }
14717
15595
  function isSha(value) {
@@ -14804,7 +15682,7 @@ function evaluateWorkspaceActivity(evidence, cutoff) {
14804
15682
  }
14805
15683
  function readGitSnapshot(worktree, commandRunner) {
14806
15684
  const comparablePath = comparableWorkspacePath(worktree.path);
14807
- if (worktree.prunable || !import_node_fs32.default.existsSync(comparablePath)) {
15685
+ if (worktree.prunable || !import_node_fs33.default.existsSync(comparablePath)) {
14808
15686
  return { worktree, checkout: "missing", head: null, committerDate: null };
14809
15687
  }
14810
15688
  const head = gitOutput(comparablePath, ["rev-parse", "--verify", "HEAD"], commandRunner);
@@ -14855,14 +15733,14 @@ function parseGitHubChanges(value, project, branch) {
14855
15733
  if (!Array.isArray(value)) return void 0;
14856
15734
  const changes = [];
14857
15735
  for (const item of value) {
14858
- if (!isRecord2(item)) return void 0;
14859
- const repository = isRecord2(item.repository) ? item.repository.nameWithOwner : void 0;
14860
- const headRepository = isRecord2(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;
14861
15739
  if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
14862
15740
  continue;
14863
15741
  }
14864
15742
  const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
14865
- const mergeCommit = isRecord2(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;
14866
15744
  const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
14867
15745
  changes.push({
14868
15746
  sourceBranch: branch,
@@ -14879,14 +15757,14 @@ function parseGitLabChanges(value, _project, branch) {
14879
15757
  if (!Array.isArray(value)) return void 0;
14880
15758
  const changes = [];
14881
15759
  for (const item of value) {
14882
- if (!isRecord2(item)) return void 0;
15760
+ if (!isRecord4(item)) return void 0;
14883
15761
  const sourceProjectId = item.source_project_id;
14884
15762
  const targetProjectId = item.target_project_id;
14885
15763
  const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
14886
15764
  if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
14887
15765
  continue;
14888
15766
  }
14889
- const diffRefs = isRecord2(item.diff_refs) ? item.diff_refs : void 0;
15767
+ const diffRefs = isRecord4(item.diff_refs) ? item.diff_refs : void 0;
14890
15768
  const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
14891
15769
  const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
14892
15770
  changes.push({
@@ -15329,7 +16207,7 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15329
16207
  containers = measureContainersFn(worktreePaths);
15330
16208
  } catch (error) {
15331
16209
  const reason = `container measurement failed: ${describeCause(error)}`;
15332
- containers = new Map(worktreePaths.map((path32) => [path32, unknownContainers(reason)]));
16210
+ containers = new Map(worktreePaths.map((path34) => [path34, unknownContainers(reason)]));
15333
16211
  }
15334
16212
  }
15335
16213
  const rows = records.map((record) => {
@@ -15378,12 +16256,12 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15378
16256
  workspaces: rows
15379
16257
  };
15380
16258
  }
15381
- var import_node_child_process29, import_node_fs32, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
16259
+ var import_node_child_process29, import_node_fs33, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
15382
16260
  var init_workspace_cleanup = __esm({
15383
16261
  "src/core/workspace-cleanup.ts"() {
15384
16262
  "use strict";
15385
16263
  import_node_child_process29 = require("child_process");
15386
- import_node_fs32 = __toESM(require("fs"));
16264
+ import_node_fs33 = __toESM(require("fs"));
15387
16265
  init_devpod_workspaces();
15388
16266
  init_host_routes();
15389
16267
  init_repo_config();
@@ -15560,7 +16438,7 @@ var init_version = __esm({
15560
16438
 
15561
16439
  // src/cli.ts
15562
16440
  var import_commander = require("commander");
15563
- var CLI_VERSION = true ? "0.0.50" : "0.0.0-dev";
16441
+ var CLI_VERSION = true ? "0.0.52" : "0.0.0-dev";
15564
16442
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
15565
16443
  function withErrorHandling(action2) {
15566
16444
  return async (...args) => {
@@ -15615,13 +16493,14 @@ program.command("up").description("Ensure devnet and start shared Traefik (reser
15615
16493
  await runUpCommand2();
15616
16494
  })
15617
16495
  );
15618
- 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(
15619
16497
  withErrorHandling(async (repoPath, _options, command) => {
15620
16498
  const options = command.opts();
15621
16499
  const { runEnsureCommand: runEnsureCommand2 } = await Promise.resolve().then(() => (init_ensure(), ensure_exports));
15622
16500
  await runEnsureCommand2({
15623
16501
  path: repoPath,
15624
16502
  profile: options.profile,
16503
+ repair: options.repair,
15625
16504
  open: Boolean(options.open),
15626
16505
  json: Boolean(options.json)
15627
16506
  });
@@ -15728,6 +16607,17 @@ profileCommand.command("resolve").description("Print the exact apps, dependencie
15728
16607
  await runProfileResolveCommand2(options);
15729
16608
  })
15730
16609
  );
16610
+ profileCommand.command("plan").description("Validate a profile against a repository contract and emit literal bindings").option("--repo <path>", "Repository path (defaults to current directory)").option(
16611
+ "--profile <name>",
16612
+ "Profile name or comma-separated selection (defaults to configured profile)"
16613
+ ).requiredOption("--contract <path>", "Repository-relative profile plan contract").option("--output <path>", "Atomically write the JSON plan with mode 0600").option("--json", "Output stable JSON").action(
16614
+ withErrorHandling(
16615
+ async (options) => {
16616
+ const { runProfilePlanCommand: runProfilePlanCommand2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
16617
+ await runProfilePlanCommand2(options);
16618
+ }
16619
+ )
16620
+ );
15731
16621
  var appCommand = program.command("app").description("Manage app entries and runtime actions from `.devrouter.yml`");
15732
16622
  appCommand.command("add").description("Add or update one app definition in `.devrouter.yml`").requiredOption("--name <name>", "App name").option("--kind <kind>", "app or dependency", "app").option("--host <host>", "Hostname ending with .localhost (required for --kind app)").option("--protocol <protocol>", "http or tcp (required for --kind app)").option(
15733
16623
  "--runtime <runtime>",
@@ -15832,7 +16722,7 @@ workspaceCommand2.command("up").description(
15832
16722
  );
15833
16723
  workspaceCommand2.command("ensure").description(
15834
16724
  "Start and prove a primary or linked checkout's workspace runtime, upstreams, and routes"
15835
- ).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(
15836
16726
  withErrorHandling(
15837
16727
  async (worktreePath, _options, command) => {
15838
16728
  const options = command.opts();
@@ -15840,6 +16730,7 @@ workspaceCommand2.command("ensure").description(
15840
16730
  await runEnsureCommand2({
15841
16731
  path: worktreePath,
15842
16732
  profile: options.profile,
16733
+ repair: options.repair,
15843
16734
  open: Boolean(options.open),
15844
16735
  json: Boolean(options.json)
15845
16736
  });