@letterstory/cli 0.115.0 → 0.117.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/lib/cli.mjs +19 -4
- package/lib/commands/admin.mjs +102 -6
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
} from "./commands.mjs";
|
|
61
61
|
|
|
62
62
|
// Keep in sync with cli/package.json.
|
|
63
|
-
export const VERSION = "0.
|
|
63
|
+
export const VERSION = "0.117.0";
|
|
64
64
|
|
|
65
65
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
66
66
|
// can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
|
|
@@ -98,6 +98,8 @@ export const BOOLEAN_FLAGS = new Set([
|
|
|
98
98
|
"verbose",
|
|
99
99
|
"no-color",
|
|
100
100
|
"dry-run",
|
|
101
|
+
// admin crm record-outcome --draft
|
|
102
|
+
"draft",
|
|
101
103
|
"prune",
|
|
102
104
|
// org git sync --force
|
|
103
105
|
"force",
|
|
@@ -786,7 +788,13 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
786
788
|
| slack-channels [--all] | link-slack <account-id> <channel-id|none>
|
|
787
789
|
| white-glove [--suggested] | set-white-glove <account-id> on|off [--note "…"]
|
|
788
790
|
| churn [--months 12] | record-churn <account-id> <reason> [--note "…"] [--at ISO]
|
|
789
|
-
[--mrr-cents N] | undo-churn <account-id> | draft-churn-reason <account-id>
|
|
791
|
+
[--mrr-cents N] [--cause voluntary|involuntary|unknown] [--notice-at ISO] | undo-churn <account-id> | draft-churn-reason <account-id>
|
|
792
|
+
| outcomes [--account <id>] [--kind churn|contraction|expansion|reactivation|save]
|
|
793
|
+
[--status draft|confirmed|discarded] [--since ISO] [--until ISO] [--limit N]
|
|
794
|
+
| record-outcome <account-id> <kind> [--reason code] [--also a,b] [--cause …] [--notice-at ISO]
|
|
795
|
+
[--at ISO] [--mrr-before N] [--mrr-after N] [--note "…"] [--save-offer …]
|
|
796
|
+
[--save-result …] [--draft]
|
|
797
|
+
| renewals [--days 90]
|
|
790
798
|
| onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
|
|
791
799
|
[--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
|
|
792
800
|
| onboarding <account-id> | retry-onboarding <onboarding-id>
|
|
@@ -800,11 +808,18 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
800
808
|
White glove is a person's decision: the
|
|
801
809
|
desk only suggests ($1K+/mo post-sales).
|
|
802
810
|
Churn: 30/90-day logo + revenue rates, who
|
|
803
|
-
churned and why
|
|
804
|
-
|
|
811
|
+
churned and why, split voluntary /
|
|
812
|
+
involuntary, and payments failing now;
|
|
813
|
+
Stripe cancellations are only suggestions
|
|
814
|
+
until a person records one.
|
|
805
815
|
draft-churn-reason drafts a reason from
|
|
806
816
|
what the CRM kept; a person confirms it in
|
|
807
817
|
the proposals queue.
|
|
818
|
+
Outcomes keep every churn, contraction,
|
|
819
|
+
expansion, reactivation and save with its
|
|
820
|
+
reason; a key records drafts a person
|
|
821
|
+
confirms. Renewals: contracts renewing
|
|
822
|
+
within N days, and Stripe cancels scheduled.
|
|
808
823
|
Onboard runs org → link → phantom sites →
|
|
809
824
|
Slack channel → checklist → app invites →
|
|
810
825
|
stage in one call (--site "" = infer the
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// The old names — evals, eval-add, eval-set, proposals, propose-eval, capture-eval, reject-eval — still work.)
|
|
15
15
|
// admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
|
|
16
16
|
// admin fleet-experiments list|articles|clients|mark|unmark|new|assign|assign-eligible|assign-orgs|spin-up|apply|start|stop|decide … (fleet:read / fleet:experiment)
|
|
17
|
-
// admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|revenue|record-churn|draft-churn-reason|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
|
|
17
|
+
// admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|revenue|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
|
|
18
18
|
// admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
|
|
19
19
|
// admin brand-detection <org-id> (fleet:read)
|
|
20
20
|
// admin brand-detection <org-id> [--term t]... [--exact t]... [--exclude p]... [--note "…"] | --clear [--note "…"] (fleet:instrument)
|
|
@@ -2562,6 +2562,16 @@ function signalLine(s) {
|
|
|
2562
2562
|
return `${s.id} [${s.status} · ${s.intent}] ${s.occurredAt.slice(0, 10)} ${s.source}/${s.kind} ${s.title}${who ? `\n ${who}` : ""}`;
|
|
2563
2563
|
}
|
|
2564
2564
|
|
|
2565
|
+
/** One outcome on a line: when, kind, status, reason, MRR move, id. */
|
|
2566
|
+
function outcomeLine(o) {
|
|
2567
|
+
const usd = (c) => (c == null ? "?" : `$${Math.round(c / 100).toLocaleString("en-US")}`);
|
|
2568
|
+
const mrr =
|
|
2569
|
+
o.mrrBeforeCents != null || o.mrrAfterCents != null
|
|
2570
|
+
? ` ${usd(o.mrrBeforeCents)} → ${usd(o.mrrAfterCents)}/mo`
|
|
2571
|
+
: "";
|
|
2572
|
+
return `${o.effectiveAt.slice(0, 10)} ${o.kind} ${o.status} ${o.primaryReason ?? "no reason"}${mrr}${o.note ? ` — ${o.note}` : ""} ${o.id}`;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2565
2575
|
function accountLine(a) {
|
|
2566
2576
|
const score = a.likelihood == null ? "—" : a.likelihood.toFixed(2);
|
|
2567
2577
|
return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
|
|
@@ -3322,12 +3332,23 @@ async function cmdAdminCrm(ctx) {
|
|
|
3322
3332
|
return out(r, () => (action === "on" ? "moved to white glove" : "left white glove"));
|
|
3323
3333
|
}
|
|
3324
3334
|
case "churn": {
|
|
3325
|
-
// admin crm churn [--months 12] — the 30/90-day rates
|
|
3335
|
+
// admin crm churn [--months 12] — the 30/90-day rates split voluntary / involuntary,
|
|
3336
|
+
// who churned and why, likely churns, and payments failing now.
|
|
3326
3337
|
const params = new URLSearchParams(compact({ months: flagStr(flags.months) }));
|
|
3327
3338
|
const pct = (x) => (x == null ? "—" : `${(x * 100).toFixed(1)}%`);
|
|
3328
3339
|
const usd = (cents) => `$${Math.round(cents / 100).toLocaleString("en-US")}`;
|
|
3340
|
+
const split = (b) =>
|
|
3341
|
+
b
|
|
3342
|
+
? ` voluntary ${b.voluntary.churned} · ${usd(b.voluntary.mrrLostCents)}/mo involuntary ${b.involuntary.churned} · ${usd(b.involuntary.mrrLostCents)}/mo unknown ${b.unknown.churned} · ${usd(b.unknown.mrrLostCents)}/mo`
|
|
3343
|
+
: null;
|
|
3329
3344
|
const win = (label, w) =>
|
|
3330
|
-
|
|
3345
|
+
[
|
|
3346
|
+
`${label}: ${w.churned}/${w.customersAtStart} customers (${pct(w.rate)}) · ${usd(w.mrrLostCents)}/mo lost (${pct(w.revenueRate)} of MRR)`,
|
|
3347
|
+
split(w.byCause),
|
|
3348
|
+
]
|
|
3349
|
+
.filter(Boolean)
|
|
3350
|
+
.join("\n");
|
|
3351
|
+
const cause = (x) => (x.cause ? ` [${x.cause}${x.causeSignal ? `: ${x.causeSignal}` : ""}]` : "");
|
|
3331
3352
|
return out(await staffRequest(ctx, "GET", `${CRM}/churn?${params}`), (r) =>
|
|
3332
3353
|
[
|
|
3333
3354
|
win("30d", r.window.days30),
|
|
@@ -3337,14 +3358,24 @@ async function cmdAdminCrm(ctx) {
|
|
|
3337
3358
|
...(r.churned.length
|
|
3338
3359
|
? r.churned.map(
|
|
3339
3360
|
(c) =>
|
|
3340
|
-
` ${c.churnedAt.slice(0, 10)}${c.churnedAtSource === "undated" ? "?" : ""} ${c.account.name} ${c.reason ?? "unrecorded"} ${usd(c.mrrLostCents)}/mo${c.note ? ` — ${c.note}` : ""}`
|
|
3361
|
+
` ${c.churnedAt.slice(0, 10)}${c.churnedAtSource === "undated" ? "?" : ""} ${c.account.name} ${c.reason ?? "unrecorded"} ${usd(c.mrrLostCents)}/mo${cause(c)}${c.note ? ` — ${c.note}` : ""}`
|
|
3341
3362
|
)
|
|
3342
3363
|
: [" (none)"]),
|
|
3343
3364
|
"",
|
|
3344
3365
|
"Likely churned (not recorded):",
|
|
3345
3366
|
...(r.likely.length
|
|
3346
|
-
? r.likely.map((l) => ` ${accountLine(l.account)}\n ${l.signal}`)
|
|
3367
|
+
? r.likely.map((l) => ` ${accountLine(l.account)}${cause(l)}\n ${l.signal}`)
|
|
3347
3368
|
: [" (none)"]),
|
|
3369
|
+
"",
|
|
3370
|
+
"Payments failing now:",
|
|
3371
|
+
...(r.failing == null
|
|
3372
|
+
? [" (unavailable: the billing read failed, so this is not an all-clear)"]
|
|
3373
|
+
: r.failing.length
|
|
3374
|
+
? r.failing.map(
|
|
3375
|
+
(f) =>
|
|
3376
|
+
` ${f.name} ${f.amountCents == null ? "amount unknown" : usd(f.amountCents)}${f.since ? ` since ${f.since.slice(0, 10)}` : ""}${f.attempts != null ? ` ${f.attempts} attempt${f.attempts === 1 ? "" : "s"}` : ""} → ${f.action ?? (f.nudgeInHours != null ? `wait ${f.nudgeInHours}h (Stripe retrying)` : "none")} (${f.accountId ?? "no account"})\n ${f.reason}`
|
|
3377
|
+
)
|
|
3378
|
+
: [" (none)"]),
|
|
3348
3379
|
].join("\n")
|
|
3349
3380
|
);
|
|
3350
3381
|
}
|
|
@@ -3381,6 +3412,8 @@ async function cmdAdminCrm(ctx) {
|
|
|
3381
3412
|
note: flagStr(flags.note),
|
|
3382
3413
|
churned_at: flagStr(flags.at),
|
|
3383
3414
|
mrr_cents: flagNum(flags["mrr-cents"]),
|
|
3415
|
+
cause: flagStr(flags.cause),
|
|
3416
|
+
notice_at: flagStr(flags["notice-at"]),
|
|
3384
3417
|
})
|
|
3385
3418
|
);
|
|
3386
3419
|
return out(
|
|
@@ -3406,6 +3439,69 @@ async function cmdAdminCrm(ctx) {
|
|
|
3406
3439
|
: `${x.status.replaceAll("_", " ")}${x.detail ? ` (${x.detail.replaceAll("_", " ")})` : ""}`
|
|
3407
3440
|
);
|
|
3408
3441
|
}
|
|
3442
|
+
case "outcomes": {
|
|
3443
|
+
// admin crm outcomes [--account id] [--kind …] [--status …] [--since ISO] [--until ISO] [--limit N]
|
|
3444
|
+
const params = new URLSearchParams(
|
|
3445
|
+
compact({
|
|
3446
|
+
account_id: flagStr(flags.account),
|
|
3447
|
+
kind: flagStr(flags.kind),
|
|
3448
|
+
status: flagStr(flags.status),
|
|
3449
|
+
since: flagStr(flags.since),
|
|
3450
|
+
until: flagStr(flags.until),
|
|
3451
|
+
limit: flagStr(flags.limit),
|
|
3452
|
+
})
|
|
3453
|
+
);
|
|
3454
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/outcomes?${params}`), (r) =>
|
|
3455
|
+
r.outcomes.length ? r.outcomes.map(outcomeLine).join("\n") : "(no outcomes)"
|
|
3456
|
+
);
|
|
3457
|
+
}
|
|
3458
|
+
case "record-outcome": {
|
|
3459
|
+
// admin crm record-outcome <account-id> <kind> [--reason code] [--also a,b] … [--draft]
|
|
3460
|
+
if (!id || !action) {
|
|
3461
|
+
throw new CliError(
|
|
3462
|
+
'Usage: admin crm record-outcome <account-id> <churn|contraction|expansion|reactivation|save> [--reason code] [--also a,b] [--cause voluntary|involuntary|unknown] [--notice-at ISO] [--at ISO] [--mrr-before N] [--mrr-after N] [--note "…"] [--save-offer discount|pause|downgrade|fix|other] [--save-result kept|lost|pending] [--draft]'
|
|
3463
|
+
);
|
|
3464
|
+
}
|
|
3465
|
+
const also = flagStr(flags.also);
|
|
3466
|
+
const r = await staffPost(
|
|
3467
|
+
ctx,
|
|
3468
|
+
`${CRM}/outcomes`,
|
|
3469
|
+
compact({
|
|
3470
|
+
account_id: id,
|
|
3471
|
+
kind: action,
|
|
3472
|
+
status: flagBool(flags.draft) ? "draft" : undefined,
|
|
3473
|
+
primary_reason: flagStr(flags.reason),
|
|
3474
|
+
contributing_reasons: also ? also.split(",").map((x) => x.trim()) : undefined,
|
|
3475
|
+
cause: flagStr(flags.cause),
|
|
3476
|
+
notice_at: flagStr(flags["notice-at"]),
|
|
3477
|
+
effective_at: flagStr(flags.at),
|
|
3478
|
+
mrr_before_cents: flagNum(flags["mrr-before"]),
|
|
3479
|
+
mrr_after_cents: flagNum(flags["mrr-after"]),
|
|
3480
|
+
note: flagStr(flags.note),
|
|
3481
|
+
save_offer: flagStr(flags["save-offer"]),
|
|
3482
|
+
save_result: flagStr(flags["save-result"]),
|
|
3483
|
+
})
|
|
3484
|
+
);
|
|
3485
|
+
return out(
|
|
3486
|
+
r,
|
|
3487
|
+
(x) =>
|
|
3488
|
+
`${x.outcome.status === "draft" ? "drafted (a person confirms it)" : "recorded"}: ${outcomeLine(x.outcome)}`
|
|
3489
|
+
);
|
|
3490
|
+
}
|
|
3491
|
+
case "renewals": {
|
|
3492
|
+
// admin crm renewals [--days 90] — contracts renewing soon, and Stripe cancels scheduled.
|
|
3493
|
+
const params = new URLSearchParams(compact({ days: flagStr(flags.days) }));
|
|
3494
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/renewals?${params}`), (r) =>
|
|
3495
|
+
r.renewals.length
|
|
3496
|
+
? r.renewals
|
|
3497
|
+
.map(
|
|
3498
|
+
(x) =>
|
|
3499
|
+
`${x.daysLeft == null ? " ?" : String(x.daysLeft).padStart(3)}d ${x.name} ${x.renewsAt ? `renews ${x.renewsAt}${x.noticeBy ? ` (notice by ${x.noticeBy})` : ""}` : ""}${x.cancelScheduledAt ? ` cancel scheduled${x.cancelsAt ? `, ends ${x.cancelsAt.slice(0, 10)}` : ""}` : ""} ${x.accountId}`
|
|
3500
|
+
)
|
|
3501
|
+
.join("\n")
|
|
3502
|
+
: `(nothing in the next ${r.days} days)`
|
|
3503
|
+
);
|
|
3504
|
+
}
|
|
3409
3505
|
case "undo-churn": {
|
|
3410
3506
|
// admin crm undo-churn <account-id> — clear the churn and move back to active.
|
|
3411
3507
|
if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
|
|
@@ -3460,7 +3556,7 @@ async function cmdAdminCrm(ctx) {
|
|
|
3460
3556
|
}
|
|
3461
3557
|
default:
|
|
3462
3558
|
throw new CliError(
|
|
3463
|
-
"Usage: admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|draft-churn-reason|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
3559
|
+
"Usage: admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
3464
3560
|
);
|
|
3465
3561
|
}
|
|
3466
3562
|
}
|