@murumets-ee/create 0.44.0 → 0.47.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/dist/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import*as e from"@clack/prompts";import t from"picocolors";import{existsSync as n,readFileSync as r}from"node:fs";import{lstat as i,mkdir as a,readFile as o,readdir as s,rename as c,writeFile as l}from"node:fs/promises";import{basename as u,dirname as d,join as f,resolve as p,sep as m}from"node:path";import{fileURLToPath as h}from"node:url";import{extract as g}from"tar";import{createHash as _}from"node:crypto";import{createServer as v}from"node:net";import{execFileSync as y}from"node:child_process";const b=/^[a-z0-9][a-z0-9-]{0,213}$/,x=new Set([`admin`,`front`]);function S(e){if(!e)return`Project name is required`;if(!b.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`;if(x.has(e))return`"${e}" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`}function C(e,t){if(e===`demo`&&!t)return"Preset `demo` installs plugins via `lumi add` after deps resolve, so --install must be true (or pick `minimal`)."}function w(e){let t={},n=e.slice(2);for(let e=0;e<n.length;e++){let r=n[e];if(r!==void 0)if(r===`--full`)t.preset=`demo`;else if(r===`--preset`&&n[e+1]){let r=n[++e];(r===`minimal`||r===`demo`)&&(t.preset=r)}else if(r.startsWith(`--preset=`)){let e=r.slice(9);(e===`minimal`||e===`demo`)&&(t.preset=e)}else r===`--install`?t.install=!0:r===`--no-install`?t.install=!1:!r.startsWith(`-`)&&!t.name&&(t.name=r)}return t}async function T(t){let n=w(process.argv),r=n.name??t;if(r!==void 0){let t=S(r);if(t)return e.intro(`@murumets-ee/create`),e.cancel(t),null}if(n.preset!==void 0&&n.install!==void 0){let t=C(n.preset,n.install);if(t)return e.intro(`@murumets-ee/create`),e.cancel(t),null}if(r&&n.install!==void 0&&n.preset!==void 0)return{name:r,installDeps:n.install,preset:n.preset};e.intro(`@murumets-ee/create`);let i=r??await e.text({message:`Project name:`,placeholder:`my-project`,validate:S});if(e.isCancel(i))return e.cancel(`Cancelled.`),null;let a=n.preset??await e.select({message:`Preset:`,options:[{value:`demo`,label:`Demo`,hint:`auth + media + ticketing (recommended for first-time setup)`},{value:`minimal`,label:`Minimal`,hint:"auth + infrastructure only — add plugins with `lumi add`"}],initialValue:`demo`});if(e.isCancel(a))return e.cancel(`Cancelled.`),null;let o=n.install??await e.confirm({message:`Install dependencies?`,initialValue:!0});if(e.isCancel(o))return e.cancel(`Cancelled.`),null;let s=C(a,o);return s?(e.cancel(s),null):{name:i,installDeps:o,preset:a}}const E=5500;function D(e){return E+_(`sha1`).update(e).digest().readUInt32BE(0)%500}async function O(e){return new Promise(t=>{let n=v(),r=!1,i=e=>{r||(r=!0,n.close(),t(e))},a=setTimeout(()=>i(!1),500);n.once(`error`,()=>{clearTimeout(a),i(!1)}),n.once(`listening`,()=>{clearTimeout(a),i(!0)}),n.listen(e,`127.0.0.1`)})}async function k(e){let t=D(e);for(let e=0;e<500;e++){let n=E+(t-E+e)%500;if(await O(n))return n}return t}const A=process.platform===`win32`;function j(e,t,n){return y(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:A})}function M(e,t,n){y(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:A})}const N=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),P=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),F=`__PROJECT_NAME__`,I=`__PROJECT_NAME_TITLE__`,L=`__POSTGRES_HOST_PORT__`;async function R(e,t){await z(e,t)}async function z(e,t){let n=S(e.name);if(n)throw Error(n);let r=C(e.preset,e.installDeps);if(r)throw Error(r);let i=f(process.cwd(),e.name),a=p(process.cwd()),o=p(i);if(o!==a&&!o.startsWith(a+m))throw Error(`Project directory ${o} resolves outside the current working directory ${a}. Refusing to write.`);t?.(`Extracting template...`),await U(i),await G(i),t?.(`Personalizing template...`);let s=await k(e.name);if(await q(i,e.name,s),await J(i,e.name),e.installDeps&&(t?.(`Installing dependencies...`),j(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`){let e=f(i,...`apps/admin`.split(`/`)),n=[...new Set(V.flatMap(e=>H[e]))];t?.(`Installing demo-preset packages...`),await B(i,e,n);for(let n of V)t?.(`Adding ${n} plugin...`),M(`pnpm`,[`exec`,`lumi`,`add`,n],{cwd:e})}}async function B(e,t,n){let r=f(e,`pnpm-workspace.yaml`),i=await o(r,`utf-8`),a=i.match(/^\s+'?@murumets-ee\/[a-z-]+'?:\s*([0-9][^\s]*)\s*$/m);if(!a?.[1]){M(`pnpm`,[`add`,...n],{cwd:t});return}let s=a[1],c=n.filter(e=>!RegExp(`'?${e}'?:`).test(i));if(c.length>0){let e=c.map(e=>` '${e}': ${s}`).join(`
3
- `),a=i.replace(/^( {2}lumi:)([ \t]*\{\})?$/m,`$1\n${e}`);if(a===i){M(`pnpm`,[`add`,...n],{cwd:t});return}await l(r,a,`utf-8`)}let u=f(t,`package.json`),d=JSON.parse(await o(u,`utf-8`));d.dependencies??={};for(let e of n)d.dependencies[e]=`catalog:lumi`;d.dependencies=Object.fromEntries(Object.entries(d.dependencies).sort(([e],[t])=>e.localeCompare(t))),await l(u,`${JSON.stringify(d,null,2)}\n`,`utf-8`),M(`pnpm`,[`install`,`--no-frozen-lockfile`],{cwd:t})}const V=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`,`rent`],H={taxonomy:[`@murumets-ee/taxonomy`],blog:[`@murumets-ee/content`],media:[`@murumets-ee/media`],ticketing:[`@murumets-ee/ticketing-ui`,`@murumets-ee/ticketing`],commerce:[`@murumets-ee/commerce-ui`],rent:[`@murumets-ee/rent-ui`,`@murumets-ee/rent`]};async function U(e){try{await a(e);return}catch(e){if(e.code!==`EEXIST`)throw e}await W(e)}async function W(e){if(!n(e))return;let t=await i(e);if(t.isSymbolicLink())throw Error(`${e} is a symbolic link. Refusing to scaffold through it — the template would be written to wherever it points, which is outside the directory you named. Remove the link, or pick a different project name.`);if(!t.isDirectory())throw Error(`${e} already exists and is not a directory. Refusing to scaffold over it — pick a different project name, or remove the file first.`);if((await s(e)).length>0)throw Error(`${e} already exists and is not empty. Refusing to scaffold over it — pick a different project name, or remove the directory first.`)}async function G(e){let t=K();if(!n(t))throw Error(`Template tarball missing: ${t}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await g({file:t,cwd:e,filter:e=>{let t=e.startsWith(`/`)||/^[A-Za-z]:[/\\]/.test(e),n=e.split(/[/\\]/).includes(`..`);return t||n?(console.warn(`@murumets-ee/create: refusing tar entry with traversal: ${e}`),!1):!0}})}function K(){let e=d(h(import.meta.url)),t=e;for(let e=0;e<5;e++){let e=f(t,`package.json`);if(n(e))try{if(JSON.parse(r(e,`utf-8`)).name===`@murumets-ee/create`)return f(t,`templates`,`template-demo.tar.gz`)}catch(t){console.warn(`@murumets-ee/create: skipped malformed package.json at ${e}: ${t instanceof Error?t.message:String(t)}`)}let i=d(t);if(i===t)break;t=i}throw Error(`Could not find @murumets-ee/create package root above ${e}. template-demo.tar.gz cannot be located.`)}async function q(e,t,n){let r=Q(t),i=String(n);for await(let n of X(e)){if(!Z(n))continue;let e=await o(n,`utf-8`);!e.includes(F)&&!e.includes(I)&&!e.includes(L)||await l(n,e.split(I).join(r).split(F).join(t).split(L).join(i),`utf-8`)}}async function J(e,t){let n=[];async function r(e){let i=await s(e,{withFileTypes:!0});for(let a of i){if(!a.isDirectory()||Y.has(a.name))continue;let i=f(e,a.name);await r(i),a.name.includes(F)&&n.push({from:i,to:f(e,a.name.split(F).join(t))})}}await r(e);for(let{from:e,to:t}of n)await c(e,t)}const Y=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*X(e){let t=await s(e,{withFileTypes:!0});for(let n of t){let t=f(e,n.name);if(n.isDirectory()){if(Y.has(n.name))continue;yield*X(t)}else n.isFile()&&(yield t)}}function Z(e){let t=u(e);if(P.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return N.has(r)}function Q(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}async function $(){let n=process.argv[2],r=await T(n);if(!r)return;let i=e.spinner();try{i.start(`Extracting template...`),await R(r,e=>{i.stop(`${t.green(`✓`)} Done`),i.start(e)}),i.stop(`${t.green(`✓`)} Done`);let n=[`cd ${r.name}`,`cp .env.example .env`,`# Edit .env — set DATABASE_URL and BETTER_AUTH_SECRET`,`cp .env apps/admin/.env`,`# The root .env is what docker compose reads; each app reads its own`,`# when you run it directly. Same file, two places.`,`docker compose up -d`,`pnpm db:migrate`,`pnpm --filter admin exec lumi create-admin`,`# Bootstraps the first admin user. Public sign-up is closed by default.`,`pnpm dev`,`# Sign in at /auth/sign-in with the admin credentials you just created`,`# Settings -> Site Settings -> Home Document picks what serves at / —`,`# unset, / 404s even though the page has its own working slug.`,``,`# The public Astro site is a second app:`,`cp .env apps/front/.env`,`# CONTENT_API_ORIGIN must carry the /api/content segment — the bare`,`# origin hits the CMS i18n middleware and the site 503s on every page.`,`pnpm dev:front`];e.note(n.join(`
2
+ import*as e from"@clack/prompts";import t from"picocolors";import{existsSync as n,readFileSync as r}from"node:fs";import{lstat as i,mkdir as a,readFile as o,readdir as s,rename as c,writeFile as l}from"node:fs/promises";import{basename as u,dirname as d,join as f,resolve as p,sep as m}from"node:path";import{fileURLToPath as h}from"node:url";import{extract as g}from"tar";import{createHash as _}from"node:crypto";import{createServer as v}from"node:net";import{execFileSync as y}from"node:child_process";const b=/^[a-z0-9][a-z0-9-]{0,213}$/,x=new Set([`admin`,`front`]);function S(e){if(!e)return`Project name is required`;if(!b.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`;if(x.has(e))return`"${e}" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`}function C(e,t){if(e===`demo`&&!t)return"Preset `demo` installs plugins via `lumi add` after deps resolve, so --install must be true (or pick `minimal`)."}function w(e){let t={},n=e.slice(2);for(let e=0;e<n.length;e++){let r=n[e];if(r!==void 0)if(r===`--full`)t.preset=`demo`;else if(r===`--preset`&&n[e+1]){let r=n[++e];(r===`minimal`||r===`demo`)&&(t.preset=r)}else if(r.startsWith(`--preset=`)){let e=r.slice(9);(e===`minimal`||e===`demo`)&&(t.preset=e)}else r===`--install`?t.install=!0:r===`--no-install`?t.install=!1:!r.startsWith(`-`)&&!t.name&&(t.name=r)}return t}async function T(t){let n=w(process.argv),r=n.name??t;if(r!==void 0){let t=S(r);if(t)return e.intro(`@murumets-ee/create`),e.cancel(t),null}if(n.preset!==void 0&&n.install!==void 0){let t=C(n.preset,n.install);if(t)return e.intro(`@murumets-ee/create`),e.cancel(t),null}if(r&&n.install!==void 0&&n.preset!==void 0)return{name:r,installDeps:n.install,preset:n.preset};e.intro(`@murumets-ee/create`);let i=r??await e.text({message:`Project name:`,placeholder:`my-project`,validate:S});if(e.isCancel(i))return e.cancel(`Cancelled.`),null;let a=n.preset??await e.select({message:`Preset:`,options:[{value:`demo`,label:`Demo`,hint:`auth + media + ticketing (recommended for first-time setup)`},{value:`minimal`,label:`Minimal`,hint:"auth + infrastructure only — add plugins with `lumi add`"}],initialValue:`demo`});if(e.isCancel(a))return e.cancel(`Cancelled.`),null;let o=n.install??await e.confirm({message:`Install dependencies?`,initialValue:!0});if(e.isCancel(o))return e.cancel(`Cancelled.`),null;let s=C(a,o);return s?(e.cancel(s),null):{name:i,installDeps:o,preset:a}}const E=5500;function D(e){return E+_(`sha1`).update(e).digest().readUInt32BE(0)%500}async function O(e){return new Promise(t=>{let n=v(),r=!1,i=e=>{r||(r=!0,n.close(),t(e))},a=setTimeout(()=>i(!1),500);n.once(`error`,()=>{clearTimeout(a),i(!1)}),n.once(`listening`,()=>{clearTimeout(a),i(!0)}),n.listen(e,`127.0.0.1`)})}async function k(e){let t=D(e);for(let e=0;e<500;e++){let n=E+(t-E+e)%500;if(await O(n))return n}return t}const A=process.platform===`win32`;function j(e,t,n){return y(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:A})}function M(e,t,n){y(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:A})}const N=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),P=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),F=`__PROJECT_NAME__`,I=`__PROJECT_NAME_TITLE__`,L=`__POSTGRES_HOST_PORT__`;async function R(e,t){await z(e,t)}async function z(e,t){let n=S(e.name);if(n)throw Error(n);let r=C(e.preset,e.installDeps);if(r)throw Error(r);let i=f(process.cwd(),e.name),a=p(process.cwd()),o=p(i);if(o!==a&&!o.startsWith(a+m))throw Error(`Project directory ${o} resolves outside the current working directory ${a}. Refusing to write.`);t?.(`Extracting template...`),await U(i),await G(i),t?.(`Personalizing template...`);let s=await k(e.name);if(await q(i,e.name,s),await J(i,e.name),e.installDeps&&(t?.(`Installing dependencies...`),j(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`){let e=f(i,...`apps/admin`.split(`/`)),n=[...new Set(V.flatMap(e=>H[e]))];t?.(`Installing demo-preset packages...`),await B(i,e,n);for(let n of V)t?.(`Adding ${n} plugin...`),M(`pnpm`,[`exec`,`lumi`,`add`,n],{cwd:e})}}async function B(e,t,n){let r=n.filter(e=>e.startsWith(`@murumets-ee/`)),i=n.filter(e=>!e.startsWith(`@murumets-ee/`)),a=f(e,`pnpm-workspace.yaml`),s=await o(a,`utf-8`),c=s.match(/^\s+'?@murumets-ee\/[a-z-]+'?:\s*([0-9][^\s]*)\s*$/m);if(!c?.[1]){M(`pnpm`,[`add`,...n],{cwd:t});return}let u=c[1],d=r.filter(e=>!RegExp(`'?${e}'?:`).test(s));if(d.length>0){let e=d.map(e=>` '${e}': ${u}`).join(`
3
+ `),r=s.replace(/^( {2}lumi:)([ \t]*\{\})?$/m,`$1\n${e}`);if(r===s){M(`pnpm`,[`add`,...n],{cwd:t});return}await l(a,r,`utf-8`)}let p=f(t,`package.json`),m=JSON.parse(await o(p,`utf-8`));m.dependencies??={};for(let e of r)m.dependencies[e]=`catalog:lumi`;let h=i.filter(e=>!(e in(m.dependencies??{})));m.dependencies=Object.fromEntries(Object.entries(m.dependencies).sort(([e],[t])=>e.localeCompare(t))),await l(p,`${JSON.stringify(m,null,2)}\n`,`utf-8`),M(`pnpm`,[`install`,`--no-frozen-lockfile`],{cwd:t}),h.length>0&&M(`pnpm`,[`add`,...h],{cwd:t})}const V=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`,`rent`],H={taxonomy:[`@murumets-ee/taxonomy`],blog:[`@murumets-ee/content`,`@murumets-ee/blocks`],media:[`@murumets-ee/media`],ticketing:[`@murumets-ee/ticketing-ui`,`@murumets-ee/ticketing`],commerce:[`@murumets-ee/commerce-ui`,`@murumets-ee/commerce`,`@murumets-ee/search-elasticsearch`,`@elastic/elasticsearch`],rent:[`@murumets-ee/rent-ui`,`@murumets-ee/rent`]};async function U(e){try{await a(e);return}catch(e){if(e.code!==`EEXIST`)throw e}await W(e)}async function W(e){if(!n(e))return;let t=await i(e);if(t.isSymbolicLink())throw Error(`${e} is a symbolic link. Refusing to scaffold through it — the template would be written to wherever it points, which is outside the directory you named. Remove the link, or pick a different project name.`);if(!t.isDirectory())throw Error(`${e} already exists and is not a directory. Refusing to scaffold over it — pick a different project name, or remove the file first.`);if((await s(e)).length>0)throw Error(`${e} already exists and is not empty. Refusing to scaffold over it — pick a different project name, or remove the directory first.`)}async function G(e){let t=K();if(!n(t))throw Error(`Template tarball missing: ${t}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await g({file:t,cwd:e,filter:e=>{let t=e.startsWith(`/`)||/^[A-Za-z]:[/\\]/.test(e),n=e.split(/[/\\]/).includes(`..`);return t||n?(console.warn(`@murumets-ee/create: refusing tar entry with traversal: ${e}`),!1):!0}})}function K(){let e=d(h(import.meta.url)),t=e;for(let e=0;e<5;e++){let e=f(t,`package.json`);if(n(e))try{if(JSON.parse(r(e,`utf-8`)).name===`@murumets-ee/create`)return f(t,`templates`,`template-demo.tar.gz`)}catch(t){console.warn(`@murumets-ee/create: skipped malformed package.json at ${e}: ${t instanceof Error?t.message:String(t)}`)}let i=d(t);if(i===t)break;t=i}throw Error(`Could not find @murumets-ee/create package root above ${e}. template-demo.tar.gz cannot be located.`)}async function q(e,t,n){let r=Q(t),i=String(n);for await(let n of X(e)){if(!Z(n))continue;let e=await o(n,`utf-8`);!e.includes(F)&&!e.includes(I)&&!e.includes(L)||await l(n,e.split(I).join(r).split(F).join(t).split(L).join(i),`utf-8`)}}async function J(e,t){let n=[];async function r(e){let i=await s(e,{withFileTypes:!0});for(let a of i){if(!a.isDirectory()||Y.has(a.name))continue;let i=f(e,a.name);await r(i),a.name.includes(F)&&n.push({from:i,to:f(e,a.name.split(F).join(t))})}}await r(e);for(let{from:e,to:t}of n)await c(e,t)}const Y=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*X(e){let t=await s(e,{withFileTypes:!0});for(let n of t){let t=f(e,n.name);if(n.isDirectory()){if(Y.has(n.name))continue;yield*X(t)}else n.isFile()&&(yield t)}}function Z(e){let t=u(e);if(P.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return N.has(r)}function Q(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}async function $(){let n=process.argv[2],r=await T(n);if(!r)return;let i=e.spinner();try{i.start(`Extracting template...`),await R(r,e=>{i.stop(`${t.green(`✓`)} Done`),i.start(e)}),i.stop(`${t.green(`✓`)} Done`);let n=[`cd ${r.name}`,`cp .env.example .env`,`# Edit .env — set DATABASE_URL and BETTER_AUTH_SECRET`,`cp .env apps/admin/.env`,`# The root .env is what docker compose reads; each app reads its own`,`# when you run it directly. Same file, two places.`,`docker compose up -d`,`pnpm db:migrate`,`pnpm --filter admin exec lumi create-admin`,`# Bootstraps the first admin user. Public sign-up is closed by default.`,`pnpm dev`,`# Sign in at /auth/sign-in with the admin credentials you just created`,`# Settings -> Site Settings -> Home Document picks what serves at / —`,`# unset, / 404s even though the page has its own working slug.`,``,`# The public Astro site is a second app:`,`cp .env apps/front/.env`,`# CONTENT_API_ORIGIN must carry the /api/content segment — the bare`,`# origin hits the CMS i18n middleware and the site 503s on every page.`,`pnpm dev:front`];e.note(n.join(`
4
4
  `),`Next steps`),e.outro(`${t.green(`Success!`)} Your project is ready.`)}catch(n){i.stop(t.red(`Failed.`)),e.log.error(n instanceof Error?n.message:String(n)),process.exit(1)}}$();export{};
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import{existsSync as e,readFileSync as t}from"node:fs";import{lstat as n,mkdir as r,readFile as i,readdir as a,rename as o,writeFile as s}from"node:fs/promises";import{basename as c,dirname as l,join as u,resolve as d,sep as f}from"node:path";import{fileURLToPath as p}from"node:url";import{extract as m}from"tar";import{createHash as h}from"node:crypto";import{createServer as g}from"node:net";import"@clack/prompts";import{execFileSync as _}from"node:child_process";const v=5500;function y(e){return v+h(`sha1`).update(e).digest().readUInt32BE(0)%500}async function b(e){return new Promise(t=>{let n=g(),r=!1,i=e=>{r||(r=!0,n.close(),t(e))},a=setTimeout(()=>i(!1),500);n.once(`error`,()=>{clearTimeout(a),i(!1)}),n.once(`listening`,()=>{clearTimeout(a),i(!0)}),n.listen(e,`127.0.0.1`)})}async function x(e){let t=y(e);for(let e=0;e<500;e++){let n=v+(t-v+e)%500;if(await b(n))return n}return t}const S=/^[a-z0-9][a-z0-9-]{0,213}$/,C=new Set([`admin`,`front`]);function w(e){if(!e)return`Project name is required`;if(!S.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`;if(C.has(e))return`"${e}" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`}function T(e,t){if(e===`demo`&&!t)return"Preset `demo` installs plugins via `lumi add` after deps resolve, so --install must be true (or pick `minimal`)."}const E=process.platform===`win32`;function D(e,t,n){return _(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:E})}function O(e,t,n){_(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:E})}const k=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),A=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),j=`__PROJECT_NAME__`,M=`__PROJECT_NAME_TITLE__`,N=`__POSTGRES_HOST_PORT__`;async function P(e,t){await F(e,t)}async function F(e,t){let n=w(e.name);if(n)throw Error(n);let r=T(e.preset,e.installDeps);if(r)throw Error(r);let i=u(process.cwd(),e.name),a=d(process.cwd()),o=d(i);if(o!==a&&!o.startsWith(a+f))throw Error(`Project directory ${o} resolves outside the current working directory ${a}. Refusing to write.`);t?.(`Extracting template...`),await z(i),await V(i),t?.(`Personalizing template...`);let s=await x(e.name);if(await U(i,e.name,s),await W(i,e.name),e.installDeps&&(t?.(`Installing dependencies...`),D(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`){let e=u(i,...`apps/admin`.split(`/`)),n=[...new Set(L.flatMap(e=>R[e]))];t?.(`Installing demo-preset packages...`),await I(i,e,n);for(let n of L)t?.(`Adding ${n} plugin...`),O(`pnpm`,[`exec`,`lumi`,`add`,n],{cwd:e})}}async function I(e,t,n){let r=u(e,`pnpm-workspace.yaml`),a=await i(r,`utf-8`),o=a.match(/^\s+'?@murumets-ee\/[a-z-]+'?:\s*([0-9][^\s]*)\s*$/m);if(!o?.[1]){O(`pnpm`,[`add`,...n],{cwd:t});return}let c=o[1],l=n.filter(e=>!RegExp(`'?${e}'?:`).test(a));if(l.length>0){let e=l.map(e=>` '${e}': ${c}`).join(`
2
- `),i=a.replace(/^( {2}lumi:)([ \t]*\{\})?$/m,`$1\n${e}`);if(i===a){O(`pnpm`,[`add`,...n],{cwd:t});return}await s(r,i,`utf-8`)}let d=u(t,`package.json`),f=JSON.parse(await i(d,`utf-8`));f.dependencies??={};for(let e of n)f.dependencies[e]=`catalog:lumi`;f.dependencies=Object.fromEntries(Object.entries(f.dependencies).sort(([e],[t])=>e.localeCompare(t))),await s(d,`${JSON.stringify(f,null,2)}\n`,`utf-8`),O(`pnpm`,[`install`,`--no-frozen-lockfile`],{cwd:t})}const L=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`,`rent`],R={taxonomy:[`@murumets-ee/taxonomy`],blog:[`@murumets-ee/content`],media:[`@murumets-ee/media`],ticketing:[`@murumets-ee/ticketing-ui`,`@murumets-ee/ticketing`],commerce:[`@murumets-ee/commerce-ui`],rent:[`@murumets-ee/rent-ui`,`@murumets-ee/rent`]};async function z(e){try{await r(e);return}catch(e){if(e.code!==`EEXIST`)throw e}await B(e)}async function B(t){if(!e(t))return;let r=await n(t);if(r.isSymbolicLink())throw Error(`${t} is a symbolic link. Refusing to scaffold through it — the template would be written to wherever it points, which is outside the directory you named. Remove the link, or pick a different project name.`);if(!r.isDirectory())throw Error(`${t} already exists and is not a directory. Refusing to scaffold over it — pick a different project name, or remove the file first.`);if((await a(t)).length>0)throw Error(`${t} already exists and is not empty. Refusing to scaffold over it — pick a different project name, or remove the directory first.`)}async function V(t){let n=H();if(!e(n))throw Error(`Template tarball missing: ${n}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await m({file:n,cwd:t,filter:e=>{let t=e.startsWith(`/`)||/^[A-Za-z]:[/\\]/.test(e),n=e.split(/[/\\]/).includes(`..`);return t||n?(console.warn(`@murumets-ee/create: refusing tar entry with traversal: ${e}`),!1):!0}})}function H(){let n=l(p(import.meta.url)),r=n;for(let n=0;n<5;n++){let n=u(r,`package.json`);if(e(n))try{if(JSON.parse(t(n,`utf-8`)).name===`@murumets-ee/create`)return u(r,`templates`,`template-demo.tar.gz`)}catch(e){console.warn(`@murumets-ee/create: skipped malformed package.json at ${n}: ${e instanceof Error?e.message:String(e)}`)}let i=l(r);if(i===r)break;r=i}throw Error(`Could not find @murumets-ee/create package root above ${n}. template-demo.tar.gz cannot be located.`)}async function U(e,t,n){let r=J(t),a=String(n);for await(let n of K(e)){if(!q(n))continue;let e=await i(n,`utf-8`);!e.includes(j)&&!e.includes(M)&&!e.includes(N)||await s(n,e.split(M).join(r).split(j).join(t).split(N).join(a),`utf-8`)}}async function W(e,t){let n=[];async function r(e){let i=await a(e,{withFileTypes:!0});for(let a of i){if(!a.isDirectory()||G.has(a.name))continue;let i=u(e,a.name);await r(i),a.name.includes(j)&&n.push({from:i,to:u(e,a.name.split(j).join(t))})}}await r(e);for(let{from:e,to:t}of n)await o(e,t)}const G=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*K(e){let t=await a(e,{withFileTypes:!0});for(let n of t){let t=u(e,n.name);if(n.isDirectory()){if(G.has(n.name))continue;yield*K(t)}else n.isFile()&&(yield t)}}function q(e){let t=c(e);if(A.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return k.has(r)}function J(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}export{P as scaffold};
1
+ import{existsSync as e,readFileSync as t}from"node:fs";import{lstat as n,mkdir as r,readFile as i,readdir as a,rename as o,writeFile as s}from"node:fs/promises";import{basename as c,dirname as l,join as u,resolve as d,sep as f}from"node:path";import{fileURLToPath as p}from"node:url";import{extract as m}from"tar";import{createHash as h}from"node:crypto";import{createServer as g}from"node:net";import"@clack/prompts";import{execFileSync as _}from"node:child_process";const v=5500;function y(e){return v+h(`sha1`).update(e).digest().readUInt32BE(0)%500}async function b(e){return new Promise(t=>{let n=g(),r=!1,i=e=>{r||(r=!0,n.close(),t(e))},a=setTimeout(()=>i(!1),500);n.once(`error`,()=>{clearTimeout(a),i(!1)}),n.once(`listening`,()=>{clearTimeout(a),i(!0)}),n.listen(e,`127.0.0.1`)})}async function x(e){let t=y(e);for(let e=0;e<500;e++){let n=v+(t-v+e)%500;if(await b(n))return n}return t}const S=/^[a-z0-9][a-z0-9-]{0,213}$/,C=new Set([`admin`,`front`]);function w(e){if(!e)return`Project name is required`;if(!S.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`;if(C.has(e))return`"${e}" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`}function T(e,t){if(e===`demo`&&!t)return"Preset `demo` installs plugins via `lumi add` after deps resolve, so --install must be true (or pick `minimal`)."}const E=process.platform===`win32`;function D(e,t,n){return _(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:E})}function O(e,t,n){_(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:E})}const k=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),A=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),j=`__PROJECT_NAME__`,M=`__PROJECT_NAME_TITLE__`,N=`__POSTGRES_HOST_PORT__`;async function P(e,t){await F(e,t)}async function F(e,t){let n=w(e.name);if(n)throw Error(n);let r=T(e.preset,e.installDeps);if(r)throw Error(r);let i=u(process.cwd(),e.name),a=d(process.cwd()),o=d(i);if(o!==a&&!o.startsWith(a+f))throw Error(`Project directory ${o} resolves outside the current working directory ${a}. Refusing to write.`);t?.(`Extracting template...`),await z(i),await V(i),t?.(`Personalizing template...`);let s=await x(e.name);if(await U(i,e.name,s),await W(i,e.name),e.installDeps&&(t?.(`Installing dependencies...`),D(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`){let e=u(i,...`apps/admin`.split(`/`)),n=[...new Set(L.flatMap(e=>R[e]))];t?.(`Installing demo-preset packages...`),await I(i,e,n);for(let n of L)t?.(`Adding ${n} plugin...`),O(`pnpm`,[`exec`,`lumi`,`add`,n],{cwd:e})}}async function I(e,t,n){let r=n.filter(e=>e.startsWith(`@murumets-ee/`)),a=n.filter(e=>!e.startsWith(`@murumets-ee/`)),o=u(e,`pnpm-workspace.yaml`),c=await i(o,`utf-8`),l=c.match(/^\s+'?@murumets-ee\/[a-z-]+'?:\s*([0-9][^\s]*)\s*$/m);if(!l?.[1]){O(`pnpm`,[`add`,...n],{cwd:t});return}let d=l[1],f=r.filter(e=>!RegExp(`'?${e}'?:`).test(c));if(f.length>0){let e=f.map(e=>` '${e}': ${d}`).join(`
2
+ `),r=c.replace(/^( {2}lumi:)([ \t]*\{\})?$/m,`$1\n${e}`);if(r===c){O(`pnpm`,[`add`,...n],{cwd:t});return}await s(o,r,`utf-8`)}let p=u(t,`package.json`),m=JSON.parse(await i(p,`utf-8`));m.dependencies??={};for(let e of r)m.dependencies[e]=`catalog:lumi`;let h=a.filter(e=>!(e in(m.dependencies??{})));m.dependencies=Object.fromEntries(Object.entries(m.dependencies).sort(([e],[t])=>e.localeCompare(t))),await s(p,`${JSON.stringify(m,null,2)}\n`,`utf-8`),O(`pnpm`,[`install`,`--no-frozen-lockfile`],{cwd:t}),h.length>0&&O(`pnpm`,[`add`,...h],{cwd:t})}const L=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`,`rent`],R={taxonomy:[`@murumets-ee/taxonomy`],blog:[`@murumets-ee/content`,`@murumets-ee/blocks`],media:[`@murumets-ee/media`],ticketing:[`@murumets-ee/ticketing-ui`,`@murumets-ee/ticketing`],commerce:[`@murumets-ee/commerce-ui`,`@murumets-ee/commerce`,`@murumets-ee/search-elasticsearch`,`@elastic/elasticsearch`],rent:[`@murumets-ee/rent-ui`,`@murumets-ee/rent`]};async function z(e){try{await r(e);return}catch(e){if(e.code!==`EEXIST`)throw e}await B(e)}async function B(t){if(!e(t))return;let r=await n(t);if(r.isSymbolicLink())throw Error(`${t} is a symbolic link. Refusing to scaffold through it — the template would be written to wherever it points, which is outside the directory you named. Remove the link, or pick a different project name.`);if(!r.isDirectory())throw Error(`${t} already exists and is not a directory. Refusing to scaffold over it — pick a different project name, or remove the file first.`);if((await a(t)).length>0)throw Error(`${t} already exists and is not empty. Refusing to scaffold over it — pick a different project name, or remove the directory first.`)}async function V(t){let n=H();if(!e(n))throw Error(`Template tarball missing: ${n}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await m({file:n,cwd:t,filter:e=>{let t=e.startsWith(`/`)||/^[A-Za-z]:[/\\]/.test(e),n=e.split(/[/\\]/).includes(`..`);return t||n?(console.warn(`@murumets-ee/create: refusing tar entry with traversal: ${e}`),!1):!0}})}function H(){let n=l(p(import.meta.url)),r=n;for(let n=0;n<5;n++){let n=u(r,`package.json`);if(e(n))try{if(JSON.parse(t(n,`utf-8`)).name===`@murumets-ee/create`)return u(r,`templates`,`template-demo.tar.gz`)}catch(e){console.warn(`@murumets-ee/create: skipped malformed package.json at ${n}: ${e instanceof Error?e.message:String(e)}`)}let i=l(r);if(i===r)break;r=i}throw Error(`Could not find @murumets-ee/create package root above ${n}. template-demo.tar.gz cannot be located.`)}async function U(e,t,n){let r=J(t),a=String(n);for await(let n of K(e)){if(!q(n))continue;let e=await i(n,`utf-8`);!e.includes(j)&&!e.includes(M)&&!e.includes(N)||await s(n,e.split(M).join(r).split(j).join(t).split(N).join(a),`utf-8`)}}async function W(e,t){let n=[];async function r(e){let i=await a(e,{withFileTypes:!0});for(let a of i){if(!a.isDirectory()||G.has(a.name))continue;let i=u(e,a.name);await r(i),a.name.includes(j)&&n.push({from:i,to:u(e,a.name.split(j).join(t))})}}await r(e);for(let{from:e,to:t}of n)await o(e,t)}const G=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*K(e){let t=await a(e,{withFileTypes:!0});for(let n of t){let t=u(e,n.name);if(n.isDirectory()){if(G.has(n.name))continue;yield*K(t)}else n.isFile()&&(yield t)}}function q(e){let t=c(e);if(A.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return k.has(r)}function J(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}export{P as scaffold};
3
3
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["tarExtract"],"sources":["../src/port.ts","../src/prompts.ts","../src/utils.ts","../src/scaffold.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport { createServer } from 'node:net'\n\n/**\n * Pick a host port to map onto the scaffolded project's Postgres container.\n *\n * Why not 5432: every Lumi developer who has another Lumi project (or just a\n * local Postgres) on the standard port will hit a `port already in use`\n * collision the first time they run `docker compose up`. Surprising, slow to\n * diagnose, and easy to avoid.\n *\n * Strategy:\n * 1. Hash the project name into a stable base in `PORT_RANGE_LOW..HIGH` so\n * re-scaffolding the same name lands on the same port (predictable for\n * the developer, no churn in `.env`).\n * 2. Probe upward from that base by binding a temporary TCP server until\n * we find one that's actually free *right now*.\n * 3. If 500 consecutive probes fail (extremely unlikely), fall back to the\n * hashed base — the user will see docker's own error and can pick.\n */\n\nconst PORT_RANGE_LOW = 5500\nconst PORT_RANGE_HIGH = 5999\nconst PROBE_TIMEOUT_MS = 500\nconst MAX_PROBES = 500\n\n/**\n * Project-name → starting port. Stable + deterministic so the same project\n * always lands on the same base when no one else is squatting on it.\n */\nexport function hashProjectNameToPortBase(name: string): number {\n const digest = createHash('sha1').update(name).digest()\n const span = PORT_RANGE_HIGH - PORT_RANGE_LOW + 1\n return PORT_RANGE_LOW + (digest.readUInt32BE(0) % span)\n}\n\nasync function isPortFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const srv = createServer()\n let settled = false\n const finish = (free: boolean) => {\n if (settled) return\n settled = true\n srv.close()\n resolve(free)\n }\n const timer = setTimeout(() => finish(false), PROBE_TIMEOUT_MS)\n srv.once('error', () => {\n clearTimeout(timer)\n finish(false)\n })\n srv.once('listening', () => {\n clearTimeout(timer)\n finish(true)\n })\n // Bind on 127.0.0.1 — the docker-compose host port also binds to 127.0.0.1\n // (or 0.0.0.0, which conflicts with anything on the same port on lo).\n srv.listen(port, '127.0.0.1')\n })\n}\n\n/**\n * Find a free host port for the project. Walks upward from the hashed base\n * with wraparound inside `PORT_RANGE_LOW..HIGH`.\n */\nexport async function findFreePostgresPort(projectName: string): Promise<number> {\n const span = PORT_RANGE_HIGH - PORT_RANGE_LOW + 1\n const base = hashProjectNameToPortBase(projectName)\n for (let i = 0; i < MAX_PROBES; i++) {\n const port = PORT_RANGE_LOW + ((base - PORT_RANGE_LOW + i) % span)\n if (await isPortFree(port)) return port\n }\n return base\n}\n","import * as p from '@clack/prompts'\nimport type { Preset, ProjectOptions } from './types'\n\ninterface CliFlags {\n name?: string\n preset?: Preset\n install?: boolean\n}\n\n/**\n * npm package name rules: lowercase alphanumeric + hyphen, must start with a\n * letter/digit (so `-foo` is rejected), max 214 chars (npm limit).\n *\n * The character class is intentionally narrow — anything outside it would\n * either break npm publish or, more importantly, allow shell metacharacters\n * to reach the args that get forwarded to `pnpm create next-app`. The exec\n * layer in `utils.ts` uses `execFileSync` (no shell) as defense-in-depth,\n * but the regex stops the bad input at the boundary.\n */\nconst PROJECT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,213}$/\n\n/**\n * Names that would collide with a workspace member the scaffold emits.\n *\n * The emitted tree is a pnpm workspace whose root manifest is named after the\n * project and whose members are `admin` and `front`. `create-lumi admin` would\n * therefore produce two projects called `admin` in one workspace — pnpm objects\n * to the duplicate, and every delegating root script (`pnpm --filter admin …`)\n * becomes ambiguous.\n */\nconst RESERVED_PROJECT_NAMES = new Set(['admin', 'front'])\n\nexport function validateProjectName(value: string): string | undefined {\n if (!value) return 'Project name is required'\n if (!PROJECT_NAME_RE.test(value)) {\n return 'Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).'\n }\n if (RESERVED_PROJECT_NAMES.has(value)) {\n return `\"${value}\" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`\n }\n return undefined\n}\n\n/**\n * The `demo` preset runs `pnpm exec lumi add <plugin>` after deps install,\n * which requires `node_modules/.bin/lumi` to exist. `--no-install` would\n * skip that, so the combo is invalid and must be caught before anything is\n * written to disk.\n */\nexport function validatePresetInstall(preset: Preset, installDeps: boolean): string | undefined {\n if (preset === 'demo' && !installDeps) {\n return (\n 'Preset `demo` installs plugins via `lumi add` after deps resolve, ' +\n 'so --install must be true (or pick `minimal`).'\n )\n }\n return undefined\n}\n\n/**\n * Parse `--preset=<name>` / `--preset <name>` / `--full` /\n * `--install` / `--no-install` from argv.\n *\n * `--full` is sugar for `--preset demo`. Unknown `--preset` values are\n * ignored — the interactive prompt then picks up.\n */\nexport function parseFlags(argv: string[]): CliFlags {\n const flags: CliFlags = {}\n const args = argv.slice(2)\n\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]\n if (arg === undefined) continue\n if (arg === '--full') {\n flags.preset = 'demo'\n } else if (arg === '--preset' && args[i + 1]) {\n const val = args[++i]\n if (val === 'minimal' || val === 'demo') flags.preset = val\n } else if (arg.startsWith('--preset=')) {\n const val = arg.slice('--preset='.length)\n if (val === 'minimal' || val === 'demo') flags.preset = val\n } else if (arg === '--install') {\n flags.install = true\n } else if (arg === '--no-install') {\n flags.install = false\n } else if (!arg.startsWith('-') && !flags.name) {\n flags.name = arg\n }\n }\n\n return flags\n}\n\nexport async function runPrompts(initialName?: string): Promise<ProjectOptions | null> {\n const flags = parseFlags(process.argv)\n const resolvedName = flags.name ?? initialName\n\n if (resolvedName !== undefined) {\n const err = validateProjectName(resolvedName)\n if (err) {\n p.intro('@murumets-ee/create')\n p.cancel(err)\n return null\n }\n }\n\n if (flags.preset !== undefined && flags.install !== undefined) {\n const err = validatePresetInstall(flags.preset, flags.install)\n if (err) {\n p.intro('@murumets-ee/create')\n p.cancel(err)\n return null\n }\n }\n\n if (resolvedName && flags.install !== undefined && flags.preset !== undefined) {\n return {\n name: resolvedName,\n installDeps: flags.install,\n preset: flags.preset,\n }\n }\n\n p.intro('@murumets-ee/create')\n\n const name =\n resolvedName ??\n (await p.text({\n message: 'Project name:',\n placeholder: 'my-project',\n validate: validateProjectName,\n }))\n\n if (p.isCancel(name)) {\n p.cancel('Cancelled.')\n return null\n }\n\n const preset =\n flags.preset ??\n (await p.select({\n message: 'Preset:',\n options: [\n {\n value: 'demo' as const,\n label: 'Demo',\n hint: 'auth + media + ticketing (recommended for first-time setup)',\n },\n {\n value: 'minimal' as const,\n label: 'Minimal',\n hint: 'auth + infrastructure only — add plugins with `lumi add`',\n },\n ],\n initialValue: 'demo' as const,\n }))\n\n if (p.isCancel(preset)) {\n p.cancel('Cancelled.')\n return null\n }\n\n const installDeps =\n flags.install ??\n (await p.confirm({\n message: 'Install dependencies?',\n initialValue: true,\n }))\n\n if (p.isCancel(installDeps)) {\n p.cancel('Cancelled.')\n return null\n }\n\n const presetErr = validatePresetInstall(preset, installDeps)\n if (presetErr) {\n p.cancel(presetErr)\n return null\n }\n\n return {\n name,\n installDeps,\n preset,\n }\n}\n","import { execFileSync } from 'node:child_process'\n\ninterface RunOptions {\n cwd?: string\n}\n\n/**\n * On Windows, `pnpm` resolves to `pnpm.CMD` (a batch shim). Node 18.20+ /\n * 20.12+ / 21.7+ / 22+ refuses to spawn `.cmd` / `.bat` files directly per\n * CVE-2024-27980 mitigation, so a plain `execFileSync('pnpm', ...)` throws\n * `EINVAL`. We work around it by routing through `cmd.exe` via\n * `{ shell: true }` on Windows only.\n *\n * Safety: the only user-controlled arg in this package's call sites is\n * `options.name`, which is gated by `validateProjectName`'s regex\n * `/^[a-z0-9][a-z0-9-]{0,213}$/` — none of cmd.exe's metacharacters\n * (`& | < > ^ ( ) % !`) can pass that gate. On non-Windows hosts we keep\n * `shell: false` so POSIX shell metachars are inert by construction.\n */\nconst useShell = process.platform === 'win32'\n\n/**\n * Run a binary with discrete args. Captures stdout.\n *\n * On POSIX, args are passed verbatim (no shell). On Windows, we route via\n * `cmd.exe` to handle `.cmd`/`.bat` shims — see the comment on `useShell`\n * for the safety argument.\n */\nexport function runCommandPiped(cmd: string, args: string[], options?: RunOptions): string {\n return execFileSync(cmd, args, {\n cwd: options?.cwd,\n stdio: 'pipe',\n encoding: 'utf-8',\n shell: useShell,\n })\n}\n\n/**\n * Like `runCommandPiped` but inherits the parent's stdio so the user sees\n * realtime output (and any error diagnostics aren't buffered until failure).\n */\nexport function runCommandInherit(cmd: string, args: string[], options?: RunOptions): void {\n execFileSync(cmd, args, {\n cwd: options?.cwd,\n stdio: 'inherit',\n shell: useShell,\n })\n}\n","import { existsSync, readFileSync } from 'node:fs'\nimport { lstat, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { basename, dirname, join, resolve, sep } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { extract as tarExtract } from 'tar'\nimport { findFreePostgresPort } from './port'\nimport { validatePresetInstall, validateProjectName } from './prompts'\nimport type { ProjectOptions } from './types'\nimport { runCommandInherit, runCommandPiped } from './utils'\n\n/**\n * The workspace member that holds the CMS. `lumi` resolves its project root by\n * walking UP for `lumi.config.ts`, so every CLI invocation (`lumi add`,\n * `lumi migrate`) has to run from here rather than from the workspace root.\n */\nconst ADMIN_APP_DIR = 'apps/admin'\n\nexport type ProgressCallback = (message: string) => void\n\n// File extensions eligible for token replacement during scaffolding.\nconst TOKENIZABLE_EXTENSIONS = new Set<string>([\n '.ts',\n '.tsx',\n '.mts',\n '.json',\n '.md',\n '.yml',\n '.yaml',\n '.css',\n '.html',\n])\n\nconst TOKENIZABLE_BASENAMES = new Set<string>(['.env.example', 'Dockerfile', '.gitignore'])\n\nconst TOKEN_PROJECT_NAME = '__PROJECT_NAME__'\nconst TOKEN_PROJECT_NAME_TITLE = '__PROJECT_NAME_TITLE__'\nconst TOKEN_POSTGRES_HOST_PORT = '__POSTGRES_HOST_PORT__'\n\nexport async function scaffold(\n options: ProjectOptions,\n onProgress?: ProgressCallback,\n): Promise<void> {\n await scaffoldSingle(options, onProgress)\n}\n\n// ---------------------------------------------------------------------------\n// scaffoldSingle — extract a prebuilt tarball, then tokenize.\n// ---------------------------------------------------------------------------\n\nasync function scaffoldSingle(\n options: ProjectOptions,\n onProgress?: ProgressCallback,\n): Promise<void> {\n // Defense-in-depth: re-validate the option tuple at the boundary. The\n // prompt layer is the canonical gate, but `scaffold` is also exported as\n // a programmatic API — we don't want a buggy caller to skip the check.\n const nameErr = validateProjectName(options.name)\n if (nameErr) throw new Error(nameErr)\n const presetErr = validatePresetInstall(options.preset, options.installDeps)\n if (presetErr) throw new Error(presetErr)\n\n const projectDir = join(process.cwd(), options.name)\n const cwdResolved = resolve(process.cwd())\n const projectResolved = resolve(projectDir)\n if (projectResolved !== cwdResolved && !projectResolved.startsWith(cwdResolved + sep)) {\n throw new Error(\n `Project directory ${projectResolved} resolves outside the current ` +\n `working directory ${cwdResolved}. Refusing to write.`,\n )\n }\n\n // 1. Extract the prebuilt template tarball into the project directory.\n //\n // This used to run `pnpm create next-app` first and extract over the\n // result. That step was removed when the scaffold became a workspace, and\n // not merely because the nesting was awkward: measured against\n // create-next-app@16, it emits a NESTED `pnpm-workspace.yaml` that hijacks\n // the workspace root for any pnpm command run inside the app, a nested git\n // repository, and a `packageManager` pin in the wrong manifest — while\n // contributing nothing the tarball does not already carry (`apps/admin`\n // ships its own `.gitignore`, `postcss.config.mjs`, `tsconfig.json`,\n // `next.config.ts` and `public/`). Undoing three emissions to gain zero\n // files is strictly more work than not calling it.\n //\n // The template is the minimal Lumi skeleton — run `lumi add <plugin>`\n // after scaffolding to layer in plugins (auth is preinstalled).\n onProgress?.('Extracting template...')\n await createTargetDirectory(projectDir)\n await extractTemplate(projectDir)\n\n // 2. Replace tokens in tokenizable files AND in directory names. Pick a free\n // Postgres host port here (not 5432 — collides with anyone else running\n // Postgres locally, which is virtually every Lumi developer).\n onProgress?.('Personalizing template...')\n const postgresHostPort = await findFreePostgresPort(options.name)\n await tokenizeProject(projectDir, options.name, postgresHostPort)\n await tokenizeDirectoryNames(projectDir, options.name)\n\n // 3. Install deps\n if (options.installDeps) {\n onProgress?.('Installing dependencies...')\n runCommandPiped('pnpm', ['install'], { cwd: projectDir })\n }\n\n // 4. Layer on demo-preset plugins via `lumi add`. Skipped for the minimal\n // preset — user runs `lumi add <plugin>` themselves. Requires the deps\n // step above, because `pnpm exec lumi` resolves the bin from\n // `node_modules/.bin/`.\n //\n // cwd is the admin app, not the project root: `lumi` finds its project\n // root by walking UP for `lumi.config.ts`, and nothing above `apps/admin`\n // has one.\n if (options.preset === 'demo') {\n const adminDir = join(projectDir, ...ADMIN_APP_DIR.split('/'))\n\n // INSTALL FIRST, then wire.\n //\n // `lumi add` does NOT install anything — it edits `lumi.config.ts`,\n // scaffolds routes, updates the manifest, and PRINTS `pnpm add <pkg>` as a\n // next step for the human. So adding a plugin whose package is absent\n // produces a config importing a module that cannot resolve, and the very\n // next command (`lumi migrate`, which loads that config) dies with\n // `Package subpath './plugin' is not defined by \"exports\"` — pointing at\n // resolution rather than at the missing dependency.\n //\n // This was invisible until the baseline config was trimmed to a content\n // set: every demo plugin used to be wired AND depended on already, so every\n // `lumi add` was a no-op reconcile and the import always resolved. Now\n // `ticketing` and `commerce` are real additions, and they need their\n // packages.\n //\n // Installing the ones already present is harmless — pnpm resolves them from\n // the lockfile — so the list is not filtered. Env is inherited, which is\n // what lets `scaffold-e2e` resolve these from its local registry rather\n // than from npm.\n const demoPackages = [...new Set(DEMO_PRESET_PLUGINS.flatMap((p) => DEMO_PRESET_PACKAGES[p]))]\n onProgress?.('Installing demo-preset packages...')\n await addDemoPackagesViaCatalog(projectDir, adminDir, demoPackages)\n\n for (const plugin of DEMO_PRESET_PLUGINS) {\n onProgress?.(`Adding ${plugin} plugin...`)\n // stdio=inherit so the user sees `lumi add`'s own step-by-step\n // output + any error messages (otherwise they get buffered on\n // failure and surfaced as a generic execSync error).\n runCommandInherit('pnpm', ['exec', 'lumi', 'add', plugin], { cwd: adminDir })\n }\n }\n}\n\n/**\n * Add the demo-preset packages THROUGH THE CATALOG, not with a bare `pnpm add`.\n *\n * `pnpm add` writes a literal `^x.y.z` into package.json. Every other\n * `@murumets-ee/*` dependency in a scaffolded project is `catalog:lumi`, so\n * these were the only ones that could drift from the rest of the toolkit — and\n * a pre-1.0 caret pins the MINOR, so `^0.42.0` does not match `0.43.0`.\n *\n * The result was a complete parallel toolkit tree: pnpm installs both versions,\n * and the app dies at typecheck with `Property '[GATED]' is missing` — an error\n * that names a type property and not one of the packages that caused it. It\n * cost a production deploy afternoon before the cause was found.\n *\n * The catalog exists precisely to make this unrepresentable; pnpm-workspace.yaml\n * says so in its own comment (\"pnpm can never resolve two different versions of\n * the same @murumets-ee package simultaneously\"). Three packages escaped it\n * because they arrived through a code path that never learned about catalogs.\n *\n * The version is READ FROM THE CATALOG rather than passed in: the scaffold's\n * own toolkit version is whatever `build-templates.ts` baked into the workspace\n * file, and re-deriving it anywhere else is how the two drift apart again.\n */\nasync function addDemoPackagesViaCatalog(\n projectDir: string,\n adminDir: string,\n packages: readonly string[],\n): Promise<void> {\n const wsPath = join(projectDir, 'pnpm-workspace.yaml')\n const ws = await readFile(wsPath, 'utf-8')\n\n // Any existing `'@murumets-ee/x': 1.2.3` entry gives the toolkit version this\n // scaffold was built for. If the catalog is somehow empty there is nothing to\n // agree WITH, so fall back to `pnpm add` rather than invent a version.\n const versionMatch = ws.match(/^\\s+'?@murumets-ee\\/[a-z-]+'?:\\s*([0-9][^\\s]*)\\s*$/m)\n if (!versionMatch?.[1]) {\n runCommandInherit('pnpm', ['add', ...packages], { cwd: adminDir })\n return\n }\n const version = versionMatch[1]\n\n // 1. Catalog entries for anything missing, at that same version.\n const missing = packages.filter((name) => !new RegExp(`'?${name}'?:`).test(ws))\n if (missing.length > 0) {\n const entries = missing.map((name) => ` '${name}': ${version}`).join('\\n')\n // Tolerates both the populated (` lumi:`) and placeholder (` lumi: {}`)\n // forms, since the overlay ships the latter.\n const next = ws.replace(/^( {2}lumi:)([ \\t]*\\{\\})?$/m, `$1\\n${entries}`)\n if (next === ws) {\n // No `lumi:` key to extend — do not silently produce a package.json\n // referencing a catalog that cannot resolve it.\n runCommandInherit('pnpm', ['add', ...packages], { cwd: adminDir })\n return\n }\n await writeFile(wsPath, next, 'utf-8')\n }\n\n // 2. Declare the deps as catalog references, then install once.\n const pkgPath = join(adminDir, 'package.json')\n const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')) as {\n dependencies?: Record<string, string>\n }\n pkg.dependencies ??= {}\n for (const name of packages) pkg.dependencies[name] = 'catalog:lumi'\n pkg.dependencies = Object.fromEntries(\n Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b)),\n )\n await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\\n`, 'utf-8')\n\n // `--no-frozen-lockfile` is REQUIRED, not a convenience. CI sets\n // `frozen-lockfile` by default, and the lines above have just added five\n // dependencies to package.json — so a frozen install refuses with\n // ERR_PNPM_OUTDATED_LOCKFILE (\"specifiers in the lockfile don't match\n // specifiers in package.json\"). Updating the lockfile is the POINT of this\n // step; the `pnpm add` it replaces did it implicitly. Caught by scaffold-e2e,\n // which is the only job that runs a real scaffold install.\n runCommandInherit('pnpm', ['install', '--no-frozen-lockfile'], { cwd: adminDir })\n}\n\n/**\n * Optional plugins layered on top of the baseline skeleton for the demo\n * preset. Must stay in sync with `CATALOG` in `@murumets-ee/cli` minus\n * `auth` (which is preinstalled in the baseline `lumi.config.ts`). The\n * `preset-parity.test.ts` test reads `CATALOG` at test time and asserts\n * `[...DEMO_PRESET_PLUGINS, 'auth'].sort()` equals\n * `Object.keys(CATALOG).sort()` — rename or add an entry here whenever\n * the CLI catalog grows.\n *\n * Order matters. `taxonomy` scaffolds `entities/category.ts` +\n * `entities/tag.ts`; `blog` imports those indirectly via entity\n * references. Running `taxonomy` first keeps the scaffolded `Article`\n * reference-field target available at plugin-resolution time.\n *\n * `media`, `ticketing`, and `commerce` don't depend on the scaffolded\n * entity files (each brings its own entities via the plugin — commerce\n * contributes its `brand` taxonomy vocab itself), so they can run in any\n * position but live at the end for readability.\n */\nconst DEMO_PRESET_PLUGINS = ['taxonomy', 'blog', 'media', 'ticketing', 'commerce', 'rent'] as const\n\n/**\n * Every npm package a demo-preset plugin needs at RUNTIME — not just the one\n * the CATALOG names.\n *\n * The CATALOG's `packageName` is what `lumi add` imports into\n * `lumi.config.ts`. It is not the whole surface: `lumi add` also SCAFFOLDS API\n * routes, and those import their own packages. `ticketing` is the case in\n * point — the config gets `@murumets-ee/ticketing-ui`, while the inbound\n * webhook route it writes does `await import('@murumets-ee/ticketing')`. Under\n * pnpm's strict layout a transitive dependency is not resolvable from the app,\n * so installing only the CATALOG package leaves `next build` failing on a file\n * the CLI itself just wrote.\n *\n * Hence a LIST per plugin, and hence declared rather than derived: the CATALOG\n * describes what to wire, and nothing in it describes what its templates\n * import. `preset-parity.test.ts` asserts the CATALOG's `packageName` is\n * present in each list, so a rename still fails in a unit test; the extra\n * entries are this file's own knowledge and are commented where they are not\n * obvious.\n *\n * Mirrored rather than imported because `@murumets-ee/cli` is not a dependency\n * of this package — it is a dependency of what this package EMITS.\n */\nconst DEMO_PRESET_PACKAGES: Record<(typeof DEMO_PRESET_PLUGINS)[number], readonly string[]> = {\n taxonomy: ['@murumets-ee/taxonomy'],\n blog: ['@murumets-ee/content'],\n media: ['@murumets-ee/media'],\n // `@murumets-ee/ticketing` is NOT redundant with `-ui`: the scaffolded\n // `app/api/ticketing/inbound/route.ts` imports it directly.\n ticketing: ['@murumets-ee/ticketing-ui', '@murumets-ee/ticketing'],\n commerce: ['@murumets-ee/commerce-ui'],\n // LAST, and the order is load-bearing: rent does not define its own customer,\n // it extends `commerce.customer` with B2B billing fields (rent SD002), so\n // commerce must be added first or the reference target does not exist.\n //\n // `@murumets-ee/rent` is NOT redundant with `-ui`: the demo's booking API\n // route imports the server package directly, exactly as ticketing does.\n rent: ['@murumets-ee/rent-ui', '@murumets-ee/rent'],\n}\n\n// ---------------------------------------------------------------------------\n// Tarball extraction\n// ---------------------------------------------------------------------------\n\n/**\n * Create the project directory, refusing anything that already exists and is\n * not an empty real directory.\n *\n * `create-next-app` used to own the non-empty half; it ran first and bailed on\n * a populated target. With the tarball extracted directly, nothing else stands\n * between a typo'd project name and an overwritten working tree.\n *\n * 🔒 The symlink half is the one worth spelling out, because three functions\n * that look safe compose into an escape. `existsSync` and `readdir` FOLLOW a\n * link, so a symlink pointing at an empty directory reads as an empty target.\n * `mkdir(…, { recursive: true })` then leaves the link in place rather than\n * replacing it. And the caller's boundary check compares resolved PATH STRINGS,\n * not real paths, so `<cwd>/myproject` passes while the bytes land wherever the\n * link points. `lstat` is what distinguishes the two, and `realpath` is what\n * makes the boundary check mean what it says.\n */\nasync function createTargetDirectory(projectDir: string): Promise<void> {\n // 🔒 NON-recursive `mkdir` first, and that is the security-relevant part.\n //\n // On the overwhelmingly common path — the directory does not exist — this\n // both creates it and PROVES what it is: the call succeeds only if it did\n // the creating, so there is no window in which anything else could have put\n // a symlink there. A check-then-create pair can never say that, however\n // carefully the check is written.\n //\n // `recursive: false` is what makes it work: recursive `mkdir` treats an\n // existing path as success and hands back a symlink happily. The parent is\n // always `process.cwd()`, so there is nothing to create recursively anyway.\n try {\n await mkdir(projectDir)\n return\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n }\n\n // It already existed, so fall back to inspecting it. This path retains a\n // narrow check-to-use window that only a local process racing us could\n // exploit — and such a process could equally rewrite the scaffolded files a\n // second later, so closing it would not change what an attacker can do.\n await assertTargetIsEmpty(projectDir)\n}\n\nasync function assertTargetIsEmpty(projectDir: string): Promise<void> {\n if (!existsSync(projectDir)) return\n\n const info = await lstat(projectDir)\n if (info.isSymbolicLink()) {\n throw new Error(\n `${projectDir} is a symbolic link. Refusing to scaffold through it — the ` +\n `template would be written to wherever it points, which is outside the ` +\n `directory you named. Remove the link, or pick a different project name.`,\n )\n }\n if (!info.isDirectory()) {\n throw new Error(\n `${projectDir} already exists and is not a directory. Refusing to scaffold ` +\n `over it — pick a different project name, or remove the file first.`,\n )\n }\n\n const entries = await readdir(projectDir)\n if (entries.length > 0) {\n throw new Error(\n `${projectDir} already exists and is not empty. Refusing to scaffold over it — ` +\n `pick a different project name, or remove the directory first.`,\n )\n }\n}\n\nasync function extractTemplate(destDir: string): Promise<void> {\n const tarball = resolveTemplateTarball()\n if (!existsSync(tarball)) {\n throw new Error(\n `Template tarball missing: ${tarball}\\n` +\n `Run \\`pnpm --filter @murumets-ee/create build:templates\\` first.`,\n )\n }\n await tarExtract({\n file: tarball,\n cwd: destDir,\n // Defense-in-depth: tar v7 already refuses absolute paths and `..`\n // traversal, but an explicit filter makes the contract loud and lets\n // us log if a malformed entry ever slips through. Includes Windows\n // drive-letter absolutes (`C:/...`) which slip past `startsWith('/')`.\n filter: (path) => {\n const isAbsoluteLike = path.startsWith('/') || /^[A-Za-z]:[/\\\\]/.test(path)\n const hasTraversal = path.split(/[/\\\\]/).includes('..')\n if (isAbsoluteLike || hasTraversal) {\n console.warn(`@murumets-ee/create: refusing tar entry with traversal: ${path}`)\n return false\n }\n return true\n },\n })\n}\n\n/**\n * Locate the template tarball relative to the published `@murumets-ee/create`\n * package root.\n *\n * Anchors on the package's own `package.json` (matched by `name`) so a parent\n * project that happens to have a `templates/` directory at a higher path\n * can't hijack the resolution. This is a defense-in-depth measure against\n * template-substitution attacks where `create-lumi` is invoked from inside\n * a malicious project layout.\n */\nfunction resolveTemplateTarball(): string {\n const here = dirname(fileURLToPath(import.meta.url))\n let dir = here\n for (let i = 0; i < 5; i++) {\n const pkgJsonPath = join(dir, 'package.json')\n if (existsSync(pkgJsonPath)) {\n try {\n const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { name?: string }\n if (pkg.name === '@murumets-ee/create') {\n return join(dir, 'templates', 'template-demo.tar.gz')\n }\n } catch (err) {\n // Don't silently swallow — a malformed package.json at the package\n // root is a corrupt-install signal that the caller should see.\n console.warn(\n `@murumets-ee/create: skipped malformed package.json at ${pkgJsonPath}: ` +\n `${err instanceof Error ? err.message : String(err)}`,\n )\n }\n }\n const parent = dirname(dir)\n if (parent === dir) break // hit filesystem root\n dir = parent\n }\n throw new Error(\n `Could not find @murumets-ee/create package root above ${here}. ` +\n `template-demo.tar.gz cannot be located.`,\n )\n}\n\n// ---------------------------------------------------------------------------\n// Token replacement\n// ---------------------------------------------------------------------------\n\n/**\n * Walk the project directory and replace `__PROJECT_NAME__` /\n * `__PROJECT_NAME_TITLE__` tokens in eligible text files.\n *\n * Binary files (images, fonts) and `node_modules` / `.next` are skipped\n * to avoid corruption and pointless work.\n */\nasync function tokenizeProject(\n projectDir: string,\n projectName: string,\n postgresHostPort: number,\n): Promise<void> {\n const titleName = toTitleCase(projectName)\n const portStr = String(postgresHostPort)\n for await (const filePath of walkFiles(projectDir)) {\n if (!isTokenizable(filePath)) continue\n const original = await readFile(filePath, 'utf-8')\n if (\n !original.includes(TOKEN_PROJECT_NAME) &&\n !original.includes(TOKEN_PROJECT_NAME_TITLE) &&\n !original.includes(TOKEN_POSTGRES_HOST_PORT)\n ) {\n continue\n }\n const replaced = original\n .split(TOKEN_PROJECT_NAME_TITLE)\n .join(titleName)\n .split(TOKEN_PROJECT_NAME)\n .join(projectName)\n .split(TOKEN_POSTGRES_HOST_PORT)\n .join(portStr)\n await writeFile(filePath, replaced, 'utf-8')\n }\n}\n\n/**\n * Rename directories whose NAME carries `__PROJECT_NAME__`.\n *\n * The tarball ships `packages/__PROJECT_NAME__-blocks/`, so a project called\n * `acme` gets `packages/acme-blocks` — matching how `packages/kulli-blocks`\n * reads in the toolkit monorepo, and matching the package name inside it\n * (which `tokenizeProject` has already rewritten).\n *\n * Renames are collected DEEPEST-FIRST and applied in that order, so a\n * tokenized directory nested inside another tokenized directory does not have\n * its path invalidated by its parent moving first.\n */\nasync function tokenizeDirectoryNames(projectDir: string, projectName: string): Promise<void> {\n const pending: { from: string; to: string }[] = []\n\n async function walk(dir: string): Promise<void> {\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory()) continue\n if (SKIP_DIRS.has(entry.name)) continue\n const full = join(dir, entry.name)\n await walk(full)\n if (entry.name.includes(TOKEN_PROJECT_NAME)) {\n pending.push({\n from: full,\n to: join(dir, entry.name.split(TOKEN_PROJECT_NAME).join(projectName)),\n })\n }\n }\n }\n\n await walk(projectDir)\n // `walk` pushes a directory only after recursing into it, so the list is\n // already deepest-first.\n for (const { from, to } of pending) {\n await rename(from, to)\n }\n}\n\n/**\n * Directories the tokenizer must skip.\n *\n * Includes hidden dirs (`.git`, `.next`, `.turbo`) explicitly instead of\n * blanket-skipping `entry.name.startsWith('.')`, so a dotted directory the\n * template ships is still tokenized rather than passed over.\n *\n * ⚠️ No such directory reaches the tarball today: `build-templates.ts`'s\n * `copyTree` drops EVERY dot-prefixed directory, so `.github/` and `.vscode/`\n * cannot be present for this walk to find. An earlier version of this comment\n * cited them as live examples. The permissive shape here is still the right\n * one — it means adding a dotted directory to the overlay needs no change\n * here — but it is a capability, not a description.\n */\nconst SKIP_DIRS = new Set<string>([\n 'node_modules',\n 'dist',\n 'build',\n 'coverage',\n 'out',\n '.git',\n '.next',\n '.turbo',\n])\n\nasync function* walkFiles(root: string): AsyncGenerator<string> {\n const entries = await readdir(root, { withFileTypes: true })\n for (const entry of entries) {\n const full = join(root, entry.name)\n if (entry.isDirectory()) {\n if (SKIP_DIRS.has(entry.name)) continue\n yield* walkFiles(full)\n } else if (entry.isFile()) {\n yield full\n }\n }\n}\n\nfunction isTokenizable(filePath: string): boolean {\n // Use `path.basename` (not manual `lastIndexOf('/')`) so the function\n // works on Windows where `walkFiles` joins with `\\\\`.\n const name = basename(filePath)\n if (TOKENIZABLE_BASENAMES.has(name)) return true\n const dot = name.lastIndexOf('.')\n if (dot < 0) return false\n const ext = name.slice(dot)\n return TOKENIZABLE_EXTENSIONS.has(ext)\n}\n\nfunction toTitleCase(name: string): string {\n return name\n .split(/[-_\\s]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["tarExtract"],"sources":["../src/port.ts","../src/prompts.ts","../src/utils.ts","../src/scaffold.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport { createServer } from 'node:net'\n\n/**\n * Pick a host port to map onto the scaffolded project's Postgres container.\n *\n * Why not 5432: every Lumi developer who has another Lumi project (or just a\n * local Postgres) on the standard port will hit a `port already in use`\n * collision the first time they run `docker compose up`. Surprising, slow to\n * diagnose, and easy to avoid.\n *\n * Strategy:\n * 1. Hash the project name into a stable base in `PORT_RANGE_LOW..HIGH` so\n * re-scaffolding the same name lands on the same port (predictable for\n * the developer, no churn in `.env`).\n * 2. Probe upward from that base by binding a temporary TCP server until\n * we find one that's actually free *right now*.\n * 3. If 500 consecutive probes fail (extremely unlikely), fall back to the\n * hashed base — the user will see docker's own error and can pick.\n */\n\nconst PORT_RANGE_LOW = 5500\nconst PORT_RANGE_HIGH = 5999\nconst PROBE_TIMEOUT_MS = 500\nconst MAX_PROBES = 500\n\n/**\n * Project-name → starting port. Stable + deterministic so the same project\n * always lands on the same base when no one else is squatting on it.\n */\nexport function hashProjectNameToPortBase(name: string): number {\n const digest = createHash('sha1').update(name).digest()\n const span = PORT_RANGE_HIGH - PORT_RANGE_LOW + 1\n return PORT_RANGE_LOW + (digest.readUInt32BE(0) % span)\n}\n\nasync function isPortFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const srv = createServer()\n let settled = false\n const finish = (free: boolean) => {\n if (settled) return\n settled = true\n srv.close()\n resolve(free)\n }\n const timer = setTimeout(() => finish(false), PROBE_TIMEOUT_MS)\n srv.once('error', () => {\n clearTimeout(timer)\n finish(false)\n })\n srv.once('listening', () => {\n clearTimeout(timer)\n finish(true)\n })\n // Bind on 127.0.0.1 — the docker-compose host port also binds to 127.0.0.1\n // (or 0.0.0.0, which conflicts with anything on the same port on lo).\n srv.listen(port, '127.0.0.1')\n })\n}\n\n/**\n * Find a free host port for the project. Walks upward from the hashed base\n * with wraparound inside `PORT_RANGE_LOW..HIGH`.\n */\nexport async function findFreePostgresPort(projectName: string): Promise<number> {\n const span = PORT_RANGE_HIGH - PORT_RANGE_LOW + 1\n const base = hashProjectNameToPortBase(projectName)\n for (let i = 0; i < MAX_PROBES; i++) {\n const port = PORT_RANGE_LOW + ((base - PORT_RANGE_LOW + i) % span)\n if (await isPortFree(port)) return port\n }\n return base\n}\n","import * as p from '@clack/prompts'\nimport type { Preset, ProjectOptions } from './types'\n\ninterface CliFlags {\n name?: string\n preset?: Preset\n install?: boolean\n}\n\n/**\n * npm package name rules: lowercase alphanumeric + hyphen, must start with a\n * letter/digit (so `-foo` is rejected), max 214 chars (npm limit).\n *\n * The character class is intentionally narrow — anything outside it would\n * either break npm publish or, more importantly, allow shell metacharacters\n * to reach the args that get forwarded to `pnpm create next-app`. The exec\n * layer in `utils.ts` uses `execFileSync` (no shell) as defense-in-depth,\n * but the regex stops the bad input at the boundary.\n */\nconst PROJECT_NAME_RE = /^[a-z0-9][a-z0-9-]{0,213}$/\n\n/**\n * Names that would collide with a workspace member the scaffold emits.\n *\n * The emitted tree is a pnpm workspace whose root manifest is named after the\n * project and whose members are `admin` and `front`. `create-lumi admin` would\n * therefore produce two projects called `admin` in one workspace — pnpm objects\n * to the duplicate, and every delegating root script (`pnpm --filter admin …`)\n * becomes ambiguous.\n */\nconst RESERVED_PROJECT_NAMES = new Set(['admin', 'front'])\n\nexport function validateProjectName(value: string): string | undefined {\n if (!value) return 'Project name is required'\n if (!PROJECT_NAME_RE.test(value)) {\n return 'Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).'\n }\n if (RESERVED_PROJECT_NAMES.has(value)) {\n return `\"${value}\" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`\n }\n return undefined\n}\n\n/**\n * The `demo` preset runs `pnpm exec lumi add <plugin>` after deps install,\n * which requires `node_modules/.bin/lumi` to exist. `--no-install` would\n * skip that, so the combo is invalid and must be caught before anything is\n * written to disk.\n */\nexport function validatePresetInstall(preset: Preset, installDeps: boolean): string | undefined {\n if (preset === 'demo' && !installDeps) {\n return (\n 'Preset `demo` installs plugins via `lumi add` after deps resolve, ' +\n 'so --install must be true (or pick `minimal`).'\n )\n }\n return undefined\n}\n\n/**\n * Parse `--preset=<name>` / `--preset <name>` / `--full` /\n * `--install` / `--no-install` from argv.\n *\n * `--full` is sugar for `--preset demo`. Unknown `--preset` values are\n * ignored — the interactive prompt then picks up.\n */\nexport function parseFlags(argv: string[]): CliFlags {\n const flags: CliFlags = {}\n const args = argv.slice(2)\n\n for (let i = 0; i < args.length; i++) {\n const arg = args[i]\n if (arg === undefined) continue\n if (arg === '--full') {\n flags.preset = 'demo'\n } else if (arg === '--preset' && args[i + 1]) {\n const val = args[++i]\n if (val === 'minimal' || val === 'demo') flags.preset = val\n } else if (arg.startsWith('--preset=')) {\n const val = arg.slice('--preset='.length)\n if (val === 'minimal' || val === 'demo') flags.preset = val\n } else if (arg === '--install') {\n flags.install = true\n } else if (arg === '--no-install') {\n flags.install = false\n } else if (!arg.startsWith('-') && !flags.name) {\n flags.name = arg\n }\n }\n\n return flags\n}\n\nexport async function runPrompts(initialName?: string): Promise<ProjectOptions | null> {\n const flags = parseFlags(process.argv)\n const resolvedName = flags.name ?? initialName\n\n if (resolvedName !== undefined) {\n const err = validateProjectName(resolvedName)\n if (err) {\n p.intro('@murumets-ee/create')\n p.cancel(err)\n return null\n }\n }\n\n if (flags.preset !== undefined && flags.install !== undefined) {\n const err = validatePresetInstall(flags.preset, flags.install)\n if (err) {\n p.intro('@murumets-ee/create')\n p.cancel(err)\n return null\n }\n }\n\n if (resolvedName && flags.install !== undefined && flags.preset !== undefined) {\n return {\n name: resolvedName,\n installDeps: flags.install,\n preset: flags.preset,\n }\n }\n\n p.intro('@murumets-ee/create')\n\n const name =\n resolvedName ??\n (await p.text({\n message: 'Project name:',\n placeholder: 'my-project',\n validate: validateProjectName,\n }))\n\n if (p.isCancel(name)) {\n p.cancel('Cancelled.')\n return null\n }\n\n const preset =\n flags.preset ??\n (await p.select({\n message: 'Preset:',\n options: [\n {\n value: 'demo' as const,\n label: 'Demo',\n hint: 'auth + media + ticketing (recommended for first-time setup)',\n },\n {\n value: 'minimal' as const,\n label: 'Minimal',\n hint: 'auth + infrastructure only — add plugins with `lumi add`',\n },\n ],\n initialValue: 'demo' as const,\n }))\n\n if (p.isCancel(preset)) {\n p.cancel('Cancelled.')\n return null\n }\n\n const installDeps =\n flags.install ??\n (await p.confirm({\n message: 'Install dependencies?',\n initialValue: true,\n }))\n\n if (p.isCancel(installDeps)) {\n p.cancel('Cancelled.')\n return null\n }\n\n const presetErr = validatePresetInstall(preset, installDeps)\n if (presetErr) {\n p.cancel(presetErr)\n return null\n }\n\n return {\n name,\n installDeps,\n preset,\n }\n}\n","import { execFileSync } from 'node:child_process'\n\ninterface RunOptions {\n cwd?: string\n}\n\n/**\n * On Windows, `pnpm` resolves to `pnpm.CMD` (a batch shim). Node 18.20+ /\n * 20.12+ / 21.7+ / 22+ refuses to spawn `.cmd` / `.bat` files directly per\n * CVE-2024-27980 mitigation, so a plain `execFileSync('pnpm', ...)` throws\n * `EINVAL`. We work around it by routing through `cmd.exe` via\n * `{ shell: true }` on Windows only.\n *\n * Safety: the only user-controlled arg in this package's call sites is\n * `options.name`, which is gated by `validateProjectName`'s regex\n * `/^[a-z0-9][a-z0-9-]{0,213}$/` — none of cmd.exe's metacharacters\n * (`& | < > ^ ( ) % !`) can pass that gate. On non-Windows hosts we keep\n * `shell: false` so POSIX shell metachars are inert by construction.\n */\nconst useShell = process.platform === 'win32'\n\n/**\n * Run a binary with discrete args. Captures stdout.\n *\n * On POSIX, args are passed verbatim (no shell). On Windows, we route via\n * `cmd.exe` to handle `.cmd`/`.bat` shims — see the comment on `useShell`\n * for the safety argument.\n */\nexport function runCommandPiped(cmd: string, args: string[], options?: RunOptions): string {\n return execFileSync(cmd, args, {\n cwd: options?.cwd,\n stdio: 'pipe',\n encoding: 'utf-8',\n shell: useShell,\n })\n}\n\n/**\n * Like `runCommandPiped` but inherits the parent's stdio so the user sees\n * realtime output (and any error diagnostics aren't buffered until failure).\n */\nexport function runCommandInherit(cmd: string, args: string[], options?: RunOptions): void {\n execFileSync(cmd, args, {\n cwd: options?.cwd,\n stdio: 'inherit',\n shell: useShell,\n })\n}\n","import { existsSync, readFileSync } from 'node:fs'\nimport { lstat, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { basename, dirname, join, resolve, sep } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { extract as tarExtract } from 'tar'\nimport { findFreePostgresPort } from './port'\nimport { validatePresetInstall, validateProjectName } from './prompts'\nimport type { ProjectOptions } from './types'\nimport { runCommandInherit, runCommandPiped } from './utils'\n\n/**\n * The workspace member that holds the CMS. `lumi` resolves its project root by\n * walking UP for `lumi.config.ts`, so every CLI invocation (`lumi add`,\n * `lumi migrate`) has to run from here rather than from the workspace root.\n */\nconst ADMIN_APP_DIR = 'apps/admin'\n\nexport type ProgressCallback = (message: string) => void\n\n// File extensions eligible for token replacement during scaffolding.\nconst TOKENIZABLE_EXTENSIONS = new Set<string>([\n '.ts',\n '.tsx',\n '.mts',\n '.json',\n '.md',\n '.yml',\n '.yaml',\n '.css',\n '.html',\n])\n\nconst TOKENIZABLE_BASENAMES = new Set<string>(['.env.example', 'Dockerfile', '.gitignore'])\n\nconst TOKEN_PROJECT_NAME = '__PROJECT_NAME__'\nconst TOKEN_PROJECT_NAME_TITLE = '__PROJECT_NAME_TITLE__'\nconst TOKEN_POSTGRES_HOST_PORT = '__POSTGRES_HOST_PORT__'\n\nexport async function scaffold(\n options: ProjectOptions,\n onProgress?: ProgressCallback,\n): Promise<void> {\n await scaffoldSingle(options, onProgress)\n}\n\n// ---------------------------------------------------------------------------\n// scaffoldSingle — extract a prebuilt tarball, then tokenize.\n// ---------------------------------------------------------------------------\n\nasync function scaffoldSingle(\n options: ProjectOptions,\n onProgress?: ProgressCallback,\n): Promise<void> {\n // Defense-in-depth: re-validate the option tuple at the boundary. The\n // prompt layer is the canonical gate, but `scaffold` is also exported as\n // a programmatic API — we don't want a buggy caller to skip the check.\n const nameErr = validateProjectName(options.name)\n if (nameErr) throw new Error(nameErr)\n const presetErr = validatePresetInstall(options.preset, options.installDeps)\n if (presetErr) throw new Error(presetErr)\n\n const projectDir = join(process.cwd(), options.name)\n const cwdResolved = resolve(process.cwd())\n const projectResolved = resolve(projectDir)\n if (projectResolved !== cwdResolved && !projectResolved.startsWith(cwdResolved + sep)) {\n throw new Error(\n `Project directory ${projectResolved} resolves outside the current ` +\n `working directory ${cwdResolved}. Refusing to write.`,\n )\n }\n\n // 1. Extract the prebuilt template tarball into the project directory.\n //\n // This used to run `pnpm create next-app` first and extract over the\n // result. That step was removed when the scaffold became a workspace, and\n // not merely because the nesting was awkward: measured against\n // create-next-app@16, it emits a NESTED `pnpm-workspace.yaml` that hijacks\n // the workspace root for any pnpm command run inside the app, a nested git\n // repository, and a `packageManager` pin in the wrong manifest — while\n // contributing nothing the tarball does not already carry (`apps/admin`\n // ships its own `.gitignore`, `postcss.config.mjs`, `tsconfig.json`,\n // `next.config.ts` and `public/`). Undoing three emissions to gain zero\n // files is strictly more work than not calling it.\n //\n // The template is the minimal Lumi skeleton — run `lumi add <plugin>`\n // after scaffolding to layer in plugins (auth is preinstalled).\n onProgress?.('Extracting template...')\n await createTargetDirectory(projectDir)\n await extractTemplate(projectDir)\n\n // 2. Replace tokens in tokenizable files AND in directory names. Pick a free\n // Postgres host port here (not 5432 — collides with anyone else running\n // Postgres locally, which is virtually every Lumi developer).\n onProgress?.('Personalizing template...')\n const postgresHostPort = await findFreePostgresPort(options.name)\n await tokenizeProject(projectDir, options.name, postgresHostPort)\n await tokenizeDirectoryNames(projectDir, options.name)\n\n // 3. Install deps\n if (options.installDeps) {\n onProgress?.('Installing dependencies...')\n runCommandPiped('pnpm', ['install'], { cwd: projectDir })\n }\n\n // 4. Layer on demo-preset plugins via `lumi add`. Skipped for the minimal\n // preset — user runs `lumi add <plugin>` themselves. Requires the deps\n // step above, because `pnpm exec lumi` resolves the bin from\n // `node_modules/.bin/`.\n //\n // cwd is the admin app, not the project root: `lumi` finds its project\n // root by walking UP for `lumi.config.ts`, and nothing above `apps/admin`\n // has one.\n if (options.preset === 'demo') {\n const adminDir = join(projectDir, ...ADMIN_APP_DIR.split('/'))\n\n // INSTALL FIRST, then wire.\n //\n // `lumi add` does NOT install anything — it edits `lumi.config.ts`,\n // scaffolds routes, updates the manifest, and PRINTS `pnpm add <pkg>` as a\n // next step for the human. So adding a plugin whose package is absent\n // produces a config importing a module that cannot resolve, and the very\n // next command (`lumi migrate`, which loads that config) dies with\n // `Package subpath './plugin' is not defined by \"exports\"` — pointing at\n // resolution rather than at the missing dependency.\n //\n // This was invisible until the baseline config was trimmed to a content\n // set: every demo plugin used to be wired AND depended on already, so every\n // `lumi add` was a no-op reconcile and the import always resolved. Now\n // `ticketing` and `commerce` are real additions, and they need their\n // packages.\n //\n // Installing the ones already present is harmless — pnpm resolves them from\n // the lockfile — so the list is not filtered. Env is inherited, which is\n // what lets `scaffold-e2e` resolve these from its local registry rather\n // than from npm.\n const demoPackages = [...new Set(DEMO_PRESET_PLUGINS.flatMap((p) => DEMO_PRESET_PACKAGES[p]))]\n onProgress?.('Installing demo-preset packages...')\n await addDemoPackagesViaCatalog(projectDir, adminDir, demoPackages)\n\n for (const plugin of DEMO_PRESET_PLUGINS) {\n onProgress?.(`Adding ${plugin} plugin...`)\n // stdio=inherit so the user sees `lumi add`'s own step-by-step\n // output + any error messages (otherwise they get buffered on\n // failure and surfaced as a generic execSync error).\n runCommandInherit('pnpm', ['exec', 'lumi', 'add', plugin], { cwd: adminDir })\n }\n }\n}\n\n/**\n * Add the demo-preset packages THROUGH THE CATALOG, not with a bare `pnpm add`.\n *\n * `pnpm add` writes a literal `^x.y.z` into package.json. Every other\n * `@murumets-ee/*` dependency in a scaffolded project is `catalog:lumi`, so\n * these were the only ones that could drift from the rest of the toolkit — and\n * a pre-1.0 caret pins the MINOR, so `^0.42.0` does not match `0.43.0`.\n *\n * The result was a complete parallel toolkit tree: pnpm installs both versions,\n * and the app dies at typecheck with `Property '[GATED]' is missing` — an error\n * that names a type property and not one of the packages that caused it. It\n * cost a production deploy afternoon before the cause was found.\n *\n * The catalog exists precisely to make this unrepresentable; pnpm-workspace.yaml\n * says so in its own comment (\"pnpm can never resolve two different versions of\n * the same @murumets-ee package simultaneously\"). Three packages escaped it\n * because they arrived through a code path that never learned about catalogs.\n *\n * The version is READ FROM THE CATALOG rather than passed in: the scaffold's\n * own toolkit version is whatever `build-templates.ts` baked into the workspace\n * file, and re-deriving it anywhere else is how the two drift apart again.\n */\nasync function addDemoPackagesViaCatalog(\n projectDir: string,\n adminDir: string,\n packages: readonly string[],\n): Promise<void> {\n // Only `@murumets-ee/*` may go through the catalog, and the reason is the\n // version this function derives: \"whatever toolkit version this scaffold was\n // built for\". That is right for a toolkit package and nonsense for anything\n // else — cataloguing `@elastic/elasticsearch` at `0.46.0` would ask pnpm for a\n // release that does not exist. Third-party packages a recipe needs already\n // ship in the template's own `package.json` (they are not in\n // `SCAFFOLD_OMIT_DEPS`), so the normal case here is that `thirdParty` is\n // entirely already-present and this does nothing.\n const toolkitPackages = packages.filter((name) => name.startsWith('@murumets-ee/'))\n const thirdParty = packages.filter((name) => !name.startsWith('@murumets-ee/'))\n\n const wsPath = join(projectDir, 'pnpm-workspace.yaml')\n const ws = await readFile(wsPath, 'utf-8')\n\n // Any existing `'@murumets-ee/x': 1.2.3` entry gives the toolkit version this\n // scaffold was built for. If the catalog is somehow empty there is nothing to\n // agree WITH, so fall back to `pnpm add` rather than invent a version.\n const versionMatch = ws.match(/^\\s+'?@murumets-ee\\/[a-z-]+'?:\\s*([0-9][^\\s]*)\\s*$/m)\n if (!versionMatch?.[1]) {\n runCommandInherit('pnpm', ['add', ...packages], { cwd: adminDir })\n return\n }\n const version = versionMatch[1]\n\n // 1. Catalog entries for anything missing, at that same version.\n const missing = toolkitPackages.filter((name) => !new RegExp(`'?${name}'?:`).test(ws))\n if (missing.length > 0) {\n const entries = missing.map((name) => ` '${name}': ${version}`).join('\\n')\n // Tolerates both the populated (` lumi:`) and placeholder (` lumi: {}`)\n // forms, since the overlay ships the latter.\n const next = ws.replace(/^( {2}lumi:)([ \\t]*\\{\\})?$/m, `$1\\n${entries}`)\n if (next === ws) {\n // No `lumi:` key to extend — do not silently produce a package.json\n // referencing a catalog that cannot resolve it.\n runCommandInherit('pnpm', ['add', ...packages], { cwd: adminDir })\n return\n }\n await writeFile(wsPath, next, 'utf-8')\n }\n\n // 2. Declare the deps as catalog references, then install once.\n const pkgPath = join(adminDir, 'package.json')\n const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')) as {\n dependencies?: Record<string, string>\n }\n pkg.dependencies ??= {}\n for (const name of toolkitPackages) pkg.dependencies[name] = 'catalog:lumi'\n // Third-party packages get a real range, never `catalog:lumi`, and only when\n // the template does not already carry them — which it does today for every\n // one a recipe names. Left to `pnpm add` so pnpm resolves the range rather\n // than this function guessing one.\n const missingThirdParty = thirdParty.filter((name) => !(name in (pkg.dependencies ?? {})))\n pkg.dependencies = Object.fromEntries(\n Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b)),\n )\n await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\\n`, 'utf-8')\n\n // `--no-frozen-lockfile` is REQUIRED, not a convenience. CI sets\n // `frozen-lockfile` by default, and the lines above have just added five\n // dependencies to package.json — so a frozen install refuses with\n // ERR_PNPM_OUTDATED_LOCKFILE (\"specifiers in the lockfile don't match\n // specifiers in package.json\"). Updating the lockfile is the POINT of this\n // step; the `pnpm add` it replaces did it implicitly. Caught by scaffold-e2e,\n // which is the only job that runs a real scaffold install.\n runCommandInherit('pnpm', ['install', '--no-frozen-lockfile'], { cwd: adminDir })\n\n if (missingThirdParty.length > 0) {\n runCommandInherit('pnpm', ['add', ...missingThirdParty], { cwd: adminDir })\n }\n}\n\n/**\n * Optional plugins layered on top of the baseline skeleton for the demo\n * preset. Must stay in sync with `CATALOG` in `@murumets-ee/cli` minus\n * `auth` (which is preinstalled in the baseline `lumi.config.ts`). The\n * `preset-parity.test.ts` test reads `CATALOG` at test time and asserts\n * `[...DEMO_PRESET_PLUGINS, 'auth'].sort()` equals\n * `Object.keys(CATALOG).sort()` — rename or add an entry here whenever\n * the CLI catalog grows.\n *\n * Order matters. `taxonomy` scaffolds `entities/category.ts` +\n * `entities/tag.ts`; `blog` imports those indirectly via entity\n * references. Running `taxonomy` first keeps the scaffolded `Article`\n * reference-field target available at plugin-resolution time.\n *\n * `media`, `ticketing`, and `commerce` don't depend on the scaffolded\n * entity files (each brings its own entities via the plugin — commerce\n * contributes its `brand` taxonomy vocab itself), so they can run in any\n * position but live at the end for readability.\n */\nconst DEMO_PRESET_PLUGINS = ['taxonomy', 'blog', 'media', 'ticketing', 'commerce', 'rent'] as const\n\n/**\n * Every npm package a demo-preset plugin needs at RUNTIME — not just the one\n * the CATALOG names.\n *\n * The CATALOG's `packageName` is what `lumi add` imports into\n * `lumi.config.ts`. It is not the whole surface: `lumi add` also SCAFFOLDS API\n * routes, and those import their own packages. `ticketing` is the case in\n * point — the config gets `@murumets-ee/ticketing-ui`, while the inbound\n * webhook route it writes does `await import('@murumets-ee/ticketing')`. Under\n * pnpm's strict layout a transitive dependency is not resolvable from the app,\n * so installing only the CATALOG package leaves `next build` failing on a file\n * the CLI itself just wrote.\n *\n * Hence a LIST per plugin. It used to be this file's own private knowledge,\n * which is why it drifted: the CATALOG described what to WIRE and nothing in it\n * described what its templates IMPORT, so the demo preset silently carried the\n * only correct answer and `lumi add ticketing` by hand shipped the broken half.\n * The CATALOG now states it (`CatalogEntry.extraPackages`), and\n * `preset-parity.test.ts` asserts every list here CONTAINS the entry's\n * `packageName` AND its `extraPackages` — so this stays a mirror that a test\n * can check rather than a second opinion.\n *\n * Mirrored rather than imported because `@murumets-ee/cli` is not a dependency\n * of this package — it is a dependency of what this package EMITS.\n */\nconst DEMO_PRESET_PACKAGES: Record<(typeof DEMO_PRESET_PLUGINS)[number], readonly string[]> = {\n taxonomy: ['@murumets-ee/taxonomy'],\n // `@murumets-ee/blocks` is NOT redundant: the scaffolded `entities/page.ts`\n // imports `@murumets-ee/blocks/server`.\n blog: ['@murumets-ee/content', '@murumets-ee/blocks'],\n media: ['@murumets-ee/media'],\n // `@murumets-ee/ticketing` is NOT redundant with `-ui`: the scaffolded\n // `app/api/ticketing/inbound/route.ts` imports it directly.\n ticketing: ['@murumets-ee/ticketing-ui', '@murumets-ee/ticketing'],\n // Three beyond `-ui`, all imported by the two files `lumi add commerce`\n // scaffolds: the storefront mount imports `@murumets-ee/commerce/server`, and\n // `lib/parts-search.ts` imports the Elasticsearch backend and its client.\n commerce: [\n '@murumets-ee/commerce-ui',\n '@murumets-ee/commerce',\n '@murumets-ee/search-elasticsearch',\n '@elastic/elasticsearch',\n ],\n // LAST, and the order is load-bearing: rent does not define its own customer,\n // it extends `commerce.customer` with B2B billing fields (rent SD002), so\n // commerce must be added first or the reference target does not exist.\n //\n // `@murumets-ee/rent` is NOT redundant with `-ui`: the demo's booking API\n // route imports the server package directly, exactly as ticketing does.\n rent: ['@murumets-ee/rent-ui', '@murumets-ee/rent'],\n}\n\n// ---------------------------------------------------------------------------\n// Tarball extraction\n// ---------------------------------------------------------------------------\n\n/**\n * Create the project directory, refusing anything that already exists and is\n * not an empty real directory.\n *\n * `create-next-app` used to own the non-empty half; it ran first and bailed on\n * a populated target. With the tarball extracted directly, nothing else stands\n * between a typo'd project name and an overwritten working tree.\n *\n * 🔒 The symlink half is the one worth spelling out, because three functions\n * that look safe compose into an escape. `existsSync` and `readdir` FOLLOW a\n * link, so a symlink pointing at an empty directory reads as an empty target.\n * `mkdir(…, { recursive: true })` then leaves the link in place rather than\n * replacing it. And the caller's boundary check compares resolved PATH STRINGS,\n * not real paths, so `<cwd>/myproject` passes while the bytes land wherever the\n * link points. `lstat` is what distinguishes the two, and `realpath` is what\n * makes the boundary check mean what it says.\n */\nasync function createTargetDirectory(projectDir: string): Promise<void> {\n // 🔒 NON-recursive `mkdir` first, and that is the security-relevant part.\n //\n // On the overwhelmingly common path — the directory does not exist — this\n // both creates it and PROVES what it is: the call succeeds only if it did\n // the creating, so there is no window in which anything else could have put\n // a symlink there. A check-then-create pair can never say that, however\n // carefully the check is written.\n //\n // `recursive: false` is what makes it work: recursive `mkdir` treats an\n // existing path as success and hands back a symlink happily. The parent is\n // always `process.cwd()`, so there is nothing to create recursively anyway.\n try {\n await mkdir(projectDir)\n return\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n }\n\n // It already existed, so fall back to inspecting it. This path retains a\n // narrow check-to-use window that only a local process racing us could\n // exploit — and such a process could equally rewrite the scaffolded files a\n // second later, so closing it would not change what an attacker can do.\n await assertTargetIsEmpty(projectDir)\n}\n\nasync function assertTargetIsEmpty(projectDir: string): Promise<void> {\n if (!existsSync(projectDir)) return\n\n const info = await lstat(projectDir)\n if (info.isSymbolicLink()) {\n throw new Error(\n `${projectDir} is a symbolic link. Refusing to scaffold through it — the ` +\n `template would be written to wherever it points, which is outside the ` +\n `directory you named. Remove the link, or pick a different project name.`,\n )\n }\n if (!info.isDirectory()) {\n throw new Error(\n `${projectDir} already exists and is not a directory. Refusing to scaffold ` +\n `over it — pick a different project name, or remove the file first.`,\n )\n }\n\n const entries = await readdir(projectDir)\n if (entries.length > 0) {\n throw new Error(\n `${projectDir} already exists and is not empty. Refusing to scaffold over it — ` +\n `pick a different project name, or remove the directory first.`,\n )\n }\n}\n\nasync function extractTemplate(destDir: string): Promise<void> {\n const tarball = resolveTemplateTarball()\n if (!existsSync(tarball)) {\n throw new Error(\n `Template tarball missing: ${tarball}\\n` +\n `Run \\`pnpm --filter @murumets-ee/create build:templates\\` first.`,\n )\n }\n await tarExtract({\n file: tarball,\n cwd: destDir,\n // Defense-in-depth: tar v7 already refuses absolute paths and `..`\n // traversal, but an explicit filter makes the contract loud and lets\n // us log if a malformed entry ever slips through. Includes Windows\n // drive-letter absolutes (`C:/...`) which slip past `startsWith('/')`.\n filter: (path) => {\n const isAbsoluteLike = path.startsWith('/') || /^[A-Za-z]:[/\\\\]/.test(path)\n const hasTraversal = path.split(/[/\\\\]/).includes('..')\n if (isAbsoluteLike || hasTraversal) {\n console.warn(`@murumets-ee/create: refusing tar entry with traversal: ${path}`)\n return false\n }\n return true\n },\n })\n}\n\n/**\n * Locate the template tarball relative to the published `@murumets-ee/create`\n * package root.\n *\n * Anchors on the package's own `package.json` (matched by `name`) so a parent\n * project that happens to have a `templates/` directory at a higher path\n * can't hijack the resolution. This is a defense-in-depth measure against\n * template-substitution attacks where `create-lumi` is invoked from inside\n * a malicious project layout.\n */\nfunction resolveTemplateTarball(): string {\n const here = dirname(fileURLToPath(import.meta.url))\n let dir = here\n for (let i = 0; i < 5; i++) {\n const pkgJsonPath = join(dir, 'package.json')\n if (existsSync(pkgJsonPath)) {\n try {\n const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { name?: string }\n if (pkg.name === '@murumets-ee/create') {\n return join(dir, 'templates', 'template-demo.tar.gz')\n }\n } catch (err) {\n // Don't silently swallow — a malformed package.json at the package\n // root is a corrupt-install signal that the caller should see.\n console.warn(\n `@murumets-ee/create: skipped malformed package.json at ${pkgJsonPath}: ` +\n `${err instanceof Error ? err.message : String(err)}`,\n )\n }\n }\n const parent = dirname(dir)\n if (parent === dir) break // hit filesystem root\n dir = parent\n }\n throw new Error(\n `Could not find @murumets-ee/create package root above ${here}. ` +\n `template-demo.tar.gz cannot be located.`,\n )\n}\n\n// ---------------------------------------------------------------------------\n// Token replacement\n// ---------------------------------------------------------------------------\n\n/**\n * Walk the project directory and replace `__PROJECT_NAME__` /\n * `__PROJECT_NAME_TITLE__` tokens in eligible text files.\n *\n * Binary files (images, fonts) and `node_modules` / `.next` are skipped\n * to avoid corruption and pointless work.\n */\nasync function tokenizeProject(\n projectDir: string,\n projectName: string,\n postgresHostPort: number,\n): Promise<void> {\n const titleName = toTitleCase(projectName)\n const portStr = String(postgresHostPort)\n for await (const filePath of walkFiles(projectDir)) {\n if (!isTokenizable(filePath)) continue\n const original = await readFile(filePath, 'utf-8')\n if (\n !original.includes(TOKEN_PROJECT_NAME) &&\n !original.includes(TOKEN_PROJECT_NAME_TITLE) &&\n !original.includes(TOKEN_POSTGRES_HOST_PORT)\n ) {\n continue\n }\n const replaced = original\n .split(TOKEN_PROJECT_NAME_TITLE)\n .join(titleName)\n .split(TOKEN_PROJECT_NAME)\n .join(projectName)\n .split(TOKEN_POSTGRES_HOST_PORT)\n .join(portStr)\n await writeFile(filePath, replaced, 'utf-8')\n }\n}\n\n/**\n * Rename directories whose NAME carries `__PROJECT_NAME__`.\n *\n * The tarball ships `packages/__PROJECT_NAME__-blocks/`, so a project called\n * `acme` gets `packages/acme-blocks` — matching how `packages/kulli-blocks`\n * reads in the toolkit monorepo, and matching the package name inside it\n * (which `tokenizeProject` has already rewritten).\n *\n * Renames are collected DEEPEST-FIRST and applied in that order, so a\n * tokenized directory nested inside another tokenized directory does not have\n * its path invalidated by its parent moving first.\n */\nasync function tokenizeDirectoryNames(projectDir: string, projectName: string): Promise<void> {\n const pending: { from: string; to: string }[] = []\n\n async function walk(dir: string): Promise<void> {\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n if (!entry.isDirectory()) continue\n if (SKIP_DIRS.has(entry.name)) continue\n const full = join(dir, entry.name)\n await walk(full)\n if (entry.name.includes(TOKEN_PROJECT_NAME)) {\n pending.push({\n from: full,\n to: join(dir, entry.name.split(TOKEN_PROJECT_NAME).join(projectName)),\n })\n }\n }\n }\n\n await walk(projectDir)\n // `walk` pushes a directory only after recursing into it, so the list is\n // already deepest-first.\n for (const { from, to } of pending) {\n await rename(from, to)\n }\n}\n\n/**\n * Directories the tokenizer must skip.\n *\n * Includes hidden dirs (`.git`, `.next`, `.turbo`) explicitly instead of\n * blanket-skipping `entry.name.startsWith('.')`, so a dotted directory the\n * template ships is still tokenized rather than passed over.\n *\n * ⚠️ No such directory reaches the tarball today: `build-templates.ts`'s\n * `copyTree` drops EVERY dot-prefixed directory, so `.github/` and `.vscode/`\n * cannot be present for this walk to find. An earlier version of this comment\n * cited them as live examples. The permissive shape here is still the right\n * one — it means adding a dotted directory to the overlay needs no change\n * here — but it is a capability, not a description.\n */\nconst SKIP_DIRS = new Set<string>([\n 'node_modules',\n 'dist',\n 'build',\n 'coverage',\n 'out',\n '.git',\n '.next',\n '.turbo',\n])\n\nasync function* walkFiles(root: string): AsyncGenerator<string> {\n const entries = await readdir(root, { withFileTypes: true })\n for (const entry of entries) {\n const full = join(root, entry.name)\n if (entry.isDirectory()) {\n if (SKIP_DIRS.has(entry.name)) continue\n yield* walkFiles(full)\n } else if (entry.isFile()) {\n yield full\n }\n }\n}\n\nfunction isTokenizable(filePath: string): boolean {\n // Use `path.basename` (not manual `lastIndexOf('/')`) so the function\n // works on Windows where `walkFiles` joins with `\\\\`.\n const name = basename(filePath)\n if (TOKENIZABLE_BASENAMES.has(name)) return true\n const dot = name.lastIndexOf('.')\n if (dot < 0) return false\n const ext = name.slice(dot)\n return TOKENIZABLE_EXTENSIONS.has(ext)\n}\n\nfunction toTitleCase(name: string): string {\n return name\n .split(/[-_\\s]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/create",
3
- "version": "0.44.0",
3
+ "version": "0.47.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "tsdown": "^0.22.2",
31
31
  "tsx": "^4.22.4",
32
32
  "typescript": "^5.7.3",
33
- "@murumets-ee/cli": "^0.44.0"
33
+ "@murumets-ee/cli": "^0.47.0"
34
34
  },
35
35
  "typeCoverage": {
36
36
  "atLeast": 100
Binary file