@wabery/cli 0.2.0 → 0.4.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/README.md +94 -12
- package/dist/api-client.d.ts +7 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +74 -22
- package/dist/api-client.js.map +1 -1
- package/dist/cli.js +494 -23
- package/dist/cli.js.map +1 -1
- package/dist/config-example.d.ts +4 -0
- package/dist/config-example.d.ts.map +1 -1
- package/dist/config-example.js +6 -0
- package/dist/config-example.js.map +1 -1
- package/dist/diagnostics.js +1 -1
- package/dist/local-config.d.ts +86 -0
- package/dist/local-config.d.ts.map +1 -1
- package/dist/local-config.js +259 -15
- package/dist/local-config.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +43 -11
- package/dist/mcp-server.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8,11 +8,15 @@ import { createInterface } from "node:readline";
|
|
|
8
8
|
import { WaberyApiClient, WaberyApiError } from "./api-client.js";
|
|
9
9
|
import { createExampleConfig } from "./config-example.js";
|
|
10
10
|
import { checkWaberyConnection } from "./diagnostics.js";
|
|
11
|
-
import { getConfigPath,
|
|
11
|
+
import { clearCredentials, findCredentialByProject, getConfigPath, readCredentials, removeCredential, repoConfigWritePath, resolveProjectId, upsertCredential, writeRepoProjectId, } from "./local-config.js";
|
|
12
12
|
import { runMcpServer } from "./mcp-server.js";
|
|
13
13
|
async function main() {
|
|
14
14
|
const parsed = parseArgs(process.argv.slice(2));
|
|
15
15
|
const [command, subcommand, ...rest] = parsed.positionals;
|
|
16
|
+
// Global output shaping for scripting: `--id-only` or `--field <dot.path>`
|
|
17
|
+
// print a single raw value instead of JSON (no jq needed).
|
|
18
|
+
outputIdOnly = booleanFlag(parsed.flags, "id-only") === true;
|
|
19
|
+
outputField = stringFlag(parsed.flags, "field");
|
|
16
20
|
if (!command ||
|
|
17
21
|
command === "help" ||
|
|
18
22
|
command === "--help" ||
|
|
@@ -29,6 +33,14 @@ async function main() {
|
|
|
29
33
|
await handleLogin(parsed.flags);
|
|
30
34
|
return;
|
|
31
35
|
}
|
|
36
|
+
if (command === "logout") {
|
|
37
|
+
await handleLogout(parsed.flags);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (command === "use") {
|
|
41
|
+
await handleUse(subcommand ?? rest[0]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
32
44
|
if (command === "mcp-config") {
|
|
33
45
|
await handleMcpConfig(subcommand, parsed.flags);
|
|
34
46
|
return;
|
|
@@ -42,25 +54,42 @@ async function main() {
|
|
|
42
54
|
await testDataExchange(parsed.flags);
|
|
43
55
|
return;
|
|
44
56
|
}
|
|
57
|
+
if (command === "flows" && subcommand === "simulate") {
|
|
58
|
+
await simulateFlow(parsed.flags);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
45
61
|
const client = new WaberyApiClient({
|
|
46
62
|
apiKey: stringFlag(parsed.flags, "api-key"),
|
|
47
63
|
baseUrl: stringFlag(parsed.flags, "base-url"),
|
|
48
64
|
apiVersion: stringFlag(parsed.flags, "api-version"),
|
|
65
|
+
projectId: stringFlag(parsed.flags, "project-id"),
|
|
49
66
|
});
|
|
50
67
|
if (command === "mcp") {
|
|
51
68
|
await runMcpServer(client);
|
|
52
69
|
return;
|
|
53
70
|
}
|
|
71
|
+
if (command === "whoami") {
|
|
72
|
+
await handleWhoami(client);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (command === "init") {
|
|
76
|
+
await handleInit(client, parsed.flags);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
54
79
|
if (command === "doctor" || command === "check") {
|
|
55
80
|
await printJson(await checkWaberyConnection(client));
|
|
56
81
|
return;
|
|
57
82
|
}
|
|
83
|
+
if (command === "env") {
|
|
84
|
+
await handleEnv(client, parsed.flags);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
58
87
|
if (command === "config") {
|
|
59
88
|
await handleConfig(client, subcommand, rest, parsed.flags);
|
|
60
89
|
return;
|
|
61
90
|
}
|
|
62
91
|
if (command === "projects") {
|
|
63
|
-
await handleProjects(client, subcommand, parsed.flags);
|
|
92
|
+
await handleProjects(client, subcommand, rest, parsed.flags);
|
|
64
93
|
return;
|
|
65
94
|
}
|
|
66
95
|
if (command === "channels" && subcommand === "list") {
|
|
@@ -131,12 +160,13 @@ async function handleConfig(client, subcommand, rest, flags) {
|
|
|
131
160
|
throw new Error("Usage: wabery config schema|export|example|init|validate|diff|apply [wabery.config.json]");
|
|
132
161
|
}
|
|
133
162
|
async function handleLocalConfig(subcommand, rest, flags) {
|
|
163
|
+
const resolvedProjectId = stringFlag(flags, "project-id") ?? resolveProjectId({}).projectId;
|
|
134
164
|
if (subcommand === "example") {
|
|
135
|
-
await printJson(createExampleConfig(
|
|
165
|
+
await printJson(createExampleConfig(resolvedProjectId));
|
|
136
166
|
return;
|
|
137
167
|
}
|
|
138
168
|
const outputPath = rest[0] ?? stringFlag(flags, "file") ?? "wabery.config.json";
|
|
139
|
-
const config = createExampleConfig(
|
|
169
|
+
const config = createExampleConfig(resolvedProjectId);
|
|
140
170
|
await writeFile(outputPath, `${JSON.stringify(config, null, 2)}\n`, {
|
|
141
171
|
flag: booleanFlag(flags, "force") ? "w" : "wx",
|
|
142
172
|
});
|
|
@@ -237,8 +267,98 @@ async function handleLogin(flags) {
|
|
|
237
267
|
if (!apiKey.startsWith("wab_live_")) {
|
|
238
268
|
throw new Error("That does not look like a Wabery CLI key (it should start with wab_live_). Run `wabery login` again.");
|
|
239
269
|
}
|
|
240
|
-
|
|
241
|
-
|
|
270
|
+
// Identify which project this key is scoped to via /v1/me, then store the
|
|
271
|
+
// credential keyed by project so multiple projects can coexist locally.
|
|
272
|
+
const client = new WaberyApiClient({ apiKey, baseUrl });
|
|
273
|
+
const identity = (await client.get("/me"));
|
|
274
|
+
const projectId = identity.project?.id;
|
|
275
|
+
if (!projectId) {
|
|
276
|
+
throw new Error("This key is not scoped to a project. Create a key from a project's settings in the Wabery dashboard.");
|
|
277
|
+
}
|
|
278
|
+
const configPath = upsertCredential({
|
|
279
|
+
projectId,
|
|
280
|
+
projectName: identity.project?.name,
|
|
281
|
+
organizationId: identity.organization?.id,
|
|
282
|
+
apiKey,
|
|
283
|
+
baseUrl,
|
|
284
|
+
});
|
|
285
|
+
let boundLine = "";
|
|
286
|
+
if (!resolveProjectId({}).projectId) {
|
|
287
|
+
// No repo binding yet — bind this directory to the project we logged into.
|
|
288
|
+
const repoPath = writeRepoProjectId(repoConfigWritePath(), projectId);
|
|
289
|
+
boundLine = `Bound this directory to project ${projectId} (${repoPath}).\n`;
|
|
290
|
+
}
|
|
291
|
+
await printText(`\nLogged in to project ${identity.project?.name ?? projectId} (${projectId}).\nCredentials saved to ${configPath}\n${boundLine}`);
|
|
292
|
+
}
|
|
293
|
+
/** Clear stored credentials: one project, or all of them with --all. */
|
|
294
|
+
async function handleLogout(flags) {
|
|
295
|
+
if (booleanFlag(flags, "all")) {
|
|
296
|
+
clearCredentials();
|
|
297
|
+
await printText("Logged out. Removed all stored Wabery credentials.\n");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const target = stringFlag(flags, "project") ?? stringFlag(flags, "project-id");
|
|
301
|
+
const credentials = readCredentials();
|
|
302
|
+
let projectId = target;
|
|
303
|
+
if (!projectId) {
|
|
304
|
+
// Default to the project resolved for the current directory.
|
|
305
|
+
projectId = resolveProjectId({}).projectId;
|
|
306
|
+
}
|
|
307
|
+
if (!projectId && credentials.length === 1) {
|
|
308
|
+
projectId = credentials[0].projectId;
|
|
309
|
+
}
|
|
310
|
+
if (!projectId) {
|
|
311
|
+
throw new Error("Specify which project to log out of: `wabery logout --project <id|name>`, or `wabery logout --all`.");
|
|
312
|
+
}
|
|
313
|
+
// Allow matching by name too.
|
|
314
|
+
const match = credentials.find((c) => c.projectId === projectId) ??
|
|
315
|
+
credentials.find((c) => c.projectName?.toLowerCase() === projectId?.toLowerCase());
|
|
316
|
+
if (!match) {
|
|
317
|
+
throw new Error(`No stored credential for "${projectId}".`);
|
|
318
|
+
}
|
|
319
|
+
removeCredential(match.projectId);
|
|
320
|
+
await printText(`Logged out of project ${match.projectName ?? match.projectId} (${match.projectId}).\n`);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Bind the current repo to one of the projects you've logged into, by writing
|
|
324
|
+
* a secret-free `.wabery.json`. Resolves a project id or (case-insensitive)
|
|
325
|
+
* name against the locally stored credentials.
|
|
326
|
+
*/
|
|
327
|
+
async function handleUse(query) {
|
|
328
|
+
if (!query) {
|
|
329
|
+
throw new Error("Usage: wabery use <project-id-or-name>");
|
|
330
|
+
}
|
|
331
|
+
const credentials = readCredentials();
|
|
332
|
+
if (credentials.length === 0) {
|
|
333
|
+
throw new Error("No projects are logged in yet. Run `wabery login` first.");
|
|
334
|
+
}
|
|
335
|
+
const match = credentials.find((c) => c.projectId === query) ??
|
|
336
|
+
credentials.find((c) => c.projectName?.toLowerCase() === query.toLowerCase());
|
|
337
|
+
if (!match) {
|
|
338
|
+
const available = credentials
|
|
339
|
+
.map((c) => ` - ${c.projectName ?? "(unnamed)"} (${c.projectId})`)
|
|
340
|
+
.join("\n");
|
|
341
|
+
throw new Error(`No logged-in project matches "${query}". Available:\n${available}\n\nRun \`wabery login\` to add another project.`);
|
|
342
|
+
}
|
|
343
|
+
const path = writeRepoProjectId(repoConfigWritePath(), match.projectId);
|
|
344
|
+
await printJson({
|
|
345
|
+
object: "repo_default",
|
|
346
|
+
path,
|
|
347
|
+
project_id: match.projectId,
|
|
348
|
+
project_name: match.projectName ?? null,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/** Show the active key (masked), org, and bound project + how it resolved. */
|
|
352
|
+
async function handleWhoami(client) {
|
|
353
|
+
const identity = (await client.get("/me"));
|
|
354
|
+
const resolved = resolveProjectId({});
|
|
355
|
+
await printJson({
|
|
356
|
+
...identity,
|
|
357
|
+
resolution: {
|
|
358
|
+
project_id: client.projectId ?? null,
|
|
359
|
+
source: resolved.source,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
242
362
|
}
|
|
243
363
|
/** Read a single line from the terminal, echoing what the user types. */
|
|
244
364
|
function promptLine(question) {
|
|
@@ -273,22 +393,163 @@ function openUrl(url) {
|
|
|
273
393
|
function normalizeAppBaseUrl(value) {
|
|
274
394
|
return value.replace(/\/api\/v1\/?$/, "").replace(/\/+$/, "");
|
|
275
395
|
}
|
|
276
|
-
async function handleProjects(client, subcommand, flags) {
|
|
396
|
+
async function handleProjects(client, subcommand, rest, flags) {
|
|
277
397
|
if (subcommand === "list") {
|
|
278
398
|
await printJson(await client.get("/projects"));
|
|
279
399
|
return;
|
|
280
400
|
}
|
|
281
401
|
if (subcommand === "create") {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
402
|
+
throw new Error("API keys are scoped to one project and cannot create projects. Create a project in the Wabery dashboard, then run `wabery login` for that project.");
|
|
403
|
+
}
|
|
404
|
+
if (subcommand === "ensure") {
|
|
405
|
+
// API keys are scoped to one project. Ensure configures that scoped
|
|
406
|
+
// project idempotently; it does not create sibling projects.
|
|
407
|
+
const name = requiredStringFlag(flags, "name");
|
|
408
|
+
await printJson(await ensureProject(client, name, flags));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const projectId = rest[0] ?? stringFlag(flags, "project-id");
|
|
412
|
+
if (subcommand === "get") {
|
|
413
|
+
await printJson(await client.get(`/projects/${requiredValue(projectId, "project-id")}`));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (subcommand === "update") {
|
|
417
|
+
const body = {};
|
|
418
|
+
const name = stringFlag(flags, "name");
|
|
419
|
+
if (name !== undefined)
|
|
420
|
+
body.name = name;
|
|
421
|
+
const description = stringFlag(flags, "description");
|
|
422
|
+
if (description !== undefined)
|
|
423
|
+
body.description = description;
|
|
424
|
+
const webhookUrl = stringFlag(flags, "webhook-url");
|
|
425
|
+
if (webhookUrl !== undefined)
|
|
426
|
+
body.webhook_url = webhookUrl;
|
|
427
|
+
const signing = booleanFlag(flags, "webhook-signing-enabled");
|
|
428
|
+
if (signing !== undefined)
|
|
429
|
+
body.webhook_signing_enabled = signing;
|
|
430
|
+
const secret = stringFlag(flags, "webhook-secret");
|
|
431
|
+
if (secret !== undefined)
|
|
432
|
+
body.webhook_secret = secret;
|
|
433
|
+
await printJson(await client.patch(`/projects/${requiredValue(projectId, "project-id")}`, body));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (subcommand === "rotate-secret") {
|
|
437
|
+
const secret = stringFlag(flags, "webhook-secret");
|
|
438
|
+
await printJson(await client.post(`/projects/${requiredValue(projectId, "project-id")}/rotate-webhook-secret`, secret ? { webhook_secret: secret } : undefined));
|
|
288
439
|
return;
|
|
289
440
|
}
|
|
290
|
-
throw new Error("Usage: wabery projects list|
|
|
441
|
+
throw new Error("Usage: wabery projects list|get|ensure|update|rotate-secret [<project_id>] [--name --description --webhook-url --webhook-signing-enabled --webhook-secret]");
|
|
442
|
+
}
|
|
443
|
+
/** Configure the project visible to this API key, then return it. Idempotent. */
|
|
444
|
+
async function ensureProject(client, name, flags) {
|
|
445
|
+
const list = (await client.get("/projects"));
|
|
446
|
+
const existing = list.data?.[0];
|
|
447
|
+
const id = existing?.project_id ?? existing?.id;
|
|
448
|
+
if (!existing || !id) {
|
|
449
|
+
throw new Error("No project is visible to this API key. Create a project in the Wabery dashboard, then run `wabery login` again.");
|
|
450
|
+
}
|
|
451
|
+
const webhookBody = {};
|
|
452
|
+
if (name !== undefined && existing.name !== name)
|
|
453
|
+
webhookBody.name = name;
|
|
454
|
+
const description = stringFlag(flags, "description");
|
|
455
|
+
if (description !== undefined)
|
|
456
|
+
webhookBody.description = description;
|
|
457
|
+
const webhookUrl = stringFlag(flags, "webhook-url");
|
|
458
|
+
if (webhookUrl !== undefined)
|
|
459
|
+
webhookBody.webhook_url = webhookUrl;
|
|
460
|
+
const signing = booleanFlag(flags, "webhook-signing-enabled");
|
|
461
|
+
if (signing !== undefined)
|
|
462
|
+
webhookBody.webhook_signing_enabled = signing;
|
|
463
|
+
const secret = stringFlag(flags, "webhook-secret");
|
|
464
|
+
if (secret !== undefined)
|
|
465
|
+
webhookBody.webhook_secret = secret;
|
|
466
|
+
if (Object.keys(webhookBody).length > 0) {
|
|
467
|
+
return client.patch(`/projects/${id}`, webhookBody);
|
|
468
|
+
}
|
|
469
|
+
return client.get(`/projects/${id}`);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Scaffold a runnable Wabery setup in the current directory:
|
|
473
|
+
* ensure a project, then write wabery.config.json, a .env block, and a webhook
|
|
474
|
+
* handler stub. Non-destructive — existing files are left untouched unless --force.
|
|
475
|
+
*/
|
|
476
|
+
async function handleInit(client, flags) {
|
|
477
|
+
const name = stringFlag(flags, "name");
|
|
478
|
+
const force = booleanFlag(flags, "force") === true;
|
|
479
|
+
const ensured = (await ensureProject(client, name, flags));
|
|
480
|
+
const projectId = ensured.project_id ?? ensured.id;
|
|
481
|
+
if (!projectId)
|
|
482
|
+
throw new Error("Could not resolve a project id.");
|
|
483
|
+
// Single GET reveals the webhook secret + url for the env block.
|
|
484
|
+
const project = (await client.get(`/projects/${projectId}`));
|
|
485
|
+
const channelId = project.channel_id ?? project.enrollment_channel?.id ?? undefined;
|
|
486
|
+
const cred = findCredentialByProject(projectId);
|
|
487
|
+
const baseUrl = stringFlag(flags, "base-url") ??
|
|
488
|
+
process.env.WABERY_BASE_URL ??
|
|
489
|
+
cred?.baseUrl ??
|
|
490
|
+
"https://api.wabery.com/v1";
|
|
491
|
+
const apiKey = stringFlag(flags, "api-key") ??
|
|
492
|
+
process.env.WABERY_API_KEY ??
|
|
493
|
+
cred?.apiKey ??
|
|
494
|
+
"wab_live_...";
|
|
495
|
+
const written = [];
|
|
496
|
+
const skipped = [];
|
|
497
|
+
const writeFlag = force ? "w" : "wx";
|
|
498
|
+
const tryWrite = async (path, content) => {
|
|
499
|
+
try {
|
|
500
|
+
await writeFile(path, content, { flag: writeFlag });
|
|
501
|
+
written.push(path);
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
if (error.code === "EEXIST") {
|
|
505
|
+
skipped.push(path);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
const envLines = [
|
|
512
|
+
`WABERY_API_KEY=${apiKey}`,
|
|
513
|
+
`WABERY_BASE_URL=${baseUrl}`,
|
|
514
|
+
`WABERY_PROJECT_ID=${projectId}`,
|
|
515
|
+
`WABERY_CHANNEL_ID=${channelId ?? ""}`,
|
|
516
|
+
];
|
|
517
|
+
if (project.webhook_url)
|
|
518
|
+
envLines.push(`WABERY_WEBHOOK_URL=${project.webhook_url}`);
|
|
519
|
+
envLines.push(`WABERY_WEBHOOK_SECRET=${project.webhook_secret ?? ""}`);
|
|
520
|
+
await tryWrite(".env.wabery", `${envLines.join("\n")}\n`);
|
|
521
|
+
await tryWrite("wabery.config.json", `${JSON.stringify(createExampleConfig(projectId), null, 2)}\n`);
|
|
522
|
+
await tryWrite("wabery-webhook-handler.ts", WEBHOOK_HANDLER_STUB);
|
|
523
|
+
await printText(`Initialized Wabery for project ${projectId}.\n` +
|
|
524
|
+
(written.length ? `Wrote: ${written.join(", ")}\n` : "") +
|
|
525
|
+
(skipped.length
|
|
526
|
+
? `Skipped (already exist, use --force to overwrite): ${skipped.join(", ")}\n`
|
|
527
|
+
: "") +
|
|
528
|
+
"\nNext: load .env.wabery, edit the webhook URL, then `wabery config apply wabery.config.json`.\n");
|
|
291
529
|
}
|
|
530
|
+
const WEBHOOK_HANDLER_STUB = `// Verify and handle Wabery webhook deliveries.
|
|
531
|
+
// npm i @wabery/sdk
|
|
532
|
+
import { Wabery } from "@wabery/sdk";
|
|
533
|
+
|
|
534
|
+
const wabery = new Wabery(); // reads WABERY_API_KEY
|
|
535
|
+
const secret = process.env.WABERY_WEBHOOK_SECRET ?? "";
|
|
536
|
+
|
|
537
|
+
// Express-style handler; adapt to your framework. Pass the RAW request body.
|
|
538
|
+
export async function handleWaberyWebhook(rawBody: string, signature: string) {
|
|
539
|
+
\tconst event = wabery.webhooks.constructEvent(rawBody, signature, secret);
|
|
540
|
+
\tswitch (event.event) {
|
|
541
|
+
\t\tcase "message.received":
|
|
542
|
+
\t\t\t// event.payload.text, .from, .conversation_id ...
|
|
543
|
+
\t\t\tbreak;
|
|
544
|
+
\t\tcase "flow.completed":
|
|
545
|
+
\t\t\t// event.payload.submission ...
|
|
546
|
+
\t\t\tbreak;
|
|
547
|
+
\t\tdefault:
|
|
548
|
+
\t\t\tbreak;
|
|
549
|
+
\t}
|
|
550
|
+
\treturn event;
|
|
551
|
+
}
|
|
552
|
+
`;
|
|
292
553
|
async function handleContacts(client, subcommand, rest, flags) {
|
|
293
554
|
if (subcommand === "list") {
|
|
294
555
|
await printJson(await client.get("/contacts", {
|
|
@@ -305,7 +566,8 @@ async function handleContacts(client, subcommand, rest, flags) {
|
|
|
305
566
|
if (subcommand === "enroll") {
|
|
306
567
|
await printJson(await client.post("/contacts", {
|
|
307
568
|
phone: requiredStringFlag(flags, "phone"),
|
|
308
|
-
|
|
569
|
+
// Keys are project-scoped; default to the key's project.
|
|
570
|
+
project_id: stringFlag(flags, "project-id") ?? client.projectId,
|
|
309
571
|
name: stringFlag(flags, "name"),
|
|
310
572
|
reference_id: stringFlag(flags, "reference-id"),
|
|
311
573
|
metadata: optionalJsonFlag(flags, "metadata"),
|
|
@@ -402,7 +664,13 @@ async function handleFlows(client, subcommand, rest, flags) {
|
|
|
402
664
|
return;
|
|
403
665
|
}
|
|
404
666
|
if (subcommand === "publish") {
|
|
405
|
-
|
|
667
|
+
const id = requiredValue(flowId, "flow-id");
|
|
668
|
+
const published = await client.post(`/flows/${id}/publish`);
|
|
669
|
+
if (booleanFlag(flags, "wait")) {
|
|
670
|
+
await printJson(await waitUntilPublished(client, id));
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
await printJson(published);
|
|
406
674
|
return;
|
|
407
675
|
}
|
|
408
676
|
if (subcommand === "send") {
|
|
@@ -423,6 +691,73 @@ async function handleFlows(client, subcommand, rest, flags) {
|
|
|
423
691
|
// `flows test` is handled before client creation (no API key needed).
|
|
424
692
|
throw new Error("Usage: wabery flows list|get|publish|send|test");
|
|
425
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* Poll a flow until Meta reports PUBLISHED (sendable), or throw with the rejection
|
|
696
|
+
* reason on a terminal BLOCKED/DEPRECATED state or timeout. Publishing surfaces most
|
|
697
|
+
* validation errors synchronously; this covers Meta's short review window.
|
|
698
|
+
*/
|
|
699
|
+
async function waitUntilPublished(client, flowId, { timeoutMs = 120_000, intervalMs = 3_000 } = {}) {
|
|
700
|
+
const deadline = Date.now() + timeoutMs;
|
|
701
|
+
for (;;) {
|
|
702
|
+
const flow = (await client.get(`/flows/${flowId}`));
|
|
703
|
+
const status = flow.meta_status ?? flow.status ?? null;
|
|
704
|
+
if (status === "PUBLISHED")
|
|
705
|
+
return flow;
|
|
706
|
+
if (status === "BLOCKED" || status === "DEPRECATED") {
|
|
707
|
+
throw new Error(`Flow ${flowId} is ${status} and cannot be published`);
|
|
708
|
+
}
|
|
709
|
+
if (Date.now() >= deadline) {
|
|
710
|
+
throw new Error(`Timed out waiting for flow ${flowId} to become publishable (last status: ${status ?? "unknown"})`);
|
|
711
|
+
}
|
|
712
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Print a ready-to-paste env block for a project. Pulls project id, the authorized
|
|
717
|
+
* channel id, API key (from your CLI config / shell), and base URL. The webhook
|
|
718
|
+
* signing secret is never returned by the API for security — paste it from the
|
|
719
|
+
* dashboard (Project → Webhooks).
|
|
720
|
+
*/
|
|
721
|
+
async function handleEnv(client, flags) {
|
|
722
|
+
let projectId = stringFlag(flags, "project-id");
|
|
723
|
+
let channelId = stringFlag(flags, "channel-id");
|
|
724
|
+
if (!projectId) {
|
|
725
|
+
const projects = (await client.get("/projects"));
|
|
726
|
+
projectId = projects.data?.[0]?.project_id ?? projects.data?.[0]?.id;
|
|
727
|
+
}
|
|
728
|
+
// Single GET reveals webhook_url + webhook_secret (the list never does).
|
|
729
|
+
let project;
|
|
730
|
+
if (projectId) {
|
|
731
|
+
project = (await client.get(`/projects/${projectId}`));
|
|
732
|
+
}
|
|
733
|
+
if (!channelId) {
|
|
734
|
+
channelId =
|
|
735
|
+
project?.channel_id ?? project?.enrollment_channel?.id ?? undefined;
|
|
736
|
+
}
|
|
737
|
+
if (!channelId) {
|
|
738
|
+
const channels = (await client.get("/channels"));
|
|
739
|
+
channelId = channels.data?.[0]?.channel_id ?? channels.data?.[0]?.id;
|
|
740
|
+
}
|
|
741
|
+
const cred = findCredentialByProject(client.projectId ?? projectId ?? "");
|
|
742
|
+
const apiKey = stringFlag(flags, "api-key") ??
|
|
743
|
+
process.env.WABERY_API_KEY ??
|
|
744
|
+
cred?.apiKey ??
|
|
745
|
+
"wab_live_...";
|
|
746
|
+
const baseUrl = stringFlag(flags, "base-url") ??
|
|
747
|
+
process.env.WABERY_BASE_URL ??
|
|
748
|
+
cred?.baseUrl ??
|
|
749
|
+
"https://api.wabery.com/v1";
|
|
750
|
+
const lines = [
|
|
751
|
+
`WABERY_API_KEY=${apiKey}`,
|
|
752
|
+
`WABERY_BASE_URL=${baseUrl}`,
|
|
753
|
+
`WABERY_PROJECT_ID=${projectId ?? "<no project found>"}`,
|
|
754
|
+
`WABERY_CHANNEL_ID=${channelId ?? "<no channel found>"}`,
|
|
755
|
+
];
|
|
756
|
+
if (project?.webhook_url)
|
|
757
|
+
lines.push(`WABERY_WEBHOOK_URL=${project.webhook_url}`);
|
|
758
|
+
lines.push(`WABERY_WEBHOOK_SECRET=${project?.webhook_secret ?? ""}`);
|
|
759
|
+
await printText(`${lines.join("\n")}\n`);
|
|
760
|
+
}
|
|
426
761
|
/** Sign a body the same way Wabery does: `sha256=<hex>` HMAC-SHA256. */
|
|
427
762
|
function signBody(secret, body) {
|
|
428
763
|
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
@@ -475,6 +810,11 @@ async function testDataExchange(flags) {
|
|
|
475
810
|
await postSigned(url, secret, body);
|
|
476
811
|
}
|
|
477
812
|
async function postSigned(url, secret, body) {
|
|
813
|
+
const { status, statusText, text } = await postSignedRaw(url, secret, body);
|
|
814
|
+
await printText(`→ POST ${url}\n${body}\n`);
|
|
815
|
+
await printText(`← ${status} ${statusText}\n${text}`);
|
|
816
|
+
}
|
|
817
|
+
async function postSignedRaw(url, secret, body) {
|
|
478
818
|
const response = await fetch(url, {
|
|
479
819
|
method: "POST",
|
|
480
820
|
headers: {
|
|
@@ -483,9 +823,75 @@ async function postSigned(url, secret, body) {
|
|
|
483
823
|
},
|
|
484
824
|
body,
|
|
485
825
|
});
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
826
|
+
return {
|
|
827
|
+
status: response.status,
|
|
828
|
+
statusText: response.statusText,
|
|
829
|
+
text: await response.text(),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Walk a whole data-exchange flow locally, then fire the completion webhook — the full
|
|
834
|
+
* journey (open → field changes → branch → submit) without a deploy or Meta publish.
|
|
835
|
+
*
|
|
836
|
+
* wabery flows simulate --url <data_exchange_url> --secret <s> \
|
|
837
|
+
* [--webhook-url <flow.completed target>] \
|
|
838
|
+
* [--steps '[{"action":"INIT"},{"action":"data_exchange","screen":"SELECT","data":{"workspace":"ws_1"}}]'] \
|
|
839
|
+
* [--submission '{"workspace":"ws_1","project":"proj_a"}']
|
|
840
|
+
*/
|
|
841
|
+
async function simulateFlow(flags) {
|
|
842
|
+
const url = requiredStringFlag(flags, "url");
|
|
843
|
+
const secret = requiredStringFlag(flags, "secret");
|
|
844
|
+
const webhookUrl = stringFlag(flags, "webhook-url");
|
|
845
|
+
const identity = {
|
|
846
|
+
flow_token: stringFlag(flags, "flow-token") ?? "sim_flow_token",
|
|
847
|
+
flow_id: stringFlag(flags, "flow-id") ?? "sim_flow_id",
|
|
848
|
+
agent_id: stringFlag(flags, "agent-id") ?? "sim_agent_id",
|
|
849
|
+
organization_id: stringFlag(flags, "organization-id") ?? "sim_org_id",
|
|
850
|
+
contact_id: stringFlag(flags, "contact-id") ?? "sim_contact_id",
|
|
851
|
+
contact_reference: stringFlag(flags, "contact-reference") ?? null,
|
|
852
|
+
from: stringFlag(flags, "from") ?? null,
|
|
853
|
+
};
|
|
854
|
+
const steps = (optionalJsonFlag(flags, "steps") ?? [
|
|
855
|
+
{ action: "INIT" },
|
|
856
|
+
]);
|
|
857
|
+
if (!Array.isArray(steps)) {
|
|
858
|
+
throw new Error("--steps must be a JSON array of { action, screen, data }");
|
|
859
|
+
}
|
|
860
|
+
for (const [index, step] of steps.entries()) {
|
|
861
|
+
const body = JSON.stringify({
|
|
862
|
+
action: step.action ?? "data_exchange",
|
|
863
|
+
screen: step.screen ?? null,
|
|
864
|
+
data: step.data ?? {},
|
|
865
|
+
...identity,
|
|
866
|
+
});
|
|
867
|
+
const res = await postSignedRaw(url, secret, body);
|
|
868
|
+
await printText(`step ${index + 1}/${steps.length} · ${step.action ?? "data_exchange"}${step.screen ? ` → ${step.screen}` : ""}\n ← ${res.status} ${res.statusText}: ${res.text}\n`);
|
|
869
|
+
if (res.status >= 400) {
|
|
870
|
+
throw new Error(`data-exchange step ${index + 1} failed (${res.status})`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (webhookUrl) {
|
|
874
|
+
const event = JSON.stringify({
|
|
875
|
+
event: "flow.completed",
|
|
876
|
+
api_version: stringFlag(flags, "api-version") ?? "2026-06-18",
|
|
877
|
+
payload: {
|
|
878
|
+
object: "flow_submission",
|
|
879
|
+
flow_token: identity.flow_token,
|
|
880
|
+
flow_id: identity.flow_id,
|
|
881
|
+
agent_id: identity.agent_id,
|
|
882
|
+
organization_id: identity.organization_id,
|
|
883
|
+
contact_id: identity.contact_id,
|
|
884
|
+
contact_reference: identity.contact_reference,
|
|
885
|
+
from: identity.from,
|
|
886
|
+
valid: true,
|
|
887
|
+
submission: optionalJsonFlag(flags, "submission") ?? {},
|
|
888
|
+
submitted_at: new Date().toISOString(),
|
|
889
|
+
},
|
|
890
|
+
sentAt: new Date().toISOString(),
|
|
891
|
+
});
|
|
892
|
+
const res = await postSignedRaw(webhookUrl, secret, event);
|
|
893
|
+
await printText(`completion · flow.completed → ${webhookUrl}\n ← ${res.status} ${res.statusText}: ${res.text}\n`);
|
|
894
|
+
}
|
|
489
895
|
}
|
|
490
896
|
function samplePayload(event) {
|
|
491
897
|
if (event === "flow.completed") {
|
|
@@ -595,7 +1001,48 @@ function requiredValue(value, name) {
|
|
|
595
1001
|
throw new Error(`Missing required --${name}`);
|
|
596
1002
|
return value;
|
|
597
1003
|
}
|
|
1004
|
+
// Global output shaping, set from --id-only / --field at startup.
|
|
1005
|
+
let outputIdOnly = false;
|
|
1006
|
+
let outputField;
|
|
1007
|
+
const ID_FIELDS = [
|
|
1008
|
+
"id",
|
|
1009
|
+
"project_id",
|
|
1010
|
+
"contact_id",
|
|
1011
|
+
"flow_id",
|
|
1012
|
+
"channel_id",
|
|
1013
|
+
"flow_token",
|
|
1014
|
+
"dispatch_id",
|
|
1015
|
+
];
|
|
1016
|
+
function extractPath(data, path) {
|
|
1017
|
+
return path.split(".").reduce((node, key) => {
|
|
1018
|
+
if (node && typeof node === "object") {
|
|
1019
|
+
return node[key];
|
|
1020
|
+
}
|
|
1021
|
+
return undefined;
|
|
1022
|
+
}, data);
|
|
1023
|
+
}
|
|
1024
|
+
function formatScalar(value) {
|
|
1025
|
+
if (value === undefined || value === null)
|
|
1026
|
+
return "";
|
|
1027
|
+
if (typeof value === "object")
|
|
1028
|
+
return JSON.stringify(value);
|
|
1029
|
+
return String(value);
|
|
1030
|
+
}
|
|
598
1031
|
async function printJson(data) {
|
|
1032
|
+
if (outputIdOnly) {
|
|
1033
|
+
const record = data && typeof data === "object"
|
|
1034
|
+
? data
|
|
1035
|
+
: undefined;
|
|
1036
|
+
const id = record
|
|
1037
|
+
? ID_FIELDS.map((key) => record[key]).find((v) => typeof v === "string")
|
|
1038
|
+
: undefined;
|
|
1039
|
+
process.stdout.write(`${formatScalar(id)}\n`);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (outputField) {
|
|
1043
|
+
process.stdout.write(`${formatScalar(extractPath(data, outputField))}\n`);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
599
1046
|
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
600
1047
|
}
|
|
601
1048
|
async function printText(text) {
|
|
@@ -618,19 +1065,38 @@ function redactSensitive(value) {
|
|
|
618
1065
|
function printHelp() {
|
|
619
1066
|
process.stdout.write(`Wabery CLI and MCP server
|
|
620
1067
|
|
|
1068
|
+
Projects & keys:
|
|
1069
|
+
API keys are scoped to a single project. Log into each project once; the CLI
|
|
1070
|
+
stores one key per project and auto-selects the right one based on the repo.
|
|
1071
|
+
A repo declares its project in a committed, secret-free .wabery.json
|
|
1072
|
+
({"project_id": "..."}); the CLI walks up from the current directory to find
|
|
1073
|
+
it (falling back to wabery.config.json's project_id).
|
|
1074
|
+
|
|
621
1075
|
Environment:
|
|
622
|
-
WABERY_API_KEY API key override
|
|
1076
|
+
WABERY_API_KEY API key override (used directly, bypasses the store).
|
|
1077
|
+
WABERY_PROJECT_ID Select which stored project's key to use.
|
|
623
1078
|
WABERY_BASE_URL Defaults to https://api.wabery.com/v1.
|
|
624
1079
|
WABERY_API_VERSION Optional dated API version.
|
|
625
1080
|
|
|
626
1081
|
Global flags:
|
|
627
1082
|
--api-key <key> Override WABERY_API_KEY.
|
|
1083
|
+
--project-id <id> Select which stored project's key to use.
|
|
628
1084
|
--base-url <url> Override WABERY_BASE_URL.
|
|
629
1085
|
--api-version <ver> Override WABERY_API_VERSION.
|
|
1086
|
+
--id-only Print just the result id (for shell: ID=$(wabery ... --id-only)).
|
|
1087
|
+
--field <dot.path> Print just one field, e.g. --field webhook_secret.
|
|
1088
|
+
|
|
1089
|
+
Getting started:
|
|
1090
|
+
wabery login # log into a project (stores its key)
|
|
1091
|
+
wabery whoami # active key (masked), org, project
|
|
1092
|
+
wabery init [--name "My project"] # scaffold .env.wabery + wabery.config.json + handler
|
|
630
1093
|
|
|
631
1094
|
Authentication:
|
|
632
|
-
wabery login
|
|
1095
|
+
wabery login # add/refresh the current project's key
|
|
633
1096
|
wabery login --no-open
|
|
1097
|
+
wabery use <project-id-or-name> # bind this repo to a logged-in project
|
|
1098
|
+
wabery logout [--project <id|name>] # remove one stored key
|
|
1099
|
+
wabery logout --all # remove every stored key
|
|
634
1100
|
Config file: ${getConfigPath()}
|
|
635
1101
|
|
|
636
1102
|
MCP:
|
|
@@ -642,6 +1108,7 @@ MCP:
|
|
|
642
1108
|
|
|
643
1109
|
Diagnostics:
|
|
644
1110
|
wabery doctor
|
|
1111
|
+
wabery env --project-id <project_id> # ready-to-paste env block
|
|
645
1112
|
|
|
646
1113
|
Config:
|
|
647
1114
|
wabery config schema
|
|
@@ -654,7 +1121,10 @@ Config:
|
|
|
654
1121
|
|
|
655
1122
|
Projects and contacts:
|
|
656
1123
|
wabery projects list
|
|
657
|
-
wabery projects
|
|
1124
|
+
wabery projects get <project_id> # reveals webhook_secret
|
|
1125
|
+
wabery projects ensure --name "Demo" # idempotently configure the key's scoped project
|
|
1126
|
+
wabery projects update <project_id> [--name <n>] [--webhook-url <url>] [--webhook-signing-enabled true|false] [--webhook-secret <secret>]
|
|
1127
|
+
wabery projects rotate-secret <project_id> [--webhook-secret <secret>]
|
|
658
1128
|
wabery contacts enroll --project-id <id> --phone +15555550123 --name "Jane" --opt-in '{"source":"checkout"}'
|
|
659
1129
|
wabery contacts get <contact_id>
|
|
660
1130
|
wabery contacts unenroll <contact_id>
|
|
@@ -672,12 +1142,13 @@ Templates, messages, and conversations:
|
|
|
672
1142
|
Flows:
|
|
673
1143
|
wabery flows list
|
|
674
1144
|
wabery flows get <flow_id>
|
|
675
|
-
wabery flows publish <flow_id>
|
|
1145
|
+
wabery flows publish <flow_id> [--wait]
|
|
676
1146
|
wabery flows send <flow_id> --channel-id <id> --contact-id <contact_id>
|
|
677
1147
|
|
|
678
1148
|
Local testing (sign + POST to your own endpoint, no deploy needed):
|
|
679
1149
|
wabery webhooks send-test --url http://localhost:3000/webhooks --secret <secret> [--event message.received] [--payload '<json>']
|
|
680
1150
|
wabery flows test --url http://localhost:3000/flow --secret <secret> [--action INIT] [--screen SELECT] [--data '<json>']
|
|
1151
|
+
wabery flows simulate --url http://localhost:3000/flow --secret <secret> [--webhook-url <url>] [--steps '<json array>'] [--submission '<json>']
|
|
681
1152
|
`);
|
|
682
1153
|
}
|
|
683
1154
|
main().catch((error) => {
|