diff --git a/.changeset/customer-local-journey.md b/.changeset/customer-local-journey.md new file mode 100644 index 000000000..4990260d8 --- /dev/null +++ b/.changeset/customer-local-journey.md @@ -0,0 +1,4 @@ +--- +--- + +Exercise the customer local setup journey in the consumer e2e gate. diff --git a/.changeset/local-runtime-fresh-setup.md b/.changeset/local-runtime-fresh-setup.md new file mode 100644 index 000000000..e2ef90528 --- /dev/null +++ b/.changeset/local-runtime-fresh-setup.md @@ -0,0 +1,5 @@ +--- +"@zitadel/cli": patch +--- + +Allow same-directory setup after starting the local Zitadel runtime. diff --git a/.changeset/pr-metadata-guidance.md b/.changeset/pr-metadata-guidance.md new file mode 100644 index 000000000..e9cac8d2b --- /dev/null +++ b/.changeset/pr-metadata-guidance.md @@ -0,0 +1,4 @@ +--- +--- + +Document PR title and description expectations for agents. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..25b957461 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## Summary + + + +- + +## Validation + + + +- + +## Release notes / changeset + + + +- + +## Notes + + + +- diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cecff8f22..9fc8dd2c3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,9 +27,9 @@ more than polish. `.changeset/.md` file directly rather than via the interactive prompt. See `AGENTS.md` for the package list and file format. npm package manifests must keep `"license": "MIT"`. -- PR titles must pass Semantic PR. Use `(optional-scope): ` and - verify allowed types/scopes against `.github/semantic.yml`; for docs-only - changes, use a title such as `docs: add preview status disclaimer`. +- PR metadata must follow `AGENTS.md`: verify the title against + `.github/semantic.yml`, prefer a scope-free title when unsure, and keep the + PR description current with summary, validation, changeset, and notes. - Server and embedded console changes are AGPL-3.0-only by default; public API, docs, CLI, and SDK paths are MIT exceptions per `LICENSING.md`. - For `consumer-journey-e2e` or `apps/cli-journey-e2e/**` changes, verify that diff --git a/.github/instructions/cli.instructions.md b/.github/instructions/cli.instructions.md index 0dd498aab..0208d4986 100644 --- a/.github/instructions/cli.instructions.md +++ b/.github/instructions/cli.instructions.md @@ -26,5 +26,8 @@ The CLI is an agent-facing product surface. Review changes against - Changes to command names, flags, envelope shape, server resolution, renderer direction, or agent guidance should update tests, `apps/cli/SKILLS.md`, and the generated README command section. +- PR descriptions for CLI changes should call out user-visible command behavior, + updated agent/docs surfaces, and the focused validation commands that were + actually run. - Mock behavior should be explicit and must not be presented as live platform behavior. diff --git a/.github/instructions/consumer-journey.instructions.md b/.github/instructions/consumer-journey.instructions.md index 0fec2efdc..352fdafd7 100644 --- a/.github/instructions/consumer-journey.instructions.md +++ b/.github/instructions/consumer-journey.instructions.md @@ -4,11 +4,14 @@ applyTo: "apps/cli-journey-e2e/**,.github/workflows/ci.yml" # Consumer Journey Review Instructions -Review consumer journey changes as a required fresh-app quality gate, not as a -demo-app e2e suite. +Review consumer journey changes as the required customer local setup quality +gate, not as a demo-app e2e suite. - CI must consume the current workflow's GoReleaser image and npm package tarballs. Do not replace this with public npm packages for Zitadel packages. +- The journey must exercise the customer local runtime flow through `npx`: + `doctor`, `start`, then `setup --framework next --server local` with + `--non-interactive --json`. - Produce package artifacts with `corepack pnpm --dir pack` and keep tarball verification for required package presence plus unresolved `catalog:` or `workspace:` dependency specs. @@ -23,6 +26,7 @@ demo-app e2e suite. - Browser tests should run serially with one worker, use `localhost` for WebAuthn, and require passkey coverage in CI. `JOURNEY_ENABLE_PASSKEY=0` is only a local debugging escape hatch. -- Failure artifacts should include Playwright output/report, setup JSON, - setup stderr, metadata, generated app package manifests, Verdaccio logs, Next - logs, and backend logs. Do not upload generated `node_modules` or `.next`. +- Failure artifacts should include Playwright output/report, doctor/start/setup + JSON and stderr, local runtime metadata/logs, metadata, generated app package + manifests, Verdaccio logs, and Next logs. Do not upload generated + `node_modules` or `.next`. diff --git a/.github/instructions/typescript.instructions.md b/.github/instructions/typescript.instructions.md index 32e5680cc..0c4a9c00c 100644 --- a/.github/instructions/typescript.instructions.md +++ b/.github/instructions/typescript.instructions.md @@ -19,6 +19,9 @@ Vitest. - User-visible changes to those packages need a changeset; write the `.changeset/.md` file directly rather than via the interactive prompt. See `AGENTS.md` for the package list and file format. +- PR descriptions for public package changes should mention the changeset + status and list the focused package validation commands that were actually + run. - Avoid committing generated `dist/**` churn unless the release or package smoke check explicitly requires it. - Respect peer dependencies in `packages/sdk-next`; do not bundle React, Next, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59a0275eb..c2a9b0470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,75 +305,6 @@ jobs: docker load -i goreleaser-dist/nextgen-image.tar docker image inspect -f '{{.Os}}/{{.Architecture}}' "$(cat goreleaser-dist/nextgen-image.tag)" | grep -q '^linux/amd64$' - - name: Smoke test CLI-style local runtime image - run: | - set -euo pipefail - IMAGE="$(cat goreleaser-dist/nextgen-image.tag)" - DATA_DIR="$RUNNER_TEMP/nextgen-dev-data" - RUNTIME_DIR="$RUNNER_TEMP/nextgen-dev-runtime" - HOST_UID="$(id -u)" - HOST_GID="$(id -g)" - PASSWD_FILE="$RUNTIME_DIR/container-passwd" - GROUP_FILE="$RUNTIME_DIR/container-group" - mkdir -p "$DATA_DIR" "$RUNTIME_DIR" - # Mirror `zitadel start`: run the container as the host user so - # bind-mounted state stays editable, and provide passwd/group entries - # because embedded Postgres initdb refuses unknown numeric users. - { - echo "root:x:0:0:root:/root:/bin/sh" - echo "nonroot:x:65532:65532:nonroot:/nonexistent:/usr/sbin/nologin" - echo "zitadel-local:x:${HOST_UID}:${HOST_GID}:Zitadel local user:/tmp:/usr/sbin/nologin" - } > "$PASSWD_FILE" - { - echo "root:x:0:" - echo "nonroot:x:65532:" - echo "zitadel-local:x:${HOST_GID}:" - } > "$GROUP_FILE" - CONTAINER="$( - docker run -d \ - -p 127.0.0.1:18080:8080 \ - -v "$DATA_DIR:/var/lib/zitadel/nextgen-data" \ - -v "$PASSWD_FILE:/etc/passwd:ro" \ - -v "$GROUP_FILE:/etc/group:ro" \ - --user "${HOST_UID}:${HOST_GID}" \ - -e NEXTGEN_SERVER_ADDRESS=:8080 \ - -e NEXTGEN_SERVER_DATA_DIR=/var/lib/zitadel/nextgen-data \ - "$IMAGE" - )" - cleanup() { - docker rm -f "$CONTAINER" >/dev/null 2>&1 || true - } - diagnose() { - docker logs "$CONTAINER" || true - } - trap cleanup EXIT - - ready=0 - for _ in $(seq 1 120); do - if curl -fsS http://localhost:18080/healthz >/dev/null; then - ready=1 - break - fi - sleep 1 - done - if [ "$ready" -ne 1 ]; then - echo "::error::timed out waiting for zero-config /healthz" - diagnose - exit 1 - fi - test -f "$DATA_DIR/server-encryption-key" - if ! curl -fsS -o /dev/null -w "%{http_code}\n" http://localhost:18080/ui/console/ | grep -q '^200$'; then - echo "::error::zero-config /ui/console/ did not return 200" - diagnose - exit 1 - fi - if ! curl -fsS -o /dev/null -w "%{http_code}\n" http://localhost:18080/ui/login/ | grep -q '^200$'; then - echo "::error::zero-config /ui/login/ did not return 200" - diagnose - exit 1 - fi - echo "CLI-style local runtime image smoke test passed" - - name: Prepare quick-start compose stack run: | mkdir -p nextgen_quickstart @@ -400,8 +331,9 @@ jobs: - name: Start stack and verify HTTP endpoints run: | set -euo pipefail - # Separate from the CLI-style smoke above: this keeps the documented - # compose fallback valid for operators/manual quick-start usage. + # Separate from the customer local setup journey: this keeps the + # documented compose fallback valid for operators/manual quick-start + # usage. COMPOSE=(docker compose -f nextgen_quickstart/docker-compose.yaml --env-file nextgen_quickstart/.env) "${COMPOSE[@]}" up -d --wait ready=0 @@ -682,32 +614,11 @@ jobs: test -f goreleaser-dist/nextgen-image.tag docker load -i goreleaser-dist/nextgen-image.tar docker image inspect -f '{{.Os}}/{{.Architecture}}' "$(cat goreleaser-dist/nextgen-image.tag)" | grep -q '^linux/amd64$' - - - name: Start backend stack - run: | - mkdir -p "$RUNNER_TEMP/nextgen-journey" - cp docs/operations/docker-compose.yaml "$RUNNER_TEMP/nextgen-journey/docker-compose.yaml" - IMAGE="$(cat goreleaser-dist/nextgen-image.tag)" - { - echo "NEXTGEN_IMAGE=${IMAGE}" - echo "NEXTGEN_PORT=8080" - } > "$RUNNER_TEMP/nextgen-journey/.env" - docker compose -f "$RUNNER_TEMP/nextgen-journey/docker-compose.yaml" --env-file "$RUNNER_TEMP/nextgen-journey/.env" up -d --wait - for _ in $(seq 1 90); do - if curl -fsS http://localhost:8080/healthz >/dev/null; then - exit 0 - fi - sleep 1 - done - echo "::error::timed out waiting for backend /healthz" - docker compose -f "$RUNNER_TEMP/nextgen-journey/docker-compose.yaml" --env-file "$RUNNER_TEMP/nextgen-journey/.env" logs nextgen - exit 1 + echo "ZITADEL_LOCAL_IMAGE=$(cat goreleaser-dist/nextgen-image.tag)" >> "$GITHUB_ENV" - name: Prepare fresh Next.js app id: prepare-app env: - JOURNEY_BACKEND_URL: http://localhost:8080 - JOURNEY_CREATE_NEXT_APP_VERSION: 16.2.4 JOURNEY_REGISTRY_URL: http://127.0.0.1:4873 JOURNEY_WORK_DIR: ${{ runner.temp }}/zitadel-cli-journey run: node apps/cli-journey-e2e/scripts/prepare-next-app.mjs @@ -735,17 +646,32 @@ jobs: - name: Collect diagnostics if: failure() run: | - docker compose -f "$RUNNER_TEMP/nextgen-journey/docker-compose.yaml" --env-file "$RUNNER_TEMP/nextgen-journey/.env" logs > "$RUNNER_TEMP/backend-compose.log" 2>&1 || true DIAG_DIR="$RUNNER_TEMP/consumer-journey-diagnostics" + APP_DIR="$RUNNER_TEMP/zitadel-cli-journey/myapp" mkdir -p "$DIAG_DIR/generated-app" - cp "$RUNNER_TEMP/backend-compose.log" "$DIAG_DIR/" 2>/dev/null || true + if [ -d "$APP_DIR" ]; then + ( + cd "$APP_DIR" + npm_config_registry=http://127.0.0.1:4873 \ + npm_config_yes=true \ + npm_config_audit=false \ + npm_config_fund=false \ + npx --yes @zitadel/cli@alpha logs --tail 400 --non-interactive --json \ + > "$DIAG_DIR/logs.json" 2> "$DIAG_DIR/logs.stderr.log" + ) || true + fi cp "$RUNNER_TEMP/next-app.log" "$DIAG_DIR/" 2>/dev/null || true cp "$RUNNER_TEMP/verdaccio.log" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey/doctor.json" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey/doctor.stderr.log" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey/start.json" "$DIAG_DIR/" 2>/dev/null || true + cp "$RUNNER_TEMP/zitadel-cli-journey/start.stderr.log" "$DIAG_DIR/" 2>/dev/null || true cp "$RUNNER_TEMP/zitadel-cli-journey/setup.json" "$DIAG_DIR/" 2>/dev/null || true cp "$RUNNER_TEMP/zitadel-cli-journey/setup.stderr.log" "$DIAG_DIR/" 2>/dev/null || true cp "$RUNNER_TEMP/zitadel-cli-journey/metadata.json" "$DIAG_DIR/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/myapp/package.json" "$DIAG_DIR/generated-app/" 2>/dev/null || true - cp "$RUNNER_TEMP/zitadel-cli-journey/myapp/package-lock.json" "$DIAG_DIR/generated-app/" 2>/dev/null || true + cp "$APP_DIR/.zitadel/local/runtime.json" "$DIAG_DIR/runtime.json" 2>/dev/null || true + cp "$APP_DIR/package.json" "$DIAG_DIR/generated-app/" 2>/dev/null || true + cp "$APP_DIR/package-lock.json" "$DIAG_DIR/generated-app/" 2>/dev/null || true - name: Upload consumer journey diagnostics if: failure() @@ -763,7 +689,15 @@ jobs: if: always() run: | if [ -n "${NEXT_APP_PID:-}" ]; then kill "$NEXT_APP_PID" 2>/dev/null || true; fi - if [ -n "${VERDACCIO_PID:-}" ]; then kill "$VERDACCIO_PID" 2>/dev/null || true; fi - if [ -f "$RUNNER_TEMP/nextgen-journey/docker-compose.yaml" ]; then - docker compose -f "$RUNNER_TEMP/nextgen-journey/docker-compose.yaml" --env-file "$RUNNER_TEMP/nextgen-journey/.env" down -v + APP_DIR="$RUNNER_TEMP/zitadel-cli-journey/myapp" + if [ -d "$APP_DIR" ]; then + ( + cd "$APP_DIR" + npm_config_registry=http://127.0.0.1:4873 \ + npm_config_yes=true \ + npm_config_audit=false \ + npm_config_fund=false \ + npx --yes @zitadel/cli@alpha reset --force --non-interactive --json + ) || true fi + if [ -n "${VERDACCIO_PID:-}" ]; then kill "$VERDACCIO_PID" 2>/dev/null || true; fi diff --git a/AGENTS.md b/AGENTS.md index a57c54d4e..b852fb49e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,13 +137,13 @@ The local reproduction command for the fresh-app consumer journey gate is: corepack pnpm run journey ``` -This runner requires Docker for Verdaccio. By default it starts the backend from -source with embedded Postgres, ensures the Playwright Chromium browsers are -installed, builds and packs local npm packages with pnpm, creates a temporary -Next.js app outside the repo, runs CLI setup through npm, starts the generated -app on `localhost`, and runs Playwright with one worker. Use -`-- --backend image --image ` to run the backend through the local -compose profile for image parity. +This runner requires Docker for Verdaccio and the CLI-managed local runtime. By +default it builds a local runtime image, ensures the Playwright Chromium +browsers are installed, builds and packs local npm packages with pnpm, creates +an empty app directory outside the repo, runs `npx @zitadel/cli@alpha doctor`, +`start`, and `setup --framework next --server local`, starts the generated app +on `localhost`, and runs Playwright with one worker. Use +`-- --image ` to reuse an existing local runtime image. In CI the dedicated `node-e2e` job (in `.github/workflows/ci.yml`) gates merges on the checked-in demo integrations. The separate `consumer-journey-e2e` job is @@ -170,7 +170,7 @@ upward**. When deciding where a new test belongs: The consumer journey suite is the exception to the checked-in demo ownership rule: it belongs in `apps/cli-journey-e2e/` and must exercise a freshly -generated app because it protects the real CLI onboarding path. +generated app because it protects the customer local setup path. A new test belongs at e2e level only when the boundary it covers is exclusively the framework integration (middleware, cookie origin, full @@ -208,11 +208,18 @@ For customer-local runtime workflows, agents should prefer ## Release, Licensing, And Secrets -- PR titles must pass the Semantic PR check. Use the conventional format - `(optional-scope): ` and treat `.github/semantic.yml` as the +- PR titles must pass the Semantic PR check. Before publishing, opening, or + renaming a PR, inspect `.github/semantic.yml` and use the conventional format + `(optional-scope): `. Treat `.github/semantic.yml` as the source of truth for allowed types and scopes. Scopes are optional; omit the - scope instead of inventing one. For documentation-only changes, use the - `docs` type, for example `docs: add preview status disclaimer`. + scope when unsure or when no allowed scope fits. Do not invent scopes. For + documentation-only changes, use the `docs` type, for example + `docs: add preview status disclaimer`. +- Agent-created or agent-updated PRs must include a concise description before + handoff. Use sections for `Summary`, `Validation`, + `Release notes / changeset`, and `Notes`. List the exact validation commands + run; if validation was not run, say so explicitly. Mention changeset status + for user-visible package changes. - User-visible changes to a public npm package need a changeset. The public packages are `@zitadel/cli` (`apps/cli/`), `@zitadel/api`, `@zitadel/components`, `@zitadel/sdk-core`, `@zitadel/sdk-next`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5da99b1a3..8d34d00a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ | --------------------------------- | -------------------------------------------------------------- | | Check local runtime prerequisites | `npx @zitadel/cli@alpha doctor` | | Start local Zitadel | `npx @zitadel/cli@alpha start` | -| Add auth to Next.js | `npx @zitadel/cli@alpha setup --framework next --server local` | +| Add auth to Next.js | `npx @zitadel/cli@alpha setup --server local` | | Stop local Zitadel, keeping data | `npx @zitadel/cli@alpha stop` | | Delete local Zitadel data | `npx @zitadel/cli@alpha reset --force` | @@ -182,3 +182,15 @@ documentation-only changes, use the `docs` type, for example: ```text docs: add preview status disclaimer ``` + +## Pull request descriptions + +Include a concise PR description before handing work off for review. Use these +sections: + +- `Summary` — what changed and why. +- `Validation` — exact commands run. If validation was not run, say so + explicitly. +- `Release notes / changeset` — changeset status for user-visible package + changes. +- `Notes` — reviewer context, follow-ups, risks, or `None`. diff --git a/README.md b/README.md index ed1e6539d..d92b5278b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Next iteration of the Zitadel identity platform. | --------------------------------- | -------------------------------------------------------------- | | Check local runtime prerequisites | `npx @zitadel/cli@alpha doctor` | | Start local Zitadel | `npx @zitadel/cli@alpha start` | -| Add auth to a Next.js app | `npx @zitadel/cli@alpha setup --framework next --server local` | +| Add auth to a Next.js app | `npx @zitadel/cli@alpha setup --server local` | | Check generated app files | `npx @zitadel/cli@alpha doctor` | | Stop local Zitadel, keeping data | `npx @zitadel/cli@alpha stop` | | Delete local Zitadel data | `npx @zitadel/cli@alpha reset --force` | @@ -52,19 +52,21 @@ startup, then runs `go run .`; help output skips the UI sync. ## Customer quick start ```sh -npx create-next-app@latest myapp +mkdir myapp cd myapp npx @zitadel/cli@alpha doctor npx @zitadel/cli@alpha start -npx @zitadel/cli@alpha setup --framework next --server local +npx @zitadel/cli@alpha setup --server local npm run dev ``` Open http://localhost:3000/login and register your first local user. The managed Zitadel runtime stores its container metadata and data under `.zitadel/local/`; `stop` preserves that data and `reset --force` -deletes it. `setup` installs dependencies with the detected package manager; -pass `--skip-install` if you want to install them yourself. +deletes it. In a fresh directory, `setup` asks which framework to scaffold and +writes the app into the current directory. It installs dependencies with the +detected package manager; pass `--skip-install` if you want to install them +yourself. ## Manual Docker quick start @@ -135,16 +137,17 @@ Use `corepack pnpm --silent run cli -- ... --json` when a script needs parseable CLI stdout. Plain `pnpm run` prints its own script prelude before the command output. -Fresh-app consumer journey check: +Customer local setup journey check: ```sh corepack pnpm run journey ``` This opt-in check ensures the Playwright Chromium browsers are installed, builds -the local npm packages, publishes them to a temporary Verdaccio registry, starts -a source backend with embedded Postgres, scaffolds a new Next.js app outside the -repo, and verifies registration/login journeys against the generated app. +the local npm packages, publishes them to a temporary Verdaccio registry, runs +`npx @zitadel/cli@alpha doctor`, `start`, and +`setup --framework next --server local` in an empty app directory, starts the +generated app, and verifies registration/login journeys. ## CI @@ -156,13 +159,15 @@ Pull requests and pushes to `main` run: - npm package dry-run/pack checks. - A non-publishing GoReleaser snapshot. - `consumer-journey-e2e`, which downloads the current workflow's GoReleaser - snapshot image and npm package tarballs, installs them through a temporary - npm registry into a fresh Next.js app, and runs the Playwright user journey. + snapshot image and npm package tarballs, installs the CLI through a temporary + npm registry, runs the customer local setup commands in a fresh app directory, + and runs the Playwright user journey against the generated app. CI uploads short-lived workflow artifacts for review: GoReleaser snapshot output and npm package tarballs. On consumer journey failures it also uploads focused -diagnostics such as Playwright traces, setup JSON, package lock metadata, and -service logs. These artifacts expire after 7 days and are not release artifacts. +diagnostics such as Playwright traces, doctor/start/setup JSON, package lock +metadata, local runtime logs, and service logs. These artifacts expire after 7 +days and are not release artifacts. ## Build & release @@ -226,7 +231,7 @@ Tester commands use either the latest alpha stream or an exact train: ```sh npx @zitadel/cli@alpha doctor npx @zitadel/cli@alpha start -npx @zitadel/cli@alpha setup --framework next --server local +npx @zitadel/cli@alpha setup --server local npx @zitadel/cli@0.1.0-alpha.N start ``` diff --git a/apps/cli-journey-e2e/AGENTS.md b/apps/cli-journey-e2e/AGENTS.md index d60de7935..4782606a6 100644 --- a/apps/cli-journey-e2e/AGENTS.md +++ b/apps/cli-journey-e2e/AGENTS.md @@ -5,8 +5,10 @@ These instructions apply to `apps/cli-journey-e2e/**`. Defer to the root ## Purpose -This project protects the real consumer onboarding journey. Tests must exercise -a freshly generated Next.js app, not the checked-in demo apps. +This project protects the customer local setup journey. Tests must exercise a +fresh app directory that runs the CLI local runtime path (`doctor`, `start`, +`setup --server local`) before starting the generated Next.js app. It must not +test the checked-in demo apps. ## Maintenance Rules @@ -17,6 +19,9 @@ a freshly generated Next.js app, not the checked-in demo apps. protocols. - CI must install Zitadel packages from current workflow tarballs through the temporary Verdaccio registry, not from public npm. +- CI must run `npx @zitadel/cli@alpha doctor`, `start`, and + `setup --framework next --server local` from the fresh app directory with + `--non-interactive --json`. - Pack and upload only the public packages: `@zitadel/cli`, `@zitadel/api`, `@zitadel/components`, `@zitadel/sdk-core`, `@zitadel/sdk-next`, `@zitadel/sdk-nuxt`, diff --git a/apps/cli-journey-e2e/README.md b/apps/cli-journey-e2e/README.md index 60a96bd33..1cd1f3abb 100644 --- a/apps/cli-journey-e2e/README.md +++ b/apps/cli-journey-e2e/README.md @@ -1,12 +1,12 @@ # @zitadel/cli-journey-e2e -Fresh-app Playwright coverage for the CLI onboarding journey. +Fresh-app Playwright coverage for the customer local setup journey. This project is private test infrastructure. It does not test the checked-in demo apps. Instead, it builds the current workspace packages, publishes packed -tarballs to a temporary registry, creates a new Next.js app outside the repo, -runs `zitadel setup`, starts the generated app, and verifies that a real user -can register, log out, and log in again. +tarballs to a temporary registry, creates an empty app directory outside the +repo, runs the customer local CLI flow through `npx`, starts the generated app, +and verifies that a real user can register, log out, and log in again. ## Local runner @@ -14,8 +14,8 @@ can register, log out, and log in again. corepack pnpm run journey ``` -The default mode uses Docker only for Verdaccio. The backend runs from local -source with embedded Postgres: +The default mode uses Docker for Verdaccio and for the CLI-managed local +runtime. It builds a local runtime image unless `--image` is provided: 1. Ensure the Playwright Chromium browsers are installed. 2. Build the public workspace packages. @@ -24,58 +24,57 @@ source with embedded Postgres: `workspace:` dependency specs. 5. Start Verdaccio with npmjs proxying enabled. 6. Publish tarballs to Verdaccio with `alpha` and `latest` tags. -7. Start `go run .` on a free local port. -8. Create a pinned `create-next-app` project in a temporary directory. -9. Run CLI setup through `npx @alpha`. -10. Run `npm install` in the generated app against the temporary registry. -11. Start the generated app on `localhost`. -12. Run the Playwright tests with one worker. +7. Build or use a local runtime Docker image for `zitadel start`. +8. Create an empty app directory in a temporary directory. +9. Run `npx @alpha doctor --non-interactive --json`. +10. Run `npx @alpha start --non-interactive --json`. +11. Run `npx @alpha setup --framework next --server local --non-interactive --json`. +12. Start the generated app on `localhost`. +13. Run the Playwright tests with one worker. ### Options ```sh corepack pnpm run journey -- --keep corepack pnpm run journey -- --work-dir /tmp/zitadel-journey -corepack pnpm run journey -- --backend image --image nextgen:local +corepack pnpm run journey -- --image nextgen:local ``` -- `--backend source` runs `go run .` with embedded Postgres. This is the - default. -- `--backend image --image ` runs the backend through - `docker-compose.local.yaml` for image parity. +- `--image ` uses an existing local runtime image instead of + building one. - `--keep` keeps the temporary work directory after success. - `--work-dir ` uses an explicit work directory. Useful environment overrides: - `JOURNEY_REGISTRY_PORT` -- `JOURNEY_BACKEND_PORT` - `JOURNEY_APP_PORT` - `JOURNEY_ENABLE_PASSKEY=0` as a local-only escape hatch while debugging passkey setup. CI must run passkey coverage. ## CI gate -The `consumer-journey-e2e` workflow job does not use public Zitadel packages. It -downloads the GoReleaser snapshot image and the public npm package tarballs -produced by the same workflow, publishes those tarballs to Verdaccio, creates a -fresh Next.js app, and runs the same Playwright project against the generated -app. Private support packages such as design tokens are bundled into the public -packages that need them and must not be uploaded or published. +The `consumer-journey-e2e` workflow job does not use public Zitadel packages or +GHCR images. It downloads the GoReleaser snapshot image and the public npm +package tarballs produced by the same workflow, publishes those tarballs to +Verdaccio, points `ZITADEL_LOCAL_IMAGE` at the loaded image, runs the same +`npx` local setup flow, and runs the same Playwright project against the +generated app. Private support packages such as design tokens are bundled into +the public packages that need them and must not be uploaded or published. -Failure diagnostics intentionally stay small: Playwright report/output, setup -JSON, setup stderr, metadata, generated app `package.json` and -`package-lock.json`, Verdaccio logs, Next logs, and backend compose logs. -Do not upload generated app `node_modules` or `.next` directories. +Failure diagnostics intentionally stay small: Playwright report/output, +doctor/start/setup JSON and stderr, local runtime metadata/logs, metadata, +generated app `package.json` and `package-lock.json`, Verdaccio logs, and Next +logs. Do not upload generated app `node_modules` or `.next` directories. ## Coverage The suite is serial and one-worker because every test shares the same fresh backend and generated app instance. -- CLI/setup contract: setup exits successfully, stdout parses as JSON, - `status` is `ok`, the generated app depends on the local SDK package, and - Zitadel packages resolve from the temporary registry. +- CLI local setup contract: doctor, start, and setup exit successfully, stdout + parses as JSON, `status` is `ok`, the generated app depends on the local SDK + package, and Zitadel packages resolve from the temporary registry. - Password-only account: register with email/password, skip passkey setup, log out, and log in again with password. - Passkey-only account: register with email/passkey, log out, and log in again diff --git a/apps/cli-journey-e2e/docker-compose.local.yaml b/apps/cli-journey-e2e/docker-compose.local.yaml index dbefa5b7e..0c71f0724 100644 --- a/apps/cli-journey-e2e/docker-compose.local.yaml +++ b/apps/cli-journey-e2e/docker-compose.local.yaml @@ -16,30 +16,3 @@ services: interval: 2s timeout: 5s retries: 30 - - postgres: - profiles: ["backend-image"] - image: postgres:18 - environment: - POSTGRES_USER: zitadel - POSTGRES_PASSWORD: zitadel - POSTGRES_DB: nextgen - PGDATA: /var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U zitadel -d nextgen"] - interval: 5s - timeout: 5s - retries: 10 - - nextgen: - profiles: ["backend-image"] - image: "${JOURNEY_BACKEND_IMAGE:?JOURNEY_BACKEND_IMAGE is required}" - depends_on: - postgres: - condition: service_healthy - ports: - - "127.0.0.1:${JOURNEY_BACKEND_PORT:-8080}:8080" - environment: - NEXTGEN_SERVER_ADDRESS: ":8080" - NEXTGEN_DATABASE_POSTGRES: postgres://zitadel:zitadel@postgres:5432/nextgen?sslmode=disable - NEXTGEN_SERVER_ENCRYPTION_KEY: "${NEXTGEN_SERVER_ENCRYPTION_KEY:?NEXTGEN_SERVER_ENCRYPTION_KEY is required}" diff --git a/apps/cli-journey-e2e/package.json b/apps/cli-journey-e2e/package.json index a941daf91..5fc5994d0 100644 --- a/apps/cli-journey-e2e/package.json +++ b/apps/cli-journey-e2e/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "private": true, "scripts": { + "test": "node --test scripts/*.test.mjs", "prepare-app": "node scripts/prepare-next-app.mjs", "publish-tarballs": "node scripts/publish-tarballs.mjs", "verify-tarballs": "node scripts/verify-tarballs.mjs", diff --git a/apps/cli-journey-e2e/scripts/prepare-next-app.mjs b/apps/cli-journey-e2e/scripts/prepare-next-app.mjs index 2667dd80d..aa240581f 100644 --- a/apps/cli-journey-e2e/scripts/prepare-next-app.mjs +++ b/apps/cli-journey-e2e/scripts/prepare-next-app.mjs @@ -5,124 +5,203 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; const here = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(here, "../../.."); - -const registryUrl = process.env.JOURNEY_REGISTRY_URL ?? "http://127.0.0.1:4873"; -const backendUrl = process.env.JOURNEY_BACKEND_URL ?? "http://localhost:8080"; -const createNextAppVersion = process.env.JOURNEY_CREATE_NEXT_APP_VERSION ?? "16.2.4"; -const outputDir = - process.env.JOURNEY_WORK_DIR ?? - join(tmpdir(), `zitadel-cli-journey-${process.pid}-${Date.now()}`); -const appDir = join(outputDir, "myapp"); -const cliPackage = process.env.JOURNEY_CLI_PACKAGE ?? (await packageName("apps/cli")); -const sdkNextPackage = - process.env.JOURNEY_SDK_NEXT_PACKAGE ?? (await packageName("packages/sdk-next")); - -await rm(appDir, { recursive: true, force: true }); -await mkdir(outputDir, { recursive: true }); - -const npmEnv = npmEnvironment(); - -await run( - "npx", - [ - "--yes", - `create-next-app@${createNextAppVersion}`, - "myapp", - "--ts", - "--app", - "--use-npm", - "--disable-git", - "--yes", - ], - { cwd: outputDir, env: npmEnv }, -); - -const setup = await run( - "npx", - [ - "--yes", - `${cliPackage}@alpha`, - "setup", - "--framework", - "next", - "--server", - backendUrl, - "--non-interactive", - "--json", - ], - { cwd: appDir, env: npmEnv }, -); - -const setupPath = join(outputDir, "setup.json"); -const setupStderrPath = join(outputDir, "setup.stderr.log"); -await writeFile(setupPath, setup.stdout); -await writeFile(setupStderrPath, setup.stderr); - -let setupJson; -try { - setupJson = JSON.parse(setup.stdout); -} catch (error) { - throw new Error(`setup stdout was not JSON: ${String(error)}\n${setup.stdout}`, { - cause: error, - }); +const defaultRepoRoot = resolve(here, "../../.."); +const defaultRegistryUrl = "http://127.0.0.1:4873"; +const defaultAppUrl = "http://localhost:3000"; + +export async function prepareNextApp(options = {}) { + const env = options.env ?? process.env; + const repoRoot = options.repoRoot ?? defaultRepoRoot; + const outputDir = resolve( + env.JOURNEY_WORK_DIR ?? join(tmpdir(), `zitadel-cli-journey-${process.pid}-${Date.now()}`), + ); + const appDir = resolve(env.JOURNEY_APP_DIR ?? join(outputDir, "myapp")); + const registryUrl = env.JOURNEY_REGISTRY_URL ?? defaultRegistryUrl; + const appUrl = env.JOURNEY_APP_URL ?? defaultAppUrl; + const fs = { + appendFile: options.appendFile ?? appendFile, + mkdir: options.mkdir ?? mkdir, + readFile: options.readFile ?? readFile, + rm: options.rm ?? rm, + writeFile: options.writeFile ?? writeFile, + }; + const runCaptureFn = options.runCapture ?? runCapture; + const packageNameFn = + options.packageName ?? ((relativePath) => packageName(relativePath, { readFile: fs.readFile, repoRoot })); + const cliPackage = env.JOURNEY_CLI_PACKAGE ?? (await packageNameFn("apps/cli")); + const sdkNextPackage = env.JOURNEY_SDK_NEXT_PACKAGE ?? (await packageNameFn("packages/sdk-next")); + const npmEnv = npmEnvironment(env, registryUrl); + + await fs.rm(appDir, { recursive: true, force: true }); + await fs.mkdir(appDir, { recursive: true }); + await fs.mkdir(outputDir, { recursive: true }); + + let startJson; + let setupJson; + try { + await runCliJsonStep({ + appDir, + cliPackage, + env: npmEnv, + outputDir, + runCapture: runCaptureFn, + step: "doctor", + stepArgs: ["doctor"], + writeFile: fs.writeFile, + }); + startJson = await runCliJsonStep({ + appDir, + cliPackage, + env: npmEnv, + outputDir, + runCapture: runCaptureFn, + step: "start", + stepArgs: ["start"], + writeFile: fs.writeFile, + }); + setupJson = await runCliJsonStep({ + appDir, + cliPackage, + env: npmEnv, + outputDir, + runCapture: runCaptureFn, + step: "setup", + stepArgs: ["setup", "--framework", "next", "--server", "local"], + writeFile: fs.writeFile, + }); + } catch (error) { + await collectLocalRuntimeLogs({ + appDir, + cliPackage, + env: npmEnv, + outputDir, + runCapture: runCaptureFn, + writeFile: fs.writeFile, + }); + throw error; + } + + await assertNoNestedApp(appDir, fs.readFile); + + const appPackage = JSON.parse(await fs.readFile(join(appDir, "package.json"), "utf8")); + const dependencies = { + ...(appPackage.dependencies ?? {}), + ...(appPackage.devDependencies ?? {}), + }; + if (!dependencies[sdkNextPackage]) { + throw new Error(`generated package.json does not depend on ${sdkNextPackage}`); + } + + const packageLockPath = join(appDir, "package-lock.json"); + const packageLock = JSON.parse(await fs.readFile(packageLockPath, "utf8")); + const packageScope = sdkNextPackage.split("/")[0]; + const scopedNodeModulePrefix = `node_modules/${packageScope}/`; + const lockedZitadelPackages = Object.entries(packageLock.packages ?? {}).filter( + ([name]) => name.startsWith(scopedNodeModulePrefix), + ); + if (lockedZitadelPackages.length === 0) { + throw new Error(`package-lock.json does not contain ${packageScope} packages`); + } + for (const [name, entry] of lockedZitadelPackages) { + const resolved = entry?.resolved; + if (typeof resolved !== "string" || !resolved.startsWith(registryUrl)) { + throw new Error(`${name} resolved outside the temporary registry: ${resolved}`); + } + } + + const metadata = { + appDir, + appUrl, + cliPackage, + doctorPath: join(outputDir, "doctor.json"), + localRuntimeUrl: startJson?.data?.urls?.api ?? null, + outputDir, + registryUrl, + runtimeMetadataPath: join(appDir, ".zitadel/local/runtime.json"), + sdkNextPackage, + setupPath: join(outputDir, "setup.json"), + setupServer: setupJson?.data?.server ?? null, + startPath: join(outputDir, "start.json"), + }; + const metadataPath = join(outputDir, "metadata.json"); + await fs.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); + + await exportEnv("JOURNEY_APP_DIR", appDir, fs.appendFile, env); + await exportEnv("JOURNEY_APP_URL", appUrl, fs.appendFile, env); + await exportEnv("JOURNEY_OUTPUT_DIR", outputDir, fs.appendFile, env); + await exportOutput("app_dir", appDir, fs.appendFile, env); + await exportOutput("output_dir", outputDir, fs.appendFile, env); + + if (options.logMetadata !== false) { + console.log(JSON.stringify(metadata, null, 2)); + } + return metadata; } -if (setupJson.status !== "ok") { - throw new Error(`setup returned status ${JSON.stringify(setupJson.status)}`); + +export function cliStepArgs(cliPackage, stepArgs) { + return ["--yes", `${cliPackage}@alpha`, ...stepArgs, "--non-interactive", "--json"]; } -const appPackage = JSON.parse(await readFile(join(appDir, "package.json"), "utf8")); -const dependencies = { - ...(appPackage.dependencies ?? {}), - ...(appPackage.devDependencies ?? {}), -}; -if (!dependencies[sdkNextPackage]) { - throw new Error(`generated package.json does not depend on ${sdkNextPackage}`); +async function runCliJsonStep(input) { + const result = await input.runCapture("npx", cliStepArgs(input.cliPackage, input.stepArgs), { + cwd: input.appDir, + env: input.env, + }); + await input.writeFile(join(input.outputDir, `${input.step}.json`), result.stdout); + await input.writeFile(join(input.outputDir, `${input.step}.stderr.log`), result.stderr); + + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch (error) { + throw new Error(`${input.step} stdout was not JSON: ${String(error)}\n${result.stdout}`, { + cause: error, + }); + } + + if (result.code !== 0) { + throw new Error(`${input.step} exited ${result.code}: ${parsed.message ?? result.stderr}`); + } + if (parsed.status !== "ok") { + throw new Error(`${input.step} returned status ${JSON.stringify(parsed.status)}`); + } + return parsed; } -await run("npm", ["install", "--registry", registryUrl], { cwd: appDir, env: npmEnv }); - -const packageLockPath = join(appDir, "package-lock.json"); -const packageLock = JSON.parse(await readFile(packageLockPath, "utf8")); -const packageScope = sdkNextPackage.split("/")[0]; -const scopedNodeModulePrefix = `node_modules/${packageScope}/`; -const lockedZitadelPackages = Object.entries(packageLock.packages ?? {}).filter( - ([name]) => name.startsWith(scopedNodeModulePrefix), -); -if (lockedZitadelPackages.length === 0) { - throw new Error(`package-lock.json does not contain ${packageScope} packages`); +async function collectLocalRuntimeLogs(input) { + let result; + try { + result = await input.runCapture( + "npx", + cliStepArgs(input.cliPackage, ["logs", "--tail", "400"]), + { cwd: input.appDir, env: input.env }, + ); + } catch (error) { + await input.writeFile( + join(input.outputDir, "logs.stderr.log"), + `failed to collect local runtime logs: ${errorMessage(error)}\n`, + ); + return; + } + await input.writeFile(join(input.outputDir, "logs.json"), result.stdout); + await input.writeFile(join(input.outputDir, "logs.stderr.log"), result.stderr); } -for (const [name, entry] of lockedZitadelPackages) { - const resolved = entry?.resolved; - if (typeof resolved !== "string" || !resolved.startsWith(registryUrl)) { - throw new Error(`${name} resolved outside the temporary registry: ${resolved}`); + +async function assertNoNestedApp(appDir, readFileFn) { + try { + await readFileFn(join(appDir, "myapp", "package.json"), "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + return; + } + throw error; } + throw new Error("setup scaffolded a nested myapp directory instead of using the app root"); } -const metadata = { - appDir, - backendUrl, - cliPackage, - createNextAppVersion, - outputDir, - registryUrl, - sdkNextPackage, - setupPath, -}; -const metadataPath = join(outputDir, "metadata.json"); -await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); - -await exportEnv("JOURNEY_APP_DIR", appDir); -await exportEnv("JOURNEY_APP_URL", "http://localhost:3000"); -await exportEnv("JOURNEY_OUTPUT_DIR", outputDir); -await exportOutput("app_dir", appDir); -await exportOutput("output_dir", outputDir); - -console.log(JSON.stringify(metadata, null, 2)); - -async function packageName(relativePath) { +async function packageName(relativePath, options) { const pkg = JSON.parse( - await readFile(join(repoRoot, relativePath, "package.json"), "utf8"), + await options.readFile(join(options.repoRoot, relativePath, "package.json"), "utf8"), ); if (typeof pkg.name !== "string" || pkg.name.length === 0) { throw new Error(`${relativePath}/package.json has no name`); @@ -130,15 +209,15 @@ async function packageName(relativePath) { return pkg.name; } -function npmEnvironment() { - const env = { ...process.env }; - for (const key of Object.keys(env)) { +function npmEnvironment(env, registryUrl) { + const next = { ...env }; + for (const key of Object.keys(next)) { if (key.startsWith("npm_config_")) { - delete env[key]; + delete next[key]; } } return { - ...env, + ...next, npm_config_audit: "false", npm_config_fund: "false", npm_config_registry: registryUrl, @@ -146,7 +225,7 @@ function npmEnvironment() { }; } -function run(command, args, options) { +function runCapture(command, args, options) { return new Promise((resolveRun, reject) => { const child = spawn(command, args, { ...options, @@ -164,25 +243,30 @@ function run(command, args, options) { }); child.on("error", reject); child.on("close", (code) => { - if (code === 0) { - resolveRun({ stdout, stderr }); - return; - } - reject( - new Error( - `${command} ${args.join(" ")} exited ${code}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`, - ), - ); + resolveRun({ code: code ?? 1, stdout, stderr }); }); }); } -async function exportEnv(name, value) { - if (!process.env.GITHUB_ENV) return; - await appendFile(process.env.GITHUB_ENV, `${name}=${value}\n`); +async function exportEnv(name, value, appendFileFn, env) { + if (!env.GITHUB_ENV) return; + await appendFileFn(env.GITHUB_ENV, `${name}=${value}\n`); +} + +async function exportOutput(name, value, appendFileFn, env) { + if (!env.GITHUB_OUTPUT) return; + await appendFileFn(env.GITHUB_OUTPUT, `${name}=${value}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await prepareNextApp(); + } catch (error) { + console.error(errorMessage(error)); + process.exit(1); + } } -async function exportOutput(name, value) { - if (!process.env.GITHUB_OUTPUT) return; - await appendFile(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); } diff --git a/apps/cli-journey-e2e/scripts/prepare-next-app.test.mjs b/apps/cli-journey-e2e/scripts/prepare-next-app.test.mjs new file mode 100644 index 000000000..05de31a99 --- /dev/null +++ b/apps/cli-journey-e2e/scripts/prepare-next-app.test.mjs @@ -0,0 +1,212 @@ +/* oxlint-disable playwright/expect-expect */ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { prepareNextApp } from "./prepare-next-app.mjs"; + +test("prepares the customer local setup journey in the app root", async () => { + const workDir = await mkdtemp(join(tmpdir(), "zitadel-journey-prepare-test-")); + const calls = []; + const registryUrl = "http://127.0.0.1:4873"; + const image = "ghcr.io/zitadel/nextgen:test"; + + try { + const metadata = await prepareNextApp({ + env: { + JOURNEY_CLI_PACKAGE: "@zitadel/cli", + JOURNEY_REGISTRY_URL: registryUrl, + JOURNEY_SDK_NEXT_PACKAGE: "@zitadel/sdk-next", + JOURNEY_WORK_DIR: workDir, + ZITADEL_LOCAL_IMAGE: image, + }, + logMetadata: false, + runCapture: async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd, env: options.env }); + if (args.includes("setup")) { + await writeGeneratedApp(options.cwd, registryUrl); + } + if (args.includes("start")) { + await mkdir(join(options.cwd, ".zitadel/local"), { recursive: true }); + await writeFile( + join(options.cwd, ".zitadel/local/runtime.json"), + `${JSON.stringify({ server_url: "http://localhost:8080" })}\n`, + ); + } + return { + code: 0, + stdout: `${JSON.stringify(okEnvelope(args))}\n`, + stderr: "", + }; + }, + }); + + const appDir = join(workDir, "myapp"); + assert.equal(metadata.appDir, appDir); + assert.equal(metadata.localRuntimeUrl, "http://localhost:8080"); + assert.deepEqual( + calls.map((call) => call.args), + [ + [ + "--yes", + "@zitadel/cli@alpha", + "doctor", + "--non-interactive", + "--json", + ], + [ + "--yes", + "@zitadel/cli@alpha", + "start", + "--non-interactive", + "--json", + ], + [ + "--yes", + "@zitadel/cli@alpha", + "setup", + "--framework", + "next", + "--server", + "local", + "--non-interactive", + "--json", + ], + ], + ); + assert.deepEqual(calls.map((call) => call.cwd), [appDir, appDir, appDir]); + assert.ok(calls.every((call) => call.env.ZITADEL_LOCAL_IMAGE === image)); + assert.ok(JSON.parse(await readFile(join(workDir, "doctor.json"), "utf8"))); + assert.ok(JSON.parse(await readFile(join(workDir, "start.json"), "utf8"))); + assert.ok(JSON.parse(await readFile(join(workDir, "setup.json"), "utf8"))); + assert.ok(JSON.parse(await readFile(join(workDir, "metadata.json"), "utf8"))); + await assert.rejects(readFile(join(appDir, "myapp/package.json"), "utf8"), { + code: "ENOENT", + }); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +}); + +test("collects local runtime logs when a CLI step fails", async () => { + const workDir = await mkdtemp(join(tmpdir(), "zitadel-journey-prepare-fail-test-")); + const calls = []; + + try { + await assert.rejects( + prepareNextApp({ + env: { + JOURNEY_CLI_PACKAGE: "@zitadel/cli", + JOURNEY_SDK_NEXT_PACKAGE: "@zitadel/sdk-next", + JOURNEY_WORK_DIR: workDir, + }, + logMetadata: false, + runCapture: async (command, args, options) => { + calls.push({ command, args, cwd: options.cwd }); + if (args.includes("doctor")) { + return { code: 0, stdout: `${JSON.stringify(okEnvelope(args))}\n`, stderr: "" }; + } + if (args.includes("start")) { + return { + code: 1, + stdout: `${JSON.stringify({ status: "error", message: "boom" })}\n`, + stderr: "start failed", + }; + } + if (args.includes("logs")) { + return { + code: 0, + stdout: `${JSON.stringify({ status: "ok", data: { logs: "runtime log" } })}\n`, + stderr: "", + }; + } + throw new Error(`unexpected command: ${args.join(" ")}`); + }, + }), + /start exited 1/, + ); + + assert.ok(calls.some((call) => call.args.includes("logs"))); + const logs = JSON.parse(await readFile(join(workDir, "logs.json"), "utf8")); + assert.equal(logs.data.logs, "runtime log"); + const start = JSON.parse(await readFile(join(workDir, "start.json"), "utf8")); + assert.equal(start.status, "error"); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +}); + +test("records log collection failures thrown as non-Error values", async () => { + const workDir = await mkdtemp(join(tmpdir(), "zitadel-journey-prepare-non-error-test-")); + + try { + await assert.rejects( + prepareNextApp({ + env: { + JOURNEY_CLI_PACKAGE: "@zitadel/cli", + JOURNEY_SDK_NEXT_PACKAGE: "@zitadel/sdk-next", + JOURNEY_WORK_DIR: workDir, + }, + logMetadata: false, + runCapture: async (command, args) => { + if (args.includes("doctor")) { + return { code: 0, stdout: `${JSON.stringify(okEnvelope(args))}\n`, stderr: "" }; + } + if (args.includes("start")) { + return { + code: 1, + stdout: `${JSON.stringify({ status: "error", message: "boom" })}\n`, + stderr: "start failed", + }; + } + if (args.includes("logs")) { + throw null; + } + throw new Error(`unexpected command: ${command} ${args.join(" ")}`); + }, + }), + /start exited 1/, + ); + + assert.equal( + await readFile(join(workDir, "logs.stderr.log"), "utf8"), + "failed to collect local runtime logs: null\n", + ); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +}); + +async function writeGeneratedApp(appDir, registryUrl) { + await writeFile( + join(appDir, "package.json"), + `${JSON.stringify({ dependencies: { "@zitadel/sdk-next": "alpha" } }, null, 2)}\n`, + ); + await writeFile( + join(appDir, "package-lock.json"), + `${JSON.stringify( + { + packages: { + "node_modules/@zitadel/sdk-next": { + resolved: `${registryUrl}/@zitadel/sdk-next/-/sdk-next.tgz`, + }, + }, + }, + null, + 2, + )}\n`, + ); +} + +function okEnvelope(args) { + const command = args[2]; + if (command === "start") { + return { status: "ok", data: { urls: { api: "http://localhost:8080" } } }; + } + if (command === "setup") { + return { status: "ok", data: { server: "http://localhost:8080" } }; + } + return { status: "ok", data: { ok: true } }; +} diff --git a/apps/cli-journey-e2e/scripts/run-local.mjs b/apps/cli-journey-e2e/scripts/run-local.mjs index c706ae204..1edda68d1 100644 --- a/apps/cli-journey-e2e/scripts/run-local.mjs +++ b/apps/cli-journey-e2e/scripts/run-local.mjs @@ -17,8 +17,6 @@ const here = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(here, ".."); const repoRoot = resolve(projectRoot, "../.."); const composeFile = join(projectRoot, "docker-compose.local.yaml"); -const devEncryptionKey = - "4d61737465726b65794e65656473546f48617665333243686172616374657273"; const packageDirs = [ "apps/cli", "packages/api", @@ -31,30 +29,30 @@ const packageDirs = [ "packages/sdk-angular", ]; -const options = parseArgs(process.argv.slice(2)); +const options = parseArgsOrExit(process.argv.slice(2)); const workDir = resolve( options.workDir || (await mkdtemp(join(tmpdir(), "zitadel-cli-journey-local-"))), ); const tarballsDir = join(workDir, "npm-packages"); const diagnosticsDir = join(workDir, "diagnostics"); +const appDir = join(workDir, "myapp"); const composeEnvPath = join(workDir, "compose.env"); const verdaccioConfigPath = join(workDir, "verdaccio", "config.yaml"); const verdaccioStoragePath = join(workDir, "verdaccio", "storage"); const verdaccioNpmrcPath = join(workDir, "verdaccio.npmrc"); -const backendLogPath = join(diagnosticsDir, "backend.log"); const nextLogPath = join(diagnosticsDir, "next-app.log"); const composeLogPath = join(diagnosticsDir, "compose.log"); const composeProjectName = `zitadel-journey-${process.pid}-${Date.now()}`; const registryPort = await resolvePort("JOURNEY_REGISTRY_PORT"); -const backendPort = await resolvePort("JOURNEY_BACKEND_PORT"); -const appPort = await resolvePort("JOURNEY_APP_PORT"); +const appPort = await resolvePort("JOURNEY_APP_PORT", 3000); const registryUrl = `http://127.0.0.1:${registryPort}`; -const backendUrl = `http://127.0.0.1:${backendPort}`; const appUrl = `http://localhost:${appPort}`; +const cliPackage = await packageName("apps/cli"); const childProcesses = new Set(); let composeStarted = false; let cleanupStarted = false; let success = false; +let localRuntimeImage = process.env.ZITADEL_LOCAL_IMAGE || options.image; process.on("SIGINT", () => void handleSignal("SIGINT")); process.on("SIGTERM", () => void handleSignal("SIGTERM")); @@ -94,26 +92,22 @@ try { }, ); - let backendProcess; - if (options.backend === "image") { - await waitForHttp(`${backendUrl}/healthz`, "backend image"); - } else { - backendProcess = await startSourceBackend(); - await waitForHttp(`${backendUrl}/healthz`, "source backend", backendProcess); + if (!localRuntimeImage) { + log("building local runtime image for npx @zitadel/cli@alpha start"); + localRuntimeImage = await buildJourneyRuntimeImage(); } await run("node", ["apps/cli-journey-e2e/scripts/prepare-next-app.mjs"], { env: { ...process.env, JOURNEY_APP_URL: appUrl, - JOURNEY_BACKEND_URL: backendUrl, JOURNEY_REGISTRY_URL: registryUrl, JOURNEY_WORK_DIR: workDir, NPM_CONFIG_USERCONFIG: verdaccioNpmrcPath, + ZITADEL_LOCAL_IMAGE: localRuntimeImage, }, }); - const appDir = join(workDir, "myapp"); const nextProcess = startChild("npm", [ "run", "dev", @@ -152,11 +146,11 @@ try { ); success = true; - log("local consumer journey passed"); + log("customer local setup journey passed"); } catch (error) { await collectDiagnostics(); console.error(""); - console.error(`[journey-local] failed: ${error.message}`); + console.error(`[journey-local] failed: ${errorMessage(error)}`); console.error(`[journey-local] diagnostics preserved in ${workDir}`); process.exitCode = 1; } finally { @@ -170,9 +164,17 @@ try { process.exit(process.exitCode ?? 0); +function parseArgsOrExit(args) { + try { + return parseArgs(args); + } catch (error) { + console.error(`[journey-local] ${errorMessage(error)}`); + process.exit(1); + } +} + function parseArgs(args) { const parsed = { - backend: "source", image: "", keep: false, workDir: "", @@ -182,8 +184,14 @@ function parseArgs(args) { const arg = args[index]; switch (arg) { case "--backend": { - parsed.backend = readValue(args, ++index, arg); - break; + readValue(args, ++index, arg); + throw new Error( + [ + "--backend was removed from the journey runner.", + "The journey now always exercises `npx @zitadel/cli@alpha start`.", + "Remove `--backend`, or pass `--image ` / set ZITADEL_LOCAL_IMAGE to choose the local runtime image.", + ].join(" "), + ); } case "--image": { parsed.image = readValue(args, ++index, arg); @@ -208,12 +216,6 @@ function parseArgs(args) { } } - if (!["source", "image"].includes(parsed.backend)) { - throw new Error(`--backend must be "source" or "image", got ${parsed.backend}`); - } - if (parsed.backend === "image" && !parsed.image) { - throw new Error("--backend image requires --image "); - } return parsed; } @@ -229,15 +231,13 @@ function printUsage() { console.log(`usage: node scripts/run-local.mjs [options] Options: - --backend source Run go run . with embedded Postgres (default) - --backend image Run the backend through docker compose - --image Required with --backend image + --image Use an existing local runtime image instead of building one --keep Keep the temp work directory after success --work-dir Use an explicit work directory `); } -async function resolvePort(envName) { +async function resolvePort(envName, fallback) { const value = process.env[envName]; if (value) { const port = Number(value); @@ -246,6 +246,9 @@ async function resolvePort(envName) { } return port; } + if (fallback) { + return fallback; + } return freePort(); } @@ -307,11 +310,8 @@ async function writeComposeEnv() { composeEnvPath, [ `JOURNEY_REGISTRY_PORT=${registryPort}`, - `JOURNEY_BACKEND_PORT=${backendPort}`, - `JOURNEY_BACKEND_IMAGE=${options.image || "unused"}`, `JOURNEY_VERDACCIO_CONFIG=${verdaccioConfigPath}`, `JOURNEY_VERDACCIO_STORAGE=${verdaccioStoragePath}`, - `NEXTGEN_SERVER_ENCRYPTION_KEY=${devEncryptionKey}`, "", ].join("\n"), ); @@ -368,6 +368,15 @@ async function ensurePlaywrightBrowsers() { ]); } +async function buildJourneyRuntimeImage() { + const result = await runCapture("node", ["scripts/build-local-runtime-image.mjs"]); + const image = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + if (!image) { + throw new Error("local runtime image build did not print an image tag"); + } + return image; +} + async function assertDockerAvailable() { let engineVersion = ""; let composeVersion = ""; @@ -408,37 +417,11 @@ async function assertDockerAvailable() { } async function startCompose() { - if (options.backend === "image") { - log(`starting Verdaccio and backend image ${options.image}`); - await run("docker", composeArgs(["up", "-d", "verdaccio", "nextgen"], true)); - } else { - log("starting Verdaccio"); - await run("docker", composeArgs(["up", "-d", "verdaccio"])); - } + log("starting Verdaccio"); + await run("docker", composeArgs(["up", "-d", "verdaccio"])); composeStarted = true; } -async function startSourceBackend() { - log(`starting source backend on ${backendUrl}`); - const env = { - ...process.env, - NEXTGEN_SERVER_ADDRESS: `127.0.0.1:${backendPort}`, - NEXTGEN_SERVER_CONSOLE_ENABLED: "false", - NEXTGEN_SERVER_ENCRYPTION_KEY: devEncryptionKey, - NEXTGEN_SERVER_LOGIN_ENABLED: "false", - }; - for (const key of Object.keys(env)) { - if (key.startsWith("NEXTGEN_DATABASE_")) { - delete env[key]; - } - } - return startChild("go", ["run", "."], { - cwd: repoRoot, - env, - logFile: backendLogPath, - }); -} - async function waitForHttp(url, label, child) { const deadline = Date.now() + 90_000; let lastError; @@ -530,7 +513,7 @@ function runCapture(command, args, optionsForRun = {}) { } function commandErrorDetail(error) { - const message = error instanceof Error ? error.message : String(error); + const message = errorMessage(error); const lines = message .split(/\r?\n/) .map((line) => line.trim()) @@ -546,7 +529,7 @@ function commandErrorDetail(error) { return (detailLines.length > 0 ? detailLines : lines).slice(-4).join("\n"); } -function composeArgs(args, includeImageProfile = false) { +function composeArgs(args) { const base = [ "compose", "--project-name", @@ -556,33 +539,46 @@ function composeArgs(args, includeImageProfile = false) { "-f", composeFile, ]; - if (includeImageProfile) { - base.push("--profile", "backend-image"); - } return [...base, ...args]; } async function collectDiagnostics() { await mkdir(diagnosticsDir, { recursive: true }); + await collectLocalRuntimeLogs(); if (composeStarted) { try { const result = await runCapture( "docker", - composeArgs(["logs"], options.backend === "image"), + composeArgs(["logs"]), ); await writeFile(composeLogPath, `${result.stdout}${result.stderr}`); } catch (error) { - await writeFile(composeLogPath, `failed to collect compose logs: ${error.message}\n`); + await writeFile(composeLogPath, `failed to collect compose logs: ${errorMessage(error)}\n`); } } - const appDir = join(workDir, "myapp"); + await copyIfExists(join(workDir, "doctor.json"), join(diagnosticsDir, "doctor.json")); + await copyIfExists( + join(workDir, "doctor.stderr.log"), + join(diagnosticsDir, "doctor.stderr.log"), + ); + await copyIfExists(join(workDir, "start.json"), join(diagnosticsDir, "start.json")); + await copyIfExists( + join(workDir, "start.stderr.log"), + join(diagnosticsDir, "start.stderr.log"), + ); await copyIfExists(join(workDir, "setup.json"), join(diagnosticsDir, "setup.json")); await copyIfExists( join(workDir, "setup.stderr.log"), join(diagnosticsDir, "setup.stderr.log"), ); await copyIfExists(join(workDir, "metadata.json"), join(diagnosticsDir, "metadata.json")); + await copyIfExists(join(workDir, "logs.json"), join(diagnosticsDir, "logs.json")); + await copyIfExists(join(workDir, "logs.stderr.log"), join(diagnosticsDir, "logs.stderr.log")); + await copyIfExists( + join(appDir, ".zitadel/local/runtime.json"), + join(diagnosticsDir, "runtime.json"), + ); await mkdir(join(diagnosticsDir, "generated-app"), { recursive: true }); await copyIfExists( join(appDir, "package.json"), @@ -598,6 +594,23 @@ async function collectDiagnostics() { ); } +async function collectLocalRuntimeLogs() { + try { + const result = await runCapture( + "npx", + cliArgs(["logs", "--tail", "400"]), + { cwd: appDir, env: npxEnv() }, + ); + await writeFile(join(diagnosticsDir, "logs.json"), result.stdout); + await writeFile(join(diagnosticsDir, "logs.stderr.log"), result.stderr); + } catch (error) { + await writeFile( + join(diagnosticsDir, "logs.stderr.log"), + `failed to collect local runtime logs: ${errorMessage(error)}\n`, + ); + } +} + async function copyIfExists(source, destination) { try { await cp(source, destination, { recursive: true }); @@ -616,15 +629,29 @@ async function cleanup() { await stopChild(child); } + await resetLocalRuntime(); + if (composeStarted) { try { - await run("docker", composeArgs(["down", "-v", "--remove-orphans"], true)); + await run("docker", composeArgs(["down", "-v", "--remove-orphans"])); } catch (error) { - console.error(`[journey-local] docker compose cleanup failed: ${error.message}`); + console.error(`[journey-local] docker compose cleanup failed: ${errorMessage(error)}`); } } } +async function resetLocalRuntime() { + try { + await runCapture( + "npx", + cliArgs(["reset", "--force"]), + { cwd: appDir, env: npxEnv() }, + ); + } catch (error) { + console.error(`[journey-local] local runtime reset failed: ${errorMessage(error)}`); + } +} + async function stopChild(child) { if (child.exitCode !== null) return; child.kill("SIGTERM"); @@ -664,6 +691,30 @@ function delay(ms) { return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); } +function cliArgs(args) { + return ["--yes", `${cliPackage}@alpha`, ...args, "--non-interactive", "--json"]; +} + +function npxEnv() { + const env = { + ...process.env, + NPM_CONFIG_USERCONFIG: verdaccioNpmrcPath, + npm_config_audit: "false", + npm_config_fund: "false", + npm_config_registry: registryUrl, + npm_config_yes: "true", + }; + const image = localRuntimeImage || process.env.ZITADEL_LOCAL_IMAGE; + if (image) { + env.ZITADEL_LOCAL_IMAGE = image; + } + return env; +} + function log(message) { console.log(`[journey-local] ${message}`); } + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/cli-journey-e2e/tsconfig.json b/apps/cli-journey-e2e/tsconfig.json index 09cfb48ee..edbc8e053 100644 --- a/apps/cli-journey-e2e/tsconfig.json +++ b/apps/cli-journey-e2e/tsconfig.json @@ -8,6 +8,7 @@ }, "include": [ "playwright.config.mts", + "scripts/**/*.mjs", "src/**/*.spec.ts", "src/**/*.d.ts" ] diff --git a/apps/cli/README.md b/apps/cli/README.md index 79385feaf..593524964 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -4,7 +4,7 @@ Scaffolds Zitadel auth (login, register, profile, middleware) into a Next.js app ```sh npx @zitadel/cli@alpha start -npx @zitadel/cli@alpha setup --framework next --server local +npx @zitadel/cli@alpha setup --server local ``` > **Beta.** This is the **next-generation Zitadel**, a ground-up rewrite of the platform. It is distinct from the established Zitadel at [github.com/zitadel/zitadel](https://github.com/zitadel/zitadel). APIs and CLI flags will change. @@ -13,16 +13,16 @@ npx @zitadel/cli@alpha setup --framework next --server local - Node 20+ - Docker for the managed local Zitadel runtime -- A Next.js project created with `create-next-app` +- A Next.js project, or an empty directory where setup can scaffold one ## Quickstart ```sh -npx create-next-app@latest my-app +mkdir my-app cd my-app npx @zitadel/cli@alpha doctor npx @zitadel/cli@alpha start -npx @zitadel/cli@alpha setup --framework next --server local +npx @zitadel/cli@alpha setup --server local npm run dev ``` @@ -31,13 +31,14 @@ under `.zitadel/local/`. Alpha CLI versions use the matching `ghcr.io/zitadel/nextgen:` image by default; local/dev builds fall back to `ghcr.io/zitadel/nextgen:latest`. Override with `--image` or `ZITADEL_LOCAL_IMAGE` for advanced debugging. -`setup --server local` creates a project on that local server, scaffolds -`app/login`, `app/register`, and `middleware.ts`, writes `.env.local` and -`.zitadel/`, and installs dependencies with the detected package manager. Pass -`--skip-install` to install them yourself. The project's default user schema -and login flow are provisioned server-side at creation time, so the CLI does -not scaffold or upload them. Open `http://localhost:3000/login` to see the -login page. +`setup --server local` creates a project on that local server, asks which +framework to scaffold when the directory is fresh, writes the Next.js app into +the current directory, scaffolds `app/login`, `app/register`, and +`middleware.ts`, writes `.env.local` and `.zitadel/`, and installs dependencies +with the detected package manager. Pass `--skip-install` to install them +yourself. The project's default user schema and login flow are provisioned +server-side at creation time, so the CLI does not scaffold or upload them. Open +`http://localhost:3000/login` to see the login page. For a reproducible tester report, use the exact alpha train from the GitHub Release: @@ -45,7 +46,7 @@ Release: ```sh npx @zitadel/cli@0.1.0-alpha.N doctor npx @zitadel/cli@0.1.0-alpha.N start -npx @zitadel/cli@0.1.0-alpha.N setup --framework next --server local +npx @zitadel/cli@0.1.0-alpha.N setup --server local ``` The default project flow supports password registration/login, passkey diff --git a/apps/cli/SKILLS.md b/apps/cli/SKILLS.md index 447eeb087..e024fe512 100644 --- a/apps/cli/SKILLS.md +++ b/apps/cli/SKILLS.md @@ -50,7 +50,9 @@ layer, not the envelope. - `setup` — create a Zitadel project and scaffold local auth (routes, middleware, `.zitadel/**`, env templates). The project's default user schema and login flow are provisioned server-side at creation, so setup neither - scaffolds nor uploads them. Flags: `--framework`, `--renderer`. + scaffolds nor uploads them. Agents must pass `--framework` when scaffolding + into a fresh directory; interactive humans can omit it and choose from the + prompt. Flags: `--framework`, `--renderer`. - `plan` — validate config and preview the sync diff without mutating anything. - `apply` — validate and upload repo config to the platform. - `doctor` — verify generated app files and local state once `zitadel.json` @@ -98,6 +100,7 @@ then re-run `plan` and `apply`. Managed files carry a marker comment; `eject` removes only files that still carry it, preserving anything the user replaced. For app-local development, `--server local` resolves through `.zitadel/local/runtime.json` and requires a healthy `npx @zitadel/cli@alpha start` -runtime. `setup` installs dependencies with the detected package manager by -default; pass `--skip-install` when the agent or host workflow will install -dependencies separately. +runtime. Runtime-only `.zitadel/local/**` state does not block fresh +same-directory scaffolding. `setup` installs dependencies with the detected +package manager by default; pass `--skip-install` when the agent or host +workflow will install dependencies separately. diff --git a/apps/cli/src/commands/doctor/index.ts b/apps/cli/src/commands/doctor/index.ts index 47ce1f233..f27435613 100644 --- a/apps/cli/src/commands/doctor/index.ts +++ b/apps/cli/src/commands/doctor/index.ts @@ -5,11 +5,10 @@ import { ZitadelError } from "../../lib/errors"; import { dockerAvailable, imageAvailable } from "../../lib/local-server/docker"; import { DEFAULT_LOCAL_SERVER_PORT, - assertWritableDirectory, + assertLocalStateWritable, checkLocalServerHealth, defaultLocalServerImageForCliVersion, isPortAvailable, - localRuntimePaths, localServerUrl, readRuntimeMetadata, } from "../../lib/local-server/runtime"; @@ -223,9 +222,10 @@ async function runLocalRuntimeChecks( "state-dir", "Local state directory is writable", async () => { - const paths = localRuntimePaths(cwd); - await assertWritableDirectory(paths.dataDir); - return `${paths.dataDir} is writable`; + const probe = await assertLocalStateWritable(cwd); + return probe.checkedPath === probe.targetPath + ? `${probe.targetPath} is writable` + : `${probe.targetPath} can be created (${probe.checkedPath} is writable)`; }, "warn", ), diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index f1adeb480..733acefc2 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -4,6 +4,7 @@ import { ZitadelError } from "../lib/errors"; import { containerLogs, followContainerLogs } from "../lib/local-server/docker"; import { DEFAULT_LOCAL_SERVER_URL, readRuntimeMetadata } from "../lib/local-server/runtime"; import { BaseCommand, type JsonEnvelope } from "../lib/oclif"; +import { resolveCwd } from "../lib/paths"; import { publicCliCommand } from "../lib/public-cli"; export default class Logs extends BaseCommand { @@ -15,7 +16,8 @@ export default class Logs extends BaseCommand { async run(): Promise { const { flags } = await this.parse(Logs); - const runtime = await readRuntimeMetadata(flags.cwd ? String(flags.cwd) : process.cwd()); + const cwd = resolveCwd(typeof flags.cwd === "string" ? flags.cwd : undefined); + const runtime = await readRuntimeMetadata(cwd); await this.toMeta(flags, { resolveServer: false, source: runtime?.server_url ?? DEFAULT_LOCAL_SERVER_URL, diff --git a/apps/cli/src/commands/reset.ts b/apps/cli/src/commands/reset.ts index e7757aaf9..5de1b77d7 100644 --- a/apps/cli/src/commands/reset.ts +++ b/apps/cli/src/commands/reset.ts @@ -10,6 +10,7 @@ import { removeRuntimeMetadata, } from "../lib/local-server/runtime"; import { BaseCommand, type JsonEnvelope } from "../lib/oclif"; +import { resolveCwd } from "../lib/paths"; import { publicCliCommand } from "../lib/public-cli"; export default class Reset extends BaseCommand { @@ -17,7 +18,8 @@ export default class Reset extends BaseCommand { async run(): Promise { const { flags } = await this.parse(Reset); - const runtime = await readRuntimeMetadata(flags.cwd ? String(flags.cwd) : process.cwd()); + const cwd = resolveCwd(typeof flags.cwd === "string" ? flags.cwd : undefined); + const runtime = await readRuntimeMetadata(cwd); await this.toMeta(flags, { resolveServer: false, source: runtime?.server_url ?? DEFAULT_LOCAL_SERVER_URL, diff --git a/apps/cli/src/commands/setup/index.ts b/apps/cli/src/commands/setup/index.ts index b7eba979a..4d6a378d7 100644 --- a/apps/cli/src/commands/setup/index.ts +++ b/apps/cli/src/commands/setup/index.ts @@ -88,9 +88,9 @@ export default class Setup extends BaseCommand { if ( error instanceof ZitadelError && error.code === "E_FRAMEWORK_NOT_DETECTED" && - (await orca.isEmpty(cwd)) + (await orca.isFreshScaffoldTarget(cwd)) ) { - consola.info("Empty directory — scaffolding a fresh project"); + consola.info("Fresh app directory — scaffolding a fresh project"); framework = await orca.scaffold( cwd, await resolveScaffoldFramework(flags.framework, nonInteractive, orca), diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index fd27a339e..89d2b8ee7 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -7,6 +7,7 @@ import { runtimeSummary, } from "../lib/local-server/runtime"; import { BaseCommand, type JsonEnvelope } from "../lib/oclif"; +import { resolveCwd } from "../lib/paths"; import { hasZitadelConfig, hasZitadelSecret, @@ -28,7 +29,8 @@ export default class Status extends BaseCommand { async run(): Promise { const { flags } = await this.parse(Status); - const runtime = await readRuntimeMetadata(flags.cwd ? String(flags.cwd) : process.cwd()); + const cwd = resolveCwd(typeof flags.cwd === "string" ? flags.cwd : undefined); + const runtime = await readRuntimeMetadata(cwd); if (flags.server === "local") { await this.toMeta(flags, { resolveServer: false, @@ -37,9 +39,7 @@ export default class Status extends BaseCommand { } else { await this.toMeta(flags); } - const { cwd } = this.meta; - - const containerName = runtime?.container_name ?? localContainerName(cwd); + const containerName = runtime?.container_name ?? localContainerName(this.meta.cwd); let docker: Awaited> | undefined; let dockerError: string | undefined; try { @@ -56,7 +56,7 @@ export default class Status extends BaseCommand { : "stopped" : "missing"; - const project = await projectStatus(cwd); + const project = await projectStatus(this.meta.cwd); const nextCommands = nextCommandsFor(serverLifecycle, project.lifecycle, this.meta.cliVersion); const nextActions = nextActionsFor(project.lifecycle); diff --git a/apps/cli/src/commands/stop.ts b/apps/cli/src/commands/stop.ts index 57e320272..166f8efc6 100644 --- a/apps/cli/src/commands/stop.ts +++ b/apps/cli/src/commands/stop.ts @@ -5,6 +5,7 @@ import { readRuntimeMetadata, } from "../lib/local-server/runtime"; import { BaseCommand, type JsonEnvelope } from "../lib/oclif"; +import { resolveCwd } from "../lib/paths"; import { publicCliCommand } from "../lib/public-cli"; export default class Stop extends BaseCommand { @@ -12,7 +13,8 @@ export default class Stop extends BaseCommand { async run(): Promise { const { flags } = await this.parse(Stop); - const runtime = await readRuntimeMetadata(flags.cwd ? String(flags.cwd) : process.cwd()); + const cwd = resolveCwd(typeof flags.cwd === "string" ? flags.cwd : undefined); + const runtime = await readRuntimeMetadata(cwd); await this.toMeta(flags, { resolveServer: false, source: runtime?.server_url ?? DEFAULT_LOCAL_SERVER_URL, diff --git a/apps/cli/src/lib/local-server/runtime.ts b/apps/cli/src/lib/local-server/runtime.ts index a93f778b6..4143fe79b 100644 --- a/apps/cli/src/lib/local-server/runtime.ts +++ b/apps/cli/src/lib/local-server/runtime.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; -import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { constants } from "node:fs"; import { createServer } from "node:net"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { ZitadelError } from "../errors"; import { isObject, parseJsonObject } from "../json"; @@ -39,6 +39,11 @@ export type LocalRuntimePaths = { containerGroupFile: string; }; +export type WritablePathProbe = { + targetPath: string; + checkedPath: string; +}; + export type ContainerIdentity = { uid: number; gid: number; @@ -80,6 +85,13 @@ export async function ensureLocalState(cwd: string): Promise return paths; } +export async function assertLocalStateWritable(cwd: string): Promise { + const paths = localRuntimePaths(cwd); + const checkedPath = await nearestExistingDirectory(paths.dataDir); + await access(checkedPath, constants.W_OK); + return { targetPath: paths.dataDir, checkedPath }; +} + export async function ensureContainerIdentity( cwd: string, user: { uid?: number; gid?: number }, @@ -249,6 +261,28 @@ export async function assertWritableDirectory(path: string): Promise { await access(path, constants.W_OK); } +async function nearestExistingDirectory(path: string): Promise { + let current = path; + while (true) { + try { + const info = await stat(current); + if (!info.isDirectory()) { + throw new Error(`${current} exists but is not a directory`); + } + return current; + } catch (error) { + if (!isErrno(error, "ENOENT")) { + throw error; + } + const parent = dirname(current); + if (parent === current) { + throw error; + } + current = parent; + } + } +} + function isErrno(error: unknown, code: string): boolean { return ( typeof error === "object" && diff --git a/apps/cli/src/lib/orca/index.ts b/apps/cli/src/lib/orca/index.ts index 70f47a571..498455ae5 100644 --- a/apps/cli/src/lib/orca/index.ts +++ b/apps/cli/src/lib/orca/index.ts @@ -1,5 +1,5 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; import { ZitadelError } from "../errors"; import { detectors } from "./detectors"; @@ -15,6 +15,13 @@ export { issuerFromPort } from "./detectors/port"; /** One framework the CLI can scaffold from scratch, surfaced to the picker. */ export type FrameworkChoice = Readonly<{ id: string; displayName: string }>; +export type ScaffoldTarget = Readonly<{ + scaffoldable: boolean; + hasRuntimeOnlyZitadel: boolean; + reason?: string; + entries: ReadonlyArray; +}>; + /** * Orchestrates the three per-framework strategies — detectors (recognise an * existing project and extract its facts), scaffolders (create a project), and @@ -79,17 +86,9 @@ export class Orca { } } - /** - * Whether `cwd` has no `package.json`, i.e. it is empty (or non-Node) and - * should be scaffolded from scratch rather than detected/patched. - */ - async isEmpty(cwd: string): Promise { - try { - await readFile(join(cwd, "package.json"), "utf8"); - return false; - } catch { - return true; - } + /** Whether `cwd` is safe for an in-place framework scaffold. */ + async isFreshScaffoldTarget(cwd: string): Promise { + return (await inspectScaffoldTarget(cwd)).scaffoldable; } /** @@ -99,12 +98,21 @@ export class Orca { * scaffolder supports the framework. */ async scaffold(cwd: string, framework: string): Promise { - if (!(await this.isEmpty(cwd))) { - throw new ZitadelError("E_CONFLICT", `Cannot scaffold: ${cwd} already contains a project`, { - hint: "Run setup in an empty directory, or integrate the existing project instead.", + const target = await inspectScaffoldTarget(cwd); + if (!target.scaffoldable) { + throw new ZitadelError("E_CONFLICT", `Cannot scaffold: ${cwd} is not empty`, { + hint: + target.reason ?? + "Run setup in an empty directory, or run setup from an existing supported app project.", + details: { entries: target.entries }, }); } - await this.scaffolderFor(framework).scaffold(cwd, framework); + const stash = target.hasRuntimeOnlyZitadel ? await stashRuntimeOnlyZitadel(cwd) : undefined; + try { + await this.scaffolderFor(framework).scaffold(cwd, framework); + } finally { + await restoreRuntimeOnlyZitadel(cwd, stash); + } return this.detect(cwd, framework); } @@ -155,3 +163,112 @@ export class Orca { export function createOrca(): Orca { return new Orca(detectors, scaffolders, patchers); } + +export async function inspectScaffoldTarget(cwd: string): Promise { + const entries = await readdir(cwd, { withFileTypes: true }); + const names = entries.map((entry) => entry.name).sort(); + let hasRuntimeOnlyZitadel = false; + + for (const entry of entries) { + if (entry.name === ".gitignore") { + if (!entry.isFile()) { + return { + scaffoldable: false, + hasRuntimeOnlyZitadel: false, + reason: ".gitignore exists but is not a file.", + entries: names, + }; + } + continue; + } + + if (entry.name === ".zitadel") { + if (!entry.isDirectory() || !(await isRuntimeOnlyZitadelDir(join(cwd, ".zitadel")))) { + return { + scaffoldable: false, + hasRuntimeOnlyZitadel: false, + reason: + ".zitadel contains project state. Move it aside or run setup from an empty app directory.", + entries: names, + }; + } + hasRuntimeOnlyZitadel = true; + continue; + } + + return { + scaffoldable: false, + hasRuntimeOnlyZitadel: false, + reason: `Directory contains ${entry.name}. Run setup from an empty directory to scaffold a new app.`, + entries: names, + }; + } + + return { scaffoldable: true, hasRuntimeOnlyZitadel, entries: names }; +} + +async function isRuntimeOnlyZitadelDir(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }); + if (entries.length !== 1 || entries[0]?.name !== "local" || !entries[0].isDirectory()) { + return false; + } + return true; +} + +async function stashRuntimeOnlyZitadel(cwd: string): Promise { + const source = join(cwd, ".zitadel"); + const parent = dirname(cwd); + const prefix = `.${basename(cwd)}.zitadel-local-stash`; + const stash = join(parent, `${prefix}-${String(process.pid)}-${String(Date.now())}`); + await rename(source, stash); + return stash; +} + +async function restoreRuntimeOnlyZitadel(cwd: string, stash: string | undefined): Promise { + if (!stash) { + return; + } + const target = join(cwd, ".zitadel"); + try { + await rename(stash, target); + await appendGitignoreEntry(cwd, ".zitadel/local/"); + return; + } catch (error) { + if (!isErrno(error, "EEXIST")) { + throw error; + } + } + + await mkdir(target, { recursive: true, mode: 0o700 }); + await rename(join(stash, "local"), join(target, "local")); + await rm(stash, { recursive: true, force: true }); + await appendGitignoreEntry(cwd, ".zitadel/local/"); +} + +async function appendGitignoreEntry(cwd: string, entry: string): Promise { + const path = join(cwd, ".gitignore"); + let existing = ""; + try { + existing = await readFile(path, "utf8"); + } catch (error) { + if (!isErrno(error, "ENOENT")) { + throw error; + } + } + + const lines = existing.split(/\r?\n/g).map((line) => line.trim()); + if (lines.includes(entry)) { + return; + } + const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n"; + await writeFile(path, `${existing}${prefix}${entry}\n`); +} + +function isErrno(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} diff --git a/apps/cli/tests/unit/commands/local-runtime.test.ts b/apps/cli/tests/unit/commands/local-runtime.test.ts index 9014f3d47..117a3b1bc 100644 --- a/apps/cli/tests/unit/commands/local-runtime.test.ts +++ b/apps/cli/tests/unit/commands/local-runtime.test.ts @@ -47,6 +47,7 @@ describe("local runtime commands", () => { const envelope = parseJson(result.stdout) as { status: string; data: { ok: boolean } }; expect(envelope.status).toBe("ok"); expect(envelope.data.ok).toBe(true); + await expect(stat(join(cwd, ".zitadel"))).rejects.toMatchObject({ code: "ENOENT" }); const dockerCalls = await readDockerCalls(fake.logPath); expect(dockerCalls).toContainEqual(["image", "inspect", defaultImage]); diff --git a/apps/cli/tests/unit/commands/setup/index.test.ts b/apps/cli/tests/unit/commands/setup/index.test.ts index c56a07abc..8fea3e53a 100644 --- a/apps/cli/tests/unit/commands/setup/index.test.ts +++ b/apps/cli/tests/unit/commands/setup/index.test.ts @@ -1,9 +1,16 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + localContainerName, + localRuntimePaths, + writeRuntimeMetadata, + type RuntimeMetadata, +} from "../../../../src/lib/local-server/runtime"; import { parseJson, runCliForTest } from "../../../helpers/run-cli"; /** @@ -15,6 +22,7 @@ import { parseJson, runCliForTest } from "../../../helpers/run-cli"; * api-mock. */ const tempDirs: string[] = []; +const servers: Server[] = []; async function makeTempDir(): Promise { const cwd = await mkdtemp(join(tmpdir(), "zitadel-setup-test-")); @@ -23,6 +31,9 @@ async function makeTempDir(): Promise { } afterEach(async () => { + for (const server of servers.splice(0)) { + await new Promise((resolve) => server.close(() => resolve())); + } while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -69,6 +80,28 @@ describe("setup command pre-flight", () => { expect(json.hint).toContain("--framework"); }); + it("still requires --framework in non-interactive local setup after runtime start", async () => { + const cwd = await makeTempDir(); + const serverUrl = await startHealthServer(); + await writeRuntimeMetadata(cwd, runtimeFor(cwd, serverUrl)); + + const res = await runCliForTest([ + "setup", + "--cwd", + cwd, + "--server", + "local", + "--non-interactive", + "--json", + ]); + + expect(res.exitCode).not.toBe(0); + const json = parseJson(res.stdout) as { status: string; code: string; hint?: string }; + expect(json.status).toBe("error"); + expect(json.code).toBe("E_FRAMEWORK_NOT_DETECTED"); + expect(json.hint).toContain("--framework"); + }); + it("throws E_FRAMEWORK_NOT_DETECTED for a non-empty dir whose framework can't be inferred", async () => { const cwd = await makeTempDir(); // A non-empty dir that isn't a known framework — Orca's detector fails @@ -83,3 +116,34 @@ describe("setup command pre-flight", () => { expect(json.code).toBe("E_FRAMEWORK_NOT_DETECTED"); }); }); + +async function startHealthServer(): Promise { + const server = createServer((req, res) => { + if (req.url === "/healthz") { + res.writeHead(200).end("ok"); + return; + } + res.writeHead(404).end(); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "localhost", () => resolve())); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("health server did not expose a TCP address"); + } + return `http://localhost:${String(address.port)}`; +} + +function runtimeFor(cwd: string, serverUrl: string): RuntimeMetadata { + return { + schema_version: 1, + container_name: localContainerName(cwd), + container_id: "container-test-id", + image: "ghcr.io/zitadel/nextgen:test", + port: Number(new URL(serverUrl).port), + server_url: serverUrl, + data_dir: localRuntimePaths(cwd).dataDir, + created_at: "2026-06-09T00:00:00.000Z", + cli_version: "0.0.0-test", + }; +} diff --git a/apps/cli/tests/unit/commands/status.test.ts b/apps/cli/tests/unit/commands/status.test.ts index 9511941bc..165e24fbb 100644 --- a/apps/cli/tests/unit/commands/status.test.ts +++ b/apps/cli/tests/unit/commands/status.test.ts @@ -1,9 +1,15 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + localContainerName, + localRuntimePaths, + writeRuntimeMetadata, + type RuntimeMetadata, +} from "../../../src/lib/local-server/runtime"; import { expectedPublicCliCommand, parseJson, runCliForTest } from "../../helpers/run-cli"; const SECRET = { @@ -114,6 +120,22 @@ describe("status command", () => { expect(json.data.next_commands).toContain(expectedPublicCliCommand("setup --server local")); }); + it("normalizes relative --cwd before reading runtime metadata", async () => { + const cwd = await makeProject(); + await writeRuntimeMetadata(cwd, runtimeFor(cwd, "http://localhost:9")); + + const res = await status(relative(process.cwd(), cwd)); + + expect(res.exitCode).toBe(0); + const json = parseJson(res.stdout) as { + status: string; + data: { server: { runtime: { configured: boolean; data_dir?: string } } }; + }; + expect(json.status).toBe("ok"); + expect(json.data.server.runtime.configured).toBe(true); + expect(json.data.server.runtime.data_dir).toBe(localRuntimePaths(cwd).dataDir); + }); + it("falls back to the secret project_id when config.project is absent", async () => { const cwd = await makeProject(); await writeFile( @@ -135,3 +157,17 @@ describe("status command", () => { expect(json.data.project.issuer).toBeUndefined(); }); }); + +function runtimeFor(cwd: string, serverUrl: string): RuntimeMetadata { + return { + schema_version: 1, + container_name: localContainerName(cwd), + container_id: "container-test-id", + image: "ghcr.io/zitadel/nextgen:test", + port: Number(new URL(serverUrl).port), + server_url: serverUrl, + data_dir: localRuntimePaths(cwd).dataDir, + created_at: "2026-06-09T00:00:00.000Z", + cli_version: "0.0.0-test", + }; +} diff --git a/apps/cli/tests/unit/lib/orca/index.test.ts b/apps/cli/tests/unit/lib/orca/index.test.ts index e995fa670..82ed9e55a 100644 --- a/apps/cli/tests/unit/lib/orca/index.test.ts +++ b/apps/cli/tests/unit/lib/orca/index.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -79,9 +79,39 @@ describe("Orca detection", () => { expect(await orca.tryDetect(await nextProject())).toMatchObject({ id: "next" }); }); - it("isEmpty reflects the presence of package.json", async () => { - expect(await orca.isEmpty(await tmp())).toBe(true); - expect(await orca.isEmpty(await nextProject())).toBe(false); + it("treats an empty directory as a fresh scaffold target", async () => { + expect(await orca.isFreshScaffoldTarget(await tmp())).toBe(true); + }); + + it("allows .gitignore in a fresh scaffold target", async () => { + const cwd = await tmp(); + await writeFile(join(cwd, ".gitignore"), ".zitadel/local/\n"); + + expect(await orca.isFreshScaffoldTarget(cwd)).toBe(true); + }); + + it("allows runtime-only .zitadel/local state in a fresh scaffold target", async () => { + const cwd = await tmp(); + await writeFile(join(cwd, ".gitignore"), ".zitadel/local/\n"); + await mkdir(join(cwd, ".zitadel/local"), { recursive: true }); + await writeFile(join(cwd, ".zitadel/local/runtime.json"), "{}"); + + expect(await orca.isFreshScaffoldTarget(cwd)).toBe(true); + }); + + it("rejects project state and arbitrary files as fresh scaffold targets", async () => { + const withSecret = await tmp(); + await mkdir(join(withSecret, ".zitadel"), { recursive: true }); + await writeFile(join(withSecret, ".zitadel/secret"), "{}"); + + const withReadme = await tmp(); + await writeFile(join(withReadme, "README.md"), "not empty"); + + const withPackageJson = await nextProject(); + + await expect(orca.isFreshScaffoldTarget(withSecret)).resolves.toBe(false); + await expect(orca.isFreshScaffoldTarget(withReadme)).resolves.toBe(false); + await expect(orca.isFreshScaffoldTarget(withPackageJson)).resolves.toBe(false); }); }); @@ -115,6 +145,33 @@ describe("Orca.scaffold", () => { await expect(readFile(join(cwd, "package.json"), "utf8")).resolves.toBe("{}"); }); + it("scaffolds in place while preserving runtime-only .zitadel/local state", async () => { + const cwd = await tmp(); + await writeFile(join(cwd, ".gitignore"), ".zitadel/local/\n"); + await mkdir(join(cwd, ".zitadel/local"), { recursive: true }); + await writeFile(join(cwd, ".zitadel/local/runtime.json"), '{"server_url":"http://localhost"}'); + + const guardedScaffolder: Scaffolder = { + ...fakeScaffolder, + async scaffold(scaffoldCwd) { + await expect(stat(join(scaffoldCwd, ".zitadel"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await writeFile(join(scaffoldCwd, ".gitignore"), "node_modules\n"); + await writeFile(join(scaffoldCwd, "package.json"), "{}"); + }, + }; + const guardedOrca = new Orca([fakeDetector], [guardedScaffolder], []); + + await expect(guardedOrca.scaffold(cwd, "fake")).resolves.toMatchObject({ id: "fake" }); + await expect(readFile(join(cwd, "package.json"), "utf8")).resolves.toBe("{}"); + await expect(readFile(join(cwd, ".zitadel/local/runtime.json"), "utf8")).resolves.toContain( + "localhost", + ); + await expect(readFile(join(cwd, ".gitignore"), "utf8")).resolves.toContain(".zitadel/local/"); + await expect(stat(join(cwd, "myapp"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("throws E_CONFLICT when the directory already contains a project", async () => { const cwd = await tmp(); await writeFile(join(cwd, "package.json"), "{}"); diff --git a/docs/quick-start/index.md b/docs/quick-start/index.md index 463f992ac..01776b2db 100644 --- a/docs/quick-start/index.md +++ b/docs/quick-start/index.md @@ -11,14 +11,13 @@ local Zitadel runtime. ## Steps ```sh -npx create-next-app@latest myapp +mkdir myapp cd myapp npx @zitadel/cli@alpha doctor npx @zitadel/cli@alpha start -npx @zitadel/cli@alpha setup --framework next --server local +npx @zitadel/cli@alpha setup --server local -npm install npm run dev ``` @@ -30,7 +29,9 @@ http://localhost:3000/login The managed local Zitadel server listens on http://localhost:8080 by default. The CLI stores runtime metadata in `.zitadel/local/runtime.json` and mounts -`.zitadel/local/nextgen-data` into the container. Stop preserves that data: +`.zitadel/local/nextgen-data` into the container. If you start from a fresh +directory, `setup --server local` asks which framework to scaffold and writes +the app into the current directory. Stop preserves runtime data: ```sh npx @zitadel/cli@alpha stop diff --git a/docs/runbooks/release-alpha-train.md b/docs/runbooks/release-alpha-train.md index 2a2d67b3b..b6ef3a973 100644 --- a/docs/runbooks/release-alpha-train.md +++ b/docs/runbooks/release-alpha-train.md @@ -37,7 +37,7 @@ Latest alpha stream: ```sh npx @zitadel/cli@alpha doctor npx @zitadel/cli@alpha start -npx @zitadel/cli@alpha setup --framework next --server local +npx @zitadel/cli@alpha setup --server local ``` Exact reproducible train: @@ -45,7 +45,7 @@ Exact reproducible train: ```sh npx @zitadel/cli@0.1.0-alpha.N doctor npx @zitadel/cli@0.1.0-alpha.N start -npx @zitadel/cli@0.1.0-alpha.N setup --framework next --server local +npx @zitadel/cli@0.1.0-alpha.N setup --server local ``` For `@zitadel/cli@0.1.0-alpha.N`, the CLI starts