@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.
- package/README.md +1 -0
- package/bin/arc.js +12 -0
- package/dist/{BaseController-dx3m2J8V.mjs → BaseController-DlCCTIxJ.mjs} +61 -19
- package/dist/{HookSystem-Iiebom92.mjs → HookSystem-Cmf7-Etp.mjs} +8 -4
- package/dist/{QueryCache-D41bfdBB.d.mts → QueryCache-SvmT_9ti.d.mts} +1 -1
- package/dist/{ResourceRegistry-CTERg_2x.mjs → ResourceRegistry-f48hFk3m.mjs} +52 -9
- package/dist/audit/index.d.mts +1 -1
- package/dist/audit/index.mjs +4 -2
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +4 -4
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi--M_i87dQ.mjs → betterAuthOpenApi-ClWxaceA.mjs} +10 -6
- package/dist/buildHandler-BZX6zzDM.mjs +300 -0
- package/dist/cache/index.d.mts +3 -3
- package/dist/cache/index.mjs +3 -3
- package/dist/{caching-SM8gghN6.mjs → caching-TeHE8G-v.mjs} +1 -1
- package/dist/cli/commands/describe.d.mts +35 -1
- package/dist/cli/commands/describe.mjs +52 -12
- package/dist/cli/commands/docs.d.mts +1 -4
- package/dist/cli/commands/docs.mjs +4 -16
- package/dist/cli/commands/generate.d.mts +2 -20
- package/dist/cli/commands/generate.mjs +1 -546
- package/dist/cli/commands/init.d.mts +2 -40
- package/dist/cli/commands/init.mjs +1 -3045
- package/dist/cli/commands/introspect.mjs +53 -64
- package/dist/cli/index.d.mts +2 -2
- package/dist/cli/index.mjs +2 -2
- package/dist/{constants-Cxde4rpC.mjs → constants-TrJVIJl0.mjs} +7 -0
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/{core-CvmOqEms.mjs → core-DBJ_j6rX.mjs} +222 -44
- package/dist/createActionRouter-DUpN3Dd1.mjs +288 -0
- package/dist/{createAggregationRouter-B0bPDf5b.mjs → createAggregationRouter-Dq-TUCuY.mjs} +3 -2
- package/dist/{createApp-PFegs47-.mjs → createApp-DNccuhyI.mjs} +16 -14
- package/dist/{defineEvent-D5h7EvAx.mjs → defineEvent-DRwY0fYm.mjs} +1 -1
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{errorHandler-Bk-AGhkU.mjs → errorHandler-DpoXQHZ9.mjs} +17 -14
- package/dist/errors-C1lX_jlm.d.mts +91 -0
- package/dist/{eventPlugin-CaKTYkYM.mjs → eventPlugin-C2cGqtRO.mjs} +1 -1
- package/dist/{eventPlugin-qXpqTebY.d.mts → eventPlugin-CtHC_av1.d.mts} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +5 -5
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-COhcH3fk.d.mts → fields-Anj0xdih.d.mts} +1 -1
- package/dist/generate-BWFwgcCM.d.mts +38 -0
- package/dist/generate-CYac-OLv.mjs +654 -0
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +2 -2
- package/dist/idempotency/index.mjs +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-BTqLEvhu.d.mts → index-3oIimXQn.d.mts} +12 -12
- package/dist/{index-BstGxcc3.d.mts → index-B-ulKx5P.d.mts} +55 -4
- package/dist/{index-BswOSJCE.d.mts → index-CkW0flkU.d.mts} +355 -16
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +7 -8
- package/dist/init-Dv71MsJr.d.mts +71 -0
- package/dist/init-HDvoO9L5.mjs +3098 -0
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/jobs.mjs +3 -3
- package/dist/integrations/mcp/index.d.mts +239 -7
- package/dist/integrations/mcp/index.mjs +2 -528
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +6 -10
- package/dist/integrations/streamline.mjs +26 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/integrations/websocket.mjs +1 -0
- package/dist/loadResourcesFromEntry-BLMEI2Xa.mjs +51 -0
- package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-7vGV51ED.mjs} +1021 -318
- package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{openapi-BHXhoX8O.mjs → openapi-34T9yNwd.mjs} +47 -36
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +1 -1
- package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
- package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +5 -5
- package/dist/plugins/index.mjs +10 -10
- package/dist/plugins/response-cache.mjs +5 -5
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +2 -2
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +4 -3
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
- package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
- package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Biqzfbi5.d.mts} +2 -2
- package/dist/{redis-DiMkdHEl.d.mts → redis-Cyzrz6SX.d.mts} +1 -1
- package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-DT-YjzrB.d.mts} +1 -1
- package/dist/registry/index.d.mts +319 -2
- package/dist/registry/index.mjs +3 -3
- package/dist/registry-BBE23CDj.mjs +576 -0
- package/dist/{routerShared-DrOa-26E.mjs → routerShared-CZV5aabX.mjs} +3 -3
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +3 -3
- package/dist/{sse-Bz-5ZeTt.mjs → sse-BY6sTy4P.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +16 -7
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +5 -5
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
- package/dist/{types-BQsjgQzS.d.mts → types-BsJMEQ4D.d.mts} +106 -12
- package/dist/{types-DrBaUwyV.d.mts → types-D-fYtKjb.d.mts} +33 -10
- package/dist/{types-CTYvcwHe.d.mts → types-DVfpSfx2.d.mts} +42 -1
- package/dist/utils/index.d.mts +1286 -2
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-_h9B3c57.mjs → utils-DC5ycPfr.mjs} +89 -40
- package/dist/{buildHandler-CcFOpJLh.mjs → validate-By96rH0r.mjs} +8 -299
- package/dist/{versioning-hmkPcDlX.d.mts → versioning-ZwX9tmbS.d.mts} +1 -1
- package/package.json +21 -28
- package/skills/arc/SKILL.md +300 -706
- package/skills/arc/references/auth.md +19 -7
- package/skills/arc-code-review/SKILL.md +1 -1
- package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
- package/dist/createActionRouter-S3MLVYot.mjs +0 -220
- package/dist/index-bRjYu21O.d.mts +0 -1320
- package/dist/org/index.d.mts +0 -66
- package/dist/org/index.mjs +0 -486
- package/dist/org/types.d.mts +0 -82
- package/dist/org/types.mjs +0 -1
- package/dist/registry-I-ogLgL9.mjs +0 -46
- /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-C-2oAHtw.d.mts} +0 -0
- /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-Hxvv5QQz.mjs} +0 -0
- /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
- /package/dist/{elevation-BXOWoGCF.d.mts → elevation-0YBpa663.d.mts} +0 -0
- /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
- /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-mHuyWzZE.d.mts} +0 -0
- /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-DFg-2KTp.d.mts} +0 -0
- /package/dist/{interface-beEtJyWM.d.mts → interface-CH0OQudo.d.mts} +0 -0
- /package/dist/{interface-DfLGcus7.d.mts → interface-NwJ_qPlY.d.mts} +0 -0
- /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
- /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-ChQEj8ih.mjs} +0 -0
- /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-TuOmguhi.mjs} +0 -0
- /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-C-gD32oF.mjs} +0 -0
- /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-Ctc89DSn.mjs} +0 -0
- /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-BqFegc0W.d.mts} +0 -0
- /package/dist/{storage-Dfzt4VTl.d.mts → storage-D2KZJAmn.d.mts} +0 -0
- /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-B0sunfZZ.mjs} +0 -0
- /package/dist/{tracing-QJVprktp.d.mts → tracing-Dm8n7Cnn.d.mts} +0 -0
- /package/dist/{versioning-BUrT5aP4.mjs → versioning-B6mimogM.mjs} +0 -0
- /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 };
|