stackloom-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/cli.js +306 -0
  4. package/branding.json +8 -0
  5. package/package.json +72 -0
  6. package/src/__tests__/cli-smoke.test.js +46 -0
  7. package/src/blueprint/__tests__/blueprint.test.js +116 -0
  8. package/src/blueprint/blueprint.js +181 -0
  9. package/src/blueprint/default.blueprint.json +78 -0
  10. package/src/blueprint/index.js +10 -0
  11. package/src/blueprint/loader.js +101 -0
  12. package/src/blueprint/schema-kit.js +161 -0
  13. package/src/blueprint/schema.js +78 -0
  14. package/src/branding/__tests__/branding.test.js +49 -0
  15. package/src/branding/index.js +48 -0
  16. package/src/commands/__tests__/commands.test.js +83 -0
  17. package/src/commands/check.js +71 -0
  18. package/src/commands/cleanup.js +347 -0
  19. package/src/commands/customize.js +263 -0
  20. package/src/commands/doctor.js +84 -0
  21. package/src/commands/env.js +75 -0
  22. package/src/commands/finalize.js +68 -0
  23. package/src/commands/generate/ci-cd.js +378 -0
  24. package/src/commands/generate/deploy-advanced.js +253 -0
  25. package/src/commands/generate/deploy.js +99 -0
  26. package/src/commands/generate/env.template.js +221 -0
  27. package/src/commands/generate/index.js +7 -0
  28. package/src/commands/generate/module.js +836 -0
  29. package/src/commands/generate/page.js +1415 -0
  30. package/src/commands/generate/test-scaffold.js +279 -0
  31. package/src/commands/generate/theme.js +67 -0
  32. package/src/commands/generate-resource.js +133 -0
  33. package/src/commands/index.js +9 -0
  34. package/src/commands/init.js +350 -0
  35. package/src/commands/make/resource.js +298 -0
  36. package/src/commands/preset.js +57 -0
  37. package/src/commands/remove.js +170 -0
  38. package/src/commands/rename.js +54 -0
  39. package/src/commands/rollback.js +90 -0
  40. package/src/commands/wizard.js +303 -0
  41. package/src/core/__tests__/generator.test.js +67 -0
  42. package/src/core/__tests__/marker-strategy.test.js +57 -0
  43. package/src/core/__tests__/resource-definition.test.js +32 -0
  44. package/src/core/generator.js +542 -0
  45. package/src/core/marker-strategy.js +138 -0
  46. package/src/core/resource-definition.js +346 -0
  47. package/src/core/state-tracker.js +67 -0
  48. package/src/core/template-loader.js +163 -0
  49. package/src/engine/__tests__/engine.test.js +306 -0
  50. package/src/engine/index.js +21 -0
  51. package/src/engine/injector.js +198 -0
  52. package/src/engine/pipeline.js +138 -0
  53. package/src/engine/transaction.js +105 -0
  54. package/src/engine/validator.js +190 -0
  55. package/src/index.js +4 -0
  56. package/src/recipes/__tests__/recipe.test.js +128 -0
  57. package/src/recipes/builtin/module.json +22 -0
  58. package/src/recipes/builtin/page.json +21 -0
  59. package/src/recipes/builtin/resource.json +35 -0
  60. package/src/recipes/condition.js +147 -0
  61. package/src/recipes/index.js +11 -0
  62. package/src/recipes/loader.js +95 -0
  63. package/src/recipes/recipe.js +89 -0
  64. package/src/recipes/schema.js +47 -0
  65. package/src/schemas/__tests__/schemas.test.js +67 -0
  66. package/src/schemas/index.js +18 -0
  67. package/src/schemas/options.js +38 -0
  68. package/src/schemas/resource.js +112 -0
  69. package/src/services/__tests__/reporter.test.js +98 -0
  70. package/src/services/clock.js +31 -0
  71. package/src/services/index.js +43 -0
  72. package/src/services/reporter.js +136 -0
  73. package/src/templates/resource/api.js.ejs +39 -0
  74. package/src/templates/resource/components/form.jsx.ejs +81 -0
  75. package/src/templates/resource/components/table.jsx.ejs +68 -0
  76. package/src/templates/resource/controller.js.ejs +154 -0
  77. package/src/templates/resource/hooks.js.ejs +46 -0
  78. package/src/templates/resource/model.js.ejs +64 -0
  79. package/src/templates/resource/page-detail.jsx.ejs +55 -0
  80. package/src/templates/resource/page-form.jsx.ejs +30 -0
  81. package/src/templates/resource/page-inline.jsx.ejs +74 -0
  82. package/src/templates/resource/page-modal.jsx.ejs +98 -0
  83. package/src/templates/resource/page-page.jsx.ejs +99 -0
  84. package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
  85. package/src/templates/resource/routes.js.ejs +35 -0
  86. package/src/templates/resource/service.js.ejs +132 -0
  87. package/src/templates/resource/test.ejs +71 -0
  88. package/src/templates/resource/types.ts.ejs +17 -0
  89. package/src/templates/resource/validator.js.ejs +26 -0
  90. package/src/templates/snippets/lazy-import.ejs +1 -0
  91. package/src/templates/snippets/nav-entry.ejs +1 -0
  92. package/src/templates/snippets/route-entry.ejs +5 -0
  93. package/src/templates/snippets/route-mount.ejs +1 -0
  94. package/src/utils/fieldValidators.js +371 -0
  95. package/src/utils/logging/logger.js +47 -0
  96. package/src/utils/namingUtils.js +38 -0
  97. package/src/utils/sanitize.js +200 -0
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import inquirer from 'inquirer';
8
+
9
+ export default async function removeCmd(resourceType, name, options) {
10
+ const spinner = ora();
11
+ const projectRoot = process.cwd();
12
+
13
+ // Validate we're in a MERN Starter project
14
+ if (!fs.existsSync(path.join(projectRoot, 'frontend/src/App.jsx'))) {
15
+ console.log(chalk.red('✖ Not a MERN Starter Kit project.'));
16
+ process.exit(1);
17
+ }
18
+
19
+ const type = resourceType.toLowerCase();
20
+ const confirmed = options.force || await confirmRemove(type, name);
21
+
22
+ if (!confirmed) {
23
+ console.log(chalk.gray('✖ Cancelled.'));
24
+ process.exit(0);
25
+ }
26
+
27
+ switch (type) {
28
+ case 'page':
29
+ await removePage(projectRoot, name, options, spinner);
30
+ break;
31
+ case 'module':
32
+ await removeModule(projectRoot, name, options, spinner);
33
+ break;
34
+ default:
35
+ console.log(chalk.red(`✖ Unknown resource type: ${type}. Use "page" or "module".`));
36
+ process.exit(1);
37
+ }
38
+ }
39
+
40
+ async function confirmRemove(type, name) {
41
+ const { confirm } = await inquirer.prompt([
42
+ {
43
+ type: 'confirm',
44
+ name: 'confirm',
45
+ message: `Remove ${type} "${name}"? This will delete files and clean up references.`,
46
+ default: false,
47
+ },
48
+ ]);
49
+ return confirm;
50
+ }
51
+
52
+ async function removePage(projectRoot, pageName, options, spinner) {
53
+ const pageNameCap = pageName.charAt(0).toUpperCase() + pageName.slice(1);
54
+ const pageDir = path.join(projectRoot, 'frontend/src/pages', pageName);
55
+ const pageFile = path.join(pageDir, `${pageNameCap}Page.jsx`);
56
+
57
+ // Delete page directory
58
+ if (fs.existsSync(pageDir)) {
59
+ await fs.remove(pageDir);
60
+ spinner.succeed(`Removed page directory: ${pageDir}`);
61
+ } else {
62
+ spinner.warn(`Page directory not found: ${pageDir}`);
63
+ }
64
+
65
+ // Remove lazy import from AppRouter.jsx
66
+ const routerPath = path.join(projectRoot, 'frontend/src/routes/AppRouter.jsx');
67
+ if (fs.existsSync(routerPath)) {
68
+ let routerCode = await fs.readFile(routerPath, 'utf-8');
69
+ const importLine = `const ${pageNameCap}Page = lazy(() => import("@/pages/${pageName}/${pageNameCap}Page"));`;
70
+
71
+ if (routerCode.includes(importLine)) {
72
+ routerCode = routerCode.replace(importLine + '\n', '');
73
+ // Also remove empty line left behind
74
+ routerCode = routerCode.replace(/^\s*$\n/m, '');
75
+ await fs.writeFile(routerPath, routerCode);
76
+ spinner.succeed('Removed lazy import from AppRouter.jsx');
77
+ } else {
78
+ spinner.warn(`Lazy import not found in AppRouter.jsx — skipping`);
79
+ }
80
+
81
+ // Remove route block from AppRouter.jsx
82
+ // Strategy: find comment line, then skip all lines until we hit a line that is just '/>' (self-closing tag terminator)
83
+ const commentLine = `/* ${pageNameCap} */`;
84
+ const lines = routerCode.split('\n');
85
+ const filtered = [];
86
+ let skipping = false;
87
+
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const line = lines[i];
90
+
91
+ if (!skipping && line.includes(commentLine)) {
92
+ skipping = true;
93
+ continue;
94
+ }
95
+
96
+ if (skipping) {
97
+ // When we encounter a line whose trimmed content is exactly '/>', the route block is closed
98
+ if (line.trim() === '/>') {
99
+ skipping = false;
100
+ }
101
+ // Skip this line (part of route block)
102
+ continue;
103
+ }
104
+
105
+ filtered.push(line);
106
+ }
107
+
108
+ const newRouterCode = filtered.join('\n');
109
+ if (newRouterCode !== routerCode) {
110
+ await fs.writeFile(routerPath, newRouterCode);
111
+ spinner.succeed('Removed route from AppRouter.jsx');
112
+ } else {
113
+ spinner.warn('Route block not found in AppRouter.jsx — skipping');
114
+ }
115
+ } else {
116
+ spinner.warn('AppRouter.jsx not found — cannot clean imports/routes');
117
+ }
118
+
119
+ // Remove navigation entry from app-preset.js
120
+ const presetPath = path.join(projectRoot, 'frontend/src/config/app-preset.js');
121
+ if (fs.existsSync(presetPath)) {
122
+ let presetCode = await fs.readFile(presetPath, 'utf-8');
123
+ const navEntryRegex = new RegExp(`\\{\\s*label:\\s*"${pageNameCap}",\\s*href:\\s*"[^"]*",\\s*icon:\\s*"[^"]*"\\s*\\},?\\s*\\n?`);
124
+ const before = presetCode;
125
+ presetCode = presetCode.replace(navEntryRegex, '');
126
+
127
+ if (presetCode !== before) {
128
+ // Clean up trailing comma left behind from navigation array
129
+ presetCode = presetCode.replace(/,\s*\n\s*]/, '\n ]');
130
+ await fs.writeFile(presetPath, presetCode);
131
+ spinner.succeed('Removed navigation entry from app-preset.js');
132
+ } else {
133
+ spinner.warn('Navigation entry not found in app-preset.js — skipping');
134
+ }
135
+ } else {
136
+ spinner.warn('app-preset.js not found — cannot remove navigation entry');
137
+ }
138
+ }
139
+
140
+ async function removeModule(projectRoot, moduleName, options, spinner) {
141
+ const modDir = path.join(projectRoot, 'backend/src/modules', moduleName);
142
+
143
+ // Delete module directory
144
+ if (fs.existsSync(modDir)) {
145
+ await fs.remove(modDir);
146
+ spinner.succeed(`Removed module directory: ${modDir}`);
147
+ } else {
148
+ spinner.warn(`Module directory not found: ${modDir}`);
149
+ }
150
+
151
+ // Unmount route from backend/src/routes/index.js
152
+ const routesIndexPath = path.join(projectRoot, 'backend/src/routes/index.js');
153
+ if (fs.existsSync(routesIndexPath)) {
154
+ let routesCode = await fs.readFile(routesIndexPath, 'utf-8');
155
+ const mountLine = `router.use("/${moduleName}", require("../modules/${moduleName}/${moduleName}.routes"));`;
156
+
157
+ if (routesCode.includes(mountLine)) {
158
+ // Remove the line and any adjacent blank lines
159
+ const lines = routesCode.split('\n');
160
+ const filtered = lines.filter((line) => !line.trim().startsWith(`router.use("/${moduleName}"`));
161
+ const cleaned = filtered.join('\n').replace(/\n{3,}/g, '\n\n'); // collapse excessive blank lines
162
+ await fs.writeFile(routesIndexPath, cleaned);
163
+ spinner.succeed(`Unmounted /${moduleName} route from backend/src/routes/index.js`);
164
+ } else {
165
+ spinner.warn(`Mount line not found in routes/index.js — skipping`);
166
+ }
167
+ } else {
168
+ spinner.warn('backend/src/routes/index.js not found — cannot unmount route');
169
+ }
170
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `loom rename <new-name>` — rebrand the CLI tool itself.
3
+ *
4
+ * Updates branding.json (the runtime source of truth) and package.json's `bin`
5
+ * key so the binary is exposed under the new name after the next install/link.
6
+ * One command, no manual find-and-replace.
7
+ */
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { branding, brandingPath, saveBrandingTo } from "../branding/index.js";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const PKG_PATH = path.join(__dirname, "..", "..", "package.json");
15
+
16
+ /** A valid CLI/bin command name: lowercase, digits, hyphens, starts with a letter. */
17
+ const VALID_NAME = /^[a-z][a-z0-9-]*$/;
18
+
19
+ export default async function rename(newName, options = {}) {
20
+ if (!newName || !VALID_NAME.test(newName)) {
21
+ console.error(
22
+ `Invalid CLI name "${newName ?? ""}". Use lowercase letters, digits and hyphens, starting with a letter.`,
23
+ );
24
+ process.exit(1);
25
+ }
26
+
27
+ const oldBin = branding.binName;
28
+ if (newName === oldBin) {
29
+ console.log(`CLI is already named "${newName}". Nothing to do.`);
30
+ return;
31
+ }
32
+
33
+ const displayName = options.displayName || newName.toUpperCase();
34
+ const description = options.description || branding.description;
35
+
36
+ // 1. Re-point package.json's bin key, preserving the existing target script.
37
+ let pkg;
38
+ try {
39
+ pkg = JSON.parse(readFileSync(PKG_PATH, "utf-8"));
40
+ } catch (err) {
41
+ console.error(`Could not read ${PKG_PATH}: ${err.message}`);
42
+ process.exit(1);
43
+ }
44
+ const binTarget =
45
+ (pkg.bin && (pkg.bin[oldBin] || Object.values(pkg.bin)[0])) || "./bin/cli.js";
46
+ pkg.bin = { [newName]: binTarget };
47
+ writeFileSync(PKG_PATH, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
48
+
49
+ // 2. Persist the new identity to branding.json.
50
+ saveBrandingTo(brandingPath, { binName: newName, displayName, description }, branding);
51
+
52
+ console.log(`Renamed CLI: ${oldBin} → ${newName} (display name "${displayName}")`);
53
+ console.log(`Re-link the binary with 'pnpm install' (or 'npm link') to use '${newName}'.`);
54
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import inquirer from 'inquirer';
8
+ import { StateTracker } from '../core/state-tracker.js';
9
+
10
+ /**
11
+ * Rollback Command — undoes the last generation action
12
+ */
13
+ export default async function rollbackCmd(options) {
14
+ const spinner = ora();
15
+ const projectRoot = process.cwd();
16
+ const tracker = new StateTracker(projectRoot);
17
+
18
+ const lastEvent = await tracker.getLastEvent();
19
+
20
+ if (!lastEvent) {
21
+ console.log(chalk.yellow('⚠ No generation history found to rollback.'));
22
+ return;
23
+ }
24
+
25
+ console.log(chalk.cyan(`\nReverting last action: ${lastEvent.action} ${lastEvent.resource || ''}`));
26
+ console.log(chalk.gray(`Timestamp: ${lastEvent.timestamp}`));
27
+ console.log(chalk.gray(`Files affected: ${lastEvent.files.length}`));
28
+
29
+ if (!options.force) {
30
+ const { confirm } = await inquirer.prompt([
31
+ {
32
+ type: 'confirm',
33
+ name: 'confirm',
34
+ message: 'Are you sure you want to revert these changes? Files created will be deleted, and updates may be lost.',
35
+ default: false,
36
+ },
37
+ ]);
38
+
39
+ if (!confirm) {
40
+ console.log(chalk.gray('✖ Rollback cancelled.'));
41
+ return;
42
+ }
43
+ }
44
+
45
+ spinner.start('Rolling back changes...');
46
+
47
+ try {
48
+ let revertedCount = 0;
49
+ for (const file of lastEvent.files) {
50
+ const fullPath = path.join(projectRoot, file.path);
51
+
52
+ if (file.action === 'CREATE') {
53
+ if (await fs.pathExists(fullPath)) {
54
+ await fs.remove(fullPath);
55
+ revertedCount++;
56
+ if (options.verbose) console.log(chalk.gray(` - Deleted ${file.path}`));
57
+ }
58
+ } else if (file.action === 'UPDATE') {
59
+ // For updates, we can't easily revert without backups
60
+ // In a real production CLI, we'd have .loom/backups/
61
+ spinner.warn(`Cannot fully revert update to ${file.path} without backup. Please check manually.`);
62
+ }
63
+ }
64
+
65
+ // Clean up empty directories
66
+ await cleanupEmptyDirs(projectRoot, lastEvent.files.map(f => f.path));
67
+
68
+ await tracker.removeEvent(lastEvent.id);
69
+ spinner.succeed(`Rollback complete. ${revertedCount} files removed.`);
70
+ } catch (err) {
71
+ spinner.fail('Rollback failed');
72
+ console.error(chalk.red(err.message));
73
+ process.exit(1);
74
+ }
75
+ }
76
+
77
+ async function cleanupEmptyDirs(projectRoot, filePaths) {
78
+ const dirs = [...new Set(filePaths.map(fp => path.dirname(path.join(projectRoot, fp))))];
79
+ // Sort by depth (deepest first)
80
+ dirs.sort((a, b) => b.length - a.length);
81
+
82
+ for (const dir of dirs) {
83
+ if (await fs.pathExists(dir)) {
84
+ const files = await fs.readdir(dir);
85
+ if (files.length === 0) {
86
+ await fs.remove(dir);
87
+ }
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+
3
+ import inquirer from 'inquirer';
4
+ import path from 'path';
5
+ import fs from 'fs-extra';
6
+ import chalk from 'chalk';
7
+ import ora from 'ora';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ // Import generators directly (they are plain async functions)
14
+ import { default as pageGenerator } from './generate/page.js';
15
+ import { default as moduleGenerator } from './generate/module.js';
16
+ import { default as themeGenerator } from './generate/theme.js';
17
+ import { default as deployGenerator } from './generate/deploy.js';
18
+ import { default as removeCommand } from './remove.js';
19
+
20
+ export default async function wizardCmd(options) {
21
+ const spinner = ora({ discardStdin: false });
22
+ const projectRoot = process.cwd();
23
+
24
+ // Verify we're in a MERN Starter project
25
+ if (!fs.existsSync(path.join(projectRoot, 'frontend/src/App.jsx'))) {
26
+ console.log(chalk.red('✖ Not a MERN Starter Kit project. Run this inside your project directory.'));
27
+ process.exit(1);
28
+ }
29
+
30
+ console.log(chalk.cyan('\n🚀 MERN Starter Kit Wizard'));
31
+ console.log(chalk.gray('Interactive guide to extend your project.\n'));
32
+
33
+ const steps = [];
34
+
35
+ while (true) {
36
+ const { action } = await inquirer.prompt([
37
+ {
38
+ type: 'list',
39
+ name: 'action',
40
+ message: 'What would you like to do?',
41
+ choices: [
42
+ { name: '➕ Add a backend module', value: 'add_module' },
43
+ { name: '➕ Add a frontend page', value: 'add_page' },
44
+ { name: '🎨 Import a shadcn theme', value: 'add_theme' },
45
+ { name: '📦 Generate deploy configs', value: 'add_deploy' },
46
+ { name: '🗑️ Remove something', value: 'remove' },
47
+ { name: '✅ Done — exit wizard', value: 'done' },
48
+ ],
49
+ },
50
+ ]);
51
+
52
+ if (action === 'done') break;
53
+
54
+ if (action === 'add_module') {
55
+ const { moduleName } = await inquirer.prompt([
56
+ {
57
+ type: 'input',
58
+ name: 'moduleName',
59
+ message: 'Module name (e.g., products, invoices, appointments):',
60
+ validate: (input) => /^[a-z0-9-_]+$/i.test(input) || 'Use only letters, numbers, dashes, underscores',
61
+ },
62
+ ]);
63
+ steps.push({ type: 'generate', subtype: 'module', name: moduleName });
64
+ }
65
+
66
+ if (action === 'add_page') {
67
+ const { pageName, route, icon, addNav } = await inquirer.prompt([
68
+ {
69
+ type: 'input',
70
+ name: 'pageName',
71
+ message: 'Page name (e.g., settings, reports, team):',
72
+ validate: (input) => /^[a-z0-9-_]+$/i.test(input) || 'Use only letters, numbers, dashes, underscores',
73
+ },
74
+ {
75
+ type: 'input',
76
+ name: 'route',
77
+ message: 'Route path (leave blank for /<page>):',
78
+ },
79
+ {
80
+ type: 'input',
81
+ name: 'icon',
82
+ message: 'Lucide icon name (e.g., layout, settings, users):',
83
+ default: 'layout',
84
+ },
85
+ {
86
+ type: 'confirm',
87
+ name: 'addNav',
88
+ message: 'Add to navigation?',
89
+ default: true,
90
+ },
91
+ ]);
92
+ steps.push({
93
+ type: 'generate',
94
+ subtype: 'page',
95
+ name: pageName,
96
+ options: {
97
+ route: route || `/${pageName}`,
98
+ icon,
99
+ noNav: !addNav,
100
+ force: false,
101
+ },
102
+ });
103
+ }
104
+
105
+ if (action === 'add_theme') {
106
+ const { method } = await inquirer.prompt([
107
+ {
108
+ type: 'list',
109
+ name: 'method',
110
+ message: 'How would you like to provide the theme CSS?',
111
+ choices: [
112
+ { name: 'From a file', value: 'file' },
113
+ { name: 'Paste CSS directly', value: 'paste' },
114
+ ],
115
+ },
116
+ ]);
117
+
118
+ let filePath, pasteContent;
119
+ if (method === 'file') {
120
+ const res = await inquirer.prompt([
121
+ {
122
+ type: 'input',
123
+ name: 'filePath',
124
+ message: 'Path to CSS file (must contain :root and .dark):',
125
+ },
126
+ ]);
127
+ filePath = res.filePath;
128
+ } else {
129
+ const res = await inquirer.prompt([
130
+ {
131
+ type: 'editor',
132
+ name: 'pasteContent',
133
+ message: 'Paste your CSS variables (Ctrl+D to finish):',
134
+ },
135
+ ]);
136
+ pasteContent = res.pasteContent;
137
+ }
138
+
139
+ steps.push({
140
+ type: 'generate',
141
+ subtype: 'theme',
142
+ options: {
143
+ file: filePath,
144
+ paste: pasteContent,
145
+ fallback: 'executiveBlue',
146
+ appearance: 'quiet',
147
+ },
148
+ });
149
+ console.log(chalk.green('✓ Will import shadcn theme'));
150
+ }
151
+
152
+ if (action === 'add_deploy') {
153
+ const { targets } = await inquirer.prompt([
154
+ {
155
+ type: 'checkbox',
156
+ name: 'targets',
157
+ message: 'Select deployment targets:',
158
+ choices: [
159
+ { name: '🐳 Docker (Dockerfile + docker-compose)', value: 'docker' },
160
+ { name: '▲ Vercel (vercel.json)', value: 'vercel' },
161
+ { name: '🚂 Railway (railway.yaml)', value: 'railway' },
162
+ ],
163
+ },
164
+ ]);
165
+ if (targets.length) {
166
+ steps.push({
167
+ type: 'generate',
168
+ subtype: 'deploy',
169
+ options: {
170
+ target: targets.join(','),
171
+ force: false,
172
+ },
173
+ });
174
+ console.log(chalk.green(`✓ Will generate: ${targets.join(', ')}`));
175
+ }
176
+ }
177
+
178
+ if (action === 'remove') {
179
+ const { resourceType, resourceName } = await inquirer.prompt([
180
+ {
181
+ type: 'list',
182
+ name: 'resourceType',
183
+ message: 'Remove what type?',
184
+ choices: ['page', 'module'],
185
+ },
186
+ {
187
+ type: 'input',
188
+ name: 'resourceName',
189
+ message: 'Name of the resource to remove:',
190
+ },
191
+ ]);
192
+ steps.push({
193
+ type: 'remove',
194
+ resourceType,
195
+ name: resourceName,
196
+ });
197
+ console.log(chalk.yellow(`⚠ Will remove ${resourceType}: ${resourceName}`));
198
+ }
199
+ }
200
+
201
+ if (steps.length === 0) {
202
+ console.log(chalk.gray('No actions selected. Bye!'));
203
+ process.exit(0);
204
+ }
205
+
206
+ // Summary + confirmation
207
+ console.log(chalk.cyan('\n📋 Summary of actions:'));
208
+ for (const step of steps) {
209
+ if (step.type === 'generate') {
210
+ const labelMap = {
211
+ module: 'Backend module',
212
+ page: 'Frontend page',
213
+ theme: 'Shadcn theme',
214
+ deploy: 'Deploy configs',
215
+ };
216
+ const label = labelMap[step.subtype] || step.subtype;
217
+ let detail = '';
218
+ if (step.subtype === 'page' && step.options) {
219
+ detail = ` → ${step.options.route}`;
220
+ }
221
+ if (step.subtype === 'deploy' && step.options) {
222
+ detail = ` → ${step.options.target}`;
223
+ }
224
+ console.log(chalk.white(` • Generate ${label}: "${step.name}"${detail}`));
225
+ } else if (step.type === 'remove') {
226
+ console.log(chalk.yellow(` • Remove ${step.resourceType} "${step.name}"`));
227
+ }
228
+ }
229
+
230
+ const skipConfirm = options.skipConfirm;
231
+ let confirmed = skipConfirm;
232
+ if (!skipConfirm) {
233
+ const { confirm } = await inquirer.prompt([
234
+ {
235
+ type: 'confirm',
236
+ name: 'confirm',
237
+ message: 'Proceed?',
238
+ default: true,
239
+ },
240
+ ]);
241
+ confirmed = confirm;
242
+ }
243
+
244
+ if (!confirmed) {
245
+ console.log(chalk.gray('✖ Cancelled.'));
246
+ process.exit(0);
247
+ }
248
+
249
+ // Execute steps sequentially
250
+ console.log('');
251
+ const originalCwd = process.cwd();
252
+
253
+ for (const step of steps) {
254
+ if (step.type === 'generate') {
255
+ spinner.start(`Generating ${step.subtype}: ${step.name || ''}`);
256
+
257
+ try {
258
+ process.chdir(projectRoot); // Ensure correct cwd for generators
259
+
260
+ switch (step.subtype) {
261
+ case 'module': {
262
+ // moduleGenerator(name, options)
263
+ await moduleGenerator(step.name, { force: false });
264
+ break;
265
+ }
266
+ case 'page': {
267
+ // pageGenerator(name, options)
268
+ await pageGenerator(step.name, step.options);
269
+ break;
270
+ }
271
+ case 'deploy': {
272
+ // deployGenerator(options)
273
+ await deployGenerator(step.options);
274
+ break;
275
+ }
276
+ case 'theme': {
277
+ // themeGenerator(options)
278
+ await themeGenerator(step.options);
279
+ break;
280
+ }
281
+ }
282
+
283
+ spinner.succeed(`${step.subtype.charAt(0).toUpperCase() + step.subtype.slice(1)} "${step.name}" generated`);
284
+ process.chdir(originalCwd);
285
+ } catch (err) {
286
+ process.chdir(originalCwd);
287
+ spinner.fail(`Failed to generate ${step.subtype} "${step.name || ''}": ${err.message}`);
288
+ }
289
+ }
290
+
291
+ if (step.type === 'remove') {
292
+ spinner.start(`Removing ${step.resourceType}: ${step.name}`);
293
+ try {
294
+ await removeCommand(step.resourceType, step.name, { force: false });
295
+ spinner.succeed(`${step.resourceType} "${step.name}" removed`);
296
+ } catch (err) {
297
+ spinner.fail(`Failed to remove ${step.resourceType} "${step.name}": ${err.message}`);
298
+ }
299
+ }
300
+ }
301
+
302
+ console.log(chalk.green.bold('\n✨ Wizard complete!\n'));
303
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { Generator } from '../generator.js';
6
+ import { ResourceDefinition } from '../resource-definition.js';
7
+
8
+ describe('Generator', () => {
9
+ let tempDir;
10
+
11
+ beforeEach(async () => {
12
+ tempDir = path.join(os.tmpdir(), `loom-test-${Math.random().toString(36).slice(2)}`);
13
+ await fs.ensureDir(tempDir);
14
+ // Create a mock project structure
15
+ await fs.ensureDir(path.join(tempDir, 'backend', 'src', 'modules'));
16
+ await fs.writeJSON(path.join(tempDir, 'backend', 'package.json'), { name: 'backend' });
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await fs.remove(tempDir);
21
+ });
22
+
23
+ it('should validate project root', async () => {
24
+ const gen = new Generator({ projectRoot: os.tmpdir() });
25
+ await expect(gen.validateProject()).rejects.toThrow(/Not a MERN Starter Kit backend/);
26
+ });
27
+
28
+ it('should build context correctly', async () => {
29
+ const res = new ResourceDefinition({ name: 'Product' });
30
+ const gen = new Generator({ projectRoot: tempDir });
31
+ gen.resource = res;
32
+ const context = await gen.buildContext();
33
+
34
+ expect(context.resource.name).toBe('Product');
35
+ expect(context.project.backendDir).toBe('backend');
36
+ expect(context.utils.pascal('test')).toBe('Test');
37
+ });
38
+
39
+ it('should generate files (backend)', async () => {
40
+ const res = new ResourceDefinition({
41
+ name: 'Product',
42
+ fields: [{ name: 'name', type: 'string' }]
43
+ });
44
+ const gen = new Generator({ projectRoot: tempDir, verbose: true });
45
+
46
+ const result = await gen.generateFromDefinition(res);
47
+
48
+ expect(result.files.length).toBeGreaterThan(0);
49
+ const modelFile = path.join(tempDir, 'backend/src/modules/product/models/Product.js');
50
+ expect(await fs.pathExists(modelFile)).toBe(true);
51
+
52
+ const content = await fs.readFile(modelFile, 'utf-8');
53
+ expect(content).toContain('ProductSchema');
54
+ expect(content).toContain('AUTO-GENERATED');
55
+ });
56
+
57
+ it('should respect dry-run mode', async () => {
58
+ const res = new ResourceDefinition({ name: 'Product' });
59
+ const gen = new Generator({ projectRoot: tempDir, dryRun: true });
60
+
61
+ const result = await gen.generateFromDefinition(res);
62
+
63
+ const modelFile = path.join(tempDir, 'backend/src/modules/product/models/Product.js');
64
+ expect(await fs.pathExists(modelFile)).toBe(false);
65
+ expect(result.files.find(f => f.action === 'CREATE')).toBeTruthy();
66
+ });
67
+ });