@johnhenry/sitefile 0.0.0-stage → 0.0.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 (174) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +475 -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 +118 -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 +109 -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/outdated.d.ts +5 -0
  37. package/lib/src/cli/outdated.js +94 -0
  38. package/lib/src/cli/registry.d.ts +2 -0
  39. package/lib/src/cli/registry.js +122 -0
  40. package/lib/src/cli/serve-worker.d.ts +2 -0
  41. package/lib/src/cli/serve-worker.js +42 -0
  42. package/lib/src/cli/servers.d.ts +15 -0
  43. package/lib/src/cli/servers.js +451 -0
  44. package/lib/src/cli/vendor.d.ts +2 -0
  45. package/lib/src/cli/vendor.js +92 -0
  46. package/lib/src/cli/watch.d.ts +26 -0
  47. package/lib/src/cli/watch.js +81 -0
  48. package/lib/src/cli.d.ts +12 -0
  49. package/lib/src/cli.js +486 -0
  50. package/lib/src/csv.d.ts +27 -0
  51. package/lib/src/csv.js +117 -0
  52. package/lib/src/deflate.d.ts +76 -0
  53. package/lib/src/deflate.js +604 -0
  54. package/lib/src/digest.d.ts +3 -0
  55. package/lib/src/digest.js +7 -0
  56. package/lib/src/errors.d.ts +34 -0
  57. package/lib/src/errors.js +20 -0
  58. package/lib/src/executor.d.ts +386 -0
  59. package/lib/src/executor.js +2193 -0
  60. package/lib/src/explain.d.ts +64 -0
  61. package/lib/src/explain.js +445 -0
  62. package/lib/src/export-formats.d.ts +16 -0
  63. package/lib/src/export-formats.js +52 -0
  64. package/lib/src/exporter.d.ts +103 -0
  65. package/lib/src/exporter.js +676 -0
  66. package/lib/src/fingerprint.d.ts +40 -0
  67. package/lib/src/fingerprint.js +168 -0
  68. package/lib/src/frontmatter.d.ts +22 -0
  69. package/lib/src/frontmatter.js +45 -0
  70. package/lib/src/index.d.ts +12 -0
  71. package/lib/src/index.js +12 -0
  72. package/lib/src/language/ast.d.ts +353 -0
  73. package/lib/src/language/ast.js +57 -0
  74. package/lib/src/language/parser.d.ts +3 -0
  75. package/lib/src/language/parser.js +612 -0
  76. package/lib/src/language/scanner.d.ts +110 -0
  77. package/lib/src/language/scanner.js +449 -0
  78. package/lib/src/language/validate.d.ts +13 -0
  79. package/lib/src/language/validate.js +367 -0
  80. package/lib/src/links.d.ts +79 -0
  81. package/lib/src/links.js +583 -0
  82. package/lib/src/lint.d.ts +10 -0
  83. package/lib/src/lint.js +223 -0
  84. package/lib/src/markdown.d.ts +29 -0
  85. package/lib/src/markdown.js +612 -0
  86. package/lib/src/media.d.ts +82 -0
  87. package/lib/src/media.js +650 -0
  88. package/lib/src/memo.d.ts +57 -0
  89. package/lib/src/memo.js +93 -0
  90. package/lib/src/oci.d.ts +178 -0
  91. package/lib/src/oci.js +380 -0
  92. package/lib/src/outdated.d.ts +46 -0
  93. package/lib/src/outdated.js +195 -0
  94. package/lib/src/pages.d.ts +10 -0
  95. package/lib/src/pages.js +54 -0
  96. package/lib/src/paths.d.ts +39 -0
  97. package/lib/src/paths.js +101 -0
  98. package/lib/src/planner.d.ts +297 -0
  99. package/lib/src/planner.js +410 -0
  100. package/lib/src/plugins/cache.d.ts +70 -0
  101. package/lib/src/plugins/cache.js +226 -0
  102. package/lib/src/plugins/exec.d.ts +51 -0
  103. package/lib/src/plugins/exec.js +144 -0
  104. package/lib/src/plugins/host.d.ts +75 -0
  105. package/lib/src/plugins/host.js +277 -0
  106. package/lib/src/plugins/wasm.d.ts +23 -0
  107. package/lib/src/plugins/wasm.js +249 -0
  108. package/lib/src/profile.d.ts +42 -0
  109. package/lib/src/profile.js +68 -0
  110. package/lib/src/ref.d.ts +40 -0
  111. package/lib/src/ref.js +79 -0
  112. package/lib/src/registry-auth.d.ts +32 -0
  113. package/lib/src/registry-auth.js +151 -0
  114. package/lib/src/registry-cache.d.ts +71 -0
  115. package/lib/src/registry-cache.js +333 -0
  116. package/lib/src/registry-ops.d.ts +51 -0
  117. package/lib/src/registry-ops.js +248 -0
  118. package/lib/src/registry.d.ts +109 -0
  119. package/lib/src/registry.js +505 -0
  120. package/lib/src/render.d.ts +134 -0
  121. package/lib/src/render.js +576 -0
  122. package/lib/src/resources/archive.d.ts +11 -0
  123. package/lib/src/resources/archive.js +306 -0
  124. package/lib/src/resources/bundle-data.d.ts +16 -0
  125. package/lib/src/resources/bundle-data.js +50 -0
  126. package/lib/src/resources/convert.d.ts +12 -0
  127. package/lib/src/resources/convert.js +63 -0
  128. package/lib/src/resources/documents.d.ts +35 -0
  129. package/lib/src/resources/documents.js +122 -0
  130. package/lib/src/resources/git.d.ts +103 -0
  131. package/lib/src/resources/git.js +720 -0
  132. package/lib/src/resources/https.d.ts +40 -0
  133. package/lib/src/resources/https.js +256 -0
  134. package/lib/src/resources/ignore.d.ts +33 -0
  135. package/lib/src/resources/ignore.js +119 -0
  136. package/lib/src/resources/local.d.ts +64 -0
  137. package/lib/src/resources/local.js +248 -0
  138. package/lib/src/resources/lockfile.d.ts +52 -0
  139. package/lib/src/resources/lockfile.js +172 -0
  140. package/lib/src/resources/netpolicy.d.ts +6 -0
  141. package/lib/src/resources/netpolicy.js +82 -0
  142. package/lib/src/resources/types.d.ts +63 -0
  143. package/lib/src/resources/types.js +13 -0
  144. package/lib/src/resources/vendor.d.ts +94 -0
  145. package/lib/src/resources/vendor.js +296 -0
  146. package/lib/src/resources/wbn-reader.d.ts +44 -0
  147. package/lib/src/resources/wbn-reader.js +302 -0
  148. package/lib/src/schema.d.ts +25 -0
  149. package/lib/src/schema.js +169 -0
  150. package/lib/src/secrets.d.ts +21 -0
  151. package/lib/src/secrets.js +103 -0
  152. package/lib/src/serve-rules.d.ts +29 -0
  153. package/lib/src/serve-rules.js +122 -0
  154. package/lib/src/server.d.ts +45 -0
  155. package/lib/src/server.js +246 -0
  156. package/lib/src/servers.d.ts +71 -0
  157. package/lib/src/servers.js +139 -0
  158. package/lib/src/site.d.ts +60 -0
  159. package/lib/src/site.js +169 -0
  160. package/lib/src/store.d.ts +151 -0
  161. package/lib/src/store.js +596 -0
  162. package/lib/src/tree.d.ts +50 -0
  163. package/lib/src/tree.js +134 -0
  164. package/lib/src/values.d.ts +7 -0
  165. package/lib/src/values.js +37 -0
  166. package/lib/src/vendor.d.ts +19 -0
  167. package/lib/src/vendor.js +145 -0
  168. package/lib/src/watch.d.ts +44 -0
  169. package/lib/src/watch.js +137 -0
  170. package/lib/src/wbn.d.ts +5 -0
  171. package/lib/src/wbn.js +147 -0
  172. package/lib/src/yaml.d.ts +34 -0
  173. package/lib/src/yaml.js +562 -0
  174. package/package.json +58 -4
package/lib/src/oci.js ADDED
@@ -0,0 +1,380 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { gunzipSync } from 'node:zlib';
4
+ import { gzipTar, writeTar } from './archive-writers.js';
5
+ import { sha256 } from './digest.js';
6
+ import { SitefileError } from './errors.js';
7
+ import { normalizeVirtual } from './paths.js';
8
+ import { parseRef } from './ref.js';
9
+ import { extractArchive } from './resources/archive.js';
10
+ import { makeFile, Tree } from './tree.js';
11
+ /**
12
+ * OCI image-spec v1.1 writer and reader for Sitefile images.
13
+ *
14
+ * A Sitefile image has exactly one layer: a deterministic tar of the site's files placed under a
15
+ * root directory (default /srv/www), gzip-compressed. It carries files only: it has no entrypoint
16
+ * and is not runnable by itself (use it with `COPY --from=` in a Dockerfile). Everything that is
17
+ * hashed (config, manifest, index) is canonical JSON, so the same tree and options always give the
18
+ * same manifest digest.
19
+ *
20
+ * Layout (the on-disk image store is this layout; `save`/`EXPORT AS oci` write it standalone):
21
+ * oci-layout {"imageLayoutVersion":"1.0.0"}
22
+ * index.json image index; entries carry org.opencontainers.image.ref.name
23
+ * blobs/sha256/<hex> manifest, config and layer blobs
24
+ */
25
+ export const DEFAULT_OCI_ROOT = '/srv/www';
26
+ export const MEDIA = {
27
+ manifest: 'application/vnd.oci.image.manifest.v1+json',
28
+ config: 'application/vnd.oci.image.config.v1+json',
29
+ layer: 'application/vnd.oci.image.layer.v1.tar+gzip',
30
+ /** Uncompressed layer (`EXPORT ... COMPRESSION none`): the blob is the tar itself, digest = diff_id. */
31
+ layerTar: 'application/vnd.oci.image.layer.v1.tar',
32
+ index: 'application/vnd.oci.image.index.v1+json',
33
+ dockerManifest: 'application/vnd.docker.distribution.manifest.v2+json',
34
+ dockerList: 'application/vnd.docker.distribution.manifest.list.v2+json',
35
+ dockerConfig: 'application/vnd.docker.container.image.v1+json',
36
+ dockerLayer: 'application/vnd.docker.image.rootfs.diff.tar.gzip',
37
+ };
38
+ export const ANN_BASE_NAME = 'org.opencontainers.image.base.name';
39
+ export const ANN_BASE_DIGEST = 'org.opencontainers.image.base.digest';
40
+ export const ANN = {
41
+ refName: 'org.opencontainers.image.ref.name',
42
+ title: 'org.opencontainers.image.title',
43
+ source: 'org.opencontainers.image.source',
44
+ revision: 'org.opencontainers.image.revision',
45
+ root: 'dev.sitefile.root',
46
+ files: 'dev.sitefile.files',
47
+ size: 'dev.sitefile.size',
48
+ stored: 'dev.sitefile.created',
49
+ };
50
+ const EPOCH_ISO = '1970-01-01T00:00:00Z';
51
+ /** JSON with sorted keys and no whitespace. */
52
+ export function canonicalJson(v) {
53
+ if (v === null || typeof v !== 'object')
54
+ return JSON.stringify(v);
55
+ if (Array.isArray(v))
56
+ return `[${v.map(canonicalJson).join(',')}]`;
57
+ const o = v;
58
+ return `{${Object.keys(o)
59
+ .filter((k) => o[k] !== undefined)
60
+ .sort()
61
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
62
+ .join(',')}}`;
63
+ }
64
+ const enc = new TextEncoder();
65
+ export function parsePlatform(p) {
66
+ if (p === undefined)
67
+ return { os: 'linux', arch: 'amd64' };
68
+ const m = /^([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)$/.exec(p);
69
+ if (!m)
70
+ throw new SitefileError('E_USAGE', `platform must be os/arch (e.g. linux/amd64), got '${p}'`);
71
+ return { os: m[1], arch: m[2] };
72
+ }
73
+ /** Validate an in-image root path (absolute, no `..`); returns the normalized form. */
74
+ export function normalizeOciRoot(raw) {
75
+ return normalizeVirtual(raw ?? DEFAULT_OCI_ROOT, undefined, 'OCI root').path;
76
+ }
77
+ function isoOf(epoch) {
78
+ return epoch === undefined ? EPOCH_ISO : new Date(epoch * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
79
+ }
80
+ /** Build the single-layer image for `tree` (the site's files, rooted at `/`). */
81
+ export function buildOciImage(tree, options = {}) {
82
+ const root = normalizeOciRoot(options.root);
83
+ const { os, arch } = parsePlatform(options.platform);
84
+ const layerTree = Tree.fromFiles(tree.mountedAt(root));
85
+ const segs = root === '/' ? [] : root.slice(1).split('/');
86
+ const dirs = segs.map((_, i) => segs.slice(0, i + 1).join('/'));
87
+ const tar = writeTar(layerTree, { epoch: options.epoch ?? 0, dirs });
88
+ const uncompressed = options.compression === 'none';
89
+ const layerBytes = uncompressed ? tar : gzipTar(tar);
90
+ const diffId = sha256(tar);
91
+ const layerDigest = uncompressed ? diffId : sha256(layerBytes);
92
+ const size = tree.entries().reduce((n, [, f]) => n + f.bytes.byteLength, 0);
93
+ const base = options.base;
94
+ const baseCfg = base?.config ?? {};
95
+ const baseInner = (isRecord(baseCfg.config) ? baseCfg.config : {});
96
+ const labels = {};
97
+ if (isRecord(baseInner.Labels))
98
+ for (const [k, v] of Object.entries(baseInner.Labels))
99
+ if (typeof v === 'string')
100
+ labels[k] = v;
101
+ labels[ANN.root] = root;
102
+ if (options.title)
103
+ labels[ANN.title] = options.title;
104
+ if (options.source)
105
+ labels[ANN.source] = options.source;
106
+ if (options.revision)
107
+ labels[ANN.revision] = options.revision;
108
+ if (base) {
109
+ labels[ANN_BASE_NAME] = base.name;
110
+ labels[ANN_BASE_DIGEST] = base.manifestDigest;
111
+ }
112
+ for (const [k, v] of Object.entries(options.labels ?? {}))
113
+ labels[k] = v;
114
+ // created: SOURCE_DATE_EPOCH when set; else the base's own timestamp (deterministic given the base
115
+ // digest) or the fixed epoch when there is no base.
116
+ const baseCreated = typeof baseCfg.created === 'string' ? baseCfg.created : undefined;
117
+ const created = options.epoch === undefined && baseCreated !== undefined ? baseCreated : isoOf(options.epoch);
118
+ const baseRootfs = isRecord(baseCfg.rootfs) ? baseCfg.rootfs : {};
119
+ const baseDiffIds = Array.isArray(baseRootfs.diff_ids) ? baseRootfs.diff_ids : [];
120
+ const baseHistory = Array.isArray(baseCfg.history) ? baseCfg.history : [];
121
+ const baseLayers = (base?.layers ?? []).map(normalizeLayer);
122
+ if (base && baseDiffIds.length !== baseLayers.length)
123
+ throw new SitefileError('E_STORE', `base image ${base.name}: config diff_ids do not match its layers`);
124
+ const configBytes = enc.encode(canonicalJson({
125
+ ...baseCfg,
126
+ architecture: base && typeof baseCfg.architecture === 'string' ? baseCfg.architecture : arch,
127
+ os: base && typeof baseCfg.os === 'string' ? baseCfg.os : os,
128
+ created,
129
+ config: { ...baseInner, Labels: labels },
130
+ rootfs: { type: 'layers', diff_ids: [...baseDiffIds, diffId] },
131
+ history: [...baseHistory, { created: isoOf(options.epoch), created_by: 'sitefile', comment: 'static site files', author: 'sitefile' }],
132
+ }));
133
+ const configDigest = sha256(configBytes);
134
+ const annotations = { [ANN.files]: String(tree.size), [ANN.size]: String(size) };
135
+ if (options.title)
136
+ annotations[ANN.title] = options.title;
137
+ if (base) {
138
+ annotations[ANN_BASE_NAME] = base.name;
139
+ annotations[ANN_BASE_DIGEST] = base.manifestDigest;
140
+ }
141
+ const manifestBytes = enc.encode(canonicalJson({
142
+ schemaVersion: 2,
143
+ mediaType: MEDIA.manifest,
144
+ config: { mediaType: MEDIA.config, digest: configDigest, size: configBytes.byteLength },
145
+ layers: [...baseLayers, { mediaType: uncompressed ? MEDIA.layerTar : MEDIA.layer, digest: layerDigest, size: layerBytes.byteLength }],
146
+ annotations,
147
+ }));
148
+ return { manifestBytes, manifestDigest: sha256(manifestBytes), configBytes, configDigest, layerBytes, layerDigest, diffId, root, baseLayers };
149
+ }
150
+ function isRecord(v) {
151
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
152
+ }
153
+ /** Docker layer media types become their OCI equivalents (same bytes, same digest). */
154
+ function normalizeLayer(d) {
155
+ const mediaType = d.mediaType === MEDIA.dockerLayer ? MEDIA.layer : d.mediaType;
156
+ return { mediaType, digest: d.digest, size: d.size };
157
+ }
158
+ export const ANN_CONTAINERD_NAME = 'io.containerd.image.name';
159
+ /**
160
+ * Index annotations for an exported image named `refName` (`blog:1`, `localhost:5000/blog:1`), in the
161
+ * convention `docker save` uses so Docker's containerd store finds the image by name after `docker load`:
162
+ * `io.containerd.image.name` is the full normalized reference and `org.opencontainers.image.ref.name` the tag.
163
+ * Single-component names live in docker.io/library/, other names without a registry host in docker.io/.
164
+ */
165
+ export function exportNameAnnotations(refName) {
166
+ const r = parseRef(refName);
167
+ const tag = r.tag ?? 'latest';
168
+ const full = r.registry !== undefined ? r.name : r.name.includes('/') ? r.name : `library/${r.name}`;
169
+ const host = r.registry ?? 'docker.io';
170
+ return { [ANN_CONTAINERD_NAME]: `${host}/${full}:${tag}`, [ANN.refName]: tag };
171
+ }
172
+ export function encodeIndex(entries) {
173
+ const manifests = entries.map((e) => {
174
+ const annotations = {};
175
+ if (e.refName)
176
+ annotations[ANN.refName] = e.refName;
177
+ if (e.stored)
178
+ annotations[ANN.stored] = e.stored;
179
+ Object.assign(annotations, e.extra);
180
+ return { mediaType: MEDIA.manifest, digest: e.digest, size: e.size, artifactType: e.artifactType, ...(Object.keys(annotations).length ? { annotations } : {}) };
181
+ });
182
+ return enc.encode(canonicalJson({ schemaVersion: 2, mediaType: MEDIA.index, manifests }));
183
+ }
184
+ export const OCI_LAYOUT_BYTES = enc.encode('{"imageLayoutVersion":"1.0.0"}');
185
+ const SHA = /^sha256:[0-9a-f]{64}$/;
186
+ export function decodeIndex(bytes) {
187
+ const j = JSON.parse(Buffer.from(bytes).toString('utf8'));
188
+ if (j.schemaVersion !== 2 || !Array.isArray(j.manifests))
189
+ throw new Error('not an OCI image index');
190
+ return j.manifests.map((m) => {
191
+ if (typeof m.digest !== 'string' || !SHA.test(m.digest) || typeof m.size !== 'number')
192
+ throw new Error('bad index entry');
193
+ const a = (m.annotations ?? {});
194
+ const ref = a[ANN.refName];
195
+ const stored = a[ANN.stored];
196
+ return { digest: m.digest, size: m.size, refName: typeof ref === 'string' ? ref : undefined, stored: typeof stored === 'string' ? stored : undefined };
197
+ });
198
+ }
199
+ export function blobPathOf(digest) {
200
+ if (!SHA.test(digest))
201
+ throw new SitefileError('E_STORE', `invalid digest '${digest}'`);
202
+ return `/blobs/sha256/${digest.slice(7)}`;
203
+ }
204
+ /**
205
+ * A standalone OCI image layout holding one image, as a Tree (write it as a directory or tar it).
206
+ * `referrers` (attestations, `build --provenance/--sbom`) are listed in index.json after the image,
207
+ * without a name, and their blobs are included.
208
+ */
209
+ export function layoutTree(img, refName,
210
+ /** Blobs of the layers below the site layer (digest -> bytes), for images built on a BASE. */
211
+ baseBlobs = new Map(), referrers = []) {
212
+ const entries = [
213
+ { digest: img.manifestDigest, size: img.manifestBytes.byteLength, ...(refName ? { extra: exportNameAnnotations(refName) } : {}) },
214
+ ...referrers.map((r) => ({ digest: r.descriptor.digest, size: r.descriptor.size, artifactType: r.descriptor.artifactType, extra: r.descriptor.annotations })),
215
+ ];
216
+ const blobs = new Map([[img.manifestDigest, img.manifestBytes], [img.configDigest, img.configBytes], [img.layerDigest, img.layerBytes], ...baseBlobs]);
217
+ for (const r of referrers)
218
+ for (const [d, b] of r.blobs)
219
+ blobs.set(d, b);
220
+ const files = [
221
+ ['/oci-layout', makeFile(OCI_LAYOUT_BYTES, 'oci')],
222
+ ['/index.json', makeFile(encodeIndex(entries), 'oci')],
223
+ ...[...blobs].map(([d, b]) => [blobPathOf(d), makeFile(b, 'oci')]),
224
+ ];
225
+ return Tree.fromFiles(files);
226
+ }
227
+ /** Deterministic tar of {@link layoutTree}: an "OCI archive" (`.oci.tar`). */
228
+ export function ociArchive(img, refName, epoch, baseBlobs, referrers = []) {
229
+ return writeTar(layoutTree(img, refName, baseBlobs, referrers), { epoch: epoch ?? 0 });
230
+ }
231
+ function desc(d, what) {
232
+ const o = d;
233
+ if (typeof o?.mediaType !== 'string' || typeof o.digest !== 'string' || !SHA.test(o.digest) || typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) {
234
+ throw new Error(`bad ${what} descriptor`);
235
+ }
236
+ return { mediaType: o.mediaType, digest: o.digest, size: o.size };
237
+ }
238
+ export function parseManifest(bytes) {
239
+ const m = JSON.parse(Buffer.from(bytes).toString('utf8'));
240
+ // OCI manifests may omit mediaType; Docker v2 manifests (pulled images) are read as-is.
241
+ if (m.schemaVersion !== 2 || (m.mediaType !== undefined && m.mediaType !== MEDIA.manifest && m.mediaType !== MEDIA.dockerManifest) || !Array.isArray(m.layers)) {
242
+ throw new Error('not an image manifest (OCI or Docker v2)');
243
+ }
244
+ const annotations = {};
245
+ for (const [k, v] of Object.entries((m.annotations ?? {})))
246
+ if (typeof v === 'string')
247
+ annotations[k] = v;
248
+ return { config: desc(m.config, 'config'), layers: m.layers.map((l) => desc(l, 'layer')), annotations };
249
+ }
250
+ export function parseConfig(bytes) {
251
+ const c = JSON.parse(Buffer.from(bytes).toString('utf8'));
252
+ const ids = c.rootfs?.diff_ids;
253
+ if (c.rootfs?.type !== 'layers' || !Array.isArray(ids) || !ids.every((d) => typeof d === 'string' && SHA.test(d)))
254
+ throw new Error('bad rootfs');
255
+ const labels = {};
256
+ for (const [k, v] of Object.entries(c.config?.Labels ?? {}))
257
+ if (typeof v === 'string')
258
+ labels[k] = v;
259
+ return { os: String(c.os ?? ''), architecture: String(c.architecture ?? ''), created: String(c.created ?? ''), labels, diffIds: ids };
260
+ }
261
+ /**
262
+ * Index of the layer holding the site's files: the only layer, or the last one of an image that
263
+ * carries the `dev.sitefile.root` label (built with BASE). -1 when the image is not a Sitefile image.
264
+ */
265
+ export function siteLayerIndex(m, cfg) {
266
+ if (m.layers.length === 1)
267
+ return 0;
268
+ return m.layers.length > 1 && cfg.labels[ANN.root] !== undefined ? m.layers.length - 1 : -1;
269
+ }
270
+ /**
271
+ * Decode a site image's files, verifying the layer digest and diff_id.
272
+ * `layerBytes` are the compressed blob bytes. Returns the tree under the image's root (its
273
+ * `dev.sitefile.root` label, else `/`), or under `rootOverride` (`FROM image … AT <dir>`).
274
+ */
275
+ export async function extractImageFiles(m, cfg, layerBytes, origin, rootOverride) {
276
+ const idx = siteLayerIndex(m, cfg);
277
+ const layer = m.layers[idx];
278
+ if (idx < 0 || !layer || (layer.mediaType !== MEDIA.layer && layer.mediaType !== MEDIA.layerTar)) {
279
+ throw new SitefileError('E_STORE', `${origin}: not a Sitefile image (only single-layer tar or tar+gzip images, or images built with BASE, hold site files)`);
280
+ }
281
+ if (sha256(layerBytes) !== layer.digest || layerBytes.byteLength !== layer.size) {
282
+ throw new SitefileError('E_STORE', `${origin}: layer blob does not match its digest`);
283
+ }
284
+ let tar = layerBytes;
285
+ if (layer.mediaType === MEDIA.layer) {
286
+ try {
287
+ tar = new Uint8Array(gunzipSync(layerBytes));
288
+ }
289
+ catch (e) {
290
+ throw new SitefileError('E_STORE', `${origin}: layer is not valid gzip`, undefined, { cause: e });
291
+ }
292
+ }
293
+ if (sha256(tar) !== cfg.diffIds[idx])
294
+ throw new SitefileError('E_STORE', `${origin}: layer content does not match the config diff_id`);
295
+ const root = rootOverride ?? cfg.labels[ANN.root] ?? '/';
296
+ const all = await extractArchive(tar, origin, { maxEntries: 1_000_000, maxTotalBytes: 8 * 2 ** 30, maxRatio: Number.MAX_SAFE_INTEGER });
297
+ return all.subtree(normalizeVirtual(root).path);
298
+ }
299
+ /**
300
+ * Label values for an image built from `contextDir`: the title (directory name),
301
+ * extra `--label`s, and, only with `--git-labels`, source/revision from a git
302
+ * checkout. Git labels are opt-in because they make the image ID change with
303
+ * every commit even when the files don't, and because finding the checkout
304
+ * reads outside the build context.
305
+ */
306
+ export async function imageMeta(contextDir, opts = {}) {
307
+ const info = opts.git ? await gitInfo(contextDir) : {};
308
+ return { title: path.basename(path.resolve(contextDir)), ...info, ...(opts.labels ? { labels: opts.labels } : {}) };
309
+ }
310
+ const LABEL_KEY = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,254}$/;
311
+ /** Parse `--label key=value` flags. */
312
+ export function parseLabels(flags) {
313
+ const out = Object.create(null);
314
+ for (const f of flags) {
315
+ const eq = f.indexOf('=');
316
+ const key = eq === -1 ? f : f.slice(0, eq);
317
+ if (eq === -1 || !LABEL_KEY.test(key)) {
318
+ throw new SitefileError('E_USAGE', `--label expects key=value with a key like org.example.team, got '${f}'`);
319
+ }
320
+ out[key] = f.slice(eq + 1);
321
+ }
322
+ return out;
323
+ }
324
+ /** Best-effort `org.opencontainers.image.{source,revision}` from a git checkout (never runs git; credentials in URLs are dropped). */
325
+ export async function gitInfo(contextDir) {
326
+ const out = {};
327
+ let dir = path.resolve(contextDir);
328
+ let gitDir;
329
+ for (let i = 0; i < 8; i++) {
330
+ const g = path.join(dir, '.git');
331
+ if (await readFile(path.join(g, 'HEAD'), 'utf8').then(() => true, () => false)) {
332
+ gitDir = g;
333
+ break;
334
+ }
335
+ const up = path.dirname(dir);
336
+ if (up === dir)
337
+ break;
338
+ dir = up;
339
+ }
340
+ if (!gitDir)
341
+ return out;
342
+ try {
343
+ const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
344
+ let rev = head;
345
+ const m = /^ref: (refs\/[\w./-]+)$/.exec(head);
346
+ if (m) {
347
+ rev = await readFile(path.join(gitDir, m[1]), 'utf8').then((t) => t.trim(), async () => {
348
+ const packed = await readFile(path.join(gitDir, 'packed-refs'), 'utf8').catch(() => '');
349
+ return packed.split('\n').find((l) => l.endsWith(` ${m[1]}`))?.split(' ')[0] ?? '';
350
+ });
351
+ }
352
+ if (/^[0-9a-f]{40,64}$/.test(rev))
353
+ out.revision = rev;
354
+ }
355
+ catch {
356
+ /* unknown */
357
+ }
358
+ try {
359
+ const cfg = await readFile(path.join(gitDir, 'config'), 'utf8');
360
+ const url = /\[remote "origin"\][^[]*?\burl\s*=\s*(\S+)/.exec(cfg)?.[1];
361
+ if (url) {
362
+ const u = /^[a-z+]+:\/\//.test(url) ? new URL(url) : undefined;
363
+ if (u) {
364
+ u.username = '';
365
+ u.password = '';
366
+ u.search = '';
367
+ u.hash = '';
368
+ out.source = u.toString();
369
+ }
370
+ else if (/^[\w.-]+@[\w.-]+:[\w./-]+$/.test(url)) {
371
+ out.source = url; // scp-style: user@host:path (no secret)
372
+ }
373
+ }
374
+ }
375
+ catch {
376
+ /* unknown */
377
+ }
378
+ return out;
379
+ }
380
+ //# sourceMappingURL=oci.js.map
@@ -0,0 +1,46 @@
1
+ import { type BuildOptions } from './executor.js';
2
+ /**
3
+ * `sitefile outdated` (issue #104): report, for every remote input, whether what it points at today
4
+ * differs from its pin. It never writes anything (no lockfile, no image store); `--update-lock`
5
+ * stays the way to change pins.
6
+ *
7
+ * What is checkable, honestly:
8
+ * - an https IMPORT has no notion of "a newer version": the URL is fetched again (a full GET under
9
+ * the build's network policy) and the body's sha256 is compared with the pin (INTEGRITY, else the
10
+ * Sitefile.lock entry). Sitefile.lock records no ETag or Last-Modified, so there is nothing to make
11
+ * a conditional request against, and a changed validator would not prove changed content anyway.
12
+ * A version inside a URL (`.../lib@1.2.3/x.js`) is not interpreted.
13
+ * - a BASE tag (`nginx:1.27-alpine`, with or without `@sha256:`) is resolved again (one manifest
14
+ * GET) and compared with the digest pinned in the Sitefile or recorded in Sitefile.lock: "changed"
15
+ * means the tag has moved. A digest-only reference (`nginx@sha256:...`) has no tag to follow.
16
+ * - for a tag that looks like a version (`[v]N[.N[.N]][-suffix]`), the registry's tag list is read and
17
+ * the highest tag of the same shape (same `v`, number of components and suffix) that is greater is
18
+ * reported as `newerTag`. Registries that refuse to list tags just give no `newerTag`.
19
+ */
20
+ export type OutdatedStatus = 'current' | 'changed' | 'unpinned' | 'digest-only' | 'error';
21
+ export interface OutdatedEntry {
22
+ readonly kind: 'import' | 'image' | 'base';
23
+ /** The IMPORT name, the stage of a `FROM image` base (its name, or `#<n>`), or `BASE`. */
24
+ readonly name: string;
25
+ /** The URL, or the canonical image reference. */
26
+ readonly source: string;
27
+ /** The pinned digest, if any. */
28
+ readonly pin: string | null;
29
+ readonly pinnedBy: 'integrity' | 'lockfile' | 'digest' | 'commit' | null;
30
+ /** What the source resolves to now (body sha256, or the tag's manifest/index digest). */
31
+ readonly latest: string | null;
32
+ readonly status: OutdatedStatus;
33
+ /** Images only (BASE and FROM image): a greater version-like tag of the same shape. */
34
+ readonly newerTag: string | null;
35
+ readonly error: {
36
+ readonly code: string;
37
+ readonly message: string;
38
+ } | null;
39
+ }
40
+ export interface OutdatedReport {
41
+ readonly inputs: readonly OutdatedEntry[];
42
+ readonly summary: Readonly<Record<OutdatedStatus, number>>;
43
+ }
44
+ /** The greatest tag of `tags` with the same shape as `current` that is greater than it (ties: the first in byte order). */
45
+ export declare function newerVersionTag(current: string, tags: readonly string[]): string | undefined;
46
+ export declare function outdated(options: BuildOptions): Promise<OutdatedReport>;
@@ -0,0 +1,195 @@
1
+ import path from 'node:path';
2
+ import { makeClient } from './base-image.js';
3
+ import { sha256 } from './digest.js';
4
+ import { isSitefileError } from './errors.js';
5
+ import { loadProgram, readImportedSitefile, rootSitefileSource } from './executor.js';
6
+ import { importedStageRefs, plan } from './planner.js';
7
+ import { parseRef, withDefaultRegistry } from './ref.js';
8
+ import { fetchRemote } from './resources/https.js';
9
+ import { fetchGitCommit, resolveRef, selectGitPath } from './resources/git.js';
10
+ import { redactError, redactSecrets, secretHeaders } from './secrets.js';
11
+ import { Lockfile } from './resources/lockfile.js';
12
+ import { DEFAULT_ARCHIVE_LIMITS, DEFAULT_FETCH_POLICY } from './resources/types.js';
13
+ const VERSION_TAG = /^(v?)(\d+)(?:\.(\d+))?(?:\.(\d+))?(-[0-9A-Za-z][0-9A-Za-z._-]*)?$/;
14
+ function versionTag(tag) {
15
+ const m = VERSION_TAG.exec(tag);
16
+ if (!m)
17
+ return undefined;
18
+ const parts = [m[2], m[3], m[4]].filter((x) => x !== undefined).map((x) => x.replace(/^0+(?=\d)/, ''));
19
+ return { prefix: m[1] ?? '', parts, suffix: m[5] ?? '' };
20
+ }
21
+ /** Numeric comparison of two digit strings without leading zeros. */
22
+ function cmpNum(a, b) {
23
+ return a.length !== b.length ? a.length - b.length : a < b ? -1 : a > b ? 1 : 0;
24
+ }
25
+ function cmpVersion(a, b) {
26
+ for (let i = 0; i < a.parts.length; i++) {
27
+ const c = cmpNum(a.parts[i], b.parts[i]);
28
+ if (c !== 0)
29
+ return c;
30
+ }
31
+ return 0;
32
+ }
33
+ /** The greatest tag of `tags` with the same shape as `current` that is greater than it (ties: the first in byte order). */
34
+ export function newerVersionTag(current, tags) {
35
+ const cur = versionTag(current);
36
+ if (!cur)
37
+ return undefined;
38
+ let best;
39
+ for (const t of [...tags].sort()) {
40
+ const v = versionTag(t);
41
+ if (!v || v.prefix !== cur.prefix || v.suffix !== cur.suffix || v.parts.length !== cur.parts.length)
42
+ continue;
43
+ if (cmpVersion(v, cur) <= 0)
44
+ continue;
45
+ if (!best || cmpVersion(v, best.v) > 0)
46
+ best = { tag: t, v };
47
+ }
48
+ return best?.tag;
49
+ }
50
+ function errorOf(e, secrets) {
51
+ if (isSitefileError(e))
52
+ return { code: e.code, message: redactSecrets(e.detail, secrets) };
53
+ throw redactError(e, secrets);
54
+ }
55
+ /** An entry with its keys in the documented order (the JSON output is compared across implementations). */
56
+ function entry(e) {
57
+ return { kind: e.kind, name: e.name, source: e.source, pin: e.pin, pinnedBy: e.pinnedBy, latest: e.latest, status: e.status, newerTag: e.newerTag, error: e.error };
58
+ }
59
+ function statusOf(pin, latest) {
60
+ return pin === null ? 'unpinned' : pin === latest ? 'current' : 'changed';
61
+ }
62
+ export async function outdated(options) {
63
+ const { program, sitefilePath, label } = await loadProgram(options);
64
+ const p = plan(program, options.buildArgs ?? {});
65
+ p.warnings.forEach((m) => options.onWarning?.(m));
66
+ const policy = { ...DEFAULT_FETCH_POLICY, ...options.fetchPolicy };
67
+ const lockPath = options.lockfile === false ? undefined : (options.lockfile ?? path.join(path.dirname(sitefilePath), 'Sitefile.lock'));
68
+ const lock = lockPath ? await Lockfile.load(lockPath) : undefined;
69
+ const inputs = [];
70
+ const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...options.archiveLimits };
71
+ /** Sitefiles imported AS sitefile, with the prefix their inputs are named by (`theme+`). */
72
+ const nested = [];
73
+ /** The rows of `sp`'s imports (`sp` read from `source`), then of the Sitefiles it imports, named `<prefix><name>`. */
74
+ const importRows = async (sp, source, prefix) => {
75
+ for (const spec of sp.imports) {
76
+ const name = prefix + spec.name;
77
+ let rowIndex = -1;
78
+ if (spec.location.kind === 'git') {
79
+ // A ref is resolved again (ls-refs, nothing is downloaded): changed = the ref moved since it was
80
+ // locked. A commit in the URL has no ref to follow.
81
+ const loc = spec.location;
82
+ const locked = lock?.getGit(loc.url)?.commit;
83
+ const pin = loc.commit ?? locked ?? null;
84
+ const pinnedBy = loc.commit !== undefined ? 'commit' : locked !== undefined ? 'lockfile' : null;
85
+ const row = { kind: 'import', name, source: loc.url, pin, pinnedBy, newerTag: null };
86
+ rowIndex = inputs.length;
87
+ if (loc.commit !== undefined) {
88
+ inputs.push(entry({ ...row, latest: null, status: 'digest-only', error: null }));
89
+ }
90
+ else {
91
+ try {
92
+ const originHeaders = secretHeaders(spec, options.secrets);
93
+ const latest = await resolveRef(loc.repo, loc.ref, { policy, span: spec.span, ...(originHeaders ? { originHeaders } : {}) });
94
+ inputs.push(entry({ ...row, latest, status: statusOf(pin, latest), error: null }));
95
+ }
96
+ catch (e) {
97
+ inputs.push(entry({ ...row, latest: null, status: 'error', error: errorOf(e, options.secrets) }));
98
+ }
99
+ }
100
+ }
101
+ else if (spec.location.kind === 'remote') {
102
+ const url = spec.location.url;
103
+ const locked = lock?.get(url);
104
+ const pin = spec.integrity ?? locked?.integrity ?? null;
105
+ const pinnedBy = spec.integrity ? 'integrity' : locked ? 'lockfile' : null;
106
+ const row = { kind: 'import', name, source: url, pin, pinnedBy, newerTag: null };
107
+ try {
108
+ const originHeaders = secretHeaders(spec, options.secrets);
109
+ const { bytes } = await fetchRemote(url, policy, undefined, originHeaders ? { originHeaders } : {});
110
+ const latest = sha256(bytes);
111
+ inputs.push(entry({ ...row, latest, status: statusOf(pin, latest), error: null }));
112
+ }
113
+ catch (e) {
114
+ inputs.push(entry({ ...row, latest: null, status: 'error', error: errorOf(e, options.secrets) }));
115
+ }
116
+ }
117
+ if (spec.as !== 'sitefile' || inputs[rowIndex]?.status === 'error')
118
+ continue;
119
+ // The inputs of an imported Sitefile, as pinned (a Git one is read at its pinned commit, else the ref's).
120
+ let sub;
121
+ try {
122
+ sub = await readImportedSitefile(spec, source, importedStageRefs(sp.stages), async (s, loc) => {
123
+ const originHeaders = secretHeaders(s, options.secrets);
124
+ const want = loc.commit ?? lock?.getGit(loc.url)?.commit;
125
+ const objs = await fetchGitCommit(loc.repo, loc.ref, want, { policy, span: s.span, ...(originHeaders ? { originHeaders } : {}) }, limits);
126
+ return { commit: objs.commit, selection: selectGitPath(objs, loc, limits, s.span) };
127
+ });
128
+ }
129
+ catch (e) {
130
+ const error = errorOf(e, options.secrets);
131
+ const prior = inputs[rowIndex];
132
+ const row = prior
133
+ ? { ...prior, latest: null, status: 'error', error }
134
+ : { kind: 'import', name, source: spec.uri, pin: null, pinnedBy: null, latest: null, status: 'error', newerTag: null, error };
135
+ if (prior)
136
+ inputs[rowIndex] = entry(row);
137
+ else
138
+ inputs.push(entry(row));
139
+ continue;
140
+ }
141
+ sub.plan.warnings.forEach((m) => options.onWarning?.(m));
142
+ nested.push({ prefix: `${name}+`, plan: sub.plan });
143
+ await importRows(sub.plan, sub.source, `${name}+`);
144
+ }
145
+ };
146
+ await importRows(p, await rootSitefileSource(options, label, sitefilePath), '');
147
+ /** A registry image (BASE or a FROM image base): resolve its tag again and compare with the pin. */
148
+ const imageRow = async (kind, name, input) => {
149
+ let source = input;
150
+ let pin = null;
151
+ let pinnedBy = null;
152
+ try {
153
+ const ref = withDefaultRegistry(parseRef(input));
154
+ const registry = ref.registry;
155
+ source = `${registry}/${ref.name}${ref.tag !== undefined ? `:${ref.tag}` : ''}${ref.digest !== undefined ? `@${ref.digest}` : ''}`;
156
+ const locked = lock?.getImage(`${registry}/${ref.name}:${ref.tag ?? 'latest'}`);
157
+ pin = ref.digest ?? locked?.digest ?? null;
158
+ pinnedBy = ref.digest !== undefined ? 'digest' : pin !== null ? 'lockfile' : null;
159
+ if (ref.tag === undefined && ref.digest !== undefined) {
160
+ return entry({ kind, name, source, pin, pinnedBy, latest: null, status: 'digest-only', newerTag: null, error: null });
161
+ }
162
+ const tag = ref.tag ?? 'latest';
163
+ const client = makeClient(registry, { ...options.registry, allowPrivateNetwork: options.registry?.allowPrivateNetwork ?? policy.allowPrivateNetwork });
164
+ const latest = (await client.getManifest(ref.name, tag)).digest;
165
+ let newerTag = null;
166
+ if (versionTag(tag)) {
167
+ try {
168
+ newerTag = newerVersionTag(tag, await client.listTags(ref.name)) ?? null;
169
+ }
170
+ catch (e) {
171
+ errorOf(e); // not listable: no newer tag
172
+ }
173
+ }
174
+ return entry({ kind, name, source, pin, pinnedBy, latest, status: statusOf(pin, latest), newerTag, error: null });
175
+ }
176
+ catch (e) {
177
+ return entry({ kind, name, source, pin, pinnedBy, latest: null, status: 'error', newerTag: null, error: errorOf(e) });
178
+ }
179
+ };
180
+ // FROM image stage bases with a registry (local-store images have no upstream), named by their stage.
181
+ for (const { prefix, plan: sp } of [{ prefix: '', plan: p }, ...nested]) {
182
+ for (const [i, st] of sp.stages.entries()) {
183
+ if (st.base.kind === 'image' && !st.base.local)
184
+ inputs.push(await imageRow('image', prefix + (st.name ?? `#${i + 1}`), st.base.ref));
185
+ }
186
+ }
187
+ const baseInput = options.baseImage ?? (p.exportFormat === 'oci' ? p.exportBase : undefined);
188
+ if (baseInput !== undefined)
189
+ inputs.push(await imageRow('base', 'BASE', baseInput));
190
+ const summary = { current: 0, changed: 0, unpinned: 0, 'digest-only': 0, error: 0 };
191
+ for (const i of inputs)
192
+ summary[i.status]++;
193
+ return { inputs, summary };
194
+ }
195
+ //# sourceMappingURL=outdated.js.map
@@ -0,0 +1,10 @@
1
+ import { type SourceSpan } from './errors.js';
2
+ type Doc = Record<string, unknown>;
3
+ /** Group documents by a field; `what` prefixes messages (`FOR tag IN posts BY tags`). */
4
+ export declare function groupBy(items: readonly unknown[], key: string, what: string, span: SourceSpan): Doc[];
5
+ /** Cut each group (or the whole list, as one group without a key) into pages of `size` items. */
6
+ export declare function paginate(groups: ReadonlyArray<{
7
+ readonly key?: string;
8
+ readonly items: readonly unknown[];
9
+ }>, size: number): Doc[][];
10
+ export {};