@heroiclands/package-build 25.6.3 → 25.7.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +19 -1
  3. package/bin/content-commands.mjs +14 -70
  4. package/bin/package-build.mjs +25 -10
  5. package/ci/wip-workflows.test.mjs +252 -0
  6. package/content-config.mjs +40 -12
  7. package/docs/api.md +237 -48
  8. package/docs/commands.md +73 -36
  9. package/docs/configuration.md +92 -48
  10. package/docs/guides/release-and-deploy.md +26 -14
  11. package/docs/guides/site.md +29 -6
  12. package/engine/cdn-publish.mjs +164 -6
  13. package/engine/content-index.mjs +71 -19
  14. package/engine/content-spans.mjs +18 -1
  15. package/engine/content-styles.mjs +85 -0
  16. package/engine/derived-sections.mjs +4 -3
  17. package/engine/event-views.mjs +6 -4
  18. package/engine/helpers.mjs +33 -9
  19. package/engine/index.mjs +1 -0
  20. package/engine/note-cache.mjs +1107 -0
  21. package/engine/note-page.mjs +424 -0
  22. package/engine/pack-config.mjs +47 -4
  23. package/engine/pathnames.mjs +22 -7
  24. package/engine/prepared-only.mjs +32 -0
  25. package/engine/preview-index.mjs +55 -13
  26. package/engine/site-build.mjs +34 -448
  27. package/engine/site-config.mjs +33 -4
  28. package/engine/site-maps.mjs +16 -24
  29. package/engine/site-origins.mjs +23 -0
  30. package/engine/site-page.mjs +648 -0
  31. package/engine/site-render.mjs +142 -10
  32. package/engine/site-serve.mjs +1 -1
  33. package/engine/sql-tables.mjs +12 -3
  34. package/package.json +6 -1
  35. package/site-theme/static/css/style.css +14 -0
  36. package/types/content-config.d.mts +24 -4
  37. package/types/engine/cdn-publish.d.mts +22 -0
  38. package/types/engine/content-index.d.mts +36 -2
  39. package/types/engine/content-styles.d.mts +20 -0
  40. package/types/engine/derived-sections.d.mts +3 -3
  41. package/types/engine/event-views.d.mts +4 -3
  42. package/types/engine/helpers.d.mts +18 -1
  43. package/types/engine/index.d.mts +1 -0
  44. package/types/engine/note-cache.d.mts +149 -0
  45. package/types/engine/note-page.d.mts +92 -0
  46. package/types/engine/pack-config.d.mts +30 -0
  47. package/types/engine/prepared-only.d.mts +14 -0
  48. package/types/engine/preview-index.d.mts +35 -0
  49. package/types/engine/site-build.d.mts +5 -100
  50. package/types/engine/site-config.d.mts +3 -2
  51. package/types/engine/site-maps.d.mts +7 -11
  52. package/types/engine/site-origins.d.mts +9 -0
  53. package/types/engine/site-page.d.mts +261 -0
  54. package/types/engine/site-render.d.mts +20 -1
  55. package/types/engine/sql-tables.d.mts +8 -4
@@ -974,23 +974,24 @@ Any other key under `docs.itemFields` is refused:
974
974
 
975
975
  **Type:** object · **Optional** · every key defaults to nothing published:
976
976
 
977
- | Key | Type | Default |
978
- | ------------------- | -------- | -------------------------------------------- |
979
- | `site.base` | string | `""` |
980
- | `site.title` | string | `""`, required when no manifest title exists |
981
- | `site.assets` | string | `""`, but required for `site build` |
982
- | `site.description` | string | `""`, but required for `site build` |
983
- | `site.packages` | string[] | `[]` |
984
- | `site.pass` | string | `""` |
985
- | `site.passOptions` | object | `{}` |
986
- | `site.notfound` | object | `null` |
987
- | `site.search` | boolean | `true` |
988
- | `site.maps` | boolean | `true` |
989
- | `site.sections` | object | `{}` |
990
- | `site.feed` | string | `""` |
991
- | `site.tags` | boolean | `false` |
992
- | `site.sitemaps` | string[] | `[]`, and only at the root of an origin |
993
- | `site.mergeIndexes` | string[] | `[]` |
977
+ | Key | Type | Default |
978
+ | ------------------- | -------- | --------------------------------------------- |
979
+ | `site.base` | string | `""` |
980
+ | `site.title` | string | `""`, required when no manifest title exists |
981
+ | `site.assets` | string | `""`; `site build` derives a CDN when omitted |
982
+ | `site.description` | string | `""`, but required for `site build` |
983
+ | `site.packages` | string[] | `[]` |
984
+ | `site.pass` | string | `""` |
985
+ | `site.passOptions` | object | `{}` |
986
+ | `site.notfound` | object | `null` |
987
+ | `site.search` | boolean | `true` |
988
+ | `site.maps` | boolean | `true` |
989
+ | `site.sections` | object | `{}` |
990
+ | `site.feed` | string | `""` |
991
+ | `site.tags` | boolean | `false` |
992
+ | `site.sitemaps` | string[] | `[]`, and only at the root of an origin |
993
+ | `site.mergeIndexes` | string[] | `[]` |
994
+ | `site.inputs` | string | `""`; optional local input-provider module |
994
995
 
995
996
  The authored content tree determines which pages reach the web. `site` is
996
997
  framing: which named pass bundle
@@ -1016,7 +1017,7 @@ renders it under the deployment root `build/site/`, at
1016
1017
 
1017
1018
  > ``package-build config: `site` must be a mapping.``
1018
1019
 
1019
- > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, title, assets, description, packages, pass, passOptions, notfound, search, maps, sections, feed, tags, sitemaps, mergeIndexes).``
1020
+ > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, title, assets, description, packages, pass, passOptions, notfound, search, maps, sections, feed, tags, sitemaps, mergeIndexes, inputs).``
1020
1021
 
1021
1022
  `site.assets` is the host every package's imagery is served from, and it is
1022
1023
  the one address in this file that is not this repository's own. A note names
@@ -1029,17 +1030,18 @@ Absolute, and the trailing slash is trimmed:
1029
1030
 
1030
1031
  > ``package-build config: `site.assets` must be an absolute `http://` or `https://` address — it is the host every package's imagery is served from, and a relative value resolves against whichever page happens to carry the image.``
1031
1032
 
1032
- `package-build site build` refuses a configuration with no `site.assets` at
1033
- all — there is no defensible default, because the theme
1034
- resolves every relative asset against it:
1033
+ `package-build site build` resolves its effective asset host from `--assets`,
1034
+ an explicit `--root`, or configured `site.assets`, in that order. If none is
1035
+ provided, it derives `https://cdn.internal.heroiclands.org` from the default
1036
+ root. Every relative asset resolves against that effective host.
1035
1037
 
1036
- > ``package-build config: `site.assets` is not declared, and a site build needs one — it is the host every package's imagery is served from, and the theme resolves every relative asset against it.``
1037
-
1038
- `package-build cdn publish` uploads the package's pictures under the same
1039
- host, at `<site.assets>/<contentPackage>/icons/<basename>` and
1040
- `…/images/<basename>`, which is the address the website gives each one. The
1041
- bucket behind the CDN is served at `site.assets`, so an object's key is its
1042
- path below this value.
1038
+ `package-build cdn publish --dest cloudflare` manually uploads pictures under
1039
+ this host, at `<site.assets>/<contentPackage>/icons/<basename>` and
1040
+ `…/images/<basename>`. The R2 bucket is served at `site.assets`, so an object's
1041
+ key is its path below this value. The default `--dest internal` instead publishes
1042
+ the same keys to atlas, served at `https://cdn.internal.heroiclands.org`; its set
1043
+ sources use that internal origin regardless of configured `site.assets`.
1044
+ Publication is separate from site building and deployment.
1043
1045
 
1044
1046
  The site settings carry the same host as
1045
1047
  `params.cdnBaseURL`, which the theme resolves a relative asset path against.
@@ -1284,10 +1286,54 @@ site:
1284
1286
  mergeIndexes: [sohl, thalorna, kethira, harnensemble]
1285
1287
  ```
1286
1288
 
1287
- The optional [`site build --base-url` and `--assets` flags](commands.md#package-build-site-build)
1288
- apply runtime overrides to these resolved settings. The base URL supplies the
1289
- effective homepage and `site.base`; the asset URL supplies effective
1290
- `site.assets`. The authored configuration stays unchanged and must be valid.
1289
+ [`site build --root <domain>`](commands.md#package-build-site-build) selects
1290
+ `https://www.<domain>` while preserving the homepage path. Its default domain
1291
+ is `internal.heroiclands.org`. Explicit `--root` replaces configured `site.assets`
1292
+ with `https://cdn.<domain>`; without the flag the configured asset host stays,
1293
+ or the derived CDN is used if no asset host is configured. Explicit `--base-url`
1294
+ and `--assets` independently take precedence over these derived values.
1295
+ The effective configuration exposes the chosen address as `homepage`, its path
1296
+ as `site.base`, and the asset host as `site.assets`. The authored configuration
1297
+ stays unchanged and must be valid. A root-website integration uses these resolved
1298
+ inputs rather than deriving hosts itself; canonical source-navigation drift
1299
+ checks remain against the original navigation before projecting its URLs.
1300
+ `readCachedNavigation(effectiveConfig)` projects only canonical Heroic Lands
1301
+ www links onto the effective homepage origin; `siteSettings` does the same for
1302
+ the brand license link. Neither function writes the cache or changes external
1303
+ attribution links.
1304
+
1305
+ ### Local site inputs
1306
+
1307
+ `site.inputs` optionally names a local module relative to `rootDir`, such as
1308
+ `utils/site-inputs.mjs`. Use a plain relative path without dot segments,
1309
+ backslashes or symlinks. Its default export is a function taking `{config}`,
1310
+ the immutable effective configuration after root and URL overrides. The function
1311
+ may return a promise and must return `{navigation, files}`:
1312
+
1313
+ ```js
1314
+ export default async function ({ config }) {
1315
+ const navigation = [{ name: "Home", url: new URL("/", config.homepage).href }];
1316
+ return { navigation, files: { "nav.json": JSON.stringify(navigation) + "\n" } };
1317
+ }
1318
+ ```
1319
+
1320
+ The navigation uses the existing `{name, url, children?}` shape. A provider
1321
+ supplies selected URLs itself; the example reads the effective homepage instead
1322
+ of deriving hosts. A root website can compare its committed canonical navigation
1323
+ against its canonical roster before returning selected-origin navigation. A
1324
+ thrown drift finding stops the build before output is replaced. Without this
1325
+ key the builder continues to require the navigation cache from `deps fetch`.
1326
+
1327
+ `files` maps relative output paths to UTF-8 strings. Paths use plain ASCII
1328
+ letters, numbers, dots, underscores and hyphens within segments, with each
1329
+ segment beginning with a letter or number. Absolute paths, dot segments,
1330
+ symlinks, case collisions and file/directory collisions are refused. A provider
1331
+ cannot claim HTML, theme/static assets, fonts, search/tag trees, page paths,
1332
+ feeds, sitemap files, robots or deployment headers. All files are validated
1333
+ before output replacement, then written inside the package site's output after
1334
+ the renderer clears it. Their paths are included in `written.files`.
1335
+ This prepares build inputs and text artifacts; it is not a shell command or a
1336
+ publication hook.
1291
1337
 
1292
1338
  ### What the site build reads
1293
1339
 
@@ -1296,17 +1342,17 @@ repository already states, a few constants of the organisation, and the
1296
1342
  navigation `deps fetch` cached. Every value has one source, and that source is
1297
1343
  where it is edited:
1298
1344
 
1299
- | Setting | Derived from |
1300
- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
1301
- | base URL | `package.json` `homepage`, checked by `checkHomepage` — an absolute URL ending `/<contentPackage>/` |
1302
- | title | `site.title` when declared, otherwise `packageBuild.manifest.title` |
1303
- | `params.description` | `site.description`, which is required |
1304
- | `params.author` | the first entry of `authors`, its `name`; absent when `authors` is empty |
1305
- | `params.cdnBaseURL` | `site.assets`, which is required |
1306
- | `params.brand` | the organisation's brand links — `logo`, `licenseURL`, `discordURL` — in `engine/site-config.mjs` |
1307
- | `params.notfound` | `site.notfound`; absent when undeclared |
1308
- | `params.scriptFonts` | the installed script fonts' stylesheet, present only when the package delivers fonts |
1309
- | menu | the navigation `package-build deps fetch` caches from `https://www.heroiclands.org/nav.json`, entry for entry, a dropdown's entries as its `children` |
1345
+ | Setting | Derived from |
1346
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1347
+ | base URL | Effective `homepage` from `siteBuildConfig`; authored `package.json` `homepage` first passes `checkHomepage` |
1348
+ | title | `site.title` when declared, otherwise `packageBuild.manifest.title` |
1349
+ | `params.description` | `site.description`, which is required |
1350
+ | `params.author` | the first entry of `authors`, its `name`; absent when `authors` is empty |
1351
+ | `params.cdnBaseURL` | `site.assets`, which is required |
1352
+ | `params.brand` | the organisation's brand links — `logo`, `licenseURL`, `discordURL` — in `engine/site-config.mjs` |
1353
+ | `params.notfound` | `site.notfound`; absent when undeclared |
1354
+ | `params.scriptFonts` | the installed script fonts' stylesheet, present only when the package delivers fonts |
1355
+ | menu | the navigation `package-build deps fetch` caches from `https://www.heroiclands.org/nav.json`, with canonical owned URLs projected onto the effective homepage origin, a dropdown's entries as its `children` |
1310
1356
 
1311
1357
  The site build reads the navigation from the cache only. A cold cache is an
1312
1358
  error naming the command that fills it:
@@ -1322,11 +1368,9 @@ the same way when it is absent:
1322
1368
 
1323
1369
  > ``package-build config: `site.description` is not declared, and the site's `<meta name="description">` reads from it.``
1324
1370
 
1325
- `params.cdnBaseURL` reads from `site.assets`, and the theme resolves every
1326
- relative asset against it, so a configuration declaring none fails the site
1327
- build the same way:
1328
-
1329
- > ``package-build config: `site.assets` is not declared, and a site build needs one — it is the host every package's imagery is served from, and the theme resolves every relative asset against it.``
1371
+ `params.cdnBaseURL` reads the effective `site.assets` chosen by the build
1372
+ resolver, and the theme resolves every relative asset against it. Direct
1373
+ `siteSettings` callers must supply that effective asset host.
1330
1374
 
1331
1375
  Nothing else is configurable. A site is its homepage and its pages: the
1332
1376
  homepage is the package root and every note is a page. A tag is a field a
@@ -133,23 +133,34 @@ the other actions and environment variables.
133
133
 
134
134
  ## Publish pictures to the CDN
135
135
 
136
- The website draws every picture a package has from
137
- `<site.assets>/<contentPackage>/`, and the art a Foundry package leaves out
138
- reaches a world from there as a named asset set. `package-build cdn publish`
139
- is the one command that writes there:
136
+ Run `package-build cdn publish` manually after pictures or their provenance change.
137
+ It uploads every icon and image flat, applies the stage's transformations, skips
138
+ pictures already present at the same size, and writes a named set listing the
139
+ pictures the Foundry package leaves out. Site builds and releases do not publish
140
+ pictures.
141
+
142
+ The default destination is atlas at `https://cdn.internal.heroiclands.org`:
140
143
 
141
144
  ```bash
142
145
  package-build cdn publish --set alt-art.json --name "Thalorna — illustrated" --dry-run
143
146
  package-build cdn publish --set alt-art.json --name "Thalorna — illustrated"
144
147
  ```
145
148
 
146
- It uploads every icon and image flat, transformed as the stage transforms
147
- them, skips what the CDN already holds at the same size, and writes
148
- `<site.assets>/<contentPackage>/alt-art.json` listing the left-out pictures.
149
- Run it after the pictures or their provenance change; it is not part of a
150
- build or a release.
149
+ Connect to the LAN or VPN. Install rsync and use your verified `atlas` SSH account
150
+ with write access to `/srv/preview/cdn`. SSH configuration or your SSH agent supplies
151
+ the key; unknown host keys fail. For a configured forced-rrsync publisher alias,
152
+ set `CDN_INTERNAL_DESTINATION=your-alias:cdn`; its relative path is required by the
153
+ restricted receiver. The command does not provision keys or change permissions.
154
+ An internal dry run only reads HTTPS; it never opens SSH or uses R2 credentials.
155
+
156
+ To publish to the public CDN, select Cloudflare explicitly:
157
+
158
+ ```bash
159
+ package-build cdn publish --dest cloudflare --set alt-art.json --name "Thalorna — illustrated"
160
+ ```
151
161
 
152
- The bucket's credentials go in `.env.local` beside the stage paths:
162
+ This retains the existing R2 transport and configured `site.assets` origin,
163
+ normally `https://cdn.heroiclands.org`. The bucket's credentials go in `.env.local`:
153
164
 
154
165
  ```dotenv
155
166
  CDN_ENDPOINT=https://<account id>.r2.cloudflarestorage.com
@@ -158,10 +169,11 @@ CDN_ACCESS_KEY_ID=<access key id>
158
169
  CDN_SECRET_ACCESS_KEY=<secret access key>
159
170
  ```
160
171
 
161
- A dry run with none of them set compares against the public CDN instead. A
162
- dispatched workflow passes the same four values as named secrets. See the
163
- [command reference](../commands.md#package-build-cdn-publish) for the set
164
- file's shape, the refusals and the exit status.
172
+ With none of those settings present, `--dest cloudflare --dry-run` compares against
173
+ `site.assets` read-only. Both destinations use the same keys and sets; neither
174
+ deletes existing shared CDN objects. See the
175
+ [command reference](../commands.md#package-build-cdn-publish) for SSH setup,
176
+ set shape, refusals and exit status.
165
177
 
166
178
  ## Publish a package-build npm version
167
179
 
@@ -306,14 +306,35 @@ The terminal reports indexing, rendering, writing and search indexing as they
306
306
  start, including page counts where available. Progress is on stdout;
307
307
  diagnostics retain their file, line and column on stderr.
308
308
 
309
- For an internal main preview, supply the build's address and asset root:
309
+ Build locally for the internal site or a public release with the selected root:
310
310
 
311
311
  ```bash
312
- package-build site build \
313
- --base-url https://www.internal.heroiclands.org/thalorna/ \
314
- --assets https://cdn.internal.heroiclands.org
312
+ package-build site build --root internal.heroiclands.org
313
+ package-build site build --root heroiclands.org
315
314
  ```
316
315
 
316
+ These select `https://www.internal.heroiclands.org` and
317
+ `https://cdn.internal.heroiclands.org`, or `https://www.heroiclands.org` and
318
+ `https://cdn.heroiclands.org`, retaining the package's path. Without `--root`,
319
+ the site defaults to the internal www origin and keeps configured `site.assets`
320
+ (or derives the internal CDN if none is configured). An explicit root overrides
321
+ the configured asset host. Build and publication are separate: this command
322
+ writes local output, reads cached dependencies, and never uploads or fetches
323
+ missing assets. Shared navigation and the brand license follow the selected
324
+ site origin; external attribution and authored content links retain their URLs.
325
+ Asset references to the exact canonical `https://cdn.heroiclands.org` origin
326
+ also use the effective asset root, retaining their path, query and fragment;
327
+ external image hosts keep their addresses. This happens during normal asset
328
+ resolution and leaves authored files unchanged.
329
+
330
+ A site that generates its own navigation can declare
331
+ [`site.inputs`](../configuration.md#local-site-inputs). Its local module receives
332
+ the selected addresses in the effective configuration and returns its menu plus
333
+ text files such as `nav.json`. The builder renders that menu and writes those
334
+ files together, so one build command prepares the complete site. A canonical
335
+ roster check belongs in that module before it returns deployment-specific URLs;
336
+ a drift finding leaves the last built site in place.
337
+
317
338
  For a pull request preview, include the whole prefix:
318
339
 
319
340
  ```bash
@@ -322,10 +343,12 @@ package-build site build \
322
343
  --assets https://cdn.internal.heroiclands.org
323
344
  ```
324
345
 
325
- The second build writes to `build/site/pr/thalorna/123/thalorna/`. Its local
346
+ This build writes to `build/site/pr/thalorna/123/thalorna/`. Its local
326
347
  links, theme files, script fonts and search stay under that prefix; its images
327
348
  use the supplied asset root. `--out` changes the deployment directory above the
328
- prefix. Both URL options are optional and independent, and require absolute
349
+ prefix. Explicit `--base-url` takes precedence over the root-derived site
350
+ address, and explicit `--assets` takes precedence over both the root-derived and
351
+ configured asset host. Both URL options are optional and independent, and require absolute
329
352
  HTTP(S) URLs. The base URL's path ends with `/`; the asset root may include a
330
353
  path and has trailing slashes trimmed. Use plain paths without percent escapes,
331
354
  dot segments (`.` or `..`), or doubled slashes, credentials, queries or fragments.
@@ -15,14 +15,17 @@
15
15
  * `package-build cdn publish` — every picture a package has, on the CDN, and
16
16
  * the art the package leaves out as a named asset set.
17
17
  *
18
+ * Internal publication uses the same plan with an atlas rsync writer and public
19
+ * HTTPS reads. Cloudflare publication keeps the S3-compatible transport.
20
+ *
18
21
  * **The layout is the deployed one.** Each icon and image is uploaded flat, as
19
- * `icons/<basename>` or `images/<basename>`, under `<site.assets>/<package>/`
20
- * — the address {@link module:engine/pathnames.resolvePathname} gives the
21
- * website for the same file, so the site and the CDN agree by construction.
22
+ * `icons/<basename>` or `images/<basename>`, under the selected CDN's package
23
+ * root. Cloudflare uses `site.assets`; internal uses `INTERNAL_CDN_HOST`.
24
+ * Select the same CDN origin for site builds that consume these pictures.
22
25
  * Shipped and excluded pictures both go up: the website shows all of them.
23
26
  *
24
- * **The bucket is served at `site.assets`.** An object's key is its path below
25
- * that URL, `<package>/images/<basename>`, so the key and the public address
27
+ * **Each destination preserves object keys.** An object's key is its path below
28
+ * the selected CDN origin, `<package>/images/<basename>`, so the key and the public address
26
29
  * differ only by the host.
27
30
  *
28
31
  * **The bytes are the staged bytes.** The repository's `assetTransform` runs
@@ -56,6 +59,9 @@
56
59
 
57
60
  import fs from "node:fs";
58
61
  import path from "node:path";
62
+ import os from "node:os";
63
+ import { execFile } from "node:child_process";
64
+ import { promisify, stripVTControlCharacters } from "node:util";
59
65
 
60
66
  import { AwsClient } from "aws4fetch";
61
67
 
@@ -278,7 +284,7 @@ export function s3Store({ endpoint, bucket, accessKeyId, secretAccessKey, fetch:
278
284
  export function publicStore(host, fetchImpl) {
279
285
  const base = host.replace(/\/+$/, "");
280
286
  const get = (key, method) =>
281
- (fetchImpl ?? fetch)(`${base}/${key}`, {
287
+ (fetchImpl ?? fetch)(`${base}/${key.split("/").map(encodeURIComponent).join("/")}`, {
282
288
  method,
283
289
  headers: method === "HEAD" ? IDENTITY : undefined,
284
290
  });
@@ -300,6 +306,156 @@ export function publicStore(host, fetchImpl) {
300
306
  };
301
307
  }
302
308
 
309
+ /** The internal CDN's public origin. Reads never require SSH credentials. */
310
+ export const INTERNAL_CDN_HOST = "https://cdn.internal.heroiclands.org";
311
+
312
+ /** The manual operator's ordinary SSH account on atlas. */
313
+ export const INTERNAL_CDN_DESTINATION = "atlas:/srv/preview/cdn";
314
+
315
+ /**
316
+ * Refuse traversal, absolute paths and control characters before touching a store.
317
+ *
318
+ * @param {string} key - A planned relative object key.
319
+ * @returns {void}
320
+ */
321
+ function validateCdnKey(key) {
322
+ if (
323
+ typeof key !== "string" ||
324
+ !/^[\x20-\x7e]+$/.test(key) ||
325
+ key.includes("\\") ||
326
+ key.split("/").some((part) => !part || part === "." || part === "..")
327
+ ) {
328
+ throw new Error("unsafe CDN key: expected a printable relative path without traversal");
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Retain the last actionable receiver diagnostic without commands or key material.
334
+ *
335
+ * @param {unknown} stderr - Child-process stderr, never the whole error message.
336
+ * @returns {string} Bounded, terminal-safe diagnostic text, or an empty string.
337
+ */
338
+ function transferDiagnostic(stderr) {
339
+ if (typeof stderr !== "string" && !Buffer.isBuffer(stderr)) return "";
340
+ const text = stripVTControlCharacters(String(stderr).slice(-8192))
341
+ .replace(
342
+ /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g,
343
+ "",
344
+ )
345
+ .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
346
+ let fallback = "";
347
+ for (const raw of text.split("\n").reverse()) {
348
+ const line = raw
349
+ .trim()
350
+ .replace(/^(?:\/[^\s/]+)*\/(rrsync|rsync|ssh)(?=(?: error)?: )/i, "$1");
351
+ if (/command failed|command:|\brsync\s+--|\bssh\s+-/i.test(line)) continue;
352
+ const known = line.match(
353
+ /Host key verification failed\.|Permission denied(?: \([A-Za-z0-9, -]{1,80}\))?\.?|REMOTE HOST IDENTIFICATION HAS CHANGED|Connection refused|Connection timed out|Could not resolve hostname/i,
354
+ );
355
+ if (known) return known[0];
356
+ if (!/^(?:r?rsync|ssh)(?: error)?: /i.test(line)) continue;
357
+ const context = line
358
+ .replace(/(["'])[^"']*\1/g, "[redacted]")
359
+ .replace(/(?:\/|[A-Za-z]:[\\/])[^\s,;)]+/g, "[path]")
360
+ .replace(/[A-Za-z0-9+/=]{48,}/g, "[redacted]")
361
+ .slice(0, 240);
362
+ if (/^rrsync(?: error)?: /i.test(line)) return context;
363
+ fallback ||= context;
364
+ }
365
+ return fallback;
366
+ }
367
+
368
+ /**
369
+ * Report an operation and its structured failure code, excluding error argv/message.
370
+ *
371
+ * @param {unknown} error - A filesystem or child-process error.
372
+ * @param {string} operation - The operation being attempted.
373
+ * @returns {string} The operator-facing failure.
374
+ */
375
+ function internalFailure(error, operation) {
376
+ const details = error && typeof error === "object" ? /** @type {any} */ (error) : {};
377
+ const code =
378
+ Number.isInteger(details.code) && details.code >= 0 && details.code <= 255 ?
379
+ `exit ${details.code}`
380
+ : typeof details.code === "string" && Object.hasOwn(os.constants.errno, details.code) ?
381
+ `code ${details.code}`
382
+ : typeof details.signal === "string" && /^SIG[A-Z0-9]{1,12}$/.test(details.signal) ?
383
+ `signal ${details.signal}`
384
+ : "";
385
+ const context = operation === "rsync transfer" ? transferDiagnostic(details.stderr) : "";
386
+ const action =
387
+ operation === "rsync transfer" ? "rsync transfer failed" : `could not ${operation}`;
388
+ return `internal CDN ${action}${code ? `; ${code}` : ""}${context ? `: ${context}` : ""}`;
389
+ }
390
+
391
+ /**
392
+ * Internal storage: HTTPS reads and rsync writes of the planned bytes only.
393
+ *
394
+ * The default uses the operator's normal atlas account. An explicitly configured
395
+ * SSH alias may select the forced-rrsync account with a relative `cdn` destination.
396
+ * SSH configuration and its agent supply keys; no remote shell is requested.
397
+ *
398
+ * @param {object} [opts]
399
+ * @param {string} [opts.destination] - `host:/srv/preview/cdn` or `alias:cdn`.
400
+ * @param {typeof fetch} [opts.fetch] - The fetch to use for public reads.
401
+ * @param {(args: string[]) => Promise<unknown>} [opts.transfer] - Run rsync.
402
+ * @returns {CdnStore} The store.
403
+ */
404
+ export function internalStore({
405
+ destination = INTERNAL_CDN_DESTINATION,
406
+ fetch: fetchImpl,
407
+ transfer = (args) => promisify(execFile)("rsync", args),
408
+ } = {}) {
409
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*:(?:\/srv\/preview\/)?cdn\/?$/.test(destination)) {
410
+ throw new Error(
411
+ "invalid internal CDN destination: use an SSH host alias followed by :/srv/preview/cdn or :cdn",
412
+ );
413
+ }
414
+ const target = `${destination.replace(/\/+$/, "")}/`;
415
+ const reader = publicStore(INTERNAL_CDN_HOST, fetchImpl);
416
+ return {
417
+ ...reader,
418
+ describe: `${destination} (${INTERNAL_CDN_HOST})`,
419
+ async write(key, body) {
420
+ validateCdnKey(key);
421
+ let scratch;
422
+ let operation = "create temporary directory";
423
+ let failure;
424
+ try {
425
+ scratch = fs.mkdtempSync(path.join(os.tmpdir(), "package-build-cdn-"));
426
+ const file = path.join(scratch, ...key.split("/"));
427
+ operation = "create staged directories";
428
+ fs.mkdirSync(path.dirname(file), { recursive: true });
429
+ operation = "write staged file";
430
+ fs.writeFileSync(file, body);
431
+ operation = "rsync transfer";
432
+ await transfer([
433
+ "--recursive",
434
+ "--relative",
435
+ "--ignore-times",
436
+ "--chmod=D755,F644",
437
+ "--omit-dir-times",
438
+ "--rsh=ssh -o BatchMode=yes -o StrictHostKeyChecking=yes",
439
+ "--",
440
+ `${scratch}/./${key}`,
441
+ target,
442
+ ]);
443
+ } catch (error) {
444
+ failure = internalFailure(error, operation);
445
+ } finally {
446
+ if (scratch) {
447
+ try {
448
+ fs.rmSync(scratch, { recursive: true, force: true });
449
+ } catch (error) {
450
+ failure ??= internalFailure(error, "remove temporary directory");
451
+ }
452
+ }
453
+ }
454
+ if (failure) throw new Error(failure);
455
+ },
456
+ };
457
+ }
458
+
303
459
  /**
304
460
  * One picture to publish.
305
461
  *
@@ -444,6 +600,8 @@ async function pooled(items, limit, work) {
444
600
  * planned size once the run ends — or would be, in a dry run.
445
601
  */
446
602
  export async function publishToCdn(plan, { store, dryRun = false, log = () => {} }) {
603
+ for (const upload of plan.uploads) validateCdnKey(upload.key);
604
+ validateCdnKey(plan.set?.key);
447
605
  if (!dryRun && !store.write) {
448
606
  throw new Error(`the store (${store.describe}) is read-only, so it can only be dry-run`);
449
607
  }