@devrouter/cli 0.0.49 → 0.0.51

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/README.md CHANGED
@@ -88,6 +88,24 @@ The report contains the canonical selection and exact apps, dependencies,
88
88
  readiness checks, managed services, and process markers. Invalid configuration
89
89
  or profile input fails before any runtime operation.
90
90
 
91
+ Repository automation can bind those app identities to build arguments,
92
+ readiness URLs, or other literal values through a separate repository-owned
93
+ contract:
94
+
95
+ ```bash
96
+ devrouter profile plan \
97
+ --repo . \
98
+ --profile manage,pwa \
99
+ --contract ci/profile-plan.yml \
100
+ --output /tmp/profile-plan.json \
101
+ --json
102
+ ```
103
+
104
+ Devrouter validates the selected resources and emits deterministic arrays. It
105
+ does not interpret binding names, expand strings, execute commands, or start a
106
+ runtime. See [Repository onboarding](./docs/REPO_ONBOARDING.md#bind-profiles-to-repository-automation)
107
+ for the version-1 contract.
108
+
91
109
  Use devrouter lifecycle commands for managed environments. Raw DevPod or Devsy
92
110
  `up`/`stop`/`delete` calls bypass ownership locks and exact checkout validation.
93
111
  Devrouter auto-detects the installed workspace runtime CLI (DevPod or Devsy) and
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, "_")\`):
@@ -380,6 +402,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
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
403
  - \`devrouter ensure [path] [--profile <name>] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts; a managed profile selects its independent resource dimensions
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
@@ -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.49" : "0.0.0-dev";
2694
+ const cliVersion = true ? "0.0.51" : "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:",
@@ -3373,6 +3398,10 @@ var init_ai_prompt = __esm({
3373
3398
  command: "devrouter profile resolve --repo <path> [--profile <selection>] [--json]",
3374
3399
  purpose: "Resolve exact profile resources without starting or inspecting a runtime; fail closed on invalid selections or unknown report schemas."
3375
3400
  },
3401
+ {
3402
+ command: "devrouter profile plan --repo <path> [--profile <selection>] --contract <repo-relative-yaml> [--output <path>] [--json]",
3403
+ purpose: "Validate exact profile resources against a repository-owned contract and emit named literal arrays without interpreting them or touching a runtime."
3404
+ },
3376
3405
  {
3377
3406
  command: "devrouter stop [path] [--delete] [--json]",
3378
3407
  purpose: "Stop the exact checkout workspace runtime and routes; --delete explicitly deletes the ownership-proven runtime without removing the checkout."
@@ -8986,7 +9015,7 @@ async function buildDoctorReport(options = {}) {
8986
9015
  const config = runtimeConfig.config;
8987
9016
  loadedConfig = config;
8988
9017
  loadedWorkspace = runtimeConfig.workspace;
8989
- const cliVersion = true ? "0.0.49" : "0.0.0-dev";
9018
+ const cliVersion = true ? "0.0.51" : "0.0.0-dev";
8990
9019
  const configVersion = config.devrouter?.version;
8991
9020
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
8992
9021
  addCheck(checks, {
@@ -9772,8 +9801,8 @@ function inspectRouterApi(protocol) {
9772
9801
  )
9773
9802
  };
9774
9803
  }
9775
- function inspectExpectedRoutes(routes) {
9776
- const missing = [];
9804
+ function inspectExpectedRoutes(routes, expectation) {
9805
+ const mismatched = [];
9777
9806
  const failures = [];
9778
9807
  for (const protocol of ["http", "tcp"]) {
9779
9808
  const expected = routes.filter((route) => (route.protocol ?? "http") === protocol).map((route) => buildHostRouteRouterName(route.repoPath, route.name));
@@ -9781,28 +9810,34 @@ function inspectExpectedRoutes(routes) {
9781
9810
  const result = inspectRouterApi(protocol);
9782
9811
  if (!result.ok) {
9783
9812
  failures.push(result.details);
9784
- missing.push(...expected);
9813
+ mismatched.push(...expected);
9785
9814
  continue;
9786
9815
  }
9787
- const absent = expected.filter((name) => !result.names.has(name));
9788
- if (absent.length > 0 && result.reachedPageLimit) {
9816
+ const unexpected = expected.filter(
9817
+ (name) => expectation === "loaded" ? !result.names.has(name) : result.names.has(name)
9818
+ );
9819
+ const boundedAbsenceIsUncertain = result.reachedPageLimit && (expectation === "loaded" ? unexpected.length > 0 : unexpected.length === 0);
9820
+ if (boundedAbsenceIsUncertain) {
9789
9821
  failures.push(
9790
9822
  `${protocol.toUpperCase()} router API reached the ${ROUTER_API_PAGE_SIZE}-entry safety limit`
9791
9823
  );
9792
9824
  }
9793
- missing.push(...absent);
9825
+ mismatched.push(...unexpected);
9826
+ if (expectation === "removed" && unexpected.length === 0 && result.reachedPageLimit) {
9827
+ mismatched.push(...expected);
9828
+ }
9794
9829
  }
9795
- return missing.length === 0 ? { ok: true } : {
9830
+ return mismatched.length === 0 ? { ok: true } : {
9796
9831
  ok: false,
9797
- missing,
9798
- details: failures.length > 0 ? failures.join("; ") : "router names are absent"
9832
+ routes: mismatched,
9833
+ details: failures.length > 0 ? failures.join("; ") : expectation === "loaded" ? "router names are absent" : "router names are still present"
9799
9834
  };
9800
9835
  }
9801
- async function waitForExpectedRoutes(routes, timeoutMs, pollIntervalMs) {
9836
+ async function waitForExpectedRoutes(routes, expectation, timeoutMs, pollIntervalMs) {
9802
9837
  const deadline = Date.now() + timeoutMs;
9803
9838
  let result;
9804
9839
  do {
9805
- result = inspectExpectedRoutes(routes);
9840
+ result = inspectExpectedRoutes(routes, expectation);
9806
9841
  if (result.ok) return result;
9807
9842
  if (Date.now() < deadline) {
9808
9843
  await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
@@ -9810,12 +9845,17 @@ async function waitForExpectedRoutes(routes, timeoutMs, pollIntervalMs) {
9810
9845
  } while (Date.now() < deadline);
9811
9846
  return result;
9812
9847
  }
9813
- async function ensureTraefikRoutesLoaded(routes, options = {}) {
9848
+ async function ensureTraefikRouteExpectation(routes, expectation, options) {
9814
9849
  if (routes.length === 0) return { restarted: false };
9815
9850
  const initialTimeoutMs = options.initialTimeoutMs ?? DEFAULT_INITIAL_TIMEOUT_MS;
9816
9851
  const recoveryTimeoutMs = options.recoveryTimeoutMs ?? DEFAULT_RECOVERY_TIMEOUT_MS;
9817
9852
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
9818
- const initial = await waitForExpectedRoutes(routes, initialTimeoutMs, pollIntervalMs);
9853
+ const initial = await waitForExpectedRoutes(
9854
+ routes,
9855
+ expectation,
9856
+ initialTimeoutMs,
9857
+ pollIntervalMs
9858
+ );
9819
9859
  if (initial.ok) return { restarted: false };
9820
9860
  import_node_fs24.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
9821
9861
  return withFileLock(
@@ -9827,17 +9867,29 @@ async function ensureTraefikRoutesLoaded(routes, options = {}) {
9827
9867
  onWait: createStderrWaitReporter("Traefik route reload recovery", "shared router")
9828
9868
  },
9829
9869
  async () => {
9830
- const recheck = await waitForExpectedRoutes(routes, 0, pollIntervalMs);
9870
+ const recheck = await waitForExpectedRoutes(routes, expectation, 0, pollIntervalMs);
9831
9871
  if (recheck.ok) return { restarted: false };
9832
9872
  restartRouterStack();
9833
- const recovered = await waitForExpectedRoutes(routes, recoveryTimeoutMs, pollIntervalMs);
9873
+ const recovered = await waitForExpectedRoutes(
9874
+ routes,
9875
+ expectation,
9876
+ recoveryTimeoutMs,
9877
+ pollIntervalMs
9878
+ );
9834
9879
  if (recovered.ok) return { restarted: true };
9880
+ const action2 = expectation === "loaded" ? "load" : "remove";
9835
9881
  throw new Error(
9836
- `Traefik did not load file-provider routes after one restart: ${recovered.missing.join(", ")} (${recovered.details}). Inspect: devrouter logs --tail 100`
9882
+ `Traefik did not ${action2} file-provider routes after one restart: ${recovered.routes.join(", ")} (${recovered.details}). Inspect: devrouter logs --tail 100`
9837
9883
  );
9838
9884
  }
9839
9885
  );
9840
9886
  }
9887
+ async function ensureTraefikRoutesLoaded(routes, options = {}) {
9888
+ return ensureTraefikRouteExpectation(routes, "loaded", options);
9889
+ }
9890
+ async function ensureTraefikRoutesRemoved(routes, options = {}) {
9891
+ return ensureTraefikRouteExpectation(routes, "removed", options);
9892
+ }
9841
9893
  var import_node_child_process20, import_node_fs24, import_node_path25, ROUTER_API_BASE, ROUTER_RELOAD_LOCK_FILE, DEFAULT_INITIAL_TIMEOUT_MS, DEFAULT_RECOVERY_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, ROUTER_API_PAGE_SIZE;
9842
9894
  var init_traefik_route_health = __esm({
9843
9895
  "src/core/traefik-route-health.ts"() {
@@ -9995,6 +10047,13 @@ function routeInputFromState(route) {
9995
10047
  workspace: route.workspace
9996
10048
  };
9997
10049
  }
10050
+ function routeReferenceKey(route) {
10051
+ return `${route.repoPath}\0${route.name}\0${route.protocol ?? "http"}`;
10052
+ }
10053
+ function removedRoutesForReplacement(previousRoutes, nextRoutes) {
10054
+ const nextKeys = new Set(nextRoutes.map(routeReferenceKey));
10055
+ return previousRoutes.filter((route) => !nextKeys.has(routeReferenceKey(route)));
10056
+ }
9998
10057
  function isWildcard2(values) {
9999
10058
  return values?.length === 1 && values[0] === "*";
10000
10059
  }
@@ -10306,6 +10365,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10306
10365
  let managedComposeProject;
10307
10366
  let managedContainer;
10308
10367
  let previousRoutes = [];
10368
+ let candidateRoutes = [];
10309
10369
  let candidateRoutesPublished = false;
10310
10370
  let transitionPhase = "validation";
10311
10371
  let managedProcessRegistry = [];
@@ -10335,6 +10395,9 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10335
10395
  options.profile
10336
10396
  );
10337
10397
  const managedRuntime = runtime.config.managedRuntime;
10398
+ previousRoutes = listHostRouteState().filter(
10399
+ (route) => sameWorkspacePath(route.repoPath, repoPath)
10400
+ );
10338
10401
  managedRuntimeConfig = managedRuntime ? runtime.config : void 0;
10339
10402
  managedProcessRegistry = managedRuntime?.processes ?? [];
10340
10403
  const desiredProcesses = managedRuntime ? desiredManagedProcesses(managedRuntime, runtime.resolvedProfile) : [];
@@ -10363,9 +10426,6 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10363
10426
  )) {
10364
10427
  throw new Error("Managed runtime state contains an unregistered process marker.");
10365
10428
  }
10366
- previousRoutes = listHostRouteState().filter(
10367
- (route) => sameWorkspacePath(route.repoPath, repoPath)
10368
- );
10369
10429
  if (managedPostStart.kind !== "runtime") {
10370
10430
  throw new Error(
10371
10431
  "managedRuntime requires the runtime managed post-start adapter for exact process reconciliation."
@@ -10556,8 +10616,13 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10556
10616
  runtime.config,
10557
10617
  target.workspace
10558
10618
  );
10619
+ candidateRoutes = publication.routes;
10559
10620
  candidateRoutesPublished = true;
10560
10621
  await ensureTraefikRoutesLoaded(publication.routes, routeLoadOptions);
10622
+ await ensureTraefikRoutesRemoved(
10623
+ removedRoutesForReplacement(previousRoutes.map(routeInputFromState), publication.routes),
10624
+ routeLoadOptions
10625
+ );
10561
10626
  try {
10562
10627
  await waitForHttpRoutes(
10563
10628
  repoPath,
@@ -10569,6 +10634,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10569
10634
  throw error;
10570
10635
  }
10571
10636
  replaceHostRoutesForRepo(repoPath, []);
10637
+ await ensureTraefikRoutesRemoved(candidateRoutes, routeLoadOptions);
10572
10638
  if (!target.hadExactDevpod || recreated) {
10573
10639
  throw error;
10574
10640
  }
@@ -10758,6 +10824,10 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10758
10824
  const rollbackRoutes = previousRoutes.map(routeInputFromState);
10759
10825
  replaceHostRoutesForRepo(repoPath, rollbackRoutes);
10760
10826
  await ensureTraefikRoutesLoaded(rollbackRoutes, routeLoadOptions);
10827
+ await ensureTraefikRoutesRemoved(
10828
+ removedRoutesForReplacement(candidateRoutes, rollbackRoutes),
10829
+ routeLoadOptions
10830
+ );
10761
10831
  } catch (rollbackError) {
10762
10832
  rollbackErrors.push(
10763
10833
  `routes: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
@@ -10803,6 +10873,10 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10803
10873
  const rollbackRoutes = previousRoutes.map(routeInputFromState);
10804
10874
  replaceHostRoutesForRepo(repoPath, rollbackRoutes);
10805
10875
  await ensureTraefikRoutesLoaded(rollbackRoutes, routeLoadOptions);
10876
+ await ensureTraefikRoutesRemoved(
10877
+ removedRoutesForReplacement(candidateRoutes, rollbackRoutes),
10878
+ routeLoadOptions
10879
+ );
10806
10880
  } catch (rollbackError) {
10807
10881
  rollbackErrors.push(
10808
10882
  `routes: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
@@ -10821,6 +10895,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10821
10895
  if (environmentStarted) {
10822
10896
  try {
10823
10897
  replaceHostRoutesForRepo(repoPath, []);
10898
+ await ensureTraefikRoutesRemoved(candidateRoutes, routeLoadOptions);
10824
10899
  } catch (cleanupError) {
10825
10900
  const original = error instanceof Error ? error.message : String(error);
10826
10901
  const cleanup = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
@@ -11189,12 +11264,13 @@ function workspaceLs(repoPath) {
11189
11264
  };
11190
11265
  });
11191
11266
  }
11192
- function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
11267
+ async function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = false) {
11193
11268
  const devpods = listDevpodWorkspaces(resolved.worktreePath);
11194
11269
  assertDevpodTargetSafe(resolved, worktrees, devpods);
11195
11270
  const devpodId = resolved.record?.devpodId ?? resolved.workspace;
11196
11271
  const mutation = action2 === "stop" ? stopOwnedDevpodWorkspace(devpodId, resolved.worktreePath) : deleteOwnedDevpodWorkspace(devpodId, resolved.worktreePath);
11197
11272
  const routes = removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
11273
+ await ensureTraefikRoutesRemoved(routes);
11198
11274
  if (!quiet) {
11199
11275
  process.stdout.write(
11200
11276
  `Freed ${routes.length} route(s) for workspace '${resolved.workspace}'.
@@ -11218,7 +11294,7 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
11218
11294
  if (removeWorktree) {
11219
11295
  assertFullDownPreflight(mainRepo, resolved);
11220
11296
  }
11221
- const result = mutateWorkspaceRuntime(
11297
+ const result = await mutateWorkspaceRuntime(
11222
11298
  action2 === "stop" ? "stop" : "delete",
11223
11299
  resolved,
11224
11300
  worktrees,
@@ -11290,6 +11366,7 @@ var init_workspace_lifecycle = __esm({
11290
11366
  init_devpod_workspaces();
11291
11367
  init_repo_config();
11292
11368
  init_route_state();
11369
+ init_traefik_route_health();
11293
11370
  init_workspace();
11294
11371
  init_workspace_ensure();
11295
11372
  init_workspace_ownership();
@@ -11341,6 +11418,7 @@ async function environmentStop(repoPath, options = {}) {
11341
11418
  const removedRoutes = removeHostRoutesWhere(
11342
11419
  (route) => sameWorkspacePath(route.repoPath, repoPath)
11343
11420
  );
11421
+ await ensureTraefikRoutesRemoved(removedRoutes);
11344
11422
  return {
11345
11423
  kind: linked ? "linked" : "primary",
11346
11424
  repoPath,
@@ -11358,6 +11436,7 @@ var init_environment_stop = __esm({
11358
11436
  init_devpod_mutation();
11359
11437
  init_devpod_workspaces();
11360
11438
  init_host_routes();
11439
+ init_traefik_route_health();
11361
11440
  init_workspace();
11362
11441
  init_workspace_lifecycle();
11363
11442
  init_workspace_ownership();
@@ -13185,9 +13264,294 @@ var init_profile_resolution = __esm({
13185
13264
  }
13186
13265
  });
13187
13266
 
13267
+ // src/core/profile-plan.ts
13268
+ function fail(message) {
13269
+ throw new Error(message);
13270
+ }
13271
+ function isRecord2(value) {
13272
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13273
+ }
13274
+ function requireRecord(value, label) {
13275
+ if (!isRecord2(value)) fail(`${label} must be a mapping.`);
13276
+ return value;
13277
+ }
13278
+ function requireExactKeys(value, allowed, label) {
13279
+ const allowedSet = new Set(allowed);
13280
+ const unknown = Object.keys(value).filter((key) => !allowedSet.has(key));
13281
+ if (unknown.length > 0) fail(`${label} has unsupported keys: ${unknown.sort().join(", ")}.`);
13282
+ const missing = allowed.filter((key) => !Object.hasOwn(value, key));
13283
+ if (missing.length > 0) fail(`${label} is missing keys: ${missing.join(", ")}.`);
13284
+ }
13285
+ function requireBoolean(value, label) {
13286
+ if (typeof value !== "boolean") fail(`${label} must be a boolean.`);
13287
+ return value;
13288
+ }
13289
+ function requireStringList(value, label, options = {}) {
13290
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
13291
+ fail(`${label} must be an array of strings.`);
13292
+ }
13293
+ if (options.allowEmpty === false && value.length === 0) fail(`${label} must not be empty.`);
13294
+ for (const entry of value) {
13295
+ if (entry.length === 0) fail(`${label} must not contain empty strings.`);
13296
+ if (entry.length > MAX_LITERAL_LENGTH) {
13297
+ fail(`${label} contains a value longer than ${MAX_LITERAL_LENGTH} characters.`);
13298
+ }
13299
+ }
13300
+ if (new Set(value).size !== value.length) fail(`${label} must not contain duplicates.`);
13301
+ return [...value];
13302
+ }
13303
+ function parseMappings(value) {
13304
+ const input2 = requireRecord(value, "apps.mappings");
13305
+ const entries = Object.entries(input2);
13306
+ if (entries.length > MAX_APP_MAPPINGS) {
13307
+ fail(`apps.mappings exceeds the limit of ${MAX_APP_MAPPINGS}.`);
13308
+ }
13309
+ const mappings = [];
13310
+ const allBindingKeys = /* @__PURE__ */ new Set();
13311
+ let bindingValues = 0;
13312
+ for (const [appName, rawMapping] of entries) {
13313
+ if (appName.length === 0) fail("apps.mappings must not contain an empty app name.");
13314
+ const mapping = requireRecord(rawMapping, `apps.mappings.${appName}`);
13315
+ requireExactKeys(mapping, ["bindings"], `apps.mappings.${appName}`);
13316
+ const rawBindings = requireRecord(mapping.bindings, `apps.mappings.${appName}.bindings`);
13317
+ if (Object.keys(rawBindings).length === 0) {
13318
+ fail(`apps.mappings.${appName}.bindings must not be empty.`);
13319
+ }
13320
+ const bindings = [];
13321
+ for (const [key, rawValues] of Object.entries(rawBindings)) {
13322
+ if (!BINDING_KEY.test(key)) {
13323
+ fail(`binding key '${key}' must match ${BINDING_KEY.source}.`);
13324
+ }
13325
+ const values = requireStringList(rawValues, `apps.mappings.${appName}.bindings.${key}`, {
13326
+ allowEmpty: false
13327
+ });
13328
+ allBindingKeys.add(key);
13329
+ bindingValues += values.length;
13330
+ bindings.push([key, values]);
13331
+ }
13332
+ mappings.push([appName, { bindings: Object.fromEntries(bindings) }]);
13333
+ }
13334
+ if (allBindingKeys.size > MAX_BINDING_KEYS) {
13335
+ fail(`contract exceeds the limit of ${MAX_BINDING_KEYS} binding keys.`);
13336
+ }
13337
+ if (bindingValues > MAX_BINDING_VALUES) {
13338
+ fail(`contract exceeds the limit of ${MAX_BINDING_VALUES} binding values.`);
13339
+ }
13340
+ return Object.fromEntries(mappings);
13341
+ }
13342
+ function parseProfilePlanContract(source) {
13343
+ const document = (0, import_yaml7.parseDocument)(source, {
13344
+ merge: false,
13345
+ schema: "core",
13346
+ uniqueKeys: true
13347
+ });
13348
+ if (document.errors.length > 0) {
13349
+ fail(`profile plan contract is invalid YAML: ${document.errors[0]?.message}`);
13350
+ }
13351
+ if (document.warnings.length > 0) {
13352
+ fail(`profile plan contract has unsupported YAML: ${document.warnings[0]?.message}`);
13353
+ }
13354
+ let raw;
13355
+ try {
13356
+ raw = document.toJS({ maxAliasCount: 0 });
13357
+ } catch (error) {
13358
+ fail(`profile plan contract could not be converted: ${error.message}`);
13359
+ }
13360
+ const contract = requireRecord(raw, "profile plan contract");
13361
+ requireExactKeys(
13362
+ contract,
13363
+ ["version", "apps", "dependencies", "managedRuntime"],
13364
+ "profile plan contract"
13365
+ );
13366
+ if (contract.version !== CONTRACT_VERSION) {
13367
+ fail(`unsupported profile plan contract version ${String(contract.version)}.`);
13368
+ }
13369
+ const apps = requireRecord(contract.apps, "apps");
13370
+ requireExactKeys(apps, ["requireNonEmpty", "mappings"], "apps");
13371
+ const dependencies = requireRecord(contract.dependencies, "dependencies");
13372
+ requireExactKeys(dependencies, ["allowed"], "dependencies");
13373
+ const managedRuntime = requireRecord(contract.managedRuntime, "managedRuntime");
13374
+ requireExactKeys(managedRuntime, ["services", "processes"], "managedRuntime");
13375
+ const services = requireRecord(managedRuntime.services, "managedRuntime.services");
13376
+ requireExactKeys(services, ["allowed"], "managedRuntime.services");
13377
+ const processes = requireRecord(managedRuntime.processes, "managedRuntime.processes");
13378
+ requireExactKeys(processes, ["exact"], "managedRuntime.processes");
13379
+ return {
13380
+ version: CONTRACT_VERSION,
13381
+ apps: {
13382
+ requireNonEmpty: requireBoolean(apps.requireNonEmpty, "apps.requireNonEmpty"),
13383
+ mappings: parseMappings(apps.mappings)
13384
+ },
13385
+ dependencies: {
13386
+ allowed: requireStringList(dependencies.allowed, "dependencies.allowed")
13387
+ },
13388
+ managedRuntime: {
13389
+ services: {
13390
+ allowed: requireStringList(services.allowed, "managedRuntime.services.allowed")
13391
+ },
13392
+ processes: {
13393
+ exact: requireStringList(processes.exact, "managedRuntime.processes.exact")
13394
+ }
13395
+ }
13396
+ };
13397
+ }
13398
+ function sortedUnique3(values) {
13399
+ return Array.from(new Set(values)).sort();
13400
+ }
13401
+ function registryFor(config) {
13402
+ const managedRuntime = config.managedRuntime;
13403
+ return {
13404
+ apps: sortedUnique3(
13405
+ config.apps.filter((app) => app.kind !== "dependency").map((app) => app.name)
13406
+ ),
13407
+ dependencies: sortedUnique3(
13408
+ config.apps.filter((app) => app.kind === "dependency").map((app) => app.name)
13409
+ ),
13410
+ managedServices: sortedUnique3([
13411
+ ...managedRuntime?.devcontainer.baseServices ?? [],
13412
+ ...managedRuntime?.devcontainer.profileServices ?? []
13413
+ ]),
13414
+ managedProcesses: sortedUnique3(managedRuntime?.processes ?? [])
13415
+ };
13416
+ }
13417
+ function assertKnown(values, registry, label) {
13418
+ const known = new Set(registry);
13419
+ const unknown = values.filter((value) => !known.has(value));
13420
+ if (unknown.length > 0) fail(`${label} contains unknown resources: ${unknown.join(", ")}.`);
13421
+ }
13422
+ function assertAllowed(selected, allowed, label) {
13423
+ const allowedSet = new Set(allowed);
13424
+ const unsupported = selected.filter((value) => !allowedSet.has(value));
13425
+ if (unsupported.length > 0) {
13426
+ fail(`${label} selects unsupported resources: ${unsupported.join(", ")}.`);
13427
+ }
13428
+ }
13429
+ function assertExact(selected, expected, label) {
13430
+ const left = sortedUnique3(selected);
13431
+ const right = sortedUnique3(expected);
13432
+ if (left.length !== right.length || left.some((value, index) => value !== right[index])) {
13433
+ fail(`${label} must equal [${right.join(", ")}], received [${left.join(", ")}].`);
13434
+ }
13435
+ }
13436
+ function buildProfilePlanReport(options) {
13437
+ const { report, contract, registry, contractPath } = options;
13438
+ assertKnown(Object.keys(contract.apps.mappings), registry.apps, "apps.mappings");
13439
+ assertKnown(contract.dependencies.allowed, registry.dependencies, "dependencies.allowed");
13440
+ assertKnown(
13441
+ contract.managedRuntime.services.allowed,
13442
+ registry.managedServices,
13443
+ "managedRuntime.services.allowed"
13444
+ );
13445
+ assertKnown(
13446
+ contract.managedRuntime.processes.exact,
13447
+ registry.managedProcesses,
13448
+ "managedRuntime.processes.exact"
13449
+ );
13450
+ if (contract.apps.requireNonEmpty && report.apps.length === 0) {
13451
+ fail("selected profile must contain at least one app for this contract.");
13452
+ }
13453
+ const unmapped = report.apps.filter((app) => !Object.hasOwn(contract.apps.mappings, app));
13454
+ if (unmapped.length > 0) fail(`selected apps have no contract mapping: ${unmapped.join(", ")}.`);
13455
+ assertAllowed(report.dependencies, contract.dependencies.allowed, "dependencies");
13456
+ assertAllowed(
13457
+ report.managedRuntime.services,
13458
+ contract.managedRuntime.services.allowed,
13459
+ "managedRuntime.services"
13460
+ );
13461
+ assertExact(
13462
+ report.managedRuntime.processes,
13463
+ contract.managedRuntime.processes.exact,
13464
+ "managedRuntime.processes"
13465
+ );
13466
+ const bindingValues = /* @__PURE__ */ new Map();
13467
+ const bindingSeen = /* @__PURE__ */ new Map();
13468
+ for (const app of report.apps) {
13469
+ if (!Object.hasOwn(contract.apps.mappings, app)) {
13470
+ fail(`selected app '${app}' has no contract mapping.`);
13471
+ }
13472
+ const mapping = contract.apps.mappings[app];
13473
+ if (!mapping) fail(`selected app '${app}' has no contract mapping.`);
13474
+ for (const key of Object.keys(mapping.bindings).sort()) {
13475
+ const values = bindingValues.get(key) ?? [];
13476
+ const seen = bindingSeen.get(key) ?? /* @__PURE__ */ new Set();
13477
+ for (const value of mapping.bindings[key] ?? []) {
13478
+ if (seen.has(value)) continue;
13479
+ values.push(value);
13480
+ seen.add(value);
13481
+ }
13482
+ bindingValues.set(key, values);
13483
+ bindingSeen.set(key, seen);
13484
+ }
13485
+ }
13486
+ const bindings = Object.fromEntries(
13487
+ Array.from(bindingValues.keys()).sort().map((key) => [key, bindingValues.get(key) ?? []])
13488
+ );
13489
+ return { ...report, contractPath, bindings };
13490
+ }
13491
+ function loadContract(repoPath, contractPath) {
13492
+ if (import_node_path31.default.isAbsolute(contractPath)) {
13493
+ fail("profile plan contract path must be relative to the repository.");
13494
+ }
13495
+ const absolutePath = assertPathWithinRepo(contractPath, repoPath, "Profile plan contract");
13496
+ let metadata;
13497
+ try {
13498
+ metadata = import_node_fs30.default.lstatSync(absolutePath);
13499
+ } catch (error) {
13500
+ fail(`could not inspect profile plan contract '${contractPath}': ${error.message}`);
13501
+ }
13502
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
13503
+ fail(`profile plan contract '${contractPath}' must be a regular file, not a symlink.`);
13504
+ }
13505
+ if (metadata.size > MAX_CONTRACT_BYTES) {
13506
+ fail(`profile plan contract exceeds the ${MAX_CONTRACT_BYTES}-byte limit.`);
13507
+ }
13508
+ const realRepo = import_node_fs30.default.realpathSync(repoPath);
13509
+ const realContract = import_node_fs30.default.realpathSync(absolutePath);
13510
+ assertPathWithinRepo(realContract, realRepo, "Profile plan contract");
13511
+ const source = import_node_fs30.default.readFileSync(absolutePath, "utf-8");
13512
+ if (Buffer.byteLength(source, "utf-8") > MAX_CONTRACT_BYTES) {
13513
+ fail(`profile plan contract exceeds the ${MAX_CONTRACT_BYTES}-byte limit.`);
13514
+ }
13515
+ return {
13516
+ contract: parseProfilePlanContract(source),
13517
+ contractPath: import_node_path31.default.relative(repoPath, absolutePath).split(import_node_path31.default.sep).join("/")
13518
+ };
13519
+ }
13520
+ function resolveProfilePlan(options) {
13521
+ const repoPath = resolveRepoPath(options.repo);
13522
+ const config = loadRepoConfig(repoPath);
13523
+ const loaded = loadContract(repoPath, options.contract);
13524
+ return buildProfilePlanReport({
13525
+ report: buildProfileResolutionReport(config, repoPath, options.profile),
13526
+ contract: loaded.contract,
13527
+ registry: registryFor(config),
13528
+ contractPath: loaded.contractPath
13529
+ });
13530
+ }
13531
+ 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;
13532
+ var init_profile_plan = __esm({
13533
+ "src/core/profile-plan.ts"() {
13534
+ "use strict";
13535
+ import_node_fs30 = __toESM(require("fs"));
13536
+ import_node_path31 = __toESM(require("path"));
13537
+ import_yaml7 = require("yaml");
13538
+ init_paths();
13539
+ init_profile_resolution();
13540
+ init_repo_config();
13541
+ CONTRACT_VERSION = 1;
13542
+ MAX_CONTRACT_BYTES = 1024 * 1024;
13543
+ MAX_APP_MAPPINGS = 256;
13544
+ MAX_BINDING_KEYS = 64;
13545
+ MAX_BINDING_VALUES = 4096;
13546
+ MAX_LITERAL_LENGTH = 4096;
13547
+ BINDING_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
13548
+ }
13549
+ });
13550
+
13188
13551
  // src/commands/profile.ts
13189
13552
  var profile_exports = {};
13190
13553
  __export(profile_exports, {
13554
+ runProfilePlanCommand: () => runProfilePlanCommand,
13191
13555
  runProfileResolveCommand: () => runProfileResolveCommand
13192
13556
  });
13193
13557
  async function runProfileResolveCommand(options) {
@@ -13198,6 +13562,18 @@ async function runProfileResolveCommand(options) {
13198
13562
  }
13199
13563
  printProfileResolutionSummary(report);
13200
13564
  }
13565
+ async function runProfilePlanCommand(options) {
13566
+ const report = resolveProfilePlan(options);
13567
+ if (options.output) {
13568
+ writeFileAtomically(import_node_path32.default.resolve(options.output), `${JSON.stringify(report, null, 2)}
13569
+ `);
13570
+ }
13571
+ if (options.json) {
13572
+ printJSON(report);
13573
+ return;
13574
+ }
13575
+ printProfilePlanSummary(report, options.output);
13576
+ }
13201
13577
  function renderValues(values) {
13202
13578
  return values.join(", ") || "-";
13203
13579
  }
@@ -13217,10 +13593,25 @@ function printProfileResolutionSummary(report) {
13217
13593
  process.stdout.write(`Managed processes: ${renderValues(report.managedRuntime.processes)}
13218
13594
  `);
13219
13595
  }
13596
+ function printProfilePlanSummary(report, output2) {
13597
+ printProfileResolutionSummary(report);
13598
+ process.stdout.write(`Contract: ${report.contractPath}
13599
+ `);
13600
+ for (const key of Object.keys(report.bindings).sort()) {
13601
+ process.stdout.write(`Binding ${key}: ${renderValues(report.bindings[key] ?? [])}
13602
+ `);
13603
+ }
13604
+ if (output2) process.stdout.write(`Output: ${import_node_path32.default.resolve(output2)}
13605
+ `);
13606
+ }
13607
+ var import_node_path32;
13220
13608
  var init_profile = __esm({
13221
13609
  "src/commands/profile.ts"() {
13222
13610
  "use strict";
13611
+ import_node_path32 = __toESM(require("path"));
13612
+ init_atomic_file();
13223
13613
  init_output();
13614
+ init_profile_plan();
13224
13615
  init_profile_resolution();
13225
13616
  }
13226
13617
  });
@@ -13507,7 +13898,7 @@ function sanitizeRouterId(value) {
13507
13898
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
13508
13899
  }
13509
13900
  function repoHash(repoPath) {
13510
- return (0, import_node_crypto10.createHash)("sha1").update(import_node_path31.default.resolve(repoPath)).digest("hex").slice(0, 12);
13901
+ return (0, import_node_crypto10.createHash)("sha1").update(import_node_path33.default.resolve(repoPath)).digest("hex").slice(0, 12);
13511
13902
  }
13512
13903
  function asDockerApp(app) {
13513
13904
  return app.runtime === "docker";
@@ -13586,11 +13977,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
13586
13977
  if (dockerApps.length === 0) {
13587
13978
  throw new Error("No docker apps selected to prepare compose overlay.");
13588
13979
  }
13589
- const cachePath = import_node_path31.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
13590
- import_node_fs30.default.mkdirSync(cachePath, { recursive: true });
13591
- const overlayPath = import_node_path31.default.join(cachePath, "compose.devrouter.yml");
13980
+ const cachePath = import_node_path33.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
13981
+ import_node_fs31.default.mkdirSync(cachePath, { recursive: true });
13982
+ const overlayPath = import_node_path33.default.join(cachePath, "compose.devrouter.yml");
13592
13983
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
13593
- import_node_fs30.default.writeFileSync(overlayPath, import_yaml7.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
13984
+ import_node_fs31.default.writeFileSync(overlayPath, import_yaml8.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
13594
13985
  return {
13595
13986
  overlayPath,
13596
13987
  composeFiles: ensureComposeFiles(dockerApps),
@@ -13710,15 +14101,15 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
13710
14101
  const port = Number(match[1]);
13711
14102
  return Number.isInteger(port) && port > 0 ? port : void 0;
13712
14103
  }
13713
- var import_node_child_process27, import_node_crypto10, import_node_fs30, import_node_path31, import_yaml7;
14104
+ var import_node_child_process27, import_node_crypto10, import_node_fs31, import_node_path33, import_yaml8;
13714
14105
  var init_docker_run = __esm({
13715
14106
  "src/core/docker-run.ts"() {
13716
14107
  "use strict";
13717
14108
  import_node_child_process27 = require("child_process");
13718
14109
  import_node_crypto10 = require("crypto");
13719
- import_node_fs30 = __toESM(require("fs"));
13720
- import_node_path31 = __toESM(require("path"));
13721
- import_yaml7 = __toESM(require("yaml"));
14110
+ import_node_fs31 = __toESM(require("fs"));
14111
+ import_node_path33 = __toESM(require("path"));
14112
+ import_yaml8 = __toESM(require("yaml"));
13722
14113
  init_docker_error_guidance();
13723
14114
  init_paths();
13724
14115
  init_router();
@@ -14539,13 +14930,13 @@ function measureWorktreeConsumption(worktreePath, options) {
14539
14930
  const startedAt = Date.now();
14540
14931
  let rootStat;
14541
14932
  try {
14542
- rootStat = import_node_fs31.default.lstatSync(worktreePath);
14933
+ rootStat = import_node_fs32.default.lstatSync(worktreePath);
14543
14934
  } catch (error) {
14544
14935
  return { status: "unknown", reason: describeError(error, worktreePath) };
14545
14936
  }
14546
14937
  let rootEntries;
14547
14938
  try {
14548
- rootEntries = import_node_fs31.default.readdirSync(worktreePath, { withFileTypes: true });
14939
+ rootEntries = import_node_fs32.default.readdirSync(worktreePath, { withFileTypes: true });
14549
14940
  } catch (error) {
14550
14941
  return { status: "unknown", reason: describeError(error, worktreePath) };
14551
14942
  }
@@ -14571,10 +14962,10 @@ function measureWorktreeConsumption(worktreePath, options) {
14571
14962
  timedOut = true;
14572
14963
  break;
14573
14964
  }
14574
- const entryPath = import_node_path32.default.join(dirPath, entry.name);
14965
+ const entryPath = import_node_path34.default.join(dirPath, entry.name);
14575
14966
  let entryStat;
14576
14967
  try {
14577
- entryStat = import_node_fs31.default.lstatSync(entryPath);
14968
+ entryStat = import_node_fs32.default.lstatSync(entryPath);
14578
14969
  } catch (error) {
14579
14970
  if (error?.code === "ENOENT") continue;
14580
14971
  unreadableReason = describeIncompleteWalk(error);
@@ -14584,7 +14975,7 @@ function measureWorktreeConsumption(worktreePath, options) {
14584
14975
  if (!entryStat.isDirectory()) continue;
14585
14976
  let childEntries;
14586
14977
  try {
14587
- childEntries = import_node_fs31.default.readdirSync(entryPath, { withFileTypes: true });
14978
+ childEntries = import_node_fs32.default.readdirSync(entryPath, { withFileTypes: true });
14588
14979
  } catch (error) {
14589
14980
  unreadableReason = describeIncompleteWalk(error);
14590
14981
  break;
@@ -14648,12 +15039,12 @@ function describeError(error, worktreePath) {
14648
15039
  }
14649
15040
  return `could not stat worktree path '${worktreePath}': ${error?.message ?? String(error)}`;
14650
15041
  }
14651
- var import_node_fs31, import_node_path32, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
15042
+ var import_node_fs32, import_node_path34, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
14652
15043
  var init_workspace_consumption = __esm({
14653
15044
  "src/core/workspace-consumption.ts"() {
14654
15045
  "use strict";
14655
- import_node_fs31 = __toESM(require("fs"));
14656
- import_node_path32 = __toESM(require("path"));
15046
+ import_node_fs32 = __toESM(require("fs"));
15047
+ import_node_path34 = __toESM(require("path"));
14657
15048
  init_devpod_environment();
14658
15049
  DEFAULT_DEADLINE_MS = 1e4;
14659
15050
  BLOCK_SIZE_BYTES = 512;
@@ -14661,7 +15052,7 @@ var init_workspace_consumption = __esm({
14661
15052
  });
14662
15053
 
14663
15054
  // src/core/workspace-cleanup.ts
14664
- function isRecord2(value) {
15055
+ function isRecord3(value) {
14665
15056
  return value !== null && typeof value === "object" && !Array.isArray(value);
14666
15057
  }
14667
15058
  function isSha(value) {
@@ -14754,7 +15145,7 @@ function evaluateWorkspaceActivity(evidence, cutoff) {
14754
15145
  }
14755
15146
  function readGitSnapshot(worktree, commandRunner) {
14756
15147
  const comparablePath = comparableWorkspacePath(worktree.path);
14757
- if (worktree.prunable || !import_node_fs32.default.existsSync(comparablePath)) {
15148
+ if (worktree.prunable || !import_node_fs33.default.existsSync(comparablePath)) {
14758
15149
  return { worktree, checkout: "missing", head: null, committerDate: null };
14759
15150
  }
14760
15151
  const head = gitOutput(comparablePath, ["rev-parse", "--verify", "HEAD"], commandRunner);
@@ -14805,14 +15196,14 @@ function parseGitHubChanges(value, project, branch) {
14805
15196
  if (!Array.isArray(value)) return void 0;
14806
15197
  const changes = [];
14807
15198
  for (const item of value) {
14808
- if (!isRecord2(item)) return void 0;
14809
- const repository = isRecord2(item.repository) ? item.repository.nameWithOwner : void 0;
14810
- const headRepository = isRecord2(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
15199
+ if (!isRecord3(item)) return void 0;
15200
+ const repository = isRecord3(item.repository) ? item.repository.nameWithOwner : void 0;
15201
+ const headRepository = isRecord3(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
14811
15202
  if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
14812
15203
  continue;
14813
15204
  }
14814
15205
  const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
14815
- const mergeCommit = isRecord2(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
15206
+ const mergeCommit = isRecord3(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
14816
15207
  const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
14817
15208
  changes.push({
14818
15209
  sourceBranch: branch,
@@ -14829,14 +15220,14 @@ function parseGitLabChanges(value, _project, branch) {
14829
15220
  if (!Array.isArray(value)) return void 0;
14830
15221
  const changes = [];
14831
15222
  for (const item of value) {
14832
- if (!isRecord2(item)) return void 0;
15223
+ if (!isRecord3(item)) return void 0;
14833
15224
  const sourceProjectId = item.source_project_id;
14834
15225
  const targetProjectId = item.target_project_id;
14835
15226
  const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
14836
15227
  if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
14837
15228
  continue;
14838
15229
  }
14839
- const diffRefs = isRecord2(item.diff_refs) ? item.diff_refs : void 0;
15230
+ const diffRefs = isRecord3(item.diff_refs) ? item.diff_refs : void 0;
14840
15231
  const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
14841
15232
  const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
14842
15233
  changes.push({
@@ -15279,7 +15670,7 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15279
15670
  containers = measureContainersFn(worktreePaths);
15280
15671
  } catch (error) {
15281
15672
  const reason = `container measurement failed: ${describeCause(error)}`;
15282
- containers = new Map(worktreePaths.map((path32) => [path32, unknownContainers(reason)]));
15673
+ containers = new Map(worktreePaths.map((path34) => [path34, unknownContainers(reason)]));
15283
15674
  }
15284
15675
  }
15285
15676
  const rows = records.map((record) => {
@@ -15328,12 +15719,12 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15328
15719
  workspaces: rows
15329
15720
  };
15330
15721
  }
15331
- var import_node_child_process29, import_node_fs32, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
15722
+ var import_node_child_process29, import_node_fs33, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
15332
15723
  var init_workspace_cleanup = __esm({
15333
15724
  "src/core/workspace-cleanup.ts"() {
15334
15725
  "use strict";
15335
15726
  import_node_child_process29 = require("child_process");
15336
- import_node_fs32 = __toESM(require("fs"));
15727
+ import_node_fs33 = __toESM(require("fs"));
15337
15728
  init_devpod_workspaces();
15338
15729
  init_host_routes();
15339
15730
  init_repo_config();
@@ -15510,7 +15901,7 @@ var init_version = __esm({
15510
15901
 
15511
15902
  // src/cli.ts
15512
15903
  var import_commander = require("commander");
15513
- var CLI_VERSION = true ? "0.0.49" : "0.0.0-dev";
15904
+ var CLI_VERSION = true ? "0.0.51" : "0.0.0-dev";
15514
15905
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
15515
15906
  function withErrorHandling(action2) {
15516
15907
  return async (...args) => {
@@ -15678,6 +16069,17 @@ profileCommand.command("resolve").description("Print the exact apps, dependencie
15678
16069
  await runProfileResolveCommand2(options);
15679
16070
  })
15680
16071
  );
16072
+ 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(
16073
+ "--profile <name>",
16074
+ "Profile name or comma-separated selection (defaults to configured profile)"
16075
+ ).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(
16076
+ withErrorHandling(
16077
+ async (options) => {
16078
+ const { runProfilePlanCommand: runProfilePlanCommand2 } = await Promise.resolve().then(() => (init_profile(), profile_exports));
16079
+ await runProfilePlanCommand2(options);
16080
+ }
16081
+ )
16082
+ );
15681
16083
  var appCommand = program.command("app").description("Manage app entries and runtime actions from `.devrouter.yml`");
15682
16084
  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(
15683
16085
  "--runtime <runtime>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "description": "Local dev routing CLI with shared Traefik reverse proxy",
5
5
  "author": "Roland Schlaefli",
6
6
  "homepage": "https://github.com/rschlaefli/devrouter#readme",
@@ -0,0 +1,26 @@
1
+ # Upgrade to devrouter 0.0.50
2
+
3
+ Devrouter now proves that Traefik unloaded exact HTTP and TCP routers removed
4
+ by stop, down, managed profile replacement, or rollback. A stale router gets
5
+ the same serialized Traefik-only restart used for publication recovery.
6
+
7
+ 1. Install `@devrouter/cli@0.0.50` on the host and bump `.devrouter.yml` to
8
+ `devrouter.version: 0.0.50`.
9
+ 2. Keep using the existing `devrouter ensure`, `devrouter stop`, and managed
10
+ profile commands. Route naming and configuration are unchanged.
11
+ 3. Treat a cleanup error after one restart as fail-closed evidence that
12
+ Traefik's live router set did not match the canonical route generation.
13
+
14
+ Verification:
15
+
16
+ - Run `devrouter -V --repo <checkout>` and confirm the installed CLI and local
17
+ repository report `0.0.50`.
18
+ - Start one safe representative managed profile, verify its exact routes, then
19
+ stop that exact checkout and require both canonical route count and matching
20
+ Traefik API routers to be zero.
21
+
22
+ Report template:
23
+
24
+ - CLI/config version: `0.0.50`
25
+ - Representative route publication and removal proof: `<passed, deferred, or details>`
26
+ - Remaining blockers: `<none or details>`
@@ -0,0 +1,40 @@
1
+ # Upgrade to devrouter 0.0.51
2
+
3
+ Devrouter can now turn an exact profile selection into repository-owned literal
4
+ bindings for CI or other automation without touching a runtime.
5
+
6
+ 1. Install `@devrouter/cli@0.0.51` on the host and bump `.devrouter.yml` to
7
+ `devrouter.version: 0.0.51`.
8
+ 2. Keep using `devrouter profile resolve` when automation only needs exact
9
+ selected resources. No consumer change is required for that command.
10
+ 3. When automation needs build arguments, readiness URLs, or other literals,
11
+ add a separate version-1 contract inside the repository and invoke
12
+ `devrouter profile plan --contract <repo-relative-yaml>`. Map every selectable
13
+ app, constrain dependency and service registries, and set the exact managed
14
+ process list for that workload.
15
+ 4. Treat bindings as data. Validate the keys expected by the consumer and pass
16
+ each value as a literal argument or input. Do not evaluate the values through
17
+ a shell or template engine.
18
+ 5. Refresh generated guidance with `devrouter repo agents` when the repository
19
+ stores Devrouter's generated `AGENTS.md` section or bundled skill.
20
+
21
+ Verification:
22
+
23
+ - Run `devrouter -V --repo <checkout>` and confirm the installed CLI and local
24
+ repository report `0.0.51`.
25
+ - With no workspace running, run `devrouter profile plan --repo <checkout>
26
+ --profile <selection> --contract <repo-relative-yaml> --output <plan.json>
27
+ --json` and require exit status zero.
28
+ - Require stdout and `<plan.json>` to contain the same schema-version-1 profile,
29
+ repository-relative `contractPath`, and expected literal `bindings`. Require
30
+ the file mode to be `0600`.
31
+ - Reject unknown schema versions, missing mappings, unexpected binding keys, and
32
+ any resource selection outside the contract instead of widening the profile.
33
+
34
+ Report template:
35
+
36
+ - CLI/config version: `0.0.51`
37
+ - Profile and contract: `<selection and repository-relative path>`
38
+ - Literal binding plan: `<passed, not used, or details>`
39
+ - Runtime side effects: `none`
40
+ - Remaining blockers: `<none or details>`