babavoss 0.0.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/NOTICE +7 -0
  2. package/bin/voss.ts +48 -0
  3. package/gui/babavoss-web.js +234 -0
  4. package/gui/chunk-5hpp1ypv.js +11710 -0
  5. package/gui/chunk-9rq662fd.js +8627 -0
  6. package/gui/chunk-k3cm8j6r.js +433 -0
  7. package/gui/chunk-pk09y98y.js +50 -0
  8. package/gui/chunk-smz02qa6.js +186 -0
  9. package/gui/chunk-wwqypxre.js +49 -0
  10. package/gui/gui.js +2015 -0
  11. package/gui/react-compiler-runtime.js +39 -0
  12. package/gui/react-dom-client.js +21 -0
  13. package/gui/react-dom.js +44 -0
  14. package/gui/react-jsx-runtime.js +19 -0
  15. package/gui/react.js +105 -0
  16. package/gui/theme.css +3053 -0
  17. package/index.ts +15 -0
  18. package/package.json +50 -4
  19. package/src/baba/check.ts +90 -0
  20. package/src/baba/config.ts +261 -0
  21. package/src/baba/find.ts +12 -0
  22. package/src/baba/init.ts +176 -0
  23. package/src/baba/node.ts +483 -0
  24. package/src/baba/project.ts +63 -0
  25. package/src/baba/registry.ts +35 -0
  26. package/src/baba/worker.ts +55 -0
  27. package/src/bench/index.ts +6 -0
  28. package/src/bench/measure.ts +132 -0
  29. package/src/bench/scenarios.ts +136 -0
  30. package/src/build/builder.ts +74 -0
  31. package/src/build/failure.ts +78 -0
  32. package/src/build/guard.ts +85 -0
  33. package/src/build/mdx-register.ts +3 -0
  34. package/src/build/mdx.ts +38 -0
  35. package/src/build/project.ts +38 -0
  36. package/src/build/views.ts +146 -0
  37. package/src/builder/main.ts +29 -0
  38. package/src/desktop/bob.ts +76 -0
  39. package/src/desktop/desktop.css +111 -0
  40. package/src/desktop/icons.ts +50 -0
  41. package/src/desktop/index.ts +323 -0
  42. package/src/desktop/routes.ts +98 -0
  43. package/src/desktop/view.tsx +673 -0
  44. package/src/door/core.ts +384 -0
  45. package/src/ecs/baba.ts +431 -0
  46. package/src/ecs/codec.ts +334 -0
  47. package/src/ecs/handles.ts +91 -0
  48. package/src/ecs/replica.ts +150 -0
  49. package/src/ecs/runtime.ts +603 -0
  50. package/src/ecs/scheduler.ts +75 -0
  51. package/src/ecs/snapshot.ts +102 -0
  52. package/src/ecs/state.ts +759 -0
  53. package/src/ecs/system.ts +256 -0
  54. package/src/ecs/table.ts +420 -0
  55. package/src/ecs/testbed.ts +97 -0
  56. package/src/exec/host.ts +177 -0
  57. package/src/exec/main.ts +98 -0
  58. package/src/exec/watch.ts +7 -0
  59. package/src/exec/wire.ts +29 -0
  60. package/src/generated/build.ts +4 -0
  61. package/src/gui/css.d.ts +1 -0
  62. package/src/gui/gui.tsx +245 -0
  63. package/src/gui/index.ts +51 -0
  64. package/src/gui/inspector.tsx +47 -0
  65. package/src/gui/levels.tsx +73 -0
  66. package/src/gui/promptware.tsx +68 -0
  67. package/src/gui/runner.tsx +118 -0
  68. package/src/gui/theme.css +498 -0
  69. package/src/gui/theme.ts +25 -0
  70. package/src/gui/wizard.tsx +227 -0
  71. package/src/guide/add-a-desktop.mdx +100 -0
  72. package/src/guide/compose-an-interface.mdx +84 -0
  73. package/src/guide/index.ts +13 -0
  74. package/src/guide/reach-outside.mdx +112 -0
  75. package/src/guide/spec-a-system.mdx +93 -0
  76. package/src/guide/systems-together.mdx +72 -0
  77. package/src/guide/write-a-system.mdx +183 -0
  78. package/src/guide/write-promptware.mdx +90 -0
  79. package/src/http/server.ts +310 -0
  80. package/src/kernel/build.ts +21 -0
  81. package/src/kernel/builder.ts +105 -0
  82. package/src/kernel/children.ts +117 -0
  83. package/src/kernel/context.ts +90 -0
  84. package/src/kernel/lock.ts +46 -0
  85. package/src/kernel/names.ts +14 -0
  86. package/src/kernel/schema.ts +130 -0
  87. package/src/kernel/where.ts +12 -0
  88. package/src/kit/index.ts +232 -0
  89. package/src/maker/system.ts +213 -0
  90. package/src/mcp/daemon.ts +61 -0
  91. package/src/mcp/main.ts +208 -0
  92. package/src/mcp/rpc.ts +64 -0
  93. package/src/mcp/tools.ts +125 -0
  94. package/src/prompt/evals.ts +42 -0
  95. package/src/prompt/index.ts +242 -0
  96. package/src/prompt/jsx-dev-runtime.ts +1 -0
  97. package/src/prompt/jsx-runtime.ts +49 -0
  98. package/src/prompt/mdx.d.ts +1 -0
  99. package/src/promptware/compile.ts +183 -0
  100. package/src/promptware/define.ts +16 -0
  101. package/src/promptware/disk.ts +72 -0
  102. package/src/promptware/markdown.d.ts +6 -0
  103. package/src/promptware/sync.ts +437 -0
  104. package/src/promptware/system.ts +215 -0
  105. package/src/runtime/bridge.ts +85 -0
  106. package/src/runtime/connect.ts +54 -0
  107. package/src/runtime/env.ts +35 -0
  108. package/src/runtime/harness.ts +80 -0
  109. package/src/runtime/main.ts +119 -0
  110. package/src/runtime/worker.ts +33 -0
  111. package/src/server/edge.ts +332 -0
  112. package/src/server/main.ts +45 -0
  113. package/src/server/messages.ts +97 -0
  114. package/src/server/protocol.ts +37 -0
  115. package/src/services/args.ts +45 -0
  116. package/src/services/exec.ts +69 -0
  117. package/src/services/fs.ts +139 -0
  118. package/src/services/http.ts +30 -0
  119. package/src/services/index.ts +113 -0
  120. package/src/services/secrets.ts +18 -0
  121. package/src/shell/address.ts +21 -0
  122. package/src/shell/args.ts +219 -0
  123. package/src/shell/client.ts +107 -0
  124. package/src/shell/codes.ts +26 -0
  125. package/src/shell/positional.ts +20 -0
  126. package/src/shell/run.ts +470 -0
  127. package/src/shell/service.ts +167 -0
  128. package/src/shell/state.ts +204 -0
  129. package/src/spec/adapters.ts +72 -0
  130. package/src/spec/diff.ts +26 -0
  131. package/src/spec/files.ts +17 -0
  132. package/src/spec/index.ts +155 -0
  133. package/src/spec/run.ts +97 -0
  134. package/src/spec/take.ts +54 -0
  135. package/src/test/index.ts +8 -0
  136. package/src/test/prove.ts +56 -0
  137. package/src/test/records.ts +23 -0
  138. package/src/test/specs.ts +56 -0
  139. package/src/test/steps.ts +100 -0
  140. package/src/test/voss-dir.ts +17 -0
  141. package/src/transport/messages.ts +110 -0
  142. package/src/transport/transport.ts +62 -0
  143. package/src/wall/probe.ts +67 -0
  144. package/src/wall/profile.ts +103 -0
  145. package/src/wall/spawn.ts +59 -0
  146. package/src/web/app.tsx +53 -0
  147. package/src/web/core.tsx +140 -0
  148. package/src/web/form.ts +155 -0
  149. package/src/web/hooks.ts +135 -0
  150. package/src/web/index.tsx +17 -0
  151. package/src/web/list.ts +19 -0
  152. package/src/web/maker.tsx +766 -0
  153. package/src/web/objects.tsx +213 -0
  154. package/src/web/socket.ts +84 -0
  155. package/src/web/state.tsx +69 -0
  156. package/src/web/store.ts +221 -0
  157. package/src/web/ui.tsx +135 -0
  158. package/README.md +0 -5
@@ -0,0 +1,139 @@
1
+ // Files, scoped to the project: the project is the baba's own ground, so
2
+ // everything inside is granted, and nothing outside is reachable, not
3
+ // through "..", not through an absolute path, not through a symlink.
4
+ import { realpath, stat, lstat, readdir, mkdir, rename, unlink, rmdir } from "node:fs/promises";
5
+ import { watch as fsWatch } from "node:fs";
6
+ import { dirname, basename, isAbsolute, join, normalize, relative, sep } from "node:path";
7
+ import { ServiceError, type Fs, type Entry, type Change, type Who } from "../kernel/context.ts";
8
+ import { string, bytes, type Ops } from "./args.ts";
9
+
10
+ /** How many distinct paths a burst may hold before it becomes one resync; how long to wait for a burst to settle. */
11
+ const COALESCE = 2000;
12
+ const SETTLE = 50;
13
+
14
+ const missing = (e: unknown) => typeof e === "object" && e !== null && (e as { code?: string }).code === "ENOENT";
15
+
16
+ /** The absolute path `path` names inside the project, with symlinks followed; denied if it leaves. */
17
+ export async function inside(who: Who, path: string): Promise<string> {
18
+ const root = await realpath(who.project);
19
+ // Follow symlinks on the deepest existing prefix; the rest does not exist yet, so it cannot point anywhere.
20
+ let existing = isAbsolute(path) ? normalize(path) : join(who.project, path);
21
+ const rest: string[] = [];
22
+ for (;;) {
23
+ try { existing = await realpath(existing); break; } catch (e) {
24
+ if (!missing(e) || existing === dirname(existing)) throw e;
25
+ rest.unshift(basename(existing));
26
+ existing = dirname(existing);
27
+ }
28
+ }
29
+ const real = join(existing, ...rest);
30
+ if (real !== root && !real.startsWith(root + sep)) throw new ServiceError("denied", `${who.system} asked for ${path}, which is outside its project ${who.project}; a baba reaches only files under its project`);
31
+ return real;
32
+ }
33
+
34
+ async function entry(who: Who, real: string): Promise<Entry | null> {
35
+ let s;
36
+ // A symlink is followed only where it stays inside the project; one that leaves is not listed.
37
+ try { if ((await lstat(real)).isSymbolicLink()) await inside(who, real); } catch (e) { if (missing(e) || e instanceof ServiceError) return null; throw e; }
38
+ try { s = await stat(real); } catch (e) { if (missing(e)) return null; throw e; }
39
+ const root = await realpath(who.project);
40
+ return { name: basename(real), path: relative(root, real).split(sep).join("/"), dir: s.isDirectory(), size: s.size, updated: s.mtime.toISOString() };
41
+ }
42
+
43
+ /** Whatever the file system refuses, said with the path the baba asked for. */
44
+ function failed(path: string, e: unknown): never {
45
+ if (e instanceof ServiceError) throw e;
46
+ if (missing(e)) throw new ServiceError("notfound", `${path}: no such file or directory`);
47
+ throw new ServiceError("failed", `${path}: ${e instanceof Error ? e.message : String(e)}`);
48
+ }
49
+
50
+ export const fs: Ops<Fs> = {
51
+ async read(who, [p]) {
52
+ const path = string(p, "path");
53
+ try { return await Bun.file(await inside(who, path)).text(); } catch (e) { failed(path, e); }
54
+ },
55
+ async readBytes(who, [p]) {
56
+ const path = string(p, "path");
57
+ try { return await Bun.file(await inside(who, path)).bytes(); } catch (e) { failed(path, e); }
58
+ },
59
+ async write(who, [p, c]) {
60
+ const path = string(p, "path");
61
+ const content = bytes(c, "content");
62
+ try {
63
+ const real = await inside(who, path);
64
+ await mkdir(dirname(real), { recursive: true });
65
+ // Atomic: a sibling temp file, then a rename; a reader never sees half a file.
66
+ const temp = `${real}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
67
+ try { await Bun.write(temp, content); await rename(temp, real); } catch (e) { await unlink(temp).catch(() => {}); throw e; }
68
+ } catch (e) { failed(path, e); }
69
+ },
70
+ async list(who, [p]) {
71
+ const path = string(p, "path");
72
+ try {
73
+ const real = await inside(who, path);
74
+ const names = (await readdir(real)).sort();
75
+ const entries = await Promise.all(names.map((n) => entry(who, join(real, n))));
76
+ return entries.filter((e): e is Entry => e !== null);
77
+ } catch (e) { failed(path, e); }
78
+ },
79
+ async stat(who, [p]) {
80
+ const path = string(p, "path");
81
+ try { return await entry(who, await inside(who, path)); } catch (e) { failed(path, e); }
82
+ },
83
+ /**
84
+ * Every change under the directory, as the OS reports it, each looked
85
+ * up as it is now: the entry, or gone. Changes are coalesced by path
86
+ * between reads; past COALESCE paths a single `resync` stands for them
87
+ * all, so a burst never grows without bound. Ends when the signal says.
88
+ */
89
+ async *watch(who, [p], signal) {
90
+ const path = string(p, "path");
91
+ let real: string;
92
+ try { real = await inside(who, path); if (!(await stat(real)).isDirectory()) throw new ServiceError("notfound", `${path}: not a directory`); } catch (e) { failed(path, e); }
93
+ const root = await realpath(who.project);
94
+ const pending = new Map<string, true>();
95
+ let resync = false;
96
+ let wake: (() => void) | null = null;
97
+ const watcher = fsWatch(real, { recursive: true }, (_, name) => {
98
+ if (name === null || name === undefined) resync = true;
99
+ else if (pending.size >= COALESCE) { resync = true; pending.clear(); }
100
+ else pending.set(String(name), true);
101
+ wake?.();
102
+ });
103
+ let error: Error | null = null;
104
+ watcher.on("error", (e) => { error = e; wake?.(); });
105
+ const stop = () => { watcher.close(); wake?.(); };
106
+ signal.addEventListener("abort", stop, { once: true });
107
+ try {
108
+ while (!signal.aborted) {
109
+ if (!resync && pending.size === 0 && !error) await new Promise<void>((r) => { wake = r; });
110
+ wake = null;
111
+ if (error) throw error;
112
+ if (signal.aborted) break;
113
+ // A short quiet after the first report, so a save's several events read as one.
114
+ await new Promise((r) => setTimeout(r, SETTLE));
115
+ if (resync) { resync = false; pending.clear(); yield { resync: true } satisfies Change; continue; }
116
+ const names = [...pending.keys()];
117
+ pending.clear();
118
+ const out: Change[] = [];
119
+ for (const name of names) {
120
+ const e = await entry(who, join(real, name)).catch(() => null);
121
+ out.push(e ?? { path: relative(root, join(real, name)).split(sep).join("/"), gone: true });
122
+ }
123
+ for (const c of out) yield c;
124
+ }
125
+ } finally { signal.removeEventListener("abort", stop); watcher.close(); }
126
+ },
127
+ async remove(who, [p]) {
128
+ const path = string(p, "path");
129
+ try {
130
+ const real = await inside(who, path);
131
+ if (real === await realpath(who.project)) throw new ServiceError("denied", `${who.system} asked to remove ${path}, the project itself; a baba removes files and empty directories under it`);
132
+ if ((await lstat(real)).isDirectory()) await rmdir(real); else await unlink(real);
133
+ } catch (e) { failed(path, e); }
134
+ },
135
+ async mkdir(who, [p]) {
136
+ const path = string(p, "path");
137
+ try { await mkdir(await inside(who, path), { recursive: true }); } catch (e) { failed(path, e); }
138
+ },
139
+ };
@@ -0,0 +1,30 @@
1
+ // The network, through the kernel: every request is attributed to the
2
+ // system, effect and job that made it, and the log says where it went.
3
+ import { ServiceError, type Http, type Who } from "../kernel/context.ts";
4
+ import { string, optional, record, object, type Ops } from "./args.ts";
5
+
6
+ export const Timeout = 30_000;
7
+
8
+
9
+ export const http: Ops<Http> = {
10
+ async fetch(who, [u, i], signal) {
11
+ const text = string(u, "url");
12
+ const init = optional(i, "init", object) ?? {};
13
+ const method = optional(init.method, "init.method", string);
14
+ const headers = optional(init.headers, "init.headers", record);
15
+ const body = optional(init.body, "init.body", string);
16
+ let url: URL;
17
+ try { url = new URL(text); } catch { throw new ServiceError("args", `url: ${text} is not a URL`); }
18
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new ServiceError("denied", `${who.system} asked to fetch ${url.protocol}//; a baba fetches http: and https: only`);
19
+ // Redirects are answered, not followed: a followed one could land on an undeclared host.
20
+ try {
21
+ const r = await fetch(url, { method, headers, body, signal: AbortSignal.any([signal, AbortSignal.timeout(Timeout)]), redirect: "manual" });
22
+ const out: Record<string, string> = {};
23
+ r.headers.forEach((v, k) => { out[k.toLowerCase()] = v; });
24
+ return { status: r.status, headers: out, body: await r.text() };
25
+ } catch (e) {
26
+ const why = e instanceof Error && e.name === "TimeoutError" ? `no answer in ${Timeout / 1000} s` : e instanceof Error ? e.message : String(e);
27
+ throw new ServiceError("failed", `fetch ${url.href}: ${why}`);
28
+ }
29
+ },
30
+ };
@@ -0,0 +1,113 @@
1
+ // The kernel's services: the only door a baba has to the machine. One
2
+ // class dispatches a request to its service, checks the operation exists,
3
+ // times it, and logs every grant and denial with who asked.
4
+ import { ServiceError, type Services, type Who } from "../kernel/context.ts";
5
+ import { isStream, type ServiceRequest, type Serviced, type ServiceName } from "../transport/messages.ts";
6
+ import type { Ops } from "./args.ts";
7
+ import { fs } from "./fs.ts";
8
+ import { http } from "./http.ts";
9
+ import { execution } from "./exec.ts";
10
+ import { secrets } from "./secrets.ts";
11
+
12
+ /** One line of the kernel's log: what was asked, by whom, and how it went. */
13
+ export interface Served { who: Who; service: ServiceName; op: string; args: unknown[]; ok: boolean; ms: number; code?: ServiceError["code"]; error?: string }
14
+
15
+ /**
16
+ * The log's line for a served request: every reach beyond the project,
17
+ * the network, programs and secrets, whether it went well or not; files
18
+ * only when they fail. Empty when nothing is worth a line.
19
+ */
20
+ export function describe(e: Served): string {
21
+ const beyond = e.service === "http" || e.service === "exec" || e.service === "secrets";
22
+ if (e.ok && !beyond) return "";
23
+ const what = e.service === "http" ? String(e.args[0] ?? "").replace(/^(https?:\/\/[^/]+)(\/.*)?$/, "$1") : e.service === "exec" ? [e.args[0], ...(Array.isArray(e.args[1]) ? e.args[1] : [])].map(String).join(" ") : e.service === "secrets" ? String(e.args[0] ?? "") : "";
24
+ const how = e.ok ? `${e.ms.toFixed(0)} ms` : `${e.code}: ${e.error}`;
25
+ return `${e.who.system}.${e.who.effect} ${e.service}.${e.op}${what ? ` ${what}` : ""}: ${how}`;
26
+ }
27
+
28
+ const modules = { fs, http, exec: null, secrets };
29
+
30
+ export class KernelServices implements Services {
31
+ private readonly modules: Record<ServiceName, Ops<any>> = { fs, http, exec: execution(), secrets };
32
+ constructor(private log: (entry: Served) => void = () => {}) {}
33
+
34
+ fs = (who: Who, op: string, args: unknown[], signal?: AbortSignal) => this.serve("fs", who, op, args, signal);
35
+ http = (who: Who, op: string, args: unknown[], signal?: AbortSignal) => this.serve("http", who, op, args, signal);
36
+ exec = (who: Who, op: string, args: unknown[], signal?: AbortSignal) => this.serve("exec", who, op, args, signal);
37
+ secrets = (who: Who, op: string, args: unknown[], signal?: AbortSignal) => this.serve("secrets", who, op, args, signal);
38
+
39
+ private serve(service: ServiceName, who: Who, op: string, args: unknown[], signal = new AbortController().signal): Promise<unknown> | AsyncIterable<unknown> {
40
+ const ops = this.modules[service];
41
+ const started = performance.now();
42
+ const fail = (e: unknown): ServiceError => {
43
+ if (e instanceof Error && e.name === "ExecutionCleanupError") throw e;
44
+ const error = e instanceof ServiceError ? e : new ServiceError("failed", e instanceof Error ? e.message : String(e));
45
+ this.log({ who, service, op, args, ok: false, ms: performance.now() - started, code: error.code, error: error.message });
46
+ return error;
47
+ };
48
+ try {
49
+ if (!Object.hasOwn(ops, op)) throw new ServiceError("args", `${service} has no operation ${op}; operations: ${Object.keys(ops).join(", ")}`);
50
+ if (!Array.isArray(args)) throw new ServiceError("args", `${service} ${op}: expected an array of arguments`);
51
+ } catch (e) { return Promise.reject(fail(e)); }
52
+ if (isStream(service, op)) {
53
+ // A stream is logged when it opens and when it closes, never per answer.
54
+ const log = this.log;
55
+ const inner = ops[op]!(who, args, signal) as AsyncIterable<unknown>;
56
+ return (async function* () {
57
+ log({ who, service, op, args, ok: true, ms: 0 });
58
+ try { for await (const v of inner) yield v; } catch (e) { throw fail(e); }
59
+ finally { log({ who, service, op: `${op} closed`, args, ok: true, ms: performance.now() - started }); }
60
+ })();
61
+ }
62
+ return (async () => {
63
+ try {
64
+ const value = await (ops[op]!(who, args, signal) as Promise<unknown>);
65
+ this.log({ who, service, op, args, ok: true, ms: performance.now() - started });
66
+ return value;
67
+ } catch (e) { throw fail(e); }
68
+ })();
69
+ }
70
+ }
71
+
72
+ /** How many values one streamed answer carries at most. */
73
+ export const BATCH = 500;
74
+
75
+ /**
76
+ * Answers one service request of a runtime, through `send`: once, or for a
77
+ * streaming operation many times, batched, then once more to end it. The
78
+ * kernel resolves `who` from the request; `signal` is the request's, aborted
79
+ * when the runtime cancels or goes away.
80
+ */
81
+ export async function serveRequest(services: Services, who: Who, req: ServiceRequest, send: (m: Serviced) => void, signal: AbortSignal, credit?: () => Promise<void>): Promise<void> {
82
+ const { id, service, op, args } = req;
83
+ const failed = (e: unknown): Serviced => e instanceof ServiceError
84
+ ? { type: "serviced", id, ok: false, code: e.code, error: e.message }
85
+ : { type: "serviced", id, ok: false, code: "failed", error: e instanceof Error ? e.message : String(e) };
86
+ try {
87
+ if (!Object.hasOwn(modules, service)) throw new ServiceError("args", `no service ${service}; services: ${Object.keys(modules).join(", ")}`);
88
+ if (!Array.isArray(args)) throw new ServiceError("args", `${service} ${op}: expected an array of arguments`);
89
+ const answer = services[service](who, op as never, args, signal);
90
+ if (!isStream(service, op)) { send({ type: "serviced", id, ok: true, value: await (answer as Promise<unknown>) }); return; }
91
+ if (service === "exec") {
92
+ for await (const v of answer as AsyncIterable<unknown>) {
93
+ await credit?.();
94
+ if (signal.aborted) return;
95
+ send({ type: "serviced", id, ok: true, more: true, values: [v] });
96
+ }
97
+ if (!signal.aborted) send({ type: "serviced", id, ok: true });
98
+ return;
99
+ }
100
+ let batch: unknown[] = [];
101
+ for await (const v of answer as AsyncIterable<unknown>) {
102
+ if (signal.aborted) return;
103
+ batch.push(v);
104
+ if (batch.length >= BATCH) { send({ type: "serviced", id, ok: true, more: true, values: batch }); batch = []; }
105
+ else queueMicrotask(() => { if (batch.length) { send({ type: "serviced", id, ok: true, more: true, values: batch }); batch = []; } });
106
+ }
107
+ if (batch.length) send({ type: "serviced", id, ok: true, more: true, values: batch });
108
+ if (!signal.aborted) send({ type: "serviced", id, ok: true });
109
+ } catch (e) {
110
+ if (e instanceof Error && e.name === "ExecutionCleanupError") throw e;
111
+ if (!signal.aborted) send(failed(e));
112
+ }
113
+ }
@@ -0,0 +1,18 @@
1
+ // Secrets, read from the operating
2
+ // system's keychain under the service "babavoss"; the value never rests in
3
+ // a file of the project.
4
+ import { ServiceError, type Secrets, type Who } from "../kernel/context.ts";
5
+ import { string, type Ops } from "./args.ts";
6
+
7
+ export const Service = "babavoss";
8
+
9
+ export const secrets: Ops<Secrets> = {
10
+ async get(who, [n]) {
11
+ const name = string(n, "name");
12
+ const keychain = (Bun as { secrets?: typeof Bun.secrets }).secrets;
13
+ if (typeof keychain?.get !== "function") throw new ServiceError("failed", `the keychain is not available on this platform (${process.platform}); secret ${name} cannot be read`);
14
+ try { return await keychain.get({ service: Service, name }); } catch (e) {
15
+ throw new ServiceError("failed", `secret ${name}: the keychain answered ${e instanceof Error ? e.message : String(e)}`);
16
+ }
17
+ },
18
+ };
@@ -0,0 +1,21 @@
1
+ import { UsageError } from "./args.ts";
2
+
3
+ /** Voss's conventional TCP port: 0xBABA. Explicit addresses override it. */
4
+ export const DEFAULT_PORT = 0xBABA;
5
+ export const DEFAULT_HOST = "127.0.0.1";
6
+ export const DEFAULT_ADDRESS = `${DEFAULT_HOST}:${DEFAULT_PORT}`;
7
+
8
+ export function hostPort(word: string | undefined): [string, number] {
9
+ const split = word?.lastIndexOf(":") ?? -1;
10
+ const host = split >= 0 ? word!.slice(0, split) || DEFAULT_HOST : DEFAULT_HOST;
11
+ const port = split >= 0 ? word!.slice(split + 1) : word ?? String(DEFAULT_PORT);
12
+ if (!/^\d+$/.test(port) || Number(port) > 65535) throw new UsageError(`expected HOST:PORT or PORT, got ${word}`);
13
+ return [host.replace(/^\[(.*)\]$/, "$1"), Number(port)];
14
+ }
15
+
16
+ /** A browser address for the installed service, including explicit hosts. */
17
+ export function browserUrl(address: string): string {
18
+ const [host, port] = hostPort(address);
19
+ const browserHost = ["127.0.0.1", "0.0.0.0", "::"].includes(host) ? "localhost" : host;
20
+ return `http://${browserHost.includes(":") ? `[${browserHost}]` : browserHost}:${port}/`;
21
+ }
@@ -0,0 +1,219 @@
1
+ // Words to arguments, and a command's usage line from its manifest.
2
+ import type { Json } from "../kernel/schema.ts";
3
+ import { redact } from "../ecs/handles.ts";
4
+
5
+ export class UsageError extends Error {
6
+ /** The code it answers: `usage` for the words, `args` for what they say. */
7
+ constructor(msg: string, readonly code: "usage" | "args" = "usage") { super(msg); }
8
+ }
9
+
10
+ export interface Spec {
11
+ /** The args filled from bare words, in order. */
12
+ positional?: string[];
13
+ /** Flags that take no value: `--flag`, `--flag=false`, `--no-flag`. */
14
+ booleans?: string[];
15
+ /** The last positional takes the rest of the bare words when it is one of these. */
16
+ arrays?: string[];
17
+ }
18
+
19
+ /**
20
+ * A command's words to its args. `--name VALUE` and `--name=VALUE` are
21
+ * flags; `--name=VALUE` is the spelling for a value starting with `--`. A
22
+ * flag that needs a value and has none is a UsageError. A boolean (named in
23
+ * `booleans`) never takes the next word: it is "true" bare, and
24
+ * `--flag=false` or `--no-flag` for false; a bare true or false right after
25
+ * it is a UsageError. A flag given twice is an array. Bare words fill the
26
+ * positionals not given as flags, in order; the last positional takes the
27
+ * rest when it is an array (`arrays`). After `--` every word is bare. An
28
+ * extra bare word is a UsageError.
29
+ */
30
+ export function words(ws: string[], spec: Spec | string[] = {}, booleansOld: string[] = [], arraysOld: string[] = []): Record<string, string | string[]> {
31
+ const { positional = [], booleans = [], arrays = [] } = Array.isArray(spec) ? { positional: spec, booleans: booleansOld, arrays: arraysOld } : spec;
32
+ const out: Record<string, string | string[]> = {};
33
+ const bare = barewords(ws, booleans, out);
34
+ const open = positional.filter((p) => !(p in out));
35
+ for (let i = 0; i < open.length && bare.length; i++) {
36
+ const p = open[i]!;
37
+ const last = p === positional.at(-1);
38
+ out[p] = last && arrays.includes(p) ? bare.splice(0) : bare.shift()!;
39
+ }
40
+ if (bare.length) {
41
+ const took = positional.length ? `takes ${positional.map((p) => `<${p}>`).join(" ")}` : "takes no bare words";
42
+ throw new UsageError(`${took}; extra: ${bare.map((b) => JSON.stringify(b)).join(" ")}`);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** The flags of `ws` into `out`; answers the bare words, in order. */
48
+ function barewords(ws: string[], booleans: string[], out: Record<string, string | string[]>, at: number[] = []): string[] {
49
+ const bare: string[] = [];
50
+ const set = (name: string, v: string) => {
51
+ const had = out[name];
52
+ out[name] = had === undefined ? v : Array.isArray(had) ? [...had, v] : [had, v];
53
+ };
54
+ let rest = false;
55
+ for (let i = 0; i < ws.length; i++) {
56
+ const w = ws[i]!;
57
+ if (rest || !w.startsWith("--")) { bare.push(w); at.push(i); continue; }
58
+ if (w === "--") { rest = true; continue; }
59
+ const eq = w.indexOf("=");
60
+ if (eq > 0) { set(w.slice(2, eq), w.slice(eq + 1)); continue; }
61
+ const name = w.slice(2);
62
+ const next = ws[i + 1];
63
+ if (booleans.includes(name)) {
64
+ if (next === "true" || next === "false") throw new UsageError(`--${name} is a flag on its own; write --${name}=${next}`);
65
+ set(name, "true");
66
+ continue;
67
+ }
68
+ if (name.startsWith("no-") && booleans.includes(name.slice(3))) { set(name.slice(3), "false"); continue; }
69
+ if (next === undefined || next.startsWith("--")) throw new UsageError(`--${name} needs a value; write --${name}=VALUE for a value starting with --`);
70
+ set(name, next);
71
+ i++;
72
+ }
73
+ return bare;
74
+ }
75
+
76
+ type Props = Record<string, Json>;
77
+ const propsOf = (args: Json) => (args.properties ?? {}) as Props;
78
+ /** A schema's type, its null removed: "string", "object", … or undefined. */
79
+ const typeOf = (j: Json | undefined): string | undefined => {
80
+ const t = j?.type;
81
+ return Array.isArray(t) ? (t.find((x) => x !== "null") as string | undefined) : (t as string | undefined);
82
+ };
83
+ const scalar = (j: Json | undefined) => { const t = typeOf(j); return t === undefined ? !(j && "properties" in j) : ["string", "number", "integer", "boolean"].includes(t); };
84
+ /** Whether a flag's value is JSON: an object, or an array whose items are not scalars. */
85
+ const jsonValued = (j: Json | undefined) => !!j && (typeOf(j) === "object" || "properties" in j || (typeOf(j) === "array" && !scalar(j.items as Json | undefined)));
86
+ const secret = (j: Json | undefined) => j?.format === "secret";
87
+
88
+ /** What words() needs from a command's args schema: its boolean and array properties. */
89
+ export function kinds(args: Json): { booleans: string[]; arrays: string[] } {
90
+ const props = propsOf(args);
91
+ const of = (t: string) => Object.keys(props).filter((n) => typeOf(props[n]) === t);
92
+ return { booleans: of("boolean"), arrays: of("array") };
93
+ }
94
+
95
+ /** A call as the shell sees it: its args schema and positionals. */
96
+ export interface Call { args: Json; positional?: string[] }
97
+
98
+ /**
99
+ * A call's words to its args, by the schema: flags and positionals as
100
+ * words() reads them; an object field, or an array of non-scalars, takes its
101
+ * flag's value as JSON; one bare word that is a JSON object is the whole args,
102
+ * the canonical spelling (a flag wins over it); with positionals declared, a
103
+ * bare `{` word that is not JSON is a positional like any other. A secret is never
104
+ * taken from the command line: `--NAME @-` reads the whole of stdin, trimmed,
105
+ * for any field. `extra` names flags the caller takes for itself (`--key`):
106
+ * they are answered apart, not as args.
107
+ */
108
+ export async function callArgs(ws: string[], def: Call, stdin?: () => Promise<string>, extra: string[] = []): Promise<{ args: Record<string, unknown>; extra: Record<string, string> }> {
109
+ const props = propsOf(def.args);
110
+ const positional = def.positional ?? [];
111
+ const { booleans, arrays } = kinds(def.args);
112
+ let whole: Record<string, unknown> = {};
113
+ let rest = ws;
114
+ const at: number[] = [];
115
+ const bare = barewords(ws, booleans, {}, at);
116
+ if (bare.length === 1 && bare[0] === "@-") {
117
+ // The whole args from stdin, as a harness hands an event to a hook.
118
+ if (!stdin) throw new UsageError("@- reads the args from standard input, and there is none");
119
+ let parsed: unknown;
120
+ try { parsed = JSON.parse((await stdin()).trim() || "{}"); } catch { throw new UsageError("the args on stdin are not JSON", "args"); }
121
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new UsageError("the args are a JSON object", "args");
122
+ whole = parsed as Record<string, unknown>;
123
+ rest = [...ws.slice(0, at[0]), ...ws.slice(at[0]! + 1)];
124
+ } else if (bare.length === 1 && bare[0]!.startsWith("{")) {
125
+ // One bare {…} word is the whole args, the canonical spelling; with positionals declared, a word that is not a JSON object is a positional like any other.
126
+ let parsed: unknown;
127
+ try { parsed = JSON.parse(bare[0]!); } catch { if (!positional.length) throw new UsageError(`the args are not JSON: ${bare[0]}`, "args"); }
128
+ if (parsed !== undefined || !positional.length) {
129
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new UsageError("the args are a JSON object", "args");
130
+ whole = parsed as Record<string, unknown>;
131
+ if (JSON.stringify(redact(def.args, whole)) !== JSON.stringify(whole)) throw new UsageError("a secret is never on the command line; pass it as --NAME @- to read it from stdin", "args");
132
+ rest = [...ws.slice(0, at[0]), ...ws.slice(at[0]! + 1)];
133
+ }
134
+ }
135
+ const w = words(rest, { positional, booleans, arrays });
136
+ const out: Record<string, unknown> = { ...whole };
137
+ const taken: Record<string, string> = {};
138
+ let read = false;
139
+ for (const [n, v] of Object.entries(w)) {
140
+ if (extra.includes(n)) {
141
+ if (Array.isArray(v)) throw new UsageError(`--${n} is given once`);
142
+ taken[n] = v;
143
+ continue;
144
+ }
145
+ const p = props[n];
146
+ if (v === "@-") {
147
+ if (!stdin) throw new UsageError(`--${n} @- reads standard input, and there is none`);
148
+ if (read) throw new UsageError(`only one field reads standard input`);
149
+ read = true;
150
+ out[n] = (await stdin()).trim();
151
+ continue;
152
+ }
153
+ if (secret(p) || secret(p?.items as Json | undefined)) {
154
+ throw new UsageError(`${n} is a secret, never on the command line; pass it as --${n} @- to read it from stdin`, "args");
155
+ }
156
+ if (jsonValued(p)) {
157
+ const one = (s: string) => {
158
+ let x: unknown;
159
+ try { x = JSON.parse(s); } catch { throw new UsageError(`--${n} takes JSON, got ${s}`, "args"); }
160
+ if (JSON.stringify(redact(p!, x)) !== JSON.stringify(x)) throw new UsageError(`--${n} holds a secret, never on the command line; pass it as --${n} @- to read it from stdin`, "args");
161
+ return x;
162
+ };
163
+ const all = (Array.isArray(v) ? v : [v]).map(one);
164
+ // An array field: one flag holding a JSON array is the array; else each flag is one item.
165
+ out[n] = typeOf(p) === "array" ? (all.length === 1 && Array.isArray(all[0]) ? all[0] : all) : Array.isArray(v) ? all : all[0];
166
+ continue;
167
+ }
168
+ out[n] = v;
169
+ }
170
+ return { args: out, extra: taken };
171
+ }
172
+
173
+ /** The word a value takes in usage: its enum, entity, JSON or stdin, else VALUE. */
174
+ function valueWord(p: Json | undefined): string {
175
+ if (!p) return "VALUE";
176
+ if (secret(p)) return "@-";
177
+ if (Array.isArray(p.enum)) return (p.enum as unknown[]).map(String).join("|");
178
+ if (typeof p["x-entity"] === "string") return "entity";
179
+ if (jsonValued(p)) return "JSON";
180
+ return "VALUE";
181
+ }
182
+
183
+ /** A command's words from its args schema: positionals in order, then flags, required first, booleans bare; types where they are cheap. */
184
+ export function usage(args: Json, positional: string[] = []): string[] {
185
+ const props = propsOf(args);
186
+ const required = new Set((args.required as string[] | undefined) ?? []);
187
+ const pos = positional.map((n) => {
188
+ const t = valueWord(typeOf(props[n]) === "array" ? props[n]!.items as Json : props[n]);
189
+ const word = `<${n}${t === "VALUE" ? "" : `:${t}`}${typeOf(props[n]) === "array" ? "…" : ""}>`;
190
+ return required.has(n) ? word : `[${word}]`;
191
+ });
192
+ const names = Object.keys(props).filter((n) => !positional.includes(n))
193
+ .sort((a, b) => Number(required.has(b)) - Number(required.has(a)) || a.localeCompare(b));
194
+ return [...pos, ...names.map((n) => {
195
+ const flag = typeOf(props[n]) === "boolean" ? `--${n}` : `--${n} ${valueWord(props[n])}`;
196
+ return required.has(n) ? flag : `[${flag}]`;
197
+ })];
198
+ }
199
+
200
+ /** One line per argument for help: positionals as <name>, the rest as --name, with type and description. */
201
+ export function describeArgs(args: Json, positional: string[] = []): string[] {
202
+ const props = propsOf(args);
203
+ const names = [...positional.filter((n) => n in props), ...Object.keys(props).filter((n) => !positional.includes(n))];
204
+ return names.map((n) => {
205
+ const p = props[n]!;
206
+ const type = Array.isArray(p.type) ? p.type.join("|") : String(p.type ?? (p.enum ? (p.enum as string[]).join("|") : "value"));
207
+ const word = positional.includes(n) ? (p.type === "array" ? `<${n}…>` : `<${n}>`) : `--${n}`;
208
+ const how = secret(p) ? ", read from stdin with --" + n + " @-" : jsonValued(p) ? ", as JSON" : "";
209
+ return ` ${word.padEnd(16)} ${type}${how}${p.description ? `: ${p.description}` : ""}`;
210
+ });
211
+ }
212
+
213
+ /** `30`, `30s`, `2m`, `500ms`: milliseconds. */
214
+ export function duration(s: string): number {
215
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(s.trim());
216
+ if (!m) throw new UsageError(`a duration is seconds, 30s, 2m or 500ms; got ${s}`);
217
+ const n = Number(m[1]);
218
+ return Math.round(n * ({ ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[m[2] ?? "s"]!));
219
+ }
@@ -0,0 +1,107 @@
1
+ // The shell's way to a baba's state. A baba runs as a child of the root's
2
+ // daemon or it does not run: the daemon writes its address next to its page
3
+ // token, and the shell speaks to the state over loopback with both. Nothing
4
+ // here loads a baba.
5
+ import { join } from "node:path";
6
+ import type { Project } from "../baba/project.ts";
7
+ import { NodeError, type Described } from "../baba/node.ts";
8
+ import type { StateAnswer } from "../ecs/snapshot.ts";
9
+ import type { Raw } from "../ecs/runtime.ts";
10
+ import { addressPath, type Address } from "../http/server.ts";
11
+ import type { ProjectEntry, Refreshed } from "../server/messages.ts";
12
+
13
+ export interface Client {
14
+ /** Where the state is: the daemon's address. */
15
+ where: string;
16
+ describe(): Promise<Described>;
17
+ /** An action; `key` is its idempotency key; `coerce` reads shell strings as the schema's numbers and booleans. */
18
+ fire(name: string, args: unknown, o?: { key?: string; coerce?: boolean }): Promise<unknown>;
19
+ read(name: string, args: unknown, o?: { coerce?: boolean }): Promise<unknown>;
20
+ /** The state once a round past `since` has run, or as it is after the daemon's wait (~25 s); `signal` cuts it short. */
21
+ state(since?: number, signal?: AbortSignal): Promise<StateAnswer>;
22
+ raw(raw: Raw): Promise<unknown>;
23
+ }
24
+
25
+ /** The root's daemon, answering. */
26
+ export interface Daemon {
27
+ where: string;
28
+ /** The base URL the calls go to. */
29
+ base: string;
30
+ projects(): Promise<ProjectEntry[]>;
31
+ /** Asks it to re-read the registry: what was added opens, what was removed retires. */
32
+ refresh(): Promise<Refreshed>;
33
+ get(path: string, signal?: AbortSignal): Promise<unknown>;
34
+ post(path: string, body: unknown, signal?: AbortSignal): Promise<unknown>;
35
+ }
36
+
37
+ /** How long a call waits for its answer by default. */
38
+ export const CALL_MS = 30_000;
39
+
40
+ /** A fetch whose failure is a NodeError: `timeout` when the time ran out, `down` when nothing answered. */
41
+ async function request(url: string, init: RequestInit, signal = AbortSignal.timeout(CALL_MS)): Promise<Response> {
42
+ try { return await fetch(url, { ...init, signal }); }
43
+ catch (err) {
44
+ const name = err instanceof Error ? err.name : "";
45
+ if (name === "TimeoutError") throw new NodeError("timeout", `no answer from ${new URL(url).host} in time`);
46
+ if (name === "AbortError") throw err;
47
+ throw new NodeError("down", `the daemon at ${new URL(url).host} does not answer: ${err instanceof Error ? err.message : String(err)}`);
48
+ }
49
+ }
50
+
51
+ /** The daemon whose address and token are in `rootDir`, if it answers. */
52
+ export async function daemon(rootDir: string): Promise<Daemon | null> {
53
+ let addr: Address;
54
+ try { addr = (await Bun.file(addressPath(rootDir)).json()) as Address; } catch { return null; }
55
+ const token = (await Bun.file(join(rootDir, "http-token")).text().catch(() => "")).trim();
56
+ if (!token || typeof addr.port !== "number") return null;
57
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
58
+ const host = !addr.hostname || addr.hostname === "0.0.0.0" || addr.hostname === "::" ? "127.0.0.1" : addr.hostname;
59
+ const base = `http://${host.includes(":") ? `[${host}]` : host}:${addr.port}`;
60
+ try {
61
+ const r = await fetch(`${base}/api/projects`, { headers, signal: AbortSignal.timeout(1500) });
62
+ if (!r.ok) return null;
63
+ } catch { return null; }
64
+ const answer = async (r: Response) => {
65
+ const j = await r.json() as { value?: unknown; error?: { code: string; message: string } };
66
+ if (!r.ok || j.error) throw new NodeError(j.error?.code ?? "failed", j.error?.message ?? r.statusText);
67
+ return j;
68
+ };
69
+ const d: Daemon = {
70
+ where: `${host}:${addr.port}`,
71
+ base,
72
+ get: async (path, signal) => answer(await request(`${base}/api/${path}`, { headers }, signal)),
73
+ post: async (path, body, signal) => answer(await request(`${base}/api/${path}`, { method: "POST", headers, body: JSON.stringify(body ?? {}) }, signal)),
74
+ projects: async () => ((await d.get("projects")) as { projects: ProjectEntry[] }).projects,
75
+ refresh: async () => (await d.post("projects/refresh", {})) as Refreshed,
76
+ };
77
+ return d;
78
+ }
79
+
80
+ /** The baba as the daemon runs it; `down` with the way to start it when the daemon does not answer or does not run it. */
81
+ export async function connect(project: Project, rootDir: string, prog = "voss baba"): Promise<Client> {
82
+ const down = new NodeError("down", `${project.name} is not running; ${prog} start`);
83
+ const d = await daemon(rootDir);
84
+ if (!d) throw down;
85
+ if (!(await d.projects()).some((p) => p.name === project.name && p.dir === project.dir)) throw down;
86
+ const name = encodeURIComponent(project.name);
87
+ const value = async (path: string, body: unknown, signal?: AbortSignal) => ((await d.post(path, body, signal)) as { value: unknown }).value;
88
+ const query = (o: { key?: string; coerce?: boolean }) => {
89
+ const q = [...(o.coerce ? ["coerce"] : []), ...(o.key ? [`key=${encodeURIComponent(o.key)}`] : [])];
90
+ return q.length ? `?${q.join("&")}` : "";
91
+ };
92
+ return {
93
+ where: d.where,
94
+ describe: async () => (await d.get(`describe/${name}`)) as Described,
95
+ // A fire that was sent and got no answer may have run: `unknown`, not `down`; its key, if any, makes a retry safe.
96
+ fire: async (n, args, o = { coerce: true }) => {
97
+ try { return await value(`fire/${name}/${encodeURIComponent(n)}${query(o)}`, args); }
98
+ catch (err) {
99
+ if (!(err instanceof NodeError) || err.code !== "timeout") throw err;
100
+ throw Object.assign(new NodeError("unknown", `${n} was sent and its answer did not come in time; it may have run${o.key ? `: the same call with --key ${o.key} answers the same` : ""}`), o.key ? { key: o.key } : {});
101
+ }
102
+ },
103
+ read: (n, args, o = { coerce: true }) => value(`read/${name}/${encodeURIComponent(n)}${query(o)}`, args),
104
+ state: (since = 0, signal) => value(`state/${name}`, { since }, signal) as Promise<StateAnswer>,
105
+ raw: (raw) => { const { op, ...rest } = raw; return value(`raw/${name}/${op}`, rest); },
106
+ };
107
+ }