omkit 0.4.1 → 0.4.2

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.
@@ -65,6 +65,14 @@ export const chromePage = action("chromePage")
65
65
  await page.goto(url);
66
66
  console.log(`navigated ${url}`);
67
67
  }
68
+ // Oms run under tsx, which forces esbuild `keepNames`: every function serialized into
69
+ // page.evaluate() carries a bare `__name(...)` wrapper that is undefined in the page and
70
+ // throws `ReferenceError: __name is not defined`. Define a no-op __name so those bodies
71
+ // run — seed the current document, and register it for any later navigation. Passed as a
72
+ // string so tsx doesn't rewrite (and re-break) the shim itself.
73
+ const nameShim = "globalThis.__name = globalThis.__name || function (f) { return f; };";
74
+ await page.evaluate(nameShim);
75
+ await page.addInitScript(nameShim);
68
76
  attach(page); // resolves instance.ref for every downstream action
69
77
  console.log(`attached ${from}`);
70
78
  // Daemon: hold the page open until teardown, then drop the CDP session if ours.
@@ -1,6 +1,11 @@
1
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;
2
+ import type { ReportItem } from "../ui/Report.tsx";
3
+ /**
4
+ * Turn typecheck diagnostics into a report: a headline, one item per diagnostic (location + message),
5
+ * and the process exit code — **1 when there are any errors**, 0 when the project is clean.
6
+ */
7
+ export declare function checkReport(diagnostics: Diagnostic[]): {
8
+ title: string;
9
+ items: ReportItem[];
5
10
  code: number;
6
11
  };
@@ -1,11 +1,14 @@
1
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 };
2
+ /**
3
+ * Turn typecheck diagnostics into a report: a headline, one item per diagnostic (location + message),
4
+ * and the process exit code — **1 when there are any errors**, 0 when the project is clean.
5
+ */
6
+ export function checkReport(diagnostics) {
7
+ const n = diagnostics.length;
8
+ const items = diagnostics.map((d) => ({
9
+ head: `${path.basename(d.file)}:${d.line}`,
10
+ detail: d.message,
11
+ }));
12
+ const title = n === 0 ? "no type errors" : `${n} type error${n === 1 ? "" : "s"}`;
13
+ return { title, items, code: n === 0 ? 0 : 1 };
11
14
  }
@@ -1,3 +1,11 @@
1
+ import type { Registry } from "../client/registry.ts";
2
+ /**
3
+ * Report an actionable "nothing to run" panel when discovery finds no oms. Names the config file
4
+ * omkit actually read (absolute, so the tsconfig.omkit.json-vs-tsconfig.json confusion goes away),
5
+ * lists any config warnings (e.g. TypeScript's "no inputs found" with the offending globs), and
6
+ * points at the usual cause: an `include` that doesn't reach the om files.
7
+ */
8
+ export declare function reportNoOms(tsconfig: string, registry: Registry): Promise<void>;
1
9
  /**
2
10
  * Resolve a `run` argument to an absolute om file: an existing file path (relative to `cwd`)
3
11
  * wins; otherwise match a discovered om by name. Returns undefined when nothing matches.
@@ -1,5 +1,22 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { renderDiagnostics } from "../ui/Report.js";
4
+ /**
5
+ * Report an actionable "nothing to run" panel when discovery finds no oms. Names the config file
6
+ * omkit actually read (absolute, so the tsconfig.omkit.json-vs-tsconfig.json confusion goes away),
7
+ * lists any config warnings (e.g. TypeScript's "no inputs found" with the offending globs), and
8
+ * points at the usual cause: an `include` that doesn't reach the om files.
9
+ */
10
+ export function reportNoOms(tsconfig, registry) {
11
+ return renderDiagnostics({
12
+ kind: "warning",
13
+ title: "no oms found",
14
+ subtitle: `config: ${path.resolve(tsconfig)}`,
15
+ items: registry.warnings.map((w) => ({ head: "", detail: w })),
16
+ hint: "Check the config's `include` — it must reach your om files, and a glob only recurses " +
17
+ 'with `**` (e.g. "src/**/*.ts", not "src/*.ts").',
18
+ });
19
+ }
3
20
  /**
4
21
  * Resolve a `run` argument to an absolute om file: an existing file path (relative to `cwd`)
5
22
  * wins; otherwise match a discovered om by name. Returns undefined when nothing matches.
@@ -5,7 +5,8 @@ import type { OmkitClient } from "../client/index.ts";
5
5
  * unmounts (it erases its own frame), so the pointer to the on-disk record survives on screen.
6
6
  *
7
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
8
+ * bin's error boundary turns it into a clean crash rather than an empty list. With no oms to show,
9
+ * it reports why (config + warnings) instead of dropping into a blank picker. Non-fatal warnings
9
10
  * are printed so they aren't lost behind the UI.
10
11
  */
11
- export declare function launchUi(client: OmkitClient): Promise<void>;
12
+ export declare function launchUi(client: OmkitClient, tsconfig: string): Promise<void>;
@@ -1,19 +1,31 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { App } from "../ui/app.js";
4
+ import { renderDiagnostics, withSpinner } from "../ui/Report.js";
5
+ import { reportNoOms } from "./run.js";
4
6
  /**
5
7
  * Render the interactive Ink app. Ink's own Ctrl+C handling is disabled so the app can tear the
6
8
  * run down gracefully first; the final verdict + run-folder path are printed here, after Ink
7
9
  * unmounts (it erases its own frame), so the pointer to the on-disk record survives on screen.
8
10
  *
9
11
  * 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
12
+ * bin's error boundary turns it into a clean crash rather than an empty list. With no oms to show,
13
+ * it reports why (config + warnings) instead of dropping into a blank picker. Non-fatal warnings
11
14
  * are printed so they aren't lost behind the UI.
12
15
  */
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`);
16
+ export async function launchUi(client, tsconfig) {
17
+ const registry = await withSpinner("discovering…", () => client.discover());
18
+ if (registry.oms.length === 0) {
19
+ await reportNoOms(tsconfig, registry);
20
+ return;
21
+ }
22
+ if (registry.warnings.length > 0) {
23
+ await renderDiagnostics({
24
+ kind: "warning",
25
+ title: `${registry.warnings.length} warning${registry.warnings.length === 1 ? "" : "s"}`,
26
+ items: registry.warnings.map((w) => ({ head: "", detail: w })),
27
+ });
28
+ }
17
29
  let finalVerdict;
18
30
  const app = render(_jsx(App, { client: client, oms: registry.oms, onExit: (v) => (finalVerdict = v) }), {
19
31
  exitOnCtrlC: false,
package/dist/cli/index.js CHANGED
@@ -36,13 +36,18 @@ async function main() {
36
36
  }
37
37
  if (cli.command === "ls") {
38
38
  const { formatRegistry } = await import("./commands/ls.js");
39
- console.log(formatRegistry(await client.discover(), { json: cli.json }));
39
+ const { withSpinner } = await import("./ui/Report.js");
40
+ const registry = await withSpinner("discovering…", () => client.discover());
41
+ console.log(formatRegistry(registry, { json: cli.json }));
40
42
  return;
41
43
  }
42
44
  if (cli.command === "check") {
43
- const { formatDiagnostics } = await import("./commands/check.js");
44
- const { text, code } = formatDiagnostics(await client.check());
45
- console.log(text);
45
+ const { checkReport } = await import("./commands/check.js");
46
+ const { renderDiagnostics, withSpinner } = await import("./ui/Report.js");
47
+ const diagnostics = await withSpinner("type-checking…", () => client.check());
48
+ const { title, items, code } = checkReport(diagnostics);
49
+ // Type errors fail the command (exit 1); a clean project reports ok and exits 0.
50
+ await renderDiagnostics({ kind: code === 0 ? "ok" : "error", title, items }, code === 0 ? process.stdout : process.stderr);
46
51
  process.exitCode = code;
47
52
  return;
48
53
  }
@@ -51,12 +56,18 @@ async function main() {
51
56
  // A bare `omkit` lands here too, since `run` is the default command.
52
57
  if (!cli.target) {
53
58
  const { launchUi } = await import("./commands/ui.js");
54
- await launchUi(client);
59
+ await launchUi(client, cli.tsconfig);
55
60
  return;
56
61
  }
57
- const { resolveOm } = await import("./commands/run.js");
62
+ const { resolveOm, reportNoOms } = await import("./commands/run.js");
58
63
  const { spawnBare } = await import("./client/runner.js");
59
- const registry = await client.discover();
64
+ const { withSpinner } = await import("./ui/Report.js");
65
+ const registry = await withSpinner("discovering…", () => client.discover());
66
+ if (registry.oms.length === 0) {
67
+ await reportNoOms(cli.tsconfig, registry);
68
+ process.exitCode = 1;
69
+ return;
70
+ }
60
71
  const omFile = resolveOm(cli.target, registry, process.cwd());
61
72
  if (!omFile) {
62
73
  const known = registry.oms.map((o) => o.name).join(", ");
@@ -95,6 +106,9 @@ function reportFatal(err) {
95
106
  if (isEntryPoint()) {
96
107
  // Nothing should fail silently. A fatal anywhere — a bad config, an uncaught throw, or a rejected
97
108
  // promise in the interactive app — prints and crashes the CLI with a non-zero exit code.
109
+ //
110
+ // The controlled path (a rejection from `main`, e.g. a bad tsconfig) gets the pretty Ink panel;
111
+ // the last-resort sync handlers stay plain, since the process may be mid-crash and can't await.
98
112
  process.on("uncaughtException", (err) => {
99
113
  reportFatal(err);
100
114
  process.exit(1);
@@ -103,8 +117,14 @@ if (isEntryPoint()) {
103
117
  reportFatal(err);
104
118
  process.exit(1);
105
119
  });
106
- main().catch((err) => {
107
- reportFatal(err);
120
+ main().catch(async (err) => {
121
+ const { renderDiagnostics } = await import("./ui/Report.js");
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ // Keep the panel clean by default (a bad tsconfig needs no stack); set OMKIT_DEBUG for the trace.
124
+ const stack = process.env.OMKIT_DEBUG && err instanceof Error && err.stack
125
+ ? err.stack.split("\n").slice(1).join("\n")
126
+ : undefined;
127
+ await renderDiagnostics({ kind: "error", title: message, hint: stack });
108
128
  process.exit(1);
109
129
  });
110
130
  }
@@ -0,0 +1,40 @@
1
+ import { type ReactElement } from "react";
2
+ export type ReportKind = "error" | "warning" | "ok" | "info";
3
+ /** One row of a report: a colored `head` (e.g. a file:line location) and a dim `detail`. */
4
+ export interface ReportItem {
5
+ head: string;
6
+ detail?: string;
7
+ }
8
+ export interface ReportProps {
9
+ kind: ReportKind;
10
+ /** The bold, colored headline (e.g. "3 type errors"). */
11
+ title: string;
12
+ /** A dim line under the title — e.g. the config file a diagnostic came from. */
13
+ subtitle?: string;
14
+ /** The list of findings, shown under a separator. */
15
+ items?: ReportItem[];
16
+ /** A dim footer with next-step guidance. */
17
+ hint?: string;
18
+ }
19
+ /**
20
+ * A colorful, bordered diagnostics panel: an icon + title header, an optional subtitle, a separator,
21
+ * a list of items (colored location + dim detail), and an optional dim hint footer. Kind picks the
22
+ * accent color (error red / warning yellow / ok green / info cyan).
23
+ */
24
+ export declare function Report({ kind, title, subtitle, items, hint }: ReportProps): ReactElement;
25
+ /**
26
+ * Render a one-shot Ink element to `stream` (stderr by default) and resolve once it's painted.
27
+ * Paints a single frame then unmounts, leaving it in the scrollback — the right shape for
28
+ * non-interactive diagnostic output, unlike the live app.
29
+ */
30
+ export declare function renderReport(element: ReactElement, stream?: NodeJS.WriteStream): Promise<void>;
31
+ /** Build a {@link Report} from plain data and render it — so non-JSX call sites (the bin, run.ts)
32
+ * stay free of JSX while still using the Ink component. */
33
+ export declare function renderDiagnostics(props: ReportProps, stream?: NodeJS.WriteStream): Promise<void>;
34
+ /**
35
+ * Show a spinner labelled `label` while `work` runs, then clear it and resolve `work`'s result.
36
+ * Good for the noticeable-but-quiet waits (discovery, typecheck). Note: a CPU-bound synchronous
37
+ * `work` blocks the event loop, so the spinner shows its label but can't animate until `work`
38
+ * yields — it still tells the user something is happening instead of a silent hang.
39
+ */
40
+ export declare function withSpinner<T>(label: string, work: () => T | Promise<T>, stream?: NodeJS.WriteStream): Promise<T>;
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ import { Box, Text, render, useStdout } from "ink";
4
+ import { Spinner } from "./Spinner.js";
5
+ const THEME = {
6
+ error: { color: "red", icon: "✗" },
7
+ warning: { color: "yellow", icon: "⚠" },
8
+ ok: { color: "green", icon: "✓" },
9
+ info: { color: "cyan", icon: "ℹ" },
10
+ };
11
+ /** A horizontal rule sized to the terminal (bounded), used to separate the header from the list. */
12
+ function Divider({ color }) {
13
+ const { stdout } = useStdout();
14
+ const width = Math.max(Math.min((stdout?.columns ?? 80) - 6, 56), 12);
15
+ return (_jsx(Text, { color: color, dimColor: true, children: "─".repeat(width) }));
16
+ }
17
+ /**
18
+ * A colorful, bordered diagnostics panel: an icon + title header, an optional subtitle, a separator,
19
+ * a list of items (colored location + dim detail), and an optional dim hint footer. Kind picks the
20
+ * accent color (error red / warning yellow / ok green / info cyan).
21
+ */
22
+ export function Report({ kind, title, subtitle, items = [], hint }) {
23
+ const { color, icon } = THEME[kind];
24
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, paddingX: 1, children: [_jsxs(Text, { color: color, bold: true, children: [icon, " ", title] }), subtitle ? _jsx(Text, { dimColor: true, children: subtitle }) : null, items.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Divider, { color: color }), items.map((it, i) => (_jsxs(Text, { children: [it.head ? _jsx(Text, { color: color, children: it.head }) : null, it.detail ? _jsx(Text, { dimColor: true, children: (it.head ? " " : "") + it.detail }) : null] }, i)))] })) : null, hint ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: hint }) })) : null] }));
25
+ }
26
+ /**
27
+ * Render a one-shot Ink element to `stream` (stderr by default) and resolve once it's painted.
28
+ * Paints a single frame then unmounts, leaving it in the scrollback — the right shape for
29
+ * non-interactive diagnostic output, unlike the live app.
30
+ */
31
+ export async function renderReport(element, stream = process.stderr) {
32
+ const app = render(element, { stdout: stream, patchConsole: false });
33
+ await new Promise((resolve) => setImmediate(resolve)); // let the frame paint once
34
+ app.unmount();
35
+ await app.waitUntilExit();
36
+ }
37
+ /** Build a {@link Report} from plain data and render it — so non-JSX call sites (the bin, run.ts)
38
+ * stay free of JSX while still using the Ink component. */
39
+ export function renderDiagnostics(props, stream = process.stderr) {
40
+ return renderReport(_jsx(Report, { ...props }), stream);
41
+ }
42
+ /**
43
+ * Show a spinner labelled `label` while `work` runs, then clear it and resolve `work`'s result.
44
+ * Good for the noticeable-but-quiet waits (discovery, typecheck). Note: a CPU-bound synchronous
45
+ * `work` blocks the event loop, so the spinner shows its label but can't animate until `work`
46
+ * yields — it still tells the user something is happening instead of a silent hang.
47
+ */
48
+ export async function withSpinner(label, work, stream = process.stderr) {
49
+ // No TTY (piped/CI) → a spinner can't animate or erase itself; skip it and just do the work,
50
+ // so the output stays clean rather than leaving a stray "…" line above the result.
51
+ if (!stream.isTTY)
52
+ return work();
53
+ const app = render(_jsx(Spinner, { label: label }), { stdout: stream, patchConsole: false });
54
+ try {
55
+ await new Promise((resolve) => setImmediate(resolve)); // let the first frame paint
56
+ return await work();
57
+ }
58
+ finally {
59
+ app.clear(); // erase the spinner frame (unmount alone leaves the last frame on screen)
60
+ app.unmount();
61
+ await app.waitUntilExit();
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",