@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 +18 -0
- package/dist/devrouter.js +454 -52
- package/package.json +1 -1
- package/upgrade-prompts/0.0.50.md +26 -0
- 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, {
|
|
@@ -9772,8 +9801,8 @@ function inspectRouterApi(protocol) {
|
|
|
9772
9801
|
)
|
|
9773
9802
|
};
|
|
9774
9803
|
}
|
|
9775
|
-
function inspectExpectedRoutes(routes) {
|
|
9776
|
-
const
|
|
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
|
-
|
|
9813
|
+
mismatched.push(...expected);
|
|
9785
9814
|
continue;
|
|
9786
9815
|
}
|
|
9787
|
-
const
|
|
9788
|
-
|
|
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
|
-
|
|
9825
|
+
mismatched.push(...unexpected);
|
|
9826
|
+
if (expectation === "removed" && unexpected.length === 0 && result.reachedPageLimit) {
|
|
9827
|
+
mismatched.push(...expected);
|
|
9828
|
+
}
|
|
9794
9829
|
}
|
|
9795
|
-
return
|
|
9830
|
+
return mismatched.length === 0 ? { ok: true } : {
|
|
9796
9831
|
ok: false,
|
|
9797
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
13590
|
-
|
|
13591
|
-
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");
|
|
13592
13983
|
const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
|
|
13593
|
-
|
|
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,
|
|
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
|
-
|
|
13720
|
-
|
|
13721
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
14965
|
+
const entryPath = import_node_path34.default.join(dirPath, entry.name);
|
|
14575
14966
|
let entryStat;
|
|
14576
14967
|
try {
|
|
14577
|
-
entryStat =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
14656
|
-
|
|
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
|
|
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 || !
|
|
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 (!
|
|
14809
|
-
const repository =
|
|
14810
|
-
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;
|
|
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 =
|
|
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 (!
|
|
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 =
|
|
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((
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
@@ -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>`
|