@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 +18 -0
- package/dist/devrouter.js +383 -31
- package/package.json +1 -1
- package/upgrade-prompts/0.0.51.md +40 -0
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.
|
|
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.
|
|
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(
|
|
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 =
|
|
13640
|
-
|
|
13641
|
-
const overlayPath =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
13770
|
-
|
|
13771
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
14965
|
+
const entryPath = import_node_path34.default.join(dirPath, entry.name);
|
|
14625
14966
|
let entryStat;
|
|
14626
14967
|
try {
|
|
14627
|
-
entryStat =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
14706
|
-
|
|
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
|
|
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 || !
|
|
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 (!
|
|
14859
|
-
const repository =
|
|
14860
|
-
const headRepository =
|
|
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 =
|
|
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 (!
|
|
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 =
|
|
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((
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
@@ -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>`
|