@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.
- package/CHANGELOG.md +128 -0
- package/LICENSE +22 -0
- package/README.md +773 -0
- package/bin/migronaut.js +36 -0
- package/index.d.ts +903 -0
- package/index.js +1 -0
- package/migronaut.schema.json +135 -0
- package/package.json +105 -0
- package/src/cli/args.js +314 -0
- package/src/cli/commands/audit.js +40 -0
- package/src/cli/commands/create.js +29 -0
- package/src/cli/commands/down.js +30 -0
- package/src/cli/commands/dry-run.js +36 -0
- package/src/cli/commands/import.js +33 -0
- package/src/cli/commands/init.js +64 -0
- package/src/cli/commands/list.js +19 -0
- package/src/cli/commands/lock.js +35 -0
- package/src/cli/commands/redo.js +16 -0
- package/src/cli/commands/status.js +52 -0
- package/src/cli/commands/unlock.js +43 -0
- package/src/cli/commands/up.js +53 -0
- package/src/cli/exit-codes.js +38 -0
- package/src/cli/index.js +91 -0
- package/src/cli/shared.js +343 -0
- package/src/cli/spinner.js +59 -0
- package/src/cli/table.js +259 -0
- package/src/core/audit.js +152 -0
- package/src/core/changelog.js +231 -0
- package/src/core/config.js +479 -0
- package/src/core/context.js +16 -0
- package/src/core/import-runner.js +164 -0
- package/src/core/import.js +60 -0
- package/src/core/lock.js +348 -0
- package/src/core/migrator.js +1475 -0
- package/src/core/run.js +144 -0
- package/src/core/runner.js +150 -0
- package/src/errors/index.js +215 -0
- package/src/index.js +59 -0
- package/src/utils/checksum.js +23 -0
- package/src/utils/colors.js +68 -0
- package/src/utils/concurrency.js +32 -0
- package/src/utils/date.js +28 -0
- package/src/utils/env.js +51 -0
- package/src/utils/error.js +11 -0
- package/src/utils/loader.js +131 -0
- package/src/utils/logger.js +109 -0
- package/src/utils/redact.js +39 -0
- package/src/utils/sanitize.js +43 -0
- package/src/utils/template.js +615 -0
- package/src/utils/user.js +25 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Zero-pad a number to two digits */
|
|
2
|
+
function pad(n) {
|
|
3
|
+
return String(n).padStart(2, '0');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Local-time compact timestamp, e.g. `20240526143021`.
|
|
8
|
+
* Used as the leading prefix for timestamped migration filenames.
|
|
9
|
+
*/
|
|
10
|
+
function formatStamp(date) {
|
|
11
|
+
return (
|
|
12
|
+
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
|
|
13
|
+
`${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Human-readable local-time timestamp, e.g. `2024-05-26 14:30:21`.
|
|
19
|
+
* Used in the status/list/import tables.
|
|
20
|
+
*/
|
|
21
|
+
function formatDateTime(date) {
|
|
22
|
+
return (
|
|
23
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
24
|
+
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { formatStamp, formatDateTime };
|
package/src/utils/env.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const util = require('node:util');
|
|
3
|
+
const { ConfigInvalidError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/** Larger than any sane .env — the same ceiling custom templates get */
|
|
6
|
+
const MAX_ENV_FILE_BYTES = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keys never copied into the target: with a plain-object `env`, assigning
|
|
10
|
+
* `__proto__` writes through to the prototype. `process.env` coerces these
|
|
11
|
+
* harmlessly today, but that is an implementation detail of process.env —
|
|
12
|
+
* the guard makes the intent explicit instead of leaning on it.
|
|
13
|
+
*/
|
|
14
|
+
const UNSAFE_ENV_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load `filePath` (.env format) into `env`, never overriding keys that are
|
|
18
|
+
* already set (dotenv's `override: false` semantics). A missing file is a
|
|
19
|
+
* silent no-op. Parsing is `util.parseEnv` — always present on the supported
|
|
20
|
+
* Node range (added in 20.12; engines require ≥22.18).
|
|
21
|
+
*/
|
|
22
|
+
async function applyEnvFile(filePath, env = process.env) {
|
|
23
|
+
// stat before read: `--env-file /dev/zero` (or a runaway file) must fail
|
|
24
|
+
// with a typed error, not hang the process reading forever.
|
|
25
|
+
let stats;
|
|
26
|
+
try {
|
|
27
|
+
stats = await fs.stat(filePath);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === 'ENOENT') return;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
// A device file (/dev/zero reports size 0), FIFO or directory would hang or
|
|
33
|
+
// garble the read — only regular files are .env candidates.
|
|
34
|
+
if (!stats.isFile()) {
|
|
35
|
+
throw new ConfigInvalidError('env file is not a regular file', { path: filePath });
|
|
36
|
+
}
|
|
37
|
+
if (stats.size > MAX_ENV_FILE_BYTES) {
|
|
38
|
+
throw new ConfigInvalidError('env file is too large (max 1 MB)', {
|
|
39
|
+
path: filePath,
|
|
40
|
+
size: stats.size,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
44
|
+
const parsed = util.parseEnv(content);
|
|
45
|
+
for (const key of Object.keys(parsed)) {
|
|
46
|
+
if (UNSAFE_ENV_KEYS.has(key)) continue;
|
|
47
|
+
if (env[key] === undefined) env[key] = parsed[key];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { applyEnvFile };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { redactUris } = require('./redact.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Human-readable message from any thrown value, with URI credentials masked.
|
|
5
|
+
* The single chokepoint for turning caught errors into strings — using it
|
|
6
|
+
* everywhere is what keeps driver messages that echo the connection URI from
|
|
7
|
+
* leaking passwords into logs, error context, or `--json` output.
|
|
8
|
+
*/
|
|
9
|
+
const errorText = (error) => redactUris(error instanceof Error ? error.message : String(error));
|
|
10
|
+
|
|
11
|
+
module.exports = { errorText };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { pathToFileURL } = require('node:url');
|
|
4
|
+
const { MigrationFileNotFoundError, MigrationInvalidExportError } = require('../errors/index.js');
|
|
5
|
+
const { errorText } = require('./error.js');
|
|
6
|
+
|
|
7
|
+
/** TypeScript source extensions that require a TS-capable runtime to import */
|
|
8
|
+
const TS_EXTENSIONS = new Set(['.ts', '.mts', '.cts']);
|
|
9
|
+
|
|
10
|
+
/** Distinguishes reload URLs; see the reload comment in loadMigrationFile */
|
|
11
|
+
let reloadCounter = 0;
|
|
12
|
+
|
|
13
|
+
/** Narrow an unknown value to a function */
|
|
14
|
+
function isFunction(value) {
|
|
15
|
+
return typeof value === 'function';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** True when an import failed because Node cannot load the file's extension */
|
|
19
|
+
function isUnknownExtensionError(error) {
|
|
20
|
+
if (typeof error !== 'object' || error === null) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const { code, message } = error;
|
|
24
|
+
return (
|
|
25
|
+
code === 'ERR_UNKNOWN_FILE_EXTENSION' ||
|
|
26
|
+
(typeof message === 'string' && message.includes('Unknown file extension'))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when type stripping refused non-erasable syntax (enum, namespace, …) */
|
|
31
|
+
function isUnsupportedTsSyntaxError(error) {
|
|
32
|
+
if (typeof error !== 'object' || error === null) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return error.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Translate a dynamic-import failure into a clear MigrationInvalidExportError
|
|
40
|
+
* when the cause is a `.ts`/`.mts`/`.cts` file the current runtime refused, or
|
|
41
|
+
* return null to let the original error propagate.
|
|
42
|
+
*
|
|
43
|
+
* The shipped CLI runs as plain Node, whose type stripping (always present on
|
|
44
|
+
* the supported Node >= 22.18 range) handles erasable TypeScript only. Two
|
|
45
|
+
* failure shapes get an actionable message instead of the raw Node error:
|
|
46
|
+
* stripping disabled entirely (`ERR_UNKNOWN_FILE_EXTENSION`, e.g. under
|
|
47
|
+
* `--no-experimental-strip-types`), and non-erasable syntax such as `enum` or
|
|
48
|
+
* `namespace` (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`).
|
|
49
|
+
*/
|
|
50
|
+
function tsLoadErrorOrNull(filepath, error) {
|
|
51
|
+
const ext = path.extname(filepath).toLowerCase();
|
|
52
|
+
if (!TS_EXTENSIONS.has(ext)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const name = path.basename(filepath);
|
|
56
|
+
let message;
|
|
57
|
+
if (isUnknownExtensionError(error)) {
|
|
58
|
+
message = `Cannot load TypeScript migration "${name}" — type stripping is disabled in this Node process. Re-enable it, run migronaut under a TypeScript loader (e.g. tsx), or author the migration as .js.`;
|
|
59
|
+
} else if (isUnsupportedTsSyntaxError(error)) {
|
|
60
|
+
message = `Cannot load TypeScript migration "${name}" — it uses syntax Node's type stripping cannot erase (e.g. enum, namespace). Rewrite with erasable-only syntax, or run migronaut under a TypeScript loader (e.g. tsx).`;
|
|
61
|
+
} else {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return new MigrationInvalidExportError(
|
|
65
|
+
message,
|
|
66
|
+
{ filepath, cause: errorText(error) },
|
|
67
|
+
{ cause: error },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Dynamically load a migration file and validate its exports.
|
|
73
|
+
*
|
|
74
|
+
* Handles all three supported formats:
|
|
75
|
+
* - TypeScript / JavaScript ESM named exports (`export async function up/down`)
|
|
76
|
+
* - CommonJS default export (`module.exports = { up, down }`)
|
|
77
|
+
*
|
|
78
|
+
* @throws {MigrationFileNotFoundError} when the file does not exist
|
|
79
|
+
* @throws {MigrationInvalidExportError} when up/down are not both functions
|
|
80
|
+
*/
|
|
81
|
+
async function loadMigrationFile(filepath, options = {}) {
|
|
82
|
+
try {
|
|
83
|
+
await fs.access(filepath);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new MigrationFileNotFoundError('Migration file not found', { filepath });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
|
|
89
|
+
// long-lived process (a test runner, a dev server re-running migrations)
|
|
90
|
+
// would keep executing the version it first imported; a unique query string
|
|
91
|
+
// forces a fresh evaluation. Off by default — it leaks a module per load.
|
|
92
|
+
// A monotonic counter, not Date.now(): two reloads in one millisecond must
|
|
93
|
+
// still get distinct URLs.
|
|
94
|
+
const url = pathToFileURL(filepath).href;
|
|
95
|
+
const href = options.reload ? `${url}?migronaut=${++reloadCounter}` : url;
|
|
96
|
+
|
|
97
|
+
let imported;
|
|
98
|
+
try {
|
|
99
|
+
imported = await import(href);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const tsError = tsLoadErrorOrNull(filepath, error);
|
|
102
|
+
if (tsError) {
|
|
103
|
+
throw tsError;
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
// `mod.default ?? mod` handles the CommonJS default-export case
|
|
108
|
+
const resolved = imported.default ?? imported;
|
|
109
|
+
|
|
110
|
+
if (!isFunction(resolved.up) || !isFunction(resolved.down)) {
|
|
111
|
+
throw new MigrationInvalidExportError('Migration must export async up() and down() functions', {
|
|
112
|
+
filepath,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const migration = { up: resolved.up, down: resolved.down };
|
|
117
|
+
|
|
118
|
+
if (typeof resolved.useTransaction === 'boolean') {
|
|
119
|
+
migration.useTransaction = resolved.useTransaction;
|
|
120
|
+
}
|
|
121
|
+
if (Number.isInteger(resolved.timeoutMs) && resolved.timeoutMs > 0) {
|
|
122
|
+
migration.timeoutMs = resolved.timeoutMs;
|
|
123
|
+
}
|
|
124
|
+
if (typeof resolved.description === 'string') {
|
|
125
|
+
migration.description = resolved.description;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return migration;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { tsLoadErrorOrNull, loadMigrationFile };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const { createColors } = require('./colors.js');
|
|
2
|
+
const { sanitizeTerminal } = require('./sanitize.js');
|
|
3
|
+
|
|
4
|
+
/** Severity order, used to drop messages below the configured level */
|
|
5
|
+
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 100 };
|
|
6
|
+
|
|
7
|
+
/** A logger whose methods are all no-ops — used to silence all output */
|
|
8
|
+
const silentLogger = {
|
|
9
|
+
debug: () => {},
|
|
10
|
+
info: () => {},
|
|
11
|
+
warn: () => {},
|
|
12
|
+
error: () => {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create the default console logger (pino-compatible method surface).
|
|
17
|
+
*
|
|
18
|
+
* `debug`/`info` write to `stream` (stdout by default); `warn`/`error`
|
|
19
|
+
* always write to stderr. Pass `process.stderr` as `stream` to keep stdout
|
|
20
|
+
* clean for machine-readable output (e.g. `--json` mode routes all human
|
|
21
|
+
* lines here). Colors: `debug` is dimmed, `warn` yellow, `error` red.
|
|
22
|
+
*
|
|
23
|
+
* `level` drops anything less severe than itself — `'debug'` shows everything,
|
|
24
|
+
* `'error'` only failures. The structured `fields` second argument that core
|
|
25
|
+
* passes is ignored here: the human line already carries that information.
|
|
26
|
+
*/
|
|
27
|
+
function createLogger(stream = process.stdout, level = 'info') {
|
|
28
|
+
const outColors = createColors(stream);
|
|
29
|
+
const errColors = createColors(process.stderr);
|
|
30
|
+
const threshold = LEVELS[level] ?? LEVELS.info;
|
|
31
|
+
const writeOut = (msg) => {
|
|
32
|
+
stream.write(`${msg}\n`);
|
|
33
|
+
};
|
|
34
|
+
const writeErr = (msg) => {
|
|
35
|
+
process.stderr.write(`${msg}\n`);
|
|
36
|
+
};
|
|
37
|
+
const at = (name, write) => (LEVELS[name] >= threshold ? write : () => {});
|
|
38
|
+
// Sanitized by construction: messages routinely embed migration names and
|
|
39
|
+
// lock-holder fields — values anyone with DB write access can influence —
|
|
40
|
+
// and no individual call site should have to remember terminal-escape
|
|
41
|
+
// hygiene. SGR color codes (migronaut's own table/level styling) survive;
|
|
42
|
+
// cursor movement and screen clearing do not. User-injected loggers are
|
|
43
|
+
// exempt (their sink is not necessarily a terminal) — this is the default
|
|
44
|
+
// terminal logger only.
|
|
45
|
+
return {
|
|
46
|
+
debug: at('debug', (msg) => writeOut(outColors.dim(sanitizeTerminal(msg)))),
|
|
47
|
+
info: at('info', (msg) => writeOut(sanitizeTerminal(msg))),
|
|
48
|
+
warn: at('warn', (msg) => writeErr(errColors.yellow(sanitizeTerminal(msg)))),
|
|
49
|
+
error: at('error', (msg) => writeErr(errColors.red(sanitizeTerminal(msg)))),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hasMethod = (value, name) => typeof value?.[name] === 'function';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Wrap a sink method so a throwing user logger can never break a migration run.
|
|
57
|
+
* `pinoStyle` swaps the argument order to `(fields, msg)`, which is what pino
|
|
58
|
+
* and its ecosystem expect for structured logging; everyone else gets
|
|
59
|
+
* `(msg, fields)` so a plain `(msg) => …` logger keeps working unchanged.
|
|
60
|
+
*/
|
|
61
|
+
const guard = (fn, pinoStyle) => (msg, fields) => {
|
|
62
|
+
try {
|
|
63
|
+
if (fields === undefined) fn(msg);
|
|
64
|
+
else if (pinoStyle) fn(fields, msg);
|
|
65
|
+
else fn(msg, fields);
|
|
66
|
+
} catch {
|
|
67
|
+
// Logging must never break a migration run.
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Adapters are cached per user logger so pino's child() binds only once */
|
|
72
|
+
const adapters = new WeakMap();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the effective logger from a config value: `null` → silent,
|
|
76
|
+
* `undefined` → default console logger, otherwise the user's logger adapted
|
|
77
|
+
* to the four-method surface. A pino-style `child` is bound once with a
|
|
78
|
+
* `component` field; a missing `debug`/`warn`/`error` falls back to `info`
|
|
79
|
+
* (or `debug` when only that exists), and every call is guarded so a
|
|
80
|
+
* throwing logger can never abort a half-applied run. A structurally unfit
|
|
81
|
+
* value (no `info`/`debug` function) silences output instead of crashing.
|
|
82
|
+
*
|
|
83
|
+
* A logger exposing `child()` is treated as pino-style, so structured fields
|
|
84
|
+
* are passed as the first argument rather than the second.
|
|
85
|
+
*/
|
|
86
|
+
function resolveLogger(logger) {
|
|
87
|
+
if (logger === null) return silentLogger;
|
|
88
|
+
if (logger === undefined) return createLogger();
|
|
89
|
+
if (typeof logger !== 'object' || (!hasMethod(logger, 'info') && !hasMethod(logger, 'debug'))) {
|
|
90
|
+
return silentLogger;
|
|
91
|
+
}
|
|
92
|
+
const cached = adapters.get(logger);
|
|
93
|
+
if (cached !== undefined) return cached;
|
|
94
|
+
const pinoStyle = hasMethod(logger, 'child');
|
|
95
|
+
const child = pinoStyle ? logger.child({ component: 'migronaut' }) : null;
|
|
96
|
+
const sink = child && (hasMethod(child, 'info') || hasMethod(child, 'debug')) ? child : logger;
|
|
97
|
+
const base = hasMethod(sink, 'info') ? sink.info.bind(sink) : sink.debug.bind(sink);
|
|
98
|
+
const pick = (name) => (hasMethod(sink, name) ? sink[name].bind(sink) : base);
|
|
99
|
+
const adapter = {
|
|
100
|
+
debug: guard(pick('debug'), pinoStyle),
|
|
101
|
+
info: guard(pick('info'), pinoStyle),
|
|
102
|
+
warn: guard(pick('warn'), pinoStyle),
|
|
103
|
+
error: guard(pick('error'), pinoStyle),
|
|
104
|
+
};
|
|
105
|
+
adapters.set(logger, adapter);
|
|
106
|
+
return adapter;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { silentLogger, createLogger, resolveLogger };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential redaction for anything that leaves the process — error messages,
|
|
3
|
+
* stacks, `--json` payloads, log lines. The MongoDB driver echoes the raw
|
|
4
|
+
* connection URI in several parse errors, so every captured message must pass
|
|
5
|
+
* through here before it can be printed or serialized.
|
|
6
|
+
*
|
|
7
|
+
* Regex-based, not `new URL()`: multi-host mongodb URIs fail WHATWG parsing,
|
|
8
|
+
* and the URI may sit anywhere inside a larger message (unlike
|
|
9
|
+
* `maskUriCredentials` in template.js, which is anchored to a whole-string URI).
|
|
10
|
+
*/
|
|
11
|
+
const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^:@/\s]+):([^@/\s]+)@/g;
|
|
12
|
+
|
|
13
|
+
/** Mask `scheme://user:secret@` as `scheme://user:****@` anywhere in `text` */
|
|
14
|
+
function redactUris(text) {
|
|
15
|
+
if (typeof text !== 'string') return text;
|
|
16
|
+
return text.replace(URI_CREDENTIALS, '$1$2:****@');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Redact every string reachable from `value` (plain objects and arrays only —
|
|
21
|
+
* class instances are left alone rather than cloned into broken shapes).
|
|
22
|
+
* Returns a copy; never mutates the input.
|
|
23
|
+
*/
|
|
24
|
+
function redactDeep(value) {
|
|
25
|
+
if (typeof value === 'string') return redactUris(value);
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
const copy = new Array(value.length);
|
|
28
|
+
for (let index = 0; index < value.length; index++) copy[index] = redactDeep(value[index]);
|
|
29
|
+
return copy;
|
|
30
|
+
}
|
|
31
|
+
if (value !== null && typeof value === 'object' && value.constructor === Object) {
|
|
32
|
+
const copy = {};
|
|
33
|
+
for (const key of Object.keys(value)) copy[key] = redactDeep(value[key]);
|
|
34
|
+
return copy;
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { redactUris, redactDeep };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal control-character sanitization for untrusted text.
|
|
3
|
+
*
|
|
4
|
+
* Cell values, lock-holder fields and migration filenames come from the
|
|
5
|
+
* changelog, the lock collection and the filesystem — sources anyone with
|
|
6
|
+
* write access to the database (or a malicious PR) can influence — so ESC and
|
|
7
|
+
* friends must never reach the terminal, where they could move the cursor,
|
|
8
|
+
* clear the screen, or restyle everything printed after them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Control characters stripped from untrusted values (tab and newline survive) */
|
|
12
|
+
// oxlint-disable-next-line no-control-regex -- stripping control characters is the point
|
|
13
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SGR color sequences (`ESC[…m`) to preserve, or a control character to drop.
|
|
17
|
+
* Colors are the one escape family migronaut itself emits (tables, log
|
|
18
|
+
* levels); every other sequence — cursor movement, screen clearing, OSC
|
|
19
|
+
* titles — has no legitimate reason to be in a log line.
|
|
20
|
+
*/
|
|
21
|
+
// oxlint-disable-next-line no-control-regex -- stripping control characters is the point
|
|
22
|
+
const SGR_OR_CONTROL = /(\u001b\[[0-9;]*m)|[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Strip every terminal control character from an untrusted value. For data
|
|
26
|
+
* that should carry no styling at all (table cells, spinner text) — apply
|
|
27
|
+
* before adding migronaut's own colors, so those survive.
|
|
28
|
+
*/
|
|
29
|
+
function sanitize(text) {
|
|
30
|
+
return String(text).replace(CONTROL_CHARS, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Strip control characters while preserving SGR color sequences. For the
|
|
35
|
+
* terminal logger's write path, where trusted colored output (a rendered
|
|
36
|
+
* table) and untrusted substrings (a migration name inside a message) arrive
|
|
37
|
+
* in the same string.
|
|
38
|
+
*/
|
|
39
|
+
function sanitizeTerminal(text) {
|
|
40
|
+
return String(text).replace(SGR_OR_CONTROL, (_match, sgr) => sgr ?? '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { sanitize, sanitizeTerminal };
|