@pajh/buldng 0.0.2 → 0.0.4

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.
@@ -0,0 +1,258 @@
1
+ import { WorkFile } from "./workfile.js";
2
+
3
+ // buldng-ops.js
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import esbuild from "esbuild";
7
+
8
+ import { assertSourceRead, assertDestWrite, looksLikeFilename, ensureWorkFile } from "./buldng-validate.js";
9
+ export const OPS = {
10
+ file: op_file,
11
+ literal: op_literal,
12
+ append: op_append,
13
+ replace: op_replace,
14
+ compile: op_compile,
15
+ materialise: op_materialise,
16
+ wrap: op_wrap,
17
+ };
18
+
19
+ // --------------------------------------------------
20
+ // FILE — lazy read from disk. op:"file"
21
+ // --------------------------------------------------
22
+ export function file(strings, ...values) {
23
+ if (values.length > 0) {
24
+ throw new Error("file`` does not support interpolation");
25
+ }
26
+
27
+ const relPath = strings.join("");
28
+ const absPath = assertSourceRead(relPath);
29
+
30
+ return new WorkFile("file", { relPath, absPath }, []);
31
+ }
32
+
33
+ // op for "file"
34
+ export function op_file(config, children) {
35
+ return fs.readFileSync(config.absPath, "utf8");
36
+ }
37
+
38
+ // --------------------------------------------------
39
+ // LITERAL — inline text
40
+ // --------------------------------------------------
41
+ export function op_literal(config, children) {
42
+ return config.value;
43
+ }
44
+
45
+ // --------------------------------------------------
46
+ // APPEND — concat children in order
47
+ // --------------------------------------------------
48
+ export function op_append(config, children) {
49
+ let out = "";
50
+
51
+ for (const child of children) {
52
+ const chunk = child.execute();
53
+
54
+ if (out.length > 0 &&
55
+ !out.endsWith("\n") &&
56
+ !chunk.startsWith("\n")) {
57
+ out += "\n";
58
+ }
59
+
60
+ out += chunk;
61
+ }
62
+
63
+ return out;
64
+ }
65
+
66
+ // --------------------------------------------------
67
+ // REPLACE — replace tag with replacement
68
+ // --------------------------------------------------
69
+ export function op_replace(config, children) {
70
+ if (children.length !== 3) {
71
+ throw new Error(`replace(): expected 3 children, got ${children.length}`);
72
+ }
73
+
74
+ const [srcWF, tagWF, repWF] = children;
75
+
76
+ const src = srcWF.execute();
77
+ const tag = tagWF.execute();
78
+ const rep = repWF.execute();
79
+
80
+ if (!src.includes(tag)) {
81
+ throw new Error(`replace(): tag "${tag}" not found in source`);
82
+ }
83
+
84
+ return src.replace(tag, rep);
85
+ }
86
+
87
+ // --------------------------------------------------
88
+ // COMPILE — esbuild wrapper
89
+ // --------------------------------------------------
90
+ export function op_compile(config, children) {
91
+ if (children.length !== 1) {
92
+ throw new Error(`compile(): expected 1 child, got ${children.length}`);
93
+ }
94
+
95
+ const child = children[0];
96
+ const compileOptions = {
97
+ minify: config.flags?.minify ?? true,
98
+ sourcemap: config.flags?.sourcemap ?? true,
99
+ format: config.flags?.format ?? "esm",
100
+ target: config.flags?.target ?? "esnext",
101
+ };
102
+
103
+ // If child is a file WorkFile, use its absolute path directly
104
+ if (child.op === "file" && child.config.absPath) {
105
+ const entry = child.config.absPath;
106
+
107
+ const result = esbuild.buildSync({
108
+ entryPoints: [entry],
109
+ bundle: true,
110
+ ...compileOptions,
111
+ write: false
112
+ });
113
+
114
+ if (result.errors?.length) {
115
+ throw new Error("Build failed:\n" + result.errors.map(e => e.text).join("\n"));
116
+ }
117
+
118
+ return result.outputFiles[0].text;
119
+ }
120
+
121
+ // Otherwise compile from stdin
122
+ const srcText = child.execute();
123
+
124
+ const result = esbuild.buildSync({
125
+ stdin: {
126
+ contents: srcText,
127
+ resolveDir: process.cwd(),
128
+ sourcefile: "input.ts"
129
+ },
130
+ bundle: true,
131
+ ...compileOptions,
132
+ write: false
133
+ });
134
+
135
+ if (result.errors?.length) {
136
+ throw new Error("Build failed:\n" + result.errors.map(e => e.text).join("\n"));
137
+ }
138
+
139
+ return result.outputFiles[0].text;
140
+ }
141
+
142
+ // --------------------------------------------------
143
+ // MATERIALISE — write file if identical or new
144
+ // --------------------------------------------------
145
+ export function materialise(child, target) {
146
+ const childWF = ensureWorkFile(child, "materialise");
147
+ return new WorkFile("materialise", { target }, [childWF]);
148
+ }
149
+
150
+ export function op_materialise(config, children) {
151
+ if (children.length !== 1) {
152
+ throw new Error(`materialise(): expected 1 child, got ${children.length}`);
153
+ }
154
+
155
+ const child = children[0];
156
+
157
+ const text = child.execute();
158
+
159
+ if (looksLikeFilename(text)) {
160
+ console.warn(
161
+ `materialise(): child returned a filename '${text}' but expected text.\n` +
162
+ `Did you accidentally pass a materialised WorkFile instead of raw text?`
163
+ );
164
+ }
165
+
166
+ const abs = assertDestWrite(config.target);
167
+
168
+ if (!fs.existsSync(abs)) {
169
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
170
+ fs.writeFileSync(abs, text, "utf8");
171
+ // NEW: mark file read-only
172
+ fs.chmodSync(abs, 0o444);
173
+ console.log(`materialise: ${abs}`);
174
+ return "/" + config.target;
175
+ }
176
+
177
+ const existing = fs.readFileSync(abs, "utf8");
178
+
179
+ if (existing === text) {
180
+ return "/" + config.target;
181
+ }
182
+
183
+ throw new Error(
184
+ `materialise(): cannot overwrite '${config.target}' — existing file differs from generated output`
185
+ );
186
+ }
187
+
188
+ // --------------------------------------------------
189
+ // WRAP — pre + child + post
190
+ // --------------------------------------------------
191
+ export function op_wrap(config, children) {
192
+ if (children.length !== 1) {
193
+ throw new Error(`wrap(): expected 1 child, got ${children.length}`);
194
+ }
195
+
196
+ const inner = children[0].execute();
197
+ const pre = config.pre ?? "";
198
+ const post = config.post ?? "";
199
+
200
+ return pre + inner + post;
201
+ }
202
+
203
+
204
+
205
+ // --------------------------------------------------
206
+ // literal(text)
207
+ // --------------------------------------------------
208
+ export function literal(value) {
209
+ return new WorkFile("literal", { value }, []);
210
+ }
211
+
212
+ // --------------------------------------------------
213
+ // wrap(pre, child, post)
214
+ // --------------------------------------------------
215
+ export function wrap(pre, child, post, tag=null) {
216
+ const childWF = ensureWorkFile(child, "wrap child");
217
+ return new WorkFile("wrap", { pre, post }, [childWF], tag);
218
+ }
219
+
220
+ export function append() {
221
+ const children = [];
222
+
223
+ for (let i = 0; i < arguments.length; i++) {
224
+ const item = arguments[i];
225
+ const wf = ensureWorkFile(item, "append item");
226
+ children.push(wf);
227
+ }
228
+
229
+ if (children.length < 2) {
230
+ throw new Error("append(): requires at least 2 items");
231
+ }
232
+
233
+ return new WorkFile("append", {}, children);
234
+ }
235
+
236
+
237
+ // --------------------------------------------------
238
+ // replace(source, tag, replacement)
239
+ // --------------------------------------------------
240
+ export function replace(source, tag, replacement) {
241
+ const srcWF = ensureWorkFile(source, "source");
242
+ const tagWF = ensureWorkFile(tag, "tag");
243
+ const repWF = ensureWorkFile(replacement, "replacement");
244
+
245
+ if (tagWF.op === "literal" && tagWF.config.value.trim() === "") {
246
+ throw new Error(`replace(): tag cannot be blank`);
247
+ }
248
+
249
+ return new WorkFile("replace", {}, [srcWF, tagWF, repWF]);
250
+ }
251
+
252
+ // --------------------------------------------------
253
+ // compile(source)
254
+ // --------------------------------------------------
255
+ export function compile(source, flags = {}) {
256
+ const srcWF = ensureWorkFile(source, "source");
257
+ return new WorkFile("compile", { flags }, [srcWF]);
258
+ }
@@ -0,0 +1,207 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { WorkFile } from "./workfile.js";
6
+ import { literal } from "./buldng-ops.js";
7
+ // --------------------------------------------------
8
+ // GLOBAL SOURCE & DEST ROOTS
9
+ // --------------------------------------------------
10
+
11
+ let SRC = null;
12
+ let DEST = null;
13
+
14
+ // --------------------------------------------------
15
+ // SETTERS WITH SAFETY CHECKS
16
+ // --------------------------------------------------
17
+
18
+ export function setSource(dir) {
19
+ const abs = path.resolve(dir);
20
+ const sentinel = path.join(abs, "BULDNG_SRC");
21
+ if (!fs.existsSync(sentinel)) {
22
+ throw new Error(`SECURITY: Source directory "${abs}" does not contain BLDNG_SRC sentinel`);
23
+ }
24
+ SRC = abs;
25
+ validateRoots();
26
+ }
27
+
28
+ export function setDest(dir) {
29
+ const abs = path.resolve(dir);
30
+
31
+ // ------------------------------------------------------------
32
+ // If dest exists, perform full safety checks
33
+ // ------------------------------------------------------------
34
+ if (fs.existsSync(abs)) {
35
+
36
+ // SECURITY: full recursive scan for BULDNG_SRC
37
+ const stack = [abs];
38
+ while (stack.length) {
39
+ const current = stack.pop();
40
+ const entries = fs.readdirSync(current, { withFileTypes: true });
41
+
42
+ for (const entry of entries) {
43
+ const full = path.join(current, entry.name);
44
+
45
+ if (entry.isDirectory()) {
46
+ stack.push(full);
47
+ } else if (entry.name === "BULDNG_SRC") {
48
+ throw new Error(`SECURITY: Dest directory "${abs}" contains BULDNG_SRC sentinel`);
49
+ }
50
+ }
51
+ }
52
+
53
+ // SECURITY: require DEST sentinel in root
54
+ const destSentinel = path.join(abs, "BULDNG_DEST");
55
+ if (!fs.existsSync(destSentinel)) {
56
+ throw new Error(`SECURITY: Refusing to delete "${abs}" — missing BULDNG_DEST sentinel`);
57
+ }
58
+
59
+ // -----------------------------------------------------------
60
+ // Copy clean.sh from package into dest
61
+ // ------------------------------------------------------------
62
+ const __filename = fileURLToPath(import.meta.url);
63
+ const __dirname = path.dirname(__filename);
64
+ const cleanSrc = path.join(__dirname, "clean.sh"); // your package copy
65
+ const cleanDest = path.join(abs, "clean.sh");
66
+
67
+ fs.copyFileSync(cleanSrc, cleanDest);
68
+ fs.chmodSync(cleanDest, 0o755);
69
+
70
+ // ------------------------------------------------------------
71
+ // Call safe_clean.sh with full path to clean.sh
72
+ // ------------------------------------------------------------
73
+ const safeClean = path.resolve("safe_clean.sh");
74
+ console.log(`SECURITY: Running safe_clean.sh on "${abs}"`);
75
+
76
+ const result = spawnSync(safeClean, [cleanDest], {
77
+ stdio: "inherit"
78
+ });
79
+
80
+ if (result.status !== 0) {
81
+ throw new Error(`SECURITY: safe_clean.sh failed with exit code ${result.status}`);
82
+ }
83
+
84
+ // ------------------------------------------------------------
85
+ // Verify directory is empty after cleaning
86
+ // ------------------------------------------------------------
87
+ const remaining = fs.readdirSync(abs);
88
+ if (remaining.length !== 0) {
89
+ throw new Error(`SECURITY: Dest directory "${abs}" not empty after safe_clean`);
90
+ }
91
+ }
92
+
93
+ // ------------------------------------------------------------
94
+ // Always recreate DEST fresh
95
+ // ------------------------------------------------------------
96
+ fs.mkdirSync(abs, { recursive: true });
97
+
98
+ // Create DEST sentinel and mark read-only
99
+ const sentinelPath = path.join(abs, "BULDNG_DEST");
100
+ fs.writeFileSync(sentinelPath, "");
101
+ fs.chmodSync(sentinelPath, 0o444);
102
+
103
+ DEST = abs;
104
+ validateRoots();
105
+ }
106
+
107
+
108
+
109
+ export function getDest() {
110
+ if (!DEST) throw new Error("SECURITY: Dest root not set");
111
+ return DEST;
112
+ }
113
+
114
+ // --------------------------------------------------
115
+ // PATH SAFETY HELPERS
116
+ // --------------------------------------------------
117
+
118
+ export function assertSourceRead(relPath) {
119
+ if (!SRC) throw new Error("SECURITY: Source root not set");
120
+ if (relPath.includes("..")) throw new Error("SECURITY: Illegal path traversal in read()");
121
+ const abs = path.resolve(SRC, relPath);
122
+ if (!abs.startsWith(SRC)) throw new Error("SECURITY: Read outside src/");
123
+ trackInput(abs);
124
+ return abs;
125
+ }
126
+
127
+ export function assertDestWrite(relPath) {
128
+ if (!DEST) throw new Error("SECURITY: Dest root not set");
129
+ if (relPath.includes("..")) throw new Error("SECURITY: Illegal path traversal in save()");
130
+ const abs = path.resolve(DEST, relPath);
131
+ if (!abs.startsWith(DEST)) throw new Error("SECURITY: Write outside dest/");
132
+ return abs;
133
+ }
134
+
135
+ function validateRoots() {
136
+ if (!SRC || !DEST) return;
137
+
138
+ const srcReal = fs.realpathSync(SRC);
139
+ const destReal = fs.realpathSync(DEST);
140
+
141
+ if (srcReal === destReal) {
142
+ throw new Error(`SECURITY: src and dest resolve to the same directory:
143
+ src: ${SRC}
144
+ dest: ${DEST}`);
145
+ }
146
+
147
+ if (srcReal.startsWith(destReal) || destReal.startsWith(srcReal)) {
148
+ throw new Error(`SECURITY: src and dest cannot be nested:
149
+ src: ${SRC}
150
+ dest: ${DEST}`);
151
+ }
152
+ }
153
+
154
+ const INPUTS = new Set();
155
+
156
+ export function getInputs() {
157
+ return INPUTS;
158
+ }
159
+
160
+ export function trackInput(absPath) {
161
+ if (!SRC || !DEST) return;
162
+ if (absPath.startsWith(SRC) && !absPath.startsWith(DEST)) {
163
+ INPUTS.add(absPath);
164
+ }
165
+ }
166
+
167
+ export function looksLikeFilename(s) {
168
+ return typeof s === "string" &&
169
+ s.length < 200 &&
170
+ /[A-Za-z0-9_\-\/]+\.(html|css|js)$/.test(s);
171
+ }
172
+
173
+ export function looksLikeHTML(s) {
174
+ return typeof s === "string" &&
175
+ s.trim().startsWith("<") &&
176
+ s.includes(">");
177
+ }
178
+
179
+ export function looksLikeCSS(s) {
180
+ return typeof s === "string" &&
181
+ s.includes("{") &&
182
+ s.includes("}");
183
+ }
184
+
185
+ export function looksLikeJS(s) {
186
+ return typeof s === "string" &&
187
+ (s.includes("function") ||
188
+ s.includes("const ") ||
189
+ s.includes("let "));
190
+ }
191
+
192
+ export function ensureLeadingSlash(p) {
193
+ return p.startsWith("/") ? p : "/" + p;
194
+ }
195
+
196
+ // --------------------------------------------------
197
+ // ensureWorkFile(x)
198
+ // --------------------------------------------------
199
+ export function ensureWorkFile(x, fieldName = "value") {
200
+ if (x instanceof WorkFile) return x;
201
+
202
+ if (typeof x === "string") {
203
+ return literal(x);
204
+ }
205
+
206
+ throw new Error(`${fieldName} must be WorkFile or string`);
207
+ }