@openworkflow/cli 0.1.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.
Files changed (51) hide show
  1. package/README.md +8 -0
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +32 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/cli.test.d.ts +2 -0
  7. package/dist/cli.test.d.ts.map +1 -0
  8. package/dist/cli.test.js +7 -0
  9. package/dist/cli.test.js.map +1 -0
  10. package/dist/commands.d.ts +33 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +718 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/commands.test.d.ts +2 -0
  15. package/dist/commands.test.d.ts.map +1 -0
  16. package/dist/commands.test.js +35 -0
  17. package/dist/commands.test.js.map +1 -0
  18. package/dist/config.d.ts +34 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +51 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/config.test.d.ts +2 -0
  23. package/dist/config.test.d.ts.map +1 -0
  24. package/dist/config.test.js +89 -0
  25. package/dist/config.test.js.map +1 -0
  26. package/dist/errors.d.ts +15 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +43 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +2 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/index.test.d.ts +2 -0
  35. package/dist/index.test.d.ts.map +1 -0
  36. package/dist/index.test.js +11 -0
  37. package/dist/index.test.js.map +1 -0
  38. package/dist/init.test.d.ts +2 -0
  39. package/dist/init.test.d.ts.map +1 -0
  40. package/dist/init.test.js +148 -0
  41. package/dist/init.test.js.map +1 -0
  42. package/dist/templates.d.ts +5 -0
  43. package/dist/templates.d.ts.map +1 -0
  44. package/dist/templates.js +55 -0
  45. package/dist/templates.js.map +1 -0
  46. package/dist/templates.test.d.ts +2 -0
  47. package/dist/templates.test.d.ts.map +1 -0
  48. package/dist/templates.test.js +20 -0
  49. package/dist/templates.test.js.map +1 -0
  50. package/dist/tsconfig.tsbuildinfo +1 -0
  51. package/package.json +38 -0
@@ -0,0 +1,718 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { CLIError } from "./errors.js";
3
+ import { HELLO_WORLD_WORKFLOW, POSTGRES_CONFIG, POSTGRES_PROD_SQLITE_DEV_CONFIG, SQLITE_CONFIG, } from "./templates.js";
4
+ import * as p from "@clack/prompts";
5
+ import { consola } from "consola";
6
+ import { config as loadDotenv } from "dotenv";
7
+ import { createJiti } from "jiti";
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import { addDependency, detectPackageManager } from "nypm";
12
+ import { OpenWorkflow } from "openworkflow";
13
+ import { isWorkflow } from "openworkflow/internal";
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ /**
16
+ * openworkflow -V | --version
17
+ * @returns the version string, or "-" if it cannot be determined
18
+ */
19
+ export function getVersion() {
20
+ const paths = [
21
+ path.join(__dirname, "package.json"), // dev: package.json
22
+ path.join(__dirname, "..", "package.json"), // prod: dist/../package.json
23
+ ];
24
+ for (const pkgPath of paths) {
25
+ if (existsSync(pkgPath)) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
28
+ if (pkg.version)
29
+ return pkg.version;
30
+ }
31
+ catch {
32
+ // ignore
33
+ }
34
+ }
35
+ }
36
+ return "-";
37
+ }
38
+ /** openworkflow init */
39
+ export async function init() {
40
+ p.intro("Initializing OpenWorkflow...");
41
+ const { configFile } = await loadConfigWithEnv();
42
+ let configFileToDelete = null;
43
+ if (configFile) {
44
+ const shouldOverride = await p.confirm({
45
+ message: `Config file already exists at ${configFile}. Override it?`,
46
+ initialValue: false,
47
+ });
48
+ if (!shouldOverride || p.isCancel(shouldOverride)) {
49
+ p.cancel("Setup canceled.");
50
+ // eslint-disable-next-line unicorn/no-process-exit
51
+ process.exit(0);
52
+ }
53
+ configFileToDelete = configFile;
54
+ }
55
+ const backendChoice = await p.select({
56
+ message: "Select a backend for OpenWorkflow:",
57
+ options: [
58
+ {
59
+ value: "sqlite",
60
+ label: "SQLite",
61
+ hint: "Recommended for testing and development",
62
+ },
63
+ {
64
+ value: "postgres",
65
+ label: "PostgreSQL",
66
+ hint: "Recommended for production",
67
+ },
68
+ {
69
+ value: "both",
70
+ label: "Both",
71
+ hint: "SQLite for dev, PostgreSQL for production",
72
+ },
73
+ ],
74
+ initialValue: "sqlite",
75
+ });
76
+ if (p.isCancel(backendChoice)) {
77
+ p.cancel("Setup canceled.");
78
+ // eslint-disable-next-line unicorn/no-process-exit
79
+ process.exit(0);
80
+ }
81
+ const spinner = p.spinner();
82
+ // detect package manager & install packages
83
+ spinner.start("Detecting package manager...");
84
+ const pm = await detectPackageManager(process.cwd());
85
+ const packageManager = pm?.name ?? "your package manager";
86
+ spinner.stop(`Using ${packageManager}`);
87
+ const packageJson = readPackageJsonForDoctor();
88
+ const configFileName = getConfigFileName(packageJson);
89
+ const exampleWorkflowFileName = getExampleWorkflowFileName(packageJson);
90
+ const shouldSetup = await p.confirm({
91
+ message: `Install packages and set up project files (.env, .gitignore, package.json, ${configFileName}, openworkflow/${exampleWorkflowFileName})?`,
92
+ initialValue: true,
93
+ });
94
+ if (p.isCancel(shouldSetup)) {
95
+ p.cancel("Setup canceled.");
96
+ // eslint-disable-next-line unicorn/no-process-exit
97
+ process.exit(0);
98
+ }
99
+ if (!shouldSetup) {
100
+ p.outro("Setup skipped.");
101
+ return;
102
+ }
103
+ if (configFileToDelete) {
104
+ unlinkSync(configFileToDelete);
105
+ }
106
+ {
107
+ const dependencies = getDependenciesToInstall(backendChoice);
108
+ spinner.start(`Installing ${dependencies.join(", ")}...`);
109
+ await addDependency(dependencies, { silent: true });
110
+ spinner.stop(`Installed ${dependencies.join(", ")}`);
111
+ }
112
+ {
113
+ const devDependencies = getDevDependenciesToInstall();
114
+ spinner.start(`Installing ${devDependencies.join(", ")}...`);
115
+ await addDependency(devDependencies, { silent: true, dev: true });
116
+ spinner.stop(`Installed ${devDependencies.join(", ")}`);
117
+ }
118
+ createExampleWorkflow(exampleWorkflowFileName);
119
+ if (backendChoice === "sqlite" || backendChoice === "both") {
120
+ updateGitignoreForSqlite();
121
+ }
122
+ if (backendChoice === "postgres" || backendChoice === "both") {
123
+ updateEnvForPostgres();
124
+ }
125
+ addWorkerScriptToPackageJson();
126
+ // write config file last, so canceling earlier doesn't leave a config file
127
+ // which would prevent re-running init
128
+ createConfigFile(backendChoice, configFileName);
129
+ // wrap up
130
+ p.note(`➡️ Start a worker with:\n$ ${packageManager === "npm" ? "npm run" : packageManager} worker\n\nOr directly with:\n$ ow worker start`, "Next steps");
131
+ p.outro("✅ Setup complete!");
132
+ }
133
+ /** openworkflow doctor */
134
+ export async function doctor() {
135
+ consola.start("Running OpenWorkflow doctor...");
136
+ const { config, configFile } = await loadConfigWithEnv();
137
+ if (!configFile) {
138
+ throw new CLIError("No config file found.", "Run `ow init` to create a config file.");
139
+ }
140
+ const backend = config.backend;
141
+ try {
142
+ consola.log("");
143
+ consola.info(`Config file: ${configFile}`);
144
+ const backendName = backend.constructor.name.replace("Backend", "");
145
+ consola.log(` • Backend: ${backendName}`);
146
+ const packageJson = readPackageJsonForDoctor();
147
+ if (packageJson) {
148
+ warnIfMissingBackendPackage(backendName, packageJson);
149
+ warnIfMissingTsconfig(packageJson);
150
+ }
151
+ // discover directories
152
+ const dirs = getWorkflowDirectories(config);
153
+ consola.log(` • Workflow directories: ${dirs.join(", ")}`);
154
+ // discover files
155
+ const configFileDir = path.dirname(configFile);
156
+ const { files, workflows } = await discoverWorkflowsInDirs(dirs, configFileDir);
157
+ consola.log("");
158
+ consola.info(`Found ${String(files.length)} workflow file(s):`);
159
+ for (const file of files) {
160
+ consola.log(` • ${file}`);
161
+ }
162
+ printDiscoveredWorkflows(workflows);
163
+ warnAboutDuplicateWorkflows(workflows);
164
+ consola.log("");
165
+ consola.success("Configuration looks good!");
166
+ }
167
+ finally {
168
+ await backend.stop();
169
+ }
170
+ }
171
+ /**
172
+ * openworkflow worker start
173
+ * @param cliOptions - Worker config overrides
174
+ */
175
+ export async function workerStart(cliOptions) {
176
+ consola.start("Starting worker...");
177
+ const { config, configFile } = await loadConfigWithEnv();
178
+ if (!configFile) {
179
+ throw new CLIError("No config file found.", "Run `ow init` to create a config file.");
180
+ }
181
+ const backend = config.backend;
182
+ const ow = new OpenWorkflow({ backend });
183
+ let worker = null;
184
+ let shuttingDown = false;
185
+ /** Stop the worker on process shutdown. */
186
+ async function gracefulShutdown() {
187
+ if (shuttingDown)
188
+ return;
189
+ shuttingDown = true;
190
+ consola.warn("Shutting down worker...");
191
+ try {
192
+ await worker?.stop();
193
+ }
194
+ finally {
195
+ await backend.stop();
196
+ }
197
+ consola.success("Worker stopped");
198
+ }
199
+ try {
200
+ // discover and import workflows
201
+ const dirs = getWorkflowDirectories(config);
202
+ consola.info(`Discovering workflows from: ${dirs.join(", ")}`);
203
+ const configFileDir = path.dirname(configFile);
204
+ const { files, workflows } = await discoverWorkflowsInDirs(dirs, configFileDir);
205
+ consola.info(`Found ${String(files.length)} workflow file(s)`);
206
+ consola.success(`Loaded ${String(workflows.length)} workflow(s): ${workflows.map((w) => w.spec.name).join(", ")}`);
207
+ assertNoDuplicateWorkflows(workflows);
208
+ const workerOptions = mergeDefinedOptions(config.worker, cliOptions);
209
+ if (workerOptions.concurrency !== undefined) {
210
+ assertPositiveInteger("concurrency", workerOptions.concurrency);
211
+ }
212
+ // register discovered workflows
213
+ for (const workflow of workflows) {
214
+ ow.implementWorkflow(workflow.spec, workflow.fn);
215
+ }
216
+ worker = ow.newWorker(workerOptions);
217
+ process.on("SIGINT", () => void gracefulShutdown());
218
+ process.on("SIGTERM", () => void gracefulShutdown());
219
+ await worker.start();
220
+ consola.success("Worker started.");
221
+ }
222
+ catch (error) {
223
+ await gracefulShutdown();
224
+ throw error;
225
+ }
226
+ }
227
+ // -----------------------------------------------------------------------------
228
+ /**
229
+ * Get workflow directories from config.
230
+ * @param config - The loaded config
231
+ * @returns Array of workflow directory paths
232
+ */
233
+ function getWorkflowDirectories(config) {
234
+ if (config.dirs) {
235
+ return Array.isArray(config.dirs) ? config.dirs : [config.dirs];
236
+ }
237
+ return ["./openworkflow"];
238
+ }
239
+ /**
240
+ * Format a workflow identity string for error messages.
241
+ * @param name - Workflow name
242
+ * @param version - Optional workflow version
243
+ * @returns Formatted identity string
244
+ */
245
+ function formatWorkflowIdentity(name, version) {
246
+ return version ? `"${name}" (version: ${version})` : `"${name}"`;
247
+ }
248
+ /**
249
+ * Find duplicate workflows by name + version.
250
+ * @param workflows - Discovered workflows
251
+ * @returns Array of duplicate metadata
252
+ */
253
+ function findDuplicateWorkflows(workflows) {
254
+ const workflowKeys = new Map();
255
+ const duplicates = [];
256
+ for (const workflow of workflows) {
257
+ const name = workflow.spec.name;
258
+ const version = workflow.spec.version ?? null;
259
+ const key = version ? `${name}@${version}` : name;
260
+ const existing = workflowKeys.get(key);
261
+ if (existing) {
262
+ existing.count += 1;
263
+ if (existing.count === 2) {
264
+ duplicates.push(existing);
265
+ }
266
+ continue;
267
+ }
268
+ workflowKeys.set(key, { name, version, count: 1 });
269
+ }
270
+ return duplicates;
271
+ }
272
+ /**
273
+ * Throw a CLIError if duplicate workflows are found.
274
+ * @param workflows - Discovered workflows
275
+ * @throws {CLIError} When duplicate workflows are found
276
+ */
277
+ function assertNoDuplicateWorkflows(workflows) {
278
+ const duplicates = findDuplicateWorkflows(workflows);
279
+ if (duplicates.length === 0)
280
+ return;
281
+ const formatted = duplicates.map((duplicate) => formatWorkflowIdentity(duplicate.name, duplicate.version));
282
+ const preview = formatted.slice(0, 3).join(", ");
283
+ const remaining = duplicates.length - 3;
284
+ const suffix = remaining > 0 ? ` (+${String(remaining)} more)` : "";
285
+ 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.");
286
+ }
287
+ /**
288
+ * Warn about duplicate workflows without failing.
289
+ * @param workflows - Discovered workflows
290
+ */
291
+ function warnAboutDuplicateWorkflows(workflows) {
292
+ const duplicates = findDuplicateWorkflows(workflows);
293
+ for (const duplicate of duplicates) {
294
+ const versionStr = duplicate.version
295
+ ? ` (version: ${duplicate.version})`
296
+ : "";
297
+ consola.warn(`Duplicate workflow detected: "${duplicate.name}"${versionStr}`);
298
+ consola.warn("Multiple files export a workflow with the same name and version.");
299
+ }
300
+ }
301
+ /**
302
+ * Print discovered workflows to the console.
303
+ * @param workflows - Array of discovered workflows
304
+ */
305
+ function printDiscoveredWorkflows(workflows) {
306
+ consola.log("");
307
+ consola.info(`Discovered ${String(workflows.length)} workflow(s):`);
308
+ for (const workflow of workflows) {
309
+ const name = workflow.spec.name;
310
+ const version = workflow.spec.version ?? "unversioned";
311
+ const versionStr = version === "unversioned" ? "" : ` (version: ${version})`;
312
+ consola.log(` • ${name}${versionStr}`);
313
+ }
314
+ }
315
+ const WORKFLOW_EXTENSIONS = ["ts", "mts", "cts", "js", "mjs", "cjs"];
316
+ /**
317
+ * Discover workflow files from directories. Recursively scans directories for
318
+ * workflow files with supported extensions (.ts, .js, .mjs, .cjs).
319
+ * @param dirs - Directory or directories to scan for workflow files
320
+ * @param baseDir - Base directory to resolve relative paths from
321
+ * @returns Array of absolute file paths
322
+ */
323
+ function discoverWorkflowFiles(dirs, baseDir) {
324
+ const discoveredFiles = [];
325
+ /**
326
+ * Recursively scan a directory for workflow files.
327
+ * @param dir - Directory to scan
328
+ */
329
+ function scanDirectory(dir) {
330
+ const absoluteDir = path.isAbsolute(dir) ? dir : path.resolve(baseDir, dir);
331
+ let entries;
332
+ try {
333
+ entries = readdirSync(absoluteDir, { withFileTypes: true });
334
+ }
335
+ catch (error) {
336
+ // doesn't exist or can't be read, skip
337
+ const errMessage = error instanceof Error ? error.message : String(error);
338
+ consola.debug(`Failed to read directory: ${absoluteDir} - ${errMessage}`);
339
+ return;
340
+ }
341
+ for (const entry of entries) {
342
+ const fullPath = path.join(absoluteDir, entry.name);
343
+ if (entry.isDirectory()) {
344
+ scanDirectory(fullPath);
345
+ }
346
+ else if (entry.isFile() &&
347
+ WORKFLOW_EXTENSIONS.some((ext) => entry.name.endsWith(`.${ext}`)) &&
348
+ !entry.name.endsWith(".d.ts")) {
349
+ discoveredFiles.push(fullPath);
350
+ }
351
+ }
352
+ }
353
+ for (const dir of dirs) {
354
+ scanDirectory(dir);
355
+ }
356
+ return discoveredFiles;
357
+ }
358
+ /**
359
+ * Import workflow files and extract workflow exports.
360
+ * Supports both named exports and default exports.
361
+ * @param files - Array of absolute file paths to import
362
+ * @returns Array of discovered workflows
363
+ */
364
+ async function importWorkflows(files) {
365
+ const workflows = [];
366
+ const jiti = createJiti(import.meta.url);
367
+ for (const file of files) {
368
+ // import the module
369
+ let module;
370
+ try {
371
+ module = await jiti.import(pathToFileURL(file).href);
372
+ }
373
+ catch (error) {
374
+ const errorMessage = error instanceof Error ? error.message : String(error);
375
+ throw new CLIError(`Failed to import workflow file: ${file}`, `Error: ${errorMessage}`);
376
+ }
377
+ // extract workflow exports (named and default)
378
+ for (const [key, value] of Object.entries(module)) {
379
+ if (isWorkflow(value)) {
380
+ const workflow = value;
381
+ workflows.push(workflow);
382
+ consola.debug(`Found workflow "${workflow.spec.name}" in ${file} (${key})`);
383
+ }
384
+ }
385
+ }
386
+ return workflows;
387
+ }
388
+ /**
389
+ * Discover workflow files and import workflows with common error handling.
390
+ * @param dirs - Workflow directories
391
+ * @param baseDir - Base directory for relative paths
392
+ * @returns Files and workflows
393
+ */
394
+ async function discoverWorkflowsInDirs(dirs, baseDir) {
395
+ const files = discoverWorkflowFiles(dirs, baseDir);
396
+ if (files.length === 0) {
397
+ const extensionsStr = WORKFLOW_EXTENSIONS.map((ext) => `*.${ext}`).join(", ");
398
+ throw new CLIError("No workflow files found.", `No workflow files found in: ${dirs.join(", ")}\n` +
399
+ `Make sure your workflow files (${extensionsStr}) exist in these directories.`);
400
+ }
401
+ const workflows = await importWorkflows(files);
402
+ if (workflows.length === 0) {
403
+ throw new CLIError("No workflows found.", `No workflows exported in: ${dirs.join(", ")}\n` +
404
+ "Make sure your workflow files export workflows created with defineWorkflow().");
405
+ }
406
+ return { files, workflows };
407
+ }
408
+ /**
409
+ * Get the config template for a backend choice.
410
+ * @param backendChoice - The selected backend choice
411
+ * @returns The config template string
412
+ */
413
+ function getConfigTemplate(backendChoice) {
414
+ switch (backendChoice) {
415
+ case "sqlite": {
416
+ return SQLITE_CONFIG;
417
+ }
418
+ case "postgres": {
419
+ return POSTGRES_CONFIG;
420
+ }
421
+ case "both": {
422
+ return POSTGRES_PROD_SQLITE_DEV_CONFIG;
423
+ }
424
+ }
425
+ }
426
+ /**
427
+ * Get the dependencies to install for a backend choice.
428
+ * @param backendChoice - The selected backend choice
429
+ * @returns Array of dependency package names to install
430
+ */
431
+ function getDependenciesToInstall(backendChoice) {
432
+ const dependencies = ["openworkflow"];
433
+ if (backendChoice === "sqlite" || backendChoice === "both") {
434
+ dependencies.push("@openworkflow/backend-sqlite");
435
+ }
436
+ if (backendChoice === "postgres" || backendChoice === "both") {
437
+ dependencies.push("@openworkflow/backend-postgres");
438
+ }
439
+ return dependencies;
440
+ }
441
+ /**
442
+ * Get the dev dependencies to install.
443
+ * @returns Array of dev dependency package names to install
444
+ */
445
+ function getDevDependenciesToInstall() {
446
+ return ["@openworkflow/cli"];
447
+ }
448
+ /**
449
+ * Create config file.
450
+ * @param backendChoice - The selected backend choice
451
+ * @param configFileName - The config file name to write
452
+ */
453
+ function createConfigFile(backendChoice, configFileName) {
454
+ const spinner = p.spinner();
455
+ spinner.start("Writing config...");
456
+ const configTemplate = getConfigTemplate(backendChoice);
457
+ const configDestPath = path.join(process.cwd(), configFileName);
458
+ writeFileSync(configDestPath, configTemplate, "utf8");
459
+ spinner.stop(`Config written to ${configDestPath}`);
460
+ }
461
+ /**
462
+ * Create example workflow.
463
+ * @param exampleWorkflowFileName - The example workflow filename to write
464
+ */
465
+ function createExampleWorkflow(exampleWorkflowFileName) {
466
+ const spinner = p.spinner();
467
+ const workflowsDir = path.join(process.cwd(), "openworkflow");
468
+ if (!existsSync(workflowsDir)) {
469
+ mkdirSync(workflowsDir, { recursive: true });
470
+ }
471
+ const helloWorldDestPath = path.join(workflowsDir, exampleWorkflowFileName);
472
+ if (existsSync(helloWorldDestPath)) {
473
+ spinner.start("Checking example (hello-world) workflow...");
474
+ spinner.stop(`Example (hello-world) workflow already exists at ${helloWorldDestPath}`);
475
+ return;
476
+ }
477
+ spinner.start("Creating example (hello-world) workflow...");
478
+ writeFileSync(helloWorldDestPath, HELLO_WORLD_WORKFLOW, "utf8");
479
+ spinner.stop(`Created example (hello-world) workflow at ${helloWorldDestPath}`);
480
+ }
481
+ /**
482
+ * Update .gitignore for SQLite.
483
+ */
484
+ function updateGitignoreForSqlite() {
485
+ const workflowsDir = path.join(process.cwd(), "openworkflow");
486
+ if (!existsSync(workflowsDir)) {
487
+ mkdirSync(workflowsDir, { recursive: true });
488
+ }
489
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
490
+ const spinner = p.spinner();
491
+ spinner.start("Updating .gitignore...");
492
+ const result = ensureGitignoreEntry(gitignorePath, "openworkflow/backend.db");
493
+ spinner.stop(result.added
494
+ ? "Added openworkflow/backend.db to .gitignore"
495
+ : "openworkflow/backend.db already in .gitignore");
496
+ }
497
+ /**
498
+ * Add worker script to package.json.
499
+ */
500
+ function addWorkerScriptToPackageJson() {
501
+ const packageJsonPath = path.join(process.cwd(), "package.json");
502
+ if (!existsSync(packageJsonPath)) {
503
+ return;
504
+ }
505
+ const spinner = p.spinner();
506
+ spinner.start("Adding worker script to package.json...");
507
+ try {
508
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
509
+ packageJson.scripts ??= {};
510
+ packageJson.scripts["worker"] = "ow worker start";
511
+ writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf8");
512
+ spinner.stop('Added "worker" script to package.json');
513
+ }
514
+ catch {
515
+ spinner.stop("Failed to update package.json");
516
+ consola.warn("Could not add worker script to package.json");
517
+ }
518
+ }
519
+ /**
520
+ * Ensure a specific entry exists in a .gitignore file. Creates the file if it
521
+ * doesn't exist, appends the entry if not present.
522
+ * @param gitignorePath - Path to the .gitignore file
523
+ * @param entry - The entry to add (e.g. "openworkflow/backend.db")
524
+ * @returns Object indicating whether the entry was added or already existed
525
+ */
526
+ function ensureGitignoreEntry(gitignorePath, entry) {
527
+ const fileExists = existsSync(gitignorePath);
528
+ let content = "";
529
+ if (fileExists) {
530
+ content = readFileSync(gitignorePath, "utf8");
531
+ }
532
+ // check if entry already exists
533
+ const lines = content.split("\n");
534
+ const hasEntry = lines.some((line) => line.trim() === entry);
535
+ if (hasEntry) {
536
+ return { added: false, created: false };
537
+ }
538
+ // add entry to .gitignore
539
+ let newContent;
540
+ if (content === "") {
541
+ newContent = `${entry}\n`;
542
+ }
543
+ else if (content.endsWith("\n")) {
544
+ newContent = `${content}${entry}\n`;
545
+ }
546
+ else {
547
+ newContent = `${content}\n${entry}\n`;
548
+ }
549
+ writeFileSync(gitignorePath, newContent, "utf8");
550
+ return { added: true, created: !fileExists };
551
+ }
552
+ /**
553
+ * Add OPENWORKFLOW_POSTGRES_URL to .env file.
554
+ */
555
+ function updateEnvForPostgres() {
556
+ const envPath = path.join(process.cwd(), ".env");
557
+ const spinner = p.spinner();
558
+ spinner.start("Updating .env...");
559
+ const result = ensureEnvEntry(envPath, "OPENWORKFLOW_POSTGRES_URL", "postgresql://user:password@localhost:5432/openworkflow");
560
+ spinner.stop(result.added
561
+ ? "Added OPENWORKFLOW_POSTGRES_URL to .env"
562
+ : "OPENWORKFLOW_POSTGRES_URL already in .env");
563
+ }
564
+ /**
565
+ * Load CLI config after loading .env, and wrap errors for user-facing output.
566
+ * @returns Loaded config and metadata.
567
+ */
568
+ async function loadConfigWithEnv() {
569
+ loadDotenv({ quiet: true });
570
+ try {
571
+ return await loadConfig();
572
+ }
573
+ catch (error) {
574
+ const message = error instanceof Error ? error.message : String(error);
575
+ throw new CLIError("Failed to load OpenWorkflow config.", message);
576
+ }
577
+ }
578
+ /**
579
+ * Load package.json for doctor checks.
580
+ * @returns Parsed package.json or null if unavailable.
581
+ */
582
+ function readPackageJsonForDoctor() {
583
+ const packageJsonPath = path.join(process.cwd(), "package.json");
584
+ if (!existsSync(packageJsonPath)) {
585
+ return null;
586
+ }
587
+ try {
588
+ return JSON.parse(readFileSync(packageJsonPath, "utf8"));
589
+ }
590
+ catch {
591
+ consola.warn("Could not read package.json for dependency checks.");
592
+ return null;
593
+ }
594
+ }
595
+ /**
596
+ * Determine the config filename to write during init.
597
+ * @param packageJson - Parsed package.json (or null if missing)
598
+ * @returns The config file name to create
599
+ */
600
+ export function getConfigFileName(packageJson) {
601
+ if (packageJson && hasDependency(packageJson, "typescript")) {
602
+ return "openworkflow.config.ts";
603
+ }
604
+ return "openworkflow.config.js";
605
+ }
606
+ /**
607
+ * Determine the example workflow filename to write during init.
608
+ * @param packageJson - Parsed package.json (or null if missing)
609
+ * @returns The example workflow file name to create
610
+ */
611
+ export function getExampleWorkflowFileName(packageJson) {
612
+ const configFileName = getConfigFileName(packageJson);
613
+ const extension = path.extname(configFileName) || ".js";
614
+ return `hello-world${extension}`;
615
+ }
616
+ /**
617
+ * Check whether a dependency is declared in package.json.
618
+ * @param packageJson - Parsed package.json.
619
+ * @param name - Dependency name to check.
620
+ * @returns True when the dependency is listed.
621
+ */
622
+ function hasDependency(packageJson, name) {
623
+ return Boolean(packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name]);
624
+ }
625
+ /**
626
+ * Warn when the configured backend is missing its package.
627
+ * @param backendName - Configured backend name.
628
+ * @param packageJson - Parsed package.json.
629
+ */
630
+ function warnIfMissingBackendPackage(backendName, packageJson) {
631
+ const backendNameLower = backendName.toLowerCase();
632
+ if (backendNameLower.includes("postgres") &&
633
+ !hasDependency(packageJson, "@openworkflow/backend-postgres")) {
634
+ consola.warn("Backend is Postgres but @openworkflow/backend-postgres is not installed.");
635
+ }
636
+ if (backendNameLower.includes("sqlite") &&
637
+ !hasDependency(packageJson, "@openworkflow/backend-sqlite")) {
638
+ consola.warn("Backend is SQLite but @openworkflow/backend-sqlite is not installed.");
639
+ }
640
+ }
641
+ /**
642
+ * Warn when TypeScript is installed but tsconfig.json is missing.
643
+ * @param packageJson - Parsed package.json.
644
+ */
645
+ function warnIfMissingTsconfig(packageJson) {
646
+ if (!hasDependency(packageJson, "typescript")) {
647
+ return;
648
+ }
649
+ const tsconfigPath = path.join(process.cwd(), "tsconfig.json");
650
+ if (!existsSync(tsconfigPath)) {
651
+ consola.warn("TypeScript is installed but no tsconfig.json was found.");
652
+ }
653
+ }
654
+ /**
655
+ * Ensure a specific environment variable exists in a .env file. Creates the file if it
656
+ * doesn't exist, appends the variable if not present.
657
+ * @param envPath - Path to the .env file
658
+ * @param key - The environment variable key (e.g. "OPENWORKFLOW_POSTGRES_URL")
659
+ * @param value - The default value for the environment variable
660
+ * @returns Object indicating whether the entry was added or already existed
661
+ */
662
+ function ensureEnvEntry(envPath, key, value) {
663
+ const fileExists = existsSync(envPath);
664
+ let content = "";
665
+ if (fileExists) {
666
+ content = readFileSync(envPath, "utf8");
667
+ }
668
+ // check if key already exists (looking for KEY= at start of line)
669
+ const lines = content.split("\n");
670
+ const hasKey = lines.some((line) => {
671
+ const trimmed = line.trim();
672
+ return trimmed.startsWith(`${key}=`) || trimmed.startsWith(`${key} =`);
673
+ });
674
+ if (hasKey) {
675
+ return { added: false, created: false };
676
+ }
677
+ // add entry to .env
678
+ let newContent;
679
+ const envEntry = `${key}=${value}`;
680
+ if (content === "") {
681
+ newContent = `${envEntry}\n`;
682
+ }
683
+ else if (content.endsWith("\n")) {
684
+ newContent = `${content}${envEntry}\n`;
685
+ }
686
+ else {
687
+ newContent = `${content}\n${envEntry}\n`;
688
+ }
689
+ writeFileSync(envPath, newContent, "utf8");
690
+ return { added: true, created: !fileExists };
691
+ }
692
+ /**
693
+ * Validate a numeric option is a positive integer.
694
+ * @param name - Option name
695
+ * @param value - Option value
696
+ * @throws {CLIError} When the value is invalid
697
+ */
698
+ function assertPositiveInteger(name, value) {
699
+ if (!Number.isInteger(value) || value <= 0) {
700
+ throw new CLIError(`Invalid ${name}: ${String(value)}`, `${name} must be a positive integer.`);
701
+ }
702
+ }
703
+ /**
704
+ * Merge CLI options into config, skipping undefined overrides.
705
+ * @param base - Config options
706
+ * @param overrides - CLI overrides
707
+ * @returns Merged options
708
+ */
709
+ function mergeDefinedOptions(base, overrides) {
710
+ const merged = base ? { ...base } : {};
711
+ for (const [key, value] of Object.entries(overrides)) {
712
+ if (value !== undefined) {
713
+ merged[key] = value;
714
+ }
715
+ }
716
+ return merged;
717
+ }
718
+ //# sourceMappingURL=commands.js.map