fix(create-app): design contracts for local Docker start failure and transient UVE 403 (#37262) - #37264
fix(create-app): design contracts for local Docker start failure and transient UVE 403 (#37262)#37264fmontes wants to merge 24 commits into
Conversation
|
Claude finished @fmontes's task in 5m 16s —— View job Code Review — PR #37264Reviewed the full diff. Findings below. 🆕 New Issues🟠 The whole point of this helper (and AC-008) is the
🟡 Moving the compose file aside can clobber a pre-existing
🟡
🟡 Stale port list in troubleshooting output —
📋 Existing IssuesNone flagged — review scope is this PR's diff. ✅ ResolvedBoth |
244a69d to
38e8ae1
Compare
4877b92 to
c3243d6
Compare
|
|
||
| for (let attempt = 1; attempt <= Math.max(1, maxRetries); attempt++) { | ||
| try { | ||
| await axios.post(url, payload, authHeaders(token)); |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
| 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 | ||
| }); |
There was a problem hiding this comment.
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 }), |
There was a problem hiding this comment.
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.
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>
2e44af0 to
e715a38
Compare
`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>
PR 2 of 2 for #37262, stacked on #37263 (the spec).
The problem
npx @dotcms/create-app --localnever 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:#000The fix
One compose file, owned by the CLI. It ships inside the package instead of being fetched from
main, sodotcmscan be gated ondbandopensearchreporting healthy, givenrestart: unless-stopped, and health-checked on/dotmgt/livez. The sharedsingle-node-demo-siteexample 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 callsprocess.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
.envand reports the connection details on every terminal path, including the 14process.exitsites afinallycannot reach.Recovery is unblocked. A dotCMS already on 8082 can be reused or replaced from the prompt instead of aborting; a failed
npm installnow 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) andverify-package(the asset is indist/and innpm packoutput — 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.
Checklist
verify-cold-start.sh; the full fault-injection E2E suite belongs to e2e: Add E2E test suite for@dotcms/create-appCLI #35096127.0.0.1rather than the wildcard, and a test asserts it is refused on the LAN addressAdditional 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