@lenne.tech/cli
Version:
lenne.Tech CLI: lt
588 lines (587 loc) • 26.5 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());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ApiMode = void 0;
const path_1 = require("path");
/**
* API Mode processing for nest-server-starter template
*
* Reads the api-mode.manifest.json from the project and processes it
* based on the selected API mode (Rest, GraphQL, Both).
*/
class ApiMode {
constructor(toolbox) {
this.toolbox = toolbox;
this.filesystem = toolbox.filesystem;
}
/**
* Process the API mode for a project
*
* @param projectPath - Path to the project root
* @param mode - Selected API mode
*/
processApiMode(projectPath, mode) {
return __awaiter(this, void 0, void 0, function* () {
const manifestPath = (0, path_1.join)(projectPath, 'api-mode.manifest.json');
// Read manifest
const manifestContent = this.filesystem.read(manifestPath);
if (!manifestContent) {
return; // No manifest = nothing to do
}
const manifest = JSON.parse(manifestContent);
if (mode === 'Both') {
// Both mode: just remove markers and cleanup
this.stripAllMarkers(projectPath);
}
else if (mode === 'Rest') {
// REST mode: remove graphql regions, keep rest regions
yield this.removeMode(projectPath, manifest, 'graphql', 'rest');
yield this.modifyConfigEnvForRest(projectPath);
}
else if (mode === 'GraphQL') {
// GraphQL mode: remove rest regions, keep graphql regions
yield this.removeMode(projectPath, manifest, 'rest', 'graphql');
}
// Remove manifest and strip-markers script
this.filesystem.remove(manifestPath);
this.filesystem.remove((0, path_1.join)(projectPath, 'scripts', 'strip-api-mode-markers.mjs'));
// Remove strip-markers script from package.json
this.removeScriptFromPackageJson(projectPath, 'strip-markers');
// NOTE: auto-format of the stripped files happens separately in
// `formatProject()`, which MUST be called by the caller AFTER
// `pnpm install`. At this point the project's formatter (oxfmt) is
// not yet on disk, so running it here would silently no-op.
});
}
/**
* Run the project's `format` (or `format:fix`) npm script, if it exists.
* Call this AFTER the project's dependencies have been installed —
* otherwise the formatter (e.g. oxfmt) isn't available yet and the
* pass silently no-ops.
*
* Used after region stripping to normalize whitespace artifacts the
* formatter would otherwise flag in `format:check` (e.g. collapsing
* `providers: [\n X,\n]` to `providers: [X]` once graphql items were
* removed). Failures are non-fatal so a misbehaving formatter never
* blocks init.
*/
formatProject(projectPath) {
return __awaiter(this, void 0, void 0, function* () {
var _a, _b, _c;
const pkgPath = (0, path_1.join)(projectPath, 'package.json');
const pkgRaw = this.filesystem.read(pkgPath);
if (!pkgRaw)
return;
let pkg;
try {
pkg = JSON.parse(pkgRaw);
}
catch (_d) {
return;
}
const scripts = (_a = pkg.scripts) !== null && _a !== void 0 ? _a : {};
const formatScript = scripts.format ? 'format' : scripts['format:fix'] ? 'format:fix' : null;
if (!formatScript)
return;
const { pm, system } = this.toolbox;
const runner = (_c = (_b = pm === null || pm === void 0 ? void 0 : pm.run) === null || _b === void 0 ? void 0 : _b.call(pm, formatScript, pm.detect(projectPath))) !== null && _c !== void 0 ? _c : `pnpm run ${formatScript}`;
try {
yield system.run(`cd "${projectPath}" && ${runner}`);
}
catch (_e) {
// Non-fatal: the user can run format manually if this misbehaves.
}
});
}
/**
* Remove a specific mode from the project
*/
removeMode(projectPath, manifest, removeMarker, keepMarker) {
return __awaiter(this, void 0, void 0, function* () {
var _a, _b, _c;
const modeConfig = manifest.modes[removeMarker];
if (!modeConfig) {
return;
}
// 1. Delete files matching filePatterns
if (modeConfig.filePatterns) {
for (const pattern of modeConfig.filePatterns) {
const matches = this.filesystem.find(projectPath, {
matching: pattern,
});
for (const file of matches) {
this.filesystem.remove(file);
}
}
}
// 2. Remove packages from package.json
const pkgPath = (0, path_1.join)(projectPath, 'package.json');
const pkg = JSON.parse(this.filesystem.read(pkgPath));
if (modeConfig.packages) {
for (const p of modeConfig.packages) {
(_a = pkg.dependencies) === null || _a === void 0 ? true : delete _a[p];
}
}
if (modeConfig.devPackages) {
for (const p of modeConfig.devPackages) {
(_b = pkg.devDependencies) === null || _b === void 0 ? true : delete _b[p];
}
}
// 3. Remove scripts
if (modeConfig.scripts) {
for (const s of modeConfig.scripts) {
(_c = pkg.scripts) === null || _c === void 0 ? true : delete _c[s];
}
}
// 4. Edit scripts (e.g. remove parts from a script value)
if (modeConfig.scriptEdits && pkg.scripts) {
for (const [scriptName, edit] of Object.entries(modeConfig.scriptEdits)) {
if (pkg.scripts[scriptName] && edit.remove) {
const removeStr = edit.remove;
// Try literal match first, then try all package manager variants
if (pkg.scripts[scriptName].includes(removeStr)) {
pkg.scripts[scriptName] = pkg.scripts[scriptName].replace(removeStr, '');
}
else {
for (const pm of ['npm', 'pnpm', 'yarn']) {
const variant = removeStr.replace(/\b(npm|pnpm|yarn)\s+run\b/g, `${pm} run`);
if (pkg.scripts[scriptName].includes(variant)) {
pkg.scripts[scriptName] = pkg.scripts[scriptName].replace(variant, '');
break;
}
}
}
}
}
}
this.filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
// 5. Strip regions from source files
this.stripRegions(projectPath, removeMarker, keepMarker);
// 6. Clean orphan imports
this.cleanOrphanImports(projectPath);
});
}
/**
* Strip region markers from all .ts files
*
* For removeMarker: delete marker lines AND content between them
* For keepMarker: delete only the marker lines, keep content
*/
stripRegions(projectPath, removeMarker, keepMarker) {
const dirs = [(0, path_1.join)(projectPath, 'src'), (0, path_1.join)(projectPath, 'tests')];
for (const dir of dirs) {
if (!this.filesystem.exists(dir)) {
continue;
}
const files = this.filesystem.find(dir, { matching: '**/*.ts' });
for (const file of files) {
const content = this.filesystem.read(file);
if (!content) {
continue;
}
// Special-case config.env.ts in REST mode: simply deleting the
// `graphQl: { … }` property is not enough — `CoreModule.forRoot`
// treats `graphQl === undefined` as enabled and tries to build
// the GraphQL schema anyway (which then fails on core models
// like CoreHealthCheckResult that reference the JSON scalar).
// Replace each stripped `// #region graphql … // #endregion
// graphql` block with an explicit `graphQl: false,` so GraphQL
// is cleanly disabled.
let processed;
if (removeMarker === 'graphql' && file.endsWith('/config.env.ts')) {
processed = this.replaceGraphqlRegionsWithDisabled(content, keepMarker);
}
else {
processed = this.processFileRegions(content, removeMarker, keepMarker);
}
if (processed !== content) {
this.filesystem.write(file, processed);
}
}
}
}
/**
* Like `processFileRegions` with removeMarker='graphql', but a
* stripped region that contains a `graphQl:` property assignment is
* replaced with `graphQl: false,` (at the region's indent) instead of
* being deleted outright. Other graphql-regions (e.g. wrapping
* `execAfterInit: 'pnpm run docs:bootstrap'`) are deleted as usual.
*
* Rationale: `CoreModule.forRoot` treats `options.graphQl === undefined`
* as enabled, so dropping the property silently keeps GraphQL active
* and later crashes when the schema references scalars that were
* purged in REST mode.
*
* Preserves keepMarker behaviour (strip marker lines only, keep content).
*/
replaceGraphqlRegionsWithDisabled(content, keepMarker) {
const lines = content.split('\n');
const result = [];
let inRegion = false;
let regionIndent = '';
let regionBuffer = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '// #region graphql') {
inRegion = true;
regionIndent = line.slice(0, line.indexOf('//'));
regionBuffer = [];
continue;
}
if (trimmed === '// #endregion graphql') {
inRegion = false;
// Only emit a replacement if the stripped block actually
// contained a `graphQl:` assignment.
if (regionBuffer.some((l) => /\bgraphQl\s*:/.test(l))) {
result.push(`${regionIndent}graphQl: false,`);
}
regionBuffer = [];
continue;
}
if (inRegion) {
regionBuffer.push(line);
continue;
}
// Keep-marker lines (e.g. `// #region rest`) are dropped; content between them stays.
if (trimmed === `// #region ${keepMarker}` || trimmed === `// #endregion ${keepMarker}`) {
continue;
}
result.push(line);
}
return result.join('\n');
}
/**
* Process region markers in file content
*/
processFileRegions(content, removeMarker, keepMarker) {
const lines = content.split('\n');
const result = [];
let removing = false;
for (const line of lines) {
const trimmed = line.trim();
// Check for region start
if (trimmed === `// #region ${removeMarker}`) {
removing = true;
continue; // Skip the marker line
}
// Check for region end
if (trimmed === `// #endregion ${removeMarker}`) {
removing = false;
continue; // Skip the marker line
}
// Check for keep markers (just remove the marker lines, keep content)
if (trimmed === `// #region ${keepMarker}` || trimmed === `// #endregion ${keepMarker}`) {
continue; // Skip marker line, content between them is kept naturally
}
// Skip content inside remove region
if (removing) {
continue;
}
result.push(line);
}
// Clean up multiple consecutive blank lines (max 1)
return this.collapseBlankLines(result.join('\n'));
}
/**
* Strip ALL markers (for Both mode) - keep all content
*/
stripAllMarkers(projectPath) {
const dirs = [(0, path_1.join)(projectPath, 'src'), (0, path_1.join)(projectPath, 'tests')];
for (const dir of dirs) {
if (!this.filesystem.exists(dir)) {
continue;
}
const files = this.filesystem.find(dir, { matching: '**/*.ts' });
for (const file of files) {
const content = this.filesystem.read(file);
if (!content) {
continue;
}
const lines = content.split('\n');
const filtered = lines.filter((line) => {
const trimmed = line.trim();
return !trimmed.match(/^\/\/ #(region|endregion)\s/);
});
const processed = filtered.join('\n');
if (processed !== content) {
this.filesystem.write(file, processed);
}
}
}
}
/**
* Modify config.env.ts for REST mode using ts-morph AST manipulation
*
* - Replace `graphQl: { ... }` blocks with `graphQl: false`
* - Remove `execAfterInit` properties
*/
modifyConfigEnvForRest(projectPath) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const configPath = (0, path_1.join)(projectPath, 'src', 'config.env.ts');
if (!this.filesystem.exists(configPath)) {
return;
}
try {
const { Project, SyntaxKind } = require('ts-morph');
const project = new Project({ skipAddingFilesFromTsConfig: true });
const sourceFile = project.addSourceFileAtPath(configPath);
// Find the 'config' variable declaration directly
// Structure: export const config: { [env: string]: ... } = { ci: { ... }, develop: { ... }, ... };
let configObj;
const variableStatements = sourceFile.getVariableStatements();
for (const vs of variableStatements) {
for (const decl of vs.getDeclarations()) {
if (decl.getName() === 'config') {
const init = decl.getInitializer();
if ((init === null || init === void 0 ? void 0 : init.getKind()) === SyntaxKind.ObjectLiteralExpression) {
configObj = init;
}
}
// Handle merge() call: const config = merge({ default: ... }, { local: ... }, ...)
if (!configObj) {
const init = decl.getInitializer();
if ((init === null || init === void 0 ? void 0 : init.getKind()) === SyntaxKind.CallExpression) {
const args = (_a = init.getArguments) === null || _a === void 0 ? void 0 : _a.call(init);
if (args) {
for (const arg of args) {
if (arg.getKind() === SyntaxKind.ObjectLiteralExpression) {
this.processConfigObject(arg, SyntaxKind);
}
}
}
}
}
}
}
if (configObj) {
this.processConfigObject(configObj, SyntaxKind);
}
sourceFile.saveSync();
}
catch (_b) {
// If ts-morph is not available or fails, fall back to regex
}
// Safety net: always run the regex fallback too. ts-morph only
// traverses direct ObjectLiteralExpression properties, so configs
// that wrap env-blocks in helper functions (e.g. `local:
// localConfig(...)`) are skipped silently. The regex is idempotent
// — if ts-morph already replaced `graphQl: {...}` with
// `graphQl: false` it's a no-op, but if ts-morph missed a wrapped
// occurrence the regex catches it. Without this, REST-mode
// projects built from a starter that lacks explicit
// `// #region graphql` markers would end up with `graphQl:
// undefined`, which `CoreModule.forRoot` treats as ENABLED, and
// the GraphQL schema build crashes on core models that still
// reference the JSON scalar.
this.modifyConfigEnvForRestFallback(projectPath);
});
}
/**
* Process a config object literal: replace graphQl and remove execAfterInit
*/
processConfigObject(obj, SyntaxKind) {
// Process all nested object literals (environment configs)
const properties = obj.getProperties();
for (const prop of properties) {
if (prop.getKind() !== SyntaxKind.PropertyAssignment) {
continue;
}
const name = prop.getName();
// Replace graphQl: { ... } with graphQl: false
if (name === 'graphQl') {
const init = prop.getInitializer();
if (init && init.getKind() === SyntaxKind.ObjectLiteralExpression) {
prop.setInitializer('false');
}
continue;
}
// Remove execAfterInit
if (name === 'execAfterInit') {
prop.remove();
continue;
}
// Recurse into nested objects (environment configs like default, local, etc.)
const init = prop.getInitializer();
if ((init === null || init === void 0 ? void 0 : init.getKind()) === SyntaxKind.ObjectLiteralExpression) {
this.processConfigObject(init, SyntaxKind);
}
}
}
/**
* Fallback: Regex-based config.env.ts modification
*/
modifyConfigEnvForRestFallback(projectPath) {
const configPath = (0, path_1.join)(projectPath, 'src', 'config.env.ts');
let content = this.filesystem.read(configPath);
if (!content) {
return;
}
// Replace graphQl: { ... } blocks with graphQl: false
// Match graphQl: { ... }, handling nested braces
content = content.replace(/graphQl:\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\},?/g, 'graphQl: false,');
// Remove execAfterInit lines
content = content.replace(/\s*execAfterInit:.*,?\n/g, '\n');
this.filesystem.write(configPath, content);
}
/**
* Clean orphan imports after region stripping
*
* After removing region content, some imports may reference identifiers
* that no longer exist in the file. This removes those imports.
*/
cleanOrphanImports(projectPath) {
const dirs = [(0, path_1.join)(projectPath, 'src')];
for (const dir of dirs) {
if (!this.filesystem.exists(dir)) {
continue;
}
const files = this.filesystem.find(dir, { matching: '**/*.ts' });
for (const file of files) {
const content = this.filesystem.read(file);
if (!content) {
continue;
}
const cleaned = this.removeUnusedImports(content);
if (cleaned !== content) {
this.filesystem.write(file, cleaned);
}
}
}
}
/**
* Remove unused imports from TypeScript file content
*/
removeUnusedImports(content) {
const lines = content.split('\n');
const importLines = [];
// Parse import statements
let i = 0;
while (i < lines.length) {
const line = lines[i];
const singleImportMatch = line.match(/^import\s+\{([^}]+)\}\s+from\s+['"]/);
const multiImportStart = line.match(/^import\s+\{$/);
if (singleImportMatch) {
const imports = singleImportMatch[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => ({ original: s, resolved: this.resolveImportAlias(s) }));
importLines.push({ end: i, imports, start: i });
}
else if (multiImportStart) {
// Multi-line import
const start = i;
const imports = [];
i++;
while (i < lines.length && !lines[i].match(/^\}\s+from\s+['"]/)) {
const raw = lines[i].replace(/,?\s*$/, '').trim();
if (raw) {
imports.push({ original: raw, resolved: this.resolveImportAlias(raw) });
}
i++;
}
importLines.push({ end: i, imports, start });
}
i++;
}
// Build the "code content" view against which import usage is checked.
//
// Previous implementation: `lines.slice(maxImportEnd + 1)` — only the
// lines AFTER the last import. That breaks for files where imports and
// top-level code are interleaved (e.g. a helper `const` declared between
// two import groups). Those inter-import usages were never seen, so the
// still-used identifiers got pruned.
//
// Fix: build a mask where all import lines are blanked out but every
// other line is preserved, so inter-import usages still count.
const importLineSet = new Set();
for (const imp of importLines) {
for (let j = imp.start; j <= imp.end; j++) {
importLineSet.add(j);
}
}
const codeContent = lines.map((line, idx) => (importLineSet.has(idx) ? '' : line)).join('\n');
// Check each import
const linesToRemove = new Set();
for (const imp of importLines) {
const usedImports = imp.imports.filter(({ resolved }) => {
// Check if resolved identifier is used in code (not in imports)
const regex = new RegExp(`\\b${resolved.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
return regex.test(codeContent);
});
if (usedImports.length === 0) {
// Remove entire import
for (let j = imp.start; j <= imp.end; j++) {
linesToRemove.add(j);
}
}
else if (usedImports.length < imp.imports.length) {
// Rebuild import with only used identifiers (using original text for aliases)
const fromMatch = lines[imp.end].match(/from\s+['"].*['"]/);
const fromClause = fromMatch ? fromMatch[0] : '';
if (fromClause) {
// Remove old lines
for (let j = imp.start; j <= imp.end; j++) {
linesToRemove.add(j);
}
const originals = usedImports.map((ui) => ui.original);
// Add rebuilt import at start position
if (originals.length <= 3) {
lines[imp.start] = `import { ${originals.join(', ')} } ${fromClause};`;
linesToRemove.delete(imp.start);
}
else {
// Multi-line for many imports
lines[imp.start] = `import {\n ${originals.join(',\n ')},\n} ${fromClause};`;
linesToRemove.delete(imp.start);
}
}
}
}
// Remove marked lines
const result = lines.filter((_, idx) => !linesToRemove.has(idx));
return this.collapseBlankLines(result.join('\n'));
}
/**
* Resolve import alias: "Schema as MongooseSchema" -> "MongooseSchema"
* For non-aliased imports, returns the identifier as-is.
*/
resolveImportAlias(identifier) {
const asMatch = identifier.match(/^\S+\s+as\s+(\S+)$/);
return asMatch ? asMatch[1] : identifier;
}
/**
* Collapse multiple consecutive blank lines into at most one
*/
collapseBlankLines(content) {
return content.replace(/\n{3,}/g, '\n\n');
}
/**
* Remove a script from package.json
*/
removeScriptFromPackageJson(projectPath, scriptName) {
var _a;
const pkgPath = (0, path_1.join)(projectPath, 'package.json');
const pkg = JSON.parse(this.filesystem.read(pkgPath));
if ((_a = pkg.scripts) === null || _a === void 0 ? void 0 : _a[scriptName]) {
delete pkg.scripts[scriptName];
this.filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
}
}
}
exports.ApiMode = ApiMode;
/**
* Extend toolbox
*/
exports.default = (toolbox) => {
toolbox.apiMode = new ApiMode(toolbox);
};