@letterstory/cli 0.114.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 +25 -5
- package/lib/commands/admin.mjs +152 -16
- package/lib/commands/shared.mjs +13 -0
- 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>
|
|
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,8 +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.
|
|
815
|
+
draft-churn-reason drafts a reason from
|
|
816
|
+
what the CRM kept; a person confirms it in
|
|
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.
|
|
805
823
|
Onboard runs org → link → phantom sites →
|
|
806
824
|
Slack channel → checklist → app invites →
|
|
807
825
|
stage in one call (--site "" = infer the
|
|
@@ -1403,7 +1421,9 @@ export async function run(argv, io = defaultIo()) {
|
|
|
1403
1421
|
await resolveDocumentFlags(client, flags);
|
|
1404
1422
|
|
|
1405
1423
|
const ctx = { client, config, positionals: positionals.slice(1), flags, io, bin };
|
|
1406
|
-
return
|
|
1424
|
+
// A handler may return what its last `io.log` returned (`true`): only a number is an exit code.
|
|
1425
|
+
const code = await handler(ctx);
|
|
1426
|
+
return typeof code === "number" ? code : 0;
|
|
1407
1427
|
} catch (err) {
|
|
1408
1428
|
if (err instanceof CliError) {
|
|
1409
1429
|
io.error(`Error: ${err.message}`);
|
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|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)
|
|
@@ -44,7 +44,17 @@
|
|
|
44
44
|
|
|
45
45
|
import { readFileSync } from "node:fs";
|
|
46
46
|
import { CliError, LetterstoryClient } from "../client.mjs";
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
flagStr,
|
|
49
|
+
flagNum,
|
|
50
|
+
flagBool,
|
|
51
|
+
flagList,
|
|
52
|
+
flagCsv,
|
|
53
|
+
printResult,
|
|
54
|
+
compact,
|
|
55
|
+
ok,
|
|
56
|
+
requirePositional,
|
|
57
|
+
} from "./shared.mjs";
|
|
48
58
|
import { watchDomainJob } from "./onboard.mjs";
|
|
49
59
|
|
|
50
60
|
const SEER_MODES = ["suggest", "skip"];
|
|
@@ -682,6 +692,21 @@ async function cmdAdminTuningContext(ctx) {
|
|
|
682
692
|
* admin tuning enhance-inputs [--set v1] | enhance-split [--since-hours 72] [--at <iso>,…] | enhance-standing
|
|
683
693
|
* admin tuning promote-enhancer <experiment-id> --version <enhancer-version-id> promote our enhancer from a "ship" decision (picks when it changes visuals)
|
|
684
694
|
*/
|
|
695
|
+
/** `--flag '<json array>'` → the parsed array of objects, or undefined when the flag is absent. */
|
|
696
|
+
function jsonArrayFlag(raw, name) {
|
|
697
|
+
const text = flagStr(raw);
|
|
698
|
+
if (text === undefined) return undefined;
|
|
699
|
+
let v;
|
|
700
|
+
try {
|
|
701
|
+
v = JSON.parse(text);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
throw new CliError(`${name} is not JSON: ${err.message}`);
|
|
704
|
+
}
|
|
705
|
+
if (!Array.isArray(v) || v.some((x) => !x || typeof x !== "object" || Array.isArray(x)))
|
|
706
|
+
throw new CliError(`${name} is a JSON array of objects: '[{…},{…}]'`);
|
|
707
|
+
return v;
|
|
708
|
+
}
|
|
709
|
+
|
|
685
710
|
/** `--flag '<json>'` → the parsed object, or undefined when the flag is absent. */
|
|
686
711
|
function jsonFlag(raw, name) {
|
|
687
712
|
const text = flagStr(raw);
|
|
@@ -846,7 +871,7 @@ async function cmdAdminTuning(ctx) {
|
|
|
846
871
|
}
|
|
847
872
|
case "compare": {
|
|
848
873
|
const title = flagStr(flags.title);
|
|
849
|
-
const list =
|
|
874
|
+
const list = flagCsv(flags.candidates);
|
|
850
875
|
if (!title || !list?.length)
|
|
851
876
|
throw new CliError(
|
|
852
877
|
'Usage: admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>] [--pairs 20] (default 10 pairs)'
|
|
@@ -2178,10 +2203,8 @@ async function cmdAdminTuning(ctx) {
|
|
|
2178
2203
|
const versionId = flagStr(flags.candidate);
|
|
2179
2204
|
const patch = jsonFlag(flags.patch, "--patch");
|
|
2180
2205
|
const nullCheck = flagBool(flags["null-check"]);
|
|
2181
|
-
const versionIds =
|
|
2182
|
-
const patches =
|
|
2183
|
-
if (patches !== undefined && !Array.isArray(patches))
|
|
2184
|
-
throw new CliError("--patches is a JSON array of patches: '[{…},{…}]'");
|
|
2206
|
+
const versionIds = flagCsv(flags.candidates);
|
|
2207
|
+
const patches = jsonArrayFlag(flags.patches, "--patches");
|
|
2185
2208
|
const named = [
|
|
2186
2209
|
versionId !== undefined,
|
|
2187
2210
|
patch !== undefined,
|
|
@@ -2217,7 +2240,7 @@ async function cmdAdminTuning(ctx) {
|
|
|
2217
2240
|
candidates: several,
|
|
2218
2241
|
// The kernel that writes every arm into a finished article (the arena the only difference).
|
|
2219
2242
|
write: flagStr(flags.write) ? { kernel: flagStr(flags.write) } : undefined,
|
|
2220
|
-
keys:
|
|
2243
|
+
keys: flagCsv(flags.keys),
|
|
2221
2244
|
concurrency: flagNum(flags.concurrency),
|
|
2222
2245
|
experiment_id: flagStr(flags.experiment),
|
|
2223
2246
|
})
|
|
@@ -2244,7 +2267,7 @@ async function cmdAdminTuning(ctx) {
|
|
|
2244
2267
|
case "comparison-extend": {
|
|
2245
2268
|
// A Write comparison grows by the next N pairs; one on fixed inputs by named keys or all.
|
|
2246
2269
|
const pairs = flagNum(flags.pairs);
|
|
2247
|
-
const keys = flagBool(flags.all) ? "all" :
|
|
2270
|
+
const keys = flagBool(flags.all) ? "all" : flagCsv(flags.keys);
|
|
2248
2271
|
const usage =
|
|
2249
2272
|
"Usage: admin tuning comparison-extend <comparison-id> --pairs N | --keys k1 --keys k2 | --all";
|
|
2250
2273
|
if (!id || (pairs === undefined && !keys?.length) || (pairs !== undefined && keys?.length))
|
|
@@ -2304,7 +2327,7 @@ async function cmdAdminTuning(ctx) {
|
|
|
2304
2327
|
primitive: "shred",
|
|
2305
2328
|
title,
|
|
2306
2329
|
candidate,
|
|
2307
|
-
keys:
|
|
2330
|
+
keys: flagCsv(flags.keys),
|
|
2308
2331
|
concurrency: flagNum(flags.concurrency),
|
|
2309
2332
|
experiment_id: flagStr(flags.experiment),
|
|
2310
2333
|
})
|
|
@@ -2422,7 +2445,7 @@ async function cmdAdminTuning(ctx) {
|
|
|
2422
2445
|
primitive: "enhance",
|
|
2423
2446
|
title,
|
|
2424
2447
|
candidate,
|
|
2425
|
-
keys:
|
|
2448
|
+
keys: flagCsv(flags.keys),
|
|
2426
2449
|
concurrency: flagNum(flags.concurrency),
|
|
2427
2450
|
experiment_id: flagStr(flags.experiment),
|
|
2428
2451
|
})
|
|
@@ -2539,6 +2562,16 @@ function signalLine(s) {
|
|
|
2539
2562
|
return `${s.id} [${s.status} · ${s.intent}] ${s.occurredAt.slice(0, 10)} ${s.source}/${s.kind} ${s.title}${who ? `\n ${who}` : ""}`;
|
|
2540
2563
|
}
|
|
2541
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
|
+
|
|
2542
2575
|
function accountLine(a) {
|
|
2543
2576
|
const score = a.likelihood == null ? "—" : a.likelihood.toFixed(2);
|
|
2544
2577
|
return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
|
|
@@ -3299,12 +3332,23 @@ async function cmdAdminCrm(ctx) {
|
|
|
3299
3332
|
return out(r, () => (action === "on" ? "moved to white glove" : "left white glove"));
|
|
3300
3333
|
}
|
|
3301
3334
|
case "churn": {
|
|
3302
|
-
// 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.
|
|
3303
3337
|
const params = new URLSearchParams(compact({ months: flagStr(flags.months) }));
|
|
3304
3338
|
const pct = (x) => (x == null ? "—" : `${(x * 100).toFixed(1)}%`);
|
|
3305
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;
|
|
3306
3344
|
const win = (label, w) =>
|
|
3307
|
-
|
|
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}` : ""}]` : "");
|
|
3308
3352
|
return out(await staffRequest(ctx, "GET", `${CRM}/churn?${params}`), (r) =>
|
|
3309
3353
|
[
|
|
3310
3354
|
win("30d", r.window.days30),
|
|
@@ -3314,14 +3358,24 @@ async function cmdAdminCrm(ctx) {
|
|
|
3314
3358
|
...(r.churned.length
|
|
3315
3359
|
? r.churned.map(
|
|
3316
3360
|
(c) =>
|
|
3317
|
-
` ${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}` : ""}`
|
|
3318
3362
|
)
|
|
3319
3363
|
: [" (none)"]),
|
|
3320
3364
|
"",
|
|
3321
3365
|
"Likely churned (not recorded):",
|
|
3322
3366
|
...(r.likely.length
|
|
3323
|
-
? r.likely.map((l) => ` ${accountLine(l.account)}\n ${l.signal}`)
|
|
3367
|
+
? r.likely.map((l) => ` ${accountLine(l.account)}${cause(l)}\n ${l.signal}`)
|
|
3324
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)"]),
|
|
3325
3379
|
].join("\n")
|
|
3326
3380
|
);
|
|
3327
3381
|
}
|
|
@@ -3358,6 +3412,8 @@ async function cmdAdminCrm(ctx) {
|
|
|
3358
3412
|
note: flagStr(flags.note),
|
|
3359
3413
|
churned_at: flagStr(flags.at),
|
|
3360
3414
|
mrr_cents: flagNum(flags["mrr-cents"]),
|
|
3415
|
+
cause: flagStr(flags.cause),
|
|
3416
|
+
notice_at: flagStr(flags["notice-at"]),
|
|
3361
3417
|
})
|
|
3362
3418
|
);
|
|
3363
3419
|
return out(
|
|
@@ -3366,6 +3422,86 @@ async function cmdAdminCrm(ctx) {
|
|
|
3366
3422
|
`${x.change === "updated" ? "churn updated" : "churn recorded"}: ${accountLine(x.account)} · ${x.account.churnMrrCents == null ? "no MRR on file" : `$${Math.round(x.account.churnMrrCents / 100)}/mo lost`}`
|
|
3367
3423
|
);
|
|
3368
3424
|
}
|
|
3425
|
+
case "draft-churn-reason": {
|
|
3426
|
+
// admin crm draft-churn-reason <account-id> — draft why a churned account left, from
|
|
3427
|
+
// what the CRM kept. The draft waits in the proposals queue for a person to confirm.
|
|
3428
|
+
if (!id) throw new CliError("Usage: admin crm draft-churn-reason <account-id>");
|
|
3429
|
+
const r = await staffPost(ctx, `${CRM}/accounts/${id}/churn/draft-reason`, {});
|
|
3430
|
+
return out(r, (x) =>
|
|
3431
|
+
x.status === "drafted"
|
|
3432
|
+
? [
|
|
3433
|
+
`drafted: ${x.reason}${x.contributing.length ? ` (also ${x.contributing.join(", ")})` : ""} · ${x.cause} · proposal ${x.proposalId}`,
|
|
3434
|
+
` "${x.quote}"`,
|
|
3435
|
+
x.summary ? ` ${x.summary}` : null,
|
|
3436
|
+
]
|
|
3437
|
+
.filter(Boolean)
|
|
3438
|
+
.join("\n")
|
|
3439
|
+
: `${x.status.replaceAll("_", " ")}${x.detail ? ` (${x.detail.replaceAll("_", " ")})` : ""}`
|
|
3440
|
+
);
|
|
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
|
+
}
|
|
3369
3505
|
case "undo-churn": {
|
|
3370
3506
|
// admin crm undo-churn <account-id> — clear the churn and move back to active.
|
|
3371
3507
|
if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
|
|
@@ -3420,7 +3556,7 @@ async function cmdAdminCrm(ctx) {
|
|
|
3420
3556
|
}
|
|
3421
3557
|
default:
|
|
3422
3558
|
throw new CliError(
|
|
3423
|
-
"Usage: admin crm import|capture|outreach|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)"
|
|
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)"
|
|
3424
3560
|
);
|
|
3425
3561
|
}
|
|
3426
3562
|
}
|
package/lib/commands/shared.mjs
CHANGED
|
@@ -32,6 +32,19 @@ export function flagList(v) {
|
|
|
32
32
|
return typeof v === "string" ? [v] : [];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A list of ids or keys, repeatable AND comma-separated: `--keys a,b --keys c` → `["a","b","c"]`.
|
|
37
|
+
* `flagList` keeps each occurrence whole (free text such as `--stance` may carry a comma); the id
|
|
38
|
+
* and key flags whose usage says `k1,k2` read through this instead, or the whole string went to the
|
|
39
|
+
* server as one id and failed validation.
|
|
40
|
+
*/
|
|
41
|
+
export function flagCsv(v) {
|
|
42
|
+
return flagList(v)
|
|
43
|
+
.flatMap((x) => x.split(","))
|
|
44
|
+
.map((x) => x.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
/**
|
|
36
49
|
* `--prefer <site>` (repeatable or comma-separated) → preferred_sources `[{ url }]` (a site; a URL is read as its site); `--prefer none`
|
|
37
50
|
* → `[]` (clears a standing list); absent → undefined.
|