yamlock 0.3.0 → 1.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.
package/dist/cli/cli.js CHANGED
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync } from 'node:fs';
3
- import { resolve, extname } from 'node:path';
2
+ import {
3
+ lstatSync,
4
+ readFileSync
5
+ } from 'node:fs';
6
+ import { extname, resolve } from 'node:path';
4
7
  import { exit } from 'node:process';
5
8
  import { createRequire } from 'node:module';
6
9
  import { randomBytes } from 'node:crypto';
10
+ import { isDeepStrictEqual } from 'node:util';
7
11
 
8
12
  import yaml from 'js-yaml';
9
13
 
10
14
  import { processConfig } from '../utils/config.js';
15
+ import { writeFileAtomically } from '../utils/file.js';
16
+ import { migrateConfig } from '../utils/migrate.js';
17
+ import { compilePathPatterns } from '../utils/path-pattern.js';
11
18
  import { listSupportedAlgorithms, TESTED_ALGORITHMS } from '../crypto/utils.js';
12
19
 
13
20
  const require = createRequire(import.meta.url);
@@ -18,6 +25,58 @@ const BANNER = `
18
25
  ░░█░░█▀█░█░▀░█░█░░░█░█░█░░░█▀▄░
19
26
  ░░▀░░▀░▀░▀░░░▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░`;
20
27
 
28
+ const KEYGEN_MIN_LENGTH = 1;
29
+ const KEYGEN_MAX_LENGTH = 4096;
30
+ const FILE_COMMANDS = new Set(['encrypt', 'decrypt', 'migrate']);
31
+ const OPTION_SPECS = [
32
+ { key: 'key', names: ['-k', '--key'], takesValue: true },
33
+ { key: 'algorithm', names: ['-a', '--algorithm'], takesValue: true },
34
+ { key: 'output', names: ['-o', '--output'], takesValue: true },
35
+ { key: 'paths', names: ['-p', '--paths'], takesValue: true },
36
+ { key: 'pathPatterns', names: ['--path-patterns'], takesValue: true },
37
+ { key: 'dryRun', names: ['-d', '--dry-run'], takesValue: false },
38
+ { key: 'allowMixed', names: ['--allow-mixed'], takesValue: false },
39
+ { key: 'noBackup', names: ['--no-backup'], takesValue: false },
40
+ { key: 'legacy', names: ['--legacy'], takesValue: false },
41
+ { key: 'errorOnEncrypted', names: ['--error-on-encrypted'], takesValue: false },
42
+ { key: 'forceEncrypt', names: ['--force-encrypt'], takesValue: false },
43
+ { key: 'length', names: ['--length'], takesValue: true },
44
+ { key: 'format', names: ['--format'], takesValue: true }
45
+ ];
46
+ const OPTION_BY_NAME = new Map(
47
+ OPTION_SPECS.flatMap((spec) => spec.names.map((name) => [name, spec]))
48
+ );
49
+ const OPTION_LABELS = new Map(
50
+ OPTION_SPECS.map((spec) => [spec.key, spec.names.at(-1)])
51
+ );
52
+ const COMMAND_OPTIONS = new Map([
53
+ ['encrypt', new Set([
54
+ 'key',
55
+ 'algorithm',
56
+ 'output',
57
+ 'paths',
58
+ 'pathPatterns',
59
+ 'dryRun',
60
+ 'legacy',
61
+ 'errorOnEncrypted',
62
+ 'forceEncrypt'
63
+ ])],
64
+ ['decrypt', new Set(['key', 'output', 'paths', 'pathPatterns', 'dryRun'])],
65
+ ['migrate', new Set([
66
+ 'key',
67
+ 'output',
68
+ 'paths',
69
+ 'pathPatterns',
70
+ 'dryRun',
71
+ 'allowMixed',
72
+ 'noBackup'
73
+ ])],
74
+ ['keygen', new Set(['length', 'format'])],
75
+ ['help', new Set()],
76
+ ['version', new Set()],
77
+ ['algorithms', new Set()]
78
+ ]);
79
+
21
80
  function getHelpText() {
22
81
  return `${BANNER}
23
82
  Version: ${packageJson.version}
@@ -28,18 +87,36 @@ yamlock <command> [options]
28
87
  Commands:
29
88
  encrypt <file> Encrypt string values in the given YAML/JSON file.
30
89
  decrypt <file> Decrypt string values in the given YAML/JSON file.
90
+ migrate <file> Migrate selected legacy payloads to authenticated v2.
91
+ help Show this help text.
31
92
  version Print the yamlock CLI version.
32
93
  algorithms Print the list of supported cipher algorithms.
33
94
  keygen Generate a random YAMLOCK_KEY.
34
95
 
35
96
  Options:
36
97
  -k, --key <value> Encryption key (or use YAMLOCK_KEY env).
37
- -a, --algorithm <value> Cipher algorithm (default: aes-256-cbc).
98
+ -a, --algorithm <value> Legacy cipher algorithm (encrypt --legacy only).
38
99
  -o, --output <file> Write the result to a different file (otherwise overwrites the input file).
39
- -p, --paths <p1,p2> Comma-separated list of field paths to process (dot/bracket notation).
40
- -d, --dry-run Show the diff without modifying files.
41
- --length <bytes> (keygen) Number of random bytes to generate (default: 32).
100
+ -p, --paths <p1,p2> Comma-separated escaped field paths to process (dot/bracket notation).
101
+ --path-patterns <p1,p2> Structural selectors using *, [*], and ** whole-segment wildcards.
102
+ -d, --dry-run Preview the operation without modifying files.
103
+ --allow-mixed (migrate) Authenticate and preserve selected v2 values.
104
+ --no-backup (migrate) Replace the input without creating <file>.yamlock.bak.
105
+ --legacy (encrypt) Write the legacy v1 format for compatibility.
106
+ --error-on-encrypted (encrypt) Fail if a selected value is already encrypted.
107
+ --force-encrypt (encrypt) Encrypt selected yl|... strings as plaintext.
108
+ --length <bytes> (keygen) Random bytes to generate, 1-${KEYGEN_MAX_LENGTH} (default: 32).
42
109
  --format <hex|base64> (keygen) Output format (default: base64).
110
+ -h, --help Show this help text.
111
+
112
+ YAML rewrite note:
113
+ Comments, anchors/aliases, explicit tags, quoting, and formatting are not
114
+ preserved byte-for-byte. Use --dry-run or --output before replacing a file.
115
+
116
+ Path syntax:
117
+ Object-key backslashes, dots, brackets, and commas must be backslash-escaped.
118
+ Example: db\\.primary.token selects { "db.primary": { "token": ... } }.
119
+ Patterns are separate selectors; e.g. services.*.token or users[*].token.
43
120
  `;
44
121
  }
45
122
 
@@ -61,14 +138,35 @@ function detectFormat(filePath) {
61
138
  }
62
139
 
63
140
  function readConfigFile(filePath) {
141
+ const stat = lstatSync(filePath);
64
142
  const content = readFileSync(filePath, 'utf8');
65
143
  const format = detectFormat(filePath);
66
144
 
67
145
  if (format === 'yaml') {
68
- return { format, data: yaml.load(content) ?? {}, raw: content };
146
+ return { format, data: yaml.load(content) ?? {}, raw: content, stat };
147
+ }
148
+
149
+ return { format, data: JSON.parse(content), raw: content, stat };
150
+ }
151
+
152
+ function describeReadFailure(error) {
153
+ if (typeof error?.reason === 'string') {
154
+ const line = Number.isInteger(error.mark?.line) ? error.mark.line + 1 : null;
155
+ const column = Number.isInteger(error.mark?.column) ? error.mark.column + 1 : null;
156
+ const location = line && column ? ` at line ${line}, column ${column}` : '';
157
+ return `Invalid YAML: ${error.reason}${location}.`;
69
158
  }
70
159
 
71
- return { format, data: JSON.parse(content), raw: content };
160
+ if (error instanceof SyntaxError) {
161
+ return 'Invalid JSON syntax.';
162
+ }
163
+
164
+ const fileErrors = {
165
+ EACCES: 'Input file is not readable.',
166
+ EISDIR: 'Input path is a directory, not a file.',
167
+ ENOENT: 'Input file does not exist.'
168
+ };
169
+ return fileErrors[error?.code] ?? 'Unable to read or parse the input file.';
72
170
  }
73
171
 
74
172
  function serializeConfig(format, data) {
@@ -79,75 +177,168 @@ function serializeConfig(format, data) {
79
177
  return `${JSON.stringify(data, null, 2)}\n`;
80
178
  }
81
179
 
82
- function writeConfigFile(filePath, serialized) {
83
- writeFileSync(filePath, serialized, 'utf8');
84
- }
85
-
86
- function parsePaths(value) {
180
+ function splitPathList(value) {
87
181
  if (!value) {
88
182
  return [];
89
183
  }
90
184
 
91
- return String(value)
92
- .split(',')
93
- .map((segment) => segment.trim())
94
- .filter((segment) => segment.length > 0);
185
+ const paths = [];
186
+ let current = '';
187
+ let escaped = false;
188
+
189
+ for (const character of String(value)) {
190
+ if (character === ',' && !escaped) {
191
+ paths.push(current);
192
+ current = '';
193
+ continue;
194
+ }
195
+
196
+ current += character;
197
+ if (escaped) {
198
+ escaped = false;
199
+ } else if (character === '\\') {
200
+ escaped = true;
201
+ }
202
+ }
203
+ paths.push(current);
204
+
205
+ return paths.map((segment) => segment.trim());
95
206
  }
96
207
 
97
208
  function parseArgs(argv) {
98
209
  const args = argv.slice(2);
210
+ const commandAliases = new Map([
211
+ ['-h', 'help'],
212
+ ['--help', 'help']
213
+ ]);
99
214
  const result = {
100
- command: args[0],
215
+ command: commandAliases.get(args[0]) ?? args[0],
101
216
  file: undefined,
102
- options: { dryRun: false }
217
+ options: {
218
+ dryRun: false,
219
+ allowMixed: false,
220
+ noBackup: false,
221
+ legacy: false,
222
+ errorOnEncrypted: false,
223
+ forceEncrypt: false
224
+ },
225
+ specifiedOptions: new Set()
103
226
  };
104
227
 
105
- let index = 1;
106
- const potentialFile = args[1];
107
- if (potentialFile && !potentialFile.startsWith('-')) {
108
- result.file = potentialFile;
109
- index = 2;
110
- } else {
111
- index = 1;
228
+ for (let i = 1; i < args.length; i += 1) {
229
+ const arg = args[i];
230
+ const spec = OPTION_BY_NAME.get(arg);
231
+
232
+ if (spec) {
233
+ if (result.specifiedOptions.has(spec.key)) {
234
+ throw cliError('ERR_DUPLICATE_OPTION', `Option ${OPTION_LABELS.get(spec.key)} was provided more than once.`);
235
+ }
236
+ result.specifiedOptions.add(spec.key);
237
+
238
+ if (!spec.takesValue) {
239
+ result.options[spec.key] = true;
240
+ continue;
241
+ }
242
+
243
+ const next = args[i + 1];
244
+ const negativeLength = spec.key === 'length' && /^-\d/.test(next ?? '');
245
+ if (
246
+ next === undefined ||
247
+ next === '' ||
248
+ (next.startsWith('-') && !negativeLength)
249
+ ) {
250
+ throw cliError(
251
+ 'ERR_MISSING_OPTION_VALUE',
252
+ `Option ${OPTION_LABELS.get(spec.key)} requires a value.`
253
+ );
254
+ }
255
+
256
+ if (spec.key === 'paths' || spec.key === 'pathPatterns') {
257
+ const splitSelectors = splitPathList(next);
258
+ const selectors = spec.key === 'pathPatterns'
259
+ ? splitSelectors
260
+ : splitSelectors.filter((selector) => selector.length > 0);
261
+ if (
262
+ selectors.length === 0 ||
263
+ (spec.key === 'pathPatterns' && selectors.some((selector) => selector.length === 0))
264
+ ) {
265
+ throw cliError(
266
+ spec.key === 'pathPatterns'
267
+ ? 'ERR_INVALID_PATH_PATTERNS'
268
+ : 'ERR_INVALID_OPTION_VALUE',
269
+ `Option ${OPTION_LABELS.get(spec.key)} requires at least one non-empty selector.`
270
+ );
271
+ }
272
+ if (spec.key === 'pathPatterns') {
273
+ compilePathPatterns(selectors);
274
+ }
275
+ result.options[spec.key] = selectors;
276
+ } else {
277
+ result.options[spec.key] = next;
278
+ }
279
+ i += 1;
280
+ continue;
281
+ }
282
+
283
+ if (arg.startsWith('-')) {
284
+ throw cliError('ERR_UNKNOWN_OPTION', `Unknown option: ${arg}`);
285
+ }
286
+
287
+ if (result.file !== undefined) {
288
+ throw cliError('ERR_UNEXPECTED_ARGUMENT', `Unexpected argument: ${arg}`);
289
+ }
290
+ result.file = arg;
112
291
  }
113
292
 
114
- for (let i = index; i < args.length; i += 1) {
115
- const arg = args[i];
116
- const next = args[i + 1];
293
+ return result;
294
+ }
117
295
 
118
- if (arg === '-k' || arg === '--key') {
119
- result.options.key = next;
120
- i += 1;
121
- } else if (arg === '-a' || arg === '--algorithm') {
122
- result.options.algorithm = next;
123
- i += 1;
124
- } else if (arg === '-o' || arg === '--output') {
125
- result.options.output = next;
126
- i += 1;
127
- } else if (arg === '-p' || arg === '--paths') {
128
- result.options.paths = parsePaths(next);
129
- i += 1;
130
- } else if (arg === '--length') {
131
- result.options.length = next;
132
- i += 1;
133
- } else if (arg === '--format') {
134
- result.options.format = next;
135
- i += 1;
136
- } else if (arg === '-d' || arg === '--dry-run') {
137
- result.options.dryRun = true;
296
+ function validateCommandLine({ command, file, specifiedOptions }) {
297
+ const allowedOptions = COMMAND_OPTIONS.get(command);
298
+ if (!allowedOptions) {
299
+ throw cliError('ERR_UNKNOWN_COMMAND', `Unknown command: ${command}`);
300
+ }
301
+
302
+ for (const option of specifiedOptions) {
303
+ if (!allowedOptions.has(option)) {
304
+ throw cliError(
305
+ 'ERR_INVALID_OPTION',
306
+ `${command} does not accept ${OPTION_LABELS.get(option)}.`
307
+ );
138
308
  }
139
309
  }
140
310
 
141
- if (result.options.dryRun && result.command && !result.file && !['version', 'algorithms', 'keygen'].includes(result.command)) {
142
- // dry-run without file is invalid, but we'll let later validation handle file requirement
311
+ if (!FILE_COMMANDS.has(command) && file !== undefined) {
312
+ throw cliError('ERR_UNEXPECTED_ARGUMENT', `${command} does not accept a file argument.`);
143
313
  }
314
+ }
144
315
 
145
- return result;
316
+ function parseKeyLength(value) {
317
+ const rawValue = String(value);
318
+ if (!/^\d+$/.test(rawValue)) {
319
+ throw cliError(
320
+ 'ERR_INVALID_LENGTH',
321
+ `Key length must be an integer between ${KEYGEN_MIN_LENGTH} and ${KEYGEN_MAX_LENGTH} bytes.`
322
+ );
323
+ }
324
+
325
+ const length = Number(rawValue);
326
+ if (
327
+ !Number.isSafeInteger(length) ||
328
+ length < KEYGEN_MIN_LENGTH ||
329
+ length > KEYGEN_MAX_LENGTH
330
+ ) {
331
+ throw cliError(
332
+ 'ERR_INVALID_LENGTH',
333
+ `Key length must be an integer between ${KEYGEN_MIN_LENGTH} and ${KEYGEN_MAX_LENGTH} bytes.`
334
+ );
335
+ }
336
+
337
+ return length;
146
338
  }
147
339
 
148
340
  function generateRandomKey(length, format) {
149
- const size = Number.isFinite(length) && length > 0 ? Math.floor(length) : 32;
150
- const buffer = randomBytes(size);
341
+ const buffer = randomBytes(length);
151
342
  if (format === 'hex') {
152
343
  return buffer.toString('hex');
153
344
  }
@@ -159,31 +350,202 @@ function fail(code, message) {
159
350
  return exit(1);
160
351
  }
161
352
 
162
- function handleWrite({ dryRun, file, outputPath, format, originalRaw, data, operation }) {
163
- const serialized = serializeConfig(format, data);
353
+ function cliError(code, message) {
354
+ return Object.assign(new Error(message), { code });
355
+ }
356
+
357
+ function validateOperationSource(filePath, config) {
358
+ if (config.stat.isSymbolicLink() || !config.stat.isFile()) {
359
+ throw cliError('ERR_UNSAFE_INPUT', 'Input must be a regular file, not a symbolic link.');
360
+ }
361
+
362
+ let currentStat;
363
+ let currentRaw;
364
+ try {
365
+ currentStat = lstatSync(filePath);
366
+ currentRaw = readFileSync(filePath, 'utf8');
367
+ } catch {
368
+ throw cliError('ERR_INPUT_CHANGED', 'Input changed after it was read; no file was replaced.');
369
+ }
370
+ if (
371
+ !currentStat.isFile() ||
372
+ currentStat.isSymbolicLink() ||
373
+ currentStat.dev !== config.stat.dev ||
374
+ currentStat.ino !== config.stat.ino ||
375
+ currentStat.mode !== config.stat.mode ||
376
+ currentRaw !== config.raw
377
+ ) {
378
+ throw cliError('ERR_INPUT_CHANGED', 'Input changed after it was read; no file was replaced.');
379
+ }
380
+ }
381
+
382
+ function resolveOutputMode(outputPath, sourceMode) {
383
+ try {
384
+ const outputStat = lstatSync(outputPath);
385
+ if (outputStat.isSymbolicLink() || !outputStat.isFile()) {
386
+ throw cliError('ERR_UNSAFE_OUTPUT', 'Output must be a regular file, not a symbolic link.');
387
+ }
388
+ return outputStat.mode & 0o7777;
389
+ } catch (error) {
390
+ if (error.code === 'ENOENT') {
391
+ return sourceMode;
392
+ }
393
+ throw error;
394
+ }
395
+ }
396
+
397
+ function handleWrite({ dryRun, file, absolutePath, outputPath, config, data, operation }) {
398
+ const serialized = serializeConfig(config.format, data);
164
399
  if (dryRun) {
165
400
  print(`DRY-RUN (${operation}) ${file}`);
166
401
  print('--- original');
167
- print((originalRaw ?? '').trimEnd());
402
+ print((config.raw ?? '').trimEnd());
168
403
  print('+++ result');
169
404
  print(serialized.trimEnd());
170
- if (outputPath !== file) {
405
+ if (outputPath !== absolutePath) {
171
406
  print(`(would write to ${outputPath})`);
172
407
  }
173
408
  print('No files were modified.');
174
409
  return;
175
410
  }
176
411
 
177
- writeConfigFile(outputPath, serialized);
412
+ const sourceMode = config.stat.mode & 0o7777;
413
+ const inPlace = outputPath === absolutePath;
414
+ validateOperationSource(absolutePath, config);
415
+ const outputMode = inPlace
416
+ ? sourceMode
417
+ : resolveOutputMode(outputPath, sourceMode);
418
+
419
+ writeFileAtomically(outputPath, serialized, { mode: outputMode });
178
420
  print(`${operation === 'encrypt' ? 'Encrypted' : 'Decrypted'} values in ${outputPath}`);
179
421
  }
180
422
 
423
+ function validateMigrationSource(filePath, config) {
424
+ if (config.stat.isSymbolicLink()) {
425
+ throw cliError(
426
+ 'ERR_MIGRATION_UNSAFE_INPUT',
427
+ 'Migration input must not be a symbolic link.'
428
+ );
429
+ }
430
+
431
+ if (!config.stat.isFile()) {
432
+ throw cliError(
433
+ 'ERR_MIGRATION_UNSAFE_INPUT',
434
+ 'Migration input must be a regular file.'
435
+ );
436
+ }
437
+
438
+ const currentStat = lstatSync(filePath);
439
+ const currentRaw = readFileSync(filePath, 'utf8');
440
+ if (
441
+ !currentStat.isFile() ||
442
+ currentStat.isSymbolicLink() ||
443
+ currentStat.dev !== config.stat.dev ||
444
+ currentStat.ino !== config.stat.ino ||
445
+ currentStat.mode !== config.stat.mode ||
446
+ currentRaw !== config.raw
447
+ ) {
448
+ throw cliError(
449
+ 'ERR_MIGRATION_INPUT_CHANGED',
450
+ 'Migration input changed after it was read.'
451
+ );
452
+ }
453
+ }
454
+
455
+ function handleMigration({ file, absolutePath, outputPath, config, key, options }) {
456
+ validateMigrationSource(absolutePath, config);
457
+ const result = migrateConfig(config.data, {
458
+ key,
459
+ paths: options.paths,
460
+ pathPatterns: options.pathPatterns,
461
+ allowMixed: options.allowMixed
462
+ });
463
+ const serialized = serializeConfig(config.format, result.data);
464
+
465
+ if (!result.changed) {
466
+ print(`No legacy values required migration; authenticated ${result.stats.preservedV2} v2 value(s).`);
467
+ print('No files were modified.');
468
+ return;
469
+ }
470
+
471
+ if (options.dryRun) {
472
+ print(`DRY-RUN (migrate) ${file}`);
473
+ print(`Would migrate ${result.stats.migrated} legacy value(s) and preserve ${result.stats.preservedV2} v2 value(s).`);
474
+ if (outputPath === absolutePath && !options.noBackup) {
475
+ print(`Would create backup ${absolutePath}.yamlock.bak.`);
476
+ }
477
+ print(`Would write to ${outputPath}.`);
478
+ print('No files were modified.');
479
+ return;
480
+ }
481
+
482
+ validateMigrationSource(absolutePath, config);
483
+ const inPlace = outputPath === absolutePath;
484
+ let backupPath;
485
+ if (inPlace && !options.noBackup) {
486
+ backupPath = `${absolutePath}.yamlock.bak`;
487
+ try {
488
+ writeFileAtomically(backupPath, config.raw, {
489
+ mode: config.stat.mode & 0o7777,
490
+ refuseExisting: true
491
+ });
492
+ } catch (error) {
493
+ if (error.code === 'EEXIST') {
494
+ throw cliError(
495
+ 'ERR_MIGRATION_BACKUP_EXISTS',
496
+ `Backup already exists: ${backupPath}`
497
+ );
498
+ }
499
+ throw error;
500
+ }
501
+ }
502
+
503
+ try {
504
+ writeFileAtomically(outputPath, serialized, {
505
+ mode: config.stat.mode & 0o7777,
506
+ refuseExisting: !inPlace
507
+ });
508
+ } catch (error) {
509
+ if (!inPlace && error.code === 'EEXIST') {
510
+ throw cliError(
511
+ 'ERR_MIGRATION_OUTPUT_EXISTS',
512
+ `Migration output already exists: ${outputPath}`
513
+ );
514
+ }
515
+ throw error;
516
+ }
517
+ print(`Migrated ${result.stats.migrated} legacy value(s) to v2 in ${outputPath}.`);
518
+ if (result.stats.preservedV2 > 0) {
519
+ print(`Authenticated and preserved ${result.stats.preservedV2} existing v2 value(s).`);
520
+ }
521
+ if (backupPath) {
522
+ print(`Backup: ${backupPath}`);
523
+ } else if (inPlace) {
524
+ print('Backup disabled by --no-backup.');
525
+ } else {
526
+ print('Source file was preserved; no backup was created.');
527
+ }
528
+ }
529
+
181
530
  export async function runCli(argv = process.argv) {
182
- const { command, file, options } = parseArgs(argv);
531
+ let parsed;
532
+ try {
533
+ parsed = parseArgs(argv);
534
+ if (parsed.command) {
535
+ validateCommandLine(parsed);
536
+ }
537
+ } catch (error) {
538
+ const code = typeof error.code === 'string' && error.code.startsWith('ERR_')
539
+ ? error.code
540
+ : 'ERR_INVALID_ARGUMENTS';
541
+ return fail(code, error.message);
542
+ }
543
+
544
+ const { command, file, options } = parsed;
183
545
 
184
- if (!command) {
546
+ if (!command || command === 'help') {
185
547
  print(getHelpText().trim());
186
- return exit(1);
548
+ return exit(0);
187
549
  }
188
550
 
189
551
  if (command === 'version') {
@@ -197,31 +559,33 @@ export async function runCli(argv = process.argv) {
197
559
  const testedSet = new Set(tested);
198
560
  const additional = algorithms.filter((name) => !testedSet.has(name));
199
561
 
200
- print('Tested algorithms (covered by yamlock fixtures):');
562
+ print('Default v2 profile: aes-256-gcm with scrypt.');
563
+ print('\nTested legacy algorithms (covered by yamlock fixtures):');
201
564
  tested.forEach((name) => print(`- ${name}`));
202
565
 
203
566
  if (additional.length > 0) {
204
567
  print('\nAdditional algorithms available in this runtime:');
205
568
  additional.forEach((name) => print(`- ${name}`));
206
- print('\nUse at your own risk; these ciphers are not part of the official test matrix yet.');
569
+ print('\nAdditional ciphers require explicit legacy mode and are not part of the official test matrix.');
207
570
  }
208
571
  return exit(0);
209
572
  }
210
573
 
211
574
  if (command === 'keygen') {
212
- const desiredLength = options.length ? Number(options.length) : 32;
213
- const normalizedFormat = (options.format ?? 'base64').toLowerCase();
214
-
215
- if (!Number.isFinite(desiredLength) || desiredLength <= 0) {
216
- return fail('ERR_INVALID_LENGTH', 'Key length must be a positive number.');
575
+ let desiredLength;
576
+ try {
577
+ desiredLength = options.length === undefined ? 32 : parseKeyLength(options.length);
578
+ } catch (error) {
579
+ return fail(error.code ?? 'ERR_INVALID_LENGTH', error.message);
217
580
  }
581
+ const normalizedFormat = (options.format ?? 'base64').toLowerCase();
218
582
 
219
583
  if (!['base64', 'hex'].includes(normalizedFormat)) {
220
584
  return fail('ERR_INVALID_FORMAT', 'Key format must be either "base64" or "hex".');
221
585
  }
222
586
 
223
587
  const keyValue = generateRandomKey(desiredLength, normalizedFormat);
224
- print(`Generated key (${normalizedFormat}, ${Math.floor(desiredLength)} bytes of entropy):`);
588
+ print(`Generated key (${normalizedFormat}, ${desiredLength} bytes of entropy):`);
225
589
  print(keyValue);
226
590
  print('\nStore it securely, e.g.');
227
591
  print(` export YAMLOCK_KEY="${keyValue}"`);
@@ -244,7 +608,7 @@ export async function runCli(argv = process.argv) {
244
608
  try {
245
609
  config = readConfigFile(absolutePath);
246
610
  } catch (error) {
247
- return fail('ERR_READ_FAILED', `Failed to read config file: ${error.message}`);
611
+ return fail('ERR_READ_FAILED', describeReadFailure(error));
248
612
  }
249
613
 
250
614
  const outputPath = options.output
@@ -252,19 +616,55 @@ export async function runCli(argv = process.argv) {
252
616
  : absolutePath;
253
617
 
254
618
  try {
619
+ if (command === 'migrate') {
620
+ handleMigration({
621
+ file,
622
+ absolutePath,
623
+ outputPath,
624
+ config,
625
+ key,
626
+ options
627
+ });
628
+ return exit(0);
629
+ }
630
+
255
631
  if (command === 'encrypt') {
632
+ if (options.algorithm !== undefined && !options.legacy) {
633
+ throw cliError(
634
+ 'ERR_INVALID_OPTION',
635
+ 'encrypt requires --legacy when --algorithm is provided.'
636
+ );
637
+ }
638
+ if (options.errorOnEncrypted && options.forceEncrypt) {
639
+ throw cliError(
640
+ 'ERR_INVALID_OPTION',
641
+ 'encrypt cannot combine --error-on-encrypted with --force-encrypt.'
642
+ );
643
+ }
644
+
256
645
  const result = processConfig(config.data, {
257
646
  mode: 'encrypt',
258
647
  key,
259
648
  algorithm: options.algorithm,
260
- paths: options.paths
649
+ formatVersion: options.legacy ? 1 : 2,
650
+ existingPayloadPolicy: options.forceEncrypt
651
+ ? 'encrypt'
652
+ : options.errorOnEncrypted
653
+ ? 'error'
654
+ : 'preserve',
655
+ paths: options.paths,
656
+ pathPatterns: options.pathPatterns
261
657
  });
658
+ if (outputPath === absolutePath && isDeepStrictEqual(result, config.data)) {
659
+ print('No plaintext values required encryption. No files were modified.');
660
+ return exit(0);
661
+ }
262
662
  handleWrite({
263
663
  dryRun: options.dryRun,
264
664
  file,
665
+ absolutePath,
265
666
  outputPath,
266
- format: config.format,
267
- originalRaw: config.raw,
667
+ config,
268
668
  data: result,
269
669
  operation: 'encrypt'
270
670
  });
@@ -275,15 +675,15 @@ export async function runCli(argv = process.argv) {
275
675
  const result = processConfig(config.data, {
276
676
  mode: 'decrypt',
277
677
  key,
278
- algorithm: options.algorithm,
279
- paths: options.paths
678
+ paths: options.paths,
679
+ pathPatterns: options.pathPatterns
280
680
  });
281
681
  handleWrite({
282
682
  dryRun: options.dryRun,
283
683
  file,
684
+ absolutePath,
284
685
  outputPath,
285
- format: config.format,
286
- originalRaw: config.raw,
686
+ config,
287
687
  data: result,
288
688
  operation: 'decrypt'
289
689
  });
@@ -293,7 +693,17 @@ export async function runCli(argv = process.argv) {
293
693
  print(getHelpText().trim());
294
694
  return fail('ERR_UNKNOWN_COMMAND', `Unknown command: ${command}`);
295
695
  } catch (error) {
296
- return fail('ERR_PROCESS_FAILED', `Operation failed: ${error.message}`);
696
+ const structuredCode = typeof error.code === 'string' && error.code.startsWith('ERR_')
697
+ ? error.code
698
+ : null;
699
+ const isMigrationCode = structuredCode === 'ERR_INVALID_PATH_PATTERNS' ||
700
+ structuredCode?.startsWith('ERR_MIGRATION_');
701
+ const code = command === 'migrate'
702
+ ? isMigrationCode
703
+ ? structuredCode
704
+ : 'ERR_MIGRATION_FAILED'
705
+ : structuredCode ?? 'ERR_PROCESS_FAILED';
706
+ return fail(code, `Operation failed: ${error.message}`);
297
707
  }
298
708
  }
299
709