UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

407 lines (406 loc) 18.8 kB
"use strict"; /** * Helpers for integrating an API (`projects/api/`) or an App * (`projects/app/`) into a fullstack workspace. Used by `lt fullstack * init` (full-workspace flow) and by `lt fullstack add-api` / * `lt fullstack add-app` (incremental flow on an already-existing * workspace that only ships one half of the stack). * * The functions here own the small amount of "monorepo glue" that sits * between the framework-agnostic setup primitives in * `extensions/server.ts` and `extensions/frontend-helper.ts`: * * - writing `projects/api/lt.config.json` with the resolved api/ * framework mode so that follow-up generators (lt server module * etc.) pick it up without re-probing, * - patching the frontend `.env` with the project-specific storage * prefix, * - running the experimental `bun run rename` step for the * `--next` nest-base template, * - running the post-install format pass on the touched sub-project, * * They deliberately do NOT manage workspace-level concerns * (lt-monorepo clone, root CLAUDE.md patching, top-level pnpm install, * git initialisation). Those stay in `commands/fullstack/init.ts` * because they only happen once per workspace creation. add-api / * add-app run on an already-installed workspace and only need to wire * in the new sub-project plus run a workspace-wide install. */ 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 }); exports.detectSubProjectContext = detectSubProjectContext; exports.detectWorkspaceLayout = detectWorkspaceLayout; exports.findWorkspaceRoot = findWorkspaceRoot; exports.isNonInteractive = isNonInteractive; exports.reconfigureUpstreamForDownstream = reconfigureUpstreamForDownstream; exports.runExperimentalNestBaseRename = runExperimentalNestBaseRename; exports.runStandaloneWorkspaceGate = runStandaloneWorkspaceGate; exports.shouldProceedAsStandalone = shouldProceedAsStandalone; exports.writeApiConfig = writeApiConfig; /** * Detect whether the current working directory IS a sub-project of an * lt-monorepo workspace (i.e. cwd is `projects/api/` or `projects/app/`, * or any nested directory thereof). Returns the workspace root + the * sub-project kind so the caller can give a precise hint like * "you're inside projects/api — go up to the workspace root". * * Returns `null` when cwd is not inside such a sub-project. */ function detectSubProjectContext(startDir, filesystem) { const root = findWorkspaceRoot(startDir, filesystem); if (!root) return null; // If the start dir IS the root, we're not inside a sub-project. if (root === startDir) return null; const apiDir = filesystem.path(root, 'projects', 'api'); const appDir = filesystem.path(root, 'projects', 'app'); // Resolve absolute prefixes for a clean startsWith compare. We use // the gluegun-resolved paths because all callers go through it. const startAbs = filesystem.path(startDir); if (startAbs === apiDir || startAbs.startsWith(`${apiDir}/`)) { return { kind: 'api', subProjectDir: apiDir, workspaceRoot: root }; } if (startAbs === appDir || startAbs.startsWith(`${appDir}/`)) { return { kind: 'app', subProjectDir: appDir, workspaceRoot: root }; } return null; } /** * Detect what's already present in a workspace directory. Used by the * fullstack commands to decide whether to perform a full init, only * add the missing sub-project, or refuse because both halves already * exist. */ function detectWorkspaceLayout(workspaceDir, filesystem) { const projectsDir = `${workspaceDir}/projects`; const apiDir = `${projectsDir}/api`; const appDir = `${projectsDir}/app`; // `hasWorkspaceMarker` covers pnpm-workspace.yaml, npm/yarn // `workspaces` in package.json, and the `projects/` directory // convention. Mirrors what `findWorkspaceRoot` walks for. const hasWorkspace = hasWorkspaceMarker(workspaceDir, filesystem); // For "hasApi"/"hasApp", a directory existing is not enough — empty // or stub directories from a partially-cloned monorepo would yield // false positives. Require at least a package.json inside. const hasApi = filesystem.exists(`${apiDir}/package.json`) === 'file'; const hasApp = filesystem.exists(`${appDir}/package.json`) === 'file'; return { hasApi, hasApp, hasWorkspace, workspaceDir }; } /** * Walk up from `startDir` until a workspace marker is found or the * filesystem root is reached. Limited to 6 levels to avoid pathological * scans on deeply-nested CWDs (e.g. inside a temp dir hierarchy). * * Returns the directory that contains the marker, or `null` if none * was found within the search budget. Used by the standalone commands * to detect the case "user is inside `projects/api/` and ran * `lt frontend nuxt` there". */ function findWorkspaceRoot(startDir, filesystem, maxDepth = 6) { // Resolve to an absolute path so the parent traversal is reliable. // gluegun's filesystem.path is a join helper; we want the OS path. let cur = startDir; // First check the start dir itself. if (hasWorkspaceMarker(cur, filesystem)) return cur; for (let i = 0; i < maxDepth; i++) { const parent = filesystem.path(cur, '..'); if (parent === cur) return null; if (hasWorkspaceMarker(parent, filesystem)) return parent; cur = parent; } return null; } /** * Treat the caller as "non-interactive" (KI/CI) when either * - `--noConfirm` was passed explicitly, OR * - stdin is not a TTY (typical for `claude < script.txt`, piped CI * runs, or any agent that captures the CLI's stdout). * * The TTY check catches AI agents that call `lt …` without * `--noConfirm` (Claude Code does this) and would otherwise hit the * `confirm()` prompt forever. Caller can opt out via `force`. * * Exposed so commands can derive their `noConfirm` value once and * pass the same boolean to `shouldProceedAsStandalone`. */ function isNonInteractive(noConfirmFlag) { if (noConfirmFlag) return true; // process.stdin may be undefined in some test runners — guard. return Boolean(process.stdin && process.stdin.isTTY === false); } /** * Reconfigure the cloned nest-base template's `.claude/upstream.json` * into its downstream shape. * * WHY: nest-base ships `.claude/upstream.json` with `isTemplate: true` * and `upstream: null` — the template-self default. The `/upstream-pr` * slash command and the `contributing-upstream` skill key off * `isTemplate` and refuse to open upstream PRs when it is still `true` * ("this repo IS the template"). The template's own notes document a * manual post-fork step (flip `isTemplate` to false, fill `upstream`) * that humans and agents both forget. The `bun run rename` step only * rewrites four files and never touches this one, so without this * helper every scaffolded project keeps `isTemplate: true` and can * never contribute core fixes back to nest-base. * * Behaviour: * - Missing file (or unparseable JSON) → `{ updated: false }`, no * throw. Older templates may not ship the file at all; that is not * an error, just nothing to do. * - Otherwise sets `isTemplate = false` and * `upstream = { repo, branch }`, preserving `$schema` and * `syncedPaths` exactly as the template defined them. * * Idempotent: running twice yields identical output. */ function reconfigureUpstreamForDownstream(options) { const { apiDir, filesystem, upstreamBranch, upstreamRepo } = options; const upstreamPath = filesystem.path(apiDir, '.claude', 'upstream.json'); // Non-fatal when absent — older templates may not ship the file. if (filesystem.exists(upstreamPath) !== 'file') { return { updated: false }; } const current = filesystem.read(upstreamPath, 'json'); if (!current) { // Unparseable / empty JSON — leave it untouched rather than risk // clobbering hand-edited content with a guessed shape. return { updated: false }; } const next = Object.assign(Object.assign({}, current), { // The whole point of the fix: this is a fork, not the template. isTemplate: false, // Replace the template-self notes (no longer applicable) with a // short downstream-appropriate marker. Kept as a fixed array so the // operation is idempotent. notes: [ 'Downstream project forked from the nest-base template.', 'Core fixes flow back upstream via the /upstream-pr command.', ], upstream: { branch: upstreamBranch !== null && upstreamBranch !== void 0 ? upstreamBranch : 'main', repo: upstreamRepo !== null && upstreamRepo !== void 0 ? upstreamRepo : 'lenneTech/nest-base', } }); filesystem.write(upstreamPath, next, { jsonIndent: 2 }); return { updated: true }; } /** * Run the experimental `bun run rename <projectDir>` step. Only relevant * for the `--next` nest-base template (it ships hard-coded `nest-base` * references in package.json, README.md, portless.yml, and * docker-compose.yml). Failures are non-fatal — the workspace is still * usable, and the user can re-run the rename script manually. * * Returns true if the step was attempted (regardless of success). The * caller decides whether to surface a warning. */ function runExperimentalNestBaseRename(options) { return __awaiter(this, void 0, void 0, function* () { const { apiDir, patching, projectDir, system } = options; // setupServerForFullstack already patched package.json to set // `name = projectDir`. The rename planner reads that name as the // "old" slug, which would short-circuit the rest of the rewrites // because they still say `nest-base`. Restore the canonical // `name = "nest-base"` first so the planner has a coherent starting // state across all four files. yield patching.update(`${apiDir}/package.json`, (config) => { config.name = 'nest-base'; return config; }); try { yield system.run(`cd ${apiDir} && bun run rename ${projectDir}`); return { attempted: true }; } catch (err) { return { attempted: true, error: err }; } }); } /** * Print + prompt + decision for a standalone scaffolding command's * workspace gate. Centralises the ~25 lines of identical logic that * `lt server create`, `lt frontend nuxt`, and `lt frontend angular` * each had inline. * * Side effects (intentionally bundled — easier to reason about as one * unit, and the three commands all want the same shape): * - prints the workspace-detected note + sub-project hint * - asks the user via `confirm()` when interactive * - prints the refusal/abort reason via `print.error` when refused * - calls `process.exit(1)` on refusal (unless `fromGluegunMenu`) * * Returns `true` when the caller may proceed, `false` when the caller * has already been told to abort and should `return` from its `run`. * * The shape stays narrow on purpose — adding more knobs (e.g. * "abort message format") would just push the duplication elsewhere. */ function runStandaloneWorkspaceGate(options) { return __awaiter(this, void 0, void 0, function* () { var _a; const { cwd, filesystem, force, fromGluegunMenu, noConfirmFlag, pieceName, print: { confirm, error, info }, projectKind, suggestion, } = options; // Sub-project hint: if the user is inside `projects/api/` or // `projects/app/` of a workspace, point them at the root explicitly // — the standalone path would otherwise clone a sibling tree // *inside* the existing sub-project, which is almost never wanted. const subProject = detectSubProjectContext(cwd, filesystem); if (subProject) { info(''); info(`You appear to be inside projects/${subProject.kind}/ of a workspace at ${subProject.workspaceRoot}.`); info(` → Run \`${suggestion}\` from the workspace root instead.`); error(`Refusing to create a standalone ${projectKind} from inside a sub-project. cd to the workspace root first.`); if (!fromGluegunMenu) process.exit(1); return false; } const layout = detectWorkspaceLayout(cwd, filesystem); if (!layout.hasWorkspace) { return true; // Plain dir → standalone is fine, no gate. } const alreadyHas = pieceName === 'api' ? layout.hasApi : layout.hasApp; info(''); info('Note: current directory looks like a fullstack workspace (pnpm-workspace.yaml, package.json#workspaces, or projects/).'); if (!alreadyHas) { info(` → To integrate the ${pieceName} into this workspace, use \`${suggestion}\`.`); } else { info(` → projects/${pieceName}/ already exists in this workspace; this command would create a separate ${projectKind}.`); } const nonInteractive = isNonInteractive(noConfirmFlag); const userConfirmed = nonInteractive ? undefined : yield confirm(`Proceed with standalone ${projectKind} creation anyway?`, false); const decision = shouldProceedAsStandalone({ force, nonInteractive, projectKind, suggestion, userConfirmed, }); if (!decision.proceed) { error((_a = decision.reason) !== null && _a !== void 0 ? _a : 'Aborted.'); if (!fromGluegunMenu) process.exit(1); return false; } if (nonInteractive && force) { info(' --force set — continuing despite workspace context.'); } info(''); return true; }); } /** * Decide whether a standalone scaffolding command (`lt server create`, * `lt frontend nuxt`, `lt frontend angular`) should run inside a * directory that already looks like a fullstack workspace. * * Three modes: * * - **interactive** (the user can answer a prompt) — caller asks via * `confirm()` and passes the result as `userConfirmed`. * - **non-interactive without force** — refuse. This is the path AI * agents and CI scripts take by default (either `--noConfirm` was * set, or stdin isn't a TTY). Forcing them onto the workspace- * aware command (`add-api` / `add-app`) prevents stray side-by- * side clones that pnpm-workspace.yaml does not pick up. * - **non-interactive with --force** — proceed, but the caller * should log a hint so the override is visible in CI logs. * * The function does NOT print or prompt. It only decides; the caller * owns the interaction surface. */ function shouldProceedAsStandalone(options) { const { force, nonInteractive, projectKind, suggestion, userConfirmed } = options; // Interactive caller already gave an explicit yes/no. if (userConfirmed !== undefined) { return userConfirmed ? { proceed: true } : { proceed: false, reason: `Aborted standalone ${projectKind} creation. Use \`${suggestion}\` instead.` }; } // Non-interactive path: refuse unless --force is set. This is the // AI-agent / CI default — fail loud rather than silently produce a // stray clone that does not integrate with the workspace. if (nonInteractive && !force) { return { proceed: false, reason: `Refusing to create a standalone ${projectKind} inside an existing fullstack workspace ` + `(non-interactive caller detected). ` + `Use \`${suggestion}\` for the workspace-aware flow, or pass --force to override (rare).`, }; } // Non-interactive + force: caller knows what they want. return { proceed: true }; } /** * Write `projects/api/lt.config.json` with the apiMode and frameworkMode * baked in so follow-up generators (lt server module, addProp, * permissions) can pick the correct controller type and detect vendor * mode without re-probing the file tree. * * Idempotent: overwrites whatever was at `lt.config.json` so a re-run * after an apiMode change reflects the new value. */ function writeApiConfig(options) { const { apiDir, apiMode, filesystem, frameworkMode } = options; filesystem.write(filesystem.path(apiDir, 'lt.config.json'), { commands: { server: { module: { controller: apiMode, }, }, }, meta: { apiMode, frameworkMode, version: '1.0.0', }, }, { jsonIndent: 2 }); } /** * Probe a single directory for workspace markers. Pure helper used by * `detectWorkspaceLayout` and `findWorkspaceRoot`. * * Recognised markers (any one is sufficient): * - `pnpm-workspace.yaml` — pnpm workspace * - `package.json` with `workspaces` field — npm/yarn/bun workspaces * - `projects/` directory — lt-monorepo convention * * Returns false for `node_modules`-style directories that may contain * a stray `package.json` with `workspaces`. */ function hasWorkspaceMarker(dir, filesystem) { var _a; if (filesystem.exists(`${dir}/pnpm-workspace.yaml`) === 'file') return true; if (filesystem.exists(`${dir}/projects`) === 'dir') return true; const pkgPath = `${dir}/package.json`; if (filesystem.exists(pkgPath) !== 'file') return false; const pkg = filesystem.read(pkgPath, 'json'); if (!pkg) return false; // npm/yarn workspaces: `workspaces` is either an array of globs // (`["packages/*"]`) or an object with a `packages` array // (yarn classic). Both count. const ws = pkg.workspaces; if (Array.isArray(ws) && ws.length > 0) return true; if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) { return ((_a = ws.packages) !== null && _a !== void 0 ? _a : []).length > 0; } return false; }