@openworkflow/cli 0.4.5 → 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,CAwHtE;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;AA8OD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,MAAM,EACf,cAAc,GAAE,MAAM,EAAO,GAC5B,MAAM,EAAE,CA4CV;AAwVD,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;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"}
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,12 +41,21 @@ 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,
@@ -54,71 +64,84 @@ export async function init(options = {}) {
54
64
  cancelSetup();
55
65
  configFileToDelete = configFile;
56
66
  }
57
- const backendChoice = await p.select({
58
- message: "Select a backend for OpenWorkflow:",
59
- options: [
60
- {
61
- value: "sqlite",
62
- label: "SQLite",
63
- hint: "Recommended for testing and development",
64
- },
65
- {
66
- value: "postgres",
67
- label: "PostgreSQL",
68
- hint: "Recommended for production",
69
- },
70
- {
71
- value: "both",
72
- label: "Both",
73
- hint: "SQLite for dev, PostgreSQL for production",
74
- },
75
- ],
76
- initialValue: "sqlite",
77
- });
78
- if (p.isCancel(backendChoice))
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")
79
90
  cancelSetup();
80
91
  const spinner = p.spinner();
81
92
  // detect package manager & install packages
82
93
  spinner.start("Detecting package manager...");
83
94
  const pm = await detectPackageManager(process.cwd());
84
- const packageManager = pm?.name ?? "your package manager";
95
+ const packageManager = pm?.name ?? "npm";
85
96
  spinner.stop(`Using ${packageManager}`);
86
97
  const packageJson = readPackageJsonForDoctor();
87
98
  if (!packageJson) {
88
99
  throw new CLIError("No package.json found.", "Please create a package.json file first by running `npm init` or `npm init -y`.");
89
100
  }
90
- const configFileName = configPath ?? getConfigFileName(packageJson);
101
+ validateInitManifest(packageJson);
102
+ const configFileName = options.config ?? getConfigFileName(packageJson);
91
103
  const clientFileName = getClientFileName(packageJson);
92
104
  const exampleWorkflowFileName = getExampleWorkflowFileName(packageJson);
93
105
  const runFileName = getRunFileName(packageJson);
94
106
  const runCommand = runFileName.endsWith(".ts")
95
107
  ? `npx tsx openworkflow/${runFileName}`
96
108
  : `node openworkflow/${runFileName}`;
97
- const shouldSetup = await p.confirm({
98
- message: "Install packages and set up project files?",
99
- initialValue: true,
100
- });
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
+ }));
101
116
  if (p.isCancel(shouldSetup))
102
117
  cancelSetup();
103
118
  if (!shouldSetup) {
104
119
  p.outro("Setup skipped.");
105
120
  return;
106
121
  }
107
- if (configFileToDelete) {
108
- 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");
109
129
  }
110
- {
111
- const dependencies = getDependenciesToInstall(backendChoice);
130
+ else {
112
131
  spinner.start(`Installing ${dependencies.join(", ")}...`);
113
- await addDependency(dependencies, { silent: true });
132
+ await addDependency(dependencies, { silent: true, packageManager });
114
133
  spinner.stop(`Installed ${dependencies.join(", ")}`);
115
- }
116
- {
117
- const devDependencies = getDevDependenciesToInstall();
118
134
  spinner.start(`Installing ${devDependencies.join(", ")}...`);
119
- await addDependency(devDependencies, { silent: true, dev: true });
135
+ await addDependency(devDependencies, {
136
+ silent: true,
137
+ dev: true,
138
+ packageManager,
139
+ });
120
140
  spinner.stop(`Installed ${devDependencies.join(", ")}`);
121
141
  }
142
+ if (configFileToDelete) {
143
+ unlinkSync(configFileToDelete);
144
+ }
122
145
  createClientFile(backendChoice, clientFileName);
123
146
  createExampleWorkflow(exampleWorkflowFileName);
124
147
  createRunFile(runFileName);
@@ -136,56 +159,89 @@ export async function init(options = {}) {
136
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");
137
160
  p.outro("✅ Setup complete!");
138
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
+ }
139
184
  /**
140
185
  * openworkflow doctor
141
186
  * @param options - Command options
142
187
  */
143
188
  export async function doctor(options = {}) {
144
- const configPath = options.config;
145
189
  consola.start("Running OpenWorkflow doctor...");
146
- 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);
147
195
  if (!configFile) {
148
196
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file.");
149
197
  }
150
198
  const backend = config.backend;
199
+ let cleanupFailed = false;
151
200
  try {
201
+ await checkBackendConnection(backend);
202
+ if (config.worker?.concurrency !== undefined) {
203
+ assertPositiveInteger("concurrency", config.worker.concurrency);
204
+ }
152
205
  consola.log("");
153
- consola.info(`Config file: ${configFile}`);
206
+ consola.info(`Config file: ${path.relative(process.cwd(), configFile)}`);
154
207
  const backendName = backend.constructor.name.replace("Backend", "");
155
208
  consola.log(` • Backend: ${backendName}`);
156
- const packageJson = readPackageJsonForDoctor();
157
- if (packageJson) {
158
- warnIfMissingBackendPackage(backendName, packageJson);
159
- warnIfMissingTsconfig(packageJson);
160
- }
161
209
  // discover directories
162
- const dirs = getWorkflowDirectories(config);
210
+ const dirs = [...new Set(getWorkflowDirectories(config))];
163
211
  consola.log(` • Workflow directories: ${dirs.join(", ")}`);
164
212
  // discover files
165
213
  const configFileDir = path.dirname(configFile);
166
- const { files, workflows } = await discoverWorkflowsInDirs(dirs, configFileDir, config.ignorePatterns ?? []);
167
- consola.log("");
168
- consola.info(`Found ${String(files.length)} workflow file(s):`);
169
- for (const file of files) {
170
- consola.log(` • ${file}`);
171
- }
214
+ const { workflows } = await discoverWorkflowsInDirs(dirs, configFileDir, config.ignorePatterns ?? []);
215
+ assertNoDuplicateWorkflows(workflows);
172
216
  printDiscoveredWorkflows(workflows);
173
- warnAboutDuplicateWorkflows(workflows);
174
- consola.log("");
175
- consola.success("Configuration looks good!");
176
217
  }
177
218
  finally {
178
- 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
+ }
179
230
  }
231
+ clearTimeout(timer);
232
+ if (cleanupFailed)
233
+ await exit(1);
234
+ consola.log("");
235
+ consola.success("Configuration looks good!");
236
+ await exit(0);
180
237
  }
181
238
  /**
182
239
  * openworkflow worker start
183
240
  * @param options - Worker config and command options
184
241
  */
185
242
  export async function workerStart(options = {}) {
186
- const { config: configPath, ...workerConfig } = options;
187
243
  consola.start("Starting worker...");
188
- const { config, configFile } = await loadConfigWithEnv(configPath);
244
+ const { config, configFile } = await loadConfigWithEnv(options);
189
245
  if (!configFile) {
190
246
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file.");
191
247
  }
@@ -208,6 +264,7 @@ export async function workerStart(options = {}) {
208
264
  consola.success("Worker stopped");
209
265
  }
210
266
  try {
267
+ await checkBackendConnection(backend);
211
268
  // discover and import workflows
212
269
  const dirs = getWorkflowDirectories(config);
213
270
  consola.info(`Discovering workflows from: ${dirs.join(", ")}`);
@@ -216,7 +273,9 @@ export async function workerStart(options = {}) {
216
273
  consola.info(`Found ${String(files.length)} workflow file(s)`);
217
274
  consola.success(`Loaded ${String(workflows.length)} workflow(s): ${workflows.map((w) => w.spec.name).join(", ")}`);
218
275
  assertNoDuplicateWorkflows(workflows);
219
- const workerOptions = mergeDefinedOptions(config.worker, workerConfig);
276
+ const workerOptions = mergeDefinedOptions(config.worker, {
277
+ concurrency: options.concurrency,
278
+ });
220
279
  if (workerOptions.concurrency !== undefined) {
221
280
  assertPositiveInteger("concurrency", workerOptions.concurrency);
222
281
  }
@@ -231,7 +290,12 @@ export async function workerStart(options = {}) {
231
290
  consola.success("Worker started.");
232
291
  }
233
292
  catch (error) {
234
- await gracefulShutdown();
293
+ try {
294
+ await gracefulShutdown();
295
+ }
296
+ catch (cleanupError) {
297
+ consola.warn(`Backend cleanup failed: ${String(cleanupError)}`);
298
+ }
235
299
  throw error;
236
300
  }
237
301
  }
@@ -274,10 +338,9 @@ export function validateDashboardPort(port) {
274
338
  * @returns Resolves when the dashboard process exits.
275
339
  */
276
340
  export async function dashboard(options = {}) {
277
- const configPath = options.config;
278
341
  const port = validateDashboardPort(options.port);
279
342
  consola.start("Starting dashboard...");
280
- const { configFile } = await loadConfigWithEnv(configPath);
343
+ const { configFile } = await loadConfigWithEnv(options);
281
344
  if (!configFile) {
282
345
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file before starting the dashboard.");
283
346
  }
@@ -320,9 +383,25 @@ export async function dashboard(options = {}) {
320
383
  */
321
384
  function cancelSetup() {
322
385
  p.cancel("Setup canceled.");
323
- // eslint-disable-next-line unicorn/no-process-exit
386
+ // oxlint-disable-next-line unicorn/no-process-exit
324
387
  process.exit(0);
325
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
+ }
326
405
  /**
327
406
  * Get workflow directories from config.
328
407
  * @param config - The loaded config
@@ -354,7 +433,7 @@ function findDuplicateWorkflows(workflows) {
354
433
  for (const workflow of workflows) {
355
434
  const name = workflow.spec.name;
356
435
  const version = workflow.spec.version ?? null;
357
- const key = version ? `${name}@${version}` : name;
436
+ const key = JSON.stringify([name, version]);
358
437
  const existing = workflowKeys.get(key);
359
438
  if (existing) {
360
439
  existing.count += 1;
@@ -382,29 +461,18 @@ function assertNoDuplicateWorkflows(workflows) {
382
461
  const suffix = remaining > 0 ? ` (+${String(remaining)} more)` : "";
383
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.");
384
463
  }
385
- /**
386
- * Warn about duplicate workflows without failing.
387
- * @param workflows - Discovered workflows
388
- */
389
- function warnAboutDuplicateWorkflows(workflows) {
390
- const duplicates = findDuplicateWorkflows(workflows);
391
- for (const duplicate of duplicates) {
392
- consola.warn(`Duplicate workflow detected: ${formatWorkflowIdentity(duplicate.name, duplicate.version)}`);
393
- consola.warn("Multiple files export a workflow with the same name and version.");
394
- }
395
- }
396
464
  /**
397
465
  * Print discovered workflows to the console.
398
466
  * @param workflows - Array of discovered workflows
399
467
  */
400
468
  function printDiscoveredWorkflows(workflows) {
401
469
  consola.log("");
402
- consola.info(`Discovered ${String(workflows.length)} workflow(s):`);
470
+ consola.info(`Found ${String(workflows.length)} workflow${workflows.length === 1 ? "" : "s"}:`);
403
471
  for (const workflow of workflows) {
404
472
  const name = workflow.spec.name;
405
- const version = workflow.spec.version ?? "unversioned";
406
- const versionStr = version === "unversioned" ? "" : ` (version: ${version})`;
407
- consola.log(` • ${name}${versionStr}`);
473
+ const version = workflow.spec.version;
474
+ const versionStr = version ? ` (${version})` : "";
475
+ consola.log(` • ${name}${versionStr} — ${workflowSources.get(workflow)}`);
408
476
  }
409
477
  }
410
478
  const WORKFLOW_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs"];
@@ -476,8 +544,8 @@ function globToRegExp(pattern) {
476
544
  return new RegExp(regex);
477
545
  }
478
546
  /**
479
- * Check whether a file path matches ignore patterns.
480
- * @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
481
549
  * @param baseDir - Base directory for relative matching
482
550
  * @param matchers - Compiled regex matchers
483
551
  * @returns Whether the file should be ignored
@@ -487,7 +555,11 @@ function isIgnoredFile(filePath, baseDir, matchers) {
487
555
  return false;
488
556
  const relativePath = normalizeForGlobMatch(path.relative(baseDir, filePath));
489
557
  const fileName = path.basename(filePath);
490
- 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}/`))));
491
563
  }
492
564
  /**
493
565
  * Discover workflow files from directories. Recursively scans directories for
@@ -512,19 +584,18 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
512
584
  entries = readdirSync(absoluteDir, { withFileTypes: true });
513
585
  }
514
586
  catch (error) {
515
- // doesn't exist or can't be read, skip
516
- const errMessage = error instanceof Error ? error.message : String(error);
517
- consola.debug(`Failed to read directory: ${absoluteDir} - ${errMessage}`);
518
- return;
587
+ throw new CLIError(`Cannot read workflow directory: ${absoluteDir}`, `${String(error)}\nCorrect config.dirs or make the directory readable.`);
519
588
  }
520
589
  for (const entry of entries) {
521
590
  const fullPath = path.join(absoluteDir, entry.name);
522
591
  if (entry.isDirectory()) {
523
- scanDirectory(fullPath);
592
+ if (!isIgnoredFile(`${fullPath}${path.sep}`, baseDir, matchers)) {
593
+ scanDirectory(fullPath);
594
+ }
524
595
  }
525
596
  else if (entry.isFile() &&
526
597
  WORKFLOW_EXTENSIONS.some((ext) => entry.name.endsWith(`.${ext}`)) &&
527
- !entry.name.endsWith(".d.ts") &&
598
+ !/\.d\.(?:ts|mts|cts)$/.test(entry.name) &&
528
599
  !isIgnoredFile(fullPath, baseDir, matchers)) {
529
600
  discoveredFiles.push(fullPath);
530
601
  }
@@ -533,7 +604,7 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
533
604
  for (const dir of dirs) {
534
605
  scanDirectory(dir);
535
606
  }
536
- return discoveredFiles;
607
+ return [...new Set(discoveredFiles)];
537
608
  }
538
609
  /**
539
610
  * Import workflow files and extract workflow exports.
@@ -543,11 +614,11 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
543
614
  */
544
615
  async function importWorkflows(files) {
545
616
  const workflows = [];
546
- const jiti = createJiti(import.meta.url);
547
617
  for (const file of files) {
548
618
  // import the module
549
619
  let module;
550
620
  try {
621
+ const jiti = createModuleLoader(file);
551
622
  module = await jiti.import(pathToFileURL(file).href);
552
623
  }
553
624
  catch (error) {
@@ -558,11 +629,12 @@ async function importWorkflows(files) {
558
629
  for (const [key, value] of Object.entries(module)) {
559
630
  if (isWorkflow(value)) {
560
631
  workflows.push(value);
632
+ workflowSources.set(value, path.relative(process.cwd(), file));
561
633
  consola.debug(`Found workflow "${value.spec.name}" in ${file} (${key})`);
562
634
  }
563
635
  }
564
636
  }
565
- return workflows;
637
+ return [...new Set(workflows)];
566
638
  }
567
639
  /**
568
640
  * Discover workflow files and import workflows with common error handling.
@@ -717,7 +789,7 @@ function addWorkerScriptToPackageJson() {
717
789
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
718
790
  packageJson.scripts ??= {};
719
791
  packageJson.scripts["worker"] = "npx @openworkflow/cli worker start";
720
- writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf8");
792
+ writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
721
793
  spinner.stop('Added "worker" script to package.json');
722
794
  }
723
795
  catch {
@@ -766,16 +838,30 @@ function updateEnvForPostgres() {
766
838
  : "OPENWORKFLOW_POSTGRES_URL already in .env");
767
839
  }
768
840
  /**
769
- * Load CLI config after loading .env, and wrap errors for user-facing output.
770
- * @param configPath - Optional explicit config file path
771
- * @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.
772
844
  */
773
- async function loadConfigWithEnv(configPath) {
774
- 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);
775
863
  try {
776
- return configPath
777
- ? await loadConfigFromPath(configPath)
778
- : await loadConfig();
864
+ return await loadConfigFromPath(configPath ?? "openworkflow.config.ts");
779
865
  }
780
866
  catch (error) {
781
867
  const message = error instanceof Error ? error.message : String(error);
@@ -851,35 +937,6 @@ export function getClientFileName(packageJson) {
851
937
  function hasDependency(packageJson, name) {
852
938
  return Boolean(packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name]);
853
939
  }
854
- /**
855
- * Warn when the configured backend is missing its package.
856
- * @param backendName - Configured backend name.
857
- * @param packageJson - Parsed package.json.
858
- */
859
- function warnIfMissingBackendPackage(backendName, packageJson) {
860
- const backendNameLower = backendName.toLowerCase();
861
- const isPostgres = backendNameLower.includes("postgres");
862
- const isSqlite = backendNameLower.includes("sqlite");
863
- if ((isPostgres || isSqlite) && !hasDependency(packageJson, "openworkflow")) {
864
- consola.warn(`Backend is ${backendName} but openworkflow is not installed.`);
865
- }
866
- if (isPostgres && !hasDependency(packageJson, "postgres")) {
867
- consola.warn(`Backend is ${backendName} but the postgres driver is not installed.`);
868
- }
869
- }
870
- /**
871
- * Warn when TypeScript is installed but tsconfig.json is missing.
872
- * @param packageJson - Parsed package.json.
873
- */
874
- function warnIfMissingTsconfig(packageJson) {
875
- if (!hasDependency(packageJson, "typescript")) {
876
- return;
877
- }
878
- const tsconfigPath = path.join(process.cwd(), "tsconfig.json");
879
- if (!existsSync(tsconfigPath)) {
880
- consola.warn("TypeScript is installed but no tsconfig.json was found.");
881
- }
882
- }
883
940
  /**
884
941
  * Ensure a specific environment variable exists in a .env file. Creates the
885
942
  * file if it doesn't exist, appends the variable if not present.
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, { tryNative: false }); // bun compatibility
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.5",
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.7.0",
30
+ "@clack/prompts": "^1.8.0",
31
31
  "commander": "^15.0.0",
32
32
  "consola": "^3.4.2",
33
33
  "dotenv": "^17.4.2",
34
34
  "jiti": "^2.7.0",
35
- "nypm": "^0.6.8"
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
  }