dotcrypt 0.0.0-stage → 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/bin/cli.js ADDED
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+ import { Command, InvalidArgumentError, Option } from 'commander';
6
+ import chalk from 'chalk';
7
+ import prompts from 'prompts';
8
+
9
+ import {
10
+ CONFIG_FILENAME,
11
+ DEFAULT_ENVIRONMENTS,
12
+ ENV_NAME_PATTERN,
13
+ listEnvironments,
14
+ loadConfig,
15
+ resolveEnvironment,
16
+ } from '../src/config.js';
17
+ import { ConfirmationRequiredError, VaultError, fileExists, hasDifferences, sanitizeForTerminal } from '../src/utils.js';
18
+ import {
19
+ decryptEnvironment,
20
+ diffEnvironment,
21
+ encryptEnvironment,
22
+ getVaultStatus,
23
+ initVault,
24
+ inspectVault,
25
+ loadEnvelope,
26
+ newPasswordProblem,
27
+ rotateEnvironmentKey,
28
+ verifyPassword,
29
+ } from '../src/vault.js';
30
+ import { isUiBuilt, openBrowser, startUiServer } from '../src/server.js';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const pkg = require('../package.json');
34
+
35
+ const PASSWORD_ENV = 'DOTCRYPT_PASSWORD';
36
+ const NEW_PASSWORD_ENV = 'DOTCRYPT_NEW_PASSWORD';
37
+
38
+ const log = {
39
+ info: (msg) => console.log(msg),
40
+ success: (msg) => console.log(`${chalk.green('✔')} ${msg}`),
41
+ warn: (msg) => console.error(`${chalk.yellow('⚠')} ${msg}`),
42
+ error: (msg) => console.error(`${chalk.red('✖')} ${msg}`),
43
+ dim: (msg) => console.log(chalk.dim(msg)),
44
+ };
45
+
46
+ const isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
47
+
48
+ function ask(questions) {
49
+ return prompts(questions, {
50
+ onCancel: () => {
51
+ throw new VaultError('Aborted by user. No files were changed.', { exitCode: 130 });
52
+ },
53
+ });
54
+ }
55
+
56
+ /** Resolves on approval; throws when declined or when approval is impossible (non-TTY). */
57
+ async function confirmOrAbort(message, { force = false } = {}) {
58
+ if (force) return;
59
+ if (!isInteractive()) {
60
+ throw new VaultError(`${message} Confirmation required, but not running interactively.`, {
61
+ hint: 'Re-run with --force to proceed without a prompt.',
62
+ });
63
+ }
64
+ const { ok } = await ask({ type: 'confirm', name: 'ok', message, initial: false });
65
+ if (!ok) throw new VaultError('Aborted. No files were changed.');
66
+ }
67
+
68
+ function assertNewPassword(password) {
69
+ const problem = newPasswordProblem(password);
70
+ if (problem) throw new VaultError(problem);
71
+ }
72
+
73
+ /**
74
+ * Password precedence: CLI flag > environment variable > interactive prompt.
75
+ * New passwords are strength-checked and confirmed when prompted.
76
+ */
77
+ async function resolvePassword({ flagValue, flagName, envVar, message, isNew = false }) {
78
+ if (flagValue !== undefined) {
79
+ log.warn(
80
+ `Passing a password via ${flagName} may expose it in shell history and process listings. ` +
81
+ `Prefer the interactive prompt or the ${envVar} environment variable.`,
82
+ );
83
+ if (!flagValue) throw new VaultError(`${flagName} must not be empty.`);
84
+ if (isNew) assertNewPassword(flagValue);
85
+ return flagValue;
86
+ }
87
+
88
+ const fromEnv = process.env[envVar];
89
+ if (fromEnv) {
90
+ if (isNew) assertNewPassword(fromEnv);
91
+ return fromEnv;
92
+ }
93
+
94
+ if (!isInteractive()) {
95
+ throw new VaultError('No password provided.', {
96
+ hint: `Run interactively, set ${envVar}, or pass ${flagName}.`,
97
+ });
98
+ }
99
+
100
+ const { password } = await ask({
101
+ type: 'password',
102
+ name: 'password',
103
+ message,
104
+ validate: (value) => (isNew ? newPasswordProblem(value) ?? true : value.length > 0 || 'Password cannot be empty.'),
105
+ });
106
+ if (isNew) {
107
+ const { confirmation } = await ask({ type: 'password', name: 'confirmation', message: 'Confirm password' });
108
+ if (confirmation !== password) throw new VaultError('Passwords do not match. No files were changed.');
109
+ }
110
+ return password;
111
+ }
112
+
113
+ async function pickEnvironment(ctx, requested) {
114
+ if (requested) return resolveEnvironment(ctx, requested);
115
+ const names = listEnvironments(ctx.config);
116
+ if (names.length === 1) return resolveEnvironment(ctx, names[0]);
117
+
118
+ if (isInteractive()) {
119
+ const initial = Math.max(0, names.indexOf(ctx.config.defaultEnvironment));
120
+ const { env } = await ask({
121
+ type: 'select',
122
+ name: 'env',
123
+ message: 'Select an environment',
124
+ choices: names.map((name) => ({ title: name, value: name })),
125
+ initial,
126
+ });
127
+ return resolveEnvironment(ctx, env);
128
+ }
129
+ if (ctx.config.defaultEnvironment) return resolveEnvironment(ctx, ctx.config.defaultEnvironment);
130
+ throw new VaultError('No environment specified.', { hint: `Use --env <${names.join('|')}>.` });
131
+ }
132
+
133
+ function summarizeDiff({ added, removed, changed }) {
134
+ return `${added.length} added, ${removed.length} removed, ${changed.length} changed`;
135
+ }
136
+
137
+ function printDiff(diff, { addedLabel, removedLabel }) {
138
+ for (const key of diff.added) log.info(chalk.green(` + ${sanitizeForTerminal(key)}`) + chalk.dim(` (${addedLabel})`));
139
+ for (const key of diff.removed) log.info(chalk.red(` - ${sanitizeForTerminal(key)}`) + chalk.dim(` (${removedLabel})`));
140
+ for (const key of diff.changed) log.info(chalk.yellow(` ~ ${sanitizeForTerminal(key)}`) + chalk.dim(' (value differs)'));
141
+ }
142
+
143
+ function printWarnings(warnings = []) {
144
+ for (const warning of warnings) log.warn(warning);
145
+ }
146
+
147
+ /** Runs an operation; on an "overwrite" confirmation, shows the key diff, asks, and retries with overwrite. */
148
+ async function withOverwriteConfirmation(run, { force, diffLabels }) {
149
+ try {
150
+ return run(force);
151
+ } catch (err) {
152
+ if (!(err instanceof ConfirmationRequiredError) || err.reason !== 'overwrite') throw err;
153
+ if (hasDifferences(err.diff)) {
154
+ log.info(`Changes: ${summarizeDiff(err.diff)}`);
155
+ printDiff(err.diff, diffLabels);
156
+ } else {
157
+ log.info('All keys and values match; only comments or formatting differ.');
158
+ }
159
+ await confirmOrAbort(err.message);
160
+ return run(true);
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Commands
166
+ // ---------------------------------------------------------------------------
167
+
168
+ async function initCommand(opts) {
169
+ const root = process.cwd();
170
+ let overwrite = Boolean(opts.force);
171
+ if (fs.existsSync(path.join(root, CONFIG_FILENAME))) {
172
+ await confirmOrAbort(`${CONFIG_FILENAME} already exists. Overwrite it?`, { force: opts.force });
173
+ overwrite = true;
174
+ }
175
+
176
+ let name = opts.name ?? path.basename(root);
177
+ let environments = opts.envs ? opts.envs.split(',') : [...DEFAULT_ENVIRONMENTS];
178
+ let defaultEnvironment = opts.defaultEnv;
179
+
180
+ if (!opts.yes && isInteractive()) {
181
+ const answers = await ask([
182
+ { type: 'text', name: 'name', message: 'Vault / project name', initial: name },
183
+ {
184
+ type: 'list',
185
+ name: 'environments',
186
+ message: 'Environments (comma-separated)',
187
+ initial: environments.join(', '),
188
+ separator: ',',
189
+ },
190
+ ]);
191
+ name = answers.name.trim();
192
+ environments = answers.environments.map((env) => env.trim()).filter(Boolean);
193
+ if (environments.length > 1) {
194
+ const preferred = defaultEnvironment ?? (environments.includes('development') ? 'development' : environments[0]);
195
+ const { selected } = await ask({
196
+ type: 'select',
197
+ name: 'selected',
198
+ message: 'Default environment',
199
+ choices: environments.map((env) => ({ title: env, value: env })),
200
+ initial: Math.max(0, environments.indexOf(preferred)),
201
+ });
202
+ defaultEnvironment = selected;
203
+ }
204
+ }
205
+
206
+ environments = environments.map((env) => env.trim()).filter(Boolean);
207
+ if (environments.length === 0) throw new VaultError('At least one environment is required.');
208
+ const invalid = environments.filter((env) => !ENV_NAME_PATTERN.test(env));
209
+ if (invalid.length) {
210
+ throw new VaultError(`Invalid environment name(s): ${invalid.map((env) => `"${env}"`).join(', ')}.`, {
211
+ hint: 'Use 1-64 letters, digits, "-" or "_", starting and ending with a letter or digit.',
212
+ });
213
+ }
214
+ if (defaultEnvironment !== undefined && !environments.includes(defaultEnvironment)) {
215
+ throw new VaultError(`Default environment "${defaultEnvironment}" is not one of: ${environments.join(', ')}.`);
216
+ }
217
+
218
+ const { config, gitignoreAdded, warnings } = initVault(root, { name, environments, defaultEnvironment, overwrite });
219
+ log.success(`Wrote ${CONFIG_FILENAME}`);
220
+ if (gitignoreAdded.length) log.success(`Updated .gitignore with: ${gitignoreAdded.join(', ')}`);
221
+ else log.success('.gitignore already protects plaintext env files');
222
+ printWarnings(warnings);
223
+
224
+ log.info('');
225
+ log.info(chalk.bold('Environments:'));
226
+ for (const [envName, mapping] of Object.entries(config.environments)) {
227
+ const marker = envName === config.defaultEnvironment ? chalk.cyan(' (default)') : '';
228
+ log.info(` ${envName}${marker}: ${mapping.file} -> ${mapping.encryptedFile}`);
229
+ }
230
+ log.info('');
231
+ log.info(chalk.bold('Next steps:'));
232
+ log.info(` 1. Create your local env file, e.g. ${config.environments[config.defaultEnvironment].file}`);
233
+ log.info(` 2. dotcrypt encrypt -e ${config.defaultEnvironment}`);
234
+ log.info(` 3. git add ${CONFIG_FILENAME} .gitignore *.enc && git commit`);
235
+ log.info(` Or run ${chalk.cyan('dotcrypt ui')} for a visual dashboard.`);
236
+ }
237
+
238
+ async function encryptCommand(opts) {
239
+ const ctx = loadConfig();
240
+ const env = await pickEnvironment(ctx, opts.env);
241
+ if (!fileExists(env.plainPath)) {
242
+ throw new VaultError(`Local file ${env.file} not found for environment "${env.name}".`);
243
+ }
244
+
245
+ // Inspect the existing vault file before prompting so we know whether this is a new password.
246
+ const vaultState = inspectVault(ctx, env);
247
+ if (vaultState.status === 'unreadable' && !opts.replace) {
248
+ throw new VaultError(vaultState.problem, {
249
+ hint: 'Fix the file (e.g. resolve a Git merge conflict), or use --replace to discard it and create a new one.',
250
+ });
251
+ }
252
+ const hasValidVault = vaultState.status === 'valid';
253
+ const passwordPrompted = opts.password === undefined && !process.env[PASSWORD_ENV];
254
+
255
+ const password = await resolvePassword({
256
+ flagValue: opts.password,
257
+ flagName: '--password',
258
+ envVar: PASSWORD_ENV,
259
+ isNew: !hasValidVault,
260
+ message: hasValidVault ? `Vault password for "${env.name}"` : `Choose a new vault password for "${env.name}"`,
261
+ });
262
+
263
+ const run = (replaceExisting) =>
264
+ withOverwriteConfirmation(
265
+ (overwrite) => encryptEnvironment(ctx, env.name, password, { overwrite, replaceExisting }),
266
+ { force: Boolean(opts.force), diffLabels: { addedLabel: 'new in local', removedLabel: 'will be removed from vault' } },
267
+ );
268
+
269
+ let result;
270
+ try {
271
+ result = await run(vaultState.status === 'unreadable');
272
+ } catch (err) {
273
+ if (!(err instanceof ConfirmationRequiredError) || err.reason !== 'password-mismatch') throw err;
274
+ if (!opts.replace) {
275
+ throw new VaultError(err.message, {
276
+ exitCode: 2,
277
+ hint:
278
+ 'Check the password. To change it use "dotcrypt rotate-key"; ' +
279
+ 'to discard the existing vault file and start over use --replace.',
280
+ });
281
+ }
282
+ // Replacing makes this the vault's new password, so treat it like one: a typo here would lock everyone out.
283
+ assertNewPassword(password);
284
+ await confirmOrAbort(
285
+ `${env.encryptedFile} is protected by a different password. Discard it and re-encrypt ${env.file} with the password you entered?`,
286
+ { force: opts.force },
287
+ );
288
+ if (passwordPrompted) {
289
+ const { confirmation } = await ask({ type: 'password', name: 'confirmation', message: 'Re-enter the new password' });
290
+ if (confirmation !== password) throw new VaultError('Passwords do not match. No files were changed.');
291
+ }
292
+ result = await run(true);
293
+ }
294
+
295
+ printWarnings(result.warnings);
296
+ if (result.status === 'unchanged') {
297
+ log.success(`${env.encryptedFile} is already up to date. Nothing to do.`);
298
+ return;
299
+ }
300
+ log.success(`Encrypted ${env.file} -> ${env.encryptedFile}`);
301
+ log.dim(`Share it with your team: ${shareCommand(env)}`);
302
+ }
303
+
304
+ function shareCommand(env) {
305
+ return `git add ${env.encryptedFile} && git commit -m "Update ${env.name} secrets" && git push`;
306
+ }
307
+
308
+ const SYNC_TEXT = {
309
+ synced: [chalk.green('✔'), 'Up to date'],
310
+ 'local-changes': [chalk.yellow('↑'), 'You have changes your team does not have'],
311
+ 'incoming-changes': [chalk.cyan('↓'), 'New changes from your team'],
312
+ conflict: [chalk.red('!'), 'Changed both locally and by your team'],
313
+ unknown: [chalk.dim('?'), 'Not checked yet on this machine'],
314
+ 'not-shared': [chalk.yellow('↑'), 'Not shared yet'],
315
+ 'not-downloaded': [chalk.cyan('↓'), 'Shared by your team, not on this machine yet'],
316
+ empty: [chalk.dim('·'), 'No env file yet'],
317
+ broken: [chalk.red('✖'), 'Encrypted file is unreadable'],
318
+ };
319
+
320
+ function nextStep(env) {
321
+ const flag = `-e ${env.name}`;
322
+ switch (env.sync) {
323
+ case 'local-changes':
324
+ case 'not-shared':
325
+ return `dotcrypt encrypt ${flag}`;
326
+ case 'incoming-changes':
327
+ case 'not-downloaded':
328
+ return `dotcrypt decrypt ${flag}`;
329
+ case 'conflict':
330
+ return `dotcrypt diff ${flag}, then keep yours (encrypt) or take theirs (decrypt)`;
331
+ case 'unknown':
332
+ return `dotcrypt diff ${flag}`;
333
+ case 'empty':
334
+ return `create ${env.file}, then dotcrypt encrypt ${flag}`;
335
+ case 'broken':
336
+ return env.vault.error ?? `${env.encryptedFile} belongs to "${env.vault.environment}"`;
337
+ default:
338
+ return null;
339
+ }
340
+ }
341
+
342
+ function statusCommand() {
343
+ const status = getVaultStatus(loadConfig());
344
+ log.info(chalk.bold(status.vault.name) + chalk.dim(` ${status.root}`));
345
+ log.info('');
346
+ const width = Math.max(...status.environments.map((env) => env.name.length + (env.isDefault ? 10 : 0)));
347
+ for (const env of status.environments) {
348
+ const share = env.vaultGit;
349
+ const unsent = env.vault.exists && share.inRepo && (share.uncommitted || share.unpushed);
350
+ const [icon, text] =
351
+ env.sync === 'synced' && unsent ? [chalk.yellow('↑'), 'Encrypted, but not pushed to your team yet'] : SYNC_TEXT[env.sync];
352
+ const label = env.isDefault ? `${env.name} ${chalk.dim('(default)')}` : env.name;
353
+ const pad = ' '.repeat(width - env.name.length - (env.isDefault ? 10 : 0));
354
+ log.info(` ${icon} ${label}${pad} ${text}`);
355
+ const step = nextStep(env);
356
+ if (step) log.dim(` → ${step}`);
357
+ if (env.git.tracked) log.warn(` ${env.file} is TRACKED by Git! Run "git rm --cached ${env.file}" and rotate its secrets.`);
358
+ if (unsent) {
359
+ log.dim(` → ${env.encryptedFile} is not pushed yet: ${share.uncommitted ? shareCommand(env) : 'git push'}`);
360
+ }
361
+ if (share.behind) log.dim(` → a teammate pushed a newer ${env.encryptedFile}: git pull`);
362
+ }
363
+ }
364
+
365
+ async function decryptCommand(opts) {
366
+ const ctx = loadConfig();
367
+ const env = await pickEnvironment(ctx, opts.env);
368
+ loadEnvelope(ctx, env); // fail fast before asking for a password
369
+ const password = await resolvePassword({
370
+ flagValue: opts.password,
371
+ flagName: '--password',
372
+ envVar: PASSWORD_ENV,
373
+ message: `Vault password for "${env.name}"`,
374
+ });
375
+
376
+ const result = await withOverwriteConfirmation(
377
+ (overwrite) => decryptEnvironment(ctx, env.name, password, { overwrite }),
378
+ {
379
+ force: Boolean(opts.force),
380
+ diffLabels: { addedLabel: 'local only — will be lost', removedLabel: 'will be restored from vault' },
381
+ },
382
+ );
383
+
384
+ if (result.status === 'unchanged') log.success(`${env.file} is already up to date. Nothing to do.`);
385
+ else log.success(`Decrypted ${env.encryptedFile} -> ${env.file}`);
386
+ printWarnings(result.warnings);
387
+ }
388
+
389
+ async function rotateKeyCommand(opts) {
390
+ const ctx = loadConfig();
391
+ const env = await pickEnvironment(ctx, opts.env);
392
+ loadEnvelope(ctx, env);
393
+
394
+ const oldPassword = await resolvePassword({
395
+ flagValue: opts.oldPassword,
396
+ flagName: '--old-password',
397
+ envVar: PASSWORD_ENV,
398
+ message: `Current password for "${env.name}"`,
399
+ });
400
+ verifyPassword(ctx, env.name, oldPassword);
401
+
402
+ const newPassword = await resolvePassword({
403
+ flagValue: opts.newPassword,
404
+ flagName: '--new-password',
405
+ envVar: NEW_PASSWORD_ENV,
406
+ isNew: true,
407
+ message: `New password for "${env.name}"`,
408
+ });
409
+
410
+ rotateEnvironmentKey(ctx, env.name, oldPassword, newPassword);
411
+ log.success(`Rotated password for ${env.encryptedFile} (fresh salt and IV).`);
412
+ log.dim('Commit the updated file and share the new password with your team over a secure channel.');
413
+ }
414
+
415
+ async function diffCommand(opts) {
416
+ const ctx = loadConfig();
417
+ const env = await pickEnvironment(ctx, opts.env);
418
+ loadEnvelope(ctx, env);
419
+ const password = await resolvePassword({
420
+ flagValue: opts.password,
421
+ flagName: '--password',
422
+ envVar: PASSWORD_ENV,
423
+ message: `Vault password for "${env.name}"`,
424
+ });
425
+
426
+ const { diff, localExists, identical } = diffEnvironment(ctx, env.name, password);
427
+ log.info(chalk.bold(`Comparing ${env.file} (local) with ${env.encryptedFile} (vault)`));
428
+ if (!localExists) log.warn(`${env.file} does not exist locally; treating it as empty.`);
429
+
430
+ if (!hasDifferences(diff)) {
431
+ log.success(`In sync (${diff.unchanged} key${diff.unchanged === 1 ? '' : 's'}).`);
432
+ if (localExists && !identical) log.dim(' Comments or formatting differ; encrypt/decrypt would still rewrite the file.');
433
+ return;
434
+ }
435
+ printDiff(diff, { addedLabel: 'local only — not yet encrypted', removedLabel: 'vault only — missing locally' });
436
+ log.info('');
437
+ log.info(`${summarizeDiff(diff)}, ${diff.unchanged} unchanged`);
438
+ if (opts.exitCode) process.exitCode = 1;
439
+ }
440
+
441
+ async function uiCommand(opts) {
442
+ if (!isUiBuilt()) {
443
+ throw new VaultError('The dashboard has not been built yet.', {
444
+ hint: 'Run "npm run build:ui" in the dotcrypt package directory.',
445
+ });
446
+ }
447
+ const timestamp = () => new Date().toLocaleTimeString();
448
+ const ui = await startUiServer({
449
+ port: opts.port,
450
+ onLog: (msg) => log.dim(`[${timestamp()}] ${sanitizeForTerminal(msg)}`),
451
+ });
452
+
453
+ log.success(`Dashboard running for ${ui.projectDir}`);
454
+ log.info(` ${chalk.cyan(ui.url)}`);
455
+ log.dim(' The URL contains a secret session token. Do not share it. Press Ctrl+C to stop.');
456
+ if (opts.open) openBrowser(ui.url);
457
+
458
+ await new Promise((resolve) => {
459
+ const stop = () => {
460
+ log.dim('\nStopping dashboard…');
461
+ ui.close().then(resolve);
462
+ };
463
+ process.once('SIGINT', stop);
464
+ process.once('SIGTERM', stop);
465
+ });
466
+ }
467
+
468
+ function parsePort(value) {
469
+ const port = Number(value);
470
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new InvalidArgumentError('Port must be 0-65535.');
471
+ return port;
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Program
476
+ // ---------------------------------------------------------------------------
477
+
478
+ const program = new Command();
479
+ program
480
+ .name('dotcrypt')
481
+ .description('Securely sync .env environments across machines via Git using AES-256-GCM encrypted files.')
482
+ .version(pkg.version, '-v, --version', 'output the version number')
483
+ .showHelpAfterError()
484
+ .addHelpText(
485
+ 'after',
486
+ `\nPasswords are read from (in order): the CLI flag, $${PASSWORD_ENV} ($${NEW_PASSWORD_ENV} for rotate-key's new password), or an interactive prompt.`,
487
+ );
488
+
489
+ program
490
+ .command('status')
491
+ .description('show what changed and what to do next for each environment (no password needed)')
492
+ .action(statusCommand);
493
+
494
+ program
495
+ .command('init')
496
+ .description(`create ${CONFIG_FILENAME} and protect plaintext env files in .gitignore`)
497
+ .option('-n, --name <name>', 'vault / project name (default: directory name)')
498
+ .option('--envs <list>', `comma-separated environments (default: ${DEFAULT_ENVIRONMENTS.join(',')})`)
499
+ .option('-d, --default-env <name>', 'default environment')
500
+ .option('-y, --yes', 'accept defaults without prompting')
501
+ .option('-f, --force', `overwrite an existing ${CONFIG_FILENAME} without prompting`)
502
+ .action(initCommand);
503
+
504
+ program
505
+ .command('encrypt')
506
+ .description('encrypt a local env file into its .enc vault file')
507
+ .option('-e, --env <name>', 'environment name')
508
+ .option('-p, --password <password>', 'vault password (prompted if omitted)')
509
+ .option('-f, --force', 'overwrite the vault file without prompting')
510
+ .option('--replace', 'discard an existing vault file that is unreadable or protected by a different password')
511
+ .action(encryptCommand);
512
+
513
+ program
514
+ .command('decrypt')
515
+ .description('decrypt a .enc vault file into its local env file')
516
+ .option('-e, --env <name>', 'environment name')
517
+ .option('-p, --password <password>', 'vault password (prompted if omitted)')
518
+ .option('-f, --force', 'overwrite the existing local file without prompting')
519
+ .action(decryptCommand);
520
+
521
+ program
522
+ .command('rotate-key')
523
+ .description('re-encrypt a vault file with a new password, salt and IV')
524
+ .option('-e, --env <name>', 'environment name')
525
+ .option('-o, --old-password <password>', 'current password (prompted if omitted)')
526
+ .option('-n, --new-password <password>', 'new password (prompted if omitted)')
527
+ .action(rotateKeyCommand);
528
+
529
+ program
530
+ .command('diff')
531
+ .description('compare key names between the vault and the local env file (values are never printed)')
532
+ .option('-e, --env <name>', 'environment name')
533
+ .option('-p, --password <password>', 'vault password (prompted if omitted)')
534
+ .option('--exit-code', 'exit with status 1 when differences are found')
535
+ .action(diffCommand);
536
+
537
+ program
538
+ .command('ui')
539
+ .description('open a local web dashboard (bound to 127.0.0.1, token-protected)')
540
+ .addOption(new Option('--port <port>', 'port to listen on').argParser(parsePort).default(0, 'a random free port'))
541
+ .option('--no-open', 'do not open the browser automatically')
542
+ .action(uiCommand);
543
+
544
+ try {
545
+ await program.parseAsync(process.argv);
546
+ } catch (err) {
547
+ if (err instanceof VaultError) {
548
+ log.error(err.message);
549
+ if (err.hint) console.error(chalk.dim(` ${err.hint}`));
550
+ process.exitCode = err.exitCode;
551
+ } else {
552
+ log.error(`Unexpected error: ${err.message}`);
553
+ if (process.env.DEBUG) console.error(err.stack);
554
+ process.exitCode = 1;
555
+ }
556
+ }
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "dotcrypt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Securely sync multiple .env environments across machines via Git using AES-256-GCM encrypted files.",
5
+ "type": "module",
6
+ "main": "src/crypto.js",
7
+ "bin": {
8
+ "dotcrypt": "bin/cli.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "ui/dist/",
14
+ "README.md",
15
+ "LICENSE",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "setup": "npm install && npm --prefix ui install && npm run build:ui",
21
+ "build:ui": "npm --prefix ui run build",
22
+ "dev:ui": "npm --prefix ui run dev",
23
+ "prepack": "npm run build:ui",
24
+ "prepublishOnly": "npm test",
25
+ "preversion": "npm test",
26
+ "postversion": "git push --follow-tags"
27
+ },
28
+ "keywords": [
29
+ "dotenv",
30
+ "env",
31
+ "secrets",
32
+ "vault",
33
+ "encryption",
34
+ "aes-256-gcm",
35
+ "cli",
36
+ "git",
37
+ "dotfiles"
38
+ ],
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/zubayerhimel/dotcrypt.git"
43
+ },
44
+ "homepage": "https://github.com/zubayerhimel/dotcrypt#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/zubayerhimel/dotcrypt/issues"
47
+ },
48
+ "engines": {
49
+ "node": ">=18.17"
50
+ },
51
+ "dependencies": {
52
+ "chalk": "^5.3.0",
53
+ "commander": "^12.1.0",
54
+ "dotenv": "^16.4.5",
55
+ "prompts": "^2.4.2"
56
+ },
57
+ "author": "zubayerhimel <zhimel007@gmail.com>",
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }