@binaryandthread/cli 0.1.9 → 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.
package/dist/bin.js CHANGED
@@ -26,18 +26,21 @@ import {
26
26
  displayName,
27
27
  empty,
28
28
  fail,
29
+ failResult,
29
30
  firstPrice,
30
31
  fmtPrice,
31
32
  formatCartLine,
32
33
  formatFatal,
33
34
  green,
34
35
  header,
36
+ isCartTaxInclusive,
35
37
  lineColour,
36
38
  ok,
37
39
  printResult,
38
40
  productColours,
39
41
  productLore,
40
42
  redact,
43
+ regionLine,
41
44
  registerStage,
42
45
  registerView,
43
46
  resolveLineItemRef,
@@ -46,10 +49,11 @@ import {
46
49
  rule,
47
50
  runStage,
48
51
  stageMarker,
52
+ taxSuffix,
49
53
  versionLine,
50
54
  warn,
51
55
  working
52
- } from "./chunk-RGXH3EZ6.js";
56
+ } from "./chunk-NJ3DW5AX.js";
53
57
  import {
54
58
  __require
55
59
  } from "./chunk-7OCVIDC7.js";
@@ -78,7 +82,7 @@ function makeLogger(debug, write = (s) => process.stderr.write(s)) {
78
82
  }
79
83
 
80
84
  // src/version.ts
81
- var VERSION = "0.1.9";
85
+ var VERSION = "0.2.0";
82
86
 
83
87
  // src/eggs.ts
84
88
  function teapot() {
@@ -371,24 +375,55 @@ function deleteToken(deps = {}) {
371
375
  }
372
376
 
373
377
  // src/commands/browse.ts
374
- function productRow(p, index, stage, style) {
375
- const { amount, currency } = firstPrice(p);
376
- const { title, handle } = displayName(p);
377
- const colours = colourMarker(productColours(p));
378
- const ref = dim(style, `[${index + 1}]`.padEnd(4));
379
- const parts = [
380
- title.padEnd(22),
381
- cyan(style, fmtPrice(amount, currency).padEnd(11)),
382
- dim(style, handle.padEnd(30)),
383
- stageMarker(stage)
384
- ];
385
- const row = `${ref} ${parts.join(" ")}`.trimEnd();
386
- return colours ? `${row}
387
- ${dim(style, colours)}` : row;
378
+ function truncate(s, w) {
379
+ if (s.length <= w) return s;
380
+ if (w <= 1) return s.slice(0, Math.max(0, w));
381
+ return s.slice(0, w - 1) + "\u2026";
382
+ }
383
+ function layoutRows(rows, style) {
384
+ const cells = rows.map(({ p, stage }, i) => {
385
+ const { amount, currency } = firstPrice(p);
386
+ const { title, handle } = displayName(p);
387
+ return {
388
+ ref: `[${i + 1}]`,
389
+ title,
390
+ price: fmtPrice(amount, currency),
391
+ handle,
392
+ stage,
393
+ colours: colourMarker(productColours(p))
394
+ };
395
+ });
396
+ const refW = Math.max(...cells.map((c) => c.ref.length));
397
+ const priceW = Math.max(...cells.map((c) => c.price.length));
398
+ const handleW = Math.max(...cells.map((c) => c.handle.length));
399
+ const maxTitle = Math.max(...cells.map((c) => c.title.length));
400
+ const GAP = 2;
401
+ const STAGE_W = 12;
402
+ const term = process.stdout.columns ?? 0;
403
+ let titleW = maxTitle;
404
+ if (term > 0) {
405
+ const fixed = refW + GAP + priceW + GAP + handleW + GAP + STAGE_W;
406
+ titleW = Math.max(12, Math.min(maxTitle, term - fixed));
407
+ }
408
+ const out = [];
409
+ for (const c of cells) {
410
+ const row = [
411
+ dim(style, c.ref.padEnd(refW)),
412
+ truncate(c.title, titleW).padEnd(titleW),
413
+ cyan(style, c.price.padStart(priceW)),
414
+ // right-align money — reads like a ledger
415
+ dim(style, c.handle.padEnd(handleW)),
416
+ stageMarker(c.stage)
417
+ ].join(" ").trimEnd();
418
+ out.push(c.colours ? `${row}
419
+ ${dim(style, c.colours)}` : row);
420
+ }
421
+ return out;
388
422
  }
389
423
  async function runBrowse(client, config, args) {
390
424
  const style = brandOf(args);
391
- const region = await client.resolveRegion(config.region);
425
+ const rr = await client.resolveRegionResult(config.region);
426
+ const region = rr.region;
392
427
  const products = await client.listProducts({ region });
393
428
  const rows = products.map((p) => ({
394
429
  p,
@@ -404,9 +439,8 @@ async function runBrowse(client, config, args) {
404
439
  lines.push(" peek at next \u2192 bt browse --branch=next");
405
440
  } else {
406
441
  lines.push(header(style, "bt browse \u2014 in stock"));
407
- rows.forEach(({ p, stage }, i) => {
408
- lines.push(productRow(p, i, stage, style));
409
- });
442
+ lines.push(regionLine(rr, style));
443
+ lines.push(...layoutRows(rows, style));
410
444
  for (const d of drops) {
411
445
  const dt = (d.title ?? d.handle ?? d.id).toString();
412
446
  lines.push(
@@ -421,6 +455,10 @@ async function runBrowse(client, config, args) {
421
455
  if (drops.length > 0) {
422
456
  lines.push(" on next, before the web \u2192 bt drops");
423
457
  }
458
+ const taxIncl = rows.length > 0 ? firstPrice(rows[0].p).taxInclusive : null;
459
+ if (taxIncl === true) {
460
+ lines.push(dim(style, " prices incl. VAT"));
461
+ }
424
462
  lines.push(" view one \u2192 bt view <n|slug> \xB7 stage one \u2192 bt stage <n|slug> --size L");
425
463
  }
426
464
  return {
@@ -440,7 +478,7 @@ function registerBrowse(program, makeClient2, loadConfig2) {
440
478
  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("--pick", "opt into the arrow-key picker (default is the numbered table \u2014 use bt stage <n>)").option("--plain", "force the static table (the default; kept for back-compat)").action(async (opts) => {
441
479
  const { json, brand } = resolveOutput(program);
442
480
  if (opts.pick && !json && brand.color && process.stdout.isTTY && process.stdin.isTTY) {
443
- const { runInteractiveBrowse } = await import("./interactive-2YROZBIN.js");
481
+ const { runInteractiveBrowse } = await import("./interactive-5PBJCENZ.js");
444
482
  const handled = await runInteractiveBrowse(makeClient2(), loadConfig2(), { branch: opts.branch, brand });
445
483
  if (handled) return;
446
484
  }
@@ -449,6 +487,143 @@ function registerBrowse(program, makeClient2, loadConfig2) {
449
487
  });
450
488
  }
451
489
 
490
+ // src/commands/region.ts
491
+ var MAX_COUNTRIES_SHOWN = 8;
492
+ function regionRow(r) {
493
+ const ccs = (r.countries ?? []).map((c) => (c.iso_2 ?? "").toLowerCase()).filter(Boolean).sort();
494
+ const shown = ccs.slice(0, MAX_COUNTRIES_SHOWN).join(" \xB7 ");
495
+ const more = ccs.length > MAX_COUNTRIES_SHOWN ? ` \u2026 (${ccs.length})` : "";
496
+ return ` ${r.currency_code.toLowerCase().padEnd(5)} ${shown}${more}`.trimEnd();
497
+ }
498
+ async function runRegion(client, config, args) {
499
+ const style = brandOf(args);
500
+ if (args.cc) {
501
+ const cc = args.cc.trim().toLowerCase();
502
+ const rr2 = await client.resolveRegionResult(cc);
503
+ if (!rr2.matched) {
504
+ return failResult({
505
+ code: EXIT.DATAERR,
506
+ what: `we don't ship to ${cc} yet. nothing was set.`,
507
+ fix: "bt region --list (see where we ship)"
508
+ });
509
+ }
510
+ return {
511
+ lines: [ok(`region \u2192 ${cc} \xB7 prices in ${rr2.region.currency_code}`)],
512
+ save: { region: cc },
513
+ json: args.json ? { region: cc, currency: rr2.region.currency_code } : void 0
514
+ };
515
+ }
516
+ if (args.list) {
517
+ const regions = await client.getRegions();
518
+ const lines = [
519
+ dim(style, "where we ship, and what you pay in:"),
520
+ ...regions.map(regionRow),
521
+ dim(style, " set yours \u2192 bt region <cc>")
522
+ ];
523
+ return {
524
+ lines,
525
+ json: args.json ? { regions: regions.map((r) => ({ currency: r.currency_code, countries: (r.countries ?? []).map((c) => c.iso_2) })) } : void 0
526
+ };
527
+ }
528
+ const rr = await client.resolveRegionResult(config.region);
529
+ return {
530
+ lines: [regionLine(rr, style), dim(style, " change \u2192 bt region <cc> \xB7 all \u2192 bt region --list")],
531
+ json: args.json ? { region: rr.requested ?? null, currency: rr.region.currency_code, matched: rr.matched, home: rr.home } : void 0
532
+ };
533
+ }
534
+ function registerRegion(program, makeClient2, loadConfig2, persist) {
535
+ program.command("region").argument("[cc]", "set your ship-to country (ISO-2, e.g. se / us / de)").option("--list", "list every country we ship to + its currency").description("see (or set) your ship-to country + currency.").action(async (cc, opts) => {
536
+ const { json, brand } = resolveOutput(program);
537
+ const res = await runRegion(makeClient2(), loadConfig2(), { cc, list: opts.list, json, brand });
538
+ if (res.save) {
539
+ persist(res.save);
540
+ }
541
+ printResult(res, json);
542
+ });
543
+ }
544
+
545
+ // src/commands/completion.ts
546
+ var COMMANDS = [
547
+ "browse",
548
+ "search",
549
+ "view",
550
+ "stage",
551
+ "buy",
552
+ "cart",
553
+ "unstage",
554
+ "checkout",
555
+ "commit",
556
+ "order",
557
+ "log",
558
+ "status",
559
+ "whoami",
560
+ "region",
561
+ "drops",
562
+ "blame",
563
+ "login",
564
+ "logout",
565
+ "config",
566
+ "token",
567
+ "doctor",
568
+ "report",
569
+ "connect",
570
+ "init",
571
+ "completion"
572
+ ];
573
+ function completionScript(shell) {
574
+ const list = COMMANDS.join(" ");
575
+ if (shell === "bash") {
576
+ return [
577
+ '# bt bash completion \u2014 add to ~/.bashrc: eval "$(bt completion bash)"',
578
+ "_bt_completions() {",
579
+ ' local cur="${COMP_WORDS[COMP_CWORD]}"',
580
+ ` COMPREPLY=( $(compgen -W "${list}" -- "$cur") )`,
581
+ "}",
582
+ "complete -F _bt_completions bt btn",
583
+ ""
584
+ ].join("\n");
585
+ }
586
+ if (shell === "zsh") {
587
+ return [
588
+ "#compdef bt btn",
589
+ '# bt zsh completion \u2014 add to ~/.zshrc: eval "$(bt completion zsh)"',
590
+ "_bt() {",
591
+ " local -a cmds",
592
+ ` cmds=(${list})`,
593
+ " _describe 'command' cmds",
594
+ "}",
595
+ "compdef _bt bt btn",
596
+ ""
597
+ ].join("\n");
598
+ }
599
+ return [
600
+ "# bt fish completion \u2014 bt completion fish | source",
601
+ "complete -c bt -f",
602
+ `for c in ${list}`,
603
+ " complete -c bt -n __fish_use_subcommand -a $c",
604
+ "end",
605
+ ""
606
+ ].join("\n");
607
+ }
608
+ function runCompletion(args) {
609
+ const shell = (args.shell ?? "").trim().toLowerCase();
610
+ if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
611
+ return failResult({
612
+ code: EXIT.DATAERR,
613
+ what: `unknown shell "${args.shell ?? ""}".`,
614
+ fix: "bt completion <bash|zsh|fish>"
615
+ });
616
+ }
617
+ const script = completionScript(shell);
618
+ return { lines: [script], json: args.json ? { shell, script } : void 0 };
619
+ }
620
+ function registerCompletion(program) {
621
+ program.command("completion").argument("<shell>", "bash | zsh | fish").description("print a shell completion script (eval it in your shell rc).").action((shell) => {
622
+ const { json } = resolveOutput(program);
623
+ printResult(runCompletion({ shell, json }), json);
624
+ });
625
+ }
626
+
452
627
  // src/commands/search.ts
453
628
  function matches(p, q) {
454
629
  const needle = q.toLowerCase();
@@ -523,13 +698,19 @@ async function runCart(client, config, args) {
523
698
  lines.push(formatCartLine(item, i, cart.currency_code, style));
524
699
  });
525
700
  if (cart.total != null) {
526
- lines.push(` total ${cyan(style, fmtPrice(cart.total, cart.currency_code))} incl. VAT`);
701
+ const shipNote = (cart.shipping_methods ?? []).length > 0 ? "" : dim(style, " (excl. shipping \u2014 calculated at checkout)");
702
+ lines.push(` total ${cyan(style, fmtPrice(cart.total, cart.currency_code))}${taxSuffix(isCartTaxInclusive(cart))}${shipNote}`);
527
703
  }
528
704
  lines.push(" remove one \u2192 bt unstage <#> \xB7 commit \u2192 bt commit");
529
705
  }
530
706
  return {
531
707
  lines,
532
- json: args.json ? { cart_id: cart.id, items: cart.items ?? [], total: cart.total ?? null } : void 0
708
+ json: args.json ? {
709
+ cart_id: cart.id,
710
+ items: cart.items ?? [],
711
+ total: cart.total ?? null,
712
+ total_excludes_shipping: (cart.shipping_methods ?? []).length === 0
713
+ } : void 0
533
714
  };
534
715
  } catch (e) {
535
716
  if (e instanceof RecoverableCartError) {
@@ -641,6 +822,7 @@ async function runCheckout(client, config, args, deps) {
641
822
  }
642
823
  const itemCount = cart.items.reduce((n, li) => n + (li.quantity ?? 1), 0);
643
824
  const totalLine = fmtPrice(shippedCart?.total ?? cart.total, cart.currency_code ?? region.currency_code);
825
+ const taxIncl = isCartTaxInclusive(cart);
644
826
  const reviewLines = [header(style, "bt checkout \u2014 staged for commit")];
645
827
  for (const li of cart.items) {
646
828
  const colour = lineColour(li);
@@ -649,7 +831,7 @@ async function runCheckout(client, config, args, deps) {
649
831
  ` ${(li.title ?? li.product_title ?? "item").toString()}${colourTag} \xB7 qty ${li.quantity}` + (li.total != null ? ` ${cyan(style, fmtPrice(li.total, cart.currency_code ?? region.currency_code))}` : "")
650
832
  );
651
833
  }
652
- reviewLines.push(` total ${cyan(style, totalLine)} incl. VAT`);
834
+ reviewLines.push(` total ${cyan(style, totalLine)}${taxSuffix(taxIncl)}`);
653
835
  const print = deps.print ?? ((line) => process.stdout.write(line + "\n"));
654
836
  for (const line of reviewLines) {
655
837
  print(line);
@@ -657,7 +839,7 @@ async function runCheckout(client, config, args, deps) {
657
839
  const autoProceed = Boolean(args.json) || Boolean(args.yes);
658
840
  if (!autoProceed) {
659
841
  const proceed = await prompts.confirm(
660
- `proceed to checkout? ${itemCount} item${itemCount === 1 ? "" : "s"} \xB7 ${totalLine} incl. VAT (y/n):`
842
+ `proceed to checkout? ${itemCount} item${itemCount === 1 ? "" : "s"} \xB7 ${totalLine}${taxSuffix(taxIncl)} (y/n):`
661
843
  );
662
844
  if (!proceed) {
663
845
  return {
@@ -702,7 +884,7 @@ async function runCheckout(client, config, args, deps) {
702
884
  lines.push(ok("deploy successful."));
703
885
  lines.push(` committed to wardrobe@main`);
704
886
  lines.push(` order #${displayId}`);
705
- lines.push(` total ${cyan(style, totalLine)} (incl. VAT)`);
887
+ lines.push(` total ${cyan(style, totalLine)}${taxIncl ? " (incl. VAT)" : ""}`);
706
888
  if (shipsTo) {
707
889
  lines.push(` ships to ${shipsTo} \xB7 ~3\u20136 working days`);
708
890
  } else {
@@ -715,7 +897,15 @@ async function runCheckout(client, config, args, deps) {
715
897
  return {
716
898
  lines,
717
899
  save,
718
- json: args.json ? { status: "placed", order_id: outcome.order_id, display_id: displayId } : void 0
900
+ json: args.json ? {
901
+ status: "placed",
902
+ order_id: outcome.order_id,
903
+ display_id: displayId,
904
+ // A2 — the SHIPPING-INCLUSIVE total the buyer consented to (same figure as the review),
905
+ // so a --json / bt buy consumer has the real amount, not just a status.
906
+ total: shippedCart?.total ?? cart.total ?? null,
907
+ currency: cart.currency_code ?? region.currency_code ?? null
908
+ } : void 0
719
909
  };
720
910
  }
721
911
  if (outcome.status === "expired") {
@@ -731,7 +921,7 @@ async function runCheckout(client, config, args, deps) {
731
921
  fail({
732
922
  code: 75,
733
923
  what: "checkout handoff timed out \u2014 we didn't see the order complete.",
734
- money: "nothing was charged; your stage is still held.",
924
+ money: "if you completed payment, your order is safe; if not, your stage is still held \u2014 check your email or bt order.",
735
925
  fix: "bt checkout --resume (or open the link below manually)",
736
926
  link: checkout_url
737
927
  })
@@ -821,40 +1011,33 @@ function timelineGraph(idx) {
821
1011
  async function runOrder(client, config, args) {
822
1012
  const email = config.email;
823
1013
  if (!email) {
824
- return {
825
- lines: [
826
- fail({
827
- code: 1,
828
- what: "order lookup needs your email.",
829
- fix: "bt config email <you@example.com> (then retry)"
830
- })
831
- ]
832
- };
1014
+ return failResult({
1015
+ code: EXIT.DATAERR,
1016
+ what: "order lookup needs your email.",
1017
+ fix: "bt config set email <you@example.com> (then retry)"
1018
+ });
833
1019
  }
834
1020
  const looked = await client.getOrder(args.displayId, email);
835
1021
  if (!looked) {
836
- return {
837
- lines: [
838
- fail({
839
- code: 1,
840
- what: `order ${args.displayId} not found for that email.`,
841
- fix: "check the order number + the email it was placed with."
842
- })
843
- ]
844
- };
1022
+ return failResult({
1023
+ code: EXIT.UNAVAILABLE,
1024
+ what: `order ${args.displayId} not found for that email.`,
1025
+ fix: "check the order number + the email it was placed with."
1026
+ });
845
1027
  }
846
1028
  const o = looked.order ?? looked;
847
1029
  const idx = stageIndex(o);
848
1030
  const display = o.display_id ?? args.displayId;
849
1031
  const total = fmtPrice(o.total ?? null, o.currency_code ?? null);
850
1032
  const items = o.items ?? [];
1033
+ const orderTaxInclusive = items.some((i) => i.is_tax_inclusive === true);
851
1034
  const first = items[0];
852
1035
  const itemLabel = first ? `${first.title ?? "item"}${first.variant_title ? ` \xB7 ${first.variant_title}` : ""}` : "your order";
853
1036
  const style = brandOf(args);
854
1037
  const lines = [];
855
1038
  lines.push(`${display} ${itemLabel} ${cyan(style, timelineGraph(idx))} ${STAGES[idx]}`);
856
1039
  lines.push(` ${STAGES.join(" \u2192 ")}${idx === 4 ? " \u{1F389}" : ""}`);
857
- lines.push(` total ${cyan(style, total)} incl. VAT`);
1040
+ lines.push(` total ${cyan(style, total)}${taxSuffix(orderTaxInclusive)}`);
858
1041
  return {
859
1042
  lines,
860
1043
  json: args.json ? { display_id: display, stage: STAGES[idx], total: o.total ?? null, currency: o.currency_code ?? null } : void 0
@@ -1048,7 +1231,7 @@ async function runStatus(client, config, args) {
1048
1231
  lines.push(formatCartLine(li, i, cart.currency_code, style));
1049
1232
  });
1050
1233
  if (cart.total != null) {
1051
- lines.push(` total ${cyan(style, fmtPrice(cart.total, cart.currency_code))} incl. VAT`);
1234
+ lines.push(` total ${cyan(style, fmtPrice(cart.total, cart.currency_code))}${taxSuffix(isCartTaxInclusive(cart))}`);
1052
1235
  }
1053
1236
  lines.push(" remove one \u2192 bt unstage <#> \xB7 commit \u2192 bt commit");
1054
1237
  }
@@ -1104,7 +1287,9 @@ async function runUnstage(client, config, args) {
1104
1287
  warn(`no such item in your cart: "${args.itemId}"`),
1105
1288
  " see what's staged (with its #) \u2192 bt cart"
1106
1289
  ],
1107
- json: args.json ? { cart_id: before.id, removed: null, items: beforeItems } : void 0
1290
+ json: args.json ? { cart_id: before.id, removed: null, items: beforeItems } : void 0,
1291
+ exitCode: EXIT.DATAERR
1292
+ // bad ref → non-zero so a script can't proceed as if it removed something (B3)
1108
1293
  };
1109
1294
  }
1110
1295
  await client.removeLineItem(config.cart_id, target.id);
@@ -1115,12 +1300,14 @@ async function runUnstage(client, config, args) {
1115
1300
  return {
1116
1301
  lines: [
1117
1302
  fail({
1118
- code: 1,
1303
+ code: EXIT.GENERIC,
1119
1304
  what: `couldn't unstage "${(target.title ?? target.product_title ?? args.itemId).toString()}" \u2014 it's still in your cart.`,
1120
1305
  fix: "check what's staged \u2192 bt cart (then try the # shown, or bt forget to clear)"
1121
1306
  })
1122
1307
  ],
1123
- json: args.json ? { cart_id: after.id, removed: null, items: afterItems } : void 0
1308
+ json: args.json ? { cart_id: after.id, removed: null, items: afterItems } : void 0,
1309
+ exitCode: EXIT.GENERIC
1310
+ // the server didn't drop it → honest non-zero, never a fake success (B3)
1124
1311
  };
1125
1312
  }
1126
1313
  const removedTitle = (target.title ?? target.product_title ?? "item").toString();
@@ -1145,7 +1332,8 @@ async function runUnstage(client, config, args) {
1145
1332
  warn("that cart is no longer valid (expired or already committed)."),
1146
1333
  empty(FRESH_REPO2),
1147
1334
  " re-stage \u2192 bt browse"
1148
- ]
1335
+ ],
1336
+ json: args.json ? { cart_id: null, removed: null, items: [] } : void 0
1149
1337
  };
1150
1338
  }
1151
1339
  throw e;
@@ -1275,7 +1463,8 @@ function mergesIn(nextUntil) {
1275
1463
  if (hours < 48) return `merges to main in ${hours}h`;
1276
1464
  return `merges to main in ${Math.round(hours / 24)}d`;
1277
1465
  }
1278
- async function runDrops(client, _config, args) {
1466
+ async function runDrops(client, config, args) {
1467
+ const region = await client.resolveRegion(config.region);
1279
1468
  const drops = await client.listDrops();
1280
1469
  if (!drops || drops.length === 0) {
1281
1470
  return {
@@ -1291,7 +1480,7 @@ async function runDrops(client, _config, args) {
1291
1480
  lines.push(
1292
1481
  [
1293
1482
  ` ${(d.handle ?? d.id).padEnd(32)}`,
1294
- fmtPrice(d.price, null).padEnd(10),
1483
+ fmtPrice(d.price, region.currency_code).padEnd(10),
1295
1484
  mergesIn(d.next_until)
1296
1485
  ].join(" ").trimEnd()
1297
1486
  );
@@ -1344,6 +1533,35 @@ function registerConnect(program) {
1344
1533
  // src/commands/login.ts
1345
1534
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1346
1535
  async function runLogin(client, prompts, keychain, config, args) {
1536
+ if (args.json) {
1537
+ const jsonEmail = (args.email ?? "").trim();
1538
+ const jsonToken = (args.token ?? "").trim();
1539
+ if (!jsonEmail || !EMAIL_RE.test(jsonEmail) || !jsonToken) {
1540
+ return failResult({
1541
+ code: EXIT.DATAERR,
1542
+ what: "login --json is non-interactive \u2014 it needs --email and a token in BT_MAGIC_TOKEN.",
1543
+ fix: "bt login --email you@dev.to with BT_MAGIC_TOKEN=<the token from the email>"
1544
+ });
1545
+ }
1546
+ let jsonResult;
1547
+ try {
1548
+ jsonResult = await client.verifyMagicLink(jsonToken);
1549
+ } catch (e) {
1550
+ if (e instanceof StoreApiError && (e.status === 401 || e.status === 403)) {
1551
+ return failResult({ code: EXIT.NOPERM, what: "that token is invalid or expired.", fix: "request a fresh one, then set BT_MAGIC_TOKEN" });
1552
+ }
1553
+ if (e instanceof StoreApiError) {
1554
+ return failResult({ code: EXIT.TEMPFAIL, what: "login couldn't complete right now.", fix: "try again in a moment" });
1555
+ }
1556
+ throw e;
1557
+ }
1558
+ keychain.setToken(jsonResult.jwt);
1559
+ return {
1560
+ lines: [ok("authenticated. welcome \u2014 you're a contributor now.")],
1561
+ save: { email: jsonEmail },
1562
+ json: { connected: true, customer_id: jsonResult.customer.id }
1563
+ };
1564
+ }
1347
1565
  let email = (args.email ?? "").trim();
1348
1566
  if (!email) {
1349
1567
  email = (await prompts.ask("email", { default: config.email })).trim();
@@ -1410,11 +1628,7 @@ function registerLogin(program, makeClient2, loadConfig2, prompts, keychain, per
1410
1628
  if (res.save) {
1411
1629
  persist(res.save);
1412
1630
  }
1413
- if (json && res.json !== void 0) {
1414
- process.stdout.write(JSON.stringify(res.json) + "\n");
1415
- } else {
1416
- process.stdout.write(res.lines.join("\n") + "\n");
1417
- }
1631
+ printResult(res, json);
1418
1632
  });
1419
1633
  }
1420
1634
 
@@ -1425,13 +1639,15 @@ async function runLogout(keychain) {
1425
1639
  lines: [
1426
1640
  ok("logged out. your token is wiped from this machine's keychain."),
1427
1641
  empty(" (server tokens are short-lived by design \u2014 they expire on their own.)")
1428
- ]
1642
+ ],
1643
+ json: { logged_out: true }
1429
1644
  };
1430
1645
  }
1431
1646
  function registerLogout(program, keychain) {
1432
1647
  program.command("logout").description("clear your local session (wipes the keychain token).").action(async () => {
1648
+ const { json } = resolveOutput(program);
1433
1649
  const res = await runLogout(keychain);
1434
- process.stdout.write(res.lines.join("\n") + "\n");
1650
+ printResult(res, json);
1435
1651
  });
1436
1652
  }
1437
1653
 
@@ -1472,7 +1688,9 @@ async function runDrop(args, exec) {
1472
1688
  if (code !== 0) {
1473
1689
  return {
1474
1690
  lines: [warn(`drop ${sub} reported a problem (exit ${code}). check the backend logs / ops alert.`)],
1475
- json: args.json ? { sub, drop_id: args.dropId, exit: code } : void 0
1691
+ json: args.json ? { sub, drop_id: args.dropId, exit: code } : void 0,
1692
+ exitCode: code
1693
+ // forward the backend's exit code so operator CI can branch on $? (B3)
1476
1694
  };
1477
1695
  }
1478
1696
  const done = {
@@ -1490,16 +1708,12 @@ function registerDrop(program, exec) {
1490
1708
  program.command("drop", { hidden: true }).argument("<sub>", "create | verify | promote | retire").option("--motif <slug>", "motif slug (create)").option("--drop-id <id>", "the drop id").option("--stage <stage>", "expected stage for verify (next|main)").description("operator: manage a drop's lifecycle (create/verify/promote/retire).").action(async (sub, opts) => {
1491
1709
  const { json } = resolveOutput(program);
1492
1710
  const operator = process.env.BT_OPERATOR === "1";
1493
- process.stdout.write(working(`drop ${sub}\u2026`) + "\n");
1711
+ if (!json) process.stdout.write(working(`drop ${sub}\u2026`) + "\n");
1494
1712
  const res = await runDrop(
1495
1713
  { sub, motif: opts.motif, dropId: opts.dropId, stage: opts.stage, operator, json },
1496
1714
  exec
1497
1715
  );
1498
- if (json && res.json !== void 0) {
1499
- process.stdout.write(JSON.stringify(res.json) + "\n");
1500
- } else {
1501
- process.stdout.write(res.lines.join("\n") + "\n");
1502
- }
1716
+ printResult(res, json);
1503
1717
  });
1504
1718
  }
1505
1719
 
@@ -1611,21 +1825,19 @@ function runConfig(args, deps) {
1611
1825
  }
1612
1826
  if (sub === "set") {
1613
1827
  if (!args.key || args.value == null || args.value === "") {
1614
- return { lines: [warn("usage: bt config set <key> <value>")] };
1828
+ return failResult({ code: EXIT.DATAERR, what: "config set needs a key and a value.", fix: "bt config set <key> <value>" });
1615
1829
  }
1616
1830
  if (!SETTABLE.includes(args.key)) {
1617
- return {
1618
- lines: [
1619
- warn(
1620
- `can't set "${args.key}". settable keys: ${SETTABLE.join(", ")}. (the auth token is never stored in config \u2014 use bt login.)`
1621
- )
1622
- ]
1623
- };
1831
+ return failResult({
1832
+ code: EXIT.DATAERR,
1833
+ what: `can't set "${args.key}".`,
1834
+ fix: `settable keys: ${SETTABLE.join(", ")} (the auth token is never stored in config \u2014 use bt login)`
1835
+ });
1624
1836
  }
1625
1837
  deps.save({ [args.key]: args.value });
1626
1838
  return { lines: [ok(`set ${args.key} = ${args.value}`)], json: args.json ? { [args.key]: args.value } : void 0 };
1627
1839
  }
1628
- return { lines: [warn("usage: bt config get [key] | bt config set <key> <value>")] };
1840
+ return failResult({ code: EXIT.DATAERR, what: `unknown config command "${sub}".`, fix: "bt config get [key] | bt config set <key> <value>" });
1629
1841
  }
1630
1842
  function registerConfig(program) {
1631
1843
  program.command("config").argument("[sub]", "get | set", "get").argument("[key]", "config key").argument("[value]", "value (for set)").description("view or set local prefs (email, region, defaults). the auth token is never stored here.").action(async (sub, key, value) => {
@@ -1644,7 +1856,9 @@ function runDoctor(s, json) {
1644
1856
  ok("bt doctor \u2014 local checkup (nothing was sent)"),
1645
1857
  ` bt ${s.btVersion}`,
1646
1858
  ` node ${s.node} \xB7 ${s.platform}`,
1647
- ` store ${s.storeUrl}`,
1859
+ // E4 — a friendly label for the production store; the raw URL only when pointed elsewhere
1860
+ // (local/staging), where it's genuinely diagnostic. The JSON snapshot keeps the raw URL.
1861
+ ` store ${s.storeUrl === BT_DEFAULT_STORE_URL ? "binaryandthread.com (production)" : s.storeUrl}`,
1648
1862
  ` key set (${s.keySource})`,
1649
1863
  ` auth ${auth}`,
1650
1864
  ` terminal ${s.tty ? "tty" : "non-tty"} \xB7 colour ${s.color ? "on" : "off"}`,
@@ -1793,7 +2007,7 @@ function buildProgram() {
1793
2007
  const program = new Command();
1794
2008
  program.name("bt").description(
1795
2009
  "the binary & thread terminal store. techno-minimalist clothing & lifestyle, bought with the ceremony of a deploy.\nprimary invocation: npx @binaryandthread/cli (a bare `bt` may collide with another binary)"
1796
- ).version(versionLine(VERSION), "-v, --version", "print the version").option("--json", "machine-readable output", false).option("--no-color", "disable ANSI colour + inline images (also honours NO_COLOR)").option("--debug, --verbose", "trace requests to stderr (redacted) \u2014 for bug reports (d15)").showSuggestionAfterError();
2010
+ ).version(versionLine(VERSION), "-v, --version", "print the version").option("--json", "machine-readable output", false).option("--no-color", "disable ANSI colour + inline images (also honours NO_COLOR)").option("--debug, --verbose", "trace requests to stderr (redacted) \u2014 for bug reports (d15)").option("--region <cc>", "ship-to country for this command (ISO-2, overrides your saved region)").showSuggestionAfterError();
1797
2011
  program.addHelpText("beforeAll", "\n bt \u2014 the binary & thread terminal store. new here? \u2192 bt init\n");
1798
2012
  program.command("brew").argument("[what]", "what to brew").description("we make clothing, not coffee.").action(() => {
1799
2013
  process.stdout.write(teapot() + "\n");
@@ -1802,7 +2016,9 @@ function buildProgram() {
1802
2016
  program.command("forget").description("wipe your local session + config (keychain token, cart, email, defaults).").action(() => {
1803
2017
  deleteToken();
1804
2018
  forgetConfig();
1805
- process.stdout.write("\u2713 local session + config wiped. (your wardrobe on the server is untouched.)\n");
2019
+ const json = Boolean(program.opts().json);
2020
+ if (json) process.stdout.write(JSON.stringify({ wiped: true }) + "\n");
2021
+ else process.stdout.write("\u2713 local session + config wiped. (your wardrobe on the server is untouched.)\n");
1806
2022
  });
1807
2023
  registerCommands(program);
1808
2024
  return program;
@@ -1822,34 +2038,41 @@ var dropExec = {
1822
2038
  }
1823
2039
  };
1824
2040
  function registerCommands(program) {
2041
+ const persist = (patch) => {
2042
+ saveConfig(patch);
2043
+ };
2044
+ const effectiveConfig = () => {
2045
+ const saved = loadConfig();
2046
+ const flag = program.opts().region;
2047
+ return flag ? { ...saved, region: flag.trim().toLowerCase() } : saved;
2048
+ };
1825
2049
  registerInit(program);
1826
- registerBrowse(program, makeClient, loadConfig);
1827
- registerSearch(program, makeClient, loadConfig);
1828
- registerView(program, makeClient, loadConfig);
1829
- registerStage(program, makeClient, loadConfig);
1830
- registerBuy(program, makeClient, loadConfig);
2050
+ registerBrowse(program, makeClient, effectiveConfig);
2051
+ registerSearch(program, makeClient, effectiveConfig);
2052
+ registerView(program, makeClient, effectiveConfig);
2053
+ registerStage(program, makeClient, effectiveConfig);
2054
+ registerBuy(program, makeClient, effectiveConfig);
1831
2055
  registerConfig(program);
2056
+ registerRegion(program, makeClient, effectiveConfig, persist);
2057
+ registerCompletion(program);
1832
2058
  registerToken(program);
1833
2059
  registerDoctor(program);
1834
2060
  registerReport(program, makeClient);
1835
- registerBlame(program, makeClient, loadConfig);
1836
- registerCart(program, makeClient, loadConfig);
1837
- registerCheckout(program, makeClient, loadConfig);
1838
- registerOrder(program, makeClient, loadConfig);
1839
- registerLog(program, makeClient, loadConfig);
1840
- registerStatus(program, makeClient, loadConfig);
1841
- registerWhoami(program, makeClient, loadConfig);
1842
- registerDrops(program, makeClient, loadConfig);
2061
+ registerBlame(program, makeClient, effectiveConfig);
2062
+ registerCart(program, makeClient, effectiveConfig);
2063
+ registerCheckout(program, makeClient, effectiveConfig);
2064
+ registerOrder(program, makeClient, effectiveConfig);
2065
+ registerLog(program, makeClient, effectiveConfig);
2066
+ registerStatus(program, makeClient, effectiveConfig);
2067
+ registerWhoami(program, makeClient, effectiveConfig);
2068
+ registerDrops(program, makeClient, effectiveConfig);
1843
2069
  registerConnect(program);
1844
- registerUnstage(program, makeClient, loadConfig);
2070
+ registerUnstage(program, makeClient, effectiveConfig);
1845
2071
  const prompts = makePrompts();
1846
- const persist = (patch) => {
1847
- saveConfig(patch);
1848
- };
1849
2072
  registerLogin(
1850
2073
  program,
1851
2074
  makeClient,
1852
- loadConfig,
2075
+ effectiveConfig,
1853
2076
  prompts,
1854
2077
  { setToken: (jwt) => setToken(jwt) },
1855
2078
  persist