@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.
@@ -1,4 +1,4 @@
1
- Third-party notices for scmjs 0.8.8
1
+ Third-party notices for scmjs 0.8.9
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,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.8";
6786
- var COMMIT = "a48a77c";
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 replays from the command line, by scmJS"),
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 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).
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
- 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({
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.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.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"