@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.
- package/LICENSE +21 -0
- package/README.md +392 -2
- package/man/oalo.1 +1530 -0
- package/package.json +44 -4
- package/plugin/.claude-plugin/plugin.json +12 -0
- package/plugin/skills/oalo/SKILL.md +414 -0
- package/src/api.js +132 -0
- package/src/app-ref.js +11 -0
- package/src/args.js +67 -0
- package/src/bot-settings.json +468 -0
- package/src/channel.js +162 -0
- package/src/commands/apps.js +64 -0
- package/src/commands/bots.js +278 -0
- package/src/commands/call.js +179 -0
- package/src/commands/channels.js +110 -0
- package/src/commands/data.js +446 -0
- package/src/commands/db.js +58 -0
- package/src/commands/deploy.js +431 -0
- package/src/commands/dockerfile.js +81 -0
- package/src/commands/docs.js +70 -0
- package/src/commands/emit.js +124 -0
- package/src/commands/env.js +223 -0
- package/src/commands/graphs.js +570 -0
- package/src/commands/incidents.js +44 -0
- package/src/commands/link.js +150 -0
- package/src/commands/login.js +180 -0
- package/src/commands/logs.js +69 -0
- package/src/commands/mcp.js +125 -0
- package/src/commands/metrics.js +168 -0
- package/src/commands/products.js +169 -0
- package/src/commands/qa.js +98 -0
- package/src/commands/rehearse.js +100 -0
- package/src/commands/run.js +152 -0
- package/src/commands/secrets.js +68 -0
- package/src/commands/setup.js +96 -0
- package/src/commands/skill.js +142 -0
- package/src/commands/sources.js +377 -0
- package/src/commands/switches.js +156 -0
- package/src/commands/tail.js +84 -0
- package/src/commands/tokens.js +85 -0
- package/src/commands/typegen.js +505 -0
- package/src/commands/whoami.js +112 -0
- package/src/config.js +157 -0
- package/src/data.js +392 -0
- package/src/envfile.js +71 -0
- package/src/events.js +63 -0
- package/src/graphfiles.js +273 -0
- package/src/help.js +920 -0
- package/src/index.js +216 -0
- package/src/mcp/tools.js +773 -0
- package/src/server.js +73 -0
- package/src/workspace.js +44 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { OaloChannel } from "../channel.js";
|
|
4
|
+
import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
|
|
5
|
+
import { requireFeature } from "../server.js";
|
|
6
|
+
import { ApiError } from "../api.js";
|
|
7
|
+
import { dim } from "../events.js";
|
|
8
|
+
import {
|
|
9
|
+
parseRecords,
|
|
10
|
+
toRows,
|
|
11
|
+
targetEnv,
|
|
12
|
+
listModels,
|
|
13
|
+
routes,
|
|
14
|
+
listRows,
|
|
15
|
+
getRow,
|
|
16
|
+
watchRows,
|
|
17
|
+
pushRows,
|
|
18
|
+
deleteRow,
|
|
19
|
+
writeTokenFor,
|
|
20
|
+
parseFields,
|
|
21
|
+
createModel,
|
|
22
|
+
promoteModel,
|
|
23
|
+
} from "../data.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `oalo data`: models and rows in this workspace.
|
|
27
|
+
*
|
|
28
|
+
* oalo data models list models (fields, owner field)
|
|
29
|
+
* oalo data models create <name> --field total:float [--field ...] [--owned-by user_id]
|
|
30
|
+
* oalo data list <table> [--since N] [--limit N]
|
|
31
|
+
* oalo data get <table> <id>
|
|
32
|
+
* oalo data push <table> <file> [--id-field id] [--format csv|json|jsonl] [--types] [--from N] [--dry-run]
|
|
33
|
+
* oalo data delete <table> <id>
|
|
34
|
+
* oalo data watch <table> [--since N]
|
|
35
|
+
* oalo data promote <table> copy dev rows to prod
|
|
36
|
+
* oalo data routes the Sync API's endpoints, scopes and limits
|
|
37
|
+
* oalo data events <table> created [updated] | --off
|
|
38
|
+
* workflow events when a live row changes
|
|
39
|
+
* oalo data vectorize <table> [--fields a,b] [--where '...'] [--describe '...'] [--id col] [--off]
|
|
40
|
+
* make a table searchable by bots
|
|
41
|
+
* oalo data keep-from-ai <table> [a,b] [--off]
|
|
42
|
+
* fields a bot's data tools never hand its AI model
|
|
43
|
+
*
|
|
44
|
+
* Every command works on one schema, `dev` or `prod` (`--env`, default the
|
|
45
|
+
* project's link, default dev), and says which. Rows written to prod sync to
|
|
46
|
+
* live apps at once, so writing there needs `--env prod` on the command line.
|
|
47
|
+
*/
|
|
48
|
+
export async function data(flags, rest, opts = {}) {
|
|
49
|
+
const sub = rest[1];
|
|
50
|
+
const cfg = resolveConfig(flags);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
switch (sub) {
|
|
54
|
+
case "models":
|
|
55
|
+
return rest[2] === "create" ? await modelsCreate(flags, rest, cfg) : await models(flags, cfg);
|
|
56
|
+
case "list":
|
|
57
|
+
return await list(flags, rest, cfg);
|
|
58
|
+
case "get":
|
|
59
|
+
return await get(flags, rest, cfg);
|
|
60
|
+
case "push":
|
|
61
|
+
return await push(flags, rest, cfg);
|
|
62
|
+
case "delete":
|
|
63
|
+
return await del(flags, rest, cfg);
|
|
64
|
+
case "watch":
|
|
65
|
+
return await watch(flags, rest, cfg);
|
|
66
|
+
case "promote":
|
|
67
|
+
return await promote(flags, rest, cfg);
|
|
68
|
+
case "routes":
|
|
69
|
+
return await showRoutes(flags, cfg);
|
|
70
|
+
case "events":
|
|
71
|
+
return await rowEvents(flags, rest, cfg);
|
|
72
|
+
case "vectorize":
|
|
73
|
+
return await vectorize(flags, rest, cfg, opts);
|
|
74
|
+
case "keep-from-ai":
|
|
75
|
+
return await keepFromAi(flags, rest, cfg, opts);
|
|
76
|
+
default:
|
|
77
|
+
usage(sub ? `unknown data command: ${sub}` : null);
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error(explain(e));
|
|
81
|
+
process.exitCode = e.exitCode || process.exitCode || 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function usage(problem) {
|
|
86
|
+
if (problem?.startsWith("usage:")) {
|
|
87
|
+
console.error(problem);
|
|
88
|
+
process.exitCode = 2;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (problem) console.error(problem + "\n");
|
|
92
|
+
console.error(
|
|
93
|
+
[
|
|
94
|
+
"usage: oalo data models | models create <name> --field name:type ...",
|
|
95
|
+
" oalo data list <table> | get <table> <id> | watch <table>",
|
|
96
|
+
" oalo data push <table> <file> [--id-field id] [--dry-run]",
|
|
97
|
+
" oalo data delete <table> <id> | promote <table> | routes",
|
|
98
|
+
" oalo data events <table> created [updated] | --off",
|
|
99
|
+
" oalo data vectorize <table> [--fields a,b] [--where '...'] [--describe '...'] [--off]",
|
|
100
|
+
" oalo data keep-from-ai <table> [a,b] [--off]",
|
|
101
|
+
"",
|
|
102
|
+
"Add --env dev|prod to pick the schema (default: the project's link, else dev). `oalo help data` has more.",
|
|
103
|
+
].join("\n"),
|
|
104
|
+
);
|
|
105
|
+
process.exitCode = 2;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A 403 on a read almost always means the token can't see that table.
|
|
109
|
+
function explain(e) {
|
|
110
|
+
if (e instanceof ApiError && e.status === 403) return `${e.message}. The saved data token can't do this; check \`oalo whoami\`, or ask an admin for access to this table.`;
|
|
111
|
+
if (e instanceof ApiError && e.status === 404) return `${e.message}. Check the table name with \`oalo data models\`.`;
|
|
112
|
+
return e.message;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `oalo data events <table> created updated`: a live table's changes start
|
|
117
|
+
* workflows (data.<table>.created / .updated), whoever writes it: the app's
|
|
118
|
+
* own SQL, the Sync API, the dashboard. Live rows only. `--off` stops them.
|
|
119
|
+
*/
|
|
120
|
+
async function rowEvents(flags, rest, cfg) {
|
|
121
|
+
const table = rest[2];
|
|
122
|
+
const kinds = rest.slice(3);
|
|
123
|
+
if (!table || (kinds.length === 0 && !flags.off) || (kinds.length && flags.off)) {
|
|
124
|
+
return usage("usage: oalo data events <table> created [updated] or oalo data events <table> --off");
|
|
125
|
+
}
|
|
126
|
+
const bad = kinds.filter((k) => !["created", "updated"].includes(k));
|
|
127
|
+
if (bad.length) return usage(`usage: the kinds are created and updated (got ${bad.join(", ")})`);
|
|
128
|
+
|
|
129
|
+
const { ch, url } = await control(cfg, "oalo data events");
|
|
130
|
+
try {
|
|
131
|
+
await requireFeature(url, "row_events", "oalo data events");
|
|
132
|
+
const r = await ch.call("data:set_row_events", { table, kinds: flags.off ? [] : kinds });
|
|
133
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
134
|
+
if (!r.row_events.length) return void console.log(`${r.table}: no workflow events`);
|
|
135
|
+
console.log(`${r.table}: ${r.row_events.map((k) => `data.${r.table}.${k}`).join(", ")}`);
|
|
136
|
+
console.log(dim("Fired when a live row changes, by anything that writes it. Pick them as a workflow's trigger."));
|
|
137
|
+
} finally {
|
|
138
|
+
ch.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const VECTORIZE_USAGE = "usage: oalo data vectorize <table> [--fields a,b] [--where 'status in active,draft'] [--describe '...'] [--id <column>] or --off";
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `oalo data vectorize <table> --fields title,body`: bots can search the
|
|
146
|
+
* table once it's attached to them (`oalo bots knowledge`). An Oalo model or a
|
|
147
|
+
* table the app made; the server decides which. With no flags, its status.
|
|
148
|
+
*/
|
|
149
|
+
async function vectorize(flags, rest, cfg, opts) {
|
|
150
|
+
const table = rest[2];
|
|
151
|
+
if (!table || rest.length > 3) return usage(VECTORIZE_USAGE);
|
|
152
|
+
const list = (v) => String(v).split(",").map((s) => s.trim()).filter(Boolean);
|
|
153
|
+
for (const f of ["fields", "where", "describe", "id"]) {
|
|
154
|
+
if (flags[f] === true) return usage(`usage: --${f} needs a value. ${VECTORIZE_USAGE}`);
|
|
155
|
+
}
|
|
156
|
+
if (flags.off && (flags.fields || flags.where)) return usage(`usage: --off can't go with --fields or --where. ${VECTORIZE_USAGE}`);
|
|
157
|
+
|
|
158
|
+
const env = flags.env || cfg.env || "dev";
|
|
159
|
+
if (!["dev", "prod"].includes(env)) return usage(`usage: --env must be dev or prod (got ${env})`);
|
|
160
|
+
const params = { table, env };
|
|
161
|
+
if (flags.fields) params.fields = list(flags.fields);
|
|
162
|
+
if (flags.where) params.where = flags.where;
|
|
163
|
+
if (flags.describe) params.description = flags.describe;
|
|
164
|
+
if (flags.id) params.id_col = flags.id;
|
|
165
|
+
if (flags.off) params.off = true;
|
|
166
|
+
const changing = Object.keys(params).length > 2;
|
|
167
|
+
|
|
168
|
+
const { ch, url } = await control(cfg, "oalo data vectorize", { channel: opts.channel });
|
|
169
|
+
let r;
|
|
170
|
+
try {
|
|
171
|
+
if (!opts.channel) await requireFeature(url, "knowledge", "oalo data vectorize");
|
|
172
|
+
r = await ch.call("imports:searchable", params);
|
|
173
|
+
} finally {
|
|
174
|
+
ch.close();
|
|
175
|
+
}
|
|
176
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
177
|
+
printSearchable(r, changing);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function printSearchable(r, changed) {
|
|
181
|
+
const name = `${r.table} (${r.kind === "model" ? "model" : "table"})`;
|
|
182
|
+
if (!r.enabled) {
|
|
183
|
+
console.log(changed ? `${name}: no longer searchable. Bots it's attached to stop finding it.` : `${name}: not searchable by bots.`);
|
|
184
|
+
if (!changed) console.log(dim(`Turn it on: oalo data vectorize ${r.table} --fields <field,...>`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
console.log(`${name}: bots can search it once attached, as ${r.handle}`);
|
|
188
|
+
console.log(` embeds: ${r.fields.join(", ")}`);
|
|
189
|
+
if (r.where) console.log(` only rows where: ${r.where}`);
|
|
190
|
+
if (r.description) console.log(` described as: ${r.description}`);
|
|
191
|
+
const rows = Object.entries(r.rows || {}).map(([env, c]) => `${env} ${c.embedded} of ${c.total}`);
|
|
192
|
+
if (rows.length) console.log(` rows embedded: ${rows.join(", ")}`);
|
|
193
|
+
if (r.enqueued) console.log(dim(`Embedding ${r.enqueued} row${r.enqueued === 1 ? "" : "s"} now. New and edited rows follow within 15 minutes.`));
|
|
194
|
+
if (r.kept_from_ai?.length) console.log(` kept from AI: ${r.kept_from_ai.join(", ")}`);
|
|
195
|
+
console.log(
|
|
196
|
+
dim(
|
|
197
|
+
`The embedding provider (Voyage) gets the text of ${r.fields.join(", ")}. A bot's answer sends the rows its search finds to its AI model` +
|
|
198
|
+
(r.kept_from_ai?.length ? `, without ${r.kept_from_ai.join(", ")}.` : `. Keep fields out of that: oalo data keep-from-ai ${r.table} <field,...>`),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
console.log(dim(`Attach it to a bot: oalo bots knowledge <bot> --add ${r.handle}`));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const KEEP_USAGE = "usage: oalo data keep-from-ai <table> [field,...] [--off]";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `oalo data keep-from-ai <table> cost,notes`: those fields stay stored and
|
|
208
|
+
* synced, and workflows read them, but a bot's data tools never hand them to
|
|
209
|
+
* its AI model, and they can't be embedded. `--off` with fields takes those
|
|
210
|
+
* off the list; `--off` alone clears it. With neither, the list.
|
|
211
|
+
*/
|
|
212
|
+
async function keepFromAi(flags, rest, cfg, opts) {
|
|
213
|
+
const table = rest[2];
|
|
214
|
+
if (!table || rest.length > 4) return usage(KEEP_USAGE);
|
|
215
|
+
const fields = rest[3] ? rest[3].split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
216
|
+
const env = flags.env || cfg.env || "dev";
|
|
217
|
+
if (!["dev", "prod"].includes(env)) return usage(`usage: --env must be dev or prod (got ${env})`);
|
|
218
|
+
|
|
219
|
+
const { ch } = await control(cfg, "oalo data keep-from-ai", { channel: opts.channel });
|
|
220
|
+
let r;
|
|
221
|
+
try {
|
|
222
|
+
r =
|
|
223
|
+
fields.length || flags.off
|
|
224
|
+
? await ch.call("data:keep_from_ai", { table, env, fields, off: flags.off === true })
|
|
225
|
+
: await ch.call("imports:searchable", { table, env });
|
|
226
|
+
} finally {
|
|
227
|
+
ch.close();
|
|
228
|
+
}
|
|
229
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
230
|
+
const kept = r.kept_from_ai || [];
|
|
231
|
+
const name = `${r.table} (${r.kind === "model" ? "model" : `${env} table`})`;
|
|
232
|
+
if (!kept.length) return void console.log(`${name}: every field can reach a bot's AI model.${fields.length || flags.off ? "" : dim(` Keep some out: oalo data keep-from-ai ${r.table} <field,...>`)}`);
|
|
233
|
+
console.log(`${name}: kept from AI: ${kept.join(", ")}`);
|
|
234
|
+
console.log(dim("Stored and synced as before, and workflows read them. A bot's data tools leave them out, and they can't be embedded."));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The read side: the saved sync token, on the chosen schema. */
|
|
238
|
+
function readCfg(flags, cfg) {
|
|
239
|
+
if (!cfg.serverUrl || !cfg.token) {
|
|
240
|
+
// Exit 2, as the help says for "no token" (audit C2).
|
|
241
|
+
throw Object.assign(new Error(`no workspace or data token for this project.\n\n${HOW_TO_CONNECT}`), { exitCode: 2 });
|
|
242
|
+
}
|
|
243
|
+
const env = flags.env || cfg.env || "dev";
|
|
244
|
+
if (!["dev", "prod"].includes(env)) throw new Error(`--env must be dev or prod (got ${env})`);
|
|
245
|
+
return { serverUrl: cfg.serverUrl, token: cfg.token, env };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The control channel, for writes made as the person. `needsFeature` is for the
|
|
250
|
+
* model commands, whose events are newer than the token events row writes use.
|
|
251
|
+
*/
|
|
252
|
+
async function control(cfg, what, { needsFeature = false, channel } = {}) {
|
|
253
|
+
const { url, token } = controlPlane(cfg);
|
|
254
|
+
if (!url || !token) throw new Error(`${what} needs a CLI token.\n\n${HOW_TO_CONNECT}`);
|
|
255
|
+
if (needsFeature) await requireFeature(url, "data", what);
|
|
256
|
+
const ch = (channel || ((o) => new OaloChannel(o)))({ url, token });
|
|
257
|
+
await ch.connect();
|
|
258
|
+
return { ch, url };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const out = (flags, value, human) => (flags.json ? console.log(JSON.stringify(value, null, 2)) : human());
|
|
262
|
+
const where = (c) => dim(`${c.serverUrl} (${c.env})`);
|
|
263
|
+
|
|
264
|
+
// ── models ───────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
async function models(flags, cfg) {
|
|
267
|
+
const c = readCfg(flags, cfg);
|
|
268
|
+
const m = await listModels(c);
|
|
269
|
+
out(flags, m, () => {
|
|
270
|
+
const names = Object.keys(m.models || {}).sort();
|
|
271
|
+
console.log(`${names.length} model${names.length === 1 ? "" : "s"} in ${where(c)}`);
|
|
272
|
+
for (const name of names) {
|
|
273
|
+
const model = m.models[name];
|
|
274
|
+
const fields = Array.isArray(model.fields)
|
|
275
|
+
? model.fields.map((f) => `${f.name}:${f.type}`)
|
|
276
|
+
: Object.entries(model.fields || {}).map(([k, v]) => `${k}:${typeof v === "string" ? v : v?.type || "?"}`);
|
|
277
|
+
const who = model.owner_field ? `owned by ${model.owner_field}` : model.shared ? "shared" : "";
|
|
278
|
+
console.log(` ${name}${who ? dim(` ${who}`) : ""}`);
|
|
279
|
+
if (fields.length) console.log(dim(` ${fields.join(" ")}`));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function modelsCreate(flags, rest, cfg) {
|
|
285
|
+
const name = rest[3];
|
|
286
|
+
const fields = parseFields(flags.field || []);
|
|
287
|
+
if (!name || !fields.length) {
|
|
288
|
+
return usage("usage: oalo data models create <name> --field name:type [--field ...] [--owned-by user_id]\n" +
|
|
289
|
+
"types: string text integer float boolean datetime date json list references:<table>");
|
|
290
|
+
}
|
|
291
|
+
const { ch, url } = await control(cfg, "oalo data models create", { needsFeature: true });
|
|
292
|
+
try {
|
|
293
|
+
const r = await createModel(ch, { name, fields, ownedBy: typeof flags["owned-by"] === "string" ? flags["owned-by"] : null });
|
|
294
|
+
out(flags, r, () => {
|
|
295
|
+
console.log(`created ${name} in ${url} (dev and prod): ${fields.map((f) => `${f.name}:${f.type}`).join(", ")}`);
|
|
296
|
+
console.log(dim("Rows go in with `oalo data push`; regenerate bindings with `oalo typegen`."));
|
|
297
|
+
});
|
|
298
|
+
} finally {
|
|
299
|
+
ch.close();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function promote(flags, rest, cfg) {
|
|
304
|
+
const table = rest[2];
|
|
305
|
+
if (!table) return usage("usage: oalo data promote <table>");
|
|
306
|
+
const { ch, url } = await control(cfg, "oalo data promote", { needsFeature: true });
|
|
307
|
+
try {
|
|
308
|
+
const r = await promoteModel(ch, table);
|
|
309
|
+
out(flags, r, () => console.log(`copying ${table} rows from dev to prod in ${url}. Live apps get them as they land.`));
|
|
310
|
+
} finally {
|
|
311
|
+
ch.close();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── rows ─────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
async function list(flags, rest, cfg) {
|
|
318
|
+
const table = rest[2];
|
|
319
|
+
if (!table) return usage("usage: oalo data list <table> [--since N] [--limit N]");
|
|
320
|
+
const c = readCfg(flags, cfg);
|
|
321
|
+
const page = await listRows(c, table, {
|
|
322
|
+
since: flags.since,
|
|
323
|
+
limit: flags.limit,
|
|
324
|
+
includeDeleted: !!flags["include-deleted"],
|
|
325
|
+
order: flags.order,
|
|
326
|
+
});
|
|
327
|
+
out(flags, page, () => {
|
|
328
|
+
for (const row of page.rows || []) console.log(JSON.stringify({ id: row.id, ...row.document }));
|
|
329
|
+
console.error(dim(`${(page.rows || []).length} rows from ${table} in ${c.serverUrl} (${c.env})` +
|
|
330
|
+
(page.has_more ? `; more with --since ${page.next_since}` : "")));
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function get(flags, rest, cfg) {
|
|
335
|
+
const [table, id] = [rest[2], rest[3]];
|
|
336
|
+
if (!table || !id) return usage("usage: oalo data get <table> <id>");
|
|
337
|
+
const row = await getRow(readCfg(flags, cfg), table, id);
|
|
338
|
+
console.log(JSON.stringify(flags.json ? row : { id: row.id, ...row.document }, null, 2));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function watch(flags, rest, cfg) {
|
|
342
|
+
const table = rest[2];
|
|
343
|
+
if (!table) return usage("usage: oalo data watch <table> [--since N]");
|
|
344
|
+
const c = readCfg(flags, cfg);
|
|
345
|
+
console.error(dim(`watching ${table} in ${c.serverUrl} (${c.env}). Ctrl-C to stop.`));
|
|
346
|
+
const ac = new AbortController();
|
|
347
|
+
process.on("SIGINT", () => ac.abort());
|
|
348
|
+
try {
|
|
349
|
+
await watchRows(c, table, {
|
|
350
|
+
since: flags.since || 0,
|
|
351
|
+
signal: ac.signal,
|
|
352
|
+
onEvent: (event, d) => {
|
|
353
|
+
if (event === "heartbeat") return;
|
|
354
|
+
console.log(flags.json ? JSON.stringify({ event, data: d }) : `${event} ${JSON.stringify(d?.id ? { id: d.id, ...d.document } : d)}`);
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
} catch (e) {
|
|
358
|
+
if (e.name !== "AbortError") throw e;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function push(flags, rest, cfg) {
|
|
363
|
+
const [table, file] = [rest[2], rest[3]];
|
|
364
|
+
if (!table || !file) return usage("usage: oalo data push <table> <file.csv|.json|.jsonl> [--id-field id] [--dry-run]");
|
|
365
|
+
|
|
366
|
+
const env = targetEnv(flags, cfg);
|
|
367
|
+
const idField = typeof flags["id-field"] === "string" ? flags["id-field"] : "id";
|
|
368
|
+
const records = parseRecords(readFileSync(file, "utf8"), {
|
|
369
|
+
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
370
|
+
name: basename(file),
|
|
371
|
+
types: !!flags.types,
|
|
372
|
+
});
|
|
373
|
+
const { rows, missing } = toRows(records, { idField, newIds: !!flags["new-ids"] });
|
|
374
|
+
const from = Number(flags.from || 0);
|
|
375
|
+
const todo = rows.slice(from);
|
|
376
|
+
|
|
377
|
+
if (missing.length) {
|
|
378
|
+
console.error(`${missing.length} record${missing.length === 1 ? "" : "s"} have no ${idField} and will be skipped (first: #${missing[0].index + 1}). ` +
|
|
379
|
+
`Pick the id column with --id-field, or let the server make ids with --new-ids (a re-send then adds copies).`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Check the table exists before writing a single row.
|
|
383
|
+
const read = readCfg({ ...flags, env }, cfg);
|
|
384
|
+
const manifest = await listModels(read);
|
|
385
|
+
if (!manifest.models?.[table]) {
|
|
386
|
+
throw new Error(`there is no model named ${table} in ${cfg.serverUrl}. Create it first: oalo data models create ${table} --field name:type ...`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (flags["dry-run"]) {
|
|
390
|
+
out(flags, { table, env, rows: todo.length, skipped: missing.length, first: todo[0] ?? null }, () => {
|
|
391
|
+
console.log(`${todo.length} rows would be upserted into ${table} in ${cfg.serverUrl} (${env}), by ${idField}.`);
|
|
392
|
+
if (todo[0]) console.log(dim(`first: ${JSON.stringify(todo[0])}`));
|
|
393
|
+
console.log(dim("dry run: nothing was written"));
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const { ch, url } = await control(cfg, "oalo data push");
|
|
399
|
+
let token;
|
|
400
|
+
try {
|
|
401
|
+
token = await writeTokenFor(url, [table], ch, { log: (m) => console.error(dim(m)) });
|
|
402
|
+
} finally {
|
|
403
|
+
ch.close();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
console.error(dim(`upserting ${todo.length} rows into ${table} in ${url} (${env}), by ${idField}`));
|
|
407
|
+
let result;
|
|
408
|
+
try {
|
|
409
|
+
result = await pushRows({ serverUrl: url, token, env }, table, todo, {
|
|
410
|
+
onBatch: ({ done, total }) => process.stderr.write(dim(` ${from + done}/${from + total}\n`)),
|
|
411
|
+
});
|
|
412
|
+
} catch (e) {
|
|
413
|
+
throw new Error(`${e.message}\nRows up to the last progress line were written. Writes are upserts by ${idField}, so it is safe to re-run, or resume with --from <that number>.`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
out(flags, { table, env, ...result, skipped: missing.length }, () => {
|
|
417
|
+
console.log(`${result.written} rows written to ${table} (${env})${result.failed.length ? `, ${result.failed.length} rejected` : ""}.`);
|
|
418
|
+
for (const f of result.failed.slice(0, 5)) console.log(` ${f.id}: ${f.error}`);
|
|
419
|
+
if (result.failed.length > 5) console.log(dim(` ...and ${result.failed.length - 5} more (--json lists them all)`));
|
|
420
|
+
if (env === "dev") console.log(dim(`Live apps read prod. When dev looks right: oalo data promote ${table}`));
|
|
421
|
+
});
|
|
422
|
+
if (result.failed.length) process.exitCode = 1;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function del(flags, rest, cfg) {
|
|
426
|
+
const [table, id] = [rest[2], rest[3]];
|
|
427
|
+
if (!table || !id) return usage("usage: oalo data delete <table> <id>");
|
|
428
|
+
const env = targetEnv(flags, cfg);
|
|
429
|
+
const { ch, url } = await control(cfg, "oalo data delete");
|
|
430
|
+
let token;
|
|
431
|
+
try {
|
|
432
|
+
token = await writeTokenFor(url, [table], ch, { log: (m) => console.error(dim(m)) });
|
|
433
|
+
} finally {
|
|
434
|
+
ch.close();
|
|
435
|
+
}
|
|
436
|
+
const r = await deleteRow({ serverUrl: url, token, env }, table, id);
|
|
437
|
+
out(flags, r, () => console.log(`deleted ${table}/${id} in ${url} (${env}). It stays in the table, marked deleted, and leaves synced apps.`));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function showRoutes(flags, cfg) {
|
|
441
|
+
const r = await routes(readCfg(flags, cfg));
|
|
442
|
+
out(flags, r, () => {
|
|
443
|
+
for (const e of r.endpoints || []) console.log(`${e.method.padEnd(6)} ${e.path.padEnd(32)} ${dim(`[${e.scope}] ${e.description}`)}`);
|
|
444
|
+
if (r.rate_limits_per_minute) console.log(dim(`\nlimits per minute: ${Object.entries(r.rate_limits_per_minute).map(([k, v]) => `${k} ${v}`).join(", ")}`));
|
|
445
|
+
});
|
|
446
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
|
|
3
|
+
import { dim } from "../events.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `oalo db dump [--env prod] [--out file.sql]`: all of an env's data as plain
|
|
7
|
+
* SQL (every app's own tables and the Oalo models, with their rows), so it can
|
|
8
|
+
* be restored anywhere with `psql -f`. Workspace admins only. The dump ends
|
|
9
|
+
* with pg_dump's own "dump complete" line; a dump without it is cut off, and
|
|
10
|
+
* the file is removed rather than left looking whole.
|
|
11
|
+
*/
|
|
12
|
+
export async function db(flags, rest, opts = {}) {
|
|
13
|
+
const sub = rest[1];
|
|
14
|
+
if (sub !== "dump") return fail("usage: oalo db dump [--env dev|prod] [--out <file.sql>]", 2);
|
|
15
|
+
const env = flags.env === "dev" ? "dev" : "prod";
|
|
16
|
+
const { url, token } = controlPlane(resolveConfig(flags));
|
|
17
|
+
if (!url || !token) return fail(`oalo db dump needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
|
|
18
|
+
|
|
19
|
+
const host = new URL(url).hostname.split(".")[0];
|
|
20
|
+
const out = typeof flags.out === "string" ? flags.out : `${host}-${env}-${new Date().toISOString().slice(0, 10)}.sql`;
|
|
21
|
+
if (existsSync(out) && !flags.force) return fail(`${out} exists. Choose another --out, or add --force to replace it.`, 2);
|
|
22
|
+
|
|
23
|
+
const doFetch = opts.fetch || fetch;
|
|
24
|
+
let res;
|
|
25
|
+
try {
|
|
26
|
+
res = await doFetch(`${url.replace(/\/$/, "")}/api/cli/v1/db/dump?env=${env}`, { headers: { authorization: `Bearer ${token}` } });
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return fail(`could not reach ${url} (${e.message})`, 1);
|
|
29
|
+
}
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const body = await res.json().catch(() => ({}));
|
|
32
|
+
return fail(body?.error?.message || `the dump was refused (HTTP ${res.status})`, 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const file = createWriteStream(out);
|
|
36
|
+
let tail = "";
|
|
37
|
+
const reader = res.body.getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
for (;;) {
|
|
40
|
+
const { done, value } = await reader.read();
|
|
41
|
+
if (done) break;
|
|
42
|
+
file.write(value);
|
|
43
|
+
tail = (tail + decoder.decode(value, { stream: true })).slice(-400);
|
|
44
|
+
}
|
|
45
|
+
await new Promise((r) => file.end(r));
|
|
46
|
+
|
|
47
|
+
if (!/PostgreSQL database dump complete/.test(tail)) {
|
|
48
|
+
rmSync(out, { force: true });
|
|
49
|
+
return fail(`the dump was cut off before the end, so ${out} was removed. Run it again.`, 1);
|
|
50
|
+
}
|
|
51
|
+
const mb = (statSync(out).size / 1048576).toFixed(1);
|
|
52
|
+
console.log(`${out} ${dim(`${mb} MB, the ${env} data as SQL. Restore it with: psql "$DATABASE_URL" -f ${out}`)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fail(message, code) {
|
|
56
|
+
console.error(message);
|
|
57
|
+
process.exitCode = code;
|
|
58
|
+
}
|