@openworkflow/cli 0.4.5 → 0.5.1

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,25 +2,38 @@
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 { initializeTelemetry, shutdownTelemetry, trackCommand, } from "./telemetry.js";
6
+ import { Command, CommanderError, Option } from "commander";
6
7
  // openworkflow
7
8
  const program = new Command();
9
+ initializeTelemetry(program);
8
10
  program
9
11
  .name("openworkflow")
10
12
  .description("OpenWorkflow CLI - learn more at https://openworkflow.dev")
11
13
  .usage("<command> [options]")
12
- .version(getVersion());
14
+ .exitOverride()
15
+ .version(getVersion())
16
+ .option("--no-telemetry", "disable telemetry");
13
17
  // init
14
18
  program
15
19
  .command("init")
16
20
  .description("initialize OpenWorkflow")
21
+ .addOption(new Option("--backend <backend>", "backend to configure").choices([
22
+ "sqlite",
23
+ "postgres",
24
+ "both",
25
+ ]))
26
+ .option("-y, --yes", "skip prompts (requires --backend; does not allow overwrites)")
27
+ .option("--skip-install", "create project files without installing dependencies")
17
28
  .option("--config <path>", "path to OpenWorkflow config file")
29
+ .option("--env-file <path>", "load environment variables from file")
18
30
  .action(withErrorHandling(init));
19
31
  // doctor
20
32
  program
21
33
  .command("doctor")
22
- .description("check configuration and list available workflows")
34
+ .description("check worker prerequisites")
23
35
  .option("--config <path>", "path to OpenWorkflow config file")
36
+ .option("--env-file <path>", "load environment variables from file")
24
37
  .action(withErrorHandling(doctor));
25
38
  // worker
26
39
  const workerCmd = program.command("worker").description("manage workers");
@@ -30,6 +43,7 @@ workerCmd
30
43
  .description("start a worker to process workflows")
31
44
  .option("-c, --concurrency <number>", "number of concurrent workflows to process", Number.parseInt)
32
45
  .option("--config <path>", "path to OpenWorkflow config file")
46
+ .option("--env-file <path>", "load environment variables from file")
33
47
  .action(withErrorHandling(workerStart));
34
48
  // dashboard
35
49
  program
@@ -37,5 +51,17 @@ program
37
51
  .description("start the dashboard to view workflow runs")
38
52
  .option("-p, --port <number>", "custom port for the dashboard server", Number.parseInt)
39
53
  .option("--config <path>", "path to OpenWorkflow config file")
54
+ .option("--env-file <path>", "load environment variables from file")
40
55
  .action(withErrorHandling(dashboard));
41
- await program.parseAsync(process.argv);
56
+ try {
57
+ await program.parseAsync(process.argv);
58
+ }
59
+ catch (error) {
60
+ if (!(error instanceof CommanderError))
61
+ throw error;
62
+ process.exitCode = error.exitCode;
63
+ trackCommand();
64
+ }
65
+ finally {
66
+ await shutdownTelemetry();
67
+ }
@@ -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;
@@ -13,8 +20,9 @@ export declare function getVersion(): string;
13
20
  /**
14
21
  * openworkflow init
15
22
  * @param options - Command options
23
+ * @returns Resolves when setup finishes.
16
24
  */
17
- export declare function init(options?: CommandOptions): Promise<void>;
25
+ export declare function init(options?: InitOptions): Promise<void>;
18
26
  /**
19
27
  * openworkflow doctor
20
28
  * @param options - Command options
@@ -63,6 +71,7 @@ export declare function dashboard(options?: DashboardOptions): Promise<void>;
63
71
  */
64
72
  export declare function discoverWorkflowFiles(dirs: string[], baseDir: string, ignorePatterns?: string[]): string[];
65
73
  interface PackageJsonForDoctor {
74
+ scripts?: Record<string, string>;
66
75
  dependencies?: Record<string, string>;
67
76
  devDependencies?: Record<string, string>;
68
77
  }
@@ -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;AAyCrB,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;;;;GAIG;AAEH,wBAAsB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsJnE;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;AA+WD,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,20 @@
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";
4
+ import { trackCommand } from "./telemetry.js";
3
5
  import { CONFIG, HELLO_WORLD_RUNNER, HELLO_WORLD_WORKFLOW, POSTGRES_CLIENT, POSTGRES_PROD_SQLITE_DEV_CLIENT, SQLITE_CLIENT, } from "./templates.js";
4
6
  import * as p from "@clack/prompts";
5
7
  import { consola } from "consola";
6
8
  import { config as loadDotenv } from "dotenv";
7
- import { createJiti } from "jiti";
8
9
  import { spawn } from "node:child_process";
9
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
10
11
  import path from "node:path";
11
12
  import { fileURLToPath, pathToFileURL } from "node:url";
12
- import { addDependency, detectPackageManager } from "nypm";
13
+ import { addDependency, addDependencyCommand, detectPackageManager, } from "nypm";
13
14
  import { OpenWorkflow } from "openworkflow";
14
15
  import { isWorkflow } from "openworkflow/internal";
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const workflowSources = new WeakMap();
16
18
  /**
17
19
  * openworkflow -V | --version
18
20
  * @returns the version string, or "-" if it cannot be determined
@@ -39,86 +41,110 @@ export function getVersion() {
39
41
  /**
40
42
  * openworkflow init
41
43
  * @param options - Command options
44
+ * @returns Resolves when setup finishes.
42
45
  */
46
+ // oxlint-disable-next-line complexity
43
47
  export async function init(options = {}) {
44
- const configPath = options.config;
48
+ if (options.yes && !options.backend) {
49
+ throw new CLIError("--backend is required with --yes.");
50
+ }
51
+ if (!options.yes && !process.stdin.isTTY) {
52
+ throw new CLIError("Interactive setup requires a terminal. Pass --backend sqlite|postgres|both --yes.");
53
+ }
45
54
  p.intro("Initializing OpenWorkflow...");
46
- const { configFile } = await loadConfigWithEnv(configPath);
55
+ const configFile = findConfigWithEnv(options);
47
56
  let configFileToDelete = null;
48
- if (configFile) {
57
+ if (configFile && existsSync(configFile)) {
58
+ if (options.yes) {
59
+ throw new CLIError(`Config file already exists at ${configFile}. --yes does not allow overwrites.`);
60
+ }
49
61
  const shouldOverride = await p.confirm({
50
62
  message: `Config file already exists at ${configFile}. Override it?`,
51
63
  initialValue: false,
52
64
  });
53
65
  if (!shouldOverride || p.isCancel(shouldOverride))
54
- cancelSetup();
66
+ return cancelSetup();
55
67
  configFileToDelete = configFile;
56
68
  }
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))
79
- cancelSetup();
69
+ const backendChoice = options.backend ??
70
+ (await p.select({
71
+ message: "Select a backend for OpenWorkflow:",
72
+ options: [
73
+ {
74
+ value: "sqlite",
75
+ label: "SQLite",
76
+ hint: "Recommended for testing and development",
77
+ },
78
+ {
79
+ value: "postgres",
80
+ label: "PostgreSQL",
81
+ hint: "Recommended for production",
82
+ },
83
+ {
84
+ value: "both",
85
+ label: "Both",
86
+ hint: "SQLite for dev, PostgreSQL for production",
87
+ },
88
+ ],
89
+ initialValue: "sqlite",
90
+ }));
91
+ if (typeof backendChoice === "symbol")
92
+ return cancelSetup();
93
+ trackCommand(backendChoice);
80
94
  const spinner = p.spinner();
81
95
  // detect package manager & install packages
82
96
  spinner.start("Detecting package manager...");
83
97
  const pm = await detectPackageManager(process.cwd());
84
- const packageManager = pm?.name ?? "your package manager";
98
+ const packageManager = pm?.name ?? "npm";
85
99
  spinner.stop(`Using ${packageManager}`);
86
100
  const packageJson = readPackageJsonForDoctor();
87
101
  if (!packageJson) {
88
102
  throw new CLIError("No package.json found.", "Please create a package.json file first by running `npm init` or `npm init -y`.");
89
103
  }
90
- const configFileName = configPath ?? getConfigFileName(packageJson);
104
+ validateInitManifest(packageJson);
105
+ const configFileName = options.config ?? getConfigFileName(packageJson);
91
106
  const clientFileName = getClientFileName(packageJson);
92
107
  const exampleWorkflowFileName = getExampleWorkflowFileName(packageJson);
93
108
  const runFileName = getRunFileName(packageJson);
94
109
  const runCommand = runFileName.endsWith(".ts")
95
110
  ? `npx tsx openworkflow/${runFileName}`
96
111
  : `node openworkflow/${runFileName}`;
97
- const shouldSetup = await p.confirm({
98
- message: "Install packages and set up project files?",
99
- initialValue: true,
100
- });
112
+ const shouldSetup = options.yes ??
113
+ (await p.confirm({
114
+ message: options.skipInstall
115
+ ? "Set up project files?"
116
+ : "Install packages and set up project files?",
117
+ initialValue: true,
118
+ }));
101
119
  if (p.isCancel(shouldSetup))
102
- cancelSetup();
120
+ return cancelSetup();
103
121
  if (!shouldSetup) {
104
122
  p.outro("Setup skipped.");
105
123
  return;
106
124
  }
107
- if (configFileToDelete) {
108
- unlinkSync(configFileToDelete);
125
+ const dependencies = getDependenciesToInstall(backendChoice);
126
+ const devDependencies = getDevDependenciesToInstall();
127
+ if (options.skipInstall) {
128
+ p.note([
129
+ addDependencyCommand(packageManager, dependencies),
130
+ addDependencyCommand(packageManager, devDependencies, { dev: true }),
131
+ ].join("\n"), "Install dependencies before running OpenWorkflow");
109
132
  }
110
- {
111
- const dependencies = getDependenciesToInstall(backendChoice);
133
+ else {
112
134
  spinner.start(`Installing ${dependencies.join(", ")}...`);
113
- await addDependency(dependencies, { silent: true });
135
+ await addDependency(dependencies, { silent: true, packageManager });
114
136
  spinner.stop(`Installed ${dependencies.join(", ")}`);
115
- }
116
- {
117
- const devDependencies = getDevDependenciesToInstall();
118
137
  spinner.start(`Installing ${devDependencies.join(", ")}...`);
119
- await addDependency(devDependencies, { silent: true, dev: true });
138
+ await addDependency(devDependencies, {
139
+ silent: true,
140
+ dev: true,
141
+ packageManager,
142
+ });
120
143
  spinner.stop(`Installed ${devDependencies.join(", ")}`);
121
144
  }
145
+ if (configFileToDelete) {
146
+ unlinkSync(configFileToDelete);
147
+ }
122
148
  createClientFile(backendChoice, clientFileName);
123
149
  createExampleWorkflow(exampleWorkflowFileName);
124
150
  createRunFile(runFileName);
@@ -136,56 +162,89 @@ export async function init(options = {}) {
136
162
  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
163
  p.outro("✅ Setup complete!");
138
164
  }
165
+ // Validate the manifest fields that init reads or updates.
166
+ function validateInitManifest(manifest) {
167
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
168
+ throw new CLIError("Invalid package.json: expected an object.");
169
+ }
170
+ for (const key of ["scripts", "dependencies", "devDependencies"]) {
171
+ const field = manifest[key];
172
+ if (field === undefined)
173
+ continue;
174
+ if (field === null ||
175
+ typeof field !== "object" ||
176
+ Array.isArray(field) ||
177
+ Object.values(field).some((value) => typeof value !== "string")) {
178
+ throw new CLIError(`Invalid package.json: ${key} must be an object containing string values.`);
179
+ }
180
+ }
181
+ const { scripts } = manifest;
182
+ const worker = scripts?.["worker"];
183
+ if (worker !== undefined && worker !== "npx @openworkflow/cli worker start") {
184
+ throw new CLIError("Setup would overwrite package.json scripts.worker.");
185
+ }
186
+ }
139
187
  /**
140
188
  * openworkflow doctor
141
189
  * @param options - Command options
142
190
  */
143
191
  export async function doctor(options = {}) {
144
- const configPath = options.config;
145
192
  consola.start("Running OpenWorkflow doctor...");
146
- const { config, configFile } = await loadConfigWithEnv(configPath);
193
+ const timer = setTimeout(() => {
194
+ consola.error("Doctor timed out after 30 seconds.");
195
+ void exit(1);
196
+ }, 30_000);
197
+ const { config, configFile } = await loadConfigWithEnv(options);
147
198
  if (!configFile) {
148
199
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file.");
149
200
  }
150
201
  const backend = config.backend;
202
+ let cleanupFailed = false;
151
203
  try {
204
+ await checkBackendConnection(backend);
205
+ if (config.worker?.concurrency !== undefined) {
206
+ assertPositiveInteger("concurrency", config.worker.concurrency);
207
+ }
152
208
  consola.log("");
153
- consola.info(`Config file: ${configFile}`);
209
+ consola.info(`Config file: ${path.relative(process.cwd(), configFile)}`);
154
210
  const backendName = backend.constructor.name.replace("Backend", "");
155
211
  consola.log(` • Backend: ${backendName}`);
156
- const packageJson = readPackageJsonForDoctor();
157
- if (packageJson) {
158
- warnIfMissingBackendPackage(backendName, packageJson);
159
- warnIfMissingTsconfig(packageJson);
160
- }
161
212
  // discover directories
162
- const dirs = getWorkflowDirectories(config);
213
+ const dirs = [...new Set(getWorkflowDirectories(config))];
163
214
  consola.log(` • Workflow directories: ${dirs.join(", ")}`);
164
215
  // discover files
165
216
  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
- }
217
+ const { workflows } = await discoverWorkflowsInDirs(dirs, configFileDir, config.ignorePatterns ?? []);
218
+ assertNoDuplicateWorkflows(workflows);
172
219
  printDiscoveredWorkflows(workflows);
173
- warnAboutDuplicateWorkflows(workflows);
174
- consola.log("");
175
- consola.success("Configuration looks good!");
176
220
  }
177
221
  finally {
178
- await backend.stop();
222
+ // Imported configs can omit the backend despite the declared config type.
223
+ // oxlint-disable-next-line typescript/no-unnecessary-condition
224
+ if (typeof backend?.stop === "function") {
225
+ try {
226
+ await backend.stop();
227
+ }
228
+ catch (error) {
229
+ cleanupFailed = true;
230
+ consola.error(`Backend cleanup failed: ${String(error)}`);
231
+ }
232
+ }
179
233
  }
234
+ clearTimeout(timer);
235
+ if (cleanupFailed)
236
+ await exit(1);
237
+ consola.log("");
238
+ consola.success("Configuration looks good!");
239
+ await exit(0);
180
240
  }
181
241
  /**
182
242
  * openworkflow worker start
183
243
  * @param options - Worker config and command options
184
244
  */
185
245
  export async function workerStart(options = {}) {
186
- const { config: configPath, ...workerConfig } = options;
187
246
  consola.start("Starting worker...");
188
- const { config, configFile } = await loadConfigWithEnv(configPath);
247
+ const { config, configFile } = await loadConfigWithEnv(options);
189
248
  if (!configFile) {
190
249
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file.");
191
250
  }
@@ -208,6 +267,7 @@ export async function workerStart(options = {}) {
208
267
  consola.success("Worker stopped");
209
268
  }
210
269
  try {
270
+ await checkBackendConnection(backend);
211
271
  // discover and import workflows
212
272
  const dirs = getWorkflowDirectories(config);
213
273
  consola.info(`Discovering workflows from: ${dirs.join(", ")}`);
@@ -216,7 +276,9 @@ export async function workerStart(options = {}) {
216
276
  consola.info(`Found ${String(files.length)} workflow file(s)`);
217
277
  consola.success(`Loaded ${String(workflows.length)} workflow(s): ${workflows.map((w) => w.spec.name).join(", ")}`);
218
278
  assertNoDuplicateWorkflows(workflows);
219
- const workerOptions = mergeDefinedOptions(config.worker, workerConfig);
279
+ const workerOptions = mergeDefinedOptions(config.worker, {
280
+ concurrency: options.concurrency,
281
+ });
220
282
  if (workerOptions.concurrency !== undefined) {
221
283
  assertPositiveInteger("concurrency", workerOptions.concurrency);
222
284
  }
@@ -231,7 +293,12 @@ export async function workerStart(options = {}) {
231
293
  consola.success("Worker started.");
232
294
  }
233
295
  catch (error) {
234
- await gracefulShutdown();
296
+ try {
297
+ await gracefulShutdown();
298
+ }
299
+ catch (cleanupError) {
300
+ consola.warn(`Backend cleanup failed: ${String(cleanupError)}`);
301
+ }
235
302
  throw error;
236
303
  }
237
304
  }
@@ -274,10 +341,9 @@ export function validateDashboardPort(port) {
274
341
  * @returns Resolves when the dashboard process exits.
275
342
  */
276
343
  export async function dashboard(options = {}) {
277
- const configPath = options.config;
278
344
  const port = validateDashboardPort(options.port);
279
345
  consola.start("Starting dashboard...");
280
- const { configFile } = await loadConfigWithEnv(configPath);
346
+ const { configFile } = await loadConfigWithEnv(options);
281
347
  if (!configFile) {
282
348
  throw new CLIError("No config file found.", "Run `npx @openworkflow/cli init` to create a config file before starting the dashboard.");
283
349
  }
@@ -317,11 +383,27 @@ export async function dashboard(options = {}) {
317
383
  // -----------------------------------------------------------------------------
318
384
  /**
319
385
  * Show a canceled-setup message and exit the process with status 0.
386
+ * @returns Never resolves because the process exits.
320
387
  */
321
388
  function cancelSetup() {
322
389
  p.cancel("Setup canceled.");
323
- // eslint-disable-next-line unicorn/no-process-exit
324
- process.exit(0);
390
+ return exit(0);
391
+ }
392
+ /**
393
+ * Exercise backend initialization, connectivity, and workflow table access.
394
+ * @param backend - Configured backend
395
+ */
396
+ async function checkBackendConnection(backend) {
397
+ if (typeof backend?.listWorkflowRuns !== "function" ||
398
+ typeof backend.stop !== "function") {
399
+ throw new CLIError("Missing or invalid backend.", "Set config.backend to a connected OpenWorkflow backend.");
400
+ }
401
+ try {
402
+ await backend.listWorkflowRuns({ limit: 1 });
403
+ }
404
+ catch (error) {
405
+ throw new CLIError("Failed to access backend.", error instanceof Error ? error.message : String(error));
406
+ }
325
407
  }
326
408
  /**
327
409
  * Get workflow directories from config.
@@ -354,7 +436,7 @@ function findDuplicateWorkflows(workflows) {
354
436
  for (const workflow of workflows) {
355
437
  const name = workflow.spec.name;
356
438
  const version = workflow.spec.version ?? null;
357
- const key = version ? `${name}@${version}` : name;
439
+ const key = JSON.stringify([name, version]);
358
440
  const existing = workflowKeys.get(key);
359
441
  if (existing) {
360
442
  existing.count += 1;
@@ -382,29 +464,18 @@ function assertNoDuplicateWorkflows(workflows) {
382
464
  const suffix = remaining > 0 ? ` (+${String(remaining)} more)` : "";
383
465
  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
466
  }
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
467
  /**
397
468
  * Print discovered workflows to the console.
398
469
  * @param workflows - Array of discovered workflows
399
470
  */
400
471
  function printDiscoveredWorkflows(workflows) {
401
472
  consola.log("");
402
- consola.info(`Discovered ${String(workflows.length)} workflow(s):`);
473
+ consola.info(`Found ${String(workflows.length)} workflow${workflows.length === 1 ? "" : "s"}:`);
403
474
  for (const workflow of workflows) {
404
475
  const name = workflow.spec.name;
405
- const version = workflow.spec.version ?? "unversioned";
406
- const versionStr = version === "unversioned" ? "" : ` (version: ${version})`;
407
- consola.log(` • ${name}${versionStr}`);
476
+ const version = workflow.spec.version;
477
+ const versionStr = version ? ` (${version})` : "";
478
+ consola.log(` • ${name}${versionStr} — ${workflowSources.get(workflow)}`);
408
479
  }
409
480
  }
410
481
  const WORKFLOW_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs"];
@@ -476,8 +547,8 @@ function globToRegExp(pattern) {
476
547
  return new RegExp(regex);
477
548
  }
478
549
  /**
479
- * Check whether a file path matches ignore patterns.
480
- * @param filePath - Absolute file path
550
+ * Check whether a file or directory path matches ignore patterns.
551
+ * @param filePath - Absolute path, with a trailing separator for directories
481
552
  * @param baseDir - Base directory for relative matching
482
553
  * @param matchers - Compiled regex matchers
483
554
  * @returns Whether the file should be ignored
@@ -487,7 +558,11 @@ function isIgnoredFile(filePath, baseDir, matchers) {
487
558
  return false;
488
559
  const relativePath = normalizeForGlobMatch(path.relative(baseDir, filePath));
489
560
  const fileName = path.basename(filePath);
490
- return matchers.some((matcher) => matcher.test(relativePath) || matcher.test(fileName));
561
+ const isDirectory = filePath.endsWith(path.sep);
562
+ return matchers.some((matcher) => matcher.test(relativePath) ||
563
+ matcher.test(fileName) ||
564
+ (isDirectory &&
565
+ (matcher.test(`${relativePath}/`) || matcher.test(`${fileName}/`))));
491
566
  }
492
567
  /**
493
568
  * Discover workflow files from directories. Recursively scans directories for
@@ -512,19 +587,18 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
512
587
  entries = readdirSync(absoluteDir, { withFileTypes: true });
513
588
  }
514
589
  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;
590
+ throw new CLIError(`Cannot read workflow directory: ${absoluteDir}`, `${String(error)}\nCorrect config.dirs or make the directory readable.`);
519
591
  }
520
592
  for (const entry of entries) {
521
593
  const fullPath = path.join(absoluteDir, entry.name);
522
594
  if (entry.isDirectory()) {
523
- scanDirectory(fullPath);
595
+ if (!isIgnoredFile(`${fullPath}${path.sep}`, baseDir, matchers)) {
596
+ scanDirectory(fullPath);
597
+ }
524
598
  }
525
599
  else if (entry.isFile() &&
526
600
  WORKFLOW_EXTENSIONS.some((ext) => entry.name.endsWith(`.${ext}`)) &&
527
- !entry.name.endsWith(".d.ts") &&
601
+ !/\.d\.(?:ts|mts|cts)$/.test(entry.name) &&
528
602
  !isIgnoredFile(fullPath, baseDir, matchers)) {
529
603
  discoveredFiles.push(fullPath);
530
604
  }
@@ -533,7 +607,7 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
533
607
  for (const dir of dirs) {
534
608
  scanDirectory(dir);
535
609
  }
536
- return discoveredFiles;
610
+ return [...new Set(discoveredFiles)];
537
611
  }
538
612
  /**
539
613
  * Import workflow files and extract workflow exports.
@@ -543,11 +617,11 @@ export function discoverWorkflowFiles(dirs, baseDir, ignorePatterns = []) {
543
617
  */
544
618
  async function importWorkflows(files) {
545
619
  const workflows = [];
546
- const jiti = createJiti(import.meta.url);
547
620
  for (const file of files) {
548
621
  // import the module
549
622
  let module;
550
623
  try {
624
+ const jiti = createModuleLoader(file);
551
625
  module = await jiti.import(pathToFileURL(file).href);
552
626
  }
553
627
  catch (error) {
@@ -558,11 +632,12 @@ async function importWorkflows(files) {
558
632
  for (const [key, value] of Object.entries(module)) {
559
633
  if (isWorkflow(value)) {
560
634
  workflows.push(value);
635
+ workflowSources.set(value, path.relative(process.cwd(), file));
561
636
  consola.debug(`Found workflow "${value.spec.name}" in ${file} (${key})`);
562
637
  }
563
638
  }
564
639
  }
565
- return workflows;
640
+ return [...new Set(workflows)];
566
641
  }
567
642
  /**
568
643
  * Discover workflow files and import workflows with common error handling.
@@ -717,7 +792,7 @@ function addWorkerScriptToPackageJson() {
717
792
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
718
793
  packageJson.scripts ??= {};
719
794
  packageJson.scripts["worker"] = "npx @openworkflow/cli worker start";
720
- writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf8");
795
+ writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
721
796
  spinner.stop('Added "worker" script to package.json');
722
797
  }
723
798
  catch {
@@ -766,16 +841,32 @@ function updateEnvForPostgres() {
766
841
  : "OPENWORKFLOW_POSTGRES_URL already in .env");
767
842
  }
768
843
  /**
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.
844
+ * Find the config and load its environment without importing it.
845
+ * @param options - Config and environment file paths
846
+ * @returns Config path, if found.
772
847
  */
773
- async function loadConfigWithEnv(configPath) {
774
- loadDotenv({ quiet: true });
848
+ function findConfigWithEnv(options) {
849
+ const { envFile } = options;
850
+ const configPath = options.config
851
+ ? path.resolve(options.config)
852
+ : findConfigFile();
853
+ const baseDir = configPath ? path.dirname(configPath) : process.cwd();
854
+ const { error } = loadDotenv({
855
+ path: envFile ?? path.join(baseDir, ".env"),
856
+ quiet: true,
857
+ });
858
+ if (envFile !== undefined && error) {
859
+ throw new CLIError(`Failed to load environment file: ${envFile}`, error.message);
860
+ }
861
+ return configPath;
862
+ }
863
+ // Load the environment before importing config for commands that use it.
864
+ async function loadConfigWithEnv(options) {
865
+ const configPath = findConfigWithEnv(options);
775
866
  try {
776
- return configPath
777
- ? await loadConfigFromPath(configPath)
778
- : await loadConfig();
867
+ const loaded = await loadConfigFromPath(configPath ?? "openworkflow.config.ts");
868
+ trackCommand(loaded.config.backend);
869
+ return loaded;
779
870
  }
780
871
  catch (error) {
781
872
  const message = error instanceof Error ? error.message : String(error);
@@ -851,35 +942,6 @@ export function getClientFileName(packageJson) {
851
942
  function hasDependency(packageJson, name) {
852
943
  return Boolean(packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name]);
853
944
  }
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
945
  /**
884
946
  * Ensure a specific environment variable exists in a .env file. Creates the
885
947
  * 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":"AAIA;;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,CAYvD;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
@@ -1,4 +1,5 @@
1
1
  /* v8 ignore file -- @preserve */
2
+ import { shutdownTelemetry } from "./telemetry.js";
2
3
  import { consola } from "consola";
3
4
  /**
4
5
  * User-facing CLI error.
@@ -11,6 +12,18 @@ export class CLIError extends Error {
11
12
  this.detail = detail;
12
13
  }
13
14
  }
15
+ /**
16
+ * Finish writing CLI output before exiting, including when handles remain open.
17
+ * @param code - Process exit code
18
+ */
19
+ export async function exit(code) {
20
+ await shutdownTelemetry();
21
+ await Promise.all([process.stdout, process.stderr].map((stream) => new Promise((resolve) => {
22
+ stream.end(resolve);
23
+ })));
24
+ // oxlint-disable-next-line unicorn/no-process-exit
25
+ process.exit(code);
26
+ }
14
27
  /**
15
28
  * Wraps a CLI action / handler function with error handling that catches
16
29
  * errors, prints them to the console, then exits.
@@ -24,19 +37,15 @@ export function withErrorHandling(fn) {
24
37
  }
25
38
  catch (error) {
26
39
  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);
40
+ consola.error([error.message, error.detail].filter(Boolean).join("\n"));
41
+ return exit(1);
32
42
  }
33
43
  const message = error instanceof Error ? error.message : String(error);
34
44
  consola.error(`Unexpected error: ${message}`);
35
45
  if (error instanceof Error && error.stack) {
36
46
  consola.debug(error.stack);
37
47
  }
38
- // eslint-disable-next-line unicorn/no-process-exit
39
- process.exit(1);
48
+ return exit(1);
40
49
  }
41
50
  };
42
51
  }
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ import type { Command } from "commander";
2
+ import type { Backend } from "openworkflow/internal";
3
+ /**
4
+ * Set up telemetry before the CLI prints output.
5
+ * @param program - Root CLI command
6
+ */
7
+ export declare function initializeTelemetry(program: Command): void;
8
+ /**
9
+ * Track after loading config or choosing an init backend.
10
+ * Help, version, and argument errors are tracked without a backend.
11
+ * @param backend - Configured backend or init selection
12
+ */
13
+ export declare function trackCommand(backend?: Backend | "sqlite" | "postgres" | "both"): void;
14
+ /** Send queued events before the CLI exits. */
15
+ export declare function shutdownTelemetry(): Promise<void>;
16
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../telemetry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAKzC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAoBrD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAsC1D;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,UAAU,GAAG,MAAM,GACjD,IAAI,CAoCN;AAED,+CAA+C;AAC/C,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAMvD"}
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { PostHog } from "posthog-node";
6
+ let telemetry;
7
+ const backendTypes = new Map([
8
+ ["BackendSqlite", "sqlite"],
9
+ ["BackendPostgres", "postgres"],
10
+ ["sqlite", "sqlite"],
11
+ ["postgres", "postgres"],
12
+ ["both", "both"],
13
+ ]);
14
+ /**
15
+ * Set up telemetry before the CLI prints output.
16
+ * @param program - Root CLI command
17
+ */
18
+ export function initializeTelemetry(program) {
19
+ telemetry = undefined;
20
+ if (process.env["DO_NOT_TRACK"] ||
21
+ process.env["OPENWORKFLOW_TELEMETRY_DISABLED"] ||
22
+ process.env["CI"] ||
23
+ process.argv.includes("--no-telemetry"))
24
+ return;
25
+ try {
26
+ const directory = path.join(homedir(), ".openworkflow");
27
+ const file = path.join(directory, "telemetry-id");
28
+ mkdirSync(directory, { recursive: true });
29
+ if (!existsSync(file)) {
30
+ writeFileSync(file, randomUUID(), { flag: "wx", mode: 0o600 });
31
+ console.error("OpenWorkflow collects CLI usage via PostHog. Set DO_NOT_TRACK=1 to opt out.\n" +
32
+ "https://openworkflow.dev/docs/cli#telemetry\n");
33
+ }
34
+ const distinctId = readFileSync(file, "utf8").trim();
35
+ if (!distinctId)
36
+ return;
37
+ const state = {
38
+ program,
39
+ distinctId,
40
+ client: new PostHog("phc_C1Cm1NAKHDFA3eKLVqcYzR5wUk8WhSbMMXltk5Qj1Ye"),
41
+ };
42
+ program.on("beforeAllHelp", ({ command }) => {
43
+ state.helpCommand = command;
44
+ });
45
+ program.on("option:version", () => {
46
+ state.version = true;
47
+ });
48
+ telemetry = state;
49
+ }
50
+ catch {
51
+ // ignore, telemetry is optional
52
+ }
53
+ }
54
+ /**
55
+ * Track after loading config or choosing an init backend.
56
+ * Help, version, and argument errors are tracked without a backend.
57
+ * @param backend - Configured backend or init selection
58
+ */
59
+ export function trackCommand(backend) {
60
+ const state = telemetry;
61
+ if (!state)
62
+ return;
63
+ try {
64
+ let command = state.helpCommand ?? state.program;
65
+ let child;
66
+ while ((child = command.commands.find((candidate) => candidate.name() === command.args[0]))) {
67
+ command = child;
68
+ }
69
+ const backendName = typeof backend === "string" ? backend : backend?.constructor.name;
70
+ const commandName = command.parent?.parent
71
+ ? `${command.parent.name()} ${command.name()}`
72
+ : command.name();
73
+ state.client.capture({
74
+ distinctId: state.distinctId,
75
+ event: "cli_command_invoked",
76
+ properties: {
77
+ command: state.version ? "version" : commandName,
78
+ backend: backendName
79
+ ? (backendTypes.get(backendName) ?? "custom")
80
+ : undefined,
81
+ help: state.helpCommand ? true : undefined,
82
+ cli_version: state.program.version(),
83
+ os: process.platform,
84
+ arch: process.arch,
85
+ node_version: process.versions.node,
86
+ },
87
+ });
88
+ }
89
+ catch {
90
+ // ignore, telemetry is optional
91
+ }
92
+ }
93
+ /** Send queued events before the CLI exits. */
94
+ export async function shutdownTelemetry() {
95
+ try {
96
+ await telemetry?.client.shutdown();
97
+ }
98
+ catch {
99
+ // ignore, telemetry is optional
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openworkflow/cli",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -27,18 +27,19 @@
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
+ "posthog-node": "~5.21.2"
36
37
  },
37
38
  "devDependencies": {
38
39
  "openworkflow": "*",
39
- "vitest": "^4.0.18"
40
+ "vitest": "^5.0.0"
40
41
  },
41
42
  "peerDependencies": {
42
- "openworkflow": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0"
43
+ "openworkflow": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0"
43
44
  }
44
45
  }