@proxy-checkout/cli 0.1.0-prx-128.104.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/cjs/auth.d.cts +38 -0
  4. package/dist/cjs/auth.d.ts +38 -0
  5. package/dist/cjs/auth.js +258 -0
  6. package/dist/cjs/bin.d.cts +2 -0
  7. package/dist/cjs/bin.d.ts +2 -0
  8. package/dist/cjs/bin.js +10 -0
  9. package/dist/cjs/cli.d.cts +10 -0
  10. package/dist/cjs/cli.d.ts +10 -0
  11. package/dist/cjs/cli.js +469 -0
  12. package/dist/cjs/command-schema.d.cts +23 -0
  13. package/dist/cjs/command-schema.d.ts +23 -0
  14. package/dist/cjs/command-schema.js +238 -0
  15. package/dist/cjs/config.d.cts +55 -0
  16. package/dist/cjs/config.d.ts +55 -0
  17. package/dist/cjs/config.js +339 -0
  18. package/dist/cjs/errors.d.cts +20 -0
  19. package/dist/cjs/errors.d.ts +20 -0
  20. package/dist/cjs/errors.js +52 -0
  21. package/dist/cjs/http-client.d.cts +25 -0
  22. package/dist/cjs/http-client.d.ts +25 -0
  23. package/dist/cjs/http-client.js +102 -0
  24. package/dist/cjs/index.d.cts +5 -0
  25. package/dist/cjs/index.d.ts +5 -0
  26. package/dist/cjs/index.js +17 -0
  27. package/dist/cjs/output.d.cts +19 -0
  28. package/dist/cjs/output.d.ts +19 -0
  29. package/dist/cjs/output.js +76 -0
  30. package/dist/cjs/package.json +3 -0
  31. package/dist/cjs/webhooks.d.cts +81 -0
  32. package/dist/cjs/webhooks.d.ts +81 -0
  33. package/dist/cjs/webhooks.js +343 -0
  34. package/dist/esm/auth.d.ts +38 -0
  35. package/dist/esm/auth.js +253 -0
  36. package/dist/esm/bin.d.ts +2 -0
  37. package/dist/esm/bin.js +8 -0
  38. package/dist/esm/cli.d.ts +10 -0
  39. package/dist/esm/cli.js +465 -0
  40. package/dist/esm/command-schema.d.ts +23 -0
  41. package/dist/esm/command-schema.js +232 -0
  42. package/dist/esm/config.d.ts +55 -0
  43. package/dist/esm/config.js +333 -0
  44. package/dist/esm/errors.d.ts +20 -0
  45. package/dist/esm/errors.js +46 -0
  46. package/dist/esm/http-client.d.ts +25 -0
  47. package/dist/esm/http-client.js +98 -0
  48. package/dist/esm/index.d.ts +5 -0
  49. package/dist/esm/index.js +5 -0
  50. package/dist/esm/output.d.ts +19 -0
  51. package/dist/esm/output.js +71 -0
  52. package/dist/esm/webhooks.d.ts +81 -0
  53. package/dist/esm/webhooks.js +337 -0
  54. package/package.json +68 -0
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProxyWebhooksClient = void 0;
4
+ exports.runListener = runListener;
5
+ exports.forwardSelectedDelivery = forwardSelectedDelivery;
6
+ const node_child_process_1 = require("node:child_process");
7
+ const errors_js_1 = require("./errors.js");
8
+ const http_client_js_1 = require("./http-client.js");
9
+ const output_js_1 = require("./output.js");
10
+ const claimPollIntervalMs = 1_000;
11
+ const heartbeatIntervalMs = 30_000;
12
+ const localForwardTimeoutMs = 30_000;
13
+ const stripeEvents = [
14
+ "checkout.session.completed",
15
+ "customer.subscription.deleted",
16
+ "customer.subscription.updated",
17
+ "invoice.paid",
18
+ "invoice.payment_failed",
19
+ "payment_intent.canceled",
20
+ "payment_intent.payment_failed",
21
+ "payment_intent.succeeded",
22
+ ].join(",");
23
+ class ProxyWebhooksClient {
24
+ api;
25
+ constructor(apiBaseUrl, token, fetchImpl) {
26
+ this.api = new http_client_js_1.ProxyApiClient(apiBaseUrl, token, fetchImpl);
27
+ }
28
+ async listEndpoints() {
29
+ return (await this.api.request("/cli/webhook-endpoints")).webhook_endpoints;
30
+ }
31
+ async createListener(sourceEndpointId) {
32
+ return (await this.api.request("/cli/webhook-listeners", {
33
+ body: { source_webhook_endpoint_id: sourceEndpointId },
34
+ method: "POST",
35
+ })).listener;
36
+ }
37
+ async getListener(listenerId) {
38
+ return (await this.api.request(`/cli/webhook-listeners/${encodeURIComponent(listenerId)}`)).listener;
39
+ }
40
+ async heartbeat(listenerId) {
41
+ return (await this.api.request(`/cli/webhook-listeners/${encodeURIComponent(listenerId)}/heartbeat`, { method: "POST" })).listener;
42
+ }
43
+ async close(listenerId) {
44
+ return (await this.api.request(`/cli/webhook-listeners/${encodeURIComponent(listenerId)}/close`, { method: "POST" })).listener;
45
+ }
46
+ async listDeliveries(listenerId, limit) {
47
+ const query = limit ? `?${new URLSearchParams({ limit: String(limit) })}` : "";
48
+ return (await this.api.request(`/cli/webhook-listeners/${encodeURIComponent(listenerId)}/deliveries${query}`)).deliveries;
49
+ }
50
+ async replay(listenerId, deliveryId, reason) {
51
+ return this.api.request(`/cli/webhook-listeners/${encodeURIComponent(listenerId)}/deliveries/${encodeURIComponent(deliveryId)}/replay`, { body: { reason }, method: "POST" });
52
+ }
53
+ async claim(listenerId, selection = {}) {
54
+ return (await this.api.request(`/cli/webhook-listeners/${encodeURIComponent(listenerId)}/claims`, {
55
+ method: "POST",
56
+ body: {
57
+ ...(selection.deliveryId ? { delivery_id: selection.deliveryId } : {}),
58
+ ...(selection.replayOutboxEventId
59
+ ? { replay_outbox_event_id: selection.replayOutboxEventId }
60
+ : {}),
61
+ },
62
+ })).claim;
63
+ }
64
+ async complete(attemptId, input) {
65
+ return this.api.request(`/cli/webhook-delivery-attempts/${encodeURIComponent(attemptId)}/complete`, {
66
+ method: "POST",
67
+ body: {
68
+ duration_ms: input.durationMs,
69
+ error_class: input.errorClass,
70
+ response_status_code: input.responseStatusCode,
71
+ },
72
+ });
73
+ }
74
+ async createStripeIngress(listenerId, webhookSecret, pspConfigId) {
75
+ return (await this.api.request(`/cli/webhook-listeners/${encodeURIComponent(listenerId)}/stripe-ingress`, {
76
+ method: "POST",
77
+ body: {
78
+ ...(pspConfigId ? { psp_config_id: pspConfigId } : {}),
79
+ webhook_secret: webhookSecret,
80
+ },
81
+ })).stripe_ingress;
82
+ }
83
+ }
84
+ exports.ProxyWebhooksClient = ProxyWebhooksClient;
85
+ async function runListener(input) {
86
+ const destination = validateLoopbackDestination(input.forwardTo);
87
+ const listener = await input.client.createListener(input.sourceEndpointId);
88
+ let stripeProcess;
89
+ let forwarded = 0;
90
+ let heartbeatFailure;
91
+ const heartbeatTimer = setInterval(() => {
92
+ void input.client.heartbeat(listener.id).catch((error) => {
93
+ heartbeatFailure = error;
94
+ });
95
+ }, heartbeatIntervalMs);
96
+ input.output.event("listener.started", { forward_to: destination.toString(), listener }, `Listening for Proxy test webhooks on ${listener.id}; forwarding to ${destination}`);
97
+ try {
98
+ if (input.signal.aborted)
99
+ throw listenerInterrupted();
100
+ if (input.stripe) {
101
+ const webhookSecret = await captureStripeWebhookSecret(input.signal);
102
+ const ingress = await input.client.createStripeIngress(listener.id, webhookSecret, input.pspConfigId);
103
+ stripeProcess = startStripeListener(ingress.inbound_url, input.output);
104
+ input.output.event("stripe.started", {
105
+ expires_at: ingress.expires_at,
106
+ ingress_id: ingress.id,
107
+ merchant_psp_config_id: ingress.merchant_psp_config_id,
108
+ }, "Stripe CLI test webhook orchestration is active.");
109
+ }
110
+ if (input.signal.aborted)
111
+ throw listenerInterrupted();
112
+ while (!input.signal.aborted && (input.maxEvents === 0 || forwarded < input.maxEvents)) {
113
+ if (heartbeatFailure) {
114
+ throw heartbeatFailure;
115
+ }
116
+ if (stripeProcess && (stripeProcess.exitCode !== null || stripeProcess.signalCode)) {
117
+ throw new errors_js_1.CliError("Stripe CLI listener exited unexpectedly", "stripe_cli_exited", errors_js_1.cliExitCodes.dependency, { exit_code: stripeProcess.exitCode, signal: stripeProcess.signalCode });
118
+ }
119
+ const claim = await input.client.claim(listener.id);
120
+ if (!claim) {
121
+ await abortableDelay(claimPollIntervalMs, input.signal);
122
+ continue;
123
+ }
124
+ const completion = await forwardClaim(input.client, claim, destination, input.signal);
125
+ forwarded += 1;
126
+ input.output.event("delivery.forwarded", {
127
+ attempt_id: claim.attempt_id,
128
+ attempt_number: claim.attempt_number,
129
+ completion,
130
+ kind: claim.kind,
131
+ webhook_delivery_id: claim.webhook_delivery_id,
132
+ }, `Forwarded ${claim.kind} ${claim.webhook_delivery_id} (${formatCompletion(completion)}).`);
133
+ }
134
+ if (input.signal.aborted)
135
+ throw listenerInterrupted();
136
+ return { forwarded, listener };
137
+ }
138
+ finally {
139
+ clearInterval(heartbeatTimer);
140
+ if (stripeProcess && stripeProcess.exitCode === null && !stripeProcess.signalCode) {
141
+ stripeProcess.kill("SIGTERM");
142
+ await waitForExit(stripeProcess, 2_000);
143
+ if (stripeProcess.exitCode === null && !stripeProcess.signalCode) {
144
+ stripeProcess.kill("SIGKILL");
145
+ }
146
+ }
147
+ try {
148
+ const closed = await input.client.close(listener.id);
149
+ input.output.event("listener.closed", { listener: closed }, `Closed local webhook listener ${listener.id}.`);
150
+ }
151
+ catch (error) {
152
+ input.output.event("listener.cleanup_failed", {
153
+ error_class: error instanceof Error ? error.name : typeof error,
154
+ listener_id: listener.id,
155
+ }, `Could not confirm cleanup for listener ${listener.id}; its short lease will expire automatically.`);
156
+ }
157
+ }
158
+ }
159
+ async function forwardSelectedDelivery(input) {
160
+ const claim = await input.client.claim(input.listenerId, {
161
+ ...(input.deliveryId ? { deliveryId: input.deliveryId } : {}),
162
+ ...(input.replayOutboxEventId ? { replayOutboxEventId: input.replayOutboxEventId } : {}),
163
+ });
164
+ if (!claim) {
165
+ throw new errors_js_1.CliError("Selected delivery or replay is not currently claimable", "cli_delivery_not_claimable", errors_js_1.cliExitCodes.conflict);
166
+ }
167
+ const completion = await forwardClaim(input.client, claim, validateLoopbackDestination(input.forwardTo), input.signal);
168
+ return {
169
+ attempt_id: claim.attempt_id,
170
+ attempt_number: claim.attempt_number,
171
+ completion,
172
+ kind: claim.kind,
173
+ webhook_delivery_id: claim.webhook_delivery_id,
174
+ };
175
+ }
176
+ async function forwardClaim(client, claim, destination, signal) {
177
+ const startedAt = Date.now();
178
+ let errorClass = null;
179
+ let responseStatusCode = null;
180
+ try {
181
+ const timeoutSignal = AbortSignal.timeout(localForwardTimeoutMs);
182
+ const response = await fetch(destination, {
183
+ body: claim.body,
184
+ headers: claim.headers,
185
+ method: "POST",
186
+ redirect: "manual",
187
+ signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
188
+ });
189
+ responseStatusCode = response.status;
190
+ await response.body?.cancel().catch(() => undefined);
191
+ }
192
+ catch (error) {
193
+ errorClass =
194
+ !signal?.aborted &&
195
+ error instanceof Error &&
196
+ (error.name === "AbortError" || error.name === "TimeoutError")
197
+ ? "timeout"
198
+ : "network_error";
199
+ }
200
+ return client.complete(claim.attempt_id, {
201
+ durationMs: Math.min(Date.now() - startedAt, 300_000),
202
+ errorClass,
203
+ responseStatusCode,
204
+ });
205
+ }
206
+ async function captureStripeWebhookSecret(signal) {
207
+ const result = await captureProcess("stripe", ["listen", "--events", stripeEvents, "--print-secret"], signal, 30_000);
208
+ if (signal.aborted)
209
+ throw listenerInterrupted();
210
+ const match = `${result.stdout}\n${result.stderr}`.match(/\bwhsec_[A-Za-z0-9_-]{8,512}\b/);
211
+ if (result.code !== 0 || !match) {
212
+ throw new errors_js_1.CliError("Stripe CLI could not provide a test webhook signing secret; authenticate Stripe CLI and try again", "stripe_cli_auth_required", errors_js_1.cliExitCodes.dependency, { exit_code: result.code, stderr: (0, output_js_1.redactText)(result.stderr).slice(0, 400) });
213
+ }
214
+ return match[0];
215
+ }
216
+ function listenerInterrupted() {
217
+ return new errors_js_1.CliError("Local webhook listener interrupted", "cli_listener_interrupted", errors_js_1.cliExitCodes.interrupted);
218
+ }
219
+ function startStripeListener(inboundUrl, output) {
220
+ const child = (0, node_child_process_1.spawn)("stripe", ["listen", "--events", stripeEvents, "--forward-to", inboundUrl], {
221
+ shell: false,
222
+ stdio: ["pipe", "pipe", "pipe"],
223
+ });
224
+ child.stdin.end();
225
+ pipeSanitized(child.stdout, (line) => output.notice(`[stripe] ${line}`));
226
+ pipeSanitized(child.stderr, (line) => output.notice(`[stripe] ${line}`));
227
+ child.once("error", () => undefined);
228
+ return child;
229
+ }
230
+ function pipeSanitized(stream, writeLine) {
231
+ let pending = "";
232
+ stream.setEncoding("utf8");
233
+ stream.on("data", (chunk) => {
234
+ pending += chunk;
235
+ const lines = pending.split(/\r?\n/);
236
+ pending = lines.pop() ?? "";
237
+ for (const line of lines) {
238
+ if (line)
239
+ writeLine((0, output_js_1.redactText)(line));
240
+ }
241
+ if (pending.length > 8_192) {
242
+ writeLine((0, output_js_1.redactText)(pending.slice(0, 8_192)));
243
+ pending = "";
244
+ }
245
+ });
246
+ stream.on("end", () => {
247
+ if (pending)
248
+ writeLine((0, output_js_1.redactText)(pending));
249
+ });
250
+ }
251
+ async function captureProcess(command, args, signal, timeoutMs) {
252
+ return new Promise((resolve, reject) => {
253
+ const child = (0, node_child_process_1.spawn)(command, args, { shell: false, stdio: ["pipe", "pipe", "pipe"] });
254
+ let stdout = "";
255
+ let stderr = "";
256
+ const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
257
+ const abort = () => child.kill("SIGTERM");
258
+ signal.addEventListener("abort", abort, { once: true });
259
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
260
+ stdout = `${stdout}${chunk}`.slice(-65_536);
261
+ });
262
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
263
+ stderr = `${stderr}${chunk}`.slice(-65_536);
264
+ });
265
+ child.on("error", (error) => {
266
+ clearTimeout(timer);
267
+ signal.removeEventListener("abort", abort);
268
+ if (error.code === "ENOENT") {
269
+ reject(new errors_js_1.CliError("Stripe CLI is not installed or not on PATH", "stripe_cli_not_found", errors_js_1.cliExitCodes.dependency));
270
+ return;
271
+ }
272
+ reject(error);
273
+ });
274
+ child.on("close", (code) => {
275
+ clearTimeout(timer);
276
+ signal.removeEventListener("abort", abort);
277
+ resolve({ code: code ?? 1, stderr, stdout });
278
+ });
279
+ child.stdin.end();
280
+ });
281
+ }
282
+ function validateLoopbackDestination(value) {
283
+ let url;
284
+ try {
285
+ url = new URL(value);
286
+ }
287
+ catch {
288
+ throw new errors_js_1.CliError("Forward destination must be a valid loopback URL", "cli_forward_destination_invalid", errors_js_1.cliExitCodes.usage);
289
+ }
290
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
291
+ if ((url.protocol !== "http:" && url.protocol !== "https:") ||
292
+ !["127.0.0.1", "::1", "localhost"].includes(hostname) ||
293
+ url.username ||
294
+ url.password ||
295
+ url.hash) {
296
+ throw new errors_js_1.CliError("Forward destination must be an HTTP(S) loopback URL without credentials or a fragment", "cli_forward_destination_invalid", errors_js_1.cliExitCodes.usage);
297
+ }
298
+ return url;
299
+ }
300
+ function formatCompletion(completion) {
301
+ if (completion &&
302
+ typeof completion === "object" &&
303
+ "completion" in completion &&
304
+ completion.completion &&
305
+ typeof completion.completion === "object" &&
306
+ "status" in completion.completion) {
307
+ return String(completion.completion.status);
308
+ }
309
+ return "completed";
310
+ }
311
+ function abortableDelay(milliseconds, signal) {
312
+ if (signal.aborted)
313
+ return Promise.resolve();
314
+ return new Promise((resolve) => {
315
+ const onAbort = () => {
316
+ clearTimeout(timer);
317
+ signal.removeEventListener("abort", onAbort);
318
+ resolve();
319
+ };
320
+ const timer = setTimeout(() => {
321
+ signal.removeEventListener("abort", onAbort);
322
+ resolve();
323
+ }, milliseconds);
324
+ signal.addEventListener("abort", onAbort, { once: true });
325
+ });
326
+ }
327
+ function waitForExit(child, timeoutMs) {
328
+ if (child.exitCode !== null || child.signalCode)
329
+ return Promise.resolve();
330
+ return new Promise((resolve) => {
331
+ const onExit = () => {
332
+ clearTimeout(timer);
333
+ resolve();
334
+ };
335
+ const timer = setTimeout(() => {
336
+ child.removeListener("exit", onExit);
337
+ resolve();
338
+ }, timeoutMs);
339
+ child.once("exit", onExit);
340
+ if (child.exitCode !== null || child.signalCode)
341
+ onExit();
342
+ });
343
+ }
@@ -0,0 +1,38 @@
1
+ import type { CliConfigStore, CliProfile } from "./config.js";
2
+ import type { CliOutput } from "./output.js";
3
+ interface CredentialExchangeResponse {
4
+ credential: string;
5
+ profile: {
6
+ api_version: string;
7
+ capability_version: number;
8
+ credential_id: string;
9
+ expires_at: string | null;
10
+ merchant_id: string;
11
+ mode: "test" | "live";
12
+ name: string;
13
+ organization_id: string;
14
+ };
15
+ }
16
+ export declare function login(input: {
17
+ apiBaseUrl: string;
18
+ configStore: CliConfigStore;
19
+ expectedMerchantId?: string;
20
+ expectedMode?: "live" | "test";
21
+ name?: string;
22
+ noBrowser: boolean;
23
+ output: CliOutput;
24
+ profileName: string;
25
+ signal?: AbortSignal;
26
+ }): Promise<CliProfile>;
27
+ export declare function status(input: {
28
+ apiBaseUrl: string;
29
+ token: string;
30
+ }): Promise<CredentialExchangeResponse["profile"]>;
31
+ export declare function logout(input: {
32
+ apiBaseUrl: string;
33
+ configStore: CliConfigStore;
34
+ profileName: string;
35
+ removeLocalProfile: boolean;
36
+ token: string;
37
+ }): Promise<void>;
38
+ export {};
@@ -0,0 +1,253 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname, platform } from "node:os";
3
+ import { CliError, cliExitCodes } from "./errors.js";
4
+ import { ProxyApiClient } from "./http-client.js";
5
+ const clientId = "proxy-cli";
6
+ const deviceScope = "proxy.cli.local-webhook-development";
7
+ const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
8
+ export async function login(input) {
9
+ const signal = input.signal ?? new AbortController().signal;
10
+ const client = new ProxyApiClient(input.apiBaseUrl);
11
+ const codeResult = await client.oauthRequest("/api/auth/device/code", {
12
+ client_id: clientId,
13
+ scope: deviceScope,
14
+ }, { signal });
15
+ const device = requireDeviceCode(codeResult);
16
+ const verificationUrl = validateVerificationUrl(device.verification_uri_complete);
17
+ input.output.event("auth.device_code", {
18
+ expires_in: device.expires_in,
19
+ user_code: device.user_code,
20
+ verification_uri: device.verification_uri,
21
+ verification_uri_complete: verificationUrl,
22
+ }, `Your pairing code is ${device.user_code}. Approve it at ${verificationUrl}`);
23
+ if (!input.noBrowser) {
24
+ const opened = await openBrowser(verificationUrl);
25
+ if (!opened) {
26
+ input.output.notice("Could not open a browser automatically; use the approval URL above.");
27
+ }
28
+ }
29
+ const temporarySession = await pollForDeviceToken(client, device, input.output, signal);
30
+ const approved = parseApprovedScope(temporarySession.scope);
31
+ const exchangeClient = new ProxyApiClient(input.apiBaseUrl, temporarySession.access_token);
32
+ const exchanged = await exchangeClient.request("/cli/auth/exchange", {
33
+ method: "POST",
34
+ signal,
35
+ body: {
36
+ approval_id: approved.approvalId,
37
+ merchant_id: approved.merchantId,
38
+ name: normalizeCredentialName(input.name),
39
+ user_code: device.user_code,
40
+ },
41
+ });
42
+ const profile = requireExchangeProfile(exchanged.profile, input.apiBaseUrl);
43
+ if (input.expectedMerchantId && profile.merchantId !== input.expectedMerchantId) {
44
+ const mismatch = new CliError("The approved CLI credential does not match the requested merchant", "cli_merchant_not_allowed", cliExitCodes.permission);
45
+ await revokeExchangedCredential(input.apiBaseUrl, exchanged.credential, profile, mismatch);
46
+ throw mismatch;
47
+ }
48
+ if (input.expectedMode && profile.mode !== input.expectedMode) {
49
+ const mismatch = new CliError("The approved CLI credential does not match the requested mode", input.expectedMode === "live" ? "cli_live_mode_not_enabled" : "cli_mode_not_allowed", cliExitCodes.permission);
50
+ await revokeExchangedCredential(input.apiBaseUrl, exchanged.credential, profile, mismatch);
51
+ throw mismatch;
52
+ }
53
+ try {
54
+ const saved = await input.configStore.saveProfile(input.profileName, profile, exchanged.credential);
55
+ if (saved.credentialStore === "file") {
56
+ input.output.notice("Warning: native credential storage is unavailable; the CLI credential is in an owner-only (0600) file fallback.");
57
+ }
58
+ return saved;
59
+ }
60
+ catch (error) {
61
+ await revokeExchangedCredential(input.apiBaseUrl, exchanged.credential, profile, error);
62
+ throw error;
63
+ }
64
+ }
65
+ async function revokeExchangedCredential(apiBaseUrl, credential, profile, originalError) {
66
+ try {
67
+ await new ProxyApiClient(apiBaseUrl, credential).request("/cli/auth/logout", {
68
+ method: "POST",
69
+ });
70
+ }
71
+ catch (cleanupError) {
72
+ throw new CliError(`Login did not complete and cleanup of credential ${profile.credentialId} could not be confirmed; revoke it in the Proxy Portal`, "cli_credential_cleanup_failed", cliExitCodes.network, {
73
+ cleanup_error_class: cleanupError instanceof Error ? cleanupError.name : typeof cleanupError,
74
+ credential_id: profile.credentialId,
75
+ original_error_class: originalError instanceof Error ? originalError.name : typeof originalError,
76
+ original_error_code: originalError instanceof CliError ? originalError.code : undefined,
77
+ });
78
+ }
79
+ }
80
+ export async function status(input) {
81
+ const response = await new ProxyApiClient(input.apiBaseUrl, input.token).request("/cli/auth/status");
82
+ return response.profile;
83
+ }
84
+ export async function logout(input) {
85
+ await new ProxyApiClient(input.apiBaseUrl, input.token).request("/cli/auth/logout", {
86
+ method: "POST",
87
+ });
88
+ if (input.removeLocalProfile) {
89
+ await input.configStore.removeProfile(input.profileName);
90
+ }
91
+ }
92
+ async function pollForDeviceToken(client, device, output, signal) {
93
+ const expiresAt = Date.now() + device.expires_in * 1_000;
94
+ let intervalMs = Math.max(1, device.interval) * 1_000;
95
+ while (Date.now() < expiresAt) {
96
+ await delay(intervalMs, signal);
97
+ const response = await client.oauthRequest("/api/auth/device/token", {
98
+ client_id: clientId,
99
+ device_code: device.device_code,
100
+ grant_type: deviceGrantType,
101
+ }, { signal });
102
+ if (response.ok) {
103
+ return requireDeviceToken(response.body);
104
+ }
105
+ const errorCode = readString(response.body, "error");
106
+ if (errorCode === "authorization_pending") {
107
+ continue;
108
+ }
109
+ if (errorCode === "slow_down") {
110
+ intervalMs += 5_000;
111
+ continue;
112
+ }
113
+ if (errorCode === "access_denied") {
114
+ throw new CliError("Device authorization was denied", "cli_device_access_denied", cliExitCodes.authentication);
115
+ }
116
+ if (errorCode === "expired_token") {
117
+ throw deviceExpired();
118
+ }
119
+ output.notice("The authentication service rejected the device exchange.");
120
+ throw new CliError("Device authorization failed", "cli_device_authorization_failed", cliExitCodes.authentication, { oauth_error: errorCode });
121
+ }
122
+ throw deviceExpired();
123
+ }
124
+ function requireDeviceCode(result) {
125
+ if (!result.ok ||
126
+ !isRecord(result.body) ||
127
+ !readString(result.body, "device_code") ||
128
+ !readString(result.body, "user_code") ||
129
+ !readString(result.body, "verification_uri") ||
130
+ !readString(result.body, "verification_uri_complete") ||
131
+ !readNumber(result.body, "expires_in") ||
132
+ !readNumber(result.body, "interval")) {
133
+ throw new CliError("Proxy authentication returned an invalid device code", "cli_invalid_auth_response", cliExitCodes.network, { status: result.status });
134
+ }
135
+ return result.body;
136
+ }
137
+ function requireDeviceToken(value) {
138
+ if (!isRecord(value) ||
139
+ readString(value, "token_type")?.toLowerCase() !== "bearer" ||
140
+ !readString(value, "access_token") ||
141
+ !readString(value, "scope")) {
142
+ throw new CliError("Proxy authentication returned an invalid temporary session", "cli_invalid_auth_response", cliExitCodes.network);
143
+ }
144
+ return value;
145
+ }
146
+ function parseApprovedScope(scope) {
147
+ const parts = new Set(scope.split(/\s+/));
148
+ const approval = [...parts].find((part) => part.startsWith("approval:"));
149
+ const merchant = [...parts].find((part) => part.startsWith("merchant:"));
150
+ if (!parts.has(deviceScope) ||
151
+ !parts.has("mode:test") ||
152
+ !approval?.startsWith("approval:cliappr_") ||
153
+ !merchant?.startsWith("merchant:merch_")) {
154
+ throw new CliError("Device approval did not contain the required test-merchant capability grant", "cli_invalid_approval_scope", cliExitCodes.permission);
155
+ }
156
+ return {
157
+ approvalId: approval.slice("approval:".length),
158
+ merchantId: merchant.slice("merchant:".length),
159
+ };
160
+ }
161
+ function requireExchangeProfile(profile, apiBaseUrl) {
162
+ if (profile.mode !== "test" ||
163
+ typeof profile.api_version !== "string" ||
164
+ profile.api_version.length === 0 ||
165
+ !Number.isSafeInteger(profile.capability_version) ||
166
+ profile.capability_version < 1 ||
167
+ !profile.credential_id.startsWith("apk_") ||
168
+ !profile.merchant_id.startsWith("merch_") ||
169
+ !profile.organization_id.startsWith("org_") ||
170
+ typeof profile.name !== "string" ||
171
+ profile.name.length < 1 ||
172
+ profile.name.length > 80 ||
173
+ (profile.expires_at !== null && Number.isNaN(Date.parse(profile.expires_at)))) {
174
+ throw new CliError("Proxy returned an invalid or non-test CLI grant", "cli_invalid_credential_grant", cliExitCodes.permission);
175
+ }
176
+ return {
177
+ apiBaseUrl,
178
+ apiVersion: profile.api_version,
179
+ capabilityVersion: profile.capability_version,
180
+ credentialId: profile.credential_id,
181
+ expiresAt: profile.expires_at,
182
+ merchantId: profile.merchant_id,
183
+ mode: profile.mode,
184
+ name: profile.name,
185
+ organizationId: profile.organization_id,
186
+ };
187
+ }
188
+ function normalizeCredentialName(name) {
189
+ const value = name?.trim() || `Proxy CLI on ${hostname()}`;
190
+ if (value.length > 80) {
191
+ throw new CliError("Credential name must be at most 80 characters", "cli_credential_name_invalid", cliExitCodes.usage);
192
+ }
193
+ return value;
194
+ }
195
+ function validateVerificationUrl(value) {
196
+ let url;
197
+ try {
198
+ url = new URL(value);
199
+ }
200
+ catch {
201
+ throw new CliError("Proxy returned an invalid browser approval URL", "cli_invalid_auth_response", cliExitCodes.network);
202
+ }
203
+ const loopback = ["127.0.0.1", "::1", "localhost"].includes(url.hostname);
204
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
205
+ url.username ||
206
+ url.password) {
207
+ throw new CliError("Proxy returned an unsafe browser approval URL", "cli_invalid_auth_response", cliExitCodes.network);
208
+ }
209
+ return url.toString();
210
+ }
211
+ async function openBrowser(url) {
212
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
213
+ const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
214
+ return new Promise((resolve) => {
215
+ const child = spawn(command, args, { detached: true, shell: false, stdio: "ignore" });
216
+ child.once("error", () => resolve(false));
217
+ child.once("spawn", () => {
218
+ child.unref();
219
+ resolve(true);
220
+ });
221
+ });
222
+ }
223
+ function deviceExpired() {
224
+ return new CliError("Device authorization expired", "cli_device_authorization_expired", cliExitCodes.authentication);
225
+ }
226
+ function readString(value, key) {
227
+ return isRecord(value) && typeof value[key] === "string" ? value[key] : undefined;
228
+ }
229
+ function readNumber(value, key) {
230
+ return isRecord(value) && typeof value[key] === "number" ? value[key] : undefined;
231
+ }
232
+ function isRecord(value) {
233
+ return typeof value === "object" && value !== null && !Array.isArray(value);
234
+ }
235
+ function delay(milliseconds, signal) {
236
+ if (signal.aborted)
237
+ return Promise.reject(authInterrupted());
238
+ return new Promise((resolve, reject) => {
239
+ const onAbort = () => {
240
+ clearTimeout(timer);
241
+ signal.removeEventListener("abort", onAbort);
242
+ reject(authInterrupted());
243
+ };
244
+ const timer = setTimeout(() => {
245
+ signal.removeEventListener("abort", onAbort);
246
+ resolve();
247
+ }, milliseconds);
248
+ signal.addEventListener("abort", onAbort, { once: true });
249
+ });
250
+ }
251
+ function authInterrupted() {
252
+ return new CliError("Device authorization interrupted", "cli_auth_interrupted", cliExitCodes.interrupted);
253
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli.js";
3
+ void runCli(process.argv.slice(2)).then((exitCode) => {
4
+ process.exitCode = exitCode;
5
+ }, () => {
6
+ process.stderr.write("Proxy CLI failed unexpectedly.\n");
7
+ process.exitCode = 10;
8
+ });
@@ -0,0 +1,10 @@
1
+ import { CliConfigStore } from "./config.js";
2
+ import { type CliIo } from "./output.js";
3
+ export declare const cliVersion = "0.1.0-prx-128.104.1";
4
+ export interface RunCliOptions {
5
+ configStore?: CliConfigStore;
6
+ environment?: NodeJS.ProcessEnv;
7
+ io?: CliIo;
8
+ signal?: AbortSignal;
9
+ }
10
+ export declare function runCli(argv: readonly string[], options?: RunCliOptions): Promise<number>;