@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 +77 -0
- package/bin/letterstory.mjs +12 -0
- package/lib/cli.mjs +152 -0
- package/lib/client.mjs +137 -0
- package/lib/commands.mjs +325 -0
- package/package.json +39 -0
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
|
+
}
|
package/lib/commands.mjs
ADDED
|
@@ -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
|
+
}
|