@pieceful/ravel-host-node 0.1.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/LICENSE +9 -0
- package/package.json +25 -0
- package/src/.gitkeep +1 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +883 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Taylor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pieceful/ravel-host-node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Node host capabilities for Ravel.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "James Taylor",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/jostylr/ravel.git", "directory": "packages/host-node" },
|
|
9
|
+
"bugs": { "url": "https://github.com/jostylr/ravel/issues" },
|
|
10
|
+
"homepage": "https://github.com/jostylr/ravel#readme",
|
|
11
|
+
"engines": { "node": ">=22" },
|
|
12
|
+
"keywords": ["ravel", "literate-programming", "node", "build"],
|
|
13
|
+
"publishConfig": { "access": "public" },
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"types": "./src/index.d.ts",
|
|
18
|
+
"files": ["src", "LICENSE"],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@pieceful/ravel-core": "0.1.0",
|
|
21
|
+
"@pieceful/ravel-markdown": "0.1.0",
|
|
22
|
+
"@pieceful/ravel-map": "0.1.0",
|
|
23
|
+
"smol-toml": "^1.7.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface SourcePosition { line: number; column: number; offset: number; }
|
|
2
|
+
export interface SourceRange { start: SourcePosition; end: SourcePosition; }
|
|
3
|
+
export interface SourceLocation { uri: string; range: SourceRange; }
|
|
4
|
+
export interface Diagnostic { code: string; severity: "error" | "warning" | "info"; message: string; source: SourceLocation; }
|
|
5
|
+
export interface RavelProgram { version?: number; deliverables: Record<string, { name: string; from: string; value: string; segments?: unknown[] }>; }
|
|
6
|
+
export interface BuildInput { pretransform: unknown; outputDirectory?: string; rootDirectory: string; buildOptions?: { clean: boolean; backup: boolean | string }; }
|
|
7
|
+
export class RavelInputError extends Error { diagnostics: Diagnostic[]; }
|
|
8
|
+
export function loadPretransformGraph(entryPath: string, options?: { document?: string; mode?: "opt-in" | "primary" }): Promise<unknown>;
|
|
9
|
+
export function loadTomlBuild(configPath: string): Promise<BuildInput>;
|
|
10
|
+
export function loadBuildInput(inputPath: string, options?: { document?: string; mode?: "opt-in" | "primary" }): Promise<BuildInput>;
|
|
11
|
+
export function planDeliverables(program: RavelProgram, outputDirectory: string): { version: 1; outputDirectory: string; manifest: string; deliverables: Array<Record<string, unknown>> };
|
|
12
|
+
export function planStaleDeliverables(program: RavelProgram, outputDirectory: string, options?: { rootDirectory?: string; staleSince?: string }): Promise<Array<Record<string, unknown>>>;
|
|
13
|
+
export function writeDeliverables(program: RavelProgram, outputDirectory: string, options?: { rootDirectory?: string }): Promise<string[]>;
|
|
14
|
+
export function createBuildManifest(program: RavelProgram, outputDirectory: string, options?: { stale?: unknown[]; builtAt?: string }): Record<string, unknown>;
|
|
15
|
+
export function writeBuildManifest(program: RavelProgram, outputDirectory: string, options?: { rootDirectory?: string; generatedAt?: string }): Promise<Record<string, unknown>>;
|
|
16
|
+
export function writeBuildArtifacts(program: RavelProgram, outputDirectory: string, options?: { rootDirectory?: string; stale?: unknown[]; generatedAt?: string }): Promise<Record<string, unknown>>;
|
|
17
|
+
export function cleanManagedArtifacts(outputDirectory: string, options?: { rootDirectory?: string; dryRun?: boolean }): Promise<Record<string, unknown>>;
|
|
18
|
+
export function refreshStaleArtifacts(outputDirectory: string, options?: { rootDirectory?: string; dryRun?: boolean; generatedAt?: string }): Promise<Record<string, unknown>>;
|
|
19
|
+
export function planOutputBackup(outputDirectory: string, options?: { outputRootDirectory?: string; backupRootDirectory?: string; backupPath?: string }): Promise<Record<string, unknown>>;
|
|
20
|
+
export function createOutputBackup(outputDirectory: string, options?: { outputRootDirectory?: string; backupRootDirectory?: string; backupPath?: string }): Promise<Record<string, unknown>>;
|
|
21
|
+
export function writeGraph(program: unknown, path: string, options?: { rootDirectory?: string }): Promise<void>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import {
|
|
6
|
+
combineMaps,
|
|
7
|
+
createBuildProvenanceMap,
|
|
8
|
+
createDeliverableProvenanceMap,
|
|
9
|
+
provenanceMapVersion
|
|
10
|
+
} from "@pieceful/ravel-core";
|
|
11
|
+
import { markdownToMap } from "@pieceful/ravel-markdown";
|
|
12
|
+
import { assertRavelMap } from "@pieceful/ravel-map";
|
|
13
|
+
|
|
14
|
+
const missing = (error) => error?.code === "ENOENT";
|
|
15
|
+
|
|
16
|
+
const inputSource = (uri) => ({
|
|
17
|
+
uri: typeof uri === "string" && uri.length ? uri : "<ravel-input>",
|
|
18
|
+
range: {
|
|
19
|
+
start: { line: 0, column: 0, offset: 0 },
|
|
20
|
+
end: { line: 0, column: 0, offset: 0 }
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Expected source/configuration failure with portable CLI/editor diagnostics. */
|
|
25
|
+
export class RavelInputError extends Error {
|
|
26
|
+
constructor(diagnostics) {
|
|
27
|
+
super(diagnostics.map((entry) => entry.message).join(" "));
|
|
28
|
+
this.name = "RavelInputError";
|
|
29
|
+
this.diagnostics = diagnostics;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const inputError = (code, message, uri) => new RavelInputError([{
|
|
34
|
+
code,
|
|
35
|
+
severity: "error",
|
|
36
|
+
message,
|
|
37
|
+
source: inputSource(uri)
|
|
38
|
+
}]);
|
|
39
|
+
|
|
40
|
+
const readInputText = async (path, description, code) => {
|
|
41
|
+
try {
|
|
42
|
+
return await readFile(path, "utf8");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw inputError(code, "Unable to read " + description + ": " + (error?.code ?? error?.message ?? String(error)), path);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const containedIn = (root, target) => {
|
|
49
|
+
const path = relative(root, target);
|
|
50
|
+
return path === "" || (!path.startsWith(".." + sep) && path !== ".." && !isAbsolute(path));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Keep persisted source locations portable and avoid exposing absolute paths. */
|
|
54
|
+
const relativeSourceUri = (root, uri) => {
|
|
55
|
+
if (typeof uri !== "string" || !isAbsolute(uri)) return uri;
|
|
56
|
+
const path = relative(root, uri);
|
|
57
|
+
if (containedIn(root, uri)) return path || ".";
|
|
58
|
+
return "<external>/" + basename(uri);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const normalizeSource = (source, root) => {
|
|
62
|
+
if (source && typeof source === "object") source.uri = relativeSourceUri(root, source.uri);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Rewrite source-bearing IR fields in place after filesystem loading is complete. */
|
|
66
|
+
const relativizeSourceUris = (graph, root) => {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const visit = (value) => {
|
|
69
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
70
|
+
seen.add(value);
|
|
71
|
+
if (value.source) normalizeSource(value.source, root);
|
|
72
|
+
for (const [key, child] of Object.entries(value)) {
|
|
73
|
+
if (key !== "source") visit(child);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
for (const document of graph.documents ?? []) {
|
|
77
|
+
document.uri = relativeSourceUri(root, document.uri);
|
|
78
|
+
}
|
|
79
|
+
visit(graph.chunks);
|
|
80
|
+
visit(graph.directives);
|
|
81
|
+
return graph;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Reject every symlink component at or beneath the declared Ravel root. */
|
|
85
|
+
const assertNoSymlinks = async (root, path, description) => {
|
|
86
|
+
if (!containedIn(root, path)) throw new Error(description + " escapes the Ravel root: " + path);
|
|
87
|
+
let current = root;
|
|
88
|
+
for (const part of relative(root, path).split(sep).filter(Boolean)) {
|
|
89
|
+
current = join(current, part);
|
|
90
|
+
let entry;
|
|
91
|
+
try {
|
|
92
|
+
entry = await lstat(current);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (missing(error)) return;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
if (entry.isSymbolicLink()) throw new Error(description + " must not traverse a symbolic link: " + current);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const assertDirectory = async (path, description) => {
|
|
102
|
+
const entry = await lstat(path);
|
|
103
|
+
if (entry.isSymbolicLink()) throw new Error(description + " must not be a symbolic link: " + path);
|
|
104
|
+
if (!entry.isDirectory()) throw new Error(description + " must be a directory: " + path);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Create a declared output root without accepting a symlink in its new path. */
|
|
108
|
+
const ensureDeclaredDirectory = async (path, description) => {
|
|
109
|
+
const missingParts = [];
|
|
110
|
+
let current = resolve(path);
|
|
111
|
+
while (true) {
|
|
112
|
+
try {
|
|
113
|
+
const entry = await lstat(current);
|
|
114
|
+
if (entry.isSymbolicLink()) throw new Error(description + " must not be a symbolic link: " + current);
|
|
115
|
+
if (!entry.isDirectory()) throw new Error(description + " must be a directory: " + current);
|
|
116
|
+
break;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!missing(error)) throw error;
|
|
119
|
+
const parent = dirname(current);
|
|
120
|
+
if (parent === current) throw error;
|
|
121
|
+
missingParts.unshift(basename(current));
|
|
122
|
+
current = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const part of missingParts) {
|
|
126
|
+
current = join(current, part);
|
|
127
|
+
await mkdir(current);
|
|
128
|
+
await assertDirectory(current, description);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const scopedPath = async (root, path, description) => {
|
|
133
|
+
const target = resolve(root, path);
|
|
134
|
+
if (!containedIn(root, target)) throw new Error(description + " escapes the Ravel root: " + path);
|
|
135
|
+
await assertNoSymlinks(root, target, description);
|
|
136
|
+
return target;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const createInputScope = async (rootDirectory) => {
|
|
140
|
+
const root = resolve(rootDirectory);
|
|
141
|
+
await assertDirectory(root, "Ravel root");
|
|
142
|
+
return { root, path: (path, description) => scopedPath(root, path, description) };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const ensureDirectoryTree = async (root, target, description) => {
|
|
146
|
+
if (!containedIn(root, target)) throw new Error(description + " escapes the Ravel root: " + target);
|
|
147
|
+
await assertDirectory(root, "Ravel root");
|
|
148
|
+
let current = root;
|
|
149
|
+
for (const part of relative(root, target).split(sep).filter(Boolean)) {
|
|
150
|
+
current = join(current, part);
|
|
151
|
+
let entry;
|
|
152
|
+
try {
|
|
153
|
+
entry = await lstat(current);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!missing(error)) throw error;
|
|
156
|
+
await mkdir(current);
|
|
157
|
+
entry = await lstat(current);
|
|
158
|
+
}
|
|
159
|
+
if (entry.isSymbolicLink()) throw new Error(description + " must not traverse a symbolic link: " + current);
|
|
160
|
+
if (!entry.isDirectory()) throw new Error(description + " requires a directory path: " + current);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const createOutputScope = async (outputDirectory, rootDirectory) => {
|
|
165
|
+
const root = resolve(rootDirectory);
|
|
166
|
+
const output = resolve(outputDirectory);
|
|
167
|
+
if (!containedIn(root, output)) throw new Error("Output directory escapes the Ravel root: " + outputDirectory);
|
|
168
|
+
await ensureDeclaredDirectory(root, "Ravel root");
|
|
169
|
+
await ensureDirectoryTree(root, output, "Output directory");
|
|
170
|
+
return { root: output, path: (path, description) => scopedPath(output, path, description) };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const readMap = async (path) => {
|
|
174
|
+
const text = await readInputText(path, "Ravel Map", "RM201");
|
|
175
|
+
let map;
|
|
176
|
+
try {
|
|
177
|
+
map = JSON.parse(text);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw inputError("RM201", "Invalid JSON Ravel Map: " + (error?.message ?? String(error)), path);
|
|
180
|
+
}
|
|
181
|
+
return assertRavelMap(map, { uri: path });
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const loadMarkdownFile = async (path, options = {}) => markdownToMap(
|
|
185
|
+
await readInputText(path, "Markdown input", "RM201"),
|
|
186
|
+
{ uri: path, document: options.document, mode: options.mode }
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const collectPretransformMaps = async (entryPath, entryOptions = {}, scope) => {
|
|
190
|
+
const activeScope = scope ?? await createInputScope(dirname(resolve(entryPath)));
|
|
191
|
+
const visited = new Set();
|
|
192
|
+
const maps = [];
|
|
193
|
+
const diagnostics = [];
|
|
194
|
+
|
|
195
|
+
const visit = async (path, options = {}) => {
|
|
196
|
+
const absolutePath = await activeScope.path(path, "Ravel input");
|
|
197
|
+
if (visited.has(absolutePath)) return;
|
|
198
|
+
visited.add(absolutePath);
|
|
199
|
+
|
|
200
|
+
const extension = extname(absolutePath).toLowerCase();
|
|
201
|
+
let map;
|
|
202
|
+
if (extension === ".json") {
|
|
203
|
+
map = await readMap(absolutePath);
|
|
204
|
+
} else if (extension === ".md" || extension === ".markdown" || extension === ".mdown") {
|
|
205
|
+
const result = await loadMarkdownFile(absolutePath, options);
|
|
206
|
+
map = result.map;
|
|
207
|
+
diagnostics.push(...result.diagnostics);
|
|
208
|
+
assertRavelMap(map, { uri: absolutePath });
|
|
209
|
+
} else {
|
|
210
|
+
throw inputError("RH101", "Ravel input must be a .json map or Markdown file.", absolutePath);
|
|
211
|
+
}
|
|
212
|
+
for (const directive of map.directives ?? []) {
|
|
213
|
+
if (directive.kind !== "in") continue;
|
|
214
|
+
const target = directive.target ?? directive.name;
|
|
215
|
+
if (typeof target !== "string" || !target) {
|
|
216
|
+
throw inputError("RH102", "in directive requires a target path.", directive.source?.uri ?? absolutePath);
|
|
217
|
+
}
|
|
218
|
+
await visit(resolve(dirname(absolutePath), target));
|
|
219
|
+
}
|
|
220
|
+
maps.push(map);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await visit(entryPath, entryOptions);
|
|
224
|
+
return { maps, diagnostics, rootDirectory: activeScope.root };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export const loadPretransformGraph = async (entryPath, options = {}) => {
|
|
228
|
+
const collected = await collectPretransformMaps(resolve(entryPath), options);
|
|
229
|
+
const graph = combineMaps(collected.maps);
|
|
230
|
+
graph.diagnostics.push(...collected.diagnostics);
|
|
231
|
+
return relativizeSourceUris(graph, collected.rootDirectory);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const configSource = inputSource;
|
|
235
|
+
|
|
236
|
+
const requireConfigString = (value, description, configPath) => {
|
|
237
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
238
|
+
throw inputError("RC102", description + " must be a non-empty string.", configPath);
|
|
239
|
+
}
|
|
240
|
+
return value;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const reportUnknownKeys = (value, allowed, description, configPath) => {
|
|
244
|
+
for (const key of Object.keys(value ?? {})) {
|
|
245
|
+
if (!allowed.has(key)) throw inputError("RC102", description + "." + key + " is not a supported configuration field.", configPath);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const loadTomlBuild = async (configPath) => {
|
|
250
|
+
const absoluteConfig = resolve(configPath);
|
|
251
|
+
const scope = await createInputScope(dirname(absoluteConfig));
|
|
252
|
+
const configFile = await scope.path(absoluteConfig, "Ravel TOML config");
|
|
253
|
+
let config;
|
|
254
|
+
try {
|
|
255
|
+
config = parseToml(await readInputText(configFile, "Ravel TOML config", "RC101"));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (Array.isArray(error?.diagnostics)) throw error;
|
|
258
|
+
throw inputError("RC101", "Invalid Ravel TOML config: " + (error?.message ?? String(error)), absoluteConfig);
|
|
259
|
+
}
|
|
260
|
+
reportUnknownKeys(config, new Set(["version", "files", "build", "outputs"]), "config", absoluteConfig);
|
|
261
|
+
if (config.version !== 1) throw inputError("RC102", "version must be 1.", absoluteConfig);
|
|
262
|
+
if (!Array.isArray(config.files) || config.files.length === 0) {
|
|
263
|
+
throw inputError("RC102", "files must contain one or more [[files]] entries.", absoluteConfig);
|
|
264
|
+
}
|
|
265
|
+
if (!config.build || typeof config.build !== "object") {
|
|
266
|
+
throw inputError("RC102", "build must be a [build] table.", absoluteConfig);
|
|
267
|
+
}
|
|
268
|
+
reportUnknownKeys(config.build, new Set(["name", "out_dir", "clean", "backup"]), "build", absoluteConfig);
|
|
269
|
+
if (config.build.name !== undefined) requireConfigString(config.build.name, "build.name", absoluteConfig);
|
|
270
|
+
if (config.build.clean !== undefined && typeof config.build.clean !== "boolean") {
|
|
271
|
+
throw inputError("RC102", "build.clean must be true or false.", absoluteConfig);
|
|
272
|
+
}
|
|
273
|
+
if (config.build.backup !== undefined && typeof config.build.backup !== "boolean" &&
|
|
274
|
+
(typeof config.build.backup !== "string" || config.build.backup.length === 0)) {
|
|
275
|
+
throw inputError("RC102", "build.backup must be true, false, or a non-empty .zip path.", absoluteConfig);
|
|
276
|
+
}
|
|
277
|
+
if (typeof config.build.backup === "string" && extname(config.build.backup).toLowerCase() !== ".zip") {
|
|
278
|
+
throw inputError("RC102", "build.backup must name a .zip file.", absoluteConfig);
|
|
279
|
+
}
|
|
280
|
+
const outDirectory = requireConfigString(config.build.out_dir, "build.out_dir", absoluteConfig);
|
|
281
|
+
const baseDirectory = scope.root;
|
|
282
|
+
const results = await Promise.all(config.files.map(async (file, index) => {
|
|
283
|
+
if (!file || typeof file !== "object") throw inputError("RC102", "files[" + index + "] must be a table.", absoluteConfig);
|
|
284
|
+
reportUnknownKeys(file, new Set(["path", "document", "mode"]), "files[" + index + "]", absoluteConfig);
|
|
285
|
+
const path = requireConfigString(file.path, "files[" + index + "].path", absoluteConfig);
|
|
286
|
+
if (file.document !== undefined) requireConfigString(file.document, "files[" + index + "].document", absoluteConfig);
|
|
287
|
+
const mode = file.mode ?? "opt-in";
|
|
288
|
+
if (!["opt-in", "primary"].includes(mode)) throw inputError("RC102", "files[" + index + "].mode must be opt-in or primary.", absoluteConfig);
|
|
289
|
+
return collectPretransformMaps(resolve(baseDirectory, path), { document: file.document, mode }, scope);
|
|
290
|
+
}));
|
|
291
|
+
const pretransform = combineMaps(results.flatMap((result) => result.maps));
|
|
292
|
+
pretransform.diagnostics.push(...results.flatMap((result) => result.diagnostics));
|
|
293
|
+
for (const output of config.outputs ?? []) {
|
|
294
|
+
if (!output || typeof output !== "object") throw inputError("RC102", "Each [[outputs]] entry must be a table.", absoluteConfig);
|
|
295
|
+
reportUnknownKeys(output, new Set(["name", "from"]), "outputs", absoluteConfig);
|
|
296
|
+
pretransform.directives.push({
|
|
297
|
+
kind: "out",
|
|
298
|
+
name: requireConfigString(output.name, "outputs.name", absoluteConfig),
|
|
299
|
+
from: requireConfigString(output.from, "outputs.from", absoluteConfig),
|
|
300
|
+
source: configSource(absoluteConfig)
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
pretransform: relativizeSourceUris(pretransform, scope.root),
|
|
305
|
+
outputDirectory: await scope.path(outDirectory, "build.out_dir"),
|
|
306
|
+
rootDirectory: scope.root,
|
|
307
|
+
buildOptions: {
|
|
308
|
+
clean: config.build.clean === true,
|
|
309
|
+
backup: config.build.backup ?? false
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** Load a JSON map, one Markdown document, or a single Ravel TOML build run. */
|
|
315
|
+
export const loadBuildInput = async (inputPath, options = {}) => {
|
|
316
|
+
const extension = extname(inputPath).toLowerCase();
|
|
317
|
+
if (extension === ".toml") return loadTomlBuild(inputPath);
|
|
318
|
+
if (extension === ".md" || extension === ".markdown" || extension === ".mdown") {
|
|
319
|
+
const rootDirectory = dirname(resolve(inputPath));
|
|
320
|
+
return {
|
|
321
|
+
pretransform: await loadPretransformGraph(resolve(inputPath), options),
|
|
322
|
+
outputDirectory: undefined,
|
|
323
|
+
rootDirectory
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (extension === ".json") {
|
|
327
|
+
return {
|
|
328
|
+
pretransform: await loadPretransformGraph(inputPath),
|
|
329
|
+
outputDirectory: undefined,
|
|
330
|
+
rootDirectory: dirname(resolve(inputPath))
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
throw inputError("RH101", "Ravel input must be a .json map, Markdown document, or .toml build config.", inputPath);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const safeDestination = (outputDirectory, name) => {
|
|
337
|
+
if (isAbsolute(name)) throw new Error("Deliverable name must be relative: " + name);
|
|
338
|
+
const root = resolve(outputDirectory);
|
|
339
|
+
const destination = resolve(root, name);
|
|
340
|
+
if (!containedIn(root, destination)) {
|
|
341
|
+
throw new Error("Deliverable escapes output directory: " + name);
|
|
342
|
+
}
|
|
343
|
+
return destination;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const portableRelative = (root, target) => relative(root, target).split(sep).join("/");
|
|
347
|
+
|
|
348
|
+
const contentHash = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
349
|
+
|
|
350
|
+
const orderedDeliverables = (program) => Object.values(program.deliverables ?? [])
|
|
351
|
+
.slice()
|
|
352
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
353
|
+
|
|
354
|
+
const manifestName = ".ravel-manifest.json";
|
|
355
|
+
const textManifestName = ".manifest.txt";
|
|
356
|
+
const aggregateProvenanceName = ".ravelmap";
|
|
357
|
+
const sidecarName = (name) => name + ".ravelmap";
|
|
358
|
+
|
|
359
|
+
/** Build a deterministic, non-writing description of artifact output. */
|
|
360
|
+
export const planDeliverables = (program, outputDirectory) => {
|
|
361
|
+
const root = resolve(outputDirectory);
|
|
362
|
+
const destinations = new Set();
|
|
363
|
+
const deliverables = orderedDeliverables(program).map((deliverable) => {
|
|
364
|
+
const destination = safeDestination(root, deliverable.name);
|
|
365
|
+
if (destinations.has(destination)) throw new Error("Multiple deliverables resolve to the same destination: " + deliverable.name);
|
|
366
|
+
destinations.add(destination);
|
|
367
|
+
const hasProvenance = Array.isArray(deliverable.segments);
|
|
368
|
+
const ravelmap = hasProvenance ? sidecarName(deliverable.name) : null;
|
|
369
|
+
if (ravelmap) {
|
|
370
|
+
const mapDestination = safeDestination(root, ravelmap);
|
|
371
|
+
if (destinations.has(mapDestination)) {
|
|
372
|
+
throw new Error("A deliverable provenance map collides with another output: " + ravelmap);
|
|
373
|
+
}
|
|
374
|
+
destinations.add(mapDestination);
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
name: deliverable.name,
|
|
378
|
+
path: portableRelative(root, destination),
|
|
379
|
+
from: deliverable.from,
|
|
380
|
+
bytes: Buffer.byteLength(deliverable.value, "utf8"),
|
|
381
|
+
sha256: contentHash(deliverable.value),
|
|
382
|
+
...(ravelmap ? { ravelmap } : {})
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
if (deliverables.some((deliverable) => deliverable.ravelmap)) {
|
|
386
|
+
const aggregateDestination = safeDestination(root, aggregateProvenanceName);
|
|
387
|
+
if (destinations.has(aggregateDestination)) {
|
|
388
|
+
throw new Error("The aggregate provenance map collides with another output: " + aggregateProvenanceName);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
version: 1,
|
|
393
|
+
outputDirectory: root,
|
|
394
|
+
manifest: join(root, manifestName),
|
|
395
|
+
deliverables
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const readManifest = async (outputDirectory, rootDirectory) => {
|
|
400
|
+
const root = resolve(rootDirectory);
|
|
401
|
+
const output = resolve(outputDirectory);
|
|
402
|
+
if (!containedIn(root, output)) throw new Error("Output directory escapes the Ravel root: " + outputDirectory);
|
|
403
|
+
let rootEntry;
|
|
404
|
+
try {
|
|
405
|
+
rootEntry = await lstat(root);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (missing(error)) return null;
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
if (rootEntry.isSymbolicLink()) throw new Error("Ravel root must not be a symbolic link: " + root);
|
|
411
|
+
if (!rootEntry.isDirectory()) throw new Error("Ravel root must be a directory: " + root);
|
|
412
|
+
await assertNoSymlinks(root, output, "Output directory");
|
|
413
|
+
let outputEntry;
|
|
414
|
+
try {
|
|
415
|
+
outputEntry = await lstat(output);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (missing(error)) return null;
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
if (outputEntry.isSymbolicLink()) throw new Error("Output directory must not be a symbolic link: " + output);
|
|
421
|
+
if (!outputEntry.isDirectory()) throw new Error("Output directory must be a directory: " + output);
|
|
422
|
+
const path = safeDestination(output, manifestName);
|
|
423
|
+
let entry;
|
|
424
|
+
try {
|
|
425
|
+
entry = await lstat(path);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (missing(error)) return null;
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
if (entry.isSymbolicLink()) throw new Error("Build manifest path must not be a symbolic link: " + path);
|
|
431
|
+
if (!entry.isFile()) throw new Error("Build manifest path must be a file: " + path);
|
|
432
|
+
let manifest;
|
|
433
|
+
try {
|
|
434
|
+
manifest = JSON.parse(await readFile(path, "utf8"));
|
|
435
|
+
} catch (error) {
|
|
436
|
+
throw new Error("Build manifest is not valid JSON: " + path + " (" + error.message + ")");
|
|
437
|
+
}
|
|
438
|
+
if (![1, 2].includes(manifest?.version) || !Array.isArray(manifest.deliverables) ||
|
|
439
|
+
manifest.deliverables.some((deliverable) => typeof deliverable?.name !== "string") ||
|
|
440
|
+
(manifest.stale !== undefined && (!Array.isArray(manifest.stale) ||
|
|
441
|
+
manifest.stale.some((deliverable) => typeof deliverable?.name !== "string")))) {
|
|
442
|
+
throw new Error("Build manifest has an unsupported shape: " + path);
|
|
443
|
+
}
|
|
444
|
+
return { path, manifest };
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
/** List deliverables recorded by the previous successful build but absent now. */
|
|
448
|
+
export const planStaleDeliverables = async (program, outputDirectory, {
|
|
449
|
+
rootDirectory = outputDirectory,
|
|
450
|
+
staleSince = new Date().toISOString()
|
|
451
|
+
} = {}) => {
|
|
452
|
+
const plan = planDeliverables(program, outputDirectory);
|
|
453
|
+
const previous = await readManifest(plan.outputDirectory, rootDirectory);
|
|
454
|
+
if (!previous) return [];
|
|
455
|
+
const active = new Set(plan.deliverables.map((deliverable) => deliverable.name));
|
|
456
|
+
const prior = [...previous.manifest.deliverables, ...(previous.manifest.stale ?? [])];
|
|
457
|
+
return prior
|
|
458
|
+
.filter((deliverable) => !active.has(deliverable.name))
|
|
459
|
+
.filter((deliverable, index, entries) => entries.findIndex((entry) => entry.name === deliverable.name) === index)
|
|
460
|
+
.map((deliverable) => ({
|
|
461
|
+
name: deliverable.name,
|
|
462
|
+
path: portableRelative(plan.outputDirectory, safeDestination(plan.outputDirectory, deliverable.name)),
|
|
463
|
+
from: deliverable.from ?? null,
|
|
464
|
+
staleSince: deliverable.staleSince ?? staleSince,
|
|
465
|
+
...(deliverable.ravelmap ? { ravelmap: deliverable.ravelmap } : {})
|
|
466
|
+
}))
|
|
467
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const existingFile = async (path, description) => {
|
|
471
|
+
try {
|
|
472
|
+
const entry = await lstat(path);
|
|
473
|
+
if (entry.isSymbolicLink()) throw new Error(description + " must not be a symbolic link: " + path);
|
|
474
|
+
if (!entry.isFile()) throw new Error(description + " must be a file when it already exists: " + path);
|
|
475
|
+
return true;
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (missing(error)) return false;
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const removeIfPresent = async (path) => {
|
|
483
|
+
try {
|
|
484
|
+
await unlink(path);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (!missing(error)) throw error;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/** Stage all files before committing any replacement, with rollback on failure. */
|
|
491
|
+
const writeFilesAtomically = async (scope, entries, description) => {
|
|
492
|
+
const staged = [];
|
|
493
|
+
try {
|
|
494
|
+
for (const entry of entries) {
|
|
495
|
+
await ensureDirectoryTree(scope.root, dirname(entry.destination), description);
|
|
496
|
+
await assertNoSymlinks(scope.root, entry.destination, description);
|
|
497
|
+
const exists = await existingFile(entry.destination, description);
|
|
498
|
+
const token = randomUUID();
|
|
499
|
+
const temporary = join(dirname(entry.destination), "." + basename(entry.destination) + ".ravel-stage-" + token);
|
|
500
|
+
const backup = join(dirname(entry.destination), "." + basename(entry.destination) + ".ravel-backup-" + token);
|
|
501
|
+
await writeFile(temporary, entry.value, "utf8");
|
|
502
|
+
staged.push({ ...entry, exists, temporary, backup, committed: false, backedUp: false });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const entry of staged) {
|
|
506
|
+
if (entry.exists) {
|
|
507
|
+
await rename(entry.destination, entry.backup);
|
|
508
|
+
entry.backedUp = true;
|
|
509
|
+
}
|
|
510
|
+
await rename(entry.temporary, entry.destination);
|
|
511
|
+
entry.committed = true;
|
|
512
|
+
}
|
|
513
|
+
// A committed artifact set is already durable. Backup cleanup is best
|
|
514
|
+
// effort: failing here must not enter rollback after an earlier backup has
|
|
515
|
+
// been removed, because that could discard an otherwise valid commit.
|
|
516
|
+
for (const entry of staged) {
|
|
517
|
+
if (!entry.backedUp) continue;
|
|
518
|
+
try {
|
|
519
|
+
await removeIfPresent(entry.backup);
|
|
520
|
+
} catch {
|
|
521
|
+
// Leave an unreachable sibling backup for a later manual cleanup.
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
for (const entry of staged.slice().reverse()) {
|
|
526
|
+
if (entry.committed) await removeIfPresent(entry.destination);
|
|
527
|
+
if (entry.backedUp) {
|
|
528
|
+
try {
|
|
529
|
+
await rename(entry.backup, entry.destination);
|
|
530
|
+
} catch (rollbackError) {
|
|
531
|
+
if (!missing(rollbackError)) throw rollbackError;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
await removeIfPresent(entry.temporary);
|
|
535
|
+
}
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
export const writeDeliverables = async (program, outputDirectory, { rootDirectory = outputDirectory } = {}) => {
|
|
541
|
+
const scope = await createOutputScope(outputDirectory, rootDirectory);
|
|
542
|
+
const plan = planDeliverables(program, scope.root);
|
|
543
|
+
const entries = plan.deliverables.map((deliverable) => ({
|
|
544
|
+
destination: safeDestination(scope.root, deliverable.name),
|
|
545
|
+
value: program.deliverables[deliverable.name].value
|
|
546
|
+
}));
|
|
547
|
+
await writeFilesAtomically(scope, entries, "Deliverable path");
|
|
548
|
+
return entries.map((entry) => entry.destination);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
export const createBuildManifest = (program, outputDirectory, { stale = [], builtAt } = {}) => {
|
|
552
|
+
const plan = planDeliverables(program, outputDirectory);
|
|
553
|
+
const hasProvenance = plan.deliverables.some((deliverable) => deliverable.ravelmap);
|
|
554
|
+
return {
|
|
555
|
+
version: 2,
|
|
556
|
+
ravelVersion: program.version ?? 1,
|
|
557
|
+
outputDirectory: plan.outputDirectory,
|
|
558
|
+
deliverables: plan.deliverables,
|
|
559
|
+
stale: stale.map((deliverable) => ({
|
|
560
|
+
name: deliverable.name,
|
|
561
|
+
path: deliverable.path,
|
|
562
|
+
from: deliverable.from ?? null,
|
|
563
|
+
staleSince: deliverable.staleSince,
|
|
564
|
+
...(deliverable.ravelmap ? { ravelmap: deliverable.ravelmap } : {})
|
|
565
|
+
})),
|
|
566
|
+
...(hasProvenance ? {
|
|
567
|
+
provenance: { version: provenanceMapVersion, aggregate: aggregateProvenanceName }
|
|
568
|
+
} : {}),
|
|
569
|
+
...(builtAt ? { builtAt } : {}),
|
|
570
|
+
result: "success"
|
|
571
|
+
};
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const formatTextManifest = (manifest, generatedAt = new Date().toISOString()) => {
|
|
575
|
+
const lines = ["Ravel managed output manifest", "Generated: " + generatedAt, "", "Current files:"];
|
|
576
|
+
for (const deliverable of manifest.deliverables) {
|
|
577
|
+
lines.push(" " + deliverable.path + " (" + deliverable.from + ")");
|
|
578
|
+
if (deliverable.ravelmap) lines.push(" provenance: " + deliverable.ravelmap);
|
|
579
|
+
}
|
|
580
|
+
if (manifest.provenance?.aggregate) {
|
|
581
|
+
lines.push(" Aggregate provenance: " + manifest.provenance.aggregate);
|
|
582
|
+
}
|
|
583
|
+
if (manifest.stale.length) {
|
|
584
|
+
lines.push("", "Stale files (retained):");
|
|
585
|
+
const byDate = new Map();
|
|
586
|
+
for (const deliverable of manifest.stale) {
|
|
587
|
+
const date = deliverable.staleSince ?? "unknown time";
|
|
588
|
+
if (!byDate.has(date)) byDate.set(date, []);
|
|
589
|
+
byDate.get(date).push(deliverable);
|
|
590
|
+
}
|
|
591
|
+
for (const [date, deliverables] of [...byDate.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
592
|
+
lines.push(" Since " + date + ":");
|
|
593
|
+
for (const deliverable of deliverables) {
|
|
594
|
+
lines.push(" " + deliverable.path + " (" + (deliverable.from ?? "previous build") + ")");
|
|
595
|
+
if (deliverable.ravelmap) lines.push(" provenance: " + deliverable.ravelmap);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return lines.join("\n") + "\n";
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const writeManifestFiles = async (scope, manifest, generatedAt, description = "Build manifest path") => {
|
|
603
|
+
const jsonDestination = safeDestination(scope.root, manifestName);
|
|
604
|
+
const textDestination = safeDestination(scope.root, textManifestName);
|
|
605
|
+
await writeFilesAtomically(scope, [
|
|
606
|
+
{ destination: jsonDestination, value: JSON.stringify(manifest, null, 2) + "\n" },
|
|
607
|
+
{ destination: textDestination, value: formatTextManifest(manifest, generatedAt) }
|
|
608
|
+
], description);
|
|
609
|
+
return { path: jsonDestination, textPath: textDestination, manifest };
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
export const writeBuildManifest = async (program, outputDirectory, {
|
|
613
|
+
rootDirectory = outputDirectory,
|
|
614
|
+
generatedAt = new Date().toISOString()
|
|
615
|
+
} = {}) => {
|
|
616
|
+
const scope = await createOutputScope(outputDirectory, rootDirectory);
|
|
617
|
+
const manifest = createBuildManifest(program, scope.root, { builtAt: generatedAt });
|
|
618
|
+
return writeManifestFiles(scope, manifest, generatedAt);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/** Commit every deliverable and its success manifest in one filesystem transaction. */
|
|
622
|
+
export const writeBuildArtifacts = async (program, outputDirectory, {
|
|
623
|
+
rootDirectory = outputDirectory,
|
|
624
|
+
stale = [],
|
|
625
|
+
generatedAt = new Date().toISOString()
|
|
626
|
+
} = {}) => {
|
|
627
|
+
const scope = await createOutputScope(outputDirectory, rootDirectory);
|
|
628
|
+
const plan = planDeliverables(program, scope.root);
|
|
629
|
+
const manifest = createBuildManifest(program, scope.root, { stale, builtAt: generatedAt });
|
|
630
|
+
const deliverableEntries = plan.deliverables.map((deliverable) => ({
|
|
631
|
+
destination: safeDestination(scope.root, deliverable.name),
|
|
632
|
+
value: program.deliverables[deliverable.name].value
|
|
633
|
+
}));
|
|
634
|
+
const provenanceEntries = plan.deliverables
|
|
635
|
+
.filter((deliverable) => deliverable.ravelmap)
|
|
636
|
+
.map((deliverable) => ({
|
|
637
|
+
destination: safeDestination(scope.root, deliverable.ravelmap),
|
|
638
|
+
value: JSON.stringify(
|
|
639
|
+
createDeliverableProvenanceMap(program.deliverables[deliverable.name]),
|
|
640
|
+
null,
|
|
641
|
+
2
|
|
642
|
+
) + "\n"
|
|
643
|
+
}));
|
|
644
|
+
const aggregateEntry = provenanceEntries.length ? [{
|
|
645
|
+
destination: safeDestination(scope.root, aggregateProvenanceName),
|
|
646
|
+
value: JSON.stringify(createBuildProvenanceMap(program), null, 2) + "\n"
|
|
647
|
+
}] : [];
|
|
648
|
+
const manifestDestination = safeDestination(scope.root, manifestName);
|
|
649
|
+
const textManifestDestination = safeDestination(scope.root, textManifestName);
|
|
650
|
+
await writeFilesAtomically(scope, [
|
|
651
|
+
...deliverableEntries,
|
|
652
|
+
...provenanceEntries,
|
|
653
|
+
...aggregateEntry,
|
|
654
|
+
{ destination: manifestDestination, value: JSON.stringify(manifest, null, 2) + "\n" },
|
|
655
|
+
{ destination: textManifestDestination, value: formatTextManifest(manifest, generatedAt) }
|
|
656
|
+
], "Build artifact path");
|
|
657
|
+
return {
|
|
658
|
+
written: deliverableEntries.map((entry) => entry.destination),
|
|
659
|
+
provenance: {
|
|
660
|
+
sidecars: provenanceEntries.map((entry) => entry.destination),
|
|
661
|
+
aggregate: aggregateEntry[0]?.destination ?? null
|
|
662
|
+
},
|
|
663
|
+
manifest: { path: manifestDestination, textPath: textManifestDestination, manifest }
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const managedDeliverables = (manifest) => [...manifest.deliverables, ...(manifest.stale ?? [])]
|
|
668
|
+
.filter((deliverable, index, entries) => entries.findIndex((entry) => entry.name === deliverable.name) === index);
|
|
669
|
+
|
|
670
|
+
const planManagedRemoval = async (outputDirectory, rootDirectory, staleOnly) => {
|
|
671
|
+
const previous = await readManifest(outputDirectory, rootDirectory);
|
|
672
|
+
if (!previous) return { outputDirectory: resolve(outputDirectory), manifest: null, deliverables: [] };
|
|
673
|
+
const deliverables = (staleOnly ? previous.manifest.stale ?? [] : managedDeliverables(previous.manifest))
|
|
674
|
+
.map((deliverable) => ({
|
|
675
|
+
name: deliverable.name,
|
|
676
|
+
path: portableRelative(resolve(outputDirectory), safeDestination(resolve(outputDirectory), deliverable.name)),
|
|
677
|
+
from: deliverable.from ?? null,
|
|
678
|
+
staleSince: deliverable.staleSince,
|
|
679
|
+
...(deliverable.ravelmap ? { ravelmap: deliverable.ravelmap } : {})
|
|
680
|
+
}))
|
|
681
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
682
|
+
return {
|
|
683
|
+
outputDirectory: resolve(outputDirectory),
|
|
684
|
+
manifest: previous,
|
|
685
|
+
deliverables,
|
|
686
|
+
aggregate: staleOnly ? null : previous.manifest.provenance?.aggregate ?? null
|
|
687
|
+
};
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const removeManagedFiles = async (plan, rootDirectory, dryRun, removeManifest) => {
|
|
691
|
+
if (!plan.manifest || dryRun) return { removed: plan.deliverables, manifestRemoved: false };
|
|
692
|
+
const scope = await createOutputScope(plan.outputDirectory, rootDirectory);
|
|
693
|
+
const targets = [
|
|
694
|
+
...plan.deliverables.flatMap((deliverable) => [
|
|
695
|
+
safeDestination(scope.root, deliverable.name),
|
|
696
|
+
...(deliverable.ravelmap ? [safeDestination(scope.root, deliverable.ravelmap)] : [])
|
|
697
|
+
]),
|
|
698
|
+
...(plan.aggregate ? [safeDestination(scope.root, plan.aggregate)] : []),
|
|
699
|
+
...(removeManifest ? [safeDestination(scope.root, manifestName), safeDestination(scope.root, textManifestName)] : [])
|
|
700
|
+
];
|
|
701
|
+
for (const target of targets) await existingFile(target, "Managed output path");
|
|
702
|
+
for (const target of targets) await removeIfPresent(target);
|
|
703
|
+
return { removed: plan.deliverables, manifestRemoved: removeManifest };
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
/** Remove all files named by a prior manifest, never arbitrary output files. */
|
|
707
|
+
export const cleanManagedArtifacts = async (outputDirectory, {
|
|
708
|
+
rootDirectory = outputDirectory,
|
|
709
|
+
dryRun = false
|
|
710
|
+
} = {}) => {
|
|
711
|
+
const plan = await planManagedRemoval(outputDirectory, rootDirectory, false);
|
|
712
|
+
return { ...(await removeManagedFiles(plan, rootDirectory, dryRun, true)), outputDirectory: plan.outputDirectory };
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
/** Remove only stale manifest entries and rewrite the manifest with none remaining. */
|
|
716
|
+
export const refreshStaleArtifacts = async (outputDirectory, {
|
|
717
|
+
rootDirectory = outputDirectory,
|
|
718
|
+
dryRun = false,
|
|
719
|
+
generatedAt = new Date().toISOString()
|
|
720
|
+
} = {}) => {
|
|
721
|
+
const plan = await planManagedRemoval(outputDirectory, rootDirectory, true);
|
|
722
|
+
if (!plan.manifest || dryRun) return { removed: plan.deliverables, outputDirectory: plan.outputDirectory, dryRun };
|
|
723
|
+
const result = await removeManagedFiles(plan, rootDirectory, false, false);
|
|
724
|
+
const scope = await createOutputScope(plan.outputDirectory, rootDirectory);
|
|
725
|
+
const manifest = { ...plan.manifest.manifest, version: 2, stale: [] };
|
|
726
|
+
await writeManifestFiles(scope, manifest, generatedAt);
|
|
727
|
+
return { ...result, outputDirectory: plan.outputDirectory, dryRun: false };
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const crcTable = (() => {
|
|
731
|
+
const table = new Uint32Array(256);
|
|
732
|
+
for (let index = 0; index < table.length; index += 1) {
|
|
733
|
+
let value = index;
|
|
734
|
+
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
735
|
+
table[index] = value >>> 0;
|
|
736
|
+
}
|
|
737
|
+
return table;
|
|
738
|
+
})();
|
|
739
|
+
|
|
740
|
+
const crc32 = (value) => {
|
|
741
|
+
let crc = 0xffffffff;
|
|
742
|
+
for (const byte of value) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
743
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const zipDateTime = (date) => {
|
|
747
|
+
const value = Number.isNaN(date.getTime()) ? new Date() : date;
|
|
748
|
+
const year = Math.min(2107, Math.max(1980, value.getFullYear()));
|
|
749
|
+
return {
|
|
750
|
+
date: ((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate(),
|
|
751
|
+
time: (value.getHours() << 11) | (value.getMinutes() << 5) | Math.floor(value.getSeconds() / 2)
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const zipArchive = (files) => {
|
|
756
|
+
if (files.length > 0xffff) throw new Error("Backup archive has too many files for ZIP: " + files.length);
|
|
757
|
+
const locals = [];
|
|
758
|
+
const central = [];
|
|
759
|
+
let offset = 0;
|
|
760
|
+
for (const file of files) {
|
|
761
|
+
const name = Buffer.from(file.name, "utf8");
|
|
762
|
+
const size = file.value.length;
|
|
763
|
+
if (name.length > 0xffff || size > 0xffffffff || offset > 0xffffffff) {
|
|
764
|
+
throw new Error("Backup archive contains a ZIP64-sized file: " + file.name);
|
|
765
|
+
}
|
|
766
|
+
const { date, time } = zipDateTime(file.mtime);
|
|
767
|
+
const crc = crc32(file.value);
|
|
768
|
+
const local = Buffer.alloc(30);
|
|
769
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
770
|
+
local.writeUInt16LE(20, 4);
|
|
771
|
+
local.writeUInt16LE(0x0800, 6);
|
|
772
|
+
local.writeUInt16LE(0, 8);
|
|
773
|
+
local.writeUInt16LE(time, 10);
|
|
774
|
+
local.writeUInt16LE(date, 12);
|
|
775
|
+
local.writeUInt32LE(crc, 14);
|
|
776
|
+
local.writeUInt32LE(size, 18);
|
|
777
|
+
local.writeUInt32LE(size, 22);
|
|
778
|
+
local.writeUInt16LE(name.length, 26);
|
|
779
|
+
local.writeUInt16LE(0, 28);
|
|
780
|
+
locals.push(local, name, file.value);
|
|
781
|
+
|
|
782
|
+
const directory = Buffer.alloc(46);
|
|
783
|
+
directory.writeUInt32LE(0x02014b50, 0);
|
|
784
|
+
directory.writeUInt16LE(20, 4);
|
|
785
|
+
directory.writeUInt16LE(20, 6);
|
|
786
|
+
directory.writeUInt16LE(0x0800, 8);
|
|
787
|
+
directory.writeUInt16LE(0, 10);
|
|
788
|
+
directory.writeUInt16LE(time, 12);
|
|
789
|
+
directory.writeUInt16LE(date, 14);
|
|
790
|
+
directory.writeUInt32LE(crc, 16);
|
|
791
|
+
directory.writeUInt32LE(size, 20);
|
|
792
|
+
directory.writeUInt32LE(size, 24);
|
|
793
|
+
directory.writeUInt16LE(name.length, 28);
|
|
794
|
+
directory.writeUInt16LE(0, 30);
|
|
795
|
+
directory.writeUInt16LE(0, 32);
|
|
796
|
+
directory.writeUInt16LE(0, 34);
|
|
797
|
+
directory.writeUInt16LE(0, 36);
|
|
798
|
+
directory.writeUInt32LE(0, 38);
|
|
799
|
+
directory.writeUInt32LE(offset, 42);
|
|
800
|
+
central.push(directory, name);
|
|
801
|
+
offset += local.length + name.length + size;
|
|
802
|
+
}
|
|
803
|
+
const centralSize = central.reduce((size, value) => size + value.length, 0);
|
|
804
|
+
if (offset + centralSize > 0xffffffff) throw new Error("Backup archive is too large for ZIP without ZIP64 support.");
|
|
805
|
+
const end = Buffer.alloc(22);
|
|
806
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
807
|
+
end.writeUInt16LE(0, 4);
|
|
808
|
+
end.writeUInt16LE(0, 6);
|
|
809
|
+
end.writeUInt16LE(files.length, 8);
|
|
810
|
+
end.writeUInt16LE(files.length, 10);
|
|
811
|
+
end.writeUInt32LE(centralSize, 12);
|
|
812
|
+
end.writeUInt32LE(offset, 16);
|
|
813
|
+
end.writeUInt16LE(0, 20);
|
|
814
|
+
return Buffer.concat([...locals, ...central, end]);
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const collectBackupFiles = async (root, directory = root) => {
|
|
818
|
+
const files = [];
|
|
819
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
820
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
821
|
+
const path = join(directory, entry.name);
|
|
822
|
+
const stat = await lstat(path);
|
|
823
|
+
if (stat.isSymbolicLink()) throw new Error("Backup output must not traverse a symbolic link: " + path);
|
|
824
|
+
if (stat.isDirectory()) files.push(...await collectBackupFiles(root, path));
|
|
825
|
+
else if (stat.isFile()) files.push({
|
|
826
|
+
name: relative(root, path).split(sep).join("/"),
|
|
827
|
+
value: await readFile(path),
|
|
828
|
+
mtime: stat.mtime
|
|
829
|
+
});
|
|
830
|
+
else throw new Error("Backup output must contain only files and directories: " + path);
|
|
831
|
+
}
|
|
832
|
+
return files;
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/** Describe a backup archive without writing it, including no-overwrite checks. */
|
|
836
|
+
export const planOutputBackup = async (outputDirectory, {
|
|
837
|
+
outputRootDirectory = outputDirectory,
|
|
838
|
+
backupRootDirectory = outputRootDirectory,
|
|
839
|
+
backupPath
|
|
840
|
+
} = {}) => {
|
|
841
|
+
const output = resolve(outputDirectory);
|
|
842
|
+
const previous = await readManifest(output, outputRootDirectory);
|
|
843
|
+
if (!previous) throw new Error("Cannot create a backup without an existing Ravel build manifest: " + output);
|
|
844
|
+
const builtAt = previous.manifest.builtAt;
|
|
845
|
+
const timestamp = Date.parse(builtAt);
|
|
846
|
+
if (!Number.isFinite(timestamp)) throw new Error("Build manifest has no valid build timestamp for backup: " + previous.path);
|
|
847
|
+
const backupRoot = resolve(backupRootDirectory);
|
|
848
|
+
await assertDirectory(backupRoot, "Backup root");
|
|
849
|
+
const path = backupPath === undefined
|
|
850
|
+
? join(backupRoot, "backups", basename(output) + "-" + Math.floor(timestamp / 1000) + ".zip")
|
|
851
|
+
: await scopedPath(backupRoot, backupPath, "Backup path");
|
|
852
|
+
if (extname(path).toLowerCase() !== ".zip") throw new Error("Backup file must have a .zip extension: " + path);
|
|
853
|
+
if (!containedIn(backupRoot, path)) throw new Error("Backup path escapes the Ravel root: " + path);
|
|
854
|
+
await assertNoSymlinks(backupRoot, path, "Backup path");
|
|
855
|
+
if (await existingFile(path, "Backup file")) throw new Error("Backup file already exists: " + path);
|
|
856
|
+
return { outputDirectory: output, path, manifest: previous, builtAt };
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/** Archive the complete current output tree before it is cleaned or replaced. */
|
|
860
|
+
export const createOutputBackup = async (outputDirectory, options = {}) => {
|
|
861
|
+
const plan = await planOutputBackup(outputDirectory, options);
|
|
862
|
+
const backupRoot = resolve(options.backupRootDirectory ?? options.outputRootDirectory ?? outputDirectory);
|
|
863
|
+
await ensureDirectoryTree(backupRoot, dirname(plan.path), "Backup path");
|
|
864
|
+
if (await existingFile(plan.path, "Backup file")) throw new Error("Backup file already exists: " + plan.path);
|
|
865
|
+
const files = await collectBackupFiles(plan.outputDirectory);
|
|
866
|
+
const temporary = join(dirname(plan.path), "." + basename(plan.path) + ".ravel-stage-" + randomUUID());
|
|
867
|
+
try {
|
|
868
|
+
await writeFile(temporary, zipArchive(files));
|
|
869
|
+
await rename(temporary, plan.path);
|
|
870
|
+
} catch (error) {
|
|
871
|
+
await removeIfPresent(temporary);
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
return { path: plan.path, files: files.map((file) => file.name), builtAt: plan.builtAt };
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
export const writeGraph = async (program, path, { rootDirectory = dirname(resolve(path)) } = {}) => {
|
|
878
|
+
const scope = await createOutputScope(rootDirectory, rootDirectory);
|
|
879
|
+
const destination = await scope.path(path, "Graph path");
|
|
880
|
+
await ensureDirectoryTree(scope.root, dirname(destination), "Graph path");
|
|
881
|
+
await assertNoSymlinks(scope.root, destination, "Graph path");
|
|
882
|
+
await writeFile(destination, JSON.stringify(program, null, 2) + "\n", "utf8");
|
|
883
|
+
};
|