@letterstory/cli 0.44.0 → 0.46.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 +19 -1
- package/lib/cli.mjs +22 -4
- package/lib/commands/admin.mjs +251 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -386,7 +386,25 @@ letterstory admin crm accounts --stage demo_scheduled # with likelihood
|
|
|
386
386
|
letterstory admin crm score <account-id> # recompute; prints the factors
|
|
387
387
|
letterstory admin crm slack-channels [--all] # Watchdog channels + the account each is linked to
|
|
388
388
|
letterstory admin crm link-slack <account-id> C0123ABC # the CRM's watch-only Slack link (`none` unlinks)
|
|
389
|
-
|
|
389
|
+
letterstory admin crm import leads.csv --dry-run # bulk-import leads: what would be created/matched
|
|
390
|
+
letterstory admin crm import leads.csv --tag "YC W26" # …and for real (dedupes by domain, then company name)
|
|
391
|
+
|
|
392
|
+
# One-click onboarding of a won client (key needs crm:write + fleet:onboard + org:create)
|
|
393
|
+
letterstory admin crm onboard <account-id> --domain acme.com \
|
|
394
|
+
--site "Where fintech compliance is heading" --site "" \
|
|
395
|
+
--slack jane@acme.com --invite jane@acme.com:admin --invite bob@acme.com
|
|
396
|
+
letterstory admin crm onboarding <account-id> # step-by-step progress (sites refreshed)
|
|
397
|
+
letterstory admin crm retry-onboarding <onboarding-id> # re-run only the failed steps
|
|
398
|
+
letterstory admin crm onboard-plan <account-id> # what the calls say: sites, Slack/app people, paid?
|
|
399
|
+
letterstory admin crm payment-link <account-id> # Stripe Payment Link at the deal amount (billing:write)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`onboard` runs org → link → phantom sites → Slack channel → checklist → app invites →
|
|
403
|
+
stage in one call. Each site is a real (not test) fleet build; `--site ""` lets the engine
|
|
404
|
+
infer its beat from the client's homepage. `onboard-plan` reads the account's recorded calls
|
|
405
|
+
(newest first, cached per set of calls; `--refresh` re-reads) and says how many sites, what each
|
|
406
|
+
is about, who to invite, and whether they've paid. `payment-link` stamps the account's org on
|
|
407
|
+
the Stripe subscription, so paying through it binds and marks the account paid by itself.
|
|
390
408
|
|
|
391
409
|
Every trigger — Cal.com bookings, Circleback recordings, Letterhome dinner RSVPs, RB2B
|
|
392
410
|
visits, and what an agent records here — goes through one intake (`ingestSignal`,
|
package/lib/cli.mjs
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
} from "./commands.mjs";
|
|
53
53
|
|
|
54
54
|
// Keep in sync with cli/package.json.
|
|
55
|
-
export const VERSION = "0.
|
|
55
|
+
export const VERSION = "0.46.0";
|
|
56
56
|
|
|
57
57
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
58
58
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -61,6 +61,8 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
61
61
|
"yes",
|
|
62
62
|
"confirm-missing-target",
|
|
63
63
|
"no-wait",
|
|
64
|
+
"no-checklist",
|
|
65
|
+
"refresh",
|
|
64
66
|
"accept",
|
|
65
67
|
"help",
|
|
66
68
|
"version",
|
|
@@ -556,7 +558,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
556
558
|
session (a trial per arm), linked to its
|
|
557
559
|
ledger entry. Written by \`npm run lab --
|
|
558
560
|
publish\` in the kernels repo.
|
|
559
|
-
admin crm
|
|
561
|
+
admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
|
|
562
|
+
| signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
|
|
560
563
|
| record --title "…" [--source bookface] [--kind other] [--intent qualified]
|
|
561
564
|
[--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
|
|
562
565
|
| signal <id> qualify|dismiss|link [--account <id>]
|
|
@@ -565,7 +568,13 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
565
568
|
| white-glove [--suggested] | set-white-glove <account-id> on|off [--note "…"]
|
|
566
569
|
| churn [--months 12] | record-churn <account-id> <reason> [--note "…"] [--at ISO]
|
|
567
570
|
[--mrr-cents N] | undo-churn <account-id>
|
|
568
|
-
|
|
571
|
+
| onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
|
|
572
|
+
[--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
|
|
573
|
+
| onboarding <account-id> | retry-onboarding <onboarding-id>
|
|
574
|
+
| onboard-plan <account-id> [--refresh]
|
|
575
|
+
| payment-link <account-id> [--amount-cents N] [--description "…"]
|
|
576
|
+
The staff CRM's pipeline: bulk-importing a
|
|
577
|
+
CSV of leads, the inbound signal
|
|
569
578
|
feed, recording a signal with no webhook
|
|
570
579
|
(Bookface, a tweet like), qualifying or
|
|
571
580
|
dismissing one, and the explained score.
|
|
@@ -574,7 +583,16 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
574
583
|
Churn: 30/90-day logo + revenue rates, who
|
|
575
584
|
churned and why; Stripe cancellations are
|
|
576
585
|
only suggestions until a person records one.
|
|
577
|
-
|
|
586
|
+
Onboard runs org → link → phantom sites →
|
|
587
|
+
Slack channel → checklist → app invites →
|
|
588
|
+
stage in one call (--site "" = infer the
|
|
589
|
+
beat from their homepage); retry re-runs
|
|
590
|
+
only what failed.
|
|
591
|
+
Needs crm:read (reads) / crm:write (writes);
|
|
592
|
+
onboard + retry also need fleet:onboard and
|
|
593
|
+
org:create. onboard-plan reads the calls
|
|
594
|
+
(sites, who to invite, paid or not);
|
|
595
|
+
payment-link needs billing:write.
|
|
578
596
|
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
579
597
|
the pipeline as deployed, levers, measures,
|
|
580
598
|
goals, related history.
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
8
|
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
9
|
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
10
|
-
// admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn … (crm:read / crm:write)
|
|
10
|
+
// admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
|
|
11
11
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
12
12
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
13
13
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -425,6 +425,207 @@ function accountLine(a) {
|
|
|
425
425
|
return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
/* ------------------------------------------------------ CRM lead import (CSV) */
|
|
429
|
+
|
|
430
|
+
/** RFC 4180-ish: quoted fields, doubled quotes, CRLF/LF, a BOM. Returns rows of cells. */
|
|
431
|
+
export function parseCsvText(text) {
|
|
432
|
+
const src = text.replace(/^/, "");
|
|
433
|
+
const rows = [];
|
|
434
|
+
let row = [];
|
|
435
|
+
let cell = "";
|
|
436
|
+
let quoted = false;
|
|
437
|
+
for (let i = 0; i < src.length; i++) {
|
|
438
|
+
const c = src[i];
|
|
439
|
+
if (quoted) {
|
|
440
|
+
if (c === '"') {
|
|
441
|
+
if (src[i + 1] === '"') {
|
|
442
|
+
cell += '"';
|
|
443
|
+
i++;
|
|
444
|
+
} else quoted = false;
|
|
445
|
+
} else cell += c;
|
|
446
|
+
} else if (c === '"') quoted = true;
|
|
447
|
+
else if (c === ",") {
|
|
448
|
+
row.push(cell);
|
|
449
|
+
cell = "";
|
|
450
|
+
} else if (c === "\n" || c === "\r") {
|
|
451
|
+
if (c === "\r" && src[i + 1] === "\n") i++;
|
|
452
|
+
row.push(cell);
|
|
453
|
+
rows.push(row);
|
|
454
|
+
row = [];
|
|
455
|
+
cell = "";
|
|
456
|
+
} else cell += c;
|
|
457
|
+
}
|
|
458
|
+
if (cell !== "" || row.length > 0) {
|
|
459
|
+
row.push(cell);
|
|
460
|
+
rows.push(row);
|
|
461
|
+
}
|
|
462
|
+
return rows.filter((r) => r.some((c) => c.trim() !== ""));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Spreadsheet header → CrmImportRow field (headers compared lower-cased, letters and digits only). */
|
|
466
|
+
const CRM_IMPORT_HEADERS = {
|
|
467
|
+
company: ["company", "companyname", "organization", "organisation", "account", "accountname"],
|
|
468
|
+
domain: ["domain", "companydomain"],
|
|
469
|
+
website: ["website", "url", "companywebsite", "companyurl", "site"],
|
|
470
|
+
name: ["name", "fullname", "contact", "contactname", "person"],
|
|
471
|
+
firstName: ["firstname", "first", "givenname"],
|
|
472
|
+
lastName: ["lastname", "last", "surname", "familyname"],
|
|
473
|
+
email: ["email", "emailaddress", "workemail", "businessemail"],
|
|
474
|
+
title: ["title", "jobtitle", "position", "role"],
|
|
475
|
+
linkedin: ["linkedin", "linkedinurl", "linkedinprofile"],
|
|
476
|
+
phone: ["phone", "phonenumber", "mobile"],
|
|
477
|
+
notes: ["notes", "note", "comments", "description"],
|
|
478
|
+
yc: ["yc", "isyc", "ycombinator"],
|
|
479
|
+
batch: ["batch", "ycbatch"],
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** A CSV's text → the CrmImportRow[] the import route takes (unknown columns are ignored). */
|
|
483
|
+
export function csvToImportRows(text) {
|
|
484
|
+
const [header, ...body] = parseCsvText(text);
|
|
485
|
+
if (!header) return { rows: [], mapped: [] };
|
|
486
|
+
const key = (h) => h.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
487
|
+
const columns = header.map((h) => {
|
|
488
|
+
const k = key(h);
|
|
489
|
+
return Object.keys(CRM_IMPORT_HEADERS).find((f) => CRM_IMPORT_HEADERS[f].includes(k)) ?? null;
|
|
490
|
+
});
|
|
491
|
+
const rows = body.map((cells) => {
|
|
492
|
+
const row = {};
|
|
493
|
+
columns.forEach((field, i) => {
|
|
494
|
+
const v = (cells[i] ?? "").trim();
|
|
495
|
+
if (field && v && row[field] === undefined) row[field] = v;
|
|
496
|
+
});
|
|
497
|
+
return row;
|
|
498
|
+
});
|
|
499
|
+
return { rows, mapped: [...new Set(columns.filter(Boolean))] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The route's cap (CRM_IMPORT_MAX_ROWS in src/types/crm.ts). */
|
|
503
|
+
const CRM_IMPORT_CHUNK = 2000;
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* `admin crm import <file.csv> [--source name] [--tag "…"] [--dry-run]` — the same importLeads
|
|
507
|
+
* the CRM's upload calls (POST /api/superadmin/crm/import), 2,000 rows per request.
|
|
508
|
+
*/
|
|
509
|
+
async function cmdAdminCrmImport(ctx, path) {
|
|
510
|
+
const { flags, io } = ctx;
|
|
511
|
+
if (!path) throw new CliError('Usage: admin crm import <file.csv> [--source name] [--tag "…"] [--dry-run]');
|
|
512
|
+
let text;
|
|
513
|
+
try {
|
|
514
|
+
text = readFileSync(path, "utf8");
|
|
515
|
+
} catch (err) {
|
|
516
|
+
throw new CliError(`Could not read ${path}: ${err.message}`);
|
|
517
|
+
}
|
|
518
|
+
const { rows, mapped } = csvToImportRows(text);
|
|
519
|
+
if (rows.length === 0) throw new CliError(`${path} has no data rows`);
|
|
520
|
+
if (!["company", "domain", "website", "email"].some((f) => mapped.includes(f))) {
|
|
521
|
+
throw new CliError("No company, domain, website or email column found in the header row");
|
|
522
|
+
}
|
|
523
|
+
const source = flagStr(flags.source) ?? path.split(/[\\/]/).pop();
|
|
524
|
+
const dryRun = flagBool(flags["dry-run"]);
|
|
525
|
+
const total = { created: 0, matched: 0, contactsAdded: 0, skipped: 0, rows: [] };
|
|
526
|
+
for (let offset = 0; offset < rows.length; offset += CRM_IMPORT_CHUNK) {
|
|
527
|
+
const r = await staffPost(
|
|
528
|
+
ctx,
|
|
529
|
+
`${CRM}/import`,
|
|
530
|
+
compact({ rows: rows.slice(offset, offset + CRM_IMPORT_CHUNK), source, dryRun, tag: flagStr(flags.tag) })
|
|
531
|
+
);
|
|
532
|
+
total.created += r.created;
|
|
533
|
+
total.matched += r.matched;
|
|
534
|
+
total.contactsAdded += r.contactsAdded;
|
|
535
|
+
total.skipped += r.skipped;
|
|
536
|
+
total.rows.push(...r.rows.map((x) => ({ ...x, index: x.index + offset })));
|
|
537
|
+
}
|
|
538
|
+
if (flagBool(flags.json)) return printResult(io, flags, total);
|
|
539
|
+
const errors = total.rows.filter((x) => x.status === "error").length;
|
|
540
|
+
io.log(
|
|
541
|
+
`${dryRun ? "Dry run — would import" : "Imported"} ${rows.length} rows from ${source} (columns: ${mapped.join(", ")}):\n` +
|
|
542
|
+
` ${total.created} accounts ${dryRun ? "to create" : "created"}, ${total.matched} matched, ` +
|
|
543
|
+
`${total.contactsAdded} contacts ${dryRun ? "to add" : "added"}, ${total.skipped} skipped${errors ? `, ${errors} errors` : ""}`
|
|
544
|
+
);
|
|
545
|
+
for (const x of total.rows.filter((y) => y.status === "skipped" || y.status === "error")) {
|
|
546
|
+
// +2: the header is line 1 and the index is 0-based.
|
|
547
|
+
io.log(` line ${x.index + 2}: ${x.status} — ${x.reason ?? ""}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function onboardingLines(o) {
|
|
552
|
+
if (!o) return "(never onboarded from the CRM)";
|
|
553
|
+
const mark = { done: "✓", skipped: "–", failed: "✗", running: "…", pending: "·" };
|
|
554
|
+
return [
|
|
555
|
+
`${o.id} started ${o.startedAt.slice(0, 16).replace("T", " ")}${o.finishedAt ? " finished" : " in progress"}`,
|
|
556
|
+
...(o.billing ? [` ${billingLine(o.billing)}`] : []),
|
|
557
|
+
...o.steps.map(
|
|
558
|
+
(s) =>
|
|
559
|
+
` ${mark[s.status] ?? "?"} ${s.key.padEnd(9)} ${s.status}${s.progress ? ` (${s.progress.done}/${s.progress.total})` : ""}${s.detail ? ` ${s.detail}` : ""}${s.error ? `\n error: ${s.error}` : ""}`
|
|
560
|
+
),
|
|
561
|
+
].join("\n");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function dollars(cents) {
|
|
565
|
+
return `$${(cents / 100).toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function billingLine(b) {
|
|
569
|
+
const paid = b.paid
|
|
570
|
+
? `PAID${b.mrrCents ? ` (${dollars(b.mrrCents)}/mo)` : ""}${b.paidAt ? ` since ${b.paidAt.slice(0, 10)}` : ""}`
|
|
571
|
+
: "NOT PAID";
|
|
572
|
+
return `billing: ${paid}${b.paymentLinkUrl ? ` link: ${b.paymentLinkUrl}` : ""}`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function planLines(p) {
|
|
576
|
+
return [
|
|
577
|
+
`domain: ${p.domain ?? "(unknown)"}`,
|
|
578
|
+
`sites: ${p.sites.length}`,
|
|
579
|
+
...p.sites.map(
|
|
580
|
+
(s, i) => ` ${i + 1}. ${s.direction || "(infer from their homepage)"}${s.why ? ` — ${s.why}` : ""}`
|
|
581
|
+
),
|
|
582
|
+
`slack: ${p.slackEmails.join(", ") || "(nobody found)"}`,
|
|
583
|
+
`invites: ${p.invites.map((i) => `${i.email}:${i.role}`).join(", ") || "(none)"}`,
|
|
584
|
+
...(p.notes ? [`notes: ${p.notes}`] : []),
|
|
585
|
+
billingLine(p.billing),
|
|
586
|
+
`read: ${p.sources.map((s) => `${s.title ?? s.transcriptId} (${s.occurredAt.slice(0, 10)})`).join("; ") || "(no calls on file)"}`,
|
|
587
|
+
].join("\n");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* `admin crm onboard` body from flags — the same CrmOnboardBodySchema the desk sends.
|
|
592
|
+
* --site "<direction>" per site (repeatable; "" = infer from the homepage) and/or
|
|
593
|
+
* --sites N (pads with inferred sites); --slack a@x,b@x (bare --slack = the channel, no
|
|
594
|
+
* invites yet); --invite email[:admin|editor] (repeatable); --no-checklist.
|
|
595
|
+
*/
|
|
596
|
+
function onboardBody(flags) {
|
|
597
|
+
const domain = flagStr(flags.domain);
|
|
598
|
+
if (!domain) {
|
|
599
|
+
throw new CliError(
|
|
600
|
+
'Usage: admin crm onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N] [--slack a@x,b@x] [--invite a@x[:admin]]… [--no-checklist]'
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const sites = flagList(flags.site).map((direction) => ({ direction: direction.trim() || null }));
|
|
604
|
+
const total = flagNum(flags.sites);
|
|
605
|
+
while (total !== undefined && sites.length < total) sites.push({ direction: null });
|
|
606
|
+
const slackEmails = flagList(flags.slack)
|
|
607
|
+
.flatMap((v) => v.split(","))
|
|
608
|
+
.map((e) => e.trim())
|
|
609
|
+
.filter(Boolean);
|
|
610
|
+
const slack = flags.slack === undefined ? null : { emails: slackEmails };
|
|
611
|
+
const invites = flagList(flags.invite)
|
|
612
|
+
.flatMap((v) => v.split(","))
|
|
613
|
+
.map((v) => v.trim())
|
|
614
|
+
.filter(Boolean)
|
|
615
|
+
.map((v) => {
|
|
616
|
+
const [email, role] = v.split(":");
|
|
617
|
+
return { email, role: role === "admin" ? "admin" : "editor" };
|
|
618
|
+
});
|
|
619
|
+
return compact({
|
|
620
|
+
domain,
|
|
621
|
+
name: flagStr(flags.name),
|
|
622
|
+
sites,
|
|
623
|
+
slack,
|
|
624
|
+
invites,
|
|
625
|
+
checklist: flagBool(flags["no-checklist"]) ? false : undefined,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
428
629
|
/**
|
|
429
630
|
* `admin crm …` — the headless half of the staff CRM, so a background agent can watch the
|
|
430
631
|
* pipeline and record the signals that have no webhook (Bookface, tweet likes, DMs).
|
|
@@ -437,6 +638,8 @@ async function cmdAdminCrm(ctx) {
|
|
|
437
638
|
const json = flagBool(flags.json);
|
|
438
639
|
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
439
640
|
switch (sub) {
|
|
641
|
+
case "import":
|
|
642
|
+
return cmdAdminCrmImport(ctx, id);
|
|
440
643
|
case "signals": {
|
|
441
644
|
const params = new URLSearchParams(
|
|
442
645
|
compact({
|
|
@@ -623,9 +826,55 @@ async function cmdAdminCrm(ctx) {
|
|
|
623
826
|
const r = await staffRequest(ctx, "DELETE", `${CRM}/accounts/${id}/churn`);
|
|
624
827
|
return out(r, (x) => `churn undone: ${accountLine(x.account)}`);
|
|
625
828
|
}
|
|
829
|
+
case "onboard": {
|
|
830
|
+
// admin crm onboard <account-id> --domain acme.com … — one-click onboarding of a won
|
|
831
|
+
// client. The key needs crm:write + fleet:onboard + org:create.
|
|
832
|
+
if (!id) throw new CliError("Usage: admin crm onboard <account-id> --domain acme.com … (see --help)");
|
|
833
|
+
const r = await staffPost(ctx, `${CRM}/accounts/${id}/onboard`, onboardBody(flags));
|
|
834
|
+
return out(r, (x) => onboardingLines(x.onboarding));
|
|
835
|
+
}
|
|
836
|
+
case "onboarding": {
|
|
837
|
+
// admin crm onboarding <account-id> — the latest onboarding, sites refreshed.
|
|
838
|
+
if (!id) throw new CliError("Usage: admin crm onboarding <account-id>");
|
|
839
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/accounts/${id}/onboard`), (x) =>
|
|
840
|
+
onboardingLines(x.onboarding)
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
case "onboard-plan": {
|
|
844
|
+
// admin crm onboard-plan <account-id> [--refresh] — what the calls say onboarding
|
|
845
|
+
// should set up (sites, Slack/app people, notes) + paid or not. --refresh re-reads.
|
|
846
|
+
if (!id) throw new CliError("Usage: admin crm onboard-plan <account-id> [--refresh]");
|
|
847
|
+
const q = flagBool(flags.refresh) ? "?refresh=1" : "";
|
|
848
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/accounts/${id}/onboard/plan${q}`), (x) =>
|
|
849
|
+
planLines(x.plan)
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
case "payment-link": {
|
|
853
|
+
// admin crm payment-link <account-id> [--amount-cents N] [--description "…"] — a Stripe
|
|
854
|
+
// Payment Link for the monthly subscription (default: the deal amount). The key needs
|
|
855
|
+
// crm:write + billing:write.
|
|
856
|
+
if (!id)
|
|
857
|
+
throw new CliError('Usage: admin crm payment-link <account-id> [--amount-cents N] [--description "…"]');
|
|
858
|
+
const r = await staffPost(
|
|
859
|
+
ctx,
|
|
860
|
+
`${CRM}/accounts/${id}/payment-link`,
|
|
861
|
+
compact({ amountCents: flagNum(flags["amount-cents"]), description: flagStr(flags.description) })
|
|
862
|
+
);
|
|
863
|
+
return out(
|
|
864
|
+
r,
|
|
865
|
+
(x) =>
|
|
866
|
+
`${x.url} (${dollars(x.amountCents)}/month${x.orgId ? "" : " — no org yet: bind in Fleet → Billing"})`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
case "retry-onboarding": {
|
|
870
|
+
// admin crm retry-onboarding <onboarding-id> — re-run the failed/pending steps.
|
|
871
|
+
if (!id) throw new CliError("Usage: admin crm retry-onboarding <onboarding-id>");
|
|
872
|
+
const r = await staffPost(ctx, `${CRM}/onboardings/${id}/retry`, {});
|
|
873
|
+
return out(r, (x) => onboardingLines(x.onboarding));
|
|
874
|
+
}
|
|
626
875
|
default:
|
|
627
876
|
throw new CliError(
|
|
628
|
-
"Usage: admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn … (see --help)"
|
|
877
|
+
"Usage: admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
629
878
|
);
|
|
630
879
|
}
|
|
631
880
|
}
|