shopstack 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shopstack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # shopstack
2
2
 
3
- Dependency-free JavaScript client and CLI for Shopstack's asynchronous checkout API.
3
+ JavaScript client and CLI for Shopstack's asynchronous checkout API.
4
4
 
5
5
  ## Install
6
6
 
@@ -12,8 +12,8 @@ npm install -g shopstack
12
12
 
13
13
  Node.js 18 or newer is required.
14
14
 
15
- The 0.2.1 source candidate defaults to Shopstack's currently deployed public staging API at
16
- `https://shopstack-staging.shopstack.workers.dev/v1`. Set
15
+ The CLI and client default to Shopstack's production API at
16
+ `https://api.shopstack.ai/v1`. Set
17
17
  `SHOPSTACK_API_URL` only when targeting a different Shopstack environment.
18
18
 
19
19
  ## Verified signup
@@ -68,7 +68,7 @@ phase. It deliberately has no card-input or payment-approval tool.
68
68
 
69
69
  The canonical agent skill ships as `SKILL.md` in this package. After
70
70
  publication it is available at
71
- `https://unpkg.com/shopstack@0.2.1/SKILL.md` with the release-pinned package.
71
+ `https://unpkg.com/shopstack@0.2.2/SKILL.md` with the release-pinned package.
72
72
 
73
73
  ## Connections
74
74
 
@@ -113,6 +113,12 @@ Then run:
113
113
  shopstack checkout run --file checkout.json
114
114
  ```
115
115
 
116
+ The CLI prints the private owner live-view URL as soon as checkout creation
117
+ succeeds. Do not redirect it into shared logs or expose it to a model. The page
118
+ is view-only unless the model explicitly requests human assistance; while that
119
+ request is active, one owner viewer can use **Take control** and **Return
120
+ control**. **Stop** is always available and never approves payment.
121
+
116
122
  When no provider is selected, the checkout runs normally until the payment
117
123
  form, then asks for card details through a no-echo terminal prompt. Card data is
118
124
  sent only to the protected payment-details endpoint. The CLI separately shows
@@ -154,7 +160,11 @@ restart recovery and durable credential storage; never use browser storage.
154
160
 
155
161
  `runCheckout` returns at a required input if its corresponding callback is
156
162
  omitted. Final payment approval is never inferred from a message or from
157
- supplying a card.
163
+ supplying a card. While the checkout is active, the CLI and client use a one-time
164
+ checkout-scoped WebSocket capability for change notification and always fetches
165
+ the canonical resource after a signal. It falls back to bounded revision-aware
166
+ HTTP waiting, then to fixed polling when either newer transport is unavailable.
167
+ Neither the API key nor WebSocket capability is placed in the socket URL.
158
168
 
159
169
  Set `SHOPSTACK_API_URL` to target a different Shopstack API and
160
170
  `SHOPSTACK_CONFIG_FILE` to relocate CLI profiles.
package/SKILL.md CHANGED
@@ -109,12 +109,27 @@ shopstack checkout run --file checkout.json
109
109
  Or create with MCP, then call `poll_checkout`. Display these authoritative fields to the user when they change:
110
110
 
111
111
  - `status`: one of exactly `queued`, `started`, `help_required`, `approval_required`, `submitting`, `complete`, `failed`, `cancelled`;
112
+ - `presentation_revision`: the monotonic revision for user-visible checkout state;
112
113
  - `activity`: bounded present-tense display text;
113
114
  - `intent.name`, `intent.phase`, and `intent.updated_at`: bounded mechanical action progress with no arguments or reasoning;
114
115
  - `required_input`: a typed handoff such as protected payment-card input;
115
116
  - `approval`: the exact amount-bound approval request when present.
116
117
 
117
- Poll every two seconds for `queued`, `started`, and `submitting`; every five seconds for `help_required` and `approval_required`; stop at `complete`, `failed`, or `cancelled`.
118
+ The CLI and JavaScript client subscribe with a one-time checkout-scoped
119
+ WebSocket capability. Each valid notification means the presentation revision
120
+ changed; the client then fetches the canonical checkout before displaying or
121
+ acting on it. The notification is not checkout state truth and never grants
122
+ payment authority. The capability belongs in the WebSocket subprotocol, never
123
+ the URL, logs, model context, or messages. If WebSocket delivery is unavailable,
124
+ the client falls back to bounded HTTP waiting and then fixed polling.
125
+
126
+ MCP and manual integrations poll every two seconds for `queued`, `started`, and
127
+ `submitting`; every five seconds for `help_required` and `approval_required`;
128
+ stop at `complete`, `failed`, or `cancelled`.
129
+
130
+ The optional `live_view_url` is a private owner-viewer capability. Show it only
131
+ to the initiating user. Do not store it in browser storage, analytics, logs,
132
+ model context, or ordinary messages.
118
133
 
119
134
  ## Respond at typed boundaries
120
135
 
package/bin/shopstack CHANGED
@@ -5,6 +5,8 @@ import { runCli } from "../src/cli.js";
5
5
  try {
6
6
  await runCli(process.argv.slice(2));
7
7
  } catch (error) {
8
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
8
+ process.stderr.write(
9
+ `${error instanceof Error ? error.message : String(error)}\n`,
10
+ );
9
11
  process.exitCode = 1;
10
12
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shopstack",
3
- "version": "0.2.1",
4
- "description": "Shopstack API client and command-line checkout tools.",
3
+ "version": "0.2.2",
4
+ "description": "Production Shopstack SDK and CLI for agentic checkout.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -23,8 +23,12 @@
23
23
  "shopstack": "bin/shopstack"
24
24
  },
25
25
  "scripts": {
26
+ "check": "npm run format:check && npm run test:coverage",
27
+ "format:check": "prettier --check .",
26
28
  "shopstack": "node ./bin/shopstack",
27
- "test": "node --test"
29
+ "test": "node --test test",
30
+ "test:coverage": "node --test --experimental-test-coverage test",
31
+ "prepublishOnly": "npm run check"
28
32
  },
29
33
  "engines": {
30
34
  "node": ">=18"
@@ -33,11 +37,27 @@
33
37
  "type": "git",
34
38
  "url": "git+https://github.com/jimbo132/shopstack-cli.git"
35
39
  },
40
+ "homepage": "https://github.com/jimbo132/shopstack-cli#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/jimbo132/shopstack-cli/issues"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "sideEffects": false,
48
+ "dependencies": {
49
+ "ws": "8.21.3"
50
+ },
36
51
  "license": "MIT",
37
52
  "keywords": [
38
53
  "shopstack",
39
54
  "cli",
40
55
  "checkout",
41
- "payments"
42
- ]
56
+ "payments",
57
+ "agentic-commerce",
58
+ "agents"
59
+ ],
60
+ "devDependencies": {
61
+ "prettier": "3.6.2"
62
+ }
43
63
  }
package/src/cli.js CHANGED
@@ -4,26 +4,50 @@ import { createInterface } from "node:readline/promises";
4
4
  import { ShopstackClient } from "./client.js";
5
5
  import { ConfigStore } from "./config.js";
6
6
 
7
- const HELP = `Shopstack
7
+ const VERSION = "0.2.2";
8
+
9
+ const HELP = `Shopstack CLI ${VERSION}
10
+
11
+ Usage:
12
+ shopstack <command> [options]
8
13
 
9
14
  Account setup:
10
15
  shopstack signup
16
+ # Create or resume an email-verified Personal or Developer profile.
11
17
  shopstack signup user --email EMAIL
18
+ # Create a Personal profile without interactive account-type prompts.
12
19
  shopstack signup developer --email EMAIL
20
+ # Create a Developer management profile without interactive prompts.
13
21
  shopstack signup resume SIGNUP_ID
22
+ # Resume a known pending email-verification flow.
14
23
  shopstack users create --external-id ID [--profile NAME]
24
+ # Create an independently scoped user from a Developer profile.
15
25
  shopstack profiles list
26
+ # List local profiles without printing their credentials.
16
27
  shopstack profiles use NAME
28
+ # Select the profile used by later commands.
17
29
 
18
30
  Payment connection:
19
31
  shopstack connect list
32
+ # Inspect payment connections for the active user profile.
20
33
  shopstack connect link
34
+ # Connect Link Agentic Wallet in the trusted browser flow.
21
35
 
22
36
  Checkout:
23
37
  shopstack checkout create --file checkout.json
38
+ # Create a checkout and return control immediately.
24
39
  shopstack checkout run --file checkout.json
40
+ # Create, monitor, and complete a checkout interactively.
25
41
  shopstack checkout get CHECKOUT_ID
42
+ # Read the canonical current checkout state.
26
43
  shopstack checkout cancel CHECKOUT_ID
44
+ # Cancel a checkout that has not reached a terminal state.
45
+
46
+ Options:
47
+ -h, --help
48
+ # Show this command reference.
49
+ -v, --version
50
+ # Print the installed Shopstack CLI version.
27
51
  `;
28
52
 
29
53
  function parseOptions(args, allowed) {
@@ -164,11 +188,7 @@ function signupPersistence(configStore, profileName) {
164
188
  async completePending(state, result) {
165
189
  const pendingId = state.attemptId ?? state.signupId ?? state.id;
166
190
  if (typeof configStore.completePendingSignup === "function") {
167
- await configStore.completePendingSignup(
168
- pendingId,
169
- profileName,
170
- result,
171
- );
191
+ await configStore.completePendingSignup(pendingId, profileName, result);
172
192
  return;
173
193
  }
174
194
  await saveVerifiedSignup(result, profileName, configStore);
@@ -201,13 +221,17 @@ async function reportSignupProgress(progress, stream) {
201
221
  stream.write("Signup expired. Start again to receive a new email.\n");
202
222
  return;
203
223
  case "rate_limited":
204
- stream.write("Signup is rate-limited. Retry after the indicated delay.\n");
224
+ stream.write(
225
+ "Signup is rate-limited. Retry after the indicated delay.\n",
226
+ );
205
227
  return;
206
228
  case "conflict":
207
229
  stream.write("Signup retry state conflicted and was cleared.\n");
208
230
  return;
209
231
  case "retryable_failure":
210
- stream.write("Signup paused after a retryable failure. Run signup again to resume.\n");
232
+ stream.write(
233
+ "Signup paused after a retryable failure. Run signup again to resume.\n",
234
+ );
211
235
  return;
212
236
  default:
213
237
  return;
@@ -216,7 +240,7 @@ async function reportSignupProgress(progress, stream) {
216
240
 
217
241
  async function activeClient(dependencies, requiredKind = "user") {
218
242
  const profile = await dependencies.configStore.activeProfile();
219
- const environmentKey = process.env.SHOPSTACK_API_KEY;
243
+ const environmentKey = dependencies.env.SHOPSTACK_API_KEY;
220
244
  const apiKey = environmentKey || profile?.apiKey;
221
245
  const keyType = environmentKey ? requiredKind : profile?.keyType;
222
246
  if (!apiKey)
@@ -227,7 +251,7 @@ async function activeClient(dependencies, requiredKind = "user") {
227
251
  return {
228
252
  client: dependencies.clientFactory({
229
253
  apiKey,
230
- baseUrl: process.env.SHOPSTACK_API_URL,
254
+ baseUrl: dependencies.env.SHOPSTACK_API_URL,
231
255
  }),
232
256
  profile,
233
257
  };
@@ -244,13 +268,23 @@ export async function runCli(args, supplied = {}) {
244
268
  stderr: process.stderr,
245
269
  stdin: process.stdin,
246
270
  stdout: process.stdout,
271
+ env: process.env,
247
272
  ...supplied,
248
273
  };
249
274
  const [group, action, ...rest] = args;
250
- if (group === undefined || group === "help" || group === "--help") {
275
+ if (
276
+ group === undefined ||
277
+ group === "help" ||
278
+ group === "--help" ||
279
+ group === "-h"
280
+ ) {
251
281
  dependencies.stdout.write(HELP);
252
282
  return;
253
283
  }
284
+ if (group === "--version" || group === "-v") {
285
+ dependencies.stdout.write(`${VERSION}\n`);
286
+ return;
287
+ }
254
288
 
255
289
  if (group === "signup") {
256
290
  if (action === "resume") {
@@ -259,10 +293,12 @@ export async function runCli(args, supplied = {}) {
259
293
  }
260
294
  const pending = await dependencies.configStore.pendingSignup(rest[0]);
261
295
  if (pending === undefined) {
262
- throw new Error("That signup is not present in the local profile store.");
296
+ throw new Error(
297
+ "That signup is not present in the local profile store.",
298
+ );
263
299
  }
264
300
  const client = dependencies.clientFactory({
265
- baseUrl: process.env.SHOPSTACK_API_URL,
301
+ baseUrl: dependencies.env.SHOPSTACK_API_URL,
266
302
  });
267
303
  const result = await client.signup({
268
304
  accountType: pending.accountType,
@@ -286,14 +322,18 @@ export async function runCli(args, supplied = {}) {
286
322
  );
287
323
  }
288
324
  const signupArgs = action === undefined ? [] : rest;
289
- const { options, positional } = parseOptions(signupArgs, new Set(["email", "profile"]));
325
+ const { options, positional } = parseOptions(
326
+ signupArgs,
327
+ new Set(["email", "profile"]),
328
+ );
290
329
  if (positional.length > 0) throw new Error("Unexpected signup argument.");
291
330
  const pendingCandidates =
292
331
  action === undefined &&
293
332
  typeof dependencies.configStore.pendingSignups === "function"
294
333
  ? await dependencies.configStore.pendingSignups()
295
334
  : [];
296
- const resumable = pendingCandidates.length === 1 ? pendingCandidates[0] : undefined;
335
+ const resumable =
336
+ pendingCandidates.length === 1 ? pendingCandidates[0] : undefined;
297
337
  let accountType;
298
338
  let email;
299
339
  if (resumable !== undefined) {
@@ -301,12 +341,13 @@ export async function runCli(args, supplied = {}) {
301
341
  email = resumable.email;
302
342
  dependencies.stderr.write("Resuming pending signup.\n");
303
343
  } else if (action === undefined) {
304
- email = String(
305
- await visiblePrompt("Email: ", dependencies),
306
- ).trim();
344
+ email = String(await visiblePrompt("Email: ", dependencies)).trim();
307
345
  if (email.length === 0) throw new Error("Email is required.");
308
346
  const selected = String(
309
- await visiblePrompt("Account type (Personal / Developer): ", dependencies),
347
+ await visiblePrompt(
348
+ "Account type (Personal / Developer): ",
349
+ dependencies,
350
+ ),
310
351
  )
311
352
  .trim()
312
353
  .toLowerCase();
@@ -322,7 +363,7 @@ export async function runCli(args, supplied = {}) {
322
363
  email = required(options, "email");
323
364
  }
324
365
  const client = dependencies.clientFactory({
325
- baseUrl: process.env.SHOPSTACK_API_URL,
366
+ baseUrl: dependencies.env.SHOPSTACK_API_URL,
326
367
  });
327
368
  const profileName =
328
369
  options.profile ??
@@ -373,10 +414,7 @@ export async function runCli(args, supplied = {}) {
373
414
  },
374
415
  { activate: true },
375
416
  );
376
- writeJson(
377
- dependencies.stdout,
378
- sanitizedUserResult(user, externalId),
379
- );
417
+ writeJson(dependencies.stdout, sanitizedUserResult(user, externalId));
380
418
  }
381
419
  }
382
420
  return;
@@ -454,6 +492,11 @@ export async function runCli(args, supplied = {}) {
454
492
  const { client } = await activeClient(dependencies, "user");
455
493
  const request = await dependencies.readJsonFile(required(options, "file"));
456
494
  const result = await client.runCheckout(request, {
495
+ onCreated: (checkout) => {
496
+ if (typeof checkout.live_view_url === "string") {
497
+ dependencies.stderr.write(`Live view: ${checkout.live_view_url}\n`);
498
+ }
499
+ },
457
500
  onProgress: (checkout) => {
458
501
  const intent =
459
502
  checkout.intent?.name === undefined
@@ -513,4 +556,4 @@ export async function runCli(args, supplied = {}) {
513
556
  throw new Error("Unknown command. Run `shopstack help`.");
514
557
  }
515
558
 
516
- export { HELP };
559
+ export { HELP, VERSION };
package/src/client.d.ts CHANGED
@@ -2,6 +2,7 @@ export interface ShopstackClientOptions {
2
2
  apiKey?: string;
3
3
  baseUrl?: string;
4
4
  fetch?: typeof fetch;
5
+ webSocket?: typeof WebSocket;
5
6
  }
6
7
 
7
8
  export interface PaymentCard {
@@ -33,6 +34,7 @@ export interface Checkout {
33
34
  | "failed"
34
35
  | "cancelled";
35
36
  revision: number;
37
+ presentation_revision: number;
36
38
  activity: string;
37
39
  intent?: {
38
40
  name: string;
@@ -40,6 +42,7 @@ export interface Checkout {
40
42
  updated_at: string;
41
43
  };
42
44
  item_url: string;
45
+ live_view_url?: string;
43
46
  required_input?: { type: "payment_card" };
44
47
  approval?: PaymentApproval;
45
48
  result?: Record<string, unknown>;
@@ -51,6 +54,7 @@ export interface Checkout {
51
54
 
52
55
  export interface RunCheckoutOptions {
53
56
  idempotencyKey?: string;
57
+ onCreated?(checkout: Checkout): void | Promise<void>;
54
58
  pollIntervalMs?: number;
55
59
  timeoutMs?: number;
56
60
  onProgress?(checkout: Checkout): void | Promise<void>;
@@ -66,6 +70,14 @@ export interface RunCheckoutOptions {
66
70
  ): string | Promise<string | undefined> | undefined;
67
71
  }
68
72
 
73
+ export interface CheckoutUpdateSubscription {
74
+ socket_url: string;
75
+ protocol: "shopstack.v1";
76
+ token: string;
77
+ expires_at: string;
78
+ presentation_revision: number;
79
+ }
80
+
69
81
  export interface AccountSignupStarted {
70
82
  id: string;
71
83
  account_type: "personal" | "developer";
@@ -93,9 +105,9 @@ export interface SignupOptions {
93
105
  timeoutMs?: number;
94
106
  persistence?: SignupPersistence;
95
107
  onProgress?(progress: SignupProgress): void | Promise<void>;
96
- onVerificationRequired?(signup: Omit<AccountSignupStarted, "poll_token">):
97
- | void
98
- | Promise<void>;
108
+ onVerificationRequired?(
109
+ signup: Omit<AccountSignupStarted, "poll_token">,
110
+ ): void | Promise<void>;
99
111
  }
100
112
 
101
113
  export interface PendingSignupState {
@@ -156,9 +168,7 @@ export class ShopstackClient {
156
168
  pollAccountSignup(
157
169
  signupId: string,
158
170
  pollToken: string,
159
- ): Promise<
160
- Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup
161
- >;
171
+ ): Promise<Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup>;
162
172
  waitForAccountSignup(
163
173
  signupId: string,
164
174
  pollToken: string,
@@ -190,6 +200,14 @@ export class ShopstackClient {
190
200
  options?: { idempotencyKey?: string },
191
201
  ): Promise<Checkout>;
192
202
  getCheckout(checkoutId: string): Promise<Checkout>;
203
+ createCheckoutUpdateSubscription(
204
+ checkoutId: string,
205
+ ): Promise<CheckoutUpdateSubscription>;
206
+ waitForCheckoutUpdate(
207
+ checkoutId: string,
208
+ afterRevision: number,
209
+ waitSeconds?: number,
210
+ ): Promise<Checkout | undefined>;
193
211
  cancelCheckout(
194
212
  checkoutId: string,
195
213
  options?: { idempotencyKey?: string },
package/src/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
+ import WebSocket from "ws";
2
3
 
3
4
  const TERMINAL_STATUSES = new Set(["complete", "failed", "cancelled"]);
4
5
 
@@ -30,11 +31,119 @@ function delay(milliseconds) {
30
31
  : new Promise((resolve) => setTimeout(resolve, milliseconds));
31
32
  }
32
33
 
34
+ function presentationRevision(checkout) {
35
+ return Number.isInteger(checkout?.presentation_revision)
36
+ ? checkout.presentation_revision
37
+ : checkout.revision;
38
+ }
39
+
40
+ class CheckoutUpdateWatcher {
41
+ constructor(client, checkoutId) {
42
+ this.client = client;
43
+ this.checkoutId = checkoutId;
44
+ this.latestRevision = 0;
45
+ this.waiter = undefined;
46
+ this.socket = undefined;
47
+ this.unavailable = typeof client.WebSocket !== "function";
48
+ }
49
+
50
+ async connect() {
51
+ if (this.socket || this.unavailable) return;
52
+ try {
53
+ const subscription = await this.client.createCheckoutUpdateSubscription(
54
+ this.checkoutId,
55
+ );
56
+ const socket = new this.client.WebSocket(subscription.socket_url, [
57
+ subscription.protocol,
58
+ subscription.token,
59
+ ]);
60
+ this.socket = socket;
61
+ socket.addEventListener("message", (event) => {
62
+ let payload;
63
+ try {
64
+ payload = JSON.parse(String(event.data));
65
+ } catch {
66
+ return;
67
+ }
68
+ if (
69
+ payload?.type !== "checkout_changed" ||
70
+ !Number.isInteger(payload.presentation_revision) ||
71
+ payload.presentation_revision < 1 ||
72
+ typeof payload.terminal !== "boolean"
73
+ ) {
74
+ return;
75
+ }
76
+ this.latestRevision = Math.max(
77
+ this.latestRevision,
78
+ payload.presentation_revision,
79
+ );
80
+ this.waiter?.();
81
+ });
82
+ const unavailable = () => {
83
+ this.unavailable = true;
84
+ this.waiter?.();
85
+ };
86
+ socket.addEventListener("error", unavailable);
87
+ socket.addEventListener("close", unavailable);
88
+ } catch {
89
+ this.unavailable = true;
90
+ }
91
+ }
92
+
93
+ async wait(afterRevision, timeoutMs) {
94
+ await this.connect();
95
+ if (this.latestRevision > afterRevision) return { kind: "changed" };
96
+ if (!this.unavailable) {
97
+ const changed = await new Promise((resolve) => {
98
+ let settled = false;
99
+ const settle = (value) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ this.waiter = undefined;
103
+ clearTimeout(timer);
104
+ resolve(value);
105
+ };
106
+ this.waiter = () =>
107
+ settle(
108
+ this.latestRevision > afterRevision ? "changed" : "unavailable",
109
+ );
110
+ const timer = setTimeout(
111
+ () => settle("timeout"),
112
+ Math.max(1, Math.min(timeoutMs, 25_000)),
113
+ );
114
+ });
115
+ if (changed === "changed") return { kind: "changed" };
116
+ if (changed === "timeout") return { kind: "refresh" };
117
+ }
118
+ try {
119
+ const checkout = await this.client.waitForCheckoutUpdate(
120
+ this.checkoutId,
121
+ afterRevision,
122
+ Math.max(1, Math.min(25, Math.ceil(timeoutMs / 1_000))),
123
+ );
124
+ return checkout === undefined
125
+ ? { kind: "refresh" }
126
+ : { checkout, kind: "checkout" };
127
+ } catch {
128
+ return { kind: "unavailable" };
129
+ }
130
+ }
131
+
132
+ close() {
133
+ try {
134
+ this.socket?.close(1000, "Checkout monitoring complete");
135
+ } catch {
136
+ // Monitoring teardown must not alter the checkout result.
137
+ }
138
+ }
139
+ }
140
+
33
141
  export class ShopstackClient {
34
142
  constructor({
35
143
  apiKey,
36
- baseUrl = "https://shopstack-staging.shopstack.workers.dev/v1",
144
+ baseUrl = "https://api.shopstack.ai/v1",
37
145
  fetch: fetchImplementation = globalThis.fetch,
146
+ webSocket: webSocketImplementation = WebSocket,
38
147
  } = {}) {
39
148
  if (typeof baseUrl !== "string" || !/^https?:\/\//u.test(baseUrl)) {
40
149
  throw new TypeError("baseUrl must be an HTTP(S) URL");
@@ -45,6 +154,7 @@ export class ShopstackClient {
45
154
  this.apiKey = apiKey;
46
155
  this.baseUrl = baseUrl.replace(/\/+$/u, "");
47
156
  this.fetch = fetchImplementation;
157
+ this.WebSocket = webSocketImplementation;
48
158
  }
49
159
 
50
160
  async request(
@@ -98,7 +208,12 @@ export class ShopstackClient {
98
208
  return payload;
99
209
  }
100
210
 
101
- _startAccountSignup({ accountType, email, idempotencyKey: key, recoveryKey }) {
211
+ _startAccountSignup({
212
+ accountType,
213
+ email,
214
+ idempotencyKey: key,
215
+ recoveryKey,
216
+ }) {
102
217
  return this.request("/accounts", {
103
218
  body: { account_type: accountType, email },
104
219
  idempotencyKey: key,
@@ -164,16 +279,14 @@ export class ShopstackClient {
164
279
  },
165
280
  };
166
281
  const publish = async (state, details = {}) => {
167
- if (typeof onProgress === "function") await onProgress({ state, ...details });
282
+ if (typeof onProgress === "function")
283
+ await onProgress({ state, ...details });
168
284
  };
169
285
  let pending = await storage.loadPending({
170
286
  accountType,
171
287
  email: normalizedEmail,
172
288
  });
173
- if (
174
- pending?.expiresAt &&
175
- Date.parse(pending.expiresAt) <= Date.now()
176
- ) {
289
+ if (pending?.expiresAt && Date.parse(pending.expiresAt) <= Date.now()) {
177
290
  await storage.deletePending(pending);
178
291
  await publish("expired", {
179
292
  account_type: accountType,
@@ -331,6 +444,22 @@ export class ShopstackClient {
331
444
  return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
332
445
  }
333
446
 
447
+ createCheckoutUpdateSubscription(checkoutId) {
448
+ return this.request(`/checkout/${encodeURIComponent(checkoutId)}/updates`, {
449
+ method: "POST",
450
+ });
451
+ }
452
+
453
+ waitForCheckoutUpdate(checkoutId, afterRevision, waitSeconds = 25) {
454
+ const query = new URLSearchParams({
455
+ after: String(afterRevision),
456
+ wait: String(waitSeconds),
457
+ });
458
+ return this.request(
459
+ `/checkout/${encodeURIComponent(checkoutId)}/updates?${query.toString()}`,
460
+ );
461
+ }
462
+
334
463
  cancelCheckout(checkoutId, { idempotencyKey: key } = {}) {
335
464
  return this.request(`/checkout/${encodeURIComponent(checkoutId)}/cancel`, {
336
465
  idempotencyKey: key ?? idempotencyKey("cancel"),
@@ -396,12 +525,16 @@ export class ShopstackClient {
396
525
  let checkout = await this.createCheckout(checkoutRequest, {
397
526
  idempotencyKey: options.idempotencyKey,
398
527
  });
528
+ if (typeof options.onCreated === "function") {
529
+ await options.onCreated(checkout);
530
+ }
399
531
  let progressFingerprint;
400
532
  const publishProgress = async (current) => {
401
533
  if (typeof options.onProgress !== "function") return;
402
534
  const fingerprint = JSON.stringify([
403
535
  current.status,
404
536
  current.revision,
537
+ current.presentation_revision,
405
538
  current.activity,
406
539
  current.intent?.name,
407
540
  current.intent?.phase,
@@ -413,74 +546,88 @@ export class ShopstackClient {
413
546
  progressFingerprint = fingerprint;
414
547
  await options.onProgress(current);
415
548
  };
416
- await publishProgress(checkout);
549
+ const watcher = new CheckoutUpdateWatcher(this, checkout.id);
417
550
  let handledPaymentRevision;
418
551
  let handledApprovalId;
419
552
  let handledMessageRevision;
420
- while (!TERMINAL_STATUSES.has(checkout.status)) {
421
- if (Date.now() - startedAt >= timeoutMs) {
422
- throw new ShopstackApiError("Checkout monitoring timed out.", {
423
- code: "checkout_timeout",
424
- });
425
- }
426
- checkout = await this.getCheckout(checkout.id);
427
- await publishProgress(checkout);
428
- if (
429
- checkout.required_input?.type === "payment_card" &&
430
- handledPaymentRevision !== checkout.revision
431
- ) {
432
- if (typeof options.paymentDetails !== "function") return checkout;
433
- handledPaymentRevision = checkout.revision;
434
- const card = await options.paymentDetails(checkout);
435
- if (card === undefined) return checkout;
436
- await this.providePaymentDetails(checkout.id, card, {
437
- idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
438
- });
439
- continue;
440
- }
441
- if (
442
- checkout.status === "approval_required" &&
443
- checkout.approval &&
444
- handledApprovalId !== checkout.approval.id
445
- ) {
446
- if (typeof options.approve !== "function") return checkout;
447
- handledApprovalId = checkout.approval.id;
448
- const approved = await options.approve(checkout.approval, checkout);
449
- if (typeof approved !== "boolean") return checkout;
450
- await this.decidePaymentApproval(
451
- checkout.id,
452
- checkout.approval.id,
453
- approved,
454
- { idempotencyKey: `approval-${checkout.approval.id}` },
553
+ try {
554
+ while (true) {
555
+ await publishProgress(checkout);
556
+ if (TERMINAL_STATUSES.has(checkout.status)) return checkout;
557
+ const elapsed = Date.now() - startedAt;
558
+ if (elapsed >= timeoutMs) {
559
+ throw new ShopstackApiError("Checkout monitoring timed out.", {
560
+ code: "checkout_timeout",
561
+ });
562
+ }
563
+ if (
564
+ checkout.required_input?.type === "payment_card" &&
565
+ handledPaymentRevision !== checkout.revision
566
+ ) {
567
+ if (typeof options.paymentDetails !== "function") return checkout;
568
+ handledPaymentRevision = checkout.revision;
569
+ const card = await options.paymentDetails(checkout);
570
+ if (card === undefined) return checkout;
571
+ checkout = await this.providePaymentDetails(checkout.id, card, {
572
+ idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
573
+ });
574
+ handledMessageRevision = checkout.revision;
575
+ continue;
576
+ }
577
+ if (
578
+ checkout.status === "approval_required" &&
579
+ checkout.approval &&
580
+ handledApprovalId !== checkout.approval.id
581
+ ) {
582
+ if (typeof options.approve !== "function") return checkout;
583
+ handledApprovalId = checkout.approval.id;
584
+ const approved = await options.approve(checkout.approval, checkout);
585
+ if (typeof approved !== "boolean") return checkout;
586
+ checkout = await this.decidePaymentApproval(
587
+ checkout.id,
588
+ checkout.approval.id,
589
+ approved,
590
+ { idempotencyKey: `approval-${checkout.approval.id}` },
591
+ );
592
+ if (!approved) return checkout;
593
+ continue;
594
+ }
595
+ if (
596
+ checkout.status === "help_required" &&
597
+ checkout.required_input === undefined &&
598
+ handledMessageRevision !== checkout.revision
599
+ ) {
600
+ if (typeof options.message !== "function") return checkout;
601
+ handledMessageRevision = checkout.revision;
602
+ const content = await options.message(checkout);
603
+ if (typeof content !== "string" || content.length === 0)
604
+ return checkout;
605
+ await this.sendMessage(checkout.id, content, {
606
+ idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
607
+ });
608
+ continue;
609
+ }
610
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
611
+ const update = await watcher.wait(
612
+ presentationRevision(checkout),
613
+ remainingMs,
455
614
  );
456
- if (!approved) return this.getCheckout(checkout.id);
457
- continue;
458
- }
459
- if (
460
- checkout.status === "help_required" &&
461
- checkout.required_input === undefined &&
462
- handledMessageRevision !== checkout.revision
463
- ) {
464
- if (typeof options.message !== "function") return checkout;
465
- handledMessageRevision = checkout.revision;
466
- const content = await options.message(checkout);
467
- if (typeof content !== "string" || content.length === 0)
468
- return checkout;
469
- await this.sendMessage(checkout.id, content, {
470
- idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
471
- });
472
- continue;
473
- }
474
- if (!TERMINAL_STATUSES.has(checkout.status)) {
475
- const pollIntervalMs =
476
- options.pollIntervalMs ??
477
- (checkout.status === "help_required" ||
478
- checkout.status === "approval_required"
479
- ? 5_000
480
- : 2_000);
481
- await delay(pollIntervalMs);
615
+ if (update.kind === "unavailable") {
616
+ const pollIntervalMs =
617
+ options.pollIntervalMs ??
618
+ (checkout.status === "help_required" ||
619
+ checkout.status === "approval_required"
620
+ ? 5_000
621
+ : 2_000);
622
+ await delay(Math.min(pollIntervalMs, remainingMs));
623
+ }
624
+ checkout =
625
+ update.kind === "checkout"
626
+ ? update.checkout
627
+ : await this.getCheckout(checkout.id);
482
628
  }
629
+ } finally {
630
+ watcher.close();
483
631
  }
484
- return checkout;
485
632
  }
486
633
  }