@classytic/arc 1.0.0 → 1.0.5

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/index.js CHANGED
@@ -1,520 +1,3254 @@
1
- import * as fs from 'fs/promises';
1
+ import fp from 'fastify-plugin';
2
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
2
3
  import * as path from 'path';
4
+ import { join } from 'path';
5
+ import * as fs from 'fs/promises';
6
+ import * as readline from 'readline';
7
+ import { execSync, spawn } from 'child_process';
8
+
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropNames = Object.getOwnPropertyNames;
11
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
12
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
13
+ }) : x)(function(x) {
14
+ if (typeof require !== "undefined") return require.apply(this, arguments);
15
+ throw Error('Dynamic require of "' + x + '" is not supported');
16
+ });
17
+ var __esm = (fn, res) => function __init() {
18
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
19
+ };
20
+ var __export = (target, all) => {
21
+ for (var name in all)
22
+ __defProp(target, name, { get: all[name], enumerable: true });
23
+ };
24
+
25
+ // src/registry/ResourceRegistry.ts
26
+ var ResourceRegistry, registryKey, globalScope, resourceRegistry;
27
+ var init_ResourceRegistry = __esm({
28
+ "src/registry/ResourceRegistry.ts"() {
29
+ ResourceRegistry = class {
30
+ _resources;
31
+ _frozen;
32
+ constructor() {
33
+ this._resources = /* @__PURE__ */ new Map();
34
+ this._frozen = false;
35
+ }
36
+ /**
37
+ * Register a resource
38
+ */
39
+ register(resource, options = {}) {
40
+ if (this._frozen) {
41
+ throw new Error(
42
+ `Registry frozen. Cannot register '${resource.name}' after startup.`
43
+ );
44
+ }
45
+ if (this._resources.has(resource.name)) {
46
+ throw new Error(`Resource '${resource.name}' already registered.`);
47
+ }
48
+ const entry = {
49
+ name: resource.name,
50
+ displayName: resource.displayName,
51
+ tag: resource.tag,
52
+ prefix: resource.prefix,
53
+ module: options.module ?? void 0,
54
+ adapter: resource.adapter ? {
55
+ type: resource.adapter.type,
56
+ name: resource.adapter.name
57
+ } : null,
58
+ permissions: resource.permissions,
59
+ presets: resource._appliedPresets ?? [],
60
+ routes: [],
61
+ // Populated later by getIntrospection()
62
+ additionalRoutes: resource.additionalRoutes.map((r) => ({
63
+ method: r.method,
64
+ path: r.path,
65
+ handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
66
+ summary: r.summary,
67
+ description: r.description,
68
+ permissions: r.permissions,
69
+ wrapHandler: r.wrapHandler,
70
+ schema: r.schema
71
+ // Include schema for OpenAPI docs
72
+ })),
73
+ events: Object.keys(resource.events ?? {}),
74
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
75
+ disableDefaultRoutes: resource.disableDefaultRoutes,
76
+ openApiSchemas: options.openApiSchemas,
77
+ plugin: resource.toPlugin()
78
+ // Store plugin factory
79
+ };
80
+ this._resources.set(resource.name, entry);
81
+ return this;
82
+ }
83
+ /**
84
+ * Get resource by name
85
+ */
86
+ get(name) {
87
+ return this._resources.get(name);
88
+ }
89
+ /**
90
+ * Get all resources
91
+ */
92
+ getAll() {
93
+ return Array.from(this._resources.values());
94
+ }
95
+ /**
96
+ * Get resources by module
97
+ */
98
+ getByModule(moduleName) {
99
+ return this.getAll().filter((r) => r.module === moduleName);
100
+ }
101
+ /**
102
+ * Get resources by preset
103
+ */
104
+ getByPreset(presetName) {
105
+ return this.getAll().filter((r) => r.presets.includes(presetName));
106
+ }
107
+ /**
108
+ * Check if resource exists
109
+ */
110
+ has(name) {
111
+ return this._resources.has(name);
112
+ }
113
+ /**
114
+ * Get registry statistics
115
+ */
116
+ getStats() {
117
+ const resources = this.getAll();
118
+ const presetCounts = {};
119
+ for (const r of resources) {
120
+ for (const preset of r.presets) {
121
+ presetCounts[preset] = (presetCounts[preset] ?? 0) + 1;
122
+ }
123
+ }
124
+ return {
125
+ totalResources: resources.length,
126
+ byModule: this._groupBy(resources, "module"),
127
+ presetUsage: presetCounts,
128
+ totalRoutes: resources.reduce((sum, r) => {
129
+ const defaultRouteCount = r.disableDefaultRoutes ? 0 : 5;
130
+ return sum + (r.additionalRoutes?.length ?? 0) + defaultRouteCount;
131
+ }, 0),
132
+ totalEvents: resources.reduce((sum, r) => sum + (r.events?.length ?? 0), 0)
133
+ };
134
+ }
135
+ /**
136
+ * Get full introspection data
137
+ */
138
+ getIntrospection() {
139
+ return {
140
+ resources: this.getAll().map((r) => {
141
+ const defaultRoutes = r.disableDefaultRoutes ? [] : [
142
+ { method: "GET", path: r.prefix, operation: "list" },
143
+ { method: "GET", path: `${r.prefix}/:id`, operation: "get" },
144
+ { method: "POST", path: r.prefix, operation: "create" },
145
+ { method: "PATCH", path: `${r.prefix}/:id`, operation: "update" },
146
+ { method: "DELETE", path: `${r.prefix}/:id`, operation: "delete" }
147
+ ];
148
+ return {
149
+ name: r.name,
150
+ displayName: r.displayName,
151
+ prefix: r.prefix,
152
+ module: r.module,
153
+ presets: r.presets,
154
+ permissions: r.permissions,
155
+ routes: [
156
+ ...defaultRoutes,
157
+ ...r.additionalRoutes?.map((ar) => ({
158
+ method: ar.method,
159
+ path: `${r.prefix}${ar.path}`,
160
+ operation: typeof ar.handler === "string" ? ar.handler : "custom",
161
+ handler: typeof ar.handler === "string" ? ar.handler : void 0,
162
+ summary: ar.summary
163
+ })) ?? []
164
+ ],
165
+ events: r.events
166
+ };
167
+ }),
168
+ stats: this.getStats(),
169
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
170
+ };
171
+ }
172
+ /**
173
+ * Freeze registry (prevent further registrations)
174
+ */
175
+ freeze() {
176
+ this._frozen = true;
177
+ }
178
+ /**
179
+ * Check if frozen
180
+ */
181
+ isFrozen() {
182
+ return this._frozen;
183
+ }
184
+ /**
185
+ * Unfreeze registry (for testing)
186
+ */
187
+ _unfreeze() {
188
+ this._frozen = false;
189
+ }
190
+ /**
191
+ * Clear all resources (for testing)
192
+ */
193
+ _clear() {
194
+ this._resources.clear();
195
+ this._frozen = false;
196
+ }
197
+ /**
198
+ * Group by key
199
+ */
200
+ _groupBy(arr, key) {
201
+ const result = {};
202
+ for (const item of arr) {
203
+ const k = String(item[key] ?? "uncategorized");
204
+ result[k] = (result[k] ?? 0) + 1;
205
+ }
206
+ return result;
207
+ }
208
+ };
209
+ registryKey = /* @__PURE__ */ Symbol.for("arc.resourceRegistry");
210
+ globalScope = globalThis;
211
+ resourceRegistry = globalScope[registryKey] ?? new ResourceRegistry();
212
+ if (!globalScope[registryKey]) {
213
+ globalScope[registryKey] = resourceRegistry;
214
+ }
215
+ }
216
+ });
217
+ var introspectionPlugin, introspectionPlugin_default;
218
+ var init_introspectionPlugin = __esm({
219
+ "src/registry/introspectionPlugin.ts"() {
220
+ init_ResourceRegistry();
221
+ introspectionPlugin = async (fastify, opts = {}) => {
222
+ const {
223
+ prefix = "/_resources",
224
+ authRoles = ["superadmin"],
225
+ enabled = process.env.NODE_ENV !== "production" || process.env.ENABLE_INTROSPECTION === "true"
226
+ } = opts;
227
+ if (!enabled) {
228
+ fastify.log?.info?.("Introspection plugin disabled");
229
+ return;
230
+ }
231
+ const typedFastify = fastify;
232
+ const authMiddleware = authRoles.length > 0 && typedFastify.authenticate ? [
233
+ typedFastify.authenticate,
234
+ typedFastify.authorize?.(...authRoles)
235
+ ].filter(Boolean) : [];
236
+ await fastify.register(async (instance) => {
237
+ instance.get(
238
+ "/",
239
+ {
240
+ preHandler: authMiddleware
241
+ },
242
+ async (_req, _reply) => {
243
+ return resourceRegistry.getIntrospection();
244
+ }
245
+ );
246
+ instance.get(
247
+ "/stats",
248
+ {
249
+ preHandler: authMiddleware
250
+ },
251
+ async (_req, _reply) => {
252
+ return resourceRegistry.getStats();
253
+ }
254
+ );
255
+ instance.get(
256
+ "/:name",
257
+ {
258
+ schema: {
259
+ params: {
260
+ type: "object",
261
+ properties: {
262
+ name: { type: "string" }
263
+ },
264
+ required: ["name"]
265
+ }
266
+ },
267
+ preHandler: authMiddleware
268
+ },
269
+ async (req, reply) => {
270
+ const resource = resourceRegistry.get(req.params.name);
271
+ if (!resource) {
272
+ return reply.code(404).send({
273
+ error: `Resource '${req.params.name}' not found`
274
+ });
275
+ }
276
+ return resource;
277
+ }
278
+ );
279
+ }, { prefix });
280
+ fastify.log?.info?.(`Introspection API at ${prefix}`);
281
+ };
282
+ introspectionPlugin_default = fp(introspectionPlugin, { name: "arc-introspection" });
283
+ }
284
+ });
285
+
286
+ // src/registry/index.ts
287
+ var registry_exports = {};
288
+ __export(registry_exports, {
289
+ ResourceRegistry: () => ResourceRegistry,
290
+ introspectionPlugin: () => introspectionPlugin_default,
291
+ introspectionPluginFn: () => introspectionPlugin,
292
+ resourceRegistry: () => resourceRegistry
293
+ });
294
+ var init_registry = __esm({
295
+ "src/registry/index.ts"() {
296
+ init_ResourceRegistry();
297
+ init_introspectionPlugin();
298
+ }
299
+ });
300
+ function isTypeScriptProject() {
301
+ return existsSync(join(process.cwd(), "tsconfig.json"));
302
+ }
303
+ function getTemplates(ts) {
304
+ return {
305
+ model: (name) => `/**
306
+ * ${name} Model
307
+ * Generated by Arc CLI
308
+ */
309
+
310
+ import mongoose${ts ? ", { type HydratedDocument }" : ""} from 'mongoose';
311
+
312
+ const { Schema } = mongoose;
313
+ ${ts ? `
314
+ type ${name} = {
315
+ name: string;
316
+ description?: string;
317
+ isActive: boolean;
318
+ };
319
+
320
+ export type ${name}Document = HydratedDocument<${name}>;
321
+ ` : ""}
322
+ const ${name.toLowerCase()}Schema = new Schema${ts ? `<${name}>` : ""}(
323
+ {
324
+ name: { type: String, required: true, trim: true },
325
+ description: { type: String, trim: true },
326
+ isActive: { type: Boolean, default: true },
327
+ },
328
+ { timestamps: true }
329
+ );
330
+
331
+ // Indexes
332
+ ${name.toLowerCase()}Schema.index({ name: 1 });
333
+ ${name.toLowerCase()}Schema.index({ isActive: 1 });
334
+
335
+ const ${name} = mongoose.models.${name}${ts ? ` as mongoose.Model<${name}>` : ""} || mongoose.model${ts ? `<${name}>` : ""}('${name}', ${name.toLowerCase()}Schema);
336
+ export default ${name};
337
+ `,
338
+ repository: (name) => `/**
339
+ * ${name} Repository
340
+ * Generated by Arc CLI
341
+ */
342
+
343
+ import {
344
+ Repository,
345
+ methodRegistryPlugin,
346
+ softDeletePlugin,
347
+ mongoOperationsPlugin,
348
+ } from '@classytic/mongokit';
349
+ ${ts ? `import type { ${name}Document } from './${name.toLowerCase()}.model.js';` : ""}
350
+ import ${name} from './${name.toLowerCase()}.model.js';
351
+
352
+ class ${name}Repository extends Repository${ts ? `<${name}Document>` : ""} {
353
+ constructor() {
354
+ super(${name}${ts ? " as any" : ""}, [
355
+ methodRegistryPlugin(),
356
+ softDeletePlugin(),
357
+ mongoOperationsPlugin(),
358
+ ]);
359
+ }
360
+
361
+ /**
362
+ * Find all active records
363
+ */
364
+ async findActive() {
365
+ return this.Model.find({ isActive: true, deletedAt: null }).lean();
366
+ }
367
+
368
+ // Add custom repository methods here
369
+ }
370
+
371
+ const ${name.toLowerCase()}Repository = new ${name}Repository();
372
+ export default ${name.toLowerCase()}Repository;
373
+ export { ${name}Repository };
374
+ `,
375
+ controller: (name) => `/**
376
+ * ${name} Controller
377
+ * Generated by Arc CLI
378
+ */
379
+
380
+ import { BaseController } from '@classytic/arc';
381
+ import ${name.toLowerCase()}Repository from './${name.toLowerCase()}.repository.js';
382
+ import { ${name.toLowerCase()}SchemaOptions } from './${name.toLowerCase()}.schemas.js';
383
+
384
+ class ${name}Controller extends BaseController {
385
+ constructor() {
386
+ super(${name.toLowerCase()}Repository${ts ? " as any" : ""}, { schemaOptions: ${name.toLowerCase()}SchemaOptions });
387
+ }
388
+
389
+ // Add custom controller methods here
390
+ }
391
+
392
+ const ${name.toLowerCase()}Controller = new ${name}Controller();
393
+ export default ${name.toLowerCase()}Controller;
394
+ `,
395
+ schemas: (name) => `/**
396
+ * ${name} Schemas
397
+ * Generated by Arc CLI
398
+ */
399
+
400
+ import ${name} from './${name.toLowerCase()}.model.js';
401
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
402
+
403
+ /**
404
+ * CRUD Schemas with Field Rules
405
+ */
406
+ const crudSchemas = buildCrudSchemasFromModel(${name}, {
407
+ strictAdditionalProperties: true,
408
+ fieldRules: {
409
+ // Mark fields as system-managed (excluded from create/update)
410
+ // deletedAt: { systemManaged: true },
411
+ },
412
+ query: {
413
+ filterableFields: {
414
+ isActive: 'boolean',
415
+ createdAt: 'date',
416
+ },
417
+ },
418
+ });
419
+
420
+ // Schema options for controller
421
+ export const ${name.toLowerCase()}SchemaOptions${ts ? ": any" : ""} = {
422
+ query: {
423
+ filterableFields: {
424
+ isActive: 'boolean',
425
+ createdAt: 'date',
426
+ },
427
+ },
428
+ };
429
+
430
+ export default crudSchemas;
431
+ `,
432
+ resource: (name) => `/**
433
+ * ${name} Resource
434
+ * Generated by Arc CLI
435
+ */
436
+
437
+ import { defineResource } from '@classytic/arc';
438
+ import { createAdapter } from '#shared/adapter.js';
439
+ import { publicReadPermissions } from '#shared/permissions.js';
440
+ import ${name} from './${name.toLowerCase()}.model.js';
441
+ import ${name.toLowerCase()}Repository from './${name.toLowerCase()}.repository.js';
442
+ import ${name.toLowerCase()}Controller from './${name.toLowerCase()}.controller.js';
443
+
444
+ const ${name.toLowerCase()}Resource = defineResource({
445
+ name: '${name.toLowerCase()}',
446
+ displayName: '${name}s',
447
+ prefix: '/${name.toLowerCase()}s',
448
+
449
+ adapter: createAdapter(${name}, ${name.toLowerCase()}Repository),
450
+ controller: ${name.toLowerCase()}Controller,
451
+
452
+ presets: ['softDelete'],
453
+
454
+ permissions: publicReadPermissions,
455
+
456
+ // Add custom routes here:
457
+ // additionalRoutes: [
458
+ // {
459
+ // method: 'GET',
460
+ // path: '/custom',
461
+ // summary: 'Custom endpoint',
462
+ // handler: async (request, reply) => { ... },
463
+ // },
464
+ // ],
465
+ });
466
+
467
+ export default ${name.toLowerCase()}Resource;
468
+ `,
469
+ test: (name) => `/**
470
+ * ${name} Tests
471
+ * Generated by Arc CLI
472
+ */
473
+
474
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
475
+ import mongoose from 'mongoose';
476
+ import { createAppInstance } from '../src/app.js';
477
+ ${ts ? "import type { FastifyInstance } from 'fastify';\n" : ""}
478
+ describe('${name} Resource', () => {
479
+ let app${ts ? ": FastifyInstance" : ""};
480
+
481
+ beforeAll(async () => {
482
+ const testDbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/test-${name.toLowerCase()}';
483
+ await mongoose.connect(testDbUri);
484
+ app = await createAppInstance();
485
+ await app.ready();
486
+ });
487
+
488
+ afterAll(async () => {
489
+ await app.close();
490
+ await mongoose.connection.close();
491
+ });
492
+
493
+ describe('GET /${name.toLowerCase()}s', () => {
494
+ it('should return a list', async () => {
495
+ const response = await app.inject({
496
+ method: 'GET',
497
+ url: '/${name.toLowerCase()}s',
498
+ });
499
+
500
+ expect(response.statusCode).toBe(200);
501
+ const body = JSON.parse(response.body);
502
+ expect(body).toHaveProperty('docs');
503
+ });
504
+ });
505
+ });
506
+ `
507
+ };
508
+ }
509
+ async function generate(type, args) {
510
+ if (!type) {
511
+ console.error("Error: Missing type argument");
512
+ console.log("Usage: arc generate <resource|controller|model|repository|schemas> <name>");
513
+ process.exit(1);
514
+ }
515
+ const [name] = args;
516
+ if (!name) {
517
+ console.error("Error: Missing name argument");
518
+ console.log("Usage: arc generate <type> <name>");
519
+ console.log("Example: arc generate resource product");
520
+ process.exit(1);
521
+ }
522
+ const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1);
523
+ const lowerName = name.toLowerCase();
524
+ const ts = isTypeScriptProject();
525
+ const ext = ts ? "ts" : "js";
526
+ const templates = getTemplates(ts);
527
+ const resourcePath = join(process.cwd(), "src", "resources", lowerName);
528
+ switch (type) {
529
+ case "resource":
530
+ case "r":
531
+ await generateResource(capitalizedName, lowerName, resourcePath, templates, ext);
532
+ break;
533
+ case "controller":
534
+ case "c":
535
+ await generateFile(capitalizedName, lowerName, resourcePath, "controller", templates.controller, ext);
536
+ break;
537
+ case "model":
538
+ case "m":
539
+ await generateFile(capitalizedName, lowerName, resourcePath, "model", templates.model, ext);
540
+ break;
541
+ case "repository":
542
+ case "repo":
543
+ await generateFile(capitalizedName, lowerName, resourcePath, "repository", templates.repository, ext);
544
+ break;
545
+ case "schemas":
546
+ case "s":
547
+ await generateFile(capitalizedName, lowerName, resourcePath, "schemas", templates.schemas, ext);
548
+ break;
549
+ default:
550
+ console.error(`Unknown type: ${type}`);
551
+ console.log("Available types: resource, controller, model, repository, schemas");
552
+ process.exit(1);
553
+ }
554
+ }
555
+ async function generateResource(name, lowerName, resourcePath, templates, ext) {
556
+ console.log(`
557
+ 📦 Generating resource: ${name}...
558
+ `);
559
+ if (!existsSync(resourcePath)) {
560
+ mkdirSync(resourcePath, { recursive: true });
561
+ console.log(` 📁 Created: src/resources/${lowerName}/`);
562
+ }
563
+ const files = {
564
+ [`${lowerName}.model.${ext}`]: templates.model(name),
565
+ [`${lowerName}.repository.${ext}`]: templates.repository(name),
566
+ [`${lowerName}.controller.${ext}`]: templates.controller(name),
567
+ [`${lowerName}.schemas.${ext}`]: templates.schemas(name),
568
+ [`${lowerName}.resource.${ext}`]: templates.resource(name)
569
+ };
570
+ for (const [filename, content] of Object.entries(files)) {
571
+ const filepath = join(resourcePath, filename);
572
+ if (existsSync(filepath)) {
573
+ console.warn(` ⚠ Skipped: ${filename} (already exists)`);
574
+ } else {
575
+ writeFileSync(filepath, content);
576
+ console.log(` ✅ Created: ${filename}`);
577
+ }
578
+ }
579
+ const testsDir = join(process.cwd(), "tests");
580
+ if (!existsSync(testsDir)) {
581
+ mkdirSync(testsDir, { recursive: true });
582
+ }
583
+ const testPath = join(testsDir, `${lowerName}.test.${ext}`);
584
+ if (!existsSync(testPath)) {
585
+ writeFileSync(testPath, templates.test(name));
586
+ console.log(` ✅ Created: tests/${lowerName}.test.${ext}`);
587
+ }
588
+ console.log(`
589
+ ╔═══════════════════════════════════════════════════════════════╗
590
+ ║ ✅ Resource Generated! ║
591
+ ╚═══════════════════════════════════════════════════════════════╝
592
+
593
+ Next steps:
594
+
595
+ 1. Register in src/resources/index.${ext}:
596
+ import ${lowerName}Resource from './${lowerName}/${lowerName}.resource.js';
597
+
598
+ export const resources = [
599
+ // ... existing resources
600
+ ${lowerName}Resource,
601
+ ];
602
+
603
+ 2. Customize the model schema in:
604
+ src/resources/${lowerName}/${lowerName}.model.${ext}
605
+
606
+ 3. Run tests:
607
+ npm test
608
+ `);
609
+ }
610
+ async function generateFile(name, lowerName, resourcePath, fileType, template, ext) {
611
+ console.log(`
612
+ 📦 Generating ${fileType}: ${name}...
613
+ `);
614
+ if (!existsSync(resourcePath)) {
615
+ mkdirSync(resourcePath, { recursive: true });
616
+ console.log(` 📁 Created: src/resources/${lowerName}/`);
617
+ }
618
+ const filename = `${lowerName}.${fileType}.${ext}`;
619
+ const filepath = join(resourcePath, filename);
620
+ if (existsSync(filepath)) {
621
+ console.error(` ❌ Error: ${filename} already exists`);
622
+ process.exit(1);
623
+ }
624
+ writeFileSync(filepath, template(name));
625
+ console.log(` ✅ Created: ${filename}`);
626
+ }
627
+ async function init(options = {}) {
628
+ console.log(`
629
+ ╔═══════════════════════════════════════════════════════════════╗
630
+ ║ 🔥 Arc Project Setup ║
631
+ ║ Resource-Oriented Backend Framework ║
632
+ ╚═══════════════════════════════════════════════════════════════╝
633
+ `);
634
+ const config = await gatherConfig(options);
635
+ console.log(`
636
+ 📦 Creating project: ${config.name}`);
637
+ console.log(` Adapter: ${config.adapter === "mongokit" ? "MongoKit (MongoDB)" : "Custom"}`);
638
+ console.log(` Tenant: ${config.tenant === "multi" ? "Multi-tenant" : "Single-tenant"}`);
639
+ console.log(` Language: ${config.typescript ? "TypeScript" : "JavaScript"}
640
+ `);
641
+ const projectPath = path.join(process.cwd(), config.name);
642
+ try {
643
+ await fs.access(projectPath);
644
+ if (!options.force) {
645
+ console.error(`❌ Directory "${config.name}" already exists. Use --force to overwrite.`);
646
+ process.exit(1);
647
+ }
648
+ } catch {
649
+ }
650
+ const packageManager = detectPackageManager();
651
+ console.log(`📦 Using package manager: ${packageManager}
652
+ `);
653
+ await createProjectStructure(projectPath, config);
654
+ if (!options.skipInstall) {
655
+ console.log("\n📥 Installing dependencies...\n");
656
+ await installDependencies(projectPath, config, packageManager);
657
+ }
658
+ printSuccessMessage(config, options.skipInstall);
659
+ }
660
+ function detectPackageManager() {
661
+ try {
662
+ const cwd = process.cwd();
663
+ if (existsSync2(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
664
+ if (existsSync2(path.join(cwd, "yarn.lock"))) return "yarn";
665
+ if (existsSync2(path.join(cwd, "bun.lockb"))) return "bun";
666
+ if (existsSync2(path.join(cwd, "package-lock.json"))) return "npm";
667
+ } catch {
668
+ }
669
+ if (isCommandAvailable("pnpm")) return "pnpm";
670
+ if (isCommandAvailable("yarn")) return "yarn";
671
+ if (isCommandAvailable("bun")) return "bun";
672
+ return "npm";
673
+ }
674
+ function isCommandAvailable(command) {
675
+ try {
676
+ execSync(`${command} --version`, { stdio: "ignore" });
677
+ return true;
678
+ } catch {
679
+ return false;
680
+ }
681
+ }
682
+ function existsSync2(filePath) {
683
+ try {
684
+ __require("fs").accessSync(filePath);
685
+ return true;
686
+ } catch {
687
+ return false;
688
+ }
689
+ }
690
+ async function installDependencies(projectPath, config, pm) {
691
+ const deps = [
692
+ "@classytic/arc@latest",
693
+ "fastify@latest",
694
+ "@fastify/cors@latest",
695
+ "@fastify/helmet@latest",
696
+ "@fastify/jwt@latest",
697
+ "@fastify/rate-limit@latest",
698
+ "@fastify/sensible@latest",
699
+ "@fastify/under-pressure@latest",
700
+ "bcryptjs@latest",
701
+ "dotenv@latest",
702
+ "jsonwebtoken@latest"
703
+ ];
704
+ if (config.adapter === "mongokit") {
705
+ deps.push("@classytic/mongokit@latest", "mongoose@latest");
706
+ }
707
+ const devDeps = [
708
+ "vitest@latest",
709
+ "pino-pretty@latest"
710
+ ];
711
+ if (config.typescript) {
712
+ devDeps.push(
713
+ "typescript@latest",
714
+ "@types/node@latest",
715
+ "@types/bcryptjs@latest",
716
+ "@types/jsonwebtoken@latest",
717
+ "tsx@latest"
718
+ );
719
+ }
720
+ const installCmd = getInstallCommand(pm, deps, false);
721
+ const installDevCmd = getInstallCommand(pm, devDeps, true);
722
+ console.log(` Installing dependencies...`);
723
+ await runCommand(installCmd, projectPath);
724
+ console.log(` Installing dev dependencies...`);
725
+ await runCommand(installDevCmd, projectPath);
726
+ console.log(`
727
+ ✅ Dependencies installed successfully!`);
728
+ }
729
+ function getInstallCommand(pm, packages, isDev) {
730
+ const pkgList = packages.join(" ");
731
+ switch (pm) {
732
+ case "pnpm":
733
+ return `pnpm add ${isDev ? "-D" : ""} ${pkgList}`;
734
+ case "yarn":
735
+ return `yarn add ${isDev ? "-D" : ""} ${pkgList}`;
736
+ case "bun":
737
+ return `bun add ${isDev ? "-d" : ""} ${pkgList}`;
738
+ case "npm":
739
+ default:
740
+ return `npm install ${isDev ? "--save-dev" : ""} ${pkgList}`;
741
+ }
742
+ }
743
+ function runCommand(command, cwd) {
744
+ return new Promise((resolve, reject) => {
745
+ const isWindows = process.platform === "win32";
746
+ const shell = isWindows ? "cmd" : "/bin/sh";
747
+ const shellFlag = isWindows ? "/c" : "-c";
748
+ const child = spawn(shell, [shellFlag, command], {
749
+ cwd,
750
+ stdio: "inherit",
751
+ env: { ...process.env, FORCE_COLOR: "1" }
752
+ });
753
+ child.on("close", (code) => {
754
+ if (code === 0) {
755
+ resolve();
756
+ } else {
757
+ reject(new Error(`Command failed with exit code ${code}`));
758
+ }
759
+ });
760
+ child.on("error", reject);
761
+ });
762
+ }
763
+ async function gatherConfig(options) {
764
+ const rl = readline.createInterface({
765
+ input: process.stdin,
766
+ output: process.stdout
767
+ });
768
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
769
+ try {
770
+ const name = options.name || await question("📁 Project name: ") || "my-arc-app";
771
+ let adapter = options.adapter || "mongokit";
772
+ if (!options.adapter) {
773
+ const adapterChoice = await question("🗄️ Database adapter [1=MongoKit (recommended), 2=Custom]: ");
774
+ adapter = adapterChoice === "2" ? "custom" : "mongokit";
775
+ }
776
+ let tenant = options.tenant || "single";
777
+ if (!options.tenant) {
778
+ const tenantChoice = await question("🏢 Tenant mode [1=Single-tenant, 2=Multi-tenant]: ");
779
+ tenant = tenantChoice === "2" ? "multi" : "single";
780
+ }
781
+ let typescript = options.typescript ?? true;
782
+ if (options.typescript === void 0) {
783
+ const tsChoice = await question("�� Language [1=TypeScript (recommended), 2=JavaScript]: ");
784
+ typescript = tsChoice !== "2";
785
+ }
786
+ return { name, adapter, tenant, typescript };
787
+ } finally {
788
+ rl.close();
789
+ }
790
+ }
791
+ async function createProjectStructure(projectPath, config) {
792
+ const ext = config.typescript ? "ts" : "js";
793
+ const dirs = [
794
+ "",
795
+ "src",
796
+ "src/config",
797
+ // Config & env loading (import first!)
798
+ "src/shared",
799
+ // Shared utilities (adapters, presets, permissions)
800
+ "src/shared/presets",
801
+ // Preset definitions
802
+ "src/plugins",
803
+ // App-specific plugins
804
+ "src/resources",
805
+ // Resource definitions
806
+ "src/resources/user",
807
+ // User resource (user.model, user.repository, etc.)
808
+ "src/resources/auth",
809
+ // Auth resource (auth.resource, auth.handlers, etc.)
810
+ "src/resources/example",
811
+ // Example resource
812
+ "tests"
813
+ ];
814
+ for (const dir of dirs) {
815
+ await fs.mkdir(path.join(projectPath, dir), { recursive: true });
816
+ console.log(` 📁 Created: ${dir || "/"}`);
817
+ }
818
+ const files = {
819
+ "package.json": packageJsonTemplate(config),
820
+ ".gitignore": gitignoreTemplate(),
821
+ ".env.example": envExampleTemplate(config),
822
+ ".env.dev": envDevTemplate(config),
823
+ "README.md": readmeTemplate(config)
824
+ };
825
+ if (config.typescript) {
826
+ files["tsconfig.json"] = tsconfigTemplate();
827
+ }
828
+ files["vitest.config.ts"] = vitestConfigTemplate(config);
829
+ files[`src/config/env.${ext}`] = envLoaderTemplate(config);
830
+ files[`src/config/index.${ext}`] = configTemplate(config);
831
+ files[`src/app.${ext}`] = appTemplate(config);
832
+ files[`src/index.${ext}`] = indexTemplate(config);
833
+ files[`src/shared/index.${ext}`] = sharedIndexTemplate(config);
834
+ files[`src/shared/adapter.${ext}`] = config.adapter === "mongokit" ? createAdapterTemplate(config) : customAdapterTemplate(config);
835
+ files[`src/shared/permissions.${ext}`] = permissionsTemplate(config);
836
+ if (config.tenant === "multi") {
837
+ files[`src/shared/presets/index.${ext}`] = presetsMultiTenantTemplate(config);
838
+ files[`src/shared/presets/flexible-multi-tenant.${ext}`] = flexibleMultiTenantPresetTemplate(config);
839
+ } else {
840
+ files[`src/shared/presets/index.${ext}`] = presetsSingleTenantTemplate(config);
841
+ }
842
+ files[`src/plugins/index.${ext}`] = pluginsIndexTemplate(config);
843
+ files[`src/resources/index.${ext}`] = resourcesIndexTemplate(config);
844
+ files[`src/resources/user/user.model.${ext}`] = userModelTemplate(config);
845
+ files[`src/resources/user/user.repository.${ext}`] = userRepositoryTemplate(config);
846
+ files[`src/resources/user/user.controller.${ext}`] = userControllerTemplate(config);
847
+ files[`src/resources/auth/auth.resource.${ext}`] = authResourceTemplate(config);
848
+ files[`src/resources/auth/auth.handlers.${ext}`] = authHandlersTemplate(config);
849
+ files[`src/resources/auth/auth.schemas.${ext}`] = authSchemasTemplate();
850
+ files[`src/resources/example/example.model.${ext}`] = exampleModelTemplate(config);
851
+ files[`src/resources/example/example.repository.${ext}`] = exampleRepositoryTemplate(config);
852
+ files[`src/resources/example/example.resource.${ext}`] = exampleResourceTemplate(config);
853
+ files[`src/resources/example/example.controller.${ext}`] = exampleControllerTemplate(config);
854
+ files[`src/resources/example/example.schemas.${ext}`] = exampleSchemasTemplate(config);
855
+ files[`tests/example.test.${ext}`] = exampleTestTemplate(config);
856
+ files[`tests/auth.test.${ext}`] = authTestTemplate(config);
857
+ for (const [filePath, content] of Object.entries(files)) {
858
+ const fullPath = path.join(projectPath, filePath);
859
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
860
+ await fs.writeFile(fullPath, content);
861
+ console.log(` ✅ Created: ${filePath}`);
862
+ }
863
+ }
864
+ function packageJsonTemplate(config) {
865
+ const scripts = config.typescript ? {
866
+ dev: "tsx watch src/index.ts",
867
+ build: "tsc",
868
+ start: "node dist/index.js",
869
+ test: "vitest run",
870
+ "test:watch": "vitest"
871
+ } : {
872
+ dev: "node --watch src/index.js",
873
+ start: "node src/index.js",
874
+ test: "vitest run",
875
+ "test:watch": "vitest"
876
+ };
877
+ const imports = config.typescript ? {
878
+ "#config/*": "./dist/config/*",
879
+ "#shared/*": "./dist/shared/*",
880
+ "#resources/*": "./dist/resources/*",
881
+ "#plugins/*": "./dist/plugins/*"
882
+ } : {
883
+ "#config/*": "./src/config/*",
884
+ "#shared/*": "./src/shared/*",
885
+ "#resources/*": "./src/resources/*",
886
+ "#plugins/*": "./src/plugins/*"
887
+ };
888
+ return JSON.stringify(
889
+ {
890
+ name: config.name,
891
+ version: "1.0.0",
892
+ type: "module",
893
+ main: config.typescript ? "dist/index.js" : "src/index.js",
894
+ imports,
895
+ scripts,
896
+ engines: {
897
+ node: ">=20"
898
+ }
899
+ },
900
+ null,
901
+ 2
902
+ );
903
+ }
904
+ function tsconfigTemplate() {
905
+ return JSON.stringify(
906
+ {
907
+ compilerOptions: {
908
+ target: "ES2022",
909
+ module: "NodeNext",
910
+ moduleResolution: "NodeNext",
911
+ lib: ["ES2022"],
912
+ outDir: "./dist",
913
+ rootDir: "./src",
914
+ strict: true,
915
+ esModuleInterop: true,
916
+ skipLibCheck: true,
917
+ forceConsistentCasingInFileNames: true,
918
+ declaration: true,
919
+ declarationMap: true,
920
+ sourceMap: true,
921
+ resolveJsonModule: true,
922
+ paths: {
923
+ "#shared/*": ["./src/shared/*"],
924
+ "#resources/*": ["./src/resources/*"],
925
+ "#config/*": ["./src/config/*"],
926
+ "#plugins/*": ["./src/plugins/*"]
927
+ }
928
+ },
929
+ include: ["src/**/*"],
930
+ exclude: ["node_modules", "dist"]
931
+ },
932
+ null,
933
+ 2
934
+ );
935
+ }
936
+ function vitestConfigTemplate(config) {
937
+ const srcDir = config.typescript ? "./src" : "./src";
938
+ return `import { defineConfig } from 'vitest/config';
939
+ import { resolve } from 'path';
940
+
941
+ export default defineConfig({
942
+ test: {
943
+ globals: true,
944
+ environment: 'node',
945
+ },
946
+ resolve: {
947
+ alias: {
948
+ '#config': resolve(__dirname, '${srcDir}/config'),
949
+ '#shared': resolve(__dirname, '${srcDir}/shared'),
950
+ '#resources': resolve(__dirname, '${srcDir}/resources'),
951
+ '#plugins': resolve(__dirname, '${srcDir}/plugins'),
952
+ },
953
+ },
954
+ });
955
+ `;
956
+ }
957
+ function gitignoreTemplate() {
958
+ return `# Dependencies
959
+ node_modules/
960
+
961
+ # Build
962
+ dist/
963
+ *.js.map
964
+
965
+ # Environment
966
+ .env
967
+ .env.local
968
+ .env.*.local
969
+
970
+ # IDE
971
+ .vscode/
972
+ .idea/
973
+ *.swp
974
+ *.swo
975
+
976
+ # OS
977
+ .DS_Store
978
+ Thumbs.db
979
+
980
+ # Logs
981
+ *.log
982
+ npm-debug.log*
983
+
984
+ # Test coverage
985
+ coverage/
986
+ `;
987
+ }
988
+ function envExampleTemplate(config) {
989
+ let content = `# Server
990
+ PORT=8040
991
+ HOST=0.0.0.0
992
+ NODE_ENV=development
993
+
994
+ # JWT
995
+ JWT_SECRET=your-32-character-minimum-secret-here
996
+ `;
997
+ if (config.adapter === "mongokit") {
998
+ content += `
999
+ # MongoDB
1000
+ MONGODB_URI=mongodb://localhost:27017/${config.name}
1001
+ `;
1002
+ }
1003
+ if (config.tenant === "multi") {
1004
+ content += `
1005
+ # Multi-tenant
1006
+ DEFAULT_ORG_ID=
1007
+ `;
1008
+ }
1009
+ return content;
1010
+ }
1011
+ function readmeTemplate(config) {
1012
+ const ext = config.typescript ? "ts" : "js";
1013
+ return `# ${config.name}
1014
+
1015
+ Built with [Arc](https://github.com/classytic/arc) - Resource-Oriented Backend Framework
1016
+
1017
+ ## Quick Start
1018
+
1019
+ \`\`\`bash
1020
+ # Install dependencies
1021
+ npm install
1022
+
1023
+ # Start development server (uses .env.dev)
1024
+ npm run dev
1025
+
1026
+ # Run tests
1027
+ npm test
1028
+ \`\`\`
1029
+
1030
+ ## Project Structure
1031
+
1032
+ \`\`\`
1033
+ src/
1034
+ ├── config/ # Configuration (loaded first)
1035
+ │ ├── env.${ext} # Env loader (import first!)
1036
+ │ └── index.${ext} # App config
1037
+ ├── shared/ # Shared utilities
1038
+ │ ├── adapter.${ext} # ${config.adapter === "mongokit" ? "MongoKit adapter factory" : "Custom adapter"}
1039
+ │ ├── permissions.${ext} # Permission helpers
1040
+ │ └── presets/ # ${config.tenant === "multi" ? "Multi-tenant presets" : "Standard presets"}
1041
+ ├── plugins/ # App-specific plugins
1042
+ │ └── index.${ext} # Plugin registry
1043
+ ├── resources/ # API Resources
1044
+ │ ├── index.${ext} # Resource registry
1045
+ │ └── example/ # Example resource
1046
+ │ ├── index.${ext} # Resource definition
1047
+ │ ├── model.${ext} # Mongoose schema
1048
+ │ └── repository.${ext} # MongoKit repository
1049
+ ├── app.${ext} # App factory (reusable)
1050
+ └── index.${ext} # Server entry point
1051
+ tests/
1052
+ └── example.test.${ext} # Example tests
1053
+ \`\`\`
1054
+
1055
+ ## Architecture
1056
+
1057
+ ### Entry Points
1058
+
1059
+ - **\`src/index.${ext}\`** - HTTP server entry point
1060
+ - **\`src/app.${ext}\`** - App factory (import for workers/tests)
1061
+
1062
+ \`\`\`${config.typescript ? "typescript" : "javascript"}
1063
+ // For workers or custom entry points:
1064
+ import { createAppInstance } from './app.js';
1065
+
1066
+ const app = await createAppInstance();
1067
+ // Use app for your worker logic
1068
+ \`\`\`
1069
+
1070
+ ### Adding Resources
1071
+
1072
+ 1. Create a new folder in \`src/resources/\`:
1073
+
1074
+ \`\`\`
1075
+ src/resources/product/
1076
+ ├── index.${ext} # Resource definition
1077
+ ├── model.${ext} # Mongoose schema
1078
+ └── repository.${ext} # MongoKit repository
1079
+ \`\`\`
1080
+
1081
+ 2. Register in \`src/resources/index.${ext}\`:
1082
+
1083
+ \`\`\`${config.typescript ? "typescript" : "javascript"}
1084
+ import productResource from './product/index.js';
1085
+
1086
+ export const resources = [
1087
+ exampleResource,
1088
+ productResource, // Add here
1089
+ ];
1090
+ \`\`\`
1091
+
1092
+ ### Adding Plugins
1093
+
1094
+ Add custom plugins in \`src/plugins/index.${ext}\`:
1095
+
1096
+ \`\`\`${config.typescript ? "typescript" : "javascript"}
1097
+ export async function registerPlugins(app, deps) {
1098
+ const { config } = deps; // Explicit dependency injection
1099
+
1100
+ await app.register(myCustomPlugin, { ...options });
1101
+ }
1102
+ \`\`\`
1103
+
1104
+ ## CLI Commands
1105
+
1106
+ \`\`\`bash
1107
+ # Generate a new resource
1108
+ arc generate resource product
1109
+
1110
+ # Introspect existing schema
1111
+ arc introspect
1112
+
1113
+ # Generate API docs
1114
+ arc docs
1115
+ \`\`\`
1116
+
1117
+ ## Environment Files
1118
+
1119
+ - \`.env.dev\` - Development (default)
1120
+ - \`.env.test\` - Testing
1121
+ - \`.env.prod\` - Production
1122
+ - \`.env\` - Fallback
1123
+
1124
+ ## API Documentation
1125
+
1126
+ API documentation is available via Scalar UI:
1127
+
1128
+ - **Interactive UI**: [http://localhost:8040/docs](http://localhost:8040/docs)
1129
+ - **OpenAPI Spec**: [http://localhost:8040/_docs/openapi.json](http://localhost:8040/_docs/openapi.json)
1130
+
1131
+ ## API Endpoints
1132
+
1133
+ | Method | Endpoint | Description |
1134
+ |--------|----------|-------------|
1135
+ | GET | /docs | API documentation (Scalar UI) |
1136
+ | GET | /_docs/openapi.json | OpenAPI 3.0 spec |
1137
+ | GET | /examples | List all |
1138
+ | GET | /examples/:id | Get by ID |
1139
+ | POST | /examples | Create |
1140
+ | PATCH | /examples/:id | Update |
1141
+ | DELETE | /examples/:id | Delete |
1142
+ `;
1143
+ }
1144
+ function indexTemplate(config) {
1145
+ const ts = config.typescript;
1146
+ return `/**
1147
+ * ${config.name} - Server Entry Point
1148
+ * Generated by Arc CLI
1149
+ *
1150
+ * This file starts the HTTP server.
1151
+ * For workers or other entry points, import createAppInstance from './app.js'
1152
+ */
1153
+
1154
+ // Load environment FIRST (before any other imports)
1155
+ import '#config/env.js';
1156
+
1157
+ import config from '#config/index.js';
1158
+ ${config.adapter === "mongokit" ? "import mongoose from 'mongoose';" : ""}
1159
+ import { createAppInstance } from './app.js';
1160
+
1161
+ async function main()${ts ? ": Promise<void>" : ""} {
1162
+ console.log(\`🔧 Environment: \${config.env}\`);
1163
+ ${config.adapter === "mongokit" ? `
1164
+ // Connect to MongoDB
1165
+ await mongoose.connect(config.database.uri);
1166
+ console.log('📦 Connected to MongoDB');
1167
+ ` : ""}
1168
+ // Create and configure app
1169
+ const app = await createAppInstance();
1170
+
1171
+ // Start server
1172
+ await app.listen({ port: config.server.port, host: config.server.host });
1173
+ console.log(\`🚀 Server running at http://\${config.server.host}:\${config.server.port}\`);
1174
+ }
1175
+
1176
+ main().catch((err) => {
1177
+ console.error('❌ Failed to start server:', err);
1178
+ process.exit(1);
1179
+ });
1180
+ `;
1181
+ }
1182
+ function appTemplate(config) {
1183
+ const ts = config.typescript;
1184
+ const typeImport = ts ? "import type { FastifyInstance } from 'fastify';\n" : "";
1185
+ return `/**
1186
+ * ${config.name} - App Factory
1187
+ * Generated by Arc CLI
1188
+ *
1189
+ * Creates and configures the Fastify app instance.
1190
+ * Can be imported by:
1191
+ * - index.ts (HTTP server)
1192
+ * - worker.ts (background workers)
1193
+ * - tests (integration tests)
1194
+ */
1195
+
1196
+ ${typeImport}import config from '#config/index.js';
1197
+ import { createApp } from '@classytic/arc/factory';
1198
+
1199
+ // App-specific plugins
1200
+ import { registerPlugins } from '#plugins/index.js';
1201
+
1202
+ // Resource registry
1203
+ import { registerResources } from '#resources/index.js';
1204
+
1205
+ /**
1206
+ * Create a fully configured app instance
1207
+ *
1208
+ * @returns Configured Fastify instance ready to use
1209
+ */
1210
+ export async function createAppInstance()${ts ? ": Promise<FastifyInstance>" : ""} {
1211
+ // Create Arc app with base configuration
1212
+ const app = await createApp({
1213
+ preset: config.env === 'production' ? 'production' : 'development',
1214
+ auth: {
1215
+ jwt: { secret: config.jwt.secret },
1216
+ },
1217
+ cors: {
1218
+ origin: config.cors.origins,
1219
+ },
1220
+ });
1221
+
1222
+ // Register app-specific plugins (explicit dependency injection)
1223
+ await registerPlugins(app, { config });
1224
+
1225
+ // Register all resources
1226
+ await registerResources(app);
1227
+
1228
+ return app;
1229
+ }
1230
+
1231
+ export default createAppInstance;
1232
+ `;
1233
+ }
1234
+ function envLoaderTemplate(config) {
1235
+ const ts = config.typescript;
1236
+ return `/**
1237
+ * Environment Loader
1238
+ *
1239
+ * MUST be imported FIRST before any other imports.
1240
+ * Loads .env files based on NODE_ENV.
1241
+ *
1242
+ * Usage:
1243
+ * import './config/env.js'; // First line of entry point
1244
+ */
1245
+
1246
+ import dotenv from 'dotenv';
1247
+ import { existsSync } from 'node:fs';
1248
+ import { resolve } from 'node:path';
1249
+
1250
+ /**
1251
+ * Normalize environment string to short form
1252
+ */
1253
+ function normalizeEnv(env${ts ? ": string | undefined" : ""})${ts ? ": string" : ""} {
1254
+ const normalized = (env || '').toLowerCase();
1255
+ if (normalized === 'production' || normalized === 'prod') return 'prod';
1256
+ if (normalized === 'test' || normalized === 'qa') return 'test';
1257
+ return 'dev';
1258
+ }
1259
+
1260
+ // Determine environment
1261
+ const env = normalizeEnv(process.env.NODE_ENV);
1262
+
1263
+ // Load environment-specific .env file
1264
+ const envFile = resolve(process.cwd(), \`.env.\${env}\`);
1265
+ const defaultEnvFile = resolve(process.cwd(), '.env');
1266
+
1267
+ if (existsSync(envFile)) {
1268
+ dotenv.config({ path: envFile });
1269
+ console.log(\`📄 Loaded: .env.\${env}\`);
1270
+ } else if (existsSync(defaultEnvFile)) {
1271
+ dotenv.config({ path: defaultEnvFile });
1272
+ console.log('📄 Loaded: .env');
1273
+ } else {
1274
+ console.warn('⚠️ No .env file found');
1275
+ }
1276
+
1277
+ // Export for reference
1278
+ export const ENV = env;
1279
+ `;
1280
+ }
1281
+ function envDevTemplate(config) {
1282
+ let content = `# Development Environment
1283
+ NODE_ENV=development
1284
+
1285
+ # Server
1286
+ PORT=8040
1287
+ HOST=0.0.0.0
1288
+
1289
+ # JWT
1290
+ JWT_SECRET=dev-secret-change-in-production-min-32-chars
1291
+ JWT_EXPIRES_IN=7d
1292
+
1293
+ # CORS
1294
+ CORS_ORIGINS=http://localhost:3000,http://localhost:5173
1295
+ `;
1296
+ if (config.adapter === "mongokit") {
1297
+ content += `
1298
+ # MongoDB
1299
+ MONGODB_URI=mongodb://localhost:27017/${config.name}
1300
+ `;
1301
+ }
1302
+ if (config.tenant === "multi") {
1303
+ content += `
1304
+ # Multi-tenant
1305
+ ORG_HEADER=x-organization-id
1306
+ `;
1307
+ }
1308
+ return content;
1309
+ }
1310
+ function pluginsIndexTemplate(config) {
1311
+ const ts = config.typescript;
1312
+ const typeImport = ts ? "import type { FastifyInstance } from 'fastify';\n" : "";
1313
+ const configType = ts ? ": { config: AppConfig }" : "";
1314
+ const appType = ts ? ": FastifyInstance" : "";
1315
+ let content = `/**
1316
+ * App Plugins Registry
1317
+ *
1318
+ * Register your app-specific plugins here.
1319
+ * Dependencies are passed explicitly (no shims, no magic).
1320
+ */
1321
+
1322
+ ${typeImport}${ts ? "import type { AppConfig } from '../config/index.js';\n" : ""}import { openApiPlugin, scalarPlugin } from '@classytic/arc/docs';
1323
+ `;
1324
+ if (config.tenant === "multi") {
1325
+ content += `import { orgScopePlugin } from '@classytic/arc/org';
1326
+ `;
1327
+ }
1328
+ content += `
1329
+ /**
1330
+ * Register all app-specific plugins
1331
+ *
1332
+ * @param app - Fastify instance
1333
+ * @param deps - Explicit dependencies (config, services, etc.)
1334
+ */
1335
+ export async function registerPlugins(
1336
+ app${appType},
1337
+ deps${configType}
1338
+ )${ts ? ": Promise<void>" : ""} {
1339
+ const { config } = deps;
1340
+
1341
+ // API Documentation (Scalar UI)
1342
+ // OpenAPI spec: /_docs/openapi.json
1343
+ // Scalar UI: /docs
1344
+ await app.register(openApiPlugin, {
1345
+ title: '${config.name} API',
1346
+ version: '1.0.0',
1347
+ description: 'API documentation for ${config.name}',
1348
+ });
1349
+ await app.register(scalarPlugin, {
1350
+ routePrefix: '/docs',
1351
+ theme: 'default',
1352
+ });
1353
+ `;
1354
+ if (config.tenant === "multi") {
1355
+ content += `
1356
+ // Multi-tenant org scope
1357
+ await app.register(orgScopePlugin, {
1358
+ header: config.org?.header || 'x-organization-id',
1359
+ bypassRoles: ['superadmin', 'admin'],
1360
+ });
1361
+ `;
1362
+ }
1363
+ content += `
1364
+ // Add your custom plugins here:
1365
+ // await app.register(myCustomPlugin, { ...options });
1366
+ }
1367
+ `;
1368
+ return content;
1369
+ }
1370
+ function resourcesIndexTemplate(config) {
1371
+ const ts = config.typescript;
1372
+ const typeImport = ts ? "import type { FastifyInstance } from 'fastify';\n" : "";
1373
+ const appType = ts ? ": FastifyInstance" : "";
1374
+ return `/**
1375
+ * Resources Registry
1376
+ *
1377
+ * Central registry for all API resources.
1378
+ * Flat structure - no barrels, direct imports.
1379
+ */
1380
+
1381
+ ${typeImport}
1382
+ // Auth resources (register, login, /users/me)
1383
+ import { authResource, userProfileResource } from './auth/auth.resource.js';
1384
+
1385
+ // App resources
1386
+ import exampleResource from './example/example.resource.js';
1387
+
1388
+ // Add more resources here:
1389
+ // import productResource from './product/product.resource.js';
1390
+
1391
+ /**
1392
+ * All registered resources
1393
+ */
1394
+ export const resources = [
1395
+ authResource,
1396
+ userProfileResource,
1397
+ exampleResource,
1398
+ ]${ts ? " as const" : ""};
1399
+
1400
+ /**
1401
+ * Register all resources with the app
1402
+ */
1403
+ export async function registerResources(app${appType})${ts ? ": Promise<void>" : ""} {
1404
+ for (const resource of resources) {
1405
+ await app.register(resource.toPlugin());
1406
+ }
1407
+ }
1408
+ `;
1409
+ }
1410
+ function sharedIndexTemplate(config) {
1411
+ const ts = config.typescript;
1412
+ return `/**
1413
+ * Shared Utilities
1414
+ *
1415
+ * Central exports for resource definitions.
1416
+ * Import from here for clean, consistent code.
1417
+ */
1418
+
1419
+ // Adapter factory
1420
+ export { createAdapter } from './adapter.js';
1421
+
1422
+ // Core Arc exports
1423
+ export { createMongooseAdapter, defineResource } from '@classytic/arc';
1424
+
1425
+ // Permission helpers
1426
+ export {
1427
+ allowPublic,
1428
+ requireAuth,
1429
+ requireRoles,
1430
+ requireOwnership,
1431
+ allOf,
1432
+ anyOf,
1433
+ denyAll,
1434
+ when,${ts ? "\n type PermissionCheck," : ""}
1435
+ } from '@classytic/arc/permissions';
1436
+
1437
+ // Application permissions
1438
+ export * from './permissions.js';
1439
+
1440
+ // Presets
1441
+ export * from './presets/index.js';
1442
+ `;
1443
+ }
1444
+ function createAdapterTemplate(config) {
1445
+ const ts = config.typescript;
1446
+ return `/**
1447
+ * MongoKit Adapter Factory
1448
+ *
1449
+ * Creates Arc adapters using MongoKit repositories.
1450
+ * The repository handles query parsing via MongoKit's built-in QueryParser.
1451
+ */
1452
+
1453
+ import { createMongooseAdapter } from '@classytic/arc';
1454
+ ${ts ? "import type { Model } from 'mongoose';\nimport type { Repository } from '@classytic/mongokit';" : ""}
1455
+
1456
+ /**
1457
+ * Create a MongoKit-powered adapter for a resource
1458
+ *
1459
+ * Note: Query parsing is handled by MongoKit's Repository class.
1460
+ * Just pass the model and repository - Arc handles the rest.
1461
+ */
1462
+ export function createAdapter${ts ? "<TDoc, TRepo extends Repository<TDoc>>" : ""}(
1463
+ model${ts ? ": Model<TDoc>" : ""},
1464
+ repository${ts ? ": TRepo" : ""}
1465
+ )${ts ? ": ReturnType<typeof createMongooseAdapter>" : ""} {
1466
+ return createMongooseAdapter({
1467
+ model,
1468
+ repository,
1469
+ });
1470
+ }
1471
+ `;
1472
+ }
1473
+ function customAdapterTemplate(config) {
1474
+ const ts = config.typescript;
1475
+ return `/**
1476
+ * Custom Adapter Factory
1477
+ *
1478
+ * Implement your own database adapter here.
1479
+ */
1480
+
1481
+ import { createMongooseAdapter } from '@classytic/arc';
1482
+ ${ts ? "import type { Model } from 'mongoose';" : ""}
1483
+
1484
+ /**
1485
+ * Create a custom adapter for a resource
1486
+ *
1487
+ * Implement this based on your database choice:
1488
+ * - Prisma: Use @classytic/prismakit (coming soon)
1489
+ * - Drizzle: Create custom adapter
1490
+ * - Raw SQL: Create custom adapter
1491
+ */
1492
+ export function createAdapter${ts ? "<TDoc>" : ""}(
1493
+ model${ts ? ": Model<TDoc>" : ""},
1494
+ repository${ts ? ": any" : ""}
1495
+ )${ts ? ": ReturnType<typeof createMongooseAdapter>" : ""} {
1496
+ // TODO: Implement your custom adapter
1497
+ return createMongooseAdapter({
1498
+ model,
1499
+ repository,
1500
+ });
1501
+ }
1502
+ `;
1503
+ }
1504
+ function presetsMultiTenantTemplate(config) {
1505
+ const ts = config.typescript;
1506
+ return `/**
1507
+ * Arc Presets - Multi-Tenant Configuration
1508
+ *
1509
+ * Pre-configured presets for multi-tenant applications.
1510
+ * Includes both strict and flexible tenant isolation options.
1511
+ */
1512
+
1513
+ import {
1514
+ multiTenantPreset,
1515
+ ownedByUserPreset,
1516
+ softDeletePreset,
1517
+ slugLookupPreset,
1518
+ } from '@classytic/arc/presets';
1519
+
1520
+ // Flexible preset for mixed public/private routes
1521
+ export { flexibleMultiTenantPreset } from './flexible-multi-tenant.js';
1522
+
1523
+ /**
1524
+ * Organization-scoped preset (STRICT)
1525
+ * Always requires auth, always filters by organizationId.
1526
+ * Use for admin-only resources.
1527
+ */
1528
+ export const orgScoped = multiTenantPreset({
1529
+ tenantField: 'organizationId',
1530
+ bypassRoles: ['superadmin', 'admin'],
1531
+ });
1532
+
1533
+ /**
1534
+ * Owned by creator preset
1535
+ * Filters queries by createdBy field.
1536
+ */
1537
+ export const ownedByCreator = ownedByUserPreset({
1538
+ ownerField: 'createdBy',
1539
+ });
1540
+
1541
+ /**
1542
+ * Owned by user preset
1543
+ * For resources where userId references the owner.
1544
+ */
1545
+ export const ownedByUser = ownedByUserPreset({
1546
+ ownerField: 'userId',
1547
+ });
1548
+
1549
+ /**
1550
+ * Soft delete preset
1551
+ * Adds deletedAt filtering and restore endpoint.
1552
+ */
1553
+ export const softDelete = softDeletePreset();
1554
+
1555
+ /**
1556
+ * Slug lookup preset
1557
+ * Enables GET by slug in addition to ID.
1558
+ */
1559
+ export const slugLookup = slugLookupPreset();
1560
+
1561
+ // Export all presets
1562
+ export const presets = {
1563
+ orgScoped,
1564
+ ownedByCreator,
1565
+ ownedByUser,
1566
+ softDelete,
1567
+ slugLookup,
1568
+ }${ts ? " as const" : ""};
1569
+
1570
+ export default presets;
1571
+ `;
1572
+ }
1573
+ function presetsSingleTenantTemplate(config) {
1574
+ const ts = config.typescript;
1575
+ return `/**
1576
+ * Arc Presets - Single-Tenant Configuration
1577
+ *
1578
+ * Pre-configured presets for single-tenant applications.
1579
+ */
1580
+
1581
+ import {
1582
+ ownedByUserPreset,
1583
+ softDeletePreset,
1584
+ slugLookupPreset,
1585
+ } from '@classytic/arc/presets';
1586
+
1587
+ /**
1588
+ * Owned by creator preset
1589
+ * Filters queries by createdBy field.
1590
+ */
1591
+ export const ownedByCreator = ownedByUserPreset({
1592
+ ownerField: 'createdBy',
1593
+ });
1594
+
1595
+ /**
1596
+ * Owned by user preset
1597
+ * For resources where userId references the owner.
1598
+ */
1599
+ export const ownedByUser = ownedByUserPreset({
1600
+ ownerField: 'userId',
1601
+ });
1602
+
1603
+ /**
1604
+ * Soft delete preset
1605
+ * Adds deletedAt filtering and restore endpoint.
1606
+ */
1607
+ export const softDelete = softDeletePreset();
1608
+
1609
+ /**
1610
+ * Slug lookup preset
1611
+ * Enables GET by slug in addition to ID.
1612
+ */
1613
+ export const slugLookup = slugLookupPreset();
1614
+
1615
+ // Export all presets
1616
+ export const presets = {
1617
+ ownedByCreator,
1618
+ ownedByUser,
1619
+ softDelete,
1620
+ slugLookup,
1621
+ }${ts ? " as const" : ""};
1622
+
1623
+ export default presets;
1624
+ `;
1625
+ }
1626
+ function flexibleMultiTenantPresetTemplate(config) {
1627
+ const ts = config.typescript;
1628
+ const typeAnnotations = ts ? `
1629
+ interface FlexibleMultiTenantOptions {
1630
+ tenantField?: string;
1631
+ bypassRoles?: string[];
1632
+ extractOrganizationId?: (request: any) => string | null;
1633
+ }
1634
+
1635
+ interface PresetMiddlewares {
1636
+ list: ((request: any, reply: any) => Promise<void>)[];
1637
+ get: ((request: any, reply: any) => Promise<void>)[];
1638
+ create: ((request: any, reply: any) => Promise<void>)[];
1639
+ update: ((request: any, reply: any) => Promise<void>)[];
1640
+ delete: ((request: any, reply: any) => Promise<void>)[];
1641
+ }
1642
+
1643
+ interface Preset {
1644
+ [key: string]: unknown;
1645
+ name: string;
1646
+ middlewares: PresetMiddlewares;
1647
+ }
1648
+ ` : "";
1649
+ return `/**
1650
+ * Flexible Multi-Tenant Preset
1651
+ *
1652
+ * Smarter tenant filtering that works with public + authenticated routes.
1653
+ *
1654
+ * Philosophy:
1655
+ * - No org header → No filtering (public data, all orgs)
1656
+ * - Org header present → Require auth, filter by org
1657
+ *
1658
+ * This differs from Arc's strict multiTenant which always requires auth.
1659
+ */
1660
+ ${typeAnnotations}
1661
+ /**
1662
+ * Default organization ID extractor
1663
+ * Tries multiple sources in order of priority
1664
+ */
1665
+ function defaultExtractOrganizationId(request${ts ? ": any" : ""})${ts ? ": string | null" : ""} {
1666
+ // Priority 1: Explicit context (set by org-scope plugin)
1667
+ if (request.context?.organizationId) {
1668
+ return String(request.context.organizationId);
1669
+ }
1670
+
1671
+ // Priority 2: User's organizationId field
1672
+ if (request.user?.organizationId) {
1673
+ return String(request.user.organizationId);
1674
+ }
1675
+
1676
+ // Priority 3: User's organization object (nested)
1677
+ if (request.user?.organization) {
1678
+ const org = request.user.organization;
1679
+ return String(org._id || org.id || org);
1680
+ }
1681
+
1682
+ return null;
1683
+ }
1684
+
1685
+ /**
1686
+ * Create flexible tenant filter middleware
1687
+ * Only filters when org context is present
1688
+ */
1689
+ function createFlexibleTenantFilter(
1690
+ tenantField${ts ? ": string" : ""},
1691
+ bypassRoles${ts ? ": string[]" : ""},
1692
+ extractOrganizationId${ts ? ": (request: any) => string | null" : ""}
1693
+ ) {
1694
+ return async (request${ts ? ": any" : ""}, reply${ts ? ": any" : ""}) => {
1695
+ const user = request.user;
1696
+ const orgId = extractOrganizationId(request);
1697
+
1698
+ // No org context - allow through (public data, no filtering)
1699
+ if (!orgId) {
1700
+ request.log?.debug?.({ msg: 'No org context - showing all data' });
1701
+ return;
1702
+ }
1703
+
1704
+ // Org context present - auth should already be handled by org-scope plugin
1705
+ // But double-check for safety
1706
+ if (!user) {
1707
+ request.log?.warn?.({ msg: 'Org context present but no user - should not happen' });
1708
+ return reply.code(401).send({
1709
+ success: false,
1710
+ error: 'Unauthorized',
1711
+ message: 'Authentication required for organization-scoped data',
1712
+ });
1713
+ }
1714
+
1715
+ // Bypass roles skip filter (superadmin sees all)
1716
+ const userRoles = Array.isArray(user.roles) ? user.roles : [];
1717
+ if (bypassRoles.some((r${ts ? ": string" : ""}) => userRoles.includes(r))) {
1718
+ request.log?.debug?.({ msg: 'Bypass role - no tenant filter' });
1719
+ return;
1720
+ }
1721
+
1722
+ // Apply tenant filter to query
1723
+ request.query = request.query ?? {};
1724
+ request.query._policyFilters = {
1725
+ ...(request.query._policyFilters ?? {}),
1726
+ [tenantField]: orgId,
1727
+ };
1728
+
1729
+ request.log?.debug?.({ msg: 'Tenant filter applied', orgId, tenantField });
1730
+ };
1731
+ }
1732
+
1733
+ /**
1734
+ * Create tenant injection middleware
1735
+ * Injects tenant ID into request body on create
1736
+ */
1737
+ function createTenantInjection(
1738
+ tenantField${ts ? ": string" : ""},
1739
+ extractOrganizationId${ts ? ": (request: any) => string | null" : ""}
1740
+ ) {
1741
+ return async (request${ts ? ": any" : ""}, reply${ts ? ": any" : ""}) => {
1742
+ const orgId = extractOrganizationId(request);
1743
+
1744
+ // Fail-closed: Require orgId for create operations
1745
+ if (!orgId) {
1746
+ return reply.code(403).send({
1747
+ success: false,
1748
+ error: 'Forbidden',
1749
+ message: 'Organization context required to create resources',
1750
+ });
1751
+ }
1752
+
1753
+ if (request.body) {
1754
+ request.body[tenantField] = orgId;
1755
+ }
1756
+ };
1757
+ }
1758
+
1759
+ /**
1760
+ * Flexible Multi-Tenant Preset
1761
+ *
1762
+ * @param options.tenantField - Field name in database (default: 'organizationId')
1763
+ * @param options.bypassRoles - Roles that bypass tenant isolation (default: ['superadmin'])
1764
+ * @param options.extractOrganizationId - Custom org ID extractor function
1765
+ */
1766
+ export function flexibleMultiTenantPreset(options${ts ? ": FlexibleMultiTenantOptions = {}" : " = {}"})${ts ? ": Preset" : ""} {
1767
+ const {
1768
+ tenantField = 'organizationId',
1769
+ bypassRoles = ['superadmin'],
1770
+ extractOrganizationId = defaultExtractOrganizationId,
1771
+ } = options;
1772
+
1773
+ const tenantFilter = createFlexibleTenantFilter(tenantField, bypassRoles, extractOrganizationId);
1774
+ const tenantInjection = createTenantInjection(tenantField, extractOrganizationId);
1775
+
1776
+ return {
1777
+ name: 'flexibleMultiTenant',
1778
+ middlewares: {
1779
+ list: [tenantFilter],
1780
+ get: [tenantFilter],
1781
+ create: [tenantInjection],
1782
+ update: [tenantFilter],
1783
+ delete: [tenantFilter],
1784
+ },
1785
+ };
1786
+ }
1787
+
1788
+ export default flexibleMultiTenantPreset;
1789
+ `;
1790
+ }
1791
+ function permissionsTemplate(config) {
1792
+ const ts = config.typescript;
1793
+ const typeImport = ts ? ",\n type PermissionCheck," : "";
1794
+ const returnType = ts ? ": PermissionCheck" : "";
1795
+ let content = `/**
1796
+ * Permission Helpers
1797
+ *
1798
+ * Clean, type-safe permission definitions for resources.
1799
+ */
1800
+
1801
+ import {
1802
+ requireAuth,
1803
+ requireRoles,
1804
+ requireOwnership,
1805
+ allowPublic,
1806
+ anyOf,
1807
+ allOf,
1808
+ denyAll,
1809
+ when${typeImport}
1810
+ } from '@classytic/arc/permissions';
1811
+
1812
+ // Re-export core helpers
1813
+ export {
1814
+ allowPublic,
1815
+ requireAuth,
1816
+ requireRoles,
1817
+ requireOwnership,
1818
+ allOf,
1819
+ anyOf,
1820
+ denyAll,
1821
+ when,
1822
+ };
1823
+
1824
+ // ============================================================================
1825
+ // Permission Helpers
1826
+ // ============================================================================
1827
+
1828
+ /**
1829
+ * Require any authenticated user
1830
+ */
1831
+ export const requireAuthenticated = ()${returnType} =>
1832
+ requireRoles(['user', 'admin', 'superadmin']);
1833
+
1834
+ /**
1835
+ * Require admin or superadmin
1836
+ */
1837
+ export const requireAdmin = ()${returnType} =>
1838
+ requireRoles(['admin', 'superadmin']);
1839
+
1840
+ /**
1841
+ * Require superadmin only
1842
+ */
1843
+ export const requireSuperadmin = ()${returnType} =>
1844
+ requireRoles(['superadmin']);
1845
+ `;
1846
+ if (config.tenant === "multi") {
1847
+ content += `
1848
+ /**
1849
+ * Require organization owner
1850
+ */
1851
+ export const requireOrgOwner = ()${returnType} =>
1852
+ requireRoles(['owner'], { bypassRoles: ['admin', 'superadmin'] });
1853
+
1854
+ /**
1855
+ * Require organization manager or higher
1856
+ */
1857
+ export const requireOrgManager = ()${returnType} =>
1858
+ requireRoles(['owner', 'manager'], { bypassRoles: ['admin', 'superadmin'] });
1859
+
1860
+ /**
1861
+ * Require organization staff (any org member)
1862
+ */
1863
+ export const requireOrgStaff = ()${returnType} =>
1864
+ requireRoles(['owner', 'manager', 'staff'], { bypassRoles: ['admin', 'superadmin'] });
1865
+ `;
1866
+ }
1867
+ content += `
1868
+ // ============================================================================
1869
+ // Standard Permission Sets
1870
+ // ============================================================================
1871
+
1872
+ /**
1873
+ * Public read, authenticated write (default for most resources)
1874
+ */
1875
+ export const publicReadPermissions = {
1876
+ list: allowPublic(),
1877
+ get: allowPublic(),
1878
+ create: requireAuthenticated(),
1879
+ update: requireAuthenticated(),
1880
+ delete: requireAuthenticated(),
1881
+ };
1882
+
1883
+ /**
1884
+ * All operations require authentication
1885
+ */
1886
+ export const authenticatedPermissions = {
1887
+ list: requireAuth(),
1888
+ get: requireAuth(),
1889
+ create: requireAuth(),
1890
+ update: requireAuth(),
1891
+ delete: requireAuth(),
1892
+ };
1893
+
1894
+ /**
1895
+ * Admin only permissions
1896
+ */
1897
+ export const adminPermissions = {
1898
+ list: requireAdmin(),
1899
+ get: requireAdmin(),
1900
+ create: requireSuperadmin(),
1901
+ update: requireSuperadmin(),
1902
+ delete: requireSuperadmin(),
1903
+ };
1904
+ `;
1905
+ if (config.tenant === "multi") {
1906
+ content += `
1907
+ /**
1908
+ * Organization staff permissions
1909
+ */
1910
+ export const orgStaffPermissions = {
1911
+ list: requireOrgStaff(),
1912
+ get: requireOrgStaff(),
1913
+ create: requireOrgManager(),
1914
+ update: requireOrgManager(),
1915
+ delete: requireOrgOwner(),
1916
+ };
1917
+ `;
1918
+ }
1919
+ return content;
1920
+ }
1921
+ function configTemplate(config) {
1922
+ const ts = config.typescript;
1923
+ let typeDefinition = "";
1924
+ if (ts) {
1925
+ typeDefinition = `
1926
+ export interface AppConfig {
1927
+ env: string;
1928
+ server: {
1929
+ port: number;
1930
+ host: string;
1931
+ };
1932
+ jwt: {
1933
+ secret: string;
1934
+ expiresIn: string;
1935
+ };
1936
+ cors: {
1937
+ origins: string[];
1938
+ };${config.adapter === "mongokit" ? `
1939
+ database: {
1940
+ uri: string;
1941
+ };` : ""}${config.tenant === "multi" ? `
1942
+ org?: {
1943
+ header: string;
1944
+ };` : ""}
1945
+ }
1946
+ `;
1947
+ }
1948
+ return `/**
1949
+ * Application Configuration
1950
+ *
1951
+ * All config is loaded from environment variables.
1952
+ * ENV file is loaded by config/env.ts (imported first in entry points).
1953
+ */
1954
+ ${typeDefinition}
1955
+ const config${ts ? ": AppConfig" : ""} = {
1956
+ env: process.env.NODE_ENV || 'development',
1957
+
1958
+ server: {
1959
+ port: parseInt(process.env.PORT || '8040', 10),
1960
+ host: process.env.HOST || '0.0.0.0',
1961
+ },
1962
+
1963
+ jwt: {
1964
+ secret: process.env.JWT_SECRET || 'dev-secret-change-in-production-min-32',
1965
+ expiresIn: process.env.JWT_EXPIRES_IN || '7d',
1966
+ },
1967
+
1968
+ cors: {
1969
+ origins: (process.env.CORS_ORIGINS || 'http://localhost:3000').split(','),
1970
+ },
1971
+ ${config.adapter === "mongokit" ? `
1972
+ database: {
1973
+ uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/${config.name}',
1974
+ },
1975
+ ` : ""}${config.tenant === "multi" ? `
1976
+ org: {
1977
+ header: process.env.ORG_HEADER || 'x-organization-id',
1978
+ },
1979
+ ` : ""}};
1980
+
1981
+ export default config;
1982
+ `;
1983
+ }
1984
+ function exampleModelTemplate(config) {
1985
+ const ts = config.typescript;
1986
+ const typeExport = ts ? `
1987
+ export type ExampleDocument = mongoose.InferSchemaType<typeof exampleSchema>;
1988
+ export type ExampleModel = mongoose.Model<ExampleDocument>;
1989
+ ` : "";
1990
+ return `/**
1991
+ * Example Model
1992
+ * Generated by Arc CLI
1993
+ */
1994
+
1995
+ import mongoose from 'mongoose';
1996
+
1997
+ const exampleSchema = new mongoose.Schema(
1998
+ {
1999
+ name: { type: String, required: true, trim: true },
2000
+ description: { type: String, trim: true },
2001
+ isActive: { type: Boolean, default: true, index: true },
2002
+ ${config.tenant === "multi" ? " organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true },\n" : ""} createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
2003
+ deletedAt: { type: Date, default: null, index: true },
2004
+ },
2005
+ {
2006
+ timestamps: true,
2007
+ toJSON: { virtuals: true },
2008
+ toObject: { virtuals: true },
2009
+ }
2010
+ );
2011
+
2012
+ // Indexes for common queries
2013
+ exampleSchema.index({ name: 1 });
2014
+ exampleSchema.index({ deletedAt: 1, isActive: 1 });
2015
+ ${config.tenant === "multi" ? "exampleSchema.index({ organizationId: 1, deletedAt: 1 });\n" : ""}${typeExport}
2016
+ const Example = mongoose.model${ts ? "<ExampleDocument>" : ""}('Example', exampleSchema);
2017
+
2018
+ export default Example;
2019
+ `;
2020
+ }
2021
+ function exampleRepositoryTemplate(config) {
2022
+ const ts = config.typescript;
2023
+ const typeImport = ts ? "import type { ExampleDocument } from './model.js';\n" : "";
2024
+ const generic = ts ? "<ExampleDocument>" : "";
2025
+ return `/**
2026
+ * Example Repository
2027
+ * Generated by Arc CLI
2028
+ *
2029
+ * MongoKit repository with plugins for:
2030
+ * - Soft delete (deletedAt filtering)
2031
+ * - Custom business logic methods
2032
+ */
2033
+
2034
+ import {
2035
+ Repository,
2036
+ softDeletePlugin,
2037
+ methodRegistryPlugin,
2038
+ } from '@classytic/mongokit';
2039
+ ${typeImport}import Example from './example.model.js';
2040
+
2041
+ class ExampleRepository extends Repository${generic} {
2042
+ constructor() {
2043
+ super(Example, [
2044
+ methodRegistryPlugin(), // Required for plugin method registration
2045
+ softDeletePlugin(), // Soft delete support
2046
+ ]);
2047
+ }
2048
+
2049
+ /**
2050
+ * Find all active (non-deleted) records
2051
+ */
2052
+ async findActive() {
2053
+ return this.Model.find({ isActive: true, deletedAt: null }).lean();
2054
+ }
2055
+ ${config.tenant === "multi" ? `
2056
+ /**
2057
+ * Find active records for an organization
2058
+ */
2059
+ async findActiveByOrg(organizationId${ts ? ": string" : ""}) {
2060
+ return this.Model.find({
2061
+ organizationId,
2062
+ isActive: true,
2063
+ deletedAt: null,
2064
+ }).lean();
2065
+ }
2066
+ ` : ""}
2067
+ // Note: softDeletePlugin provides restore() and getDeleted() methods automatically
2068
+ }
2069
+
2070
+ const exampleRepository = new ExampleRepository();
2071
+
2072
+ export default exampleRepository;
2073
+ export { ExampleRepository };
2074
+ `;
2075
+ }
2076
+ function exampleResourceTemplate(config) {
2077
+ config.typescript;
2078
+ config.tenant === "multi" ? "['softDelete', 'flexibleMultiTenant']" : "['softDelete']";
2079
+ return `/**
2080
+ * Example Resource
2081
+ * Generated by Arc CLI
2082
+ *
2083
+ * A complete resource with:
2084
+ * - Model (Mongoose schema)
2085
+ * - Repository (MongoKit with plugins)
2086
+ * - Permissions (role-based access)
2087
+ * - Presets (soft delete${config.tenant === "multi" ? ", multi-tenant" : ""})
2088
+ */
2089
+
2090
+ import { defineResource } from '@classytic/arc';
2091
+ import { createAdapter } from '#shared/adapter.js';
2092
+ import { publicReadPermissions } from '#shared/permissions.js';
2093
+ ${config.tenant === "multi" ? "import { flexibleMultiTenantPreset } from '#shared/presets/flexible-multi-tenant.js';\n" : ""}import Example from './example.model.js';
2094
+ import exampleRepository from './example.repository.js';
2095
+ import exampleController from './example.controller.js';
2096
+
2097
+ const exampleResource = defineResource({
2098
+ name: 'example',
2099
+ displayName: 'Examples',
2100
+ prefix: '/examples',
2101
+
2102
+ adapter: createAdapter(Example, exampleRepository),
2103
+ controller: exampleController,
2104
+
2105
+ presets: [
2106
+ 'softDelete',${config.tenant === "multi" ? `
2107
+ flexibleMultiTenantPreset({ tenantField: 'organizationId' }),` : ""}
2108
+ ],
2109
+
2110
+ permissions: publicReadPermissions,
2111
+
2112
+ // Add custom routes here:
2113
+ // additionalRoutes: [
2114
+ // {
2115
+ // method: 'GET',
2116
+ // path: '/custom',
2117
+ // summary: 'Custom endpoint',
2118
+ // handler: async (request, reply) => { ... },
2119
+ // },
2120
+ // ],
2121
+ });
2122
+
2123
+ export default exampleResource;
2124
+ `;
2125
+ }
2126
+ function exampleControllerTemplate(config) {
2127
+ const ts = config.typescript;
2128
+ return `/**
2129
+ * Example Controller
2130
+ * Generated by Arc CLI
2131
+ *
2132
+ * BaseController provides CRUD operations with:
2133
+ * - Automatic pagination
2134
+ * - Query parsing
2135
+ * - Validation
2136
+ */
2137
+
2138
+ import { BaseController } from '@classytic/arc';
2139
+ import exampleRepository from './example.repository.js';
2140
+ import { exampleSchemaOptions } from './example.schemas.js';
3
2141
 
4
- // src/cli/index.ts
5
- async function generate(type, name, options = {}) {
6
- const validTypes = ["resource", "controller", "model"];
7
- if (!validTypes.includes(type)) {
8
- throw new Error(`Unknown generation type: ${type}. Valid types: ${validTypes.join(", ")}`);
9
- }
10
- const {
11
- module: moduleName,
12
- presets = [],
13
- parentField = "parent",
14
- withTests = true,
15
- dryRun = false,
16
- force = false,
17
- outputDir = process.cwd(),
18
- typescript = true
19
- } = options;
20
- const SAFE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*$/;
21
- if (!SAFE_NAME_PATTERN.test(name)) {
22
- throw new Error(`Invalid resource name: "${name}". Use only letters, numbers, and hyphens (must start with letter).`);
23
- }
24
- if (moduleName && !SAFE_NAME_PATTERN.test(moduleName)) {
25
- throw new Error(`Invalid module name: "${moduleName}". Use only letters, numbers, and hyphens (must start with letter).`);
26
- }
27
- const ext = typescript ? "ts" : "js";
28
- const kebab = kebabCase(name);
29
- const dirPath = moduleName ? path.join(outputDir, "modules", moduleName, kebab) : path.join(outputDir, "modules", kebab);
30
- let files;
31
- switch (type) {
32
- case "resource":
33
- files = generateResourceFiles(name, { presets, parentField, module: moduleName, withTests, typescript });
34
- break;
35
- case "controller":
36
- files = [{ name: `${kebab}.controller.${ext}`, content: controllerTemplate(name, { presets, typescript }) }];
37
- break;
38
- case "model":
39
- files = [{ name: `${kebab}.model.${ext}`, content: modelTemplate(name, { presets, parentField, typescript }) }];
40
- break;
41
- default:
42
- throw new Error(`Unknown type: ${type}`);
2142
+ class ExampleController extends BaseController {
2143
+ constructor() {
2144
+ super(exampleRepository${ts ? " as any" : ""}, { schemaOptions: exampleSchemaOptions });
43
2145
  }
44
- console.log(`
45
- 📦 Generating ${type}: ${pascalCase(name)}`);
46
- console.log(`📁 Directory: ${dirPath}`);
47
- console.log(`🔧 Presets: ${presets.length ? presets.join(", ") : "none"}`);
48
- console.log(`📝 Language: ${typescript ? "TypeScript" : "JavaScript"}`);
49
- if (dryRun) {
50
- console.log("\n🏃 DRY RUN - No files created\n");
51
- for (const file of files) {
52
- console.log(` Would create: ${file.name}`);
53
- }
54
- return { files, dirPath };
55
- }
56
- await fs.mkdir(dirPath, { recursive: true });
57
- let created = 0;
58
- let skipped = 0;
59
- for (const file of files) {
60
- const filePath = path.join(dirPath, file.name);
61
- try {
62
- await fs.access(filePath);
63
- if (!force) {
64
- console.log(` ⏭️ Skipped: ${file.name} (exists)`);
65
- skipped++;
66
- continue;
67
- }
68
- } catch {
69
- }
70
- await fs.writeFile(filePath, file.content);
71
- console.log(` ✅ Created: ${file.name}`);
72
- created++;
2146
+
2147
+ // Add custom controller methods here:
2148
+ // async customAction(request, reply) {
2149
+ // // Custom logic
2150
+ // }
2151
+ }
2152
+
2153
+ const exampleController = new ExampleController();
2154
+ export default exampleController;
2155
+ `;
2156
+ }
2157
+ function exampleSchemasTemplate(config) {
2158
+ const ts = config.typescript;
2159
+ const multiTenantFields = config.tenant === "multi";
2160
+ return `/**
2161
+ * Example Schemas
2162
+ * Generated by Arc CLI
2163
+ *
2164
+ * Schema options for controller validation and query parsing
2165
+ */
2166
+
2167
+ import Example from './example.model.js';
2168
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
2169
+
2170
+ /**
2171
+ * CRUD Schemas with Field Rules
2172
+ * Auto-generated from Mongoose model
2173
+ */
2174
+ const crudSchemas = buildCrudSchemasFromModel(Example, {
2175
+ strictAdditionalProperties: true,
2176
+ fieldRules: {
2177
+ // Mark fields as system-managed (excluded from create/update)
2178
+ // deletedAt: { systemManaged: true },
2179
+ },
2180
+ query: {
2181
+ filterableFields: {
2182
+ isActive: 'boolean',${multiTenantFields ? `
2183
+ organizationId: 'ObjectId',` : ""}
2184
+ createdAt: 'date',
2185
+ },
2186
+ },
2187
+ });
2188
+
2189
+ // Schema options for controller
2190
+ export const exampleSchemaOptions${ts ? ": any" : ""} = {
2191
+ query: {${multiTenantFields ? `
2192
+ allowedPopulate: ['organizationId'],` : ""}
2193
+ filterableFields: {
2194
+ isActive: 'boolean',${multiTenantFields ? `
2195
+ organizationId: 'ObjectId',` : ""}
2196
+ createdAt: 'date',
2197
+ },
2198
+ },
2199
+ };
2200
+
2201
+ export default crudSchemas;
2202
+ `;
2203
+ }
2204
+ function exampleTestTemplate(config) {
2205
+ const ts = config.typescript;
2206
+ return `/**
2207
+ * Example Resource Tests
2208
+ * Generated by Arc CLI
2209
+ *
2210
+ * Run tests: npm test
2211
+ * Watch mode: npm run test:watch
2212
+ */
2213
+
2214
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2215
+ ${config.adapter === "mongokit" ? "import mongoose from 'mongoose';\n" : ""}import { createAppInstance } from '../src/app.js';
2216
+ ${ts ? "import type { FastifyInstance } from 'fastify';\n" : ""}
2217
+ describe('Example Resource', () => {
2218
+ let app${ts ? ": FastifyInstance" : ""};
2219
+
2220
+ beforeAll(async () => {
2221
+ ${config.adapter === "mongokit" ? ` // Connect to test database
2222
+ const testDbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/${config.name}-test';
2223
+ await mongoose.connect(testDbUri);
2224
+ ` : ""}
2225
+ // Create app instance
2226
+ app = await createAppInstance();
2227
+ await app.ready();
2228
+ });
2229
+
2230
+ afterAll(async () => {
2231
+ await app.close();
2232
+ ${config.adapter === "mongokit" ? " await mongoose.connection.close();" : ""}
2233
+ });
2234
+
2235
+ describe('GET /examples', () => {
2236
+ it('should return a list of examples', async () => {
2237
+ const response = await app.inject({
2238
+ method: 'GET',
2239
+ url: '/examples',
2240
+ });
2241
+
2242
+ expect(response.statusCode).toBe(200);
2243
+ const body = JSON.parse(response.body);
2244
+ expect(body).toHaveProperty('docs');
2245
+ expect(Array.isArray(body.docs)).toBe(true);
2246
+ });
2247
+ });
2248
+
2249
+ describe('POST /examples', () => {
2250
+ it('should require authentication', async () => {
2251
+ const response = await app.inject({
2252
+ method: 'POST',
2253
+ url: '/examples',
2254
+ payload: { name: 'Test Example' },
2255
+ });
2256
+
2257
+ // Should fail without auth token
2258
+ expect(response.statusCode).toBe(401);
2259
+ });
2260
+ });
2261
+
2262
+ // Add more tests as needed:
2263
+ // - GET /examples/:id
2264
+ // - PATCH /examples/:id
2265
+ // - DELETE /examples/:id
2266
+ // - Custom endpoints
2267
+ });
2268
+ `;
2269
+ }
2270
+ function userModelTemplate(config) {
2271
+ const ts = config.typescript;
2272
+ const orgRoles = config.tenant === "multi" ? `
2273
+ // Organization roles (for multi-tenant)
2274
+ const ORG_ROLES = ['owner', 'manager', 'hr', 'staff', 'contractor'] as const;
2275
+ type OrgRole = typeof ORG_ROLES[number];
2276
+ ` : "";
2277
+ const orgInterface = config.tenant === "multi" ? `
2278
+ type UserOrganization = {
2279
+ organizationId: Types.ObjectId;
2280
+ organizationName: string;
2281
+ roles: OrgRole[];
2282
+ joinedAt: Date;
2283
+ };
2284
+ ` : "";
2285
+ const orgSchema = config.tenant === "multi" ? `
2286
+ // Multi-org support
2287
+ organizations: [{
2288
+ organizationId: { type: Schema.Types.ObjectId, ref: 'Organization', required: true },
2289
+ organizationName: { type: String, required: true },
2290
+ roles: { type: [String], enum: ORG_ROLES, default: [] },
2291
+ joinedAt: { type: Date, default: () => new Date() },
2292
+ }],
2293
+ ` : "";
2294
+ const orgMethods = config.tenant === "multi" ? `
2295
+ // Organization methods
2296
+ userSchema.methods.getOrgRoles = function(orgId${ts ? ": Types.ObjectId | string" : ""}) {
2297
+ const org = this.organizations.find(o => o.organizationId.toString() === orgId.toString());
2298
+ return org?.roles || [];
2299
+ };
2300
+
2301
+ userSchema.methods.hasOrgAccess = function(orgId${ts ? ": Types.ObjectId | string" : ""}) {
2302
+ return this.organizations.some(o => o.organizationId.toString() === orgId.toString());
2303
+ };
2304
+
2305
+ userSchema.methods.addOrganization = function(
2306
+ organizationId${ts ? ": Types.ObjectId" : ""},
2307
+ organizationName${ts ? ": string" : ""},
2308
+ roles${ts ? ": OrgRole[]" : ""} = []
2309
+ ) {
2310
+ const existing = this.organizations.find(o => o.organizationId.toString() === organizationId.toString());
2311
+ if (existing) {
2312
+ existing.organizationName = organizationName;
2313
+ existing.roles = [...new Set([...existing.roles, ...roles])];
2314
+ } else {
2315
+ this.organizations.push({ organizationId, organizationName, roles, joinedAt: new Date() });
73
2316
  }
74
- console.log(`
75
- 🎉 Done! Created ${created} file(s), skipped ${skipped}
76
- `);
77
- if (type === "resource") {
78
- printNextSteps(name, moduleName);
79
- }
80
- return { files, dirPath };
81
- }
82
- function generateResourceFiles(name, options) {
83
- const { presets, parentField, module: moduleName, withTests, typescript } = options;
84
- const kebab = kebabCase(name);
85
- const ext = typescript ? "ts" : "js";
86
- const files = [
87
- { name: `${kebab}.model.${ext}`, content: modelTemplate(name, { presets, parentField, typescript }) },
88
- { name: `${kebab}.repository.${ext}`, content: repositoryTemplate(name, { presets, parentField, typescript }) },
89
- { name: `${kebab}.controller.${ext}`, content: controllerTemplate(name, { presets, typescript }) },
90
- { name: `${kebab}.resource.${ext}`, content: resourceTemplate(name, { presets, parentField, module: moduleName}) },
91
- { name: `routes.${ext}`, content: routesTemplate(name) }
92
- ];
93
- if (withTests) {
94
- files.push({ name: `${kebab}.test.${ext}`, content: testTemplate(name, { presets, typescript }) });
95
- }
96
- return files;
97
- }
98
- function modelTemplate(name, opts) {
99
- const pascal = pascalCase(name);
100
- const camel = camelCase(name);
101
- const { presets = [], parentField = "parent", typescript = true } = opts;
102
- const hasSlug = presets.includes("slugLookup");
103
- const hasSoftDelete = presets.includes("softDelete");
104
- const hasTree = presets.includes("tree");
105
- const hasMultiTenant = presets.includes("multiTenant");
106
- const hasOwned = presets.includes("ownedByUser");
107
- const hasAudited = presets.includes("audited");
2317
+ return this;
2318
+ };
2319
+
2320
+ userSchema.methods.removeOrganization = function(organizationId${ts ? ": Types.ObjectId" : ""}) {
2321
+ this.organizations = this.organizations.filter(o => o.organizationId.toString() !== organizationId.toString());
2322
+ return this;
2323
+ };
2324
+
2325
+ // Index for org queries
2326
+ userSchema.index({ 'organizations.organizationId': 1 });
2327
+ ` : "";
2328
+ const userType = ts ? `
2329
+ type PlatformRole = 'user' | 'admin' | 'superadmin';
2330
+
2331
+ type User = {
2332
+ name: string;
2333
+ email: string;
2334
+ password: string;
2335
+ roles: PlatformRole[];${config.tenant === "multi" ? `
2336
+ organizations: UserOrganization[];` : ""}
2337
+ resetPasswordToken?: string;
2338
+ resetPasswordExpires?: Date;
2339
+ };
2340
+
2341
+ type UserMethods = {
2342
+ matchPassword: (enteredPassword: string) => Promise<boolean>;${config.tenant === "multi" ? `
2343
+ getOrgRoles: (orgId: Types.ObjectId | string) => OrgRole[];
2344
+ hasOrgAccess: (orgId: Types.ObjectId | string) => boolean;
2345
+ addOrganization: (orgId: Types.ObjectId, name: string, roles?: OrgRole[]) => UserDocument;
2346
+ removeOrganization: (orgId: Types.ObjectId) => UserDocument;` : ""}
2347
+ };
2348
+
2349
+ export type UserDocument = HydratedDocument<User, UserMethods>;
2350
+ export type UserModel = Model<User, {}, UserMethods>;
2351
+ ` : "";
108
2352
  return `/**
109
- * ${pascal} Model
110
- * @generated by Arc CLI
2353
+ * User Model
2354
+ * Generated by Arc CLI
111
2355
  */
112
2356
 
113
- import mongoose from 'mongoose';
114
- ${hasSlug ? "import slugPlugin from '@classytic/mongoose-slug-plugin';\n" : ""}
115
- const ${camel}Schema = new mongoose.Schema(
2357
+ import bcrypt from 'bcryptjs';
2358
+ import mongoose${ts ? ", { type HydratedDocument, type Model, type Types }" : ""} from 'mongoose';
2359
+ ${orgRoles}
2360
+ const { Schema } = mongoose;
2361
+ ${orgInterface}${userType}
2362
+ const userSchema = new Schema${ts ? "<User, UserModel, UserMethods>" : ""}(
116
2363
  {
117
2364
  name: { type: String, required: true, trim: true },
118
- ${hasSlug ? " slug: { type: String, unique: true, sparse: true, index: true },\n" : ""}${hasTree ? ` ${parentField}: { type: mongoose.Schema.Types.ObjectId, ref: '${pascal}', default: null, index: true },
119
- displayOrder: { type: Number, default: 0 },
120
- ` : ""}${hasMultiTenant ? " organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true },\n" : ""}${hasOwned ? " createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },\n" : ""} description: { type: String, trim: true },
121
- isActive: { type: Boolean, default: true, index: true },
122
- ${hasSoftDelete ? " deletedAt: { type: Date, default: null, index: true },\n" : ""}${hasAudited ? ` lastModifiedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
123
- ` : ""} },
124
- {
125
- timestamps: true,
126
- toJSON: { virtuals: true },
127
- toObject: { virtuals: true },
128
- }
2365
+ email: {
2366
+ type: String,
2367
+ required: true,
2368
+ unique: true,
2369
+ lowercase: true,
2370
+ trim: true,
2371
+ },
2372
+ password: { type: String, required: true },
2373
+
2374
+ // Platform roles
2375
+ roles: {
2376
+ type: [String],
2377
+ enum: ['user', 'admin', 'superadmin'],
2378
+ default: ['user'],
2379
+ },
2380
+ ${orgSchema}
2381
+ // Password reset
2382
+ resetPasswordToken: String,
2383
+ resetPasswordExpires: Date,
2384
+ },
2385
+ { timestamps: true }
129
2386
  );
130
2387
 
131
- // Indexes
132
- ${camel}Schema.index({ name: 1 });
133
- ${hasSoftDelete ? `${camel}Schema.index({ deletedAt: 1, isActive: 1 });
134
- ` : ""}${hasSlug ? `
135
- ${camel}Schema.plugin(slugPlugin, { sourceField: 'name' });
136
- ` : ""}
137
- ${typescript ? `export type ${pascal}Document = mongoose.InferSchemaType<typeof ${camel}Schema>;
138
- ` : ""}
139
- export const ${pascal} = mongoose.model${typescript ? `<${pascal}Document>` : ""}('${pascal}', ${camel}Schema);
140
- export default ${pascal};
141
- `;
142
- }
143
- function repositoryTemplate(name, opts) {
144
- const pascal = pascalCase(name);
145
- const camel = camelCase(name);
146
- const kebab = kebabCase(name);
147
- const { presets = [], parentField = "parent", typescript = true } = opts;
148
- const hasSoftDelete = presets.includes("softDelete");
149
- const hasSlug = presets.includes("slugLookup");
150
- const hasTree = presets.includes("tree");
151
- const typeImport = typescript ? `
152
- import type { ${pascal}Document } from './${kebab}.model.js';` : "";
153
- const repoGeneric = typescript ? `<${pascal}Document>` : "";
154
- const returnType = (t) => typescript ? `: Promise<${t}>` : "";
2388
+ // Password hashing
2389
+ userSchema.pre('save', async function() {
2390
+ if (!this.isModified('password')) return;
2391
+ const salt = await bcrypt.genSalt(10);
2392
+ this.password = await bcrypt.hash(this.password, salt);
2393
+ });
2394
+
2395
+ // Password comparison
2396
+ userSchema.methods.matchPassword = async function(enteredPassword${ts ? ": string" : ""}) {
2397
+ return bcrypt.compare(enteredPassword, this.password);
2398
+ };
2399
+ ${orgMethods}
2400
+ // Exclude password in JSON
2401
+ userSchema.set('toJSON', {
2402
+ transform: (_doc, ret${ts ? ": any" : ""}) => {
2403
+ delete ret.password;
2404
+ delete ret.resetPasswordToken;
2405
+ delete ret.resetPasswordExpires;
2406
+ return ret;
2407
+ },
2408
+ });
2409
+
2410
+ const User = mongoose.models.User${ts ? " as UserModel" : ""} || mongoose.model${ts ? "<User, UserModel>" : ""}('User', userSchema);
2411
+ export default User;
2412
+ `;
2413
+ }
2414
+ function userRepositoryTemplate(config) {
2415
+ const ts = config.typescript;
2416
+ const typeImport = ts ? "import type { UserDocument } from './model.js';\nimport type { ClientSession, Types } from 'mongoose';\n" : "";
155
2417
  return `/**
156
- * ${pascal} Repository
157
- * @generated by Arc CLI
2418
+ * User Repository
2419
+ * Generated by Arc CLI
2420
+ *
2421
+ * MongoKit repository with plugins for common operations
158
2422
  */
159
2423
 
160
- import { Repository${hasSoftDelete ? ", softDeletePlugin" : ""} } from '@classytic/mongokit';
161
- import { ${pascal} } from './${kebab}.model.js';${typeImport}
2424
+ import {
2425
+ Repository,
2426
+ methodRegistryPlugin,
2427
+ mongoOperationsPlugin,
2428
+ } from '@classytic/mongokit';
2429
+ ${typeImport}import User from './user.model.js';
162
2430
 
163
- class ${pascal}Repository extends Repository${repoGeneric} {
2431
+ ${ts ? "type ID = string | Types.ObjectId;\n" : ""}
2432
+ class UserRepository extends Repository${ts ? "<UserDocument>" : ""} {
164
2433
  constructor() {
165
- super(${pascal}${hasSoftDelete ? ", [softDeletePlugin()]" : ""});
2434
+ super(User${ts ? " as any" : ""}, [
2435
+ methodRegistryPlugin(),
2436
+ mongoOperationsPlugin(),
2437
+ ]);
166
2438
  }
167
2439
 
168
- /** Find all active records */
169
- async findActive()${returnType(`${pascal}Document[]`)} {
170
- return this.Model.find({ isActive: true${hasSoftDelete ? ", deletedAt: null" : ""} }).lean();
171
- }
172
- ${hasSlug ? `
173
- /** Find by slug */
174
- async getBySlug(slug${typescript ? ": string" : ""})${returnType(`${pascal}Document | null`)} {
175
- return this.Model.findOne({ slug: slug.toLowerCase()${hasSoftDelete ? ", deletedAt: null" : ""} }).lean();
176
- }
177
- ` : ""}${hasSoftDelete ? `
178
- /** Get soft-deleted records */
179
- async getDeleted()${returnType(`${pascal}Document[]`)} {
180
- return this.Model.find({ deletedAt: { $ne: null } }).sort({ deletedAt: -1 }).lean();
2440
+ /**
2441
+ * Find user by email
2442
+ */
2443
+ async findByEmail(email${ts ? ": string" : ""}) {
2444
+ return this.Model.findOne({ email: email.toLowerCase().trim() });
181
2445
  }
182
2446
 
183
- /** Restore a soft-deleted record */
184
- async restore(id${typescript ? ": string" : ""})${returnType(`${pascal}Document | null`)} {
185
- return this.Model.findByIdAndUpdate(id, { deletedAt: null }, { new: true }).lean();
2447
+ /**
2448
+ * Find user by reset token
2449
+ */
2450
+ async findByResetToken(token${ts ? ": string" : ""}) {
2451
+ return this.Model.findOne({
2452
+ resetPasswordToken: token,
2453
+ resetPasswordExpires: { $gt: Date.now() },
2454
+ });
186
2455
  }
187
- ` : ""}${hasTree ? `
188
- /** Get hierarchical tree structure */
189
- async getTree()${returnType(`${pascal}Document[]`)} {
190
- const all = await this.Model.find({ isActive: true${hasSoftDelete ? ", deletedAt: null" : ""} })
191
- .sort({ displayOrder: 1 })
192
- .lean();
193
2456
 
194
- const map = new Map${typescript ? `<string, ${pascal}Document & { children: ${pascal}Document[] }>` : ""}();
195
- const roots${typescript ? `: (${pascal}Document & { children: ${pascal}Document[] })[]` : ""} = [];
196
-
197
- for (const item of all) {
198
- map.set(item._id.toString(), { ...item, children: [] });
199
- }
2457
+ /**
2458
+ * Check if email exists
2459
+ */
2460
+ async emailExists(email${ts ? ": string" : ""})${ts ? ": Promise<boolean>" : ""} {
2461
+ const result = await this.Model.exists({ email: email.toLowerCase().trim() });
2462
+ return !!result;
2463
+ }
200
2464
 
201
- for (const item of all) {
202
- const node = map.get(item._id.toString())!;
203
- const parentId = (item${typescript ? " as any" : ""}).${parentField};
204
- if (parentId && map.has(parentId.toString())) {
205
- map.get(parentId.toString())!.children.push(node);
206
- } else {
207
- roots.push(node);
208
- }
209
- }
2465
+ /**
2466
+ * Update user password (triggers hash middleware)
2467
+ */
2468
+ async updatePassword(userId${ts ? ": ID" : ""}, newPassword${ts ? ": string" : ""}, options${ts ? ": { session?: ClientSession }" : ""} = {}) {
2469
+ const user = await this.Model.findById(userId).session(options.session ?? null);
2470
+ if (!user) throw new Error('User not found');
210
2471
 
211
- return roots;
2472
+ user.password = newPassword;
2473
+ user.resetPasswordToken = undefined;
2474
+ user.resetPasswordExpires = undefined;
2475
+ await user.save({ session: options.session ?? undefined });
2476
+ return user;
212
2477
  }
213
2478
 
214
- /** Get direct children of a parent */
215
- async getChildren(parentId${typescript ? ": string" : ""})${returnType(`${pascal}Document[]`)} {
216
- return this.Model.find({
217
- ${parentField}: parentId,
218
- isActive: true${hasSoftDelete ? ",\n deletedAt: null" : ""},
219
- }).sort({ displayOrder: 1 }).lean();
2479
+ /**
2480
+ * Set reset token
2481
+ */
2482
+ async setResetToken(userId${ts ? ": ID" : ""}, token${ts ? ": string" : ""}, expiresAt${ts ? ": Date" : ""}) {
2483
+ return this.Model.findByIdAndUpdate(
2484
+ userId,
2485
+ { resetPasswordToken: token, resetPasswordExpires: expiresAt },
2486
+ { new: true }
2487
+ );
2488
+ }
2489
+ ${config.tenant === "multi" ? `
2490
+ /**
2491
+ * Find users by organization
2492
+ */
2493
+ async findByOrganization(organizationId${ts ? ": ID" : ""}) {
2494
+ return this.Model.find({ 'organizations.organizationId': organizationId })
2495
+ .select('-password -resetPasswordToken -resetPasswordExpires')
2496
+ .lean();
220
2497
  }
221
- ` : ""}}
2498
+ ` : ""}
2499
+ }
222
2500
 
223
- export const ${camel}Repository = new ${pascal}Repository();
224
- export default ${camel}Repository;
2501
+ const userRepository = new UserRepository();
2502
+ export default userRepository;
2503
+ export { UserRepository };
225
2504
  `;
226
2505
  }
227
- function controllerTemplate(name, opts) {
228
- const pascal = pascalCase(name);
229
- const camel = camelCase(name);
230
- const kebab = kebabCase(name);
231
- const { presets = [], typescript = true } = opts;
232
- const hasSoftDelete = presets.includes("softDelete");
233
- const hasSlug = presets.includes("slugLookup");
234
- const hasTree = presets.includes("tree");
2506
+ function userControllerTemplate(config) {
2507
+ const ts = config.typescript;
235
2508
  return `/**
236
- * ${pascal} Controller
237
- * @generated by Arc CLI
2509
+ * User Controller
2510
+ * Generated by Arc CLI
238
2511
  *
239
- * Extends BaseController for built-in security:
240
- * - Organization scoping (multi-tenant isolation)
241
- * - Ownership checks (user data protection)
242
- * - Policy-based filtering
2512
+ * BaseController for user management operations.
2513
+ * Used by auth resource for /users/me endpoints.
243
2514
  */
244
2515
 
245
2516
  import { BaseController } from '@classytic/arc';
246
- ${typescript ? "import type { IRequestContext, IControllerResponse } from '@classytic/arc';\n" : ""}import { ${camel}Repository } from './${kebab}.repository.js';
2517
+ import userRepository from './user.repository.js';
247
2518
 
248
- class ${pascal}Controller extends BaseController {
2519
+ class UserController extends BaseController {
249
2520
  constructor() {
250
- super(${camel}Repository);
251
-
252
- // Bind methods (required for route handler context)
253
- ${hasSlug ? " this.getBySlug = this.getBySlug.bind(this);\n" : ""}${hasSoftDelete ? ` this.getDeleted = this.getDeleted.bind(this);
254
- this.restore = this.restore.bind(this);
255
- ` : ""}${hasTree ? ` this.getTree = this.getTree.bind(this);
256
- this.getChildren = this.getChildren.bind(this);
257
- ` : ""} }
258
-
259
- // ========================================
260
- // Custom Methods (add your own below)
261
- // ========================================
262
-
263
- // Example: Custom search endpoint
264
- // async search(ctx${typescript ? ": IRequestContext" : ""})${typescript ? ": Promise<IControllerResponse>" : ""} {
265
- // const { query } = ctx.query${typescript ? " as { query: string }" : ""};
266
- // const results = await this.repository.Model.find({
267
- // name: { $regex: query, $options: 'i' },
268
- // }).lean();
269
- // return { success: true, data: results, status: 200 };
270
- // }
2521
+ super(userRepository${ts ? " as any" : ""});
2522
+ }
2523
+
2524
+ // Custom user operations can be added here
271
2525
  }
272
2526
 
273
- export const ${camel}Controller = new ${pascal}Controller();
274
- export default ${camel}Controller;
2527
+ const userController = new UserController();
2528
+ export default userController;
275
2529
  `;
276
2530
  }
277
- function resourceTemplate(name, opts) {
278
- const pascal = pascalCase(name);
279
- const camel = camelCase(name);
280
- const kebab = kebabCase(name);
281
- const { presets = [], parentField = "parent", module: moduleName} = opts;
282
- const presetsStr = presets.length > 0 ? presets.map((p) => {
283
- if (p === "tree" && parentField !== "parent") {
284
- return `{ name: 'tree', parentField: '${parentField}' }`;
285
- }
286
- return `'${p}'`;
287
- }).join(",\n ") : "";
2531
+ function authResourceTemplate(config) {
2532
+ const ts = config.typescript;
288
2533
  return `/**
289
- * ${pascal} Resource Definition
290
- * @generated by Arc CLI
2534
+ * Auth Resource
2535
+ * Generated by Arc CLI
2536
+ *
2537
+ * Combined auth + user profile endpoints:
2538
+ * - POST /auth/register
2539
+ * - POST /auth/login
2540
+ * - POST /auth/refresh
2541
+ * - POST /auth/forgot-password
2542
+ * - POST /auth/reset-password
2543
+ * - GET /users/me
2544
+ * - PATCH /users/me
291
2545
  */
292
2546
 
293
- import { defineResource, createMongooseAdapter } from '@classytic/arc';
294
- import { ${pascal} } from './${kebab}.model.js';
295
- import { ${camel}Repository } from './${kebab}.repository.js';
2547
+ import { defineResource } from '@classytic/arc';
2548
+ import { allowPublic, requireAuth } from '@classytic/arc/permissions';
2549
+ import { createAdapter } from '#shared/adapter.js';
2550
+ import User from '../user/user.model.js';
2551
+ import userRepository from '../user/user.repository.js';
2552
+ import * as handlers from './auth.handlers.js';
2553
+ import * as schemas from './auth.schemas.js';
296
2554
 
297
- export default defineResource({
298
- name: '${kebab}',
299
- displayName: '${pascal}s',
300
- ${moduleName ? ` module: '${moduleName}',
301
- ` : ""}
302
- adapter: createMongooseAdapter({
303
- model: ${pascal},
304
- repository: ${camel}Repository,
305
- }),
306
- ${presetsStr ? `
307
- presets: [
308
- ${presetsStr},
309
- ],
310
- ` : ""}
311
- permissions: {
312
- list: [],
313
- get: [],
314
- create: ['admin'],
315
- update: ['admin'],
316
- delete: ['admin'],
317
- },
2555
+ /**
2556
+ * Auth Resource - handles authentication
2557
+ */
2558
+ export const authResource = defineResource({
2559
+ name: 'auth',
2560
+ displayName: 'Authentication',
2561
+ tag: 'Authentication',
2562
+ prefix: '/auth',
318
2563
 
319
- additionalRoutes: [],
2564
+ adapter: createAdapter(User${ts ? " as any" : ""}, userRepository${ts ? " as any" : ""}),
2565
+ disableDefaultRoutes: true,
320
2566
 
321
- events: {
322
- created: { description: '${pascal} created' },
323
- updated: { description: '${pascal} updated' },
324
- deleted: { description: '${pascal} deleted' },
325
- },
2567
+ additionalRoutes: [
2568
+ {
2569
+ method: 'POST',
2570
+ path: '/register',
2571
+ summary: 'Register new user',
2572
+ permissions: allowPublic(),
2573
+ handler: handlers.register,
2574
+ wrapHandler: false,
2575
+ schema: { body: schemas.registerBody },
2576
+ },
2577
+ {
2578
+ method: 'POST',
2579
+ path: '/login',
2580
+ summary: 'User login',
2581
+ permissions: allowPublic(),
2582
+ handler: handlers.login,
2583
+ wrapHandler: false,
2584
+ schema: { body: schemas.loginBody },
2585
+ },
2586
+ {
2587
+ method: 'POST',
2588
+ path: '/refresh',
2589
+ summary: 'Refresh access token',
2590
+ permissions: allowPublic(),
2591
+ handler: handlers.refreshToken,
2592
+ wrapHandler: false,
2593
+ schema: { body: schemas.refreshBody },
2594
+ },
2595
+ {
2596
+ method: 'POST',
2597
+ path: '/forgot-password',
2598
+ summary: 'Request password reset',
2599
+ permissions: allowPublic(),
2600
+ handler: handlers.forgotPassword,
2601
+ wrapHandler: false,
2602
+ schema: { body: schemas.forgotBody },
2603
+ },
2604
+ {
2605
+ method: 'POST',
2606
+ path: '/reset-password',
2607
+ summary: 'Reset password with token',
2608
+ permissions: allowPublic(),
2609
+ handler: handlers.resetPassword,
2610
+ wrapHandler: false,
2611
+ schema: { body: schemas.resetBody },
2612
+ },
2613
+ ],
2614
+ });
2615
+
2616
+ /**
2617
+ * User Profile Resource - handles /users/me
2618
+ */
2619
+ export const userProfileResource = defineResource({
2620
+ name: 'user-profile',
2621
+ displayName: 'User Profile',
2622
+ tag: 'User Profile',
2623
+ prefix: '/users',
2624
+
2625
+ adapter: createAdapter(User${ts ? " as any" : ""}, userRepository${ts ? " as any" : ""}),
2626
+ disableDefaultRoutes: true,
2627
+
2628
+ additionalRoutes: [
2629
+ {
2630
+ method: 'GET',
2631
+ path: '/me',
2632
+ summary: 'Get current user profile',
2633
+ permissions: requireAuth(),
2634
+ handler: handlers.getUserProfile,
2635
+ wrapHandler: false,
2636
+ },
2637
+ {
2638
+ method: 'PATCH',
2639
+ path: '/me',
2640
+ summary: 'Update current user profile',
2641
+ permissions: requireAuth(),
2642
+ handler: handlers.updateUserProfile,
2643
+ wrapHandler: false,
2644
+ schema: { body: schemas.updateUserBody },
2645
+ },
2646
+ ],
326
2647
  });
2648
+
2649
+ export default authResource;
327
2650
  `;
328
2651
  }
329
- function routesTemplate(name, opts) {
330
- const camel = camelCase(name);
331
- const kebab = kebabCase(name);
2652
+ function authHandlersTemplate(config) {
2653
+ const ts = config.typescript;
2654
+ const typeAnnotations = ts ? `
2655
+ import type { FastifyRequest, FastifyReply } from 'fastify';
2656
+ ` : "";
332
2657
  return `/**
333
- * ${pascalCase(name)} Routes
334
- * @generated by Arc CLI
335
- *
336
- * Register this plugin in your app:
337
- * await fastify.register(${camel}Routes);
2658
+ * Auth Handlers
2659
+ * Generated by Arc CLI
2660
+ */
2661
+
2662
+ import jwt from 'jsonwebtoken';
2663
+ import config from '#config/index.js';
2664
+ import userRepository from '../user/user.repository.js';
2665
+ ${typeAnnotations}
2666
+ // Token helpers
2667
+ function generateTokens(userId${ts ? ": string" : ""}) {
2668
+ const accessToken = jwt.sign({ id: userId }, config.jwt.secret, { expiresIn: '15m' });
2669
+ const refreshToken = jwt.sign({ id: userId }, config.jwt.secret, { expiresIn: '7d' });
2670
+ return { accessToken, refreshToken };
2671
+ }
2672
+
2673
+ /**
2674
+ * Register new user
2675
+ */
2676
+ export async function register(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
2677
+ try {
2678
+ const { name, email, password } = request.body${ts ? " as any" : ""};
2679
+
2680
+ // Check if email exists
2681
+ if (await userRepository.emailExists(email)) {
2682
+ return reply.code(400).send({ success: false, message: 'Email already registered' });
2683
+ }
2684
+
2685
+ // Create user
2686
+ await userRepository.create({ name, email, password, roles: ['user'] });
2687
+
2688
+ return reply.code(201).send({ success: true, message: 'User registered successfully' });
2689
+ } catch (error) {
2690
+ request.log.error({ err: error }, 'Register error');
2691
+ return reply.code(500).send({ success: false, message: 'Registration failed' });
2692
+ }
2693
+ }
2694
+
2695
+ /**
2696
+ * Login user
2697
+ */
2698
+ export async function login(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
2699
+ try {
2700
+ const { email, password } = request.body${ts ? " as any" : ""};
2701
+
2702
+ const user = await userRepository.findByEmail(email);
2703
+ if (!user || !(await user.matchPassword(password))) {
2704
+ return reply.code(401).send({ success: false, message: 'Invalid credentials' });
2705
+ }
2706
+
2707
+ const tokens = generateTokens(user._id.toString());
2708
+
2709
+ return reply.send({
2710
+ success: true,
2711
+ user: { id: user._id, name: user.name, email: user.email, roles: user.roles },
2712
+ ...tokens,
2713
+ });
2714
+ } catch (error) {
2715
+ request.log.error({ err: error }, 'Login error');
2716
+ return reply.code(500).send({ success: false, message: 'Login failed' });
2717
+ }
2718
+ }
2719
+
2720
+ /**
2721
+ * Refresh access token
2722
+ */
2723
+ export async function refreshToken(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
2724
+ try {
2725
+ const { token } = request.body${ts ? " as any" : ""};
2726
+ if (!token) {
2727
+ return reply.code(401).send({ success: false, message: 'Refresh token required' });
2728
+ }
2729
+
2730
+ const decoded = jwt.verify(token, config.jwt.secret)${ts ? " as { id: string }" : ""};
2731
+ const tokens = generateTokens(decoded.id);
2732
+
2733
+ return reply.send({ success: true, ...tokens });
2734
+ } catch {
2735
+ return reply.code(401).send({ success: false, message: 'Invalid refresh token' });
2736
+ }
2737
+ }
2738
+
2739
+ /**
2740
+ * Forgot password
2741
+ */
2742
+ export async function forgotPassword(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
2743
+ try {
2744
+ const { email } = request.body${ts ? " as any" : ""};
2745
+ const user = await userRepository.findByEmail(email);
2746
+
2747
+ if (user) {
2748
+ const token = Math.random().toString(36).slice(2) + Date.now().toString(36);
2749
+ const expires = new Date(Date.now() + 3600000); // 1 hour
2750
+ await userRepository.setResetToken(user._id, token, expires);
2751
+ // TODO: Send email with reset link
2752
+ request.log.info(\`Password reset token for \${email}: \${token}\`);
2753
+ }
2754
+
2755
+ // Always return success to prevent email enumeration
2756
+ return reply.send({ success: true, message: 'If email exists, reset link sent' });
2757
+ } catch (error) {
2758
+ request.log.error({ err: error }, 'Forgot password error');
2759
+ return reply.code(500).send({ success: false, message: 'Failed to process request' });
2760
+ }
2761
+ }
2762
+
2763
+ /**
2764
+ * Reset password
2765
+ */
2766
+ export async function resetPassword(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
2767
+ try {
2768
+ const { token, newPassword } = request.body${ts ? " as any" : ""};
2769
+ const user = await userRepository.findByResetToken(token);
2770
+
2771
+ if (!user) {
2772
+ return reply.code(400).send({ success: false, message: 'Invalid or expired token' });
2773
+ }
2774
+
2775
+ await userRepository.updatePassword(user._id, newPassword);
2776
+ return reply.send({ success: true, message: 'Password has been reset' });
2777
+ } catch (error) {
2778
+ request.log.error({ err: error }, 'Reset password error');
2779
+ return reply.code(500).send({ success: false, message: 'Failed to reset password' });
2780
+ }
2781
+ }
2782
+
2783
+ /**
2784
+ * Get current user profile
2785
+ */
2786
+ export async function getUserProfile(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
2787
+ try {
2788
+ const userId = (request${ts ? " as any" : ""}).user?._id || (request${ts ? " as any" : ""}).user?.id;
2789
+ const user = await userRepository.getById(userId);
2790
+
2791
+ if (!user) {
2792
+ return reply.code(404).send({ success: false, message: 'User not found' });
2793
+ }
2794
+
2795
+ return reply.send({ success: true, data: user });
2796
+ } catch (error) {
2797
+ request.log.error({ err: error }, 'Get profile error');
2798
+ return reply.code(500).send({ success: false, message: 'Failed to get profile' });
2799
+ }
2800
+ }
2801
+
2802
+ /**
2803
+ * Update current user profile
338
2804
  */
2805
+ export async function updateUserProfile(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
2806
+ try {
2807
+ const userId = (request${ts ? " as any" : ""}).user?._id || (request${ts ? " as any" : ""}).user?.id;
2808
+ const updates = { ...request.body${ts ? " as any" : ""} };
339
2809
 
340
- import ${camel}Resource from './${kebab}.resource.js';
2810
+ // Prevent updating protected fields
2811
+ if ('password' in updates) delete updates.password;
2812
+ if ('roles' in updates) delete updates.roles;
2813
+ if ('organizations' in updates) delete updates.organizations;
341
2814
 
342
- export default ${camel}Resource.toPlugin();
2815
+ const user = await userRepository.Model.findByIdAndUpdate(userId, updates, { new: true });
2816
+ return reply.send({ success: true, data: user });
2817
+ } catch (error) {
2818
+ request.log.error({ err: error }, 'Update profile error');
2819
+ return reply.code(500).send({ success: false, message: 'Failed to update profile' });
2820
+ }
2821
+ }
343
2822
  `;
344
2823
  }
345
- function testTemplate(name, opts) {
346
- const pascal = pascalCase(name);
347
- const kebab = kebabCase(name);
348
- const { presets = [], typescript = true } = opts;
349
- const hasSoftDelete = presets.includes("softDelete");
350
- const hasSlug = presets.includes("slugLookup");
2824
+ function authSchemasTemplate(config) {
351
2825
  return `/**
352
- * ${pascal} Tests
353
- * @generated by Arc CLI
2826
+ * Auth Schemas
2827
+ * Generated by Arc CLI
354
2828
  */
355
2829
 
356
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
357
- ${typescript ? "import type { FastifyInstance } from 'fastify';\n" : ""}import { createTestApp } from '@classytic/arc/testing';
2830
+ export const registerBody = {
2831
+ type: 'object',
2832
+ required: ['name', 'email', 'password'],
2833
+ properties: {
2834
+ name: { type: 'string', minLength: 2 },
2835
+ email: { type: 'string', format: 'email' },
2836
+ password: { type: 'string', minLength: 6 },
2837
+ },
2838
+ };
2839
+
2840
+ export const loginBody = {
2841
+ type: 'object',
2842
+ required: ['email', 'password'],
2843
+ properties: {
2844
+ email: { type: 'string', format: 'email' },
2845
+ password: { type: 'string' },
2846
+ },
2847
+ };
2848
+
2849
+ export const refreshBody = {
2850
+ type: 'object',
2851
+ required: ['token'],
2852
+ properties: {
2853
+ token: { type: 'string' },
2854
+ },
2855
+ };
2856
+
2857
+ export const forgotBody = {
2858
+ type: 'object',
2859
+ required: ['email'],
2860
+ properties: {
2861
+ email: { type: 'string', format: 'email' },
2862
+ },
2863
+ };
358
2864
 
359
- describe('${pascal} API', () => {
360
- let app${typescript ? ": FastifyInstance" : ""};
2865
+ export const resetBody = {
2866
+ type: 'object',
2867
+ required: ['token', 'newPassword'],
2868
+ properties: {
2869
+ token: { type: 'string' },
2870
+ newPassword: { type: 'string', minLength: 6 },
2871
+ },
2872
+ };
2873
+
2874
+ export const updateUserBody = {
2875
+ type: 'object',
2876
+ properties: {
2877
+ name: { type: 'string', minLength: 2 },
2878
+ email: { type: 'string', format: 'email' },
2879
+ },
2880
+ };
2881
+ `;
2882
+ }
2883
+ function authTestTemplate(config) {
2884
+ const ts = config.typescript;
2885
+ return `/**
2886
+ * Auth Tests
2887
+ * Generated by Arc CLI
2888
+ */
2889
+
2890
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2891
+ ${config.adapter === "mongokit" ? "import mongoose from 'mongoose';\n" : ""}import { createAppInstance } from '../src/app.js';
2892
+ ${ts ? "import type { FastifyInstance } from 'fastify';\n" : ""}
2893
+ describe('Auth', () => {
2894
+ let app${ts ? ": FastifyInstance" : ""};
2895
+ const testUser = {
2896
+ name: 'Test User',
2897
+ email: 'test@example.com',
2898
+ password: 'password123',
2899
+ };
361
2900
 
362
2901
  beforeAll(async () => {
363
- app = await createTestApp({
364
- auth: { jwt: { secret: 'test-secret-32-chars-minimum-len' } },
365
- });
2902
+ ${config.adapter === "mongokit" ? ` const testDbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/${config.name}-test';
2903
+ await mongoose.connect(testDbUri);
2904
+ // Clean up test data
2905
+ await mongoose.connection.collection('users').deleteMany({ email: testUser.email });
2906
+ ` : ""}
2907
+ app = await createAppInstance();
2908
+ await app.ready();
366
2909
  });
367
2910
 
368
2911
  afterAll(async () => {
369
- await app?.close();
2912
+ ${config.adapter === "mongokit" ? ` await mongoose.connection.collection('users').deleteMany({ email: testUser.email });
2913
+ await mongoose.connection.close();
2914
+ ` : ""} await app.close();
370
2915
  });
371
2916
 
372
- describe('CRUD Operations', () => {
373
- let createdId${typescript ? ": string" : ""};
374
-
375
- it('should create a ${kebab}', async () => {
2917
+ describe('POST /auth/register', () => {
2918
+ it('should register a new user', async () => {
376
2919
  const response = await app.inject({
377
2920
  method: 'POST',
378
- url: '/${kebab}s',
379
- payload: { name: 'Test ${pascal}' },
2921
+ url: '/auth/register',
2922
+ payload: testUser,
380
2923
  });
381
2924
 
382
2925
  expect(response.statusCode).toBe(201);
383
- const body = response.json();
384
- expect(body.success).toBe(true);
385
- expect(body.data.name).toBe('Test ${pascal}');
386
- createdId = body.data._id;
387
- });
388
-
389
- it('should list ${kebab}s', async () => {
390
- const response = await app.inject({
391
- method: 'GET',
392
- url: '/${kebab}s',
393
- });
394
-
395
- expect(response.statusCode).toBe(200);
396
- const body = response.json();
2926
+ const body = JSON.parse(response.body);
397
2927
  expect(body.success).toBe(true);
398
- expect(Array.isArray(body.docs || body.data)).toBe(true);
399
2928
  });
400
2929
 
401
- it('should get ${kebab} by id', async () => {
2930
+ it('should reject duplicate email', async () => {
402
2931
  const response = await app.inject({
403
- method: 'GET',
404
- url: \`/${kebab}s/\${createdId}\`,
2932
+ method: 'POST',
2933
+ url: '/auth/register',
2934
+ payload: testUser,
405
2935
  });
406
2936
 
407
- expect(response.statusCode).toBe(200);
408
- expect(response.json().data._id).toBe(createdId);
2937
+ expect(response.statusCode).toBe(400);
409
2938
  });
2939
+ });
410
2940
 
411
- it('should update ${kebab}', async () => {
2941
+ describe('POST /auth/login', () => {
2942
+ it('should login with valid credentials', async () => {
412
2943
  const response = await app.inject({
413
- method: 'PATCH',
414
- url: \`/${kebab}s/\${createdId}\`,
415
- payload: { name: 'Updated ${pascal}' },
2944
+ method: 'POST',
2945
+ url: '/auth/login',
2946
+ payload: { email: testUser.email, password: testUser.password },
416
2947
  });
417
2948
 
418
2949
  expect(response.statusCode).toBe(200);
419
- expect(response.json().data.name).toBe('Updated ${pascal}');
2950
+ const body = JSON.parse(response.body);
2951
+ expect(body.success).toBe(true);
2952
+ expect(body.accessToken).toBeDefined();
2953
+ expect(body.refreshToken).toBeDefined();
420
2954
  });
421
2955
 
422
- it('should delete ${kebab}', async () => {
2956
+ it('should reject invalid credentials', async () => {
423
2957
  const response = await app.inject({
424
- method: 'DELETE',
425
- url: \`/${kebab}s/\${createdId}\`,
2958
+ method: 'POST',
2959
+ url: '/auth/login',
2960
+ payload: { email: testUser.email, password: 'wrongpassword' },
426
2961
  });
427
2962
 
428
- expect(response.statusCode).toBe(200);
429
- expect(response.json().success).toBe(true);
2963
+ expect(response.statusCode).toBe(401);
430
2964
  });
431
2965
  });
432
- ${hasSlug ? `
433
- describe('Slug Lookup', () => {
434
- it('should get by slug', async () => {
435
- // Create with auto-generated slug
436
- const createRes = await app.inject({
437
- method: 'POST',
438
- url: '/${kebab}s',
439
- payload: { name: 'Slug Test Item' },
440
- });
441
- const slug = createRes.json().data.slug;
442
2966
 
2967
+ describe('GET /users/me', () => {
2968
+ it('should require authentication', async () => {
443
2969
  const response = await app.inject({
444
2970
  method: 'GET',
445
- url: \`/${kebab}s/slug/\${slug}\`,
2971
+ url: '/users/me',
446
2972
  });
447
2973
 
448
- expect(response.statusCode).toBe(200);
449
- expect(response.json().data.slug).toBe(slug);
2974
+ expect(response.statusCode).toBe(401);
450
2975
  });
451
2976
  });
452
- ` : ""}${hasSoftDelete ? `
453
- describe('Soft Delete', () => {
454
- it('should soft delete and restore', async () => {
455
- // Create
456
- const createRes = await app.inject({
457
- method: 'POST',
458
- url: '/${kebab}s',
459
- payload: { name: 'Soft Delete Test' },
460
- });
461
- const id = createRes.json().data._id;
2977
+ });
2978
+ `;
2979
+ }
2980
+ function printSuccessMessage(config, skipInstall) {
2981
+ const installStep = skipInstall ? ` npm install
2982
+ ` : "";
2983
+ console.log(`
2984
+ ╔═══════════════════════════════════════════════════════════════╗
2985
+ ║ ✅ Project Created! ║
2986
+ ╚═══════════════════════════════════════════════════════════════╝
462
2987
 
463
- // Delete (soft)
464
- await app.inject({
465
- method: 'DELETE',
466
- url: \`/${kebab}s/\${id}\`,
467
- });
2988
+ Next steps:
468
2989
 
469
- // Verify in deleted list
470
- const deletedRes = await app.inject({
471
- method: 'GET',
472
- url: '/${kebab}s/deleted',
473
- });
474
- expect(deletedRes.json().data.some((d${typescript ? ": any" : ""}) => d._id === id)).toBe(true);
2990
+ cd ${config.name}
2991
+ ${installStep} npm run dev # Uses .env.dev automatically
475
2992
 
476
- // Restore
477
- const restoreRes = await app.inject({
478
- method: 'POST',
479
- url: \`/${kebab}s/\${id}/restore\`,
480
- });
481
- expect(restoreRes.statusCode).toBe(200);
482
- });
483
- });
484
- ` : ""}});
485
- `;
486
- }
487
- function printNextSteps(name, moduleName) {
488
- const kebab = kebabCase(name);
489
- const camel = camelCase(name);
490
- const modulePath = moduleName ? `${moduleName}/${kebab}` : kebab;
491
- console.log(`📋 Next Steps:
2993
+ API Documentation:
2994
+
2995
+ http://localhost:8040/docs # Scalar UI
2996
+ http://localhost:8040/_docs/openapi.json # OpenAPI spec
2997
+
2998
+ Run tests:
492
2999
 
493
- 1. Register the route in your app:
494
- ${`import ${camel}Routes from '#modules/${modulePath}/routes.js';
495
- await fastify.register(${camel}Routes);`}
3000
+ npm test # Run once
3001
+ npm run test:watch # Watch mode
496
3002
 
497
- 2. Run tests:
498
- npm test -- ${kebab}
3003
+ Add resources:
499
3004
 
500
- 3. Access your API:
501
- GET /${kebab}s List all
502
- GET /${kebab}s/:id Get by ID
503
- POST /${kebab}s Create
504
- PATCH /${kebab}s/:id Update
505
- DELETE /${kebab}s/:id Delete
3005
+ 1. Create folder: src/resources/product/
3006
+ 2. Add: index.${config.typescript ? "ts" : "js"}, model.${config.typescript ? "ts" : "js"}, repository.${config.typescript ? "ts" : "js"}
3007
+ 3. Register in src/resources/index.${config.typescript ? "ts" : "js"}
3008
+
3009
+ Project structure:
3010
+
3011
+ src/
3012
+ ├── app.${config.typescript ? "ts" : "js"} # App factory (for workers/tests)
3013
+ ├── index.${config.typescript ? "ts" : "js"} # Server entry
3014
+ ├── config/ # Configuration
3015
+ ├── shared/ # Adapters, presets, permissions
3016
+ ├── plugins/ # App plugins (DI pattern)
3017
+ └── resources/ # API resources
3018
+
3019
+ Documentation:
3020
+ https://github.com/classytic/arc
506
3021
  `);
507
3022
  }
508
- function pascalCase(str) {
509
- return str.split(/[-_\s]+/).map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join("");
510
- }
511
- function camelCase(str) {
512
- const pascal = pascalCase(str);
513
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
3023
+
3024
+ // src/cli/commands/introspect.ts
3025
+ async function introspect(args) {
3026
+ console.log("Introspecting Arc resources...\n");
3027
+ try {
3028
+ const { resourceRegistry: resourceRegistry2 } = await Promise.resolve().then(() => (init_registry(), registry_exports));
3029
+ const resources = resourceRegistry2.getAll();
3030
+ if (resources.length === 0) {
3031
+ console.log("⚠️ No resources registered.");
3032
+ console.log("\nTo introspect resources, you need to load them first:");
3033
+ console.log(" arc introspect --entry ./index.js");
3034
+ console.log("\nWhere index.js imports all your resource definitions.");
3035
+ return;
3036
+ }
3037
+ console.log(`Found ${resources.length} resource(s):
3038
+ `);
3039
+ resources.forEach((resource, index) => {
3040
+ console.log(`${index + 1}. ${resource.name}`);
3041
+ console.log(` Display Name: ${resource.displayName}`);
3042
+ console.log(` Prefix: ${resource.prefix}`);
3043
+ console.log(` Module: ${resource.module || "none"}`);
3044
+ if (resource.permissions) {
3045
+ console.log(` Permissions:`);
3046
+ Object.entries(resource.permissions).forEach(([op, roles]) => {
3047
+ console.log(` ${op}: [${roles.join(", ")}]`);
3048
+ });
3049
+ }
3050
+ if (resource.presets && resource.presets.length > 0) {
3051
+ console.log(` Presets: ${resource.presets.join(", ")}`);
3052
+ }
3053
+ if (resource.additionalRoutes && resource.additionalRoutes.length > 0) {
3054
+ console.log(` Additional Routes: ${resource.additionalRoutes.length}`);
3055
+ }
3056
+ console.log("");
3057
+ });
3058
+ const stats = resourceRegistry2.getStats();
3059
+ console.log("Summary:");
3060
+ console.log(` Total Resources: ${stats.totalResources}`);
3061
+ console.log(` With Presets: ${resources.filter((r) => r.presets?.length > 0).length}`);
3062
+ console.log(
3063
+ ` With Custom Routes: ${resources.filter((r) => r.additionalRoutes && r.additionalRoutes.length > 0).length}`
3064
+ );
3065
+ } catch (error) {
3066
+ console.error("Error:", error.message);
3067
+ console.log("\nTip: Run this command after starting your application.");
3068
+ process.exit(1);
3069
+ }
514
3070
  }
515
- function kebabCase(str) {
516
- return str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
3071
+ async function exportDocs(args) {
3072
+ const [outputPath = "./openapi.json"] = args;
3073
+ console.log("Exporting OpenAPI specification...\n");
3074
+ try {
3075
+ const { resourceRegistry: resourceRegistry2 } = await Promise.resolve().then(() => (init_registry(), registry_exports));
3076
+ const resources = resourceRegistry2.getAll();
3077
+ if (resources.length === 0) {
3078
+ console.warn("⚠️ No resources registered.");
3079
+ console.log("\nTo export docs, you need to load your resources first:");
3080
+ console.log(" arc docs ./openapi.json --entry ./index.js");
3081
+ console.log("\nWhere index.js imports all your resource definitions.");
3082
+ process.exit(1);
3083
+ }
3084
+ const spec = {
3085
+ openapi: "3.0.0",
3086
+ info: {
3087
+ title: "Arc API",
3088
+ version: "1.0.0",
3089
+ description: "Auto-generated from Arc resources"
3090
+ },
3091
+ servers: [
3092
+ {
3093
+ url: "http://localhost:8040/api/v1",
3094
+ description: "Development server"
3095
+ }
3096
+ ],
3097
+ paths: {},
3098
+ components: {
3099
+ schemas: {},
3100
+ securitySchemes: {
3101
+ bearerAuth: {
3102
+ type: "http",
3103
+ scheme: "bearer",
3104
+ bearerFormat: "JWT"
3105
+ }
3106
+ }
3107
+ }
3108
+ };
3109
+ resources.forEach((resource) => {
3110
+ const basePath = resource.prefix || `/${resource.name}s`;
3111
+ spec.paths[basePath] = {
3112
+ get: {
3113
+ tags: [resource.name],
3114
+ summary: `List ${resource.name}s`,
3115
+ security: resource.permissions?.list ? [{ bearerAuth: [] }] : [],
3116
+ parameters: [
3117
+ {
3118
+ name: "page",
3119
+ in: "query",
3120
+ schema: { type: "integer", default: 1 }
3121
+ },
3122
+ {
3123
+ name: "limit",
3124
+ in: "query",
3125
+ schema: { type: "integer", default: 20 }
3126
+ }
3127
+ ],
3128
+ responses: {
3129
+ 200: {
3130
+ description: "Successful response",
3131
+ content: {
3132
+ "application/json": {
3133
+ schema: {
3134
+ type: "object",
3135
+ properties: {
3136
+ success: { type: "boolean" },
3137
+ data: {
3138
+ type: "array",
3139
+ items: { $ref: `#/components/schemas/${resource.name}` }
3140
+ },
3141
+ total: { type: "integer" },
3142
+ page: { type: "integer" }
3143
+ }
3144
+ }
3145
+ }
3146
+ }
3147
+ }
3148
+ }
3149
+ },
3150
+ post: {
3151
+ tags: [resource.name],
3152
+ summary: `Create ${resource.name}`,
3153
+ security: resource.permissions?.create ? [{ bearerAuth: [] }] : [],
3154
+ requestBody: {
3155
+ required: true,
3156
+ content: {
3157
+ "application/json": {
3158
+ schema: { $ref: `#/components/schemas/${resource.name}` }
3159
+ }
3160
+ }
3161
+ },
3162
+ responses: {
3163
+ 201: {
3164
+ description: "Created successfully"
3165
+ }
3166
+ }
3167
+ }
3168
+ };
3169
+ spec.paths[`${basePath}/{id}`] = {
3170
+ get: {
3171
+ tags: [resource.name],
3172
+ summary: `Get ${resource.name} by ID`,
3173
+ security: resource.permissions?.get ? [{ bearerAuth: [] }] : [],
3174
+ parameters: [
3175
+ {
3176
+ name: "id",
3177
+ in: "path",
3178
+ required: true,
3179
+ schema: { type: "string" }
3180
+ }
3181
+ ],
3182
+ responses: {
3183
+ 200: {
3184
+ description: "Successful response"
3185
+ }
3186
+ }
3187
+ },
3188
+ patch: {
3189
+ tags: [resource.name],
3190
+ summary: `Update ${resource.name}`,
3191
+ security: resource.permissions?.update ? [{ bearerAuth: [] }] : [],
3192
+ parameters: [
3193
+ {
3194
+ name: "id",
3195
+ in: "path",
3196
+ required: true,
3197
+ schema: { type: "string" }
3198
+ }
3199
+ ],
3200
+ requestBody: {
3201
+ required: true,
3202
+ content: {
3203
+ "application/json": {
3204
+ schema: { $ref: `#/components/schemas/${resource.name}` }
3205
+ }
3206
+ }
3207
+ },
3208
+ responses: {
3209
+ 200: {
3210
+ description: "Updated successfully"
3211
+ }
3212
+ }
3213
+ },
3214
+ delete: {
3215
+ tags: [resource.name],
3216
+ summary: `Delete ${resource.name}`,
3217
+ security: resource.permissions?.delete ? [{ bearerAuth: [] }] : [],
3218
+ parameters: [
3219
+ {
3220
+ name: "id",
3221
+ in: "path",
3222
+ required: true,
3223
+ schema: { type: "string" }
3224
+ }
3225
+ ],
3226
+ responses: {
3227
+ 200: {
3228
+ description: "Deleted successfully"
3229
+ }
3230
+ }
3231
+ }
3232
+ };
3233
+ spec.components.schemas[resource.name] = {
3234
+ type: "object",
3235
+ properties: {
3236
+ _id: { type: "string" },
3237
+ createdAt: { type: "string", format: "date-time" },
3238
+ updatedAt: { type: "string", format: "date-time" }
3239
+ }
3240
+ };
3241
+ });
3242
+ const fullPath = join(process.cwd(), outputPath);
3243
+ writeFileSync(fullPath, JSON.stringify(spec, null, 2));
3244
+ console.log(`✅ OpenAPI spec exported to: ${fullPath}`);
3245
+ console.log(`
3246
+ Resources included: ${resources.length}`);
3247
+ console.log(`Total endpoints: ${Object.keys(spec.paths).length}`);
3248
+ } catch (error) {
3249
+ console.error("Error:", error.message);
3250
+ process.exit(1);
3251
+ }
517
3252
  }
518
- var cli_default = { generate };
519
3253
 
520
- export { cli_default as default, generate };
3254
+ export { exportDocs, generate, init, introspect };