diff --git a/README.md b/README.md index 0081e9f..70db706 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,37 @@ $ lt fullstack init --name myapp --framework-mode vendor --dry-run --noConfirm $ lt server create --name myapp --framework-mode vendor ``` +### Experimental: `--next` (nest-base) + +Both `lt fullstack init` and `lt server create` support an experimental +`--next` flag that swaps the API template from +[`nest-server-starter`](https://github.com/lenneTech/nest-server-starter) +(MongoDB) to [`nest-base`](https://github.com/lenneTech/nest-base) — a new +NestJS stack on **Bun + Prisma 7 + Postgres + Better-Auth** with a built-in +`/dev` cockpit. + +```bash +# experimental standalone api +$ lt server create my-next-api --next --noConfirm + +# experimental fullstack (nuxt + nest-base) +$ lt fullstack init --name my-next-app --frontend nuxt --next --noConfirm +``` + +When `--next` is set the CLI: + +- clones `nest-base` instead of `nest-server-starter`, +- forces `--api-mode Rest` and `--framework-mode npm` (other modes are not + applicable to nest-base), +- skips `nest-server-starter`-specific patching (`config.env.ts`, + `main.ts` Swagger setup, `meta.json`, `lt.config.json`), +- skips the workspace install in fullstack mode — run `pnpm install` for + the frontend and `bun install` for the API yourself. + +This option is **experimental** and may change. The downstream `lt server +module/object/addProp/test/permissions` commands target the classic +`nest-server` layout and are not yet compatible with `nest-base`. + ### Working on an existing project All `lt server …` commands (module, object, addProp, test, permissions) diff --git a/docs/LT-ECOSYSTEM-GUIDE.md b/docs/LT-ECOSYSTEM-GUIDE.md index 8962a31..5fe01c2 100644 --- a/docs/LT-ECOSYSTEM-GUIDE.md +++ b/docs/LT-ECOSYSTEM-GUIDE.md @@ -117,6 +117,7 @@ Flags: - `--frontend-framework-mode npm|vendor` — Frontend mode - `--framework-upstream-branch ` — Specific nest-server version for vendor - `--dry-run` — Show plan without making changes +- `--next` — **Experimental:** clone [`nest-base`](https://github.com/lenneTech/nest-base) (Bun + Prisma 7 + Postgres + Better-Auth) for the API instead of `nest-server-starter`. Forces `--api-mode Rest`, `--framework-mode npm`, and skips workspace install (run `pnpm install` for app and `bun install` for api manually). Downstream `lt server module/object/addProp/test/permissions` are NOT compatible with the resulting layout. --- diff --git a/docs/commands.md b/docs/commands.md index 1c9dbb0..6d2801a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -79,13 +79,16 @@ lt server create [name] [options] |--------|-------------| | `--description ` | Project description | | `--author ` | Author name | +| `--api-mode ` | API mode (ignored with `--next`) | +| `--framework-mode ` | Framework consumption mode (ignored with `--next`) | | `--branch ` / `-b` | Branch of nest-server-starter to use as template | | `--copy ` / `-c` | Copy from local template directory instead of cloning | | `--link ` | Symlink to local template directory (fastest, changes affect original) | | `--git` | Initialize git repository | +| `--next` | **Experimental:** clone [`nest-base`](https://github.com/lenneTech/nest-base) (Bun + Prisma 7 + Postgres + Better-Auth) instead of `nest-server-starter`. Skips API-mode / vendor-mode / install / lt.config.json processing. | | `--noConfirm` | Skip confirmation prompts | -**CLAUDE.md Patching:** If the project contains a `CLAUDE.md`, the generic API mode description is replaced with the selected mode. In single-mode projects (`Rest` or `GraphQL`), the "API Mode System" documentation section is condensed to a brief note. +**CLAUDE.md Patching:** If the project contains a `CLAUDE.md`, the generic API mode description is replaced with the selected mode. In single-mode projects (`Rest` or `GraphQL`), the "API Mode System" documentation section is condensed to a brief note. Skipped when `--next` is used. **Configuration:** `commands.server.create.*`, `defaults.author`, `defaults.noConfirm` @@ -532,6 +535,7 @@ lt fullstack init [options] | `--framework-mode ` | Backend framework consumption mode: `npm` (classic) or `vendor` (pilot, core copied to `src/core/`) | | `--framework-upstream-branch ` | Upstream `nest-server` branch/tag to vendor from (only with `--framework-mode vendor`) | | `--frontend-framework-mode ` | Frontend framework consumption mode: `npm` or `vendor` (nuxt-extensions copied to `app/core/`) | +| `--next` | **Experimental:** clone [`nest-base`](https://github.com/lenneTech/nest-base) (Bun + Prisma 7 + Postgres + Better-Auth) for the API instead of `nest-server-starter`. Forces `--api-mode Rest` and `--framework-mode npm`, skips workspace install (run `pnpm install` for app and `bun install` for api manually). | | `--dry-run` | Print the resolved plan without making any changes | | `--git` | Push initial commit to remote repository (git is always initialized) | | `--git-link ` | Git remote repository URL (required when `--git` is true) | diff --git a/src/commands/fullstack/init.ts b/src/commands/fullstack/init.ts index 2fa7d3d..70ee877 100644 --- a/src/commands/fullstack/init.ts +++ b/src/commands/fullstack/init.ts @@ -36,7 +36,7 @@ const NewCommand: GluegunCommand = { // Hint for non-interactive callers (e.g. Claude Code) toolbox.tools.nonInteractiveHint( - 'lt fullstack init --name --frontend --api-mode --framework-mode [--framework-upstream-branch ] [--dry-run] --noConfirm', + 'lt fullstack init --name --frontend --api-mode --framework-mode [--framework-upstream-branch ] [--next] [--dry-run] --noConfirm', ); // Check git @@ -75,9 +75,11 @@ const NewCommand: GluegunCommand = { git: cliGit, 'git-link': cliGitLink, name: cliName, + next: cliNext, } = parameters.options; const dryRun = cliDryRun === true || cliDryRun === 'true'; + const experimental = cliNext === true || cliNext === 'true'; const frameworkUpstreamBranch = typeof cliFrameworkUpstreamBranch === 'string' && cliFrameworkUpstreamBranch.length > 0 ? cliFrameworkUpstreamBranch @@ -147,7 +149,10 @@ const NewCommand: GluegunCommand = { // Determine API mode with priority: CLI > config > global > interactive (default: Rest) const globalApiMode = config.getGlobalDefault<'Both' | 'GraphQL' | 'Rest'>(ltConfig, 'apiMode'); let apiMode: 'Both' | 'GraphQL' | 'Rest'; - if (cliApiMode) { + if (experimental) { + apiMode = 'Rest'; + info('Using experimental nest-base template (Bun + Prisma + Postgres + Better-Auth)'); + } else if (cliApiMode) { apiMode = cliApiMode as 'Both' | 'GraphQL' | 'Rest'; } else if (configApiMode) { apiMode = configApiMode; @@ -189,7 +194,9 @@ const NewCommand: GluegunCommand = { // // Default is still 'npm' until the vendoring pilot is fully evaluated. let frameworkMode: 'npm' | 'vendor'; - if (cliFrameworkMode === 'npm' || cliFrameworkMode === 'vendor') { + if (experimental) { + frameworkMode = 'npm'; + } else if (cliFrameworkMode === 'npm' || cliFrameworkMode === 'vendor') { frameworkMode = cliFrameworkMode; } else if (cliFrameworkMode) { error(`Invalid --framework-mode value "${cliFrameworkMode}". Use "npm" or "vendor".`); @@ -325,7 +332,9 @@ const NewCommand: GluegunCommand = { info('Would execute:'); info(` 1. git clone lt-monorepo → ${projectDir}/`); info(` 2. setup frontend (${frontend}) → ${projectDir}/projects/app`); - if (frameworkMode === 'vendor') { + if (experimental) { + info(` 3. clone nest-base (experimental) → ${projectDir}/projects/api`); + } else if (frameworkMode === 'vendor') { info(` 3. clone nest-server-starter → ${projectDir}/projects/api`); info( ` 4. clone @lenne.tech/nest-server${frameworkUpstreamBranch ? ` (branch/tag: ${frameworkUpstreamBranch})` : ''} → /tmp`, @@ -472,6 +481,7 @@ const NewCommand: GluegunCommand = { apiMode, branch: apiBranch, copyPath: apiCopy, + experimental, frameworkMode, frameworkUpstreamBranch, linkPath: apiLink, @@ -526,16 +536,20 @@ const NewCommand: GluegunCommand = { hoistWorkspacePnpmConfig({ filesystem, projectDir, subProjects: ['projects/api', 'projects/app'] }); // Install all packages - const installSpinner = spin('Install all packages'); - try { - const detectedPm = toolbox.pm.detect(projectDir); - await system.run( - `cd ${projectDir} && ${toolbox.pm.install(detectedPm)} && ${toolbox.pm.run('init', detectedPm)}`, - ); - installSpinner.succeed('Successfully installed all packages'); - } catch (err) { - installSpinner.fail(`Failed to install packages: ${err.message}`); - return; + if (!experimental) { + const installSpinner = spin('Install all packages'); + try { + const detectedPm = toolbox.pm.detect(projectDir); + await system.run( + `cd ${projectDir} && ${toolbox.pm.install(detectedPm)} && ${toolbox.pm.run('init', detectedPm)}`, + ); + installSpinner.succeed('Successfully installed all packages'); + } catch (err) { + installSpinner.fail(`Failed to install packages: ${err.message}`); + return; + } + } else { + info('Skipping workspace install — run `bun install` (api) and `pnpm install` (app) manually.'); } // Post-install format pass. processApiMode (run earlier in @@ -544,10 +558,10 @@ const NewCommand: GluegunCommand = { // `pnpm run format:check` (multi-line arrays/imports after region // stripping, import-path rewrites that now fit single-line). The // formatter is only available after install, so we normalize here. - if (apiMode && filesystem.isDirectory(`${projectDir}/projects/api`)) { + if (!experimental && apiMode && filesystem.isDirectory(`${projectDir}/projects/api`)) { await toolbox.apiMode.formatProject(`${projectDir}/projects/api`); } - if (isNuxt && filesystem.isDirectory(`${projectDir}/projects/app`)) { + if (!experimental && isNuxt && filesystem.isDirectory(`${projectDir}/projects/app`)) { await toolbox.apiMode.formatProject(`${projectDir}/projects/app`); } @@ -578,9 +592,17 @@ const NewCommand: GluegunCommand = { ); info(''); info('Next:'); - info(` Run ${name}`); - info(` $ cd ${projectDir}`); - info(` $ ${toolbox.pm.run('start')}`); + if (experimental) { + info(` $ cd ${projectDir}`); + info(' Frontend: cd projects/app && pnpm install'); + info(' API: cd projects/api && bun install'); + info(' Configure projects/api/.env (see .env.example)'); + info(' Start Postgres + run prisma generate / migrate'); + } else { + info(` Run ${name}`); + info(` $ cd ${projectDir}`); + info(` $ ${toolbox.pm.run('start')}`); + } info(''); if (!toolbox.parameters.options.fromGluegunMenu) { diff --git a/src/commands/server/create.ts b/src/commands/server/create.ts index 6117dee..374467f 100644 --- a/src/commands/server/create.ts +++ b/src/commands/server/create.ts @@ -48,6 +48,13 @@ const NewCommand: GluegunCommand = { { description: 'Git branch to clone from', flag: '--branch', required: false, type: 'string' }, { description: 'Copy from local path instead of cloning', flag: '--copy', required: false, type: 'string' }, { description: 'Symlink to local path instead of cloning', flag: '--link', required: false, type: 'string' }, + { + default: false, + description: 'Use experimental nest-base template (Bun + Prisma + Postgres)', + flag: '--next', + required: false, + type: 'boolean', + }, { default: false, description: 'Skip all interactive prompts', @@ -87,6 +94,7 @@ const NewCommand: GluegunCommand = { const cliApiMode = parameters.options['api-mode'] || parameters.options.apiMode; const cliFrameworkMode = parameters.options['framework-mode'] as 'npm' | 'vendor' | undefined; const cliFrameworkUpstreamBranch = parameters.options['framework-upstream-branch'] as string | undefined; + const experimental = parameters.options.next === true || parameters.options.next === 'true'; // Determine noConfirm with priority: CLI > config > global > default (false) const noConfirm = config.getNoConfirm({ @@ -102,7 +110,9 @@ const NewCommand: GluegunCommand = { info('Create a new server'); // Hint for non-interactive callers (e.g. Claude Code) - toolbox.tools.nonInteractiveHint('lt server create --name --api-mode --noConfirm'); + toolbox.tools.nonInteractiveHint( + 'lt server create --name --api-mode [--next] --noConfirm', + ); // Check git if (!(await git.gitInstalled())) { @@ -168,7 +178,10 @@ const NewCommand: GluegunCommand = { // Determine API mode with priority: CLI > config > global > interactive (default: Rest) let apiMode: 'Both' | 'GraphQL' | 'Rest'; - if (cliApiMode) { + if (experimental) { + apiMode = 'Rest'; + info('Using experimental nest-base template (Bun + Prisma + Postgres + Better-Auth)'); + } else if (cliApiMode) { apiMode = cliApiMode as 'Both' | 'GraphQL' | 'Rest'; } else if (configApiMode) { apiMode = configApiMode; @@ -199,7 +212,9 @@ const NewCommand: GluegunCommand = { // Determine framework consumption mode — same resolution cascade as // lt fullstack init: CLI flag > lt.config > interactive (default npm). let frameworkMode: 'npm' | 'vendor'; - if (cliFrameworkMode === 'npm' || cliFrameworkMode === 'vendor') { + if (experimental) { + frameworkMode = 'npm'; + } else if (cliFrameworkMode === 'npm' || cliFrameworkMode === 'vendor') { frameworkMode = cliFrameworkMode; } else if (cliFrameworkMode) { error(`Invalid --framework-mode value "${cliFrameworkMode}". Use "npm" or "vendor".`); @@ -239,6 +254,7 @@ const NewCommand: GluegunCommand = { branch, copyPath, description, + experimental, frameworkMode, frameworkUpstreamBranch, linkPath, @@ -302,28 +318,30 @@ const NewCommand: GluegunCommand = { // Derive controller type from API mode and save project config const controllerType: 'Both' | 'GraphQL' | 'Rest' = apiMode; - // Create lt.config.json - const projectConfig = { - commands: { - server: { - module: { - controller: controllerType, + if (!experimental) { + // Create lt.config.json + const projectConfig = { + commands: { + server: { + module: { + controller: controllerType, + }, }, }, - }, - meta: { - apiMode, - version: '1.0.0', - }, - }; + meta: { + apiMode, + version: '1.0.0', + }, + }; - const configPath = filesystem.path(projectDir, 'lt.config.json'); - filesystem.write(configPath, projectConfig, { jsonIndent: 2 }); + const configPath = filesystem.path(projectDir, 'lt.config.json'); + filesystem.write(configPath, projectConfig, { jsonIndent: 2 }); - info(''); - success(`Configuration saved to ${projectDir}/lt.config.json`); - info(` API mode: ${apiMode}`); - info(` Default controller type: ${controllerType}`); + info(''); + success(`Configuration saved to ${projectDir}/lt.config.json`); + info(` API mode: ${apiMode}`); + info(` Default controller type: ${controllerType}`); + } // We're done, so show what to do next info(''); @@ -332,11 +350,19 @@ const NewCommand: GluegunCommand = { ); info(''); info('Next:'); - info(' Start database server (e.g. MongoDB)'); - info(` Check config: ${projectDir}/src/config.env.ts`); - info(` Go to project directory: cd ${projectDir}`); - info(` Run tests: ${toolbox.pm.run('test:e2e')}`); - info(` Start server: ${toolbox.pm.run('start')}`); + if (experimental) { + info(` Go to project directory: cd ${projectDir}`); + info(' Install dependencies: bun install'); + info(' Configure .env (see .env.example)'); + info(' Start Postgres + run prisma generate / migrate'); + info(' Start server: bun run dev'); + } else { + info(' Start database server (e.g. MongoDB)'); + info(` Check config: ${projectDir}/src/config.env.ts`); + info(` Go to project directory: cd ${projectDir}`); + info(` Run tests: ${toolbox.pm.run('test:e2e')}`); + info(` Start server: ${toolbox.pm.run('start')}`); + } info(''); if (!toolbox.parameters.options.fromGluegunMenu) { diff --git a/src/extensions/server.ts b/src/extensions/server.ts index 8f9479c..cbc1be1 100644 --- a/src/extensions/server.ts +++ b/src/extensions/server.ts @@ -686,6 +686,11 @@ export class Server { branch?: string; copyPath?: string; description?: string; + /** + * Experimental nest-base template (Bun + Prisma + Postgres + Better-Auth). + * Skips all nest-server-starter specific post-processing. + */ + experimental?: boolean; /** * Framework consumption mode. See `setupServerForFullstack` for the * full explanation; the semantics here are identical — standalone @@ -708,6 +713,7 @@ export class Server { branch, copyPath, description = '', + experimental = false, frameworkMode = 'npm', frameworkUpstreamBranch, linkPath, @@ -717,12 +723,16 @@ export class Server { skipPatching = false, } = options; + const repoUrl = experimental + ? 'https://github.com/lenneTech/nest-base.git' + : 'https://github.com/lenneTech/nest-server-starter.git'; + // Setup template const result = await templateHelper.setup(dest, { branch, copyPath, linkPath, - repoUrl: 'https://github.com/lenneTech/nest-server-starter.git', + repoUrl, }); if (!result.success) { @@ -737,22 +747,24 @@ export class Server { // Apply patches (config.env.ts, package.json, main.ts, meta.json) if (!skipPatching) { try { - // Generate README - await template.generate({ - props: { description, name }, - target: `${dest}/README.md`, - template: 'nest-server-starter/README.md.ejs', - }); + if (!experimental) { + // Generate README + await template.generate({ + props: { description, name }, + target: `${dest}/README.md`, + template: 'nest-server-starter/README.md.ejs', + }); - // Replace secret or private keys and update database names via AST - this.patchConfigEnvTs(`${dest}/src/config.env.ts`, projectDir); + // Replace secret or private keys and update database names via AST + this.patchConfigEnvTs(`${dest}/src/config.env.ts`, projectDir); - // Update Swagger configuration in main.ts - await patching.update(`${dest}/src/main.ts`, (content: string) => - content - .replace(/\.setTitle\('.*?'\)/, `.setTitle('${name}')`) - .replace(/\.setDescription\('.*?'\)/, `.setDescription('${description || name}')`), - ); + // Update Swagger configuration in main.ts + await patching.update(`${dest}/src/main.ts`, (content: string) => + content + .replace(/\.setTitle\('.*?'\)/, `.setTitle('${name}')`) + .replace(/\.setDescription\('.*?'\)/, `.setDescription('${description || name}')`), + ); + } // Update package.json await patching.update(`${dest}/package.json`, (config: Record) => { @@ -766,13 +778,15 @@ export class Server { return config; }); - // Update meta.json if exists - if (this.filesystem.exists(`${dest}/src/meta`)) { - await patching.update(`${dest}/src/meta`, (config: Record) => { - config.name = name; - config.description = description; - return config; - }); + if (!experimental) { + // Update meta.json if exists + if (this.filesystem.exists(`${dest}/src/meta`)) { + await patching.update(`${dest}/src/meta`, (config: Record) => { + config.name = name; + config.description = description; + return config; + }); + } } } catch (err) { return { method: result.method, path: dest, success: false }; @@ -796,7 +810,7 @@ export class Server { // manifest (same dance as in setupServerForFullstack). let standaloneVendorUpstreamDeps: Record = {}; let standaloneVendorCoreEssentials: string[] = []; - if (frameworkMode === 'vendor') { + if (!experimental && frameworkMode === 'vendor') { try { const converted = await this.convertCloneToVendored({ dest, @@ -811,7 +825,7 @@ export class Server { } // Process API mode (before install so package.json is correct) - if (apiMode) { + if (!experimental && apiMode) { try { await apiModeHelper.processApiMode(dest, apiMode); } catch (err) { @@ -820,7 +834,7 @@ export class Server { } // Restore core essentials after processApiMode stripped them (vendor + REST only). - if (frameworkMode === 'vendor' && apiMode === 'Rest') { + if (!experimental && frameworkMode === 'vendor' && apiMode === 'Rest') { try { this.restoreVendorCoreEssentials({ dest, @@ -833,10 +847,12 @@ export class Server { } // Patch CLAUDE.md with API mode info - this.patchClaudeMdApiMode(dest, apiMode); + if (!experimental) { + this.patchClaudeMdApiMode(dest, apiMode); + } // Install packages - if (!skipInstall) { + if (!skipInstall && !experimental) { try { const { pm } = this.toolbox; await system.run(`cd "${dest}" && ${pm.install(pm.detect(dest))}`); @@ -869,6 +885,11 @@ export class Server { apiMode?: 'Both' | 'GraphQL' | 'Rest'; branch?: string; copyPath?: string; + /** + * Experimental nest-base template (Bun + Prisma + Postgres + Better-Auth). + * Skips all nest-server-starter specific post-processing. + */ + experimental?: boolean; frameworkMode?: 'npm' | 'vendor'; /** * Branch, tag, or commit of the upstream @lenne.tech/nest-server repo @@ -886,6 +907,7 @@ export class Server { apiMode, branch, copyPath, + experimental = false, frameworkMode = 'npm', frameworkUpstreamBranch, linkPath, @@ -893,6 +915,10 @@ export class Server { projectDir, } = options; + const repoUrl = experimental + ? 'https://github.com/lenneTech/nest-base.git' + : 'https://github.com/lenneTech/nest-server-starter'; + // Both npm and vendor mode clone nest-server-starter as the base. The // starter ships the minimal consumer conventions a project needs // (src/server/common/models/persistence.model.ts, src/server/modules/user/, @@ -912,7 +938,7 @@ export class Server { branch, copyPath, linkPath, - repoUrl: 'https://github.com/lenneTech/nest-server-starter', + repoUrl, }); if (!result.success) { @@ -925,18 +951,31 @@ export class Server { } // Apply minimal patches for fullstack - try { - // Write meta.json - this.filesystem.write(`${dest}/src/meta.json`, { - description: `API for ${name} app`, - name: `${name}-api-server`, - version: '0.0.0', - }); + if (!experimental) { + try { + // Write meta.json + this.filesystem.write(`${dest}/src/meta.json`, { + description: `API for ${name} app`, + name: `${name}-api-server`, + version: '0.0.0', + }); - // Replace secret or private keys and update database names via AST - this.patchConfigEnvTs(`${dest}/src/config.env.ts`, projectDir); - } catch (err) { - return { method: result.method, path: dest, success: false }; + // Replace secret or private keys and update database names via AST + this.patchConfigEnvTs(`${dest}/src/config.env.ts`, projectDir); + } catch (err) { + return { method: result.method, path: dest, success: false }; + } + } else { + try { + await this.toolbox.patching.update(`${dest}/package.json`, (config: Record) => { + config.name = projectDir; + config.description = `API for ${name} app`; + config.version = '0.0.0'; + return config; + }); + } catch (err) { + return { method: result.method, path: dest, success: false }; + } } // Clean up copied template artifacts @@ -959,7 +998,7 @@ export class Server { // without hard-coding package lists. let vendorUpstreamDeps: Record = {}; let vendorCoreEssentials: string[] = []; - if (frameworkMode === 'vendor') { + if (!experimental && frameworkMode === 'vendor') { try { const converted = await this.convertCloneToVendored({ dest, @@ -983,7 +1022,7 @@ export class Server { } // Process API mode (before install which happens at monorepo level) - if (apiMode) { + if (!experimental && apiMode) { try { await apiModeHelper.processApiMode(dest, apiMode); } catch (err) { @@ -994,7 +1033,7 @@ export class Server { // In vendor mode + REST, re-add the graphql essentials that // processApiMode just stripped. Both and GraphQL keep all packages // by construction and don't need restoration. - if (frameworkMode === 'vendor' && apiMode === 'Rest') { + if (!experimental && frameworkMode === 'vendor' && apiMode === 'Rest') { try { this.restoreVendorCoreEssentials({ dest, @@ -1008,7 +1047,9 @@ export class Server { } // Patch CLAUDE.md with API mode info - this.patchClaudeMdApiMode(dest, apiMode); + if (!experimental) { + this.patchClaudeMdApiMode(dest, apiMode); + } return { method: result.method, path: dest, success: true }; }