outrank-cli 0.7.0 → 0.8.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.
@@ -0,0 +1,873 @@
1
+ import {
2
+ BLOG_ANALYTICS_RANGES,
3
+ BLOG_DOMAIN_MODES,
4
+ BLOG_LIST_MIN_PAGE,
5
+ BLOG_MAX_POSITIONS_PER_REQUEST,
6
+ BLOG_PAGE_LAYOUTS,
7
+ BLOG_PAGE_STATUSES,
8
+ BLOG_POSITION_MAX,
9
+ BLOG_POST_LIST_STATUSES,
10
+ BLOG_PREVIEW_DESIGN_SCOPE,
11
+ BLOG_PREVIEW_SCOPES,
12
+ BLOG_REDIRECT_GONE_STATUS_CODE,
13
+ BLOG_REDIRECT_STATUS_CODES,
14
+ BLOG_SNIPPET_KINDS,
15
+ BLOG_SNIPPET_KINDS_WITH_REQUIRED_CONTENT,
16
+ BLOG_SUBSCRIBER_STATUSES,
17
+ BLOG_TEMPLATES,
18
+ BLOG_WEBHOOK_EVENTS,
19
+ } from '../../core/constants.js';
20
+ import { Surface } from '../../core/constants.js';
21
+ import { MCP_USAGE_GUIDANCE, usageError } from '../../core/errors.js';
22
+ import { bool, int, json, list, object, oneOf, string, uuid } from '../../core/schema.js';
23
+ import { BodyMode, CommandKind, Toolset, defineCommand, defineGroup } from '../registry.js';
24
+ import { productInput } from './shared.js';
25
+
26
+ const BLOG_USAGE = `Usage:
27
+ outrank-cli products blog get <product-id>
28
+ outrank-cli products blog setup <product-id>
29
+ outrank-cli products blog check-subdomain <product-id> --subdomain <subdomain>
30
+ outrank-cli products blog create <product-id> [--json <object>]
31
+ outrank-cli products blog update-settings <product-id> --json <object>
32
+ outrank-cli products blog delete <product-id> --confirm <subdomain>
33
+ outrank-cli products blog usage <product-id>
34
+ outrank-cli products blog analytics <product-id> [--range 7d|30d|90d]
35
+ outrank-cli products blog starter-posts <product-id>
36
+ outrank-cli products blog preview <product-id> --scope design|post|page [--target-id <id>]
37
+ outrank-cli products blog design get|update|publish|discard <product-id> [--json <object>]
38
+ outrank-cli products blog domain get|connect|verify|remove <product-id> [--json <object>]
39
+ outrank-cli products blog posts list <product-id> [--status all|published|scheduled|draft] [--search <text>] [--page <n>]
40
+ outrank-cli products blog posts create <product-id> [--title <title>]
41
+ outrank-cli products blog posts get|get-content|unpublish|sync <product-id> <article-id>
42
+ outrank-cli products blog posts update|update-content <product-id> <article-id> --json <object>
43
+ outrank-cli products blog posts publish <product-id> <article-id> [--scheduled-for <iso-datetime>]
44
+ outrank-cli products blog pages list|create|reorder <product-id> [--json <object>]
45
+ outrank-cli products blog pages get|update|delete <product-id> <page-id> [--json <object>]
46
+ outrank-cli products blog categories list|create|reorder <product-id> [--json <object>]
47
+ outrank-cli products blog categories update|delete <product-id> <category-id> [--json <object>]
48
+ outrank-cli products blog authors list|create <product-id> [--json <object>]
49
+ outrank-cli products blog authors update|delete <product-id> <author-id> [--json <object>]
50
+ outrank-cli products blog redirects list|create <product-id> [--json <object>]
51
+ outrank-cli products blog redirects delete <product-id> <redirect-id>
52
+ outrank-cli products blog snippets list|create <product-id> [--json <object>]
53
+ outrank-cli products blog snippets update|delete <product-id> <snippet-id> [--json <object>]
54
+ outrank-cli products blog subscribers list <product-id> [--status subscribed|unsubscribed] [--search <text>] [--page <n>]
55
+ outrank-cli products blog subscribers delete <product-id> <subscriber-id>
56
+ outrank-cli products blog webhooks list|create <product-id> [--json <object>]
57
+ outrank-cli products blog webhooks get|update|delete|test <product-id> <webhook-id> [--json <object>]`;
58
+
59
+ const TOOLSETS = [Toolset.BLOG];
60
+ const BLOG_PATH = '/products/{productId}/blog';
61
+ const PRODUCT_ID = 'productId';
62
+ const CAMEL_CASE_NOTE = 'Blog fields use camelCase, like the blog dashboard.';
63
+ const HEX_COLOR_HINT = '6-digit hex color such as #2563EB.';
64
+ const HTTPS_URL_HINT = 'Full https:// URL.';
65
+ const SLUG_HINT = 'Lowercase letters, numbers and hyphens.';
66
+ const POSITION_HINT = 'Zero-based order in the list.';
67
+ const CONFIRM_REQUIRED = {
68
+ guidance:
69
+ 'Deleting a blog takes it offline and turns its posts back into unpublished articles. Confirm with the human, ' +
70
+ 'then pass --confirm <subdomain> with the blog subdomain shown by `outrank-cli products blog get <product-id>`.',
71
+ };
72
+ const SUBDOMAIN_REQUIRED = {
73
+ guidance: 'Pass the subdomain to check: `outrank-cli products blog check-subdomain <product-id> --subdomain <name>`.',
74
+ };
75
+ const SCOPE_REQUIRED = {
76
+ guidance: 'Pass --scope design for the draft theme, or --scope post|page with --target-id <article-id|page-id>.',
77
+ };
78
+ const DOMAIN_REQUIRED = {
79
+ guidance:
80
+ 'Connecting a domain needs mode (subdomain, apex or subfolder) and hostname; for subfolder, basePath sets ' +
81
+ 'the path (default /blog).',
82
+ };
83
+ const NAME_REQUIRED = { guidance: 'A name is required.' };
84
+ const TITLE_REQUIRED = { guidance: 'A page needs a title.' };
85
+ const FROM_PATH_REQUIRED = { guidance: 'A redirect needs fromPath, the old path such as /old-post.' };
86
+ const URL_REQUIRED = { guidance: 'A webhook needs an https:// url.' };
87
+ const SNIPPET_REQUIRED = {
88
+ guidance: 'A snippet needs name and kind (cta, html or newsletter); cta and html snippets also need content.',
89
+ };
90
+ const ITEMS_REQUIRED = { guidance: 'Send {"items": [{"id": "<id>", "position": 0}, ...]} with each id once.' };
91
+
92
+ const BLOG_CREATE_BODY = object().shape({
93
+ name: string().describe('Blog name; defaults to the product name.'),
94
+ subdomain: string().describe('Subdomain on outrankblog.com; check it with products_blog_check_subdomain.'),
95
+ template: oneOf(BLOG_TEMPLATES),
96
+ accentColor: string().describe(HEX_COLOR_HINT),
97
+ publishReadyArticles: bool().describe('Also publish the articles that are already ready.'),
98
+ publishHere: bool().describe('Publish new articles to this blog even when another integration is connected.'),
99
+ });
100
+
101
+ const BLOG_SETTINGS_BODY = object().shape({
102
+ name: string(),
103
+ description: string().nullable(),
104
+ language: string().describe('Language code such as en or en-US.'),
105
+ platformSubdomain: string().describe('New outrankblog.com subdomain.'),
106
+ isPlatformIndexable: bool().describe('Paid plans only.'),
107
+ badgeEnabled: bool().describe('Turning the Outrank badge off needs a paid plan.'),
108
+ analyticsSettings: json().describe('Partial object, for example {"ga4MeasurementId": "G-ABC123", "builtInAnalytics": true}.'),
109
+ codeInjection: json().describe(
110
+ 'Slots to replace, each sent whole, for example {"head": {"html": "<script>...</script>", "strategy": "idle"}}.',
111
+ ),
112
+ });
113
+
114
+ const DOMAIN_BODY = object().shape({
115
+ mode: oneOf(BLOG_DOMAIN_MODES).required(DOMAIN_REQUIRED),
116
+ hostname: string().required(DOMAIN_REQUIRED).describe('Hostname such as blog.example.com or example.com.'),
117
+ basePath: string().describe('Subfolder mode only: the path the blog lives under. Default /blog.'),
118
+ });
119
+
120
+ const POST_SETTINGS_BODY = object().shape({
121
+ slug: string().describe(SLUG_HINT),
122
+ authorId: uuid().nullable(),
123
+ categoryId: uuid().nullable(),
124
+ newCategoryName: string().describe('Creates the category when it does not exist yet.'),
125
+ tags: list(),
126
+ featured: bool(),
127
+ excerpt: string(),
128
+ seoTitle: string(),
129
+ seoDescription: string(),
130
+ ogImageUrl: string().describe(HTTPS_URL_HINT),
131
+ coverImageUrl: string().nullable().describe(HTTPS_URL_HINT),
132
+ coverAlt: string(),
133
+ canonicalUrl: string().describe(HTTPS_URL_HINT),
134
+ noindex: bool(),
135
+ showToc: bool(),
136
+ });
137
+
138
+ const POST_CONTENT_BODY = object().shape({
139
+ title: string(),
140
+ content: string().describe('Markdown body; replaces the whole body.'),
141
+ });
142
+
143
+ const pageShape = (titleRequired) =>
144
+ object().shape({
145
+ title: titleRequired ? string().required(TITLE_REQUIRED) : string(),
146
+ slug: string().describe(SLUG_HINT),
147
+ content: string().describe('Markdown body.'),
148
+ seoTitle: string().nullable(),
149
+ seoDescription: string().nullable(),
150
+ status: oneOf(BLOG_PAGE_STATUSES),
151
+ showInNav: bool(),
152
+ position: int({ min: 0, max: BLOG_POSITION_MAX }).describe(POSITION_HINT),
153
+ layout: oneOf(BLOG_PAGE_LAYOUTS),
154
+ });
155
+
156
+ const categoryShape = (nameRequired) =>
157
+ object().shape({
158
+ name: nameRequired ? string().required(NAME_REQUIRED) : string(),
159
+ slug: string().describe(SLUG_HINT),
160
+ description: string().nullable(),
161
+ seoTitle: string().nullable(),
162
+ seoDescription: string().nullable(),
163
+ position: int({ min: 0, max: BLOG_POSITION_MAX }).describe(POSITION_HINT),
164
+ });
165
+
166
+ const authorShape = (nameRequired) =>
167
+ object().shape({
168
+ name: nameRequired ? string().required(NAME_REQUIRED) : string(),
169
+ slug: string().describe(SLUG_HINT),
170
+ avatarUrl: string().nullable().describe(HTTPS_URL_HINT),
171
+ bio: string().nullable(),
172
+ jobTitle: string().nullable(),
173
+ websiteUrl: string().nullable().describe(HTTPS_URL_HINT),
174
+ social: json().describe('Object of network to URL, for example {"x": "https://x.com/acme"}.'),
175
+ isDefault: bool().describe('Use this author for new posts.'),
176
+ });
177
+
178
+ const REDIRECT_BODY = object().shape({
179
+ fromPath: string().required(FROM_PATH_REQUIRED),
180
+ toPath: string().describe('Target path or URL; omit for status 410.'),
181
+ statusCode: int().describe('301 (default), 302, or 410 for a removed page.'),
182
+ });
183
+
184
+ const snippetShape = (required) =>
185
+ object().shape({
186
+ name: required ? string().required(SNIPPET_REQUIRED) : string(),
187
+ kind: required ? oneOf(BLOG_SNIPPET_KINDS).required(SNIPPET_REQUIRED) : oneOf(BLOG_SNIPPET_KINDS),
188
+ content: json().describe('Required for cta and html snippets; newsletter snippets fall back to defaults.'),
189
+ placement: json().describe('Where it shows in posts, for example {"position": "end", "categorySlugs": []}.'),
190
+ isActive: bool(),
191
+ });
192
+
193
+ const webhookShape = (required) =>
194
+ object().shape({
195
+ url: required ? string().required(URL_REQUIRED).describe(HTTPS_URL_HINT) : string().describe(HTTPS_URL_HINT),
196
+ events: list({ item: oneOf(BLOG_WEBHOOK_EVENTS) }),
197
+ ...(required ? {} : { isActive: bool(), rotateSecret: bool().describe('Returns a new signing secret.') }),
198
+ });
199
+
200
+ const POSITIONS_BODY = object().shape({
201
+ items: list({
202
+ item: object().shape({
203
+ id: uuid().required(ITEMS_REQUIRED),
204
+ position: int({ min: 0, max: BLOG_POSITION_MAX }).required(ITEMS_REQUIRED),
205
+ }),
206
+ }).required(
207
+ ITEMS_REQUIRED,
208
+ ),
209
+ });
210
+
211
+ const PREVIEW_TARGET_MESSAGES = Object.freeze({
212
+ [Surface.CLI]: {
213
+ missing: 'Missing required option: --target-id <article-id|page-id> for --scope post or page.',
214
+ unexpected: '--target-id only applies to --scope post or page; omit it for --scope design.',
215
+ },
216
+ [Surface.MCP]: {
217
+ missing: 'Missing required argument: target_id (the article id for post, the page id for page).',
218
+ unexpected: 'target_id only applies to scope post or page; omit it for scope design.',
219
+ },
220
+ });
221
+
222
+ const refinePreviewTarget = (input, ctx) => {
223
+ const needsTarget = input.scope !== BLOG_PREVIEW_DESIGN_SCOPE;
224
+ if (needsTarget === Boolean(input.targetId)) return;
225
+ const isMcp = ctx.surface === Surface.MCP;
226
+ const messages = PREVIEW_TARGET_MESSAGES[isMcp ? Surface.MCP : Surface.CLI];
227
+ throw usageError(needsTarget ? messages.missing : messages.unexpected, isMcp ? MCP_USAGE_GUIDANCE : SCOPE_REQUIRED.guidance);
228
+ };
229
+
230
+ const REDIRECT_GUIDANCE =
231
+ 'Send {"fromPath": "/old", "toPath": "/new"} for a redirect (statusCode 301 or 302), or ' +
232
+ '{"fromPath": "/old", "statusCode": 410} to mark a page as gone.';
233
+
234
+ const refineRedirect = (input, ctx) => {
235
+ const { statusCode, toPath } = input.body ?? {};
236
+ const guidance = ctx.surface === Surface.MCP ? MCP_USAGE_GUIDANCE : REDIRECT_GUIDANCE;
237
+ if (statusCode !== undefined && !BLOG_REDIRECT_STATUS_CODES.includes(statusCode)) {
238
+ throw usageError(`statusCode must be one of ${BLOG_REDIRECT_STATUS_CODES.join(', ')}.`, guidance);
239
+ }
240
+ if (statusCode !== BLOG_REDIRECT_GONE_STATUS_CODE && !(typeof toPath === 'string' && toPath.trim())) {
241
+ throw usageError('toPath is required unless statusCode is 410.', guidance);
242
+ }
243
+ };
244
+
245
+ const VOWEL_START_PATTERN = /^[aeiou]/i;
246
+ const articleFor = (noun) => (VOWEL_START_PATTERN.test(noun) ? 'an' : 'a');
247
+
248
+ const bodyUsageError = (ctx, message, guidance) =>
249
+ usageError(message, ctx.surface === Surface.MCP ? MCP_USAGE_GUIDANCE : guidance);
250
+
251
+ const refinePositions = (input, ctx) => {
252
+ const items = input.body?.items ?? [];
253
+ if (items.length > BLOG_MAX_POSITIONS_PER_REQUEST) {
254
+ throw bodyUsageError(ctx, `items accepts at most ${BLOG_MAX_POSITIONS_PER_REQUEST} entries per request.`, ITEMS_REQUIRED.guidance);
255
+ }
256
+ if (new Set(items.map((item) => item.id)).size !== items.length) {
257
+ throw bodyUsageError(ctx, 'Each id may appear only once in items.', ITEMS_REQUIRED.guidance);
258
+ }
259
+ };
260
+
261
+ const refineSnippetContent = (input, ctx) => {
262
+ const { kind, content } = input.body ?? {};
263
+ const hasContent = Boolean(content) && typeof content === 'object' && !Array.isArray(content);
264
+ if (BLOG_SNIPPET_KINDS_WITH_REQUIRED_CONTENT.includes(kind) && !hasContent) {
265
+ throw bodyUsageError(ctx, `A ${kind} snippet needs a content object.`, SNIPPET_REQUIRED.guidance);
266
+ }
267
+ };
268
+
269
+ const POST_CONTENT_GUIDANCE = 'Send {"title": "..."}, {"content": "<markdown>"} or both.';
270
+
271
+ const refinePostContent = (input, ctx) => {
272
+ const { title, content } = input.body ?? {};
273
+ if (title === undefined && content === undefined) {
274
+ throw bodyUsageError(ctx, 'Send title, content or both.', POST_CONTENT_GUIDANCE);
275
+ }
276
+ };
277
+
278
+ const refineWebhookEvents = (input, ctx) => {
279
+ const events = input.body?.events;
280
+ if (Array.isArray(events) && events.length === 0) {
281
+ throw bodyUsageError(ctx, 'events needs at least one event; omit it to receive every event.', URL_REQUIRED.guidance);
282
+ }
283
+ };
284
+
285
+ const pickParams = (input, names) => Object.fromEntries(names.map((name) => [name, input[name]]));
286
+
287
+ const blogCommand = ({
288
+ id,
289
+ summary,
290
+ kind,
291
+ method,
292
+ suffix = '',
293
+ ids = [],
294
+ input = {},
295
+ body,
296
+ bodyField,
297
+ bodyOf,
298
+ query,
299
+ mcp,
300
+ refine,
301
+ description,
302
+ }) =>
303
+ defineCommand({
304
+ id: `products.blog.${id}`,
305
+ summary,
306
+ description: description ?? (bodyField ? `${summary} ${CAMEL_CASE_NOTE}` : summary),
307
+ kind,
308
+ toolsets: TOOLSETS,
309
+ input: { ...productInput(), ...Object.fromEntries(ids.map((name) => [name, uuid().required()])), ...input },
310
+ cli: { positionals: [PRODUCT_ID, ...ids] },
311
+ ...(body ? { body } : {}),
312
+ ...(bodyField ? { bodyField } : {}),
313
+ ...(mcp ? { mcp } : {}),
314
+ ...(refine ? { refine } : {}),
315
+ run: (values, ctx) =>
316
+ ctx.http({
317
+ method,
318
+ path: `${BLOG_PATH}${suffix}`,
319
+ pathParams: pickParams(values, [PRODUCT_ID, ...ids]),
320
+ ...(query ? { query: query(values) } : {}),
321
+ ...(bodyOf ? { body: bodyOf(values) } : {}),
322
+ ...(body && !bodyOf ? { body: values.body } : {}),
323
+ }),
324
+ });
325
+
326
+ const group = ({ id, usage, guidance, children }) =>
327
+ defineGroup({
328
+ id: `products.blog.${id}`,
329
+ help: { usage, guidance },
330
+ unknown: (word) => ({
331
+ message: `Unknown products blog ${id} subcommand: ${word}.`,
332
+ guidance: `Run \`outrank-cli products blog ${id} --help\` to see the available ${id} commands.`,
333
+ }),
334
+ children,
335
+ });
336
+
337
+ const usageFor = (lines) => `Usage:\n${lines.map((line) => ` outrank-cli products blog ${line}`).join('\n')}`;
338
+
339
+ const listQuery = (values) => ({ status: values.status, search: values.search, page: values.page });
340
+
341
+ const SUBSCRIBER_UNTRUSTED_FIELDS = [
342
+ 'items[].email',
343
+ 'items[].name',
344
+ 'items[].source_path',
345
+ 'items[].consent_text',
346
+ 'items[].form_key',
347
+ ];
348
+ const WEBHOOK_TEST_UNTRUSTED_FIELDS = ['responseSnippet', 'error'];
349
+ const ANALYTICS_UNTRUSTED_FIELDS = ['topPages[].path', 'topPages[].url', 'referrers[].source'];
350
+
351
+ const designGroup = group({
352
+ id: 'design',
353
+ usage: usageFor([
354
+ 'design get <product-id>',
355
+ 'design update <product-id> --json <object>',
356
+ 'design publish <product-id>',
357
+ 'design discard <product-id>',
358
+ ]),
359
+ guidance:
360
+ 'Edit the draft theme with `update` (send the whole config, starting from the draft returned by `get`), ' +
361
+ 'preview it with `outrank-cli products blog preview <product-id> --scope design`, then `publish` it.',
362
+ children: [
363
+ blogCommand({
364
+ id: 'design.get',
365
+ summary: 'Show the live and draft theme config of the blog.',
366
+ kind: CommandKind.READ,
367
+ method: 'GET',
368
+ suffix: '/design',
369
+ }),
370
+ blogCommand({
371
+ id: 'design.update',
372
+ summary: 'Save the draft theme config of the blog from a JSON body (the live blog is unchanged).',
373
+ kind: CommandKind.WRITE,
374
+ method: 'PUT',
375
+ suffix: '/design',
376
+ body: BodyMode.JSON,
377
+ }),
378
+ blogCommand({
379
+ id: 'design.publish',
380
+ summary: 'Publish the draft theme to the live blog.',
381
+ kind: CommandKind.WRITE,
382
+ method: 'POST',
383
+ suffix: '/design/publish',
384
+ }),
385
+ blogCommand({
386
+ id: 'design.discard',
387
+ summary: 'Discard the draft theme and go back to the live one.',
388
+ kind: CommandKind.DESTRUCTIVE,
389
+ method: 'POST',
390
+ suffix: '/design/discard',
391
+ }),
392
+ ],
393
+ });
394
+
395
+ const domainGroup = group({
396
+ id: 'domain',
397
+ usage: usageFor([
398
+ 'domain get <product-id>',
399
+ 'domain connect <product-id> --json <object>',
400
+ 'domain verify <product-id>',
401
+ 'domain remove <product-id>',
402
+ ]),
403
+ guidance:
404
+ 'Connect a custom domain, show the returned DNS records to the human, then `verify` once the records are in ' +
405
+ 'place. DNS can take a while, so check again later instead of changing the records.',
406
+ children: [
407
+ blogCommand({
408
+ id: 'domain.get',
409
+ summary: 'Show the custom domain of the blog and its DNS and verification state.',
410
+ kind: CommandKind.READ,
411
+ method: 'GET',
412
+ suffix: '/domain',
413
+ }),
414
+ blogCommand({
415
+ id: 'domain.connect',
416
+ summary: 'Connect or replace the custom domain of the blog from a JSON body.',
417
+ kind: CommandKind.WRITE,
418
+ method: 'POST',
419
+ suffix: '/domain',
420
+ body: BodyMode.JSON,
421
+ bodyField: DOMAIN_BODY,
422
+ }),
423
+ blogCommand({
424
+ id: 'domain.verify',
425
+ summary: 'Check the DNS of the custom domain and activate it once it is correct.',
426
+ kind: CommandKind.WRITE,
427
+ method: 'POST',
428
+ suffix: '/domain/verify',
429
+ }),
430
+ blogCommand({
431
+ id: 'domain.remove',
432
+ summary: 'Remove the custom domain; the blog goes back to its outrankblog.com subdomain.',
433
+ kind: CommandKind.DESTRUCTIVE,
434
+ method: 'DELETE',
435
+ suffix: '/domain',
436
+ }),
437
+ ],
438
+ });
439
+
440
+ const POST_ID = ['articleId'];
441
+ const POST_PATH = '/posts/{articleId}';
442
+
443
+ const postsGroup = group({
444
+ id: 'posts',
445
+ usage: usageFor([
446
+ 'posts list <product-id> [--status all|published|scheduled|draft] [--search <text>] [--page <n>]',
447
+ 'posts create <product-id> [--title <title>]',
448
+ 'posts get <product-id> <article-id>',
449
+ 'posts update <product-id> <article-id> --json <object>',
450
+ 'posts get-content <product-id> <article-id>',
451
+ 'posts update-content <product-id> <article-id> --json <object>',
452
+ 'posts publish <product-id> <article-id> [--scheduled-for <iso-datetime>]',
453
+ 'posts unpublish <product-id> <article-id>',
454
+ 'posts sync <product-id> <article-id>',
455
+ ]),
456
+ guidance:
457
+ 'Posts are keyed by their article id. `create` a draft, write it with `update-content`, set slug, SEO and ' +
458
+ 'category with `update`, then `publish` (now or with --scheduled-for). After editing a live post, run `sync`.',
459
+ children: [
460
+ blogCommand({
461
+ id: 'posts.list',
462
+ summary: 'List the posts of the blog with optional status, search and page filters.',
463
+ kind: CommandKind.READ,
464
+ method: 'GET',
465
+ suffix: '/posts',
466
+ input: { status: oneOf(BLOG_POST_LIST_STATUSES), search: string(), page: int({ min: BLOG_LIST_MIN_PAGE }) },
467
+ query: listQuery,
468
+ }),
469
+ blogCommand({
470
+ id: 'posts.create',
471
+ summary: 'Create an empty draft post on the blog.',
472
+ kind: CommandKind.WRITE,
473
+ method: 'POST',
474
+ suffix: '/posts',
475
+ input: { title: string() },
476
+ bodyOf: (values) => (values.title ? { title: values.title } : {}),
477
+ }),
478
+ blogCommand({
479
+ id: 'posts.get',
480
+ summary: 'Show the settings, status and live URL of one post.',
481
+ kind: CommandKind.READ,
482
+ method: 'GET',
483
+ suffix: POST_PATH,
484
+ ids: POST_ID,
485
+ }),
486
+ blogCommand({
487
+ id: 'posts.update',
488
+ summary: 'Change the slug, author, category, tags, excerpt, SEO or cover of one post from a JSON body.',
489
+ kind: CommandKind.WRITE,
490
+ method: 'PATCH',
491
+ suffix: POST_PATH,
492
+ ids: POST_ID,
493
+ body: BodyMode.JSON,
494
+ bodyField: POST_SETTINGS_BODY,
495
+ }),
496
+ blogCommand({
497
+ id: 'posts.get-content',
498
+ summary: 'Show the title and markdown body of one post.',
499
+ kind: CommandKind.READ,
500
+ method: 'GET',
501
+ suffix: `${POST_PATH}/content`,
502
+ ids: POST_ID,
503
+ }),
504
+ blogCommand({
505
+ id: 'posts.update-content',
506
+ summary: 'Replace the title and/or markdown body of one post from a JSON body.',
507
+ kind: CommandKind.WRITE,
508
+ method: 'PUT',
509
+ suffix: `${POST_PATH}/content`,
510
+ ids: POST_ID,
511
+ body: BodyMode.JSON,
512
+ bodyField: POST_CONTENT_BODY,
513
+ refine: refinePostContent,
514
+ }),
515
+ blogCommand({
516
+ id: 'posts.publish',
517
+ summary: 'Publish one post now, or schedule it with --scheduled-for.',
518
+ kind: CommandKind.WRITE,
519
+ method: 'POST',
520
+ suffix: `${POST_PATH}/publish`,
521
+ ids: POST_ID,
522
+ input: { scheduledFor: string().describe('ISO 8601 date-time with offset, at most 365 days ahead.') },
523
+ bodyOf: (values) => (values.scheduledFor ? { scheduledFor: values.scheduledFor } : {}),
524
+ }),
525
+ blogCommand({
526
+ id: 'posts.unpublish',
527
+ summary: 'Take one post off the blog (or cancel its schedule); it becomes a draft.',
528
+ kind: CommandKind.WRITE,
529
+ method: 'POST',
530
+ suffix: `${POST_PATH}/unpublish`,
531
+ ids: POST_ID,
532
+ }),
533
+ blogCommand({
534
+ id: 'posts.sync',
535
+ summary: 'Push the latest body of a live post to the blog after editing it.',
536
+ kind: CommandKind.WRITE,
537
+ method: 'POST',
538
+ suffix: `${POST_PATH}/sync`,
539
+ ids: POST_ID,
540
+ }),
541
+ ],
542
+ });
543
+
544
+ const collectionGroup = ({
545
+ id,
546
+ noun,
547
+ idName,
548
+ idLabel,
549
+ createBody,
550
+ updateBody,
551
+ itemRead = false,
552
+ reorder = false,
553
+ update = true,
554
+ createRefine,
555
+ updateRefine,
556
+ updateKind = CommandKind.WRITE,
557
+ extraChildren = [],
558
+ extraUsage = [],
559
+ guidance,
560
+ }) => {
561
+ const itemPath = `/${id}/{${idName}}`;
562
+ const ids = [idName];
563
+ const lines = [
564
+ `${id} list <product-id>`,
565
+ ...(itemRead ? [`${id} get <product-id> <${idLabel}>`] : []),
566
+ `${id} create <product-id> --json <object>`,
567
+ ...(update ? [`${id} update <product-id> <${idLabel}> --json <object>`] : []),
568
+ `${id} delete <product-id> <${idLabel}>`,
569
+ ...(reorder ? [`${id} reorder <product-id> --json <object>`] : []),
570
+ ...extraUsage,
571
+ ];
572
+ return group({
573
+ id,
574
+ usage: usageFor(lines),
575
+ guidance,
576
+ children: [
577
+ blogCommand({
578
+ id: `${id}.list`,
579
+ summary: `List the ${id} of the blog.`,
580
+ kind: CommandKind.READ,
581
+ method: 'GET',
582
+ suffix: `/${id}`,
583
+ }),
584
+ ...(itemRead
585
+ ? [
586
+ blogCommand({
587
+ id: `${id}.get`,
588
+ summary: `Show one ${noun} of the blog.`,
589
+ kind: CommandKind.READ,
590
+ method: 'GET',
591
+ suffix: itemPath,
592
+ ids,
593
+ }),
594
+ ]
595
+ : []),
596
+ blogCommand({
597
+ id: `${id}.create`,
598
+ summary: `Create ${articleFor(noun)} ${noun} on the blog from a JSON body.`,
599
+ kind: CommandKind.WRITE,
600
+ method: 'POST',
601
+ suffix: `/${id}`,
602
+ body: BodyMode.JSON,
603
+ bodyField: createBody,
604
+ refine: createRefine,
605
+ }),
606
+ ...(update
607
+ ? [
608
+ blogCommand({
609
+ id: `${id}.update`,
610
+ summary: `Change one ${noun} of the blog from a JSON body.`,
611
+ kind: updateKind,
612
+ method: 'PATCH',
613
+ suffix: itemPath,
614
+ ids,
615
+ body: BodyMode.JSON,
616
+ bodyField: updateBody,
617
+ refine: updateRefine,
618
+ }),
619
+ ]
620
+ : []),
621
+ blogCommand({
622
+ id: `${id}.delete`,
623
+ summary: `Delete one ${noun} of the blog.`,
624
+ kind: CommandKind.DESTRUCTIVE,
625
+ method: 'DELETE',
626
+ suffix: itemPath,
627
+ ids,
628
+ }),
629
+ ...(reorder
630
+ ? [
631
+ blogCommand({
632
+ id: `${id}.reorder`,
633
+ summary: `Save the order of the ${id} from a JSON body of ids and positions.`,
634
+ kind: CommandKind.WRITE,
635
+ method: 'PATCH',
636
+ suffix: `/${id}/positions`,
637
+ body: BodyMode.JSON,
638
+ bodyField: POSITIONS_BODY,
639
+ refine: refinePositions,
640
+ }),
641
+ ]
642
+ : []),
643
+ ...extraChildren,
644
+ ],
645
+ });
646
+ };
647
+
648
+ const pagesGroup = collectionGroup({
649
+ id: 'pages',
650
+ noun: 'page',
651
+ idName: 'pageId',
652
+ idLabel: 'page-id',
653
+ createBody: pageShape(true),
654
+ updateBody: pageShape(false),
655
+ itemRead: true,
656
+ reorder: true,
657
+ guidance:
658
+ 'Pages are static pages such as About or Contact. A draft page stays hidden until it is saved with ' +
659
+ '"status": "published"; "showInNav" adds it to the blog navigation and `reorder` sets the order.',
660
+ });
661
+
662
+ const categoriesGroup = collectionGroup({
663
+ id: 'categories',
664
+ noun: 'category',
665
+ idName: 'categoryId',
666
+ idLabel: 'category-id',
667
+ createBody: categoryShape(true),
668
+ updateBody: categoryShape(false),
669
+ reorder: true,
670
+ guidance: 'Categories group posts. Assign one with `outrank-cli products blog posts update` and "categoryId".',
671
+ });
672
+
673
+ const authorsGroup = collectionGroup({
674
+ id: 'authors',
675
+ noun: 'author',
676
+ idName: 'authorId',
677
+ idLabel: 'author-id',
678
+ createBody: authorShape(true),
679
+ updateBody: authorShape(false),
680
+ guidance:
681
+ 'Authors are shown on posts. "isDefault" makes an author the default for new posts; set a post author with ' +
682
+ '`outrank-cli products blog posts update` and "authorId".',
683
+ });
684
+
685
+ const redirectsGroup = collectionGroup({
686
+ id: 'redirects',
687
+ noun: 'redirect',
688
+ idName: 'redirectId',
689
+ idLabel: 'redirect-id',
690
+ createBody: REDIRECT_BODY,
691
+ createRefine: refineRedirect,
692
+ update: false,
693
+ guidance: 'Redirects send an old path to a new one (301 or 302) or mark it gone (410).',
694
+ });
695
+
696
+ const snippetsGroup = collectionGroup({
697
+ id: 'snippets',
698
+ noun: 'snippet',
699
+ idName: 'snippetId',
700
+ idLabel: 'snippet-id',
701
+ createBody: snippetShape(true),
702
+ updateBody: snippetShape(false),
703
+ createRefine: refineSnippetContent,
704
+ guidance: 'Snippets are reusable call-to-action, newsletter or HTML blocks shown on the blog.',
705
+ });
706
+
707
+ const webhooksGroup = collectionGroup({
708
+ id: 'webhooks',
709
+ noun: 'webhook',
710
+ idName: 'webhookId',
711
+ idLabel: 'webhook-id',
712
+ createBody: webhookShape(true),
713
+ updateBody: webhookShape(false),
714
+ createRefine: refineWebhookEvents,
715
+ updateRefine: refineWebhookEvents,
716
+ updateKind: CommandKind.DESTRUCTIVE,
717
+ itemRead: true,
718
+ extraUsage: ['webhooks test <product-id> <webhook-id>'],
719
+ extraChildren: [
720
+ blogCommand({
721
+ id: 'webhooks.test',
722
+ summary: 'Send a test event to one webhook and report the response status.',
723
+ kind: CommandKind.WRITE,
724
+ method: 'POST',
725
+ suffix: '/webhooks/{webhookId}/test',
726
+ ids: ['webhookId'],
727
+ mcp: { untrustedOutput: true, untrustedFields: WEBHOOK_TEST_UNTRUSTED_FIELDS },
728
+ }),
729
+ ],
730
+ guidance:
731
+ 'Webhooks call an https URL when a reader subscribes. The signing secret is returned once by `create` (and ' +
732
+ 'by `update` with "rotateSecret": true); give it to the human for their endpoint.',
733
+ });
734
+
735
+ const subscribersGroup = group({
736
+ id: 'subscribers',
737
+ usage: usageFor([
738
+ 'subscribers list <product-id> [--status subscribed|unsubscribed] [--search <text>] [--page <n>]',
739
+ 'subscribers delete <product-id> <subscriber-id>',
740
+ ]),
741
+ guidance: 'Subscribers are readers who joined the blog newsletter; their emails are personal data.',
742
+ children: [
743
+ blogCommand({
744
+ id: 'subscribers.list',
745
+ summary: 'List the newsletter subscribers of the blog.',
746
+ kind: CommandKind.READ,
747
+ method: 'GET',
748
+ suffix: '/subscribers',
749
+ input: { status: oneOf(BLOG_SUBSCRIBER_STATUSES), search: string(), page: int({ min: BLOG_LIST_MIN_PAGE }) },
750
+ query: listQuery,
751
+ mcp: { untrustedOutput: true, untrustedFields: SUBSCRIBER_UNTRUSTED_FIELDS },
752
+ }),
753
+ blogCommand({
754
+ id: 'subscribers.delete',
755
+ summary: 'Delete one newsletter subscriber of the blog.',
756
+ kind: CommandKind.DESTRUCTIVE,
757
+ method: 'DELETE',
758
+ suffix: '/subscribers/{subscriberId}',
759
+ ids: ['subscriberId'],
760
+ }),
761
+ ],
762
+ });
763
+
764
+ export const blogGroup = defineGroup({
765
+ id: 'products.blog',
766
+ help: {
767
+ usage: BLOG_USAGE,
768
+ guidance:
769
+ 'Start with `get` to see whether the product has a hosted Outrank Blog. Without one, read `setup` and run ' +
770
+ '`create`. Then manage posts, design, pages, categories, authors, domain, redirects, snippets, subscribers ' +
771
+ 'and webhooks from this subtree. Request bodies use camelCase fields, like the blog dashboard.',
772
+ },
773
+ unknown: (word) => ({
774
+ message: `Unknown products blog subcommand: ${word}.`,
775
+ guidance: 'Run `outrank-cli products blog --help` to see the available blog commands.',
776
+ }),
777
+ children: [
778
+ blogCommand({
779
+ id: 'get',
780
+ summary: 'Show the hosted Outrank Blog of a product with its domain, usage and post counts.',
781
+ kind: CommandKind.READ,
782
+ method: 'GET',
783
+ }),
784
+ blogCommand({
785
+ id: 'setup',
786
+ summary: 'Show the suggested name, subdomain and accent color for a new blog on a product.',
787
+ kind: CommandKind.READ,
788
+ method: 'GET',
789
+ suffix: '/setup',
790
+ }),
791
+ blogCommand({
792
+ id: 'check-subdomain',
793
+ summary: 'Check whether an outrankblog.com subdomain is valid and free.',
794
+ kind: CommandKind.READ,
795
+ method: 'GET',
796
+ suffix: '/subdomain-availability',
797
+ input: { subdomain: string().required(SUBDOMAIN_REQUIRED) },
798
+ query: (values) => ({ subdomain: values.subdomain }),
799
+ }),
800
+ blogCommand({
801
+ id: 'create',
802
+ summary: 'Create the hosted Outrank Blog of a product; every body field is optional.',
803
+ kind: CommandKind.WRITE,
804
+ method: 'POST',
805
+ body: BodyMode.OPTIONAL_JSON_OR_EMPTY_OBJECT,
806
+ bodyField: BLOG_CREATE_BODY,
807
+ }),
808
+ blogCommand({
809
+ id: 'update-settings',
810
+ summary: 'Change the blog name, description, language, subdomain, badge, analytics or code injection.',
811
+ kind: CommandKind.WRITE,
812
+ method: 'PATCH',
813
+ suffix: '/settings',
814
+ body: BodyMode.JSON,
815
+ bodyField: BLOG_SETTINGS_BODY,
816
+ }),
817
+ blogCommand({
818
+ id: 'delete',
819
+ summary: 'Delete the blog of a product; its posts become unpublished articles again.',
820
+ kind: CommandKind.DESTRUCTIVE,
821
+ method: 'DELETE',
822
+ input: { confirm: string().required(CONFIRM_REQUIRED).describe('The blog subdomain, to confirm.') },
823
+ bodyOf: (values) => ({ confirm: values.confirm }),
824
+ }),
825
+ blogCommand({
826
+ id: 'usage',
827
+ summary: 'Show the plan limits and current usage of the blog.',
828
+ kind: CommandKind.READ,
829
+ method: 'GET',
830
+ suffix: '/usage',
831
+ }),
832
+ blogCommand({
833
+ id: 'analytics',
834
+ summary: 'Show the built-in traffic analytics of the blog.',
835
+ kind: CommandKind.READ,
836
+ method: 'GET',
837
+ suffix: '/analytics',
838
+ input: { range: oneOf(BLOG_ANALYTICS_RANGES) },
839
+ query: (values) => ({ range: values.range }),
840
+ mcp: { untrustedOutput: true, untrustedFields: ANALYTICS_UNTRUSTED_FIELDS },
841
+ }),
842
+ blogCommand({
843
+ id: 'starter-posts',
844
+ summary: 'Show the free AI starter-post allowance and the starter posts being written.',
845
+ kind: CommandKind.READ,
846
+ method: 'GET',
847
+ suffix: '/starter-posts',
848
+ }),
849
+ blogCommand({
850
+ id: 'preview',
851
+ summary: 'Create a short-lived private preview link for the draft design, a draft post or a draft page.',
852
+ kind: CommandKind.WRITE,
853
+ method: 'POST',
854
+ suffix: '/preview-token',
855
+ input: {
856
+ scope: oneOf(BLOG_PREVIEW_SCOPES).required(SCOPE_REQUIRED),
857
+ targetId: uuid().describe('Article id for a post, page id for a page; omit for design.'),
858
+ },
859
+ refine: refinePreviewTarget,
860
+ bodyOf: (values) => ({ scope: values.scope, ...(values.targetId ? { targetId: values.targetId } : {}) }),
861
+ }),
862
+ designGroup,
863
+ domainGroup,
864
+ postsGroup,
865
+ pagesGroup,
866
+ categoriesGroup,
867
+ authorsGroup,
868
+ redirectsGroup,
869
+ snippetsGroup,
870
+ subscribersGroup,
871
+ webhooksGroup,
872
+ ],
873
+ });