@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
package/src/envfile.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a `.env` file into `[{name, value}]`, the way dotenv does:
|
|
3
|
+
*
|
|
4
|
+
* NAME=value # a comment after a space
|
|
5
|
+
* export NAME=value
|
|
6
|
+
* NAME="double quoted, \n and \" work, and it may span lines"
|
|
7
|
+
* NAME='single quoted, taken as is'
|
|
8
|
+
*
|
|
9
|
+
* Blank lines and lines starting with # are skipped. A later line wins over an
|
|
10
|
+
* earlier one with the same name. Throws on a line it can't read, naming it.
|
|
11
|
+
*/
|
|
12
|
+
export function parseDotenv(text) {
|
|
13
|
+
const out = new Map();
|
|
14
|
+
const lines = String(text).replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < lines.length; i++) {
|
|
17
|
+
const raw = lines[i];
|
|
18
|
+
const line = raw.trim();
|
|
19
|
+
if (!line || line.startsWith("#")) continue;
|
|
20
|
+
|
|
21
|
+
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
22
|
+
if (!m) throw new Error(`line ${i + 1} isn't NAME=value: ${raw.slice(0, 60)}`);
|
|
23
|
+
const [, name] = m;
|
|
24
|
+
let rest = m[2];
|
|
25
|
+
let value;
|
|
26
|
+
|
|
27
|
+
if (rest.startsWith('"')) {
|
|
28
|
+
// May continue over the next lines until the closing quote.
|
|
29
|
+
let body = rest.slice(1);
|
|
30
|
+
const start = i;
|
|
31
|
+
while (!closes(body)) {
|
|
32
|
+
if (++i >= lines.length) throw new Error(`line ${start + 1}: ${name} opens a " that never closes`);
|
|
33
|
+
body += "\n" + lines[i];
|
|
34
|
+
}
|
|
35
|
+
const end = closingIndex(body);
|
|
36
|
+
value = body
|
|
37
|
+
.slice(0, end)
|
|
38
|
+
.replace(/\\n/g, "\n")
|
|
39
|
+
.replace(/\\r/g, "\r")
|
|
40
|
+
.replace(/\\t/g, "\t")
|
|
41
|
+
.replace(/\\(["\\])/g, "$1");
|
|
42
|
+
} else if (rest.startsWith("'")) {
|
|
43
|
+
const end = rest.indexOf("'", 1);
|
|
44
|
+
if (end < 0) throw new Error(`line ${i + 1}: ${name} opens a ' that never closes`);
|
|
45
|
+
value = rest.slice(1, end);
|
|
46
|
+
} else {
|
|
47
|
+
value = rest.replace(/\s+#.*$/, "").trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
out.set(name, value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return [...out].map(([name, value]) => ({ name, value }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function closingIndex(s) {
|
|
57
|
+
for (let i = 0; i < s.length; i++) {
|
|
58
|
+
if (s[i] === "\\") i++;
|
|
59
|
+
else if (s[i] === '"') return i;
|
|
60
|
+
}
|
|
61
|
+
return -1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const closes = (s) => closingIndex(s) >= 0;
|
|
65
|
+
|
|
66
|
+
/** `NAME=value` from the command line. The value may contain `=`. */
|
|
67
|
+
export function parseAssignment(arg) {
|
|
68
|
+
const i = arg.indexOf("=");
|
|
69
|
+
if (i <= 0) return null;
|
|
70
|
+
return { name: arg.slice(0, i), value: arg.slice(i + 1) };
|
|
71
|
+
}
|
package/src/events.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared event rendering for `oalo tail` and `oalo run --watch`. Both stream the
|
|
3
|
+
* same channel pushes, so they format them the same way from one place.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Phoenix's own frames. Not events, and showing them buries the signal.
|
|
7
|
+
// Note replies arrive as `chan_reply_<ref>`, NOT `phx_reply`, so the prefix
|
|
8
|
+
// test matters — without it every RPC answer shows up as an event.
|
|
9
|
+
const INTERNAL = new Set(["phx_reply", "phx_close", "phx_error", "phx_join", "phx_leave"]);
|
|
10
|
+
const isInternal = (event) => INTERNAL.has(event) || event.startsWith("chan_reply_");
|
|
11
|
+
|
|
12
|
+
// High-frequency streaming chunks. A tail is for "what happened", not for
|
|
13
|
+
// re-rendering an LLM token stream, so these need --all to appear.
|
|
14
|
+
const NOISY = /(:chunk$|^response:|_chunk$)/;
|
|
15
|
+
|
|
16
|
+
const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
17
|
+
const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : s);
|
|
18
|
+
const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : s);
|
|
19
|
+
|
|
20
|
+
function clock(at) {
|
|
21
|
+
const d = at ? new Date(at) : new Date();
|
|
22
|
+
return Number.isNaN(d.getTime()) ? new Date().toTimeString().slice(0, 8) : d.toTimeString().slice(0, 8);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One readable line per event. Falls back to compact JSON for unknown shapes. */
|
|
26
|
+
function format(event, payload) {
|
|
27
|
+
const p = payload && typeof payload === "object" ? payload : {};
|
|
28
|
+
|
|
29
|
+
if (event === "activity:event") {
|
|
30
|
+
const who = p.actor?.username || p.actor?.user_id || "someone";
|
|
31
|
+
const what = [p.verb, p.object_type, p.object_name && `"${p.object_name}"`]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(" ");
|
|
34
|
+
return `${bold(who)} ${what || p.surface}${p.link ? dim(` ${p.link}`) : ""}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (event === "jobs:update") {
|
|
38
|
+
const queues = Object.entries(p.by_queue || {})
|
|
39
|
+
.map(([q, n]) => `${q}=${n}`)
|
|
40
|
+
.join(" ");
|
|
41
|
+
return `running=${p.running ?? 0}${queues ? ` ${dim(queues)}` : ""}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (event === "loop_run_status" || event === "loop_node_status") {
|
|
45
|
+
return [p.status, p.loop_name || p.loop_id, p.node_id && dim(p.node_id)].filter(Boolean).join(" ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (event === "team_chat:message") {
|
|
49
|
+
// The field is `content`; system announcements have a null user_id.
|
|
50
|
+
const who = p.username || (p.kind === "system" ? "system" : "?");
|
|
51
|
+
return `${bold(who)}: ${String(p.content ?? "").slice(0, 120)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (event === "build:updated" || event === "preview:building" || event === "preview:reload") {
|
|
55
|
+
return [p.status, p.slug, p.app_id].filter(Boolean).join(" ") || dim("(no detail)");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const json = JSON.stringify(p);
|
|
59
|
+
return json === "{}" ? dim("(no payload)") : json.length > 160 ? json.slice(0, 157) + "..." : json;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
export { isInternal, NOISY, format, clock, dim, bold };
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, dirname, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flows and loops as files in the project, for `oalo pull` / `push` / `status`.
|
|
7
|
+
*
|
|
8
|
+
* .oalo/flows/<bot public id>/<flow name>.json
|
|
9
|
+
* .oalo/loops/<loop name>.json (org loops)
|
|
10
|
+
* .oalo/loops/apps/<app id>/<loop name>.json (loops pinned to one app)
|
|
11
|
+
* .oalo/tools/<tool name>.js + .json a code tool: its JavaScript, and its
|
|
12
|
+
* description, schemas and bookkeeping
|
|
13
|
+
* .oalo/catalog/{flow,loop}.json the node contract, refreshed on pull
|
|
14
|
+
*
|
|
15
|
+
* Each file separates WHAT YOU EDIT (`flow` / `loop`) from bookkeeping the CLI
|
|
16
|
+
* owns (`id`, `version`, `base_hash`). `base_hash` is the hash of the editable
|
|
17
|
+
* content as it was pulled, so "you changed this" (hash differs) can be told
|
|
18
|
+
* apart from "the server moved on" (remote version differs) without keeping a
|
|
19
|
+
* second copy of every graph. `version` goes back to the server as
|
|
20
|
+
* `base_version`, so a concurrent edit is refused instead of overwritten.
|
|
21
|
+
*
|
|
22
|
+
* The editable keys mirror what the save events accept. Read-only extras the
|
|
23
|
+
* server returns (stats, timestamps) are dropped, and the editor's own keys
|
|
24
|
+
* inside the graph (x/y, position, labels) are kept so the visual editor still
|
|
25
|
+
* lays a pushed graph out.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const ROOT = ".oalo";
|
|
29
|
+
|
|
30
|
+
export const FLOW_FIELDS = [
|
|
31
|
+
"name",
|
|
32
|
+
"description",
|
|
33
|
+
"enabled",
|
|
34
|
+
"trigger",
|
|
35
|
+
"trigger_instructions",
|
|
36
|
+
"auto_start_on_open",
|
|
37
|
+
"max_cost_usd",
|
|
38
|
+
"graph",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const LOOP_FIELDS = [
|
|
42
|
+
"description",
|
|
43
|
+
"enabled",
|
|
44
|
+
"cron",
|
|
45
|
+
"max_iterations",
|
|
46
|
+
"max_cost_usd",
|
|
47
|
+
"session_mode",
|
|
48
|
+
"provider",
|
|
49
|
+
"model",
|
|
50
|
+
"trigger_type",
|
|
51
|
+
"trigger_event",
|
|
52
|
+
"trigger_filter",
|
|
53
|
+
"entry",
|
|
54
|
+
"nodes",
|
|
55
|
+
"edges",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export const TOOL_FIELDS = ["description", "input_schema", "output_schema"];
|
|
59
|
+
|
|
60
|
+
const ABOUT = {
|
|
61
|
+
tool: "The code is in the .js file next to this one (`source`); edit it, or `tool`, then `oalo push`. It is compiled when pushed, so code that doesn't build is refused. Leave id, version and base_hash alone.",
|
|
62
|
+
flow: "Edit `flow`, then `oalo lint` and `oalo push`. Leave id, version and base_hash alone. The node contract is .oalo/catalog/flow.json.",
|
|
63
|
+
loop: "Edit `loop`, then `oalo lint` and `oalo push`. The name is the loop's identity. Leave version, base_hash and trigger_base alone. The node contract is .oalo/catalog/loop.json. `triggers` is what starts the loop: add one as {type, event | cron | widget_id, filter?} (types and events: oalo_loop_triggers action=catalog), delete one to remove it, and push. Set a trigger's `enabled` to turn it on or off. `trigger_type`/`trigger_event` in `loop` are the old single trigger; prefer `triggers`.",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** JSON with object keys sorted at every level, so equal content hashes equal. */
|
|
67
|
+
export function canonical(value) {
|
|
68
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
69
|
+
if (value && typeof value === "object") {
|
|
70
|
+
return `{${Object.keys(value)
|
|
71
|
+
.sort()
|
|
72
|
+
.filter((k) => value[k] !== undefined)
|
|
73
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
|
74
|
+
.join(",")}}`;
|
|
75
|
+
}
|
|
76
|
+
return JSON.stringify(value ?? null);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const hashOf = (content) => createHash("sha256").update(canonical(content)).digest("hex").slice(0, 16);
|
|
80
|
+
|
|
81
|
+
/** A file-name-safe form of a flow or loop name (the real name stays inside). */
|
|
82
|
+
export function slug(name) {
|
|
83
|
+
const s = String(name || "")
|
|
84
|
+
.normalize("NFD")
|
|
85
|
+
.replace(/[̀-ͯ]/g, "")
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[^a-z0-9_-]+/g, "_")
|
|
88
|
+
.replace(/^_+|_+$/g, "");
|
|
89
|
+
return s || "unnamed";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pick = (obj, keys) => Object.fromEntries(keys.filter((k) => k in (obj || {})).map((k) => [k, obj[k]]));
|
|
93
|
+
|
|
94
|
+
export const flowPath = (bot, name, cwd = process.cwd()) => join(cwd, ROOT, "flows", bot, `${slug(name)}.json`);
|
|
95
|
+
// Org loops are unique by name; a loop pinned to an app is unique within that
|
|
96
|
+
// app, so it gets its own folder (an org loop and an app loop can share a name).
|
|
97
|
+
export const loopPath = (name, appId = null, cwd = process.cwd()) =>
|
|
98
|
+
appId ? join(cwd, ROOT, "loops", "apps", slug(appId), `${slug(name)}.json`) : join(cwd, ROOT, "loops", `${slug(name)}.json`);
|
|
99
|
+
export const catalogPath = (surface, cwd = process.cwd()) => join(cwd, ROOT, "catalog", `${surface}.json`);
|
|
100
|
+
|
|
101
|
+
/** A flow as the server returned it -> the file we keep. */
|
|
102
|
+
export function flowFile(remote, bot) {
|
|
103
|
+
const flow = pick(remote, FLOW_FIELDS);
|
|
104
|
+
return { $about: ABOUT.flow, kind: "flow", bot, id: remote.id, version: remote.version ?? null, base_hash: hashOf(flow), flow };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A loop entry as the server returned it -> the file we keep. With `triggers`
|
|
109
|
+
* (the loop's trigger list), the file carries them and the ids it saw
|
|
110
|
+
* (`trigger_base`), so a push removes only triggers deleted from the file, not
|
|
111
|
+
* ones a teammate added since.
|
|
112
|
+
*/
|
|
113
|
+
export function loopFile(remote, triggers) {
|
|
114
|
+
const loop = pick(remote, LOOP_FIELDS);
|
|
115
|
+
const file = {
|
|
116
|
+
$about: ABOUT.loop,
|
|
117
|
+
kind: "loop",
|
|
118
|
+
name: remote.name,
|
|
119
|
+
app_id: remote.app_id ?? null,
|
|
120
|
+
version: remote.version ?? null,
|
|
121
|
+
base_hash: null,
|
|
122
|
+
loop,
|
|
123
|
+
};
|
|
124
|
+
if (Array.isArray(triggers)) {
|
|
125
|
+
file.triggers = triggers.map(cleanTrigger);
|
|
126
|
+
file.trigger_base = file.triggers.map((t) => t.id).filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
file.base_hash = hashOf(content(file));
|
|
129
|
+
return file;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A trigger as listed, without empty keys. */
|
|
133
|
+
export function cleanTrigger(t) {
|
|
134
|
+
return Object.fromEntries(Object.entries(t || {}).filter(([, v]) => v !== null && v !== undefined));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The keys that make two triggers the same trigger (not its id or on/off). */
|
|
138
|
+
// An api trigger's contract (description, input, output) is part of it: a changed
|
|
139
|
+
// contract is pushed as a changed trigger.
|
|
140
|
+
export const triggerKey = (t) => canonical({ type: t.type, event: t.event ?? null, cron: t.cron ?? null, batch_size: t.batch_size ?? null, filter: t.filter ?? null, widget_id: t.widget_id ?? null, description: t.description ?? null, input: t.input ?? null, output: t.output ?? null });
|
|
141
|
+
|
|
142
|
+
export const toolPaths = (name, cwd = process.cwd()) => {
|
|
143
|
+
const base = join(cwd, ROOT, "tools", slug(name));
|
|
144
|
+
return { meta: `${base}.json`, source: `${base}.js` };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A code tool as fn:source returned it -> {meta file, source text}. Its
|
|
149
|
+
* `version` is the server's source hash: tools have no version number, so the
|
|
150
|
+
* code itself is the concurrency token.
|
|
151
|
+
*/
|
|
152
|
+
export function toolFile(remote, bot = null) {
|
|
153
|
+
const tool = pick(remote, TOOL_FIELDS);
|
|
154
|
+
const source = remote.source_js || "";
|
|
155
|
+
return {
|
|
156
|
+
meta: {
|
|
157
|
+
$about: ABOUT.tool,
|
|
158
|
+
kind: "tool",
|
|
159
|
+
id: remote.id,
|
|
160
|
+
name: remote.name,
|
|
161
|
+
...(bot ? { bot } : {}),
|
|
162
|
+
source: `${slug(remote.name)}.js`,
|
|
163
|
+
version: remote.source_hash ?? null,
|
|
164
|
+
// Trailing whitespace is ignored: the .js file always ends in a newline.
|
|
165
|
+
base_hash: hashOf({ ...tool, source: source.trimEnd() }),
|
|
166
|
+
tool,
|
|
167
|
+
},
|
|
168
|
+
source,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A loop file without `triggers` (pulled before they were) hashes as before.
|
|
173
|
+
export const content = (file) =>
|
|
174
|
+
file.kind === "flow"
|
|
175
|
+
? file.flow
|
|
176
|
+
: file.kind === "loop"
|
|
177
|
+
? Array.isArray(file.triggers)
|
|
178
|
+
? { ...file.loop, __triggers: file.triggers.map(cleanTrigger) }
|
|
179
|
+
: file.loop
|
|
180
|
+
: { ...file.tool, source: (file._source ?? "").trimEnd() };
|
|
181
|
+
|
|
182
|
+
/** Has the editable content changed since it was pulled (or is it new)? */
|
|
183
|
+
export function locallyModified(file) {
|
|
184
|
+
if (file.version == null || !file.base_hash) return true;
|
|
185
|
+
return hashOf(content(file)) !== file.base_hash;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Where a local file stands against the remote copy.
|
|
190
|
+
* new never pushed (no version)
|
|
191
|
+
* clean same as the server
|
|
192
|
+
* modified edited locally; the server has not moved: push it
|
|
193
|
+
* behind unchanged locally; the server has a newer version: pull it
|
|
194
|
+
* conflict edited locally AND the server moved: merge by hand
|
|
195
|
+
* gone the server no longer has it
|
|
196
|
+
*/
|
|
197
|
+
export function standing(file, remote) {
|
|
198
|
+
const edited = locallyModified(file);
|
|
199
|
+
if (file.version == null) return remote ? "conflict" : "new";
|
|
200
|
+
if (!remote) return "gone";
|
|
201
|
+
const moved = remote.version !== file.version;
|
|
202
|
+
if (edited && moved) return "conflict";
|
|
203
|
+
if (edited) return "modified";
|
|
204
|
+
if (moved) return "behind";
|
|
205
|
+
return "clean";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function readGraphFile(path) {
|
|
209
|
+
const file = JSON.parse(readFileSync(path, "utf8"));
|
|
210
|
+
if (!["flow", "loop", "tool"].includes(file.kind)) throw new Error(`${path}: not an oalo flow, loop or tool file (kind is ${JSON.stringify(file.kind)})`);
|
|
211
|
+
if (file.kind === "tool") {
|
|
212
|
+
if (!file.name || !file.tool) throw new Error(`${path}: a tool file needs \`name\` and \`tool\``);
|
|
213
|
+
const src = join(dirname(path), file.source || `${slug(file.name)}.js`);
|
|
214
|
+
if (!existsSync(src)) throw new Error(`${path}: its source file ${file.source} is missing`);
|
|
215
|
+
// Not written back: the .js file is the source of truth for the code.
|
|
216
|
+
Object.defineProperty(file, "_source", { value: readFileSync(src, "utf8"), enumerable: false });
|
|
217
|
+
}
|
|
218
|
+
if (file.kind === "flow" && (!file.bot || !file.flow)) throw new Error(`${path}: a flow file needs \`bot\` and \`flow\``);
|
|
219
|
+
if (file.kind === "loop" && (!file.name || !file.loop)) throw new Error(`${path}: a loop file needs \`name\` and \`loop\``);
|
|
220
|
+
return file;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function writeToolFiles(name, { meta, source }, cwd = process.cwd()) {
|
|
224
|
+
const p = toolPaths(name, cwd);
|
|
225
|
+
writeGraphFile(p.meta, meta);
|
|
226
|
+
writeFileSync(p.source, source.endsWith("\n") ? source : source + "\n");
|
|
227
|
+
return p.meta;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function writeGraphFile(path, file) {
|
|
231
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
232
|
+
writeFileSync(path, JSON.stringify(file, null, 2) + "\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function removeFile(path) {
|
|
236
|
+
if (existsSync(path)) unlinkSync(path);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// `<name>.server.json` is the server's copy saved by a conflicted push: a merge
|
|
240
|
+
// aid, not a file to push.
|
|
241
|
+
const isGraphFile = (f) => f.endsWith(".json") && !f.endsWith(".server.json");
|
|
242
|
+
|
|
243
|
+
/** Every flow and loop file under .oalo/, as absolute paths. */
|
|
244
|
+
export function listGraphFiles(cwd = process.cwd()) {
|
|
245
|
+
const out = [];
|
|
246
|
+
const flowsDir = join(cwd, ROOT, "flows");
|
|
247
|
+
const loopsDir = join(cwd, ROOT, "loops");
|
|
248
|
+
const toolsDir = join(cwd, ROOT, "tools");
|
|
249
|
+
|
|
250
|
+
if (existsSync(flowsDir)) {
|
|
251
|
+
for (const bot of readdirSync(flowsDir, { withFileTypes: true })) {
|
|
252
|
+
if (!bot.isDirectory()) continue;
|
|
253
|
+
for (const f of readdirSync(join(flowsDir, bot.name))) if (isGraphFile(f)) out.push(join(flowsDir, bot.name, f));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (existsSync(loopsDir)) {
|
|
257
|
+
for (const e of readdirSync(loopsDir, { withFileTypes: true })) {
|
|
258
|
+
if (e.isFile() && isGraphFile(e.name)) out.push(join(loopsDir, e.name));
|
|
259
|
+
if (e.isDirectory() && e.name === "apps") {
|
|
260
|
+
for (const app of readdirSync(join(loopsDir, "apps"), { withFileTypes: true })) {
|
|
261
|
+
if (!app.isDirectory()) continue;
|
|
262
|
+
for (const f of readdirSync(join(loopsDir, "apps", app.name))) if (isGraphFile(f)) out.push(join(loopsDir, "apps", app.name, f));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (existsSync(toolsDir)) {
|
|
268
|
+
for (const f of readdirSync(toolsDir)) if (isGraphFile(f)) out.push(join(toolsDir, f));
|
|
269
|
+
}
|
|
270
|
+
return out.sort();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const rel = (path, cwd = process.cwd()) => relative(cwd, path);
|