@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.
@@ -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
+ }