@letterstory/cli 0.84.0 → 0.86.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 +4 -0
- package/lib/cli.mjs +10 -1
- package/lib/commands/org.mjs +107 -5
- package/lib/commands/outbound.mjs +38 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -644,6 +644,10 @@ letterstory org join acme/ > acme.json # rebuild the identical bundl
|
|
|
644
644
|
letterstory org diff acme.json # what applying the bundle would change: + add, ~ change, - only in the org
|
|
645
645
|
letterstory org apply acme.json --dry-run # what apply would write; then drop --dry-run to apply
|
|
646
646
|
letterstory org apply acme.json --prune --yes # also delete records the bundle lacks
|
|
647
|
+
letterstory org git connect acme/config --path letterstory/org.json # keep the config in GitHub: each new commit is planned and posted to Slack
|
|
648
|
+
letterstory org git connect acme/config --mode apply # …or applied (deletes only with --prune)
|
|
649
|
+
letterstory org git status # the connected repo and its last sync
|
|
650
|
+
letterstory org git sync [--force] # sync now instead of waiting for the 15-minute poll
|
|
647
651
|
```
|
|
648
652
|
|
|
649
653
|
## Seers — event-driven signals
|
package/lib/cli.mjs
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
} from "./commands.mjs";
|
|
58
58
|
|
|
59
59
|
// Keep in sync with cli/package.json.
|
|
60
|
-
export const VERSION = "0.
|
|
60
|
+
export const VERSION = "0.86.0";
|
|
61
61
|
|
|
62
62
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
63
63
|
// can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
|
|
@@ -77,6 +77,8 @@ export const BOOLEAN_FLAGS = new Set([
|
|
|
77
77
|
"no-color",
|
|
78
78
|
"dry-run",
|
|
79
79
|
"prune",
|
|
80
|
+
// org git sync --force
|
|
81
|
+
"force",
|
|
80
82
|
"buy",
|
|
81
83
|
"rebuild",
|
|
82
84
|
"print-key",
|
|
@@ -816,6 +818,7 @@ Seers (event-driven signals -> drafts):
|
|
|
816
818
|
org join <dir> [--file <path>] Rebuild the bundle from a --split directory
|
|
817
819
|
org diff <file|dir> What applying a bundle would change (reads only)
|
|
818
820
|
org apply <file|dir> [--dry-run] [--prune --yes] Make the org match a bundle (adds/updates; --prune deletes)
|
|
821
|
+
[--allow spends,sends,destroys --yes] Also apply gated changes (admins; without --yes it only previews)
|
|
819
822
|
|
|
820
823
|
Content templates (compose structures seers bind via config.template_key):
|
|
821
824
|
templates list Custom + built-in catalog
|
|
@@ -963,7 +966,9 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
|
|
|
963
966
|
[--[no-]opt-out-footer] [--[no-]postal-address-footer] [--[no-]unsubscribe-links] [--[no-]detect-reply-opt-outs]
|
|
964
967
|
[--[no-]test-mode] [--default-lede <fallback opener line>]
|
|
965
968
|
[--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
|
|
969
|
+
[--enroll-on-cake paid|scheduled|out_for_delivery|delivered|none]
|
|
966
970
|
--test-mode (internal accounts only): walks as live, sends nothing
|
|
971
|
+
--enroll-on-cake: start it when a recipient's Daymaker cake reaches that stage
|
|
967
972
|
sequences pause|resume <sequence-id> Stop / restart sending
|
|
968
973
|
sequences enroll <sequence-id> (--list <list-id> | --email <addr>... | --recipients '<json>'|--recipients-file <path>)
|
|
969
974
|
sequences stop <sequence-id> <enrollment-id> Stop one recipient
|
|
@@ -973,6 +978,10 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
|
|
|
973
978
|
sequences links list Live Pocket Dispatch links (no tokens)
|
|
974
979
|
sequences links create --days <1..3> Mint a no-login review link (shown once)
|
|
975
980
|
sequences links revoke <link-id> --yes
|
|
981
|
+
sequences cakes [--stage <stage>] [--limit <n>] Daymaker cakes: stage, matched prospect, sequences started
|
|
982
|
+
sequences cakes sync Re-read the connected Daymaker account now
|
|
983
|
+
sequences cakes connect --daymaker-key <dm_live_…> Connect the org's Daymaker account (key checked, stored encrypted)
|
|
984
|
+
sequences cakes disconnect Forget the key and remove the webhook
|
|
976
985
|
|
|
977
986
|
Dispatches (the named review desks a sequence's review stage queues to):
|
|
978
987
|
dispatches list Desks + how many emails each has queued
|
package/lib/commands/org.mjs
CHANGED
|
@@ -22,7 +22,21 @@ export const ARGS = {
|
|
|
22
22
|
config: { value: (_flags, positionals) => readBundle(requirePositional(positionals, 0, "file|dir")) },
|
|
23
23
|
dry_run: { flag: "dry-run", type: "bool" },
|
|
24
24
|
prune: { flag: "prune", type: "bool" },
|
|
25
|
+
allow: { flag: "allow", type: "list" },
|
|
26
|
+
confirm: { flag: "confirm" },
|
|
25
27
|
},
|
|
28
|
+
get_org_config_source: {},
|
|
29
|
+
connect_org_config_source: {
|
|
30
|
+
repo: { positional: 0, label: "owner/name" },
|
|
31
|
+
branch: { flag: "branch" },
|
|
32
|
+
path: { flag: "path" },
|
|
33
|
+
mode: { flag: "mode" },
|
|
34
|
+
prune: { flag: "prune", type: "bool" },
|
|
35
|
+
},
|
|
36
|
+
sync_org_config_source: {
|
|
37
|
+
force: { flag: "force", type: "bool" },
|
|
38
|
+
},
|
|
39
|
+
disconnect_org_config_source: {},
|
|
26
40
|
};
|
|
27
41
|
|
|
28
42
|
export async function cmdOrg(ctx) {
|
|
@@ -37,8 +51,10 @@ export async function cmdOrg(ctx) {
|
|
|
37
51
|
return orgDiff(rest);
|
|
38
52
|
case "apply":
|
|
39
53
|
return orgApply(rest);
|
|
54
|
+
case "git":
|
|
55
|
+
return orgGit(rest);
|
|
40
56
|
default:
|
|
41
|
-
throw new CliError(`Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply`);
|
|
57
|
+
throw new CliError(`Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply, git`);
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
60
|
|
|
@@ -204,6 +220,10 @@ export function formatApply(result) {
|
|
|
204
220
|
for (const a of result.skipped) lines.push(` ${SYMBOL[a.op]} ${a.section}/${a.name} — ${a.reason}`);
|
|
205
221
|
if (result.failed.length) lines.push("Failed:");
|
|
206
222
|
for (const a of result.failed) lines.push(` ${SYMBOL[a.op]} ${a.section}/${a.name} — ${a.error}`);
|
|
223
|
+
if (result.gated?.length) {
|
|
224
|
+
lines.push(`Gated (allow with --allow; confirm ${result.confirm}):`);
|
|
225
|
+
for (const a of result.gated) lines.push(` ${SYMBOL[a.op]} ${a.section}/${a.name} — ${a.gate}`);
|
|
226
|
+
}
|
|
207
227
|
for (const w of result.plan.warnings) lines.push(`warning: ${w}`);
|
|
208
228
|
return lines.join("\n");
|
|
209
229
|
}
|
|
@@ -213,10 +233,92 @@ async function orgApply(ctx) {
|
|
|
213
233
|
refuseUnknownFlags(ctx, ARGS.apply_org_config, ["yes"]);
|
|
214
234
|
if (flagBool(flags.prune) && !flagBool(flags["dry-run"]) && !flagBool(flags.yes))
|
|
215
235
|
throw new CliError("--prune deletes records missing from the bundle. Review with --dry-run, then add --yes.");
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
236
|
+
const args = argsFromFlags(ARGS.apply_org_config, flags, ctx.positionals);
|
|
237
|
+
if (args.allow)
|
|
238
|
+
args.allow = args.allow
|
|
239
|
+
.flatMap((g) => g.split(","))
|
|
240
|
+
.map((g) => g.trim())
|
|
241
|
+
.filter(Boolean);
|
|
242
|
+
if (args.allow?.length && !args.dry_run && !args.confirm) {
|
|
243
|
+
// Gated changes need the plan's hash: dry-run to get it, show what it unlocks, then apply with it.
|
|
244
|
+
const { result: plan } = await client.callTool("apply_org_config", { ...args, dry_run: true });
|
|
245
|
+
const unlocked = (plan.gated ?? []).filter((g) => args.allow.includes(g.gate));
|
|
246
|
+
if (unlocked.length && !flagBool(flags.yes)) {
|
|
247
|
+
printResult(io, flags, plan, formatApply);
|
|
248
|
+
throw new CliError(
|
|
249
|
+
`--allow ${args.allow.join(",")} unlocks ${unlocked.length} gated change(s) above. Re-run with --yes to apply them.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
args.confirm = plan.confirm ?? undefined;
|
|
253
|
+
}
|
|
254
|
+
const { result } = await client.callTool("apply_org_config", args);
|
|
220
255
|
printResult(io, flags, result, formatApply);
|
|
221
256
|
return result.failed.length ? 1 : 0;
|
|
222
257
|
}
|
|
258
|
+
|
|
259
|
+
export function formatSource(source) {
|
|
260
|
+
if (!source) return "No config repo connected. Connect one with `org git connect <owner/name>`.";
|
|
261
|
+
const lines = [
|
|
262
|
+
`${source.repo}@${source.branch}:${source.path} — mode ${source.mode}${source.prune ? " (prune)" : ""}`,
|
|
263
|
+
` last seen: ${source.last_seen_sha?.slice(0, 7) ?? "—"}${source.last_synced_at ? ` at ${source.last_synced_at}` : ""}`,
|
|
264
|
+
` last applied: ${source.last_applied_sha?.slice(0, 7) ?? "—"}`,
|
|
265
|
+
];
|
|
266
|
+
if (source.last_error) lines.push(` last error: ${source.last_error}`);
|
|
267
|
+
return lines.join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function formatSync(outcome) {
|
|
271
|
+
const at = outcome.sha ? outcome.sha.slice(0, 7) : "?";
|
|
272
|
+
if (outcome.status === "unchanged")
|
|
273
|
+
return `Already synced ${at} — nothing new on the branch (use --force to re-run it).`;
|
|
274
|
+
if (outcome.status === "error") return `Sync failed at ${at}: ${outcome.error}`;
|
|
275
|
+
if (outcome.status === "planned") return `Planned ${at} (plan mode, nothing applied):\n${formatPlan(outcome.plan)}`;
|
|
276
|
+
return `Applied ${at}:\n${formatApply(outcome.result)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function orgGit(ctx) {
|
|
280
|
+
const { client, flags, io } = ctx;
|
|
281
|
+
const sub = ctx.positionals[0];
|
|
282
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
283
|
+
switch (sub) {
|
|
284
|
+
case "status": {
|
|
285
|
+
refuseUnknownFlags(rest, ARGS.get_org_config_source);
|
|
286
|
+
const { source } = await client.callTool("get_org_config_source", {});
|
|
287
|
+
printResult(io, flags, source, formatSource);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
case "connect": {
|
|
291
|
+
refuseUnknownFlags(rest, ARGS.connect_org_config_source);
|
|
292
|
+
const out = await client.callTool(
|
|
293
|
+
"connect_org_config_source",
|
|
294
|
+
argsFromFlags(ARGS.connect_org_config_source, flags, rest.positionals)
|
|
295
|
+
);
|
|
296
|
+
printResult(io, flags, out, (o) =>
|
|
297
|
+
[
|
|
298
|
+
`Connected ${o.org} to ${formatSource(o.source)}`,
|
|
299
|
+
`The next sync ${o.source.mode === "apply" ? "applies" : "plans"} ${o.head.slice(0, 7)} — run \`org git sync\` to do it now.`,
|
|
300
|
+
].join("\n")
|
|
301
|
+
);
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
case "sync": {
|
|
305
|
+
refuseUnknownFlags(rest, ARGS.sync_org_config_source);
|
|
306
|
+
const { outcome } = await client.callTool(
|
|
307
|
+
"sync_org_config_source",
|
|
308
|
+
argsFromFlags(ARGS.sync_org_config_source, flags)
|
|
309
|
+
);
|
|
310
|
+
printResult(io, flags, outcome, formatSync);
|
|
311
|
+
return outcome.status === "error" ? 1 : 0;
|
|
312
|
+
}
|
|
313
|
+
case "disconnect": {
|
|
314
|
+
refuseUnknownFlags(rest, ARGS.disconnect_org_config_source);
|
|
315
|
+
const { disconnected } = await client.callTool("disconnect_org_config_source", {});
|
|
316
|
+
ok(ctx, disconnected ? "Disconnected the config repo." : "No config repo was connected.");
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
default:
|
|
320
|
+
throw new CliError(
|
|
321
|
+
`Unknown org git subcommand: ${sub ?? "(none)"}. Try: status, connect, sync, disconnect`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -89,6 +89,21 @@ const SEQUENCE_WINDOW_ARGS = {
|
|
|
89
89
|
send_window_end_minute: { value: windowMinute("send-window-end") },
|
|
90
90
|
send_window_timezone: "send-window-timezone",
|
|
91
91
|
};
|
|
92
|
+
// --enroll-on-cake <stage>: start the sequence when a recipient's Daymaker cake
|
|
93
|
+
// reaches it; `none` clears it.
|
|
94
|
+
const CAKE_TRIGGER_STAGES = ["paid", "scheduled", "out_for_delivery", "delivered"];
|
|
95
|
+
const CAKE_STAGES = ["draft", ...CAKE_TRIGGER_STAGES, "delivery_failed", "cancelled"];
|
|
96
|
+
const enrollOnCake = {
|
|
97
|
+
value: (flags) => {
|
|
98
|
+
const raw = flagStr(flags["enroll-on-cake"]);
|
|
99
|
+
if (raw === undefined) return undefined;
|
|
100
|
+
if (raw === "none") return null;
|
|
101
|
+
if (!CAKE_TRIGGER_STAGES.includes(raw)) {
|
|
102
|
+
throw new CliError(`--enroll-on-cake takes ${CAKE_TRIGGER_STAGES.join(", ")} or none (got ${raw})`);
|
|
103
|
+
}
|
|
104
|
+
return raw;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
92
107
|
const PROSPECT_STATUSES = ["new", "contacted", "replied", "unsubscribed", "archived"];
|
|
93
108
|
const mailerIds = {
|
|
94
109
|
value: (flags) => {
|
|
@@ -147,6 +162,7 @@ export const ARGS = {
|
|
|
147
162
|
mailer_ids: mailerIds,
|
|
148
163
|
...SEQUENCE_RAIL_ARGS,
|
|
149
164
|
...SEQUENCE_WINDOW_ARGS,
|
|
165
|
+
enroll_on_cake: enrollOnCake,
|
|
150
166
|
},
|
|
151
167
|
update_sequence: {
|
|
152
168
|
sequence_id: { positional: 0, label: "sequence-id", required: true },
|
|
@@ -156,7 +172,15 @@ export const ARGS = {
|
|
|
156
172
|
mailer_ids: mailerIds,
|
|
157
173
|
...SEQUENCE_RAIL_ARGS,
|
|
158
174
|
...SEQUENCE_WINDOW_ARGS,
|
|
175
|
+
enroll_on_cake: enrollOnCake,
|
|
159
176
|
},
|
|
177
|
+
list_cakes: {
|
|
178
|
+
stage: { flag: "stage", enum: CAKE_STAGES },
|
|
179
|
+
limit: { flag: "limit", type: "num" },
|
|
180
|
+
},
|
|
181
|
+
sync_cakes: {},
|
|
182
|
+
connect_daymaker: { api_key: { flag: "daymaker-key", required: true } },
|
|
183
|
+
disconnect_daymaker: {},
|
|
160
184
|
enroll_in_sequence: {
|
|
161
185
|
sequence_id: { positional: 0, label: "sequence-id", required: true },
|
|
162
186
|
recipients: { value: readRecipients },
|
|
@@ -357,7 +381,7 @@ async function run(ctx, tool, message) {
|
|
|
357
381
|
return 0;
|
|
358
382
|
}
|
|
359
383
|
|
|
360
|
-
const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links>`;
|
|
384
|
+
const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links|cakes>`;
|
|
361
385
|
const DISPATCHES_USAGE = `Usage: dispatches <list|create|rename|delete>`;
|
|
362
386
|
|
|
363
387
|
// Dispatches: the named review desks a sequence's review stage queues to.
|
|
@@ -425,6 +449,19 @@ export async function cmdSequences(ctx) {
|
|
|
425
449
|
return sequencesReview({ ...rest, positionals: rest.positionals.slice(1) }, rest.positionals[0]);
|
|
426
450
|
case "links":
|
|
427
451
|
return sequencesLinks({ ...rest, positionals: rest.positionals.slice(1) }, rest.positionals[0]);
|
|
452
|
+
case "cakes": {
|
|
453
|
+
const cakesCtx = { ...rest, positionals: rest.positionals.slice(1) };
|
|
454
|
+
switch (rest.positionals[0]) {
|
|
455
|
+
case "sync":
|
|
456
|
+
return run(cakesCtx, "sync_cakes", "Synced cakes from Daymaker.");
|
|
457
|
+
case "connect":
|
|
458
|
+
return run(cakesCtx, "connect_daymaker", "Daymaker connected.");
|
|
459
|
+
case "disconnect":
|
|
460
|
+
return run(cakesCtx, "disconnect_daymaker", "Daymaker disconnected.");
|
|
461
|
+
default:
|
|
462
|
+
return run(rest, "list_cakes");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
428
465
|
default:
|
|
429
466
|
throw new CliError(`Unknown sequences subcommand: ${sub ?? "(none)"}. ${SEQUENCES_USAGE}`);
|
|
430
467
|
}
|