@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.
@@ -0,0 +1,457 @@
1
+ "use strict";
2
+ /**
3
+ * Workspace-level files emitted by `nage create` (PLAN.md §9.1).
4
+ *
5
+ * The generated workspace is monorepo-first: one lockfile, one CI pipeline, one
6
+ * set of tooling, seeded with a single app so the simple case stays simple.
7
+ * Everything here is production-shaped from the first commit — strict TypeScript,
8
+ * a CI workflow, Docker, and a `.env.example` documenting every variable — because
9
+ * retrofitting those onto a project that started loose rarely happens.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.driverPackageFor = driverPackageFor;
13
+ exports.workspaceFiles = workspaceFiles;
14
+ exports.json = json;
15
+ const manifest_js_1 = require("../workspace/manifest.js");
16
+ /** The driver package that matches the workspace engine (§14.1: one, never both). */
17
+ function driverPackageFor(engine) {
18
+ return engine === 'mongodb' ? '@nage-api/data-mongo' : '@nage-api/data-sql';
19
+ }
20
+ function workspaceFiles(input) {
21
+ const { manifest } = input;
22
+ const version = manifest.frameworkVersion;
23
+ return [
24
+ { path: 'nage.workspace.json', contents: (0, manifest_js_1.serialiseManifest)(manifest) },
25
+ {
26
+ path: 'package.json',
27
+ contents: json({
28
+ name: manifest.name,
29
+ version: '0.0.0',
30
+ private: true,
31
+ packageManager: 'pnpm@10.33.0',
32
+ engines: { node: '>=22.0.0' },
33
+ scripts: {
34
+ build: 'turbo run build',
35
+ dev: 'turbo run dev',
36
+ lint: 'turbo run build && eslint .',
37
+ typecheck: 'turbo run typecheck',
38
+ test: 'turbo run test',
39
+ 'db:migrate': 'nage db migrate',
40
+ 'db:seed': 'nage db seed',
41
+ doctor: 'nage doctor',
42
+ },
43
+ devDependencies: {
44
+ '@nage-api/cli': version,
45
+ // `eslint.config.mjs` imports this; without it `pnpm lint` fails to
46
+ // resolve the config before linting a single file.
47
+ '@nage-api/eslint-config': version,
48
+ '@types/node': '22.20.1',
49
+ eslint: '10.8.1',
50
+ prettier: '3.9.6',
51
+ // Generated migrations live at the workspace root and import the
52
+ // driver's own types, so the root — not just the app — has to declare
53
+ // them. `sequelize` is a peer dependency of `@nage-api/data-sql`, which
54
+ // means the consumer provides it.
55
+ ...(manifest.engine === 'mongodb'
56
+ ? {}
57
+ : { [driverPackageFor(manifest.engine)]: version, sequelize: '^6.37.0' }),
58
+ turbo: '2.10.9',
59
+ typescript: '5.9.3',
60
+ vitest: '4.1.10',
61
+ },
62
+ }),
63
+ },
64
+ {
65
+ path: 'pnpm-workspace.yaml',
66
+ contents: [
67
+ 'packages:',
68
+ " - 'apps/*'",
69
+ " - 'packages/*'",
70
+ '',
71
+ '# Local packages are copied into the consumers that depend on them rather',
72
+ "# than symlinked, so `pnpm deploy` — which is how an app's Dockerfile builds",
73
+ '# its production node_modules — can produce a tree that stands on its own.',
74
+ '# Left symlinked, a deployed `@app/*` dependency is a link to a path that',
75
+ "# does not exist in the runtime stage, and the image dies on Node's",
76
+ '# `Cannot find module`. pnpm 10 also refuses to deploy at all without this.',
77
+ 'injectWorkspacePackages: true',
78
+ '',
79
+ '# What that costs: a consumer holds a copy, so an edit to a local package is',
80
+ '# invisible to it until the copy is refreshed. Refreshing after `build` is',
81
+ '# enough, because `turbo run build` builds a dependency before its consumer.',
82
+ 'syncInjectedDepsAfterScripts:',
83
+ ' - build',
84
+ '',
85
+ ].join('\n'),
86
+ },
87
+ {
88
+ path: 'turbo.json',
89
+ contents: json({
90
+ $schema: 'https://turbo.build/schema.json',
91
+ tasks: {
92
+ build: { dependsOn: ['^build'], outputs: ['dist/**'] },
93
+ dev: { cache: false, persistent: true },
94
+ typecheck: { dependsOn: ['^build'], outputs: [] },
95
+ test: { dependsOn: ['^build'], outputs: [] },
96
+ },
97
+ }),
98
+ },
99
+ {
100
+ path: 'tsconfig.base.json',
101
+ contents: json({
102
+ compilerOptions: {
103
+ target: 'ES2023',
104
+ lib: ['ES2023'],
105
+ module: 'NodeNext',
106
+ moduleResolution: 'NodeNext',
107
+ // The same strictness the framework holds itself to (§13): a project
108
+ // that starts strict stays strict, and one that does not, never does.
109
+ strict: true,
110
+ noUncheckedIndexedAccess: true,
111
+ exactOptionalPropertyTypes: true,
112
+ noImplicitOverride: true,
113
+ useUnknownInCatchVariables: true,
114
+ experimentalDecorators: true,
115
+ emitDecoratorMetadata: true,
116
+ declaration: true,
117
+ sourceMap: true,
118
+ skipLibCheck: true,
119
+ paths: {},
120
+ },
121
+ exclude: ['**/node_modules', '**/dist'],
122
+ }),
123
+ },
124
+ { path: 'tsconfig.json', contents: json({ files: [], references: [] }) },
125
+ {
126
+ // Migrations and seeds are TypeScript, and under type-aware ESLint a file
127
+ // belonging to no project is a hard parsing error rather than a skip — so
128
+ // `nage generate resource` used to make `pnpm lint` fail on the migration it
129
+ // had just written.
130
+ //
131
+ // Deliberately *not* referenced from the solution `tsconfig.json` above: a
132
+ // fresh workspace has no migrations, and an empty `include` makes `tsc -b`
133
+ // fail with "No inputs were found". ESLint's project service finds this
134
+ // config by walking up from the file, which needs no reference.
135
+ path: 'database/tsconfig.json',
136
+ contents: json({
137
+ extends: '../tsconfig.base.json',
138
+ compilerOptions: { noEmit: true, types: ['node'] },
139
+ include: ['migrations/**/*.ts', 'seeds/**/*.ts'],
140
+ }),
141
+ },
142
+ {
143
+ path: '.gitignore',
144
+ contents: [
145
+ 'node_modules/',
146
+ 'dist/',
147
+ 'coverage/',
148
+ '.turbo/',
149
+ '.env',
150
+ // A committed `tsbuildinfo` makes `tsc -b` believe an absent `dist` is
151
+ // already up to date, so a fresh checkout — or a Docker build — emits
152
+ // nothing and the image ships without its entry point.
153
+ '*.tsbuildinfo',
154
+ '*.log',
155
+ '',
156
+ ].join('\n'),
157
+ },
158
+ { path: '.dockerignore', contents: dockerIgnore() },
159
+ { path: '.nvmrc', contents: '22\n' },
160
+ {
161
+ path: '.env.example',
162
+ contents: envExample(manifest),
163
+ },
164
+ {
165
+ path: 'eslint.config.mjs',
166
+ contents: [
167
+ "import nage from '@nage-api/eslint-config';",
168
+ '',
169
+ 'export default nage({ tsconfigRootDir: import.meta.dirname });',
170
+ '',
171
+ ].join('\n'),
172
+ },
173
+ {
174
+ path: '.prettierrc.json',
175
+ contents: json({ singleQuote: true, semi: true, trailingComma: 'all', printWidth: 100 }),
176
+ },
177
+ // SQLite is a file, not a server: a compose file with a database service in
178
+ // it would be a lie, and one with no services is not a valid compose file.
179
+ ...(manifest.engine === 'sqlite'
180
+ ? []
181
+ : [{ path: 'docker-compose.yml', contents: dockerCompose(manifest.engine) }]),
182
+ {
183
+ path: '.github/workflows/ci.yml',
184
+ contents: workspaceCi(),
185
+ },
186
+ {
187
+ path: 'database/migrations/.gitkeep',
188
+ contents: '',
189
+ },
190
+ { path: 'database/seeds/.gitkeep', contents: '' },
191
+ { path: 'README.md', contents: readme(manifest) },
192
+ ];
193
+ }
194
+ function envExample(manifest) {
195
+ const lines = [
196
+ '# Every variable the workspace reads. `nage doctor` checks this file is current.',
197
+ 'NODE_ENV=development',
198
+ '',
199
+ '# Comma-separated allow-list. Omit to disable CORS entirely (the safe default).',
200
+ 'CORS_ORIGINS=http://localhost:3000',
201
+ '',
202
+ ];
203
+ if (manifest.engine === 'mongodb') {
204
+ lines.push('DATABASE_URL=mongodb://localhost:27017/app', '');
205
+ }
206
+ else {
207
+ lines.push(`DATABASE_URL=${manifest.engine}://app:app@localhost:5432/app`, '');
208
+ }
209
+ for (const app of manifest.apps) {
210
+ lines.push(`# ${app.name}`, `${app.name.toUpperCase()}_PORT=${String(app.port)}`, '');
211
+ }
212
+ return lines.join('\n');
213
+ }
214
+ /**
215
+ * What must never reach a build layer.
216
+ *
217
+ * Read from the context root — the workspace root, which is where a build has to
218
+ * start (`docker build -f apps/<app>/Dockerfile .`). A file named
219
+ * `apps/<app>/.dockerignore` is read by no builder: the classic one looks only at
220
+ * the context root, and BuildKit additionally accepts
221
+ * `apps/<app>/Dockerfile.dockerignore` but not that. That is how the first
222
+ * version of this template shipped a build stage with the developer's `.env`
223
+ * inside it — verified by reading the file back out of the build layer.
224
+ */
225
+ function dockerIgnore() {
226
+ return [
227
+ '# Read from the build context root, which is the only place Docker looks.',
228
+ '#',
229
+ '# Every pattern is `**/`-prefixed because these are not recursive otherwise:',
230
+ "# a bare `node_modules` excludes the workspace root's and leaves",
231
+ '# `apps/*/node_modules` — and `apps/*/.env` — in the context. That is measured,',
232
+ '# not assumed: without the prefix a probe of the build context still finds',
233
+ '# `apps/<app>/dist`, `apps/<app>/node_modules` and the stale tsbuildinfo.',
234
+ '#',
235
+ '# `.env` first, because it is the file here that must never reach a layer:',
236
+ '# `.gitignore` keeps it out of the repository but not out of the build',
237
+ '# context, and `COPY . .` in the build stage would bake live credentials into',
238
+ '# an image whose layers and build cache outlive the container.',
239
+ '**/.env',
240
+ '**/.env.*',
241
+ '**/*.pem',
242
+ '**/*.key',
243
+ '',
244
+ '# Never copied in, always produced inside the image: a stale `dist` or',
245
+ '# `tsbuildinfo` from the host makes `tsc -b` treat an absent `dist` as up to',
246
+ '# date and emit nothing, and the host `node_modules` is a symlink farm built',
247
+ "# against the host's paths and platform.",
248
+ '**/node_modules',
249
+ '**/dist',
250
+ '**/*.tsbuildinfo',
251
+ '**/.turbo',
252
+ '',
253
+ '# Noise: bytes in the context are bytes over the daemon socket on every build.',
254
+ '.git',
255
+ '.github',
256
+ '**/coverage',
257
+ '**/*.log',
258
+ '',
259
+ ].join('\n');
260
+ }
261
+ /**
262
+ * The engines an app talks to, each with a healthcheck.
263
+ *
264
+ * The healthchecks are the point (PLAN.md §21). `docker compose up -d` returns as
265
+ * soon as containers are *created*, so `pnpm db:migrate` on the next line of the
266
+ * README raced a server that had not finished initialising. Worse, the official
267
+ * images serve that initialisation from a temporary server bound to a unix socket
268
+ * only: measured on postgres:16, the socket accepts connections at t+0.86s and
269
+ * the real listener the app connects to appears at t+1.19s — so a check that does
270
+ * not name a TCP host reports ready before the database exists, and the gap grows
271
+ * with the size of the data directory. Each check below therefore goes through
272
+ * 127.0.0.1, and `up -d --wait` blocks until it passes.
273
+ */
274
+ function dockerCompose(engine) {
275
+ const database = engine === 'mongodb'
276
+ ? [
277
+ ' mongo:',
278
+ ' image: mongo:7',
279
+ ' ports: [' + "'27017:27017'" + ']',
280
+ ' volumes: [mongo-data:/data/db]',
281
+ ' healthcheck:',
282
+ // `mongosh --eval` exits non-zero when it cannot connect, which is what
283
+ // makes it usable as a check; `db.adminCommand('ping')` needs no auth.
284
+ ' test: [' +
285
+ "'CMD-SHELL', 'mongosh --quiet --host 127.0.0.1 " +
286
+ '--eval "db.adminCommand({ ping: 1 }).ok" | grep -q 1\'' +
287
+ ']',
288
+ ' interval: 5s',
289
+ ' timeout: 5s',
290
+ ' retries: 12',
291
+ ' start_period: 10s',
292
+ ]
293
+ : engine === 'mariadb'
294
+ ? [
295
+ ' mariadb:',
296
+ // MariaDB, not mysql:8: the workspace picked the MariaDB dialect, and
297
+ // developing against a server that is only nearly compatible with it
298
+ // moves the surprises to production.
299
+ ' image: mariadb:11',
300
+ ' environment:',
301
+ ' MARIADB_DATABASE: app',
302
+ ' MARIADB_ROOT_PASSWORD: app',
303
+ ' ports: [' + "'3306:3306'" + ']',
304
+ ' volumes: [mariadb-data:/var/lib/mysql]',
305
+ ' healthcheck:',
306
+ // Shipped in the image for this purpose, and needs no credentials on
307
+ // the command line. `--innodb_initialized` is the half that matters:
308
+ // the server answers a ping before InnoDB has finished recovery.
309
+ ' test: [' + "'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'" + ']',
310
+ ' interval: 5s',
311
+ ' timeout: 5s',
312
+ ' retries: 12',
313
+ ' start_period: 10s',
314
+ ]
315
+ : engine === 'mysql'
316
+ ? [
317
+ ' mysql:',
318
+ ' image: mysql:8',
319
+ ' environment:',
320
+ ' MYSQL_DATABASE: app',
321
+ ' MYSQL_ROOT_PASSWORD: app',
322
+ ' ports: [' + "'3306:3306'" + ']',
323
+ ' volumes: [mysql-data:/var/lib/mysql]',
324
+ ' healthcheck:',
325
+ // `$$` so compose passes the variable through for the container's
326
+ // shell to expand, rather than substituting it from the host
327
+ // environment — where it is not set. Through `MYSQL_PWD` rather
328
+ // than `-p`, which prints "using a password on the command line is
329
+ // insecure" into the healthcheck log on every probe and tempts the
330
+ // next reader to drop the credentials — leaving a check that
331
+ // passes on a server still rejecting every login.
332
+ ' test: [' +
333
+ "'CMD-SHELL', 'MYSQL_PWD=\"$$MYSQL_ROOT_PASSWORD\" " +
334
+ "mysqladmin ping -h 127.0.0.1 -u root --silent'" +
335
+ ']',
336
+ ' interval: 5s',
337
+ ' timeout: 5s',
338
+ ' retries: 12',
339
+ ' start_period: 10s',
340
+ ]
341
+ : [
342
+ ' postgres:',
343
+ ' image: postgres:16',
344
+ ' environment:',
345
+ ' POSTGRES_USER: app',
346
+ ' POSTGRES_PASSWORD: app',
347
+ ' POSTGRES_DB: app',
348
+ ' ports: [' + "'5432:5432'" + ']',
349
+ ' volumes: [postgres-data:/var/lib/postgresql/data]',
350
+ ' healthcheck:',
351
+ // Without `-h 127.0.0.1`, pg_isready answers over the unix socket
352
+ // the initialisation phase serves on, and reports ready some seconds
353
+ // before the database the app connects to exists.
354
+ ' test: [' + "'CMD-SHELL', 'pg_isready -h 127.0.0.1 -U app -d app'" + ']',
355
+ ' interval: 5s',
356
+ ' timeout: 5s',
357
+ // A minute of retries: initialising a data directory on a slow disk
358
+ // takes longer than a healthcheck's patience would suggest.
359
+ ' retries: 12',
360
+ ' start_period: 10s',
361
+ ];
362
+ const volumeName = engine === 'mongodb'
363
+ ? 'mongo-data'
364
+ : engine === 'mariadb'
365
+ ? 'mariadb-data'
366
+ : engine === 'mysql'
367
+ ? 'mysql-data'
368
+ : 'postgres-data';
369
+ return ['services:', ...database, '', 'volumes:', ` ${volumeName}:`, ''].join('\n');
370
+ }
371
+ function workspaceCi() {
372
+ return [
373
+ 'name: CI',
374
+ '',
375
+ 'on:',
376
+ ' push:',
377
+ ' branches: [main]',
378
+ ' pull_request:',
379
+ '',
380
+ 'jobs:',
381
+ ' ci:',
382
+ ' runs-on: ubuntu-latest',
383
+ ' steps:',
384
+ ' - uses: actions/checkout@v4',
385
+ ' - uses: pnpm/action-setup@v4',
386
+ ' - uses: actions/setup-node@v4',
387
+ ' with:',
388
+ ' node-version-file: .nvmrc',
389
+ ' cache: pnpm',
390
+ ' - run: pnpm install --frozen-lockfile',
391
+ ' # Build first: type-aware lint resolves workspace packages through',
392
+ ' # their build output.',
393
+ ' - run: pnpm build',
394
+ ' - run: pnpm lint',
395
+ ' - run: pnpm typecheck',
396
+ ' - run: pnpm test',
397
+ ' - run: pnpm doctor',
398
+ ' # Every app image, on every pull request. A Dockerfile that is only built',
399
+ ' # at release time is a Dockerfile that has never been built, and it fails',
400
+ ' # at the least convenient moment there is. The loop covers apps added',
401
+ ' # later without being edited.',
402
+ ' - name: Build app images',
403
+ ' run: |',
404
+ ' for dockerfile in apps/*/Dockerfile; do',
405
+ ' docker build -f "$dockerfile" -t "$(basename "$(dirname "$dockerfile")"):ci" .',
406
+ ' done',
407
+ '',
408
+ ].join('\n');
409
+ }
410
+ function readme(manifest) {
411
+ return [
412
+ `# ${manifest.name}`,
413
+ '',
414
+ 'Generated with `nage create`.',
415
+ '',
416
+ '## Getting started',
417
+ '',
418
+ '```bash',
419
+ 'pnpm install',
420
+ 'cp .env.example .env',
421
+ ...(manifest.engine === 'sqlite'
422
+ ? // SQLite needs no server, so there is no compose file to bring up.
423
+ []
424
+ : [
425
+ '# --wait, not bare `up -d`: it blocks until the healthcheck passes, and',
426
+ '# a migration run against a database still initialising fails.',
427
+ 'docker compose up -d --wait',
428
+ ]),
429
+ 'pnpm db:migrate',
430
+ 'pnpm dev',
431
+ '```',
432
+ '',
433
+ '## Layout',
434
+ '',
435
+ '```',
436
+ 'apps/ deployable applications',
437
+ 'packages/ shared local packages (@app/*)',
438
+ 'database/ migrations and seeds shared by every app',
439
+ '```',
440
+ '',
441
+ '## Commands',
442
+ '',
443
+ '| Command | Purpose |',
444
+ '|---|---|',
445
+ '| `nage new app <name>` | Add another deployable app |',
446
+ '| `nage new package <name>` | Add a shared local package |',
447
+ '| `nage g resource <name>` | Generate a wired CRUD resource |',
448
+ '| `nage add <feature>` | Enable an optional package for an app |',
449
+ '| `nage doctor` | Check workspace integrity and configuration |',
450
+ '',
451
+ ].join('\n');
452
+ }
453
+ /** Deterministic JSON with a trailing newline, so regeneration is diff-free. */
454
+ function json(value) {
455
+ return `${JSON.stringify(value, null, 2)}\n`;
456
+ }
457
+ //# sourceMappingURL=workspace.template.js.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `nage.workspace.json` — the source of truth for the workspace (PLAN.md §10.4).
3
+ *
4
+ * Successor to the legacy single-file `.nac-metadata.json`, extended for many
5
+ * apps. `new app` / `remove app` / `add` / `list` / `doctor` all read and write
6
+ * it, and it is serialised deterministically so a regenerated manifest produces
7
+ * an empty diff rather than reordered noise.
8
+ */
9
+ import type { DatabaseDriver } from '@nage-api/contracts';
10
+ export declare const MANIFEST_FILE = "nage.workspace.json";
11
+ /** App shapes the CLI can scaffold (§10.2). */
12
+ export type Preset = 'minimal' | 'api' | 'api-realtime' | 'worker';
13
+ /** Optional packages an app can enable (§10.1). */
14
+ export type FeatureName = 'auth' | 'cache' | 'queue' | 'storage' | 'realtime' | 'notify' | 'observability';
15
+ export interface AppEntry {
16
+ readonly name: string;
17
+ readonly preset: Preset;
18
+ /** Distinct per app; `doctor` reports a collision. */
19
+ readonly port: number;
20
+ readonly features: readonly FeatureName[];
21
+ }
22
+ export interface PackageEntry {
23
+ readonly name: string;
24
+ /** Import alias, e.g. `@app/domain`. */
25
+ readonly alias: string;
26
+ }
27
+ export interface WorkspaceManifest {
28
+ readonly version: 1;
29
+ readonly name: string;
30
+ /**
31
+ * One engine per workspace (§10.2): apps share `packages/domain` entities and
32
+ * `database/migrations`, which only stays coherent with a single engine.
33
+ */
34
+ readonly engine: DatabaseDriver;
35
+ /** The `@nage-api/*` version line every app pins to; `upgrade` moves it. */
36
+ readonly frameworkVersion: string;
37
+ readonly apps: readonly AppEntry[];
38
+ readonly packages: readonly PackageEntry[];
39
+ }
40
+ export declare const DEFAULT_PORT = 3000;
41
+ export declare function createManifest(input: {
42
+ name: string;
43
+ engine: DatabaseDriver;
44
+ frameworkVersion: string;
45
+ }): WorkspaceManifest;
46
+ /** Serialise deterministically: sorted collections, stable key order, trailing newline. */
47
+ export declare function serialiseManifest(manifest: WorkspaceManifest): string;
48
+ /** Parse and validate a manifest, naming what is wrong rather than throwing a cast error. */
49
+ export declare function parseManifest(contents: string, location?: string): WorkspaceManifest;
50
+ /**
51
+ * Walk up from `start` looking for a workspace root.
52
+ *
53
+ * Commands can then be run from anywhere inside the workspace, which is how
54
+ * `--app` stays optional: the current directory usually says which app is meant.
55
+ */
56
+ export declare function findWorkspaceRoot(start: string): string | undefined;
57
+ /** Load the manifest for the workspace containing `start`. */
58
+ export declare function loadWorkspace(start: string): Promise<{
59
+ root: string;
60
+ manifest: WorkspaceManifest;
61
+ }>;
62
+ /** Which app a command targets: `--app`, or the one the cwd sits inside. */
63
+ export declare function resolveTargetApp(manifest: WorkspaceManifest, options: {
64
+ root: string;
65
+ cwd: string;
66
+ app?: string;
67
+ }): AppEntry;
68
+ /** Next free port, so two apps never default to the same one. */
69
+ export declare function nextAvailablePort(manifest: WorkspaceManifest): number;
70
+ //# sourceMappingURL=manifest.d.ts.map