@wanbicoi/next-steps 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1174 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // src/cli.ts
5
+ import { existsSync as existsSync3 } from "fs";
6
+ import { basename, join as join2 } from "path";
7
+ import { pathToFileURL } from "url";
8
+ import { parseArgs } from "util";
9
+
10
+ // src/types.ts
11
+ var END = "END";
12
+
13
+ class AppError extends Error {
14
+ code;
15
+ exitCode;
16
+ details;
17
+ constructor(code, message, exitCode = 1, details) {
18
+ super(message);
19
+ this.code = code;
20
+ this.exitCode = exitCode;
21
+ this.details = details;
22
+ this.name = "AppError";
23
+ }
24
+ }
25
+
26
+ // src/format.ts
27
+ function table(headers, rows) {
28
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
29
+ const render = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd();
30
+ return [render(headers), ...rows.map(render)].join(`
31
+ `);
32
+ }
33
+ function formatTime(iso) {
34
+ return iso.slice(0, 19).replace("T", " ");
35
+ }
36
+ function optionLine(option, index) {
37
+ const title = option.node === END ? "" : option.title;
38
+ const review = option.reviewRequired ? " [review]" : "";
39
+ const head = ` ${index + 1}. ${option.node}${title ? ` ${title}` : ""}${review}`.trimEnd();
40
+ const lines = [head, ` ${option.description}`];
41
+ if (option.condition)
42
+ lines.push(` when: ${option.condition}`);
43
+ return lines;
44
+ }
45
+ function formatNext(next) {
46
+ if (next.done && next.next.length === 0)
47
+ return "done: true";
48
+ if (next.next.length === 0)
49
+ return "next: (none)";
50
+ const lines = ["next:"];
51
+ for (const [index, option] of next.next.entries()) {
52
+ lines.push(...optionLine(option, index));
53
+ }
54
+ return lines.join(`
55
+ `);
56
+ }
57
+ function formatCurrent(next) {
58
+ if (next.done)
59
+ return `current: ${next.current ?? END} (finished)`;
60
+ if (next.current === null)
61
+ return "current: (not started)";
62
+ const title = next.currentTitle ? ` (${next.currentTitle})` : "";
63
+ return `current: ${next.current}${title}`;
64
+ }
65
+
66
+ // src/commands/shared.ts
67
+ function usageError(message) {
68
+ return new AppError("usage", message, 2);
69
+ }
70
+ function requireString(value, flag) {
71
+ if (typeof value !== "string" || value.length === 0) {
72
+ throw usageError(`--${flag} is required`);
73
+ }
74
+ return value;
75
+ }
76
+ function toJson(value) {
77
+ return JSON.stringify(value, null, 2);
78
+ }
79
+
80
+ // src/commands/session.ts
81
+ function cmdCreate(service, input) {
82
+ const { session, workflowTitle, next } = service.create({ key: input.key });
83
+ if (input.json) {
84
+ return toJson({
85
+ key: session.key,
86
+ workflowId: session.workflowId,
87
+ workflowTitle,
88
+ status: session.status,
89
+ current: null,
90
+ done: next.done,
91
+ next: next.next,
92
+ statePath: service.statePath
93
+ });
94
+ }
95
+ return [
96
+ `session: ${session.key}`,
97
+ `workflow: ${session.workflowId} ${workflowTitle}`,
98
+ `status: ${session.status}`,
99
+ "",
100
+ formatNext(next)
101
+ ].join(`
102
+ `);
103
+ }
104
+ function cmdLog(service, input) {
105
+ const key = requireString(input.key, "key");
106
+ const node = requireString(input.node, "node");
107
+ const { session, step, next } = service.log(key, node);
108
+ if (input.json) {
109
+ return toJson({
110
+ step,
111
+ state: session,
112
+ done: next.done,
113
+ next: next.next,
114
+ statePath: service.statePath
115
+ });
116
+ }
117
+ const origin = step.from ? ` (from ${step.from})` : " (entry)";
118
+ return [
119
+ `logged: #${step.seq} ${step.node}${origin}`,
120
+ `status: ${session.status}`,
121
+ "",
122
+ formatNext(next)
123
+ ].join(`
124
+ `);
125
+ }
126
+ function cmdNext(service, input) {
127
+ const key = requireString(input.key, "key");
128
+ const { session, next } = service.next(key);
129
+ if (input.json) {
130
+ return toJson({
131
+ key,
132
+ workflowId: session.workflowId,
133
+ status: session.status,
134
+ current: next.current === null ? null : { node: next.current, title: next.currentTitle, at: session.updatedAt },
135
+ done: next.done,
136
+ next: next.next,
137
+ statePath: service.statePath
138
+ });
139
+ }
140
+ return [formatCurrent(next), "", formatNext(next)].join(`
141
+ `);
142
+ }
143
+ function cmdSteps(service, input) {
144
+ const key = requireString(input.key, "key");
145
+ const { session, steps } = service.steps(key, { limit: input.limit, after: input.after });
146
+ if (input.json) {
147
+ return toJson({
148
+ key,
149
+ workflowId: session.workflowId,
150
+ status: session.status,
151
+ current: session.current,
152
+ steps,
153
+ statePath: service.statePath
154
+ });
155
+ }
156
+ const head = `session: ${key} status: ${session.status} current: ${session.current ?? "(not started)"}`;
157
+ if (steps.length === 0)
158
+ return `${head}
159
+
160
+ no steps yet`;
161
+ const rows = steps.map((step) => [
162
+ String(step.seq),
163
+ step.node,
164
+ step.edge ?? "-",
165
+ step.from ?? "-",
166
+ formatTime(step.at)
167
+ ]);
168
+ return `${head}
169
+
170
+ ${table(["SEQ", "NODE", "EDGE", "FROM", "AT"], rows)}`;
171
+ }
172
+ function cmdKeys(service, input) {
173
+ const sessions = service.keys();
174
+ if (input.json) {
175
+ return toJson({ statePath: service.statePath, sessions });
176
+ }
177
+ const head = `state: ${service.statePath}
178
+ `;
179
+ if (sessions.length === 0)
180
+ return `${head}no sessions`;
181
+ const rows = sessions.map((session) => [
182
+ session.key,
183
+ session.workflowId,
184
+ session.status,
185
+ session.current ?? "-",
186
+ formatTime(session.updatedAt)
187
+ ]);
188
+ return head + table(["KEY", "WORKFLOW", "STATUS", "CURRENT", "UPDATED"], rows);
189
+ }
190
+ function cmdReset(service, input) {
191
+ const key = requireString(input.key, "key");
192
+ const result = service.reset(key);
193
+ if (input.json) {
194
+ return toJson({ ...result, statePath: service.statePath });
195
+ }
196
+ return `reset: ${result.key} (${result.deletedSteps} steps deleted)`;
197
+ }
198
+
199
+ // src/commands/shared.ts
200
+ function usageError2(message) {
201
+ return new AppError("usage", message, 2);
202
+ }
203
+ function optionalInt(value, flag) {
204
+ if (value === undefined)
205
+ return;
206
+ if (typeof value !== "string" || !/^\d+$/.test(value)) {
207
+ throw usageError2(`--${flag} must be a non-negative integer`);
208
+ }
209
+ return Number(value);
210
+ }
211
+ function toJson2(value) {
212
+ return JSON.stringify(value, null, 2);
213
+ }
214
+
215
+ // src/commands/viz.ts
216
+ import { existsSync, readFileSync } from "node:fs";
217
+ import { createServer } from "node:http";
218
+ import { createRequire } from "node:module";
219
+ import { dirname, join } from "node:path";
220
+ import { fileURLToPath } from "node:url";
221
+ var require2 = createRequire(import.meta.url);
222
+ function findTemplate() {
223
+ let dir = dirname(fileURLToPath(import.meta.url));
224
+ for (let depth = 0;depth < 6; depth++) {
225
+ const candidate = join(dir, "viz", "template.html");
226
+ if (existsSync(candidate))
227
+ return candidate;
228
+ const parent = dirname(dir);
229
+ if (parent === dir)
230
+ break;
231
+ dir = parent;
232
+ }
233
+ throw new AppError("viz_template_missing", "could not find viz/template.html next to the package", 3);
234
+ }
235
+ function renderVizHtml(template, workflow) {
236
+ const data = JSON.stringify({ workflow }).replaceAll("<", "\\u003c");
237
+ return template.replace("__WORKFLOW_DATA__", data);
238
+ }
239
+ function startVizServer(workflow, port) {
240
+ const template = readFileSync(findTemplate(), "utf8");
241
+ const cytoscapeJs = readFileSync(require2.resolve("cytoscape/dist/cytoscape.min.js"), "utf8");
242
+ const dagreJs = readFileSync(require2.resolve("cytoscape-dagre"), "utf8");
243
+ const html = renderVizHtml(template, workflow);
244
+ const server = createServer((request, response) => {
245
+ const path = (request.url ?? "/").split("?")[0];
246
+ if (path === "/" || path === "/index.html") {
247
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
248
+ response.end(html);
249
+ return;
250
+ }
251
+ if (path === "/vendor/cytoscape.min.js") {
252
+ response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
253
+ response.end(cytoscapeJs);
254
+ return;
255
+ }
256
+ if (path === "/vendor/cytoscape-dagre.js") {
257
+ response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
258
+ response.end(dagreJs);
259
+ return;
260
+ }
261
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
262
+ response.end("not found");
263
+ });
264
+ server.on("error", (error) => {
265
+ console.error(`viz: ${error.message}`);
266
+ process.exitCode = 1;
267
+ });
268
+ server.listen(port, "127.0.0.1");
269
+ return { url: `http://127.0.0.1:${port}`, workflow: workflow.id };
270
+ }
271
+
272
+ // src/commands/workflow.ts
273
+ import { existsSync as existsSync2, mkdirSync, writeFileSync } from "node:fs";
274
+ import { dirname as dirname2 } from "node:path";
275
+ var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
276
+ function skeleton(id) {
277
+ return {
278
+ id,
279
+ title: id,
280
+ start: "start",
281
+ nodes: [
282
+ {
283
+ id: "start",
284
+ title: "Start",
285
+ description: "Describe the first step.",
286
+ next: [{ to: END, description: "Done." }]
287
+ }
288
+ ]
289
+ };
290
+ }
291
+ function cmdWorkflowNew(graphPath, id, json) {
292
+ if (existsSync2(graphPath)) {
293
+ throw usageError(`workflow file already exists: ${graphPath}`);
294
+ }
295
+ const workflowId = ID_PATTERN.test(id) ? id : "workflow";
296
+ mkdirSync(dirname2(graphPath), { recursive: true });
297
+ writeFileSync(graphPath, `${JSON.stringify(skeleton(workflowId), null, 2)}
298
+ `, "utf8");
299
+ if (json)
300
+ return toJson({ created: graphPath, workflowId });
301
+ return `created: ${graphPath}`;
302
+ }
303
+ function requireValid(load) {
304
+ if (load.errors.length > 0 || !load.workflow) {
305
+ throw new AppError("invalid_workflow", `workflow file is invalid: ${load.path}`, 5, {
306
+ errors: load.errors
307
+ });
308
+ }
309
+ return load.workflow;
310
+ }
311
+ function cmdWorkflowShow(load, json) {
312
+ const wf = requireValid(load);
313
+ if (json)
314
+ return toJson({ workflow: wf, warnings: load.warnings });
315
+ const lines = [`${wf.id} ${wf.title}`, `start: ${wf.start}`];
316
+ let group;
317
+ for (const node of wf.nodes) {
318
+ if (node.group !== group) {
319
+ group = node.group;
320
+ if (group)
321
+ lines.push("", group);
322
+ }
323
+ const review = node.reviewRequired ? " [review]" : "";
324
+ lines.push(` ${node.id} ${node.title}${review}`);
325
+ for (const move of node.next) {
326
+ const when = move.condition ? ` [when: ${move.condition}]` : "";
327
+ lines.push(` -> ${move.to} ${move.description}${when}`);
328
+ }
329
+ }
330
+ return lines.join(`
331
+ `);
332
+ }
333
+ function cmdWorkflowCheck(load, json) {
334
+ if (load.errors.length > 0 || !load.workflow) {
335
+ throw new AppError("invalid_workflow", "workflow validation failed", 5, {
336
+ errors: load.errors
337
+ });
338
+ }
339
+ const wf = load.workflow;
340
+ const moves = wf.nodes.reduce((count, node) => count + node.next.length, 0);
341
+ if (json) {
342
+ return toJson({
343
+ ok: true,
344
+ workflowId: wf.id,
345
+ title: wf.title,
346
+ nodes: wf.nodes.length,
347
+ moves,
348
+ warnings: load.warnings
349
+ });
350
+ }
351
+ const lines = [`ok: ${wf.id} (${wf.nodes.length} nodes, ${moves} moves)`];
352
+ for (const warning of load.warnings)
353
+ lines.push(`warning: ${warning}`);
354
+ return lines.join(`
355
+ `);
356
+ }
357
+
358
+ // src/workflow/validate.ts
359
+ import { z } from "zod";
360
+ var moveSchema = z.object({
361
+ to: z.string().min(1),
362
+ description: z.string(),
363
+ condition: z.string().min(1).optional()
364
+ }).strict();
365
+ var nodeSchema = z.object({
366
+ id: z.string().min(1),
367
+ title: z.string().min(1),
368
+ description: z.string(),
369
+ group: z.string().min(1).optional(),
370
+ reviewRequired: z.boolean().optional(),
371
+ next: z.array(moveSchema).min(1)
372
+ }).strict();
373
+ var workflowSchema = z.object({
374
+ id: z.string().min(1),
375
+ title: z.string().min(1),
376
+ start: z.string().min(1),
377
+ nodes: z.array(nodeSchema).min(1)
378
+ }).strict();
379
+ function reportLegacyShape(raw, report) {
380
+ if (typeof raw !== "object" || raw === null)
381
+ return;
382
+ const file = raw;
383
+ if (Array.isArray(file.edges)) {
384
+ report(`edges are inline now: move each edge into its node's "next" list`);
385
+ }
386
+ if (!Array.isArray(file.nodes))
387
+ return;
388
+ for (const node of file.nodes) {
389
+ if (typeof node !== "object" || node === null)
390
+ continue;
391
+ const candidate = node;
392
+ if (candidate.type === "subgraph") {
393
+ report(`node "${String(candidate.id)}" is a subgraph: inline its nodes and use "group" for the label`);
394
+ } else if ("type" in candidate) {
395
+ report(`node "${String(candidate.id)}" has a "type" field; nodes are flat, drop it`);
396
+ }
397
+ }
398
+ }
399
+ function validateWorkflow(raw, source) {
400
+ const errors = [];
401
+ const warnings = [];
402
+ const report = (message) => {
403
+ errors.push(`${source}: ${message}`);
404
+ };
405
+ reportLegacyShape(raw, report);
406
+ const parsed = workflowSchema.safeParse(raw);
407
+ if (!parsed.success) {
408
+ for (const issue of parsed.error.issues) {
409
+ const path = issue.path.length > 0 ? `.${issue.path.join(".")}` : "";
410
+ report(`${path} ${issue.message}`.trim());
411
+ }
412
+ return { errors, warnings };
413
+ }
414
+ const wf = parsed.data;
415
+ const nodes = new Map;
416
+ for (const node of wf.nodes) {
417
+ if (node.id === END) {
418
+ report(`node id "${END}" is reserved; point a move at END instead`);
419
+ }
420
+ if (nodes.has(node.id)) {
421
+ report(`duplicate node id "${node.id}"`);
422
+ }
423
+ nodes.set(node.id, node);
424
+ }
425
+ if (wf.start === END) {
426
+ report(`start must be a node, not END`);
427
+ } else if (!nodes.has(wf.start)) {
428
+ report(`start "${wf.start}" is not a node`);
429
+ }
430
+ let reachesEnd = false;
431
+ for (const node of wf.nodes) {
432
+ const seen = new Set;
433
+ for (const move of node.next) {
434
+ if (move.to === END) {
435
+ reachesEnd = true;
436
+ } else if (!nodes.has(move.to)) {
437
+ report(`move from "${node.id}" targets unknown node "${move.to}" (use END to finish)`);
438
+ }
439
+ if (seen.has(move.to)) {
440
+ report(`duplicate move ${node.id} -> ${move.to}; transitions must be unambiguous`);
441
+ }
442
+ seen.add(move.to);
443
+ }
444
+ }
445
+ if (!reachesEnd) {
446
+ report(`workflow "${wf.id}" has no move to END`);
447
+ }
448
+ if (errors.length === 0) {
449
+ const reachable = new Set([wf.start]);
450
+ const queue = [wf.start];
451
+ while (queue.length > 0) {
452
+ const id = queue.shift();
453
+ for (const move of movesFor(wf, id)) {
454
+ if (move.to === END || reachable.has(move.to))
455
+ continue;
456
+ reachable.add(move.to);
457
+ queue.push(move.to);
458
+ }
459
+ }
460
+ for (const node of wf.nodes) {
461
+ if (!reachable.has(node.id)) {
462
+ warnings.push(`${source}: node "${node.id}" is unreachable from start "${wf.start}"`);
463
+ }
464
+ }
465
+ const canFinish = new Set;
466
+ for (const node of wf.nodes) {
467
+ if (node.next.some((move) => move.to === END))
468
+ canFinish.add(node.id);
469
+ }
470
+ let grew = true;
471
+ while (grew) {
472
+ grew = false;
473
+ for (const node of wf.nodes) {
474
+ if (canFinish.has(node.id))
475
+ continue;
476
+ if (!node.next.some((move) => canFinish.has(move.to)))
477
+ continue;
478
+ canFinish.add(node.id);
479
+ grew = true;
480
+ }
481
+ }
482
+ for (const node of wf.nodes) {
483
+ if (!canFinish.has(node.id)) {
484
+ warnings.push(`${source}: node "${node.id}" cannot reach END`);
485
+ }
486
+ }
487
+ }
488
+ return { workflow: wf, errors, warnings };
489
+ }
490
+ function movesFor(wf, nodeId) {
491
+ return wf.nodes.find((node) => node.id === nodeId)?.next ?? [];
492
+ }
493
+ function onlyEnd(wf, nodeId) {
494
+ const moves = movesFor(wf, nodeId);
495
+ return moves.length === 1 && moves[0]?.to === END;
496
+ }
497
+
498
+ // src/workflow/graph.ts
499
+ function getNode(wf, id) {
500
+ return wf.nodes.find((node) => node.id === id);
501
+ }
502
+ function computeNext(wf, session) {
503
+ if (session.status === "done") {
504
+ return { done: true, current: session.current, currentTitle: null, next: [] };
505
+ }
506
+ if (session.current === null) {
507
+ const entry = getNode(wf, wf.start);
508
+ return {
509
+ done: false,
510
+ current: null,
511
+ currentTitle: null,
512
+ next: [
513
+ {
514
+ edge: null,
515
+ node: wf.start,
516
+ title: entry?.title ?? wf.start,
517
+ description: entry?.description ?? "",
518
+ reviewRequired: entry?.reviewRequired ?? false
519
+ }
520
+ ]
521
+ };
522
+ }
523
+ const node = getNode(wf, session.current);
524
+ if (onlyEnd(wf, session.current)) {
525
+ return {
526
+ done: true,
527
+ current: session.current,
528
+ currentTitle: node?.title ?? null,
529
+ next: []
530
+ };
531
+ }
532
+ const current = session.current;
533
+ const next = movesFor(wf, current).map((move) => {
534
+ const edge = `${current}->${move.to}`;
535
+ if (move.to === END) {
536
+ return {
537
+ edge,
538
+ node: END,
539
+ title: "finish",
540
+ description: move.description,
541
+ ...move.condition ? { condition: move.condition } : {},
542
+ reviewRequired: false
543
+ };
544
+ }
545
+ const target = getNode(wf, move.to);
546
+ return {
547
+ edge,
548
+ node: move.to,
549
+ title: target?.title ?? move.to,
550
+ description: move.description,
551
+ ...move.condition ? { condition: move.condition } : {},
552
+ reviewRequired: target?.reviewRequired ?? false
553
+ };
554
+ });
555
+ return {
556
+ done: false,
557
+ current,
558
+ currentTitle: node?.title ?? null,
559
+ next
560
+ };
561
+ }
562
+
563
+ // src/runs/service.ts
564
+ var KEY_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
565
+
566
+ class SessionService {
567
+ workflow;
568
+ store;
569
+ constructor(workflow, store) {
570
+ this.workflow = workflow;
571
+ this.store = store;
572
+ }
573
+ get statePath() {
574
+ return this.store.path;
575
+ }
576
+ create(input = {}) {
577
+ const key = (input.key ?? "").trim() || crypto.randomUUID();
578
+ if (!KEY_PATTERN.test(key)) {
579
+ throw new AppError("key_invalid", `key "${key}" is invalid; use 1-128 characters from A-Z a-z 0-9 . _ -`, 2);
580
+ }
581
+ const now = new Date().toISOString();
582
+ const session = {
583
+ key,
584
+ workflowId: this.workflow.id,
585
+ status: "running",
586
+ current: null,
587
+ version: 0,
588
+ createdAt: now,
589
+ updatedAt: now
590
+ };
591
+ if (!this.store.createSession(session)) {
592
+ throw new AppError("key_exists", `session "${key}" already exists`, 2, { key });
593
+ }
594
+ return {
595
+ session,
596
+ workflowTitle: this.workflow.title,
597
+ next: computeNext(this.workflow, session)
598
+ };
599
+ }
600
+ log(key, node) {
601
+ return this.store.transaction(() => {
602
+ const session = this.requireSession(key);
603
+ const wf = this.workflow;
604
+ if (session.status === "done") {
605
+ throw new AppError("invalid_transition", `session "${key}" is already done`, 4, {
606
+ current: END,
607
+ node,
608
+ legal: []
609
+ });
610
+ }
611
+ const now = new Date().toISOString();
612
+ const from = session.current;
613
+ let stepInput;
614
+ if (node === END) {
615
+ if (from === null || !movesFor(wf, from).some((move) => move.to === END)) {
616
+ throw this.invalidTransition(from, node);
617
+ }
618
+ stepInput = {
619
+ key,
620
+ node: END,
621
+ from,
622
+ edge: `${from}->${END}`,
623
+ workflowId: wf.id,
624
+ at: now
625
+ };
626
+ session.current = END;
627
+ session.status = "done";
628
+ } else {
629
+ const target = getNode(wf, node);
630
+ if (!target) {
631
+ throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: this.legalOptions(from) });
632
+ }
633
+ let edge = null;
634
+ if (from === null) {
635
+ if (node !== wf.start)
636
+ throw this.invalidTransition(from, node);
637
+ } else {
638
+ if (!movesFor(wf, from).some((move) => move.to === node)) {
639
+ throw this.invalidTransition(from, node);
640
+ }
641
+ edge = `${from}->${node}`;
642
+ }
643
+ stepInput = { key, node, from, edge, workflowId: wf.id, at: now };
644
+ session.current = node;
645
+ if (onlyEnd(wf, node)) {
646
+ session.current = END;
647
+ session.status = "done";
648
+ }
649
+ }
650
+ session.version += 1;
651
+ session.updatedAt = now;
652
+ const next = computeNext(wf, session);
653
+ const step = this.store.appendStep(stepInput);
654
+ this.store.updateSession(session);
655
+ return { session, step, next };
656
+ });
657
+ }
658
+ next(key) {
659
+ const session = this.requireSession(key);
660
+ return { session, next: computeNext(this.workflow, session) };
661
+ }
662
+ steps(key, query = {}) {
663
+ const session = this.requireSession(key);
664
+ return { session, steps: this.store.listSteps(key, query) };
665
+ }
666
+ keys() {
667
+ return this.store.listSessions();
668
+ }
669
+ reset(key) {
670
+ this.requireSession(key);
671
+ return { key, deletedSteps: this.store.reset(key) };
672
+ }
673
+ legalOptions(from) {
674
+ if (from === null) {
675
+ const entry = getNode(this.workflow, this.workflow.start);
676
+ return [{ edge: null, node: this.workflow.start, description: entry?.description ?? "" }];
677
+ }
678
+ return movesFor(this.workflow, from).map((move) => ({
679
+ edge: `${from}->${move.to}`,
680
+ node: move.to,
681
+ description: move.description
682
+ }));
683
+ }
684
+ invalidTransition(from, node) {
685
+ return new AppError("invalid_transition", `cannot move to "${node}" from "${from ?? "(start)"}" in workflow "${this.workflow.id}"`, 4, { current: from, node, legal: this.legalOptions(from) });
686
+ }
687
+ requireSession(key) {
688
+ const session = this.store.getSession(key);
689
+ if (!session)
690
+ throw new AppError("key_not_found", `session "${key}" does not exist`, 3);
691
+ return session;
692
+ }
693
+ }
694
+
695
+ // src/runs/store.ts
696
+ import { mkdirSync as mkdirSync2, unlinkSync } from "node:fs";
697
+ import { dirname as dirname3 } from "node:path";
698
+
699
+ // src/runs/driver.ts
700
+ import { createRequire as createRequire2 } from "node:module";
701
+
702
+ class BunDriver {
703
+ db;
704
+ constructor(db) {
705
+ this.db = db;
706
+ }
707
+ exec(sql) {
708
+ this.db.exec(sql);
709
+ }
710
+ run(sql, params) {
711
+ return Number(this.db.run(sql, params).changes);
712
+ }
713
+ get(sql, params) {
714
+ return this.db.query(sql).get(...params) ?? null;
715
+ }
716
+ all(sql, params) {
717
+ return this.db.query(sql).all(...params);
718
+ }
719
+ transaction(fn) {
720
+ return this.db.transaction(fn)();
721
+ }
722
+ close() {
723
+ this.db.close();
724
+ }
725
+ }
726
+
727
+ class NodeDriver {
728
+ db;
729
+ constructor(db) {
730
+ this.db = db;
731
+ }
732
+ exec(sql) {
733
+ this.db.exec(sql);
734
+ }
735
+ run(sql, params) {
736
+ return Number(this.db.prepare(sql).run(...params).changes);
737
+ }
738
+ get(sql, params) {
739
+ return this.db.prepare(sql).get(...params) ?? null;
740
+ }
741
+ all(sql, params) {
742
+ return this.db.prepare(sql).all(...params);
743
+ }
744
+ transaction(fn) {
745
+ this.db.exec("BEGIN");
746
+ try {
747
+ const result = fn();
748
+ this.db.exec("COMMIT");
749
+ return result;
750
+ } catch (error) {
751
+ this.db.exec("ROLLBACK");
752
+ throw error;
753
+ }
754
+ }
755
+ close() {
756
+ this.db.close();
757
+ }
758
+ }
759
+ function openDriver(path) {
760
+ const load = createRequire2(import.meta.url);
761
+ if ("bun" in process.versions) {
762
+ const { Database } = load("bun:sqlite");
763
+ return new BunDriver(new Database(path, { create: true }));
764
+ }
765
+ const { DatabaseSync } = load("node:sqlite");
766
+ return new NodeDriver(new DatabaseSync(path));
767
+ }
768
+
769
+ // src/runs/store.ts
770
+ var SCHEMA = `
771
+ CREATE TABLE IF NOT EXISTS runs (
772
+ key TEXT PRIMARY KEY,
773
+ workflow_id TEXT NOT NULL,
774
+ status TEXT NOT NULL,
775
+ current TEXT,
776
+ version INTEGER NOT NULL DEFAULT 0,
777
+ created_at TEXT NOT NULL,
778
+ updated_at TEXT NOT NULL
779
+ );
780
+
781
+ CREATE TABLE IF NOT EXISTS steps (
782
+ key TEXT NOT NULL REFERENCES runs(key) ON DELETE CASCADE,
783
+ seq INTEGER NOT NULL,
784
+ node TEXT NOT NULL,
785
+ from_node TEXT,
786
+ edge TEXT,
787
+ workflow_id TEXT NOT NULL,
788
+ at TEXT NOT NULL,
789
+ PRIMARY KEY (key, seq)
790
+ );
791
+ `;
792
+ var LEGACY_COLUMNS = new Set(["stack", "scope"]);
793
+ function toSession(row) {
794
+ return {
795
+ key: row.key,
796
+ workflowId: row.workflow_id,
797
+ status: row.status,
798
+ current: row.current,
799
+ version: row.version,
800
+ createdAt: row.created_at,
801
+ updatedAt: row.updated_at
802
+ };
803
+ }
804
+ function toStep(row) {
805
+ return {
806
+ key: row.key,
807
+ seq: row.seq,
808
+ node: row.node,
809
+ from: row.from_node,
810
+ edge: row.edge,
811
+ workflowId: row.workflow_id,
812
+ at: row.at
813
+ };
814
+ }
815
+
816
+ class SqliteRunStore {
817
+ path;
818
+ db;
819
+ constructor(path) {
820
+ this.path = path;
821
+ if (path !== ":memory:") {
822
+ mkdirSync2(dirname3(path), { recursive: true });
823
+ }
824
+ this.db = openDriver(path);
825
+ if (path !== ":memory:") {
826
+ if (this.isOutdated())
827
+ this.recreate(path);
828
+ this.db.exec("PRAGMA journal_mode = WAL;");
829
+ }
830
+ this.db.exec("PRAGMA foreign_keys = ON;");
831
+ this.db.exec("PRAGMA busy_timeout = 5000;");
832
+ this.db.exec(SCHEMA);
833
+ }
834
+ isOutdated() {
835
+ const columns = [
836
+ ...this.db.all("PRAGMA table_info(runs)", []),
837
+ ...this.db.all("PRAGMA table_info(steps)", [])
838
+ ];
839
+ return columns.some((column) => LEGACY_COLUMNS.has(column.name));
840
+ }
841
+ recreate(path) {
842
+ this.db.close();
843
+ for (const suffix of ["", "-wal", "-shm"]) {
844
+ try {
845
+ unlinkSync(`${path}${suffix}`);
846
+ } catch {}
847
+ }
848
+ this.db = openDriver(path);
849
+ }
850
+ createSession(session) {
851
+ return this.db.run(`INSERT OR IGNORE INTO runs
852
+ (key, workflow_id, status, current, version, created_at, updated_at)
853
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
854
+ session.key,
855
+ session.workflowId,
856
+ session.status,
857
+ session.current,
858
+ session.version,
859
+ session.createdAt,
860
+ session.updatedAt
861
+ ]) > 0;
862
+ }
863
+ getSession(key) {
864
+ const row = this.db.get("SELECT * FROM runs WHERE key = ?", [key]);
865
+ return row ? toSession(row) : null;
866
+ }
867
+ updateSession(session) {
868
+ this.db.run(`UPDATE runs
869
+ SET status = ?, current = ?, version = ?, updated_at = ?
870
+ WHERE key = ?`, [session.status, session.current, session.version, session.updatedAt, session.key]);
871
+ }
872
+ appendStep(step) {
873
+ const row = this.db.get("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM steps WHERE key = ?", [
874
+ step.key
875
+ ]);
876
+ const seq = row?.next ?? 1;
877
+ this.db.run(`INSERT INTO steps (key, seq, node, from_node, edge, workflow_id, at)
878
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [step.key, seq, step.node, step.from, step.edge, step.workflowId, step.at]);
879
+ return { ...step, seq };
880
+ }
881
+ listSteps(key, query = {}) {
882
+ const limit = Math.min(Math.max(query.limit ?? 50, 1), 500);
883
+ const after = Math.max(query.after ?? 0, 0);
884
+ const rows = this.db.all("SELECT * FROM steps WHERE key = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [key, after, limit]);
885
+ return rows.map(toStep);
886
+ }
887
+ listSessions() {
888
+ const rows = this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
889
+ return rows.map(toSession);
890
+ }
891
+ reset(key) {
892
+ const count = this.db.get("SELECT COUNT(*) AS n FROM steps WHERE key = ?", [key]);
893
+ this.db.run("DELETE FROM runs WHERE key = ?", [key]);
894
+ return count?.n ?? 0;
895
+ }
896
+ transaction(fn) {
897
+ return this.db.transaction(fn);
898
+ }
899
+ close() {
900
+ this.db.close();
901
+ }
902
+ }
903
+
904
+ // src/types.ts
905
+ class AppError2 extends Error {
906
+ code;
907
+ exitCode;
908
+ details;
909
+ constructor(code, message, exitCode = 1, details) {
910
+ super(message);
911
+ this.code = code;
912
+ this.exitCode = exitCode;
913
+ this.details = details;
914
+ this.name = "AppError";
915
+ }
916
+ }
917
+
918
+ // src/workflow/load.ts
919
+ import { readFileSync as readFileSync2 } from "node:fs";
920
+ function loadWorkflowFile(path) {
921
+ let raw;
922
+ try {
923
+ raw = JSON.parse(readFileSync2(path, "utf8"));
924
+ } catch (error) {
925
+ const message = error instanceof Error ? error.message : String(error);
926
+ const missing = error.code === "ENOENT";
927
+ return {
928
+ path,
929
+ errors: [
930
+ missing ? `workflow file not found: ${path} (run "next-steps workflow new")` : `${path}: invalid JSON (${message})`
931
+ ],
932
+ warnings: []
933
+ };
934
+ }
935
+ const check = validateWorkflow(raw, path);
936
+ return { path, workflow: check.workflow, errors: check.errors, warnings: check.warnings };
937
+ }
938
+
939
+ // src/cli.ts
940
+ var VERSION = "0.1.1";
941
+ var HELP = `next-steps \u2014 record the current step, ask what comes next
942
+
943
+ usage:
944
+ next-steps create [--key <key>] [--json]
945
+ next-steps log --key <key> --node <node> [--json]
946
+ next-steps next --key <key> [--json]
947
+ next-steps steps --key <key> [--limit <n>] [--after <seq>] [--json]
948
+ next-steps keys [--json]
949
+ next-steps reset --key <key> [--json]
950
+
951
+ next-steps workflow new [--json]
952
+ next-steps workflow show [--json]
953
+ next-steps workflow check [--json]
954
+
955
+ next-steps viz [--port <n>] [--json] (serve the graph viewer on a local port)
956
+
957
+ next-steps help
958
+ next-steps version
959
+
960
+ state: ./.next-steps/state.db (created on first use, gitignored)
961
+ graph: ./.next-steps/workflow.json (one graph per project, committed)
962
+
963
+ exit codes:
964
+ 0 ok 2 usage 3 not found 4 invalid transition 5 invalid workflow`;
965
+ function parseOptions(args, options, allowPositionals = false) {
966
+ try {
967
+ return parseArgs({ args, options, allowPositionals, strict: true });
968
+ } catch (error) {
969
+ throw usageError2(error instanceof Error ? error.message : String(error));
970
+ }
971
+ }
972
+ function asString(value) {
973
+ return typeof value === "string" ? value : undefined;
974
+ }
975
+ function requireGraphFile(graphPath) {
976
+ if (existsSync3(graphPath))
977
+ return;
978
+ throw new AppError2("workflow_not_found", `workflow file not found: ${graphPath} (run "next-steps workflow new")`, 3);
979
+ }
980
+ function requireWorkflow(graphPath) {
981
+ requireGraphFile(graphPath);
982
+ const load = loadWorkflowFile(graphPath);
983
+ if (load.errors.length > 0 || !load.workflow) {
984
+ throw new AppError2("invalid_workflow", "workflow file is invalid", 5, {
985
+ errors: load.errors
986
+ });
987
+ }
988
+ return load.workflow;
989
+ }
990
+ function execute(argv, options) {
991
+ const cwd = options.cwd ?? process.cwd();
992
+ const graphPath = options.graphPath ?? join2(cwd, ".next-steps", "workflow.json");
993
+ const dbPath = options.dbPath ?? join2(cwd, ".next-steps", "state.db");
994
+ const [command, ...rest] = argv;
995
+ if (!command || command === "help" || command === "--help" || command === "-h") {
996
+ return HELP;
997
+ }
998
+ if (command === "version" || command === "--version" || command === "-v") {
999
+ return `next-steps ${VERSION}`;
1000
+ }
1001
+ if (command === "workflow") {
1002
+ const { positionals, values } = parseOptions(rest, { json: { type: "boolean" } }, true);
1003
+ const [subcommand] = positionals;
1004
+ const json = Boolean(values.json);
1005
+ if (subcommand === "new") {
1006
+ if (positionals.length > 1)
1007
+ throw usageError2(`unexpected argument "${positionals[1]}"`);
1008
+ return cmdWorkflowNew(graphPath, basename(cwd), json);
1009
+ }
1010
+ if (subcommand !== "show" && subcommand !== "check") {
1011
+ throw usageError2("usage: next-steps workflow <new|show|check> [--json]");
1012
+ }
1013
+ if (positionals.length > 1)
1014
+ throw usageError2(`unexpected argument "${positionals[1]}"`);
1015
+ requireGraphFile(graphPath);
1016
+ const load = loadWorkflowFile(graphPath);
1017
+ return subcommand === "show" ? cmdWorkflowShow(load, json) : cmdWorkflowCheck(load, json);
1018
+ }
1019
+ if (command === "viz") {
1020
+ const { values } = parseOptions(rest, { port: { type: "string" }, json: { type: "boolean" } });
1021
+ const port = optionalInt(values.port, "port") ?? 3000;
1022
+ if (port < 1 || port > 65535) {
1023
+ throw usageError2("--port must be between 1 and 65535");
1024
+ }
1025
+ const result = startVizServer(requireWorkflow(graphPath), port);
1026
+ return values.json ? toJson2({ url: result.url, workflow: result.workflow }) : `viewer: ${result.url}
1027
+ press Ctrl+C to stop`;
1028
+ }
1029
+ const workflow = requireWorkflow(graphPath);
1030
+ const store = new SqliteRunStore(dbPath);
1031
+ try {
1032
+ const service = new SessionService(workflow, store);
1033
+ switch (command) {
1034
+ case "create": {
1035
+ const { values } = parseOptions(rest, {
1036
+ key: { type: "string" },
1037
+ json: { type: "boolean" }
1038
+ });
1039
+ return cmdCreate(service, {
1040
+ key: asString(values.key),
1041
+ json: Boolean(values.json)
1042
+ });
1043
+ }
1044
+ case "log": {
1045
+ const { values } = parseOptions(rest, {
1046
+ key: { type: "string" },
1047
+ node: { type: "string" },
1048
+ json: { type: "boolean" }
1049
+ });
1050
+ return cmdLog(service, {
1051
+ key: asString(values.key),
1052
+ node: asString(values.node),
1053
+ json: Boolean(values.json)
1054
+ });
1055
+ }
1056
+ case "next": {
1057
+ const { values } = parseOptions(rest, {
1058
+ key: { type: "string" },
1059
+ json: { type: "boolean" }
1060
+ });
1061
+ return cmdNext(service, {
1062
+ key: asString(values.key),
1063
+ json: Boolean(values.json)
1064
+ });
1065
+ }
1066
+ case "steps": {
1067
+ const { values } = parseOptions(rest, {
1068
+ key: { type: "string" },
1069
+ limit: { type: "string" },
1070
+ after: { type: "string" },
1071
+ json: { type: "boolean" }
1072
+ });
1073
+ return cmdSteps(service, {
1074
+ key: asString(values.key),
1075
+ limit: optionalInt(values.limit, "limit"),
1076
+ after: optionalInt(values.after, "after"),
1077
+ json: Boolean(values.json)
1078
+ });
1079
+ }
1080
+ case "keys": {
1081
+ const { values } = parseOptions(rest, { json: { type: "boolean" } });
1082
+ return cmdKeys(service, { json: Boolean(values.json) });
1083
+ }
1084
+ case "reset": {
1085
+ const { values } = parseOptions(rest, {
1086
+ key: { type: "string" },
1087
+ json: { type: "boolean" }
1088
+ });
1089
+ return cmdReset(service, {
1090
+ key: asString(values.key),
1091
+ json: Boolean(values.json)
1092
+ });
1093
+ }
1094
+ default:
1095
+ throw usageError2(`unknown command "${command}" (try: next-steps help)`);
1096
+ }
1097
+ } finally {
1098
+ store.close();
1099
+ }
1100
+ }
1101
+ function prettyError(error) {
1102
+ const lines = [`error: ${error.message}`];
1103
+ const details = error.details;
1104
+ if (details) {
1105
+ if (Array.isArray(details.errors)) {
1106
+ for (const message of details.errors)
1107
+ lines.push(` - ${String(message)}`);
1108
+ }
1109
+ if (Array.isArray(details.legal)) {
1110
+ if (details.legal.length === 0) {
1111
+ lines.push(" no legal next steps");
1112
+ } else {
1113
+ lines.push(" legal next steps:");
1114
+ for (const option of details.legal) {
1115
+ const label = option.edge ? `${option.edge} -> ${option.node}` : option.node;
1116
+ lines.push(` - ${label} ${option.description}`);
1117
+ }
1118
+ }
1119
+ }
1120
+ if (Array.isArray(details.available)) {
1121
+ lines.push(` available: ${details.available.join(", ")}`);
1122
+ }
1123
+ }
1124
+ return lines.join(`
1125
+ `);
1126
+ }
1127
+ function formatError(error, json) {
1128
+ if (error instanceof AppError2) {
1129
+ if (json) {
1130
+ return JSON.stringify({
1131
+ error: {
1132
+ code: error.code,
1133
+ message: error.message,
1134
+ ...error.details === undefined ? {} : { details: error.details }
1135
+ }
1136
+ }, null, 2);
1137
+ }
1138
+ return prettyError(error);
1139
+ }
1140
+ const message = error instanceof Error ? error.message : String(error);
1141
+ if (json) {
1142
+ return JSON.stringify({ error: { code: "internal", message } }, null, 2);
1143
+ }
1144
+ return `error: ${message}`;
1145
+ }
1146
+ async function run(argv, options = {}) {
1147
+ const wantsJson = argv.includes("--json");
1148
+ try {
1149
+ return { exitCode: 0, stdout: execute(argv, options), stderr: "" };
1150
+ } catch (error) {
1151
+ const exitCode = error instanceof AppError2 ? error.exitCode : 1;
1152
+ return { exitCode, stdout: "", stderr: formatError(error, wantsJson) };
1153
+ }
1154
+ }
1155
+ function isEntrypoint() {
1156
+ const meta = import.meta;
1157
+ if (typeof meta.main === "boolean")
1158
+ return meta.main;
1159
+ const entry = process.argv[1];
1160
+ return entry !== undefined && import.meta.url === pathToFileURL(entry).href;
1161
+ }
1162
+ if (isEntrypoint()) {
1163
+ const result = await run(process.argv.slice(2));
1164
+ if (result.stdout)
1165
+ process.stdout.write(`${result.stdout}
1166
+ `);
1167
+ if (result.stderr)
1168
+ process.stderr.write(`${result.stderr}
1169
+ `);
1170
+ process.exitCode = result.exitCode;
1171
+ }
1172
+ export {
1173
+ run
1174
+ };