@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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +475 -2
- package/lib/src/archive-writers.d.ts +31 -0
- package/lib/src/archive-writers.js +197 -0
- package/lib/src/attest.d.ts +126 -0
- package/lib/src/attest.js +220 -0
- package/lib/src/bake.d.ts +74 -0
- package/lib/src/bake.js +372 -0
- package/lib/src/base-image.d.ts +76 -0
- package/lib/src/base-image.js +138 -0
- package/lib/src/build-args.d.ts +26 -0
- package/lib/src/build-args.js +164 -0
- package/lib/src/cache-limit.d.ts +54 -0
- package/lib/src/cache-limit.js +239 -0
- package/lib/src/casefold-data.d.ts +2 -0
- package/lib/src/casefold-data.js +217 -0
- package/lib/src/cbor.d.ts +39 -0
- package/lib/src/cbor.js +174 -0
- package/lib/src/checklinks.d.ts +42 -0
- package/lib/src/checklinks.js +118 -0
- package/lib/src/cli/bake.d.ts +2 -0
- package/lib/src/cli/bake.js +179 -0
- package/lib/src/cli/build-image.d.ts +21 -0
- package/lib/src/cli/build-image.js +42 -0
- package/lib/src/cli/cache.d.ts +8 -0
- package/lib/src/cli/cache.js +109 -0
- package/lib/src/cli/common.d.ts +29 -0
- package/lib/src/cli/common.js +100 -0
- package/lib/src/cli/git-context.d.ts +18 -0
- package/lib/src/cli/git-context.js +48 -0
- package/lib/src/cli/images.d.ts +6 -0
- package/lib/src/cli/images.js +285 -0
- package/lib/src/cli/index.d.ts +10 -0
- package/lib/src/cli/index.js +95 -0
- package/lib/src/cli/outdated.d.ts +5 -0
- package/lib/src/cli/outdated.js +94 -0
- package/lib/src/cli/registry.d.ts +2 -0
- package/lib/src/cli/registry.js +122 -0
- package/lib/src/cli/serve-worker.d.ts +2 -0
- package/lib/src/cli/serve-worker.js +42 -0
- package/lib/src/cli/servers.d.ts +15 -0
- package/lib/src/cli/servers.js +451 -0
- package/lib/src/cli/vendor.d.ts +2 -0
- package/lib/src/cli/vendor.js +92 -0
- package/lib/src/cli/watch.d.ts +26 -0
- package/lib/src/cli/watch.js +81 -0
- package/lib/src/cli.d.ts +12 -0
- package/lib/src/cli.js +486 -0
- package/lib/src/csv.d.ts +27 -0
- package/lib/src/csv.js +117 -0
- package/lib/src/deflate.d.ts +76 -0
- package/lib/src/deflate.js +604 -0
- package/lib/src/digest.d.ts +3 -0
- package/lib/src/digest.js +7 -0
- package/lib/src/errors.d.ts +34 -0
- package/lib/src/errors.js +20 -0
- package/lib/src/executor.d.ts +386 -0
- package/lib/src/executor.js +2193 -0
- package/lib/src/explain.d.ts +64 -0
- package/lib/src/explain.js +445 -0
- package/lib/src/export-formats.d.ts +16 -0
- package/lib/src/export-formats.js +52 -0
- package/lib/src/exporter.d.ts +103 -0
- package/lib/src/exporter.js +676 -0
- package/lib/src/fingerprint.d.ts +40 -0
- package/lib/src/fingerprint.js +168 -0
- package/lib/src/frontmatter.d.ts +22 -0
- package/lib/src/frontmatter.js +45 -0
- package/lib/src/index.d.ts +12 -0
- package/lib/src/index.js +12 -0
- package/lib/src/language/ast.d.ts +353 -0
- package/lib/src/language/ast.js +57 -0
- package/lib/src/language/parser.d.ts +3 -0
- package/lib/src/language/parser.js +612 -0
- package/lib/src/language/scanner.d.ts +110 -0
- package/lib/src/language/scanner.js +449 -0
- package/lib/src/language/validate.d.ts +13 -0
- package/lib/src/language/validate.js +367 -0
- package/lib/src/links.d.ts +79 -0
- package/lib/src/links.js +583 -0
- package/lib/src/lint.d.ts +10 -0
- package/lib/src/lint.js +223 -0
- package/lib/src/markdown.d.ts +29 -0
- package/lib/src/markdown.js +612 -0
- package/lib/src/media.d.ts +82 -0
- package/lib/src/media.js +650 -0
- package/lib/src/memo.d.ts +57 -0
- package/lib/src/memo.js +93 -0
- package/lib/src/oci.d.ts +178 -0
- package/lib/src/oci.js +380 -0
- package/lib/src/outdated.d.ts +46 -0
- package/lib/src/outdated.js +195 -0
- package/lib/src/pages.d.ts +10 -0
- package/lib/src/pages.js +54 -0
- package/lib/src/paths.d.ts +39 -0
- package/lib/src/paths.js +101 -0
- package/lib/src/planner.d.ts +297 -0
- package/lib/src/planner.js +410 -0
- package/lib/src/plugins/cache.d.ts +70 -0
- package/lib/src/plugins/cache.js +226 -0
- package/lib/src/plugins/exec.d.ts +51 -0
- package/lib/src/plugins/exec.js +144 -0
- package/lib/src/plugins/host.d.ts +75 -0
- package/lib/src/plugins/host.js +277 -0
- package/lib/src/plugins/wasm.d.ts +23 -0
- package/lib/src/plugins/wasm.js +249 -0
- package/lib/src/profile.d.ts +42 -0
- package/lib/src/profile.js +68 -0
- package/lib/src/ref.d.ts +40 -0
- package/lib/src/ref.js +79 -0
- package/lib/src/registry-auth.d.ts +32 -0
- package/lib/src/registry-auth.js +151 -0
- package/lib/src/registry-cache.d.ts +71 -0
- package/lib/src/registry-cache.js +333 -0
- package/lib/src/registry-ops.d.ts +51 -0
- package/lib/src/registry-ops.js +248 -0
- package/lib/src/registry.d.ts +109 -0
- package/lib/src/registry.js +505 -0
- package/lib/src/render.d.ts +134 -0
- package/lib/src/render.js +576 -0
- package/lib/src/resources/archive.d.ts +11 -0
- package/lib/src/resources/archive.js +306 -0
- package/lib/src/resources/bundle-data.d.ts +16 -0
- package/lib/src/resources/bundle-data.js +50 -0
- package/lib/src/resources/convert.d.ts +12 -0
- package/lib/src/resources/convert.js +63 -0
- package/lib/src/resources/documents.d.ts +35 -0
- package/lib/src/resources/documents.js +122 -0
- package/lib/src/resources/git.d.ts +103 -0
- package/lib/src/resources/git.js +720 -0
- package/lib/src/resources/https.d.ts +40 -0
- package/lib/src/resources/https.js +256 -0
- package/lib/src/resources/ignore.d.ts +33 -0
- package/lib/src/resources/ignore.js +119 -0
- package/lib/src/resources/local.d.ts +64 -0
- package/lib/src/resources/local.js +248 -0
- package/lib/src/resources/lockfile.d.ts +52 -0
- package/lib/src/resources/lockfile.js +172 -0
- package/lib/src/resources/netpolicy.d.ts +6 -0
- package/lib/src/resources/netpolicy.js +82 -0
- package/lib/src/resources/types.d.ts +63 -0
- package/lib/src/resources/types.js +13 -0
- package/lib/src/resources/vendor.d.ts +94 -0
- package/lib/src/resources/vendor.js +296 -0
- package/lib/src/resources/wbn-reader.d.ts +44 -0
- package/lib/src/resources/wbn-reader.js +302 -0
- package/lib/src/schema.d.ts +25 -0
- package/lib/src/schema.js +169 -0
- package/lib/src/secrets.d.ts +21 -0
- package/lib/src/secrets.js +103 -0
- package/lib/src/serve-rules.d.ts +29 -0
- package/lib/src/serve-rules.js +122 -0
- package/lib/src/server.d.ts +45 -0
- package/lib/src/server.js +246 -0
- package/lib/src/servers.d.ts +71 -0
- package/lib/src/servers.js +139 -0
- package/lib/src/site.d.ts +60 -0
- package/lib/src/site.js +169 -0
- package/lib/src/store.d.ts +151 -0
- package/lib/src/store.js +596 -0
- package/lib/src/tree.d.ts +50 -0
- package/lib/src/tree.js +134 -0
- package/lib/src/values.d.ts +7 -0
- package/lib/src/values.js +37 -0
- package/lib/src/vendor.d.ts +19 -0
- package/lib/src/vendor.js +145 -0
- package/lib/src/watch.d.ts +44 -0
- package/lib/src/watch.js +137 -0
- package/lib/src/wbn.d.ts +5 -0
- package/lib/src/wbn.js +147 -0
- package/lib/src/yaml.d.ts +34 -0
- package/lib/src/yaml.js +562 -0
- package/package.json +58 -4
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { sha256 } from '../digest.js';
|
|
5
|
+
/**
|
|
6
|
+
* Content-addressed cache of TRANSFORM results (see docs/PLUGINS.md, "Caching").
|
|
7
|
+
*
|
|
8
|
+
* <dir>/entries/<key>.json manifest: which blobs make up the result
|
|
9
|
+
* <dir>/blobs/<hex> result bytes, named by their SHA-256
|
|
10
|
+
*
|
|
11
|
+
* The key is the SHA-256 of a canonical JSON document describing everything the
|
|
12
|
+
* result can depend on. Reads verify every blob's digest and size; a damaged
|
|
13
|
+
* entry is removed and counts as a miss, so the next put rewrites it.
|
|
14
|
+
*/
|
|
15
|
+
/** Bump to invalidate every existing entry when the key or entry format changes. */
|
|
16
|
+
export const CACHE_VERSION = 1;
|
|
17
|
+
/** JSON with sorted object keys, so equal values always serialize equally. */
|
|
18
|
+
export function canonicalJson(value) {
|
|
19
|
+
const norm = (v) => {
|
|
20
|
+
if (v === null || typeof v !== 'object')
|
|
21
|
+
return typeof v === 'bigint' ? v.toString() : v;
|
|
22
|
+
if (v instanceof Date)
|
|
23
|
+
return v.toISOString();
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(norm);
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const k of Object.keys(v).sort()) {
|
|
28
|
+
const x = v[k];
|
|
29
|
+
if (x !== undefined)
|
|
30
|
+
out[k] = norm(x);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
return JSON.stringify(norm(value)) ?? 'null';
|
|
35
|
+
}
|
|
36
|
+
export function cacheKey(parts) {
|
|
37
|
+
return createHash('sha256').update(canonicalJson({ cacheVersion: CACHE_VERSION, ...parts })).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
40
|
+
export class TransformCache {
|
|
41
|
+
dir;
|
|
42
|
+
remote;
|
|
43
|
+
stats = { hits: 0, misses: 0, writes: 0, corrupt: 0 };
|
|
44
|
+
constructor(dir, remote) {
|
|
45
|
+
this.dir = dir;
|
|
46
|
+
this.remote = remote;
|
|
47
|
+
}
|
|
48
|
+
entryPath(key) {
|
|
49
|
+
return path.join(this.dir, 'entries', `${key}.json`);
|
|
50
|
+
}
|
|
51
|
+
blobPath(hex) {
|
|
52
|
+
return path.join(this.dir, 'blobs', hex);
|
|
53
|
+
}
|
|
54
|
+
async get(key) {
|
|
55
|
+
const file = this.entryPath(key);
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(file, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
const fetched = await this.remote?.fetch(key, (hex) => this.blobPath(hex));
|
|
62
|
+
if (!fetched) {
|
|
63
|
+
this.stats.misses++;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const m = { version: CACHE_VERSION, kind: fetched.kind, files: fetched.files };
|
|
67
|
+
raw = JSON.stringify(m);
|
|
68
|
+
await this.writeAtomic(file, raw, false).catch(() => { });
|
|
69
|
+
}
|
|
70
|
+
const loaded = await this.load(raw);
|
|
71
|
+
if (!loaded) {
|
|
72
|
+
this.stats.corrupt++;
|
|
73
|
+
this.stats.misses++;
|
|
74
|
+
await rm(file, { force: true });
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
this.stats.hits++;
|
|
78
|
+
const now = new Date();
|
|
79
|
+
await utimes(file, now, now).catch(() => { });
|
|
80
|
+
this.remote?.used(key, loaded.entry);
|
|
81
|
+
return loaded.result;
|
|
82
|
+
}
|
|
83
|
+
async load(raw) {
|
|
84
|
+
let m;
|
|
85
|
+
try {
|
|
86
|
+
m = JSON.parse(raw);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (m?.version !== CACHE_VERSION || (m.kind !== 'file' && m.kind !== 'files') || !Array.isArray(m.files))
|
|
92
|
+
return undefined;
|
|
93
|
+
if (m.kind === 'file' && m.files.length !== 1)
|
|
94
|
+
return undefined;
|
|
95
|
+
const loaded = [];
|
|
96
|
+
for (const f of m.files) {
|
|
97
|
+
if (!Array.isArray(f) || typeof f[0] !== 'string' || typeof f[1] !== 'string' || !HEX64.test(f[1]) || typeof f[2] !== 'number')
|
|
98
|
+
return undefined;
|
|
99
|
+
let bytes;
|
|
100
|
+
try {
|
|
101
|
+
bytes = await readFile(this.blobPath(f[1]));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (bytes.byteLength !== f[2] || sha256(bytes) !== `sha256:${f[1]}`) {
|
|
107
|
+
await rm(this.blobPath(f[1]), { force: true });
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
loaded.push([f[0], new Uint8Array(bytes)]);
|
|
111
|
+
}
|
|
112
|
+
const result = m.kind === 'file' ? { kind: 'file', bytes: loaded[0][1] } : { kind: 'files', files: loaded };
|
|
113
|
+
return { result, entry: { kind: m.kind, files: m.files.map((f) => [f[0], f[1], f[2]]) } };
|
|
114
|
+
}
|
|
115
|
+
async put(key, result) {
|
|
116
|
+
try {
|
|
117
|
+
const pairs = result.kind === 'file' ? [['', result.bytes]] : result.files;
|
|
118
|
+
const files = [];
|
|
119
|
+
for (const [p, bytes] of pairs) {
|
|
120
|
+
const hex = sha256(bytes).slice('sha256:'.length);
|
|
121
|
+
await this.writeAtomic(this.blobPath(hex), bytes, true);
|
|
122
|
+
files.push([p, hex, bytes.byteLength]);
|
|
123
|
+
}
|
|
124
|
+
const manifest = { version: CACHE_VERSION, kind: result.kind, files };
|
|
125
|
+
await this.writeAtomic(this.entryPath(key), JSON.stringify(manifest), false);
|
|
126
|
+
this.stats.writes++;
|
|
127
|
+
this.remote?.used(key, { kind: result.kind, files });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// A cache that cannot be written (read-only home, full disk) must not fail the build.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async writeAtomic(file, data, keepIfSameSize) {
|
|
134
|
+
if (keepIfSameSize) {
|
|
135
|
+
const size = typeof data === 'string' ? Buffer.byteLength(data) : data.byteLength;
|
|
136
|
+
const st = await stat(file).catch(() => undefined);
|
|
137
|
+
if (st?.isFile() && st.size === size) {
|
|
138
|
+
// Reused: refresh its mtime so the size limit's grace period covers it until the entry lands.
|
|
139
|
+
const now = new Date();
|
|
140
|
+
await utimes(file, now, now).catch(() => { });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
145
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
146
|
+
await writeFile(tmp, data);
|
|
147
|
+
await rename(tmp, file);
|
|
148
|
+
}
|
|
149
|
+
async info() {
|
|
150
|
+
const entries = (await readdir(path.join(this.dir, 'entries')).catch(() => [])).filter((n) => n.endsWith('.json')).length;
|
|
151
|
+
let blobs = 0;
|
|
152
|
+
let bytes = 0;
|
|
153
|
+
for (const n of await readdir(path.join(this.dir, 'blobs')).catch(() => [])) {
|
|
154
|
+
const st = await stat(path.join(this.dir, 'blobs', n)).catch(() => undefined);
|
|
155
|
+
if (st?.isFile()) {
|
|
156
|
+
blobs++;
|
|
157
|
+
bytes += st.size;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { dir: this.dir, entries, blobs, bytes };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Remove entries not used for `olderThanMs` (0 removes all), then every blob
|
|
164
|
+
* no remaining entry references. Returns what was removed.
|
|
165
|
+
*/
|
|
166
|
+
async prune(olderThanMs = 0, now = Date.now()) {
|
|
167
|
+
let removedEntries = 0;
|
|
168
|
+
const live = new Set();
|
|
169
|
+
for (const n of await readdir(path.join(this.dir, 'entries')).catch(() => [])) {
|
|
170
|
+
const file = path.join(this.dir, 'entries', n);
|
|
171
|
+
const st = await stat(file).catch(() => undefined);
|
|
172
|
+
if (!st)
|
|
173
|
+
continue;
|
|
174
|
+
if (olderThanMs === 0 || now - st.mtimeMs >= olderThanMs || !n.endsWith('.json')) {
|
|
175
|
+
await rm(file, { force: true });
|
|
176
|
+
removedEntries++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
for (const f of JSON.parse(await readFile(file, 'utf8')).files)
|
|
181
|
+
live.add(f[1]);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
await rm(file, { force: true });
|
|
185
|
+
removedEntries++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let removedBlobs = 0;
|
|
189
|
+
let bytes = 0;
|
|
190
|
+
for (const n of await readdir(path.join(this.dir, 'blobs')).catch(() => [])) {
|
|
191
|
+
if (live.has(n))
|
|
192
|
+
continue;
|
|
193
|
+
const file = path.join(this.dir, 'blobs', n);
|
|
194
|
+
const st = await stat(file).catch(() => undefined);
|
|
195
|
+
await rm(file, { force: true });
|
|
196
|
+
removedBlobs++;
|
|
197
|
+
bytes += st?.size ?? 0;
|
|
198
|
+
}
|
|
199
|
+
return { entries: removedEntries, blobs: removedBlobs, bytes };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Digest of every regular file under `dir` (a plugin can read all of them, so any may change its output). */
|
|
203
|
+
export async function digestFolder(dir, limits = { files: 2000, bytes: 128 * 1024 * 1024 }) {
|
|
204
|
+
const h = createHash('sha256');
|
|
205
|
+
let files = 0;
|
|
206
|
+
let bytes = 0;
|
|
207
|
+
const walk = async (rel) => {
|
|
208
|
+
const entries = (await readdir(path.join(dir, rel), { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
209
|
+
for (const e of entries) {
|
|
210
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
211
|
+
if (e.isDirectory()) {
|
|
212
|
+
if (!(await walk(r)))
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
else if (e.isFile()) {
|
|
216
|
+
const data = await readFile(path.join(dir, r));
|
|
217
|
+
if (++files > limits.files || (bytes += data.byteLength) > limits.bytes)
|
|
218
|
+
return false;
|
|
219
|
+
h.update(`${r}\0${sha256(data)}\n`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
return (await walk('')) ? h.digest('hex') : undefined;
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=cache.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type SourceSpan } from '../errors.js';
|
|
2
|
+
import type { PluginResult } from './host.js';
|
|
3
|
+
/**
|
|
4
|
+
* The exec adapter: runs an external program that the user allow-listed with
|
|
5
|
+
* `--allow-exec`. There is no shell: the program is started with an argv array
|
|
6
|
+
* (`shell: false`), in a fresh temporary directory, with an almost empty
|
|
7
|
+
* environment and a closed stdin. Node's permission model cannot restrict a
|
|
8
|
+
* native binary, so the tool runs with the user's privileges: the allow-list is
|
|
9
|
+
* the control. See docs/PLUGINS.md.
|
|
10
|
+
*/
|
|
11
|
+
export interface ToolPolicy {
|
|
12
|
+
/** Per-call time limit (default 60 s). */
|
|
13
|
+
readonly timeoutMs: number;
|
|
14
|
+
/** Largest output file accepted (default 64 MiB). */
|
|
15
|
+
readonly maxOutputBytes: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_TOOL_POLICY: ToolPolicy;
|
|
18
|
+
/** An allow-listed program, resolved to an absolute real path. */
|
|
19
|
+
export interface ResolvedTool {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly size: number;
|
|
23
|
+
readonly mtimeMs: number;
|
|
24
|
+
}
|
|
25
|
+
/** The environment given to every tool: nothing but a fixed PATH (tools are started by absolute path). */
|
|
26
|
+
export declare const TOOL_ENV: Readonly<Record<string, string>>;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve `--allow-exec name[=path]`. A bare name is looked up on PATH; an explicit
|
|
29
|
+
* path must be absolute. The result is the real path (symlinks followed), so what
|
|
30
|
+
* is printed, keyed and run is one file.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveTool(name: string, explicit: string | undefined, searchPath: string | undefined, span?: SourceSpan): Promise<ResolvedTool>;
|
|
33
|
+
/** What the runner needs from one TRANSFORM call. */
|
|
34
|
+
export interface ToolCall {
|
|
35
|
+
/** argv template with ${…} already interpolated; `{input}`, `{output}` and `{option.key}` are replaced here. */
|
|
36
|
+
readonly args: readonly string[];
|
|
37
|
+
readonly dest: string;
|
|
38
|
+
readonly options: Readonly<Record<string, string>>;
|
|
39
|
+
readonly input?: {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly bytes: Uint8Array;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The argv after placeholder substitution. `{input}` and `{output}` become
|
|
46
|
+
* *relative* names inside the call's temp dir (the tool's cwd), so filter syntaxes
|
|
47
|
+
* with `:` or `,` need no escaping. Values are inserted verbatim into one argv entry;
|
|
48
|
+
* they are never re-scanned, and never split.
|
|
49
|
+
*/
|
|
50
|
+
export declare function substituteArgs(args: readonly string[], inputFile: string | undefined, outputFile: string, options: Readonly<Record<string, string>>): string[];
|
|
51
|
+
export declare function runTool(tool: ResolvedTool, call: ToolCall, policy: ToolPolicy, log: (line: string) => void, span?: SourceSpan): Promise<PluginResult>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access, lstat, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { SitefileError } from '../errors.js';
|
|
7
|
+
import { TOOL_PLACEHOLDER_RE } from '../language/validate.js';
|
|
8
|
+
export const DEFAULT_TOOL_POLICY = { timeoutMs: 60_000, maxOutputBytes: 64 * 1024 * 1024 };
|
|
9
|
+
/** The environment given to every tool: nothing but a fixed PATH (tools are started by absolute path). */
|
|
10
|
+
export const TOOL_ENV = { PATH: '/usr/bin:/bin' };
|
|
11
|
+
const FLAG_HINT = (name) => `--allow-exec ${name}`;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve `--allow-exec name[=path]`. A bare name is looked up on PATH; an explicit
|
|
14
|
+
* path must be absolute. The result is the real path (symlinks followed), so what
|
|
15
|
+
* is printed, keyed and run is one file.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveTool(name, explicit, searchPath, span) {
|
|
18
|
+
const candidates = [];
|
|
19
|
+
if (explicit !== undefined) {
|
|
20
|
+
if (!path.isAbsolute(explicit)) {
|
|
21
|
+
throw new SitefileError('E_USAGE', `--allow-exec ${name}=${explicit}: the path must be absolute`, span);
|
|
22
|
+
}
|
|
23
|
+
candidates.push(explicit);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
for (const dir of (searchPath ?? '').split(path.delimiter))
|
|
27
|
+
if (dir && path.isAbsolute(dir))
|
|
28
|
+
candidates.push(path.join(dir, name));
|
|
29
|
+
}
|
|
30
|
+
for (const c of candidates) {
|
|
31
|
+
try {
|
|
32
|
+
const real = await realpath(c);
|
|
33
|
+
const st = await stat(real);
|
|
34
|
+
if (!st.isFile())
|
|
35
|
+
continue;
|
|
36
|
+
await access(real, constants.X_OK);
|
|
37
|
+
return { name, path: real, size: st.size, mtimeMs: st.mtimeMs };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// try the next PATH entry
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new SitefileError('E_NOT_FOUND', explicit !== undefined ? `TOOL ${name}: ${explicit} is not an executable file` : `TOOL ${name}: no executable '${name}' found on PATH (or pass ${FLAG_HINT(name)}=/absolute/path)`, span);
|
|
44
|
+
}
|
|
45
|
+
const MAX_LOG_LINES = 20;
|
|
46
|
+
const MAX_LINE = 300;
|
|
47
|
+
/** A file extension safe to put in a temp file name (tools such as ffmpeg pick formats from it). */
|
|
48
|
+
function safeExt(name) {
|
|
49
|
+
const ext = path.extname(name);
|
|
50
|
+
return /^\.[A-Za-z0-9]{1,12}$/.test(ext) ? ext : '';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The argv after placeholder substitution. `{input}` and `{output}` become
|
|
54
|
+
* *relative* names inside the call's temp dir (the tool's cwd), so filter syntaxes
|
|
55
|
+
* with `:` or `,` need no escaping. Values are inserted verbatim into one argv entry;
|
|
56
|
+
* they are never re-scanned, and never split.
|
|
57
|
+
*/
|
|
58
|
+
export function substituteArgs(args, inputFile, outputFile, options) {
|
|
59
|
+
return args.map((a) => a.replace(TOOL_PLACEHOLDER_RE, (_m, name) => {
|
|
60
|
+
if (name === 'input')
|
|
61
|
+
return inputFile ?? '';
|
|
62
|
+
if (name === 'output')
|
|
63
|
+
return outputFile;
|
|
64
|
+
return options[name.slice('option.'.length)] ?? '';
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
export async function runTool(tool, call, policy, log, span) {
|
|
68
|
+
const label = `TOOL ${tool.name}`;
|
|
69
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'sitefile-tool-'));
|
|
70
|
+
try {
|
|
71
|
+
let inputFile;
|
|
72
|
+
if (call.input) {
|
|
73
|
+
inputFile = `input${safeExt(call.input.name)}`;
|
|
74
|
+
await writeFile(path.join(dir, inputFile), call.input.bytes, { mode: 0o600 });
|
|
75
|
+
}
|
|
76
|
+
const outputFile = `output${safeExt(call.dest)}`;
|
|
77
|
+
const argv = substituteArgs(call.args, inputFile, outputFile, call.options);
|
|
78
|
+
const { code, signal, stderr, stdout, timedOut, oversized, spawnError } = await new Promise((resolve) => {
|
|
79
|
+
const child = spawn(tool.path, argv, { cwd: dir, env: { ...TOOL_ENV }, shell: false, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
80
|
+
let stdout = '';
|
|
81
|
+
let stderr = '';
|
|
82
|
+
let timedOut = false;
|
|
83
|
+
let oversized = false;
|
|
84
|
+
let spawnError;
|
|
85
|
+
// Keep only the tail of each stream; a chatty tool must not exhaust memory.
|
|
86
|
+
const keep = (cur, chunk) => (cur + chunk).slice(-16 * 1024);
|
|
87
|
+
child.stdout.setEncoding('utf8').on('data', (c) => (stdout = keep(stdout, c)));
|
|
88
|
+
child.stderr.setEncoding('utf8').on('data', (c) => (stderr = keep(stderr, c)));
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
timedOut = true;
|
|
91
|
+
child.kill('SIGKILL');
|
|
92
|
+
}, policy.timeoutMs);
|
|
93
|
+
// Stop a tool that is filling the disk with an oversized output.
|
|
94
|
+
const watch = setInterval(() => {
|
|
95
|
+
stat(path.join(dir, outputFile)).then((st) => {
|
|
96
|
+
if (st.size > policy.maxOutputBytes && !oversized) {
|
|
97
|
+
oversized = true;
|
|
98
|
+
child.kill('SIGKILL');
|
|
99
|
+
}
|
|
100
|
+
}, () => { });
|
|
101
|
+
}, 100);
|
|
102
|
+
child.on('error', (e) => {
|
|
103
|
+
spawnError = e.message;
|
|
104
|
+
});
|
|
105
|
+
child.on('close', (code, signal) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
clearInterval(watch);
|
|
108
|
+
resolve({ code, signal, stderr, stdout, timedOut, oversized, spawnError });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const tail = (s) => s.split('\n').map((l) => l.trimEnd()).filter(Boolean).slice(-MAX_LOG_LINES).map((l) => (l.length > MAX_LINE ? `${l.slice(0, MAX_LINE)}…` : l));
|
|
112
|
+
const fail = (why) => {
|
|
113
|
+
const lines = tail(stderr || stdout).slice(-8);
|
|
114
|
+
throw new SitefileError('E_PLUGIN', `${label}: ${why}${lines.length ? `\n${lines.join('\n')}` : ''}`, span);
|
|
115
|
+
};
|
|
116
|
+
if (spawnError)
|
|
117
|
+
fail(`could not start ${tool.path}: ${spawnError}`);
|
|
118
|
+
if (timedOut)
|
|
119
|
+
fail(`timed out after ${policy.timeoutMs} ms`);
|
|
120
|
+
if (oversized)
|
|
121
|
+
fail(`output is larger than ${policy.maxOutputBytes} bytes`);
|
|
122
|
+
if (signal)
|
|
123
|
+
fail(`stopped by ${signal}`);
|
|
124
|
+
if (code !== 0)
|
|
125
|
+
fail(`exited with code ${code}`);
|
|
126
|
+
for (const line of tail(stdout))
|
|
127
|
+
log(`[tool ${tool.name}] ${line}`);
|
|
128
|
+
for (const line of tail(stderr))
|
|
129
|
+
log(`[tool ${tool.name}] ${line}`);
|
|
130
|
+
const out = path.join(dir, outputFile);
|
|
131
|
+
const st = await lstat(out).catch(() => undefined);
|
|
132
|
+
if (!st)
|
|
133
|
+
fail(`did not write {output} (${outputFile})`);
|
|
134
|
+
if (!st.isFile())
|
|
135
|
+
fail(`{output} is not a regular file`);
|
|
136
|
+
if (st.size > policy.maxOutputBytes)
|
|
137
|
+
fail(`output is larger than ${policy.maxOutputBytes} bytes`);
|
|
138
|
+
return { kind: 'file', bytes: new Uint8Array(await readFile(out)) };
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await rm(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=exec.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type SourceSpan } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Plugins run one Node process each, under Node's permission model:
|
|
4
|
+
*
|
|
5
|
+
* node --permission --allow-fs-read=<plugin folder> --input-type=module -e <runner>
|
|
6
|
+
*
|
|
7
|
+
* so a plugin can read files in its own folder and nothing else: no writes,
|
|
8
|
+
* child processes, workers, native addons, WASI or network, and an empty
|
|
9
|
+
* environment (no secrets). Requests and results travel over stdin/stdout.
|
|
10
|
+
*
|
|
11
|
+
* Node documents the permission model as a "seat belt" for trusted code, not a
|
|
12
|
+
* defence against deliberately malicious code: plugins are code you choose to
|
|
13
|
+
* run. Pin them with INTEGRITY.
|
|
14
|
+
*/
|
|
15
|
+
export interface PluginPolicy {
|
|
16
|
+
/** Per-call time limit (default 60 s); the process is killed when it passes. */
|
|
17
|
+
readonly timeoutMs: number;
|
|
18
|
+
/** Largest result accepted from one call (default 64 MiB). */
|
|
19
|
+
readonly maxOutputBytes: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const DEFAULT_PLUGIN_POLICY: PluginPolicy;
|
|
22
|
+
/** A directory TRANSFORM runs at most this many wasm or tool calls at a time (a JS plugin, one). */
|
|
23
|
+
export declare const TRANSFORM_JOBS: number;
|
|
24
|
+
/** What a plugin receives (see docs/PLUGINS.md). */
|
|
25
|
+
export interface PluginRequest {
|
|
26
|
+
readonly apiVersion: 1;
|
|
27
|
+
readonly dest: string;
|
|
28
|
+
readonly options: Readonly<Record<string, string>>;
|
|
29
|
+
readonly input?: {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly bytes: Uint8Array;
|
|
33
|
+
readonly text?: string;
|
|
34
|
+
};
|
|
35
|
+
readonly data?: unknown;
|
|
36
|
+
readonly item?: unknown;
|
|
37
|
+
}
|
|
38
|
+
/** A plugin's result, normalized: one file, or several under the destination. */
|
|
39
|
+
export type PluginResult = {
|
|
40
|
+
readonly kind: 'file';
|
|
41
|
+
readonly bytes: Uint8Array;
|
|
42
|
+
} | {
|
|
43
|
+
readonly kind: 'files';
|
|
44
|
+
readonly files: ReadonlyArray<readonly [string, Uint8Array]>;
|
|
45
|
+
};
|
|
46
|
+
/** Counters a build reports (see BuildResult.transformStats). */
|
|
47
|
+
export interface HostStats {
|
|
48
|
+
/** Plugin processes started. */
|
|
49
|
+
spawned: number;
|
|
50
|
+
/** transform() calls sent to a plugin process. */
|
|
51
|
+
calls: number;
|
|
52
|
+
}
|
|
53
|
+
export declare class PluginHost {
|
|
54
|
+
private readonly policy;
|
|
55
|
+
private readonly log;
|
|
56
|
+
private readonly registered;
|
|
57
|
+
private readonly procs;
|
|
58
|
+
readonly stats: HostStats;
|
|
59
|
+
constructor(policy?: PluginPolicy, log?: (line: string) => void);
|
|
60
|
+
/**
|
|
61
|
+
* `file` is the plugin's absolute real path (already sandbox- and integrity-checked).
|
|
62
|
+
* For a `.wasm` plugin, pass the checked module bytes. Runners start lazily.
|
|
63
|
+
*/
|
|
64
|
+
register(name: string, file: string, span?: SourceSpan, wasm?: Uint8Array): void;
|
|
65
|
+
/** Whether a registered plugin is a WebAssembly module. */
|
|
66
|
+
isWasm(name: string): boolean;
|
|
67
|
+
/** A registered wasm plugin's module bytes. */
|
|
68
|
+
wasmBytes(name: string): Uint8Array | undefined;
|
|
69
|
+
call(name: string, request: PluginRequest, span?: SourceSpan): Promise<PluginResult>;
|
|
70
|
+
/** The folder a registered plugin lives in. */
|
|
71
|
+
folderOf(name: string): string | undefined;
|
|
72
|
+
/** Whether results of this plugin may be cached (false after it exported `cache = false`). */
|
|
73
|
+
cacheable(name: string): boolean;
|
|
74
|
+
close(): void;
|
|
75
|
+
}
|