feat(resolve): derive base-URL env vars from manifest domains via ${domain:...} (#66) #67

Merged
dan-claude-bot merged 1 commit from feat/derive-domain-refs into main 2026-07-15 12:43:49 +00:00
dan-claude-bot commented 2026-07-15 12:02:59 +00:00 (Migrated from github.com)

Closes #66.

Adds a ${domain:<app>[.<service>]} env-template ref that resolves to a public domain the manifest already declares — the same domains/service_domains cast parses to reconcile Coolify domains. Kills the hand-transcription of base-URL env vars that drift from the domains cast creates (incubator's prod LANDING_BASE_URL had silently kept a pre-apex host — the exact bug this closes).

LANDING_BASE_URL=${domain:landing}          # applications.landing.domains[0]
ADMIN_WEB_BASE_URL=${domain:core.admin}     # applications.core.service_domains.admin[0]

Design — symmetric with ${resource:...} (#60), but simpler

Same shape: parse → sentinel → validate → fill. The difference: a resource URL needs the live database (internal_db_url) and defers to the executor; a domain is pure manifest data, known at plan time, so it resolves fully in desiredFromManifest against a map built from the manifest — no live read, no executor step, no unresolved-at-write path.

  • Public, not secret → resolves to { value, secret: false } (printed in diffs), and reads as a plain literal downstream — so no diff.ts change.
  • Not a secret ref → excluded from templateRefs; capture never looks for it; never enters the store.
  • assertDomainRefs is the single validation gate (apply/diff/capture), refusing before any write: an undeclared app/service, a wrong-shape ref (${domain:app} on a compose app, or ${domain:app.svc} on a plain-domains app), or an empty/blank domain list — so the unresolved sentinel can never escape to Coolify.
  • Applications only (Coolify 4.1.2 can't set service domains anyway). REPORTING_TZ-style operator literals stay literal — out of scope.

Tests

Extends test/envtemplate.test.ts + test/resolve.test.ts (500 passing): parse/classify + mutual exclusivity with the other two ref kinds; sentinel-not-literal; fillDomainEnv resolution + marker drop; templateDomainRefs extraction with templateRefs/required exclusion; one case per assertDomainRefs error branch including the schema-valid domains: [""] blank-first-entry leak (validation refuses it; the sentinel does not reach a returned env).

Consumer

heavy-duty/incubator PR#12 switches its prod base-URL literals to these refs once this merges. Like ${resource:...}, an older cast writes the literal string ${domain:...} — so consumers must re-run the installer onto the merged cast before applying.

Gates: npm run build, npm test (500), npm run check — all green.

Closes #66. Adds a `${domain:<app>[.<service>]}` env-template ref that resolves to a public domain the manifest already declares — the same `domains`/`service_domains` cast parses to reconcile Coolify domains. Kills the hand-transcription of base-URL env vars that drift from the domains cast creates (incubator's prod `LANDING_BASE_URL` had silently kept a pre-apex host — the exact bug this closes). ``` LANDING_BASE_URL=${domain:landing} # applications.landing.domains[0] ADMIN_WEB_BASE_URL=${domain:core.admin} # applications.core.service_domains.admin[0] ``` ## Design — symmetric with `${resource:...}` (#60), but simpler Same shape: parse → sentinel → validate → fill. The difference: a resource URL needs the *live* database (`internal_db_url`) and defers to the executor; **a domain is pure manifest data, known at plan time**, so it resolves fully in `desiredFromManifest` against a map built from the manifest — no live read, no executor step, no unresolved-at-write path. - **Public, not secret** → resolves to `{ value, secret: false }` (printed in diffs), and reads as a plain literal downstream — so **no `diff.ts` change**. - **Not a secret ref** → excluded from `templateRefs`; `capture` never looks for it; never enters the store. - **`assertDomainRefs` is the single validation gate** (`apply`/`diff`/`capture`), refusing before any write: an undeclared app/service, a wrong-shape ref (`${domain:app}` on a compose app, or `${domain:app.svc}` on a plain-`domains` app), or an empty/blank domain list — so the unresolved sentinel can never escape to Coolify. - **Applications only** (Coolify 4.1.2 can't set service domains anyway). `REPORTING_TZ`-style operator literals stay literal — out of scope. ## Tests Extends `test/envtemplate.test.ts` + `test/resolve.test.ts` (500 passing): parse/classify + mutual exclusivity with the other two ref kinds; sentinel-not-literal; `fillDomainEnv` resolution + marker drop; `templateDomainRefs` extraction with `templateRefs`/`required` exclusion; one case per `assertDomainRefs` error branch including the schema-valid `domains: [""]` blank-first-entry leak (validation refuses it; the sentinel does not reach a returned env). ## Consumer `heavy-duty/incubator` PR#12 switches its prod base-URL literals to these refs once this merges. Like `${resource:...}`, an older cast writes the literal string `${domain:...}` — so consumers must re-run the installer onto the merged cast before applying. Gates: `npm run build`, `npm test` (500), `npm run check` — all green.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#67
No description provided.