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