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,350 @@
1
+ #!/usr/bin/env node
2
+
3
+ import inquirer from "inquirer";
4
+ import path from "path";
5
+ import fs from "fs-extra";
6
+ import os from "os";
7
+ import { fileURLToPath } from "url";
8
+ import chalk from "chalk";
9
+ import ora from "ora";
10
+ import { execSync } from "child_process";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+
14
+ const TEMPLATE_REPO = "dellzetter-lang/starter-kit-mern";
15
+ const DEFAULT_BRANCH = "main";
16
+ const GITHUB_TAR_URL = `https://github.com/${TEMPLATE_REPO}/archive/refs/heads/${DEFAULT_BRANCH}.tar.gz`;
17
+
18
+ const PRESET_VARIANTS = [
19
+ "saas",
20
+ "clinic",
21
+ "studio",
22
+ "operations",
23
+ "commerce",
24
+ "custom",
25
+ ];
26
+
27
+ const DESIGN_THEMES = [
28
+ "executiveBlue",
29
+ "clinicSoft",
30
+ "studioElevated",
31
+ "operationsDense",
32
+ "commerceWarm",
33
+ ];
34
+
35
+ const DESIGN_LAYOUTS = [
36
+ "hybridSaas",
37
+ "sidebarWorkspace",
38
+ "topbarPortal",
39
+ "rightRailStudio",
40
+ ];
41
+
42
+ const DATA_TEMPLATES = ["dashboard", "denseOps", "editorial", "commerce"];
43
+
44
+ export default async function initCmd(projectName, options) {
45
+ const spinner = ora({ discardStdin: false });
46
+
47
+ // Resolve project name and directory
48
+ let resolvedProjectName = projectName;
49
+ let parentDir = options.target ? path.resolve(options.target) : process.cwd();
50
+
51
+ // 1. Ask for Project Name if not provided
52
+ if (!resolvedProjectName) {
53
+ const { name } = await inquirer.prompt([
54
+ {
55
+ type: "input",
56
+ name: "name",
57
+ message: "Project name:",
58
+ default: "my-loom-app",
59
+ validate: (input) =>
60
+ /^[a-z0-9-_]+$/i.test(input) ||
61
+ "Use only letters, numbers, dashes, underscores",
62
+ },
63
+ ]);
64
+ resolvedProjectName = name;
65
+ }
66
+
67
+ const outDir = path.join(parentDir, resolvedProjectName);
68
+
69
+ // 2. Check for directory existence
70
+ if (fs.existsSync(outDir)) {
71
+ if (options.force) {
72
+ await fs.remove(outDir);
73
+ } else {
74
+ const files = fs.readdirSync(outDir).filter((f) => f !== "node_modules");
75
+ if (files.length > 0) {
76
+ const { confirm } = await inquirer.prompt([
77
+ {
78
+ type: "confirm",
79
+ name: "confirm",
80
+ message: `Directory ${resolvedProjectName} is not empty. Overwrite?`,
81
+ default: false,
82
+ },
83
+ ]);
84
+ if (!confirm) {
85
+ console.log(chalk.gray("✖ Cancelled."));
86
+ process.exit(0);
87
+ }
88
+ await fs.remove(outDir);
89
+ }
90
+ }
91
+ }
92
+ await fs.ensureDir(outDir);
93
+
94
+ // 3. Smart Interactive Configuration
95
+ // We only ask for options that weren't provided as flags
96
+ const config = { ...options };
97
+
98
+ const questions = [];
99
+
100
+ if (!config.preset) {
101
+ questions.push({
102
+ type: "list",
103
+ name: "preset",
104
+ message: "Choose a preset variant:",
105
+ choices: PRESET_VARIANTS,
106
+ default: "saas",
107
+ });
108
+ }
109
+
110
+ if (!config.theme) {
111
+ questions.push({
112
+ type: "list",
113
+ name: "theme",
114
+ message: "Design theme:",
115
+ choices: DESIGN_THEMES,
116
+ default: (answers) => {
117
+ const p = config.preset || answers.preset;
118
+ const map = {
119
+ saas: "operationsDense",
120
+ clinic: "clinicSoft",
121
+ studio: "studioElevated",
122
+ operations: "operationsDense",
123
+ commerce: "commerceWarm",
124
+ };
125
+ return map[p] || "executiveBlue";
126
+ },
127
+ });
128
+ }
129
+
130
+ if (!config.layout) {
131
+ questions.push({
132
+ type: "list",
133
+ name: "layout",
134
+ message: "Layout shell:",
135
+ choices: DESIGN_LAYOUTS,
136
+ default: (answers) => {
137
+ const p = config.preset || answers.preset;
138
+ const map = {
139
+ saas: "topbarPortal",
140
+ clinic: "sidebarWorkspace",
141
+ studio: "rightRailStudio",
142
+ operations: "sidebarWorkspace",
143
+ commerce: "topbarPortal",
144
+ };
145
+ return map[p] || "hybridSaas";
146
+ },
147
+ });
148
+ }
149
+
150
+ if (!config.architecture) {
151
+ questions.push({
152
+ type: "list",
153
+ name: "architecture",
154
+ message: "Architecture level:",
155
+ choices: [
156
+ { name: "Lightweight (Minimalist)", value: "lightweight" },
157
+ { name: "Moderate (Standard MERN)", value: "moderate" },
158
+ { name: "Advanced (Enterprise Ready)", value: "advanced" },
159
+ ],
160
+ default: "moderate",
161
+ });
162
+ }
163
+
164
+ if (config.install === undefined) {
165
+ questions.push({
166
+ type: "confirm",
167
+ name: "installDeps",
168
+ message: "Install dependencies automatically?",
169
+ default: true,
170
+ });
171
+ }
172
+
173
+ const interactiveAnswers =
174
+ questions.length > 0 ? await inquirer.prompt(questions) : {};
175
+ const finalConfig = { ...config, ...interactiveAnswers };
176
+
177
+ // Set defaults for brand/tagline if not provided
178
+ const presetDefaults = {
179
+ saas: { brand: "MERN Starter", tagline: "Secure app foundation" },
180
+ clinic: { brand: "CareDesk", tagline: "Clinic operations kit" },
181
+ studio: { brand: "StudioBoard", tagline: "Creative production hub" },
182
+ operations: { brand: "OpsGrid", tagline: "Internal operations console" },
183
+ commerce: { brand: "MarketPilot", tagline: "Commerce admin starter" },
184
+ custom: { brand: resolvedProjectName, tagline: "Build something great" },
185
+ };
186
+
187
+ const selectedPreset = finalConfig.preset || "saas";
188
+ finalConfig.brandName =
189
+ finalConfig.brandName || presetDefaults[selectedPreset].brand;
190
+ finalConfig.tagline =
191
+ finalConfig.tagline || presetDefaults[selectedPreset].tagline;
192
+
193
+ // 4. Scaffolding Process
194
+ spinner.start("Downloading template...");
195
+ const tempDir = path.join(os.tmpdir(), `loom-${Date.now()}`);
196
+ await fs.ensureDir(tempDir);
197
+
198
+ try {
199
+ await downloadTemplate(tempDir);
200
+ spinner.succeed("Template downloaded");
201
+ } catch (err) {
202
+ spinner.fail("Failed to download template");
203
+ console.error(chalk.red(err.message));
204
+ process.exit(1);
205
+ }
206
+
207
+ spinner.start("Extracting and customizing...");
208
+ await fs.copy(tempDir, outDir);
209
+ await fs.remove(tempDir);
210
+
211
+ await applyPresetCustomization(outDir, finalConfig);
212
+ await syncProjectDependencies(outDir);
213
+
214
+ // Ensure sanitize.js
215
+ const sanitizePath = path.join(outDir, "frontend/src/utils/sanitize.js");
216
+ if (!fs.existsSync(sanitizePath)) {
217
+ await fs.ensureDir(path.dirname(sanitizePath));
218
+ await fs.writeFile(sanitizePath, sanitizeUtilContent);
219
+ }
220
+ spinner.succeed("Project customized");
221
+
222
+ // 5. Install Dependencies
223
+ if (finalConfig.installDeps || finalConfig.install) {
224
+ console.log(chalk.cyan("\n━> Installing dependencies with pnpm..."));
225
+ try {
226
+ execSync("pnpm install --no-frozen-lockfile", {
227
+ cwd: outDir,
228
+ stdio: "inherit",
229
+ env: { ...process.env, CI: "true" },
230
+ });
231
+ console.log(chalk.green("✓ Dependencies installed\n"));
232
+ } catch (err) {
233
+ console.log(
234
+ chalk.yellow(
235
+ "⚠ Installation failed. You can run 'pnpm install' manually.\n",
236
+ ),
237
+ );
238
+ }
239
+ }
240
+
241
+ // 6. Setup .env
242
+ const backendEnvPath = path.join(outDir, "backend", ".env");
243
+ const backendEnvExamplePath = path.join(outDir, "backend", ".env.example");
244
+ if (!fs.existsSync(backendEnvPath) && fs.existsSync(backendEnvExamplePath)) {
245
+ await fs.copy(backendEnvExamplePath, backendEnvPath);
246
+ }
247
+
248
+ const frontendEnvPath = path.join(outDir, "frontend", ".env");
249
+ const frontendEnvExamplePath = path.join(outDir, "frontend", ".env.example");
250
+ if (!fs.existsSync(frontendEnvPath) && fs.existsSync(frontendEnvExamplePath)) {
251
+ await fs.copy(frontendEnvExamplePath, frontendEnvPath);
252
+ }
253
+
254
+ console.log(chalk.green.bold("✨ Project created successfully!"));
255
+ console.log(chalk.white(`\n cd ${resolvedProjectName}`));
256
+ console.log(chalk.white(` pnpm dev\n`));
257
+ }
258
+
259
+ // ─── Helpers ────────────────────────────────────────────
260
+
261
+ async function downloadTemplate(destDir) {
262
+ const { createGunzip } = await import("zlib");
263
+ const { pipeline } = await import("stream");
264
+ const https = await import("https");
265
+ const { extract } = await import("tar");
266
+
267
+ return new Promise((resolve, reject) => {
268
+ https
269
+ .get(GITHUB_TAR_URL, (res) => {
270
+ if (res.statusCode === 301 || res.statusCode === 302) {
271
+ https
272
+ .get(res.headers.location, (res2) => {
273
+ pipeline(
274
+ res2.pipe(createGunzip()),
275
+ extract({ cwd: destDir, strip: 1 }),
276
+ (err) => {
277
+ if (err) reject(err);
278
+ else resolve();
279
+ },
280
+ );
281
+ })
282
+ .on("error", reject);
283
+ return;
284
+ }
285
+ if (res.statusCode !== 200)
286
+ return reject(new Error(`HTTP ${res.statusCode}`));
287
+ pipeline(
288
+ res.pipe(createGunzip()),
289
+ extract({ cwd: destDir, strip: 1 }),
290
+ (err) => {
291
+ if (err) reject(err);
292
+ else resolve();
293
+ },
294
+ );
295
+ })
296
+ .on("error", reject);
297
+ });
298
+ }
299
+
300
+ async function applyPresetCustomization(projectRoot, config) {
301
+ const presetPath = path.join(
302
+ projectRoot,
303
+ "frontend/src/config/app-preset.js",
304
+ );
305
+ if (!(await fs.pathExists(presetPath))) return;
306
+
307
+ let code = await fs.readFile(presetPath, "utf-8");
308
+ const presetVal =
309
+ config.preset === "custom"
310
+ ? `{ ...baseContent, brand: { name: "${config.brandName}", tagline: "${config.tagline}" }, layout: designLayouts.${config.layout}, theme: designThemes.${config.theme} }`
311
+ : `presetVariants.${config.preset || "saas"}`;
312
+
313
+ code = code.replace(
314
+ /export const appPreset = .+;/,
315
+ `export const appPreset = ${presetVal};`,
316
+ );
317
+ await fs.writeFile(presetPath, code, "utf-8");
318
+ }
319
+
320
+ async function syncProjectDependencies(outDir) {
321
+ const frontendPkgPath = path.join(outDir, "frontend/package.json");
322
+ if (await fs.pathExists(frontendPkgPath)) {
323
+ const pkg = await fs.readJSON(frontendPkgPath);
324
+ const required = {
325
+ "lucide-react": "^1.8.0",
326
+ clsx: "^2.1.1",
327
+ "tailwind-merge": "^3.5.0",
328
+ "class-variance-authority": "^0.7.1",
329
+ sonner: "^2.0.7",
330
+ "@radix-ui/react-dialog": "^1.1.2",
331
+ "@radix-ui/react-slot": "^1.2.4",
332
+ };
333
+ let changed = false;
334
+ for (const [name, version] of Object.entries(required)) {
335
+ if (!pkg.dependencies[name]) {
336
+ pkg.dependencies[name] = version;
337
+ changed = true;
338
+ }
339
+ }
340
+ if (changed) await fs.writeJSON(frontendPkgPath, pkg, { spaces: 2 });
341
+ }
342
+ }
343
+
344
+ const sanitizeUtilContent = `export function sanitizeText(v) { return typeof v === 'string' ? v.replace(/<[^>]*>?/gm, "").trim() : v; }
345
+ export function sanitizeEmail(v) { return typeof v === 'string' ? v.toLowerCase().trim() : v; }
346
+ export function sanitizeUrl(v) { return typeof v === 'string' ? v.trim() : v; }
347
+ export function sanitizePhone(v) { return typeof v === 'string' ? v.replace(/[^+0-9]/g, '').trim() : v; }
348
+ export function sanitizeNumber(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; }
349
+ export function sanitizeBoolean(v) { return typeof v === 'boolean' ? v : (typeof v === 'string' ? v.toLowerCase() === 'true' : !!v); }
350
+ `;
@@ -0,0 +1,298 @@
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
+ import { ResourceDefinition, parseFieldSpec } from '../../core/resource-definition.js';
9
+ import { Generator } from '../../core/generator.js';
10
+
11
+ export default async function makeResourceCmd(name, options) {
12
+ console.warn(
13
+ chalk.yellow(
14
+ "⚠ 'make:resource' is superseded by 'loom generate resource'\n" +
15
+ " (engine-backed: transactional render→inject→validate→commit). This command still works.",
16
+ ),
17
+ );
18
+ const spinner = ora();
19
+ const projectRoot = process.cwd();
20
+
21
+ // Determine resource definition
22
+ let resourceDef;
23
+
24
+ if (options.file) {
25
+ // Load from definition file
26
+ const defPath = path.resolve(projectRoot, options.file);
27
+ if (!(await fs.pathExists(defPath))) {
28
+ console.error(chalk.red(`✖ Resource definition file not found: ${defPath}`));
29
+ process.exit(1);
30
+ }
31
+ const mod = await import(defPath);
32
+ const def = mod.default || mod;
33
+ // Validate
34
+ resourceDef = new ResourceDefinition(def);
35
+ } else if (options.fields) {
36
+ // Build from CLI arguments
37
+ const fields = options.fields.split(';').map(spec => {
38
+ const parsed = parseFieldSpec(spec);
39
+ if (!parsed) {
40
+ throw new Error(`Failed to parse field spec: "${spec}"`);
41
+ }
42
+ return parsed;
43
+ });
44
+
45
+ resourceDef = new ResourceDefinition({
46
+ name: name,
47
+ fields,
48
+ relations: options.relations ? parseRelations(options.relations) : {},
49
+ features: parseFeatures(options),
50
+ ui: { listView: options.ui || 'table' },
51
+ permissions: parsePermissions(options.permissions),
52
+ });
53
+ } else if (options.interactive) {
54
+ // Interactive mode: ask user
55
+ resourceDef = await interactiveResourceWizard(name);
56
+ } else {
57
+ console.error(chalk.red('✖ You must provide either --fields, --file, or --interactive'));
58
+ console.log(chalk.gray(' Examples:'));
59
+ console.log(chalk.gray(` loom make:resource Product --fields "name:str,price:num"`));
60
+ console.log(chalk.gray(` loom make:resource User --file .loom/resources/user.resource.js`));
61
+ process.exit(1);
62
+ }
63
+
64
+ // Validate name consistency
65
+ if (name && resourceDef.name !== name) {
66
+ console.warn(chalk.yellow(`⚠ Resource name "${resourceDef.name}" differs from command arg "${name}". Using definition file name.`));
67
+ }
68
+
69
+ // Show preview if dry-run
70
+ if (options.dryRun) {
71
+ await showPreview(projectRoot, resourceDef, options);
72
+ return;
73
+ }
74
+
75
+ // Confirm if interactive and files would be overwritten
76
+ if (!options.force && !options.nonInteractive) {
77
+ const conflicts = await detectConflicts(projectRoot, resourceDef);
78
+ if (conflicts.length > 0) {
79
+ console.log(chalk.yellow('⚠ The following files would be overwritten:'));
80
+ conflicts.forEach(f => console.log(chalk.gray(` ${f}`)));
81
+ const { confirm } = await inquirer.prompt([
82
+ { type: 'confirm', name: 'confirm', message: 'Continue?', default: false }
83
+ ]);
84
+ if (!confirm) {
85
+ console.log(chalk.gray('✖ cancelled.'));
86
+ process.exit(0);
87
+ }
88
+ }
89
+ }
90
+
91
+ // Generate
92
+ spinner.start(`Generating resource: ${resourceDef.name}`);
93
+
94
+ try {
95
+ const generator = new Generator({
96
+ projectRoot,
97
+ architecture: options.arch || 'moderate',
98
+ dryRun: false,
99
+ verbose: options.verbose,
100
+ force: options.force,
101
+ withFrontend: options.noFrontend ? false : true,
102
+ withTests: options.withTests || false,
103
+ });
104
+
105
+ const result = await generator.generateFromDefinition(resourceDef);
106
+
107
+ spinner.succeed(`Generated ${resourceDef.name} (${result.files.length} files)`);
108
+
109
+ // Show summary
110
+ console.log('');
111
+ console.log(chalk.green('── Summary ──'));
112
+ result.files.forEach(f => {
113
+ const icon = f.action === 'CREATE' ? '+' : f.action === 'UPDATE' ? '~' : '⊘';
114
+ const color = f.action === 'CREATE' ? chalk.green : f.action === 'UPDATE' ? chalk.yellow : chalk.gray;
115
+ console.log(color(`${icon} ${f.output}`));
116
+ });
117
+
118
+ if (result.issues.length) {
119
+ console.log('');
120
+ console.log(chalk.yellow('Issues:'));
121
+ result.issues.forEach(i => {
122
+ const prefix = i.type === 'error' ? '✖' : '⚠';
123
+ const color = i.type === 'error' ? chalk.red : chalk.yellow;
124
+ console.log(color(` ${prefix} ${i.message}`));
125
+ });
126
+ }
127
+
128
+ console.log('');
129
+ console.log(chalk.gray('Next steps:'));
130
+ console.log(chalk.gray(` cd ${projectRoot}`));
131
+ console.log(chalk.gray(' pnpm dev'));
132
+ console.log('');
133
+ } catch (err) {
134
+ spinner.fail('Generation failed');
135
+ console.error(chalk.red(err.message));
136
+ if (options.debug) {
137
+ console.error(err.stack);
138
+ }
139
+ process.exit(1);
140
+ }
141
+ }
142
+
143
+ // ═══════════════════════════════════════════════════════════════════════════════
144
+ // Helper Functions
145
+ // ═══════════════════════════════════════════════════════════════════════════════
146
+
147
+ function parseRelations(str) {
148
+ // Format: "Post:hasMany,Comment:hasMany:through=PostComment"
149
+ // Not fully implemented in v0.2.0 Phase 1 — skip for now
150
+ return { belongsTo: [], hasMany: [] };
151
+ }
152
+
153
+ function parseFeatures(options) {
154
+ const features = {};
155
+ if (options.softDelete) features.softDelete = true;
156
+ if (options.auditLog) features.auditLog = true;
157
+ if (options.auth) features.auth = options.auth;
158
+ if (options.search) features.search = options.search;
159
+ return features;
160
+ }
161
+
162
+ function parsePermissions(str) {
163
+ // Format: "create:admin,manager read:all update:admin,self delete:admin"
164
+ if (!str) return {};
165
+ // Simplified parsing
166
+ const perms = {};
167
+ str.split(' ').forEach(segment => {
168
+ const [action, roles] = segment.split(':');
169
+ if (action && roles) {
170
+ perms[action] = roles.split(',');
171
+ }
172
+ });
173
+ return perms;
174
+ }
175
+
176
+ async function detectConflicts(projectRoot, resourceDef) {
177
+ const conflicts = [];
178
+ const commonFiles = [
179
+ `backend/src/modules/${resourceDef.kebabName}/models/${resourceDef.name}.js`,
180
+ `backend/src/modules/${resourceDef.kebabName}/routes/${resourceDef.name}.routes.js`,
181
+ `frontend/src/pages/admin/${resourceDef.kebabName}/ListPage.jsx`,
182
+ ];
183
+
184
+ for (const file of commonFiles) {
185
+ const fullPath = path.join(projectRoot, file);
186
+ if (await fs.pathExists(fullPath)) {
187
+ conflicts.push(file);
188
+ }
189
+ }
190
+ return conflicts;
191
+ }
192
+
193
+ async function showPreview(projectRoot, resourceDef, options) {
194
+ console.log('');
195
+ console.log(chalk.cyan.bold(`═══ PREVIEW: ${resourceDef.name} ═══`));
196
+ console.log('');
197
+
198
+ const generator = new Generator({
199
+ projectRoot,
200
+ architecture: options.arch || 'moderate',
201
+ dryRun: true,
202
+ verbose: false,
203
+ });
204
+
205
+ try {
206
+ const result = await generator.generateFromDefinition(resourceDef);
207
+
208
+ // Group by action
209
+ const creates = result.files.filter(f => f.action === 'CREATE');
210
+ const updates = result.files.filter(f => f.action === 'UPDATE');
211
+ const skips = result.files.filter(f => f.action === 'SKIP');
212
+
213
+ if (creates.length) {
214
+ console.log(chalk.green('CREATE:'));
215
+ creates.forEach(f => console.log(chalk.gray(` ${f.output}`)));
216
+ }
217
+ if (updates.length) {
218
+ console.log(chalk.yellow('UPDATE:'));
219
+ updates.forEach(f => console.log(chalk.gray(` ${f.output}`)));
220
+ }
221
+ if (skips.length) {
222
+ console.log(chalk.gray('SKIP (already exist):'));
223
+ skips.forEach(f => console.log(chalk.gray(` ${f.output}`)));
224
+ }
225
+
226
+ console.log('');
227
+ console.log(chalk.white(`Total: ${creates.length} new, ${updates.length} updates, ${skips.length} skipped`));
228
+ console.log(chalk.gray('(Use --verbose to see estimated time saved)'));
229
+ console.log('');
230
+ } catch (err) {
231
+ console.error(chalk.red('Preview error:'), err.message);
232
+ if (options.debug) console.error(err.stack);
233
+ process.exit(1);
234
+ }
235
+ }
236
+
237
+ async function interactiveResourceWizard(name) {
238
+ const answers = await inquirer.prompt([
239
+ {
240
+ type: 'input',
241
+ name: 'resourceName',
242
+ message: 'Resource name (PascalCase):',
243
+ default: name ? name.charAt(0).toUpperCase() + name.slice(1) : 'MyResource',
244
+ validate: (v) => /^[A-Z][a-zA-Z0-9]*$/.test(v) || 'Must be PascalCase',
245
+ },
246
+ {
247
+ type: 'input',
248
+ name: 'fields',
249
+ message: 'Fields (semicolon-separated, e.g., "name:str,email:email,age:num")',
250
+ default: 'name:str',
251
+ },
252
+ {
253
+ type: 'list',
254
+ name: 'arch',
255
+ message: 'Architecture:',
256
+ choices: [
257
+ { name: 'Lightweight — inline controllers, minimal files', value: 'lightweight' },
258
+ { name: 'Moderate — full separation (model/service/controller)', value: 'moderate' },
259
+ { name: 'Advanced — plus tests, DTOs, domain logic', value: 'advanced' },
260
+ ],
261
+ default: 'moderate',
262
+ },
263
+ {
264
+ type: 'confirm',
265
+ name: 'withFrontend',
266
+ message: 'Generate frontend pages and components?',
267
+ default: true,
268
+ },
269
+ {
270
+ type: 'confirm',
271
+ name: 'withTests',
272
+ message: 'Generate test files?',
273
+ default: false,
274
+ },
275
+ {
276
+ type: 'confirm',
277
+ name: 'softDelete',
278
+ message: 'Enable soft delete?',
279
+ default: false,
280
+ },
281
+ ]);
282
+
283
+ const fields = answers.fields.split(';').map(spec => {
284
+ const parsed = parseFieldSpec(spec);
285
+ if (!parsed) throw new Error(`Invalid field spec: ${spec}`);
286
+ return parsed;
287
+ });
288
+
289
+ return new ResourceDefinition({
290
+ name: answers.resourceName,
291
+ fields,
292
+ features: {
293
+ softDelete: answers.softDelete,
294
+ auditLog: true,
295
+ },
296
+ ui: { listView: 'table' },
297
+ });
298
+ }
@@ -0,0 +1,57 @@
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
+ const PRESETS = ['saas', 'clinic', 'studio', 'operations', 'commerce'];
10
+
11
+ /**
12
+ * Preset Command — applies a predefined configuration preset
13
+ */
14
+ export default async function presetCmd(presetName) {
15
+ const spinner = ora();
16
+ const projectRoot = process.cwd();
17
+
18
+ const presetPath = path.join(projectRoot, 'frontend/src/config/app-preset.js');
19
+ if (!(await fs.pathExists(presetPath))) {
20
+ console.log(chalk.red('✖ Not a MERN Starter Kit project (missing app-preset.js).'));
21
+ return;
22
+ }
23
+
24
+ let selectedPreset = presetName;
25
+
26
+ if (!selectedPreset || !PRESETS.includes(selectedPreset)) {
27
+ const { choice } = await inquirer.prompt([
28
+ {
29
+ type: 'list',
30
+ name: 'choice',
31
+ message: 'Select a preset to apply:',
32
+ choices: PRESETS,
33
+ },
34
+ ]);
35
+ selectedPreset = choice;
36
+ }
37
+
38
+ spinner.start(`Applying ${selectedPreset} preset...`);
39
+
40
+ try {
41
+ let presetCode = await fs.readFile(presetPath, 'utf-8');
42
+
43
+ // Replace the export line
44
+ presetCode = presetCode.replace(
45
+ /export const appPreset = .+;/,
46
+ `export const appPreset = presetVariants.${selectedPreset};`
47
+ );
48
+
49
+ await fs.writeFile(presetPath, presetCode, 'utf-8');
50
+ spinner.succeed(`Preset "${selectedPreset}" applied successfully.`);
51
+
52
+ console.log(chalk.gray('\nNote: This changed your UI configuration. Run `pnpm dev` to see changes.'));
53
+ } catch (err) {
54
+ spinner.fail('Failed to apply preset');
55
+ console.error(chalk.red(err.message));
56
+ }
57
+ }