@openworkflow/cli 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /* v8 ignore file -- @preserve */
3
3
  import { dashboard, doctor, getVersion, init, workerStart, } from "./commands.js";
4
4
  import { withErrorHandling } from "./errors.js";
5
- import { Command } from "commander";
5
+ import { Command, Option } from "commander";
6
6
  // openworkflow
7
7
  const program = new Command();
8
8
  program
@@ -14,13 +14,22 @@ program
14
14
  program
15
15
  .command("init")
16
16
  .description("initialize OpenWorkflow")
17
+ .addOption(new Option("--backend <backend>", "backend to configure").choices([
18
+ "sqlite",
19
+ "postgres",
20
+ "both",
21
+ ]))
22
+ .option("-y, --yes", "skip prompts (requires --backend; does not allow overwrites)")
23
+ .option("--skip-install", "create project files without installing dependencies")
17
24
  .option("--config <path>", "path to OpenWorkflow config file")
25
+ .option("--env-file <path>", "load environment variables from file")
18
26
  .action(withErrorHandling(init));
19
27
  // doctor
20
28
  program
21
29
  .command("doctor")
22
- .description("check configuration and list available workflows")
30
+ .description("check worker prerequisites")
23
31
  .option("--config <path>", "path to OpenWorkflow config file")
32
+ .option("--env-file <path>", "load environment variables from file")
24
33
  .action(withErrorHandling(doctor));
25
34
  // worker
26
35
  const workerCmd = program.command("worker").description("manage workers");
@@ -30,6 +39,7 @@ workerCmd
30
39
  .description("start a worker to process workflows")
31
40
  .option("-c, --concurrency <number>", "number of concurrent workflows to process", Number.parseInt)
32
41
  .option("--config <path>", "path to OpenWorkflow config file")
42
+ .option("--env-file <path>", "load environment variables from file")
33
43
  .action(withErrorHandling(workerStart));
34
44
  // dashboard
35
45
  program
@@ -37,5 +47,6 @@ program
37
47
  .description("start the dashboard to view workflow runs")
38
48
  .option("-p, --port <number>", "custom port for the dashboard server", Number.parseInt)
39
49
  .option("--config <path>", "path to OpenWorkflow config file")
50
+ .option("--env-file <path>", "load environment variables from file")
40
51
  .action(withErrorHandling(dashboard));
41
52
  await program.parseAsync(process.argv);
@@ -1,6 +1,13 @@
1
1
  import { WorkerConfig } from "./config.js";
2
+ type BackendChoice = "sqlite" | "postgres" | "both";
2
3
  interface CommandOptions {
3
4
  config?: string;
5
+ envFile?: string;
6
+ }
7
+ interface InitOptions extends CommandOptions {
8
+ backend?: BackendChoice;
9
+ yes?: boolean;
10
+ skipInstall?: boolean;
4
11
  }
5
12
  interface DashboardOptions extends CommandOptions {
6
13
  port?: number;
@@ -14,7 +21,7 @@ export declare function getVersion(): string;
14
21
  * openworkflow init
15
22
  * @param options - Command options
16
23
  */
17
- export declare function init(options?: CommandOptions): Promise<void>;
24
+ export declare function init(options?: InitOptions): Promise<void>;
18
25
  /**
19
26
  * openworkflow doctor
20
27
  * @param options - Command options
@@ -63,6 +70,7 @@ export declare function dashboard(options?: DashboardOptions): Promise<void>;
63
70
  */
64
71
  export declare function discoverWorkflowFiles(dirs: string[], baseDir: string, ignorePatterns?: string[]): string[];
65
72
  interface PackageJsonForDoctor {
73
+ scripts?: Record<string, string>;
66
74
  dependencies?: Record<string, string>;
67
75
  devDependencies?: Record<string, string>;
68
76
  }
@@ -1 +1 @@
1
- {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAkC,MAAM,aAAa,CAAC;AAiC3E,UAAU,cAAc;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,UAAU,gBAAiB,SAAQ,cAAc;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAoBnC;AAED;;;GAGG;AACH,wBAAsB,IAAI,CAAC,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAoItE;AAED;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAmDxE;AAED,MAAM,MAAM,kBAAkB,GAAG,YAAY,GAAG,cAAc,CAAC;AAE/D;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAuEf;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,YAAY,EAAE;QACZ,KAAK,EAAE,SAAS,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;KACzB,CAAC;CACH,CAYA;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAavE;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8D7E;AAwOD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,MAAM,EACf,cAAc,GAAE,MAAM,EAAO,GAC5B,MAAM,EAAE,CA4CV;AAiWD,UAAU,oBAAoB;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAsBD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAMR;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAKR;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAKR;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAKR"}
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../commands.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EAIb,MAAM,aAAa,CAAC;AAwCrB,KAAK,aAAa,GAAG,QAAQ,GAAG,UAAU,GAAG,MAAM,CAAC;AAEpD,UAAU,cAAc;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,WAAY,SAAQ,cAAc;IAC1C,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,UAAU,gBAAiB,SAAQ,cAAc;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAoBnC;AAED;;;GAGG;AAEH,wBAAsB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAqJnE;AA8BD;;;GAGG;AACH,wBAAsB,MAAM,CAAC,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2DxE;AAED,MAAM,MAAM,kBAAkB,GAAG,YAAY,GAAG,cAAc,CAAC;AAE/D;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CA8Ef;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,YAAY,EAAE;QACZ,KAAK,EAAE,SAAS,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;KACzB,CAAC;CACH,CAYA;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAavE;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6D7E;AA4PD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,MAAM,EACf,cAAc,GAAE,MAAM,EAAO,GAC5B,MAAM,EAAE,CA8CV;AA2WD,UAAU,oBAAoB;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAoCD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAER;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAER;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAER;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,IAAI,GACjD,MAAM,CAER"}
package/dist/commands.js CHANGED
@@ -1,18 +1,19 @@
1
- import { loadConfig, loadConfigFromPath } from "./config.js";
2
- import { CLIError } from "./errors.js";
1
+ import { findConfigFile, loadConfigFromPath, } from "./config.js";
2
+ import { CLIError, exit } from "./errors.js";
3
+ import { createModuleLoader } from "./module-loader.js";
3
4
  import { CONFIG, HELLO_WORLD_RUNNER, HELLO_WORLD_WORKFLOW, POSTGRES_CLIENT, POSTGRES_PROD_SQLITE_DEV_CLIENT, SQLITE_CLIENT, } from "./templates.js";
4
5
  import * as p from "@clack/prompts";
5
6
  import { consola } from "consola";
6
7
  import { config as loadDotenv } from "dotenv";
7
- import { createJiti } from "jiti";
8
8
  import { spawn } from "node:child_process";
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
- import { addDependency, detectPackageManager } from "nypm";
12
+ import { addDependency, addDependencyCommand, detectPackageManager, } from "nypm";
13
13
  import { OpenWorkflow } from "openworkflow";
14
14
  import { isWorkflow } from "openworkflow/internal";
15
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const workflowSources = new WeakMap();
16
17
  /**
17
18
  * openworkflow -V | --version
18
19
  * @returns the version string, or "-" if it cannot be determined
@@ -40,94 +41,107 @@ export function getVersion() {
40
41
  * openworkflow init
41
42
  * @param options - Command options
42
43
  */
44
+ // oxlint-disable-next-line complexity
43
45
  export async function init(options = {}) {
44
- const configPath = options.config;
46
+ if (options.yes && !options.backend) {
47
+ throw new CLIError("--backend is required with --yes.");
48
+ }
49
+ if (!options.yes && !process.stdin.isTTY) {
50
+ throw new CLIError("Interactive setup requires a terminal. Pass --backend sqlite|postgres|both --yes.");
51
+ }
45
52
  p.intro("Initializing OpenWorkflow...");
46
- const { configFile } = await loadConfigWithEnv(configPath);
53
+ const configFile = findConfigWithEnv(options);
47
54
  let configFileToDelete = null;
48
- if (configFile) {
55
+ if (configFile && existsSync(configFile)) {
56
+ if (options.yes) {
57
+ throw new CLIError(`Config file already exists at ${configFile}. --yes does not allow overwrites.`);
58
+ }
49
59
  const shouldOverride = await p.confirm({
50
60
  message: `Config file already exists at ${configFile}. Override it?`,
51
61
  initialValue: false,
52
62
  });
53
- if (!shouldOverride || p.isCancel(shouldOverride)) {
54
- p.cancel("Setup canceled.");
55
- // eslint-disable-next-line unicorn/no-process-exit
56
- process.exit(0);
57
- }
63
+ if (!shouldOverride || p.isCancel(shouldOverride))
64
+ cancelSetup();
58
65
  configFileToDelete = configFile;
59
66
  }
60
- const backendChoice = await p.select({
61
- message: "Select a backend for OpenWorkflow:",
62
- options: [
63
- {
64
- value: "sqlite",
65
- label: "SQLite",
66
- hint: "Recommended for testing and development",
67
- },
68
- {
69
- value: "postgres",
70
- label: "PostgreSQL",
71
- hint: "Recommended for production",
72
- },
73
- {
74
- value: "both",
75
- label: "Both",
76
- hint: "SQLite for dev, PostgreSQL for production",
77
- },
78
- ],
79
- initialValue: "sqlite",
80
- });
81
- if (p.isCancel(backendChoice)) {
82
- p.cancel("Setup canceled.");
83
- // eslint-disable-next-line unicorn/no-process-exit
84
- process.exit(0);
85
- }
67
+ const backendChoice = options.backend ??
68
+ (await p.select({
69
+ message: "Select a backend for OpenWorkflow:",
70
+ options: [
71
+ {
72
+ value: "sqlite",
73
+ label: "SQLite",
74
+ hint: "Recommended for testing and development",
75
+ },
76
+ {
77
+ value: "postgres",
78
+ label: "PostgreSQL",
79
+ hint: "Recommended for production",
80
+ },
81
+ {
82
+ value: "both",
83
+ label: "Both",
84
+ hint: "SQLite for dev, PostgreSQL for production",
85
+ },
86
+ ],
87
+ initialValue: "sqlite",
88
+ }));
89
+ if (typeof backendChoice === "symbol")
90
+ cancelSetup();
86
91
  const spinner = p.spinner();
87
92
  // detect package manager & install packages
88
93
  spinner.start("Detecting package manager...");
89
94
  const pm = await detectPackageManager(process.cwd());
90
- const packageManager = pm?.name ?? "your package manager";
95
+ const packageManager = pm?.name ?? "npm";
91
96
  spinner.stop(`Using ${packageManager}`);
92
97
  const packageJson = readPackageJsonForDoctor();
93
98
  if (!packageJson) {
94
99
  throw new CLIError("No package.json found.", "Please create a package.json file first by running `npm init` or `npm init -y`.");
95
100
  }
96
- const configFileName = configPath ?? getConfigFileName(packageJson);
101
+ validateInitManifest(packageJson);
102
+ const configFileName = options.config ?? getConfigFileName(packageJson);
97
103
  const clientFileName = getClientFileName(packageJson);
98
104
  const exampleWorkflowFileName = getExampleWorkflowFileName(packageJson);
99
105
  const runFileName = getRunFileName(packageJson);
100
106
  const runCommand = runFileName.endsWith(".ts")
101
107
  ? `npx tsx openworkflow/${runFileName}`
102
108
  : `node openworkflow/${runFileName}`;
103
- const shouldSetup = await p.confirm({
104
- message: "Install packages and set up project files?",
105
- initialValue: true,
106
- });
107
- if (p.isCancel(shouldSetup)) {
108
- p.cancel("Setup canceled.");
109
- // eslint-disable-next-line unicorn/no-process-exit
110
- process.exit(0);
111
- }
109
+ const shouldSetup = options.yes ??
110
+ (await p.confirm({
111
+ message: options.skipInstall
112
+ ? "Set up project files?"
113
+ : "Install packages and set up project files?",
114
+ initialValue: true,
115
+ }));
116
+ if (p.isCancel(shouldSetup))
117
+ cancelSetup();
112
118
  if (!shouldSetup) {
113
119
  p.outro("Setup skipped.");
114
120
  return;
115
121
  }
116
- if (configFileToDelete) {
117
- unlinkSync(configFileToDelete);
122
+ const dependencies = getDependenciesToInstall(backendChoice);
123
+ const devDependencies = getDevDependenciesToInstall();
124
+ if (options.skipInstall) {
125
+ p.note([
126
+ addDependencyCommand(packageManager, dependencies),
127
+ addDependencyCommand(packageManager, devDependencies, { dev: true }),
128
+ ].join("\n"), "Install dependencies before running OpenWorkflow");
118
129
  }
119
- {
120
- const dependencies = getDependenciesToInstall(backendChoice);
130
+ else {
121
131
  spinner.start(`Installing ${dependencies.join(", ")}...`);
122
- await addDependency(dependencies, { silent: true });
132
+ await addDependency(dependencies, { silent: true, packageManager });
123
133
  spinner.stop(`Installed ${dependencies.join(", ")}`);
124
- }
125
- {
126
- const devDependencies = getDevDependenciesToInstall();
127
134
  spinner.start(`Installing ${devDependencies.join(", ")}...`);
128
- await addDependency(devDependencies, { silent: true, dev: true });
135
+ await addDependency(devDependencies, {
136
+ silent: true,
137
+ dev: true,
138
+ packageManager,
139
+ });
129
140
  spinner.stop(`Installed ${devDependencies.join(", ")}`);
130
141
  }
142
+ if (configFileToDelete) {
143
+ unlinkSync(configFileToDelete);
144
+ }
131
145
  createClientFile(backendChoice, clientFileName);
132
146
  createExampleWorkflow(exampleWorkflowFileName);
133
147
  createRunFile(runFileName);
@@ -145,56 +159,89 @@ export async function init(options = {}) {
145
159
  p.note(`➡️ Start a worker:\n$ npx @openworkflow/cli worker start\n\n➡️ Run the example workflow:\n$ ${runCommand}\n\n➡️ View the dashboard:\n$ npx @openworkflow/cli dashboard`, "Next steps");
146
160
  p.outro("✅ Setup complete!");
147
161
  }
162
+ // Validate the manifest fields that init reads or updates.
163
+ function validateInitManifest(manifest) {
164
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
165
+ throw new CLIError("Invalid package.json: expected an object.");
166
+ }
167
+ for (const key of ["scripts", "dependencies", "devDependencies"]) {
168
+ const field = manifest[key];
169
+ if (field === undefined)
170
+ continue;
171
+ if (field === null ||
172
+ typeof field !== "object" ||
173
+ Array.isArray(field) ||
174
+ Object.values(field).some((value) => typeof value !== "string")) {
175
+ throw new CLIError(`Invalid package.json: ${key} must be an object containing string values.`);
176
+ }
177
+ }
178
+ const { scripts } = manifest;
179
+ const worker = scripts?.["worker"];
180
+ if (worker !== undefined && worker !== "npx @openworkflow/cli worker start") {
181
+ throw new CLIError("Setup would overwrite package.json scripts.worker.");
182
+ }
183
+ }
148
184
  /**
149
185
  * openworkflow doctor
150
186
  * @param options - Command options
151
187
  */
152
188
  export async function doctor(options = {}) {
153
- const configPath = options.config;
154
189
  consola.start("Running OpenWorkflow doctor...");
155
- const { config, configFile } = await loadConfigWithEnv(configPath);
190
+ const timer = setTimeout(() => {
191
+ consola.error("Doctor timed out after 30 seconds.");
192
+ void exit(1);
193
+ }, 30_000);
194
+ const { config, configFile } = await loadConfigWithEnv(options);
156
195
  if (!configFile) {
157
196
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file.");
158
197
  }
159
198
  const backend = config.backend;
199
+ let cleanupFailed = false;
160
200
  try {
201
+ await checkBackendConnection(backend);
202
+ if (config.worker?.concurrency !== undefined) {
203
+ assertPositiveInteger("concurrency", config.worker.concurrency);
204
+ }
161
205
  consola.log("");
162
- consola.info(`Config file: ${configFile}`);
206
+ consola.info(`Config file: ${path.relative(process.cwd(), configFile)}`);
163
207
  const backendName = backend.constructor.name.replace("Backend", "");
164
208
  consola.log(` • Backend: ${backendName}`);
165
- const packageJson = readPackageJsonForDoctor();
166
- if (packageJson) {
167
- warnIfMissingBackendPackage(backendName, packageJson);
168
- warnIfMissingTsconfig(packageJson);
169
- }
170
209
  // discover directories
171
- const dirs = getWorkflowDirectories(config);
210
+ const dirs = [...new Set(getWorkflowDirectories(config))];
172
211
  consola.log(` • Workflow directories: ${dirs.join(", ")}`);
173
212
  // discover files
174
213
  const configFileDir = path.dirname(configFile);
175
- const { files, workflows } = await discoverWorkflowsInDirs(dirs, configFileDir, config.ignorePatterns ?? []);
176
- consola.log("");
177
- consola.info(`Found ${String(files.length)} workflow file(s):`);
178
- for (const file of files) {
179
- consola.log(` • ${file}`);
180
- }
214
+ const { workflows } = await discoverWorkflowsInDirs(dirs, configFileDir, config.ignorePatterns ?? []);
215
+ assertNoDuplicateWorkflows(workflows);
181
216
  printDiscoveredWorkflows(workflows);
182
- warnAboutDuplicateWorkflows(workflows);
183
- consola.log("");
184
- consola.success("Configuration looks good!");
185
217
  }
186
218
  finally {
187
- await backend.stop();
219
+ // Imported configs can omit the backend despite the declared config type.
220
+ // oxlint-disable-next-line typescript/no-unnecessary-condition
221
+ if (typeof backend?.stop === "function") {
222
+ try {
223
+ await backend.stop();
224
+ }
225
+ catch (error) {
226
+ cleanupFailed = true;
227
+ consola.error(`Backend cleanup failed: ${String(error)}`);
228
+ }
229
+ }
188
230
  }
231
+ clearTimeout(timer);
232
+ if (cleanupFailed)
233
+ await exit(1);
234
+ consola.log("");
235
+ consola.success("Configuration looks good!");
236
+ await exit(0);
189
237
  }
190
238
  /**
191
239
  * openworkflow worker start
192
240
  * @param options - Worker config and command options
193
241
  */
194
242
  export async function workerStart(options = {}) {
195
- const { config: configPath, ...workerConfig } = options;
196
243
  consola.start("Starting worker...");
197
- const { config, configFile } = await loadConfigWithEnv(configPath);
244
+ const { config, configFile } = await loadConfigWithEnv(options);
198
245
  if (!configFile) {
199
246
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file.");
200
247
  }
@@ -217,6 +264,7 @@ export async function workerStart(options = {}) {
217
264
  consola.success("Worker stopped");
218
265
  }
219
266
  try {
267
+ await checkBackendConnection(backend);
220
268
  // discover and import workflows
221
269
  const dirs = getWorkflowDirectories(config);
222
270
  consola.info(`Discovering workflows from: ${dirs.join(", ")}`);
@@ -225,7 +273,9 @@ export async function workerStart(options = {}) {
225
273
  consola.info(`Found ${String(files.length)} workflow file(s)`);
226
274
  consola.success(`Loaded ${String(workflows.length)} workflow(s): ${workflows.map((w) => w.spec.name).join(", ")}`);
227
275
  assertNoDuplicateWorkflows(workflows);
228
- const workerOptions = mergeDefinedOptions(config.worker, workerConfig);
276
+ const workerOptions = mergeDefinedOptions(config.worker, {
277
+ concurrency: options.concurrency,
278
+ });
229
279
  if (workerOptions.concurrency !== undefined) {
230
280
  assertPositiveInteger("concurrency", workerOptions.concurrency);
231
281
  }
@@ -240,7 +290,12 @@ export async function workerStart(options = {}) {
240
290
  consola.success("Worker started.");
241
291
  }
242
292
  catch (error) {
243
- await gracefulShutdown();
293
+ try {
294
+ await gracefulShutdown();
295
+ }
296
+ catch (cleanupError) {
297
+ consola.warn(`Backend cleanup failed: ${String(cleanupError)}`);
298
+ }
244
299
  throw error;
245
300
  }
246
301
  }
@@ -283,10 +338,9 @@ export function validateDashboardPort(port) {
283
338
  * @returns Resolves when the dashboard process exits.
284
339
  */
285
340
  export async function dashboard(options = {}) {
286
- const configPath = options.config;
287
341
  const port = validateDashboardPort(options.port);
288
342
  consola.start("Starting dashboard...");
289
- const { configFile } = await loadConfigWithEnv(configPath);
343
+ const { configFile } = await loadConfigWithEnv(options);
290
344
  if (!configFile) {
291
345
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file before starting the dashboard.");
292
346
  }
@@ -324,6 +378,30 @@ export async function dashboard(options = {}) {
324
378
  });
325
379
  }
326
380
  // -----------------------------------------------------------------------------
381
+ /**
382
+ * Show a canceled-setup message and exit the process with status 0.
383
+ */
384
+ function cancelSetup() {
385
+ p.cancel("Setup canceled.");
386
+ // oxlint-disable-next-line unicorn/no-process-exit
387
+ process.exit(0);
388
+ }
389
+ /**
390
+ * Exercise backend initialization, connectivity, and workflow table access.
391
+ * @param backend - Configured backend
392
+ */
393
+ async function checkBackendConnection(backend) {
394
+ if (typeof backend?.listWorkflowRuns !== "function" ||
395
+ typeof backend.stop !== "function") {
396
+ throw new CLIError("Missing or invalid backend.", "Set config.backend to a connected OpenWorkflow backend.");
397
+ }
398
+ try {
399
+ await backend.listWorkflowRuns({ limit: 1 });
400
+ }
401
+ catch (error) {
402
+ throw new CLIError("Failed to access backend.", error instanceof Error ? error.message : String(error));
403
+ }
404
+ }
327
405
  /**
328
406
  * Get workflow directories from config.
329
407
  * @param config - The loaded config
@@ -355,7 +433,7 @@ function findDuplicateWorkflows(workflows) {
355
433
  for (const workflow of workflows) {
356
434
  const name = workflow.spec.name;
357
435
  const version = workflow.spec.version ?? null;
358
- const key = version ? `${name}@${version}` : name;
436
+ const key = JSON.stringify([name, version]);
359
437
  const existing = workflowKeys.get(key);
360
438
  if (existing) {
361
439
  existing.count += 1;
@@ -383,32 +461,18 @@ function assertNoDuplicateWorkflows(workflows) {
383
461
  const suffix = remaining > 0 ? ` (+${String(remaining)} more)` : "";
384
462
  throw new CLIError(`Duplicate workflow name${duplicates.length === 1 ? "" : "s"} detected: ${preview}${suffix}`, "Multiple workflow files export workflows with the same name and version. Each workflow must have a unique name and version combination.");
385
463
  }
386
- /**
387
- * Warn about duplicate workflows without failing.
388
- * @param workflows - Discovered workflows
389
- */
390
- function warnAboutDuplicateWorkflows(workflows) {
391
- const duplicates = findDuplicateWorkflows(workflows);
392
- for (const duplicate of duplicates) {
393
- const versionStr = duplicate.version
394
- ? ` (version: ${duplicate.version})`
395
- : "";
396
- consola.warn(`Duplicate workflow detected: "${duplicate.name}"${versionStr}`);
397
- consola.warn("Multiple files export a workflow with the same name and version.");
398
- }
399
- }
400
464
  /**
401
465
  * Print discovered workflows to the console.
402
466
  * @param workflows - Array of discovered workflows
403
467
  */
404
468
  function printDiscoveredWorkflows(workflows) {
405
469
  consola.log("");
406
- consola.info(`Discovered ${String(workflows.length)} workflow(s):`);
470
+ consola.info(`Found ${String(workflows.length)} workflow${workflows.length === 1 ? "" : "s"}:`);
407
471
  for (const workflow of workflows) {
408
472
  const name = workflow.spec.name;
409
- const version = workflow.spec.version ?? "unversioned";
410
- const versionStr = version === "unversioned" ? "" : ` (version: ${version})`;
411
- consola.log(` • ${name}${versionStr}`);
473
+ const version = workflow.spec.version;
474
+ const versionStr = version ? ` (${version})` : "";
475
+ consola.log(` • ${name}${versionStr} — ${workflowSources.get(workflow)}`);
412
476
  }
413
477
  }
414
478
  const WORKFLOW_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs"];
@@ -480,8 +544,8 @@ function globToRegExp(pattern) {
480
544
  return new RegExp(regex);
481
545
  }
482
546
  /**
483
- * Check whether a file path matches ignore patterns.
484
- * @param filePath - Absolute file path
547
+ * Check whether a file or directory path matches ignore patterns.
548
+ * @param filePath - Absolute path, with a trailing separator for directories
485
549
  * @param baseDir - Base directory for relative matching
486
550
  * @param matchers - Compiled regex matchers
487
551
  * @returns Whether the file should be ignored
@@ -491,7 +555,11 @@ function isIgnoredFile(filePath, baseDir, matchers) {
491
555
  return false;
492
556
  const relativePath = normalizeForGlobMatch(path.relative(baseDir, filePath));
493
557
  const fileName = path.basename(filePath);
494
- return matchers.some((matcher) => matcher.test(relativePath) || matcher.test(fileName));
558
+ const isDirectory = filePath.endsWith(path.sep);
559
+ return matchers.some((matcher) => matcher.test(relativePath) ||
560
+ matcher.test(fileName) ||
561
+ (isDirectory &&
562
+ (matcher.test(`${relativePath}/`) || matcher.test(`${fileName}/`))));
495
563
  }
496
564
  /**
497
565
  * Discover workflow files from directories. Recursively scans directories for
@@ -516,19 +584,18 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
516
584
  entries = readdirSync(absoluteDir, { withFileTypes: true });
517
585
  }
518
586
  catch (error) {
519
- // doesn't exist or can't be read, skip
520
- const errMessage = error instanceof Error ? error.message : String(error);
521
- consola.debug(`Failed to read directory: ${absoluteDir} - ${errMessage}`);
522
- return;
587
+ throw new CLIError(`Cannot read workflow directory: ${absoluteDir}`, `${String(error)}\nCorrect config.dirs or make the directory readable.`);
523
588
  }
524
589
  for (const entry of entries) {
525
590
  const fullPath = path.join(absoluteDir, entry.name);
526
591
  if (entry.isDirectory()) {
527
- scanDirectory(fullPath);
592
+ if (!isIgnoredFile(`${fullPath}${path.sep}`, baseDir, matchers)) {
593
+ scanDirectory(fullPath);
594
+ }
528
595
  }
529
596
  else if (entry.isFile() &&
530
597
  WORKFLOW_EXTENSIONS.some((ext) => entry.name.endsWith(`.${ext}`)) &&
531
- !entry.name.endsWith(".d.ts") &&
598
+ !/\.d\.(?:ts|mts|cts)$/.test(entry.name) &&
532
599
  !isIgnoredFile(fullPath, baseDir, matchers)) {
533
600
  discoveredFiles.push(fullPath);
534
601
  }
@@ -537,7 +604,7 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
537
604
  for (const dir of dirs) {
538
605
  scanDirectory(dir);
539
606
  }
540
- return discoveredFiles;
607
+ return [...new Set(discoveredFiles)];
541
608
  }
542
609
  /**
543
610
  * Import workflow files and extract workflow exports.
@@ -547,11 +614,11 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
547
614
  */
548
615
  async function importWorkflows(files) {
549
616
  const workflows = [];
550
- const jiti = createJiti(import.meta.url);
551
617
  for (const file of files) {
552
618
  // import the module
553
619
  let module;
554
620
  try {
621
+ const jiti = createModuleLoader(file);
555
622
  module = await jiti.import(pathToFileURL(file).href);
556
623
  }
557
624
  catch (error) {
@@ -561,13 +628,13 @@ async function importWorkflows(files) {
561
628
  // extract workflow exports (named and default)
562
629
  for (const [key, value] of Object.entries(module)) {
563
630
  if (isWorkflow(value)) {
564
- const workflow = value;
565
- workflows.push(workflow);
566
- consola.debug(`Found workflow "${workflow.spec.name}" in ${file} (${key})`);
631
+ workflows.push(value);
632
+ workflowSources.set(value, path.relative(process.cwd(), file));
633
+ consola.debug(`Found workflow "${value.spec.name}" in ${file} (${key})`);
567
634
  }
568
635
  }
569
636
  }
570
- return workflows;
637
+ return [...new Set(workflows)];
571
638
  }
572
639
  /**
573
640
  * Discover workflow files and import workflows with common error handling.
@@ -646,24 +713,36 @@ function createConfigFile(configFileName) {
646
713
  spinner.stop(`Config written to ${configDestPath}`);
647
714
  }
648
715
  /**
649
- * Create hello-world runner file.
650
- * @param runFileName - The runner filename to write
716
+ * Write a file under the `openworkflow/` project directory, skipping when a
717
+ * file with the same name already exists. Progress is surfaced via the clack
718
+ * spinner using the provided label (e.g. "client file").
719
+ * @param label - Lowercase label describing the file (used in spinner text)
720
+ * @param fileName - Filename to write inside `openworkflow/`
721
+ * @param content - File contents to write when the file does not exist
651
722
  */
652
- function createRunFile(runFileName) {
723
+ function writeWorkflowFileIfMissing(label, fileName, content) {
653
724
  const spinner = p.spinner();
654
725
  const workflowsDir = path.join(process.cwd(), "openworkflow");
655
726
  if (!existsSync(workflowsDir)) {
656
727
  mkdirSync(workflowsDir, { recursive: true });
657
728
  }
658
- const runDestPath = path.join(workflowsDir, runFileName);
659
- if (existsSync(runDestPath)) {
660
- spinner.start("Checking hello-world runner...");
661
- spinner.stop(`Hello-world runner already exists at ${runDestPath}`);
729
+ const destPath = path.join(workflowsDir, fileName);
730
+ if (existsSync(destPath)) {
731
+ spinner.start(`Checking ${label}...`);
732
+ const capitalized = label.charAt(0).toUpperCase() + label.slice(1);
733
+ spinner.stop(`${capitalized} already exists at ${destPath}`);
662
734
  return;
663
735
  }
664
- spinner.start("Creating hello-world runner...");
665
- writeFileSync(runDestPath, HELLO_WORLD_RUNNER, "utf8");
666
- spinner.stop(`Created hello-world runner at ${runDestPath}`);
736
+ spinner.start(`Creating ${label}...`);
737
+ writeFileSync(destPath, content, "utf8");
738
+ spinner.stop(`Created ${label} at ${destPath}`);
739
+ }
740
+ /**
741
+ * Create hello-world runner file.
742
+ * @param runFileName - The runner filename to write
743
+ */
744
+ function createRunFile(runFileName) {
745
+ writeWorkflowFileIfMissing("hello-world runner", runFileName, HELLO_WORLD_RUNNER);
667
746
  }
668
747
  /**
669
748
  * Create client file.
@@ -671,41 +750,14 @@ function createRunFile(runFileName) {
671
750
  * @param clientFileName - The client filename to write
672
751
  */
673
752
  function createClientFile(backendChoice, clientFileName) {
674
- const spinner = p.spinner();
675
- const workflowsDir = path.join(process.cwd(), "openworkflow");
676
- if (!existsSync(workflowsDir)) {
677
- mkdirSync(workflowsDir, { recursive: true });
678
- }
679
- const clientDestPath = path.join(workflowsDir, clientFileName);
680
- if (existsSync(clientDestPath)) {
681
- spinner.start("Checking client file...");
682
- spinner.stop(`Client file already exists at ${clientDestPath}`);
683
- return;
684
- }
685
- spinner.start("Creating client file...");
686
- const clientTemplate = getClientTemplate(backendChoice);
687
- writeFileSync(clientDestPath, clientTemplate, "utf8");
688
- spinner.stop(`Created client file at ${clientDestPath}`);
753
+ writeWorkflowFileIfMissing("client file", clientFileName, getClientTemplate(backendChoice));
689
754
  }
690
755
  /**
691
756
  * Create example workflow.
692
757
  * @param exampleWorkflowFileName - The example workflow filename to write
693
758
  */
694
759
  function createExampleWorkflow(exampleWorkflowFileName) {
695
- const spinner = p.spinner();
696
- const workflowsDir = path.join(process.cwd(), "openworkflow");
697
- if (!existsSync(workflowsDir)) {
698
- mkdirSync(workflowsDir, { recursive: true });
699
- }
700
- const helloWorldDestPath = path.join(workflowsDir, exampleWorkflowFileName);
701
- if (existsSync(helloWorldDestPath)) {
702
- spinner.start("Checking example (hello-world) workflow...");
703
- spinner.stop(`Example (hello-world) workflow already exists at ${helloWorldDestPath}`);
704
- return;
705
- }
706
- spinner.start("Creating example (hello-world) workflow...");
707
- writeFileSync(helloWorldDestPath, HELLO_WORLD_WORKFLOW, "utf8");
708
- spinner.stop(`Created example (hello-world) workflow at ${helloWorldDestPath}`);
760
+ writeWorkflowFileIfMissing("example (hello-world) workflow", exampleWorkflowFileName, HELLO_WORLD_WORKFLOW);
709
761
  }
710
762
  /**
711
763
  * Update .gitignore for SQLite.
@@ -718,8 +770,8 @@ function updateGitignoreForSqlite() {
718
770
  const gitignorePath = path.join(process.cwd(), ".gitignore");
719
771
  const spinner = p.spinner();
720
772
  spinner.start("Updating .gitignore...");
721
- const result = ensureGitignoreEntry(gitignorePath, "openworkflow/backend.db*");
722
- spinner.stop(result.added
773
+ const added = ensureGitignoreEntry(gitignorePath, "openworkflow/backend.db*");
774
+ spinner.stop(added
723
775
  ? "Added openworkflow/backend.db* to .gitignore"
724
776
  : "openworkflow/backend.db* already in .gitignore");
725
777
  }
@@ -737,7 +789,7 @@ function addWorkerScriptToPackageJson() {
737
789
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
738
790
  packageJson.scripts ??= {};
739
791
  packageJson.scripts["worker"] = "npx @openworkflow/cli worker start";
740
- writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf8");
792
+ writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
741
793
  spinner.stop('Added "worker" script to package.json');
742
794
  }
743
795
  catch {
@@ -745,38 +797,33 @@ function addWorkerScriptToPackageJson() {
745
797
  consola.warn("Could not add worker script to package.json");
746
798
  }
747
799
  }
800
+ /**
801
+ * Append a line to a file if no existing line matches. Creates the file if it
802
+ * doesn't exist.
803
+ * @param filePath - Path to the file
804
+ * @param line - Line to append (without a trailing newline)
805
+ * @param matchesExisting - Predicate that returns true when an existing line
806
+ * should be treated as already representing `line`
807
+ * @returns Whether the line was appended
808
+ */
809
+ function appendLineIfMissing(filePath, line, matchesExisting) {
810
+ const content = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
811
+ if (content.split("\n").some((existing) => matchesExisting(existing))) {
812
+ return false;
813
+ }
814
+ const separator = content === "" || content.endsWith("\n") ? "" : "\n";
815
+ writeFileSync(filePath, `${content}${separator}${line}\n`, "utf8");
816
+ return true;
817
+ }
748
818
  /**
749
819
  * Ensure a specific entry exists in a .gitignore file. Creates the file if it
750
820
  * doesn't exist, appends the entry if not present.
751
821
  * @param gitignorePath - Path to the .gitignore file
752
822
  * @param entry - The entry to add (e.g. "openworkflow/backend.db*")
753
- * @returns Object indicating whether the entry was added or already existed
823
+ * @returns Whether the entry was appended
754
824
  */
755
825
  function ensureGitignoreEntry(gitignorePath, entry) {
756
- const fileExists = existsSync(gitignorePath);
757
- let content = "";
758
- if (fileExists) {
759
- content = readFileSync(gitignorePath, "utf8");
760
- }
761
- // check if entry already exists
762
- const lines = content.split("\n");
763
- const hasEntry = lines.some((line) => line.trim() === entry);
764
- if (hasEntry) {
765
- return { added: false, created: false };
766
- }
767
- // add entry to .gitignore
768
- let newContent;
769
- if (content === "") {
770
- newContent = `${entry}\n`;
771
- }
772
- else if (content.endsWith("\n")) {
773
- newContent = `${content}${entry}\n`;
774
- }
775
- else {
776
- newContent = `${content}\n${entry}\n`;
777
- }
778
- writeFileSync(gitignorePath, newContent, "utf8");
779
- return { added: true, created: !fileExists };
826
+ return appendLineIfMissing(gitignorePath, entry, (line) => line.trim() === entry);
780
827
  }
781
828
  /**
782
829
  * Add OPENWORKFLOW_POSTGRES_URL to .env file.
@@ -785,22 +832,36 @@ function updateEnvForPostgres() {
785
832
  const envPath = path.join(process.cwd(), ".env");
786
833
  const spinner = p.spinner();
787
834
  spinner.start("Updating .env...");
788
- const result = ensureEnvEntry(envPath, "OPENWORKFLOW_POSTGRES_URL", "postgresql://user:password@localhost:5432/openworkflow");
789
- spinner.stop(result.added
835
+ const added = ensureEnvEntry(envPath, "OPENWORKFLOW_POSTGRES_URL", "postgresql://user:password@localhost:5432/openworkflow");
836
+ spinner.stop(added
790
837
  ? "Added OPENWORKFLOW_POSTGRES_URL to .env"
791
838
  : "OPENWORKFLOW_POSTGRES_URL already in .env");
792
839
  }
793
840
  /**
794
- * Load CLI config after loading .env, and wrap errors for user-facing output.
795
- * @param configPath - Optional explicit config file path
796
- * @returns Loaded config and metadata.
841
+ * Find the config and load its environment without importing it.
842
+ * @param options - Config and environment file paths
843
+ * @returns Config path, if found.
797
844
  */
798
- async function loadConfigWithEnv(configPath) {
799
- loadDotenv({ quiet: true });
845
+ function findConfigWithEnv(options) {
846
+ const { envFile } = options;
847
+ const configPath = options.config
848
+ ? path.resolve(options.config)
849
+ : findConfigFile();
850
+ const baseDir = configPath ? path.dirname(configPath) : process.cwd();
851
+ const { error } = loadDotenv({
852
+ path: envFile ?? path.join(baseDir, ".env"),
853
+ quiet: true,
854
+ });
855
+ if (envFile !== undefined && error) {
856
+ throw new CLIError(`Failed to load environment file: ${envFile}`, error.message);
857
+ }
858
+ return configPath;
859
+ }
860
+ // Load the environment before importing config for commands that use it.
861
+ async function loadConfigWithEnv(options) {
862
+ const configPath = findConfigWithEnv(options);
800
863
  try {
801
- return configPath
802
- ? await loadConfigFromPath(configPath)
803
- : await loadConfig();
864
+ return await loadConfigFromPath(configPath ?? "openworkflow.config.ts");
804
865
  }
805
866
  catch (error) {
806
867
  const message = error instanceof Error ? error.message : String(error);
@@ -824,16 +885,24 @@ function readPackageJsonForDoctor() {
824
885
  return null;
825
886
  }
826
887
  }
888
+ /**
889
+ * Pick the script file extension for generated files based on whether the
890
+ * project uses TypeScript.
891
+ * @param packageJson - Parsed package.json (or null if missing)
892
+ * @returns ".ts" when TypeScript is a dependency, otherwise ".js"
893
+ */
894
+ function getScriptExtension(packageJson) {
895
+ return packageJson && hasDependency(packageJson, "typescript")
896
+ ? ".ts"
897
+ : ".js";
898
+ }
827
899
  /**
828
900
  * Determine the config filename to write during init.
829
901
  * @param packageJson - Parsed package.json (or null if missing)
830
902
  * @returns The config file name to create
831
903
  */
832
904
  export function getConfigFileName(packageJson) {
833
- if (packageJson && hasDependency(packageJson, "typescript")) {
834
- return "openworkflow.config.ts";
835
- }
836
- return "openworkflow.config.js";
905
+ return `openworkflow.config${getScriptExtension(packageJson)}`;
837
906
  }
838
907
  /**
839
908
  * Determine the example workflow filename to write during init.
@@ -841,9 +910,7 @@ export function getConfigFileName(packageJson) {
841
910
  * @returns The example workflow file name to create
842
911
  */
843
912
  export function getExampleWorkflowFileName(packageJson) {
844
- const configFileName = getConfigFileName(packageJson);
845
- const extension = path.extname(configFileName) || ".js";
846
- return `hello-world${extension}`;
913
+ return `hello-world${getScriptExtension(packageJson)}`;
847
914
  }
848
915
  /**
849
916
  * Determine the hello-world runner filename to write during init.
@@ -851,9 +918,7 @@ export function getExampleWorkflowFileName(packageJson) {
851
918
  * @returns The runner file name to create
852
919
  */
853
920
  export function getRunFileName(packageJson) {
854
- const configFileName = getConfigFileName(packageJson);
855
- const extension = path.extname(configFileName) || ".js";
856
- return `hello-world.run${extension}`;
921
+ return `hello-world.run${getScriptExtension(packageJson)}`;
857
922
  }
858
923
  /**
859
924
  * Determine the client filename to write during init.
@@ -861,9 +926,7 @@ export function getRunFileName(packageJson) {
861
926
  * @returns The client file name to create
862
927
  */
863
928
  export function getClientFileName(packageJson) {
864
- const configFileName = getConfigFileName(packageJson);
865
- const extension = path.extname(configFileName) || ".js";
866
- return `client${extension}`;
929
+ return `client${getScriptExtension(packageJson)}`;
867
930
  }
868
931
  /**
869
932
  * Check whether a dependency is declared in package.json.
@@ -875,71 +938,18 @@ function hasDependency(packageJson, name) {
875
938
  return Boolean(packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name]);
876
939
  }
877
940
  /**
878
- * Warn when the configured backend is missing its package.
879
- * @param backendName - Configured backend name.
880
- * @param packageJson - Parsed package.json.
881
- */
882
- function warnIfMissingBackendPackage(backendName, packageJson) {
883
- const backendNameLower = backendName.toLowerCase();
884
- const isPostgres = backendNameLower.includes("postgres");
885
- const isSqlite = backendNameLower.includes("sqlite");
886
- if ((isPostgres || isSqlite) && !hasDependency(packageJson, "openworkflow")) {
887
- consola.warn(`Backend is ${backendName} but openworkflow is not installed.`);
888
- }
889
- if (isPostgres && !hasDependency(packageJson, "postgres")) {
890
- consola.warn(`Backend is ${backendName} but the postgres driver is not installed.`);
891
- }
892
- }
893
- /**
894
- * Warn when TypeScript is installed but tsconfig.json is missing.
895
- * @param packageJson - Parsed package.json.
896
- */
897
- function warnIfMissingTsconfig(packageJson) {
898
- if (!hasDependency(packageJson, "typescript")) {
899
- return;
900
- }
901
- const tsconfigPath = path.join(process.cwd(), "tsconfig.json");
902
- if (!existsSync(tsconfigPath)) {
903
- consola.warn("TypeScript is installed but no tsconfig.json was found.");
904
- }
905
- }
906
- /**
907
- * Ensure a specific environment variable exists in a .env file. Creates the file if it
908
- * doesn't exist, appends the variable if not present.
941
+ * Ensure a specific environment variable exists in a .env file. Creates the
942
+ * file if it doesn't exist, appends the variable if not present.
909
943
  * @param envPath - Path to the .env file
910
944
  * @param key - The environment variable key (e.g. "OPENWORKFLOW_POSTGRES_URL")
911
945
  * @param value - The default value for the environment variable
912
- * @returns Object indicating whether the entry was added or already existed
946
+ * @returns Whether the entry was appended
913
947
  */
914
948
  function ensureEnvEntry(envPath, key, value) {
915
- const fileExists = existsSync(envPath);
916
- let content = "";
917
- if (fileExists) {
918
- content = readFileSync(envPath, "utf8");
919
- }
920
- // check if key already exists (looking for KEY= at start of line)
921
- const lines = content.split("\n");
922
- const hasKey = lines.some((line) => {
949
+ return appendLineIfMissing(envPath, `${key}=${value}`, (line) => {
923
950
  const trimmed = line.trim();
924
951
  return trimmed.startsWith(`${key}=`) || trimmed.startsWith(`${key} =`);
925
952
  });
926
- if (hasKey) {
927
- return { added: false, created: false };
928
- }
929
- // add entry to .env
930
- let newContent;
931
- const envEntry = `${key}=${value}`;
932
- if (content === "") {
933
- newContent = `${envEntry}\n`;
934
- }
935
- else if (content.endsWith("\n")) {
936
- newContent = `${content}${envEntry}\n`;
937
- }
938
- else {
939
- newContent = `${content}\n${envEntry}\n`;
940
- }
941
- writeFileSync(envPath, newContent, "utf8");
942
- return { added: true, created: !fileExists };
943
953
  }
944
954
  /**
945
955
  * Validate a numeric option is a positive integer.
package/dist/config.d.ts CHANGED
@@ -46,5 +46,11 @@ export declare function loadConfigFromPath(configPath: string, startDir?: string
46
46
  * @returns The loaded configuration and metadata
47
47
  */
48
48
  export declare function loadConfig(startDir?: string): Promise<LoadedConfig>;
49
+ /**
50
+ * Find the nearest config without executing it, so its environment can load first.
51
+ * @param startDir - Directory to search from
52
+ * @returns Config path, if found
53
+ */
54
+ export declare function findConfigFile(startDir?: string): string | undefined;
49
55
  export {};
50
56
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAErD,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;AAE9D;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,kBAAkB,CAE3E;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAMD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,YAAY,CAAC,CAKvB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAyBzE"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAErD,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;AAE9D;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,kBAAkB,CAE3E;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAKD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,YAAY,CAAC,CAKvB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAGzE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,SAAgB,GAAG,MAAM,GAAG,SAAS,CAyB3E"}
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createJiti } from "jiti";
1
+ import { createModuleLoader } from "./module-loader.js";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
@@ -12,7 +12,6 @@ export function defineConfig(config) {
12
12
  }
13
13
  const CONFIG_NAME = "openworkflow.config";
14
14
  const CONFIG_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs"];
15
- const jiti = createJiti(import.meta.url);
16
15
  /**
17
16
  * Load OpenWorkflow config from an explicit path.
18
17
  * @param configPath - Explicit config file path
@@ -34,15 +33,24 @@ export async function loadConfigFromPath(configPath, startDir) {
34
33
  * @returns The loaded configuration and metadata
35
34
  */
36
35
  export async function loadConfig(startDir) {
37
- let currentDir = path.resolve(startDir ?? process.cwd());
36
+ const configFile = findConfigFile(startDir);
37
+ return configFile ? importConfigFile(configFile) : getEmptyLoadedConfig();
38
+ }
39
+ /**
40
+ * Find the nearest config without executing it, so its environment can load first.
41
+ * @param startDir - Directory to search from
42
+ * @returns Config path, if found
43
+ */
44
+ export function findConfigFile(startDir = process.cwd()) {
45
+ let currentDir = path.resolve(startDir);
38
46
  // search up the directory tree
39
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
47
+ // oxlint-disable-next-line typescript/no-unnecessary-condition
40
48
  while (true) {
41
49
  for (const ext of CONFIG_EXTENSIONS) {
42
50
  const fileName = `${CONFIG_NAME}.${ext}`;
43
51
  const filePath = path.join(currentDir, fileName);
44
52
  if (existsSync(filePath)) {
45
- return await importConfigFile(filePath);
53
+ return filePath;
46
54
  }
47
55
  }
48
56
  const parentDir = path.dirname(currentDir);
@@ -52,7 +60,7 @@ export async function loadConfig(startDir) {
52
60
  }
53
61
  currentDir = parentDir;
54
62
  }
55
- return getEmptyLoadedConfig();
63
+ return undefined;
56
64
  }
57
65
  /**
58
66
  * Import a config file and wrap load errors with a stable message.
@@ -61,17 +69,23 @@ export async function loadConfig(startDir) {
61
69
  */
62
70
  async function importConfigFile(filePath) {
63
71
  try {
72
+ const jiti = createModuleLoader(filePath, { tryNative: false }); // bun compatibility
64
73
  const fileUrl = pathToFileURL(filePath).href;
65
74
  const config = await jiti.import(fileUrl, {
66
75
  default: true,
67
76
  });
77
+ if (typeof config !== "object" || config === null) {
78
+ throw new Error("Config must export an object.");
79
+ }
68
80
  return {
69
- config,
81
+ config: config,
70
82
  configFile: filePath,
71
83
  };
72
84
  }
73
85
  catch (error) {
74
- throw new Error(`Failed to load config file ${filePath}: ${String(error)}`);
86
+ throw new Error(`Failed to load config file ${filePath}: ${String(error)}`, {
87
+ cause: error,
88
+ });
75
89
  }
76
90
  }
77
91
  /**
package/dist/errors.d.ts CHANGED
@@ -5,6 +5,11 @@ export declare class CLIError extends Error {
5
5
  readonly detail: string | undefined;
6
6
  constructor(message: string, detail?: string);
7
7
  }
8
+ /**
9
+ * Finish writing CLI output before exiting, including when handles remain open.
10
+ * @param code - Process exit code
11
+ */
12
+ export declare function exit(code: number): Promise<never>;
8
13
  /**
9
14
  * Wraps a CLI action / handler function with error handling that catches
10
15
  * errors, prints them to the console, then exits.
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../errors.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;gBAExB,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAK7C;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,OAAO,EAAE,EACnD,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GACvC,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAoB/B"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../errors.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;gBAExB,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAK7C;AAED;;;GAGG;AACH,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAWvD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,OAAO,EAAE,EACnD,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GACvC,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAiB/B"}
package/dist/errors.js CHANGED
@@ -11,6 +11,17 @@ export class CLIError extends Error {
11
11
  this.detail = detail;
12
12
  }
13
13
  }
14
+ /**
15
+ * Finish writing CLI output before exiting, including when handles remain open.
16
+ * @param code - Process exit code
17
+ */
18
+ export async function exit(code) {
19
+ await Promise.all([process.stdout, process.stderr].map((stream) => new Promise((resolve) => {
20
+ stream.end(resolve);
21
+ })));
22
+ // oxlint-disable-next-line unicorn/no-process-exit
23
+ process.exit(code);
24
+ }
14
25
  /**
15
26
  * Wraps a CLI action / handler function with error handling that catches
16
27
  * errors, prints them to the console, then exits.
@@ -24,19 +35,15 @@ export function withErrorHandling(fn) {
24
35
  }
25
36
  catch (error) {
26
37
  if (error instanceof CLIError) {
27
- consola.error(error.message);
28
- if (error.detail)
29
- consola.info(error.detail);
30
- // eslint-disable-next-line unicorn/no-process-exit
31
- process.exit(1);
38
+ consola.error([error.message, error.detail].filter(Boolean).join("\n"));
39
+ return exit(1);
32
40
  }
33
41
  const message = error instanceof Error ? error.message : String(error);
34
42
  consola.error(`Unexpected error: ${message}`);
35
43
  if (error instanceof Error && error.stack) {
36
44
  consola.debug(error.stack);
37
45
  }
38
- // eslint-disable-next-line unicorn/no-process-exit
39
- process.exit(1);
46
+ return exit(1);
40
47
  }
41
48
  };
42
49
  }
@@ -0,0 +1,9 @@
1
+ import { type Jiti, type JitiOptions } from "jiti";
2
+ /**
3
+ * Create a loader using the requested file's nearest tsconfig when available.
4
+ * @param filePath - Absolute path to the file being loaded.
5
+ * @param options - Runtime import options.
6
+ * @returns A loader with optional TypeScript path resolution.
7
+ */
8
+ export declare function createModuleLoader(filePath: string, options?: JitiOptions): Jiti;
9
+ //# sourceMappingURL=module-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module-loader.d.ts","sourceRoot":"","sources":["../module-loader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,IAAI,EAAE,KAAK,WAAW,EAAE,MAAM,MAAM,CAAC;AAE/D;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,WAAgB,GACxB,IAAI,CAQN"}
@@ -0,0 +1,17 @@
1
+ import { createJiti } from "jiti";
2
+ /**
3
+ * Create a loader using the requested file's nearest tsconfig when available.
4
+ * @param filePath - Absolute path to the file being loaded.
5
+ * @param options - Runtime import options.
6
+ * @returns A loader with optional TypeScript path resolution.
7
+ */
8
+ export function createModuleLoader(filePath, options = {}) {
9
+ try {
10
+ return createJiti(filePath, { ...options, tsconfigPaths: true });
11
+ }
12
+ catch {
13
+ // Deployments may omit development dependencies referenced by tsconfig.
14
+ // Fall back before executing user code so ordinary imports still work.
15
+ return createJiti(filePath, { ...options, tsconfigPaths: false });
16
+ }
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openworkflow/cli",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -27,18 +27,18 @@
27
27
  "prepublishOnly": "npm run build"
28
28
  },
29
29
  "dependencies": {
30
- "@clack/prompts": "^1.2.0",
31
- "commander": "^14.0.3",
30
+ "@clack/prompts": "^1.8.0",
31
+ "commander": "^15.0.0",
32
32
  "consola": "^3.4.2",
33
- "dotenv": "^17.4.1",
34
- "jiti": "^2.6.1",
35
- "nypm": "^0.6.5"
33
+ "dotenv": "^17.4.2",
34
+ "jiti": "^2.7.0",
35
+ "nypm": "^0.6.10"
36
36
  },
37
37
  "devDependencies": {
38
38
  "openworkflow": "*",
39
- "vitest": "^4.0.18"
39
+ "vitest": "^5.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "openworkflow": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0"
42
+ "openworkflow": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0"
43
43
  }
44
44
  }