@oalo-ai/cli 0.0.0-stage → 0.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -2
  3. package/man/oalo.1 +1530 -0
  4. package/package.json +44 -4
  5. package/plugin/.claude-plugin/plugin.json +12 -0
  6. package/plugin/skills/oalo/SKILL.md +414 -0
  7. package/src/api.js +132 -0
  8. package/src/app-ref.js +11 -0
  9. package/src/args.js +67 -0
  10. package/src/bot-settings.json +468 -0
  11. package/src/channel.js +162 -0
  12. package/src/commands/apps.js +64 -0
  13. package/src/commands/bots.js +278 -0
  14. package/src/commands/call.js +179 -0
  15. package/src/commands/channels.js +110 -0
  16. package/src/commands/data.js +446 -0
  17. package/src/commands/db.js +58 -0
  18. package/src/commands/deploy.js +431 -0
  19. package/src/commands/dockerfile.js +81 -0
  20. package/src/commands/docs.js +70 -0
  21. package/src/commands/emit.js +124 -0
  22. package/src/commands/env.js +223 -0
  23. package/src/commands/graphs.js +570 -0
  24. package/src/commands/incidents.js +44 -0
  25. package/src/commands/link.js +150 -0
  26. package/src/commands/login.js +180 -0
  27. package/src/commands/logs.js +69 -0
  28. package/src/commands/mcp.js +125 -0
  29. package/src/commands/metrics.js +168 -0
  30. package/src/commands/products.js +169 -0
  31. package/src/commands/qa.js +98 -0
  32. package/src/commands/rehearse.js +100 -0
  33. package/src/commands/run.js +152 -0
  34. package/src/commands/secrets.js +68 -0
  35. package/src/commands/setup.js +96 -0
  36. package/src/commands/skill.js +142 -0
  37. package/src/commands/sources.js +377 -0
  38. package/src/commands/switches.js +156 -0
  39. package/src/commands/tail.js +84 -0
  40. package/src/commands/tokens.js +85 -0
  41. package/src/commands/typegen.js +505 -0
  42. package/src/commands/whoami.js +112 -0
  43. package/src/config.js +157 -0
  44. package/src/data.js +392 -0
  45. package/src/envfile.js +71 -0
  46. package/src/events.js +63 -0
  47. package/src/graphfiles.js +273 -0
  48. package/src/help.js +920 -0
  49. package/src/index.js +216 -0
  50. package/src/mcp/tools.js +773 -0
  51. package/src/server.js +73 -0
  52. package/src/workspace.js +44 -0
@@ -0,0 +1,85 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
+ import { dim } from "../events.js";
4
+
5
+ /**
6
+ * `oalo tokens`: the workspace's sync tokens (data plane), managed as the
7
+ * signed-in person.
8
+ *
9
+ * oalo tokens list
10
+ * oalo tokens create --name shop-mirror --scope write:orders [--scope read:products] [--days 90]
11
+ * oalo tokens revoke <id>
12
+ *
13
+ * A sync token is for an app's own server (mirroring rows in, reading them
14
+ * out). It carries no end-user identity and reads or writes every row of the
15
+ * tables it covers, so it never goes inside a mobile or web app. Prefer the
16
+ * narrowest scope: `write:<table>` over `write`.
17
+ */
18
+ export async function tokens(flags, rest) {
19
+ const sub = rest[1] || "list";
20
+ const cfg = resolveConfig(flags);
21
+ const { url, token } = controlPlane(cfg);
22
+ if (!url || !token) {
23
+ console.error(`oalo tokens needs a CLI token.\n\n${HOW_TO_CONNECT}`);
24
+ process.exitCode = 2;
25
+ return;
26
+ }
27
+
28
+ const ch = new OaloChannel({ url, token });
29
+ try {
30
+ await ch.connect();
31
+ if (sub === "list") {
32
+ const r = await ch.call("sync_api:list_tokens", {});
33
+ const list = r.tokens || [];
34
+ if (flags.json) return void console.log(JSON.stringify(list, null, 2));
35
+ if (!list.length) return void console.log(`no sync tokens in ${url}`);
36
+ for (const t of list) {
37
+ const when = t.last_used_at ? `last used ${t.last_used_at.slice(0, 10)}` : "never used";
38
+ const exp = t.expires_at ? `, expires ${t.expires_at.slice(0, 10)}` : "";
39
+ console.log(`${t.id} ${t.name} ${(t.scopes || []).join(" ")} ${dim(when + exp)}`);
40
+ }
41
+ return;
42
+ }
43
+
44
+ if (sub === "create") {
45
+ const scopes = [].concat(flags.scope || []).flatMap((s) => String(s).split(",")).map((s) => s.trim()).filter(Boolean);
46
+ if (typeof flags.name !== "string" || !scopes.length) {
47
+ console.error("usage: oalo tokens create --name <name> --scope write:<table> [--scope ...] [--days N]\n" +
48
+ "scopes: read, write, read_manifest, read:<table>, write:<table>, events, workflows, chat, env:dev, env:prod");
49
+ process.exitCode = 2;
50
+ return;
51
+ }
52
+ const days = Number(flags.days || 90);
53
+ const expires_at = days > 0 ? new Date(Date.now() + days * 86_400_000).toISOString() : undefined;
54
+ const r = await ch.call("sync_api:create_token", { name: flags.name, scopes, ...(expires_at ? { expires_at } : {}) });
55
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
56
+ console.log(r.token);
57
+ // What the server granted, not what was asked for.
58
+ const granted = (r.scopes || scopes).join(" ");
59
+ const expiry = expires_at ? `expires ${expires_at.slice(0, 10)} (${days} days; --days 0 for no expiry)` : "never expires";
60
+ console.error(dim(`${flags.name}: ${granted}, ${expiry}. ` +
61
+ "Shown once: put it in the app server's secrets now. Never ship it inside a mobile or web app."));
62
+ return;
63
+ }
64
+
65
+ if (sub === "revoke") {
66
+ const id = rest[2];
67
+ if (!id) {
68
+ console.error("usage: oalo tokens revoke <id> (ids from `oalo tokens list`)");
69
+ process.exitCode = 2;
70
+ return;
71
+ }
72
+ await ch.call("sync_api:revoke_token", { id });
73
+ console.log(`revoked ${id}. Anything still using it gets 401 from now on.`);
74
+ return;
75
+ }
76
+
77
+ console.error(`unknown tokens command: ${sub} (list, create, revoke)`);
78
+ process.exitCode = 2;
79
+ } catch (e) {
80
+ console.error(e.message);
81
+ process.exitCode = 1;
82
+ } finally {
83
+ ch.close();
84
+ }
85
+ }
@@ -0,0 +1,505 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { resolve as resolveConfig, HOW_TO_CONNECT } from "../config.js";
4
+ import { fetchManifest, fetchWorkflows, syncGet } from "../api.js";
5
+
6
+ const DEFAULT_OUT = "oalo.generated.ts";
7
+
8
+ /**
9
+ * Field-type vocabulary is defined by `mix sync.gen.model`, which is what
10
+ * creates models in the first place. This mirrors its `dart_type/1` exactly so
11
+ * the two generators cannot drift:
12
+ *
13
+ * string|text|references -> String (a reference holds the target's id)
14
+ * integer|datetime -> int (datetime is an epoch int, not a string)
15
+ * float -> double
16
+ * boolean -> bool
17
+ * anything else -> dynamic (json lands here)
18
+ */
19
+ function tsType(type) {
20
+ switch (type) {
21
+ case "string":
22
+ case "text":
23
+ case "references":
24
+ return "string";
25
+ case "integer":
26
+ case "float":
27
+ case "datetime":
28
+ return "number";
29
+ case "boolean":
30
+ return "boolean";
31
+ default:
32
+ return "unknown";
33
+ }
34
+ }
35
+
36
+ const pascal = (table) =>
37
+ table
38
+ .split(/[^a-zA-Z0-9]+/)
39
+ .filter(Boolean)
40
+ .map((w) => w[0].toUpperCase() + w.slice(1))
41
+ .join("");
42
+
43
+ /**
44
+ * `<Table>Doc` rather than a singularised `<Table>`. Singularising is a bug
45
+ * farm (quizzes, measurements, stages all break naive rules) and the suffix
46
+ * says what the type actually is: the document body, not the whole row.
47
+ */
48
+ const docTypeName = (table) => `${pascal(table)}Doc`;
49
+
50
+ /**
51
+ * Quote a property name only when it is not a plain identifier. Field names come
52
+ * from user-created models, so they cannot be assumed safe, but quoting
53
+ * everything makes the common case noisy to read.
54
+ */
55
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
56
+ const propKey = (name) => (IDENT.test(name) ? name : JSON.stringify(name));
57
+
58
+ function renderModel(table, model) {
59
+ const fields = model.fields || [];
60
+ const lines = [];
61
+
62
+ lines.push(`/** \`${table}\` — primary key \`${model.primary_key || "id"}\`${
63
+ model.shared ? ", shared" : ""
64
+ }${model.owner_field ? `, owned via \`${model.owner_field}\`` : ""} */`);
65
+ lines.push(`export interface ${docTypeName(table)} {`);
66
+
67
+ for (const f of fields) {
68
+ if (!f?.name) continue;
69
+ lines.push(` ${propKey(f.name)}?: ${tsType(f.type)};`);
70
+ }
71
+
72
+ // The manifest is a PARTIAL description, not a closed schema: `clients`
73
+ // declares only `user_id` while real documents also carry email, name and
74
+ // phone. Closing the interface would make correct code fail to compile, so
75
+ // declared fields are typed and the rest stay reachable.
76
+ lines.push(` /** The manifest describes the fields above; documents may carry more. */`);
77
+ lines.push(` [key: string]: unknown;`);
78
+ lines.push(`}`);
79
+ return lines.join("\n");
80
+ }
81
+
82
+ // A workflow contract's field types (AppBuilder.Loops.ApiCalls) in TypeScript.
83
+ function contractType(type) {
84
+ switch (type) {
85
+ case "string":
86
+ return "string";
87
+ case "number":
88
+ case "integer":
89
+ return "number";
90
+ case "boolean":
91
+ return "boolean";
92
+ case "object":
93
+ return "Record<string, unknown>";
94
+ case "array":
95
+ return "unknown[]";
96
+ default:
97
+ return "unknown";
98
+ }
99
+ }
100
+
101
+ function renderFields(name, fields) {
102
+ const lines = [`export interface ${name} {`];
103
+ for (const [field, f] of Object.entries(fields || {}).sort(([a], [b]) => a.localeCompare(b))) {
104
+ if (f?.description) lines.push(` /** ${String(f.description).replace(/\*\//g, "* /")} */`);
105
+ lines.push(` ${propKey(field)}${f?.required === false ? "?" : ""}: ${contractType(f?.type)};`);
106
+ }
107
+ lines.push(`}`);
108
+ return lines.join("\n");
109
+ }
110
+
111
+ // The workflows an app can call: their types, and `runWorkflow` to call one
112
+ // from the app's server with the token OALO gives it.
113
+ function renderWorkflows(workflows) {
114
+ const list = [...workflows].sort((a, b) => a.name.localeCompare(b.name));
115
+ const type = (w) => pascal(w.name);
116
+ const out = [
117
+ ``,
118
+ `// ── Workflows ─────────────────────────────────────────────────────────────`,
119
+ `// The workflows this workspace's apps can call: POST /api/workflows/v1/<name>/run.`,
120
+ ``,
121
+ ];
122
+ for (const w of list) {
123
+ if (w.description) out.push(`/** ${w.name}: ${String(w.description).replace(/\*\//g, "* /")} */`);
124
+ out.push(renderFields(`${type(w)}Input`, w.input), renderFields(`${type(w)}Output`, w.output), ``);
125
+ }
126
+ out.push(
127
+ `export interface WorkflowTypes {`,
128
+ ...list.map((w) => ` ${JSON.stringify(w.name)}: { input: ${type(w)}Input; output: ${type(w)}Output };`),
129
+ `}`,
130
+ ``,
131
+ `export type WorkflowName = keyof WorkflowTypes;`,
132
+ ``,
133
+ `export interface WorkflowRun<K extends WorkflowName> {`,
134
+ ` run_id: number;`,
135
+ ` workflow: K;`,
136
+ ` /** running: not done within \`wait\`; read it again with getWorkflowRun. */`,
137
+ ` status: "success" | "running" | "waiting" | "error" | "halted_iterations" | "halted_cost" | "cancelled";`,
138
+ ` output: WorkflowTypes[K]["output"] | null;`,
139
+ ` error: string | null;`,
140
+ ` cost_usd: number | null;`,
141
+ ` /** A preview's call: dev data, outside actions simulated. */`,
142
+ ` test: boolean;`,
143
+ ` poll: string;`,
144
+ `}`,
145
+ ``,
146
+ `export interface WorkflowCallOptions extends OaloServerOptions {`,
147
+ ` /** The person it's about, so the workflow's sends reach them. */`,
148
+ ` contact?: { external_ref?: string; email?: string; phone?: string; name?: string };`,
149
+ ` /** Seconds to wait for the answer (0 to 55, default 30). */`,
150
+ ` wait?: number;`,
151
+ `}`,
152
+ ``,
153
+ `/** Run a workflow and wait for its output. Check \`status\` before reading \`output\`. */`,
154
+ `export async function runWorkflow<K extends WorkflowName>(name: K, input: WorkflowTypes[K]["input"], opts: WorkflowCallOptions = {}): Promise<WorkflowRun<K>> {`,
155
+ ` const body = { input, contact: opts.contact, wait: opts.wait };`,
156
+ ` return (await oaloFetch(\`/api/workflows/v1/\${encodeURIComponent(name)}/run\`, { method: "POST", body: JSON.stringify(body) }, opts)) as WorkflowRun<K>;`,
157
+ `}`,
158
+ ``,
159
+ `/** A run that answered \`running\`, read again. */`,
160
+ `export async function getWorkflowRun<K extends WorkflowName>(run: WorkflowRun<K> | number, opts: WorkflowCallOptions = {}): Promise<WorkflowRun<K>> {`,
161
+ ` const id = typeof run === "number" ? run : run.run_id;`,
162
+ ` return (await oaloFetch(\`/api/workflows/v1/runs/\${id}\`, { method: "GET" }, opts)) as WorkflowRun<K>;`,
163
+ `}`,
164
+ ``,
165
+ );
166
+ return out;
167
+ }
168
+
169
+ // What every generated file gets: the server-side fetch the helpers share, and
170
+ // createChatSession for the chat widget's data-user-url endpoint.
171
+ function renderServerHelpers() {
172
+ return [
173
+ ``,
174
+ `// ── Server-side helpers ───────────────────────────────────────────────────`,
175
+ `// They use the OALO_URL and OALO_SYNC_TOKEN OALO gives a server app. Never`,
176
+ `// call them from a browser: the token must stay on the server.`,
177
+ ``,
178
+ `export interface OaloServerOptions {`,
179
+ ` /** Default: OALO_URL and OALO_SYNC_TOKEN from the environment. */`,
180
+ ` url?: string;`,
181
+ ` token?: string;`,
182
+ `}`,
183
+ ``,
184
+ `const oaloEnv = (k: string): string | undefined => (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[k];`,
185
+ ``,
186
+ `async function oaloFetch(path: string, init: RequestInit, opts: OaloServerOptions): Promise<unknown> {`,
187
+ ` const url = opts.url ?? oaloEnv("OALO_URL");`,
188
+ ` const token = opts.token ?? oaloEnv("OALO_SYNC_TOKEN");`,
189
+ ` if (!url || !token) throw new Error("OALO_URL and OALO_SYNC_TOKEN are not set: call OALO from the app's server");`,
190
+ ` const res = await fetch(url.replace(/\\/$/, "") + path, { ...init, headers: { Authorization: \`Bearer \${token}\`, "Content-Type": "application/json" } });`,
191
+ ` const body = (await res.json().catch(() => null)) as { error?: string | { message?: string } } | null;`,
192
+ ` const message = typeof body?.error === "string" ? body.error : body?.error?.message;`,
193
+ ` if (!res.ok && res.status !== 202) throw new Error(message ?? \`Oalo call failed: HTTP \${res.status}\`);`,
194
+ ` return body;`,
195
+ `}`,
196
+ ``,
197
+ `/** One aggregate over a model's rows (oalo docs dashboards). */`,
198
+ `export interface MetricQuery<K extends ModelName = ModelName> {`,
199
+ ` table: K;`,
200
+ ` /** Default count. sum, avg, min and max read \`field\` as a number; distinct counts its values. */`,
201
+ ` measure?: "count" | "sum" | "avg" | "min" | "max" | "distinct";`,
202
+ ` field?: string & keyof DocumentOf<K>;`,
203
+ ` /** Group by a field (top \`limit\` groups, default 12). */`,
204
+ ` by?: (string & keyof DocumentOf<K>) | "owner_id" | "availability" | "product_id";`,
205
+ ` every?: "day" | "week" | "month";`,
206
+ ` /** Which time a row counts at: when it was created (default), last changed, or a date field. */`,
207
+ ` time?: "created" | "changed" | (string & keyof DocumentOf<K>);`,
208
+ ` /** The window, ending now. Leave it out for all time. */`,
209
+ ` days?: number;`,
210
+ ` /** Also the same measure over the window before (needs days). */`,
211
+ ` compare?: boolean;`,
212
+ ` limit?: number;`,
213
+ ` /** Buckets in this zone, like America/Argentina/Buenos_Aires. Default UTC. */`,
214
+ ` tz?: string;`,
215
+ ` where?: { field: string & keyof DocumentOf<K>; op: "eq" | "neq" | "in" | "gt" | "gte" | "lt" | "lte" | "set" | "unset" | "contains"; value?: string | number | (string | number)[] }[];`,
216
+ `}`,
217
+ ``,
218
+ `export interface MetricResult {`,
219
+ ` rows: { bucket?: string; group?: string; value: number }[];`,
220
+ ` total: number;`,
221
+ ` previous: number | null;`,
222
+ ` /** Every bucket in the window, so a line has its empty days. */`,
223
+ ` buckets: string[] | null;`,
224
+ ` groups: string[] | null;`,
225
+ ` more_groups: boolean;`,
226
+ ` /** Rows counted by created time that are older than the created stamp. */`,
227
+ ` untracked: number;`,
228
+ `}`,
229
+ ``,
230
+ `/** A count, sum or average over a model, grouped or over time, for the app's own charts. From the app's server. */`,
231
+ `export async function queryMetric<K extends ModelName>(query: MetricQuery<K>, opts: OaloServerOptions = {}): Promise<MetricResult> {`,
232
+ ` return (await oaloFetch("/api/sync/v1/_metrics", { method: "POST", body: JSON.stringify(query) }, opts)) as MetricResult;`,
233
+ `}`,
234
+ ``,
235
+ `export interface ChatSession {`,
236
+ ` /** Give this to the chat widget: answer it from the endpoint in its data-user-url. */`,
237
+ ` user_token: string;`,
238
+ ` expires_at: string;`,
239
+ ` bot: string;`,
240
+ `}`,
241
+ ``,
242
+ `/**`,
243
+ ` * The chat widget as your signed-in user. Answer the widget's data-user-url with`,
244
+ ` * this (JSON), after checking the user is signed in; answer 401 when they aren't.`,
245
+ ` */`,
246
+ `export async function createChatSession(`,
247
+ ` bot: string,`,
248
+ ` user: { external_ref: string; email?: string; name?: string; phone?: string },`,
249
+ ` opts: OaloServerOptions & { context?: string } = {},`,
250
+ `): Promise<ChatSession> {`,
251
+ ` const body = { bot, user, context: opts.context };`,
252
+ ` return (await oaloFetch("/api/chat/v1/sessions", { method: "POST", body: JSON.stringify(body) }, opts)) as ChatSession;`,
253
+ `}`,
254
+ ``,
255
+ `/** A bot's call to one of your endpoints, declared in .oalo/app.json. */`,
256
+ `export interface OaloToolCall {`,
257
+ ` tool: string;`,
258
+ ` /** The bot's public id. */`,
259
+ ` bot: string;`,
260
+ ` conversation: string | null;`,
261
+ ` /** Your own id for the signed-in user (from createChatSession), or null when the chat is anonymous. */`,
262
+ ` user: string | null;`,
263
+ `}`,
264
+ ``,
265
+ `/**`,
266
+ ` * Check a request to a tool endpoint came from OALO, and for which user. Pass the`,
267
+ ` * method, the path with its query as received, the headers and the RAW body (before`,
268
+ ` * JSON parsing). Throws when it isn't OALO's, or is over 5 minutes old.`,
269
+ ` */`,
270
+ `export async function verifyOaloToolCall(`,
271
+ ` req: { method: string; path: string; headers: Headers | Record<string, string | string[] | undefined>; body: string },`,
272
+ ` opts: { token?: string; maxAgeSeconds?: number } = {},`,
273
+ `): Promise<OaloToolCall> {`,
274
+ ` const header = (k: string): string | undefined => {`,
275
+ ` const v = typeof (req.headers as Headers).get === "function" ? (req.headers as Headers).get(k) ?? undefined : (req.headers as Record<string, string | string[] | undefined>)[k];`,
276
+ ` return Array.isArray(v) ? v[0] : v;`,
277
+ ` };`,
278
+ ` const token = opts.token ?? oaloEnv("OALO_SYNC_TOKEN");`,
279
+ ` if (!token) throw new Error("OALO_SYNC_TOKEN is not set");`,
280
+ ` const ts = header("x-oalo-timestamp");`,
281
+ ` const sig = header("x-oalo-signature");`,
282
+ ` const user = header("x-oalo-user") ?? "";`,
283
+ ` if (!ts || !sig || !sig.startsWith("v1=")) throw new Error("not an OALO tool call");`,
284
+ ` if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= (opts.maxAgeSeconds ?? 300))) throw new Error("the OALO tool call is too old");`,
285
+ ` const enc = new TextEncoder();`,
286
+ ` const key = await crypto.subtle.importKey("raw", enc.encode(token), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);`,
287
+ ` const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode([ts, req.method.toUpperCase(), req.path, user, req.body].join("\\n"))));`,
288
+ ` const want = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");`,
289
+ ` const got = sig.slice(3);`,
290
+ ` let diff = want.length ^ got.length;`,
291
+ ` for (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ (got.charCodeAt(i) || 0);`,
292
+ ` if (diff !== 0) throw new Error("the OALO signature doesn't match");`,
293
+ ` return { tool: header("x-oalo-tool") ?? "", bot: header("x-oalo-bot") ?? "", conversation: header("x-oalo-conversation") || null, user: user || null };`,
294
+ `}`,
295
+ ``,
296
+ ];
297
+ }
298
+
299
+ /**
300
+ * The workspace's products by id, so code names `"premium"` as a `ProductId`
301
+ * the compiler checks (BuyButton, a row's product_id, purchased_products),
302
+ * not a loose string. Older Bases send no `products`: nothing is emitted.
303
+ */
304
+ function renderProducts(products) {
305
+ if (!Array.isArray(products)) return [];
306
+ const sorted = [...products].sort((a, b) => String(a.id).localeCompare(String(b.id)));
307
+ return [
308
+ `/** The workspace's products (\`oalo products\` lists them). Archived ones are left out. */`,
309
+ `export const PRODUCTS = {`,
310
+ ...sorted.map((p) => ` ${JSON.stringify(p.id)}: { title: ${JSON.stringify(p.title ?? p.id)}, type: ${JSON.stringify(p.type ?? null)} },`),
311
+ `} as const;`,
312
+ ``,
313
+ `export type ProductId = ${sorted.length ? "keyof typeof PRODUCTS" : "never"};`,
314
+ ``,
315
+ `/** Narrow a string (a row's product_id, an entry of purchased_products) to a ProductId. */`,
316
+ `export const isProductId = (s: unknown): s is ProductId =>`,
317
+ ` typeof s === "string" && Object.prototype.hasOwnProperty.call(PRODUCTS, s);`,
318
+ ``,
319
+ ];
320
+ }
321
+
322
+ function render(manifest, cfg, workflows = []) {
323
+ const models = manifest?.models || {};
324
+ const tables = Object.keys(models).sort();
325
+
326
+ const head = [
327
+ `// Generated by \`oalo typegen\`. DO NOT EDIT.`,
328
+ `//`,
329
+ `// workspace ${cfg.serverUrl}`,
330
+ `// env ${cfg.env}`,
331
+ `// schema version ${manifest?.schema_version ?? "unknown"}`,
332
+ `// models ${tables.length}`,
333
+ `// workflows ${workflows.length}`,
334
+ ...(Array.isArray(manifest?.products) ? [`// products ${manifest.products.length}`] : []),
335
+ `//`,
336
+ `// Regenerate after a schema change; \`oalo typegen --check\` fails when stale.`,
337
+ `// The env is stamped deliberately: dev and prod are separate schemas, and`,
338
+ `// bindings generated against one are not a promise about the other.`,
339
+ ``,
340
+ `export type SyncId = string;`,
341
+ ``,
342
+ `/** The envelope every row carries; \`document\` holds the model's own fields. */`,
343
+ `export interface SyncRow<T> {`,
344
+ ` id: SyncId;`,
345
+ ` document: T;`,
346
+ ` owner_id: string | null;`,
347
+ ` availability: string | null;`,
348
+ ` product_id: string | null;`,
349
+ ` seqnum: number;`,
350
+ ` row_hash: string;`,
351
+ ` deleted_at: number | null;`,
352
+ ` last_modified_ms: number;`,
353
+ `}`,
354
+ ``,
355
+ `/** A page from \`GET /api/sync/v1/<table>\`. */`,
356
+ `export interface SyncPage<T> {`,
357
+ ` rows: SyncRow<T>[];`,
358
+ ` has_more: boolean;`,
359
+ ` next_since: number;`,
360
+ `}`,
361
+ ``,
362
+ ];
363
+
364
+ const bodies = tables.map((t) => renderModel(t, models[t]));
365
+
366
+ const registry = [
367
+ ``,
368
+ `/** Every table this token can read, with its key and ownership shape. */`,
369
+ `export const MODELS = {`,
370
+ ...tables.map((t) => {
371
+ const m = models[t];
372
+ return ` ${JSON.stringify(t)}: { primaryKey: ${JSON.stringify(
373
+ m.primary_key || "id",
374
+ )}, shared: ${!!m.shared}, ownerField: ${JSON.stringify(m.owner_field ?? null)} },`;
375
+ }),
376
+ `} as const;`,
377
+ ``,
378
+ `export type ModelName = keyof typeof MODELS;`,
379
+ ``,
380
+ `/** Map a table name to its document type: \`DocumentOf<"exercises">\`. */`,
381
+ `export interface DocumentTypes {`,
382
+ ...tables.map((t) => ` ${JSON.stringify(t)}: ${docTypeName(t)};`),
383
+ `}`,
384
+ ``,
385
+ `export type DocumentOf<K extends ModelName> = DocumentTypes[K];`,
386
+ `export type RowOf<K extends ModelName> = SyncRow<DocumentOf<K>>;`,
387
+ `export type PageOf<K extends ModelName> = SyncPage<DocumentOf<K>>;`,
388
+ ``,
389
+ ];
390
+
391
+ return [...head, ...bodies, ...registry, ...renderProducts(manifest?.products), ...renderServerHelpers(), ...(workflows.length ? renderWorkflows(workflows) : [])].join("\n");
392
+ }
393
+
394
+ export { render as renderTypes };
395
+
396
+ export async function typegen(flags) {
397
+ const cfg = resolveConfig(flags);
398
+
399
+ if (!cfg.serverUrl || !cfg.token) {
400
+ console.error(`no workspace or data token for this project.\n\n${HOW_TO_CONNECT}`);
401
+ process.exitCode = 2;
402
+ return;
403
+ }
404
+ if (flags.dart) return dart(flags, cfg);
405
+
406
+ let manifest;
407
+ let workflows = [];
408
+ try {
409
+ manifest = await fetchManifest(cfg);
410
+ workflows = await fetchWorkflows(cfg).catch((e) => {
411
+ // A token that can read models but not list workflows still gets models.
412
+ if (e.status === 403) return [];
413
+ throw e;
414
+ });
415
+ } catch (e) {
416
+ console.error(`typegen failed: ${e.message}`);
417
+ process.exitCode = 1;
418
+ return;
419
+ }
420
+
421
+ const out = typeof flags.out === "string" ? flags.out : DEFAULT_OUT;
422
+ const generated = render(manifest, cfg, workflows);
423
+ const count = Object.keys(manifest?.models || {}).length;
424
+
425
+ if (flags.check) {
426
+ const current = existsSync(out) ? readFileSync(out, "utf8") : null;
427
+ if (current === generated) {
428
+ console.log(`${out} is up to date (schema version ${manifest?.schema_version}).`);
429
+ return;
430
+ }
431
+ console.error(
432
+ current === null
433
+ ? `${out} does not exist. Run: oalo typegen`
434
+ : `${out} is STALE (workspace schema version ${manifest?.schema_version}). Run: oalo typegen`,
435
+ );
436
+ process.exitCode = 1;
437
+ return;
438
+ }
439
+
440
+ mkdirSync(dirname(out) === "" ? "." : dirname(out), { recursive: true });
441
+ writeFileSync(out, generated);
442
+
443
+ console.log(`wrote ${out}`);
444
+ console.log(` models ${count}`);
445
+ console.log(` workflows ${workflows.length}`);
446
+ console.log(` schema version ${manifest?.schema_version}`);
447
+ console.log(` env ${cfg.env}`);
448
+ if (count === 0) {
449
+ console.log(`\n No models visible. If this token is table-scoped, it only`);
450
+ console.log(` describes the tables it can read.`);
451
+ }
452
+ }
453
+
454
+ const DEFINES = ".oalo/dart-defines.json";
455
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
456
+
457
+ /**
458
+ * `oalo typegen --dart`: the Dart models, queries, mutations and providers a
459
+ * Flutter app built from Oalo's template uses, rendered by the Base's own
460
+ * generator (the one that writes them on the box), into lib/ under --out
461
+ * (default the project root). Also .oalo/dart-defines.json, so
462
+ * `flutter run --dart-define-from-file=.oalo/dart-defines.json` talks to the
463
+ * linked Base as the linked app.
464
+ */
465
+ async function dart(flags, cfg, get = syncGet) {
466
+ const root = typeof flags.out === "string" ? flags.out : ".";
467
+ let r;
468
+ try {
469
+ r = await get("/_codegen/dart", cfg);
470
+ } catch (e) {
471
+ console.error(e.status === 404 ? "typegen failed: this Base is older than your CLI and can't render Dart yet" : `typegen failed: ${e.message}`);
472
+ process.exitCode = 1;
473
+ return;
474
+ }
475
+
476
+ const defines = { SYNC_SERVER_URL: cfg.serverUrl.replace(/\/$/, ""), OALO_ENV: cfg.env || "dev" };
477
+ if (cfg.appId && !UUID.test(cfg.appId)) defines.APP_SLUG = cfg.appId;
478
+ const files = { ...r.files, [DEFINES]: JSON.stringify(defines, null, 2) + "\n" };
479
+
480
+ if (flags.check) {
481
+ const stale = Object.entries(files).filter(([rel, content]) => {
482
+ const path = join(root, rel);
483
+ return !existsSync(path) || readFileSync(path, "utf8") !== content;
484
+ });
485
+ if (stale.length === 0) return void console.log(`Dart files are up to date (schema version ${r.schema_version}).`);
486
+ console.error(`stale or missing: ${stale.map(([rel]) => rel).join(", ")}. Run: oalo typegen --dart`);
487
+ process.exitCode = 1;
488
+ return;
489
+ }
490
+
491
+ for (const [rel, content] of Object.entries(files)) {
492
+ const path = join(root, rel);
493
+ mkdirSync(dirname(path), { recursive: true });
494
+ writeFileSync(path, content);
495
+ }
496
+ const models = Object.keys(r.files).filter((f) => f.startsWith("lib/models/")).length;
497
+ console.log(`wrote lib/sync/generated (5 files) and lib/models (${models} model${models === 1 ? "" : "s"})${root === "." ? "" : ` under ${root}`}`);
498
+ console.log(` schema version ${r.schema_version}`);
499
+ console.log(` env ${defines.OALO_ENV}`);
500
+ console.log(`wrote ${DEFINES}: the Base, ${defines.APP_SLUG ? `app ${defines.APP_SLUG}, ` : ""}env ${defines.OALO_ENV}`);
501
+ if (!defines.APP_SLUG) console.log(` No app slug: link one (oalo link --app <slug>) so sync and chat know which app this is.`);
502
+ console.log(`\nRun it against the Base: flutter run -d chrome --dart-define-from-file=${DEFINES}`);
503
+ }
504
+
505
+ export { dart as _dart };