@oalo-ai/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,377 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, extname } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { OaloChannel } from "../channel.js";
5
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
6
+ import { requireFeature } from "../server.js";
7
+ import { parseRecords } from "../data.js";
8
+ import { dim } from "../events.js";
9
+
10
+ /**
11
+ * `oalo sources`: feeds that send Oalo rows from outside (another system, a
12
+ * webhook, an export), keeping only the fields you choose.
13
+ *
14
+ * oalo sources list them
15
+ * oalo sources create <name> --from <file> [--table t] [--id-field f] [--mode upsert|snapshot] [--auth bearer|hmac|url]
16
+ * oalo sources create <name> --table <model or table> [--fields a,b] [--keep-from-ai a,b] [--retention days] ...
17
+ * oalo sources show <name> its address, fields and recent arrivals
18
+ * oalo sources create <name> --bot <bot> documents into a bot's knowledge
19
+ * oalo sources push <name> <file.csv|.json|.jsonl>
20
+ * oalo sources push <name> <doc.pdf> [more...] into a knowledge source
21
+ * oalo sources remove <name> <document> a knowledge source's document
22
+ * oalo sources fetch <name> <url> [--every 60] [--header "Name: value"]... [--records-at a.b] [--document name]
23
+ * oalo sources fetch <name> --now | --off Oalo fetches the address instead of a sender posting
24
+ * oalo sources fields <name> a,b keep exactly these of the table's fields
25
+ * oalo sources retention <name> <days|off> a made model drops rows not sent in that many days
26
+ * oalo sources rotate <name> a new secret; the old one stops now
27
+ * oalo sources delete <name> [--wipe]
28
+ *
29
+ * A sender posts to the source's address with its secret; `push` sends a file
30
+ * from here over your CLI token instead.
31
+ */
32
+ export async function sources(flags, rest, opts = {}) {
33
+ const sub = rest[1] || "list";
34
+ const name = rest[2];
35
+ const cfg = resolveConfig(flags);
36
+ const { url, token } = controlPlane(cfg);
37
+ if (!url || !token) return fail(`oalo sources needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
38
+ if (!["list", "create", "show", "push", "remove", "fetch", "fields", "retention", "rotate", "delete"].includes(sub)) return fail(`unknown sources command: ${sub}\n\n${USAGE}`, 2);
39
+ if (sub !== "list" && !name) return fail(USAGE, 2);
40
+
41
+ if (!opts.channel) {
42
+ try {
43
+ await requireFeature(url, "sources", "oalo sources");
44
+ } catch (e) {
45
+ return fail(e.message, 1);
46
+ }
47
+ }
48
+
49
+ const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
50
+ try {
51
+ await ch.connect();
52
+ switch (sub) {
53
+ case "list":
54
+ return await list(ch, flags);
55
+ case "create":
56
+ return await create(ch, name, flags);
57
+ case "show":
58
+ return await show(ch, name, flags);
59
+ case "push":
60
+ return await push(ch, name, rest.slice(3), flags, opts);
61
+ case "remove":
62
+ return await remove(ch, name, rest[3], flags);
63
+ case "fetch":
64
+ return await fetchCmd(ch, name, rest[3], flags);
65
+ case "fields":
66
+ return await fields(ch, name, rest[3], flags);
67
+ case "retention":
68
+ return await retention(ch, name, rest[3], flags);
69
+ case "rotate":
70
+ return await rotate(ch, name, flags);
71
+ case "delete":
72
+ return await del(ch, name, flags);
73
+ }
74
+ } catch (e) {
75
+ return fail(e.message, 1);
76
+ } finally {
77
+ ch.close();
78
+ }
79
+ }
80
+
81
+ const USAGE = [
82
+ "usage: oalo sources [list]",
83
+ " oalo sources create <name> --from <sample file> [--table <new model>] [--id-field <field>] [--mode upsert|snapshot] [--auth bearer|hmac|url]",
84
+ " oalo sources create <name> --table <existing model or table> [--fields a,b] [--id-field <field>] [--auth ...]",
85
+ " (either form: [--keep-from-ai a,b] [--retention <days>], retention for a new model only)",
86
+ " oalo sources show <name> | rotate <name> | delete <name> [--wipe]",
87
+ " oalo sources fields <name> a,b | retention <name> <days|off>",
88
+ " oalo sources create <name> --bot <bot> [--auth ...] documents into the bot's knowledge",
89
+ " oalo sources push <name> <file.csv|.json|.jsonl> or, for --bot, <doc.pdf|.docx|.md|.html|.txt> ...",
90
+ " oalo sources remove <name> <document>",
91
+ " oalo sources fetch <name> <https url> [--every <minutes>] [--header \"Name: value\"] [--records-at <a.b>] [--document <name>]",
92
+ " oalo sources fetch <name> --now | --off",
93
+ ].join("\n");
94
+
95
+ const BATCH = 1000;
96
+ const list_ = (v) => (typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined);
97
+
98
+ async function list(ch, flags) {
99
+ const { sources: all = [] } = await ch.call("sources:list", {});
100
+ if (flags.json) return void console.log(JSON.stringify(all, null, 2));
101
+ if (!all.length) return void console.log("no sources yet. Make one: oalo sources create <name> --from <sample file>");
102
+ for (const s of all) {
103
+ if (s.kind === "knowledge") {
104
+ console.log(`${s.name} ${dim(`-> ${s.bot}'s knowledge, ${s.auth}, ${s.received_count} documents sent${s.last_received_at ? `, last ${s.last_received_at}` : ""}`)}`);
105
+ continue;
106
+ }
107
+ console.log(`${s.name} ${dim(`-> ${s.table} (${s.env}), ${s.mode}, ${s.auth}, ${s.received_count} rows${s.last_received_at ? `, last ${s.last_received_at}` : ""}`)}`);
108
+ }
109
+ }
110
+
111
+ async function create(ch, name, flags) {
112
+ const params = { name };
113
+ for (const [flag, key] of [["bot", "bot"], ["table", "table"], ["id-field", "id_field"], ["mode", "mode"], ["auth", "auth"], ["env", "env"], ["hmac-header", "hmac_header"], ["hmac-format", "hmac_format"]]) {
114
+ if (flags[flag] === true) return fail(`--${flag} needs a value`, 2);
115
+ if (typeof flags[flag] === "string") params[key] = flags[flag];
116
+ }
117
+ if (flags.fields) params.fields = list_(flags.fields);
118
+ for (const f of ["keep-from-ai", "retention"]) if (flags[f] === true) return fail(`--${f} needs a value`, 2);
119
+ if (flags["keep-from-ai"]) params.keep_from_ai = list_(flags["keep-from-ai"]);
120
+ if (flags.retention) params.retention_days = flags.retention;
121
+
122
+ if (typeof flags.from === "string") {
123
+ const text = readFileSync(flags.from, "utf8");
124
+ // A sample is enough to read the fields and their types.
125
+ params.sample = /\.csv$/i.test(flags.from)
126
+ ? { csv: text.split(/\r?\n/).slice(0, 201).join("\n") }
127
+ : parseRecords(text, { name: basename(flags.from) }).slice(0, 200);
128
+ }
129
+
130
+ const s = await ch.call("sources:create", params);
131
+ if (flags.json) return void console.log(JSON.stringify(s, null, 2));
132
+
133
+ if (s.kind === "knowledge") {
134
+ console.log(`made source ${s.name} -> ${s.bot}'s knowledge`);
135
+ console.log(` address ${s.url}`);
136
+ console.log(` secret ${s.secret}`);
137
+ console.log(dim("\nThe secret is shown only now. Keep it where the sender keeps secrets; `oalo sources rotate` replaces it."));
138
+ console.log(dim(sendExample(s)));
139
+ console.log(dim(`Each upload is one document, by name: the same name again replaces it, and DELETE with ?name= removes it. The bot answers from it once it's indexed, live at once. A workflow can start on ${s.event}.`));
140
+ return;
141
+ }
142
+
143
+ console.log(`made source ${s.name} -> ${s.table} (${s.env})${s.owns_table ? `, a new model` : s.kind === "table" ? `, a plain table` : ""}`);
144
+ console.log(` fields ${Object.entries(s.fields).map(([k, t]) => `${k}:${t}`).join(", ")}`);
145
+ if (s.keep_from_ai?.length) console.log(` kept from AI ${s.keep_from_ai.join(", ")}`);
146
+ if (s.retention_days) console.log(` retention rows not sent in ${s.retention_days} days are dropped`);
147
+ console.log(` id field ${s.id_field}${s.mode === "snapshot" ? ", snapshot (each upload is the whole set)" : ""}`);
148
+ console.log(` address ${s.url}`);
149
+ console.log(` secret ${s.secret}`);
150
+ console.log(dim("\nThe secret is shown only now. Keep it where the sender keeps secrets; `oalo sources rotate` replaces it."));
151
+ console.log(dim(sendExample(s)));
152
+ console.log(dim(`Fields a sender adds that aren't listed above are dropped, and each upload says which. A workflow can start on ${s.event}.`));
153
+ }
154
+
155
+ function sendExample(s) {
156
+ if (s.kind === "knowledge") {
157
+ const auth = s.auth === "bearer" ? ` -H "Authorization: Bearer <secret>"` : "";
158
+ const how = `\nSend: curl -X POST "${s.url}?name=handbook.pdf"${auth} -H "Content-Type: application/pdf" --data-binary @handbook.pdf\n(Word .docx, Markdown, HTML and plain text too; or JSON {"name": "...", "text": "..."}.)`;
159
+ return s.auth === "hmac" ? `${how}\nThe sender signs the raw body with the secret and sends header ${s.hmac_header}.` : how;
160
+ }
161
+ if (s.auth === "url") return `\nSend: curl -X POST "${s.url}" -H "Content-Type: application/json" -d '[{"${s.id_field}": "1", ...}]'`;
162
+ if (s.auth === "hmac") {
163
+ const value = s.hmac_format === "base64" ? "<base64 HMAC-SHA256>" : s.hmac_format === "hex" ? "<hex HMAC-SHA256>" : "sha256=<hex HMAC-SHA256>";
164
+ return `\nThe sender signs the raw body with the secret and sends header ${s.hmac_header}: ${value}`;
165
+ }
166
+ return `\nSend: curl -X POST "${s.url}" -H "Authorization: Bearer <secret>" -H "Content-Type: application/json" -d '[{"${s.id_field}": "1", ...}]'\n(CSV with Content-Type: text/csv, or NDJSON with application/x-ndjson.)`;
167
+ }
168
+
169
+ // One document per file, named by the file. The server reads its text.
170
+ const MAX_DOC = 10_000_000;
171
+ const MAX_UPLOAD = 50_000_000;
172
+
173
+ // Over 10 MB: an upload address from the Base, the file put there directly,
174
+ // then complete, and its status until it's read (AppBuilder.Sources.Uploads).
175
+ async function uploadDocument(ch, name, doc, type, bytes, opts = {}) {
176
+ const put = opts.put || ((url, init) => fetch(url, init));
177
+ const start = await ch.call("sources:upload_start", { name, file: doc, content_type: type, size: bytes.length });
178
+ const res = await put(start.upload_url, { method: "PUT", headers: start.headers, body: bytes });
179
+ if (!res.ok) throw new Error(`the upload was refused (HTTP ${res.status})`);
180
+ await ch.call("sources:upload_complete", { name, upload_id: start.upload_id });
181
+ const wait = opts.wait || ((ms) => new Promise((r) => setTimeout(r, ms)));
182
+ for (let i = 0; i < 90; i++) {
183
+ const s = await ch.call("sources:upload_status", { name, upload_id: start.upload_id });
184
+ if (s.status === "done") return s.result;
185
+ if (s.status === "failed") throw new Error(s.error || "it couldn't be read");
186
+ await wait(2000);
187
+ }
188
+ throw new Error("still reading it after 3 minutes; oalo sources show " + name + " lists it once done");
189
+ }
190
+ const DOC_TYPES = { ".pdf": "application/pdf", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".md": "text/markdown", ".markdown": "text/markdown", ".html": "text/html", ".htm": "text/html", ".txt": "text/plain" };
191
+
192
+ async function pushDocuments(ch, name, files, flags, opts = {}) {
193
+ const results = [];
194
+ let failed = 0;
195
+ for (const file of files) {
196
+ const doc = basename(file);
197
+ const type = DOC_TYPES[extname(doc).toLowerCase()];
198
+ if (!type) {
199
+ results.push({ document: doc, error: "not a PDF, Word .docx, Markdown, HTML or text file" });
200
+ failed++;
201
+ continue;
202
+ }
203
+ const bytes = readFileSync(file);
204
+ if (bytes.length > MAX_UPLOAD) {
205
+ results.push({ document: doc, error: "over 50 MB" });
206
+ failed++;
207
+ continue;
208
+ }
209
+ try {
210
+ results.push(
211
+ bytes.length > MAX_DOC
212
+ ? await uploadDocument(ch, name, doc, type, bytes, opts)
213
+ : await ch.call("sources:push_document", { name, document: doc, content_type: type, content_base64: bytes.toString("base64") }, 120000),
214
+ );
215
+ } catch (e) {
216
+ results.push({ document: doc, error: e.message });
217
+ failed++;
218
+ }
219
+ }
220
+ if (failed) process.exitCode = 1;
221
+ if (flags.json) return void console.log(JSON.stringify(results, null, 2));
222
+ for (const r of results) {
223
+ console.log(r.error ? `${r.document}: refused: ${r.error}` : `${r.document}: ${r.replaced ? "replaced" : "added"}, ${r.chars} characters, indexing`);
224
+ }
225
+ }
226
+
227
+ // Oalo fetches the address on a schedule. The header's value goes to the
228
+ // server once, sealed there; show prints only its name.
229
+ async function fetchCmd(ch, name, url, flags) {
230
+ for (const f of ["every", "records-at", "document", "relation", "key", "cursor"]) if (flags[f] === true) return fail(`--${f} needs a value`, 2);
231
+ if (url && /^postgres(ql)?:\/\//.test(url) && !flags.relation) return fail("a database address needs the view or table to read: --relation public.orders_for_oalo", 2);
232
+ if ([].concat(flags.header ?? []).includes(true)) return fail("--header needs a value", 2);
233
+ if (flags.off) {
234
+ await ch.call("sources:set_fetch", { name, off: true });
235
+ return void console.log(`${name}: no longer fetched. Senders can still post to its address.`);
236
+ }
237
+ if (url) {
238
+ const params = { name, url };
239
+ if (flags.every) params.every = flags.every;
240
+ // Repeatable: each --header is one; --header "" clears them.
241
+ if (flags.header !== undefined) params.headers = [].concat(flags.header).filter((h) => typeof h === "string" && h !== "");
242
+ if (flags["records-at"]) params.records_at = flags["records-at"];
243
+ if (flags.document) params.document = flags.document;
244
+ for (const f of ["relation", "key", "cursor"]) if (flags[f]) params[f] = flags[f];
245
+ const { fetch: f } = await ch.call("sources:set_fetch", params);
246
+ if (flags.json && !flags.now) return void console.log(JSON.stringify(f, null, 2));
247
+ if (f.relation) console.log(`${name}: reads ${f.relation} from ${f.url} every ${f.every_minutes} minutes, by ${f.key}${f.cursor ? `, only rows past the last ${f.cursor} seen` : ", the whole view each time"}`);
248
+ else console.log(`${name}: fetches ${f.url} every ${f.every_minutes} minutes${f.headers?.length ? `, with header${f.headers.length > 1 ? "s" : ""} ${f.headers.join(", ")}` : ""}${f.records_at ? `, records at ${f.records_at}` : ""}`);
249
+ if (!flags.now) console.log(dim(`The first fetch is within a minute. An answer that hasn't changed is skipped. \`oalo sources fetch ${name} --now\` fetches at once.`));
250
+ } else if (!flags.now) {
251
+ return fail(`usage: oalo sources fetch ${name} <https url> [--every <minutes>] [--header "Name: value"] or --now | --off`, 2);
252
+ }
253
+ if (flags.now) {
254
+ const r = await ch.call("sources:fetch_now", { name }, 120000);
255
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
256
+ if (r.unchanged) return void console.log(`${name}: the answer is the same as last time, so nothing changed.`);
257
+ if (r.document) return void console.log(`${name}: ${r.document} ${r.replaced ? "replaced" : "added"}, ${r.chars} characters, indexing`);
258
+ console.log(`${name}: ${r.upserted} of ${r.records} stored${r.deleted ? `, ${r.deleted} deleted (snapshot)` : ""}${r.rejected_count ? `, ${r.rejected_count} rejected` : ""}`);
259
+ if (r.rejected_count) process.exitCode = 1;
260
+ }
261
+ }
262
+
263
+ async function remove(ch, name, doc, flags) {
264
+ if (!doc) return fail("usage: oalo sources remove <name> <document>", 2);
265
+ const r = await ch.call("sources:remove_document", { name, document: doc });
266
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
267
+ if (!r.removed) {
268
+ process.exitCode = 1;
269
+ return void console.log(`${name} has no document ${doc} (oalo sources show ${name} lists them)`);
270
+ }
271
+ console.log(`${doc}: removed from ${name}; the bot stops answering from it now.`);
272
+ }
273
+
274
+ function printFetch(f) {
275
+ if (!f) return;
276
+ if (f.relation) console.log(` reads ${f.relation} from ${f.url} every ${f.every_minutes} min, by ${f.key}${f.cursor ? `, cursor ${f.cursor}` : ""}`);
277
+ else console.log(` fetches ${f.url} every ${f.every_minutes} min${f.headers?.length ? `, headers ${f.headers.join(", ")}` : ""}${f.records_at ? `, records at ${f.records_at}` : ""}`);
278
+ if (f.last_fetched_at) console.log(` last ${f.last_fetched_at}${f.last_error ? `: ${f.last_error}` : ", ok"}`);
279
+ }
280
+
281
+ async function show(ch, name, flags) {
282
+ const s = await ch.call("sources:get", { name });
283
+ if (flags.json) return void console.log(JSON.stringify(s, null, 2));
284
+ if (s.kind === "knowledge") {
285
+ console.log(`${s.name} -> ${s.bot}'s knowledge, ${s.auth}${s.auth === "hmac" ? ` (${s.hmac_header}, ${s.hmac_format})` : ""}`);
286
+ console.log(` address ${s.url}`);
287
+ console.log(` event ${s.event}`);
288
+ printFetch(s.fetch);
289
+ console.log(` documents ${s.documents.length ? "" : "none yet"}`);
290
+ for (const d of s.documents) console.log(` ${d.name} ${dim(`${d.chars} characters, ${d.status}, ${d.updated_at}`)}`);
291
+ return;
292
+ }
293
+ console.log(`${s.name} -> ${s.table} (${s.env}), ${s.mode}, ${s.auth}${s.auth === "hmac" ? ` (${s.hmac_header}, ${s.hmac_format})` : ""}`);
294
+ console.log(` address ${s.url}`);
295
+ console.log(` fields ${Object.entries(s.fields).map(([k, t]) => `${k}:${t}`).join(", ")} (id: ${s.id_field})`);
296
+ if (s.keep_from_ai?.length) console.log(` kept from AI ${s.keep_from_ai.join(", ")}`);
297
+ if (s.retention_days) console.log(` retention ${s.retention_days} days`);
298
+ console.log(` event ${s.event}`);
299
+ printFetch(s.fetch);
300
+ console.log(` stored ${s.received_count} rows${s.last_received_at ? `, last ${s.last_received_at}` : ""}`);
301
+ if (s.log?.length) {
302
+ console.log("\nRecent arrivals:");
303
+ for (const l of s.log) {
304
+ const what = l.error ? `refused: ${l.error}` : `${l.upserted} stored${l.rejected ? `, ${l.rejected} rejected` : ""}${l.deleted ? `, ${l.deleted} deleted` : ""}`;
305
+ console.log(` ${l.at} ${l.sender || "?"} ${l.records} records, ${what}${l.dropped_fields?.length ? dim(` dropped: ${l.dropped_fields.join(", ")}`) : ""}`);
306
+ }
307
+ }
308
+ }
309
+
310
+ async function push(ch, name, files, flags, opts = {}) {
311
+ const file = files[0];
312
+ if (!file) return fail(`usage: oalo sources push ${name} <file.csv|.json|.jsonl> (a knowledge source: <doc> [more...])`, 2);
313
+ const s = await ch.call("sources:get", { name });
314
+ if (s.kind === "knowledge") return pushDocuments(ch, name, files, flags, opts);
315
+ if (files.length > 1) return fail(`${name} takes records: push one file at a time`, 2);
316
+ const records = parseRecords(readFileSync(file, "utf8"), { name: basename(file) });
317
+ if (records.length === 0) return fail(`${file} has no records`, 2);
318
+ const pushId = randomBytes(9).toString("base64url");
319
+ const total = { records: 0, upserted: 0, rejected_count: 0, deleted: 0, dropped: new Set(), rejected: [] };
320
+
321
+ for (let i = 0; i < records.length; i += BATCH) {
322
+ const batch = records.slice(i, i + BATCH);
323
+ const final = i + BATCH >= records.length;
324
+ const r = await ch.call("sources:push", { name, records: batch, push_id: pushId, final }, 120000);
325
+ total.records += r.records;
326
+ total.upserted += r.upserted;
327
+ total.rejected_count += r.rejected_count;
328
+ total.deleted += r.deleted;
329
+ (r.dropped_fields || []).forEach((f) => total.dropped.add(f));
330
+ total.rejected.push(...(r.rejected || []).map((x) => ({ ...x, index: x.index + i })));
331
+ }
332
+
333
+ if (flags.json) return void console.log(JSON.stringify({ ...total, dropped: [...total.dropped] }, null, 2));
334
+ console.log(`${name}: ${total.upserted} of ${total.records} stored${total.deleted ? `, ${total.deleted} deleted (snapshot)` : ""}`);
335
+ if (total.dropped.size) console.log(dim(` dropped (not one of the source's fields): ${[...total.dropped].join(", ")}`));
336
+ if (total.rejected_count) {
337
+ console.log(` ${total.rejected_count} rejected:`);
338
+ total.rejected.slice(0, 10).forEach((x) => console.log(` record ${x.index + 1}: ${x.error}`));
339
+ process.exitCode = 1;
340
+ }
341
+ }
342
+
343
+ async function fields(ch, name, csv, flags) {
344
+ const want = list_(csv);
345
+ if (!want?.length) return fail(`usage: oalo sources fields ${name} a,b`, 2);
346
+ const s = await ch.call("sources:set_fields", { name, fields: want });
347
+ if (flags.json) return void console.log(JSON.stringify(s, null, 2));
348
+ console.log(`${name} keeps: ${Object.entries(s.fields).map(([k, t]) => `${k}:${t}`).join(", ")}`);
349
+ console.log(dim("Its address and secret are unchanged. Other fields a sender sends are dropped."));
350
+ }
351
+
352
+ async function retention(ch, name, value, flags) {
353
+ if (!value) return fail("usage: oalo sources retention <name> <days|off>", 2);
354
+ const days = value === "off" ? null : value;
355
+ const s = await ch.call("sources:set_retention", { name, days });
356
+ if (flags.json) return void console.log(JSON.stringify(s, null, 2));
357
+ console.log(s.retention_days ? `${name}: rows the source hasn't sent in ${s.retention_days} days are dropped each night (04:30 UTC).` : `${name}: rows stay until they're deleted.`);
358
+ }
359
+
360
+ async function rotate(ch, name, flags) {
361
+ const r = await ch.call("sources:rotate", { name });
362
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
363
+ console.log(`new secret for ${name}: ${r.secret}`);
364
+ console.log(` address ${r.url}`);
365
+ console.log(dim("The old secret stopped working just now; give the sender the new one."));
366
+ }
367
+
368
+ async function del(ch, name, flags) {
369
+ const r = await ch.call("sources:delete", { name, wipe: flags.wipe === true });
370
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
371
+ console.log(`deleted source ${name}${r.rows_deleted ? `, and its ${r.rows_deleted} rows` : ""}`);
372
+ }
373
+
374
+ function fail(message, code) {
375
+ console.error(message);
376
+ process.exitCode = code;
377
+ }
@@ -0,0 +1,156 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { OaloChannel } from "../channel.js";
3
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
4
+ import { dim } from "../events.js";
5
+ import { appIdFor } from "../app-ref.js";
6
+
7
+ /**
8
+ * `oalo loops` and `oalo flows`: test, switch on or off, and delete, without
9
+ * pulling and pushing the whole definition.
10
+ *
11
+ * oalo loops test <name> [--sample latest|example] [--input <json|@file>]
12
+ * oalo loops on <name> | off <name> | delete <name>
13
+ * (each with --app <slug> for a loop pinned to one app)
14
+ * oalo flows on <bot> <flow> | off <bot> <flow> | delete <bot> <flow>
15
+ *
16
+ * A test runs every step but simulates the ones that send messages or change
17
+ * records. Starting a loop for real is `oalo run loop <name>`.
18
+ */
19
+ export async function loops(flags, rest, opts = {}) {
20
+ const [, sub, name] = rest;
21
+ if (sub === "runs") return runs(flags, name, opts);
22
+ if (!["test", "on", "off", "delete"].includes(sub) || !name) {
23
+ return fail("usage: oalo loops test|on|off|delete <name>, or oalo loops runs [<name>] [--id <run>]", 2);
24
+ }
25
+
26
+ let input;
27
+ if (sub === "test" && flags.input !== undefined) {
28
+ try {
29
+ input = readInput(flags.input);
30
+ } catch (e) {
31
+ return fail(e.message, 2);
32
+ }
33
+ }
34
+ if (flags.sample !== undefined && !["latest", "example"].includes(flags.sample)) {
35
+ return fail("--sample is latest or example", 2);
36
+ }
37
+
38
+ return withChannel(flags, opts, async (ch) => {
39
+ const appId = await pinnedApp(ch, flags);
40
+ if (sub === "test") {
41
+ console.error(dim(`testing ${name}: sends and record changes are simulated`));
42
+ const params = { name, ...appId, ...(flags.sample ? { sample: flags.sample } : {}), ...(input !== undefined ? { input } : {}) };
43
+ const r = await ch.call("loops:test", params, 200_000);
44
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
45
+ printTest(r);
46
+ if (r.status !== "success") process.exitCode = 1;
47
+ return;
48
+ }
49
+ if (sub === "delete") {
50
+ const r = await ch.call("loops:delete", { name, ...appId });
51
+ return void console.log(r.message || `deleted ${name}`);
52
+ }
53
+ const r = await ch.call("loops:set_enabled", { name, enabled: sub === "on", ...appId });
54
+ console.log(r.message || `${name} ${sub}`);
55
+ if (sub === "on") console.error(dim("it now runs for real whenever its triggers fire"));
56
+ });
57
+ }
58
+
59
+ export async function flows(flags, rest, opts = {}) {
60
+ const [, sub, bot, flow] = rest;
61
+ if (!["on", "off", "delete"].includes(sub) || !bot || !flow) {
62
+ return fail("usage: oalo flows on|off|delete <bot> <flow> (bot: its public id; flow: its name or id)", 2);
63
+ }
64
+
65
+ return withChannel(flags, opts, async (ch) => {
66
+ const r =
67
+ sub === "delete"
68
+ ? await ch.call("chat:bots:flows:delete", { bot_id: bot, flow })
69
+ : await ch.call("chat:bots:flows:set_enabled", { bot_id: bot, flow, enabled: sub === "on" });
70
+ console.log(r.message || `${flow} ${sub}`);
71
+ });
72
+ }
73
+
74
+ /** `oalo loops runs [name]`: recent runs; `--id <run>`: one run, step by step. */
75
+ async function runs(flags, name, opts) {
76
+ return withChannel(flags, opts, async (ch) => {
77
+ const appId = await pinnedApp(ch, flags);
78
+ if (flags.id !== undefined) {
79
+ const r = await ch.call("loops:get_run", { run_id: Number(flags.id) });
80
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
81
+ const run = r.run || {};
82
+ console.log(`${run.name} run ${run.id}: ${run.status}${run.error ? `: ${oneLine(run.error)}` : ""} ${dim(`(${run.trigger || "?"}, ${String(run.started_at || "").slice(0, 19).replace("T", " ")})`)}`);
83
+ for (const n of r.nodes || []) {
84
+ console.log(` ${n.node_id} ${dim(`(${n.node_type}${n.status ? `, ${n.status}` : ""})`)}`);
85
+ const out = n.error || n.output;
86
+ if (out !== undefined && out !== null) console.log(dim(` ${oneLine(out)}`));
87
+ }
88
+ return;
89
+ }
90
+ const limit = Number(flags.limit) || 20;
91
+ const r = await ch.call("loops:list_runs_for_org", { ...(name ? { name } : {}), ...appId, limit });
92
+ if (flags.json) return void console.log(JSON.stringify(r.runs, null, 2));
93
+ if (!r.runs?.length) return void console.log(name ? `no runs of ${name} yet` : "no loop runs yet");
94
+ for (const x of r.runs) {
95
+ const when = String(x.started_at || "").slice(0, 16).replace("T", " ");
96
+ const test = x.rehearsal ? ", test" : "";
97
+ console.log(`${String(x.id).padEnd(7)} ${x.name} ${x.status}${x.error ? dim(`: ${oneLine(x.error).slice(0, 120)}`) : ""} ${dim(`${when}, ${x.trigger}${test}`)}`);
98
+ }
99
+ console.error(dim("one run step by step: oalo loops runs --id <run>"));
100
+ });
101
+ }
102
+
103
+ function printTest(r) {
104
+ console.log(`${r.status}${r.error ? `: ${r.error}` : ""} ${dim(`(run ${r.run_id})`)}`);
105
+ for (const s of r.steps || []) {
106
+ const tag = s.error ? "failed" : s.simulated ? "simulated" : "ran";
107
+ const ms = Number.isFinite(s.duration_ms) ? `, ${s.duration_ms} ms` : "";
108
+ console.log(` ${s.node_id} ${dim(`(${s.type}, ${tag}${ms})`)}`);
109
+ const out = s.error || s.output;
110
+ if (out !== undefined && out !== null) console.log(dim(` ${oneLine(out)}`));
111
+ }
112
+ if (r.final_output !== undefined && r.final_output !== null) console.log(`output: ${oneLine(r.final_output)}`);
113
+ }
114
+
115
+ function oneLine(v) {
116
+ const text = typeof v === "string" ? v : JSON.stringify(v);
117
+ return text.length > 300 ? text.slice(0, 300) + "..." : text;
118
+ }
119
+
120
+ /** `--input '{"type": ...}'` or `--input @event.json`. */
121
+ function readInput(raw) {
122
+ if (raw === true) throw new Error("--input needs JSON, or @file.json");
123
+ const text = String(raw).startsWith("@") ? readFileSync(String(raw).slice(1), "utf8") : String(raw);
124
+ try {
125
+ return JSON.parse(text);
126
+ } catch {
127
+ throw new Error("--input isn't valid JSON");
128
+ }
129
+ }
130
+
131
+ /** `{app_id}` for `--app <slug>` (a loop pinned to one app), else `{}`. */
132
+ async function pinnedApp(ch, flags) {
133
+ if (flags["app-id"] !== undefined) throw new Error("--app-id is now --app <slug> (or the app's id), as in `oalo deploy`.");
134
+ return typeof flags.app === "string" ? { app_id: await appIdFor(ch, flags.app) } : {};
135
+ }
136
+
137
+ async function withChannel(flags, opts, fn) {
138
+ const cfg = resolveConfig(flags);
139
+ const { url, token } = controlPlane(cfg);
140
+ if (!url || !token) return fail(`this needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
141
+
142
+ const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
143
+ try {
144
+ await ch.connect();
145
+ await fn(ch);
146
+ } catch (e) {
147
+ fail(e.message, 1);
148
+ } finally {
149
+ ch.close();
150
+ }
151
+ }
152
+
153
+ function fail(message, code) {
154
+ console.error(message);
155
+ process.exitCode = code;
156
+ }
@@ -0,0 +1,84 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
+ import { isInternal, NOISY, format, clock, dim } from "../events.js";
4
+
5
+ /**
6
+ * `oalo tail` — watch the workspace while you work.
7
+ *
8
+ * The question this answers is "I did a thing; did the thing I expected
9
+ * happen?" Loop runs, job state, builds and activity all arrive here, so a flow
10
+ * that never fired shows up as silence rather than as a mystery.
11
+ *
12
+ * What it does NOT show: direct writes through the Sync REST API. Those do not
13
+ * broadcast on this channel. What you will see is their CONSEQUENCES — the loop
14
+ * that a write triggered, the job it queued.
15
+ */
16
+
17
+ export async function tail(flags) {
18
+ const cfg = resolveConfig(flags);
19
+ const { url, token } = controlPlane(cfg);
20
+
21
+ if (!url || !token) {
22
+ console.error(
23
+ `oalo tail needs a CLI token.\n\n${HOW_TO_CONNECT}`,
24
+ );
25
+ process.exitCode = 2;
26
+ return;
27
+ }
28
+
29
+ const showAll = !!flags.all;
30
+ const asJson = !!flags.json;
31
+ const filter = typeof flags.filter === "string" ? flags.filter.toLowerCase() : null;
32
+
33
+ const ch = new OaloChannel({ url, token });
34
+
35
+ ch.onEvent((event, payload) => {
36
+ if (isInternal(event)) return;
37
+ if (!showAll && NOISY.test(event)) return;
38
+
39
+ if (filter) {
40
+ const hay = `${event} ${JSON.stringify(payload ?? {})}`.toLowerCase();
41
+ if (!hay.includes(filter)) return;
42
+ }
43
+
44
+ if (asJson) {
45
+ // One JSON object per line so it pipes straight into jq.
46
+ console.log(JSON.stringify({ at: new Date().toISOString(), event, payload }));
47
+ return;
48
+ }
49
+
50
+ const at = payload && typeof payload === "object" ? payload.at : null;
51
+ // If a formatter produced nothing useful, show the payload rather than a
52
+ // blank line — a silent event is indistinguishable from no event at all.
53
+ let line = format(event, payload);
54
+ if (!line || !line.replace(/\x1b\[[0-9;]*m/g, "").trim().replace(/^[^:]*:$/, "")) {
55
+ const json = JSON.stringify(payload ?? {});
56
+ line = json === "{}" ? dim("(no payload)") : json.slice(0, 160);
57
+ }
58
+ console.log(`${dim(clock(at))} ${event.padEnd(22)} ${line}`);
59
+ });
60
+
61
+ try {
62
+ await ch.connect();
63
+ } catch (e) {
64
+ console.error(`tail failed: ${e.message}`);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+
69
+ if (!asJson) {
70
+ console.error(dim(`watching ${url}; ctrl-c stops`));
71
+ if (!showAll) console.error(dim("(streaming chunk events hidden; --all shows them)"));
72
+ }
73
+
74
+ const stop = () => {
75
+ ch.close();
76
+ process.exit(0);
77
+ };
78
+ process.on("SIGINT", stop);
79
+ process.on("SIGTERM", stop);
80
+
81
+ // Hold the process open. The channel's own heartbeat keeps the socket alive;
82
+ // this just stops Node from exiting on an empty event loop.
83
+ await new Promise(() => {});
84
+ }