@music-league-eras/local-runner 0.1.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/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # Music League Eras Local Runner (npx)
2
+
3
+ This package runs the Music League scraping flow **locally** (opens a real browser on your machine), then uploads the
4
+ extracted `manifest.json` to the Music League Eras API using your short-lived `sync_token`.
5
+
6
+ ## Prerequisites (macOS MVP)
7
+
8
+ - Node.js `>=18.17`
9
+ - Python `>=3.11` available as `python3`
10
+
11
+ On first run, Playwright may download Chromium (network required).
12
+
13
+ ## Usage
14
+
15
+ Run the commands shown in the web app (recommended), or run manually:
16
+
17
+ ### Step A (bootstrap, no secrets)
18
+
19
+ ```bash
20
+ npx @music-league-eras/local-runner@latest bootstrap
21
+ ```
22
+
23
+ ### Step B (runner, safe default)
24
+
25
+ ```bash
26
+ npx @music-league-eras/local-runner@latest local-sync \\
27
+ --api-base-url https://<deployment-host> \\
28
+ --sync-session-id <sync_session_id>
29
+ ```
30
+
31
+ The runner will prompt you to paste the sync token (input hidden).
32
+
33
+ ### Step B (power users / CI)
34
+
35
+ Option A: environment variable (recommended for CI)
36
+
37
+ ```bash
38
+ export ML_ERAS_SYNC_TOKEN='<sync_token>'
39
+ npx @music-league-eras/local-runner@latest local-sync \\
40
+ --api-base-url https://<deployment-host> \\
41
+ --sync-session-id <sync_session_id>
42
+ ```
43
+
44
+ Option B: CLI flag (advanced; not recommended)
45
+
46
+ ```bash
47
+ npx @music-league-eras/local-runner@latest local-sync \\
48
+ --api-base-url https://<deployment-host> \\
49
+ --sync-session-id <sync_session_id> \\
50
+ --sync-token <sync_token>
51
+ ```
52
+
53
+ Optional flags:
54
+
55
+ - `--music-league-base-url https://app.musicleague.com`
56
+ - `--capture-headless` / `--no-capture-headless`
57
+ - `--scrape-headless` / `--no-scrape-headless`
58
+ - `--out-dir /path/to/artifacts`
59
+ - `--timeout-s 60`
60
+
61
+ ## Runner home directory
62
+
63
+ By default, the runner stores its Python virtualenv under:
64
+
65
+ - `~/.music-league-eras/local-runner`
66
+
67
+ Override with:
68
+
69
+ - `ML_ERAS_LOCAL_RUNNER_HOME=/some/dir`
70
+
71
+ To reset the runner environment, delete that folder.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/cli.js";
3
+
4
+ const exitCode = await main(process.argv.slice(2));
5
+ process.exit(exitCode);
6
+
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@music-league-eras/local-runner",
3
+ "version": "0.1.0",
4
+ "description": "Music League Eras local runner (npx wrapper around the Python scraper runner).",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "engines": {
8
+ "node": ">=18.17.0"
9
+ },
10
+ "bin": {
11
+ "ml-eras-local-runner": "./bin/ml-eras-local-runner.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "src/",
16
+ "scripts/",
17
+ "vendor/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "prepack": "node ./scripts/prepack.mjs",
22
+ "test": "node --test"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }
28
+
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ function ensureDir(dir) {
6
+ fs.mkdirSync(dir, { recursive: true });
7
+ }
8
+
9
+ function copyFile(src, dst) {
10
+ ensureDir(path.dirname(dst));
11
+ fs.copyFileSync(src, dst);
12
+ }
13
+
14
+ function assertExists(filePath) {
15
+ if (!fs.existsSync(filePath)) {
16
+ throw new Error(`Missing expected file: ${filePath}`);
17
+ }
18
+ }
19
+
20
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
21
+ const pkg = path.resolve(packageRoot, "..");
22
+ const repoRoot = path.resolve(pkg, "..", "..");
23
+
24
+ const destRoot = path.join(pkg, "vendor", "python", "app");
25
+ fs.rmSync(destRoot, { recursive: true, force: true });
26
+ ensureDir(destRoot);
27
+
28
+ const files = [
29
+ // package markers
30
+ { src: "services/scraper/app/__init__.py", dst: "vendor/python/app/__init__.py" },
31
+ { src: "services/scraper/app/browser/__init__.py", dst: "vendor/python/app/browser/__init__.py" },
32
+ { src: "services/scraper/app/services/__init__.py", dst: "vendor/python/app/services/__init__.py" },
33
+
34
+ // entrypoint + runner
35
+ { src: "services/scraper/app/local_runner_cli.py", dst: "vendor/python/app/local_runner_cli.py" },
36
+ { src: "services/scraper/app/local_sync_runner.py", dst: "vendor/python/app/local_sync_runner.py" },
37
+
38
+ // browser capture
39
+ { src: "services/scraper/app/browser/session.py", dst: "vendor/python/app/browser/session.py" },
40
+
41
+ // scraping
42
+ { src: "services/scraper/app/services/scrape_manifest.py", dst: "vendor/python/app/services/scrape_manifest.py" },
43
+ { src: "services/scraper/app/services/viewer.py", dst: "vendor/python/app/services/viewer.py" }
44
+ ];
45
+
46
+ for (const file of files) {
47
+ const srcAbs = path.join(repoRoot, file.src);
48
+ const dstAbs = path.join(pkg, file.dst);
49
+ assertExists(srcAbs);
50
+ copyFile(srcAbs, dstAbs);
51
+ }
52
+
53
+ console.log(`Vendored ${files.length} Python files into ${path.relative(repoRoot, path.join(pkg, "vendor", "python"))}`);
54
+
package/src/args.js ADDED
@@ -0,0 +1,64 @@
1
+ const BOOLEAN_FLAGS = new Set(["--capture-headless", "--no-capture-headless", "--scrape-headless", "--no-scrape-headless"]);
2
+
3
+ export function parseArgs(argv) {
4
+ const args = Array.isArray(argv) ? argv : [];
5
+ const nonFlags = args.filter((a) => typeof a === "string" && !a.startsWith("-"));
6
+ const firstNonFlag = nonFlags[0];
7
+
8
+ const command = firstNonFlag === "bootstrap" ? "bootstrap" : "local-sync";
9
+ const tokens = firstNonFlag === "bootstrap" || firstNonFlag === "local-sync" ? args.slice(1) : args.slice(0);
10
+
11
+ if (tokens.includes("--help") || tokens.includes("-h")) {
12
+ return { command, help: true, error: null, flags: {} };
13
+ }
14
+
15
+ if (command === "bootstrap") {
16
+ if (tokens.length) {
17
+ return { command, help: false, error: `Unexpected argument: ${tokens[0]}`, flags: {} };
18
+ }
19
+ return { command, help: false, error: null, flags: {} };
20
+ }
21
+
22
+ const flags = {};
23
+ for (let i = 0; i < tokens.length; i += 1) {
24
+ const token = tokens[i];
25
+ if (!token || typeof token !== "string") continue;
26
+ if (!token.startsWith("--")) {
27
+ return { command, help: false, error: `Unexpected argument: ${token}`, flags: {} };
28
+ }
29
+ if (BOOLEAN_FLAGS.has(token)) {
30
+ if (token === "--capture-headless") flags.captureHeadless = true;
31
+ if (token === "--no-capture-headless") flags.captureHeadless = false;
32
+ if (token === "--scrape-headless") flags.scrapeHeadless = true;
33
+ if (token === "--no-scrape-headless") flags.scrapeHeadless = false;
34
+ continue;
35
+ }
36
+ const value = tokens[i + 1];
37
+ if (!value || typeof value !== "string" || value.startsWith("--")) {
38
+ return { command, help: false, error: `Missing value for ${token}`, flags: {} };
39
+ }
40
+ i += 1;
41
+ if (token === "--api-base-url") flags.apiBaseUrl = value;
42
+ else if (token === "--sync-session-id") flags.syncSessionId = value;
43
+ else if (token === "--sync-token") flags.syncToken = value;
44
+ else if (token === "--music-league-base-url") flags.musicLeagueBaseUrl = value;
45
+ else if (token === "--out-dir") flags.outDir = value;
46
+ else if (token === "--timeout-s") flags.timeoutS = value;
47
+ else return { command, help: false, error: `Unknown flag: ${token}`, flags: {} };
48
+ }
49
+
50
+ const missing = [];
51
+ if (!flags.apiBaseUrl) missing.push("--api-base-url");
52
+ if (!flags.syncSessionId) missing.push("--sync-session-id");
53
+ if (missing.length) {
54
+ return { command, help: false, error: `Missing required flags: ${missing.join(", ")}`, flags: {} };
55
+ }
56
+
57
+ const timeoutS = flags.timeoutS ? Number(flags.timeoutS) : undefined;
58
+ if (flags.timeoutS && (!Number.isFinite(timeoutS) || timeoutS <= 0)) {
59
+ return { command, help: false, error: `Invalid --timeout-s: ${flags.timeoutS}`, flags: {} };
60
+ }
61
+ if (timeoutS) flags.timeoutS = timeoutS;
62
+
63
+ return { command, help: false, error: null, flags };
64
+ }
package/src/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { parseArgs } from "./args.js";
5
+ import { createLogger, redactSecret } from "./log.js";
6
+ import { bootstrapPythonRunner, resolveRunnerHome } from "./python/bootstrap.js";
7
+ import { runPythonLocalSync } from "./python/run.js";
8
+ import { resolveSyncToken } from "./syncToken.js";
9
+
10
+ function usageBootstrap() {
11
+ return [
12
+ "Usage:",
13
+ " ml-eras-local-runner bootstrap",
14
+ "",
15
+ "Example:",
16
+ " npx @music-league-eras/local-runner@latest bootstrap"
17
+ ].join("\n");
18
+ }
19
+
20
+ function usageLocalSync() {
21
+ return [
22
+ "Usage:",
23
+ " ml-eras-local-runner local-sync --api-base-url <url> --sync-session-id <id>",
24
+ "",
25
+ "Example:",
26
+ " npx @music-league-eras/local-runner@latest local-sync \\",
27
+ " --api-base-url https://<deployment-host> \\",
28
+ " --sync-session-id <sync_session_id>",
29
+ "",
30
+ "Token input:",
31
+ " - default: prompt (safe, input hidden)",
32
+ " - or: export ML_ERAS_SYNC_TOKEN=... (advanced/CI)",
33
+ " - or: --sync-token ... (advanced/CI; not recommended)"
34
+ ].join("\n");
35
+ }
36
+
37
+ export async function main(argv, { stdout, stderr } = {}) {
38
+ const log = createLogger({ stdout, stderr });
39
+ const parsed = parseArgs(argv);
40
+ if (parsed.help) {
41
+ if (parsed.command === "bootstrap") log.info(usageBootstrap());
42
+ else log.info(usageLocalSync());
43
+ return 0;
44
+ }
45
+ if (parsed.error) {
46
+ log.error(parsed.error);
47
+ log.error("");
48
+ if (parsed.command === "bootstrap") log.error(usageBootstrap());
49
+ else log.error(usageLocalSync());
50
+ return 1;
51
+ }
52
+
53
+ if (parsed.command === "bootstrap") {
54
+ log.info("Bootstrapping local runner (Python + Playwright)...");
55
+ log.info(`Runner home: ${resolveRunnerHome()}`);
56
+
57
+ const packageRoot = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
58
+ const runnerHome = resolveRunnerHome();
59
+ const requirementsPath = path.join(packageRoot, "vendor", "python", "requirements.txt");
60
+
61
+ const boot = bootstrapPythonRunner({ packageRoot, runnerHome, requirementsPath, log });
62
+ if (!boot.ok) return boot.exitCode;
63
+
64
+ log.info("");
65
+ log.info("✅ Prereqs installed. Return to the web app and run Step B (local-sync).");
66
+ return 0;
67
+ }
68
+
69
+ const flags = parsed.flags;
70
+ const tokenResult = await resolveSyncToken({
71
+ flagToken: flags.syncToken,
72
+ env: process.env,
73
+ stdin: process.stdin,
74
+ stdout: stdout ?? process.stdout,
75
+ stderr: stderr ?? process.stderr
76
+ });
77
+ if (!tokenResult.ok) {
78
+ log.error(tokenResult.error);
79
+ return 1;
80
+ }
81
+ flags.syncToken = tokenResult.token;
82
+
83
+ log.info("Bootstrapping local runner (Python + Playwright)...");
84
+ log.info(`Runner home: ${resolveRunnerHome()}`);
85
+ log.info(
86
+ `Sync session: ${flags.syncSessionId}, token: ${redactSecret(flags.syncToken)} (not printed), API: ${flags.apiBaseUrl}`
87
+ );
88
+
89
+ const packageRoot = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
90
+ const runnerHome = resolveRunnerHome();
91
+ const requirementsPath = path.join(packageRoot, "vendor", "python", "requirements.txt");
92
+
93
+ const boot = bootstrapPythonRunner({ packageRoot, runnerHome, requirementsPath, log });
94
+ if (!boot.ok) return boot.exitCode;
95
+
96
+ log.info("Starting local sync (browser will open)...");
97
+ const code = runPythonLocalSync({ venvPython: boot.venvPython, pythonPath: boot.pythonPath, flags });
98
+ return code;
99
+ }
package/src/log.js ADDED
@@ -0,0 +1,16 @@
1
+ export function createLogger({ stdout = process.stdout, stderr = process.stderr } = {}) {
2
+ return {
3
+ info(message) {
4
+ stdout.write(`${message}\n`);
5
+ },
6
+ error(message) {
7
+ stderr.write(`${message}\n`);
8
+ }
9
+ };
10
+ }
11
+
12
+ export function redactSecret(value) {
13
+ if (!value) return "<redacted>";
14
+ return "<redacted>";
15
+ }
16
+
@@ -0,0 +1,127 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { spawnSync as nodeSpawnSync } from "node:child_process";
5
+
6
+ function _defaultRunnerHome() {
7
+ return path.join(os.homedir(), ".music-league-eras", "local-runner");
8
+ }
9
+
10
+ export function resolveRunnerHome(env = process.env) {
11
+ const override = env.ML_ERAS_LOCAL_RUNNER_HOME;
12
+ return override && override.trim() ? override.trim() : _defaultRunnerHome();
13
+ }
14
+
15
+ export function resolveVenvPython(runnerHome, platform = process.platform) {
16
+ if (platform === "win32") return path.win32.join(runnerHome, "venv", "Scripts", "python.exe");
17
+ return path.join(runnerHome, "venv", "bin", "python");
18
+ }
19
+
20
+ function _spawn(spawnSync, file, args, opts) {
21
+ const result = spawnSync(file, args, opts);
22
+ if (result.error) throw result.error;
23
+ return result;
24
+ }
25
+
26
+ function _pythonCandidates(platform = process.platform) {
27
+ if (platform === "win32") {
28
+ // Prefer the official Python Launcher (`py -3`) if present.
29
+ return [
30
+ { file: "py", prefixArgs: ["-3"] },
31
+ { file: "python", prefixArgs: [] },
32
+ { file: "python3", prefixArgs: [] }
33
+ ];
34
+ }
35
+ return [{ file: "python3", prefixArgs: [] }, { file: "python", prefixArgs: [] }];
36
+ }
37
+
38
+ function _pythonNotFoundMessage(platform = process.platform) {
39
+ if (platform === "win32") {
40
+ return [
41
+ "python3 not found.",
42
+ "Install Python 3.11+ (recommended: `winget install Python.Python.3.11`),",
43
+ "then re-run: `npx @music-league-eras/local-runner@latest bootstrap`."
44
+ ].join(" ");
45
+ }
46
+ return "python3 not found. Install Python 3.11+ (e.g. `brew install python@3.11`).";
47
+ }
48
+
49
+ export function ensurePythonAvailable({ spawnSync = nodeSpawnSync, platform = process.platform } = {}) {
50
+ const versionSnippet = "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')";
51
+ const candidates = _pythonCandidates(platform);
52
+ let chosen = null;
53
+ let versionText = null;
54
+
55
+ for (const c of candidates) {
56
+ const result = _spawn(spawnSync, c.file, [...c.prefixArgs, "-c", versionSnippet], { encoding: "utf-8" });
57
+ if (result.status === 0) {
58
+ chosen = c;
59
+ versionText = String(result.stdout || "").trim();
60
+ break;
61
+ }
62
+ }
63
+
64
+ if (!chosen) {
65
+ return { ok: false, error: _pythonNotFoundMessage(platform) };
66
+ }
67
+ const text = String(versionText || "").trim();
68
+ const parts = text.split(".").map((p) => Number(p));
69
+ if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) {
70
+ return { ok: false, error: `Unable to parse python3 version: ${text}` };
71
+ }
72
+ const [major, minor] = parts;
73
+ if (major < 3 || (major === 3 && minor < 11)) {
74
+ return { ok: false, error: `python3 >= 3.11 required (found ${text}).` };
75
+ }
76
+ return { ok: true, version: text, python: chosen };
77
+ }
78
+
79
+ export function bootstrapPythonRunner({
80
+ packageRoot,
81
+ runnerHome,
82
+ requirementsPath,
83
+ spawnSync = nodeSpawnSync,
84
+ log,
85
+ platform = process.platform,
86
+ existsSync = fs.existsSync
87
+ }) {
88
+ const pythonCheck = ensurePythonAvailable({ spawnSync, platform });
89
+ if (!pythonCheck.ok) {
90
+ log.error(pythonCheck.error);
91
+ return { ok: false, exitCode: 2 };
92
+ }
93
+ log.info(`Using python3 ${pythonCheck.version}`);
94
+
95
+ const venvDir = platform === "win32" ? path.win32.join(runnerHome, "venv") : path.join(runnerHome, "venv");
96
+ const venvPython = resolveVenvPython(runnerHome, platform);
97
+
98
+ // Create venv if missing.
99
+ if (!existsSync(venvPython)) {
100
+ log.info(`Creating venv at ${venvDir}`);
101
+ const created = _spawn(spawnSync, pythonCheck.python.file, [...pythonCheck.python.prefixArgs, "-m", "venv", venvDir], {
102
+ stdio: "inherit"
103
+ });
104
+ if (created.status !== 0) return { ok: false, exitCode: created.status ?? 1 };
105
+ }
106
+
107
+ log.info("Installing Python dependencies (pip)");
108
+ const pipUpgrade = _spawn(spawnSync, venvPython, ["-m", "pip", "install", "--upgrade", "pip"], {
109
+ stdio: "inherit"
110
+ });
111
+ if (pipUpgrade.status !== 0) return { ok: false, exitCode: pipUpgrade.status ?? 1 };
112
+
113
+ const pipInstall = _spawn(spawnSync, venvPython, ["-m", "pip", "install", "-r", requirementsPath], {
114
+ stdio: "inherit"
115
+ });
116
+ if (pipInstall.status !== 0) return { ok: false, exitCode: pipInstall.status ?? 1 };
117
+
118
+ log.info("Ensuring Playwright Chromium is installed");
119
+ const pwInstall = _spawn(spawnSync, venvPython, ["-m", "playwright", "install", "chromium"], { stdio: "inherit" });
120
+ if (pwInstall.status !== 0) return { ok: false, exitCode: pwInstall.status ?? 1 };
121
+
122
+ return {
123
+ ok: true,
124
+ venvPython,
125
+ pythonPath: path.join(packageRoot, "vendor", "python")
126
+ };
127
+ }
@@ -0,0 +1,50 @@
1
+ import path from "node:path";
2
+ import { spawnSync as nodeSpawnSync } from "node:child_process";
3
+
4
+ function _spawn(spawnSync, file, args, opts) {
5
+ const result = spawnSync(file, args, opts);
6
+ if (result.error) throw result.error;
7
+ return result;
8
+ }
9
+
10
+ export function runPythonLocalSync({
11
+ venvPython,
12
+ pythonPath,
13
+ flags,
14
+ spawnSync = nodeSpawnSync
15
+ }) {
16
+ const args = [
17
+ "-m",
18
+ "app.local_runner_cli",
19
+ "local-sync",
20
+ "--api-base-url",
21
+ flags.apiBaseUrl,
22
+ "--sync-session-id",
23
+ flags.syncSessionId
24
+ ];
25
+ if (flags.musicLeagueBaseUrl) {
26
+ args.push("--music-league-base-url", flags.musicLeagueBaseUrl);
27
+ }
28
+ if (typeof flags.captureHeadless === "boolean") {
29
+ args.push(flags.captureHeadless ? "--capture-headless" : "--no-capture-headless");
30
+ }
31
+ if (typeof flags.scrapeHeadless === "boolean") {
32
+ args.push(flags.scrapeHeadless ? "--scrape-headless" : "--no-scrape-headless");
33
+ }
34
+ if (flags.outDir) {
35
+ args.push("--out-dir", flags.outDir);
36
+ }
37
+ if (flags.timeoutS) {
38
+ args.push("--timeout-s", String(flags.timeoutS));
39
+ }
40
+
41
+ const env = { ...process.env };
42
+ if (flags.syncToken) {
43
+ env.ML_ERAS_SYNC_TOKEN = flags.syncToken;
44
+ }
45
+ const existing = env.PYTHONPATH ? String(env.PYTHONPATH) : "";
46
+ env.PYTHONPATH = existing ? `${pythonPath}${path.delimiter}${existing}` : pythonPath;
47
+
48
+ const result = _spawn(spawnSync, venvPython, args, { stdio: "inherit", env });
49
+ return typeof result.status === "number" ? result.status : 1;
50
+ }
@@ -0,0 +1,37 @@
1
+ import { promptSecret } from "./ttyPrompt.js";
2
+
3
+ function _trimOrNull(value) {
4
+ if (typeof value !== "string") return null;
5
+ const trimmed = value.trim();
6
+ return trimmed ? trimmed : null;
7
+ }
8
+
9
+ export async function resolveSyncToken({
10
+ flagToken,
11
+ env,
12
+ stdin,
13
+ stdout,
14
+ stderr,
15
+ promptSecretFn = promptSecret
16
+ }) {
17
+ const fromFlag = _trimOrNull(flagToken);
18
+ if (fromFlag) return { ok: true, token: fromFlag, source: "flag" };
19
+
20
+ const fromEnv = _trimOrNull(env?.ML_ERAS_SYNC_TOKEN);
21
+ if (fromEnv) return { ok: true, token: fromEnv, source: "env" };
22
+
23
+ if (!stdin?.isTTY) {
24
+ return {
25
+ ok: false,
26
+ error:
27
+ "Missing sync token. Re-run with ML_ERAS_SYNC_TOKEN set, or (advanced) pass --sync-token."
28
+ };
29
+ }
30
+
31
+ stderr?.write("Paste the sync token from the web app (input hidden), then press Enter:\n");
32
+ const prompted = _trimOrNull(await promptSecretFn("Sync token: ", { stdin, stdout }));
33
+ if (!prompted) {
34
+ return { ok: false, error: "Sync token was empty. Re-run and paste the token from the web app." };
35
+ }
36
+ return { ok: true, token: prompted, source: "prompt" };
37
+ }
@@ -0,0 +1,50 @@
1
+ export async function promptSecret(prompt, { stdin = process.stdin, stdout = process.stdout } = {}) {
2
+ if (!stdin.isTTY) {
3
+ throw new Error("promptSecret requires a TTY stdin.");
4
+ }
5
+
6
+ stdout.write(prompt);
7
+
8
+ stdin.setEncoding("utf8");
9
+ stdin.setRawMode(true);
10
+ stdin.resume();
11
+
12
+ let value = "";
13
+
14
+ return await new Promise((resolve, reject) => {
15
+ function cleanup() {
16
+ try {
17
+ stdin.setRawMode(false);
18
+ } catch {
19
+ // ignore
20
+ }
21
+ stdin.pause();
22
+ stdin.off("data", onData);
23
+ }
24
+
25
+ function onData(chunk) {
26
+ const input = String(chunk);
27
+ for (const char of input) {
28
+ if (char === "\r" || char === "\n") {
29
+ cleanup();
30
+ stdout.write("\n");
31
+ resolve(value);
32
+ return;
33
+ }
34
+ if (char === "\u0003") {
35
+ cleanup();
36
+ reject(new Error("Cancelled."));
37
+ return;
38
+ }
39
+ if (char === "\u007f" || char === "\b") {
40
+ value = value.slice(0, -1);
41
+ continue;
42
+ }
43
+ value += char;
44
+ }
45
+ }
46
+
47
+ stdin.on("data", onData);
48
+ });
49
+ }
50
+
File without changes
File without changes