@binaryandthread/cli 0.3.1 → 0.4.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.
@@ -10,6 +10,7 @@ var ALLOWED_KEYS = [
10
10
  "region",
11
11
  "defaultSize",
12
12
  "defaultColour",
13
+ "defaultFit",
13
14
  "defaultBranch"
14
15
  ];
15
16
  function configDir() {
@@ -63,4 +64,4 @@ export {
63
64
  saveConfig,
64
65
  forgetConfig
65
66
  };
66
- //# sourceMappingURL=chunk-ZNDKXHMV.js.map
67
+ //# sourceMappingURL=chunk-T7MFWTB7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["// Local config — ~/.config/bt/config.json.\n//\n// Holds ONLY non-sensitive fields (SECURITY G15): cart_id, email, region, defaults.\n// The auth TOKEN is NEVER here — it goes in the OS keychain in Wave 5. Persisting a\n// token in plaintext config would be the exact leak G15 forbids.\n//\n// Writes are ATOMIC (F-B1): write to config.json.tmp then fs.renameSync, so a crash\n// mid-write never corrupts the file (rename is atomic on POSIX + Windows same-volume).\n\nimport * as fs from \"node:fs\"\nimport * as os from \"node:os\"\nimport * as path from \"node:path\"\n\nexport interface BtConfig {\n cart_id?: string\n email?: string\n region?: string\n defaultSize?: string\n defaultColour?: string\n /** CAT-31: \"regular\" | \"oversize\", used when a design comes in both fits and no --fit is given. */\n defaultFit?: string\n defaultBranch?: string\n /** RUNTIME-ONLY (ENG-21): where the effective region came from this run — flag, saved config, or\n * a locale guess. Deliberately NOT in ALLOWED_KEYS, so saveConfig always strips it. */\n region_source?: \"flag\" | \"config\" | \"locale\"\n}\n\n// Persisted keys exclude the RUNTIME-ONLY fields (region_source) — typing ALLOWED_KEYS as the full\n// `keyof BtConfig` wrongly widened `key` to include region_source's literal union, so `out[key] = v`\n// (a string) failed to typecheck. PersistedKey narrows it to the string-valued, persistable fields.\ntype PersistedKey = Exclude<keyof BtConfig, \"region_source\">\n\n// Only these keys are ever persisted — anything else (notably `token`) is dropped (G15).\nconst ALLOWED_KEYS: Array<PersistedKey> = [\n \"cart_id\",\n \"email\",\n \"region\",\n \"defaultSize\",\n \"defaultColour\",\n \"defaultFit\",\n \"defaultBranch\",\n]\n\n/** The config directory: $XDG_CONFIG_HOME/bt or ~/.config/bt. */\nexport function configDir(): string {\n const xdg = process.env.XDG_CONFIG_HOME\n const base = xdg && xdg.trim() ? xdg : path.join(os.homedir(), \".config\")\n return path.join(base, \"bt\")\n}\n\nexport function configPath(): string {\n return path.join(configDir(), \"config.json\")\n}\n\n/** Load the config, or {} if none / unreadable. Never throws. */\nexport function loadConfig(): BtConfig {\n try {\n const raw = fs.readFileSync(configPath(), \"utf8\")\n const parsed = JSON.parse(raw) as Record<string, unknown>\n return sanitize(parsed)\n } catch {\n return {}\n }\n}\n\n/** Merge-and-save the given fields. Strips any non-allowlisted key (G15). Atomic (F-B1). */\nexport function saveConfig(patch: Partial<BtConfig>): BtConfig {\n const merged = { ...loadConfig(), ...sanitize(patch as Record<string, unknown>) }\n const dir = configDir()\n fs.mkdirSync(dir, { recursive: true })\n const target = configPath()\n const tmp = target + \".tmp\"\n // write to a temp file then atomically rename (F-B1)\n fs.writeFileSync(tmp, JSON.stringify(merged, null, 2), { encoding: \"utf8\", mode: 0o600 })\n fs.renameSync(tmp, target)\n return merged\n}\n\n/** Wipe the local config file (G15 erasure — `bt forget`). Never throws. */\nexport function forgetConfig(): void {\n try {\n fs.rmSync(configPath(), { force: true })\n } catch {\n /* ignore */\n }\n}\n\n/** Keep only allowlisted, non-sensitive keys (drops `token` and anything unexpected). */\nfunction sanitize(obj: Record<string, unknown>): BtConfig {\n const out: BtConfig = {}\n for (const key of ALLOWED_KEYS) {\n const v = obj[key]\n if (typeof v === \"string\" && v.length > 0) {\n out[key] = v\n }\n }\n return out\n}\n"],"mappings":";;;AASA,YAAY,QAAQ;AACpB,YAAY,QAAQ;AACpB,YAAY,UAAU;AAsBtB,IAAM,eAAoC;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,SAAS,YAAoB;AAClC,QAAM,MAAM,QAAQ,IAAI;AACxB,QAAM,OAAO,OAAO,IAAI,KAAK,IAAI,MAAW,UAAQ,WAAQ,GAAG,SAAS;AACxE,SAAY,UAAK,MAAM,IAAI;AAC7B;AAEO,SAAS,aAAqB;AACnC,SAAY,UAAK,UAAU,GAAG,aAAa;AAC7C;AAGO,SAAS,aAAuB;AACrC,MAAI;AACF,UAAM,MAAS,gBAAa,WAAW,GAAG,MAAM;AAChD,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,WAAO,SAAS,MAAM;AAAA,EACxB,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAGO,SAAS,WAAW,OAAoC;AAC7D,QAAM,SAAS,EAAE,GAAG,WAAW,GAAG,GAAG,SAAS,KAAgC,EAAE;AAChF,QAAM,MAAM,UAAU;AACtB,EAAG,aAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AACrC,QAAM,SAAS,WAAW;AAC1B,QAAM,MAAM,SAAS;AAErB,EAAG,iBAAc,KAAK,KAAK,UAAU,QAAQ,MAAM,CAAC,GAAG,EAAE,UAAU,QAAQ,MAAM,IAAM,CAAC;AACxF,EAAG,cAAW,KAAK,MAAM;AACzB,SAAO;AACT;AAGO,SAAS,eAAqB;AACnC,MAAI;AACF,IAAG,UAAO,WAAW,GAAG,EAAE,OAAO,KAAK,CAAC;AAAA,EACzC,QAAQ;AAAA,EAER;AACF;AAGA,SAAS,SAAS,KAAwC;AACxD,QAAM,MAAgB,CAAC;AACvB,aAAW,OAAO,cAAc;AAC9B,UAAM,IAAI,IAAI,GAAG;AACjB,QAAI,OAAO,MAAM,YAAY,EAAE,SAAS,GAAG;AACzC,UAAI,GAAG,IAAI;AAAA,IACb;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
@@ -545,6 +545,116 @@ function isAllowedImageHost(url) {
545
545
  (allowed) => host === allowed || host.endsWith("." + allowed)
546
546
  );
547
547
  }
548
+ var FITS = ["regular", "oversize"];
549
+ function parseFit(v) {
550
+ const s = typeof v === "string" ? v.trim().toLowerCase() : "";
551
+ return s === "regular" || s === "oversize" ? s : null;
552
+ }
553
+ function fitLabel(f) {
554
+ return f === "oversize" ? "Oversize" : "Regular";
555
+ }
556
+ function variantFit(v) {
557
+ const opt = (v.options ?? []).find((o) => (o.option?.title ?? "").toLowerCase() === "fit");
558
+ return opt ? parseFit(opt.value) : null;
559
+ }
560
+ function productFits(p) {
561
+ const seen = /* @__PURE__ */ new Set();
562
+ for (const v of p.variants ?? []) {
563
+ const f = variantFit(v);
564
+ if (f) seen.add(f);
565
+ }
566
+ return FITS.filter((f) => seen.has(f));
567
+ }
568
+ var BLANK_COLOURS = {
569
+ "gildan-5000": ["black", "white"],
570
+ "sols-03996": ["black", "white"],
571
+ "shaka-shmhss": ["black", "white", "khaki"]
572
+ };
573
+ function blankFor(fit, countryCode) {
574
+ if (fit === "regular") return "gildan-5000";
575
+ return (countryCode ?? "").trim().toLowerCase() === "us" ? "shaka-shmhss" : "sols-03996";
576
+ }
577
+ function colourBuyable(fit, colour, countryCode) {
578
+ return BLANK_COLOURS[blankFor(fit, countryCode)].includes(colour.trim().toLowerCase());
579
+ }
580
+ function variantsOfFit(p, fit) {
581
+ const all = p.variants ?? [];
582
+ return fit === null ? all : all.filter((v) => variantFit(v) === fit);
583
+ }
584
+ function coloursFor(p, fit, countryCode) {
585
+ const out = [];
586
+ for (const v of variantsOfFit(p, fit)) {
587
+ const c = variantColour(v);
588
+ if (!c || out.includes(c)) continue;
589
+ if (fit && countryCode !== void 0 && !colourBuyable(fit, c, countryCode)) continue;
590
+ out.push(c);
591
+ }
592
+ return out;
593
+ }
594
+ function sizesFor(p, fit, colour) {
595
+ const target = colour.trim().toLowerCase();
596
+ const out = [];
597
+ for (const v of variantsOfFit(p, fit)) {
598
+ if (variantColour(v) !== target) continue;
599
+ const s = sizeOfVariant(v);
600
+ if (s && !out.includes(s)) out.push(s);
601
+ }
602
+ return out;
603
+ }
604
+ function cheapest(vs) {
605
+ const priced = vs.filter((v2) => typeof v2.calculated_price?.calculated_amount === "number").sort(
606
+ (a, b) => a.calculated_price.calculated_amount - b.calculated_price.calculated_amount
607
+ );
608
+ const v = priced[0];
609
+ return {
610
+ amount: v?.calculated_price?.calculated_amount ?? null,
611
+ currency: v?.calculated_price?.currency_code ?? null,
612
+ taxInclusive: v?.calculated_price?.is_calculated_price_tax_inclusive ?? null
613
+ };
614
+ }
615
+ function priceFrom(p) {
616
+ const fits = productFits(p);
617
+ const base = cheapest(p.variants ?? []);
618
+ const amounts = new Set(fits.map((f) => cheapest(variantsOfFit(p, f)).amount));
619
+ return { ...base, from: fits.length > 1 && amounts.size > 1 };
620
+ }
621
+ function priceFor(p, fit) {
622
+ return cheapest(variantsOfFit(p, fit));
623
+ }
624
+ function resolveVariant(p, opts) {
625
+ const fits = productFits(p);
626
+ const requested = parseFit(opts.fit);
627
+ const askedFor = typeof opts.fit === "string" ? opts.fit.trim() : "";
628
+ let fit = null;
629
+ if (fits.length > 0) {
630
+ if (requested && fits.includes(requested)) fit = requested;
631
+ else if (askedFor) return { ok: false, reason: "fit_not_offered", requested: askedFor, fits };
632
+ else if (fits.length === 1) fit = fits[0];
633
+ else return { ok: false, reason: "ambiguous_fit", fits: fits.map((f) => ({ fit: f, ...priceFor(p, f) })) };
634
+ }
635
+ const pool = variantsOfFit(p, fit);
636
+ const colours = coloursFor(p, fit, opts.countryCode);
637
+ const colour = (opts.colour ?? "").trim().toLowerCase() || (colours[0] ?? null);
638
+ if (fit && colour && opts.countryCode !== void 0 && !colourBuyable(fit, colour, opts.countryCode)) {
639
+ return { ok: false, reason: "fit_unavailable", fit, colour };
640
+ }
641
+ const size = (opts.size ?? "").trim() || null;
642
+ const sizeLc = size?.toLowerCase();
643
+ const v = pool.find((x) => {
644
+ const colourOk = !colour || variantColour(x) === colour;
645
+ const sizeOk = !sizeLc || (sizeOfVariant(x) ?? "").toLowerCase() === sizeLc || (x.title ?? "").toLowerCase() === sizeLc;
646
+ return colourOk && sizeOk;
647
+ });
648
+ if (!v) {
649
+ return { ok: false, reason: "not_found", fit, colour, size, colours, sizes: colour ? sizesFor(p, fit, colour) : [] };
650
+ }
651
+ return { ok: true, variant: v, fit, colour: variantColour(v), size: sizeOfVariant(v) };
652
+ }
653
+ var SIZE_TOKEN = /^(xxs|xs|s|m|l|xl|xxl|2xl|3xl|xxxl|4xl|5xl)$/i;
654
+ var FIT_TOKEN = /^(regular|oversize)$/i;
655
+ function titleSegments(title) {
656
+ return (title ?? "").split("/").map((s) => s.trim()).filter(Boolean);
657
+ }
548
658
  function displayColour(colour) {
549
659
  const c = colour.trim();
550
660
  if (!c) return c;
@@ -557,11 +667,10 @@ function variantColour(v) {
557
667
  if (opt && typeof opt.value === "string" && opt.value.trim()) {
558
668
  return opt.value.trim().toLowerCase();
559
669
  }
560
- const title = (v.title ?? "").trim();
561
- if (title.includes("/")) {
562
- const parts = title.split("/").map((p) => p.trim());
563
- const tail = parts[parts.length - 1];
564
- if (tail) return tail.toLowerCase();
670
+ const segs = titleSegments(v.title);
671
+ if (segs.length >= 2) {
672
+ const seg = segs.find((s) => !SIZE_TOKEN.test(s) && !FIT_TOKEN.test(s));
673
+ if (seg) return seg.toLowerCase();
565
674
  }
566
675
  return null;
567
676
  }
@@ -575,32 +684,28 @@ function productColours(p) {
575
684
  }
576
685
  return seen;
577
686
  }
578
- function sizesForColour(p, colour) {
579
- const target = colour.trim().toLowerCase();
580
- const sizes = [];
581
- for (const v of p.variants ?? []) {
582
- if (variantColour(v) !== target) continue;
583
- const size = sizeOfVariant(v);
584
- if (size && !sizes.includes(size)) {
585
- sizes.push(size);
586
- }
587
- }
588
- return sizes;
589
- }
590
687
  function sizeOfVariant(v) {
591
688
  const opt = (v.options ?? []).find((o) => (o.option?.title ?? "").toLowerCase() === "size");
592
689
  if (opt && typeof opt.value === "string" && opt.value.trim()) {
593
690
  return opt.value.trim();
594
691
  }
595
- const title = (v.title ?? "").trim();
596
- if (title.includes("/")) {
597
- const head = title.split("/")[0]?.trim();
598
- if (head) return head;
599
- }
600
- return title || null;
692
+ const segs = titleSegments(v.title);
693
+ const token = segs.find((s) => SIZE_TOKEN.test(s));
694
+ if (token) return token;
695
+ return segs.length === 1 ? segs[0] : segs[0] ?? null;
601
696
  }
602
697
  function findVariant(p, opts = {}) {
603
- const variants = p.variants ?? [];
698
+ const fits = productFits(p);
699
+ let fit = null;
700
+ if (fits.length > 0) {
701
+ const wanted = parseFit(opts.fit);
702
+ const askedFor = typeof opts.fit === "string" ? opts.fit.trim() : "";
703
+ if (wanted && fits.includes(wanted)) fit = wanted;
704
+ else if (askedFor) return void 0;
705
+ else if (fits.length === 1) fit = fits[0];
706
+ else return void 0;
707
+ }
708
+ const variants = (p.variants ?? []).filter((v) => fit === null || variantFit(v) === fit);
604
709
  const size = opts.size?.trim().toLowerCase();
605
710
  const colour = opts.colour?.trim().toLowerCase();
606
711
  const sizeMatches = (v) => {
@@ -660,6 +765,24 @@ function imageForColour(p, colour) {
660
765
  }
661
766
  return null;
662
767
  }
768
+ function imageFor(p, sel) {
769
+ const colour = sel.colour.trim().toLowerCase();
770
+ if (!colour) return null;
771
+ const metas = (p.images ?? []).map((img) => ({
772
+ url: (img?.url ?? "").trim(),
773
+ scene: typeof img?.metadata?.["scene"] === "string" ? String(img.metadata["scene"]) : null,
774
+ fit: parseFit(img?.metadata?.["fit"]),
775
+ colour: typeof img?.metadata?.["colour"] === "string" ? String(img.metadata["colour"]).toLowerCase() : null
776
+ }));
777
+ const tagged = metas.filter((m) => m.url && m.colour === colour && m.scene === PREFERRED_SCENE);
778
+ const exact = tagged.find((m) => sel.fit !== null && m.fit === sel.fit);
779
+ if (exact) return exact.url;
780
+ const neutral = tagged.find((m) => m.fit === null);
781
+ if (neutral) return neutral.url;
782
+ const otherFitOnly = tagged.length > 0 && sel.fit !== null;
783
+ if (otherFitOnly) return null;
784
+ return imageForColour(p, colour);
785
+ }
663
786
  function firstImageUrl(images) {
664
787
  for (const img of images ?? []) {
665
788
  const u = (img?.url ?? "").trim();
@@ -673,15 +796,19 @@ function escapeRegex(s) {
673
796
  function str(v, fallback) {
674
797
  return typeof v === "string" && v.length > 0 ? v : fallback;
675
798
  }
676
- function productSpecs(p) {
799
+ function productSpecs(p, fit) {
677
800
  const md = p.metadata ?? {};
801
+ const byFit = md.specs_by_fit ?? null;
802
+ const fitKey = fit ?? (byFit && Object.keys(byFit).length === 1 ? Object.keys(byFit)[0] : void 0);
803
+ const src = fitKey && byFit?.[fitKey] || md;
804
+ const pick = (k, fallback) => str(src[k] ?? md[k], fallback);
678
805
  return [
679
- { key: "material", value: str(md.material, "100% cotton") },
680
- { key: "weight", value: str(md.weight, "180 g/m\xB2") },
681
- { key: "fit", value: str(md.fit, "Unisex heavyweight crewneck") },
682
- { key: "print", value: str(md.print, "Front print") },
683
- { key: "delivery", value: str(md.delivery, "Ships in 2\u20135 business days + transit") },
684
- { key: "care", value: str(md.care, "Cold wash, line dry") }
806
+ { key: "material", value: pick("material", "100% cotton") },
807
+ { key: "weight", value: pick("weight", "180 g/m\xB2") },
808
+ { key: "fit", value: pick("fit", "Unisex heavyweight crewneck") },
809
+ { key: "print", value: pick("print", "Front print") },
810
+ { key: "delivery", value: pick("delivery", "Ships in 2\u20135 business days + transit") },
811
+ { key: "care", value: pick("care", "Cold wash, line dry") }
685
812
  ];
686
813
  }
687
814
  function productLore(p) {
@@ -891,6 +1018,7 @@ function colourSwatch(colour) {
891
1018
  const c = colour.trim().toLowerCase();
892
1019
  if (c === "white") return "\u2B1C";
893
1020
  if (c === "black") return "\u2B1B";
1021
+ if (c === "khaki") return "\u{1F7EB}";
894
1022
  return "\u25FC";
895
1023
  }
896
1024
  function colourMarker(colours) {
@@ -928,13 +1056,8 @@ function fmtPrice(amount, currency) {
928
1056
  function taxSuffix(isTaxInclusive) {
929
1057
  return isTaxInclusive ? " incl. VAT" : "";
930
1058
  }
931
- function firstPrice(p) {
932
- const v = (p.variants ?? [])[0];
933
- return {
934
- amount: v?.calculated_price?.calculated_amount ?? null,
935
- currency: v?.calculated_price?.currency_code ?? null,
936
- taxInclusive: v?.calculated_price?.is_calculated_price_tax_inclusive ?? null
937
- };
1059
+ function priceLine(p) {
1060
+ return priceFrom(p);
938
1061
  }
939
1062
  function stageMarker(channelStage) {
940
1063
  return channelStage === "next" ? "\u25CB on next" : "\u25CF in stock";
@@ -944,7 +1067,14 @@ function displayName(p) {
944
1067
  const title = (p.title ?? "").toString().trim() || handle;
945
1068
  return { title, handle };
946
1069
  }
1070
+ function lineOption(item, titles) {
1071
+ const o = item.variant?.options?.find((x) => titles.includes((x.option?.title ?? "").toLowerCase()));
1072
+ const v = typeof o?.value === "string" ? o.value.trim() : "";
1073
+ return v ? v : null;
1074
+ }
947
1075
  function lineColour(item) {
1076
+ const opt = lineOption(item, ["colour", "color"]);
1077
+ if (opt) return opt.toLowerCase();
948
1078
  const vt = (item.variant_title ?? "").toString();
949
1079
  if (vt) {
950
1080
  const c = variantColour({ id: "x", title: vt });
@@ -952,6 +1082,15 @@ function lineColour(item) {
952
1082
  }
953
1083
  return null;
954
1084
  }
1085
+ function lineFit(item) {
1086
+ const opt = parseFit(lineOption(item, ["fit"]));
1087
+ if (opt) return opt;
1088
+ for (const seg of (item.variant_title ?? "").toString().split("/")) {
1089
+ const f = parseFit(seg);
1090
+ if (f) return f;
1091
+ }
1092
+ return null;
1093
+ }
955
1094
  function shortItemId(id) {
956
1095
  const s = (id ?? "").toString();
957
1096
  return s.length <= 6 ? s : s.slice(-6);
@@ -962,7 +1101,9 @@ function formatCartLine(item, index, currency, brand) {
962
1101
  const title = (item.title ?? item.product_title ?? "item").toString();
963
1102
  const idTag = item.id ? dimTag(style, ` (#${shortItemId(item.id)})`) : "";
964
1103
  const colour = lineColour(item);
965
- const colourTag = colour ? ` \xB7 ${colour}` : "";
1104
+ const fit = lineFit(item);
1105
+ const tags = [colour, fit ? fitLabel(fit).toLowerCase() : null].filter(Boolean).join(", ");
1106
+ const colourTag = tags ? ` \xB7 ${tags}` : "";
966
1107
  const price = item.total != null ? ` ${accentPrice(style, fmtPrice(item.total, currency))}` : "";
967
1108
  return ` ${ref} ${title}${colourTag}${idTag} \xB7 qty ${item.quantity}${price}`;
968
1109
  }
@@ -1032,6 +1173,17 @@ async function resolveProduct(client, region, selector) {
1032
1173
  if (exact) {
1033
1174
  return { product: exact };
1034
1175
  }
1176
+ const m = needle.match(/^(.+)-oversize$/i);
1177
+ if (m && m[1]) {
1178
+ const base = await client.getProduct(m[1], { region });
1179
+ if (base) {
1180
+ return {
1181
+ product: base,
1182
+ impliedFit: "oversize",
1183
+ aliasNote: `"${needle}" is a retired handle \xB7 resolved to ${base.handle ?? m[1]} --fit oversize`
1184
+ };
1185
+ }
1186
+ }
1035
1187
  }
1036
1188
  const products = await client.listProducts({ region });
1037
1189
  const r = resolveProductSelector(products, needle);
@@ -1172,47 +1324,70 @@ async function runView(client, config, args) {
1172
1324
  });
1173
1325
  }
1174
1326
  const { title, handle } = displayName(product);
1175
- const colours = productColours(product);
1327
+ const fits = productFits(product);
1328
+ const requestedFit = parseFit(args.fit ?? picked.impliedFit);
1329
+ if (args.fit && (!requestedFit || !fits.includes(requestedFit))) {
1330
+ return failResult({
1331
+ code: EXIT.DATAERR,
1332
+ what: fits.length ? `no fit called "${args.fit}" for ${handle}.` : `${handle} comes in one fit; --fit does not apply.`,
1333
+ fix: fits.length ? `bt view ${handle} --fit ${fits.join(" | ")}` : `bt view ${handle}`
1334
+ });
1335
+ }
1336
+ const fit = fits.length ? requestedFit ?? (fits.includes("regular") ? "regular" : fits[0]) : null;
1337
+ const countryCode = (rr.requested ?? region.countries?.[0]?.iso_2 ?? "").toLowerCase() || null;
1338
+ const colours = coloursFor(product, fit, countryCode);
1339
+ const unavailable = coloursFor(product, fit).filter((c) => !colours.includes(c));
1176
1340
  const lines = [];
1177
1341
  if (picked.fuzzy && handle !== args.handle.trim()) {
1178
1342
  lines.push(working(`matched "${handle}" from "${args.handle}"`));
1179
1343
  }
1344
+ if (picked.aliasNote) {
1345
+ lines.push(working(picked.aliasNote));
1346
+ }
1180
1347
  lines.push(header(style, title));
1181
1348
  lines.push(` ${dim(style, handle)}`);
1182
1349
  if (product.description) {
1183
1350
  lines.push(empty(` ${product.description}`));
1184
1351
  }
1185
1352
  const fabric = product.metadata?.["fabric"] ?? null;
1186
- const specs = productSpecs(product);
1353
+ if (fit && fits.length > 1) {
1354
+ const other = fits.find((f) => f !== fit);
1355
+ const op = priceFor(product, other);
1356
+ lines.push(` fits ${fits.map((f) => fitLabel(f)).join(" \xB7 ")} \u2192 viewing ${fitLabel(fit)} \xB7 the other \u2192 bt view ${handle} --fit ${other} (${fmtPrice(op.amount, op.currency)})`);
1357
+ }
1358
+ const specs = productSpecs(product, fit);
1187
1359
  for (const s of specs) {
1188
1360
  lines.push(` ${s.key.padEnd(9)}${s.value}`);
1189
1361
  }
1190
- if (colours.length > 0) {
1362
+ if (colours.length > 0 || unavailable.length > 0) {
1191
1363
  lines.push(` colours ${colours.map((c) => `${colourSwatch(c)} ${c}`).join(" \xB7 ")}`);
1192
1364
  for (const c of colours) {
1193
- const sizes = sortSizes(sizesForColour(product, c));
1365
+ const sizes = sortSizes(sizesFor(product, fit, c));
1194
1366
  lines.push(` ${colourSwatch(c)} ${c.padEnd(6)} ${sizes.join(" ")}`);
1195
1367
  }
1368
+ for (const c of unavailable) {
1369
+ lines.push(dim(style, ` ${colourSwatch(c)} ${c.padEnd(6)} not available where you ship (${countryCode ?? "your region"})`));
1370
+ }
1196
1371
  } else {
1197
1372
  const sizes = sortSizes((product.variants ?? []).map((v) => v.title ?? "?"));
1198
1373
  lines.push(` sizes ${sizes.join(" ")}`);
1199
1374
  }
1200
- const v0 = (product.variants ?? [])[0];
1201
- if (v0?.calculated_price) {
1375
+ const pr = fit ? { ...priceFor(product, fit), from: false } : priceLine(product);
1376
+ if (pr.amount != null) {
1202
1377
  lines.push(
1203
- ` price ${cyan(style, fmtPrice(v0.calculated_price.calculated_amount, v0.calculated_price.currency_code))}${taxSuffix(v0.calculated_price.is_calculated_price_tax_inclusive)}`
1378
+ ` price ${cyan(style, `${pr.from ? "from " : ""}${fmtPrice(pr.amount, pr.currency)}`)}${taxSuffix(pr.taxInclusive)}`
1204
1379
  );
1205
1380
  lines.push(` ${regionLine(rr, style, config.region_source)}`);
1206
1381
  }
1207
1382
  let imageUrl = product.thumbnail ?? null;
1208
1383
  let blackNote = null;
1209
- if (args.colour) {
1210
- const wanted = args.colour.trim().toLowerCase();
1211
- const resolved = imageForColour(product, wanted);
1384
+ const wanted = args.colour?.trim().toLowerCase() || (fit ? colours[0] : void 0);
1385
+ if (wanted) {
1386
+ const resolved = imageFor(product, { fit, colour: wanted }) ?? (fit === null ? imageForColour(product, wanted) : null);
1212
1387
  if (resolved) {
1213
1388
  imageUrl = resolved;
1214
- } else {
1215
- blackNote = ` ${displayColour(wanted)} available \u2014 showing the default mockup (no ${wanted} render on file).`;
1389
+ } else if (args.colour) {
1390
+ blackNote = unavailable.includes(wanted) ? ` ${displayColour(wanted)} isn't available where you ship \u2014 showing the default mockup.` : ` ${displayColour(wanted)} available \u2014 showing the default mockup (no ${wanted} render on file).`;
1216
1391
  }
1217
1392
  }
1218
1393
  if (imageUrl) {
@@ -1224,7 +1399,7 @@ async function runView(client, config, args) {
1224
1399
  }
1225
1400
  if (colours.length > 1 && !args.colour) {
1226
1401
  const others = colours.filter((c) => c !== colours[0]).join(", ");
1227
- lines.push(` see another colour \u2192 bt view ${handle} --colour ${others.split(",")[0]?.trim()}`);
1402
+ lines.push(` see another colour \u2192 bt view ${handle} --colour ${others.split(",")[0]?.trim()}${fit && fits.length > 1 ? ` --fit ${fit}` : ""}`);
1228
1403
  }
1229
1404
  if (productLore(product).text) {
1230
1405
  lines.push(` the story \u2192 bt blame ${handle}`);
@@ -1237,11 +1412,17 @@ async function runView(client, config, args) {
1237
1412
  title: product.title,
1238
1413
  fabric,
1239
1414
  specs,
1415
+ fits,
1416
+ fit,
1240
1417
  colours,
1418
+ unavailable_colours: unavailable,
1241
1419
  thumbnail: product.thumbnail,
1420
+ // CAT-31: `size` is the REAL size (never the variant title); fit/colour from the options.
1242
1421
  variants: (product.variants ?? []).map((v) => ({
1243
1422
  id: v.id,
1244
- size: v.title,
1423
+ fit: variantFit(v),
1424
+ colour: variantColour(v),
1425
+ size: sizeOfVariant(v),
1245
1426
  price: v.calculated_price?.calculated_amount ?? null,
1246
1427
  currency: v.calculated_price?.currency_code ?? null
1247
1428
  }))
@@ -1249,12 +1430,13 @@ async function runView(client, config, args) {
1249
1430
  };
1250
1431
  }
1251
1432
  function registerView(program, makeClient, loadConfig) {
1252
- program.command("view").alias("show").argument("<handle>", "the design: a slug, a bt browse number (3), or a fuzzy hint (termnl)").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) => {
1433
+ program.command("view").alias("show").argument("<handle>", "the design: a slug, a bt browse number (3), or a fuzzy hint (termnl)").option("--colour <colour>", "render a specific colour's mockup (e.g. black)").option("--fit <fit>", "regular or oversize: which fit's sizes, price and image to show (default: regular)").description("render a design: specs + image (always with the link as the floor).").action(async (handle, opts) => {
1253
1434
  const { json, brand } = resolveOutput(program);
1254
1435
  const noColor = program.opts().color === false;
1255
1436
  const res = await runView(makeClient(), loadConfig(), {
1256
1437
  handle,
1257
1438
  colour: opts.colour,
1439
+ fit: opts.fit,
1258
1440
  json,
1259
1441
  noColor,
1260
1442
  brand
@@ -1279,38 +1461,74 @@ async function runStage(client, config, args) {
1279
1461
  const resolved = await resolveProduct(client, region, args.handle);
1280
1462
  if (!resolved.product) {
1281
1463
  if (resolved.candidates && resolved.candidates.length > 1) {
1282
- const r = failResult({ code: EXIT.DATAERR, what: `"${args.handle}" matches ${resolved.candidates.length} designs \u2014 pick one:` });
1283
- r.lines.push(...resolved.candidates.map((p) => ` ${p.handle ?? p.id}`), " or be exact \u2192 bt stage <slug>");
1284
- return r;
1464
+ const r2 = failResult({ code: EXIT.DATAERR, what: `"${args.handle}" matches ${resolved.candidates.length} designs \u2014 pick one:` });
1465
+ r2.lines.push(...resolved.candidates.map((p) => ` ${p.handle ?? p.id}`), " or be exact \u2192 bt stage <slug>");
1466
+ return r2;
1285
1467
  }
1286
1468
  return failResult({ code: EXIT.UNAVAILABLE, what: `no design here called "${args.handle}".`, fix: "bt browse" });
1287
1469
  }
1288
1470
  const product = resolved.product;
1289
1471
  const matchNote = resolved.fuzzy && (product.handle ?? "") !== args.handle.trim() ? working(`matched "${product.handle}" from "${args.handle}"`) : null;
1290
1472
  const size = args.size ?? config.defaultSize;
1291
- const colours = productColours(product);
1292
- const colour = args.colour ?? config.defaultColour ?? (colours.length > 0 ? colours[0] : void 0);
1293
- const variant = findVariant(product, { size, colour });
1294
- if (!variant) {
1295
- const have = colours.length ? ` available colours: ${colours.join(", ")}.` : "";
1473
+ const countryCode = (rr.requested ?? region.countries?.[0]?.iso_2 ?? "").toLowerCase() || null;
1474
+ const explicitFit = args.fit ?? resolved.impliedFit;
1475
+ const fitArg = explicitFit ?? (productFits(product).length > 1 ? config.defaultFit : void 0);
1476
+ const r = resolveVariant(product, { fit: fitArg, size, colour: args.colour ?? config.defaultColour, countryCode });
1477
+ const handleShown = product.handle ?? args.handle;
1478
+ if (!r.ok) {
1479
+ if (r.reason === "ambiguous_fit") {
1480
+ const fitList = r.fits.map((f) => `${f.fit} (${fmtPrice(f.amount, f.currency)})`).join(" \xB7 ");
1481
+ const res = failResult({
1482
+ code: EXIT.DATAERR,
1483
+ what: `${handleShown} comes in two fits: ${fitList}.`,
1484
+ money: "nothing staged, nothing charged.",
1485
+ fix: `bt stage ${handleShown} --fit ${r.fits[0].fit}${size ? ` --size ${size}` : ""}`
1486
+ });
1487
+ res.lines.push(` or set yours once \u2192 bt config set defaultFit ${r.fits[0].fit}`);
1488
+ return res;
1489
+ }
1490
+ if (r.reason === "fit_not_offered") {
1491
+ return failResult({
1492
+ code: EXIT.DATAERR,
1493
+ what: `${handleShown} isn't sold in "${r.requested}"${r.fits.length ? ` \u2014 it comes in ${r.fits.join(" \xB7 ")}` : ""}.`,
1494
+ money: "nothing staged, nothing charged.",
1495
+ fix: r.fits.length ? `bt stage ${handleShown} --fit ${r.fits[0]}${size ? ` --size ${size}` : ""}` : `bt stage ${handleShown}${size ? ` --size ${size}` : ""} (this design comes in one fit)`
1496
+ });
1497
+ }
1498
+ if (r.reason === "fit_unavailable") {
1499
+ return failResult({
1500
+ code: EXIT.DATAERR,
1501
+ what: `${r.colour} is not available in ${r.fit} where you ship (${countryCode ?? "your region"}).`,
1502
+ money: "nothing staged, nothing charged.",
1503
+ fix: `bt view ${handleShown} --fit ${r.fit} (to see the colours you can buy)`
1504
+ });
1505
+ }
1506
+ const have = r.colours.length ? ` available colours: ${r.colours.join(", ")}.` : "";
1507
+ const haveSizes = r.sizes.length ? ` sizes: ${r.sizes.join(" ")}.` : "";
1296
1508
  return failResult({
1297
1509
  code: EXIT.DATAERR,
1298
- what: `no variant for ${args.handle}${size ? `#size=${size}` : ""}${colour ? ` (${colour})` : ""}.`,
1299
- fix: `bt view ${args.handle} (to see available sizes + colours)${have}`
1510
+ what: `no variant for ${args.handle}${r.fit ? ` --fit ${r.fit}` : ""}${size ? `#size=${size}` : ""}${r.colour ? ` (${r.colour})` : ""}.`,
1511
+ fix: `bt view ${args.handle}${r.fit ? ` --fit ${r.fit}` : ""} (to see available sizes + colours)${have}${haveSizes}`
1300
1512
  });
1301
1513
  }
1514
+ const variant = r.variant;
1515
+ const colour = r.colour ?? void 0;
1516
+ const fit = r.fit;
1517
+ const refTags = [colour, fit].filter(Boolean).join(", ");
1518
+ const colourSuffix = refTags ? ` (${refTags})` : "";
1302
1519
  if (args.dryRun) {
1303
- const colourSuffix2 = colour ? ` (${colour})` : "";
1304
1520
  return {
1305
1521
  lines: [
1306
1522
  ...matchNote ? [matchNote] : [],
1307
- ok(`dry-run \u2014 would stage ${product.handle ?? args.handle}${size ? `#${size}` : ""}${colourSuffix2} (nothing staged).`),
1523
+ ...resolved.aliasNote ? [warn(resolved.aliasNote)] : [],
1524
+ ok(`dry-run \u2014 would stage ${product.handle ?? args.handle}${size ? `#${size}` : ""}${colourSuffix} (nothing staged).`),
1308
1525
  " run without --dry-run to acquire the inventory lock."
1309
1526
  ],
1310
1527
  json: args.json ? {
1311
1528
  dry_run: true,
1312
1529
  would_stage: {
1313
1530
  handle: product.handle ?? args.handle,
1531
+ fit: fit ?? null,
1314
1532
  size: size ?? null,
1315
1533
  colour: colour ?? null,
1316
1534
  variant_id: variant.id
@@ -1322,6 +1540,9 @@ async function runStage(client, config, args) {
1322
1540
  if (matchNote) {
1323
1541
  lines.push(matchNote);
1324
1542
  }
1543
+ if (resolved.aliasNote) {
1544
+ lines.push(warn(resolved.aliasNote));
1545
+ }
1325
1546
  let cartId = config.cart_id;
1326
1547
  let recovered = false;
1327
1548
  if (!cartId) {
@@ -1348,7 +1569,6 @@ async function runStage(client, config, args) {
1348
1569
  )
1349
1570
  );
1350
1571
  }
1351
- const colourSuffix = colour ? ` (${colour})` : "";
1352
1572
  lines.push(
1353
1573
  ok(
1354
1574
  `staged ${product.handle ?? args.handle}${size ? `#${size}` : ""}${colourSuffix} \xB7 inventory lock acquired (held 10m)`
@@ -1363,22 +1583,23 @@ async function runStage(client, config, args) {
1363
1583
  // persist the cart_id atomically BEFORE returning (the router calls config.save)
1364
1584
  save: { cart_id: cartId },
1365
1585
  lines,
1366
- json: args.json ? { cart_id: cartId, recovered, colour: colour ?? null, items: cart.items ?? [], total: cart.total ?? null } : void 0
1586
+ json: args.json ? { cart_id: cartId, recovered, fit: fit ?? null, colour: colour ?? null, items: cart.items ?? [], total: cart.total ?? null } : void 0
1367
1587
  };
1368
1588
  }
1369
1589
  function registerStage(program, makeClient, loadConfig) {
1370
- program.command("stage").alias("add").argument("<handle>", "the design: a slug (terminal-velocity), a bt browse number (3), or a fuzzy hint (termnl)").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) => {
1590
+ program.command("stage").alias("add").argument("<handle>", "the design: a slug (terminal-velocity), a bt browse number (3), or a fuzzy hint (termnl)").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("--fit <fit>", "regular or oversize (required when a design comes in both; default: your defaultFit)").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) => {
1371
1591
  const { json, brand } = resolveOutput(program);
1372
1592
  const res = await runStage(makeClient(), loadConfig(), {
1373
1593
  handle,
1374
1594
  size: opts.size,
1375
1595
  colour: opts.colour,
1596
+ fit: opts.fit,
1376
1597
  dryRun: opts.dryRun,
1377
1598
  json,
1378
1599
  brand
1379
1600
  });
1380
1601
  if (res.save) {
1381
- const { saveConfig } = await import("./config-GVEB4R7I.js");
1602
+ const { saveConfig } = await import("./config-L4X7G563.js");
1382
1603
  saveConfig(res.save);
1383
1604
  }
1384
1605
  printResult(res, json);
@@ -1392,8 +1613,12 @@ export {
1392
1613
  StoreClient,
1393
1614
  BT_DEFAULT_STORE_URL,
1394
1615
  BT_DEFAULT_PUBLISHABLE_KEY,
1616
+ fitLabel,
1617
+ productFits,
1618
+ coloursFor,
1619
+ sizesFor,
1620
+ priceFor,
1395
1621
  productColours,
1396
- sizesForColour,
1397
1622
  findVariant,
1398
1623
  productLore,
1399
1624
  isCartTaxInclusive,
@@ -1419,7 +1644,7 @@ export {
1419
1644
  brandOf,
1420
1645
  fmtPrice,
1421
1646
  taxSuffix,
1422
- firstPrice,
1647
+ priceLine,
1423
1648
  stageMarker,
1424
1649
  displayName,
1425
1650
  lineColour,
@@ -1434,4 +1659,4 @@ export {
1434
1659
  runStage,
1435
1660
  registerStage
1436
1661
  };
1437
- //# sourceMappingURL=chunk-VDOFLQ2P.js.map
1662
+ //# sourceMappingURL=chunk-X3RWS3AG.js.map