@usejourney/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/LICENSE +21 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1840 -0
- package/package.json +40 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1840 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { resolve as resolvePath } from "path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/commands/envList.ts
|
|
8
|
+
import { listEnvironments, loadConfig, resolveConfigPaths } from "@usejourney/core";
|
|
9
|
+
async function runEnvList(projectDir) {
|
|
10
|
+
const loaded = await loadConfig(projectDir);
|
|
11
|
+
const { environmentsDir } = resolveConfigPaths(loaded);
|
|
12
|
+
const envs = await listEnvironments(environmentsDir);
|
|
13
|
+
if (envs.length === 0) {
|
|
14
|
+
console.log(`No environments found in ${environmentsDir}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const def = loaded.config.defaultEnvironment;
|
|
18
|
+
for (const name of envs) {
|
|
19
|
+
console.log(name === def ? `* ${name}` : ` ${name}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/commands/exportK6.ts
|
|
24
|
+
import { stat } from "fs/promises";
|
|
25
|
+
import { isAbsolute, resolve } from "path";
|
|
26
|
+
import { exportToK6 } from "@usejourney/k6-adapter";
|
|
27
|
+
|
|
28
|
+
// src/util/discover.ts
|
|
29
|
+
import { readdir } from "fs/promises";
|
|
30
|
+
import { join } from "path";
|
|
31
|
+
async function discoverJourneyFiles(journeysDir) {
|
|
32
|
+
try {
|
|
33
|
+
const entries = await readdir(journeysDir);
|
|
34
|
+
return entries.filter((e) => e.endsWith(".journey.ts")).sort().map((e) => join(journeysDir, e));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.code === "ENOENT") return [];
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/util/loadJourneyFile.ts
|
|
42
|
+
import { pathToFileURL } from "url";
|
|
43
|
+
import { clearRegistry, getRegisteredJourneys } from "@usejourney/core";
|
|
44
|
+
import { tsImport } from "tsx/esm/api";
|
|
45
|
+
async function loadJourneyDefs(file) {
|
|
46
|
+
clearRegistry();
|
|
47
|
+
await tsImport(pathToFileURL(file).href, import.meta.url);
|
|
48
|
+
const defs = getRegisteredJourneys().slice();
|
|
49
|
+
clearRegistry();
|
|
50
|
+
return defs;
|
|
51
|
+
}
|
|
52
|
+
async function importJourneyFiles(files) {
|
|
53
|
+
clearRegistry();
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
await tsImport(pathToFileURL(file).href, import.meta.url);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/commands/exportK6.ts
|
|
60
|
+
function matches(def, tags) {
|
|
61
|
+
if (tags.length === 0) return true;
|
|
62
|
+
return tags.every((t) => def.options?.tags?.includes(t) === true);
|
|
63
|
+
}
|
|
64
|
+
function pickK6Options(file, matching) {
|
|
65
|
+
const withK6 = matching.filter((d) => d.options?.k6);
|
|
66
|
+
if (withK6.length === 0) return void 0;
|
|
67
|
+
if (withK6.length > 1) {
|
|
68
|
+
const names = withK6.map((d) => `"${d.name}"`).join(", ");
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${file}: ${withK6.length} journeys declare k6 options (${names}). k6 'export const options' is module-scoped \u2014 split into separate files or remove all but one k6 config block.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return withK6[0].options.k6;
|
|
74
|
+
}
|
|
75
|
+
async function runExportK6(opts) {
|
|
76
|
+
const target = isAbsolute(opts.path) ? opts.path : resolve(process.cwd(), opts.path);
|
|
77
|
+
const tags = opts.tags ?? [];
|
|
78
|
+
let info;
|
|
79
|
+
try {
|
|
80
|
+
info = await stat(target);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err.code === "ENOENT") {
|
|
83
|
+
throw new Error(`Path not found: ${opts.path}`);
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
const isDir = info.isDirectory();
|
|
88
|
+
if (isDir && opts.out) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"--out is only valid with a single journey file. Use --out-dir for directories."
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const files = isDir ? await discoverJourneyFiles(target) : [target];
|
|
94
|
+
if (files.length === 0) {
|
|
95
|
+
console.log(`No .journey.ts files found in ${opts.path}`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
const work = [];
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const defs = await loadJourneyDefs(file);
|
|
101
|
+
work.push({ file, defs });
|
|
102
|
+
}
|
|
103
|
+
let exported = 0;
|
|
104
|
+
for (const { file, defs } of work) {
|
|
105
|
+
const matching = defs.filter((d) => matches(d, tags));
|
|
106
|
+
if (matching.length === 0) {
|
|
107
|
+
if (tags.length > 0) console.log(`Skipped (no matching journey) \u2192 ${file}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const k6Options = pickK6Options(file, matching);
|
|
111
|
+
const result = await exportToK6({
|
|
112
|
+
journeyFile: file,
|
|
113
|
+
...opts.out !== void 0 ? { outFile: opts.out } : {},
|
|
114
|
+
...opts.outDir !== void 0 ? { outDir: opts.outDir } : {},
|
|
115
|
+
...k6Options !== void 0 ? { k6Options } : {}
|
|
116
|
+
});
|
|
117
|
+
console.log(`Wrote k6 script \u2192 ${result.outFile}`);
|
|
118
|
+
exported++;
|
|
119
|
+
}
|
|
120
|
+
if (exported === 0 && tags.length > 0) {
|
|
121
|
+
console.log(`No journeys matched tags: ${tags.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/commands/exportPostman.ts
|
|
127
|
+
import { mkdir, stat as stat2, writeFile } from "fs/promises";
|
|
128
|
+
import { basename, dirname, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
|
|
129
|
+
import {
|
|
130
|
+
clearActiveEnvironment,
|
|
131
|
+
collectPipeline,
|
|
132
|
+
collectSubPipeline,
|
|
133
|
+
listEnvironments as listEnvironments2,
|
|
134
|
+
loadConfig as loadConfig2,
|
|
135
|
+
loadEnvironment,
|
|
136
|
+
resolveConfigPaths as resolveConfigPaths2,
|
|
137
|
+
setActiveEnvironment
|
|
138
|
+
} from "@usejourney/core";
|
|
139
|
+
import {
|
|
140
|
+
ENV_PROXY,
|
|
141
|
+
buildCollection,
|
|
142
|
+
buildEnvironment,
|
|
143
|
+
buildFolder
|
|
144
|
+
} from "@usejourney/postman-adapter";
|
|
145
|
+
var MAX_SUB_DEPTH = 8;
|
|
146
|
+
function firstIdentParam(src) {
|
|
147
|
+
const s = src.trim();
|
|
148
|
+
const bare = /^(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/.exec(s);
|
|
149
|
+
if (bare) return bare[1];
|
|
150
|
+
const paren = /^(?:async\s+)?(?:function\b[^(]*)?\(([^)]*)\)/.exec(s);
|
|
151
|
+
if (!paren) return void 0;
|
|
152
|
+
const first = paren[1].split(",")[0].trim();
|
|
153
|
+
return /^[A-Za-z_$][\w$]*$/.test(first) ? first : void 0;
|
|
154
|
+
}
|
|
155
|
+
function buildInputThread(call) {
|
|
156
|
+
if (typeof call.inputs !== "function") return void 0;
|
|
157
|
+
const inputParam = firstIdentParam(call.handle.__def.body.toString());
|
|
158
|
+
if (!inputParam) return void 0;
|
|
159
|
+
return { inputsSrc: call.inputs.toString(), inputParam };
|
|
160
|
+
}
|
|
161
|
+
async function resolveSubInputs(call) {
|
|
162
|
+
const raw = call.inputs;
|
|
163
|
+
if (raw === void 0) return void 0;
|
|
164
|
+
try {
|
|
165
|
+
const value = typeof raw === "function" ? await raw() : raw;
|
|
166
|
+
return value && typeof value === "object" ? value : void 0;
|
|
167
|
+
} catch {
|
|
168
|
+
return void 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function resolveSubCache(call, inputs) {
|
|
172
|
+
if (call.cacheKey === void 0 || call.cache === "off") return {};
|
|
173
|
+
let resolvedKey;
|
|
174
|
+
try {
|
|
175
|
+
const rk = typeof call.cacheKey === "function" ? call.cacheKey(inputs) : call.cacheKey;
|
|
176
|
+
if (typeof rk !== "string") return {};
|
|
177
|
+
resolvedKey = rk;
|
|
178
|
+
} catch {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
cacheKey: `${call.handle.name}:${resolvedKey}`,
|
|
183
|
+
...call.cacheTtlMs !== void 0 ? { cacheTtlMs: call.cacheTtlMs } : {}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async function toExportNodes(nodes, depth) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const node of nodes) {
|
|
189
|
+
if (node.kind === "step") {
|
|
190
|
+
out.push({ kind: "step", def: node.def });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const name = node.def.name ?? node.def.handle.name;
|
|
194
|
+
const inputs = await resolveSubInputs(node.def);
|
|
195
|
+
let childNodes = [];
|
|
196
|
+
if (depth < MAX_SUB_DEPTH) {
|
|
197
|
+
try {
|
|
198
|
+
const childPipeline = await collectSubPipeline(node.def);
|
|
199
|
+
childNodes = await toExportNodes(childPipeline, depth + 1);
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const cache = resolveSubCache(node.def, inputs);
|
|
204
|
+
const inputThread = buildInputThread(node.def);
|
|
205
|
+
out.push({
|
|
206
|
+
kind: "sub",
|
|
207
|
+
name,
|
|
208
|
+
...inputs ? { inputs } : {},
|
|
209
|
+
...inputThread ?? {},
|
|
210
|
+
...cache,
|
|
211
|
+
...node.def.after ? { after: node.def.after } : {},
|
|
212
|
+
...node.def.assert ? { assert: node.def.assert } : {},
|
|
213
|
+
nodes: childNodes
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
var BUNDLE_BASENAME = "journeys.postman_collection.json";
|
|
219
|
+
function matches2(def, tags) {
|
|
220
|
+
if (tags.length === 0) return true;
|
|
221
|
+
return tags.every((t) => def.options?.tags?.includes(t) === true);
|
|
222
|
+
}
|
|
223
|
+
function uniqueFolderName(name, seen) {
|
|
224
|
+
const n = seen.get(name) ?? 0;
|
|
225
|
+
seen.set(name, n + 1);
|
|
226
|
+
if (n === 0) return name;
|
|
227
|
+
console.warn(`Duplicate journey name "${name}" across files \u2014 renamed to "${name} (${n + 1})".`);
|
|
228
|
+
return `${name} (${n + 1})`;
|
|
229
|
+
}
|
|
230
|
+
async function exportEnvironment(environmentsDir, envName, outDir) {
|
|
231
|
+
const values = await loadEnvironment(environmentsDir, envName);
|
|
232
|
+
const env = buildEnvironment(envName, values);
|
|
233
|
+
const outFile = join2(outDir, `${envName}.postman_environment.json`);
|
|
234
|
+
await mkdir(dirname(outFile), { recursive: true });
|
|
235
|
+
await writeFile(outFile, JSON.stringify(env, null, 2), "utf8");
|
|
236
|
+
return outFile;
|
|
237
|
+
}
|
|
238
|
+
async function runExportPostman(opts) {
|
|
239
|
+
const target = isAbsolute2(opts.path) ? opts.path : resolve2(process.cwd(), opts.path);
|
|
240
|
+
const tags = opts.tags ?? [];
|
|
241
|
+
let info;
|
|
242
|
+
try {
|
|
243
|
+
info = await stat2(target);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (err.code === "ENOENT") {
|
|
246
|
+
throw new Error(`Path not found: ${opts.path}`);
|
|
247
|
+
}
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
const isDir = info.isDirectory();
|
|
251
|
+
if (isDir && opts.out && !opts.bundle) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
"--out is only valid with a single journey file, or with --bundle. Use --out-dir for per-file directory output."
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const files = isDir ? await discoverJourneyFiles(target) : [target];
|
|
257
|
+
if (files.length === 0) {
|
|
258
|
+
console.log(`No .journey.ts files found in ${opts.path}`);
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
let environmentsDir;
|
|
262
|
+
if (opts.env || opts.allEnvs) {
|
|
263
|
+
try {
|
|
264
|
+
const loaded = await loadConfig2(opts.projectDir ?? process.cwd());
|
|
265
|
+
environmentsDir = resolveConfigPaths2(loaded).environmentsDir;
|
|
266
|
+
} catch {
|
|
267
|
+
throw new Error(
|
|
268
|
+
"Could not load journey.config.json. --env/--all-envs require a Journey project."
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
setActiveEnvironment("__postman_export__", ENV_PROXY);
|
|
273
|
+
let exported = 0;
|
|
274
|
+
const writtenEnvs = /* @__PURE__ */ new Set();
|
|
275
|
+
async function writeEnvs(envOutDir) {
|
|
276
|
+
if (!environmentsDir) return;
|
|
277
|
+
if (opts.allEnvs && !writtenEnvs.has("__all__")) {
|
|
278
|
+
writtenEnvs.add("__all__");
|
|
279
|
+
const envNames = await listEnvironments2(environmentsDir);
|
|
280
|
+
for (const envName of envNames) {
|
|
281
|
+
const envFile = await exportEnvironment(environmentsDir, envName, envOutDir);
|
|
282
|
+
console.log(`Wrote Postman environment \u2192 ${envFile}`);
|
|
283
|
+
}
|
|
284
|
+
} else if (opts.env && !writtenEnvs.has(opts.env)) {
|
|
285
|
+
writtenEnvs.add(opts.env);
|
|
286
|
+
const envFile = await exportEnvironment(environmentsDir, opts.env, envOutDir);
|
|
287
|
+
console.log(`Wrote Postman environment \u2192 ${envFile}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
if (opts.bundle) {
|
|
292
|
+
const mergedFolders = [];
|
|
293
|
+
const seenNames = /* @__PURE__ */ new Map();
|
|
294
|
+
for (const file of files) {
|
|
295
|
+
const defs = await loadJourneyDefs(file);
|
|
296
|
+
const matching = defs.filter((d) => matches2(d, tags));
|
|
297
|
+
if (matching.length === 0) {
|
|
298
|
+
if (tags.length > 0) console.log(`Skipped (no matching journey) \u2192 ${file}`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
for (const def of matching) {
|
|
302
|
+
const pipeline = await collectPipeline(def);
|
|
303
|
+
const nodes = await toExportNodes(pipeline, 0);
|
|
304
|
+
const folder = await buildFolder(def.name, nodes, {
|
|
305
|
+
...opts.threadState !== void 0 ? { threadState: opts.threadState } : {},
|
|
306
|
+
...opts.lenient !== void 0 ? { lenient: opts.lenient } : {}
|
|
307
|
+
});
|
|
308
|
+
folder.name = uniqueFolderName(folder.name, seenNames);
|
|
309
|
+
mergedFolders.push(folder);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (mergedFolders.length === 0) {
|
|
313
|
+
if (tags.length > 0) console.log(`No journeys matched tags: ${tags.join(", ")}`);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
const outFile = opts.out ? resolve2(process.cwd(), opts.out) : opts.outDir ? resolve2(process.cwd(), opts.outDir, BUNDLE_BASENAME) : resolve2(process.cwd(), BUNDLE_BASENAME);
|
|
317
|
+
const collection = buildCollection(opts.name ?? "journeys", mergedFolders, {
|
|
318
|
+
reset: opts.threadState ?? false
|
|
319
|
+
});
|
|
320
|
+
const collectionOutDir = dirname(outFile);
|
|
321
|
+
await mkdir(collectionOutDir, { recursive: true });
|
|
322
|
+
await writeFile(outFile, JSON.stringify(collection, null, 2), "utf8");
|
|
323
|
+
console.log(`Wrote Postman collection \u2192 ${outFile}`);
|
|
324
|
+
exported++;
|
|
325
|
+
await writeEnvs(opts.outDir ? resolve2(process.cwd(), opts.outDir) : collectionOutDir);
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
for (const file of files) {
|
|
329
|
+
const defs = await loadJourneyDefs(file);
|
|
330
|
+
const matching = defs.filter((d) => matches2(d, tags));
|
|
331
|
+
if (matching.length === 0) {
|
|
332
|
+
if (tags.length > 0) console.log(`Skipped (no matching journey) \u2192 ${file}`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const fileBase = basename(file).replace(/\.journey\.ts$/, "");
|
|
336
|
+
const collectionName = opts.name ?? fileBase;
|
|
337
|
+
const folders = [];
|
|
338
|
+
for (const def of matching) {
|
|
339
|
+
const pipeline = await collectPipeline(def);
|
|
340
|
+
const nodes = await toExportNodes(pipeline, 0);
|
|
341
|
+
folders.push(
|
|
342
|
+
await buildFolder(def.name, nodes, {
|
|
343
|
+
...opts.threadState !== void 0 ? { threadState: opts.threadState } : {},
|
|
344
|
+
...opts.lenient !== void 0 ? { lenient: opts.lenient } : {}
|
|
345
|
+
})
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const collection = buildCollection(collectionName, folders, {
|
|
349
|
+
reset: opts.threadState ?? false
|
|
350
|
+
});
|
|
351
|
+
const json = JSON.stringify(collection, null, 2);
|
|
352
|
+
const outFile = opts.out ? resolve2(process.cwd(), opts.out) : opts.outDir ? resolve2(process.cwd(), opts.outDir, `${fileBase}.postman_collection.json`) : join2(dirname(file), `${fileBase}.postman_collection.json`);
|
|
353
|
+
const collectionOutDir = dirname(outFile);
|
|
354
|
+
await mkdir(collectionOutDir, { recursive: true });
|
|
355
|
+
await writeFile(outFile, json, "utf8");
|
|
356
|
+
console.log(`Wrote Postman collection \u2192 ${outFile}`);
|
|
357
|
+
exported++;
|
|
358
|
+
await writeEnvs(opts.outDir ? resolve2(process.cwd(), opts.outDir) : collectionOutDir);
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
clearActiveEnvironment();
|
|
362
|
+
}
|
|
363
|
+
if (exported === 0 && tags.length > 0) {
|
|
364
|
+
console.log(`No journeys matched tags: ${tags.join(", ")}`);
|
|
365
|
+
}
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/commands/generate.ts
|
|
370
|
+
import { stat as stat3 } from "fs/promises";
|
|
371
|
+
import { loadConfig as loadConfig3, resolveConfigPaths as resolveConfigPaths3 } from "@usejourney/core";
|
|
372
|
+
import { generate } from "@usejourney/codegen";
|
|
373
|
+
async function runGenerate(projectDir) {
|
|
374
|
+
const loaded = await loadConfig3(projectDir);
|
|
375
|
+
const { specPath, generatedDir, journeysDir } = resolveConfigPaths3(loaded);
|
|
376
|
+
try {
|
|
377
|
+
await stat3(specPath);
|
|
378
|
+
} catch {
|
|
379
|
+
throw new Error(`Spec file not found at ${specPath}`);
|
|
380
|
+
}
|
|
381
|
+
const result = await generate({ specPath, outDir: generatedDir });
|
|
382
|
+
console.log(
|
|
383
|
+
`Regenerated ${result.operationCount} operations \u2192 ${result.modelsPath}, ${result.endpointsPath}`
|
|
384
|
+
);
|
|
385
|
+
await stat3(journeysDir).catch(() => void 0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/commands/init.ts
|
|
389
|
+
import { access, copyFile, mkdir as mkdir2, readdir as readdir2, writeFile as writeFile2 } from "fs/promises";
|
|
390
|
+
import { basename as basename2, isAbsolute as isAbsolute3, join as join3, resolve as resolve3 } from "path";
|
|
391
|
+
import { generate as generate2, loadSpec } from "@usejourney/codegen";
|
|
392
|
+
async function isEmpty(dir) {
|
|
393
|
+
try {
|
|
394
|
+
const entries = await readdir2(dir);
|
|
395
|
+
return entries.length === 0;
|
|
396
|
+
} catch (err) {
|
|
397
|
+
if (err.code === "ENOENT") return true;
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function runInit(opts) {
|
|
402
|
+
const projectDir = isAbsolute3(opts.dir) ? opts.dir : resolve3(process.cwd(), opts.dir);
|
|
403
|
+
const empty = await isEmpty(projectDir);
|
|
404
|
+
if (!empty && !opts.force) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`Target directory ${projectDir} is not empty. Pass --force to scaffold anyway.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const specSource = isAbsolute3(opts.spec) ? opts.spec : resolve3(process.cwd(), opts.spec);
|
|
410
|
+
try {
|
|
411
|
+
await access(specSource);
|
|
412
|
+
} catch {
|
|
413
|
+
throw new Error(`Spec file not found: ${specSource}`);
|
|
414
|
+
}
|
|
415
|
+
await loadSpec(specSource);
|
|
416
|
+
const specDestName = basename2(specSource);
|
|
417
|
+
const specDest = join3(projectDir, specDestName);
|
|
418
|
+
await mkdir2(projectDir, { recursive: true });
|
|
419
|
+
await mkdir2(join3(projectDir, "generated"), { recursive: true });
|
|
420
|
+
await mkdir2(join3(projectDir, "journeys"), { recursive: true });
|
|
421
|
+
await mkdir2(join3(projectDir, "environments"), { recursive: true });
|
|
422
|
+
await mkdir2(join3(projectDir, ".journey", "cache"), { recursive: true });
|
|
423
|
+
await copyFile(specSource, specDest);
|
|
424
|
+
const config = {
|
|
425
|
+
name: basename2(projectDir),
|
|
426
|
+
spec: specDestName,
|
|
427
|
+
generatedDir: "generated",
|
|
428
|
+
journeysDir: "journeys",
|
|
429
|
+
environmentsDir: "environments"
|
|
430
|
+
};
|
|
431
|
+
await writeFile2(
|
|
432
|
+
join3(projectDir, "journey.config.json"),
|
|
433
|
+
`${JSON.stringify(config, null, 2)}
|
|
434
|
+
`,
|
|
435
|
+
"utf8"
|
|
436
|
+
);
|
|
437
|
+
await writeFile2(join3(projectDir, ".gitignore"), `.journey/cache/
|
|
438
|
+
node_modules/
|
|
439
|
+
`, "utf8");
|
|
440
|
+
await writeFile2(
|
|
441
|
+
join3(projectDir, "package.json"),
|
|
442
|
+
`${JSON.stringify({ name: basename2(projectDir), private: true, type: "module" }, null, 2)}
|
|
443
|
+
`,
|
|
444
|
+
"utf8"
|
|
445
|
+
);
|
|
446
|
+
const generated = await generate2({
|
|
447
|
+
specPath: specDest,
|
|
448
|
+
outDir: join3(projectDir, "generated")
|
|
449
|
+
});
|
|
450
|
+
console.log(
|
|
451
|
+
`Initialized Journey project at ${projectDir} (${generated.operationCount} operations).`
|
|
452
|
+
);
|
|
453
|
+
if (generated.operationCount === 0) {
|
|
454
|
+
console.warn(
|
|
455
|
+
"journey: warning \u2014 spec parsed but contained 0 operations. The generated endpoints.ts will be empty."
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// src/commands/run.ts
|
|
461
|
+
import { watch } from "fs/promises";
|
|
462
|
+
import { dirname as dirname3, isAbsolute as isAbsolute4, join as join5, resolve as resolve4 } from "path";
|
|
463
|
+
import {
|
|
464
|
+
clearActiveEnvironment as clearActiveEnvironment2,
|
|
465
|
+
createConsoleLogger,
|
|
466
|
+
createSubJourneyCache,
|
|
467
|
+
loadConfig as loadConfig4,
|
|
468
|
+
loadEnvironment as loadEnvironment2,
|
|
469
|
+
loggerFromEnv,
|
|
470
|
+
pruneRuns,
|
|
471
|
+
resolveBaseUrl,
|
|
472
|
+
resolveConfigPaths as resolveConfigPaths4,
|
|
473
|
+
runAllRegistered,
|
|
474
|
+
setActiveEnvironment as setActiveEnvironment2,
|
|
475
|
+
writeRun
|
|
476
|
+
} from "@usejourney/core";
|
|
477
|
+
|
|
478
|
+
// src/report.ts
|
|
479
|
+
function printResults(results) {
|
|
480
|
+
for (const r of results) {
|
|
481
|
+
const marker = r.ok ? "\u2713" : "\u2717";
|
|
482
|
+
console.log(`${marker} ${r.name} (${r.durationMs}ms)`);
|
|
483
|
+
for (const s of r.steps) {
|
|
484
|
+
const sm = s.ok ? " \u2713" : " \u2717";
|
|
485
|
+
const reqStr = s.request ? ` ${s.request.method} ${s.request.url}` : "";
|
|
486
|
+
const status = s.response ? ` \u2192 ${s.response.status}` : "";
|
|
487
|
+
console.log(`${sm} ${s.name}${reqStr}${status} (${s.durationMs}ms)`);
|
|
488
|
+
if (!s.ok && s.error) console.log(` ${s.error}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const passed = results.filter((r) => r.ok).length;
|
|
492
|
+
const failed = results.length - passed;
|
|
493
|
+
console.log(`
|
|
494
|
+
${passed} passed, ${failed} failed`);
|
|
495
|
+
}
|
|
496
|
+
function overallOk(results) {
|
|
497
|
+
return results.every((r) => r.ok);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/util/projectCoreLink.ts
|
|
501
|
+
import { existsSync, readFileSync } from "fs";
|
|
502
|
+
import { mkdir as mkdir3, symlink } from "fs/promises";
|
|
503
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
504
|
+
import { fileURLToPath } from "url";
|
|
505
|
+
function locateJourneyCoreDir() {
|
|
506
|
+
let dir = dirname2(fileURLToPath(import.meta.url));
|
|
507
|
+
for (let depth = 0; depth < 50; depth++) {
|
|
508
|
+
const pkgJson = join4(dir, "node_modules", "@usejourney", "core", "package.json");
|
|
509
|
+
if (existsSync(pkgJson)) {
|
|
510
|
+
JSON.parse(readFileSync(pkgJson, "utf8"));
|
|
511
|
+
return dirname2(pkgJson);
|
|
512
|
+
}
|
|
513
|
+
const parent = dirname2(dir);
|
|
514
|
+
if (parent === dir) break;
|
|
515
|
+
dir = parent;
|
|
516
|
+
}
|
|
517
|
+
throw new Error("ensureProjectCoreLink: could not locate the CLI-bundled @usejourney/core");
|
|
518
|
+
}
|
|
519
|
+
var cachedCoreDir;
|
|
520
|
+
async function ensureProjectCoreLink(projectDir) {
|
|
521
|
+
const link = join4(projectDir, "node_modules", "@usejourney", "core");
|
|
522
|
+
if (existsSync(link)) return;
|
|
523
|
+
if (!cachedCoreDir) cachedCoreDir = locateJourneyCoreDir();
|
|
524
|
+
await mkdir3(dirname2(link), { recursive: true });
|
|
525
|
+
const type = process.platform === "win32" ? "junction" : "dir";
|
|
526
|
+
await symlink(cachedCoreDir, link, type);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/commands/run.ts
|
|
530
|
+
var insecureAgent;
|
|
531
|
+
async function enableInsecureTls() {
|
|
532
|
+
if (!insecureAgent) {
|
|
533
|
+
const { Agent, setGlobalDispatcher } = await import("undici");
|
|
534
|
+
insecureAgent = new Agent({ connect: { rejectUnauthorized: false } });
|
|
535
|
+
setGlobalDispatcher(insecureAgent);
|
|
536
|
+
console.error("journey: WARNING \u2014 TLS verification disabled (--insecure)");
|
|
537
|
+
}
|
|
538
|
+
return insecureAgent;
|
|
539
|
+
}
|
|
540
|
+
async function runCommand(opts) {
|
|
541
|
+
const loaded = await loadConfig4(opts.projectDir);
|
|
542
|
+
const paths = resolveConfigPaths4(loaded);
|
|
543
|
+
clearActiveEnvironment2();
|
|
544
|
+
const envName = opts.env ?? loaded.config.defaultEnvironment;
|
|
545
|
+
if (envName) {
|
|
546
|
+
const values = await loadEnvironment2(paths.environmentsDir, envName);
|
|
547
|
+
setActiveEnvironment2(envName, values);
|
|
548
|
+
}
|
|
549
|
+
const files = opts.all ? await discoverJourneyFiles(paths.journeysDir) : (opts.files ?? []).map((f) => isAbsolute4(f) ? f : resolve4(process.cwd(), f));
|
|
550
|
+
if (files.length === 0) {
|
|
551
|
+
throw new Error("No journey files to run.");
|
|
552
|
+
}
|
|
553
|
+
await ensureProjectCoreLink(opts.projectDir);
|
|
554
|
+
await importJourneyFiles(files);
|
|
555
|
+
const ctx = {};
|
|
556
|
+
const baseUrl = resolveBaseUrl(loaded.config);
|
|
557
|
+
if (baseUrl) ctx.baseUrl = baseUrl;
|
|
558
|
+
const logger = opts.debug ? createConsoleLogger() : loggerFromEnv();
|
|
559
|
+
if (logger) ctx.logger = logger;
|
|
560
|
+
if (opts.insecure || loaded.config.tlsRejectUnauthorized === false) {
|
|
561
|
+
ctx.dispatcher = await enableInsecureTls();
|
|
562
|
+
}
|
|
563
|
+
const subCache = createSubJourneyCache(opts.cache ?? "process", {
|
|
564
|
+
diskDir: join5(opts.projectDir, ".journey", "cache", "sub-journey")
|
|
565
|
+
});
|
|
566
|
+
if (subCache) ctx.subJourneyCache = subCache;
|
|
567
|
+
if (opts.cacheTtlMs !== void 0) ctx.subJourneyCacheTtlMs = opts.cacheTtlMs;
|
|
568
|
+
const results = await runAllRegistered(ctx);
|
|
569
|
+
printResults(results);
|
|
570
|
+
const cacheDir = join5(opts.projectDir, ".journey", "cache");
|
|
571
|
+
await writeRun(cacheDir, results);
|
|
572
|
+
await pruneRuns(cacheDir, loaded.config.runHistoryKeepCount);
|
|
573
|
+
if (!opts.watch) return overallOk(results) ? 0 : 1;
|
|
574
|
+
const DEBOUNCE_MS = 300;
|
|
575
|
+
let timer;
|
|
576
|
+
let running = false;
|
|
577
|
+
const abortCtrl = new AbortController();
|
|
578
|
+
const rerun = async () => {
|
|
579
|
+
if (running) return;
|
|
580
|
+
running = true;
|
|
581
|
+
process.stdout.write("\x1Bc");
|
|
582
|
+
try {
|
|
583
|
+
await runCommand({ ...opts, watch: false });
|
|
584
|
+
} catch (err) {
|
|
585
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
586
|
+
} finally {
|
|
587
|
+
running = false;
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
const schedule = () => {
|
|
591
|
+
if (timer) clearTimeout(timer);
|
|
592
|
+
timer = setTimeout(() => void rerun(), DEBOUNCE_MS);
|
|
593
|
+
};
|
|
594
|
+
const dirs = /* @__PURE__ */ new Set([paths.journeysDir]);
|
|
595
|
+
for (const f of files) dirs.add(dirname3(f));
|
|
596
|
+
const watchers = [];
|
|
597
|
+
for (const dir of dirs) {
|
|
598
|
+
try {
|
|
599
|
+
watchers.push(watch(dir, { recursive: true, signal: abortCtrl.signal }));
|
|
600
|
+
} catch {
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
console.log(`
|
|
604
|
+
Watching for changes in ${[...dirs].join(", ")}\u2026`);
|
|
605
|
+
console.log("Press Ctrl+C to stop.\n");
|
|
606
|
+
const consume = async (watcher) => {
|
|
607
|
+
try {
|
|
608
|
+
for await (const event of watcher) {
|
|
609
|
+
const e = event;
|
|
610
|
+
if (e.filename && (e.filename.endsWith(".ts") || e.filename.endsWith(".json"))) {
|
|
611
|
+
schedule();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} catch (err) {
|
|
615
|
+
if (err.name === "AbortError") return;
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
const exitPromise = new Promise((res) => {
|
|
620
|
+
const handler = () => {
|
|
621
|
+
abortCtrl.abort();
|
|
622
|
+
if (timer) clearTimeout(timer);
|
|
623
|
+
res();
|
|
624
|
+
};
|
|
625
|
+
process.once("SIGINT", handler);
|
|
626
|
+
process.once("SIGTERM", handler);
|
|
627
|
+
});
|
|
628
|
+
await Promise.race([...watchers.map(consume), exitPromise]);
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/server/server.ts
|
|
633
|
+
import { createServer } from "http";
|
|
634
|
+
import { readFile as readFile2, readdir as readdir3, stat as stat5, unlink, writeFile as writeFile3 } from "fs/promises";
|
|
635
|
+
import { isAbsolute as isAbsolute7, join as join9 } from "path";
|
|
636
|
+
import {
|
|
637
|
+
JourneyConfigSchema,
|
|
638
|
+
createSubJourneyCache as createSubJourneyCache2,
|
|
639
|
+
listRuns,
|
|
640
|
+
loadConfig as loadConfig5,
|
|
641
|
+
listEnvironments as listEnvironments3,
|
|
642
|
+
readRun,
|
|
643
|
+
resolveConfigPaths as resolveConfigPaths5
|
|
644
|
+
} from "@usejourney/core";
|
|
645
|
+
import { collectOperations as collectOperations2, generate as generate3, loadSpec as loadSpec3, operationName } from "@usejourney/codegen";
|
|
646
|
+
|
|
647
|
+
// src/server/runner.ts
|
|
648
|
+
import { isAbsolute as isAbsolute5, join as join6, resolve as resolve5 } from "path";
|
|
649
|
+
import {
|
|
650
|
+
clearActiveEnvironment as clearActiveEnvironment3,
|
|
651
|
+
createConsoleLogger as createConsoleLogger2,
|
|
652
|
+
loadEnvironment as loadEnvironment3,
|
|
653
|
+
loggerFromEnv as loggerFromEnv2,
|
|
654
|
+
pruneRuns as pruneRuns2,
|
|
655
|
+
resolveBaseUrl as resolveBaseUrl2,
|
|
656
|
+
runAllRegistered as runAllRegistered2,
|
|
657
|
+
setActiveEnvironment as setActiveEnvironment3,
|
|
658
|
+
writeRun as writeRun2
|
|
659
|
+
} from "@usejourney/core";
|
|
660
|
+
|
|
661
|
+
// src/server/consolePatch.ts
|
|
662
|
+
import { inspect } from "util";
|
|
663
|
+
function patchConsole(logger) {
|
|
664
|
+
if (!logger.onLog) return () => {
|
|
665
|
+
};
|
|
666
|
+
const orig = {
|
|
667
|
+
log: console.log,
|
|
668
|
+
info: console.info,
|
|
669
|
+
warn: console.warn,
|
|
670
|
+
error: console.error
|
|
671
|
+
};
|
|
672
|
+
const forward = (level, original, args) => {
|
|
673
|
+
try {
|
|
674
|
+
logger.onLog?.({ level, text: formatArgs(args) });
|
|
675
|
+
} catch {
|
|
676
|
+
}
|
|
677
|
+
original.apply(console, args);
|
|
678
|
+
};
|
|
679
|
+
console.log = (...args) => forward("info", orig.log, args);
|
|
680
|
+
console.info = (...args) => forward("info", orig.info, args);
|
|
681
|
+
console.warn = (...args) => forward("warn", orig.warn, args);
|
|
682
|
+
console.error = (...args) => forward("error", orig.error, args);
|
|
683
|
+
return () => {
|
|
684
|
+
console.log = orig.log;
|
|
685
|
+
console.info = orig.info;
|
|
686
|
+
console.warn = orig.warn;
|
|
687
|
+
console.error = orig.error;
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function formatArgs(args) {
|
|
691
|
+
return args.map((a) => {
|
|
692
|
+
if (typeof a === "string") return a;
|
|
693
|
+
return inspect(a, { depth: 4, colors: false, compact: true });
|
|
694
|
+
}).join(" ");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/server/runner.ts
|
|
698
|
+
async function runJourneyFile(opts) {
|
|
699
|
+
const abs = isAbsolute5(opts.file) ? opts.file : opts.file.includes("/") || opts.file.includes("\\") ? resolve5(process.cwd(), opts.file) : join6(opts.journeysDir, opts.file);
|
|
700
|
+
let dispatcher;
|
|
701
|
+
if (opts.loaded.config.tlsRejectUnauthorized === false) {
|
|
702
|
+
dispatcher = await enableInsecureTls();
|
|
703
|
+
}
|
|
704
|
+
clearActiveEnvironment3();
|
|
705
|
+
const envName = opts.env ?? opts.loaded.config.defaultEnvironment;
|
|
706
|
+
if (envName) {
|
|
707
|
+
const values = await loadEnvironment3(opts.environmentsDir, envName);
|
|
708
|
+
setActiveEnvironment3(envName, values);
|
|
709
|
+
}
|
|
710
|
+
await ensureProjectCoreLink(opts.loaded.projectDir);
|
|
711
|
+
await importJourneyFiles([abs]);
|
|
712
|
+
const ctx = {};
|
|
713
|
+
const baseUrl = resolveBaseUrl2(opts.loaded.config);
|
|
714
|
+
if (baseUrl) ctx.baseUrl = baseUrl;
|
|
715
|
+
const logger = opts.logger ?? (opts.debug ? createConsoleLogger2() : loggerFromEnv2());
|
|
716
|
+
if (logger) ctx.logger = logger;
|
|
717
|
+
if (dispatcher !== void 0) ctx.dispatcher = dispatcher;
|
|
718
|
+
if (opts.signal !== void 0) ctx.signal = opts.signal;
|
|
719
|
+
if (opts.subJourneyCache !== void 0) ctx.subJourneyCache = opts.subJourneyCache;
|
|
720
|
+
if (opts.subJourneyCacheTtlMs !== void 0) {
|
|
721
|
+
ctx.subJourneyCacheTtlMs = opts.subJourneyCacheTtlMs;
|
|
722
|
+
}
|
|
723
|
+
const unpatchConsole = logger ? patchConsole(logger) : () => {
|
|
724
|
+
};
|
|
725
|
+
let results;
|
|
726
|
+
try {
|
|
727
|
+
results = await runAllRegistered2(ctx, {
|
|
728
|
+
...opts.runId !== void 0 ? { runId: opts.runId } : {},
|
|
729
|
+
...opts.upToStepIdx !== void 0 ? { upToStepIdx: opts.upToStepIdx } : {}
|
|
730
|
+
});
|
|
731
|
+
} finally {
|
|
732
|
+
unpatchConsole();
|
|
733
|
+
}
|
|
734
|
+
const cacheDir = join6(opts.loaded.projectDir, ".journey", "cache");
|
|
735
|
+
await writeRun2(cacheDir, results);
|
|
736
|
+
await pruneRuns2(cacheDir, opts.loaded.config.runHistoryKeepCount);
|
|
737
|
+
return results;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/server/planner.ts
|
|
741
|
+
import { isAbsolute as isAbsolute6, join as join7, resolve as resolve6 } from "path";
|
|
742
|
+
import {
|
|
743
|
+
clearActiveEnvironment as clearActiveEnvironment4,
|
|
744
|
+
loadEnvironment as loadEnvironment4,
|
|
745
|
+
planJourney,
|
|
746
|
+
setActiveEnvironment as setActiveEnvironment4
|
|
747
|
+
} from "@usejourney/core";
|
|
748
|
+
async function planJourneyFile(opts) {
|
|
749
|
+
const abs = isAbsolute6(opts.file) ? opts.file : opts.file.includes("/") || opts.file.includes("\\") ? resolve6(process.cwd(), opts.file) : join7(opts.journeysDir, opts.file);
|
|
750
|
+
clearActiveEnvironment4();
|
|
751
|
+
const envName = opts.env ?? opts.loaded.config.defaultEnvironment;
|
|
752
|
+
if (envName) {
|
|
753
|
+
const values = await loadEnvironment4(opts.environmentsDir, envName);
|
|
754
|
+
setActiveEnvironment4(envName, values);
|
|
755
|
+
}
|
|
756
|
+
await ensureProjectCoreLink(opts.loaded.projectDir);
|
|
757
|
+
const defs = await loadJourneyDefs(abs);
|
|
758
|
+
const journeys = [];
|
|
759
|
+
for (const def of defs) {
|
|
760
|
+
journeys.push({ name: def.name, steps: await planJourney(def) });
|
|
761
|
+
}
|
|
762
|
+
return journeys;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/server/specDrift.ts
|
|
766
|
+
import { readFile, stat as stat4 } from "fs/promises";
|
|
767
|
+
import { join as join8 } from "path";
|
|
768
|
+
import { collectOperations, loadSpec as loadSpec2 } from "@usejourney/codegen";
|
|
769
|
+
function parseGeneratedEndpoints(source) {
|
|
770
|
+
const out = [];
|
|
771
|
+
const re = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*\{\s*method:\s*"([A-Z]+)"\s*,\s*path:\s*"([^"]+)"\s*,\s*operationId:\s*"([^"]+)"\s*\}/gm;
|
|
772
|
+
let m;
|
|
773
|
+
while (m = re.exec(source)) {
|
|
774
|
+
const [, , method, path, operationId] = m;
|
|
775
|
+
out.push({ method, path, operationId });
|
|
776
|
+
}
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
779
|
+
async function computeSpecDrift(specPath, generatedDir) {
|
|
780
|
+
const [spec, generated] = await Promise.all([readSpecOps(specPath), readGenerated(generatedDir)]);
|
|
781
|
+
if (!spec || !generated) {
|
|
782
|
+
return {
|
|
783
|
+
added: [],
|
|
784
|
+
removed: [],
|
|
785
|
+
hasGenerated: generated !== void 0,
|
|
786
|
+
hasSpec: spec !== void 0,
|
|
787
|
+
count: 0
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const specKey = /* @__PURE__ */ new Map();
|
|
791
|
+
for (const op of spec) {
|
|
792
|
+
specKey.set(`${op.method.toUpperCase()} ${op.path}`, {
|
|
793
|
+
method: op.method.toUpperCase(),
|
|
794
|
+
path: op.path,
|
|
795
|
+
operationId: op.operationId
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
const genKey = /* @__PURE__ */ new Map();
|
|
799
|
+
for (const op of generated) {
|
|
800
|
+
genKey.set(`${op.method} ${op.path}`, op);
|
|
801
|
+
}
|
|
802
|
+
const added = [];
|
|
803
|
+
for (const [k, v] of specKey) {
|
|
804
|
+
if (!genKey.has(k)) added.push(v);
|
|
805
|
+
}
|
|
806
|
+
const removed = [];
|
|
807
|
+
for (const [k, v] of genKey) {
|
|
808
|
+
if (!specKey.has(k)) removed.push(v);
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
added,
|
|
812
|
+
removed,
|
|
813
|
+
hasGenerated: true,
|
|
814
|
+
hasSpec: true,
|
|
815
|
+
count: added.length + removed.length
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
async function readSpecOps(specPath) {
|
|
819
|
+
try {
|
|
820
|
+
await stat4(specPath);
|
|
821
|
+
} catch {
|
|
822
|
+
return void 0;
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const doc = await loadSpec2(specPath);
|
|
826
|
+
return collectOperations(doc);
|
|
827
|
+
} catch {
|
|
828
|
+
return void 0;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async function readGenerated(generatedDir) {
|
|
832
|
+
try {
|
|
833
|
+
const source = await readFile(join8(generatedDir, "endpoints.ts"), "utf8");
|
|
834
|
+
return parseGeneratedEndpoints(source);
|
|
835
|
+
} catch {
|
|
836
|
+
return void 0;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/server/runBroadcaster.ts
|
|
841
|
+
var RunBroadcaster = class {
|
|
842
|
+
runId;
|
|
843
|
+
events = [];
|
|
844
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
845
|
+
currentStepIdx = -1;
|
|
846
|
+
nextRequestSeq = 0;
|
|
847
|
+
// Tags each in-flight RequestLog with its monotonic sequence so onResponse /
|
|
848
|
+
// onError emit the matching `requestIdx`. Per-step uniqueness isn't enough —
|
|
849
|
+
// helpers (auth bootstrap, fan-out) make multiple requests inside one step.
|
|
850
|
+
requestSeqs = /* @__PURE__ */ new WeakMap();
|
|
851
|
+
completed = false;
|
|
852
|
+
completedAt;
|
|
853
|
+
constructor(runId) {
|
|
854
|
+
this.runId = runId;
|
|
855
|
+
}
|
|
856
|
+
get isCompleted() {
|
|
857
|
+
return this.completed;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Core-level logger that feeds everything into the event buffer. `stepIdx`
|
|
861
|
+
* is tracked locally from `onStepStart` and attached to intervening
|
|
862
|
+
* request/response/error events so SSE consumers don't need to correlate
|
|
863
|
+
* by timing.
|
|
864
|
+
*/
|
|
865
|
+
toLogger() {
|
|
866
|
+
return {
|
|
867
|
+
onRunStart: (e) => {
|
|
868
|
+
this.emit({ kind: "run:start", runId: e.runId, journeyNames: e.journeyNames });
|
|
869
|
+
},
|
|
870
|
+
onPlanned: (e) => {
|
|
871
|
+
this.emit({
|
|
872
|
+
kind: "step:planned",
|
|
873
|
+
runId: e.runId,
|
|
874
|
+
journeyIdx: e.journeyIdx,
|
|
875
|
+
journeyName: e.journeyName,
|
|
876
|
+
stepIdxOffset: e.stepIdxOffset,
|
|
877
|
+
steps: e.steps
|
|
878
|
+
});
|
|
879
|
+
},
|
|
880
|
+
onStepStart: (e) => {
|
|
881
|
+
this.currentStepIdx = e.stepIdx;
|
|
882
|
+
this.emit({
|
|
883
|
+
kind: "step:start",
|
|
884
|
+
runId: e.runId,
|
|
885
|
+
journeyIdx: e.journeyIdx,
|
|
886
|
+
journeyName: e.journeyName,
|
|
887
|
+
stepIdx: e.stepIdx,
|
|
888
|
+
name: e.name
|
|
889
|
+
});
|
|
890
|
+
},
|
|
891
|
+
onRequest: (req) => {
|
|
892
|
+
const requestIdx = this.nextRequestSeq++;
|
|
893
|
+
this.requestSeqs.set(req, requestIdx);
|
|
894
|
+
this.emit({
|
|
895
|
+
kind: "request",
|
|
896
|
+
runId: this.runId,
|
|
897
|
+
stepIdx: this.currentStepIdx,
|
|
898
|
+
requestIdx,
|
|
899
|
+
method: req.method,
|
|
900
|
+
url: req.url,
|
|
901
|
+
headers: req.headers,
|
|
902
|
+
...req.body !== void 0 ? { body: req.body } : {}
|
|
903
|
+
});
|
|
904
|
+
},
|
|
905
|
+
onResponse: (req, res) => {
|
|
906
|
+
this.emit({
|
|
907
|
+
kind: "response",
|
|
908
|
+
runId: this.runId,
|
|
909
|
+
stepIdx: this.currentStepIdx,
|
|
910
|
+
requestIdx: this.requestSeqs.get(req) ?? -1,
|
|
911
|
+
status: res.status,
|
|
912
|
+
headers: res.headers,
|
|
913
|
+
body: res.body,
|
|
914
|
+
durationMs: res.durationMs
|
|
915
|
+
});
|
|
916
|
+
},
|
|
917
|
+
onError: (req, err, durationMs) => {
|
|
918
|
+
this.emit({
|
|
919
|
+
kind: "error",
|
|
920
|
+
runId: this.runId,
|
|
921
|
+
stepIdx: this.currentStepIdx,
|
|
922
|
+
requestIdx: this.requestSeqs.get(req) ?? -1,
|
|
923
|
+
message: err instanceof Error ? err.message : String(err),
|
|
924
|
+
durationMs
|
|
925
|
+
});
|
|
926
|
+
},
|
|
927
|
+
onLog: (e) => {
|
|
928
|
+
this.emit({
|
|
929
|
+
kind: "log",
|
|
930
|
+
runId: this.runId,
|
|
931
|
+
stepIdx: this.currentStepIdx,
|
|
932
|
+
level: e.level,
|
|
933
|
+
text: e.text
|
|
934
|
+
});
|
|
935
|
+
},
|
|
936
|
+
onStepEnd: (e) => {
|
|
937
|
+
this.emit({
|
|
938
|
+
kind: "step:end",
|
|
939
|
+
runId: e.runId,
|
|
940
|
+
journeyIdx: e.journeyIdx,
|
|
941
|
+
stepIdx: e.stepIdx,
|
|
942
|
+
ok: e.ok,
|
|
943
|
+
durationMs: e.durationMs,
|
|
944
|
+
...e.error !== void 0 ? { error: e.error } : {}
|
|
945
|
+
});
|
|
946
|
+
},
|
|
947
|
+
onGroupStart: (e) => {
|
|
948
|
+
this.emit({
|
|
949
|
+
kind: "group:start",
|
|
950
|
+
runId: e.runId,
|
|
951
|
+
journeyIdx: e.journeyIdx,
|
|
952
|
+
name: e.name,
|
|
953
|
+
childJourneyName: e.childJourneyName,
|
|
954
|
+
stepIdx: e.stepIdx,
|
|
955
|
+
firstChildStepIdx: e.firstChildStepIdx,
|
|
956
|
+
cacheStatus: e.cacheStatus,
|
|
957
|
+
...e.resolvedKey !== void 0 ? { resolvedKey: e.resolvedKey } : {}
|
|
958
|
+
});
|
|
959
|
+
},
|
|
960
|
+
onGroupEnd: (e) => {
|
|
961
|
+
this.emit({
|
|
962
|
+
kind: "group:end",
|
|
963
|
+
runId: e.runId,
|
|
964
|
+
journeyIdx: e.journeyIdx,
|
|
965
|
+
name: e.name,
|
|
966
|
+
childJourneyName: e.childJourneyName,
|
|
967
|
+
stepIdx: e.stepIdx,
|
|
968
|
+
lastChildStepIdx: e.lastChildStepIdx,
|
|
969
|
+
ok: e.ok,
|
|
970
|
+
durationMs: e.durationMs,
|
|
971
|
+
...e.error !== void 0 ? { error: e.error } : {}
|
|
972
|
+
});
|
|
973
|
+
},
|
|
974
|
+
onRunEnd: (e) => {
|
|
975
|
+
this.emit({
|
|
976
|
+
kind: "run:end",
|
|
977
|
+
runId: e.runId,
|
|
978
|
+
ok: e.ok,
|
|
979
|
+
durationMs: e.durationMs,
|
|
980
|
+
results: e.results
|
|
981
|
+
});
|
|
982
|
+
this.complete();
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Emits a fatal error (e.g. a failure to load the journey file, before any
|
|
988
|
+
* step events fire). Translates to a `run:end` ok=false so clients stop
|
|
989
|
+
* waiting.
|
|
990
|
+
*/
|
|
991
|
+
fail(message) {
|
|
992
|
+
this.emit({
|
|
993
|
+
kind: "run:end",
|
|
994
|
+
runId: this.runId,
|
|
995
|
+
ok: false,
|
|
996
|
+
durationMs: 0,
|
|
997
|
+
results: [{ name: "error", ok: false }]
|
|
998
|
+
});
|
|
999
|
+
this.emit({
|
|
1000
|
+
kind: "error",
|
|
1001
|
+
runId: this.runId,
|
|
1002
|
+
stepIdx: -1,
|
|
1003
|
+
requestIdx: -1,
|
|
1004
|
+
message,
|
|
1005
|
+
durationMs: 0
|
|
1006
|
+
});
|
|
1007
|
+
this.complete();
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Wires a ServerResponse as a long-lived SSE subscriber. Replays the buffered
|
|
1011
|
+
* events, then holds the connection open for new ones. If the run is already
|
|
1012
|
+
* complete, the connection is closed immediately after replay so the client
|
|
1013
|
+
* moves on without blocking.
|
|
1014
|
+
*/
|
|
1015
|
+
subscribe(res) {
|
|
1016
|
+
res.writeHead(200, {
|
|
1017
|
+
"content-type": "text/event-stream",
|
|
1018
|
+
"cache-control": "no-cache",
|
|
1019
|
+
connection: "keep-alive",
|
|
1020
|
+
"x-accel-buffering": "no",
|
|
1021
|
+
"access-control-allow-origin": "*"
|
|
1022
|
+
});
|
|
1023
|
+
for (const ev of this.events) {
|
|
1024
|
+
writeEvent(res, ev);
|
|
1025
|
+
}
|
|
1026
|
+
if (this.completed) {
|
|
1027
|
+
res.end();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const heartbeat = setInterval(() => {
|
|
1031
|
+
try {
|
|
1032
|
+
res.write(": keep-alive\n\n");
|
|
1033
|
+
} catch {
|
|
1034
|
+
}
|
|
1035
|
+
}, 15e3);
|
|
1036
|
+
const sub = { res, heartbeat };
|
|
1037
|
+
this.subscribers.add(sub);
|
|
1038
|
+
res.on("close", () => {
|
|
1039
|
+
clearInterval(heartbeat);
|
|
1040
|
+
this.subscribers.delete(sub);
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
emit(event) {
|
|
1044
|
+
this.events.push(event);
|
|
1045
|
+
for (const sub of this.subscribers) {
|
|
1046
|
+
try {
|
|
1047
|
+
writeEvent(sub.res, event);
|
|
1048
|
+
} catch {
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
complete() {
|
|
1053
|
+
if (this.completed) return;
|
|
1054
|
+
this.completed = true;
|
|
1055
|
+
this.completedAt = Date.now();
|
|
1056
|
+
abortControllers.delete(this.runId);
|
|
1057
|
+
for (const sub of this.subscribers) {
|
|
1058
|
+
try {
|
|
1059
|
+
clearInterval(sub.heartbeat);
|
|
1060
|
+
sub.res.end();
|
|
1061
|
+
} catch {
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
this.subscribers.clear();
|
|
1065
|
+
}
|
|
1066
|
+
/** True when this broadcaster is ready to be evicted from the registry. */
|
|
1067
|
+
canEvict(graceMs) {
|
|
1068
|
+
if (!this.completed) return false;
|
|
1069
|
+
if (this.completedAt === void 0) return true;
|
|
1070
|
+
return Date.now() - this.completedAt > graceMs;
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
function writeEvent(res, event) {
|
|
1074
|
+
res.write(`event: ${event.kind}
|
|
1075
|
+
`);
|
|
1076
|
+
res.write(`data: ${JSON.stringify(event)}
|
|
1077
|
+
|
|
1078
|
+
`);
|
|
1079
|
+
}
|
|
1080
|
+
var EVICTION_GRACE_MS = 5 * 60 * 1e3;
|
|
1081
|
+
var registry = /* @__PURE__ */ new Map();
|
|
1082
|
+
var abortControllers = /* @__PURE__ */ new Map();
|
|
1083
|
+
function newRunId() {
|
|
1084
|
+
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
1085
|
+
return globalThis.crypto.randomUUID();
|
|
1086
|
+
}
|
|
1087
|
+
return `run_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1088
|
+
}
|
|
1089
|
+
function registerBroadcaster(runId) {
|
|
1090
|
+
evictStale();
|
|
1091
|
+
const b = new RunBroadcaster(runId);
|
|
1092
|
+
registry.set(runId, b);
|
|
1093
|
+
return b;
|
|
1094
|
+
}
|
|
1095
|
+
function getBroadcaster(runId) {
|
|
1096
|
+
evictStale();
|
|
1097
|
+
return registry.get(runId);
|
|
1098
|
+
}
|
|
1099
|
+
function evictStale(graceMs = EVICTION_GRACE_MS) {
|
|
1100
|
+
for (const [id, b] of registry) {
|
|
1101
|
+
if (b.canEvict(graceMs)) {
|
|
1102
|
+
registry.delete(id);
|
|
1103
|
+
abortControllers.delete(id);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function registerAbortController(runId, controller) {
|
|
1108
|
+
abortControllers.set(runId, controller);
|
|
1109
|
+
}
|
|
1110
|
+
function clearAbortController(runId) {
|
|
1111
|
+
abortControllers.delete(runId);
|
|
1112
|
+
}
|
|
1113
|
+
function abortRun(runId) {
|
|
1114
|
+
const controller = abortControllers.get(runId);
|
|
1115
|
+
if (!controller) return false;
|
|
1116
|
+
controller.abort(new Error("run aborted by user"));
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// src/server/server.ts
|
|
1121
|
+
function makeCacheResolver(mode) {
|
|
1122
|
+
let processCache;
|
|
1123
|
+
let processInit = false;
|
|
1124
|
+
return (projectDir) => {
|
|
1125
|
+
const diskDir = join9(projectDir, ".journey", "cache", "sub-journey");
|
|
1126
|
+
if (mode === "process") {
|
|
1127
|
+
if (!processInit) {
|
|
1128
|
+
processCache = createSubJourneyCache2("process", { diskDir });
|
|
1129
|
+
processInit = true;
|
|
1130
|
+
}
|
|
1131
|
+
return processCache;
|
|
1132
|
+
}
|
|
1133
|
+
return createSubJourneyCache2(mode, { diskDir });
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
function send(res, status, body) {
|
|
1137
|
+
res.writeHead(status, { "content-type": "application/json", "access-control-allow-origin": "*" });
|
|
1138
|
+
res.end(JSON.stringify(body));
|
|
1139
|
+
}
|
|
1140
|
+
async function countEndpoints(generatedDir) {
|
|
1141
|
+
try {
|
|
1142
|
+
await stat5(generatedDir);
|
|
1143
|
+
} catch {
|
|
1144
|
+
return 0;
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
const { readFile: readFile3 } = await import("fs/promises");
|
|
1148
|
+
const { join: join10 } = await import("path");
|
|
1149
|
+
const content = await readFile3(join10(generatedDir, "endpoints.ts"), "utf8");
|
|
1150
|
+
const matches3 = content.match(/^\s{2}[a-zA-Z_$][a-zA-Z0-9_$]*:\s*\{/gm);
|
|
1151
|
+
return matches3 ? matches3.length : 0;
|
|
1152
|
+
} catch {
|
|
1153
|
+
return 0;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async function countJourneys(journeysDir) {
|
|
1157
|
+
try {
|
|
1158
|
+
const { readdir: readdir4 } = await import("fs/promises");
|
|
1159
|
+
const entries = await readdir4(journeysDir);
|
|
1160
|
+
return entries.filter((e) => e.endsWith(".journey.ts")).length;
|
|
1161
|
+
} catch {
|
|
1162
|
+
return 0;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
async function buildProjectSummary(loaded) {
|
|
1166
|
+
const paths = resolveConfigPaths5(loaded);
|
|
1167
|
+
const [endpoints, journeys, envs] = await Promise.all([
|
|
1168
|
+
countEndpoints(paths.generatedDir),
|
|
1169
|
+
countJourneys(paths.journeysDir),
|
|
1170
|
+
listEnvironments3(paths.environmentsDir)
|
|
1171
|
+
]);
|
|
1172
|
+
return {
|
|
1173
|
+
projectDir: loaded.projectDir,
|
|
1174
|
+
config: {
|
|
1175
|
+
...loaded.config.name !== void 0 ? { name: loaded.config.name } : {},
|
|
1176
|
+
spec: loaded.config.spec,
|
|
1177
|
+
...loaded.config.baseUrl !== void 0 ? { baseUrl: loaded.config.baseUrl } : {},
|
|
1178
|
+
...loaded.config.defaultEnvironment !== void 0 ? { defaultEnvironment: loaded.config.defaultEnvironment } : {},
|
|
1179
|
+
tlsRejectUnauthorized: loaded.config.tlsRejectUnauthorized
|
|
1180
|
+
},
|
|
1181
|
+
counts: {
|
|
1182
|
+
endpoints,
|
|
1183
|
+
journeys,
|
|
1184
|
+
environments: envs.length
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
var PATCHABLE_CONFIG_KEYS = ["tlsRejectUnauthorized"];
|
|
1189
|
+
async function patchProjectConfig(projectDir, patch) {
|
|
1190
|
+
const configPath = join9(projectDir, "journey.config.json");
|
|
1191
|
+
const raw = await readFile2(configPath, "utf8");
|
|
1192
|
+
const current = JSON.parse(raw);
|
|
1193
|
+
for (const key of Object.keys(patch)) {
|
|
1194
|
+
if (!PATCHABLE_CONFIG_KEYS.includes(key)) {
|
|
1195
|
+
throw new Error(`config key "${key}" is not patchable via the API`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const merged = { ...current };
|
|
1199
|
+
for (const key of PATCHABLE_CONFIG_KEYS) {
|
|
1200
|
+
if (Object.prototype.hasOwnProperty.call(patch, key)) {
|
|
1201
|
+
merged[key] = patch[key];
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const result = JourneyConfigSchema.safeParse(merged);
|
|
1205
|
+
if (!result.success) {
|
|
1206
|
+
const issues = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
|
|
1207
|
+
throw new Error(`config patch failed validation: ${issues}`);
|
|
1208
|
+
}
|
|
1209
|
+
await writeFile3(configPath, `${JSON.stringify(merged, null, 2)}
|
|
1210
|
+
`, "utf8");
|
|
1211
|
+
return await buildProjectSummary(await loadConfig5(projectDir));
|
|
1212
|
+
}
|
|
1213
|
+
async function buildTree(root) {
|
|
1214
|
+
try {
|
|
1215
|
+
const entries = await readdir3(root, { withFileTypes: true });
|
|
1216
|
+
const out = [];
|
|
1217
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1218
|
+
if (entry.name.startsWith(".")) continue;
|
|
1219
|
+
if (entry.isDirectory()) {
|
|
1220
|
+
out.push({
|
|
1221
|
+
name: entry.name,
|
|
1222
|
+
type: "dir",
|
|
1223
|
+
children: await buildTree(join9(root, entry.name))
|
|
1224
|
+
});
|
|
1225
|
+
} else {
|
|
1226
|
+
out.push({ name: entry.name, type: "file" });
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return out;
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
if (err.code === "ENOENT") return [];
|
|
1232
|
+
throw err;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
async function buildProjectTree(loaded) {
|
|
1236
|
+
const paths = resolveConfigPaths5(loaded);
|
|
1237
|
+
const [journeys, environments, generated] = await Promise.all([
|
|
1238
|
+
buildTree(paths.journeysDir),
|
|
1239
|
+
buildTree(paths.environmentsDir),
|
|
1240
|
+
buildTree(paths.generatedDir)
|
|
1241
|
+
]);
|
|
1242
|
+
return {
|
|
1243
|
+
projectDir: loaded.projectDir,
|
|
1244
|
+
sections: [
|
|
1245
|
+
{ label: "journeys", dir: paths.journeysDir, children: journeys },
|
|
1246
|
+
{ label: "environments", dir: paths.environmentsDir, children: environments },
|
|
1247
|
+
{ label: "generated", dir: paths.generatedDir, children: generated }
|
|
1248
|
+
]
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
function normalizeParameters(params) {
|
|
1252
|
+
if (!Array.isArray(params)) return [];
|
|
1253
|
+
const out = [];
|
|
1254
|
+
for (const p of params) {
|
|
1255
|
+
if (!p || typeof p !== "object") continue;
|
|
1256
|
+
const loc = p.in;
|
|
1257
|
+
if (loc !== "query" && loc !== "path" && loc !== "header") continue;
|
|
1258
|
+
if (typeof p.name !== "string") continue;
|
|
1259
|
+
const entry = {
|
|
1260
|
+
name: p.name,
|
|
1261
|
+
in: loc,
|
|
1262
|
+
required: p.required === true || loc === "path"
|
|
1263
|
+
};
|
|
1264
|
+
if (typeof p.description === "string") entry.description = p.description;
|
|
1265
|
+
out.push(entry);
|
|
1266
|
+
}
|
|
1267
|
+
return out;
|
|
1268
|
+
}
|
|
1269
|
+
async function readEndpoints(specPath) {
|
|
1270
|
+
try {
|
|
1271
|
+
const doc = await loadSpec3(specPath);
|
|
1272
|
+
const ops = collectOperations2(doc);
|
|
1273
|
+
const paths = doc.paths ?? {};
|
|
1274
|
+
const out = [];
|
|
1275
|
+
for (const op of ops) {
|
|
1276
|
+
const pathItem = paths[op.path];
|
|
1277
|
+
const pathLevel = normalizeParameters(pathItem?.parameters);
|
|
1278
|
+
const opLevel = normalizeParameters(
|
|
1279
|
+
pathItem?.[op.method]?.parameters
|
|
1280
|
+
);
|
|
1281
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1282
|
+
for (const p of pathLevel) merged.set(`${p.in}:${p.name}`, p);
|
|
1283
|
+
for (const p of opLevel) merged.set(`${p.in}:${p.name}`, p);
|
|
1284
|
+
const rawOp = pathItem?.[op.method];
|
|
1285
|
+
const rawOperationId = typeof rawOp?.operationId === "string" ? rawOp.operationId : void 0;
|
|
1286
|
+
const entry = {
|
|
1287
|
+
name: operationName(op.method, op.path, rawOperationId),
|
|
1288
|
+
method: op.method.toUpperCase(),
|
|
1289
|
+
path: op.path,
|
|
1290
|
+
parameters: [...merged.values()]
|
|
1291
|
+
};
|
|
1292
|
+
if (rawOperationId) entry.operationId = rawOperationId;
|
|
1293
|
+
out.push(entry);
|
|
1294
|
+
}
|
|
1295
|
+
return out;
|
|
1296
|
+
} catch {
|
|
1297
|
+
return [];
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
async function readRequestBody(req) {
|
|
1301
|
+
const chunks = [];
|
|
1302
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1303
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1304
|
+
if (!raw) return void 0;
|
|
1305
|
+
try {
|
|
1306
|
+
return JSON.parse(raw);
|
|
1307
|
+
} catch {
|
|
1308
|
+
throw new Error("request body is not valid JSON");
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
async function proxyRequest(body) {
|
|
1312
|
+
if (!body.method || !body.url) throw new Error("method and url are required");
|
|
1313
|
+
const init = {
|
|
1314
|
+
method: body.method,
|
|
1315
|
+
...body.headers ? { headers: body.headers } : {}
|
|
1316
|
+
};
|
|
1317
|
+
if (body.body !== void 0) {
|
|
1318
|
+
init.body = typeof body.body === "string" ? body.body : JSON.stringify(body.body);
|
|
1319
|
+
}
|
|
1320
|
+
const started = Date.now();
|
|
1321
|
+
const res = await fetch(body.url, init);
|
|
1322
|
+
const respHeaders = {};
|
|
1323
|
+
res.headers.forEach((v, k) => {
|
|
1324
|
+
respHeaders[k] = v;
|
|
1325
|
+
});
|
|
1326
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
1327
|
+
const parsed = ct.includes("json") ? await res.json().catch(() => null) : await res.text();
|
|
1328
|
+
return {
|
|
1329
|
+
status: res.status,
|
|
1330
|
+
headers: respHeaders,
|
|
1331
|
+
body: parsed,
|
|
1332
|
+
durationMs: Date.now() - started
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
async function route(req, res, projectDir, debug, setProjectDir, cacheFor, cacheTtlMs) {
|
|
1336
|
+
if (req.method === "OPTIONS") {
|
|
1337
|
+
res.writeHead(204, {
|
|
1338
|
+
"access-control-allow-origin": "*",
|
|
1339
|
+
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
1340
|
+
"access-control-allow-headers": "content-type"
|
|
1341
|
+
});
|
|
1342
|
+
res.end();
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
try {
|
|
1346
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
1347
|
+
if (url.pathname === "/api/project" && req.method === "GET") {
|
|
1348
|
+
const loaded = await loadConfig5(projectDir);
|
|
1349
|
+
send(res, 200, await buildProjectSummary(loaded));
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (url.pathname === "/api/project/open" && req.method === "POST") {
|
|
1353
|
+
const body = await readRequestBody(req);
|
|
1354
|
+
const path = body?.path;
|
|
1355
|
+
if (typeof path !== "string" || path.length === 0 || !isAbsolute7(path)) {
|
|
1356
|
+
send(res, 400, { error: "body.path must be an absolute filesystem path" });
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
let loaded;
|
|
1360
|
+
try {
|
|
1361
|
+
loaded = await loadConfig5(path);
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1364
|
+
send(res, 400, { error: `Not a Journey project: ${message}` });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
setProjectDir(loaded.projectDir);
|
|
1368
|
+
send(res, 200, await buildProjectSummary(loaded));
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
if (url.pathname === "/api/project/config" && req.method === "PATCH") {
|
|
1372
|
+
const body = await readRequestBody(req);
|
|
1373
|
+
if (!body || typeof body !== "object") {
|
|
1374
|
+
send(res, 400, { error: "request body must be a JSON object" });
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
send(res, 200, await patchProjectConfig(projectDir, body));
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
if (url.pathname === "/api/tree" && req.method === "GET") {
|
|
1381
|
+
const loaded = await loadConfig5(projectDir);
|
|
1382
|
+
send(res, 200, await buildProjectTree(loaded));
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (url.pathname === "/api/endpoints" && req.method === "GET") {
|
|
1386
|
+
const loaded = await loadConfig5(projectDir);
|
|
1387
|
+
const { specPath } = resolveConfigPaths5(loaded);
|
|
1388
|
+
const endpoints = await readEndpoints(specPath);
|
|
1389
|
+
send(res, 200, { baseUrl: loaded.config.baseUrl, endpoints });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (url.pathname === "/api/request" && req.method === "POST") {
|
|
1393
|
+
const body = await readRequestBody(req);
|
|
1394
|
+
send(res, 200, await proxyRequest(body));
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const journeyFileMatch = url.pathname.match(/^\/api\/journeys\/([^/]+)$/);
|
|
1398
|
+
if (journeyFileMatch) {
|
|
1399
|
+
const file = decodeURIComponent(journeyFileMatch[1]);
|
|
1400
|
+
if (!/^[a-zA-Z0-9_.-]+\.journey\.ts$/.test(file)) {
|
|
1401
|
+
send(res, 400, { error: "invalid journey filename" });
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const loaded = await loadConfig5(projectDir);
|
|
1405
|
+
const { journeysDir } = resolveConfigPaths5(loaded);
|
|
1406
|
+
const filePath = join9(journeysDir, file);
|
|
1407
|
+
if (req.method === "GET") {
|
|
1408
|
+
try {
|
|
1409
|
+
const source = await readFile2(filePath, "utf8");
|
|
1410
|
+
send(res, 200, { file, source });
|
|
1411
|
+
} catch (err) {
|
|
1412
|
+
if (err.code === "ENOENT") {
|
|
1413
|
+
send(res, 404, { error: "not found" });
|
|
1414
|
+
} else throw err;
|
|
1415
|
+
}
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
if (req.method === "PUT") {
|
|
1419
|
+
const body = await readRequestBody(req) ?? {};
|
|
1420
|
+
if (typeof body.source !== "string") {
|
|
1421
|
+
send(res, 400, { error: "body.source must be a string" });
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
const { mkdir: mkdir4 } = await import("fs/promises");
|
|
1425
|
+
await mkdir4(journeysDir, { recursive: true });
|
|
1426
|
+
await writeFile3(filePath, body.source, "utf8");
|
|
1427
|
+
send(res, 200, { file, bytes: Buffer.byteLength(body.source, "utf8") });
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
if (req.method === "DELETE") {
|
|
1431
|
+
try {
|
|
1432
|
+
await unlink(filePath);
|
|
1433
|
+
send(res, 200, { file, deleted: true });
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
if (err.code === "ENOENT") {
|
|
1436
|
+
send(res, 404, { error: "not found" });
|
|
1437
|
+
} else throw err;
|
|
1438
|
+
}
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
if (url.pathname === "/api/journeys" && req.method === "GET") {
|
|
1443
|
+
const loaded = await loadConfig5(projectDir);
|
|
1444
|
+
const { journeysDir } = resolveConfigPaths5(loaded);
|
|
1445
|
+
const { readdir: readdir4 } = await import("fs/promises");
|
|
1446
|
+
let files = [];
|
|
1447
|
+
try {
|
|
1448
|
+
const entries = await readdir4(journeysDir);
|
|
1449
|
+
files = entries.filter((e) => e.endsWith(".journey.ts")).sort();
|
|
1450
|
+
} catch {
|
|
1451
|
+
files = [];
|
|
1452
|
+
}
|
|
1453
|
+
send(res, 200, { journeysDir, files });
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
if (url.pathname === "/api/environments" && req.method === "GET") {
|
|
1457
|
+
const loaded = await loadConfig5(projectDir);
|
|
1458
|
+
const { environmentsDir } = resolveConfigPaths5(loaded);
|
|
1459
|
+
const names = await listEnvironments3(environmentsDir);
|
|
1460
|
+
const out = [];
|
|
1461
|
+
for (const name of names) {
|
|
1462
|
+
try {
|
|
1463
|
+
const raw = await readFile2(join9(environmentsDir, `${name}.json`), "utf8");
|
|
1464
|
+
const values = JSON.parse(raw);
|
|
1465
|
+
const normalized = {};
|
|
1466
|
+
for (const [k, v] of Object.entries(values))
|
|
1467
|
+
normalized[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
1468
|
+
out.push({ name, values: normalized });
|
|
1469
|
+
} catch {
|
|
1470
|
+
out.push({ name, values: {} });
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
send(res, 200, { defaultEnvironment: loaded.config.defaultEnvironment, environments: out });
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const envMatch = url.pathname.match(/^\/api\/environments\/([^/]+)$/);
|
|
1477
|
+
if (envMatch) {
|
|
1478
|
+
const name = decodeURIComponent(envMatch[1]);
|
|
1479
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
|
1480
|
+
send(res, 400, { error: "invalid environment name" });
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
const loaded = await loadConfig5(projectDir);
|
|
1484
|
+
const { environmentsDir } = resolveConfigPaths5(loaded);
|
|
1485
|
+
const file = join9(environmentsDir, `${name}.json`);
|
|
1486
|
+
if (req.method === "PUT") {
|
|
1487
|
+
const body = await readRequestBody(req);
|
|
1488
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1489
|
+
send(res, 400, { error: "body must be a JSON object" });
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
await writeFile3(file, `${JSON.stringify(body, null, 2)}
|
|
1493
|
+
`, "utf8");
|
|
1494
|
+
send(res, 200, { name, values: body });
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (req.method === "DELETE") {
|
|
1498
|
+
try {
|
|
1499
|
+
await unlink(file);
|
|
1500
|
+
send(res, 200, { name, deleted: true });
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
if (err.code === "ENOENT") {
|
|
1503
|
+
send(res, 404, { error: "not found" });
|
|
1504
|
+
} else {
|
|
1505
|
+
throw err;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const journeyPlanMatch = url.pathname.match(/^\/api\/journeys\/([^/]+)\/plan$/);
|
|
1512
|
+
if (journeyPlanMatch && req.method === "GET") {
|
|
1513
|
+
const file = decodeURIComponent(journeyPlanMatch[1]);
|
|
1514
|
+
const env = url.searchParams.get("env") ?? void 0;
|
|
1515
|
+
const loaded = await loadConfig5(projectDir);
|
|
1516
|
+
const { journeysDir, environmentsDir } = resolveConfigPaths5(loaded);
|
|
1517
|
+
try {
|
|
1518
|
+
const journeys = await planJourneyFile({
|
|
1519
|
+
loaded,
|
|
1520
|
+
journeysDir,
|
|
1521
|
+
environmentsDir,
|
|
1522
|
+
file,
|
|
1523
|
+
...env !== void 0 ? { env } : {}
|
|
1524
|
+
});
|
|
1525
|
+
send(res, 200, { journeys });
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
send(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1528
|
+
}
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
const runMatch = url.pathname.match(/^\/api\/journeys\/([^/]+)\/run$/);
|
|
1532
|
+
if (runMatch && req.method === "POST") {
|
|
1533
|
+
const file = decodeURIComponent(runMatch[1]);
|
|
1534
|
+
const body = await readRequestBody(req) ?? {};
|
|
1535
|
+
const loaded = await loadConfig5(projectDir);
|
|
1536
|
+
const { journeysDir, environmentsDir } = resolveConfigPaths5(loaded);
|
|
1537
|
+
const runId = newRunId();
|
|
1538
|
+
const broadcaster = registerBroadcaster(runId);
|
|
1539
|
+
const abortController = new AbortController();
|
|
1540
|
+
registerAbortController(runId, abortController);
|
|
1541
|
+
const subJourneyCache = cacheFor(projectDir);
|
|
1542
|
+
const runPromise = runJourneyFile({
|
|
1543
|
+
loaded,
|
|
1544
|
+
journeysDir,
|
|
1545
|
+
environmentsDir,
|
|
1546
|
+
file,
|
|
1547
|
+
runId,
|
|
1548
|
+
logger: broadcaster.toLogger(),
|
|
1549
|
+
signal: abortController.signal,
|
|
1550
|
+
...body.env !== void 0 ? { env: body.env } : {},
|
|
1551
|
+
...typeof body.upToStepIdx === "number" ? { upToStepIdx: body.upToStepIdx } : {},
|
|
1552
|
+
...debug ? { debug: true } : {},
|
|
1553
|
+
...subJourneyCache !== void 0 ? { subJourneyCache } : {},
|
|
1554
|
+
...cacheTtlMs !== void 0 ? { subJourneyCacheTtlMs: cacheTtlMs } : {}
|
|
1555
|
+
});
|
|
1556
|
+
if (body.stream) {
|
|
1557
|
+
runPromise.catch((err) => {
|
|
1558
|
+
broadcaster.fail(err instanceof Error ? err.message : String(err));
|
|
1559
|
+
}).finally(() => clearAbortController(runId));
|
|
1560
|
+
send(res, 202, { runId });
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
try {
|
|
1564
|
+
const results = await runPromise;
|
|
1565
|
+
send(res, 200, { runId, results });
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
broadcaster.fail(err instanceof Error ? err.message : String(err));
|
|
1568
|
+
send(res, 500, { runId, error: err instanceof Error ? err.message : String(err) });
|
|
1569
|
+
} finally {
|
|
1570
|
+
clearAbortController(runId);
|
|
1571
|
+
}
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const runAbortMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/abort$/);
|
|
1575
|
+
if (runAbortMatch && req.method === "POST") {
|
|
1576
|
+
const id = decodeURIComponent(runAbortMatch[1]);
|
|
1577
|
+
if (abortRun(id)) {
|
|
1578
|
+
send(res, 202, { runId: id, aborted: true });
|
|
1579
|
+
} else {
|
|
1580
|
+
send(res, 404, { error: "unknown or completed run" });
|
|
1581
|
+
}
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
if (url.pathname === "/api/runs" && req.method === "GET") {
|
|
1585
|
+
const cacheDir = join9(projectDir, ".journey", "cache");
|
|
1586
|
+
send(res, 200, await listRuns(cacheDir));
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const runEventsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/events$/);
|
|
1590
|
+
if (runEventsMatch && req.method === "GET") {
|
|
1591
|
+
const id = decodeURIComponent(runEventsMatch[1]);
|
|
1592
|
+
const broadcaster = getBroadcaster(id);
|
|
1593
|
+
if (!broadcaster) {
|
|
1594
|
+
send(res, 404, { error: "unknown run (already evicted or never started)" });
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
broadcaster.subscribe(res);
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const runIdMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
1601
|
+
if (runIdMatch && req.method === "GET") {
|
|
1602
|
+
const id = decodeURIComponent(runIdMatch[1]);
|
|
1603
|
+
const cacheDir = join9(projectDir, ".journey", "cache");
|
|
1604
|
+
const record = await readRun(cacheDir, id);
|
|
1605
|
+
if (!record) {
|
|
1606
|
+
send(res, 404, { error: "run not found" });
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
send(res, 200, record);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (url.pathname === "/api/spec/drift" && req.method === "GET") {
|
|
1613
|
+
const loaded = await loadConfig5(projectDir);
|
|
1614
|
+
const { specPath, generatedDir } = resolveConfigPaths5(loaded);
|
|
1615
|
+
send(res, 200, await computeSpecDrift(specPath, generatedDir));
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (url.pathname === "/api/generate" && req.method === "POST") {
|
|
1619
|
+
const loaded = await loadConfig5(projectDir);
|
|
1620
|
+
const { specPath, generatedDir } = resolveConfigPaths5(loaded);
|
|
1621
|
+
try {
|
|
1622
|
+
const result = await generate3({ specPath, outDir: generatedDir });
|
|
1623
|
+
send(res, 200, {
|
|
1624
|
+
operationCount: result.operationCount,
|
|
1625
|
+
endpointsPath: result.endpointsPath,
|
|
1626
|
+
modelsPath: result.modelsPath
|
|
1627
|
+
});
|
|
1628
|
+
} catch (err) {
|
|
1629
|
+
send(res, 500, {
|
|
1630
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
if (url.pathname === "/api/health") {
|
|
1636
|
+
send(res, 200, { ok: true });
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
send(res, 404, { error: "not found" });
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
send(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async function startServer(opts) {
|
|
1645
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1646
|
+
const port = opts.port ?? 5181;
|
|
1647
|
+
const state = { projectDir: opts.projectDir };
|
|
1648
|
+
const setProjectDir = (next) => {
|
|
1649
|
+
state.projectDir = next;
|
|
1650
|
+
};
|
|
1651
|
+
const cacheFor = makeCacheResolver(opts.cache ?? "process");
|
|
1652
|
+
const http = createServer((req, res) => {
|
|
1653
|
+
void route(
|
|
1654
|
+
req,
|
|
1655
|
+
res,
|
|
1656
|
+
state.projectDir,
|
|
1657
|
+
opts.debug ?? false,
|
|
1658
|
+
setProjectDir,
|
|
1659
|
+
cacheFor,
|
|
1660
|
+
opts.cacheTtlMs
|
|
1661
|
+
);
|
|
1662
|
+
});
|
|
1663
|
+
await new Promise((resolve7, reject) => {
|
|
1664
|
+
http.once("error", reject);
|
|
1665
|
+
http.listen(port, host, () => resolve7());
|
|
1666
|
+
});
|
|
1667
|
+
const addr = http.address();
|
|
1668
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1669
|
+
return {
|
|
1670
|
+
url: `http://${host}:${actualPort}`,
|
|
1671
|
+
port: actualPort,
|
|
1672
|
+
close: () => new Promise((resolve7) => http.close(() => resolve7()))
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// src/commands/serve.ts
|
|
1677
|
+
async function runServe(opts) {
|
|
1678
|
+
if (opts.insecure) await enableInsecureTls();
|
|
1679
|
+
const srv = await startServer({
|
|
1680
|
+
projectDir: opts.projectDir,
|
|
1681
|
+
...opts.host !== void 0 ? { host: opts.host } : {},
|
|
1682
|
+
...opts.port !== void 0 ? { port: opts.port } : {},
|
|
1683
|
+
...opts.debug !== void 0 ? { debug: opts.debug } : {},
|
|
1684
|
+
...opts.cache !== void 0 ? { cache: opts.cache } : {},
|
|
1685
|
+
...opts.cacheTtlMs !== void 0 ? { cacheTtlMs: opts.cacheTtlMs } : {}
|
|
1686
|
+
});
|
|
1687
|
+
console.log(`Journey API listening at ${srv.url}`);
|
|
1688
|
+
console.log(`For the GUI, run: pnpm --filter @usejourney/gui dev`);
|
|
1689
|
+
await new Promise((resolve7) => {
|
|
1690
|
+
const handler = () => {
|
|
1691
|
+
console.log("Shutting down\u2026");
|
|
1692
|
+
void srv.close().then(() => resolve7());
|
|
1693
|
+
};
|
|
1694
|
+
process.once("SIGINT", handler);
|
|
1695
|
+
process.once("SIGTERM", handler);
|
|
1696
|
+
});
|
|
1697
|
+
return 0;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// src/index.ts
|
|
1701
|
+
function parseCacheMode(value) {
|
|
1702
|
+
if (value === "off" || value === "run" || value === "process" || value === "disk") {
|
|
1703
|
+
return value;
|
|
1704
|
+
}
|
|
1705
|
+
throw new Error(`--cache must be one of off|run|process|disk (got "${value}")`);
|
|
1706
|
+
}
|
|
1707
|
+
async function handle(fn) {
|
|
1708
|
+
try {
|
|
1709
|
+
const code = await fn() ?? 0;
|
|
1710
|
+
process.exit(code);
|
|
1711
|
+
} catch (err) {
|
|
1712
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1713
|
+
console.error(`journey: ${msg}`);
|
|
1714
|
+
process.exit(1);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function buildProgram() {
|
|
1718
|
+
const program = new Command();
|
|
1719
|
+
program.name("journey").description("Local-first API testing & orchestration tool").version("0.0.0");
|
|
1720
|
+
program.command("init <dir>").requiredOption("--spec <path>", "OpenAPI spec file").option("--force", "Scaffold into a non-empty directory", false).description("Scaffold a new Journey project from an OpenAPI spec").action(
|
|
1721
|
+
(dir, options) => handle(
|
|
1722
|
+
() => runInit({
|
|
1723
|
+
dir,
|
|
1724
|
+
spec: options.spec,
|
|
1725
|
+
...options.force !== void 0 ? { force: options.force } : {}
|
|
1726
|
+
})
|
|
1727
|
+
)
|
|
1728
|
+
);
|
|
1729
|
+
program.command("generate").description("Regenerate typed endpoints/models from the spec").action(() => handle(() => runGenerate(process.cwd())));
|
|
1730
|
+
program.command("run [journey-file...]").option("--env <name>", "Environment file to use").option("--all", "Run all journeys in the project", false).option("--debug", "Log every request and response to stderr", false).option("--watch", "Rerun on file changes", false).option(
|
|
1731
|
+
"--insecure",
|
|
1732
|
+
"Disable TLS certificate verification (for self-signed / corporate CAs)",
|
|
1733
|
+
false
|
|
1734
|
+
).option(
|
|
1735
|
+
"--cache <mode>",
|
|
1736
|
+
"Sub-journey output cache: off|run|process|disk",
|
|
1737
|
+
parseCacheMode,
|
|
1738
|
+
"process"
|
|
1739
|
+
).option("--cache-ttl <ms>", "Default sub-journey cache TTL in ms", (v) => parseInt(v, 10)).description("Run one or more journeys (or --all)").action(
|
|
1740
|
+
(files, options) => handle(
|
|
1741
|
+
() => runCommand({
|
|
1742
|
+
projectDir: process.cwd(),
|
|
1743
|
+
files,
|
|
1744
|
+
...options.all !== void 0 ? { all: options.all } : {},
|
|
1745
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
1746
|
+
...options.debug !== void 0 ? { debug: options.debug } : {},
|
|
1747
|
+
...options.watch !== void 0 ? { watch: options.watch } : {},
|
|
1748
|
+
...options.insecure !== void 0 ? { insecure: options.insecure } : {},
|
|
1749
|
+
...options.cache !== void 0 ? { cache: options.cache } : {},
|
|
1750
|
+
...options.cacheTtl !== void 0 ? { cacheTtlMs: options.cacheTtl } : {}
|
|
1751
|
+
})
|
|
1752
|
+
)
|
|
1753
|
+
);
|
|
1754
|
+
const exp = program.command("export").description("Export a journey to another format");
|
|
1755
|
+
exp.command("k6 <path>").option("--out <path>", "Output file (single-file mode only)").option(
|
|
1756
|
+
"--out-dir <path>",
|
|
1757
|
+
"Output directory (used in directory mode; falls back to writing next to each source)"
|
|
1758
|
+
).option(
|
|
1759
|
+
"--tag <tag>",
|
|
1760
|
+
"Only export journeys with this tag (repeatable, AND across repeats)",
|
|
1761
|
+
(v, prev = []) => [...prev, v],
|
|
1762
|
+
[]
|
|
1763
|
+
).description("Export one or more journeys as k6 scripts (path may be a file or directory)").action(
|
|
1764
|
+
(path, options) => handle(
|
|
1765
|
+
() => runExportK6({
|
|
1766
|
+
path,
|
|
1767
|
+
...options.out !== void 0 ? { out: options.out } : {},
|
|
1768
|
+
...options.outDir !== void 0 ? { outDir: options.outDir } : {},
|
|
1769
|
+
tags: options.tag
|
|
1770
|
+
})
|
|
1771
|
+
)
|
|
1772
|
+
);
|
|
1773
|
+
exp.command("postman <path>").option("--out <path>", "Output file (single-file mode only)").option(
|
|
1774
|
+
"--out-dir <path>",
|
|
1775
|
+
"Output directory (used in directory mode; falls back to writing next to each source)"
|
|
1776
|
+
).option(
|
|
1777
|
+
"--tag <tag>",
|
|
1778
|
+
"Only export journeys with this tag (repeatable, AND across repeats)",
|
|
1779
|
+
(v, prev = []) => [...prev, v],
|
|
1780
|
+
[]
|
|
1781
|
+
).option("--name <name>", "Override the collection name").option("--env <name>", "Export named environment as a Postman environment file").option("--all-envs", "Export all environments as Postman environment files").option("--bundle", "Aggregate every matching journey (across all files) into one collection").option(
|
|
1782
|
+
"--thread-state",
|
|
1783
|
+
"Experimental: thread journey state through collection variables so sub-journey outputs reach later requests"
|
|
1784
|
+
).option(
|
|
1785
|
+
"--lenient",
|
|
1786
|
+
"With --thread-state: emit non-enforcing assertions (legacy swallow-all; a failing expect() stays a console line, not a red pm.test)"
|
|
1787
|
+
).description(
|
|
1788
|
+
"Export one or more journeys as a Postman Collection v2.1.0 (path may be a file or directory)"
|
|
1789
|
+
).action(
|
|
1790
|
+
(path, options) => handle(
|
|
1791
|
+
() => runExportPostman({
|
|
1792
|
+
path,
|
|
1793
|
+
...options.out !== void 0 ? { out: options.out } : {},
|
|
1794
|
+
...options.outDir !== void 0 ? { outDir: options.outDir } : {},
|
|
1795
|
+
...options.name !== void 0 ? { name: options.name } : {},
|
|
1796
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
1797
|
+
...options.allEnvs !== void 0 ? { allEnvs: options.allEnvs } : {},
|
|
1798
|
+
...options.bundle !== void 0 ? { bundle: options.bundle } : {},
|
|
1799
|
+
...options.threadState !== void 0 ? { threadState: options.threadState } : {},
|
|
1800
|
+
...options.lenient !== void 0 ? { lenient: options.lenient } : {},
|
|
1801
|
+
tags: options.tag
|
|
1802
|
+
})
|
|
1803
|
+
)
|
|
1804
|
+
);
|
|
1805
|
+
program.command("serve").option("--port <n>", "Port (default 5181)", (v) => parseInt(v, 10)).option("--host <host>", "Host (default 127.0.0.1)").option("--project <dir>", "Project directory (default: cwd)").option("--debug", "Log every request/response while running journeys", false).option(
|
|
1806
|
+
"--insecure",
|
|
1807
|
+
"Disable TLS certificate verification for journey runs triggered via the API",
|
|
1808
|
+
false
|
|
1809
|
+
).option(
|
|
1810
|
+
"--cache <mode>",
|
|
1811
|
+
"Sub-journey output cache: off|run|process|disk",
|
|
1812
|
+
parseCacheMode,
|
|
1813
|
+
"process"
|
|
1814
|
+
).option("--cache-ttl <ms>", "Default sub-journey cache TTL in ms", (v) => parseInt(v, 10)).description("Run the GUI backend API for the current project").action(
|
|
1815
|
+
(options) => {
|
|
1816
|
+
const projectDir = options.project ? resolvePath(process.cwd(), options.project) : process.cwd();
|
|
1817
|
+
return handle(
|
|
1818
|
+
() => runServe({
|
|
1819
|
+
projectDir,
|
|
1820
|
+
...options.port !== void 0 ? { port: options.port } : {},
|
|
1821
|
+
...options.host !== void 0 ? { host: options.host } : {},
|
|
1822
|
+
...options.debug !== void 0 ? { debug: options.debug } : {},
|
|
1823
|
+
...options.insecure !== void 0 ? { insecure: options.insecure } : {},
|
|
1824
|
+
...options.cache !== void 0 ? { cache: options.cache } : {},
|
|
1825
|
+
...options.cacheTtl !== void 0 ? { cacheTtlMs: options.cacheTtl } : {}
|
|
1826
|
+
})
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
);
|
|
1830
|
+
const envCmd = program.command("env").description("Environment management");
|
|
1831
|
+
envCmd.command("list").description("List available environments").action(() => handle(() => runEnvList(process.cwd())));
|
|
1832
|
+
return program;
|
|
1833
|
+
}
|
|
1834
|
+
var isDirectRun = import.meta.url === `file://${process.argv[1]}`;
|
|
1835
|
+
if (isDirectRun) {
|
|
1836
|
+
buildProgram().parseAsync(process.argv);
|
|
1837
|
+
}
|
|
1838
|
+
export {
|
|
1839
|
+
buildProgram
|
|
1840
|
+
};
|