@binaryandthread/cli 0.1.8 → 0.2.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.
@@ -2,6 +2,17 @@
2
2
  import "./chunk-7OCVIDC7.js";
3
3
 
4
4
  // src/browser.ts
5
+ var DEFAULT_CHECKOUT_HOSTS = ["binaryandthread.com"];
6
+ function isSafeCheckoutUrl(url, allowedHosts = DEFAULT_CHECKOUT_HOSTS) {
7
+ try {
8
+ const u = new URL(url);
9
+ if (u.protocol !== "https:") return false;
10
+ const host = u.hostname.toLowerCase();
11
+ return allowedHosts.some((a) => host === a || host.endsWith("." + a));
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
5
16
  async function defaultOpener(url) {
6
17
  const mod = await import("open");
7
18
  return mod.default(url);
@@ -24,15 +35,22 @@ function renderQr(url, qr) {
24
35
  async function openCheckout(url, opts = {}) {
25
36
  const opener = opts.opener ?? defaultOpener;
26
37
  const qr = opts.qr ?? await defaultQr();
27
- const qrString = renderQr(url, qr);
38
+ const safe = isSafeCheckoutUrl(url, opts.allowedHosts);
39
+ const lines = [];
28
40
  let opened = false;
41
+ if (!safe) {
42
+ lines.push("\u26A0 this checkout link isn't the expected binaryandthread.com https page.");
43
+ lines.push(" not opening it automatically \u2014 verify the domain, then open the link below yourself.");
44
+ lines.push(` link \u2192 ${url}`);
45
+ return { opened: false, lines };
46
+ }
47
+ const qrString = renderQr(url, qr);
29
48
  try {
30
49
  await opener(url);
31
50
  opened = true;
32
51
  } catch {
33
52
  opened = false;
34
53
  }
35
- const lines = [];
36
54
  lines.push("\u2699 opening checkout in your browser. cards never touch the terminal.");
37
55
  if (qrString) {
38
56
  lines.push(qrString);
@@ -45,6 +63,7 @@ async function openCheckout(url, opts = {}) {
45
63
  return { opened, lines };
46
64
  }
47
65
  export {
66
+ isSafeCheckoutUrl,
48
67
  openCheckout
49
68
  };
50
- //# sourceMappingURL=browser-EO5EQUXQ.js.map
69
+ //# sourceMappingURL=browser-WCNC4AWS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/browser.ts"],"sourcesContent":["// The browser/QR handoff — the Spike-A floor's client side (F-C5).\n//\n// The terminal NEVER takes a card. After minting the checkout, we hand the buyer off to the\n// storefront checkout page (Stripe Elements lives there, not here). We do that two ways at\n// once so the buyer always has a path:\n// 1. open the default browser at the checkout url, AND\n// 2. render a scannable QR of the same url (so a phone can complete the checkout).\n//\n// This MUST degrade gracefully: a headless/SSH/CI session has no browser to open, so a\n// failed open is normal, not an error. On failure we still print the url + the QR and a\n// one-line \"open this manually\" hint, and we NEVER throw — a buy is never blocked on the\n// browser opening cleanly (F-C5). The `open` and `qrcode-terminal` libraries are injected so\n// the handoff is testable without spawning a real browser or a real QR renderer.\n\n/** The hosts a checkout_url is allowed to point at before we'll auto-open it. The brand storefront\n * (checkout_url = STOREFRONT_URL + path, and STOREFRONT_URL is binaryandthread.com in prod). */\nconst DEFAULT_CHECKOUT_HOSTS = [\"binaryandthread.com\"]\n\n/**\n * Is `url` safe to auto-open? Only https on an allowed host (exact or a subdomain). The checkout_url\n * comes from the backend; this is the last gate before an OS-level open() — it refuses http/file/\n * custom-scheme and a swapped host, so a coerced URL can't be launched or QR'd (defense-in-depth).\n */\nexport function isSafeCheckoutUrl(url: string, allowedHosts: string[] = DEFAULT_CHECKOUT_HOSTS): boolean {\n try {\n const u = new URL(url)\n if (u.protocol !== \"https:\") return false\n const host = u.hostname.toLowerCase()\n return allowedHosts.some((a) => host === a || host.endsWith(\".\" + a))\n } catch {\n return false\n }\n}\n\n/** open the url in the default browser; returns when the handoff is launched. */\nexport type Opener = (url: string) => Promise<unknown>\n\n/** qrcode-terminal-shaped renderer: generate(url, opts, cb) → cb(qrString). */\nexport type QrRenderer = (\n url: string,\n opts: { small?: boolean },\n cb: (qr: string) => void\n) => void\n\nexport interface OpenCheckoutOptions {\n /** injectable browser opener (default: the `open` npm package). */\n opener?: Opener\n /** injectable QR renderer (default: the `qrcode-terminal` package). */\n qr?: QrRenderer\n /** hosts a checkout_url may point at to be auto-opened (default: the brand storefront). */\n allowedHosts?: string[]\n}\n\nexport interface OpenCheckoutResult {\n /** whether the default browser was launched (false on a headless/CI session). */\n opened: boolean\n /** voice-compliant output lines (QR + url floor + a manual-open hint when needed). */\n lines: string[]\n}\n\n/** Lazily load the real `open` package (ESM, spawns OS binaries — kept external by tsup). */\nasync function defaultOpener(url: string): Promise<unknown> {\n const mod = (await import(\"open\")) as unknown as { default: Opener }\n return mod.default(url)\n}\n\n/** Lazily load the real `qrcode-terminal` package. */\nasync function defaultQr(): Promise<QrRenderer> {\n const mod = (await import(\"qrcode-terminal\")) as unknown as {\n default: { generate: QrRenderer }\n }\n return mod.default.generate\n}\n\n/** Render the QR string for `url`, never throwing (returns \"\" if the renderer blows up). */\nfunction renderQr(url: string, qr: QrRenderer): string {\n try {\n let out = \"\"\n qr(url, { small: true }, (s) => {\n out = s\n })\n return out\n } catch {\n return \"\"\n }\n}\n\n/**\n * Open the storefront checkout in the browser AND print a scannable QR. Degrades gracefully\n * (never throws): on a failed browser open it still emits the QR + the url + a manual hint.\n * The caller prints `result.lines`; cards never touch the terminal — they live on that page.\n */\nexport async function openCheckout(\n url: string,\n opts: OpenCheckoutOptions = {}\n): Promise<OpenCheckoutResult> {\n const opener = opts.opener ?? defaultOpener\n const qr = opts.qr ?? (await defaultQr())\n const safe = isSafeCheckoutUrl(url, opts.allowedHosts)\n\n const lines: string[] = []\n let opened = false\n\n if (!safe) {\n // The URL isn't the expected brand https page → NEVER auto-open or QR it (a coerced/misconfigured\n // checkout_url could be a phishing origin or a non-http scheme). Show it with a warning so the\n // buyer can inspect + decide; the buy is not blocked (the link floor is still printed).\n lines.push(\"⚠ this checkout link isn't the expected binaryandthread.com https page.\")\n lines.push(\" not opening it automatically — verify the domain, then open the link below yourself.\")\n lines.push(` link → ${url}`)\n return { opened: false, lines }\n }\n\n // Safe: render the QR (part of the floor) + try to open the browser.\n const qrString = renderQr(url, qr)\n try {\n await opener(url)\n opened = true\n } catch {\n // headless / SSH / CI — no browser to open. Not an error; degrade to the manual floor.\n opened = false\n }\n\n lines.push(\"⚙ opening checkout in your browser. cards never touch the terminal.\")\n if (qrString) {\n lines.push(qrString)\n lines.push(\" scan the code above to finish on your phone.\")\n }\n if (!opened) {\n lines.push(\" couldn't open a browser here — open this link manually:\")\n }\n // the url is the floor — always printed last so it's the easy copy target.\n lines.push(` link → ${url}`)\n\n return { opened, lines }\n}\n"],"mappings":";;;;AAgBA,IAAM,yBAAyB,CAAC,qBAAqB;AAO9C,SAAS,kBAAkB,KAAa,eAAyB,wBAAiC;AACvG,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,QAAI,EAAE,aAAa,SAAU,QAAO;AACpC,UAAM,OAAO,EAAE,SAAS,YAAY;AACpC,WAAO,aAAa,KAAK,CAAC,MAAM,SAAS,KAAK,KAAK,SAAS,MAAM,CAAC,CAAC;AAAA,EACtE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AA6BA,eAAe,cAAc,KAA+B;AAC1D,QAAM,MAAO,MAAM,OAAO,MAAM;AAChC,SAAO,IAAI,QAAQ,GAAG;AACxB;AAGA,eAAe,YAAiC;AAC9C,QAAM,MAAO,MAAM,OAAO,iBAAiB;AAG3C,SAAO,IAAI,QAAQ;AACrB;AAGA,SAAS,SAAS,KAAa,IAAwB;AACrD,MAAI;AACF,QAAI,MAAM;AACV,OAAG,KAAK,EAAE,OAAO,KAAK,GAAG,CAAC,MAAM;AAC9B,YAAM;AAAA,IACR,CAAC;AACD,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOA,eAAsB,aACpB,KACA,OAA4B,CAAC,GACA;AAC7B,QAAM,SAAS,KAAK,UAAU;AAC9B,QAAM,KAAK,KAAK,MAAO,MAAM,UAAU;AACvC,QAAM,OAAO,kBAAkB,KAAK,KAAK,YAAY;AAErD,QAAM,QAAkB,CAAC;AACzB,MAAI,SAAS;AAEb,MAAI,CAAC,MAAM;AAIT,UAAM,KAAK,+EAA0E;AACrF,UAAM,KAAK,8FAAyF;AACpG,UAAM,KAAK,kBAAa,GAAG,EAAE;AAC7B,WAAO,EAAE,QAAQ,OAAO,MAAM;AAAA,EAChC;AAGA,QAAM,WAAW,SAAS,KAAK,EAAE;AACjC,MAAI;AACF,UAAM,OAAO,GAAG;AAChB,aAAS;AAAA,EACX,QAAQ;AAEN,aAAS;AAAA,EACX;AAEA,QAAM,KAAK,2EAAsE;AACjF,MAAI,UAAU;AACZ,UAAM,KAAK,QAAQ;AACnB,UAAM,KAAK,iDAAiD;AAAA,EAC9D;AACA,MAAI,CAAC,QAAQ;AACX,UAAM,KAAK,iEAA4D;AAAA,EACzE;AAEA,QAAM,KAAK,kBAAa,GAAG,EAAE;AAE7B,SAAO,EAAE,QAAQ,MAAM;AACzB;","names":[]}
@@ -286,24 +286,45 @@ var StoreClient = class {
286
286
  return this.regionsCache;
287
287
  }
288
288
  /**
289
- * Resolve a region by country code, falling back to region #1.
290
- * SEK is region #1 by configuration — NEVER a hardcoded literal.
289
+ * Resolve a region by country code, WITH provenance (matched / home / requested), so callers on
290
+ * the money path can fail closed on an unmatched explicit country instead of silently pricing in
291
+ * the wrong currency (F-A2). Region #1 is the launch/home default — SEK today, derived, never a
292
+ * hardcoded literal.
293
+ *
294
+ * Semantics:
295
+ * - country given AND a region ships there → { matched: true, home: false }.
296
+ * - country given, none ships there → { matched: false, home: false } (money surfaces MUST
297
+ * refuse; discovery may show the home region + a warning). We still return a region so a
298
+ * discovery command can render *something*, but `matched:false` is the signal to fail closed.
299
+ * - no country given → { matched: false, home: true } (the legitimate
300
+ * launch default — not a "wrong" currency).
291
301
  */
292
- async resolveRegion(countryCode) {
302
+ async resolveRegionResult(countryCode) {
293
303
  const regions = this.regionsCache ?? await this.getRegions();
294
304
  if (regions.length === 0) {
295
305
  throw new Error("no regions configured \u2014 cannot resolve a region for pricing");
296
306
  }
307
+ const home = regions[0];
297
308
  if (countryCode) {
298
309
  const cc = countryCode.toLowerCase();
299
310
  const match = regions.find(
300
311
  (r) => (r.countries ?? []).some((c) => (c.iso_2 ?? "").toLowerCase() === cc)
301
312
  );
302
313
  if (match) {
303
- return match;
314
+ return { region: match, matched: true, requested: cc, home: false };
304
315
  }
316
+ this.logWarn("store.region.unmatched", { countryCode: cc, fallback: home.id });
317
+ return { region: home, matched: false, requested: cc, home: false };
305
318
  }
306
- return regions[0];
319
+ return { region: home, matched: false, requested: void 0, home: true };
320
+ }
321
+ /**
322
+ * Resolve a region by country code, falling back to region #1. A thin wrapper over
323
+ * resolveRegionResult for callers that only need the region (back-compat).
324
+ * SEK is region #1 by configuration — NEVER a hardcoded literal.
325
+ */
326
+ async resolveRegion(countryCode) {
327
+ return (await this.resolveRegionResult(countryCode)).region;
307
328
  }
308
329
  // ----- products (region resolved BEFORE pricing) ---------------------------
309
330
  async listProducts(opts) {
@@ -657,6 +678,9 @@ function productLore(p) {
657
678
  ).map((s) => ({ url: s.url, label: s.label }));
658
679
  return { text, sources };
659
680
  }
681
+ function isCartTaxInclusive(cart) {
682
+ return (cart?.items ?? []).some((i) => i.is_tax_inclusive === true);
683
+ }
660
684
 
661
685
  // src/voice.ts
662
686
  var GLYPH = {
@@ -805,12 +829,15 @@ var BOX = {
805
829
  ml: "\u251C",
806
830
  mr: "\u2524"
807
831
  };
832
+ function cursor(style) {
833
+ return cyan(style, "_");
834
+ }
808
835
  function header(style, title, width = 56) {
809
836
  const label = ` ${title} `;
810
837
  const lead = BOX.h.repeat(2);
811
838
  const used = lead.length + label.length;
812
- const tail = BOX.h.repeat(Math.max(2, width - used));
813
- return `${lead}${cyan(style, label)}${tail}`;
839
+ const tail = BOX.h.repeat(Math.max(2, width - used - 2));
840
+ return `${lead}${cyan(style, label)}${tail} ${cursor(style)}`;
814
841
  }
815
842
  function rule(width = 56) {
816
843
  return BOX.h.repeat(width);
@@ -859,6 +886,20 @@ function colourMarker(colours) {
859
886
  }
860
887
 
861
888
  // src/commands/shared.ts
889
+ function regionLine(rr, style) {
890
+ const cc = (rr.requested ?? rr.region.countries?.[0]?.iso_2 ?? "").toLowerCase();
891
+ const loc = cc ? `${cc} \xB7 ` : "";
892
+ const nudge = rr.home ? " \xB7 not you? \u2192 bt region" : "";
893
+ return dim(style, `${GLYPH.gear} region: ${loc}${rr.region.currency_code}${nudge}`);
894
+ }
895
+ var SIZE_ORDER = ["xxs", "xs", "s", "m", "l", "xl", "2xl", "xxl", "3xl", "xxxl", "4xl", "5xl"];
896
+ function sortSizes(sizes) {
897
+ const rank = (s) => {
898
+ const i = SIZE_ORDER.indexOf(s.trim().toLowerCase());
899
+ return i === -1 ? SIZE_ORDER.length : i;
900
+ };
901
+ return [...sizes].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
902
+ }
862
903
  function brandOf(args) {
863
904
  return args?.brand ?? PLAIN;
864
905
  }
@@ -869,11 +910,15 @@ function fmtPrice(amount, currency) {
869
910
  const cur = (currency ?? "").toUpperCase();
870
911
  return cur ? `${amount} ${cur}` : `${amount}`;
871
912
  }
913
+ function taxSuffix(isTaxInclusive) {
914
+ return isTaxInclusive ? " incl. VAT" : "";
915
+ }
872
916
  function firstPrice(p) {
873
917
  const v = (p.variants ?? [])[0];
874
918
  return {
875
919
  amount: v?.calculated_price?.calculated_amount ?? null,
876
- currency: v?.calculated_price?.currency_code ?? null
920
+ currency: v?.calculated_price?.currency_code ?? null,
921
+ taxInclusive: v?.calculated_price?.is_calculated_price_tax_inclusive ?? null
877
922
  };
878
923
  }
879
924
  function stageMarker(channelStage) {
@@ -986,12 +1031,32 @@ function resolveOutput(program) {
986
1031
  const brand = json ? makeBrand({ noColor: true }) : makeBrand({ noColor: opts.color === false });
987
1032
  return { json, brand };
988
1033
  }
1034
+ function failResult(spec) {
1035
+ const numericCode = typeof spec.code === "number" && spec.code >= 1 && spec.code <= 255;
1036
+ const exitCode = spec.exit ?? (numericCode ? spec.code : EXIT.GENERIC);
1037
+ return {
1038
+ lines: [fail(spec)],
1039
+ exitCode,
1040
+ json: {
1041
+ error: {
1042
+ code: exitCode,
1043
+ message: spec.what,
1044
+ ...spec.fix ? { fix: spec.fix } : {},
1045
+ ...spec.link ? { link: spec.link } : {}
1046
+ }
1047
+ }
1048
+ };
1049
+ }
989
1050
  function printResult(res, json) {
990
1051
  if (res.exitCode) {
991
1052
  process.exitCode = res.exitCode;
992
1053
  }
993
- if (json && res.json !== void 0) {
994
- process.stdout.write(JSON.stringify(res.json, null, 2) + "\n");
1054
+ if (json) {
1055
+ if (res.json !== void 0) {
1056
+ process.stdout.write(JSON.stringify(res.json, null, 2) + "\n");
1057
+ } else {
1058
+ process.stderr.write(res.lines.join("\n") + "\n");
1059
+ }
995
1060
  return;
996
1061
  }
997
1062
  process.stdout.write(res.lines.join("\n") + "\n");
@@ -1066,30 +1131,21 @@ async function defaultRender(url, opts) {
1066
1131
  // src/commands/view.ts
1067
1132
  async function runView(client, config, args) {
1068
1133
  const style = brandOf(args);
1069
- const region = await client.resolveRegion(config.region);
1134
+ const rr = await client.resolveRegionResult(config.region);
1135
+ const region = rr.region;
1070
1136
  const picked = await resolveProduct(client, region, args.handle);
1071
1137
  const product = picked.product;
1072
1138
  if (!product) {
1073
1139
  if (picked.candidates && picked.candidates.length > 1) {
1074
- return {
1075
- lines: [
1076
- fail({ code: EXIT.DATAERR, what: `"${args.handle}" matches ${picked.candidates.length} designs \u2014 pick one:` }),
1077
- ...picked.candidates.map((p) => ` ${p.handle ?? p.id}`),
1078
- " or be exact \u2192 bt view <slug>"
1079
- ],
1080
- exitCode: EXIT.DATAERR
1081
- };
1140
+ const r = failResult({ code: EXIT.DATAERR, what: `"${args.handle}" matches ${picked.candidates.length} designs \u2014 pick one:` });
1141
+ r.lines.push(...picked.candidates.map((p) => ` ${p.handle ?? p.id}`), " or be exact \u2192 bt view <slug>");
1142
+ return r;
1082
1143
  }
1083
- return {
1084
- lines: [
1085
- fail({
1086
- code: EXIT.UNAVAILABLE,
1087
- what: `no design here called "${args.handle}".`,
1088
- fix: "bt browse (to see what's in stock)"
1089
- })
1090
- ],
1091
- exitCode: EXIT.UNAVAILABLE
1092
- };
1144
+ return failResult({
1145
+ code: EXIT.UNAVAILABLE,
1146
+ what: `no design here called "${args.handle}".`,
1147
+ fix: "bt browse (to see what's in stock)"
1148
+ });
1093
1149
  }
1094
1150
  const { title, handle } = displayName(product);
1095
1151
  const colours = productColours(product);
@@ -1110,18 +1166,19 @@ async function runView(client, config, args) {
1110
1166
  if (colours.length > 0) {
1111
1167
  lines.push(` colours ${colours.map((c) => `${colourSwatch(c)} ${c}`).join(" \xB7 ")}`);
1112
1168
  for (const c of colours) {
1113
- const sizes = sizesForColour(product, c);
1169
+ const sizes = sortSizes(sizesForColour(product, c));
1114
1170
  lines.push(` ${colourSwatch(c)} ${c.padEnd(6)} ${sizes.join(" ")}`);
1115
1171
  }
1116
1172
  } else {
1117
- const sizes = (product.variants ?? []).map((v) => v.title ?? "?");
1173
+ const sizes = sortSizes((product.variants ?? []).map((v) => v.title ?? "?"));
1118
1174
  lines.push(` sizes ${sizes.join(" ")}`);
1119
1175
  }
1120
1176
  const v0 = (product.variants ?? [])[0];
1121
1177
  if (v0?.calculated_price) {
1122
1178
  lines.push(
1123
- ` price ${cyan(style, fmtPrice(v0.calculated_price.calculated_amount, v0.calculated_price.currency_code))} incl. VAT`
1179
+ ` price ${cyan(style, fmtPrice(v0.calculated_price.calculated_amount, v0.calculated_price.currency_code))}${taxSuffix(v0.calculated_price.is_calculated_price_tax_inclusive)}`
1124
1180
  );
1181
+ lines.push(` ${regionLine(rr, style)}`);
1125
1182
  }
1126
1183
  let imageUrl = product.thumbnail ?? null;
1127
1184
  let blackNote = null;
@@ -1185,27 +1242,24 @@ function registerView(program, makeClient, loadConfig) {
1185
1242
  // src/commands/stage.ts
1186
1243
  async function runStage(client, config, args) {
1187
1244
  const style = brandOf(args);
1188
- const region = await client.resolveRegion(config.region);
1245
+ const rr = await client.resolveRegionResult(config.region);
1246
+ if (rr.requested && !rr.matched) {
1247
+ return failResult({
1248
+ code: EXIT.DATAERR,
1249
+ what: `we don't ship to ${rr.requested} yet.`,
1250
+ money: "nothing staged, nothing charged.",
1251
+ fix: "bt region --list (see where we ship, then bt region <cc>)"
1252
+ });
1253
+ }
1254
+ const region = rr.region;
1189
1255
  const resolved = await resolveProduct(client, region, args.handle);
1190
1256
  if (!resolved.product) {
1191
1257
  if (resolved.candidates && resolved.candidates.length > 1) {
1192
- return {
1193
- lines: [
1194
- fail({ code: EXIT.DATAERR, what: `"${args.handle}" matches ${resolved.candidates.length} designs \u2014 pick one:` }),
1195
- ...resolved.candidates.map((p) => ` ${p.handle ?? p.id}`),
1196
- " or be exact \u2192 bt stage <slug>"
1197
- ],
1198
- exitCode: EXIT.DATAERR
1199
- // ambiguous input → non-zero so `bt stage x && bt checkout` halts (d10)
1200
- };
1258
+ const r = failResult({ code: EXIT.DATAERR, what: `"${args.handle}" matches ${resolved.candidates.length} designs \u2014 pick one:` });
1259
+ r.lines.push(...resolved.candidates.map((p) => ` ${p.handle ?? p.id}`), " or be exact \u2192 bt stage <slug>");
1260
+ return r;
1201
1261
  }
1202
- return {
1203
- lines: [
1204
- fail({ code: EXIT.UNAVAILABLE, what: `no design here called "${args.handle}".`, fix: "bt browse" })
1205
- ],
1206
- exitCode: EXIT.UNAVAILABLE
1207
- // not-found → non-zero (d10)
1208
- };
1262
+ return failResult({ code: EXIT.UNAVAILABLE, what: `no design here called "${args.handle}".`, fix: "bt browse" });
1209
1263
  }
1210
1264
  const product = resolved.product;
1211
1265
  const matchNote = resolved.fuzzy && (product.handle ?? "") !== args.handle.trim() ? working(`matched "${product.handle}" from "${args.handle}"`) : null;
@@ -1215,17 +1269,11 @@ async function runStage(client, config, args) {
1215
1269
  const variant = findVariant(product, { size, colour });
1216
1270
  if (!variant) {
1217
1271
  const have = colours.length ? ` available colours: ${colours.join(", ")}.` : "";
1218
- return {
1219
- lines: [
1220
- fail({
1221
- code: EXIT.DATAERR,
1222
- what: `no variant for ${args.handle}${size ? `#size=${size}` : ""}${colour ? ` (${colour})` : ""}.`,
1223
- fix: `bt view ${args.handle} (to see available sizes + colours)${have}`
1224
- })
1225
- ],
1226
- exitCode: EXIT.DATAERR
1227
- // unprocessable size+colour combo → non-zero (d10)
1228
- };
1272
+ return failResult({
1273
+ code: EXIT.DATAERR,
1274
+ what: `no variant for ${args.handle}${size ? `#size=${size}` : ""}${colour ? ` (${colour})` : ""}.`,
1275
+ fix: `bt view ${args.handle} (to see available sizes + colours)${have}`
1276
+ });
1229
1277
  }
1230
1278
  if (args.dryRun) {
1231
1279
  const colourSuffix2 = colour ? ` (${colour})` : "";
@@ -1324,6 +1372,7 @@ export {
1324
1372
  sizesForColour,
1325
1373
  findVariant,
1326
1374
  productLore,
1375
+ isCartTaxInclusive,
1327
1376
  GLYPH,
1328
1377
  ok,
1329
1378
  warn,
@@ -1342,8 +1391,10 @@ export {
1342
1391
  contributionBar,
1343
1392
  colourSwatch,
1344
1393
  colourMarker,
1394
+ regionLine,
1345
1395
  brandOf,
1346
1396
  fmtPrice,
1397
+ taxSuffix,
1347
1398
  firstPrice,
1348
1399
  stageMarker,
1349
1400
  displayName,
@@ -1352,10 +1403,11 @@ export {
1352
1403
  resolveLineItemRef,
1353
1404
  resolveProduct,
1354
1405
  resolveOutput,
1406
+ failResult,
1355
1407
  printResult,
1356
1408
  runView,
1357
1409
  registerView,
1358
1410
  runStage,
1359
1411
  registerStage
1360
1412
  };
1361
- //# sourceMappingURL=chunk-B6PV7EQG.js.map
1413
+ //# sourceMappingURL=chunk-NJ3DW5AX.js.map