--config-file is now a file, not a folder

This commit is contained in:
Konstantin Wohlwend 2026-05-15 15:54:54 -07:00
parent 9102b3db75
commit 049c557a06
8 changed files with 96 additions and 24 deletions

View File

@ -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.

View File

@ -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");

View File

@ -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 <path> 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);

View File

@ -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();

View File

@ -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);
}

View File

@ -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 };

View File

@ -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/);
});
});

View File

@ -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;
}