requestshield 0.1.7 → 0.1.9

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 (36) hide show
  1. package/README.md +167 -58
  2. package/config/.env.prod +4 -0
  3. package/package.json +1 -1
  4. package/skills/requestshield/SKILL.md +20 -17
  5. package/skills/requestshield/assets/AGENTS.codex.md +5 -3
  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 +1 -1
  9. package/skills/requestshield/references/cli.md +92 -28
  10. package/skills/requestshield/references/integration-planning.md +9 -7
  11. package/skills/requestshield/references/troubleshooting.md +2 -2
  12. package/src/api-client.mjs +1 -1
  13. package/src/args.mjs +98 -106
  14. package/src/cli.mjs +69 -243
  15. package/src/command-registry.mjs +97 -0
  16. package/src/commands/agent-setup.mjs +24 -17
  17. package/src/commands/agent-status.mjs +60 -0
  18. package/src/commands/application-mutations.mjs +8 -4
  19. package/src/commands/apps-get.mjs +12 -4
  20. package/src/commands/apps-list.mjs +20 -13
  21. package/src/commands/contract.mjs +25 -0
  22. package/src/commands/keys-create.mjs +5 -2
  23. package/src/commands/mutation-support.mjs +26 -12
  24. package/src/commands/output.mjs +21 -0
  25. package/src/commands/secret-commands.mjs +13 -6
  26. package/src/commands/signin.mjs +17 -8
  27. package/src/commands/signout.mjs +7 -3
  28. package/src/commands/update-check.mjs +97 -43
  29. package/src/config.mjs +29 -3
  30. package/src/entrypoint.mjs +20 -13
  31. package/src/integration-contract-client.mjs +81 -0
  32. package/src/integration-contract.mjs +104 -0
  33. package/src/oauth-client.mjs +2 -2
  34. package/src/oauth-loopback.mjs +1 -1
  35. package/src/session-files.mjs +4 -4
  36. package/src/session-store.mjs +2 -2
@@ -1,14 +1,16 @@
1
1
  // @ts-check
2
2
  import { parseApplicationResponse } from "./application-response.mjs";
3
+ import { outputContext, printApplication } from "./output.mjs";
3
4
  import { confirmAction, mutationKey, parseAccepted, printAccepted, validateHttpStatus, withMutationRecovery } from "./mutation-support.mjs";
4
5
 
5
6
  /** @typedef {import('./mutation-support.mjs').MutationResponse} MutationResponse */
6
7
  /** @typedef {import('./mutation-support.mjs').CommandOutput & {sessions: {loadToken(): Promise<string>}}} Dependencies */
7
8
 
8
- /** @param {{appKey: string, name: string, yes?: boolean, idempotencyKey?: string}} options
9
+ /** @param {{appKey: string, name: string, json?: boolean, yes?: boolean, idempotencyKey?: string}} options
9
10
  * @param {Dependencies & {api: {renameApp(token: string, appKey: string, options: {name: string, idempotencyKey: string}): Promise<MutationResponse>}}} deps
10
11
  */
11
12
  export async function renameApp(options, deps) {
13
+ deps = outputContext(options, deps);
12
14
  const idempotencyKey = mutationKey(options.idempotencyKey);
13
15
  const token = await deps.sessions.loadToken();
14
16
  const application = await withMutationRecovery(idempotencyKey, deps, async () => {
@@ -16,18 +18,20 @@ export async function renameApp(options, deps) {
16
18
  validateHttpStatus(result, 200);
17
19
  return parseApplicationResponse(result.body, options.appKey);
18
20
  });
19
- deps.log(JSON.stringify({data: application}, null, 2));
21
+ printApplication(application, options, deps);
22
+ return application;
20
23
  }
21
24
 
22
- /** @param {{appKey: string, enabled: boolean, yes?: boolean, idempotencyKey?: string}} options
25
+ /** @param {{appKey: string, enabled: boolean, json?: boolean, yes?: boolean, idempotencyKey?: string}} options
23
26
  * @param {Dependencies & {api: {setAppEnabled(token: string, appKey: string, options: {enabled: boolean, idempotencyKey: string}): Promise<MutationResponse>}}} deps
24
27
  */
25
28
  export async function setAppEnabled(options, deps) {
29
+ deps = outputContext(options, deps);
26
30
  if (!options.enabled) await confirmAction(options, deps, `Disable application ${options.appKey}?`, "DISABLE");
27
31
  const idempotencyKey = mutationKey(options.idempotencyKey);
28
32
  const token = await deps.sessions.loadToken();
29
33
  await withMutationRecovery(idempotencyKey, deps, async () => {
30
34
  parseAccepted(await deps.api.setAppEnabled(token, options.appKey, {enabled: options.enabled, idempotencyKey}));
31
35
  });
32
- printAccepted(options.enabled ? "Application enable" : "Application disable", options.appKey, deps);
36
+ return printAccepted(options.enabled ? "Application enable" : "Application disable", options.appKey, deps, options);
33
37
  }
@@ -1,20 +1,28 @@
1
1
  // @ts-check
2
2
 
3
3
  import { parseApplicationResponse } from "./application-response.mjs";
4
+ import { printApplication } from "./output.mjs";
4
5
 
5
6
  /**
6
- * @param {{ appKey: string }} options
7
+ * @param {{ appKey: string, json?: boolean }} options
7
8
  * @param {{ api: { getApp(accessToken: string, appKey: string): Promise<{body: unknown}> }, sessions: { loadToken(): Promise<string> }, log: (message: string) => void }} deps
8
9
  */
9
10
  export async function getApp(options, deps) {
11
+ const app = await fetchApp(options.appKey, deps);
12
+ printApplication(app, options, deps);
13
+ return app;
14
+ }
15
+
16
+ /** @param {string} appKey @param {Pick<Parameters<typeof getApp>[1], 'api' | 'sessions'>} deps */
17
+ export async function fetchApp(appKey, deps) {
10
18
  const accessToken =
11
19
  await deps.sessions.loadToken();
12
20
 
13
21
  const result =
14
- await deps.api.getApp(accessToken, options.appKey);
22
+ await deps.api.getApp(accessToken, appKey);
15
23
 
16
24
  const app =
17
- parseApplicationResponse(result.body, options.appKey);
25
+ parseApplicationResponse(result.body, appKey);
18
26
 
19
- deps.log(JSON.stringify({ data: app }, null, 2));
27
+ return app;
20
28
  }
@@ -14,6 +14,25 @@ const MAX_PAGES = 100;
14
14
  * @param {{api: {listApps(accessToken: string, options: {limit?: number, cursor?: string}): Promise<{body: unknown}>}, sessions: {loadToken(): Promise<string>}, log: (message: string) => void, profile?: import('../config.mjs').Profile}} deps
15
15
  */
16
16
  export async function listApps(options, deps) {
17
+ const result = await collectApps(options, deps);
18
+ const {data: apps, nextCursor} = result;
19
+ if (options.json) deps.log(JSON.stringify(result, null, 2));
20
+ else {
21
+ if (apps.length === 0) {
22
+ deps.log(nextCursor === null && !options.cursor
23
+ ? "No applications are available to this account."
24
+ : "No applications were returned on this page.");
25
+ } else {
26
+ for (const line of formatTable(apps)) deps.log(line);
27
+ }
28
+ if (nextCursor !== null) deps.log(`More applications are available. Continue with \`${getCommandInvocation(deps.profile)} app list --cursor ${nextCursor}\` or use --all.`);
29
+ }
30
+ return result;
31
+ }
32
+
33
+ /** @param {{limit?: number, cursor?: string, all?: boolean}} options
34
+ * @param {Pick<Parameters<typeof listApps>[1], 'api' | 'sessions'>} deps */
35
+ export async function collectApps(options, deps) {
17
36
  /** @type {App[]} */
18
37
  const apps = [];
19
38
  let cursor = options.cursor;
@@ -41,19 +60,7 @@ export async function listApps(options, deps) {
41
60
  }
42
61
  }
43
62
 
44
- if (options.json) {
45
- deps.log(JSON.stringify({ data: apps, nextCursor }, null, 2));
46
- return;
47
- }
48
-
49
- if (apps.length === 0) {
50
- deps.log(nextCursor === null && !options.cursor
51
- ? "No applications are available to this account."
52
- : "No applications were returned on this page.");
53
- } else {
54
- for (const line of formatTable(apps)) deps.log(line);
55
- }
56
- if (nextCursor !== null) deps.log(`More applications are available. Continue with \`${getCommandInvocation(deps.profile)} apps list --cursor ${nextCursor}\` or use --all.`);
63
+ return {data: apps, nextCursor};
57
64
  }
58
65
 
59
66
  /** @param {unknown} body @returns {{ data: App[], nextCursor: string | null }} */
@@ -0,0 +1,25 @@
1
+ // @ts-check
2
+ import { getDocsUrl } from "../config.mjs";
3
+ import { IntegrationContractClient } from "../integration-contract-client.mjs";
4
+
5
+ /**
6
+ * @param {{json?: boolean}} options
7
+ * @param {{profile?: import('../config.mjs').Profile, fetchImpl?: typeof fetch, log(message: string): void}} deps
8
+ */
9
+ export async function showContract(options, deps) {
10
+ const profile = deps.profile ?? "prod";
11
+ const contract = await new IntegrationContractClient({
12
+ docsUrl: getDocsUrl(profile), environment: profile, fetchImpl: deps.fetchImpl,
13
+ }).getContract();
14
+ if (options.json) deps.log(JSON.stringify(contract, null, 2));
15
+ else {
16
+ const {data} = contract;
17
+ deps.log(`Integration contract: ${data.contractVersion} (schema ${data.schemaVersion})\nEnvironment: ${data.environment}`);
18
+ deps.log(`Challenge service: ${data.services.challengeUrl}`);
19
+ deps.log(`Browser SDK: ${data.browser.sdkVersion}\nScript: ${data.browser.scriptUrl}\nIntegrity: ${data.browser.integrity}\nToken header: ${data.browser.tokenHeader}\nModes: ${data.browser.availableModes.join(", ")}`);
20
+ deps.log(`CSP additions:\n script-src: ${data.browser.cspAdditions.scriptSrc.join(" ")}\n connect-src: ${data.browser.cspAdditions.connectSrc.join(" ")}\n worker-src: ${data.browser.cspAdditions.workerSrc.join(" ")}`);
21
+ deps.log(`Java SDK: ${data.backend.sdkVersion} (JDK ${data.backend.minJdk}+)\nMaven repository: ${data.backend.mavenRepository}\nCore: ${data.backend.core.groupId}:${data.backend.core.artifactId}:${data.backend.sdkVersion}\n${data.backend.springBoot3.framework}: ${data.backend.springBoot3.groupId}:${data.backend.springBoot3.artifactId}:${data.backend.sdkVersion}`);
22
+ deps.log(`Documentation:\n Browser SDK: ${data.documentation.browserSdk}\n Java SDK: ${data.documentation.javaSdk}\n Domains and CSP: ${data.documentation.domainsAndCsp}`);
23
+ }
24
+ return contract;
25
+ }
@@ -1,15 +1,18 @@
1
1
  // @ts-check
2
2
 
3
3
  import { mutationKey, parseIssuance, printIssuance, withMutationRecovery } from "./mutation-support.mjs";
4
+ import { outputContext } from "./output.mjs";
4
5
 
5
6
  /**
6
- * @param {{appName: string, yes?: boolean, idempotencyKey?: string}} options
7
+ * @param {{appName: string, json?: boolean, yes?: boolean, idempotencyKey?: string}} options
7
8
  * @param {import('./mutation-support.mjs').CommandOutput & {api: {createApp(token: string, options: {name: string, idempotencyKey: string}): Promise<import('./mutation-support.mjs').MutationResponse>}, sessions: {loadToken(): Promise<string>}}} deps
8
9
  */
9
10
  export async function createKeys(options, deps) {
11
+ deps = outputContext(options, deps);
10
12
  const idempotencyKey = mutationKey(options.idempotencyKey);
11
13
  const token = await deps.sessions.loadToken();
12
14
  const issuance = await withMutationRecovery(idempotencyKey, deps, async () =>
13
15
  parseIssuance(await deps.api.createApp(token, {name: options.appName, idempotencyKey}), 201));
14
- printIssuance(issuance, deps);
16
+ printIssuance(issuance, deps, options);
17
+ return issuance;
15
18
  }
@@ -1,7 +1,8 @@
1
1
  // @ts-check
2
2
  import { randomUUID } from "node:crypto";
3
3
  import readline from "node:readline/promises";
4
- import { stdin, stdout } from "node:process";
4
+ import { stdin, stderr } from "node:process";
5
+ import { printData } from "./output.mjs";
5
6
  import { CliError } from "../errors.mjs";
6
7
  import { getCommandInvocation } from "../config.mjs";
7
8
  import { APPLICATION_STATUSES, invalidResponse, responseObject } from "./application-response.mjs";
@@ -18,16 +19,17 @@ export function mutationKey(supplied) {
18
19
  return key;
19
20
  }
20
21
 
21
- /** @param {{yes?: boolean}} options @param {CommandOutput} deps @param {string} prompt @param {string} word */
22
+ /** @param {{yes?: boolean, json?: boolean}} options @param {CommandOutput} deps @param {string} prompt @param {string} word */
22
23
  export async function confirmAction(options, deps, prompt, word) {
23
24
  if (options.yes) return;
25
+ if (options.json) throw new CliError("This action requires confirmation; use --yes with --json", {code: "CONFIRMATION_REQUIRED", exitCode: 2});
24
26
  let accepted;
25
27
  if (deps.confirm) accepted = await deps.confirm();
26
28
  else {
27
- if (!stdin.isTTY || !stdout.isTTY) {
29
+ if (!stdin.isTTY || !stderr.isTTY) {
28
30
  throw new CliError("This action requires confirmation; use --yes in a non-interactive terminal", {code: "CONFIRMATION_REQUIRED", exitCode: 2});
29
31
  }
30
- const terminal = readline.createInterface({input: stdin, output: stdout});
32
+ const terminal = readline.createInterface({input: stdin, output: stderr});
31
33
  try { accepted = await terminal.question(`${prompt} Type ${word} to continue: `) === word; }
32
34
  finally { terminal.close(); }
33
35
  }
@@ -81,18 +83,26 @@ export function isApiSecret(value) {
81
83
  return typeof value === "string" && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
82
84
  }
83
85
 
84
- /** @param {{appKey: string, status: string, apiSecret: string | null}} issuance @param {CommandOutput} deps */
85
- export function printIssuance(issuance, deps) {
86
+ /** @param {{appKey: string, status: string, apiSecret: string | null}} issuance @param {CommandOutput} deps @param {{json?: boolean}} [options] */
87
+ export function printIssuance(issuance, deps, options = {}) {
86
88
  const invocation = getCommandInvocation(deps.profile);
89
+ if (options.json) {
90
+ printData(issuance, deps);
91
+ const warn = deps.warn ?? console.error;
92
+ if (issuance.apiSecret === null) warn(`This is an idempotency replay; the API did not return a secret. Run \`${invocation} secret reveal ${issuance.appKey}\` to retrieve the current active secret.`);
93
+ else warn("Store this secret in your backend secret manager. The CLI did not save it.");
94
+ (deps.warn ?? console.error)("Configuration publication is asynchronous; this response does not confirm propagation.");
95
+ return;
96
+ }
87
97
  deps.log(`App Key: ${issuance.appKey}`);
88
98
  deps.log(`Status: ${issuance.status}`);
89
99
  if (issuance.apiSecret === null) {
90
- deps.log(`This is an idempotency replay; the API did not return a secret. Run \`${invocation} keys reveal ${issuance.appKey}\` to retrieve the current active secret.`);
100
+ deps.log(`This is an idempotency replay; the API did not return a secret. Run \`${invocation} secret reveal ${issuance.appKey}\` to retrieve the current active secret.`);
91
101
  } else {
92
102
  deps.log(`Secret Key: ${issuance.apiSecret}`);
93
103
  deps.log("Store this secret in your backend secret manager. The CLI did not save it.");
94
104
  }
95
- deps.log("Configuration publication is asynchronous; this response does not confirm propagation.");
105
+ (deps.warn ?? deps.log)("Configuration publication is asynchronous; this response does not confirm propagation.");
96
106
  }
97
107
 
98
108
  /** @param {MutationResponse} result */
@@ -103,8 +113,12 @@ export function parseAccepted(result) {
103
113
  }
104
114
  }
105
115
 
106
- /** @param {string} action @param {string} appKey @param {CommandOutput} deps */
107
- export function printAccepted(action, appKey, deps) {
108
- deps.log(`${action} accepted for ${appKey}. Configuration publication is asynchronous.`);
109
- deps.log(`Run \`${getCommandInvocation(deps.profile)} apps get ${appKey}\` to check the current application status.`);
116
+ /** @param {string} action @param {string} appKey @param {CommandOutput} deps @param {{json?: boolean}} [options] */
117
+ export function printAccepted(action, appKey, deps, options = {}) {
118
+ const data = {appKey, status: "accepted"};
119
+ if (options.json) printData(data, deps);
120
+ else deps.log(`${action} accepted for ${appKey}.`);
121
+ (deps.warn ?? deps.log)("Configuration publication is asynchronous.");
122
+ (deps.warn ?? deps.log)(`Run \`${getCommandInvocation(deps.profile)} app get ${appKey}\` to check the current application status.`);
123
+ return data;
110
124
  }
@@ -0,0 +1,21 @@
1
+ // @ts-check
2
+
3
+ /** @param {unknown} data @param {{log(message: string): void}} deps */
4
+ export function printData(data, deps) {
5
+ deps.log(JSON.stringify({data}, null, 2));
6
+ }
7
+
8
+ /** Keep diagnostics separate from machine-readable results, including direct callers.
9
+ * @template {{log: (message: string) => void, warn?: (message: string) => void}} T
10
+ * @param {{json?: boolean}} options @param {T} deps @returns {T}
11
+ */
12
+ export function outputContext(options, deps) {
13
+ return {...deps, warn: deps.warn ?? (options.json ? console.error : deps.log)};
14
+ }
15
+
16
+ /** @param {import('./application-response.mjs').Application} app
17
+ * @param {{json?: boolean}} options @param {{log(message: string): void}} deps */
18
+ export function printApplication(app, options, deps) {
19
+ if (options.json) return printData(app, deps);
20
+ deps.log(`App Key: ${app.appKey}\nName: ${app.name}\nStatus: ${app.status}\nCreated: ${app.createdAt}\nUpdated: ${app.updatedAt}`);
21
+ }
@@ -1,45 +1,52 @@
1
1
  // @ts-check
2
2
  import { invalidResponse, responseObject } from "./application-response.mjs";
3
+ import { outputContext, printData } from "./output.mjs";
3
4
  import { confirmAction, isApiSecret, mutationKey, parseAccepted, parseIssuance, printAccepted, printIssuance, validateHttpStatus, withMutationRecovery } from "./mutation-support.mjs";
4
5
 
5
6
  /** @typedef {import('./mutation-support.mjs').MutationResponse} MutationResponse */
6
7
  /** @typedef {import('./mutation-support.mjs').CommandOutput & {sessions: {loadToken(): Promise<string>}}} Dependencies */
7
- /** @typedef {{appKey: string, yes?: boolean, idempotencyKey?: string}} MutationOptions */
8
+ /** @typedef {{appKey: string, yes?: boolean, json?: boolean, idempotencyKey?: string}} MutationOptions */
8
9
 
9
10
  /** @param {MutationOptions} options
10
11
  * @param {Dependencies & {api: {rotateSecret(token: string, appKey: string, options: {idempotencyKey: string}): Promise<MutationResponse>}}} deps
11
12
  */
12
13
  export async function rotateSecret(options, deps) {
14
+ deps = outputContext(options, deps);
13
15
  await confirmAction(options, deps, `Replace the current secret for ${options.appKey}? Existing backend configuration will need the new secret.`, "ROTATE");
14
16
  const idempotencyKey = mutationKey(options.idempotencyKey);
15
17
  const token = await deps.sessions.loadToken();
16
18
  const issuance = await withMutationRecovery(idempotencyKey, deps, async () =>
17
19
  parseIssuance(await deps.api.rotateSecret(token, options.appKey, {idempotencyKey}), 200, options.appKey));
18
- printIssuance(issuance, deps);
20
+ printIssuance(issuance, deps, options);
21
+ return issuance;
19
22
  }
20
23
 
21
- /** @param {{appKey: string, yes?: boolean}} options
24
+ /** @param {{appKey: string, yes?: boolean, json?: boolean}} options
22
25
  * @param {Dependencies & {api: {revealSecret(token: string, appKey: string): Promise<MutationResponse>}}} deps
23
26
  */
24
27
  export async function revealSecret(options, deps) {
28
+ deps = outputContext(options, deps);
25
29
  await confirmAction(options, deps, `Display the current API secret for ${options.appKey} in this terminal?`, "REVEAL");
26
30
  const token = await deps.sessions.loadToken();
27
31
  const result = await deps.api.revealSecret(token, options.appKey);
28
32
  validateHttpStatus(result, 200);
29
33
  const {apiSecret} = responseObject(result.body);
30
34
  if (!isApiSecret(apiSecret)) throw invalidResponse("The reveal response did not contain a valid API secret");
31
- deps.log(`Secret Key: ${apiSecret}`);
32
- deps.log("Store this secret in your backend secret manager. The CLI did not save it.");
35
+ if (options.json) printData({apiSecret}, deps);
36
+ else deps.log(`Secret Key: ${apiSecret}`);
37
+ (deps.warn ?? deps.log)("Store this secret in your backend secret manager. The CLI did not save it.");
38
+ return {apiSecret};
33
39
  }
34
40
 
35
41
  /** @param {MutationOptions} options
36
42
  * @param {Dependencies & {api: {revokeSecret(token: string, appKey: string, options: {idempotencyKey: string}): Promise<MutationResponse>}}} deps
37
43
  */
38
44
  export async function revokeSecret(options, deps) {
45
+ deps = outputContext(options, deps);
39
46
  await confirmAction(options, deps, `Revoke the current API secret for ${options.appKey}?`, "REVOKE");
40
47
  const idempotencyKey = mutationKey(options.idempotencyKey);
41
48
  const token = await deps.sessions.loadToken();
42
49
  await withMutationRecovery(idempotencyKey, deps, async () =>
43
50
  parseAccepted(await deps.api.revokeSecret(token, options.appKey, {idempotencyKey})));
44
- printAccepted("Secret revocation", options.appKey, deps);
51
+ return printAccepted("Secret revocation", options.appKey, deps, options);
45
52
  }
@@ -4,12 +4,15 @@ import { CliError } from "../errors.mjs";
4
4
  import { REQUESTED_OAUTH_SCOPES, validateAuthorizationIssuer } from "../config.mjs";
5
5
  import { createLoopbackReceiver } from "../oauth-loopback.mjs";
6
6
  import { openBrowser } from "../browser-opener.mjs";
7
+ import { printData, outputContext } from "./output.mjs";
7
8
 
8
9
  /** @typedef {{
9
10
  * oauth: Pick<import('../oauth-client.mjs').OAuthClient, 'discover' | 'exchangeCode' | 'config'>,
10
11
  * authorizationIssuer?: string,
11
12
  * sessions: {save(credentials: import('../oauth-client.mjs').OAuthCredentials): Promise<void>},
12
13
  * log: (message: string) => void,
14
+ * warn?: (message: string) => void,
15
+ * profile?: import('../config.mjs').Profile,
13
16
  * openBrowser?: typeof openBrowser,
14
17
  * signal?: AbortSignal,
15
18
  * timeoutMs?: number,
@@ -17,8 +20,11 @@ import { openBrowser } from "../browser-opener.mjs";
17
20
  * }} SigninDependencies
18
21
  */
19
22
 
20
- /** @param {{noOpen?: boolean}} options @param {SigninDependencies} deps */
21
- export async function signin({noOpen = false}, deps) {
23
+ /** @param {{noOpen?: boolean, json?: boolean}} options @param {SigninDependencies} deps */
24
+ export async function signin(options, deps) {
25
+ const {noOpen = false} = options;
26
+ deps = outputContext(options, deps);
27
+ const progress = deps.warn ?? deps.log;
22
28
  const authorizationIssuer = validateAuthorizationIssuer(deps.authorizationIssuer ?? deps.oauth.config.issuer);
23
29
  const controller = new AbortController();
24
30
  let expired = false;
@@ -45,13 +51,13 @@ export async function signin({noOpen = false}, deps) {
45
51
  code_challenge_method: "S256", state, scope: REQUESTED_OAUTH_SCOPES.join(" "),
46
52
  }).toString();
47
53
  if (noOpen) {
48
- deps.log(`Open this authorization URL in a browser on this computer:\n${authorizeUrl.href}`);
54
+ progress(`Open this authorization URL in a browser on this computer:\n${authorizeUrl.href}`);
49
55
  } else {
50
- deps.log("Opening your browser to sign in. Complete authorization on this computer.");
56
+ progress("Opening your browser to sign in. Complete authorization on this computer.");
51
57
  try { await (deps.openBrowser ?? openBrowser)(authorizeUrl.href, {signal}); }
52
58
  catch {
53
59
  signal.throwIfAborted();
54
- deps.log(`Open this authorization URL in a browser on this computer:\n${authorizeUrl.href}`);
60
+ progress(`Open this authorization URL in a browser on this computer:\n${authorizeUrl.href}`);
55
61
  }
56
62
  }
57
63
  const {code} = await receiver.result;
@@ -61,14 +67,17 @@ export async function signin({noOpen = false}, deps) {
61
67
  await deps.sessions.save(credentials);
62
68
  signal.throwIfAborted();
63
69
  receiver.complete(true);
64
- deps.log("Signed in successfully. The session is stored for the current user.");
70
+ const result = {profile: deps.profile ?? "prod", signedIn: true};
71
+ if (options.json) printData(result, deps);
72
+ else deps.log("Signed in successfully. The session is stored for the current user.");
73
+ return result;
65
74
  } catch (error) {
66
75
  receiver?.complete(false);
67
76
  if (controller.signal.aborted) throw new CliError(expired
68
- ? "Sign-in expired; run `requestshield signin` again"
77
+ ? "Sign-in expired; run `requestshield login` again"
69
78
  : "Sign-in was cancelled", {code: expired ? "SIGNIN_EXPIRED" : "SIGNIN_CANCELLED", exitCode: 3});
70
79
  if (error instanceof CliError) throw error;
71
- throw new CliError("Sign-in failed; run `requestshield signin` again", {code: "SIGNIN_FAILED", exitCode: 3});
80
+ throw new CliError("Sign-in failed; run `requestshield login` again", {code: "SIGNIN_FAILED", exitCode: 3});
72
81
  } finally {
73
82
  clearTimeout(timer);
74
83
  interruptSource.removeListener("SIGINT", cancel);
@@ -1,9 +1,13 @@
1
1
  // @ts-check
2
2
 
3
- /** @param {{}} _options
3
+ import { printData } from "./output.mjs";
4
+
5
+ /** @param {{json?: boolean}} options
4
6
  * @param {{sessions: {signout(): Promise<{profile: import('../config.mjs').Profile, removed: boolean}>}, log: (message: string) => void}} deps
5
7
  */
6
- export async function signout(_options, deps) {
8
+ export async function signout(options, deps) {
7
9
  const { profile, removed } = await deps.sessions.signout();
8
- deps.log(removed ? `Signed out locally from ${profile}.` : `No saved local session for ${profile}.`);
10
+ if (options.json) printData({profile, removed}, deps);
11
+ else deps.log(removed ? `Signed out locally from ${profile}.` : `No saved local session for ${profile}.`);
12
+ return {profile, removed};
9
13
  }
@@ -2,46 +2,93 @@
2
2
 
3
3
  import readline from "node:readline/promises";
4
4
  import { spawn } from "node:child_process";
5
- import { stdin, stdout } from "node:process";
5
+ import { stdin, stderr } from "node:process";
6
6
  import { CliError } from "../errors.mjs";
7
7
 
8
8
  const DEFAULT_REGISTRY = "https://registry.npmjs.org";
9
9
 
10
10
  /**
11
- * @param {{ currentVersion: string, packageName: string, profile?: "qat" | "stg" | "prod", log: (message: string) => void, fetchImpl?: typeof fetch, confirm?: (latestVersion: string) => Promise<boolean>, install?: (packageName: string, version: string) => Promise<void> }} options
11
+ * @typedef {{ currentVersion: string, packageName: string, profile?: "qat" | "stg" | "prod", json?: boolean, yes?: boolean, log: (message: string) => void, warn?: (message: string) => void, fetchImpl?: typeof fetch, confirm?: (latestVersion: string) => Promise<boolean>, install?: (packageName: string, version: string) => Promise<void> }} UpdateOptions
12
+ * @typedef {{ profile: "qat" | "stg" | "prod", currentVersion: string, latestVersion: string | null, updateAvailable: boolean | null, action: "check" | "apply", outcome: "source_checkout" | "up_to_date" | "available" | "installed" | "cancelled", target?: string }} UpdateResult
12
13
  */
14
+
15
+ /** @param {UpdateOptions} options */
13
16
  export async function checkForUpdate(options) {
17
+ const result = await inspectUpdate(options, "check");
18
+ printResult(result, options);
19
+ return result;
20
+ }
21
+
22
+ /** @param {UpdateOptions} options */
23
+ export async function applyUpdate(options) {
24
+ const result = await inspectUpdate(options, "apply");
25
+ if (result.outcome !== "available" || result.latestVersion === null) {
26
+ printResult(result, options);
27
+ return result;
28
+ }
29
+
30
+ const warn = options.warn ?? ((message) => console.error(message));
31
+ warn(`Update target: global npm installation of ${options.packageName}.`);
32
+ if (options.json && !options.yes) {
33
+ throw confirmationRequired();
34
+ }
35
+ const accepted = options.yes || await (options.confirm ?? confirmUpdate)(result.latestVersion);
36
+ if (!accepted) {
37
+ result.outcome = "cancelled";
38
+ printResult(result, options);
39
+ return result;
40
+ }
41
+
42
+ warn(`Installing RequestShield ${result.latestVersion} globally with npm...`);
43
+ await (options.install ?? installWithNpm)(options.packageName, result.latestVersion);
44
+ result.outcome = "installed";
45
+ printResult(result, options);
46
+ return result;
47
+ }
48
+
49
+ /** @param {UpdateOptions} options @param {"check" | "apply"} action @returns {Promise<UpdateResult>} */
50
+ async function inspectUpdate(options, action) {
14
51
  const profile = options.profile ?? "prod";
15
52
  if (profile !== "prod") {
16
- options.log(`requestshield-${profile} runs this local repository checkout and has no separately published npm release.`);
17
- options.log("Update the checkout with your normal Git workflow.");
18
- options.log(`Then run from requestshield-cli/: npx --offline --prefix ./dev requestshield-${profile} <command>`);
19
- return;
53
+ return { profile, currentVersion: options.currentVersion, latestVersion: null, updateAvailable: null, action, outcome: "source_checkout" };
20
54
  }
21
- const latestVersion = await fetchLatestVersion(
22
- options.packageName,
23
- options.fetchImpl ?? fetch,
24
- );
25
-
26
- options.log(`Current version: ${options.currentVersion}`);
27
- options.log(`Latest version: ${latestVersion}`);
55
+ const latestVersion = await fetchLatestVersion(options.packageName, options.fetchImpl ?? fetch);
56
+ const updateAvailable = compareVersions(latestVersion, options.currentVersion) > 0;
57
+ return {
58
+ profile,
59
+ currentVersion: options.currentVersion,
60
+ latestVersion,
61
+ updateAvailable,
62
+ action,
63
+ outcome: updateAvailable ? "available" : "up_to_date",
64
+ target: `global npm installation of ${options.packageName}`,
65
+ };
66
+ }
28
67
 
29
- if (compareVersions(latestVersion, options.currentVersion) <= 0) {
30
- options.log("RequestShield is already up to date.");
68
+ /** @param {UpdateResult} result @param {UpdateOptions} options */
69
+ function printResult(result, options) {
70
+ if (options.json) {
71
+ options.log(JSON.stringify({ data: result }, null, 2));
31
72
  return;
32
73
  }
33
-
34
- options.log(`Update target: global npm installation of ${options.packageName}.`);
35
- const accepted = await (options.confirm ?? confirmUpdate)(latestVersion);
36
- if (!accepted) {
37
- options.log("Update cancelled.");
74
+ if (result.outcome === "source_checkout") {
75
+ options.log(`requestshield-${result.profile} runs this local repository checkout and has no separately published npm release.`);
76
+ options.log("Update the checkout with your normal Git workflow.");
77
+ options.log(`Then run from requestshield-cli/: npx --offline --prefix ./dev requestshield-${result.profile} <command>`);
38
78
  return;
39
79
  }
40
-
41
- options.log(`Installing RequestShield ${latestVersion} globally with npm...`);
42
- await (options.install ?? installWithNpm)(options.packageName, latestVersion);
43
- options.log(`RequestShield ${latestVersion} was installed successfully (global npm installation).`);
44
- options.log("Open a new terminal before running RequestShield again.");
80
+ options.log(`Current version: ${result.currentVersion}`);
81
+ options.log(`Latest version: ${result.latestVersion}`);
82
+ if (result.outcome === "up_to_date") options.log("RequestShield is already up to date.");
83
+ if (result.outcome === "available") {
84
+ options.log("Run requestshield update apply to install the update.");
85
+ options.log(`Update target: ${result.target}.`);
86
+ }
87
+ if (result.outcome === "cancelled") options.log("Update cancelled.");
88
+ if (result.outcome === "installed") {
89
+ options.log(`RequestShield ${result.latestVersion} was installed successfully (global npm installation).`);
90
+ options.log("Open a new terminal before running RequestShield again.");
91
+ }
45
92
  }
46
93
 
47
94
  /** @param {string} packageName @param {typeof fetch} fetchImpl */
@@ -50,8 +97,8 @@ async function fetchLatestVersion(packageName, fetchImpl) {
50
97
  let response;
51
98
  try {
52
99
  response = await fetchImpl(url, { signal: AbortSignal.timeout(15_000) });
53
- } catch (error) {
54
- throw new CliError(`Could not check the latest RequestShield version: ${messageOf(error)}`, {
100
+ } catch {
101
+ throw new CliError("Could not check the latest RequestShield version. Check your connection and retry.", {
55
102
  code: "UPDATE_CHECK_FAILED",
56
103
  exitCode: 7,
57
104
  });
@@ -64,7 +111,14 @@ async function fetchLatestVersion(packageName, fetchImpl) {
64
111
  });
65
112
  }
66
113
 
67
- const body = await response.json();
114
+ let body;
115
+ try {
116
+ body = await response.json();
117
+ } catch {
118
+ throw new CliError("The npm registry returned an invalid RequestShield version", {
119
+ code: "INVALID_UPDATE_RESPONSE",
120
+ });
121
+ }
68
122
  const version = body && typeof body === "object" ? Reflect.get(body, "version") : undefined;
69
123
  if (typeof version !== "string" || !parseVersion(version)) {
70
124
  throw new CliError("The npm registry returned an invalid RequestShield version", {
@@ -76,13 +130,8 @@ async function fetchLatestVersion(packageName, fetchImpl) {
76
130
 
77
131
  /** @param {string} latestVersion */
78
132
  async function confirmUpdate(latestVersion) {
79
- if (!stdin.isTTY || !stdout.isTTY) {
80
- throw new CliError(`RequestShield ${latestVersion} is available, but confirmation requires an interactive terminal`, {
81
- code: "UPDATE_CONFIRMATION_REQUIRED",
82
- exitCode: 2,
83
- });
84
- }
85
- const prompt = readline.createInterface({ input: stdin, output: stdout });
133
+ if (!stdin.isTTY || !stderr.isTTY) throw confirmationRequired();
134
+ const prompt = readline.createInterface({ input: stdin, output: stderr });
86
135
  try {
87
136
  const answer = await prompt.question(`Install RequestShield ${latestVersion} globally with npm? (y/N): `);
88
137
  return answer.trim().toLowerCase() === "y";
@@ -91,16 +140,26 @@ async function confirmUpdate(latestVersion) {
91
140
  }
92
141
  }
93
142
 
143
+ function confirmationRequired() {
144
+ return new CliError("Installing an update requires confirmation. Use an interactive terminal or pass --yes to requestshield update apply.", {
145
+ code: "UPDATE_CONFIRMATION_REQUIRED",
146
+ exitCode: 2,
147
+ });
148
+ }
149
+
94
150
  /** @param {string} packageName @param {string} version */
95
151
  async function installWithNpm(packageName, version) {
96
152
  await new Promise((resolve, reject) => {
97
153
  const child = spawn("npm", ["install", "--global", `${packageName}@${version}`], {
98
154
  shell: process.platform === "win32",
99
- stdio: "inherit",
155
+ // npm's output is diagnostic. Keep command stdout available for JSON.
156
+ stdio: ["ignore", "pipe", "pipe"],
100
157
  windowsHide: true,
101
158
  });
102
- child.once("error", (error) => {
103
- reject(new CliError(`Could not start npm: ${error.message}`, {
159
+ child.stdout?.pipe(stderr, { end: false });
160
+ child.stderr?.pipe(stderr, { end: false });
161
+ child.once("error", () => {
162
+ reject(new CliError("Could not start npm. Check that npm is installed and available.", {
104
163
  code: "UPDATE_FAILED",
105
164
  exitCode: 1,
106
165
  }));
@@ -139,9 +198,4 @@ function parseVersion(value) {
139
198
  };
140
199
  }
141
200
 
142
- /** @param {unknown} error */
143
- function messageOf(error) {
144
- return error instanceof Error ? error.message : String(error);
145
- }
146
-
147
201
  export { compareVersions };