diff --git a/.github/actions/publish-preview-realm/action.yml b/.github/actions/publish-preview-realm/action.yml new file mode 100644 index 0000000000..6db4e6b049 --- /dev/null +++ b/.github/actions/publish-preview-realm/action.yml @@ -0,0 +1,188 @@ +name: Publish preview realm +description: | + End-to-end preview-realm deploy for a PR: idempotently create a source + realm, push local content into it, and publish it at a public URL. The + publish step accepts the realm-server's 202 + status:pending contract + and polls /_readiness-check until the realm is mounted and indexed. + Replaces the matrix→openid→server-session curl block + manual 200/201 + check that boxel-home maintained in preview-realm.yml. + +inputs: + realm-name: + description: | + Endpoint slug for the source realm. Lowercase letters, digits, and + hyphens only. The source realm URL will be + `${realm-server-url}/${matrix-username}/${realm-name}/`. + required: true + display-name: + description: Human-readable display name for the source realm. + required: true + published-realm-url: + description: | + Public URL the published realm will serve at, e.g. + `https://${user}.staging.boxel.dev/boxel-home-pr-57/`. Must satisfy + the realm-server's `domainsForPublishedRealms` allow-list. + required: true + source-path: + description: Local directory whose contents are pushed to the source realm. + required: false + default: "." + matrix-url: + description: Matrix server URL. + required: true + matrix-username: + description: Matrix username without the leading @ or :domain suffix. + required: true + matrix-password: + description: Matrix password. + required: true + realm-server-url: + description: | + Realm-server URL. When empty, the action infers it from matrix-url + for the boxel.ai (production) and matrix-staging.stack.cards + (staging) cases only. Supply this explicitly for any other + environment, including local dev (e.g. http://localhost:4201/). + required: false + default: "" + readiness-timeout-ms: + description: | + Maximum time to wait for the published realm to pass its readiness + check before failing the action. Default: 300000 (5 minutes). + required: false + default: "300000" + +outputs: + source-realm-url: + description: URL of the source realm that was created / synced. + value: ${{ steps.urls.outputs.source-realm-url }} + published-realm-url: + description: URL the realm is published at. + value: ${{ inputs.published-realm-url }} + +# The first seven steps duplicate the same setup-boxel-cli scaffolding in +# workspace-sync and unpublish-preview-realm: a composite action's +# `uses:` ref must be a literal string at parse time (no expressions), so +# we cannot delegate to a sibling action and have it run at the same ref +# the consumer pinned. Keep them in sync when changing any one. +runs: + using: composite + steps: + - name: Checkout boxel monorepo for boxel-cli source + shell: bash + env: + BOXEL_REPO: ${{ github.action_repository }} + BOXEL_REF: ${{ github.action_ref }} + run: | + SRC="${RUNNER_TEMP}/boxel-src" + rm -rf "$SRC" + git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" + git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" + git -C "$SRC" checkout FETCH_HEAD + echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" + + - name: Install mise (node + pnpm) + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 + with: + install: true + working_directory: ${{ env.BOXEL_SRC }} + + - name: Cache pnpm store + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} + restore-keys: | + ${{ runner.os }}-boxel-pnpm- + + - name: Install workspace dependencies + shell: bash + working-directory: ${{ env.BOXEL_SRC }} + run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... + + - name: Build boxel-cli + shell: bash + working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli + run: pnpm build + + - name: Make `boxel` available on PATH + shell: bash + # The bin directory contains `boxel.js` (the file package.json's + # `bin` field maps to `boxel`). Just prepending the directory to + # PATH would expose `boxel.js`, not `boxel`. Symlink the entry + # point under /usr/local/bin so consumers can call `boxel …` the + # way npm/pnpm-installed callers do. + run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel + + - name: Configure boxel profile + shell: bash + env: + BOXEL_PASSWORD: ${{ inputs.matrix-password }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + run: | + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + DOMAIN="boxel.ai" + elif echo "$MATRIX_URL" | grep -q "stack.cards"; then + DOMAIN="stack.cards" + else + DOMAIN="localhost" + fi + ARGS=( + profile add + --user "@${MATRIX_USERNAME}:${DOMAIN}" + --password "$BOXEL_PASSWORD" + --matrix-url "$MATRIX_URL" + ) + if [ -n "$REALM_SERVER_URL" ]; then + ARGS+=(--realm-server-url "$REALM_SERVER_URL") + fi + boxel "${ARGS[@]}" + + - id: urls + shell: bash + env: + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_NAME: ${{ inputs.realm-name }} + run: | + # Mirror boxel profile add's environment-default logic so the + # computed source URL matches the profile's realmServerUrl. + if [ -z "$REALM_SERVER_URL" ]; then + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + REALM_SERVER_URL="https://app.boxel.ai/" + elif echo "$MATRIX_URL" | grep -q "matrix-staging.stack.cards"; then + REALM_SERVER_URL="https://realms-staging.stack.cards/" + else + echo "::error::realm-server-url must be supplied for matrix-url=$MATRIX_URL" + exit 1 + fi + fi + BASE="${REALM_SERVER_URL%/}" + SOURCE_URL="${BASE}/${MATRIX_USERNAME}/${REALM_NAME}/" + echo "source-realm-url=${SOURCE_URL}" >> "$GITHUB_OUTPUT" + echo "Source realm URL: ${SOURCE_URL}" + + - name: Create source realm if absent + shell: bash + env: + REALM_NAME: ${{ inputs.realm-name }} + DISPLAY_NAME: ${{ inputs.display-name }} + run: boxel realm create "$REALM_NAME" "$DISPLAY_NAME" + + - name: Push content to source realm + shell: bash + env: + SOURCE_PATH: ${{ inputs.source-path }} + SOURCE_URL: ${{ steps.urls.outputs.source-realm-url }} + run: boxel realm push "$SOURCE_PATH" "$SOURCE_URL" --delete + + - name: Publish + wait for readiness + shell: bash + env: + SOURCE_URL: ${{ steps.urls.outputs.source-realm-url }} + PUBLISHED_URL: ${{ inputs.published-realm-url }} + TIMEOUT_MS: ${{ inputs.readiness-timeout-ms }} + run: | + boxel realm publish "$SOURCE_URL" "$PUBLISHED_URL" --timeout "$TIMEOUT_MS" diff --git a/.github/actions/unpublish-preview-realm/action.yml b/.github/actions/unpublish-preview-realm/action.yml new file mode 100644 index 0000000000..a8efce2cf4 --- /dev/null +++ b/.github/actions/unpublish-preview-realm/action.yml @@ -0,0 +1,111 @@ +name: Unpublish preview realm +description: | + Unpublish a preview realm via `boxel realm unpublish`. Tolerates the + "not currently published" case so PR-close cleanup can run + unconditionally without failing when an earlier run already removed it. + +inputs: + published-realm-url: + description: Public-facing URL of the published realm to remove. + required: true + matrix-url: + description: Matrix server URL. + required: true + matrix-username: + description: Matrix username without the leading @ or :domain suffix. + required: true + matrix-password: + description: Matrix password. + required: true + realm-server-url: + description: | + Realm-server URL. Defaults inferred from matrix-url for + stack.cards / boxel.ai / localhost domains; supply this for any + other environment. + required: false + default: "" + +# The first seven steps duplicate the same setup-boxel-cli scaffolding in +# workspace-sync and publish-preview-realm: a composite action's `uses:` +# ref must be a literal string at parse time (no expressions), so we +# cannot delegate to a sibling action and have it run at the same ref the +# consumer pinned. Keep them in sync when changing any one. +runs: + using: composite + steps: + - name: Checkout boxel monorepo for boxel-cli source + shell: bash + env: + BOXEL_REPO: ${{ github.action_repository }} + BOXEL_REF: ${{ github.action_ref }} + run: | + SRC="${RUNNER_TEMP}/boxel-src" + rm -rf "$SRC" + git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" + git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" + git -C "$SRC" checkout FETCH_HEAD + echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" + + - name: Install mise (node + pnpm) + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 + with: + install: true + working_directory: ${{ env.BOXEL_SRC }} + + - name: Cache pnpm store + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} + restore-keys: | + ${{ runner.os }}-boxel-pnpm- + + - name: Install workspace dependencies + shell: bash + working-directory: ${{ env.BOXEL_SRC }} + run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... + + - name: Build boxel-cli + shell: bash + working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli + run: pnpm build + + - name: Make `boxel` available on PATH + shell: bash + # The bin directory contains `boxel.js` (the file package.json's + # `bin` field maps to `boxel`). Just prepending the directory to + # PATH would expose `boxel.js`, not `boxel`. Symlink the entry + # point under /usr/local/bin so consumers can call `boxel …` the + # way npm/pnpm-installed callers do. + run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel + + - name: Configure boxel profile + shell: bash + env: + BOXEL_PASSWORD: ${{ inputs.matrix-password }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + run: | + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + DOMAIN="boxel.ai" + elif echo "$MATRIX_URL" | grep -q "stack.cards"; then + DOMAIN="stack.cards" + else + DOMAIN="localhost" + fi + ARGS=( + profile add + --user "@${MATRIX_USERNAME}:${DOMAIN}" + --password "$BOXEL_PASSWORD" + --matrix-url "$MATRIX_URL" + ) + if [ -n "$REALM_SERVER_URL" ]; then + ARGS+=(--realm-server-url "$REALM_SERVER_URL") + fi + boxel "${ARGS[@]}" + + - shell: bash + env: + PUBLISHED_URL: ${{ inputs.published-realm-url }} + run: boxel realm unpublish "$PUBLISHED_URL" --tolerate-missing diff --git a/.github/actions/workspace-sync/action.yml b/.github/actions/workspace-sync/action.yml new file mode 100644 index 0000000000..f7022c4b96 --- /dev/null +++ b/.github/actions/workspace-sync/action.yml @@ -0,0 +1,151 @@ +name: Sync to Boxel workspace +description: | + Sync a local directory to a Boxel workspace using `boxel realm push` + from the in-tree @cardstack/boxel-cli. Replaces hand-rolled curl / + boxel-cli-clone steps that boxel-home, boxel-catalog, and boxel-skills + each used to maintain independently. + +inputs: + workspace-url: + description: Target workspace URL. + required: true + matrix-url: + description: Matrix server URL. + required: true + matrix-username: + description: Matrix username without the leading @ or :domain suffix. + required: true + matrix-password: + description: Matrix password. + required: true + realm-server-url: + description: | + Optional realm-server URL. Defaults are inferred from matrix-url for + stack.cards / boxel.ai / localhost domains; supply this for any other + environment. + required: false + default: "" + source-path: + description: Local directory to push to the workspace. + required: false + default: "." + delete: + description: When 'true', delete remote files that do not exist locally. + required: false + default: "true" + dry-run: + description: When 'true', show what would change without writing. + required: false + default: "false" + +outputs: + sync-output: + description: Captured stdout/stderr from `boxel realm push`. + value: ${{ steps.sync.outputs.sync-output }} + +# The first seven steps duplicate the same setup-boxel-cli scaffolding in +# publish-preview-realm and unpublish-preview-realm: a composite action's +# `uses:` ref must be a literal string at parse time (no expressions), so +# we cannot delegate to a sibling action and have it run at the same ref +# the consumer pinned. Keep them in sync when changing any one. +runs: + using: composite + steps: + - name: Checkout boxel monorepo for boxel-cli source + shell: bash + env: + BOXEL_REPO: ${{ github.action_repository }} + BOXEL_REF: ${{ github.action_ref }} + run: | + SRC="${RUNNER_TEMP}/boxel-src" + rm -rf "$SRC" + git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" + git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" + git -C "$SRC" checkout FETCH_HEAD + echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" + + - name: Install mise (node + pnpm) + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 + with: + install: true + working_directory: ${{ env.BOXEL_SRC }} + + - name: Cache pnpm store + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} + restore-keys: | + ${{ runner.os }}-boxel-pnpm- + + - name: Install workspace dependencies + shell: bash + working-directory: ${{ env.BOXEL_SRC }} + run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... + + - name: Build boxel-cli + shell: bash + working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli + run: pnpm build + + - name: Make `boxel` available on PATH + shell: bash + # The bin directory contains `boxel.js` (the file package.json's + # `bin` field maps to `boxel`). Just prepending the directory to + # PATH would expose `boxel.js`, not `boxel`. Symlink the entry + # point under /usr/local/bin so consumers can call `boxel …` the + # way npm/pnpm-installed callers do. + run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel + + - name: Configure boxel profile + shell: bash + env: + BOXEL_PASSWORD: ${{ inputs.matrix-password }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + run: | + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + DOMAIN="boxel.ai" + elif echo "$MATRIX_URL" | grep -q "stack.cards"; then + DOMAIN="stack.cards" + else + DOMAIN="localhost" + fi + ARGS=( + profile add + --user "@${MATRIX_USERNAME}:${DOMAIN}" + --password "$BOXEL_PASSWORD" + --matrix-url "$MATRIX_URL" + ) + if [ -n "$REALM_SERVER_URL" ]; then + ARGS+=(--realm-server-url "$REALM_SERVER_URL") + fi + boxel "${ARGS[@]}" + + - id: sync + shell: bash + env: + SOURCE_PATH: ${{ inputs.source-path }} + WORKSPACE_URL: ${{ inputs.workspace-url }} + DELETE: ${{ inputs.delete }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + URL="${WORKSPACE_URL%/}/" + ARGS=(realm push "$SOURCE_PATH" "$URL") + if [ "$DELETE" = "true" ]; then ARGS+=(--delete); fi + if [ "$DRY_RUN" = "true" ]; then ARGS+=(--dry-run); fi + + set +e + OUTPUT=$(boxel "${ARGS[@]}" 2>&1) + STATUS=$? + set -e + echo "$OUTPUT" + + { + echo "sync-output<> "$GITHUB_OUTPUT" + + exit $STATUS diff --git a/.github/workflows/cs-11180-action-demo.yml b/.github/workflows/cs-11180-action-demo.yml new file mode 100644 index 0000000000..b8f2389c90 --- /dev/null +++ b/.github/workflows/cs-11180-action-demo.yml @@ -0,0 +1,130 @@ +name: CS-11180 action demo (TEMPORARY) + +# TEMPORARY: exercises the three new composite actions +# (workspace-sync, publish-preview-realm, unpublish-preview-realm) +# end-to-end against the same localhost matrix + realm-server stack +# that boxel-cli-test boots, so the PR shows them running in CI. +# Delete this file (and the test-web-assets call below) after the +# demo run is captured. The CLI commands these actions wrap live on +# the parent branch (CS-11161, PR #4851); this branch keeps those +# commits while the parent PR's review proceeds in parallel. + +on: + workflow_dispatch: + push: + branches: [cs-11180-extract-shared-preview-realm-github-actions-to-monorepo] + +concurrency: + group: cs-11180-action-demo-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test-web-assets: + name: Build test web assets + uses: ./.github/workflows/test-web-assets.yaml + with: + caller: cs-11180-action-demo + + action-demo: + name: Run new actions against local stack + needs: [test-web-assets] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/init + + - name: Download test web assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.test-web-assets.outputs.artifact_name }} + path: .test-web-assets-artifact + - name: Restore test web assets into workspace + shell: bash + run: | + shopt -s dotglob + cp -a .test-web-assets-artifact/. ./ + + - name: Start PostgreSQL + # register-all (below) writes EXTRA_USERS to the boxel users + # table, so postgres must be up before the matrix-side + # registration runs. boxel-cli-test gets away without this step + # because it uses register-realm-users (realms-only), which + # never touches the DB. + run: pnpm start:pg + working-directory: packages/realm-server + - name: Start Matrix + run: pnpm start:matrix + working-directory: packages/realm-server + - name: Wait for PostgreSQL to accept connections + run: timeout 60 bash -c 'until (echo > /dev/tcp/127.0.0.1/5435) >/dev/null 2>&1; do sleep 1; done' + - name: Run database migrations + # register-all calls ensureUserRecord which writes to the boxel + # users table. The boxel database (and that table) are created + # by the realm-server's migrations on first boot. Run them up + # front so the registration step has a target schema to write + # against. + run: pnpm migrate + working-directory: packages/realm-server + - name: Register matrix users (all) + # `register-realm-users` runs in realms-only mode and skips the + # `user`/`password` account this demo authenticates as. Use the + # `register-all` variant so EXTRA_USERS get created too. + run: pnpm register-all + working-directory: packages/matrix + - name: Start dev stack (base realm + prerenderer) + run: | + mise run test-services:matrix | tee -a /tmp/server.log & + # Realm-server and vite host both speak HTTPS+HTTP/2 only; `-k` + # tolerates the mkcert leaf cert from infra:ensure-dev-cert. The + # %{http_code} check guards against the dispatcher's 3xx ahead + # of the base realm finishing its initial index. + timeout 600 bash -c 'until curl -sk -o /dev/null -w "%{http_code}" https://localhost:4200/ | grep -qx 200 && curl -sk -o /dev/null -w "%{http_code}" -H "Accept: application/vnd.api+json" https://localhost:4201/base/_readiness-check | grep -qx 200; do sleep 2; done' + + - name: Build a small fixture to push + shell: bash + run: | + mkdir -p /tmp/cs-11161-fixture + cat > /tmp/cs-11161-fixture/README.md <<'EOF' + # CS-11161 action demo + Pushed by the temporary demo workflow to prove the new + workspace-sync / publish-preview-realm / unpublish-preview-realm + actions work end-to-end on a fresh runner. + EOF + + - name: Demo publish-preview-realm + id: publish_demo + uses: cardstack/boxel/.github/actions/publish-preview-realm@cs-11180-extract-shared-preview-realm-github-actions-to-monorepo + with: + realm-name: cs-11161-demo-${{ github.run_id }} + display-name: "CS-11161 demo realm" + published-realm-url: https://cs-11161-demo-${{ github.run_id }}.localhost:4201/ + source-path: /tmp/cs-11161-fixture + matrix-url: http://localhost:8008 + matrix-username: user + matrix-password: password + realm-server-url: https://localhost:4201/ + readiness-timeout-ms: "120000" + + - name: Demo workspace-sync (re-push to the just-created source realm) + uses: cardstack/boxel/.github/actions/workspace-sync@cs-11180-extract-shared-preview-realm-github-actions-to-monorepo + with: + workspace-url: ${{ steps.publish_demo.outputs.source-realm-url }} + source-path: /tmp/cs-11161-fixture + matrix-url: http://localhost:8008 + matrix-username: user + matrix-password: password + realm-server-url: https://localhost:4201/ + + - name: Demo unpublish-preview-realm + if: always() && steps.publish_demo.outcome == 'success' + uses: cardstack/boxel/.github/actions/unpublish-preview-realm@cs-11180-extract-shared-preview-realm-github-actions-to-monorepo + with: + published-realm-url: https://cs-11161-demo-${{ github.run_id }}.localhost:4201/ + matrix-url: http://localhost:8008 + matrix-username: user + matrix-password: password + realm-server-url: https://localhost:4201/ + + - name: Print server logs + if: ${{ !cancelled() }} + run: cat /tmp/server.log diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts index 82bd2fdd09..cdc3d6a146 100644 --- a/packages/boxel-cli/src/commands/realm/publish.ts +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -193,7 +193,7 @@ async function waitForPublishedRealmReady( } lastError = `HTTP ${response.status}`; } catch (error) { - lastError = error instanceof Error ? error.message : String(error); + lastError = describeFetchError(error); } let remaining = timeoutMs - (Date.now() - startedAt); if (remaining <= 0) break; @@ -217,6 +217,21 @@ async function safeReadResponseText(response: Response): Promise { } } +// Node's fetch error surface is shallow: the outer error is always +// `TypeError: fetch failed`, and the *real* reason (ECONNRESET, TLS +// failure, undici socket error, etc.) lives on `error.cause`. Inline both +// when summarizing for log output so opaque "fetch failed" lines don't +// reach the operator without context. +function describeFetchError(error: unknown): string { + let msg = error instanceof Error ? error.message : String(error); + if (error instanceof Error && error.cause) { + let cause = error.cause; + let causeMsg = cause instanceof Error ? cause.message : String(cause); + return `${msg} (caused by: ${causeMsg})`; + } + return msg; +} + export interface PublishCliOptions { // Commander exposes `--no-wait` / `--no-republish` on the positive // keys (`wait` / `republish`), defaulting to `true` and flipping to @@ -276,6 +291,12 @@ export function registerPublishCommand(realm: Command): void { console.error( `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, ); + // Node's fetch surfaces the actual transport error (ECONNRESET, + // TLS failure, undici socket error, etc.) on `error.cause`. Print + // it so opaque "fetch failed" messages don't strand the caller. + if (err instanceof Error && err.cause) { + console.error(`${FG_RED}Caused by:${RESET}`, err.cause); + } process.exit(1); } }, diff --git a/packages/boxel-cli/src/commands/realm/unpublish.ts b/packages/boxel-cli/src/commands/realm/unpublish.ts index 7f16d6dae6..894bdcbd34 100644 --- a/packages/boxel-cli/src/commands/realm/unpublish.ts +++ b/packages/boxel-cli/src/commands/realm/unpublish.ts @@ -64,12 +64,21 @@ export async function unpublishRealm( }, ); } catch (err) { + // Node's fetch error surface is shallow: the outer error is always + // `TypeError: fetch failed`, and the *real* reason (ECONNRESET, TLS + // failure, undici socket error, etc.) lives on `error.cause`. Include + // it inline so opaque "fetch failed" lines don't reach the operator + // without context. + let msg = err instanceof Error ? err.message : String(err); + if (err instanceof Error && err.cause) { + let cause = err.cause; + let causeMsg = cause instanceof Error ? cause.message : String(cause); + msg = `${msg} (caused by: ${causeMsg})`; + } return { publishedRealmURL: normalized, unpublished: false, - error: `Failed to reach realm server: ${ - err instanceof Error ? err.message : String(err) - }`, + error: `Failed to reach realm server: ${msg}`, }; }