From 21ceaaa546a506095409b74591d1ec07aa0cf253 Mon Sep 17 00:00:00 2001 From: Matt Murray <37455908+mmurrs@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:28:09 -0400 Subject: [PATCH 1/2] fix(cli): restore non-interactive behavior for explicit --environment and --force Two regressions from #126 surface as opaque "Cannot prompt in non-interactive mode" errors in CI and scripted flows. Both are fixed here. 1. `getEnvironmentInteractive` wrapped environment validation in a silent try/catch, so any error from `getEnvironmentConfig` (unknown env, env unavailable in current build type) fell through to `ensureInteractive` and surfaced as a generic "Cannot prompt in non-interactive mode. Provide --environment or ECLOUD_ENV via CLI flags or environment variables" message -- even when the user had provided ECLOUD_ENV or --environment. The real errors ("Unknown environment: X", "Environment X is not available in this build type") are now surfaced directly, and the interactive guard only fires when nothing was provided at all. 2. `promptUseVerifiableBuild` was called unconditionally from `app deploy` and `app upgrade` in the image-ref-only path (no --dockerfile). It routes through `confirmWithDefault`, which throws in non-TTY mode with the message "Use --force to skip confirmation prompts." But the function had no --force awareness, so passing --force did nothing. Now `promptUseVerifiableBuild(force)` short-circuits to the default answer (false = non-verifiable) when force is true, matching the pattern already used in `auth/login.ts`. Also adds vitest coverage for both regressions in `src/utils/__tests__/prompts.test.ts`. Verified manually: - `ECLOUD_ENV=mainnet-alpha ecloud compute app info ` returns app info - `ECLOUD_ENV=bogusenv ecloud compute app info ` returns "Unknown environment: bogusenv" - `ecloud compute app deploy --image-ref ... --force` proceeds past the "Build from verifiable source?" prompt --- .../cli/src/commands/compute/app/deploy.ts | 2 +- .../cli/src/commands/compute/app/upgrade.ts | 2 +- .../cli/src/utils/__tests__/prompts.test.ts | 86 +++++++++++++++++++ packages/cli/src/utils/prompts.ts | 27 +++--- 4 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/prompts.test.ts diff --git a/packages/cli/src/commands/compute/app/deploy.ts b/packages/cli/src/commands/compute/app/deploy.ts index ee283b45..202b17b1 100644 --- a/packages/cli/src/commands/compute/app/deploy.ts +++ b/packages/cli/src/commands/compute/app/deploy.ts @@ -261,7 +261,7 @@ export default class AppDeploy extends Command { // Interactive verifiable selection when --verifiable is not set. // If the user explicitly provided --dockerfile, assume they want the normal local-build flow. if (!flags.dockerfile) { - const useVerifiable = await promptUseVerifiableBuild(); + const useVerifiable = await promptUseVerifiableBuild(flags.force); if (useVerifiable) { const sourceType = await promptVerifiableSourceType(); verifiableMode = sourceType; diff --git a/packages/cli/src/commands/compute/app/upgrade.ts b/packages/cli/src/commands/compute/app/upgrade.ts index a8813f4a..90a3ad04 100644 --- a/packages/cli/src/commands/compute/app/upgrade.ts +++ b/packages/cli/src/commands/compute/app/upgrade.ts @@ -197,7 +197,7 @@ export default class AppUpgrade extends Command { // Interactive verifiable selection when --verifiable is not set. // If the user explicitly provided --dockerfile, assume they want the normal local-build flow. if (!flags.dockerfile) { - const useVerifiable = await promptUseVerifiableBuild(); + const useVerifiable = await promptUseVerifiableBuild(flags.force); if (useVerifiable) { const sourceType = await promptVerifiableSourceType(); verifiableMode = sourceType; diff --git a/packages/cli/src/utils/__tests__/prompts.test.ts b/packages/cli/src/utils/__tests__/prompts.test.ts new file mode 100644 index 00000000..ecb3ee0c --- /dev/null +++ b/packages/cli/src/utils/__tests__/prompts.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getEnvironmentInteractive, promptUseVerifiableBuild } from "../prompts"; + +/** + * Regression tests for two non-interactive mode bugs introduced in PR #126: + * + * 1. `getEnvironmentInteractive("mainnet-alpha")` in a dev build silently + * swallowed the real error ("not available in this build type") and + * surfaced the generic "Cannot prompt in non-interactive mode" message. + * 2. `promptUseVerifiableBuild()` had no knowledge of `--force`, so any + * image-ref-only `app deploy` / `app upgrade` in non-TTY mode threw + * `Cannot confirm "Build from verifiable source?" in non-interactive mode. + * Use --force to skip confirmation prompts.` even when --force was set. + */ +describe("prompts non-interactive regressions", () => { + const origBuildType = process.env.BUILD_TYPE; + const origIsTTY = process.stdin.isTTY; + + afterEach(() => { + if (origBuildType === undefined) delete process.env.BUILD_TYPE; + else process.env.BUILD_TYPE = origBuildType; + process.stdin.isTTY = origIsTTY; + vi.restoreAllMocks(); + }); + + describe("getEnvironmentInteractive", () => { + it("returns the environment verbatim when it is valid and available", async () => { + process.env.BUILD_TYPE = "prod"; + await expect(getEnvironmentInteractive("sepolia")).resolves.toBe("sepolia"); + await expect(getEnvironmentInteractive("mainnet-alpha")).resolves.toBe("mainnet-alpha"); + }); + + it("surfaces 'Unknown environment' for an unrecognized value", async () => { + process.env.BUILD_TYPE = "prod"; + await expect(getEnvironmentInteractive("bogusenv")).rejects.toThrow( + /Unknown environment: bogusenv/, + ); + }); + + it("surfaces 'not available in this build type' for envs missing from the current build", async () => { + // Bug 1 scenario: user installed a dev build, then ran a command with + // --environment mainnet-alpha. Previously they got the misleading + // "Cannot prompt in non-interactive mode" error. Now they get the + // real reason, referencing the build type. + // + // The SDK bakes BUILD_TYPE in at build time via tsup define, so + // `process.env.BUILD_TYPE` at runtime cannot flip it back to "dev". + // Instead, exercise the inverse: in the prod-built SDK used by tests, + // "sepolia-dev" is the environment that is *not* available — same + // code path, same error shape. + process.env.BUILD_TYPE = "prod"; + await expect(getEnvironmentInteractive("sepolia-dev")).rejects.toThrow( + /not available in this build type/, + ); + }); + + it("falls through to the interactive guard only when no environment is supplied", async () => { + process.env.BUILD_TYPE = "prod"; + process.stdin.isTTY = false; + await expect(getEnvironmentInteractive(undefined)).rejects.toThrow( + /Cannot prompt in non-interactive mode/, + ); + }); + }); + + describe("promptUseVerifiableBuild", () => { + it("short-circuits to false when force is true, even in non-TTY mode", async () => { + process.stdin.isTTY = false; + await expect(promptUseVerifiableBuild(true)).resolves.toBe(false); + }); + + it("throws the 'Use --force' guidance when force is false in non-TTY mode", async () => { + process.stdin.isTTY = false; + await expect(promptUseVerifiableBuild(false)).rejects.toThrow( + /Cannot confirm "Build from verifiable source\?" in non-interactive mode\. Use --force/, + ); + }); + + it("defaults force to false so existing callers still see the non-interactive error", async () => { + process.stdin.isTTY = false; + await expect(promptUseVerifiableBuild()).rejects.toThrow( + /Cannot confirm "Build from verifiable source\?" in non-interactive mode/, + ); + }); + }); +}); diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index e13c6933..1c16c7cc 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -15,7 +15,6 @@ import { privateKeyToAccount } from "viem/accounts"; import { getEnvironmentConfig, getAvailableEnvironments, - isEnvironmentAvailable, getAllAppsByDeveloper, getCategoryDescriptions, fetchTemplateCatalog, @@ -152,8 +151,14 @@ function detectGitRepoInfo(): { repoUrl?: string; commitSha?: string } { /** * Prompt: "Build from verifiable source?" (only used when --verifiable is not set) + * + * When `force` is true (i.e. the user passed --force), skip the prompt entirely + * and return the default answer (false = regular build). Without this short-circuit, + * `confirmWithDefault` throws in non-interactive mode, which means --force on an + * image-ref-only deploy or upgrade was unusable in CI. */ -export async function promptUseVerifiableBuild(): Promise { +export async function promptUseVerifiableBuild(force: boolean = false): Promise { + if (force) return false; return confirmWithDefault("Build from verifiable source?", false); } @@ -1522,15 +1527,15 @@ export async function getPrivateKeyInteractive(privateKey?: string): Promise { if (environment) { - try { - getEnvironmentConfig(environment); - if (!isEnvironmentAvailable(environment)) { - throw new Error(`Environment ${environment} is not available in this build`); - } - return environment; - } catch { - // Invalid environment, continue to prompt - } + // Validate the explicit value and surface the real error to the user. + // getEnvironmentConfig throws a descriptive error for unknown environments + // AND for environments not available in the current build (dev vs prod). + // Previously we caught this silently and fell through to the interactive + // prompt, which in non-TTY mode surfaced the generic "Cannot prompt in + // non-interactive mode" error — hiding the real reason (e.g. "mainnet-alpha + // is not available in this build type" when using a dev build). + getEnvironmentConfig(environment); + return environment; } ensureInteractive("--environment or ECLOUD_ENV"); From 00568a22b99b627f0287c369b65be54827733dd0 Mon Sep 17 00:00:00 2001 From: Matt Murray <37455908+mmurrs@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:06:37 -0400 Subject: [PATCH 2/2] test(cli): add non-interactive smoke test and CI workflow Black-box smoke test that invokes the built CLI binary with stdin closed and asserts every command path with a non-interactive escape hatch either succeeds or fails with a specific, actionable error message -- never the generic "Cannot prompt in non-interactive mode" fallback. This would have caught both regressions fixed in #130: - Bug 1 (getEnvironmentInteractive swallowing the real error): 6 of 8 assertions in the "unknown environment" and "env unavailable in build type" sections fail against unpatched master, with the generic prompt error instead of the expected specific message. - Bug 2 (promptUseVerifiableBuild ignoring --force): 2 assertions for image-ref-only deploy and upgrade with --force fail against unpatched master, matching the "Cannot confirm Build from verifiable source" message. Verified by temporarily checking out packages/cli/src from origin/master and running the script -- 8 passes, 8 failures, matching the exact regressions #130 fixes. On the patched branch: 16/16 pass. Layout: - .github/workflows/cli-smoke.yml -- runs on every PR + master push that touches packages/cli, packages/sdk, or the workflow itself - packages/cli/test/e2e/non-interactive.sh -- portable assertion script (~10s runtime, no secrets, no on-chain writes) - packages/cli/test/e2e/fixtures/minimal.env -- trivial env fixture - packages/cli/test/e2e/README.md -- how to run locally and how to extend. Also sketches the two follow-up tiers (full testnet deploy, agent-skill runbook). The workflow also runs pnpm -C packages/cli run test so the existing vitest suite gains a PR-level gate it didn't have before. --- .github/workflows/cli-smoke.yml | 54 ++++++++ packages/cli/test/e2e/README.md | 61 +++++++++ packages/cli/test/e2e/fixtures/minimal.env | 4 + packages/cli/test/e2e/non-interactive.sh | 150 +++++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 .github/workflows/cli-smoke.yml create mode 100644 packages/cli/test/e2e/README.md create mode 100644 packages/cli/test/e2e/fixtures/minimal.env create mode 100755 packages/cli/test/e2e/non-interactive.sh diff --git a/.github/workflows/cli-smoke.yml b/.github/workflows/cli-smoke.yml new file mode 100644 index 00000000..58f8fe21 --- /dev/null +++ b/.github/workflows/cli-smoke.yml @@ -0,0 +1,54 @@ +name: CLI Smoke + +on: + pull_request: + paths: + - "packages/cli/**" + - "packages/sdk/**" + - ".github/workflows/cli-smoke.yml" + - "pnpm-lock.yaml" + push: + branches: + - master + +permissions: + contents: read + +env: + NODE_VERSION: "20.x" + +jobs: + non-interactive: + name: Non-interactive smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.0.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build SDK + working-directory: ./packages/sdk + run: pnpm run build + + - name: Build CLI + working-directory: ./packages/cli + run: pnpm run build + + - name: Run unit tests + working-directory: ./packages/cli + run: pnpm run test + + - name: Non-interactive smoke + run: ./packages/cli/test/e2e/non-interactive.sh diff --git a/packages/cli/test/e2e/README.md b/packages/cli/test/e2e/README.md new file mode 100644 index 00000000..44dfe6b0 --- /dev/null +++ b/packages/cli/test/e2e/README.md @@ -0,0 +1,61 @@ +# CLI end-to-end tests + +These are black-box smoke tests that invoke the built CLI binary the same way +CI and users do — via a child process with stdin closed. They complement the +vitest unit tests in `packages/cli/src/**/__tests__/` by catching regressions +that only surface once the full oclif command tree is loaded. + +## `non-interactive.sh` + +Non-TTY smoke test. Every command path that offers a non-interactive escape +hatch (`--force`, explicit flag, env var) is invoked with `stdin &2 + FAIL=$((FAIL + 1)) + fi +} + +assert_not_match() { + local desc="$1" pattern="$2" output="$3" + if echo "$output" | grep -qE -- "$pattern"; then + echo "::error::[$desc] output unexpectedly matched /$pattern/" + echo "$output" | sed 's/^/ /' >&2 + FAIL=$((FAIL + 1)) + else + echo " ✓ [$desc] output clean of /$pattern/" + PASS=$((PASS + 1)) + fi +} + +# Run the CLI non-interactively with a timeout. Merges stderr into stdout. +# Always returns 0 so tests can capture and inspect output regardless of the +# command's own exit code. +run_cli() { + timeout 20 "$CLI" "$@" &1 || true +} + +section() { echo ""; echo "▸ $1"; } + +# ----------------------------------------------------------------------------- +# Test suite +# ----------------------------------------------------------------------------- + +section "Help surfaces load cleanly" +for subcmd in "--version" "--help" "compute app info --help" "compute app deploy --help" "compute app upgrade --help" "billing status --help" "auth whoami --help"; do + # shellcheck disable=SC2086 + out=$(run_cli $subcmd) + assert_not_match "'$subcmd' loaded without prompt error" "Cannot prompt in non-interactive" "$out" +done + +section "Bug 1 — unknown environment surfaces a distinct error" +# Invalid env name via ECLOUD_ENV +out=$(ECLOUD_ENV=bogusenv run_cli compute app info "$DUMMY_APP_ID") +assert_match "bogusenv via ECLOUD_ENV" "Unknown environment: bogusenv" "$out" +assert_not_match "bogusenv via ECLOUD_ENV" "Cannot prompt in non-interactive" "$out" + +# Invalid env name via --environment +out=$(run_cli compute app info "$DUMMY_APP_ID" --environment bogusenv) +assert_match "bogusenv via --environment" "Unknown environment: bogusenv" "$out" +assert_not_match "bogusenv via --environment" "Cannot prompt in non-interactive" "$out" + +section "Bug 1 — env unavailable in current build type surfaces the real reason" +# The CLI in CI is built as prod by default, so sepolia-dev is the env that is +# defined but not available. Asserting this here covers the mirror case of +# "dev build running against mainnet-alpha" that originally triggered the bug. +out=$(ECLOUD_ENV=sepolia-dev run_cli compute app info "$DUMMY_APP_ID") +assert_match "sepolia-dev in prod build" "not available in this build type" "$out" +assert_not_match "sepolia-dev in prod build" "Cannot prompt in non-interactive" "$out" + +section "Bug 2 — deploy with image-ref only and --force skips the verifiable prompt" +# Uses a non-existent image ref so the command will fail downstream (pull / +# billing / network), but the assertion is specifically that it does NOT hit +# the verifiable-source confirmation gate. +out=$(run_cli compute app deploy \ + --name smoke-image-ref-only \ + --env-file "$FIXTURE_ENV" \ + --image-ref "ghcr.io/ecloud-smoke-test/does-not-exist:latest" \ + --instance-type g1-micro-1v \ + --log-visibility public \ + --resource-usage-monitoring enable \ + --skip-profile \ + --description "non-interactive smoke" \ + --force) +assert_not_match "image-ref deploy with --force" 'Cannot confirm "Build from verifiable source\?"' "$out" + +section "Bug 2 — upgrade with image-ref only and --force skips the verifiable prompt" +out=$(run_cli compute app upgrade "$DUMMY_APP_ID" \ + --env-file "$FIXTURE_ENV" \ + --image-ref "ghcr.io/ecloud-smoke-test/does-not-exist:latest" \ + --instance-type g1-micro-1v \ + --log-visibility public \ + --resource-usage-monitoring enable \ + --force) +assert_not_match "image-ref upgrade with --force" 'Cannot confirm "Build from verifiable source\?"' "$out" + +section "Auth — whoami runs non-interactively without crashing" +# whoami has no side effects and no prompts regardless of keyring state. +out=$(run_cli auth whoami) +assert_not_match "auth whoami" "Cannot prompt in non-interactive" "$out" + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- + +echo "" +echo "============================================================" +echo " Non-interactive smoke: $PASS passed, $FAIL failed" +echo "============================================================" + +if [ "$FAIL" -ne 0 ]; then + exit 1 +fi