@heroiclands/package-build 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/container.mjs ADDED
@@ -0,0 +1,699 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Running a built package inside a Foundry VTT container.
16
+ *
17
+ * **The seam is the deploy's own.** `package-build deploy <stage>` installs a
18
+ * staged package into `FOUNDRYVTT_<STAGE>_DATA`; this module bind-mounts that
19
+ * same directory at `/data` and serves it. Nothing about the destination is
20
+ * restated — running Foundry against what was just deployed is the next step
21
+ * from one variable.
22
+ *
23
+ * The container runs the community `felddy/foundryvtt` image, which downloads
24
+ * the correct Foundry build for its platform at run time. A local Foundry
25
+ * install is deliberately **not** mounted: Foundry's Node distribution bundles
26
+ * per-platform native modules (`better-sqlite3`, `classic-level`), so a macOS
27
+ * install cannot run inside a Linux container.
28
+ *
29
+ * Licensing and provisioning are left to the image. Every `FOUNDRY_*` and
30
+ * `CONTAINER_*` variable in the environment is passed through, so credentials,
31
+ * a timed `FOUNDRY_RELEASE_URL`, or a pre-seeded cache are all a matter of
32
+ * configuration rather than code. See https://hub.docker.com/r/felddy/foundryvtt.
33
+ *
34
+ * **The environment is baked in at create time.** `FOUNDRY_*` values are fixed
35
+ * when the container is first created; a plain `start` or `restart` does not
36
+ * pick up a change to one. `recreate` is what applies it.
37
+ *
38
+ * The rules are pure functions over data — what a stage resolves to, which
39
+ * build a run pins, the argument vector that follows. The functions that talk
40
+ * to `docker` or the filesystem are named for it.
41
+ *
42
+ * @module
43
+ */
44
+
45
+ import fs from "node:fs";
46
+ import path from "node:path";
47
+ import process from "node:process";
48
+ import { spawnSync } from "node:child_process";
49
+
50
+ /**
51
+ * Host port each conventional stage publishes, chosen so they can run at once.
52
+ *
53
+ * A stage a repository adds of its own declares its port in
54
+ * `packageBuild.container.stages`; these four need no entry because every
55
+ * HeroicLands package deploys to the same four.
56
+ */
57
+ export const DEFAULT_STAGE_PORTS = Object.freeze({
58
+ dev: 30000,
59
+ qa: 30001,
60
+ prod: 30002,
61
+ test: 30003,
62
+ });
63
+
64
+ /** The port Foundry listens on inside the container. */
65
+ export const CONTAINER_PORT = 30000;
66
+
67
+ /**
68
+ * Where a host-provided download cache is mounted.
69
+ *
70
+ * A dedicated mount point rather than a subpath of `/data` keeps the cache
71
+ * independent of the data root, so one cache can serve every stage.
72
+ */
73
+ export const CACHE_MOUNT = "/container_cache";
74
+
75
+ /** What `package-build container` can be asked to do. */
76
+ export const CONTAINER_ACTIONS = Object.freeze([
77
+ "start",
78
+ "stop",
79
+ "restart",
80
+ "recreate",
81
+ "rm",
82
+ "status",
83
+ "logs",
84
+ "pull",
85
+ ]);
86
+
87
+ /**
88
+ * The image tag used when the package claims no Foundry version at all.
89
+ *
90
+ * Deliberately not a major: a hard-coded `:14` here would be this package
91
+ * choosing a Foundry generation on every consumer's behalf, and would rot on
92
+ * the next one. A package that states a `compatibility.minimum` gets its own
93
+ * major; one that states nothing floats, visibly.
94
+ */
95
+ const FLOATING_IMAGE = "felddy/foundryvtt:release";
96
+
97
+ /**
98
+ * The environment variable naming a stage's Foundry data root.
99
+ *
100
+ * Derived rather than tabulated, so a repository that adds a stage of its own
101
+ * gets the variable without this package learning its name.
102
+ *
103
+ * @param {string} stage - The stage name.
104
+ * @returns {string} The variable to read.
105
+ */
106
+ export function dataEnvVar(stage) {
107
+ return `FOUNDRYVTT_${stage.toUpperCase()}_DATA`;
108
+ }
109
+
110
+ /**
111
+ * The container name for a package's stage.
112
+ *
113
+ * Named after the package so two HeroicLands packages can run their own
114
+ * containers side by side, and stable so Foundry's signed licence — which is
115
+ * bound to the container hostname — survives a recreate.
116
+ *
117
+ * @param {string} packageId - The Foundry package id.
118
+ * @param {string} stage - The stage name.
119
+ * @returns {string} The container name.
120
+ */
121
+ export function containerName(packageId, stage) {
122
+ return `${packageId}-foundry-${stage}`;
123
+ }
124
+
125
+ /**
126
+ * A stage declared in `packageBuild.container.stages`.
127
+ *
128
+ * @typedef {object} ContainerStage
129
+ * @property {number|null} port Host port to publish.
130
+ * @property {string|null} world World to auto-launch; `""` forces none.
131
+ * @property {string|null} version Exact Foundry build to pin.
132
+ */
133
+
134
+ /**
135
+ * The host port a stage publishes.
136
+ *
137
+ * `FOUNDRYVTT_<STAGE>_PORT` first, then the stage's declared port, then the
138
+ * conventional default.
139
+ *
140
+ * @param {string} stage - The stage name.
141
+ * @param {object} [opts]
142
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
143
+ * @param {Record<string, ContainerStage>} [opts.stages] - Declared stages.
144
+ * @returns {number} The host port.
145
+ * @throws {Error} When the stage has no port from any source.
146
+ */
147
+ export function resolveStagePort(
148
+ stage,
149
+ { env = process.env, stages = {} } = {},
150
+ ) {
151
+ const fromEnv = env[`FOUNDRYVTT_${stage.toUpperCase()}_PORT`]?.trim();
152
+ const port =
153
+ fromEnv ? Number(fromEnv)
154
+ : stages[stage]?.port != null ? stages[stage].port
155
+ : DEFAULT_STAGE_PORTS[stage];
156
+ if (!Number.isFinite(port)) {
157
+ throw new Error(
158
+ `No host port for stage '${stage}'. Declare it under ` +
159
+ `\`packageBuild.container.stages.${stage}.port\`, or set ` +
160
+ `FOUNDRYVTT_${stage.toUpperCase()}_PORT.`,
161
+ );
162
+ }
163
+ return Number(port);
164
+ }
165
+
166
+ /** An exact Foundry build: a major and a build number, nothing else. */
167
+ const EXACT_BUILD = /^\d+\.\d+$/;
168
+
169
+ /**
170
+ * The exact Foundry build a stage is pinned to, or `null` to float.
171
+ *
172
+ * `FOUNDRYVTT_<STAGE>_VERSION` wins — that is how a sweep runs against a build
173
+ * without touching committed configuration. Then the stage's own declared
174
+ * version. Then, **for the end-to-end stage only**, the package's
175
+ * `compatibility.minimum`.
176
+ *
177
+ * That last rule is the point. `compatibility.minimum` is a promise the
178
+ * manifest makes to every user, and a promise is only defended if something
179
+ * exercises it: a regression that breaks the floor while working on a newer
180
+ * build passes a suite run above the floor in silence. Deriving the pin from
181
+ * the claim means the evidence and the claim are the same number, and neither
182
+ * can drift from the other.
183
+ *
184
+ * A floor that names no build (`"14"`) cannot pin one, so the run floats on the
185
+ * major tag — visibly, rather than by pretending to a precision it lacks.
186
+ *
187
+ * @param {string} stage - The stage name.
188
+ * @param {object} [opts]
189
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
190
+ * @param {Record<string, ContainerStage>} [opts.stages] - Declared stages.
191
+ * @param {string|null} [opts.compatibilityMinimum] - The claimed floor.
192
+ * @param {string} [opts.e2eStage] - Which stage the suite runs against.
193
+ * @returns {string|null} The exact build, or `null`.
194
+ */
195
+ export function resolveFoundryVersion(
196
+ stage,
197
+ {
198
+ env = process.env,
199
+ stages = {},
200
+ compatibilityMinimum = null,
201
+ e2eStage = "test",
202
+ } = {},
203
+ ) {
204
+ const fromEnv = env[`FOUNDRYVTT_${stage.toUpperCase()}_VERSION`]?.trim();
205
+ if (fromEnv) return fromEnv;
206
+ const declared = stages[stage]?.version;
207
+ if (declared) return declared;
208
+ if (stage !== e2eStage) return null;
209
+ return EXACT_BUILD.test(compatibilityMinimum ?? "") ?
210
+ /** @type {string} */ (compatibilityMinimum)
211
+ : null;
212
+ }
213
+
214
+ /**
215
+ * The image a run uses.
216
+ *
217
+ * `FOUNDRYVTT_CONTAINER_IMAGE` wins, then `packageBuild.container.image`, then
218
+ * felddy's major tag for whichever version the package already implies — the
219
+ * pinned build, or failing that the compatibility floor. Only a package that
220
+ * claims neither floats.
221
+ *
222
+ * @param {object} [opts]
223
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
224
+ * @param {string|null} [opts.image] - The configured image.
225
+ * @param {string|null} [opts.version] - The pinned build, if any.
226
+ * @param {string|null} [opts.compatibilityMinimum] - The claimed floor.
227
+ * @returns {string} The image reference.
228
+ */
229
+ export function resolveImage({
230
+ env = process.env,
231
+ image = null,
232
+ version = null,
233
+ compatibilityMinimum = null,
234
+ } = {}) {
235
+ const explicit = env.FOUNDRYVTT_CONTAINER_IMAGE?.trim();
236
+ if (explicit) return explicit;
237
+ if (image) return image;
238
+ const major = (version ?? compatibilityMinimum ?? "").split(".")[0];
239
+ return major ? `felddy/foundryvtt:${major}` : FLOATING_IMAGE;
240
+ }
241
+
242
+ /**
243
+ * The world a stage auto-launches.
244
+ *
245
+ * `null` leaves `FOUNDRY_WORLD` alone, so whatever the image was given decides.
246
+ * An empty string is a *declared* "never auto-launch" — the shape a stage whose
247
+ * world is managed by hand needs, and distinct from saying nothing.
248
+ *
249
+ * @param {string} stage - The stage name.
250
+ * @param {object} [opts]
251
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
252
+ * @param {Record<string, ContainerStage>} [opts.stages] - Declared stages.
253
+ * @returns {string|null} The world id, `""`, or `null`.
254
+ */
255
+ export function resolveWorld(stage, { env = process.env, stages = {} } = {}) {
256
+ const fromEnv = env[`FOUNDRYVTT_${stage.toUpperCase()}_WORLD`];
257
+ if (fromEnv !== undefined) return fromEnv.trim();
258
+ const declared = stages[stage]?.world;
259
+ return declared === undefined || declared === null ?
260
+ null
261
+ : String(declared);
262
+ }
263
+
264
+ /**
265
+ * A stage's dedicated Foundry licence key, if it has one.
266
+ *
267
+ * Foundry is single-seat, so running a `dev` and a `test` container at once
268
+ * needs two keys. `FOUNDRYVTT_<STAGE>_LICENSE_KEY` dedicates one to a stage,
269
+ * overriding any global `FOUNDRY_LICENSE_KEY` passed through.
270
+ *
271
+ * @param {string} stage - The stage name.
272
+ * @param {NodeJS.ProcessEnv} [env] - Environment to read.
273
+ * @returns {string|null} The key, or `null`.
274
+ */
275
+ export function resolveLicenseKey(stage, env = process.env) {
276
+ return env[`FOUNDRYVTT_${stage.toUpperCase()}_LICENSE_KEY`]?.trim() || null;
277
+ }
278
+
279
+ /**
280
+ * The Foundry data root for a stage, checked for what a bind mount needs.
281
+ *
282
+ * @param {string} stage - The stage name.
283
+ * @param {object} [opts]
284
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
285
+ * @returns {string} The local path.
286
+ * @throws {Error} When it is unset, or names a remote host.
287
+ */
288
+ export function resolveDataRoot(stage, { env = process.env } = {}) {
289
+ const variable = dataEnvVar(stage);
290
+ const dataRoot = env[variable]?.trim() ?? "";
291
+ if (!dataRoot) {
292
+ throw new Error(
293
+ `No data directory configured for stage '${stage}'. Set ` +
294
+ `${variable} — for example ${variable}="/path/to/foundryvtt/data".`,
295
+ );
296
+ }
297
+ // A remote SFTP target (`[user@]host:/path`) is a perfectly good deploy
298
+ // destination and an impossible bind mount.
299
+ const colon = dataRoot.indexOf(":");
300
+ const isWindowsPath = /^[A-Za-z]:[\\/]/.test(dataRoot);
301
+ if (!dataRoot.startsWith("/") && !isWindowsPath && colon > 0) {
302
+ throw new Error(
303
+ `${variable} is a remote target ('${dataRoot}'). A container needs ` +
304
+ `a local path to bind-mount.`,
305
+ );
306
+ }
307
+ return dataRoot;
308
+ }
309
+
310
+ /**
311
+ * The image's own environment variables, as key/value pairs.
312
+ *
313
+ * `CONTAINER_CACHE` is deliberately withheld: it names a path *inside* the
314
+ * container, and {@link dockerRunArgs} sets it to match a mount it actually
315
+ * makes. A host path from a `.env` file would otherwise reach the image naming
316
+ * a directory that is not there.
317
+ *
318
+ * @param {NodeJS.ProcessEnv} [env] - Environment to read.
319
+ * @returns {[string, string][]} The pairs to pass through.
320
+ */
321
+ export function passthroughEnv(env = process.env) {
322
+ const pairs = [];
323
+ for (const [key, value] of Object.entries(env)) {
324
+ if (value == null) continue;
325
+ const passes =
326
+ key.startsWith("FOUNDRY_") ||
327
+ (key.startsWith("CONTAINER_") && key !== "CONTAINER_CACHE");
328
+ if (passes) pairs.push([key, value]);
329
+ }
330
+ return pairs;
331
+ }
332
+
333
+ /**
334
+ * The full `docker run` argument vector for a stage's container.
335
+ *
336
+ * Order matters at the end: docker takes the **last** `-e` for a repeated key,
337
+ * so the per-stage version, world and licence are appended after the
338
+ * passthrough and win over anything it carried.
339
+ *
340
+ * @param {object} opts
341
+ * @param {string} opts.name - Container name.
342
+ * @param {string} opts.image - Image reference.
343
+ * @param {number} opts.port - Host port to publish.
344
+ * @param {string} opts.dataRoot - Host directory to mount at `/data`.
345
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to pass through.
346
+ * @param {string|null} [opts.cacheDir] - Host download cache to mount.
347
+ * @param {string|null} [opts.version] - Exact build to pin.
348
+ * @param {string|null} [opts.world] - World to auto-launch; `""` forces none.
349
+ * @param {string|null} [opts.licenseKey] - Dedicated licence key.
350
+ * @returns {string[]} Arguments after `docker`.
351
+ */
352
+ export function dockerRunArgs({
353
+ name,
354
+ image,
355
+ port,
356
+ dataRoot,
357
+ env = process.env,
358
+ cacheDir = null,
359
+ version = null,
360
+ world = null,
361
+ licenseKey = null,
362
+ }) {
363
+ const args = [
364
+ "run",
365
+ "--detach",
366
+ "--name",
367
+ name,
368
+ // Foundry binds a signed licence to the hostname. Without a stable one
369
+ // docker assigns a fresh container id each run and the licence reverts
370
+ // to "requires signature" on every recreate.
371
+ "--hostname",
372
+ name,
373
+ "--publish",
374
+ `${port}:${CONTAINER_PORT}`,
375
+ "--volume",
376
+ `${dataRoot}:/data`,
377
+ ];
378
+ if (cacheDir) {
379
+ args.push(
380
+ "--volume",
381
+ `${cacheDir}:${CACHE_MOUNT}`,
382
+ "-e",
383
+ `CONTAINER_CACHE=${CACHE_MOUNT}`,
384
+ );
385
+ }
386
+ for (const [key, value] of passthroughEnv(env)) {
387
+ args.push("-e", `${key}=${value}`);
388
+ }
389
+ if (version) args.push("-e", `FOUNDRY_VERSION=${version}`);
390
+ if (world !== null) args.push("-e", `FOUNDRY_WORLD=${world}`);
391
+ if (licenseKey) args.push("-e", `FOUNDRY_LICENSE_KEY=${licenseKey}`);
392
+ args.push(image);
393
+ return args;
394
+ }
395
+
396
+ /**
397
+ * Run the `docker` CLI, inheriting stdio.
398
+ *
399
+ * @param {string[]} args - Arguments after `docker`.
400
+ * @returns {number} The exit status.
401
+ * @throws {Error} When `docker` is not on `PATH`.
402
+ */
403
+ export function runDocker(args) {
404
+ const result = spawnSync("docker", args, { stdio: "inherit" });
405
+ if (result.error) {
406
+ if (
407
+ /** @type {NodeJS.ErrnoException} */ (result.error).code ===
408
+ "ENOENT"
409
+ ) {
410
+ throw new Error(
411
+ "docker was not found on PATH. Install Docker and make sure " +
412
+ "the `docker` CLI is available.",
413
+ );
414
+ }
415
+ throw result.error;
416
+ }
417
+ return result.status ?? 0;
418
+ }
419
+
420
+ /**
421
+ * Run the `docker` CLI and capture its output, tolerating failure.
422
+ *
423
+ * @param {string[]} args - Arguments after `docker`.
424
+ * @returns {string} Trimmed stdout, or `""` when the command failed.
425
+ */
426
+ export function captureDocker(args) {
427
+ const result = spawnSync("docker", args, { encoding: "utf8" });
428
+ if (result.error || result.status !== 0) return "";
429
+ return (result.stdout ?? "").trim();
430
+ }
431
+
432
+ /**
433
+ * @param {string} name - Container name.
434
+ * @param {boolean} [runningOnly] - Only count a running container.
435
+ * @returns {boolean} Whether a container with exactly this name is present.
436
+ */
437
+ export function containerExists(name, runningOnly = false) {
438
+ const args = ["ps"];
439
+ if (!runningOnly) args.push("-a");
440
+ args.push("--filter", `name=^${name}$`, "--format", "{{.Names}}");
441
+ return captureDocker(args).split("\n").includes(name);
442
+ }
443
+
444
+ /**
445
+ * The HeroicLands-convention Foundry containers currently running.
446
+ *
447
+ * Used to warn about a single-seat licence clash before a run starts, which is
448
+ * why the filter is the shared `-foundry-` convention rather than one package's
449
+ * prefix: the clash is between *any* two licensed instances, not between two
450
+ * containers of the same package.
451
+ *
452
+ * @param {string} [except] - A container to omit from the list.
453
+ * @returns {string[]} The container names.
454
+ */
455
+ export function runningFoundryContainers(except = "") {
456
+ return captureDocker([
457
+ "ps",
458
+ "--filter",
459
+ "name=-foundry-",
460
+ "--format",
461
+ "{{.Names}}",
462
+ ])
463
+ .split("\n")
464
+ .filter((name) => name && name !== except);
465
+ }
466
+
467
+ /**
468
+ * Remove the data-root lock a container left behind when it did not shut down
469
+ * cleanly (`docker rm -f`, a crash, an OOM).
470
+ *
471
+ * Foundry then refuses to start with "already locked by another process",
472
+ * naming no owner — so a stale lock turns every later boot into a failure that
473
+ * reads like corruption rather than litter.
474
+ *
475
+ * **Only safe while the container is stopped**, which is exactly when every
476
+ * boot path here calls it: with nothing running against the data root, a lock
477
+ * present is by definition stale.
478
+ *
479
+ * @param {string} dataRoot - The Foundry user-data root.
480
+ * @param {(message: string) => void} [log] - Progress reporting.
481
+ */
482
+ export function clearStaleLock(dataRoot, log = () => {}) {
483
+ const lock = path.join(dataRoot, "Config", "options.json.lock");
484
+ if (!fs.existsSync(lock)) return;
485
+ fs.rmSync(lock, { recursive: true, force: true });
486
+ log(`Cleared stale Foundry lock: ${lock}`);
487
+ }
488
+
489
+ /**
490
+ * Everything a container action needs, resolved from configuration and the
491
+ * environment once.
492
+ *
493
+ * @typedef {object} ResolvedContainer
494
+ * @property {string} stage
495
+ * @property {string} name
496
+ * @property {number} port
497
+ * @property {string} image
498
+ * @property {string} url
499
+ * @property {string|null} version
500
+ * @property {string|null} world
501
+ * @property {string|null} licenseKey
502
+ * @property {string|null} cacheDir
503
+ */
504
+
505
+ /**
506
+ * Resolve a stage's container settings.
507
+ *
508
+ * @param {object} opts
509
+ * @param {string} opts.stage - The stage name.
510
+ * @param {object} opts.config - The resolved package-build configuration.
511
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
512
+ * @returns {ResolvedContainer} The resolved settings.
513
+ */
514
+ export function resolveContainer({ stage, config, env = process.env }) {
515
+ const stages = config.containerStages;
516
+ const version = resolveFoundryVersion(stage, {
517
+ env,
518
+ stages,
519
+ compatibilityMinimum: config.compatibilityMinimum,
520
+ e2eStage: config.e2eStage,
521
+ });
522
+ const port = resolveStagePort(stage, { env, stages });
523
+ return {
524
+ stage,
525
+ name: containerName(config.packageId, stage),
526
+ port,
527
+ image: resolveImage({
528
+ env,
529
+ image: config.containerImage,
530
+ version,
531
+ compatibilityMinimum: config.compatibilityMinimum,
532
+ }),
533
+ url: `http://localhost:${port}`,
534
+ version,
535
+ world: resolveWorld(stage, { env, stages }),
536
+ licenseKey: resolveLicenseKey(stage, env),
537
+ cacheDir: env.FOUNDRYVTT_CACHE?.trim() || null,
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Start a stage's container, creating it when it does not exist yet.
543
+ *
544
+ * @param {ResolvedContainer} container - Resolved settings.
545
+ * @param {object} opts
546
+ * @param {string} opts.dataRoot - Host directory to mount at `/data`.
547
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to pass through.
548
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
549
+ * @returns {number} The exit status.
550
+ */
551
+ export function startContainer(
552
+ container,
553
+ { dataRoot, env = process.env, log = () => {} },
554
+ ) {
555
+ if (containerExists(container.name)) {
556
+ // Nothing is running against this data root — we are about to start it
557
+ // — so any lock present was left by a previous crash.
558
+ if (!containerExists(container.name, true))
559
+ clearStaleLock(dataRoot, log);
560
+ log(
561
+ `Starting existing container '${container.name}' → ${container.url}`,
562
+ );
563
+ return runDocker(["start", container.name]);
564
+ }
565
+
566
+ clearStaleLock(dataRoot, log);
567
+ if (container.cacheDir && !fs.existsSync(container.cacheDir)) {
568
+ throw new Error(
569
+ `FOUNDRYVTT_CACHE directory does not exist: ${container.cacheDir}.`,
570
+ );
571
+ }
572
+ log(
573
+ `Creating container '${container.name}' from ${container.image}\n` +
574
+ ` data: ${dataRoot}\n url: ${container.url}`,
575
+ );
576
+ const status = runDocker(
577
+ dockerRunArgs({
578
+ name: container.name,
579
+ image: container.image,
580
+ port: container.port,
581
+ dataRoot,
582
+ env,
583
+ cacheDir: container.cacheDir,
584
+ version: container.version,
585
+ world: container.world,
586
+ licenseKey: container.licenseKey,
587
+ }),
588
+ );
589
+ if (status === 0) {
590
+ log(
591
+ `Started. Open ${container.url} (a first run installs Foundry — ` +
592
+ `see \`container logs\`).`,
593
+ );
594
+ }
595
+ return status;
596
+ }
597
+
598
+ /**
599
+ * Stop and remove a container, tolerating "not running" and "no such
600
+ * container".
601
+ *
602
+ * @param {string} name - Container name.
603
+ * @param {(message: string) => void} [log] - Progress reporting.
604
+ */
605
+ export function removeContainer(name, log = () => {}) {
606
+ if (!containerExists(name)) {
607
+ log(`No container '${name}' to remove.`);
608
+ return;
609
+ }
610
+ runDocker(["stop", name]);
611
+ runDocker(["rm", name]);
612
+ }
613
+
614
+ /**
615
+ * Perform one container action for a stage.
616
+ *
617
+ * @param {object} opts
618
+ * @param {string} opts.action - One of {@link CONTAINER_ACTIONS}.
619
+ * @param {string} opts.stage - The stage name.
620
+ * @param {object} opts.config - The resolved package-build configuration.
621
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
622
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
623
+ * @returns {number} The exit status.
624
+ * @throws {Error} On an unknown action, or a stage with no usable data root.
625
+ */
626
+ export function containerAction({
627
+ action,
628
+ stage,
629
+ config,
630
+ env = process.env,
631
+ log = () => {},
632
+ }) {
633
+ if (!CONTAINER_ACTIONS.includes(action)) {
634
+ throw new Error(
635
+ `Invalid container action '${action}'. Valid actions are: ` +
636
+ `${CONTAINER_ACTIONS.join(", ")}.`,
637
+ );
638
+ }
639
+ const container = resolveContainer({ stage, config, env });
640
+
641
+ switch (action) {
642
+ case "start": {
643
+ const dataRoot = requireDataRoot(stage, env);
644
+ return startContainer(container, { dataRoot, env, log });
645
+ }
646
+ case "stop":
647
+ return runDocker(["stop", container.name]);
648
+ case "restart": {
649
+ // Not `docker restart`: that leaves no window in which to sweep the
650
+ // lock, so a container that died holding one could never be
651
+ // restarted back into a working state.
652
+ const dataRoot = requireDataRoot(stage, env);
653
+ runDocker(["stop", container.name]);
654
+ clearStaleLock(dataRoot, log);
655
+ return runDocker(["start", container.name]);
656
+ }
657
+ case "recreate": {
658
+ // The image's environment is fixed at `docker run` time, so this is
659
+ // what applies a changed FOUNDRY_WORLD, licence or cache.
660
+ removeContainer(container.name, log);
661
+ const dataRoot = requireDataRoot(stage, env);
662
+ clearStaleLock(dataRoot, log);
663
+ return startContainer(container, { dataRoot, env, log });
664
+ }
665
+ case "rm":
666
+ removeContainer(container.name, log);
667
+ return 0;
668
+ case "status":
669
+ return runDocker([
670
+ "ps",
671
+ "-a",
672
+ "--filter",
673
+ `name=^${container.name}$`,
674
+ ]);
675
+ case "logs":
676
+ return runDocker(["logs", "-f", container.name]);
677
+ default:
678
+ return runDocker(["pull", container.image]);
679
+ }
680
+ }
681
+
682
+ /**
683
+ * The stage's data root, checked for existence as well as shape.
684
+ *
685
+ * @param {string} stage - The stage name.
686
+ * @param {NodeJS.ProcessEnv} env - Environment to read.
687
+ * @returns {string} The local path.
688
+ * @throws {Error} When it is unset, remote, or absent from disk.
689
+ */
690
+ function requireDataRoot(stage, env) {
691
+ const dataRoot = resolveDataRoot(stage, { env });
692
+ if (!fs.existsSync(dataRoot)) {
693
+ throw new Error(
694
+ `Data directory does not exist: ${dataRoot} (from ` +
695
+ `${dataEnvVar(stage)}).`,
696
+ );
697
+ }
698
+ return dataRoot;
699
+ }