@johnhenry/sitefile 0.0.0 → 0.0.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +58 -20
  3. package/lib/src/checklinks.js +2 -0
  4. package/lib/src/cli/build-image.js +2 -2
  5. package/lib/src/cli/cache.js +15 -5
  6. package/lib/src/cli/images.js +3 -3
  7. package/lib/src/cli/index.d.ts +1 -1
  8. package/lib/src/cli/index.js +1 -1
  9. package/lib/src/cli/network.d.ts +34 -0
  10. package/lib/src/cli/network.js +56 -0
  11. package/lib/src/cli/outdated.js +5 -0
  12. package/lib/src/cli/registry.js +6 -1
  13. package/lib/src/cli/servers.js +23 -7
  14. package/lib/src/cli/vendor.js +5 -0
  15. package/lib/src/cli.js +44 -8
  16. package/lib/src/errors.d.ts +25 -3
  17. package/lib/src/errors.js +9 -0
  18. package/lib/src/executor.d.ts +7 -0
  19. package/lib/src/executor.js +18 -10
  20. package/lib/src/export-formats.d.ts +10 -3
  21. package/lib/src/export-formats.js +17 -6
  22. package/lib/src/index.d.ts +2 -1
  23. package/lib/src/index.js +2 -1
  24. package/lib/src/language/ast.d.ts +9 -2
  25. package/lib/src/outdated.js +1 -1
  26. package/lib/src/planner.d.ts +1 -1
  27. package/lib/src/planner.js +2 -2
  28. package/lib/src/plugins/cache.d.ts +29 -3
  29. package/lib/src/plugins/cache.js +104 -5
  30. package/lib/src/registry.d.ts +6 -0
  31. package/lib/src/registry.js +38 -19
  32. package/lib/src/resources/https.d.ts +35 -1
  33. package/lib/src/resources/https.js +175 -18
  34. package/lib/src/resources/network.d.ts +70 -0
  35. package/lib/src/resources/network.js +304 -0
  36. package/lib/src/resources/types.d.ts +6 -0
  37. package/lib/src/secrets.js +1 -1
  38. package/lib/src/serve-rules.js +2 -1
  39. package/lib/src/servers.d.ts +14 -0
  40. package/lib/src/servers.js +89 -3
  41. package/lib/src/vendor.js +1 -1
  42. package/lib/src/wbn.d.ts +21 -0
  43. package/lib/src/wbn.js +111 -17
  44. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,109 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.1 (2026-11-08)
4
+
5
+ - **`serve` serves archive files** (johnhenry/sitefile#155): `sitefile serve
6
+ site.zip` (and `serve -d`) serves a zip, tar, tar.gz/tgz, Web Bundle or OCI
7
+ image archive (`build -o x.oci.tar`, `save`) the way it serves a directory.
8
+ The format comes from the bytes; a path-like argument that is a file is an
9
+ archive, and a bare name is an archive file when no image has that name.
10
+ Zip, tar and wbn are extracted with the default archive limits and served
11
+ without hidden files (as a directory is); an OCI archive serves its first
12
+ image as the store would, with every blob checked against its digest. The
13
+ file is read again on every start and `server restart`. Server records gain
14
+ `{"kind": "archive", "path": …}` (older records load unchanged); Rust adds
15
+ `ServerSource::Archive`, `servers::load_archive_tree` and
16
+ `load_archive_tree_with`, with every existing signature unchanged. A file
17
+ that is not an archive is `E_TYPE` naming it. docs/CLI.md, "Site servers".
18
+ - **Explicit network settings: proxy, host rules, connect/read timeouts**
19
+ (johnhenry/sitefile#156), for networks that need a proxy and for sitefile
20
+ Desktop's Settings screen. `--proxy` / `SITEFILE_PROXY` (an `http://` proxy:
21
+ a CONNECT tunnel for https, absolute form for http; `HTTP(S)_PROXY` is still
22
+ never read), `--no-proxy` / `SITEFILE_NO_PROXY` (`NO_PROXY` syntax),
23
+ `--allow-hosts` / `SITEFILE_ALLOW_HOSTS` and `--deny-hosts` /
24
+ `SITEFILE_DENY_HOSTS` (checked on every hop, registries included; deny beats
25
+ allow; the new code `E_HOST_BLOCKED` names the rule), and `--connect-timeout`
26
+ / `--read-timeout` / `SITEFILE_FETCH_TIMEOUT`, on `build`, `validate`,
27
+ `inspect`, `graph`, `why`, `pull`, `push`, `vendor` and `outdated`. A proxy
28
+ never widens the fetch policy: the target is resolved and checked against
29
+ the private-address rule before the proxy is asked. Embedders pass the same
30
+ settings as `FetchPolicy.network` / `RegistryOptions.network`
31
+ (`NetworkSettings`, additive in both implementations). docs/CLI.md,
32
+ "Network"; `test/network.test.ts`, `rust/src/resources/network.rs`, and the
33
+ `net-proxy*`, `net-no-proxy`, `net-allow-hosts` and `net-deny-hosts`
34
+ conformance cases. Added in c131eb3 (#162).
35
+
36
+ - **Library API for embedders** (johnhenry/sitefile#153), found by embedding
37
+ the crate in sitefile Desktop. Rust: `servers::serve_detached`,
38
+ `restart_detached`, `start_detached`, `stop_server`, `resolve_source`,
39
+ `parse_publish` and `DetachedServe` (the CLI's `serve -d`, `server restart`
40
+ and `server stop` now call them); `executor::store_build_as_image` with
41
+ `ImageInfo` and `StoredImage` (what `build -t` calls);
42
+ `BuildOptions::store_home`, so `FROM <local image>`, `BASE` and the wasm
43
+ module cache use an embedder's store instead of `$SITEFILE_HOME`;
44
+ `BuildOptions::cancel` with `CancelToken`, checked between imports, stages,
45
+ operations and plugin or tool calls, failing with the new code
46
+ `E_CANCELLED`; detached workers are reaped on a background thread, so a
47
+ long-lived host can stop them without waiting out the grace period.
48
+ Both implementations: TRANSFORM cache entries record their PLUGIN or TOOL
49
+ (`"plugin"`, same bytes in both; older entries are still read, keys are
50
+ unchanged), with `usage_by_plugin`/`usageByPlugin`,
51
+ `prune_plugin`/`prunePlugin` and `sitefile cache prune --plugin <name>`; the
52
+ `E_UNSUPPORTED` error of a PLUGIN or TOOL that is not allowed carries
53
+ `needs` (kind, name, flag), with the message unchanged. CLI output is
54
+ otherwise unchanged. docs/RUST.md, "Embedding the crate";
55
+ `rust/tests/embed.rs`. Added in e0c555d (#154).
56
+
57
+ - **`EXPORT … AS wbn COMPRESSION gzip`** (johnhenry/sitefile#143): a Web Bundle
58
+ export can now be gzip(`application/webbundle`), the `.wbn` archive format of
59
+ `@johnhenry/packfile` (`toArchive`/`fromArchive`) and `@johnhenry/fileable`
60
+ (`encode="wbn"`). `COMPRESSION` used to be `E_VALIDATION` on anything but
61
+ `oci`; it now applies to `oci` and `wbn`, and on `wbn` defaults to `none`, so
62
+ existing exports are byte-for-byte unchanged. The gzip layer only wraps the
63
+ finished bundle and uses the shared deterministic encoder (`src/deflate.ts`,
64
+ `rust/src/deflate.rs`), so both CLIs write identical bytes. On the CLI,
65
+ `-o site.wbn.gz` selects a gzipped wbn (also for `sitefile save`), the
66
+ default destination of such a Sitefile is `dist.wbn.gz`, and the new
67
+ `--compression gzip|none` overrides `EXPORT … COMPRESSION` for the export's
68
+ format (oci or wbn; any other format is a usage error); `--oci-compression`
69
+ stays as the oci-only spelling. A Sitefile's `COMPRESSION` belongs to its own
70
+ `AS` format: `--format wbn` on an `AS oci COMPRESSION …` Sitefile does not
71
+ inherit it. `test/packfile-interop.test.ts` now reads a gzip export built with
72
+ `BASE https://packfile.invalid/` through plain `fromArchive(buffer)`; new
73
+ conformance cases compare the bytes of both implementations. Added in
74
+ 3c537de (#149).
75
+
76
+ - **Example: a fileable plugin for TRANSFORM** (`examples/fileable/`,
77
+ johnhenry/sitefile#145). A JavaScript plugin renders a `@johnhenry/fileable`
78
+ tree into the build with fileable's in-memory `toFiles()` and returns
79
+ `{ files }`, so a JSX file tree needs no directory or zip on disk in
80
+ between. fileable stays out of Sitefile's dependencies: it is a
81
+ devDependency, and `examples/fileable/vendor.mjs` copies the files its
82
+ browser entry point loads into the plugin folder (gitignored; nothing
83
+ generated is committed), the only folder a plugin can read. Its `cmd`
84
+ attribute and other features that need files, processes or the environment
85
+ fail with a clear `E_PLUGIN`. Checked by `npm run examples` and by
86
+ conformance (both CLIs). docs/PLUGINS.md, "Example: rendering a fileable
87
+ tree". Added in 0b5c4b3 (#147).
88
+
89
+ - **`EXPORT … AS wbn` bakes HEADER and exact REDIRECT rules into the bundle's
90
+ responses** (johnhenry/sitefile#144, #150). Each response gets the headers of the
91
+ `/_headers` blocks matching its URL, with `sitefile serve`'s matching
92
+ (`serve-rules.ts`; a directory URL like `<base>docs/` matches with its
93
+ slash and gets its own response when its headers differ). Exact-path
94
+ `/_redirects` rules with a 3xx status become responses with that status, a
95
+ `location` and an empty body, in `serve`'s order (a forced `301!` replaces
96
+ the file; other rules apply only where `serve` would find nothing). Splats,
97
+ `200` rewrites and `404` rules stay in `/_redirects`, which, like
98
+ `/_headers`, is still in the bundle. Bundles of trees without those files
99
+ are byte-identical to before. packfile's `createWebBundleRouter()` now
100
+ answers with the HEADER values and redirects (`test/packfile-interop.test.ts`);
101
+ conformance cases `ok-wbn-rules` and `ok-wbn-rules-files` compare both
102
+ implementations' bytes. The TypeScript `headersFor` no longer drops a
103
+ header named `__proto__` (the Rust port never did); this also
104
+ changes the TypeScript `sitefile serve`, which now sends such a header as
105
+ the Rust binary does. Added in 429ab24 (#150).
106
+
3
107
  ## 0.0.0 — first npm release (2026-11-06)
4
108
 
5
109
  `@johnhenry/sitefile` is a new package: Sitefile was never published to npm
package/README.md CHANGED
@@ -84,10 +84,10 @@ no runtime dependencies.
84
84
  | `TOOL name` + `TRANSFORM … WITH name TO <dest> [OPTIONS …] [FOR …] ARGS …` | yes; run an external program (e.g. `ffmpeg`) you allow with `--allow-exec name`: argv only, no shell, temp files, scrubbed environment (see [docs/PLUGINS.md](docs/PLUGINS.md)) |
85
85
  | `SITEMAP BASE <url> [TO <dest>]` | yes; a sitemaps.org `/sitemap.xml` of the `.html` pages so far |
86
86
  | `FEED <import> BASE <url> LINK <path> [TO <dest>] [TITLE <text>]` | yes; an Atom `/feed.xml` from Markdown posts (`title` and `date` required) |
87
- | `REDIRECT <from> <to> [STATUS 301\|302\|303\|307\|308]`, `HEADER <pattern> <Name>: <value>` | yes; `/_redirects` and `/_headers` for Netlify / Cloudflare Pages (and `sitefile serve`) |
87
+ | `REDIRECT <from> <to> [STATUS 301\|302\|303\|307\|308]`, `HEADER <pattern> <Name>: <value>` | yes; `/_redirects` and `/_headers` for Netlify / Cloudflare Pages (and `sitefile serve`); a `wbn` export also bakes HEADER values into each response and exact-path REDIRECTs into 3xx responses (splats stay in `/_redirects`) |
88
88
  | `CHECKLINKS [--warn] [--external]` | yes; fails (or warns) on links to files that do not exist, with file and line; `--external` requests http(s) links under the network policy |
89
89
  | `FINGERPRINT [--overwrite] <pattern>… [MANIFEST <dest>]` | yes; renames assets to `name.<hash>.ext` and rewrites the HTML and CSS that reference them, plus a JSON manifest |
90
- | `EXPORT <path> [AS directory\|zip\|tar\|tgz\|wbn\|oci] [BASE <https-url>] [AT <root>]` | yes; archives are deterministic, `wbn` is an unsigned Web Bundle (needs `BASE` or `--base-url`), `oci` is a single-layer OCI image of the files (under `AT`, default `/srv/www`) |
90
+ | `EXPORT <path> [AS directory\|zip\|tar\|tgz\|wbn\|oci] [BASE <https-url>] [AT <root>] [COMPRESSION gzip\|none]` | yes; archives are deterministic, `wbn` is an unsigned Web Bundle (needs `BASE` or `--base-url`; `COMPRESSION gzip` gzips it, default `none`), `oci` is a single-layer OCI image of the files (under `AT`, default `/srv/www`; `COMPRESSION` sets its layer, default `gzip`) |
91
91
 
92
92
  Templates can also live in the Sitefile itself (see `examples/inline/`):
93
93
 
@@ -163,7 +163,7 @@ build used, `--cache-from <ref>` reuses them (see [docs/CLI.md](docs/CLI.md#regi
163
163
  ## CLI
164
164
 
165
165
  ```
166
- sitefile build [context] [-o dist] [--format directory|zip|tar|tgz|wbn] [--base-url URL]
166
+ sitefile build [context] [-o dist] [--format directory|zip|tar|tgz|wbn] [--base-url URL] [--compression gzip|none]
167
167
  [--build-arg K=V | --build-arg K] [--build-arg-file vars.json|.yaml|.env] [--force]
168
168
  [--frozen-lockfile | --update-lock] [--strict-pins] [--allow-http] [--allow-private-network]
169
169
  [--allow-hardlinks] [--allow-exec name[=/abs/path]]... [--allow-plugin name]... [--allow-plugins]
@@ -181,7 +181,7 @@ sitefile build [context] -t name[:tag] # also store the build as an image
181
181
  sitefile images | image inspect|diff|prune | tag | rmi | save
182
182
  sitefile cache info | cache prune [--older-than days] # TRANSFORM result cache
183
183
  sitefile cache du # cache sizes; SITEFILE_CACHE_MAX bounds them (default 1G)
184
- sitefile serve [-d] [--name n] [-p [host:]port] [--spa] <image|./dir>
184
+ sitefile serve [-d] [--name n] [-p [host:]port] [--spa] <image|./dir|site.zip>
185
185
  sitefile serve --watch [-p [host:]port] [--spa] [context] # build in memory, serve, live reload
186
186
  sitefile servers [-a] | server stop|rm [-f]|restart|logs [-f] <name>
187
187
  ```
@@ -273,6 +273,7 @@ CycloneDX file list to the image as OCI 1.1 referrers (deterministic, pushed wit
273
273
  sitefile build examples/hello --format zip # examples/hello/dist.zip
274
274
  sitefile build examples/hello -o hello.tar.gz # format from the extension
275
275
  sitefile build examples/hello --format wbn --base-url https://example.com/
276
+ sitefile build examples/hello -o hello.wbn.gz --base-url https://example.com/ # gzip(Web Bundle)
276
277
  ```
277
278
 
278
279
  Archives are reproducible: sorted entries, fixed timestamps (or `SOURCE_DATE_EPOCH`),
@@ -324,6 +325,7 @@ stopgap in VS Code.
324
325
  - [docs/RUST.md](docs/RUST.md) — the Rust port: layout, parity checks, known differences
325
326
  - [docs/LSP.md](docs/LSP.md) — the language server for editors (`sitefile lsp`, Rust binary)
326
327
  - [docs/PLUGINS.md](docs/PLUGINS.md) — writing renderer plugins, and the sandbox
328
+ - [docs/DOCKER.md](docs/DOCKER.md) — Sitefile next to Docker: site images Docker runs, Dockerfile stages, shared registries
327
329
  - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — invariants and module layout
328
330
  - [docs/MILESTONES.md](docs/MILESTONES.md), [docs/TASKS.md](docs/TASKS.md)
329
331
  - [AGENTS.md](AGENTS.md) — the verification loop and rules for anyone (human or agent) changing this repo
@@ -388,7 +390,11 @@ tools are code, and run only when you name them on the command line.
388
390
  - **Remote imports are `https:` only, to public addresses only** — checked at
389
391
  connect time and on every redirect (SSRF), size- and time-limited, never
390
392
  decompressed or executed. `--allow-http` and `--allow-private-network` are the
391
- only ways to widen this.
393
+ only ways to widen this. An explicit proxy (`--proxy` / `$SITEFILE_PROXY`;
394
+ `HTTP(S)_PROXY` is never read) does not widen it either: sitefile resolves and
395
+ checks the target itself before handing it to the proxy. `--allow-hosts` and
396
+ `--deny-hosts` narrow it further, on every hop
397
+ ([docs/CLI.md](docs/CLI.md#network-proxy-host-rules-and-timeouts)).
392
398
  - **Pins are enforced.** Unpinned URLs are recorded in `Sitefile.lock` and must
393
399
  match on later builds; `--strict-pins` refuses any remote import or BASE image
394
400
  that is not pinned (`INTEGRITY`, `@sha256:` or a lockfile entry).
@@ -451,25 +457,57 @@ tools are code, and run only when you name them on the command line.
451
457
  Sitefile is the build orchestrator of the @johnhenry family's static tools. It
452
458
  runs the pipeline (pinned imports, stages, templates, sandboxed plugins, checks,
453
459
  exports); its siblings write file trees in code and serve the result. It has no
454
- runtime dependency on either: they meet at file formats, a directory or a `.wbn`
455
- Web Bundle.
460
+ runtime dependency on either: they meet at file formats, a directory, a zip or
461
+ a `.wbn` Web Bundle.
456
462
 
457
463
  - **[`@johnhenry/fileable`](https://github.com/johnhenry/fileable)** -- writes
458
464
  file trees in JSX (`Dir`, `File`, `Rm`), for scaffolding and codegen. A
459
- fileable build's output directory, or its `encode="zip"` archive, is an
460
- ordinary Sitefile input: `COPY ./generated/ /`, or `IMPORT gen FROM
461
- ./site.zip AS tree` and `COPY gen:/ /` (both give the same output). Its
462
- `encode="wbn"` archive is gzip around a Web Bundle, which `AS tree` does not
463
- read yet (johnhenry/sitefile#141). Neither package depends on the other.
465
+ fileable build's output is an ordinary Sitefile input. Neither package
466
+ depends on the other.
464
467
  - **[`@johnhenry/packfile`](https://github.com/johnhenry/packfile)** -- the
465
- family's Web Bundle reader and in-memory server. `EXPORT / AS wbn BASE <url>`
466
- writes a plain b2 Web Bundle that packfile's `fromWebBundle(buffer, { baseURL })`
467
- reads back into exactly the exported files, and `createRouter()` serves as a
468
- `(Request) => Response` handler. packfile's own archive format
469
- (`fromArchive()`) is gzip around the same bundle under
470
- `https://packfile.invalid/`: gzip a Sitefile bundle exported with that BASE and
471
- `fromArchive()` reads it as is. `test/packfile-interop.test.ts` checks all
472
- three; `@johnhenry/packfile` is a devDependency for that test only.
468
+ family's Web Bundle reader and in-memory server. It reads what `EXPORT … AS
469
+ wbn` writes and serves it as a `(Request) => Response` handler.
470
+ `@johnhenry/packfile` is a devDependency here for
471
+ `test/packfile-interop.test.ts` only.
472
+
473
+ ### Working together: what works today
474
+
475
+ Checked on 2026-11-07 against the npm releases (`@johnhenry/sitefile` 0.0.0,
476
+ `@johnhenry/fileable` 0.1.1, `@johnhenry/packfile` 0.2.0) and each
477
+ repository's `main`. "Fixed on main, in the next release" means merged but
478
+ not published: the npm versions still behave as the rest of that row says.
479
+
480
+ | From | To | How | Status |
481
+ | --- | --- | --- | --- |
482
+ | fileable, loose output | Sitefile | `COPY ./generated/ /` | Works |
483
+ | fileable, `encode="zip"` | Sitefile | `IMPORT gen FROM ./site.zip AS tree`, then `COPY gen:/ /` | Works (same output as loose) |
484
+ | fileable, `encode="wbn"` | Sitefile | `IMPORT … AS tree` | **Not yet:** it's gzip around a Web Bundle, and `AS tree` reads only plain bundles ([#141](https://github.com/johnhenry/sitefile/issues/141)) |
485
+ | fileable tree, rendered during a build | Sitefile | a JavaScript `TRANSFORM` plugin calling fileable's in-memory `toFiles()` | An example on main, not part of the npm package: [`examples/fileable/`](examples/fileable/) ([#145](https://github.com/johnhenry/sitefile/issues/145)). fileable is vendored into the plugin folder; `cmd`, local `src=` and `env://` fail with `E_PLUGIN` |
486
+ | Sitefile, `EXPORT / AS wbn BASE <url>` | packfile | `fromWebBundle(buffer, { baseURL })` from `@johnhenry/packfile/web-bundle`, then `createRouter(files)` | Works; tested here (`test/packfile-interop.test.ts`), and packfile's own tests read a bundle written by Sitefile 0.0.0 |
487
+ | Sitefile `.wbn` | packfile `fromArchive()` | with 0.0.0 / 0.2.0: gzip it, and export with `BASE https://packfile.invalid/` | Works with that workaround. **Fixed on main, in the next release:** `EXPORT … AS wbn COMPRESSION gzip` (or `-o site.wbn.gz`) writes the gzip ([#143](https://github.com/johnhenry/sitefile/issues/143)), and `fromArchive()` takes a `baseURL` or the origin of the bundle's primary URL, so any `BASE` reads back ([packfile#18](https://github.com/johnhenry/packfile/issues/18)) |
488
+ | Sitefile `HEADER` / `REDIRECT` | packfile `createWebBundleRouter()` | -- | **Not applied in 0.0.0:** only the `/_headers` and `/_redirects` files are in the bundle. **Fixed on main, in the next release:** each response carries its HEADER values, and exact-path 301/302/303/307/308 REDIRECTs are 3xx responses with `location` ([#144](https://github.com/johnhenry/sitefile/issues/144)). Splats, `200` rewrites and `404` rules stay in `/_redirects` only |
489
+ | packfile `toArchive()` | Sitefile | `IMPORT … AS tree` | **Not yet** (same as fileable's `.wbn`, [#141](https://github.com/johnhenry/sitefile/issues/141)) |
490
+
491
+ Gotchas when serving a Sitefile bundle with packfile:
492
+
493
+ - **Pass `createRouter()` a path string for non-ASCII paths.** A `Request`
494
+ for `/docs/ünï/page.html` returns 404 because the pathname isn't
495
+ percent-decoded; the string `"/docs/ünï/page.html"` works
496
+ ([packfile#20](https://github.com/johnhenry/packfile/issues/20)).
497
+ - **`fromWebBundle` skips the bundle's entry at the bare base URL.** Sitefile
498
+ stores the index page there as well as at `/index.html`; you get it from
499
+ `index.html`.
500
+ - **The `baseURL` you pass to `fromWebBundle` must be the `BASE` you exported
501
+ with.** Entries under any other origin are skipped, so the wrong base reads
502
+ back as an empty map, without an error. packfile's `fromArchive()` on its
503
+ main branch (next release) defaults to the origin of the primary URL, which
504
+ Sitefile writes only when the site has `/index.html`; with a `BASE` that has
505
+ a path (`https://site.example/blog/`), the keys start with `blog/` unless you
506
+ pass that `baseURL`.
507
+ - **HEADER and REDIRECT need `createWebBundleRouter()`** (on main, next
508
+ release): `createWebBundleRouter(new wbn.Bundle(bytes), { baseURL })` serves
509
+ each exchange's own status and headers. `createRouter()` serves a files map,
510
+ so it answers with the files only.
473
511
 
474
512
  ## License
475
513
 
@@ -44,6 +44,8 @@ function externalReason(e, url, policy) {
44
44
  return /^http:/i.test(url) && !policy.allowHttp ? 'plain http is checked only with --allow-http' : 'not allowed by the network policy';
45
45
  case 'E_SSRF_BLOCKED':
46
46
  return 'a non-public address (checked only with --allow-private-network)';
47
+ case 'E_HOST_BLOCKED':
48
+ return 'refused by the allow-hosts/deny-hosts rules';
47
49
  case 'E_REDIRECT':
48
50
  return 'too many or invalid redirects';
49
51
  default:
@@ -1,6 +1,6 @@
1
1
  import { sourceDateEpoch } from '../archive-writers.js';
2
2
  import { SitefileError } from '../errors.js';
3
- import { imageAttestations } from '../executor.js';
3
+ import { imageAttestations, sitefileCompression } from '../executor.js';
4
4
  import { imageMeta } from '../oci.js';
5
5
  import { parseLocalRef } from '../ref.js';
6
6
  import { shortId, Store } from '../store.js';
@@ -20,7 +20,7 @@ export async function storeBuildAsImage(result, tags, info) {
20
20
  const store = await Store.open();
21
21
  const { id } = await store.addImage(result.output, {
22
22
  root: info.ociRoot ?? result.plan.exportRoot,
23
- compression: info.ociCompression ?? result.plan.exportCompression,
23
+ compression: info.ociCompression ?? sitefileCompression(result.plan, 'oci'),
24
24
  platform: info.platform,
25
25
  epoch: sourceDateEpoch(),
26
26
  base: result.base,
@@ -24,17 +24,24 @@ const info = {
24
24
  const prune = {
25
25
  name: 'cache prune',
26
26
  summary: 'delete cached TRANSFORM results',
27
- usage: 'cache prune [--older-than <days> | --max-size <size>]',
28
- description: 'Delete every cached TRANSFORM result, or only entries not used for the given number of days, and the blobs no\nentry still uses. With --max-size, instead apply the automatic size limit now with that size: least recently\nused evictable data (TRANSFORM results, compiled wasm modules, untagged images and unused blobs) is deleted\nuntil the rest fits. Tagged images are never touched.',
29
- flags: [' --older-than <days> keep entries used within this many days', ' --max-size <size> shrink evictable data to this size (e.g. 500M, 2G)'],
30
- options: { 'older-than': { type: 'string' }, 'max-size': { type: 'string' } },
27
+ usage: 'cache prune [--older-than <days>] [--plugin <name>] | --max-size <size>',
28
+ description: 'Delete every cached TRANSFORM result, or only entries not used for the given number of days, and the blobs no\nentry still uses. With --plugin, only the results of that PLUGIN or TOOL (entries written before results\nrecorded their plugin are kept). With --max-size, instead apply the automatic size limit now with that size:\nleast recently used evictable data (TRANSFORM results, compiled wasm modules, untagged images and unused\nblobs) is deleted until the rest fits. Tagged images are never touched.',
29
+ flags: [
30
+ ' --older-than <days> keep entries used within this many days',
31
+ ' --plugin <name> only the results of this PLUGIN or TOOL',
32
+ ' --max-size <size> shrink evictable data to this size (e.g. 500M, 2G)',
33
+ ],
34
+ options: { 'older-than': { type: 'string' }, plugin: { type: 'string' }, 'max-size': { type: 'string' } },
31
35
  async run(v, pos) {
32
36
  requireArgs(pos, 0, 0, 'no arguments');
33
37
  const raw = v['older-than'];
34
38
  const max = str(v['max-size']);
39
+ const plugin = str(v['plugin']);
35
40
  if (max !== undefined) {
36
41
  if (raw !== undefined)
37
42
  usage('--older-than and --max-size conflict');
43
+ if (plugin !== undefined)
44
+ usage('--plugin and --max-size conflict');
38
45
  const n = parseSize(max);
39
46
  if (n === undefined || n === 0)
40
47
  usage('--max-size expects a size greater than 0, such as 500M or 2G');
@@ -49,7 +56,10 @@ const prune = {
49
56
  usage('--older-than expects a positive number of days');
50
57
  ms = days * 86_400_000;
51
58
  }
52
- const r = await new TransformCache(transformCacheDir()).prune(ms);
59
+ if (plugin !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(plugin))
60
+ usage(`--plugin expects a PLUGIN or TOOL name (an identifier), got '${plugin}'`);
61
+ const cache = new TransformCache(transformCacheDir());
62
+ const r = plugin === undefined ? await cache.prune(ms) : await cache.prunePlugin(plugin, ms);
53
63
  process.stdout.write(`Deleted ${r.entries} entr${r.entries === 1 ? 'y' : 'ies'} and ${r.blobs} blob${r.blobs === 1 ? '' : 's'}\nTotal reclaimed space: ${humanSize(r.bytes)}\n`);
54
64
  return 0;
55
65
  },
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { sourceDateEpoch, writeTar } from '../archive-writers.js';
5
5
  import { SitefileError } from '../errors.js';
6
6
  import { exportDirectory, exportFile } from '../exporter.js';
7
- import { encodeTree, formatFromExtension, isArchiveFormat } from '../export-formats.js';
7
+ import { compressionFromExtension, encodeTree, formatFromExtension, isArchiveFormat } from '../export-formats.js';
8
8
  import { EXPORT_FORMATS, parseExportFormat } from '../language/ast.js';
9
9
  import { MEDIA, siteLayerIndex } from '../oci.js';
10
10
  import { looksLikeImageId, parseLocalRef, refKey } from '../ref.js';
@@ -175,7 +175,7 @@ const save = {
175
175
  name: 'save',
176
176
  summary: 'export an image to a directory or archive',
177
177
  usage: 'save <ref|id> -o <out> [--format fmt] [--base-url url] [--force]',
178
- description: 'Write an image\'s files to a directory, or to a zip, tar, tar.gz (tgz) or wbn archive, or the image itself as\nan OCI archive (-o x.oci.tar) or OCI layout directory (--format oci). The format is chosen like "build -o":\nfrom --format, else the extension (.zip .tar .tar.gz .tgz .wbn .oci.tar), else a directory.\nArchives are deterministic (set SOURCE_DATE_EPOCH to stamp them). A non-empty destination needs --force.',
178
+ description: 'Write an image\'s files to a directory, or to a zip, tar, tar.gz (tgz) or wbn archive, or the image itself as\nan OCI archive (-o x.oci.tar) or OCI layout directory (--format oci). The format is chosen like "build -o":\nfrom --format, else the extension (.zip .tar .tar.gz .tgz .wbn .wbn.gz .oci.tar), else a directory; a wbn\nwritten to *.wbn.gz is gzipped.\nArchives are deterministic (set SOURCE_DATE_EPOCH to stamp them). A non-empty destination needs --force.',
179
179
  flags: [
180
180
  '-o, --out <path> destination (required)',
181
181
  ` --format <fmt> ${EXPORT_FORMATS.join(' | ')} (alias tar.gz)`,
@@ -223,7 +223,7 @@ const save = {
223
223
  process.stdout.write(`Saved ${shortId(id)} (${tree.size} files) to ${shown(w.outDir)}\n`);
224
224
  }
225
225
  else {
226
- const data = encodeTree(tree, format, { epoch: sourceDateEpoch(), baseUrl: str(v['base-url']) });
226
+ const data = encodeTree(tree, format, { epoch: sourceDateEpoch(), baseUrl: str(v['base-url']), compression: compressionFromExtension(out, format) });
227
227
  const w = await exportFile(data, out, opts);
228
228
  process.stdout.write(`Saved ${shortId(id)} (${tree.size} files) to ${shown(w.outFile)} (${format === 'tgz' ? 'tar.gz' : format}, ${w.bytes} bytes)\n`);
229
229
  }
@@ -5,6 +5,6 @@ export declare const EXTRA_COMMANDS: readonly string[];
5
5
  * an editor tool. Here `sitefile lsp` is a usage error that points there.
6
6
  */
7
7
  export declare const LSP_UNAVAILABLE = "'sitefile lsp' (the language server) is provided by the Rust implementation: build it with 'cd rust && cargo build --release' and run 'rust/target/release/sitefile lsp' (see docs/LSP.md)";
8
- export declare const EXTRA_HELP = "\nImages (local store: $SITEFILE_HOME or ~/.sitefile):\n sitefile build [context] -t name[:tag] also store the result as an image (repeatable -t)\n sitefile images [--json] [--digests] [-q] list images (alias: image ls)\n sitefile image inspect <ref> [--json] show an image manifest\n sitefile image prune delete unreferenced manifests and blobs\n sitefile image diff <a> <b> added/removed/changed paths\n sitefile tag <src> <dst> add a name:tag\n sitefile rmi [-f] <ref|id>... remove tags / images\n sitefile save <ref> -o out[.zip|.tar|.tar.gz|.wbn] export an image\n sitefile push <registry/name[:tag]> push a (tagged) image to an OCI registry\n sitefile pull <[registry/]name[:tag][@digest]> pull an image (a bare name means Docker Hub)\n\nVariants:\n sitefile bake [targets...] [-f file] [--print] build several targets (matrix, groups) from\n sitefile.bake.yaml; --print shows the plan\n\nOffline builds:\n sitefile vendor [context] [--vendor-dir dir] download remote imports and the BASE image into\n vendor/sitefile; builds then read them from there\n\nPins:\n sitefile outdated [context] [--json] compare remote imports and the BASE image with\n their pins (never changes anything)\n\nBuild cache (TRANSFORM results: $SITEFILE_HOME/cache/transform):\n sitefile cache info [--json] location, entries, size\n sitefile cache prune [--older-than <days>] delete cached results\n sitefile cache prune --max-size <size> shrink evictable data (LRU) to <size>\n sitefile cache du [--json] size of every cache and the store (alias: cache ls)\n Evictable data over $SITEFILE_CACHE_MAX (default 1GiB, 0 = no limit) is deleted after each build.\n\nServing sites (detached servers are tracked by name):\n sitefile serve [--name n] [-p [host:]port] [-d] [--spa] <ref|./dir>\n sitefile serve --watch [-p [host:]port] [ctx] build in memory, serve, rebuild + live reload\n sitefile servers [-a] [--json] list servers (alias: server ls)\n sitefile server stop|rm [-f]|restart|logs [-f] <name|id>\n\nEditor support:\n sitefile lsp [--stdio] language server over stdio (Rust build only; docs/LSP.md)\n\nRun 'sitefile <command> --help' for the options of a command.\n";
8
+ export declare const EXTRA_HELP = "\nImages (local store: $SITEFILE_HOME or ~/.sitefile):\n sitefile build [context] -t name[:tag] also store the result as an image (repeatable -t)\n sitefile images [--json] [--digests] [-q] list images (alias: image ls)\n sitefile image inspect <ref> [--json] show an image manifest\n sitefile image prune delete unreferenced manifests and blobs\n sitefile image diff <a> <b> added/removed/changed paths\n sitefile tag <src> <dst> add a name:tag\n sitefile rmi [-f] <ref|id>... remove tags / images\n sitefile save <ref> -o out[.zip|.tar|.tar.gz|.wbn] export an image\n sitefile push <registry/name[:tag]> push a (tagged) image to an OCI registry\n sitefile pull <[registry/]name[:tag][@digest]> pull an image (a bare name means Docker Hub)\n\nVariants:\n sitefile bake [targets...] [-f file] [--print] build several targets (matrix, groups) from\n sitefile.bake.yaml; --print shows the plan\n\nOffline builds:\n sitefile vendor [context] [--vendor-dir dir] download remote imports and the BASE image into\n vendor/sitefile; builds then read them from there\n\nPins:\n sitefile outdated [context] [--json] compare remote imports and the BASE image with\n their pins (never changes anything)\n\nBuild cache (TRANSFORM results: $SITEFILE_HOME/cache/transform):\n sitefile cache info [--json] location, entries, size\n sitefile cache prune [--older-than <days>] delete cached results\n sitefile cache prune --max-size <size> shrink evictable data (LRU) to <size>\n sitefile cache du [--json] size of every cache and the store (alias: cache ls)\n Evictable data over $SITEFILE_CACHE_MAX (default 1GiB, 0 = no limit) is deleted after each build.\n\nServing sites (detached servers are tracked by name):\n sitefile serve [--name n] [-p [host:]port] [-d] [--spa] <ref|./dir|file>\n sitefile serve --watch [-p [host:]port] [ctx] build in memory, serve, rebuild + live reload\n sitefile servers [-a] [--json] list servers (alias: server ls)\n sitefile server stop|rm [-f]|restart|logs [-f] <name|id>\n\nEditor support:\n sitefile lsp [--stdio] language server over stdio (Rust build only; docs/LSP.md)\n\nRun 'sitefile <command> --help' for the options of a command.\n";
9
9
  export declare function isExtraCommand(name: string | undefined): boolean;
10
10
  export declare function runExtraCommand(argv: readonly string[]): Promise<number>;
@@ -51,7 +51,7 @@ Build cache (TRANSFORM results: $SITEFILE_HOME/cache/transform):
51
51
  Evictable data over $SITEFILE_CACHE_MAX (default 1GiB, 0 = no limit) is deleted after each build.
52
52
 
53
53
  Serving sites (detached servers are tracked by name):
54
- sitefile serve [--name n] [-p [host:]port] [-d] [--spa] <ref|./dir>
54
+ sitefile serve [--name n] [-p [host:]port] [-d] [--spa] <ref|./dir|file>
55
55
  sitefile serve --watch [-p [host:]port] [ctx] build in memory, serve, rebuild + live reload
56
56
  sitefile servers [-a] [--json] list servers (alias: server ls)
57
57
  sitefile server stop|rm [-f]|restart|logs [-f] <name|id>
@@ -0,0 +1,34 @@
1
+ import { type NetworkSettings } from '../resources/network.js';
2
+ /** parseArgs options for the network flags (build, validate, inspect, graph, why, pull, push, vendor, outdated). */
3
+ export declare const NETWORK_OPTIONS: {
4
+ readonly proxy: {
5
+ readonly type: 'string';
6
+ };
7
+ readonly 'no-proxy': {
8
+ readonly type: 'string';
9
+ readonly multiple: true;
10
+ };
11
+ readonly 'allow-hosts': {
12
+ readonly type: 'string';
13
+ readonly multiple: true;
14
+ };
15
+ readonly 'deny-hosts': {
16
+ readonly type: 'string';
17
+ readonly multiple: true;
18
+ };
19
+ readonly 'connect-timeout': {
20
+ readonly type: 'string';
21
+ };
22
+ readonly 'read-timeout': {
23
+ readonly type: 'string';
24
+ };
25
+ };
26
+ /** Help lines for {@link NETWORK_OPTIONS}, at the given flag-column width. */
27
+ export declare function networkHelp(width: number): string[];
28
+ type Values = Record<string, string | boolean | Array<string | boolean> | undefined>;
29
+ /**
30
+ * The network settings for a command: the SITEFILE_* environment, then the flags on top (flags replace
31
+ * the proxy and timeouts and add to the host lists). Throws E_USAGE for a bad value.
32
+ */
33
+ export declare function networkOption(v: Values, env?: NodeJS.ProcessEnv): NetworkSettings | undefined;
34
+ export {};
@@ -0,0 +1,56 @@
1
+ import { SitefileError } from '../errors.js';
2
+ import { mergeNetwork, networkFromEnv, networkProblem, splitList } from '../resources/network.js';
3
+ /** parseArgs options for the network flags (build, validate, inspect, graph, why, pull, push, vendor, outdated). */
4
+ export const NETWORK_OPTIONS = {
5
+ proxy: { type: 'string' },
6
+ 'no-proxy': { type: 'string', multiple: true },
7
+ 'allow-hosts': { type: 'string', multiple: true },
8
+ 'deny-hosts': { type: 'string', multiple: true },
9
+ 'connect-timeout': { type: 'string' },
10
+ 'read-timeout': { type: 'string' },
11
+ };
12
+ /** Help lines for {@link NETWORK_OPTIONS}, at the given flag-column width. */
13
+ export function networkHelp(width) {
14
+ const rows = [
15
+ ['--proxy <url>', ['send requests through this HTTP proxy: http://[user:password@]host[:port]', '(also $SITEFILE_PROXY; HTTP(S)_PROXY are never read)']],
16
+ ['--no-proxy <hosts>', ['hosts reached directly, not through the proxy (NO_PROXY syntax; repeatable,', 'comma-separated; also $SITEFILE_NO_PROXY)']],
17
+ ['--allow-hosts <hosts>', ['contact only hosts matching these patterns (repeatable, comma-separated;', 'also $SITEFILE_ALLOW_HOSTS)']],
18
+ ['--deny-hosts <hosts>', ['never contact hosts matching these patterns; beats --allow-hosts', '(repeatable, comma-separated; also $SITEFILE_DENY_HOSTS)']],
19
+ ['--connect-timeout <ms>', ['time limit for opening each connection (also $SITEFILE_FETCH_TIMEOUT)']],
20
+ ['--read-timeout <ms>', ['longest wait for data on a connection (also $SITEFILE_FETCH_TIMEOUT)']],
21
+ ];
22
+ const out = [];
23
+ for (const [flag, lines] of rows) {
24
+ lines.forEach((l, i) => out.push(`${(i === 0 ? flag : '').padEnd(width)}${l}`));
25
+ }
26
+ return out;
27
+ }
28
+ function ms(raw, flag) {
29
+ if (raw === undefined)
30
+ return undefined;
31
+ const n = Number(raw);
32
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(n) || n <= 0)
33
+ throw new SitefileError('E_USAGE', `${flag} expects a positive integer`);
34
+ return n;
35
+ }
36
+ const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
37
+ /**
38
+ * The network settings for a command: the SITEFILE_* environment, then the flags on top (flags replace
39
+ * the proxy and timeouts and add to the host lists). Throws E_USAGE for a bad value.
40
+ */
41
+ export function networkOption(v, env = process.env) {
42
+ const flags = {
43
+ ...(typeof v.proxy === 'string' ? { proxy: v.proxy } : {}),
44
+ noProxy: strings(v['no-proxy']).flatMap(splitList),
45
+ allowHosts: strings(v['allow-hosts']).flatMap(splitList),
46
+ denyHosts: strings(v['deny-hosts']).flatMap(splitList),
47
+ connectTimeoutMs: ms(typeof v['connect-timeout'] === 'string' ? v['connect-timeout'] : undefined, '--connect-timeout'),
48
+ readTimeoutMs: ms(typeof v['read-timeout'] === 'string' ? v['read-timeout'] : undefined, '--read-timeout'),
49
+ };
50
+ const merged = mergeNetwork(networkFromEnv(env), flags);
51
+ const problem = networkProblem(merged);
52
+ if (problem)
53
+ throw new SitefileError('E_USAGE', problem);
54
+ return Object.keys(merged).length ? merged : undefined;
55
+ }
56
+ //# sourceMappingURL=network.js.map
@@ -1,3 +1,4 @@
1
+ import { NETWORK_OPTIONS, networkHelp, networkOption } from './network.js';
1
2
  import { insecureRegistriesFromEnv } from '../base-image.js';
2
3
  import { collectBuildArgs } from '../build-args.js';
3
4
  import { outdated } from '../outdated.js';
@@ -47,10 +48,12 @@ const outdatedCommand = {
47
48
  '--allow-private-network permit loopback/private network addresses',
48
49
  `--max-download-bytes <n> per-resource size limit (default ${DEFAULT_FETCH_POLICY.maxBytes})`,
49
50
  `--timeout <ms> per-resource fetch timeout (default ${DEFAULT_FETCH_POLICY.timeoutMs})`,
51
+ ...networkHelp(30),
50
52
  '--base <image> check this BASE image (overrides EXPORT ... AS oci BASE)',
51
53
  '--insecure-registry <h> allow plain http to registry host[:port] (repeatable)',
52
54
  ],
53
55
  options: {
56
+ ...NETWORK_OPTIONS,
54
57
  file: { type: 'string', short: 'f' },
55
58
  json: { type: 'boolean' },
56
59
  'build-arg': { type: 'string', multiple: true },
@@ -68,6 +71,7 @@ const outdatedCommand = {
68
71
  requireArgs(pos, 0, 1, 'a context directory');
69
72
  const maxBytes = positive(str(v['max-download-bytes']), '--max-download-bytes');
70
73
  const timeoutMs = positive(str(v.timeout), '--timeout');
74
+ const network = networkOption(v);
71
75
  const file = str(v.file);
72
76
  const base = str(v.base);
73
77
  const r = await outdated({
@@ -80,6 +84,7 @@ const outdatedCommand = {
80
84
  allowPrivateNetwork: !!v['allow-private-network'],
81
85
  ...(maxBytes ? { maxBytes } : {}),
82
86
  ...(timeoutMs ? { timeoutMs } : {}),
87
+ ...(network ? { network } : {}),
83
88
  },
84
89
  ...(v['no-lockfile'] ? { lockfile: false } : {}),
85
90
  ...(base !== undefined ? { baseImage: base } : {}),
@@ -1,3 +1,4 @@
1
+ import { NETWORK_OPTIONS, networkHelp, networkOption } from './network.js';
1
2
  import { insecureRegistriesFromEnv, makeClient } from '../base-image.js';
2
3
  import { SitefileError } from '../errors.js';
3
4
  import { parseRef, refKey, withDefaultRegistry, canonicalRef } from '../ref.js';
@@ -30,9 +31,11 @@ const COMMON_FLAGS = [
30
31
  ' $SITEFILE_REGISTRY_AUTH or ~/.docker/config.json (auths, credHelpers, credsStore)',
31
32
  '--password-stdin read the password from stdin',
32
33
  '--allow-private-network let redirects/token servers on other hosts resolve to private addresses',
33
- '--timeout <ms> per-request idle timeout (default 30000)',
34
+ '--timeout <ms> per-request idle timeout (default 30000; --read-timeout wins)',
35
+ ...networkHelp(36),
34
36
  ];
35
37
  const COMMON_OPTIONS = {
38
+ ...NETWORK_OPTIONS,
36
39
  'insecure-registry': { type: 'string', multiple: true },
37
40
  username: { type: 'string' },
38
41
  'password-stdin': { type: 'boolean' },
@@ -52,12 +55,14 @@ async function clientOptions(v) {
52
55
  }
53
56
  const timeoutMs = positive(str(v.timeout), '--timeout');
54
57
  const maxBlob = positive(str(v['max-download-bytes']), '--max-download-bytes');
58
+ const network = networkOption(v);
55
59
  return {
56
60
  explicit,
57
61
  options: {
58
62
  insecureRegistries: insecureRegistriesFromEnv(strs(v['insecure-registry'])),
59
63
  allowPrivateNetwork: !!v['allow-private-network'],
60
64
  limits: { ...(timeoutMs ? { timeoutMs } : {}), ...(maxBlob ? { maxBlobBytes: maxBlob } : {}) },
65
+ ...(network ? { network } : {}),
61
66
  },
62
67
  };
63
68
  }
@@ -33,12 +33,26 @@ export function parsePublish(spec) {
33
33
  return { host, port };
34
34
  }
35
35
  const isWildcard = (h) => h === '0.0.0.0' || h === '::';
36
+ async function isFile(p) {
37
+ return fs.stat(p).then((s) => s.isFile(), () => false);
38
+ }
39
+ /** An archive file source: labelled by its absolute path, stored by its real path. */
40
+ async function archiveSource(arg) {
41
+ const file = path.resolve(arg);
42
+ return { label: file, source: { kind: 'archive', path: await fs.realpath(file).catch(() => file) } };
43
+ }
44
+ /**
45
+ * A directory or archive file when `arg` looks like a path, else an image reference or ID, else an
46
+ * archive file by that name (`site.zip`). Whether a file is an archive is checked when it is loaded.
47
+ */
36
48
  async function resolveSource(store, arg) {
37
49
  if (/^(\.\.?|~.*|\.\.?\/.*|\/.*|.*\/)$/.test(arg)) {
38
50
  const dir = path.resolve(arg);
51
+ if (await isFile(dir))
52
+ return archiveSource(arg);
39
53
  const st = await fs.stat(dir).catch(() => undefined);
40
54
  if (!st?.isDirectory())
41
- throw new SitefileError('E_NOT_FOUND', `'${arg}' is not a directory`);
55
+ throw new SitefileError('E_NOT_FOUND', `'${arg}' is not a directory or a file`);
42
56
  return { label: dir, source: { kind: 'dir', path: await fs.realpath(dir) } };
43
57
  }
44
58
  try {
@@ -48,6 +62,8 @@ async function resolveSource(store, arg) {
48
62
  if (e instanceof SitefileError && e.code === 'E_NOT_FOUND' && (await fs.stat(arg).then((s) => s.isDirectory(), () => false))) {
49
63
  throw new SitefileError('E_NOT_FOUND', `${e.message} (to serve the directory, write ./${arg})`);
50
64
  }
65
+ if (await isFile(arg))
66
+ return archiveSource(arg);
51
67
  throw e;
52
68
  }
53
69
  }
@@ -88,9 +104,9 @@ async function startDetached(reg, state) {
88
104
  }
89
105
  const run = {
90
106
  name: 'serve',
91
- summary: 'serve an image or directory over HTTP',
92
- usage: 'serve [--name n] [-p [host:]port] [-d] [--spa] <ref|id|./dir> | serve --watch [options] [context]',
93
- description: 'Serve an image from the local store, or a directory (a snapshot read at start; paths starting with ./, ../ or /),\nover HTTP. GET and HEAD only; no directory listings. Binds 127.0.0.1 on a free port unless -p is given;\n0.0.0.0 is bound only when you pass it explicitly. Foreground by default (Ctrl-C stops it); -d detaches and\nrecords the server for servers, server stop, rm, logs and restart.\n\nWith --watch the argument is a build context (default .): its Sitefile is built in memory and served, rebuilt\nwhen the context, the Sitefile or a --build-arg-file changes, and open pages reload after each good rebuild\n(HTML responses get a small script listening on /__sitefile/reload). A failed rebuild keeps serving the last\ngood build. Nothing is written to the context except Sitefile.lock.',
107
+ summary: 'serve an image, directory or archive file over HTTP',
108
+ usage: 'serve [--name n] [-p [host:]port] [-d] [--spa] <ref|id|./dir|file> | serve --watch [options] [context]',
109
+ description: 'Serve an image from the local store, a directory (paths starting with ./, ../ or /), or an archive file over HTTP:\nzip, tar, tar.gz, wbn, or an OCI archive as build -o x.oci.tar and save write it (the format is read from the\nbytes). A directory or archive is a snapshot read at start. GET and HEAD only; no directory listings.\nBinds 127.0.0.1 on a free port unless -p is given;\n0.0.0.0 is bound only when you pass it explicitly. Foreground by default (Ctrl-C stops it); -d detaches and\nrecords the server for servers, server stop, rm, logs and restart.\n\nWith --watch the argument is a build context (default .): its Sitefile is built in memory and served, rebuilt\nwhen the context, the Sitefile or a --build-arg-file changes, and open pages reload after each good rebuild\n(HTML responses get a small script listening on /__sitefile/reload). A failed rebuild keeps serving the last\ngood build. Nothing is written to the context except Sitefile.lock.',
94
110
  flags: [
95
111
  '--name <n> name for the server (-d only; default site-<6 hex>)',
96
112
  '-p, --publish <[host:]port> address to bind (default 127.0.0.1, a free port)',
@@ -123,7 +139,7 @@ const run = {
123
139
  for (const f of WATCH_ONLY)
124
140
  if (v[f] !== undefined)
125
141
  usage(`--${f} needs --watch`);
126
- requireArgs(pos, 1, 1, 'an image reference or ./directory');
142
+ requireArgs(pos, 1, 1, 'an image reference, ./directory or archive file');
127
143
  const bind = parsePublish(str(v.publish));
128
144
  const name = str(v.name);
129
145
  if (name !== undefined && !v.detach)
@@ -141,7 +157,7 @@ const run = {
141
157
  await closeServer(server);
142
158
  return 0;
143
159
  }
144
- await loadServedTree(store, source); // fail fast on a bad image or directory
160
+ await loadServedTree(store, source); // fail fast on a bad image, directory or archive
145
161
  const reg = await ServerRegistry.open(store);
146
162
  const { id, name: finalName } = await reg.allocate(name);
147
163
  const state = {
@@ -415,7 +431,7 @@ const restart = {
415
431
  name: 'restart',
416
432
  summary: 'restart a site server',
417
433
  usage: 'server restart [--time secs] <name|id>',
418
- description: 'Stop the server if it is running, then start it again with the same name, image and bind address.\nImages are served by the same image ID; a directory is re-read.',
434
+ description: 'Stop the server if it is running, then start it again with the same name, image and bind address.\nImages are served by the same image ID; a directory or archive file is re-read.',
419
435
  flags: ['--time <secs> grace period before SIGKILL (default 5)'],
420
436
  options: { time: { type: 'string' } },
421
437
  async run(v, pos) {