@nage-api/cli 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +196 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +276 -0
- package/dist/commands/create.d.ts +56 -0
- package/dist/commands/create.js +219 -0
- package/dist/commands/doctor.d.ts +47 -0
- package/dist/commands/doctor.js +208 -0
- package/dist/commands/features.d.ts +56 -0
- package/dist/commands/features.js +229 -0
- package/dist/commands/generate.d.ts +37 -0
- package/dist/commands/generate.js +151 -0
- package/dist/fs/file-tree.d.ts +57 -0
- package/dist/fs/file-tree.js +136 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +80 -0
- package/dist/main.d.ts +11 -0
- package/dist/main.js +43 -0
- package/dist/naming.d.ts +36 -0
- package/dist/naming.js +72 -0
- package/dist/templates/app.template.d.ts +19 -0
- package/dist/templates/app.template.js +601 -0
- package/dist/templates/resource.template.d.ts +39 -0
- package/dist/templates/resource.template.js +600 -0
- package/dist/templates/workspace.template.d.ts +22 -0
- package/dist/templates/workspace.template.js +457 -0
- package/dist/workspace/manifest.d.ts +70 -0
- package/dist/workspace/manifest.js +162 -0
- package/dist/workspace/wiring.d.ts +33 -0
- package/dist/workspace/wiring.js +112 -0
- package/package.json +51 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Files for one application (PLAN.md §9.1, §10.2).
|
|
4
|
+
*
|
|
5
|
+
* Four presets, one shape: a thin `main.ts` that calls `bootstrap`, a typed
|
|
6
|
+
* `nage.config.ts`, a zod env schema, health endpoints and a Dockerfile. What
|
|
7
|
+
* differs between presets is which modules are imported and whether an HTTP
|
|
8
|
+
* server is started at all.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.featurePackage = featurePackage;
|
|
12
|
+
exports.isHttpPreset = isHttpPreset;
|
|
13
|
+
exports.appFiles = appFiles;
|
|
14
|
+
const naming_js_1 = require("../naming.js");
|
|
15
|
+
const workspace_template_js_1 = require("./workspace.template.js");
|
|
16
|
+
/** npm packages an enabled feature pulls in. */
|
|
17
|
+
const FEATURE_PACKAGES = {
|
|
18
|
+
auth: '@nage-api/auth',
|
|
19
|
+
cache: '@nage-api/cache',
|
|
20
|
+
queue: '@nage-api/queue',
|
|
21
|
+
storage: '@nage-api/storage',
|
|
22
|
+
realtime: '@nage-api/realtime',
|
|
23
|
+
notify: '@nage-api/notify',
|
|
24
|
+
observability: '@nage-api/observability',
|
|
25
|
+
};
|
|
26
|
+
function featurePackage(feature) {
|
|
27
|
+
return FEATURE_PACKAGES[feature];
|
|
28
|
+
}
|
|
29
|
+
/** A worker has no HTTP surface, so it neither binds a port nor gets CORS. */
|
|
30
|
+
function isHttpPreset(preset) {
|
|
31
|
+
return preset !== 'worker';
|
|
32
|
+
}
|
|
33
|
+
function appFiles(input) {
|
|
34
|
+
const { app, manifest } = input;
|
|
35
|
+
const base = `apps/${app.name}`;
|
|
36
|
+
const names = (0, naming_js_1.deriveNames)(app.name);
|
|
37
|
+
const version = manifest.frameworkVersion;
|
|
38
|
+
const http = isHttpPreset(app.preset);
|
|
39
|
+
const files = [
|
|
40
|
+
{
|
|
41
|
+
path: `${base}/package.json`,
|
|
42
|
+
contents: (0, workspace_template_js_1.json)({
|
|
43
|
+
name: `@${manifest.name}/${app.name}`,
|
|
44
|
+
version: '0.0.0',
|
|
45
|
+
private: true,
|
|
46
|
+
type: 'commonjs',
|
|
47
|
+
main: 'dist/main.js',
|
|
48
|
+
scripts: {
|
|
49
|
+
build: 'tsc -b tsconfig.build.json',
|
|
50
|
+
dev: 'node --watch --enable-source-maps dist/main.js',
|
|
51
|
+
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
52
|
+
test: 'vitest run',
|
|
53
|
+
},
|
|
54
|
+
dependencies: {
|
|
55
|
+
'@nage-api/core': version,
|
|
56
|
+
'@nage-api/config': version,
|
|
57
|
+
'@nage-api/data': version,
|
|
58
|
+
[(0, workspace_template_js_1.driverPackageFor)(manifest.engine)]: version,
|
|
59
|
+
...Object.fromEntries(app.features.map((feature) => [featurePackage(feature), version])),
|
|
60
|
+
'@nestjs/common': '^11.0.0',
|
|
61
|
+
'@nestjs/core': '^11.0.0',
|
|
62
|
+
// `NestFactory.create` and `createNestApplication` both need an HTTP
|
|
63
|
+
// adapter. Without it the app cannot boot and the generated e2e spec
|
|
64
|
+
// cannot start a server, so it is a dependency and not a suggestion.
|
|
65
|
+
'@nestjs/platform-express': '^11.0.0',
|
|
66
|
+
'class-transformer': '^0.5.1',
|
|
67
|
+
'class-validator': '^0.15.0',
|
|
68
|
+
'reflect-metadata': '^0.2.0',
|
|
69
|
+
rxjs: '^7.8.0',
|
|
70
|
+
// A peer dependency of the SQL driver, which means this package is the
|
|
71
|
+
// one that has to supply it.
|
|
72
|
+
...(manifest.engine === 'mongodb' ? {} : { sequelize: '^6.37.0' }),
|
|
73
|
+
zod: '^4.0.0',
|
|
74
|
+
},
|
|
75
|
+
devDependencies: {
|
|
76
|
+
'@nestjs/testing': '^11.0.0',
|
|
77
|
+
// The generated tsconfig sets `types: ['node']`, and the e2e spec
|
|
78
|
+
// imports `supertest`: both need their type packages present or the
|
|
79
|
+
// app does not typecheck as generated.
|
|
80
|
+
'@types/node': '^22.0.0',
|
|
81
|
+
'@types/supertest': '^7.0.0',
|
|
82
|
+
// Vitest transforms with esbuild by default, which does not emit
|
|
83
|
+
// `design:paramtypes` — the metadata NestJS DI reads. Every generated
|
|
84
|
+
// resource injects something, so the SWC transform is required rather
|
|
85
|
+
// than an optimisation. See vitest.config.mts below.
|
|
86
|
+
'@swc/core': '^1.15.0',
|
|
87
|
+
supertest: '^7.0.0',
|
|
88
|
+
typescript: '5.9.3',
|
|
89
|
+
'unplugin-swc': '^1.5.0',
|
|
90
|
+
vitest: '4.1.10',
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
path: `${base}/tsconfig.json`,
|
|
96
|
+
contents: (0, workspace_template_js_1.json)({
|
|
97
|
+
extends: '../../tsconfig.base.json',
|
|
98
|
+
compilerOptions: { noEmit: true, types: ['node'] },
|
|
99
|
+
// The vitest config is included so the generated ESLint config can lint
|
|
100
|
+
// it: a TypeScript file that belongs to no project is a hard parsing
|
|
101
|
+
// error under type-aware rules, not a skipped file.
|
|
102
|
+
include: ['src/**/*.ts', 'test/**/*.ts', 'vitest.config.mts'],
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
path: `${base}/tsconfig.build.json`,
|
|
107
|
+
contents: (0, workspace_template_js_1.json)({
|
|
108
|
+
extends: '../../tsconfig.base.json',
|
|
109
|
+
compilerOptions: { composite: true, rootDir: 'src', outDir: 'dist', types: ['node'] },
|
|
110
|
+
include: ['src/**/*.ts'],
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
{ path: `${base}/vitest.config.mts`, contents: vitestConfig() },
|
|
114
|
+
{ path: `${base}/src/main.ts`, contents: mainFile(app) },
|
|
115
|
+
{ path: `${base}/src/app.module.ts`, contents: appModule(app, names.pascal) },
|
|
116
|
+
{ path: `${base}/src/config/env.schema.ts`, contents: envSchema(app, manifest.engine) },
|
|
117
|
+
{ path: `${base}/src/config/nage.config.ts`, contents: nageConfig(app, manifest) },
|
|
118
|
+
// No `.dockerignore` beside it: the build context is the workspace root, and
|
|
119
|
+
// that is the only directory Docker reads one from. See the root
|
|
120
|
+
// `.dockerignore` in workspace.template.ts.
|
|
121
|
+
{ path: `${base}/Dockerfile`, contents: dockerfile(app, manifest) },
|
|
122
|
+
];
|
|
123
|
+
if (http) {
|
|
124
|
+
files.push({ path: `${base}/src/health/health.controller.ts`, contents: healthController() }, { path: `${base}/src/health/health.module.ts`, contents: healthModule() }, { path: `${base}/test/health.e2e-spec.ts`, contents: healthSpec(app) });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
files.push({ path: `${base}/test/worker.spec.ts`, contents: workerSpec(names.pascal) });
|
|
128
|
+
}
|
|
129
|
+
return files;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The app's Vitest configuration.
|
|
133
|
+
*
|
|
134
|
+
* Two things it must get right, both of which the framework learned the hard
|
|
135
|
+
* way and neither of which is Vitest's default:
|
|
136
|
+
*
|
|
137
|
+
* - **SWC, not esbuild.** NestJS DI and `ValidationPipe` read
|
|
138
|
+
* `design:paramtypes`, which esbuild does not emit. Every generated resource
|
|
139
|
+
* injects a repository and a unit of work, so a suite on the default
|
|
140
|
+
* transform fails with "Nest can't resolve dependencies" the first time a
|
|
141
|
+
* resource is generated.
|
|
142
|
+
* - **the `-spec.ts` suffix.** Vitest's default `include` matches `*.spec.ts`
|
|
143
|
+
* but not `*.e2e-spec.ts`, so an app whose only test is an e2e spec exits 1
|
|
144
|
+
* with "No test files found".
|
|
145
|
+
*/
|
|
146
|
+
function vitestConfig() {
|
|
147
|
+
return [
|
|
148
|
+
"import swc from 'unplugin-swc';",
|
|
149
|
+
"import { defineConfig } from 'vitest/config';",
|
|
150
|
+
'',
|
|
151
|
+
'export default defineConfig({',
|
|
152
|
+
' plugins: [',
|
|
153
|
+
' swc.vite({',
|
|
154
|
+
" module: { type: 'es6' },",
|
|
155
|
+
' jsc: {',
|
|
156
|
+
" target: 'es2022',",
|
|
157
|
+
" parser: { syntax: 'typescript', decorators: true },",
|
|
158
|
+
' transform: { legacyDecorator: true, decoratorMetadata: true },',
|
|
159
|
+
' },',
|
|
160
|
+
' }),',
|
|
161
|
+
' ],',
|
|
162
|
+
' test: {',
|
|
163
|
+
" environment: 'node',",
|
|
164
|
+
" include: ['src/**/*.spec.ts', 'src/**/*-spec.ts', 'test/**/*.spec.ts', 'test/**/*-spec.ts'],",
|
|
165
|
+
' },',
|
|
166
|
+
'});',
|
|
167
|
+
'',
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
function mainFile(app) {
|
|
171
|
+
const http = isHttpPreset(app.preset);
|
|
172
|
+
const imports = http
|
|
173
|
+
? 'bootstrap'
|
|
174
|
+
: 'createApplication, installProcessGuards, installShutdown, JsonLogger';
|
|
175
|
+
return [
|
|
176
|
+
"import 'reflect-metadata';",
|
|
177
|
+
'',
|
|
178
|
+
`import { ${imports} } from '@nage-api/core';`,
|
|
179
|
+
"import { loadEnvOrExit } from '@nage-api/config';",
|
|
180
|
+
'',
|
|
181
|
+
"import { AppModule } from './app.module.js';",
|
|
182
|
+
"import { EnvSchema } from './config/env.schema.js';",
|
|
183
|
+
"import { buildConfig } from './config/nage.config.js';",
|
|
184
|
+
'',
|
|
185
|
+
'// Invalid configuration exits non-zero here, before a port is bound.',
|
|
186
|
+
'const env = loadEnvOrExit(EnvSchema);',
|
|
187
|
+
'',
|
|
188
|
+
http
|
|
189
|
+
? [
|
|
190
|
+
'// `bootstrap` installs the signal handlers and the fatal-error policy:',
|
|
191
|
+
'// SIGTERM fails readiness, drains in-flight requests, then closes pools',
|
|
192
|
+
"// (§21). Nest's own `enableShutdownHooks()` does those in the opposite",
|
|
193
|
+
'// order and is deliberately not used.',
|
|
194
|
+
'void bootstrap({ module: AppModule, config: buildConfig(env) });',
|
|
195
|
+
].join('\n')
|
|
196
|
+
: [
|
|
197
|
+
'// A worker consumes jobs; it starts no HTTP listener, so it wires the same',
|
|
198
|
+
'// shutdown sequence by hand. There is nothing to drain, but the deadline and',
|
|
199
|
+
'// the ordered `app.close()` still matter: a job handler mid-flight is exactly',
|
|
200
|
+
'// what a queue driver must be allowed to finish (§21).',
|
|
201
|
+
'async function main(): Promise<void> {',
|
|
202
|
+
' const config = buildConfig(env);',
|
|
203
|
+
' const app = await createApplication({ module: AppModule, config });',
|
|
204
|
+
' await app.init();',
|
|
205
|
+
'',
|
|
206
|
+
' const logger = new JsonLogger(undefined, config);',
|
|
207
|
+
' const shutdown = installShutdown(app, { config, logger });',
|
|
208
|
+
' installProcessGuards({',
|
|
209
|
+
' logger,',
|
|
210
|
+
" onFatal: async () => { await shutdown.run('fatal-error'); },",
|
|
211
|
+
' });',
|
|
212
|
+
'}',
|
|
213
|
+
'',
|
|
214
|
+
'void main();',
|
|
215
|
+
].join('\n'),
|
|
216
|
+
'',
|
|
217
|
+
].join('\n');
|
|
218
|
+
}
|
|
219
|
+
function appModule(app, pascal) {
|
|
220
|
+
const imports = [
|
|
221
|
+
'NageCoreModule.forRoot(config)',
|
|
222
|
+
'NageConfigModule.forRoot({ config, envSchema: EnvSchema })',
|
|
223
|
+
];
|
|
224
|
+
if (isHttpPreset(app.preset))
|
|
225
|
+
imports.push('HealthModule');
|
|
226
|
+
return [
|
|
227
|
+
"import { Module } from '@nestjs/common';",
|
|
228
|
+
"import { NageCoreModule } from '@nage-api/core';",
|
|
229
|
+
"import { NageConfigModule } from '@nage-api/config';",
|
|
230
|
+
'',
|
|
231
|
+
"import { EnvSchema } from './config/env.schema.js';",
|
|
232
|
+
"import { buildConfig } from './config/nage.config.js';",
|
|
233
|
+
...(isHttpPreset(app.preset)
|
|
234
|
+
? ["import { HealthModule } from './health/health.module.js';"]
|
|
235
|
+
: []),
|
|
236
|
+
'',
|
|
237
|
+
'const config = buildConfig(EnvSchema.parse(process.env));',
|
|
238
|
+
'',
|
|
239
|
+
'@Module({',
|
|
240
|
+
` imports: [${imports.join(', ')}],`,
|
|
241
|
+
'})',
|
|
242
|
+
`export class ${pascal}AppModule {}`,
|
|
243
|
+
'',
|
|
244
|
+
'// Exported under a stable name so main.ts does not change when the app is renamed.',
|
|
245
|
+
`export { ${pascal}AppModule as AppModule };`,
|
|
246
|
+
'',
|
|
247
|
+
].join('\n');
|
|
248
|
+
}
|
|
249
|
+
function envSchema(app, engine) {
|
|
250
|
+
const lines = [
|
|
251
|
+
"import { baseEnvSchema } from '@nage-api/config';",
|
|
252
|
+
"import { z } from 'zod';",
|
|
253
|
+
'',
|
|
254
|
+
'/**',
|
|
255
|
+
' * Every variable this app reads, validated once at boot.',
|
|
256
|
+
' * A missing or malformed value stops the process instead of surfacing later.',
|
|
257
|
+
' */',
|
|
258
|
+
'export const EnvSchema = baseEnvSchema.extend({',
|
|
259
|
+
// SQLite's "URL" is a file path, so `z.url()` would reject every valid value.
|
|
260
|
+
engine === 'sqlite'
|
|
261
|
+
? ' DATABASE_URL: z.string().min(1), // file path, e.g. ./database/dev.sqlite'
|
|
262
|
+
: ' DATABASE_URL: z.url(),',
|
|
263
|
+
];
|
|
264
|
+
if (isHttpPreset(app.preset)) {
|
|
265
|
+
lines.push(` PORT: z.coerce.number().int().min(1).max(65535).default(${String(app.port)}),`);
|
|
266
|
+
}
|
|
267
|
+
if (app.features.includes('auth')) {
|
|
268
|
+
// `@nage-api/auth` needs a signing pair plus one pepper per stored credential
|
|
269
|
+
// type; every one of them is a secret with no usable default, so the app
|
|
270
|
+
// refuses to boot without them rather than inventing one.
|
|
271
|
+
lines.push(' JWT_PRIVATE_KEY: z.string().min(16),', ' JWT_PUBLIC_KEY: z.string().min(16),', ' PASSWORD_PEPPER: z.string().min(16),', ' SESSION_PEPPER: z.string().min(16),', ' OTP_PEPPER: z.string().min(16),');
|
|
272
|
+
}
|
|
273
|
+
if (app.features.includes('cache') || app.features.includes('queue')) {
|
|
274
|
+
lines.push(' REDIS_URL: z.url(),');
|
|
275
|
+
}
|
|
276
|
+
lines.push('});', '', 'export type Env = z.infer<typeof EnvSchema>;', '');
|
|
277
|
+
return lines.join('\n');
|
|
278
|
+
}
|
|
279
|
+
function nageConfig(app, manifest) {
|
|
280
|
+
const features = app.features.map((feature) => ` ${feature}: { enabled: true },`).join('\n');
|
|
281
|
+
return [
|
|
282
|
+
"import { defineConfig } from '@nage-api/config';",
|
|
283
|
+
"import type { NageConfig } from '@nage-api/core';",
|
|
284
|
+
'',
|
|
285
|
+
"import type { Env } from './env.schema.js';",
|
|
286
|
+
'',
|
|
287
|
+
'/** Committed, typed configuration. Secrets come from the environment. */',
|
|
288
|
+
'export function buildConfig(env: Env): NageConfig {',
|
|
289
|
+
' return defineConfig({',
|
|
290
|
+
' app: {',
|
|
291
|
+
` name: '${app.name}',`,
|
|
292
|
+
' environment: env.NODE_ENV,',
|
|
293
|
+
...(isHttpPreset(app.preset) ? [' port: env.PORT,'] : []),
|
|
294
|
+
' },',
|
|
295
|
+
...(isHttpPreset(app.preset)
|
|
296
|
+
? [
|
|
297
|
+
' http: {',
|
|
298
|
+
' // Omitting `cors` disables it; an allow-list is required to enable it.',
|
|
299
|
+
' ...(env.CORS_ORIGINS === undefined ? {} : { cors: { origins: env.CORS_ORIGINS } }),',
|
|
300
|
+
' },',
|
|
301
|
+
]
|
|
302
|
+
: []),
|
|
303
|
+
' database: {',
|
|
304
|
+
' enabled: true,',
|
|
305
|
+
` driver: '${manifest.engine}',`,
|
|
306
|
+
' url: env.DATABASE_URL,',
|
|
307
|
+
" ssl: 'verify-full',",
|
|
308
|
+
' },',
|
|
309
|
+
...(features === '' ? [] : [features]),
|
|
310
|
+
' });',
|
|
311
|
+
'}',
|
|
312
|
+
'',
|
|
313
|
+
].join('\n');
|
|
314
|
+
}
|
|
315
|
+
function healthController() {
|
|
316
|
+
return [
|
|
317
|
+
"import { Controller, Get, HttpStatus, Inject, Optional, Res } from '@nestjs/common';",
|
|
318
|
+
"import { NAGE_LIFECYCLE, NoEnvelope, Public, SkipRateLimit } from '@nage-api/core';",
|
|
319
|
+
// The contract types are re-exported by @nage-api/core, so the app needs no
|
|
320
|
+
// direct dependency on @nage-api/contracts.
|
|
321
|
+
"import type { DataSourceHealth, LifecycleState } from '@nage-api/core';",
|
|
322
|
+
"import { NAGE_DATA_HEALTH } from '@nage-api/data';",
|
|
323
|
+
'',
|
|
324
|
+
'/**',
|
|
325
|
+
' * Liveness and readiness (PLAN.md §21).',
|
|
326
|
+
' *',
|
|
327
|
+
' * Both are public, exempt from throttling (an orchestrator polls them every',
|
|
328
|
+
' * few seconds) and unwrapped, because probes expect an exact body.',
|
|
329
|
+
' *',
|
|
330
|
+
' * They must not mean the same thing. **Liveness** answers "is this process',
|
|
331
|
+
' * wedged?" and never touches a dependency: if a database blip failed liveness,',
|
|
332
|
+
' * the orchestrator would restart every instance and turn a degraded system into',
|
|
333
|
+
' * an outage. **Readiness** answers "should traffic come here?", so it fails when',
|
|
334
|
+
' * the database is unreachable and as soon as a shutdown signal arrives — a',
|
|
335
|
+
' * draining process is about to close the connection it is being offered.',
|
|
336
|
+
' *',
|
|
337
|
+
' * The database probe is optional injection: it appears once a driver module is',
|
|
338
|
+
' * registered, and until then there is nothing to check. Add a probe per',
|
|
339
|
+
' * dependency you cannot serve without; for several, `@nage-api/observability`',
|
|
340
|
+
' * ships a registry that runs them concurrently under a timeout.',
|
|
341
|
+
' */',
|
|
342
|
+
"@Controller('health')",
|
|
343
|
+
'export class HealthController {',
|
|
344
|
+
' constructor(',
|
|
345
|
+
' @Optional() @Inject(NAGE_LIFECYCLE) private readonly lifecycle?: LifecycleState,',
|
|
346
|
+
' @Optional() @Inject(NAGE_DATA_HEALTH) private readonly database?: DataSourceHealth,',
|
|
347
|
+
' ) {}',
|
|
348
|
+
'',
|
|
349
|
+
" @Get('live')",
|
|
350
|
+
' @Public()',
|
|
351
|
+
' @SkipRateLimit()',
|
|
352
|
+
' @NoEnvelope()',
|
|
353
|
+
" live(): { status: 'ok' } {",
|
|
354
|
+
' // No dependency checks, and no lifecycle check either: a draining process',
|
|
355
|
+
' // is healthy, and failing liveness while it drains gets it killed.',
|
|
356
|
+
" return { status: 'ok' };",
|
|
357
|
+
' }',
|
|
358
|
+
'',
|
|
359
|
+
" @Get('ready')",
|
|
360
|
+
' @Public()',
|
|
361
|
+
' @SkipRateLimit()',
|
|
362
|
+
' @NoEnvelope()',
|
|
363
|
+
' async ready(',
|
|
364
|
+
' @Res({ passthrough: true }) response: { statusCode?: number },',
|
|
365
|
+
' ): Promise<{ status: string; checks: { name: string; status: string }[] }> {',
|
|
366
|
+
' if (this.lifecycle?.draining === true) {',
|
|
367
|
+
' response.statusCode = HttpStatus.SERVICE_UNAVAILABLE;',
|
|
368
|
+
" return { status: 'draining', checks: [] };",
|
|
369
|
+
' }',
|
|
370
|
+
'',
|
|
371
|
+
' const checks: { name: string; status: string }[] = [];',
|
|
372
|
+
' if (this.database !== undefined) {',
|
|
373
|
+
' const up = await this.database.ping();',
|
|
374
|
+
" checks.push({ name: this.database.driver, status: up ? 'up' : 'down' });",
|
|
375
|
+
' if (!up) response.statusCode = HttpStatus.SERVICE_UNAVAILABLE;',
|
|
376
|
+
' }',
|
|
377
|
+
'',
|
|
378
|
+
" const down = checks.some((check) => check.status === 'down');",
|
|
379
|
+
" return { status: down ? 'down' : 'up', checks };",
|
|
380
|
+
' }',
|
|
381
|
+
'}',
|
|
382
|
+
'',
|
|
383
|
+
].join('\n');
|
|
384
|
+
}
|
|
385
|
+
function healthModule() {
|
|
386
|
+
return [
|
|
387
|
+
"import { Module } from '@nestjs/common';",
|
|
388
|
+
'',
|
|
389
|
+
"import { HealthController } from './health.controller.js';",
|
|
390
|
+
'',
|
|
391
|
+
'@Module({ controllers: [HealthController] })',
|
|
392
|
+
'export class HealthModule {}',
|
|
393
|
+
'',
|
|
394
|
+
].join('\n');
|
|
395
|
+
}
|
|
396
|
+
function healthSpec(app) {
|
|
397
|
+
return [
|
|
398
|
+
"import 'reflect-metadata';",
|
|
399
|
+
'',
|
|
400
|
+
"import { afterEach, describe, expect, it } from 'vitest';",
|
|
401
|
+
"import { Test } from '@nestjs/testing';",
|
|
402
|
+
"import type { INestApplication } from '@nestjs/common';",
|
|
403
|
+
"import { LifecycleState, NAGE_LIFECYCLE } from '@nage-api/core';",
|
|
404
|
+
"import { NAGE_DATA_HEALTH } from '@nage-api/data';",
|
|
405
|
+
"import request from 'supertest';",
|
|
406
|
+
'',
|
|
407
|
+
"import { HealthController } from '../src/health/health.controller.js';",
|
|
408
|
+
'',
|
|
409
|
+
'/**',
|
|
410
|
+
' * The probes must not mean the same thing (PLAN.md §21): a dependency being',
|
|
411
|
+
' * down has to fail readiness and leave liveness alone, or an orchestrator',
|
|
412
|
+
' * restarts every healthy instance during a database blip.',
|
|
413
|
+
' *',
|
|
414
|
+
' * The controller is declared directly rather than through `HealthModule`, so',
|
|
415
|
+
' * these tests can bind the probes it injects optionally.',
|
|
416
|
+
' */',
|
|
417
|
+
`describe('${app.name} health', () => {`,
|
|
418
|
+
' let app: INestApplication | undefined;',
|
|
419
|
+
'',
|
|
420
|
+
' async function boot(providers: NonNullable<',
|
|
421
|
+
" Parameters<typeof Test.createTestingModule>[0]['providers']",
|
|
422
|
+
' > = []): Promise<INestApplication> {',
|
|
423
|
+
' const moduleRef = await Test.createTestingModule({',
|
|
424
|
+
' controllers: [HealthController],',
|
|
425
|
+
' providers,',
|
|
426
|
+
' }).compile();',
|
|
427
|
+
' app = moduleRef.createNestApplication();',
|
|
428
|
+
' await app.init();',
|
|
429
|
+
' return app;',
|
|
430
|
+
' }',
|
|
431
|
+
'',
|
|
432
|
+
' afterEach(async () => {',
|
|
433
|
+
' await app?.close();',
|
|
434
|
+
' app = undefined;',
|
|
435
|
+
' });',
|
|
436
|
+
'',
|
|
437
|
+
" it('should report liveness without touching a dependency', async () => {",
|
|
438
|
+
' const booted = await boot([',
|
|
439
|
+
' {',
|
|
440
|
+
' provide: NAGE_DATA_HEALTH,',
|
|
441
|
+
" useValue: { driver: 'sql:test', ping: () => Promise.resolve(false) },",
|
|
442
|
+
' },',
|
|
443
|
+
' ]);',
|
|
444
|
+
'',
|
|
445
|
+
" await request(booted.getHttpServer()).get('/health/live').expect(200, { status: 'ok' });",
|
|
446
|
+
' });',
|
|
447
|
+
'',
|
|
448
|
+
" it('should report readiness when there is nothing to check', async () => {",
|
|
449
|
+
' const booted = await boot();',
|
|
450
|
+
'',
|
|
451
|
+
" const response = await request(booted.getHttpServer()).get('/health/ready').expect(200);",
|
|
452
|
+
" expect(response.body).toEqual({ status: 'up', checks: [] });",
|
|
453
|
+
' });',
|
|
454
|
+
'',
|
|
455
|
+
" it('should fail readiness when the database is unreachable', async () => {",
|
|
456
|
+
' const booted = await boot([',
|
|
457
|
+
' {',
|
|
458
|
+
' provide: NAGE_DATA_HEALTH,',
|
|
459
|
+
" useValue: { driver: 'sql:test', ping: () => Promise.resolve(false) },",
|
|
460
|
+
' },',
|
|
461
|
+
' ]);',
|
|
462
|
+
'',
|
|
463
|
+
" const response = await request(booted.getHttpServer()).get('/health/ready').expect(503);",
|
|
464
|
+
" expect(response.body).toMatchObject({ status: 'down' });",
|
|
465
|
+
' });',
|
|
466
|
+
'',
|
|
467
|
+
" it('should fail readiness while the process is draining', async () => {",
|
|
468
|
+
' // SIGTERM has arrived: the instance still serves what it accepted, but it',
|
|
469
|
+
' // must stop being sent anything new.',
|
|
470
|
+
' const lifecycle = new LifecycleState();',
|
|
471
|
+
' const booted = await boot([{ provide: NAGE_LIFECYCLE, useValue: lifecycle }]);',
|
|
472
|
+
'',
|
|
473
|
+
" await request(booted.getHttpServer()).get('/health/ready').expect(200);",
|
|
474
|
+
' lifecycle.markDraining();',
|
|
475
|
+
'',
|
|
476
|
+
" const response = await request(booted.getHttpServer()).get('/health/ready').expect(503);",
|
|
477
|
+
" expect(response.body).toMatchObject({ status: 'draining' });",
|
|
478
|
+
" await request(booted.getHttpServer()).get('/health/live').expect(200, { status: 'ok' });",
|
|
479
|
+
' });',
|
|
480
|
+
'});',
|
|
481
|
+
'',
|
|
482
|
+
].join('\n');
|
|
483
|
+
}
|
|
484
|
+
function workerSpec(pascal) {
|
|
485
|
+
return [
|
|
486
|
+
"import { describe, expect, it } from 'vitest';",
|
|
487
|
+
'',
|
|
488
|
+
"import { buildConfig } from '../src/config/nage.config.js';",
|
|
489
|
+
'',
|
|
490
|
+
`describe('${pascal} worker', () => {`,
|
|
491
|
+
" it('should build a valid configuration', () => {",
|
|
492
|
+
' const config = buildConfig({',
|
|
493
|
+
" NODE_ENV: 'test',",
|
|
494
|
+
" DATABASE_URL: 'postgres://localhost:5432/app',",
|
|
495
|
+
" HOST: '0.0.0.0',",
|
|
496
|
+
" LOG_LEVEL: 'info',",
|
|
497
|
+
' } as never);',
|
|
498
|
+
'',
|
|
499
|
+
' expect(config.database?.enabled).toBe(true);',
|
|
500
|
+
' });',
|
|
501
|
+
'});',
|
|
502
|
+
'',
|
|
503
|
+
].join('\n');
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The app's Dockerfile: multi-stage, non-root, init as PID 1 (PLAN.md §21).
|
|
507
|
+
*
|
|
508
|
+
* Every line below that looks removable was a defect first. The first version of
|
|
509
|
+
* this template built an image that could not start — it copied the workspace
|
|
510
|
+
* root's `node_modules`, which in a pnpm workspace holds the *root's* dev
|
|
511
|
+
* tooling and the virtual store and none of the app's own dependencies, so
|
|
512
|
+
* `node dist/main.js` died on `Cannot find module 'reflect-metadata'` — and it
|
|
513
|
+
* ran node as PID 1, where SIGTERM is discarded rather than delivered.
|
|
514
|
+
*/
|
|
515
|
+
function dockerfile(app, manifest) {
|
|
516
|
+
// pnpm addresses workspace members by package name, not by directory.
|
|
517
|
+
const pkg = `@${manifest.name}/${app.name}`;
|
|
518
|
+
return [
|
|
519
|
+
'# Multi-stage build, non-root runtime, an init as PID 1 (PLAN.md §21).',
|
|
520
|
+
'#',
|
|
521
|
+
'# Build from the workspace root, never from this directory — the lockfile and',
|
|
522
|
+
'# every workspace package this app imports live above it:',
|
|
523
|
+
'#',
|
|
524
|
+
`# docker build -f apps/${app.name}/Dockerfile -t ${app.name} .`,
|
|
525
|
+
'',
|
|
526
|
+
'# Dependencies. `pnpm fetch` resolves from the lockfile alone and reads no',
|
|
527
|
+
'# package.json, so this — the only layer that needs the network — is',
|
|
528
|
+
'# invalidated when a dependency changes and not when a source file does. The',
|
|
529
|
+
'# root manifest comes with it for its `packageManager` field: without that,',
|
|
530
|
+
'# corepack installs whatever pnpm is newest and the build stops being',
|
|
531
|
+
'# reproducible.',
|
|
532
|
+
'FROM node:22-alpine AS deps',
|
|
533
|
+
'WORKDIR /workspace',
|
|
534
|
+
'RUN corepack enable',
|
|
535
|
+
'COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./',
|
|
536
|
+
'RUN pnpm fetch',
|
|
537
|
+
'',
|
|
538
|
+
'# Build. `--offline` keeps every layer below the fetch off the network, so a',
|
|
539
|
+
'# lockfile missing an entry fails here rather than being quietly resolved;',
|
|
540
|
+
'# `--frozen-lockfile` refuses to rewrite the lockfile rather than building',
|
|
541
|
+
'# something the repository never described.',
|
|
542
|
+
'FROM deps AS build',
|
|
543
|
+
'COPY . .',
|
|
544
|
+
'RUN pnpm install --frozen-lockfile --offline',
|
|
545
|
+
`RUN pnpm --filter ${pkg} build`,
|
|
546
|
+
"# `deploy` flattens pnpm's symlink farm into one self-contained tree and",
|
|
547
|
+
'# `--prod` leaves the devDependencies behind, so the runtime stage inherits no',
|
|
548
|
+
'# compiler, test runner or linter: ~70 MB of dependencies instead of ~310 MB.',
|
|
549
|
+
'# It is self-contained only because the workspace sets',
|
|
550
|
+
'# `injectWorkspacePackages` — without it pnpm links `@app/*` dependencies to',
|
|
551
|
+
'# paths outside /deploy, and copying the tree into the runtime stage yields',
|
|
552
|
+
'# dangling symlinks and a container that exits on its first require.',
|
|
553
|
+
`RUN pnpm --filter ${pkg} deploy --prod --offline /deploy`,
|
|
554
|
+
'',
|
|
555
|
+
'# Runtime. A fresh stage from the base image, so nothing above it — pnpm, the',
|
|
556
|
+
'# store, the sources, the lockfile — is reachable from the shipped image.',
|
|
557
|
+
'FROM node:22-alpine AS runtime',
|
|
558
|
+
'# tini as PID 1, because PID 1 is exempt from default signal dispositions: the',
|
|
559
|
+
'# kernel discards any signal the process has installed no handler for. Node',
|
|
560
|
+
'# gets its SIGTERM listener when `bootstrap` calls `installShutdown`, so a',
|
|
561
|
+
'# SIGTERM arriving before that — an aborted rolling deploy, a crash loop being',
|
|
562
|
+
'# stopped — is dropped, and the orchestrator waits out its entire grace period',
|
|
563
|
+
'# before SIGKILL. Graceful shutdown (§21) is worth nothing if PID 1 never sees',
|
|
564
|
+
'# the signal. tini also reaps the orphans PID 1 inherits, which otherwise',
|
|
565
|
+
'# accumulate as zombies for the life of the container.',
|
|
566
|
+
'RUN apk add --no-cache tini',
|
|
567
|
+
'WORKDIR /app',
|
|
568
|
+
'ENV NODE_ENV=production',
|
|
569
|
+
'# Copied as root and run as `node`: the runtime user can read and execute the',
|
|
570
|
+
'# application but cannot rewrite it, so code that reaches the process cannot',
|
|
571
|
+
'# persist itself by patching dist/ or node_modules.',
|
|
572
|
+
'COPY --from=build /deploy/node_modules ./node_modules',
|
|
573
|
+
'# The deployed manifest, so `type` and `main` are declared rather than inferred.',
|
|
574
|
+
'COPY --from=build /deploy/package.json ./package.json',
|
|
575
|
+
`COPY --from=build /workspace/apps/${app.name}/dist ./dist`,
|
|
576
|
+
'USER node',
|
|
577
|
+
...(isHttpPreset(app.preset)
|
|
578
|
+
? [
|
|
579
|
+
`EXPOSE ${String(app.port)}`,
|
|
580
|
+
'# `/health/live`, never `/health/ready` (§21). Liveness answers "is this',
|
|
581
|
+
'# process broken"; readiness answers "should traffic come here" and goes',
|
|
582
|
+
"# false while a dependency reconnects. Docker's answer to an unhealthy",
|
|
583
|
+
'# container under a restart policy is to kill it, so probing readiness',
|
|
584
|
+
'# would restart an app whose only fault was a database briefly away —',
|
|
585
|
+
'# destroying the in-flight requests shutdown hooks exist to drain.',
|
|
586
|
+
'# Three misses at 30s is ~90s of confirmed failure before the container is',
|
|
587
|
+
'# called unhealthy; --start-period keeps a slow boot out of that count and',
|
|
588
|
+
'# --timeout stops one hung request from holding the probe for 30s.',
|
|
589
|
+
'HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \\',
|
|
590
|
+
` CMD wget -qO- http://127.0.0.1:${String(app.port)}/health/live || exit 1`,
|
|
591
|
+
]
|
|
592
|
+
: [
|
|
593
|
+
'# No healthcheck: a worker has no HTTP surface to probe. Its liveness is',
|
|
594
|
+
"# the queue's to report (§21), not this container's.",
|
|
595
|
+
]),
|
|
596
|
+
'ENTRYPOINT ["/sbin/tini", "--"]',
|
|
597
|
+
'CMD ["node", "dist/main.js"]',
|
|
598
|
+
'',
|
|
599
|
+
].join('\n');
|
|
600
|
+
}
|
|
601
|
+
//# sourceMappingURL=app.template.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nage g resource product` (PLAN.md §10.3).
|
|
3
|
+
*
|
|
4
|
+
* One command produces a live, secured, paginated REST resource: entity,
|
|
5
|
+
* service, controller, DTOs, migration and tests, wired into the target app.
|
|
6
|
+
* The point is that it inherits capability rather than copying code — the
|
|
7
|
+
* service extends `ModelService`, so the list DSL, pagination envelope, soft
|
|
8
|
+
* delete and audit fields come from the framework, not from generated
|
|
9
|
+
* boilerplate a developer then has to maintain.
|
|
10
|
+
*/
|
|
11
|
+
import type { DatabaseDriver } from '@nage-api/contracts';
|
|
12
|
+
import type { PlannedFile } from '../fs/file-tree.js';
|
|
13
|
+
/** A column declared with `--fields "name:string,slug:string:unique"`. */
|
|
14
|
+
export interface FieldSpec {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly type: 'string' | 'number' | 'boolean' | 'date' | 'text' | 'json';
|
|
17
|
+
readonly unique: boolean;
|
|
18
|
+
readonly optional: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ResourceTemplateInput {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly appName: string;
|
|
23
|
+
readonly engine: DatabaseDriver;
|
|
24
|
+
readonly fields: readonly FieldSpec[];
|
|
25
|
+
/** Base route; defaults to the plural kebab name. */
|
|
26
|
+
readonly route?: string;
|
|
27
|
+
readonly withMigration?: boolean;
|
|
28
|
+
readonly withSpec?: boolean;
|
|
29
|
+
/** Emit the entity into a shared package instead of the app. */
|
|
30
|
+
readonly entityPackage?: string;
|
|
31
|
+
/** Injected so a generated migration id is deterministic in tests. */
|
|
32
|
+
readonly timestamp?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Parse `--fields "name:string,slug:string:unique,note:text:optional"`. */
|
|
35
|
+
export declare function parseFields(raw: string | undefined): FieldSpec[];
|
|
36
|
+
export declare function resourceFiles(input: ResourceTemplateInput): PlannedFile[];
|
|
37
|
+
/** `20260814T134512` — sorts chronologically and reads as a date. */
|
|
38
|
+
export declare function compactTimestamp(now?: Date): string;
|
|
39
|
+
//# sourceMappingURL=resource.template.d.ts.map
|