@lenne.tech/cli
Version:
lenne.Tech CLI: lt
407 lines (406 loc) • 18.8 kB
JavaScript
;
/**
* 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;
}