Skip to content

Commit 32594d6

Browse files
authored
fix(cli): fail fast in non-TTY environments instead of hanging on config-creation prompts (#1199)
When `open-next.config.ts` or `wrangler.(toml|json|jsonc)` is missing, the CLI prompts the user to auto-create it. In non-TTY environments (Cloudflare Workers Builds, Docker, CI) the Enquirer prompt can't read stdin, so the build hangs on a truncated prompt and exits with a cryptic code. Both prompts now throw an actionable error in non-interactive mode; interactive behavior is unchanged.
1 parent f0d0226 commit 32594d6

9 files changed

Lines changed: 118 additions & 12 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
fix(cli): fail fast in non-TTY environments instead of hanging on config-creation prompts
6+
7+
When `open-next.config.ts` (or `wrangler.(toml|json|jsonc)`) is missing, the CLI
8+
prompts the user to auto-create it. In non-TTY environments (Cloudflare Workers
9+
Builds, Docker, CI) the Enquirer prompt can't read stdin, so the build hangs or
10+
fails with a truncated prompt and a cryptic exit code — the user sees
11+
`? Missing required open-next.config.ts file, do you want to create one? (Y/n)`
12+
and then ` ELIFECYCLE Command failed with exit code 13`, with no hint at what
13+
to do next.
14+
15+
Now, in non-interactive environments, both prompts throw an actionable error
16+
with the exact template to paste (for `open-next.config.ts`) or point at the
17+
existing `--skipWranglerConfigCheck` / `SKIP_WRANGLER_CONFIG_CHECK` escape
18+
hatch (for the wrangler config). Interactive behavior is unchanged.

packages/cloudflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@ast-grep/napi": "^0.40.5",
5656
"@dotenvx/dotenvx": "catalog:",
5757
"@opennextjs/aws": "3.10.2",
58+
"ci-info": "^4.2.0",
5859
"cloudflare": "^4.4.1",
5960
"comment-json": "^4.5.1",
6061
"enquirer": "^2.4.1",

packages/cloudflare/src/cli/commands/build.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { askConfirmation } from "../utils/ask-confirmation.js";
55
import { createWranglerConfigFile } from "../utils/create-wrangler-config.js";
66
import { buildCommand } from "./build.js";
77

8+
vi.mock("../utils/is-interactive.js", () => ({
9+
isNonInteractiveOrCI: vi.fn(() => false),
10+
}));
11+
812
// Mock logger
913
vi.mock("@opennextjs/aws/logger.js", () => ({
1014
default: {

packages/cloudflare/src/cli/commands/build.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type yargs from "yargs";
44
import { build as buildImpl } from "../build/build.js";
55
import { askConfirmation } from "../utils/ask-confirmation.js";
66
import { createWranglerConfigFile, findWranglerConfig } from "../utils/create-wrangler-config.js";
7+
import { isNonInteractiveOrCI } from "../utils/is-interactive.js";
78
import type { WithWranglerArgs } from "./utils/utils.js";
89
import {
910
compileConfig,
@@ -41,6 +42,15 @@ export async function buildCommand(
4142
// nor when `--skipWranglerConfigCheck` is used.
4243
if (!projectOpts.wranglerConfigPath && !args.skipWranglerConfigCheck) {
4344
if (!findWranglerConfig(projectOpts.sourceDir)) {
45+
// In non-interactive environments (CI, Cloudflare Workers Builds,
46+
// Docker, etc.) the prompt would hang or crash. Fail fast with a
47+
// clear message pointing at the existing escape hatch.
48+
if (isNonInteractiveOrCI()) {
49+
throw new Error(
50+
"No `wrangler.(toml|json|jsonc)` config file found.\n\nCreate one at the project root before running the build, or skip this check with `--skipWranglerConfigCheck` or `SKIP_WRANGLER_CONFIG_CHECK=yes`."
51+
);
52+
}
53+
4454
const confirmCreate = "No `wrangler.(toml|json|jsonc)` config file found, do you want to create one?";
4555
if (await askConfirmation(confirmCreate)) {
4656
await createWranglerConfigFile(projectOpts.sourceDir);

packages/cloudflare/src/cli/commands/utils/utils.spec.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
import { askConfirmation } from "../../utils/ask-confirmation.js";
44
import { createOpenNextConfigFile, findOpenNextConfig } from "../../utils/create-open-next-config.js";
5+
import { isNonInteractiveOrCI } from "../../utils/is-interactive.js";
56
import { compileConfig } from "./utils.js";
67

78
const { mockExistsSync } = vi.hoisted(() => ({
@@ -20,12 +21,16 @@ vi.mock("@opennextjs/aws/logger.js", () => ({
2021
}));
2122

2223
// Mock compileOpenNextConfig
23-
const mockCompileOpenNextConfig = vi.fn(async () => ({
24-
config: { default: {} },
25-
buildDir: "/build",
26-
}));
24+
const mockCompileOpenNextConfig = vi.fn(async (...args: [unknown, unknown]) => {
25+
void args;
26+
27+
return {
28+
config: { default: {} },
29+
buildDir: "/build",
30+
};
31+
});
2732
vi.mock("@opennextjs/aws/build/compileConfig.js", () => ({
28-
compileOpenNextConfig: (...args: unknown[]) => mockCompileOpenNextConfig(...args),
33+
compileOpenNextConfig: (...args: [unknown, unknown]) => mockCompileOpenNextConfig(...args),
2934
}));
3035

3136
// Mock ensureCloudflareConfig
@@ -38,6 +43,11 @@ vi.mock("../../utils/ask-confirmation.js", () => ({
3843
askConfirmation: vi.fn(),
3944
}));
4045

46+
// Mock CI/non-interactive detector — default to interactive local behavior.
47+
vi.mock("../../utils/is-interactive.js", () => ({
48+
isNonInteractiveOrCI: vi.fn(() => false),
49+
}));
50+
4151
// Mock create-config-files (unused import in utils.ts but required for module resolution)
4252
vi.mock("../../utils/create-config-files.js", () => ({
4353
createOpenNextConfigIfNotExistent: vi.fn(),
@@ -47,6 +57,7 @@ vi.mock("../../utils/create-config-files.js", () => ({
4757
vi.mock("../../utils/create-open-next-config.js", () => ({
4858
findOpenNextConfig: vi.fn(),
4959
createOpenNextConfigFile: vi.fn(() => "/test/open-next.config.ts"),
60+
OPEN_NEXT_CONFIG_FILE_NAME: "open-next.config.ts",
5061
}));
5162

5263
// Mock wrangler
@@ -66,6 +77,10 @@ vi.mock("@opennextjs/aws/build/helper.js", () => ({
6677
}));
6778

6879
describe("compileConfig", () => {
80+
beforeEach(() => {
81+
vi.mocked(isNonInteractiveOrCI).mockReturnValue(false);
82+
});
83+
6984
it("should compile config when configPath is provided and file exists", async () => {
7085
mockExistsSync.mockReturnValue(true);
7186

@@ -123,4 +138,27 @@ describe("compileConfig", () => {
123138
expect(askConfirmation).toHaveBeenCalledOnce();
124139
expect(createOpenNextConfigFile).not.toHaveBeenCalled();
125140
});
141+
142+
it("should throw a helpful error (without prompting) when no configPath found in a non-interactive environment", async () => {
143+
vi.mocked(findOpenNextConfig).mockReturnValue(undefined);
144+
vi.mocked(isNonInteractiveOrCI).mockReturnValue(true);
145+
146+
await expect(compileConfig(undefined)).rejects.toThrowError(
147+
/No `open-next\.config\.ts` file was found.*opennextjs-cloudflare migrate/s
148+
);
149+
150+
expect(askConfirmation).not.toHaveBeenCalled();
151+
expect(createOpenNextConfigFile).not.toHaveBeenCalled();
152+
});
153+
154+
it("should still prompt in interactive environments", async () => {
155+
vi.mocked(findOpenNextConfig).mockReturnValue(undefined);
156+
vi.mocked(isNonInteractiveOrCI).mockReturnValue(false);
157+
vi.mocked(askConfirmation).mockResolvedValue(true);
158+
159+
await compileConfig(undefined);
160+
161+
expect(askConfirmation).toHaveBeenCalledOnce();
162+
expect(createOpenNextConfigFile).toHaveBeenCalledOnce();
163+
});
126164
});

packages/cloudflare/src/cli/commands/utils/utils.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import type yargs from "yargs";
1313
import type { OpenNextConfig } from "../../../api/config.js";
1414
import { ensureCloudflareConfig } from "../../build/utils/ensure-cf-config.js";
1515
import { askConfirmation } from "../../utils/ask-confirmation.js";
16-
import { createOpenNextConfigFile, findOpenNextConfig } from "../../utils/create-open-next-config.js";
16+
import {
17+
createOpenNextConfigFile,
18+
findOpenNextConfig,
19+
OPEN_NEXT_CONFIG_FILE_NAME,
20+
} from "../../utils/create-open-next-config.js";
21+
import { isNonInteractiveOrCI } from "../../utils/is-interactive.js";
1722

1823
export type WithWranglerArgs<T = unknown> = T & {
1924
// Array of arguments that can be given to wrangler commands, including the `--config` and `--env` args.
@@ -58,12 +63,21 @@ export async function compileConfig(configPath: string | undefined) {
5863
configPath ??= findOpenNextConfig(nextAppDir);
5964

6065
if (!configPath) {
66+
// In non-interactive environments (CI, Cloudflare Workers Builds,
67+
// Docker, etc.) there is no TTY to answer a prompt — the build would
68+
// hang or crash. Fail fast with an actionable message instead.
69+
if (isNonInteractiveOrCI()) {
70+
throw new Error(
71+
`No \`${OPEN_NEXT_CONFIG_FILE_NAME}\` file was found in the project root.\n\nThis file is required for OpenNext Cloudflare builds.\nRun \`opennextjs-cloudflare migrate\` to create it, or see https://opennext.js.org/cloudflare/get-started for setup guidance.\nCommit it and re-run the build.`
72+
);
73+
}
74+
6175
const answer = await askConfirmation(
62-
"Missing required `open-next.config.ts` file, do you want to create one?"
76+
`Missing required \`${OPEN_NEXT_CONFIG_FILE_NAME}\` file, do you want to create one?`
6377
);
6478

6579
if (!answer) {
66-
throw new Error("The `open-next.config.ts` file is required, aborting!");
80+
throw new Error(`The \`${OPEN_NEXT_CONFIG_FILE_NAME}\` file is required, aborting!`);
6781
}
6882

6983
configPath = createOpenNextConfigFile(nextAppDir, { cache: false });

packages/cloudflare/src/cli/utils/create-open-next-config.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
55

66
import { getPackageTemplatesDirPath } from "../../utils/get-package-templates-dir-path.js";
77

8+
export const OPEN_NEXT_CONFIG_FILE_NAME = "open-next.config.ts";
9+
810
/**
911
* Finds the path to the OpenNext configuration file if it exists.
1012
*
1113
* @param appDir The directory to check for the open-next.config.ts file
1214
* @returns The full path to open-next.config.ts if it exists, undefined otherwise
1315
*/
1416
export function findOpenNextConfig(appDir: string): string | undefined {
15-
const openNextConfigPath = join(appDir, "open-next.config.ts");
17+
const openNextConfigPath = join(appDir, OPEN_NEXT_CONFIG_FILE_NAME);
1618

1719
if (existsSync(openNextConfigPath)) {
1820
return openNextConfigPath;
@@ -29,9 +31,9 @@ export function findOpenNextConfig(appDir: string): string | undefined {
2931
* @returns The path to the created configuration file
3032
*/
3133
export function createOpenNextConfigFile(appDir: string, options: { cache: boolean }): string {
32-
const openNextConfigPath = join(appDir, "open-next.config.ts");
34+
const openNextConfigPath = join(appDir, OPEN_NEXT_CONFIG_FILE_NAME);
3335

34-
let content = readFileSync(join(getPackageTemplatesDirPath(), "open-next.config.ts"), "utf8");
36+
let content = readFileSync(join(getPackageTemplatesDirPath(), OPEN_NEXT_CONFIG_FILE_NAME), "utf8");
3537

3638
if (!options.cache) {
3739
content = patchCode(content, commentOutR2ImportRule);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import ci from "ci-info";
2+
3+
/**
4+
* Whether the current process is running in an interactive terminal.
5+
*/
6+
export function isInteractive(): boolean {
7+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
8+
}
9+
10+
/**
11+
* Whether prompts should be suppressed.
12+
*/
13+
export function isNonInteractiveOrCI(): boolean {
14+
return !isInteractive() || ci.isCI;
15+
}

pnpm-lock.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)