@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 +37 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +318 -0
- package/package.json +29 -0
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.
|
package/dist/index.d.ts
ADDED
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
|
+
}
|