diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index cecf07e..262a722 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -205,4 +205,4 @@ ├── ⚙️ turbo.json └── ⚙️ vitest.config.ts -``` \ No newline at end of file +``` diff --git a/apps/cli/src/commands/add.ts b/apps/cli/src/commands/add.ts index 81ebcf1..a4e28fd 100644 --- a/apps/cli/src/commands/add.ts +++ b/apps/cli/src/commands/add.ts @@ -4,7 +4,16 @@ import { CLIContext } from '../context.js'; import { CLIOutput } from '../utils/output.js'; import { StructifyCLIError } from '../utils/error.js'; import { getElapsedMs } from '../utils/middleware.js'; -import { createModulePlan, executePatchPlan } from '@structify/core'; +import { + createModulePlan, + executePatchPlan, + detectStack, + getIntegrationsForCategory, + buildIntegrationPatchPlan, + readProjectState, + CATEGORIES_LIST, + appendHistoryEntry +} from '@structify/core'; export interface AddOptions { dryRun?: boolean; @@ -14,6 +23,32 @@ export interface AddOptions { database?: 'postgres' | 'mongodb'; } +async function promptChoice( + question: string, + choices: { id: string; name: string }[], + rl: readline.Interface +): Promise { + return new Promise((resolve) => { + console.log(`\n${question}`); + choices.forEach((choice, index) => { + console.log(` ${index + 1}) ${choice.name}`); + }); + + const ask = () => { + rl.question(`\nSelect an option [1-${choices.length}]: `, (answer) => { + const num = parseInt(answer.trim(), 10); + if (num >= 1 && num <= choices.length) { + resolve(choices[num - 1].id); + } else { + console.log(`Invalid choice. Please enter a number between 1 and ${choices.length}.`); + ask(); + } + }); + }; + ask(); + }); +} + export async function handleAdd( moduleName: string | undefined, options: AddOptions, @@ -25,12 +60,123 @@ export async function handleAdd( if (!moduleName || moduleName.trim() === '') { throw new StructifyCLIError( 'USAGE_ERROR', - 'Module name is required. Example: "structify add docker"', + 'Module name or category is required. Example: "structify add auth" or "structify add docker"', ); } const projectPath = path.resolve(context.cwd, options.path ?? '.'); const dryRun = options.dryRun === true; + + const lowerName = moduleName.toLowerCase().trim(); + if (CATEGORIES_LIST.includes(lowerName)) { + // 1. Detect project stack + const detection = detectStack(projectPath); + if (!detection.success) { + throw new StructifyCLIError('STACK_DETECTION_FAILED', detection.error || 'Failed to detect project stack.'); + } + const stack = detection.detectedStack; + output.info(`Detected stack - Frontend: ${stack.frontend}, Backend: ${stack.backend}`); + + // 2. Fetch available integrations + const integrations = getIntegrationsForCategory(lowerName, stack); + if (integrations.length === 0) { + throw new StructifyCLIError( + 'MODULE_INCOMPATIBLE', + `No compatible marketplace integrations found for category "${moduleName}" on stack: ${stack.frontend}/${stack.backend}.`, + ); + } + + // 3. Choose integration + let selectedIntegration = integrations[0]; + if (integrations.length > 1) { + if (options.yes) { + selectedIntegration = integrations[0]; + } else { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const selectedId = await promptChoice( + `Available integrations for category "${moduleName}":`, + integrations.map((i) => ({ id: i.id, name: i.name })), + rl, + ); + rl.close(); + try { + process.stdin.pause(); + } catch { + // ignore + } + const found = integrations.find((i) => i.id === selectedId); + if (found) selectedIntegration = found; + } + } + + output.success(`Selected Integration: ${selectedIntegration.name}`); + output.info(`- Compatibility: Frontend: ${selectedIntegration.compatibility.frontends.join(', ')}, Backend: ${selectedIntegration.compatibility.backends.join(', ')}`); + output.info(`- Dependencies: ${selectedIntegration.dependencies.join(', ')}`); + if (selectedIntegration.envVars.length > 0) { + output.info(`- Environment Variables: ${selectedIntegration.envVars.map((v) => v.key).join(', ')}`); + } + output.info(`- Documentation: ${selectedIntegration.docsLink}`); + + // 4. Build integration patch plan + const state = readProjectState(projectPath); + const plan = buildIntegrationPatchPlan(projectPath, selectedIntegration, state); + + if (plan.conflicts.length > 0) { + output.warn(`Patch conflicts detected:`); + for (const conflict of plan.conflicts) { + output.error(`- ${conflict.message}`); + } + if (!options.force) { + appendHistoryEntry(projectPath, { + operation: 'add', + status: 'failed', + duration: getElapsedMs(context.startTime), + filesChanged: [], + summary: `Added ${selectedIntegration.name}`, + }, context.packageVersion); + throw new StructifyCLIError('PATCH_CONFLICT', 'Integration addition aborted due to conflicts. Run with --force to overwrite.'); + } + } + + // Print preview plan details + output.subheading('\nPreview Plan:'); + output.info(`- Files to generate: ${plan.operations.filter((op) => op.type === 'create-file').map((op) => op.targetPath).join(', ') || 'none'}`); + output.info(`- Files to modify: ${plan.operations.filter((op) => op.type !== 'create-file').map((op) => op.targetPath).join(', ') || 'none'}`); + output.info(`- Dependencies to add: ${plan.dependencyChanges.map((c) => c.name).join(', ') || 'none'}`); + + if (dryRun) { + output.info('\n[Dry Run] Integration plan generated successfully.'); + output.showFooter('add'); + return; + } + + // 5. Confirm and Apply + if (!options.yes && !(await confirmApply(`Apply integration ${selectedIntegration.name}?`))) { + output.warn('Integration addition cancelled. No files were changed.'); + return; + } + + const result = executePatchPlan(projectPath, plan); + appendHistoryEntry(projectPath, { + operation: 'add', + status: result.success ? 'success' : 'failed', + duration: getElapsedMs(context.startTime), + filesChanged: result.success ? result.appliedOperations.map((op) => op.targetPath) : [], + summary: `Added ${selectedIntegration.name}`, + }, context.packageVersion); + if (!result.success) { + output.error(`Integration installation failed: ${result.errors.map((e) => e.message).join(', ')}`); + if (result.rollbackResults.length > 0) { + output.warn(`Rollback executed for ${result.rollbackResults.length} operation(s).`); + } + throw new StructifyCLIError('CONFLICT_ERROR', 'Patch application failed.'); + } + + output.success(`Integration "${selectedIntegration.name}" successfully added to project.`); + output.showFooter('add'); + return; + } + const modulePlan = createModulePlan(projectPath, moduleName, { force: options.force, dryRun, @@ -67,6 +213,13 @@ export async function handleAdd( } renderModulePlan(output, modulePlan, dryRun); if (modulePlan.code === 'MODULE_INCOMPATIBLE' || modulePlan.code === 'PATCH_CONFLICT') { + appendHistoryEntry(projectPath, { + operation: 'add', + status: 'failed', + duration: elapsed, + filesChanged: [], + summary: `Added ${moduleName ? moduleName.charAt(0).toUpperCase() + moduleName.slice(1) : 'Module'}`, + }, context.packageVersion); throw new StructifyCLIError(modulePlan.code, modulePlan.message); } output.showFooter('add'); @@ -82,6 +235,13 @@ export async function handleAdd( throw new StructifyCLIError('INTERNAL_ERROR', 'Module plan was not produced.'); } const result = executePatchPlan(projectPath, modulePlan.plan); + appendHistoryEntry(projectPath, { + operation: 'add', + status: result.success ? 'success' : 'failed', + duration: getElapsedMs(context.startTime), + filesChanged: result.success ? result.appliedOperations.map((op) => op.targetPath) : [], + summary: `Added ${modulePlan.moduleName.charAt(0).toUpperCase() + modulePlan.moduleName.slice(1)}`, + }, context.packageVersion); if (context.json) { output.json({ success: result.success, diff --git a/apps/cli/src/commands/deps.spec.ts b/apps/cli/src/commands/deps.spec.ts new file mode 100644 index 0000000..b9f5515 --- /dev/null +++ b/apps/cli/src/commands/deps.spec.ts @@ -0,0 +1,71 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createCLIContext } from '../context.js'; +import { handleDeps } from './deps.js'; + +const tempDirs: string[] = []; + +describe('CLI deps command', () => { + afterEach(() => { + vi.restoreAllMocks(); + for (const tempDir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('prints dependency intelligence pointwise summary and recommendations', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'structify-cli-deps-')); + tempDirs.push(projectDir); + writeProject(projectDir, { + 'package.json': '{"name":"cli-deps","dependencies":{"react":"^18.2.0","request":"^2.88.2"}}', + }); + + const context = createCLIContext(['node', 'structify', 'deps'], { cwd: projectDir }); + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + logs.push(String(message)); + }); + + await handleDeps({}, context); + + const output = logs.join('\n'); + expect(output).toContain('Structify Dependency Intelligence'); + expect(output).toContain('Installed'); + expect(output).toContain('Outdated'); + expect(output).toContain('Deprecated'); + expect(output).toContain('Recommendations'); + }); + + it('outputs json format when context.json is active', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'structify-cli-deps-json-')); + tempDirs.push(projectDir); + writeProject(projectDir, { + 'package.json': '{"name":"cli-deps-json","dependencies":{"lodash":"^4.17.21"}}', + }); + + const context = createCLIContext(['node', 'structify', 'deps', '--json'], { cwd: projectDir }); + context.json = true; + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + logs.push(String(message)); + }); + + await handleDeps({}, context); + + const output = logs.join('\n'); + const parsed = JSON.parse(output); + expect(parsed.success).toBe(true); + expect(parsed.command).toBe('deps'); + expect(parsed.data.installedCount).toBe(1); + }); +}); + +function writeProject(root: string, files: Record): void { + for (const [relativePath, content] of Object.entries(files)) { + const targetPath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content, 'utf8'); + } +} diff --git a/apps/cli/src/commands/deps.ts b/apps/cli/src/commands/deps.ts new file mode 100644 index 0000000..a93e9cc --- /dev/null +++ b/apps/cli/src/commands/deps.ts @@ -0,0 +1,72 @@ +import path from 'path'; +import { analyzeProject, analyzeDependencies, appendHistoryEntry } from '@structify/core'; +import { CLIContext } from '../context.js'; +import { CLIOutput } from '../utils/output.js'; +import { getElapsedMs } from '../utils/middleware.js'; + +export interface DepsOptions { + path?: string; +} + +export async function handleDeps(options: DepsOptions, context: CLIContext): Promise { + const output = new CLIOutput(context); + output.heading('Structify Dependency Intelligence'); + const projectPath = path.resolve(context.cwd, options.path ?? '.'); + + const analysis = analyzeProject(projectPath); + const report = analyzeDependencies(projectPath, analysis); + const elapsed = getElapsedMs(context.startTime); + + appendHistoryEntry(projectPath, { + operation: 'deps', + status: 'success', + duration: elapsed, + filesChanged: [], + summary: 'Dependency Audit', + }, context.packageVersion); + + if (context.json) { + output.json({ + success: true, + command: 'deps', + version: '1.0.0', + timestamp: new Date().toISOString(), + durationMs: elapsed, + data: report, + }); + return; + } + + output.info(`Project: ${projectPath}`); + output.info(''); + output.heading('Summary'); + output.info(`${report.installedCount} Installed`); + output.info(`${report.outdatedCount} Outdated`); + output.info(`${report.deprecatedCount} Deprecated`); + output.info(`${report.unusedCount} Unused`); + output.info(`${report.breakingCount} Breaking`); + output.info(`${report.migrationCount} Migration Required`); + output.info(''); + + if (report.recommendations.length > 0) { + output.heading('Recommendations'); + for (const rec of report.recommendations) { + const typeLabel = rec.type.toUpperCase(); + const severityLabel = rec.severity; + + if (rec.severity === 'BREAKING' || rec.severity === 'MIGRATION REQUIRED') { + output.error(`[${severityLabel}] [${typeLabel}] ${rec.packageName}`); + } else { + output.warn(`[${severityLabel}] [${typeLabel}] ${rec.packageName}`); + } + + output.info(` Message: ${rec.message}`); + output.info(` Rationale: ${rec.rationale}`); + output.info(''); + } + } else { + output.success('All dependencies are healthy, up to date, and utilized!'); + } + + output.showFooter('deps'); +} diff --git a/apps/cli/src/commands/graph.ts b/apps/cli/src/commands/graph.ts index 73b9289..ee474e0 100644 --- a/apps/cli/src/commands/graph.ts +++ b/apps/cli/src/commands/graph.ts @@ -1,6 +1,12 @@ import fs from 'fs'; import path from 'path'; -import { analyzeProject, createArchitectureView, renderArchitectureTree, renderArchitectureTreeMarkdown } from '@structify/core'; +import { + analyzeProject, + createArchitectureView, + renderArchitectureTree, + renderArchitectureTreeMarkdown, + appendHistoryEntry, +} from '@structify/core'; import { CLIContext } from '../context.js'; import { CLIOutput } from '../utils/output.js'; import { getElapsedMs } from '../utils/middleware.js'; @@ -30,6 +36,15 @@ export async function handleGraph(options: GraphOptions, context: CLIContext): P const rendered = renderArchitectureTree(view, renderOptions); const isWritingMarkdown = Boolean(options.md || options.output); + const elapsed = getElapsedMs(context.startTime); + + appendHistoryEntry(projectPath, { + operation: 'graph', + status: 'success', + duration: elapsed, + filesChanged: isWritingMarkdown ? [options.output ?? 'PROJECT_STRUCTURE.md'] : [], + summary: 'Generated Graph', + }, context.packageVersion); if (isWritingMarkdown) { const markdownPath = path.resolve(projectPath, options.output ?? 'PROJECT_STRUCTURE.md'); diff --git a/apps/cli/src/commands/history.spec.ts b/apps/cli/src/commands/history.spec.ts new file mode 100644 index 0000000..d30822d --- /dev/null +++ b/apps/cli/src/commands/history.spec.ts @@ -0,0 +1,80 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createCLIContext } from '../context.js'; +import { handleHistory } from './history.js'; +import { appendHistoryEntry } from '@structify/core'; + +const tempDirs: string[] = []; + +describe('CLI history command', () => { + afterEach(() => { + vi.restoreAllMocks(); + for (const tempDir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('renders a vertical timeline of recorded operations', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'structify-cli-history-')); + tempDirs.push(projectDir); + + appendHistoryEntry(projectDir, { + operation: 'init', + status: 'success', + duration: 100, + filesChanged: ['package.json'], + summary: 'Project Created', + }); + + appendHistoryEntry(projectDir, { + operation: 'add', + status: 'success', + duration: 50, + filesChanged: ['lib/auth.ts'], + summary: 'Added Clerk', + }); + + const context = createCLIContext(['node', 'structify', 'history'], { cwd: projectDir }); + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + logs.push(String(message)); + }); + + await handleHistory({ path: projectDir }, context); + + const output = logs.join('\n'); + expect(output).toContain('Project History Timeline'); + expect(output).toContain('[SUCCESS] Project Created'); + expect(output).toContain('[SUCCESS] Added Clerk'); + expect(output).toContain('↓'); + }); + + it('renders raw JSON format', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'structify-cli-history-json-')); + tempDirs.push(projectDir); + + appendHistoryEntry(projectDir, { + operation: 'init', + status: 'success', + duration: 100, + filesChanged: [], + summary: 'Project Created', + }); + + const context = createCLIContext(['node', 'structify', 'history', '--json'], { cwd: projectDir }); + context.json = true; + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => { + logs.push(String(message)); + }); + + await handleHistory({ path: projectDir, json: true }, context); + + const output = logs.join('\n'); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].summary).toBe('Project Created'); + }); +}); diff --git a/apps/cli/src/commands/history.ts b/apps/cli/src/commands/history.ts new file mode 100644 index 0000000..4247e76 --- /dev/null +++ b/apps/cli/src/commands/history.ts @@ -0,0 +1,73 @@ +import path from 'path'; +import { CLIContext } from '../context.js'; +import { CLIOutput } from '../utils/output.js'; +import { StructifyCLIError } from '../utils/error.js'; +import { readHistory, HistoryEntry } from '@structify/core'; + +export interface HistoryOptions { + json?: boolean; + limit?: string; + since?: string; + path?: string; +} + +export async function handleHistory( + options: HistoryOptions, + context: CLIContext, +): Promise { + const output = new CLIOutput(context); + const projectPath = path.resolve(context.cwd, options.path ?? '.'); + + const history = readHistory(projectPath); + + // Filter by since + let filtered = history; + if (options.since) { + const sinceTime = new Date(options.since).getTime(); + if (isNaN(sinceTime)) { + throw new StructifyCLIError('USAGE_ERROR', `Invalid ISO date format for --since: "${options.since}"`); + } + filtered = filtered.filter((entry) => new Date(entry.timestamp).getTime() >= sinceTime); + } + + // Filter/slice by limit + if (options.limit) { + const limitNum = parseInt(options.limit, 10); + if (isNaN(limitNum) || limitNum <= 0) { + throw new StructifyCLIError('USAGE_ERROR', `Invalid number format for --limit: "${options.limit}"`); + } + filtered = filtered.slice(-limitNum); + } + + // Handle JSON output + if (context.json || options.json) { + output.json(filtered); + return; + } + + output.heading('Structify Project History Timeline'); + + if (filtered.length === 0) { + output.info('No history entries matching criteria recorded yet.'); + return; + } + + filtered.forEach((entry, index) => { + // Print summary + output.info(`[${entry.status.toUpperCase()}] ${entry.summary}`); + + // Print details if verbose/debug is set or just generally + if (context.verbose) { + output.info(` Operation: ${entry.operation}`); + output.info(` Timestamp: ${entry.timestamp}`); + output.info(` Duration: ${entry.duration}ms`); + output.info(` Files Changed: ${entry.filesChanged.join(', ') || 'none'}`); + } + + if (index < filtered.length - 1) { + output.info(' ↓'); + } + }); + + output.showFooter('history'); +} diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index e9ac60a..3b8eb4d 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -5,6 +5,7 @@ import { handleDoctor } from './doctor.js'; import { handleAdd } from './add.js'; import { handleInspect } from './inspect.js'; import { handleGraph } from './graph.js'; +import { handleDeps } from './deps.js'; import { handleRepair } from './repair.js'; import { handleVerifyProject } from './verify-project.js'; import { handleUpgrade } from './upgrade.js'; @@ -19,10 +20,13 @@ import { handleTemplates, handleValidateTemplate, } from './phase8.js'; +import { handleHistory } from './history.js'; import { handleEnterpriseCommand } from './phase912.js'; import { createCLIContext } from '../context.js'; import { wrapAction } from '../utils/middleware.js'; + + export function registerCommands(program: Command): void { program .command('init') @@ -221,6 +225,35 @@ export function registerCommands(program: Command): void { await wrapped(options, commandInstance); }); + const ENTERPRISE_CMD_DESCRIPTIONS: Record = { + registry: 'Manage connected enterprise module registries', + install: 'Install custom modules from registry', + uninstall: 'Uninstall stack modules from project', + update: 'Update modules to latest compatible versions', + search: 'Search registry for custom stack modules', + publish: 'Publish local stack module to registry', + 'validate-workspace': 'Validate workspace configuration integrity', + diagnose: 'Run workspace health check diagnostics', + 'explain-generation': 'Explain generation differences for template', + 'explain-merge': 'Analyze and explain git/file merge strategies', + 'explain-blueprint': 'Explain stack Blueprint configurations', + 'explain-hook': 'Inspect and explain lifecycle hook executions', + 'dependency-graph': 'Generate project package dependency graph', + 'template-graph': 'Generate local template dependency graph', + 'blueprint-graph': 'Generate project Blueprint dependency graph', + 'plugin-graph': 'Generate active plugins execution graph', + 'workspace-report': 'Generate comprehensive workspace status report', + 'export-report': 'Export diagnostic status reports to disk', + profile: 'Profile execution performance of template build', + benchmark: 'Run performance benchmark tests on generator', + 'clean-cache': 'Clean local template and artifact cache', + 'warm-cache': 'Pre-build and warm template build caches', + migration: 'Preview or apply database migrations', + rollback: 'Rollback workspace to previous snapshot', + snapshot: 'Take a snapshot of current workspace files', + restore: 'Restore workspace files from snapshot', + }; + const enterpriseCommands = [ 'registry', 'install', @@ -252,7 +285,7 @@ export function registerCommands(program: Command): void { for (const enterpriseCommand of enterpriseCommands) { program .command(enterpriseCommand) - .description(`Enterprise generation platform command: ${enterpriseCommand}`) + .description(ENTERPRISE_CMD_DESCRIPTIONS[enterpriseCommand] || `Enterprise generation platform command: ${enterpriseCommand}`) .argument('[action]', 'Command action') .argument('[target]', 'Optional command target') .option('-d, --dry-run', 'Preview enterprise operation without writing files') @@ -334,13 +367,16 @@ export function registerCommands(program: Command): void { program .command('add') .description('Add a module incrementally into an existing workspace') - .argument('', 'Name of module (e.g. docker, eslint, prisma)') + .argument('', 'Name of module (e.g. docker, eslint, prisma) or marketplace category (e.g. auth, payments, logging)') .option('-d, --dry-run', 'Preview module patch plan without writing files') .option('-y, --yes', 'Apply without confirmation') .option('--force', 'Allow intentional overwrites when conflicts are safe') .option('--path ', 'Project path to modify') .option('--database ', 'Database override for database modules') - .addHelpText('after', '\nExamples:\n $ structify add docker') + .addHelpText( + 'after', + '\nMarketplace Categories:\n auth, payments, logging, monitoring, cache, queue, email, storage, validation, analytics\n\nExamples:\n $ structify add docker\n $ structify add auth\n $ structify add payments' + ) .action(async (moduleName, options, commandInstance) => { const globalOpts = program.opts(); const context = createCLIContext(process.argv, { ...globalOpts, ...options }); @@ -364,6 +400,20 @@ export function registerCommands(program: Command): void { await wrapped(options, commandInstance); }); + program + .command('deps') + .description('Analyze project package dependencies and imports') + .option('--path ', 'Project path to analyze') + .addHelpText('after', '\nExamples:\n $ structify deps\n $ structify deps --json') + .action(async (options, commandInstance) => { + const globalOpts = program.opts(); + const context = createCLIContext(process.argv, { ...globalOpts, ...options }); + (commandInstance as Command & { context?: unknown }).context = context; + + const wrapped = wrapAction('deps', (opts, ctx) => handleDeps(opts, ctx)); + await wrapped(options, commandInstance); + }); + program .command('repair') .description('Fix configuration mismatches flagged by doctor command') @@ -425,4 +475,20 @@ export function registerCommands(program: Command): void { ); await wrapped(options, commandInstance); }); + + program + .command('history') + .description('View persistent project history timeline') + .option('--json', 'Render machine-readable JSON payloads') + .option('--limit ', 'Limit the number of history logs displayed') + .option('--since ', 'Display history logs after a specific timestamp') + .option('--path ', 'Path to project root') + .action(async (options, commandInstance) => { + const globalOpts = program.opts(); + const context = createCLIContext(process.argv, { ...globalOpts, ...options }); + (commandInstance as Command & { context?: unknown }).context = context; + + const wrapped = wrapAction('history', (opts, ctx) => handleHistory(opts, ctx)); + await wrapped(options, commandInstance); + }); } diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index a0ddbd0..ca7b287 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -21,6 +21,7 @@ import { validateGeneratedProject, PresetManager, PresetManifestMetadata, + appendHistoryEntry, } from '@structify/core'; export interface InitOptions { @@ -341,6 +342,17 @@ export async function handleInit(options: InitOptions, context: CLIContext): Pro force: options.force, }); + appendHistoryEntry(targetDir, { + operation: context.commandName || 'init', + status: result.success ? 'success' : 'failed', + duration: result.durationMs, + filesChanged: result.success ? result.generatedFiles : [], + summary: 'Project Created', + details: { + config: selectedConfig, + } + }, context.packageVersion); + if (options.eventLog && result.success) { const eventLogDir = path.join(targetDir, '.structify'); fs.mkdirSync(eventLogDir, { recursive: true }); diff --git a/apps/cli/src/commands/inspect.ts b/apps/cli/src/commands/inspect.ts index 60f2445..0b50427 100644 --- a/apps/cli/src/commands/inspect.ts +++ b/apps/cli/src/commands/inspect.ts @@ -2,7 +2,12 @@ import path from 'path'; import { CLIContext } from '../context.js'; import { CLIOutput } from '../utils/output.js'; import { getElapsedMs } from '../utils/middleware.js'; -import { createUpgradePlan, runProjectHealthCheck } from '@structify/core'; +import { + createUpgradePlan, + runProjectHealthCheck, + analyzeProject, + analyzeDependencies, +} from '@structify/core'; export interface InspectOptions { path?: string; @@ -23,6 +28,8 @@ export async function handleInspect(options: InspectOptions, context: CLIContext output.heading('Structify Project Inspection'); const projectPath = path.resolve(context.cwd, options.path ?? '.'); + const analysis = analyzeProject(projectPath); + const depReport = analyzeDependencies(projectPath, analysis); const healthReport = runProjectHealthCheck(projectPath); const { state, drift, detectedStack } = healthReport; const upgrade = createUpgradePlan(projectPath); @@ -51,6 +58,7 @@ export async function handleInspect(options: InspectOptions, context: CLIContext driftReport: drift, moduleReport: { installedModules, availableCompatibleModules: availableModules }, upgradeReport: upgrade, + dependencies: depReport, repairSuggestions: healthReport.repairability.repairSuggestions, fixableIssues: healthReport.repairability.fixableIssues, notFixableIssues: healthReport.repairability.notFixableIssues, @@ -99,6 +107,9 @@ export async function handleInspect(options: InspectOptions, context: CLIContext output.info(`Files: ${state.files.length}`); output.info(`Drift: ${drift.hasDrift ? 'yes' : 'no'}`); + output.info( + `Dependencies: ${depReport.installedCount} Installed / ${depReport.outdatedCount} Outdated / ${depReport.deprecatedCount} Deprecated / ${depReport.unusedCount} Unused / ${depReport.breakingCount} Breaking / ${depReport.migrationCount} Migration Required`, + ); output.info(`Installed Modules: ${installedModules.join(', ') || 'none'}`); output.info(`Available Modules: ${availableModules.join(', ') || 'none'}`); output.info(`Upgrade: ${upgrade.code}`); diff --git a/apps/cli/src/commands/repair.ts b/apps/cli/src/commands/repair.ts index 6ea4a1a..2d0a412 100644 --- a/apps/cli/src/commands/repair.ts +++ b/apps/cli/src/commands/repair.ts @@ -8,6 +8,7 @@ import { executePatchPlan, runProjectHealthCheck, HealthDiagnostic, + appendHistoryEntry, } from '@structify/core'; export interface RepairOptions { @@ -34,6 +35,13 @@ export async function handleRepair(options: RepairOptions, context: CLIContext): if (context.json) { if (options.apply && options.yes && repair.plan.operations.length > 0) { const result = executePatchPlan(projectPath, repair.plan); + appendHistoryEntry(projectPath, { + operation: 'repair', + status: result.success ? 'success' : 'failed', + duration: getElapsedMs(context.startTime), + filesChanged: result.success ? result.appliedOperations.map((op) => op.targetPath) : [], + summary: 'Health Repair', + }, context.packageVersion); output.json({ success: result.success, command: 'repair', @@ -129,6 +137,13 @@ export async function handleRepair(options: RepairOptions, context: CLIContext): throw new StructifyCLIError('USAGE_ERROR', 'Use --yes with --apply to apply safe repairs.'); } const result = executePatchPlan(projectPath, repair.plan); + appendHistoryEntry(projectPath, { + operation: 'repair', + status: result.success ? 'success' : 'failed', + duration: getElapsedMs(context.startTime), + filesChanged: result.success ? result.appliedOperations.map((op) => op.targetPath) : [], + summary: 'Health Repair', + }, context.packageVersion); if (!result.success) { throw new StructifyCLIError( 'CONFLICT_ERROR', diff --git a/apps/cli/src/commands/upgrade.ts b/apps/cli/src/commands/upgrade.ts index c54bc11..bbe4305 100644 --- a/apps/cli/src/commands/upgrade.ts +++ b/apps/cli/src/commands/upgrade.ts @@ -3,7 +3,7 @@ import { CLIContext } from '../context.js'; import { CLIOutput } from '../utils/output.js'; import { StructifyCLIError } from '../utils/error.js'; import { getElapsedMs } from '../utils/middleware.js'; -import { createUpgradePlan, executePatchPlan } from '@structify/core'; +import { createUpgradePlan, executePatchPlan, appendHistoryEntry } from '@structify/core'; export interface UpgradeOptions { dryRun?: boolean; @@ -21,6 +21,13 @@ export async function handleUpgrade(options: UpgradeOptions, context: CLIContext if (context.json) { if (!options.dryRun && options.yes && !upgrade.reviewRequired) { const result = executePatchPlan(projectPath, upgrade.plan); + appendHistoryEntry(projectPath, { + operation: 'upgrade', + status: result.success ? 'success' : 'failed', + duration: getElapsedMs(context.startTime), + filesChanged: result.success ? result.appliedOperations.map((op) => op.targetPath) : [], + summary: 'Dependency Upgrade', + }, context.packageVersion); output.json({ success: result.success, command: 'upgrade', @@ -68,6 +75,13 @@ export async function handleUpgrade(options: UpgradeOptions, context: CLIContext throw new StructifyCLIError('UPGRADE_REQUIRES_REVIEW', upgrade.message); } const result = executePatchPlan(projectPath, upgrade.plan); + appendHistoryEntry(projectPath, { + operation: 'upgrade', + status: result.success ? 'success' : 'failed', + duration: getElapsedMs(context.startTime), + filesChanged: result.success ? result.appliedOperations.map((op) => op.targetPath) : [], + summary: 'Dependency Upgrade', + }, context.packageVersion); if (!result.success) { throw new StructifyCLIError( 'INTERNAL_ERROR', diff --git a/apps/cli/src/context.ts b/apps/cli/src/context.ts index 8dffd65..3bbf123 100644 --- a/apps/cli/src/context.ts +++ b/apps/cli/src/context.ts @@ -38,7 +38,19 @@ export function createCLIContext( cwd?: string; }, ): CLIContext { - const targetCwd = options.cwd ? path.resolve(options.cwd) : process.cwd(); + const argList = args || []; + const manualNoColor = argList.includes('--no-color'); + const manualVerbose = argList.includes('--verbose'); + const manualDebug = argList.includes('--debug'); + const manualJson = argList.includes('--json'); + + let manualCwd: string | undefined; + const cwdIdx = argList.indexOf('--cwd'); + if (cwdIdx !== -1 && cwdIdx + 1 < argList.length) { + manualCwd = argList[cwdIdx + 1]; + } + + const targetCwd = options.cwd ? path.resolve(options.cwd) : (manualCwd ? path.resolve(manualCwd) : process.cwd()); let detectedPackageManager: 'npm' | 'none' = 'none'; try { @@ -59,10 +71,10 @@ export function createCLIContext( nodeVersion: process.version, platform: os.platform(), arch: os.arch(), - debug: !!options.debug, - verbose: !!options.verbose, - json: !!options.json, - noColor: !!options.noColor || process.env.NO_COLOR === 'true', + debug: !!options.debug || manualDebug, + verbose: !!options.verbose || manualVerbose, + json: !!options.json || manualJson, + noColor: !!options.noColor || manualNoColor || process.env.NO_COLOR === 'true', isCI: process.env.CI === 'true', isTTY: process.stdout.isTTY ?? false, processId: process.pid, diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index ff89474..c13275f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -48,16 +48,19 @@ async function main() { // Register commands registerCommands(program); + // Configure beautiful help output + program.configureHelp({ + formatHelp: () => { + const context = createCLIContext(process.argv, {}); + return formatHelpScreen(program, context); + }, + }); + // If no arguments, show custom welcome message instead of default help if (process.argv.length <= 2) { const context = createCLIContext(process.argv, {}); const output = new CLIOutput(context); - output.showStartupBanner(); - output.info('\nFor a list of all commands, run:'); - output.subheading(' $ structify --help'); - output.info('\nNext steps:'); - output.info(' - To test your environment: $ structify doctor'); - output.info(' - To initialize a project: $ structify init'); + output.showWelcomeScreen(); process.exit(0); } @@ -85,4 +88,189 @@ async function main() { } } +const SETUP_WORKSPACE_CMDS = new Set(['init', 'generate', 'add', 'repair', 'preset', 'upgrade', 'validate']); +const INTELLIGENCE_AUDIT_CMDS = new Set(['doctor', 'inspect', 'deps', 'graph', 'verify-project']); +const TEMPLATING_ARTIFACT_CMDS = new Set(['templates', 'generators', 'validate-template', 'explain-template', 'preview', 'plan', 'render', 'blueprint']); + +const REGISTRY_PACKAGE_CMDS = new Set(['registry', 'install', 'uninstall', 'update', 'search', 'publish']); +const GRAPH_VISUALIZATION_CMDS = new Set(['dependency-graph', 'template-graph', 'blueprint-graph', 'plugin-graph']); +const PERFORMANCE_REPORT_CMDS = new Set(['workspace-report', 'export-report', 'profile', 'benchmark', 'clean-cache', 'warm-cache']); +const STATE_MIGRATION_CMDS = new Set(['migration', 'rollback', 'snapshot', 'restore', 'history']); +const DIAGNOSTIC_EXPLAIN_CMDS = new Set(['validate-workspace', 'diagnose', 'explain-generation', 'explain-merge', 'explain-blueprint', 'explain-hook']); + +function formatHelpScreen(program: Command, context: ReturnType): string { + const cyan = (text: string) => context.noColor ? text : `\x1b[36m${text}\x1b[0m`; + const purple = (text: string) => context.noColor ? text : `\x1b[35m${text}\x1b[0m`; + const bold = (text: string) => context.noColor ? text : `\x1b[1m${text}\x1b[0m`; + const gray = (text: string) => context.noColor ? text : `\x1b[90m${text}\x1b[0m`; + + const lines: string[] = []; + + // 1. ASCII Art Header + lines.push(cyan('╔══════════════════════════════════════════════════════════════════════════╗')); + lines.push(cyan(' ███████╗████████╗██████╗ ██╗ ██╗ ██████╗████████╗██╗███████╗██╗ ██╗')); + lines.push(cyan(' ██╔════╝╚══██╔══╝██╔══██╗██║ ██║██╔════╝╚══██╔══╝██║██╔════╝╚██╗ ██╔╝')); + lines.push(cyan(' ███████╗ ██║ ██████╔╝██║ ██║██║ ██║ ██║█████╗ ╚████╔╝ ')); + lines.push(cyan(' ╚════██║ ██║ ██╔══██╗██║ ██║██║ ██║ ██║██╔══╝ ╚██╔╝ ')); + lines.push(cyan(' ███████║ ██║ ██║ ██║╚██████╔╝╚██████╗ ██║ ██║██║ ██║ ')); + lines.push(cyan(' ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ')); + lines.push(cyan('╚══════════════════════════════════════════════════════════════════════════╝')); + lines.push(''); + + // 2. Usage + lines.push(`${cyan('◆')} ${bold('Usage:')} ${purple('structify')} ${gray('[options]')} ${cyan('[command]')}`); + lines.push(''); + + // 3. Global Options + lines.push(`${gray('┌─')} ${bold('GLOBAL OPTIONS')} ${gray('───────────────────────────────────────┐')}`); + lines.push(`${gray('│')} ${cyan('-V, --version')} ${gray('Output the version number')} ${gray('│')}`); + lines.push(`${gray('│')} ${cyan('--verbose')} ${gray('Print additional diagnostic logs')} ${gray('│')}`); + lines.push(`${gray('│')} ${cyan('--debug')} ${gray('Enable debug output and stack traces')} ${gray('│')}`); + lines.push(`${gray('│')} ${cyan('--json')} ${gray('Render machine-readable JSON payloads')} ${gray('│')}`); + lines.push(`${gray('│')} ${cyan('--no-color')} ${gray('Omit colored console outputs')} ${gray('│')}`); + lines.push(`${gray('│')} ${cyan('--cwd ')} ${gray('Change context working directory')} ${gray('│')}`); + lines.push(gray('└────────────────────────────────────────────────────────┘')); + lines.push(''); + + // Group all registered commands dynamically + const setupGroup: string[] = []; + const intelligenceGroup: string[] = []; + const templatingGroup: string[] = []; + const registryGroup: string[] = []; + const graphGroup: string[] = []; + const performanceGroup: string[] = []; + const migrationGroup: string[] = []; + const diagnosticGroup: string[] = []; + const otherGroup: string[] = []; + + for (const cmd of program.commands) { + const name = cmd.name(); + const desc = cmd.description() || ''; + const usage = cmd.usage() ? ` ${cmd.usage()}` : ''; + const cmdStr = name + usage; + const paddedCmd = cmdStr.padEnd(20); + const formattedLine = `${cyan(paddedCmd)} ${gray(desc)}`; + + if (SETUP_WORKSPACE_CMDS.has(name)) { + setupGroup.push(formattedLine); + } else if (INTELLIGENCE_AUDIT_CMDS.has(name)) { + intelligenceGroup.push(formattedLine); + } else if (TEMPLATING_ARTIFACT_CMDS.has(name)) { + templatingGroup.push(formattedLine); + } else if (REGISTRY_PACKAGE_CMDS.has(name)) { + registryGroup.push(formattedLine); + } else if (GRAPH_VISUALIZATION_CMDS.has(name)) { + graphGroup.push(formattedLine); + } else if (PERFORMANCE_REPORT_CMDS.has(name)) { + performanceGroup.push(formattedLine); + } else if (STATE_MIGRATION_CMDS.has(name)) { + migrationGroup.push(formattedLine); + } else if (DIAGNOSTIC_EXPLAIN_CMDS.has(name)) { + diagnosticGroup.push(formattedLine); + } else { + otherGroup.push(formattedLine); + } + } + + // 4. Command Groups + lines.push(bold('COMMANDS BY CATEGORY')); + lines.push(''); + + // Group 1: Workspace & Setup + if (setupGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('SETUP & WORKSPACE')} ${gray('────────────────────────────────────╮')}`); + for (const line of setupGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 2: Intelligence & Auditing + if (intelligenceGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('INTELLIGENCE & AUDITING')} ${gray('──────────────────────────────╮')}`); + for (const line of intelligenceGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 3: Core Templating & Generation + if (templatingGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('TEMPLATING & ARTIFACTS')} ${gray('───────────────────────────────╮')}`); + for (const line of templatingGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 4: Registry & Packages + if (registryGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('REGISTRY & PACKAGES')} ${gray('──────────────────────────────────╮')}`); + for (const line of registryGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 5: Visualization & Graphs + if (graphGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('VISUALIZATION & GRAPHS')} ${gray('───────────────────────────────╮')}`); + for (const line of graphGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 6: Workspace State & Migrations + if (migrationGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('STATE & MIGRATIONS')} ${gray('───────────────────────────────────╮')}`); + for (const line of migrationGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 7: Performance & Reports + if (performanceGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('PERFORMANCE & REPORTS')} ${gray('────────────────────────────────╮')}`); + for (const line of performanceGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 8: Deep Explanations & Diagnostics + if (diagnosticGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('EXPLANATIONS & DIAGNOSTICS')} ${gray('───────────────────────────╮')}`); + for (const line of diagnosticGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + // Group 9: Other Commands (Dynamic fallback) + if (otherGroup.length > 0) { + lines.push(`${gray('╭─')} ${bold('OTHER COMMANDS')} ${gray('───────────────────────────────────────╮')}`); + for (const line of otherGroup) { + lines.push(`${gray('│')} ${line}`); + } + lines.push(gray('╰────────────────────────────────────────────────────────╯')); + lines.push(''); + } + + lines.push(`${gray('Run')} ${purple('structify help ')} ${gray('for custom command parameters')}`); + lines.push(''); + + return lines.join('\n'); +} + main(); + diff --git a/apps/cli/src/utils/output.ts b/apps/cli/src/utils/output.ts index b5b45c5..953f1b8 100644 --- a/apps/cli/src/utils/output.ts +++ b/apps/cli/src/utils/output.ts @@ -2,7 +2,7 @@ import { CLIContext } from '../context.js'; import { getElapsedMs } from './middleware.js'; export class CLIOutput { - constructor(private context: CLIContext) {} + constructor(private context: CLIContext) { } private colorize(colorCode: string, text: string): string { if (this.context.noColor) { @@ -41,6 +41,35 @@ export class CLIOutput { this.divider(); } + showWelcomeScreen(): void { + if (this.context.json) return; + + const cyan = (text: string) => this.colorize('\x1b[36m', text); + const purple = (text: string) => this.colorize('\x1b[35m', text); + const bold = (text: string) => this.colorize('\x1b[1m', text); + const gray = (text: string) => this.colorize('\x1b[90m', text); + + console.log(cyan('╔══════════════════════════════════════════════════════════════════════════╗')); + console.log(cyan(' ███████╗████████╗██████╗ ██╗ ██╗ ██████╗████████╗██╗███████╗██╗ ██╗')); + console.log(cyan(' ██╔════╝╚══██╔══╝██╔══██╗██║ ██║██╔════╝╚══██╔══╝██║██╔════╝╚██╗ ██╔╝')); + console.log(cyan(' ███████╗ ██║ ██████╔╝██║ ██║██║ ██║ ██║█████╗ ╚████╔╝ ')); + console.log(cyan(' ╚════██║ ██║ ██╔══██╗██║ ██║██║ ██║ ██║██╔══╝ ╚██╔╝ ')); + console.log(cyan(' ███████║ ██║ ██║ ██║╚██████╔╝╚██████╗ ██║ ██║██║ ██║ ')); + console.log(cyan(' ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ')); + console.log(''); + console.log(' One CLI. Endless Possibilities') + console.log(cyan('╚══════════════════════════════════════════════════════════════════════════╝')); + console.log(''); + console.log(`${cyan('◆')} ${bold('Version')} ${gray('─')} ${purple('v' + this.context.packageVersion)}`); + console.log(''); + + console.log(gray('╭─ QUICK START ──────────────────────────────────────────╮')); + console.log(`${gray('│')} Run ${purple('structify --help')} to see all commands ${gray('│')}`); + console.log(`${gray('│')} Run ${purple('structify --version')} to check version ${gray('│')}`); + console.log(gray('╰────────────────────────────────────────────────────────╯')); + console.log(''); + } + heading(text: string): void { if (this.context.json) return; console.log('\n' + this.colorize('\x1b[1m\x1b[36m', `=== ${text} ===`)); diff --git a/graph.html b/graph.html index 11b0698..5f26184 100644 --- a/graph.html +++ b/graph.html @@ -1,95 +1,48432 @@ - + - - - - structify-monorepo Architecture Explorer - - - -
- -
-
-
-

Project Tree

-

Sections, folders, and architectural files prepared from the view model.

+
+
Select a file node to inspect its details.
- Architectural -
-
-
- -
- - - \ No newline at end of file + + + + + diff --git a/packages/core/src/architecture/explorer.ts b/packages/core/src/architecture/explorer.ts index a6dc88c..a0c168e 100644 --- a/packages/core/src/architecture/explorer.ts +++ b/packages/core/src/architecture/explorer.ts @@ -51,7 +51,8 @@ export function createArchitectureExplorerModelFromAnalysis( path: file.path, type: file.type, importance: file.importance, - size: typeof file.metadata['size'] === 'number' ? (file.metadata['size'] as number) : null, + size: + typeof file.metadata['size'] === 'number' ? (file.metadata['size'] as number) : null, lastModified: typeof file.metadata['lastModified'] === 'string' ? (file.metadata['lastModified'] as string) diff --git a/packages/core/src/architecture/index.spec.ts b/packages/core/src/architecture/index.spec.ts index 0613b48..dffd90f 100644 --- a/packages/core/src/architecture/index.spec.ts +++ b/packages/core/src/architecture/index.spec.ts @@ -59,7 +59,9 @@ describe('Architecture View Model', () => { const architecturalView = filterArchitecturalView(analysis); const completeView = filterCompleteView(analysis); - expect(completeView.sections.flatMap((section) => section.childNodes).length).toBeGreaterThanOrEqual( + expect( + completeView.sections.flatMap((section) => section.childNodes).length, + ).toBeGreaterThanOrEqual( architecturalView.sections.flatMap((section) => section.childNodes).length, ); }); diff --git a/packages/core/src/architecture/tree.ts b/packages/core/src/architecture/tree.ts index 50c386a..523fb1d 100644 --- a/packages/core/src/architecture/tree.ts +++ b/packages/core/src/architecture/tree.ts @@ -1,11 +1,7 @@ import path from 'path'; import fs from 'fs'; import { isGenerated, isIgnored } from '../intelligence/ignore.js'; -import { - ArchitecturalImportance, - ProjectAnalysis, - ProjectNode, -} from '../intelligence/types.js'; +import { ArchitecturalImportance, ProjectAnalysis, ProjectNode } from '../intelligence/types.js'; import { ArchitectureViewModel } from './types.js'; export type ArchitectureTreeRenderMode = 'overview' | 'full' | 'important'; @@ -490,8 +486,18 @@ function collectSummary( } } - const SECTION_KEYS = new Set(['frontend', 'backend', 'shared', 'database', 'assets', 'public', 'configuration']); - const sectionsDisplayed = Array.from(displayedCategories).filter((cat) => SECTION_KEYS.has(cat)).length; + const SECTION_KEYS = new Set([ + 'frontend', + 'backend', + 'shared', + 'database', + 'assets', + 'public', + 'configuration', + ]); + const sectionsDisplayed = Array.from(displayedCategories).filter((cat) => + SECTION_KEYS.has(cat), + ).length; return { sectionsDisplayed, @@ -533,7 +539,11 @@ function countVisibleNodes( if (!importantOnly) { count += 1; } else { - const fileImportance = getFileImportance(path.basename(node.path), node.path, node.importance); + const fileImportance = getFileImportance( + path.basename(node.path), + node.path, + node.importance, + ); if (fileImportance === 'critical' || fileImportance === 'high') { count += 1; } @@ -554,7 +564,9 @@ function countPhysicalIgnoredFiles(dir: string, baseDir: string): number { for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(baseDir, fullPath).replaceAll('\\', '/'); - const isCustomIgnored = GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(entry.name)) || RENDERER_IGNORED_DIRS.has(entry.name); + const isCustomIgnored = + GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(entry.name)) || + RENDERER_IGNORED_DIRS.has(entry.name); if (isIgnored(relativePath) || isGenerated(relativePath) || isCustomIgnored) { if (entry.isDirectory()) { count += countAllPhysicalFiles(fullPath); diff --git a/packages/core/src/architecture/types.ts b/packages/core/src/architecture/types.ts index 30676ad..5546b0e 100644 --- a/packages/core/src/architecture/types.ts +++ b/packages/core/src/architecture/types.ts @@ -1,4 +1,10 @@ -import { ArchitecturalCategory, ArchitecturalFileType, ArchitecturalImportance, ProjectAnalysis, ProjectNodeKind } from '../intelligence/types.js'; +import { + ArchitecturalCategory, + ArchitecturalFileType, + ArchitecturalImportance, + ProjectAnalysis, + ProjectNodeKind, +} from '../intelligence/types.js'; export type ArchitectureRenderMode = 'architectural' | 'complete'; diff --git a/packages/core/src/architecture/view.ts b/packages/core/src/architecture/view.ts index e3d0ab7..d977fa8 100644 --- a/packages/core/src/architecture/view.ts +++ b/packages/core/src/architecture/view.ts @@ -4,7 +4,12 @@ import { ProjectAnalysis, ProjectNode, } from '../intelligence/types.js'; -import { ArchitectureRenderMode, ArchitectureSection, ArchitectureViewModel, ArchitectureViewNode } from './types.js'; +import { + ArchitectureRenderMode, + ArchitectureSection, + ArchitectureViewModel, + ArchitectureViewNode, +} from './types.js'; const SECTION_ORDER = [ ['frontend', 'Frontend'], @@ -34,7 +39,10 @@ export function createArchitectureView( backendFiles: analysis.files.filter((file) => file.category === 'backend').length, assets: analysis.files.filter((file) => file.category === 'assets').length, configuration: analysis.files.filter( - (file) => file.category === 'configuration' || file.category === 'environment' || file.category === 'metadata', + (file) => + file.category === 'configuration' || + file.category === 'environment' || + file.category === 'metadata', ).length, databaseFiles: analysis.files.filter((file) => file.category === 'database').length, }, @@ -69,7 +77,9 @@ export function groupSections( const sorted = sortNodes(deduped); const flatFiles = flattenNodes(sorted).filter((node) => node.kind === 'file'); const folders = sorted.filter((node) => node.kind !== 'file'); - const architecturalFiles = flatFiles.filter((node) => node.kind === 'file' && node.type !== 'asset'); + const architecturalFiles = flatFiles.filter( + (node) => node.kind === 'file' && node.type !== 'asset', + ); return { id: `section:${key}`, diff --git a/packages/core/src/intelligence/dependencies.spec.ts b/packages/core/src/intelligence/dependencies.spec.ts new file mode 100644 index 0000000..ad078f4 --- /dev/null +++ b/packages/core/src/intelligence/dependencies.spec.ts @@ -0,0 +1,94 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { analyzeProject } from './engine.js'; +import { analyzeDependencies } from './dependencies.js'; + +const tempDirs: string[] = []; + +describe('Dependency Intelligence Subsystem', () => { + afterEach(() => { + for (const tempDir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('detects outdated, deprecated, unused, duplicate, missing, and peer dependency issues', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'structify-deps-')); + tempDirs.push(projectDir); + + // Write a mock workspace structure + writeProject(projectDir, { + 'package.json': JSON.stringify({ + name: 'test-project', + dependencies: { + react: '^18.2.0', // Outdated (latest is 19.0.0) -> SAFE/BREAKING + request: '^2.88.2', // Deprecated -> MIGRATION REQUIRED + lodash: '^4.17.21', // Unused (never imported in source) -> MINOR REVIEW + 'react-dom': '^18.2.0', // Requires react@^19.0.0 in catalog -> peer-issue + }, + }), + 'src/index.ts': ` + import react from 'react'; + import express from 'express'; // Missing dependency! + console.log(react); + `, + 'package-lock.json': JSON.stringify({ + name: 'test-project', + lockfileVersion: 2, + packages: { + 'node_modules/react': { version: '18.2.0' }, + 'node_modules/request': { version: '2.88.2' }, + 'node_modules/lodash': { version: '4.17.21' }, + 'node_modules/react-dom': { version: '18.2.0' }, + }, + }), + }); + + const analysis = analyzeProject(projectDir); + const report = analyzeDependencies(projectDir, analysis); + + expect(report.installedCount).toBe(4); + expect(report.outdatedCount).toBe(2); + expect(report.deprecatedCount).toBe(1); + expect(report.unusedCount).toBe(3); + + // Verify recommendations + const deprecatedRec = report.recommendations.find((r) => r.type === 'deprecated'); + expect(deprecatedRec).toBeDefined(); + expect(deprecatedRec?.severity).toBe('MIGRATION REQUIRED'); + expect(deprecatedRec?.packageName).toBe('request'); + expect(deprecatedRec?.rationale).toContain('Request has been deprecated since 2020'); + + const outdatedRec = report.recommendations.find((r) => r.type === 'outdated'); + expect(outdatedRec).toBeDefined(); + expect(outdatedRec?.severity).toBe('BREAKING'); // major upgrade 18.2.0 -> 19.0.0 + expect(outdatedRec?.packageName).toBe('react'); + + const unusedRec = report.recommendations.find( + (r) => r.type === 'unused' && r.packageName === 'lodash', + ); + expect(unusedRec).toBeDefined(); + expect(unusedRec?.severity).toBe('MINOR REVIEW'); + expect(unusedRec?.packageName).toBe('lodash'); + + const missingRec = report.recommendations.find((r) => r.type === 'missing'); + expect(missingRec).toBeDefined(); + expect(missingRec?.severity).toBe('BREAKING'); + expect(missingRec?.packageName).toBe('express'); + + const peerRec = report.recommendations.find((r) => r.type === 'peer-issue'); + expect(peerRec).toBeDefined(); + expect(peerRec?.packageName).toBe('react-dom'); + expect(peerRec?.message).toContain('Peer dependency mismatch'); + }); +}); + +function writeProject(root: string, files: Record): void { + for (const [relativePath, content] of Object.entries(files)) { + const targetPath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content, 'utf8'); + } +} diff --git a/packages/core/src/intelligence/dependencies.ts b/packages/core/src/intelligence/dependencies.ts new file mode 100644 index 0000000..eaef18b --- /dev/null +++ b/packages/core/src/intelligence/dependencies.ts @@ -0,0 +1,475 @@ +import fs from 'fs'; +import path from 'path'; +import { ProjectAnalysis } from './types.js'; + +export interface DependencyRecommendation { + packageName: string; + type: 'outdated' | 'deprecated' | 'unused' | 'duplicate' | 'conflict' | 'missing' | 'peer-issue'; + severity: 'SAFE' | 'MINOR REVIEW' | 'BREAKING' | 'MIGRATION REQUIRED'; + currentVersion: string; + targetVersion?: string; + message: string; + rationale: string; +} + +export interface DependencyReport { + installedCount: number; + outdatedCount: number; + deprecatedCount: number; + unusedCount: number; + breakingCount: number; + migrationCount: number; + recommendations: DependencyRecommendation[]; +} + +interface PackageMetadata { + latestVersion: string; + deprecated?: boolean; + deprecationMessage?: string; + peerDependencies?: Record; +} + +// Deterministic offline registry of common packages +const PACKAGE_CATALOG: Record = { + request: { + latestVersion: '2.88.2', + deprecated: true, + deprecationMessage: 'Request has been deprecated since 2020. Use axios or node-fetch instead.', + }, + 'express-graphql': { + latestVersion: '0.12.0', + deprecated: true, + deprecationMessage: 'express-graphql is deprecated. Use graphql-http instead.', + }, + 'node-sass': { + latestVersion: '9.0.0', + deprecated: true, + deprecationMessage: 'node-sass is deprecated. Use sass instead.', + }, + 'babel-eslint': { + latestVersion: '10.1.0', + deprecated: true, + deprecationMessage: 'babel-eslint is deprecated. Use @babel/eslint-parser instead.', + }, + react: { + latestVersion: '19.0.0', + }, + 'react-dom': { + latestVersion: '19.0.0', + peerDependencies: { + react: '^19.0.0', + }, + }, + next: { + latestVersion: '15.0.0', + }, + lodash: { + latestVersion: '4.17.21', + }, + 'eslint-plugin-react': { + latestVersion: '7.34.0', + peerDependencies: { + eslint: '^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0', + }, + }, +}; + +const TOOLING_PACKAGES = new Set([ + 'typescript', + 'eslint', + 'prettier', + 'vitest', + 'tsup', + 'turbo', + 'nx', + 'lerna', + 'jest', + 'cypress', + 'playwright', + 'rimraf', + 'cross-env', + 'nodemon', + 'concurrently', +]); + +const NODE_BUILTINS = new Set([ + 'assert', + 'async_hooks', + 'buffer', + 'child_process', + 'cluster', + 'console', + 'constants', + 'crypto', + 'dgram', + 'dns', + 'domain', + 'events', + 'fs', + 'fs/promises', + 'http', + 'http2', + 'https', + 'inspector', + 'module', + 'net', + 'os', + 'path', + 'perf_hooks', + 'process', + 'punycode', + 'querystring', + 'readline', + 'repl', + 'stream', + 'string_decoder', + 'timers', + 'tls', + 'trace_events', + 'tty', + 'url', + 'util', + 'v8', + 'vm', + 'wasi', + 'worker_threads', + 'zlib', +]); + +export function analyzeDependencies( + projectPath: string, + analysis: ProjectAnalysis, +): DependencyReport { + const recommendations: DependencyRecommendation[] = []; + + // 1. Gather all declared dependencies in the workspace + const rootPackageJsonPath = path.join(projectPath, 'package.json'); + const workspaceJsonPaths = analysis.metadata.workspacePackageJsons.map((p) => + path.join(projectPath, p), + ); + const allJsonPaths = fs.existsSync(rootPackageJsonPath) + ? [rootPackageJsonPath, ...workspaceJsonPaths] + : workspaceJsonPaths; + + const declaredVersions: Record> = {}; + const packageDependencies: Record> = {}; + + for (const jsonPath of allJsonPaths) { + try { + const content = fs.readFileSync(jsonPath, 'utf8'); + const pkg = JSON.parse(content); + const deps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + ...pkg.optionalDependencies, + }; + + for (const [name, version] of Object.entries(deps)) { + if (typeof version === 'string') { + if (!declaredVersions[name]) { + declaredVersions[name] = new Set(); + } + declaredVersions[name].add(version); + + if (!packageDependencies[name]) { + packageDependencies[name] = {}; + } + packageDependencies[name][jsonPath] = version; + } + } + } catch { + // Ignore parse errors + } + } + + // 2. Read lock files for exact resolved versions + const lockfileVersions: Record = {}; + const lockFiles = analysis.metadata.lockFiles.map((p) => path.join(projectPath, p)); + for (const lockPath of lockFiles) { + if (fs.existsSync(lockPath)) { + try { + const lockContent = fs.readFileSync(lockPath, 'utf8'); + const baseName = path.basename(lockPath); + for (const name of Object.keys(declaredVersions)) { + let resolved: string | null = null; + if (baseName === 'package-lock.json') { + resolved = getPackageLockVersion(lockContent, name); + } else if (baseName === 'yarn.lock') { + resolved = getYarnLockVersion(lockContent, name); + } else if (baseName === 'pnpm-lock.yaml') { + resolved = getPnpmLockVersion(lockContent, name); + } + if (resolved) { + lockfileVersions[name] = resolved; + } + } + } catch { + // Ignore read errors + } + } + } + + // 3. Scan project files for imports/requires + const importedPackages = new Set(); + scanImportsRecursively(projectPath, projectPath, importedPackages); + + // 4. Perform rules analysis + const installedCount = Object.keys(declaredVersions).length; + + for (const [name, versions] of Object.entries(declaredVersions)) { + const declaredArr = Array.from(versions); + const firstDeclared = declaredArr[0] ?? '*'; + const resolvedVersion = lockfileVersions[name] ?? cleanVersion(firstDeclared); + + // Rule A: Deprecated packages + const catalogMeta = PACKAGE_CATALOG[name]; + if (catalogMeta?.deprecated) { + recommendations.push({ + packageName: name, + type: 'deprecated', + severity: 'MIGRATION REQUIRED', + currentVersion: resolvedVersion, + message: `Package "${name}" is deprecated.`, + rationale: + catalogMeta.deprecationMessage ?? + `Migration is required as "${name}" is no longer maintained.`, + }); + } + + // Rule B: Outdated packages + if (catalogMeta && !catalogMeta.deprecated) { + if (isOlder(resolvedVersion, catalogMeta.latestVersion)) { + const isBreaking = isMajorUpgrade(resolvedVersion, catalogMeta.latestVersion); + recommendations.push({ + packageName: name, + type: 'outdated', + severity: isBreaking ? 'BREAKING' : 'SAFE', + currentVersion: resolvedVersion, + targetVersion: catalogMeta.latestVersion, + message: `Package "${name}" is outdated (current: ${resolvedVersion}, latest: ${catalogMeta.latestVersion}).`, + rationale: isBreaking + ? `Upgrading "${name}" to major version ${catalogMeta.latestVersion} contains breaking API changes and needs testing.` + : `Upgrading "${name}" to version ${catalogMeta.latestVersion} is safe and fits semver patch/minor compatibility rules.`, + }); + } + } + + // Rule C: Unused packages + if (!importedPackages.has(name) && !TOOLING_PACKAGES.has(name) && !name.startsWith('@types/')) { + recommendations.push({ + packageName: name, + type: 'unused', + severity: 'MINOR REVIEW', + currentVersion: resolvedVersion, + message: `Package "${name}" appears to be unused.`, + rationale: `No imports or require statements for "${name}" were detected in your source code. You can safely remove it to reduce project size.`, + }); + } + + // Rule D: Conflicting/Duplicate declared versions + if (declaredArr.length > 1) { + recommendations.push({ + packageName: name, + type: 'conflict', + severity: 'MINOR REVIEW', + currentVersion: declaredArr.join(', '), + message: `Multiple conflicting versions declared for "${name}": ${declaredArr.join(', ')}.`, + rationale: `Monorepo workspace packages specify different semver ranges for "${name}". Consolidating them ensures consistent runtime behavior.`, + }); + } + + // Rule E: Peer Dependency Issues + if (catalogMeta?.peerDependencies) { + for (const [peerName, peerRange] of Object.entries(catalogMeta.peerDependencies)) { + const peerVersions = declaredVersions[peerName]; + if (!peerVersions) { + recommendations.push({ + packageName: name, + type: 'peer-issue', + severity: 'MINOR REVIEW', + currentVersion: resolvedVersion, + message: `Unsatisfied peer dependency: "${name}" requires "${peerName}@${peerRange}" but it is not declared.`, + rationale: `Install "${peerName}" to satisfy peer requirements of "${name}" and prevent package resolution warnings.`, + }); + } else { + const peerResolved = + lockfileVersions[peerName] ?? cleanVersion(Array.from(peerVersions)[0] ?? '*'); + if (isOlder(peerResolved, cleanVersion(peerRange))) { + recommendations.push({ + packageName: name, + type: 'peer-issue', + severity: 'BREAKING', + currentVersion: resolvedVersion, + message: `Peer dependency mismatch: "${name}" requires "${peerName}@${peerRange}" but version "${peerResolved}" is installed.`, + rationale: `Upgrading "${peerName}" to satisfy the "${peerRange}" range is required to prevent runtime incompatibility.`, + }); + } + } + } + } + } + + // Rule F: Missing dependencies + for (const name of importedPackages) { + if ( + !declaredVersions[name] && + !NODE_BUILTINS.has(name) && + !name.startsWith('.') && + !name.startsWith('..') + ) { + recommendations.push({ + packageName: name, + type: 'missing', + severity: 'BREAKING', + currentVersion: 'none', + message: `Missing dependency: Package "${name}" is imported in source code but not declared in package.json.`, + rationale: `Add "${name}" to dependencies to prevent build/runtime errors when executing the project.`, + }); + } + } + + const outdatedCount = recommendations.filter((r) => r.type === 'outdated').length; + const deprecatedCount = recommendations.filter((r) => r.type === 'deprecated').length; + const unusedCount = recommendations.filter((r) => r.type === 'unused').length; + const breakingCount = recommendations.filter((r) => r.severity === 'BREAKING').length; + const migrationCount = recommendations.filter((r) => r.severity === 'MIGRATION REQUIRED').length; + + return { + installedCount, + outdatedCount, + deprecatedCount, + unusedCount, + breakingCount, + migrationCount, + recommendations, + }; +} + +function cleanVersion(v: string): string { + return v + .replace(/[\^~>=<]/g, '') + .split('-')[0] + .trim(); +} + +function isOlder(v1: string, v2: string): boolean { + const parts1 = cleanVersion(v1) + .split('.') + .map((x) => parseInt(x, 10) || 0); + const parts2 = cleanVersion(v2) + .split('.') + .map((x) => parseInt(x, 10) || 0); + for (let i = 0; i < 3; i++) { + const p1 = parts1[i] ?? 0; + const p2 = parts2[i] ?? 0; + if (p1 < p2) return true; + if (p1 > p2) return false; + } + return false; +} + +function isMajorUpgrade(v1: string, v2: string): boolean { + const major1 = parseInt(cleanVersion(v1).split('.')[0], 10) || 0; + const major2 = parseInt(cleanVersion(v2).split('.')[0], 10) || 0; + return major1 < major2; +} + +function escapeRegex(str: string): string { + return str.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +function getPackageLockVersion(lockContent: string, packageName: string): string | null { + try { + const lockObj = JSON.parse(lockContent); + if (lockObj.packages) { + const pkgKey = `node_modules/${packageName}`; + if (lockObj.packages[pkgKey]?.version) { + return lockObj.packages[pkgKey].version; + } + } + if (lockObj.dependencies?.[packageName]?.version) { + return lockObj.dependencies[packageName].version; + } + } catch { + // Ignore JSON errors + } + return null; +} + +function getYarnLockVersion(lockContent: string, packageName: string): string | null { + const regex = new RegExp( + `["']?${escapeRegex(packageName)}@(?:[\\s\\S]*?)\\s+version\\s+["']([^"']+)["']`, + 'i', + ); + const match = regex.exec(lockContent); + return match ? match[1] : null; +} + +function getPnpmLockVersion(lockContent: string, packageName: string): string | null { + const regex = new RegExp(`/${escapeRegex(packageName)}@([^:\\s]+)`, 'i'); + const match = regex.exec(lockContent); + return match ? match[1] : null; +} + +function scanImportsRecursively(dir: string, baseDir: string, importedPackages: Set): void { + if (!fs.existsSync(dir)) return; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + // Skip ignored directories + if ( + entry.isDirectory() && + (entry.name.startsWith('.') || + entry.name === 'node_modules' || + entry.name === 'dist' || + entry.name === 'build' || + entry.name === 'coverage' || + entry.name === 'tmp' || + entry.name === 'temp' || + entry.name === 'logs') + ) { + continue; + } + + if (entry.isDirectory()) { + scanImportsRecursively(fullPath, baseDir, importedPackages); + } else if (/\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(entry.name)) { + const content = fs.readFileSync(fullPath, 'utf8'); + extractImports(content, importedPackages); + } + } + } catch { + // Ignore read errors + } +} + +function extractImports(content: string, importedPackages: Set): void { + const importRegex = /(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/g; + const requireRegex = /(?:require|import)\(['"]([^'"]+)['"]\)/g; + + let match; + while ((match = importRegex.exec(content)) !== null) { + const rawPkg = match[1]; + if (rawPkg) { + importedPackages.add(cleanPackageName(rawPkg)); + } + } + + while ((match = requireRegex.exec(content)) !== null) { + const rawPkg = match[1]; + if (rawPkg) { + importedPackages.add(cleanPackageName(rawPkg)); + } + } +} + +function cleanPackageName(rawPkg: string): string { + const segments = rawPkg.split('/'); + if (rawPkg.startsWith('@')) { + return segments.slice(0, 2).join('/'); + } + return segments[0] ?? rawPkg; +} diff --git a/packages/core/src/intelligence/engine.ts b/packages/core/src/intelligence/engine.ts index 0d9e355..6eeae86 100644 --- a/packages/core/src/intelligence/engine.ts +++ b/packages/core/src/intelligence/engine.ts @@ -12,13 +12,7 @@ import { ProjectNode, ProjectPackageManagerInfo, } from './types.js'; -import { - isArchitectural, - isAsset, - isConfiguration, - isGenerated, - isIgnored, -} from './ignore.js'; +import { isArchitectural, isAsset, isConfiguration, isGenerated, isIgnored } from './ignore.js'; type Manifest = Record; @@ -147,8 +141,12 @@ function walkProject( const childNames = entry.isDirectory() ? fs .readdirSync(absoluteEntryPath, { withFileTypes: true }) - .filter((child) => !isIgnored(normalizeRelativePath(path.join(entryRelativePath, child.name)))) - .map((child) => createId('file', normalizeRelativePath(path.join(entryRelativePath, child.name)))) + .filter( + (child) => !isIgnored(normalizeRelativePath(path.join(entryRelativePath, child.name))), + ) + .map((child) => + createId('file', normalizeRelativePath(path.join(entryRelativePath, child.name))), + ) : []; const fileRecord = createArchitecturalFile( @@ -581,7 +579,8 @@ function readRecord(manifest: Manifest | undefined, field: string): Record typeof entry[0] === 'string' && typeof entry[1] === 'string', + (entry): entry is [string, string] => + typeof entry[0] === 'string' && typeof entry[1] === 'string', ), ); } diff --git a/packages/core/src/intelligence/index.ts b/packages/core/src/intelligence/index.ts index add7fc8..84f14cf 100644 --- a/packages/core/src/intelligence/index.ts +++ b/packages/core/src/intelligence/index.ts @@ -1,3 +1,4 @@ export * from './types.js'; export * from './ignore.js'; export * from './engine.js'; +export * from './dependencies.js'; diff --git a/packages/core/src/intelligence/types.ts b/packages/core/src/intelligence/types.ts index ae18fd5..9893b2e 100644 --- a/packages/core/src/intelligence/types.ts +++ b/packages/core/src/intelligence/types.ts @@ -99,14 +99,7 @@ export interface ProjectMetadataInfo { } export interface ArchitectureBucket { - key: - | 'frontend' - | 'backend' - | 'shared' - | 'assets' - | 'configuration' - | 'public' - | 'database'; + key: 'frontend' | 'backend' | 'shared' | 'assets' | 'configuration' | 'public' | 'database'; paths: string[]; fileIds: string[]; files: ArchitecturalFile[]; diff --git a/packages/core/src/platform/history.spec.ts b/packages/core/src/platform/history.spec.ts new file mode 100644 index 0000000..24e3685 --- /dev/null +++ b/packages/core/src/platform/history.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import { readHistory, appendHistoryEntry } from './history.js'; + +describe('Project History Module', () => { + const tempDir = path.resolve('./temp-history-test'); + + beforeEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('reads empty history if history.json does not exist', () => { + const history = readHistory(tempDir); + expect(history).toEqual([]); + }); + + it('correctly appends, writes, and reads history entries', () => { + const entry1 = appendHistoryEntry(tempDir, { + operation: 'init', + status: 'success', + duration: 150, + filesChanged: ['package.json'], + summary: 'Project Created', + }); + + expect(entry1.id).toBeDefined(); + expect(entry1.timestamp).toBeDefined(); + expect(entry1.version).toBe('1.0.1'); + expect(entry1.summary).toBe('Project Created'); + + const history = readHistory(tempDir); + expect(history.length).toBe(1); + expect(history[0].id).toBe(entry1.id); + + // Append second entry + appendHistoryEntry(tempDir, { + operation: 'add', + status: 'success', + duration: 40, + filesChanged: ['lib/auth.ts'], + summary: 'Added Better Auth', + }); + + const updatedHistory = readHistory(tempDir); + expect(updatedHistory.length).toBe(2); + expect(updatedHistory[1].summary).toBe('Added Better Auth'); + }); +}); diff --git a/packages/core/src/platform/history.ts b/packages/core/src/platform/history.ts new file mode 100644 index 0000000..5b5d99f --- /dev/null +++ b/packages/core/src/platform/history.ts @@ -0,0 +1,59 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; + +export interface HistoryEntry { + id: string; + timestamp: string; + operation: string; + status: 'success' | 'failed'; + duration: number; + filesChanged: string[]; + version: string; + summary: string; + details?: any; +} + +const HISTORY_FILE = 'history.json'; +const STRUCTIFY_DIR = '.structify'; + +function getHistoryPath(projectPath: string): string { + return path.join(projectPath, STRUCTIFY_DIR, HISTORY_FILE); +} + +export function readHistory(projectPath: string): HistoryEntry[] { + const filePath = getHistoryPath(projectPath); + if (!fs.existsSync(filePath)) { + return []; + } + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +export function appendHistoryEntry( + projectPath: string, + entry: Omit, + cliVersion: string = '1.0.1' +): HistoryEntry { + const dirPath = path.join(projectPath, STRUCTIFY_DIR); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + const history = readHistory(projectPath); + const fullEntry: HistoryEntry = { + id: randomUUID(), + timestamp: new Date().toISOString(), + version: cliVersion, + ...entry, + }; + + history.push(fullEntry); + fs.writeFileSync(getHistoryPath(projectPath), JSON.stringify(history, null, 2), 'utf8'); + return fullEntry; +} diff --git a/packages/core/src/platform/index.ts b/packages/core/src/platform/index.ts index c90ff2f..7b1d54f 100644 --- a/packages/core/src/platform/index.ts +++ b/packages/core/src/platform/index.ts @@ -11,3 +11,5 @@ export * from './service-container.js'; export * from './phase9.js'; export * from './stack-detector.js'; export * from './health-engine.js'; +export * from './marketplace.js'; +export * from './history.js'; diff --git a/packages/core/src/platform/marketplace.spec.ts b/packages/core/src/platform/marketplace.spec.ts new file mode 100644 index 0000000..f2f33d7 --- /dev/null +++ b/packages/core/src/platform/marketplace.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import { + getIntegrationsForCategory, + buildIntegrationPatchPlan, + INTEGRATIONS_CATALOG +} from './marketplace.js'; +import { DetectedStack } from './stack-detector.js'; +import { ProjectState } from './phase9.js'; + +describe('Smart Marketplace Integrations', () => { + const dummyStack: DetectedStack = { + frontend: 'next', + backend: 'none', + database: 'none', + orm: 'none', + styling: 'none', + packageManager: 'npm', + docker: false, + githubActions: false, + eslint: false, + prettier: false, + git: false, + editorconfig: false, + detectionSource: 'none', + confidence: 'low', + indicators: [] + }; + + it('correctly filters catalog integrations by category and stack', () => { + const integrations = getIntegrationsForCategory('auth', dummyStack); + expect(integrations.length).toBeGreaterThan(0); + expect(integrations.every((i) => i.category === 'auth')).toBe(true); + + // Clerk is next compatible, should be in list + const hasClerk = integrations.some((i) => i.id === 'clerk'); + expect(hasClerk).toBe(true); + }); + + it('correctly builds a patch plan for an integration', () => { + const integration = INTEGRATIONS_CATALOG.find((i) => i.id === 'better-auth')!; + const state: ProjectState = { + projectPath: '.', + files: [], + modifiedFiles: [], + config: { + name: 'test-project', + version: '1.0.0', + template: 'next', + modules: [] + }, + packageManager: 'npm' + }; + + const plan = buildIntegrationPatchPlan('.', integration, state); + expect(plan.id).toBe('add-integration-better-auth'); + expect(plan.operations.length).toBeGreaterThan(0); + expect(plan.dependencyChanges.some((c) => c.name === 'better-auth')).toBe(true); + }); +}); diff --git a/packages/core/src/platform/marketplace.ts b/packages/core/src/platform/marketplace.ts new file mode 100644 index 0000000..79ec845 --- /dev/null +++ b/packages/core/src/platform/marketplace.ts @@ -0,0 +1,500 @@ +import path from 'path'; +import fs from 'fs'; +import { DetectedStack } from './stack-detector.js'; +import { ProjectState } from './phase9.js'; +import { PatchPlan, PatchOperation, MigrationGraph } from './phase9.js'; + +export interface Integration { + id: string; + name: string; + category: string; + compatibility: { + frontends: string[]; + backends: string[]; + }; + dependencies: string[]; + devDependencies?: string[]; + generatedFiles: { path: string; content: string }[]; + envVars: { key: string; value: string; description: string }[]; + docsLink: string; + installSteps: string[]; +} + +export const INTEGRATIONS_CATALOG: Integration[] = [ + // --- AUTHENTICATION --- + { + id: 'better-auth', + name: 'Better Auth', + category: 'auth', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['better-auth'], + generatedFiles: [ + { path: 'lib/auth.ts', content: `import { betterAuth } from "better-auth";\nexport const auth = betterAuth({});\n` } + ], + envVars: [ + { key: 'BETTER_AUTH_SECRET', value: 'super-secret-key-123', description: 'Secret key for signing sessions' } + ], + docsLink: 'https://better-auth.com', + installSteps: ['Install better-auth package', 'Configure lib/auth.ts', 'Add BETTER_AUTH_SECRET env variable'] + }, + { + id: 'next-auth', + name: 'NextAuth', + category: 'auth', + compatibility: { frontends: ['next'], backends: ['none', 'unknown'] }, + dependencies: ['next-auth'], + generatedFiles: [ + { path: 'app/api/auth/[...nextauth]/route.ts', content: `import NextAuth from "next-auth";\nconst handler = NextAuth({});\nexport { handler as GET, handler as POST };\n` } + ], + envVars: [ + { key: 'NEXTAUTH_SECRET', value: 'nextauth-secret-key', description: 'Encryption secret for NextAuth sessions' } + ], + docsLink: 'https://next-auth.js.org', + installSteps: ['Install next-auth package', 'Configure NextAuth API Route', 'Add NEXTAUTH_SECRET env variable'] + }, + { + id: 'clerk', + name: 'Clerk Auth', + category: 'auth', + compatibility: { frontends: ['next', 'vite-react'], backends: ['none', 'unknown'] }, + dependencies: ['@clerk/nextjs'], + generatedFiles: [ + { path: 'middleware.ts', content: `import { clerkMiddleware } from "@clerk/nextjs/server";\nexport default clerkMiddleware();\nexport const config = { matcher: ["/((?!.*\\\\..*|_next).*)", "/", "/(api|trpc)(.*)"] };\n` } + ], + envVars: [ + { key: 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', value: 'pk_test_...', description: 'Clerk publishable api key' } + ], + docsLink: 'https://clerk.com', + installSteps: ['Install @clerk/nextjs', 'Set up Clerk middleware.ts', 'Add publishable key to environment'] + }, + { + id: 'supabase-auth', + name: 'Supabase Auth', + category: 'auth', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['@supabase/supabase-js'], + generatedFiles: [ + { path: 'lib/supabase.ts', content: `import { createClient } from "@supabase/supabase-js";\nexport const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);\n` } + ], + envVars: [ + { key: 'NEXT_PUBLIC_SUPABASE_URL', value: 'https://your-supabase-url.supabase.co', description: 'Supabase Project URL' }, + { key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', value: 'anon-key-here', description: 'Supabase Anon Public API Key' } + ], + docsLink: 'https://supabase.com/docs/guides/auth', + installSteps: ['Install supabase-js client', 'Configure lib/supabase.ts client', 'Add Supabase environment configs'] + }, + { + id: 'firebase-auth', + name: 'Firebase Auth', + category: 'auth', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['firebase'], + generatedFiles: [ + { path: 'lib/firebase.ts', content: `import { initializeApp } from "firebase/app";\nimport { getAuth } from "firebase/auth";\nconst app = initializeApp({});\nexport const auth = getAuth(app);\n` } + ], + envVars: [ + { key: 'NEXT_PUBLIC_FIREBASE_API_KEY', value: 'api-key-here', description: 'Firebase Web SDK API Key' } + ], + docsLink: 'https://firebase.google.com/docs/auth', + installSteps: ['Install firebase package', 'Configure lib/firebase.ts', 'Configure Firebase environment variables'] + }, + + // --- PAYMENTS --- + { + id: 'stripe', + name: 'Stripe Payments', + category: 'payments', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['stripe', '@stripe/stripe-js'], + generatedFiles: [ + { path: 'lib/stripe.ts', content: `import Stripe from 'stripe';\nexport const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20' as any });\n` } + ], + envVars: [ + { key: 'STRIPE_SECRET_KEY', value: 'sk_test_...', description: 'Stripe Secret API Key' } + ], + docsLink: 'https://stripe.com/docs', + installSteps: ['Install stripe SDKs', 'Configure stripe client lib/stripe.ts', 'Add Stripe Secret API Key'] + }, + { + id: 'lemon-squeezy', + name: 'Lemon Squeezy', + category: 'payments', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['@lemonsqueezy/lemonsqueezy.js'], + generatedFiles: [ + { path: 'lib/lemonsqueezy.ts', content: `import { lemonSqueezySetup } from "@lemonsqueezy/lemonsqueezy.js";\nlemonSqueezySetup({ apiKey: process.env.LEMON_SQUEEZY_API_KEY });\n` } + ], + envVars: [ + { key: 'LEMON_SQUEEZY_API_KEY', value: 'api_key_...', description: 'Lemon Squeezy API Key' } + ], + docsLink: 'https://docs.lemonsqueezy.com', + installSteps: ['Install lemonsqueezy.js client sdk', 'Configure lemonsqueezy client lib/lemonsqueezy.ts', 'Add API Key'] + }, + + // --- LOGGING --- + { + id: 'winston', + name: 'Winston', + category: 'logging', + compatibility: { frontends: ['next'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['winston'], + generatedFiles: [ + { path: 'lib/logger.ts', content: `import winston from 'winston';\nexport const logger = winston.createLogger({ transports: [new winston.transports.Console()] });\n` } + ], + envVars: [], + docsLink: 'https://github.com/winstonjs/winston', + installSteps: ['Install winston logging package', 'Configure log transporter lib/logger.ts'] + }, + { + id: 'pino', + name: 'Pino Logger', + category: 'logging', + compatibility: { frontends: ['next'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['pino'], + generatedFiles: [ + { path: 'lib/logger.ts', content: `import pino from 'pino';\nexport const logger = pino();\n` } + ], + envVars: [], + docsLink: 'https://getpino.io', + installSteps: ['Install pino logging package', 'Configure logger logger.ts'] + }, + + // --- MONITORING --- + { + id: 'sentry', + name: 'Sentry Monitoring', + category: 'monitoring', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['@sentry/nextjs'], + generatedFiles: [ + { path: 'sentry.client.config.js', content: `import * as Sentry from "@sentry/nextjs";\nSentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN });\n` } + ], + envVars: [ + { key: 'NEXT_PUBLIC_SENTRY_DSN', value: 'https://...@sentry.io/...', description: 'Sentry Data Source Name' } + ], + docsLink: 'https://sentry.io', + installSteps: ['Install @sentry/nextjs SDK', 'Create client config file sentry.client.config.js', 'Add Sentry DSN'] + }, + + // --- CACHE --- + { + id: 'redis', + name: 'Redis Cache', + category: 'cache', + compatibility: { frontends: ['next'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['ioredis'], + generatedFiles: [ + { path: 'lib/redis.ts', content: `import Redis from 'ioredis';\nexport const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');\n` } + ], + envVars: [ + { key: 'REDIS_URL', value: 'redis://localhost:6379', description: 'Redis Connection Connection URL' } + ], + docsLink: 'https://redis.io', + installSteps: ['Install ioredis client package', 'Configure lib/redis.ts connector client', 'Add REDIS_URL'] + }, + + // --- QUEUE --- + { + id: 'bullmq', + name: 'BullMQ Queue', + category: 'queue', + compatibility: { frontends: ['next'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['bullmq'], + generatedFiles: [ + { path: 'lib/queue.ts', content: `import { Queue } from 'bullmq';\nexport const taskQueue = new Queue('tasks', { connection: { host: 'localhost', port: 6379 } });\n` } + ], + envVars: [], + docsLink: 'https://bullmq.io', + installSteps: ['Install bullmq', 'Set up task queue helper lib/queue.ts'] + }, + + // --- EMAIL --- + { + id: 'resend', + name: 'Resend', + category: 'email', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['resend'], + generatedFiles: [ + { path: 'lib/email.ts', content: `import { Resend } from 'resend';\nexport const resend = new Resend(process.env.RESEND_API_KEY);\n` } + ], + envVars: [ + { key: 'RESEND_API_KEY', value: 're_...', description: 'Resend API Key' } + ], + docsLink: 'https://resend.com', + installSteps: ['Install resend client package', 'Configure client lib/email.ts', 'Add RESEND_API_KEY env'] + }, + + // --- STORAGE --- + { + id: 's3', + name: 'AWS S3 Storage', + category: 'storage', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['@aws-sdk/client-s3'], + generatedFiles: [ + { path: 'lib/s3.ts', content: `import { S3Client } from '@aws-sdk/client-s3';\nexport const s3 = new S3Client({ region: process.env.AWS_REGION });\n` } + ], + envVars: [ + { key: 'AWS_REGION', value: 'us-east-1', description: 'AWS S3 region' } + ], + docsLink: 'https://aws.amazon.com/s3/', + installSteps: ['Install S3 client package SDK', 'Configure lib/s3.ts client', 'Add AWS region variable'] + }, + + // --- VALIDATION --- + { + id: 'zod', + name: 'Zod Validation', + category: 'validation', + compatibility: { frontends: ['next', 'vite-react'], backends: ['express', 'nest', 'none', 'unknown'] }, + dependencies: ['zod'], + generatedFiles: [ + { path: 'schemas/index.ts', content: `import { z } from 'zod';\nexport const UserSchema = z.object({ id: z.string(), email: z.string().email() });\n` } + ], + envVars: [], + docsLink: 'https://zod.dev', + installSteps: ['Install zod schema library', 'Create standard user model schemas/index.ts'] + }, + + // --- ANALYTICS --- + { + id: 'posthog', + name: 'PostHog Analytics', + category: 'analytics', + compatibility: { frontends: ['next', 'vite-react'], backends: ['none', 'unknown'] }, + dependencies: ['posthog-js'], + generatedFiles: [ + { path: 'lib/posthog.ts', content: `import posthog from 'posthog-js';\nif (typeof window !== 'undefined') { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: 'https://app.posthog.com' }); }\nexport { posthog };\n` } + ], + envVars: [ + { key: 'NEXT_PUBLIC_POSTHOG_KEY', value: 'phc_...', description: 'PostHog API Token' } + ], + docsLink: 'https://posthog.com', + installSteps: ['Install posthog-js SDK client', 'Create init module lib/posthog.ts', 'Add token config to environment'] + } +]; + +export const CATEGORIES_LIST = [ + 'auth', + 'payments', + 'logging', + 'monitoring', + 'cache', + 'queue', + 'email', + 'storage', + 'validation', + 'analytics', + 'database', + 'orm' +]; + +export function getIntegrationsForCategory(category: string, stack: DetectedStack): Integration[] { + const normalizedCategory = category.toLowerCase().trim(); + + // Filter catalog integrations by category and stack compatibility + return INTEGRATIONS_CATALOG.filter((integration) => { + if (integration.category !== normalizedCategory) { + return false; + } + + // Check compatibility: matching frontend framework + const frontendMatch = integration.compatibility.frontends.includes(stack.frontend) || + integration.compatibility.frontends.includes('unknown'); + + // Check compatibility: matching backend framework + const backendMatch = integration.compatibility.backends.includes(stack.backend) || + integration.compatibility.backends.includes('unknown'); + + return frontendMatch || backendMatch; + }); +} + +function buildMigrationGraph(operation: string, operations: PatchOperation[]): MigrationGraph { + return { + version: '1.0.0', + operation, + nodes: operations.map((patch) => ({ + id: `migration-${patch.id}`, + type: patch.targetPath === 'package.json' ? 'dependency' : 'module', + dependsOn: [], + preconditions: [], + affectedFiles: [patch.targetPath], + rollbackActions: [`Revert changes to ${patch.targetPath}`], + risk: 'low', + verificationSteps: [`Verify ${patch.targetPath}`] + })) + }; +} + +export function buildIntegrationPatchPlan( + projectPath: string, + integration: Integration, + _state: ProjectState +): PatchPlan { + const operations: PatchOperation[] = []; + const conflicts: PatchPlan['conflicts'] = []; + + // 1. Plan environment variable insertions into .env.example + if (integration.envVars.length > 0) { + const envPath = '.env.example'; + const absoluteEnvPath = path.join(projectPath, envPath); + const exists = fs.existsSync(absoluteEnvPath); + const currentContent = exists ? fs.readFileSync(absoluteEnvPath, 'utf8') : ''; + + let appendLines = ''; + for (const v of integration.envVars) { + if (!currentContent.includes(`${v.key}=`)) { + appendLines += `# ${v.description}\n${v.key}=${v.value}\n`; + } + } + + if (appendLines) { + operations.push({ + id: `append-${envPath}`, + type: 'append-file', + targetPath: envPath, + description: `Append env configs for ${integration.name}`, + appendContent: appendLines, + conflictPolicy: 'merge' + }); + } + } + + // 2. Plan files creation + for (const file of integration.generatedFiles) { + const absoluteFilePath = path.join(projectPath, file.path); + const exists = fs.existsSync(absoluteFilePath); + if (exists) { + conflicts.push({ + code: 'PATCH_CONFLICT', + message: `File already exists with conflicting contents: ${file.path}`, + path: file.path + }); + } else { + operations.push({ + id: `create-${file.path}`, + type: 'create-file', + targetPath: file.path, + description: `Generate ${file.path} for ${integration.name} setup`, + content: file.content, + conflictPolicy: 'error' + }); + } + } + + // 3. Plan package.json dependencies modifications + const dependencyChanges: PatchPlan['dependencyChanges'] = []; + for (const dep of integration.dependencies) { + dependencyChanges.push({ + section: 'dependencies', + name: dep, + to: 'latest' + }); + } + + if (integration.devDependencies) { + for (const devDep of integration.devDependencies) { + dependencyChanges.push({ + section: 'devDependencies', + name: devDep, + to: 'latest' + }); + } + } + + // Add package.json update operation if dependencies changed + if (dependencyChanges.length > 0) { + const packageJsonPath = 'package.json'; + const absolutePackageJsonPath = path.join(projectPath, packageJsonPath); + if (fs.existsSync(absolutePackageJsonPath)) { + const pkgObj = JSON.parse(fs.readFileSync(absolutePackageJsonPath, 'utf8')); + if (!pkgObj.dependencies) pkgObj.dependencies = {}; + + for (const change of dependencyChanges) { + if (change.section === 'dependencies') { + pkgObj.dependencies[change.name] = change.to; + } else if (change.section === 'devDependencies') { + if (!pkgObj.devDependencies) pkgObj.devDependencies = {}; + pkgObj.devDependencies[change.name] = change.to; + } + } + + operations.push({ + id: 'update-package-json', + type: 'update-file', + targetPath: packageJsonPath, + description: `Install dependencies for ${integration.name}`, + content: JSON.stringify(pkgObj, null, 2) + '\n', + conflictPolicy: 'merge' + }); + } + } + + // 4. Update structify.manifest.json metadata if present + const manifestPath = 'structify.manifest.json'; + const absoluteManifestPath = path.join(projectPath, manifestPath); + if (fs.existsSync(absoluteManifestPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(absoluteManifestPath, 'utf8')); + if (!manifest.modules) manifest.modules = []; + if (!manifest.modules.includes(integration.id)) { + manifest.modules.push(integration.id); + operations.push({ + id: 'update-manifest', + type: 'update-file', + targetPath: manifestPath, + description: `Update modules list in ${manifestPath}`, + content: JSON.stringify(manifest, null, 2) + '\n', + conflictPolicy: 'merge' + }); + } + } catch { + // Ignore if unparseable + } + } + + // 5. Update structify.project-graph.json metadata if present + const graphPath = 'structify.project-graph.json'; + const absoluteGraphPath = path.join(projectPath, graphPath); + if (fs.existsSync(absoluteGraphPath)) { + try { + const graph = JSON.parse(fs.readFileSync(absoluteGraphPath, 'utf8')); + if (!graph.nodes) graph.nodes = []; + const nodeExists = graph.nodes.some((n: any) => n.id === `module:${integration.id}`); + if (!nodeExists) { + graph.nodes.push({ + id: `module:${integration.id}`, + type: 'module', + label: integration.name, + properties: { + category: integration.category, + dependencies: integration.dependencies + } + }); + operations.push({ + id: 'update-project-graph', + type: 'update-file', + targetPath: graphPath, + description: `Register node in structify.project-graph.json`, + content: JSON.stringify(graph, null, 2) + '\n', + conflictPolicy: 'merge' + }); + } + } catch { + // Ignore + } + } + + return { + id: `add-integration-${integration.id}`, + description: `Add marketplace integration ${integration.name}`, + operations, + conflicts, + dependencyChanges, + filesChanged: operations.map(op => op.targetPath), + migrationGraph: buildMigrationGraph(`add-integration-${integration.id}`, operations), + dryRun: false + }; +} diff --git a/packages/core/src/platform/stack-detector.ts b/packages/core/src/platform/stack-detector.ts index 3c3ac87..02bdc9b 100644 --- a/packages/core/src/platform/stack-detector.ts +++ b/packages/core/src/platform/stack-detector.ts @@ -69,14 +69,9 @@ export function detectStack( orm: mapOrm(manifestStack?.orm, analysis.framework.orm), styling: mapStyling(manifestStack?.styling, analysis.framework.styling), packageManager: mapPackageManager(manifestStack?.packageManager, analysis.packageManager.name), - docker: - manifestStack?.docker ?? - analysis.modules.detected.includes('docker') ?? - false, + docker: manifestStack?.docker ?? analysis.modules.detected.includes('docker') ?? false, githubActions: - manifestStack?.githubActions ?? - analysis.modules.detected.includes('github-actions') ?? - false, + manifestStack?.githubActions ?? analysis.modules.detected.includes('github-actions') ?? false, eslint: manifestStack?.eslint ?? analysis.modules.detected.includes('eslint'), prettier: manifestStack?.prettier ?? analysis.modules.detected.includes('prettier'), git: analysis.files.some((file) => file.path === '.git'), @@ -226,10 +221,7 @@ function mapStyling(override: string | undefined, detected: string[]): DetectedS return value ? 'unknown' : 'none'; } -function mapPackageManager( - override: string | undefined, - detected: string, -): DetectedPackageManager { +function mapPackageManager(override: string | undefined, detected: string): DetectedPackageManager { const value = override ?? detected; return value === 'npm' ? 'npm' : 'unknown'; } diff --git a/tmp_inspect_view.js b/tmp_inspect_view.js index 1135c03..e15652a 100644 --- a/tmp_inspect_view.js +++ b/tmp_inspect_view.js @@ -7,7 +7,10 @@ fs.mkdirSync(path.join(dir, 'apps', 'web', 'src', 'app'), { recursive: true }); fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"tree-app"}'); fs.writeFileSync(path.join(dir, 'README.md'), '# Tree App'); fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}'); -fs.writeFileSync(path.join(dir, 'apps/web/src/app/page.tsx'), 'export default function Page() { return null; }'); +fs.writeFileSync( + path.join(dir, 'apps/web/src/app/page.tsx'), + 'export default function Page() { return null; }', +); fs.writeFileSync(path.join(dir, 'apps/web/next.config.ts'), 'export default {};'); const analysis = analyzeProject(dir); const view = createArchitectureView(analysis, 'architectural');