omkit 0.0.15 → 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.
Files changed (110) hide show
  1. package/README.md +110 -97
  2. package/dist/actions/chrome-page.d.ts +12 -29
  3. package/dist/actions/chrome-page.js +19 -32
  4. package/dist/actions/command.d.ts +18 -39
  5. package/dist/actions/command.js +28 -80
  6. package/dist/actions/healthcheck.d.ts +3 -11
  7. package/dist/actions/healthcheck.js +8 -22
  8. package/dist/actions/index.d.ts +6 -8
  9. package/dist/actions/index.js +4 -6
  10. package/dist/actions/prompt.d.ts +5 -19
  11. package/dist/actions/prompt.js +10 -31
  12. package/dist/actions/tail-log.d.ts +7 -21
  13. package/dist/actions/tail-log.js +13 -34
  14. package/dist/actions/until-log.d.ts +4 -7
  15. package/dist/actions/until-log.js +3 -3
  16. package/dist/actions/watch-dir.d.ts +5 -19
  17. package/dist/actions/watch-dir.js +13 -28
  18. package/dist/actions/watch.d.ts +4 -12
  19. package/dist/actions/watch.js +9 -16
  20. package/dist/core/ActionRun.d.ts +97 -0
  21. package/dist/core/ActionRun.js +266 -0
  22. package/dist/core/ExecutionTree.d.ts +88 -0
  23. package/dist/core/ExecutionTree.js +320 -0
  24. package/dist/core/action.d.ts +6 -0
  25. package/dist/core/action.js +72 -0
  26. package/dist/core/context.d.ts +9 -0
  27. package/dist/core/context.js +8 -0
  28. package/dist/core/om.d.ts +9 -0
  29. package/dist/core/om.js +25 -0
  30. package/dist/core/step.d.ts +9 -0
  31. package/dist/core/step.js +18 -0
  32. package/dist/core/types.d.ts +128 -0
  33. package/dist/foundation/ActionRef.d.ts +18 -0
  34. package/dist/foundation/ActionRef.js +5 -0
  35. package/dist/{utils → foundation}/AsyncQueue.d.ts +2 -4
  36. package/dist/{utils → foundation}/AsyncQueue.js +6 -19
  37. package/dist/foundation/CancelledError.d.ts +10 -0
  38. package/dist/foundation/CancelledError.js +13 -0
  39. package/dist/foundation/Deferred.d.ts +8 -0
  40. package/dist/foundation/Deferred.js +10 -0
  41. package/dist/foundation/LogEntry.d.ts +29 -0
  42. package/dist/{orchestration/events → foundation}/events.d.ts +2 -11
  43. package/dist/{orchestration/events → foundation}/events.js +1 -7
  44. package/dist/{utils → foundation}/format.d.ts +3 -4
  45. package/dist/{utils → foundation}/format.js +12 -4
  46. package/dist/foundation/fsSafe.d.ts +7 -0
  47. package/dist/foundation/fsSafe.js +7 -0
  48. package/dist/foundation/ids.d.ts +9 -0
  49. package/dist/foundation/ids.js +10 -0
  50. package/dist/index.d.ts +11 -22
  51. package/dist/index.js +10 -30
  52. package/dist/output/LiveRenderer.d.ts +13 -0
  53. package/dist/output/LiveRenderer.js +56 -0
  54. package/dist/output/console/ConsoleCapture.d.ts +20 -0
  55. package/dist/output/console/ConsoleCapture.js +47 -0
  56. package/dist/output/folder/RunFolder.d.ts +11 -22
  57. package/dist/output/folder/RunFolder.js +18 -36
  58. package/dist/output/log/LogStore.d.ts +22 -0
  59. package/dist/output/log/LogStore.js +56 -0
  60. package/dist/output/log/RawStream.d.ts +11 -0
  61. package/dist/output/log/RawStream.js +18 -0
  62. package/dist/output/snapshot/SnapshotStore.d.ts +6 -43
  63. package/dist/output/snapshot/SnapshotStore.js +12 -52
  64. package/dist/output/writers/NodeLogWriter.d.ts +8 -0
  65. package/dist/output/writers/NodeLogWriter.js +36 -0
  66. package/dist/output/writers/ResultWriter.d.ts +3 -0
  67. package/dist/output/writers/ResultWriter.js +5 -0
  68. package/dist/output/writers/RollupWriter.d.ts +9 -0
  69. package/dist/output/writers/RollupWriter.js +29 -0
  70. package/dist/output/writers/entryLine.d.ts +10 -0
  71. package/dist/output/writers/entryLine.js +39 -0
  72. package/dist/output/writers/views.d.ts +38 -0
  73. package/dist/output/writers/views.js +6 -0
  74. package/dist/system/fs/FolderCache.d.ts +6 -34
  75. package/dist/system/fs/FolderCache.js +7 -38
  76. package/dist/system/proc.d.ts +51 -0
  77. package/dist/system/proc.js +148 -0
  78. package/dist/system/procRegistry.d.ts +52 -0
  79. package/dist/system/procRegistry.js +117 -0
  80. package/package.json +3 -1
  81. package/dist/orchestration/action/action.d.ts +0 -23
  82. package/dist/orchestration/action/action.js +0 -256
  83. package/dist/orchestration/action/types.d.ts +0 -182
  84. package/dist/orchestration/assert.d.ts +0 -24
  85. package/dist/orchestration/assert.js +0 -26
  86. package/dist/orchestration/spin/SpinHost.d.ts +0 -111
  87. package/dist/orchestration/spin/SpinHost.js +0 -345
  88. package/dist/orchestration/spin/spin.d.ts +0 -24
  89. package/dist/orchestration/spin/spin.js +0 -57
  90. package/dist/orchestration/spin/types.d.ts +0 -63
  91. package/dist/output/Output.d.ts +0 -24
  92. package/dist/output/Output.js +0 -27
  93. package/dist/output/index.d.ts +0 -11
  94. package/dist/output/index.js +0 -11
  95. package/dist/output/log/LogRenderer.d.ts +0 -73
  96. package/dist/output/log/LogRenderer.js +0 -133
  97. package/dist/output/log/LogsCollector.d.ts +0 -79
  98. package/dist/output/log/LogsCollector.js +0 -141
  99. package/dist/output/log/RunLog.d.ts +0 -54
  100. package/dist/output/log/RunLog.js +0 -152
  101. package/dist/output/log/console-capture.d.ts +0 -24
  102. package/dist/output/log/console-capture.js +0 -53
  103. package/dist/system/fs/streams.d.ts +0 -11
  104. package/dist/system/fs/streams.js +0 -14
  105. package/dist/system/process/process.d.ts +0 -35
  106. package/dist/system/process/process.js +0 -104
  107. package/dist/utils/serialize.d.ts +0 -2
  108. package/dist/utils/serialize.js +0 -12
  109. /package/dist/{orchestration/action → core}/types.js +0 -0
  110. /package/dist/{orchestration/spin/types.js → foundation/LogEntry.js} +0 -0
package/README.md CHANGED
@@ -2,136 +2,147 @@
2
2
 
3
3
  **The operational-model kit.** _Typed actions. One run. One log._
4
4
 
5
- A runtime for the workflows around your code — build, watch, spawn, probe, drive
6
- a browser, inspect state, tear it all down together. `omkit` is the running,
7
- typed model of your system in motion. Where
8
- [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer, `omkit` is
9
- the _operational_ one.
5
+ A tiny runtime for the workflows _around_ your code — build, watch, spawn, probe, drive a browser, read state back, and tear it all down together. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer, `omkit` is the _operational_ one: the running, typed model of your system in motion.
10
6
 
11
7
  ## The problem
12
8
 
13
- Real developer workflows aren't linear. They start processes, wait for health
14
- checks, connect a browser, read state back, watch files, react to events — and
15
- shut everything down as one. Most teams glue this together with shell scripts,
16
- npm scripts, and ad-hoc Node.
9
+ Real developer workflows aren't linear. They start processes, wait for health checks, connect a browser, read state back, watch files, react to events — and shut everything down as one. Most teams glue this together with shell scripts, npm scripts, and ad-hoc Node, and get back a pile of opaque process output.
17
10
 
18
- `omkit` models the workflow instead. Each step is a typed **action** running
19
- inside one runtime, and actions don't share globals — they hand each other typed
20
- runtime **capabilities** (a port, a client, a live browser page) and emit typed
21
- **events**. So a running system exposes structured handoffs and observations
22
- rather than a pile of opaque process output — which makes the whole thing
23
- observable, reproducible, and scriptable.
11
+ `omkit` models the workflow instead. Each step is a typed **action** running inside one runtime. Actions don't share globals — they hand each other typed runtime **capabilities** (a port, a client, a live browser page) and emit typed **events**, all onto one log. So a running system exposes structured handoffs and observations, which makes it observable, reproducible, and scriptable.
24
12
 
25
13
  ```ts
26
- import { spin } from "omkit";
27
- import { command, healthcheck, chromedriver, chromePage } from "omkit/actions";
14
+ import { om } from "omkit";
15
+ import { command, healthcheck, chromePage } from "omkit/actions";
28
16
 
29
- spin(async ({ nod }) => {
30
- nod(command("api", "npm run dev:api")); // start a daemon…
31
- await nod(healthcheck({ port: 3000 })).once("healthy"); // …gate on it…
17
+ om(async () => {
18
+ command("api", "npm run dev:api").exec().tag("api:daemon"); // start a daemon…
32
19
 
33
- const chrome = nod(chromedriver({ url: "http://localhost:3000" }));
34
- nod(chromePage(chrome.ref)); // …then hand the live page downstream
20
+ const ready = await healthcheck({ port: 3000 }).exec().result; // …gate on it…
21
+ if (!ready.ok) return;
22
+
23
+ // …then hand the live page downstream — typed, not scraped from a log.
24
+ const page = chromePage("app", "localhost:9222", { url: "http://localhost:3000" }).exec();
25
+ await inspect(page.ref); // whatever drives the app receives the live Page
26
+
27
+ console.log("up — press Ctrl+C to stop");
35
28
  });
36
29
  ```
37
30
 
38
- That last line is the point. `chrome.ref` isn't a URL scraped from a log — it's a
39
- typed capability the next action receives and drives. Few workflow tools pass
40
- anything but strings between steps.
31
+ `page.ref` isn't a URL scraped from stdout — it's a typed capability the next step receives and drives. Few workflow tools pass anything but strings between steps.
41
32
 
42
33
  ```
43
- spin()
44
- │
45
- ┌─────────────┼─────────────┐
46
- ▼ ▼ ▼
47
- command healthcheck chromedriver
48
- │ .ref
49
- ▼
50
- chromePage
51
- │ .ref
34
+ om()
35
+ │
36
+ ┌──────────────┼──────────────┐
37
+ ▼ ▼ ▼
38
+ command healthcheck chromePage
39
+ (daemon) (gate) │ .ref
52
40
  ▼
53
- inspectPage
41
+ your driver
54
42
  ```
55
43
 
56
- ## Five concepts
57
-
58
- - **Action** — a named, typed unit of work; optionally declares events and an
59
- imperative handle. Calling it constructs an instance (deferred — the run
60
- executes it).
61
-
62
- ```ts
63
- const build = action("Build").run(({ proc }) => proc("tsc")`tsc -b`);
64
- ```
65
-
66
- - **Spin** — hosts an async orchestration _inside_ the runtime. You write normal
67
- `await` / `if` / loops / variables, while the actions you launch keep running
68
- in parallel. omkit runs your function as one in-flight action and hands it
69
- `nod`; because that host stays in-flight while you `await`, the run never idles
70
- shut between steps. `nod(instance)` launches an action and returns it, so you
71
- can `await` its `.once(...)` or its `.ref`:
72
-
73
- ```ts
74
- spin({ failFast: false }, async ({ nod }) => {
75
- if ((await nod(prompt({ … })).once("done")) === "yes") await nod(test).once("done");
76
- nod(devServer); // daemon: keeps the run alive
77
- const chrome = nod(chromedriver({ … }));
78
- nod(chromePage(chrome.ref));
79
- });
80
- ```
44
+ ## Core concepts
45
+
46
+ ### Actions & Activities
81
47
 
82
- - **Handle — a typed capability** — an action can `attach` a value (a port, a
83
- client, a page) that others `await` via `instance.ref`. Not a file descriptor:
84
- a typed runtime handoff, so actions chain without globals or string-scraping:
48
+ An **Action** is an immutable description of work — named, typed, optionally declaring events and an imperative handle. An **Activity** is a live execution of one, created synchronously by `.exec()`. Everything _before_ `exec()` configures the Action; everything _after_ configures or observes the Activity.
49
+
50
+ ```ts
51
+ import { action } from "omkit";
52
+
53
+ const Build = action("Build").run(({ proc }) => proc("tsc")`tsc -b`);
54
+
55
+ const activity = Build().exec(); // launch; returns the live Activity
56
+ ```
85
57
 
86
- ```ts
87
- const Server = action("Server")
88
- .ref<number>()
89
- .run(async ({ attach, signal }) => {
90
- attach(await listen()); // publish the capability…
91
- await until(signal); // …and stay alive
92
- });
58
+ `om(async (ctx) => …)` hosts a linear orchestration as the root of a run. You write normal `await` / `if` / loops / variables; the Activities you launch keep running in parallel. Because the body stays in-flight while you `await`, the run never idles shut between steps. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
93
59
 
94
- const Probe = action("Probe").run((_ctx, port: Promise<number>) => drive(port));
60
+ ### Typed capabilities
95
61
 
96
- spin(async ({ nod }) => {
97
- const server = nod(Server());
98
- nod(Probe(server.ref)); // Probe receives Server's port, typed
62
+ An action can `attach` a value — a port, a client, a page — that downstream actions receive by awaiting `activity.ref`. Not a file descriptor: a typed runtime handoff, so actions chain without globals or string-scraping.
63
+
64
+ ```ts
65
+ const Server = action("Server")
66
+ .ref<number>()
67
+ .run(async ({ attach, signal }) => {
68
+ attach(await listen()); // publish the capability…
69
+ await until(signal); // …and stay alive until teardown
99
70
  });
100
- ```
101
71
 
102
- - **Log** — every action's output, events, and lifecycle land on one timeline. It
103
- streams live and is written to disk. (A spin auto-drains; pass `drain: false`
104
- to start the stream yourself — e.g. after a `prompt` so it doesn't write over
105
- the question.)
72
+ const Probe = action("Probe").run((_ctx, port: Promise<number>) => drive(port));
73
+
74
+ om(async () => {
75
+ const server = Server().exec();
76
+ Probe(server.ref).exec(); // Probe receives Server's port, typed
77
+ });
78
+ ```
106
79
 
107
- - **Run** — the runtime a spin sits on: actions in parallel under one abort
108
- controller and the process-wide log, one run per process. `spin` is the usual
109
- entry point; drop to `run(...)` directly when you just want to launch a fixed
110
- set and `await r.done`. Ctrl-C, a failure, or natural completion tears it all
111
- down.
80
+ ### Outcomes — a result that doesn't throw
81
+
82
+ Every Activity settles to a typed **`Outcome`**, read via `activity.result` — which **never throws**:
83
+
84
+ ```ts
85
+ type Outcome<T> = { ok: true; value: T } | { ok: false; error: unknown };
86
+
87
+ const r = await runTests().exec().result;
88
+ if (!r.ok) {
89
+ // an expected, operational failure — inspect it and carry on
90
+ }
91
+ ```
92
+
93
+ Awaiting `.result` also _observes_ the Activity (see teardown, below). A cancelled Activity resolves `{ ok: false, error: CancelledError }`.
94
+
95
+ ### Failure & teardown
96
+
97
+ omkit uses **structured supervision**: a failure that **nobody is watching** tears the whole run down and marks it failed. An Activity is "watched" if — before it fails — you awaited its `.result` (or `.ref`), attached an `on("error")` listener, or attached `.handleFailure`.
98
+
99
+ - **Await it** (`await x.exec().result`) → the failure is yours to inspect as `{ ok: false }`; the run stays green.
100
+ - **Fire-and-forget it** (`x.exec()`, never awaited) → an unobserved failure tears the run down. This is the guardrail for daemons: a crashed daemon fails the run instead of leaving it wedged.
101
+ - **`.handleFailure(fn)`** → own a fire-and-forget Activity's failure: `fn(error)` runs instead of tearing down.
102
+
103
+ ```ts
104
+ watchDir(".build").exec().tag("watch:daemon"); // crash here → run tears down
105
+ someFlakyDaemon()
106
+ .exec()
107
+ .handleFailure((e) => log(e)); // …unless you own it
108
+ ```
109
+
110
+ **Ctrl+C** and **`ctx.cancel()`** tear everything down gracefully — the log is always written. And **success is keep-alive**: when your `om` body returns, daemons it launched keep running until Ctrl+C or `cancel()`, so a body that finishes wiring things up doesn't kill the servers it started.
111
+
112
+ ### One log, on disk
113
+
114
+ Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline**. It streams live to the terminal (curated milestones) and is written to a per-run folder:
115
+
116
+ - `result.json` — the run's tree, per-node status, and verdict.
117
+ - `raw.jsonl` — every entry, machine-readable.
118
+ - `main.log` + one `.log` per node — the human-readable timelines.
119
+ - `events.log` / `asserts.log` / `snapshots.log` — cross-cutting rollups.
120
+
121
+ `ctx` also gives each action `assert(...)` (tallies into the verdict) and `snapshot(name, value)` (captures JSON state to the run folder) — so a run is an inspectable artifact, not just exit codes.
112
122
 
113
123
  ## Events
114
124
 
115
- An instance exposes its declared events plus the lifecycle ones (`done`,
116
- `error`, `attached`):
125
+ An Activity exposes its declared events plus the lifecycle ones (`done`, `error`, `attached`):
117
126
 
118
- - **`instance.on(event, handler)`** — subscribe with a handler for every emit.
119
- - **`instance.once(event)`** — a **promise** for the next emit: resolves with the
120
- payload, or rejects if the action settles first (so an `await` never hangs).
121
- `await instance.once("done")` is the action's result; a retained snapshot (like
122
- `healthy`) resolves immediately even if you subscribe late.
127
+ - **`activity.on(event, handler)`** — subscribe to every emit.
128
+ - **`activity.once(event)`** — a **promise** for the next emit; resolves `undefined` if the Activity settles first, so an `await` never hangs. A retained snapshot (like `healthy`) is replayed immediately even if you subscribe late.
129
+
130
+ ```ts
131
+ const probe = healthcheck({ port: 3000 }).exec();
132
+ probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
133
+ ```
123
134
 
124
135
  ## Batteries — `omkit/actions`
125
136
 
126
- - **`command`** — wrap a shell command (output streams to the log, killed on teardown).
137
+ Reusable actions built on the core engine. For anything beyond these, drop down to `action(...).run(...)`.
138
+
139
+ - **`command`** — run a shell command; output streams to the log, killed on teardown.
127
140
  - **`watch`** / **`watchDir`** — watch a file or directory for changes (survives wipe + recreate).
128
141
  - **`untilLog`** — gate on the first log entry matching a predicate.
129
- - **`tailLog`** — tail a file another process writes, streaming its lines into the combined log.
130
- - **`healthcheck`** — poll a URL/port until status (and optionally body) matches.
142
+ - **`tailLog`** — tail a file another process writes, folding its lines into the combined log.
143
+ - **`healthcheck`** — poll a URL/port until the status (and optionally body) matches.
131
144
  - **`prompt`** — ask the terminal for input or a choice, with a timeout that falls back to a default; the answer is its handle.
132
- - **`chromePage`** — attach to Chrome over CDP — or to an existing Playwright
133
- `Page`/`Browser`/`Context` (including Electron windows) — and expose the live
134
- `Page` as a handle.
145
+ - **`chromePage`** — attach to Chrome over CDP — or to an existing Playwright `Page`/`Browser`/`Context`, including Electron windows — and expose the live `Page` as a handle.
135
146
 
136
147
  ## Install
137
148
 
@@ -139,6 +150,8 @@ An instance exposes its declared events plus the lifecycle ones (`done`,
139
150
  npm install omkit
140
151
  ```
141
152
 
153
+ Requires Node ≥ 20.11. ESM only.
154
+
142
155
  ## License
143
156
 
144
157
  MIT
@@ -1,48 +1,31 @@
1
1
  import { type Browser, type BrowserContext, type Page } from "playwright-core";
2
2
  export type { Page, Browser, BrowserContext } from "playwright-core";
3
3
  /**
4
- * Where {@link chromePage} gets its page from:
5
- * - a **CDP endpoint** (`host:port` or `http://host:port`) — it connects, and
6
- * owns (closes) that connection on teardown;
7
- * - an existing **`Page`** — e.g. an Electron `BrowserWindow`, used as-is;
4
+ * Where {@link chromePage} gets its page:
5
+ * - a **CDP endpoint** (`host:port` or `http://host:port`) — connected and owned
6
+ * (closed on teardown);
7
+ * - an existing **`Page`** (e.g. an Electron window) — used as-is;
8
8
  * - a **`BrowserContext`** or **`Browser`** — a page is taken (or opened) from it.
9
9
  *
10
- * Handle sources aren't owned: teardown never closes a browser/window the action
11
- * didn't open, leaving its lifecycle to whoever launched it.
10
+ * Handles aren't owned: teardown never closes a browser/window the action didn't open.
12
11
  */
13
12
  export type ChromePageSource = string | Page | BrowserContext | Browser;
14
- /** Events {@link chromePage} pushes to the global log (and exposes via `.on`). */
13
+ /** Events {@link chromePage} emits. */
15
14
  export interface ChromePageEvents {
16
- /** A `console.*` call in the page; payload is the rendered text. */
17
15
  console: string;
18
- /** An uncaught error in the page; payload is its message. */
19
16
  pageerror: string;
20
- /** A top-level navigation settled; payload is the new URL. */
21
17
  navigated: string;
22
18
  }
23
19
  export interface ChromePageOptions {
24
- /**
25
- * Navigate the attached page here once acquired. Omit to drive the page as-is
26
- * (the existing window, or the first page of a connected/handed-in browser).
27
- */
20
+ /** Navigate the attached page here once acquired. Omit to drive it as-is. */
28
21
  url?: string;
29
22
  }
30
23
  /**
31
24
  * Publishes a Chrome/Chromium {@link Page} as this action's handle, so downstream
32
- * actions can await `instance.ref` and drive the same live page. The page comes
33
- * from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
34
- * or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
35
- * `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
36
- * straight from another action's `.ref` (e.g. `chromePage("Explorer",
37
- * chromedriver.ref)` or `chromePage("App", electronApp.ref)`).
38
- *
39
- * Daemon-shaped like the watch actions: acquire the page, attach it, then stay
40
- * alive on a promise that only resolves when the run's `signal` aborts. On
41
- * teardown it closes the CDP connection *only* when it opened one — a handed-in
42
- * browser or window is left untouched for its owner to close.
43
- *
44
- * `label` names this page in the log (its `source`), so several `chromePage`
45
- * instances in one run stay distinguishable — e.g. `chromePage("Explorer",
46
- * chrome.ref)` logs its lines under `Explorer` rather than a shared default.
25
+ * actions can await `instance.ref` and drive the same live page. The source may be
26
+ * a promise, so it wires straight from another action's `.ref` (e.g.
27
+ * `chromePage("Explorer", chromedriver.ref)`). `label` tags this run so several
28
+ * pages in one run stay distinguishable. Daemon: acquire + attach, stay alive
29
+ * until teardown, then close the CDP connection only when it opened one.
47
30
  */
48
31
  export declare const chromePage: import("../index.ts").Action<[label: string, source: ChromePageSource | Promise<ChromePageSource>, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;
@@ -1,38 +1,26 @@
1
1
  import { chromium } from "playwright-core";
2
- import { action } from "../orchestration/action/action.js";
3
- // Playwright's classes are structurally distinct by these methods; only Browser
4
- // has `contexts()`, only Page has `mainFrame()`, and a BrowserContext has
5
- // `newPage()` without either — enough to tell a handed-in handle apart.
2
+ import { action } from "../core/action.js";
3
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
6
4
  const isPage = (x) => typeof x.mainFrame === "function";
7
5
  const isBrowser = (x) => typeof x.contexts === "function";
8
6
  /** Take the first existing page of a context, or open one. */
9
7
  const pageOf = (context, fresh) => fresh ? context.newPage() : (context.pages()[0] ?? context.newPage());
10
8
  /**
11
9
  * Publishes a Chrome/Chromium {@link Page} as this action's handle, so downstream
12
- * actions can await `instance.ref` and drive the same live page. The page comes
13
- * from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
14
- * or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
15
- * `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
16
- * straight from another action's `.ref` (e.g. `chromePage("Explorer",
17
- * chromedriver.ref)` or `chromePage("App", electronApp.ref)`).
18
- *
19
- * Daemon-shaped like the watch actions: acquire the page, attach it, then stay
20
- * alive on a promise that only resolves when the run's `signal` aborts. On
21
- * teardown it closes the CDP connection *only* when it opened one — a handed-in
22
- * browser or window is left untouched for its owner to close.
23
- *
24
- * `label` names this page in the log (its `source`), so several `chromePage`
25
- * instances in one run stay distinguishable — e.g. `chromePage("Explorer",
26
- * chrome.ref)` logs its lines under `Explorer` rather than a shared default.
10
+ * actions can await `instance.ref` and drive the same live page. The source may be
11
+ * a promise, so it wires straight from another action's `.ref` (e.g.
12
+ * `chromePage("Explorer", chromedriver.ref)`). `label` tags this run so several
13
+ * pages in one run stay distinguishable. Daemon: acquire + attach, stay alive
14
+ * until teardown, then close the CDP connection only when it opened one.
27
15
  */
28
- export const chromePage = action("Chrome Page")
16
+ export const chromePage = action("chromePage")
29
17
  .emits()
30
18
  .ref()
31
- .run(async ({ logs, signal, emit, attach }, label, source, opts = {}) => {
19
+ .run(async ({ signal, emit, attach, tag }, label, source, opts = {}) => {
20
+ tag(label);
32
21
  const { url } = opts;
33
22
  const resolved = await source;
34
- // Resolve the source to a Page, recording what (if anything) we own. Only a
35
- // CDP connection we open here is ours to close; handles belong to callers.
23
+ // Only a CDP connection we open here is ours to close; handles belong to callers.
36
24
  let owned;
37
25
  let page;
38
26
  let from;
@@ -40,8 +28,6 @@ export const chromePage = action("Chrome Page")
40
28
  const cdpUrl = resolved.includes("://") ? resolved : `http://${resolved}`;
41
29
  from = cdpUrl;
42
30
  try {
43
- // connectOverCDP resolves the ws endpoint from /json/version under the
44
- // hood, so the plain http URL is what to hand it.
45
31
  owned = await chromium.connectOverCDP(cdpUrl);
46
32
  }
47
33
  catch (cause) {
@@ -64,11 +50,11 @@ export const chromePage = action("Chrome Page")
64
50
  page = await pageOf(resolved, url !== undefined);
65
51
  }
66
52
  page.on("console", (msg) => {
67
- logs.append({ source: label, level: "info", message: `console: ${msg.text()}` });
53
+ console.log(`console: ${msg.text()}`);
68
54
  emit("console", `from: ${label}: ${msg.text()}`);
69
55
  });
70
56
  page.on("pageerror", (err) => {
71
- logs.append({ source: label, level: "error", message: err.message });
57
+ console.error(err.message);
72
58
  emit("pageerror", `from: ${label}: ${err.message}`);
73
59
  });
74
60
  page.on("framenavigated", (frame) => {
@@ -77,16 +63,17 @@ export const chromePage = action("Chrome Page")
77
63
  });
78
64
  if (url) {
79
65
  await page.goto(url);
80
- logs.append({ source: label, level: "info", message: `navigated ${url}` });
66
+ console.log(`navigated ${url}`);
81
67
  }
82
68
  attach(page); // resolves instance.ref for every downstream action
83
- logs.append({ source: label, level: "info", message: `attached ${from}` });
84
- // Daemon: hold the page open until teardown, then drop the CDP session if it
85
- // was ours; never close a handed-in browser/window.
69
+ console.log(`attached ${from}`);
70
+ // Daemon: hold the page open until teardown, then drop the CDP session if ours.
86
71
  return new Promise((resolveRun) => {
87
72
  const stop = () => {
73
+ // Bound the close: on a broken CDP connection `close()` can hang, which
74
+ // would wedge teardown — settle after a short race regardless.
88
75
  if (owned)
89
- void owned.close().finally(resolveRun);
76
+ void Promise.race([owned.close(), delay(2000)]).finally(resolveRun);
90
77
  else
91
78
  resolveRun();
92
79
  };
@@ -1,48 +1,27 @@
1
- import type { AnyActionInstance } from "../orchestration/action/types.ts";
2
1
  /** Tunes an action built with {@link command}. */
3
2
  export interface CommandOptions {
4
- /**
5
- * Directory to run the command in. Relative paths resolve against the directory
6
- * node was executed from (process.cwd()). Default ".".
7
- */
3
+ /** Directory to run in; relative paths resolve against `process.cwd()`. Default ".". */
8
4
  cwd?: string;
9
- /**
10
- * Environment for the child. Pass the full set you want (e.g.
11
- * `{ ...process.env, NODE_ENV: "development" }`) — it's handed to the child
12
- * as-is, not merged. Omit to inherit the parent's `process.env`.
13
- */
5
+ /** Full environment for the child (handed as-is, not merged). Omit to inherit. */
14
6
  env?: NodeJS.ProcessEnv;
7
+ /** Let this command inherit the orchestrator's debugger (default false). */
8
+ inheritDebugger?: boolean;
15
9
  }
10
+ declare function build(name: string, cmdOrFile: string, argsOrOptions: readonly string[] | CommandOptions, maybeOptions: CommandOptions): import("../index.ts").ActionInstance<import("zx").ProcessOutput, import("../core/types.ts").NoEvents, void>;
16
11
  /**
17
- * Runs a fixed command as an action **instance** — built in a single call, the
18
- * same shape as every other bundled action (`healthcheck`, `prompt`, …), rather
19
- * than a factory you instantiate separately. `name` identifies it across the run
20
- * (the `launch <name>` line and the child's log source).
12
+ * Runs a shell command as an action instance. Two forms:
21
13
  *
22
- * Two forms:
14
+ * - `command(name, cmd, options?)` — a full command **string**, run verbatim through the
15
+ * shell, so pipes, globs and `&&` work (quote anything yourself).
16
+ * - `command(name, file, args, options?)` — an executable **plus an args array**; each arg
17
+ * is quoted individually, so spaces and `=`/paths in args are safe without hand-quoting.
23
18
  *
24
- * - **Shell form** — `command(name, cmd, opts)`. The string runs verbatim
25
- * through a shell (PowerShell on Windows), so pipes, globs, and `&&` work:
26
- *
27
- * command("TSKB:dev", "npm run dev:explorer", { cwd: pkg })
28
- *
29
- * The direct child is the *shell*, and the real program is a grandchild. On
30
- * Windows teardown reaps the tree only while that shell is alive — so a GUI
31
- * app that ignores console Ctrl-C (Electron) can be **orphaned and survive**.
32
- *
33
- * - **Direct form** — `command(name, file, args, opts)`. Spawns `file` with
34
- * `args` **without a shell**, so the action's direct child *is* the program.
35
- * On teardown it's force-reaped by its own PID (`taskkill /F /T`), so it dies
36
- * even when a shell would have orphaned it. Use this for long-lived apps you
37
- * launch and must be able to kill on Ctrl-C:
38
- *
39
- * command("platform", electronExe, [".", "--inspect"], { cwd: app })
40
- *
41
- * `file` must be a real executable (an actual `electron.exe`), not a `.cmd`
42
- * shim — shims need a shell to run.
43
- *
44
- * For anything beyond a static command — interpolated args, emitted events, a
45
- * daemon loop — drop down to `action(...).run(...)` directly.
19
+ * Output streams into the run's log under `name`; a non-zero exit fails the action;
20
+ * teardown kills the child tree. For interpolated events or a daemon loop, drop to
21
+ * `action(...).run(...)`.
46
22
  */
47
- export declare function command(name: string, cmd: string, options?: CommandOptions): AnyActionInstance;
48
- export declare function command(name: string, file: string, args: string[], options?: CommandOptions): AnyActionInstance;
23
+ export declare const command: {
24
+ (name: string, cmd: string, options?: CommandOptions): ReturnType<typeof build>;
25
+ (name: string, file: string, args: readonly string[], options?: CommandOptions): ReturnType<typeof build>;
26
+ };
27
+ export {};
@@ -1,90 +1,38 @@
1
- import { spawn } from "node:child_process";
2
1
  import path from "node:path";
3
- import { action } from "../orchestration/action/action.js";
4
- import { killTree } from "../system/process/process.js";
2
+ import { action } from "../core/action.js";
5
3
  /**
6
- * Build the literal part of a zx tagged template from a command string, so it
7
- * runs verbatim. zx only quotes interpolated args, not the template's `pieces` —
8
- * so placing the whole command in `pieces` is identical to writing it inline as
9
- * `` proc`cmd` ``. The command is developer-supplied (not user input), so
10
- * verbatim is exactly what we want.
4
+ * Build a zx tagged template that runs `cmd` verbatim — zx only quotes interpolated
5
+ * args, not the template's `pieces`, so the whole (developer-supplied) command in
6
+ * `pieces` runs exactly as written (pipes, globs, `&&`).
11
7
  */
12
8
  const verbatim = (cmd) => Object.assign([cmd], { raw: [cmd] });
13
- export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
14
- const direct = Array.isArray(argsOrOptions);
15
- const options = (direct ? maybeOptions : argsOrOptions) ?? {};
16
- if (direct) {
17
- return action(name).run((ctx) => spawnDirect(ctx.output.snapshots, ctx.logs, ctx.signal, name, cmdOrFile, argsOrOptions, options))();
18
- }
19
- return action(name).run(({ proc }) => {
20
- // path.resolve keeps an absolute cwd as-is and resolves a relative one
21
- // against process.cwd() — exactly the "absolute from where node runs" rule.
9
+ function build(name, cmdOrFile, argsOrOptions, maybeOptions) {
10
+ const hasArgs = Array.isArray(argsOrOptions);
11
+ const args = hasArgs ? argsOrOptions : undefined;
12
+ const options = (hasArgs ? maybeOptions : argsOrOptions) ?? {};
13
+ return action(name).run((ctx) => {
22
14
  const cwd = path.resolve(options.cwd ?? ".");
23
- // proc sources the LogsCollector from async context and streams output into it.
24
- return proc(name, {
15
+ const proc = ctx.proc(name, {
25
16
  cwd,
26
17
  ...(options.env ? { env: options.env } : {}),
27
- })(verbatim(cmdOrFile));
28
- })(); // construct the instance here, so callers get one in a single call
18
+ ...(options.inheritDebugger ? { inheritDebugger: true } : {}),
19
+ });
20
+ // `file, args` form: zx quotes the file and every arg individually (arrays spread), so
21
+ // paths/values with spaces are safe and nothing is re-parsed by the shell. String form:
22
+ // run the whole command verbatim.
23
+ return args ? proc `${cmdOrFile} ${args}` : proc(verbatim(cmdOrFile));
24
+ })();
29
25
  }
30
26
  /**
31
- * The direct (shell-less) launcher behind {@link command}'s four-arg form.
32
- * Spawns `file`/`args` with no shell — so the returned child *is* the program,
33
- * killable by its own PID on teardown — streams its stdout/stderr into the run's
34
- * log, and resolves when it exits (0 = ok; a non-zero exit is a genuine failure,
35
- * unless the run is tearing down, where the abort-kill is the reason).
27
+ * Runs a shell command as an action instance. Two forms:
28
+ *
29
+ * - `command(name, cmd, options?)` — a full command **string**, run verbatim through the
30
+ * shell, so pipes, globs and `&&` work (quote anything yourself).
31
+ * - `command(name, file, args, options?)` — an executable **plus an args array**; each arg
32
+ * is quoted individually, so spaces and `=`/paths in args are safe without hand-quoting.
33
+ *
34
+ * Output streams into the run's log under `name`; a non-zero exit fails the action;
35
+ * teardown kills the child tree. For interpolated events or a daemon loop, drop to
36
+ * `action(...).run(...)`.
36
37
  */
37
- const spawnDirect = (snapshots, logs, signal, name, file, args, options) => {
38
- const cwd = path.resolve(options.cwd ?? ".");
39
- const source = name;
40
- // windowsHide keeps a stray console window from flashing for the child.
41
- // env omitted -> spawn inherits the parent's process.env (Node's default).
42
- const child = spawn(file, args, { cwd, windowsHide: true, env: options.env });
43
- // Mirror proc's launch line: a filterable milestone on the timeline, with the
44
- // structured args snapshotted and linked (see process.ts for the rationale).
45
- const snap = snapshots.captureJson(`proc-${name}-start`, { cmd: file, args, cwd });
46
- logs.append({
47
- source: name,
48
- level: "event",
49
- message: `${name} · start · ${file} ${args.join(" ")} · in ${cwd} · → ${snap.rel}`,
50
- });
51
- if (child.stdout)
52
- logs.attach(child.stdout, source, "info");
53
- if (child.stderr)
54
- logs.attach(child.stderr, source, "error");
55
- // Force-reap the child's tree on teardown, by its own PID. Guarded so a child
56
- // that has already exited isn't re-killed (its PID may have been reused).
57
- let finished = false;
58
- const onAbort = () => {
59
- if (finished || child.pid == null)
60
- return;
61
- killTree(child.pid);
62
- };
63
- const done = () => {
64
- finished = true;
65
- signal.removeEventListener("abort", onAbort);
66
- };
67
- if (signal.aborted)
68
- killTree(child.pid ?? -1);
69
- else
70
- signal.addEventListener("abort", onAbort, { once: true });
71
- return new Promise((resolve, reject) => {
72
- child.on("error", (error) => {
73
- done();
74
- reject(error);
75
- });
76
- child.on("exit", (code, sig) => {
77
- done();
78
- // During teardown the abort-kill is why it exited — a clean shutdown, not a
79
- // fault; let it settle successfully so it isn't recorded as a failure.
80
- if (signal.aborted || code === 0)
81
- return resolve();
82
- // Non-zero exit: reject with an error carrying `exitCode`, so the framework
83
- // surfaces it on the action's Outcome (`{ ok: false, exitCode }`).
84
- const error = Object.assign(new Error(`${name} exited with ${code ?? sig}`), {
85
- exitCode: code ?? undefined,
86
- });
87
- reject(error);
88
- });
89
- });
90
- };
38
+ export const command = (name, cmdOrFile, argsOrOptions = {}, maybeOptions = {}) => build(name, cmdOrFile, argsOrOptions, maybeOptions);
@@ -28,11 +28,8 @@ export interface HealthcheckOptions {
28
28
  }
29
29
  /** The passing probe — also this action's result and `healthy` event payload. */
30
30
  export interface HealthcheckResult {
31
- /** The URL that was probed. */
32
31
  url: string;
33
- /** Status of the passing probe. */
34
32
  status: number;
35
- /** Probes made, counting the passing one. */
36
33
  attempts: number;
37
34
  }
38
35
  /** Events emitted by {@link healthcheck}. */
@@ -42,14 +39,9 @@ export interface HealthcheckEvents {
42
39
  }
43
40
  /**
44
41
  * Gate: polls an HTTP endpoint until it answers in a healthy way (status and,
45
- * optionally, body), then resolves with that probe and emits `healthy`. The
46
- * counterpart to {@link import("./until-log.ts").untilLog} for "wait until a
47
- * service is up" — robust where log-scraping is brittle (no output format to
48
- * track, no ANSI to strip).
49
- *
50
- * Connection refusals and slow probes during startup are expected and simply
51
- * retried; each request is bounded by `requestTimeoutMs` (so a hung socket can't
52
- * stall the loop) and the whole wait by the run's abort signal and an optional
42
+ * optionally, body), then resolves with that probe and emits `healthy`. Refusals
43
+ * and slow probes during startup are retried; each request is bounded by
44
+ * `requestTimeoutMs` and the whole wait by the run's abort signal and optional
53
45
  * `timeoutMs`, so teardown unblocks a pending probe instead of hanging.
54
46
  */
55
47
  export declare const healthcheck: import("../index.ts").Action<[opts?: HealthcheckOptions | undefined], HealthcheckResult, HealthcheckEvents, void>;