omkit 0.0.1 → 0.0.3

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.
@@ -1,3 +1,4 @@
1
+ import { type AnyActionInstance } from "../core/action.ts";
1
2
  /** Tunes an action built with {@link command}. */
2
3
  export interface CommandOptions {
3
4
  /**
@@ -9,18 +10,35 @@ export interface CommandOptions {
9
10
  label?: string;
10
11
  }
11
12
  /**
12
- * Runs a fixed shell command via `proc` as an action **instance** — built in a
13
- * single call, the same shape as every other bundled action (`healthcheck`,
14
- * `prompt`, …), rather than a factory you instantiate separately. `name`
15
- * identifies it across the run (the `launch <name>` line and the proc's log
16
- * source); `cmd` runs verbatim in the resolved `cwd`.
13
+ * Runs a fixed command as an action **instance** — built in a single call, the
14
+ * same shape as every other bundled action (`healthcheck`, `prompt`, …), rather
15
+ * than a factory you instantiate separately. `name` identifies it across the run
16
+ * (the `launch <name>` line and the child's log source).
17
17
  *
18
- * run(
19
- * command("TSKB:dev", "npm run dev:explorer", { cwd: pkg }),
20
- * command("TSKB:test", "npm test", { cwd: repo }),
21
- * );
18
+ * Two forms:
19
+ *
20
+ * - **Shell form** — `command(name, cmd, opts)`. The string runs verbatim
21
+ * through a shell (PowerShell on Windows), so pipes, globs, and `&&` work:
22
+ *
23
+ * command("TSKB:dev", "npm run dev:explorer", { cwd: pkg })
24
+ *
25
+ * The direct child is the *shell*, and the real program is a grandchild. On
26
+ * Windows teardown reaps the tree only while that shell is alive — so a GUI
27
+ * app that ignores console Ctrl-C (Electron) can be **orphaned and survive**.
28
+ *
29
+ * - **Direct form** — `command(name, file, args, opts)`. Spawns `file` with
30
+ * `args` **without a shell**, so the action's direct child *is* the program.
31
+ * On teardown it's force-reaped by its own PID (`taskkill /F /T`), so it dies
32
+ * even when a shell would have orphaned it. Use this for long-lived apps you
33
+ * launch and must be able to kill on Ctrl-C:
34
+ *
35
+ * command("platform", electronExe, [".", "--inspect"], { cwd: app })
36
+ *
37
+ * `file` must be a real executable (an actual `electron.exe`), not a `.cmd`
38
+ * shim — shims need a shell to run.
22
39
  *
23
40
  * For anything beyond a static command — interpolated args, emitted events, a
24
41
  * daemon loop — drop down to `action(...).run(...)` directly.
25
42
  */
26
- export declare const command: (name: string, cmd: string, options?: CommandOptions) => import("../core/action.ts").ActionInstance<import("zx").ProcessOutput, import("../core/action.ts").NoEvents, void>;
43
+ export declare function command(name: string, cmd: string, options?: CommandOptions): AnyActionInstance;
44
+ export declare function command(name: string, file: string, args: string[], options?: CommandOptions): AnyActionInstance;
@@ -1,5 +1,8 @@
1
+ import { spawn } from "node:child_process";
1
2
  import path from "node:path";
2
3
  import { action } from "../core/action.js";
4
+ import { killTree } from "../core/process.js";
5
+ import { captureSnapshot } from "../core/output.js";
3
6
  /**
4
7
  * Build the literal part of a zx tagged template from a command string, so it
5
8
  * runs verbatim. zx only quotes interpolated args, not the template's `pieces` —
@@ -8,25 +11,73 @@ import { action } from "../core/action.js";
8
11
  * verbatim is exactly what we want.
9
12
  */
10
13
  const verbatim = (cmd) => Object.assign([cmd], { raw: [cmd] });
14
+ export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
15
+ const direct = Array.isArray(argsOrOptions);
16
+ const options = (direct ? maybeOptions : argsOrOptions) ?? {};
17
+ if (direct) {
18
+ return action(name).run((ctx) => spawnDirect(ctx.logs, ctx.signal, name, cmdOrFile, argsOrOptions, options))();
19
+ }
20
+ return action(name).run(({ proc }) => {
21
+ // path.resolve keeps an absolute cwd as-is and resolves a relative one
22
+ // against process.cwd() — exactly the "absolute from where node runs" rule.
23
+ const cwd = path.resolve(options.cwd ?? ".");
24
+ // proc sources the LogsCollector from async context and streams output into it.
25
+ return proc(options.label ?? name, { cwd })(verbatim(cmdOrFile));
26
+ })(); // construct the instance here, so callers get one in a single call
27
+ }
11
28
  /**
12
- * Runs a fixed shell command via `proc` as an action **instance** — built in a
13
- * single call, the same shape as every other bundled action (`healthcheck`,
14
- * `prompt`, …), rather than a factory you instantiate separately. `name`
15
- * identifies it across the run (the `launch <name>` line and the proc's log
16
- * source); `cmd` runs verbatim in the resolved `cwd`.
17
- *
18
- * run(
19
- * command("TSKB:dev", "npm run dev:explorer", { cwd: pkg }),
20
- * command("TSKB:test", "npm test", { cwd: repo }),
21
- * );
22
- *
23
- * For anything beyond a static command — interpolated args, emitted events, a
24
- * daemon loop — drop down to `action(...).run(...)` directly.
29
+ * The direct (shell-less) launcher behind {@link command}'s four-arg form.
30
+ * Spawns `file`/`args` with no shell — so the returned child *is* the program,
31
+ * killable by its own PID on teardown — streams its stdout/stderr into the run's
32
+ * log, and resolves when it exits (0 = ok; a non-zero exit is a genuine failure,
33
+ * unless the run is tearing down, where the abort-kill is the reason).
25
34
  */
26
- export const command = (name, cmd, options = {}) => action(name).run(({ proc }) => {
27
- // path.resolve keeps an absolute cwd as-is and resolves a relative one
28
- // against process.cwd() — exactly the "absolute from where node runs" rule.
35
+ const spawnDirect = (logs, signal, name, file, args, options) => {
29
36
  const cwd = path.resolve(options.cwd ?? ".");
30
- // proc sources the LogsCollector from async context and streams output into it.
31
- return proc(options.label ?? name, { cwd })(verbatim(cmd));
32
- })(); // construct the instance here, so callers get one in a single call
37
+ const source = options.label ?? name;
38
+ // windowsHide keeps a stray console window from flashing for the child.
39
+ const child = spawn(file, args, { cwd, windowsHide: true });
40
+ // Mirror proc's launch line: a filterable milestone on the timeline, with the
41
+ // structured args snapshotted and linked (see process.ts for the rationale).
42
+ const snap = captureSnapshot(`proc-${name}-start`, { cmd: file, args, cwd });
43
+ logs.append({
44
+ source: name,
45
+ level: "event",
46
+ message: `${name} · start · ${file} ${args.join(" ")} · in ${cwd} · → ${snap.rel}`,
47
+ });
48
+ if (child.stdout)
49
+ logs.attach(child.stdout, source, "info");
50
+ if (child.stderr)
51
+ logs.attach(child.stderr, source, "error");
52
+ // Force-reap the child's tree on teardown, by its own PID. Guarded so a child
53
+ // that has already exited isn't re-killed (its PID may have been reused).
54
+ let finished = false;
55
+ const onAbort = () => {
56
+ if (finished || child.pid == null)
57
+ return;
58
+ killTree(child.pid);
59
+ };
60
+ const done = () => {
61
+ finished = true;
62
+ signal.removeEventListener("abort", onAbort);
63
+ };
64
+ if (signal.aborted)
65
+ killTree(child.pid ?? -1);
66
+ else
67
+ signal.addEventListener("abort", onAbort, { once: true });
68
+ return new Promise((resolve, reject) => {
69
+ child.on("error", (error) => {
70
+ done();
71
+ reject(error);
72
+ });
73
+ child.on("exit", (code, sig) => {
74
+ done();
75
+ // During teardown the abort-kill is why it exited — a clean shutdown, not a
76
+ // fault; let it settle successfully so it isn't recorded as a failure.
77
+ if (signal.aborted || code === 0)
78
+ resolve();
79
+ else
80
+ reject(new Error(`${name} exited with ${code ?? sig}`));
81
+ });
82
+ });
83
+ };
@@ -74,7 +74,11 @@ export const prompt = action("Prompt")
74
74
  via = "input";
75
75
  }
76
76
  else {
77
- logs.append({ source: "Prompt", level: "info", message: `invalid answer "${raw}", using default` });
77
+ logs.append({
78
+ source: "Prompt",
79
+ level: "info",
80
+ message: `invalid answer "${raw}", using default`,
81
+ });
78
82
  via = "default";
79
83
  }
80
84
  }
@@ -20,7 +20,7 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
20
20
  const file = resolve(target);
21
21
  const dir = dirname(file);
22
22
  const name = basename(file);
23
- const { source = name, level = "info", fromStart = false, encoding = "utf8", debounceMs = 50 } = opts;
23
+ const { source = name, level = "info", fromStart = false, encoding = "utf8", debounceMs = 50, } = opts;
24
24
  let pos = 0; // bytes consumed so far
25
25
  let pending = ""; // partial trailing line, buffered until its newline
26
26
  // Append every complete line in `text`, keeping any remainder for next time.
@@ -99,6 +99,9 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
99
99
  catch {
100
100
  pos = 0;
101
101
  }
102
+ // Announce the target up front so the combined log shows which file these
103
+ // lines come from — the resolved absolute path, plus whether we're replaying.
104
+ logs.append({ source, level, message: `tailing ${file}${fromStart ? " (from start)" : ""}` });
102
105
  if (fromStart)
103
106
  pump();
104
107
  const watcher = fsWatch(dir, (_event, filename) => {
@@ -62,6 +62,19 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
62
62
  * subscriptions; `once` is the awaitable, one-value form.
63
63
  */
64
64
  once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K]>;
65
+ /**
66
+ * Gate this action on a folder fingerprint: returns a new instance that skips
67
+ * running when the mtime+size of every file under `folders` matches the last
68
+ * successful run, and otherwise runs this action and records the fingerprint.
69
+ * Every path must be **absolute** (throws otherwise). A cache hit resolves
70
+ * immediately as success (result `undefined`) and logs a `cached, skipping`
71
+ * line; a failure of the inner action records nothing, so it re-runs next time.
72
+ *
73
+ * Because `nod`/`run` launch an instance the moment they receive it, chain
74
+ * `.withCache` *before* handing the instance in:
75
+ * `nod(command("build", "…").withCache(distAbs))`.
76
+ */
77
+ withCache(...folders: string[]): AnyActionInstance;
65
78
  }
66
79
  /**
67
80
  * Any constructed instance, whatever its result, events, or handle. The launch
@@ -1,5 +1,6 @@
1
1
  import { events } from "./events.js";
2
2
  import { createProc } from "./process.js";
3
+ import { fingerprint, read, write, resolveFolders } from "./folder-cache.js";
3
4
  /** A promise paired with its settlers, for the set-once handle. */
4
5
  const deferred = () => {
5
6
  let resolve;
@@ -103,6 +104,27 @@ const build = (name, exec) => {
103
104
  once(key) {
104
105
  return awaitEvent(emitter, name, key);
105
106
  },
107
+ // Wrap this instance in a fresh one (reusing the engine for its own
108
+ // emitter/ref/on/once) that fingerprints `folders` and either skips or
109
+ // runs+records. The wrapper keeps this action's `name`, so log lines and
110
+ // the `launch <name>` line are unchanged. It forwards its injected
111
+ // logs/signal straight into the inner action's `start`.
112
+ withCache(...folders) {
113
+ // Validate + canonicalize eagerly here (at the call site), not lazily
114
+ // inside the run — a relative path fails fast where it's written, and
115
+ // the resolved paths give a stable cache key regardless of spelling.
116
+ const dirs = resolveFolders(folders);
117
+ return action(name).run(async (ctx) => {
118
+ const fp = await fingerprint(dirs);
119
+ if ((await read(dirs)) === fp) {
120
+ ctx.logs.append({ source: name, level: "info", message: "cached, skipping" });
121
+ return undefined;
122
+ }
123
+ const result = await instance.start({ logs: ctx.logs, signal: ctx.signal });
124
+ await write(dirs, fp); // only on success — a throw above skips this
125
+ return result;
126
+ })();
127
+ },
106
128
  };
107
129
  return instance;
108
130
  };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A folder-fingerprint cache: hash the mtime+size of every file under one or
3
+ * more folders, so an expensive action can be skipped when nothing it depends
4
+ * on has changed since its last successful run. Backs the `.withCache(...)`
5
+ * method on action instances.
6
+ *
7
+ * Entries live under `node_modules/.cache/omkit/` (conventional, gitignored) —
8
+ * one JSON file per set of folders, named by a hash of their absolute paths.
9
+ */
10
+ /** Where cache entries are written — `node_modules/.cache/omkit/` under the cwd. */
11
+ export declare const cacheDir: string;
12
+ /**
13
+ * Require every path to be **absolute** (a relative folder is a developer error
14
+ * — throws, naming it) and return them canonicalized with `resolve`, so
15
+ * different spellings of the same folder (`.`/`..`/trailing slash) collapse to
16
+ * one stable fingerprint prefix and cache key. `resolve` on an already-absolute
17
+ * path only normalizes — it never falls back to the cwd.
18
+ */
19
+ export declare const resolveFolders: (folders: string[]) => string[];
20
+ /**
21
+ * Fingerprints one or more folders by their files' mtime+size. Every path must
22
+ * be absolute (throws otherwise). A missing folder contributes nothing (so an
23
+ * output folder that hasn't been built yet is naturally a miss, not an error).
24
+ * The entry list is sorted for determinism before hashing.
25
+ */
26
+ export declare const fingerprint: (folders: string[]) => Promise<string>;
27
+ /**
28
+ * Reads the fingerprint stored for these folders, or `undefined` when there's
29
+ * no cache (absent, unreadable, or malformed — all treated as a miss).
30
+ */
31
+ export declare const read: (folders: string[]) => Promise<string | undefined>;
32
+ /**
33
+ * Records the fingerprint for these folders after a successful run. Writes
34
+ * atomically (temp file + rename) so a concurrent read never sees a half-file.
35
+ */
36
+ export declare const write: (folders: string[], fp: string) => Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ /**
5
+ * A folder-fingerprint cache: hash the mtime+size of every file under one or
6
+ * more folders, so an expensive action can be skipped when nothing it depends
7
+ * on has changed since its last successful run. Backs the `.withCache(...)`
8
+ * method on action instances.
9
+ *
10
+ * Entries live under `node_modules/.cache/omkit/` (conventional, gitignored) —
11
+ * one JSON file per set of folders, named by a hash of their absolute paths.
12
+ */
13
+ /** Where cache entries are written — `node_modules/.cache/omkit/` under the cwd. */
14
+ export const cacheDir = join(process.cwd(), "node_modules", ".cache", "omkit");
15
+ /**
16
+ * Require every path to be **absolute** (a relative folder is a developer error
17
+ * — throws, naming it) and return them canonicalized with `resolve`, so
18
+ * different spellings of the same folder (`.`/`..`/trailing slash) collapse to
19
+ * one stable fingerprint prefix and cache key. `resolve` on an already-absolute
20
+ * path only normalizes — it never falls back to the cwd.
21
+ */
22
+ export const resolveFolders = (folders) => folders.map((folder) => {
23
+ if (!isAbsolute(folder)) {
24
+ throw new Error(`withCache: folder path must be absolute, got "${folder}"`);
25
+ }
26
+ return resolve(folder);
27
+ });
28
+ /**
29
+ * Fingerprints one or more folders by their files' mtime+size. Every path must
30
+ * be absolute (throws otherwise). A missing folder contributes nothing (so an
31
+ * output folder that hasn't been built yet is naturally a miss, not an error).
32
+ * The entry list is sorted for determinism before hashing.
33
+ */
34
+ export const fingerprint = async (folders) => {
35
+ const dirs = resolveFolders(folders);
36
+ const entries = [];
37
+ for (const folder of dirs) {
38
+ let rels;
39
+ try {
40
+ rels = await readdir(folder, { recursive: true });
41
+ }
42
+ catch {
43
+ continue; // folder missing/unreadable — contributes no entries
44
+ }
45
+ for (const rel of rels) {
46
+ const abs = join(folder, rel);
47
+ try {
48
+ const stats = await stat(abs);
49
+ if (stats.isFile())
50
+ entries.push(`${folder}\0${rel}\0${stats.mtimeMs}\0${stats.size}`);
51
+ }
52
+ catch {
53
+ // entry vanished between readdir and stat — skip
54
+ }
55
+ }
56
+ }
57
+ entries.sort();
58
+ return createHash("sha1").update(entries.join("\n")).digest("hex");
59
+ };
60
+ /** The on-disk filename for a set of folders — a hash of their absolute paths. */
61
+ const entryPath = (folders) => {
62
+ const key = createHash("sha1").update(resolveFolders(folders).sort().join("\0")).digest("hex");
63
+ return join(cacheDir, `${key}.json`);
64
+ };
65
+ /**
66
+ * Reads the fingerprint stored for these folders, or `undefined` when there's
67
+ * no cache (absent, unreadable, or malformed — all treated as a miss).
68
+ */
69
+ export const read = async (folders) => {
70
+ try {
71
+ const raw = await readFile(entryPath(folders), "utf8");
72
+ const entry = JSON.parse(raw);
73
+ return typeof entry.fingerprint === "string" ? entry.fingerprint : undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ };
79
+ /**
80
+ * Records the fingerprint for these folders after a successful run. Writes
81
+ * atomically (temp file + rename) so a concurrent read never sees a half-file.
82
+ */
83
+ export const write = async (folders, fp) => {
84
+ await mkdir(cacheDir, { recursive: true });
85
+ const target = entryPath(folders);
86
+ const entry = { folders, fingerprint: fp, ts: Date.now() };
87
+ const tmp = `${target}.${process.pid}.tmp`;
88
+ await writeFile(tmp, JSON.stringify(entry, null, 2));
89
+ await rename(tmp, target);
90
+ };
@@ -1,5 +1,18 @@
1
1
  import { type Options, type ProcessPromise } from "zx";
2
2
  import type { Logger } from "./log-collector/LogsCollector.ts";
3
+ /**
4
+ * Force-kill a process **and its whole tree, synchronously**.
5
+ *
6
+ * Windows has no process groups: a child outlives its parent, and a Ctrl-C sent
7
+ * to the console group can make an intermediate shell exit and *orphan* the real
8
+ * app (e.g. electron.exe), which then survives teardown. So the tree must be
9
+ * reaped explicitly by PID — `taskkill /F /T` does it, and running it
10
+ * **synchronously** guarantees it completes before the run's own process exits
11
+ * (a fire-and-forget kill can lose that race). On POSIX, zx's `kill` already
12
+ * does a process-group tree kill. Best-effort: an already-dead/missing PID is
13
+ * ignored.
14
+ */
15
+ export declare const killTree: (pid: number) => void;
3
16
  /**
4
17
  * Spawns a command (tagged-template, exactly like zx's `$`) and streams the
5
18
  * child's stdout/stderr into a log (stdout as `info`, stderr as `error`) under
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import os from "node:os";
2
3
  import { $, kill, usePowerShell } from "zx";
3
4
  import { captureSnapshot } from "./output.js";
@@ -5,6 +6,31 @@ import { captureSnapshot } from "./output.js";
5
6
  // Use Windows PowerShell there so zx spawns and quotes for the right shell.
6
7
  if (os.platform() === "win32")
7
8
  usePowerShell();
9
+ /**
10
+ * Force-kill a process **and its whole tree, synchronously**.
11
+ *
12
+ * Windows has no process groups: a child outlives its parent, and a Ctrl-C sent
13
+ * to the console group can make an intermediate shell exit and *orphan* the real
14
+ * app (e.g. electron.exe), which then survives teardown. So the tree must be
15
+ * reaped explicitly by PID — `taskkill /F /T` does it, and running it
16
+ * **synchronously** guarantees it completes before the run's own process exits
17
+ * (a fire-and-forget kill can lose that race). On POSIX, zx's `kill` already
18
+ * does a process-group tree kill. Best-effort: an already-dead/missing PID is
19
+ * ignored.
20
+ */
21
+ export const killTree = (pid) => {
22
+ if (process.platform === "win32") {
23
+ try {
24
+ execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
25
+ }
26
+ catch {
27
+ // already gone, access denied, or taskkill missing — nothing more to do
28
+ }
29
+ }
30
+ else {
31
+ void kill(pid).catch(() => { });
32
+ }
33
+ };
8
34
  /**
9
35
  * Builds a {@link Proc} bound to a specific log sink and abort signal. The action
10
36
  * pipeline injects one per action as `ctx.proc`, wired to that action's logger
@@ -46,19 +72,21 @@ export const createProc = (logs, signal) => (name, opts) => {
46
72
  });
47
73
  logs.attach(child.stdout, name, "info");
48
74
  logs.attach(child.stderr, name, "error");
49
- // Kill the child's entire process tree on teardown. `child.kill()` (zx's
50
- // ProcessPromise method) throws "Too late to kill" when the direct child
51
- // has already settled — but grandchildren (vite, tsc, npm scripts) may
52
- // still be alive. Using zx's standalone `kill(pid)` bypasses that guard
53
- // and does a cross-platform tree kill (taskkill /T /F on Windows,
54
- // ps.tree + process-group kill on Unix).
75
+ // Kill the child's entire process tree on teardown via {@link killTree} —
76
+ // a synchronous, force tree kill by PID (taskkill /F /T on Windows,
77
+ // process-group kill on Unix). Synchronous so it finishes before the run's
78
+ // process exits. NOTE: with zx the direct child is a *shell wrapper*
79
+ // (PowerShell on Windows), so this only reaps grandchildren while that
80
+ // wrapper is still alive; a GUI app that must survive its shell being
81
+ // Ctrl-C'd should be launched shell-less via `command(name, file, args)`,
82
+ // whose direct child is the app itself.
55
83
  let finished = false;
56
84
  const onAbort = () => {
57
85
  if (finished)
58
86
  return;
59
87
  const pid = child.pid;
60
88
  if (pid != null)
61
- void kill(pid).catch(() => { });
89
+ killTree(pid);
62
90
  };
63
91
  const markFinished = () => {
64
92
  finished = true;
package/dist/index.d.ts CHANGED
@@ -23,3 +23,4 @@ export type { Logger, LogEntry, LogInput, SubscribeOptions, } from "./core/log-c
23
23
  export { log } from "./core/log-collector/global.ts";
24
24
  export { snapshot, captureSnapshot } from "./core/output.ts";
25
25
  export type { SnapshotRef } from "./core/output.ts";
26
+ export { fingerprint, cacheDir } from "./core/folder-cache.ts";
package/dist/index.js CHANGED
@@ -23,3 +23,7 @@ export { LogsCollector } from "./core/log-collector/LogsCollector.js";
23
23
  export { log } from "./core/log-collector/global.js";
24
24
  // ── Snapshots ──────────────────────────────────────────────────────────────
25
25
  export { snapshot, captureSnapshot } from "./core/output.js";
26
+ // ── Folder cache ─────────────────────────────────────────────────────────────
27
+ // Backs the `.withCache(...folders)` method on instances; exported for scripts
28
+ // that want to inspect or manually invalidate the cache.
29
+ export { fingerprint, cacheDir } from "./core/folder-cache.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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",