@letterstory/cli 0.83.0 → 0.85.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 +9 -1
- package/lib/commands/org.mjs +82 -1
- package/lib/commands/outbound.mjs +55 -2
- package/lib/commands/targets.mjs +1 -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.85.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",
|
|
@@ -963,7 +965,9 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
|
|
|
963
965
|
[--[no-]opt-out-footer] [--[no-]postal-address-footer] [--[no-]unsubscribe-links] [--[no-]detect-reply-opt-outs]
|
|
964
966
|
[--[no-]test-mode] [--default-lede <fallback opener line>]
|
|
965
967
|
[--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
|
|
968
|
+
[--enroll-on-cake paid|scheduled|out_for_delivery|delivered|none]
|
|
966
969
|
--test-mode (internal accounts only): walks as live, sends nothing
|
|
970
|
+
--enroll-on-cake: start it when a recipient's Daymaker cake reaches that stage
|
|
967
971
|
sequences pause|resume <sequence-id> Stop / restart sending
|
|
968
972
|
sequences enroll <sequence-id> (--list <list-id> | --email <addr>... | --recipients '<json>'|--recipients-file <path>)
|
|
969
973
|
sequences stop <sequence-id> <enrollment-id> Stop one recipient
|
|
@@ -973,6 +977,10 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
|
|
|
973
977
|
sequences links list Live Pocket Dispatch links (no tokens)
|
|
974
978
|
sequences links create --days <1..3> Mint a no-login review link (shown once)
|
|
975
979
|
sequences links revoke <link-id> --yes
|
|
980
|
+
sequences cakes [--stage <stage>] [--limit <n>] Daymaker cakes: stage, matched prospect, sequences started
|
|
981
|
+
sequences cakes sync Re-read the connected Daymaker account now
|
|
982
|
+
sequences cakes connect --daymaker-key <dm_live_…> Connect the org's Daymaker account (key checked, stored encrypted)
|
|
983
|
+
sequences cakes disconnect Forget the key and remove the webhook
|
|
976
984
|
|
|
977
985
|
Dispatches (the named review desks a sequence's review stage queues to):
|
|
978
986
|
dispatches list Desks + how many emails each has queued
|
package/lib/commands/org.mjs
CHANGED
|
@@ -23,6 +23,18 @@ export const ARGS = {
|
|
|
23
23
|
dry_run: { flag: "dry-run", type: "bool" },
|
|
24
24
|
prune: { flag: "prune", type: "bool" },
|
|
25
25
|
},
|
|
26
|
+
get_org_config_source: {},
|
|
27
|
+
connect_org_config_source: {
|
|
28
|
+
repo: { positional: 0, label: "owner/name" },
|
|
29
|
+
branch: { flag: "branch" },
|
|
30
|
+
path: { flag: "path" },
|
|
31
|
+
mode: { flag: "mode" },
|
|
32
|
+
prune: { flag: "prune", type: "bool" },
|
|
33
|
+
},
|
|
34
|
+
sync_org_config_source: {
|
|
35
|
+
force: { flag: "force", type: "bool" },
|
|
36
|
+
},
|
|
37
|
+
disconnect_org_config_source: {},
|
|
26
38
|
};
|
|
27
39
|
|
|
28
40
|
export async function cmdOrg(ctx) {
|
|
@@ -37,8 +49,10 @@ export async function cmdOrg(ctx) {
|
|
|
37
49
|
return orgDiff(rest);
|
|
38
50
|
case "apply":
|
|
39
51
|
return orgApply(rest);
|
|
52
|
+
case "git":
|
|
53
|
+
return orgGit(rest);
|
|
40
54
|
default:
|
|
41
|
-
throw new CliError(`Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply`);
|
|
55
|
+
throw new CliError(`Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply, git`);
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
|
|
@@ -220,3 +234,70 @@ async function orgApply(ctx) {
|
|
|
220
234
|
printResult(io, flags, result, formatApply);
|
|
221
235
|
return result.failed.length ? 1 : 0;
|
|
222
236
|
}
|
|
237
|
+
|
|
238
|
+
export function formatSource(source) {
|
|
239
|
+
if (!source) return "No config repo connected. Connect one with `org git connect <owner/name>`.";
|
|
240
|
+
const lines = [
|
|
241
|
+
`${source.repo}@${source.branch}:${source.path} — mode ${source.mode}${source.prune ? " (prune)" : ""}`,
|
|
242
|
+
` last seen: ${source.last_seen_sha?.slice(0, 7) ?? "—"}${source.last_synced_at ? ` at ${source.last_synced_at}` : ""}`,
|
|
243
|
+
` last applied: ${source.last_applied_sha?.slice(0, 7) ?? "—"}`,
|
|
244
|
+
];
|
|
245
|
+
if (source.last_error) lines.push(` last error: ${source.last_error}`);
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatSync(outcome) {
|
|
250
|
+
const at = outcome.sha ? outcome.sha.slice(0, 7) : "?";
|
|
251
|
+
if (outcome.status === "unchanged")
|
|
252
|
+
return `Already synced ${at} — nothing new on the branch (use --force to re-run it).`;
|
|
253
|
+
if (outcome.status === "error") return `Sync failed at ${at}: ${outcome.error}`;
|
|
254
|
+
if (outcome.status === "planned") return `Planned ${at} (plan mode, nothing applied):\n${formatPlan(outcome.plan)}`;
|
|
255
|
+
return `Applied ${at}:\n${formatApply(outcome.result)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function orgGit(ctx) {
|
|
259
|
+
const { client, flags, io } = ctx;
|
|
260
|
+
const sub = ctx.positionals[0];
|
|
261
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
262
|
+
switch (sub) {
|
|
263
|
+
case "status": {
|
|
264
|
+
refuseUnknownFlags(rest, ARGS.get_org_config_source);
|
|
265
|
+
const { source } = await client.callTool("get_org_config_source", {});
|
|
266
|
+
printResult(io, flags, source, formatSource);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
case "connect": {
|
|
270
|
+
refuseUnknownFlags(rest, ARGS.connect_org_config_source);
|
|
271
|
+
const out = await client.callTool(
|
|
272
|
+
"connect_org_config_source",
|
|
273
|
+
argsFromFlags(ARGS.connect_org_config_source, flags, rest.positionals)
|
|
274
|
+
);
|
|
275
|
+
printResult(io, flags, out, (o) =>
|
|
276
|
+
[
|
|
277
|
+
`Connected ${o.org} to ${formatSource(o.source)}`,
|
|
278
|
+
`The next sync ${o.source.mode === "apply" ? "applies" : "plans"} ${o.head.slice(0, 7)} — run \`org git sync\` to do it now.`,
|
|
279
|
+
].join("\n")
|
|
280
|
+
);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
case "sync": {
|
|
284
|
+
refuseUnknownFlags(rest, ARGS.sync_org_config_source);
|
|
285
|
+
const { outcome } = await client.callTool(
|
|
286
|
+
"sync_org_config_source",
|
|
287
|
+
argsFromFlags(ARGS.sync_org_config_source, flags)
|
|
288
|
+
);
|
|
289
|
+
printResult(io, flags, outcome, formatSync);
|
|
290
|
+
return outcome.status === "error" ? 1 : 0;
|
|
291
|
+
}
|
|
292
|
+
case "disconnect": {
|
|
293
|
+
refuseUnknownFlags(rest, ARGS.disconnect_org_config_source);
|
|
294
|
+
const { disconnected } = await client.callTool("disconnect_org_config_source", {});
|
|
295
|
+
ok(ctx, disconnected ? "Disconnected the config repo." : "No config repo was connected.");
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
default:
|
|
299
|
+
throw new CliError(
|
|
300
|
+
`Unknown org git subcommand: ${sub ?? "(none)"}. Try: status, connect, sync, disconnect`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -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) => {
|
|
@@ -110,6 +125,22 @@ function intPositional(index, label) {
|
|
|
110
125
|
};
|
|
111
126
|
}
|
|
112
127
|
|
|
128
|
+
/**
|
|
129
|
+
* A mail message ref: an IMAP UID (sent as a number) or a Gmail message id
|
|
130
|
+
* (hex string). An all-digit Gmail id past the safe-integer range stays a string.
|
|
131
|
+
*/
|
|
132
|
+
function messageRefPositional(index, label) {
|
|
133
|
+
return {
|
|
134
|
+
value: (_flags, positionals) => {
|
|
135
|
+
const raw = positionals[index];
|
|
136
|
+
if (raw === undefined) throw new CliError(`Missing required <${label}>`);
|
|
137
|
+
if (/^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))) return Number(raw);
|
|
138
|
+
if (/^[0-9a-f]{1,32}$/i.test(raw)) return raw;
|
|
139
|
+
throw new CliError(`<${label}> must be a message uid or Gmail message id (got ${raw})`);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
const SUPPRESSION_REASONS = ["unsubscribe", "bounce", "complaint", "manual"];
|
|
114
145
|
const MAILER_FOLDERS = ["inbox", "sent"];
|
|
115
146
|
|
|
@@ -131,6 +162,7 @@ export const ARGS = {
|
|
|
131
162
|
mailer_ids: mailerIds,
|
|
132
163
|
...SEQUENCE_RAIL_ARGS,
|
|
133
164
|
...SEQUENCE_WINDOW_ARGS,
|
|
165
|
+
enroll_on_cake: enrollOnCake,
|
|
134
166
|
},
|
|
135
167
|
update_sequence: {
|
|
136
168
|
sequence_id: { positional: 0, label: "sequence-id", required: true },
|
|
@@ -140,7 +172,15 @@ export const ARGS = {
|
|
|
140
172
|
mailer_ids: mailerIds,
|
|
141
173
|
...SEQUENCE_RAIL_ARGS,
|
|
142
174
|
...SEQUENCE_WINDOW_ARGS,
|
|
175
|
+
enroll_on_cake: enrollOnCake,
|
|
143
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: {},
|
|
144
184
|
enroll_in_sequence: {
|
|
145
185
|
sequence_id: { positional: 0, label: "sequence-id", required: true },
|
|
146
186
|
recipients: { value: readRecipients },
|
|
@@ -275,7 +315,7 @@ export const ARGS = {
|
|
|
275
315
|
},
|
|
276
316
|
get_mailer_message: {
|
|
277
317
|
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
278
|
-
uid:
|
|
318
|
+
uid: messageRefPositional(1, "uid"),
|
|
279
319
|
folder: { flag: "folder", required: true, enum: MAILER_FOLDERS },
|
|
280
320
|
},
|
|
281
321
|
set_mailer_forwarding: {
|
|
@@ -341,7 +381,7 @@ async function run(ctx, tool, message) {
|
|
|
341
381
|
return 0;
|
|
342
382
|
}
|
|
343
383
|
|
|
344
|
-
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>`;
|
|
345
385
|
const DISPATCHES_USAGE = `Usage: dispatches <list|create|rename|delete>`;
|
|
346
386
|
|
|
347
387
|
// Dispatches: the named review desks a sequence's review stage queues to.
|
|
@@ -409,6 +449,19 @@ export async function cmdSequences(ctx) {
|
|
|
409
449
|
return sequencesReview({ ...rest, positionals: rest.positionals.slice(1) }, rest.positionals[0]);
|
|
410
450
|
case "links":
|
|
411
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
|
+
}
|
|
412
465
|
default:
|
|
413
466
|
throw new CliError(`Unknown sequences subcommand: ${sub ?? "(none)"}. ${SEQUENCES_USAGE}`);
|
|
414
467
|
}
|
package/lib/commands/targets.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function readOffering(flags) {
|
|
|
26
26
|
const offering = { value: readOffering };
|
|
27
27
|
|
|
28
28
|
// `--priority high|normal|low|default`: how much the target's questions count in share of
|
|
29
|
-
// voice (high =
|
|
29
|
+
// voice (high = 3 votes, normal = 2, low = 1); default = back to high, a target's default.
|
|
30
30
|
function readPriority(flags) {
|
|
31
31
|
const v = flagStr(flags.priority);
|
|
32
32
|
if (v === undefined) {
|