UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

588 lines (587 loc) 26.5 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 }); 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); };