requestshield 0.1.5 → 0.1.6

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.
Files changed (39) hide show
  1. package/README.md +414 -276
  2. package/config/.env.prod +7 -0
  3. package/package.json +8 -5
  4. package/skills/requestshield/SKILL.md +55 -63
  5. package/skills/requestshield/assets/AGENTS.codex.md +17 -17
  6. package/skills/requestshield/references/backend-java-core.md +3 -3
  7. package/skills/requestshield/references/backend-spring-boot.md +3 -3
  8. package/skills/requestshield/references/browser-manual.md +4 -4
  9. package/skills/requestshield/references/browser-seamless.md +7 -15
  10. package/skills/requestshield/references/cli.md +93 -169
  11. package/skills/requestshield/references/integration-planning.md +20 -47
  12. package/skills/requestshield/references/troubleshooting.md +26 -30
  13. package/src/api-client.mjs +106 -165
  14. package/src/args.mjs +108 -151
  15. package/src/browser-opener.mjs +32 -0
  16. package/src/cli.mjs +50 -28
  17. package/src/commands/agent-setup.mjs +34 -37
  18. package/src/commands/application-mutations.mjs +33 -0
  19. package/src/commands/application-response.mjs +55 -0
  20. package/src/commands/apps-get.mjs +3 -47
  21. package/src/commands/apps-list.mjs +40 -36
  22. package/src/commands/auth-status.mjs +37 -0
  23. package/src/commands/keys-create.mjs +7 -38
  24. package/src/commands/mutation-support.mjs +110 -0
  25. package/src/commands/secret-commands.mjs +45 -0
  26. package/src/commands/signin.mjs +70 -57
  27. package/src/commands/signout.mjs +9 -0
  28. package/src/commands/update-check.mjs +12 -4
  29. package/src/config.mjs +145 -3
  30. package/src/entrypoint.mjs +24 -0
  31. package/src/errors.mjs +3 -1
  32. package/src/main.mjs +2 -21
  33. package/src/oauth-client.mjs +153 -0
  34. package/src/oauth-loopback.mjs +120 -0
  35. package/src/session-files.mjs +213 -0
  36. package/src/session-store.mjs +177 -64
  37. package/src/commands/billing-get.mjs +0 -110
  38. package/src/commands/challenge-volume.mjs +0 -81
  39. package/src/commands/contract.mjs +0 -106
@@ -1,192 +1,138 @@
1
1
  // @ts-check
2
-
3
2
  import { CliError } from "./errors.mjs";
3
+ import { validateBearerToken, validatedUrl } from "./config.mjs";
4
4
 
5
- const API_ENDPOINTS = {
6
- SIGNIN: "/v1/cli/signin",
7
- SIGNIN_TOKEN: "/v1/cli/signin/token",
8
- KEYS_ROTATE: "/v1/cli/keys/rotate",
9
- INTEGRATION_CONTRACT: "/v1/integration-contract",
10
- APPLICATIONS: "/v1/applications",
11
- };
5
+ const APPLICATIONS = "/v1/applications";
6
+ const MAX_BODY_BYTES = 1024 * 1024;
7
+ const API_ERRORS = new Set(["invalid_request", "invalid_token", "insufficient_scope", "application_not_found",
8
+ "idempotency_conflict", "secret_revoked", "dependency_unavailable", "rate_limited"]);
12
9
 
13
10
  export class ManagementApiClient {
14
- /** @param {{ baseUrl: string, fetchImpl?: typeof fetch }} options */
15
- constructor({ baseUrl, fetchImpl = fetch }) {
16
- let url;
17
- try {
18
- url = new URL(baseUrl);
19
- } catch {
20
- throw new CliError("REQUESTSHIELD_API_URL must be a valid URL", { exitCode: 2 });
21
- }
22
- if (url.protocol !== "https:" && !isLoopback(url)) {
23
- throw new CliError("The management API must use HTTPS except on loopback", {
24
- exitCode: 2,
25
- });
26
- }
27
- this.baseUrl = url.href.replace(/\/$/, "");
11
+ /** @param {{baseUrl: string, fetchImpl?: typeof fetch, timeoutMs?: number}} options */
12
+ constructor({baseUrl, fetchImpl = fetch, timeoutMs = 15_000}) {
13
+ this.baseUrl = validatedUrl(baseUrl, "API_URL");
28
14
  this.fetchImpl = fetchImpl;
15
+ this.timeoutMs = timeoutMs;
29
16
  }
30
17
 
31
- async startSignin() {
32
- return this.#request(API_ENDPOINTS.SIGNIN, {
33
- method: "POST",
34
- body: JSON.stringify({ client: "requestshield-cli" }),
35
- });
18
+ /** @param {string} token @param {{limit?: number, cursor?: string}} [options] */
19
+ listApps(token, options = {}) {
20
+ const query = new URLSearchParams();
21
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
22
+ if (options.cursor !== undefined) query.set("cursor", options.cursor);
23
+ return this.#request(`${APPLICATIONS}${query.size ? `?${query}` : ""}`, token, "GET");
36
24
  }
37
25
 
38
- /** @param {string} deviceCode */
39
- async pollSignin(deviceCode) {
40
- return this.#request(
41
- API_ENDPOINTS.SIGNIN_TOKEN,
42
- { method: "POST", body: JSON.stringify({ deviceCode }) },
43
- true,
44
- );
45
- }
26
+ /** @param {string} token @param {string} appKey */
27
+ getApp(token, appKey) { return this.#request(appPath(appKey), token, "GET"); }
46
28
 
47
- /** @param {string} accessToken */
48
- async rotateKeys(accessToken) {
49
- return this.#request(API_ENDPOINTS.KEYS_ROTATE, {
50
- method: "POST",
51
- headers: { Authorization: `Bearer ${accessToken}` },
52
- body: "{}",
53
- });
29
+ /** @param {string} token @param {{name: string, idempotencyKey: string}} options */
30
+ createApp(token, {name, idempotencyKey}) {
31
+ return this.#request(APPLICATIONS, token, "POST", {name}, idempotencyKey);
54
32
  }
55
33
 
56
- /** @param {string} accessToken */
57
- async integrationContract(accessToken) {
58
- return this.#request(API_ENDPOINTS.INTEGRATION_CONTRACT, {
59
- method: "GET",
60
- headers: {
61
- Accept: "application/json",
62
- Authorization: `Bearer ${accessToken}`,
63
- },
64
- });
34
+ /** @param {string} token @param {string} appKey @param {{name: string, idempotencyKey: string}} options */
35
+ renameApp(token, appKey, {name, idempotencyKey}) {
36
+ return this.#request(appPath(appKey), token, "PATCH", {name}, idempotencyKey);
65
37
  }
66
38
 
67
- /** @param {string} accessToken */
68
- async listApps(accessToken) {
69
- return this.#request(API_ENDPOINTS.APPLICATIONS, {
70
- method: "GET",
71
- headers: {
72
- Accept: "application/json",
73
- Authorization: `Bearer ${accessToken}`,
74
- },
75
- });
39
+ /** @param {string} token @param {string} appKey @param {{enabled: boolean, idempotencyKey: string}} options */
40
+ setAppEnabled(token, appKey, {enabled, idempotencyKey}) {
41
+ return this.#request(`${appPath(appKey)}/${enabled ? "enable" : "disable"}`, token, "POST", undefined, idempotencyKey);
76
42
  }
77
43
 
78
- /** @param {string} accessToken @param {string} appKey */
79
- async getApp(accessToken, appKey) {
80
- return this.#request(
81
- `${API_ENDPOINTS.APPLICATIONS}/${encodeURIComponent(appKey)}`,
82
- {
83
- method: "GET",
84
- headers: {
85
- Accept: "application/json",
86
- Authorization: `Bearer ${accessToken}`,
87
- },
88
- },
89
- );
44
+ /** @param {string} token @param {string} appKey @param {{idempotencyKey: string}} options */
45
+ rotateSecret(token, appKey, {idempotencyKey}) {
46
+ return this.#request(`${appPath(appKey)}/secret/rotate`, token, "POST", undefined, idempotencyKey);
90
47
  }
91
48
 
92
- /**
93
- * @param {string} accessToken
94
- * @param {string} appKey
95
- * @param {{ from?: string, to?: string, granularity?: string }} options
96
- */
97
- async challengeVolume(accessToken, appKey, options) {
98
- const query = new URLSearchParams();
99
-
100
- if (options.from)
101
- query.set("from", options.from);
102
-
103
- if (options.to)
104
- query.set("to", options.to);
105
-
106
- if (options.granularity)
107
- query.set("granularity", options.granularity);
108
-
109
- const suffix = query.size > 0 ? `?${query}` : "";
110
-
111
- const endpoint =
112
- `${API_ENDPOINTS.APPLICATIONS}/` +
113
- `${encodeURIComponent(appKey)}/challenge-volume` +
114
- suffix;
49
+ /** @param {string} token @param {string} appKey */
50
+ revealSecret(token, appKey) { return this.#request(`${appPath(appKey)}/secret/reveal`, token, "POST"); }
115
51
 
116
- return this.#request(endpoint, {
117
- method: "GET",
118
- headers: {
119
- Accept: "application/json",
120
- Authorization: `Bearer ${accessToken}`,
121
- },
122
- });
123
- }
124
-
125
-
126
- /** @param {string} accessToken @param {string} appKey */
127
- async getBilling(accessToken, appKey) {
128
- return this.#request(
129
- `${API_ENDPOINTS.APPLICATIONS}/${encodeURIComponent(appKey)}/billing`,
130
- {
131
- method: "GET",
132
- headers: {
133
- Accept: "application/json",
134
- Authorization: `Bearer ${accessToken}`,
135
- },
136
- },
137
- );
52
+ /** @param {string} token @param {string} appKey @param {{idempotencyKey: string}} options */
53
+ revokeSecret(token, appKey, {idempotencyKey}) {
54
+ return this.#request(`${appPath(appKey)}/secret/revoke`, token, "POST", undefined, idempotencyKey);
138
55
  }
139
56
 
140
- /**
141
- * @param {string} path
142
- * @param {RequestInit} init
143
- * @param {boolean} [allowErrorBody]
57
+ /** No retries: a lost mutation response may represent a committed operation.
58
+ * @param {string} path @param {string} token @param {string} method
59
+ * @param {{name: string}} [payload] @param {string} [idempotencyKey]
144
60
  */
145
- async #request(path, init, allowErrorBody = false) {
146
- let response;
147
- try {
148
- response = await this.fetchImpl(`${this.baseUrl}${path}`, {
149
- ...init,
150
- headers: { "Content-Type": "application/json", ...init.headers },
151
- signal: AbortSignal.timeout(15_000),
152
- });
153
- } catch (error) {
154
- throw new CliError(`Could not reach the RequestShield API: ${messageOf(error)}`, {
155
- code: "NETWORK_ERROR",
156
- exitCode: 7,
157
- });
158
- }
159
-
160
- const text = await response.text();
161
- let body = {};
162
- if (text !== "") {
163
- try {
164
- body = JSON.parse(text);
165
- } catch {
166
- throw new CliError("The RequestShield API returned invalid JSON", {
167
- code: "INVALID_RESPONSE",
168
- });
61
+ async #request(path, token, method, payload, idempotencyKey) {
62
+ const headers = {Accept: "application/json", Authorization: `Bearer ${validateBearerToken(token)}`};
63
+ if (idempotencyKey !== undefined) {
64
+ if (!/^[A-Za-z0-9._~-]{1,128}$/.test(idempotencyKey)) {
65
+ throw new CliError("Invalid idempotency key", {code: "INVALID_ARGUMENT", exitCode: 2});
169
66
  }
67
+ Object.assign(headers, {"Idempotency-Key": idempotencyKey});
170
68
  }
171
- if (!response.ok && !allowErrorBody) {
172
- const nestedError = body && typeof body === "object" ? Reflect.get(body, "error") : undefined;
173
- const detail = objectString(nestedError, "message")
174
- ?? objectString(body, "message")
175
- ?? `HTTP ${response.status}`;
176
- const exitCode = response.status === 401 || response.status === 403 ? 3 : 1;
177
- throw new CliError(`RequestShield API error: ${detail}`, {
178
- code: objectString(nestedError, "code") ?? objectString(body, "code") ?? "API_ERROR",
179
- exitCode,
180
- });
69
+ if (payload !== undefined) Object.assign(headers, {"Content-Type": "application/json"});
70
+ const controller = new AbortController();
71
+ /** @type {ReadableStreamDefaultReader<Uint8Array> | undefined} */
72
+ let reader;
73
+ let expired = false;
74
+ const deadline = new Promise((_, reject) => {
75
+ controller.signal.addEventListener("abort", () => reject(new CliError(
76
+ "The RequestShield API request timed out. Check connectivity and try again.",
77
+ {code: "REQUEST_TIMEOUT", exitCode: 7},
78
+ )), {once: true});
79
+ });
80
+ const timer = setTimeout(() => { expired = true; controller.abort(); }, this.timeoutMs);
81
+ try {
82
+ const operation = (async () => {
83
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
84
+ method, headers, ...(payload !== undefined ? {body: JSON.stringify(payload)} : {}),
85
+ signal: controller.signal, redirect: "error", credentials: "omit", cache: "no-store",
86
+ });
87
+ if (expired) { void response.body?.cancel().catch(() => {}); throw new Error(); }
88
+ if (Number(response.headers.get("content-length")) > MAX_BODY_BYTES) {
89
+ void response.body?.cancel().catch(() => {}); throw invalidResponse();
90
+ }
91
+ const chunks = [];
92
+ let size = 0;
93
+ if (response.body) {
94
+ reader = response.body.getReader();
95
+ while (true) {
96
+ const {done, value} = await reader.read();
97
+ if (expired) throw new Error();
98
+ if (done) break;
99
+ size += value.length;
100
+ if (size > MAX_BODY_BYTES) throw invalidResponse();
101
+ chunks.push(value);
102
+ }
103
+ }
104
+ let body;
105
+ try { body = JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(Buffer.concat(chunks))); }
106
+ catch { if (response.ok) throw invalidResponse(); }
107
+ if (!response.ok) {
108
+ const providerCode = objectString(body?.error, "code");
109
+ const safeCode = providerCode && API_ERRORS.has(providerCode) ? providerCode : undefined;
110
+ const guidance = response.status === 401 ? "; run `requestshield signin` again"
111
+ : safeCode === "insufficient_scope" ? "; this session lacks the required permissions" : "";
112
+ throw new CliError(`RequestShield API error: HTTP ${response.status}${safeCode ? ` (${safeCode})` : ""}${guidance}`, {
113
+ code: response.status === 401 ? "UNAUTHENTICATED" : "API_ERROR",
114
+ exitCode: response.status === 401 || response.status === 403 ? 3 : 1,
115
+ httpStatus: response.status, apiErrorCode: safeCode,
116
+ });
117
+ }
118
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw invalidResponse();
119
+ return {ok: true, status: response.status, body, replayed: response.headers.get("Idempotency-Replayed") === "true"};
120
+ })();
121
+ return await Promise.race([operation, /** @type {Promise<never>} */ (deadline)]);
122
+ } catch (error) {
123
+ if (error instanceof CliError) throw error;
124
+ throw new CliError("Could not reach the RequestShield API", {code: "NETWORK_ERROR", exitCode: 7});
125
+ } finally {
126
+ clearTimeout(timer);
127
+ if (reader) { void reader.cancel().catch(() => {}); }
128
+ controller.abort();
181
129
  }
182
- return { ok: response.ok, status: response.status, body };
183
130
  }
184
131
  }
185
132
 
186
- /** @param {URL} url */
187
- function isLoopback(url) {
188
- return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
189
- }
133
+ /** @param {string} appKey */
134
+ function appPath(appKey) { return `${APPLICATIONS}/${encodeURIComponent(appKey)}`; }
135
+ function invalidResponse() { return new CliError("The RequestShield API returned an invalid or oversized response", {code: "INVALID_RESPONSE"}); }
190
136
 
191
137
  /** @param {unknown} value @param {string} property */
192
138
  export function objectString(value, property) {
@@ -194,8 +140,3 @@ export function objectString(value, property) {
194
140
  const found = Reflect.get(value, property);
195
141
  return typeof found === "string" ? found : undefined;
196
142
  }
197
-
198
- /** @param {unknown} error */
199
- function messageOf(error) {
200
- return error instanceof Error ? error.message : String(error);
201
- }
package/src/args.mjs CHANGED
@@ -1,188 +1,145 @@
1
1
  // @ts-check
2
-
3
2
  import { CliError } from "./errors.mjs";
4
3
 
5
4
  const HELP = `RequestShield CLI
6
5
 
7
6
  Usage:
8
- requestshield signin
9
- requestshield keys create [--yes]
10
- requestshield contract
11
- requestshield apps list [--json]
7
+ requestshield signin [--no-open]
8
+ requestshield auth status [--json]
9
+ requestshield signout
10
+ requestshield keys create --app-name <name> [--idempotency-key <key>]
11
+ requestshield keys rotate <app-key> [--idempotency-key <key>] [--yes]
12
+ requestshield keys reveal <app-key> [--yes]
13
+ requestshield keys revoke <app-key> [--idempotency-key <key>] [--yes]
14
+ requestshield apps list [--json] [--limit <1-100>] [--cursor <cursor> | --all]
12
15
  requestshield apps get <app-key>
13
- requestshield challenge volume <app-key> [--from <time>] [--to <time>] [--granularity <value>]
14
- requestshield get billing <app-key>
16
+ requestshield apps rename <app-key> --name <name> [--idempotency-key <key>]
17
+ requestshield apps enable <app-key> [--idempotency-key <key>]
18
+ requestshield apps disable <app-key> [--idempotency-key <key>] [--yes]
15
19
  requestshield agent setup [--force]
16
20
  requestshield agent setup --codex [--force]
17
21
  requestshield agent setup --claude [--force]
18
22
  requestshield update check
19
23
  requestshield --help
20
- requestshield --version`;
24
+ requestshield --version
25
+
26
+ List defaults to one page. Use --all for a bounded complete listing.
27
+ contract, challenge volume and get billing are not available yet.`;
21
28
 
22
29
  /**
23
- * @typedef {{ command: "help", help: string }
24
- * | { command: "version" }
25
- * | { command: "update-check" }
26
- * | { command: "signin" }
27
- * | { command: "keys-create", yes: boolean }
28
- * | { command: "contract" }
29
- * | { command: "apps-list", json: boolean }
30
- * | { command: "apps-get", appKey: string }
31
- * | { command: "challenge-volume", appKey: string, from?: string, to?: string, granularity?: string }
32
- * | { command: "billing-get", appKey: string }
33
- * | { command: "agent-setup", agent?: "codex" | "claude", force: boolean }} ParsedArgs
30
+ * @typedef {{command:"help",help:string} | {command:"version"|"update-check"|"signout"}
31
+ * | {command:"signin",noOpen:boolean} | {command:"auth-status",json:boolean}
32
+ * | {command:"keys-create",appName:string,yes:boolean,idempotencyKey?:string}
33
+ * | {command:"keys-rotate"|"keys-reveal"|"keys-revoke",appKey:string,yes:boolean,idempotencyKey?:string}
34
+ * | {command:"apps-list",json:boolean,all:boolean,limit?:number,cursor?:string}
35
+ * | {command:"apps-get",appKey:string}
36
+ * | {command:"apps-rename",appKey:string,name:string,yes:boolean,idempotencyKey?:string}
37
+ * | {command:"apps-enable"|"apps-disable",appKey:string,enabled:boolean,yes:boolean,idempotencyKey?:string}
38
+ * | {command:"agent-setup",agent?:"codex"|"claude",force:boolean}} ParsedArgs
34
39
  */
35
40
 
36
41
  /** @param {string[]} argv @returns {ParsedArgs} */
37
42
  export function parseArgs(argv) {
38
- if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
39
- return { command: "help", help: HELP };
40
- }
41
- if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "-V")) {
42
- return { command: "version" };
43
- }
44
- if (argv[0] === "update" && argv[1] === "check") {
45
- assertOnly(argv.slice(2), new Set());
46
- return { command: "update-check" };
47
- }
48
- if (argv[0] === "signin") {
49
- assertOnly(argv.slice(1), new Set());
50
- return { command: "signin" };
43
+ if (!argv.length || argv.includes("--help") || argv.includes("-h")) return {command:"help",help:HELP};
44
+ if (argv.length === 1 && ["--version","-v","-V"].includes(argv[0])) return {command:"version"};
45
+ const [group, action] = argv;
46
+ if (group === "contract" || group === "challenge" && action === "volume" || group === "get" && action === "billing") {
47
+ throw new CliError("This command is not available: its Management API endpoint has not been implemented.", {code:"COMMAND_UNAVAILABLE",exitCode:2});
51
48
  }
52
- if (argv[0] === "keys" && argv[1] === "create") {
53
- assertOnly(argv.slice(2), new Set(["--yes"]));
54
- return { command: "keys-create", yes: argv.includes("--yes") };
49
+ if (group === "signin") {
50
+ const flags = parseFlags(argv.slice(1), ["--no-open"]);
51
+ return {command:"signin",noOpen:flags.has("--no-open")};
55
52
  }
56
- if (argv[0] === "contract") {
57
- assertOnly(argv.slice(1), new Set());
58
- return { command: "contract" };
53
+ if (group === "signout") { parseFlags(argv.slice(1), []); return {command:"signout"}; }
54
+ if (group === "auth" && action === "status") {
55
+ const flags = parseFlags(argv.slice(2), ["--json"]);
56
+ return {command:"auth-status",json:flags.has("--json")};
59
57
  }
60
- if (argv[0] === "apps" && argv[1] === "list") {
61
- assertOnly(argv.slice(2), new Set(["--json"]));
62
- return { command: "apps-list", json: argv.includes("--json") };
58
+ if (group === "update" && action === "check") { parseFlags(argv.slice(2), []); return {command:"update-check"}; }
59
+ if (group === "keys" && action === "create") {
60
+ const flags = parseFlags(argv.slice(2), ["--yes"], ["--app-name","--idempotency-key"]);
61
+ const name = flags.get("--app-name");
62
+ if (typeof name !== "string") usage("keys create --app-name <name> [--idempotency-key <key>]");
63
+ return {command:"keys-create",appName:applicationName(name),...mutationFlags(flags)};
63
64
  }
64
- if (argv[0] === "apps" && argv[1] === "get") {
65
- return {
66
- command: "apps-get",
67
- appKey: parseAppKey(argv, "Usage: requestshield apps get <app-key>"),
68
- };
65
+ if (group === "keys" && ["rotate","reveal","revoke"].includes(action)) {
66
+ const appKey = requiredAppKey(argv, `keys ${action} <app-key>`);
67
+ const flags = parseFlags(argv.slice(3), ["--yes"], action === "reveal" ? [] : ["--idempotency-key"]);
68
+ return {command:/** @type {"keys-rotate"|"keys-reveal"|"keys-revoke"} */ (`keys-${action}`),appKey,...mutationFlags(flags)};
69
69
  }
70
- if (argv[0] === "challenge" && argv[1] === "volume") {
71
- return parseChallengeVolume(argv);
70
+ if (group === "apps" && action === "list") {
71
+ const flags = parseFlags(argv.slice(2), ["--json","--all"], ["--limit","--cursor"]);
72
+ const limit = flags.get("--limit");
73
+ if (limit !== undefined && (typeof limit !== "string" || !/^[1-9][0-9]{0,2}$/.test(limit) || Number(limit) > 100)) {
74
+ throw invalid("--limit must be an integer from 1 to 100");
75
+ }
76
+ const cursor = flags.get("--cursor");
77
+ if (cursor !== undefined && (typeof cursor !== "string" || !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor))) throw invalid("Invalid --cursor");
78
+ if (cursor !== undefined && flags.has("--all")) throw invalid("--cursor and --all cannot be combined");
79
+ return {command:"apps-list",json:flags.has("--json"),all:flags.has("--all"),
80
+ ...(limit !== undefined ? {limit:Number(limit)} : {}),...(typeof cursor === "string" ? {cursor} : {})};
72
81
  }
73
- if (argv[0] === "get" && argv[1] === "billing") {
74
- return {
75
- command: "billing-get",
76
- appKey: parseAppKey(argv, "Usage: requestshield get billing <app-key>"),
77
- };
82
+ if (group === "apps" && action === "get") {
83
+ const appKey = requiredAppKey(argv, "apps get <app-key>");
84
+ parseFlags(argv.slice(3), []);
85
+ return {command:"apps-get",appKey};
78
86
  }
79
- if (argv[0] === "agent" && argv[1] === "setup") {
80
- const rest = argv.slice(2);
81
- /** @type {"codex" | "claude" | undefined} */
82
- let agent;
83
- let force = false;
84
- for (let index = 0; index < rest.length; index++) {
85
- const arg = rest[index];
86
- if (arg === "--force") {
87
- force = true;
88
- } else if (arg === "--codex" || arg === "--claude") {
89
- if (agent !== undefined) {
90
- throw new CliError("Only one of --codex or --claude may be provided", {
91
- code: "INVALID_AGENT",
92
- exitCode: 2,
93
- });
94
- }
95
- agent = arg === "--codex" ? "codex" : "claude";
96
- } else {
97
- throw new CliError(`Unknown option: ${arg}`, { exitCode: 2 });
98
- }
87
+ if (group === "apps" && ["rename","enable","disable"].includes(action)) {
88
+ const appKey = requiredAppKey(argv, `apps ${action} <app-key>${action === "rename" ? " --name <name>" : ""}`);
89
+ const flags = parseFlags(argv.slice(3), ["--yes"], ["--idempotency-key",...(action === "rename" ? ["--name"] : [])]);
90
+ if (action === "rename") {
91
+ const name = flags.get("--name");
92
+ if (typeof name !== "string") usage("apps rename <app-key> --name <name>");
93
+ return {command:"apps-rename",appKey,name:applicationName(name),...mutationFlags(flags)};
99
94
  }
100
- return {
101
- command: "agent-setup",
102
- ...(agent ? { agent } : {}),
103
- force,
104
- };
95
+ return {command:action === "enable" ? "apps-enable" : "apps-disable",appKey,enabled:action === "enable",...mutationFlags(flags)};
105
96
  }
106
- throw new CliError(`Unknown command.\n\n${HELP}`, { exitCode: 2 });
107
- }
108
-
109
- /** @param {string[]} args @param {Set<string>} allowed */
110
- function assertOnly(args, allowed) {
111
- for (const arg of args) {
112
- if (!allowed.has(arg)) {
113
- throw new CliError(`Unknown option: ${arg}`, { exitCode: 2 });
114
- }
97
+ if (group === "agent" && action === "setup") {
98
+ const flags = parseFlags(argv.slice(2), ["--codex","--claude","--force"]);
99
+ if (flags.has("--codex") && flags.has("--claude")) throw invalid("Only one of --codex or --claude may be provided");
100
+ return {command:"agent-setup",force:flags.has("--force"),
101
+ ...(flags.has("--codex") ? {agent:/** @type {const} */ ("codex")} : flags.has("--claude") ? {agent:/** @type {const} */ ("claude")} : {})};
115
102
  }
103
+ throw invalid(`Unknown command.\n\n${HELP}`);
116
104
  }
117
105
 
118
- /** @param {string[]} argv @param {string} usage */
119
- function parseAppKey(argv, usage) {
120
- if (argv.length !== 3) throw new CliError(usage, { exitCode: 2 });
121
- return validateAppKey(argv[2]);
122
- }
123
-
124
- /** @param {string[]} argv @returns {Extract<ParsedArgs, {command: "challenge-volume"}>} */
125
- function parseChallengeVolume(argv) {
126
- const usage = "Usage: requestshield challenge volume <app-key> [--from <time>] [--to <time>] [--granularity <value>]";
127
- if (argv.length < 3) throw new CliError(usage, { exitCode: 2 });
128
- const appKey = validateAppKey(argv[2]);
129
- /** @type {{ from?: string, to?: string, granularity?: string }} */
130
- const options = {};
131
- /** @type {Map<string, "from" | "to" | "granularity">} */
132
- const optionNames = new Map([
133
- ["--from", "from"],
134
- ["--to", "to"],
135
- ["--granularity", "granularity"],
136
- ]);
137
- for (let index = 3; index < argv.length; index += 2) {
138
- const option = argv[index];
139
- const property = optionNames.get(option);
140
- if (!property) throw new CliError(`Unknown option: ${option}`, { exitCode: 2 });
141
- const value = argv[index + 1];
142
- if (!value || value.startsWith("--")) {
143
- throw new CliError(`${option} requires a value`, { exitCode: 2 });
144
- }
145
- if (options[property] !== undefined) {
146
- throw new CliError(`${option} may only be provided once`, { exitCode: 2 });
147
- }
148
- if ((property === "from" || property === "to") && !isIsoTime(value)) {
149
- throw new CliError(`${option} must be an ISO-8601 timestamp`, {
150
- code: "INVALID_TIME",
151
- exitCode: 2,
152
- });
106
+ /** @param {string[]} args @param {string[]} booleans @param {string[]} [values] */
107
+ function parseFlags(args, booleans, values = []) {
108
+ /** @type {Map<string,string|true>} */
109
+ const flags = new Map();
110
+ for (let i = 0; i < args.length; i++) {
111
+ const arg = args[i];
112
+ if (!booleans.includes(arg) && !values.includes(arg)) throw invalid(`Unknown option: ${arg}`);
113
+ if (flags.has(arg)) throw invalid(`${arg} may only be provided once`);
114
+ if (booleans.includes(arg)) flags.set(arg, true);
115
+ else {
116
+ const value = args[++i];
117
+ if (!value || value.startsWith("--")) throw invalid(`${arg} requires a value`);
118
+ flags.set(arg,value);
153
119
  }
154
- if (property === "granularity" && !/^[a-z][a-z0-9_-]{0,31}$/.test(value)) {
155
- throw new CliError(
156
- "--granularity must be 1-32 lowercase letters, numbers, '_' or '-'",
157
- { code: "INVALID_GRANULARITY", exitCode: 2 },
158
- );
159
- }
160
- options[property] = value;
161
- }
162
- if (options.from && options.to && Date.parse(options.from) > Date.parse(options.to)) {
163
- throw new CliError("--from must not be later than --to", {
164
- code: "INVALID_TIME_RANGE",
165
- exitCode: 2,
166
- });
167
120
  }
168
- return { command: "challenge-volume", appKey, ...options };
121
+ return flags;
169
122
  }
170
-
171
- /** @param {string} appKey */
172
- function validateAppKey(appKey) {
173
- if (!/^[A-Za-z0-9._~-]{1,128}$/.test(appKey)) {
174
- throw new CliError(
175
- "App Key must be 1-128 characters using letters, numbers, '.', '_', '~', or '-'",
176
- { code: "INVALID_APP_KEY", exitCode: 2 },
177
- );
178
- }
179
- return appKey;
123
+ /** @param {Map<string,string|true>} flags */
124
+ function mutationFlags(flags) {
125
+ const key = flags.get("--idempotency-key");
126
+ if (key !== undefined && (typeof key !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/.test(key))) throw invalid("Invalid --idempotency-key");
127
+ return {yes:flags.has("--yes"),...(typeof key === "string" ? {idempotencyKey:key} : {})};
128
+ }
129
+ /** @param {string[]} argv @param {string} help */
130
+ function requiredAppKey(argv, help) {
131
+ if (!argv[2] || argv[2].startsWith("--")) usage(help);
132
+ if (!/^[A-Za-z0-9._~-]{1,128}$/.test(argv[2])) throw invalid("App Key must be 1-128 letters, numbers, '.', '_', '~' or '-'");
133
+ return argv[2];
180
134
  }
181
-
182
135
  /** @param {string} value */
183
- function isIsoTime(value) {
184
- return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
185
- && Number.isFinite(Date.parse(value));
136
+ function applicationName(value) {
137
+ const name = value.trim();
138
+ if (!name || [...name].length > 100 || /[\p{Cc}\p{Cs}]/u.test(name)) throw invalid("Application name must contain 1-100 characters without control characters");
139
+ return name;
186
140
  }
187
-
141
+ /** @param {string} command @returns {never} */
142
+ function usage(command) { throw invalid(`Usage: requestshield ${command}`); }
143
+ /** @param {string} message */
144
+ function invalid(message) { return new CliError(message,{code:"INVALID_ARGUMENT",exitCode:2}); }
188
145
  export { HELP };
@@ -0,0 +1,32 @@
1
+ // @ts-check
2
+ import { spawn } from "node:child_process";
3
+ import { CliError } from "./errors.mjs";
4
+
5
+ /** Uses argument arrays and a fixed Windows script; never interpolate URL into shell code.
6
+ * @param {string} url
7
+ * @param {{signal?: AbortSignal, platform?: NodeJS.Platform, spawnImpl?: typeof spawn}} [options]
8
+ */
9
+ export async function openBrowser(url, {signal, platform = process.platform, spawnImpl = spawn} = {}) {
10
+ const failure = () => new CliError("Could not open the browser automatically", {code: "BROWSER_OPEN_FAILED"});
11
+ const command = platform === "win32" ? "powershell.exe" : platform === "darwin" ? "open" : "xdg-open";
12
+ const args = platform === "win32"
13
+ ? ["-NoProfile", "-NonInteractive", "-Command", "Start-Process -FilePath $env:REQUESTSHIELD_SIGNIN_BROWSER_URL"]
14
+ : [url];
15
+ await new Promise((resolve, reject) => {
16
+ if (signal?.aborted) { reject(failure()); return; }
17
+ let child;
18
+ try {
19
+ child = spawnImpl(command, args, {
20
+ windowsHide: true, stdio: "ignore", shell: false,
21
+ env: platform === "win32" ? {...process.env, REQUESTSHIELD_SIGNIN_BROWSER_URL: url} : process.env,
22
+ });
23
+ } catch { reject(failure()); return; }
24
+ const abort = () => { cleanup(); child.kill(); reject(failure()); };
25
+ const timer = setTimeout(abort, 10_000);
26
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); };
27
+ child.once("error", () => { cleanup(); reject(failure()); });
28
+ child.once("exit", code => { cleanup(); code === 0 ? resolve(undefined) : reject(failure()); });
29
+ signal?.addEventListener("abort", abort, {once: true});
30
+ if (signal?.aborted) abort();
31
+ });
32
+ }