dotcrypt 1.0.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/CHANGELOG.md +18 -0
- package/README.md +61 -32
- package/bin/cli.js +68 -1
- package/package.json +1 -1
- package/src/sync.js +100 -0
- package/src/utils.js +30 -0
- package/src/vault.js +36 -8
- package/ui/dist/assets/index-BlKnGv6b.js +9 -0
- package/ui/dist/assets/index-DL_3iL0a.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CmGxJebe.js +0 -9
- package/ui/dist/assets/index-DBqwUbdm.css +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `status` command: shows, per environment, whether you or your team changed something since your
|
|
12
|
+
last sync and what to do next. No password needed.
|
|
13
|
+
- Sync tracking: fingerprints recorded in `.git/dotcrypt/sync-state.json` (per clone, never
|
|
14
|
+
committed) so dotcrypt can tell local changes from incoming ones.
|
|
15
|
+
- `encrypt` now prints the Git command to send the `.enc` file to your team.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Simpler dashboard: plain-language states ("You have unshared changes", "New changes from your
|
|
20
|
+
team", ...), one main button per environment, a copyable commit/push command, a "How sharing
|
|
21
|
+
works" guide, and a Compare dialog to choose between your version and your team's.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- The dashboard no longer reports "Local changed" right after decrypting.
|
|
26
|
+
|
|
9
27
|
## [1.0.0] - 2026-11-10
|
|
10
28
|
|
|
11
29
|
### Added
|
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ Prefer the terminal? Everything in the dashboard is also available as a CLI comm
|
|
|
60
60
|
|
|
61
61
|
| Command | Description |
|
|
62
62
|
| --- | --- |
|
|
63
|
+
| `status` | Show what changed for each environment and what to do next. No password needed. |
|
|
63
64
|
| `init [-n name] [--envs a,b,c] [-d env] [-y] [-f]` | Create `.dotcrypt.json` and add ignore rules for plaintext env files to `.gitignore`. |
|
|
64
65
|
| `encrypt [-e env] [-p password] [-f] [--replace]` | Encrypt the mapped local file (e.g. `.env.staging`) into its `.enc` file. |
|
|
65
66
|
| `decrypt [-e env] [-p password] [-f]` | Decrypt the `.enc` file into the mapped local file. |
|
|
@@ -136,35 +137,62 @@ Custom plaintext paths that the default `.env` / `.env.*` rules don't cover are
|
|
|
136
137
|
|
|
137
138
|
## Team workflow
|
|
138
139
|
|
|
139
|
-
**
|
|
140
|
+
The idea in one sentence: **your secrets live in normal `.env` files; you share them by
|
|
141
|
+
encrypting them into `.enc` files and pushing those through Git.**
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
dotcrypt encrypt -e development # choose a strong password
|
|
145
|
-
dotcrypt encrypt -e production # use a *different* password per environment
|
|
146
|
-
git add .dotcrypt.json .gitignore *.enc
|
|
147
|
-
git commit -m "Add encrypted environments"
|
|
148
|
-
```
|
|
143
|
+
The easiest way to work is the dashboard (`dotcrypt ui`). Each environment card says in plain
|
|
144
|
+
words what state it is in and shows one button for the next step. `dotcrypt status` shows the
|
|
145
|
+
same thing in the terminal.
|
|
149
146
|
|
|
150
|
-
|
|
147
|
+
**Setting up (once, by one person):**
|
|
151
148
|
|
|
152
|
-
|
|
149
|
+
1. Run `dotcrypt init` (or open `dotcrypt ui` and click through the setup).
|
|
150
|
+
2. Create your `.env` files as usual and click **Share with team** on each card (or run
|
|
151
|
+
`dotcrypt encrypt -e <env>`). You choose a password per environment.
|
|
152
|
+
3. Commit and push `.dotcrypt.json`, `.gitignore` and the `.enc` files. The card shows the exact
|
|
153
|
+
command.
|
|
154
|
+
4. Give teammates the passwords privately (a password manager, not chat or the repo).
|
|
153
155
|
|
|
154
|
-
|
|
155
|
-
git pull
|
|
156
|
-
dotcrypt decrypt -e development
|
|
157
|
-
```
|
|
156
|
+
**You added or changed a variable:**
|
|
158
157
|
|
|
159
|
-
|
|
158
|
+
1. Edit the `.env` file in your editor.
|
|
159
|
+
2. The card now says **You have unshared changes**. Click **Share changes**
|
|
160
|
+
(CLI: `dotcrypt encrypt -e <env>`).
|
|
161
|
+
3. Commit and push the `.enc` file. The card shows the command, for example:
|
|
162
|
+
`git add .env.development.enc && git commit -m "Update development secrets" && git push`.
|
|
160
163
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
dotcrypt
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
**A teammate changed something:**
|
|
165
|
+
|
|
166
|
+
1. `git pull`.
|
|
167
|
+
2. The card says **New changes from your team**. Click **Get latest**
|
|
168
|
+
(CLI: `dotcrypt decrypt -e <env>`).
|
|
169
|
+
|
|
170
|
+
**A new teammate joins:** they clone the repo, run `npx dotcrypt ui`, click **Get secrets** on
|
|
171
|
+
each card, and enter the password you gave them.
|
|
172
|
+
|
|
173
|
+
**Both of you changed the same environment:** the card says **Changed on both sides**. Click
|
|
174
|
+
**Review** to see which variable names differ, then choose **Keep mine and share it** or **Use
|
|
175
|
+
team's version**. If you need parts of both, copy the missing variables into your file first,
|
|
176
|
+
then keep yours.
|
|
177
|
+
|
|
178
|
+
### How dotcrypt knows what changed
|
|
179
|
+
|
|
180
|
+
`.enc` files are re-encrypted with a fresh random IV every time, so comparing timestamps or file
|
|
181
|
+
contents can't tell who changed what. Instead, every time you share, get, or compare an
|
|
182
|
+
environment, dotcrypt records fingerprints (SHA-256) of both files at that moment in
|
|
183
|
+
`.git/dotcrypt/sync-state.json`. That file is local to your clone and never committed. Later it
|
|
184
|
+
compares the current files with those fingerprints:
|
|
185
|
+
|
|
186
|
+
| State | Meaning | Next step |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| Up to date | Neither file changed since your last sync. | Nothing. |
|
|
189
|
+
| You have unshared changes | Your `.env` changed. | Share, then commit and push. |
|
|
190
|
+
| New changes from your team | The `.enc` changed (e.g. after `git pull`). | Get latest. |
|
|
191
|
+
| Changed on both sides | Both changed. | Review and choose a version. |
|
|
192
|
+
| Not shared yet / Ready to download | Only one of the two files exists. | Share / Get secrets. |
|
|
193
|
+
| Not checked yet | No fingerprint yet (e.g. first run on an existing checkout). | Compare once with the password. |
|
|
194
|
+
|
|
195
|
+
Neither the dashboard nor `status` needs a password to show these states.
|
|
168
196
|
|
|
169
197
|
**Someone leaves the team / password leaked:**
|
|
170
198
|
|
|
@@ -289,17 +317,18 @@ dashboard assets are not built, run `npm run build:ui` in this repository.
|
|
|
289
317
|
A local dashboard built with React, [shadcn/ui](https://ui.shadcn.com) (Base UI primitives) and
|
|
290
318
|
Tailwind. It shows:
|
|
291
319
|
|
|
292
|
-
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
320
|
+
- a short "How sharing works" guide (hide it, and reopen it from **How it works** in the header)
|
|
321
|
+
- one card per environment with a plain-language state (see [Team workflow](#how-dotcrypt-knows-what-changed))
|
|
322
|
+
and a single button for the next step: **Share changes**, **Get latest**, **Review**, ...
|
|
323
|
+
- the Git command to send a shared `.enc` file to your team (with a copy button), and a hint when a
|
|
324
|
+
teammate pushed something newer
|
|
325
|
+
- Git safety: whether plaintext files are ignored or (dangerously) tracked, and a one-click fix for
|
|
326
|
+
missing `.gitignore` rules
|
|
298
327
|
- an activity log for the session
|
|
299
328
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
the CLI. The dashboard refreshes every 5 seconds, so changes made in the terminal or by `git pull`
|
|
329
|
+
Less common actions (compare, force share or get, change password, make default) are in each
|
|
330
|
+
card's **⋯** menu. Changes that overwrite a file show the variable names involved and ask for
|
|
331
|
+
confirmation, just like the CLI. The dashboard refreshes every 5 seconds, so changes made in the terminal or by `git pull`
|
|
303
332
|
show up automatically.
|
|
304
333
|
|
|
305
334
|
### Dashboard security model
|
package/bin/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
decryptEnvironment,
|
|
20
20
|
diffEnvironment,
|
|
21
21
|
encryptEnvironment,
|
|
22
|
+
getVaultStatus,
|
|
22
23
|
initVault,
|
|
23
24
|
inspectVault,
|
|
24
25
|
loadEnvelope,
|
|
@@ -297,7 +298,68 @@ async function encryptCommand(opts) {
|
|
|
297
298
|
return;
|
|
298
299
|
}
|
|
299
300
|
log.success(`Encrypted ${env.file} -> ${env.encryptedFile}`);
|
|
300
|
-
log.dim(`
|
|
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
|
+
}
|
|
301
363
|
}
|
|
302
364
|
|
|
303
365
|
async function decryptCommand(opts) {
|
|
@@ -424,6 +486,11 @@ program
|
|
|
424
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.`,
|
|
425
487
|
);
|
|
426
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
|
+
|
|
427
494
|
program
|
|
428
495
|
.command('init')
|
|
429
496
|
.description(`create ${CONFIG_FILENAME} and protect plaintext env files in .gitignore`)
|
package/package.json
CHANGED
package/src/sync.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { MAX_FILE_SIZE, gitOutput } from './utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Remembers, per machine, the fingerprints of the local env file and the vault file at the moment
|
|
9
|
+
* they were last known to match (after encrypt, decrypt, or an identical diff). Comparing the
|
|
10
|
+
* current files against that baseline tells us who changed what, without needing the password:
|
|
11
|
+
*
|
|
12
|
+
* local changed only -> you have changes to share
|
|
13
|
+
* vault changed only -> a teammate shared changes (after git pull)
|
|
14
|
+
* both changed -> conflict
|
|
15
|
+
*
|
|
16
|
+
* The state lives inside the Git directory (.git/dotcrypt/sync-state.json), so it is never
|
|
17
|
+
* committed and every clone or worktree tracks its own files.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const STATE_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
export function fingerprint(data) {
|
|
23
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SHA-256 of a file, or null when it is missing, too large or unreadable. */
|
|
27
|
+
export function fingerprintFile(absPath) {
|
|
28
|
+
try {
|
|
29
|
+
if (fs.statSync(absPath).size > MAX_FILE_SIZE) return null;
|
|
30
|
+
const data = fs.readFileSync(absPath);
|
|
31
|
+
try {
|
|
32
|
+
return fingerprint(data);
|
|
33
|
+
} finally {
|
|
34
|
+
data.fill(0);
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function locateState(root) {
|
|
42
|
+
const out = gitOutput(root, ['rev-parse', '--absolute-git-dir', '--show-prefix']);
|
|
43
|
+
if (!out) return null;
|
|
44
|
+
const [gitDir, prefix = ''] = out.split('\n');
|
|
45
|
+
if (!gitDir) return null;
|
|
46
|
+
return { file: path.join(gitDir, 'dotcrypt', 'sync-state.json'), project: prefix.replace(/\/$/, '') || '.' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readState(file) {
|
|
50
|
+
try {
|
|
51
|
+
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
|
+
if (state?.version === STATE_VERSION && state.projects && typeof state.projects === 'object') return state;
|
|
53
|
+
} catch {
|
|
54
|
+
// Missing or corrupt state simply means "unknown"; it is rebuilt on the next sync.
|
|
55
|
+
}
|
|
56
|
+
return { version: STATE_VERSION, projects: {} };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Opens the sync state for a project root. All methods are no-ops outside a Git repository. */
|
|
60
|
+
export function openSyncState(root) {
|
|
61
|
+
const location = locateState(root);
|
|
62
|
+
const state = location ? readState(location.file) : null;
|
|
63
|
+
const entries = () => (state ? (state.projects[location.project] ?? {}) : {});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
get(envName) {
|
|
67
|
+
const entry = Object.hasOwn(entries(), envName) ? entries()[envName] : null;
|
|
68
|
+
return entry && typeof entry.local === 'string' && typeof entry.vault === 'string' ? entry : null;
|
|
69
|
+
},
|
|
70
|
+
set(envName, { local, vault }) {
|
|
71
|
+
if (!state || !local || !vault) return;
|
|
72
|
+
state.projects[location.project] = { ...entries(), [envName]: { local, vault, syncedAt: new Date().toISOString() } };
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(path.dirname(location.file), { recursive: true });
|
|
75
|
+
const tmp = `${location.file}.${process.pid}.tmp`;
|
|
76
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
fs.renameSync(tmp, location.file);
|
|
78
|
+
} catch {
|
|
79
|
+
// Best effort: sync tracking must never make encrypt or decrypt fail.
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Plain-language sync state of one environment:
|
|
87
|
+
* empty | not-shared | not-downloaded | unknown | synced | local-changes | incoming-changes | conflict
|
|
88
|
+
*/
|
|
89
|
+
export function computeSync({ localExists, vaultExists, localHash, vaultHash, baseline }) {
|
|
90
|
+
if (!localExists && !vaultExists) return 'empty';
|
|
91
|
+
if (!vaultExists) return 'not-shared';
|
|
92
|
+
if (!localExists) return 'not-downloaded';
|
|
93
|
+
if (!baseline || !localHash || !vaultHash) return 'unknown';
|
|
94
|
+
const localChanged = localHash !== baseline.local;
|
|
95
|
+
const vaultChanged = vaultHash !== baseline.vault;
|
|
96
|
+
if (localChanged && vaultChanged) return 'conflict';
|
|
97
|
+
if (localChanged) return 'local-changes';
|
|
98
|
+
if (vaultChanged) return 'incoming-changes';
|
|
99
|
+
return 'synced';
|
|
100
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -219,6 +219,36 @@ export function gitStatusOf(root, relPath) {
|
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/** Returns trimmed stdout of a Git command, or null when it fails or Git is unavailable. */
|
|
223
|
+
export function gitOutput(root, args) {
|
|
224
|
+
try {
|
|
225
|
+
return execFileSync('git', args, {
|
|
226
|
+
cwd: root,
|
|
227
|
+
encoding: 'utf8',
|
|
228
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
229
|
+
windowsHide: true,
|
|
230
|
+
}).trimEnd();
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Describes how far a committed file is from the team:
|
|
238
|
+
* - uncommitted: changed or new in the working tree
|
|
239
|
+
* - unpushed: committed locally but not on the upstream branch yet
|
|
240
|
+
* - behind: the upstream branch has a newer version (visible after a fetch)
|
|
241
|
+
* Each flag is false when unknown (no repo, no upstream).
|
|
242
|
+
*/
|
|
243
|
+
export function gitShareStatus(root, relPath) {
|
|
244
|
+
const file = toPosix(relPath);
|
|
245
|
+
const uncommitted = Boolean(gitOutput(root, ['status', '--porcelain', '--untracked-files=all', '--', file]));
|
|
246
|
+
const hasUpstream = gitOutput(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']) !== null;
|
|
247
|
+
if (!hasUpstream) return { uncommitted, unpushed: false, behind: false, hasUpstream };
|
|
248
|
+
const touched = (range) => Boolean(gitOutput(root, ['log', '-1', '--format=%H', range, '--', file]));
|
|
249
|
+
return { uncommitted, unpushed: touched('@{u}..HEAD'), behind: touched('HEAD..@{u}'), hasUpstream };
|
|
250
|
+
}
|
|
251
|
+
|
|
222
252
|
export function parseEnv(content) {
|
|
223
253
|
return dotenv.parse(content);
|
|
224
254
|
}
|
package/src/vault.js
CHANGED
|
@@ -18,11 +18,13 @@ import {
|
|
|
18
18
|
diffEnv,
|
|
19
19
|
ensureGitignore,
|
|
20
20
|
fileExists,
|
|
21
|
+
gitShareStatus,
|
|
21
22
|
gitStatusOf,
|
|
22
23
|
parseEnv,
|
|
23
24
|
safeReadFile,
|
|
24
25
|
safeWriteFile,
|
|
25
26
|
} from './utils.js';
|
|
27
|
+
import { computeSync, fingerprint, fingerprintFile, openSyncState } from './sync.js';
|
|
26
28
|
|
|
27
29
|
export const MIN_PASSWORD_LENGTH = 12;
|
|
28
30
|
|
|
@@ -116,7 +118,7 @@ function fileInfo(root, absPath) {
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
/** Describes an environment without decrypting anything. Never exposes secret values. */
|
|
119
|
-
export function getEnvironmentStatus(ctx, name) {
|
|
121
|
+
export function getEnvironmentStatus(ctx, name, syncState = openSyncState(ctx.root)) {
|
|
120
122
|
const env = resolveEnvironment(ctx, name);
|
|
121
123
|
const local = fileInfo(ctx.root, env.plainPath);
|
|
122
124
|
const vault = { ...fileInfo(ctx.root, env.encPath), valid: false };
|
|
@@ -136,25 +138,35 @@ export function getEnvironmentStatus(ctx, name) {
|
|
|
136
138
|
|
|
137
139
|
const git = gitStatusOf(ctx.root, env.file);
|
|
138
140
|
const vaultGit = gitStatusOf(ctx.root, env.encryptedFile);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
const broken = vault.exists && !(vault.valid && vault.environmentMatches);
|
|
142
|
+
const sync = broken
|
|
143
|
+
? 'broken'
|
|
144
|
+
: computeSync({
|
|
145
|
+
localExists: local.exists,
|
|
146
|
+
vaultExists: vault.exists,
|
|
147
|
+
localHash: local.exists && vault.exists ? fingerprintFile(env.plainPath) : null,
|
|
148
|
+
vaultHash: local.exists && vault.exists ? fingerprintFile(env.encPath) : null,
|
|
149
|
+
baseline: syncState.get(env.name),
|
|
150
|
+
});
|
|
142
151
|
|
|
143
152
|
return {
|
|
144
153
|
name: env.name,
|
|
145
154
|
file: env.file,
|
|
146
155
|
encryptedFile: env.encryptedFile,
|
|
147
156
|
isDefault: ctx.config.defaultEnvironment === env.name,
|
|
157
|
+
sync,
|
|
148
158
|
local,
|
|
149
159
|
vault,
|
|
150
160
|
git,
|
|
151
|
-
vaultGit: vaultGit.inRepo
|
|
152
|
-
|
|
161
|
+
vaultGit: vaultGit.inRepo
|
|
162
|
+
? { inRepo: true, tracked: vaultGit.tracked, ...gitShareStatus(ctx.root, env.encryptedFile) }
|
|
163
|
+
: { inRepo: false },
|
|
153
164
|
};
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
export function getVaultStatus(ctx) {
|
|
157
|
-
const
|
|
168
|
+
const syncState = openSyncState(ctx.root);
|
|
169
|
+
const environments = listEnvironments(ctx.config).map((name) => getEnvironmentStatus(ctx, name, syncState));
|
|
158
170
|
return {
|
|
159
171
|
root: ctx.root,
|
|
160
172
|
configFile: CONFIG_FILENAME,
|
|
@@ -164,6 +176,11 @@ export function getVaultStatus(ctx) {
|
|
|
164
176
|
};
|
|
165
177
|
}
|
|
166
178
|
|
|
179
|
+
/** Records that the local file and the vault file currently hold the same content. */
|
|
180
|
+
function markSynced(ctx, env, plaintext) {
|
|
181
|
+
openSyncState(ctx.root).set(env.name, { local: fingerprint(plaintext), vault: fingerprintFile(env.encPath) });
|
|
182
|
+
}
|
|
183
|
+
|
|
167
184
|
/**
|
|
168
185
|
* Encrypts the local file into the vault file.
|
|
169
186
|
* Options:
|
|
@@ -218,7 +235,10 @@ export function encryptEnvironment(ctx, name, password, { overwrite = false, rep
|
|
|
218
235
|
let diff;
|
|
219
236
|
if (previous) {
|
|
220
237
|
try {
|
|
221
|
-
if (buffersEqual(previous, plaintext))
|
|
238
|
+
if (buffersEqual(previous, plaintext)) {
|
|
239
|
+
markSynced(ctx, env, plaintext);
|
|
240
|
+
return { status: 'unchanged', environment: env.name, warnings };
|
|
241
|
+
}
|
|
222
242
|
diff = diffEnv(parseEnv(previous), parseEnv(plaintext));
|
|
223
243
|
} finally {
|
|
224
244
|
previous.fill(0);
|
|
@@ -233,6 +253,7 @@ export function encryptEnvironment(ctx, name, password, { overwrite = false, rep
|
|
|
233
253
|
|
|
234
254
|
const envelope = encrypt(plaintext, password, { environment: env.name, sourceFile: env.file });
|
|
235
255
|
safeWriteFile(ctx.root, env.encPath, serializeEnvelope(envelope), { mode: 0o644 });
|
|
256
|
+
markSynced(ctx, env, plaintext);
|
|
236
257
|
return { status: vaultExisted ? 'updated' : 'created', environment: env.name, diff, warnings };
|
|
237
258
|
} finally {
|
|
238
259
|
plaintext.fill(0);
|
|
@@ -255,6 +276,7 @@ export function decryptEnvironment(ctx, name, password, { overwrite = false } =
|
|
|
255
276
|
const current = safeReadFile(ctx.root, env.plainPath);
|
|
256
277
|
try {
|
|
257
278
|
if (buffersEqual(current, plaintext)) {
|
|
279
|
+
markSynced(ctx, env, plaintext);
|
|
258
280
|
return { status: 'unchanged', environment: env.name, warnings: plaintextWarnings(ctx.root, env.file) };
|
|
259
281
|
}
|
|
260
282
|
if (!overwrite) {
|
|
@@ -269,6 +291,7 @@ export function decryptEnvironment(ctx, name, password, { overwrite = false } =
|
|
|
269
291
|
}
|
|
270
292
|
|
|
271
293
|
safeWriteFile(ctx.root, env.plainPath, plaintext, { mode: 0o600 });
|
|
294
|
+
markSynced(ctx, env, plaintext);
|
|
272
295
|
return {
|
|
273
296
|
status: localExisted ? 'updated' : 'created',
|
|
274
297
|
environment: env.name,
|
|
@@ -299,7 +322,11 @@ export function rotateEnvironmentKey(ctx, name, oldPassword, newPassword) {
|
|
|
299
322
|
check.fill(0);
|
|
300
323
|
if (!ok) throw new VaultError('Verification of the re-encrypted data failed. The original file was not modified.');
|
|
301
324
|
|
|
325
|
+
const syncState = openSyncState(ctx.root);
|
|
326
|
+
const baseline = syncState.get(env.name);
|
|
327
|
+
const wasSynced = baseline?.vault === fingerprintFile(env.encPath);
|
|
302
328
|
safeWriteFile(ctx.root, env.encPath, serializeEnvelope(rotated), { mode: 0o644 });
|
|
329
|
+
if (wasSynced) syncState.set(env.name, { local: baseline.local, vault: fingerprintFile(env.encPath) });
|
|
303
330
|
return { status: 'rotated', environment: env.name };
|
|
304
331
|
} finally {
|
|
305
332
|
plaintext.fill(0);
|
|
@@ -334,6 +361,7 @@ export function diffEnvironment(ctx, name, password) {
|
|
|
334
361
|
if (local) {
|
|
335
362
|
localVars = parseEnv(local);
|
|
336
363
|
identical = buffersEqual(local, plaintext);
|
|
364
|
+
if (identical) markSynced(ctx, env, plaintext);
|
|
337
365
|
}
|
|
338
366
|
} finally {
|
|
339
367
|
plaintext.fill(0);
|