@letterstory/cli 0.8.0 → 0.10.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 CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  } from "./commands.mjs";
41
41
 
42
42
  // Keep in sync with cli/package.json.
43
- export const VERSION = "0.8.0";
43
+ export const VERSION = "0.10.0";
44
44
 
45
45
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
46
46
  // can't accidentally swallow the id as --json's value.
@@ -254,12 +254,32 @@ Phantom orchestrator:
254
254
  phantom-job status <job-id> Check a job's stage
255
255
 
256
256
  Phantom onboarding recipe (domain -> live phantom blog with first posts):
257
- ${bin === "phantom" ? "onboard" : "phantom-onboard"} <domain> [--name X] [--posts N] [--cadence N]
258
- [--cover editorial_image|full_ai|line_art] [--theme T] [--accept] [--dry-run]
257
+ ${bin === "phantom" ? "onboard" : "phantom-onboard"} <domain> [--about "the beat"] [--name X]
258
+ [--posts N] [--cadence N] [--cover editorial_image|full_ai|line_art] [--theme T]
259
+ [--real] [--domain host] [--domain-mode subdomain|buy_now|connect_later]
260
+ [--accept] [--dry-run]
259
261
  Plan (read-only) then, with --accept
260
262
  or an override flag, provision + configure
261
- + seed. --name is committed at create (it
262
- seeds the URL); pass it before applying.
263
+ + seed. --about becomes the planner's
264
+ steering prompt, so it shapes the topic map
265
+ and every post — use it for a client's
266
+ second phantom. --name is committed at
267
+ create (it seeds the URL); pass it before
268
+ applying. --domain-mode buy_now SPENDS
269
+ money (2/org, $30 cap).
270
+
271
+ Collection onboarding (your OWN blog, on your org's domain — no provisioning):
272
+ ${bin === "phantom" ? "onboard" : "phantom-onboard"} collection [--collection <uuid>] [--name "<collection name>"]
273
+ [--about "the direction"] [--sitemap <url>] [--watch]
274
+ Import the existing blog from its sitemap
275
+ (auto-discovered from the org's domain unless
276
+ --sitemap pins one), derive content areas +
277
+ series, build the topic map, bind SEO demand,
278
+ and stock Headlines & Queue. Omit --collection
279
+ to create a fresh one (--name, default Blog).
280
+ The Planner stays OFF (manual approval).
281
+ --watch polls to completion, printing each
282
+ review note as it lands.
263
283
 
264
284
  Seers (event-driven signals -> drafts):
265
285
  seers list List seers with recent activity
@@ -6,15 +6,30 @@
6
6
  // durable onboarding job server-side and returns a job_id; this command then polls
7
7
  // `get_phantom_onboard_status` until the job is done (provision → configure → seed).
8
8
  //
9
+ // `--about "<the beat>"` is the highest-leverage flag: it becomes the publication's planner
10
+ // steering prompt, so it shapes the topic map and every post the site ever writes — not just
11
+ // the name. Use it when the client already has a phantom and this one needs a different beat.
12
+ //
13
+ // `--domain <host>` records a custom domain; nothing is bought unless you also pass
14
+ // `--domain-mode buy_now`, which SPENDS money against a 2-per-org, $30-per-domain cap.
15
+ //
9
16
  // IMPORTANT: the masthead name is committed when the site is created (it seeds the URL),
10
17
  // so to use a name other than the inferred one, pass --name BEFORE applying. The first
11
18
  // posts are written by background jobs (20-40min each); they're reported as job ids and
12
19
  // this command does not block on them — poll with `phantom-job status <job-id>`.
13
20
 
21
+ // `onboard collection` is the CLIENT-blog sibling: the same recipe minus provisioning,
22
+ // driven by `onboard_collection` + `get_collection_onboard_status`. The org (and its
23
+ // domain) comes from the API key — the job reads the org's registered domain, imports
24
+ // the existing blog from its sitemap, derives content areas + series, builds the topic
25
+ // map, binds SEO demand, and stocks the Headlines tab. `--watch` polls to completion,
26
+ // printing each review note as the job writes it.
27
+
14
28
  import { CliError } from "../client.mjs";
15
29
  import { flagStr, flagNum, flagBool, requirePositional, printResult, compact, ok, pollUntil } from "./shared.mjs";
16
30
 
17
31
  const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
32
+ const DOMAIN_MODES = ["subdomain", "buy_now", "connect_later"];
18
33
 
19
34
  function formatPlan(plan) {
20
35
  const lines = [
@@ -23,6 +38,10 @@ function formatPlan(plan) {
23
38
  ` description: ${plan.description}`,
24
39
  ` defaults: ${plan.defaults.cadence_per_week}/week · ${plan.defaults.cover_canvas} · seed ${plan.defaults.posts_to_seed}`,
25
40
  ];
41
+ if (plan.direction) lines.push(` direction: ${plan.direction}`);
42
+ if (plan.domain_plan?.site_domain) {
43
+ lines.push(` domain: ${plan.domain_plan.site_domain} (${plan.domain_plan.mode})`);
44
+ }
26
45
  if (plan.strategy) {
27
46
  lines.push(` topics: ${(plan.strategy.priority_topics ?? []).slice(0, 5).join(", ") || "(none)"}`);
28
47
  } else {
@@ -54,17 +73,121 @@ function overridesFromFlags(flags) {
54
73
  if (cover !== undefined && !COVER_STYLES.includes(cover)) {
55
74
  throw new CliError(`--cover must be one of: ${COVER_STYLES.join(", ")}`);
56
75
  }
76
+ const domainMode = flagStr(flags["domain-mode"]);
77
+ if (domainMode !== undefined && !DOMAIN_MODES.includes(domainMode)) {
78
+ throw new CliError(`--domain-mode must be one of: ${DOMAIN_MODES.join(", ")}`);
79
+ }
80
+ const siteDomain = flagStr(flags.domain);
81
+ // buy_now spends real money against a 2-per-org cap; refusing the mismatch here beats
82
+ // silently downgrading it to a subdomain launch the caller didn't ask for.
83
+ if (domainMode && domainMode !== "subdomain" && !siteDomain) {
84
+ throw new CliError(`--domain-mode ${domainMode} needs --domain <bare-domain>`);
85
+ }
57
86
  return compact({
58
87
  name: flagStr(flags.name),
59
88
  cadence_per_week: flagNum(flags.cadence),
60
89
  cover_canvas: cover,
61
90
  theme: flagStr(flags.theme),
62
91
  posts_to_seed: flagNum(flags.posts),
92
+ // The highest-leverage override: it becomes the planner's steering prompt, so it
93
+ // shapes the topic map and every post, not just the masthead.
94
+ direction: flagStr(flags.about) ?? flagStr(flags.direction),
95
+ site_domain: siteDomain,
96
+ domain_mode: domainMode,
97
+ is_test: flagBool(flags["real"]) ? false : undefined,
63
98
  });
64
99
  }
65
100
 
101
+ // Final readback of a collection-onboarding job — the counts the engine PERSISTED.
102
+ function formatCollectionStatus(s) {
103
+ const r = s.result ?? {};
104
+ const lines = [
105
+ `Onboarding ${s.status} at stage "${s.stage}"`,
106
+ ` collection: ${s.collection_id}`,
107
+ ` blog found: ${s.has_blog === null ? "(not yet determined)" : s.has_blog ? (s.sitemap_url ?? "yes") : "no — onboarded from positioning"}`,
108
+ ];
109
+ if (s.error) lines.push(` error: ${s.error}`);
110
+ if (s.status === "done") {
111
+ lines.push(
112
+ ` posts imported: ${r.posts_imported ?? 0}`,
113
+ ` content areas (org): ${r.coverage_areas ?? 0}`,
114
+ ` pillars: ${r.pillars ?? 0}`,
115
+ ` series: ${r.series ?? 0}`,
116
+ ` open topics: ${r.spokes_open ?? 0} (${r.spokes_seo_bound ?? 0} SEO-bound)`,
117
+ ` headlines suggested: ${r.headlines_suggested ?? 0}`,
118
+ ` Review them in Headlines & Queue — the Planner is configured but OFF (manual approval).`
119
+ );
120
+ }
121
+ return lines.join("\n");
122
+ }
123
+
124
+ // `onboard collection` — onboard the caller's own (client-brand) blog. The org and its
125
+ // domain come from the API key, so there's no <domain> positional; pin the sitemap with
126
+ // --sitemap when auto-discovery from the org's domain won't find it.
127
+ async function cmdOnboardCollection(ctx) {
128
+ const { client, flags } = ctx;
129
+ // The phantom variant's --domain means "custom site domain to buy/connect"; here the
130
+ // site is the org's own, read from the API key. Refuse rather than silently ignore.
131
+ if (flagStr(flags.domain) !== undefined) {
132
+ throw new CliError(
133
+ "onboard collection uses your organization's registered domain (from the API key). " +
134
+ "To pin where posts import from, pass --sitemap <https://…/sitemap.xml> instead."
135
+ );
136
+ }
137
+
138
+ const args = compact({
139
+ collection_id: flagStr(flags.collection),
140
+ collection_name: flagStr(flags.name),
141
+ direction: flagStr(flags.about) ?? flagStr(flags.direction),
142
+ sitemap_url: flagStr(flags.sitemap),
143
+ });
144
+
145
+ ok(ctx, "Starting collection onboarding…");
146
+ const started = await client.callTool("onboard_collection", args);
147
+ if (!started.job_id) throw new CliError("onboard_collection did not return a job id.");
148
+ ok(ctx, `Job ${started.job_id} running for collection ${started.collection_id}.`);
149
+
150
+ if (!flagBool(flags.watch)) {
151
+ printResult(ctx.io, flags, started, (s) =>
152
+ [
153
+ `Onboarding started (stage "${s.stage}").`,
154
+ ` job: ${s.job_id}`,
155
+ ` collection: ${s.collection_id}`,
156
+ `Watch it with: --watch (re-running is safe), or poll:`,
157
+ ` ${ctx.bin} call get_collection_onboard_status --args '{"job_id":"${s.job_id}"}'`,
158
+ ].join("\n")
159
+ );
160
+ return 0;
161
+ }
162
+
163
+ // Watch: poll to a terminal state, printing each review note as the job writes it.
164
+ let printed = 0;
165
+ const final = await pollUntil(
166
+ ctx.io,
167
+ async () => {
168
+ const s = await client.callTool("get_collection_onboard_status", { job_id: started.job_id });
169
+ const review = Array.isArray(s.review) ? s.review : [];
170
+ for (; printed < review.length; printed++) ok(ctx, ` … ${review[printed]}`);
171
+ if (s.status === "failed") {
172
+ throw new CliError(
173
+ `Onboarding failed at stage "${s.stage}": ${s.error ?? "unknown error"} (re-running the command retries — the job is idempotent).`
174
+ );
175
+ }
176
+ return { done: s.status !== "running", value: s };
177
+ },
178
+ { label: "collection onboarding to finish", timeoutMs: 30 * 60 * 1000, intervalMs: 5000 }
179
+ );
180
+
181
+ printResult(ctx.io, flags, final, formatCollectionStatus);
182
+ return 0;
183
+ }
184
+
66
185
  export async function cmdOnboard(ctx) {
67
186
  const { client, flags, io } = ctx;
187
+ // `onboard collection …` — the client-blog variant (no <domain>; org from the key).
188
+ if (ctx.positionals[0] === "collection") {
189
+ return cmdOnboardCollection({ ...ctx, positionals: ctx.positionals.slice(1) });
190
+ }
68
191
  const domain = requirePositional(ctx.positionals, 0, "domain");
69
192
  const overrides = overridesFromFlags(flags);
70
193
 
@@ -84,7 +207,7 @@ export async function cmdOnboard(ctx) {
84
207
  if (!flagBool(flags.accept) && !overrideGiven) {
85
208
  ok(
86
209
  ctx,
87
- "Re-run with --accept to apply, or pass --name/--cadence/--cover/--posts/--theme to adjust. " +
210
+ "Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust. " +
88
211
  "Note: --name is committed at create (it seeds the URL)."
89
212
  );
90
213
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",