UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

373 lines (372 loc) 19.6 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()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const hoist_workspace_pnpm_config_1 = require("../../lib/hoist-workspace-pnpm-config"); const workspace_integration_1 = require("../../lib/workspace-integration"); /** * Add an API (`projects/api/`) to a fullstack workspace that currently * only ships a frontend (`projects/app/`). Mirrors every API-related * flag from `lt fullstack init` so the surface area stays in lockstep. * * Refuses to run if `projects/api/` already exists — use a regular * `lt fullstack init` workflow on a fresh directory instead, or remove * the existing API first. */ const NewCommand = { alias: ['add-api'], description: 'Add API to fullstack workspace', hidden: false, name: 'add-api', run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; const { config, filesystem, git, parameters, patching, print: { error, info, spin, success, warning }, prompt: { ask }, server, system, } = toolbox; // Help-JSON support so AI agents can introspect the flags. if (toolbox.tools.helpJson({ aliases: ['add-api'], configuration: 'commands.fullstack.*', description: 'Add a NestJS API to an existing fullstack workspace', name: 'add-api', options: [ { description: 'API mode', flag: '--api-mode', type: 'string', values: ['Rest', 'GraphQL', 'Both'] }, { description: 'Framework consumption mode', flag: '--framework-mode', type: 'string', values: ['npm', 'vendor'], }, { description: 'Branch/tag/commit of upstream nest-server (vendor mode)', flag: '--framework-upstream-branch', type: 'string', }, { description: 'Branch of nest-server-starter to clone', flag: '--api-branch', type: 'string' }, { description: 'Path to local API template to copy from', flag: '--api-copy', type: 'string' }, { description: 'Path to local API template to symlink', flag: '--api-link', type: 'string' }, { description: 'Use experimental nest-base template (Bun + Prisma + Postgres + Better-Auth)', flag: '--next', type: 'boolean', }, { description: 'Workspace root (defaults to cwd)', flag: '--workspace-dir', type: 'string' }, { description: 'Skip install / format after API integration', flag: '--skip-install', type: 'boolean' }, { description: 'Print resolved plan and exit without disk changes', flag: '--dry-run', type: 'boolean' }, { description: 'Skip all interactive prompts', flag: '--noConfirm', type: 'boolean' }, ], })) { return; } const timer = system.startTimer(); info('Add API to fullstack workspace'); toolbox.tools.nonInteractiveHint('lt fullstack add-api --api-mode <Rest|GraphQL|Both> --framework-mode <npm|vendor> [--api-branch <ref>] [--next] [--dry-run] --noConfirm'); if (!(yield git.gitInstalled())) { return; } const ltConfig = config.loadConfig(); // Parse CLI options const cliApiMode = parameters.options['api-mode'] || parameters.options.apiMode; const cliFrameworkMode = parameters.options['framework-mode']; const cliFrameworkUpstreamBranch = parameters.options['framework-upstream-branch']; const cliApiBranch = parameters.options['api-branch']; const cliApiCopy = parameters.options['api-copy']; const cliApiLink = parameters.options['api-link']; const cliWorkspaceDir = parameters.options['workspace-dir']; const cliDryRun = parameters.options['dry-run']; const cliSkipInstall = parameters.options['skip-install']; const experimental = parameters.options.next === true || parameters.options.next === 'true'; const dryRun = cliDryRun === true || cliDryRun === 'true'; const skipInstall = cliSkipInstall === true || cliSkipInstall === 'true'; const noConfirm = config.getNoConfirm({ cliValue: parameters.options.noConfirm, commandConfig: (_a = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _a === void 0 ? void 0 : _a.fullstack, config: ltConfig, }); // Pull the same defaults from lt.config that init does, so commands // share configuration without users having to maintain two blocks. const configApiMode = (_c = (_b = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _b === void 0 ? void 0 : _b.fullstack) === null || _c === void 0 ? void 0 : _c.apiMode; const configFrameworkMode = (_e = (_d = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _d === void 0 ? void 0 : _d.fullstack) === null || _e === void 0 ? void 0 : _e.frameworkMode; const configApiBranch = (_g = (_f = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _f === void 0 ? void 0 : _f.fullstack) === null || _g === void 0 ? void 0 : _g.apiBranch; const configApiCopy = (_j = (_h = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _h === void 0 ? void 0 : _h.fullstack) === null || _j === void 0 ? void 0 : _j.apiCopy; const configApiLink = (_l = (_k = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _k === void 0 ? void 0 : _k.fullstack) === null || _l === void 0 ? void 0 : _l.apiLink; const globalApiMode = config.getGlobalDefault(ltConfig, 'apiMode'); // Workspace detection. Priority: // 1. explicit `--workspace-dir <path>` always wins // 2. cwd if it itself is a workspace // 3. nearest workspace found by walking up from cwd (catches the // "user is inside projects/app/src/" case so they don't need // to manually pass `--workspace-dir ../..`) let workspaceDir; if (cliWorkspaceDir) { workspaceDir = cliWorkspaceDir; } else { const cwdLayout = (0, workspace_integration_1.detectWorkspaceLayout)('.', filesystem); if (cwdLayout.hasWorkspace) { workspaceDir = '.'; } else { const upRoot = (0, workspace_integration_1.findWorkspaceRoot)('.', filesystem); if (upRoot) { workspaceDir = upRoot; info(`Detected fullstack workspace at ${upRoot} (walked up from cwd).`); } else { workspaceDir = '.'; } } } const layout = (0, workspace_integration_1.detectWorkspaceLayout)(workspaceDir, filesystem); if (!layout.hasWorkspace) { error(`No fullstack workspace detected at "${workspaceDir}". Expected pnpm-workspace.yaml, package.json#workspaces, or a projects/ directory. Use \`lt fullstack init\` for a fresh workspace.`); return; } if (layout.hasApi) { error(`An API already exists at "${workspaceDir}/projects/api". Remove it first or use \`lt fullstack init\` in a fresh directory.`); return; } // Resolve api mode (CLI > experimental override > config > global > interactive/default). 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 (noConfirm mode)'); } 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]; } // Resolve framework mode. 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'; } 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'; } const frameworkUpstreamBranch = typeof cliFrameworkUpstreamBranch === 'string' && cliFrameworkUpstreamBranch.length > 0 ? cliFrameworkUpstreamBranch : undefined; const apiBranch = cliApiBranch || configApiBranch; const apiCopy = cliApiCopy || configApiCopy; const apiLink = cliApiLink || configApiLink; // Derive a project name. We use the workspace directory's basename // as a reasonable default; the user can override via --name. This // matches the project slug `init.ts` would have written into // package.json during the original workspace creation. const cliName = parameters.options.name || parameters.first; let name = cliName; if (!name) { // Try to read from existing projects/app/package.json const appPkgPath = filesystem.path(workspaceDir, 'projects', 'app', 'package.json'); if (filesystem.exists(appPkgPath)) { const appPkg = filesystem.read(appPkgPath, 'json'); if (appPkg && typeof appPkg.name === 'string' && appPkg.name && appPkg.name !== 'app') { name = appPkg.name; } } } if (!name) { // Fall back to the directory basename so we never block on this. const segments = filesystem.path(workspaceDir).split(/[\\/]/).filter(Boolean); name = segments[segments.length - 1] || 'fullstack-app'; } const projectDir = workspaceDir === '.' ? filesystem.cwd().split(/[\\/]/).filter(Boolean).pop() || name : name; if (dryRun) { info(''); info('Dry-run plan:'); info(` workspaceDir: ${workspaceDir}`); info(` name: ${name}`); info(` apiMode: ${apiMode}`); info(` frameworkMode: ${frameworkMode}`); if (frameworkUpstreamBranch) { info(` frameworkUpstreamBranch: ${frameworkUpstreamBranch}`); } info(` apiBranch: ${apiBranch || '(default)'}`); info(` apiCopy: ${apiCopy || '(none)'}`); info(` apiLink: ${apiLink || '(none)'}`); info(` experimental (--next): ${experimental}`); info(''); info('Would execute:'); if (experimental) { info(` 1. clone nest-base → ${workspaceDir}/projects/api`); info(` 2. patch package.json + bun run rename ${projectDir}`); } else if (frameworkMode === 'vendor') { info(` 1. clone nest-server-starter → ${workspaceDir}/projects/api`); info(` 2. clone @lenne.tech/nest-server${frameworkUpstreamBranch ? ` (${frameworkUpstreamBranch})` : ''} → /tmp`); info(` 3. vendor core/ + flatten-fix + codemod consumer imports`); info(` 4. merge upstream deps`); info(` 5. run processApiMode(${apiMode})`); if (apiMode === 'Rest') info(` 6. restore vendored core essentials (graphql-*)`); } else { info(` 1. clone nest-server-starter → ${workspaceDir}/projects/api`); info(` 2. run processApiMode(${apiMode})`); } info(` N. write projects/api/lt.config.json + hoist pnpm overrides`); if (!skipInstall) info(` M. pnpm install + format projects/api`); info(''); return `fullstack add-api dry-run (${frameworkMode} / ${apiMode})`; } // Actually integrate the API. const apiDest = `${workspaceDir}/projects/api`; const apiSpinner = spin(`Integrate API${apiLink ? ' (link)' : apiCopy ? ' (copy)' : apiBranch ? ` (branch: ${apiBranch})` : ''}`); const apiResult = yield server.setupServerForFullstack(apiDest, { apiMode, branch: apiBranch, copyPath: apiCopy, experimental, frameworkMode, frameworkUpstreamBranch, linkPath: apiLink, name, projectDir, }); if (!apiResult.success) { apiSpinner.fail(`Failed to set up API: ${apiResult.path}`); return; } apiSpinner.succeed(`API integrated (${apiResult.method})`); // For the experimental nest-base template, run the rename script so // the four files that reference `nest-base` are aligned with the // workspace name. Non-fatal on failure. if (experimental && apiResult.method !== 'link') { const renameSpinner = spin(`Rename nest-base → ${projectDir}`); const renameResult = yield (0, workspace_integration_1.runExperimentalNestBaseRename)({ apiDir: apiDest, patching, projectDir, system, }); if (renameResult.error) { renameSpinner.warn(`Auto-rename failed (${renameResult.error.message}). Run \`bun run rename ${projectDir}\` manually inside projects/api.`); } else { renameSpinner.succeed(`Renamed nest-base → ${projectDir} in 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'); } } // Persist apiMode + frameworkMode for downstream generators. if (apiResult.method !== 'link') { (0, workspace_integration_1.writeApiConfig)({ apiDir: apiDest, apiMode, filesystem, frameworkMode }); } // Hoist pnpm config — `setupServerForFullstack` may have produced a // sub-project package.json with `pnpm.overrides` etc. that pnpm // ignores at non-root level. (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({ filesystem, projectDir: workspaceDir, subProjects: ['projects/api', 'projects/app'], }); // Run install + format unless explicitly skipped (CI/agents may // want to chain multiple add-* calls before installing once). if (!skipInstall && !experimental) { const installSpinner = spin('Install workspace packages'); try { const detectedPm = toolbox.pm.detect(workspaceDir); yield system.run(`cd ${workspaceDir} && ${toolbox.pm.install(detectedPm)}`); installSpinner.succeed('Successfully installed workspace packages'); } catch (err) { installSpinner.fail(`Failed to install packages: ${err.message}`); warning('Run install manually after fixing the issue.'); } // processApiMode rewrites source files, leaving whitespace // artifacts that oxfmt flags in `pnpm run format:check`. Format // here so the sub-project lands in a clean state. if (filesystem.isDirectory(apiDest)) { yield toolbox.apiMode.formatProject(apiDest); } } else if (experimental) { info('Skipping workspace install — run `bun install` inside projects/api manually.'); } info(''); success(`API integrated into ${workspaceDir} in ${toolbox.helper.msToMinutesAndSeconds(timer())}m.`); info(''); info('Next:'); if (experimental) { info(` $ cd ${workspaceDir}/projects/api && bun install`); info(` Configure projects/api/.env (see .env.example)`); info(` Start Postgres + run prisma generate / migrate`); } else { info(` $ cd ${workspaceDir}`); info(` $ ${toolbox.pm.run('start')}`); } info(''); if (!toolbox.parameters.options.fromGluegunMenu) { process.exit(); } return `added api to workspace ${workspaceDir}`; }), }; exports.default = NewCommand;