@rankwrangler/cli 0.4.1 → 0.6.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.
package/README.md CHANGED
@@ -11,21 +11,33 @@ npm install -g @rankwrangler/cli
11
11
  ## Usage
12
12
 
13
13
  ```bash
14
- export RR_LICENSE_KEY=rrk_...
15
14
  rw --version
16
15
  rw changelog
16
+ rw auth set rrk_...
17
+ printf %s "$RR_LICENSE_KEY" | rw auth set --stdin
17
18
  rw config set storage-dir ~/.config/rankwrangler
19
+ rw config get marketplace
20
+ rw config unset marketplace
18
21
  rw products get B0DV53VS61
19
- rw products history B0DV53VS61 --metrics bsr,price
22
+ rw products history B0DV53VS61 --metrics bsr,price --bucket week
23
+ RR_LICENSE_KEY=rrk_... rw license status
20
24
  # `rankwrangler` is also supported as an alias
21
25
  ```
22
26
 
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.
27
+ `rw auth set [licenseKey]` stores the license key in the platform secure store
28
+ (`macOS Keychain` on macOS). `rw auth status` reports whether the CLI will use an env override,
29
+ stored auth, or no auth. `rw auth set --stdin` reads the key from standard input for agents and
30
+ scripts. `rw auth clear` removes the stored key.
31
+
32
+ `rw config set storage-dir <path>` saves the active storage directory globally. After that, every
33
+ CLI command reads and writes its non-secret config/data from that directory, while preserving
34
+ existing config values when switching. `rw config get <key>`, `rw config unset <key>`, and
35
+ `rw config reset` inspect or remove non-secret config without touching stored auth.
36
+ `RR_LICENSE_KEY`, `RR_STORAGE_DIR`, `RR_API_URL`, and `RR_MARKETPLACE_ID` override saved CLI state
37
+ when set for CI, automation, or agent runtimes.
38
+ `rw products history` ensures product/history data before returning bucketed history (`auto`,
39
+ `day`, `week`, or `month`), not raw point series. `rw --version` prints the installed CLI version.
40
+ `rw changelog` prints the latest bundled release notes shipped with the package.
29
41
 
30
42
  ## Development
31
43
 
@@ -55,9 +67,9 @@ matching `@rankwrangler/http-client` version is already available on npm.
55
67
 
56
68
  ```bash
57
69
  cd packages/cli
58
- set -a
59
- source ../../.env
60
- set +a
61
- npm whoami --userconfig ../../.npmrc
62
- npm publish --access public --userconfig ../../.npmrc
70
+ node ../../scripts/release/with-npm-token.mjs npm whoami --userconfig ../../.npmrc
71
+ node ../../scripts/release/with-npm-token.mjs npm publish --access public --userconfig ../../.npmrc
63
72
  ```
73
+
74
+ On macOS, the helper reads `NPM_TOKEN` from the environment first and otherwise falls back to the
75
+ Keychain item `rankwrangler-npm-token` for account `$USER`.
package/dist/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v0.6.0 - 2026-06-15
6
+
7
+ ### Added
8
+
9
+ - Add `rw auth set --stdin` and hidden local auth entry so agents can pipe license keys and humans
10
+ can avoid shell history.
11
+ - Add `rw config get`, `rw config unset`, and `rw config reset` for explicit non-secret config
12
+ inspection and cleanup.
13
+
14
+ ### Changed
15
+
16
+ - Show auth source/status in `rw config show` without printing secrets.
17
+ - Update CLI help, README, CLI spec, and e2e coverage for the clearer auth/config surface.
18
+
19
+ ### Removed
20
+
21
+ - Remove `rw config clear` in favor of `rw config unset <key>` and `rw config reset`.
22
+
23
+ ## v0.5.0 - 2026-03-18
24
+
25
+ ### Added
26
+
27
+ - Add CLI secure-store auth with `rw auth set`, `rw auth status`, and `rw auth clear`, storing
28
+ license keys in the platform secure store while keeping `RR_LICENSE_KEY` as an override for
29
+ CI, automation, and agent runtimes.
30
+
31
+ ### Changed
32
+
33
+ - Change local development defaults so the app server no longer starts the job runner unless you
34
+ use the explicit jobs-enabled dev commands.
35
+
36
+ ### Fixed
37
+
38
+ - Fix top search terms scheduler timestamp handling so due datasets are selected and refreshed
39
+ reliably.
40
+
5
41
  ## v0.4.1 - 2026-03-09
6
42
 
7
43
  ### Added
@@ -0,0 +1,112 @@
1
+ export declare const LICENSE_KEY_ENV_VAR = "RR_LICENSE_KEY";
2
+ export declare const AUTH_SERVICE_NAME = "RankWrangler CLI";
3
+ export declare const AUTH_ACCOUNT_NAME = "license-key";
4
+ type CliFail = (code: string, message: string, details?: unknown) => never;
5
+ type AuthCommand = {
6
+ verb: string;
7
+ args: string[];
8
+ };
9
+ type AuthCommandOptions = {
10
+ stdin?: boolean;
11
+ };
12
+ export declare class CliAuthError extends Error {
13
+ readonly code: string;
14
+ readonly details?: unknown;
15
+ constructor(code: string, message: string, details?: unknown);
16
+ }
17
+ export declare const resolveApiKey: () => Promise<string | undefined>;
18
+ export declare const runAuthCommand: (command: AuthCommand, fail: CliFail, options?: AuthCommandOptions) => Promise<{
19
+ source: string;
20
+ envOverride: boolean;
21
+ secureStore: {
22
+ backend: string;
23
+ serviceName: string;
24
+ accountName: string;
25
+ available: boolean;
26
+ hasStoredLicenseKey: boolean;
27
+ error?: undefined;
28
+ };
29
+ } | {
30
+ source: string;
31
+ envOverride: boolean;
32
+ secureStore: {
33
+ backend: string;
34
+ serviceName: string;
35
+ accountName: string;
36
+ available: boolean;
37
+ hasStoredLicenseKey: null;
38
+ error: string;
39
+ };
40
+ } | {
41
+ saved: boolean;
42
+ source: string;
43
+ envOverride: boolean;
44
+ secureStore: {
45
+ backend: string;
46
+ serviceName: string;
47
+ accountName: string;
48
+ available: boolean;
49
+ hasStoredLicenseKey: boolean;
50
+ error?: undefined;
51
+ };
52
+ } | {
53
+ saved: boolean;
54
+ source: string;
55
+ envOverride: boolean;
56
+ secureStore: {
57
+ backend: string;
58
+ serviceName: string;
59
+ accountName: string;
60
+ available: boolean;
61
+ hasStoredLicenseKey: null;
62
+ error: string;
63
+ };
64
+ } | {
65
+ cleared: boolean;
66
+ source: string;
67
+ envOverride: boolean;
68
+ secureStore: {
69
+ backend: string;
70
+ serviceName: string;
71
+ accountName: string;
72
+ available: boolean;
73
+ hasStoredLicenseKey: boolean;
74
+ error?: undefined;
75
+ };
76
+ } | {
77
+ cleared: boolean;
78
+ source: string;
79
+ envOverride: boolean;
80
+ secureStore: {
81
+ backend: string;
82
+ serviceName: string;
83
+ accountName: string;
84
+ available: boolean;
85
+ hasStoredLicenseKey: null;
86
+ error: string;
87
+ };
88
+ }>;
89
+ export declare const buildAuthStatus: () => Promise<{
90
+ source: string;
91
+ envOverride: boolean;
92
+ secureStore: {
93
+ backend: string;
94
+ serviceName: string;
95
+ accountName: string;
96
+ available: boolean;
97
+ hasStoredLicenseKey: boolean;
98
+ error?: undefined;
99
+ };
100
+ } | {
101
+ source: string;
102
+ envOverride: boolean;
103
+ secureStore: {
104
+ backend: string;
105
+ serviceName: string;
106
+ accountName: string;
107
+ available: boolean;
108
+ hasStoredLicenseKey: null;
109
+ error: string;
110
+ };
111
+ }>;
112
+ export {};
@@ -0,0 +1,222 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+ export const LICENSE_KEY_ENV_VAR = 'RR_LICENSE_KEY';
6
+ export const AUTH_SERVICE_NAME = 'RankWrangler CLI';
7
+ export const AUTH_ACCOUNT_NAME = 'license-key';
8
+ const INTERNAL_SECRET_STORE_DIR_ENV_VAR = 'RW_INTERNAL_CLI_SECRET_STORE_DIR';
9
+ export class CliAuthError extends Error {
10
+ code;
11
+ details;
12
+ constructor(code, message, details) {
13
+ super(message);
14
+ this.name = 'CliAuthError';
15
+ this.code = code;
16
+ this.details = details;
17
+ }
18
+ }
19
+ export const resolveApiKey = async () => {
20
+ const envLicenseKey = resolveEnvLicenseKey();
21
+ if (envLicenseKey) {
22
+ return envLicenseKey;
23
+ }
24
+ const secureStore = await createSecureStore();
25
+ return (await secureStore.get()) ?? undefined;
26
+ };
27
+ export const runAuthCommand = async (command, fail, options = {}) => {
28
+ if (command.verb === 'status') {
29
+ return buildAuthStatus();
30
+ }
31
+ if (command.verb === 'set') {
32
+ const value = await resolveAuthSetValue(command.args, fail, options);
33
+ const secureStore = await createSecureStore();
34
+ await secureStore.set(value);
35
+ return {
36
+ ...(await buildAuthStatus()),
37
+ saved: true,
38
+ };
39
+ }
40
+ if (command.verb === 'clear') {
41
+ const secureStore = await createSecureStore();
42
+ const cleared = await secureStore.clear();
43
+ return {
44
+ ...(await buildAuthStatus()),
45
+ cleared,
46
+ };
47
+ }
48
+ fail('UNKNOWN_COMMAND', 'Unknown auth command', { verb: command.verb });
49
+ };
50
+ export const buildAuthStatus = async () => {
51
+ const envLicenseKey = resolveEnvLicenseKey();
52
+ try {
53
+ const secureStore = await createSecureStore();
54
+ const storedLicenseKey = await secureStore.get();
55
+ return {
56
+ source: envLicenseKey ? 'env' : storedLicenseKey ? 'secure-store' : 'none',
57
+ envOverride: Boolean(envLicenseKey),
58
+ secureStore: {
59
+ backend: secureStore.backend,
60
+ serviceName: secureStore.serviceName,
61
+ accountName: secureStore.accountName,
62
+ available: true,
63
+ hasStoredLicenseKey: Boolean(storedLicenseKey),
64
+ },
65
+ };
66
+ }
67
+ catch (error) {
68
+ return {
69
+ source: envLicenseKey ? 'env' : 'none',
70
+ envOverride: Boolean(envLicenseKey),
71
+ secureStore: {
72
+ backend: getSecureStoreBackendLabel(),
73
+ serviceName: AUTH_SERVICE_NAME,
74
+ accountName: AUTH_ACCOUNT_NAME,
75
+ available: false,
76
+ hasStoredLicenseKey: null,
77
+ error: error instanceof Error ? error.message : 'Unknown secure-store error',
78
+ },
79
+ };
80
+ }
81
+ };
82
+ const resolveAuthSetValue = async (args, fail, options) => {
83
+ const positionalValue = args.join(' ').trim();
84
+ if (positionalValue) {
85
+ return positionalValue;
86
+ }
87
+ if (options.stdin) {
88
+ const stdinValue = await readSecretFromStdin();
89
+ if (stdinValue) {
90
+ return stdinValue;
91
+ }
92
+ fail('INVALID_INPUT', 'auth set --stdin received an empty license key');
93
+ }
94
+ const envLicenseKey = resolveEnvLicenseKey();
95
+ if (envLicenseKey) {
96
+ return envLicenseKey;
97
+ }
98
+ if (process.stdin.isTTY && process.stderr.isTTY) {
99
+ const promptValue = await readSecretFromPrompt('RankWrangler license key: ');
100
+ if (promptValue) {
101
+ return promptValue;
102
+ }
103
+ fail('INVALID_INPUT', 'auth set received an empty license key');
104
+ }
105
+ fail('INVALID_INPUT', `auth set requires <licenseKey>, --stdin, or ${LICENSE_KEY_ENV_VAR}`);
106
+ };
107
+ const readSecretFromStdin = async () => {
108
+ const chunks = [];
109
+ for await (const chunk of process.stdin) {
110
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
111
+ }
112
+ return Buffer.concat(chunks).toString('utf8').trim();
113
+ };
114
+ const readSecretFromPrompt = async (prompt) => {
115
+ process.stderr.write(prompt);
116
+ setTerminalEcho(false);
117
+ const rl = readline.createInterface({
118
+ input: process.stdin,
119
+ output: process.stderr,
120
+ terminal: true,
121
+ });
122
+ try {
123
+ const value = await new Promise(resolve => {
124
+ rl.question('', resolve);
125
+ });
126
+ process.stderr.write('\n');
127
+ return value.trim();
128
+ }
129
+ finally {
130
+ rl.close();
131
+ setTerminalEcho(true);
132
+ }
133
+ };
134
+ const setTerminalEcho = (enabled) => {
135
+ if (process.platform === 'win32' || !process.stdin.isTTY) {
136
+ return;
137
+ }
138
+ spawnSync('stty', [enabled ? 'echo' : '-echo'], {
139
+ stdio: ['inherit', 'ignore', 'ignore'],
140
+ });
141
+ };
142
+ const resolveEnvLicenseKey = () => {
143
+ const value = process.env[LICENSE_KEY_ENV_VAR]?.trim();
144
+ return value ? value : undefined;
145
+ };
146
+ const createSecureStore = async () => {
147
+ const internalSecretStoreDir = process.env[INTERNAL_SECRET_STORE_DIR_ENV_VAR]?.trim();
148
+ if (internalSecretStoreDir) {
149
+ return createFileSecretStore(path.resolve(internalSecretStoreDir));
150
+ }
151
+ const keytar = await loadKeytar();
152
+ return {
153
+ backend: getSecureStoreBackendLabel(),
154
+ serviceName: AUTH_SERVICE_NAME,
155
+ accountName: AUTH_ACCOUNT_NAME,
156
+ get: () => keytar.getPassword(AUTH_SERVICE_NAME, AUTH_ACCOUNT_NAME),
157
+ set: value => keytar.setPassword(AUTH_SERVICE_NAME, AUTH_ACCOUNT_NAME, value),
158
+ clear: () => keytar.deletePassword(AUTH_SERVICE_NAME, AUTH_ACCOUNT_NAME),
159
+ };
160
+ };
161
+ const loadKeytar = async () => {
162
+ try {
163
+ const imported = (await import('keytar'));
164
+ return imported.default ?? imported;
165
+ }
166
+ catch (error) {
167
+ throw new CliAuthError('SECURE_STORE_UNAVAILABLE', 'platform secure store is unavailable', error instanceof Error ? { cause: error.message } : undefined);
168
+ }
169
+ };
170
+ const getSecureStoreBackendLabel = () => {
171
+ switch (process.platform) {
172
+ case 'darwin':
173
+ return 'macos-keychain';
174
+ case 'win32':
175
+ return 'windows-credential-manager';
176
+ default:
177
+ return 'platform-secure-store';
178
+ }
179
+ };
180
+ const createFileSecretStore = (secretStoreDir) => {
181
+ const secretPath = path.join(secretStoreDir, 'license-key.json');
182
+ return {
183
+ backend: 'test-file-store',
184
+ serviceName: AUTH_SERVICE_NAME,
185
+ accountName: AUTH_ACCOUNT_NAME,
186
+ get: async () => {
187
+ try {
188
+ const raw = await readFile(secretPath, 'utf8');
189
+ const parsed = JSON.parse(raw);
190
+ return typeof parsed.licenseKey === 'string' ? parsed.licenseKey : null;
191
+ }
192
+ catch (error) {
193
+ if (error &&
194
+ typeof error === 'object' &&
195
+ 'code' in error &&
196
+ error.code === 'ENOENT') {
197
+ return null;
198
+ }
199
+ throw new CliAuthError('INVALID_CONFIG', 'failed to read secure-store test data');
200
+ }
201
+ },
202
+ set: async (value) => {
203
+ await mkdir(secretStoreDir, { recursive: true });
204
+ await writeFile(secretPath, `${JSON.stringify({ licenseKey: value }, null, 2)}\n`, 'utf8');
205
+ },
206
+ clear: async () => {
207
+ try {
208
+ await rm(secretPath);
209
+ return true;
210
+ }
211
+ catch (error) {
212
+ if (error &&
213
+ typeof error === 'object' &&
214
+ 'code' in error &&
215
+ error.code === 'ENOENT') {
216
+ return false;
217
+ }
218
+ throw error;
219
+ }
220
+ },
221
+ };
222
+ };
@@ -0,0 +1,101 @@
1
+ import { type CliConfig, type CliPaths } from './cli-config';
2
+ type CliFail = (code: string, message: string, details?: unknown) => never;
3
+ type ConfigCommand = {
4
+ verb: string;
5
+ args: string[];
6
+ };
7
+ export declare const runConfigCommand: (command: ConfigCommand, config: CliConfig, paths: CliPaths, fail: CliFail) => Promise<{
8
+ storageDir: string;
9
+ path: string;
10
+ globalPath: string;
11
+ config: CliConfig;
12
+ auth: {
13
+ source: string;
14
+ envOverride: boolean;
15
+ secureStore: {
16
+ backend: string;
17
+ serviceName: string;
18
+ accountName: string;
19
+ available: boolean;
20
+ hasStoredLicenseKey: boolean;
21
+ error?: undefined;
22
+ };
23
+ } | {
24
+ source: string;
25
+ envOverride: boolean;
26
+ secureStore: {
27
+ backend: string;
28
+ serviceName: string;
29
+ accountName: string;
30
+ available: boolean;
31
+ hasStoredLicenseKey: null;
32
+ error: string;
33
+ };
34
+ };
35
+ } | {
36
+ key: "base-url" | "marketplace" | "storage-dir";
37
+ value: string | null;
38
+ } | {
39
+ unset: "base-url" | "marketplace" | "storage-dir";
40
+ storageDir: string;
41
+ path: string;
42
+ globalPath: string;
43
+ config: CliConfig;
44
+ auth: {
45
+ source: string;
46
+ envOverride: boolean;
47
+ secureStore: {
48
+ backend: string;
49
+ serviceName: string;
50
+ accountName: string;
51
+ available: boolean;
52
+ hasStoredLicenseKey: boolean;
53
+ error?: undefined;
54
+ };
55
+ } | {
56
+ source: string;
57
+ envOverride: boolean;
58
+ secureStore: {
59
+ backend: string;
60
+ serviceName: string;
61
+ accountName: string;
62
+ available: boolean;
63
+ hasStoredLicenseKey: null;
64
+ error: string;
65
+ };
66
+ };
67
+ key?: undefined;
68
+ value?: undefined;
69
+ } | {
70
+ reset: boolean;
71
+ storageDir: string;
72
+ path: string;
73
+ globalPath: string;
74
+ config: CliConfig;
75
+ auth: {
76
+ source: string;
77
+ envOverride: boolean;
78
+ secureStore: {
79
+ backend: string;
80
+ serviceName: string;
81
+ accountName: string;
82
+ available: boolean;
83
+ hasStoredLicenseKey: boolean;
84
+ error?: undefined;
85
+ };
86
+ } | {
87
+ source: string;
88
+ envOverride: boolean;
89
+ secureStore: {
90
+ backend: string;
91
+ serviceName: string;
92
+ accountName: string;
93
+ available: boolean;
94
+ hasStoredLicenseKey: null;
95
+ error: string;
96
+ };
97
+ };
98
+ key?: undefined;
99
+ value?: undefined;
100
+ }>;
101
+ export {};
@@ -0,0 +1,118 @@
1
+ import { buildAuthStatus } from './cli-auth.js';
2
+ import { resetConfig, saveConfig, switchStorageDir, unsetStorageDir, } from './cli-config.js';
3
+ import { normalizeBaseUrl } from './cli-options.js';
4
+ const CONFIG_KEYS = ['base-url', 'marketplace', 'storage-dir'];
5
+ export const runConfigCommand = async (command, config, paths, fail) => {
6
+ if (command.verb === 'show') {
7
+ return buildConfigResponse(paths, config);
8
+ }
9
+ if (command.verb === 'get') {
10
+ const key = requireConfigKey(command.args[0], 'config get', fail);
11
+ return {
12
+ key,
13
+ value: getConfigValue(key, paths, config),
14
+ };
15
+ }
16
+ if (command.verb === 'unset') {
17
+ const key = requireConfigKey(command.args[0], 'config unset', fail);
18
+ const nextState = await unsetConfigValue(key, paths, config);
19
+ return {
20
+ ...(await buildConfigResponse(nextState.paths, nextState.config)),
21
+ unset: key,
22
+ };
23
+ }
24
+ if (command.verb === 'reset') {
25
+ const nextState = await resetConfig(paths);
26
+ return {
27
+ ...(await buildConfigResponse(nextState.paths, nextState.config)),
28
+ reset: true,
29
+ };
30
+ }
31
+ if (command.verb !== 'set') {
32
+ fail('UNKNOWN_COMMAND', 'Unknown config command', { verb: command.verb });
33
+ }
34
+ const [key, ...valueParts] = command.args;
35
+ if (!key || valueParts.length === 0) {
36
+ fail('INVALID_INPUT', 'config set requires <key> <value>');
37
+ }
38
+ const value = valueParts.join(' ').trim();
39
+ if (!value) {
40
+ fail('INVALID_INPUT', 'config set value cannot be empty');
41
+ }
42
+ if (key === 'storage-dir') {
43
+ const nextState = await switchStorageDir({
44
+ currentConfig: config,
45
+ currentPaths: paths,
46
+ requestedStorageDir: value,
47
+ });
48
+ return buildConfigResponse(nextState.paths, nextState.config);
49
+ }
50
+ const nextConfig = { ...config };
51
+ if (key === 'base-url') {
52
+ nextConfig.baseUrl = normalizeBaseUrl(value, fail);
53
+ }
54
+ else if (key === 'marketplace') {
55
+ nextConfig.marketplaceId = value;
56
+ }
57
+ else {
58
+ failUnsupportedConfigKey(key, fail);
59
+ }
60
+ await saveConfig(paths, nextConfig);
61
+ return buildConfigResponse(paths, nextConfig);
62
+ };
63
+ const buildConfigResponse = async (paths, config) => {
64
+ return {
65
+ storageDir: paths.storageDir,
66
+ path: paths.configPath,
67
+ globalPath: paths.globalConfigPath,
68
+ config,
69
+ auth: await buildAuthStatus(),
70
+ };
71
+ };
72
+ const requireConfigKey = (key, commandName, fail) => {
73
+ if (!key) {
74
+ fail('INVALID_INPUT', `${commandName} requires <key>`, {
75
+ supportedKeys: CONFIG_KEYS,
76
+ });
77
+ }
78
+ if (isConfigKey(key)) {
79
+ return key;
80
+ }
81
+ return failUnsupportedConfigKey(key, fail);
82
+ };
83
+ const isConfigKey = (key) => {
84
+ return CONFIG_KEYS.includes(key);
85
+ };
86
+ const getConfigValue = (key, paths, config) => {
87
+ switch (key) {
88
+ case 'base-url':
89
+ return config.baseUrl ?? null;
90
+ case 'marketplace':
91
+ return config.marketplaceId ?? null;
92
+ case 'storage-dir':
93
+ return paths.storageDir;
94
+ }
95
+ };
96
+ const unsetConfigValue = async (key, paths, config) => {
97
+ if (key === 'storage-dir') {
98
+ return unsetStorageDir(paths);
99
+ }
100
+ const nextConfig = { ...config };
101
+ if (key === 'base-url') {
102
+ nextConfig.baseUrl = undefined;
103
+ }
104
+ else {
105
+ nextConfig.marketplaceId = undefined;
106
+ }
107
+ await saveConfig(paths, nextConfig);
108
+ return {
109
+ paths,
110
+ config: nextConfig,
111
+ };
112
+ };
113
+ const failUnsupportedConfigKey = (key, fail) => {
114
+ fail('INVALID_INPUT', 'unsupported config key', {
115
+ key,
116
+ supportedKeys: CONFIG_KEYS,
117
+ });
118
+ };
@@ -19,8 +19,15 @@ export declare const loadCliContext: () => Promise<{
19
19
  config: CliConfig;
20
20
  }>;
21
21
  export declare const loadCliPathsOrDefault: () => Promise<CliPaths>;
22
- export declare const clearConfig: (paths: CliPaths) => Promise<void>;
22
+ export declare const resetConfig: (paths: CliPaths) => Promise<{
23
+ paths: CliPaths;
24
+ config: {};
25
+ }>;
23
26
  export declare const saveConfig: (paths: CliPaths, config: CliConfig) => Promise<void>;
27
+ export declare const unsetStorageDir: (paths: CliPaths) => Promise<{
28
+ paths: CliPaths;
29
+ config: CliConfig;
30
+ }>;
24
31
  export declare const switchStorageDir: ({ currentConfig, currentPaths, requestedStorageDir, }: {
25
32
  currentConfig: CliConfig;
26
33
  currentPaths: CliPaths;
@@ -34,13 +34,31 @@ export const loadCliPathsOrDefault = async () => {
34
34
  return getDefaultCliPaths();
35
35
  }
36
36
  };
37
- export const clearConfig = async (paths) => {
38
- await rm(paths.configPath, { force: true });
37
+ export const resetConfig = async (paths) => {
38
+ await Promise.all([
39
+ rm(paths.configPath, { force: true }),
40
+ rm(paths.globalConfigPath, { force: true }),
41
+ paths.storageDir === paths.defaultStorageDir
42
+ ? Promise.resolve()
43
+ : rm(path.join(paths.defaultStorageDir, CONFIG_FILENAME), { force: true }),
44
+ ]);
45
+ return {
46
+ paths: getDefaultCliPaths(),
47
+ config: {},
48
+ };
39
49
  };
40
50
  export const saveConfig = async (paths, config) => {
41
51
  await mkdir(paths.storageDir, { recursive: true });
42
52
  await writeFile(paths.configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
43
53
  };
54
+ export const unsetStorageDir = async (paths) => {
55
+ await rm(paths.globalConfigPath, { force: true });
56
+ const nextPaths = getDefaultCliPaths();
57
+ return {
58
+ paths: nextPaths,
59
+ config: await loadConfig(nextPaths.configPath),
60
+ };
61
+ };
44
62
  export const switchStorageDir = async ({ currentConfig, currentPaths, requestedStorageDir, }) => {
45
63
  const storageDir = normalizeStorageDir(requestedStorageDir);
46
64
  const nextPaths = buildCliPaths(storageDir);
@@ -1,5 +1,5 @@
1
1
  import type { CliConfig } from './cli-config';
2
- import { type HistoryMetricAlias } from './history-response';
2
+ import { type HistoryBucket, type HistoryMetricAlias } from './history-response';
3
3
  type CliFail = (code: string, message: string, details?: unknown) => never;
4
4
  export type CliOptionValues = {
5
5
  baseUrl?: string;
@@ -10,6 +10,7 @@ export type CliOptionValues = {
10
10
  endAt?: string;
11
11
  days?: string;
12
12
  limit?: string;
13
+ bucket?: string;
13
14
  help?: boolean;
14
15
  version?: boolean;
15
16
  };
@@ -17,12 +18,12 @@ export declare const requireMarketplaceId: (values: CliOptionValues, config: Cli
17
18
  export declare const requireAsins: (commandArgs: string[], values: CliOptionValues, fail: CliFail) => string[];
18
19
  export declare const requireSingleAsin: (commandArgs: string[], values: CliOptionValues, fail: CliFail) => string;
19
20
  export declare const resolveHistoryMetrics: (values: CliOptionValues, fail: CliFail) => HistoryMetricAlias[];
21
+ export declare const resolveHistoryBucket: (values: CliOptionValues, fail: CliFail) => HistoryBucket;
20
22
  export declare const resolveHistoryWindow: (values: CliOptionValues, fail: CliFail) => {
21
23
  days: number;
22
24
  endAt?: string | undefined;
23
25
  startAt?: string | undefined;
24
26
  };
25
- export declare const resolveApiKey: () => string | undefined;
26
27
  export declare const resolveBaseUrl: (values: CliOptionValues, config: CliConfig, defaultBaseUrl: string, fail: CliFail) => string;
27
28
  export declare const normalizeBaseUrl: (value: string, fail: CliFail) => string;
28
29
  export declare const parseIntegerOption: ({ value, optionName, min, max, defaultValue, }: {
@@ -1,4 +1,4 @@
1
- import { HISTORY_METRIC_ALIASES } from './history-response.js';
1
+ import { HISTORY_BUCKETS, HISTORY_METRIC_ALIASES, } from './history-response.js';
2
2
  const ASIN_REGEX = /^[A-Z0-9]{10}$/i;
3
3
  const TRAILING_SLASHES_REGEX = /\/+$/;
4
4
  const API_SUFFIX_REGEX = /\/api$/i;
@@ -38,6 +38,15 @@ export const resolveHistoryMetrics = (values, fail) => {
38
38
  }
39
39
  return Array.from(new Set(requested));
40
40
  };
41
+ export const resolveHistoryBucket = (values, fail) => {
42
+ const requested = (values.bucket ?? 'auto').trim().toLowerCase();
43
+ if (!HISTORY_BUCKETS.includes(requested)) {
44
+ fail('INVALID_INPUT', `unsupported history bucket: ${requested}`, {
45
+ supportedBuckets: HISTORY_BUCKETS,
46
+ });
47
+ }
48
+ return requested;
49
+ };
41
50
  export const resolveHistoryWindow = (values, fail) => {
42
51
  const startAt = values.startAt ? normalizeDateOption('startAt', values.startAt, fail) : undefined;
43
52
  const endAt = values.endAt ? normalizeDateOption('endAt', values.endAt, fail) : undefined;
@@ -56,9 +65,6 @@ export const resolveHistoryWindow = (values, fail) => {
56
65
  }, fail),
57
66
  };
58
67
  };
59
- export const resolveApiKey = () => {
60
- return process.env.RR_LICENSE_KEY;
61
- };
62
68
  export const resolveBaseUrl = (values, config, defaultBaseUrl, fail) => {
63
69
  return normalizeBaseUrl(values.baseUrl ?? config.baseUrl ?? process.env.RR_API_URL ?? defaultBaseUrl, fail);
64
70
  };
@@ -1,3 +1,4 @@
1
+ import { CliAuthError } from './cli-auth.js';
1
2
  import { CliStorageError } from './cli-config.js';
2
3
  const OUTPUT_PRETTY = true;
3
4
  export const printSuccess = (data) => {
@@ -27,6 +28,13 @@ export const resolveError = (error) => {
27
28
  details: error.details,
28
29
  };
29
30
  }
31
+ if (error instanceof CliAuthError) {
32
+ return {
33
+ code: error.code,
34
+ message: error.message,
35
+ details: error.details,
36
+ };
37
+ }
30
38
  if (error && typeof error === 'object' && 'data' in error) {
31
39
  const data = error.data;
32
40
  if (data?.code) {
@@ -1,6 +1,17 @@
1
1
  export declare const HISTORY_METRIC_ALIASES: readonly ["bsr", "price"];
2
+ export declare const HISTORY_BUCKETS: readonly ["auto", "day", "week", "month"];
2
3
  export type HistoryMetricAlias = (typeof HISTORY_METRIC_ALIASES)[number];
3
- type HistoryPointTuple = [string, number | null] | [string, null, 1];
4
+ export type HistoryBucket = (typeof HISTORY_BUCKETS)[number];
5
+ type HistoryBucketTuple = [string, number | null];
6
+ type HistoryBucketSummary = {
7
+ first: number | null;
8
+ latest: number | null;
9
+ min: number | null;
10
+ max: number | null;
11
+ count: number;
12
+ firstBucketAt: string | null;
13
+ latestBucketAt: string | null;
14
+ };
4
15
  export type AgentHistorySeries = {
5
16
  bsr?: {
6
17
  unit: 'rank';
@@ -8,13 +19,15 @@ export type AgentHistorySeries = {
8
19
  id: number;
9
20
  name: string | null;
10
21
  } | null;
11
- points: HistoryPointTuple[];
22
+ buckets: HistoryBucketTuple[];
23
+ summary: HistoryBucketSummary;
12
24
  };
13
25
  price?: {
14
26
  unit: 'minorCurrency';
15
27
  currencyCode: 'USD';
16
28
  valueScale: 100;
17
- points: HistoryPointTuple[];
29
+ buckets: HistoryBucketTuple[];
30
+ summary: HistoryBucketSummary;
18
31
  };
19
32
  };
20
33
  export type AgentHistoryResponse = {
@@ -22,6 +35,11 @@ export type AgentHistoryResponse = {
22
35
  status?: string;
23
36
  syncTriggered: boolean;
24
37
  latestImportAt: string | null;
38
+ range?: {
39
+ startAt: string;
40
+ endAt: string;
41
+ bucket: Exclude<HistoryBucket, 'auto'>;
42
+ };
25
43
  series?: AgentHistorySeries;
26
44
  };
27
45
  export declare const buildCliHistoryResponse: ({ asin, marketplaceId, metrics, response, }: {
@@ -30,12 +48,17 @@ export declare const buildCliHistoryResponse: ({ asin, marketplaceId, metrics, r
30
48
  metrics: HistoryMetricAlias[];
31
49
  response: AgentHistoryResponse;
32
50
  }) => {
51
+ series: AgentHistorySeries;
52
+ range?: {
53
+ startAt: string;
54
+ endAt: string;
55
+ bucket: Exclude<HistoryBucket, "auto">;
56
+ } | undefined;
33
57
  schemaVersion: number;
34
58
  asin: string;
35
59
  marketplaceId: string;
36
60
  status: string;
37
61
  latestImportAt: string | null;
38
62
  syncTriggered: boolean;
39
- series: AgentHistorySeries;
40
63
  };
41
64
  export {};
@@ -1,4 +1,5 @@
1
1
  export const HISTORY_METRIC_ALIASES = ['bsr', 'price'];
2
+ export const HISTORY_BUCKETS = ['auto', 'day', 'week', 'month'];
2
3
  export const buildCliHistoryResponse = ({ asin, marketplaceId, metrics, response, }) => {
3
4
  const series = {
4
5
  ...(metrics.includes('bsr') && response.series?.bsr ? { bsr: response.series.bsr } : {}),
@@ -6,15 +7,16 @@ export const buildCliHistoryResponse = ({ asin, marketplaceId, metrics, response
6
7
  ? { price: response.series.price }
7
8
  : {}),
8
9
  };
9
- const hasAnyPoints = Object.values(series).some(metricSeries => metricSeries.points.length > 0);
10
+ const hasAnyBuckets = Object.values(series).some(metricSeries => metricSeries.buckets.length > 0);
10
11
  const status = normalizeHistoryStatus(response.status);
11
12
  return {
12
- schemaVersion: response.schemaVersion ?? 1,
13
+ schemaVersion: response.schemaVersion ?? 2,
13
14
  asin,
14
15
  marketplaceId,
15
- status: status ?? (response.syncTriggered ? 'collecting' : hasAnyPoints ? 'ready' : 'empty'),
16
+ status: status ?? (response.syncTriggered ? 'collecting' : hasAnyBuckets ? 'ready' : 'empty'),
16
17
  latestImportAt: response.latestImportAt,
17
18
  syncTriggered: response.syncTriggered,
19
+ ...(response.range ? { range: response.range } : {}),
18
20
  series,
19
21
  };
20
22
  };
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { createRankWranglerClient, DEFAULT_API_BASE_URL } from '@rankwrangler/http-client';
4
+ import { resolveApiKey, runAuthCommand } from './cli-auth.js';
4
5
  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';
6
+ import { runConfigCommand } from './cli-config-command.js';
7
+ import { loadCliContext, loadCliPathsOrDefault } from './cli-config.js';
8
+ import { parseIntegerOption, requireAsins, requireMarketplaceId, requireSingleAsin, resolveBaseUrl, resolveHistoryBucket, resolveHistoryMetrics, resolveHistoryWindow, } from './cli-options.js';
7
9
  import { fail, printSuccess, resolveError } from './cli-output.js';
8
10
  import { buildCliHistoryResponse } from './history-response.js';
9
11
  import { printUsage } from './usage.js';
@@ -12,8 +14,13 @@ const SUPPORTED_COMMANDS = new Set([
12
14
  'products:history',
13
15
  'license:status',
14
16
  'license:validate',
17
+ 'auth:status',
18
+ 'auth:set',
19
+ 'auth:clear',
15
20
  'config:show',
16
- 'config:clear',
21
+ 'config:get',
22
+ 'config:unset',
23
+ 'config:reset',
17
24
  'config:set',
18
25
  ]);
19
26
  const { positionals, values } = parseArgs({
@@ -29,6 +36,8 @@ const { positionals, values } = parseArgs({
29
36
  endAt: { type: 'string' },
30
37
  days: { type: 'string' },
31
38
  limit: { type: 'string' },
39
+ bucket: { type: 'string' },
40
+ stdin: { type: 'boolean' },
32
41
  },
33
42
  allowPositionals: true,
34
43
  });
@@ -57,12 +66,16 @@ const main = async () => {
57
66
  }
58
67
  const { config, paths } = await loadCliContext();
59
68
  if (command.resource === 'config') {
60
- printSuccess(await runConfigCommand(command, config, paths));
69
+ printSuccess(await runConfigCommand(command, config, paths, fail));
61
70
  return;
62
71
  }
63
- const apiKey = resolveApiKey();
72
+ if (command.resource === 'auth') {
73
+ printSuccess(await runAuthCommand(command, fail, { stdin: Boolean(values.stdin) }));
74
+ return;
75
+ }
76
+ const apiKey = await resolveApiKey();
64
77
  if (!apiKey) {
65
- fail('MISSING_CONFIG', 'api key is required. set RR_LICENSE_KEY');
78
+ fail('MISSING_CONFIG', 'license key is required. run `rw auth set`, `rw auth set --stdin`, or set RR_LICENSE_KEY');
66
79
  }
67
80
  const client = createRankWranglerClient({
68
81
  baseUrl: resolveBaseUrl(optionValues, config, DEFAULT_API_BASE_URL, fail),
@@ -98,6 +111,7 @@ const runProductHistoryCommand = async (command, client, config) => {
98
111
  const asin = requireSingleAsin(command.args, optionValues, fail);
99
112
  const marketplaceId = requireMarketplaceId(optionValues, config);
100
113
  const metrics = resolveHistoryMetrics(optionValues, fail);
114
+ const bucket = resolveHistoryBucket(optionValues, fail);
101
115
  const historyWindow = resolveHistoryWindow(optionValues, fail);
102
116
  const limit = parseIntegerOption({
103
117
  value: optionValues.limit,
@@ -111,6 +125,7 @@ const runProductHistoryCommand = async (command, client, config) => {
111
125
  asin,
112
126
  metrics,
113
127
  format: 'agent',
128
+ bucket,
114
129
  limit,
115
130
  ...historyWindow,
116
131
  });
@@ -121,60 +136,6 @@ const runProductHistoryCommand = async (command, client, config) => {
121
136
  response: response,
122
137
  });
123
138
  };
124
- const runConfigCommand = async (command, config, paths) => {
125
- if (command.verb === 'show') {
126
- return buildConfigResponse(paths, config);
127
- }
128
- if (command.verb === 'clear') {
129
- await clearConfig(paths);
130
- return {
131
- ...buildConfigResponse(paths, {}),
132
- cleared: true,
133
- };
134
- }
135
- if (command.verb !== 'set') {
136
- fail('UNKNOWN_COMMAND', 'Unknown config command', { verb: command.verb });
137
- }
138
- const [key, ...valueParts] = command.args;
139
- if (!key || valueParts.length === 0) {
140
- fail('INVALID_INPUT', 'config set requires <key> <value>');
141
- }
142
- const value = valueParts.join(' ').trim();
143
- if (!value) {
144
- fail('INVALID_INPUT', 'config set value cannot be empty');
145
- }
146
- if (key === 'storage-dir') {
147
- const nextState = await switchStorageDir({
148
- currentConfig: config,
149
- currentPaths: paths,
150
- requestedStorageDir: value,
151
- });
152
- return buildConfigResponse(nextState.paths, nextState.config);
153
- }
154
- const nextConfig = { ...config };
155
- if (key === 'base-url') {
156
- nextConfig.baseUrl = normalizeBaseUrl(value, fail);
157
- }
158
- else if (key === 'marketplace') {
159
- nextConfig.marketplaceId = value;
160
- }
161
- else {
162
- fail('INVALID_INPUT', 'unsupported config key', {
163
- key,
164
- supportedKeys: ['base-url', 'marketplace', 'storage-dir'],
165
- });
166
- }
167
- await saveConfig(paths, nextConfig);
168
- return buildConfigResponse(paths, nextConfig);
169
- };
170
- const buildConfigResponse = (paths, config) => {
171
- return {
172
- storageDir: paths.storageDir,
173
- path: paths.configPath,
174
- globalPath: paths.globalConfigPath,
175
- config,
176
- };
177
- };
178
139
  const resolveMetaCommand = (inputPositionals) => {
179
140
  if (inputPositionals.length === 1 && inputPositionals[0] === 'changelog') {
180
141
  return { name: 'changelog' };
package/dist/usage.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { LICENSE_KEY_ENV_VAR } from './cli-auth.js';
1
2
  const DEFAULT_MARKETPLACE_ID = 'ATVPDKIKX0DER';
2
3
  export const printUsage = (paths) => {
3
4
  const usage = [
@@ -18,11 +19,17 @@ export const printUsage = (paths) => {
18
19
  ' products history <ASIN>',
19
20
  ' license status',
20
21
  ' license validate',
22
+ ' auth status',
23
+ ' auth set [licenseKey]',
24
+ ' auth set --stdin',
25
+ ' auth clear',
21
26
  ' config show',
22
- ' config clear',
27
+ ' config get <key>',
23
28
  ' config set base-url <origin>',
24
29
  ' config set marketplace <marketplaceId>',
25
30
  ' config set storage-dir <path>',
31
+ ' config unset <key>',
32
+ ' config reset',
26
33
  '',
27
34
  'OPTIONS',
28
35
  ' -h, --help Show this help message',
@@ -31,17 +38,19 @@ export const printUsage = (paths) => {
31
38
  ` -m, --marketplace <id> Override marketplace (default: ${DEFAULT_MARKETPLACE_ID})`,
32
39
  ' --asin <ASIN> Add ASIN (repeatable)',
33
40
  ' --metrics <list> History metrics: bsr,price (default: bsr,price)',
41
+ ' --bucket <unit> History buckets: auto,day,week,month (default: auto)',
34
42
  ' --days <N> History lookback window (30-3650, default: 365)',
35
43
  ' --startAt <ISO> History range start (ISO date/time)',
36
44
  ' --endAt <ISO> History range end (ISO date/time)',
37
- ' --limit <N> Max points per metric (1-10000, default: 5000)',
45
+ ' --limit <N> Internal point cap (1-10000, default: 5000)',
46
+ ' --stdin Read auth secret from standard input',
38
47
  '',
39
48
  'FILES',
40
49
  ` ${paths.configPath} Active CLI config`,
41
50
  ` ${paths.globalConfigPath} Global CLI settings`,
42
51
  '',
43
52
  'ENVIRONMENT',
44
- ' RR_LICENSE_KEY API key source',
53
+ ` ${LICENSE_KEY_ENV_VAR} License key override for CI/automation`,
45
54
  ' RR_API_URL API origin fallback',
46
55
  ' RR_MARKETPLACE_ID Marketplace fallback',
47
56
  ' RR_STORAGE_DIR Storage directory override',
@@ -50,13 +59,17 @@ export const printUsage = (paths) => {
50
59
  ' RR_HISTORY_METRICS Comma-separated history metrics fallback',
51
60
  '',
52
61
  'EXAMPLES',
53
- ' RR_LICENSE_KEY=rrk_... rw products get B0DV53VS61',
62
+ ' rw auth set rrk_...',
63
+ ' printf %s "$RR_LICENSE_KEY" | rw auth set --stdin',
64
+ ` ${LICENSE_KEY_ENV_VAR}=rrk_... rw products get B0DV53VS61`,
54
65
  ' RR_STORAGE_DIR=~/.config/rankwrangler rw config show',
55
66
  ' rw --version',
56
67
  ' rw changelog',
68
+ ' rw config get marketplace',
57
69
  ' 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',
70
+ ' rw config unset marketplace',
71
+ ' rw products history B0DV53VS61 --metrics bsr,price --bucket week',
72
+ ' rw products history B0DV53VS61 --startAt 2025-01-01 --endAt 2025-12-31 --bucket month',
60
73
  ' rankwrangler license status',
61
74
  ];
62
75
  console.log(usage.join('\n'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankwrangler/cli",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Official RankWrangler command line interface",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,14 +20,16 @@
20
20
  "prepublishOnly": "node ../../scripts/release/ensure-cli-http-client-published.mjs"
21
21
  },
22
22
  "publishConfig": {
23
- "access": "public"
23
+ "access": "public",
24
+ "provenance": true
24
25
  },
25
26
  "dependencies": {
26
- "@rankwrangler/http-client": "^0.4.1"
27
+ "@rankwrangler/http-client": "^0.6.0",
28
+ "keytar": "7.9.0"
27
29
  },
28
30
  "devDependencies": {
29
- "@types/node": "^24.3.0",
30
- "typescript": "^5.9.2"
31
+ "@types/node": "24.10.12",
32
+ "typescript": "5.9.3"
31
33
  },
32
34
  "packageManager": "bun@1.1.27"
33
35
  }