@scm-js/cli 0.8.8 → 0.8.10

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.
@@ -1,4 +1,4 @@
1
- Third-party notices for scmjs 0.8.8
1
+ Third-party notices for scmjs 0.8.10
2
2
 
3
3
  scmjs is MIT-licensed (LICENSE). It is built into one file from scmJS's own code and the
4
4
  packages below, whose license texts this file carries. Who wrote what, and where the
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 path, { basename, join, resolve } from "node:path";
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,18 +6856,50 @@ 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.8";
6786
- var COMMIT = "a48a77c";
6859
+ /** `scmjs about`: this build and whether npm has a newer one, who made it, the machine it runs on, the desktop app and Docker, and where everything is. */
6860
+ var VERSION = "0.8.10";
6861
+ var COMMIT = "6cf22f8";
6787
6862
  var BUILT = "2026-11-06";
6788
- function aboutReport() {
6863
+ var PACKAGE = "@scm-js/cli";
6864
+ /** a < b, for x.y.z versions (a prerelease or build suffix is ignored). */
6865
+ function olderThan(a, b) {
6866
+ const n = (v) => v.split(/[-+]/)[0].split(".").map((x) => Number(x) || 0);
6867
+ const [x, y] = [n(a), n(b)];
6868
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
6869
+ return false;
6870
+ }
6871
+ /** The newest version on npm, or null when npm could not be asked (offline, slow): two seconds at most. */
6872
+ async function newestVersion() {
6873
+ try {
6874
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
6875
+ signal: AbortSignal.timeout(2e3),
6876
+ headers: { accept: "application/json" }
6877
+ });
6878
+ if (!res.ok) return null;
6879
+ const v = (await res.json()).version;
6880
+ return typeof v === "string" ? v : null;
6881
+ } catch {
6882
+ return null;
6883
+ }
6884
+ }
6885
+ /** About's "Newest" row: the update to install, this one, or what npm has when this is newer (a build from a clone). */
6886
+ function newestLine(version, latest) {
6887
+ if (latest === null) return import_picocolors.default.dim("could not ask npm");
6888
+ if (olderThan(version, latest)) return `${import_picocolors.default.yellow(latest)}${dot}npm install -g ${PACKAGE}@latest`;
6889
+ return import_picocolors.default.dim(latest === version ? "this one" : `${latest} on npm`);
6890
+ }
6891
+ async function aboutReport() {
6892
+ const latest = await newestVersion();
6789
6893
  return {
6790
6894
  version: VERSION,
6895
+ latest,
6896
+ outdated: latest !== null && olderThan("0.8.10", latest),
6791
6897
  commit: COMMIT,
6792
6898
  built: BUILT,
6793
6899
  node: process.version,
6794
6900
  platform: `${process.platform} ${process.arch}`,
6795
6901
  desktopApp: findDesktopApp(),
6902
+ docker: dockerVersion(),
6796
6903
  pluginApi: 1,
6797
6904
  reads: {
6798
6905
  maps: [
@@ -6800,21 +6907,25 @@ function aboutReport() {
6800
6907
  ".scx",
6801
6908
  ".chk"
6802
6909
  ],
6803
- replays: [".rep"]
6910
+ replays: [".rep"],
6911
+ bots: [".scmbot"]
6804
6912
  },
6805
6913
  links: {
6806
6914
  editor: EDITOR_URL,
6915
+ play: "https://scmjs.dev/play/",
6807
6916
  docs: "https://docs.scmjs.dev",
6917
+ bots: BOT_GUIDE,
6808
6918
  source: "https://github.com/scm-js/scm-js",
6809
6919
  issues: "https://github.com/scm-js/scm-js/issues"
6810
6920
  },
6921
+ author: "Jeany",
6811
6922
  license: "MIT"
6812
6923
  };
6813
6924
  }
6814
6925
  function printAbout(r) {
6815
6926
  return [
6816
6927
  `${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 replays from the command line, by scmJS"),
6928
+ import_picocolors.default.dim("StarCraft: Brood War maps, replays and bots from the command line, by scmJS"),
6818
6929
  "",
6819
6930
  ...rows([
6820
6931
  ["Version", [
@@ -6822,15 +6933,21 @@ function printAbout(r) {
6822
6933
  r.commit && `commit ${r.commit}`,
6823
6934
  r.built && `built ${r.built}`
6824
6935
  ].filter(Boolean).join(dot)],
6936
+ ["Newest", newestLine(r.version, r.latest)],
6825
6937
  ["Node", `${r.node}${dot}${r.platform}`],
6826
6938
  ["Desktop app", r.desktopApp ?? import_picocolors.default.dim("not found (scmjs … open uses the browser)")],
6939
+ ["Docker", r.docker ? `${r.docker}` : import_picocolors.default.dim("not found (scmjs bot needs it)")],
6827
6940
  ["Plugin API", String(r.pluginApi)],
6828
- ["Reads", `maps ${r.reads.maps.join(" ")}${dot}replays ${r.reads.replays.join(" ")}`],
6941
+ ["Reads", `maps ${r.reads.maps.join(" ")}${dot}replays ${r.reads.replays.join(" ")}${dot}bots ${r.reads.bots.join(" ")}`],
6829
6942
  null,
6830
6943
  ["Editor", import_picocolors.default.cyan(r.links.editor)],
6944
+ ["Play", import_picocolors.default.cyan(r.links.play)],
6831
6945
  ["Docs", import_picocolors.default.cyan(r.links.docs)],
6946
+ ["Bots", import_picocolors.default.cyan(r.links.bots)],
6832
6947
  ["Source", import_picocolors.default.cyan(r.links.source)],
6833
6948
  ["Issues", import_picocolors.default.cyan(r.links.issues)],
6949
+ null,
6950
+ ["Made by", import_picocolors.default.bold(r.author)],
6834
6951
  ["Licence", r.license]
6835
6952
  ])
6836
6953
  ];
@@ -15225,6 +15342,690 @@ function wrap(text, width, most = 4) {
15225
15342
  return lines.slice(0, most).join("\n") + (lines.length > most ? " …" : "");
15226
15343
  }
15227
15344
  //#endregion
15345
+ //#region src/plugins/loader.ts
15346
+ var PluginLoadError = class extends Error {
15347
+ constructor(message) {
15348
+ super(message);
15349
+ this.name = "PluginLoadError";
15350
+ }
15351
+ };
15352
+ var ENTRY_EXT = /\.(?:ts|tsx|mts|js|mjs)$/i;
15353
+ function dirOf(url) {
15354
+ return url.slice(0, url.lastIndexOf("/") + 1);
15355
+ }
15356
+ /**
15357
+ * `builtin:name`; `github:owner/repo[@ref][/dir]`; a github.com repository URL (with
15358
+ * an optional `/tree/ref/dir`); or any URL — a `.json` is the manifest, a script file
15359
+ * is the entry, anything else is a directory holding `plugin.json`.
15360
+ */
15361
+ function parseSpec(spec) {
15362
+ const s = spec.trim();
15363
+ if (s === "") throw new PluginLoadError(t("Enter a plugin location."));
15364
+ const builtin = /^builtin:([\w-]+)$/i.exec(s);
15365
+ if (builtin) return {
15366
+ kind: "builtin",
15367
+ name: builtin[1]
15368
+ };
15369
+ const gh = /^github:([^/@\s]+)\/([^/@\s]+)(?:@([^/\s]+))?(?:\/(.*))?$/i.exec(s);
15370
+ if (gh) return githubSource(gh[1], gh[2], gh[3], gh[4]);
15371
+ const ghUrl = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/tree\/([^/\s]+)(?:\/(.*?))?)?\/?$/i.exec(s);
15372
+ if (ghUrl) return githubSource(ghUrl[1], ghUrl[2], ghUrl[3], ghUrl[4]);
15373
+ if (/^https?:\/\//i.test(s)) {
15374
+ let url;
15375
+ try {
15376
+ url = new URL(s);
15377
+ } catch {
15378
+ throw new PluginLoadError(t("Not a valid URL: {url}", { url: s }));
15379
+ }
15380
+ url.hash = "";
15381
+ const path = url.pathname;
15382
+ if (/\.json$/i.test(path)) return {
15383
+ kind: "remote",
15384
+ manifestUrl: url.href,
15385
+ entryUrl: null,
15386
+ base: dirOf(url.href),
15387
+ display: s
15388
+ };
15389
+ if (ENTRY_EXT.test(path)) return {
15390
+ kind: "remote",
15391
+ manifestUrl: null,
15392
+ entryUrl: url.href,
15393
+ base: dirOf(url.href),
15394
+ display: s
15395
+ };
15396
+ const base = url.href.endsWith("/") ? url.href : `${url.href}/`;
15397
+ return {
15398
+ kind: "remote",
15399
+ manifestUrl: `${base}plugin.json`,
15400
+ entryUrl: null,
15401
+ base,
15402
+ display: s
15403
+ };
15404
+ }
15405
+ 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 }));
15406
+ }
15407
+ function githubSource(owner, repo, ref, dir) {
15408
+ const sub = dir ? dir.replace(/^\/+|\/+$/g, "") : "";
15409
+ const base = `https://raw.githubusercontent.com/${owner}/${repo}/${ref ?? "HEAD"}/${sub ? `${sub}/` : ""}`;
15410
+ const web = `https://github.com/${owner}/${repo}${ref || sub ? `/tree/${ref ?? "HEAD"}${sub ? `/${sub}` : ""}` : ""}`;
15411
+ return {
15412
+ kind: "remote",
15413
+ manifestUrl: `${base}plugin.json`,
15414
+ entryUrl: null,
15415
+ base,
15416
+ display: `github:${owner}/${repo}${ref ? `@${ref}` : ""}${sub ? `/${sub}` : ""}`,
15417
+ webUrl: web,
15418
+ github: {
15419
+ owner,
15420
+ repo,
15421
+ ref: ref ?? null,
15422
+ dir: sub
15423
+ }
15424
+ };
15425
+ }
15426
+ function validateManifest(raw, where) {
15427
+ if (!raw || typeof raw !== "object") throw new PluginLoadError(t("{where} is not a JSON object.", { where }));
15428
+ const m = raw;
15429
+ if (typeof m.name !== "string" || m.name.trim() === "") throw new PluginLoadError(t("{where} needs a \"name\".", { where }));
15430
+ const str = (k) => typeof m[k] === "string" ? m[k] : void 0;
15431
+ const out = { name: m.name.trim() };
15432
+ for (const k of [
15433
+ "id",
15434
+ "version",
15435
+ "description",
15436
+ "author",
15437
+ "homepage",
15438
+ "entry",
15439
+ "build",
15440
+ "icon"
15441
+ ]) {
15442
+ const v = str(k);
15443
+ if (v !== void 0) out[k] = v;
15444
+ }
15445
+ if (typeof m.api === "number") out.api = m.api;
15446
+ 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.", {
15447
+ where,
15448
+ field: k
15449
+ }));
15450
+ if (Array.isArray(m.requires)) {
15451
+ const requires = [];
15452
+ for (const r of m.requires) {
15453
+ if (typeof r !== "string" || r.trim() === "") continue;
15454
+ let spec;
15455
+ try {
15456
+ spec = canonicalSpec(parseSpec(r));
15457
+ } catch (err) {
15458
+ throw new PluginLoadError(t("{where}: \"requires\" names a plugin location the editor cannot use, \"{location}\": {error}", {
15459
+ where,
15460
+ location: r,
15461
+ error: err instanceof Error ? err.message : String(err)
15462
+ }));
15463
+ }
15464
+ if (!requires.includes(spec)) requires.push(spec);
15465
+ }
15466
+ if (requires.length > 0) out.requires = requires;
15467
+ }
15468
+ return out;
15469
+ }
15470
+ var IMAGE_EXT = /\.(?:png|svg|jpe?g|gif|webp|avif|ico)$/i;
15471
+ var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
15472
+ /**
15473
+ * A manifest `icon` string as the chrome should show it. Four forms are understood —
15474
+ * a short glyph (an emoji), a `data:image/…` URI, an `http(s)` image, and a path
15475
+ * relative to the manifest — and nothing else is: any other scheme (`javascript:`
15476
+ * above all) and any long non-path string resolve to null and the plugin simply shows
15477
+ * the default icon. `base` is where relative paths resolve from; a built-in has none
15478
+ * (its file comes through `BuiltinPlugin.iconUrl` instead).
15479
+ */
15480
+ function resolveIcon(icon, base) {
15481
+ const s = icon?.trim();
15482
+ if (!s) return null;
15483
+ if (/^data:image\//i.test(s) || /^https?:\/\//i.test(s)) return {
15484
+ kind: "image",
15485
+ url: s
15486
+ };
15487
+ if (HAS_SCHEME.test(s)) return null;
15488
+ if (s.includes("/") || IMAGE_EXT.test(s)) {
15489
+ if (!base) return null;
15490
+ try {
15491
+ return {
15492
+ kind: "image",
15493
+ url: new URL(s, base).href
15494
+ };
15495
+ } catch {
15496
+ return null;
15497
+ }
15498
+ }
15499
+ return [...s].length <= 4 ? {
15500
+ kind: "text",
15501
+ text: s
15502
+ } : null;
15503
+ }
15504
+ /** The spec as it is stored and shown: the short form for a repository, `builtin:name` for a built-in. */
15505
+ function canonicalSpec(source) {
15506
+ return source.kind === "builtin" ? `builtin:${source.name}` : source.display;
15507
+ }
15508
+ //#endregion
15509
+ //#region cli/src/plugin.ts
15510
+ /**
15511
+ * `scmjs plugin new | check | dev`: writing a plugin for the editor (docs/plugins.md, .claude/internals/cli.md).
15512
+ *
15513
+ * - `new` writes a plugin to start from, set up as the project's own are (Hello World's layout: manifest, source,
15514
+ * Korean catalogue and its test, typings, build, the shared CI).
15515
+ * - `check` reads the plugin as the editor would load it (`validateManifest`, the file it would import, the imports
15516
+ * it would refuse) and says what would go wrong, for a person or a CI step.
15517
+ * - `dev` serves the folder on the loopback address, builds it when it names a build, and opens the editor with
15518
+ * `?plugin-dev=` (src/hooks/usePluginDev.ts), which installs it the usual way the first time and reloads it on
15519
+ * every change the page is told of here.
15520
+ */
15521
+ /** `Base Check` → `base-check`. */
15522
+ var pluginId = (name) => name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "my-plugin";
15523
+ /** `base-check` or `plugin-base-check` → `Base Check`. */
15524
+ var nameFromFolder = (folder) => basename(folder).replace(/^(?:scmjs-)?plugin-/, "").split(/[-_\s]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ") || "My Plugin";
15525
+ function gitUser() {
15526
+ const r = spawnSync("git", ["config", "user.name"], { encoding: "utf8" });
15527
+ return r.status === 0 ? r.stdout.trim() : "";
15528
+ }
15529
+ /** The files of a new plugin, by path. */
15530
+ function pluginFiles(o) {
15531
+ const json = (v) => JSON.stringify(v, null, 2) + "\n";
15532
+ const lit = JSON.stringify(o.name);
15533
+ return {
15534
+ "plugin.json": json({
15535
+ name: o.name,
15536
+ id: o.id,
15537
+ version: "0.1.0",
15538
+ description: `${o.name}: a plugin for the scmJS map editor.`,
15539
+ ...o.author ? { author: o.author } : {},
15540
+ entry: "plugin.ts",
15541
+ build: "dist/plugin.js",
15542
+ icon: "🧩",
15543
+ api: 1
15544
+ }),
15545
+ "plugin.ts": `/**
15546
+ * ${o.name}: a plugin for the scmJS map editor.
15547
+ *
15548
+ * It adds Tools ▸ ${o.name}…, which opens a pane about the map that is open. Change it into your own; the API is
15549
+ * documented at https://docs.scmjs.dev/plugins/.
15550
+ *
15551
+ * npm start the editor with this plugin, reloaded each time you save (scmjs plugin dev)
15552
+ * npm test the tests, including the one that keeps ko.ts complete
15553
+ * npm run check what the editor would make of this folder (scmjs plugin check)
15554
+ */
15555
+ import type { PluginApi } from "@scm-js/plugin-api";
15556
+ import { KO } from "./ko";
15557
+
15558
+ export default function activate(api: PluginApi) {
15559
+ // The Korean for what the plugin shows (ko.ts). English is the key: pass every string to t() as a literal.
15560
+ api.i18n.register({ ko: KO });
15561
+ const t = api.i18n.t;
15562
+
15563
+ api.menu.add("Tools", {
15564
+ label: ${JSON.stringify(`${o.name}…`)},
15565
+ enabled: () => api.document.isOpen(),
15566
+ run: () => {
15567
+ const info = api.document.info();
15568
+ const units = api.document.scenario()?.units.length ?? 0;
15569
+ api.ui.dialog({
15570
+ title: ${lit},
15571
+ size: "sm",
15572
+ mount(body) {
15573
+ // Plain DOM: the editor gives the plugin an empty element to fill.
15574
+ const name = document.createElement("p");
15575
+ name.textContent = info?.name ?? ""; // the map's own text, never translated
15576
+ const count = document.createElement("p");
15577
+ count.textContent = t("{n, plural, one {# unit} other {# units}} on the map", { n: units });
15578
+ body.append(name, count);
15579
+ },
15580
+ buttons: [{ label: t("Close") }],
15581
+ });
15582
+ },
15583
+ });
15584
+ }
15585
+ `,
15586
+ "ko.ts": `// Korean for every string the plugin shows; English is the key. tests/ko.test.ts keeps it complete.
15587
+ export const KO: Record<string, string> = {
15588
+ "Close": "닫기",
15589
+ "{n, plural, one {# unit} other {# units}} on the map": "맵의 유닛 {n, plural, other {#개}}",
15590
+ };
15591
+ `,
15592
+ "tests/ko.test.ts": `import { readFileSync } from "node:fs";
15593
+ import { describe, expect, it } from "vitest";
15594
+ import { KO } from "../ko";
15595
+
15596
+ // Every string the plugin shows through \`t("…")\`, or hands the editor as \`msg("…")\`, read from the source.
15597
+ // A plugin with more files lists them here.
15598
+ const files = ["plugin.ts"];
15599
+ const sources = files.map((f) => readFileSync(new URL(\`../\${f}\`, import.meta.url), "utf8"));
15600
+ const keys = new Set(sources.flatMap((source) => [...source.matchAll(/\\b(?:t|msg)\\("((?:[^"\\\\]|\\\\.)*)"/g)].map((m) => JSON.parse(\`"\${m[1]}"\`) as string)));
15601
+
15602
+ describe("the Korean catalogue", () => {
15603
+ it("has every string the plugin shows", () => {
15604
+ expect([...keys].filter((k) => !(k in KO))).toEqual([]);
15605
+ });
15606
+ it("has nothing the plugin no longer shows", () => {
15607
+ expect(Object.keys(KO).filter((k) => !keys.has(k))).toEqual([]);
15608
+ });
15609
+ });
15610
+ `,
15611
+ "package.json": json({
15612
+ name: `scmjs-plugin-${o.id}`,
15613
+ version: "0.1.0",
15614
+ private: true,
15615
+ description: `${o.name}: a plugin for the scmJS map editor.`,
15616
+ ...o.author ? { author: o.author } : {},
15617
+ license: "MIT",
15618
+ type: "module",
15619
+ scripts: {
15620
+ start: "scmjs plugin dev",
15621
+ check: "scmjs plugin check",
15622
+ 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.\"",
15623
+ typecheck: "tsc -p tsconfig.json",
15624
+ test: "vitest run"
15625
+ },
15626
+ devDependencies: {
15627
+ "@scm-js/cli": `^${VERSION}`,
15628
+ "@scm-js/plugin-api": `^1`,
15629
+ esbuild: "^0.28.2",
15630
+ typescript: "~6.0.2",
15631
+ vitest: "^5.1.0"
15632
+ }
15633
+ }),
15634
+ "tsconfig.json": json({
15635
+ compilerOptions: {
15636
+ target: "ES2022",
15637
+ lib: [
15638
+ "ES2022",
15639
+ "DOM",
15640
+ "DOM.Iterable"
15641
+ ],
15642
+ module: "ESNext",
15643
+ moduleResolution: "bundler",
15644
+ types: [],
15645
+ strict: true,
15646
+ noUnusedLocals: true,
15647
+ noUnusedParameters: true,
15648
+ verbatimModuleSyntax: true,
15649
+ erasableSyntaxOnly: true,
15650
+ isolatedModules: true,
15651
+ skipLibCheck: true,
15652
+ noEmit: true
15653
+ },
15654
+ include: ["plugin.ts", "ko.ts"]
15655
+ }),
15656
+ ".gitignore": "node_modules/\n*.tsbuildinfo\n",
15657
+ ".github/workflows/ci.yml": `# Type-check, test, and build dist/plugin.js, the bundle the editor loads (\`build\` in plugin.json), with the
15658
+ # workflow every scm-js plugin uses: on a push to main it commits the rebuilt bundle, on a v* tag it checks the
15659
+ # committed one matches, and weekly it type-checks against the newest @scm-js/plugin-api.
15660
+ name: CI
15661
+
15662
+ on:
15663
+ push:
15664
+ branches: [main]
15665
+ tags: ["v*"]
15666
+ pull_request:
15667
+ schedule:
15668
+ - cron: "0 6 * * 1"
15669
+ workflow_dispatch:
15670
+
15671
+ permissions:
15672
+ contents: write
15673
+
15674
+ jobs:
15675
+ ci:
15676
+ uses: scm-js/.github/.github/workflows/plugin-ci.yml@main
15677
+ `,
15678
+ "README.md": `# ${o.name}
15679
+
15680
+ A plugin for [scmJS](https://scmjs.dev/editor/), the StarCraft: Brood War map editor.
15681
+
15682
+ ## Developing
15683
+
15684
+ \`\`\`sh
15685
+ npm install
15686
+ npm start # opens the editor with the plugin, and reloads it each time you save
15687
+ \`\`\`
15688
+
15689
+ \`npm test\` runs the tests, \`npm run typecheck\` the type-check, and \`npm run check\` says what the editor would
15690
+ make of the folder. The plugin guide is at [docs.scmjs.dev/plugins](https://docs.scmjs.dev/plugins/).
15691
+
15692
+ ## Installing
15693
+
15694
+ Once this is a public GitHub repository, paste \`github:<you>/<repository>\` into Plugins ▸ Manage Plugins….
15695
+ Tag releases (\`v1.0.0\`): they are what the editor's update check offers.
15696
+ `,
15697
+ LICENSE: `MIT License
15698
+
15699
+ Copyright (c) ${o.year} ${o.author || "the authors"}
15700
+
15701
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15702
+ of this software and associated documentation files (the "Software"), to deal
15703
+ in the Software without restriction, including without limitation the rights
15704
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15705
+ copies of the Software, and to permit persons to whom the Software is
15706
+ furnished to do so, subject to the following conditions:
15707
+
15708
+ The above copyright notice and this permission notice shall be included in all
15709
+ copies or substantial portions of the Software.
15710
+
15711
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15712
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15713
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15714
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15715
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
15716
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
15717
+ SOFTWARE.
15718
+ `
15719
+ };
15720
+ }
15721
+ /** `scmjs plugin new [name]`: a folder `plugin-<id>` (or `--dir`) with a plugin to start from. */
15722
+ function newPlugin(o) {
15723
+ const name = (o.name?.trim() || (o.dir ? nameFromFolder(resolve(o.dir)) : "")).slice(0, 64);
15724
+ if (!name) throw new CliError("Name the plugin", "scmjs plugin new \"Base Check\" (or --dir <folder>)", 2);
15725
+ const id = pluginId(name);
15726
+ const dir = resolve(o.dir ?? `plugin-${id}`);
15727
+ 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);
15728
+ const files = pluginFiles({
15729
+ name,
15730
+ id,
15731
+ author: gitUser(),
15732
+ year: (/* @__PURE__ */ new Date()).getFullYear()
15733
+ });
15734
+ for (const [path, text] of Object.entries(files)) {
15735
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
15736
+ writeFileSync(join(dir, path), text);
15737
+ }
15738
+ return {
15739
+ dir,
15740
+ files: Object.keys(files)
15741
+ };
15742
+ }
15743
+ var SOURCE_EXT = [
15744
+ ".ts",
15745
+ ".tsx",
15746
+ ".mts",
15747
+ ".js",
15748
+ ".mjs"
15749
+ ];
15750
+ /** The imports of a source file the editor would follow or refuse (type-only imports are erased first). */
15751
+ function sourceImports(code) {
15752
+ const text = code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
15753
+ const out = [];
15754
+ for (const m of text.matchAll(/\b(import|export)\s+(type\s+)?(?:[^'"`;]*?\sfrom\s*)?(['"])([^'"\n]+)\3|\bimport\s*\(\s*(['"])([^'"\n]+)\5\s*\)/g)) {
15755
+ if (m[2]) continue;
15756
+ out.push(m[4] ?? m[6]);
15757
+ }
15758
+ return out;
15759
+ }
15760
+ /** The file a relative import names, as the editor's loader looks for it (`./x`, `./x.js` meaning `x.ts`, `./dir`). */
15761
+ function importedFile(from, spec) {
15762
+ const base = resolve(dirname(from), spec);
15763
+ return [
15764
+ base,
15765
+ ...SOURCE_EXT.map((e) => base + e),
15766
+ base.replace(/\.js$/, ".ts"),
15767
+ ...SOURCE_EXT.map((e) => join(base, "index" + e))
15768
+ ].find((f) => existsSync(f) && statSync(f).isFile()) ?? null;
15769
+ }
15770
+ var newest = (dir, skip) => {
15771
+ let t = 0;
15772
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
15773
+ if (e.name.startsWith(".") || e.name === "node_modules" || skip.has(join(dir, e.name))) continue;
15774
+ const p = join(dir, e.name);
15775
+ if (e.isDirectory()) t = Math.max(t, newest(p, skip));
15776
+ 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);
15777
+ }
15778
+ return t;
15779
+ };
15780
+ /** `scmjs plugin check`: the plugin in `dir` as the editor would load it. */
15781
+ function checkPlugin(dirArg = ".") {
15782
+ const dir = resolve(dirArg);
15783
+ const out = {
15784
+ dir,
15785
+ name: null,
15786
+ id: null,
15787
+ version: null,
15788
+ loads: null,
15789
+ issues: []
15790
+ };
15791
+ const add = (level, message, hint) => out.issues.push({
15792
+ level,
15793
+ message,
15794
+ ...hint ? { hint } : {}
15795
+ });
15796
+ const file = join(dir, "plugin.json");
15797
+ 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);
15798
+ let raw;
15799
+ try {
15800
+ raw = JSON.parse(readFileSync(file, "utf8"));
15801
+ } catch (e) {
15802
+ add("error", `plugin.json is not valid JSON: ${e.message}`);
15803
+ return out;
15804
+ }
15805
+ let m;
15806
+ try {
15807
+ m = validateManifest(raw, "plugin.json");
15808
+ } catch (e) {
15809
+ add("error", e.message);
15810
+ return out;
15811
+ }
15812
+ out.name = m.name;
15813
+ out.id = m.id ?? pluginId(m.name);
15814
+ out.version = m.version ?? null;
15815
+ 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.`);
15816
+ else if (m.api > 1) add("error", `It needs plugin API ${m.api}; the editor provides 1`);
15817
+ 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.");
15818
+ for (const k of [
15819
+ "version",
15820
+ "description",
15821
+ "author",
15822
+ "homepage"
15823
+ ]) if (!m[k]) add("info", `No "${k}": shown when someone adds the plugin`);
15824
+ const icon = resolveIcon(m.icon, "file:///plugin/plugin.json");
15825
+ if (!m.icon) add("info", "No \"icon\": the default plugin mark is shown");
15826
+ 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.");
15827
+ 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`);
15828
+ const entry = m.entry ?? ["plugin.ts", "plugin.js"].find((f) => existsSync(join(dir, f)));
15829
+ const entryFile = entry ? join(dir, entry) : null;
15830
+ if (!entryFile || !existsSync(entryFile)) add("error", entry ? `The entry ${entry} is not there` : "No \"entry\", and neither plugin.ts nor plugin.js is there");
15831
+ if (m.build) {
15832
+ const built = join(dir, m.build);
15833
+ out.loads = m.build;
15834
+ 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.");
15835
+ 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.");
15836
+ } else if (entryFile && existsSync(entryFile)) {
15837
+ out.loads = entry;
15838
+ const seen = /* @__PURE__ */ new Set(), todo = [entryFile];
15839
+ while (todo.length) {
15840
+ const f = todo.pop();
15841
+ if (seen.has(f)) continue;
15842
+ seen.add(f);
15843
+ for (const spec of sourceImports(readFileSync(f, "utf8"))) if (/^(?:\.{1,2}\/|\/)/.test(spec)) {
15844
+ const to = importedFile(f, spec);
15845
+ if (to) todo.push(to);
15846
+ else add("error", `${relative(dir, f)} imports ${spec}, which is not there`);
15847
+ } 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\".");
15848
+ }
15849
+ }
15850
+ if (out.issues.some((i) => i.level === "error")) out.loads = null;
15851
+ return out;
15852
+ }
15853
+ function printPluginCheck(c, all) {
15854
+ const shown = c.issues.filter((i) => all || i.level !== "info");
15855
+ const errors = c.issues.filter((i) => i.level === "error").length, warns = c.issues.filter((i) => i.level === "warn").length;
15856
+ 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})`) : ""}`;
15857
+ const order = {
15858
+ error: 0,
15859
+ warn: 1,
15860
+ info: 2
15861
+ };
15862
+ 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}`)] : []])];
15863
+ }
15864
+ var TYPES = {
15865
+ ".json": "application/json",
15866
+ ".js": "text/javascript",
15867
+ ".mjs": "text/javascript",
15868
+ ".ts": "text/plain",
15869
+ ".tsx": "text/plain",
15870
+ ".mts": "text/plain",
15871
+ ".svg": "image/svg+xml",
15872
+ ".png": "image/png",
15873
+ ".jpg": "image/jpeg",
15874
+ ".jpeg": "image/jpeg",
15875
+ ".gif": "image/gif",
15876
+ ".webp": "image/webp",
15877
+ ".avif": "image/avif",
15878
+ ".ico": "image/x-icon",
15879
+ ".css": "text/css",
15880
+ ".html": "text/html",
15881
+ ".txt": "text/plain",
15882
+ ".md": "text/plain"
15883
+ };
15884
+ var HEADERS = {
15885
+ "access-control-allow-origin": "*",
15886
+ "access-control-allow-private-network": "true",
15887
+ "cache-control": "no-store"
15888
+ };
15889
+ /** The editor's address that loads the plugin served at `served` (src/plugins/devLink.ts reads it). */
15890
+ var devEditorUrl = (editor, served) => {
15891
+ const u = new URL(editor);
15892
+ u.searchParams.set("plugin-dev", served);
15893
+ return u.href;
15894
+ };
15895
+ /** `scmjs plugin dev`: serve, build, open the editor, and tell it to reload on each change, until Ctrl+C. */
15896
+ async function devPlugin(o) {
15897
+ const dir = resolve(o.dir ?? ".");
15898
+ checkPlugin(dir);
15899
+ const manifest = JSON.parse(readFileSync(join(dir, "plugin.json"), "utf8"));
15900
+ const pkg = existsSync(join(dir, "package.json")) ? JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) : null;
15901
+ const builds = !!(manifest.build && pkg?.scripts?.build);
15902
+ 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);
15903
+ const clients = /* @__PURE__ */ new Set();
15904
+ const send = (event, data) => {
15905
+ for (const c of clients) c.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
15906
+ };
15907
+ let connected = false;
15908
+ const server = createServer((req, res) => {
15909
+ if (req.method === "OPTIONS") {
15910
+ res.writeHead(204, {
15911
+ ...HEADERS,
15912
+ "access-control-allow-methods": "GET",
15913
+ "access-control-allow-headers": "*"
15914
+ }).end();
15915
+ return;
15916
+ }
15917
+ const path = decodeURIComponent(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
15918
+ if (path === "/__scmjs/events") {
15919
+ res.writeHead(200, {
15920
+ ...HEADERS,
15921
+ "content-type": "text/event-stream",
15922
+ connection: "keep-alive"
15923
+ });
15924
+ res.write(`event: hello\ndata: ${JSON.stringify({ name: manifest.name })}\n\n`);
15925
+ clients.add(res);
15926
+ if (!connected) {
15927
+ connected = true;
15928
+ print(`${mark.ok} The editor is connected`);
15929
+ }
15930
+ req.on("close", () => clients.delete(res));
15931
+ return;
15932
+ }
15933
+ const file = resolve(dir, "." + (path.endsWith("/") ? path + "index.html" : path));
15934
+ if (!(file === dir || file.startsWith(dir + sep)) || /(?:^|[\\/])(?:node_modules|\.git)(?:[\\/]|$)/.test(relative(dir, file)) || !existsSync(file) || !statSync(file).isFile()) {
15935
+ res.writeHead(404, HEADERS).end();
15936
+ return;
15937
+ }
15938
+ res.writeHead(200, {
15939
+ ...HEADERS,
15940
+ "content-type": TYPES[extname(file).toLowerCase()] ?? "application/octet-stream"
15941
+ });
15942
+ res.end(readFileSync(file));
15943
+ });
15944
+ await new Promise((ok, fail) => {
15945
+ 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));
15946
+ server.listen(o.port, "127.0.0.1", () => ok());
15947
+ });
15948
+ const served = `http://127.0.0.1:${o.port}/`;
15949
+ const heartbeat = setInterval(() => {
15950
+ for (const c of clients) c.write(": still here\n\n");
15951
+ }, 2e4);
15952
+ const build = () => new Promise((done) => {
15953
+ if (!builds) return done(null);
15954
+ const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", [
15955
+ "run",
15956
+ "build",
15957
+ "--silent"
15958
+ ], {
15959
+ cwd: dir,
15960
+ shell: process.platform === "win32"
15961
+ });
15962
+ let text = "";
15963
+ child.stdout.on("data", (d) => {
15964
+ text += d;
15965
+ });
15966
+ child.stderr.on("data", (d) => {
15967
+ text += d;
15968
+ });
15969
+ child.on("close", (code) => done(code === 0 ? null : text.trim() || `npm run build exited with ${code}`));
15970
+ child.on("error", (e) => done(e.message));
15971
+ });
15972
+ const failed = await build();
15973
+ const first = checkPlugin(dir);
15974
+ print(`${import_picocolors.default.bold(first.name ?? basename(dir))}${first.version ? import_picocolors.default.dim(` ${first.version}`) : ""}`);
15975
+ if (failed) print(`${mark.error} The build failed`, import_picocolors.default.dim(failed.split("\n").map((l) => ` ${l}`).join("\n")));
15976
+ else for (const line of printPluginCheck(first, false).slice(1)) print(line);
15977
+ const url = devEditorUrl(o.editor ?? process.env.SCMJS_EDITOR_URL ?? "https://scmjs.dev/editor/", served);
15978
+ print(`${mark.info} Serving it at ${import_picocolors.default.cyan(served)}${builds ? dot + "built with npm run build" : ""}`);
15979
+ if (o.open && process.env.BROWSER !== "none") {
15980
+ openBrowser(url);
15981
+ print(`${mark.info} Opening the editor. The first time, it asks you to add the plugin.`);
15982
+ }
15983
+ print(import_picocolors.default.dim(` If nothing opens, go to ${url}`), import_picocolors.default.dim(" Watching for changes. Ctrl+C stops."));
15984
+ let timer = null, changed = /* @__PURE__ */ new Set(), running = false;
15985
+ const built = manifest.build?.replace(/^\.\//, "") ?? null;
15986
+ const skip = (f) => {
15987
+ const p = f.replace(/\\/g, "/");
15988
+ return /(^|\/)(?:node_modules|\.git)(\/|$)/.test(p) || !!built && (p === built || p === dirname(built));
15989
+ };
15990
+ const flush = async () => {
15991
+ if (running) {
15992
+ timer = setTimeout(flush, 100);
15993
+ return;
15994
+ }
15995
+ running = true;
15996
+ const files = [...changed];
15997
+ changed = /* @__PURE__ */ new Set();
15998
+ const err = await build();
15999
+ const at = (/* @__PURE__ */ new Date()).toTimeString().slice(0, 8);
16000
+ if (err) {
16001
+ 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")));
16002
+ send("failed", { text: err.split("\n").slice(0, 3).join("\n") });
16003
+ } else {
16004
+ send("reload", { files });
16005
+ 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")}`);
16006
+ }
16007
+ running = false;
16008
+ };
16009
+ const watcher = watch(dir, { recursive: true }, (_e, f) => {
16010
+ if (!f || skip(f)) return;
16011
+ changed.add(f.replace(/\\/g, "/"));
16012
+ if (timer) clearTimeout(timer);
16013
+ timer = setTimeout(flush, 150);
16014
+ });
16015
+ await new Promise((done) => {
16016
+ const stop = () => {
16017
+ watcher.close();
16018
+ clearInterval(heartbeat);
16019
+ for (const c of clients) c.end();
16020
+ server.close();
16021
+ print("", import_picocolors.default.dim("Stopped."));
16022
+ done();
16023
+ };
16024
+ process.once("SIGINT", stop);
16025
+ process.once("SIGTERM", stop);
16026
+ });
16027
+ }
16028
+ //#endregion
15228
16029
  //#region cli/src/replay.ts
15229
16030
  /** `scmjs replay info`: who played, on what, for how long, from the replay's header, commands and its copy of the map. */
15230
16031
  var RACES = [
@@ -15352,8 +16153,8 @@ function printReplayInfo(r) {
15352
16153
  //#endregion
15353
16154
  //#region cli/src/main.ts
15354
16155
  /**
15355
- * scmjs: StarCraft: Brood War maps and replays from the command line. One command, a noun for each kind of thing
15356
- * (`map`, `replay`), the editor's own code underneath (docs/cli.md, .claude/internals/cli.md).
16156
+ * scmjs: StarCraft: Brood War maps, replays and bots from the command line. One command, a noun for each kind of
16157
+ * thing (`map`, `replay`, `plugin`, `bot`), the editor's own code underneath (docs/cli.md, .claude/internals/cli.md).
15357
16158
  */
15358
16159
  var json = new Option("--json", "print JSON instead of text, for scripts");
15359
16160
  /** 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,29 +16243,35 @@ async function botInfo(input) {
15442
16243
  ]
15443
16244
  };
15444
16245
  }
15445
- var program = new Command$1("scmjs").description("StarCraft: Brood War maps and replays 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({
16246
+ for (const stream of [process.stdout, process.stderr]) stream.on("error", (e) => {
16247
+ if (e.code === "EPIPE") process.exit(process.exitCode ?? 0);
16248
+ else throw e;
16249
+ });
16250
+ 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().commandsGroup("This tool:").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
16251
  styleTitle: (s) => import_picocolors.default.bold(s),
15447
16252
  styleCommandText: (s) => import_picocolors.default.cyan(s),
15448
16253
  styleSubcommandText: (s) => import_picocolors.default.cyan(s),
15449
16254
  styleArgumentText: (s) => import_picocolors.default.yellow(s),
15450
- styleOptionText: (s) => import_picocolors.default.green(s)
16255
+ styleOptionText: (s) => import_picocolors.default.green(s),
16256
+ subcommandTerm: (cmd) => {
16257
+ const subs = cmd.commands.map((c) => c.name()).filter((n) => n !== "help");
16258
+ return subs.length ? `${cmd.name()} ${subs.join("|")}` : new Help$1().subcommandTerm(cmd).replace(" [options]", "");
16259
+ }
15451
16260
  }).addHelpText("after", () => `
15452
16261
  ${import_picocolors.default.bold("Examples:")}
15453
16262
  ${import_picocolors.default.dim("$")} scmjs info "(2)Destination.scx" what a map or a replay holds
15454
16263
  ${import_picocolors.default.dim("$")} scmjs map check *.scx Check Map on every map here
15455
16264
  ${import_picocolors.default.dim("$")} scmjs replay open game.rep watch it in the editor
16265
+ ${import_picocolors.default.dim("$")} scmjs plugin new "Base Check" a plugin for the editor to start from
16266
+ ${import_picocolors.default.dim("$")} scmjs bot new a BWAPI bot to start from, for scmjs.dev/play
15456
16267
  ${import_picocolors.default.dim("$")} scmjs about this version, and where things are
15457
16268
 
15458
16269
  ${import_picocolors.default.dim("Docs: https://docs.scmjs.dev")}`);
15459
- program.command("about").description("this version of scmjs, and where things are").addOption(json).action((o) => {
15460
- const r = aboutReport();
15461
- print(...o.json ? [JSON.stringify(r, null, 2)] : printAbout(r));
15462
- });
15463
- program.command("info").description("what a map, replay or bot file holds").argument("<files...>", "maps (.scm .scx .chk), replays (.rep) or bots (.scmbot)").addOption(json).action(async (files, o) => void await eachFile(files, !!o.json, async (input) => {
16270
+ program.command("info").description("what a map, replay or bot file holds").helpGroup("Maps and replays:").argument("<files...>", "maps (.scm .scx .chk), replays (.rep) or bots (.scmbot)").addOption(json).action(async (files, o) => void await eachFile(files, !!o.json, async (input) => {
15464
16271
  const kind = expectKind(input, "map", "replay", "bot");
15465
16272
  return kind === "map" ? mapInfo(input) : kind === "replay" ? replayInfo(input) : botInfo(input);
15466
16273
  }));
15467
- var map = program.command("map").description("read, check and open maps");
16274
+ var map = program.command("map").description("read, check and open maps").helpGroup("Maps and replays:");
15468
16275
  map.command("info").description("a map's size, players, units, triggers and its check").argument("<maps...>").addOption(json).action(async (files, o) => void await eachFile(files, !!o.json, mapInfo));
15469
16276
  map.command("check").description("Check Map, as the editor's: errors, then warnings").argument("<maps...>").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 a map has errors (or warnings, with --strict): fit for a CI step.`).action(async (files, o) => {
15470
16277
  const reports = await eachFile(files, !!o.json, async (input) => {
@@ -15491,15 +16298,61 @@ map.command("open").description("open a map in the scmJS app, or the editor in y
15491
16298
  expectKind(input, "map");
15492
16299
  await openInScmjs(input, o);
15493
16300
  });
15494
- var replay = program.command("replay").description("read and open replays");
16301
+ var replay = program.command("replay").description("read and open replays").helpGroup("Maps and replays:");
15495
16302
  replay.command("info").description("who played, on what map, for how long, at what APM").argument("<replays...>").addOption(json).action(async (files, o) => void await eachFile(files, !!o.json, replayInfo));
15496
16303
  replay.command("open").description("watch a replay in the scmJS app, or the editor in your browser").argument("<replay>").option("--browser", "the editor in the browser, even with the app installed").addOption(new Option("--editor <url>", "the editor's address (SCMJS_EDITOR_URL)").hideHelp()).action(async (file, o) => {
15497
16304
  const input = await readInput(file);
15498
16305
  expectKind(input, "replay");
15499
16306
  await openInScmjs(input, o);
15500
16307
  });
16308
+ var plugin = program.command("plugin").description("start, check and run a plugin for the editor").helpGroup("Plugins and bots:");
16309
+ 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) => {
16310
+ const made = newPlugin({
16311
+ name,
16312
+ dir: o.dir
16313
+ });
16314
+ const rel = relative(process.cwd(), made.dir) || ".";
16315
+ 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/"));
16316
+ });
16317
+ 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) => {
16318
+ const c = checkPlugin(folder);
16319
+ if (c.issues.some((i) => i.level === "error" || o.strict && i.level === "warn")) process.exitCode = 1;
16320
+ print(...o.json ? [JSON.stringify(c, null, 2)] : printPluginCheck(c, !!o.all));
16321
+ });
16322
+ 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) => {
16323
+ const port = Number(o.port);
16324
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliError(`--port ${o.port} is not a port`, null, 2);
16325
+ await devPlugin({
16326
+ dir: folder,
16327
+ port,
16328
+ open: o.open,
16329
+ editor: o.editor
16330
+ });
16331
+ });
16332
+ var bot = program.command("bot").description("build a BWAPI bot for scmjs.dev/play (Docker)").helpGroup("Plugins and bots:").addHelpText("after", () => `
16333
+ Run these in your bot's folder. The toolchain is a Docker image, downloaded the first time.
16334
+ A built bot is added on the play page: Options → Community bots → Add a bot from a file…
16335
+
16336
+ ${import_picocolors.default.dim(`Guide: ${BOT_GUIDE}`)}`);
16337
+ bot.command("new").description("a bot to start from, in this folder").action(async () => {
16338
+ await runBot(["new"]);
16339
+ });
16340
+ 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) => {
16341
+ await runBot(project ? ["init", project] : ["init"]);
16342
+ });
16343
+ bot.command("build").description("out/<key>.scmbot, to add on the play page").action(async () => {
16344
+ await runBot(["build"]);
16345
+ });
16346
+ bot.command("update").description("the newest toolchain, the one the play page expects").action(async () => {
16347
+ await updateBot();
16348
+ });
16349
+ program.command("about").description("this version, any newer one, and links").helpGroup("This tool:").addOption(json).action(async (o) => {
16350
+ const r = await aboutReport();
16351
+ print(...o.json ? [JSON.stringify(r, null, 2)] : printAbout(r));
16352
+ });
15501
16353
  try {
15502
- await program.parseAsync();
16354
+ if (process.argv.length <= 2) program.outputHelp();
16355
+ else await program.parseAsync();
15503
16356
  } catch (e) {
15504
16357
  if (e instanceof CliError) {
15505
16358
  fail(e);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@scm-js/cli",
3
- "version": "0.8.8",
4
- "description": "StarCraft: Brood War maps and replays from the command line: what a map or replay holds, Check Map, and opening them in scmJS",
3
+ "version": "0.8.10",
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"