@heroiclands/package-build 0.1.0 → 0.2.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,140 @@
1
+ # @heroiclands/package-build
2
+
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 4144291: **Release from merged changesets instead of a remembered command**
8
+
9
+ Fixes [#4](https://github.com/HeroicLands/package-build/issues/4). Releasing was
10
+ hand-driven — bump `package.json` on a branch, merge, then remember
11
+ `gh release create`, because cutting the Release is what published. Nothing
12
+ enforced the last step, so a merged version could sit unpublished with no check
13
+ red; the sibling repository lost two versions that way.
14
+
15
+ - Every pull request now declares its bump as a `.changeset/*.md` file, and CI's
16
+ **Changeset declared** job fails one that does not. `npx changeset add --empty`
17
+ is how a change says it needs no release — explicitly, rather than by omission.
18
+ - Merging to `main` opens a **Version Packages** pull request carrying the bump
19
+ and the rewritten `CHANGELOG.md`. An unreleased state is now a pull request
20
+ waiting in the queue rather than nothing at all.
21
+ - Merging that runs `changeset publish`: npm publish, the `v<version>` tag, and
22
+ the GitHub Release with the changelog section as its body. The OIDC Trusted
23
+ Publishing step is unchanged and still last; there is still no `NPM_TOKEN`, and
24
+ re-running on a published version is a no-op.
25
+ - `CHANGELOG.md` is seeded from the two hand-cut Releases so far and now ships
26
+ with the package. A changeset is also where a raised dependency floor gets
27
+ recorded — 0.2.0 raised one to `@heroiclands/content-build >= 0.15.0` and said
28
+ so nowhere.
29
+
30
+ <!-- Sections at 0.2.1 and above are generated by `changeset version` from the
31
+ changesets merged into `main`. Sections at 0.2.0 and below predate that
32
+ pipeline and are the hand-written GitHub Release notes, kept verbatim
33
+ (headings demoted one level to sit under their version) so no history was
34
+ lost in adopting it. -->
35
+
36
+ ## 0.2.0
37
+
38
+ _2026-08-22 — a command line, not a wrapper script per job_
39
+
40
+ **A command line, so a consumer writes configuration instead of scripts.**
41
+
42
+ This package was library-only, so every consuming repository wrote a wrapper script per job — six of them in the Song of Heroic Lands repository, 441 lines that between them contained no logic:
43
+
44
+ | Wrapper | Lines | What was in it |
45
+ | --- | --- | --- |
46
+ | `build-system-json.mjs` | 143 | |
47
+ | `push-stage.mjs` | 76 | argv, dotenv, then `packageKind: "systems"` and `packageId: "sohl"` hard-coded beside a configuration that already declared both |
48
+ | `copy-assets.mjs` | 71 | a nine-entry data table, plus one genuine repository-specific transform |
49
+ | `check-lang.mjs` | 69 | a glob, a call, and a help string |
50
+ | `clean.mjs` | 47 | a `repoRoot` from `import.meta.url`, one flag, one call — no consumer-specific value at all |
51
+ | `pack-release.mjs` | 35 | one call passing `{ artifact: "system" }`, which `packageKind` already decides |
52
+
53
+ Every copy had drifted from its sibling in the other repositories, because copies do: `clean.mjs` was 47 lines in SoHL and 48 in `sohl-thalorna`, `copy-assets.mjs` 71 and 76. `sohl-thalorna`'s copy still reimplements the recursive directory copy this package has exported since it was extracted, because it was written before the extraction and nobody went back.
54
+
55
+ It is the same shape the configuration had before it became data: not logic, but the boilerplate a code file needs in order to *state a literal*. So the literals moved into configuration, and the boilerplate lives here, once.
56
+
57
+ #### The commands
58
+
59
+ ```
60
+ npx package-build clean [--distclean]
61
+ npx package-build assets
62
+ npx package-build lang check
63
+ npx package-build release
64
+ npx package-build deploy <stage>
65
+ ```
66
+
67
+ Wrapped as npm scripts:
68
+
69
+ ```json
70
+ {
71
+ "clean": "package-build clean",
72
+ "build:assets": "package-build assets",
73
+ "lint:lang": "package-build lang check",
74
+ "build:pack-release": "package-build release",
75
+ "push:qa": "package-build deploy qa"
76
+ }
77
+ ```
78
+
79
+ #### One configuration file, not two
80
+
81
+ Settings come from the reserved `packageBuild:` section of `content-build.config.yaml` — the file a repository already has — which requires **`@heroiclands/content-build` 0.15.0 or later**.
82
+
83
+ ```yaml
84
+ packageKind: systems # read from the top level, never restated below
85
+ foundryPackage: sohl
86
+
87
+ packageBuild:
88
+ assets:
89
+ - { from: lang, to: lang }
90
+ assetTransform: ./utils/svg-theme.mjs
91
+ clean:
92
+ extra: [site/content, site/public]
93
+ deploy:
94
+ envPrefix: SOHL
95
+ ```
96
+
97
+ A second config file would have restated `packageKind` and `foundryPackage`, which is two places for one fact. content-build validates only that the section is a mapping and hands it back frozen; everything inside it is validated here, so neither package learns the other's schema.
98
+
99
+ **Derived, never stated:** the repository root (the config file's own location), `packageKind` and `packageId` (the shared configuration's top level), and the release artifact — a system ships `system.json`, a module `module.json`, so the kind already decides it.
100
+
101
+ **The one genuine piece of consumer code stays the consumer's.** SoHL rewrites each SVG's hard-coded fill so icons follow the Foundry theme; `packageBuild.assetTransform` names a module exporting `transform(sourcePath) -> string | null`.
102
+
103
+ #### Also in this release
104
+
105
+ - `exports["./config"]` now ships its type declaration. It was declared but never generated — `tsconfig.dts.json` lists its inputs explicitly and `config.mjs` was missing from the list — so the subpath would have pointed at a file that does not exist.
106
+ - The library modules are unchanged. Every signature the CLI needed was already there.
107
+
108
+ #### Notes
109
+
110
+ `--version` and `--help` answer in a directory with no configuration at all. Failures report one line rather than a stack, whether the handler was synchronous or asynchronous. Localization findings follow the diagnostics contract, `file:line:column: severity: message`.
111
+
112
+ **Full changelog:** https://github.com/HeroicLands/package-build/compare/v0.1.0...v0.2.0
113
+
114
+ ## 0.1.0
115
+
116
+ _2026-08-22 — the Foundry package toolchain_
117
+
118
+ The counterpart to [`@heroiclands/content-build`](https://github.com/HeroicLands/content-build). The two split by **input**, not by repository: content-build reads `assets/content/**` and answers for what a package *says*; this one reads `lang/`, `styles/`, `src/`, `assets/` and the manifest template, and answers for what a package *is* — the parts Foundry loads whether or not the package ships any content.
119
+
120
+ A module uses either, or both. An adventure module that ships only notes needs no bundler; a variant module that ships only behavior needs no Markdown pipeline. The coupling runs one way: package-build asks content-build for the compiled `packs[]` block, never the reverse.
121
+
122
+ ### Seven modules — the whole of assemble → validate → ship
123
+
124
+ - **`manifest`** — `system.json` / `module.json`. The artifact is inferred from the template's name, and every address is derived from `package.json`'s `repository` rather than transcribed. Handles the `git+https://….git` spelling npm writes, which yields a 404 on every Foundry update check if left in place.
125
+ - **`stage`** — assembling the build stage and clearing it away. A listed asset that does not exist **fails the build** instead of shipping a package that quietly lacks its localization or templates; the whole list is checked before anything is copied.
126
+ - **`lang`** — what a shippable localization file must satisfy: it parses, its top level is an object, no key is both a leaf and a dotted prefix of another (which makes Foundry discard the entire file), placeholders are single-braced, key segments carry no data.
127
+ - **`bundle`** — whether the manifest agrees with the file it points at. Declared under `"esmodules"` the bundle must parse as a module; declared under `"scripts"` it must declare **nothing** at top level, because each top-level declaration in a classic script is a global lexical binding and one colliding with a non-configurable `window` property throws at parse time. That rule bricked SoHL v0.8.0.
128
+ - **`release`** — the two assets a GitHub Release carries. Waits for the archive to be *written*, not merely finalized.
129
+ - **`deploy`** — installing into a Foundry data directory, local or SFTP, always as a staged atomic swap: a running Foundry holds its LevelDB packs open, and replacing them in place leaves a directory LevelDB "repairs" to zero.
130
+ - **`text`** — locating a literal, so a finding names its line and column.
131
+
132
+ ### Design
133
+
134
+ The rules are pure functions over data; the functions that touch disk or a network are named for what they do. That is what makes them testable at all — the scripts they were extracted from each ran their work at import time and exported nothing.
135
+
136
+ ### Provenance
137
+
138
+ Developed as a workspace inside the Song of Heroic Lands repository across six changes (SoHL#1680, #1682, #1684, #1685, #1686), exactly as content-build incubated before its own extraction, and extracted now that its shape has stopped changing.
139
+
140
+ 128 tests; declaration files are emitted from the JSDoc at pack time.
package/README.md CHANGED
@@ -56,6 +56,104 @@ The whole of assemble → validate → ship, one subpath each:
56
56
  - **`text`** — locating a literal inside a file, so a finding names the line and
57
57
  column it is about.
58
58
 
59
+ ## Configure
60
+
61
+ A repository declares its build in **one** file — `content-build.config.yaml`,
62
+ the same one `content-build` reads — and this package takes its settings from
63
+ the reserved `packageBuild:` section:
64
+
65
+ ```yaml
66
+ # Read from the top level, not restated below.
67
+ packageKind: modules
68
+ foundryPackage: sohl-thalorna
69
+
70
+ packageBuild:
71
+ # Where the package is assembled. Every `to:` below is relative to it, so a
72
+ # table reads `lang`, not `build/stage/lang`.
73
+ stageDir: build/stage
74
+
75
+ assets:
76
+ - { from: lang, to: lang }
77
+ - { from: assets/icons, to: assets/icons }
78
+ - { from: LICENSE.md, to: LICENSE.md }
79
+
80
+ # Optional. A module exporting `transform(sourcePath) -> string | null`,
81
+ # applied to every staged file — `null` copies it verbatim. This is the one
82
+ # genuine piece of code in staging, and it stays the repository's: SoHL
83
+ # rewrites each SVG's hard-coded fill so icons follow the Foundry theme.
84
+ assetTransform: ./utils/svg-theme.mjs
85
+
86
+ clean:
87
+ # Beyond the conventional build artifacts, which the library already knows.
88
+ extra: [site/content, site/public, site/resources]
89
+
90
+ lang:
91
+ sources: lang/*.json
92
+ # Printed after a failure — where this repository documents its key rules.
93
+ help: See kb/dev-docs/reference/localization-keys.md.
94
+
95
+ deploy:
96
+ # Prefix of the shared SFTP override variables. Default `SOHL`.
97
+ envPrefix: SOHL
98
+ ```
99
+
100
+ **Why one file and not two.** Two of the values this package needs —
101
+ `packageKind` and `foundryPackage` — are already declared for `content-build`. A
102
+ second config file would restate them, which is two places for one fact; that is
103
+ exactly what every consumer's `push-stage.mjs` did, hard-coding
104
+ `packageKind: "systems"` and `packageId: "sohl"` beside a configuration that
105
+ already said both.
106
+
107
+ `content-build` checks only that `packageBuild:` is a mapping and hands it back
108
+ frozen. Everything inside it is validated here, so neither package learns the
109
+ other's schema — they split by input, and the dependency runs one way.
110
+
111
+ **What is derived, not stated:**
112
+
113
+ | Field | Derived from |
114
+ | -------------------------- | -------------------------------------------------------------------- |
115
+ | the repository root | the configuration file's own location |
116
+ | `packageKind`, `packageId` | the shared configuration's top level |
117
+ | the release artifact | `packageKind` — a system ships `system.json`, a module `module.json` |
118
+
119
+ ## Command line
120
+
121
+ ```
122
+ npx package-build clean [--distclean]
123
+ npx package-build assets
124
+ npx package-build lang check
125
+ npx package-build release
126
+ npx package-build deploy <stage>
127
+ ```
128
+
129
+ Wrapped as npm scripts — SoHL spells them:
130
+
131
+ ```json
132
+ {
133
+ "clean": "package-build clean",
134
+ "distclean": "package-build clean --distclean",
135
+ "build:assets": "package-build assets",
136
+ "lint:lang": "package-build lang check",
137
+ "build:pack-release": "package-build release",
138
+ "push:qa": "package-build deploy qa"
139
+ }
140
+ ```
141
+
142
+ **Why the CLI exists.** This package was library-only, so every consuming
143
+ repository wrote a wrapper script per job — six of them in the SoHL repository,
144
+ 441 lines that between them contained no logic. `clean.mjs` was 47 lines that
145
+ computed a `repoRoot` from `import.meta.url`, read one flag, and made one call.
146
+ Every copy had drifted from its sibling in the other repositories, because
147
+ copies do: `clean.mjs` was 47 lines in one and 48 in another, `copy-assets.mjs`
148
+ 71 and 76.
149
+
150
+ It is the same shape the configuration had before it became data — not logic,
151
+ but the boilerplate a code file needs in order to state a literal. The literals
152
+ moved into configuration; the boilerplate lives in the CLI, once.
153
+
154
+ `--version` and `--help` answer in a directory with no configuration at all.
155
+ Running an actual command resolves it, and fails loudly when it is missing.
156
+
59
157
  ## Design
60
158
 
61
159
  **The rules are pure, and I/O is confined to functions named for it.** A rule
@@ -100,6 +198,43 @@ would let something reach for one.
100
198
  declared `dependency`. Inside a workspace a missing declaration is invisible;
101
199
  installed from npm it fails on the first import.
102
200
 
201
+ ## Releasing
202
+
203
+ Releasing is not a command anyone runs. It is a consequence of merging, in two
204
+ steps, and each step is visible while it is pending.
205
+
206
+ **Every pull request declares its bump.** Run `npx changeset` and pick
207
+ major/minor/patch; the summary you write becomes the changelog entry and the
208
+ release note. If the change ships nothing a consumer can see, say so explicitly
209
+ with `npx changeset add --empty`. CI's **Changeset declared** job fails a pull
210
+ request that declares neither — `npm run changeset:check` is the same check,
211
+ locally.
212
+
213
+ **Merging to `main` opens a Version Packages pull request** carrying the version
214
+ bump and the rewritten `CHANGELOG.md`. That pull request _is_ the pending
215
+ release: as long as something is merged but unpublished, there is an open pull
216
+ request saying so. The previous, hand-driven process failed by leaving _nothing_
217
+ behind when its final step was forgotten (#4).
218
+
219
+ **Merging that publishes.** `changeset publish` puts the version on npm through
220
+ Trusted Publishing (OIDC — there is no `NPM_TOKEN`), tags the commit `v<version>`
221
+ and cuts the GitHub Release with the changelog section as its body. It publishes
222
+ only versions that are not already on the registry, so re-running it is a no-op;
223
+ `workflow_dispatch` on **Publish to npm** is the recovery path if a run fails
224
+ after versioning.
225
+
226
+ A changeset is also where a **raised dependency floor** gets recorded. 0.2.0 began
227
+ requiring `@heroiclands/content-build >= 0.15.0` and announced it nowhere; a
228
+ changeset is the place that now happens.
229
+
230
+ Below 1.0.0, `^0.x` never crosses a minor — a consumer on `^0.2.0` will not see
231
+ `0.3.0` until it bumps the pin deliberately, and Dependabot raises that as its own
232
+ pull request.
233
+
234
+ > After a successful publish, `npm view @heroiclands/package-build version` can
235
+ > report the _previous_ version for a minute or so. `dist-tags` is correct
236
+ > immediately, and is what the workflow prints.
237
+
103
238
  ## Licence
104
239
 
105
240
  GPL-3.0-or-later.
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
4
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
5
+ *
6
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
7
+ * You may copy, modify, and distribute it under the terms of that license.
8
+ *
9
+ * For full terms, see the LICENSE.md file in the project root or visit:
10
+ * https://www.gnu.org/licenses/gpl-3.0.html
11
+ *
12
+ * SPDX-License-Identifier: GPL-3.0-or-later
13
+ */
14
+
15
+ /**
16
+ * The `package-build` command line — clean, stage, check, package, deploy.
17
+ *
18
+ * **Why this exists.** This package was library-only, so every consuming
19
+ * repository wrote a wrapper script per job: six of them in the Song of Heroic
20
+ * Lands repository, 441 lines that between them contained no logic. `clean.mjs`
21
+ * was 47 lines that computed a `repoRoot` from `import.meta.url`, read one
22
+ * flag, and made one call. `push-stage.mjs` hard-coded `packageKind: "systems"`
23
+ * and `packageId: "sohl"` beside a configuration that already declared both.
24
+ * Each copy had drifted from its sibling in the other repositories, because
25
+ * copies do.
26
+ *
27
+ * It is the same shape the configuration had before it became data: not logic,
28
+ * but the boilerplate a code file needs in order to state a literal. So the
29
+ * literals move into `content-build.config.yaml`'s reserved `packageBuild:`
30
+ * section, and the boilerplate lives here, once.
31
+ *
32
+ * **Every side effect lives in this file.** argv parsing, the environment,
33
+ * writing to the filesystem, and the process exit code. The library modules
34
+ * stay import-safe, so a consuming repository's build — or a test — can call
35
+ * them without any of it happening.
36
+ *
37
+ * The side effects that need *configuration* live inside the command handlers,
38
+ * never at module scope, so `--version` and `--help` answer in a directory with
39
+ * no configuration at all. Running an actual command still resolves it, and
40
+ * still fails loudly when it is missing.
41
+ *
42
+ * Usage:
43
+ * npx package-build clean [--distclean]
44
+ * npx package-build assets
45
+ * npx package-build lang check
46
+ * npx package-build release
47
+ * npx package-build deploy <stage>
48
+ *
49
+ * In a consuming repository, wrapped as npm scripts — SoHL spells them:
50
+ * npm run clean // → … clean
51
+ * npm run build:assets // → … assets
52
+ * npm run lint:lang // → … lang check
53
+ * npm run build:pack-release // → … release
54
+ * npm run push:qa // → … deploy qa
55
+ */
56
+
57
+ import fs from "node:fs";
58
+ import path from "node:path";
59
+ import process from "node:process";
60
+ import { globSync } from "glob";
61
+ import yargs from "yargs";
62
+ import { hideBin } from "yargs/helpers";
63
+
64
+ import { loadPackageBuildConfig } from "../config.mjs";
65
+ import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
66
+ import { validateLangSource } from "../lang.mjs";
67
+ import { packRelease } from "../release.mjs";
68
+ import { deployStage } from "../deploy.mjs";
69
+
70
+ /**
71
+ * This package's own version, for `--version`.
72
+ *
73
+ * Read from this package's manifest rather than left to yargs, which defaults
74
+ * to the *nearest* `package.json` walking up from the working directory —
75
+ * inside a consuming repository that is the consumer's, so the CLI would report
76
+ * the consumer's version instead of the toolchain's.
77
+ *
78
+ * @returns {string} The `version` field of this package's manifest.
79
+ */
80
+ function ownVersion() {
81
+ return JSON.parse(
82
+ fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
83
+ ).version;
84
+ }
85
+
86
+ /**
87
+ * Report a failure the way a build should: one line, no stack, non-zero exit.
88
+ *
89
+ * @param {unknown} err - What went wrong.
90
+ * @returns {never}
91
+ */
92
+ function die(err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ console.error(`package-build: ${message}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ /**
99
+ * Wrap a command handler so every failure is reported the same way.
100
+ *
101
+ * yargs' own `.fail()` sees a *synchronous* handler's throw but not an async
102
+ * one's rejection, so without this a `clean` failure printed one clean line and
103
+ * a `deploy` failure printed a stack trace. A build's diagnostics should not
104
+ * depend on whether the command it ran happened to await something.
105
+ *
106
+ * @param {(args: object) => unknown} run - The handler body.
107
+ * @returns {(args: object) => Promise<void>} The wrapped handler.
108
+ */
109
+ function handler(run) {
110
+ return async (args) => {
111
+ try {
112
+ await run(args);
113
+ } catch (err) {
114
+ die(err);
115
+ }
116
+ };
117
+ }
118
+
119
+ /**
120
+ * `clean` — remove this repository's build artifacts.
121
+ *
122
+ * The conventional artifact directories are the library's; a repository that
123
+ * generates more (a site's `content/`, `public/` and `resources/`) names them
124
+ * in `packageBuild.clean.extra` rather than reimplementing the walk, which is
125
+ * what every consumer's `clean.mjs` did.
126
+ *
127
+ * @returns {object} The yargs command module.
128
+ */
129
+ function cleanCommand() {
130
+ return {
131
+ command: "clean",
132
+ describe: "Remove build artifacts",
133
+ builder: (y) =>
134
+ y.option("distclean", {
135
+ type: "boolean",
136
+ default: false,
137
+ describe: "Also remove node_modules",
138
+ }),
139
+ handler: handler((args) => {
140
+ const config = loadPackageBuildConfig();
141
+ const removed = cleanBuildArtifacts(config.rootDir, {
142
+ includeNodeModules: args.distclean,
143
+ extra: config.cleanExtra,
144
+ });
145
+ for (const dir of removed) console.log(`Removed ${dir}`);
146
+ if (!removed.length) console.log("Nothing to clean.");
147
+ }),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * `assets` — stage the repository's static files into the package root.
153
+ *
154
+ * The table is data (`packageBuild.assets`). A repository that has to *change*
155
+ * a file on the way — SoHL rewrites each SVG's hard-coded fill so icons follow
156
+ * the Foundry theme — names a module in `packageBuild.assetTransform`, whose
157
+ * `transform(sourcePath)` returns replacement text or `null` to copy verbatim.
158
+ * That is the one genuine piece of code in the job, and it stays the
159
+ * repository's.
160
+ *
161
+ * @returns {object} The yargs command module.
162
+ */
163
+ function assetsCommand() {
164
+ return {
165
+ command: "assets",
166
+ describe: "Stage static assets into the package root",
167
+ builder: (y) => y,
168
+ handler: handler(async () => {
169
+ const config = loadPackageBuildConfig();
170
+ if (!config.assets.length) {
171
+ console.log(
172
+ "package-build: no `packageBuild.assets` declared; nothing to stage.",
173
+ );
174
+ return;
175
+ }
176
+
177
+ let transform;
178
+ if (config.assetTransform) {
179
+ const module = await import(
180
+ `file://${config.assetTransform}`
181
+ ).catch((err) =>
182
+ die(
183
+ `cannot load \`packageBuild.assetTransform\` ` +
184
+ `(${config.assetTransform}): ${err.message}`,
185
+ ),
186
+ );
187
+ transform = module.transform;
188
+ if (typeof transform !== "function") {
189
+ die(
190
+ `\`packageBuild.assetTransform\` ` +
191
+ `(${config.assetTransform}) exports no \`transform\` ` +
192
+ `function. It must export ` +
193
+ `\`transform(sourcePath) -> string | null\`.`,
194
+ );
195
+ }
196
+ }
197
+
198
+ // `to:` is relative to the staged package root, so a
199
+ // repository's table reads `lang`, not `build/stage/lang`.
200
+ const entries = config.assets.map(({ from, to }) => [
201
+ from,
202
+ path.join(config.stageDir, to),
203
+ ]);
204
+ const { entries: count, files } = stageAssets(entries, {
205
+ cwd: config.rootDir,
206
+ transform,
207
+ });
208
+ console.log(
209
+ `✅ Static assets staged (${count} entries, ${files} files).`,
210
+ );
211
+ }),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * `lang check` — verify every localization file survives `expandObject`.
217
+ *
218
+ * A dotted-prefix collision makes `foundry.utils.expandObject` throw, and
219
+ * Foundry then drops the whole translation file silently. The rule is the
220
+ * library's; the glob and any repository-specific guidance are data.
221
+ *
222
+ * @returns {object} The yargs command module.
223
+ */
224
+ function langCommand() {
225
+ return {
226
+ command: "lang <action>",
227
+ describe: "Localization checks",
228
+ builder: (y) =>
229
+ y.positional("action", {
230
+ choices: ["check"],
231
+ describe: "check: verify the files are expandObject-safe",
232
+ }),
233
+ handler: handler(() => {
234
+ const config = loadPackageBuildConfig();
235
+ const files = globSync(config.langSources, {
236
+ cwd: config.rootDir,
237
+ absolute: true,
238
+ });
239
+ if (!files.length) {
240
+ die(
241
+ `no localization files matched ` +
242
+ `\`${config.langSources}\` under ${config.rootDir}.`,
243
+ );
244
+ }
245
+
246
+ let total = 0;
247
+ for (const file of files.sort()) {
248
+ const relative = path.relative(config.rootDir, file);
249
+ for (const finding of validateLangSource(
250
+ fs.readFileSync(file, "utf8"),
251
+ )) {
252
+ total++;
253
+ // The diagnostics contract: the path starts the line, and a
254
+ // field is dropped rather than guessed.
255
+ const at = [relative, finding.line, finding.column]
256
+ .filter((part) => part !== undefined && part !== null)
257
+ .join(":");
258
+ console.error(
259
+ `${at}: ${finding.severity ?? "error"}: ${finding.message}`,
260
+ );
261
+ }
262
+ }
263
+
264
+ if (total) {
265
+ if (config.langHelp) console.error(`\n${config.langHelp}`);
266
+ process.exit(1);
267
+ }
268
+ console.log(
269
+ `package-build: ${files.length} localization file(s) are ` +
270
+ `expandObject-safe.`,
271
+ );
272
+ }),
273
+ };
274
+ }
275
+
276
+ /**
277
+ * `release` — zip the staged package for a GitHub release.
278
+ *
279
+ * The artifact name is derived from `packageKind`: Foundry installs a system
280
+ * from `system.json` and a module from `module.json`, so the kind already
281
+ * decides it and no repository states it.
282
+ *
283
+ * @returns {object} The yargs command module.
284
+ */
285
+ function releaseCommand() {
286
+ return {
287
+ command: "release",
288
+ describe: "Package the staged build for release",
289
+ builder: (y) => y,
290
+ handler: handler(async () => {
291
+ const config = loadPackageBuildConfig();
292
+ const { zip, version, bytes } = await packRelease({
293
+ artifact: config.artifact,
294
+ });
295
+ console.log(
296
+ `✅ Packaged ${version} for release: ` +
297
+ `${path.relative(config.rootDir, zip)} ` +
298
+ `(${(bytes / 1024 / 1024).toFixed(1)} MB)`,
299
+ );
300
+ }),
301
+ };
302
+ }
303
+
304
+ /**
305
+ * `deploy <stage>` — push the staged package to a Foundry data directory.
306
+ *
307
+ * `packageKind` and `packageId` come from the shared configuration, where they
308
+ * were already declared. Every consumer's `push-stage.mjs` hard-coded them a
309
+ * second time, which is two places for one fact and exactly the drift this
310
+ * command removes.
311
+ *
312
+ * @returns {object} The yargs command module.
313
+ */
314
+ function deployCommand() {
315
+ return {
316
+ command: "deploy <stage>",
317
+ describe: "Deploy the staged package to a stage",
318
+ builder: (y) =>
319
+ y.positional("stage", {
320
+ type: "string",
321
+ describe: "Target stage (e.g. dev, qa, prod, test)",
322
+ }),
323
+ handler: handler(async (args) => {
324
+ const config = loadPackageBuildConfig();
325
+
326
+ // Loaded here rather than at module scope: `--help` must answer in
327
+ // a repository that has no environment file at all.
328
+ const dotenv = await import("dotenv");
329
+ dotenv.config({
330
+ path: path.join(config.rootDir, ".env.local"),
331
+ quiet: true,
332
+ });
333
+ dotenv.config({
334
+ path: path.join(config.rootDir, ".env"),
335
+ quiet: true,
336
+ });
337
+
338
+ const { stage } = await deployStage({
339
+ stage: args.stage,
340
+ source: path.join(config.rootDir, config.stageDir),
341
+ packageKind: config.packageKind,
342
+ packageId: config.packageId,
343
+ prefix: config.envPrefix,
344
+ log: (message) => console.log(message),
345
+ });
346
+ console.log(`Deployed stage '${stage}' successfully.`);
347
+ }),
348
+ };
349
+ }
350
+
351
+ yargs(hideBin(process.argv))
352
+ .scriptName("package-build")
353
+ .command(cleanCommand())
354
+ .command(assetsCommand())
355
+ .command(langCommand())
356
+ .command(releaseCommand())
357
+ .command(deployCommand())
358
+ .demandCommand(1, "Name a command.")
359
+ .strict()
360
+ .version(ownVersion())
361
+ .help()
362
+ .alias("help", "h")
363
+ .fail((message, err) => die(err ?? message)).argv;
package/config.mjs ADDED
@@ -0,0 +1,318 @@
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
+ * The per-repository configuration this package reads.
16
+ *
17
+ * **One repository, one configuration file.** A repository already declares
18
+ * itself in `content-build.config.yaml`, and two of the values this package
19
+ * needs — `packageKind` and `foundryPackage` — are already in it. A second file
20
+ * would restate them, which is two places for one fact; that is precisely what
21
+ * every consumer's `push-stage.mjs` did, hard-coding `packageKind: "systems"`
22
+ * and `packageId: "sohl"` beside a config that already said both.
23
+ *
24
+ * So this package reads the *same* file, through content-build's loader, and
25
+ * takes its own settings from the reserved `packageBuild:` section. The two
26
+ * packages split by **input** — content-build reads the content tree, this one
27
+ * reads `lang/`, `styles/`, `src/`, the assets and the manifest template — and
28
+ * neither validates the other's keys. content-build checks that the section is
29
+ * a mapping and hands it back frozen; everything inside it is validated here.
30
+ *
31
+ * **The dependency runs one way.** This package depends on content-build;
32
+ * content-build must never depend on this one. It is the same direction the
33
+ * loader already implies, and keeping it means content-build stays usable by a
34
+ * repository that ships content and no Foundry package at all.
35
+ *
36
+ * ```yaml
37
+ * # content-build.config.yaml
38
+ * packageKind: systems # read from the top level, not restated below
39
+ * foundryPackage: sohl
40
+ *
41
+ * packageBuild:
42
+ * assets:
43
+ * - { from: lang, to: lang }
44
+ * - { from: assets/icons, to: assets/icons }
45
+ * assetTransform: ./utils/svg-theme.mjs
46
+ * stageDir: build/stage
47
+ * clean:
48
+ * extra: [site/content, site/public]
49
+ * lang:
50
+ * sources: lang/*.json
51
+ * deploy:
52
+ * envPrefix: SOHL
53
+ * ```
54
+ *
55
+ * @module
56
+ */
57
+
58
+ import path from "node:path";
59
+ import { loadPackConfig } from "@heroiclands/content-build/engine/pack-config";
60
+
61
+ /** Keys the reserved section may declare. */
62
+ const SECTION_KEYS = [
63
+ "stageDir",
64
+ "assets",
65
+ "assetTransform",
66
+ "clean",
67
+ "lang",
68
+ "deploy",
69
+ "release",
70
+ ];
71
+ const ASSET_KEYS = ["from", "to"];
72
+ const CLEAN_KEYS = ["extra"];
73
+ const LANG_KEYS = ["sources", "help"];
74
+ const DEPLOY_KEYS = ["envPrefix"];
75
+ const RELEASE_KEYS = ["artifact"];
76
+
77
+ /**
78
+ * The artifact name each package kind ships, so no repository states it.
79
+ *
80
+ * Foundry installs a system from `system.json` and a module from `module.json`;
81
+ * the kind already says which, so `pack-release.mjs` passing
82
+ * `{ artifact: "system" }` by hand was restating `packageKind`.
83
+ */
84
+ const ARTIFACT_OF_KIND = Object.freeze({
85
+ systems: "system",
86
+ modules: "module",
87
+ });
88
+
89
+ /**
90
+ * @param {string} where - Dotted path of the offending key.
91
+ * @param {string} problem - What is wrong with it.
92
+ * @returns {never}
93
+ */
94
+ function fail(where, problem) {
95
+ throw new TypeError(`package-build config: \`${where}\` ${problem}.`);
96
+ }
97
+
98
+ /**
99
+ * @param {unknown} value
100
+ * @returns {boolean} Whether it is a plain mapping.
101
+ */
102
+ function isMapping(value) {
103
+ return value !== null && typeof value === "object" && !Array.isArray(value);
104
+ }
105
+
106
+ /**
107
+ * @param {Record<string, unknown>} object - The mapping to check.
108
+ * @param {readonly string[]} allowed - The keys it may declare.
109
+ * @param {string} prefix - Dotted path prefix for the error.
110
+ */
111
+ function rejectUnknownKeys(object, allowed, prefix) {
112
+ for (const key of Object.keys(object)) {
113
+ if (!allowed.includes(key)) {
114
+ fail(
115
+ `${prefix}${key}`,
116
+ `is not a recognised key (expected one of: ${allowed.join(", ")})`,
117
+ );
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @param {unknown} value
124
+ * @param {string} where
125
+ * @returns {string}
126
+ */
127
+ function requireNonEmptyString(value, where) {
128
+ if (typeof value !== "string" || value.trim() === "") {
129
+ fail(where, "must be a non-empty string");
130
+ }
131
+ return /** @type {string} */ (value);
132
+ }
133
+
134
+ /**
135
+ * One staging copy: a source path in the repository, and where it lands under
136
+ * the staged package root.
137
+ *
138
+ * @typedef {object} AssetSpec
139
+ * @property {string} from Source path, relative to the repository root.
140
+ * @property {string} to Destination, relative to the staged package root.
141
+ */
142
+
143
+ /**
144
+ * @param {unknown} value
145
+ * @param {number} index
146
+ * @returns {Readonly<AssetSpec>}
147
+ */
148
+ function normalizeAsset(value, index) {
149
+ const where = `packageBuild.assets[${index}]`;
150
+ if (!isMapping(value)) fail(where, "must be a mapping");
151
+ const asset = /** @type {Record<string, unknown>} */ (value);
152
+ rejectUnknownKeys(asset, ASSET_KEYS, `${where}.`);
153
+ return Object.freeze({
154
+ from: requireNonEmptyString(asset.from, `${where}.from`),
155
+ to: requireNonEmptyString(asset.to, `${where}.to`),
156
+ });
157
+ }
158
+
159
+ /**
160
+ * The resolved `packageBuild` section, every optional half filled in.
161
+ *
162
+ * @typedef {object} PackageBuildConfig
163
+ * @property {string} rootDir The repository root, from content-build.
164
+ * @property {string} packageKind `systems` or `modules`.
165
+ * @property {string} packageId The Foundry package id.
166
+ * @property {string} artifact Derived: `system` or `module`.
167
+ * @property {string} stageDir The staged package root, relative to
168
+ * `rootDir`. Every asset `to:` lands under it.
169
+ * @property {readonly Readonly<AssetSpec>[]} assets
170
+ * @property {string|null} assetTransform Module to load a `transform` from,
171
+ * resolved against `rootDir`. `null` when
172
+ * the repository stages assets verbatim.
173
+ * @property {readonly string[]} cleanExtra Directories to remove beyond the
174
+ * conventional build artifacts.
175
+ * @property {string} langSources Glob for the localization files to check.
176
+ * @property {string|null} langHelp Extra guidance printed after a failure.
177
+ * @property {string} envPrefix Prefix of the deploy environment variables.
178
+ */
179
+
180
+ /**
181
+ * Resolve a package-build configuration from an already-loaded shared one.
182
+ *
183
+ * Separate from {@link loadPackageBuildConfig} because this half is pure: it
184
+ * reads no file and touches no environment, so the validation rules can be
185
+ * described directly by a test instead of through a fixture repository on
186
+ * disk. {@link loadPackageBuildConfig} is the same function with the loading
187
+ * put back.
188
+ *
189
+ * @param {object} shared - The resolved content-build configuration.
190
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
191
+ * @throws {TypeError} When the reserved section declares something malformed.
192
+ */
193
+ export function resolvePackageBuildConfig(shared) {
194
+ const section = /** @type {Record<string, unknown>} */ (
195
+ shared.packageBuild ?? {}
196
+ );
197
+ rejectUnknownKeys(section, SECTION_KEYS, "packageBuild.");
198
+
199
+ if (section.assets !== undefined && !Array.isArray(section.assets)) {
200
+ fail("packageBuild.assets", "must be a list");
201
+ }
202
+ const assets = (section.assets ?? []).map(normalizeAsset);
203
+
204
+ const clean = section.clean ?? {};
205
+ if (!isMapping(clean)) fail("packageBuild.clean", "must be a mapping");
206
+ rejectUnknownKeys(
207
+ /** @type {Record<string, unknown>} */ (clean),
208
+ CLEAN_KEYS,
209
+ "packageBuild.clean.",
210
+ );
211
+ const extra = /** @type {Record<string, unknown>} */ (clean).extra ?? [];
212
+ if (!Array.isArray(extra)) {
213
+ fail("packageBuild.clean.extra", "must be a list");
214
+ }
215
+ const cleanExtra = extra.map((dir, i) =>
216
+ requireNonEmptyString(dir, `packageBuild.clean.extra[${i}]`),
217
+ );
218
+
219
+ const lang = section.lang ?? {};
220
+ if (!isMapping(lang)) fail("packageBuild.lang", "must be a mapping");
221
+ rejectUnknownKeys(
222
+ /** @type {Record<string, unknown>} */ (lang),
223
+ LANG_KEYS,
224
+ "packageBuild.lang.",
225
+ );
226
+ const langInput = /** @type {Record<string, unknown>} */ (lang);
227
+
228
+ const deploy = section.deploy ?? {};
229
+ if (!isMapping(deploy)) fail("packageBuild.deploy", "must be a mapping");
230
+ rejectUnknownKeys(
231
+ /** @type {Record<string, unknown>} */ (deploy),
232
+ DEPLOY_KEYS,
233
+ "packageBuild.deploy.",
234
+ );
235
+ const deployInput = /** @type {Record<string, unknown>} */ (deploy);
236
+
237
+ const release = section.release ?? {};
238
+ if (!isMapping(release)) fail("packageBuild.release", "must be a mapping");
239
+ rejectUnknownKeys(
240
+ /** @type {Record<string, unknown>} */ (release),
241
+ RELEASE_KEYS,
242
+ "packageBuild.release.",
243
+ );
244
+ const releaseInput = /** @type {Record<string, unknown>} */ (release);
245
+
246
+ return Object.freeze({
247
+ rootDir: shared.rootDir,
248
+ // Where the package is assembled before it is zipped or deployed. Every
249
+ // asset destination is relative to it, so a repository's table says
250
+ // `lang`, not `build/stage/lang` — the latter is what each consumer's
251
+ // `copy-assets.mjs` spelled out on every row.
252
+ stageDir:
253
+ section.stageDir === undefined ?
254
+ "build/stage"
255
+ : requireNonEmptyString(
256
+ section.stageDir,
257
+ "packageBuild.stageDir",
258
+ ),
259
+ packageKind: shared.packageKind,
260
+ packageId: shared.foundryPackage,
261
+ // Derived from the kind, which already decides it. Stating it was one
262
+ // more literal every consumer's release script carried.
263
+ artifact:
264
+ releaseInput.artifact === undefined ?
265
+ ARTIFACT_OF_KIND[
266
+ /** @type {"systems"|"modules"} */ (shared.packageKind)
267
+ ]
268
+ : requireNonEmptyString(
269
+ releaseInput.artifact,
270
+ "packageBuild.release.artifact",
271
+ ),
272
+ assets: Object.freeze(assets),
273
+ assetTransform:
274
+ section.assetTransform === undefined ?
275
+ null
276
+ : path.resolve(
277
+ shared.rootDir,
278
+ requireNonEmptyString(
279
+ section.assetTransform,
280
+ "packageBuild.assetTransform",
281
+ ),
282
+ ),
283
+ cleanExtra: Object.freeze(cleanExtra),
284
+ langSources:
285
+ langInput.sources === undefined ?
286
+ "lang/*.json"
287
+ : requireNonEmptyString(
288
+ langInput.sources,
289
+ "packageBuild.lang.sources",
290
+ ),
291
+ langHelp:
292
+ langInput.help === undefined ?
293
+ null
294
+ : requireNonEmptyString(langInput.help, "packageBuild.lang.help"),
295
+ envPrefix:
296
+ deployInput.envPrefix === undefined ?
297
+ "SOHL"
298
+ : requireNonEmptyString(
299
+ deployInput.envPrefix,
300
+ "packageBuild.deploy.envPrefix",
301
+ ),
302
+ });
303
+ }
304
+
305
+ /**
306
+ * The repository's resolved package-build configuration.
307
+ *
308
+ * Read on call rather than at import, exactly as content-build resolves its
309
+ * own: importing a module of this package must not require a configuration to
310
+ * exist anywhere above it.
311
+ *
312
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
313
+ * @throws {TypeError} When there is no configuration, or the reserved section
314
+ * declares something malformed.
315
+ */
316
+ export function loadPackageBuildConfig() {
317
+ return resolvePackageBuildConfig(loadPackConfig());
318
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "0.1.0",
4
- "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package manifest, localization, staging, bundle, release and deployment.",
3
+ "version": "0.2.1",
4
+ "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
7
7
  "main": "./index.mjs",
@@ -39,7 +39,11 @@
39
39
  "types": "./types/text.d.mts",
40
40
  "import": "./text.mjs"
41
41
  },
42
- "./package.json": "./package.json"
42
+ "./package.json": "./package.json",
43
+ "./config": {
44
+ "types": "./types/config.d.mts",
45
+ "import": "./config.mjs"
46
+ }
43
47
  },
44
48
  "files": [
45
49
  "bundle.mjs",
@@ -51,7 +55,10 @@
51
55
  "stage.mjs",
52
56
  "text.mjs",
53
57
  "types",
54
- "README.md"
58
+ "CHANGELOG.md",
59
+ "README.md",
60
+ "bin",
61
+ "config.mjs"
55
62
  ],
56
63
  "scripts": {
57
64
  "test": "vitest run",
@@ -60,14 +67,21 @@
60
67
  "build:types": "tsc -p tsconfig.dts.json",
61
68
  "format": "prettier --write .",
62
69
  "format:check": "prettier --check .",
70
+ "changeset": "changeset",
71
+ "changeset:check": "changeset status --since=origin/main",
63
72
  "prepare": "git config core.hooksPath .githooks || true"
64
73
  },
65
74
  "dependencies": {
75
+ "@heroiclands/content-build": "^0.15.0",
66
76
  "acorn": "^8.18.0",
67
77
  "archiver": "^8.0.0",
68
- "ssh2-sftp-client": "^12.1.1"
78
+ "dotenv": "^17.2.3",
79
+ "glob": "^11.0.3",
80
+ "ssh2-sftp-client": "^12.1.1",
81
+ "yargs": "^18.1.0"
69
82
  },
70
83
  "devDependencies": {
84
+ "@changesets/cli": "^3.0.0",
71
85
  "@types/node": "^26.2.0",
72
86
  "prettier": "^3.9.6",
73
87
  "typescript": "^6.0.3",
@@ -95,5 +109,8 @@
95
109
  },
96
110
  "bugs": {
97
111
  "url": "https://github.com/HeroicLands/package-build/issues"
112
+ },
113
+ "bin": {
114
+ "package-build": "./bin/package-build.mjs"
98
115
  }
99
116
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The resolved `packageBuild` section, every optional half filled in.
3
+ *
4
+ * @typedef {object} PackageBuildConfig
5
+ * @property {string} rootDir The repository root, from content-build.
6
+ * @property {string} packageKind `systems` or `modules`.
7
+ * @property {string} packageId The Foundry package id.
8
+ * @property {string} artifact Derived: `system` or `module`.
9
+ * @property {string} stageDir The staged package root, relative to
10
+ * `rootDir`. Every asset `to:` lands under it.
11
+ * @property {readonly Readonly<AssetSpec>[]} assets
12
+ * @property {string|null} assetTransform Module to load a `transform` from,
13
+ * resolved against `rootDir`. `null` when
14
+ * the repository stages assets verbatim.
15
+ * @property {readonly string[]} cleanExtra Directories to remove beyond the
16
+ * conventional build artifacts.
17
+ * @property {string} langSources Glob for the localization files to check.
18
+ * @property {string|null} langHelp Extra guidance printed after a failure.
19
+ * @property {string} envPrefix Prefix of the deploy environment variables.
20
+ */
21
+ /**
22
+ * Resolve a package-build configuration from an already-loaded shared one.
23
+ *
24
+ * Separate from {@link loadPackageBuildConfig} because this half is pure: it
25
+ * reads no file and touches no environment, so the validation rules can be
26
+ * described directly by a test instead of through a fixture repository on
27
+ * disk. {@link loadPackageBuildConfig} is the same function with the loading
28
+ * put back.
29
+ *
30
+ * @param {object} shared - The resolved content-build configuration.
31
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
32
+ * @throws {TypeError} When the reserved section declares something malformed.
33
+ */
34
+ export function resolvePackageBuildConfig(shared: object): Readonly<PackageBuildConfig>;
35
+ /**
36
+ * The repository's resolved package-build configuration.
37
+ *
38
+ * Read on call rather than at import, exactly as content-build resolves its
39
+ * own: importing a module of this package must not require a configuration to
40
+ * exist anywhere above it.
41
+ *
42
+ * @returns {Readonly<PackageBuildConfig>} The frozen configuration.
43
+ * @throws {TypeError} When there is no configuration, or the reserved section
44
+ * declares something malformed.
45
+ */
46
+ export function loadPackageBuildConfig(): Readonly<PackageBuildConfig>;
47
+ /**
48
+ * One staging copy: a source path in the repository, and where it lands under
49
+ * the staged package root.
50
+ */
51
+ export type AssetSpec = {
52
+ /**
53
+ * Source path, relative to the repository root.
54
+ */
55
+ from: string;
56
+ /**
57
+ * Destination, relative to the staged package root.
58
+ */
59
+ to: string;
60
+ };
61
+ /**
62
+ * The resolved `packageBuild` section, every optional half filled in.
63
+ */
64
+ export type PackageBuildConfig = {
65
+ /**
66
+ * The repository root, from content-build.
67
+ */
68
+ rootDir: string;
69
+ /**
70
+ * `systems` or `modules`.
71
+ */
72
+ packageKind: string;
73
+ /**
74
+ * The Foundry package id.
75
+ */
76
+ packageId: string;
77
+ /**
78
+ * Derived: `system` or `module`.
79
+ */
80
+ artifact: string;
81
+ /**
82
+ * The staged package root, relative to
83
+ * `rootDir`. Every asset `to:` lands under it.
84
+ */
85
+ stageDir: string;
86
+ assets: readonly Readonly<AssetSpec>[];
87
+ /**
88
+ * Module to load a `transform` from,
89
+ * resolved against `rootDir`. `null` when
90
+ * the repository stages assets verbatim.
91
+ */
92
+ assetTransform: string | null;
93
+ /**
94
+ * Directories to remove beyond the
95
+ * conventional build artifacts.
96
+ */
97
+ cleanExtra: readonly string[];
98
+ /**
99
+ * Glob for the localization files to check.
100
+ */
101
+ langSources: string;
102
+ /**
103
+ * Extra guidance printed after a failure.
104
+ */
105
+ langHelp: string | null;
106
+ /**
107
+ * Prefix of the deploy environment variables.
108
+ */
109
+ envPrefix: string;
110
+ };