@letterstory/cli 0.1.0 → 0.2.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 +119 -13
- package/bin/phantom.mjs +16 -0
- package/lib/cli.mjs +139 -18
- package/lib/client.mjs +78 -16
- package/lib/commands/auth.mjs +125 -0
- package/lib/commands/collections.mjs +93 -0
- package/lib/commands/connectors.mjs +56 -0
- package/lib/commands/deploy.mjs +241 -0
- package/lib/commands/discovery.mjs +43 -0
- package/lib/commands/flows.mjs +83 -0
- package/lib/commands/insights.mjs +51 -0
- package/lib/commands/posts.mjs +122 -0
- package/lib/commands/shared.mjs +95 -0
- package/lib/commands/strategy.mjs +209 -0
- package/lib/commands.mjs +15 -325
- package/lib/oauth.mjs +204 -0
- package/package.json +3 -2
package/lib/commands.mjs
CHANGED
|
@@ -1,325 +1,15 @@
|
|
|
1
|
-
// Command handlers
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
}
|
|
1
|
+
// Command handlers, split into focused modules under ./commands/*.mjs as the surface
|
|
2
|
+
// grew past a single-file's worth of clarity (auth, discovery, deploy/domain, posts,
|
|
3
|
+
// collections, flows, connectors, strategy, insights). This file re-exports
|
|
4
|
+
// everything so `cli.mjs` — and any test importing straight from "commands.mjs" —
|
|
5
|
+
// keep a single, stable import path regardless of how the implementation is split.
|
|
6
|
+
|
|
7
|
+
export * from "./commands/auth.mjs";
|
|
8
|
+
export * from "./commands/discovery.mjs";
|
|
9
|
+
export * from "./commands/deploy.mjs";
|
|
10
|
+
export * from "./commands/posts.mjs";
|
|
11
|
+
export * from "./commands/collections.mjs";
|
|
12
|
+
export * from "./commands/flows.mjs";
|
|
13
|
+
export * from "./commands/connectors.mjs";
|
|
14
|
+
export * from "./commands/strategy.mjs";
|
|
15
|
+
export * from "./commands/insights.mjs";
|
package/lib/oauth.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Browser-based OAuth 2.1 login (RFC 8252 loopback redirect + PKCE) for the
|
|
2
|
+
// Letterstory CLI. This talks to the app's own /api/oauth/* endpoints — see
|
|
3
|
+
// src/lib/oauth/core.ts on the server side for the matching implementation.
|
|
4
|
+
|
|
5
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { CliError } from "./client.mjs";
|
|
9
|
+
|
|
10
|
+
export const CLIENT_ID = "letterstory_cli";
|
|
11
|
+
const CALLBACK_TIMEOUT_MS = 180_000;
|
|
12
|
+
|
|
13
|
+
function generatePkce() {
|
|
14
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
15
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
16
|
+
return { verifier, challenge };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function openBrowser(url) {
|
|
20
|
+
let cmd, args;
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
cmd = "open";
|
|
23
|
+
args = [url];
|
|
24
|
+
} else if (process.platform === "win32") {
|
|
25
|
+
cmd = "cmd";
|
|
26
|
+
args = ["/c", "start", '""', url];
|
|
27
|
+
} else {
|
|
28
|
+
cmd = "xdg-open";
|
|
29
|
+
args = [url];
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
33
|
+
child.on("error", () => {});
|
|
34
|
+
child.unref();
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const CALLBACK_HTML_OK = `<!doctype html><html><head><title>Letterstory CLI</title></head>
|
|
42
|
+
<body style="font-family: system-ui, sans-serif; max-width: 28rem; margin: 4rem auto; text-align: center;">
|
|
43
|
+
<h2>You're signed in.</h2><p>You can close this tab and return to your terminal.</p>
|
|
44
|
+
</body></html>`;
|
|
45
|
+
|
|
46
|
+
const CALLBACK_HTML_ERROR = `<!doctype html><html><head><title>Letterstory CLI</title></head>
|
|
47
|
+
<body style="font-family: system-ui, sans-serif; max-width: 28rem; margin: 4rem auto; text-align: center;">
|
|
48
|
+
<h2>Sign-in failed.</h2><p>Return to your terminal for details.</p>
|
|
49
|
+
</body></html>`;
|
|
50
|
+
|
|
51
|
+
// RFC 8252 §7.3: bind to loopback only, let the OS pick an ephemeral port,
|
|
52
|
+
// and use whatever the OS gave us as part of the registered-but-portless
|
|
53
|
+
// redirect_uri sent to /authorize.
|
|
54
|
+
function startLoopbackServer() {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const server = createServer();
|
|
57
|
+
let settled = false;
|
|
58
|
+
|
|
59
|
+
const resultPromise = new Promise((resolveResult) => {
|
|
60
|
+
server.on("request", (req, res) => {
|
|
61
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
62
|
+
if (url.pathname !== "/callback") {
|
|
63
|
+
res.writeHead(404).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const code = url.searchParams.get("code");
|
|
67
|
+
const state = url.searchParams.get("state");
|
|
68
|
+
const error = url.searchParams.get("error");
|
|
69
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
70
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
71
|
+
res.end(error ? CALLBACK_HTML_ERROR : CALLBACK_HTML_OK);
|
|
72
|
+
if (!settled) {
|
|
73
|
+
settled = true;
|
|
74
|
+
resolveResult(error ? { error, errorDescription } : { code, state });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
server.on("error", (err) => {
|
|
78
|
+
if (!settled) {
|
|
79
|
+
settled = true;
|
|
80
|
+
reject(err);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
server.listen(0, "127.0.0.1", () => {
|
|
86
|
+
const { port } = server.address();
|
|
87
|
+
resolve({
|
|
88
|
+
port,
|
|
89
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
90
|
+
waitForCallback: () => resultPromise,
|
|
91
|
+
close: () => new Promise((r) => server.close(r)),
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function withTimeout(promise, ms, message) {
|
|
98
|
+
let timer;
|
|
99
|
+
const timeout = new Promise((_, reject) => {
|
|
100
|
+
timer = setTimeout(() => reject(new CliError(message)), ms);
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
return await Promise.race([promise, timeout]);
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function tokenRequest(url, body, fetchImpl) {
|
|
110
|
+
const endpoint = `${url.replace(/\/+$/, "")}/api/oauth/token`;
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await fetchImpl(endpoint, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
116
|
+
body: new URLSearchParams(body).toString(),
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new CliError(`Could not reach ${endpoint}: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
const json = await res.json().catch(() => null);
|
|
122
|
+
if (!res.ok || !json?.access_token) {
|
|
123
|
+
const detail = json?.error_description || json?.error || `HTTP ${res.status}`;
|
|
124
|
+
throw new CliError(`Login failed: ${detail}`);
|
|
125
|
+
}
|
|
126
|
+
return json;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Opens the system browser, runs a one-shot loopback server, and exchanges
|
|
130
|
+
// the resulting code for a token pair. Scope is left to the server's
|
|
131
|
+
// default (the CLI client's full registered scope, including offline_access)
|
|
132
|
+
// so a plain `letterstory login` always comes back with a refresh token.
|
|
133
|
+
export async function browserLogin({ url, io, fetchImpl = globalThis.fetch }) {
|
|
134
|
+
const { verifier, challenge } = generatePkce();
|
|
135
|
+
const state = randomBytes(16).toString("hex");
|
|
136
|
+
const server = await startLoopbackServer();
|
|
137
|
+
|
|
138
|
+
const authUrl = new URL(`${url.replace(/\/+$/, "")}/api/oauth/authorize`);
|
|
139
|
+
authUrl.searchParams.set("response_type", "code");
|
|
140
|
+
authUrl.searchParams.set("client_id", CLIENT_ID);
|
|
141
|
+
authUrl.searchParams.set("redirect_uri", server.redirectUri);
|
|
142
|
+
authUrl.searchParams.set("state", state);
|
|
143
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
144
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
145
|
+
|
|
146
|
+
io.log(`Opening your browser to sign in…`);
|
|
147
|
+
io.log(`If it doesn't open automatically, visit:\n ${authUrl.toString()}\n`);
|
|
148
|
+
openBrowser(authUrl.toString());
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const result = await withTimeout(
|
|
152
|
+
server.waitForCallback(),
|
|
153
|
+
CALLBACK_TIMEOUT_MS,
|
|
154
|
+
"Timed out waiting for the browser sign-in. Please try again."
|
|
155
|
+
);
|
|
156
|
+
if (result.error) {
|
|
157
|
+
throw new CliError(`Sign-in was not completed: ${result.errorDescription || result.error}`);
|
|
158
|
+
}
|
|
159
|
+
if (result.state !== state) {
|
|
160
|
+
throw new CliError("Sign-in response failed a security check (state mismatch). Please try again.");
|
|
161
|
+
}
|
|
162
|
+
if (!result.code) {
|
|
163
|
+
throw new CliError("Sign-in did not return an authorization code. Please try again.");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tokens = await tokenRequest(
|
|
167
|
+
url,
|
|
168
|
+
{
|
|
169
|
+
grant_type: "authorization_code",
|
|
170
|
+
client_id: CLIENT_ID,
|
|
171
|
+
code: result.code,
|
|
172
|
+
redirect_uri: server.redirectUri,
|
|
173
|
+
code_verifier: verifier,
|
|
174
|
+
},
|
|
175
|
+
fetchImpl
|
|
176
|
+
);
|
|
177
|
+
return tokens;
|
|
178
|
+
} finally {
|
|
179
|
+
await server.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function refreshAccessToken({ url, refreshToken, fetchImpl = globalThis.fetch }) {
|
|
184
|
+
return tokenRequest(
|
|
185
|
+
url,
|
|
186
|
+
{ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken },
|
|
187
|
+
fetchImpl
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Best-effort: RFC 7009 revocation always "succeeds" server-side, and a
|
|
192
|
+
// network failure here shouldn't block a local logout.
|
|
193
|
+
export async function revokeToken({ url, token, fetchImpl = globalThis.fetch }) {
|
|
194
|
+
const endpoint = `${url.replace(/\/+$/, "")}/api/oauth/revoke`;
|
|
195
|
+
try {
|
|
196
|
+
await fetchImpl(endpoint, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
199
|
+
body: new URLSearchParams({ token, client_id: CLIENT_ID }).toString(),
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
// ignore — logout still clears local state
|
|
203
|
+
}
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letterstory/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Spin up and manage Letterstory phantom blogs from your terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"mcp"
|
|
24
24
|
],
|
|
25
25
|
"bin": {
|
|
26
|
-
"letterstory": "bin/letterstory.mjs"
|
|
26
|
+
"letterstory": "bin/letterstory.mjs",
|
|
27
|
+
"phantom": "bin/phantom.mjs"
|
|
27
28
|
},
|
|
28
29
|
"engines": {
|
|
29
30
|
"node": ">=20"
|