@rankwrangler/cli 0.2.3 → 0.4.1
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 +15 -1
- package/dist/CHANGELOG.md +187 -0
- package/dist/cli-config.d.ts +31 -0
- package/dist/cli-config.js +136 -0
- package/dist/cli-metadata.d.ts +2 -0
- package/dist/cli-metadata.js +31 -0
- package/dist/cli-options.d.ts +35 -0
- package/dist/cli-options.js +101 -0
- package/dist/cli-output.d.ts +11 -0
- package/dist/cli-output.js +53 -0
- package/dist/history-response.d.ts +41 -0
- package/dist/history-response.js +26 -0
- package/dist/index.js +86 -318
- package/dist/usage.d.ts +2 -0
- package/dist/usage.js +63 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -11,16 +11,27 @@ npm install -g @rankwrangler/cli
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
14
|
+
export RR_LICENSE_KEY=rrk_...
|
|
15
|
+
rw --version
|
|
16
|
+
rw changelog
|
|
17
|
+
rw config set storage-dir ~/.config/rankwrangler
|
|
15
18
|
rw products get B0DV53VS61
|
|
16
19
|
rw products history B0DV53VS61 --metrics bsr,price
|
|
17
20
|
# `rankwrangler` is also supported as an alias
|
|
18
21
|
```
|
|
19
22
|
|
|
23
|
+
`RR_LICENSE_KEY` is the API-key source. `rw config set storage-dir <path>` saves the
|
|
24
|
+
active storage directory globally. After that, every CLI command reads and writes its non-secret
|
|
25
|
+
config/data from that directory, while preserving existing config values when switching.
|
|
26
|
+
`RR_STORAGE_DIR`, `RR_API_URL`, and `RR_MARKETPLACE_ID` override saved CLI config when set.
|
|
27
|
+
`rw --version` prints the installed CLI version. `rw changelog` prints the latest bundled release
|
|
28
|
+
notes shipped with the package.
|
|
29
|
+
|
|
20
30
|
## Development
|
|
21
31
|
|
|
22
32
|
```bash
|
|
23
33
|
bun run cli:build
|
|
34
|
+
bun run cli:test:e2e
|
|
24
35
|
```
|
|
25
36
|
|
|
26
37
|
## Release (Maintainers)
|
|
@@ -39,6 +50,9 @@ bun run release:check-cli-pack
|
|
|
39
50
|
|
|
40
51
|
Then publish:
|
|
41
52
|
|
|
53
|
+
Publish `packages/http-client` first. `packages/cli` now fails fast on `npm publish` until the
|
|
54
|
+
matching `@rankwrangler/http-client` version is already available on npm.
|
|
55
|
+
|
|
42
56
|
```bash
|
|
43
57
|
cd packages/cli
|
|
44
58
|
set -a
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## v0.4.1 - 2026-03-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add CLI `rw --version` and `rw changelog` commands, including bundled release notes in the
|
|
10
|
+
published npm package.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Rotate API key generation by email so creating a new key for the same email replaces older
|
|
15
|
+
active keys.
|
|
16
|
+
- Remove the remaining PostHog integration from the server runtime, config, and related docs.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Make CLI publish fail fast until the matching `@rankwrangler/http-client` version is already on
|
|
21
|
+
npm, reducing release-order mistakes.
|
|
22
|
+
|
|
23
|
+
## v0.4.0 - 2026-03-09
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Add `RR_STORAGE_DIR` and document CLI env vars in `.env.example` so local CLI testing can use
|
|
28
|
+
env-based configuration end to end.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Make CLI environment variables override saved config for storage-dir, API origin, and
|
|
33
|
+
marketplace values.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Fix the built CLI package to run under Node after the CLI module split and strengthen release
|
|
38
|
+
pack validation around the built artifact.
|
|
39
|
+
|
|
40
|
+
## v0.3.0 - 2026-03-09
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Add BA Top Search Terms ingestion, trend views, keyword search, and job execution visibility
|
|
45
|
+
across the dashboard.
|
|
46
|
+
- Add product facet classification, facet metrics, and facet badges to improve dashboard filtering
|
|
47
|
+
and product detail context.
|
|
48
|
+
- Add persistent CLI storage-directory support so local CLI config/data can move to a custom path.
|
|
49
|
+
- Add `RR_STORAGE_DIR` so CLI storage location can be controlled by environment as well as saved
|
|
50
|
+
config.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Redesign the search terms dashboard UI with richer toolbar controls, stat sparklines, clearer
|
|
55
|
+
metric timing, and stronger layout polish.
|
|
56
|
+
- Improve top search terms scheduling, batching, report polling, and status performance so refresh
|
|
57
|
+
throughput and visibility stay stable as datasets grow.
|
|
58
|
+
- Centralize adaptive SP-API limiter tuning and expose limiter stats for better operational control.
|
|
59
|
+
- Breaking: require `RR_LICENSE_KEY` for CLI auth and stop storing API keys in CLI config.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- Fix weekly top search terms windows to align to Sunday-Saturday boundaries and correct
|
|
64
|
+
reclassification batch payload handling.
|
|
65
|
+
- Tighten merch keyword filtering and category-slot parsing to reduce seasonal/accessory noise
|
|
66
|
+
without dropping valid terms.
|
|
67
|
+
- Fix Node runtime imports in the built CLI package and strengthen release pack validation after
|
|
68
|
+
the CLI module split.
|
|
69
|
+
- Fix facet metrics timestamp anchoring, SQL casting, and drawer badge data flow in dashboard
|
|
70
|
+
surfaces.
|
|
71
|
+
|
|
72
|
+
## v0.2.3 - 2026-03-01
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- Add CLI `products history` agent output for BSR and price metric series.
|
|
77
|
+
- Add dashboard logs page and event log pipeline with Keepa queue/poll visibility.
|
|
78
|
+
- Add tiered Keepa scheduling with improved enqueue metrics and status reporting.
|
|
79
|
+
- Add shared history chart usage across website and extension surfaces.
|
|
80
|
+
- Add extension UI preview workflow updates with safer placeholder license key handling.
|
|
81
|
+
|
|
82
|
+
### Changed
|
|
83
|
+
|
|
84
|
+
- Handle SP-API no-payload catalog responses as product deletions in sync flows.
|
|
85
|
+
- Refine event logging behavior, server documentation, and Keepa policy labeling.
|
|
86
|
+
|
|
87
|
+
### Fixed
|
|
88
|
+
|
|
89
|
+
- Fix product drawer chart skeleton height and sponsored carousel stats placement.
|
|
90
|
+
- Fix Caddy build wiring to include the history-chart workspace dependency.
|
|
91
|
+
- Improve dashboard log view layout behavior and readability.
|
|
92
|
+
|
|
93
|
+
## v0.2.2 - 2026-02-24
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- Add dashboard-style BSR chart styling to the extension product view.
|
|
98
|
+
- Add Keepa stale sync tests and documentation coverage.
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
102
|
+
- Add bottom border to the appearance settings section for stronger visual separation.
|
|
103
|
+
- Clarify agent guidance for work-in-progress rewrite expectations.
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
|
|
107
|
+
- Clarify history chart date labeling behavior.
|
|
108
|
+
|
|
109
|
+
## v0.2.1 - 2026-02-22
|
|
110
|
+
|
|
111
|
+
### Added
|
|
112
|
+
|
|
113
|
+
- Add fuzzy dashboard product search and stronger filter-driven sidebar workflows.
|
|
114
|
+
- Add extension BSR history graph flow, including history sheet deep-linking from BSR badges.
|
|
115
|
+
- Add admin job runtime observability, queue log browsing, and per-source drilldown stats.
|
|
116
|
+
- Add Keepa sync loading states and richer history date-range controls.
|
|
117
|
+
|
|
118
|
+
### Changed
|
|
119
|
+
|
|
120
|
+
- Move product ingest processing to an event-driven queue flow and align Keepa refresh to one-shot
|
|
121
|
+
enqueue jobs.
|
|
122
|
+
- Rename ingest queue labeling to the SP-API sync queue across admin surfaces.
|
|
123
|
+
- Refine BSR/history chart interactions, sparse-range handling, and loading/empty-state behavior.
|
|
124
|
+
- Improve extension build prebuild dependency handling and dashboard/admin UI polish.
|
|
125
|
+
- Clarify docs for Keepa automatic vs manual behavior.
|
|
126
|
+
|
|
127
|
+
## v0.2.0 - 2026-02-20
|
|
128
|
+
|
|
129
|
+
### Added
|
|
130
|
+
|
|
131
|
+
- Add Keepa history import flow and dashboard history UI.
|
|
132
|
+
- Add Keepa refresh queue processing and Keepa status metrics in the dashboard.
|
|
133
|
+
- Add admin job runtime observability endpoints and dashboard visibility.
|
|
134
|
+
- Add extension BSR history modal with on-demand history loading flow.
|
|
135
|
+
- Add public + app product history query/load procedures for Keepa-backed BSR history.
|
|
136
|
+
- Add public API usage consumption to history query/load endpoints to enforce API key limits.
|
|
137
|
+
|
|
138
|
+
### Changed
|
|
139
|
+
|
|
140
|
+
- Align Keepa refresh behavior to a one-shot enqueue model.
|
|
141
|
+
- Redesign dashboard sidebar header/layout, Keepa history panel, filters, and settings modal UX.
|
|
142
|
+
- Standardize external naming to `API key` across public API routes, CLI commands, extension UI
|
|
143
|
+
copy, and related docs.
|
|
144
|
+
- Rename CLI auth commands from `license status|validate` to `api-key status|validate`.
|
|
145
|
+
- Rename public API auth namespace from `api.public.license.*` to `api.public.apiKey.*` and app
|
|
146
|
+
namespace from `api.app.license.*` to `api.app.apiKey.*`.
|
|
147
|
+
- Switch CLI environment fallback from `RR_LICENSE_KEY` to `RR_API_KEY`.
|
|
148
|
+
|
|
149
|
+
### Fixed
|
|
150
|
+
|
|
151
|
+
- Fix Keepa fetch worker job payload handling.
|
|
152
|
+
|
|
153
|
+
## v0.1.3 - 2026-02-17
|
|
154
|
+
|
|
155
|
+
### Changed
|
|
156
|
+
|
|
157
|
+
- Set `@rankwrangler/cli` to expose both `rw` (primary) and `rankwrangler` binaries.
|
|
158
|
+
- Tighten CLI help output into a concise man-style format and remove principles text from runtime
|
|
159
|
+
help.
|
|
160
|
+
- Remove legacy CLI command and flag aliases by default (`get-product-info`,
|
|
161
|
+
`get-product-info-batch`, `products get-batch`, `--licenseKey`, `--url`, `--marketplaceId`,
|
|
162
|
+
`--asins`).
|
|
163
|
+
- Add explicit CLI policy guidance that legacy aliases/compatibility shims require explicit user
|
|
164
|
+
direction.
|
|
165
|
+
|
|
166
|
+
## v0.1.2 - 2026-02-17
|
|
167
|
+
|
|
168
|
+
### Changed
|
|
169
|
+
|
|
170
|
+
- Update extension's pinned HTTP client tarball dependency to
|
|
171
|
+
`rankwrangler-http-client-0.1.2.tgz`.
|
|
172
|
+
- Update release docs to require synchronized versions between `CHANGELOG.md` releases and npm
|
|
173
|
+
packages.
|
|
174
|
+
|
|
175
|
+
## v0.1.0 - 2026-02-16
|
|
176
|
+
|
|
177
|
+
### Added
|
|
178
|
+
|
|
179
|
+
- Add public batch product endpoint (`api.public.getProductInfoBatch`) to support multi-ASIN workflows.
|
|
180
|
+
- Add CLI multi-ASIN product lookup (`products get <asin...>`) with default marketplace config and per-command override (`--marketplace` / `-m`).
|
|
181
|
+
- Add public license usage endpoints used by CLI/public HTTP clients for status and consume flows.
|
|
182
|
+
|
|
183
|
+
### Changed
|
|
184
|
+
|
|
185
|
+
- Rename and standardize the typed npm client package to `@merchbase/rankwrangler-http-client`.
|
|
186
|
+
- Set initial HTTP client package release to `0.1.0` in `packages/http-client/package.json`.
|
|
187
|
+
- Document npm publish and versioning policy for the typed HTTP client in `docs/http-client-spec.md`.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type CliConfig = {
|
|
2
|
+
baseUrl?: string;
|
|
3
|
+
marketplaceId?: string;
|
|
4
|
+
};
|
|
5
|
+
export type CliPaths = {
|
|
6
|
+
defaultStorageDir: string;
|
|
7
|
+
storageDir: string;
|
|
8
|
+
configPath: string;
|
|
9
|
+
globalConfigPath: string;
|
|
10
|
+
};
|
|
11
|
+
export declare class CliStorageError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly details?: unknown;
|
|
14
|
+
constructor(code: string, message: string, details?: unknown);
|
|
15
|
+
}
|
|
16
|
+
export declare const getDefaultCliPaths: () => CliPaths;
|
|
17
|
+
export declare const loadCliContext: () => Promise<{
|
|
18
|
+
paths: CliPaths;
|
|
19
|
+
config: CliConfig;
|
|
20
|
+
}>;
|
|
21
|
+
export declare const loadCliPathsOrDefault: () => Promise<CliPaths>;
|
|
22
|
+
export declare const clearConfig: (paths: CliPaths) => Promise<void>;
|
|
23
|
+
export declare const saveConfig: (paths: CliPaths, config: CliConfig) => Promise<void>;
|
|
24
|
+
export declare const switchStorageDir: ({ currentConfig, currentPaths, requestedStorageDir, }: {
|
|
25
|
+
currentConfig: CliConfig;
|
|
26
|
+
currentPaths: CliPaths;
|
|
27
|
+
requestedStorageDir: string;
|
|
28
|
+
}) => Promise<{
|
|
29
|
+
paths: CliPaths;
|
|
30
|
+
config: CliConfig;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const DEFAULT_STORAGE_DIR = path.join(homedir(), '.rankwrangler');
|
|
5
|
+
const CONFIG_FILENAME = 'config.json';
|
|
6
|
+
const GLOBAL_CONFIG_FILENAME = 'global.json';
|
|
7
|
+
const STORAGE_DIR_ENV_VAR = 'RR_STORAGE_DIR';
|
|
8
|
+
export class CliStorageError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
details;
|
|
11
|
+
constructor(code, message, details) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'CliStorageError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.details = details;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const getDefaultCliPaths = () => {
|
|
19
|
+
return buildCliPaths(DEFAULT_STORAGE_DIR);
|
|
20
|
+
};
|
|
21
|
+
export const loadCliContext = async () => {
|
|
22
|
+
const paths = await resolveCliPaths();
|
|
23
|
+
const config = await loadConfig(paths.configPath);
|
|
24
|
+
return {
|
|
25
|
+
paths,
|
|
26
|
+
config,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export const loadCliPathsOrDefault = async () => {
|
|
30
|
+
try {
|
|
31
|
+
return await resolveCliPaths();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return getDefaultCliPaths();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
export const clearConfig = async (paths) => {
|
|
38
|
+
await rm(paths.configPath, { force: true });
|
|
39
|
+
};
|
|
40
|
+
export const saveConfig = async (paths, config) => {
|
|
41
|
+
await mkdir(paths.storageDir, { recursive: true });
|
|
42
|
+
await writeFile(paths.configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
43
|
+
};
|
|
44
|
+
export const switchStorageDir = async ({ currentConfig, currentPaths, requestedStorageDir, }) => {
|
|
45
|
+
const storageDir = normalizeStorageDir(requestedStorageDir);
|
|
46
|
+
const nextPaths = buildCliPaths(storageDir);
|
|
47
|
+
const targetConfig = await loadConfig(nextPaths.configPath);
|
|
48
|
+
const nextConfig = mergeConfigs(currentConfig, targetConfig);
|
|
49
|
+
await saveConfig(nextPaths, nextConfig);
|
|
50
|
+
await saveGlobalConfig(currentPaths.globalConfigPath, {
|
|
51
|
+
storageDir: storageDir === currentPaths.defaultStorageDir ? undefined : storageDir,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
paths: nextPaths,
|
|
55
|
+
config: nextConfig,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const resolveCliPaths = async () => {
|
|
59
|
+
const defaults = getDefaultCliPaths();
|
|
60
|
+
const globalConfig = await loadGlobalConfig(defaults.globalConfigPath);
|
|
61
|
+
const storageDir = normalizeStorageDir(process.env[STORAGE_DIR_ENV_VAR] ?? globalConfig.storageDir ?? defaults.storageDir);
|
|
62
|
+
return buildCliPaths(storageDir);
|
|
63
|
+
};
|
|
64
|
+
const buildCliPaths = (storageDir) => {
|
|
65
|
+
return {
|
|
66
|
+
defaultStorageDir: DEFAULT_STORAGE_DIR,
|
|
67
|
+
storageDir,
|
|
68
|
+
configPath: path.join(storageDir, CONFIG_FILENAME),
|
|
69
|
+
globalConfigPath: path.join(DEFAULT_STORAGE_DIR, GLOBAL_CONFIG_FILENAME),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const loadConfig = async (configPath) => {
|
|
73
|
+
const parsed = await readJsonFile(configPath, 'failed to read config');
|
|
74
|
+
if (!parsed) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
baseUrl: typeof parsed.baseUrl === 'string' ? parsed.baseUrl : undefined,
|
|
79
|
+
marketplaceId: typeof parsed.marketplaceId === 'string' ? parsed.marketplaceId : undefined,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const loadGlobalConfig = async (globalConfigPath) => {
|
|
83
|
+
const parsed = await readJsonFile(globalConfigPath, 'failed to read global config');
|
|
84
|
+
if (!parsed) {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
storageDir: typeof parsed.storageDir === 'string' ? parsed.storageDir : undefined,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const saveGlobalConfig = async (globalConfigPath, config) => {
|
|
92
|
+
if (!config.storageDir) {
|
|
93
|
+
await rm(globalConfigPath, { force: true });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await mkdir(path.dirname(globalConfigPath), { recursive: true });
|
|
97
|
+
await writeFile(globalConfigPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
98
|
+
};
|
|
99
|
+
const readJsonFile = async (filePath, errorMessage) => {
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(filePath, 'utf8');
|
|
102
|
+
return JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error &&
|
|
106
|
+
typeof error === 'object' &&
|
|
107
|
+
'code' in error &&
|
|
108
|
+
error.code === 'ENOENT') {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
throw new CliStorageError('INVALID_CONFIG', errorMessage, { path: filePath });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const normalizeStorageDir = (value) => {
|
|
115
|
+
const trimmed = value.trim();
|
|
116
|
+
if (!trimmed) {
|
|
117
|
+
throw new CliStorageError('INVALID_INPUT', 'storage dir cannot be empty');
|
|
118
|
+
}
|
|
119
|
+
const expanded = expandHomeDirectory(trimmed);
|
|
120
|
+
return path.resolve(expanded);
|
|
121
|
+
};
|
|
122
|
+
const expandHomeDirectory = (value) => {
|
|
123
|
+
if (value === '~') {
|
|
124
|
+
return homedir();
|
|
125
|
+
}
|
|
126
|
+
if (value.startsWith('~/')) {
|
|
127
|
+
return path.join(homedir(), value.slice(2));
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
};
|
|
131
|
+
const mergeConfigs = (currentConfig, targetConfig) => {
|
|
132
|
+
return {
|
|
133
|
+
baseUrl: targetConfig.baseUrl ?? currentConfig.baseUrl,
|
|
134
|
+
marketplaceId: targetConfig.marketplaceId ?? currentConfig.marketplaceId,
|
|
135
|
+
};
|
|
136
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
const PACKAGE_JSON_PATH = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
4
|
+
const CHANGELOG_PATH = fileURLToPath(new URL('./CHANGELOG.md', import.meta.url));
|
|
5
|
+
const RELEASE_HEADING_PATTERN = /^## v\d+\.\d+\.\d+ - \d{4}-\d{2}-\d{2}$/gm;
|
|
6
|
+
export const printCliVersion = () => {
|
|
7
|
+
const metadata = readPackageMetadata();
|
|
8
|
+
console.log(metadata.version);
|
|
9
|
+
};
|
|
10
|
+
export const printBundledChangelog = () => {
|
|
11
|
+
const changelog = readBundledChangelog();
|
|
12
|
+
const latestEntry = extractLatestReleaseEntry(changelog);
|
|
13
|
+
console.log(latestEntry);
|
|
14
|
+
};
|
|
15
|
+
const readPackageMetadata = () => {
|
|
16
|
+
return JSON.parse(readFileSync(PACKAGE_JSON_PATH, 'utf8'));
|
|
17
|
+
};
|
|
18
|
+
const readBundledChangelog = () => {
|
|
19
|
+
return readFileSync(CHANGELOG_PATH, 'utf8').trim();
|
|
20
|
+
};
|
|
21
|
+
const extractLatestReleaseEntry = (changelog) => {
|
|
22
|
+
const matches = Array.from(changelog.matchAll(RELEASE_HEADING_PATTERN));
|
|
23
|
+
if (matches.length === 0) {
|
|
24
|
+
throw new Error('Bundled changelog is missing a release entry');
|
|
25
|
+
}
|
|
26
|
+
const latestMatch = matches[0];
|
|
27
|
+
const nextMatch = matches[1];
|
|
28
|
+
const start = latestMatch.index ?? 0;
|
|
29
|
+
const end = nextMatch?.index ?? changelog.length;
|
|
30
|
+
return changelog.slice(start, end).trim();
|
|
31
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CliConfig } from './cli-config';
|
|
2
|
+
import { type HistoryMetricAlias } from './history-response';
|
|
3
|
+
type CliFail = (code: string, message: string, details?: unknown) => never;
|
|
4
|
+
export type CliOptionValues = {
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
marketplace?: string;
|
|
7
|
+
asin?: string[];
|
|
8
|
+
metrics?: string;
|
|
9
|
+
startAt?: string;
|
|
10
|
+
endAt?: string;
|
|
11
|
+
days?: string;
|
|
12
|
+
limit?: string;
|
|
13
|
+
help?: boolean;
|
|
14
|
+
version?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare const requireMarketplaceId: (values: CliOptionValues, config: CliConfig) => string;
|
|
17
|
+
export declare const requireAsins: (commandArgs: string[], values: CliOptionValues, fail: CliFail) => string[];
|
|
18
|
+
export declare const requireSingleAsin: (commandArgs: string[], values: CliOptionValues, fail: CliFail) => string;
|
|
19
|
+
export declare const resolveHistoryMetrics: (values: CliOptionValues, fail: CliFail) => HistoryMetricAlias[];
|
|
20
|
+
export declare const resolveHistoryWindow: (values: CliOptionValues, fail: CliFail) => {
|
|
21
|
+
days: number;
|
|
22
|
+
endAt?: string | undefined;
|
|
23
|
+
startAt?: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
export declare const resolveApiKey: () => string | undefined;
|
|
26
|
+
export declare const resolveBaseUrl: (values: CliOptionValues, config: CliConfig, defaultBaseUrl: string, fail: CliFail) => string;
|
|
27
|
+
export declare const normalizeBaseUrl: (value: string, fail: CliFail) => string;
|
|
28
|
+
export declare const parseIntegerOption: ({ value, optionName, min, max, defaultValue, }: {
|
|
29
|
+
value: string | undefined;
|
|
30
|
+
optionName: string;
|
|
31
|
+
min: number;
|
|
32
|
+
max: number;
|
|
33
|
+
defaultValue: number;
|
|
34
|
+
}, fail: CliFail) => number;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { HISTORY_METRIC_ALIASES } from './history-response.js';
|
|
2
|
+
const ASIN_REGEX = /^[A-Z0-9]{10}$/i;
|
|
3
|
+
const TRAILING_SLASHES_REGEX = /\/+$/;
|
|
4
|
+
const API_SUFFIX_REGEX = /\/api$/i;
|
|
5
|
+
const DEFAULT_MARKETPLACE_ID = 'ATVPDKIKX0DER';
|
|
6
|
+
export const requireMarketplaceId = (values, config) => {
|
|
7
|
+
return values.marketplace ?? config.marketplaceId ?? process.env.RR_MARKETPLACE_ID ?? DEFAULT_MARKETPLACE_ID;
|
|
8
|
+
};
|
|
9
|
+
export const requireAsins = (commandArgs, values, fail) => {
|
|
10
|
+
const candidates = [...commandArgs, ...(values.asin ?? []), ...resolveEnvAsins()]
|
|
11
|
+
.map(value => value.trim())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
if (candidates.length === 0) {
|
|
14
|
+
fail('INVALID_INPUT', 'at least one asin is required');
|
|
15
|
+
}
|
|
16
|
+
return Array.from(new Set(candidates.map(value => normalizeAsin(value, fail))));
|
|
17
|
+
};
|
|
18
|
+
export const requireSingleAsin = (commandArgs, values, fail) => {
|
|
19
|
+
const asins = requireAsins(commandArgs, values, fail);
|
|
20
|
+
if (asins.length !== 1) {
|
|
21
|
+
fail('INVALID_INPUT', 'products history requires exactly one asin');
|
|
22
|
+
}
|
|
23
|
+
return asins[0];
|
|
24
|
+
};
|
|
25
|
+
export const resolveHistoryMetrics = (values, fail) => {
|
|
26
|
+
const requested = (values.metrics ?? process.env.RR_HISTORY_METRICS ?? 'bsr,price')
|
|
27
|
+
.split(',')
|
|
28
|
+
.map(value => value.trim().toLowerCase())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
if (requested.length === 0) {
|
|
31
|
+
fail('INVALID_INPUT', 'metrics cannot be empty');
|
|
32
|
+
}
|
|
33
|
+
const invalid = requested.filter(value => !HISTORY_METRIC_ALIASES.includes(value));
|
|
34
|
+
if (invalid.length > 0) {
|
|
35
|
+
fail('INVALID_INPUT', `unsupported history metric: ${invalid[0]}`, {
|
|
36
|
+
supportedMetrics: HISTORY_METRIC_ALIASES,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return Array.from(new Set(requested));
|
|
40
|
+
};
|
|
41
|
+
export const resolveHistoryWindow = (values, fail) => {
|
|
42
|
+
const startAt = values.startAt ? normalizeDateOption('startAt', values.startAt, fail) : undefined;
|
|
43
|
+
const endAt = values.endAt ? normalizeDateOption('endAt', values.endAt, fail) : undefined;
|
|
44
|
+
if (values.days && (startAt || endAt)) {
|
|
45
|
+
fail('INVALID_INPUT', 'use --days or --startAt/--endAt, not both');
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...(startAt ? { startAt } : {}),
|
|
49
|
+
...(endAt ? { endAt } : {}),
|
|
50
|
+
days: parseIntegerOption({
|
|
51
|
+
value: values.days,
|
|
52
|
+
optionName: 'days',
|
|
53
|
+
min: 30,
|
|
54
|
+
max: 3650,
|
|
55
|
+
defaultValue: 365,
|
|
56
|
+
}, fail),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
export const resolveApiKey = () => {
|
|
60
|
+
return process.env.RR_LICENSE_KEY;
|
|
61
|
+
};
|
|
62
|
+
export const resolveBaseUrl = (values, config, defaultBaseUrl, fail) => {
|
|
63
|
+
return normalizeBaseUrl(values.baseUrl ?? config.baseUrl ?? process.env.RR_API_URL ?? defaultBaseUrl, fail);
|
|
64
|
+
};
|
|
65
|
+
export const normalizeBaseUrl = (value, fail) => {
|
|
66
|
+
const normalized = value.trim().replace(TRAILING_SLASHES_REGEX, '').replace(API_SUFFIX_REGEX, '');
|
|
67
|
+
if (!normalized) {
|
|
68
|
+
fail('INVALID_INPUT', 'base url cannot be empty');
|
|
69
|
+
}
|
|
70
|
+
return normalized;
|
|
71
|
+
};
|
|
72
|
+
export const parseIntegerOption = ({ value, optionName, min, max, defaultValue, }, fail) => {
|
|
73
|
+
if (!value) {
|
|
74
|
+
return defaultValue;
|
|
75
|
+
}
|
|
76
|
+
const parsed = Number.parseInt(value, 10);
|
|
77
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
78
|
+
fail('INVALID_INPUT', `${optionName} must be an integer between ${min} and ${max}`);
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
};
|
|
82
|
+
const normalizeDateOption = (optionName, value, fail) => {
|
|
83
|
+
const parsed = new Date(value);
|
|
84
|
+
if (!Number.isFinite(parsed.getTime())) {
|
|
85
|
+
fail('INVALID_INPUT', `${optionName} must be a valid date`);
|
|
86
|
+
}
|
|
87
|
+
return parsed.toISOString();
|
|
88
|
+
};
|
|
89
|
+
const normalizeAsin = (value, fail) => {
|
|
90
|
+
const normalized = value.trim().toUpperCase();
|
|
91
|
+
if (!ASIN_REGEX.test(normalized)) {
|
|
92
|
+
fail('INVALID_INPUT', `invalid ASIN: ${value}`);
|
|
93
|
+
}
|
|
94
|
+
return normalized;
|
|
95
|
+
};
|
|
96
|
+
const resolveEnvAsins = () => {
|
|
97
|
+
return [
|
|
98
|
+
...(process.env.RR_ASINS ? process.env.RR_ASINS.split(',') : []),
|
|
99
|
+
...(process.env.RR_ASIN ? [process.env.RR_ASIN] : []),
|
|
100
|
+
];
|
|
101
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const printSuccess: <T>(data: T) => void;
|
|
2
|
+
export declare const fail: (code: string, message: string, details?: unknown) => never;
|
|
3
|
+
export declare const resolveError: (error: unknown) => {
|
|
4
|
+
code: string;
|
|
5
|
+
message: string;
|
|
6
|
+
details: unknown;
|
|
7
|
+
} | {
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
details?: undefined;
|
|
11
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { CliStorageError } from './cli-config.js';
|
|
2
|
+
const OUTPUT_PRETTY = true;
|
|
3
|
+
export const printSuccess = (data) => {
|
|
4
|
+
const envelope = {
|
|
5
|
+
ok: true,
|
|
6
|
+
data,
|
|
7
|
+
};
|
|
8
|
+
console.log(JSON.stringify(envelope, null, OUTPUT_PRETTY ? 2 : undefined));
|
|
9
|
+
};
|
|
10
|
+
export const fail = (code, message, details) => {
|
|
11
|
+
const envelope = {
|
|
12
|
+
ok: false,
|
|
13
|
+
error: {
|
|
14
|
+
code,
|
|
15
|
+
message,
|
|
16
|
+
...(details !== undefined ? { details } : {}),
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
console.error(JSON.stringify(envelope, null, OUTPUT_PRETTY ? 2 : undefined));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
};
|
|
22
|
+
export const resolveError = (error) => {
|
|
23
|
+
if (error instanceof CliStorageError) {
|
|
24
|
+
return {
|
|
25
|
+
code: error.code,
|
|
26
|
+
message: error.message,
|
|
27
|
+
details: error.details,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (error && typeof error === 'object' && 'data' in error) {
|
|
31
|
+
const data = error.data;
|
|
32
|
+
if (data?.code) {
|
|
33
|
+
return {
|
|
34
|
+
code: data.code,
|
|
35
|
+
message: error instanceof Error ? error.message : 'Request failed',
|
|
36
|
+
details: data.httpStatus ? { httpStatus: data.httpStatus } : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
41
|
+
const message = error.message;
|
|
42
|
+
if (typeof message === 'string') {
|
|
43
|
+
return {
|
|
44
|
+
code: 'REQUEST_FAILED',
|
|
45
|
+
message,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
code: 'UNKNOWN_ERROR',
|
|
51
|
+
message: 'Unknown error',
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const HISTORY_METRIC_ALIASES: readonly ["bsr", "price"];
|
|
2
|
+
export type HistoryMetricAlias = (typeof HISTORY_METRIC_ALIASES)[number];
|
|
3
|
+
type HistoryPointTuple = [string, number | null] | [string, null, 1];
|
|
4
|
+
export type AgentHistorySeries = {
|
|
5
|
+
bsr?: {
|
|
6
|
+
unit: 'rank';
|
|
7
|
+
category: {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string | null;
|
|
10
|
+
} | null;
|
|
11
|
+
points: HistoryPointTuple[];
|
|
12
|
+
};
|
|
13
|
+
price?: {
|
|
14
|
+
unit: 'minorCurrency';
|
|
15
|
+
currencyCode: 'USD';
|
|
16
|
+
valueScale: 100;
|
|
17
|
+
points: HistoryPointTuple[];
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export type AgentHistoryResponse = {
|
|
21
|
+
schemaVersion?: number;
|
|
22
|
+
status?: string;
|
|
23
|
+
syncTriggered: boolean;
|
|
24
|
+
latestImportAt: string | null;
|
|
25
|
+
series?: AgentHistorySeries;
|
|
26
|
+
};
|
|
27
|
+
export declare const buildCliHistoryResponse: ({ asin, marketplaceId, metrics, response, }: {
|
|
28
|
+
asin: string;
|
|
29
|
+
marketplaceId: string;
|
|
30
|
+
metrics: HistoryMetricAlias[];
|
|
31
|
+
response: AgentHistoryResponse;
|
|
32
|
+
}) => {
|
|
33
|
+
schemaVersion: number;
|
|
34
|
+
asin: string;
|
|
35
|
+
marketplaceId: string;
|
|
36
|
+
status: string;
|
|
37
|
+
latestImportAt: string | null;
|
|
38
|
+
syncTriggered: boolean;
|
|
39
|
+
series: AgentHistorySeries;
|
|
40
|
+
};
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const HISTORY_METRIC_ALIASES = ['bsr', 'price'];
|
|
2
|
+
export const buildCliHistoryResponse = ({ asin, marketplaceId, metrics, response, }) => {
|
|
3
|
+
const series = {
|
|
4
|
+
...(metrics.includes('bsr') && response.series?.bsr ? { bsr: response.series.bsr } : {}),
|
|
5
|
+
...(metrics.includes('price') && response.series?.price
|
|
6
|
+
? { price: response.series.price }
|
|
7
|
+
: {}),
|
|
8
|
+
};
|
|
9
|
+
const hasAnyPoints = Object.values(series).some(metricSeries => metricSeries.points.length > 0);
|
|
10
|
+
const status = normalizeHistoryStatus(response.status);
|
|
11
|
+
return {
|
|
12
|
+
schemaVersion: response.schemaVersion ?? 1,
|
|
13
|
+
asin,
|
|
14
|
+
marketplaceId,
|
|
15
|
+
status: status ?? (response.syncTriggered ? 'collecting' : hasAnyPoints ? 'ready' : 'empty'),
|
|
16
|
+
latestImportAt: response.latestImportAt,
|
|
17
|
+
syncTriggered: response.syncTriggered,
|
|
18
|
+
series,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const normalizeHistoryStatus = (status) => {
|
|
22
|
+
if (status === 'ready' || status === 'collecting' || status === 'empty') {
|
|
23
|
+
return status;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
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
2
|
import { parseArgs } from 'node:util';
|
|
6
3
|
import { createRankWranglerClient, DEFAULT_API_BASE_URL } from '@rankwrangler/http-client';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
14
|
-
const DEFAULT_OUTPUT_PRETTY = true;
|
|
4
|
+
import { printBundledChangelog, printCliVersion } from './cli-metadata.js';
|
|
5
|
+
import { clearConfig, loadCliContext, loadCliPathsOrDefault, saveConfig, switchStorageDir, } from './cli-config.js';
|
|
6
|
+
import { normalizeBaseUrl, parseIntegerOption, requireAsins, requireMarketplaceId, requireSingleAsin, resolveApiKey, resolveBaseUrl, resolveHistoryMetrics, resolveHistoryWindow, } from './cli-options.js';
|
|
7
|
+
import { fail, printSuccess, resolveError } from './cli-output.js';
|
|
8
|
+
import { buildCliHistoryResponse } from './history-response.js';
|
|
9
|
+
import { printUsage } from './usage.js';
|
|
15
10
|
const SUPPORTED_COMMANDS = new Set([
|
|
16
11
|
'products:get',
|
|
17
12
|
'products:history',
|
|
@@ -25,7 +20,7 @@ const { positionals, values } = parseArgs({
|
|
|
25
20
|
args: process.argv.slice(2),
|
|
26
21
|
options: {
|
|
27
22
|
help: { type: 'boolean', short: 'h' },
|
|
28
|
-
|
|
23
|
+
version: { type: 'boolean' },
|
|
29
24
|
baseUrl: { type: 'string' },
|
|
30
25
|
marketplace: { type: 'string', short: 'm' },
|
|
31
26
|
asin: { type: 'string', multiple: true },
|
|
@@ -37,44 +32,49 @@ const { positionals, values } = parseArgs({
|
|
|
37
32
|
},
|
|
38
33
|
allowPositionals: true,
|
|
39
34
|
});
|
|
40
|
-
const outputPretty = DEFAULT_OUTPUT_PRETTY;
|
|
41
35
|
const main = async () => {
|
|
36
|
+
const optionValues = values;
|
|
37
|
+
if (optionValues.version) {
|
|
38
|
+
printCliVersion();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
42
41
|
if (values.help || positionals.length === 0) {
|
|
43
|
-
printUsage();
|
|
42
|
+
printUsage(await loadCliPathsOrDefault());
|
|
44
43
|
process.exit(values.help ? 0 : 1);
|
|
45
44
|
}
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
const metaCommand = resolveMetaCommand(positionals);
|
|
46
|
+
if (metaCommand) {
|
|
47
|
+
if (metaCommand.name === 'changelog') {
|
|
48
|
+
printBundledChangelog();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
50
51
|
}
|
|
52
|
+
const command = resolveCommandOrFail(positionals);
|
|
51
53
|
if (!isSupportedCommand(command)) {
|
|
52
|
-
fail('UNKNOWN_COMMAND', 'Unknown command', {
|
|
53
|
-
|
|
54
|
+
fail('UNKNOWN_COMMAND', 'Unknown command', {
|
|
55
|
+
command: `${command.resource} ${command.verb}`,
|
|
56
|
+
});
|
|
54
57
|
}
|
|
55
|
-
const config = await
|
|
58
|
+
const { config, paths } = await loadCliContext();
|
|
56
59
|
if (command.resource === 'config') {
|
|
57
|
-
|
|
58
|
-
printSuccess(response);
|
|
60
|
+
printSuccess(await runConfigCommand(command, config, paths));
|
|
59
61
|
return;
|
|
60
62
|
}
|
|
61
|
-
const apiKey = resolveApiKey(
|
|
63
|
+
const apiKey = resolveApiKey();
|
|
62
64
|
if (!apiKey) {
|
|
63
|
-
fail('MISSING_CONFIG', 'api key is required. set
|
|
64
|
-
return;
|
|
65
|
+
fail('MISSING_CONFIG', 'api key is required. set RR_LICENSE_KEY');
|
|
65
66
|
}
|
|
66
|
-
const baseUrl = resolveBaseUrl(config);
|
|
67
67
|
const client = createRankWranglerClient({
|
|
68
|
-
baseUrl,
|
|
68
|
+
baseUrl: resolveBaseUrl(optionValues, config, DEFAULT_API_BASE_URL, fail),
|
|
69
69
|
apiKey,
|
|
70
70
|
});
|
|
71
|
-
|
|
72
|
-
printSuccess(response);
|
|
71
|
+
printSuccess(await runApiCommand(command, client, config));
|
|
73
72
|
};
|
|
74
73
|
const runApiCommand = async (command, client, config) => {
|
|
75
74
|
if (command.resource === 'products' && command.verb === 'get') {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
75
|
+
const optionValues = values;
|
|
76
|
+
const marketplaceId = requireMarketplaceId(optionValues, config);
|
|
77
|
+
const asins = requireAsins(command.args, optionValues, fail);
|
|
78
78
|
if (asins.length === 1) {
|
|
79
79
|
return client.getProductInfo.mutate({ marketplaceId, asin: asins[0] });
|
|
80
80
|
}
|
|
@@ -92,20 +92,20 @@ const runApiCommand = async (command, client, config) => {
|
|
|
92
92
|
fail('UNKNOWN_COMMAND', 'Unknown command', {
|
|
93
93
|
command: `${command.resource} ${command.verb}`,
|
|
94
94
|
});
|
|
95
|
-
return null;
|
|
96
95
|
};
|
|
97
96
|
const runProductHistoryCommand = async (command, client, config) => {
|
|
98
|
-
const
|
|
99
|
-
const asin = requireSingleAsin(command.args);
|
|
100
|
-
const
|
|
101
|
-
const
|
|
97
|
+
const optionValues = values;
|
|
98
|
+
const asin = requireSingleAsin(command.args, optionValues, fail);
|
|
99
|
+
const marketplaceId = requireMarketplaceId(optionValues, config);
|
|
100
|
+
const metrics = resolveHistoryMetrics(optionValues, fail);
|
|
101
|
+
const historyWindow = resolveHistoryWindow(optionValues, fail);
|
|
102
102
|
const limit = parseIntegerOption({
|
|
103
|
-
value:
|
|
103
|
+
value: optionValues.limit,
|
|
104
104
|
optionName: 'limit',
|
|
105
105
|
min: 1,
|
|
106
106
|
max: 10000,
|
|
107
107
|
defaultValue: 5000,
|
|
108
|
-
});
|
|
108
|
+
}, fail);
|
|
109
109
|
const response = await client.getProductHistory.mutate({
|
|
110
110
|
marketplaceId,
|
|
111
111
|
asin,
|
|
@@ -118,187 +118,69 @@ const runProductHistoryCommand = async (command, client, config) => {
|
|
|
118
118
|
asin,
|
|
119
119
|
marketplaceId,
|
|
120
120
|
metrics,
|
|
121
|
-
response,
|
|
121
|
+
response: response,
|
|
122
122
|
});
|
|
123
123
|
};
|
|
124
|
-
const runConfigCommand = async (command, config) => {
|
|
124
|
+
const runConfigCommand = async (command, config, paths) => {
|
|
125
125
|
if (command.verb === 'show') {
|
|
126
|
-
return
|
|
127
|
-
path: CONFIG_PATH,
|
|
128
|
-
config,
|
|
129
|
-
};
|
|
126
|
+
return buildConfigResponse(paths, config);
|
|
130
127
|
}
|
|
131
128
|
if (command.verb === 'clear') {
|
|
132
|
-
await
|
|
129
|
+
await clearConfig(paths);
|
|
133
130
|
return {
|
|
134
|
-
|
|
131
|
+
...buildConfigResponse(paths, {}),
|
|
135
132
|
cleared: true,
|
|
136
133
|
};
|
|
137
134
|
}
|
|
138
|
-
if (command.verb
|
|
139
|
-
|
|
140
|
-
if (!key || valueParts.length === 0) {
|
|
141
|
-
fail('INVALID_INPUT', 'config set requires <key> <value>');
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
const value = valueParts.join(' ').trim();
|
|
145
|
-
if (!value) {
|
|
146
|
-
fail('INVALID_INPUT', 'config set value cannot be empty');
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
const nextConfig = { ...config };
|
|
150
|
-
if (key === 'api-key') {
|
|
151
|
-
nextConfig.apiKey = value;
|
|
152
|
-
}
|
|
153
|
-
else if (key === 'base-url') {
|
|
154
|
-
nextConfig.baseUrl = normalizeBaseUrl(value);
|
|
155
|
-
}
|
|
156
|
-
else if (key === 'marketplace') {
|
|
157
|
-
nextConfig.marketplaceId = value;
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
fail('INVALID_INPUT', 'unsupported config key', {
|
|
161
|
-
key,
|
|
162
|
-
supportedKeys: ['api-key', 'base-url', 'marketplace'],
|
|
163
|
-
});
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
await saveConfig(nextConfig);
|
|
167
|
-
return {
|
|
168
|
-
path: CONFIG_PATH,
|
|
169
|
-
config: nextConfig,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
fail('UNKNOWN_COMMAND', 'Unknown config command', { verb: command.verb });
|
|
173
|
-
return null;
|
|
174
|
-
};
|
|
175
|
-
const requireMarketplaceId = (config) => {
|
|
176
|
-
const marketplaceId = values.marketplace ?? config.marketplaceId ?? process.env.RR_MARKETPLACE_ID ?? DEFAULT_MARKETPLACE_ID;
|
|
177
|
-
return marketplaceId;
|
|
178
|
-
};
|
|
179
|
-
const requireAsins = (commandArgs) => {
|
|
180
|
-
const candidates = collectAsinCandidates(commandArgs);
|
|
181
|
-
if (candidates.length === 0) {
|
|
182
|
-
fail('INVALID_INPUT', 'at least one asin is required');
|
|
183
|
-
}
|
|
184
|
-
return Array.from(new Set(candidates.map(normalizeAsin)));
|
|
185
|
-
};
|
|
186
|
-
const requireSingleAsin = (commandArgs) => {
|
|
187
|
-
const asins = requireAsins(commandArgs);
|
|
188
|
-
if (asins.length !== 1) {
|
|
189
|
-
fail('INVALID_INPUT', 'products history requires exactly one asin');
|
|
135
|
+
if (command.verb !== 'set') {
|
|
136
|
+
fail('UNKNOWN_COMMAND', 'Unknown config command', { verb: command.verb });
|
|
190
137
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const optionAsins = [...(values.asin ?? [])];
|
|
195
|
-
const envAsins = process.env.RR_ASINS
|
|
196
|
-
? process.env.RR_ASINS.split(',').map(value => value.trim())
|
|
197
|
-
: [];
|
|
198
|
-
const envSingleAsin = process.env.RR_ASIN ? [process.env.RR_ASIN] : [];
|
|
199
|
-
return [...commandArgs, ...optionAsins, ...envAsins, ...envSingleAsin]
|
|
200
|
-
.map(value => value.trim())
|
|
201
|
-
.filter(Boolean);
|
|
202
|
-
};
|
|
203
|
-
const normalizeAsin = (value) => {
|
|
204
|
-
const normalized = value.trim().toUpperCase();
|
|
205
|
-
if (!ASIN_REGEX.test(normalized)) {
|
|
206
|
-
fail('INVALID_INPUT', `invalid ASIN: ${value}`);
|
|
138
|
+
const [key, ...valueParts] = command.args;
|
|
139
|
+
if (!key || valueParts.length === 0) {
|
|
140
|
+
fail('INVALID_INPUT', 'config set requires <key> <value>');
|
|
207
141
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const rawMetrics = values.metrics ?? process.env.RR_HISTORY_METRICS ?? 'bsr,price';
|
|
212
|
-
const requested = rawMetrics
|
|
213
|
-
.split(',')
|
|
214
|
-
.map(value => value.trim().toLowerCase())
|
|
215
|
-
.filter(Boolean);
|
|
216
|
-
if (requested.length === 0) {
|
|
217
|
-
fail('INVALID_INPUT', 'metrics cannot be empty');
|
|
142
|
+
const value = valueParts.join(' ').trim();
|
|
143
|
+
if (!value) {
|
|
144
|
+
fail('INVALID_INPUT', 'config set value cannot be empty');
|
|
218
145
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
146
|
+
if (key === 'storage-dir') {
|
|
147
|
+
const nextState = await switchStorageDir({
|
|
148
|
+
currentConfig: config,
|
|
149
|
+
currentPaths: paths,
|
|
150
|
+
requestedStorageDir: value,
|
|
223
151
|
});
|
|
152
|
+
return buildConfigResponse(nextState.paths, nextState.config);
|
|
224
153
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const startAt = values.startAt ? normalizeDateOption('startAt', values.startAt) : undefined;
|
|
229
|
-
const endAt = values.endAt ? normalizeDateOption('endAt', values.endAt) : undefined;
|
|
230
|
-
const days = parseIntegerOption({
|
|
231
|
-
value: values.days,
|
|
232
|
-
optionName: 'days',
|
|
233
|
-
min: 30,
|
|
234
|
-
max: 3650,
|
|
235
|
-
defaultValue: 365,
|
|
236
|
-
});
|
|
237
|
-
if (values.days && (startAt || endAt)) {
|
|
238
|
-
fail('INVALID_INPUT', 'use --days or --startAt/--endAt, not both');
|
|
154
|
+
const nextConfig = { ...config };
|
|
155
|
+
if (key === 'base-url') {
|
|
156
|
+
nextConfig.baseUrl = normalizeBaseUrl(value, fail);
|
|
239
157
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
...(endAt ? { endAt } : {}),
|
|
243
|
-
days,
|
|
244
|
-
};
|
|
245
|
-
};
|
|
246
|
-
const parseIntegerOption = ({ value, optionName, min, max, defaultValue, }) => {
|
|
247
|
-
if (!value) {
|
|
248
|
-
return defaultValue;
|
|
158
|
+
else if (key === 'marketplace') {
|
|
159
|
+
nextConfig.marketplaceId = value;
|
|
249
160
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
};
|
|
256
|
-
const normalizeDateOption = (optionName, value) => {
|
|
257
|
-
const parsed = new Date(value);
|
|
258
|
-
if (!Number.isFinite(parsed.getTime())) {
|
|
259
|
-
fail('INVALID_INPUT', `${optionName} must be a valid date`);
|
|
161
|
+
else {
|
|
162
|
+
fail('INVALID_INPUT', 'unsupported config key', {
|
|
163
|
+
key,
|
|
164
|
+
supportedKeys: ['base-url', 'marketplace', 'storage-dir'],
|
|
165
|
+
});
|
|
260
166
|
}
|
|
261
|
-
|
|
167
|
+
await saveConfig(paths, nextConfig);
|
|
168
|
+
return buildConfigResponse(paths, nextConfig);
|
|
262
169
|
};
|
|
263
|
-
const
|
|
264
|
-
const series = {
|
|
265
|
-
...(metrics.includes('bsr') && response.series?.bsr ? { bsr: response.series.bsr } : {}),
|
|
266
|
-
...(metrics.includes('price') && response.series?.price ? { price: response.series.price } : {}),
|
|
267
|
-
};
|
|
268
|
-
const hasAnyPoints = Object.values(series).some(metricSeries => metricSeries.points.length > 0);
|
|
269
|
-
const status = normalizeHistoryStatus(response.status);
|
|
170
|
+
const buildConfigResponse = (paths, config) => {
|
|
270
171
|
return {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
latestImportAt: response.latestImportAt,
|
|
276
|
-
syncTriggered: response.syncTriggered,
|
|
277
|
-
series,
|
|
172
|
+
storageDir: paths.storageDir,
|
|
173
|
+
path: paths.configPath,
|
|
174
|
+
globalPath: paths.globalConfigPath,
|
|
175
|
+
config,
|
|
278
176
|
};
|
|
279
177
|
};
|
|
280
|
-
const
|
|
281
|
-
if (
|
|
282
|
-
return
|
|
178
|
+
const resolveMetaCommand = (inputPositionals) => {
|
|
179
|
+
if (inputPositionals.length === 1 && inputPositionals[0] === 'changelog') {
|
|
180
|
+
return { name: 'changelog' };
|
|
283
181
|
}
|
|
284
182
|
return null;
|
|
285
183
|
};
|
|
286
|
-
const resolveApiKey = (config) => {
|
|
287
|
-
return values.apiKey ?? config.apiKey ?? process.env.RR_LICENSE_KEY;
|
|
288
|
-
};
|
|
289
|
-
const resolveBaseUrl = (config) => {
|
|
290
|
-
const configured = values.baseUrl ?? config.baseUrl ?? process.env.RR_API_URL;
|
|
291
|
-
return normalizeBaseUrl(configured ?? DEFAULT_API_BASE_URL);
|
|
292
|
-
};
|
|
293
|
-
const normalizeBaseUrl = (value) => {
|
|
294
|
-
const trimmed = value.trim();
|
|
295
|
-
const withoutTrailingSlashes = trimmed.replace(TRAILING_SLASHES_REGEX, '');
|
|
296
|
-
const withoutApiSuffix = withoutTrailingSlashes.replace(API_SUFFIX_REGEX, '');
|
|
297
|
-
if (!withoutApiSuffix) {
|
|
298
|
-
fail('INVALID_INPUT', 'base url cannot be empty');
|
|
299
|
-
}
|
|
300
|
-
return withoutApiSuffix;
|
|
301
|
-
};
|
|
302
184
|
const resolveCommand = (inputPositionals) => {
|
|
303
185
|
const [first, second, ...rest] = inputPositionals;
|
|
304
186
|
if (!first || !second) {
|
|
@@ -310,130 +192,16 @@ const resolveCommand = (inputPositionals) => {
|
|
|
310
192
|
args: rest,
|
|
311
193
|
};
|
|
312
194
|
};
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
try {
|
|
318
|
-
const raw = await readFile(CONFIG_PATH, 'utf8');
|
|
319
|
-
const parsed = JSON.parse(raw);
|
|
320
|
-
return {
|
|
321
|
-
apiKey: typeof parsed.apiKey === 'string' ? parsed.apiKey : undefined,
|
|
322
|
-
baseUrl: typeof parsed.baseUrl === 'string' ? normalizeBaseUrl(parsed.baseUrl) : undefined,
|
|
323
|
-
marketplaceId: typeof parsed.marketplaceId === 'string' ? parsed.marketplaceId : undefined,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
catch (error) {
|
|
327
|
-
if (error &&
|
|
328
|
-
typeof error === 'object' &&
|
|
329
|
-
'code' in error &&
|
|
330
|
-
error.code === 'ENOENT') {
|
|
331
|
-
return {};
|
|
332
|
-
}
|
|
333
|
-
fail('INVALID_CONFIG', 'failed to read config', { path: CONFIG_PATH });
|
|
334
|
-
return {};
|
|
335
|
-
}
|
|
336
|
-
};
|
|
337
|
-
const saveConfig = async (config) => {
|
|
338
|
-
await mkdir(CONFIG_DIR, { recursive: true });
|
|
339
|
-
await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
340
|
-
};
|
|
341
|
-
const resolveError = (error) => {
|
|
342
|
-
if (error && typeof error === 'object') {
|
|
343
|
-
if ('data' in error) {
|
|
344
|
-
const data = error.data;
|
|
345
|
-
if (data?.code) {
|
|
346
|
-
return {
|
|
347
|
-
code: data.code,
|
|
348
|
-
message: error instanceof Error
|
|
349
|
-
? error.message
|
|
350
|
-
: 'Request failed',
|
|
351
|
-
details: data.httpStatus ? { httpStatus: data.httpStatus } : undefined,
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
if ('message' in error && typeof error.message === 'string') {
|
|
356
|
-
return {
|
|
357
|
-
code: 'REQUEST_FAILED',
|
|
358
|
-
message: error.message,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
195
|
+
const resolveCommandOrFail = (inputPositionals) => {
|
|
196
|
+
const command = resolveCommand(inputPositionals);
|
|
197
|
+
if (command) {
|
|
198
|
+
return command;
|
|
361
199
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
message: 'Unknown error',
|
|
365
|
-
};
|
|
366
|
-
};
|
|
367
|
-
const printSuccess = (data) => {
|
|
368
|
-
const envelope = {
|
|
369
|
-
ok: true,
|
|
370
|
-
data,
|
|
371
|
-
};
|
|
372
|
-
console.log(JSON.stringify(envelope, null, outputPretty ? 2 : undefined));
|
|
200
|
+
fail('UNKNOWN_COMMAND', 'Unknown command', { command: inputPositionals.join(' ') });
|
|
201
|
+
throw new Error('unreachable');
|
|
373
202
|
};
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
ok: false,
|
|
377
|
-
error: {
|
|
378
|
-
code,
|
|
379
|
-
message,
|
|
380
|
-
...(details !== undefined ? { details } : {}),
|
|
381
|
-
},
|
|
382
|
-
};
|
|
383
|
-
console.error(JSON.stringify(envelope, null, outputPretty ? 2 : undefined));
|
|
384
|
-
process.exit(1);
|
|
385
|
-
};
|
|
386
|
-
const printUsage = () => {
|
|
387
|
-
const usage = [
|
|
388
|
-
'NAME',
|
|
389
|
-
' rw, rankwrangler - RankWrangler command line interface',
|
|
390
|
-
'',
|
|
391
|
-
'SYNOPSIS',
|
|
392
|
-
' rw <resource> <verb> [args...] [options]',
|
|
393
|
-
' rankwrangler <resource> <verb> [args...] [options]',
|
|
394
|
-
'',
|
|
395
|
-
'COMMANDS',
|
|
396
|
-
' products get <ASIN...>',
|
|
397
|
-
' products history <ASIN>',
|
|
398
|
-
' license status',
|
|
399
|
-
' license validate',
|
|
400
|
-
' config show',
|
|
401
|
-
' config clear',
|
|
402
|
-
' config set api-key <value>',
|
|
403
|
-
' config set base-url <origin>',
|
|
404
|
-
' config set marketplace <marketplaceId>',
|
|
405
|
-
'',
|
|
406
|
-
'OPTIONS',
|
|
407
|
-
' -h, --help Show this help message',
|
|
408
|
-
' --apiKey <value> Override API key',
|
|
409
|
-
' --baseUrl <origin> Override API origin',
|
|
410
|
-
` -m, --marketplace <id> Override marketplace (default: ${DEFAULT_MARKETPLACE_ID})`,
|
|
411
|
-
' --asin <ASIN> Add ASIN (repeatable)',
|
|
412
|
-
' --metrics <list> History metrics: bsr,price (default: bsr,price)',
|
|
413
|
-
' --days <N> History lookback window (30-3650, default: 365)',
|
|
414
|
-
' --startAt <ISO> History range start (ISO date/time)',
|
|
415
|
-
' --endAt <ISO> History range end (ISO date/time)',
|
|
416
|
-
' --limit <N> Max points per metric (1-10000, default: 5000)',
|
|
417
|
-
'',
|
|
418
|
-
'FILES',
|
|
419
|
-
` ${CONFIG_PATH} Local CLI config`,
|
|
420
|
-
'',
|
|
421
|
-
'ENVIRONMENT',
|
|
422
|
-
' RR_LICENSE_KEY API key fallback',
|
|
423
|
-
' RR_API_URL API origin fallback',
|
|
424
|
-
' RR_MARKETPLACE_ID Marketplace fallback',
|
|
425
|
-
' RR_ASIN Single ASIN fallback',
|
|
426
|
-
' RR_ASINS Comma-separated ASIN fallback',
|
|
427
|
-
' RR_HISTORY_METRICS Comma-separated history metrics fallback',
|
|
428
|
-
'',
|
|
429
|
-
'EXAMPLES',
|
|
430
|
-
' rw config set api-key rrk_...',
|
|
431
|
-
' rw products get B0DV53VS61',
|
|
432
|
-
' rw products history B0DV53VS61 --metrics bsr,price',
|
|
433
|
-
' rw products history B0DV53VS61 --startAt 2025-01-01 --endAt 2025-12-31',
|
|
434
|
-
' rankwrangler license status',
|
|
435
|
-
];
|
|
436
|
-
console.log(usage.join('\n'));
|
|
203
|
+
const isSupportedCommand = (command) => {
|
|
204
|
+
return SUPPORTED_COMMANDS.has(`${command.resource}:${command.verb}`);
|
|
437
205
|
};
|
|
438
206
|
await main().catch(error => {
|
|
439
207
|
const resolved = resolveError(error);
|
package/dist/usage.d.ts
ADDED
package/dist/usage.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const DEFAULT_MARKETPLACE_ID = 'ATVPDKIKX0DER';
|
|
2
|
+
export const printUsage = (paths) => {
|
|
3
|
+
const usage = [
|
|
4
|
+
'NAME',
|
|
5
|
+
' rw, rankwrangler - RankWrangler command line interface',
|
|
6
|
+
'',
|
|
7
|
+
'SYNOPSIS',
|
|
8
|
+
' rw --version',
|
|
9
|
+
' rankwrangler --version',
|
|
10
|
+
' rw changelog',
|
|
11
|
+
' rankwrangler changelog',
|
|
12
|
+
' rw <resource> <verb> [args...] [options]',
|
|
13
|
+
' rankwrangler <resource> <verb> [args...] [options]',
|
|
14
|
+
'',
|
|
15
|
+
'COMMANDS',
|
|
16
|
+
' changelog',
|
|
17
|
+
' products get <ASIN...>',
|
|
18
|
+
' products history <ASIN>',
|
|
19
|
+
' license status',
|
|
20
|
+
' license validate',
|
|
21
|
+
' config show',
|
|
22
|
+
' config clear',
|
|
23
|
+
' config set base-url <origin>',
|
|
24
|
+
' config set marketplace <marketplaceId>',
|
|
25
|
+
' config set storage-dir <path>',
|
|
26
|
+
'',
|
|
27
|
+
'OPTIONS',
|
|
28
|
+
' -h, --help Show this help message',
|
|
29
|
+
' --version Print the CLI version',
|
|
30
|
+
' --baseUrl <origin> Override API origin',
|
|
31
|
+
` -m, --marketplace <id> Override marketplace (default: ${DEFAULT_MARKETPLACE_ID})`,
|
|
32
|
+
' --asin <ASIN> Add ASIN (repeatable)',
|
|
33
|
+
' --metrics <list> History metrics: bsr,price (default: bsr,price)',
|
|
34
|
+
' --days <N> History lookback window (30-3650, default: 365)',
|
|
35
|
+
' --startAt <ISO> History range start (ISO date/time)',
|
|
36
|
+
' --endAt <ISO> History range end (ISO date/time)',
|
|
37
|
+
' --limit <N> Max points per metric (1-10000, default: 5000)',
|
|
38
|
+
'',
|
|
39
|
+
'FILES',
|
|
40
|
+
` ${paths.configPath} Active CLI config`,
|
|
41
|
+
` ${paths.globalConfigPath} Global CLI settings`,
|
|
42
|
+
'',
|
|
43
|
+
'ENVIRONMENT',
|
|
44
|
+
' RR_LICENSE_KEY API key source',
|
|
45
|
+
' RR_API_URL API origin fallback',
|
|
46
|
+
' RR_MARKETPLACE_ID Marketplace fallback',
|
|
47
|
+
' RR_STORAGE_DIR Storage directory override',
|
|
48
|
+
' RR_ASIN Single ASIN fallback',
|
|
49
|
+
' RR_ASINS Comma-separated ASIN fallback',
|
|
50
|
+
' RR_HISTORY_METRICS Comma-separated history metrics fallback',
|
|
51
|
+
'',
|
|
52
|
+
'EXAMPLES',
|
|
53
|
+
' RR_LICENSE_KEY=rrk_... rw products get B0DV53VS61',
|
|
54
|
+
' RR_STORAGE_DIR=~/.config/rankwrangler rw config show',
|
|
55
|
+
' rw --version',
|
|
56
|
+
' rw changelog',
|
|
57
|
+
' rw config set storage-dir ~/.config/rankwrangler',
|
|
58
|
+
' rw products history B0DV53VS61 --metrics bsr,price',
|
|
59
|
+
' rw products history B0DV53VS61 --startAt 2025-01-01 --endAt 2025-12-31',
|
|
60
|
+
' rankwrangler license status',
|
|
61
|
+
];
|
|
62
|
+
console.log(usage.join('\n'));
|
|
63
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rankwrangler/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Official RankWrangler command line interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
"README.md"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
-
"build": "tsc -p tsconfig.json"
|
|
17
|
+
"build": "tsc -p tsconfig.json && node scripts/fix-dist-imports.mjs && node scripts/sync-changelog.mjs",
|
|
18
|
+
"prepack": "node scripts/sync-changelog.mjs",
|
|
19
|
+
"test:e2e": "bun test test/index.e2e.test.ts",
|
|
20
|
+
"prepublishOnly": "node ../../scripts/release/ensure-cli-http-client-published.mjs"
|
|
18
21
|
},
|
|
19
22
|
"publishConfig": {
|
|
20
23
|
"access": "public"
|
|
21
24
|
},
|
|
22
25
|
"dependencies": {
|
|
23
|
-
"@rankwrangler/http-client": "^0.
|
|
26
|
+
"@rankwrangler/http-client": "^0.4.1"
|
|
24
27
|
},
|
|
25
28
|
"devDependencies": {
|
|
26
29
|
"@types/node": "^24.3.0",
|