@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,570 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve as resolvePath } from "node:path";
|
|
3
|
+
import { openWorkspace, printFindings } from "../workspace.js";
|
|
4
|
+
import {
|
|
5
|
+
flowFile,
|
|
6
|
+
loopFile,
|
|
7
|
+
toolFile,
|
|
8
|
+
toolPaths,
|
|
9
|
+
writeToolFiles,
|
|
10
|
+
flowPath,
|
|
11
|
+
loopPath,
|
|
12
|
+
catalogPath,
|
|
13
|
+
readGraphFile,
|
|
14
|
+
writeGraphFile,
|
|
15
|
+
removeFile,
|
|
16
|
+
listGraphFiles,
|
|
17
|
+
locallyModified,
|
|
18
|
+
standing,
|
|
19
|
+
rel,
|
|
20
|
+
cleanTrigger,
|
|
21
|
+
triggerKey,
|
|
22
|
+
} from "../graphfiles.js";
|
|
23
|
+
import { dim } from "../events.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `oalo pull` / `push` / `status` / `lint`: edit chat flows and loops as files.
|
|
27
|
+
*
|
|
28
|
+
* The round trip an agent (or a person) runs:
|
|
29
|
+
*
|
|
30
|
+
* oalo pull fetch every flow and loop into .oalo/, plus the node catalogs
|
|
31
|
+
* (edit .oalo/flows/<bot>/<flow>.json or .oalo/loops/<loop>.json)
|
|
32
|
+
* oalo lint check the drafts; nothing is saved
|
|
33
|
+
* oalo push lint, then save with the version you pulled and strict on
|
|
34
|
+
* oalo status what changed, locally and on the server
|
|
35
|
+
*
|
|
36
|
+
* Safety: push sends the pulled `version` as `base_version`, so a save based on
|
|
37
|
+
* an older copy is refused (`conflict`) instead of overwriting someone's edit;
|
|
38
|
+
* and it saves with `strict`, so a graph with error-level lint findings is
|
|
39
|
+
* refused. `--force` drops the version guard, `--no-strict` the lint gate.
|
|
40
|
+
* Pull never overwrites a file you have edited unless you pass --force.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// ---- server calls -------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const listBots = async (ch) => (await ch.call("chat:bots:list", {}))?.bots ?? [];
|
|
46
|
+
const listFlows = async (ch, botId) => (await ch.call("chat:bots:flows:list", { bot_id: botId }))?.flows ?? [];
|
|
47
|
+
const listLoops = async (ch) => (await ch.call("loops:list_for_org", {}))?.entries ?? [];
|
|
48
|
+
|
|
49
|
+
async function getFlow(ch, id) {
|
|
50
|
+
try {
|
|
51
|
+
return (await ch.call("chat:bots:flows:get", { id }))?.flow ?? null;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (e.code === "not_found") return null;
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getLoop(ch, name, appId) {
|
|
59
|
+
try {
|
|
60
|
+
const params = appId ? { name, app_id: appId } : { name };
|
|
61
|
+
return (await ch.call("loops:get", params))?.entry ?? null;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e.code === "not_found" || /no loop named/.test(e.message)) return null;
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An org loop's triggers. `undefined` for an app-pinned loop (triggers are
|
|
69
|
+
// org-level) or a workspace too old to list them, so the file leaves them out.
|
|
70
|
+
async function listTriggers(ch, name, appId) {
|
|
71
|
+
if (appId) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
return (await ch.call("loops:triggers:list", { name }))?.triggers ?? [];
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const listTools = async (ch) => (await ch.call("fn:list_org", {}))?.tools ?? [];
|
|
80
|
+
|
|
81
|
+
// fn:source, with the source hash as the version (tools have no version number).
|
|
82
|
+
async function getTool(ch, id) {
|
|
83
|
+
try {
|
|
84
|
+
const t = await ch.call("fn:source", { tool_id: id });
|
|
85
|
+
return t ? { ...t, version: t.source_hash } : null;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (/unknown function/.test(e.message)) return null;
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const remoteOf = (ch, file) =>
|
|
93
|
+
file.kind === "flow"
|
|
94
|
+
? file.id
|
|
95
|
+
? getFlow(ch, file.id)
|
|
96
|
+
: null
|
|
97
|
+
: file.kind === "loop"
|
|
98
|
+
? getLoop(ch, file.name, file.app_id)
|
|
99
|
+
: file.id
|
|
100
|
+
? getTool(ch, file.id)
|
|
101
|
+
: null;
|
|
102
|
+
|
|
103
|
+
async function lintFile(ch, file) {
|
|
104
|
+
// A tool's code is checked by compiling it, which happens on push.
|
|
105
|
+
if (file.kind === "tool") return [];
|
|
106
|
+
if (file.kind === "flow") {
|
|
107
|
+
return (await ch.call("chat:flows:lint", { graph: file.flow.graph ?? {}, bot_id: file.bot }))?.findings ?? [];
|
|
108
|
+
}
|
|
109
|
+
const l = file.loop;
|
|
110
|
+
const params = { name: file.name, nodes: l.nodes ?? [], edges: l.edges ?? [] };
|
|
111
|
+
if (l.entry) params.entry = l.entry;
|
|
112
|
+
return (await ch.call("loops:lint", params))?.findings ?? [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function sameNamedRemote(ch, file) {
|
|
116
|
+
if (file.kind === "loop") return getLoop(ch, file.name, file.app_id);
|
|
117
|
+
if (file.kind === "tool") {
|
|
118
|
+
const hit = (await listTools(ch)).find((t) => t.name === file.name);
|
|
119
|
+
return hit ? getTool(ch, hit.id) : null;
|
|
120
|
+
}
|
|
121
|
+
if (file.id) return getFlow(ch, file.id);
|
|
122
|
+
return (await listFlows(ch, file.bot)).find((f) => f.name === file.flow.name) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- helpers --------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function botKey(bots, flow) {
|
|
128
|
+
const bot = bots.find((b) => b.id === flow.bot_id);
|
|
129
|
+
return bot?.public_bot_id || String(flow.bot_id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Resolve the files a command acts on: the ones named, or every file. */
|
|
133
|
+
function targetFiles(rest) {
|
|
134
|
+
const named = rest.slice(1);
|
|
135
|
+
if (named.length === 0) return listGraphFiles();
|
|
136
|
+
return named.map((p) => resolvePath(p));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function load(path) {
|
|
140
|
+
try {
|
|
141
|
+
return { file: readGraphFile(path) };
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return { error: e.message };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Write a freshly fetched remote copy, refusing to clobber local edits. */
|
|
148
|
+
function writeFetched(path, next, force, report) {
|
|
149
|
+
if (existsSync(path) && !force) {
|
|
150
|
+
const { file } = load(path);
|
|
151
|
+
if (file && locallyModified(file)) {
|
|
152
|
+
report.kept.push(rel(path));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
writeGraphFile(path, next);
|
|
157
|
+
report.written.push(rel(path));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- pull ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export async function pull(flags, rest) {
|
|
163
|
+
const what = rest[1] || "all";
|
|
164
|
+
if (!["all", "flows", "loops", "tools"].includes(what)) {
|
|
165
|
+
console.error("usage: oalo pull [flows|loops|tools] [--bot <id>] [--name <name>] [--force]");
|
|
166
|
+
process.exitCode = 2;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ws = await openWorkspace(flags, "pull").catch((e) => (console.error(`pull failed: ${e.message}`), null));
|
|
171
|
+
if (!ws) return void (process.exitCode = process.exitCode || 2);
|
|
172
|
+
const { ch } = ws;
|
|
173
|
+
const report = { written: [], kept: [] };
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// The node contracts, so a local agent can read them without a server call.
|
|
177
|
+
for (const surface of ["flow", "loop"]) {
|
|
178
|
+
const event = surface === "flow" ? "chat:flows:node_catalog" : "loops:node_catalog";
|
|
179
|
+
const catalog = (await ch.call(event, {}))?.catalog;
|
|
180
|
+
if (catalog) writeGraphFile(catalogPath(surface), catalog);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (what === "all" || what === "flows") {
|
|
184
|
+
const bots = await listBots(ch);
|
|
185
|
+
const chosen = flags.bot
|
|
186
|
+
? bots.filter((b) => String(b.id) === String(flags.bot) || b.public_bot_id === flags.bot)
|
|
187
|
+
: bots;
|
|
188
|
+
|
|
189
|
+
if (flags.bot && chosen.length === 0) {
|
|
190
|
+
console.error(`no bot ${flags.bot} in this workspace`);
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const bot of chosen) {
|
|
196
|
+
for (const f of await listFlows(ch, bot.id)) {
|
|
197
|
+
if (flags.name && f.name !== flags.name) continue;
|
|
198
|
+
const key = botKey(bots, f);
|
|
199
|
+
writeFetched(flowPath(key, f.name), flowFile(f, key), flags.force, report);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (what === "all" || what === "loops") {
|
|
205
|
+
for (const l of await listLoops(ch)) {
|
|
206
|
+
if (flags.name && l.name !== flags.name) continue;
|
|
207
|
+
writeFetched(loopPath(l.name, l.app_id), loopFile(l, await listTriggers(ch, l.name, l.app_id)), flags.force, report);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (what === "all" || what === "tools") {
|
|
212
|
+
for (const t of await listTools(ch)) {
|
|
213
|
+
// Only code tools can be edited and pushed back; others (webhooks,
|
|
214
|
+
// built-ins, Elixir actions) are configured in the dashboard.
|
|
215
|
+
if (!["wasm", null, undefined].includes(t.transport)) continue;
|
|
216
|
+
if (flags.name && t.name !== flags.name) continue;
|
|
217
|
+
const remote = await getTool(ch, t.id);
|
|
218
|
+
if (!remote) continue;
|
|
219
|
+
const paths = toolPaths(t.name);
|
|
220
|
+
if (existsSync(paths.meta) && !flags.force) {
|
|
221
|
+
const { file } = load(paths.meta);
|
|
222
|
+
if (file && locallyModified(file)) {
|
|
223
|
+
report.kept.push(rel(paths.meta));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
writeToolFiles(t.name, toolFile(remote));
|
|
228
|
+
report.written.push(rel(paths.meta));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const p of report.written) console.log(`pulled ${p}`);
|
|
233
|
+
for (const p of report.kept) console.log(`kept ${p} ${dim("(edited locally; push it, or pull --force to discard)")}`);
|
|
234
|
+
console.error(dim(`${report.written.length} written, ${report.kept.length} kept. Node catalogs in .oalo/catalog/.`));
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.error(`pull failed: ${e.message}`);
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
} finally {
|
|
239
|
+
ch.close();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---- status ----------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
export async function status(flags, rest) {
|
|
246
|
+
const paths = targetFiles(rest);
|
|
247
|
+
if (paths.length === 0) {
|
|
248
|
+
console.error("no flow or loop files under .oalo/. Run `oalo pull` first.");
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const ws = await openWorkspace(flags, "status").catch((e) => (console.error(`status failed: ${e.message}`), null));
|
|
254
|
+
if (!ws) return void (process.exitCode = process.exitCode || 2);
|
|
255
|
+
const { ch } = ws;
|
|
256
|
+
const rows = [];
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
for (const path of paths) {
|
|
260
|
+
const { file, error } = load(path);
|
|
261
|
+
if (error) {
|
|
262
|
+
rows.push({ path: rel(path), state: "invalid", note: error });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
let remote;
|
|
266
|
+
try {
|
|
267
|
+
remote = await remoteOf(ch, file);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
rows.push({ path: rel(path), state: "error", note: e.message });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const state = standing(file, remote);
|
|
273
|
+
const note =
|
|
274
|
+
state === "behind" || state === "conflict"
|
|
275
|
+
? `server at v${remote?.version}, file based on v${file.version}`
|
|
276
|
+
: state === "gone"
|
|
277
|
+
? "deleted on the server"
|
|
278
|
+
: "";
|
|
279
|
+
rows.push({ path: rel(path), state, note });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (flags.json) {
|
|
283
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
284
|
+
} else {
|
|
285
|
+
for (const r of rows) console.log(`${r.state.padEnd(9)} ${r.path}${r.note ? dim(` ${r.note}`) : ""}`);
|
|
286
|
+
}
|
|
287
|
+
if (rows.some((r) => ["conflict", "invalid", "error"].includes(r.state))) process.exitCode = 1;
|
|
288
|
+
} catch (e) {
|
|
289
|
+
console.error(`status failed: ${e.message}`);
|
|
290
|
+
process.exitCode = 1;
|
|
291
|
+
} finally {
|
|
292
|
+
ch.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- lint ------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export async function lint(flags, rest) {
|
|
299
|
+
const paths = targetFiles(rest);
|
|
300
|
+
if (paths.length === 0) {
|
|
301
|
+
console.error("no flow or loop files under .oalo/. Run `oalo pull` first, or name a file.");
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const ws = await openWorkspace(flags, "lint").catch((e) => (console.error(`lint failed: ${e.message}`), null));
|
|
307
|
+
if (!ws) return void (process.exitCode = process.exitCode || 2);
|
|
308
|
+
const { ch } = ws;
|
|
309
|
+
let errors = 0;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
for (const path of paths) {
|
|
313
|
+
const { file, error } = load(path);
|
|
314
|
+
if (error) {
|
|
315
|
+
console.log(`invalid ${rel(path)}: ${error}`);
|
|
316
|
+
errors++;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const findings = await lintFile(ch, file);
|
|
320
|
+
if (flags.json) {
|
|
321
|
+
console.log(JSON.stringify({ path: rel(path), findings }));
|
|
322
|
+
errors += findings.filter((f) => f.severity === "error").length;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
console.log(`${rel(path)}`);
|
|
326
|
+
const c = printFindings(findings, { verbose: flags.verbose });
|
|
327
|
+
if (c.errors + c.warnings === 0) console.log(" no errors or warnings");
|
|
328
|
+
errors += c.errors;
|
|
329
|
+
}
|
|
330
|
+
if (errors > 0) process.exitCode = 1;
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error(`lint failed: ${e.message}`);
|
|
333
|
+
process.exitCode = 1;
|
|
334
|
+
} finally {
|
|
335
|
+
ch.close();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- push ------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
function saveParams(file, { force, strict }) {
|
|
342
|
+
const base = force || file.version == null ? {} : { base_version: file.version };
|
|
343
|
+
|
|
344
|
+
if (file.kind === "tool") {
|
|
345
|
+
const fields = { source_js: file._source, ...file.tool };
|
|
346
|
+
if (file.id) {
|
|
347
|
+
const guard = force || file.version == null ? {} : { base_source_hash: file.version };
|
|
348
|
+
return { event: "fn:save_source", params: { tool_id: file.id, ...fields, ...guard } };
|
|
349
|
+
}
|
|
350
|
+
// A new tool: created on the bot named in the file.
|
|
351
|
+
return { event: "chat:bots:tools:save_wasm_source", params: { bot_id: file.bot, name: file.name, js_source: file._source, ...file.tool } };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (file.kind === "flow") {
|
|
355
|
+
return {
|
|
356
|
+
event: "chat:bots:flows:upsert",
|
|
357
|
+
params: { bot_id: file.bot, ...(file.id ? { id: file.id } : {}), ...file.flow, ...base, strict },
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const params = { name: file.name, ...file.loop, ...base, strict };
|
|
362
|
+
if (file.app_id) params.app_id = file.app_id;
|
|
363
|
+
return { event: file.version == null ? "loops:create" : "loops:update", params };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function pushOne(ch, path, file, flags) {
|
|
367
|
+
const strict = flags["no-strict"] !== true;
|
|
368
|
+
|
|
369
|
+
// Lint first, so the author sees every finding even when the save would
|
|
370
|
+
// only report the blocking ones.
|
|
371
|
+
const findings = await lintFile(ch, file);
|
|
372
|
+
const counts = printFindings(findings, { verbose: flags.verbose });
|
|
373
|
+
|
|
374
|
+
if (flags["dry-run"]) return { ok: counts.errors === 0, state: "dry-run" };
|
|
375
|
+
|
|
376
|
+
// A file that was never pushed has no version to guard with, and both saves
|
|
377
|
+
// match by name, so a same-named flow or loop on the server would be
|
|
378
|
+
// silently replaced. Refuse instead.
|
|
379
|
+
if (file.version == null && !flags.force) {
|
|
380
|
+
const existing = await sameNamedRemote(ch, file);
|
|
381
|
+
if (existing) {
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
state: "conflict",
|
|
385
|
+
note: `a ${file.kind} with this name already exists on the server (v${existing.version}). Pull it and edit that file, or push --force to replace it.`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (strict && counts.errors > 0) return { ok: false, state: "refused", note: `${counts.errors} error(s); fix them or push --no-strict` };
|
|
390
|
+
|
|
391
|
+
const { event, params } = saveParams(file, { force: flags.force === true, strict });
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
if (file.kind === "tool" && !file.id && !file.bot) {
|
|
395
|
+
return { ok: false, state: "refused", note: "a new tool file needs `bot` (the bot it is created on)" };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const reply = await ch.call(event, params);
|
|
399
|
+
|
|
400
|
+
if (file.kind === "tool") {
|
|
401
|
+
const id = file.id || reply?.tool?.id;
|
|
402
|
+
const remote = id ? await getTool(ch, id) : null;
|
|
403
|
+
if (remote) writeToolFiles(file.name, toolFile(remote, file.bot));
|
|
404
|
+
return { ok: true, state: "pushed", note: "compiled and saved" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (file.kind === "flow") {
|
|
408
|
+
const next = flowFile(reply.flow, file.bot);
|
|
409
|
+
const newPath = flowPath(file.bot, next.flow.name);
|
|
410
|
+
writeGraphFile(newPath, next);
|
|
411
|
+
if (resolvePath(newPath) !== resolvePath(path)) removeFile(path);
|
|
412
|
+
return { ok: true, state: "pushed", note: `v${next.version}` };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const triggerNotes = Array.isArray(file.triggers) && !file.app_id ? await syncTriggers(ch, file) : { notes: [], failed: [] };
|
|
416
|
+
|
|
417
|
+
// Loop saves reply with a version only; read the stored copy back so the
|
|
418
|
+
// file matches what the server normalised (legacy edge keys, stale entry).
|
|
419
|
+
const remote = await getLoop(ch, file.name, file.app_id);
|
|
420
|
+
if (remote) writeGraphFile(loopPath(file.name, file.app_id), loopFile(remote, await listTriggers(ch, file.name, file.app_id)));
|
|
421
|
+
if (file.app_id) console.log(dim(" app-pinned loop: saved without the version guard or strict check"));
|
|
422
|
+
for (const n of triggerNotes.notes) console.log(dim(` ${n}`));
|
|
423
|
+
if (triggerNotes.failed.length) {
|
|
424
|
+
return { ok: false, state: "partly pushed", note: `the loop saved (v${remote?.version ?? "?"}), but ${triggerNotes.failed.join("; ")}` };
|
|
425
|
+
}
|
|
426
|
+
return { ok: true, state: "pushed", note: remote ? `v${remote.version}` : "" };
|
|
427
|
+
} catch (e) {
|
|
428
|
+
if (e.code === "conflict") {
|
|
429
|
+
// Save the server's copy beside the file, so the merge is a file diff
|
|
430
|
+
// rather than a lost edit. It becomes the base for the next push.
|
|
431
|
+
const current = await remoteOf(ch, file).catch(() => null);
|
|
432
|
+
const theirsPath = path.replace(/\.json$/, ".server.json");
|
|
433
|
+
let theirs = null;
|
|
434
|
+
if (current && file.kind === "tool") {
|
|
435
|
+
// For a tool, the conflicting thing is the code: save theirs beside yours.
|
|
436
|
+
const t = toolFile(current, file.bot);
|
|
437
|
+
theirs = t.meta;
|
|
438
|
+
writeFileSync(path.replace(/\.json$/, ".server.js"), t.source);
|
|
439
|
+
} else if (current) {
|
|
440
|
+
theirs = file.kind === "flow" ? flowFile(current, file.bot) : loopFile(current, await listTriggers(ch, file.name, file.app_id));
|
|
441
|
+
writeGraphFile(theirsPath, theirs);
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
state: "conflict",
|
|
446
|
+
note:
|
|
447
|
+
`someone saved v${theirs?.version ?? e.reply?.current?.version ?? "?"} since your v${file.version}. ` +
|
|
448
|
+
(theirs
|
|
449
|
+
? file.kind === "tool"
|
|
450
|
+
? `Their code is in ${rel(path.replace(/\.json$/, ".server.js"))}: merge it into your .js file, then \`oalo push ${rel(path)} --force\` (a tool has no version to re-base on). `
|
|
451
|
+
: `Their version is in ${rel(theirsPath)}: copy your changes into it, move it over ${rel(path)}, and push again. `
|
|
452
|
+
: "") +
|
|
453
|
+
"Only push --force without merging if their change should be thrown away.",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (e.code === "invalid_graph") {
|
|
457
|
+
// The same linter ran just above, so its findings are already printed.
|
|
458
|
+
return { ok: false, state: "refused", note: "the server refused the graph: fix the errors above (--no-strict does not skip structural damage)" };
|
|
459
|
+
}
|
|
460
|
+
return { ok: false, state: "failed", note: e.message };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Make the loop's triggers match the file, three-way: add the file's new ones,
|
|
466
|
+
* remove the ones deleted from it (only those it was pulled with, in
|
|
467
|
+
* `trigger_base`), and replace one whose settings changed. A trigger added on
|
|
468
|
+
* the server since the pull is kept. There is no trigger update event, so a
|
|
469
|
+
* change is a remove and an add.
|
|
470
|
+
*/
|
|
471
|
+
export async function syncTriggers(ch, file) {
|
|
472
|
+
const notes = [];
|
|
473
|
+
const failed = [];
|
|
474
|
+
const server = (await listTriggers(ch, file.name, null)) ?? [];
|
|
475
|
+
const byId = new Map(server.map((t) => [t.id, t]));
|
|
476
|
+
const base = new Set(file.trigger_base || []);
|
|
477
|
+
const wanted = file.triggers.map(cleanTrigger);
|
|
478
|
+
const keptIds = new Set(wanted.map((t) => t.id).filter(Boolean));
|
|
479
|
+
|
|
480
|
+
const add = async (t) => {
|
|
481
|
+
const { id: _id, enabled: on, ...attrs } = t;
|
|
482
|
+
try {
|
|
483
|
+
const before = new Set(((await listTriggers(ch, file.name, null)) ?? []).map((x) => x.id));
|
|
484
|
+
const after = (await ch.call("loops:triggers:add", { name: file.name, ...attrs }))?.triggers ?? [];
|
|
485
|
+
notes.push(`trigger added: ${describeTrigger(t)}`);
|
|
486
|
+
// A trigger is added on; one written with `enabled: false` is then switched off.
|
|
487
|
+
const added = after.find((x) => !before.has(x.id));
|
|
488
|
+
if (on === false && added) await ch.call("loops:triggers:set_enabled", { name: file.name, trigger_id: added.id, enabled: false });
|
|
489
|
+
} catch (e) {
|
|
490
|
+
failed.push(`the trigger ${describeTrigger(t)} was refused: ${e.reply?.message || e.message}`);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const remove = async (t) => {
|
|
494
|
+
await ch.call("loops:triggers:remove", { name: file.name, trigger_id: t.id });
|
|
495
|
+
notes.push(`trigger removed: ${describeTrigger(t)}`);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
for (const t of server) {
|
|
499
|
+
if (base.has(t.id) && !keptIds.has(t.id)) await remove(t);
|
|
500
|
+
}
|
|
501
|
+
for (const t of wanted) {
|
|
502
|
+
const current = t.id ? byId.get(t.id) : null;
|
|
503
|
+
if (!current) {
|
|
504
|
+
if (!t.id || !base.has(t.id)) await add(t);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (triggerKey(current) !== triggerKey(t)) {
|
|
508
|
+
await remove(current);
|
|
509
|
+
await add(t);
|
|
510
|
+
} else if (typeof t.enabled === "boolean" && t.enabled !== current.enabled) {
|
|
511
|
+
try {
|
|
512
|
+
await ch.call("loops:triggers:set_enabled", { name: file.name, trigger_id: t.id, enabled: t.enabled });
|
|
513
|
+
notes.push(`trigger turned ${t.enabled ? "on" : "off"}: ${describeTrigger(t)}`);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
failed.push(`turning ${describeTrigger(t)} ${t.enabled ? "on" : "off"} failed: ${e.reply?.error || e.message}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const theirs = server.filter((t) => !base.has(t.id) && !keptIds.has(t.id));
|
|
520
|
+
if (theirs.length) notes.push(`kept ${theirs.length} trigger(s) added on the server since your pull`);
|
|
521
|
+
return { notes, failed };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const describeTrigger = (t) =>
|
|
525
|
+
[t.type, t.type === "api" && `(input: ${Object.keys(t.input || {}).join(", ") || "none"})`, t.event, t.cron, t.widget_id && `widget ${t.widget_id}`, t.filter && Object.keys(t.filter).length ? `filter ${JSON.stringify(t.filter)}` : null]
|
|
526
|
+
.filter(Boolean)
|
|
527
|
+
.join(" ");
|
|
528
|
+
|
|
529
|
+
export async function push(flags, rest) {
|
|
530
|
+
const explicit = rest.length > 1;
|
|
531
|
+
const paths = targetFiles(rest);
|
|
532
|
+
if (paths.length === 0) {
|
|
533
|
+
console.error("no flow or loop files under .oalo/. Run `oalo pull` first, or name a file.");
|
|
534
|
+
process.exitCode = 1;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const ws = await openWorkspace(flags, "push").catch((e) => (console.error(`push failed: ${e.message}`), null));
|
|
539
|
+
if (!ws) return void (process.exitCode = process.exitCode || 2);
|
|
540
|
+
const { ch } = ws;
|
|
541
|
+
let failed = 0;
|
|
542
|
+
let pushed = 0;
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
for (const path of paths) {
|
|
546
|
+
const { file, error } = load(path);
|
|
547
|
+
if (error) {
|
|
548
|
+
console.log(`invalid ${rel(path)}: ${error}`);
|
|
549
|
+
failed++;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
// Without named files, push only what changed (or is new).
|
|
553
|
+
if (!explicit && !locallyModified(file) && !flags.all) continue;
|
|
554
|
+
|
|
555
|
+
console.log(`${rel(path)}`);
|
|
556
|
+
const r = await pushOne(ch, path, file, flags);
|
|
557
|
+
console.log(` ${r.state}${r.note ? `: ${r.note}` : ""}`);
|
|
558
|
+
if (r.ok) pushed++;
|
|
559
|
+
else failed++;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (pushed + failed === 0) console.log("nothing to push: no file differs from what was pulled");
|
|
563
|
+
if (failed > 0) process.exitCode = 1;
|
|
564
|
+
} catch (e) {
|
|
565
|
+
console.error(`push failed: ${e.message}`);
|
|
566
|
+
process.exitCode = 1;
|
|
567
|
+
} finally {
|
|
568
|
+
ch.close();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { OaloChannel } from "../channel.js";
|
|
2
|
+
import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
|
|
3
|
+
import { dim } from "../events.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `oalo incidents [--app <slug>]`: a live app's outages and restart bursts,
|
|
7
|
+
* newest first, as the uptime check saw them (AppBuilder.Uptime): what it
|
|
8
|
+
* was, when it opened and closed, and the facts (restarts, a memory kill,
|
|
9
|
+
* the last log lines).
|
|
10
|
+
*/
|
|
11
|
+
export async function incidents(flags, rest, opts = {}) {
|
|
12
|
+
const cfg = resolveConfig(flags);
|
|
13
|
+
const { url, token } = controlPlane(cfg);
|
|
14
|
+
if (!url || !token) return fail(`oalo incidents needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
|
|
15
|
+
const app = typeof flags.app === "string" ? flags.app : cfg.appId;
|
|
16
|
+
if (!app) return fail("which app? Pass --app <slug> (`oalo apps` lists them), or save it with `oalo link --app <slug>`.", 2);
|
|
17
|
+
|
|
18
|
+
const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
|
|
19
|
+
let r;
|
|
20
|
+
try {
|
|
21
|
+
await ch.connect();
|
|
22
|
+
r = await ch.call("incidents:list", { app });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return fail(e.message, 1);
|
|
25
|
+
} finally {
|
|
26
|
+
ch.close();
|
|
27
|
+
}
|
|
28
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
29
|
+
|
|
30
|
+
const list = r.incidents || [];
|
|
31
|
+
if (!list.length) return void console.log(`${r.app}: no incidents. The live app is checked every minute.`);
|
|
32
|
+
const when = (iso) => String(iso).slice(0, 16).replace("T", " ");
|
|
33
|
+
for (const i of list) {
|
|
34
|
+
const state = i.resolved_at ? `${when(i.opened_at)} to ${when(i.resolved_at)}` : `open since ${when(i.opened_at)}`;
|
|
35
|
+
console.log(`${i.resolved_at ? " " : "!"} ${i.title} ${dim(state)}`);
|
|
36
|
+
if (!i.resolved_at || flags.all) console.log(dim(i.detail.split("\n").map((l) => ` ${l}`).join("\n")));
|
|
37
|
+
}
|
|
38
|
+
if (list.some((i) => !i.resolved_at)) process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(message, code) {
|
|
42
|
+
console.error(message);
|
|
43
|
+
process.exitCode = code;
|
|
44
|
+
}
|