@lenne.tech/cli
Version:
lenne.Tech CLI: lt
719 lines (718 loc) • 39 kB
JavaScript
;
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;