@scm-js/cli 0.8.8 → 0.8.9
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/THIRD-PARTY-NOTICES.txt +1 -1
- package/dist/scmjs.mjs +828 -12
- package/package.json +6 -3
package/dist/scmjs.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import path, { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
2
3
|
import { EventEmitter } from "node:events";
|
|
3
|
-
import childProcess, { execFileSync, spawn } from "node:child_process";
|
|
4
|
-
import
|
|
5
|
-
import fs, { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import childProcess, { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import fs, { existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
|
|
6
6
|
import process$1 from "node:process";
|
|
7
7
|
import { stripVTControlCharacters } from "node:util";
|
|
8
8
|
import { createServer } from "node:http";
|
|
@@ -6081,6 +6081,8 @@ var catalogues = {
|
|
|
6081
6081
|
"Closed {name} — {next} is in front.": "{name}을(를) 닫음 — {next}이(가) 앞에 있습니다.",
|
|
6082
6082
|
"Closed the scenario — File ▸ New or Open to continue.": "시나리오를 닫음 — 파일 ▸ 새로 만들기 또는 열기로 계속하세요.",
|
|
6083
6083
|
"A copy of {name} to edit — Save asks where to keep it.": "편집할 {name}의 사본 — 저장할 때 위치를 묻습니다.",
|
|
6084
|
+
"Reloaded {name}": "{name}{name|을} 다시 불러왔습니다",
|
|
6085
|
+
"{name} did not build": "{name}{name|을} 빌드하지 못했습니다",
|
|
6084
6086
|
"The link uses the {name} plugin, which is turned off.": "이 링크는 꺼져 있는 {name} 플러그인을 사용합니다.",
|
|
6085
6087
|
"Turn It On": "켜기",
|
|
6086
6088
|
"Preparing workspace": "작업 공간 준비 중",
|
|
@@ -6597,6 +6599,79 @@ var CliError = class extends Error {
|
|
|
6597
6599
|
this.exitCode = exitCode;
|
|
6598
6600
|
}
|
|
6599
6601
|
};
|
|
6602
|
+
var BOT_GUIDE = "https://github.com/scm-js/scmbot";
|
|
6603
|
+
/** Docker's version, or null when there is no Docker to run (not installed, or its engine not started). */
|
|
6604
|
+
function dockerVersion() {
|
|
6605
|
+
const r = spawnSync("docker", [
|
|
6606
|
+
"version",
|
|
6607
|
+
"--format",
|
|
6608
|
+
"{{.Server.Version}}"
|
|
6609
|
+
], {
|
|
6610
|
+
encoding: "utf8",
|
|
6611
|
+
timeout: 1e4
|
|
6612
|
+
});
|
|
6613
|
+
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
|
|
6614
|
+
}
|
|
6615
|
+
/**
|
|
6616
|
+
* `docker run`'s arguments: the folder mounted at /bot, its name given for `new` (the toolchain sees only /bot), this
|
|
6617
|
+
* command's name for the toolchain's messages, and on Linux and macOS the files written as the person running this
|
|
6618
|
+
* rather than as root.
|
|
6619
|
+
*/
|
|
6620
|
+
function dockerArgs(o) {
|
|
6621
|
+
return [
|
|
6622
|
+
"run",
|
|
6623
|
+
"--rm",
|
|
6624
|
+
...o.tty ? ["-it"] : ["-i"],
|
|
6625
|
+
"-v",
|
|
6626
|
+
`${o.folder}:/bot`,
|
|
6627
|
+
"-e",
|
|
6628
|
+
`SCMBOT_FOLDER=${basename(o.folder)}`,
|
|
6629
|
+
"-e",
|
|
6630
|
+
"SCMBOT_COMMAND=scmjs bot",
|
|
6631
|
+
...o.user ? ["-u", o.user] : [],
|
|
6632
|
+
o.image,
|
|
6633
|
+
...o.args
|
|
6634
|
+
];
|
|
6635
|
+
}
|
|
6636
|
+
var hasImage = (image) => spawnSync("docker", [
|
|
6637
|
+
"image",
|
|
6638
|
+
"inspect",
|
|
6639
|
+
image
|
|
6640
|
+
], { stdio: "ignore" }).status === 0;
|
|
6641
|
+
function needDocker() {
|
|
6642
|
+
if (dockerVersion()) return;
|
|
6643
|
+
const installed = spawnSync("docker", ["--version"], { stdio: "ignore" }).status === 0;
|
|
6644
|
+
throw new CliError(installed ? "Docker is installed but not running" : "scmjs bot needs Docker", installed ? "Start Docker (Docker Desktop on Windows and macOS), then run this again." : "Install it from https://docs.docker.com/get-docker/, then run this again.");
|
|
6645
|
+
}
|
|
6646
|
+
/** Runs the toolchain with `args` in this folder; the toolchain's own output and exit code are the command's. */
|
|
6647
|
+
async function runBot(args, image = process.env.SCMBOT_IMAGE || "ghcr.io/scm-js/scmbot") {
|
|
6648
|
+
needDocker();
|
|
6649
|
+
if (!hasImage(image)) print(import_picocolors.default.dim(`Downloading the bot toolchain (${image}), once. It is a few GB.`));
|
|
6650
|
+
const user = process.platform === "win32" || !process.getuid ? null : `${process.getuid()}:${process.getgid?.()}`;
|
|
6651
|
+
const child = spawn("docker", dockerArgs({
|
|
6652
|
+
folder: process.cwd(),
|
|
6653
|
+
args,
|
|
6654
|
+
image,
|
|
6655
|
+
tty: !!process.stdout.isTTY && !!process.stdin.isTTY,
|
|
6656
|
+
user
|
|
6657
|
+
}), { stdio: "inherit" });
|
|
6658
|
+
const code = await new Promise((resolve) => {
|
|
6659
|
+
child.on("error", () => resolve(1));
|
|
6660
|
+
child.on("close", (c) => resolve(c ?? 1));
|
|
6661
|
+
});
|
|
6662
|
+
if (code) process.exitCode = code;
|
|
6663
|
+
}
|
|
6664
|
+
/** `scmjs bot update`: the newest toolchain, the one the play page expects. */
|
|
6665
|
+
async function updateBot(image = process.env.SCMBOT_IMAGE || "ghcr.io/scm-js/scmbot") {
|
|
6666
|
+
needDocker();
|
|
6667
|
+
print(import_picocolors.default.dim(`Fetching ${image}…`));
|
|
6668
|
+
const child = spawn("docker", ["pull", image], { stdio: "inherit" });
|
|
6669
|
+
if (await new Promise((resolve) => {
|
|
6670
|
+
child.on("error", () => resolve(1));
|
|
6671
|
+
child.on("close", (c) => resolve(c ?? 1));
|
|
6672
|
+
})) throw new CliError("The toolchain could not be fetched", `docker pull ${image} said why, above.`);
|
|
6673
|
+
print(`${mark.ok} The bot toolchain is up to date`);
|
|
6674
|
+
}
|
|
6600
6675
|
//#endregion
|
|
6601
6676
|
//#region cli/src/open.ts
|
|
6602
6677
|
/**
|
|
@@ -6781,9 +6856,9 @@ document.getElementById("go").onclick = go;
|
|
|
6781
6856
|
}
|
|
6782
6857
|
//#endregion
|
|
6783
6858
|
//#region cli/src/about.ts
|
|
6784
|
-
/** `scmjs about`: this build, the machine it runs on, the desktop app, and where everything is. */
|
|
6785
|
-
var VERSION = "0.8.
|
|
6786
|
-
var COMMIT = "
|
|
6859
|
+
/** `scmjs about`: this build, who made it, the machine it runs on, the desktop app and Docker, and where everything is. */
|
|
6860
|
+
var VERSION = "0.8.9";
|
|
6861
|
+
var COMMIT = "0469680";
|
|
6787
6862
|
var BUILT = "2026-11-06";
|
|
6788
6863
|
function aboutReport() {
|
|
6789
6864
|
return {
|
|
@@ -6793,6 +6868,7 @@ function aboutReport() {
|
|
|
6793
6868
|
node: process.version,
|
|
6794
6869
|
platform: `${process.platform} ${process.arch}`,
|
|
6795
6870
|
desktopApp: findDesktopApp(),
|
|
6871
|
+
docker: dockerVersion(),
|
|
6796
6872
|
pluginApi: 1,
|
|
6797
6873
|
reads: {
|
|
6798
6874
|
maps: [
|
|
@@ -6800,21 +6876,25 @@ function aboutReport() {
|
|
|
6800
6876
|
".scx",
|
|
6801
6877
|
".chk"
|
|
6802
6878
|
],
|
|
6803
|
-
replays: [".rep"]
|
|
6879
|
+
replays: [".rep"],
|
|
6880
|
+
bots: [".scmbot"]
|
|
6804
6881
|
},
|
|
6805
6882
|
links: {
|
|
6806
6883
|
editor: EDITOR_URL,
|
|
6884
|
+
play: "https://scmjs.dev/play/",
|
|
6807
6885
|
docs: "https://docs.scmjs.dev",
|
|
6886
|
+
bots: BOT_GUIDE,
|
|
6808
6887
|
source: "https://github.com/scm-js/scm-js",
|
|
6809
6888
|
issues: "https://github.com/scm-js/scm-js/issues"
|
|
6810
6889
|
},
|
|
6890
|
+
author: "Jeany",
|
|
6811
6891
|
license: "MIT"
|
|
6812
6892
|
};
|
|
6813
6893
|
}
|
|
6814
6894
|
function printAbout(r) {
|
|
6815
6895
|
return [
|
|
6816
6896
|
`${import_picocolors.default.bold(import_picocolors.default.cyan("scmjs"))} ${import_picocolors.default.bold(r.version)}`,
|
|
6817
|
-
import_picocolors.default.dim("StarCraft: Brood War maps and
|
|
6897
|
+
import_picocolors.default.dim("StarCraft: Brood War maps, replays and bots from the command line, by scmJS"),
|
|
6818
6898
|
"",
|
|
6819
6899
|
...rows([
|
|
6820
6900
|
["Version", [
|
|
@@ -6824,13 +6904,18 @@ function printAbout(r) {
|
|
|
6824
6904
|
].filter(Boolean).join(dot)],
|
|
6825
6905
|
["Node", `${r.node}${dot}${r.platform}`],
|
|
6826
6906
|
["Desktop app", r.desktopApp ?? import_picocolors.default.dim("not found (scmjs … open uses the browser)")],
|
|
6907
|
+
["Docker", r.docker ? `${r.docker}` : import_picocolors.default.dim("not found (scmjs bot needs it)")],
|
|
6827
6908
|
["Plugin API", String(r.pluginApi)],
|
|
6828
|
-
["Reads", `maps ${r.reads.maps.join(" ")}${dot}replays ${r.reads.replays.join(" ")}`],
|
|
6909
|
+
["Reads", `maps ${r.reads.maps.join(" ")}${dot}replays ${r.reads.replays.join(" ")}${dot}bots ${r.reads.bots.join(" ")}`],
|
|
6829
6910
|
null,
|
|
6830
6911
|
["Editor", import_picocolors.default.cyan(r.links.editor)],
|
|
6912
|
+
["Play", import_picocolors.default.cyan(r.links.play)],
|
|
6831
6913
|
["Docs", import_picocolors.default.cyan(r.links.docs)],
|
|
6914
|
+
["Bots", import_picocolors.default.cyan(r.links.bots)],
|
|
6832
6915
|
["Source", import_picocolors.default.cyan(r.links.source)],
|
|
6833
6916
|
["Issues", import_picocolors.default.cyan(r.links.issues)],
|
|
6917
|
+
null,
|
|
6918
|
+
["Made by", import_picocolors.default.bold(r.author)],
|
|
6834
6919
|
["Licence", r.license]
|
|
6835
6920
|
])
|
|
6836
6921
|
];
|
|
@@ -15225,6 +15310,690 @@ function wrap(text, width, most = 4) {
|
|
|
15225
15310
|
return lines.slice(0, most).join("\n") + (lines.length > most ? " …" : "");
|
|
15226
15311
|
}
|
|
15227
15312
|
//#endregion
|
|
15313
|
+
//#region src/plugins/loader.ts
|
|
15314
|
+
var PluginLoadError = class extends Error {
|
|
15315
|
+
constructor(message) {
|
|
15316
|
+
super(message);
|
|
15317
|
+
this.name = "PluginLoadError";
|
|
15318
|
+
}
|
|
15319
|
+
};
|
|
15320
|
+
var ENTRY_EXT = /\.(?:ts|tsx|mts|js|mjs)$/i;
|
|
15321
|
+
function dirOf(url) {
|
|
15322
|
+
return url.slice(0, url.lastIndexOf("/") + 1);
|
|
15323
|
+
}
|
|
15324
|
+
/**
|
|
15325
|
+
* `builtin:name`; `github:owner/repo[@ref][/dir]`; a github.com repository URL (with
|
|
15326
|
+
* an optional `/tree/ref/dir`); or any URL — a `.json` is the manifest, a script file
|
|
15327
|
+
* is the entry, anything else is a directory holding `plugin.json`.
|
|
15328
|
+
*/
|
|
15329
|
+
function parseSpec(spec) {
|
|
15330
|
+
const s = spec.trim();
|
|
15331
|
+
if (s === "") throw new PluginLoadError(t("Enter a plugin location."));
|
|
15332
|
+
const builtin = /^builtin:([\w-]+)$/i.exec(s);
|
|
15333
|
+
if (builtin) return {
|
|
15334
|
+
kind: "builtin",
|
|
15335
|
+
name: builtin[1]
|
|
15336
|
+
};
|
|
15337
|
+
const gh = /^github:([^/@\s]+)\/([^/@\s]+)(?:@([^/\s]+))?(?:\/(.*))?$/i.exec(s);
|
|
15338
|
+
if (gh) return githubSource(gh[1], gh[2], gh[3], gh[4]);
|
|
15339
|
+
const ghUrl = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/tree\/([^/\s]+)(?:\/(.*?))?)?\/?$/i.exec(s);
|
|
15340
|
+
if (ghUrl) return githubSource(ghUrl[1], ghUrl[2], ghUrl[3], ghUrl[4]);
|
|
15341
|
+
if (/^https?:\/\//i.test(s)) {
|
|
15342
|
+
let url;
|
|
15343
|
+
try {
|
|
15344
|
+
url = new URL(s);
|
|
15345
|
+
} catch {
|
|
15346
|
+
throw new PluginLoadError(t("Not a valid URL: {url}", { url: s }));
|
|
15347
|
+
}
|
|
15348
|
+
url.hash = "";
|
|
15349
|
+
const path = url.pathname;
|
|
15350
|
+
if (/\.json$/i.test(path)) return {
|
|
15351
|
+
kind: "remote",
|
|
15352
|
+
manifestUrl: url.href,
|
|
15353
|
+
entryUrl: null,
|
|
15354
|
+
base: dirOf(url.href),
|
|
15355
|
+
display: s
|
|
15356
|
+
};
|
|
15357
|
+
if (ENTRY_EXT.test(path)) return {
|
|
15358
|
+
kind: "remote",
|
|
15359
|
+
manifestUrl: null,
|
|
15360
|
+
entryUrl: url.href,
|
|
15361
|
+
base: dirOf(url.href),
|
|
15362
|
+
display: s
|
|
15363
|
+
};
|
|
15364
|
+
const base = url.href.endsWith("/") ? url.href : `${url.href}/`;
|
|
15365
|
+
return {
|
|
15366
|
+
kind: "remote",
|
|
15367
|
+
manifestUrl: `${base}plugin.json`,
|
|
15368
|
+
entryUrl: null,
|
|
15369
|
+
base,
|
|
15370
|
+
display: s
|
|
15371
|
+
};
|
|
15372
|
+
}
|
|
15373
|
+
throw new PluginLoadError(t("Unrecognised plugin location \"{location}\". Paste a GitHub repository link such as https://github.com/owner/repo, the short form github:owner/repo, or a URL to a plugin.json or plugin.ts.", { location: s }));
|
|
15374
|
+
}
|
|
15375
|
+
function githubSource(owner, repo, ref, dir) {
|
|
15376
|
+
const sub = dir ? dir.replace(/^\/+|\/+$/g, "") : "";
|
|
15377
|
+
const base = `https://raw.githubusercontent.com/${owner}/${repo}/${ref ?? "HEAD"}/${sub ? `${sub}/` : ""}`;
|
|
15378
|
+
const web = `https://github.com/${owner}/${repo}${ref || sub ? `/tree/${ref ?? "HEAD"}${sub ? `/${sub}` : ""}` : ""}`;
|
|
15379
|
+
return {
|
|
15380
|
+
kind: "remote",
|
|
15381
|
+
manifestUrl: `${base}plugin.json`,
|
|
15382
|
+
entryUrl: null,
|
|
15383
|
+
base,
|
|
15384
|
+
display: `github:${owner}/${repo}${ref ? `@${ref}` : ""}${sub ? `/${sub}` : ""}`,
|
|
15385
|
+
webUrl: web,
|
|
15386
|
+
github: {
|
|
15387
|
+
owner,
|
|
15388
|
+
repo,
|
|
15389
|
+
ref: ref ?? null,
|
|
15390
|
+
dir: sub
|
|
15391
|
+
}
|
|
15392
|
+
};
|
|
15393
|
+
}
|
|
15394
|
+
function validateManifest(raw, where) {
|
|
15395
|
+
if (!raw || typeof raw !== "object") throw new PluginLoadError(t("{where} is not a JSON object.", { where }));
|
|
15396
|
+
const m = raw;
|
|
15397
|
+
if (typeof m.name !== "string" || m.name.trim() === "") throw new PluginLoadError(t("{where} needs a \"name\".", { where }));
|
|
15398
|
+
const str = (k) => typeof m[k] === "string" ? m[k] : void 0;
|
|
15399
|
+
const out = { name: m.name.trim() };
|
|
15400
|
+
for (const k of [
|
|
15401
|
+
"id",
|
|
15402
|
+
"version",
|
|
15403
|
+
"description",
|
|
15404
|
+
"author",
|
|
15405
|
+
"homepage",
|
|
15406
|
+
"entry",
|
|
15407
|
+
"build",
|
|
15408
|
+
"icon"
|
|
15409
|
+
]) {
|
|
15410
|
+
const v = str(k);
|
|
15411
|
+
if (v !== void 0) out[k] = v;
|
|
15412
|
+
}
|
|
15413
|
+
if (typeof m.api === "number") out.api = m.api;
|
|
15414
|
+
for (const k of ["entry", "build"]) if (out[k] && /^(?:[a-z]+:)?\/\//i.test(out[k])) throw new PluginLoadError(t("{where}: \"{field}\" must be a path relative to the manifest.", {
|
|
15415
|
+
where,
|
|
15416
|
+
field: k
|
|
15417
|
+
}));
|
|
15418
|
+
if (Array.isArray(m.requires)) {
|
|
15419
|
+
const requires = [];
|
|
15420
|
+
for (const r of m.requires) {
|
|
15421
|
+
if (typeof r !== "string" || r.trim() === "") continue;
|
|
15422
|
+
let spec;
|
|
15423
|
+
try {
|
|
15424
|
+
spec = canonicalSpec(parseSpec(r));
|
|
15425
|
+
} catch (err) {
|
|
15426
|
+
throw new PluginLoadError(t("{where}: \"requires\" names a plugin location the editor cannot use, \"{location}\": {error}", {
|
|
15427
|
+
where,
|
|
15428
|
+
location: r,
|
|
15429
|
+
error: err instanceof Error ? err.message : String(err)
|
|
15430
|
+
}));
|
|
15431
|
+
}
|
|
15432
|
+
if (!requires.includes(spec)) requires.push(spec);
|
|
15433
|
+
}
|
|
15434
|
+
if (requires.length > 0) out.requires = requires;
|
|
15435
|
+
}
|
|
15436
|
+
return out;
|
|
15437
|
+
}
|
|
15438
|
+
var IMAGE_EXT = /\.(?:png|svg|jpe?g|gif|webp|avif|ico)$/i;
|
|
15439
|
+
var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
15440
|
+
/**
|
|
15441
|
+
* A manifest `icon` string as the chrome should show it. Four forms are understood —
|
|
15442
|
+
* a short glyph (an emoji), a `data:image/…` URI, an `http(s)` image, and a path
|
|
15443
|
+
* relative to the manifest — and nothing else is: any other scheme (`javascript:`
|
|
15444
|
+
* above all) and any long non-path string resolve to null and the plugin simply shows
|
|
15445
|
+
* the default icon. `base` is where relative paths resolve from; a built-in has none
|
|
15446
|
+
* (its file comes through `BuiltinPlugin.iconUrl` instead).
|
|
15447
|
+
*/
|
|
15448
|
+
function resolveIcon(icon, base) {
|
|
15449
|
+
const s = icon?.trim();
|
|
15450
|
+
if (!s) return null;
|
|
15451
|
+
if (/^data:image\//i.test(s) || /^https?:\/\//i.test(s)) return {
|
|
15452
|
+
kind: "image",
|
|
15453
|
+
url: s
|
|
15454
|
+
};
|
|
15455
|
+
if (HAS_SCHEME.test(s)) return null;
|
|
15456
|
+
if (s.includes("/") || IMAGE_EXT.test(s)) {
|
|
15457
|
+
if (!base) return null;
|
|
15458
|
+
try {
|
|
15459
|
+
return {
|
|
15460
|
+
kind: "image",
|
|
15461
|
+
url: new URL(s, base).href
|
|
15462
|
+
};
|
|
15463
|
+
} catch {
|
|
15464
|
+
return null;
|
|
15465
|
+
}
|
|
15466
|
+
}
|
|
15467
|
+
return [...s].length <= 4 ? {
|
|
15468
|
+
kind: "text",
|
|
15469
|
+
text: s
|
|
15470
|
+
} : null;
|
|
15471
|
+
}
|
|
15472
|
+
/** The spec as it is stored and shown: the short form for a repository, `builtin:name` for a built-in. */
|
|
15473
|
+
function canonicalSpec(source) {
|
|
15474
|
+
return source.kind === "builtin" ? `builtin:${source.name}` : source.display;
|
|
15475
|
+
}
|
|
15476
|
+
//#endregion
|
|
15477
|
+
//#region cli/src/plugin.ts
|
|
15478
|
+
/**
|
|
15479
|
+
* `scmjs plugin new | check | dev`: writing a plugin for the editor (docs/plugins.md, .claude/internals/cli.md).
|
|
15480
|
+
*
|
|
15481
|
+
* - `new` writes a plugin to start from, set up as the project's own are (Hello World's layout: manifest, source,
|
|
15482
|
+
* Korean catalogue and its test, typings, build, the shared CI).
|
|
15483
|
+
* - `check` reads the plugin as the editor would load it (`validateManifest`, the file it would import, the imports
|
|
15484
|
+
* it would refuse) and says what would go wrong, for a person or a CI step.
|
|
15485
|
+
* - `dev` serves the folder on the loopback address, builds it when it names a build, and opens the editor with
|
|
15486
|
+
* `?plugin-dev=` (src/hooks/usePluginDev.ts), which installs it the usual way the first time and reloads it on
|
|
15487
|
+
* every change the page is told of here.
|
|
15488
|
+
*/
|
|
15489
|
+
/** `Base Check` → `base-check`. */
|
|
15490
|
+
var pluginId = (name) => name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "my-plugin";
|
|
15491
|
+
/** `base-check` or `plugin-base-check` → `Base Check`. */
|
|
15492
|
+
var nameFromFolder = (folder) => basename(folder).replace(/^(?:scmjs-)?plugin-/, "").split(/[-_\s]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ") || "My Plugin";
|
|
15493
|
+
function gitUser() {
|
|
15494
|
+
const r = spawnSync("git", ["config", "user.name"], { encoding: "utf8" });
|
|
15495
|
+
return r.status === 0 ? r.stdout.trim() : "";
|
|
15496
|
+
}
|
|
15497
|
+
/** The files of a new plugin, by path. */
|
|
15498
|
+
function pluginFiles(o) {
|
|
15499
|
+
const json = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
15500
|
+
const lit = JSON.stringify(o.name);
|
|
15501
|
+
return {
|
|
15502
|
+
"plugin.json": json({
|
|
15503
|
+
name: o.name,
|
|
15504
|
+
id: o.id,
|
|
15505
|
+
version: "0.1.0",
|
|
15506
|
+
description: `${o.name}: a plugin for the scmJS map editor.`,
|
|
15507
|
+
...o.author ? { author: o.author } : {},
|
|
15508
|
+
entry: "plugin.ts",
|
|
15509
|
+
build: "dist/plugin.js",
|
|
15510
|
+
icon: "🧩",
|
|
15511
|
+
api: 1
|
|
15512
|
+
}),
|
|
15513
|
+
"plugin.ts": `/**
|
|
15514
|
+
* ${o.name}: a plugin for the scmJS map editor.
|
|
15515
|
+
*
|
|
15516
|
+
* It adds Tools ▸ ${o.name}…, which opens a pane about the map that is open. Change it into your own; the API is
|
|
15517
|
+
* documented at https://docs.scmjs.dev/plugins/.
|
|
15518
|
+
*
|
|
15519
|
+
* npm start the editor with this plugin, reloaded each time you save (scmjs plugin dev)
|
|
15520
|
+
* npm test the tests, including the one that keeps ko.ts complete
|
|
15521
|
+
* npm run check what the editor would make of this folder (scmjs plugin check)
|
|
15522
|
+
*/
|
|
15523
|
+
import type { PluginApi } from "@scm-js/plugin-api";
|
|
15524
|
+
import { KO } from "./ko";
|
|
15525
|
+
|
|
15526
|
+
export default function activate(api: PluginApi) {
|
|
15527
|
+
// The Korean for what the plugin shows (ko.ts). English is the key: pass every string to t() as a literal.
|
|
15528
|
+
api.i18n.register({ ko: KO });
|
|
15529
|
+
const t = api.i18n.t;
|
|
15530
|
+
|
|
15531
|
+
api.menu.add("Tools", {
|
|
15532
|
+
label: ${JSON.stringify(`${o.name}…`)},
|
|
15533
|
+
enabled: () => api.document.isOpen(),
|
|
15534
|
+
run: () => {
|
|
15535
|
+
const info = api.document.info();
|
|
15536
|
+
const units = api.document.scenario()?.units.length ?? 0;
|
|
15537
|
+
api.ui.dialog({
|
|
15538
|
+
title: ${lit},
|
|
15539
|
+
size: "sm",
|
|
15540
|
+
mount(body) {
|
|
15541
|
+
// Plain DOM: the editor gives the plugin an empty element to fill.
|
|
15542
|
+
const name = document.createElement("p");
|
|
15543
|
+
name.textContent = info?.name ?? ""; // the map's own text, never translated
|
|
15544
|
+
const count = document.createElement("p");
|
|
15545
|
+
count.textContent = t("{n, plural, one {# unit} other {# units}} on the map", { n: units });
|
|
15546
|
+
body.append(name, count);
|
|
15547
|
+
},
|
|
15548
|
+
buttons: [{ label: t("Close") }],
|
|
15549
|
+
});
|
|
15550
|
+
},
|
|
15551
|
+
});
|
|
15552
|
+
}
|
|
15553
|
+
`,
|
|
15554
|
+
"ko.ts": `// Korean for every string the plugin shows; English is the key. tests/ko.test.ts keeps it complete.
|
|
15555
|
+
export const KO: Record<string, string> = {
|
|
15556
|
+
"Close": "닫기",
|
|
15557
|
+
"{n, plural, one {# unit} other {# units}} on the map": "맵의 유닛 {n, plural, other {#개}}",
|
|
15558
|
+
};
|
|
15559
|
+
`,
|
|
15560
|
+
"tests/ko.test.ts": `import { readFileSync } from "node:fs";
|
|
15561
|
+
import { describe, expect, it } from "vitest";
|
|
15562
|
+
import { KO } from "../ko";
|
|
15563
|
+
|
|
15564
|
+
// Every string the plugin shows through \`t("…")\`, or hands the editor as \`msg("…")\`, read from the source.
|
|
15565
|
+
// A plugin with more files lists them here.
|
|
15566
|
+
const files = ["plugin.ts"];
|
|
15567
|
+
const sources = files.map((f) => readFileSync(new URL(\`../\${f}\`, import.meta.url), "utf8"));
|
|
15568
|
+
const keys = new Set(sources.flatMap((source) => [...source.matchAll(/\\b(?:t|msg)\\("((?:[^"\\\\]|\\\\.)*)"/g)].map((m) => JSON.parse(\`"\${m[1]}"\`) as string)));
|
|
15569
|
+
|
|
15570
|
+
describe("the Korean catalogue", () => {
|
|
15571
|
+
it("has every string the plugin shows", () => {
|
|
15572
|
+
expect([...keys].filter((k) => !(k in KO))).toEqual([]);
|
|
15573
|
+
});
|
|
15574
|
+
it("has nothing the plugin no longer shows", () => {
|
|
15575
|
+
expect(Object.keys(KO).filter((k) => !keys.has(k))).toEqual([]);
|
|
15576
|
+
});
|
|
15577
|
+
});
|
|
15578
|
+
`,
|
|
15579
|
+
"package.json": json({
|
|
15580
|
+
name: `scmjs-plugin-${o.id}`,
|
|
15581
|
+
version: "0.1.0",
|
|
15582
|
+
private: true,
|
|
15583
|
+
description: `${o.name}: a plugin for the scmJS map editor.`,
|
|
15584
|
+
...o.author ? { author: o.author } : {},
|
|
15585
|
+
license: "MIT",
|
|
15586
|
+
type: "module",
|
|
15587
|
+
scripts: {
|
|
15588
|
+
start: "scmjs plugin dev",
|
|
15589
|
+
check: "scmjs plugin check",
|
|
15590
|
+
build: "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js --banner:js=\"// Generated by npm run build - do not edit; the source is plugin.ts.\"",
|
|
15591
|
+
typecheck: "tsc -p tsconfig.json",
|
|
15592
|
+
test: "vitest run"
|
|
15593
|
+
},
|
|
15594
|
+
devDependencies: {
|
|
15595
|
+
"@scm-js/cli": `^${VERSION}`,
|
|
15596
|
+
"@scm-js/plugin-api": `^1`,
|
|
15597
|
+
esbuild: "^0.28.2",
|
|
15598
|
+
typescript: "~6.0.2",
|
|
15599
|
+
vitest: "^5.1.0"
|
|
15600
|
+
}
|
|
15601
|
+
}),
|
|
15602
|
+
"tsconfig.json": json({
|
|
15603
|
+
compilerOptions: {
|
|
15604
|
+
target: "ES2022",
|
|
15605
|
+
lib: [
|
|
15606
|
+
"ES2022",
|
|
15607
|
+
"DOM",
|
|
15608
|
+
"DOM.Iterable"
|
|
15609
|
+
],
|
|
15610
|
+
module: "ESNext",
|
|
15611
|
+
moduleResolution: "bundler",
|
|
15612
|
+
types: [],
|
|
15613
|
+
strict: true,
|
|
15614
|
+
noUnusedLocals: true,
|
|
15615
|
+
noUnusedParameters: true,
|
|
15616
|
+
verbatimModuleSyntax: true,
|
|
15617
|
+
erasableSyntaxOnly: true,
|
|
15618
|
+
isolatedModules: true,
|
|
15619
|
+
skipLibCheck: true,
|
|
15620
|
+
noEmit: true
|
|
15621
|
+
},
|
|
15622
|
+
include: ["plugin.ts", "ko.ts"]
|
|
15623
|
+
}),
|
|
15624
|
+
".gitignore": "node_modules/\n*.tsbuildinfo\n",
|
|
15625
|
+
".github/workflows/ci.yml": `# Type-check, test, and build dist/plugin.js, the bundle the editor loads (\`build\` in plugin.json), with the
|
|
15626
|
+
# workflow every scm-js plugin uses: on a push to main it commits the rebuilt bundle, on a v* tag it checks the
|
|
15627
|
+
# committed one matches, and weekly it type-checks against the newest @scm-js/plugin-api.
|
|
15628
|
+
name: CI
|
|
15629
|
+
|
|
15630
|
+
on:
|
|
15631
|
+
push:
|
|
15632
|
+
branches: [main]
|
|
15633
|
+
tags: ["v*"]
|
|
15634
|
+
pull_request:
|
|
15635
|
+
schedule:
|
|
15636
|
+
- cron: "0 6 * * 1"
|
|
15637
|
+
workflow_dispatch:
|
|
15638
|
+
|
|
15639
|
+
permissions:
|
|
15640
|
+
contents: write
|
|
15641
|
+
|
|
15642
|
+
jobs:
|
|
15643
|
+
ci:
|
|
15644
|
+
uses: scm-js/.github/.github/workflows/plugin-ci.yml@main
|
|
15645
|
+
`,
|
|
15646
|
+
"README.md": `# ${o.name}
|
|
15647
|
+
|
|
15648
|
+
A plugin for [scmJS](https://scmjs.dev/editor/), the StarCraft: Brood War map editor.
|
|
15649
|
+
|
|
15650
|
+
## Developing
|
|
15651
|
+
|
|
15652
|
+
\`\`\`sh
|
|
15653
|
+
npm install
|
|
15654
|
+
npm start # opens the editor with the plugin, and reloads it each time you save
|
|
15655
|
+
\`\`\`
|
|
15656
|
+
|
|
15657
|
+
\`npm test\` runs the tests, \`npm run typecheck\` the type-check, and \`npm run check\` says what the editor would
|
|
15658
|
+
make of the folder. The plugin guide is at [docs.scmjs.dev/plugins](https://docs.scmjs.dev/plugins/).
|
|
15659
|
+
|
|
15660
|
+
## Installing
|
|
15661
|
+
|
|
15662
|
+
Once this is a public GitHub repository, paste \`github:<you>/<repository>\` into Plugins ▸ Manage Plugins….
|
|
15663
|
+
Tag releases (\`v1.0.0\`): they are what the editor's update check offers.
|
|
15664
|
+
`,
|
|
15665
|
+
LICENSE: `MIT License
|
|
15666
|
+
|
|
15667
|
+
Copyright (c) ${o.year} ${o.author || "the authors"}
|
|
15668
|
+
|
|
15669
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15670
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15671
|
+
in the Software without restriction, including without limitation the rights
|
|
15672
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15673
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15674
|
+
furnished to do so, subject to the following conditions:
|
|
15675
|
+
|
|
15676
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15677
|
+
copies or substantial portions of the Software.
|
|
15678
|
+
|
|
15679
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15680
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15681
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
15682
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
15683
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
15684
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
15685
|
+
SOFTWARE.
|
|
15686
|
+
`
|
|
15687
|
+
};
|
|
15688
|
+
}
|
|
15689
|
+
/** `scmjs plugin new [name]`: a folder `plugin-<id>` (or `--dir`) with a plugin to start from. */
|
|
15690
|
+
function newPlugin(o) {
|
|
15691
|
+
const name = (o.name?.trim() || (o.dir ? nameFromFolder(resolve(o.dir)) : "")).slice(0, 64);
|
|
15692
|
+
if (!name) throw new CliError("Name the plugin", "scmjs plugin new \"Base Check\" (or --dir <folder>)", 2);
|
|
15693
|
+
const id = pluginId(name);
|
|
15694
|
+
const dir = resolve(o.dir ?? `plugin-${id}`);
|
|
15695
|
+
if (existsSync(dir) && readdirSync(dir).filter((f) => f !== ".git").length > 0) throw new CliError(`${relative(process.cwd(), dir) || "."} is not empty`, "Give an empty or new folder with --dir.", 2);
|
|
15696
|
+
const files = pluginFiles({
|
|
15697
|
+
name,
|
|
15698
|
+
id,
|
|
15699
|
+
author: gitUser(),
|
|
15700
|
+
year: (/* @__PURE__ */ new Date()).getFullYear()
|
|
15701
|
+
});
|
|
15702
|
+
for (const [path, text] of Object.entries(files)) {
|
|
15703
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
15704
|
+
writeFileSync(join(dir, path), text);
|
|
15705
|
+
}
|
|
15706
|
+
return {
|
|
15707
|
+
dir,
|
|
15708
|
+
files: Object.keys(files)
|
|
15709
|
+
};
|
|
15710
|
+
}
|
|
15711
|
+
var SOURCE_EXT = [
|
|
15712
|
+
".ts",
|
|
15713
|
+
".tsx",
|
|
15714
|
+
".mts",
|
|
15715
|
+
".js",
|
|
15716
|
+
".mjs"
|
|
15717
|
+
];
|
|
15718
|
+
/** The imports of a source file the editor would follow or refuse (type-only imports are erased first). */
|
|
15719
|
+
function sourceImports(code) {
|
|
15720
|
+
const text = code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
|
15721
|
+
const out = [];
|
|
15722
|
+
for (const m of text.matchAll(/\b(import|export)\s+(type\s+)?(?:[^'"`;]*?\sfrom\s*)?(['"])([^'"\n]+)\3|\bimport\s*\(\s*(['"])([^'"\n]+)\5\s*\)/g)) {
|
|
15723
|
+
if (m[2]) continue;
|
|
15724
|
+
out.push(m[4] ?? m[6]);
|
|
15725
|
+
}
|
|
15726
|
+
return out;
|
|
15727
|
+
}
|
|
15728
|
+
/** The file a relative import names, as the editor's loader looks for it (`./x`, `./x.js` meaning `x.ts`, `./dir`). */
|
|
15729
|
+
function importedFile(from, spec) {
|
|
15730
|
+
const base = resolve(dirname(from), spec);
|
|
15731
|
+
return [
|
|
15732
|
+
base,
|
|
15733
|
+
...SOURCE_EXT.map((e) => base + e),
|
|
15734
|
+
base.replace(/\.js$/, ".ts"),
|
|
15735
|
+
...SOURCE_EXT.map((e) => join(base, "index" + e))
|
|
15736
|
+
].find((f) => existsSync(f) && statSync(f).isFile()) ?? null;
|
|
15737
|
+
}
|
|
15738
|
+
var newest = (dir, skip) => {
|
|
15739
|
+
let t = 0;
|
|
15740
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
15741
|
+
if (e.name.startsWith(".") || e.name === "node_modules" || skip.has(join(dir, e.name))) continue;
|
|
15742
|
+
const p = join(dir, e.name);
|
|
15743
|
+
if (e.isDirectory()) t = Math.max(t, newest(p, skip));
|
|
15744
|
+
else if (SOURCE_EXT.includes(extname(e.name)) && !e.name.endsWith(".d.ts") && !p.includes(`${sep}tests${sep}`)) t = Math.max(t, statSync(p).mtimeMs);
|
|
15745
|
+
}
|
|
15746
|
+
return t;
|
|
15747
|
+
};
|
|
15748
|
+
/** `scmjs plugin check`: the plugin in `dir` as the editor would load it. */
|
|
15749
|
+
function checkPlugin(dirArg = ".") {
|
|
15750
|
+
const dir = resolve(dirArg);
|
|
15751
|
+
const out = {
|
|
15752
|
+
dir,
|
|
15753
|
+
name: null,
|
|
15754
|
+
id: null,
|
|
15755
|
+
version: null,
|
|
15756
|
+
loads: null,
|
|
15757
|
+
issues: []
|
|
15758
|
+
};
|
|
15759
|
+
const add = (level, message, hint) => out.issues.push({
|
|
15760
|
+
level,
|
|
15761
|
+
message,
|
|
15762
|
+
...hint ? { hint } : {}
|
|
15763
|
+
});
|
|
15764
|
+
const file = join(dir, "plugin.json");
|
|
15765
|
+
if (!existsSync(file)) throw new CliError(`No plugin.json in ${relative(process.cwd(), dir) || "this folder"}`, "Run this in the plugin's folder, or start one with scmjs plugin new.", 2);
|
|
15766
|
+
let raw;
|
|
15767
|
+
try {
|
|
15768
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
15769
|
+
} catch (e) {
|
|
15770
|
+
add("error", `plugin.json is not valid JSON: ${e.message}`);
|
|
15771
|
+
return out;
|
|
15772
|
+
}
|
|
15773
|
+
let m;
|
|
15774
|
+
try {
|
|
15775
|
+
m = validateManifest(raw, "plugin.json");
|
|
15776
|
+
} catch (e) {
|
|
15777
|
+
add("error", e.message);
|
|
15778
|
+
return out;
|
|
15779
|
+
}
|
|
15780
|
+
out.name = m.name;
|
|
15781
|
+
out.id = m.id ?? pluginId(m.name);
|
|
15782
|
+
out.version = m.version ?? null;
|
|
15783
|
+
if (m.api === void 0) add("warn", "plugin.json names no \"api\"", `Add "api": 1, so an older editor says it is too old instead of failing.`);
|
|
15784
|
+
else if (m.api > 1) add("error", `It needs plugin API ${m.api}; the editor provides 1`);
|
|
15785
|
+
if (!m.id) add("info", `No "id": the editor uses "${out.id}", made from the name`, "An id keeps storage and commands the same if the name changes.");
|
|
15786
|
+
for (const k of [
|
|
15787
|
+
"version",
|
|
15788
|
+
"description",
|
|
15789
|
+
"author",
|
|
15790
|
+
"homepage"
|
|
15791
|
+
]) if (!m[k]) add("info", `No "${k}": shown when someone adds the plugin`);
|
|
15792
|
+
const icon = resolveIcon(m.icon, "file:///plugin/plugin.json");
|
|
15793
|
+
if (!m.icon) add("info", "No \"icon\": the default plugin mark is shown");
|
|
15794
|
+
else if (!icon) add("warn", `The icon "${m.icon}" is not one the editor shows`, "An image beside plugin.json, an https or data: image, or up to four characters.");
|
|
15795
|
+
else if (icon.kind === "image" && icon.url.startsWith("file:///plugin/") && !existsSync(join(dir, decodeURIComponent(icon.url.slice(15))))) add("error", `The icon ${m.icon} is not in the folder`);
|
|
15796
|
+
const entry = m.entry ?? ["plugin.ts", "plugin.js"].find((f) => existsSync(join(dir, f)));
|
|
15797
|
+
const entryFile = entry ? join(dir, entry) : null;
|
|
15798
|
+
if (!entryFile || !existsSync(entryFile)) add("error", entry ? `The entry ${entry} is not there` : "No \"entry\", and neither plugin.ts nor plugin.js is there");
|
|
15799
|
+
if (m.build) {
|
|
15800
|
+
const built = join(dir, m.build);
|
|
15801
|
+
out.loads = m.build;
|
|
15802
|
+
if (!existsSync(built)) add("error", `The build ${m.build} is not there, and the editor loads it instead of ${entry ?? "the source"}`, existsSync(join(dir, "package.json")) ? "Run npm run build (scmjs plugin dev builds it for you)." : "Build it, or take \"build\" out of plugin.json.");
|
|
15803
|
+
else if (newest(dir, /* @__PURE__ */ new Set([built])) > statSync(built).mtimeMs + 1e3) add("warn", `${m.build} is older than the source`, "Run npm run build, or the editor loads what it was.");
|
|
15804
|
+
} else if (entryFile && existsSync(entryFile)) {
|
|
15805
|
+
out.loads = entry;
|
|
15806
|
+
const seen = /* @__PURE__ */ new Set(), todo = [entryFile];
|
|
15807
|
+
while (todo.length) {
|
|
15808
|
+
const f = todo.pop();
|
|
15809
|
+
if (seen.has(f)) continue;
|
|
15810
|
+
seen.add(f);
|
|
15811
|
+
for (const spec of sourceImports(readFileSync(f, "utf8"))) if (/^(?:\.{1,2}\/|\/)/.test(spec)) {
|
|
15812
|
+
const to = importedFile(f, spec);
|
|
15813
|
+
if (to) todo.push(to);
|
|
15814
|
+
else add("error", `${relative(dir, f)} imports ${spec}, which is not there`);
|
|
15815
|
+
} else if (!/^https?:\/\//.test(spec)) add("error", `${relative(dir, f)} imports the package "${spec}"`, "The editor loads source without a module resolver: bundle the plugin and name the bundle as \"build\".");
|
|
15816
|
+
}
|
|
15817
|
+
}
|
|
15818
|
+
if (out.issues.some((i) => i.level === "error")) out.loads = null;
|
|
15819
|
+
return out;
|
|
15820
|
+
}
|
|
15821
|
+
function printPluginCheck(c, all) {
|
|
15822
|
+
const shown = c.issues.filter((i) => all || i.level !== "info");
|
|
15823
|
+
const errors = c.issues.filter((i) => i.level === "error").length, warns = c.issues.filter((i) => i.level === "warn").length;
|
|
15824
|
+
const head = `${import_picocolors.default.bold(c.name ?? basename(c.dir))}${c.version ? import_picocolors.default.dim(` ${c.version}`) : ""} ${errors ? `${mark.error} ${plural(errors, "error")}` : warns ? `${mark.warn} ${plural(warns, "warning")}` : `${mark.ok} loads`}${c.loads && !errors ? import_picocolors.default.dim(` (${c.loads})`) : ""}`;
|
|
15825
|
+
const order = {
|
|
15826
|
+
error: 0,
|
|
15827
|
+
warn: 1,
|
|
15828
|
+
info: 2
|
|
15829
|
+
};
|
|
15830
|
+
return [head, ...shown.sort((a, b) => order[a.level] - order[b.level]).flatMap((i) => [` ${i.level === "error" ? mark.error : i.level === "warn" ? mark.warn : mark.info} ${i.message}`, ...i.hint ? [import_picocolors.default.dim(` ${i.hint}`)] : []])];
|
|
15831
|
+
}
|
|
15832
|
+
var TYPES = {
|
|
15833
|
+
".json": "application/json",
|
|
15834
|
+
".js": "text/javascript",
|
|
15835
|
+
".mjs": "text/javascript",
|
|
15836
|
+
".ts": "text/plain",
|
|
15837
|
+
".tsx": "text/plain",
|
|
15838
|
+
".mts": "text/plain",
|
|
15839
|
+
".svg": "image/svg+xml",
|
|
15840
|
+
".png": "image/png",
|
|
15841
|
+
".jpg": "image/jpeg",
|
|
15842
|
+
".jpeg": "image/jpeg",
|
|
15843
|
+
".gif": "image/gif",
|
|
15844
|
+
".webp": "image/webp",
|
|
15845
|
+
".avif": "image/avif",
|
|
15846
|
+
".ico": "image/x-icon",
|
|
15847
|
+
".css": "text/css",
|
|
15848
|
+
".html": "text/html",
|
|
15849
|
+
".txt": "text/plain",
|
|
15850
|
+
".md": "text/plain"
|
|
15851
|
+
};
|
|
15852
|
+
var HEADERS = {
|
|
15853
|
+
"access-control-allow-origin": "*",
|
|
15854
|
+
"access-control-allow-private-network": "true",
|
|
15855
|
+
"cache-control": "no-store"
|
|
15856
|
+
};
|
|
15857
|
+
/** The editor's address that loads the plugin served at `served` (src/plugins/devLink.ts reads it). */
|
|
15858
|
+
var devEditorUrl = (editor, served) => {
|
|
15859
|
+
const u = new URL(editor);
|
|
15860
|
+
u.searchParams.set("plugin-dev", served);
|
|
15861
|
+
return u.href;
|
|
15862
|
+
};
|
|
15863
|
+
/** `scmjs plugin dev`: serve, build, open the editor, and tell it to reload on each change, until Ctrl+C. */
|
|
15864
|
+
async function devPlugin(o) {
|
|
15865
|
+
const dir = resolve(o.dir ?? ".");
|
|
15866
|
+
checkPlugin(dir);
|
|
15867
|
+
const manifest = JSON.parse(readFileSync(join(dir, "plugin.json"), "utf8"));
|
|
15868
|
+
const pkg = existsSync(join(dir, "package.json")) ? JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) : null;
|
|
15869
|
+
const builds = !!(manifest.build && pkg?.scripts?.build);
|
|
15870
|
+
if (builds && !existsSync(join(dir, "node_modules"))) throw new CliError("The plugin's packages are not installed", "Run npm install in its folder first.", 2);
|
|
15871
|
+
const clients = /* @__PURE__ */ new Set();
|
|
15872
|
+
const send = (event, data) => {
|
|
15873
|
+
for (const c of clients) c.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
15874
|
+
};
|
|
15875
|
+
let connected = false;
|
|
15876
|
+
const server = createServer((req, res) => {
|
|
15877
|
+
if (req.method === "OPTIONS") {
|
|
15878
|
+
res.writeHead(204, {
|
|
15879
|
+
...HEADERS,
|
|
15880
|
+
"access-control-allow-methods": "GET",
|
|
15881
|
+
"access-control-allow-headers": "*"
|
|
15882
|
+
}).end();
|
|
15883
|
+
return;
|
|
15884
|
+
}
|
|
15885
|
+
const path = decodeURIComponent(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
|
|
15886
|
+
if (path === "/__scmjs/events") {
|
|
15887
|
+
res.writeHead(200, {
|
|
15888
|
+
...HEADERS,
|
|
15889
|
+
"content-type": "text/event-stream",
|
|
15890
|
+
connection: "keep-alive"
|
|
15891
|
+
});
|
|
15892
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ name: manifest.name })}\n\n`);
|
|
15893
|
+
clients.add(res);
|
|
15894
|
+
if (!connected) {
|
|
15895
|
+
connected = true;
|
|
15896
|
+
print(`${mark.ok} The editor is connected`);
|
|
15897
|
+
}
|
|
15898
|
+
req.on("close", () => clients.delete(res));
|
|
15899
|
+
return;
|
|
15900
|
+
}
|
|
15901
|
+
const file = resolve(dir, "." + (path.endsWith("/") ? path + "index.html" : path));
|
|
15902
|
+
if (!(file === dir || file.startsWith(dir + sep)) || /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)/.test(relative(dir, file)) || !existsSync(file) || !statSync(file).isFile()) {
|
|
15903
|
+
res.writeHead(404, HEADERS).end();
|
|
15904
|
+
return;
|
|
15905
|
+
}
|
|
15906
|
+
res.writeHead(200, {
|
|
15907
|
+
...HEADERS,
|
|
15908
|
+
"content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
|
|
15909
|
+
});
|
|
15910
|
+
res.end(readFileSync(file));
|
|
15911
|
+
});
|
|
15912
|
+
await new Promise((ok, fail) => {
|
|
15913
|
+
server.once("error", (e) => fail(e.code === "EADDRINUSE" ? new CliError(`Port ${o.port} is in use`, `Pick another with --port. The editor keeps one install per address, so stay with the one you choose.`) : e));
|
|
15914
|
+
server.listen(o.port, "127.0.0.1", () => ok());
|
|
15915
|
+
});
|
|
15916
|
+
const served = `http://127.0.0.1:${o.port}/`;
|
|
15917
|
+
const heartbeat = setInterval(() => {
|
|
15918
|
+
for (const c of clients) c.write(": still here\n\n");
|
|
15919
|
+
}, 2e4);
|
|
15920
|
+
const build = () => new Promise((done) => {
|
|
15921
|
+
if (!builds) return done(null);
|
|
15922
|
+
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", [
|
|
15923
|
+
"run",
|
|
15924
|
+
"build",
|
|
15925
|
+
"--silent"
|
|
15926
|
+
], {
|
|
15927
|
+
cwd: dir,
|
|
15928
|
+
shell: process.platform === "win32"
|
|
15929
|
+
});
|
|
15930
|
+
let text = "";
|
|
15931
|
+
child.stdout.on("data", (d) => {
|
|
15932
|
+
text += d;
|
|
15933
|
+
});
|
|
15934
|
+
child.stderr.on("data", (d) => {
|
|
15935
|
+
text += d;
|
|
15936
|
+
});
|
|
15937
|
+
child.on("close", (code) => done(code === 0 ? null : text.trim() || `npm run build exited with ${code}`));
|
|
15938
|
+
child.on("error", (e) => done(e.message));
|
|
15939
|
+
});
|
|
15940
|
+
const failed = await build();
|
|
15941
|
+
const first = checkPlugin(dir);
|
|
15942
|
+
print(`${import_picocolors.default.bold(first.name ?? basename(dir))}${first.version ? import_picocolors.default.dim(` ${first.version}`) : ""}`);
|
|
15943
|
+
if (failed) print(`${mark.error} The build failed`, import_picocolors.default.dim(failed.split("\n").map((l) => ` ${l}`).join("\n")));
|
|
15944
|
+
else for (const line of printPluginCheck(first, false).slice(1)) print(line);
|
|
15945
|
+
const url = devEditorUrl(o.editor ?? process.env.SCMJS_EDITOR_URL ?? "https://scmjs.dev/editor/", served);
|
|
15946
|
+
print(`${mark.info} Serving it at ${import_picocolors.default.cyan(served)}${builds ? dot + "built with npm run build" : ""}`);
|
|
15947
|
+
if (o.open && process.env.BROWSER !== "none") {
|
|
15948
|
+
openBrowser(url);
|
|
15949
|
+
print(`${mark.info} Opening the editor. The first time, it asks you to add the plugin.`);
|
|
15950
|
+
}
|
|
15951
|
+
print(import_picocolors.default.dim(` If nothing opens, go to ${url}`), import_picocolors.default.dim(" Watching for changes. Ctrl+C stops."));
|
|
15952
|
+
let timer = null, changed = /* @__PURE__ */ new Set(), running = false;
|
|
15953
|
+
const built = manifest.build?.replace(/^\.\//, "") ?? null;
|
|
15954
|
+
const skip = (f) => {
|
|
15955
|
+
const p = f.replace(/\\/g, "/");
|
|
15956
|
+
return /(^|\/)(?:node_modules|\.git)(\/|$)/.test(p) || !!built && (p === built || p === dirname(built));
|
|
15957
|
+
};
|
|
15958
|
+
const flush = async () => {
|
|
15959
|
+
if (running) {
|
|
15960
|
+
timer = setTimeout(flush, 100);
|
|
15961
|
+
return;
|
|
15962
|
+
}
|
|
15963
|
+
running = true;
|
|
15964
|
+
const files = [...changed];
|
|
15965
|
+
changed = /* @__PURE__ */ new Set();
|
|
15966
|
+
const err = await build();
|
|
15967
|
+
const at = (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8);
|
|
15968
|
+
if (err) {
|
|
15969
|
+
print(`${mark.error} ${import_picocolors.default.dim(at)} The build failed`, import_picocolors.default.dim(err.split("\n").slice(0, 12).map((l) => ` ${l}`).join("\n")));
|
|
15970
|
+
send("failed", { text: err.split("\n").slice(0, 3).join("\n") });
|
|
15971
|
+
} else {
|
|
15972
|
+
send("reload", { files });
|
|
15973
|
+
print(`${mark.ok} ${import_picocolors.default.dim(at)} ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}${clients.size ? "; reloaded in the editor" : import_picocolors.default.dim("; no editor connected")}`);
|
|
15974
|
+
}
|
|
15975
|
+
running = false;
|
|
15976
|
+
};
|
|
15977
|
+
const watcher = watch(dir, { recursive: true }, (_e, f) => {
|
|
15978
|
+
if (!f || skip(f)) return;
|
|
15979
|
+
changed.add(f.replace(/\\/g, "/"));
|
|
15980
|
+
if (timer) clearTimeout(timer);
|
|
15981
|
+
timer = setTimeout(flush, 150);
|
|
15982
|
+
});
|
|
15983
|
+
await new Promise((done) => {
|
|
15984
|
+
const stop = () => {
|
|
15985
|
+
watcher.close();
|
|
15986
|
+
clearInterval(heartbeat);
|
|
15987
|
+
for (const c of clients) c.end();
|
|
15988
|
+
server.close();
|
|
15989
|
+
print("", import_picocolors.default.dim("Stopped."));
|
|
15990
|
+
done();
|
|
15991
|
+
};
|
|
15992
|
+
process.once("SIGINT", stop);
|
|
15993
|
+
process.once("SIGTERM", stop);
|
|
15994
|
+
});
|
|
15995
|
+
}
|
|
15996
|
+
//#endregion
|
|
15228
15997
|
//#region cli/src/replay.ts
|
|
15229
15998
|
/** `scmjs replay info`: who played, on what, for how long, from the replay's header, commands and its copy of the map. */
|
|
15230
15999
|
var RACES = [
|
|
@@ -15352,8 +16121,8 @@ function printReplayInfo(r) {
|
|
|
15352
16121
|
//#endregion
|
|
15353
16122
|
//#region cli/src/main.ts
|
|
15354
16123
|
/**
|
|
15355
|
-
* scmjs: StarCraft: Brood War maps and
|
|
15356
|
-
* (`map`, `replay`), the editor's own code underneath (docs/cli.md, .claude/internals/cli.md).
|
|
16124
|
+
* scmjs: StarCraft: Brood War maps, replays and bots from the command line. One command, a noun for each kind of
|
|
16125
|
+
* thing (`map`, `replay`, `plugin`, `bot`), the editor's own code underneath (docs/cli.md, .claude/internals/cli.md).
|
|
15357
16126
|
*/
|
|
15358
16127
|
var json = new Option("--json", "print JSON instead of text, for scripts");
|
|
15359
16128
|
/** Runs `fn` on each file in turn; a file that fails is said and the rest go on. JSON: one value, or a list for several. */
|
|
@@ -15442,7 +16211,11 @@ async function botInfo(input) {
|
|
|
15442
16211
|
]
|
|
15443
16212
|
};
|
|
15444
16213
|
}
|
|
15445
|
-
|
|
16214
|
+
for (const stream of [process.stdout, process.stderr]) stream.on("error", (e) => {
|
|
16215
|
+
if (e.code === "EPIPE") process.exit(process.exitCode ?? 0);
|
|
16216
|
+
else throw e;
|
|
16217
|
+
});
|
|
16218
|
+
var program = new Command$1("scmjs").description("StarCraft: Brood War maps, replays, plugins and bots from the command line").version(VERSION, "-v, --version", "print the version").helpOption("-h, --help", "show help for a command").addOption(new Option("--no-color", "plain text, no colour (also NO_COLOR=1)")).showSuggestionAfterError().helpCommand("help [command]", "show help for a command").configureOutput({ outputError: (text, write) => write(text.replace(/^error: /, `${mark.error} `)) }).showHelpAfterError(import_picocolors.default.dim("(scmjs --help lists the commands)")).configureHelp({
|
|
15446
16219
|
styleTitle: (s) => import_picocolors.default.bold(s),
|
|
15447
16220
|
styleCommandText: (s) => import_picocolors.default.cyan(s),
|
|
15448
16221
|
styleSubcommandText: (s) => import_picocolors.default.cyan(s),
|
|
@@ -15453,6 +16226,8 @@ ${import_picocolors.default.bold("Examples:")}
|
|
|
15453
16226
|
${import_picocolors.default.dim("$")} scmjs info "(2)Destination.scx" what a map or a replay holds
|
|
15454
16227
|
${import_picocolors.default.dim("$")} scmjs map check *.scx Check Map on every map here
|
|
15455
16228
|
${import_picocolors.default.dim("$")} scmjs replay open game.rep watch it in the editor
|
|
16229
|
+
${import_picocolors.default.dim("$")} scmjs plugin new "Base Check" a plugin for the editor to start from
|
|
16230
|
+
${import_picocolors.default.dim("$")} scmjs bot new a BWAPI bot to start from, for scmjs.dev/play
|
|
15456
16231
|
${import_picocolors.default.dim("$")} scmjs about this version, and where things are
|
|
15457
16232
|
|
|
15458
16233
|
${import_picocolors.default.dim("Docs: https://docs.scmjs.dev")}`);
|
|
@@ -15498,6 +16273,47 @@ replay.command("open").description("watch a replay in the scmJS app, or the edit
|
|
|
15498
16273
|
expectKind(input, "replay");
|
|
15499
16274
|
await openInScmjs(input, o);
|
|
15500
16275
|
});
|
|
16276
|
+
var plugin = program.command("plugin").description("write a plugin for the editor: start one, check it, run it");
|
|
16277
|
+
plugin.command("new").description("a plugin to start from, in a new folder").argument("[name]", "the plugin's name, such as \"Base Check\"").option("--dir <folder>", "the folder to write it in (default plugin-<id>)").action((name, o) => {
|
|
16278
|
+
const made = newPlugin({
|
|
16279
|
+
name,
|
|
16280
|
+
dir: o.dir
|
|
16281
|
+
});
|
|
16282
|
+
const rel = relative(process.cwd(), made.dir) || ".";
|
|
16283
|
+
print(`${mark.ok} ${import_picocolors.default.bold(name ?? rel)} written in ${import_picocolors.default.cyan(rel)}${dot}${plural(made.files.length, "file")}`, "", import_picocolors.default.dim(" Next:"), ` cd ${rel.includes(" ") ? JSON.stringify(rel) : rel}`, " npm install", ` npm start ${import_picocolors.default.dim("the editor with your plugin, reloaded each time you save")}`, "", import_picocolors.default.dim(" plugin.ts is the plugin; plugin.json describes it. Guide: https://docs.scmjs.dev/plugins/"));
|
|
16284
|
+
});
|
|
16285
|
+
plugin.command("check").description("what the editor would make of the plugin").argument("[folder]", "the plugin's folder", ".").addOption(json).option("--all", "list notes too, not only errors and warnings").option("--strict", "fail on warnings as well as errors").addHelpText("after", `\nExits 1 when the editor would not load it (or on warnings, with --strict): fit for a CI step.`).action((folder, o) => {
|
|
16286
|
+
const c = checkPlugin(folder);
|
|
16287
|
+
if (c.issues.some((i) => i.level === "error" || o.strict && i.level === "warn")) process.exitCode = 1;
|
|
16288
|
+
print(...o.json ? [JSON.stringify(c, null, 2)] : printPluginCheck(c, !!o.all));
|
|
16289
|
+
});
|
|
16290
|
+
plugin.command("dev").description("the editor with your plugin, reloaded on every save").argument("[folder]", "the plugin's folder", ".").option("--port <port>", "the port to serve it on; the editor remembers the plugin by it", "5310").option("--no-open", "do not open the editor; print its address instead").addOption(new Option("--editor <url>", "the editor's address (SCMJS_EDITOR_URL), such as a local one").hideHelp()).addHelpText("after", `\nBuilds it first when plugin.json names a "build" and package.json has a build script.`).action(async (folder, o) => {
|
|
16291
|
+
const port = Number(o.port);
|
|
16292
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError(`--port ${o.port} is not a port`, null, 2);
|
|
16293
|
+
await devPlugin({
|
|
16294
|
+
dir: folder,
|
|
16295
|
+
port,
|
|
16296
|
+
open: o.open,
|
|
16297
|
+
editor: o.editor
|
|
16298
|
+
});
|
|
16299
|
+
});
|
|
16300
|
+
var bot = program.command("bot").description("build your own BWAPI bot for scmjs.dev/play (needs Docker)").addHelpText("after", () => `
|
|
16301
|
+
Run these in your bot's folder. The toolchain is a Docker image, downloaded the first time.
|
|
16302
|
+
A built bot is added on the play page: Options → Community bots → Add a bot from a file…
|
|
16303
|
+
|
|
16304
|
+
${import_picocolors.default.dim(`Guide: ${BOT_GUIDE}`)}`);
|
|
16305
|
+
bot.command("new").description("a bot to start from, in this folder").action(async () => {
|
|
16306
|
+
await runBot(["new"]);
|
|
16307
|
+
});
|
|
16308
|
+
bot.command("init").description("scmbot.json from the bot's Visual Studio project").argument("[project]", "the .vcxproj, when the folder has several").action(async (project) => {
|
|
16309
|
+
await runBot(project ? ["init", project] : ["init"]);
|
|
16310
|
+
});
|
|
16311
|
+
bot.command("build").description("out/<key>.scmbot, to add on the play page").action(async () => {
|
|
16312
|
+
await runBot(["build"]);
|
|
16313
|
+
});
|
|
16314
|
+
bot.command("update").description("the newest toolchain, the one the play page expects").action(async () => {
|
|
16315
|
+
await updateBot();
|
|
16316
|
+
});
|
|
15501
16317
|
try {
|
|
15502
16318
|
await program.parseAsync();
|
|
15503
16319
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scm-js/cli",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "StarCraft: Brood War maps and
|
|
3
|
+
"version": "0.8.9",
|
|
4
|
+
"description": "StarCraft: Brood War maps, replays and bots from the command line: what a map or replay holds, Check Map, opening them in scmJS, and building a BWAPI bot for the play page",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"starcraft",
|
|
7
7
|
"brood war",
|
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
"scm",
|
|
11
11
|
"scx",
|
|
12
12
|
"rep",
|
|
13
|
-
"cli"
|
|
13
|
+
"cli",
|
|
14
|
+
"bwapi",
|
|
15
|
+
"bot"
|
|
14
16
|
],
|
|
15
17
|
"license": "MIT",
|
|
18
|
+
"author": "Jeany",
|
|
16
19
|
"type": "module",
|
|
17
20
|
"bin": {
|
|
18
21
|
"scmjs": "dist/scmjs.mjs"
|