@binaryandthread/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1126 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ PLAIN,
4
+ colourMarker,
5
+ colourSwatch,
6
+ cyan,
7
+ dim,
8
+ header,
9
+ makeBrand
10
+ } from "./chunk-P3LSUCEO.js";
11
+
12
+ // ../store-client/dist/index.js
13
+ var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
14
+ "authorization",
15
+ "token",
16
+ "access_token",
17
+ "refresh_token",
18
+ "jwt",
19
+ "idempotency-key",
20
+ "x-publishable-api-key",
21
+ "password",
22
+ "secret",
23
+ "cookie",
24
+ "set-cookie"
25
+ ]);
26
+ var MASK = "[REDACTED]";
27
+ var BEARER_RE = /Bearer\s+[A-Za-z0-9._\-]+/gi;
28
+ var EMAIL_RE = /([^\s@]+)@([^\s@]+\.[^\s@]+)/g;
29
+ function maskEmail(email) {
30
+ const at = email.indexOf("@");
31
+ if (at <= 0) {
32
+ return email;
33
+ }
34
+ const local = email.slice(0, at);
35
+ const host = email.slice(at + 1);
36
+ const localMasked = local[0] + "*".repeat(Math.max(2, local.length - 1));
37
+ const hostMasked = host.length > 0 ? host[0] : "x";
38
+ return `${localMasked}@${hostMasked}`;
39
+ }
40
+ function redactString(s) {
41
+ return s.replace(BEARER_RE, "Bearer [REDACTED]").replace(
42
+ EMAIL_RE,
43
+ (_m, _l, _h) => maskEmail(`${_l}@${_h}`)
44
+ );
45
+ }
46
+ function redact(input, _seen) {
47
+ if (input == null) {
48
+ return input;
49
+ }
50
+ if (typeof input === "string") {
51
+ return redactString(input);
52
+ }
53
+ if (typeof input !== "object") {
54
+ return input;
55
+ }
56
+ const seen = _seen ?? /* @__PURE__ */ new WeakSet();
57
+ if (seen.has(input)) {
58
+ return input;
59
+ }
60
+ seen.add(input);
61
+ if (Array.isArray(input)) {
62
+ return input.map((v) => redact(v, seen));
63
+ }
64
+ const out = {};
65
+ for (const [key, value] of Object.entries(input)) {
66
+ if (SENSITIVE_KEYS.has(key.toLowerCase())) {
67
+ out[key] = MASK;
68
+ } else if (key.toLowerCase() === "email" && typeof value === "string") {
69
+ out[key] = maskEmail(value);
70
+ } else {
71
+ out[key] = redact(value, seen);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ var DEFAULT_RETRY = {
77
+ attempts: 3,
78
+ baseDelayMs: 200,
79
+ maxDelayMs: 2e3
80
+ };
81
+ var RETRY_AFTER_MAX_MS = 1e4;
82
+ function parseRetryAfterMs(res) {
83
+ let raw = null;
84
+ try {
85
+ raw = res.headers?.get?.("retry-after") ?? null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ if (!raw) {
90
+ return null;
91
+ }
92
+ const secs = Number(raw);
93
+ let ms;
94
+ if (Number.isFinite(secs)) {
95
+ ms = secs * 1e3;
96
+ } else {
97
+ const dateMs = Date.parse(raw);
98
+ if (!Number.isFinite(dateMs)) {
99
+ return null;
100
+ }
101
+ ms = dateMs - Date.now();
102
+ }
103
+ return Math.min(Math.max(0, ms), RETRY_AFTER_MAX_MS);
104
+ }
105
+ var RecoverableCartError = class extends Error {
106
+ recoverable = true;
107
+ status;
108
+ constructor(message, status) {
109
+ super(message);
110
+ this.name = "RecoverableCartError";
111
+ this.status = status;
112
+ }
113
+ };
114
+ var StoreApiError = class extends Error {
115
+ status;
116
+ path;
117
+ constructor(message, status, path) {
118
+ super(message);
119
+ this.name = "StoreApiError";
120
+ this.status = status;
121
+ this.path = path;
122
+ }
123
+ };
124
+ function sleep(ms) {
125
+ return new Promise((resolve) => setTimeout(resolve, ms));
126
+ }
127
+ function newUuid() {
128
+ const g = globalThis;
129
+ if (g.crypto?.randomUUID) {
130
+ return g.crypto.randomUUID();
131
+ }
132
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
133
+ const r = Math.random() * 16 | 0;
134
+ const v = c === "x" ? r : r & 3 | 8;
135
+ return v.toString(16);
136
+ });
137
+ }
138
+ var StoreClient = class {
139
+ baseUrl;
140
+ publishableKey;
141
+ jwt;
142
+ fetchImpl;
143
+ logger;
144
+ retry;
145
+ sleepImpl;
146
+ /** Cached resolved regions (read-only catalog optimisation). */
147
+ regionsCache = null;
148
+ constructor(opts) {
149
+ if (!opts.baseUrl) {
150
+ throw new Error("StoreClient: baseUrl is required");
151
+ }
152
+ if (!opts.publishableKey) {
153
+ throw new Error("StoreClient: publishableKey is required");
154
+ }
155
+ if (/^sk_/.test(opts.publishableKey)) {
156
+ throw new Error("StoreClient: refusing a secret key \u2014 the client carries only the publishable key (T6)");
157
+ }
158
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
159
+ this.publishableKey = opts.publishableKey;
160
+ this.jwt = opts.jwt;
161
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
162
+ this.logger = opts.logger ?? console;
163
+ this.retry = { ...DEFAULT_RETRY, ...opts.retry ?? {} };
164
+ this.sleepImpl = opts.sleepImpl ?? sleep;
165
+ }
166
+ // ----- the request engine --------------------------------------------------
167
+ buildUrl(path, query) {
168
+ const url = new URL(this.baseUrl + path);
169
+ if (query) {
170
+ for (const [k, v] of Object.entries(query)) {
171
+ if (v !== void 0 && v !== null && v !== "") {
172
+ url.searchParams.set(k, String(v));
173
+ }
174
+ }
175
+ }
176
+ return url.toString();
177
+ }
178
+ baseHeaders() {
179
+ const headers = {
180
+ "Content-Type": "application/json",
181
+ "x-publishable-api-key": this.publishableKey
182
+ };
183
+ if (this.jwt) {
184
+ headers["Authorization"] = `Bearer ${this.jwt}`;
185
+ }
186
+ return headers;
187
+ }
188
+ backoffMs(attemptIndex) {
189
+ const exp = this.retry.baseDelayMs * 2 ** attemptIndex;
190
+ const capped = Math.min(exp, this.retry.maxDelayMs);
191
+ return Math.floor(Math.random() * (capped + 1));
192
+ }
193
+ async request(method, path, opts = {}) {
194
+ const headers = this.baseHeaders();
195
+ if (opts.idempotent) {
196
+ headers["Idempotency-Key"] = newUuid();
197
+ }
198
+ const url = this.buildUrl(path, opts.query);
199
+ const init = {
200
+ method,
201
+ headers,
202
+ ...opts.body !== void 0 ? { body: JSON.stringify(opts.body) } : {}
203
+ };
204
+ let lastErr;
205
+ for (let attempt = 0; attempt < this.retry.attempts; attempt++) {
206
+ let retryAfterMs = null;
207
+ try {
208
+ const res = await this.fetchImpl(url, init);
209
+ if (res.ok) {
210
+ const text = await res.text();
211
+ return text ? JSON.parse(text) : {};
212
+ }
213
+ let parsed = {};
214
+ try {
215
+ const t = await res.text();
216
+ parsed = t ? JSON.parse(t) : {};
217
+ } catch {
218
+ parsed = {};
219
+ }
220
+ if (opts.cartRecovery && (res.status === 404 || res.status === 409 || String(parsed.type ?? "").includes("cart.completed") || /cart.*complete/i.test(String(parsed.message ?? "")))) {
221
+ throw new RecoverableCartError(
222
+ `cart is stale or completed (HTTP ${res.status})`,
223
+ res.status
224
+ );
225
+ }
226
+ if (res.status === 429) {
227
+ retryAfterMs = parseRetryAfterMs(res);
228
+ lastErr = new StoreApiError(`rate limited (HTTP 429)`, 429, path);
229
+ this.logWarn("store.request.rate_limited", {
230
+ path,
231
+ attempt: attempt + 1,
232
+ retryAfterMs
233
+ });
234
+ } else if (res.status >= 400 && res.status < 500) {
235
+ if (!(opts.expected4xx ?? []).includes(res.status)) {
236
+ this.logError("store.request.client_error", { path, status: res.status, body: parsed });
237
+ }
238
+ throw new StoreApiError(
239
+ `request failed (HTTP ${res.status})`,
240
+ res.status,
241
+ path
242
+ );
243
+ } else {
244
+ lastErr = new StoreApiError(`upstream error (HTTP ${res.status})`, res.status, path);
245
+ this.logWarn("store.request.retryable", { path, status: res.status, attempt: attempt + 1 });
246
+ }
247
+ } catch (e) {
248
+ if (e instanceof RecoverableCartError) {
249
+ throw e;
250
+ }
251
+ if (e instanceof StoreApiError && e.status >= 400 && e.status < 500) {
252
+ throw e;
253
+ }
254
+ lastErr = e;
255
+ this.logWarn("store.request.network_retry", { path, attempt: attempt + 1 });
256
+ }
257
+ if (attempt < this.retry.attempts - 1) {
258
+ await this.sleepImpl(retryAfterMs ?? this.backoffMs(attempt));
259
+ }
260
+ }
261
+ this.logError("store.request.exhausted", { path, attempts: this.retry.attempts });
262
+ if (lastErr instanceof StoreApiError) {
263
+ throw lastErr;
264
+ }
265
+ throw new StoreApiError(
266
+ `request failed after ${this.retry.attempts} attempts: ${path}`,
267
+ 0,
268
+ path
269
+ );
270
+ }
271
+ logWarn(event, ctx) {
272
+ this.logger.warn(event, redact(ctx));
273
+ }
274
+ logError(event, ctx) {
275
+ this.logger.error(event, redact(ctx));
276
+ }
277
+ // ----- regions (F-A2 / INV-11) ---------------------------------------------
278
+ async getRegions() {
279
+ const data = await this.request("GET", "/store/regions");
280
+ this.regionsCache = data.regions ?? [];
281
+ return this.regionsCache;
282
+ }
283
+ /**
284
+ * Resolve a region by country code, falling back to region #1.
285
+ * SEK is region #1 by configuration — NEVER a hardcoded literal.
286
+ */
287
+ async resolveRegion(countryCode) {
288
+ const regions = this.regionsCache ?? await this.getRegions();
289
+ if (regions.length === 0) {
290
+ throw new Error("no regions configured \u2014 cannot resolve a region for pricing");
291
+ }
292
+ if (countryCode) {
293
+ const cc = countryCode.toLowerCase();
294
+ const match = regions.find(
295
+ (r) => (r.countries ?? []).some((c) => (c.iso_2 ?? "").toLowerCase() === cc)
296
+ );
297
+ if (match) {
298
+ return match;
299
+ }
300
+ }
301
+ return regions[0];
302
+ }
303
+ // ----- products (region resolved BEFORE pricing) ---------------------------
304
+ async listProducts(opts) {
305
+ const region = opts?.region ?? await this.resolveRegion(opts?.countryCode);
306
+ const data = await this.request("GET", "/store/products", {
307
+ query: { region_id: region.id, limit: 100 }
308
+ });
309
+ return data.products ?? [];
310
+ }
311
+ async getProduct(handle, opts) {
312
+ const region = opts?.region ?? await this.resolveRegion(opts?.countryCode);
313
+ const data = await this.request("GET", "/store/products", {
314
+ query: { handle, region_id: region.id, limit: 1 }
315
+ });
316
+ return (data.products ?? [])[0] ?? null;
317
+ }
318
+ // ----- cart (mutations carry Idempotency-Key) ------------------------------
319
+ async createCart(region) {
320
+ const data = await this.request("POST", "/store/carts", {
321
+ idempotent: true,
322
+ body: { region_id: region.id }
323
+ });
324
+ return data.cart;
325
+ }
326
+ async addLineItem(cartId, variantId, quantity) {
327
+ const data = await this.request(
328
+ "POST",
329
+ `/store/carts/${encodeURIComponent(cartId)}/line-items`,
330
+ {
331
+ idempotent: true,
332
+ cartRecovery: true,
333
+ body: { variant_id: variantId, quantity }
334
+ }
335
+ );
336
+ return data.cart;
337
+ }
338
+ async getCart(cartId) {
339
+ const data = await this.request(
340
+ "GET",
341
+ `/store/carts/${encodeURIComponent(cartId)}`,
342
+ { cartRecovery: true }
343
+ );
344
+ return data.cart;
345
+ }
346
+ /** Remove a staged line item (bt unstage). Returns the updated cart (Medusa's `parent`). */
347
+ async removeLineItem(cartId, itemId) {
348
+ const data = await this.request(
349
+ "DELETE",
350
+ `/store/carts/${encodeURIComponent(cartId)}/line-items/${encodeURIComponent(itemId)}`,
351
+ { idempotent: true, cartRecovery: true }
352
+ );
353
+ return data.parent ?? data.cart;
354
+ }
355
+ /**
356
+ * Ensure the cart has a shipping method before checkout (BUG 1 — the headless
357
+ * /store/headless/checkout route guards on it, and neither client ever set one).
358
+ *
359
+ * - Idempotent: if the cart already carries a shipping method, no-op (no POST).
360
+ * - Lists options via the STANDARD store route GET /store/shipping-options?cart_id=…
361
+ * (NOT a /headless route — standard routes are always available, no kill-switch).
362
+ * - Picks deterministically: exactly one → use it; several → the cheapest by amount.
363
+ * - The SE launch option is region-scoped (price_type:"flat"), so listing by cart_id
364
+ * returns it without a shipping address being set first (confirmed: seed-shipping.ts).
365
+ *
366
+ * TODO(04.5): buyer-choice / intl multiple shipping options — today we auto-pick the
367
+ * cheapest; an explicit buyer chooser belongs to the Phase-6 international pass.
368
+ */
369
+ async ensureCartShippingMethod(cartId) {
370
+ const cart = await this.getCart(cartId);
371
+ if (cart.shipping_methods && cart.shipping_methods.length > 0) {
372
+ return cart;
373
+ }
374
+ const data = await this.request(
375
+ "GET",
376
+ "/store/shipping-options",
377
+ { query: { cart_id: cartId } }
378
+ );
379
+ const options = data.shipping_options ?? [];
380
+ if (options.length === 0) {
381
+ throw new Error(
382
+ "no shipping option is available for this cart \u2014 cannot attach a shipping method"
383
+ );
384
+ }
385
+ const chosen = options.length === 1 ? options[0] : [...options].sort(
386
+ (a, b) => (a.amount ?? Number.POSITIVE_INFINITY) - (b.amount ?? Number.POSITIVE_INFINITY)
387
+ )[0];
388
+ const res = await this.request(
389
+ "POST",
390
+ `/store/carts/${encodeURIComponent(cartId)}/shipping-methods`,
391
+ {
392
+ idempotent: true,
393
+ cartRecovery: true,
394
+ body: { option_id: chosen.id }
395
+ }
396
+ );
397
+ return res.cart;
398
+ }
399
+ // ----- headless contract (04.5-01) -----------------------------------------
400
+ async mintCheckout(cartId, email, channel = "cli") {
401
+ return this.request("POST", "/store/headless/checkout", {
402
+ idempotent: true,
403
+ body: { cart_id: cartId, email, channel }
404
+ });
405
+ }
406
+ async poll(sessionId) {
407
+ return this.request(
408
+ "GET",
409
+ `/store/headless/poll/${encodeURIComponent(sessionId)}`
410
+ );
411
+ }
412
+ /** JWT-bound: returns the authenticated customer's own profile (G2). */
413
+ async whoami() {
414
+ return this.request("GET", "/store/headless/whoami", { expected4xx: [401, 403] });
415
+ }
416
+ // ----- magic-link auth (04.5-05) -------------------------------------------
417
+ // Passwordless login — magic-link is the only grant (Medusa has no OAuth pairing-grant, G1).
418
+ // request-link emails a single-use link; verify exchanges the link's token for a customer-scoped
419
+ // JWT. The JWT is stored in the OS keychain by the CLI — never here, never in plaintext config (G7/G8).
420
+ /** Start a magic-link login. Returns a UNIFORM ack regardless of whether the email exists (G12). */
421
+ async requestMagicLink(email) {
422
+ return this.request("POST", "/store/headless/auth/request-link", {
423
+ idempotent: true,
424
+ body: { email }
425
+ });
426
+ }
427
+ /** Exchange a magic-link token for a customer-scoped JWT (the customer is materialized server-side). */
428
+ async verifyMagicLink(token) {
429
+ return this.request("POST", "/store/headless/auth/verify", {
430
+ idempotent: true,
431
+ body: { token }
432
+ });
433
+ }
434
+ async listDrops() {
435
+ const data = await this.request(
436
+ "GET",
437
+ "/store/headless/drops"
438
+ );
439
+ return data.drops ?? [];
440
+ }
441
+ /**
442
+ * File a developer bug report (d14 — `bt report` / MCP report_issue). Returns a correlation
443
+ * reference. Additive: no money/cart path. The backend redacts authoritatively; callers SHOULD
444
+ * pass an already-redacted context (the doctor snapshot). A mutation → carries an Idempotency-Key
445
+ * so a retry can't email the report twice.
446
+ */
447
+ async report(message, context) {
448
+ return this.request("POST", "/store/headless/report", {
449
+ idempotent: true,
450
+ body: { message, ...context !== void 0 ? { context } : {} }
451
+ });
452
+ }
453
+ /**
454
+ * The authenticated customer's order history (d13 — `bt log`). JWT-bound; the backend derives the
455
+ * customer from the bearer token and returns MINIMAL fields (status/timeline only — never
456
+ * email/address, F-H3). A guest (no/expired JWT) gets a 401 — the EXPECTED unauthenticated branch
457
+ * we turn into the honest empty history (`[]`), NOT an error to dump (BUG 3).
458
+ */
459
+ async listOrders() {
460
+ try {
461
+ const data = await this.request("GET", "/store/headless/orders", {
462
+ expected4xx: [401, 403]
463
+ });
464
+ return data.orders ?? [];
465
+ } catch (e) {
466
+ if (e instanceof StoreApiError && (e.status === 401 || e.status === 403)) {
467
+ return [];
468
+ }
469
+ throw e;
470
+ }
471
+ }
472
+ /**
473
+ * Guest order lookup — requires order display id + EXACT email (F-H1).
474
+ * The backend returns 404 on a mismatch (no enumeration); the client surfaces null.
475
+ */
476
+ async getOrder(displayId, email) {
477
+ try {
478
+ return await this.request("GET", "/store/orders", {
479
+ query: { display_id: String(displayId), email }
480
+ });
481
+ } catch (e) {
482
+ if (e instanceof StoreApiError && e.status === 404) {
483
+ return null;
484
+ }
485
+ throw e;
486
+ }
487
+ }
488
+ };
489
+ var BT_DEFAULT_STORE_URL = "https://backend-production-511f5.up.railway.app";
490
+ var BT_DEFAULT_PUBLISHABLE_KEY = "pk_0d7d706860568a0414dff0436aadb9713a2609de3a1b153ef2a6f9694d23f907";
491
+ var BLOB_HOST_ALLOWLIST = [
492
+ "blob.vercel-storage.com"
493
+ // matches the exact host + *.blob.vercel-storage.com (dot-delimited subdomains only)
494
+ ];
495
+ function isAllowedImageHost(url) {
496
+ let host;
497
+ try {
498
+ host = new URL(url).hostname.toLowerCase();
499
+ } catch {
500
+ return false;
501
+ }
502
+ return BLOB_HOST_ALLOWLIST.some(
503
+ (allowed) => host === allowed || host.endsWith("." + allowed)
504
+ );
505
+ }
506
+ function displayColour(colour) {
507
+ const c = colour.trim();
508
+ if (!c) return c;
509
+ return c.charAt(0).toUpperCase() + c.slice(1).toLowerCase();
510
+ }
511
+ function variantColour(v) {
512
+ const opt = (v.options ?? []).find(
513
+ (o) => (o.option?.title ?? "").toLowerCase() === "colour" || (o.option?.title ?? "").toLowerCase() === "color"
514
+ );
515
+ if (opt && typeof opt.value === "string" && opt.value.trim()) {
516
+ return opt.value.trim().toLowerCase();
517
+ }
518
+ const title = (v.title ?? "").trim();
519
+ if (title.includes("/")) {
520
+ const parts = title.split("/").map((p) => p.trim());
521
+ const tail = parts[parts.length - 1];
522
+ if (tail) return tail.toLowerCase();
523
+ }
524
+ return null;
525
+ }
526
+ function productColours(p) {
527
+ const seen = [];
528
+ for (const v of p.variants ?? []) {
529
+ const c = variantColour(v);
530
+ if (c && !seen.includes(c)) {
531
+ seen.push(c);
532
+ }
533
+ }
534
+ return seen;
535
+ }
536
+ function sizesForColour(p, colour) {
537
+ const target = colour.trim().toLowerCase();
538
+ const sizes = [];
539
+ for (const v of p.variants ?? []) {
540
+ if (variantColour(v) !== target) continue;
541
+ const size = sizeOfVariant(v);
542
+ if (size && !sizes.includes(size)) {
543
+ sizes.push(size);
544
+ }
545
+ }
546
+ return sizes;
547
+ }
548
+ function sizeOfVariant(v) {
549
+ const opt = (v.options ?? []).find((o) => (o.option?.title ?? "").toLowerCase() === "size");
550
+ if (opt && typeof opt.value === "string" && opt.value.trim()) {
551
+ return opt.value.trim();
552
+ }
553
+ const title = (v.title ?? "").trim();
554
+ if (title.includes("/")) {
555
+ const head = title.split("/")[0]?.trim();
556
+ if (head) return head;
557
+ }
558
+ return title || null;
559
+ }
560
+ function findVariant(p, opts = {}) {
561
+ const variants = p.variants ?? [];
562
+ const size = opts.size?.trim().toLowerCase();
563
+ const colour = opts.colour?.trim().toLowerCase();
564
+ const sizeMatches = (v) => {
565
+ if (!size) return true;
566
+ return (sizeOfVariant(v) ?? "").toLowerCase() === size || (v.title ?? "").toLowerCase() === size;
567
+ };
568
+ const colourMatches = (v) => {
569
+ if (!colour) return true;
570
+ return variantColour(v) === colour;
571
+ };
572
+ if (size && colour) {
573
+ return variants.find((v) => sizeMatches(v) && colourMatches(v));
574
+ }
575
+ if (colour && !size) {
576
+ return variants.find((v) => colourMatches(v));
577
+ }
578
+ if (size && !colour) {
579
+ return variants.find((v) => sizeMatches(v)) ?? variants[0];
580
+ }
581
+ return variants[0];
582
+ }
583
+ function imageForColour(p, colour) {
584
+ const target = colour.trim().toLowerCase();
585
+ if (!target) return p.thumbnail ?? firstImageUrl(p.images) ?? null;
586
+ for (const v of p.variants ?? []) {
587
+ if (variantColour(v) !== target) continue;
588
+ const url = firstImageUrl(v.images);
589
+ if (url) return url;
590
+ }
591
+ for (const img of p.images ?? []) {
592
+ const tag = (img.metadata?.["colour"] ?? img.metadata?.["color"] ?? "").toString().toLowerCase();
593
+ if (tag === target) {
594
+ const u = (img.url ?? "").trim();
595
+ if (u) return u;
596
+ }
597
+ }
598
+ for (const img of p.images ?? []) {
599
+ const u = (img.url ?? "").trim();
600
+ if (u && new RegExp(`(^|[^a-z])${escapeRegex(target)}([^a-z]|$)`, "i").test(u)) {
601
+ return u;
602
+ }
603
+ }
604
+ return null;
605
+ }
606
+ function firstImageUrl(images) {
607
+ for (const img of images ?? []) {
608
+ const u = (img?.url ?? "").trim();
609
+ if (u) return u;
610
+ }
611
+ return null;
612
+ }
613
+ function escapeRegex(s) {
614
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
615
+ }
616
+
617
+ // src/voice.ts
618
+ var GLYPH = {
619
+ ok: "\u2713",
620
+ fail: "\u2717",
621
+ warn: "\u26A0",
622
+ gear: "\u2699",
623
+ arrow: "\u2192"
624
+ };
625
+ function noShout(s) {
626
+ return s.replace(/!+/g, ".");
627
+ }
628
+ function ok(msg) {
629
+ return `${GLYPH.ok} ${noShout(msg)}`;
630
+ }
631
+ function warn(msg) {
632
+ return `${GLYPH.warn} ${noShout(msg)}`;
633
+ }
634
+ function working(msg) {
635
+ return `${GLYPH.gear} ${noShout(msg)}`;
636
+ }
637
+ function empty(msg) {
638
+ return noShout(msg);
639
+ }
640
+ function fail(spec) {
641
+ const lines = [];
642
+ lines.push(`${GLYPH.fail} exit ${spec.code} \u2014 ${noShout(spec.what)}`);
643
+ if (spec.money) {
644
+ lines.push(` ${noShout(spec.money)}`);
645
+ }
646
+ if (spec.fix) {
647
+ lines.push(` fix ${GLYPH.arrow} ${noShout(spec.fix)}`);
648
+ }
649
+ if (spec.link) {
650
+ lines.push(` link ${GLYPH.arrow} ${spec.link}`);
651
+ }
652
+ return lines.join("\n");
653
+ }
654
+ var CODENAMES = [
655
+ "tabs-over-spaces",
656
+ "vim-not-emacs",
657
+ "2-space-indent",
658
+ "trailing-comma"
659
+ ];
660
+ function codename(_version) {
661
+ return CODENAMES[0];
662
+ }
663
+ function versionLine(version) {
664
+ return `bt ${version} "${codename(version)}"`;
665
+ }
666
+
667
+ // src/commands/shared.ts
668
+ function brandOf(args) {
669
+ return args?.brand ?? PLAIN;
670
+ }
671
+ function fmtPrice(amount, currency) {
672
+ if (amount == null) {
673
+ return "\u2014";
674
+ }
675
+ const cur = (currency ?? "").toUpperCase();
676
+ return cur ? `${amount} ${cur}` : `${amount}`;
677
+ }
678
+ function firstPrice(p) {
679
+ const v = (p.variants ?? [])[0];
680
+ return {
681
+ amount: v?.calculated_price?.calculated_amount ?? null,
682
+ currency: v?.calculated_price?.currency_code ?? null
683
+ };
684
+ }
685
+ function stageMarker(channelStage) {
686
+ return channelStage === "next" ? "\u25CB on next" : "\u25CF in stock";
687
+ }
688
+ function displayName(p) {
689
+ const handle = (p.handle ?? p.id ?? "").toString();
690
+ const title = (p.title ?? "").toString().trim() || handle;
691
+ return { title, handle };
692
+ }
693
+ function lineColour(item) {
694
+ const vt = (item.variant_title ?? "").toString();
695
+ if (vt) {
696
+ const c = variantColour({ id: "x", title: vt });
697
+ if (c) return c;
698
+ }
699
+ return null;
700
+ }
701
+ function shortItemId(id) {
702
+ const s = (id ?? "").toString();
703
+ return s.length <= 6 ? s : s.slice(-6);
704
+ }
705
+ function formatCartLine(item, index, currency, brand) {
706
+ const style = brand ?? PLAIN;
707
+ const ref = `[${index + 1}]`;
708
+ const title = (item.title ?? item.product_title ?? "item").toString();
709
+ const idTag = item.id ? dimTag(style, ` (#${shortItemId(item.id)})`) : "";
710
+ const colour = lineColour(item);
711
+ const colourTag = colour ? ` \xB7 ${colour}` : "";
712
+ const price = item.total != null ? ` ${accentPrice(style, fmtPrice(item.total, currency))}` : "";
713
+ return ` ${ref} ${title}${colourTag}${idTag} \xB7 qty ${item.quantity}${price}`;
714
+ }
715
+ function dimTag(style, s) {
716
+ return style.color ? `\x1B[2m${s}\x1B[0m` : s;
717
+ }
718
+ function accentPrice(style, s) {
719
+ return style.color ? `\x1B[38;5;48m${s}\x1B[0m` : s;
720
+ }
721
+ function resolveLineItemRef(items, ref) {
722
+ const needle = (ref ?? "").trim();
723
+ if (!needle || items.length === 0) {
724
+ return null;
725
+ }
726
+ if (/^\d+$/.test(needle)) {
727
+ const idx = Number(needle) - 1;
728
+ if (idx >= 0 && idx < items.length) {
729
+ return items[idx];
730
+ }
731
+ }
732
+ const byId = items.find((li) => li.id === needle || shortItemId(li.id) === needle);
733
+ if (byId) {
734
+ return byId;
735
+ }
736
+ const lower = needle.toLowerCase();
737
+ const byTitle = items.find(
738
+ (li) => (li.title ?? li.product_title ?? "").toString().toLowerCase().includes(lower)
739
+ );
740
+ return byTitle ?? null;
741
+ }
742
+
743
+ // src/image-ladder.ts
744
+ var DEFAULT_RENDER_HEIGHT = 28;
745
+ function urlFloor(url) {
746
+ return ` ${url}`;
747
+ }
748
+ async function renderImage(blobUrl, opts = {}) {
749
+ const noColor = opts.noColor ?? Boolean(process.env.NO_COLOR);
750
+ if (!isAllowedImageHost(blobUrl)) {
751
+ return ` (image host not allowed \u2014 showing link only)
752
+ ${urlFloor(blobUrl)}`;
753
+ }
754
+ if (noColor || opts.forceNoRenderer) {
755
+ return urlFloor(blobUrl);
756
+ }
757
+ try {
758
+ const rendered = opts.renderer ? await opts.renderer(blobUrl) : await defaultRender(blobUrl, opts);
759
+ return `${rendered}
760
+ ${urlFloor(blobUrl)}`;
761
+ } catch {
762
+ return ` (image unavailable)
763
+ ${urlFloor(blobUrl)}`;
764
+ }
765
+ }
766
+ var IMAGE_FETCH_TIMEOUT_MS = 5e3;
767
+ async function defaultRender(url, opts) {
768
+ const mod = await import("terminal-image");
769
+ const terminalImage = mod.default ?? mod;
770
+ const bufferFn = terminalImage.buffer;
771
+ if (!bufferFn) {
772
+ throw new Error("terminal-image.buffer unavailable");
773
+ }
774
+ const ctrl = new AbortController();
775
+ const timer = setTimeout(() => ctrl.abort(), IMAGE_FETCH_TIMEOUT_MS);
776
+ let res;
777
+ try {
778
+ res = await fetch(url, { signal: ctrl.signal });
779
+ } finally {
780
+ clearTimeout(timer);
781
+ }
782
+ if (!res.ok) {
783
+ throw new Error(`blob fetch failed: HTTP ${res.status}`);
784
+ }
785
+ const buf = Buffer.from(await res.arrayBuffer());
786
+ return bufferFn(buf, {
787
+ height: opts.height ?? DEFAULT_RENDER_HEIGHT,
788
+ preserveAspectRatio: true,
789
+ preferNativeRender: true
790
+ });
791
+ }
792
+
793
+ // src/commands/browse.ts
794
+ function productRow(p, stage, style) {
795
+ const { amount, currency } = firstPrice(p);
796
+ const { title, handle } = displayName(p);
797
+ const colours = colourMarker(productColours(p));
798
+ const parts = [
799
+ title.padEnd(22),
800
+ cyan(style, fmtPrice(amount, currency).padEnd(11)),
801
+ dim(style, handle.padEnd(34)),
802
+ stageMarker(stage)
803
+ ];
804
+ const row = parts.join(" ").trimEnd();
805
+ return colours ? `${row}
806
+ ${dim(style, colours)}` : row;
807
+ }
808
+ async function runBrowse(client, config, args) {
809
+ const style = brandOf(args);
810
+ const region = await client.resolveRegion(config.region);
811
+ const products = await client.listProducts({ region });
812
+ const rows = products.map((p) => ({
813
+ p,
814
+ stage: p.metadata?.["channel_stage"] ?? "main"
815
+ }));
816
+ let drops = [];
817
+ if (args.branch === "next") {
818
+ drops = await client.listDrops();
819
+ }
820
+ const lines = [];
821
+ if (rows.length === 0 && drops.length === 0) {
822
+ lines.push(empty("nothing in stock on this branch yet. main is the latest."));
823
+ lines.push(" peek at next \u2192 bt browse --branch=next");
824
+ } else {
825
+ lines.push(header(style, "bt browse \u2014 in stock"));
826
+ for (const { p, stage } of rows) {
827
+ lines.push(productRow(p, stage, style));
828
+ }
829
+ for (const d of drops) {
830
+ const dt = (d.title ?? d.handle ?? d.id).toString();
831
+ lines.push(
832
+ [
833
+ dt.padEnd(22),
834
+ cyan(style, fmtPrice(d.price, region.currency_code).padEnd(11)),
835
+ dim(style, (d.handle ?? d.id).padEnd(34)),
836
+ stageMarker(d.channel_stage)
837
+ ].join(" ").trimEnd()
838
+ );
839
+ }
840
+ if (drops.length > 0) {
841
+ lines.push(" on next, before the web \u2192 bt drops");
842
+ }
843
+ lines.push(" view one \u2192 bt view <handle> \xB7 stage one \u2192 bt stage <handle> --size L");
844
+ }
845
+ return {
846
+ lines,
847
+ json: args.json ? products.map((p) => ({
848
+ id: p.id,
849
+ handle: p.handle,
850
+ title: p.title,
851
+ price: firstPrice(p).amount,
852
+ currency: firstPrice(p).currency,
853
+ colours: productColours(p),
854
+ channel_stage: p.metadata?.["channel_stage"] ?? "main"
855
+ })) : void 0
856
+ };
857
+ }
858
+ function registerBrowse(program, makeClient, loadConfig) {
859
+ program.command("browse").alias("ls").description("list what's in stock on a branch.").option("--branch <branch>", "main (live) or next (terminal-only drops)").option("--plain", "force the static table (skip the interactive picker)").action(async (opts) => {
860
+ const gOpts = program.opts();
861
+ const json = Boolean(gOpts.json);
862
+ const noColor = gOpts.color === false;
863
+ const brand = json ? makeBrand({ noColor: true }) : makeBrand({ noColor });
864
+ if (!json && !opts.plain && brand.color && process.stdout.isTTY) {
865
+ const { runInteractiveBrowse } = await import("./interactive-TFZOY3JU.js");
866
+ const handled = await runInteractiveBrowse(makeClient(), loadConfig(), { branch: opts.branch, brand });
867
+ if (handled) return;
868
+ }
869
+ const res = await runBrowse(makeClient(), loadConfig(), { branch: opts.branch, json, brand });
870
+ printResult(res, json);
871
+ });
872
+ }
873
+ function printResult(res, json) {
874
+ if (json && res.json !== void 0) {
875
+ process.stdout.write(JSON.stringify(res.json, null, 2) + "\n");
876
+ return;
877
+ }
878
+ process.stdout.write(res.lines.join("\n") + "\n");
879
+ }
880
+
881
+ // src/commands/view.ts
882
+ async function runView(client, config, args) {
883
+ const style = brandOf(args);
884
+ const region = await client.resolveRegion(config.region);
885
+ const product = await client.getProduct(args.handle, { region });
886
+ if (!product) {
887
+ return {
888
+ lines: [
889
+ fail({
890
+ code: 1,
891
+ what: `variant ${args.handle} not found.`,
892
+ fix: "bt browse (to see what's in stock)"
893
+ })
894
+ ]
895
+ };
896
+ }
897
+ const { title, handle } = displayName(product);
898
+ const colours = productColours(product);
899
+ const lines = [];
900
+ lines.push(header(style, title));
901
+ lines.push(` ${dim(style, handle)}`);
902
+ if (product.description) {
903
+ lines.push(empty(` ${product.description}`));
904
+ }
905
+ const fabric = product.metadata?.["fabric"] ?? null;
906
+ if (fabric) {
907
+ lines.push(` fabric ${fabric}`);
908
+ }
909
+ if (colours.length > 0) {
910
+ lines.push(` colours ${colours.map((c) => `${colourSwatch(c)} ${c}`).join(" \xB7 ")}`);
911
+ for (const c of colours) {
912
+ const sizes = sizesForColour(product, c);
913
+ lines.push(` ${colourSwatch(c)} ${c.padEnd(6)} ${sizes.join(" ")}`);
914
+ }
915
+ } else {
916
+ const sizes = (product.variants ?? []).map((v) => v.title ?? "?");
917
+ lines.push(` sizes ${sizes.join(" ")}`);
918
+ }
919
+ const v0 = (product.variants ?? [])[0];
920
+ if (v0?.calculated_price) {
921
+ lines.push(
922
+ ` price ${cyan(style, fmtPrice(v0.calculated_price.calculated_amount, v0.calculated_price.currency_code))} incl. VAT`
923
+ );
924
+ }
925
+ let imageUrl = product.thumbnail ?? null;
926
+ let blackNote = null;
927
+ if (args.colour) {
928
+ const wanted = args.colour.trim().toLowerCase();
929
+ const resolved = imageForColour(product, wanted);
930
+ if (resolved) {
931
+ imageUrl = resolved;
932
+ } else {
933
+ blackNote = ` ${displayColour(wanted)} available \u2014 showing the default mockup (no ${wanted} render on file).`;
934
+ }
935
+ }
936
+ if (imageUrl) {
937
+ const rendered = await renderImage(imageUrl, { noColor: args.noColor });
938
+ lines.push(rendered);
939
+ }
940
+ if (blackNote) {
941
+ lines.push(blackNote);
942
+ }
943
+ if (colours.length > 1 && !args.colour) {
944
+ const others = colours.filter((c) => c !== colours[0]).join(", ");
945
+ lines.push(` see another colour \u2192 bt view ${handle} --colour ${others.split(",")[0]?.trim()}`);
946
+ }
947
+ return {
948
+ lines,
949
+ json: args.json ? {
950
+ id: product.id,
951
+ handle: product.handle,
952
+ title: product.title,
953
+ fabric,
954
+ colours,
955
+ thumbnail: product.thumbnail,
956
+ variants: (product.variants ?? []).map((v) => ({
957
+ id: v.id,
958
+ size: v.title,
959
+ price: v.calculated_price?.calculated_amount ?? null,
960
+ currency: v.calculated_price?.currency_code ?? null
961
+ }))
962
+ } : void 0
963
+ };
964
+ }
965
+ function registerView(program, makeClient, loadConfig) {
966
+ program.command("view").alias("show").argument("<handle>", "the product handle/slug").option("--colour <colour>", "render a specific colour's mockup (e.g. black)").description("render a design: specs + image (always with the link as the floor).").action(async (handle, opts) => {
967
+ const gOpts = program.opts();
968
+ const json = Boolean(gOpts.json);
969
+ const noColor = gOpts.color === false;
970
+ const brand = json ? makeBrand({ noColor: true }) : makeBrand({ noColor });
971
+ const res = await runView(makeClient(), loadConfig(), {
972
+ handle,
973
+ colour: opts.colour,
974
+ json,
975
+ noColor,
976
+ brand
977
+ });
978
+ printResult(res, json);
979
+ });
980
+ }
981
+
982
+ // src/commands/stage.ts
983
+ async function runStage(client, config, args) {
984
+ const style = brandOf(args);
985
+ const region = await client.resolveRegion(config.region);
986
+ const product = await client.getProduct(args.handle, { region });
987
+ if (!product) {
988
+ return {
989
+ lines: [
990
+ fail({ code: 1, what: `variant ${args.handle} not found.`, fix: "bt browse" })
991
+ ]
992
+ };
993
+ }
994
+ const size = args.size ?? config.defaultSize;
995
+ const colours = productColours(product);
996
+ const colour = args.colour ?? config.defaultColour ?? (colours.length > 0 ? colours[0] : void 0);
997
+ const variant = findVariant(product, { size, colour });
998
+ if (!variant) {
999
+ const have = colours.length ? ` available colours: ${colours.join(", ")}.` : "";
1000
+ return {
1001
+ lines: [
1002
+ fail({
1003
+ code: 1,
1004
+ what: `no variant for ${args.handle}${size ? `#size=${size}` : ""}${colour ? ` (${colour})` : ""}.`,
1005
+ fix: `bt view ${args.handle} (to see available sizes + colours)${have}`
1006
+ })
1007
+ ]
1008
+ };
1009
+ }
1010
+ if (args.dryRun) {
1011
+ const colourSuffix2 = colour ? ` (${colour})` : "";
1012
+ return {
1013
+ lines: [
1014
+ ok(`dry-run \u2014 would stage ${product.handle ?? args.handle}${size ? `#${size}` : ""}${colourSuffix2} (nothing staged).`),
1015
+ " run without --dry-run to acquire the inventory lock."
1016
+ ],
1017
+ json: args.json ? {
1018
+ dry_run: true,
1019
+ would_stage: {
1020
+ handle: product.handle ?? args.handle,
1021
+ size: size ?? null,
1022
+ colour: colour ?? null,
1023
+ variant_id: variant.id
1024
+ }
1025
+ } : void 0
1026
+ };
1027
+ }
1028
+ const lines = [];
1029
+ let cartId = config.cart_id;
1030
+ let recovered = false;
1031
+ if (!cartId) {
1032
+ const fresh = await client.createCart(region);
1033
+ cartId = fresh.id;
1034
+ }
1035
+ try {
1036
+ await client.addLineItem(cartId, variant.id, 1);
1037
+ } catch (e) {
1038
+ if (e instanceof RecoverableCartError) {
1039
+ const fresh = await client.createCart(region);
1040
+ cartId = fresh.id;
1041
+ await client.addLineItem(cartId, variant.id, 1);
1042
+ recovered = true;
1043
+ } else {
1044
+ throw e;
1045
+ }
1046
+ }
1047
+ const cart = await client.getCart(cartId);
1048
+ if (recovered) {
1049
+ lines.push(
1050
+ warn(
1051
+ "your previous cart had expired or was already committed \u2014 created a fresh cart and re-added it."
1052
+ )
1053
+ );
1054
+ }
1055
+ const colourSuffix = colour ? ` (${colour})` : "";
1056
+ lines.push(
1057
+ ok(
1058
+ `staged ${product.handle ?? args.handle}${size ? `#${size}` : ""}${colourSuffix} \xB7 inventory lock acquired (held 10m)`
1059
+ )
1060
+ );
1061
+ (cart.items ?? []).forEach((item, i) => {
1062
+ lines.push(formatCartLine(item, i, cart.currency_code, style));
1063
+ });
1064
+ const itemCount = (cart.items ?? []).reduce((n, li) => n + (li.quantity ?? 1), 0);
1065
+ lines.push(` ${itemCount} item${itemCount === 1 ? "" : "s"} staged \u2192 bt commit (or bt buy to do both)`);
1066
+ return {
1067
+ // persist the cart_id atomically BEFORE returning (the router calls config.save)
1068
+ save: { cart_id: cartId },
1069
+ lines,
1070
+ json: args.json ? { cart_id: cartId, recovered, colour: colour ?? null, items: cart.items ?? [], total: cart.total ?? null } : void 0
1071
+ };
1072
+ }
1073
+ function registerStage(program, makeClient, loadConfig) {
1074
+ program.command("stage").alias("add").argument("<handle>", "the product handle/slug").option("--size <size>", "garment size (default: your last commit's size)").option("--colour <colour>", "colour, e.g. white / black (default: the product's first colour)").option("--dry-run", "resolve the variant + show what would be staged, without staging").description("reserve stock \u2014 acquires an inventory lock, reserves nothing permanent.").action(async (handle, opts) => {
1075
+ const gOpts = program.opts();
1076
+ const json = Boolean(gOpts.json);
1077
+ const brand = json ? makeBrand({ noColor: true }) : makeBrand({ noColor: gOpts.color === false });
1078
+ const res = await runStage(makeClient(), loadConfig(), {
1079
+ handle,
1080
+ size: opts.size,
1081
+ colour: opts.colour,
1082
+ dryRun: opts.dryRun,
1083
+ json,
1084
+ brand
1085
+ });
1086
+ if (res.save) {
1087
+ const { saveConfig } = await import("./config-FXGCWC6Y.js");
1088
+ saveConfig(res.save);
1089
+ }
1090
+ printResult(res, json);
1091
+ });
1092
+ }
1093
+
1094
+ export {
1095
+ redact,
1096
+ RecoverableCartError,
1097
+ StoreApiError,
1098
+ StoreClient,
1099
+ BT_DEFAULT_STORE_URL,
1100
+ BT_DEFAULT_PUBLISHABLE_KEY,
1101
+ productColours,
1102
+ sizesForColour,
1103
+ findVariant,
1104
+ GLYPH,
1105
+ ok,
1106
+ warn,
1107
+ working,
1108
+ empty,
1109
+ fail,
1110
+ versionLine,
1111
+ brandOf,
1112
+ fmtPrice,
1113
+ firstPrice,
1114
+ stageMarker,
1115
+ displayName,
1116
+ lineColour,
1117
+ formatCartLine,
1118
+ resolveLineItemRef,
1119
+ runView,
1120
+ registerView,
1121
+ runStage,
1122
+ registerStage,
1123
+ registerBrowse,
1124
+ printResult
1125
+ };
1126
+ //# sourceMappingURL=chunk-DWTH7CNI.js.map