@letterstory/cli 0.19.0 → 0.23.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 +40 -0
- package/lib/cli.mjs +56 -13
- package/lib/commands/collections.mjs +115 -53
- package/lib/commands/connectors.mjs +69 -51
- package/lib/commands/kernel.mjs +101 -30
- package/lib/commands/onboard.mjs +329 -103
- package/lib/commands/shared.mjs +184 -0
- package/lib/commands/shredder.mjs +31 -29
- package/lib/commands/strategy.mjs +79 -54
- package/package.json +1 -1
package/lib/commands/onboard.mjs
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
// so to use a name other than the inferred one, pass --name BEFORE applying. The first
|
|
18
18
|
// posts are written by background jobs (20-40min each); they're reported as job ids and
|
|
19
19
|
// this command does not block on them — poll with `phantom-job status <job-id>`.
|
|
20
|
+
//
|
|
21
|
+
// `--accept-plan <path|->` closes the review loop: the apply PINS the plan instead of
|
|
22
|
+
// recomputing it, so the masthead that was reviewed is the masthead that is committed. In one
|
|
23
|
+
// command the dry run's plan is carried into the apply automatically; the flag is for the
|
|
24
|
+
// two-command shape (`onboard acme.com --json > plan.json`, read it, then `onboard acme.com
|
|
25
|
+
// --accept-plan plan.json`), which is what an agent or a script actually does.
|
|
20
26
|
|
|
21
27
|
// `onboard domain` is the ONE-SHOT parent: a bare company domain in (the org's registered
|
|
22
28
|
// domain, from the API key), a fully-configured organization out — the main blog onboarded
|
|
@@ -46,18 +52,119 @@
|
|
|
46
52
|
import { CliError } from "../client.mjs";
|
|
47
53
|
import {
|
|
48
54
|
flagStr,
|
|
49
|
-
flagNum,
|
|
50
55
|
flagBool,
|
|
51
56
|
requirePositional,
|
|
52
57
|
printResult,
|
|
58
|
+
refuseUnknownFlags,
|
|
53
59
|
compact,
|
|
54
60
|
ok,
|
|
55
61
|
pollUntil,
|
|
56
62
|
readTextInput,
|
|
63
|
+
argsFromFlags,
|
|
57
64
|
} from "./shared.mjs";
|
|
58
65
|
|
|
59
66
|
const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
|
|
60
67
|
const DOMAIN_MODES = ["subdomain", "buy_now", "connect_later"];
|
|
68
|
+
const SEER_MODES = ["suggest", "skip"];
|
|
69
|
+
const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
|
|
70
|
+
const APPROVAL_MODES = ["autopilot", "review_window", "manual"];
|
|
71
|
+
|
|
72
|
+
// How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
|
|
73
|
+
// `scripts/parity-check.ts` compares these keys against each tool's published input schema.
|
|
74
|
+
export const ARGS = {
|
|
75
|
+
// The router read. It takes no arguments at all — the org comes from the API
|
|
76
|
+
// key — but it still declares an entry, because the parity ratchet's rule is
|
|
77
|
+
// that a curated command's tool is DECLARED, not that it has flags. An empty
|
|
78
|
+
// map says "no inputs" explicitly, where a missing one says "nobody checked".
|
|
79
|
+
get_onboarding_plan: {},
|
|
80
|
+
onboard_collection: {
|
|
81
|
+
collection_id: "collection",
|
|
82
|
+
collection_name: "name",
|
|
83
|
+
direction: ["about", "direction"],
|
|
84
|
+
sitemap_url: "sitemap",
|
|
85
|
+
// Source the existing blog from a connected CMS instead of a sitemap.
|
|
86
|
+
cms_provider: { flag: "cms", enum: CMS_PROVIDERS },
|
|
87
|
+
// Planner activation (#1731): the same one-call activation the app's Guided review
|
|
88
|
+
// step performs. Off unless asked for; needs a user-scoped key (an acting user).
|
|
89
|
+
enable_planner: { flag: "enable-planner", type: "bool" },
|
|
90
|
+
cadence_per_week: { flag: "cadence", type: "num" },
|
|
91
|
+
approval_mode: { flag: "approval", enum: APPROVAL_MODES },
|
|
92
|
+
},
|
|
93
|
+
get_collection_onboard_status: {
|
|
94
|
+
job_id: { internal: "the job id onboard_collection just returned" },
|
|
95
|
+
},
|
|
96
|
+
sync_collection_sitemap: {
|
|
97
|
+
collection_id: "collection",
|
|
98
|
+
},
|
|
99
|
+
onboard_domain: {
|
|
100
|
+
direction: ["about", "direction"],
|
|
101
|
+
sitemap_url: "sitemap",
|
|
102
|
+
cms_provider: { flag: "cms", enum: CMS_PROVIDERS },
|
|
103
|
+
collection_name: "name",
|
|
104
|
+
phantom: {
|
|
105
|
+
object: {
|
|
106
|
+
enabled: { flag: "no-phantom", type: "bool", invert: true },
|
|
107
|
+
posts_to_seed: { flag: "posts", type: "num" },
|
|
108
|
+
name: "phantom-name",
|
|
109
|
+
site_domain: "site-domain",
|
|
110
|
+
domain_mode: { flag: "domain-mode", enum: DOMAIN_MODES },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
seers: { flag: "seers", enum: SEER_MODES },
|
|
114
|
+
visuals: { flag: "no-visuals", type: "bool", invert: true },
|
|
115
|
+
enhance: { flag: "no-enhance", type: "bool", invert: true },
|
|
116
|
+
is_test: { flag: "test", type: "bool" },
|
|
117
|
+
readiness_minutes: { flag: "readiness-minutes", type: "num" },
|
|
118
|
+
client_email: "client-email",
|
|
119
|
+
},
|
|
120
|
+
get_domain_onboard_status: {
|
|
121
|
+
job_id: "attach",
|
|
122
|
+
},
|
|
123
|
+
onboard_phantom: {
|
|
124
|
+
domain: { positional: 0, label: "domain", required: true },
|
|
125
|
+
// --accept gates the two-call flow below (dry run, then apply) rather than being
|
|
126
|
+
// forwarded: the first call never sends it, the second always sends true.
|
|
127
|
+
accept: { internal: "false on the dry run, true on apply — decided by the command" },
|
|
128
|
+
// The reviewed plan, pinned so the apply does not recompute the masthead the user
|
|
129
|
+
// just approved (#1773 / C2). Like `accept`, the command decides it rather than a
|
|
130
|
+
// flag mapping straight through: it comes from `--accept-plan <path|->` /
|
|
131
|
+
// `--accept-plan-json`, or from the dry run this same command just printed.
|
|
132
|
+
plan: { internal: "the reviewed plan — from --accept-plan, or the dry run just printed" },
|
|
133
|
+
overrides: {
|
|
134
|
+
object: {
|
|
135
|
+
name: "name",
|
|
136
|
+
cadence_per_week: { flag: "cadence", type: "num" },
|
|
137
|
+
cover_canvas: { flag: "cover", enum: COVER_STYLES },
|
|
138
|
+
theme: "theme",
|
|
139
|
+
posts_to_seed: { flag: "posts", type: "num" },
|
|
140
|
+
// The highest-leverage override: it becomes the planner's steering prompt, so it
|
|
141
|
+
// shapes the topic map and every post, not just the masthead. A chosen topic
|
|
142
|
+
// plan (--plan-file) supplies it when no --about is given.
|
|
143
|
+
direction: {
|
|
144
|
+
value: (flags, _p, extra) =>
|
|
145
|
+
flagStr(flags.about) ?? flagStr(flags.direction) ?? extra.plan?.direction,
|
|
146
|
+
},
|
|
147
|
+
// The approved plan's content areas become the topic map's pillars VERBATIM.
|
|
148
|
+
pillars: { value: (_f, _p, extra) => extra.plan?.pillars },
|
|
149
|
+
site_domain: "domain",
|
|
150
|
+
domain_mode: { flag: "domain-mode", enum: DOMAIN_MODES },
|
|
151
|
+
is_test: { flag: "real", type: "bool", invert: true },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
get_phantom_onboard_status: {
|
|
156
|
+
job_id: { internal: "the job id onboard_phantom just returned" },
|
|
157
|
+
},
|
|
158
|
+
propose_phantom_plan: {
|
|
159
|
+
client_domain: { positional: 0, label: "client-domain" },
|
|
160
|
+
direction: ["about", "direction"],
|
|
161
|
+
name: "name",
|
|
162
|
+
},
|
|
163
|
+
propose_phantom_mastheads: {
|
|
164
|
+
client_domain: { positional: 0, label: "client-domain", required: true },
|
|
165
|
+
plan: { value: (_f, _p, extra) => extra.plan },
|
|
166
|
+
},
|
|
167
|
+
};
|
|
61
168
|
|
|
62
169
|
function formatPlan(plan) {
|
|
63
170
|
const lines = [
|
|
@@ -126,6 +233,38 @@ export function readTopicPlan(flags) {
|
|
|
126
233
|
return doc;
|
|
127
234
|
}
|
|
128
235
|
|
|
236
|
+
// The plan a caller already reviewed, handed back to be applied VERBATIM: `--accept-plan
|
|
237
|
+
// <path|->` (or `--accept-plan-json '<json>'`). Accepts either the plan object itself or a
|
|
238
|
+
// saved dry-run response, whose `plan` key holds it — so `onboard <domain> --json > plan.json`
|
|
239
|
+
// then `onboard <domain> --accept-plan plan.json` is the whole review-then-accept loop.
|
|
240
|
+
//
|
|
241
|
+
// Why it exists: without a pinned plan the apply RE-PLANS — a second homepage read and a
|
|
242
|
+
// second masthead proposal, which can answer differently than the one just approved, and
|
|
243
|
+
// that name is committed into the URL when the site is created.
|
|
244
|
+
export function readAcceptedPlan(flags) {
|
|
245
|
+
const raw = readTextInput(flags, "accept-plan-json", "accept-plan");
|
|
246
|
+
if (raw === undefined) return undefined;
|
|
247
|
+
let doc;
|
|
248
|
+
try {
|
|
249
|
+
doc = JSON.parse(raw);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
throw new CliError(`--accept-plan is not valid JSON: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
// A saved dry-run response wraps the plan; the plan itself does not.
|
|
254
|
+
const plan = doc && typeof doc === "object" && doc.plan && typeof doc.plan === "object" ? doc.plan : doc;
|
|
255
|
+
if (!plan || typeof plan !== "object" || typeof plan.description !== "string" || !plan.defaults) {
|
|
256
|
+
throw new CliError(
|
|
257
|
+
"--accept-plan must be the `plan` object from an onboard dry run (or the whole saved --json response)."
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (!plan.name) {
|
|
261
|
+
throw new CliError(
|
|
262
|
+
"That plan has no masthead name, and the name is committed at create. Re-run the dry run with --name <MastheadName> and accept the plan it returns."
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return plan;
|
|
266
|
+
}
|
|
267
|
+
|
|
129
268
|
// The recipe's spelling under this binary, for next-step hints.
|
|
130
269
|
function recipeName(ctx) {
|
|
131
270
|
return ctx.bin === "phantom" ? "onboard" : "phantom-onboard";
|
|
@@ -191,11 +330,7 @@ function formatMastheads(r, plan) {
|
|
|
191
330
|
// because the tool falls back to the org's own recorded domain (the key's org IS the client).
|
|
192
331
|
async function cmdOnboardPropose(ctx) {
|
|
193
332
|
const { client, flags, io } = ctx;
|
|
194
|
-
const args =
|
|
195
|
-
client_domain: ctx.positionals[0],
|
|
196
|
-
direction: flagStr(flags.about) ?? flagStr(flags.direction),
|
|
197
|
-
name: flagStr(flags.name),
|
|
198
|
-
});
|
|
333
|
+
const args = argsFromFlags(ARGS.propose_phantom_plan, flags, ctx.positionals);
|
|
199
334
|
ok(ctx, "Proposing…");
|
|
200
335
|
const proposal = await client.callTool("propose_phantom_plan", args);
|
|
201
336
|
printResult(io, flags, proposal, formatProposal);
|
|
@@ -219,7 +354,10 @@ async function cmdOnboardMastheads(ctx) {
|
|
|
219
354
|
);
|
|
220
355
|
}
|
|
221
356
|
ok(ctx, `Proposing mastheads for "${plan.title}"…`);
|
|
222
|
-
const result = await client.callTool(
|
|
357
|
+
const result = await client.callTool(
|
|
358
|
+
"propose_phantom_mastheads",
|
|
359
|
+
argsFromFlags(ARGS.propose_phantom_mastheads, flags, ctx.positionals, { plan })
|
|
360
|
+
);
|
|
223
361
|
printResult(io, flags, result, (r) => formatMastheads(r, plan));
|
|
224
362
|
if (result.options?.length) {
|
|
225
363
|
ok(
|
|
@@ -234,35 +372,13 @@ async function cmdOnboardMastheads(ctx) {
|
|
|
234
372
|
function overridesFromFlags(flags) {
|
|
235
373
|
// A chosen topic plan pins direction + pillars; an explicit --about still wins on direction.
|
|
236
374
|
const plan = readTopicPlan(flags);
|
|
237
|
-
const
|
|
238
|
-
if (cover !== undefined && !COVER_STYLES.includes(cover)) {
|
|
239
|
-
throw new CliError(`--cover must be one of: ${COVER_STYLES.join(", ")}`);
|
|
240
|
-
}
|
|
241
|
-
const domainMode = flagStr(flags["domain-mode"]);
|
|
242
|
-
if (domainMode !== undefined && !DOMAIN_MODES.includes(domainMode)) {
|
|
243
|
-
throw new CliError(`--domain-mode must be one of: ${DOMAIN_MODES.join(", ")}`);
|
|
244
|
-
}
|
|
245
|
-
const siteDomain = flagStr(flags.domain);
|
|
375
|
+
const overrides = argsFromFlags(ARGS.onboard_phantom.overrides.object, flags, [], { plan });
|
|
246
376
|
// buy_now spends real money against a 2-per-org cap; refusing the mismatch here beats
|
|
247
377
|
// silently downgrading it to a subdomain launch the caller didn't ask for.
|
|
248
|
-
if (
|
|
249
|
-
throw new CliError(`--domain-mode ${
|
|
250
|
-
}
|
|
251
|
-
return
|
|
252
|
-
name: flagStr(flags.name),
|
|
253
|
-
cadence_per_week: flagNum(flags.cadence),
|
|
254
|
-
cover_canvas: cover,
|
|
255
|
-
theme: flagStr(flags.theme),
|
|
256
|
-
posts_to_seed: flagNum(flags.posts),
|
|
257
|
-
// The highest-leverage override: it becomes the planner's steering prompt, so it
|
|
258
|
-
// shapes the topic map and every post, not just the masthead.
|
|
259
|
-
direction: flagStr(flags.about) ?? flagStr(flags.direction) ?? plan?.direction,
|
|
260
|
-
// The approved plan's content areas become the topic map's pillars VERBATIM.
|
|
261
|
-
pillars: plan?.pillars,
|
|
262
|
-
site_domain: siteDomain,
|
|
263
|
-
domain_mode: domainMode,
|
|
264
|
-
is_test: flagBool(flags["real"]) ? false : undefined,
|
|
265
|
-
});
|
|
378
|
+
if (overrides.domain_mode && overrides.domain_mode !== "subdomain" && !overrides.site_domain) {
|
|
379
|
+
throw new CliError(`--domain-mode ${overrides.domain_mode} needs --domain <bare-domain>`);
|
|
380
|
+
}
|
|
381
|
+
return overrides;
|
|
266
382
|
}
|
|
267
383
|
|
|
268
384
|
// Final readback of a collection-onboarding job — the counts the engine PERSISTED.
|
|
@@ -282,7 +398,9 @@ function formatCollectionStatus(s) {
|
|
|
282
398
|
` series: ${r.series ?? 0}`,
|
|
283
399
|
` open topics: ${r.spokes_open ?? 0} (${r.spokes_seo_bound ?? 0} SEO-bound)`,
|
|
284
400
|
` headlines suggested: ${r.headlines_suggested ?? 0}`,
|
|
285
|
-
|
|
401
|
+
s.enable_planner
|
|
402
|
+
? ` Planner: ON — ${s.cadence_per_week ?? "?"}/week, ${s.approval_mode ?? "manual"} approval, queue filled.`
|
|
403
|
+
: ` Review them in Headlines & Queue — the Planner is configured but OFF (pass --enable-planner to turn it on).`
|
|
286
404
|
);
|
|
287
405
|
}
|
|
288
406
|
return lines.join("\n");
|
|
@@ -302,14 +420,38 @@ async function cmdOnboardCollection(ctx) {
|
|
|
302
420
|
);
|
|
303
421
|
}
|
|
304
422
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
423
|
+
// READ PATHS FIRST, before anything that writes. `--status` and `--attach` mean here
|
|
424
|
+
// exactly what they mean on `onboard domain`: show me the job, do not start one.
|
|
425
|
+
// They had no branch at all, so they fell through to onboard_collection and STARTED A
|
|
426
|
+
// SECOND ONBOARDING — a read verb that spent money and left a duplicate collection.
|
|
427
|
+
const attach = flagStr(flags.attach);
|
|
428
|
+
if (attach) {
|
|
429
|
+
const s = await client.callTool("get_collection_onboard_status", { job_id: attach });
|
|
430
|
+
printResult(ctx.io, flags, s, formatCollectionStatus);
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
if (flagBool(flags.status)) {
|
|
434
|
+
const collectionId = flagStr(flags.collection);
|
|
435
|
+
const s = await client.callTool(
|
|
436
|
+
"get_collection_onboard_status",
|
|
437
|
+
collectionId ? { collection_id: collectionId } : {}
|
|
438
|
+
);
|
|
439
|
+
printResult(ctx.io, flags, s, formatCollectionStatus);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Everything below WRITES. A flag we do not understand is refused rather than ignored:
|
|
444
|
+
// the ARGS map that builds the call is the list of what may be passed to it.
|
|
445
|
+
refuseUnknownFlags(ctx, ARGS.onboard_collection, ["status", "attach", "watch", "no-wait", "domain"]);
|
|
446
|
+
|
|
447
|
+
const args = argsFromFlags(ARGS.onboard_collection, flags, ctx.positionals);
|
|
448
|
+
// --cadence / --approval only mean something once the Planner is being turned on; a
|
|
449
|
+
// silently ignored flag is exactly the kind of invisible opinion this lane exists to remove.
|
|
450
|
+
if ((args.cadence_per_week !== undefined || args.approval_mode !== undefined) && !args.enable_planner) {
|
|
451
|
+
throw new CliError(
|
|
452
|
+
"--cadence and --approval only apply with --enable-planner (the Planner stays OFF otherwise)."
|
|
453
|
+
);
|
|
454
|
+
}
|
|
313
455
|
|
|
314
456
|
ok(ctx, "Starting collection onboarding…");
|
|
315
457
|
const started = await client.callTool("onboard_collection", args);
|
|
@@ -357,13 +499,13 @@ async function cmdOnboardCollection(ctx) {
|
|
|
357
499
|
// bounded per run, so it returns synchronously.
|
|
358
500
|
async function cmdOnboardSyncSitemap(ctx) {
|
|
359
501
|
const { client, flags } = ctx;
|
|
360
|
-
const
|
|
361
|
-
if (!
|
|
502
|
+
const args = argsFromFlags(ARGS.sync_collection_sitemap, flags, ctx.positionals);
|
|
503
|
+
if (!args.collection_id) {
|
|
362
504
|
throw new CliError("onboard sync-sitemap requires --collection <uuid>.");
|
|
363
505
|
}
|
|
364
506
|
|
|
365
507
|
ok(ctx, "Re-checking the sitemap…");
|
|
366
|
-
const result = await client.callTool("sync_collection_sitemap",
|
|
508
|
+
const result = await client.callTool("sync_collection_sitemap", args);
|
|
367
509
|
|
|
368
510
|
printResult(ctx.io, flags, result, (r) =>
|
|
369
511
|
[
|
|
@@ -383,9 +525,6 @@ async function cmdOnboardSyncSitemap(ctx) {
|
|
|
383
525
|
return 0;
|
|
384
526
|
}
|
|
385
527
|
|
|
386
|
-
const SEER_MODES = ["suggest", "skip"];
|
|
387
|
-
const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
|
|
388
|
-
|
|
389
528
|
// Final readback of a domain-onboarding job — the counts the engine PERSISTED.
|
|
390
529
|
export function formatDomainStatus(s) {
|
|
391
530
|
const r = s.result ?? {};
|
|
@@ -474,35 +613,7 @@ async function cmdOnboardDomain(ctx) {
|
|
|
474
613
|
return 0;
|
|
475
614
|
}
|
|
476
615
|
|
|
477
|
-
const
|
|
478
|
-
if (seers !== undefined && !SEER_MODES.includes(seers)) {
|
|
479
|
-
throw new CliError(`--seers must be one of: ${SEER_MODES.join(", ")}`);
|
|
480
|
-
}
|
|
481
|
-
const cms = flagStr(flags.cms);
|
|
482
|
-
if (cms !== undefined && !CMS_PROVIDERS.includes(cms)) {
|
|
483
|
-
throw new CliError(`--cms must be one of: ${CMS_PROVIDERS.join(", ")}`);
|
|
484
|
-
}
|
|
485
|
-
const posts = flagNum(flags.posts);
|
|
486
|
-
const phantom = compact({
|
|
487
|
-
enabled: flagBool(flags["no-phantom"]) ? false : undefined,
|
|
488
|
-
posts_to_seed: posts,
|
|
489
|
-
name: flagStr(flags["phantom-name"]),
|
|
490
|
-
site_domain: flagStr(flags["site-domain"]),
|
|
491
|
-
domain_mode: flagStr(flags["domain-mode"]),
|
|
492
|
-
});
|
|
493
|
-
const args = compact({
|
|
494
|
-
direction: flagStr(flags.about) ?? flagStr(flags.direction),
|
|
495
|
-
sitemap_url: flagStr(flags.sitemap),
|
|
496
|
-
cms_provider: cms,
|
|
497
|
-
collection_name: flagStr(flags.name),
|
|
498
|
-
phantom: Object.keys(phantom).length ? phantom : undefined,
|
|
499
|
-
seers,
|
|
500
|
-
visuals: flagBool(flags["no-visuals"]) ? false : undefined,
|
|
501
|
-
enhance: flagBool(flags["no-enhance"]) ? false : undefined,
|
|
502
|
-
is_test: flagBool(flags.test) ? true : undefined,
|
|
503
|
-
readiness_minutes: flagNum(flags["readiness-minutes"]),
|
|
504
|
-
client_email: flagStr(flags["client-email"]),
|
|
505
|
-
});
|
|
616
|
+
const args = argsFromFlags(ARGS.onboard_domain, flags, ctx.positionals);
|
|
506
617
|
|
|
507
618
|
ok(ctx, "Starting domain onboarding…");
|
|
508
619
|
const started = await client.callTool("onboard_domain", args);
|
|
@@ -525,8 +636,108 @@ async function cmdOnboardDomain(ctx) {
|
|
|
525
636
|
return watchDomainJob(ctx, started.job_id);
|
|
526
637
|
}
|
|
527
638
|
|
|
639
|
+
// `onboard plan` — what does this organization still need? One read of get_onboarding_plan
|
|
640
|
+
// (the router), printed as a table. `onboard next` prints the first ready step's exact CLI
|
|
641
|
+
// invocation with what the facts already determine (the main collection's id) filled in.
|
|
642
|
+
// Neither decides anything: an optional step is listed, never chosen.
|
|
643
|
+
const STATUS_ORDER = ["ready", "running", "blocked", "done"];
|
|
644
|
+
|
|
645
|
+
function formatPlanTable(plan) {
|
|
646
|
+
const rows = plan.steps.map((s) => [
|
|
647
|
+
s.id,
|
|
648
|
+
s.status,
|
|
649
|
+
s.disposition,
|
|
650
|
+
s.blocked_by.length
|
|
651
|
+
? s.blocked_by.join(", ")
|
|
652
|
+
: s.missing.length && s.status === "done"
|
|
653
|
+
? `(${s.missing.join(", ")})`
|
|
654
|
+
: "",
|
|
655
|
+
s.cli,
|
|
656
|
+
]);
|
|
657
|
+
const head = ["step", "status", "opinion", "blocked by", "run"];
|
|
658
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
|
|
659
|
+
const line = (cells) =>
|
|
660
|
+
cells
|
|
661
|
+
.map((c, i) => String(c).padEnd(widths[i]))
|
|
662
|
+
.join(" ")
|
|
663
|
+
.trimEnd();
|
|
664
|
+
const lines = [line(head), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)];
|
|
665
|
+
lines.push("");
|
|
666
|
+
lines.push(
|
|
667
|
+
plan.next
|
|
668
|
+
? `Next: ${plan.next} — ${plan.steps.find((s) => s.id === plan.next)?.why ?? ""}`
|
|
669
|
+
: plan.steps.every((s) => s.status === "done")
|
|
670
|
+
? "Nothing left: every step is done."
|
|
671
|
+
: "Nothing is ready to run — see the blocked-by column."
|
|
672
|
+
);
|
|
673
|
+
if (plan.optional_ready?.length) lines.push(`Optional and ready (needs a yes): ${plan.optional_ready.join(", ")}`);
|
|
674
|
+
return lines.join("\n");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function cliInvocation(ctx, step) {
|
|
678
|
+
const hint = Object.entries(step.args_hint ?? {});
|
|
679
|
+
// The generic door: `tool <name> --arg k=v` takes the schema's own keys.
|
|
680
|
+
if (/^tool /.test(step.cli)) {
|
|
681
|
+
const args = hint.map(([k, v]) => ` --arg ${k}=${v}`).join("");
|
|
682
|
+
return `${ctx.bin} ${step.cli}${args}`;
|
|
683
|
+
}
|
|
684
|
+
// A curated command: the main collection's id is `--collection` on every one that takes it.
|
|
685
|
+
const collection = step.args_hint?.collection_id ? ` --collection ${step.args_hint.collection_id}` : "";
|
|
686
|
+
return `${ctx.bin} ${step.cli}${collection}`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function cmdOnboardPlan(ctx) {
|
|
690
|
+
const plan = await ctx.client.callTool("get_onboarding_plan", {});
|
|
691
|
+
if (!Array.isArray(plan.steps)) throw new CliError("get_onboarding_plan did not return steps.");
|
|
692
|
+
printResult(ctx.io, ctx.flags, plan, formatPlanTable);
|
|
693
|
+
return 0;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function cmdOnboardNext(ctx) {
|
|
697
|
+
const plan = await ctx.client.callTool("get_onboarding_plan", {});
|
|
698
|
+
if (!Array.isArray(plan.steps)) throw new CliError("get_onboarding_plan did not return steps.");
|
|
699
|
+
const next = plan.next ? plan.steps.find((s) => s.id === plan.next) : null;
|
|
700
|
+
if (!next) {
|
|
701
|
+
const running = plan.steps.find((s) => s.status === "running");
|
|
702
|
+
if (running) {
|
|
703
|
+
printResult(ctx.io, ctx.flags, running, (s) => `Waiting on "${s.id}": ${s.why}`);
|
|
704
|
+
return 0;
|
|
705
|
+
}
|
|
706
|
+
const optional = (plan.optional_ready ?? []).map((id) => plan.steps.find((s) => s.id === id)).filter(Boolean);
|
|
707
|
+
printResult(ctx.io, ctx.flags, plan, () =>
|
|
708
|
+
[
|
|
709
|
+
plan.steps.every((s) => s.status === "done")
|
|
710
|
+
? "Nothing left: every step is done."
|
|
711
|
+
: "Nothing is ready to run.",
|
|
712
|
+
...optional.map((s) => `Optional (needs a yes): ${s.id} — ${cliInvocation(ctx, s)}`),
|
|
713
|
+
...plan.steps
|
|
714
|
+
.filter((s) => s.status === "blocked")
|
|
715
|
+
.sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status))
|
|
716
|
+
.map((s) => `Blocked: ${s.id} — ${s.why}`),
|
|
717
|
+
].join("\n")
|
|
718
|
+
);
|
|
719
|
+
return 0;
|
|
720
|
+
}
|
|
721
|
+
printResult(ctx.io, ctx.flags, next, (s) =>
|
|
722
|
+
[
|
|
723
|
+
`Next: ${s.title} (${s.disposition})`,
|
|
724
|
+
` ${s.purpose}`,
|
|
725
|
+
s.prepare.length ? ` Read first: ${s.prepare.join(", ")}` : null,
|
|
726
|
+
` Run: ${cliInvocation(ctx, s)}`,
|
|
727
|
+
s.poll ? ` Poll: ${s.poll}` : null,
|
|
728
|
+
` Docs: https://app.letterstory.com${s.docs}`,
|
|
729
|
+
]
|
|
730
|
+
.filter(Boolean)
|
|
731
|
+
.join("\n")
|
|
732
|
+
);
|
|
733
|
+
return 0;
|
|
734
|
+
}
|
|
735
|
+
|
|
528
736
|
export async function cmdOnboard(ctx) {
|
|
529
737
|
const { client, flags, io } = ctx;
|
|
738
|
+
// `onboard plan` / `onboard next` — the router: what does this org still need?
|
|
739
|
+
if (ctx.positionals[0] === "plan") return cmdOnboardPlan(ctx);
|
|
740
|
+
if (ctx.positionals[0] === "next") return cmdOnboardNext(ctx);
|
|
530
741
|
// `onboard collection …` — the client-blog variant (no <domain>; org from the key).
|
|
531
742
|
if (ctx.positionals[0] === "collection") {
|
|
532
743
|
return cmdOnboardCollection({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
@@ -549,39 +760,54 @@ export async function cmdOnboard(ctx) {
|
|
|
549
760
|
const domain = requirePositional(ctx.positionals, 0, "domain");
|
|
550
761
|
const overrides = overridesFromFlags(flags);
|
|
551
762
|
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
);
|
|
557
|
-
ok(ctx, "Onboarding plan:");
|
|
558
|
-
printResult(io, flags, plan, formatPlan);
|
|
763
|
+
// A plan handed in with --accept-plan was reviewed already, and re-running the dry run
|
|
764
|
+
// would re-read the homepage and re-propose a masthead — exactly what pinning prevents.
|
|
765
|
+
// So it also stands in for --accept: you cannot hand back a plan you have not read.
|
|
766
|
+
let reviewedPlan = readAcceptedPlan(flags);
|
|
559
767
|
|
|
560
|
-
if (
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
if (!flagBool(flags.accept) && !overrideGiven) {
|
|
566
|
-
ok(
|
|
567
|
-
ctx,
|
|
568
|
-
"Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust " +
|
|
569
|
-
`(or pick the beat and name first: ${recipeName(ctx)} propose / mastheads). ` +
|
|
570
|
-
"Note: --name is committed at create (it seeds the URL)."
|
|
571
|
-
);
|
|
572
|
-
return 0;
|
|
573
|
-
}
|
|
574
|
-
if (plan.proposed_name === null && flagStr(flags.name) === undefined) {
|
|
575
|
-
throw new CliError(
|
|
576
|
-
"No blog name available: the plan proposed none (strategy soft-failed). Pass --name <MastheadName> to apply."
|
|
768
|
+
if (!reviewedPlan) {
|
|
769
|
+
// Step 1: dry-run plan (accept defaults to false server-side). Persists nothing.
|
|
770
|
+
const dryRun = await client.callTool(
|
|
771
|
+
"onboard_phantom",
|
|
772
|
+
compact({ domain, overrides: Object.keys(overrides).length ? overrides : undefined })
|
|
577
773
|
);
|
|
774
|
+
ok(ctx, "Onboarding plan:");
|
|
775
|
+
printResult(io, flags, dryRun, formatPlan);
|
|
776
|
+
|
|
777
|
+
if (flagBool(flags["dry-run"])) return 0;
|
|
778
|
+
|
|
779
|
+
// Step 2: the review gate. A bare, unaccepted run stops here so nothing is provisioned
|
|
780
|
+
// by accident — re-run with --accept (or an override flag) once happy.
|
|
781
|
+
const overrideGiven = Object.keys(overrides).length > 0;
|
|
782
|
+
if (!flagBool(flags.accept) && !overrideGiven) {
|
|
783
|
+
ok(
|
|
784
|
+
ctx,
|
|
785
|
+
"Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust " +
|
|
786
|
+
`(or pick the beat and name first: ${recipeName(ctx)} propose / mastheads). ` +
|
|
787
|
+
"Note: --name is committed at create (it seeds the URL)."
|
|
788
|
+
);
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
if (dryRun.proposed_name === null && flagStr(flags.name) === undefined) {
|
|
792
|
+
throw new CliError(
|
|
793
|
+
"No blog name available: the plan proposed none (strategy soft-failed). Pass --name <MastheadName> to apply."
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
// Apply the plan that was just printed, not a fresh one: an old server that does not
|
|
797
|
+
// return `plan` leaves this undefined and the job plans for itself, as before.
|
|
798
|
+
reviewedPlan = dryRun.plan;
|
|
578
799
|
}
|
|
579
800
|
|
|
580
801
|
// Step 3: apply — start the durable onboarding job.
|
|
581
802
|
ok(ctx, "Applying — starting the onboarding job…");
|
|
582
803
|
const started = await client.callTool(
|
|
583
804
|
"onboard_phantom",
|
|
584
|
-
compact({
|
|
805
|
+
compact({
|
|
806
|
+
domain,
|
|
807
|
+
accept: true,
|
|
808
|
+
overrides: Object.keys(overrides).length ? overrides : undefined,
|
|
809
|
+
plan: reviewedPlan,
|
|
810
|
+
})
|
|
585
811
|
);
|
|
586
812
|
const jobId = started.job_id;
|
|
587
813
|
if (!jobId) throw new CliError("onboard_phantom did not return a job id.");
|