@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 +71 -0
- package/bin/ml-eras-local-runner.js +6 -0
- package/package.json +28 -0
- package/scripts/prepack.mjs +54 -0
- package/src/args.js +64 -0
- package/src/cli.js +99 -0
- package/src/log.js +16 -0
- package/src/python/bootstrap.js +127 -0
- package/src/python/run.js +50 -0
- package/src/syncToken.js +37 -0
- package/src/ttyPrompt.js +50 -0
- package/vendor/python/app/__init__.py +0 -0
- package/vendor/python/app/browser/__init__.py +0 -0
- package/vendor/python/app/browser/session.py +267 -0
- package/vendor/python/app/local_runner_cli.py +112 -0
- package/vendor/python/app/local_sync_runner.py +99 -0
- package/vendor/python/app/services/__init__.py +0 -0
- package/vendor/python/app/services/scrape_manifest.py +774 -0
- package/vendor/python/app/services/viewer.py +58 -0
- package/vendor/python/requirements.txt +3 -0
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.
|
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
|
+
}
|
package/src/syncToken.js
ADDED
|
@@ -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
|
+
}
|
package/src/ttyPrompt.js
ADDED
|
@@ -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
|