@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.
- 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,112 @@
|
|
|
1
|
+
import { OaloChannel } from "../channel.js";
|
|
2
|
+
import { resolve as resolveConfig, controlPlane, readToken, HOW_TO_CONNECT } from "../config.js";
|
|
3
|
+
import { fetchManifest } from "../api.js";
|
|
4
|
+
import { serverInfo, cliTooOld, CLI_VERSION } from "../server.js";
|
|
5
|
+
import { dim } from "../events.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `oalo whoami [--json]`: which workspace, person and connection this machine
|
|
9
|
+
* uses, and whether each saved token works right now. The first thing to run
|
|
10
|
+
* after `oalo login`, and when a command is refused.
|
|
11
|
+
*/
|
|
12
|
+
export async function whoami(flags) {
|
|
13
|
+
const cfg = resolveConfig(flags);
|
|
14
|
+
const { url, token } = controlPlane(cfg);
|
|
15
|
+
|
|
16
|
+
if (!url || !token) {
|
|
17
|
+
console.error(`Not connected to a workspace.\n\n${HOW_TO_CONNECT}`);
|
|
18
|
+
process.exitCode = 2;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const info = await serverInfo(url);
|
|
23
|
+
const out = { workspace: url, cli_version: CLI_VERSION, server: info, me: null, data_token: null, problems: [] };
|
|
24
|
+
|
|
25
|
+
if (!info) out.problems.push(`could not reach ${url}`);
|
|
26
|
+
const old = cliTooOld(info);
|
|
27
|
+
if (old) out.problems.push(old);
|
|
28
|
+
|
|
29
|
+
// Control plane: who the CLI token acts as.
|
|
30
|
+
const ch = new OaloChannel({ url, token, timeoutMs: 15000 });
|
|
31
|
+
try {
|
|
32
|
+
await ch.connect();
|
|
33
|
+
out.me = await ch.call("cli_api:whoami", {});
|
|
34
|
+
} catch (e) {
|
|
35
|
+
out.problems.push(
|
|
36
|
+
e.code === "unknown_event"
|
|
37
|
+
? `${url} runs an older Oalo, so it can't say who you are. Your CLI token does work.`
|
|
38
|
+
: `CLI token: ${e.message}`,
|
|
39
|
+
);
|
|
40
|
+
} finally {
|
|
41
|
+
ch.close();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Data plane: is there a data token for this workspace, and does it work?
|
|
45
|
+
const dataToken = (cfg.serverUrl === url && cfg.token) || readToken({ serverUrl: url, appId: cfg.appId }, "sync");
|
|
46
|
+
if (dataToken) {
|
|
47
|
+
try {
|
|
48
|
+
const m = await fetchManifest({ serverUrl: url, token: dataToken, env: cfg.env });
|
|
49
|
+
out.data_token = { works: true, tables: Object.keys(m?.models || {}).length };
|
|
50
|
+
} catch (e) {
|
|
51
|
+
out.data_token = { works: false, error: e.message };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (flags.json) {
|
|
56
|
+
console.log(JSON.stringify(out, null, 2));
|
|
57
|
+
} else {
|
|
58
|
+
print(out);
|
|
59
|
+
}
|
|
60
|
+
if (out.problems.length || !out.me) process.exitCode = 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function print({ workspace, cli_version, server, me, data_token, problems }) {
|
|
64
|
+
const rows = [];
|
|
65
|
+
rows.push(["workspace", me?.org_name ? `${workspace} (${me.org_name})` : workspace]);
|
|
66
|
+
|
|
67
|
+
if (me) {
|
|
68
|
+
const role = [me.role, me.developer && me.role !== "admin" ? "developer" : null, me.restricted ? "restricted access" : null]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join(", ");
|
|
71
|
+
rows.push(["you", `${me.username || me.user_id}${me.email ? ` <${me.email}>` : ""}, ${role}`]);
|
|
72
|
+
|
|
73
|
+
const c = me.connection;
|
|
74
|
+
rows.push([
|
|
75
|
+
"connection",
|
|
76
|
+
c
|
|
77
|
+
? `"${c.name}", created ${day(c.created_at)}${c.last_used_at ? `, last used ${ago(c.last_used_at)}` : ""}${c.expires_at ? `, expires ${day(c.expires_at)}` : ""}`
|
|
78
|
+
: "not a saved connection",
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
rows.push([
|
|
83
|
+
"data token",
|
|
84
|
+
!data_token
|
|
85
|
+
? "none saved (typegen and tail need one)"
|
|
86
|
+
: data_token.works
|
|
87
|
+
? `works (${data_token.tables} tables)`
|
|
88
|
+
: `rejected: ${data_token.error}`,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
rows.push([
|
|
92
|
+
"server",
|
|
93
|
+
!server ? "unreachable" : server.features.length ? `api ${server.api}: ${server.features.join(", ")}` : `api ${server.api} (older than this CLI: no login, whoami or emit)`,
|
|
94
|
+
]);
|
|
95
|
+
rows.push(["cli", cli_version]);
|
|
96
|
+
|
|
97
|
+
const w = Math.max(...rows.map(([k]) => k.length));
|
|
98
|
+
for (const [k, v] of rows) console.log(`${k.padEnd(w)} ${v}`);
|
|
99
|
+
|
|
100
|
+
if (me && !me.developer) console.log(dim("\noalo emit needs admin or Developer access. An admin can turn on Developer for you under Team."));
|
|
101
|
+
for (const p of problems) console.error(`\n${p}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const day = (iso) => (iso ? iso.slice(0, 10) : "?");
|
|
105
|
+
|
|
106
|
+
function ago(iso) {
|
|
107
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
108
|
+
if (s < 90) return "just now";
|
|
109
|
+
if (s < 5400) return `${Math.round(s / 60)} min ago`;
|
|
110
|
+
if (s < 129600) return `${Math.round(s / 3600)} h ago`;
|
|
111
|
+
return `${Math.round(s / 86400)} days ago`;
|
|
112
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Two stores, deliberately split.
|
|
7
|
+
*
|
|
8
|
+
* .oalo/config.json in the PROJECT, meant to be committed.
|
|
9
|
+
* Which workspace, which app, which env.
|
|
10
|
+
* ~/.oalo/credentials.json at USER level, never in the project tree.
|
|
11
|
+
*
|
|
12
|
+
* A sync token is workspace-wide data access with no end-user identity: it can
|
|
13
|
+
* read and write every member's rows. Keeping it out of the repo has to be
|
|
14
|
+
* structural, not a habit — so it is never written under the project at all,
|
|
15
|
+
* and `oalo link` additionally adds `.oalo/credentials*` to .gitignore in case
|
|
16
|
+
* someone later puts one there by hand.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PROJECT_CONFIG = ".oalo/config.json";
|
|
20
|
+
// OALO_HOME relocates the credential store. Needed so tests never touch a real
|
|
21
|
+
// developer's credentials, and useful for CI and containers where $HOME is not
|
|
22
|
+
// where you want a long-lived token to land.
|
|
23
|
+
const CRED_PATH = join(process.env.OALO_HOME || homedir(), ".oalo", "credentials.json");
|
|
24
|
+
|
|
25
|
+
function readJson(path, fallback) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeJson(path, data, mode) {
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
35
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
36
|
+
if (mode) chmodSync(path, mode);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readConfig(cwd = process.cwd()) {
|
|
40
|
+
return readJson(join(cwd, PROJECT_CONFIG), null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeConfig(config, cwd = process.cwd()) {
|
|
44
|
+
writeJson(join(cwd, PROJECT_CONFIG), config);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Credentials are keyed by workspace + app, so one machine can hold several. */
|
|
48
|
+
function credKey({ serverUrl, appId }) {
|
|
49
|
+
return `${serverUrl}#${appId || "-"}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tokens are stored BY KIND, because the CLI uses two different kinds against
|
|
54
|
+
* the same workspace and they are not interchangeable:
|
|
55
|
+
*
|
|
56
|
+
* sync — the DATA plane (/api/sync/v1). What `oalo typegen` reads.
|
|
57
|
+
* cli — the CONTROL plane (the build surface). What `oalo mcp` joins with.
|
|
58
|
+
*
|
|
59
|
+
* Handing a sync token to the channel just fails to authenticate, so keeping
|
|
60
|
+
* them distinct in the store is what stops a confusing "token rejected" when
|
|
61
|
+
* the token is perfectly valid for something else.
|
|
62
|
+
*
|
|
63
|
+
* { "<url>#<app>": { sync: {token, saved_at}, cli: {token, saved_at} } }
|
|
64
|
+
*/
|
|
65
|
+
export const TOKEN_KINDS = ["sync", "cli"];
|
|
66
|
+
|
|
67
|
+
export function readToken(config, kind = "sync") {
|
|
68
|
+
const all = readJson(CRED_PATH, {});
|
|
69
|
+
// `oalo login` saves per workspace (no app), so an app-specific link falls
|
|
70
|
+
// back to the workspace's own entry.
|
|
71
|
+
const entry = all[credKey(config)] || (config.appId ? all[credKey({ ...config, appId: null })] : null);
|
|
72
|
+
if (!entry) return null;
|
|
73
|
+
// Tolerate the earlier flat shape ({token, saved_at}) as a sync token.
|
|
74
|
+
if (typeof entry.token === "string") return kind === "sync" ? entry.token : null;
|
|
75
|
+
return entry[kind]?.token ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeToken(config, token, kind = "sync", extra = {}) {
|
|
79
|
+
const all = readJson(CRED_PATH, {});
|
|
80
|
+
const key = credKey(config);
|
|
81
|
+
const existing = all[key] && typeof all[key].token !== "string" ? all[key] : {};
|
|
82
|
+
all[key] = { ...existing, [kind]: { token, saved_at: new Date().toISOString(), ...extra } };
|
|
83
|
+
// 0600: the file holds workspace-wide access.
|
|
84
|
+
writeJson(CRED_PATH, all, 0o600);
|
|
85
|
+
return CRED_PATH;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** All workspaces that have a token of `kind`, newest-written first. */
|
|
89
|
+
export function listWorkspaces(kind) {
|
|
90
|
+
const all = readJson(CRED_PATH, {});
|
|
91
|
+
return Object.entries(all)
|
|
92
|
+
.map(([key, entry]) => {
|
|
93
|
+
const flat = typeof entry?.token === "string";
|
|
94
|
+
const rec = flat ? (kind === "sync" ? { token: entry.token, saved_at: entry.saved_at } : null) : entry?.[kind];
|
|
95
|
+
if (!rec?.token) return null;
|
|
96
|
+
const [serverUrl, appId] = key.split("#");
|
|
97
|
+
return { serverUrl, appId: appId === "-" ? null : appId, token: rec.token, savedAt: rec.saved_at };
|
|
98
|
+
})
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.sort((a, b) => String(b.savedAt).localeCompare(String(a.savedAt)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const credentialsPath = () => CRED_PATH;
|
|
104
|
+
|
|
105
|
+
/** The saved entry for a workspace (no app), e.g. to find a login's connection id. */
|
|
106
|
+
export function readEntry(serverUrl) {
|
|
107
|
+
return readJson(CRED_PATH, {})[credKey({ serverUrl, appId: null })] || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Forget every saved credential for a workspace. Returns how many entries went. */
|
|
111
|
+
export function forgetWorkspace(serverUrl) {
|
|
112
|
+
const all = readJson(CRED_PATH, {});
|
|
113
|
+
const keys = Object.keys(all).filter((k) => k.split("#")[0] === serverUrl);
|
|
114
|
+
for (const k of keys) delete all[k];
|
|
115
|
+
if (keys.length) writeJson(CRED_PATH, all, 0o600);
|
|
116
|
+
return keys.length;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The control-plane URL and CLI token for a command. When this project (or
|
|
121
|
+
* --server / OALO_URL) names a workspace, only that workspace's token is used;
|
|
122
|
+
* a token saved for another workspace would just fail to authenticate. With
|
|
123
|
+
* no workspace named, the most recently saved one is used.
|
|
124
|
+
*/
|
|
125
|
+
export function controlPlane(cfg) {
|
|
126
|
+
if (cfg.cliToken) return { url: cfg.serverUrl, token: cfg.cliToken };
|
|
127
|
+
const saved = listWorkspaces("cli");
|
|
128
|
+
const match = cfg.serverUrl ? saved.find((w) => w.serverUrl === cfg.serverUrl) : saved[0];
|
|
129
|
+
return { url: cfg.serverUrl || match?.serverUrl || null, token: match?.token || null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** How to get a CLI token, for "needs a CLI token" messages. */
|
|
133
|
+
export const HOW_TO_CONNECT =
|
|
134
|
+
"Connect this machine with:\n oalo login <workspace> (your Base at <workspace>.oalo.ai)\n\n" +
|
|
135
|
+
"Or create a connection in the dashboard under Account -> CLI & agents and run the oalo link command it shows.";
|
|
136
|
+
|
|
137
|
+
/** Defensive: the token never lands here, but make sure it cannot if it did. */
|
|
138
|
+
export function ensureGitignored(cwd = process.cwd()) {
|
|
139
|
+
const gi = join(cwd, ".gitignore");
|
|
140
|
+
const rule = ".oalo/credentials*";
|
|
141
|
+
const current = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
142
|
+
if (current.includes(rule)) return false;
|
|
143
|
+
appendFileSync(gi, `${current.endsWith("\n") || !current ? "" : "\n"}\n# Oalo — never commit credentials\n${rule}\n`);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve config + token, preferring explicit flags, then config, then env. */
|
|
148
|
+
export function resolve(flags = {}, cwd = process.cwd()) {
|
|
149
|
+
const cfg = readConfig(cwd) || {};
|
|
150
|
+
const serverUrl = flags.server || cfg.serverUrl || process.env.OALO_URL || null;
|
|
151
|
+
const appId = flags.app || cfg.appId || null;
|
|
152
|
+
const env = flags.env || cfg.env || "dev";
|
|
153
|
+
const base = { serverUrl, appId, env };
|
|
154
|
+
const token = flags.token || process.env.OALO_SYNC_TOKEN || readToken(base, "sync");
|
|
155
|
+
const cliToken = flags["cli-token"] || process.env.OALO_CLI_TOKEN || readToken(base, "cli");
|
|
156
|
+
return { ...base, token, cliToken, schemaVersion: cfg.schemaVersion ?? null };
|
|
157
|
+
}
|
package/src/data.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data plane for `oalo data` and its MCP tools: models and rows in this
|
|
3
|
+
* workspace's `dev` and `prod` schemas.
|
|
4
|
+
*
|
|
5
|
+
* Two credentials, on purpose:
|
|
6
|
+
* - Reads (models, list, get, watch, routes) use the sync token. The one
|
|
7
|
+
* `oalo login` saves is read-only, which is all they need.
|
|
8
|
+
* - Row writes (push, delete) use a write token limited to the tables written,
|
|
9
|
+
* created as the signed-in person over the CLI token the first time a table
|
|
10
|
+
* is written, and saved beside the read token. Defining and promoting
|
|
11
|
+
* models goes over the CLI token directly (`data:create_model`,
|
|
12
|
+
* `data:promote_model`), so it follows the person's dashboard access.
|
|
13
|
+
*
|
|
14
|
+
* Every write names its schema. `prod` rows sync to live apps at once, so a
|
|
15
|
+
* write to `prod` must be asked for explicitly (see `targetEnv`).
|
|
16
|
+
*/
|
|
17
|
+
import { hostname } from "node:os";
|
|
18
|
+
import { syncRequest, ApiError } from "./api.js";
|
|
19
|
+
import { readEntry, writeToken } from "./config.js";
|
|
20
|
+
|
|
21
|
+
export const BULK_MAX = 1000;
|
|
22
|
+
const WRITE_TOKEN_DAYS = 30;
|
|
23
|
+
|
|
24
|
+
// ── input ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse rows from a file's text. `format` is jsonl, json or csv; when omitted
|
|
28
|
+
* it is guessed from the file name. JSON may be an array or `{rows: [...]}`.
|
|
29
|
+
* CSV values stay strings unless `types` is set, which turns plain numbers,
|
|
30
|
+
* true/false and empty cells into numbers, booleans and null. Off by default,
|
|
31
|
+
* since ids and phone numbers with leading zeros must stay text.
|
|
32
|
+
*/
|
|
33
|
+
export function parseRecords(text, { format, name = "", types = false } = {}) {
|
|
34
|
+
const fmt = format || guessFormat(name, text);
|
|
35
|
+
const clean = text.replace(/^/, "");
|
|
36
|
+
|
|
37
|
+
if (fmt === "jsonl") {
|
|
38
|
+
return clean
|
|
39
|
+
.split(/\r?\n/)
|
|
40
|
+
.map((l) => l.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((l, i) => {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(l);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new Error(`line ${i + 1} is not valid JSON: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (fmt === "json") {
|
|
52
|
+
const data = JSON.parse(clean);
|
|
53
|
+
const rows = Array.isArray(data) ? data : Array.isArray(data?.rows) ? data.rows : null;
|
|
54
|
+
if (!rows) throw new Error("JSON input must be an array of objects, or {\"rows\": [...]}");
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (fmt === "csv") return parseCsv(clean, { types });
|
|
59
|
+
throw new Error(`unknown format ${fmt} (use jsonl, json or csv)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function guessFormat(name, text) {
|
|
63
|
+
if (/\.jsonl$|\.ndjson$/i.test(name)) return "jsonl";
|
|
64
|
+
if (/\.json$/i.test(name)) return "json";
|
|
65
|
+
if (/\.csv$/i.test(name)) return "csv";
|
|
66
|
+
const t = text.trimStart();
|
|
67
|
+
if (t.startsWith("[") || t.startsWith("{\"rows\"")) return "json";
|
|
68
|
+
if (t.startsWith("{")) return "jsonl";
|
|
69
|
+
return "csv";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** RFC 4180 CSV: quoted fields, "" escapes, commas and newlines inside quotes. */
|
|
73
|
+
export function parseCsv(text, { types = false } = {}) {
|
|
74
|
+
const rows = [];
|
|
75
|
+
let row = [];
|
|
76
|
+
let field = "";
|
|
77
|
+
let quoted = false;
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < text.length; i++) {
|
|
80
|
+
const c = text[i];
|
|
81
|
+
if (quoted) {
|
|
82
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
83
|
+
field += '"';
|
|
84
|
+
i++;
|
|
85
|
+
} else if (c === '"') {
|
|
86
|
+
quoted = false;
|
|
87
|
+
} else {
|
|
88
|
+
field += c;
|
|
89
|
+
}
|
|
90
|
+
} else if (c === '"') {
|
|
91
|
+
quoted = true;
|
|
92
|
+
} else if (c === ",") {
|
|
93
|
+
row.push(field);
|
|
94
|
+
field = "";
|
|
95
|
+
} else if (c === "\n" || c === "\r") {
|
|
96
|
+
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
97
|
+
row.push(field);
|
|
98
|
+
rows.push(row);
|
|
99
|
+
row = [];
|
|
100
|
+
field = "";
|
|
101
|
+
} else {
|
|
102
|
+
field += c;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (field !== "" || row.length) {
|
|
106
|
+
row.push(field);
|
|
107
|
+
rows.push(row);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const [header, ...body] = rows.filter((r) => !(r.length === 1 && r[0] === ""));
|
|
111
|
+
if (!header) return [];
|
|
112
|
+
return body.map((r) => Object.fromEntries(header.map((h, i) => [h.trim(), cast(r[i] ?? "", types)])));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function cast(v, types) {
|
|
116
|
+
if (!types) return v;
|
|
117
|
+
if (v === "") return null;
|
|
118
|
+
if (v === "true" || v === "false") return v === "true";
|
|
119
|
+
if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(v)) return Number(v);
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Records to Sync API rows. The row id comes from `idField` (default `id`), so
|
|
125
|
+
* sending the same record again updates it instead of adding a copy. A record
|
|
126
|
+
* that already has a `document` is passed through as a full row. Records with
|
|
127
|
+
* no id are reported, unless `newIds` lets the server make them up (then a
|
|
128
|
+
* re-send adds copies, which is why it is opt-in).
|
|
129
|
+
*/
|
|
130
|
+
export function toRows(records, { idField = "id", newIds = false } = {}) {
|
|
131
|
+
const rows = [];
|
|
132
|
+
const missing = [];
|
|
133
|
+
|
|
134
|
+
records.forEach((rec, i) => {
|
|
135
|
+
if (rec === null || typeof rec !== "object" || Array.isArray(rec)) {
|
|
136
|
+
missing.push({ index: i, error: "not an object" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const raw = rec[idField];
|
|
140
|
+
const id = raw === undefined || raw === null || raw === "" ? null : String(raw);
|
|
141
|
+
if (!id && !newIds) {
|
|
142
|
+
missing.push({ index: i, error: `no ${idField}` });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (rec.document && typeof rec.document === "object") {
|
|
146
|
+
rows.push(id ? { ...rec, id } : rec);
|
|
147
|
+
} else {
|
|
148
|
+
const { [idField]: _drop, ...fields } = rec;
|
|
149
|
+
rows.push(id ? { id, ...(idField === "id" ? fields : { ...fields, [idField]: raw }) } : fields);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return { rows, missing };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function chunk(list, size = BULK_MAX) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── which schema ─────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The schema a write goes to: `--env` if given, else the project's linked env
|
|
166
|
+
* (default dev). Writing to `prod` without `--env prod` on the command line is
|
|
167
|
+
* refused, so a project linked to prod can't push there by accident.
|
|
168
|
+
*/
|
|
169
|
+
export function targetEnv(flags, cfg) {
|
|
170
|
+
const env = flags.env || cfg.env || "dev";
|
|
171
|
+
if (!["dev", "prod"].includes(env)) throw new Error(`--env must be dev or prod (got ${env})`);
|
|
172
|
+
if (env === "prod" && flags.env !== "prod") {
|
|
173
|
+
throw new Error("this project is linked to prod. Rows written there sync to live apps at once: pass --env prod to confirm.");
|
|
174
|
+
}
|
|
175
|
+
return env;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── reads ────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export async function listModels(cfg) {
|
|
181
|
+
const { body } = await syncRequest("GET", "/manifest", cfg);
|
|
182
|
+
return body;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function routes(cfg) {
|
|
186
|
+
const { body } = await syncRequest("GET", "/_routes", cfg);
|
|
187
|
+
return body;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function listRows(cfg, table, { since, limit, includeDeleted, order } = {}) {
|
|
191
|
+
const { body } = await syncRequest("GET", `/${encodeURIComponent(table)}`, cfg, {
|
|
192
|
+
query: { since, limit, include_deleted: includeDeleted ? "true" : undefined, order },
|
|
193
|
+
});
|
|
194
|
+
return body;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function getRow(cfg, table, id) {
|
|
198
|
+
const { body } = await syncRequest("GET", `/${encodeURIComponent(table)}/${encodeURIComponent(id)}`, cfg);
|
|
199
|
+
return body;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Follow a table's live stream (`GET /:table/stream`, server-sent events):
|
|
204
|
+
* rows after `since` first, then each write as it happens. Calls `onEvent(name,
|
|
205
|
+
* data)` until `signal` aborts.
|
|
206
|
+
*/
|
|
207
|
+
export async function watchRows(cfg, table, { since = 0, onEvent, signal, fetchImpl = fetch }) {
|
|
208
|
+
const url = new URL(`${cfg.serverUrl.replace(/\/$/, "")}/api/sync/v1/${encodeURIComponent(table)}/stream`);
|
|
209
|
+
url.searchParams.set("since", String(since));
|
|
210
|
+
if (cfg.env) url.searchParams.set("env", cfg.env);
|
|
211
|
+
|
|
212
|
+
const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${cfg.token}`, Accept: "text/event-stream" }, signal });
|
|
213
|
+
if (!res.ok) throw new ApiError(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`, res.status, null);
|
|
214
|
+
|
|
215
|
+
const decoder = new TextDecoder();
|
|
216
|
+
let buf = "";
|
|
217
|
+
for await (const part of res.body) {
|
|
218
|
+
buf += decoder.decode(part, { stream: true });
|
|
219
|
+
let cut;
|
|
220
|
+
while ((cut = buf.indexOf("\n\n")) >= 0) {
|
|
221
|
+
const block = buf.slice(0, cut);
|
|
222
|
+
buf = buf.slice(cut + 2);
|
|
223
|
+
let event = "message";
|
|
224
|
+
let data = "";
|
|
225
|
+
for (const line of block.split("\n")) {
|
|
226
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
227
|
+
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
228
|
+
}
|
|
229
|
+
if (!data) continue;
|
|
230
|
+
let parsed = data;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(data);
|
|
233
|
+
} catch {
|
|
234
|
+
/* keep text */
|
|
235
|
+
}
|
|
236
|
+
onEvent(event, parsed);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── writes ───────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Upsert rows in batches of up to 1,000. Bulk upserts by id are safe to repeat,
|
|
245
|
+
* so a batch that hits a network error or a 5xx is retried, and a 429 waits
|
|
246
|
+
* until the rate-limit window resets. Rows the server rejects are collected,
|
|
247
|
+
* not fatal. Returns `{written, failed: [{id, error}], batches}`.
|
|
248
|
+
*
|
|
249
|
+
* `onBatch({done, total})` reports progress. `sleep` and `fetchImpl` are for
|
|
250
|
+
* tests.
|
|
251
|
+
*/
|
|
252
|
+
export async function pushRows(cfg, table, rows, { onBatch, sleep = defaultSleep, fetchImpl = fetch, maxRetries = 4 } = {}) {
|
|
253
|
+
const batches = chunk(rows);
|
|
254
|
+
let written = 0;
|
|
255
|
+
const failed = [];
|
|
256
|
+
|
|
257
|
+
for (let b = 0; b < batches.length; b++) {
|
|
258
|
+
const operations = batches[b].map((row) => ({ op: "upsert", row }));
|
|
259
|
+
const res = await withRetry(
|
|
260
|
+
() => syncRequest("POST", `/${encodeURIComponent(table)}/bulk`, cfg, { body: { operations }, fetchImpl }),
|
|
261
|
+
{ sleep, maxRetries },
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
for (const r of res.body?.results || []) {
|
|
265
|
+
if (r.ok) written++;
|
|
266
|
+
else failed.push({ id: r.id, error: r.error || "rejected" });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
onBatch?.({ done: Math.min((b + 1) * BULK_MAX, rows.length), total: rows.length });
|
|
270
|
+
|
|
271
|
+
// Out of budget for this window: wait for it instead of earning a 429.
|
|
272
|
+
if (res.headers?.get?.("x-ratelimit-remaining") === "0" && b < batches.length - 1) {
|
|
273
|
+
await sleep(resetDelayMs(res.headers));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { written, failed, batches: batches.length };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Soft-delete rows by id through `bulk`, with the same batching and retries. */
|
|
281
|
+
export async function deleteRows(cfg, table, ids, { sleep = defaultSleep, fetchImpl = fetch, maxRetries = 4 } = {}) {
|
|
282
|
+
let deleted = 0;
|
|
283
|
+
const failed = [];
|
|
284
|
+
for (const batch of chunk(ids)) {
|
|
285
|
+
const operations = batch.map((id) => ({ op: "delete", id: String(id) }));
|
|
286
|
+
const res = await withRetry(
|
|
287
|
+
() => syncRequest("POST", `/${encodeURIComponent(table)}/bulk`, cfg, { body: { operations }, fetchImpl }),
|
|
288
|
+
{ sleep, maxRetries },
|
|
289
|
+
);
|
|
290
|
+
for (const r of res.body?.results || []) {
|
|
291
|
+
if (r.ok) deleted++;
|
|
292
|
+
else failed.push({ id: r.id, error: r.error || "rejected" });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { deleted, failed };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function deleteRow(cfg, table, id) {
|
|
299
|
+
const { body } = await syncRequest("DELETE", `/${encodeURIComponent(table)}/${encodeURIComponent(id)}`, cfg);
|
|
300
|
+
return body;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function withRetry(fn, { sleep, maxRetries }) {
|
|
304
|
+
for (let attempt = 0; ; attempt++) {
|
|
305
|
+
try {
|
|
306
|
+
return await fn();
|
|
307
|
+
} catch (e) {
|
|
308
|
+
const retryable = e instanceof ApiError && (e.status === 0 || e.status === 429 || e.status >= 500);
|
|
309
|
+
if (!retryable || attempt >= maxRetries) throw e;
|
|
310
|
+
await sleep(e.status === 429 ? resetDelayMs(e.headers) : 1000 * 2 ** attempt);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// `x-ratelimit-reset` is the window's end in Unix seconds. Wait until then, at
|
|
316
|
+
// least a second and at most a minute (the window is 60 seconds).
|
|
317
|
+
export function resetDelayMs(headers, now = Date.now()) {
|
|
318
|
+
const reset = Number(headers?.get?.("x-ratelimit-reset"));
|
|
319
|
+
if (!Number.isFinite(reset) || reset <= 0) return 5000;
|
|
320
|
+
return Math.min(60_000, Math.max(1000, reset * 1000 - now));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
324
|
+
|
|
325
|
+
// ── the write token ──────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A sync token that can write `tables`, made as the signed-in person.
|
|
329
|
+
*
|
|
330
|
+
* Saved per workspace with the tables it covers. When a write needs a table it
|
|
331
|
+
* doesn't cover, a new token is made for the old tables plus the new one and
|
|
332
|
+
* the old token is revoked, so there is one write token per workspace, limited
|
|
333
|
+
* to the tables this machine has written. It expires after 30 days.
|
|
334
|
+
*
|
|
335
|
+
* `channel` is a connected OaloChannel on the CLI token. `log` reports what was
|
|
336
|
+
* created, since making a credential should never be silent.
|
|
337
|
+
*/
|
|
338
|
+
export async function writeTokenFor(serverUrl, tables, channel, { log = () => {}, now = Date.now() } = {}) {
|
|
339
|
+
const saved = readEntry(serverUrl)?.sync_write;
|
|
340
|
+
const covered = new Set(saved?.tables || []);
|
|
341
|
+
const live = saved?.token && (!saved.expires_at || Date.parse(saved.expires_at) > now + 60_000);
|
|
342
|
+
if (live && tables.every((t) => covered.has(t))) return saved.token;
|
|
343
|
+
|
|
344
|
+
const all = [...new Set([...(live ? covered : []), ...tables])].sort();
|
|
345
|
+
const expires_at = new Date(now + WRITE_TOKEN_DAYS * 86_400_000).toISOString();
|
|
346
|
+
const minted = await channel.call("sync_api:create_token", {
|
|
347
|
+
name: `${hostname()} CLI writes`,
|
|
348
|
+
scopes: all.map((t) => `write:${t}`),
|
|
349
|
+
expires_at,
|
|
350
|
+
});
|
|
351
|
+
if (!minted?.token) throw new Error("the workspace did not return a token");
|
|
352
|
+
|
|
353
|
+
writeToken({ serverUrl, appId: null }, minted.token, "sync_write", { id: minted.id, tables: all, expires_at });
|
|
354
|
+
log(`made a write token for ${all.join(", ")} (expires ${expires_at.slice(0, 10)}). See it with \`oalo tokens list\`.`);
|
|
355
|
+
|
|
356
|
+
if (saved?.id && saved.id !== minted.id) {
|
|
357
|
+
try {
|
|
358
|
+
await channel.call("sync_api:revoke_token", { id: saved.id });
|
|
359
|
+
} catch {
|
|
360
|
+
/* already gone; nothing to do */
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return minted.token;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── models (over the CLI token) ──────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* `--field name:type` flags to the Data API's field list. Types: string, text,
|
|
370
|
+
* integer, float, boolean, datetime, date, json, list, references:<table>.
|
|
371
|
+
* A bare name is a string.
|
|
372
|
+
*/
|
|
373
|
+
export function parseFields(specs) {
|
|
374
|
+
const list = (Array.isArray(specs) ? specs : [specs]).filter((s) => typeof s === "string" && s);
|
|
375
|
+
return list.flatMap((s) => s.split(",")).map((s) => {
|
|
376
|
+
const [name, ...rest] = s.trim().split(":");
|
|
377
|
+
return { name, type: rest.length ? rest.join(":") : "string" };
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function createModel(channel, { name, fields, ownedBy, shared }) {
|
|
382
|
+
const params = { name, fields };
|
|
383
|
+
if (ownedBy) {
|
|
384
|
+
params.owner_field = ownedBy;
|
|
385
|
+
params.shared = false;
|
|
386
|
+
} else if (shared !== undefined) {
|
|
387
|
+
params.shared = shared;
|
|
388
|
+
}
|
|
389
|
+
return channel.call("data:create_model", params);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export const promoteModel = (channel, table) => channel.call("data:promote_model", { table });
|