@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,168 @@
|
|
|
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 metrics`: a number from the workspace's data, the same query the
|
|
7
|
+
* dashboards draw (AppBuilder.Metrics).
|
|
8
|
+
*
|
|
9
|
+
* oalo metrics orders how many rows
|
|
10
|
+
* oalo metrics orders --sum total --by status --days 30
|
|
11
|
+
* oalo metrics orders --every week --days 90 --where "status = paid"
|
|
12
|
+
* oalo metrics events --event order.created --every day --days 14
|
|
13
|
+
* oalo metrics contacts --by source
|
|
14
|
+
*
|
|
15
|
+
* `oalo dashboards`: the workspace's dashboards, and a chart put on one.
|
|
16
|
+
*
|
|
17
|
+
* oalo dashboards
|
|
18
|
+
* oalo dashboards add Sales orders --title "Revenue by plan" --kind bars --sum total --by plan --days 30
|
|
19
|
+
* oalo dashboards add Sales events --title Funnel --kind funnel --steps lead.captured,order.created --days 30
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MEASURES = ["sum", "avg", "min", "max", "distinct"];
|
|
23
|
+
const KINDS = ["number", "line", "bars", "table", "funnel"];
|
|
24
|
+
|
|
25
|
+
/** "status = paid and total > 100" → filters. Throws on what it can't read. */
|
|
26
|
+
export function parseWhere(text) {
|
|
27
|
+
if (text == null || text === true) return [];
|
|
28
|
+
return String(text)
|
|
29
|
+
.split(/\s+and\s+/i)
|
|
30
|
+
.map((part) => {
|
|
31
|
+
const p = part.trim();
|
|
32
|
+
let m;
|
|
33
|
+
if ((m = p.match(/^([\w.]+)\s+(present|absent)$/i))) return { field: m[1], op: m[2].toLowerCase() === "present" ? "set" : "unset" };
|
|
34
|
+
if ((m = p.match(/^([\w.]+)\s+in\s+(.+)$/i))) return { field: m[1], op: "in", value: m[2].split(",").map((v) => v.trim()).filter(Boolean) };
|
|
35
|
+
if ((m = p.match(/^([\w.]+)\s*(!=|>=|<=|==|=|>|<|~)\s*(.+)$/))) {
|
|
36
|
+
const op = { "=": "eq", "==": "eq", "!=": "neq", ">": "gt", ">=": "gte", "<": "lt", "<=": "lte", "~": "contains" }[m[2]];
|
|
37
|
+
const raw = m[3].trim().replace(/^["']|["']$/g, "");
|
|
38
|
+
const value = ["gt", "gte", "lt", "lte"].includes(op) && raw !== "" && !Number.isNaN(Number(raw)) ? Number(raw) : raw;
|
|
39
|
+
return { field: m[1], op, value };
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`can't read the filter ${JSON.stringify(p)}: write field = value, field != value, field > 10, field ~ text, field in a,b, field present or field absent, joined with "and"`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The query from `what` (a model, events or contacts) and the flags. */
|
|
46
|
+
export function queryFromFlags(what, flags) {
|
|
47
|
+
if (!what) throw new Error("what to count? a model's name, events or contacts");
|
|
48
|
+
const q = what === "events" ? { source: "event" } : what === "contacts" ? { source: "contact" } : { source: "model", table: what };
|
|
49
|
+
if (typeof flags.event === "string") {
|
|
50
|
+
if (q.source !== "event") throw new Error("--event is for events");
|
|
51
|
+
q.event = flags.event;
|
|
52
|
+
}
|
|
53
|
+
const measures = MEASURES.filter((m) => flags[m] !== undefined);
|
|
54
|
+
if (measures.length > 1) throw new Error(`one measure at a time, not --${measures.join(" and --")}`);
|
|
55
|
+
if (measures.length) {
|
|
56
|
+
if (typeof flags[measures[0]] !== "string") throw new Error(`--${measures[0]} needs a field`);
|
|
57
|
+
q.measure = measures[0];
|
|
58
|
+
q.field = flags[measures[0]];
|
|
59
|
+
}
|
|
60
|
+
if (typeof flags.by === "string") q.by = flags.by;
|
|
61
|
+
if (flags.every !== undefined) {
|
|
62
|
+
if (!["day", "week", "month"].includes(flags.every)) throw new Error("--every is day, week or month");
|
|
63
|
+
q.every = flags.every;
|
|
64
|
+
}
|
|
65
|
+
if (flags.days !== undefined) {
|
|
66
|
+
const d = Number(flags.days);
|
|
67
|
+
if (!Number.isInteger(d) || d < 1) throw new Error("--days is a whole number of days");
|
|
68
|
+
q.days = d;
|
|
69
|
+
}
|
|
70
|
+
if (typeof flags.time === "string") q.time = flags.time;
|
|
71
|
+
if (flags.compare) q.compare = true;
|
|
72
|
+
if (flags.env === "dev" || flags.env === "prod") q.env = flags.env;
|
|
73
|
+
const where = parseWhere(flags.where);
|
|
74
|
+
if (where.length) q.where = where;
|
|
75
|
+
return q;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function metrics(flags, rest, opts = {}) {
|
|
79
|
+
let query;
|
|
80
|
+
try {
|
|
81
|
+
query = queryFromFlags(rest[1], flags);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return fail(`${e.message}\n\n${METRICS_USAGE}`, 2);
|
|
84
|
+
}
|
|
85
|
+
const r = await call("metrics:query", { query }, flags, opts);
|
|
86
|
+
if (!r) return;
|
|
87
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
88
|
+
printResult(r);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function dashboards(flags, rest, opts = {}) {
|
|
92
|
+
const sub = rest[1] || "list";
|
|
93
|
+
if (sub === "list") {
|
|
94
|
+
const r = await call("dashboards:list", {}, flags, opts);
|
|
95
|
+
if (!r) return;
|
|
96
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
97
|
+
for (const d of r.dashboards || []) console.log(`${d.name} ${dim(`${d.widget_count} widget${d.widget_count === 1 ? "" : "s"}${d.app_id ? ", on an app" : ""}`)}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (sub !== "add") return fail(`unknown dashboards command: ${sub}\n\n${DASHBOARDS_USAGE}`, 2);
|
|
101
|
+
|
|
102
|
+
const [name, what] = [rest[2], rest[3]];
|
|
103
|
+
if (!name) return fail(`which dashboard? its name (made when there's none)\n\n${DASHBOARDS_USAGE}`, 2);
|
|
104
|
+
if (typeof flags.title !== "string" || !flags.title.trim()) return fail('what is the chart called? --title "Orders per week"', 2);
|
|
105
|
+
if (!KINDS.includes(flags.kind)) return fail(`--kind is ${KINDS.join(", ")}`, 2);
|
|
106
|
+
|
|
107
|
+
let query;
|
|
108
|
+
try {
|
|
109
|
+
if (flags.kind === "funnel") {
|
|
110
|
+
if (typeof flags.steps !== "string") throw new Error("a funnel needs --steps a,b,c (event types, in order)");
|
|
111
|
+
query = { steps: flags.steps.split(",").map((s) => s.trim()).filter(Boolean), ...(flags.days ? { days: Number(flags.days) } : {}) };
|
|
112
|
+
} else query = queryFromFlags(what, flags);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return fail(`${e.message}\n\n${DASHBOARDS_USAGE}`, 2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const widget = { title: flags.title.trim(), kind: flags.kind, query, ...(flags.width ? { width: Number(flags.width) } : {}) };
|
|
118
|
+
const r = await call("dashboards:put_widget", { dashboard: name, ...(typeof flags.app === "string" ? { app: flags.app } : {}), widget }, flags, opts);
|
|
119
|
+
if (!r) return;
|
|
120
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
121
|
+
console.log(`${r.replaced ? "Replaced" : "Added"} "${r.widget.title}" on ${r.dashboard.name}${r.made ? " (a new dashboard)" : ""}.`);
|
|
122
|
+
printResult(r.result);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const METRICS_USAGE = [
|
|
126
|
+
"usage: oalo metrics <model | events | contacts> [--sum|--avg|--min|--max|--distinct <field>] [--by <field>]",
|
|
127
|
+
" [--every day|week|month] [--days N] [--where 'status = paid and total > 10'] [--event <type>] [--time created|changed|<date field>] [--compare] [--env dev] [--json]",
|
|
128
|
+
].join("\n");
|
|
129
|
+
|
|
130
|
+
const DASHBOARDS_USAGE = [
|
|
131
|
+
"usage: oalo dashboards",
|
|
132
|
+
' oalo dashboards add <dashboard> <model | events | contacts> --title "<title>" --kind number|line|bars|table [metrics flags] [--app <slug>] [--width 1|2|3]',
|
|
133
|
+
' oalo dashboards add <dashboard> --title "<title>" --kind funnel --steps a,b,c [--days N]',
|
|
134
|
+
].join("\n");
|
|
135
|
+
|
|
136
|
+
function printResult(r) {
|
|
137
|
+
if (r.steps) {
|
|
138
|
+
for (const s of r.steps) console.log(`${s.step} ${s.contacts}${s.rate != null ? dim(` ${Math.round(s.rate * 100)}%`) : ""}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const fmt = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(2));
|
|
142
|
+
const single = r.rows.length === 1 && r.rows[0].bucket == null && r.rows[0].group == null;
|
|
143
|
+
if (!single) for (const x of r.rows) console.log(`${[x.bucket, x.group].filter((v) => v != null).join(" ")} ${fmt(x.value)}`);
|
|
144
|
+
console.log(`${single ? "" : dim("total ")}${fmt(r.total)}${r.previous != null ? dim(` (the window before: ${fmt(r.previous)})`) : ""}`);
|
|
145
|
+
if (r.more_groups) console.log(dim("more groups than listed: the largest are shown"));
|
|
146
|
+
if (r.untracked > 0) console.log(dim(`${r.untracked} older row${r.untracked === 1 ? " has" : "s have"} no created time and aren't counted (they're from before Oalo stamped it)`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function call(event, params, flags, opts) {
|
|
150
|
+
const { url, token } = controlPlane(resolveConfig(flags));
|
|
151
|
+
if (!url || !token) return fail(`this needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
|
|
152
|
+
const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
|
|
153
|
+
try {
|
|
154
|
+
await ch.connect();
|
|
155
|
+
return await ch.call(event, params, 20000);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
fail(e.message, 1);
|
|
158
|
+
return null;
|
|
159
|
+
} finally {
|
|
160
|
+
ch.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function fail(message, code) {
|
|
165
|
+
console.error(message);
|
|
166
|
+
process.exitCode = code;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
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 products`: what the workspace sells (Oalo commerce, on the
|
|
7
|
+
* workspace's own Stripe account).
|
|
8
|
+
*
|
|
9
|
+
* oalo products list them
|
|
10
|
+
* oalo products create premium --name "Premium" --price 9.99 --monthly
|
|
11
|
+
* oalo products edit premium --name "Premium plan"
|
|
12
|
+
* oalo products price premium 12.99 a new price; the old one is archived
|
|
13
|
+
* oalo products archive premium --yes no longer sold; buyers keep access
|
|
14
|
+
* oalo products delete premium --yes an archived one nobody bought; frees the id
|
|
15
|
+
* oalo products orders [premium] [--limit 50]
|
|
16
|
+
*
|
|
17
|
+
* The id (`premium`) is what code uses: BuyButton, a row's product_id, a
|
|
18
|
+
* member's purchased_products. `oalo typegen` writes them as PRODUCTS and
|
|
19
|
+
* ProductId so the compiler checks them.
|
|
20
|
+
*
|
|
21
|
+
* Live keys and amounts over $500 need --yes: the Base's commerce guardrail
|
|
22
|
+
* asks for it, the same one the dashboard and the builder go through.
|
|
23
|
+
*/
|
|
24
|
+
export async function products(flags, rest, opts = {}) {
|
|
25
|
+
const sub = rest[1] || "list";
|
|
26
|
+
const id = rest[2];
|
|
27
|
+
if (!["list", "create", "edit", "price", "archive", "delete", "orders"].includes(sub)) return fail(`unknown products command: ${sub}\n\n${USAGE}`, 2);
|
|
28
|
+
if (["create", "edit", "price", "archive", "delete"].includes(sub) && !id) return fail(`which product? give its id\n\n${USAGE}`, 2);
|
|
29
|
+
|
|
30
|
+
let event;
|
|
31
|
+
let params;
|
|
32
|
+
if (sub === "list") [event, params] = ["commerce:products:list", {}];
|
|
33
|
+
else if (sub === "orders") [event, params] = ["commerce:orders:list", { limit: limit(flags) }];
|
|
34
|
+
else if (sub === "archive") {
|
|
35
|
+
if (!flags.yes) return fail(`archiving ${id} stops it being sold at once. Its orders stay, and the people who bought it keep their access. Run it again with --yes to go ahead.`, 2);
|
|
36
|
+
[event, params] = ["commerce:products:delete", { id }];
|
|
37
|
+
} else if (sub === "delete") {
|
|
38
|
+
if (!flags.yes) return fail(`deleting ${id} removes it for good and frees its id. Only an archived product nobody bought can be deleted. Run it again with --yes to go ahead.`, 2);
|
|
39
|
+
[event, params] = ["commerce:products:hard_delete", { id }];
|
|
40
|
+
} else if (sub === "price") {
|
|
41
|
+
const cents = toCents(rest[3]);
|
|
42
|
+
if (cents == null) return fail(`what price? oalo products price ${id} 12.99`, 2);
|
|
43
|
+
[event, params] = ["commerce:products:set_price", { id, price_cents: cents, confirmed: !!flags.yes }];
|
|
44
|
+
} else if (sub === "edit") {
|
|
45
|
+
const patch = {};
|
|
46
|
+
if (typeof flags.name === "string") patch.title = flags.name;
|
|
47
|
+
if (typeof flags.description === "string") patch.description = flags.description;
|
|
48
|
+
if (!Object.keys(patch).length) return fail("what changes? --name <name> and/or --description <text> (the price: oalo products price)", 2);
|
|
49
|
+
[event, params] = ["commerce:products:update", { id, ...patch }];
|
|
50
|
+
} else {
|
|
51
|
+
const made = createParams(id, flags);
|
|
52
|
+
if (typeof made === "string") return fail(made, 2);
|
|
53
|
+
[event, params] = ["commerce:products:create", made];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cfg = resolveConfig(flags);
|
|
57
|
+
const { url, token } = controlPlane(cfg);
|
|
58
|
+
if (!url || !token) return fail(`oalo products needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
|
|
59
|
+
|
|
60
|
+
const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
|
|
61
|
+
let r;
|
|
62
|
+
try {
|
|
63
|
+
await ch.connect();
|
|
64
|
+
r = await ch.call(event, params, ["create", "price"].includes(sub) ? 60000 : 20000);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
const msg = e.message || String(e);
|
|
67
|
+
if (e.code === "confirm" || /^Needs your confirmation/.test(msg)) return fail(`${msg}\nRun it again with --yes to go ahead.`, 2);
|
|
68
|
+
if (e.code === "not_found") return fail(`no product ${id} (oalo products lists them)`, 1);
|
|
69
|
+
if (e.code === "not_archived") return fail(`${id} is still on sale. Archive it first: oalo products archive ${id} --yes`, 1);
|
|
70
|
+
return fail(msg, 1);
|
|
71
|
+
} finally {
|
|
72
|
+
ch.close();
|
|
73
|
+
}
|
|
74
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
75
|
+
print(sub, id, r, flags);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const USAGE = [
|
|
79
|
+
"usage: oalo products",
|
|
80
|
+
' oalo products create <id> --name "<name>" --price <amount> --once|--monthly|--yearly [--trial <days>] [--currency <code>] [--description <text>] [--live] [--yes]',
|
|
81
|
+
' oalo products edit <id> [--name "<name>"] [--description <text>]',
|
|
82
|
+
" oalo products price <id> <amount> [--yes]",
|
|
83
|
+
" oalo products archive <id> --yes",
|
|
84
|
+
" oalo products delete <id> --yes",
|
|
85
|
+
" oalo products orders [<id>] [--limit N]",
|
|
86
|
+
].join("\n");
|
|
87
|
+
|
|
88
|
+
/** "9.99" → 999. Null for anything that isn't a positive amount with at most 2 decimals. */
|
|
89
|
+
export function toCents(s) {
|
|
90
|
+
if (typeof s !== "string" || !/^\d+(\.\d{1,2})?$/.test(s.trim())) return null;
|
|
91
|
+
const cents = Math.round(parseFloat(s) * 100);
|
|
92
|
+
return cents > 0 ? cents : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function limit(flags) {
|
|
96
|
+
const n = parseInt(flags.limit, 10);
|
|
97
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, 500) : 50;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The create params, or a message saying what's missing. */
|
|
101
|
+
export function createParams(id, flags) {
|
|
102
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(id)) return `the id is what code uses: lowercase letters, digits, _ and -, like premium or pro_yearly. Not ${JSON.stringify(id)}.`;
|
|
103
|
+
if (typeof flags.name !== "string" || !flags.name.trim()) return 'what is it called? --name "Premium"';
|
|
104
|
+
const cents = toCents(typeof flags.price === "string" ? flags.price : "");
|
|
105
|
+
if (cents == null) return "what does it cost? --price 9.99";
|
|
106
|
+
const every = ["once", "monthly", "yearly"].filter((k) => flags[k]);
|
|
107
|
+
if (every.length !== 1) return "one of --once (paid once), --monthly or --yearly (a subscription)";
|
|
108
|
+
const trial = flags.trial === undefined ? 0 : parseInt(flags.trial, 10);
|
|
109
|
+
if (!Number.isInteger(trial) || trial < 0) return "--trial is a number of days";
|
|
110
|
+
if (trial && every[0] === "once") return "--trial is for a subscription";
|
|
111
|
+
|
|
112
|
+
const p = {
|
|
113
|
+
id,
|
|
114
|
+
display_name: flags.name.trim(),
|
|
115
|
+
price_cents: cents,
|
|
116
|
+
type: every[0] === "once" ? "onetime" : "subscription",
|
|
117
|
+
platforms: ["stripe"],
|
|
118
|
+
live: !!flags.live,
|
|
119
|
+
confirmed: !!flags.yes,
|
|
120
|
+
};
|
|
121
|
+
if (typeof flags.description === "string") p.description = flags.description;
|
|
122
|
+
if (typeof flags.currency === "string") p.currency = flags.currency.toUpperCase();
|
|
123
|
+
if (p.type === "subscription") p.recurring = { interval: every[0] === "yearly" ? "year" : "month", interval_count: 1, trial_period_days: trial };
|
|
124
|
+
return p;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const money = (cents, currency) => (cents == null ? "" : `${(cents / 100).toFixed(2)} ${currency || "USD"}`);
|
|
128
|
+
|
|
129
|
+
function priceText(p) {
|
|
130
|
+
const each = p.unit_price_cents ? `${money(p.unit_price_cents, p.currency)} per ${p.unit_label || "unit"}` : money(p.price_cents, p.currency);
|
|
131
|
+
return p.type === "subscription" ? `${each}, a subscription` : `${each}, once`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function print(sub, id, r, flags) {
|
|
135
|
+
if (sub === "list") {
|
|
136
|
+
const list = (r.products || []).filter((p) => flags.all || p.active !== false);
|
|
137
|
+
if (!list.length) return void console.log(`No products yet. Make one: oalo products create premium --name "Premium" --price 9.99 --monthly`);
|
|
138
|
+
for (const p of list) {
|
|
139
|
+
const env = p.env === "live" ? "LIVE" : p.env === "test" ? "test" : "mode unknown";
|
|
140
|
+
console.log(`${p.id} ${p.title} ${dim(`${priceText(p)}, ${env}${p.active === false ? ", archived" : ""}${p.stripe_price_id ? "" : ", no Stripe price"}`)}`);
|
|
141
|
+
}
|
|
142
|
+
if (!flags.all && list.length < (r.products || []).length) console.log(dim(`(archived ones hidden: --all shows them)`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (sub === "orders") {
|
|
146
|
+
const list = (r.orders || []).filter((o) => !id || o.product_id === id);
|
|
147
|
+
if (!list.length) return void console.log(id ? `No orders for ${id}.` : "No orders yet.");
|
|
148
|
+
for (const o of list) {
|
|
149
|
+
console.log(`${String(o.created_at).slice(0, 16).replace("T", " ")} ${o.product_id} ${money(o.amount_cents, o.currency)} ${dim(`${o.status || ""}${o.email ? `, ${o.email}` : ""}${o.platform ? `, ${o.platform}` : ""}`)}`);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (sub === "archive") return void console.log(`${id} archived: it isn't sold any more. The people who bought it keep their access.`);
|
|
154
|
+
if (sub === "delete") return void console.log(`${id} deleted. The id is free for a new product.`);
|
|
155
|
+
const p = r.product;
|
|
156
|
+
if (sub === "create" && !p) return void console.log(r.message || `${id} created.`);
|
|
157
|
+
if (sub === "create") {
|
|
158
|
+
console.log(`${p.id} created: ${p.title}, ${priceText(p)}${p.env === "live" ? ", LIVE" : ", test mode"}.`);
|
|
159
|
+
console.log(dim(`Use the id in code: BuyButton productId="${p.id}", or a row's product_id. Run oalo typegen to get it as a ProductId.`));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (sub === "price") return void console.log(`${p.id} now costs ${priceText(p)}. New buyers pay it; existing subscriptions keep their price.`);
|
|
163
|
+
console.log(`${p.id} updated: ${p.title}.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fail(message, code) {
|
|
167
|
+
console.error(message);
|
|
168
|
+
process.exitCode = code;
|
|
169
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { openWorkspace } from "../workspace.js";
|
|
3
|
+
import { dim } from "../events.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `oalo qa`: a scripted test conversation with a bot, printed as a transcript.
|
|
7
|
+
*
|
|
8
|
+
* oalo qa <bot> "hola" "tela boss, color gris" --channel whatsapp
|
|
9
|
+
* oalo qa <bot> --script quote.json {"channel": "...", "messages": [...]}
|
|
10
|
+
*
|
|
11
|
+
* The server (chat:bots:qa_run) opens a preview conversation with no phone
|
|
12
|
+
* number or external id, so nothing is sent to WhatsApp, Instagram or SMS;
|
|
13
|
+
* `--channel` only changes how the bot behaves (text mode vs pickers). The
|
|
14
|
+
* bot's tools and flows run for real. Each turn is streamed as it finishes
|
|
15
|
+
* (`qa:turn`), and the reply carries the whole run: replies per turn, flow
|
|
16
|
+
* state, the path of flow steps, and the cost.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function printTurn(t) {
|
|
20
|
+
console.log(`\n> ${t.user}`);
|
|
21
|
+
for (const r of t.replies || []) {
|
|
22
|
+
const extra = [r.tool && `tool: ${r.tool}`, r.widget && `widget: ${r.widget.widget_name || r.widget.kind}`].filter(Boolean);
|
|
23
|
+
const text = (r.text || "").trim();
|
|
24
|
+
if (text) console.log(` ${r.role === "assistant" ? "bot" : r.role}: ${text}`);
|
|
25
|
+
if (extra.length) console.log(dim(` [${extra.join(", ")}]`));
|
|
26
|
+
}
|
|
27
|
+
if ((t.replies || []).length === 0) console.log(dim(" (no reply)"));
|
|
28
|
+
for (const f of t.flows || []) {
|
|
29
|
+
console.log(dim(` flow ${f.flow}: ${f.status} at ${f.at}${f.waiting_for ? `, waiting for ${f.waiting_for}` : ""}`));
|
|
30
|
+
}
|
|
31
|
+
const secs = (t.ms / 1000).toFixed(1);
|
|
32
|
+
console.log(dim(` ${t.settled === "ok" ? "" : `${t.settled}, `}${secs}s${t.error ? `, error: ${t.error}` : ""}`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function qa(flags, rest) {
|
|
36
|
+
const bot = rest[1];
|
|
37
|
+
let messages = rest.slice(2);
|
|
38
|
+
let channel = flags.channel || "web";
|
|
39
|
+
// A flow to start with the first message, even one that's off, so a new
|
|
40
|
+
// flow can be tested before anyone turns it on.
|
|
41
|
+
let flow = typeof flags.flow === "string" ? flags.flow : undefined;
|
|
42
|
+
|
|
43
|
+
if (flags.script) {
|
|
44
|
+
try {
|
|
45
|
+
const script = JSON.parse(readFileSync(flags.script, "utf8"));
|
|
46
|
+
messages = script.messages || [];
|
|
47
|
+
channel = flags.channel || script.channel || channel;
|
|
48
|
+
flow = flow || (typeof script.flow === "string" ? script.flow : undefined);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error(`could not read --script ${flags.script}: ${e.message}`);
|
|
51
|
+
process.exitCode = 2;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!bot || messages.length === 0) {
|
|
57
|
+
console.error('usage: oalo qa <bot> "<message>" ["<message>" ...] [--channel web|whatsapp|instagram|sms] [--flow <name>] [--json]\n oalo qa <bot> --script <file.json>');
|
|
58
|
+
process.exitCode = 2;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const turnTimeout = Number(flags.timeout) > 0 ? Number(flags.timeout) : 90;
|
|
63
|
+
const ws = await openWorkspace(flags, "qa").catch((e) => (console.error(`qa failed: ${e.message}`), null));
|
|
64
|
+
if (!ws) return void (process.exitCode = process.exitCode || 2);
|
|
65
|
+
const { ch } = ws;
|
|
66
|
+
|
|
67
|
+
if (!flags.json) {
|
|
68
|
+
console.error(dim(`test conversation with ${bot} as ${channel}: preview only, nothing is sent to customers; tools and flows run for real`));
|
|
69
|
+
ch.onEvent((event, payload) => {
|
|
70
|
+
if (event === "qa:turn") printTurn(payload);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// One call for the whole script; the server replies when the last turn
|
|
76
|
+
// settles, so the window is generous: every turn's own timeout plus slack.
|
|
77
|
+
const windowMs = (messages.length * (turnTimeout + 15) + 30) * 1000;
|
|
78
|
+
const result = await ch.call(
|
|
79
|
+
"chat:bots:qa_run",
|
|
80
|
+
{ bot_id: bot, messages, channel, turn_timeout_s: turnTimeout, label: flags.label || "cli", ...(flow ? { flow } : {}) },
|
|
81
|
+
windowMs,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (flags.json) {
|
|
85
|
+
console.log(JSON.stringify(result, null, 2));
|
|
86
|
+
} else {
|
|
87
|
+
const path = (result.flow_path || []).join(" > ");
|
|
88
|
+
console.log(`\n${path ? `flow path: ${path}\n` : ""}cost: $${result.cost_usd} (conversation ${result.conversation_id}, closed)`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if ((result.turns || []).some((t) => t.settled !== "ok")) process.exitCode = 1;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error(`qa failed: ${e.message}`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
} finally {
|
|
96
|
+
ch.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { openWorkspace } from "../workspace.js";
|
|
3
|
+
import { dim, bold } from "../events.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `oalo rehearse`: rehearse inbound WhatsApp conversations end to end.
|
|
7
|
+
*
|
|
8
|
+
* oalo rehearse the standard scenarios for this workspace
|
|
9
|
+
* oalo rehearse --script cases.json {"scenarios": [...]} (see AppBuilder.Chat.Rehearsal)
|
|
10
|
+
*
|
|
11
|
+
* The server (chat:bots:rehearse) has a fictional customer write to one of the
|
|
12
|
+
* org's numbers and runs the real inbound path: routing, triage loops, moves,
|
|
13
|
+
* the bot's reply. Sends to the fictional number are recorded, never posted,
|
|
14
|
+
* the threads stay out of the Inbox and nobody on the team is notified. Each
|
|
15
|
+
* scenario is printed as it finishes (`rehearse:scenario`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function printScenario(s) {
|
|
19
|
+
const mark = s.pass ? "PASS" : "FAIL";
|
|
20
|
+
console.log(`\n${bold(mark)} ${s.name}`);
|
|
21
|
+
if (s.error) return void console.log(` error: ${s.error}`);
|
|
22
|
+
|
|
23
|
+
for (const t of s.turns || []) {
|
|
24
|
+
console.log(` > ${t.customer}`);
|
|
25
|
+
for (const m of t.sent || []) console.log(` bot: ${(m.text || "").trim()}`);
|
|
26
|
+
if ((t.sent || []).length === 0) console.log(dim(" (nothing sent)"));
|
|
27
|
+
if (t.settled !== "ok") console.log(dim(` ${t.settled} after ${(t.ms / 1000).toFixed(1)}s`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const th = s.thread;
|
|
31
|
+
if (th) {
|
|
32
|
+
const who = th.assigned_user_id ? `, assigned to ${th.assigned_user_id}` : "";
|
|
33
|
+
console.log(dim(` thread #${th.id} on ${th.bot} (bot ${th.bot_id}), autopilot ${th.autopilot ? "on" : "off"}${who}, tags: ${(th.tags || []).join(", ") || "none"}`));
|
|
34
|
+
}
|
|
35
|
+
for (const other of (s.threads || []).filter((t) => t.superseded_by)) {
|
|
36
|
+
console.log(dim(` thread #${other.id} on ${other.bot} closed: superseded by #${other.superseded_by}`));
|
|
37
|
+
}
|
|
38
|
+
for (const run of s.loop_runs || []) {
|
|
39
|
+
const steps = (run.steps || []).map((st) => {
|
|
40
|
+
const r = st.result || {};
|
|
41
|
+
const detail = typeof r === "string" ? r : [r.category, r.status, r.error, r.reason].filter(Boolean).join(" ");
|
|
42
|
+
return `${st.step}${detail ? ` (${detail})` : ""}`;
|
|
43
|
+
});
|
|
44
|
+
console.log(dim(` loop ${run.loop}: ${run.status}: ${steps.join(" > ")}`));
|
|
45
|
+
}
|
|
46
|
+
if (s.why_silent) console.log(` silent because: ${s.why_silent}`);
|
|
47
|
+
for (const c of s.checks || []) console.log(` ${c.pass ? "ok " : "FAIL"} ${c.check}`);
|
|
48
|
+
console.log(dim(` cost $${s.cost_usd}, customer ${s.customer_number}`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function rehearse(flags) {
|
|
52
|
+
const params = { suite: true };
|
|
53
|
+
|
|
54
|
+
if (flags.script) {
|
|
55
|
+
try {
|
|
56
|
+
const script = JSON.parse(readFileSync(flags.script, "utf8"));
|
|
57
|
+
params.suite = false;
|
|
58
|
+
params.scenarios = Array.isArray(script) ? script : script.scenarios || [];
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(`could not read --script ${flags.script}: ${e.message}`);
|
|
61
|
+
process.exitCode = 2;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (flags.number) params.number = String(flags.number);
|
|
66
|
+
// The bot's public id (bot_...), as `oalo bots` shows it. The Base resolves it
|
|
67
|
+
// and refuses one it can't find.
|
|
68
|
+
if (typeof flags.responder === "string") params.responder_bot_id = flags.responder;
|
|
69
|
+
|
|
70
|
+
const turnTimeout = Number(flags.timeout) > 0 ? Number(flags.timeout) : 120;
|
|
71
|
+
params.turn_timeout_s = turnTimeout;
|
|
72
|
+
|
|
73
|
+
const ws = await openWorkspace(flags, "rehearse").catch((e) => (console.error(`rehearse failed: ${e.message}`), null));
|
|
74
|
+
if (!ws) return void (process.exitCode = process.exitCode || 2);
|
|
75
|
+
const { ch } = ws;
|
|
76
|
+
|
|
77
|
+
if (!flags.json) {
|
|
78
|
+
console.error(dim("rehearsal: a fictional customer writes to your WhatsApp number; nothing is sent, the Inbox and the team see nothing; LLM calls are real"));
|
|
79
|
+
ch.onEvent((event, payload) => {
|
|
80
|
+
if (event === "rehearse:scenario") printScenario(payload);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// At most 10 messages per scenario; the standard suite has up to 8 scenarios.
|
|
86
|
+
const count = params.scenarios ? params.scenarios.length : 8;
|
|
87
|
+
const windowMs = count * 3 * (turnTimeout + 15) * 1000 + 60_000;
|
|
88
|
+
const result = await ch.call("chat:bots:rehearse", params, windowMs);
|
|
89
|
+
|
|
90
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
91
|
+
else console.log(`\n${result.passed} passed, ${result.failed} failed`);
|
|
92
|
+
|
|
93
|
+
if (result.failed > 0) process.exitCode = 1;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(`rehearse failed: ${e.message}`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
} finally {
|
|
98
|
+
ch.close();
|
|
99
|
+
}
|
|
100
|
+
}
|