@alexify/migronaut 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/LICENSE +22 -0
  3. package/README.md +773 -0
  4. package/bin/migronaut.js +36 -0
  5. package/index.d.ts +903 -0
  6. package/index.js +1 -0
  7. package/migronaut.schema.json +135 -0
  8. package/package.json +105 -0
  9. package/src/cli/args.js +314 -0
  10. package/src/cli/commands/audit.js +40 -0
  11. package/src/cli/commands/create.js +29 -0
  12. package/src/cli/commands/down.js +30 -0
  13. package/src/cli/commands/dry-run.js +36 -0
  14. package/src/cli/commands/import.js +33 -0
  15. package/src/cli/commands/init.js +64 -0
  16. package/src/cli/commands/list.js +19 -0
  17. package/src/cli/commands/lock.js +35 -0
  18. package/src/cli/commands/redo.js +16 -0
  19. package/src/cli/commands/status.js +52 -0
  20. package/src/cli/commands/unlock.js +43 -0
  21. package/src/cli/commands/up.js +53 -0
  22. package/src/cli/exit-codes.js +38 -0
  23. package/src/cli/index.js +91 -0
  24. package/src/cli/shared.js +343 -0
  25. package/src/cli/spinner.js +59 -0
  26. package/src/cli/table.js +259 -0
  27. package/src/core/audit.js +152 -0
  28. package/src/core/changelog.js +231 -0
  29. package/src/core/config.js +479 -0
  30. package/src/core/context.js +16 -0
  31. package/src/core/import-runner.js +164 -0
  32. package/src/core/import.js +60 -0
  33. package/src/core/lock.js +348 -0
  34. package/src/core/migrator.js +1475 -0
  35. package/src/core/run.js +144 -0
  36. package/src/core/runner.js +150 -0
  37. package/src/errors/index.js +215 -0
  38. package/src/index.js +59 -0
  39. package/src/utils/checksum.js +23 -0
  40. package/src/utils/colors.js +68 -0
  41. package/src/utils/concurrency.js +32 -0
  42. package/src/utils/date.js +28 -0
  43. package/src/utils/env.js +51 -0
  44. package/src/utils/error.js +11 -0
  45. package/src/utils/loader.js +131 -0
  46. package/src/utils/logger.js +109 -0
  47. package/src/utils/redact.js +39 -0
  48. package/src/utils/sanitize.js +43 -0
  49. package/src/utils/template.js +615 -0
  50. package/src/utils/user.js +25 -0
@@ -0,0 +1,615 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const {
4
+ ConfigFileExistsError,
5
+ ConfigInvalidError,
6
+ MigrationFileExistsError,
7
+ MigrationFileNotFoundError,
8
+ } = require('../errors/index.js');
9
+ const { formatStamp } = require('./date.js');
10
+ const { errorText } = require('./error.js');
11
+
12
+ /**
13
+ * Convert an arbitrary migration name into a kebab-case slug. Unicode-aware —
14
+ * an ASCII-only rule turned "Ünïcödé Ñame" into "n-c-d-ame", quietly mangling
15
+ * every non-English name.
16
+ */
17
+ function slugify(name) {
18
+ const slug = name
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
22
+ .replace(/^-+|-+$/g, '');
23
+ if (slug.length === 0) {
24
+ throw new ConfigInvalidError('Migration name must contain at least one letter or digit', {
25
+ name,
26
+ });
27
+ }
28
+ return slug;
29
+ }
30
+
31
+ /**
32
+ * Build the leading prefix for a migration filename.
33
+ * Timestamp form (default) or zero-padded sequential form (`0001`).
34
+ */
35
+ function buildPrefix(options) {
36
+ if (options.sequential) {
37
+ return String(options.index).padStart(4, '0');
38
+ }
39
+ return formatStamp(new Date());
40
+ }
41
+
42
+ /**
43
+ * Next sequence index for a directory: one past the highest numeric prefix
44
+ * already present.
45
+ *
46
+ * Deliberately not a file count — counting reuses an index after a file is
47
+ * deleted, producing two migrations with the same prefix, and the new one then
48
+ * sorts before an already-applied migration.
49
+ */
50
+ async function nextSequenceIndex(dir, extensions) {
51
+ let files;
52
+ try {
53
+ files = await fs.readdir(dir);
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT') return 1;
56
+ throw error;
57
+ }
58
+ let max = 0;
59
+ for (const file of files) {
60
+ let matchesExtension = false;
61
+ for (const ext of extensions) {
62
+ if (file.endsWith(ext)) {
63
+ matchesExtension = true;
64
+ break;
65
+ }
66
+ }
67
+ if (!matchesExtension) continue;
68
+ const prefix = /^(\d+)-/.exec(file);
69
+ if (prefix) {
70
+ const index = Number(prefix[1]);
71
+ if (index > max) max = index;
72
+ }
73
+ }
74
+ return max + 1;
75
+ }
76
+
77
+ /**
78
+ * The built-in TypeScript migration template.
79
+ *
80
+ * Like the config templates, the export form follows the project's module
81
+ * system: an ESM migration in a CommonJS project makes Node reparse the file
82
+ * and warn on every run. migronaut's loader accepts either form.
83
+ */
84
+ function defaultTemplateTs(esm = false) {
85
+ if (esm) {
86
+ return `import type { MigrationContext } from '@alexify/migronaut';
87
+
88
+ export const description = '';
89
+
90
+ export async function up({ db }: MigrationContext): Promise<void> {
91
+ // TODO: implement migration
92
+ }
93
+
94
+ export async function down({ db }: MigrationContext): Promise<void> {
95
+ // TODO: implement rollback
96
+ }
97
+ `;
98
+ }
99
+ return `import type { MigrationContext } from '@alexify/migronaut';
100
+
101
+ const description = '';
102
+
103
+ async function up({ db }: MigrationContext): Promise<void> {
104
+ // TODO: implement migration
105
+ }
106
+
107
+ async function down({ db }: MigrationContext): Promise<void> {
108
+ // TODO: implement rollback
109
+ }
110
+
111
+ module.exports = { description, up, down };
112
+ `;
113
+ }
114
+
115
+ /** The built-in JavaScript migration template */
116
+ function defaultTemplateJs(esm = false) {
117
+ if (esm) {
118
+ return `export const description = '';
119
+
120
+ /** @param {import('@alexify/migronaut').MigrationContext} ctx */
121
+ export async function up({ db }) {
122
+ // TODO: implement migration
123
+ }
124
+
125
+ /** @param {import('@alexify/migronaut').MigrationContext} ctx */
126
+ export async function down({ db }) {
127
+ // TODO: implement rollback
128
+ }
129
+ `;
130
+ }
131
+ return `const description = '';
132
+
133
+ /** @param {import('@alexify/migronaut').MigrationContext} ctx */
134
+ async function up({ db }) {
135
+ // TODO: implement migration
136
+ }
137
+
138
+ /** @param {import('@alexify/migronaut').MigrationContext} ctx */
139
+ async function down({ db }) {
140
+ // TODO: implement rollback
141
+ }
142
+
143
+ module.exports = { description, up, down };
144
+ `;
145
+ }
146
+
147
+ /** Extensions a custom `--template` file may have — anything else is refused */
148
+ const TEMPLATE_EXTENSIONS = ['.ts', '.js', '.cjs', '.mjs'];
149
+
150
+ /** Size cap for a custom template — a migration scaffold is never this big */
151
+ const MAX_TEMPLATE_BYTES = 1024 * 1024;
152
+
153
+ /** Resolve template file contents — a custom template if provided, else the built-in */
154
+ async function resolveTemplateContent(templatePath, js, esm = false) {
155
+ if (templatePath) {
156
+ const ext = path.extname(templatePath);
157
+ if (!TEMPLATE_EXTENSIONS.includes(ext)) {
158
+ throw new ConfigInvalidError(
159
+ `Template file must have one of the extensions: ${TEMPLATE_EXTENSIONS.join(', ')}`,
160
+ { templatePath },
161
+ );
162
+ }
163
+ let stats;
164
+ try {
165
+ stats = await fs.stat(templatePath);
166
+ } catch (error) {
167
+ if (error.code === 'ENOENT') {
168
+ throw new MigrationFileNotFoundError('Template file not found', { templatePath });
169
+ }
170
+ throw error;
171
+ }
172
+ if (stats.size > MAX_TEMPLATE_BYTES) {
173
+ throw new ConfigInvalidError('Template file is too large (max 1 MB)', {
174
+ templatePath,
175
+ size: stats.size,
176
+ });
177
+ }
178
+ return fs.readFile(templatePath, 'utf8');
179
+ }
180
+ return js ? defaultTemplateJs(esm) : defaultTemplateTs(esm);
181
+ }
182
+
183
+ /**
184
+ * Create a new migration file on disk and return its absolute path.
185
+ * The directory must already exist.
186
+ */
187
+ async function createMigrationFile(options) {
188
+ const ext = options.js ? '.js' : '.ts';
189
+ const extensions = options.fileExtensions ?? ['.ts', '.js'];
190
+ const index = await nextSequenceIndex(options.dir, extensions);
191
+ const prefix = buildPrefix({ sequential: options.sequential, index });
192
+ const filename = `${prefix}-${slugify(options.name)}${ext}`;
193
+ const filepath = path.join(options.dir, filename);
194
+ const content = await resolveTemplateContent(
195
+ options.templatePath,
196
+ options.js,
197
+ // Match the project's module system, so a generated migration never makes
198
+ // Node reparse it and warn.
199
+ await isEsmProject(options.dir),
200
+ );
201
+ try {
202
+ // 'wx' fails if the path exists — creating a migration must never silently
203
+ // overwrite hand-written code (two `create`s in the same second collide).
204
+ await fs.writeFile(filepath, content, { encoding: 'utf8', flag: 'wx' });
205
+ } catch (error) {
206
+ if (error.code === 'EEXIST') {
207
+ throw new MigrationFileExistsError('Migration file already exists', { path: filepath });
208
+ }
209
+ throw error;
210
+ }
211
+ return filepath;
212
+ }
213
+
214
+ /**
215
+ * Whether files in `dir` are treated as ESM, by walking up to the nearest
216
+ * package.json and reading its `"type"`.
217
+ *
218
+ * A generated config that uses `export default` in a CommonJS project makes
219
+ * Node reparse it and warn (MODULE_TYPELESS_PACKAGE_JSON) on every single
220
+ * command — so the generator matches the project instead of assuming.
221
+ * Defaults to CommonJS, which is what an absent `"type"` means.
222
+ */
223
+ async function isEsmProject(dir) {
224
+ let current = path.resolve(dir);
225
+ for (;;) {
226
+ try {
227
+ const raw = await fs.readFile(path.join(current, 'package.json'), 'utf8');
228
+ return JSON.parse(raw).type === 'module';
229
+ } catch (error) {
230
+ // Only a *missing* package.json continues the walk. A malformed one
231
+ // (trailing comma) silently adopting the grandparent's "type" — and
232
+ // emitting ESM into a CommonJS project — is the exact failure this
233
+ // function exists to prevent.
234
+ if (error.code !== 'ENOENT') {
235
+ throw new ConfigInvalidError(
236
+ `Could not parse ${path.join(current, 'package.json')} while detecting the module system`,
237
+ { path: path.join(current, 'package.json'), cause: errorText(error) },
238
+ { cause: error },
239
+ );
240
+ }
241
+ }
242
+ const parent = path.dirname(current);
243
+ if (parent === current) return false;
244
+ current = parent;
245
+ }
246
+ }
247
+
248
+ /** The export statement matching the project's module system */
249
+ const exportStatement = (esm, expression) =>
250
+ esm ? `export default ${expression};` : `module.exports = ${expression};`;
251
+
252
+ // ─── Config File Creation ───────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Mask the password in a connection URI (`user:secret@` → `user:****@`) so a
256
+ * generated config file never carries plaintext credentials. Regex-based, not
257
+ * `new URL()` — multi-host mongodb URIs (`mongodb://h1:27017,h2:27017/db`) fail
258
+ * WHATWG URL parsing. `hasCredentials` is true whenever a userinfo part exists;
259
+ * `masked` only when a non-empty password was actually replaced.
260
+ */
261
+ function maskUriCredentials(uri) {
262
+ const match = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^@/]+)@(.*)$/.exec(uri);
263
+ if (!match) {
264
+ return { uri, hasCredentials: false, masked: false };
265
+ }
266
+ const [, scheme, userinfo, rest] = match;
267
+ const colon = userinfo.indexOf(':');
268
+ if (colon === -1 || colon === userinfo.length - 1) {
269
+ return { uri, hasCredentials: true, masked: false };
270
+ }
271
+ const username = userinfo.slice(0, colon);
272
+ return { uri: `${scheme}${username}:****@${rest}`, hasCredentials: true, masked: true };
273
+ }
274
+
275
+ /** Merge caller-supplied config values over the built-in defaults */
276
+ function configFields(values) {
277
+ const { uri, masked } = maskUriCredentials(values.uri ?? 'mongodb://localhost:27017');
278
+ return {
279
+ uri,
280
+ uriMasked: masked,
281
+ dbName: values.dbName ?? 'myapp',
282
+ migrationsDir: values.migrationsDir ?? './migrations',
283
+ };
284
+ }
285
+
286
+ /**
287
+ * The commented body shared by the TS and JS templates. Documents every option
288
+ * so the file is the single place to set behavior and nothing has to be
289
+ * remembered as a CLI flag. `createExtension` is seeded to match the config
290
+ * file's own language (a `.ts` config defaults to TS migrations, `.js` to JS).
291
+ */
292
+ function configBody(values, createExtension) {
293
+ const { uri, uriMasked, dbName, migrationsDir } = configFields(values);
294
+ // User values go through JSON.stringify — never raw interpolation — so a
295
+ // quote/backslash/newline in a URI or db name cannot break out of the string
296
+ // literal and inject code into the generated (and later executed) config.
297
+ const maskNote = uriMasked
298
+ ? '\n // NOTE: the password below was masked at generation time — provide the real\n' +
299
+ ' // value via MIGRONAUT_URI or a gitignored .env instead of committing it here.'
300
+ : '';
301
+ return ` // ── Connection ──────────────────────────────────────────────
302
+ // To load these from a secret manager instead, run: migronaut init --secret-provider${maskNote}
303
+ uri: ${JSON.stringify(uri)},
304
+ dbName: ${JSON.stringify(dbName)},
305
+
306
+ // ── Migration files ─────────────────────────────────────────
307
+ migrationsDir: ${JSON.stringify(migrationsDir)},
308
+ // Extensions scanned when discovering migrations.
309
+ fileExtensions: ['.ts', '.js'],
310
+ // File type \`migronaut create\` generates by default ('ts' | 'js').
311
+ // Override for a single run with --js / --ts.
312
+ createExtension: '${createExtension}',
313
+ // Use 0001-style sequential numbering instead of timestamps.
314
+ sequential: false,
315
+ // Path to a custom template used by \`migronaut create\`.
316
+ // templatePath: './migration.template.ts',
317
+
318
+ // ── Bookkeeping collections ─────────────────────────────────
319
+ migrationsCollection: '_migronaut_migrations',
320
+ lockCollection: '_migronaut_locks',
321
+ // Seconds before a held lock is considered stale and reclaimable.
322
+ lockTTLSeconds: 60,
323
+
324
+ // ── Behavior ────────────────────────────────────────────────
325
+ // Abort (instead of warn) when a file's checksum no longer matches.
326
+ strict: false,
327
+ // Wrap every migration in a transaction. Override per file with
328
+ // \`export const useTransaction = true\`.
329
+ useTransaction: false,
330
+
331
+ // ── Lifecycle hooks (code only — not available in JSON config) ──
332
+ // hooks: {
333
+ // beforeAll: async (ctx) => {},
334
+ // afterAll: async (ctx, summary) => {}, // summary: { success, applied, direction }
335
+ // beforeEach: async (name, ctx, info) => {}, // info: { direction, index, total }
336
+ // afterEach: async (name, duration, ctx, info) => {},
337
+ // onError: async (name, error, ctx) => {},
338
+ // },`;
339
+ }
340
+
341
+ /** The built-in TypeScript config template */
342
+ function defaultConfigTs(values = {}, esm = false) {
343
+ return `import type { MigronautConfig } from '@alexify/migronaut';
344
+
345
+ /**
346
+ * migronaut configuration.
347
+ * Precedence (highest first): CLI flags > MIGRONAUT_* env vars > this file > defaults.
348
+ * Every field is optional; the values below are the built-in defaults.
349
+ */
350
+ const config: Partial<MigronautConfig> = {
351
+ ${configBody(values, 'ts')}
352
+ };
353
+
354
+ ${exportStatement(esm, 'config')}
355
+ `;
356
+ }
357
+
358
+ /** The built-in JavaScript (ESM) config template */
359
+ function defaultConfigJs(values = {}, esm = false) {
360
+ return `/**
361
+ * migronaut configuration.
362
+ * Precedence (highest first): CLI flags > MIGRONAUT_* env vars > this file > defaults.
363
+ * Every field is optional; the values below are the built-in defaults.
364
+ *
365
+ * @type {Partial<import('@alexify/migronaut').MigronautConfig>}
366
+ */
367
+ const config = {
368
+ ${configBody(values, 'js')}
369
+ };
370
+
371
+ ${exportStatement(esm, 'config')}
372
+ `;
373
+ }
374
+
375
+ /**
376
+ * The built-in JSON config template. JSON cannot hold comments or functions, so
377
+ * the `hooks`, `mongoose`, and `logger` options are unavailable here — use a
378
+ * `.ts`/`.js` config if you need them.
379
+ */
380
+ function defaultConfigJson(values = {}) {
381
+ const { uri, dbName, migrationsDir } = configFields(values);
382
+ const config = {
383
+ // Editors use this to offer completion and validate the file as you type.
384
+ $schema: 'https://migronaut.vercel.app/migronaut.schema.json',
385
+ uri,
386
+ dbName,
387
+ migrationsDir,
388
+ fileExtensions: ['.ts', '.js'],
389
+ createExtension: 'js',
390
+ sequential: false,
391
+ migrationsCollection: '_migronaut_migrations',
392
+ lockCollection: '_migronaut_locks',
393
+ lockTTLSeconds: 60,
394
+ strict: false,
395
+ useTransaction: false,
396
+ };
397
+ return `${JSON.stringify(config, null, 2)}\n`;
398
+ }
399
+
400
+ /**
401
+ * Shared documentation block for the secret-provider templates. Explains the
402
+ * factory-function form and — crucially — that the example uses AWS but ANY
403
+ * provider works by editing `loadMongoSecret()`.
404
+ */
405
+ const SECRET_PROVIDER_GUIDE = `/**
406
+ * migronaut configuration — loads the connection from a secret manager.
407
+ *
408
+ * This config exports an async FUNCTION (not a plain object), so the MongoDB
409
+ * connection is fetched at runtime on every \`migronaut\` command. The value stays in
410
+ * memory and is never written to disk, so this file is safe to commit.
411
+ *
412
+ * Precedence (highest first): CLI flags > MIGRONAUT_* env vars > this file > defaults.
413
+ *
414
+ * ── Provider-agnostic ────────────────────────────────────────────────────────
415
+ * The example below uses AWS Secrets Manager, but ANY source works — change
416
+ * only the body of loadMongoSecret() to use Google Secret Manager, HashiCorp
417
+ * Vault, Azure Key Vault, your own HTTP API, etc. It just has to return an
418
+ * object containing at least { uri, dbName }. For example, Google:
419
+ *
420
+ * import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
421
+ * const client = new SecretManagerServiceClient();
422
+ * const [version] = await client.accessSecretVersion({
423
+ * name: 'projects/PROJECT/secrets/mongo/versions/latest',
424
+ * });
425
+ * return JSON.parse(version.payload.data.toString());
426
+ */`;
427
+
428
+ /**
429
+ * The migration-tool options shared by both secret-provider templates, indented
430
+ * to sit inside the returned object of the async factory (4 spaces).
431
+ */
432
+ function secretConfigOptions(createExtension, migrationsDir) {
433
+ return ` // ── Migration files ─────────────────────────────────────
434
+ migrationsDir: ${JSON.stringify(migrationsDir)},
435
+ fileExtensions: ['.ts', '.js'],
436
+ createExtension: '${createExtension}',
437
+ sequential: false,
438
+
439
+ // ── Bookkeeping collections ─────────────────────────────
440
+ migrationsCollection: '_migronaut_migrations',
441
+ lockCollection: '_migronaut_locks',
442
+ lockTTLSeconds: 60,
443
+
444
+ // ── Behavior ────────────────────────────────────────────
445
+ strict: false,
446
+ useTransaction: false,`;
447
+ }
448
+
449
+ /** Secret-provider JavaScript (ESM) config template */
450
+ function secretConfigJs(values = {}, esm = false) {
451
+ const { migrationsDir } = configFields(values);
452
+ return `${SECRET_PROVIDER_GUIDE}
453
+
454
+ // Install the SDK for your provider, e.g.:
455
+ // npm install @aws-sdk/client-secrets-manager
456
+ import {
457
+ SecretsManagerClient,
458
+ GetSecretValueCommand,
459
+ } from '@aws-sdk/client-secrets-manager';
460
+
461
+ /**
462
+ * Fetch the connection details from your secret manager. Swap the body for
463
+ * GCP / Vault / Azure / anything — it just has to return { uri, dbName }.
464
+ */
465
+ async function loadMongoSecret() {
466
+ // Secret name/ARN. Read from an env var so it can differ per environment.
467
+ const secretId = process.env.MONGO_SECRET_ID ?? 'prod/myapp/mongo';
468
+
469
+ // Region & credentials come from the environment (AWS_REGION,
470
+ // AWS_ACCESS_KEY_ID/SECRET, or an attached IAM role).
471
+ const client = new SecretsManagerClient({});
472
+ const res = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
473
+
474
+ if (!res.SecretString) {
475
+ throw new Error(\`Secret "\${secretId}" has no SecretString\`);
476
+ }
477
+ // Stored value is JSON, e.g. { "uri": "mongodb+srv://...", "dbName": "myapp" }
478
+ return JSON.parse(res.SecretString);
479
+ }
480
+
481
+ /** @type {() => Promise<Partial<import('@alexify/migronaut').MigronautConfig>>} */
482
+ ${exportStatement(
483
+ esm,
484
+ `async () => {
485
+ const secret = await loadMongoSecret();
486
+
487
+ return {
488
+ // ── Connection (from your secret) ───────────────────────
489
+ uri: secret.uri,
490
+ dbName: secret.dbName,
491
+
492
+ ${secretConfigOptions('js', migrationsDir)}
493
+ };
494
+ }`,
495
+ )}
496
+ `;
497
+ }
498
+
499
+ /** Secret-provider TypeScript config template */
500
+ function secretConfigTs(values = {}, esm = false) {
501
+ const { migrationsDir } = configFields(values);
502
+ return `${SECRET_PROVIDER_GUIDE}
503
+ import type { MigronautConfig } from '@alexify/migronaut';
504
+ // Install the SDK for your provider, e.g.:
505
+ // npm install @aws-sdk/client-secrets-manager
506
+ import {
507
+ SecretsManagerClient,
508
+ GetSecretValueCommand,
509
+ } from '@aws-sdk/client-secrets-manager';
510
+
511
+ /**
512
+ * Fetch the connection details from your secret manager. Swap the body for
513
+ * GCP / Vault / Azure / anything — it just has to return { uri, dbName }.
514
+ */
515
+ async function loadMongoSecret(): Promise<{ uri: string; dbName: string }> {
516
+ // Secret name/ARN. Read from an env var so it can differ per environment.
517
+ const secretId = process.env.MONGO_SECRET_ID ?? 'prod/myapp/mongo';
518
+
519
+ // Region & credentials come from the environment (AWS_REGION,
520
+ // AWS_ACCESS_KEY_ID/SECRET, or an attached IAM role).
521
+ const client = new SecretsManagerClient({});
522
+ const res = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
523
+
524
+ if (!res.SecretString) {
525
+ throw new Error(\`Secret "\${secretId}" has no SecretString\`);
526
+ }
527
+ // Stored value is JSON, e.g. { "uri": "mongodb+srv://...", "dbName": "myapp" }
528
+ return JSON.parse(res.SecretString);
529
+ }
530
+
531
+ ${exportStatement(
532
+ esm,
533
+ `async (): Promise<Partial<MigronautConfig>> => {
534
+ const secret = await loadMongoSecret();
535
+
536
+ return {
537
+ // ── Connection (from your secret) ───────────────────────
538
+ uri: secret.uri,
539
+ dbName: secret.dbName,
540
+
541
+ ${secretConfigOptions('ts', migrationsDir)}
542
+ };
543
+ }`,
544
+ )}
545
+ `;
546
+ }
547
+
548
+ /**
549
+ * Return the config file contents for the requested format. When
550
+ * `secretProvider` is true a runtime secret-loading template is emitted instead
551
+ * of the static object form — only valid for `js`/`ts` (JSON cannot hold code).
552
+ */
553
+ function configTemplateContent(format, values = {}, secretProvider = false, esm = false) {
554
+ if (secretProvider) {
555
+ if (format === 'json') {
556
+ throw new ConfigInvalidError('Secret-provider configs are only available for js/ts', {
557
+ format,
558
+ });
559
+ }
560
+ return format === 'ts' ? secretConfigTs(values, esm) : secretConfigJs(values, esm);
561
+ }
562
+ if (format === 'js') {
563
+ return defaultConfigJs(values, esm);
564
+ }
565
+ if (format === 'json') {
566
+ return defaultConfigJson(values);
567
+ }
568
+ return defaultConfigTs(values, esm);
569
+ }
570
+
571
+ /**
572
+ * Create an `migronaut.config.<format>` file on disk and return its absolute path.
573
+ * Throws ConfigFileExistsError if the file exists and `force` is false.
574
+ */
575
+ async function createConfigFile(options) {
576
+ const filepath = path.join(options.dir, `migronaut.config.${options.format}`);
577
+ if (!options.force) {
578
+ const exists = await fs
579
+ .access(filepath)
580
+ .then(() => true)
581
+ .catch(() => false);
582
+ if (exists) {
583
+ throw new ConfigFileExistsError('Config file already exists', { path: filepath });
584
+ }
585
+ }
586
+ const content = configTemplateContent(
587
+ options.format,
588
+ options.values ?? {},
589
+ options.secretProvider ?? false,
590
+ // Match the project's module system, so the generated file never triggers
591
+ // Node's reparse warning.
592
+ await isEsmProject(options.dir),
593
+ );
594
+ await fs.writeFile(filepath, content, 'utf8');
595
+ return filepath;
596
+ }
597
+
598
+ module.exports = {
599
+ isEsmProject,
600
+ slugify,
601
+ buildPrefix,
602
+ nextSequenceIndex,
603
+ defaultTemplateTs,
604
+ defaultTemplateJs,
605
+ resolveTemplateContent,
606
+ createMigrationFile,
607
+ maskUriCredentials,
608
+ defaultConfigTs,
609
+ defaultConfigJs,
610
+ defaultConfigJson,
611
+ secretConfigJs,
612
+ secretConfigTs,
613
+ configTemplateContent,
614
+ createConfigFile,
615
+ };
@@ -0,0 +1,25 @@
1
+ const os = require('node:os');
2
+
3
+ /**
4
+ * Who to record as having run a migration.
5
+ *
6
+ * Precedence: MIGRONAUT_USER > os.userInfo().username > USER > USERNAME >
7
+ * 'unknown'. MIGRONAUT_USER is an override rather than another fallback,
8
+ * because in CI the OS user is a meaningless `runner`/`root` — the useful
9
+ * identity is the deploy or actor name, and only the caller knows it.
10
+ *
11
+ * `os.userInfo()` throws a SystemError when the running UID has no passwd entry
12
+ * — routine in distroless/scratch containers started with an arbitrary
13
+ * `--user`. Recording who ran a migration must never be the reason a run fails.
14
+ */
15
+ function safeUsername() {
16
+ const override = process.env.MIGRONAUT_USER;
17
+ if (override !== undefined && override !== '') return override;
18
+ try {
19
+ return os.userInfo().username;
20
+ } catch {
21
+ return process.env.USER ?? process.env.USERNAME ?? 'unknown';
22
+ }
23
+ }
24
+
25
+ module.exports = { safeUsername };