Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PROJECT_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,4 @@
├── ⚙️ turbo.json
└── ⚙️ vitest.config.ts

```
```
164 changes: 162 additions & 2 deletions apps/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +23,32 @@ export interface AddOptions {
database?: 'postgres' | 'mongodb';
}

async function promptChoice(
question: string,
choices: { id: string; name: string }[],
rl: readline.Interface
): Promise<string> {
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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');
Expand All @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions apps/cli/src/commands/deps.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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');
}
}
72 changes: 72 additions & 0 deletions apps/cli/src/commands/deps.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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');
}
17 changes: 16 additions & 1 deletion apps/cli/src/commands/graph.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading