@devrouter/cli 0.0.50 → 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.50" : "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.50" : "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, {
@@ -13235,9 +13264,294 @@ var init_profile_resolution = __esm({
13235
13264
  }
13236
13265
  });
13237
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
+
13238
13551
  // src/commands/profile.ts
13239
13552
  var profile_exports = {};
13240
13553
  __export(profile_exports, {
13554
+ runProfilePlanCommand: () => runProfilePlanCommand,
13241
13555
  runProfileResolveCommand: () => runProfileResolveCommand
13242
13556
  });
13243
13557
  async function runProfileResolveCommand(options) {
@@ -13248,6 +13562,18 @@ async function runProfileResolveCommand(options) {
13248
13562
  }
13249
13563
  printProfileResolutionSummary(report);
13250
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
+ }
13251
13577
  function renderValues(values) {
13252
13578
  return values.join(", ") || "-";
13253
13579
  }
@@ -13267,10 +13593,25 @@ function printProfileResolutionSummary(report) {
13267
13593
  process.stdout.write(`Managed processes: ${renderValues(report.managedRuntime.processes)}
13268
13594
  `);
13269
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;
13270
13608
  var init_profile = __esm({
13271
13609
  "src/commands/profile.ts"() {
13272
13610
  "use strict";
13611
+ import_node_path32 = __toESM(require("path"));
13612
+ init_atomic_file();
13273
13613
  init_output();
13614
+ init_profile_plan();
13274
13615
  init_profile_resolution();
13275
13616
  }
13276
13617
  });
@@ -13557,7 +13898,7 @@ function sanitizeRouterId(value) {
13557
13898
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
13558
13899
  }
13559
13900
  function repoHash(repoPath) {
13560
- 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);
13561
13902
  }
13562
13903
  function asDockerApp(app) {
13563
13904
  return app.runtime === "docker";
@@ -13636,11 +13977,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
13636
13977
  if (dockerApps.length === 0) {
13637
13978
  throw new Error("No docker apps selected to prepare compose overlay.");
13638
13979
  }
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");
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");
13642
13983
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
13643
- 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");
13644
13985
  return {
13645
13986
  overlayPath,
13646
13987
  composeFiles: ensureComposeFiles(dockerApps),
@@ -13760,15 +14101,15 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
13760
14101
  const port = Number(match[1]);
13761
14102
  return Number.isInteger(port) && port > 0 ? port : void 0;
13762
14103
  }
13763
- 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;
13764
14105
  var init_docker_run = __esm({
13765
14106
  "src/core/docker-run.ts"() {
13766
14107
  "use strict";
13767
14108
  import_node_child_process27 = require("child_process");
13768
14109
  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"));
14110
+ import_node_fs31 = __toESM(require("fs"));
14111
+ import_node_path33 = __toESM(require("path"));
14112
+ import_yaml8 = __toESM(require("yaml"));
13772
14113
  init_docker_error_guidance();
13773
14114
  init_paths();
13774
14115
  init_router();
@@ -14589,13 +14930,13 @@ function measureWorktreeConsumption(worktreePath, options) {
14589
14930
  const startedAt = Date.now();
14590
14931
  let rootStat;
14591
14932
  try {
14592
- rootStat = import_node_fs31.default.lstatSync(worktreePath);
14933
+ rootStat = import_node_fs32.default.lstatSync(worktreePath);
14593
14934
  } catch (error) {
14594
14935
  return { status: "unknown", reason: describeError(error, worktreePath) };
14595
14936
  }
14596
14937
  let rootEntries;
14597
14938
  try {
14598
- rootEntries = import_node_fs31.default.readdirSync(worktreePath, { withFileTypes: true });
14939
+ rootEntries = import_node_fs32.default.readdirSync(worktreePath, { withFileTypes: true });
14599
14940
  } catch (error) {
14600
14941
  return { status: "unknown", reason: describeError(error, worktreePath) };
14601
14942
  }
@@ -14621,10 +14962,10 @@ function measureWorktreeConsumption(worktreePath, options) {
14621
14962
  timedOut = true;
14622
14963
  break;
14623
14964
  }
14624
- const entryPath = import_node_path32.default.join(dirPath, entry.name);
14965
+ const entryPath = import_node_path34.default.join(dirPath, entry.name);
14625
14966
  let entryStat;
14626
14967
  try {
14627
- entryStat = import_node_fs31.default.lstatSync(entryPath);
14968
+ entryStat = import_node_fs32.default.lstatSync(entryPath);
14628
14969
  } catch (error) {
14629
14970
  if (error?.code === "ENOENT") continue;
14630
14971
  unreadableReason = describeIncompleteWalk(error);
@@ -14634,7 +14975,7 @@ function measureWorktreeConsumption(worktreePath, options) {
14634
14975
  if (!entryStat.isDirectory()) continue;
14635
14976
  let childEntries;
14636
14977
  try {
14637
- childEntries = import_node_fs31.default.readdirSync(entryPath, { withFileTypes: true });
14978
+ childEntries = import_node_fs32.default.readdirSync(entryPath, { withFileTypes: true });
14638
14979
  } catch (error) {
14639
14980
  unreadableReason = describeIncompleteWalk(error);
14640
14981
  break;
@@ -14698,12 +15039,12 @@ function describeError(error, worktreePath) {
14698
15039
  }
14699
15040
  return `could not stat worktree path '${worktreePath}': ${error?.message ?? String(error)}`;
14700
15041
  }
14701
- 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;
14702
15043
  var init_workspace_consumption = __esm({
14703
15044
  "src/core/workspace-consumption.ts"() {
14704
15045
  "use strict";
14705
- import_node_fs31 = __toESM(require("fs"));
14706
- import_node_path32 = __toESM(require("path"));
15046
+ import_node_fs32 = __toESM(require("fs"));
15047
+ import_node_path34 = __toESM(require("path"));
14707
15048
  init_devpod_environment();
14708
15049
  DEFAULT_DEADLINE_MS = 1e4;
14709
15050
  BLOCK_SIZE_BYTES = 512;
@@ -14711,7 +15052,7 @@ var init_workspace_consumption = __esm({
14711
15052
  });
14712
15053
 
14713
15054
  // src/core/workspace-cleanup.ts
14714
- function isRecord2(value) {
15055
+ function isRecord3(value) {
14715
15056
  return value !== null && typeof value === "object" && !Array.isArray(value);
14716
15057
  }
14717
15058
  function isSha(value) {
@@ -14804,7 +15145,7 @@ function evaluateWorkspaceActivity(evidence, cutoff) {
14804
15145
  }
14805
15146
  function readGitSnapshot(worktree, commandRunner) {
14806
15147
  const comparablePath = comparableWorkspacePath(worktree.path);
14807
- if (worktree.prunable || !import_node_fs32.default.existsSync(comparablePath)) {
15148
+ if (worktree.prunable || !import_node_fs33.default.existsSync(comparablePath)) {
14808
15149
  return { worktree, checkout: "missing", head: null, committerDate: null };
14809
15150
  }
14810
15151
  const head = gitOutput(comparablePath, ["rev-parse", "--verify", "HEAD"], commandRunner);
@@ -14855,14 +15196,14 @@ function parseGitHubChanges(value, project, branch) {
14855
15196
  if (!Array.isArray(value)) return void 0;
14856
15197
  const changes = [];
14857
15198
  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;
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;
14861
15202
  if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
14862
15203
  continue;
14863
15204
  }
14864
15205
  const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
14865
- 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;
14866
15207
  const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
14867
15208
  changes.push({
14868
15209
  sourceBranch: branch,
@@ -14879,14 +15220,14 @@ function parseGitLabChanges(value, _project, branch) {
14879
15220
  if (!Array.isArray(value)) return void 0;
14880
15221
  const changes = [];
14881
15222
  for (const item of value) {
14882
- if (!isRecord2(item)) return void 0;
15223
+ if (!isRecord3(item)) return void 0;
14883
15224
  const sourceProjectId = item.source_project_id;
14884
15225
  const targetProjectId = item.target_project_id;
14885
15226
  const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
14886
15227
  if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
14887
15228
  continue;
14888
15229
  }
14889
- const diffRefs = isRecord2(item.diff_refs) ? item.diff_refs : void 0;
15230
+ const diffRefs = isRecord3(item.diff_refs) ? item.diff_refs : void 0;
14890
15231
  const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
14891
15232
  const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
14892
15233
  changes.push({
@@ -15329,7 +15670,7 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15329
15670
  containers = measureContainersFn(worktreePaths);
15330
15671
  } catch (error) {
15331
15672
  const reason = `container measurement failed: ${describeCause(error)}`;
15332
- containers = new Map(worktreePaths.map((path32) => [path32, unknownContainers(reason)]));
15673
+ containers = new Map(worktreePaths.map((path34) => [path34, unknownContainers(reason)]));
15333
15674
  }
15334
15675
  }
15335
15676
  const rows = records.map((record) => {
@@ -15378,12 +15719,12 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15378
15719
  workspaces: rows
15379
15720
  };
15380
15721
  }
15381
- 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;
15382
15723
  var init_workspace_cleanup = __esm({
15383
15724
  "src/core/workspace-cleanup.ts"() {
15384
15725
  "use strict";
15385
15726
  import_node_child_process29 = require("child_process");
15386
- import_node_fs32 = __toESM(require("fs"));
15727
+ import_node_fs33 = __toESM(require("fs"));
15387
15728
  init_devpod_workspaces();
15388
15729
  init_host_routes();
15389
15730
  init_repo_config();
@@ -15560,7 +15901,7 @@ var init_version = __esm({
15560
15901
 
15561
15902
  // src/cli.ts
15562
15903
  var import_commander = require("commander");
15563
- var CLI_VERSION = true ? "0.0.50" : "0.0.0-dev";
15904
+ var CLI_VERSION = true ? "0.0.51" : "0.0.0-dev";
15564
15905
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
15565
15906
  function withErrorHandling(action2) {
15566
15907
  return async (...args) => {
@@ -15728,6 +16069,17 @@ profileCommand.command("resolve").description("Print the exact apps, dependencie
15728
16069
  await runProfileResolveCommand2(options);
15729
16070
  })
15730
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
+ );
15731
16083
  var appCommand = program.command("app").description("Manage app entries and runtime actions from `.devrouter.yml`");
15732
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(
15733
16085
  "--runtime <runtime>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrouter/cli",
3
- "version": "0.0.50",
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,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>`