omkit 0.2.2 → 0.4.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/README.md +66 -43
- package/dist/actions/browser.d.ts +28 -0
- package/dist/actions/browser.js +36 -0
- package/dist/actions/command.d.ts +11 -7
- package/dist/actions/command.js +16 -10
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/prompt.js +43 -17
- package/dist/cli/client/channel.d.ts +14 -0
- package/dist/cli/client/channel.js +48 -0
- package/dist/cli/client/check.d.ts +3 -0
- package/dist/cli/client/check.js +32 -0
- package/dist/cli/client/discovery.d.ts +8 -0
- package/dist/cli/client/discovery.js +163 -0
- package/dist/cli/client/index.d.ts +9 -0
- package/dist/cli/client/index.js +11 -0
- package/dist/cli/client/registry.d.ts +29 -0
- package/dist/cli/client/registry.js +1 -0
- package/dist/cli/client/runner.d.ts +15 -0
- package/dist/cli/client/runner.js +61 -0
- package/dist/cli/client/types.d.ts +53 -0
- package/dist/cli/client/types.js +1 -0
- package/dist/cli/commands/check.d.ts +6 -0
- package/dist/cli/commands/check.js +11 -0
- package/dist/cli/commands/init.d.ts +5 -0
- package/dist/cli/commands/init.js +54 -0
- package/dist/cli/commands/ls.d.ts +5 -0
- package/dist/cli/commands/ls.js +25 -0
- package/dist/cli/commands/run.d.ts +10 -0
- package/dist/cli/commands/run.js +12 -0
- package/dist/cli/commands/ui.d.ts +11 -0
- package/dist/cli/commands/ui.js +37 -0
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.js +110 -0
- package/dist/cli/ui/Spinner.d.ts +10 -0
- package/dist/cli/ui/Spinner.js +20 -0
- package/dist/cli/ui/app.d.ts +12 -0
- package/dist/cli/ui/app.js +95 -0
- package/dist/cli/ui/views/OmList.d.ts +9 -0
- package/dist/cli/ui/views/OmList.js +36 -0
- package/dist/cli/ui/views/RunView.d.ts +14 -0
- package/dist/cli/ui/views/RunView.js +47 -0
- package/dist/core/ActionRun.d.ts +2 -4
- package/dist/core/ActionRun.js +32 -36
- package/dist/core/ExecutionTree.d.ts +1 -0
- package/dist/core/ExecutionTree.js +25 -4
- package/dist/core/interaction.d.ts +63 -0
- package/dist/core/interaction.js +82 -0
- package/dist/core/step.js +2 -7
- package/dist/core/types.d.ts +10 -20
- package/dist/index.d.ts +1 -1
- package/dist/output/LiveRenderer.d.ts +7 -17
- package/dist/output/LiveRenderer.js +8 -82
- package/dist/output/milestones.d.ts +8 -0
- package/dist/output/milestones.js +39 -0
- package/package.json +9 -1
|
@@ -0,0 +1,163 @@
|
|
|
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. Type errors in the user's oms/actions degrade to `warnings` (an editor-in-progress
|
|
6
|
+
* project stays useful), but a **fatal config problem** — a missing, unparseable, or invalid
|
|
7
|
+
* tsconfig — throws, so the CLI crashes visibly instead of silently returning an empty project.
|
|
8
|
+
*/
|
|
9
|
+
export function discover(tsconfigPath) {
|
|
10
|
+
const oms = [];
|
|
11
|
+
const actions = [];
|
|
12
|
+
const warnings = [];
|
|
13
|
+
const { parsed, warnings: configWarnings } = loadProject(tsconfigPath);
|
|
14
|
+
warnings.push(...configWarnings);
|
|
15
|
+
const program = ts.createProgram({
|
|
16
|
+
rootNames: parsed.fileNames,
|
|
17
|
+
options: { ...parsed.options, noEmit: true },
|
|
18
|
+
});
|
|
19
|
+
const fileSet = new Set(parsed.fileNames.map((f) => canonical(f)));
|
|
20
|
+
for (const sf of program.getSourceFiles()) {
|
|
21
|
+
if (!fileSet.has(canonical(sf.fileName)))
|
|
22
|
+
continue;
|
|
23
|
+
for (const d of program.getSemanticDiagnostics(sf))
|
|
24
|
+
warnings.push(formatDiagnostic(d));
|
|
25
|
+
const names = omkitImports(sf);
|
|
26
|
+
const visit = (node) => {
|
|
27
|
+
if (ts.isCallExpression(node) &&
|
|
28
|
+
ts.isIdentifier(node.expression) &&
|
|
29
|
+
(node.expression.text === names.om || node.expression.text === names.step)) {
|
|
30
|
+
const arg = node.arguments[0];
|
|
31
|
+
if (arg && ts.isStringLiteralLike(arg)) {
|
|
32
|
+
oms.push({ name: arg.text, file: sf.fileName, line: lineOf(sf, node) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
ts.forEachChild(node, visit);
|
|
36
|
+
};
|
|
37
|
+
visit(sf);
|
|
38
|
+
for (const stmt of sf.statements) {
|
|
39
|
+
if (!ts.isVariableStatement(stmt) || !isExported(stmt))
|
|
40
|
+
continue;
|
|
41
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
42
|
+
if (!decl.initializer || !ts.isIdentifier(decl.name))
|
|
43
|
+
continue;
|
|
44
|
+
const info = actionChain(decl.initializer, names.action);
|
|
45
|
+
if (info) {
|
|
46
|
+
actions.push({
|
|
47
|
+
name: info.name,
|
|
48
|
+
file: sf.fileName,
|
|
49
|
+
exportName: decl.name.text,
|
|
50
|
+
publishesCapability: info.ref,
|
|
51
|
+
events: info.emits,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { oms, actions, warnings };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Load and validate the tsconfig, throwing on a fatal config problem — a missing/unparseable file,
|
|
61
|
+
* or a config-level error (bad compilerOptions, an unresolvable `extends`). The benign "no inputs
|
|
62
|
+
* found" (TS18003) is not fatal: an empty-but-valid project comes back as a warning instead.
|
|
63
|
+
*
|
|
64
|
+
* Parses against an absolute base dir so `parsed.fileNames` are absolute — otherwise root files
|
|
65
|
+
* stay relative while imported files (an action pulled in by an om) resolve absolute, and the two
|
|
66
|
+
* never match in the caller's `fileSet`.
|
|
67
|
+
*/
|
|
68
|
+
function loadProject(tsconfigPath) {
|
|
69
|
+
const flatten = (d) => ts.flattenDiagnosticMessageText(d.messageText, "\n");
|
|
70
|
+
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
71
|
+
if (configFile.error)
|
|
72
|
+
throw new Error(`could not read ${tsconfigPath}: ${flatten(configFile.error)}`);
|
|
73
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.resolve(path.dirname(tsconfigPath)));
|
|
74
|
+
const fatal = parsed.errors.filter((e) => e.category === ts.DiagnosticCategory.Error && e.code !== 18003);
|
|
75
|
+
if (fatal.length) {
|
|
76
|
+
throw new Error(`invalid ${tsconfigPath}:\n${fatal.map((e) => ` ${flatten(e)}`).join("\n")}`);
|
|
77
|
+
}
|
|
78
|
+
const warnings = parsed.errors.filter((e) => e.code === 18003).map(flatten);
|
|
79
|
+
return { parsed, warnings };
|
|
80
|
+
}
|
|
81
|
+
/** Collect the local names bound to om/step/action from `import … from "omkit"`. */
|
|
82
|
+
function omkitImports(sf) {
|
|
83
|
+
const names = {};
|
|
84
|
+
for (const stmt of sf.statements) {
|
|
85
|
+
if (!isOmkitImport(stmt))
|
|
86
|
+
continue;
|
|
87
|
+
const bindings = stmt.importClause?.namedBindings;
|
|
88
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
89
|
+
continue;
|
|
90
|
+
for (const el of bindings.elements)
|
|
91
|
+
bindName(names, el);
|
|
92
|
+
}
|
|
93
|
+
return names;
|
|
94
|
+
}
|
|
95
|
+
/** True for `import … from "omkit"`. */
|
|
96
|
+
function isOmkitImport(stmt) {
|
|
97
|
+
return (ts.isImportDeclaration(stmt) &&
|
|
98
|
+
ts.isStringLiteral(stmt.moduleSpecifier) &&
|
|
99
|
+
stmt.moduleSpecifier.text === "omkit");
|
|
100
|
+
}
|
|
101
|
+
/** Record the local binding for an imported `om` / `step` / `action` specifier. */
|
|
102
|
+
function bindName(names, el) {
|
|
103
|
+
const imported = (el.propertyName ?? el.name).text;
|
|
104
|
+
if (imported === "om")
|
|
105
|
+
names.om = el.name.text;
|
|
106
|
+
else if (imported === "step")
|
|
107
|
+
names.step = el.name.text;
|
|
108
|
+
else if (imported === "action")
|
|
109
|
+
names.action = el.name.text;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Walk a builder chain (`action("x").emits<…>().ref<…>().run(fn)`) from the outer call down to
|
|
113
|
+
* the base `action("name")` call, noting whether `.ref` / `.emits` appear. Returns undefined
|
|
114
|
+
* when the chain is not rooted in the local `action` binding with a string-literal name.
|
|
115
|
+
*/
|
|
116
|
+
function actionChain(expr, actionName) {
|
|
117
|
+
if (actionName === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
let ref = false;
|
|
120
|
+
let emits = false;
|
|
121
|
+
let node = expr;
|
|
122
|
+
while (ts.isCallExpression(node)) {
|
|
123
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
124
|
+
const method = node.expression.name.text;
|
|
125
|
+
if (method === "ref")
|
|
126
|
+
ref = true;
|
|
127
|
+
if (method === "emits")
|
|
128
|
+
emits = true;
|
|
129
|
+
node = node.expression.expression;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === actionName) {
|
|
133
|
+
const arg = node.arguments[0];
|
|
134
|
+
if (arg && ts.isStringLiteralLike(arg))
|
|
135
|
+
return { name: arg.text, ref, emits };
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
function isExported(stmt) {
|
|
142
|
+
return Boolean(stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A canonical key for a file path. TypeScript resolves relative imports with a different
|
|
146
|
+
* drive-letter case than the tsconfig `include` glob does on Windows, so a plain
|
|
147
|
+
* `path.normalize` comparison misses files reached both ways. Fold case where the OS does.
|
|
148
|
+
*/
|
|
149
|
+
function canonical(file) {
|
|
150
|
+
const normalized = path.normalize(file);
|
|
151
|
+
return ts.sys.useCaseSensitiveFileNames ? normalized : normalized.toLowerCase();
|
|
152
|
+
}
|
|
153
|
+
function lineOf(sf, node) {
|
|
154
|
+
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
155
|
+
}
|
|
156
|
+
function formatDiagnostic(d) {
|
|
157
|
+
const message = ts.flattenDiagnosticMessageText(d.messageText, "\n");
|
|
158
|
+
if (d.file && d.start !== undefined) {
|
|
159
|
+
const { line } = d.file.getLineAndCharacterOfPosition(d.start);
|
|
160
|
+
return `${path.basename(d.file.fileName)}:${line + 1} ${message}`;
|
|
161
|
+
}
|
|
162
|
+
return message;
|
|
163
|
+
}
|
|
@@ -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,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,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,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,11 @@
|
|
|
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
|
+
* Discovery runs first, before Ink takes the terminal: a fatal config problem throws here and the
|
|
8
|
+
* bin's error boundary turns it into a clean crash rather than an empty list. Non-fatal warnings
|
|
9
|
+
* are printed so they aren't lost behind the UI.
|
|
10
|
+
*/
|
|
11
|
+
export declare function launchUi(client: OmkitClient): Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
* Discovery runs first, before Ink takes the terminal: a fatal config problem throws here and the
|
|
10
|
+
* bin's error boundary turns it into a clean crash rather than an empty list. Non-fatal warnings
|
|
11
|
+
* are printed so they aren't lost behind the UI.
|
|
12
|
+
*/
|
|
13
|
+
export async function launchUi(client) {
|
|
14
|
+
const registry = await client.discover();
|
|
15
|
+
for (const w of registry.warnings)
|
|
16
|
+
process.stderr.write(`omkit: warning: ${w}\n`);
|
|
17
|
+
let finalVerdict;
|
|
18
|
+
const app = render(_jsx(App, { client: client, oms: registry.oms, onExit: (v) => (finalVerdict = v) }), {
|
|
19
|
+
exitOnCtrlC: false,
|
|
20
|
+
});
|
|
21
|
+
void app.waitUntilExit().then(() => {
|
|
22
|
+
if (finalVerdict) {
|
|
23
|
+
// Ink erased its frame on unmount; reprint the verdict marker plus the run's recap block
|
|
24
|
+
// (the same summary a bare run prints) so the pointers to the on-disk record survive.
|
|
25
|
+
console.log("");
|
|
26
|
+
console.log(finalVerdict.ok ? "✓ ok" : "✗ failed");
|
|
27
|
+
for (const line of finalVerdict.summary)
|
|
28
|
+
console.log(line);
|
|
29
|
+
process.exitCode = finalVerdict.ok ? 0 : 1;
|
|
30
|
+
}
|
|
31
|
+
// Backstop: the runner disconnects the supervised child so both processes can exit, but if
|
|
32
|
+
// anything (a wedged pipe, a lingering handle) keeps the event loop alive past the grace,
|
|
33
|
+
// force the exit. Unref'd, so it only fires when the process is genuinely stuck — a clean
|
|
34
|
+
// exit beats it to the punch and this never runs.
|
|
35
|
+
setTimeout(() => process.exit(process.exitCode ?? 0), 2000).unref();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -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,110 @@
|
|
|
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
|
+
await 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
|
+
/** Print a fatal error to stderr in one consistent shape, just before the process exits non-zero. */
|
|
91
|
+
function reportFatal(err) {
|
|
92
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
93
|
+
process.stderr.write(`\nomkit: ${detail}\n`);
|
|
94
|
+
}
|
|
95
|
+
if (isEntryPoint()) {
|
|
96
|
+
// Nothing should fail silently. A fatal anywhere — a bad config, an uncaught throw, or a rejected
|
|
97
|
+
// promise in the interactive app — prints and crashes the CLI with a non-zero exit code.
|
|
98
|
+
process.on("uncaughtException", (err) => {
|
|
99
|
+
reportFatal(err);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
|
102
|
+
process.on("unhandledRejection", (err) => {
|
|
103
|
+
reportFatal(err);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
106
|
+
main().catch((err) => {
|
|
107
|
+
reportFatal(err);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|
|
110
|
+
}
|