@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 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.84.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
@@ -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 { result } = await client.callTool(
217
- "apply_org_config",
218
- argsFromFlags(ARGS.apply_org_config, flags, ctx.positionals)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.84.0",
3
+ "version": "0.86.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",