@theme-registry/refract 0.1.3 → 0.1.4

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/cli.js CHANGED
@@ -8430,16 +8430,22 @@ function runCreate(options) {
8430
8430
  }
8431
8431
 
8432
8432
  /**
8433
- * Minimal interactive prompts over `node:readline` (Node-only).
8433
+ * Interactive prompts over `node:readline` and raw stdin (Node-only).
8434
8434
  *
8435
8435
  * The package ships zero runtime dependencies and this is the build layer, so rather than pull in a
8436
- * prompt library for one command these are the four shapes the CLI actually needs. They are
8437
- * line-based, not raw-mode: you type a number or a value and press Enter. That loses arrow-key
8438
- * navigation, and buys a prompt that behaves predictably over SSH, in CI logs, in a Docker build,
8439
- * and under any terminal that doesn't grant raw mode — where a fancier prompt would hang or garble.
8436
+ * prompt library these are the shapes the CLIs actually need, implemented directly.
8440
8437
  *
8441
- * Every prompt is **non-interactive-safe**: with no TTY (a pipe, CI, `--yes`) it takes the default
8442
- * without blocking, so a scripted run never deadlocks waiting on stdin that will never arrive.
8438
+ * Three tiers, chosen per call:
8439
+ *
8440
+ * 1. **Raw mode** (a TTY that grants `setRawMode`) — arrow keys move, space toggles, Enter confirms.
8441
+ * What people expect from `npm create`.
8442
+ * 2. **Line mode** (a TTY that refuses raw mode — some CI shells, some remote terminals) — the same
8443
+ * questions answered by typing a number. Plainer, but never wedged.
8444
+ * 3. **Non-interactive** (no TTY at all: a pipe, CI, `--yes`) — every prompt takes its default
8445
+ * without blocking, so a scripted run can't deadlock on stdin that will never arrive.
8446
+ *
8447
+ * Key handling is a **pure reducer** (`applyKey`), so navigation is unit-testable without a terminal;
8448
+ * the raw loop is only plumbing around it.
8443
8449
  */
8444
8450
  /** ANSI helpers — no-ops when the stream isn't a TTY, so piped output stays clean. */
8445
8451
  const useColor = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -8449,10 +8455,102 @@ const dim = (s) => paint("2", s);
8449
8455
  const cyan = (s) => paint("36", s);
8450
8456
  const green = (s) => paint("32", s);
8451
8457
  const yellow = (s) => paint("33", s);
8458
+ /** Terminal control, named so the rendering below reads as intent rather than escape soup. */
8459
+ const cursorUp = (n) => (n > 0 ? `\u001b[${n}A` : "");
8460
+ const CLEAR_DOWN = "\u001b[0J";
8461
+ const HIDE_CURSOR = "\u001b[?25l";
8462
+ const SHOW_CURSOR = "\u001b[?25h";
8463
+ /** Decode a raw stdin chunk into a {@link ListKey}. Arrows arrive as escape sequences; j/k mirror vim. */
8464
+ function decodeKey(chunk) {
8465
+ switch (chunk) {
8466
+ case "\u001b[A":
8467
+ case "k":
8468
+ return "up";
8469
+ case "\u001b[B":
8470
+ case "j":
8471
+ return "down";
8472
+ case " ":
8473
+ return "space";
8474
+ case "\r":
8475
+ case "\n":
8476
+ return "submit";
8477
+ case "a":
8478
+ return "all";
8479
+ case "\u0003": // Ctrl-C
8480
+ case "\u001b": // bare Escape
8481
+ return "cancel";
8482
+ default:
8483
+ return "none";
8484
+ }
8485
+ }
8486
+ /**
8487
+ * Split one stdin chunk into the keys it contains.
8488
+ *
8489
+ * A held-down arrow key, or fast typing, delivers several sequences in a single `data` event — so
8490
+ * decoding the chunk as one key would swallow all but the first and make the list feel like it drops
8491
+ * input. Escape sequences (`ESC [ <letter>`) are taken as a unit; everything else is one key each.
8492
+ */
8493
+ function decodeKeys(chunk) {
8494
+ const keys = [];
8495
+ for (let i = 0; i < chunk.length;) {
8496
+ if (chunk[i] === "\u001b" && chunk[i + 1] === "[" && i + 2 < chunk.length) {
8497
+ keys.push(decodeKey(chunk.slice(i, i + 3)));
8498
+ i += 3;
8499
+ }
8500
+ else {
8501
+ keys.push(decodeKey(chunk[i]));
8502
+ i += 1;
8503
+ }
8504
+ }
8505
+ return keys;
8506
+ }
8452
8507
  /**
8453
- * A prompt session. Holds one readline interface for the whole interview so stdin is opened and
8454
- * closed exactly once; `interactive` is false when there's no TTY, and every ask short-circuits to
8455
- * its default.
8508
+ * Advance a list prompt by one key. Pure no I/O so navigation is testable without a terminal.
8509
+ *
8510
+ * The cursor **wraps** at both ends: with six options, up from the first should land on the last
8511
+ * rather than stick. `space` and `a` toggle only in multi mode; in single mode Enter is the commit,
8512
+ * so space would be ambiguous.
8513
+ */
8514
+ function applyKey(state, key, count, multi) {
8515
+ if (state.done || state.cancelled || count === 0)
8516
+ return state;
8517
+ switch (key) {
8518
+ case "up":
8519
+ return { ...state, cursor: (state.cursor - 1 + count) % count };
8520
+ case "down":
8521
+ return { ...state, cursor: (state.cursor + 1) % count };
8522
+ case "space": {
8523
+ if (!multi)
8524
+ return state;
8525
+ const next = new Set(state.selected);
8526
+ if (next.has(state.cursor))
8527
+ next.delete(state.cursor);
8528
+ else
8529
+ next.add(state.cursor);
8530
+ return { ...state, selected: next };
8531
+ }
8532
+ case "all": {
8533
+ if (!multi)
8534
+ return state;
8535
+ const everything = state.selected.size === count;
8536
+ return {
8537
+ ...state,
8538
+ selected: everything ? new Set() : new Set(Array.from({ length: count }, (_, i) => i)),
8539
+ };
8540
+ }
8541
+ case "submit":
8542
+ return { ...state, done: true };
8543
+ case "cancel":
8544
+ return { ...state, cancelled: true };
8545
+ default:
8546
+ return state;
8547
+ }
8548
+ }
8549
+ /** Can we drive the terminal directly? Some TTYs (and most CI) don't grant raw mode. */
8550
+ const rawCapable = () => Boolean(process.stdin.isTTY) && typeof process.stdin.setRawMode === "function";
8551
+ /**
8552
+ * A prompt session. Holds at most one readline interface, so stdin is opened and closed once;
8553
+ * `interactive` is false when there's no TTY, and every ask short-circuits to its default.
8456
8554
  */
8457
8555
  class Prompter {
8458
8556
  constructor(interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
@@ -8471,7 +8569,11 @@ class Prompter {
8471
8569
  question(text) {
8472
8570
  return new Promise(resolve => this.io.question(text, answer => resolve(answer)));
8473
8571
  }
8474
- /** Print a line to stdout. Kept on the prompter so command code never touches `process.stdout`. */
8572
+ /** Raw write, no newline for cursor control during a live render. */
8573
+ out(s) {
8574
+ process.stdout.write(s);
8575
+ }
8576
+ /** Print a line. Kept on the prompter so command code never touches `process.stdout`. */
8475
8577
  write(line = "") {
8476
8578
  process.stdout.write(`${line}\n`);
8477
8579
  }
@@ -8498,11 +8600,129 @@ class Prompter {
8498
8600
  });
8499
8601
  return Number(answer);
8500
8602
  }
8501
- /** Pick one. Answers are 1-based indices; blank takes the default (the first choice unless given). */
8603
+ /** Pick one arrows to move, Enter to choose. */
8502
8604
  async select(label, choices, defaultIndex = 0) {
8503
- var _a, _b;
8605
+ var _a, _b, _c;
8504
8606
  if (!this.interactive || choices.length === 1)
8505
8607
  return (_b = (_a = choices[defaultIndex]) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : choices[0].value;
8608
+ if (rawCapable()) {
8609
+ const picked = await this.runList(label, choices, {
8610
+ multi: false,
8611
+ cursor: defaultIndex,
8612
+ selected: new Set(),
8613
+ });
8614
+ return choices[(_c = picked[0]) !== null && _c !== void 0 ? _c : defaultIndex].value;
8615
+ }
8616
+ return this.selectByNumber(label, choices, defaultIndex);
8617
+ }
8618
+ /** Pick any — arrows to move, space to toggle, `a` for all/none, Enter to confirm. */
8619
+ async multiselect(label, choices, preselected) {
8620
+ var _a;
8621
+ if (!this.interactive)
8622
+ return preselected.map(i => choices[i].value);
8623
+ if (rawCapable()) {
8624
+ const picked = await this.runList(label, choices, {
8625
+ multi: true,
8626
+ cursor: (_a = preselected[0]) !== null && _a !== void 0 ? _a : 0,
8627
+ selected: new Set(preselected),
8628
+ });
8629
+ return picked.map(i => choices[i].value);
8630
+ }
8631
+ return this.multiselectByNumber(label, choices, preselected);
8632
+ }
8633
+ /**
8634
+ * The raw-mode list loop. Renders in place: each keypress rewinds over the block just drawn and
8635
+ * repaints it, so the list stays put instead of scrolling the terminal away.
8636
+ *
8637
+ * Any readline interface is closed first — it would otherwise swallow the keystrokes we need.
8638
+ * `cleanup` always restores the terminal (raw mode off, cursor shown), including on cancel, so a
8639
+ * Ctrl-C can't leave the shell in a state where typing is invisible.
8640
+ */
8641
+ runList(label, choices, init) {
8642
+ this.close(); // readline and raw mode can't both own stdin
8643
+ const stdin = process.stdin;
8644
+ const { multi } = init;
8645
+ let state = {
8646
+ cursor: Math.max(0, Math.min(init.cursor, choices.length - 1)),
8647
+ selected: init.selected,
8648
+ done: false,
8649
+ cancelled: false,
8650
+ };
8651
+ let painted = 0;
8652
+ const help = multi ? "↑↓ move · space toggle · a all · enter confirm" : "↑↓ move · enter select";
8653
+ const frame = () => {
8654
+ const rows = choices.map((c, i) => {
8655
+ const here = i === state.cursor;
8656
+ const mark = multi ? (state.selected.has(i) ? green("◉") : dim("◯")) : here ? green("❯") : " ";
8657
+ const name = here ? bold(c.label) : c.label;
8658
+ const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";
8659
+ return ` ${mark} ${name}${hint}`;
8660
+ });
8661
+ return [`${cyan("?")} ${bold(label)} ${dim(help)}`, ...rows].join("\n");
8662
+ };
8663
+ const paintFrame = () => {
8664
+ if (painted)
8665
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8666
+ const text = frame();
8667
+ this.out(`${text}\n`);
8668
+ painted = text.split("\n").length;
8669
+ };
8670
+ return new Promise((resolve, reject) => {
8671
+ const cleanup = () => {
8672
+ stdin.removeListener("data", onData);
8673
+ stdin.removeListener("end", onEnd);
8674
+ if (stdin.isTTY)
8675
+ stdin.setRawMode(false);
8676
+ stdin.pause();
8677
+ this.out(SHOW_CURSOR);
8678
+ };
8679
+ /**
8680
+ * stdin closed while we were waiting — a piped run, a closed terminal, a killed parent. There
8681
+ * is no further input coming, so commit what's on screen instead of blocking forever.
8682
+ */
8683
+ const onEnd = () => {
8684
+ const chosen = multi ? [...state.selected].sort((a, b) => a - b) : [state.cursor];
8685
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8686
+ cleanup();
8687
+ resolve(chosen);
8688
+ };
8689
+ const onData = (chunk) => {
8690
+ // One event can carry several keys (a held-down arrow); apply them all, then paint once.
8691
+ for (const key of decodeKeys(chunk)) {
8692
+ state = applyKey(state, key, choices.length, multi);
8693
+ if (state.done || state.cancelled)
8694
+ break;
8695
+ }
8696
+ if (state.cancelled) {
8697
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8698
+ cleanup();
8699
+ reject(new Error("Cancelled."));
8700
+ return;
8701
+ }
8702
+ if (state.done) {
8703
+ const chosen = multi ? [...state.selected].sort((a, b) => a - b) : [state.cursor];
8704
+ // Replace the live block with a one-line record of the answer, so a finished interview
8705
+ // reads back as a transcript rather than a wall of spent menus.
8706
+ this.out(cursorUp(painted) + CLEAR_DOWN);
8707
+ const summary = chosen.length ? chosen.map(i => choices[i].label).join(", ") : "none";
8708
+ this.write(`${cyan("?")} ${bold(label)} ${dim("›")} ${green(summary)}`);
8709
+ cleanup();
8710
+ resolve(chosen);
8711
+ return;
8712
+ }
8713
+ paintFrame();
8714
+ };
8715
+ stdin.setRawMode(true);
8716
+ stdin.resume();
8717
+ stdin.setEncoding("utf8");
8718
+ this.out(HIDE_CURSOR);
8719
+ stdin.on("data", onData);
8720
+ stdin.on("end", onEnd);
8721
+ paintFrame();
8722
+ });
8723
+ }
8724
+ /** Line-mode fallback: pick one by number. */
8725
+ async selectByNumber(label, choices, defaultIndex) {
8506
8726
  this.write(`${cyan("?")} ${bold(label)}`);
8507
8727
  choices.forEach((c, i) => {
8508
8728
  const marker = i === defaultIndex ? green("❯") : " ";
@@ -8519,14 +8739,9 @@ class Prompter {
8519
8739
  this.write(` ${yellow("!")} Enter a number between 1 and ${choices.length}.`);
8520
8740
  }
8521
8741
  }
8522
- /**
8523
- * Pick any. Answers are comma-separated indices; blank keeps the pre-selected set, and an explicit
8524
- * `none` clears it — otherwise there'd be no way to deselect everything with a line-based prompt.
8525
- */
8526
- async multiselect(label, choices, preselected) {
8527
- if (!this.interactive)
8528
- return preselected.map(i => choices[i].value);
8529
- this.write(`${cyan("?")} ${bold(label)} ${dim("(comma-separated, blank = keep, \"none\" = clear)")}`);
8742
+ /** Line-mode fallback: pick any by comma-separated numbers. */
8743
+ async multiselectByNumber(label, choices, preselected) {
8744
+ this.write(`${cyan("?")} ${bold(label)} ${dim('(comma-separated, blank = keep, "none" = clear)')}`);
8530
8745
  choices.forEach((c, i) => {
8531
8746
  const on = preselected.includes(i);
8532
8747
  const hint = c.hint ? ` ${dim(`— ${c.hint}`)}` : "";