@rankwrangler/cli 0.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/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @rankwrangler/cli
2
+
3
+ Official CLI for RankWrangler.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @rankwrangler/cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ rankwrangler config set api-key rrk_...
15
+ rankwrangler products get B0DV53VS61
16
+ ```
17
+
18
+ ## Development
19
+
20
+ ```bash
21
+ bun run cli:build
22
+ ```
23
+
24
+ ## Release (Maintainers)
25
+
26
+ ```bash
27
+ cd packages/cli
28
+ set -a
29
+ source ../../.env
30
+ set +a
31
+ npm whoami --userconfig ../../.npmrc
32
+ npm publish --access public --userconfig ../../.npmrc
33
+ ```
34
+
35
+ If npm rejects the version as already published, bump `version` in
36
+ `packages/cli/package.json` to match the next `vX.Y.Z` release in `CHANGELOG.md`,
37
+ run `bun install` from repo root, and publish again.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { createRankWranglerClient, DEFAULT_API_BASE_URL } from '@rankwrangler/http-client';
7
+ const ASIN_REGEX = /^[A-Z0-9]{10}$/i;
8
+ const TRAILING_SLASHES_REGEX = /\/+$/;
9
+ const API_SUFFIX_REGEX = /\/api$/i;
10
+ const DEFAULT_MARKETPLACE_ID = 'ATVPDKIKX0DER';
11
+ const CONFIG_DIR = path.join(homedir(), '.rankwrangler');
12
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
13
+ const DEFAULT_OUTPUT_PRETTY = true;
14
+ const { positionals, values } = parseArgs({
15
+ args: process.argv.slice(2),
16
+ options: {
17
+ help: { type: 'boolean', short: 'h' },
18
+ apiKey: { type: 'string' },
19
+ licenseKey: { type: 'string' },
20
+ baseUrl: { type: 'string' },
21
+ url: { type: 'string' },
22
+ marketplace: { type: 'string', short: 'm' },
23
+ marketplaceId: { type: 'string' },
24
+ asin: { type: 'string', multiple: true },
25
+ asins: { type: 'string', multiple: true },
26
+ },
27
+ allowPositionals: true,
28
+ });
29
+ const outputPretty = DEFAULT_OUTPUT_PRETTY;
30
+ const main = async () => {
31
+ if (values.help || positionals.length === 0) {
32
+ printUsage();
33
+ process.exit(values.help ? 0 : 1);
34
+ }
35
+ const command = resolveCommand(positionals);
36
+ if (!command) {
37
+ fail('UNKNOWN_COMMAND', 'Unknown command', { command: positionals.join(' ') });
38
+ return;
39
+ }
40
+ const config = await loadConfig();
41
+ if (command.resource === 'config') {
42
+ const response = await runConfigCommand(command, config);
43
+ printSuccess(response);
44
+ return;
45
+ }
46
+ const apiKey = resolveApiKey(config);
47
+ if (!apiKey) {
48
+ fail('MISSING_CONFIG', 'api key is required. set via `config set api-key <value>`');
49
+ return;
50
+ }
51
+ const baseUrl = resolveBaseUrl(config);
52
+ const client = createRankWranglerClient({
53
+ baseUrl,
54
+ apiKey,
55
+ });
56
+ const response = await runApiCommand(command, client, config);
57
+ printSuccess(response);
58
+ };
59
+ const runApiCommand = async (command, client, config) => {
60
+ if (command.resource === 'products' && command.verb === 'get') {
61
+ const marketplaceId = requireMarketplaceId(config);
62
+ const asins = requireAsins(command.args);
63
+ if (asins.length === 1) {
64
+ return client.getProductInfo.mutate({ marketplaceId, asin: asins[0] });
65
+ }
66
+ return client.getProductInfoBatch.mutate({ marketplaceId, asins });
67
+ }
68
+ if (command.resource === 'license' && command.verb === 'status') {
69
+ return client.license.status.mutate();
70
+ }
71
+ if (command.resource === 'license' && command.verb === 'validate') {
72
+ return client.license.validate.mutate();
73
+ }
74
+ fail('UNKNOWN_COMMAND', 'Unknown command', {
75
+ command: `${command.resource} ${command.verb}`,
76
+ });
77
+ return null;
78
+ };
79
+ const runConfigCommand = async (command, config) => {
80
+ if (command.verb === 'show') {
81
+ return {
82
+ path: CONFIG_PATH,
83
+ config,
84
+ };
85
+ }
86
+ if (command.verb === 'clear') {
87
+ await rm(CONFIG_PATH, { force: true });
88
+ return {
89
+ path: CONFIG_PATH,
90
+ cleared: true,
91
+ };
92
+ }
93
+ if (command.verb === 'set') {
94
+ const [key, ...valueParts] = command.args;
95
+ if (!key || valueParts.length === 0) {
96
+ fail('INVALID_INPUT', 'config set requires <key> <value>');
97
+ return null;
98
+ }
99
+ const value = valueParts.join(' ').trim();
100
+ if (!value) {
101
+ fail('INVALID_INPUT', 'config set value cannot be empty');
102
+ return null;
103
+ }
104
+ const nextConfig = { ...config };
105
+ if (key === 'api-key') {
106
+ nextConfig.apiKey = value;
107
+ }
108
+ else if (key === 'base-url') {
109
+ nextConfig.baseUrl = normalizeBaseUrl(value);
110
+ }
111
+ else if (key === 'marketplace' || key === 'marketplace-id') {
112
+ nextConfig.marketplaceId = value;
113
+ }
114
+ else {
115
+ fail('INVALID_INPUT', 'unsupported config key', {
116
+ key,
117
+ supportedKeys: ['api-key', 'base-url', 'marketplace'],
118
+ });
119
+ return null;
120
+ }
121
+ await saveConfig(nextConfig);
122
+ return {
123
+ path: CONFIG_PATH,
124
+ config: nextConfig,
125
+ };
126
+ }
127
+ fail('UNKNOWN_COMMAND', 'Unknown config command', { verb: command.verb });
128
+ return null;
129
+ };
130
+ const requireMarketplaceId = (config) => {
131
+ const marketplaceId = values.marketplaceId ??
132
+ values.marketplace ??
133
+ config.marketplaceId ??
134
+ process.env.RR_MARKETPLACE_ID ??
135
+ DEFAULT_MARKETPLACE_ID;
136
+ return marketplaceId;
137
+ };
138
+ const requireAsins = (commandArgs) => {
139
+ const candidates = collectAsinCandidates(commandArgs);
140
+ if (candidates.length === 0) {
141
+ fail('INVALID_INPUT', 'at least one asin is required');
142
+ }
143
+ return Array.from(new Set(candidates.map(normalizeAsin)));
144
+ };
145
+ const collectAsinCandidates = (commandArgs) => {
146
+ const optionAsins = [...(values.asin ?? []), ...(values.asins ?? [])];
147
+ const envAsins = process.env.RR_ASINS
148
+ ? process.env.RR_ASINS.split(',').map(value => value.trim())
149
+ : [];
150
+ const envSingleAsin = process.env.RR_ASIN ? [process.env.RR_ASIN] : [];
151
+ return [...commandArgs, ...optionAsins, ...envAsins, ...envSingleAsin]
152
+ .map(value => value.trim())
153
+ .filter(Boolean);
154
+ };
155
+ const normalizeAsin = (value) => {
156
+ const normalized = value.trim().toUpperCase();
157
+ if (!ASIN_REGEX.test(normalized)) {
158
+ fail('INVALID_INPUT', `invalid ASIN: ${value}`);
159
+ }
160
+ return normalized;
161
+ };
162
+ const resolveApiKey = (config) => {
163
+ return values.apiKey ?? values.licenseKey ?? config.apiKey ?? process.env.RR_LICENSE_KEY;
164
+ };
165
+ const resolveBaseUrl = (config) => {
166
+ const configured = values.baseUrl ?? values.url ?? config.baseUrl ?? process.env.RR_API_URL;
167
+ return normalizeBaseUrl(configured ?? DEFAULT_API_BASE_URL);
168
+ };
169
+ const normalizeBaseUrl = (value) => {
170
+ const trimmed = value.trim();
171
+ const withoutTrailingSlashes = trimmed.replace(TRAILING_SLASHES_REGEX, '');
172
+ const withoutApiSuffix = withoutTrailingSlashes.replace(API_SUFFIX_REGEX, '');
173
+ if (!withoutApiSuffix) {
174
+ fail('INVALID_INPUT', 'base url cannot be empty');
175
+ }
176
+ return withoutApiSuffix;
177
+ };
178
+ const resolveCommand = (inputPositionals) => {
179
+ const [first, second, ...rest] = inputPositionals;
180
+ if (!first) {
181
+ return null;
182
+ }
183
+ if (first === 'get-product-info') {
184
+ return {
185
+ resource: 'products',
186
+ verb: 'get',
187
+ args: rest,
188
+ };
189
+ }
190
+ if (first === 'get-product-info-batch') {
191
+ return {
192
+ resource: 'products',
193
+ verb: 'get',
194
+ args: rest,
195
+ };
196
+ }
197
+ if (!second) {
198
+ return null;
199
+ }
200
+ return {
201
+ resource: first,
202
+ verb: first === 'products' && second === 'get-batch' ? 'get' : second,
203
+ args: rest,
204
+ };
205
+ };
206
+ const loadConfig = async () => {
207
+ try {
208
+ const raw = await readFile(CONFIG_PATH, 'utf8');
209
+ const parsed = JSON.parse(raw);
210
+ return {
211
+ apiKey: typeof parsed.apiKey === 'string' ? parsed.apiKey : undefined,
212
+ baseUrl: typeof parsed.baseUrl === 'string' ? normalizeBaseUrl(parsed.baseUrl) : undefined,
213
+ marketplaceId: typeof parsed.marketplaceId === 'string' ? parsed.marketplaceId : undefined,
214
+ };
215
+ }
216
+ catch (error) {
217
+ if (error &&
218
+ typeof error === 'object' &&
219
+ 'code' in error &&
220
+ error.code === 'ENOENT') {
221
+ return {};
222
+ }
223
+ fail('INVALID_CONFIG', 'failed to read config', { path: CONFIG_PATH });
224
+ return {};
225
+ }
226
+ };
227
+ const saveConfig = async (config) => {
228
+ await mkdir(CONFIG_DIR, { recursive: true });
229
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
230
+ };
231
+ const resolveError = (error) => {
232
+ if (error && typeof error === 'object') {
233
+ if ('data' in error) {
234
+ const data = error.data;
235
+ if (data?.code) {
236
+ return {
237
+ code: data.code,
238
+ message: error instanceof Error
239
+ ? error.message
240
+ : 'Request failed',
241
+ details: data.httpStatus ? { httpStatus: data.httpStatus } : undefined,
242
+ };
243
+ }
244
+ }
245
+ if ('message' in error && typeof error.message === 'string') {
246
+ return {
247
+ code: 'REQUEST_FAILED',
248
+ message: error.message,
249
+ };
250
+ }
251
+ }
252
+ return {
253
+ code: 'UNKNOWN_ERROR',
254
+ message: 'Unknown error',
255
+ };
256
+ };
257
+ const printSuccess = (data) => {
258
+ const envelope = {
259
+ ok: true,
260
+ data,
261
+ };
262
+ console.log(JSON.stringify(envelope, null, outputPretty ? 2 : undefined));
263
+ };
264
+ const fail = (code, message, details) => {
265
+ const envelope = {
266
+ ok: false,
267
+ error: {
268
+ code,
269
+ message,
270
+ ...(details !== undefined ? { details } : {}),
271
+ },
272
+ };
273
+ console.error(JSON.stringify(envelope, null, outputPretty ? 2 : undefined));
274
+ process.exit(1);
275
+ };
276
+ const printUsage = () => {
277
+ const usage = [
278
+ 'RankWrangler CLI',
279
+ '',
280
+ 'Command Shape',
281
+ ' rankwrangler <resource> <verb> [args...] [flags...]',
282
+ '',
283
+ 'Principles',
284
+ ' - Resource-first, verb-second commands',
285
+ ' - JSON-only output with {"ok": true|false, ...} envelope',
286
+ ' - One command maps to one API capability',
287
+ '',
288
+ 'API Commands',
289
+ ' products get <ASIN...> [--marketplace <id>|-m <id>]',
290
+ ' license status',
291
+ ' license validate',
292
+ '',
293
+ 'Config Commands',
294
+ ' config show',
295
+ ' config clear',
296
+ ' config set api-key <value>',
297
+ ' config set base-url <origin>',
298
+ ' config set marketplace <marketplaceId>',
299
+ '',
300
+ 'Global Flags',
301
+ ' --apiKey <value> Override API key (alias: --licenseKey)',
302
+ ' --baseUrl <origin> Override API origin (alias: --url)',
303
+ ` --marketplace <id>, -m Override marketplace (default: ${DEFAULT_MARKETPLACE_ID})`,
304
+ ' --marketplaceId <id> Compatibility alias for --marketplace',
305
+ ' --asin <ASIN> Add ASIN (repeatable)',
306
+ ' --asins <ASIN> Add ASIN (repeatable, compatibility alias)',
307
+ '',
308
+ 'Legacy Aliases (still supported)',
309
+ ' get-product-info',
310
+ ' get-product-info-batch',
311
+ ' products get-batch',
312
+ ];
313
+ console.log(usage.join('\n'));
314
+ };
315
+ await main().catch(error => {
316
+ const resolved = resolveError(error);
317
+ fail(resolved.code, resolved.message, resolved.details);
318
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@rankwrangler/cli",
3
+ "version": "0.1.2",
4
+ "description": "Official RankWrangler command line interface",
5
+ "type": "module",
6
+ "bin": {
7
+ "rankwrangler": "dist/index.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@rankwrangler/http-client": "^0.1.2"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^24.3.0",
26
+ "typescript": "^5.9.2"
27
+ },
28
+ "packageManager": "bun@1.1.27"
29
+ }