omkit 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +66 -42
  2. package/dist/actions/browser.d.ts +28 -0
  3. package/dist/actions/browser.js +36 -0
  4. package/dist/actions/command.d.ts +11 -7
  5. package/dist/actions/command.js +16 -10
  6. package/dist/actions/index.d.ts +2 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/actions/prompt.js +43 -17
  9. package/dist/cli/client/channel.d.ts +14 -0
  10. package/dist/cli/client/channel.js +48 -0
  11. package/dist/cli/client/check.d.ts +3 -0
  12. package/dist/cli/client/check.js +32 -0
  13. package/dist/cli/client/discovery.d.ts +7 -0
  14. package/dist/cli/client/discovery.js +147 -0
  15. package/dist/cli/client/index.d.ts +9 -0
  16. package/dist/cli/client/index.js +11 -0
  17. package/dist/cli/client/registry.d.ts +29 -0
  18. package/dist/cli/client/registry.js +1 -0
  19. package/dist/cli/client/runner.d.ts +15 -0
  20. package/dist/cli/client/runner.js +61 -0
  21. package/dist/cli/client/types.d.ts +53 -0
  22. package/dist/cli/client/types.js +1 -0
  23. package/dist/cli/commands/check.d.ts +6 -0
  24. package/dist/cli/commands/check.js +11 -0
  25. package/dist/cli/commands/init.d.ts +5 -0
  26. package/dist/cli/commands/init.js +54 -0
  27. package/dist/cli/commands/ls.d.ts +5 -0
  28. package/dist/cli/commands/ls.js +25 -0
  29. package/dist/cli/commands/run.d.ts +10 -0
  30. package/dist/cli/commands/run.js +12 -0
  31. package/dist/cli/commands/ui.d.ts +7 -0
  32. package/dist/cli/commands/ui.js +30 -0
  33. package/dist/cli/index.d.ts +9 -0
  34. package/dist/cli/index.js +91 -0
  35. package/dist/cli/ui/Spinner.d.ts +10 -0
  36. package/dist/cli/ui/Spinner.js +20 -0
  37. package/dist/cli/ui/app.d.ts +9 -0
  38. package/dist/cli/ui/app.js +93 -0
  39. package/dist/cli/ui/views/OmList.d.ts +9 -0
  40. package/dist/cli/ui/views/OmList.js +36 -0
  41. package/dist/cli/ui/views/RunView.d.ts +14 -0
  42. package/dist/cli/ui/views/RunView.js +47 -0
  43. package/dist/core/ActionRun.d.ts +2 -4
  44. package/dist/core/ActionRun.js +27 -32
  45. package/dist/core/ExecutionTree.d.ts +1 -0
  46. package/dist/core/ExecutionTree.js +25 -4
  47. package/dist/core/interaction.d.ts +63 -0
  48. package/dist/core/interaction.js +82 -0
  49. package/dist/core/step.js +2 -7
  50. package/dist/core/types.d.ts +12 -17
  51. package/dist/index.d.ts +1 -1
  52. package/dist/output/LiveRenderer.d.ts +7 -17
  53. package/dist/output/LiveRenderer.js +8 -82
  54. package/dist/output/milestones.d.ts +8 -0
  55. package/dist/output/milestones.js +39 -0
  56. package/package.json +9 -1
@@ -0,0 +1,147 @@
1
+ import ts from "typescript";
2
+ import path from "node:path";
3
+ /**
4
+ * Statically scan the project described by `tsconfigPath` for runnable oms and inspectable
5
+ * actions. Never throws for user-code problems — type errors become `warnings`, and whatever
6
+ * parsed is still returned (so an editor-in-progress project stays useful).
7
+ */
8
+ export function discover(tsconfigPath) {
9
+ const oms = [];
10
+ const actions = [];
11
+ const warnings = [];
12
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
13
+ if (configFile.error) {
14
+ warnings.push(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n"));
15
+ return { oms, actions, warnings };
16
+ }
17
+ // Resolve to an absolute base so `parsed.fileNames` are absolute. Otherwise root files stay
18
+ // relative while imported files (e.g. an action pulled in by an om) resolve to absolute paths,
19
+ // and the two never match in `fileSet`.
20
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.resolve(path.dirname(tsconfigPath)));
21
+ const program = ts.createProgram({
22
+ rootNames: parsed.fileNames,
23
+ options: { ...parsed.options, noEmit: true },
24
+ });
25
+ const fileSet = new Set(parsed.fileNames.map((f) => canonical(f)));
26
+ for (const sf of program.getSourceFiles()) {
27
+ if (!fileSet.has(canonical(sf.fileName)))
28
+ continue;
29
+ for (const d of program.getSemanticDiagnostics(sf))
30
+ warnings.push(formatDiagnostic(d));
31
+ const names = omkitImports(sf);
32
+ const visit = (node) => {
33
+ if (ts.isCallExpression(node) &&
34
+ ts.isIdentifier(node.expression) &&
35
+ (node.expression.text === names.om || node.expression.text === names.step)) {
36
+ const arg = node.arguments[0];
37
+ if (arg && ts.isStringLiteralLike(arg)) {
38
+ oms.push({ name: arg.text, file: sf.fileName, line: lineOf(sf, node) });
39
+ }
40
+ }
41
+ ts.forEachChild(node, visit);
42
+ };
43
+ visit(sf);
44
+ for (const stmt of sf.statements) {
45
+ if (!ts.isVariableStatement(stmt) || !isExported(stmt))
46
+ continue;
47
+ for (const decl of stmt.declarationList.declarations) {
48
+ if (!decl.initializer || !ts.isIdentifier(decl.name))
49
+ continue;
50
+ const info = actionChain(decl.initializer, names.action);
51
+ if (info) {
52
+ actions.push({
53
+ name: info.name,
54
+ file: sf.fileName,
55
+ exportName: decl.name.text,
56
+ publishesCapability: info.ref,
57
+ events: info.emits,
58
+ });
59
+ }
60
+ }
61
+ }
62
+ }
63
+ return { oms, actions, warnings };
64
+ }
65
+ /** Collect the local names bound to om/step/action from `import … from "omkit"`. */
66
+ function omkitImports(sf) {
67
+ const names = {};
68
+ for (const stmt of sf.statements) {
69
+ if (!isOmkitImport(stmt))
70
+ continue;
71
+ const bindings = stmt.importClause?.namedBindings;
72
+ if (!bindings || !ts.isNamedImports(bindings))
73
+ continue;
74
+ for (const el of bindings.elements)
75
+ bindName(names, el);
76
+ }
77
+ return names;
78
+ }
79
+ /** True for `import … from "omkit"`. */
80
+ function isOmkitImport(stmt) {
81
+ return (ts.isImportDeclaration(stmt) &&
82
+ ts.isStringLiteral(stmt.moduleSpecifier) &&
83
+ stmt.moduleSpecifier.text === "omkit");
84
+ }
85
+ /** Record the local binding for an imported `om` / `step` / `action` specifier. */
86
+ function bindName(names, el) {
87
+ const imported = (el.propertyName ?? el.name).text;
88
+ if (imported === "om")
89
+ names.om = el.name.text;
90
+ else if (imported === "step")
91
+ names.step = el.name.text;
92
+ else if (imported === "action")
93
+ names.action = el.name.text;
94
+ }
95
+ /**
96
+ * Walk a builder chain (`action("x").emits<…>().ref<…>().run(fn)`) from the outer call down to
97
+ * the base `action("name")` call, noting whether `.ref` / `.emits` appear. Returns undefined
98
+ * when the chain is not rooted in the local `action` binding with a string-literal name.
99
+ */
100
+ function actionChain(expr, actionName) {
101
+ if (actionName === undefined)
102
+ return undefined;
103
+ let ref = false;
104
+ let emits = false;
105
+ let node = expr;
106
+ while (ts.isCallExpression(node)) {
107
+ if (ts.isPropertyAccessExpression(node.expression)) {
108
+ const method = node.expression.name.text;
109
+ if (method === "ref")
110
+ ref = true;
111
+ if (method === "emits")
112
+ emits = true;
113
+ node = node.expression.expression;
114
+ continue;
115
+ }
116
+ if (ts.isIdentifier(node.expression) && node.expression.text === actionName) {
117
+ const arg = node.arguments[0];
118
+ if (arg && ts.isStringLiteralLike(arg))
119
+ return { name: arg.text, ref, emits };
120
+ }
121
+ return undefined;
122
+ }
123
+ return undefined;
124
+ }
125
+ function isExported(stmt) {
126
+ return Boolean(stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
127
+ }
128
+ /**
129
+ * A canonical key for a file path. TypeScript resolves relative imports with a different
130
+ * drive-letter case than the tsconfig `include` glob does on Windows, so a plain
131
+ * `path.normalize` comparison misses files reached both ways. Fold case where the OS does.
132
+ */
133
+ function canonical(file) {
134
+ const normalized = path.normalize(file);
135
+ return ts.sys.useCaseSensitiveFileNames ? normalized : normalized.toLowerCase();
136
+ }
137
+ function lineOf(sf, node) {
138
+ return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
139
+ }
140
+ function formatDiagnostic(d) {
141
+ const message = ts.flattenDiagnosticMessageText(d.messageText, "\n");
142
+ if (d.file && d.start !== undefined) {
143
+ const { line } = d.file.getLineAndCharacterOfPosition(d.start);
144
+ return `${path.basename(d.file.fileName)}:${line + 1} ${message}`;
145
+ }
146
+ return message;
147
+ }
@@ -0,0 +1,9 @@
1
+ import type { OmkitClient } from "./types.ts";
2
+ export type { OmkitClient, RunSession, RunOptions, Verdict, Diagnostic, PromptRequest, RunEvents, } from "./types.ts";
3
+ export type { Registry, DiscoveredOm, DiscoveredAction } from "./registry.ts";
4
+ /** Configuration for a client — the path to the project's `tsconfig.omkit.json`. */
5
+ export interface OmkitConfig {
6
+ tsconfig: string;
7
+ }
8
+ /** Build the headless engine: discovery + supervised run + typecheck over one config. */
9
+ export declare function createOmkitClient(config: OmkitConfig): OmkitClient;
@@ -0,0 +1,11 @@
1
+ import { discover } from "./discovery.js";
2
+ import { runOm } from "./runner.js";
3
+ import { runCheck } from "./check.js";
4
+ /** Build the headless engine: discovery + supervised run + typecheck over one config. */
5
+ export function createOmkitClient(config) {
6
+ return {
7
+ discover: async () => discover(config.tsconfig),
8
+ run: (omFile, opts) => runOm(omFile, opts),
9
+ check: async () => runCheck(config.tsconfig),
10
+ };
11
+ }
@@ -0,0 +1,29 @@
1
+ /** A runnable om discovered from a top-level `om("name", …)` call. */
2
+ export interface DiscoveredOm {
3
+ /** The om's name — its first string argument. */
4
+ readonly name: string;
5
+ /** Absolute path of the file that defines it. */
6
+ readonly file: string;
7
+ /** 1-based line of the `om(...)` call (informational). */
8
+ readonly line: number;
9
+ }
10
+ /** An inspectable action discovered from an exported `action("name")…` builder. */
11
+ export interface DiscoveredAction {
12
+ /** The action's name — the first string argument to `action(...)`. */
13
+ readonly name: string;
14
+ /** Absolute path of the file that defines it. */
15
+ readonly file: string;
16
+ /** The exported binding name (`export const <exportName> = action(...)`). */
17
+ readonly exportName: string;
18
+ /** True when the builder chain includes `.ref<…>()` (publishes a capability). */
19
+ readonly publishesCapability: boolean;
20
+ /** True when the builder chain includes `.emits<…>()`. */
21
+ readonly events: boolean;
22
+ }
23
+ /** The result of scanning a project: runnable oms, inspectable actions, and soft warnings. */
24
+ export interface Registry {
25
+ readonly oms: DiscoveredOm[];
26
+ readonly actions: DiscoveredAction[];
27
+ /** Non-fatal diagnostics (type errors, unresolved config) — discovery never throws for these. */
28
+ readonly warnings: string[];
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { RunSession, RunOptions } from "./types.ts";
2
+ /**
3
+ * Fork `omFile` as a supervised child: the `tsx` loader runs the TypeScript directly,
4
+ * `OMKIT_SUPERVISED=1` flips the child into channel mode (see core/interaction.ts), and its
5
+ * IPC channel is wrapped into a {@link RunSession}. stdout/stderr are piped (not inherited)
6
+ * so the child never writes to the supervisor's terminal.
7
+ */
8
+ export declare function runOm(omFile: string, opts?: RunOptions): RunSession;
9
+ /**
10
+ * Spawn `omFile` **bare** — inherited stdio, no supervision. The child owns the terminal, so
11
+ * its LiveRenderer, summary, and native `readline` prompts all work. Resolves the exit code.
12
+ */
13
+ export declare function spawnBare(omFile: string, opts?: {
14
+ cwd?: string;
15
+ }): Promise<number>;
@@ -0,0 +1,61 @@
1
+ import { fork } from "node:child_process";
2
+ import path from "node:path";
3
+ import { createChannel } from "./channel.js";
4
+ /**
5
+ * The `tsx` loader, resolved to an absolute URL from omkit's own location. Resolving it here
6
+ * (not as the bare specifier `--import tsx`) keeps the child's `tsx` independent of its cwd —
7
+ * a run in any directory still finds the loader shipped with omkit.
8
+ */
9
+ const tsxLoader = import.meta.resolve("tsx");
10
+ /**
11
+ * Fork `omFile` as a supervised child: the `tsx` loader runs the TypeScript directly,
12
+ * `OMKIT_SUPERVISED=1` flips the child into channel mode (see core/interaction.ts), and its
13
+ * IPC channel is wrapped into a {@link RunSession}. stdout/stderr are piped (not inherited)
14
+ * so the child never writes to the supervisor's terminal.
15
+ */
16
+ export function runOm(omFile, opts = {}) {
17
+ const child = fork(omFile, [], {
18
+ execArgv: ["--import", tsxLoader],
19
+ cwd: opts.cwd ?? path.dirname(omFile),
20
+ env: { ...process.env, OMKIT_SUPERVISED: "1" },
21
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
22
+ });
23
+ const transport = {
24
+ send: (message) => void child.send(message),
25
+ onMessage: (handler) => void child.on("message", (m) => handler(m)),
26
+ onClose: (handler) => void child.on("close", (code) => handler(code)),
27
+ };
28
+ const session = createChannel(transport);
29
+ // When the run settles, the child has already written its logs and reaped its process tree —
30
+ // but the live IPC channel (and the unread stdout/stderr pipes) keep both this process and the
31
+ // child alive, so the frontend would hang after teardown. Release it: disconnect so the child
32
+ // can drain and exit, and force-kill if it lingers past a short grace.
33
+ void session.result.then(() => {
34
+ if (child.exitCode !== null || child.signalCode !== null)
35
+ return; // already gone
36
+ if (child.connected)
37
+ child.disconnect();
38
+ const kill = setTimeout(() => child.kill(), 1500);
39
+ child.once("close", () => clearTimeout(kill));
40
+ });
41
+ return session;
42
+ }
43
+ /**
44
+ * Spawn `omFile` **bare** — inherited stdio, no supervision. The child owns the terminal, so
45
+ * its LiveRenderer, summary, and native `readline` prompts all work. Resolves the exit code.
46
+ */
47
+ export function spawnBare(omFile, opts = {}) {
48
+ // A bare run is unsupervised even if the parent process happens to carry the flag (e.g. a
49
+ // test runner spawned from inside a supervised run) — clear it so the child stays bare.
50
+ const env = { ...process.env };
51
+ delete env.OMKIT_SUPERVISED;
52
+ const child = fork(omFile, [], {
53
+ execArgv: ["--import", tsxLoader],
54
+ cwd: opts.cwd ?? path.dirname(omFile),
55
+ stdio: "inherit",
56
+ env,
57
+ });
58
+ return new Promise((resolve) => {
59
+ child.on("close", (code) => resolve(code ?? 0));
60
+ });
61
+ }
@@ -0,0 +1,53 @@
1
+ import type { LogEntry } from "../../foundation/LogEntry.ts";
2
+ import type { PromptSpec } from "../../core/interaction.ts";
3
+ import type { Registry } from "./registry.ts";
4
+ /** A run's terminal verdict, resolved when the child settles. */
5
+ export interface Verdict {
6
+ readonly ok: boolean;
7
+ /** The absolute run-folder path the child reported. */
8
+ readonly folder: string;
9
+ /** The end-of-run recap lines (run folder + sibling log paths + assert tally), the same
10
+ * block a bare run prints to its terminal. Empty if the child closed without settling. */
11
+ readonly summary: readonly string[];
12
+ }
13
+ /** A typecheck diagnostic from `check()`. */
14
+ export interface Diagnostic {
15
+ readonly file: string;
16
+ readonly line: number;
17
+ readonly message: string;
18
+ }
19
+ /** Options for launching a run. */
20
+ export interface RunOptions {
21
+ /** Working directory for the child process (defaults to the om file's directory). */
22
+ readonly cwd?: string;
23
+ }
24
+ /** A prompt the running om is waiting on — surfaced to the frontend, answered via `answer`. */
25
+ export interface PromptRequest {
26
+ readonly id: string;
27
+ readonly spec: PromptSpec;
28
+ }
29
+ /** Handler map for {@link RunSession.on}. */
30
+ export interface RunEvents {
31
+ log: (entry: LogEntry) => void;
32
+ prompt: (request: PromptRequest) => void;
33
+ settled: (verdict: Verdict) => void;
34
+ }
35
+ /** A live handle to one supervised run. */
36
+ export interface RunSession {
37
+ on<K extends keyof RunEvents>(event: K, handler: RunEvents[K]): void;
38
+ /** Answer a pending prompt (`via` defaults to "input"). */
39
+ answer(id: string, value: string, via?: string): void;
40
+ /** Request graceful teardown of the run. */
41
+ cancel(): void;
42
+ /** Resolves when the run settles (or the child exits without settling → ok:false). */
43
+ readonly result: Promise<Verdict>;
44
+ }
45
+ /** The headless engine behind every omkit frontend. */
46
+ export interface OmkitClient {
47
+ /** Statically scan the project's `tsconfig.omkit.json` for oms and actions. */
48
+ discover(): Promise<Registry>;
49
+ /** Spawn an om file as a supervised child and return its live session. */
50
+ run(omFile: string, opts?: RunOptions): RunSession;
51
+ /** Typecheck the project (`tsc --noEmit`) and return diagnostics. */
52
+ check(): Promise<Diagnostic[]>;
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { Diagnostic } from "../client/types.ts";
2
+ /** Format typecheck diagnostics into terminal text plus a process exit code. */
3
+ export declare function formatDiagnostics(diagnostics: Diagnostic[]): {
4
+ text: string;
5
+ code: number;
6
+ };
@@ -0,0 +1,11 @@
1
+ import path from "node:path";
2
+ /** Format typecheck diagnostics into terminal text plus a process exit code. */
3
+ export function formatDiagnostics(diagnostics) {
4
+ if (diagnostics.length === 0) {
5
+ return { text: "no type errors", code: 0 };
6
+ }
7
+ const lines = diagnostics.map((d) => `${path.basename(d.file)}:${d.line} ${d.message}`);
8
+ lines.push("");
9
+ lines.push(`${diagnostics.length} error${diagnostics.length === 1 ? "" : "s"}`);
10
+ return { text: lines.join("\n"), code: 1 };
11
+ }
@@ -0,0 +1,5 @@
1
+ /** Write the starter project into `dir`, never overwriting existing files. */
2
+ export declare function scaffold(dir: string): {
3
+ created: string[];
4
+ skipped: string[];
5
+ };
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const TSCONFIG = `{
4
+ "compilerOptions": {
5
+ "target": "esnext",
6
+ "module": "nodenext",
7
+ "moduleResolution": "nodenext",
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "allowImportingTsExtensions": true
12
+ },
13
+ "include": ["oms/**/*.ts", "actions/**/*.ts"]
14
+ }
15
+ `;
16
+ const DEV_OM = `import { om } from "omkit";
17
+ import { command, healthcheck } from "omkit/actions";
18
+
19
+ om("dev", async () => {
20
+ command("npm run dev", { cwd: "api" }).tag("api");
21
+ const up = await healthcheck({ url: "http://localhost:3000" }).result;
22
+ if (!up.ok) return;
23
+ console.log("stack is up — press Ctrl+C to stop");
24
+ });
25
+ `;
26
+ const HELLO_ACTION = `import { action } from "omkit";
27
+
28
+ export const hello = action("hello")
29
+ .ref<string>()
30
+ .run(async ({ attach }) => {
31
+ attach("world");
32
+ });
33
+ `;
34
+ const FILES = [
35
+ { rel: "tsconfig.omkit.json", body: TSCONFIG },
36
+ { rel: "oms/dev.ts", body: DEV_OM },
37
+ { rel: "actions/hello.ts", body: HELLO_ACTION },
38
+ ];
39
+ /** Write the starter project into `dir`, never overwriting existing files. */
40
+ export function scaffold(dir) {
41
+ const created = [];
42
+ const skipped = [];
43
+ for (const { rel, body } of FILES) {
44
+ const target = path.join(dir, rel);
45
+ if (fs.existsSync(target)) {
46
+ skipped.push(rel);
47
+ continue;
48
+ }
49
+ fs.mkdirSync(path.dirname(target), { recursive: true });
50
+ fs.writeFileSync(target, body);
51
+ created.push(rel);
52
+ }
53
+ return { created, skipped };
54
+ }
@@ -0,0 +1,5 @@
1
+ import type { Registry } from "../client/registry.ts";
2
+ /** Render a {@link Registry} for the terminal (plain text) or as JSON. */
3
+ export declare function formatRegistry(registry: Registry, opts?: {
4
+ json?: boolean;
5
+ }): string;
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ /** Render a {@link Registry} for the terminal (plain text) or as JSON. */
3
+ export function formatRegistry(registry, opts = {}) {
4
+ if (opts.json)
5
+ return JSON.stringify(registry, null, 2);
6
+ const lines = [];
7
+ lines.push(`oms (${registry.oms.length})`);
8
+ for (const om of registry.oms) {
9
+ lines.push(` ${om.name} ${path.basename(om.file)}:${om.line}`);
10
+ }
11
+ lines.push("");
12
+ lines.push(`actions (${registry.actions.length})`);
13
+ for (const a of registry.actions) {
14
+ const tags = [a.publishesCapability ? "ref" : "", a.events ? "events" : ""].filter(Boolean);
15
+ const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
16
+ lines.push(` ${a.name} ${path.basename(a.file)}${suffix}`);
17
+ }
18
+ if (registry.warnings.length) {
19
+ lines.push("");
20
+ lines.push(`warnings (${registry.warnings.length})`);
21
+ for (const w of registry.warnings)
22
+ lines.push(` ${w}`);
23
+ }
24
+ return lines.join("\n");
25
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Resolve a `run` argument to an absolute om file: an existing file path (relative to `cwd`)
3
+ * wins; otherwise match a discovered om by name. Returns undefined when nothing matches.
4
+ */
5
+ export declare function resolveOm(arg: string, registry: {
6
+ oms: {
7
+ name: string;
8
+ file: string;
9
+ }[];
10
+ }, cwd: string): string | undefined;
@@ -0,0 +1,12 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Resolve a `run` argument to an absolute om file: an existing file path (relative to `cwd`)
5
+ * wins; otherwise match a discovered om by name. Returns undefined when nothing matches.
6
+ */
7
+ export function resolveOm(arg, registry, cwd) {
8
+ const asPath = path.resolve(cwd, arg);
9
+ if (fs.existsSync(asPath) && fs.statSync(asPath).isFile())
10
+ return asPath;
11
+ return registry.oms.find((o) => o.name === arg)?.file;
12
+ }
@@ -0,0 +1,7 @@
1
+ import type { OmkitClient } from "../client/index.ts";
2
+ /**
3
+ * Render the interactive Ink app. Ink's own Ctrl+C handling is disabled so the app can tear the
4
+ * run down gracefully first; the final verdict + run-folder path are printed here, after Ink
5
+ * unmounts (it erases its own frame), so the pointer to the on-disk record survives on screen.
6
+ */
7
+ export declare function launchUi(client: OmkitClient): void;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { App } from "../ui/app.js";
4
+ /**
5
+ * Render the interactive Ink app. Ink's own Ctrl+C handling is disabled so the app can tear the
6
+ * run down gracefully first; the final verdict + run-folder path are printed here, after Ink
7
+ * unmounts (it erases its own frame), so the pointer to the on-disk record survives on screen.
8
+ */
9
+ export function launchUi(client) {
10
+ let finalVerdict;
11
+ const app = render(_jsx(App, { client: client, onExit: (v) => (finalVerdict = v) }), {
12
+ exitOnCtrlC: false,
13
+ });
14
+ void app.waitUntilExit().then(() => {
15
+ if (finalVerdict) {
16
+ // Ink erased its frame on unmount; reprint the verdict marker plus the run's recap block
17
+ // (the same summary a bare run prints) so the pointers to the on-disk record survive.
18
+ console.log("");
19
+ console.log(finalVerdict.ok ? "✓ ok" : "✗ failed");
20
+ for (const line of finalVerdict.summary)
21
+ console.log(line);
22
+ process.exitCode = finalVerdict.ok ? 0 : 1;
23
+ }
24
+ // Backstop: the runner disconnects the supervised child so both processes can exit, but if
25
+ // anything (a wedged pipe, a lingering handle) keeps the event loop alive past the grace,
26
+ // force the exit. Unref'd, so it only fires when the process is genuinely stuck — a clean
27
+ // exit beats it to the punch and this never runs.
28
+ setTimeout(() => process.exit(process.exitCode ?? 0), 2000).unref();
29
+ });
30
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ export interface Cli {
3
+ command: string;
4
+ target?: string;
5
+ json: boolean;
6
+ tsconfig: string;
7
+ }
8
+ /** Parse argv (without node/script) into a command, an optional target, and flags. */
9
+ export declare function parseCli(argv: string[]): Cli;
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { realpathSync } from "node:fs";
6
+ import { createOmkitClient } from "./client/index.js";
7
+ /** Parse argv (without node/script) into a command, an optional target, and flags. */
8
+ export function parseCli(argv) {
9
+ const { values, positionals } = parseArgs({
10
+ args: argv,
11
+ options: {
12
+ json: { type: "boolean", default: false },
13
+ tsconfig: { type: "string", default: "tsconfig.omkit.json" },
14
+ },
15
+ allowPositionals: true,
16
+ });
17
+ return {
18
+ command: positionals[0] ?? "run",
19
+ target: positionals[1],
20
+ json: Boolean(values.json),
21
+ tsconfig: values.tsconfig,
22
+ };
23
+ }
24
+ /** The bin entry: route to a lazily-imported command; owns stdout and the exit code. */
25
+ async function main() {
26
+ const cli = parseCli(process.argv.slice(2));
27
+ const client = createOmkitClient({ tsconfig: cli.tsconfig });
28
+ if (cli.command === "init") {
29
+ const { scaffold } = await import("./commands/init.js");
30
+ const { created, skipped } = scaffold(process.cwd());
31
+ for (const f of created)
32
+ console.log(`created ${f}`);
33
+ for (const f of skipped)
34
+ console.log(`exists, skipped ${f}`);
35
+ return;
36
+ }
37
+ if (cli.command === "ls") {
38
+ const { formatRegistry } = await import("./commands/ls.js");
39
+ console.log(formatRegistry(await client.discover(), { json: cli.json }));
40
+ return;
41
+ }
42
+ if (cli.command === "check") {
43
+ const { formatDiagnostics } = await import("./commands/check.js");
44
+ const { text, code } = formatDiagnostics(await client.check());
45
+ console.log(text);
46
+ process.exitCode = code;
47
+ return;
48
+ }
49
+ if (cli.command === "run") {
50
+ // No target → open the interactive picker (browse/search oms, run one with live output).
51
+ // A bare `omkit` lands here too, since `run` is the default command.
52
+ if (!cli.target) {
53
+ const { launchUi } = await import("./commands/ui.js");
54
+ launchUi(client);
55
+ return;
56
+ }
57
+ const { resolveOm } = await import("./commands/run.js");
58
+ const { spawnBare } = await import("./client/runner.js");
59
+ const registry = await client.discover();
60
+ const omFile = resolveOm(cli.target, registry, process.cwd());
61
+ if (!omFile) {
62
+ const known = registry.oms.map((o) => o.name).join(", ");
63
+ console.error(`no om matches "${cli.target}". Known oms: ${known}`);
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ process.on("SIGINT", () => { }); // let the child tear down; don't die first
68
+ process.exitCode = await spawnBare(omFile, { cwd: path.dirname(omFile) });
69
+ return;
70
+ }
71
+ console.error(`unknown command "${cli.command}"`);
72
+ process.exitCode = 1;
73
+ }
74
+ /**
75
+ * True when this module is the process entry point — not when a test imports `parseCli`.
76
+ * Compares real paths so it holds even when invoked through the `.bin` symlink (where
77
+ * `process.argv[1]` is the link, not the resolved file).
78
+ */
79
+ function isEntryPoint() {
80
+ const argv1 = process.argv[1];
81
+ if (!argv1)
82
+ return false;
83
+ try {
84
+ return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ if (isEntryPoint())
91
+ void main();
@@ -0,0 +1,10 @@
1
+ import { type ReactElement } from "react";
2
+ /**
3
+ * A tiny animated spinner — cycles braille frames on a timer. Self-contained (no `ink-spinner`
4
+ * dependency). The interval is unref'd so it never keeps the process alive on its own: it animates
5
+ * only while something else (the live run's IPC channel) holds the event loop open, and stops the
6
+ * instant the run is disposed — so it can't block the app's exit.
7
+ */
8
+ export declare function Spinner({ label }: {
9
+ label?: string;
10
+ }): ReactElement;
@@ -0,0 +1,20 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text } from "ink";
4
+ // Braille dots — a smooth ten-frame cycle that reads as motion in a terminal.
5
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ /**
7
+ * A tiny animated spinner — cycles braille frames on a timer. Self-contained (no `ink-spinner`
8
+ * dependency). The interval is unref'd so it never keeps the process alive on its own: it animates
9
+ * only while something else (the live run's IPC channel) holds the event loop open, and stops the
10
+ * instant the run is disposed — so it can't block the app's exit.
11
+ */
12
+ export function Spinner({ label }) {
13
+ const [frame, setFrame] = useState(0);
14
+ useEffect(() => {
15
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
16
+ id.unref?.();
17
+ return () => clearInterval(id);
18
+ }, []);
19
+ return (_jsxs(Text, { dimColor: true, children: [FRAMES[frame], label ? ` ${label}` : ""] }));
20
+ }