brepjs 18.138.0 → 18.140.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/brepjs.mjs +310 -0
  2. package/package.json +6 -2
package/bin/brepjs.mjs ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * brepjs CLI — copy-in distribution for brepjs-families (the shadcn model).
4
+ *
5
+ * brepjs add <family...> copy family source (and its family deps) into
6
+ * your project as owned code
7
+ * brepjs diff <family> compare a copied family against the registry
8
+ *
9
+ * Options:
10
+ * --registry <url|path> registry root (default: the brepjs GitHub registry)
11
+ * --dir <path> target directory (default: src/families)
12
+ * --force overwrite locally modified files
13
+ * --install run `npm install` for missing npm deps
14
+ *
15
+ * The registry is data (manifest.json + source files), so any static host or
16
+ * directory works — point --registry at a firm-internal copy to self-host.
17
+ */
18
+
19
+ import { mkdir, open, readFile, writeFile, access, lstat, realpath, rename, rm } from 'node:fs/promises';
20
+ import { constants } from 'node:fs';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { join, resolve as resolvePath, sep } from 'node:path';
23
+ import process from 'node:process';
24
+
25
+ const DEFAULT_REGISTRY =
26
+ 'https://raw.githubusercontent.com/andymai/brepjs/main/packages/brepjs-families/registry';
27
+
28
+ function parseArgs(argv) {
29
+ const args = { _: [], registry: DEFAULT_REGISTRY, dir: 'src/families', force: false, install: false };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '--registry') args.registry = argv[++i];
33
+ else if (a === '--dir') args.dir = argv[++i];
34
+ else if (a === '--force') args.force = true;
35
+ else if (a === '--install') args.install = true;
36
+ else args._.push(a);
37
+ }
38
+ return args;
39
+ }
40
+
41
+ function isUrl(s) {
42
+ return s.startsWith('http://') || s.startsWith('https://');
43
+ }
44
+
45
+ async function fetchText(registry, rel) {
46
+ if (isUrl(registry)) {
47
+ const url = `${registry.replace(/\/$/, '')}/${rel}`;
48
+ const res = await fetch(url);
49
+ if (!res.ok) throw new Error(`fetch failed (${res.status}): ${url}`);
50
+ return res.text();
51
+ }
52
+ return readFile(join(registry, rel), 'utf8');
53
+ }
54
+
55
+ // The manifest is the trust boundary: registry file entries become local
56
+ // write paths and npmDeps become `npm install` arguments, so both are held
57
+ // to strict allowlists before anything else touches them.
58
+ const FILE_ENTRY = /^families\/[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$/;
59
+ const NPM_NAME = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/;
60
+
61
+ function validateManifest(manifest) {
62
+ if (manifest.schemaVersion !== 1) {
63
+ throw new Error(`unsupported registry schemaVersion: ${manifest.schemaVersion}`);
64
+ }
65
+ for (const fam of manifest.families) {
66
+ for (const file of fam.files) {
67
+ if (!FILE_ENTRY.test(file) || file.includes('..')) {
68
+ throw new Error(`registry file entry outside families/: ${file}`);
69
+ }
70
+ }
71
+ for (const dep of fam.npmDeps) {
72
+ if (!NPM_NAME.test(dep)) {
73
+ throw new Error(`invalid npm dependency name in registry: ${dep}`);
74
+ }
75
+ }
76
+ }
77
+ return manifest;
78
+ }
79
+
80
+ async function loadManifest(registry) {
81
+ if (registry.startsWith('http://')) {
82
+ throw new Error('plaintext http registries are not supported — use https or a local path');
83
+ }
84
+ return validateManifest(JSON.parse(await fetchText(registry, 'manifest.json')));
85
+ }
86
+
87
+ function familyByName(manifest, name) {
88
+ const fam = manifest.families.find((f) => f.name === name);
89
+ if (!fam) {
90
+ const known = manifest.families.map((f) => f.name).join(', ');
91
+ throw new Error(`unknown family '${name}' (registry has: ${known})`);
92
+ }
93
+ return fam;
94
+ }
95
+
96
+ /** Requested families plus their familyDeps, dependencies first. */
97
+ function resolveClosure(manifest, names) {
98
+ const ordered = [];
99
+ const seen = new Set();
100
+ const visit = (name, trail) => {
101
+ if (seen.has(name)) return;
102
+ if (trail.includes(name)) {
103
+ throw new Error(`familyDeps cycle: ${[...trail, name].join(' -> ')}`);
104
+ }
105
+ const fam = familyByName(manifest, name);
106
+ for (const dep of fam.familyDeps) visit(dep, [...trail, name]);
107
+ seen.add(name);
108
+ ordered.push(fam);
109
+ };
110
+ for (const name of names) visit(name, []);
111
+ return ordered;
112
+ }
113
+
114
+ async function exists(path) {
115
+ try {
116
+ await access(path);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /** Refuse writes through symlinks: the (created) parent must really live
124
+ * under the target root, and the file is opened with O_NOFOLLOW so a
125
+ * symlinked target is rejected atomically at open time (no check-then-write
126
+ * race on the final component). A symlinked target root itself is respected
127
+ * as the user's own layout choice. */
128
+ async function guardedWrite(targetRoot, target, content) {
129
+ await mkdir(join(target, '..'), { recursive: true });
130
+ const rootReal = await realpath(targetRoot);
131
+ const parentReal = await realpath(join(target, '..'));
132
+ if (parentReal !== rootReal && !parentReal.startsWith(rootReal + sep)) {
133
+ throw new Error(`refusing to write outside the target directory: ${target}`);
134
+ }
135
+ const stat = await lstat(target).catch(() => null);
136
+ if (stat?.isSymbolicLink()) {
137
+ throw new Error(`refusing to write through a symlink: ${target}`);
138
+ }
139
+ // Write a sibling temp file, then rename over the target: rename is atomic
140
+ // and never follows symlinks, so a failed write leaves the target intact
141
+ // (no O_TRUNC damage to report) and a racing symlink is replaced, never
142
+ // followed.
143
+ const tmp = `${target}.brepjs-tmp-${process.pid}`;
144
+ const handle = await open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
145
+ try {
146
+ await handle.writeFile(content);
147
+ await handle.close();
148
+ await rename(tmp, target);
149
+ } catch (err) {
150
+ await handle.close().catch(() => {});
151
+ await rm(tmp, { force: true });
152
+ throw err;
153
+ }
154
+ }
155
+
156
+ async function add(args) {
157
+ const manifest = await loadManifest(args.registry);
158
+ const families = resolveClosure(manifest, args._);
159
+ const targetRoot = resolvePath(args.dir);
160
+
161
+ // Plan first, write after: a conflict anywhere in the closure aborts the
162
+ // whole command before any file is touched, so a failed add never leaves a
163
+ // partially installed family set.
164
+ const planned = [];
165
+ for (const fam of families) {
166
+ for (const file of fam.files) {
167
+ const content = await fetchText(args.registry, file);
168
+ const target = resolvePath(targetRoot, file.replace(/^families\//, ''));
169
+ if (!target.startsWith(targetRoot + sep)) {
170
+ throw new Error(`registry file entry escapes the target directory: ${file}`);
171
+ }
172
+ planned.push({ target, content });
173
+ }
174
+ }
175
+
176
+ const writes = [];
177
+ const skipped = [];
178
+ for (const p of planned) {
179
+ if (await exists(p.target)) {
180
+ const current = await readFile(p.target, 'utf8');
181
+ if (current === p.content) {
182
+ skipped.push(p.target);
183
+ continue;
184
+ }
185
+ if (!args.force) {
186
+ console.error(`refusing to overwrite modified file (use --force): ${p.target}`);
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+ }
191
+ writes.push(p);
192
+ }
193
+
194
+ // Copies are idempotent (re-running converges on the same closure), so a
195
+ // mid-apply failure surfaces as an explicit partial-state report rather
196
+ // than staging/rollback machinery.
197
+ const written = [];
198
+ for (const w of writes) {
199
+ try {
200
+ await guardedWrite(targetRoot, w.target, w.content);
201
+ } catch (err) {
202
+ for (const t of written) console.warn(`wrote ${t}`);
203
+ console.error(
204
+ `partial write: ${written.length} of ${writes.length} files written before the failure — fix the cause and re-run to complete the closure`
205
+ );
206
+ throw err;
207
+ }
208
+ written.push(w.target);
209
+ }
210
+
211
+ for (const t of written) console.warn(`wrote ${t}`);
212
+ for (const t of skipped) console.warn(`up to date ${t}`);
213
+
214
+ const needed = [...new Set(families.flatMap((f) => f.npmDeps))];
215
+ const missing = [];
216
+ const pkgPath = resolvePath('package.json');
217
+ if (await exists(pkgPath)) {
218
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
219
+ const have = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies };
220
+ for (const dep of needed) if (!(dep in have)) missing.push(dep);
221
+ } else {
222
+ missing.push(...needed);
223
+ }
224
+ if (missing.length > 0) {
225
+ if (args.install) {
226
+ console.warn(`installing: ${missing.join(' ')}`);
227
+ // Copy-in means choosing to trust the registry's code, but install-time
228
+ // lifecycle scripts are a surprise nobody opted into: suppress them.
229
+ const r = spawnSync('npm', ['install', '--ignore-scripts', ...missing], {
230
+ stdio: 'inherit',
231
+ });
232
+ if (r.status !== 0) process.exitCode = r.status ?? 1;
233
+ } else {
234
+ console.warn(`missing npm deps — run: npm install ${missing.join(' ')}`);
235
+ }
236
+ }
237
+ }
238
+
239
+ function markerOf(content) {
240
+ const first = content.split('\n', 1)[0] ?? '';
241
+ const m = /^\/\/ brepjs-family: ([a-z0-9-]+)@(\d+)$/.exec(first);
242
+ return m ? { name: m[1], version: Number(m[2]) } : null;
243
+ }
244
+
245
+ async function diff(args) {
246
+ const [name] = args._;
247
+ const manifest = await loadManifest(args.registry);
248
+ const fam = familyByName(manifest, name);
249
+ let dirty = false;
250
+ for (const file of fam.files) {
251
+ const registryContent = await fetchText(args.registry, file);
252
+ const target = join(resolvePath(args.dir), file.replace(/^families\//, ''));
253
+ const stat = await lstat(target).catch(() => null);
254
+ if (stat === null) {
255
+ console.error(`not copied in: ${target}`);
256
+ dirty = true;
257
+ continue;
258
+ }
259
+ // Never read or diff through a symlink: in CI, a committed link could
260
+ // leak whatever readable file it points at into the job output.
261
+ if (stat.isSymbolicLink()) {
262
+ console.error(`${target}: is a symlink — refusing to diff through it`);
263
+ dirty = true;
264
+ continue;
265
+ }
266
+ // Same for symlinked parent directories: the fully resolved path must
267
+ // stay inside the project, or a committed dir link exfiltrates files
268
+ // beyond the checkout.
269
+ const projectRoot = await realpath(process.cwd());
270
+ const real = await realpath(target);
271
+ if (real !== projectRoot && !real.startsWith(projectRoot + sep)) {
272
+ console.error(`${target}: resolves outside the project — refusing to diff`);
273
+ dirty = true;
274
+ continue;
275
+ }
276
+ const local = await readFile(target, 'utf8');
277
+ const localMarker = markerOf(local);
278
+ if (localMarker && localMarker.version !== fam.version) {
279
+ console.warn(`${target}: local ${name}@${localMarker.version}, registry ${name}@${fam.version}`);
280
+ }
281
+ if (local === registryContent) {
282
+ console.warn(`${target}: up to date`);
283
+ continue;
284
+ }
285
+ dirty = true;
286
+ const r = spawnSync('git', ['diff', '--no-index', '--', target, '/dev/stdin'], {
287
+ input: registryContent,
288
+ stdio: ['pipe', 'inherit', 'inherit'],
289
+ });
290
+ if (r.error) console.error(`${target}: differs from registry (git unavailable for a diff)`);
291
+ }
292
+ if (dirty) process.exitCode = 1;
293
+ }
294
+
295
+ async function main() {
296
+ const [cmd, ...rest] = process.argv.slice(2);
297
+ const args = parseArgs(rest);
298
+ if (cmd === 'add' && args._.length > 0) return add(args);
299
+ if (cmd === 'diff' && args._.length === 1) return diff(args);
300
+ console.error(
301
+ 'usage: brepjs add <family...> [--registry <url|path>] [--dir <path>] [--force] [--install]\n' +
302
+ ' brepjs diff <family> [--registry <url|path>] [--dir <path>]'
303
+ );
304
+ process.exitCode = 2;
305
+ }
306
+
307
+ main().catch((err) => {
308
+ console.error(`brepjs: ${err instanceof Error ? err.message : String(err)}`);
309
+ process.exitCode = 1;
310
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brepjs",
3
- "version": "18.138.0",
3
+ "version": "18.140.0",
4
4
  "description": "Web CAD library with pluggable geometry kernel",
5
5
  "keywords": [
6
6
  "cad",
@@ -207,8 +207,12 @@
207
207
  }
208
208
  }
209
209
  },
210
+ "bin": {
211
+ "brepjs": "bin/brepjs.mjs"
212
+ },
210
213
  "files": [
211
- "dist"
214
+ "dist",
215
+ "bin"
212
216
  ],
213
217
  "scripts": {
214
218
  "build": "vite build && node scripts/build-quick.js",