@devrouter/cli 0.0.57 → 0.0.59

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.
@@ -461,6 +461,14 @@ if [ -n "$prepare_command" ]; then
461
461
  wait "$preparation_pid" || preparation_status=$?
462
462
  [ "$interrupted" -eq 0 ] || exit "$interrupted"
463
463
  [ "$preparation_status" -eq 0 ] || die "Preparation for '$name' failed (exit $preparation_status)."
464
+ # Build tools can return before short-lived child shutdown finishes. Keep the
465
+ # lifecycle lock while allowing natural drainage; persistent children still fail.
466
+ for ((attempt = 0; attempt < 20; attempt += 1)); do
467
+ [ "$interrupted" -eq 0 ] || exit "$interrupted"
468
+ process_group_alive "$preparation_pid" || break
469
+ sleep 0.1
470
+ done
471
+ [ "$interrupted" -eq 0 ] || exit "$interrupted"
464
472
  if process_group_alive "$preparation_pid"; then
465
473
  die "Preparation for '$name' left running children; a synchronous foreground command is required."
466
474
  fi
@@ -23,7 +23,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  ));
24
24
 
25
25
  // src/core/devpod-exec.ts
26
- var import_node_child_process22 = require("child_process");
26
+ var import_node_child_process23 = require("child_process");
27
27
  var import_node_crypto11 = require("crypto");
28
28
 
29
29
  // src/core/devpod-environment.ts
@@ -1516,7 +1516,7 @@ function selectDevpodWorkspace(workspaces, repoPath) {
1516
1516
  }
1517
1517
 
1518
1518
  // src/core/devsy-exec.ts
1519
- var import_node_child_process21 = require("child_process");
1519
+ var import_node_child_process22 = require("child_process");
1520
1520
 
1521
1521
  // src/core/execution-outcome.ts
1522
1522
  var ExecutionOutcomeError = class extends Error {
@@ -2869,8 +2869,8 @@ function workerGroupAbsent(pid) {
2869
2869
  }
2870
2870
 
2871
2871
  // src/core/workspace-ensure.ts
2872
- var import_node_child_process20 = require("child_process");
2873
- var import_node_fs21 = __toESM(require("fs"));
2872
+ var import_node_child_process21 = require("child_process");
2873
+ var import_node_fs22 = __toESM(require("fs"));
2874
2874
  var import_node_path23 = __toESM(require("path"));
2875
2875
 
2876
2876
  // src/core/devcontainer-profile.ts
@@ -3579,6 +3579,8 @@ function assertHostNotTemplated(host, label) {
3579
3579
  }
3580
3580
  }
3581
3581
  var MAX_COMMAND_LENGTH = 4096;
3582
+ var MAX_PREPARE_ARGS = 64;
3583
+ var MAX_PREPARE_ARG_BYTES = 4096;
3582
3584
  var DEFAULT_HOST_STRATEGY = {
3583
3585
  type: "auto",
3584
3586
  denyPorts: [80, 443, 5432],
@@ -3999,6 +4001,38 @@ function parseRequiredUniqueStringArray(value, pathLabel) {
3999
4001
  }
4000
4002
  return parseUniqueStringArray(value, pathLabel);
4001
4003
  }
4004
+ function parsePrepareCommand(value, pathLabel) {
4005
+ if (value === void 0) {
4006
+ return void 0;
4007
+ }
4008
+ if (!Array.isArray(value)) {
4009
+ throw new Error(`${pathLabel} must be a non-empty array of strings.`);
4010
+ }
4011
+ if (value.length === 0) {
4012
+ throw new Error(`${pathLabel} must be a non-empty array of strings.`);
4013
+ }
4014
+ if (value.length > MAX_PREPARE_ARGS) {
4015
+ throw new Error(`${pathLabel} exceeds the maximum of ${MAX_PREPARE_ARGS} arguments.`);
4016
+ }
4017
+ return value.map((item, index) => {
4018
+ if (typeof item !== "string") {
4019
+ throw new Error(`${pathLabel}[${index}] must be a string.`);
4020
+ }
4021
+ if (index === 0 && item.length === 0) {
4022
+ throw new Error(`${pathLabel}[0] must be a non-empty executable.`);
4023
+ }
4024
+ if (item.includes("\0")) {
4025
+ throw new Error(`${pathLabel}[${index}] must not contain null bytes.`);
4026
+ }
4027
+ const byteLength = Buffer.byteLength(item, "utf8");
4028
+ if (byteLength > MAX_PREPARE_ARG_BYTES) {
4029
+ throw new Error(
4030
+ `${pathLabel}[${index}] exceeds the maximum of ${MAX_PREPARE_ARG_BYTES} UTF-8 bytes.`
4031
+ );
4032
+ }
4033
+ return item;
4034
+ });
4035
+ }
4002
4036
  function parseManagedRuntime(value, configPath) {
4003
4037
  if (value === void 0) {
4004
4038
  return void 0;
@@ -4011,7 +4045,7 @@ function parseManagedRuntime(value, configPath) {
4011
4045
  );
4012
4046
  ensureAllowedKeys(
4013
4047
  devcontainer,
4014
- ["baseServices", "profileServices"],
4048
+ ["baseServices", "profileServices", "prepareCommand"],
4015
4049
  `${configPath}.managedRuntime.devcontainer`
4016
4050
  );
4017
4051
  const baseServices = parseRequiredUniqueStringArray(
@@ -4022,6 +4056,10 @@ function parseManagedRuntime(value, configPath) {
4022
4056
  devcontainer.profileServices,
4023
4057
  `${configPath}.managedRuntime.devcontainer.profileServices`
4024
4058
  );
4059
+ const prepareCommand = parsePrepareCommand(
4060
+ devcontainer.prepareCommand,
4061
+ `${configPath}.managedRuntime.devcontainer.prepareCommand`
4062
+ );
4025
4063
  const baseSet = new Set(baseServices);
4026
4064
  const overlappingServices = profileServices.filter((service) => baseSet.has(service));
4027
4065
  if (overlappingServices.length > 0) {
@@ -4042,7 +4080,11 @@ function parseManagedRuntime(value, configPath) {
4042
4080
  );
4043
4081
  }
4044
4082
  return {
4045
- devcontainer: { baseServices, profileServices },
4083
+ devcontainer: {
4084
+ baseServices,
4085
+ profileServices,
4086
+ ...prepareCommand ? { prepareCommand } : {}
4087
+ },
4046
4088
  processes
4047
4089
  };
4048
4090
  }
@@ -4463,7 +4505,7 @@ function loadRepoConfig(repoPath) {
4463
4505
  const config = parseConfig(parsed ?? {}, configPath);
4464
4506
  const requiredVersion = config.devrouter?.version;
4465
4507
  if (requiredVersion && !hasWarnedVersionMismatch) {
4466
- const cliVersion = true ? "0.0.57" : "0.0.0-dev";
4508
+ const cliVersion = true ? "0.0.59" : "0.0.0-dev";
4467
4509
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
4468
4510
  hasWarnedVersionMismatch = true;
4469
4511
  process.stderr.write(
@@ -6139,10 +6181,88 @@ function transportFailure() {
6139
6181
  };
6140
6182
  }
6141
6183
 
6142
- // src/core/managed-post-start.ts
6184
+ // src/core/managed-host-preparation.ts
6143
6185
  var import_node_child_process18 = require("child_process");
6144
- var import_node_crypto10 = require("crypto");
6145
6186
  var import_node_fs19 = __toESM(require("fs"));
6187
+ var PREPARATION_TIMEOUT_MS = 6e4;
6188
+ var CONFIG_READ_ERROR = "Managed host preparation could not read .devrouter.yml.";
6189
+ var CONFIG_CHANGED_ERROR = "Managed host preparation changed .devrouter.yml; refusing to continue.";
6190
+ var START_ERROR = "Managed host preparation could not start.";
6191
+ var FAILURE_ERROR = "Managed host preparation failed.";
6192
+ var TIMEOUT_ERROR = "Managed host preparation timed out after 60 seconds.";
6193
+ function runPreparationCommand(argv, cwd) {
6194
+ return new Promise((resolve) => {
6195
+ let child;
6196
+ try {
6197
+ child = (0, import_node_child_process18.spawn)(argv[0], argv.slice(1), {
6198
+ cwd,
6199
+ detached: true,
6200
+ shell: false,
6201
+ stdio: "ignore"
6202
+ });
6203
+ } catch {
6204
+ resolve("start-error");
6205
+ return;
6206
+ }
6207
+ let timedOut = false;
6208
+ const deadline = setTimeout(() => {
6209
+ timedOut = true;
6210
+ if (child.pid !== void 0 && child.exitCode === null && child.signalCode === null) {
6211
+ try {
6212
+ process.kill(-child.pid, "SIGKILL");
6213
+ } catch {
6214
+ }
6215
+ }
6216
+ }, PREPARATION_TIMEOUT_MS);
6217
+ child.once("error", () => {
6218
+ clearTimeout(deadline);
6219
+ resolve("start-error");
6220
+ });
6221
+ child.once("close", (code) => {
6222
+ clearTimeout(deadline);
6223
+ resolve(timedOut ? "timeout" : code === 0 ? "success" : "failure");
6224
+ });
6225
+ });
6226
+ }
6227
+ function readConfigBytes(configPath) {
6228
+ try {
6229
+ return import_node_fs19.default.readFileSync(configPath);
6230
+ } catch {
6231
+ return void 0;
6232
+ }
6233
+ }
6234
+ async function runManagedHostPreparation(repoPath, config) {
6235
+ const prepareCommand = config.managedRuntime?.devcontainer.prepareCommand;
6236
+ if (!prepareCommand) return;
6237
+ if (process.platform === "win32") {
6238
+ throw new Error("Managed host preparation requires POSIX process-group ownership.");
6239
+ }
6240
+ const resolvedRepoPath = resolveRepoPath(repoPath);
6241
+ const configPath = getRepoConfigPath(resolvedRepoPath);
6242
+ const before = readConfigBytes(configPath);
6243
+ if (!before) {
6244
+ throw new Error(CONFIG_READ_ERROR);
6245
+ }
6246
+ const result = await runPreparationCommand(prepareCommand, resolvedRepoPath);
6247
+ const after = readConfigBytes(configPath);
6248
+ if (!after?.equals(before)) {
6249
+ throw new Error(CONFIG_CHANGED_ERROR);
6250
+ }
6251
+ if (result === "start-error") {
6252
+ throw new Error(START_ERROR);
6253
+ }
6254
+ if (result === "timeout") {
6255
+ throw new Error(TIMEOUT_ERROR);
6256
+ }
6257
+ if (result === "failure") {
6258
+ throw new Error(FAILURE_ERROR);
6259
+ }
6260
+ }
6261
+
6262
+ // src/core/managed-post-start.ts
6263
+ var import_node_child_process19 = require("child_process");
6264
+ var import_node_crypto10 = require("crypto");
6265
+ var import_node_fs20 = __toESM(require("fs"));
6146
6266
  var import_node_path21 = __toESM(require("path"));
6147
6267
  var MANAGED_MARKER = "devrouter:managed devcontainer";
6148
6268
  var MANAGED_ADAPTER_PATH = ".devcontainer/post-start.sh";
@@ -6180,7 +6300,7 @@ function commandFailure3(result) {
6180
6300
  return [result.error?.message, result.stderr, result.stdout].filter(Boolean).map((value) => String(value).trim()).filter(Boolean).join("\n");
6181
6301
  }
6182
6302
  function deliverRuntimeFile(containerId, targetPath, contents, label) {
6183
- const result = (0, import_node_child_process18.spawnSync)(
6303
+ const result = (0, import_node_child_process19.spawnSync)(
6184
6304
  "docker",
6185
6305
  ["exec", "-i", containerId, "sh", "-c", renderDeliveryScript(targetPath)],
6186
6306
  { input: contents, encoding: "utf-8" }
@@ -6191,8 +6311,8 @@ function deliverRuntimeFile(containerId, targetPath, contents, label) {
6191
6311
  }
6192
6312
  }
6193
6313
  function readRegularFileBytes(filePath) {
6194
- if (!import_node_fs19.default.existsSync(filePath) || !import_node_fs19.default.lstatSync(filePath).isFile()) return void 0;
6195
- return import_node_fs19.default.readFileSync(filePath);
6314
+ if (!import_node_fs20.default.existsSync(filePath) || !import_node_fs20.default.lstatSync(filePath).isFile()) return void 0;
6315
+ return import_node_fs20.default.readFileSync(filePath);
6196
6316
  }
6197
6317
  function readRegularFile(filePath) {
6198
6318
  return readRegularFileBytes(filePath)?.toString("utf-8");
@@ -6205,7 +6325,7 @@ function resolveProcessHelperPath() {
6205
6325
  import_node_path21.default.resolve(__dirname, "..", "bin", "devrouter-process"),
6206
6326
  import_node_path21.default.resolve(__dirname, "..", "..", "bin", "devrouter-process")
6207
6327
  ];
6208
- const helperPath = candidates.find((candidate) => import_node_fs19.default.existsSync(candidate));
6328
+ const helperPath = candidates.find((candidate) => import_node_fs20.default.existsSync(candidate));
6209
6329
  if (!helperPath) {
6210
6330
  throw new Error("Could not locate the packaged devrouter-process helper.");
6211
6331
  }
@@ -6257,7 +6377,7 @@ function resolveManagedPostStartPlan(repoPath) {
6257
6377
  }
6258
6378
  function runManagedPostStart(options) {
6259
6379
  if (options.plan.kind !== "runtime") return;
6260
- const helper = import_node_fs19.default.readFileSync(resolveProcessHelperPath());
6380
+ const helper = import_node_fs20.default.readFileSync(resolveProcessHelperPath());
6261
6381
  deliverRuntimeFile(
6262
6382
  options.container.id,
6263
6383
  RUNTIME_HELPER_PATH,
@@ -6271,7 +6391,7 @@ function runManagedPostStart(options) {
6271
6391
  options.plan.adapterContents,
6272
6392
  "the managed post-start adapter"
6273
6393
  );
6274
- const started = (0, import_node_child_process18.spawnSync)(
6394
+ const started = (0, import_node_child_process19.spawnSync)(
6275
6395
  "docker",
6276
6396
  [
6277
6397
  "exec",
@@ -6309,7 +6429,7 @@ function assertSafeProcessName(name) {
6309
6429
  }
6310
6430
  function runManagedProcessAction(options) {
6311
6431
  assertSafeProcessName(options.name);
6312
- const result = (0, import_node_child_process18.spawnSync)(
6432
+ const result = (0, import_node_child_process19.spawnSync)(
6313
6433
  "docker",
6314
6434
  [
6315
6435
  "exec",
@@ -6773,8 +6893,8 @@ function collectManagedRuntimeStatus(options) {
6773
6893
  }
6774
6894
 
6775
6895
  // src/core/traefik-route-health.ts
6776
- var import_node_child_process19 = require("child_process");
6777
- var import_node_fs20 = __toESM(require("fs"));
6896
+ var import_node_child_process20 = require("child_process");
6897
+ var import_node_fs21 = __toESM(require("fs"));
6778
6898
  var import_node_path22 = __toESM(require("path"));
6779
6899
  var ROUTER_API_BASE = "http://127.0.0.1:8080/api";
6780
6900
  var ROUTER_RELOAD_LOCK_FILE = import_node_path22.default.join(DEVROUTER_HOME, "router-reload.lock");
@@ -6787,7 +6907,7 @@ function sleep(ms) {
6787
6907
  return new Promise((resolve) => setTimeout(resolve, ms));
6788
6908
  }
6789
6909
  function inspectRouterApi(protocol) {
6790
- const result = (0, import_node_child_process19.spawnSync)(
6910
+ const result = (0, import_node_child_process20.spawnSync)(
6791
6911
  "curl",
6792
6912
  [
6793
6913
  "--silent",
@@ -6830,7 +6950,7 @@ function isRecord3(value) {
6830
6950
  return value !== null && typeof value === "object" && !Array.isArray(value);
6831
6951
  }
6832
6952
  function inspectNamedRouterApi(protocol, resource, name) {
6833
- const result = (0, import_node_child_process19.spawnSync)(
6953
+ const result = (0, import_node_child_process20.spawnSync)(
6834
6954
  "curl",
6835
6955
  [
6836
6956
  "--silent",
@@ -7041,7 +7161,7 @@ async function ensureTraefikRouteExpectation(routes, expectation, options) {
7041
7161
  throw new Error(
7042
7162
  "Traefik routes did not reload; shared router restart is disabled for this operation."
7043
7163
  );
7044
- import_node_fs20.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
7164
+ import_node_fs21.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
7045
7165
  return withFileLock(
7046
7166
  ROUTER_RELOAD_LOCK_FILE,
7047
7167
  {
@@ -7400,7 +7520,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
7400
7520
  target
7401
7521
  });
7402
7522
  if (target.kind === "linked") {
7403
- const workspaceEnv = (0, import_node_child_process20.spawnSync)(
7523
+ const workspaceEnv = (0, import_node_child_process21.spawnSync)(
7404
7524
  "docker",
7405
7525
  ["exec", appContainer.id, "printenv", "WORKSPACE"],
7406
7526
  { encoding: "utf-8" }
@@ -7410,7 +7530,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
7410
7530
  `Workspace app container must expose WORKSPACE='${target.workspace}' (got '${workspaceEnv.stdout.trim() || "(empty)"}').`
7411
7531
  );
7412
7532
  }
7413
- const devrouterWorkspaceEnv = (0, import_node_child_process20.spawnSync)(
7533
+ const devrouterWorkspaceEnv = (0, import_node_child_process21.spawnSync)(
7414
7534
  "docker",
7415
7535
  ["exec", appContainer.id, "printenv", "DEVROUTER_WORKSPACE"],
7416
7536
  { encoding: "utf-8" }
@@ -7421,7 +7541,7 @@ async function waitForContainerPreflight(repoPath, target, upstreamHosts, timeou
7421
7541
  );
7422
7542
  }
7423
7543
  }
7424
- const gitCheck = (0, import_node_child_process20.spawnSync)(
7544
+ const gitCheck = (0, import_node_child_process21.spawnSync)(
7425
7545
  "docker",
7426
7546
  [
7427
7547
  "exec",
@@ -7632,12 +7752,14 @@ function assertRepairBaseline(options) {
7632
7752
  )?.Destination;
7633
7753
  if (!workspacePath) throw new Error("Repair cannot prove the primary workspace mount.");
7634
7754
  for (const name of primary.state.Running ? config.managedRuntime?.processes ?? [] : []) {
7635
- if (!processes.includes(name) && runManagedProcessAction({
7755
+ if (processes.includes(name) && !options.verifyRetainedProcesses) continue;
7756
+ const processStatus = runManagedProcessAction({
7636
7757
  container: { id: primary.id, workspacePath },
7637
7758
  name,
7638
7759
  action: "status",
7639
7760
  quiet: true
7640
- }) !== "stopped") {
7761
+ });
7762
+ if (processStatus !== "stopped" && !(processes.includes(name) && processStatus === "running")) {
7641
7763
  throw new Error(`Repair refuses unexpected or unproven process '${name}'.`);
7642
7764
  }
7643
7765
  }
@@ -7674,14 +7796,14 @@ function resolvePrimaryTarget(repoPath) {
7674
7796
  }
7675
7797
  function isPrimaryCheckout(repoPath) {
7676
7798
  try {
7677
- return import_node_fs21.default.statSync(import_node_path23.default.join(repoPath, ".git")).isDirectory();
7799
+ return import_node_fs22.default.statSync(import_node_path23.default.join(repoPath, ".git")).isDirectory();
7678
7800
  } catch {
7679
7801
  return false;
7680
7802
  }
7681
7803
  }
7682
7804
  function openUrls(urls) {
7683
7805
  for (const url of urls) {
7684
- const opened = (0, import_node_child_process20.spawnSync)("open", [url], { encoding: "utf-8" });
7806
+ const opened = (0, import_node_child_process21.spawnSync)("open", [url], { encoding: "utf-8" });
7685
7807
  if (opened.status !== 0) {
7686
7808
  process.stderr.write(`Warning: could not open '${url}'.
7687
7809
  `);
@@ -7733,7 +7855,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
7733
7855
  let devpodId = target.devpodId;
7734
7856
  if (target.kind === "linked") {
7735
7857
  const overlayPath = import_node_path23.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
7736
- if (!import_node_fs21.default.existsSync(overlayPath)) {
7858
+ if (!import_node_fs22.default.existsSync(overlayPath)) {
7737
7859
  throw new Error(`Missing required DevPod compose overlay: ${overlayPath}`);
7738
7860
  }
7739
7861
  }
@@ -7887,7 +8009,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
7887
8009
  repairMutationStarted = true;
7888
8010
  transitionPhase = "service-start";
7889
8011
  claimLifecycleEffect();
7890
- const result = (0, import_node_child_process20.spawnSync)("docker", ["start", ...stopped.map((entry) => entry.id)], {
8012
+ const result = (0, import_node_child_process21.spawnSync)("docker", ["start", ...stopped.map((entry) => entry.id)], {
7891
8013
  encoding: "utf-8",
7892
8014
  timeout: DEFAULT_READINESS_TIMEOUT_MS,
7893
8015
  stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit"
@@ -8214,27 +8336,29 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8214
8336
  );
8215
8337
  }
8216
8338
  try {
8217
- const previousAllServices = [
8218
- managedPlan.primaryService,
8219
- ...managedPlan.baseServices,
8220
- ...previousServices
8221
- ];
8222
- claimLifecycleEffect();
8223
- startExactManagedServices({
8224
- plan: managedPlan,
8225
- composeProject: managedComposeProject,
8226
- services: previousAllServices,
8227
- quiet: options.quiet,
8228
- workspace: managedWorkspaceEnv
8229
- });
8230
- await waitForManagedServices(
8231
- managedPlan,
8232
- repoPath,
8233
- managedComposeProject,
8234
- previousAllServices,
8235
- managedContainer.id,
8236
- options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
8237
- );
8339
+ if (previousManagedState?.status !== "degraded") {
8340
+ const previousAllServices = [
8341
+ managedPlan.primaryService,
8342
+ ...managedPlan.baseServices,
8343
+ ...previousServices
8344
+ ];
8345
+ claimLifecycleEffect();
8346
+ startExactManagedServices({
8347
+ plan: managedPlan,
8348
+ composeProject: managedComposeProject,
8349
+ services: previousAllServices,
8350
+ quiet: options.quiet,
8351
+ workspace: managedWorkspaceEnv
8352
+ });
8353
+ await waitForManagedServices(
8354
+ managedPlan,
8355
+ repoPath,
8356
+ managedComposeProject,
8357
+ previousAllServices,
8358
+ managedContainer.id,
8359
+ options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
8360
+ );
8361
+ }
8238
8362
  } catch (rollbackError) {
8239
8363
  rollbackErrors.push(
8240
8364
  `services: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
@@ -8244,14 +8368,16 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8244
8368
  if (!managedPostStartPlan) {
8245
8369
  throw new Error("Managed post-start plan disappeared during rollback.");
8246
8370
  }
8247
- claimLifecycleEffect();
8248
- runManagedPostStart({
8249
- plan: managedPostStartPlan,
8250
- container: managedContainer,
8251
- quiet: options.quiet,
8252
- profile: previousManagedState?.profile ?? "full",
8253
- processes: previousProcesses
8254
- });
8371
+ if (previousManagedState?.status !== "degraded") {
8372
+ claimLifecycleEffect();
8373
+ runManagedPostStart({
8374
+ plan: managedPostStartPlan,
8375
+ container: managedContainer,
8376
+ quiet: options.quiet,
8377
+ profile: previousManagedState?.profile ?? "full",
8378
+ processes: previousProcesses
8379
+ });
8380
+ }
8255
8381
  for (const process2 of managedProcessRegistry) {
8256
8382
  if (previousProcessSet.has(process2)) continue;
8257
8383
  claimLifecycleEffect();
@@ -8320,7 +8446,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8320
8446
  );
8321
8447
  }
8322
8448
  }
8323
- if (rollbackErrors.length > 0 && previousManagedState) {
8449
+ if (previousManagedState && (rollbackErrors.length > 0 || previousManagedState.status === "degraded")) {
8324
8450
  try {
8325
8451
  markManagedRuntimeDegraded(previousManagedState, failedPhase);
8326
8452
  } catch (stateError) {
@@ -8330,7 +8456,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8330
8456
  }
8331
8457
  }
8332
8458
  const original = error instanceof Error ? error.message : String(error);
8333
- const suffix = rollbackErrors.length > 0 ? ` Rollback left degraded drift: ${rollbackErrors.join("; ")}.` : " Candidate runtime was rolled back.";
8459
+ const suffix = rollbackErrors.length > 0 ? ` Rollback left degraded drift: ${rollbackErrors.join("; ")}.` : previousManagedState?.status === "degraded" ? " Candidate resources were reconciled; the retained runtime remains degraded." : " Candidate runtime was rolled back.";
8334
8460
  throw new Error(`${original}${suffix}`);
8335
8461
  }
8336
8462
  if (managedPlan) {
@@ -8395,9 +8521,15 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8395
8521
  }
8396
8522
  };
8397
8523
  return withLifecycleOperationLock(repoPath, async () => {
8524
+ const preparationConfig = loadRepoConfig(repoPath);
8525
+ if (preparationConfig.managedRuntime?.devcontainer.prepareCommand) {
8526
+ claimLifecycleEffect();
8527
+ await runManagedHostPreparation(repoPath, preparationConfig);
8528
+ claimLifecycleEffect();
8529
+ }
8398
8530
  const repairLocked = () => {
8399
8531
  const runtime = resolveWorkspaceRuntimeOrDefault(repoPath);
8400
- import_node_fs21.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
8532
+ import_node_fs22.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
8401
8533
  return withFileLock(
8402
8534
  import_node_path23.default.join(DEVROUTER_HOME, `${runtime}-mutation.lock`),
8403
8535
  {
@@ -8421,6 +8553,35 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
8421
8553
  linked ? workspace : "",
8422
8554
  requestedOptions.profile
8423
8555
  ).profile;
8556
+ if (desiredProfile !== retained.profile) {
8557
+ const target = resolveRepairTarget(repoPath, linked);
8558
+ const baseline = loadRuntimeConfig(repoPath, linked ? workspace : "", retained.profile);
8559
+ const plan = inspectManagedDevcontainerConfig({
8560
+ repoPath,
8561
+ config: baseline.config,
8562
+ profile: baseline.resolvedProfile,
8563
+ linked
8564
+ });
8565
+ if (!baseline.config.managedRuntime)
8566
+ throw new Error("Recovery requires the retained managed configuration.");
8567
+ assertRepairBaseline({
8568
+ repoPath,
8569
+ target,
8570
+ state: retained,
8571
+ plan,
8572
+ config: baseline.config,
8573
+ profile: baseline.profile,
8574
+ processes: desiredManagedProcesses(
8575
+ baseline.config.managedRuntime,
8576
+ baseline.resolvedProfile
8577
+ ),
8578
+ routes: listHostRouteState().filter((route) => sameWorkspacePath(route.repoPath, repoPath)),
8579
+ verifyRetainedProcesses: true,
8580
+ workspace: target.kind === "linked" ? { token: target.workspace, gitCommonDir: target.gitCommonDir } : void 0
8581
+ });
8582
+ claimLifecycleEffect();
8583
+ return ensureLocked();
8584
+ }
8424
8585
  options = { ...requestedOptions, repair: true, profile: retained.profile, open: false };
8425
8586
  let repaired;
8426
8587
  try {
@@ -8682,7 +8843,7 @@ async function devsyExecOutcome(repoPath, command) {
8682
8843
  let child;
8683
8844
  try {
8684
8845
  claimLifecycleEffect();
8685
- child = (0, import_node_child_process21.spawn)("devsy", args, { stdio: "inherit" });
8846
+ child = (0, import_node_child_process22.spawn)("devsy", args, { stdio: "inherit" });
8686
8847
  } catch (error) {
8687
8848
  reject(
8688
8849
  new ExecutionOutcomeError(`devsy exec failed: ${errorMessage(error)}`, {
@@ -8728,7 +8889,7 @@ function ensureGuidance2(repoPath) {
8728
8889
  return `Run 'devrouter ensure ${repoPath}' first.`;
8729
8890
  }
8730
8891
  function assertRunningDevpod(devpodId, repoPath) {
8731
- const result = (0, import_node_child_process22.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
8892
+ const result = (0, import_node_child_process23.spawnSync)("devpod", ["status", devpodId, "--output", "json"], {
8732
8893
  encoding: "utf-8"
8733
8894
  });
8734
8895
  if (result.status !== 0) {
@@ -8907,7 +9068,7 @@ async function devpodExecOutcome(repoPath, command) {
8907
9068
  let child;
8908
9069
  try {
8909
9070
  claimLifecycleEffect();
8910
- child = (0, import_node_child_process22.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
9071
+ child = (0, import_node_child_process23.spawn)("devpod", args, { stdio: ["inherit", "inherit", "pipe"] });
8911
9072
  } catch (error) {
8912
9073
  settled = true;
8913
9074
  reject(
@@ -8964,12 +9125,12 @@ async function devpodExecOutcome(repoPath, command) {
8964
9125
  }
8965
9126
 
8966
9127
  // src/core/workspace-lifecycle.ts
8967
- var import_node_child_process23 = require("child_process");
8968
- var import_node_fs23 = __toESM(require("fs"));
9128
+ var import_node_child_process24 = require("child_process");
9129
+ var import_node_fs24 = __toESM(require("fs"));
8969
9130
  var import_node_path25 = __toESM(require("path"));
8970
9131
 
8971
9132
  // src/core/route-state.ts
8972
- var import_node_fs22 = __toESM(require("fs"));
9133
+ var import_node_fs23 = __toESM(require("fs"));
8973
9134
  function removeWorkspaceRoutesForWorktree(workspace, worktreePath) {
8974
9135
  return removeHostRoutesWhere(
8975
9136
  (route) => route.workspace === workspace && sameWorkspacePath(route.repoPath, worktreePath)