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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/cli.js +306 -0
- package/branding.json +8 -0
- package/package.json +72 -0
- package/src/__tests__/cli-smoke.test.js +46 -0
- package/src/blueprint/__tests__/blueprint.test.js +116 -0
- package/src/blueprint/blueprint.js +181 -0
- package/src/blueprint/default.blueprint.json +78 -0
- package/src/blueprint/index.js +10 -0
- package/src/blueprint/loader.js +101 -0
- package/src/blueprint/schema-kit.js +161 -0
- package/src/blueprint/schema.js +78 -0
- package/src/branding/__tests__/branding.test.js +49 -0
- package/src/branding/index.js +48 -0
- package/src/commands/__tests__/commands.test.js +83 -0
- package/src/commands/check.js +71 -0
- package/src/commands/cleanup.js +347 -0
- package/src/commands/customize.js +263 -0
- package/src/commands/doctor.js +84 -0
- package/src/commands/env.js +75 -0
- package/src/commands/finalize.js +68 -0
- package/src/commands/generate/ci-cd.js +378 -0
- package/src/commands/generate/deploy-advanced.js +253 -0
- package/src/commands/generate/deploy.js +99 -0
- package/src/commands/generate/env.template.js +221 -0
- package/src/commands/generate/index.js +7 -0
- package/src/commands/generate/module.js +836 -0
- package/src/commands/generate/page.js +1415 -0
- package/src/commands/generate/test-scaffold.js +279 -0
- package/src/commands/generate/theme.js +67 -0
- package/src/commands/generate-resource.js +133 -0
- package/src/commands/index.js +9 -0
- package/src/commands/init.js +350 -0
- package/src/commands/make/resource.js +298 -0
- package/src/commands/preset.js +57 -0
- package/src/commands/remove.js +170 -0
- package/src/commands/rename.js +54 -0
- package/src/commands/rollback.js +90 -0
- package/src/commands/wizard.js +303 -0
- package/src/core/__tests__/generator.test.js +67 -0
- package/src/core/__tests__/marker-strategy.test.js +57 -0
- package/src/core/__tests__/resource-definition.test.js +32 -0
- package/src/core/generator.js +542 -0
- package/src/core/marker-strategy.js +138 -0
- package/src/core/resource-definition.js +346 -0
- package/src/core/state-tracker.js +67 -0
- package/src/core/template-loader.js +163 -0
- package/src/engine/__tests__/engine.test.js +306 -0
- package/src/engine/index.js +21 -0
- package/src/engine/injector.js +198 -0
- package/src/engine/pipeline.js +138 -0
- package/src/engine/transaction.js +105 -0
- package/src/engine/validator.js +190 -0
- package/src/index.js +4 -0
- package/src/recipes/__tests__/recipe.test.js +128 -0
- package/src/recipes/builtin/module.json +22 -0
- package/src/recipes/builtin/page.json +21 -0
- package/src/recipes/builtin/resource.json +35 -0
- package/src/recipes/condition.js +147 -0
- package/src/recipes/index.js +11 -0
- package/src/recipes/loader.js +95 -0
- package/src/recipes/recipe.js +89 -0
- package/src/recipes/schema.js +47 -0
- package/src/schemas/__tests__/schemas.test.js +67 -0
- package/src/schemas/index.js +18 -0
- package/src/schemas/options.js +38 -0
- package/src/schemas/resource.js +112 -0
- package/src/services/__tests__/reporter.test.js +98 -0
- package/src/services/clock.js +31 -0
- package/src/services/index.js +43 -0
- package/src/services/reporter.js +136 -0
- package/src/templates/resource/api.js.ejs +39 -0
- package/src/templates/resource/components/form.jsx.ejs +81 -0
- package/src/templates/resource/components/table.jsx.ejs +68 -0
- package/src/templates/resource/controller.js.ejs +154 -0
- package/src/templates/resource/hooks.js.ejs +46 -0
- package/src/templates/resource/model.js.ejs +64 -0
- package/src/templates/resource/page-detail.jsx.ejs +55 -0
- package/src/templates/resource/page-form.jsx.ejs +30 -0
- package/src/templates/resource/page-inline.jsx.ejs +74 -0
- package/src/templates/resource/page-modal.jsx.ejs +98 -0
- package/src/templates/resource/page-page.jsx.ejs +99 -0
- package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
- package/src/templates/resource/routes.js.ejs +35 -0
- package/src/templates/resource/service.js.ejs +132 -0
- package/src/templates/resource/test.ejs +71 -0
- package/src/templates/resource/types.ts.ejs +17 -0
- package/src/templates/resource/validator.js.ejs +26 -0
- package/src/templates/snippets/lazy-import.ejs +1 -0
- package/src/templates/snippets/nav-entry.ejs +1 -0
- package/src/templates/snippets/route-entry.ejs +5 -0
- package/src/templates/snippets/route-mount.ejs +1 -0
- package/src/utils/fieldValidators.js +371 -0
- package/src/utils/logging/logger.js +47 -0
- package/src/utils/namingUtils.js +38 -0
- 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
|
+
}
|