From 049c557a061102684b399b045423ef2f07c4f40f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 15:54:54 -0700 Subject: [PATCH] --config-file is now a file, not a folder --- .claude/CLAUDE-KNOWLEDGE.md | 3 ++ .../src/commands/config-file.test.ts | 9 +++++ .../stack-cli/src/commands/config-file.ts | 12 +++--- packages/stack-cli/src/commands/emulator.ts | 11 ++--- packages/stack-cli/src/commands/exec.ts | 8 +--- packages/stack-cli/src/commands/init.ts | 9 +++-- .../src/lib/config-file-path.test.ts | 40 +++++++++++++++++++ .../stack-cli/src/lib/config-file-path.ts | 28 +++++++++++++ 8 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 packages/stack-cli/src/lib/config-file-path.test.ts create mode 100644 packages/stack-cli/src/lib/config-file-path.ts diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index e6ffc701b..3e7e84f03 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -457,3 +457,6 @@ A: `docs-mintlify/apps-sidebar-filter.js` injects the Apps filter with inline st ## Q: How should `StackAssertionError` preserve an underlying thrown error? A: Pass the underlying error as the `cause` property in the second argument. The `StackAssertionError` constructor only forwards `cause` into `ErrorOptions`, so storing a caught error under an `error` property captures it as ordinary metadata instead of preserving the error cause chain. + +## Q: How should Stack CLI `--config-file` options interpret paths? +A: `--config-file` should point directly to a regular config file. Do not treat an existing directory as a shortcut for `stack.config.ts` inside it; reject directories with a clear error instead. `stack config pull` may default to `./stack.config.ts` when the flag is omitted, but an explicitly provided directory is still invalid. diff --git a/packages/stack-cli/src/commands/config-file.test.ts b/packages/stack-cli/src/commands/config-file.test.ts index 4ad21975d..d8fa23cad 100644 --- a/packages/stack-cli/src/commands/config-file.test.ts +++ b/packages/stack-cli/src/commands/config-file.test.ts @@ -20,12 +20,21 @@ describe("resolveConfigFilePathForPull", () => { expect(resolveConfigFilePathForPull({ configFile: explicit }, tmpDir)).toBe(path.resolve(explicit)); }); + it("rejects an explicit --config-file path that points to a directory", () => { + expect(() => resolveConfigFilePathForPull({ configFile: tmpDir }, tmpDir)).toThrow(/must point to a config file/); + }); + it("falls back to ./stack.config.ts in cwd when --config-file is omitted", () => { const expected = path.join(tmpDir, "stack.config.ts"); fs.writeFileSync(expected, "// placeholder\n"); expect(resolveConfigFilePathForPull({}, tmpDir)).toBe(expected); }); + it("rejects the default ./stack.config.ts path when it is a directory", () => { + fs.mkdirSync(path.join(tmpDir, "stack.config.ts")); + expect(() => resolveConfigFilePathForPull({}, tmpDir)).toThrow(/directory instead of a file/); + }); + it("treats an empty --config-file string as omitted (falls back to cwd)", () => { const expected = path.join(tmpDir, "stack.config.ts"); fs.writeFileSync(expected, "// placeholder\n"); diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index 093da8006..fdbec50ad 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -4,6 +4,7 @@ import * as fs from "fs"; import { isProjectAuthWithRefreshToken, isProjectAuthWithSecretServerKey, resolveAuth, type ProjectAuthWithSecretServerKey } from "../lib/auth.js"; import { getAdminProject } from "../lib/app.js"; import { CliError } from "../lib/errors.js"; +import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; @@ -121,12 +122,15 @@ function sourceToSdkSource(source: BranchConfigSourceApi): // if it isn't there. Exported for unit tests. export function resolveConfigFilePathForPull(opts: { configFile?: string }, cwd: string): string { if (opts.configFile != null && opts.configFile !== "") { - return path.resolve(opts.configFile); + return resolveConfigFilePathOption(opts.configFile); } const candidate = path.join(cwd, "stack.config.ts"); if (!fs.existsSync(candidate)) { throw new CliError("No --config-file provided and no stack.config.ts found in the current directory. Pass --config-file or run this command in a directory containing a stack.config.ts file."); } + if (fs.statSync(candidate).isDirectory()) { + throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`); + } return candidate; } @@ -175,17 +179,13 @@ export function registerConfigCommand(program: Command) { .action(async (opts) => { const auth = resolveAuth(opts.cloudProjectId); - const filePath = path.resolve(opts.configFile); + const filePath = resolveConfigFilePathOption(opts.configFile, { mustExist: true }); const ext = path.extname(filePath); if (ext !== ".js" && ext !== ".ts") { throw new CliError("Config file must have a .js or .ts extension."); } - if (!fs.existsSync(filePath)) { - throw new CliError(`Config file not found: ${filePath}`); - } - const { createJiti } = await import("jiti"); const jiti = createJiti(import.meta.url); const configModule: { config?: unknown } = await jiti.import(filePath); diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index f8c6f81f6..177391cf9 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -19,6 +19,7 @@ import { pollInternalPck, } from "../lib/emulator-paths.js"; import { CliError } from "../lib/errors.js"; +import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; import { writeIso } from "../lib/iso.js"; const DEFAULT_PORT_PREFIX = "81"; @@ -739,10 +740,7 @@ export function registerEmulatorCommand(program: Command) { let resolvedConfigFile: string | undefined; if (opts.configFile) { - resolvedConfigFile = resolve(opts.configFile); - if (!existsSync(resolvedConfigFile)) { - throw new CliError(`Config file not found: ${resolvedConfigFile}`); - } + resolvedConfigFile = resolveConfigFilePathOption(opts.configFile, { mustExist: true }); } let freshlyStarted = false; @@ -782,10 +780,7 @@ export function registerEmulatorCommand(program: Command) { let resolvedConfigFile: string | undefined; if (opts.configFile) { - resolvedConfigFile = resolve(opts.configFile); - if (!existsSync(resolvedConfigFile)) { - throw new CliError(`Config file not found: ${resolvedConfigFile}`); - } + resolvedConfigFile = resolveConfigFilePathOption(opts.configFile, { mustExist: true }); } const alreadyRunning = isEmulatorRunning(); diff --git a/packages/stack-cli/src/commands/exec.ts b/packages/stack-cli/src/commands/exec.ts index 824b369a0..5ed73e5cb 100644 --- a/packages/stack-cli/src/commands/exec.ts +++ b/packages/stack-cli/src/commands/exec.ts @@ -1,10 +1,9 @@ import { Command } from "commander"; -import * as fs from "fs"; -import * as path from "path"; import { isProjectAuthWithRefreshToken, resolveAuth, resolveLocalEmulatorAuth, type ProjectAuthWithRefreshToken } from "../lib/auth.js"; import { lookupLocalEmulatorProjectIdByPath } from "../lib/local-emulator-client.js"; import { getAdminProject } from "../lib/app.js"; import { CliError } from "../lib/errors.js"; +import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; function getErrorMessage(err: unknown): string { if (err instanceof Error) { @@ -69,10 +68,7 @@ export function registerExecCommand(program: Command) { } auth = cloudAuth; } else { - const absPath = path.resolve(target.configFile); - if (!fs.existsSync(absPath)) { - throw new CliError(`Config file not found: ${absPath}`); - } + const absPath = resolveConfigFilePathOption(target.configFile, { mustExist: true }); const projectId = await lookupLocalEmulatorProjectIdByPath(absPath); auth = await resolveLocalEmulatorAuth(projectId); } diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 2932693db..03b1fbd12 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -12,6 +12,7 @@ import { isNonInteractiveEnv } from "../lib/interactive.js"; import { createInitPrompt } from "../lib/init-prompt.js"; import { createProjectInteractively } from "../lib/create-project.js"; import { runClaudeAgent } from "../lib/claude-agent.js"; +import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; import { isEmulatorImageInstalled } from "./emulator.js"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -196,14 +197,14 @@ async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath if (!fs.existsSync(resolved)) { return `File not found: ${resolved}`; } + if (fs.statSync(resolved).isDirectory()) { + return `--config-file must point to a config file, but got a directory: ${resolved}`; + } return true; }, }); - const configPath = path.resolve(filePath); - if (!fs.existsSync(configPath)) { - throw new CliError(`File not found: ${configPath}`); - } + const configPath = resolveConfigFilePathOption(filePath, { mustExist: true }); console.log(`\nLinked to config file: ${configPath}`); return { configPath }; diff --git a/packages/stack-cli/src/lib/config-file-path.test.ts b/packages/stack-cli/src/lib/config-file-path.test.ts new file mode 100644 index 000000000..0579a0952 --- /dev/null +++ b/packages/stack-cli/src/lib/config-file-path.test.ts @@ -0,0 +1,40 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveConfigFilePathOption } from "./config-file-path.js"; + +describe("resolveConfigFilePathOption", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-file-path-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns a resolved existing file path", () => { + const configFile = path.join(tmpDir, "stack.config.ts"); + fs.writeFileSync(configFile, "// config\n"); + + expect(resolveConfigFilePathOption(configFile, { mustExist: true })).toBe(configFile); + }); + + it("rejects an existing directory", () => { + expect(() => resolveConfigFilePathOption(tmpDir, { mustExist: true })).toThrow(/must point to a config file, but got a directory/); + }); + + it("allows a missing file path when mustExist is not set", () => { + const configFile = path.join(tmpDir, "missing.config.ts"); + + expect(resolveConfigFilePathOption(configFile)).toBe(configFile); + }); + + it("rejects a missing file path when mustExist is set", () => { + const configFile = path.join(tmpDir, "missing.config.ts"); + + expect(() => resolveConfigFilePathOption(configFile, { mustExist: true })).toThrow(/Config file not found/); + }); +}); diff --git a/packages/stack-cli/src/lib/config-file-path.ts b/packages/stack-cli/src/lib/config-file-path.ts new file mode 100644 index 000000000..63e3ab1a5 --- /dev/null +++ b/packages/stack-cli/src/lib/config-file-path.ts @@ -0,0 +1,28 @@ +import { existsSync, statSync } from "fs"; +import { resolve } from "path"; +import { CliError } from "./errors.js"; + +export function resolveConfigFilePathOption(inputPath: string, options?: { + mustExist?: boolean, + optionName?: string, +}): string { + const resolved = resolve(inputPath); + const optionName = options?.optionName ?? "--config-file"; + + if (!existsSync(resolved)) { + if (options?.mustExist === true) { + throw new CliError(`Config file not found: ${resolved}`); + } + return resolved; + } + + const stat = statSync(resolved); + if (stat.isDirectory()) { + throw new CliError(`${optionName} must point to a config file, but got a directory: ${resolved}`); + } + if (!stat.isFile()) { + throw new CliError(`${optionName} must point to a regular config file: ${resolved}`); + } + + return resolved; +}