@classytic/arc 2.15.4 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +1 -0
  2. package/bin/arc.js +12 -0
  3. package/dist/{BaseController-dx3m2J8V.mjs → BaseController-DlCCTIxJ.mjs} +61 -19
  4. package/dist/{HookSystem-Iiebom92.mjs → HookSystem-Cmf7-Etp.mjs} +8 -4
  5. package/dist/{QueryCache-D41bfdBB.d.mts → QueryCache-SvmT_9ti.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-CTERg_2x.mjs → ResourceRegistry-f48hFk3m.mjs} +52 -9
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/audit/index.mjs +4 -2
  9. package/dist/auth/index.d.mts +4 -4
  10. package/dist/auth/index.mjs +4 -4
  11. package/dist/auth/redis-session.d.mts +1 -1
  12. package/dist/{betterAuthOpenApi--M_i87dQ.mjs → betterAuthOpenApi-ClWxaceA.mjs} +10 -6
  13. package/dist/buildHandler-BZX6zzDM.mjs +300 -0
  14. package/dist/cache/index.d.mts +3 -3
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/{caching-SM8gghN6.mjs → caching-TeHE8G-v.mjs} +1 -1
  17. package/dist/cli/commands/describe.d.mts +35 -1
  18. package/dist/cli/commands/describe.mjs +52 -12
  19. package/dist/cli/commands/docs.d.mts +1 -4
  20. package/dist/cli/commands/docs.mjs +4 -16
  21. package/dist/cli/commands/generate.d.mts +2 -20
  22. package/dist/cli/commands/generate.mjs +1 -546
  23. package/dist/cli/commands/init.d.mts +2 -40
  24. package/dist/cli/commands/init.mjs +1 -3045
  25. package/dist/cli/commands/introspect.mjs +53 -64
  26. package/dist/cli/index.d.mts +2 -2
  27. package/dist/cli/index.mjs +2 -2
  28. package/dist/{constants-Cxde4rpC.mjs → constants-TrJVIJl0.mjs} +7 -0
  29. package/dist/core/index.d.mts +3 -3
  30. package/dist/core/index.mjs +5 -5
  31. package/dist/{core-CvmOqEms.mjs → core-DBJ_j6rX.mjs} +222 -44
  32. package/dist/createActionRouter-DUpN3Dd1.mjs +288 -0
  33. package/dist/{createAggregationRouter-B0bPDf5b.mjs → createAggregationRouter-Dq-TUCuY.mjs} +3 -2
  34. package/dist/{createApp-PFegs47-.mjs → createApp-DNccuhyI.mjs} +16 -14
  35. package/dist/{defineEvent-D5h7EvAx.mjs → defineEvent-DRwY0fYm.mjs} +1 -1
  36. package/dist/docs/index.d.mts +2 -2
  37. package/dist/docs/index.mjs +1 -1
  38. package/dist/{errorHandler-Bk-AGhkU.mjs → errorHandler-DpoXQHZ9.mjs} +17 -14
  39. package/dist/errors-C1lX_jlm.d.mts +91 -0
  40. package/dist/{eventPlugin-CaKTYkYM.mjs → eventPlugin-C2cGqtRO.mjs} +1 -1
  41. package/dist/{eventPlugin-qXpqTebY.d.mts → eventPlugin-CtHC_av1.d.mts} +1 -1
  42. package/dist/events/index.d.mts +3 -3
  43. package/dist/events/index.mjs +5 -5
  44. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +1 -1
  47. package/dist/factory/index.mjs +2 -2
  48. package/dist/{fields-COhcH3fk.d.mts → fields-Anj0xdih.d.mts} +1 -1
  49. package/dist/generate-BWFwgcCM.d.mts +38 -0
  50. package/dist/generate-CYac-OLv.mjs +654 -0
  51. package/dist/hooks/index.d.mts +1 -1
  52. package/dist/hooks/index.mjs +1 -1
  53. package/dist/idempotency/index.d.mts +2 -2
  54. package/dist/idempotency/index.mjs +1 -1
  55. package/dist/idempotency/redis.d.mts +1 -1
  56. package/dist/{index-BTqLEvhu.d.mts → index-3oIimXQn.d.mts} +12 -12
  57. package/dist/{index-BstGxcc3.d.mts → index-B-ulKx5P.d.mts} +55 -4
  58. package/dist/{index-BswOSJCE.d.mts → index-CkW0flkU.d.mts} +355 -16
  59. package/dist/index.d.mts +6 -6
  60. package/dist/index.mjs +7 -8
  61. package/dist/init-Dv71MsJr.d.mts +71 -0
  62. package/dist/init-HDvoO9L5.mjs +3098 -0
  63. package/dist/integrations/event-gateway.d.mts +2 -2
  64. package/dist/integrations/event-gateway.mjs +1 -1
  65. package/dist/integrations/index.d.mts +2 -2
  66. package/dist/integrations/jobs.mjs +3 -3
  67. package/dist/integrations/mcp/index.d.mts +239 -7
  68. package/dist/integrations/mcp/index.mjs +2 -528
  69. package/dist/integrations/mcp/testing.d.mts +2 -2
  70. package/dist/integrations/mcp/testing.mjs +6 -10
  71. package/dist/integrations/streamline.mjs +26 -1
  72. package/dist/integrations/websocket-redis.d.mts +1 -1
  73. package/dist/integrations/websocket.d.mts +1 -1
  74. package/dist/integrations/websocket.mjs +1 -0
  75. package/dist/loadResourcesFromEntry-BLMEI2Xa.mjs +51 -0
  76. package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-7vGV51ED.mjs} +1021 -318
  77. package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
  78. package/dist/middleware/index.d.mts +1 -1
  79. package/dist/middleware/index.mjs +1 -1
  80. package/dist/{openapi-BHXhoX8O.mjs → openapi-34T9yNwd.mjs} +47 -36
  81. package/dist/permissions/index.d.mts +2 -2
  82. package/dist/permissions/index.mjs +1 -1
  83. package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
  84. package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
  85. package/dist/pipeline/index.d.mts +1 -1
  86. package/dist/pipeline/index.mjs +1 -1
  87. package/dist/plugins/index.d.mts +5 -5
  88. package/dist/plugins/index.mjs +10 -10
  89. package/dist/plugins/response-cache.mjs +5 -5
  90. package/dist/plugins/tracing-entry.d.mts +1 -1
  91. package/dist/plugins/tracing-entry.mjs +1 -1
  92. package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
  93. package/dist/presets/filesUpload.d.mts +4 -4
  94. package/dist/presets/filesUpload.mjs +2 -2
  95. package/dist/presets/index.d.mts +1 -1
  96. package/dist/presets/index.mjs +1 -1
  97. package/dist/presets/multiTenant.d.mts +1 -1
  98. package/dist/presets/multiTenant.mjs +4 -3
  99. package/dist/presets/search.d.mts +2 -2
  100. package/dist/presets/search.mjs +1 -1
  101. package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
  102. package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
  103. package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Biqzfbi5.d.mts} +2 -2
  104. package/dist/{redis-DiMkdHEl.d.mts → redis-Cyzrz6SX.d.mts} +1 -1
  105. package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-DT-YjzrB.d.mts} +1 -1
  106. package/dist/registry/index.d.mts +319 -2
  107. package/dist/registry/index.mjs +3 -3
  108. package/dist/registry-BBE23CDj.mjs +576 -0
  109. package/dist/{routerShared-DrOa-26E.mjs → routerShared-CZV5aabX.mjs} +3 -3
  110. package/dist/scope/index.d.mts +3 -3
  111. package/dist/scope/index.mjs +3 -3
  112. package/dist/{sse-Bz-5ZeTt.mjs → sse-BY6sTy4P.mjs} +1 -1
  113. package/dist/testing/index.d.mts +2 -2
  114. package/dist/testing/index.mjs +16 -7
  115. package/dist/testing/storageContract.d.mts +1 -1
  116. package/dist/types/index.d.mts +5 -5
  117. package/dist/types/storage.d.mts +1 -1
  118. package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
  119. package/dist/{types-BQsjgQzS.d.mts → types-BsJMEQ4D.d.mts} +106 -12
  120. package/dist/{types-DrBaUwyV.d.mts → types-D-fYtKjb.d.mts} +33 -10
  121. package/dist/{types-CTYvcwHe.d.mts → types-DVfpSfx2.d.mts} +42 -1
  122. package/dist/utils/index.d.mts +1286 -2
  123. package/dist/utils/index.mjs +1 -1
  124. package/dist/{utils-_h9B3c57.mjs → utils-DC5ycPfr.mjs} +89 -40
  125. package/dist/{buildHandler-CcFOpJLh.mjs → validate-By96rH0r.mjs} +8 -299
  126. package/dist/{versioning-hmkPcDlX.d.mts → versioning-ZwX9tmbS.d.mts} +1 -1
  127. package/package.json +21 -28
  128. package/skills/arc/SKILL.md +300 -706
  129. package/skills/arc/references/auth.md +19 -7
  130. package/skills/arc-code-review/SKILL.md +1 -1
  131. package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
  132. package/dist/createActionRouter-S3MLVYot.mjs +0 -220
  133. package/dist/index-bRjYu21O.d.mts +0 -1320
  134. package/dist/org/index.d.mts +0 -66
  135. package/dist/org/index.mjs +0 -486
  136. package/dist/org/types.d.mts +0 -82
  137. package/dist/org/types.mjs +0 -1
  138. package/dist/registry-I-ogLgL9.mjs +0 -46
  139. /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-C-2oAHtw.d.mts} +0 -0
  140. /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-Hxvv5QQz.mjs} +0 -0
  141. /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
  142. /package/dist/{elevation-BXOWoGCF.d.mts → elevation-0YBpa663.d.mts} +0 -0
  143. /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
  144. /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-mHuyWzZE.d.mts} +0 -0
  145. /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-DFg-2KTp.d.mts} +0 -0
  146. /package/dist/{interface-beEtJyWM.d.mts → interface-CH0OQudo.d.mts} +0 -0
  147. /package/dist/{interface-DfLGcus7.d.mts → interface-NwJ_qPlY.d.mts} +0 -0
  148. /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
  149. /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-ChQEj8ih.mjs} +0 -0
  150. /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-TuOmguhi.mjs} +0 -0
  151. /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-C-gD32oF.mjs} +0 -0
  152. /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-Ctc89DSn.mjs} +0 -0
  153. /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-BqFegc0W.d.mts} +0 -0
  154. /package/dist/{storage-Dfzt4VTl.d.mts → storage-D2KZJAmn.d.mts} +0 -0
  155. /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-B0sunfZZ.mjs} +0 -0
  156. /package/dist/{tracing-QJVprktp.d.mts → tracing-Dm8n7Cnn.d.mts} +0 -0
  157. /package/dist/{versioning-BUrT5aP4.mjs → versioning-B6mimogM.mjs} +0 -0
  158. /package/dist/{websocket-ChC2rqe1.d.mts → websocket-BkjeGZRn.d.mts} +0 -0
@@ -0,0 +1,3098 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { accessSync } from "node:fs";
4
+ import * as readline from "node:readline";
5
+ import { execSync, spawn } from "node:child_process";
6
+ //#region src/cli/commands/init/templates/adapter.ts
7
+ function createAdapterTemplate(config) {
8
+ const ts = config.typescript;
9
+ return `/**
10
+ * MongoKit Adapter Factory
11
+ *
12
+ * Creates Arc adapters using MongoKit repositories.
13
+ * The repository handles query parsing via MongoKit's built-in QueryParser.
14
+ */
15
+
16
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
17
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
18
+ ${ts ? "import type { Model } from 'mongoose';\nimport type { Repository } from '@classytic/mongokit';" : ""}
19
+
20
+ /**
21
+ * Create a MongoKit-powered adapter for a resource.
22
+ *
23
+ * Note: Query parsing is handled by MongoKit's Repository class.
24
+ * \`buildCrudSchemasFromModel\` is the canonical OpenAPI schema generator
25
+ * for arc + Mongoose (arc 2.12+ no longer ships a built-in fallback —
26
+ * passing it explicitly is required for OpenAPI auto-generation).
27
+ */
28
+ export function createAdapter${ts ? "<TDoc = unknown>" : ""}(
29
+ model${ts ? ": Model<TDoc>" : ""},
30
+ repository${ts ? ": Repository<TDoc>" : ""}
31
+ ) {
32
+ return createMongooseAdapter({
33
+ model,
34
+ repository,
35
+ schemaGenerator: buildCrudSchemasFromModel,
36
+ });
37
+ }
38
+ `;
39
+ }
40
+ function customAdapterTemplate(config) {
41
+ const ts = config.typescript;
42
+ return `/**
43
+ * Custom Adapter Factory
44
+ *
45
+ * Use this for the bring-your-own-repository path — any object that
46
+ * satisfies the \`RepositoryLike\` contract from
47
+ * \`@classytic/repo-core/adapter\` plugs in here. Each classytic kit
48
+ * also ships its own \`/adapter\` subpath; if one of those fits, import
49
+ * its factory directly instead.
50
+ */
51
+
52
+ ${ts ? "import type { DataAdapter, RepositoryLike } from '@classytic/repo-core/adapter';" : ""}
53
+
54
+ /**
55
+ * Create a custom adapter for a resource.
56
+ *
57
+ * Pass any object satisfying \`RepositoryLike<TDoc>\` (a 5-method floor:
58
+ * \`getAll\` / \`getById\` / \`create\` / \`update\` / \`delete\`, plus any
59
+ * optional \`StandardRepo\` methods you implement).
60
+ */
61
+ export function createAdapter${ts ? "<TDoc = unknown>" : ""}(
62
+ _source${ts ? ": unknown" : ""},
63
+ repository${ts ? ": RepositoryLike<TDoc>" : ""}
64
+ )${ts ? ": DataAdapter<TDoc>" : ""} {
65
+ return {
66
+ type: 'custom',
67
+ name: 'custom-repository',
68
+ repository,
69
+ };
70
+ }
71
+ `;
72
+ }
73
+ //#endregion
74
+ //#region src/cli/commands/init/templates/app.ts
75
+ function indexTemplate(config) {
76
+ const ts = config.typescript;
77
+ if (config.edge) return edgeIndexTemplate(config);
78
+ return `/**
79
+ * ${config.name} - Server Entry Point
80
+ * Generated by Arc CLI
81
+ *
82
+ * This file starts the HTTP server.
83
+ * For workers or other entry points, import createAppInstance from './app.js'
84
+ */
85
+
86
+ // Load environment FIRST (before any other imports)
87
+ import '#config/env.js';
88
+
89
+ import config from '#config/index.js';
90
+ ${config.adapter === "mongokit" ? "import mongoose from 'mongoose';" : ""}
91
+ import { createAppInstance } from './app.js';
92
+
93
+ async function main()${ts ? ": Promise<void>" : ""} {
94
+ console.log(\`Environment: \${config.env}\`);
95
+ ${config.adapter === "mongokit" ? `
96
+ // Connect to MongoDB
97
+ await mongoose.connect(config.database.uri);
98
+ console.log('Connected to MongoDB');
99
+ ` : ""}
100
+ // Create and configure app
101
+ const app = await createAppInstance();
102
+
103
+ // Start server
104
+ await app.listen({ port: config.server.port, host: config.server.host });
105
+ console.log(\`Server running at http://\${config.server.host}:\${config.server.port}\`);
106
+ }
107
+
108
+ main().catch((err) => {
109
+ console.error('Failed to start server:', err);
110
+ process.exit(1);
111
+ });
112
+ `;
113
+ }
114
+ /**
115
+ * Edge/serverless entry point — exports a Web Standards fetch handler.
116
+ * Works on Cloudflare Workers, AWS Lambda, Vercel Serverless, etc.
117
+ */
118
+ function edgeIndexTemplate(config) {
119
+ const ts = config.typescript;
120
+ const dbNote = config.adapter === "mongokit" ? ` *\n * NOTE: Mongoose does NOT work on Cloudflare Workers. This entry point\n * works on AWS Lambda and Vercel Serverless (Node.js runtime) where\n * Mongoose/MongoKit works normally. For Cloudflare Workers, switch to\n * Drizzle + Hyperdrive (PostgreSQL) or the raw mongodb driver.` : "";
121
+ return `/**
122
+ * ${config.name} - Edge/Serverless Entry Point
123
+ * Generated by Arc CLI
124
+ *
125
+ * Exports a Web Standards fetch handler that works on:
126
+ * - Cloudflare Workers (enable nodejs_compat in wrangler.toml)
127
+ * - AWS Lambda (via fetch-based adapter)
128
+ * - Vercel Serverless Functions
129
+ * - Any runtime supporting the Web Standards Request/Response API
130
+ *
131
+ * No app.listen() — routes through Fastify's .inject() internally.
132
+ ${dbNote}
133
+ */
134
+
135
+ import { toFetchHandler } from '@classytic/arc/factory';
136
+ import { createAppInstance } from './app.js';
137
+
138
+ const app = await createAppInstance();
139
+ const handler = toFetchHandler(app);
140
+
141
+ /**
142
+ * Cloudflare Workers / generic fetch handler
143
+ */
144
+ export default {
145
+ async fetch(request${ts ? ": Request" : ""})${ts ? ": Promise<Response>" : ""} {
146
+ return handler(request);
147
+ },
148
+ };
149
+
150
+ /**
151
+ * Named export for platforms that expect it (Vercel, AWS Lambda adapters)
152
+ */
153
+ export { handler };
154
+ `;
155
+ }
156
+ function appTemplate(config) {
157
+ const ts = config.typescript;
158
+ const typeImport = ts ? "import type { FastifyInstance } from 'fastify';\n" : "";
159
+ const betterAuthImport = config.auth === "better-auth" ? `import { createBetterAuthAdapter } from '@classytic/arc/auth';
160
+ import { getAuth } from './auth.js';
161
+ ` : "";
162
+ const authConfig = config.auth === "better-auth" ? `auth: {
163
+ type: 'betterAuth',
164
+ betterAuth: createBetterAuthAdapter({ auth: getAuth(), orgContext: true }),
165
+ },` : `auth: {
166
+ type: 'jwt',
167
+ jwt: { secret: config.jwt.secret },
168
+ },`;
169
+ return `/**
170
+ * ${config.name} - App Factory
171
+ * Generated by Arc CLI
172
+ *
173
+ * Creates and configures the Fastify app instance.
174
+ * Can be imported by:
175
+ * - index.ts (HTTP server via app.listen, or edge handler via toFetchHandler)
176
+ * - worker.ts (background workers)
177
+ * - tests (integration tests via app.inject)
178
+ */
179
+
180
+ ${typeImport}import config from '#config/index.js';
181
+ import { createApp, loadResources } from '@classytic/arc/factory';
182
+ ${betterAuthImport}
183
+ // App-specific plugins
184
+ import { registerPlugins } from '#plugins/index.js';
185
+
186
+ // Resource registry
187
+ import { resources, registerResources } from '#resources/index.js';
188
+
189
+ /**
190
+ * Create a fully configured app instance
191
+ *
192
+ * @returns Configured Fastify instance ready to use
193
+ */
194
+ export async function createAppInstance()${ts ? ": Promise<FastifyInstance>" : ""} {
195
+ // Create Arc app with resources and base configuration. \`resourcePrefix\`
196
+ // mounts every resource under \`/api\`, matching the \`apiPrefix\` the
197
+ // OpenAPI plugin advertises — keeps docs and routes in agreement.
198
+ const app = await createApp({
199
+ preset: config.env === 'production' ? (${config.edge ? "'edge'" : "'production'"}) : 'development',
200
+ resources,
201
+ resourcePrefix: '/api',
202
+ ${authConfig}
203
+ cors: {
204
+ origin: config.cors.origins,
205
+ methods: config.cors.methods,
206
+ allowedHeaders: config.cors.allowedHeaders,
207
+ credentials: config.cors.credentials,
208
+ },
209
+ trustProxy: true,
210
+ arcPlugins: {
211
+ metrics: config.env === 'production', // Prometheus /_metrics endpoint
212
+ },
213
+ });
214
+
215
+ // Register app-specific plugins (explicit dependency injection)
216
+ await registerPlugins(app, { config });
217
+
218
+ return app;
219
+ }
220
+
221
+ export default createAppInstance;
222
+ `;
223
+ }
224
+ function envLoaderTemplate(config) {
225
+ const ts = config.typescript;
226
+ return `/**
227
+ * Environment Loader
228
+ *
229
+ * MUST be imported FIRST before any other imports.
230
+ * Loads .env files based on NODE_ENV with Next.js-style priority:
231
+ *
232
+ * .env.local (always loaded first — gitignored, machine-specific overrides)
233
+ * .env.{environment} (e.g., .env.production, .env.dev, .env.test)
234
+ * .env (fallback defaults)
235
+ *
236
+ * Supports both long-form (production, development, test) and
237
+ * short-form (prod, dev, test) env file names.
238
+ *
239
+ * Usage:
240
+ * import '#config/env.js'; // First line of entry point
241
+ */
242
+
243
+ import dotenv from 'dotenv';
244
+ import { existsSync } from 'node:fs';
245
+ import { resolve } from 'node:path';
246
+
247
+ ${ts ? "type EnvName = 'prod' | 'dev' | 'test';\n" : ""}const ENV_ALIASES${ts ? ": Record<EnvName, string>" : ""} = {
248
+ prod: 'production',
249
+ dev: 'development',
250
+ test: 'test',
251
+ };
252
+
253
+ function normalizeEnv(env${ts ? ": string | undefined" : ""})${ts ? ": EnvName" : ""} {
254
+ const raw = (env || '').toLowerCase();
255
+ if (raw === 'production' || raw === 'prod') return 'prod';
256
+ if (raw === 'test' || raw === 'qa') return 'test';
257
+ return 'dev';
258
+ }
259
+
260
+ const env = normalizeEnv(process.env.NODE_ENV);
261
+ const longForm = ENV_ALIASES[env];
262
+
263
+ // Priority: .env.local → .env.{long} → .env.{short} → .env
264
+ // Same convention as Next.js — .env.local always wins, never committed to git
265
+ const candidates = [
266
+ '.env.local',
267
+ \`.env.\${longForm}\`,
268
+ \`.env.\${env}\`,
269
+ '.env',
270
+ ].map((f) => resolve(process.cwd(), f));
271
+
272
+ const loaded${ts ? ": string[]" : ""} = [];
273
+ for (const file of candidates) {
274
+ if (existsSync(file)) {
275
+ // override: false means earlier files take priority (first loaded wins)
276
+ dotenv.config({ path: file, override: false });
277
+ loaded.push(file.split(/[\\\\/]/).pop()${ts ? "!" : ""});
278
+ }
279
+ }
280
+
281
+ // Only log in development (silent in production/test)
282
+ if (env === 'dev' && loaded.length > 0) {
283
+ console.log(\`env: \${loaded.join(' + ')}\`);
284
+ } else if (loaded.length === 0) {
285
+ console.warn('No .env file found — using process environment only');
286
+ }
287
+
288
+ export const ENV = env;
289
+ `;
290
+ }
291
+ //#endregion
292
+ //#region src/cli/commands/init/templates/auth.ts
293
+ function authResourceTemplate(config) {
294
+ const ts = config.typescript;
295
+ return `/**
296
+ * Auth Resource
297
+ * Generated by Arc CLI
298
+ *
299
+ * Combined auth + user profile endpoints:
300
+ * - POST /auth/register
301
+ * - POST /auth/login
302
+ * - POST /auth/refresh
303
+ * - POST /auth/forgot-password
304
+ * - POST /auth/reset-password
305
+ * - GET /users/me
306
+ * - PATCH /users/me
307
+ */
308
+
309
+ import { defineResource } from '@classytic/arc';
310
+ import { allowPublic, requireAuth } from '@classytic/arc/permissions';
311
+ import { createAdapter } from '#shared/adapter.js';
312
+ import User from '../user/user.model.js';
313
+ import userRepository from '../user/user.repository.js';
314
+ import * as handlers from './auth.handlers.js';
315
+ import * as schemas from './auth.schemas.js';
316
+
317
+ /**
318
+ * Auth Resource - handles authentication
319
+ */
320
+ export const authResource = defineResource({
321
+ name: 'auth',
322
+ displayName: 'Authentication',
323
+ tag: 'Authentication',
324
+ prefix: '/auth',
325
+
326
+ adapter: createAdapter(User${ts ? " as unknown as never" : ""}, userRepository${ts ? " as unknown as never" : ""}),
327
+ disableDefaultRoutes: true,
328
+
329
+ routes: [
330
+ {
331
+ method: 'POST',
332
+ path: '/register',
333
+ summary: 'Register new user',
334
+ permissions: allowPublic(),
335
+ handler: handlers.register,
336
+ raw: true,
337
+ schema: { body: schemas.registerBody, response: { 201: schemas.successResponse } },
338
+ },
339
+ {
340
+ method: 'POST',
341
+ path: '/login',
342
+ summary: 'User login',
343
+ permissions: allowPublic(),
344
+ handler: handlers.login,
345
+ raw: true,
346
+ schema: { body: schemas.loginBody, response: { 200: schemas.loginResponse } },
347
+ },
348
+ {
349
+ method: 'POST',
350
+ path: '/refresh',
351
+ summary: 'Refresh access token',
352
+ permissions: allowPublic(),
353
+ handler: handlers.refreshToken,
354
+ raw: true,
355
+ schema: { body: schemas.refreshBody, response: { 200: schemas.tokenResponse } },
356
+ },
357
+ {
358
+ method: 'POST',
359
+ path: '/forgot-password',
360
+ summary: 'Request password reset',
361
+ permissions: allowPublic(),
362
+ handler: handlers.forgotPassword,
363
+ raw: true,
364
+ schema: { body: schemas.forgotBody, response: { 200: schemas.successResponse } },
365
+ },
366
+ {
367
+ method: 'POST',
368
+ path: '/reset-password',
369
+ summary: 'Reset password with token',
370
+ permissions: allowPublic(),
371
+ handler: handlers.resetPassword,
372
+ raw: true,
373
+ schema: { body: schemas.resetBody, response: { 200: schemas.successResponse } },
374
+ },
375
+ ],
376
+ });
377
+
378
+ /**
379
+ * User Profile Resource - handles /users/me
380
+ */
381
+ export const userProfileResource = defineResource({
382
+ name: 'user-profile',
383
+ displayName: 'User Profile',
384
+ tag: 'User Profile',
385
+ prefix: '/users',
386
+
387
+ adapter: createAdapter(User${ts ? " as unknown as never" : ""}, userRepository${ts ? " as unknown as never" : ""}),
388
+ disableDefaultRoutes: true,
389
+
390
+ routes: [
391
+ {
392
+ method: 'GET',
393
+ path: '/me',
394
+ summary: 'Get current user profile',
395
+ permissions: requireAuth(),
396
+ handler: handlers.getUserProfile,
397
+ raw: true,
398
+ schema: { response: { 200: schemas.userProfileResponse } },
399
+ },
400
+ {
401
+ method: 'PATCH',
402
+ path: '/me',
403
+ summary: 'Update current user profile',
404
+ permissions: requireAuth(),
405
+ handler: handlers.updateUserProfile,
406
+ raw: true,
407
+ schema: { body: schemas.updateUserBody, response: { 200: schemas.userProfileResponse } },
408
+ },
409
+ ],
410
+ });
411
+
412
+ export default authResource;
413
+ `;
414
+ }
415
+ function betterAuthSetupTemplate(config) {
416
+ const ts = config.typescript;
417
+ const useMongo = config.adapter === "mongokit";
418
+ const useStubs = useMongo;
419
+ const useOrg = config.tenant === "multi";
420
+ const useBearer = config.session === "bearer" || config.session === "both";
421
+ const useApiKey = config.apiKey;
422
+ const mongoImport = useMongo ? `import mongoose from 'mongoose';
423
+ import { mongodbAdapter } from '@better-auth/mongo-adapter';` : "";
424
+ const stubsImport = useStubs ? `import { registerBetterAuthStubs } from '@classytic/mongokit/better-auth';` : "";
425
+ const pluginImports = [
426
+ useOrg ? `import { organization } from 'better-auth/plugins';` : "",
427
+ useBearer ? `import { bearer } from 'better-auth/plugins';` : "",
428
+ useApiKey ? `import { apiKey } from '@better-auth/api-key';` : ""
429
+ ].filter(Boolean).join("\n");
430
+ const dbAdapter = useMongo ? config.typescript ? `database: mongodbAdapter(mongoose.connection.getClient().db() as unknown as never),` : `database: mongodbAdapter(mongoose.connection.getClient().db()),` : `// Configure your database adapter here
431
+ // See: https://www.better-auth.com/docs/concepts/database`;
432
+ const pluginEntries = [
433
+ useBearer ? ` bearer(),` : "",
434
+ useApiKey ? ` apiKey({
435
+ enableMetadata: true,
436
+ rateLimit: { enabled: true, timeWindow: 1000 * 60 * 60 * 24, maxRequests: 10 },
437
+ }),` : "",
438
+ useOrg ? ` organization({
439
+ allowUserToCreateOrganization: true,
440
+ creatorRole: 'owner',
441
+ teams: { enabled: true },
442
+ }),` : ""
443
+ ].filter(Boolean);
444
+ const orgPluginUsage = pluginEntries.length > 0 ? `
445
+ plugins: [
446
+ ${pluginEntries.join("\n")}
447
+ ],` : "";
448
+ const stubPluginsList = [useOrg ? `'organization'` : ""].filter(Boolean).join(", ");
449
+ const stubExtras = useApiKey ? `, extraCollections: ['apikey']` : "";
450
+ return `/**
451
+ * Better Auth Configuration
452
+ * Generated by Arc CLI
453
+ *
454
+ * Better Auth owns auth flows + writes its own tables. Arc reads them as
455
+ * resources via \`@classytic/mongokit/better-auth\`'s overlay (full pagination,
456
+ * queryparser, OpenAPI, audit) without re-implementing CRUD.
457
+ *
458
+ * BA-managed tables: user, session, account, verification${useOrg ? ", organization, member, invitation, team, teamMember" : ""}${useApiKey ? ", apikey" : ""}
459
+ *
460
+ * @see https://www.better-auth.com/docs
461
+ */
462
+
463
+ import { betterAuth } from 'better-auth';
464
+ ${[
465
+ mongoImport,
466
+ pluginImports,
467
+ stubsImport
468
+ ].filter(Boolean).join("\n")}
469
+ import config from '#config/index.js';
470
+
471
+ let _auth${ts ? ": ReturnType<typeof betterAuth> | null" : ""} = null;
472
+
473
+ /**
474
+ * Get the Better Auth instance (lazy singleton)
475
+ *
476
+ * Must be called AFTER database connection is established.
477
+ */
478
+ export function getAuth()${ts ? ": ReturnType<typeof betterAuth>" : ""} {
479
+ if (process.env.NODE_ENV === 'production' && !process.env.BETTER_AUTH_SECRET) {
480
+ throw new Error('BETTER_AUTH_SECRET is required in production (min 32 chars)');
481
+ }
482
+
483
+ if (!_auth) {
484
+ _auth = betterAuth({
485
+ secret: config.betterAuth.secret,
486
+ baseURL: process.env.BETTER_AUTH_URL || \`http://localhost:\${config.server.port}\`,
487
+ basePath: '/api/auth',
488
+
489
+ ${dbAdapter}
490
+ ${config.tenant === "multi" ? `
491
+ user: {
492
+ additionalFields: {
493
+ roles: {
494
+ type: 'string[]',
495
+ defaultValue: ['user'],
496
+ required: false,
497
+ input: false, // Cannot be set during signup
498
+ },
499
+ },
500
+ },
501
+ ` : ""}
502
+ emailAndPassword: {
503
+ enabled: true,
504
+ minPasswordLength: 6,
505
+ },
506
+
507
+ // Google OAuth (enabled when env vars are set)
508
+ ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
509
+ ? {
510
+ socialProviders: {
511
+ google: {
512
+ clientId: process.env.GOOGLE_CLIENT_ID,
513
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
514
+ },
515
+ },
516
+ }
517
+ : {}),
518
+ ${orgPluginUsage}
519
+ session: {
520
+ cookieCache: {
521
+ enabled: true,
522
+ maxAge: 5 * 60, // 5 minutes
523
+ },
524
+ },
525
+
526
+ trustedOrigins: [config.frontend.url],
527
+
528
+ rateLimit: {
529
+ enabled: process.env.NODE_ENV === 'production',
530
+ },
531
+ });
532
+ ${useStubs ? `
533
+ // Register stub Mongoose models for Better Auth's collections so
534
+ // \`.populate('user')\` / \`ref: 'organization'\` resolve against BA-owned
535
+ // documents app-wide. Idempotent, strict:false, plugin-aware.
536
+ registerBetterAuthStubs(mongoose, {${stubPluginsList ? ` plugins: [${stubPluginsList}]` : ""}${stubExtras} });
537
+ ` : ""} }
538
+
539
+ return _auth;
540
+ }
541
+
542
+ export default getAuth;
543
+ `;
544
+ }
545
+ function authHandlersTemplate(config) {
546
+ const ts = config.typescript;
547
+ return `/**
548
+ * Auth Handlers
549
+ * Generated by Arc CLI
550
+ *
551
+ * Uses Arc's built-in JWT utilities via fastify.auth (provided by @fastify/jwt v10).
552
+ * No standalone jsonwebtoken dependency needed.
553
+ */
554
+
555
+ import userRepository from '../user/user.repository.js';
556
+ ${ts ? `
557
+ import type { FastifyRequest, FastifyReply } from 'fastify';
558
+ // Load Arc auth type augmentations (adds request.server.auth typings)
559
+ import '@classytic/arc/auth';
560
+ ` : ""}
561
+
562
+ /**
563
+ * Register new user
564
+ */
565
+ export async function register(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
566
+ try {
567
+ const { name, email, password } = request.body${ts ? " as Record<string, string>" : ""};
568
+
569
+ // Check if email exists
570
+ if (await userRepository.emailExists(email)) {
571
+ return reply.code(400).send({ error: 'Email already registered', code: 'arc.email_exists' });
572
+ }
573
+
574
+ // Create user
575
+ await userRepository.create({ name, email, password, role: 'user' });
576
+
577
+ return reply.code(201).send({ message: 'User registered successfully' });
578
+ } catch (error) {
579
+ request.log.error({ err: error }, 'Register error');
580
+ return reply.code(500).send({ error: 'Registration failed', code: 'arc.internal' });
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Login user
586
+ */
587
+ export async function login(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
588
+ try {
589
+ const { email, password } = request.body${ts ? " as Record<string, string>" : ""};
590
+
591
+ const user = await userRepository.findByEmail(email);
592
+ if (!user || !(await user.matchPassword(password))) {
593
+ return reply.code(401).send({ error: 'Invalid credentials', code: 'arc.unauthorized' });
594
+ }
595
+
596
+ const tokens = request.server.auth.issueTokens({ id: user._id.toString(), role: user.role });
597
+
598
+ return reply.send({
599
+ user: { id: user._id, name: user.name, email: user.email, role: user.role },
600
+ ...tokens,
601
+ });
602
+ } catch (error) {
603
+ request.log.error({ err: error }, 'Login error');
604
+ return reply.code(500).send({ error: 'Login failed', code: 'arc.internal' });
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Refresh access token
610
+ */
611
+ export async function refreshToken(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
612
+ try {
613
+ const { token } = request.body${ts ? " as { token?: string }" : ""};
614
+ if (!token) {
615
+ return reply.code(401).send({ error: 'Refresh token required', code: 'arc.unauthorized' });
616
+ }
617
+
618
+ const decoded = request.server.auth.verifyRefreshToken(token)${ts ? " as { id: string }" : ""};
619
+ const tokens = request.server.auth.issueTokens({ id: decoded.id });
620
+
621
+ return reply.send(tokens);
622
+ } catch {
623
+ return reply.code(401).send({ error: 'Invalid refresh token', code: 'arc.unauthorized' });
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Forgot password
629
+ */
630
+ export async function forgotPassword(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
631
+ try {
632
+ const { email } = request.body${ts ? " as { email: string }" : ""};
633
+ const user = await userRepository.findByEmail(email);
634
+
635
+ if (user) {
636
+ const { randomBytes } = await import('node:crypto');
637
+ const token = randomBytes(32).toString('hex');
638
+ const expires = new Date(Date.now() + 3600000); // 1 hour
639
+ await userRepository.setResetToken(user._id, token, expires);
640
+ // SCAFFOLD: Integrate your email provider to send the reset link
641
+ request.log.info(\`Password reset requested for \${email}\`);
642
+ }
643
+
644
+ // Always 200 to prevent email enumeration
645
+ return reply.send({ message: 'If email exists, reset link sent' });
646
+ } catch (error) {
647
+ request.log.error({ err: error }, 'Forgot password error');
648
+ return reply.code(500).send({ error: 'Failed to process request', code: 'arc.internal' });
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Reset password
654
+ */
655
+ export async function resetPassword(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
656
+ try {
657
+ const { token, newPassword } = request.body${ts ? " as Record<string, string>" : ""};
658
+ const user = await userRepository.findByResetToken(token);
659
+
660
+ if (!user) {
661
+ return reply.code(400).send({ error: 'Invalid or expired token', code: 'arc.bad_request' });
662
+ }
663
+
664
+ await userRepository.updatePassword(user._id, newPassword);
665
+ return reply.send({ message: 'Password has been reset' });
666
+ } catch (error) {
667
+ request.log.error({ err: error }, 'Reset password error');
668
+ return reply.code(500).send({ error: 'Failed to reset password', code: 'arc.internal' });
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Get current user profile
674
+ */
675
+ export async function getUserProfile(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
676
+ try {
677
+ const requestUser = (request${ts ? " as unknown as { user?: { _id?: string; id?: string } }" : ""}).user;
678
+ const userId = requestUser?._id || requestUser?.id;
679
+ const user = await userRepository.getById(userId);
680
+
681
+ if (!user) {
682
+ return reply.code(404).send({ error: 'User not found', code: 'arc.not_found' });
683
+ }
684
+
685
+ return reply.send(user);
686
+ } catch (error) {
687
+ request.log.error({ err: error }, 'Get profile error');
688
+ return reply.code(500).send({ error: 'Failed to get profile', code: 'arc.internal' });
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Update current user profile
694
+ */
695
+ export async function updateUserProfile(request${ts ? ": FastifyRequest" : ""}, reply${ts ? ": FastifyReply" : ""}) {
696
+ try {
697
+ const requestUser = (request${ts ? " as unknown as { user?: { _id?: string; id?: string } }" : ""}).user;
698
+ const userId = requestUser?._id || requestUser?.id;
699
+ const updates${ts ? ": Record<string, unknown>" : ""} = { ...request.body${ts ? " as Record<string, unknown>" : ""} };
700
+
701
+ // Prevent updating protected fields — auth-managed only
702
+ delete updates.password;
703
+ delete updates.role;
704
+ delete updates.organizations;
705
+
706
+ const user = await userRepository.Model.findByIdAndUpdate(userId, updates, { new: true });
707
+ return reply.send(user);
708
+ } catch (error) {
709
+ request.log.error({ err: error }, 'Update profile error');
710
+ return reply.code(500).send({ error: 'Failed to update profile', code: 'arc.internal' });
711
+ }
712
+ }
713
+ `;
714
+ }
715
+ function authSchemasTemplate(_config) {
716
+ return `/**
717
+ * Auth Schemas
718
+ * Generated by Arc CLI
719
+ */
720
+
721
+ export const registerBody = {
722
+ type: 'object',
723
+ required: ['name', 'email', 'password'],
724
+ properties: {
725
+ name: { type: 'string', minLength: 2 },
726
+ email: { type: 'string', format: 'email' },
727
+ password: { type: 'string', minLength: 6 },
728
+ },
729
+ };
730
+
731
+ export const loginBody = {
732
+ type: 'object',
733
+ required: ['email', 'password'],
734
+ properties: {
735
+ email: { type: 'string', format: 'email' },
736
+ password: { type: 'string' },
737
+ },
738
+ };
739
+
740
+ export const refreshBody = {
741
+ type: 'object',
742
+ required: ['token'],
743
+ properties: {
744
+ token: { type: 'string' },
745
+ },
746
+ };
747
+
748
+ export const forgotBody = {
749
+ type: 'object',
750
+ required: ['email'],
751
+ properties: {
752
+ email: { type: 'string', format: 'email' },
753
+ },
754
+ };
755
+
756
+ export const resetBody = {
757
+ type: 'object',
758
+ required: ['token', 'newPassword'],
759
+ properties: {
760
+ token: { type: 'string' },
761
+ newPassword: { type: 'string', minLength: 6 },
762
+ },
763
+ };
764
+
765
+ export const updateUserBody = {
766
+ type: 'object',
767
+ properties: {
768
+ name: { type: 'string', minLength: 2 },
769
+ email: { type: 'string', format: 'email' },
770
+ },
771
+ };
772
+
773
+ // Response schemas (enables fast-json-stringify serialization)
774
+
775
+ export const successResponse = {
776
+ type: 'object',
777
+ properties: {
778
+ success: { type: 'boolean' },
779
+ message: { type: 'string' },
780
+ },
781
+ };
782
+
783
+ export const loginResponse = {
784
+ type: 'object',
785
+ properties: {
786
+ success: { type: 'boolean' },
787
+ user: {
788
+ type: 'object',
789
+ properties: {
790
+ id: { type: 'string' },
791
+ name: { type: 'string' },
792
+ email: { type: 'string' },
793
+ role: { type: 'string' },
794
+ },
795
+ },
796
+ accessToken: { type: 'string' },
797
+ refreshToken: { type: 'string' },
798
+ },
799
+ };
800
+
801
+ export const tokenResponse = {
802
+ type: 'object',
803
+ properties: {
804
+ success: { type: 'boolean' },
805
+ accessToken: { type: 'string' },
806
+ refreshToken: { type: 'string' },
807
+ },
808
+ };
809
+
810
+ export const userProfileResponse = {
811
+ type: 'object',
812
+ properties: {
813
+ success: { type: 'boolean' },
814
+ data: { type: 'object', additionalProperties: true },
815
+ },
816
+ };
817
+ `;
818
+ }
819
+ function authTestTemplate(config) {
820
+ const ts = config.typescript;
821
+ return `/**
822
+ * Auth Tests
823
+ * Generated by Arc CLI
824
+ */
825
+
826
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
827
+ ${config.adapter === "mongokit" ? "import mongoose from 'mongoose';\n" : ""}import { createAppInstance } from '../src/app.js';
828
+ ${ts ? "import type { FastifyInstance } from 'fastify';\n" : ""}
829
+ describe('Auth', () => {
830
+ let app${ts ? ": FastifyInstance" : ""};
831
+ const testUser = {
832
+ name: 'Test User',
833
+ email: 'test@example.com',
834
+ password: 'password123',
835
+ };
836
+
837
+ beforeAll(async () => {
838
+ ${config.adapter === "mongokit" ? ` const testDbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/${config.name}-test';
839
+ await mongoose.connect(testDbUri);
840
+ // Clean up test data
841
+ await mongoose.connection.collection('users').deleteMany({ email: testUser.email });
842
+ ` : ""}
843
+ app = await createAppInstance();
844
+ await app.ready();
845
+ });
846
+
847
+ afterAll(async () => {
848
+ ${config.adapter === "mongokit" ? ` await mongoose.connection.collection('users').deleteMany({ email: testUser.email });
849
+ await mongoose.connection.close();
850
+ ` : ""} await app.close();
851
+ });
852
+
853
+ describe('POST /auth/register', () => {
854
+ it('should register a new user', async () => {
855
+ const response = await app.inject({
856
+ method: 'POST',
857
+ url: '/auth/register',
858
+ payload: testUser,
859
+ });
860
+
861
+ expect(response.statusCode).toBe(201);
862
+ const body = JSON.parse(response.body);
863
+ expect(body.success).toBe(true);
864
+ });
865
+
866
+ it('should reject duplicate email', async () => {
867
+ const response = await app.inject({
868
+ method: 'POST',
869
+ url: '/auth/register',
870
+ payload: testUser,
871
+ });
872
+
873
+ expect(response.statusCode).toBe(400);
874
+ });
875
+ });
876
+
877
+ describe('POST /auth/login', () => {
878
+ it('should login with valid credentials', async () => {
879
+ const response = await app.inject({
880
+ method: 'POST',
881
+ url: '/auth/login',
882
+ payload: { email: testUser.email, password: testUser.password },
883
+ });
884
+
885
+ expect(response.statusCode).toBe(200);
886
+ const body = JSON.parse(response.body);
887
+ expect(body.success).toBe(true);
888
+ expect(body.accessToken).toBeDefined();
889
+ expect(body.refreshToken).toBeDefined();
890
+ });
891
+
892
+ it('should reject invalid credentials', async () => {
893
+ const response = await app.inject({
894
+ method: 'POST',
895
+ url: '/auth/login',
896
+ payload: { email: testUser.email, password: 'wrongpassword' },
897
+ });
898
+
899
+ expect(response.statusCode).toBe(401);
900
+ });
901
+ });
902
+
903
+ describe('GET /users/me', () => {
904
+ it('should require authentication', async () => {
905
+ const response = await app.inject({
906
+ method: 'GET',
907
+ url: '/users/me',
908
+ });
909
+
910
+ expect(response.statusCode).toBe(401);
911
+ });
912
+ });
913
+ });
914
+ `;
915
+ }
916
+ //#endregion
917
+ //#region src/cli/commands/init/dependency-plan.ts
918
+ const SCAFFOLD_DEP_VERSIONS = {
919
+ core: {
920
+ "@classytic/arc": "^2.13.0",
921
+ "@classytic/primitives": "^0.6.0",
922
+ "@classytic/repo-core": "^0.4.2",
923
+ "@fastify/cors": "^11.2.0",
924
+ "@fastify/helmet": "^13.0.2",
925
+ "@fastify/rate-limit": "^10.3.0",
926
+ "@fastify/sensible": "^6.0.4",
927
+ "@fastify/under-pressure": "^9.0.3",
928
+ dotenv: "^17.4.2",
929
+ fastify: "^5.8.5"
930
+ },
931
+ authJwt: {
932
+ "@fastify/jwt": "^10.0.0",
933
+ bcryptjs: "^3.0.0"
934
+ },
935
+ authBetterAuth: {
936
+ "better-auth": "^1.6.9",
937
+ mongodb: "^7.1.0"
938
+ },
939
+ authBetterAuthApiKey: { "@better-auth/api-key": "^1.6.9" },
940
+ adapterMongokit: {
941
+ "@classytic/mongokit": "^3.13.0",
942
+ mongoose: "^9.6.1"
943
+ },
944
+ devCommon: {
945
+ "mongodb-memory-server": "^11.1.0",
946
+ "pino-pretty": "^13.0.0",
947
+ vitest: "^4.1.5"
948
+ },
949
+ devTypescript: {
950
+ "@types/node": "^22.10.0",
951
+ tsx: "^4.21.0",
952
+ typescript: "^5.7.2"
953
+ },
954
+ typesJwt: { "@types/bcryptjs": "^3.0.0" }
955
+ };
956
+ /**
957
+ * Resolve the dependency manifest for a scaffold configuration.
958
+ *
959
+ * Returns sorted records (alphabetical by package name) so the generated
960
+ * `package.json` is deterministic — diffs across re-runs stay clean.
961
+ */
962
+ function resolveScaffoldDependencies(config) {
963
+ const dependencies = { ...SCAFFOLD_DEP_VERSIONS.core };
964
+ const devDependencies = { ...SCAFFOLD_DEP_VERSIONS.devCommon };
965
+ if (config.auth === "better-auth") {
966
+ Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authBetterAuth);
967
+ if (config.apiKey) Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authBetterAuthApiKey);
968
+ } else {
969
+ Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authJwt);
970
+ if (config.typescript) Object.assign(devDependencies, SCAFFOLD_DEP_VERSIONS.typesJwt);
971
+ }
972
+ if (config.adapter === "mongokit") Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.adapterMongokit);
973
+ if (config.typescript) Object.assign(devDependencies, SCAFFOLD_DEP_VERSIONS.devTypescript);
974
+ return {
975
+ dependencies: sortByKey(dependencies),
976
+ devDependencies: sortByKey(devDependencies)
977
+ };
978
+ }
979
+ /** Sort a record alphabetically by key — package.json convention. */
980
+ function sortByKey(record) {
981
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
982
+ }
983
+ //#endregion
984
+ //#region src/cli/commands/init/templates/config.ts
985
+ /**
986
+ * Project-level config templates — package.json, tsconfig, vitest, gitignore,
987
+ * env files, README, runtime config module.
988
+ *
989
+ * Every function here is pure: takes a `ProjectConfig` and returns a file
990
+ * body. No I/O, no side effects — so each template is unit-testable in
991
+ * isolation by the CLI verification suite.
992
+ */
993
+ function packageJsonTemplate(config) {
994
+ const { dependencies, devDependencies } = resolveScaffoldDependencies(config);
995
+ const scripts = config.typescript ? config.edge ? {
996
+ dev: "tsx watch src/index.ts",
997
+ build: "tsc",
998
+ start: "node dist/index.js",
999
+ deploy: "wrangler deploy",
1000
+ "deploy:dev": "wrangler dev",
1001
+ test: "vitest run",
1002
+ "test:watch": "vitest"
1003
+ } : {
1004
+ dev: "tsx watch src/index.ts",
1005
+ build: "tsc",
1006
+ start: "node dist/index.js",
1007
+ test: "vitest run",
1008
+ "test:watch": "vitest"
1009
+ } : config.edge ? {
1010
+ dev: "node --watch src/index.js",
1011
+ start: "node src/index.js",
1012
+ deploy: "wrangler deploy",
1013
+ "deploy:dev": "wrangler dev",
1014
+ test: "vitest run",
1015
+ "test:watch": "vitest"
1016
+ } : {
1017
+ dev: "node --watch src/index.js",
1018
+ start: "node src/index.js",
1019
+ test: "vitest run",
1020
+ "test:watch": "vitest"
1021
+ };
1022
+ const imports = config.typescript ? {
1023
+ "#config/*": "./dist/config/*",
1024
+ "#shared/*": "./dist/shared/*",
1025
+ "#resources/*": "./dist/resources/*",
1026
+ "#plugins/*": "./dist/plugins/*",
1027
+ "#services/*": "./dist/services/*",
1028
+ "#lib/*": "./dist/lib/*",
1029
+ "#utils/*": "./dist/utils/*"
1030
+ } : {
1031
+ "#config/*": "./src/config/*",
1032
+ "#shared/*": "./src/shared/*",
1033
+ "#resources/*": "./src/resources/*",
1034
+ "#plugins/*": "./src/plugins/*",
1035
+ "#services/*": "./src/services/*",
1036
+ "#lib/*": "./src/lib/*",
1037
+ "#utils/*": "./src/utils/*"
1038
+ };
1039
+ return JSON.stringify({
1040
+ name: config.name,
1041
+ version: "1.0.0",
1042
+ type: "module",
1043
+ main: config.typescript ? "dist/index.js" : "src/index.js",
1044
+ imports,
1045
+ scripts,
1046
+ dependencies,
1047
+ devDependencies,
1048
+ engines: { node: ">=22" }
1049
+ }, null, 2);
1050
+ }
1051
+ function tsconfigTemplate() {
1052
+ return JSON.stringify({
1053
+ compilerOptions: {
1054
+ target: "ES2022",
1055
+ module: "NodeNext",
1056
+ moduleResolution: "NodeNext",
1057
+ lib: ["ES2022"],
1058
+ outDir: "./dist",
1059
+ rootDir: "./src",
1060
+ strict: true,
1061
+ esModuleInterop: true,
1062
+ skipLibCheck: true,
1063
+ forceConsistentCasingInFileNames: true,
1064
+ declaration: true,
1065
+ declarationMap: true,
1066
+ sourceMap: true,
1067
+ resolveJsonModule: true,
1068
+ paths: {
1069
+ "#shared/*": ["./src/shared/*"],
1070
+ "#resources/*": ["./src/resources/*"],
1071
+ "#config/*": ["./src/config/*"],
1072
+ "#plugins/*": ["./src/plugins/*"]
1073
+ }
1074
+ },
1075
+ include: ["src/**/*"],
1076
+ exclude: ["node_modules", "dist"]
1077
+ }, null, 2);
1078
+ }
1079
+ function vitestConfigTemplate(config) {
1080
+ const srcDir = config.typescript ? "./src" : "./src";
1081
+ return `import { defineConfig } from 'vitest/config';
1082
+ import { resolve } from 'path';
1083
+
1084
+ export default defineConfig({
1085
+ test: {
1086
+ globals: true,
1087
+ environment: 'node',
1088
+ },
1089
+ resolve: {
1090
+ alias: {
1091
+ '#config': resolve(__dirname, '${srcDir}/config'),
1092
+ '#shared': resolve(__dirname, '${srcDir}/shared'),
1093
+ '#resources': resolve(__dirname, '${srcDir}/resources'),
1094
+ '#plugins': resolve(__dirname, '${srcDir}/plugins'),
1095
+ },
1096
+ },
1097
+ });
1098
+ `;
1099
+ }
1100
+ function gitignoreTemplate() {
1101
+ return `# Dependencies
1102
+ node_modules/
1103
+
1104
+ # Build
1105
+ dist/
1106
+ *.js.map
1107
+
1108
+ # Environment (local overrides — never commit secrets)
1109
+ .env.local
1110
+ .env.*.local
1111
+ # Uncomment if your .env contains secrets:
1112
+ # .env
1113
+
1114
+ # IDE
1115
+ .vscode/
1116
+ .idea/
1117
+ *.swp
1118
+ *.swo
1119
+
1120
+ # OS
1121
+ .DS_Store
1122
+ Thumbs.db
1123
+
1124
+ # Logs
1125
+ *.log
1126
+ npm-debug.log*
1127
+
1128
+ # Test coverage
1129
+ coverage/
1130
+ `;
1131
+ }
1132
+ function envExampleTemplate(config) {
1133
+ let content = `# Environment Files (Next.js-style priority):
1134
+ # .env.local → machine-specific overrides (gitignored)
1135
+ # .env.production → production defaults
1136
+ # .env.development → development defaults (or .env.dev)
1137
+ # .env → shared defaults (fallback)
1138
+ #
1139
+ # Tip: Copy this file to .env.local for local development
1140
+
1141
+ # Server
1142
+ PORT=8040
1143
+ HOST=0.0.0.0
1144
+ NODE_ENV=development
1145
+ `;
1146
+ if (config.auth === "better-auth") content += `
1147
+ # Better Auth
1148
+ BETTER_AUTH_SECRET=your-32-character-minimum-secret-here
1149
+ FRONTEND_URL=http://localhost:3000
1150
+
1151
+ # Google OAuth (optional)
1152
+ # GOOGLE_CLIENT_ID=
1153
+ # GOOGLE_CLIENT_SECRET=
1154
+ `;
1155
+ else content += `
1156
+ # JWT
1157
+ JWT_SECRET=your-32-character-minimum-secret-here
1158
+ JWT_EXPIRES_IN=7d
1159
+ `;
1160
+ content += `
1161
+ # CORS - Allowed origins
1162
+ # Options:
1163
+ # * = allow all origins (not recommended for production)
1164
+ # Comma-separated list = specific origins only
1165
+ CORS_ORIGINS=http://localhost:3000,http://localhost:5173
1166
+ `;
1167
+ if (config.adapter === "mongokit") content += `
1168
+ # MongoDB
1169
+ MONGODB_URI=mongodb://localhost:27017/${config.name}
1170
+ `;
1171
+ if (config.tenant === "multi") content += `
1172
+ # Multi-tenant
1173
+ ORG_HEADER=x-organization-id
1174
+ `;
1175
+ return content;
1176
+ }
1177
+ function envDevTemplate(config) {
1178
+ let content = `# Development Environment
1179
+ NODE_ENV=development
1180
+
1181
+ # Server
1182
+ PORT=8040
1183
+ HOST=0.0.0.0
1184
+ `;
1185
+ if (config.auth === "better-auth") content += `
1186
+ # Better Auth
1187
+ BETTER_AUTH_SECRET=dev-secret-change-in-production-min-32-chars
1188
+ FRONTEND_URL=http://localhost:3000
1189
+
1190
+ # Google OAuth (optional — leave empty to disable)
1191
+ GOOGLE_CLIENT_ID=
1192
+ GOOGLE_CLIENT_SECRET=
1193
+ `;
1194
+ else content += `
1195
+ # JWT
1196
+ JWT_SECRET=dev-secret-change-in-production-min-32-chars
1197
+ JWT_EXPIRES_IN=7d
1198
+ `;
1199
+ content += `
1200
+ # CORS - Allowed origins
1201
+ # Options:
1202
+ # * = allow all origins (not recommended for production)
1203
+ # Comma-separated list = specific origins only
1204
+ CORS_ORIGINS=http://localhost:3000,http://localhost:5173
1205
+ `;
1206
+ if (config.adapter === "mongokit") content += `
1207
+ # MongoDB
1208
+ MONGODB_URI=mongodb://localhost:27017/${config.name}
1209
+ `;
1210
+ if (config.tenant === "multi") content += `
1211
+ # Multi-tenant
1212
+ ORG_HEADER=x-organization-id
1213
+ `;
1214
+ return content;
1215
+ }
1216
+ function readmeTemplate(config) {
1217
+ const ext = config.typescript ? "ts" : "js";
1218
+ return `# ${config.name}
1219
+
1220
+ Built with [Arc](https://github.com/classytic/arc) - Resource-Oriented Backend Framework
1221
+
1222
+ ## Quick Start
1223
+
1224
+ \`\`\`bash
1225
+ # Install dependencies
1226
+ npm install
1227
+
1228
+ # Start development server (uses .env.dev)
1229
+ npm run dev
1230
+
1231
+ # Run tests
1232
+ npm test
1233
+ \`\`\`
1234
+
1235
+ ## Project Structure
1236
+
1237
+ \`\`\`
1238
+ src/
1239
+ ├── config/ # Configuration (loaded first)
1240
+ │ ├── env.${ext} # Env loader (import first!)
1241
+ │ └── index.${ext} # App config
1242
+ ├── shared/ # Shared utilities
1243
+ │ ├── adapter.${ext} # ${config.adapter === "mongokit" ? "MongoKit adapter factory" : "Custom / Drizzle-ready adapter"}
1244
+ │ ├── permissions.${ext} # Permission helpers
1245
+ │ └── presets/ # ${config.tenant === "multi" ? "Multi-tenant presets" : "Standard presets"}
1246
+ ├── plugins/ # App-specific plugins
1247
+ │ └── index.${ext} # Plugin registry
1248
+ ├── resources/ # API Resources
1249
+ │ ├── index.${ext} # Resource registry
1250
+ │ └── example/ # Example resource
1251
+ │ ├── index.${ext} # Resource definition
1252
+ │ ├── model.${ext} # Mongoose schema
1253
+ │ └── repository.${ext} # MongoKit repository
1254
+ ├── app.${ext} # App factory (reusable)
1255
+ └── index.${ext} # Server entry point
1256
+ tests/
1257
+ └── example.test.${ext} # Example tests
1258
+ \`\`\`
1259
+
1260
+ ## Architecture
1261
+
1262
+ ### Entry Points
1263
+
1264
+ - **\`src/index.${ext}\`** - ${config.edge ? "Edge/serverless fetch handler (Cloudflare Workers, Lambda, Vercel)" : "HTTP server entry point"}
1265
+ - **\`src/app.${ext}\`** - App factory (import for workers/tests)
1266
+
1267
+ \`\`\`${config.typescript ? "typescript" : "javascript"}
1268
+ // For workers or custom entry points:
1269
+ import { createAppInstance } from './app.js';
1270
+
1271
+ const app = await createAppInstance();
1272
+ // Use app for your worker logic
1273
+ \`\`\`
1274
+
1275
+ ### Adding Resources
1276
+
1277
+ 1. Create a new folder in \`src/resources/\`:
1278
+
1279
+ \`\`\`
1280
+ src/resources/product/
1281
+ ├── index.${ext} # Resource definition
1282
+ ├── model.${ext} # Mongoose schema
1283
+ └── repository.${ext} # MongoKit repository
1284
+ \`\`\`
1285
+
1286
+ 2. Register in \`src/resources/index.${ext}\`:
1287
+
1288
+ \`\`\`${config.typescript ? "typescript" : "javascript"}
1289
+ import productResource from './product/index.js';
1290
+
1291
+ export const resources = [
1292
+ exampleResource,
1293
+ productResource, // Add here
1294
+ ];
1295
+ \`\`\`
1296
+
1297
+ ### Adding Plugins
1298
+
1299
+ Add custom plugins in \`src/plugins/index.${ext}\`:
1300
+
1301
+ \`\`\`${config.typescript ? "typescript" : "javascript"}
1302
+ export async function registerPlugins(app, deps) {
1303
+ const { config } = deps; // Explicit dependency injection
1304
+
1305
+ await app.register(myCustomPlugin, { ...options });
1306
+ }
1307
+ \`\`\`
1308
+
1309
+ ## CLI Commands
1310
+
1311
+ \`\`\`bash
1312
+ # Generate a new resource
1313
+ arc generate resource product
1314
+
1315
+ # Introspect existing schema
1316
+ arc introspect
1317
+
1318
+ # Generate API docs
1319
+ arc docs
1320
+ \`\`\`
1321
+
1322
+ ## Environment Files (Next.js-style)
1323
+
1324
+ Priority (first loaded wins):
1325
+ 1. \`.env.local\` — Machine-specific overrides (gitignored)
1326
+ 2. \`.env.{environment}\` — e.g., \`.env.production\`, \`.env.development\`, \`.env.test\`
1327
+ 3. \`.env\` — Shared defaults (fallback)
1328
+
1329
+ Short forms also supported: \`.env.prod\`, \`.env.dev\`, \`.env.test\`
1330
+
1331
+ ## API Documentation
1332
+
1333
+ API documentation is available via Scalar UI:
1334
+
1335
+ - **Interactive UI**: [http://localhost:8040/docs](http://localhost:8040/data)
1336
+ - **OpenAPI Spec**: [http://localhost:8040/_docs/openapi.json](http://localhost:8040/_docs/openapi.json)
1337
+
1338
+ ## API Endpoints
1339
+
1340
+ | Method | Endpoint | Description |
1341
+ |--------|----------|-------------|
1342
+ | GET | /docs | API documentation (Scalar UI) |
1343
+ | GET | /_docs/openapi.json | OpenAPI 3.0 spec |
1344
+ | GET | /examples | List all |
1345
+ | GET | /examples/:id | Get by ID |
1346
+ | POST | /examples | Create |
1347
+ | PATCH | /examples/:id | Update |
1348
+ | DELETE | /examples/:id | Delete |
1349
+
1350
+ ## Docker Deployment
1351
+
1352
+ This project comes ready for containerization:
1353
+
1354
+ \`\`\`bash
1355
+ # Build the production image
1356
+ docker build -t ${config.name} .
1357
+
1358
+ # Run the container
1359
+ docker run -p 8040:8040 --env-file .env ${config.name}
1360
+ \`\`\`
1361
+
1362
+ If you're using a database (like MongoDB), you can use Docker Compose to spin up the full stack locally:
1363
+
1364
+ \`\`\`bash
1365
+ docker-compose up -d
1366
+ \`\`\`
1367
+ `;
1368
+ }
1369
+ function configTemplate(config) {
1370
+ const ts = config.typescript;
1371
+ const authTypeBlock = config.auth === "better-auth" ? `
1372
+ betterAuth: {
1373
+ secret: string;
1374
+ };
1375
+ frontend: {
1376
+ url: string;
1377
+ };` : `
1378
+ jwt: {
1379
+ secret: string;
1380
+ expiresIn: string;
1381
+ };`;
1382
+ let typeDefinition = "";
1383
+ if (ts) typeDefinition = `
1384
+ export interface AppConfig {
1385
+ env: string;
1386
+ isDev: boolean;
1387
+ isProd: boolean;
1388
+ server: {
1389
+ port: number;
1390
+ host: string;
1391
+ };${authTypeBlock}
1392
+ cors: {
1393
+ origins: string[] | boolean; // true = allow all ('*')
1394
+ methods: string[];
1395
+ allowedHeaders: string[];
1396
+ credentials: boolean;
1397
+ };${config.adapter === "mongokit" ? `
1398
+ database: {
1399
+ uri: string;
1400
+ };` : ""}${config.tenant === "multi" ? `
1401
+ org: {
1402
+ header: string;
1403
+ };` : ""}
1404
+ }
1405
+ `;
1406
+ const authConfigBlock = config.auth === "better-auth" ? `
1407
+ betterAuth: {
1408
+ secret: process.env.BETTER_AUTH_SECRET || 'dev-secret-change-in-production-min-32-chars',
1409
+ },
1410
+
1411
+ frontend: {
1412
+ url: process.env.FRONTEND_URL || 'http://localhost:3000',
1413
+ },` : `
1414
+ jwt: {
1415
+ secret: process.env.JWT_SECRET || 'dev-secret-change-in-production-min-32',
1416
+ expiresIn: process.env.JWT_EXPIRES_IN || '7d',
1417
+ },`;
1418
+ return `/**
1419
+ * Application Configuration
1420
+ *
1421
+ * All config is loaded from environment variables.
1422
+ * ENV file is loaded by config/env.ts (imported first in entry points).
1423
+ */
1424
+ ${typeDefinition}
1425
+ const config${ts ? ": AppConfig" : ""} = {
1426
+ env: process.env.NODE_ENV || 'development',
1427
+ isDev: (process.env.NODE_ENV || 'development') !== 'production',
1428
+ isProd: process.env.NODE_ENV === 'production',
1429
+
1430
+ server: {
1431
+ port: parseInt(process.env.PORT || '8040', 10),
1432
+ host: process.env.HOST || '0.0.0.0',
1433
+ },
1434
+ ${authConfigBlock}
1435
+
1436
+ cors: {
1437
+ // '*' = allow all origins (true), otherwise comma-separated list
1438
+ origins:
1439
+ process.env.CORS_ORIGINS === '*'
1440
+ ? true
1441
+ : (process.env.CORS_ORIGINS || 'http://localhost:3000').split(','),
1442
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
1443
+ allowedHeaders: ['Content-Type', 'Authorization', 'x-organization-id', 'x-request-id'],
1444
+ credentials: true,
1445
+ },
1446
+ ${config.adapter === "mongokit" ? `
1447
+ database: {
1448
+ uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/${config.name}',
1449
+ },
1450
+ ` : ""}${config.tenant === "multi" ? `
1451
+ org: {
1452
+ header: process.env.ORG_HEADER || 'x-organization-id',
1453
+ },
1454
+ ` : ""}};
1455
+
1456
+ export default config;
1457
+ `;
1458
+ }
1459
+ //#endregion
1460
+ //#region src/cli/commands/init/templates/docker.ts
1461
+ function dockerignoreTemplate() {
1462
+ return `node_modules
1463
+ dist
1464
+ .env
1465
+ .env.*
1466
+ .git
1467
+ .vscode
1468
+ .idea
1469
+ Dockerfile
1470
+ docker-compose.yml
1471
+ coverage
1472
+ npm-debug.log*
1473
+ .DS_Store
1474
+ `;
1475
+ }
1476
+ function dockerfileTemplate(config) {
1477
+ return `# Multi-stage Dockerfile for Arc + Fastify
1478
+ # Optimized for production and caching
1479
+
1480
+ # 1. Build Stage
1481
+ FROM node:22-alpine AS builder
1482
+ WORKDIR /app
1483
+ COPY package*.json ./
1484
+ ${config.typescript ? "COPY tsconfig*.json ./" : ""}
1485
+ # If using pnpm, bun, or yarn, adjust the lockfile here
1486
+ RUN npm ci
1487
+
1488
+ COPY . .
1489
+ ${config.typescript ? "RUN npm run build" : ""}
1490
+
1491
+ # 2. Production Stage
1492
+ FROM node:22-alpine AS runner
1493
+ WORKDIR /app
1494
+ ENV NODE_ENV=production
1495
+
1496
+ COPY package*.json ./
1497
+ RUN npm ci --only=production
1498
+
1499
+ ${config.typescript ? "COPY --from=builder /app/dist ./dist" : "COPY src ./src"}
1500
+
1501
+ EXPOSE 8040
1502
+ CMD ["npm", "start"]
1503
+ `;
1504
+ }
1505
+ function dockerComposeTemplate(config) {
1506
+ let content = `version: '3.8'
1507
+
1508
+ services:
1509
+ api:
1510
+ build:
1511
+ context: .
1512
+ dockerfile: Dockerfile
1513
+ ports:
1514
+ - "8040:8040"
1515
+ environment:
1516
+ - NODE_ENV=development
1517
+ - PORT=8040
1518
+ - HOST=0.0.0.0`;
1519
+ if (config.adapter === "mongokit") content += `
1520
+ - MONGODB_URI=mongodb://mongo:27017/${config.name}
1521
+ depends_on:
1522
+ - mongo
1523
+
1524
+ mongo:
1525
+ image: mongo:7
1526
+ ports:
1527
+ - "27017:27017"
1528
+ volumes:
1529
+ - mongo-data:/data/db`;
1530
+ content += `
1531
+
1532
+ volumes:`;
1533
+ if (config.adapter === "mongokit") content += `
1534
+ mongo-data:
1535
+ `;
1536
+ return content;
1537
+ }
1538
+ function wranglerTemplate(config) {
1539
+ const entry = config.typescript ? "dist/index.js" : "src/index.js";
1540
+ const compatFlag = config.adapter === "mongokit" ? "nodejs_compat_v2" : "nodejs_compat";
1541
+ let dbConfig = "";
1542
+ if (config.adapter === "mongokit") dbConfig = `
1543
+ # MongoDB Atlas — store URI as a secret:
1544
+ # npx wrangler secret put MONGODB_URI
1545
+ #
1546
+ # IMPORTANT: Mongoose does NOT work on Workers. Use the raw mongodb driver (6.15+).
1547
+ # For Lambda/Vercel (Node.js), Mongoose works normally.
1548
+ `;
1549
+ else dbConfig = `
1550
+ # Database options for Cloudflare Workers:
1551
+ #
1552
+ # PostgreSQL via Hyperdrive (recommended — connection pooling + caching):
1553
+ # npx wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host:5432/db"
1554
+ # Then uncomment:
1555
+ # [[hyperdrive]]
1556
+ # binding = "HYPERDRIVE"
1557
+ # id = "<your-hyperdrive-id>"
1558
+ #
1559
+ # Turso (edge SQLite):
1560
+ # npx wrangler secret put TURSO_URL
1561
+ # npx wrangler secret put TURSO_AUTH_TOKEN
1562
+ #
1563
+ # Neon (serverless PostgreSQL via HTTP):
1564
+ # npx wrangler secret put DATABASE_URL
1565
+ #
1566
+ # D1 (Cloudflare's native SQLite):
1567
+ # [[d1_databases]]
1568
+ # binding = "DB"
1569
+ # database_name = "${config.name}-db"
1570
+ # database_id = "<run: npx wrangler d1 create ${config.name}-db>"
1571
+ `;
1572
+ return `# Cloudflare Workers configuration
1573
+ # Generated by Arc CLI — see https://developers.cloudflare.com/workers/
1574
+
1575
+ name = "${config.name}"
1576
+ main = "${entry}"
1577
+ compatibility_date = "2025-03-20"
1578
+
1579
+ # Required for Arc — enables node:crypto and AsyncLocalStorage
1580
+ compatibility_flags = ["${compatFlag}"]
1581
+
1582
+ [vars]
1583
+ NODE_ENV = "production"
1584
+
1585
+ # Secrets (never commit these — use wrangler secret put):
1586
+ # npx wrangler secret put JWT_SECRET
1587
+ ${dbConfig}
1588
+ # Custom domain:
1589
+ # [routes]
1590
+ # { pattern = "api.example.com/*", zone_name = "example.com" }
1591
+ `;
1592
+ }
1593
+ //#endregion
1594
+ //#region src/cli/commands/init/templates/example.ts
1595
+ function exampleModelTemplate(config) {
1596
+ const ts = config.typescript;
1597
+ const typeExport = ts ? `
1598
+ export type ExampleDocument = mongoose.InferSchemaType<typeof exampleSchema>;
1599
+ export type ExampleModel = mongoose.Model<ExampleDocument>;
1600
+ ` : "";
1601
+ return `/**
1602
+ * Example Model
1603
+ * Generated by Arc CLI
1604
+ */
1605
+
1606
+ import mongoose from 'mongoose';
1607
+
1608
+ const exampleSchema = new mongoose.Schema(
1609
+ {
1610
+ name: { type: String, required: true, trim: true },
1611
+ description: { type: String, trim: true },
1612
+ isActive: { type: Boolean, default: true, index: true },
1613
+ ${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 },
1614
+ deletedAt: { type: Date, default: null, index: true },
1615
+ },
1616
+ {
1617
+ timestamps: true,
1618
+ toJSON: { virtuals: true },
1619
+ toObject: { virtuals: true },
1620
+ }
1621
+ );
1622
+
1623
+ // Indexes for common queries
1624
+ exampleSchema.index({ name: 1 });
1625
+ exampleSchema.index({ deletedAt: 1, isActive: 1 });
1626
+ ${config.tenant === "multi" ? "exampleSchema.index({ organizationId: 1, deletedAt: 1 });\n" : ""}${typeExport}
1627
+ const Example = mongoose.model${ts ? "<ExampleDocument>" : ""}('Example', exampleSchema);
1628
+
1629
+ export default Example;
1630
+ `;
1631
+ }
1632
+ function exampleRepositoryTemplate(config) {
1633
+ const ts = config.typescript;
1634
+ return `/**
1635
+ * Example Repository
1636
+ * Generated by Arc CLI
1637
+ *
1638
+ * MongoKit repository with plugins for:
1639
+ * - Soft delete (deletedAt filtering)
1640
+ * - Custom business logic methods
1641
+ */
1642
+
1643
+ import {
1644
+ Repository,
1645
+ softDeletePlugin,
1646
+ methodRegistryPlugin,
1647
+ mongoOperationsPlugin,
1648
+ } from '@classytic/mongokit';
1649
+ ${ts ? "import type { ExampleDocument } from './example.model.js';\n" : ""}import Example from './example.model.js';
1650
+
1651
+ class ExampleRepository extends Repository${ts ? "<ExampleDocument>" : ""} {
1652
+ constructor() {
1653
+ super(Example, [
1654
+ methodRegistryPlugin(),
1655
+ softDeletePlugin(),
1656
+ mongoOperationsPlugin(),
1657
+ ]);
1658
+ }
1659
+
1660
+ /**
1661
+ * Find all active (non-deleted) records
1662
+ */
1663
+ async findActive() {
1664
+ return this.Model.find({ isActive: true, deletedAt: null }).lean();
1665
+ }
1666
+ ${config.tenant === "multi" ? `
1667
+ /**
1668
+ * Find active records for an organization
1669
+ */
1670
+ async findActiveByOrg(organizationId${ts ? ": string" : ""}) {
1671
+ return this.Model.find({
1672
+ organizationId,
1673
+ isActive: true,
1674
+ deletedAt: null,
1675
+ }).lean();
1676
+ }
1677
+ ` : ""}
1678
+ // Note: softDeletePlugin provides restore() and getDeleted() methods automatically
1679
+ }
1680
+
1681
+ const exampleRepository = new ExampleRepository();
1682
+
1683
+ export default exampleRepository;
1684
+ export { ExampleRepository };
1685
+ `;
1686
+ }
1687
+ function exampleResourceTemplate(config) {
1688
+ const ts = config.typescript;
1689
+ return `/**
1690
+ * Example Resource
1691
+ * Generated by Arc CLI
1692
+ *
1693
+ * A complete resource with:
1694
+ * - Model (Mongoose schema)
1695
+ * - Repository (MongoKit with plugins)
1696
+ * - Permissions (role-based access)
1697
+ * - Presets (soft delete${config.tenant === "multi" ? ", multi-tenant" : ""})
1698
+ */
1699
+
1700
+ import { defineResource } from '@classytic/arc';
1701
+ import { QueryParser } from '@classytic/mongokit';
1702
+ import { createAdapter } from '#shared/adapter.js';
1703
+ import { ${config.tenant === "multi" ? "orgStaffPermissions" : "publicReadPermissions"} } from '#shared/permissions.js';
1704
+ ${config.tenant === "multi" ? "import { flexibleMultiTenantPreset } from '#shared/presets/flexible-multi-tenant.js';\n" : ""}import Example${ts ? ", { type ExampleDocument }" : ""} from './example.model.js';
1705
+ import exampleRepository from './example.repository.js';
1706
+ import exampleController from './example.controller.js';
1707
+
1708
+ const queryParser = new QueryParser({
1709
+ allowedFilterFields: ['isActive'],
1710
+ });
1711
+
1712
+ const exampleResource = defineResource${ts ? "<ExampleDocument>" : ""}({
1713
+ name: 'example',
1714
+ displayName: 'Examples',
1715
+ prefix: '/examples',
1716
+
1717
+ adapter: createAdapter(Example, exampleRepository),
1718
+ controller: exampleController,
1719
+ queryParser,
1720
+
1721
+ presets: [
1722
+ 'softDelete',
1723
+ 'bulk',${config.tenant === "multi" ? `
1724
+ flexibleMultiTenantPreset({ tenantField: 'organizationId' }),` : ""}
1725
+ ],
1726
+
1727
+ permissions: ${config.tenant === "multi" ? "orgStaffPermissions" : "publicReadPermissions"},
1728
+
1729
+ // Add custom routes here:
1730
+ // routes: [
1731
+ // {
1732
+ // method: 'GET',
1733
+ // path: '/custom',
1734
+ // summary: 'Custom endpoint',
1735
+ // handler: async (request, reply) => { ... },
1736
+ // },
1737
+ // ],
1738
+ });
1739
+
1740
+ export default exampleResource;
1741
+ `;
1742
+ }
1743
+ function exampleControllerTemplate(config) {
1744
+ return `/**
1745
+ * Example Controller
1746
+ * Generated by Arc CLI
1747
+ *
1748
+ * BaseController provides CRUD operations with:
1749
+ * - Automatic pagination
1750
+ * - Query parsing
1751
+ * - Validation
1752
+ */
1753
+
1754
+ import { BaseController } from '@classytic/arc';
1755
+ import exampleRepository from './example.repository.js';
1756
+ import { exampleSchemaOptions } from './example.schemas.js';
1757
+
1758
+ class ExampleController extends BaseController {
1759
+ constructor() {
1760
+ super(exampleRepository, {
1761
+ schemaOptions: exampleSchemaOptions,${config.tenant === "multi" ? `
1762
+ tenantField: 'organizationId', // Configurable tenant field for multi-tenant` : `
1763
+ // tenantField: 'organizationId', // For multi-tenant apps`}
1764
+ });
1765
+ }
1766
+
1767
+ // Add custom controller methods here:
1768
+ // async customAction(request, reply) {
1769
+ // // Custom logic
1770
+ // }
1771
+ }
1772
+
1773
+ const exampleController = new ExampleController();
1774
+ export default exampleController;
1775
+ `;
1776
+ }
1777
+ function exampleSchemasTemplate(config) {
1778
+ const ts = config.typescript;
1779
+ const multiTenantFields = config.tenant === "multi";
1780
+ return `/**
1781
+ * Example Schemas
1782
+ * Generated by Arc CLI
1783
+ *
1784
+ * Schema options for controller validation and query parsing
1785
+ */
1786
+
1787
+ import Example from './example.model.js';
1788
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
1789
+
1790
+ /**
1791
+ * CRUD Schemas with Field Rules
1792
+ * Auto-generated from Mongoose model
1793
+ */
1794
+ const crudSchemas = buildCrudSchemasFromModel(Example, {
1795
+ strictAdditionalProperties: true,
1796
+ fieldRules: {
1797
+ // Framework-injected fields — strip from body + required[]
1798
+ // deletedAt: { systemManaged: true },
1799
+ // Legitimate null values (Zod .nullable() patterns) — widen JSON-Schema type
1800
+ // priceMode: { nullable: true },
1801
+ // Elevated-admin override for systemManaged fields (cross-tenant writes)
1802
+ // organizationId: { systemManaged: true, preserveForElevated: true },
1803
+ },
1804
+ query: {
1805
+ filterableFields: {
1806
+ isActive: 'boolean',${multiTenantFields ? `
1807
+ organizationId: 'ObjectId',` : ""}
1808
+ createdAt: 'date',
1809
+ },
1810
+ },
1811
+ });
1812
+
1813
+ // Schema options for controller
1814
+ export const exampleSchemaOptions${ts ? ": Record<string, unknown>" : ""} = {
1815
+ query: {${multiTenantFields ? `
1816
+ allowedPopulate: ['organizationId'],` : ""}
1817
+ filterableFields: {
1818
+ isActive: 'boolean',${multiTenantFields ? `
1819
+ organizationId: 'ObjectId',` : ""}
1820
+ createdAt: 'date',
1821
+ },
1822
+ },
1823
+ };
1824
+
1825
+ export default crudSchemas;
1826
+ `;
1827
+ }
1828
+ function exampleTestTemplate(config) {
1829
+ const ts = config.typescript;
1830
+ return `/**
1831
+ * Example Resource Tests
1832
+ * Generated by Arc CLI
1833
+ *
1834
+ * Run tests: npm test
1835
+ * Watch mode: npm run test:watch
1836
+ *
1837
+ * Uses arc's 2.11 testing surface:
1838
+ * - createTestApp — turnkey Fastify + in-memory Mongo + auth + fixtures
1839
+ * - expectArc — fluent envelope matchers (.ok, .unauthorized, .forbidden, ...)
1840
+ * - ctx.auth — unified TestAuthProvider, register a role once then reuse .headers
1841
+ */
1842
+
1843
+ import { describe, it, beforeAll, afterAll } from 'vitest';
1844
+ import { createTestApp, expectArc } from '@classytic/arc/testing';
1845
+ import type { TestAppContext } from '@classytic/arc/testing';
1846
+ import { exampleResource } from '../src/resources/example/example.js';
1847
+
1848
+ describe('Example Resource', () => {
1849
+ let ctx${ts ? ": TestAppContext" : ""};
1850
+
1851
+ beforeAll(async () => {
1852
+ ctx = await createTestApp({
1853
+ resources: [exampleResource],
1854
+ authMode: 'jwt',
1855
+ ${config.adapter === "mongokit" ? " connectMongoose: true,\n" : ""} });
1856
+
1857
+ // Arc's permission engine reads singular user.role — string,
1858
+ // comma-separated string, or array all normalise via getUserRoles().
1859
+ ctx.auth${ts ? "!" : ""}.register('admin', {
1860
+ user: { id: '1', role: 'admin' },
1861
+ orgId: 'org-1',
1862
+ });
1863
+ });
1864
+
1865
+ afterAll(() => ctx.close());
1866
+
1867
+ describe('GET /examples', () => {
1868
+ it('should return a list of examples (public)', async () => {
1869
+ const res = await ctx.app.inject({ method: 'GET', url: '/examples' });
1870
+ expectArc(res).ok().paginated();
1871
+ });
1872
+ });
1873
+
1874
+ describe('POST /examples', () => {
1875
+ it('should require authentication', async () => {
1876
+ const res = await ctx.app.inject({
1877
+ method: 'POST',
1878
+ url: '/examples',
1879
+ payload: { name: 'Test Example' },
1880
+ });
1881
+ expectArc(res).unauthorized();
1882
+ });
1883
+
1884
+ it('should create when admin is authenticated', async () => {
1885
+ const res = await ctx.app.inject({
1886
+ method: 'POST',
1887
+ url: '/examples',
1888
+ headers: ctx.auth${ts ? "!" : ""}.as('admin').headers,
1889
+ payload: { name: 'Test Example' },
1890
+ });
1891
+ expectArc(res).ok();
1892
+ });
1893
+ });
1894
+
1895
+ // Add more tests as needed:
1896
+ // - GET /examples/:id (expectArc(res).ok().hasData({ name: '...' }))
1897
+ // - PATCH /examples/:id (expectArc(res).ok())
1898
+ // - DELETE /examples/:id (expectArc(res).ok())
1899
+ // - Custom endpoints
1900
+ // - Permission denials (expectArc(res).forbidden().hasError(/reason/))
1901
+ // - Field hiding (expectArc(res).hidesField('password'))
1902
+ });
1903
+ `;
1904
+ }
1905
+ //#endregion
1906
+ //#region src/cli/commands/init/templates/permissions.ts
1907
+ function permissionsTemplate(config) {
1908
+ const ts = config.typescript;
1909
+ const typeImport = ts ? ",\n type PermissionCheck," : "";
1910
+ const returnType = ts ? ": PermissionCheck" : "";
1911
+ let content = `/**
1912
+ * Permission Helpers
1913
+ *
1914
+ * Clean, type-safe permission definitions for resources.
1915
+ */
1916
+
1917
+ import {
1918
+ requireAuth,
1919
+ requireRoles,
1920
+ requireOwnership,
1921
+ allowPublic,
1922
+ roles,
1923
+ anyOf,
1924
+ allOf,
1925
+ denyAll,
1926
+ when${typeImport}
1927
+ } from '@classytic/arc/permissions';
1928
+
1929
+ // Re-export core helpers
1930
+ export {
1931
+ allowPublic,
1932
+ requireAuth,
1933
+ requireRoles,
1934
+ requireOwnership,
1935
+ roles,
1936
+ allOf,
1937
+ anyOf,
1938
+ denyAll,
1939
+ when,
1940
+ };
1941
+
1942
+ // ============================================================================
1943
+ // Permission Helpers
1944
+ // ============================================================================
1945
+
1946
+ /**
1947
+ * Require any authenticated user
1948
+ */
1949
+ export const requireAuthenticated = ()${returnType} =>
1950
+ requireRoles(['user', 'admin', 'superadmin']);
1951
+
1952
+ /**
1953
+ * Require admin or superadmin
1954
+ */
1955
+ export const requireAdmin = ()${returnType} =>
1956
+ requireRoles(['admin', 'superadmin']);
1957
+
1958
+ /**
1959
+ * Require superadmin only
1960
+ */
1961
+ export const requireSuperadmin = ()${returnType} =>
1962
+ requireRoles(['superadmin']);
1963
+ `;
1964
+ if (config.tenant === "multi") if (config.auth === "better-auth") content += `
1965
+ // ============================================================================
1966
+ // Better Auth Organization & Team Permission Helpers
1967
+ // ============================================================================
1968
+
1969
+ /**
1970
+ * Organization-level guards (per-org member.role):
1971
+ *
1972
+ * - requireRoles('admin') — checks BOTH user.role AND org member.role (recommended)
1973
+ * - requireOrgRole(['admin','owner']) — checks member.role in active org ONLY
1974
+ * - requireOrgMembership() — just checks if user is in the org (any role)
1975
+ * - requireTeamMembership() — checks if user is in the active team
1976
+ *
1977
+ * RECOMMENDED: Use requireRoles() for most cases. Since Arc 2.7.1 it defaults to
1978
+ * checking both platform AND org roles, so a single call covers BA org plugin users
1979
+ * with platform-admin overrides. Use requireOrgRole() when you ONLY want org-level
1980
+ * checks (and want to explicitly exclude platform admins).
1981
+ *
1982
+ * Platform superadmin automatically bypasses all org role checks.
1983
+ *
1984
+ * IMPORTANT: When using Better Auth's Access Control (ac) with custom roles,
1985
+ * you MUST define ALL roles (owner, admin, member, + any custom) using the
1986
+ * same AC instance. BA's built-in defaults won't cover custom statements.
1987
+ * Omitting any role causes BA's hasPermission to fail silently for that role.
1988
+ *
1989
+ * @see multi-org-betterauth boilerplate (src/shared/access-control.ts) for the recommended pattern.
1990
+ */
1991
+ import {
1992
+ requireOrgMembership,
1993
+ requireOrgRole,
1994
+ requireTeamMembership,
1995
+ } from '@classytic/arc/permissions';
1996
+ export { requireOrgMembership, requireOrgRole, requireTeamMembership };
1997
+
1998
+ /**
1999
+ * Require organization owner (checks member.role, not user.role)
2000
+ */
2001
+ export const requireOrgOwner = ()${returnType} =>
2002
+ requireOrgRole(['owner']);
2003
+
2004
+ /**
2005
+ * Require organization manager or higher (checks member.role, not user.role)
2006
+ */
2007
+ export const requireOrgManager = ()${returnType} =>
2008
+ requireOrgRole(['manager', 'admin', 'owner']);
2009
+
2010
+ /**
2011
+ * Require any organization member (any role)
2012
+ */
2013
+ export const requireOrgStaff = ()${returnType} =>
2014
+ requireOrgMembership();
2015
+ `;
2016
+ else content += `
2017
+ /**
2018
+ * Require organization owner (elevated scope auto-bypasses)
2019
+ */
2020
+ export const requireOrgOwner = ()${returnType} =>
2021
+ requireRoles(['owner', 'admin', 'superadmin']);
2022
+
2023
+ /**
2024
+ * Require organization manager or higher
2025
+ */
2026
+ export const requireOrgManager = ()${returnType} =>
2027
+ requireRoles(['owner', 'manager', 'admin', 'superadmin']);
2028
+
2029
+ /**
2030
+ * Require organization staff (any org member)
2031
+ */
2032
+ export const requireOrgStaff = ()${returnType} =>
2033
+ requireRoles(['owner', 'manager', 'staff', 'admin', 'superadmin']);
2034
+ `;
2035
+ content += `
2036
+ // ============================================================================
2037
+ // Standard Permission Sets
2038
+ // ============================================================================
2039
+
2040
+ /**
2041
+ * Public read, authenticated write (default for most resources)
2042
+ */
2043
+ export const publicReadPermissions = {
2044
+ list: allowPublic(),
2045
+ get: allowPublic(),
2046
+ create: requireAuthenticated(),
2047
+ update: requireAuthenticated(),
2048
+ delete: requireAuthenticated(),
2049
+ };
2050
+
2051
+ /**
2052
+ * All operations require authentication
2053
+ */
2054
+ export const authenticatedPermissions = {
2055
+ list: requireAuth(),
2056
+ get: requireAuth(),
2057
+ create: requireAuth(),
2058
+ update: requireAuth(),
2059
+ delete: requireAuth(),
2060
+ };
2061
+
2062
+ /**
2063
+ * Admin only permissions
2064
+ */
2065
+ export const adminPermissions = {
2066
+ list: requireAdmin(),
2067
+ get: requireAdmin(),
2068
+ create: requireSuperadmin(),
2069
+ update: requireSuperadmin(),
2070
+ delete: requireSuperadmin(),
2071
+ };
2072
+ `;
2073
+ if (config.tenant === "multi") {
2074
+ content += `
2075
+ /**
2076
+ * Organization staff permissions
2077
+ */
2078
+ export const orgStaffPermissions = {
2079
+ list: requireOrgStaff(),
2080
+ get: requireOrgStaff(),
2081
+ create: requireOrgManager(),
2082
+ update: requireOrgManager(),
2083
+ delete: requireOrgOwner(),
2084
+ };
2085
+ `;
2086
+ if (config.auth === "better-auth") content += `
2087
+ /**
2088
+ * Team-scoped permissions (requires active team)
2089
+ * Uses Better Auth's team membership — flat groups, no team-level roles.
2090
+ */
2091
+ export const teamScopedPermissions = {
2092
+ list: requireTeamMembership(),
2093
+ get: requireTeamMembership(),
2094
+ create: requireTeamMembership(),
2095
+ update: requireTeamMembership(),
2096
+ delete: requireOrgOwner(),
2097
+ };
2098
+ `;
2099
+ }
2100
+ return content;
2101
+ }
2102
+ //#endregion
2103
+ //#region src/cli/commands/init/templates/plugins.ts
2104
+ function pluginsIndexTemplate(config) {
2105
+ const ts = config.typescript;
2106
+ const typeImport = ts ? "import type { FastifyInstance } from 'fastify';\n" : "";
2107
+ const configType = ts ? ": { config: AppConfig }" : "";
2108
+ const appType = ts ? ": FastifyInstance" : "";
2109
+ let content = `/**
2110
+ * App Plugins Registry
2111
+ *
2112
+ * Register your app-specific plugins here.
2113
+ * Dependencies are passed explicitly (no shims, no magic).
2114
+ */
2115
+
2116
+ ${typeImport}${ts ? "import type { AppConfig } from '../config/index.js';\n" : ""}import { openApiPlugin, scalarPlugin } from '@classytic/arc/docs';
2117
+ import { errorHandlerPlugin } from '@classytic/arc/plugins';
2118
+ `;
2119
+ content += `
2120
+ /**
2121
+ * Register all app-specific plugins
2122
+ *
2123
+ * @param app - Fastify instance
2124
+ * @param deps - Explicit dependencies (config, services, etc.)
2125
+ */
2126
+ export async function registerPlugins(
2127
+ app${appType},
2128
+ deps${configType}
2129
+ )${ts ? ": Promise<void>" : ""} {
2130
+ const { config } = deps;
2131
+
2132
+ // Error handling (CastError → 400, validation → 422, duplicate → 409)
2133
+ await app.register(errorHandlerPlugin, {
2134
+ includeStack: config.isDev,
2135
+ });
2136
+
2137
+ // API Documentation (Scalar UI)
2138
+ // OpenAPI spec: /_docs/openapi.json
2139
+ // Scalar UI: /docs
2140
+ await app.register(openApiPlugin, {
2141
+ title: '${config.name} API',
2142
+ version: '1.0.0',
2143
+ description: 'API documentation for ${config.name}',
2144
+ apiPrefix: '/api',
2145
+ });
2146
+ await app.register(scalarPlugin, {
2147
+ routePrefix: '/docs',
2148
+ theme: 'default',
2149
+ });
2150
+
2151
+ // Add your custom plugins here:
2152
+ // await app.register(myCustomPlugin, { ...options });
2153
+ }
2154
+ `;
2155
+ return content;
2156
+ }
2157
+ //#endregion
2158
+ //#region src/cli/commands/init/templates/presets.ts
2159
+ function presetsMultiTenantTemplate(config) {
2160
+ return `/**
2161
+ * Arc Presets - Multi-Tenant Configuration
2162
+ *
2163
+ * Pre-configured presets for multi-tenant applications.
2164
+ * Includes both strict and flexible tenant isolation options.
2165
+ */
2166
+
2167
+ import {
2168
+ multiTenantPreset,
2169
+ ownedByUserPreset,
2170
+ softDeletePreset,
2171
+ slugLookupPreset,
2172
+ } from '@classytic/arc/presets';
2173
+
2174
+ // Flexible preset for mixed public/private routes
2175
+ export { flexibleMultiTenantPreset } from './flexible-multi-tenant.js';
2176
+
2177
+ /**
2178
+ * Organization-scoped preset (STRICT)
2179
+ * Always requires auth, always filters by organizationId.
2180
+ * Use for admin-only resources.
2181
+ */
2182
+ export const orgScoped = multiTenantPreset({
2183
+ tenantField: 'organizationId',
2184
+ });
2185
+
2186
+ /**
2187
+ * Owned by creator preset
2188
+ * Filters queries by createdBy field.
2189
+ */
2190
+ export const ownedByCreator = ownedByUserPreset({
2191
+ ownerField: 'createdBy',
2192
+ });
2193
+
2194
+ /**
2195
+ * Owned by user preset
2196
+ * For resources where userId references the owner.
2197
+ */
2198
+ export const ownedByUser = ownedByUserPreset({
2199
+ ownerField: 'userId',
2200
+ });
2201
+
2202
+ /**
2203
+ * Soft delete preset
2204
+ * Adds deletedAt filtering and restore endpoint.
2205
+ */
2206
+ export const softDelete = softDeletePreset();
2207
+
2208
+ /**
2209
+ * Slug lookup preset
2210
+ * Enables GET by slug in addition to ID.
2211
+ */
2212
+ export const slugLookup = slugLookupPreset();
2213
+
2214
+ // Export all presets
2215
+ export const presets = {
2216
+ orgScoped,
2217
+ ownedByCreator,
2218
+ ownedByUser,
2219
+ softDelete,
2220
+ slugLookup,
2221
+ }${config.typescript ? " as const" : ""};
2222
+
2223
+ export default presets;
2224
+ `;
2225
+ }
2226
+ function presetsSingleTenantTemplate(config) {
2227
+ return `/**
2228
+ * Arc Presets - Single-Tenant Configuration
2229
+ *
2230
+ * Pre-configured presets for single-tenant applications.
2231
+ */
2232
+
2233
+ import {
2234
+ ownedByUserPreset,
2235
+ softDeletePreset,
2236
+ slugLookupPreset,
2237
+ } from '@classytic/arc/presets';
2238
+
2239
+ /**
2240
+ * Owned by creator preset
2241
+ * Filters queries by createdBy field.
2242
+ */
2243
+ export const ownedByCreator = ownedByUserPreset({
2244
+ ownerField: 'createdBy',
2245
+ });
2246
+
2247
+ /**
2248
+ * Owned by user preset
2249
+ * For resources where userId references the owner.
2250
+ */
2251
+ export const ownedByUser = ownedByUserPreset({
2252
+ ownerField: 'userId',
2253
+ });
2254
+
2255
+ /**
2256
+ * Soft delete preset
2257
+ * Adds deletedAt filtering and restore endpoint.
2258
+ */
2259
+ export const softDelete = softDeletePreset();
2260
+
2261
+ /**
2262
+ * Slug lookup preset
2263
+ * Enables GET by slug in addition to ID.
2264
+ */
2265
+ export const slugLookup = slugLookupPreset();
2266
+
2267
+ // Export all presets
2268
+ export const presets = {
2269
+ ownedByCreator,
2270
+ ownedByUser,
2271
+ softDelete,
2272
+ slugLookup,
2273
+ }${config.typescript ? " as const" : ""};
2274
+
2275
+ export default presets;
2276
+ `;
2277
+ }
2278
+ function flexibleMultiTenantPresetTemplate(config) {
2279
+ const ts = config.typescript;
2280
+ return `/**
2281
+ * Flexible Multi-Tenant Preset
2282
+ *
2283
+ * Smarter tenant filtering that works with public + authenticated routes.
2284
+ *
2285
+ * Philosophy:
2286
+ * - No org scope → No filtering (public data, all orgs)
2287
+ * - Org scope present → Filter by org
2288
+ * - Elevated scope → No filter (platform admin sees all)
2289
+ *
2290
+ * Uses request.scope (RequestScope) from Arc's scope system.
2291
+ */
2292
+ ${ts ? `
2293
+ import { getOrgId, isElevated, isMember } from '@classytic/arc/scope';
2294
+ import type { RequestScope } from '@classytic/arc/scope';
2295
+
2296
+ interface FlexibleMultiTenantOptions {
2297
+ tenantField?: string;
2298
+ }
2299
+
2300
+ interface PresetMiddlewares {
2301
+ list: ((request: any, reply: any) => Promise<void>)[];
2302
+ get: ((request: any, reply: any) => Promise<void>)[];
2303
+ create: ((request: any, reply: any) => Promise<void>)[];
2304
+ update: ((request: any, reply: any) => Promise<void>)[];
2305
+ delete: ((request: any, reply: any) => Promise<void>)[];
2306
+ }
2307
+
2308
+ interface Preset {
2309
+ [key: string]: unknown;
2310
+ name: string;
2311
+ middlewares: PresetMiddlewares;
2312
+ }
2313
+ ` : `
2314
+ const { getOrgId, isElevated, isMember } = require('@classytic/arc/scope');
2315
+ `}
2316
+ /**
2317
+ * Create flexible tenant filter middleware.
2318
+ * Only filters when org context is present.
2319
+ */
2320
+ function createFlexibleTenantFilter(tenantField${ts ? ": string" : ""}) {
2321
+ return async (request${ts ? ": any" : ""}, reply${ts ? ": any" : ""}) => {
2322
+ const scope${ts ? ": RequestScope" : ""} = request.scope ?? { kind: 'public' };
2323
+
2324
+ // Elevated scope — platform admin sees all, no filter
2325
+ if (isElevated(scope)) {
2326
+ request.log?.debug?.({ msg: 'Elevated scope — no tenant filter' });
2327
+ return;
2328
+ }
2329
+
2330
+ // Member scope — filter by org
2331
+ if (isMember(scope)) {
2332
+ request.query = request.query ?? {};
2333
+ request.query._policyFilters = {
2334
+ ...(request.query._policyFilters ?? {}),
2335
+ [tenantField]: scope.organizationId,
2336
+ };
2337
+ request.log?.debug?.({ msg: 'Tenant filter applied', orgId: scope.organizationId, tenantField });
2338
+ return;
2339
+ }
2340
+
2341
+ // Public / authenticated — no org context, show all data (public routes)
2342
+ request.log?.debug?.({ msg: 'No org context — showing all data' });
2343
+ };
2344
+ }
2345
+
2346
+ /**
2347
+ * Create tenant injection middleware.
2348
+ * Injects tenant ID into request body on create.
2349
+ */
2350
+ function createTenantInjection(tenantField${ts ? ": string" : ""}) {
2351
+ return async (request${ts ? ": any" : ""}, reply${ts ? ": any" : ""}) => {
2352
+ const scope${ts ? ": RequestScope" : ""} = request.scope ?? { kind: 'public' };
2353
+ const orgId = getOrgId(scope);
2354
+
2355
+ // Fail-closed: Require orgId for create operations
2356
+ if (!orgId) {
2357
+ return reply.code(403).send({
2358
+ error: 'Organization context required to create resources',
2359
+ code: 'arc.org_required',
2360
+ });
2361
+ }
2362
+
2363
+ if (request.body) {
2364
+ request.body[tenantField] = orgId;
2365
+ }
2366
+ };
2367
+ }
2368
+
2369
+ /**
2370
+ * Flexible Multi-Tenant Preset
2371
+ *
2372
+ * @param options.tenantField - Field name in database (default: 'organizationId')
2373
+ */
2374
+ export function flexibleMultiTenantPreset(options${ts ? ": FlexibleMultiTenantOptions = {}" : " = {}"})${ts ? ": Preset" : ""} {
2375
+ const { tenantField = 'organizationId' } = options;
2376
+
2377
+ const tenantFilter = createFlexibleTenantFilter(tenantField);
2378
+ const tenantInjection = createTenantInjection(tenantField);
2379
+
2380
+ return {
2381
+ name: 'flexibleMultiTenant',
2382
+ middlewares: {
2383
+ list: [tenantFilter],
2384
+ get: [tenantFilter],
2385
+ create: [tenantInjection],
2386
+ update: [tenantFilter],
2387
+ delete: [tenantFilter],
2388
+ },
2389
+ };
2390
+ }
2391
+
2392
+ export default flexibleMultiTenantPreset;
2393
+ `;
2394
+ }
2395
+ //#endregion
2396
+ //#region src/cli/commands/init/templates/resources.ts
2397
+ function resourcesIndexTemplate(config) {
2398
+ const ts = config.typescript;
2399
+ const typeImport = ts ? "import type { FastifyInstance } from 'fastify';\n" : "";
2400
+ const appType = ts ? ": FastifyInstance" : "";
2401
+ return `/**
2402
+ * Resources Registry
2403
+ *
2404
+ * Central registry for all API resources.
2405
+ * All resources are mounted under /api prefix via Fastify scoping.
2406
+ */
2407
+
2408
+ ${typeImport}${config.auth === "jwt" ? `
2409
+ // Auth resources (register, login, /users/me)
2410
+ import { authResource, userProfileResource } from './auth/auth.resource.js';
2411
+ ` : `
2412
+ // Auth is handled by Better Auth — routes at /api/auth/*
2413
+ // No manual auth resource needed.
2414
+ `}
2415
+ // App resources
2416
+ import exampleResource from './example/example.resource.js';
2417
+
2418
+ // Add more resources here:
2419
+ // import productResource from './product/product.resource.js';
2420
+
2421
+ /**
2422
+ * All registered resources
2423
+ */
2424
+ export const resources = [
2425
+ ${config.auth === "jwt" ? ` authResource,
2426
+ userProfileResource,
2427
+ ` : ` `}exampleResource,
2428
+ ]${ts ? " as const" : ""};
2429
+
2430
+ /**
2431
+ * Register all resources with the app under a common prefix.
2432
+ * Fastify scoping ensures all routes are mounted at /api/*.
2433
+ * The apiPrefix option in openApiPlugin keeps OpenAPI docs in sync.
2434
+ */
2435
+ export async function registerResources(app${appType}, prefix = '/api')${ts ? ": Promise<void>" : ""} {
2436
+ await app.register(async (scope) => {
2437
+ for (const resource of resources) {
2438
+ await scope.register(resource.toPlugin());
2439
+ }
2440
+ }, { prefix });
2441
+ }
2442
+ `;
2443
+ }
2444
+ function sharedIndexTemplate(_config) {
2445
+ return `/**
2446
+ * Shared Utilities
2447
+ *
2448
+ * Central exports for resource definitions.
2449
+ * Import from here for clean, consistent code.
2450
+ */
2451
+
2452
+ // Adapter factory
2453
+ export { createAdapter } from './adapter.js';
2454
+
2455
+ // Core Arc exports
2456
+ export { defineResource } from '@classytic/arc';
2457
+ export { createMongooseAdapter } from '@classytic/mongokit/adapter';
2458
+
2459
+ // Permission helpers (core + application-level)
2460
+ export * from './permissions.js';
2461
+
2462
+ // Presets
2463
+ export * from './presets/index.js';
2464
+ `;
2465
+ }
2466
+ //#endregion
2467
+ //#region src/cli/commands/init/templates/user.ts
2468
+ function userModelTemplate(config) {
2469
+ const ts = config.typescript;
2470
+ const orgRoles = config.tenant === "multi" ? `
2471
+ // Organization roles (for multi-tenant)
2472
+ const ORG_ROLES = ['owner', 'manager', 'hr', 'staff', 'contractor'] as const;
2473
+ type OrgRole = typeof ORG_ROLES[number];
2474
+ ` : "";
2475
+ const orgInterface = config.tenant === "multi" ? `
2476
+ type UserOrganization = {
2477
+ organizationId: Types.ObjectId;
2478
+ organizationName: string;
2479
+ roles: OrgRole[];
2480
+ joinedAt: Date;
2481
+ };
2482
+ ` : "";
2483
+ const orgSchema = config.tenant === "multi" ? `
2484
+ // Multi-org support
2485
+ organizations: [{
2486
+ organizationId: { type: Schema.Types.ObjectId, ref: 'Organization', required: true },
2487
+ organizationName: { type: String, required: true },
2488
+ roles: { type: [String], enum: ORG_ROLES, default: [] },
2489
+ joinedAt: { type: Date, default: () => new Date() },
2490
+ }],
2491
+ ` : "";
2492
+ const orgMethods = config.tenant === "multi" ? `
2493
+ // Organization methods
2494
+ userSchema.methods.getOrgRoles = function(orgId${ts ? ": Types.ObjectId | string" : ""}) {
2495
+ const org = this.organizations.find(o => o.organizationId.toString() === orgId.toString());
2496
+ return org?.roles || [];
2497
+ };
2498
+
2499
+ userSchema.methods.hasOrgAccess = function(orgId${ts ? ": Types.ObjectId | string" : ""}) {
2500
+ return this.organizations.some(o => o.organizationId.toString() === orgId.toString());
2501
+ };
2502
+
2503
+ userSchema.methods.addOrganization = function(
2504
+ organizationId${ts ? ": Types.ObjectId" : ""},
2505
+ organizationName${ts ? ": string" : ""},
2506
+ roles${ts ? ": OrgRole[]" : ""} = []
2507
+ ) {
2508
+ const existing = this.organizations.find(o => o.organizationId.toString() === organizationId.toString());
2509
+ if (existing) {
2510
+ existing.organizationName = organizationName;
2511
+ existing.roles = [...new Set([...existing.roles, ...roles])];
2512
+ } else {
2513
+ this.organizations.push({ organizationId, organizationName, roles, joinedAt: new Date() });
2514
+ }
2515
+ return this;
2516
+ };
2517
+
2518
+ userSchema.methods.removeOrganization = function(organizationId${ts ? ": Types.ObjectId" : ""}) {
2519
+ this.organizations = this.organizations.filter(o => o.organizationId.toString() !== organizationId.toString());
2520
+ return this;
2521
+ };
2522
+
2523
+ // Index for org queries
2524
+ userSchema.index({ 'organizations.organizationId': 1 });
2525
+ ` : "";
2526
+ const userType = ts ? `
2527
+ const PLATFORM_ROLES = ['user', 'admin', 'superadmin'] as const;
2528
+ type PlatformRole = typeof PLATFORM_ROLES[number];
2529
+
2530
+ /**
2531
+ * Comma-separated list of platform roles (Better Auth admin-plugin convention).
2532
+ * Single role: 'admin'. Multiple: 'admin,trainer'. Arc's permission engine
2533
+ * normalises both forms via getUserRoles() — see @classytic/arc/scope.
2534
+ */
2535
+ type User = {
2536
+ name: string;
2537
+ email: string;
2538
+ password: string;
2539
+ role: string;${config.tenant === "multi" ? `
2540
+ organizations: UserOrganization[];` : ""}
2541
+ resetPasswordToken?: string;
2542
+ resetPasswordExpires?: Date;
2543
+ };
2544
+
2545
+ type UserMethods = {
2546
+ matchPassword: (enteredPassword: string) => Promise<boolean>;${config.tenant === "multi" ? `
2547
+ getOrgRoles: (orgId: Types.ObjectId | string) => OrgRole[];
2548
+ hasOrgAccess: (orgId: Types.ObjectId | string) => boolean;
2549
+ addOrganization: (orgId: Types.ObjectId, name: string, roles?: OrgRole[]) => UserDocument;
2550
+ removeOrganization: (orgId: Types.ObjectId) => UserDocument;` : ""}
2551
+ };
2552
+
2553
+ export type UserDocument = HydratedDocument<User, UserMethods>;
2554
+ export type UserModel = Model<User, {}, UserMethods>;
2555
+ ` : "";
2556
+ return `/**
2557
+ * User Model
2558
+ * Generated by Arc CLI
2559
+ */
2560
+
2561
+ import bcrypt from 'bcryptjs';
2562
+ import mongoose${ts ? ", { type HydratedDocument, type Model, type Types }" : ""} from 'mongoose';
2563
+ ${orgRoles}
2564
+ const { Schema } = mongoose;
2565
+ ${orgInterface}${userType}
2566
+ const userSchema = new Schema${ts ? "<User, UserModel, UserMethods>" : ""}(
2567
+ {
2568
+ name: { type: String, required: true, trim: true },
2569
+ email: {
2570
+ type: String,
2571
+ required: true,
2572
+ unique: true,
2573
+ lowercase: true,
2574
+ trim: true,
2575
+ },
2576
+ password: { type: String, required: true },
2577
+
2578
+ // Platform role — singular field, matches Arc's permission engine
2579
+ // (req.user.role) and Better Auth's admin-plugin convention.
2580
+ // Comma-separated for multi-role users (e.g. 'admin,trainer');
2581
+ // getUserRoles() in @classytic/arc/scope normalises both forms.
2582
+ role: {
2583
+ type: String,
2584
+ required: true,
2585
+ default: 'user',
2586
+ index: true,
2587
+ validate: {
2588
+ validator: (v${ts ? ": string" : ""}) =>
2589
+ /^(user|admin|superadmin)(,(user|admin|superadmin))*$/.test(v),
2590
+ message: (props${ts ? ": { value: string }" : ""}) =>
2591
+ \`Invalid role "\${props.value}" — expected one or more of user|admin|superadmin\`,
2592
+ },
2593
+ },
2594
+ ${orgSchema}
2595
+ // Password reset
2596
+ resetPasswordToken: String,
2597
+ resetPasswordExpires: Date,
2598
+ },
2599
+ { timestamps: true }
2600
+ );
2601
+
2602
+ // Password hashing
2603
+ userSchema.pre('save', async function() {
2604
+ if (!this.isModified('password')) return;
2605
+ const salt = await bcrypt.genSalt(10);
2606
+ this.password = await bcrypt.hash(this.password, salt);
2607
+ });
2608
+
2609
+ // Password comparison
2610
+ userSchema.methods.matchPassword = async function(enteredPassword${ts ? ": string" : ""}) {
2611
+ return bcrypt.compare(enteredPassword, this.password);
2612
+ };
2613
+ ${orgMethods}
2614
+ // Exclude password in JSON
2615
+ userSchema.set('toJSON', {
2616
+ transform: (_doc, ret${ts ? ": Record<string, unknown>" : ""}) => {
2617
+ delete ret.password;
2618
+ delete ret.resetPasswordToken;
2619
+ delete ret.resetPasswordExpires;
2620
+ return ret;
2621
+ },
2622
+ });
2623
+
2624
+ const User = mongoose.models.User${ts ? " as UserModel" : ""} || mongoose.model${ts ? "<User, UserModel>" : ""}('User', userSchema);
2625
+ export default User;
2626
+ `;
2627
+ }
2628
+ function userRepositoryTemplate(config) {
2629
+ const ts = config.typescript;
2630
+ return `/**
2631
+ * User Repository
2632
+ * Generated by Arc CLI
2633
+ *
2634
+ * MongoKit repository with plugins for common operations
2635
+ */
2636
+
2637
+ import {
2638
+ Repository,
2639
+ methodRegistryPlugin,
2640
+ mongoOperationsPlugin,
2641
+ } from '@classytic/mongokit';
2642
+ ${ts ? "import type { UserDocument } from './user.model.js';\nimport type { ClientSession, Types } from 'mongoose';\n" : ""}import User from './user.model.js';
2643
+
2644
+ ${ts ? "type ID = string | Types.ObjectId;\n" : ""}
2645
+ class UserRepository extends Repository${ts ? "<UserDocument>" : ""} {
2646
+ constructor() {
2647
+ super(User${ts ? " as unknown as never" : ""}, [
2648
+ methodRegistryPlugin(),
2649
+ mongoOperationsPlugin(),
2650
+ ]);
2651
+ }
2652
+
2653
+ /**
2654
+ * Find user by email
2655
+ */
2656
+ async findByEmail(email${ts ? ": string" : ""}) {
2657
+ return this.Model.findOne({ email: email.toLowerCase().trim() });
2658
+ }
2659
+
2660
+ /**
2661
+ * Find user by reset token
2662
+ */
2663
+ async findByResetToken(token${ts ? ": string" : ""}) {
2664
+ return this.Model.findOne({
2665
+ resetPasswordToken: token,
2666
+ resetPasswordExpires: { $gt: Date.now() },
2667
+ });
2668
+ }
2669
+
2670
+ /**
2671
+ * Check if email exists
2672
+ */
2673
+ async emailExists(email${ts ? ": string" : ""})${ts ? ": Promise<boolean>" : ""} {
2674
+ const result = await this.Model.exists({ email: email.toLowerCase().trim() });
2675
+ return !!result;
2676
+ }
2677
+
2678
+ /**
2679
+ * Update user password (triggers hash middleware)
2680
+ */
2681
+ async updatePassword(userId${ts ? ": ID" : ""}, newPassword${ts ? ": string" : ""}, options${ts ? ": { session?: ClientSession }" : ""} = {}) {
2682
+ const user = await this.Model.findById(userId).session(options.session ?? null);
2683
+ if (!user) throw new Error('User not found');
2684
+
2685
+ user.password = newPassword;
2686
+ user.resetPasswordToken = undefined;
2687
+ user.resetPasswordExpires = undefined;
2688
+ await user.save({ session: options.session ?? undefined });
2689
+ return user;
2690
+ }
2691
+
2692
+ /**
2693
+ * Set reset token
2694
+ */
2695
+ async setResetToken(userId${ts ? ": ID" : ""}, token${ts ? ": string" : ""}, expiresAt${ts ? ": Date" : ""}) {
2696
+ return this.Model.findByIdAndUpdate(
2697
+ userId,
2698
+ { resetPasswordToken: token, resetPasswordExpires: expiresAt },
2699
+ { new: true }
2700
+ );
2701
+ }
2702
+ ${config.tenant === "multi" ? `
2703
+ /**
2704
+ * Find users by organization
2705
+ */
2706
+ async findByOrganization(organizationId${ts ? ": ID" : ""}) {
2707
+ return this.Model.find({ 'organizations.organizationId': organizationId })
2708
+ .select('-password -resetPasswordToken -resetPasswordExpires')
2709
+ .lean();
2710
+ }
2711
+ ` : ""}
2712
+ }
2713
+
2714
+ const userRepository = new UserRepository();
2715
+ export default userRepository;
2716
+ export { UserRepository };
2717
+ `;
2718
+ }
2719
+ function userControllerTemplate(config) {
2720
+ return `/**
2721
+ * User Controller
2722
+ * Generated by Arc CLI
2723
+ *
2724
+ * BaseController for user management operations.
2725
+ * Used by auth resource for /users/me endpoints.
2726
+ */
2727
+
2728
+ import { BaseController } from '@classytic/arc';
2729
+ import userRepository from './user.repository.js';
2730
+
2731
+ class UserController extends BaseController {
2732
+ constructor() {
2733
+ super(userRepository${config.typescript ? " as unknown as never" : ""});
2734
+ }
2735
+
2736
+ // Custom user operations can be added here
2737
+ }
2738
+
2739
+ const userController = new UserController();
2740
+ export default userController;
2741
+ `;
2742
+ }
2743
+ //#endregion
2744
+ //#region src/cli/commands/init/file-writer.ts
2745
+ /**
2746
+ * Scaffolding I/O — given a fully-resolved `ProjectConfig`, materialise
2747
+ * the project directory tree and write every template-produced file.
2748
+ *
2749
+ * Splitting this out of the orchestrator keeps `init()` focused on
2750
+ * lifecycle (validation, prompts, package-manager work) while the
2751
+ * directory layout + file-fanout logic lives here. Templates remain
2752
+ * pure — this module is the only place file I/O happens.
2753
+ */
2754
+ async function createProjectStructure(projectPath, config) {
2755
+ const ext = config.typescript ? "ts" : "js";
2756
+ const dirs = [
2757
+ "",
2758
+ "src",
2759
+ "src/config",
2760
+ "src/shared",
2761
+ "src/shared/presets",
2762
+ "src/plugins",
2763
+ "src/resources",
2764
+ ...config.auth === "jwt" ? ["src/resources/user", "src/resources/auth"] : [],
2765
+ "src/resources/example",
2766
+ "tests"
2767
+ ];
2768
+ for (const dir of dirs) {
2769
+ await fs.mkdir(path.join(projectPath, dir), { recursive: true });
2770
+ console.log(` + Created: ${dir || "/"}`);
2771
+ }
2772
+ const files = {
2773
+ "package.json": packageJsonTemplate(config),
2774
+ ".gitignore": gitignoreTemplate(),
2775
+ ".env.example": envExampleTemplate(config),
2776
+ ".env.dev": envDevTemplate(config),
2777
+ "README.md": readmeTemplate(config)
2778
+ };
2779
+ if (config.typescript) files["tsconfig.json"] = tsconfigTemplate();
2780
+ files["vitest.config.ts"] = vitestConfigTemplate(config);
2781
+ files[`src/config/env.${ext}`] = envLoaderTemplate(config);
2782
+ files[`src/config/index.${ext}`] = configTemplate(config);
2783
+ files[`src/app.${ext}`] = appTemplate(config);
2784
+ files[`src/index.${ext}`] = indexTemplate(config);
2785
+ files[`src/shared/index.${ext}`] = sharedIndexTemplate(config);
2786
+ files[`src/shared/adapter.${ext}`] = config.adapter === "mongokit" ? createAdapterTemplate(config) : customAdapterTemplate(config);
2787
+ files[`src/shared/permissions.${ext}`] = permissionsTemplate(config);
2788
+ if (config.tenant === "multi") {
2789
+ files[`src/shared/presets/index.${ext}`] = presetsMultiTenantTemplate(config);
2790
+ files[`src/shared/presets/flexible-multi-tenant.${ext}`] = flexibleMultiTenantPresetTemplate(config);
2791
+ } else files[`src/shared/presets/index.${ext}`] = presetsSingleTenantTemplate(config);
2792
+ files[`src/plugins/index.${ext}`] = pluginsIndexTemplate(config);
2793
+ files[`src/resources/index.${ext}`] = resourcesIndexTemplate(config);
2794
+ if (config.auth === "better-auth") files[`src/auth.${ext}`] = betterAuthSetupTemplate(config);
2795
+ else {
2796
+ files[`src/resources/user/user.model.${ext}`] = userModelTemplate(config);
2797
+ files[`src/resources/user/user.repository.${ext}`] = userRepositoryTemplate(config);
2798
+ files[`src/resources/user/user.controller.${ext}`] = userControllerTemplate(config);
2799
+ files[`src/resources/auth/auth.resource.${ext}`] = authResourceTemplate(config);
2800
+ files[`src/resources/auth/auth.handlers.${ext}`] = authHandlersTemplate(config);
2801
+ files[`src/resources/auth/auth.schemas.${ext}`] = authSchemasTemplate(config);
2802
+ }
2803
+ files[`src/resources/example/example.model.${ext}`] = exampleModelTemplate(config);
2804
+ files[`src/resources/example/example.repository.${ext}`] = exampleRepositoryTemplate(config);
2805
+ files[`src/resources/example/example.resource.${ext}`] = exampleResourceTemplate(config);
2806
+ files[`src/resources/example/example.controller.${ext}`] = exampleControllerTemplate(config);
2807
+ files[`src/resources/example/example.schemas.${ext}`] = exampleSchemasTemplate(config);
2808
+ files[`tests/example.test.${ext}`] = exampleTestTemplate(config);
2809
+ if (config.auth === "jwt") files[`tests/auth.test.${ext}`] = authTestTemplate(config);
2810
+ if (config.docker && !config.edge) {
2811
+ files.Dockerfile = dockerfileTemplate(config);
2812
+ files[".dockerignore"] = dockerignoreTemplate();
2813
+ files["docker-compose.yml"] = dockerComposeTemplate(config);
2814
+ }
2815
+ if (config.edge) files["wrangler.toml"] = wranglerTemplate(config);
2816
+ files[".arcrc"] = `${JSON.stringify({
2817
+ adapter: config.adapter,
2818
+ auth: config.auth,
2819
+ tenant: config.tenant,
2820
+ typescript: config.typescript
2821
+ }, null, 2)}\n`;
2822
+ for (const [filePath, content] of Object.entries(files)) {
2823
+ const fullPath = path.join(projectPath, filePath);
2824
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
2825
+ await fs.writeFile(fullPath, content);
2826
+ console.log(` + Created: ${filePath}`);
2827
+ }
2828
+ }
2829
+ /** Print the post-scaffold success message — next steps for the user. */
2830
+ function printSuccessMessage(config, skipInstall) {
2831
+ const installStep = skipInstall ? ` npm install\n` : "";
2832
+ const ext = config.typescript ? "ts" : "js";
2833
+ const authInfo = config.auth === "better-auth" ? `
2834
+ Auth (Better Auth):
2835
+
2836
+ Auth routes: http://localhost:8040/api/auth/*
2837
+ Better Auth handles: registration, login, sessions, OAuth
2838
+ Config file: src/auth.${ext}
2839
+ ` : `
2840
+ Auth (JWT):
2841
+
2842
+ POST /auth/register # Register
2843
+ POST /auth/login # Login (returns JWT)
2844
+ POST /auth/refresh # Refresh token
2845
+ GET /users/me # Current user profile
2846
+ `;
2847
+ console.log(`
2848
+ ╔═══════════════════════════════════════════════════════════════╗
2849
+ ║ Project Created ║
2850
+ ╚═══════════════════════════════════════════════════════════════╝
2851
+
2852
+ Next steps:
2853
+
2854
+ cd ${config.name}
2855
+ ${installStep} npm run dev # Uses .env.dev automatically
2856
+ ${authInfo}
2857
+ API Documentation:
2858
+
2859
+ http://localhost:8040/docs # Scalar UI
2860
+ http://localhost:8040/_docs/openapi.json # OpenAPI spec
2861
+
2862
+ Run tests:
2863
+
2864
+ npm test # Run once
2865
+ npm run test:watch # Watch mode
2866
+
2867
+ Add resources:
2868
+
2869
+ arc generate resource product
2870
+
2871
+ Project structure:
2872
+
2873
+ src/
2874
+ ├── app.${ext} # App factory (for workers/tests)
2875
+ ├── index.${ext} # Server entry${config.auth === "better-auth" ? `\n ├── auth.${ext} # Better Auth config` : ""}
2876
+ ├── config/ # Configuration
2877
+ ├── shared/ # Adapters, presets, permissions
2878
+ ├── plugins/ # App plugins (DI pattern)
2879
+ └── resources/ # API resources
2880
+
2881
+ Documentation:
2882
+ https://github.com/classytic/arc
2883
+ `);
2884
+ }
2885
+ //#endregion
2886
+ //#region src/cli/commands/init/options.ts
2887
+ /**
2888
+ * Interactive option-gathering for `arc init`.
2889
+ *
2890
+ * Two modes:
2891
+ * - Interactive: any unspecified option is prompted via readline.
2892
+ * - Non-interactive: triggered when `options.name` is provided — every
2893
+ * unspecified option falls to its sensible default. Used by tests and
2894
+ * by scripted scaffolds.
2895
+ *
2896
+ * Side effect: prints a Mongoose-on-Workers compatibility warning when
2897
+ * the user selects `edge + mongokit`, and offers to switch to the
2898
+ * custom adapter. This is the one spot where the CLI nudges the user
2899
+ * away from a known-broken combo.
2900
+ */
2901
+ async function gatherConfig(options) {
2902
+ const rl = readline.createInterface({
2903
+ input: process.stdin,
2904
+ output: process.stdout
2905
+ });
2906
+ const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
2907
+ const nonInteractive = !!options.name;
2908
+ try {
2909
+ const name = options.name || await question("Project name: ") || "my-arc-app";
2910
+ let adapter = options.adapter || "mongokit";
2911
+ if (!options.adapter && !nonInteractive) adapter = await question("Database adapter [1=MongoKit (recommended), 2=Custom / Drizzle-ready]: ") === "2" ? "custom" : "mongokit";
2912
+ let auth = options.auth || "better-auth";
2913
+ if (!options.auth && !nonInteractive) auth = await question("Auth strategy [1=Better Auth (recommended), 2=Arc JWT]: ") === "2" ? "jwt" : "better-auth";
2914
+ let session = options.session ?? "cookie";
2915
+ let apiKey = options.apiKey ?? false;
2916
+ if (auth === "better-auth" && !nonInteractive) {
2917
+ if (options.session === void 0) {
2918
+ const sessionChoice = await question("Session strategy [1=Cookie (web app, default), 2=Bearer token (mobile/SPA), 3=Both]: ");
2919
+ session = sessionChoice === "2" ? "bearer" : sessionChoice === "3" ? "both" : "cookie";
2920
+ }
2921
+ if (options.apiKey === void 0) apiKey = (await question("Enable API key plugin (machine-to-machine auth via @better-auth/api-key)? [y/N]: ")).toLowerCase() === "y";
2922
+ }
2923
+ let tenant = options.tenant || "single";
2924
+ if (!options.tenant && !nonInteractive) tenant = await question("Tenant mode [1=Single-tenant, 2=Multi-tenant]: ") === "2" ? "multi" : "single";
2925
+ let typescript = options.typescript ?? true;
2926
+ if (options.typescript === void 0 && !nonInteractive) typescript = await question("Language [1=TypeScript (recommended), 2=JavaScript]: ") !== "2";
2927
+ let edge = options.edge ?? false;
2928
+ if (options.edge === void 0 && !nonInteractive) edge = await question("Deployment target [1=Node.js Server (default), 2=Edge/Serverless]: ") === "2";
2929
+ let docker = (options.docker ?? false) && !edge;
2930
+ if (options.docker === void 0 && !nonInteractive && !edge) docker = (await question("Include Docker (Dockerfile + compose.yml) for local dev? [y/N]: ")).toLowerCase() === "y";
2931
+ if (edge && adapter === "mongokit" && !nonInteractive) {
2932
+ console.log("");
2933
+ console.log(" ⚠ Edge + MongoKit: Mongoose does NOT work on Cloudflare Workers.");
2934
+ console.log(" MongoDB Atlas works with the raw driver (mongodb 6.15+ with nodejs_compat_v2),");
2935
+ console.log(" but MongoKit depends on Mongoose. Options:");
2936
+ console.log(" 1. Use AWS Lambda / Vercel Serverless (Node.js) — Mongoose works normally");
2937
+ console.log(" 2. Use Cloudflare Hyperdrive + PostgreSQL (wire sqlitekit/Drizzle via custom adapter)");
2938
+ console.log(" 3. Continue with MongoKit — works on Lambda/Vercel, NOT on Cloudflare Workers");
2939
+ console.log("");
2940
+ if ((await question("Continue with MongoKit? [y/N]: ")).toLowerCase() !== "y") {
2941
+ adapter = "custom";
2942
+ console.log(" Switched to custom adapter. Wire sqlitekit/Drizzle, prismakit, or any RepositoryLike-conforming repo.");
2943
+ }
2944
+ }
2945
+ return {
2946
+ name,
2947
+ adapter,
2948
+ auth,
2949
+ tenant,
2950
+ apiKey,
2951
+ session,
2952
+ typescript,
2953
+ edge,
2954
+ docker
2955
+ };
2956
+ } finally {
2957
+ rl.close();
2958
+ }
2959
+ }
2960
+ //#endregion
2961
+ //#region src/cli/commands/init/postinstall.ts
2962
+ /**
2963
+ * Package manager detection + post-scaffold installation.
2964
+ *
2965
+ * Detection priority: lockfile in cwd > globally-available command
2966
+ * (pnpm > yarn > bun > npm). The lockfile check honours whatever the
2967
+ * user already has in the parent directory — installing a project
2968
+ * alongside an existing pnpm monorepo should prefer pnpm even if the
2969
+ * user also has npm installed globally.
2970
+ */
2971
+ /**
2972
+ * Detect which package manager to use.
2973
+ * Priority: pnpm > yarn > bun > npm (based on lockfile or global availability)
2974
+ */
2975
+ function detectPackageManager() {
2976
+ try {
2977
+ const cwd = process.cwd();
2978
+ if (existsSync$1(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2979
+ if (existsSync$1(path.join(cwd, "yarn.lock"))) return "yarn";
2980
+ if (existsSync$1(path.join(cwd, "bun.lockb"))) return "bun";
2981
+ if (existsSync$1(path.join(cwd, "package-lock.json"))) return "npm";
2982
+ } catch {}
2983
+ if (isCommandAvailable("pnpm")) return "pnpm";
2984
+ if (isCommandAvailable("yarn")) return "yarn";
2985
+ if (isCommandAvailable("bun")) return "bun";
2986
+ return "npm";
2987
+ }
2988
+ /** Check if a command is available in PATH. */
2989
+ function isCommandAvailable(command) {
2990
+ try {
2991
+ execSync(`${command} --version`, { stdio: "ignore" });
2992
+ return true;
2993
+ } catch {
2994
+ return false;
2995
+ }
2996
+ }
2997
+ /** Sync check if file exists (ESM-compatible — no require()). */
2998
+ function existsSync$1(filePath) {
2999
+ try {
3000
+ accessSync(filePath);
3001
+ return true;
3002
+ } catch {
3003
+ return false;
3004
+ }
3005
+ }
3006
+ /**
3007
+ * Install dependencies using the detected package manager.
3008
+ *
3009
+ * Dependencies are already declared in the generated `package.json` (see
3010
+ * `packageJsonTemplate`), so a single plain `install` resolves the full
3011
+ * tree. No two-pass `npm add` flow — the manifest is the source of truth.
3012
+ */
3013
+ async function installDependencies(projectPath, _config, pm) {
3014
+ console.log(` Installing dependencies...`);
3015
+ await runCommand(getInstallCommand(pm), projectPath);
3016
+ console.log(`\nDependencies installed successfully.`);
3017
+ }
3018
+ /**
3019
+ * Get the plain `install` command for a package manager. Reads the declared
3020
+ * dependencies from the project's `package.json`.
3021
+ */
3022
+ function getInstallCommand(pm) {
3023
+ switch (pm) {
3024
+ case "pnpm": return "pnpm install";
3025
+ case "yarn": return "yarn install";
3026
+ case "bun": return "bun install";
3027
+ default: return "npm install";
3028
+ }
3029
+ }
3030
+ /** Run a shell command in a directory. */
3031
+ function runCommand(command, cwd) {
3032
+ return new Promise((resolve, reject) => {
3033
+ const isWindows = process.platform === "win32";
3034
+ const child = spawn(isWindows ? "cmd" : "/bin/sh", [isWindows ? "/c" : "-c", command], {
3035
+ cwd,
3036
+ stdio: "inherit",
3037
+ env: {
3038
+ ...process.env,
3039
+ FORCE_COLOR: "1"
3040
+ }
3041
+ });
3042
+ child.on("close", (code) => {
3043
+ if (code === 0) resolve();
3044
+ else reject(/* @__PURE__ */ new Error(`Command failed with exit code ${code}`));
3045
+ });
3046
+ child.on("error", reject);
3047
+ });
3048
+ }
3049
+ //#endregion
3050
+ //#region src/cli/commands/init/index.ts
3051
+ /**
3052
+ * `arc init` orchestrator — banners, prompt-gathering, scaffold writing,
3053
+ * dependency installation. Every step lives in its own module under
3054
+ * `./`; this file owns the lifecycle and the user-facing console output
3055
+ * (`src/cli/` is the one place in arc that's allowed to speak directly
3056
+ * to `console.*` — see CLAUDE.md).
3057
+ */
3058
+ /**
3059
+ * Initialize a new Arc project.
3060
+ */
3061
+ async function init(options = {}) {
3062
+ console.log(`
3063
+ ╔═══════════════════════════════════════════════════════════════╗
3064
+ ║ Arc Project Setup ║
3065
+ ║ Resource-Oriented Backend Framework ║
3066
+ ╚═══════════════════════════════════════════════════════════════╝
3067
+ `);
3068
+ const config = await gatherConfig(options);
3069
+ console.log(`\nCreating project: ${config.name}`);
3070
+ console.log(` Adapter: ${config.adapter === "mongokit" ? "MongoKit (MongoDB)" : "Custom / Drizzle-ready"}`);
3071
+ console.log(` Auth: ${config.auth === "better-auth" ? "Better Auth (recommended)" : "Arc JWT"}`);
3072
+ if (config.auth === "better-auth") {
3073
+ console.log(` Session: ${config.session === "cookie" ? "Cookie" : config.session === "bearer" ? "Bearer token" : "Cookie + Bearer"}`);
3074
+ if (config.apiKey) console.log(` API keys: enabled (@better-auth/api-key)`);
3075
+ }
3076
+ console.log(` Tenant: ${config.tenant === "multi" ? "Multi-tenant" : "Single-tenant"}`);
3077
+ console.log(` Language: ${config.typescript ? "TypeScript" : "JavaScript"}`);
3078
+ console.log(` Target: ${config.edge ? "Edge/Serverless" : "Node.js Server"}`);
3079
+ if (config.docker) console.log(` Docker: included (--docker)`);
3080
+ console.log("");
3081
+ const projectPath = path.join(process.cwd(), config.name);
3082
+ try {
3083
+ await fs.access(projectPath);
3084
+ if (!options.force) throw new Error(`Directory "${config.name}" already exists. Use --force to overwrite.`);
3085
+ } catch (err) {
3086
+ if (!(err && typeof err === "object" && "code" in err && err.code === "ENOENT")) throw err;
3087
+ }
3088
+ const packageManager = detectPackageManager();
3089
+ console.log(`Using package manager: ${packageManager}\n`);
3090
+ await createProjectStructure(projectPath, config);
3091
+ if (!options.skipInstall) {
3092
+ console.log("\n📥 Installing dependencies...\n");
3093
+ await installDependencies(projectPath, config, packageManager);
3094
+ }
3095
+ printSuccessMessage(config, options.skipInstall);
3096
+ }
3097
+ //#endregion
3098
+ export { init as t };