Skip to content
Merged
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
5 changes: 5 additions & 0 deletions packages/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@ const program = new Command();
program
.name("openworkflow")
.description("OpenWorkflow CLI - learn more at https://openworkflow.dev")
.usage("<command> [options]")
.version(getVersion());

// init
program
.command("init")
.description("initialize OpenWorkflow")
.option("--config <path>", "path to OpenWorkflow config file")
.action(withErrorHandling(init));
Comment on lines 15 to 26
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue #284’s example usage shows openworkflow --config <path> worker start (a global option before the subcommand), but this implementation only defines --config on individual subcommands. As written, openworkflow --config … worker start will be rejected as an unknown option by Commander. Either add --config as a global option on the root program and plumb it through, or update the issue/CLI docs/PR scope to reflect that only openworkflow worker start --config … is supported.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already noted in the PR description


// doctor
program
.command("doctor")
.description("check configuration and list available workflows")
.option("--config <path>", "path to OpenWorkflow config file")
.action(withErrorHandling(doctor));

// worker
Expand All @@ -41,12 +44,14 @@ workerCmd
"number of concurrent workflows to process",
Number.parseInt,
)
.option("--config <path>", "path to OpenWorkflow config file")
.action(withErrorHandling(workerStart));

// dashboard
program
.command("dashboard")
.description("start the dashboard to view workflow runs")
.option("--config <path>", "path to OpenWorkflow config file")
.action(withErrorHandling(dashboard));

await program.parseAsync(process.argv);
60 changes: 43 additions & 17 deletions packages/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { WorkerConfig, loadConfig } from "./config.js";
import { WorkerConfig, loadConfig, loadConfigFromPath } from "./config.js";
import { CLIError } from "./errors.js";
import {
CONFIG,
Expand Down Expand Up @@ -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
Expand All @@ -57,11 +61,15 @@ export function getVersion(): string {
return "-";
}

/** openworkflow init */
export async function init(): Promise<void> {
/**
* openworkflow init
* @param options - Command options
*/
export async function init(options: CommandOptions = {}): Promise<void> {
const configPath = options.config;
p.intro("Initializing OpenWorkflow...");

const { configFile } = await loadConfigWithEnv();
const { configFile } = await loadConfigWithEnv(configPath);
let configFileToDelete: string | null = null;

if (configFile) {
Expand Down Expand Up @@ -123,7 +131,7 @@ export async function init(): Promise<void> {
);
}

const configFileName = getConfigFileName(packageJson);
const configFileName = configPath ?? getConfigFileName(packageJson);
const clientFileName = getClientFileName(packageJson);
const exampleWorkflowFileName = getExampleWorkflowFileName(packageJson);
const runFileName = getRunFileName(packageJson);
Expand Down Expand Up @@ -191,11 +199,15 @@ export async function init(): Promise<void> {
p.outro("✅ Setup complete!");
}

/** openworkflow doctor */
export async function doctor(): Promise<void> {
/**
* openworkflow doctor
* @param options - Command options
*/
export async function doctor(options: CommandOptions = {}): Promise<void> {
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.",
Expand Down Expand Up @@ -244,14 +256,19 @@ export async function doctor(): Promise<void> {
}
}

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<void> {
export async function workerStart(
options: WorkerStartOptions = {},
): Promise<void> {
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.",
Expand Down Expand Up @@ -297,7 +314,7 @@ export async function workerStart(cliOptions: WorkerConfig): Promise<void> {

assertNoDuplicateWorkflows(workflows);

const workerOptions = mergeDefinedOptions(config.worker, cliOptions);
const workerOptions = mergeDefinedOptions(config.worker, workerConfig);
if (workerOptions.concurrency !== undefined) {
assertPositiveInteger("concurrency", workerOptions.concurrency);
}
Expand All @@ -323,11 +340,13 @@ export async function workerStart(cliOptions: WorkerConfig): Promise<void> {
/**
* openworkflow dashboard
* Starts the dashboard by delegating to `@openworkflow/dashboard` via npx.
* @param options - Command options
*/
export async function dashboard(): Promise<void> {
export async function dashboard(options: CommandOptions = {}): Promise<void> {
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.",
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/config.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
});
});
62 changes: 46 additions & 16 deletions packages/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoadedConfig> {
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
Expand All @@ -64,22 +80,7 @@ export async function loadConfig(startDir?: string): Promise<LoadedConfig> {
const filePath = path.join(currentDir, fileName);

if (existsSync(filePath)) {
try {
const fileUrl = pathToFileURL(filePath).href;

const config = await jiti.import<OpenWorkflowConfig>(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);
}
}

Expand All @@ -92,6 +93,35 @@ export async function loadConfig(startDir?: string): Promise<LoadedConfig> {
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<LoadedConfig> {
try {
const fileUrl = pathToFileURL(filePath).href;
const config = await jiti.import<OpenWorkflowConfig>(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
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion packages/docs/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down