@johnhenry/sitefile 0.0.0-stage → 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 (178) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +21 -0
  3. package/README.md +513 -2
  4. package/lib/src/archive-writers.d.ts +31 -0
  5. package/lib/src/archive-writers.js +197 -0
  6. package/lib/src/attest.d.ts +126 -0
  7. package/lib/src/attest.js +220 -0
  8. package/lib/src/bake.d.ts +74 -0
  9. package/lib/src/bake.js +372 -0
  10. package/lib/src/base-image.d.ts +76 -0
  11. package/lib/src/base-image.js +138 -0
  12. package/lib/src/build-args.d.ts +26 -0
  13. package/lib/src/build-args.js +164 -0
  14. package/lib/src/cache-limit.d.ts +54 -0
  15. package/lib/src/cache-limit.js +239 -0
  16. package/lib/src/casefold-data.d.ts +2 -0
  17. package/lib/src/casefold-data.js +217 -0
  18. package/lib/src/cbor.d.ts +39 -0
  19. package/lib/src/cbor.js +174 -0
  20. package/lib/src/checklinks.d.ts +42 -0
  21. package/lib/src/checklinks.js +120 -0
  22. package/lib/src/cli/bake.d.ts +2 -0
  23. package/lib/src/cli/bake.js +179 -0
  24. package/lib/src/cli/build-image.d.ts +21 -0
  25. package/lib/src/cli/build-image.js +42 -0
  26. package/lib/src/cli/cache.d.ts +8 -0
  27. package/lib/src/cli/cache.js +119 -0
  28. package/lib/src/cli/common.d.ts +29 -0
  29. package/lib/src/cli/common.js +100 -0
  30. package/lib/src/cli/git-context.d.ts +18 -0
  31. package/lib/src/cli/git-context.js +48 -0
  32. package/lib/src/cli/images.d.ts +6 -0
  33. package/lib/src/cli/images.js +285 -0
  34. package/lib/src/cli/index.d.ts +10 -0
  35. package/lib/src/cli/index.js +95 -0
  36. package/lib/src/cli/network.d.ts +34 -0
  37. package/lib/src/cli/network.js +56 -0
  38. package/lib/src/cli/outdated.d.ts +5 -0
  39. package/lib/src/cli/outdated.js +99 -0
  40. package/lib/src/cli/registry.d.ts +2 -0
  41. package/lib/src/cli/registry.js +127 -0
  42. package/lib/src/cli/serve-worker.d.ts +2 -0
  43. package/lib/src/cli/serve-worker.js +42 -0
  44. package/lib/src/cli/servers.d.ts +15 -0
  45. package/lib/src/cli/servers.js +467 -0
  46. package/lib/src/cli/vendor.d.ts +2 -0
  47. package/lib/src/cli/vendor.js +97 -0
  48. package/lib/src/cli/watch.d.ts +26 -0
  49. package/lib/src/cli/watch.js +81 -0
  50. package/lib/src/cli.d.ts +12 -0
  51. package/lib/src/cli.js +522 -0
  52. package/lib/src/csv.d.ts +27 -0
  53. package/lib/src/csv.js +117 -0
  54. package/lib/src/deflate.d.ts +76 -0
  55. package/lib/src/deflate.js +604 -0
  56. package/lib/src/digest.d.ts +3 -0
  57. package/lib/src/digest.js +7 -0
  58. package/lib/src/errors.d.ts +56 -0
  59. package/lib/src/errors.js +29 -0
  60. package/lib/src/executor.d.ts +393 -0
  61. package/lib/src/executor.js +2201 -0
  62. package/lib/src/explain.d.ts +64 -0
  63. package/lib/src/explain.js +445 -0
  64. package/lib/src/export-formats.d.ts +23 -0
  65. package/lib/src/export-formats.js +63 -0
  66. package/lib/src/exporter.d.ts +103 -0
  67. package/lib/src/exporter.js +676 -0
  68. package/lib/src/fingerprint.d.ts +40 -0
  69. package/lib/src/fingerprint.js +168 -0
  70. package/lib/src/frontmatter.d.ts +22 -0
  71. package/lib/src/frontmatter.js +45 -0
  72. package/lib/src/index.d.ts +13 -0
  73. package/lib/src/index.js +13 -0
  74. package/lib/src/language/ast.d.ts +360 -0
  75. package/lib/src/language/ast.js +57 -0
  76. package/lib/src/language/parser.d.ts +3 -0
  77. package/lib/src/language/parser.js +612 -0
  78. package/lib/src/language/scanner.d.ts +110 -0
  79. package/lib/src/language/scanner.js +449 -0
  80. package/lib/src/language/validate.d.ts +13 -0
  81. package/lib/src/language/validate.js +367 -0
  82. package/lib/src/links.d.ts +79 -0
  83. package/lib/src/links.js +583 -0
  84. package/lib/src/lint.d.ts +10 -0
  85. package/lib/src/lint.js +223 -0
  86. package/lib/src/markdown.d.ts +29 -0
  87. package/lib/src/markdown.js +612 -0
  88. package/lib/src/media.d.ts +82 -0
  89. package/lib/src/media.js +650 -0
  90. package/lib/src/memo.d.ts +57 -0
  91. package/lib/src/memo.js +93 -0
  92. package/lib/src/oci.d.ts +178 -0
  93. package/lib/src/oci.js +380 -0
  94. package/lib/src/outdated.d.ts +46 -0
  95. package/lib/src/outdated.js +195 -0
  96. package/lib/src/pages.d.ts +10 -0
  97. package/lib/src/pages.js +54 -0
  98. package/lib/src/paths.d.ts +39 -0
  99. package/lib/src/paths.js +101 -0
  100. package/lib/src/planner.d.ts +297 -0
  101. package/lib/src/planner.js +410 -0
  102. package/lib/src/plugins/cache.d.ts +96 -0
  103. package/lib/src/plugins/cache.js +325 -0
  104. package/lib/src/plugins/exec.d.ts +51 -0
  105. package/lib/src/plugins/exec.js +144 -0
  106. package/lib/src/plugins/host.d.ts +75 -0
  107. package/lib/src/plugins/host.js +277 -0
  108. package/lib/src/plugins/wasm.d.ts +23 -0
  109. package/lib/src/plugins/wasm.js +249 -0
  110. package/lib/src/profile.d.ts +42 -0
  111. package/lib/src/profile.js +68 -0
  112. package/lib/src/ref.d.ts +40 -0
  113. package/lib/src/ref.js +79 -0
  114. package/lib/src/registry-auth.d.ts +32 -0
  115. package/lib/src/registry-auth.js +151 -0
  116. package/lib/src/registry-cache.d.ts +71 -0
  117. package/lib/src/registry-cache.js +333 -0
  118. package/lib/src/registry-ops.d.ts +51 -0
  119. package/lib/src/registry-ops.js +248 -0
  120. package/lib/src/registry.d.ts +115 -0
  121. package/lib/src/registry.js +524 -0
  122. package/lib/src/render.d.ts +134 -0
  123. package/lib/src/render.js +576 -0
  124. package/lib/src/resources/archive.d.ts +11 -0
  125. package/lib/src/resources/archive.js +306 -0
  126. package/lib/src/resources/bundle-data.d.ts +16 -0
  127. package/lib/src/resources/bundle-data.js +50 -0
  128. package/lib/src/resources/convert.d.ts +12 -0
  129. package/lib/src/resources/convert.js +63 -0
  130. package/lib/src/resources/documents.d.ts +35 -0
  131. package/lib/src/resources/documents.js +122 -0
  132. package/lib/src/resources/git.d.ts +103 -0
  133. package/lib/src/resources/git.js +720 -0
  134. package/lib/src/resources/https.d.ts +74 -0
  135. package/lib/src/resources/https.js +413 -0
  136. package/lib/src/resources/ignore.d.ts +33 -0
  137. package/lib/src/resources/ignore.js +119 -0
  138. package/lib/src/resources/local.d.ts +64 -0
  139. package/lib/src/resources/local.js +248 -0
  140. package/lib/src/resources/lockfile.d.ts +52 -0
  141. package/lib/src/resources/lockfile.js +172 -0
  142. package/lib/src/resources/netpolicy.d.ts +6 -0
  143. package/lib/src/resources/netpolicy.js +82 -0
  144. package/lib/src/resources/network.d.ts +70 -0
  145. package/lib/src/resources/network.js +304 -0
  146. package/lib/src/resources/types.d.ts +69 -0
  147. package/lib/src/resources/types.js +13 -0
  148. package/lib/src/resources/vendor.d.ts +94 -0
  149. package/lib/src/resources/vendor.js +296 -0
  150. package/lib/src/resources/wbn-reader.d.ts +44 -0
  151. package/lib/src/resources/wbn-reader.js +302 -0
  152. package/lib/src/schema.d.ts +25 -0
  153. package/lib/src/schema.js +169 -0
  154. package/lib/src/secrets.d.ts +21 -0
  155. package/lib/src/secrets.js +103 -0
  156. package/lib/src/serve-rules.d.ts +29 -0
  157. package/lib/src/serve-rules.js +123 -0
  158. package/lib/src/server.d.ts +45 -0
  159. package/lib/src/server.js +246 -0
  160. package/lib/src/servers.d.ts +85 -0
  161. package/lib/src/servers.js +225 -0
  162. package/lib/src/site.d.ts +60 -0
  163. package/lib/src/site.js +169 -0
  164. package/lib/src/store.d.ts +151 -0
  165. package/lib/src/store.js +596 -0
  166. package/lib/src/tree.d.ts +50 -0
  167. package/lib/src/tree.js +134 -0
  168. package/lib/src/values.d.ts +7 -0
  169. package/lib/src/values.js +37 -0
  170. package/lib/src/vendor.d.ts +19 -0
  171. package/lib/src/vendor.js +145 -0
  172. package/lib/src/watch.d.ts +44 -0
  173. package/lib/src/watch.js +137 -0
  174. package/lib/src/wbn.d.ts +26 -0
  175. package/lib/src/wbn.js +241 -0
  176. package/lib/src/yaml.d.ts +34 -0
  177. package/lib/src/yaml.js +562 -0
  178. package/package.json +59 -4
@@ -0,0 +1,467 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { SitefileError } from '../errors.js';
7
+ import { collectBuildArgs } from '../build-args.js';
8
+ import { build } from '../executor.js';
9
+ import { closeServer, createSiteServer, formatUrl, listen, LiveReload } from '../server.js';
10
+ import { isRunning, loadServedTree, pidAlive, ServerRegistry } from '../servers.js';
11
+ import { Store } from '../store.js';
12
+ import { Tree } from '../tree.js';
13
+ import { transformCacheDir } from './cache.js';
14
+ import { requireArgs, str, strs, table, usage, usageError } from './common.js';
15
+ import { watchMemo } from '../memo.js';
16
+ import { watchLoop } from './watch.js';
17
+ const WORKER = fileURLToPath(new URL('./serve-worker.js', import.meta.url));
18
+ /** `-p` value: `port`, `host:port` or `[v6]:port`. Default bind is 127.0.0.1 with a free port. */
19
+ export function parsePublish(spec) {
20
+ if (spec === undefined)
21
+ return { host: '127.0.0.1', port: 0 };
22
+ const m = /^(?:(?:\[([0-9A-Fa-f:]+)\]|([^:[\]]+)):)?([0-9]{1,5})$/.exec(spec);
23
+ if (!m)
24
+ usage(`-p expects [host:]port, got '${spec}'`);
25
+ const port = Number(m[3]);
26
+ if (port > 65535)
27
+ usage(`-p: port ${port} is out of range`);
28
+ let host = m[1] ?? m[2] ?? '127.0.0.1';
29
+ if (host === 'localhost')
30
+ host = '127.0.0.1';
31
+ if (!net.isIP(host))
32
+ usage(`-p: host must be an IP address or 'localhost', got '${host}'`);
33
+ return { host, port };
34
+ }
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
+ */
48
+ async function resolveSource(store, arg) {
49
+ if (/^(\.\.?|~.*|\.\.?\/.*|\/.*|.*\/)$/.test(arg)) {
50
+ const dir = path.resolve(arg);
51
+ if (await isFile(dir))
52
+ return archiveSource(arg);
53
+ const st = await fs.stat(dir).catch(() => undefined);
54
+ if (!st?.isDirectory())
55
+ throw new SitefileError('E_NOT_FOUND', `'${arg}' is not a directory or a file`);
56
+ return { label: dir, source: { kind: 'dir', path: await fs.realpath(dir) } };
57
+ }
58
+ try {
59
+ return { label: arg, source: { kind: 'image', imageId: await store.resolve(arg) } };
60
+ }
61
+ catch (e) {
62
+ if (e instanceof SitefileError && e.code === 'E_NOT_FOUND' && (await fs.stat(arg).then((s) => s.isDirectory(), () => false))) {
63
+ throw new SitefileError('E_NOT_FOUND', `${e.message} (to serve the directory, write ./${arg})`);
64
+ }
65
+ if (await isFile(arg))
66
+ return archiveSource(arg);
67
+ throw e;
68
+ }
69
+ }
70
+ function waitForSignal() {
71
+ return new Promise((resolve) => {
72
+ process.once('SIGINT', () => resolve('SIGINT'));
73
+ process.once('SIGTERM', () => resolve('SIGTERM'));
74
+ });
75
+ }
76
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
77
+ /** Spawn the detached worker for `state` and wait until it listens (or fails). */
78
+ async function startDetached(reg, state) {
79
+ const log = await fs.open(state.logFile, 'a', 0o600);
80
+ const child = spawn(process.execPath, [WORKER, state.id], {
81
+ detached: true,
82
+ stdio: ['ignore', log.fd, log.fd],
83
+ env: { ...process.env, SITEFILE_HOME: reg.store.home },
84
+ });
85
+ await log.close();
86
+ let exited = false;
87
+ child.once('exit', () => (exited = true));
88
+ child.once('error', () => (exited = true));
89
+ const deadline = Date.now() + 15_000;
90
+ for (;;) {
91
+ const cur = await reg.read(state.id);
92
+ if (cur?.ready) {
93
+ child.unref();
94
+ return cur;
95
+ }
96
+ if (exited || cur?.error || Date.now() > deadline) {
97
+ if (!exited)
98
+ child.kill('SIGKILL');
99
+ const why = cur?.error ?? (exited ? 'the server process exited before it was ready' : 'timed out waiting for the server to start');
100
+ throw new SitefileError('E_SERVER', why.replace(/^error\[E_SERVER\]: /, ''));
101
+ }
102
+ await sleep(40);
103
+ }
104
+ }
105
+ const run = {
106
+ name: 'serve',
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.',
110
+ flags: [
111
+ '--name <n> name for the server (-d only; default site-<6 hex>)',
112
+ '-p, --publish <[host:]port> address to bind (default 127.0.0.1, a free port)',
113
+ '-d, --detach run in the background and print the server ID',
114
+ '--spa serve /index.html for unknown paths without a file extension',
115
+ '--watch build the context, serve it, rebuild on change and live-reload pages (foreground only)',
116
+ '-f, --file <path> Sitefile path (--watch; default <context>/Sitefile)',
117
+ '--target <stage> build up to this stage and serve its whole tree (--watch)',
118
+ '--build-arg <NAME=value> set an ARG (--watch; repeatable)',
119
+ '--build-arg-file <path> read ARGs from a .json, .yaml/.yml or .env file (--watch; repeatable)',
120
+ '--no-cache do not use the TRANSFORM result cache (--watch)',
121
+ '--no-lockfile do not read or write Sitefile.lock (--watch)',
122
+ ],
123
+ options: {
124
+ name: { type: 'string' },
125
+ publish: { type: 'string', short: 'p' },
126
+ detach: { type: 'boolean', short: 'd' },
127
+ spa: { type: 'boolean' },
128
+ watch: { type: 'boolean' },
129
+ file: { type: 'string', short: 'f' },
130
+ target: { type: 'string' },
131
+ 'build-arg': { type: 'string', multiple: true },
132
+ 'build-arg-file': { type: 'string', multiple: true },
133
+ 'no-cache': { type: 'boolean' },
134
+ 'no-lockfile': { type: 'boolean' },
135
+ },
136
+ async run(v, pos) {
137
+ if (v.watch)
138
+ return serveWatch(v, pos);
139
+ for (const f of WATCH_ONLY)
140
+ if (v[f] !== undefined)
141
+ usage(`--${f} needs --watch`);
142
+ requireArgs(pos, 1, 1, 'an image reference, ./directory or archive file');
143
+ const bind = parsePublish(str(v.publish));
144
+ const name = str(v.name);
145
+ if (name !== undefined && !v.detach)
146
+ usage('--name needs -d (only detached servers are tracked)');
147
+ const store = await Store.open();
148
+ const { label, source } = await resolveSource(store, pos[0]);
149
+ if (isWildcard(bind.host))
150
+ process.stderr.write(`warning: binding ${bind.host}: the site will be reachable from other machines\n`);
151
+ if (!v.detach) {
152
+ const tree = await loadServedTree(store, source);
153
+ const server = createSiteServer(tree, { spa: !!v.spa, log: (l) => process.stdout.write(`${l}\n`) });
154
+ const addr = await listen(server, bind.host, bind.port);
155
+ process.stdout.write(`Serving ${label} (${tree.size} files) at ${formatUrl(addr.host, addr.port)} (Ctrl-C to stop)\n`);
156
+ await waitForSignal();
157
+ await closeServer(server);
158
+ return 0;
159
+ }
160
+ await loadServedTree(store, source); // fail fast on a bad image, directory or archive
161
+ const reg = await ServerRegistry.open(store);
162
+ const { id, name: finalName } = await reg.allocate(name);
163
+ const state = {
164
+ id,
165
+ name: finalName,
166
+ image: label,
167
+ source,
168
+ spa: !!v.spa,
169
+ bind,
170
+ host: bind.host,
171
+ port: bind.port,
172
+ pid: 0,
173
+ started: new Date().toISOString(),
174
+ logFile: reg.logPath(id),
175
+ ready: false,
176
+ };
177
+ await reg.write(state);
178
+ try {
179
+ const up = await startDetached(reg, state);
180
+ process.stdout.write(`Started ${up.name} (${up.id}) serving ${label} at ${formatUrl(up.host, up.port)}\n`);
181
+ return 0;
182
+ }
183
+ catch (e) {
184
+ await reg.remove(id);
185
+ throw e;
186
+ }
187
+ },
188
+ };
189
+ /** serve flags that only mean something with --watch. */
190
+ const WATCH_ONLY = ['file', 'target', 'build-arg', 'build-arg-file', 'no-cache', 'no-lockfile'];
191
+ /** `serve --watch [context]`: build in memory, serve, rebuild on change, live reload. */
192
+ async function serveWatch(v, pos) {
193
+ requireArgs(pos, 0, 1, 'a build context directory');
194
+ if (v.detach)
195
+ usage('--watch cannot be combined with -d');
196
+ if (v.name !== undefined)
197
+ usage('--name needs -d (only detached servers are tracked)');
198
+ const bind = parsePublish(str(v.publish));
199
+ const context = pos[0] ?? '.';
200
+ const file = str(v.file);
201
+ const target = str(v.target);
202
+ const argFiles = strs(v['build-arg-file']);
203
+ const argFlags = strs(v['build-arg']);
204
+ if (isWildcard(bind.host))
205
+ process.stderr.write(`warning: binding ${bind.host}: the site will be reachable from other machines\n`);
206
+ let current = Tree.empty;
207
+ let started = false;
208
+ const memo = watchMemo();
209
+ const live = new LiveReload();
210
+ const server = createSiteServer(() => current, { spa: !!v.spa, liveReload: live, log: (l) => process.stdout.write(`${l}\n`) });
211
+ try {
212
+ return await watchLoop({
213
+ context,
214
+ ...(file ? { sitefile: file } : {}),
215
+ argFiles,
216
+ build: async () => {
217
+ try {
218
+ const r = await build({
219
+ contextDir: context,
220
+ ...(file ? { sitefile: file } : {}),
221
+ ...(target !== undefined ? { target } : {}),
222
+ buildArgs: await collectBuildArgs(argFiles, argFlags),
223
+ ...(v['no-lockfile'] ? { lockfile: false } : {}),
224
+ ...(v['no-cache'] ? {} : { cacheDir: transformCacheDir() }),
225
+ skipBase: true,
226
+ ...(memo ? { memo } : {}),
227
+ onNotice: (m) => process.stderr.write(`${m}\n`),
228
+ onWarning: (m) => process.stderr.write(`warning: ${m}\n`),
229
+ });
230
+ current = r.output;
231
+ process.stdout.write(`Built ${r.output.size} file${r.output.size === 1 ? '' : 's'} (${r.output.digest()})\n`);
232
+ if (started)
233
+ live.reload();
234
+ return { code: 0 };
235
+ }
236
+ catch (e) {
237
+ if (!(e instanceof SitefileError))
238
+ throw e;
239
+ if (e.code === 'E_USAGE')
240
+ return { code: usageError(e.detail) };
241
+ process.stderr.write(`error[${e.code}]: ${e.message}\n`);
242
+ return { code: 1 };
243
+ }
244
+ },
245
+ onStarted: async () => {
246
+ const addr = await listen(server, bind.host, bind.port);
247
+ started = true;
248
+ process.stdout.write(`Serving ${context} (${current.size} files) at ${formatUrl(addr.host, addr.port)} (Ctrl-C to stop)\n`);
249
+ },
250
+ });
251
+ }
252
+ finally {
253
+ if (started)
254
+ await closeServer(server);
255
+ }
256
+ }
257
+ const ps = {
258
+ name: 'servers',
259
+ summary: 'list site servers (also: server ls)',
260
+ usage: 'servers [-a] [--json]',
261
+ description: 'List running site servers (-a: also exited ones). A server is running only if its process is alive\nand its port answers; stale entries show as exited.',
262
+ flags: ['-a, --all include exited servers', '--json machine-readable output'],
263
+ options: { all: { type: 'boolean', short: 'a' }, json: { type: 'boolean' } },
264
+ async run(v, pos) {
265
+ requireArgs(pos, 0, 0, 'no arguments');
266
+ const reg = await ServerRegistry.open();
267
+ const views = await Promise.all((await reg.all()).map((s) => reg.view(s)));
268
+ const shown = views.filter((s) => v.all || s.status === 'running');
269
+ if (v.json) {
270
+ // 'site' is the image reference or directory being served.
271
+ const out = shown.map(({ image, ...rest }) => ({ ...rest, site: image }));
272
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
273
+ return 0;
274
+ }
275
+ process.stdout.write(table([
276
+ ['NAME', 'SITE', 'URL', 'STATUS', 'ID'],
277
+ ...shown.map((s) => [s.name, s.image, s.url || '-', s.status, s.id]),
278
+ ]));
279
+ return 0;
280
+ },
281
+ };
282
+ /** SIGTERM, wait, then SIGKILL. Returns false if the server was not running. */
283
+ export async function stopServer(reg, s, graceMs) {
284
+ if (!(await isRunning(s)))
285
+ return false;
286
+ const gone = async (ms) => {
287
+ for (const end = Date.now() + ms; Date.now() < end; await sleep(50))
288
+ if (!pidAlive(s.pid))
289
+ return true;
290
+ return !pidAlive(s.pid);
291
+ };
292
+ try {
293
+ process.kill(s.pid, 'SIGTERM');
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ if (!(await gone(graceMs))) {
299
+ try {
300
+ process.kill(s.pid, 'SIGKILL');
301
+ }
302
+ catch {
303
+ /* already gone */
304
+ }
305
+ if (!(await gone(2000)))
306
+ throw new SitefileError('E_SERVER', `could not stop ${s.name} (pid ${s.pid})`);
307
+ }
308
+ await reg.update(s.id, { ready: false }).catch(() => undefined);
309
+ return true;
310
+ }
311
+ function graceOf(v) {
312
+ const t = str(v.time);
313
+ if (t === undefined)
314
+ return 5000;
315
+ const n = Number(t);
316
+ if (!Number.isFinite(n) || n < 0)
317
+ usage('--time expects a number of seconds');
318
+ return n * 1000;
319
+ }
320
+ /** Run `fn` for each target; print each failure and keep going. */
321
+ async function eachTarget(reg, keys, fn) {
322
+ let failed = false;
323
+ for (const key of keys) {
324
+ try {
325
+ process.stdout.write(`${await fn(await reg.find(key))}\n`);
326
+ }
327
+ catch (e) {
328
+ if (!(e instanceof SitefileError))
329
+ throw e;
330
+ process.stderr.write(`error[${e.code}]: ${e.message}\n`);
331
+ failed = true;
332
+ }
333
+ }
334
+ return failed ? 1 : 0;
335
+ }
336
+ const stop = {
337
+ name: 'stop',
338
+ summary: 'stop running site servers',
339
+ usage: 'server stop [--time secs] <name|id>...',
340
+ description: 'Send SIGTERM, wait up to --time seconds (default 5), then SIGKILL. Stopping an exited server is a no-op.',
341
+ flags: ['--time <secs> grace period before SIGKILL (default 5)'],
342
+ options: { time: { type: 'string' } },
343
+ async run(v, pos) {
344
+ requireArgs(pos, 1, Infinity, 'a server id or name');
345
+ const reg = await ServerRegistry.open();
346
+ const grace = graceOf(v);
347
+ return eachTarget(reg, pos, async (s) => {
348
+ await stopServer(reg, s, grace);
349
+ return s.name;
350
+ });
351
+ },
352
+ };
353
+ const rm = {
354
+ name: 'rm',
355
+ summary: 'remove site servers',
356
+ usage: 'server rm [-f] <name|id>...',
357
+ description: 'Delete the record and log of exited servers. Running servers are refused unless -f, which stops them first.',
358
+ flags: ['-f, --force stop and remove running servers'],
359
+ options: { force: { type: 'boolean', short: 'f' } },
360
+ async run(v, pos) {
361
+ requireArgs(pos, 1, Infinity, 'a server id or name');
362
+ const reg = await ServerRegistry.open();
363
+ return eachTarget(reg, pos, async (s) => {
364
+ if (await isRunning(s)) {
365
+ if (!v.force)
366
+ throw new SitefileError('E_SERVER', `${s.name} is running; stop it first or use -f`);
367
+ await stopServer(reg, s, 5000);
368
+ }
369
+ await reg.remove(s.id);
370
+ return s.name;
371
+ });
372
+ },
373
+ };
374
+ /** The last n lines of text, each newline-terminated; a trailing newline is not an extra empty line. */
375
+ export function tailLines(text, n) {
376
+ const lines = text.split('\n');
377
+ if (lines[lines.length - 1] === '')
378
+ lines.pop();
379
+ return (n === 0 ? [] : lines.slice(-n)).map((l) => `${l}\n`).join('');
380
+ }
381
+ const logs = {
382
+ name: 'logs',
383
+ summary: 'show a server\'s access log',
384
+ usage: 'server logs [-f] [-n lines] <name|id>',
385
+ description: 'Print the log of a detached server: one line per request ("METHOD /path STATUS BYTES MSms").',
386
+ flags: ['-f, --follow keep printing new lines until Ctrl-C', '-n, --tail <n> only the last n lines'],
387
+ options: { follow: { type: 'boolean', short: 'f' }, tail: { type: 'string', short: 'n' } },
388
+ async run(v, pos) {
389
+ requireArgs(pos, 1, 1, 'a server id or name');
390
+ const reg = await ServerRegistry.open();
391
+ const s = await reg.find(pos[0]);
392
+ let text = await fs.readFile(s.logFile, 'utf8').catch(() => '');
393
+ const offset = Buffer.byteLength(text);
394
+ const tail = str(v.tail);
395
+ if (tail !== undefined) {
396
+ const n = Number(tail);
397
+ if (!Number.isSafeInteger(n) || n < 0)
398
+ usage('--tail expects a non-negative integer');
399
+ text = tailLines(text, n);
400
+ }
401
+ process.stdout.write(text);
402
+ if (!v.follow)
403
+ return 0;
404
+ let pos2 = offset;
405
+ let done = false;
406
+ void waitForSignal().then(() => (done = true));
407
+ while (!done) {
408
+ await sleep(150);
409
+ const fh = await fs.open(s.logFile, 'r').catch(() => undefined);
410
+ if (!fh)
411
+ continue;
412
+ try {
413
+ const size = (await fh.stat()).size;
414
+ if (size < pos2)
415
+ pos2 = 0;
416
+ if (size > pos2) {
417
+ const buf = Buffer.alloc(size - pos2);
418
+ await fh.read(buf, 0, buf.length, pos2);
419
+ pos2 = size;
420
+ process.stdout.write(buf);
421
+ }
422
+ }
423
+ finally {
424
+ await fh.close();
425
+ }
426
+ }
427
+ return 0;
428
+ },
429
+ };
430
+ const restart = {
431
+ name: 'restart',
432
+ summary: 'restart a site server',
433
+ usage: 'server restart [--time secs] <name|id>',
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.',
435
+ flags: ['--time <secs> grace period before SIGKILL (default 5)'],
436
+ options: { time: { type: 'string' } },
437
+ async run(v, pos) {
438
+ requireArgs(pos, 1, 1, 'a server id or name');
439
+ const reg = await ServerRegistry.open();
440
+ const s = await reg.find(pos[0]);
441
+ await stopServer(reg, s, graceOf(v));
442
+ await loadServedTree(reg.store, s.source);
443
+ const fresh = { ...s, host: s.bind.host, port: s.bind.port, pid: 0, ready: false, started: new Date().toISOString() };
444
+ delete fresh.error;
445
+ await reg.write(fresh);
446
+ const up = await startDetached(reg, fresh).catch(async (e) => {
447
+ await reg.update(s.id, { ready: false });
448
+ throw e;
449
+ });
450
+ process.stdout.write(`Restarted ${up.name} (${up.id}) serving ${up.image} at ${formatUrl(up.host, up.port)}\n`);
451
+ return 0;
452
+ },
453
+ };
454
+ /** Top-level: `sitefile serve`, `sitefile servers`. */
455
+ export const serverCommands = new Map([
456
+ ['serve', run],
457
+ ['servers', ps],
458
+ ]);
459
+ /** `sitefile server <ls|stop|rm|logs|restart>`. */
460
+ export const serverSubcommands = new Map([
461
+ ['ls', ps],
462
+ ['stop', stop],
463
+ ['rm', rm],
464
+ ['logs', logs],
465
+ ['restart', restart],
466
+ ]);
467
+ //# sourceMappingURL=servers.js.map
@@ -0,0 +1,2 @@
1
+ import { type Command } from './common.js';
2
+ export declare const vendorCommands: ReadonlyMap<string, Command>;
@@ -0,0 +1,97 @@
1
+ import { NETWORK_OPTIONS, networkHelp, networkOption } from './network.js';
2
+ import path from 'node:path';
3
+ import { insecureRegistriesFromEnv } from '../base-image.js';
4
+ import { collectBuildArgs } from '../build-args.js';
5
+ import { DEFAULT_FETCH_POLICY } from '../resources/types.js';
6
+ import { vendor } from '../vendor.js';
7
+ import { loadSecrets } from '../secrets.js';
8
+ import { requireArgs, str, strs, usage } from './common.js';
9
+ function positive(raw, flag) {
10
+ if (raw === undefined)
11
+ return undefined;
12
+ const n = Number(raw);
13
+ if (!Number.isSafeInteger(n) || n <= 0)
14
+ usage(`${flag} expects a positive integer`);
15
+ return n;
16
+ }
17
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
18
+ const vendorCommand = {
19
+ name: 'vendor',
20
+ summary: 'download remote imports and the BASE image for offline builds',
21
+ usage: 'vendor [context] [options]',
22
+ description: "Fetch every remote IMPORT and the BASE image (EXPORT ... AS oci BASE, or --base) as a build would, verify\nthem against INTEGRITY and Sitefile.lock (which is updated like a build updates it), and write them to\nthe vendor directory: content-addressed blobs (blobs/sha256/<hex>) and manifest.json. Later builds\nread vendored copies instead of the network (verified again) and never copy the directory into the\noutput. The directory is replaced as a whole; one that is not a vendor directory is refused.",
23
+ flags: [
24
+ '-f, --file <path> Sitefile path (default: <context>/Sitefile)',
25
+ '--vendor-dir <dir> vendor directory, relative to the context (default vendor/sitefile)',
26
+ '--build-arg <NAME=value> set an ARG (repeatable); --build-arg NAME reads $NAME',
27
+ '--build-arg-file <path> read ARGs from a .json, .yaml/.yml or .env file (repeatable)',
28
+ '--secret id=<id>,env=<VAR> a build secret for IMPORT ... SECRET (or id=<id>,src=<file>; repeatable)',
29
+ '--frozen-lockfile fail instead of changing Sitefile.lock',
30
+ '--update-lock re-record unpinned URLs whose content changed, re-resolve BASE tags',
31
+ '--allow-http permit plain http:// imports',
32
+ '--allow-private-network permit loopback/private network addresses',
33
+ `--max-download-bytes <n> per-resource size limit (default ${DEFAULT_FETCH_POLICY.maxBytes})`,
34
+ `--timeout <ms> per-resource fetch timeout (default ${DEFAULT_FETCH_POLICY.timeoutMs})`,
35
+ ...networkHelp(30),
36
+ '--base <image> vendor this BASE image (overrides EXPORT ... AS oci BASE)',
37
+ '--platform <os/arch> BASE platform to vendor (default linux/amd64)',
38
+ '--insecure-registry <h> allow plain http to registry host[:port] (repeatable)',
39
+ ],
40
+ options: {
41
+ ...NETWORK_OPTIONS,
42
+ file: { type: 'string', short: 'f' },
43
+ 'vendor-dir': { type: 'string' },
44
+ 'build-arg': { type: 'string', multiple: true },
45
+ 'build-arg-file': { type: 'string', multiple: true },
46
+ secret: { type: 'string', multiple: true },
47
+ 'frozen-lockfile': { type: 'boolean' },
48
+ 'update-lock': { type: 'boolean' },
49
+ 'allow-http': { type: 'boolean' },
50
+ 'allow-private-network': { type: 'boolean' },
51
+ 'max-download-bytes': { type: 'string' },
52
+ timeout: { type: 'string' },
53
+ base: { type: 'string' },
54
+ platform: { type: 'string' },
55
+ 'insecure-registry': { type: 'string', multiple: true },
56
+ },
57
+ async run(v, pos) {
58
+ requireArgs(pos, 0, 1, 'a context directory');
59
+ if (v['frozen-lockfile'] && v['update-lock'])
60
+ usage('--frozen-lockfile and --update-lock conflict');
61
+ const context = pos[0] ?? '.';
62
+ const maxBytes = positive(str(v['max-download-bytes']), '--max-download-bytes');
63
+ const timeoutMs = positive(str(v.timeout), '--timeout');
64
+ const network = networkOption(v);
65
+ const file = str(v.file);
66
+ const vendorDir = str(v['vendor-dir']);
67
+ const base = str(v.base);
68
+ const platform = str(v.platform);
69
+ const r = await vendor({
70
+ contextDir: context,
71
+ ...(file ? { sitefile: file } : {}),
72
+ buildArgs: await collectBuildArgs(strs(v['build-arg-file']), strs(v['build-arg'])),
73
+ secrets: await loadSecrets(strs(v.secret)),
74
+ fetchPolicy: {
75
+ allowHttp: !!v['allow-http'],
76
+ allowPrivateNetwork: !!v['allow-private-network'],
77
+ ...(maxBytes ? { maxBytes } : {}),
78
+ ...(timeoutMs ? { timeoutMs } : {}),
79
+ ...(network ? { network } : {}),
80
+ },
81
+ ...(vendorDir !== undefined ? { vendorDir } : {}),
82
+ ...(base !== undefined ? { baseImage: base } : {}),
83
+ ...(platform !== undefined ? { platform } : {}),
84
+ registry: { insecureRegistries: insecureRegistriesFromEnv(strs(v['insecure-registry'])) },
85
+ frozenLockfile: !!v['frozen-lockfile'],
86
+ updateLock: !!v['update-lock'],
87
+ onWarning: (m) => process.stderr.write(`warning: ${m}\n`),
88
+ });
89
+ const where = path.relative(process.cwd(), r.dir) || '.';
90
+ process.stdout.write(`Vendored ${plural(r.resources, 'resource')} and ${plural(r.images, 'image')} (${plural(r.blobs, 'blob')}, ${r.bytes} bytes) to ${where}\n`);
91
+ if (r.lockfileWritten)
92
+ process.stdout.write('Updated Sitefile.lock\n');
93
+ return 0;
94
+ },
95
+ };
96
+ export const vendorCommands = new Map([['vendor', vendorCommand]]);
97
+ //# sourceMappingURL=vendor.js.map
@@ -0,0 +1,26 @@
1
+ export interface BuildOutcome {
2
+ /** CLI exit code of the build: 0 ok, 1 build error (already printed), 2 usage error (already printed). */
3
+ readonly code: number;
4
+ /** Paths the build writes (the export destination): changes there never trigger a rebuild. */
5
+ readonly wrote?: readonly string[];
6
+ }
7
+ export interface WatchLoopOptions {
8
+ /** The build context, as given (shown in messages). */
9
+ readonly context: string;
10
+ /** `-f`: the Sitefile path (default `<context>/Sitefile`). */
11
+ readonly sitefile?: string;
12
+ /** `--build-arg-file` files; they are re-read on every build. */
13
+ readonly argFiles: readonly string[];
14
+ readonly build: () => Promise<BuildOutcome>;
15
+ /** After the first build, before the "Watching" line (serve starts listening here). */
16
+ readonly onStarted?: () => Promise<void>;
17
+ /** For tests: resolves to stop the loop (default: SIGINT or SIGTERM). */
18
+ readonly stop?: Promise<void>;
19
+ }
20
+ /**
21
+ * `build --watch` / `serve --watch`: build once, then rebuild whenever a file in the
22
+ * context, the Sitefile or a --build-arg-file changes (debounced; changes during a build
23
+ * coalesce into one follow-up). A failed rebuild is reported and the loop keeps going.
24
+ * Returns the exit code: 0 on Ctrl-C, 2 when the first build is a usage error.
25
+ */
26
+ export declare function watchLoop(o: WatchLoopOptions): Promise<number>;
@@ -0,0 +1,81 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { SitefileError } from '../errors.js';
4
+ import { resolveStoreHome } from '../store.js';
5
+ import { isIgnoredChange, RebuildScheduler, resolveReal, watchPaths } from '../watch.js';
6
+ const MSG_CHANGE = 'Change detected, rebuilding\n';
7
+ const MSG_FAILED = 'Build failed; keeping the last good output\n';
8
+ function signalled() {
9
+ return new Promise((resolve) => {
10
+ process.once('SIGINT', () => resolve());
11
+ process.once('SIGTERM', () => resolve());
12
+ });
13
+ }
14
+ /**
15
+ * `build --watch` / `serve --watch`: build once, then rebuild whenever a file in the
16
+ * context, the Sitefile or a --build-arg-file changes (debounced; changes during a build
17
+ * coalesce into one follow-up). A failed rebuild is reported and the loop keeps going.
18
+ * Returns the exit code: 0 on Ctrl-C, 2 when the first build is a usage error.
19
+ */
20
+ export async function watchLoop(o) {
21
+ const st = await fs.stat(o.context).catch(() => undefined);
22
+ if (!st?.isDirectory())
23
+ throw new SitefileError('E_NOT_FOUND', `'${o.context}' is not a directory`);
24
+ const root = await fs.realpath(o.context);
25
+ const sitefile = await resolveReal(o.sitefile ?? path.join(o.context, 'Sitefile'));
26
+ const argFiles = await Promise.all(o.argFiles.map(resolveReal));
27
+ // The build itself writes the export, Sitefile.lock and (maybe) the store; none of that is input.
28
+ const ignored = new Set([await resolveReal(resolveStoreHome()), path.join(path.dirname(sitefile), 'Sitefile.lock')]);
29
+ const relevant = (p) => !isIgnoredChange(p, [root], ignored);
30
+ let finish;
31
+ const finished = new Promise((r) => (finish = r));
32
+ let first = true;
33
+ const scheduler = new RebuildScheduler(async (changed) => {
34
+ if (!first) {
35
+ if (!changed.some(relevant))
36
+ return; // only our own writes
37
+ process.stdout.write(MSG_CHANGE);
38
+ }
39
+ const r = await o.build();
40
+ for (const w of r.wrote ?? []) {
41
+ ignored.add(path.resolve(w));
42
+ ignored.add(await resolveReal(w));
43
+ }
44
+ if (!first) {
45
+ if (r.code !== 0)
46
+ process.stderr.write(MSG_FAILED);
47
+ return;
48
+ }
49
+ first = false;
50
+ if (r.code === 2)
51
+ return finish(2);
52
+ try {
53
+ await o.onStarted?.();
54
+ }
55
+ catch (e) {
56
+ if (!(e instanceof SitefileError))
57
+ throw e;
58
+ process.stderr.write(`error[${e.code}]: ${e.message}\n`);
59
+ return finish(1);
60
+ }
61
+ process.stdout.write(`Watching ${o.context} for changes (Ctrl-C to stop)\n`);
62
+ });
63
+ const close = watchPaths({
64
+ dirs: [root],
65
+ files: [sitefile, ...argFiles],
66
+ onChange: (p) => {
67
+ if (relevant(p))
68
+ scheduler.notify(p);
69
+ },
70
+ });
71
+ void (o.stop ?? signalled()).then(() => finish(0));
72
+ try {
73
+ await scheduler.run();
74
+ return await finished;
75
+ }
76
+ finally {
77
+ close();
78
+ scheduler.close();
79
+ }
80
+ }
81
+ //# sourceMappingURL=watch.js.map