UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

719 lines (718 loc) 39 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const gluegun_1 = require("gluegun"); const caddy_1 = require("../../lib/caddy"); const dev_migrate_helper_1 = require("../../lib/dev-migrate-helper"); const dev_project_1 = require("../../lib/dev-project"); const hoist_workspace_pnpm_config_1 = require("../../lib/hoist-workspace-pnpm-config"); const package_name_1 = require("../../lib/package-name"); const workspace_integration_1 = require("../../lib/workspace-integration"); const add_api_1 = __importDefault(require("./add-api")); const add_app_1 = __importDefault(require("./add-app")); /** * Create a new fullstack workspace */ const NewCommand = { alias: ['init'], description: 'Create fullstack workspace', hidden: false, name: 'init', run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0; // Retrieve the tools we need const { config, filesystem, frontendHelper, git, helper, parameters, patching, print: { colors, error, info, spin, success }, prompt: { ask, confirm }, server, strings: { kebabCase }, system, template, } = toolbox; // Start timer const timer = system.startTimer(); // Info info('Create a new fullstack workspace'); // Hint for non-interactive callers (e.g. Claude Code) toolbox.tools.nonInteractiveHint('lt fullstack init --name <name> --frontend <nuxt|angular> --api-mode <Rest|GraphQL|Both> --framework-mode <npm|vendor> [--framework-upstream-branch <ref>] [--next: implies nuxt-base-starter#next unless --frontend-branch overrides] [--dry-run] --noConfirm'); // Check git if (!(yield git.gitInstalled())) { return; } // Load configuration const ltConfig = config.loadConfig(); const configFrontend = (_b = (_a = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _a === void 0 ? void 0 : _a.fullstack) === null || _b === void 0 ? void 0 : _b.frontend; const configApiMode = (_d = (_c = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _c === void 0 ? void 0 : _c.fullstack) === null || _d === void 0 ? void 0 : _d.apiMode; const configGit = (_f = (_e = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _e === void 0 ? void 0 : _e.fullstack) === null || _f === void 0 ? void 0 : _f.git; const configGitLink = (_h = (_g = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _g === void 0 ? void 0 : _g.fullstack) === null || _h === void 0 ? void 0 : _h.gitLink; const configApiBranch = (_k = (_j = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _j === void 0 ? void 0 : _j.fullstack) === null || _k === void 0 ? void 0 : _k.apiBranch; const configFrontendBranch = (_m = (_l = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _l === void 0 ? void 0 : _l.fullstack) === null || _m === void 0 ? void 0 : _m.frontendBranch; const configApiCopy = (_p = (_o = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _o === void 0 ? void 0 : _o.fullstack) === null || _p === void 0 ? void 0 : _p.apiCopy; const configFrontendCopy = (_r = (_q = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _q === void 0 ? void 0 : _q.fullstack) === null || _r === void 0 ? void 0 : _r.frontendCopy; const configApiLink = (_t = (_s = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _s === void 0 ? void 0 : _s.fullstack) === null || _t === void 0 ? void 0 : _t.apiLink; const configFrontendLink = (_v = (_u = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _u === void 0 ? void 0 : _u.fullstack) === null || _v === void 0 ? void 0 : _v.frontendLink; const configFrameworkMode = (_x = (_w = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _w === void 0 ? void 0 : _w.fullstack) === null || _x === void 0 ? void 0 : _x.frameworkMode; // Parse CLI arguments const { 'api-branch': cliApiBranch, 'api-copy': cliApiCopy, 'api-link': cliApiLink, 'api-mode': cliApiMode, 'dry-run': cliDryRun, 'framework-mode': cliFrameworkMode, 'framework-upstream-branch': cliFrameworkUpstreamBranch, frontend: cliFrontend, 'frontend-branch': cliFrontendBranch, 'frontend-copy': cliFrontendCopy, 'frontend-framework-mode': cliFrontendFrameworkMode, 'frontend-link': cliFrontendLink, 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 : undefined; // Determine noConfirm with priority: CLI > command > parent > global > default const noConfirm = config.getNoConfirm({ cliValue: parameters.options.noConfirm, commandConfig: (_y = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _y === void 0 ? void 0 : _y.fullstack, config: ltConfig, }); // ── Auto-detect existing workspace ───────────────────────────────── // // If `lt fullstack init` runs inside a directory that already looks // like a workspace (pnpm-workspace.yaml or projects/) and the user // didn't pass a workspace name, dispatch to the matching incremental // command instead of trying to clone a new lt-monorepo on top of the // existing one. Three branches: // // - both projects/api and projects/app exist → nothing to do. // - only projects/app exists → delegate to add-api. // - only projects/api exists → delegate to add-app. // // Users who really want to create a *new* workspace from inside an // existing one bypass detection by supplying `--name <slug>` (or a // positional argument); the slug then becomes the new project dir // and the original detection-skipping path runs normally. const noNameProvided = !cliName && !parameters.first; if (noNameProvided) { const cwdLayout = (0, workspace_integration_1.detectWorkspaceLayout)('.', filesystem); if (cwdLayout.hasWorkspace) { if (cwdLayout.hasApi && cwdLayout.hasApp) { error('Workspace already has both projects/api and projects/app — nothing to add.'); info('Use `lt fullstack add-api --help-json` or `lt fullstack add-app --help-json` to inspect options.'); return; } if (cwdLayout.hasApp && !cwdLayout.hasApi) { info('Detected existing workspace with projects/app — delegating to `lt fullstack add-api`.'); // gluegun's `GluegunCommand.run` is typed as // `void | Promise<any>`. Cast through `unknown` so the await // is statically meaningful for our async implementations. return (yield add_api_1.default.run(toolbox)); } if (cwdLayout.hasApi && !cwdLayout.hasApp) { info('Detected existing workspace with projects/api — delegating to `lt fullstack add-app`.'); return (yield add_app_1.default.run(toolbox)); } // Workspace dir but neither sub-project present — fall through // to normal init. The user can still pass --name to override. } } // Get name of the workspace const name = cliName || (yield helper.getInput(parameters.first, { name: 'workspace name', showError: true, })); if (!name) { return; } // Set project directory const projectDir = kebabCase(name); // Check if directory already exists if (filesystem.exists(projectDir)) { info(''); error(`There's already a folder named "${projectDir}" here.`); return; } // Determine frontend with priority: CLI > config > interactive let frontend; if (cliFrontend) { frontend = cliFrontend === 'angular' ? 'angular' : cliFrontend === 'nuxt' ? 'nuxt' : null; if (!frontend) { error('Invalid frontend option. Use "angular" or "nuxt".'); return; } } else if (configFrontend) { frontend = configFrontend; info(`Using frontend from lt.config: ${frontend}`); } else if (noConfirm) { // Use default when noConfirm frontend = 'nuxt'; info('Using default frontend: nuxt (noConfirm mode)'); } else { // Interactive mode with sensible default const choices = ['angular', 'nuxt']; frontend = (yield ask({ choices, initial: 1, // Default to nuxt message: 'Which frontend framework?', name: 'frontend', type: 'select', })).frontend; if (!frontend) { return; } } // Determine API mode with priority: CLI > config > global > interactive (default: Rest) const globalApiMode = config.getGlobalDefault(ltConfig, 'apiMode'); let apiMode; if (experimental) { apiMode = 'Rest'; info('Using experimental nest-base template (Bun + Prisma + Postgres + Better-Auth)'); } else if (cliApiMode) { apiMode = cliApiMode; } else if (configApiMode) { apiMode = configApiMode; info(`Using API mode from lt.config: ${apiMode}`); } else if (globalApiMode) { apiMode = globalApiMode; info(`Using API mode from lt.config defaults: ${apiMode}`); } else if (noConfirm) { apiMode = 'Rest'; info('Using default API mode: REST/RPC'); } else { const apiModeChoice = yield ask({ choices: [ 'Rest - REST/RPC API with Swagger documentation (recommended)', 'GraphQL - GraphQL API with subscriptions', 'Both - REST and GraphQL in parallel (hybrid)', ], initial: 0, message: 'API mode?', name: 'apiMode', type: 'select', }); apiMode = apiModeChoice.apiMode.split(' - ')[0]; } // Determine framework-consumption mode (npm vs vendored) // // npm — classic: @lenne.tech/nest-server is an npm dependency. Framework // source lives in node_modules/@lenne.tech/nest-server. Backend is // cloned from nest-server-starter. Updates via // `/lt-dev:backend:update-nest-server`. // // vendor — pilot: the framework's core/ directory is copied directly into // projects/api/src/core/ as first-class project code. No npm // dependency. Backend is cloned from the nest-server framework repo // itself and stripped of framework-internal content. Updates via // `/lt-dev:backend:update-nest-server-core`; local patches are logged // in src/core/VENDOR.md. // // Default is still 'npm' until the vendoring pilot is fully evaluated. let frameworkMode; 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".`); return; } else if (configFrameworkMode === 'npm' || configFrameworkMode === 'vendor') { frameworkMode = configFrameworkMode; info(`Using framework mode from lt.config: ${frameworkMode}`); } else if (noConfirm) { frameworkMode = 'npm'; info('Using default framework mode: npm (noConfirm mode)'); } else { const frameworkModeChoice = yield ask({ choices: [ 'npm - @lenne.tech/nest-server as npm dependency (classic, stable)', 'vendor - framework core vendored into projects/api/src/core/ (pilot, allows local patches)', ], initial: 0, message: 'Framework consumption mode?', name: 'frameworkMode', type: 'select', }); frameworkMode = frameworkModeChoice.frameworkMode.startsWith('vendor') ? 'vendor' : 'npm'; } // ── Frontend framework mode ───────────────────────────────────────── const configFrontendFrameworkMode = (_0 = (_z = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _z === void 0 ? void 0 : _z.fullstack) === null || _0 === void 0 ? void 0 : _0.frontendFrameworkMode; let frontendFrameworkMode; if (cliFrontendFrameworkMode === 'npm' || cliFrontendFrameworkMode === 'vendor') { frontendFrameworkMode = cliFrontendFrameworkMode; } else if (cliFrontendFrameworkMode) { error(`Invalid --frontend-framework-mode value "${cliFrontendFrameworkMode}". Use "npm" or "vendor".`); return; } else if (configFrontendFrameworkMode === 'npm' || configFrontendFrameworkMode === 'vendor') { frontendFrameworkMode = configFrontendFrameworkMode; info(`Using frontend framework mode from lt.config: ${frontendFrameworkMode}`); } else if (noConfirm) { frontendFrameworkMode = 'npm'; } else { // Default to npm without asking (unless user sets it explicitly) frontendFrameworkMode = 'npm'; } // Determine remote push settings with priority: CLI > config > interactive // Git is always initialized; the question is whether to push to a remote let pushToRemote = false; let gitLink; if (cliGit !== undefined) { // CLI parameter provided pushToRemote = cliGit === 'true' || cliGit === true; if (pushToRemote) { gitLink = cliGitLink || configGitLink; if (!gitLink) { error('--git-link is required when --git is true (or configure gitLink in lt.config)'); return; } } } else if (configGit !== undefined) { // Config value provided pushToRemote = configGit; if (pushToRemote) { gitLink = cliGitLink || configGitLink; if (!gitLink) { // Ask for git link interactively gitLink = yield helper.getInput(null, { name: 'git repository link', showError: true, }); if (!gitLink) { pushToRemote = false; } } else { info(`Using git configuration from lt.config`); } } } else if (!noConfirm && parameters.third !== 'false') { // Interactive mode pushToRemote = parameters.third === 'true' || (yield confirm('Push initial commit to a remote repository (dev branch)?')); if (pushToRemote) { gitLink = configGitLink || (yield helper.getInput(null, { name: 'git repository link', showError: true, })); if (!gitLink) { pushToRemote = false; } } } // Determine branches and copy/link paths with priority: CLI > config const apiBranch = cliApiBranch || configApiBranch; // Under `--next`, default the nuxt-base-starter ref to the `next` // branch — that branch ships an auth basePath (`/api/auth`) that // matches the experimental nest-base API. Explicit // `--frontend-branch` (or lt.config) still wins so consumers can // target a custom branch on either line. const frontendBranch = cliFrontendBranch || configFrontendBranch || (experimental ? 'next' : undefined); const apiCopy = cliApiCopy || configApiCopy; const apiLink = cliApiLink || configApiLink; const frontendCopy = cliFrontendCopy || configFrontendCopy; const frontendLink = cliFrontendLink || configFrontendLink; // Dry-run mode: print the resolved plan and exit without any disk // changes. Useful for CI previews, for Claude Code confirmation // steps, and for debugging the mode-detection logic without // committing to a multi-minute init flow. if (dryRun) { info(''); info('Dry-run plan:'); info(` name: ${name}`); info(` projectDir: ${projectDir}`); info(` frontend: ${frontend}`); info(` apiMode: ${apiMode}`); info(` frameworkMode: ${frameworkMode}`); info(` frontendFrameworkMode: ${frontendFrameworkMode}`); if (frameworkUpstreamBranch) { info(` frameworkUpstreamBranch: ${frameworkUpstreamBranch}`); } info(` apiBranch: ${apiBranch || '(default)'}`); info(` frontendBranch: ${frontendBranch || '(default)'}`); info(` apiCopy: ${apiCopy || '(none)'}`); info(` apiLink: ${apiLink || '(none)'}`); info(` frontendCopy: ${frontendCopy || '(none)'}`); info(` frontendLink: ${frontendLink || '(none)'}`); info(` pushToRemote: ${pushToRemote}`); if (pushToRemote) { info(` gitLink: ${gitLink || '(unset — would abort at run-time)'}`); } info(''); info('Would execute:'); info(` 1. git clone lt-monorepo → ${projectDir}/`); info(` 2. setup frontend (${frontend}) → ${projectDir}/projects/app`); 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`); info(` 5. vendor core/ + flatten-fix + codemod consumer imports`); info(` 6. merge upstream deps (dynamic, no hard-coded list)`); info(` 7. run processApiMode(${apiMode})`); if (apiMode === 'Rest') { info(` 8. restore vendored core essentials (graphql-*)`); } } else { info(` 3. clone nest-server-starter → ${projectDir}/projects/api`); info(` 4. run processApiMode(${apiMode})`); } if (frontendFrameworkMode === 'vendor') { info(` M1. clone @lenne.tech/nuxt-extensions → /tmp`); info(` M2. vendor app/core/ (module.ts + runtime/) + codemod consumer imports`); info(` M3. rewrite nuxt.config.ts module entry`); } info(' N. pnpm install + initial git commit'); info(''); return `fullstack init dry-run (${frameworkMode} / ${apiMode})`; } const workspaceSpinner = spin(`Create fullstack workspace with ${frontend} in ${projectDir} with ${name} app`); // Clone monorepo try { yield system.run(`git clone https://github.com/lenneTech/lt-monorepo.git ${projectDir}`); } catch (err) { workspaceSpinner.fail(`Failed to clone monorepo: ${err.message}`); return; } // Check for directory if (!filesystem.isDirectory(`./${projectDir}`)) { workspaceSpinner.fail(`The directory "${projectDir}" could not be created.`); return; } workspaceSpinner.succeed(`Create fullstack workspace with ${frontend} in ${projectDir} for ${name} created`); // Include example app const ngBaseSpinner = spin(`Integrate example for ${frontend}`); // Remove git folder after clone filesystem.remove(`${projectDir}/.git`); // Patch root files for the project. // // For the classic flow we patch the cloned `lt-monorepo` CLAUDE.md with // template variables. For `--next` (experimental) we replace the root // README.md, CLAUDE.md, and create `.claude/QUICKSTART.md` outright, // because `lt-monorepo`'s root files describe the legacy MongoDB + // GraphQL stack which is explicitly out of scope for the nest-base // template — leaving them in place poisons every AI agent's context. if (experimental) { const nextTemplateProps = { name, projectDir }; // Render new root files. `template.generate({ target })` overwrites // anything at `target`, which is what we want — the freshly cloned // monorepo's stale README/CLAUDE.md must be replaced wholesale. yield template.generate({ props: nextTemplateProps, target: `${projectDir}/README.md`, template: 'next-fullstack/README.md.ejs', }); yield template.generate({ props: nextTemplateProps, target: `${projectDir}/CLAUDE.md`, template: 'next-fullstack/CLAUDE.md.ejs', }); yield template.generate({ props: nextTemplateProps, target: `${projectDir}/.claude/QUICKSTART.md`, template: 'next-fullstack/.claude/QUICKSTART.md.ejs', }); } else { const claudeMdPath = `${projectDir}/CLAUDE.md`; if (filesystem.exists(claudeMdPath)) { const frontendName = frontend === 'nuxt' ? 'Nuxt 4' : 'Angular'; yield patching.update(claudeMdPath, (content) => content .replace(/\{\{PROJECT_NAME\}\}/g, () => name) .replace(/\{\{PROJECT_DIR\}\}/g, () => projectDir) .replace(/\{\{API_MODE\}\}/g, () => apiMode) .replace(/\{\{FRAMEWORK_MODE\}\}/g, () => frameworkMode) .replace(/\{\{FRONTEND_FRAMEWORK\}\}/g, () => frontendName)); } } // Rename the cloned monorepo's root package so each project gets a unique // `lt dev` slug. The slug is derived from package.json `name`; without // this rename every lt-monorepo-based project would register as // `lt-monorepo` and collide on `https://lt-monorepo.localhost`. (0, package_name_1.setPackageName)({ filesystem, name: projectDir, packageJsonPath: `${projectDir}/package.json` }); // Always initialize git try { yield system.run(`cd ${projectDir} && git init --initial-branch=dev`); } catch (err) { error(`Failed to initialize git: ${err.message}`); return; } // Add remote if push is configured if (pushToRemote && gitLink) { try { yield system.run(`cd ${projectDir} && git remote add origin ${gitLink}`); } catch (err) { error(`Failed to add remote: ${err.message}`); return; } } // Setup frontend using FrontendHelper const frontendDest = `${projectDir}/projects/app`; const isNuxt = frontend === 'nuxt'; let frontendResult; if (isNuxt) { frontendResult = yield frontendHelper.setupNuxt(frontendDest, { branch: frontendBranch, copyPath: frontendCopy, linkPath: frontendLink, skipInstall: true, // Will install at monorepo level }); } else { frontendResult = yield frontendHelper.setupAngular(frontendDest, { branch: frontendBranch, copyPath: frontendCopy, linkPath: frontendLink, skipGitInit: true, // Git is handled at monorepo level skipHuskyRemoval: true, // Will handle at monorepo level if needed skipInstall: true, // Will install at monorepo level }); } if (!frontendResult.success) { error(`Failed to set up ${frontend} frontend: ${frontendResult.path}`); return; } // Patch frontend .env with project-specific values (skip for linked templates) if (frontendResult.method !== 'link') { frontendHelper.patchFrontendEnv(frontendDest, projectDir); } // ── Frontend vendoring (if requested) ─────────────────────────────── if (isNuxt && frontendFrameworkMode === 'vendor' && frontendResult.method !== 'link') { const vendorSpinner = spin('Converting frontend to vendor mode...'); try { yield frontendHelper.convertAppCloneToVendored({ dest: frontendDest, projectName: name, }); vendorSpinner.succeed('Frontend converted to vendor mode (app/core/)'); } catch (err) { vendorSpinner.fail(`Frontend vendor conversion failed: ${err.message}`); toolbox.print.warning('Continuing with npm mode for frontend.'); } } // Remove gitkeep file filesystem.remove(`${projectDir}/projects/.gitkeep`); // Integrate files if (filesystem.isDirectory(`./${projectDir}/projects/app`)) { ngBaseSpinner.succeed(`Example for ${frontend} integrated`); // Include files from https://github.com/lenneTech/nest-server-starter const serverSpinner = spin(`Integrate Nest Server Starter${apiLink ? ' (link)' : apiCopy ? ' (copy)' : apiBranch ? ` (branch: ${apiBranch})` : ''}`); // Setup API using Server extension const apiDest = `${projectDir}/projects/api`; const apiResult = yield server.setupServerForFullstack(apiDest, { apiMode, branch: apiBranch, copyPath: apiCopy, experimental, frameworkMode, frameworkUpstreamBranch, linkPath: apiLink, name, projectDir, }); if (!apiResult.success) { serverSpinner.fail(`Failed to set up API: ${apiResult.path}`); return; } // Auto-run `bun run rename <projectDir>` for the experimental nest-base // template. The template ships with hard-coded `nest-base` references in // four files (package.json, README.md, portless.yml, docker-compose.yml). // The rename script patches all four idempotently. Since the consumer // already gave us --name, doing this for them is strictly less // friction-prone than relying on a manual follow-up step (which agents // and humans both forget). Failure is non-fatal: the workspace is still // usable, the user can re-run `bun run rename <name>` manually. // // Note: setupServerForFullstack already patched projects/api/package.json // to set `name = projectDir`. The rename planner reads that name as the // "old" slug, which would short-circuit the README/portless/compose // rewrites because they still say `nest-base`. We restore the canonical // `name = "nest-base"` first so the planner has a coherent starting // state across all four files; the rename then writes the project name // into every spot consistently. if (experimental && apiResult.method !== 'link') { const apiPackageJsonPath = `${apiDest}/package.json`; if (filesystem.exists(apiPackageJsonPath)) { yield patching.update(apiPackageJsonPath, (config) => { config.name = 'nest-base'; return config; }); } const renameSpinner = spin(`Rename nest-base → ${projectDir}`); try { yield system.run(`cd ${apiDest} && bun run rename ${projectDir}`); renameSpinner.succeed(`Renamed nest-base → ${projectDir} in projects/api`); } catch (err) { renameSpinner.warn(`Auto-rename failed (${err.message}). Run \`bun run rename ${projectDir}\` manually inside projects/api.`); } // Flip .claude/upstream.json from the template-self default to the // downstream shape so `/upstream-pr` can contribute core fixes back // to nest-base. Independent of the rename above — run it even if the // rename failed. Non-fatal when the file is absent. const upstreamResult = (0, workspace_integration_1.reconfigureUpstreamForDownstream)({ apiDir: apiDest, filesystem, upstreamBranch: apiBranch, }); if (upstreamResult.updated) { info('Configured .claude/upstream.json for downstream contributions'); } } // Create lt.config.json for API // Note: frameworkMode is persisted under meta so that subsequent `lt // server module` / `addProp` / `permissions` calls can detect the mode // without re-probing src/core/VENDOR.md each time (the VENDOR.md check // still works; this is just an explicit marker). const apiConfigPath = filesystem.path(apiDest, 'lt.config.json'); filesystem.write(apiConfigPath, { commands: { server: { module: { controller: apiMode, }, }, }, meta: { apiMode, frameworkMode, version: '1.0.0', }, }, { jsonIndent: 2 }); // Integrate files if (filesystem.isDirectory(`./${projectDir}/projects/api`)) { serverSpinner.succeed('Nest Server Starter integrated'); } else { serverSpinner.warn('Nest Server Starter not integrated'); } // Hoist workspace-scoped pnpm config out of sub-projects. pnpm only // honors `pnpm.overrides`, `pnpm.onlyBuiltDependencies`, and // `pnpm.ignoredOptionalDependencies` at the workspace root; leaving // them in projects/api/package.json or projects/app/package.json // causes `WARN The field … was found in … This will not take // effect. You should configure … at the root of the workspace // instead.` and silently disables CVE overrides. (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({ filesystem, projectDir, subProjects: ['projects/api', 'projects/app'] }); // Install all packages if (!experimental) { const installSpinner = spin('Install all packages'); try { const detectedPm = toolbox.pm.detect(projectDir); yield 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 // setupServerForFullstack) and convertAppCloneToVendored rewrite // source files, leaving whitespace artifacts that oxfmt flags in // `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 (!experimental && apiMode && filesystem.isDirectory(`${projectDir}/projects/api`)) { yield toolbox.apiMode.formatProject(`${projectDir}/projects/api`); } if (!experimental && isNuxt && filesystem.isDirectory(`${projectDir}/projects/app`)) { yield toolbox.apiMode.formatProject(`${projectDir}/projects/app`); } // Create initial commit after everything is set up try { yield system.run(`cd ${projectDir} && git add . && git commit -m "Initial commit"`); } catch (err) { error(`Failed to create initial commit: ${err.message}`); return; } // Push to remote if configured if (pushToRemote) { try { yield system.run(`cd ${projectDir} && git push -u origin dev`); } catch (err) { error(`Failed to push to remote: ${err.message}`); return; } } // Best-effort `lt dev init` so the workspace is ready for `lt dev up` // out-of-the-box: registers the slug in `~/.lenneTech/projects.json`, // injects the URL block into CLAUDE.md, adds `.lt-dev/` to .gitignore. // Failures here are non-fatal — `init` itself remains successful. let devMigrateOk = false; try { const layout = (0, dev_project_1.resolveLayout)(projectDir, gluegun_1.filesystem); const migrate = (0, dev_migrate_helper_1.runMigrate)({ layout }); devMigrateOk = true; if (!migrate.alreadyMigrated) { info(colors.dim(` registered "${migrate.identity.slug}" with \`lt dev\``)); } } catch (_1) { /* best-effort — never block init */ } // We're done, so show what to do next info(''); success(`Generated fullstack workspace with ${frontend} in ${projectDir} with ${name} app in ${helper.msToMinutesAndSeconds(timer())}m.`); info(''); info('Next:'); 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}`); if (devMigrateOk) { // Prefer lt dev up — sets up Caddy + HTTPS URLs + cross-wiring guards. const caddyOk = yield (0, caddy_1.caddyAvailable)(); if (caddyOk) { info(' $ lt dev up # start API + App behind Caddy with project-specific URLs'); } else { info(' $ lt dev install # one-time per machine: verify Caddy + CA'); info(' $ lt dev up # then: start API + App behind Caddy'); } info(colors.dim(` (fallback: ${toolbox.pm.run('start')} runs the classic localhost:3000/3001 mode)`)); } else { info(` $ ${toolbox.pm.run('start')}`); } } info(''); if (!toolbox.parameters.options.fromGluegunMenu) { process.exit(); } // For tests return `new workspace ${projectDir} with ${name}`; } }), }; exports.default = NewCommand;