@alexify/migronaut 1.0.0 → 2.1.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 (59) hide show
  1. package/CHANGELOG.md +409 -1
  2. package/README.md +248 -24
  3. package/bin/migronaut.js +11 -3
  4. package/bullmq.d.ts +845 -0
  5. package/bullmq.js +1 -0
  6. package/index.d.ts +757 -29
  7. package/migronaut.schema.json +191 -1
  8. package/package.json +27 -6
  9. package/src/bullmq/index.js +55 -0
  10. package/src/bullmq/jobs.js +454 -0
  11. package/src/bullmq/processor.js +608 -0
  12. package/src/bullmq/producer.js +424 -0
  13. package/src/bullmq/service.js +653 -0
  14. package/src/bullmq/wait.js +124 -0
  15. package/src/cli/args.js +12 -2
  16. package/src/cli/commands/baseline.js +45 -0
  17. package/src/cli/commands/converge.js +160 -0
  18. package/src/cli/commands/down.js +2 -0
  19. package/src/cli/commands/lock.js +2 -1
  20. package/src/cli/commands/redo.js +8 -1
  21. package/src/cli/commands/unlock.js +12 -2
  22. package/src/cli/commands/up.js +14 -1
  23. package/src/cli/exit-codes.js +10 -2
  24. package/src/cli/index.js +4 -0
  25. package/src/cli/shared.js +29 -7
  26. package/src/cli/table.js +105 -0
  27. package/src/core/audit.js +17 -3
  28. package/src/core/baseline.js +80 -0
  29. package/src/core/changelog.js +140 -24
  30. package/src/core/collections.js +372 -0
  31. package/src/core/config.js +125 -27
  32. package/src/core/converge-log.js +47 -0
  33. package/src/core/converge-plan.js +483 -0
  34. package/src/core/converge.js +867 -0
  35. package/src/core/import-runner.js +34 -6
  36. package/src/core/import.js +14 -7
  37. package/src/core/index-spec.js +496 -0
  38. package/src/core/lock-wait.js +260 -0
  39. package/src/core/lock.js +71 -20
  40. package/src/core/migrator.js +805 -304
  41. package/src/core/options.js +251 -0
  42. package/src/core/run-recorder.js +157 -0
  43. package/src/core/run.js +71 -71
  44. package/src/core/runner.js +70 -20
  45. package/src/core/sequence.js +134 -0
  46. package/src/errors/index.js +71 -1
  47. package/src/index.js +16 -0
  48. package/src/utils/actor.js +48 -0
  49. package/src/utils/canonical.js +179 -0
  50. package/src/utils/collection-name.js +21 -0
  51. package/src/utils/error.js +18 -1
  52. package/src/utils/id.js +77 -0
  53. package/src/utils/loader.js +39 -21
  54. package/src/utils/logger.js +30 -12
  55. package/src/utils/migration-name.js +32 -0
  56. package/src/utils/redact.js +57 -4
  57. package/src/utils/sanitize.js +8 -3
  58. package/src/utils/telemetry.js +393 -0
  59. package/src/utils/template.js +60 -12
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Collection names we accept for the changelog/lock collections,
3
+ * `import --from/--to` and declared collections: non-empty, no `$` or NUL
4
+ * (invalid server-side), and outside the reserved `system.` namespace — so no
5
+ * config value can ever point a read or write at a system collection.
6
+ *
7
+ * Lives here rather than in config.js because the collection-definition
8
+ * validator (core/collections.js) needs it too, and config.js requires that
9
+ * validator — keeping the predicate in config.js would make the two a cycle.
10
+ */
11
+ function isCollectionName(value) {
12
+ return (
13
+ typeof value === 'string' &&
14
+ value.length > 0 &&
15
+ !value.includes('$') &&
16
+ !value.includes('\0') &&
17
+ !value.startsWith('system.')
18
+ );
19
+ }
20
+
21
+ module.exports = { isCollectionName };
@@ -1,3 +1,4 @@
1
+ const { MigronautError } = require('../errors/index.js');
1
2
  const { redactUris } = require('./redact.js');
2
3
 
3
4
  /**
@@ -8,4 +9,20 @@ const { redactUris } = require('./redact.js');
8
9
  */
9
10
  const errorText = (error) => redactUris(error instanceof Error ? error.message : String(error));
10
11
 
11
- module.exports = { errorText };
12
+ /**
13
+ * {@link errorText} joined with the wrapped cause: "Migration up failed: X"
14
+ * says WHICH migration failed, and its `context.cause` says WHY — the half
15
+ * forensics actually needs. Both halves are redacted (the cause is the raw
16
+ * thrown message). What the changelog's failure trace and a failed span's
17
+ * status message both carry.
18
+ */
19
+ function errorWithCause(error) {
20
+ const message = errorText(error);
21
+ const cause =
22
+ error instanceof MigronautError && typeof error.context?.cause === 'string'
23
+ ? errorText(error.context.cause)
24
+ : undefined;
25
+ return cause ? `${message} — ${cause}` : message;
26
+ }
27
+
28
+ module.exports = { errorText, errorWithCause };
@@ -0,0 +1,77 @@
1
+ const { randomUUID } = require('node:crypto');
2
+ const { ConfigInvalidError } = require('../errors/index.js');
3
+ const { errorText } = require('./error.js');
4
+
5
+ /**
6
+ * Longest id migronaut accepts. Also the limit a queue worker enforces on a
7
+ * job's group id — one constant, so a producer can never mint an id its own
8
+ * worker would reject.
9
+ */
10
+ const MAX_ID_LENGTH = 128;
11
+
12
+ /**
13
+ * The default id: a random (v4) UUID. This module is the only place in `src/`
14
+ * that mints one — everything else asks for an id through it, which is what
15
+ * lets the `generateId` config option replace the format everywhere at once.
16
+ */
17
+ const randomId = () => randomUUID();
18
+
19
+ /**
20
+ * Return `value` when it is usable as an id, throw otherwise. An id is stored
21
+ * as a string field (changelog `runId`, lock `owner`, a job's `groupId`) and
22
+ * gated on by truthiness in the kit, so an empty or non-string value would
23
+ * silently switch off the reentrancy guard and the owner-scoped lock release.
24
+ */
25
+ function assertId(value) {
26
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_LENGTH) {
27
+ throw new ConfigInvalidError(
28
+ `generateId must return a non-empty string of at most ${MAX_ID_LENGTH} characters`,
29
+ typeof value === 'string' ? { length: value.length } : { returned: typeof value },
30
+ );
31
+ }
32
+ return value;
33
+ }
34
+
35
+ /**
36
+ * Turn the `generateId` config option into the function the kit mints ids
37
+ * with. `undefined` keeps the default (`randomId`); anything else must be a
38
+ * function, and every id it returns is checked.
39
+ *
40
+ * The user's function is called bare — no arguments, no receiver — so a
41
+ * third-party generator passes straight through (`generateId: ulid`,
42
+ * `generateId: nanoid`): their first parameter means something of its own
43
+ * (a seed time, a size), and any argument migronaut passed would be read as it.
44
+ *
45
+ * It must be synchronous: the run id is minted in the same tick as the
46
+ * reentrancy guard that checks it, and a promise would be stored as a truthy
47
+ * non-id.
48
+ */
49
+ function createIdGenerator(generateId) {
50
+ if (generateId === undefined) return randomId;
51
+ if (typeof generateId !== 'function') {
52
+ throw new ConfigInvalidError('generateId must be a function', {
53
+ generateId: typeof generateId,
54
+ });
55
+ }
56
+ return () => {
57
+ let value;
58
+ try {
59
+ value = generateId();
60
+ } catch (error) {
61
+ throw new ConfigInvalidError(
62
+ 'generateId threw',
63
+ { cause: errorText(error) },
64
+ { cause: error },
65
+ );
66
+ }
67
+ if (typeof value?.then === 'function') {
68
+ // The promise is dropped, so its rejection must not surface as an
69
+ // unhandled one on top of the error below.
70
+ value.then(undefined, () => {});
71
+ throw new ConfigInvalidError('generateId must be synchronous — it returned a promise');
72
+ }
73
+ return assertId(value);
74
+ };
75
+ }
76
+
77
+ module.exports = { MAX_ID_LENGTH, assertId, createIdGenerator, randomId };
@@ -7,7 +7,7 @@ const { errorText } = require('./error.js');
7
7
  /** TypeScript source extensions that require a TS-capable runtime to import */
8
8
  const TS_EXTENSIONS = new Set(['.ts', '.mts', '.cts']);
9
9
 
10
- /** Distinguishes reload URLs; see the reload comment in loadMigrationFile */
10
+ /** Distinguishes reload URLs; see importUserFile */
11
11
  let reloadCounter = 0;
12
12
 
13
13
  /** Narrow an unknown value to a function */
@@ -36,9 +36,10 @@ function isUnsupportedTsSyntaxError(error) {
36
36
  }
37
37
 
38
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.
39
+ * An actionable message for a dynamic-import failure whose cause is a
40
+ * `.ts`/`.mts`/`.cts` file the current runtime refused, or null to let the
41
+ * original error speak for itself. `noun` names what the file is ("migration",
42
+ * "collection definition").
42
43
  *
43
44
  * The shipped CLI runs as plain Node, whose type stripping (always present on
44
45
  * the supported Node >= 22.18 range) handles erasable TypeScript only. Two
@@ -47,18 +48,29 @@ function isUnsupportedTsSyntaxError(error) {
47
48
  * `--no-experimental-strip-types`), and non-erasable syntax such as `enum` or
48
49
  * `namespace` (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`).
49
50
  */
50
- function tsLoadErrorOrNull(filepath, error) {
51
+ function tsLoadMessageOrNull(filepath, error, noun) {
51
52
  const ext = path.extname(filepath).toLowerCase();
52
53
  if (!TS_EXTENSIONS.has(ext)) {
53
54
  return null;
54
55
  }
55
56
  const name = path.basename(filepath);
56
- let message;
57
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 {
58
+ return `Cannot load TypeScript ${noun} "${name}" — type stripping is disabled in this Node process. Re-enable it, run migronaut under a TypeScript loader (e.g. tsx), or author the ${noun} as .js.`;
59
+ }
60
+ if (isUnsupportedTsSyntaxError(error)) {
61
+ return `Cannot load TypeScript ${noun} "${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).`;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Translate a dynamic-import failure of a migration into a clear
68
+ * MigrationInvalidExportError (see {@link tsLoadMessageOrNull}), or return
69
+ * null to let the original error propagate.
70
+ */
71
+ function tsLoadErrorOrNull(filepath, error) {
72
+ const message = tsLoadMessageOrNull(filepath, error, 'migration');
73
+ if (message === null) {
62
74
  return null;
63
75
  }
64
76
  return new MigrationInvalidExportError(
@@ -68,6 +80,21 @@ function tsLoadErrorOrNull(filepath, error) {
68
80
  );
69
81
  }
70
82
 
83
+ /**
84
+ * Import a user-authored module (a migration, a collection definition).
85
+ *
86
+ * Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
87
+ * long-lived process (a test runner, a dev server re-running migrations, a
88
+ * queue worker) would keep evaluating the version it first imported; with
89
+ * `reload` a unique query string forces a fresh evaluation. Off by default —
90
+ * it leaks a module per load. A monotonic counter, not Date.now(): two
91
+ * reloads in one millisecond must still get distinct URLs.
92
+ */
93
+ function importUserFile(filepath, options = {}) {
94
+ const url = pathToFileURL(filepath).href;
95
+ return import(options.reload ? `${url}?migronaut=${++reloadCounter}` : url);
96
+ }
97
+
71
98
  /**
72
99
  * Dynamically load a migration file and validate its exports.
73
100
  *
@@ -85,18 +112,9 @@ async function loadMigrationFile(filepath, options = {}) {
85
112
  throw new MigrationFileNotFoundError('Migration file not found', { filepath });
86
113
  }
87
114
 
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
115
  let imported;
98
116
  try {
99
- imported = await import(href);
117
+ imported = await importUserFile(filepath, { reload: options.reload });
100
118
  } catch (error) {
101
119
  const tsError = tsLoadErrorOrNull(filepath, error);
102
120
  if (tsError) {
@@ -128,4 +146,4 @@ async function loadMigrationFile(filepath, options = {}) {
128
146
  return migration;
129
147
  }
130
148
 
131
- module.exports = { tsLoadErrorOrNull, loadMigrationFile };
149
+ module.exports = { importUserFile, loadMigrationFile, tsLoadErrorOrNull, tsLoadMessageOrNull };
@@ -52,6 +52,13 @@ function createLogger(stream = process.stdout, level = 'info') {
52
52
 
53
53
  const hasMethod = (value, name) => typeof value?.[name] === 'function';
54
54
 
55
+ /** A sink is usable when it exposes any of the four level methods */
56
+ const isUsableSink = (value) =>
57
+ hasMethod(value, 'info') ||
58
+ hasMethod(value, 'debug') ||
59
+ hasMethod(value, 'warn') ||
60
+ hasMethod(value, 'error');
61
+
55
62
  /**
56
63
  * Wrap a sink method so a throwing user logger can never break a migration run.
57
64
  * `pinoStyle` swaps the argument order to `(fields, msg)`, which is what pino
@@ -75,10 +82,12 @@ const adapters = new WeakMap();
75
82
  * Resolve the effective logger from a config value: `null` → silent,
76
83
  * `undefined` → default console logger, otherwise the user's logger adapted
77
84
  * 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.
85
+ * `component` field; a missing method falls back to the nearest present one
86
+ * of similar severity (a warn/error-only logger keeps its warn/error output —
87
+ * its missing debug/info become no-ops, never a reason to silence failures),
88
+ * and every call is guarded so a throwing logger can never abort a
89
+ * half-applied run. A structurally unfit value (no level method at all)
90
+ * silences output instead of crashing.
82
91
  *
83
92
  * A logger exposing `child()` is treated as pino-style, so structured fields
84
93
  * are passed as the first argument rather than the second.
@@ -86,21 +95,30 @@ const adapters = new WeakMap();
86
95
  function resolveLogger(logger) {
87
96
  if (logger === null) return silentLogger;
88
97
  if (logger === undefined) return createLogger();
89
- if (typeof logger !== 'object' || (!hasMethod(logger, 'info') && !hasMethod(logger, 'debug'))) {
98
+ if (typeof logger !== 'object' || !isUsableSink(logger)) {
90
99
  return silentLogger;
91
100
  }
92
101
  const cached = adapters.get(logger);
93
102
  if (cached !== undefined) return cached;
94
103
  const pinoStyle = hasMethod(logger, 'child');
95
104
  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);
105
+ const sink = child && isUsableSink(child) ? child : logger;
106
+ const noop = () => {};
107
+ // Fallback preference per level: same severity first, then the neighbors a
108
+ // reader of that sink would expect. debug/info never escalate to warn/error
109
+ // (running commentary must not masquerade as problems); warn/error always
110
+ // find SOME sink so failures stay visible.
111
+ const pick = (order) => {
112
+ for (const name of order) {
113
+ if (hasMethod(sink, name)) return sink[name].bind(sink);
114
+ }
115
+ return noop;
116
+ };
99
117
  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),
118
+ debug: guard(pick(['debug', 'info']), pinoStyle),
119
+ info: guard(pick(['info', 'debug']), pinoStyle),
120
+ warn: guard(pick(['warn', 'info', 'debug', 'error']), pinoStyle),
121
+ error: guard(pick(['error', 'warn', 'info', 'debug']), pinoStyle),
104
122
  };
105
123
  adapters.set(logger, adapter);
106
124
  return adapter;
@@ -0,0 +1,32 @@
1
+ const { MigrationInvalidNameError } = require('../errors/index.js');
2
+
3
+ /**
4
+ * Whether `name` is a bare filename: a non-empty string with no path
5
+ * separator, no NUL byte, and not `.`/`..`. The single definition of the rule
6
+ * that keeps a migration name from escaping the migrations directory — shared
7
+ * by the kit's path resolution and by anything that accepts a name from
8
+ * outside the process (a queue job's payload).
9
+ */
10
+ function isBareFilename(name) {
11
+ return (
12
+ typeof name === 'string' &&
13
+ name.length > 0 &&
14
+ name !== '.' &&
15
+ name !== '..' &&
16
+ !name.includes('/') &&
17
+ !name.includes('\\') &&
18
+ !name.includes('\0')
19
+ );
20
+ }
21
+
22
+ /** Throw MigrationInvalidNameError unless `name` is a bare filename */
23
+ function assertMigrationName(name, context = {}) {
24
+ if (!isBareFilename(name)) {
25
+ throw new MigrationInvalidNameError(
26
+ 'Invalid migration name — must be a bare filename with no path segments',
27
+ { name, ...context },
28
+ );
29
+ }
30
+ }
31
+
32
+ module.exports = { assertMigrationName, isBareFilename };
@@ -8,12 +8,65 @@
8
8
  * and the URI may sit anywhere inside a larger message (unlike
9
9
  * `maskUriCredentials` in template.js, which is anchored to a whole-string URI).
10
10
  */
11
- const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^:@/\s]+):([^@/\s]+)@/g;
11
+ const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^:@/\s]*):([^@/\s]+)@/g;
12
12
 
13
- /** Mask `scheme://user:secret@` as `scheme://user:****@` anywhere in `text` */
13
+ /**
14
+ * Query parameters whose value is a secret. The userinfo form is not the only
15
+ * place a MongoDB URI carries credentials: TLS key passphrases and proxy
16
+ * passwords travel as plain query parameters and would otherwise survive
17
+ * redaction into logs, error context and `--json` output.
18
+ */
19
+ const URI_QUERY_SECRETS =
20
+ /([?&](?:tlsCertificateKeyFilePassword|proxyPassword|sslKeyPassword)=)[^&\s]+/gi;
21
+
22
+ /**
23
+ * `authMechanismProperties` is a comma-separated `KEY:VALUE` list; only the
24
+ * values of secret-bearing keys (AWS_SESSION_TOKEN et al.) are masked, so
25
+ * non-secret properties (SERVICE_NAME, …) stay readable.
26
+ */
27
+ const AUTH_MECHANISM_PROPS = /([?&]authMechanismProperties=)([^&\s]+)/gi;
28
+ const SENSITIVE_PROP_KEY = /TOKEN|SECRET|PASSWORD/i;
29
+
30
+ /**
31
+ * Mask credentials anywhere in `text`: `scheme://user:secret@` (an empty
32
+ * username still hides the password), secret-bearing query parameters, and
33
+ * secret values inside `authMechanismProperties`.
34
+ */
14
35
  function redactUris(text) {
15
36
  if (typeof text !== 'string') return text;
16
- return text.replace(URI_CREDENTIALS, '$1$2:****@');
37
+ return text
38
+ .replace(URI_CREDENTIALS, '$1$2:****@')
39
+ .replace(URI_QUERY_SECRETS, '$1****')
40
+ .replace(AUTH_MECHANISM_PROPS, (_match, prefix, value) => {
41
+ const pairs = value.split(',');
42
+ for (let i = 0; i < pairs.length; i++) {
43
+ const colon = pairs[i].indexOf(':');
44
+ if (colon === -1) continue;
45
+ const key = pairs[i].slice(0, colon);
46
+ if (SENSITIVE_PROP_KEY.test(key)) pairs[i] = `${key}:****`;
47
+ }
48
+ return `${prefix}${pairs.join(',')}`;
49
+ });
50
+ }
51
+
52
+ /**
53
+ * The document values a server error can quote: an E11000 duplicate-key
54
+ * message ends with the offending key's values — an email, a phone number —
55
+ * which is the database's data, not an error's. Everything from `dup key: {`
56
+ * to the last `}` on that line is masked; the index name before it still says
57
+ * which constraint was violated.
58
+ */
59
+ const DUPLICATE_KEY_VALUES = /(dup key: )\{[^\n]*\}/g;
60
+
61
+ /**
62
+ * For text that leaves the process for a third party — a tracing backend, a
63
+ * queue that keeps failed jobs and serves them to dashboards: credentials
64
+ * masked (as everywhere) and the data values a server error quotes, too.
65
+ * Local log lines keep the values; they are what a developer debugs with.
66
+ */
67
+ function redactOutbound(text) {
68
+ if (typeof text !== 'string') return text;
69
+ return redactUris(text).replace(DUPLICATE_KEY_VALUES, '$1{ <redacted> }');
17
70
  }
18
71
 
19
72
  /**
@@ -36,4 +89,4 @@ function redactDeep(value) {
36
89
  return value;
37
90
  }
38
91
 
39
- module.exports = { redactUris, redactDeep };
92
+ module.exports = { redactDeep, redactOutbound, redactUris };
@@ -8,9 +8,14 @@
8
8
  * clear the screen, or restyle everything printed after them.
9
9
  */
10
10
 
11
- /** Control characters stripped from untrusted values (tab and newline survive) */
11
+ /**
12
+ * Control characters stripped from untrusted values (tab and newline survive).
13
+ * The full C1 block (U+0080-U+009F) is included: CSI, OSC, DCS, PM and APC are
14
+ * single-codepoint escape introducers on terminals that decode C1, and none of
15
+ * them is ever legitimate text.
16
+ */
12
17
  // oxlint-disable-next-line no-control-regex -- stripping control characters is the point
13
- const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
18
+ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u0080-\u009f]/g;
14
19
 
15
20
  /**
16
21
  * SGR color sequences (`ESC[…m`) to preserve, or a control character to drop.
@@ -19,7 +24,7 @@ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
19
24
  * titles — has no legitimate reason to be in a log line.
20
25
  */
21
26
  // 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;
27
+ const SGR_OR_CONTROL = /(\u001b\[[0-9;]*m)|[\u0000-\u0008\u000b-\u001f\u007f\u0080-\u009f]/g;
23
28
 
24
29
  /**
25
30
  * Strip every terminal control character from an untrusted value. For data