@elixpo/lixblogs-cli 1.1.2
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/API.md +86 -0
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/THREAT_MODEL.md +91 -0
- package/bin/lixblogs.mjs +490 -0
- package/package.json +71 -0
- package/src/api/BlogClient.js +135 -0
- package/src/auth/AuthProvider.js +90 -0
- package/src/auth/AuthenticatedClient.js +116 -0
- package/src/auth/ElixpoAuthProvider.js +281 -0
- package/src/auth/MockAuthProvider.js +170 -0
- package/src/auth/productionGate.js +44 -0
- package/src/commands/auth/login.js +103 -0
- package/src/commands/auth/logout.js +21 -0
- package/src/commands/auth/profiles.js +27 -0
- package/src/commands/auth/revoke.js +45 -0
- package/src/commands/auth/status.js +33 -0
- package/src/commands/blog/index.js +81 -0
- package/src/commands/blog/input.js +59 -0
- package/src/config/CredentialStore.js +142 -0
- package/src/config/KeychainCredentialStore.js +180 -0
- package/src/config/ProfileRegistry.js +105 -0
- package/src/config/config.js +60 -0
- package/src/config/credentialStoreFactory.js +63 -0
- package/src/config/providerFactory.js +42 -0
- package/src/config/redact.js +74 -0
- package/src/content/markdown.js +68 -0
- package/src/content/validate.js +45 -0
package/bin/lixblogs.mjs
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bin/lixblogs.mjs — CLI entry point.
|
|
5
|
+
*
|
|
6
|
+
* Per maintainer direction: zero third-party dependencies for argument
|
|
7
|
+
* parsing — uses Node's native util.parseArgs (built into Node 18+)
|
|
8
|
+
* instead of commander/oclif/etc. UI/branding (panda welcome screen,
|
|
9
|
+
* theming) is Divyanshu's territory later; this file only handles
|
|
10
|
+
* parsing and dispatch, deliberately unstyled for now.
|
|
11
|
+
*
|
|
12
|
+
* Currently wires up `auth login|status|logout|revoke` only, per #137's
|
|
13
|
+
* scope. Other command groups (blog, media, org, stats — see #135) are out
|
|
14
|
+
* of scope for this issue and will be added in follow-up issues.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately thin: all real logic lives in src/commands/**, this file
|
|
17
|
+
* only parses args, resolves config, constructs dependencies via the
|
|
18
|
+
* factories, and calls into the tested command functions. None of that
|
|
19
|
+
* logic changed when swapping the parser out — this is exactly the
|
|
20
|
+
* decoupling that made this swap fast.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { parseArgs } from "node:util";
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { resolveConfig } from "../src/config/config.js";
|
|
26
|
+
import { createAuthProvider } from "../src/config/providerFactory.js";
|
|
27
|
+
import { createCredentialStore } from "../src/config/credentialStoreFactory.js";
|
|
28
|
+
import { safeJsonStringify, redactErrorMessage } from "../src/config/redact.js";
|
|
29
|
+
import { authLogin } from "../src/commands/auth/login.js";
|
|
30
|
+
import { authStatus } from "../src/commands/auth/status.js";
|
|
31
|
+
import { authLogout } from "../src/commands/auth/logout.js";
|
|
32
|
+
import { authRevoke } from "../src/commands/auth/revoke.js";
|
|
33
|
+
import { authProfiles, authUse } from "../src/commands/auth/profiles.js";
|
|
34
|
+
import { ProfileRegistry, validateProfileId } from "../src/config/ProfileRegistry.js";
|
|
35
|
+
import { AuthenticatedClient } from "../src/auth/AuthenticatedClient.js";
|
|
36
|
+
import { BlogClient, BlogApiError } from "../src/api/BlogClient.js";
|
|
37
|
+
import {
|
|
38
|
+
blogCreate,
|
|
39
|
+
blogDelete,
|
|
40
|
+
blogEdit,
|
|
41
|
+
blogGet,
|
|
42
|
+
blogList,
|
|
43
|
+
blogPublish,
|
|
44
|
+
blogRestore,
|
|
45
|
+
blogUnpublish,
|
|
46
|
+
} from "../src/commands/blog/index.js";
|
|
47
|
+
|
|
48
|
+
const OPTIONS = {
|
|
49
|
+
profile: { type: "string" },
|
|
50
|
+
env: { type: "string" },
|
|
51
|
+
json: { type: "boolean", default: false },
|
|
52
|
+
quiet: { type: "boolean", default: false },
|
|
53
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
54
|
+
"allow-insecure-fallback": { type: "boolean", default: false },
|
|
55
|
+
"auth-provider": { type: "string" },
|
|
56
|
+
"accounts-url": { type: "string" },
|
|
57
|
+
"api-url": { type: "string" },
|
|
58
|
+
"client-id": { type: "string" },
|
|
59
|
+
audience: { type: "string" },
|
|
60
|
+
scope: { type: "string", multiple: true },
|
|
61
|
+
open: { type: "boolean", default: false },
|
|
62
|
+
status: { type: "string" },
|
|
63
|
+
limit: { type: "string" },
|
|
64
|
+
cursor: { type: "string" },
|
|
65
|
+
file: { type: "string" },
|
|
66
|
+
stdin: { type: "boolean", default: false },
|
|
67
|
+
content: { type: "string" },
|
|
68
|
+
editor: { type: "boolean", default: false },
|
|
69
|
+
title: { type: "string" },
|
|
70
|
+
subtitle: { type: "string" },
|
|
71
|
+
slug: { type: "string" },
|
|
72
|
+
tag: { type: "string", multiple: true },
|
|
73
|
+
emoji: { type: "string" },
|
|
74
|
+
publication: { type: "string" },
|
|
75
|
+
collection: { type: "string" },
|
|
76
|
+
cover: { type: "string" },
|
|
77
|
+
"member-only": { type: "boolean", default: false },
|
|
78
|
+
"no-member-only": { type: "boolean", default: false },
|
|
79
|
+
secret: { type: "boolean", default: false },
|
|
80
|
+
"not-secret": { type: "boolean", default: false },
|
|
81
|
+
"dry-run": { type: "boolean", default: false },
|
|
82
|
+
"no-input": { type: "boolean", default: false },
|
|
83
|
+
etag: { type: "string" },
|
|
84
|
+
permanent: { type: "boolean", default: false },
|
|
85
|
+
"idempotency-key": { type: "string" },
|
|
86
|
+
help: { type: "boolean", short: "h", default: false },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const HELP_TEXT = `lixblogs — LixBlogs CLI
|
|
90
|
+
|
|
91
|
+
Usage:
|
|
92
|
+
lixblogs auth login [--profile <name>] [--env <environment>] [--json] [--quiet] [--allow-insecure-fallback]
|
|
93
|
+
lixblogs auth status [--profile <name>] [--json]
|
|
94
|
+
lixblogs auth logout [--profile <name>] [--json] [--quiet]
|
|
95
|
+
lixblogs auth revoke [--profile <name>] [--json] [--quiet] --yes
|
|
96
|
+
lixblogs auth profiles [--json]
|
|
97
|
+
lixblogs auth use <name> [--json]
|
|
98
|
+
lixblogs blog list [--status <status>] [--limit <n>] [--cursor <cursor>] [--json]
|
|
99
|
+
lixblogs blog get <id> [--json]
|
|
100
|
+
lixblogs blog create [--file <post.md>|--stdin|--content <markdown>|--editor] [metadata]
|
|
101
|
+
lixblogs blog edit <id> [--file <post.md>|--stdin|--content <markdown>|--editor] [metadata]
|
|
102
|
+
lixblogs blog publish <id> [--dry-run] [--json]
|
|
103
|
+
lixblogs blog unpublish <id> [--dry-run] [--json]
|
|
104
|
+
lixblogs blog delete <id> --yes [--permanent] [--dry-run] [--json]
|
|
105
|
+
lixblogs blog restore <id> [--dry-run] [--json]
|
|
106
|
+
|
|
107
|
+
Global flags:
|
|
108
|
+
--profile <name> named profile to use (default: "default")
|
|
109
|
+
--env <environment> override environment (development|staging|production)
|
|
110
|
+
--auth-provider <provider> elixpo, or mock in development/test only
|
|
111
|
+
--accounts-url <url> override the Accounts discovery origin
|
|
112
|
+
--api-url <url> LixBlogs API origin (default: https://blogs.elixpo.com)
|
|
113
|
+
--scope <scope> request an OAuth scope (repeatable)
|
|
114
|
+
--file <path> read blog Markdown from a file
|
|
115
|
+
--stdin read blog Markdown from stdin
|
|
116
|
+
--content <markdown> use inline Markdown
|
|
117
|
+
--editor open the current blog in $EDITOR
|
|
118
|
+
--title/--subtitle/--slug update blog metadata
|
|
119
|
+
--tag <tag> set a tag (repeatable, up to five)
|
|
120
|
+
--publication <target> personal or org:<id>
|
|
121
|
+
--collection <id> organization collection ID
|
|
122
|
+
--dry-run validate and show the intended action without writing
|
|
123
|
+
--permanent permanently delete instead of moving to trash
|
|
124
|
+
--open open the complete device verification URL
|
|
125
|
+
--json machine-readable JSON output
|
|
126
|
+
--quiet suppress non-essential output
|
|
127
|
+
--yes, -y auto-confirm destructive actions (required for revoke)
|
|
128
|
+
--allow-insecure-fallback explicit opt-in: if the OS keychain is unavailable, use a
|
|
129
|
+
non-persistent in-memory store instead of failing
|
|
130
|
+
--help, -h show this help
|
|
131
|
+
|
|
132
|
+
Note: interactive confirmation prompting is not implemented yet (CLI-shell/UX
|
|
133
|
+
work, a later issue) — destructive actions require --yes explicitly, always.
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
const DEFAULT_SCOPES = [
|
|
137
|
+
"openid", "profile", "email",
|
|
138
|
+
"lixblogs:profile:read", "lixblogs:blog:read",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
function configFlags(opts) {
|
|
142
|
+
return {
|
|
143
|
+
profile: opts.profile,
|
|
144
|
+
env: opts.env,
|
|
145
|
+
authProvider: opts["auth-provider"],
|
|
146
|
+
accountsUrl: opts["accounts-url"],
|
|
147
|
+
apiUrl: opts["api-url"],
|
|
148
|
+
clientId: opts["client-id"],
|
|
149
|
+
audience: opts.audience,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function selectedProfile(config, registry) {
|
|
154
|
+
if (config.profileExplicit) return validateProfileId(config.profile);
|
|
155
|
+
return (await registry.getActive()) || validateProfileId(config.profile);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function openBrowser(url) {
|
|
159
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
160
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
161
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
162
|
+
child.on("error", () => {});
|
|
163
|
+
child.unref();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function output(opts, data) {
|
|
167
|
+
if (opts.json) {
|
|
168
|
+
process.stdout.write(safeJsonStringify(data) + "\n");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fail(opts, message, exitCode = 1) {
|
|
173
|
+
const safeMessage = redactErrorMessage(message);
|
|
174
|
+
if (opts.json) {
|
|
175
|
+
process.stdout.write(safeJsonStringify({ ok: false, error: safeMessage }) + "\n");
|
|
176
|
+
} else if (!opts.quiet) {
|
|
177
|
+
process.stderr.write(`Error: ${safeMessage}\n`);
|
|
178
|
+
}
|
|
179
|
+
process.exitCode = exitCode;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Shared helper: constructs the credential store, surfacing a
|
|
184
|
+
* CredentialStoreUnavailableError as a clean CLI-level failure (via fail())
|
|
185
|
+
* rather than an uncaught stack trace, and pointing the user at
|
|
186
|
+
* --allow-insecure-fallback if they haven't already opted in.
|
|
187
|
+
* @returns {Promise<import("../src/config/CredentialStore.js").CredentialStore | null>}
|
|
188
|
+
* null if construction failed and fail() was already called.
|
|
189
|
+
*/
|
|
190
|
+
async function getCredentialStoreOrFail(opts, profileRegistry) {
|
|
191
|
+
try {
|
|
192
|
+
return await createCredentialStore({
|
|
193
|
+
allowInsecureFallback: opts["allow-insecure-fallback"],
|
|
194
|
+
profileRegistry,
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
fail(
|
|
198
|
+
opts,
|
|
199
|
+
`${err.message}${opts["allow-insecure-fallback"] ? "" : " Re-run with --allow-insecure-fallback to opt in to non-persistent storage instead."}`
|
|
200
|
+
);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runLogin(opts) {
|
|
206
|
+
const config = resolveConfig({ flags: configFlags(opts) });
|
|
207
|
+
const profileRegistry = new ProfileRegistry();
|
|
208
|
+
const profileId = await selectedProfile(config, profileRegistry);
|
|
209
|
+
|
|
210
|
+
let provider;
|
|
211
|
+
try {
|
|
212
|
+
provider = createAuthProvider(config);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return fail(opts, err.message);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
|
|
218
|
+
if (!credentialStore) return;
|
|
219
|
+
|
|
220
|
+
const result = await authLogin({
|
|
221
|
+
provider,
|
|
222
|
+
credentialStore,
|
|
223
|
+
profileId,
|
|
224
|
+
scopes: opts.scope?.length ? opts.scope : DEFAULT_SCOPES,
|
|
225
|
+
openBrowser: opts.open ? openBrowser : undefined,
|
|
226
|
+
onStatus: (status) => {
|
|
227
|
+
if (opts.json) {
|
|
228
|
+
output(opts, { event: status.type, ...status });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (opts.quiet) return;
|
|
232
|
+
if (status.type === "verification_pending") {
|
|
233
|
+
console.log(`To log in, visit: ${status.verificationUriComplete || status.verificationUri}`);
|
|
234
|
+
console.log(`Enter code: ${status.userCode}`);
|
|
235
|
+
console.log(`(expires in ${status.expiresInSeconds}s)`);
|
|
236
|
+
} else if (status.type === "pending") {
|
|
237
|
+
console.log("Waiting for approval...");
|
|
238
|
+
} else if (status.type === "slow_down") {
|
|
239
|
+
console.log("Slowing down polling as requested by the server...");
|
|
240
|
+
} else if (status.type === "approved") {
|
|
241
|
+
console.log("Login approved.");
|
|
242
|
+
} else if (status.type === "denied") {
|
|
243
|
+
console.log("Login was denied.");
|
|
244
|
+
} else if (status.type === "expired") {
|
|
245
|
+
console.log("Device code expired.");
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (!result.ok) {
|
|
251
|
+
return fail(opts, result.reason);
|
|
252
|
+
}
|
|
253
|
+
await profileRegistry.add(result.profileId);
|
|
254
|
+
await profileRegistry.setActive(result.profileId);
|
|
255
|
+
output(opts, { ok: true, profile: result.profileId });
|
|
256
|
+
if (!opts.json && !opts.quiet) {
|
|
257
|
+
console.log(`Logged in as profile "${result.profileId}".`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function runStatus(opts) {
|
|
262
|
+
const config = resolveConfig({ flags: configFlags(opts) });
|
|
263
|
+
const profileRegistry = new ProfileRegistry();
|
|
264
|
+
const profileId = await selectedProfile(config, profileRegistry);
|
|
265
|
+
const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
|
|
266
|
+
if (!credentialStore) return;
|
|
267
|
+
|
|
268
|
+
const result = await authStatus({ credentialStore, profileId });
|
|
269
|
+
|
|
270
|
+
output(opts, result);
|
|
271
|
+
if (!opts.json) {
|
|
272
|
+
for (const entry of result) {
|
|
273
|
+
if (!entry.loggedIn) {
|
|
274
|
+
console.log(`${entry.profileId}: not logged in`);
|
|
275
|
+
} else {
|
|
276
|
+
console.log(
|
|
277
|
+
`${entry.profileId}: logged in${entry.expired ? " (expired)" : ""} — scopes: ${entry.scopes.join(", ")}`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function runLogout(opts) {
|
|
285
|
+
const config = resolveConfig({ flags: configFlags(opts) });
|
|
286
|
+
const profileRegistry = new ProfileRegistry();
|
|
287
|
+
const profileId = await selectedProfile(config, profileRegistry);
|
|
288
|
+
const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
|
|
289
|
+
if (!credentialStore) return;
|
|
290
|
+
|
|
291
|
+
const result = await authLogout({ credentialStore, profileId });
|
|
292
|
+
output(opts, result);
|
|
293
|
+
if (!opts.json && !opts.quiet) {
|
|
294
|
+
console.log(`Logged out profile "${profileId}".`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function runRevoke(opts) {
|
|
299
|
+
const config = resolveConfig({ flags: configFlags(opts) });
|
|
300
|
+
const profileRegistry = new ProfileRegistry();
|
|
301
|
+
const profileId = await selectedProfile(config, profileRegistry);
|
|
302
|
+
|
|
303
|
+
// Destructive action: per #135, cannot run accidentally in a
|
|
304
|
+
// non-interactive session. Interactive confirmation prompting is
|
|
305
|
+
// CLI-shell/UX work (later issue) — for now, --yes is the only
|
|
306
|
+
// supported path, and omitting it fails closed rather than silently
|
|
307
|
+
// proceeding or silently doing nothing.
|
|
308
|
+
if (!opts.yes) {
|
|
309
|
+
return fail(
|
|
310
|
+
opts,
|
|
311
|
+
"This is a destructive action. Re-run with --yes to confirm (interactive confirmation prompt not yet implemented)."
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let provider;
|
|
316
|
+
try {
|
|
317
|
+
provider = createAuthProvider(config);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return fail(opts, err.message);
|
|
320
|
+
}
|
|
321
|
+
const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
|
|
322
|
+
if (!credentialStore) return;
|
|
323
|
+
|
|
324
|
+
const result = await authRevoke({
|
|
325
|
+
provider,
|
|
326
|
+
credentialStore,
|
|
327
|
+
profileId,
|
|
328
|
+
confirmed: true,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (!result.ok) {
|
|
332
|
+
return fail(opts, result.reason);
|
|
333
|
+
}
|
|
334
|
+
output(opts, result);
|
|
335
|
+
if (!opts.json && !opts.quiet) {
|
|
336
|
+
console.log(`Revoked and logged out profile "${profileId}".`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function runProfiles(opts) {
|
|
341
|
+
const profileRegistry = new ProfileRegistry();
|
|
342
|
+
const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
|
|
343
|
+
if (!credentialStore) return;
|
|
344
|
+
const result = await authProfiles({ credentialStore, profileRegistry });
|
|
345
|
+
output(opts, result);
|
|
346
|
+
if (!opts.json) {
|
|
347
|
+
if (!result.profiles.length) console.log("No profiles. Run `lixblogs auth login` first.");
|
|
348
|
+
for (const profile of result.profiles) {
|
|
349
|
+
console.log(`${profile.active ? "*" : " "} ${profile.profileId}${profile.expired ? " (expired)" : ""}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runUse(opts, args) {
|
|
355
|
+
let profileId;
|
|
356
|
+
try {
|
|
357
|
+
profileId = validateProfileId(args[0]);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
return fail(opts, error.message);
|
|
360
|
+
}
|
|
361
|
+
const profileRegistry = new ProfileRegistry();
|
|
362
|
+
const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
|
|
363
|
+
if (!credentialStore) return;
|
|
364
|
+
const result = await authUse({ credentialStore, profileRegistry, profileId });
|
|
365
|
+
if (!result.ok) return fail(opts, result.reason);
|
|
366
|
+
output(opts, result);
|
|
367
|
+
if (!opts.json && !opts.quiet) console.log(`Using profile "${profileId}".`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const BLOG_COMMANDS = {
|
|
371
|
+
list: blogList,
|
|
372
|
+
get: blogGet,
|
|
373
|
+
create: blogCreate,
|
|
374
|
+
edit: blogEdit,
|
|
375
|
+
publish: blogPublish,
|
|
376
|
+
unpublish: blogUnpublish,
|
|
377
|
+
delete: blogDelete,
|
|
378
|
+
restore: blogRestore,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
async function runBlog(opts, args, action) {
|
|
382
|
+
const config = resolveConfig({ flags: configFlags(opts) });
|
|
383
|
+
const profileRegistry = new ProfileRegistry();
|
|
384
|
+
const profileId = await selectedProfile(config, profileRegistry);
|
|
385
|
+
const credentialStore = await getCredentialStoreOrFail(opts, profileRegistry);
|
|
386
|
+
if (!credentialStore) return;
|
|
387
|
+
let provider;
|
|
388
|
+
try { provider = createAuthProvider(config); } catch (error) { return fail(opts, error.message); }
|
|
389
|
+
const http = new AuthenticatedClient({
|
|
390
|
+
provider, credentialStore, profileId, apiBaseUrl: config.apiBaseUrl,
|
|
391
|
+
});
|
|
392
|
+
const client = new BlogClient(http);
|
|
393
|
+
const normalized = {
|
|
394
|
+
...opts,
|
|
395
|
+
limit: opts.limit === undefined ? undefined : Number.parseInt(opts.limit, 10),
|
|
396
|
+
};
|
|
397
|
+
try {
|
|
398
|
+
const result = await BLOG_COMMANDS[action]({
|
|
399
|
+
client, id: args[0], options: normalized, stdin: process.stdin,
|
|
400
|
+
});
|
|
401
|
+
output(opts, { ok: true, ...result });
|
|
402
|
+
if (!opts.json && !opts.quiet) {
|
|
403
|
+
if (action === 'list') {
|
|
404
|
+
for (const blog of result.data || []) console.log(`${blog.id}\t${blog.status}\t${blog.title || '(untitled)'}`);
|
|
405
|
+
if (result.meta?.nextCursor) console.log(`Next cursor: ${result.meta.nextCursor}`);
|
|
406
|
+
} else if (action === 'get') {
|
|
407
|
+
console.log(`${result.title || '(untitled)'} [${result.status}]\n${result.markdown || ''}`);
|
|
408
|
+
} else if (result.dryRun) {
|
|
409
|
+
console.log(`Dry run: ${action} validated; no changes sent.`);
|
|
410
|
+
} else {
|
|
411
|
+
console.log(result.url || `${action} completed for ${result.id}.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (opts.json && error instanceof BlogApiError) {
|
|
416
|
+
process.stdout.write(safeJsonStringify({
|
|
417
|
+
ok: false,
|
|
418
|
+
error: { code: error.code, message: error.message, requestId: error.requestId, details: error.details },
|
|
419
|
+
}) + '\n');
|
|
420
|
+
process.exitCode = error.status === 412 ? 3 : 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
return fail(opts, `${error.message}${error.requestId ? ` (request ${error.requestId})` : ''}`, error.status === 412 ? 3 : 1);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const ROUTES = {
|
|
428
|
+
auth: {
|
|
429
|
+
login: runLogin,
|
|
430
|
+
status: runStatus,
|
|
431
|
+
logout: runLogout,
|
|
432
|
+
revoke: runRevoke,
|
|
433
|
+
profiles: runProfiles,
|
|
434
|
+
use: runUse,
|
|
435
|
+
},
|
|
436
|
+
blog: Object.fromEntries(Object.keys(BLOG_COMMANDS).map((action) => [
|
|
437
|
+
action,
|
|
438
|
+
(opts, args) => runBlog(opts, args, action),
|
|
439
|
+
])),
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
async function main() {
|
|
443
|
+
let values, positionals;
|
|
444
|
+
try {
|
|
445
|
+
({ values, positionals } = parseArgs({
|
|
446
|
+
args: process.argv.slice(2),
|
|
447
|
+
options: OPTIONS,
|
|
448
|
+
allowPositionals: true,
|
|
449
|
+
strict: true,
|
|
450
|
+
}));
|
|
451
|
+
} catch (err) {
|
|
452
|
+
// strict: true makes parseArgs throw ERR_PARSE_ARGS_UNKNOWN_OPTION for
|
|
453
|
+
// unrecognized flags rather than silently ignoring them — surface that
|
|
454
|
+
// clearly instead of an unhandled exception.
|
|
455
|
+
process.stderr.write(`Error: Invalid flag. ${err.message}\n`);
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (values.help || positionals.length === 0) {
|
|
461
|
+
process.stdout.write(HELP_TEXT);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const [category, action] = positionals;
|
|
466
|
+
const categoryRoutes = ROUTES[category];
|
|
467
|
+
|
|
468
|
+
if (!categoryRoutes) {
|
|
469
|
+
process.stderr.write(`Error: Unknown command category "${category}".\n`);
|
|
470
|
+
process.stderr.write(`Available categories: ${Object.keys(ROUTES).join(", ")}\n`);
|
|
471
|
+
process.exitCode = 1;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const handler = categoryRoutes[action];
|
|
476
|
+
if (!handler) {
|
|
477
|
+
process.stderr.write(`Error: Unknown ${category} command "${action}".\n`);
|
|
478
|
+
process.stderr.write(
|
|
479
|
+
`Available commands: ${Object.keys(categoryRoutes)
|
|
480
|
+
.map((a) => `${category} ${a}`)
|
|
481
|
+
.join(", ")}\n`
|
|
482
|
+
);
|
|
483
|
+
process.exitCode = 1;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
await handler(values, positionals.slice(2));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elixpo/lixblogs-cli",
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "Official CLI for LixBlogs — publish, manage, and inspect blogs through the supported API. Built for creators and agent automation.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Elixpo",
|
|
7
|
+
"email": "hello@elixpo.com",
|
|
8
|
+
"url": "https://elixpo.com"
|
|
9
|
+
},
|
|
10
|
+
"contributors": [
|
|
11
|
+
{
|
|
12
|
+
"name": "Ayushman Bhattacharya",
|
|
13
|
+
"url": "https://github.com/Circuit-Overtime"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"lixblogs": "bin/lixblogs.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"bin",
|
|
23
|
+
"README.md",
|
|
24
|
+
"API.md",
|
|
25
|
+
"THREAT_MODEL.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test tests/*.test.mjs",
|
|
29
|
+
"prepublishOnly": "npm test"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@napi-rs/keyring": "^1.3.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"lixblogs",
|
|
39
|
+
"elixpo",
|
|
40
|
+
"blog",
|
|
41
|
+
"blogging",
|
|
42
|
+
"markdown",
|
|
43
|
+
"cli",
|
|
44
|
+
"publishing",
|
|
45
|
+
"cms",
|
|
46
|
+
"creator-tools",
|
|
47
|
+
"content-management",
|
|
48
|
+
"oauth2",
|
|
49
|
+
"device-flow",
|
|
50
|
+
"automation",
|
|
51
|
+
"terminal"
|
|
52
|
+
],
|
|
53
|
+
"os": [
|
|
54
|
+
"darwin",
|
|
55
|
+
"linux",
|
|
56
|
+
"win32"
|
|
57
|
+
],
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/elixpo/blogs.elixpo.git",
|
|
62
|
+
"directory": "packages/lixblogs-cli"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://blogs.elixpo.com",
|
|
65
|
+
"bugs": {
|
|
66
|
+
"url": "https://github.com/elixpo/blogs.elixpo/issues"
|
|
67
|
+
},
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"access": "public"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export class BlogApiError extends Error {
|
|
4
|
+
constructor(code, message, { status, requestId, details } = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'BlogApiError';
|
|
7
|
+
this.code = code || 'api_error';
|
|
8
|
+
this.status = status || 0;
|
|
9
|
+
this.requestId = requestId || null;
|
|
10
|
+
this.details = details || null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function parseResponse(response) {
|
|
15
|
+
let payload;
|
|
16
|
+
try { payload = await response.json(); } catch { payload = null; }
|
|
17
|
+
if (!response.ok || payload?.error) {
|
|
18
|
+
throw new BlogApiError(
|
|
19
|
+
payload?.error?.code || `http_${response.status}`,
|
|
20
|
+
payload?.error?.message || `LixBlogs returned HTTP ${response.status}.`,
|
|
21
|
+
{
|
|
22
|
+
status: response.status,
|
|
23
|
+
requestId: payload?.error?.requestId || response.headers.get('x-request-id'),
|
|
24
|
+
details: payload?.error?.details,
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return { payload, etag: response.headers.get('etag') };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class BlogClient {
|
|
32
|
+
constructor(authenticatedClient, { sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) } = {}) {
|
|
33
|
+
this.http = authenticatedClient;
|
|
34
|
+
this.sleep = sleep;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async request(path, options = {}) {
|
|
38
|
+
const requestOptions = {
|
|
39
|
+
...options,
|
|
40
|
+
headers: {
|
|
41
|
+
accept: 'application/json',
|
|
42
|
+
...(options.body ? { 'content-type': 'application/json' } : {}),
|
|
43
|
+
...options.headers,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const method = requestOptions.method || 'GET';
|
|
47
|
+
const retryable = method === 'GET' || Boolean(requestOptions.headers['idempotency-key']);
|
|
48
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
49
|
+
try {
|
|
50
|
+
const response = await this.http.request(path, requestOptions);
|
|
51
|
+
if (retryable && attempt === 0 && (response.status === 429 || response.status >= 500)) {
|
|
52
|
+
const seconds = Math.min(2, Number.parseInt(response.headers.get('retry-after') || '1', 10) || 1);
|
|
53
|
+
await this.sleep(seconds * 1000);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
return parseResponse(response);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!retryable || attempt > 0 || error instanceof BlogApiError || error?.code) throw error;
|
|
59
|
+
await this.sleep(250);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw new BlogApiError('request_failed', 'The LixBlogs request failed after retrying.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async requireScopes(scopes) {
|
|
66
|
+
if (typeof this.http.requireScopes === 'function') await this.http.requireScopes(scopes);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async list({ status = 'all', limit = 20, cursor } = {}) {
|
|
70
|
+
await this.requireScopes(['lixblogs:blog:read']);
|
|
71
|
+
const query = new URLSearchParams({ status, limit: String(limit) });
|
|
72
|
+
if (cursor) query.set('cursor', cursor);
|
|
73
|
+
return (await this.request(`/api/v1/blogs?${query}`)).payload;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async get(id) {
|
|
77
|
+
await this.requireScopes(['lixblogs:blog:read']);
|
|
78
|
+
const result = await this.request(`/api/v1/blogs/${encodeURIComponent(id)}`);
|
|
79
|
+
return { ...result.payload.data, etag: result.etag || result.payload.data.etag };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async create(input, { idempotencyKey = randomUUID() } = {}) {
|
|
83
|
+
await this.requireScopes(['lixblogs:blog:write']);
|
|
84
|
+
return (await this.request('/api/v1/blogs', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'idempotency-key': idempotencyKey },
|
|
87
|
+
body: JSON.stringify(input),
|
|
88
|
+
})).payload.data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async update(id, input, { etag }) {
|
|
92
|
+
await this.requireScopes(['lixblogs:blog:write']);
|
|
93
|
+
return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}`, {
|
|
94
|
+
method: 'PATCH',
|
|
95
|
+
headers: { 'if-match': etag },
|
|
96
|
+
body: JSON.stringify(input),
|
|
97
|
+
})).payload.data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async publish(id, { etag, idempotencyKey = randomUUID() }) {
|
|
101
|
+
await this.requireScopes(['lixblogs:blog:publish']);
|
|
102
|
+
return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}/publish`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'if-match': etag, 'idempotency-key': idempotencyKey },
|
|
105
|
+
})).payload.data;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async unpublish(id, { etag }) {
|
|
109
|
+
await this.requireScopes(['lixblogs:blog:publish']);
|
|
110
|
+
return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}/unpublish`, {
|
|
111
|
+
method: 'POST', headers: { 'if-match': etag },
|
|
112
|
+
})).payload.data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async delete(id, { etag, permanent = false }) {
|
|
116
|
+
await this.requireScopes([
|
|
117
|
+
'lixblogs:blog:delete',
|
|
118
|
+
...(permanent ? ['lixblogs:blog:delete:permanent'] : []),
|
|
119
|
+
]);
|
|
120
|
+
return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}${permanent ? '?permanent=true' : ''}`, {
|
|
121
|
+
method: 'DELETE',
|
|
122
|
+
headers: {
|
|
123
|
+
'if-match': etag,
|
|
124
|
+
...(permanent ? { 'x-confirm-permanent-delete': id } : {}),
|
|
125
|
+
},
|
|
126
|
+
})).payload.data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async restore(id, { etag }) {
|
|
130
|
+
await this.requireScopes(['lixblogs:blog:delete']);
|
|
131
|
+
return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}/restore`, {
|
|
132
|
+
method: 'POST', headers: { 'if-match': etag },
|
|
133
|
+
})).payload.data;
|
|
134
|
+
}
|
|
135
|
+
}
|