outrank-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ parseArgs,
6
+ getRequiredPositional,
7
+ readJsonInput,
8
+ hasFlag,
9
+ parseIntegerOption,
10
+ getOption,
11
+ } from '../core/args.js';
12
+ import { requestAgentApi } from '../core/api.js';
13
+ import { cliSuccess } from '../core/errors.js';
14
+
15
+ export const ARTICLES_HELP = `Usage:
16
+ outrank-cli articles list [--product <product-id>] [--status generated|approved|published|failed|deleted] [--title <text>] [--keyword <text>] [--content <text>] [--slug <text>] [--created-from <date>] [--created-to <date>] [--scheduled-from <date>] [--scheduled-to <date>] [--sort-by created_at|status|title] [--sort-order asc|desc] [--limit <n>] [--offset <n>]
17
+ outrank-cli articles get <article-id>
18
+ outrank-cli articles get-content <article-id> [--format markdown|html]
19
+ outrank-cli articles update <article-id> --json <object>
20
+ outrank-cli articles update-content <article-id> --json <object>
21
+ outrank-cli articles replace <article-id> --json <object>
22
+ outrank-cli articles bulk-replace --json <object>
23
+ outrank-cli articles upload-image --file <path> [--article <article-id>] [--keyword <text>]
24
+ outrank-cli articles upload-featured-image <article-id> --file <path>
25
+ outrank-cli articles retry-publish <article-id>
26
+ outrank-cli articles delete <article-id>
27
+ outrank-cli articles precreate <keyword-id> # eligible future scheduled keyword only
28
+ outrank-cli articles generate <keyword-id> # scheduled for today only
29
+
30
+ JSON Input:
31
+ Use one of: --json '<object>', --json-file payload.json, or --json-stdin
32
+
33
+ Examples:
34
+ outrank-cli articles list --product 00000000-0000-0000-0000-000000000000 --status published --limit 20
35
+ outrank-cli articles get 00000000-0000-0000-0000-000000000000
36
+ outrank-cli articles get-content 00000000-0000-0000-0000-000000000000 --format html
37
+ outrank-cli articles update 00000000-0000-0000-0000-000000000000 --json '{"title":"New title"}'
38
+ outrank-cli articles update 00000000-0000-0000-0000-000000000000 --json '{"image_url":"https://example.com/featured.jpg"}'
39
+ outrank-cli articles update-content 00000000-0000-0000-0000-000000000000 --json-file article-content.json
40
+ outrank-cli articles replace 00000000-0000-0000-0000-000000000000 --json '{"find":"Old brand","replace_with":"New brand","preview_only":true}'
41
+ outrank-cli articles bulk-replace --json-file bulk-replace.json
42
+ outrank-cli articles upload-image --file .\\diagram.png --article 00000000-0000-0000-0000-000000000000
43
+ outrank-cli articles upload-featured-image 00000000-0000-0000-0000-000000000000 --file .\\hero.jpg
44
+ outrank-cli articles retry-publish 00000000-0000-0000-0000-000000000000
45
+ outrank-cli articles precreate 00000000-0000-0000-0000-000000000000
46
+ outrank-cli articles generate 00000000-0000-0000-0000-000000000000
47
+ `;
48
+
49
+ const IMAGE_MIME_BY_EXTENSION = {
50
+ '.jpg': 'image/jpeg',
51
+ '.jpeg': 'image/jpeg',
52
+ '.png': 'image/png',
53
+ '.webp': 'image/webp',
54
+ '.gif': 'image/gif',
55
+ };
56
+
57
+ const buildQueryPath = (basePath, searchParams) => {
58
+ const url = new URL(`https://local${basePath}`);
59
+ for (const [key, value] of Object.entries(searchParams)) {
60
+ if (value !== undefined && value !== null && value !== '') {
61
+ url.searchParams.set(key, String(value));
62
+ }
63
+ }
64
+ return `${url.pathname}${url.search}`;
65
+ };
66
+
67
+ const resolveImageUploadPayload = async (parsed) => {
68
+ const filePath = getOption(parsed, 'file');
69
+ if (!filePath) {
70
+ throw Object.assign(new Error('Missing required option: --file <path>.'), {
71
+ cliCode: 'CLI_INVALID_USAGE',
72
+ guidance: 'Pass a local image path with `--file <path>`.',
73
+ });
74
+ }
75
+
76
+ const absolutePath = path.resolve(process.cwd(), filePath);
77
+ const buffer = await fs.readFile(absolutePath).catch((error) => {
78
+ throw Object.assign(new Error(`Failed to read file: ${error.message}`), {
79
+ cliCode: 'CLI_INVALID_USAGE',
80
+ guidance: `Confirm that ${absolutePath} exists and is readable, then retry.`,
81
+ });
82
+ });
83
+
84
+ const fileName = path.basename(absolutePath);
85
+ const extension = path.extname(fileName).toLowerCase();
86
+ const fileType = IMAGE_MIME_BY_EXTENSION[extension];
87
+
88
+ if (!fileType) {
89
+ throw Object.assign(new Error(`Unsupported image file extension: ${extension || '(none)'}.`), {
90
+ cliCode: 'CLI_INVALID_USAGE',
91
+ guidance: 'Use one of: .jpg, .jpeg, .png, .webp, .gif.',
92
+ });
93
+ }
94
+
95
+ return {
96
+ file_base64: buffer.toString('base64'),
97
+ file_name: fileName,
98
+ file_type: fileType,
99
+ };
100
+ };
101
+
102
+ const handleArticlesList = async (parsed) => {
103
+ const path = buildQueryPath('/articles', {
104
+ product_id: getOption(parsed, 'product'),
105
+ status: getOption(parsed, 'status'),
106
+ title: getOption(parsed, 'title'),
107
+ keyword: getOption(parsed, 'keyword'),
108
+ content: getOption(parsed, 'content'),
109
+ slug: getOption(parsed, 'slug'),
110
+ created_from: getOption(parsed, 'created-from'),
111
+ created_to: getOption(parsed, 'created-to'),
112
+ scheduled_from: getOption(parsed, 'scheduled-from'),
113
+ scheduled_to: getOption(parsed, 'scheduled-to'),
114
+ sort_by: getOption(parsed, 'sort-by'),
115
+ sort_order: getOption(parsed, 'sort-order'),
116
+ limit: parseIntegerOption(parsed, 'limit', { min: 1, max: 100 }),
117
+ offset: parseIntegerOption(parsed, 'offset', { min: 0 }),
118
+ });
119
+ const response = await requestAgentApi(parsed, {
120
+ method: 'GET',
121
+ path,
122
+ });
123
+ return response.payload;
124
+ };
125
+
126
+ const handleArticlesGet = async (parsed) => {
127
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
128
+ const response = await requestAgentApi(parsed, {
129
+ method: 'GET',
130
+ path: `/articles/${articleId}`,
131
+ });
132
+ return response.payload;
133
+ };
134
+
135
+ const handleArticlesGetContent = async (parsed) => {
136
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
137
+ const path = buildQueryPath(`/articles/${articleId}/content`, {
138
+ format: getOption(parsed, 'format'),
139
+ });
140
+ const response = await requestAgentApi(parsed, {
141
+ method: 'GET',
142
+ path,
143
+ });
144
+ return response.payload;
145
+ };
146
+
147
+ const handleArticlesUpdate = async (parsed) => {
148
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
149
+ const body = await readJsonInput(parsed);
150
+ const response = await requestAgentApi(parsed, {
151
+ method: 'PATCH',
152
+ path: `/articles/${articleId}`,
153
+ body,
154
+ });
155
+ return response.payload;
156
+ };
157
+
158
+ const handleArticlesUpdateContent = async (parsed) => {
159
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
160
+ const body = await readJsonInput(parsed);
161
+ const response = await requestAgentApi(parsed, {
162
+ method: 'PUT',
163
+ path: `/articles/${articleId}/content`,
164
+ body,
165
+ });
166
+ return response.payload;
167
+ };
168
+
169
+ const handleArticlesReplace = async (parsed) => {
170
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
171
+ const body = await readJsonInput(parsed);
172
+ const response = await requestAgentApi(parsed, {
173
+ method: 'POST',
174
+ path: `/articles/${articleId}/replace`,
175
+ body,
176
+ });
177
+ return response.payload;
178
+ };
179
+
180
+ const handleArticlesBulkReplace = async (parsed) => {
181
+ const body = await readJsonInput(parsed);
182
+ const response = await requestAgentApi(parsed, {
183
+ method: 'POST',
184
+ path: '/articles/bulk-replace',
185
+ body,
186
+ });
187
+ return response.payload;
188
+ };
189
+
190
+ const handleArticlesUploadImage = async (parsed) => {
191
+ const imagePayload = await resolveImageUploadPayload(parsed);
192
+ const articleId = getOption(parsed, 'article');
193
+ const keyword = getOption(parsed, 'keyword');
194
+
195
+ if (!articleId && !keyword) {
196
+ throw Object.assign(new Error('Provide at least one of: --article <article-id> or --keyword <text>.'), {
197
+ cliCode: 'CLI_INVALID_USAGE',
198
+ guidance:
199
+ 'Pass `--article <article-id>` for an existing article editor-style upload, or `--keyword <text>` when you want keyword-driven image metadata without an existing article.',
200
+ });
201
+ }
202
+
203
+ const response = await requestAgentApi(parsed, {
204
+ method: 'POST',
205
+ path: '/articles/images/upload',
206
+ body: {
207
+ ...imagePayload,
208
+ ...(articleId ? { article_id: articleId } : {}),
209
+ ...(keyword ? { keyword } : {}),
210
+ },
211
+ });
212
+ return response.payload;
213
+ };
214
+
215
+ const handleArticlesUploadFeaturedImage = async (parsed) => {
216
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
217
+ const imagePayload = await resolveImageUploadPayload(parsed);
218
+ const response = await requestAgentApi(parsed, {
219
+ method: 'POST',
220
+ path: `/articles/${articleId}/featured-image/upload`,
221
+ body: imagePayload,
222
+ });
223
+ return response.payload;
224
+ };
225
+
226
+ const handleArticlesRetryPublish = async (parsed) => {
227
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
228
+ const response = await requestAgentApi(parsed, {
229
+ method: 'POST',
230
+ path: `/articles/${articleId}/retry-publish`,
231
+ });
232
+ return response.payload;
233
+ };
234
+
235
+ const handleArticlesDelete = async (parsed) => {
236
+ const articleId = getRequiredPositional(parsed, 0, '<article-id>');
237
+ const response = await requestAgentApi(parsed, {
238
+ method: 'DELETE',
239
+ path: `/articles/${articleId}`,
240
+ });
241
+ return response.payload;
242
+ };
243
+
244
+ const handleArticlesPrecreate = async (parsed) => {
245
+ const keywordId = getRequiredPositional(parsed, 0, '<keyword-id>');
246
+ const response = await requestAgentApi(parsed, {
247
+ method: 'POST',
248
+ path: '/articles/precreate',
249
+ body: {
250
+ keyword_id: keywordId,
251
+ },
252
+ });
253
+ return response.payload;
254
+ };
255
+
256
+ const handleArticlesGenerate = async (parsed) => {
257
+ const keywordId = getRequiredPositional(parsed, 0, '<keyword-id>');
258
+ const response = await requestAgentApi(parsed, {
259
+ method: 'POST',
260
+ path: '/articles/generate',
261
+ body: {
262
+ keyword_id: keywordId,
263
+ },
264
+ });
265
+ return response.payload;
266
+ };
267
+
268
+ export const handleArticlesCommand = async (argv) => {
269
+ const subcommand = argv[0];
270
+ const parsed = parseArgs(argv.slice(1));
271
+ const wantsHelp = !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
272
+
273
+ if (wantsHelp) {
274
+ return cliSuccess(
275
+ {
276
+ usage: ARTICLES_HELP.trim(),
277
+ },
278
+ 'Choose one of: list, get, get-content, update, update-content, replace, bulk-replace, upload-image, upload-featured-image, retry-publish, delete, precreate, generate.',
279
+ );
280
+ }
281
+
282
+ if (subcommand === 'list') return handleArticlesList(parsed);
283
+ if (subcommand === 'get') return handleArticlesGet(parsed);
284
+ if (subcommand === 'get-content') return handleArticlesGetContent(parsed);
285
+ if (subcommand === 'update') return handleArticlesUpdate(parsed);
286
+ if (subcommand === 'update-content') return handleArticlesUpdateContent(parsed);
287
+ if (subcommand === 'replace') return handleArticlesReplace(parsed);
288
+ if (subcommand === 'bulk-replace') return handleArticlesBulkReplace(parsed);
289
+ if (subcommand === 'upload-image') return handleArticlesUploadImage(parsed);
290
+ if (subcommand === 'upload-featured-image') return handleArticlesUploadFeaturedImage(parsed);
291
+ if (subcommand === 'retry-publish') return handleArticlesRetryPublish(parsed);
292
+ if (subcommand === 'delete') return handleArticlesDelete(parsed);
293
+ if (subcommand === 'precreate') return handleArticlesPrecreate(parsed);
294
+ if (subcommand === 'generate') return handleArticlesGenerate(parsed);
295
+
296
+ throw Object.assign(new Error(`Unknown articles subcommand: ${subcommand}.`), {
297
+ cliCode: 'CLI_INVALID_USAGE',
298
+ guidance: 'Run `outrank-cli articles --help` to see available article commands.',
299
+ });
300
+ };
301
+
@@ -0,0 +1,127 @@
1
+ import { getOption, hasFlag, parseArgs } from '../core/args.js';
2
+ import { requestAgentApi } from '../core/api.js';
3
+ import {
4
+ clearStoredAuth,
5
+ getConfigPath,
6
+ getDefaultBaseUrl,
7
+ resolveRuntimeConfig,
8
+ storeAuthConfig,
9
+ } from '../core/config.js';
10
+ import { cliSuccess } from '../core/errors.js';
11
+
12
+ export const AUTH_HELP = `Usage:
13
+ outrank-cli auth login <api-key> [--base-url <url>] [--no-verify]
14
+ outrank-cli auth whoami
15
+ outrank-cli auth status
16
+ outrank-cli auth logout
17
+
18
+ Commands:
19
+ login Store an API key locally and optionally verify it immediately.
20
+ whoami Verify auth and return the current user and organization details.
21
+ status Show the currently resolved auth and base-url configuration.
22
+ logout Remove the stored API key from local config.
23
+
24
+ Notes:
25
+ Environment variables override local config: OUTRANK_API_KEY, OUTRANK_BASE_URL.
26
+ Optional config-dir override: OUTRANK_CONFIG_DIR
27
+ Local config path: ~/.outrank/config.json
28
+ `;
29
+
30
+ export const handleAuthCommand = async (argv) => {
31
+ const subcommand = argv[0];
32
+ const parsed = parseArgs(argv.slice(1));
33
+ const wantsHelp =
34
+ !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
35
+
36
+ if (wantsHelp) {
37
+ return cliSuccess(
38
+ {
39
+ usage: AUTH_HELP.trim(),
40
+ },
41
+ 'Choose one of: login, whoami, status, logout.',
42
+ );
43
+ }
44
+
45
+ if (subcommand === 'login') {
46
+ const apiKey = parsed.positionals[0] ?? getOption(parsed, 'api-key');
47
+ if (!apiKey) {
48
+ throw Object.assign(new Error('Missing API key.'), {
49
+ cliCode: 'CLI_INVALID_USAGE',
50
+ guidance:
51
+ 'Run `outrank-cli auth login <api-key>` or pass `--api-key <api-key>`, then retry.',
52
+ });
53
+ }
54
+
55
+ const baseUrl = getOption(parsed, 'base-url') ?? getDefaultBaseUrl();
56
+ if (!hasFlag(parsed, 'no-verify')) {
57
+ const verifyParsed = parseArgs(['--api-key', apiKey, '--base-url', baseUrl]);
58
+ const response = await requestAgentApi(verifyParsed, {
59
+ method: 'GET',
60
+ path: '/auth/whoami',
61
+ });
62
+ if (!response.ok) {
63
+ return response.payload;
64
+ }
65
+ }
66
+
67
+ const saved = await storeAuthConfig({ apiKey, baseUrl });
68
+ return cliSuccess(
69
+ {
70
+ stored: {
71
+ api_key: true,
72
+ base_url: saved.baseUrl,
73
+ config_path: getConfigPath(),
74
+ },
75
+ },
76
+ 'Credentials saved. Run `outrank-cli auth whoami` to confirm the active workspace at any time.',
77
+ );
78
+ }
79
+
80
+ if (subcommand === 'whoami') {
81
+ const response = await requestAgentApi(parsed, {
82
+ method: 'GET',
83
+ path: '/auth/whoami',
84
+ });
85
+ return response.payload;
86
+ }
87
+
88
+ if (subcommand === 'status') {
89
+ const runtime = await resolveRuntimeConfig(parsed);
90
+ return cliSuccess(
91
+ {
92
+ base_url: runtime.baseUrl,
93
+ api_key_present: Boolean(runtime.apiKey),
94
+ api_key_source: process.env.OUTRANK_API_KEY
95
+ ? 'env'
96
+ : runtime.config.apiKey
97
+ ? 'config'
98
+ : 'missing',
99
+ base_url_source: process.env.OUTRANK_BASE_URL
100
+ ? 'env'
101
+ : runtime.config.baseUrl
102
+ ? 'config'
103
+ : 'default',
104
+ config_path: getConfigPath(),
105
+ },
106
+ 'Confirm this target before running live commands. If you use a non-default Outrank deployment, set `OUTRANK_BASE_URL` first.',
107
+ );
108
+ }
109
+
110
+ if (subcommand === 'logout') {
111
+ const runtime = await resolveRuntimeConfig(parsed);
112
+ await clearStoredAuth();
113
+ return cliSuccess(
114
+ {
115
+ cleared: ['apiKey'],
116
+ config_path: getConfigPath(),
117
+ base_url_still_configured: Boolean(runtime.config.baseUrl),
118
+ },
119
+ 'Stored API key removed. Any future authenticated call now requires OUTRANK_API_KEY or a fresh `auth login`.',
120
+ );
121
+ }
122
+
123
+ throw Object.assign(new Error(`Unknown auth subcommand: ${subcommand}.`), {
124
+ cliCode: 'CLI_INVALID_USAGE',
125
+ guidance: 'Run `outrank-cli auth --help` to see available auth commands.',
126
+ });
127
+ };
@@ -0,0 +1,129 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { parseArgs, hasFlag, parseIntegerOption, getOption } from '../core/args.js';
4
+ import { requestAgentApi } from '../core/api.js';
5
+ import { cliSuccess } from '../core/errors.js';
6
+
7
+ export const BILLING_HELP = `Usage:
8
+ outrank-cli billing portal-url
9
+ outrank-cli billing purchase-products quote --additional-products <n> [--idempotency-key <key>]
10
+ outrank-cli billing purchase-products buy --additional-products <n> --confirm-charge-cents <n> --idempotency-key <key>
11
+
12
+ Notes:
13
+ portal-url returns a Stripe customer-portal link for the human to open in a browser.
14
+ quote requests pricing without authorizing the charge.
15
+ quote auto-generates an idempotency key if you do not pass one.
16
+ buy requires the server-quoted recurring total in cents and an explicit idempotency key.
17
+ `;
18
+
19
+ const handlePurchaseProducts = async (argv) => {
20
+ const subcommand = argv[0];
21
+ const parsed = parseArgs(argv.slice(1));
22
+ const wantsHelp =
23
+ !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help' || hasFlag(parsed, 'help');
24
+
25
+ if (wantsHelp) {
26
+ return cliSuccess(
27
+ {
28
+ usage:
29
+ 'Usage:\n outrank-cli billing purchase-products quote --additional-products <n> [--idempotency-key <key>]\n outrank-cli billing purchase-products buy --additional-products <n> --confirm-charge-cents <n> --idempotency-key <key>',
30
+ },
31
+ 'Use `quote` first, then `buy` with the quoted recurring total and a stable idempotency key.',
32
+ );
33
+ }
34
+
35
+ const additionalProducts = parseIntegerOption(parsed, 'additional-products', {
36
+ required: true,
37
+ min: 1,
38
+ max: 30,
39
+ });
40
+
41
+ if (subcommand === 'quote') {
42
+ const idempotencyKey = getOption(parsed, 'idempotency-key') || randomUUID();
43
+ const response = await requestAgentApi(parsed, {
44
+ method: 'POST',
45
+ path: '/billing/purchase-products',
46
+ body: {
47
+ additional_products: additionalProducts,
48
+ },
49
+ idempotencyKey,
50
+ });
51
+
52
+ if (!response.ok && response.status === 409) {
53
+ return cliSuccess(
54
+ {
55
+ quote: response.payload.error?.details ?? null,
56
+ api_response: response.payload,
57
+ idempotency_key: idempotencyKey,
58
+ },
59
+ 'Quote received. Review the recurring total and, if the human approves, run the matching `buy` command with --confirm-charge-cents set to the quoted amount.',
60
+ );
61
+ }
62
+
63
+ return response.payload;
64
+ }
65
+
66
+ if (subcommand === 'buy') {
67
+ const confirmChargeCents = parseIntegerOption(parsed, 'confirm-charge-cents', {
68
+ required: true,
69
+ min: 0,
70
+ });
71
+ const idempotencyKey = getOption(parsed, 'idempotency-key');
72
+ if (!idempotencyKey) {
73
+ throw Object.assign(new Error('Missing required option: --idempotency-key.'), {
74
+ cliCode: 'CLI_INVALID_USAGE',
75
+ guidance:
76
+ `Generate a stable unique key for this purchase, for example ${randomUUID()}, then retry with --idempotency-key <value>.`,
77
+ });
78
+ }
79
+
80
+ const response = await requestAgentApi(parsed, {
81
+ method: 'POST',
82
+ path: '/billing/purchase-products',
83
+ body: {
84
+ additional_products: additionalProducts,
85
+ confirm_charge_cents: confirmChargeCents,
86
+ },
87
+ idempotencyKey,
88
+ });
89
+ return response.payload;
90
+ }
91
+
92
+ throw Object.assign(new Error(`Unknown billing purchase-products subcommand: ${subcommand}.`), {
93
+ cliCode: 'CLI_INVALID_USAGE',
94
+ guidance:
95
+ 'Run `outrank-cli billing purchase-products --help` to see the available purchase-products commands.',
96
+ });
97
+ };
98
+
99
+ export const handleBillingCommand = async (argv) => {
100
+ const subcommand = argv[0];
101
+ const parsed = parseArgs(argv.slice(1));
102
+ const wantsHelp = !subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help';
103
+
104
+ if (wantsHelp) {
105
+ return cliSuccess(
106
+ {
107
+ usage: BILLING_HELP.trim(),
108
+ },
109
+ 'Choose one of the currently available billing groups: `portal-url` or `purchase-products`.',
110
+ );
111
+ }
112
+
113
+ if (subcommand === 'portal-url') {
114
+ const response = await requestAgentApi(parsed, {
115
+ method: 'GET',
116
+ path: '/billing/portal-url',
117
+ });
118
+ return response.payload;
119
+ }
120
+
121
+ if (subcommand === 'purchase-products') {
122
+ return handlePurchaseProducts(argv.slice(1));
123
+ }
124
+
125
+ throw Object.assign(new Error(`Unknown billing subcommand: ${subcommand}.`), {
126
+ cliCode: 'CLI_INVALID_USAGE',
127
+ guidance: 'Run `outrank-cli billing --help` to see available billing commands.',
128
+ });
129
+ };