vue-feat-cli 26.9.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/dist/cli.js ADDED
@@ -0,0 +1,948 @@
1
+ // src/cli.ts
2
+ import { cac } from "cac";
3
+
4
+ // src/commands/generate-component.ts
5
+ import { cancel, intro, isCancel, log, outro, text } from "@clack/prompts";
6
+ import fs3 from "fs-extra";
7
+ import path3 from "path";
8
+
9
+ // src/config.ts
10
+ import fs from "fs-extra";
11
+ import path from "path";
12
+ var defaultConfig = {
13
+ srcDir: "src",
14
+ featuresDir: "src/features",
15
+ sharedDir: "src/shared",
16
+ alias: "@",
17
+ httpClient: "fetch",
18
+ usesPinia: false,
19
+ usesVueRouter: false,
20
+ usesTanstackQuery: false
21
+ };
22
+ var CONFIG_FILENAME = "vf.config.json";
23
+ async function loadConfig(root) {
24
+ const configPath = path.join(root, CONFIG_FILENAME);
25
+ if (await fs.pathExists(configPath)) {
26
+ const userConfig = await fs.readJson(configPath);
27
+ return { ...defaultConfig, ...userConfig };
28
+ }
29
+ return defaultConfig;
30
+ }
31
+ async function saveConfig(root, config) {
32
+ const configPath = path.join(root, CONFIG_FILENAME);
33
+ await fs.writeJson(configPath, config, { spaces: 2 });
34
+ }
35
+
36
+ // src/utils/case.ts
37
+ var pascalCase = (str) => str.replace(/(^\w|[-_]\w)/g, (m) => m.replace(/[-_]/, "").toUpperCase());
38
+ var camelCase = (str) => {
39
+ const p = pascalCase(str);
40
+ return p.charAt(0).toLowerCase() + p.slice(1);
41
+ };
42
+ var kebabCase = (str) => str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
43
+
44
+ // src/utils/render.ts
45
+ import fs2 from "fs-extra";
46
+ import Handlebars from "handlebars";
47
+ import path2 from "path";
48
+ import prettier from "prettier";
49
+ import { fileURLToPath } from "url";
50
+ var __dirname = path2.dirname(fileURLToPath(import.meta.url));
51
+ var TEMPLATES_DIR = __dirname.endsWith("utils") ? path2.resolve(__dirname, "../templates") : path2.resolve(__dirname, "templates");
52
+ async function resolveTemplatePath(template, root, templatesDir) {
53
+ const safeRoot = path2.resolve(root) + path2.sep;
54
+ const localPath = path2.resolve(root, templatesDir, template);
55
+ if (!localPath.startsWith(safeRoot)) {
56
+ throw new Error(`Security: template path "${template}" escapes the project root.`);
57
+ }
58
+ if (await fs2.pathExists(localPath)) return localPath;
59
+ return path2.join(TEMPLATES_DIR, template);
60
+ }
61
+ async function renderTemplate({
62
+ template,
63
+ outputPath,
64
+ context,
65
+ skipIfExists = false,
66
+ templatesDir,
67
+ root
68
+ }) {
69
+ const templatePath = templatesDir && root ? await resolveTemplatePath(template, root, templatesDir) : path2.join(TEMPLATES_DIR, template);
70
+ const raw = await fs2.readFile(templatePath, "utf-8");
71
+ const compiled = Handlebars.compile(raw);
72
+ let output = compiled(context);
73
+ const parser = outputPath.endsWith(".vue") ? "vue" : "typescript";
74
+ try {
75
+ output = await prettier.format(output, { parser, semi: false, singleQuote: true });
76
+ } catch {
77
+ }
78
+ await fs2.ensureDir(path2.dirname(outputPath));
79
+ if (await fs2.pathExists(outputPath)) {
80
+ if (skipIfExists) return null;
81
+ throw new Error(`File already exists: ${outputPath}`);
82
+ }
83
+ await fs2.writeFile(outputPath, output, "utf-8");
84
+ return outputPath;
85
+ }
86
+
87
+ // src/commands/generate-component.ts
88
+ async function generateComponent(name, options) {
89
+ const segments = name.split("/").filter(Boolean);
90
+ const componentName = segments.pop();
91
+ const subPath = segments.join(path3.sep);
92
+ const Name = pascalCase(componentName);
93
+ intro(`\u{1F9F1} Generating component: ${Name}.vue`);
94
+ const root = process.cwd();
95
+ const config = await loadConfig(root);
96
+ let feature = options.feature;
97
+ if (feature === void 0) {
98
+ const answer = await text({
99
+ message: "Which feature does this component belong to? (leave empty for shared)",
100
+ placeholder: "e.g. acme",
101
+ defaultValue: ""
102
+ });
103
+ if (isCancel(answer)) {
104
+ cancel("Operation cancelled.");
105
+ process.exit(0);
106
+ }
107
+ feature = answer.trim();
108
+ }
109
+ let basePath;
110
+ if (feature) {
111
+ feature = kebabCase(feature);
112
+ const featurePath = path3.join(root, config.featuresDir, feature);
113
+ if (!await fs3.pathExists(featurePath)) {
114
+ log.error(`Feature "${feature}" not found. Run "vf generate:feat ${feature}" first.`);
115
+ process.exit(1);
116
+ }
117
+ basePath = path3.join(featurePath, "components", subPath);
118
+ } else {
119
+ basePath = path3.join(root, config.sharedDir, "components", subPath);
120
+ }
121
+ const context = {
122
+ name: kebabCase(componentName),
123
+ Name
124
+ };
125
+ const outputPath = path3.join(basePath, `${Name}.vue`);
126
+ const created = await renderTemplate({
127
+ template: "component/Component.vue.hbs",
128
+ outputPath,
129
+ context,
130
+ templatesDir: config.templatesDir,
131
+ root
132
+ });
133
+ outro(`Created: ${path3.relative(root, created)}`);
134
+ }
135
+
136
+ // src/commands/generate-composable.ts
137
+ import { cancel as cancel2, intro as intro2, isCancel as isCancel2, log as log2, outro as outro2, text as text2 } from "@clack/prompts";
138
+ import fs4 from "fs-extra";
139
+ import path4 from "path";
140
+ async function generateComposable(name, options) {
141
+ intro2(`\u{1F9E9} Generating composable: use${pascalCase(name)}`);
142
+ const root = process.cwd();
143
+ const config = await loadConfig(root);
144
+ let feature = options.feature;
145
+ if (feature === void 0) {
146
+ const answer = await text2({
147
+ message: "Which feature does this composable belong to? (leave empty for shared)",
148
+ placeholder: "e.g. acme",
149
+ defaultValue: ""
150
+ });
151
+ if (isCancel2(answer)) {
152
+ cancel2("Operation cancelled.");
153
+ process.exit(0);
154
+ }
155
+ feature = answer.trim();
156
+ }
157
+ const context = {
158
+ name: kebabCase(name),
159
+ Name: pascalCase(name)
160
+ };
161
+ let basePath;
162
+ if (feature) {
163
+ feature = kebabCase(feature);
164
+ const featurePath = path4.join(root, config.featuresDir, feature);
165
+ if (!await fs4.pathExists(featurePath)) {
166
+ log2.error(`Feature "${feature}" not found. Run "vf generate:feat ${feature}" first.`);
167
+ process.exit(1);
168
+ }
169
+ basePath = path4.join(featurePath, "composables");
170
+ } else {
171
+ basePath = path4.join(root, config.sharedDir, "composables");
172
+ }
173
+ const outputPath = path4.join(basePath, `use${pascalCase(name)}.ts`);
174
+ const created = await renderTemplate({
175
+ template: "composable/composable.ts.hbs",
176
+ outputPath,
177
+ context,
178
+ templatesDir: config.templatesDir,
179
+ root
180
+ });
181
+ outro2(`Created: ${path4.relative(root, created)}`);
182
+ }
183
+
184
+ // src/commands/generate-feat.ts
185
+ import { intro as intro3, log as log5, outro as outro3 } from "@clack/prompts";
186
+ import fs8 from "fs-extra";
187
+ import path8 from "path";
188
+
189
+ // src/utils/http-client.ts
190
+ import { log as log3 } from "@clack/prompts";
191
+ import fs5 from "fs-extra";
192
+ import path5 from "path";
193
+ async function ensureHttpClient(root, config) {
194
+ const outputPath = path5.join(root, config.sharedDir, "http", "client.ts");
195
+ const template = config.httpClient === "axios" ? "shared/http-client-axios.ts.hbs" : "shared/http-client-fetch.ts.hbs";
196
+ const created = await renderTemplate({
197
+ template,
198
+ outputPath,
199
+ context: {},
200
+ skipIfExists: true
201
+ });
202
+ if (created) {
203
+ log3.success(`Created: ${path5.relative(root, created)}`);
204
+ } else {
205
+ log3.info(`HTTP client already exists at: ${path5.relative(root, outputPath)} (skipped)`);
206
+ }
207
+ await ensureEnvExample(root);
208
+ }
209
+ async function ensureEnvExample(root) {
210
+ const envPath = path5.join(root, ".env.example");
211
+ if (await fs5.pathExists(envPath)) return;
212
+ await fs5.writeFile(envPath, "VITE_API_BASE_URL=http://localhost:3333\n", "utf-8");
213
+ log3.success(`Created: ${path5.relative(root, envPath)}`);
214
+ }
215
+
216
+ // src/utils/package.ts
217
+ import fs6 from "fs-extra";
218
+ import path6 from "path";
219
+ async function hasDependency(root, name) {
220
+ const pkgPath = path6.join(root, "package.json");
221
+ if (!await fs6.pathExists(pkgPath)) return false;
222
+ const pkg = await fs6.readJson(pkgPath);
223
+ return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
224
+ }
225
+
226
+ // src/utils/router.ts
227
+ import { log as log4 } from "@clack/prompts";
228
+ import fs7 from "fs-extra";
229
+ import path7 from "path";
230
+ function insertAfterLastImport(content, importLine) {
231
+ const lines = content.split("\n");
232
+ let lastImportIdx = -1;
233
+ for (let i = 0; i < lines.length; i++) {
234
+ if (lines[i].trimStart().startsWith("import ")) lastImportIdx = i;
235
+ }
236
+ if (lastImportIdx === -1) return importLine + "\n" + content;
237
+ lines.splice(lastImportIdx + 1, 0, importLine);
238
+ return lines.join("\n");
239
+ }
240
+ function insertBeforeRoutesClose(content, spread) {
241
+ const routesKeyword = content.indexOf("routes:");
242
+ if (routesKeyword === -1) return content;
243
+ const openBracket = content.indexOf("[", routesKeyword);
244
+ if (openBracket === -1) return content;
245
+ let depth = 0;
246
+ let closeIdx = -1;
247
+ for (let i = openBracket; i < content.length; i++) {
248
+ if (content[i] === "[") depth++;
249
+ else if (content[i] === "]") {
250
+ depth--;
251
+ if (depth === 0) {
252
+ closeIdx = i;
253
+ break;
254
+ }
255
+ }
256
+ }
257
+ if (closeIdx === -1) return content;
258
+ const inner = content.slice(openBracket + 1, closeIdx);
259
+ let routesLineStart = routesKeyword;
260
+ while (routesLineStart > 0 && content[routesLineStart - 1] !== "\n") routesLineStart--;
261
+ const routesIndent = content.slice(routesLineStart, routesKeyword).match(/^(\s*)/)?.[1] ?? " ";
262
+ const innerIndent = `${routesIndent} `;
263
+ if (inner.trim() === "") {
264
+ return content.slice(0, openBracket + 1) + `
265
+ ${innerIndent}${spread},
266
+ ${routesIndent}` + content.slice(closeIdx);
267
+ }
268
+ let lineStart = closeIdx;
269
+ while (lineStart > 0 && content[lineStart - 1] !== "\n") lineStart--;
270
+ const before = content.substring(0, lineStart);
271
+ const lastContentLine = before.split("\n").filter((l) => l.trim()).pop() ?? "";
272
+ const indent = lastContentLine.match(/^(\s+)/)?.[1] ?? innerIndent;
273
+ return before + `${indent}${spread},
274
+ ` + content.substring(lineStart);
275
+ }
276
+ async function registerRouteInRouter(root, config, featureName, nameCamel) {
277
+ const routerPath = path7.join(root, config.srcDir, "router", "index.ts");
278
+ if (!await fs7.pathExists(routerPath)) {
279
+ log4.warn(`Router not found at ${path7.relative(root, routerPath)} \u2014 skipping route registration.`);
280
+ return;
281
+ }
282
+ let content = await fs7.readFile(routerPath, "utf-8");
283
+ if (content.includes(`${nameCamel}Routes`)) {
284
+ log4.info(`Route for "${featureName}" is already registered in the router (skipped).`);
285
+ return;
286
+ }
287
+ const routerDir = path7.dirname(routerPath);
288
+ const featureRoutesPath = path7.join(root, config.featuresDir, featureName, "routes");
289
+ const relImport = path7.relative(routerDir, featureRoutesPath).replace(/\\/g, "/");
290
+ const importLine = `import { ${nameCamel}Routes } from '${relImport.startsWith(".") ? relImport : `./${relImport}`}'`;
291
+ content = insertAfterLastImport(content, importLine);
292
+ content = insertBeforeRoutesClose(content, `...${nameCamel}Routes`);
293
+ await fs7.writeFile(routerPath, content, "utf-8");
294
+ log4.success(`Registered route in ${path7.relative(root, routerPath)}`);
295
+ }
296
+
297
+ // src/commands/generate-feat.ts
298
+ async function generateFeat(name, options = {}) {
299
+ intro3(`\u{1F680} Scaffolding feature: ${name}`);
300
+ const root = process.cwd();
301
+ const config = await loadConfig(root);
302
+ const featureName = kebabCase(name);
303
+ const base = path8.join(root, config.featuresDir, featureName);
304
+ const context = {
305
+ name: featureName,
306
+ Name: pascalCase(name),
307
+ nameCamel: camelCase(name),
308
+ alias: config.alias,
309
+ usesVueRouter: config.usesVueRouter
310
+ };
311
+ if (config.usesPinia && !await hasDependency(root, "pinia")) {
312
+ log5.warn('"pinia" not found in package.json \u2014 run "npm install pinia" and set up createPinia() in main.ts before using the store.');
313
+ }
314
+ const storeTemplate = config.usesPinia ? "feature/store.ts.hbs" : "feature/store-composable.ts.hbs";
315
+ const serviceTemplate = options.withCrud ? "feature/service-crud.ts.hbs" : "feature/service.ts.hbs";
316
+ const serviceComposableTemplate = options.withCrud ? "feature/service-composable-crud.ts.hbs" : "feature/service-composable.ts.hbs";
317
+ const pageComposableTemplate = options.withCrud ? "feature/page-composable-crud.ts.hbs" : "feature/page-composable.ts.hbs";
318
+ const typesTemplate = options.withCrud ? "feature/types-crud.ts.hbs" : "feature/types.ts.hbs";
319
+ const PascalName = pascalCase(name);
320
+ const files = [
321
+ { template: serviceTemplate, out: path8.join(base, "services", `${featureName}.service.ts`) },
322
+ { template: serviceComposableTemplate, out: path8.join(base, "composables", `use${PascalName}Service.ts`) },
323
+ { template: pageComposableTemplate, out: path8.join(base, "composables", `use${PascalName}Page.ts`) },
324
+ { template: storeTemplate, out: path8.join(base, "stores", `${featureName}.store.ts`) },
325
+ { template: typesTemplate, out: path8.join(base, "types", `${featureName}.types.ts`) },
326
+ { template: "feature/index.ts.hbs", out: path8.join(base, "index.ts") }
327
+ ];
328
+ if (config.usesVueRouter) {
329
+ files.push(
330
+ { template: "feature/routes.ts.hbs", out: path8.join(base, "routes.ts") },
331
+ { template: "feature/View.vue.hbs", out: path8.join(base, "views", `${PascalName}View.vue`) }
332
+ );
333
+ }
334
+ for (const file of files) {
335
+ const created = await renderTemplate({ ...file, outputPath: file.out, context, templatesDir: config.templatesDir, root });
336
+ log5.success(`Created: ${path8.relative(root, created)}`);
337
+ }
338
+ await fs8.ensureDir(path8.join(base, "components"));
339
+ if (!config.usesVueRouter) {
340
+ await fs8.ensureDir(path8.join(base, "views"));
341
+ }
342
+ await ensureHttpClient(root, config);
343
+ if (options.registerRoute) {
344
+ if (!config.usesVueRouter) {
345
+ log5.warn('--register-route requires "usesVueRouter: true" in vf.config.json (skipped).');
346
+ } else {
347
+ await registerRouteInRouter(root, config, featureName, context.nameCamel);
348
+ }
349
+ }
350
+ outro3(`Feature "${featureName}" created at ${path8.relative(root, base)}`);
351
+ }
352
+
353
+ // src/commands/generate-service.ts
354
+ import { cancel as cancel3, intro as intro4, isCancel as isCancel3, log as log6, outro as outro4, text as text3 } from "@clack/prompts";
355
+ import fs9 from "fs-extra";
356
+ import path9 from "path";
357
+ async function generateService(name, options) {
358
+ intro4(`\u{1F50C} Generating service: ${kebabCase(name)}.service.ts`);
359
+ const root = process.cwd();
360
+ const config = await loadConfig(root);
361
+ await ensureHttpClient(root, config);
362
+ let feature = options.feature;
363
+ if (!feature) {
364
+ const answer = await text3({
365
+ message: "Which feature does this service belong to?",
366
+ placeholder: "e.g. acme"
367
+ });
368
+ if (isCancel3(answer)) {
369
+ cancel3("Operation cancelled.");
370
+ process.exit(0);
371
+ }
372
+ feature = answer;
373
+ }
374
+ feature = kebabCase(feature);
375
+ const featurePath = path9.join(root, config.featuresDir, feature);
376
+ if (!await fs9.pathExists(featurePath)) {
377
+ log6.error(`Feature "${feature}" not found. Run "vf generate:feat ${feature}" first.`);
378
+ process.exit(1);
379
+ }
380
+ const resourceName = kebabCase(name);
381
+ const context = {
382
+ name: resourceName,
383
+ Name: pascalCase(name),
384
+ nameCamel: camelCase(name),
385
+ alias: config.alias
386
+ };
387
+ const typesPath = path9.join(featurePath, "types", `${resourceName}.types.ts`);
388
+ const typesCreated = await renderTemplate({
389
+ template: "feature/types.ts.hbs",
390
+ outputPath: typesPath,
391
+ context,
392
+ skipIfExists: true,
393
+ templatesDir: config.templatesDir,
394
+ root
395
+ });
396
+ if (typesCreated) {
397
+ log6.success(`Created: ${path9.relative(root, typesCreated)}`);
398
+ } else {
399
+ log6.info(`Types already exist at: ${path9.relative(root, typesPath)} (skipped)`);
400
+ }
401
+ const servicePath = path9.join(featurePath, "services", `${resourceName}.service.ts`);
402
+ const created = await renderTemplate({
403
+ template: "feature/service.ts.hbs",
404
+ outputPath: servicePath,
405
+ context,
406
+ templatesDir: config.templatesDir,
407
+ root
408
+ });
409
+ outro4(`Created: ${path9.relative(root, created)}`);
410
+ }
411
+
412
+ // src/commands/generate-store.ts
413
+ import { cancel as cancel4, intro as intro5, isCancel as isCancel4, log as log7, outro as outro5, text as text4 } from "@clack/prompts";
414
+ import fs10 from "fs-extra";
415
+ import path10 from "path";
416
+ async function generateStore(name, options) {
417
+ intro5(`\u{1F5C3}\uFE0F Generating store: ${kebabCase(name)}.store.ts`);
418
+ const root = process.cwd();
419
+ const config = await loadConfig(root);
420
+ let feature = options.feature;
421
+ if (!feature) {
422
+ const answer = await text4({
423
+ message: "Which feature does this store belong to?",
424
+ placeholder: "e.g. acme"
425
+ });
426
+ if (isCancel4(answer)) {
427
+ cancel4("Operation cancelled.");
428
+ process.exit(0);
429
+ }
430
+ feature = answer;
431
+ }
432
+ feature = kebabCase(feature);
433
+ const featurePath = path10.join(root, config.featuresDir, feature);
434
+ if (!await fs10.pathExists(featurePath)) {
435
+ log7.error(`Feature "${feature}" not found. Run "vf generate:feat ${feature}" first.`);
436
+ process.exit(1);
437
+ }
438
+ const resourceName = kebabCase(name);
439
+ const context = {
440
+ name: resourceName,
441
+ Name: pascalCase(name),
442
+ nameCamel: camelCase(name)
443
+ };
444
+ const typesPath = path10.join(featurePath, "types", `${resourceName}.types.ts`);
445
+ const typesCreated = await renderTemplate({
446
+ template: "feature/types.ts.hbs",
447
+ outputPath: typesPath,
448
+ context,
449
+ skipIfExists: true,
450
+ templatesDir: config.templatesDir,
451
+ root
452
+ });
453
+ if (typesCreated) {
454
+ log7.success(`Created: ${path10.relative(root, typesCreated)}`);
455
+ } else {
456
+ log7.info(`Types already exist at: ${path10.relative(root, typesPath)} (skipped)`);
457
+ }
458
+ const storeTemplate = config.usesPinia ? "feature/store.ts.hbs" : "feature/store-composable.ts.hbs";
459
+ if (!config.usesPinia) {
460
+ log7.warn("Pinia not enabled in vf.config.json. Generating store with reactive() fallback.");
461
+ } else if (!await hasDependency(root, "pinia")) {
462
+ log7.warn('"pinia" not found in package.json \u2014 run "npm install pinia" and set up createPinia() in main.ts.');
463
+ }
464
+ const storePath = path10.join(featurePath, "stores", `${resourceName}.store.ts`);
465
+ const created = await renderTemplate({
466
+ template: storeTemplate,
467
+ outputPath: storePath,
468
+ context,
469
+ templatesDir: config.templatesDir,
470
+ root
471
+ });
472
+ outro5(`Created: ${path10.relative(root, created)}`);
473
+ }
474
+
475
+ // src/commands/help.ts
476
+ import { isCancel as isCancel5, select } from "@clack/prompts";
477
+
478
+ // src/data/help-data.ts
479
+ var COMMAND_DOCS = {
480
+ init: {
481
+ name: "init",
482
+ description: "Configure vue-feat-cli in the current project",
483
+ longDescription: "Detects installed dependencies (Pinia, Vue Router, Axios, TanStack Query) and the TypeScript path alias from tsconfig.json. Saves preferences to vf.config.json and creates the HTTP client at src/shared/http/client.ts. All subsequent generators read this file.",
484
+ usage: "vf init [options]",
485
+ options: [
486
+ {
487
+ flag: "--yes",
488
+ description: "Accept all detected defaults without interactive prompts.",
489
+ note: "Useful for CI/CD \u2014 uses detected dependencies as the answer to each question."
490
+ }
491
+ ],
492
+ examples: [
493
+ { cmd: "vf init", note: "Interactive mode \u2014 detects and confirms each setting" },
494
+ { cmd: "vf init --yes", note: "Accept everything automatically, no prompts" }
495
+ ],
496
+ related: ["templates:init"]
497
+ },
498
+ "generate:feat": {
499
+ name: "generate:feat",
500
+ alias: "g:feat",
501
+ description: "Scaffold a complete feature module",
502
+ longDescription: "Generates the full structure of a feature (service, composables, store, types, barrel export and, when Vue Router is enabled, view and routes). Behavior is driven by vf.config.json \u2014 Pinia uses defineStore, without a router routes.ts and views/ are omitted.",
503
+ usage: "vf generate:feat <name> [options]",
504
+ options: [
505
+ {
506
+ flag: "--with-crud",
507
+ description: "Generate full CRUD methods (list, getById, create, update, delete) in the service and composables.",
508
+ note: "Also expands types with CreateDto and UpdateDto."
509
+ },
510
+ {
511
+ flag: "--register-route",
512
+ description: "Auto-register the feature route in the router (inserts import + spread into the routes array).",
513
+ note: "Requires usesVueRouter: true in vf.config.json and src/router/index.ts to exist."
514
+ }
515
+ ],
516
+ examples: [
517
+ { cmd: "vf g:feat Product", note: "Basic feature without CRUD" },
518
+ { cmd: "vf g:feat Order --with-crud", note: "Feature with full CRUD methods" },
519
+ { cmd: "vf g:feat Category --with-crud --register-route", note: "CRUD + route auto-registered in the router" },
520
+ { cmd: "vf generate:feat UserProfile", note: "Using the full command (equivalent to the alias)" }
521
+ ],
522
+ related: ["generate:service", "generate:store", "generate:composable"]
523
+ },
524
+ "generate:composable": {
525
+ name: "generate:composable",
526
+ alias: "g:composable",
527
+ description: "Create a composable inside a feature or in shared",
528
+ longDescription: "Generates a TypeScript composable. If --feature is omitted, an interactive prompt lists existing features to choose from. Skipping the selection places the composable in src/shared/composables/.",
529
+ usage: "vf generate:composable <name> [options]",
530
+ options: [
531
+ {
532
+ flag: "--feature <feature>",
533
+ description: "Target feature for the composable.",
534
+ note: "If omitted, the prompt lists available features. Leave blank to create in src/shared/composables/."
535
+ }
536
+ ],
537
+ examples: [
538
+ { cmd: "vf g:composable useFilters --feature product", note: "Composable inside the product feature" },
539
+ { cmd: "vf g:composable useTheme", note: "Interactive prompt to choose the target" },
540
+ { cmd: "vf g:composable usePagination --feature order", note: "Composable in the order feature" }
541
+ ],
542
+ related: ["generate:feat", "generate:service"]
543
+ },
544
+ "generate:service": {
545
+ name: "generate:service",
546
+ alias: "g:service",
547
+ description: "Create a service inside an existing feature",
548
+ longDescription: "Adds a service file and its types file to an existing feature. Ensures the HTTP client is present at src/shared/http/client.ts before creating the service.",
549
+ usage: "vf generate:service <name> [options]",
550
+ options: [
551
+ {
552
+ flag: "--feature <feature>",
553
+ description: "Target feature (prompted interactively if omitted)."
554
+ }
555
+ ],
556
+ examples: [
557
+ { cmd: "vf g:service product --feature product", note: "Add a service to the product feature" },
558
+ { cmd: "vf g:service auth --feature user", note: "Auth service inside the user feature" }
559
+ ],
560
+ related: ["generate:feat", "generate:store"]
561
+ },
562
+ "generate:component": {
563
+ name: "generate:component",
564
+ alias: "g:component",
565
+ description: "Create a Vue component inside a feature or in shared",
566
+ longDescription: "Generates a single-file component (.vue). Supports nested paths in the name to create subdirectories automatically. If --feature is omitted, the component is placed in src/shared/components/.",
567
+ usage: "vf generate:component <name> [options]",
568
+ options: [
569
+ {
570
+ flag: "--feature <feature>",
571
+ description: "Target feature. If omitted, the component is created in src/shared/components/."
572
+ }
573
+ ],
574
+ examples: [
575
+ { cmd: "vf g:component ProductCard --feature product", note: "Component inside the product feature" },
576
+ { cmd: "vf g:component cards/ProductCard", note: "Nested path creates a cards/ subdirectory" },
577
+ { cmd: "vf g:component BaseButton", note: "Shared component in src/shared/components/" }
578
+ ],
579
+ related: ["generate:feat", "generate:composable"]
580
+ },
581
+ "generate:store": {
582
+ name: "generate:store",
583
+ alias: "g:store",
584
+ description: "Create a store inside an existing feature",
585
+ longDescription: "Adds a store to an existing feature. Uses defineStore (Pinia) when usesPinia: true in vf.config.json, or reactive() as a fallback when Pinia is not configured.",
586
+ usage: "vf generate:store <name> [options]",
587
+ options: [
588
+ {
589
+ flag: "--feature <feature>",
590
+ description: "Target feature (prompted interactively if omitted)."
591
+ }
592
+ ],
593
+ examples: [
594
+ { cmd: "vf g:store product --feature product", note: "Pinia store for the product feature" },
595
+ { cmd: "vf g:store cart --feature checkout", note: "Store for the checkout feature" }
596
+ ],
597
+ related: ["generate:feat", "generate:service"]
598
+ },
599
+ "templates:init": {
600
+ name: "templates:init",
601
+ description: "Copy default templates to .vf/templates/ for local customization",
602
+ longDescription: "Exports all built-in Handlebars templates to .vf/templates/, allowing per-project code generation customization. Creates CONTEXT.md with documentation for all available template variables. Updates vf.config.json with templatesDir if not already set.",
603
+ usage: "vf templates:init",
604
+ options: [],
605
+ examples: [
606
+ { cmd: "vf templates:init", note: "Copy all templates to .vf/templates/" }
607
+ ],
608
+ related: ["init"]
609
+ }
610
+ };
611
+ var COMMAND_ALIASES = {
612
+ "g:feat": "generate:feat",
613
+ "g:composable": "generate:composable",
614
+ "g:service": "generate:service",
615
+ "g:component": "generate:component",
616
+ "g:store": "generate:store"
617
+ };
618
+
619
+ // src/commands/help.ts
620
+ var b = (s) => `\x1B[1m${s}\x1B[0m`;
621
+ var dim = (s) => `\x1B[2m${s}\x1B[0m`;
622
+ var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
623
+ var green = (s) => `\x1B[32m${s}\x1B[0m`;
624
+ var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
625
+ function resolveDoc(name) {
626
+ const canonical = COMMAND_ALIASES[name] ?? name;
627
+ return COMMAND_DOCS[canonical];
628
+ }
629
+ function printCommandDoc(doc) {
630
+ console.log();
631
+ console.log(` ${b(doc.name)}${doc.alias ? ` ${dim(`alias: ${doc.alias}`)}` : ""}`);
632
+ console.log();
633
+ console.log(` ${doc.longDescription}`);
634
+ console.log();
635
+ console.log(` ${b("Usage")}`);
636
+ console.log(` ${cyan(doc.usage)}`);
637
+ console.log();
638
+ if (doc.options.length > 0) {
639
+ console.log(` ${b("Options")}`);
640
+ for (const opt of doc.options) {
641
+ console.log(` ${yellow(opt.flag.padEnd(26))} ${opt.description}`);
642
+ if (opt.note) {
643
+ console.log(` ${"".padEnd(28)}${dim(opt.note)}`);
644
+ }
645
+ }
646
+ console.log();
647
+ }
648
+ console.log(` ${b("Examples")}`);
649
+ for (const ex of doc.examples) {
650
+ console.log(` ${green("$")} ${ex.cmd}`);
651
+ console.log(` ${dim(ex.note)}`);
652
+ }
653
+ console.log();
654
+ if (doc.related.length > 0) {
655
+ console.log(` ${b("See also")} ${doc.related.map(cyan).join(" ")}`);
656
+ console.log();
657
+ }
658
+ }
659
+ function printOverview() {
660
+ console.log();
661
+ console.log(` ${b("vue-feat-cli")} ${dim("\u2014 opinionated Vue 3 feature scaffolder")}`);
662
+ console.log();
663
+ console.log(` ${b("Commands")}`);
664
+ for (const doc of Object.values(COMMAND_DOCS)) {
665
+ const padded = doc.name.length + (doc.alias ? doc.alias.length + 3 : 0);
666
+ const gap = " ".repeat(Math.max(2, 36 - padded));
667
+ console.log(` ${cyan(doc.name)}${doc.alias ? ` ${dim(`(${doc.alias})`)}` : ""}${gap}${doc.description}`);
668
+ }
669
+ console.log();
670
+ console.log(` ${dim("Run")} ${cyan("vf help <command>")} ${dim("for detailed usage and examples.")}`);
671
+ console.log();
672
+ }
673
+ async function help(command) {
674
+ if (command) {
675
+ const doc2 = resolveDoc(command);
676
+ if (!doc2) {
677
+ console.log();
678
+ console.log(` ${b("Unknown command:")} "${command}"`);
679
+ console.log(` Run ${cyan("vf help")} to see all available commands.`);
680
+ console.log();
681
+ process.exit(1);
682
+ }
683
+ printCommandDoc(doc2);
684
+ return;
685
+ }
686
+ printOverview();
687
+ console.log(` ${dim("Use")} ${dim("\u2191\u2193")} ${dim("to navigate,")} ${dim("Enter")} ${dim("to select,")} ${dim("Esc")} ${dim("to exit.")}`);
688
+ console.log();
689
+ const choice = await select({
690
+ message: "Open detailed help for a command?",
691
+ options: [
692
+ ...Object.values(COMMAND_DOCS).map((d) => ({
693
+ value: d.name,
694
+ label: d.name,
695
+ hint: d.alias ?? void 0
696
+ })),
697
+ { value: "__exit__", label: "Exit" }
698
+ ]
699
+ });
700
+ if (isCancel5(choice) || choice === "__exit__") {
701
+ console.log();
702
+ return;
703
+ }
704
+ const doc = COMMAND_DOCS[choice];
705
+ if (doc) printCommandDoc(doc);
706
+ }
707
+
708
+ // src/commands/init.ts
709
+ import { cancel as cancel5, confirm, intro as intro6, isCancel as isCancel6, log as log8, outro as outro6, select as select2, text as text5 } from "@clack/prompts";
710
+ import fs11 from "fs-extra";
711
+ import path11 from "path";
712
+ async function detectAlias(root) {
713
+ const tsconfigPath = path11.join(root, "tsconfig.json");
714
+ if (!await fs11.pathExists(tsconfigPath)) return null;
715
+ try {
716
+ const tsconfig = await fs11.readJson(tsconfigPath);
717
+ const paths = tsconfig.compilerOptions?.paths ?? {};
718
+ for (const key of Object.keys(paths)) {
719
+ const match = key.match(/^(.+)\/\*$/);
720
+ if (match) return match[1];
721
+ }
722
+ } catch {
723
+ return null;
724
+ }
725
+ return null;
726
+ }
727
+ function exitIfCancelled(value) {
728
+ if (isCancel6(value)) {
729
+ cancel5("Operation cancelled.");
730
+ process.exit(0);
731
+ }
732
+ return value;
733
+ }
734
+ async function init(options = {}) {
735
+ const { yes = false } = options;
736
+ intro6("\u2699\uFE0F Configuring vue-feat-cli");
737
+ const root = process.cwd();
738
+ const configPath = path11.join(root, CONFIG_FILENAME);
739
+ if (await fs11.pathExists(configPath)) {
740
+ if (!yes) {
741
+ const overwrite = exitIfCancelled(
742
+ await confirm({
743
+ message: `${CONFIG_FILENAME} already exists. Overwrite?`,
744
+ initialValue: false
745
+ })
746
+ );
747
+ if (!overwrite) {
748
+ cancel5("Operation cancelled.");
749
+ process.exit(0);
750
+ }
751
+ } else {
752
+ log8.info(`${CONFIG_FILENAME} already exists \u2014 overwriting (--yes).`);
753
+ }
754
+ }
755
+ log8.step("Detecting project dependencies...");
756
+ const hasPinia = await hasDependency(root, "pinia");
757
+ const hasVueRouter = await hasDependency(root, "vue-router");
758
+ const hasTanstackQuery = await hasDependency(root, "@tanstack/vue-query");
759
+ const hasAxios = await hasDependency(root, "axios");
760
+ const detectedAlias = await detectAlias(root);
761
+ log8.info(`Pinia: ${hasPinia ? "\u2705 found" : "\u274C not found"}`);
762
+ log8.info(`Vue Router: ${hasVueRouter ? "\u2705 found" : "\u274C not found"}`);
763
+ log8.info(`TanStack Query: ${hasTanstackQuery ? "\u2705 found" : "\u274C not found"}`);
764
+ log8.info(`Axios: ${hasAxios ? "\u2705 found" : "\u274C not found (will use fetch)"}`);
765
+ log8.info(`Detected alias: ${detectedAlias ?? 'none (defaulting to "@")'}`);
766
+ const usesPinia = yes ? hasPinia : exitIfCancelled(
767
+ await confirm({ message: "Use Pinia for feature stores?", initialValue: hasPinia })
768
+ );
769
+ const usesVueRouter = yes ? hasVueRouter : exitIfCancelled(
770
+ await confirm({ message: "Does this project use Vue Router?", initialValue: hasVueRouter })
771
+ );
772
+ const usesTanstackQuery = yes ? hasTanstackQuery : exitIfCancelled(
773
+ await confirm({
774
+ message: "Use TanStack Query for data composables?",
775
+ initialValue: hasTanstackQuery
776
+ })
777
+ );
778
+ const httpClient = yes ? hasAxios ? "axios" : "fetch" : exitIfCancelled(
779
+ await select2({
780
+ message: "Which HTTP client should services use?",
781
+ options: [
782
+ { value: "fetch", label: "fetch (native)" },
783
+ { value: "axios", label: "axios" }
784
+ ],
785
+ initialValue: hasAxios ? "axios" : "fetch"
786
+ })
787
+ );
788
+ const alias = yes ? detectedAlias ?? "@" : exitIfCancelled(
789
+ await text5({
790
+ message: 'Import alias for absolute paths (e.g. "@")',
791
+ placeholder: "@",
792
+ defaultValue: detectedAlias ?? "@",
793
+ initialValue: detectedAlias ?? "@"
794
+ })
795
+ );
796
+ const useCustomTemplates = yes ? false : exitIfCancelled(
797
+ await confirm({
798
+ message: "Use custom templates? (creates .vf/templates/ for overrides)",
799
+ initialValue: false
800
+ })
801
+ );
802
+ const config = {
803
+ ...defaultConfig,
804
+ usesPinia,
805
+ usesVueRouter,
806
+ usesTanstackQuery,
807
+ httpClient,
808
+ alias: alias.trim() || "@",
809
+ ...useCustomTemplates ? { templatesDir: ".vf/templates" } : {}
810
+ };
811
+ await saveConfig(root, config);
812
+ if (useCustomTemplates) {
813
+ await fs11.ensureDir(path11.join(root, ".vf", "templates"));
814
+ log8.info('Created .vf/templates/ \u2014 add .hbs files here to override defaults. Run "vf templates:init" to copy all defaults.');
815
+ }
816
+ log8.success(`Configuration saved to ${CONFIG_FILENAME}`);
817
+ await fs11.ensureDir(path11.join(root, config.sharedDir, "components"));
818
+ await fs11.ensureDir(path11.join(root, config.sharedDir, "composables"));
819
+ await fs11.ensureDir(path11.join(root, config.sharedDir, "utils"));
820
+ await fs11.ensureDir(path11.join(root, config.sharedDir, "http"));
821
+ await fs11.ensureDir(path11.join(root, config.featuresDir));
822
+ await ensureHttpClient(root, config);
823
+ outro6('Done! Run "vf generate:feat <name>" to scaffold your first feature.');
824
+ }
825
+
826
+ // src/commands/init-templates.ts
827
+ import { confirm as confirm2, intro as intro7, log as log9, outro as outro7 } from "@clack/prompts";
828
+ import fs12 from "fs-extra";
829
+ import path12 from "path";
830
+ var CONTEXT_MD = `# Handlebars Context \u2014 vue-feat-cli templates
831
+
832
+ This file documents every variable available in each template group.
833
+ All variables are injected automatically by the CLI \u2014 no configuration needed.
834
+
835
+ ---
836
+
837
+ ## feature/ templates
838
+
839
+ Used by: \`vf generate:feat <name>\`, \`vf generate:service <name>\`, \`vf generate:store <name>\`
840
+
841
+ | Variable | Type | Example (input: "product") | Description |
842
+ |---------------|-----------|-----------------------------|------------------------------------|
843
+ | \`name\` | \`string\` | \`product\` | kebab-case feature name |
844
+ | \`Name\` | \`string\` | \`Product\` | PascalCase feature name |
845
+ | \`nameCamel\` | \`string\` | \`product\` | camelCase feature name |
846
+ | \`alias\` | \`string\` | \`@\` | import alias from vf.config.json |
847
+ | \`usesVueRouter\` | \`boolean\` | \`true\` | whether Vue Router is enabled |
848
+
849
+ ### Usage in a template
850
+
851
+ \`\`\`hbs
852
+ import { httpClient } from '{{alias}}/shared/http/client'
853
+ import type { {{Name}} } from '../types/{{name}}.types'
854
+
855
+ export const {{nameCamel}}Service = {
856
+ async getAll(): Promise<{{Name}}[]> {
857
+ return httpClient.get<{{Name}}[]>('/{{name}}s')
858
+ },
859
+ }
860
+ \`\`\`
861
+
862
+ ---
863
+
864
+ ## component/ templates
865
+
866
+ Used by: \`vf generate:component <name>\`
867
+
868
+ | Variable | Type | Example (input: "ProductCard") | Description |
869
+ |----------|----------|-------------------------------|------------------------|
870
+ | \`name\` | \`string\` | \`product-card\` | kebab-case component name |
871
+ | \`Name\` | \`string\` | \`ProductCard\` | PascalCase component name |
872
+
873
+ ---
874
+
875
+ ## composable/ templates
876
+
877
+ Used by: \`vf generate:composable <name>\`
878
+
879
+ | Variable | Type | Example (input: "useFilters") | Description |
880
+ |----------|----------|-------------------------------|--------------------------|
881
+ | \`name\` | \`string\` | \`use-filters\` | kebab-case composable name |
882
+ | \`Name\` | \`string\` | \`UseFilters\` | PascalCase composable name |
883
+
884
+ ---
885
+
886
+ ## Escaping Vue template syntax
887
+
888
+ Handlebars and Vue both use \`{{ }}\`. To output a Vue expression in your template,
889
+ escape the opening braces with a backslash:
890
+
891
+ \`\`\`hbs
892
+ <p>\\{{ item.name }}</p> {{! outputs: {{ item.name }} in the .vue file }}
893
+ \`\`\`
894
+
895
+ ---
896
+
897
+ ## Fallback behaviour
898
+
899
+ If a template file is absent from this folder, vue-feat-cli falls back to the
900
+ built-in default automatically. You only need to include files you actually want
901
+ to override \u2014 a partial set is perfectly valid.
902
+ `;
903
+ async function initTemplates() {
904
+ intro7("\u{1F4C1} Initializing local templates");
905
+ const root = process.cwd();
906
+ const config = await loadConfig(root);
907
+ const dest = path12.resolve(root, config.templatesDir ?? ".vf/templates");
908
+ const safeRoot = path12.resolve(root) + path12.sep;
909
+ if (!dest.startsWith(safeRoot)) {
910
+ log9.error(`templatesDir "${config.templatesDir}" resolves outside the project root \u2014 aborting.`);
911
+ process.exit(1);
912
+ }
913
+ const alreadyExists = await fs12.pathExists(dest) && (await fs12.readdir(dest)).length > 0;
914
+ if (alreadyExists) {
915
+ const overwrite = await confirm2({
916
+ message: `${path12.relative(root, dest)} already has files. Overwrite?`,
917
+ initialValue: false
918
+ });
919
+ if (!overwrite) {
920
+ log9.warn("Aborted.");
921
+ process.exit(0);
922
+ }
923
+ }
924
+ await fs12.copy(TEMPLATES_DIR, dest, { overwrite: true });
925
+ await fs12.writeFile(path12.join(dest, "CONTEXT.md"), CONTEXT_MD, "utf-8");
926
+ if (!config.templatesDir) {
927
+ const cfg = await fs12.readJson(path12.join(root, CONFIG_FILENAME)).catch(() => ({ ...defaultConfig }));
928
+ await fs12.writeJson(path12.join(root, CONFIG_FILENAME), { ...cfg, templatesDir: ".vf/templates" }, { spaces: 2 });
929
+ log9.info(`Added "templatesDir": ".vf/templates" to ${CONFIG_FILENAME}`);
930
+ }
931
+ const count = (await fs12.readdir(dest, { recursive: true })).length;
932
+ log9.info(`CONTEXT.md written \u2014 open it to see all available Handlebars variables.`);
933
+ outro7(`Copied ${count} template files to ${path12.relative(root, dest)}/`);
934
+ }
935
+
936
+ // src/cli.ts
937
+ var cli = cac("vf");
938
+ cli.command("generate:feat <name>", "Scaffold a complete feature module").alias("g:feat").option("--with-crud", "Generate full CRUD methods in service and composables").option("--register-route", "Auto-register the route in the router file (requires usesVueRouter: true)").action(generateFeat);
939
+ cli.command("generate:composable <name>", "Create a composable inside a feature").alias("g:composable").option("--feature <feature>", "Feature to attach this to").action(generateComposable);
940
+ cli.command("generate:service <name>", "Create a service inside a feature").alias("g:service").option("--feature <feature>", "Feature to attach this to").action(generateService);
941
+ cli.command("generate:component <name>", "Create a component").alias("g:component").option("--feature <feature>", "Feature to attach this to (omit for shared)").action(generateComponent);
942
+ cli.command("generate:store <name>", "Create a Pinia store inside a feature").alias("g:store").option("--feature <feature>", "Feature to attach this to").action(generateStore);
943
+ cli.command("init", "Configura o vue-feat-cli no projeto atual").option("--yes", "Accept all detected defaults without prompting (useful for CI)").action(init);
944
+ cli.command("templates:init", "Copy default templates to .vf/templates/ for local customization").action(initTemplates);
945
+ cli.command("help [command]", "Show detailed help for a command").action(help);
946
+ cli.help();
947
+ cli.version("26.9.0");
948
+ cli.parse();