Skip to content

fix(create-app): design contracts for local Docker start failure and transient UVE 403 (#37262) - #37264

Open
fmontes wants to merge 24 commits into
mainfrom
issue-37262-create-app-docker-uve
Open

fix(create-app): design contracts for local Docker start failure and transient UVE 403 (#37262)#37264
fmontes wants to merge 24 commits into
mainfrom
issue-37262-create-app-docker-uve

Conversation

@fmontes

@fmontes fmontes commented Aug 28, 2026

Copy link
Copy Markdown
Member

PR 2 of 2 for #37262, stacked on #37263 (the spec).

The problem

npx @dotcms/create-app --local never starts dotCMS — it races Postgres and dies. Hand-starting corrupts the install, every UVE call 403s forever, and the CLI exits with nothing scaffolded.

flowchart TD
    A["dotcms starts before Postgres accepts connections"] -->|no restart policy| B["exits, stays dead"]
    B --> C["user hand-starts it from Docker Desktop"]
    C --> D["starter import left incomplete"]
    D --> E["every Apps API call 403s — permanently"]
    E --> F["CLI exits 1 · empty directory · working token discarded"]

    FIX1["**Fix 1** — compose gates dotcms on db + opensearch<br/>healthy, with a restart policy"]
    FIX2["**Fix 2** — UVE failure is non-fatal:<br/>project still scaffolds, .env still written"]

    FIX1 -.->|removes| A
    FIX2 -.->|removes| F

    style FIX1 fill:#dff0e8,stroke:#146046,color:#000
    style FIX2 fill:#dff0e8,stroke:#146046,color:#000
    style F fill:#fbe7e4,stroke:#a8281f,color:#000
Loading

The fix

One compose file, owned by the CLI. It ships inside the package instead of being fetched from main, so dotcms can be gated on db and opensearch reporting healthy, given restart: unless-stopped, and health-checked on /dotmgt/livez. The shared single-node-demo-site example is untouched. This alone removes the 403, because it removes the interrupted boot that causes it.

A failed UVE call no longer destroys the run. Two duplicated exit-on-failure call sites collapse into one configureUVE() that never calls process.exit. The 403 guidance differs by path: recreate the local stack, or check the token's permissions on your own server — opposite advice for the same status code.

Nothing successful is discarded. An exit handler writes .env and reports the connection details on every terminal path, including the 14 process.exit sites a finally cannot reach.

Recovery is unblocked. A dotCMS already on 8082 can be reused or replaced from the prompt instead of aborting; a failed npm install now actually reports failure; the compose file is never stranded in the parent directory.

The wait is legible. Compose progress is streamed with an elapsed-time ticker, and readiness moves to /dotmgt/readyz.

Verification

144 Jest tests. Two gates now run in CI via nx affected -t test: verify-compose-static (compose shape, no Docker) and verify-package (the asset is in dist/ and in npm pack output — asserting the artifact, not the manifests).

Verified end to end against a real stack: cold start healthy unaided in 60s, self-exit restarted by the policy, 8090 refused on the LAN address, and both 403 messages exercised against a stub.

One caveat. The spec's root cause 2 could not be independently reproduced: deleting the site's 27 permission rows still returned 200, because userDoesNotHaveAccess() short-circuits on user.isAdmin(). The fix is unaffected — it removes the crash, and the 403 handling is verified — but #37268 may be chasing the wrong data.

Checklist

  • Tests — 144 Jest tests, written and confirmed failing before implementation (constitution Principle V). Cold-start behaviour is covered by verify-cold-start.sh; the full fault-injection E2E suite belongs to e2e: Add E2E test suite for @dotcms/create-app CLI #35096
  • Translations — n/a
  • Security Implications Contemplated — the management port is authorized by arrival port with no credential check, so it is bound to 127.0.0.1 rather than the wildcard, and a test asserts it is refused on the LAN address

Additional Info

No Java, no com.dotmarketing.*, no DB/ES/REST contract change — not rollback-unsafe. Per ADR-0019 this ships in a dotCMS release rather than a standalone SDK publish.

Refs #37262

This PR fixes: #37262

@fmontes fmontes changed the title docs(create-app): add design contracts and data model for #37262 fix(create-app): design contracts for local Docker start failure and transient UVE 403 (#37262) Aug 28, 2026
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fmontes's task in 5m 16s —— View job


Code Review — PR #37264

Reviewed the full diff. Findings below.

🆕 New Issues

🟠 withComposeFileMovedAside does not restore the compose file on the scaffolding-failure path it exists forsrc/utils/compose-move.ts:30-36

The whole point of this helper (and AC-008) is the finally that moves docker-compose.yml back so a failed scaffold doesn't strand it in the parent directory. But the real scaffold-failure path doesn't throw — it calls process.exit(1):

// src/index.ts:784-787 (inside the action passed to withComposeFileMovedAside)
if (!created.ok) {
    spinner.fail(`Failed to scaffold frontend project (${selectedFramework}).`);
    process.exit(1);
}

process.exit() fires 'exit' handlers and terminates synchronously — it does not run finally blocks in suspended async functions. So on a clone/scaffold failure (the fresh-provision path, where the compose file was moved aside), the file is left in the parent directory and never restored — reintroducing exactly the AC-008 regression this module claims to prevent. cloneFrontEndSample errors are caught inside scaffoldFrontendProject and turned into Err, so this exit path is the normal one, not an edge case. Only a thrown error would let finally run.

🟡 Moving the compose file aside can clobber a pre-existing docker-compose.yml in the parentsrc/utils/compose-move.ts:23,27

const asideNext = path.join(directory, '..', COMPOSE_FILE);
...
await fs.move(inProject, asideNext, { overwrite: true });

overwrite: true into the project's parent (typically the user's cwd) silently overwrites any docker-compose.yml already there; the finally then moves the bundled file into the project, so the parent's original file is destroyed rather than restored. A user running npx @dotcms/create-app from inside another compose project loses that file with no prompt.

🟡 applyStarterUrl treats $ in the starter URL as a replacement patternsrc/utils/starter-url.ts:37

return composeContents.replace(CUSTOM_STARTER_URL_LINE, `$1"${starterUrl}"`);

String.replace interprets $1, $&, $` etc. in the replacement string. Because starterUrl is interpolated into that string, a URL containing a $ sequence is rewritten incorrectly (e.g. $1 re-inserts capture group 1). Use a replacement function ((_, p1) => ${p1}"${starterUrl}"``) so the value is written literally.

🟡 Stale port list in troubleshooting outputsrc/utils/index.ts:660

getDockerDiagnostics still prints Check if ports 8082, 8443, 9200, and 9600 are available. This PR deliberately dropped 9200/9600 from REQUIRED_PORTS (the bundled compose file publishes neither), so this advice now points users at ports that are irrelevant to the failure.

📋 Existing Issues

None flagged — review scope is this PR's diff.

✅ Resolved

Both semgrep-dotcms High findings on this PR (axios Proxy-Authorization leak on cross-origin redirect) are resolved, not merely patched. Commit 962f589c removed the axios dependency entirely — src/api/index.ts and the UVE/readiness/health paths now use Node's native fetch via the new src/utils/http.ts, and no runtime axios usage remains (the remaining mentions are comments explaining why it was dropped). The semgrep annotations were scanned against an earlier SHA that still imported axios; the vulnerable code class is gone.

@fmontes
fmontes force-pushed the issue-37262-create-app-docker-uve branch from 244a69d to 38e8ae1 Compare August 28, 2026 11:59
@fmontes
fmontes force-pushed the issue-37262-create-app-docker-uve branch 4 times, most recently from 4877b92 to c3243d6 Compare August 28, 2026 20:38
@github-actions github-actions Bot added Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Aug 28, 2026

for (let attempt = 1; attempt <= Math.max(1, maxRetries); attempt++) {
try {
await axios.post(url, payload, authHeaders(token));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity vulnerability may affect your project—review required:
Line 221 lists a dependency (axios) with a known High severity vulnerability.

ℹ️ Why this matters

Affected versions of axios are vulnerable to Insertion of Sensitive Information Into Sent Data. The Node.js HTTP adapter in axios fails to clear the Proxy-Authorization header when a request that initially used an authenticated HTTP proxy is redirected to a target requiring no proxy (e.g. an HTTP-to-HTTPS redirect with no HTTPS proxy configured), leaking the proxy credentials to the final origin server.

References: https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-36262, GHSA, CVE

To resolve this comment:
Check if you make requests with the Node.js HTTP adapter through an authenticated HTTP proxy with redirect following enabled.

  • If you're affected, upgrade this dependency to at least version 1.16.0 at core-web/pnpm-lock.yaml.
  • If you're not affected, comment /fp we don't use this [condition]
💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

If this is a critical or high severity finding, please also link this issue in the #security channel in Slack.

You can view more details on this finding in the Semgrep AppSec Platform here.

// Read before write, exactly once. A poll would be wrong here: the failure this guards
// against never clears, so waiting for it to clear never terminates (contract X3).
try {
await axios.get(url, authHeaders(token));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity vulnerability may affect your project—review required:
Line 185 lists a dependency (axios) with a known High severity vulnerability.

ℹ️ Why this matters

Affected versions of axios are vulnerable to Insertion of Sensitive Information Into Sent Data. The Node.js HTTP adapter in axios fails to clear the Proxy-Authorization header when a request that initially used an authenticated HTTP proxy is redirected to a target requiring no proxy (e.g. an HTTP-to-HTTPS redirect with no HTTPS proxy configured), leaking the proxy credentials to the final origin server.

References: https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-36262, GHSA, CVE

To resolve this comment:
Check if you make requests with the Node.js HTTP adapter through an authenticated HTTP proxy with redirect following enabled.

  • If you're affected, upgrade this dependency to at least version 1.16.0 at core-web/pnpm-lock.yaml.
  • If you're not affected, comment /fp we don't use this [condition]
💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

If this is a critical or high severity finding, please also link this issue in the #security channel in Slack.

You can view more details on this finding in the Semgrep AppSec Platform here.

Comment on lines 58 to 63
return await axios.get(url, {
timeout: requestTimeout,
// Accept any 2xx status code as success (health endpoints may return 200, 201, 204, etc.)
validateStatus: (status) => status >= 200 && status < 300
// Any 2xx is success — the same rule isDotcmsRunning applies, so a 204 cannot be
// accepted here and rejected there.
validateStatus: isSuccessStatus
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity vulnerability may affect your project—review required:
Line 58 lists a dependency (axios) with a known High severity vulnerability.

ℹ️ Why this matters

Affected versions of axios are vulnerable to Insertion of Sensitive Information Into Sent Data. The Node.js HTTP adapter in axios fails to clear the Proxy-Authorization header when a request that initially used an authenticated HTTP proxy is redirected to a target requiring no proxy (e.g. an HTTP-to-HTTPS redirect with no HTTPS proxy configured), leaking the proxy credentials to the final origin server.

References: https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-36262, GHSA, CVE

To resolve this comment:
Check if you make requests with the Node.js HTTP adapter through an authenticated HTTP proxy with redirect following enabled.

  • If you're affected, upgrade this dependency to at least version 1.16.0 at core-web/pnpm-lock.yaml.
  • If you're not affected, comment /fp we don't use this [condition]
💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

If this is a critical or high severity finding, please also link this issue in the #security channel in Slack.

You can view more details on this finding in the Semgrep AppSec Platform here.

const readiness = await waitForReadiness({
readyzUrl: `${LOCAL_MANAGEMENT_HOST}/dotmgt/readyz`,
fallbackUrl: DOTCMS_HEALTH_API,
get: (url) => axios.get(url, { timeout: 10000, validateStatus: () => true }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity vulnerability may affect your project—review required:
Line 409 lists a dependency (axios) with a known High severity vulnerability.

ℹ️ Why this matters

Affected versions of axios are vulnerable to Insertion of Sensitive Information Into Sent Data. The Node.js HTTP adapter in axios fails to clear the Proxy-Authorization header when a request that initially used an authenticated HTTP proxy is redirected to a target requiring no proxy (e.g. an HTTP-to-HTTPS redirect with no HTTPS proxy configured), leaking the proxy credentials to the final origin server.

References: https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-36262, GHSA, CVE

To resolve this comment:
Check if you make requests with the Node.js HTTP adapter through an authenticated HTTP proxy with redirect following enabled.

  • If you're affected, upgrade this dependency to at least version 1.16.0 at core-web/pnpm-lock.yaml.
  • If you're not affected, comment /fp we don't use this [condition]
💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

If this is a critical or high severity finding, please also link this issue in the #security channel in Slack.

You can view more details on this finding in the Semgrep AppSec Platform here.

Comment thread core-web/libs/sdk/create-app/src/uve/configure-uve.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/uve/configure-uve.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/utils/index.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/index.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/uve/configure-uve.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/uve/configure-uve.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/utils/index.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/index.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/uve/configure-uve.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/uve/configure-uve.ts Outdated
Comment thread core-web/libs/sdk/create-app/src/utils/index.ts Outdated
fmontes and others added 23 commits September 1, 2026 19:11
Phase 1 design artifacts from /speckit-plan. plan.md, research.md and
quickstart.md stay local per .gitignore — this repo tracks only spec.md,
data-model.md and contracts/.

Research corrected four assumptions carried in the issue:

- "Six siblings use condition: service_healthy" — actually three, and NONE of
  them gates dotcms on opensearch being healthy (all use service_started).
  Gating on both deviates from every precedent; justified because this stack is
  driven by an unattended CLI, using os-migration's proven opensearch probe.

- restart: unless-stopped does NOT restart an unhealthy container — Compose
  restart policies react to exit, not health. The feared "flapping" cannot
  happen; the real risk is the opposite, `--wait` blocking on a bad probe.

- The management port is authorized purely by arrival port — no credential
  check, no IP allowlist. Publishing 8090 on 0.0.0.0 would expose
  /dotmgt/health and /dotmgt/metrics to the local network, so the contract
  requires 127.0.0.1:8090:8090.

- The Jest harness already exists (jest.config.ts, tsconfig.spec.json,
  @nx/jest/plugin); only spec files are missing. node_modules is absent in this
  worktree, so pnpm install is a prerequisite for the Red gate.

Also records a compatibility constraint the compose edit must not break: the
file must keep a line matching the CUSTOM_STARTER_URL regex in
updateDockerComposeStarterUrl, which throws on no match in every installed CLI.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gn findings

Adds verify-cold-start.sh (T005-T008) — the executable form of the compose
acceptance checks, accepted at the T009 gate as the recorded Principle V
substitute for automated coverage, since the behavior needs a real Docker
daemon and a multi-minute starter import.

Adds cli-design-decisions.md covering three questions the contracts left open,
and withdraws contract X1's implementation note, which was wrong: it required a
`finally`-equivalent position, but `finally` does not run on process.exit() and
there are 17 such call sites, 13 inside a single try.

Measurement changed the diagnosis. On a clean boot there is NO settling window:
the UVE endpoint is usable at 46s, two seconds BEFORE /dotmgt/readyz goes green,
because the starter import and ES reindex complete inside Tomcat startup and the
connector accepts no traffic until after them.

Reproducing the reporter's actual path instead — kill dotcms mid starter-import,
then hand-start it — reproduces the 403 exactly, and it is PERMANENT: 193
consecutive attempts over ~7 minutes, zero successes. The server reports the
admin user lacking READ permission on demo.dotcms.com; the interrupted import
never wrote the site's permission rows and a restart does not repair them.

So the read-before-write gate polls forever against a condition that never
clears, the poll budget question is moot, and the planned "configure UVE
manually" warning is wrong advice — manual setup fails identically. Fixing the
crash removes the 403 entirely.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…osis

/speckit-analyze flagged three CRITICAL inconsistencies: the polled-GET
contract outlived the diagnosis that justified it, and two of the three
contradicting files are tracked in this PR — so a reviewer was reading a
contract that contradicted the spec in the same stack.

contracts/cli-exit-contract.md X3 and data-model.md's UVEAppConfig precondition
both said "poll GET until 200, retry on 401/403/5xx". Measurement showed a 403
here is terminal, not transient: 193 consecutive failures over ~7 minutes after
an interrupted starter import, because the site's permission rows were never
written. Polling would spin forever.

Both now specify a single GET probe, retry on 5xx only, and no retry on 403.
data-model.md gains a status-to-message table making the terminal-403 path
explicit: on 403 the CLI must tell the user to recreate the instance with
`docker compose down -v`, and must NOT offer manual UVE setup steps, which fail
identically for the same missing permissions.

Titles corrected from "transient UVE 403" to "permanent".

Refs #37262, #37268

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ation

D3 decided: silent auto-reuse on CI only; otherwise ask, and let the user stop
right there. Two points that settles — the prompt must offer abort, not just
reuse, since someone who did not expect a dotCMS on 8082 needs to stop and look;
and even the CI path prints a notice, because "silent" means no prompt, not no
output. A scripted run quietly attaching to an unknown instance is the failure
this is meant to avoid. No TTY without a CI env var is treated as CI: there is
nobody to answer, so blocking is the worst option.

D1 recommendation corrected. An earlier draft said "Option A for the guarantee,
Option B for the UVE path", which was imprecise — the UVE site does not need
throwing or catching at all. X2 requires the run to CONTINUE, so that
process.exit(1) is simply deleted and replaced with ordinary control flow. The
whole change is one process.on('exit') handler plus one deleted exit; there is
no 13-site refactor.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng the shared example

Resolves all eight open design decisions and rescopes the compose work.

The original plan hardened
docker/docker-compose-examples/single-node-demo-site/docker-compose.yml, which is
fetched from main at runtime and also used directly by README readers. Every
hardening step we wanted was therefore a behavior change shipped unversioned to
consumers who never asked for it. Gating on opensearch health was the sharpest
case: it introduces a way for dotCMS to NEVER start if that probe later breaks —
an opensearch:1 -> :2 bump invalidating admin:admin would do it — where today the
container starts regardless.

So the CLI now ships its own compose file, bundled in the npm package, and the
shared example is left untouched. Nothing else reads the CLI's file, so it can be
strict at no cost to anyone: both services gated on service_healthy, livez
healthcheck with start_period 120s (~2.5x the measured 46s boot), restart
policies, and 8090 published loopback-only.

Accepted consequence: users on <=1.2.5 keep fetching the old shared file and are
not repaired. This starts fresh local instances rather than serving CI, no known
users have it in CI, and `npx @dotcms/create-app` resolves to latest anyway — only
a warm npx cache stays behind.

Bundling also removes downloadFile's missing timeout, absent redirect handling and
lack of retry from the default path. A ComposeSource interface keeps remote
fetching one env var away (DOTCMS_COMPOSE_URL) so a field hotfix needs no release.

Other decisions recorded: X1 emits via a synchronous process.on('exit') handler
(finally does not run on process.exit, and there are 17 such call sites); .env is
always named .env, written if absent; port reuse prompts on a TTY offering reuse
or abort, and auto-reuses with a printed notice on CI or no TTY; --wait-timeout is
600s conditional on continuous feedback for the whole wait, since ten minutes of
frozen spinner is the failure this issue was reported for.

Deliberately still open: the image tag stays `latest`, so the drift the report
flagged and ADR-0019 alignment are deferred, not resolved.

verify-cold-start.sh moves to core-web/libs/sdk/create-app/scripts/ and targets
the CLI's own file.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cisions

The committed design artifacts predated four decisions the spec has since
settled. Brought into line during a /speckit-plan resync.

* start_period 120s -> 180s (data-model.md, compose-service-contract.md C2,
  cli-design-decisions.md D5). Also corrects the stated failure mode: a short
  window does not make `docker compose up --wait` block until timeout, it
  marks the container unhealthy and makes --wait abort early, abandoning an
  instance that would have been healthy. restart: unless-stopped cannot
  rescue it because restart policies react to exit, not health. Since the
  first successful probe ends the window, erring high is free - which is what
  justifies 180s over 120s.

* cli-exit-contract.md X2 now names the single configureUVE({host, siteId,
  token, mode}) owner that replaces both call sites (src/index.ts:226 and
  :369) and contains no process.exit.

* cli-exit-contract.md X3 now makes the 403 message mode-dependent. The same
  status code means different things on the two paths: on the local stack it
  is the bricked boot and `docker compose down -v` is the fix, while on a
  user-supplied server there is no stack to recreate and the token simply
  lacks permission on the site - where manual UVE setup does work and should
  be offered. Suggesting down -v there is actively wrong advice.

* cli-design-decisions.md D5 records the rejected credential-free OpenSearch
  probe. The admin:admin coupling is the probe's only real exposure and it is
  contained: the image tag is pinned to major 1, so the :1 -> :2 bump that
  would invalidate the default credentials needs a deliberate edit to this
  file by whoever then owns the probe.

* compose-service-contract.md C5 fixes the guard path
  (core-web/libs/sdk/create-app/scripts/verify-cold-start.sh, not repo root)
  and names both guards. verify-cold-start.sh --static asserts the file still
  matches the shape installed CLIs depend on, with no Docker; a Jest spec
  runs updateDockerComposeStarterUrl() and asserts the function's output.
  Neither subsumes the other.

plan.md, research.md and quickstart.md were regenerated in the same pass but
are gitignored by this repo's spec-kit setup, so they are not in this diff.
They were lost when their worktree was deleted.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ution (#37262)

Test-infrastructure only; no product code. Both problems were found by running
the things rather than reading them.

verify-cold-start.sh T006 asserted that `docker kill` on the dotcms container
is followed by a restart. Docker does not do that: an externally initiated kill
is treated as a user-requested stop and the restart policy is deliberately not
applied. Measured on docker 29.4:

  self-exit    -> RestartCount=4, status=running   (policy applied)
  docker kill  -> RestartCount=0, status=exited    (policy skipped)

So against a CORRECT compose file the check printed "it stayed dead, exactly as
reported" - a permanent false negative accusing the fix of being the bug. It now
kills the JVM inside the container so the container exits on its own, which is
also the real failure mode in this issue (dotcms dying because Postgres was not
accepting connections yet). PID 1 is tini and the JVM is a child of it, so it can
be signalled from inside; the kernel refuses SIGKILL to PID 1 from within its own
namespace, which is why signalling PID 1 would not work. The check now also
asserts RestartCount actually moved, so a container that never died cannot pass.
Verified live against a real stack: RestartCount 0 -> 1 within 3s.

T007 probed /dotmgt/readyz for an exposure test. readyz lags livez: measured,
it returned 503 for a few seconds after `up --wait` had already reported the
container healthy, so the check could flake. It now probes /dotmgt/livez, which
is what the container healthcheck guarantees and what an exposure test needs.

tsconfig.spec.json pinned moduleResolution: node10, which cannot read the
"exports" maps used by ESM-only packages - and five of this CLI's dependencies
are type: module (inquirer, ora, chalk, execa, axios). Any spec importing the
CLI's own source failed to compile with TS2307, which would have blocked the
specs planned for the port-reuse, npm-install and compose-move fixes. Dropping
the override inherits moduleResolution: bundler from tsconfig.base.json, which
is what every other SDK lib in this workspace already does; create-app was one
of only four still pinned to node10.

That exposed a second layer at the Jest runtime (chalk failing with "Cannot use
import statement outside a module"), fixed with transformIgnorePatterns: [].
Other libs here use a named allow-list, but this package's transitive tree would
need ~51 entries and would rot on every dependency bump, so the whole of
node_modules is transformed instead. Cold uncached full-suite run: ~3s.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aided (#37262)

US1, the MVP for this issue. The CLI stops downloading its docker-compose file
from dotCMS/core@main at run time and ships its own instead.

Why this is the whole fix, not just part of it. The downloaded file let dotcms
boot before Postgres was accepting connections and gave it no restart policy, so
it exited and stayed exited. Users got past that by hand-starting the container -
which left the starter import incomplete, the demo site's permission rows
unwritten, and every subsequent Apps API call returning 403 permanently. The 403
was never a separate bug; removing the crash removes it.

Owning the file is what makes hardening safe. The shared
docker/docker-compose-examples/single-node-demo-site example is fetched from main
by every installed CLI and read directly by README users, so every change wanted
here would have shipped to them unversioned and unasked. That example is left
untouched, asserted by diff (AC-010), and nothing in the CLI references it any
more - the hardcoded raw.githubusercontent.com URL is gone from the shipped
bundle.

The bundled stack (assets/docker-compose.yml), verified end to end against a real
cold start before being written: db and opensearch both get healthchecks and
restart: unless-stopped; dotcms gates on both at condition: service_healthy and
carries a /dotmgt/livez healthcheck with start_period: 180s. Measured: db and
opensearch reach healthy before dotcms starts, dotcms reaches healthy unaided,
`up -d --wait` returns 0 in 59s against a 600s budget, and start_period never
came near expiry.

/dotmgt/livez was the design's one unverified premise - both in-repo examples
probing it run dotcms-test, not the released image. Confirmed present on
dotcms/dotcms:latest: 200 "alive", from the host and from inside the container,
which is where the healthcheck runs.

The management port is published as 127.0.0.1:8090:8090, not the wildcard the
issue proposed and not what lgtm-observability does. dotCMS authorizes those
endpoints purely by arrival port - no credential check, no IP allow-list - so a
wildcard binding would put /dotmgt/health and /dotmgt/metrics on the local
network. Verified refused on the LAN address (AC-011). Documented in the README
rather than left to be discovered.

Also here:

* startup uses `up -d --wait --wait-timeout 600`, so "containers started
  successfully" is only printed once the stack is genuinely usable. Previously
  `up -d` returned as soon as containers were created, which is how the CLI came
  to report success about a dotcms that had already exited (AC-002).
* the CUSTOM_STARTER_URL rewrite is extracted to a pure, spec-guarded
  applyStarterUrl(). A reformat of that one line silently breaks --starter for
  every installed CLI (AC-012). The extraction also removed a latent bug: the old
  code detected "no match" by comparing whether the string changed, so rewriting
  to the same URL threw spuriously.
* the compose asset is declared in both package.json `files` and project.json's
  esbuild `assets`. Missing either ships a package with no compose file and every
  local-Docker run fails at step one - the most likely way to break this release,
  so it is guarded by a spec and verified with npm pack --dry-run (AC-013).

Tests written and confirmed failing before implementation, per constitution
Principle V. 30 tests, 3 suites. The cold-start behaviour of AC-001/AC-002 cannot
be unit-tested - it needs a real Docker daemon, a ~2GB pull and a multi-minute
starter import - and is covered by scripts/verify-cold-start.sh instead; that
exception was declared and approved rather than assumed.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
US2. This is the half of the issue that turned a recoverable hiccup into total
data loss.

Until now a failed Universal Visual Editor call ran process.exit(1) BEFORE
scaffolding, so the user was left with an empty directory - and the API token and
site ID that had already been obtained successfully were discarded without ever
being printed. The identical fatal block existed twice: src/index.ts:226-228 on
the existing-instance path and :369-371 on the local-Docker path. Only the second
was named anywhere in the spec, so the first could easily have been fixed and the
other left in place.

Both are replaced by a single owner, configureUVE({host, siteId, token, mode,
frontendUrl}), which never calls process.exit and never throws: failure is a
returned outcome the caller warns on and continues past. process.exit sites in
index.ts drop from 16 to 14, and DotCMSApi.setupUVEConfig has no callers left.

The 403 advice is deliberately NOT shared between the two paths, because the same
status code means opposite things:

  local  - the bricked first boot. The interrupted starter import never wrote the
           site's permission rows and a restart does not repair them, so manual
           UVE setup fails identically. Tell the user to recreate the instance
           with `docker compose down -v` and reference #37268. Do NOT offer the
           manual steps.
  remote - the user's own server. There is no stack to recreate, so suggesting
           `docker compose down -v` would be destructive advice aimed at the wrong
           machine. It is an ordinary permissions problem: name the site, the app
           key, and link the guide, because here the manual steps do work.

A 403 is never retried and never polled. Measured during planning: after an
interrupted starter import the endpoint returned 403 on 193 consecutive attempts
over ~7 minutes with zero successes, so a read-before-write poll would spin
forever. Retry is restricted to 5xx.

Recoverable state is now guaranteed to escape. exit-state.ts registers a single
process.on('exit') handler before anything can fail; once host, token and siteId
are known it prints them and writes .env. It must be 'exit' and not 'beforeExit'
- 'beforeExit' is skipped on process.exit(), which is precisely the path that
loses the token - and it must be synchronous, because Node runs no async work
during exit. A `finally` cannot do this job: it does not run on process.exit(),
and 13 of the 14 remaining call sites sit inside one try block.

One defect was caught before it shipped. The approved spec pinned the POST body as
`value: <frontendUrl>` with call sites passing a bare `http://localhost:<port>`,
but the endpoint expects the serialized config object that getUVEConfigValue
produces. Posting the raw origin would very likely be accepted, reported as
success, and leave the editor quietly non-functional - the same shape as the bug
this issue is about. configureUVE now owns the serialization so no call site can
get it wrong, and the spec asserts the real shape. Also verified before deleting
the now-unused emaConfigApiURL that DOTCMS_EMA_CONFIG_API resolves to the same URL
configureUVE builds, so the remote path still posts to the same endpoint.

Tests written and confirmed failing before implementation, per constitution
Principle V; both spec sets were satisfiability-checked against throwaway stubs so
the Red state proved missing code rather than a broken spec. 69 tests, 5 suites.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry (#37262)

US3. Three defects that each turn a recoverable situation into a dead end.

Port conflict (AC-006, reproduction step 6). checkPortsAvailability() hard-failed
on 8082/8443/9200/9600 - exactly the ports a SUCCESSFUL previous run now holds -
so re-running the CLI aborted with "Required ports are already in use". The CLI's
own success blocked its own retry. It now probes before failing: a busy 8082 with
a healthy dotCMS behind it is an instance to reuse, not a conflict.

Per decision D3, "reuse" is never silent and never assumed:
  - non-interactive (CI or no TTY): auto-reuse, because a prompt would hang a
    scripted run - but PRINT a notice. Silent means no prompt, not no output; a
    script that quietly attaches to an unknown instance is the failure this is
    meant to prevent.
  - interactive: a real choice of reuse or abort, so someone who did not expect a
    dotCMS on 8082 can stop and look instead of being carried into it.
  - reuse requires BOTH readiness and token issuance to succeed. A busy port with
    something else behind it, or a half-dead dotCMS, is still a hard failure.
  - anything other than 8082 alone being held is somebody else's stack, not ours
    to adopt.

Unreachable failure branch (AC-007, contract X7). installDependenciesForProject()
returns a Result, and Err(val) is {ok:false, val} - a truthy object - so the
caller's `if (!result)` was never true and a failed npm install was reported as
success. Now branches on result.ok via reportInstallResult(), which also surfaces
the underlying reason instead of the generic message. The spec pins the trap
itself with a test asserting Err() is truthy, so nobody reintroduces `!result`.

Stranded compose file (AC-008). docker-compose.yml is moved one level up before
scaffolding because git needs an empty directory, and moved back afterwards. A
scaffolding failure in between skipped the move-back and left the file in the
PARENT directory - so the user could not `docker compose down` the stack that was
still running. Now wrapped in withComposeFileMovedAside(), a try/finally that
restores the file on both paths while letting the original error propagate; a
finally that swallowed the cause would trade one silent failure for another.

Also: prepareDirectory() offered to empty a non-empty target directory, which
would delete a docker-compose.yml left by a previous run - again destroying the
only means of tearing down containers that may still be running. It now detects
that file, says so explicitly in the prompt, and preserves it across the clear
rather than folding it into a blanket "all files will be deleted".

Note this makes a previously-dead branch live: runs with a broken npm that
silently "succeeded" will now correctly fail. Intended, and recorded in the
spec's Regression Risk.

Tests written and confirmed failing before implementation, per constitution
Principle V. 89 tests, 8 suites; build and lint clean.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ful (#37262)

US4. This addresses the symptom the issue was actually reported for: a spinner
that sits motionless for minutes while the user has no idea whether anything is
happening.

Retry progress is reported, not printed. fetchWithRetry called console.log
between attempts while an ora spinner was running. A spinner owns and repaints
the last terminal line, so concurrent writes tear it - that is the mangled retry
block in the original report. It now takes an optional reporter and stays silent
without one; the CLI supplies a reporter that routes retries through the spinner
it owns. formatRetryReport deliberately returns a SINGLE line, because a
multi-line report tears across the repaint.

The whole wait is now covered. `docker compose up --wait` writes its
Waiting/Healthy transitions and pull progress to stderr, which execa swallowed;
both streams are now absorbed. On top of that a ~2s ticker shows elapsed time,
because compose itself goes quiet for minutes while a single layer downloads or
the starter imports - and during that silence, elapsed time moving is the only
thing distinguishing "still working" from "hung".

A --wait timeout now names itself: the failure appends per-service container
state, so the user is not left to go digging for why it gave up.

Readiness now asks the right question. The bundled compose file publishes 8090,
so /dotmgt/readyz - the purpose-built probe, which does not depend on the web app
being up - is available and preferred, with /api/v1/appconfiguration kept as the
fallback for images that do not serve the management endpoints.

This matters more than the "correctness tidy-up" the spec called it. Measured
against a real stack on 2026-08-31: `up --wait` reporting HEALTHY means the
instance is LIVE, not READY - the container healthcheck probes livez - and readyz
returned 503 for a few seconds after --wait had already returned. Probing only
the app endpoint let the CLI start making API calls inside that window.

A 503 from readyz is therefore the ordinary "still starting" state, not an error,
and it does NOT fall back to the app endpoint: readyz answered authoritatively,
and appconfiguration can return 200 while the stack is still coming up, so a
second opinion there would report a booting instance as ready - reintroducing the
bug in a new place.

Also closes a status mismatch: fetchWithRetry accepted any 2xx while
isDotcmsRunning demanded exactly 200, so a 204 was success to one and failure to
the other and a healthy instance could be reported unreachable. One rule now,
isSuccessStatus, used by both.

Tests written and confirmed failing before implementation, per constitution
Principle V; the readiness spec was additionally proven satisfiable against a
throwaway stub at 100% branch coverage. 125 tests, 10 suites; build and lint
clean, and docker/docker-compose-examples/ remains untouched (AC-010).

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…7262)

Both were left with zero callers by the changes in this branch, and dead code in
a diff is a review cost with no upside.

downloadFile() is gone. Its only caller was the runtime fetch of the shared
compose example, which now reads the bundled asset through ComposeSource. Task
T051 asked for a timeout on it, but adding one to unreachable code would have
been worse than useless - the reachable remote path is ComposeSource's own
fetch(), which already has redirect handling and an AbortController timeout,
i.e. exactly what downloadFile lacked. The `https` import went with it.

DotCMSApi.setupUVEConfig() is gone. configureUVE() replaced both call sites in
US2. Nothing outside its own module referenced it, and this package publishes a
`bin`, not a library, so there is no consumer API to break. Its endpoint
construction (`(url || defaultUveConfigApi) + siteId`) was checked against
configureUVE's before deleting - they resolve to the same URL. The UVEConfig
request/response types and FailedToSetUpUVEConfig went with it.

Also ran the static half of scripts/verify-cold-start.sh against the bundled
asset now that both exist: 9 passed, 0 failed, in 0.3s with no Docker daemon.
That is cheap enough to gate every PR, which was the argument for keeping it
alongside the Jest guard rather than choosing between them.

125 tests, 10 suites; build and lint clean.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…exposed (#37262)

T052 ran the full verify-cold-start.sh against the shipped assets/docker-compose.yml
for the first time - every earlier proof used a scratchpad candidate on shifted
ports. It found two more problems in this script, both mine, and both introduced
or left behind by the earlier T005 fix.

Ordering. T007 probed the management port about 3 seconds after T006 deliberately
kills the JVM, and dotCMS takes ~46s to boot. It reported "/dotmgt/livez
unreachable - the management port is not published", accusing a correct compose
file of a defect it does not have - the same false-negative shape as the
`docker kill` assertion fixed earlier in this same file. Isolated by measurement
rather than assumed: against a healthy, never-killed stack the identical probe
returns 200 on loopback and 000 on the LAN address. T007 now waits for dotcms to
return to healthy first, and that wait lives inside T007 rather than at the end of
T006, so the check does not depend on what ran before it.

A silently skipped security assertion - the worse of the two. The vacuity guard
and the LAN probe still used /dotmgt/readyz while the positive check had been
moved to /dotmgt/livez. readyz lags livez by a few seconds after a restart, so
straight after T006 the guard saw a 503, concluded 8090 was unpublished, and
skipped the LAN check - while the suite printed "16 passed, 0 failed". The one
assertion behind AC-011 (unauthenticated /dotmgt/health and /dotmgt/metrics must
not be reachable off-host) was not running, and nothing in the summary said so.
The guard now reuses the result of the probe above instead of re-issuing one
against a different endpoint.

And the general fix behind it: a security assertion that cannot run now FAILS
rather than passing quietly. If no LAN address can be found, T007 reports AC-011
as UNVERIFIED instead of skipping with an info line, and LAN_ADDR can be set to
check it explicitly. A green summary that omits this check is worse than a red
one, because nobody goes looking.

Verified after the fixes: 17 passed, 0 failed, exit 0 - 17, not the previous 16,
the extra being the LAN assertion that had been skipped. Confirmed against the
real shipped asset: dependencies healthy before dotcms starts, dotcms healthy
unaided, `up --wait` exit 0 in 60s, self-exit restarted by the policy
(RestartCount 0->1 in 3s), livez 200 on loopback, and 8090 refused on
192.168.4.34.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37262)

Found by running the CLI end to end (T054), which turned out to be automatable
after all - every prompt has a flag, and the no-TTY path is one the port-reuse
logic already handles by design, so `node index.js my-app -d <dir> -f nextjs
--local` runs the whole thing unattended.

The .env written for AC-004 used variable names I invented rather than the ones
the project reads. It emitted DOTCMS_AUTH_TOKEN where Next.js reads
NEXT_PUBLIC_DOTCMS_AUTH_TOKEN, and omitted NEXT_PUBLIC_DOTCMS_MODE entirely. The
file looked entirely plausible and `npm run dev` would have failed to
authenticate with nothing explaining why - the same failure shape as the bug this
issue is about, a green signal that does not mean what it claims.

The unit test did not catch it because it asserted the file "contains the host,
token and siteId", which a wrong-named file satisfies perfectly.

Two more cases were wrong for the same reason: Astro reads PUBLIC_* with a
different variable set, and Angular has no dotenv file at all - it reads a
TypeScript `environment` object, so writing .env there was cargo-culting. The
`framework` field was already on RecoverableState and simply never used.

Root cause was duplication: getEnvVariablesForNextJS had defined the real names a
few hundred lines away in the same package. There is now a single owner,
getEnvFileSpec(), used by BOTH the printed block and the written file, so they
cannot drift apart again. It returns filename: null for frameworks with no dotenv
file, which is what makes the Angular case correct rather than merely skipped.

The spec now pins all three shapes, including a negative assertion that the bare
DOTCMS_AUTH_TOKEN name never appears on its own.

Also reworded the final steps, which told the user to `touch .env` moments before
the CLI wrote that same file - instructions contradicting an action the tool was
about to take.

Verified by re-running the same end-to-end path: exit 0 in ~100s, project
scaffolded, all four NEXT_PUBLIC_* variables present and correct, compose file
back in the project directory with no orphan in the parent, and UVE configured
with no 403 - which is the causal chain behaving as diagnosed, since a clean boot
is exactly the case that should not 403.

128 tests, 10 suites; build and lint clean.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37262)

Two defects in the wiring, both found by running the CLI against a real running
instance (T054 step 5b) and neither reachable by the unit tests.

Reuse could never trigger. resolvePortConflict required 8082 to be the ONLY busy
port, but a running stack publishes 8082 AND 8443, so the condition could not
occur in practice and reproduction step 6 stayed broken. Twelve tests passed
because the fixture used a single busy port - a shape no real instance produces.
The fixtures are now derived from what the bundled asset actually publishes and
named A_REAL_RUNNING_STACK, since the abstraction was the bug.

Reuse was then ignored anyway. The decision was computed and used only to choose a
spinner message; the CLI went on to write a compose file and run `up` regardless,
which failed with "Bind for 0.0.0.0:8082 failed: port is already allocated". Reuse
that still provisions is not reuse. Provisioning is now skipped when reusing,
which is the whole point of the decision.

REQUIRED_PORTS still listed 9200 and 9600, inherited from the shared compose
example the CLI no longer downloads. The bundled asset publishes neither -
OpenSearch has no ports section - so the CLI refused to run for anyone with their
own OpenSearch on 9200, over a conflict that cannot happen. It now checks 8082,
8443 and 8090, and a spec parses the asset and asserts the two lists agree so they
cannot drift again.

Verified end to end against a real instance: the CLI reuses a running stack,
skips provisioning, and completes (exit 0, scaffolded, .env written). A separate
run confirmed the opposite branch too - an instance that is up but cannot issue a
token is correctly refused rather than adopted, with "Something is listening on
8082, but it did not answer as a usable dotCMS".

Both 403 messages are now verified against real code rather than mocks, using a
stub server whose UVE endpoint always 403s:

  local  - exit 0, scaffolded, .env written; says unrecoverable, gives
           `docker compose down -v`, cites #37268, WITHHOLDS the manual guide.
  remote - exit 0, scaffolded, .env written; names the permission problem, the
           site and the app key, OFFERS the guide, and leaks neither `down -v`
           nor #37268.

In both cases the UVE endpoint was hit exactly ONCE - no POST after a 403, no
retry, no poll (AC-005).

132 tests, 10 suites; build and lint clean.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prompt said:

  ? A dotCMS instance is already running on port 8082. What would you like to do?
    > Reuse the running instance
      Abort so I can check what it is

That states a fact and then abandons the user. It never says WHAT is running, so
there is no basis for choosing - "is that my last run, or the instance I am using
for other work?" - and the only two answers are take it or quit. Anyone who did
not want that instance had to leave the CLI and work out the docker incantation
themselves.

It now says what it found and offers a way through:

  ⚠  Found a dotCMS already running at http://localhost:8082
     Docker project "my-app" · Up 8 minutes (healthy)

  ? How would you like to continue?
  > Use this instance for my project
      Fastest. Keeps its existing content.
    Replace it with a clean instance
      Stops it and DELETES its data, then starts fresh.
    Cancel
      Change nothing and exit.

describePortOwner() reads the compose labels off whatever publishes the port, so
the project name, health and uptime are shown before the question is asked.

Replace runs `docker compose -p <project> down -v` and then provisions fresh. The
-v is the point: keeping the volumes keeps the corruption, so a bricked instance
would come back just as broken (#37268). This turns the documented recovery from
something the CLI tells you to go and do into something it can do.

Two safety properties:

* Replace is only offered when a compose project owns the port. A container
  started outside compose has no project label and is not ours to destroy, so the
  option is withheld rather than offered and then failed.
* Replace is NEVER selected non-interactively. Destroying an instance is not
  something to infer from the absence of a TTY; a scripted run still auto-reuses
  with a printed notice, per decision D3.

Verified against a real running stack: owner detection reports
`Docker project "my-app" · Up 8 minutes (healthy)`, and a non-interactive run
still prints its notice, reuses, and completes with .env written and exit 0.

136 tests, 10 suites; build and lint clean.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prompt printed its question and nothing under it:

  ? How would you like to continue?

It used `type: 'list'`. This package is on inquirer 13, which is built on
@inquirer/prompts, where the single-choice prompt is `select` - `list` is the
inquirer 8/9 name. An unregistered type renders the MESSAGE and silently renders
no choices: nothing throws, nothing warns, and the user is left looking at a
question with no answers.

It was the only `type: 'list'` in the package. Every other prompt in asks.ts -
askCloudOrLocalInstance, askFramework, prepareDirectory - already used `select`,
`input`, `password` or `confirm`, and was visibly working in the same terminal
session. Comparing against the neighbouring prompt that worked would have found
this immediately; instead it survived a round of fixes aimed at embedded newlines
and nested chalk in the choice labels, which were real smells but not the cause.

Proven by holding everything else constant and changing only the type:

  type=list    choice text rendered: 0
  type=select  choice text rendered: 2

A spec now guards it, and was confirmed to fail when the bug is reintroduced. It
asserts asks.ts never uses type: 'list', and that every prompt type used is one
this inquirer version actually registers. This is worth a test because the
failure is silent and looks like a styling problem rather than a wrong prompt
type, so it costs far more to diagnose than to prevent.

The choice labels are also cleaned up as part of this: single-line names with the
hint moved to `description`, which @inquirer/select 5.2.1 supports and renders
under the highlighted option, instead of a `\n` inside the name and a nested
chalk.gray inside a label inquirer re-styles when highlighting.

Confirmed working in a real terminal by the reporter.

138 tests, 11 suites; build and lint clean.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A successful run ended by printing the API token twice - once in a "copy this
block and paste it into your .env" section, and again in the recovery block -
while having already written that exact file itself. So it duplicated a JWT into
scrollback and told the user to redo work that was done.

Now the CLI owns the file and says so:

  Wrote .env with your dotCMS connection details.
    host    : http://localhost:8082
    site id : 48190c8c-42c4-46af-8d1a-0cd5db894797
    token   : stored in .env

The token is no longer echoed when it was successfully written. It is safe on
disk, and a JWT in terminal scrollback and CI logs buys nothing. The "create your
environment file" and "add your dotCMS configuration" steps are gone from the
Next.js and Astro flows, and the remaining steps renumber to 1-4: cd, npm run dev,
open the browser, edit the page.

The guarantee behind contract X1 is unchanged - no successful state is ever
discarded - but it is now satisfied by the file rather than by the terminal. When
nothing could be written the full block still prints, including the token, because
then the terminal IS the only place the run survives. Three cases take that path:
the framework has no dotenv file, a .env already exists and is left untouched, or
the write failed.

Angular deliberately keeps its paste block. It has no .env - it reads a TypeScript
environment object - so getEnvFileSpec writes nothing for it and the user really
does have to paste into the environment files. The exit handler prints the full
block for Angular for the same reason.

Also dropped `siteId` and `token` from finalStepsForNextjs and finalStepsForAstro,
which no longer render them.

140 tests, 11 suites; build and lint clean.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A successful run ended with the summary, and then a second block after it:

    📋 Next Steps:
    ...
    💬 Community: https://community.dotcms.com

    Wrote .env with your dotCMS connection details.
      host    : ...

That was structural, not cosmetic. The exit handler runs on process 'exit', so
anything it prints necessarily lands AFTER everything else - it could only ever
append to the summary that was supposed to contain it.

flushRecoverableState() lets the success path do the env write and claim the
reporting, so the details render inside the Next Steps block, above step 1:

    📋 Next Steps:

       ✔ Your dotCMS credentials are already in .env
         host    : http://localhost:8082
         site id : 48190c8c-42c4-46af-8d1a-0cd5db894797

    1. Navigate to your project:
    ...

The exit handler then stays silent, because the state has already been surfaced.
It remains the fallback for every path that never reaches the summary - the 14
process.exit sites, an unexpected throw - which is the whole point of contract X1
and is unchanged. A spec pins both halves: silent after a flush, still speaking
without one.

The same renderer covers the case where no file was written (Angular has no
dotenv file, or a .env already exists, or the write failed). It then prints the
values to paste, in the same place, rather than in a separate trailing block.

144 tests, 11 suites; build and lint clean. Placement verified in the shipped
bundle: the summary renders between the "Next Steps" heading and step 1.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Closes the gap @nicobytes raised on #37263: AC-013 is the one criterion the spec
itself calls "the most likely way to break the release", and nothing actually
enforced it.

What existed asserted the wrong thing. packaging.spec.ts reads package.json and
project.json from the SOURCE TREE, and verify-cold-start.sh --static resolves the
asset relative to src/. Both pass identically whether or not the file ever ships.
A wrong `output:` in the esbuild assets entry satisfies every one of them and
still publishes a package with no compose file - and then every local-Docker run
fails at its first step, which is precisely what AC-013 exists to prevent. The
packaging had in fact been verified once, by hand, with npm pack --dry-run; there
was no gate.

scripts/verify-package.sh asserts the artifact instead:

  * the compose asset is in dist/libs/sdk/create-app at the path the CLI resolves
    at runtime (resolveComposeSource walks up from the bundle entry, so it must
    sit beside index.js exactly as it does in the source tree), and
  * npm pack --dry-run lists it in the tarball contents.

Those are two independent failures and both are checked, because either alone
ships a broken package: esbuild copies the file but `files` omits it, or `files`
is right and the copy never happened.

Confirmed to FAIL on both modes rather than assumed: removing dist/assets fails
both checks; restoring the asset and reverting `files` to its pre-fix
["*.js", "README.md"] passes the first and fails the second, naming package.json.

Wired as the nx target `verify-package` with dependsOn build, deliberately NOT
folded into `test` - tests must stay fast and must not require a build.

Also tags T052 with AC-011. That criterion (8090 answers on loopback, refused on
the LAN address) was already enforced by verify-cold-start.sh's T007 check but
referenced by no task, so the only security criterion in the list read as
uncovered. Acceptance-criteria coverage is now 13/13.

144 tests, 11 suites; build, lint and both verification scripts clean.

Refs #37262, #37263

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither script ran in CI. The Maven build drives nx with exactly four
invocations - `nx affected -t lint`, `nx format:check`, `nx run-many -t build`,
`nx affected -t test` - so `verify-package` was never reached, and
verify-cold-start.sh was not an nx target at all. Both were manual, which for
AC-013 in particular means the criterion the spec calls "the most likely way to
break the release" had a gate nobody ran.

Both now hang off this project's `test` target, so `nx affected -t test` picks
them up. That keeps the change inside libs/sdk/create-app/project.json rather
than editing core-web/pom.xml, which every project in the monorepo shares.

New target `verify-compose-static` runs verify-cold-start.sh --static: the
config-only half, no Docker, ~0.3s. It is what catches a reformatted
CUSTOM_STARTER_URL line silently breaking --starter for every installed CLI. The
runtime half (cold start, restart recovery, LAN exposure) needs a real daemon and
a multi-minute starter import and stays manual.

Both targets get `cache: true` with narrow inputs - the compose asset, the
manifests and the scripts themselves - so they do not re-run on unrelated edits.

Measured: 4s cold, 1s warm, against 144 Jest tests. This reverses the "keep tests
fast and buildless" call made when verify-package was added; that rule earns its
keep on a large Angular library, not on a CLI whose build is 1.3s.

Verified the chain actually fails rather than assumed: reverting `files` in
package.json to its pre-fix ["*.js", "README.md"] fails the packaging gate and
aborts the run before Jest executes.

Refs #37262, #37263

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
semgrep-dotcms flagged axios on this PR with several High findings, all the same
root cause: axios's Node adapter does not clear `Proxy-Authorization` when a
request that went through an authenticated proxy is redirected to a target that
does not use that proxy, leaking the proxy credentials to the redirect origin.

Bumping to axios 1.20.0 would close those CVEs. Removing axios closes the class,
and the case for removing it here is unusually strong:

* create-app was the ONLY consumer of axios in the workspace - no other lib or app
  imports it.
* It sat in esbuild's `external` list, so it was a real runtime install for
  everyone running `npx @dotcms/create-app`, not just a build-time dependency.
* compose-source.ts in this same package already used native fetch, so the
  inconsistency was ours.
* Node >= 22.22.3 is required here (.nvmrc), where fetch is stable.

New `src/utils/http.ts` is the single owner of HTTP: timeout via AbortController
(fetch has none of its own, so a dead instance used to hang the CLI), best-effort
JSON parsing that tolerates a 204 or a plain-text body, and an HttpError that
keeps axios's `error.response.status` shape so configureUVE's statusOf() and the
retry classifier keep working unchanged. Throw-on-non-2xx is the default because
that is what every call site expected; `acceptAnyStatus` covers the readiness
probe, where a 503 is data rather than a failure.

Worth noting the fetch spec requires stripping `Authorization` on a cross-origin
redirect - the protection axios's Node adapter was missing.

Migrated: api/index.ts, uve/configure-uve.ts, utils/index.ts (fetchWithRetry),
utils/fetch-retry.ts and index.ts. axios is gone from the published dependencies
and from the esbuild external list; the shipped bundle contains zero references.
The pnpm lockfile is unaffected - libs/sdk/create-app is not a separate importer,
so its package.json is the published manifest rather than an install manifest.

configure-uve.spec mocks the http module rather than fetch, so its cases stay
about the contract (probe once, retry 5xx only, mode-dependent guidance) while
the new http.spec covers the transport - 15 cases including timeout, transport
failure, non-JSON bodies and the 2xx boundary.

Verified over real HTTP end to end, not just against mocks: a run against a stub
whose UVE endpoint 403s makes all four calls through native fetch (health, token
POST, site GET, UVE probe), exits 0, scaffolds, writes .env, and prints the
remote-mode 403 guidance without leaking `docker compose down -v`.

159 tests, 12 suites; build, lint and both verification gates clean.

Refs #37262, #37264

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37262)

The README was updated when the compose file became bundled, then drifted: three
later changes invalidated parts of it and it was never revisited. Two of those
were outright wrong rather than merely stale.

Wrong:

* "Validates required ports: 8082, 8443, 9200, 9600". The CLI checks 8082, 8443
  and 8090. The 9200/9600 pair was inherited from the shared compose example the
  CLI no longer downloads.
* The published-ports table listed "9200, 9600 — all interfaces" for OpenSearch.
  The bundled stack publishes NOTHING for Postgres or OpenSearch; they are only
  reachable inside the compose network. Readers were being told to expect a port
  conflict that cannot happen, and the table now says so explicitly, since
  "can I run my own OpenSearch alongside this?" is the obvious question.

Stale:

* Both flows ended with "prints framework-specific env setup instructions". The
  CLI writes .env itself now.
* Nothing described the port-conflict prompt, which is a destructive choice
  (Replace removes volumes) and needs documenting before someone meets it.
* Local flow claimed a generic "waits for local health check"; it waits on
  /dotmgt/readyz with /api/v1/appconfiguration as fallback.
* UVE configuration was presented as a required step in both flows. It is
  optional - failure warns and continues, which is the entire point of the fix.
* Troubleshooting told the reader to go stop the process on 8082 by hand. The CLI
  now offers to reuse or replace it.
* Requirements said "Node.js + npm"; native fetch means Node 22.22.3+ is required
  and the .nvmrc already pins it.
* Dev commands used yarn; this workspace is pnpm.

Added: "If dotCMS is already running" (the prompt, what Replace destroys, why it
is withheld when no Compose project owns the port, and the non-interactive
behaviour) and "Your .env" (per-framework variable names, that an existing file
is never overwritten, and that Angular has none). Also documents
`pnpm nx verify-package sdk-create-app`.

Every remaining factual claim was cross-checked against the source rather than
re-read: port list, readiness endpoints, and the DOTCMS_COMPOSE_URL escape hatch.

Refs #37262

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fmontes
fmontes force-pushed the issue-37262-create-app-docker-uve branch from 2e44af0 to e715a38 Compare September 1, 2026 19:11
`nx format:check` failed CI on five files this branch touched: project.json,
src/api/index.ts, src/asks.spec.ts, src/exit-state.spec.ts and
src/utils/ports.ts. Formatting only — no behaviour change. 159 tests, lint and
both verification gates still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fmontes
fmontes marked this pull request as ready for review September 1, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

create-app: local Docker run never starts dotCMS, then a transient UVE 403 aborts the CLI and discards the project

1 participant