Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/cli-smoke.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/cli/src/commands/compute/app/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/compute/app/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/utils/__tests__/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
});
27 changes: 16 additions & 11 deletions packages/cli/src/utils/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { privateKeyToAccount } from "viem/accounts";
import {
getEnvironmentConfig,
getAvailableEnvironments,
isEnvironmentAvailable,
getAllAppsByDeveloper,
getCategoryDescriptions,
fetchTemplateCatalog,
Expand Down Expand Up @@ -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<boolean> {
export async function promptUseVerifiableBuild(force: boolean = false): Promise<boolean> {
if (force) return false;
return confirmWithDefault("Build from verifiable source?", false);
}

Expand Down Expand Up @@ -1522,15 +1527,15 @@ export async function getPrivateKeyInteractive(privateKey?: string): Promise<str
*/
export async function getEnvironmentInteractive(environment?: string): Promise<string> {
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");
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/test/e2e/README.md
Original file line number Diff line number Diff line change
@@ -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 </dev/null`
and asserted to either:

- succeed, or
- fail with a specific, actionable error message

The assertion that catches the most regressions is that commands **never**
emit the generic `Cannot prompt in non-interactive mode` error when enough
information has been provided via flags or env vars. That was the common
shape of the regressions fixed in #130.

### Run locally

```bash
pnpm -r build # build SDK + CLI
./packages/cli/test/e2e/non-interactive.sh
```

Expected runtime: ~10 seconds. Exits non-zero on any failed assertion.

### Add a new assertion

Every assertion follows the same shape:

```bash
out=$(run_cli compute app info "$DUMMY_APP_ID" --environment bogusenv)
assert_match "description" "expected pattern" "$out"
assert_not_match "description" "unwanted pattern" "$out"
```

`run_cli` always returns zero so you can capture output regardless of the
command's own exit code — assertions are pattern-based on the merged
stdout + stderr.

Prefer asserting both a positive match (the specific expected error) AND
a negative match (no `"Cannot prompt in non-interactive mode"`) when
testing error paths — the negative check is what catches the class of
regressions that triggered this script.

## Future tiers

This script is Tier 1 of a planned three-tier validation stack:

| Tier | What | When |
|---|---|---|
| 1 | This file — non-TTY smoke, no on-chain writes | every PR |
| 2 | Full Sepolia deploy lifecycle on a dedicated funded wallet | nightly / pre-release |
| 3 | Deploy-agent skill runbook executed end-to-end | pre-release validation |

Tier 2 and Tier 3 need several upstream fixes first (`billing subscribe`
non-interactive mode, `auth gen --store` keyring-replace flag, etc).
4 changes: 4 additions & 0 deletions packages/cli/test/e2e/fixtures/minimal.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Minimal env file used only by the non-interactive smoke script.
# Contents are intentionally trivial — the goal is to exercise --env-file
# loading code paths, not to deploy a real app.
SMOKE_TEST=true
Loading
Loading