@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/build/prompt.d.ts +46 -8
- package/dist/build.cjs.js +1 -1
- package/dist/build.cjs.js.map +1 -1
- package/dist/build.esm.js +1 -1
- package/dist/build.esm.js.map +1 -1
- package/dist/cli.js +236 -21
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8430,16 +8430,22 @@ function runCreate(options) {
|
|
|
8430
8430
|
}
|
|
8431
8431
|
|
|
8432
8432
|
/**
|
|
8433
|
-
*
|
|
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
|
|
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
|
-
*
|
|
8442
|
-
*
|
|
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
|
-
*
|
|
8454
|
-
*
|
|
8455
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
8524
|
-
|
|
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}`)}` : "";
|