@murumets-ee/create 0.32.3 → 0.33.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,3 +1,3 @@
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{readFile as i,readdir as a,rm 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{execFileSync as _}from"node:child_process";const v=/^[a-z0-9][a-z0-9-]{0,213}$/;function y(e){if(!e)return`Project name is required`;if(!v.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`}function b(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 x(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 S(t){let n=x(process.argv),r=n.name??t;if(r!==void 0){let t=y(r);if(t)return e.intro(`@murumets-ee/create`),e.cancel(t),null}if(n.preset!==void 0&&n.install!==void 0){let t=b(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:y});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=b(a,o);return s?(e.cancel(s),null):{name:i,installDeps:o,preset:a}}const C=5500;function w(e){return C+h(`sha1`).update(e).digest().readUInt32BE(0)%500}async function T(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 E(e){let t=w(e);for(let e=0;e<500;e++){let n=C+(t-C+e)%500;if(await T(n))return n}return t}const D=process.platform===`win32`;function O(e,t,n){return _(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:D})}function k(e,t,n){_(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:D})}async function A(e,t){for(let n of t)await o(u(e,n),{force:!0,recursive:!0})}const j=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),M=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),N=`__PROJECT_NAME__`,P=`__PROJECT_NAME_TITLE__`,F=`__POSTGRES_HOST_PORT__`;async function I(e,t){await L(e,t)}async function L(e,t){let n=y(e.name);if(n)throw Error(n);let r=b(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?.(`Creating Next.js app...`),O(`pnpm`,[`create`,`next-app@16`,e.name,`--typescript`,`--tailwind`,`--no-eslint`,`--app`,`--no-src-dir`,`--turbopack`,`--react-compiler`,`--skip-install`,`--import-alias`,`@/*`]),await A(i,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`app/globals.css`,`README.md`]),t?.(`Extracting template...`),await z(i),t?.(`Personalizing template...`);let s=await E(e.name);if(await V(i,e.name,s),e.installDeps&&(t?.(`Installing dependencies...`),O(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`)for(let e of R)t?.(`Adding ${e} plugin...`),k(`pnpm`,[`exec`,`lumi`,`add`,e],{cwd:i})}const R=[`taxonomy`,`blog`,`media`,`ticketing`];async function z(e){let t=B();if(!n(t))throw Error(`Template tarball missing: ${t}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await m({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 B(){let e=l(p(import.meta.url)),t=e;for(let e=0;e<5;e++){let e=u(t,`package.json`);if(n(e))try{if(JSON.parse(r(e,`utf-8`)).name===`@murumets-ee/create`)return u(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=l(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 V(e,t,n){let r=G(t),a=String(n);for await(let n of U(e)){if(!W(n))continue;let e=await i(n,`utf-8`);!e.includes(N)&&!e.includes(P)&&!e.includes(F)||await s(n,e.split(P).join(r).split(N).join(t).split(F).join(a),`utf-8`)}}const H=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*U(e){let t=await a(e,{withFileTypes:!0});for(let n of t){let t=u(e,n.name);if(n.isDirectory()){if(H.has(n.name))continue;yield*U(t)}else n.isFile()&&(yield t)}}function W(e){let t=c(e);if(M.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return j.has(r)}function G(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}async function K(){let n=process.argv[2],r=await S(n);if(!r)return;let i=e.spinner();try{i.start(`Creating Next.js app...`),await I(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`,`docker compose up -d`,`pnpm db:migrate`,`pnpm 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`];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{readFile as i,readdir as a,rm 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{execFileSync as _}from"node:child_process";const v=/^[a-z0-9][a-z0-9-]{0,213}$/;function y(e){if(!e)return`Project name is required`;if(!v.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`}function b(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 x(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 S(t){let n=x(process.argv),r=n.name??t;if(r!==void 0){let t=y(r);if(t)return e.intro(`@murumets-ee/create`),e.cancel(t),null}if(n.preset!==void 0&&n.install!==void 0){let t=b(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:y});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=b(a,o);return s?(e.cancel(s),null):{name:i,installDeps:o,preset:a}}const C=5500;function w(e){return C+h(`sha1`).update(e).digest().readUInt32BE(0)%500}async function T(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 E(e){let t=w(e);for(let e=0;e<500;e++){let n=C+(t-C+e)%500;if(await T(n))return n}return t}const D=process.platform===`win32`;function O(e,t,n){return _(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:D})}function k(e,t,n){_(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:D})}async function A(e,t){for(let n of t)await o(u(e,n),{force:!0,recursive:!0})}const j=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),M=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),N=`__PROJECT_NAME__`,P=`__PROJECT_NAME_TITLE__`,F=`__POSTGRES_HOST_PORT__`;async function I(e,t){await L(e,t)}async function L(e,t){let n=y(e.name);if(n)throw Error(n);let r=b(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?.(`Creating Next.js app...`),O(`pnpm`,[`create`,`next-app@16`,e.name,`--typescript`,`--tailwind`,`--no-eslint`,`--app`,`--no-src-dir`,`--turbopack`,`--react-compiler`,`--skip-install`,`--import-alias`,`@/*`]),await A(i,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`app/globals.css`,`README.md`]),t?.(`Extracting template...`),await z(i),t?.(`Personalizing template...`);let s=await E(e.name);if(await V(i,e.name,s),e.installDeps&&(t?.(`Installing dependencies...`),O(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`)for(let e of R)t?.(`Adding ${e} plugin...`),k(`pnpm`,[`exec`,`lumi`,`add`,e],{cwd:i})}const R=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`];async function z(e){let t=B();if(!n(t))throw Error(`Template tarball missing: ${t}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await m({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 B(){let e=l(p(import.meta.url)),t=e;for(let e=0;e<5;e++){let e=u(t,`package.json`);if(n(e))try{if(JSON.parse(r(e,`utf-8`)).name===`@murumets-ee/create`)return u(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=l(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 V(e,t,n){let r=G(t),a=String(n);for await(let n of U(e)){if(!W(n))continue;let e=await i(n,`utf-8`);!e.includes(N)&&!e.includes(P)&&!e.includes(F)||await s(n,e.split(P).join(r).split(N).join(t).split(F).join(a),`utf-8`)}}const H=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*U(e){let t=await a(e,{withFileTypes:!0});for(let n of t){let t=u(e,n.name);if(n.isDirectory()){if(H.has(n.name))continue;yield*U(t)}else n.isFile()&&(yield t)}}function W(e){let t=c(e);if(M.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return j.has(r)}function G(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}async function K(){let n=process.argv[2],r=await S(n);if(!r)return;let i=e.spinner();try{i.start(`Creating Next.js app...`),await I(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`,`docker compose up -d`,`pnpm db:migrate`,`pnpm 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`];e.note(n.join(`
3
3
  `),`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)}}K();export{};
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{existsSync as e,readFileSync as t}from"node:fs";import{readFile as n,readdir as r,rm as i,writeFile as a}from"node:fs/promises";import{basename as o,dirname as s,join as c,resolve as l,sep as u}from"node:path";import{fileURLToPath as d}from"node:url";import{extract as f}from"tar";import{createHash as p}from"node:crypto";import{createServer as m}from"node:net";import"@clack/prompts";import{execFileSync as h}from"node:child_process";const g=5500;function _(e){return g+p(`sha1`).update(e).digest().readUInt32BE(0)%500}async function v(e){return new Promise(t=>{let n=m(),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 y(e){let t=_(e);for(let e=0;e<500;e++){let n=g+(t-g+e)%500;if(await v(n))return n}return t}const b=/^[a-z0-9][a-z0-9-]{0,213}$/;function x(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).`}function S(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 C=process.platform===`win32`;function w(e,t,n){return h(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:C})}function T(e,t,n){h(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:C})}async function E(e,t){for(let n of t)await i(c(e,n),{force:!0,recursive:!0})}const D=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),O=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),k=`__PROJECT_NAME__`,A=`__PROJECT_NAME_TITLE__`,j=`__POSTGRES_HOST_PORT__`;async function M(e,t){await N(e,t)}async function N(e,t){let n=x(e.name);if(n)throw Error(n);let r=S(e.preset,e.installDeps);if(r)throw Error(r);let i=c(process.cwd(),e.name),a=l(process.cwd()),o=l(i);if(o!==a&&!o.startsWith(a+u))throw Error(`Project directory ${o} resolves outside the current working directory ${a}. Refusing to write.`);t?.(`Creating Next.js app...`),w(`pnpm`,[`create`,`next-app@16`,e.name,`--typescript`,`--tailwind`,`--no-eslint`,`--app`,`--no-src-dir`,`--turbopack`,`--react-compiler`,`--skip-install`,`--import-alias`,`@/*`]),await E(i,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`app/globals.css`,`README.md`]),t?.(`Extracting template...`),await F(i),t?.(`Personalizing template...`);let s=await y(e.name);if(await L(i,e.name,s),e.installDeps&&(t?.(`Installing dependencies...`),w(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`)for(let e of P)t?.(`Adding ${e} plugin...`),T(`pnpm`,[`exec`,`lumi`,`add`,e],{cwd:i})}const P=[`taxonomy`,`blog`,`media`,`ticketing`];async function F(t){let n=I();if(!e(n))throw Error(`Template tarball missing: ${n}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await f({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 I(){let n=s(d(import.meta.url)),r=n;for(let n=0;n<5;n++){let n=c(r,`package.json`);if(e(n))try{if(JSON.parse(t(n,`utf-8`)).name===`@murumets-ee/create`)return c(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=s(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 L(e,t,r){let i=V(t),o=String(r);for await(let r of z(e)){if(!B(r))continue;let e=await n(r,`utf-8`);!e.includes(k)&&!e.includes(A)&&!e.includes(j)||await a(r,e.split(A).join(i).split(k).join(t).split(j).join(o),`utf-8`)}}const R=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*z(e){let t=await r(e,{withFileTypes:!0});for(let n of t){let t=c(e,n.name);if(n.isDirectory()){if(R.has(n.name))continue;yield*z(t)}else n.isFile()&&(yield t)}}function B(e){let t=o(e);if(O.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return D.has(r)}function V(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}export{M as scaffold};
1
+ import{existsSync as e,readFileSync as t}from"node:fs";import{readFile as n,readdir as r,rm as i,writeFile as a}from"node:fs/promises";import{basename as o,dirname as s,join as c,resolve as l,sep as u}from"node:path";import{fileURLToPath as d}from"node:url";import{extract as f}from"tar";import{createHash as p}from"node:crypto";import{createServer as m}from"node:net";import"@clack/prompts";import{execFileSync as h}from"node:child_process";const g=5500;function _(e){return g+p(`sha1`).update(e).digest().readUInt32BE(0)%500}async function v(e){return new Promise(t=>{let n=m(),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 y(e){let t=_(e);for(let e=0;e<500;e++){let n=g+(t-g+e)%500;if(await v(n))return n}return t}const b=/^[a-z0-9][a-z0-9-]{0,213}$/;function x(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).`}function S(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 C=process.platform===`win32`;function w(e,t,n){return h(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:C})}function T(e,t,n){h(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:C})}async function E(e,t){for(let n of t)await i(c(e,n),{force:!0,recursive:!0})}const D=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),O=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),k=`__PROJECT_NAME__`,A=`__PROJECT_NAME_TITLE__`,j=`__POSTGRES_HOST_PORT__`;async function M(e,t){await N(e,t)}async function N(e,t){let n=x(e.name);if(n)throw Error(n);let r=S(e.preset,e.installDeps);if(r)throw Error(r);let i=c(process.cwd(),e.name),a=l(process.cwd()),o=l(i);if(o!==a&&!o.startsWith(a+u))throw Error(`Project directory ${o} resolves outside the current working directory ${a}. Refusing to write.`);t?.(`Creating Next.js app...`),w(`pnpm`,[`create`,`next-app@16`,e.name,`--typescript`,`--tailwind`,`--no-eslint`,`--app`,`--no-src-dir`,`--turbopack`,`--react-compiler`,`--skip-install`,`--import-alias`,`@/*`]),await E(i,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`app/globals.css`,`README.md`]),t?.(`Extracting template...`),await F(i),t?.(`Personalizing template...`);let s=await y(e.name);if(await L(i,e.name,s),e.installDeps&&(t?.(`Installing dependencies...`),w(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`)for(let e of P)t?.(`Adding ${e} plugin...`),T(`pnpm`,[`exec`,`lumi`,`add`,e],{cwd:i})}const P=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`];async function F(t){let n=I();if(!e(n))throw Error(`Template tarball missing: ${n}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await f({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 I(){let n=s(d(import.meta.url)),r=n;for(let n=0;n<5;n++){let n=c(r,`package.json`);if(e(n))try{if(JSON.parse(t(n,`utf-8`)).name===`@murumets-ee/create`)return c(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=s(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 L(e,t,r){let i=V(t),o=String(r);for await(let r of z(e)){if(!B(r))continue;let e=await n(r,`utf-8`);!e.includes(k)&&!e.includes(A)&&!e.includes(j)||await a(r,e.split(A).join(i).split(k).join(t).split(j).join(o),`utf-8`)}}const R=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*z(e){let t=await r(e,{withFileTypes:!0});for(let n of t){let t=c(e,n.name);if(n.isDirectory()){if(R.has(n.name))continue;yield*z(t)}else n.isFile()&&(yield t)}}function B(e){let t=o(e);if(O.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return D.has(r)}function V(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}export{M as scaffold};
2
2
  //# 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\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 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'\nimport { rm } from 'node:fs/promises'\nimport { join } from 'node:path'\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\nexport async function deleteFiles(basePath: string, files: string[]): Promise<void> {\n for (const file of files) {\n await rm(join(basePath, file), { force: true, recursive: true })\n }\n}\n","import { existsSync, readFileSync } from 'node:fs'\nimport { readdir, readFile, 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 { deleteFiles, runCommandInherit, runCommandPiped } from './utils'\n\n// Major Next.js version passed to `pnpm create next-app@<ver>`.\n// Bumped when the toolkit as a whole moves to a new Next major.\nconst CREATE_NEXT_APP_VERSION = '16'\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. Run create-next-app to get an authoritative Next.js skeleton\n // (lockfile, ESLint config, default tsconfig, etc.)\n onProgress?.('Creating Next.js app...')\n runCommandPiped('pnpm', [\n 'create',\n `next-app@${CREATE_NEXT_APP_VERSION}`,\n options.name,\n '--typescript',\n '--tailwind',\n '--no-eslint',\n '--app',\n '--no-src-dir',\n '--turbopack',\n '--react-compiler',\n '--skip-install',\n '--import-alias',\n '@/*',\n ])\n\n // 2. Cleanup default Next.js artifacts that the template will replace\n await deleteFiles(projectDir, [\n 'app/page.tsx',\n 'app/page.module.css',\n 'app/fonts',\n 'app/globals.css',\n 'README.md',\n ])\n\n // 3. Extract the prebuilt template tarball over the project directory.\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 extractTemplate(projectDir)\n\n // 4. Replace tokens in tokenizable files. Pick a free Postgres host port\n // here (not 5432 — collides with anyone else running Postgres locally,\n // 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\n // 5. Install deps\n if (options.installDeps) {\n onProgress?.('Installing dependencies...')\n runCommandPiped('pnpm', ['install'], { cwd: projectDir })\n }\n\n // 6. 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 if (options.preset === 'demo') {\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: projectDir })\n }\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` and `ticketing` don't depend on the entity files; they can\n * run in any position but live at the end for readability.\n */\nconst DEMO_PRESET_PLUGINS = ['taxonomy', 'blog', 'media', 'ticketing'] as const\n\n// ---------------------------------------------------------------------------\n// Tarball extraction\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 * Directories the tokenizer must skip.\n *\n * Includes hidden dirs (`.git`, `.next`, `.turbo`) explicitly instead of\n * blanket-skipping `entry.name.startsWith('.')` so that legitimate dotted\n * dirs like `.github/workflows/*.yml` and `.vscode/settings.json` get\n * tokenized when the template ships placeholders there.\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":"0bAqBA,MAAM,EAAiB,KASvB,SAAgB,EAA0B,EAAsB,CAG9D,OAAO,EAFQ,EAAW,MAAM,CAAC,CAAC,OAAO,CAAI,CAAC,CAAC,OAEjB,CAAC,CAAC,aAAa,CAAC,EAAI,GACpD,CAEA,eAAe,EAAW,EAAgC,CACxD,OAAO,IAAI,QAAS,GAAY,CAC9B,IAAM,EAAM,EAAa,EACrB,EAAU,GACR,EAAU,GAAkB,CAC5B,IACJ,EAAU,GACV,EAAI,MAAM,EACV,EAAQ,CAAI,EACd,EACM,EAAQ,eAAiB,EAAO,EAAK,EAAG,GAAgB,EAC9D,EAAI,KAAK,YAAe,CACtB,aAAa,CAAK,EAClB,EAAO,EAAK,CACd,CAAC,EACD,EAAI,KAAK,gBAAmB,CAC1B,aAAa,CAAK,EAClB,EAAO,EAAI,CACb,CAAC,EAGD,EAAI,OAAO,EAAM,WAAW,CAC9B,CAAC,CACH,CAMA,eAAsB,EAAqB,EAAsC,CAC/E,IACM,EAAO,EAA0B,CAAW,EAClD,IAAK,IAAI,EAAI,EAAG,EAAI,IAAY,IAAK,CACnC,IAAM,EAAO,GAAmB,EAAO,EAAiB,GAAK,IAC7D,GAAI,MAAM,EAAW,CAAI,EAAG,OAAO,CACrC,CACA,OAAO,CACT,CCtDA,MAAM,EAAkB,6BAExB,SAAgB,EAAoB,EAAmC,CACrE,GAAI,CAAC,EAAO,MAAO,2BACnB,GAAI,CAAC,EAAgB,KAAK,CAAK,EAC7B,MAAO,sGAGX,CAQA,SAAgB,EAAsB,EAAgB,EAA0C,CAC9F,GAAI,IAAW,QAAU,CAAC,EACxB,MACE,kHAKN,CCtBA,MAAM,EAAW,QAAQ,WAAa,QAStC,SAAgB,EAAgB,EAAa,EAAgB,EAA8B,CACzF,OAAO,EAAa,EAAK,EAAM,CAC7B,IAAK,GAAS,IACd,MAAO,OACP,SAAU,QACV,MAAO,CACT,CAAC,CACH,CAMA,SAAgB,EAAkB,EAAa,EAAgB,EAA4B,CACzF,EAAa,EAAK,EAAM,CACtB,IAAK,GAAS,IACd,MAAO,UACP,MAAO,CACT,CAAC,CACH,CAEA,eAAsB,EAAY,EAAkB,EAAgC,CAClF,IAAK,IAAM,KAAQ,EACjB,MAAM,EAAG,EAAK,EAAU,CAAI,EAAG,CAAE,MAAO,GAAM,UAAW,EAAK,CAAC,CAEnE,CC3CA,MAKM,EAAyB,IAAI,IAAY,CAC7C,MACA,OACA,OACA,QACA,MACA,OACA,QACA,OACA,OACF,CAAC,EAEK,EAAwB,IAAI,IAAY,CAAC,eAAgB,aAAc,YAAY,CAAC,EAEpF,EAAqB,mBACrB,EAA2B,yBAC3B,EAA2B,yBAEjC,eAAsB,EACpB,EACA,EACe,CACf,MAAM,EAAe,EAAS,CAAU,CAC1C,CAMA,eAAe,EACb,EACA,EACe,CAIf,IAAM,EAAU,EAAoB,EAAQ,IAAI,EAChD,GAAI,EAAS,MAAU,MAAM,CAAO,EACpC,IAAM,EAAY,EAAsB,EAAQ,OAAQ,EAAQ,WAAW,EAC3E,GAAI,EAAW,MAAU,MAAM,CAAS,EAExC,IAAM,EAAa,EAAK,QAAQ,IAAI,EAAG,EAAQ,IAAI,EAC7C,EAAc,EAAQ,QAAQ,IAAI,CAAC,EACnC,EAAkB,EAAQ,CAAU,EAC1C,GAAI,IAAoB,GAAe,CAAC,EAAgB,WAAW,EAAc,CAAG,EAClF,MAAU,MACR,qBAAqB,EAAgB,kDACd,EAAY,qBACrC,EAKF,IAAa,yBAAyB,EACtC,EAAgB,OAAQ,CACtB,SACA,cACA,EAAQ,KACR,eACA,aACA,cACA,QACA,eACA,cACA,mBACA,iBACA,iBACA,KACF,CAAC,EAGD,MAAM,EAAY,EAAY,CAC5B,eACA,sBACA,YACA,kBACA,WACF,CAAC,EAKD,IAAa,wBAAwB,EACrC,MAAM,EAAgB,CAAU,EAKhC,IAAa,2BAA2B,EACxC,IAAM,EAAmB,MAAM,EAAqB,EAAQ,IAAI,EAahE,GAZA,MAAM,EAAgB,EAAY,EAAQ,KAAM,CAAgB,EAG5D,EAAQ,cACV,IAAa,4BAA4B,EACzC,EAAgB,OAAQ,CAAC,SAAS,EAAG,CAAE,IAAK,CAAW,CAAC,GAOtD,EAAQ,SAAW,OACrB,IAAK,IAAM,KAAU,EACnB,IAAa,UAAU,EAAO,WAAW,EAIzC,EAAkB,OAAQ,CAAC,OAAQ,OAAQ,MAAO,CAAM,EAAG,CAAE,IAAK,CAAW,CAAC,CAGpF,CAmBA,MAAM,EAAsB,CAAC,WAAY,OAAQ,QAAS,WAAW,EAMrE,eAAe,EAAgB,EAAgC,CAC7D,IAAM,EAAU,EAAuB,EACvC,GAAI,CAAC,EAAW,CAAO,EACrB,MAAU,MACR,6BAA6B,EAAQ,mEAEvC,EAEF,MAAMA,EAAW,CACf,KAAM,EACN,IAAK,EAKL,OAAS,GAAS,CAChB,IAAM,EAAiB,EAAK,WAAW,GAAG,GAAK,kBAAkB,KAAK,CAAI,EACpE,EAAe,EAAK,MAAM,OAAO,CAAC,CAAC,SAAS,IAAI,EAKtD,OAJI,GAAkB,GACpB,QAAQ,KAAK,2DAA2D,GAAM,EACvE,IAEF,EACT,CACF,CAAC,CACH,CAYA,SAAS,GAAiC,CACxC,IAAM,EAAO,EAAQ,EAAc,OAAO,KAAK,GAAG,CAAC,EAC/C,EAAM,EACV,IAAK,IAAI,EAAI,EAAG,EAAI,EAAG,IAAK,CAC1B,IAAM,EAAc,EAAK,EAAK,cAAc,EAC5C,GAAI,EAAW,CAAW,EACxB,GAAI,CAEF,GADY,KAAK,MAAM,EAAa,EAAa,OAAO,CAClD,CAAC,CAAC,OAAS,sBACf,OAAO,EAAK,EAAK,YAAa,sBAAsB,CAExD,OAAS,EAAK,CAGZ,QAAQ,KACN,0DAA0D,EAAY,IACjE,aAAe,MAAQ,EAAI,QAAU,OAAO,CAAG,GACtD,CACF,CAEF,IAAM,EAAS,EAAQ,CAAG,EAC1B,GAAI,IAAW,EAAK,MACpB,EAAM,CACR,CACA,MAAU,MACR,yDAAyD,EAAK,0CAEhE,CACF,CAaA,eAAe,EACb,EACA,EACA,EACe,CACf,IAAM,EAAY,EAAY,CAAW,EACnC,EAAU,OAAO,CAAgB,EACvC,UAAW,IAAM,KAAY,EAAU,CAAU,EAAG,CAClD,GAAI,CAAC,EAAc,CAAQ,EAAG,SAC9B,IAAM,EAAW,MAAM,EAAS,EAAU,OAAO,EAE/C,CAAC,EAAS,SAAS,CAAkB,GACrC,CAAC,EAAS,SAAS,CAAwB,GAC3C,CAAC,EAAS,SAAS,CAAwB,GAW7C,MAAM,EAAU,EAPC,EACd,MAAM,CAAwB,CAAC,CAC/B,KAAK,CAAS,CAAC,CACf,MAAM,CAAkB,CAAC,CACzB,KAAK,CAAW,CAAC,CACjB,MAAM,CAAwB,CAAC,CAC/B,KAAK,CACyB,EAAG,OAAO,CAC7C,CACF,CAUA,MAAM,EAAY,IAAI,IAAY,CAChC,eACA,OACA,QACA,WACA,MACA,OACA,QACA,QACF,CAAC,EAED,eAAgB,EAAU,EAAsC,CAC9D,IAAM,EAAU,MAAM,EAAQ,EAAM,CAAE,cAAe,EAAK,CAAC,EAC3D,IAAK,IAAM,KAAS,EAAS,CAC3B,IAAM,EAAO,EAAK,EAAM,EAAM,IAAI,EAClC,GAAI,EAAM,YAAY,EAAG,CACvB,GAAI,EAAU,IAAI,EAAM,IAAI,EAAG,SAC/B,MAAO,EAAU,CAAI,CACvB,MAAW,EAAM,OAAO,IACtB,MAAM,EAEV,CACF,CAEA,SAAS,EAAc,EAA2B,CAGhD,IAAM,EAAO,EAAS,CAAQ,EAC9B,GAAI,EAAsB,IAAI,CAAI,EAAG,MAAO,GAC5C,IAAM,EAAM,EAAK,YAAY,GAAG,EAChC,GAAI,EAAM,EAAG,MAAO,GACpB,IAAM,EAAM,EAAK,MAAM,CAAG,EAC1B,OAAO,EAAuB,IAAI,CAAG,CACvC,CAEA,SAAS,EAAY,EAAsB,CACzC,OAAO,EACJ,MAAM,SAAS,CAAC,CAChB,OAAO,OAAO,CAAC,CACf,IAAK,GAAS,EAAK,OAAO,CAAC,CAAC,CAAC,YAAY,EAAI,EAAK,MAAM,CAAC,CAAC,CAAC,CAC3D,KAAK,GAAG,CACb"}
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\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 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'\nimport { rm } from 'node:fs/promises'\nimport { join } from 'node:path'\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\nexport async function deleteFiles(basePath: string, files: string[]): Promise<void> {\n for (const file of files) {\n await rm(join(basePath, file), { force: true, recursive: true })\n }\n}\n","import { existsSync, readFileSync } from 'node:fs'\nimport { readdir, readFile, 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 { deleteFiles, runCommandInherit, runCommandPiped } from './utils'\n\n// Major Next.js version passed to `pnpm create next-app@<ver>`.\n// Bumped when the toolkit as a whole moves to a new Next major.\nconst CREATE_NEXT_APP_VERSION = '16'\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. Run create-next-app to get an authoritative Next.js skeleton\n // (lockfile, ESLint config, default tsconfig, etc.)\n onProgress?.('Creating Next.js app...')\n runCommandPiped('pnpm', [\n 'create',\n `next-app@${CREATE_NEXT_APP_VERSION}`,\n options.name,\n '--typescript',\n '--tailwind',\n '--no-eslint',\n '--app',\n '--no-src-dir',\n '--turbopack',\n '--react-compiler',\n '--skip-install',\n '--import-alias',\n '@/*',\n ])\n\n // 2. Cleanup default Next.js artifacts that the template will replace\n await deleteFiles(projectDir, [\n 'app/page.tsx',\n 'app/page.module.css',\n 'app/fonts',\n 'app/globals.css',\n 'README.md',\n ])\n\n // 3. Extract the prebuilt template tarball over the project directory.\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 extractTemplate(projectDir)\n\n // 4. Replace tokens in tokenizable files. Pick a free Postgres host port\n // here (not 5432 — collides with anyone else running Postgres locally,\n // 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\n // 5. Install deps\n if (options.installDeps) {\n onProgress?.('Installing dependencies...')\n runCommandPiped('pnpm', ['install'], { cwd: projectDir })\n }\n\n // 6. 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 if (options.preset === 'demo') {\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: projectDir })\n }\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'] as const\n\n// ---------------------------------------------------------------------------\n// Tarball extraction\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 * Directories the tokenizer must skip.\n *\n * Includes hidden dirs (`.git`, `.next`, `.turbo`) explicitly instead of\n * blanket-skipping `entry.name.startsWith('.')` so that legitimate dotted\n * dirs like `.github/workflows/*.yml` and `.vscode/settings.json` get\n * tokenized when the template ships placeholders there.\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.32.3",
3
+ "version": "0.33.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.32.3"
33
+ "@murumets-ee/cli": "^0.33.0"
34
34
  },
35
35
  "typeCoverage": {
36
36
  "atLeast": 100
Binary file