@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.
- package/README.md +8 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +32 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +7 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/commands.d.ts +33 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +718 -0
- package/dist/commands.js.map +1 -0
- package/dist/commands.test.d.ts +2 -0
- package/dist/commands.test.d.ts.map +1 -0
- package/dist/commands.test.js +35 -0
- package/dist/commands.test.js.map +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +51 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +89 -0
- package/dist/config.test.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +43 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +11 -0
- package/dist/index.test.js.map +1 -0
- package/dist/init.test.d.ts +2 -0
- package/dist/init.test.d.ts.map +1 -0
- package/dist/init.test.js +148 -0
- package/dist/init.test.js.map +1 -0
- package/dist/templates.d.ts +5 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +55 -0
- package/dist/templates.js.map +1 -0
- package/dist/templates.test.d.ts +2 -0
- package/dist/templates.test.d.ts.map +1 -0
- package/dist/templates.test.js +20 -0
- package/dist/templates.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +38 -0
package/dist/commands.js
ADDED
|
@@ -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
|