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