@letterstory/cli 0.1.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 ADDED
@@ -0,0 +1,77 @@
1
+ # `@letterstory/cli`
2
+
3
+ Spin up and manage Letterstory **phantom blogs** from your terminal. The CLI is a thin
4
+ client over the Letterstory MCP endpoint (`POST /api/mcp`) — it wraps the same tool
5
+ manifest an agent sees, so it never drifts from the API.
6
+
7
+ ## Install
8
+
9
+ The CLI is plain ESM with **zero dependencies** and needs no build step (Node ≥ 20).
10
+
11
+ ```bash
12
+ # from a checkout of this repo
13
+ cd cli
14
+ npm link # puts `letterstory` on your PATH
15
+ # …or run it directly without linking:
16
+ node cli/bin/letterstory.mjs --help
17
+ ```
18
+
19
+ ## Authenticate
20
+
21
+ You need a Letterstory API key (starts with `lb_`) with the `deployment:read` and
22
+ `deployment:write` capabilities — mint one in the app under **Settings → API keys**.
23
+ Add `deployment:domain` too if you plan to buy custom domains.
24
+
25
+ ```bash
26
+ letterstory login --key lb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
27
+ # points at https://app.letterstory.com by default; override with --url
28
+ ```
29
+
30
+ Credentials resolve from `--key`/`--url` flags, then `LETTERSTORY_API_KEY` /
31
+ `LETTERSTORY_API_URL`, then `~/.letterstory/config.json` (written by `login`, mode 600).
32
+
33
+ ## Spin up a blog
34
+
35
+ ```bash
36
+ # create a blog and wait until it's live (polls for you)
37
+ letterstory deploy create --name "My Blog" --theme gazette
38
+
39
+ # Created deployment 7c19c7… (provisioning)
40
+ # … provisioning · building
41
+ # … provisioning · connecting
42
+ # ✓ Live at https://my-blog-7c19c7.letterstory-staging.com
43
+
44
+ letterstory deploy list
45
+ letterstory deploy get <deployment-id>
46
+ letterstory deploy rebuild <deployment-id> # after publishing new articles
47
+ letterstory deploy delete <deployment-id> --yes
48
+ ```
49
+
50
+ Pass `--no-wait` to return immediately and poll later with `deploy get`.
51
+
52
+ ## Custom domains
53
+
54
+ ```bash
55
+ letterstory domain check myblog.com # price it (no charge)
56
+ letterstory domain buy <deployment-id> myblog.com --yes # buys + attaches (spends money)
57
+ ```
58
+
59
+ ## Anything else
60
+
61
+ Every Letterstory tool is reachable, not just the deployment ones:
62
+
63
+ ```bash
64
+ letterstory tools # list all tools
65
+ letterstory call list_articles --args '{"limit":5}'
66
+ letterstory call ingest_article --args '{"title":"…","content":"…"}'
67
+ ```
68
+
69
+ ## Global flags
70
+
71
+ | Flag | Meaning |
72
+ | ----------- | -------------------------------------- |
73
+ | `--json` | Machine-readable output |
74
+ | `--url` | Override the API base URL for one call |
75
+ | `--key` | Override the API key for one call |
76
+ | `--help` | Show usage |
77
+ | `--version` | Print the CLI version |
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Thin entry point: hand argv to run() and translate its exit code to the process.
3
+ // All logic lives in ../lib so it stays unit-testable without spawning a process.
4
+ import { run } from "../lib/cli.mjs";
5
+
6
+ run(process.argv.slice(2))
7
+ .then((code) => process.exit(code))
8
+ .catch((err) => {
9
+ // A non-CliError escaped run() — a real bug. Show the stack.
10
+ console.error(err);
11
+ process.exit(1);
12
+ });
package/lib/cli.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // Argument parsing, help, and command routing for the Letterstory CLI. `run(argv)`
2
+ // is the single entry the bin and the tests both call; it returns an exit code and
3
+ // never throws for user-facing problems (those become a printed CliError + code 1).
4
+
5
+ import { LetterstoryClient, CliError, resolveConfig } from "./client.mjs";
6
+ import { cmdLogin, cmdLogout, cmdConfig, cmdTools, cmdCall, cmdDeploy, cmdDomain } from "./commands.mjs";
7
+
8
+ // Keep in sync with cli/package.json.
9
+ export const VERSION = "0.1.0";
10
+
11
+ // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
12
+ // can't accidentally swallow the id as --json's value.
13
+ const BOOLEAN_FLAGS = new Set(["json", "yes", "no-wait", "help", "version"]);
14
+
15
+ // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
16
+ /**
17
+ * @param {string[]} argv
18
+ * @returns {{ positionals: string[], flags: Record<string, string | boolean> }}
19
+ */
20
+ export function parseArgs(argv) {
21
+ const positionals = [];
22
+ const flags = {};
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const tok = argv[i];
25
+ if (!tok.startsWith("--")) {
26
+ positionals.push(tok);
27
+ continue;
28
+ }
29
+ const eq = tok.indexOf("=");
30
+ if (eq !== -1) {
31
+ flags[tok.slice(2, eq)] = tok.slice(eq + 1);
32
+ continue;
33
+ }
34
+ const name = tok.slice(2);
35
+ const next = argv[i + 1];
36
+ if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith("--")) {
37
+ flags[name] = true;
38
+ } else {
39
+ flags[name] = next;
40
+ i++;
41
+ }
42
+ }
43
+ return { positionals, flags };
44
+ }
45
+
46
+ function str(v) {
47
+ return typeof v === "string" ? v : undefined;
48
+ }
49
+
50
+ export const HELP = `letterstory — spin up and manage Letterstory phantom blogs from your terminal
51
+
52
+ Usage:
53
+ letterstory <command> [args] [--flags]
54
+
55
+ Auth:
56
+ login --key <lb_…> [--url <url>] Save your API key (default url: https://app.letterstory.com)
57
+ logout Forget saved credentials
58
+ config Show the resolved url + key (masked)
59
+
60
+ Phantom blogs:
61
+ deploy create --name <name> [--description <text>] [--theme <theme>]
62
+ [--collection <uuid>] [--no-wait] Create a blog; waits until it's live
63
+ deploy list [--limit <n>] List your blogs
64
+ deploy get <deployment-id> Show one blog's status + URL
65
+ deploy rebuild <deployment-id> Rebuild to pull in newly-published content
66
+ deploy diagnostics <deployment-id> Why is my blog empty? snapshot
67
+ deploy delete <deployment-id> --yes Tear down the blog (keeps the collection)
68
+
69
+ Custom domains:
70
+ domain check <domain> Price a domain (read-only, no charge)
71
+ domain buy <deployment-id> <domain> --yes Buy + attach a domain (spends money)
72
+
73
+ Anything else:
74
+ tools List every tool this server exposes
75
+ call <tool> [--args '<json>'] [--flag value …] Call any tool directly
76
+
77
+ Global flags:
78
+ --json Machine-readable output
79
+ --url <url> Override the API base URL for this invocation
80
+ --key <lb_…> Override the API key for this invocation
81
+ --help Show this help
82
+ --version Print the CLI version
83
+
84
+ Credentials resolve from --key/--url, then LETTERSTORY_API_KEY / LETTERSTORY_API_URL,
85
+ then ~/.letterstory/config.json.`;
86
+
87
+ const CLIENT_COMMANDS = {
88
+ login: cmdLogin,
89
+ logout: cmdLogout,
90
+ config: cmdConfig,
91
+ tools: cmdTools,
92
+ call: cmdCall,
93
+ deploy: cmdDeploy,
94
+ domain: cmdDomain,
95
+ };
96
+
97
+ // LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
98
+ // end-to-end test) tune the deploy-create wait loop without editing code. Bad or
99
+ // absent values fall back to the shipping defaults (4s interval, 90 polls ≈ 6 min).
100
+ function envInt(name, fallback) {
101
+ const n = Number(process.env[name]);
102
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
103
+ }
104
+
105
+ export function defaultIo() {
106
+ return {
107
+ log: (m) => process.stdout.write(`${m}\n`),
108
+ error: (m) => process.stderr.write(`${m}\n`),
109
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
110
+ pollIntervalMs: envInt("LETTERSTORY_POLL_INTERVAL_MS", 4000),
111
+ maxPolls: envInt("LETTERSTORY_MAX_POLLS", 90),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * @param {string[]} argv
117
+ * @param {{ log: (m: string) => void, error: (m: string) => void, sleep: (ms: number) => Promise<void>, pollIntervalMs?: number, maxPolls?: number }} [io]
118
+ * @returns {Promise<number>} process exit code
119
+ */
120
+ export async function run(argv, io = defaultIo()) {
121
+ const { positionals, flags } = parseArgs(argv);
122
+ const command = positionals[0];
123
+
124
+ if (flags.version || command === "version") {
125
+ io.log(VERSION);
126
+ return 0;
127
+ }
128
+ if (!command || command === "help" || flags.help) {
129
+ io.log(HELP);
130
+ return 0;
131
+ }
132
+
133
+ const handler = CLIENT_COMMANDS[command];
134
+ if (!handler) {
135
+ io.error(`Unknown command: ${command}`);
136
+ io.error(`Run \`letterstory help\` for usage.`);
137
+ return 1;
138
+ }
139
+
140
+ try {
141
+ const config = resolveConfig({ url: str(flags.url), key: str(flags.key) });
142
+ const client = new LetterstoryClient({ url: config.url, key: config.key });
143
+ const ctx = { client, config, positionals: positionals.slice(1), flags, io };
144
+ return await handler(ctx);
145
+ } catch (err) {
146
+ if (err instanceof CliError) {
147
+ io.error(`Error: ${err.message}`);
148
+ return 1;
149
+ }
150
+ throw err;
151
+ }
152
+ }
package/lib/client.mjs ADDED
@@ -0,0 +1,137 @@
1
+ // The Letterstory CLI's transport: a thin JSON-RPC client over the MCP endpoint
2
+ // (POST /api/mcp), plus config resolution. The CLI wraps the same tool manifest an
3
+ // agent sees, so it stays in sync with the server by construction — there is no
4
+ // second copy of the surface here, only ergonomic wrappers around tools/call.
5
+
6
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync, rmSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join, dirname } from "node:path";
9
+
10
+ export const DEFAULT_API_URL = "https://app.letterstory.com";
11
+
12
+ // A CliError is a message we've already made human-friendly; the entry point prints
13
+ // its message and exits 1 without a stack trace. Anything else is a real bug.
14
+ export class CliError extends Error {}
15
+
16
+ // Config lives at ~/.letterstory/config.json. LETTERSTORY_CONFIG_HOME overrides the
17
+ // home dir (the tests point it at a temp dir so they never touch the real file).
18
+ export function configPath() {
19
+ const home = process.env.LETTERSTORY_CONFIG_HOME || homedir();
20
+ return join(home, ".letterstory", "config.json");
21
+ }
22
+
23
+ export function readConfigFile() {
24
+ const path = configPath();
25
+ if (!existsSync(path)) return {};
26
+ try {
27
+ return JSON.parse(readFileSync(path, "utf8"));
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ // The file holds an lb_ API key, so it's written owner-only (0600) and the mode is
34
+ // re-asserted in case it pre-existed with looser bits.
35
+ export function writeConfigFile(config) {
36
+ const path = configPath();
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
39
+ chmodSync(path, 0o600);
40
+ return path;
41
+ }
42
+
43
+ export function clearConfigFile() {
44
+ const path = configPath();
45
+ if (existsSync(path)) rmSync(path);
46
+ return path;
47
+ }
48
+
49
+ // Precedence: explicit flags > env > config file > built-in default. Returns which
50
+ // source the key came from so `config` can show it. The URL defaults to prod.
51
+ export function resolveConfig({ url, key } = {}) {
52
+ const file = readConfigFile();
53
+ const keySource = key ? "flag" : process.env.LETTERSTORY_API_KEY ? "env" : file.key ? "file" : "none";
54
+ return {
55
+ url: url || process.env.LETTERSTORY_API_URL || file.url || DEFAULT_API_URL,
56
+ key: key || process.env.LETTERSTORY_API_KEY || file.key || null,
57
+ keySource,
58
+ };
59
+ }
60
+
61
+ export class LetterstoryClient {
62
+ constructor({ url, key, fetchImpl } = {}) {
63
+ this.url = (url || DEFAULT_API_URL).replace(/\/+$/, "");
64
+ this.key = key || null;
65
+ this.fetch = fetchImpl || globalThis.fetch;
66
+ }
67
+
68
+ get mcpEndpoint() {
69
+ return `${this.url}/api/mcp`;
70
+ }
71
+
72
+ // Unauthenticated discovery (GET /api/mcp): tool names + descriptions, no schema.
73
+ // Lets `letterstory tools` work before you've logged in.
74
+ async discover() {
75
+ let res;
76
+ try {
77
+ res = await this.fetch(this.mcpEndpoint, { headers: { accept: "application/json" } });
78
+ } catch (err) {
79
+ throw new CliError(`Could not reach ${this.mcpEndpoint}: ${err.message}`);
80
+ }
81
+ if (!res.ok) throw new CliError(`Discovery failed (HTTP ${res.status}) at ${this.mcpEndpoint}`);
82
+ return res.json();
83
+ }
84
+
85
+ // One JSON-RPC round trip. The MCP route is stateless per POST, so tools/call
86
+ // needs no prior initialize handshake.
87
+ async rpc(method, params) {
88
+ if (!this.key) {
89
+ throw new CliError("Not logged in. Run `letterstory login --key <lb_…>` or set LETTERSTORY_API_KEY.");
90
+ }
91
+ let res;
92
+ try {
93
+ res = await this.fetch(this.mcpEndpoint, {
94
+ method: "POST",
95
+ headers: {
96
+ "content-type": "application/json",
97
+ accept: "application/json",
98
+ "x-integrations-key": this.key,
99
+ },
100
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
101
+ });
102
+ } catch (err) {
103
+ throw new CliError(`Could not reach ${this.mcpEndpoint}: ${err.message}`);
104
+ }
105
+ // Auth failures come back as a plain HTTP error, not a JSON-RPC envelope.
106
+ if (res.status === 401 || res.status === 403) {
107
+ throw new CliError(`Authentication failed (HTTP ${res.status}). Check your API key and its capabilities.`);
108
+ }
109
+ const body = await res.json().catch(() => null);
110
+ if (!body) throw new CliError(`Unexpected non-JSON response (HTTP ${res.status}) from ${this.mcpEndpoint}`);
111
+ if (body.error) {
112
+ const detail = body.error.data ? `: ${JSON.stringify(body.error.data)}` : "";
113
+ throw new CliError(`${body.error.message}${detail}`);
114
+ }
115
+ return body.result;
116
+ }
117
+
118
+ async listTools() {
119
+ const result = await this.rpc("tools/list", {});
120
+ return result?.tools ?? [];
121
+ }
122
+
123
+ // Invoke a tool and unwrap the MCP content envelope. A tool-level failure arrives
124
+ // as { isError: true, content:[{text}] } on HTTP 200 (e.g. a capability the key
125
+ // lacks, or a validation error) — surface that text as a CliError.
126
+ async callTool(name, args = {}) {
127
+ const result = await this.rpc("tools/call", { name, arguments: args });
128
+ const text = result?.content?.[0]?.text ?? "";
129
+ if (result?.isError) throw new CliError(text || `Tool ${name} failed`);
130
+ if (!text) return {};
131
+ try {
132
+ return JSON.parse(text);
133
+ } catch {
134
+ return text; // a few tools return a bare string
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,325 @@
1
+ // Command handlers. Each takes a ctx { client, positionals, flags, io } and returns
2
+ // a process exit code. Handlers do their own printing through io (so poll progress
3
+ // streams live), and throw CliError for anything the user needs to fix.
4
+
5
+ import { LetterstoryClient, CliError, resolveConfig, writeConfigFile, clearConfigFile, configPath } from "./client.mjs";
6
+
7
+ // --- small helpers ----------------------------------------------------------
8
+
9
+ // A flag with no value parses to `true`; coerce that back to undefined so a bare
10
+ // `--name` reads as "missing", not the string "true".
11
+ function flagStr(v) {
12
+ return typeof v === "string" ? v : undefined;
13
+ }
14
+
15
+ function flagNum(v) {
16
+ const s = flagStr(v);
17
+ if (s === undefined) return undefined;
18
+ const n = Number(s);
19
+ if (!Number.isFinite(n)) throw new CliError(`Expected a number, got "${s}"`);
20
+ return n;
21
+ }
22
+
23
+ function requireFlag(flags, name) {
24
+ const v = flagStr(flags[name]);
25
+ if (v === undefined) throw new CliError(`Missing required --${name}`);
26
+ return v;
27
+ }
28
+
29
+ function requirePositional(positionals, index, label) {
30
+ const v = positionals[index];
31
+ if (v === undefined) throw new CliError(`Missing required <${label}>`);
32
+ return v;
33
+ }
34
+
35
+ function printResult(io, flags, value, formatter) {
36
+ if (flags.json || !formatter) {
37
+ io.log(JSON.stringify(value, null, 2));
38
+ } else {
39
+ io.log(formatter(value));
40
+ }
41
+ }
42
+
43
+ function formatDeployment(d) {
44
+ const lines = [
45
+ `${d.name} [${d.deployment_id}]`,
46
+ ` status: ${d.status}${d.phase ? ` · ${d.phase}` : ""}`,
47
+ ` theme: ${d.theme}`,
48
+ ` collection: ${d.collection_id ?? "(none)"}`,
49
+ ];
50
+ if (d.url) lines.push(` url: ${d.url}`);
51
+ if (d.domain) lines.push(` domain: ${d.domain}`);
52
+ if (d.last_error) lines.push(` error: ${d.last_error}`);
53
+ return lines.join("\n");
54
+ }
55
+
56
+ // Poll get_deployment until the blog reaches a terminal state. Emits a line only
57
+ // when status/phase changes so the output reads as progress, not a firehose.
58
+ async function pollUntilTerminal(client, id, io) {
59
+ const maxPolls = io.maxPolls ?? 90;
60
+ const interval = io.pollIntervalMs ?? 4000;
61
+ let last = "";
62
+ for (let i = 0; i < maxPolls; i++) {
63
+ const d = await client.callTool("get_deployment", { deployment_id: id });
64
+ if (d.status === "live" || d.status === "error") return d;
65
+ const line = `${d.status}${d.phase ? ` · ${d.phase}` : ""}`;
66
+ if (line !== last) {
67
+ io.log(` … ${line}`);
68
+ last = line;
69
+ }
70
+ await io.sleep(interval);
71
+ }
72
+ return {
73
+ status: "error",
74
+ last_error: `Timed out waiting for it to go live. Check: letterstory deploy get ${id}`,
75
+ url: null,
76
+ };
77
+ }
78
+
79
+ // --- auth / config ----------------------------------------------------------
80
+
81
+ export async function cmdLogin(ctx) {
82
+ const { flags, io } = ctx;
83
+ const key = requireFlag(flags, "key");
84
+ const url = flagStr(flags.url);
85
+ if (!key.startsWith("lb_")) {
86
+ io.error("Warning: Letterstory API keys usually start with 'lb_'. Saving anyway.");
87
+ }
88
+ const saved = { key, ...(url ? { url } : {}) };
89
+ const path = writeConfigFile(saved);
90
+ io.log(`Saved credentials to ${path}`);
91
+
92
+ // Verify the key works (and report the tool count) without failing the save if
93
+ // the network is down — a saved-but-unverified key is still usable later.
94
+ try {
95
+ const resolved = resolveConfig({ url, key });
96
+ const client = new LetterstoryClient({ url: resolved.url, key: resolved.key });
97
+ const tools = await client.listTools();
98
+ io.log(`Authenticated to ${resolved.url} — ${tools.length} tools available.`);
99
+ return 0;
100
+ } catch (err) {
101
+ io.error(`Saved, but could not verify the key: ${err.message}`);
102
+ return 0;
103
+ }
104
+ }
105
+
106
+ export function cmdLogout(ctx) {
107
+ const path = clearConfigFile();
108
+ ctx.io.log(`Cleared credentials at ${path}`);
109
+ return 0;
110
+ }
111
+
112
+ export function cmdConfig(ctx) {
113
+ const { config, io, flags } = ctx;
114
+ const masked = config.key ? `${config.key.slice(0, 6)}…${config.key.slice(-4)}` : "(none)";
115
+ const value = {
116
+ url: config.url,
117
+ key: masked,
118
+ key_source: config.keySource,
119
+ config_file: configPath(),
120
+ };
121
+ if (flags.json) {
122
+ io.log(JSON.stringify(value, null, 2));
123
+ } else {
124
+ io.log(`url: ${value.url}`);
125
+ io.log(`key: ${value.key} (from ${value.key_source})`);
126
+ io.log(`config file: ${value.config_file}`);
127
+ }
128
+ return 0;
129
+ }
130
+
131
+ // --- discovery / generic passthrough ----------------------------------------
132
+
133
+ export async function cmdTools(ctx) {
134
+ const { client, io, flags } = ctx;
135
+ const doc = await client.discover();
136
+ const tools = doc.tools ?? [];
137
+ if (flags.json) {
138
+ io.log(JSON.stringify(tools, null, 2));
139
+ return 0;
140
+ }
141
+ io.log(`${tools.length} tools available at ${client.url}:\n`);
142
+ for (const t of tools) io.log(` ${t.name} — ${t.description ?? ""}`);
143
+ return 0;
144
+ }
145
+
146
+ // Escape hatch: call any tool by name. Args come from --args '<json>', or from
147
+ // individual string flags (everything after the tool name). Keeps the whole
148
+ // manifest reachable without a bespoke subcommand per tool.
149
+ export async function cmdCall(ctx) {
150
+ const { client, positionals, flags, io } = ctx;
151
+ const name = requirePositional(positionals, 0, "tool");
152
+ let args = {};
153
+ if (flags.args !== undefined) {
154
+ try {
155
+ args = JSON.parse(flagStr(flags.args) ?? "");
156
+ } catch {
157
+ throw new CliError(`--args must be a JSON object, e.g. --args '{"name":"My Blog"}'`);
158
+ }
159
+ } else {
160
+ for (const [k, v] of Object.entries(flags)) {
161
+ if (k === "json" || k === "url" || k === "key") continue;
162
+ args[k] = v;
163
+ }
164
+ }
165
+ const result = await client.callTool(name, args);
166
+ io.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
167
+ return 0;
168
+ }
169
+
170
+ // --- deployments (phantom blogs) --------------------------------------------
171
+
172
+ export async function cmdDeploy(ctx) {
173
+ const sub = ctx.positionals[0];
174
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
175
+ switch (sub) {
176
+ case "create":
177
+ return deployCreate(rest);
178
+ case "list":
179
+ return deployList(rest);
180
+ case "get":
181
+ case "status":
182
+ return deployGet(rest);
183
+ case "rebuild":
184
+ return deployRebuild(rest);
185
+ case "diagnostics":
186
+ return deployDiagnostics(rest);
187
+ case "delete":
188
+ return deployDelete(rest);
189
+ default:
190
+ throw new CliError(
191
+ `Unknown deploy subcommand: ${sub ?? "(none)"}. Try: create, list, get, rebuild, diagnostics, delete`
192
+ );
193
+ }
194
+ }
195
+
196
+ async function deployCreate(ctx) {
197
+ const { client, flags, io } = ctx;
198
+ const name = requireFlag(flags, "name");
199
+ const args = { name };
200
+ const description = flagStr(flags.description);
201
+ const theme = flagStr(flags.theme);
202
+ const collection = flagStr(flags.collection);
203
+ if (description !== undefined) args.description = description;
204
+ if (theme !== undefined) args.theme = theme;
205
+ if (collection !== undefined) args.collection_id = collection;
206
+
207
+ const created = await client.callTool("create_deployment", args);
208
+ io.log(`Created deployment ${created.deployment_id} (${created.status})`);
209
+
210
+ if (flags["no-wait"]) {
211
+ io.log(`Provisioning in the background. Poll with: letterstory deploy get ${created.deployment_id}`);
212
+ return 0;
213
+ }
214
+
215
+ const final = await pollUntilTerminal(client, created.deployment_id, io);
216
+ if (final.status === "live") {
217
+ io.log(`\n✓ Live at ${final.url}`);
218
+ return 0;
219
+ }
220
+ io.error(`\n✗ Deployment ${final.status}: ${final.last_error ?? "unknown error"}`);
221
+ return 1;
222
+ }
223
+
224
+ async function deployList(ctx) {
225
+ const { client, flags, io } = ctx;
226
+ const limit = flagNum(flags.limit);
227
+ const result = await client.callTool("list_deployments", limit !== undefined ? { limit } : {});
228
+ const items = result.items ?? [];
229
+ if (flags.json) {
230
+ io.log(JSON.stringify(result, null, 2));
231
+ return 0;
232
+ }
233
+ if (items.length === 0) {
234
+ io.log("No deployments yet. Create one with: letterstory deploy create --name <name>");
235
+ return 0;
236
+ }
237
+ for (const d of items) {
238
+ io.log(`${d.status.padEnd(13)} ${d.deployment_id} ${d.name}${d.url ? ` ${d.url}` : ""}`);
239
+ }
240
+ return 0;
241
+ }
242
+
243
+ async function deployGet(ctx) {
244
+ const { client, positionals, flags, io } = ctx;
245
+ const id = requirePositional(positionals, 0, "deployment-id");
246
+ const d = await client.callTool("get_deployment", { deployment_id: id });
247
+ printResult(io, flags, d, formatDeployment);
248
+ return 0;
249
+ }
250
+
251
+ async function deployRebuild(ctx) {
252
+ const { client, positionals, io } = ctx;
253
+ const id = requirePositional(positionals, 0, "deployment-id");
254
+ await client.callTool("rebuild_deployment", { deployment_id: id });
255
+ io.log(`Rebuild started for ${id}. New published content will appear once the build finishes.`);
256
+ return 0;
257
+ }
258
+
259
+ async function deployDiagnostics(ctx) {
260
+ const { client, positionals, io } = ctx;
261
+ const id = requirePositional(positionals, 0, "deployment-id");
262
+ const diag = await client.callTool("get_deployment_diagnostics", { deployment_id: id });
263
+ io.log(JSON.stringify(diag, null, 2));
264
+ return 0;
265
+ }
266
+
267
+ async function deployDelete(ctx) {
268
+ const { client, positionals, flags, io } = ctx;
269
+ const id = requirePositional(positionals, 0, "deployment-id");
270
+ if (!flags.yes) {
271
+ io.error(`This tears down the blog's site and revokes its content key (the collection is kept).`);
272
+ io.error(`Re-run with --yes to confirm: letterstory deploy delete ${id} --yes`);
273
+ return 1;
274
+ }
275
+ await client.callTool("delete_deployment", { deployment_id: id });
276
+ io.log(`Deleted deployment ${id}.`);
277
+ return 0;
278
+ }
279
+
280
+ // --- custom domains ---------------------------------------------------------
281
+
282
+ export async function cmdDomain(ctx) {
283
+ const sub = ctx.positionals[0];
284
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
285
+ switch (sub) {
286
+ case "check":
287
+ return domainCheck(rest);
288
+ case "buy":
289
+ return domainBuy(rest);
290
+ default:
291
+ throw new CliError(`Unknown domain subcommand: ${sub ?? "(none)"}. Try: check, buy`);
292
+ }
293
+ }
294
+
295
+ async function domainCheck(ctx) {
296
+ const { client, positionals, flags, io } = ctx;
297
+ const domain = requirePositional(positionals, 0, "domain");
298
+ const quote = await client.callTool("check_domain", { domain });
299
+ if (flags.json) {
300
+ io.log(JSON.stringify(quote, null, 2));
301
+ return 0;
302
+ }
303
+ io.log(`${domain}`);
304
+ io.log(` available: ${quote.available}`);
305
+ if (quote.price !== undefined) io.log(` price: $${quote.price}`);
306
+ io.log(` purchasable: ${quote.purchasable}`);
307
+ return 0;
308
+ }
309
+
310
+ async function domainBuy(ctx) {
311
+ const { client, positionals, flags, io } = ctx;
312
+ const id = requirePositional(positionals, 0, "deployment-id");
313
+ const domain = requirePositional(positionals, 1, "domain");
314
+ if (!flags.yes) {
315
+ io.error(`Buying ${domain} SPENDS money (registered under Letterstory, auto-renewing yearly).`);
316
+ io.error(`Price it first with: letterstory domain check ${domain}`);
317
+ io.error(`Then confirm with: letterstory domain buy ${id} ${domain} --yes`);
318
+ return 1;
319
+ }
320
+ const result = await client.callTool("buy_domain", { deployment_id: id, domain });
321
+ io.log(`Purchase started for ${domain}. Attach + DNS run in the background.`);
322
+ io.log(`Poll with: letterstory deploy get ${id}`);
323
+ if (flags.json) io.log(JSON.stringify(result, null, 2));
324
+ return 0;
325
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@letterstory/cli",
3
+ "version": "0.1.0",
4
+ "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "author": "Letterstory",
8
+ "homepage": "https://github.com/letterstory/Letterbrace/tree/main/cli#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/letterstory/Letterbrace.git",
12
+ "directory": "cli"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/letterstory/Letterbrace/issues"
16
+ },
17
+ "keywords": [
18
+ "letterstory",
19
+ "cli",
20
+ "cms",
21
+ "blog",
22
+ "phantom",
23
+ "mcp"
24
+ ],
25
+ "bin": {
26
+ "letterstory": "bin/letterstory.mjs"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "lib",
34
+ "README.md"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }