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 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
- **Project owner (once):**
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
- ```bash
142
- dotcrypt init # creates .dotcrypt.json, updates .gitignore
143
- # create .env.development, .env.staging, ...
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
- Share each environment's password **out of band** (password manager, not chat or the repo).
147
+ **Setting up (once, by one person):**
151
148
 
152
- **Teammate on a new machine:**
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
- ```bash
155
- git pull
156
- dotcrypt decrypt -e development
157
- ```
156
+ **You added or changed a variable:**
158
157
 
159
- **Changing a secret:**
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
- ```bash
162
- git pull # always start from the latest vault
163
- dotcrypt diff -e development # see what differs before acting
164
- # edit .env.development
165
- dotcrypt encrypt -e development
166
- git add .env.development.enc && git commit -m "Update dev env" && git push
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
- - every environment in `.dotcrypt.json`: local file and vault file status, sizes, last-modified /
293
- last-encrypted times, and the default environment
294
- - a health badge per environment (Synced, Local changed, Not encrypted, Not decrypted, Vault
295
- unreadable, Plaintext tracked)
296
- - Git safety: whether plaintext files are ignored or (dangerously) tracked, whether `.enc` files are
297
- committed, and a one-click fix for missing `.gitignore` rules
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
- You can initialize a vault, add environments, set the default, and run encrypt, decrypt, diff and
301
- rotate-key from the dashboard. Overwrites show the key-name diff and need confirmation, just like
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(`Commit ${env.encryptedFile} to share it with your team.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotcrypt",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Securely sync multiple .env environments across machines via Git using AES-256-GCM encrypted files.",
5
5
  "type": "module",
6
6
  "main": "src/crypto.js",
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 localNewerThanVault = Boolean(
140
- local.modifiedAt && vault.encryptedAt && Date.parse(local.modifiedAt) > Date.parse(vault.encryptedAt),
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 ? { inRepo: true, tracked: vaultGit.tracked } : { inRepo: false },
152
- localNewerThanVault,
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 environments = listEnvironments(ctx.config).map((name) => getEnvironmentStatus(ctx, name));
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)) return { status: 'unchanged', environment: env.name, warnings };
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);