@letterstory/cli 0.1.1 → 0.2.1

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.
@@ -0,0 +1,125 @@
1
+ // Auth / config: login, logout, config, whoami. `letterstory login` (no --key)
2
+ // opens the browser for OAuth 2.1 + PKCE; `--key <ls_…>` keeps the static-key
3
+ // path for CI/automation, where there's no browser to open. Both save to the
4
+ // same config file, but under different keys (`oauth` vs `key`) so
5
+ // resolveConfig() can tell them apart. cmdWhoami mirrors phantomstory-cli's
6
+ // `phantom whoami` (alias `status`) — verify the resolved credential by
7
+ // calling get_company_info, same probe `login` uses to verify a fresh key.
8
+
9
+ import { LetterstoryClient, resolveConfig, writeConfigFile, clearConfigFile, configPath } from "../client.mjs";
10
+ import { flagStr } from "./shared.mjs";
11
+ import { browserLogin, revokeToken } from "../oauth.mjs";
12
+
13
+ export async function cmdLogin(ctx) {
14
+ const { flags, io } = ctx;
15
+ const url = flagStr(flags.url) || resolveConfig({}).url;
16
+ const key = flagStr(flags.key);
17
+
18
+ if (key) {
19
+ if (!key.startsWith("ls_") && !key.startsWith("lb_")) {
20
+ io.error(
21
+ "Warning: Letterstory API keys usually start with 'ls_' (legacy keys start with 'lb_'). Saving anyway."
22
+ );
23
+ }
24
+ const saved = { key, ...(flagStr(flags.url) ? { url } : {}) };
25
+ const path = writeConfigFile(saved);
26
+ io.log(`Saved credentials to ${path}`);
27
+
28
+ // Verify the key works (and report the tool count) without failing the save if
29
+ // the network is down — a saved-but-unverified key is still usable later.
30
+ try {
31
+ const resolved = resolveConfig({ url, key });
32
+ const client = new LetterstoryClient({ url: resolved.url, key: resolved.key });
33
+ const tools = await client.listTools();
34
+ io.log(`Authenticated to ${resolved.url} — ${tools.length} tools available.`);
35
+ return 0;
36
+ } catch (err) {
37
+ io.error(`Saved, but could not verify the key: ${err.message}`);
38
+ return 0;
39
+ }
40
+ }
41
+
42
+ const tokens = await browserLogin({ url, io });
43
+ const oauth = {
44
+ access_token: tokens.access_token,
45
+ refresh_token: tokens.refresh_token,
46
+ expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
47
+ scope: tokens.scope,
48
+ };
49
+ const saved = { oauth, ...(flagStr(flags.url) ? { url } : {}) };
50
+ const path = writeConfigFile(saved);
51
+ io.log(`Saved credentials to ${path}`);
52
+
53
+ try {
54
+ const client = new LetterstoryClient({ url, oauth });
55
+ const tools = await client.listTools();
56
+ io.log(`Authenticated to ${url} — ${tools.length} tools available.`);
57
+ } catch (err) {
58
+ io.error(`Saved, but could not verify the session: ${err.message}`);
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ export async function cmdLogout(ctx) {
64
+ const { config, io } = ctx;
65
+ if (config.oauth?.access_token) {
66
+ await revokeToken({ url: config.url, token: config.oauth.access_token });
67
+ if (config.oauth.refresh_token) await revokeToken({ url: config.url, token: config.oauth.refresh_token });
68
+ }
69
+ const path = clearConfigFile();
70
+ io.log(`Cleared credentials at ${path}`);
71
+ return 0;
72
+ }
73
+
74
+ export function cmdConfig(ctx) {
75
+ const { config, io, flags } = ctx;
76
+ const masked = config.key
77
+ ? `${config.key.slice(0, 6)}…${config.key.slice(-4)}`
78
+ : config.oauth
79
+ ? "(browser session)"
80
+ : "(none)";
81
+ const value = {
82
+ url: config.url,
83
+ key: masked,
84
+ key_source: config.keySource,
85
+ config_file: configPath(),
86
+ };
87
+ if (flags.json) {
88
+ io.log(JSON.stringify(value, null, 2));
89
+ } else {
90
+ io.log(`url: ${value.url}`);
91
+ io.log(`key: ${value.key} (from ${value.key_source})`);
92
+ io.log(`config file: ${value.config_file}`);
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ // `whoami` (alias `status`): confirm the resolved credential actually works and
98
+ // show who it's for. get_onboarding_status is best-effort (older orgs may not
99
+ // have onboarding state).
100
+ export async function cmdWhoami(ctx) {
101
+ const { client, config, io, flags } = ctx;
102
+ const company = await client.callTool("get_company_info", {});
103
+ let onboarding;
104
+ try {
105
+ onboarding = await client.callTool("get_onboarding_status", {});
106
+ } catch {
107
+ onboarding = undefined;
108
+ }
109
+ const masked = config.key
110
+ ? `${config.key.slice(0, 6)}…${config.key.slice(-4)}`
111
+ : config.oauth
112
+ ? "(browser session)"
113
+ : "(none)";
114
+ if (flags.json) {
115
+ io.log(
116
+ JSON.stringify({ url: config.url, key: masked, key_source: config.keySource, company, onboarding }, null, 2)
117
+ );
118
+ return 0;
119
+ }
120
+ io.log(`url: ${config.url}`);
121
+ io.log(`key: ${masked} (from ${config.keySource})`);
122
+ io.log(`company: ${JSON.stringify(company)}`);
123
+ if (onboarding !== undefined) io.log(`onboarding: ${JSON.stringify(onboarding)}`);
124
+ return 0;
125
+ }
@@ -0,0 +1,93 @@
1
+ // `collections` — group articles into collections and set a publishing cadence.
2
+ // Ported from phantomstory-cli's collections.ts.
3
+
4
+ import { CliError } from "../client.mjs";
5
+ import { flagStr, flagNum, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
6
+
7
+ export async function cmdCollections(ctx) {
8
+ const sub = ctx.positionals[0];
9
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
10
+ switch (sub) {
11
+ case "list":
12
+ case "ls":
13
+ return collectionsList(rest);
14
+ case "new":
15
+ case "create":
16
+ return collectionsNew(rest);
17
+ case "update":
18
+ return collectionsUpdate(rest);
19
+ case "delete":
20
+ case "rm":
21
+ return collectionsDelete(rest);
22
+ case "assign":
23
+ return collectionsAssign(rest);
24
+ default:
25
+ throw new CliError(
26
+ `Unknown collections subcommand: ${sub ?? "(none)"}. Try: list, new, update, delete, assign`
27
+ );
28
+ }
29
+ }
30
+
31
+ async function collectionsList(ctx) {
32
+ const { client, flags, io } = ctx;
33
+ const result = await client.callTool("list_collections", {});
34
+ printResult(io, flags, result);
35
+ return 0;
36
+ }
37
+
38
+ async function collectionsNew(ctx) {
39
+ const { client, flags, io } = ctx;
40
+ const name = requireFlag(flags, "name");
41
+ const args = compact({ name, description: flagStr(flags.description) });
42
+ const result = await client.callTool("create_collection", args);
43
+ ok(ctx, `Created collection "${name}".`);
44
+ printResult(io, flags, result);
45
+ return 0;
46
+ }
47
+
48
+ async function collectionsUpdate(ctx) {
49
+ const { client, positionals, flags, io } = ctx;
50
+ const id = requirePositional(positionals, 0, "collection-id");
51
+ const patch = compact({
52
+ collection_id: id,
53
+ name: flagStr(flags.name),
54
+ description: flagStr(flags.description),
55
+ cadence_target: flagNum(flags["cadence-target"]),
56
+ cadence_period: flagStr(flags["cadence-period"]),
57
+ });
58
+ if (Object.keys(patch).length <= 1) {
59
+ throw new CliError(
60
+ "Nothing to update — pass at least one of --name/--description/--cadence-target/--cadence-period."
61
+ );
62
+ }
63
+ const result = await client.callTool("update_collection", patch);
64
+ ok(ctx, "Updated collection.");
65
+ printResult(io, flags, result);
66
+ return 0;
67
+ }
68
+
69
+ async function collectionsDelete(ctx) {
70
+ const { client, positionals, flags, io } = ctx;
71
+ const id = requirePositional(positionals, 0, "collection-id");
72
+ if (!flags.yes) {
73
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} collections delete ${id} --yes`);
74
+ return 1;
75
+ }
76
+ const result = await client.callTool("delete_collection", { collection_id: id });
77
+ ok(ctx, "Collection deleted.");
78
+ printResult(io, flags, result);
79
+ return 0;
80
+ }
81
+
82
+ async function collectionsAssign(ctx) {
83
+ const { client, positionals, flags, io } = ctx;
84
+ const articleId = requirePositional(positionals, 0, "article-id");
85
+ const collectionId = requirePositional(positionals, 1, "collection-id");
86
+ const result = await client.callTool("assign_article_to_collection", {
87
+ article_id: articleId,
88
+ collection_id: collectionId,
89
+ });
90
+ ok(ctx, "Article assigned.");
91
+ printResult(io, flags, result);
92
+ return 0;
93
+ }
@@ -0,0 +1,56 @@
1
+ // `connectors` — export finished articles to external CMSs/doc tools (Webflow,
2
+ // Framer, Google Docs, Contentful) and check publish status. Ported from
3
+ // phantomstory-cli's connectors.ts.
4
+
5
+ import { CliError } from "../client.mjs";
6
+ import { flagStr, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
7
+
8
+ export async function cmdConnectors(ctx) {
9
+ const sub = ctx.positionals[0];
10
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
11
+ switch (sub) {
12
+ case "list":
13
+ case "ls":
14
+ case "targets":
15
+ return connectorsList(rest);
16
+ case "publish":
17
+ return connectorsPublish(rest);
18
+ case "status":
19
+ return connectorsStatus(rest);
20
+ default:
21
+ throw new CliError(`Unknown connectors subcommand: ${sub ?? "(none)"}. Try: list, publish, status`);
22
+ }
23
+ }
24
+
25
+ async function connectorsList(ctx) {
26
+ const { client, flags, io } = ctx;
27
+ const result = await client.callTool("list_publish_targets", {});
28
+ printResult(io, flags, result);
29
+ return 0;
30
+ }
31
+
32
+ async function connectorsPublish(ctx) {
33
+ const { client, positionals, flags, io } = ctx;
34
+ const articleId = requirePositional(positionals, 0, "article-id");
35
+ const to = requireFlag(flags, "to");
36
+ const target = requireFlag(flags, "target");
37
+ const args = compact({
38
+ article_id: articleId,
39
+ connector: to,
40
+ target_id: target,
41
+ flow_run_id: flagStr(flags["flow-run"]),
42
+ });
43
+ const result = await client.callTool("publish_to_connector", args);
44
+ ok(ctx, `Publishing to ${to}.`);
45
+ printResult(io, flags, result);
46
+ return 0;
47
+ }
48
+
49
+ async function connectorsStatus(ctx) {
50
+ const { client, flags, io } = ctx;
51
+ const connector = requireFlag(flags, "connector");
52
+ const publishId = requireFlag(flags, "publish-id");
53
+ const result = await client.callTool("get_publish_status", { connector, publish_id: publishId });
54
+ printResult(io, flags, result);
55
+ return 0;
56
+ }
@@ -0,0 +1,387 @@
1
+ // Phantom blogs (deployments) and custom domains. `cmdDeploy`/`cmdDomain` are the
2
+ // original, stable command surface; `blogs`/`domains` are Mathew's phantomstory-cli
3
+ // names for the exact same operations. Rather than duplicate handler bodies, `blogs`
4
+ // and `domains` are literal references to `cmdDeploy`/`cmdDomain` (see the bottom of
5
+ // this file) and Mathew's verb spellings (ls/new/show/rm/diag) are additional cases
6
+ // in the same switch statements below — one implementation, two vocabularies.
7
+
8
+ import { CliError } from "../client.mjs";
9
+ import { flagStr, flagNum, requireFlag, requirePositional, printResult, compact } from "./shared.mjs";
10
+
11
+ function stripScheme(domain) {
12
+ return domain.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
13
+ }
14
+
15
+ function formatDeployment(d) {
16
+ const lines = [
17
+ `${d.name} [${d.deployment_id}]`,
18
+ ` status: ${d.status}${d.phase ? ` · ${d.phase}` : ""}`,
19
+ ` theme: ${d.theme}`,
20
+ ` collection: ${d.collection_id ?? "(none)"}`,
21
+ ];
22
+ if (d.url) lines.push(` url: ${d.url}`);
23
+ if (d.domain) lines.push(` domain: ${d.domain}`);
24
+ if (d.last_error) lines.push(` error: ${d.last_error}`);
25
+ return lines.join("\n");
26
+ }
27
+
28
+ // Poll get_deployment until the blog reaches a terminal state. Emits a line only
29
+ // when status/phase changes so the output reads as progress, not a firehose.
30
+ async function pollUntilTerminal(client, id, io, bin) {
31
+ const maxPolls = io.maxPolls ?? 90;
32
+ const interval = io.pollIntervalMs ?? 4000;
33
+ let last = "";
34
+ for (let i = 0; i < maxPolls; i++) {
35
+ const d = await client.callTool("get_deployment", { deployment_id: id });
36
+ if (d.status === "live" || d.status === "error") return d;
37
+ const line = `${d.status}${d.phase ? ` · ${d.phase}` : ""}`;
38
+ if (line !== last) {
39
+ io.log(` … ${line}`);
40
+ last = line;
41
+ }
42
+ await io.sleep(interval);
43
+ }
44
+ return {
45
+ status: "error",
46
+ last_error: `Timed out waiting for it to go live. Check: ${bin} deploy get ${id}`,
47
+ url: null,
48
+ };
49
+ }
50
+
51
+ // --- deployments (phantom blogs) --------------------------------------------
52
+
53
+ export async function cmdDeploy(ctx) {
54
+ const sub = ctx.positionals[0];
55
+ // Bare `deploy` (no subcommand word) is phantomstory-cli's flagship, flag-only
56
+ // invocation — `phantom deploy --domain x.com --buy` — not one of the create/
57
+ // list/get/… verbs below, so route it to the orchestrator before the switch.
58
+ if (sub === undefined) return deployOrchestrate(ctx);
59
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
60
+ switch (sub) {
61
+ case "create":
62
+ case "new":
63
+ return deployCreate(rest);
64
+ case "list":
65
+ case "ls":
66
+ return deployList(rest);
67
+ case "get":
68
+ case "status":
69
+ case "show":
70
+ return deployGet(rest);
71
+ case "update":
72
+ return deployUpdate(rest);
73
+ case "rebuild":
74
+ return deployRebuild(rest);
75
+ case "diagnostics":
76
+ case "diag":
77
+ return deployDiagnostics(rest);
78
+ case "delete":
79
+ case "rm":
80
+ return deployDelete(rest);
81
+ default:
82
+ throw new CliError(
83
+ `Unknown deploy subcommand: ${sub}. Try: create, list, get, update, rebuild, diagnostics, delete — or bare ` +
84
+ `"${ctx.bin} deploy --domain <domain>" to reserve+price+build in one step.`
85
+ );
86
+ }
87
+ }
88
+
89
+ // --- one-shot orchestrator: reserve/locate → price domain → optionally buy →
90
+ // rebuild-if-changed. Mirrors phantomstory-cli's flagship `phantom deploy` in this
91
+ // CLI's own plain-text conventions (no spinners/animation, see commands/README notes
92
+ // on the ui/render toolkit not being ported).
93
+ function nameFromDomain(domain) {
94
+ if (!domain) return undefined;
95
+ const host = domain
96
+ .replace(/^https?:\/\//, "")
97
+ .replace(/^www\./, "")
98
+ .split("/")[0];
99
+ const base = host.split(".")[0];
100
+ if (!base) return undefined;
101
+ return base
102
+ .split(/[-_]/)
103
+ .filter(Boolean)
104
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
105
+ .join(" ");
106
+ }
107
+
108
+ function pickId(obj) {
109
+ if (obj && typeof obj === "object") {
110
+ for (const key of ["deployment_id", "id", "deploymentId"]) {
111
+ if (typeof obj[key] === "string") return obj[key];
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ function liveUrl(domain, collected) {
118
+ if (domain) return `https://${domain}`;
119
+ for (const v of Object.values(collected)) {
120
+ if (v && typeof v === "object") {
121
+ const url = v.url ?? v.live_url ?? v.blog_url;
122
+ if (typeof url === "string") return url;
123
+ }
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ async function deployOrchestrate(ctx) {
129
+ const { client, flags, io } = ctx;
130
+ const domainFlag = flagStr(flags.domain);
131
+ const nameFlag = flagStr(flags.name);
132
+ const themeFlag = flagStr(flags.theme);
133
+ const collectionFlag = flagStr(flags.collection);
134
+ const blogFlag = flagStr(flags.blog);
135
+ const buy = Boolean(flags.buy);
136
+ const forceRebuild = Boolean(flags.rebuild);
137
+ const domain = domainFlag !== undefined ? stripScheme(domainFlag) : undefined;
138
+
139
+ if (!domain && !nameFlag && !blogFlag) {
140
+ throw new CliError(
141
+ `Nothing to deploy — pass --domain, --name, or --blog. e.g. "${ctx.bin} deploy --domain yourverticalreview.com"`
142
+ );
143
+ }
144
+ if (buy && !domain) {
145
+ throw new CliError("--buy needs a --domain to register.");
146
+ }
147
+ if (blogFlag && (nameFlag || themeFlag || collectionFlag)) {
148
+ throw new CliError(
149
+ `--name/--theme/--collection can't be combined with --blog. Use "${ctx.bin} deploy update <id>" to rename, re-theme, or repoint an existing blog.`
150
+ );
151
+ }
152
+
153
+ if (flags["dry-run"]) {
154
+ const name = nameFlag || nameFromDomain(domain) || "Phantom Blog";
155
+ io.log(`Dry run — would deploy with:`);
156
+ io.log(
157
+ JSON.stringify(
158
+ compact({
159
+ blog: blogFlag,
160
+ name: blogFlag ? undefined : name,
161
+ theme: themeFlag,
162
+ collection: collectionFlag,
163
+ domain,
164
+ buy,
165
+ rebuild: forceRebuild,
166
+ }),
167
+ null,
168
+ 2
169
+ )
170
+ );
171
+ if (domain) io.log(`\nWould be live at https://${domain}`);
172
+ return 0;
173
+ }
174
+
175
+ const collected = {};
176
+ let changed = false;
177
+ let deploymentId = blogFlag;
178
+
179
+ if (deploymentId) {
180
+ collected.blog = await client.callTool("get_deployment", { deployment_id: deploymentId });
181
+ io.log(`Found phantom blog ${deploymentId}`);
182
+ } else {
183
+ const name = nameFlag || nameFromDomain(domain) || "Phantom Blog";
184
+ const created = await client.callTool(
185
+ "create_deployment",
186
+ compact({ name, theme: themeFlag, collection_id: collectionFlag })
187
+ );
188
+ collected.blog = created;
189
+ deploymentId = pickId(created);
190
+ changed = true;
191
+ io.log(`Reserved phantom blog "${name}"${deploymentId ? ` (${deploymentId})` : ""}`);
192
+ }
193
+
194
+ if (domain) {
195
+ collected.domain = await client.callTool("check_domain", { domain });
196
+ io.log(`Priced ${domain}`);
197
+
198
+ if (buy) {
199
+ if (!deploymentId) throw new CliError("Cannot register a domain without a blog id.");
200
+ collected.purchase = await client.callTool("buy_domain", { deployment_id: deploymentId, domain });
201
+ changed = true;
202
+ io.log(`Registered ${domain}`);
203
+ }
204
+ }
205
+
206
+ if (deploymentId && (changed || forceRebuild)) {
207
+ collected.build = await client.callTool("rebuild_deployment", { deployment_id: deploymentId });
208
+ io.log(`SSL + DNS provisioned · sitemap + schema generated`);
209
+ try {
210
+ collected.diagnostics = await client.callTool("get_deployment_diagnostics", {
211
+ deployment_id: deploymentId,
212
+ });
213
+ } catch {
214
+ // best-effort — diagnostics may not be ready immediately after a build kicks off
215
+ }
216
+ } else if (deploymentId) {
217
+ io.log(`No changes — skipped rebuild (pass --rebuild to force).`);
218
+ }
219
+
220
+ if (flags.json) {
221
+ io.log(JSON.stringify(collected, null, 2));
222
+ return 0;
223
+ }
224
+
225
+ const url = liveUrl(domain, collected);
226
+ io.log(`\nYour phantom blog is live${url ? ` at ${url}` : ""}.`);
227
+ return 0;
228
+ }
229
+
230
+ async function deployCreate(ctx) {
231
+ const { client, flags, io } = ctx;
232
+ const name = requireFlag(flags, "name");
233
+ const args = { name };
234
+ const description = flagStr(flags.description);
235
+ const theme = flagStr(flags.theme);
236
+ const collection = flagStr(flags.collection);
237
+ if (description !== undefined) args.description = description;
238
+ if (theme !== undefined) args.theme = theme;
239
+ if (collection !== undefined) args.collection_id = collection;
240
+
241
+ if (flags["dry-run"]) {
242
+ io.log(`Dry run — would create a phantom blog with:`);
243
+ io.log(JSON.stringify(args, null, 2));
244
+ return 0;
245
+ }
246
+
247
+ const created = await client.callTool("create_deployment", args);
248
+ io.log(`Created deployment ${created.deployment_id} (${created.status})`);
249
+
250
+ if (flags["no-wait"]) {
251
+ io.log(`Provisioning in the background. Poll with: ${ctx.bin} deploy get ${created.deployment_id}`);
252
+ return 0;
253
+ }
254
+
255
+ const final = await pollUntilTerminal(client, created.deployment_id, io, ctx.bin);
256
+ if (final.status === "live") {
257
+ io.log(`\n✓ Live at ${final.url}`);
258
+ return 0;
259
+ }
260
+ io.error(`\n✗ Deployment ${final.status}: ${final.last_error ?? "unknown error"}`);
261
+ return 1;
262
+ }
263
+
264
+ async function deployList(ctx) {
265
+ const { client, flags, io } = ctx;
266
+ const limit = flagNum(flags.limit);
267
+ const result = await client.callTool("list_deployments", limit !== undefined ? { limit } : {});
268
+ const items = result.items ?? [];
269
+ if (flags.json) {
270
+ io.log(JSON.stringify(result, null, 2));
271
+ return 0;
272
+ }
273
+ if (items.length === 0) {
274
+ io.log(`No deployments yet. Create one with: ${ctx.bin} deploy create --name <name>`);
275
+ return 0;
276
+ }
277
+ for (const d of items) {
278
+ io.log(`${d.status.padEnd(13)} ${d.deployment_id} ${d.name}${d.url ? ` ${d.url}` : ""}`);
279
+ }
280
+ return 0;
281
+ }
282
+
283
+ async function deployGet(ctx) {
284
+ const { client, positionals, flags, io } = ctx;
285
+ const id = requirePositional(positionals, 0, "deployment-id");
286
+ const d = await client.callTool("get_deployment", { deployment_id: id });
287
+ printResult(io, flags, d, formatDeployment);
288
+ return 0;
289
+ }
290
+
291
+ async function deployUpdate(ctx) {
292
+ const { client, positionals, flags, io } = ctx;
293
+ const id = requirePositional(positionals, 0, "deployment-id");
294
+ const patch = compact({
295
+ deployment_id: id,
296
+ name: flagStr(flags.name),
297
+ description: flagStr(flags.description),
298
+ theme: flagStr(flags.theme),
299
+ collection_id: flagStr(flags.collection),
300
+ });
301
+ if (Object.keys(patch).length <= 1) {
302
+ throw new CliError("Nothing to update — pass at least one of --name/--description/--theme/--collection.");
303
+ }
304
+ const d = await client.callTool("update_deployment", patch);
305
+ io.log(`Updated deployment ${id}.`);
306
+ printResult(io, flags, d, formatDeployment);
307
+ return 0;
308
+ }
309
+
310
+ async function deployRebuild(ctx) {
311
+ const { client, positionals, io } = ctx;
312
+ const id = requirePositional(positionals, 0, "deployment-id");
313
+ await client.callTool("rebuild_deployment", { deployment_id: id });
314
+ io.log(`Rebuild started for ${id}. New published content will appear once the build finishes.`);
315
+ return 0;
316
+ }
317
+
318
+ async function deployDiagnostics(ctx) {
319
+ const { client, positionals, io } = ctx;
320
+ const id = requirePositional(positionals, 0, "deployment-id");
321
+ const diag = await client.callTool("get_deployment_diagnostics", { deployment_id: id });
322
+ io.log(JSON.stringify(diag, null, 2));
323
+ return 0;
324
+ }
325
+
326
+ async function deployDelete(ctx) {
327
+ const { client, positionals, flags, io } = ctx;
328
+ const id = requirePositional(positionals, 0, "deployment-id");
329
+ if (!flags.yes) {
330
+ io.error(`This tears down the blog's site and revokes its content key (the collection is kept).`);
331
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} deploy delete ${id} --yes`);
332
+ return 1;
333
+ }
334
+ await client.callTool("delete_deployment", { deployment_id: id });
335
+ io.log(`Deleted deployment ${id}.`);
336
+ return 0;
337
+ }
338
+
339
+ // --- custom domains ---------------------------------------------------------
340
+
341
+ export async function cmdDomain(ctx) {
342
+ const sub = ctx.positionals[0];
343
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
344
+ switch (sub) {
345
+ case "check":
346
+ return domainCheck(rest);
347
+ case "buy":
348
+ return domainBuy(rest);
349
+ default:
350
+ throw new CliError(`Unknown domain subcommand: ${sub ?? "(none)"}. Try: check, buy`);
351
+ }
352
+ }
353
+
354
+ async function domainCheck(ctx) {
355
+ const { client, positionals, flags, io } = ctx;
356
+ const domain = stripScheme(requirePositional(positionals, 0, "domain"));
357
+ const quote = await client.callTool("check_domain", { domain });
358
+ if (flags.json) {
359
+ io.log(JSON.stringify(quote, null, 2));
360
+ return 0;
361
+ }
362
+ io.log(`${domain}`);
363
+ io.log(` available: ${quote.available}`);
364
+ if (quote.price !== undefined) io.log(` price: $${quote.price}`);
365
+ io.log(` purchasable: ${quote.purchasable}`);
366
+ return 0;
367
+ }
368
+
369
+ // No confirm gate: the app's BuyDomainDialog charges on a single "Get this domain"
370
+ // click, and the CLI mirrors the app's user-facing flow. The spend is bounded on the
371
+ // server, not by a client prompt — `buy_domain` is server-priced and hard-capped
372
+ // (≤2 domains/org, <$30, blog must be live with no domain yet).
373
+ async function domainBuy(ctx) {
374
+ const { client, positionals, flags, io } = ctx;
375
+ const id = requirePositional(positionals, 0, "deployment-id");
376
+ const domain = stripScheme(requirePositional(positionals, 1, "domain"));
377
+ const result = await client.callTool("buy_domain", { deployment_id: id, domain });
378
+ io.log(`Purchase started for ${domain}. Attach + DNS run in the background.`);
379
+ io.log(`Poll with: ${ctx.bin} deploy get ${id}`);
380
+ if (flags.json) io.log(JSON.stringify(result, null, 2));
381
+ return 0;
382
+ }
383
+
384
+ // `blogs`/`domains` are phantomstory-cli's names for these exact same commands — a
385
+ // literal reference, not a wrapper, so they can never drift from deploy/domain.
386
+ export const cmdBlogs = cmdDeploy;
387
+ export const cmdDomains = cmdDomain;