diff --git a/packages/cli/cli.ts b/packages/cli/cli.ts index 5f10e459..009e491f 100644 --- a/packages/cli/cli.ts +++ b/packages/cli/cli.ts @@ -15,18 +15,21 @@ const program = new Command(); program .name("openworkflow") .description("OpenWorkflow CLI - learn more at https://openworkflow.dev") + .usage(" [options]") .version(getVersion()); // init program .command("init") .description("initialize OpenWorkflow") + .option("--config ", "path to OpenWorkflow config file") .action(withErrorHandling(init)); // doctor program .command("doctor") .description("check configuration and list available workflows") + .option("--config ", "path to OpenWorkflow config file") .action(withErrorHandling(doctor)); // worker @@ -41,12 +44,14 @@ workerCmd "number of concurrent workflows to process", Number.parseInt, ) + .option("--config ", "path to OpenWorkflow config file") .action(withErrorHandling(workerStart)); // dashboard program .command("dashboard") .description("start the dashboard to view workflow runs") + .option("--config ", "path to OpenWorkflow config file") .action(withErrorHandling(dashboard)); await program.parseAsync(process.argv); diff --git a/packages/cli/commands.ts b/packages/cli/commands.ts index 61b98be7..e296485b 100644 --- a/packages/cli/commands.ts +++ b/packages/cli/commands.ts @@ -1,4 +1,4 @@ -import { WorkerConfig, loadConfig } from "./config.js"; +import { WorkerConfig, loadConfig, loadConfigFromPath } from "./config.js"; import { CLIError } from "./errors.js"; import { CONFIG, @@ -31,6 +31,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); type BackendChoice = "sqlite" | "postgres" | "both"; +interface CommandOptions { + config?: string; +} + /** * openworkflow -V | --version * @returns the version string, or "-" if it cannot be determined @@ -57,11 +61,15 @@ export function getVersion(): string { return "-"; } -/** openworkflow init */ -export async function init(): Promise { +/** + * openworkflow init + * @param options - Command options + */ +export async function init(options: CommandOptions = {}): Promise { + const configPath = options.config; p.intro("Initializing OpenWorkflow..."); - const { configFile } = await loadConfigWithEnv(); + const { configFile } = await loadConfigWithEnv(configPath); let configFileToDelete: string | null = null; if (configFile) { @@ -123,7 +131,7 @@ export async function init(): Promise { ); } - const configFileName = getConfigFileName(packageJson); + const configFileName = configPath ?? getConfigFileName(packageJson); const clientFileName = getClientFileName(packageJson); const exampleWorkflowFileName = getExampleWorkflowFileName(packageJson); const runFileName = getRunFileName(packageJson); @@ -191,11 +199,15 @@ export async function init(): Promise { p.outro("✅ Setup complete!"); } -/** openworkflow doctor */ -export async function doctor(): Promise { +/** + * openworkflow doctor + * @param options - Command options + */ +export async function doctor(options: CommandOptions = {}): Promise { + const configPath = options.config; consola.start("Running OpenWorkflow doctor..."); - const { config, configFile } = await loadConfigWithEnv(); + const { config, configFile } = await loadConfigWithEnv(configPath); if (!configFile) { throw new CLIError( "No config file found.", @@ -244,14 +256,19 @@ export async function doctor(): Promise { } } +export type WorkerStartOptions = WorkerConfig & CommandOptions; + /** * openworkflow worker start - * @param cliOptions - Worker config overrides + * @param options - Worker config and command options */ -export async function workerStart(cliOptions: WorkerConfig): Promise { +export async function workerStart( + options: WorkerStartOptions = {}, +): Promise { + const { config: configPath, ...workerConfig } = options; consola.start("Starting worker..."); - const { config, configFile } = await loadConfigWithEnv(); + const { config, configFile } = await loadConfigWithEnv(configPath); if (!configFile) { throw new CLIError( "No config file found.", @@ -297,7 +314,7 @@ export async function workerStart(cliOptions: WorkerConfig): Promise { assertNoDuplicateWorkflows(workflows); - const workerOptions = mergeDefinedOptions(config.worker, cliOptions); + const workerOptions = mergeDefinedOptions(config.worker, workerConfig); if (workerOptions.concurrency !== undefined) { assertPositiveInteger("concurrency", workerOptions.concurrency); } @@ -323,11 +340,13 @@ export async function workerStart(cliOptions: WorkerConfig): Promise { /** * openworkflow dashboard * Starts the dashboard by delegating to `@openworkflow/dashboard` via npx. + * @param options - Command options */ -export async function dashboard(): Promise { +export async function dashboard(options: CommandOptions = {}): Promise { + const configPath = options.config; consola.start("Starting dashboard..."); - const { configFile } = await loadConfigWithEnv(); + const { configFile } = await loadConfigWithEnv(configPath); if (!configFile) { throw new CLIError( "No config file found.", @@ -808,7 +827,11 @@ function getDevDependenciesToInstall(): string[] { function createConfigFile(configFileName: string): void { const spinner = p.spinner(); spinner.start("Writing config..."); - const configDestPath = path.join(process.cwd(), configFileName); + const configDestPath = path.resolve(process.cwd(), configFileName); + + // mkdir if the user specified a config file, and they want it in a dir + mkdirSync(path.dirname(configDestPath), { recursive: true }); + writeFileSync(configDestPath, CONFIG, "utf8"); spinner.stop(`Config written to ${configDestPath}`); } @@ -1001,12 +1024,15 @@ function updateEnvForPostgres(): void { /** * Load CLI config after loading .env, and wrap errors for user-facing output. + * @param configPath - Optional explicit config file path * @returns Loaded config and metadata. */ -async function loadConfigWithEnv() { +async function loadConfigWithEnv(configPath?: string) { loadDotenv({ quiet: true }); try { - return await loadConfig(); + return configPath + ? await loadConfigFromPath(configPath) + : await loadConfig(); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new CLIError("Failed to load OpenWorkflow config.", message); diff --git a/packages/cli/config.test.ts b/packages/cli/config.test.ts index 2b74f7dd..988364ab 100644 --- a/packages/cli/config.test.ts +++ b/packages/cli/config.test.ts @@ -1,4 +1,4 @@ -import { defineConfig, loadConfig } from "./config.js"; +import { defineConfig, loadConfig, loadConfigFromPath } from "./config.js"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -107,4 +107,30 @@ describe("loadConfig", () => { process.chdir(originalCwd); } }); + + test("loads an explicit config path", async () => { + const filePath = path.join(tmpDir, "src", "openworkflow.config.js"); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `export default { name: "explicit" };`); + + const { config, configFile } = await loadConfigFromPath( + "src/openworkflow.config.js", + tmpDir, + ); + const cfg = config as unknown as TestConfig; + expect(cfg.name).toBe("explicit"); + expect(configFile).toBe(filePath); + }); + + test("does not fallback to discovered config when explicit path is missing", async () => { + const filePath = path.join(tmpDir, "openworkflow.config.js"); + fs.writeFileSync(filePath, `export default { name: "discovered" };`); + + const { config, configFile } = await loadConfigFromPath( + "src/openworkflow.config.js", + tmpDir, + ); + expect(config).toEqual({}); + expect(configFile).toBeUndefined(); + }); }); diff --git a/packages/cli/config.ts b/packages/cli/config.ts index bb9b3724..35ab9433 100644 --- a/packages/cli/config.ts +++ b/packages/cli/config.ts @@ -45,6 +45,22 @@ const CONFIG_NAME = "openworkflow.config"; const CONFIG_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs"] as const; const jiti = createJiti(import.meta.url); +/** + * Load OpenWorkflow config from an explicit path. + * @param configPath - Explicit config file path + * @param startDir - Optional base directory for resolving relative paths + * @returns The loaded configuration and metadata + */ +export async function loadConfigFromPath( + configPath: string, + startDir?: string, +): Promise { + const filePath = path.resolve(startDir ?? process.cwd(), configPath); + return existsSync(filePath) + ? importConfigFile(filePath) + : getEmptyLoadedConfig(); +} + /** * Load the OpenWorkflow config at openworkflow.config.{ts,mts,cts,js,mjs,cjs}. * Searches up the directory tree from the starting directory to find the @@ -64,22 +80,7 @@ export async function loadConfig(startDir?: string): Promise { const filePath = path.join(currentDir, fileName); if (existsSync(filePath)) { - try { - const fileUrl = pathToFileURL(filePath).href; - - const config = await jiti.import(fileUrl, { - default: true, - }); - - return { - config, - configFile: filePath, - }; - } catch (error: unknown) { - throw new Error( - `Failed to load config file ${filePath}: ${String(error)}`, - ); - } + return await importConfigFile(filePath); } } @@ -92,6 +93,35 @@ export async function loadConfig(startDir?: string): Promise { currentDir = parentDir; } + return getEmptyLoadedConfig(); +} + +/** + * Import a config file and wrap load errors with a stable message. + * @param filePath - Absolute config file path. + * @returns Loaded config metadata. + */ +async function importConfigFile(filePath: string): Promise { + try { + const fileUrl = pathToFileURL(filePath).href; + const config = await jiti.import(fileUrl, { + default: true, + }); + + return { + config, + configFile: filePath, + }; + } catch (error: unknown) { + throw new Error(`Failed to load config file ${filePath}: ${String(error)}`); + } +} + +/** + * Return an empty config result when no config file is found. + * @returns Empty config metadata. + */ +function getEmptyLoadedConfig(): LoadedConfig { return { // not great, but meant to match the c12 api since that is what was used in // the initial implementation of loadConfig diff --git a/packages/docs/docs/cli.mdx b/packages/docs/docs/cli.mdx index 864fddd3..ae49225e 100644 --- a/packages/docs/docs/cli.mdx +++ b/packages/docs/docs/cli.mdx @@ -5,7 +5,7 @@ description: Command-line interface for OpenWorkflow The OpenWorkflow CLI is the primary way to set up your project, run workers, and launch the dashboard. CLI commands read your `openworkflow.config.ts` -automatically. +automatically, or you can override the path with `--config`. ## Installation diff --git a/packages/docs/docs/configuration.mdx b/packages/docs/docs/configuration.mdx index 21192c5e..c4d7635b 100644 --- a/packages/docs/docs/configuration.mdx +++ b/packages/docs/docs/configuration.mdx @@ -44,7 +44,11 @@ export default defineConfig({ ``` The CLI automatically finds this file by searching up from the current -directory. +directory. If your config lives somewhere else, pass an explicit path: + +```bash +npx @openworkflow/cli worker start --config src/openworkflow.config.ts +``` ## Verify Configuration