@loomwork/create-loom-app 0.0.0-stage → 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/src/index.ts ADDED
@@ -0,0 +1,463 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { appFiles, isValidPackageName, packageNameFrom } from "@loomwork/scaffold";
5
+
6
+ /**
7
+ * The file map for a new Loom app (folder convention, wired via defineConfig).
8
+ * Templates live in `@loomwork/scaffold`, shared with `loom new`. Re-exported here as
9
+ * `scaffoldFiles` for backwards compatibility.
10
+ */
11
+ export const scaffoldFiles = appFiles;
12
+
13
+ const USAGE = `usage: create-loom-app <directory> [--loom-path <loom checkout>] [--name <package>] [--force]
14
+
15
+ <directory> new app folder; refused when it already has files (unless --force)
16
+ --loom-path your built loom checkout. @loomwork/* is not published, so an app created
17
+ OUTSIDE the loom pnpm workspace links to it (link: + pnpm overrides).
18
+ Not needed inside the workspace (apps/<name> gets workspace:*).
19
+ --name npm package name, may be scoped (default: the folder name, which must be a valid one)
20
+ --force scaffold into a non-empty folder: overwrites only the files this generates,
21
+ never deletes anything`;
22
+
23
+ /** A refusal: nothing has been written; exit code 2 (bad usage / refused, distinct from a crash). */
24
+ class Refusal extends Error {}
25
+
26
+ // ── Where is the target? ─────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Glob (pnpm-workspace `packages:` syntax: `*` = one segment, `**` = any depth) → RegExp.
30
+ * `**` also matches zero segments: `**\/foo` matches top-level `foo`, `packages/**` matches
31
+ * `packages` itself.
32
+ */
33
+ export function globToRegExp(glob: string): RegExp {
34
+ const segs = glob.replace(/^\.\//, "").replace(/\/+$/, "").split("/");
35
+ let re = "";
36
+ segs.forEach((seg, i) => {
37
+ const last = i === segs.length - 1;
38
+ if (seg === "**") {
39
+ // zero or more whole segments
40
+ re += last ? (i === 0 ? ".*" : "(?:/.*)?") : "(?:.*/)?";
41
+ return;
42
+ }
43
+ const part = seg.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
44
+ const prevGlobstar = i > 0 && segs[i - 1] === "**";
45
+ re += (i === 0 || prevGlobstar ? "" : "/") + part;
46
+ });
47
+ return new RegExp(`^${re}$`);
48
+ }
49
+
50
+ function unquote(raw: string): string {
51
+ const t = raw.trim();
52
+ const m = /^(?:'([^']*)'|"([^"]*)"|([^\s#'"]+))\s*(?:#.*)?$/.exec(t);
53
+ if (!m) throw new Refusal(`cannot parse pnpm-workspace.yaml entry ${JSON.stringify(raw)}`);
54
+ return (m[1] ?? m[2] ?? m[3]) as string;
55
+ }
56
+
57
+ /**
58
+ * The `packages:` globs of a pnpm-workspace.yaml (the only key we need; no YAML dependency).
59
+ * `undefined` = no `packages:` key (pnpm then treats every directory as a package). Anything we
60
+ * cannot read with confidence throws a {@link Refusal} rather than guessing "not a member".
61
+ */
62
+ export function workspaceGlobs(yaml: string): string[] | undefined {
63
+ const lines = yaml.split(/\r?\n/);
64
+ const idx = lines.findIndex((l) => /^packages\s*:/.test(l));
65
+ if (idx < 0) return undefined;
66
+ const fail = (): never => {
67
+ throw new Refusal(
68
+ "cannot confidently parse the `packages:` list of the enclosing pnpm-workspace.yaml; refusing to guess whether the target is a workspace member. Scaffold outside that workspace."
69
+ );
70
+ };
71
+ const rest = (lines[idx] as string).replace(/^packages\s*:/, "").trim();
72
+ if (rest !== "" && !rest.startsWith("#")) {
73
+ // Inline / flow form, possibly spanning lines: packages: ['a/*', "b"]
74
+ if (!rest.startsWith("[")) return fail();
75
+ let text = rest;
76
+ for (let j = idx + 1; !text.includes("]") && j < lines.length; j++) text += ` ${lines[j]}`;
77
+ const m = /^\[([^\]]*)\]\s*(?:#.*)?$/.exec(text);
78
+ if (!m) return fail();
79
+ const items = (m[1] as string)
80
+ .split(",")
81
+ .map((x) => x.trim())
82
+ .filter((x) => x !== "");
83
+ return items.map(unquote);
84
+ }
85
+ const out: string[] = [];
86
+ for (let j = idx + 1; j < lines.length; j++) {
87
+ const line = lines[j] as string;
88
+ if (/^\s*(#.*)?$/.test(line)) continue;
89
+ if (/^\S/.test(line)) break; // next top-level key
90
+ const m = /^\s*-\s*(.*)$/.exec(line);
91
+ if (!m) return fail();
92
+ out.push(unquote(m[1] as string));
93
+ }
94
+ if (out.length === 0) return fail();
95
+ return out;
96
+ }
97
+
98
+ export interface WorkspaceContext {
99
+ /** The nearest pnpm workspace that lists the target as a member, if any. */
100
+ root: string | undefined;
101
+ /** Whether that workspace is the loom checkout itself (so `workspace:*` resolves). */
102
+ isLoom: boolean;
103
+ }
104
+
105
+ function readName(manifest: string): string | undefined {
106
+ try {
107
+ return (JSON.parse(fs.readFileSync(manifest, "utf8")) as { name?: string }).name;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Walk up from `target` for a `pnpm-workspace.yaml` whose globs include it. Inside the loom
115
+ * checkout, `@loomwork/*` as `workspace:*` resolves; anywhere else it cannot.
116
+ */
117
+ export function findWorkspace(target: string): WorkspaceContext {
118
+ const abs = path.resolve(target);
119
+ for (let dir = abs; ; dir = path.dirname(dir)) {
120
+ const file = path.join(dir, "pnpm-workspace.yaml");
121
+ if (fs.existsSync(file)) {
122
+ const rel = path.relative(dir, abs).split(path.sep).join("/");
123
+ const globs = workspaceGlobs(fs.readFileSync(file, "utf8"));
124
+ const member =
125
+ rel === "" ||
126
+ globs === undefined ||
127
+ (globs.some((g) => !g.startsWith("!") && globToRegExp(g).test(rel)) &&
128
+ !globs.some((g) => g.startsWith("!") && globToRegExp(g.slice(1)).test(rel)));
129
+ if (member) {
130
+ const isLoom =
131
+ readName(path.join(dir, "packages", "core", "package.json")) === "@loomwork/core";
132
+ return { root: dir, isLoom };
133
+ }
134
+ }
135
+ if (path.dirname(dir) === dir) return { root: undefined, isLoom: false };
136
+ }
137
+ }
138
+
139
+ // ── A loom checkout to link to ───────────────────────────────────────────────
140
+
141
+ /** Every `@loomwork/*` package in a loom checkout: name → absolute directory. */
142
+ export function loomPackages(loomRoot: string): Map<string, string> {
143
+ const out = new Map<string, string>();
144
+ for (const tier of ["packages", "plugins", "tools"]) {
145
+ const base = path.join(loomRoot, tier);
146
+ if (!fs.existsSync(base)) continue;
147
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
148
+ if (!entry.isDirectory()) continue;
149
+ const dir = path.join(base, entry.name);
150
+ const name = readName(path.join(dir, "package.json"));
151
+ if (name?.startsWith("@loomwork/")) out.set(name, dir);
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** `path.resolve` + `realpath` of the deepest existing ancestor, so `/var` vs `/private/var` can't skew a relative link. */
158
+ function realResolve(p: string): string {
159
+ const abs = path.resolve(p);
160
+ const tail: string[] = [];
161
+ let cur = abs;
162
+ while (!fs.existsSync(cur) && path.dirname(cur) !== cur) {
163
+ tail.unshift(path.basename(cur));
164
+ cur = path.dirname(cur);
165
+ }
166
+ return path.join(fs.realpathSync(cur), ...tail);
167
+ }
168
+
169
+ /** Resolve and validate `--loom-path`: a loom checkout whose packages are built. */
170
+ function resolveLoomPath(raw: string, cwd: string): string {
171
+ const root = realResolve(path.resolve(cwd, raw));
172
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
173
+ throw new Refusal(`--loom-path "${raw}" is not a directory (resolved ${root})`);
174
+ }
175
+ if (
176
+ !fs.existsSync(path.join(root, "pnpm-workspace.yaml")) ||
177
+ readName(path.join(root, "packages", "core", "package.json")) !== "@loomwork/core"
178
+ ) {
179
+ throw new Refusal(
180
+ `--loom-path "${raw}" is not a loom checkout (no pnpm-workspace.yaml with packages/core = @loomwork/core). Clone https://github.com/chhetrisushil/loom.git and point at its root.`
181
+ );
182
+ }
183
+ const pkgs = loomPackages(root);
184
+ const unbuilt = ["@loomwork/core", "@loomwork/app", "@loomwork/cli", "@loomwork/testing"].filter(
185
+ (n) => {
186
+ const dir = pkgs.get(n);
187
+ return dir === undefined || !fs.existsSync(path.join(dir, "dist"));
188
+ }
189
+ );
190
+ if (unbuilt.length > 0) {
191
+ throw new Refusal(
192
+ `the loom checkout at ${root} is not built (no dist/ in ${unbuilt.join(", ")}). Run \`pnpm install && pnpm build\` there first.`
193
+ );
194
+ }
195
+ return root;
196
+ }
197
+
198
+ /**
199
+ * Rewrite the scaffolded `package.json` for an app outside the loom workspace: `@loomwork/*`
200
+ * dependencies become `*`, and `pnpm.overrides` redirects EVERY loom package to a `link:` into
201
+ * the checkout. All of them, not just the app's direct ones: a `link:` dependency does not bring
202
+ * its own `workspace:*` dependencies, so each transitive one must be mapped too — the same wiring
203
+ * `loom-examples` uses. (Everything, so a later `loom new sentinel` only adds `"…": "*"`.)
204
+ */
205
+ export function linkPackageJson(json: string, appRoot: string, loomRoot: string): string {
206
+ const pkg = JSON.parse(json) as {
207
+ dependencies: Record<string, string>;
208
+ devDependencies: Record<string, string>;
209
+ [k: string]: unknown;
210
+ };
211
+ for (const table of [pkg.dependencies, pkg.devDependencies]) {
212
+ for (const name of Object.keys(table)) if (name.startsWith("@loomwork/")) table[name] = "*";
213
+ }
214
+ const overrides: Record<string, string> = {};
215
+ for (const [name, dir] of [...loomPackages(loomRoot)].sort(([a], [b]) => a.localeCompare(b))) {
216
+ const rel = path.relative(appRoot, dir).split(path.sep).join("/");
217
+ overrides[name] = `link:${rel}`;
218
+ }
219
+ pkg.pnpm = { overrides };
220
+ return `${JSON.stringify(pkg, null, 2)}\n`;
221
+ }
222
+
223
+ // ── CLI ──────────────────────────────────────────────────────────────────────
224
+
225
+ interface Args {
226
+ target?: string;
227
+ loomPath?: string;
228
+ name?: string;
229
+ force: boolean;
230
+ help: boolean;
231
+ }
232
+
233
+ /** Quote a path for the printed `cd` hint when the shell would split or expand it. */
234
+ function shellQuote(s: string): string {
235
+ return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
236
+ }
237
+
238
+ function parseArgs(argv: string[]): Args {
239
+ const args: Args = { force: false, help: false };
240
+ for (let i = 0; i < argv.length; i++) {
241
+ const a = argv[i] as string;
242
+ const valued = (flag: string): string => {
243
+ if (a !== flag) return a.slice(flag.length + 1); // --flag=value
244
+ const v = argv[++i];
245
+ if (v === undefined || v.startsWith("-")) {
246
+ throw new Error(`create-loom-app: ${flag} needs a value\n${USAGE}`);
247
+ }
248
+ return v;
249
+ };
250
+ if (a === "--help" || a === "-h") args.help = true;
251
+ else if (a === "--force") args.force = true;
252
+ else if (a === "--loom-path" || a.startsWith("--loom-path="))
253
+ args.loomPath = valued("--loom-path");
254
+ else if (a === "--name" || a.startsWith("--name=")) args.name = valued("--name");
255
+ else if (a.startsWith("-")) {
256
+ // A flag is not a directory name. Without this, `create-loom-app --help` scaffolds eleven
257
+ // files into a folder literally called `--help` and reports success.
258
+ throw new Error(`create-loom-app: "${a}" is not a directory name\n${USAGE}`);
259
+ } else if (args.target === undefined) args.target = a;
260
+ else throw new Error(`create-loom-app: unexpected extra argument "${a}"\n${USAGE}`);
261
+ }
262
+ return args;
263
+ }
264
+
265
+ /** Refuse when any component of `dest` below `root` (the target itself included) is a symbolic link. */
266
+ export function assertNoLinkBelow(root: string, dest: string): void {
267
+ const rel = path.relative(path.resolve(root), path.resolve(dest));
268
+ let cur = path.resolve(root);
269
+ for (const seg of rel.split(path.sep)) {
270
+ cur = path.join(cur, seg);
271
+ let st: fs.Stats;
272
+ try {
273
+ st = fs.lstatSync(cur);
274
+ } catch {
275
+ return; // nothing deeper exists
276
+ }
277
+ if (st.isSymbolicLink()) {
278
+ throw new Refusal(
279
+ `refusing to write through the symbolic link "${path.relative(root, cur)}" - replace it with a real file or directory, or scaffold into a clean directory`
280
+ );
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Refuse a target that is, or lies below, a symbolic link under the project (cwd). Decided by FILESYSTEM
287
+ * IDENTITY (st_dev + st_ino), not path spelling. (1) The target's final component is never a link
288
+ * (the cwd itself is exempt: it is trusted even when reached through a link). (2) The nearest ancestor,
289
+ * in the spelling used, whose identity is the cwd's is the root: every component between it and the
290
+ * target is lstat-checked. (3) No spelled ancestor matches but the target's PHYSICAL parent chain
291
+ * reaches the cwd: refuse (a spelling the walk cannot cover). (4) Otherwise the target is genuinely
292
+ * outside the cwd and only its own final component is checked. A small copy of `containmentRootFor`
293
+ * from @loomwork/appgen (create-loom-app depends only on @loomwork/scaffold; no new edge).
294
+ */
295
+ export function assertTargetNotLinked(target: string, cwd: string): void {
296
+ const stat = (p: string): fs.Stats | undefined => {
297
+ try {
298
+ return fs.statSync(p);
299
+ } catch {
300
+ return undefined;
301
+ }
302
+ };
303
+ const same = (a: fs.Stats, b: fs.Stats) => a.dev === b.dev && a.ino === b.ino;
304
+ const abs = path.resolve(cwd, target);
305
+ const cwdId = stat(cwd);
306
+ const absId = stat(abs);
307
+ if (cwdId !== undefined && absId !== undefined && same(absId, cwdId)) return;
308
+ let isLink = false;
309
+ try {
310
+ isLink = fs.lstatSync(abs).isSymbolicLink();
311
+ } catch {
312
+ /* not there yet */
313
+ }
314
+ if (isLink) {
315
+ throw new Refusal(
316
+ `"${target}" is a symbolic link (${abs}); pass the real path of the directory, or scaffold into a real directory`
317
+ );
318
+ }
319
+ if (cwdId === undefined) return;
320
+ for (let a = path.dirname(abs); ; a = path.dirname(a)) {
321
+ const st = stat(a);
322
+ if (st !== undefined && same(st, cwdId)) {
323
+ assertNoLinkBelow(a, abs);
324
+ return;
325
+ }
326
+ if (path.dirname(a) === a) break;
327
+ }
328
+ let p = abs;
329
+ while (stat(p) === undefined && path.dirname(p) !== p) p = path.dirname(p);
330
+ for (let cur = p; ; ) {
331
+ const st = stat(cur);
332
+ if (st === undefined) return;
333
+ if (same(st, cwdId)) {
334
+ throw new Refusal(
335
+ `"${target}" resolves inside the project through a spelling the symlink check cannot walk; pass a path relative to the project`
336
+ );
337
+ }
338
+ const up = stat(path.join(cur, ".."));
339
+ if (up === undefined || same(up, st)) return; // reached "/": genuinely outside
340
+ cur = path.join(cur, "..");
341
+ }
342
+ }
343
+
344
+ /** Runs the CLI; returns the exit code. Every Refusal happens before the first write. */
345
+ export function run(argv: string[], cwd: string): number {
346
+ let args: Args;
347
+ try {
348
+ args = parseArgs(argv);
349
+ } catch (err) {
350
+ console.error((err as Error).message);
351
+ return 2;
352
+ }
353
+ if (args.help) {
354
+ console.log(USAGE);
355
+ return 0;
356
+ }
357
+ if (args.target === undefined) {
358
+ console.error(USAGE);
359
+ return 2;
360
+ }
361
+ const target = args.target;
362
+ try {
363
+ assertTargetNotLinked(target, cwd);
364
+ const root = realResolve(path.resolve(cwd, target));
365
+
366
+ // The package name: a valid npm name, or refuse with the name we would have used. Taken from
367
+ // the folder as TYPED (a symlinked target is refused above).
368
+ const dirName = path.basename(path.resolve(cwd, target));
369
+ const pkgName = args.name ?? dirName;
370
+ if (!isValidPackageName(pkgName)) {
371
+ const hint = packageNameFrom(args.name ?? dirName);
372
+ throw new Refusal(
373
+ `"${pkgName}" is not a valid npm package name (lowercase letters, digits, - . _ ~, optionally @scope/; no spaces).${
374
+ hint
375
+ ? ` Try \`--name ${hint}\` or a folder called ${hint}.`
376
+ : " Pass a valid one with --name."
377
+ }`
378
+ );
379
+ }
380
+
381
+ // Never write into someone's project by accident.
382
+ let existing = 0;
383
+ if (fs.existsSync(root)) {
384
+ if (!fs.statSync(root).isDirectory())
385
+ throw new Refusal(`${root} exists and is not a directory`);
386
+ const entries = fs.readdirSync(root);
387
+ if (entries.length > 0 && !args.force) {
388
+ const shown = entries.slice(0, 5).join(", ") + (entries.length > 5 ? ", …" : "");
389
+ throw new Refusal(
390
+ `${target === "." ? "the current directory" : `"${target}"`} is not empty (${shown}). Pick a new directory, or pass --force to scaffold into it (that overwrites only the files this generates and never deletes anything).`
391
+ );
392
+ }
393
+ existing = entries.length;
394
+ }
395
+
396
+ // Inside the loom workspace `workspace:*` resolves; anywhere else it cannot (unpublished).
397
+ const ws = findWorkspace(root);
398
+ let loomRoot: string | undefined;
399
+ if (ws.root !== undefined && !ws.isLoom) {
400
+ throw new Refusal(
401
+ `"${target}" is a member of the pnpm workspace at ${ws.root}, which is not a loom checkout. pnpm reads \`overrides\` only from a workspace root, so a linked app cannot live there — scaffold outside that workspace.`
402
+ );
403
+ }
404
+ if (!ws.isLoom) {
405
+ if (args.loomPath === undefined) {
406
+ throw new Refusal(
407
+ `@loomwork/* packages are not published to npm yet, so an app outside the loom workspace must link to a built loom checkout.\n create-loom-app ${shellQuote(target)} --loom-path <path to your loom checkout>\n(build it first: \`pnpm install && pnpm build\` there). Or scaffold inside it, e.g. <loom>/apps/<name>, which needs no flag.`
408
+ );
409
+ }
410
+ loomRoot = resolveLoomPath(args.loomPath, cwd);
411
+ }
412
+
413
+ const files = appFiles(pkgName);
414
+ if (loomRoot !== undefined) {
415
+ files["package.json"] = linkPackageJson(files["package.json"] as string, root, loomRoot);
416
+ }
417
+
418
+ // `--force` into an existing directory: a planted `package.json -> ~/.x` (or a linked `src/`) must not be
419
+ // written through. Check EVERY target first so a refusal leaves the directory untouched.
420
+ for (const rel of Object.keys(files)) assertNoLinkBelow(root, path.join(root, rel));
421
+ let overwritten = 0;
422
+ for (const [rel, content] of Object.entries(files)) {
423
+ const dest = path.join(root, rel);
424
+ if (fs.existsSync(dest)) overwritten++;
425
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
426
+ assertNoLinkBelow(root, dest);
427
+ const tmp = `${dest}.${process.pid}.tmp`;
428
+ try {
429
+ fs.writeFileSync(tmp, content, { flag: "wx" });
430
+ fs.renameSync(tmp, dest); // replaces the entry; never follows a link at the final component
431
+ } catch (e) {
432
+ fs.rmSync(tmp, { force: true });
433
+ throw e;
434
+ }
435
+ }
436
+ console.log(`Scaffolded ${Object.keys(files).length} files into ${target}/`);
437
+ if (args.force && existing > 0) {
438
+ console.log(
439
+ `--force: overwrote ${overwritten} existing generated file(s); every other file in the directory was left untouched (nothing is ever deleted).`
440
+ );
441
+ }
442
+ console.log(
443
+ loomRoot !== undefined
444
+ ? `Linked @loomwork/* to ${loomRoot} (link: + pnpm overrides in package.json).`
445
+ : "Inside the loom workspace: @loomwork/* is workspace:* (run `pnpm install` from the repo root or this app)."
446
+ );
447
+ const cd = target === "." ? "" : `cd ${shellQuote(target)} && `;
448
+ console.log(`Next: ${cd}pnpm install && pnpm test && pnpm start`);
449
+ return 0;
450
+ } catch (err) {
451
+ if (!(err instanceof Refusal)) throw err;
452
+ console.error(`create-loom-app: ${err.message}`);
453
+ return 2;
454
+ }
455
+ }
456
+
457
+ // Run only when invoked as a CLI (not when imported by tests).
458
+ // `require.main === module`, not a filename check: `npm create loom-app` and any global
459
+ // install invoke this through a symlink named `create-loom-app`, where argv[1] never ends
460
+ // in "index.js" — the scaffolder would exit 0 having created nothing.
461
+ if (require.main === module) {
462
+ process.exitCode = run(process.argv.slice(2), process.cwd());
463
+ }