@murumets-ee/create 0.46.0 → 0.48.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/bin/lumi-sync-template.mjs +17 -0
- package/dist/cli.mjs +3 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/sync-cli.mjs +14 -0
- package/package.json +4 -3
- package/templates/template-demo.tar.gz +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Source-shipped bin shim, same shape and same reasoning as create-lumi.mjs:
|
|
3
|
+
// it always exists, so `pnpm install` never warns about a missing bin target
|
|
4
|
+
// on a cold checkout where dist/ has not been built. The real CLI is a tsdown
|
|
5
|
+
// bundle at ../dist/sync-cli.mjs.
|
|
6
|
+
//
|
|
7
|
+
// The import is inside the try for the same reason `runSync` has a catch: an
|
|
8
|
+
// unhandled rejection here — a cold checkout, a half-installed package — makes
|
|
9
|
+
// Node exit 1, and 1 is this tool's code for "drift is outstanding". A CI check
|
|
10
|
+
// would read a broken install as a finding. Failing to run is exit 2.
|
|
11
|
+
try {
|
|
12
|
+
const { runSync } = await import('../dist/sync-cli.mjs')
|
|
13
|
+
process.exitCode = await runSync(process.argv.slice(2))
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.error(`lumi-sync-template could not start:\n ${err instanceof Error ? err.stack : err}`)
|
|
16
|
+
process.exitCode = 2
|
|
17
|
+
}
|
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
|
|
3
|
-
`),
|
|
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)}}
|
|
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,rm as l,writeFile as u}from"node:fs/promises";import{basename as d,dirname as f,join as p,relative as m,resolve as h,sep as g}from"node:path";import{fileURLToPath as _}from"node:url";import{extract as v}from"tar";import{createHash as y}from"node:crypto";import{createServer as b}from"node:net";import{execFileSync as x}from"node:child_process";const ee=/^[a-z0-9][a-z0-9-]{0,213}$/,S=new Set([`admin`,`front`]);function C(e){if(!e)return`Project name is required`;if(!ee.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`;if(S.has(e))return`"${e}" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`}function w(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 T(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 te(t){let n=T(process.argv),r=n.name??t;if(r!==void 0){let t=C(r);if(t)return e.intro(`@murumets-ee/create`),e.cancel(t),null}if(n.preset!==void 0&&n.install!==void 0){let t=w(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:C});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=w(a,o);return s?(e.cancel(s),null):{name:i,installDeps:o,preset:a}}const E=5500;function ne(e){return E+y(`sha1`).update(e).digest().readUInt32BE(0)%500}async function re(e){return new Promise(t=>{let n=b(),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 ie(e){let t=ne(e);for(let e=0;e<500;e++){let n=E+(t-E+e)%500;if(await re(n))return n}return t}const D=[`package.json`,`src/styles/globals.css`],O=new Set([`node_modules`,`dist`,`.astro`,`.turbo`,`.git`]);function k(e){return new Set([...D,...e??[]])}function A(e){return{manifestVersion:1,templateVersion:e.templateVersion,...e.templateSource===void 0?{}:{templateSource:e.templateSource},appDir:e.appDir??`apps/front`,generated:[...k(e.generated)].sort(),files:j(e.files)}}function j(e){let t={};for(let n of Object.keys(e).sort()){let r=e[n];r!==void 0&&(t[n]=r)}return t}function M(e){return y(`sha256`).update(e).digest(`hex`)}async function N(e){if(!n(e))return{};let t={};async function r(n){let i=await s(n,{withFileTypes:!0});for(let a of i){if(a.isDirectory()){if(O.has(a.name))continue;await r(p(n,a.name));continue}if(!a.isFile())continue;let i=p(n,a.name);t[m(e,i).split(g).join(`/`)]=M(await o(i))}}return await r(e),t}async function P(e,t){let n=p(e,`.lumi-template.json`),r=`${n}.tmp`;try{await u(r,`${JSON.stringify(t,null,2)}\n`,`utf-8`),await c(r,n)}catch(e){throw await l(r,{force:!0}),e}}async function F(e){let t=await N(p(e.projectRoot,...`apps/front`.split(`/`)));for(let e of D)delete t[e];let n=A({templateVersion:e.templateVersion,files:t});return await P(e.projectRoot,n),n}function I(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}const L=process.platform===`win32`;function R(e,t,n){return x(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:L})}function z(e,t,n){x(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:L})}const B=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),V=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),H=`__PROJECT_NAME__`,U=`__PROJECT_NAME_TITLE__`,W=`__POSTGRES_HOST_PORT__`;async function G(e,t){await K(e,t)}async function K(e,t){let n=C(e.name);if(n)throw Error(n);let r=w(e.preset,e.installDeps);if(r)throw Error(r);let i=p(process.cwd(),e.name),a=h(process.cwd()),o=h(i);if(o!==a&&!o.startsWith(a+g))throw Error(`Project directory ${o} resolves outside the current working directory ${a}. Refusing to write.`);t?.(`Extracting template...`),await ae(i),await se(i),t?.(`Personalizing template...`);let s=await ie(e.name);if(await le(i,e.name,s),await ue(i,e.name),await F({projectRoot:i,templateVersion:Z()}),e.installDeps&&(t?.(`Installing dependencies...`),R(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`){let e=p(i,...`apps/admin`.split(`/`)),n=[...new Set(J.flatMap(e=>Y[e]))];t?.(`Installing demo-preset packages...`),await q(i,e,n);for(let n of J)t?.(`Adding ${n} plugin...`),z(`pnpm`,[`exec`,`lumi`,`add`,n],{cwd:e})}}async function q(e,t,n){let r=n.filter(e=>e.startsWith(`@murumets-ee/`)),i=n.filter(e=>!e.startsWith(`@murumets-ee/`)),a=p(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]){z(`pnpm`,[`add`,...n],{cwd:t});return}let l=c[1],d=r.filter(e=>!RegExp(`'?${e}'?:`).test(s));if(d.length>0){let e=d.map(e=>` '${e}': ${l}`).join(`
|
|
3
|
+
`),r=s.replace(/^( {2}lumi:)([ \t]*\{\})?$/m,`$1\n${e}`);if(r===s){z(`pnpm`,[`add`,...n],{cwd:t});return}await u(a,r,`utf-8`)}let f=p(t,`package.json`),m=JSON.parse(await o(f,`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 u(f,`${JSON.stringify(m,null,2)}\n`,`utf-8`),z(`pnpm`,[`install`,`--no-frozen-lockfile`],{cwd:t}),h.length>0&&z(`pnpm`,[`add`,...h],{cwd:t})}const J=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`,`rent`],Y={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 ae(e){try{await a(e);return}catch(e){if(e.code!==`EEXIST`)throw e}await oe(e)}async function oe(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 se(e){let t=ce();if(!n(t))throw Error(`Template tarball missing: ${t}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await v({file:t,cwd:e,filter:I})}function X(){let e=f(_(import.meta.url)),t=e;for(let e=0;e<5;e++){let e=p(t,`package.json`);if(n(e))try{let n=JSON.parse(r(e,`utf-8`));if(n.name===`@murumets-ee/create`)return{dir:t,version:typeof n.version==`string`?n.version:`0.0.0`}}catch(t){console.warn(`@murumets-ee/create: skipped malformed package.json at ${e}: ${t instanceof Error?t.message:String(t)}`)}let i=f(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.`)}function ce(){return p(X().dir,`templates`,`template-demo.tar.gz`)}function Z(){return X().version}async function le(e,t,n){let r=fe(t),i=String(n);for await(let n of $(e)){if(!de(n))continue;let e=await o(n,`utf-8`);!e.includes(H)&&!e.includes(U)&&!e.includes(W)||await u(n,e.split(U).join(r).split(H).join(t).split(W).join(i),`utf-8`)}}async function ue(e,t){let n=[];async function r(e){let i=await s(e,{withFileTypes:!0});for(let a of i){if(!a.isDirectory()||Q.has(a.name))continue;let i=p(e,a.name);await r(i),a.name.includes(H)&&n.push({from:i,to:p(e,a.name.split(H).join(t))})}}await r(e);for(let{from:e,to:t}of n)await c(e,t)}const Q=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*$(e){let t=await s(e,{withFileTypes:!0});for(let n of t){let t=p(e,n.name);if(n.isDirectory()){if(Q.has(n.name))continue;yield*$(t)}else n.isFile()&&(yield t)}}function de(e){let t=d(e);if(V.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return B.has(r)}function fe(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}async function pe(){let n=process.argv[2],r=await te(n);if(!r)return;let i=e.spinner();try{i.start(`Extracting template...`),await G(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
|
+
`),`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)}}pe();export{};
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/scaffold.ts"],"mappings":";;AAOA;;;;AAAkB;AAElB;KAFY,MAAA;AAAA,UAEK,cAAA;EACf,IAAA;EACA,WAAA;EACA,MAAA,EAAQ,MAAM;AAAA;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/scaffold.ts"],"mappings":";;AAOA;;;;AAAkB;AAElB;KAFY,MAAA;AAAA,UAEK,cAAA;EACf,IAAA;EACA,WAAA;EACA,MAAA,EAAQ,MAAM;AAAA;;;KCMJ,gBAAA,IAAoB,OAAe;AAAA,iBAqBzB,QAAA,CACpB,OAAA,EAAS,cAAA,EACT,UAAA,GAAa,gBAAA,GACZ,OAAA"}
|
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
|
|
2
|
-
`),
|
|
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,rm as s,writeFile as c}from"node:fs/promises";import{basename as l,dirname as u,join as d,relative as f,resolve as p,sep as m}from"node:path";import{fileURLToPath as h}from"node:url";import{extract as ee}from"tar";import{createHash as g}from"node:crypto";import{createServer as _}from"node:net";import"@clack/prompts";import{execFileSync as v}from"node:child_process";const y=5500;function b(e){return y+g(`sha1`).update(e).digest().readUInt32BE(0)%500}async function x(e){return new Promise(t=>{let n=_(),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 S(e){let t=b(e);for(let e=0;e<500;e++){let n=y+(t-y+e)%500;if(await x(n))return n}return t}const C=/^[a-z0-9][a-z0-9-]{0,213}$/,w=new Set([`admin`,`front`]);function T(e){if(!e)return`Project name is required`;if(!C.test(e))return`Use lowercase letters, numbers, and hyphens only (must start with a letter or digit, max 214 chars).`;if(w.has(e))return`"${e}" is the name of a workspace member the scaffold emits (apps/admin, apps/front). Pick another name.`}function E(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 D=[`package.json`,`src/styles/globals.css`],te=new Set([`node_modules`,`dist`,`.astro`,`.turbo`,`.git`]);function ne(e){return new Set([...D,...e??[]])}function O(e){return{manifestVersion:1,templateVersion:e.templateVersion,...e.templateSource===void 0?{}:{templateSource:e.templateSource},appDir:e.appDir??`apps/front`,generated:[...ne(e.generated)].sort(),files:k(e.files)}}function k(e){let t={};for(let n of Object.keys(e).sort()){let r=e[n];r!==void 0&&(t[n]=r)}return t}function A(e){return g(`sha256`).update(e).digest(`hex`)}async function j(t){if(!e(t))return{};let n={};async function r(e){let o=await a(e,{withFileTypes:!0});for(let a of o){if(a.isDirectory()){if(te.has(a.name))continue;await r(d(e,a.name));continue}if(!a.isFile())continue;let o=d(e,a.name);n[f(t,o).split(m).join(`/`)]=A(await i(o))}}return await r(t),n}async function M(e,t){let n=d(e,`.lumi-template.json`),r=`${n}.tmp`;try{await c(r,`${JSON.stringify(t,null,2)}\n`,`utf-8`),await o(r,n)}catch(e){throw await s(r,{force:!0}),e}}async function N(e){let t=await j(d(e.projectRoot,...`apps/front`.split(`/`)));for(let e of D)delete t[e];let n=O({templateVersion:e.templateVersion,files:t});return await M(e.projectRoot,n),n}function P(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}const F=process.platform===`win32`;function I(e,t,n){return v(e,t,{cwd:n?.cwd,stdio:`pipe`,encoding:`utf-8`,shell:F})}function L(e,t,n){v(e,t,{cwd:n?.cwd,stdio:`inherit`,shell:F})}const R=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),z=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),B=`__PROJECT_NAME__`,V=`__PROJECT_NAME_TITLE__`,H=`__POSTGRES_HOST_PORT__`;async function re(e,t){await U(e,t)}async function U(e,t){let n=T(e.name);if(n)throw Error(n);let r=E(e.preset,e.installDeps);if(r)throw Error(r);let i=d(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 q(i),await Y(i),t?.(`Personalizing template...`);let s=await S(e.name);if(await ae(i,e.name,s),await oe(i,e.name),await N({projectRoot:i,templateVersion:ie()}),e.installDeps&&(t?.(`Installing dependencies...`),I(`pnpm`,[`install`],{cwd:i})),e.preset===`demo`){let e=d(i,...`apps/admin`.split(`/`)),n=[...new Set(G.flatMap(e=>K[e]))];t?.(`Installing demo-preset packages...`),await W(i,e,n);for(let n of G)t?.(`Adding ${n} plugin...`),L(`pnpm`,[`exec`,`lumi`,`add`,n],{cwd:e})}}async function W(e,t,n){let r=n.filter(e=>e.startsWith(`@murumets-ee/`)),a=n.filter(e=>!e.startsWith(`@murumets-ee/`)),o=d(e,`pnpm-workspace.yaml`),s=await i(o,`utf-8`),l=s.match(/^\s+'?@murumets-ee\/[a-z-]+'?:\s*([0-9][^\s]*)\s*$/m);if(!l?.[1]){L(`pnpm`,[`add`,...n],{cwd:t});return}let u=l[1],f=r.filter(e=>!RegExp(`'?${e}'?:`).test(s));if(f.length>0){let e=f.map(e=>` '${e}': ${u}`).join(`
|
|
2
|
+
`),r=s.replace(/^( {2}lumi:)([ \t]*\{\})?$/m,`$1\n${e}`);if(r===s){L(`pnpm`,[`add`,...n],{cwd:t});return}await c(o,r,`utf-8`)}let p=d(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 c(p,`${JSON.stringify(m,null,2)}\n`,`utf-8`),L(`pnpm`,[`install`,`--no-frozen-lockfile`],{cwd:t}),h.length>0&&L(`pnpm`,[`add`,...h],{cwd:t})}const G=[`taxonomy`,`blog`,`media`,`ticketing`,`commerce`,`rent`],K={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 q(e){try{await r(e);return}catch(e){if(e.code!==`EEXIST`)throw e}await J(e)}async function J(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 Y(t){let n=Z();if(!e(n))throw Error(`Template tarball missing: ${n}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await ee({file:n,cwd:t,filter:P})}function X(){let n=u(h(import.meta.url)),r=n;for(let n=0;n<5;n++){let n=d(r,`package.json`);if(e(n))try{let e=JSON.parse(t(n,`utf-8`));if(e.name===`@murumets-ee/create`)return{dir:r,version:typeof e.version==`string`?e.version:`0.0.0`}}catch(e){console.warn(`@murumets-ee/create: skipped malformed package.json at ${n}: ${e instanceof Error?e.message:String(e)}`)}let i=u(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.`)}function Z(){return d(X().dir,`templates`,`template-demo.tar.gz`)}function ie(){return X().version}async function ae(e,t,n){let r=ce(t),a=String(n);for await(let n of $(e)){if(!se(n))continue;let e=await i(n,`utf-8`);!e.includes(B)&&!e.includes(V)&&!e.includes(H)||await c(n,e.split(V).join(r).split(B).join(t).split(H).join(a),`utf-8`)}}async function oe(e,t){let n=[];async function r(e){let i=await a(e,{withFileTypes:!0});for(let a of i){if(!a.isDirectory()||Q.has(a.name))continue;let i=d(e,a.name);await r(i),a.name.includes(B)&&n.push({from:i,to:d(e,a.name.split(B).join(t))})}}await r(e);for(let{from:e,to:t}of n)await o(e,t)}const Q=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`,`.git`,`.next`,`.turbo`]);async function*$(e){let t=await a(e,{withFileTypes:!0});for(let n of t){let t=d(e,n.name);if(n.isDirectory()){if(Q.has(n.name))continue;yield*$(t)}else n.isFile()&&(yield t)}}function se(e){let t=l(e);if(z.has(t))return!0;let n=t.lastIndexOf(`.`);if(n<0)return!1;let r=t.slice(n);return R.has(r)}function ce(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(` `)}export{re as scaffold};
|
|
3
3
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -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":["SKIP_DIRS","tarExtract"],"sources":["../src/port.ts","../src/prompts.ts","../src/template-sync.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","/**\n * Template sync — keep a scaffolded project's copy of `apps/front` in step\n * with the template it came from.\n *\n * ## Why this exists\n *\n * `create` emits `apps/front` as ordinary app source the customer owns and\n * edits (D006). `assertFrontAppVerbatim` in `scripts/build-templates.ts`\n * guarantees those bytes are identical to `apps/front-astro` at emission time,\n * so every scaffolded project starts as an exact copy of the template — and\n * then diverges, in two directions at once, with nothing recording which.\n *\n * Measured on the two live installs (plan `frontend-delivery` R040/R041):\n * dava-site is 7 minors behind and differs from the template in 39 files, of\n * which only 22 actually need a human. Nine can be applied mechanically, eight\n * are the customer's own code the template never touched, and two are files the\n * customer DELETED on purpose (`src/blocks/renders.tsx` — the demo renders D006\n * exists to replace). A plain two-way diff cannot tell those apart: it reports\n * all 39 as \"the template has a newer version of this\", which for those ten is\n * false and for the two deletions is actively destructive.\n *\n * The thing that separates them is a BASELINE — the bytes the template last\n * handed this project. That is what `.lumi-template.json` records and what\n * every classification below is computed against.\n *\n * ## What it deliberately does not do\n *\n * No three-way text merge. A file in `modified` is reported and left alone;\n * the human merges it. Merging is where a tool like this earns conflict markers\n * in a live customer's site, and the measured population that would benefit is\n * small and concentrated in files (`registry.ts`, `theme.ts`, `BaseLayout.astro`)\n * the project is SUPPOSED to own forever.\n */\n\nimport { createHash } from 'node:crypto'\nimport { existsSync } from 'node:fs'\nimport { mkdir, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'\nimport { dirname, join, relative, sep } from 'node:path'\n\n/** Where the baseline lives, relative to the project root. */\nexport const TEMPLATE_MANIFEST_FILE = '.lumi-template.json'\n\n/** The workspace member this tool tracks. */\nexport const FRONT_APP_DIR = 'apps/front'\n\n/** Bump when the on-disk shape of `.lumi-template.json` changes. */\nexport const MANIFEST_VERSION = 1\n\n/**\n * Files under `apps/front/` the template build GENERATES per project rather\n * than copying, so they are never template-owned and never synced.\n *\n * ⚠️ This is the single source of truth for that set: `build-templates.ts`\n * builds its `FRONT_GENERATED_FILES` from it. Keep the two from drifting by\n * never writing the list twice.\n *\n * - `package.json` — rewritten by `writeAppPackageJson` (name, catalog refs,\n * the project's own `<project>-blocks` dependency).\n * - `src/styles/globals.css` — one INSERTED line, a literal\n * `@source \"…/packages/<project>-blocks/src\"`. Tailwind v4 excludes\n * `node_modules`, and a glob emits a stylesheet byte-identical to declaring\n * no `@source` at all, so the project's own package name has to be written in.\n */\nexport const GENERATED_FILES: readonly string[] = ['package.json', 'src/styles/globals.css']\n\n/** Directory names never walked, in either tree. */\nconst SKIP_DIRS = new Set(['node_modules', 'dist', '.astro', '.turbo', '.git'])\n\n/**\n * The generated set a run actually uses: {@link GENERATED_FILES} UNION whatever\n * the project's manifest recorded.\n *\n * A union, never an override, in both directions.\n *\n * Shrinking: `generated` is read back from `.lumi-template.json`, which is an\n * ordinary file in the customer's repo. An explicit `[]` there would make\n * `package.json` and `src/styles/globals.css` — files the template build\n * GENERATES per project — into tracked paths, reported as `collides` on every\n * run forever. `GENERATED_FILES` is documented as the single source of truth\n * for that set; a file on disk must not be able to weaken it.\n *\n * Growing: a manifest written by an older version records the set as it was\n * then, and nothing ever refreshes it — so without the union, a file added to\n * `GENERATED_FILES` later would be treated as template-owned in every project\n * scaffolded before it. The manifest's copy is an audit record of what a past\n * run believed, not a policy this run has to obey.\n */\nexport function effectiveGeneratedFiles(recorded: readonly string[] | undefined): Set<string> {\n return new Set([...GENERATED_FILES, ...(recorded ?? [])])\n}\n\n// ---------------------------------------------------------------------------\n// Classification — the pure core\n// ---------------------------------------------------------------------------\n\n/**\n * What became of one path across the three trees.\n *\n * The three inputs are BASELINE (what the template last gave this project),\n * PROJECT (what is on disk now) and TEMPLATE (what the installed template\n * ships now). Each class names exactly one correct action.\n */\nexport type FileClass =\n /** PROJECT already matches TEMPLATE, or both are absent. Nothing to do. */\n | 'unchanged'\n /** PROJECT still matches BASELINE and TEMPLATE moved. Safe to overwrite. */\n | 'stale'\n /** TEMPLATE ships a file this project has never had. Safe to write. */\n | 'added'\n /** TEMPLATE dropped a file the project never touched. Safe to delete. */\n | 'removed-upstream'\n /** Both moved since BASELINE. A human merges this one. */\n | 'modified'\n /** The project changed it; TEMPLATE did not. Its code — stay out of it. */\n | 'local'\n /** The project deleted it and TEMPLATE has not moved. Deliberate — leave deleted. */\n | 'deleted-locally'\n /** The project deleted it and TEMPLATE changed it. Worth telling them, not worth resurrecting. */\n | 'deleted-locally-changed'\n /**\n * TEMPLATE ships it, the project does not have it, and with no baseline\n * there is nothing that could say which of the two reasons applies — a file\n * added to the template since, or one the project deleted on purpose.\n *\n * Distinct from `deleted-locally-changed` because that class ASSERTS the\n * project deleted it, and saying \"you deleted it\" about a file a project\n * never had is a false statement dressed as a diagnosis.\n */\n | 'absent-here'\n /** The project authored a path TEMPLATE now also ships. Needs a human. */\n | 'collides'\n /** Never template-owned. The project's own file. */\n | 'project-own'\n\n/** Classes the tool may write without asking. */\nexport const APPLICABLE: readonly FileClass[] = ['stale', 'added', 'removed-upstream']\n\n/** Classes that need a human and are reported. */\nexport const REVIEWABLE: readonly FileClass[] = [\n 'modified',\n 'collides',\n 'deleted-locally-changed',\n 'absent-here',\n]\n\nexport interface ClassifyInput {\n /** sha256 of the bytes the template last handed this project, if known. */\n readonly baseline: string | undefined\n /** sha256 of the file on disk now, or undefined if absent. */\n readonly project: string | undefined\n /** sha256 of the file the installed template ships, or undefined if absent. */\n readonly template: string | undefined\n /**\n * Whether a baseline manifest exists AT ALL.\n *\n * Distinct from `baseline === undefined`, which means \"this path is not in\n * the manifest\" — a real and different fact. With no manifest, nothing can be\n * proven about intent, so every difference degrades to a class that only ever\n * reports and never writes.\n */\n readonly hasManifest: boolean\n}\n\nexport function classifyFile(input: ClassifyInput): FileClass {\n const { baseline, project, template, hasManifest } = input\n\n if (!hasManifest) {\n // Conservative degradation. In particular a file present in TEMPLATE and\n // absent from PROJECT is NOT 'added' here: without a baseline we cannot\n // tell a new template file from one the project deleted on purpose, and\n // guessing wrong resurrects the demo renders D006 deleted.\n if (template === undefined) return 'project-own'\n if (project === undefined) return 'absent-here'\n return project === template ? 'unchanged' : 'modified'\n }\n\n if (template === undefined) {\n if (baseline === undefined) return 'project-own'\n if (project === undefined) return 'unchanged'\n return project === baseline ? 'removed-upstream' : 'modified'\n }\n\n if (project === undefined) {\n if (baseline === undefined) return 'added'\n return template === baseline ? 'deleted-locally' : 'deleted-locally-changed'\n }\n\n if (project === template) return 'unchanged'\n if (baseline === undefined) return 'collides'\n if (project === baseline) return 'stale'\n if (template === baseline) return 'local'\n return 'modified'\n}\n\nexport interface SyncEntry {\n readonly path: string\n readonly className: FileClass\n}\n\nexport interface SyncPlan {\n readonly entries: readonly SyncEntry[]\n /** Paths the tool would write or delete, in `--apply`. */\n readonly apply: readonly SyncEntry[]\n /** Paths needing a human. */\n readonly review: readonly SyncEntry[]\n /** Paths skipped because the build generates them per project. */\n readonly generated: readonly string[]\n readonly hasManifest: boolean\n}\n\nexport interface PlanInput {\n /** path → sha256, from `.lumi-template.json`. `undefined` when absent. */\n readonly baseline: Readonly<Record<string, string>> | undefined\n /** path → sha256, walked from the project's `apps/front`. */\n readonly project: Readonly<Record<string, string>>\n /** path → sha256, walked from the installed template's `apps/front`. */\n readonly template: Readonly<Record<string, string>>\n /** Defaults to {@link GENERATED_FILES}. */\n readonly generatedFiles?: readonly string[] | undefined\n}\n\nexport function planSync(input: PlanInput): SyncPlan {\n const generatedSet = effectiveGeneratedFiles(input.generatedFiles)\n const hasManifest = input.baseline !== undefined\n const baseline = input.baseline ?? {}\n\n const paths = new Set<string>([\n ...Object.keys(baseline),\n ...Object.keys(input.project),\n ...Object.keys(input.template),\n ])\n\n const entries: SyncEntry[] = []\n const generated: string[] = []\n for (const path of [...paths].sort()) {\n if (generatedSet.has(path)) {\n generated.push(path)\n continue\n }\n entries.push({\n path,\n className: classifyFile({\n baseline: baseline[path],\n project: input.project[path],\n template: input.template[path],\n hasManifest,\n }),\n })\n }\n\n const applicable = new Set<FileClass>(APPLICABLE)\n const reviewable = new Set<FileClass>(REVIEWABLE)\n return {\n entries,\n apply: entries.filter((e) => applicable.has(e.className)),\n review: entries.filter((e) => reviewable.has(e.className)),\n generated: generated.sort(),\n hasManifest,\n }\n}\n\n/**\n * Paths a completed `--apply` may move to the template's current hash WITHOUT\n * the project's file matching it.\n *\n * Exactly one class qualifies, and the boundary is load-bearing. A\n * `deleted-locally-changed` file is one the project deleted on purpose and the\n * template has since changed: the project's decision is unambiguous and final,\n * so the only useful thing to say is \"changed again since you last looked\".\n * Without acknowledgement it says it every run forever — dava-site's two\n * deleted demo-render files would sit in the report permanently — and a report\n * with permanent unresolvable noise is one people stop reading.\n *\n * `modified` and `collides` must NEVER be acknowledged. Both mean an unresolved\n * difference in a file that still exists; recording the template's hash for one\n * would reclassify it `local` on the next run and silence it, which is the\n * failure this whole tool exists to prevent, achieved by the tool itself.\n */\nexport const ACKNOWLEDGEABLE: readonly FileClass[] = ['deleted-locally-changed']\n\n/**\n * The manifest AFTER a sync: applied and acknowledged paths move to the\n * template's hash; every other recorded path keeps the baseline it had, so a\n * `modified` file stays comparable next time.\n *\n * 🔴 NOTHING IS EVER REMOVED, and that is load-bearing rather than lazy.\n *\n * An earlier version dropped every recorded path the CURRENT template lacks,\n * which reads as tidy and re-opens the resurrection hole by a second route:\n * one `--apply` against an older template (a downgrade, or a mistyped\n * `--template`) prunes a path out of the baseline, and the next `--apply`\n * against the real template sees no baseline entry, classifies it `added`, and\n * writes back a file the project deleted on purpose — exit 0, no warning. The\n * demo renders D006 exists to delete are the obvious casualty.\n *\n * \"The template gave this project this path at some point\" is a durable fact\n * and the manifest is the only thing that records it. Keeping it costs a line\n * of JSON; dropping it costs the customer's deletion. A retained entry whose\n * path is absent from both trees classifies `unchanged` and stays silent.\n */\nexport function nextManifestFiles(args: {\n readonly previous: Readonly<Record<string, string>> | undefined\n readonly template: Readonly<Record<string, string>>\n readonly applied: readonly string[]\n /** Paths whose template change was reported and needs no repeating. */\n readonly acknowledged?: readonly string[]\n}): Record<string, string> {\n const next: Record<string, string> = { ...(args.previous ?? {}) }\n for (const path of [...args.applied, ...(args.acknowledged ?? [])]) {\n const hash = args.template[path]\n // A `removed-upstream` apply has no template hash to record. The previous\n // baseline stays, so the path keeps its history rather than becoming a\n // stranger the next run would offer to add back.\n if (hash !== undefined) next[path] = hash\n }\n return next\n}\n\n// ---------------------------------------------------------------------------\n// The manifest\n// ---------------------------------------------------------------------------\n\nexport interface TemplateManifest {\n readonly manifestVersion: number\n /** The `@murumets-ee/create` version whose template these hashes came from. */\n readonly templateVersion: string\n /**\n * Set ONLY when the hashes came from an out-of-band `--template <path>`.\n *\n * Without it the manifest asserts a provenance it cannot prove:\n * `templateVersion` is read from the INSTALLED `@murumets-ee/create`, while\n * the hashes come from wherever `--template` pointed. A later run prints that\n * version on its `baseline` line and a reader takes it as fact. A record that\n * claims more than it knows is the exact defect this whole file exists to\n * fix, so it must not be one.\n */\n readonly templateSource?: string\n /** The tracked workspace member, so a future restructure is visible. */\n readonly appDir: string\n /** Paths the build generates per project; recorded so a change to the set is auditable. */\n readonly generated: readonly string[]\n /** Path (relative to `appDir`) → sha256 hex. */\n readonly files: Readonly<Record<string, string>>\n}\n\nexport function buildManifest(args: {\n readonly templateVersion: string\n readonly files: Readonly<Record<string, string>>\n readonly appDir?: string | undefined\n readonly generated?: readonly string[] | undefined\n readonly templateSource?: string | undefined\n}): TemplateManifest {\n return {\n manifestVersion: MANIFEST_VERSION,\n templateVersion: args.templateVersion,\n ...(args.templateSource === undefined ? {} : { templateSource: args.templateSource }),\n appDir: args.appDir ?? FRONT_APP_DIR,\n generated: [...effectiveGeneratedFiles(args.generated)].sort(),\n files: sortKeys(args.files),\n }\n}\n\n/**\n * Parse a manifest, returning `undefined` for anything that is not one.\n *\n * A corrupt or future-versioned manifest degrades to \"no baseline\" — which\n * reports everything and writes nothing — rather than throwing. Refusing to run\n * is the wrong failure mode for a tool whose whole job is telling you what\n * changed.\n */\nexport function parseManifest(raw: string): TemplateManifest | undefined {\n let parsed: unknown\n try {\n parsed = JSON.parse(raw)\n } catch {\n return undefined\n }\n const candidate = asRecord(parsed)\n if (!candidate) return undefined\n if (candidate.manifestVersion !== MANIFEST_VERSION) return undefined\n if (typeof candidate.templateVersion !== 'string') return undefined\n if (typeof candidate.appDir !== 'string') return undefined\n\n const rawFiles = asRecord(candidate.files)\n if (!rawFiles) return undefined\n const files: Record<string, string> = {}\n for (const [key, value] of Object.entries(rawFiles)) {\n if (typeof value !== 'string') return undefined\n files[key] = value\n }\n\n const templateSource = candidate.templateSource\n return {\n manifestVersion: MANIFEST_VERSION,\n templateVersion: candidate.templateVersion,\n // Carried through rather than dropped: `parseManifest` rebuilds the object\n // explicitly, so a field it does not name is silently erased on the next\n // write — and provenance that disappears on rewrite is worse than none.\n ...(typeof templateSource === 'string' ? { templateSource } : {}),\n appDir: candidate.appDir,\n // Kept verbatim as the RECORD of what a past run believed. Every consumer\n // goes through `effectiveGeneratedFiles`, which unions it with the current\n // `GENERATED_FILES`, so a short list here cannot weaken the invariant.\n generated: asStringArray(candidate.generated) ?? [...GENERATED_FILES],\n files,\n }\n}\n\n/**\n * Narrow `unknown` to a plain string-keyed object.\n *\n * The cast is what a `typeof`/`null`/`Array.isArray` check cannot express to\n * TypeScript on its own: `object` carries no index signature, so reading a\n * property off it is an error and `Object.entries` on it yields `any` values —\n * which is how four `any`s got into a file with a 100% coverage floor.\n * Arrays are excluded because `typeof [] === 'object'` and a JSON array here\n * means a malformed manifest, not an empty one.\n */\nfunction asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined\n return value as Readonly<Record<string, unknown>>\n}\n\n/**\n * Narrow `unknown` to `string[]`, or `undefined` if it is not one.\n *\n * `Array.isArray` on an `unknown` narrows to `any[]`, so the elements need\n * re-widening to `unknown` before they can be checked without an `any`. The\n * intermediate binding is an assignment, not a cast — it only discards the\n * `any`.\n */\nfunction asStringArray(value: unknown): string[] | undefined {\n if (!Array.isArray(value)) return undefined\n const items: readonly unknown[] = value\n const out: string[] = []\n for (const item of items) {\n if (typeof item !== 'string') return undefined\n out.push(item)\n }\n return out\n}\n\nfunction sortKeys(input: Readonly<Record<string, string>>): Record<string, string> {\n const out: Record<string, string> = {}\n for (const key of Object.keys(input).sort()) {\n const value = input[key]\n if (value !== undefined) out[key] = value\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// Filesystem\n// ---------------------------------------------------------------------------\n\nexport function hashBuffer(buf: Buffer): string {\n return createHash('sha256').update(buf).digest('hex')\n}\n\n/**\n * sha256 every file under `root`, keyed by POSIX-relative path.\n *\n * Returns `{}` for a missing root rather than throwing: an absent `apps/front`\n * is a legitimate state (a project that removed it) and the plan expresses that\n * perfectly well.\n */\nexport async function hashTree(root: string): Promise<Record<string, string>> {\n if (!existsSync(root)) return {}\n const out: Record<string, string> = {}\n async function walk(dir: string): Promise<void> {\n const dirents = await readdir(dir, { withFileTypes: true })\n for (const dirent of dirents) {\n if (dirent.isDirectory()) {\n if (SKIP_DIRS.has(dirent.name)) continue\n await walk(join(dir, dirent.name))\n continue\n }\n // Symlinks are neither hashed nor followed. The template ships none, and\n // following one would let a link inside the project decide what bytes the\n // baseline records.\n if (!dirent.isFile()) continue\n const full = join(dir, dirent.name)\n out[relative(root, full).split(sep).join('/')] = hashBuffer(await readFile(full))\n }\n }\n await walk(root)\n return out\n}\n\nexport async function readManifest(projectRoot: string): Promise<TemplateManifest | undefined> {\n const path = join(projectRoot, TEMPLATE_MANIFEST_FILE)\n if (!existsSync(path)) return undefined\n return parseManifest(await readFile(path, 'utf-8'))\n}\n\nexport async function writeManifest(\n projectRoot: string,\n manifest: TemplateManifest,\n): Promise<void> {\n // Write-then-rename, for the reason `applyPlan` does it: this file IS the\n // baseline, and a half-written one is a baseline the next run cannot parse.\n // That degrades safely (report-only, refuse to write) rather than dangerously,\n // but it still costs the project its history for no reason.\n const dest = join(projectRoot, TEMPLATE_MANIFEST_FILE)\n const temp = `${dest}.tmp`\n try {\n await writeFile(temp, `${JSON.stringify(manifest, null, 2)}\\n`, 'utf-8')\n await rename(temp, dest)\n } catch (err) {\n await rm(temp, { force: true })\n throw err\n }\n}\n\n/**\n * The baseline `--adopt` should record: the TEMPLATE's hashes, for the paths\n * the template owns.\n *\n * 🔴 Not the project's own bytes, which is the intuitive answer and is\n * catastrophically wrong. `baseline` means \"what the template last handed this\n * project\", so recording the project's bytes tells the next run that the\n * template gave it every file it has — and a project-own file (`template`\n * absent, `project === baseline`) then classifies `removed-upstream` and is\n * DELETED, while a customised one (`project === baseline`, template differs)\n * classifies `stale` and is OVERWRITTEN. Both are applicable, so one\n * `--adopt` followed by one `--apply` destroys exactly the work the customer\n * ran `--adopt` to protect.\n *\n * Recording the template's hashes gives the right class in all four cases:\n * a file the project matches stays `unchanged`; one it has customised becomes\n * `local` (silent) until the template moves again and then `modified`\n * (reported, never applied); one it deleted becomes `deleted-locally`; and one\n * only the project has is absent from the manifest entirely, so `project-own`.\n */\nexport function adoptedManifestFiles(\n templateFiles: Readonly<Record<string, string>>,\n generated: readonly string[],\n): Record<string, string> {\n const files: Record<string, string> = { ...templateFiles }\n for (const path of generated) delete files[path]\n return files\n}\n\n/**\n * Write a baseline for a freshly scaffolded project.\n *\n * Called by `scaffold.ts` AFTER tokenization, so the hashes describe the bytes\n * that actually landed on disk rather than the bytes inside the tarball. For\n * `apps/front` the two are identical today (it carries no `__PROJECT_NAME__`\n * tokens, which `assertFrontAppVerbatim` is what keeps true) — hashing the real\n * files means that staying true is not a precondition.\n */\nexport async function writeScaffoldManifest(args: {\n readonly projectRoot: string\n readonly templateVersion: string\n}): Promise<TemplateManifest> {\n const appRoot = join(args.projectRoot, ...FRONT_APP_DIR.split('/'))\n const files = await hashTree(appRoot)\n for (const generated of GENERATED_FILES) delete files[generated]\n const manifest = buildManifest({ templateVersion: args.templateVersion, files })\n await writeManifest(args.projectRoot, manifest)\n return manifest\n}\n\n/**\n * Copy or delete the files a plan marked applicable.\n *\n * Returns the ENTRIES, not the paths: a caller that reports only paths cannot\n * tell a write from a deletion, and deletion is the one action here that a\n * customer cannot undo by re-running.\n */\nexport async function applyPlan(args: {\n readonly plan: SyncPlan\n readonly projectAppRoot: string\n readonly templateAppRoot: string\n}): Promise<SyncEntry[]> {\n const applied: SyncEntry[] = []\n const appRootReal = await realpath(args.projectAppRoot)\n for (const entry of args.plan.apply) {\n const dest = join(args.projectAppRoot, ...entry.path.split('/'))\n if (entry.className === 'removed-upstream') {\n await rm(dest, { force: true })\n applied.push(entry)\n continue\n }\n\n const source = join(args.templateAppRoot, ...entry.path.split('/'))\n await mkdir(dirname(dest), { recursive: true })\n\n // 🔴 Resolve the destination DIRECTORY and refuse anything outside the app.\n // `hashTree` skips symlinks — including symlinked DIRECTORIES, since a\n // symlink's dirent is not `isDirectory()` — so a symlinked path component\n // is invisible to the plan, `mkdir(..., {recursive:true})` succeeds on it,\n // and the write follows it clean out of `apps/front`. Checking the leaf is\n // not enough; the escape is one directory up.\n const destDirReal = await realpath(dirname(dest))\n if (destDirReal !== appRootReal && !destDirReal.startsWith(appRootReal + sep)) {\n throw new Error(\n `Refusing to write ${entry.path}: it resolves to ${destDirReal}, outside ` +\n `${appRootReal}. A symlinked directory inside the app would put the ` +\n `template's bytes somewhere this tool has no business writing.`,\n )\n }\n\n // Write-then-rename, NOT unlink-then-write.\n //\n // `rename` replaces a symlink at the destination WITHOUT following it, so\n // it gives the same protection an `rm` first would — and unlike `rm` it is\n // atomic. That matters more than it looks: with unlink-then-write, a write\n // that does not happen (ENOSPC, EIO, or a Ctrl-C between the two, which is\n // ordinary for a CLI run against a live site) leaves the file GONE rather\n // than stale. A stale file re-classifies `stale` and is fixed by re-running;\n // a missing one classifies `deleted-locally-changed`, and the next `--apply`\n // acknowledges it into `deleted-locally` — silent, exit 0, permanently lost.\n const temp = `${dest}.lumi-sync-tmp`\n try {\n // Inside the try, not before it: a `writeFile` that fails part-way\n // (ENOSPC, EIO) leaves a partial scratch file, which is the same litter\n // the cleanup below exists to prevent.\n await writeFile(temp, await readFile(source))\n await rename(temp, dest)\n } catch (err) {\n // Do not leave the scratch file in the customer's tree. It is not\n // template-owned, so the next run would report it as `project-own`\n // forever — litter that looks like something they wrote.\n await rm(temp, { force: true })\n throw err\n }\n applied.push(entry)\n }\n return applied\n}\n\n/**\n * Refuse a tar entry whose path escapes the extraction root.\n *\n * Shared with `scaffold.ts`'s `extractTemplate` so the two extraction sites\n * cannot drift. tar v7 already refuses absolute paths and `..`, but an explicit\n * filter makes the contract loud and logs anything that ever slips through.\n * Includes Windows drive-letter absolutes (`C:/…`), which slip past a bare\n * `startsWith('/')`.\n */\nexport function refuseTraversal(path: string): boolean {\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 * Files that must be present for a directory to be a plausible `apps/front`.\n *\n * The same two anchors `assertFrontAppVerbatim` uses, for the same reason it\n * gives: their absence means the tool is looking at the wrong directory. Here\n * it is load-bearing rather than tidy — `existsSync(templateAppRoot)` is\n * satisfied by an EMPTY directory, and against an empty template every\n * baselined file classifies `removed-upstream`, so `--apply` would delete the\n * whole of `apps/front` and exit 0.\n */\nexport const TEMPLATE_ANCHORS: readonly string[] = ['astro.config.ts', 'src/pages/[...slug].astro']\n\n/**\n * The anchors missing from a hashed tree; empty means it looks real.\n *\n * Applied to the TEMPLATE tree and to the BASELINE, because a sparse baseline\n * is the same hazard from the other side: `added` needs only an absent baseline\n * entry, so a manifest that parses but covers little makes every file the\n * project deleted look new. The anchors are a coverage floor for both.\n */\nexport function missingAnchors(files: Readonly<Record<string, string>>): string[] {\n return TEMPLATE_ANCHORS.filter((anchor) => files[anchor] === undefined)\n}\n\n/**\n * Refuse a plan that would delete an implausible share of the baseline.\n *\n * The anchors are a NECESSARY condition, not a sufficient one: a directory\n * holding just those two files passes them and then classifies every other\n * baselined path `removed-upstream`. Measured on a 32-file project, that is 30\n * deletions at exit 0.\n *\n * This is a threshold and there is no way to make it not one — so it is set\n * where the two error costs are wildly asymmetric rather than where it is\n * elegant. A real release has never removed a majority of `apps/front` and\n * plausibly never will; a truncated tarball or a wrong `--template` removes\n * nearly all of it. A false refusal costs one re-run after checking the path.\n */\nexport const MAX_DELETION_SHARE = 0.5\n\nexport function excessiveDeletion(args: {\n readonly plan: SyncPlan\n readonly baselineSize: number\n}): { deleting: number; limit: number } | null {\n const deleting = args.plan.apply.filter((e) => e.className === 'removed-upstream').length\n const limit = Math.floor(args.baselineSize * MAX_DELETION_SHARE)\n return deleting > limit ? { deleting, limit } : null\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 { refuseTraversal, writeScaffoldManifest } from './template-sync'\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 // 2b. Record the template baseline — the sha256 of every file under\n // `apps/front` as this scaffold wrote it.\n //\n // `apps/front` is ordinary app source the project owns and edits, and it\n // is also ~7,500 lines of generic host code the template keeps fixing. A\n // later `lumi-sync-template` run has three trees to compare — what the\n // template gave you, what you have now, what the template ships today —\n // and only the middle one is on disk. Without this file the other two\n // collapse into a two-way diff that cannot tell a toolkit fix from the\n // project's own code, and answers \"the template has a newer version of\n // this\" about files the customer wrote. See `template-sync.ts`.\n //\n // After tokenization deliberately: these hash what actually landed, not\n // what the tarball held.\n await writeScaffoldManifest({\n projectRoot: projectDir,\n templateVersion: readCreatePackageVersion(),\n })\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 // `refuseTraversal` is shared with `sync-cli.ts`, which extracts the same\n // tarball for a different reason. Two copies of a defense-in-depth filter is\n // one copy that stops getting updated.\n await tarExtract({ file: tarball, cwd: destDir, filter: refuseTraversal })\n}\n\n/**\n * Locate the published `@murumets-ee/create` package root, and read its\n * manifest while we are there.\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 resolveCreatePackage(): { dir: string; version: 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 {\n name?: string\n version?: string\n }\n if (pkg.name === '@murumets-ee/create') {\n // `typeof`, not `??`: the cast above is unchecked, so a non-string\n // `version` in a corrupt manifest sails past a nullish check and is\n // written into `.lumi-template.json` as `templateVersion`.\n // `parseManifest` then rejects the WHOLE manifest, and a freshly\n // scaffolded project degrades to no-baseline on its first sync — under\n // the diagnostic \"written by a newer @murumets-ee/create\", which is\n // both wrong and unactionable.\n return { dir, version: typeof pkg.version === 'string' ? pkg.version : '0.0.0' }\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\nexport function resolveTemplateTarball(): string {\n return join(resolveCreatePackage().dir, 'templates', 'template-demo.tar.gz')\n}\n\n/**\n * The version of `@murumets-ee/create` doing the scaffolding — recorded in\n * `.lumi-template.json` so `lumi-sync-template` knows which template release\n * the project's baseline came from.\n */\nexport function readCreatePackageVersion(): string {\n return resolveCreatePackage().version\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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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import{existsSync as e,readFileSync as t}from"node:fs";import{mkdir as n,mkdtemp as r,readFile as i,readdir as a,realpath as o,rename as s,rm as c,writeFile as l}from"node:fs/promises";import{tmpdir as u}from"node:os";import{dirname as d,join as f,relative as p,resolve as m,sep as h}from"node:path";import{extract as g}from"tar";import{fileURLToPath as _}from"node:url";import{createHash as ee}from"node:crypto";import"@clack/prompts";const v=`.lumi-template.json`,y=`apps/front`,b=[`package.json`,`src/styles/globals.css`],x=new Set([`node_modules`,`dist`,`.astro`,`.turbo`,`.git`]);function S(e){return new Set([...b,...e??[]])}const C=[`stale`,`added`,`removed-upstream`],w=[`modified`,`collides`,`deleted-locally-changed`,`absent-here`];function T(e){let{baseline:t,project:n,template:r,hasManifest:i}=e;return i?r===void 0?t===void 0?`project-own`:n===void 0?`unchanged`:n===t?`removed-upstream`:`modified`:n===void 0?t===void 0?`added`:r===t?`deleted-locally`:`deleted-locally-changed`:n===r?`unchanged`:t===void 0?`collides`:n===t?`stale`:r===t?`local`:`modified`:r===void 0?`project-own`:n===void 0?`absent-here`:n===r?`unchanged`:`modified`}function E(e){let t=S(e.generatedFiles),n=e.baseline!==void 0,r=e.baseline??{},i=new Set([...Object.keys(r),...Object.keys(e.project),...Object.keys(e.template)]),a=[],o=[];for(let s of[...i].sort()){if(t.has(s)){o.push(s);continue}a.push({path:s,className:T({baseline:r[s],project:e.project[s],template:e.template[s],hasManifest:n})})}let s=new Set(C),c=new Set(w);return{entries:a,apply:a.filter(e=>s.has(e.className)),review:a.filter(e=>c.has(e.className)),generated:o.sort(),hasManifest:n}}const D=[`deleted-locally-changed`];function O(e){let t={...e.previous??{}};for(let n of[...e.applied,...e.acknowledged??[]]){let r=e.template[n];r!==void 0&&(t[n]=r)}return t}function k(e){return{manifestVersion:1,templateVersion:e.templateVersion,...e.templateSource===void 0?{}:{templateSource:e.templateSource},appDir:e.appDir??`apps/front`,generated:[...S(e.generated)].sort(),files:N(e.files)}}function A(e){let t;try{t=JSON.parse(e)}catch{return}let n=j(t);if(!n||n.manifestVersion!==1||typeof n.templateVersion!=`string`||typeof n.appDir!=`string`)return;let r=j(n.files);if(!r)return;let i={};for(let[e,t]of Object.entries(r)){if(typeof t!=`string`)return;i[e]=t}let a=n.templateSource;return{manifestVersion:1,templateVersion:n.templateVersion,...typeof a==`string`?{templateSource:a}:{},appDir:n.appDir,generated:M(n.generated)??[...b],files:i}}function j(e){if(!(typeof e!=`object`||!e||Array.isArray(e)))return e}function M(e){if(!Array.isArray(e))return;let t=e,n=[];for(let e of t){if(typeof e!=`string`)return;n.push(e)}return n}function N(e){let t={};for(let n of Object.keys(e).sort()){let r=e[n];r!==void 0&&(t[n]=r)}return t}function P(e){return ee(`sha256`).update(e).digest(`hex`)}async function F(t){if(!e(t))return{};let n={};async function r(e){let o=await a(e,{withFileTypes:!0});for(let a of o){if(a.isDirectory()){if(x.has(a.name))continue;await r(f(e,a.name));continue}if(!a.isFile())continue;let o=f(e,a.name);n[p(t,o).split(h).join(`/`)]=P(await i(o))}}return await r(t),n}async function I(t){let n=f(t,v);if(e(n))return A(await i(n,`utf-8`))}async function L(e,t){let n=f(e,v),r=`${n}.tmp`;try{await l(r,`${JSON.stringify(t,null,2)}\n`,`utf-8`),await s(r,n)}catch(e){throw await c(r,{force:!0}),e}}function R(e,t){let n={...e};for(let e of t)delete n[e];return n}async function z(e){let t=[],r=await o(e.projectAppRoot);for(let a of e.plan.apply){let u=f(e.projectAppRoot,...a.path.split(`/`));if(a.className===`removed-upstream`){await c(u,{force:!0}),t.push(a);continue}let p=f(e.templateAppRoot,...a.path.split(`/`));await n(d(u),{recursive:!0});let m=await o(d(u));if(m!==r&&!m.startsWith(r+h))throw Error(`Refusing to write ${a.path}: it resolves to ${m}, outside ${r}. A symlinked directory inside the app would put the template's bytes somewhere this tool has no business writing.`);let g=`${u}.lumi-sync-tmp`;try{await l(g,await i(p)),await s(g,u)}catch(e){throw await c(g,{force:!0}),e}t.push(a)}return t}function B(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}const V=[`astro.config.ts`,`src/pages/[...slug].astro`];function H(e){return V.filter(t=>e[t]===void 0)}function U(e){let t=e.plan.apply.filter(e=>e.className===`removed-upstream`).length,n=Math.floor(e.baselineSize*.5);return t>n?{deleting:t,limit:n}:null}process.platform;function W(){let n=d(_(import.meta.url)),r=n;for(let n=0;n<5;n++){let n=f(r,`package.json`);if(e(n))try{let e=JSON.parse(t(n,`utf-8`));if(e.name===`@murumets-ee/create`)return{dir:r,version:typeof e.version==`string`?e.version:`0.0.0`}}catch(e){console.warn(`@murumets-ee/create: skipped malformed package.json at ${n}: ${e instanceof Error?e.message:String(e)}`)}let i=d(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.`)}function G(){return f(W().dir,`templates`,`template-demo.tar.gz`)}function K(){return W().version}const q=new Set([`--template`,`--project`]),J=new Set([`--apply`,`--adopt`,`--help`,`-h`]);function Y(e){let t=!1,n=!1,r=!1,i=null,a=null;for(let o=0;o<e.length;o++){let s=e[o];if(s!==void 0){if(q.has(s)){let t=e[o+1];if(t===void 0||t.startsWith(`-`))throw Error(`${s} needs a path.`);s===`--template`?i=t:a=t,o++;continue}if(!J.has(s))throw Error(`Unknown argument: ${s}. Supported: ${[...J,...q].sort().join(`, `)}.`);s===`--apply`?t=!0:s===`--adopt`?n=!0:r=!0}}if(t&&n)throw Error(`--apply and --adopt are mutually exclusive. --adopt records what you already have as the baseline; --apply changes files to match the template.`);return{apply:t,adopt:n,help:r,template:i,project:a}}const X=`lumi-sync-template — keep apps/front in step with the template it was scaffolded from
|
|
2
|
+
|
|
3
|
+
lumi-sync-template report drift; change nothing (exit 1 if anything is outstanding)
|
|
4
|
+
lumi-sync-template --apply also take the changes that are provably safe
|
|
5
|
+
lumi-sync-template --adopt record what you have now as the baseline, and stop there
|
|
6
|
+
|
|
7
|
+
--template <path> an extracted template dir or a .tar.gz, instead of the installed one
|
|
8
|
+
--project <path> the project root, instead of walking up from the working directory
|
|
9
|
+
|
|
10
|
+
Exit codes: 0 nothing outstanding, 1 something is, 2 the tool could not run.
|
|
11
|
+
`;function Z(e,t){let n=m(e);for(let e=0;e<32;e++){if(t(f(n,...`apps/front`.split(`/`))))return n;let e=m(n,`..`);if(e===n)break;n=e}return null}const Q={unchanged:`already matches the template`,stale:`the template moved and you never touched it`,added:`new in the template`,"removed-upstream":`the template dropped it and you never touched it`,modified:`both you and the template changed it`,local:`yours — the template has not moved`,"deleted-locally":`you deleted it and the template has not moved`,"deleted-locally-changed":`you deleted it and the template changed it`,"absent-here":`the template ships it and you do not have it — no baseline says why`,collides:`you authored a path the template now ships too`,"project-own":`yours alone`};function $(e){let{plan:t}=e,n=new Map;for(let e of t.entries)n.set(e.className,(n.get(e.className)??0)+1);let r=[];r.push(`lumi-sync-template — ${y}`),r.push(``),r.push(` template @murumets-ee/create ${e.templateVersion}`),r.push(e.baselineVersion===void 0?` baseline none — no ${v} in this project`:` baseline ${e.baselineVersion} (${v})`),e.baselineSource!==void 0&&r.push(` ...recorded from --template ${e.baselineSource}, not the install`),r.push(``),t.hasManifest||r.push(` Without a baseline this tool cannot tell "the template moved" from "you`,` wrote this", so it reports every difference and writes nothing. Reconcile`,` what matters, then run --adopt to record where you landed; every run after`,` that is exact.`,``);let i=[];for(let e of[`unchanged`,`local`,`deleted-locally`,`project-own`]){let t=n.get(e)??0;t>0&&i.push(`${t} ${e}`)}if(t.generated.length>0&&i.push(`${t.generated.length} generated-per-project`),i.length>0&&r.push(` ${i.join(`, `)}`,``),e.applied!==null){let t=e.applied.filter(e=>e.className===`removed-upstream`);r.push(e.applied.length===0?` Applied nothing — there was nothing safe to apply.`:` Applied ${e.applied.length} file(s)`+(t.length>0?`, of which ${t.length} DELETED:`:`:`));for(let t of e.applied)r.push(` ${t.className===`removed-upstream`?`DELETED `:`written `} ${t.path}`);r.push(``)}else if(t.apply.length>0){r.push(` ${t.apply.length} file(s) can be applied automatically:`);for(let e of t.apply)r.push(` ${e.className.padEnd(17)} ${e.path}`);r.push(``)}if(t.review.length>0){r.push(` ${t.review.length} file(s) need you:`);for(let e of t.review)r.push(` ${e.className.padEnd(17)} ${e.path} (${Q[e.className]})`);r.push(``)}return t.apply.length===0&&t.review.length===0?r.push(` Nothing outstanding.`,``):e.applied===null&&t.apply.length>0&&r.push(` Run with --apply to take the automatic ones.`,``),r.join(`
|
|
12
|
+
`)}async function te(e){try{return await ne(e)}catch(e){return console.error(`\nlumi-sync-template could not run:\n ${e instanceof Error?e.stack:String(e)}`),2}}async function ne(t){let n;try{n=Y(t)}catch(e){return console.error(e instanceof Error?e.message:String(e)),console.error(`\n${X}`),2}if(n.help)return console.log(X),0;let i=n.project?m(n.project):Z(process.cwd(),e);if(i===null||!e(f(i,...`apps/front`.split(`/`))))return console.error(`Could not find ${y}/ at or above ${process.cwd()}. Run this from inside a scaffolded project, or pass --project <path>.`),2;let a=f(i,...y.split(`/`)),o=K(),s=e(f(i,v)),l=await I(i),d=s&&!l;if(d&&(console.error(`${v} exists but could not be read as a manifest this version understands (manifestVersion 1). It may have been written by a newer @murumets-ee/create.\nReporting without a baseline. Refusing to write over it — upgrade the toolkit, or move the file aside deliberately.`),n.apply||n.adopt))return 2;if(l&&l.appDir!==`apps/front`)return console.error(`${v} describes "${l.appDir}", but this tool syncs "${y}". Refusing to act on a baseline that describes a different tree.`),2;if(n.apply&&!l)return console.error(`Refusing to --apply without a baseline.\n\nThere is no ${v} in ${i}, so nothing here can tell a file the template changed from one you wrote. Run without --apply to see the differences, reconcile what matters, then \`--adopt\` to record where you landed.`),2;if(n.apply&&l){let e=H(l.files);if(e.length>0)return console.error(`${v} does not cover ${y}: missing ${e.join(`, `)}.\nA baseline this sparse makes every file you deleted look like one the template just added. Re-record it with --adopt.`),2}let p=n.template?m(n.template):void 0,h=p??G();if(!e(h))return console.error(`Template not found at ${h}.\nInstall it with \`pnpm add -Dw @murumets-ee/create@catalog:lumi\`, or pass --template <dir|tarball>.`),2;let _=await r(f(u(),`lumi-template-`));try{let t;if(e(f(h,...`apps/front`.split(`/`)))?t=f(h,...y.split(`/`)):(await g({file:h,cwd:_,filter:B}),t=f(_,...y.split(`/`))),!e(t))return console.error(`${h} does not contain ${y}/.`),2;let[r,s]=await Promise.all([F(a),F(t)]),c=H(s);if(c.length>0)return console.error(`${t} does not look like ${y}: missing ${c.join(`, `)}.\nRefusing to treat it as the template — every file you have would read as one the template deleted.`),2;if(n.adopt){let e=[...S(l?.generated)],t=k({templateVersion:o,files:R(s,e),generated:e,templateSource:p});return await L(i,t),console.log(`Recorded the template at ${o} — ${Object.keys(t.files).length} file(s) under ${y} — as this project's baseline, in ${v}.`+(l?` Replaced the previous baseline at ${l.templateVersion}.`:``)+`
|
|
13
|
+
|
|
14
|
+
From here on: a file you have customised is silent until the template changes it again, and then it is REPORTED, never applied. A file only you have is never touched. Commit ${v}.`),0}let u=E({baseline:l?.files,project:r,template:s,generatedFiles:l?.generated}),m=null;if(n.apply){let e=U({plan:u,baselineSize:Object.keys(l?.files??{}).length});if(e)return console.error(`Refusing to delete ${e.deleting} of ${Object.keys(l?.files??{}).length} baselined file(s) — more than the ${e.limit} this will do without a second look.\nNo release removes that much of ${y} at once, so the template being read is very likely the wrong one or an incomplete one. Check --template, or the @murumets-ee/create install.`),console.log($({plan:u,templateVersion:o,baselineVersion:l?.templateVersion,baselineSource:l?.templateSource,applied:null})),2;m=await z({plan:u,projectAppRoot:a,templateAppRoot:t}),await L(i,k({templateVersion:o,templateSource:p,files:O({previous:l?.files,template:s,applied:m.map(e=>e.path),acknowledged:u.entries.filter(e=>D.includes(e.className)).map(e=>e.path)}),generated:l?.generated}))}return console.log($({plan:u,templateVersion:o,baselineVersion:l?.templateVersion,baselineSource:l?.templateSource,applied:m})),d?2:+(u.review.length>0||!n.apply&&u.apply.length>0)}finally{await c(_,{recursive:!0,force:!0})}}export{X as USAGE,Z as findProjectRoot,$ as formatReport,Y as parseSyncArgs,te as runSync};
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/create",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"create-lumi": "./bin/create-lumi.mjs"
|
|
7
|
+
"create-lumi": "./bin/create-lumi.mjs",
|
|
8
|
+
"lumi-sync-template": "./bin/lumi-sync-template.mjs"
|
|
8
9
|
},
|
|
9
10
|
"exports": {
|
|
10
11
|
".": {
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"tsdown": "^0.22.2",
|
|
31
32
|
"tsx": "^4.22.4",
|
|
32
33
|
"typescript": "^5.7.3",
|
|
33
|
-
"@murumets-ee/cli": "^0.
|
|
34
|
+
"@murumets-ee/cli": "^0.48.0"
|
|
34
35
|
},
|
|
35
36
|
"typeCoverage": {
|
|
36
37
|
"atLeast": 100
|
|
Binary file
|