envstash-cli 0.10.0 → 0.12.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/README.md CHANGED
@@ -127,8 +127,11 @@ definition and therefore has no undo:
127
127
  - **`diff` masks values**, showing at most the first 4 characters and the
128
128
  length, and nothing at all of a value 12 characters or shorter. Use
129
129
  `--show-values` to reveal them.
130
- - **`sync` never deletes** in either direction, and never overwrites a value
131
- that differs on both sides without being told which side wins.
130
+ - **`sync` never prunes keys** in either direction, and never overwrites a
131
+ value that differs on both sides without being told which side wins. It does
132
+ follow a whole-file deletion: a file this machine has synced before and you
133
+ have since deleted is removed from the cloud. A machine that never had the
134
+ file pulls it instead, so a fresh clone cannot delete anything.
132
135
  - **`remove` needs every file named** — there is no "remove everything"
133
136
  default — and it only touches the cloud copy; files on your machines stay.
134
137
  - Pulled files are written `0600` — replaced atomically, so a file that
@@ -171,21 +174,31 @@ $ envs sync
171
174
  See them with envs diff, then choose a side: envs sync --prefer local or envs sync --prefer cloud
172
175
  ```
173
176
 
174
- | Situation | What `sync` does |
175
- | ---------------------------------- | ----------------------------------- |
176
- | File on disk, not in the cloud | pushes it |
177
- | File in the cloud, not on disk | pulls it |
178
- | Key on one side only | copies it to the other side |
179
- | Key on both sides, different value | **conflict** — left alone, reported |
180
- | Everything matches | nothing |
181
-
182
- **It never deletes.** Sync does not prune in either direction. There is no
183
- record of the last sync, so nothing can know whether a key that exists on one
184
- side only is one you just added or one a teammate removed — the same limit
185
- `envs status` acknowledges by saying `local-only` rather than `added`. Copying
186
- is the guess whose failure mode is a key coming back, which the next
187
- `envs status` shows you and `envs rm` undoes. Deleting is the guess whose
188
- failure mode is a secret that exists nowhere any more.
177
+ | Situation | What `sync` does |
178
+ | -------------------------------------------------- | ----------------------------------- |
179
+ | File on disk, not in the cloud | pushes it |
180
+ | File in the cloud, not on disk, never synced here | pulls it |
181
+ | File in the cloud, not on disk, synced here before | removes it from the cloud |
182
+ | Key on one side only | copies it to the other side |
183
+ | Key on both sides, different value | **conflict** — left alone, reported |
184
+ | Everything matches | nothing |
185
+
186
+ **It never prunes keys.** There is no record of the last sync at key level, so
187
+ nothing can know whether a key that exists on one side only is one you just
188
+ added or one a teammate removed — the same limit `envs status` acknowledges by
189
+ saying `local-only` rather than `added`. Copying is the guess whose failure
190
+ mode is a key coming back, which the next `envs status` shows you and
191
+ `envs rm` undoes. Deleting is the guess whose failure mode is a secret that
192
+ exists nowhere any more.
193
+
194
+ **It does follow a deleted file.** Whole files are the one place a record
195
+ exists: every `sync`, `pull` and `push` notes, in `~/.envstash/synced.json`,
196
+ which files this machine ended up with on both sides. A file on that list that
197
+ is gone from disk was deleted here, and `sync` removes it from the cloud and
198
+ says so. A cloud file not on the list is pulled as before — a fresh clone has
199
+ no record, so it can never delete anything — and `envs rm` takes a file off
200
+ the list, so one a teammate adds back later is pulled rather than removed
201
+ again.
189
202
 
190
203
  **A conflict is never guessed at either.** When the same key holds different
191
204
  values on each side, both copies are left exactly as they are, the key is
@@ -244,7 +257,7 @@ on the dashboard under **Shared Files**, not inside any project.
244
257
  ```json
245
258
  {
246
259
  "project": "demo-app",
247
- "files": [".env.local", ".env.production"]
260
+ "files": [".env", ".env.production"]
248
261
  }
249
262
  ```
250
263
 
@@ -259,6 +272,16 @@ and `.agents/` (see below). That list is the default, and
259
272
  `.dist` are ignored, since those are meant to be committed and hold placeholder
260
273
  values.
261
274
 
275
+ `.env.local` and `.env.*.local` are not discovered either. Every dotenv loader
276
+ that layers files puts `.local` on top — `.env`, then `.env.<mode>`, then
277
+ `.env.local`, then the real environment — so by name it is the override a
278
+ developer writes to differ from the shared values on this one checkout. A tool
279
+ that distributes the shared values fills the lower layers and leaves that one
280
+ alone; syncing it would leave no layer that can differ between machines.
281
+ `init`, `push`, and `sync` say when they skip one. To sync it anyway, list it in
282
+ `.envstash.json` or name it on the command line — that is taken as written. A
283
+ `.local` file already in the cloud keeps syncing until you `envs rm` it.
284
+
262
285
  ### Files in `.config/`
263
286
 
264
287
  A service-account key or a cert is a secret that is not `KEY=value`, and it does
package/bin/envs.mjs CHANGED
File without changes
package/bin/envstash.mjs CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Git-style CLI for EnvStash — keep your .env files in sync with your cloud account.",
5
5
  "keywords": [
6
6
  "env",
@@ -31,12 +31,13 @@
31
31
  "engines": {
32
32
  "node": ">=18.17"
33
33
  },
34
- "publishConfig": {
35
- "access": "public"
36
- },
37
34
  "scripts": {
38
35
  "build": "pnpm pack --dry-run",
39
36
  "test": "node --test \"test/*.test.mjs\"",
37
+ "prepublishOnly": "pnpm run test",
40
38
  "release": "pnpm publish"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
41
42
  }
42
- }
43
+ }
@@ -15,7 +15,7 @@ import {
15
15
  import { requireHostToken, resolveHostFor, resolveHostSource } from '../session.mjs'
16
16
  import { DEFAULT_SETTINGS, readSettings, settingsPath } from '../settings.mjs'
17
17
  import { out, pad, pluralize, sanitize, style } from '../ui.mjs'
18
- import { announceDiscoveryDirs } from './push.mjs'
18
+ import { announceDiscoveryDirs, announceLocalEnvFiles } from './push.mjs'
19
19
 
20
20
  /**
21
21
  * `envs version` — the version, and the environment it would act in.
@@ -266,6 +266,7 @@ export async function init(flags, args) {
266
266
  const files = existing?.files?.length
267
267
  ? existing.files
268
268
  : discoverTrackedFiles(repoRoot, readSettings().discover)
269
+ announceLocalEnvFiles(repoRoot, { discovering: !existing?.files?.length, covered: files })
269
270
  const path = writeLink(repoRoot, {
270
271
  project: name,
271
272
  files,
@@ -332,6 +333,7 @@ export async function link(flags, args) {
332
333
  const files = existing?.files?.length
333
334
  ? existing.files
334
335
  : discoverTrackedFiles(repoRoot, readSettings().discover)
336
+ announceLocalEnvFiles(repoRoot, { discovering: !existing?.files?.length, covered: files })
335
337
  const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
336
338
 
337
339
  out()
@@ -10,6 +10,7 @@ import {
10
10
  import { serializeEnv } from '../env-file.mjs'
11
11
  import { writeSecretFile } from '../local-write.mjs'
12
12
  import { compareProject, FILE_STATE } from '../status.mjs'
13
+ import { recordSyncedFiles } from '../sync-record.mjs'
13
14
  import { CliError, out, pluralize, style } from '../ui.mjs'
14
15
 
15
16
  /**
@@ -137,6 +138,13 @@ export async function pull(flags, args) {
137
138
 
138
139
  out()
139
140
 
141
+ // Every file compared is now on both sides — written, already there, or
142
+ // skipped because it was there with changes — so a later `envs sync` may
143
+ // treat its disappearance from disk as a deletion.
144
+ if (!flags['dry-run']) {
145
+ recordSyncedFiles(context, scope, { synced: comparison.map(file => file.name) })
146
+ }
147
+
140
148
  if (skipped > 0) {
141
149
  out(
142
150
  ` ${style.dim('Skipped')} ${pluralize(skipped, 'file')} ${style.dim('with local changes.')}`
@@ -13,11 +13,14 @@ import {
13
13
  ensureSecretDirIgnored,
14
14
  ENV_FILES_TARGET,
15
15
  lacksIgnoreRule,
16
+ LINK_FILE,
17
+ localEnvFiles,
16
18
  SECRET_FILE_DIR,
17
19
  } from '../project.mjs'
18
20
  import { resolveRepoFile } from '../repo-path.mjs'
19
21
  import { readSettings } from '../settings.mjs'
20
22
  import { compareProject, FILE_STATE } from '../status.mjs'
23
+ import { recordSyncedFiles } from '../sync-record.mjs'
21
24
  import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
22
25
 
23
26
  /**
@@ -62,6 +65,39 @@ export function announceDiscoveryDirs(repoRoot, { discovering = true } = {}) {
62
65
  }
63
66
  }
64
67
 
68
+ /**
69
+ * Say which `.local` files the dotenv scan left behind, and how to list one.
70
+ *
71
+ * The scan skips them by name: `.local` is the per-checkout override layer, not
72
+ * the shared values. A developer whose secrets live in `.env.local` would
73
+ * otherwise see it silently missing from the list a command just decided.
74
+ *
75
+ * `covered` is that list. A `.local` file already in the cloud — pushed by an
76
+ * older version, or by name — is still synced through the cloud listing until
77
+ * `envs rm`, and saying otherwise would be false. Only when discovery decided
78
+ * the list (`discovering`) and the scan is on is there anything to point out.
79
+ */
80
+ export function announceLocalEnvFiles(repoRoot, { discovering = true, covered = [] } = {}) {
81
+ if (!discovering || !readSettings().discover.includes(ENV_FILES_TARGET)) {
82
+ return
83
+ }
84
+
85
+ const left = localEnvFiles(repoRoot).filter(name => !covered.includes(name))
86
+
87
+ if (left.length === 0) {
88
+ return
89
+ }
90
+
91
+ out()
92
+
93
+ for (const name of left) {
94
+ out(
95
+ ` ${style.dim('·')} ${style.bold(name)} is a local override, so it is not synced ` +
96
+ style.dim(`— list it in ${LINK_FILE} to sync it`)
97
+ )
98
+ }
99
+ }
100
+
65
101
  /**
66
102
  * `envs push` — send local files up.
67
103
  *
@@ -106,6 +142,11 @@ export async function push(flags, args) {
106
142
  requested: args,
107
143
  })
108
144
 
145
+ announceLocalEnvFiles(context.repoRoot, {
146
+ discovering: args.length === 0 && !context.link?.files?.length,
147
+ covered: names,
148
+ })
149
+
109
150
  const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
110
151
  const presentNames = names.filter(name => localFiles.get(name) !== null)
111
152
 
@@ -209,6 +250,13 @@ export async function push(flags, args) {
209
250
 
210
251
  out()
211
252
 
253
+ // Every file compared is on both sides once the loop is through — sent up
254
+ // just now, or already there — so a later `envs sync` may treat its
255
+ // disappearance from disk as a deletion.
256
+ if (!flags['dry-run']) {
257
+ recordSyncedFiles(context, scope, { synced: comparison.map(file => file.name) })
258
+ }
259
+
212
260
  if (!flags['dry-run'] && pushed > 0) {
213
261
  // The shared scope's internal name is not for printing — say what it means.
214
262
  const destination = shared ? 'your shared files' : context.project.name
@@ -1,5 +1,6 @@
1
1
  import { api, ApiError } from '../api.mjs'
2
2
  import { resolveContext, SHARED_SCOPE } from '../context.mjs'
3
+ import { recordSyncedFiles } from '../sync-record.mjs'
3
4
  import { CliError, out, pluralize, style } from '../ui.mjs'
4
5
 
5
6
  /**
@@ -58,6 +59,13 @@ export async function remove(flags, args) {
58
59
 
59
60
  out()
60
61
 
62
+ // Forget them, whether or not the cloud still had them: a file by the same
63
+ // name that a teammate adds later is new to this machine, not deleted here,
64
+ // and `envs sync` must pull it rather than remove it again.
65
+ if (!flags['dry-run']) {
66
+ recordSyncedFiles(context, scope, { forgotten: [...new Set(args)] })
67
+ }
68
+
61
69
  if (!flags['dry-run'] && removed > 0) {
62
70
  const source = shared ? 'your shared files' : context.project.name
63
71
  out(
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
 
3
- import { api } from '../api.mjs'
3
+ import { api, ApiError } from '../api.mjs'
4
4
  import { formatSize } from '../blob.mjs'
5
5
  import {
6
6
  cloudKinds,
@@ -13,9 +13,16 @@ import { entriesToMap, mergeEnvText, serializeEnv } from '../env-file.mjs'
13
13
  import { writeSecretFile } from '../local-write.mjs'
14
14
  import { resolveRepoFile } from '../repo-path.mjs'
15
15
  import { compareProject } from '../status.mjs'
16
+ import { readSyncedFiles, recordSyncedFiles } from '../sync-record.mjs'
16
17
  import { conflictExitCode, isBlocked, isChanging, planSync, PREFER, SYNC_ACTION } from '../sync.mjs'
17
18
  import { CliError, out, pad, pluralize, sanitize, style } from '../ui.mjs'
18
- import { announceDiscoveryDirs, confirmNewFiles, fetchCloudFiles, pushOneBlob } from './push.mjs'
19
+ import {
20
+ announceDiscoveryDirs,
21
+ announceLocalEnvFiles,
22
+ confirmNewFiles,
23
+ fetchCloudFiles,
24
+ pushOneBlob,
25
+ } from './push.mjs'
19
26
 
20
27
  /**
21
28
  * `envs sync` — reconcile this repo with the cloud, in both directions at once.
@@ -32,9 +39,15 @@ import { announceDiscoveryDirs, confirmNewFiles, fetchCloudFiles, pushOneBlob }
32
39
  * copies are left untouched, and the command exits 1. `--prefer local` or
33
40
  * `--prefer cloud` resolves them in one direction, as a separate deliberate act.
34
41
  *
35
- * Nothing is ever deleted: sync never prunes, in either direction. A key that
42
+ * No key is ever deleted: sync never prunes, in either direction. A key that
36
43
  * exists on one side only is copied rather than removed, so the worst a wrong
37
44
  * guess costs is a key coming back — never one that exists nowhere any more.
45
+ *
46
+ * Whole files are different, because for them there is a record: every sync,
47
+ * pull and push writes down which files this machine had on both sides
48
+ * afterwards. A file on that record that is now gone from disk was deleted
49
+ * here, and sync removes it from the cloud. One not on the record is pulled,
50
+ * as before — a fresh clone has no record and so never deletes anything.
38
51
  */
39
52
  export async function sync(flags, args) {
40
53
  const shared = Boolean(flags.shared)
@@ -70,6 +83,11 @@ export async function sync(flags, args) {
70
83
  sink: 'write',
71
84
  })
72
85
 
86
+ announceLocalEnvFiles(context.repoRoot, {
87
+ discovering: args.length === 0 && !context.link?.files?.length,
88
+ covered: names,
89
+ })
90
+
73
91
  const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
74
92
  const comparison = compareProject({
75
93
  cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
@@ -83,7 +101,7 @@ export async function sync(flags, args) {
83
101
  return 0
84
102
  }
85
103
 
86
- const plans = planSync(comparison, { prefer })
104
+ const plans = planSync(comparison, { prefer, synced: readSyncedFiles(context, scope) })
87
105
  const byName = new Map(comparison.map(file => [file.name, file]))
88
106
 
89
107
  // The same listing — and, when `confirmNewFiles` is set, the same gate —
@@ -100,7 +118,7 @@ export async function sync(flags, args) {
100
118
  out()
101
119
 
102
120
  const width = Math.max(...plans.map(plan => plan.name.length)) + 2
103
- const totals = { pulled: 0, pushed: 0, files: 0 }
121
+ const totals = { pulled: 0, pushed: 0, removed: 0, files: 0 }
104
122
 
105
123
  for (const plan of plans) {
106
124
  const applied = await applyPlan(flags, context, scope, plan, byName.get(plan.name), localFiles)
@@ -110,10 +128,23 @@ export async function sync(flags, args) {
110
128
  if (applied.changed) {
111
129
  totals.pulled += applied.pulled
112
130
  totals.pushed += applied.pushed
131
+ totals.removed += plan.action === SYNC_ACTION.DELETE ? 1 : 0
113
132
  totals.files += 1
114
133
  }
115
134
  }
116
135
 
136
+ // Every plan but a deletion leaves the file on both sides — a conflict
137
+ // included, since both copies stay — so that is what the next run may treat
138
+ // as "deleted here" if it goes missing. Not under --dry-run: nothing moved.
139
+ if (!flags['dry-run']) {
140
+ const removed = plans.filter(plan => plan.action === SYNC_ACTION.DELETE)
141
+
142
+ recordSyncedFiles(context, scope, {
143
+ synced: plans.filter(plan => !removed.includes(plan)).map(plan => plan.name),
144
+ forgotten: removed.map(plan => plan.name),
145
+ })
146
+ }
147
+
117
148
  out()
118
149
  renderSummary(flags, plans, totals, prefer)
119
150
 
@@ -164,6 +195,10 @@ async function applyPlan(flags, context, scope, plan, file, localFiles) {
164
195
  return await pushWholeFile(flags, context, scope, plan, file, localFiles.get(plan.name))
165
196
  }
166
197
 
198
+ if (plan.action === SYNC_ACTION.DELETE) {
199
+ return await deleteWholeFile(flags, context, scope, plan)
200
+ }
201
+
167
202
  return await mergeOneFile(flags, context, scope, plan, localFiles.get(plan.name) ?? [])
168
203
  }
169
204
 
@@ -189,6 +224,32 @@ async function pullWholeFile(flags, context, scope, plan) {
189
224
  return { changed: true, pulled: count, pushed: 0, detail }
190
225
  }
191
226
 
227
+ /**
228
+ * A file this machine had and the developer deleted: the cloud copy goes too.
229
+ *
230
+ * "Already gone" is a state, not a failure, exactly as `envs rm` treats it —
231
+ * a teammate may have removed it first. Either way the file is absent on both
232
+ * sides afterwards, which is what the record is then told.
233
+ */
234
+ async function deleteWholeFile(flags, context, scope, plan) {
235
+ if (!flags['dry-run']) {
236
+ try {
237
+ await api.deleteFile(context.host, context.token, scope, plan.name)
238
+ } catch (error) {
239
+ if (!(error instanceof ApiError && error.status === 404)) {
240
+ throw error
241
+ }
242
+ }
243
+ }
244
+
245
+ return {
246
+ changed: true,
247
+ pulled: 0,
248
+ pushed: 0,
249
+ detail: 'removed from the cloud — deleted here since the last sync',
250
+ }
251
+ }
252
+
192
253
  /** A dotenv file's text on disk, sent with its keys as the server's layout —
193
254
  * or nothing, for a file that is not there to read. */
194
255
  function localText(context, name) {
@@ -313,7 +374,11 @@ function render(flags, plan, applied, width) {
313
374
  const name = pad(plan.name, width)
314
375
 
315
376
  if (applied.detail) {
316
- const mark = flags['dry-run'] ? style.dim('·') : style.green('✓')
377
+ const mark = flags['dry-run']
378
+ ? style.dim('·')
379
+ : plan.action === SYNC_ACTION.DELETE
380
+ ? style.red('✗')
381
+ : style.green('✓')
317
382
  const verb = flags['dry-run'] ? 'would be ' : ''
318
383
  // Named on the same line as the work that did happen, so a merge that was
319
384
  // three-quarters successful does not read as wholly successful.
@@ -366,6 +431,7 @@ function renderSummary(flags, plans, totals, prefer) {
366
431
  const moved = [
367
432
  totals.pulled > 0 ? `${pluralize(totals.pulled, 'key')} down` : null,
368
433
  totals.pushed > 0 ? `${pluralize(totals.pushed, 'key')} up` : null,
434
+ totals.removed > 0 ? `${pluralize(totals.removed, 'file')} removed from the cloud` : null,
369
435
  ].filter(Boolean)
370
436
 
371
437
  const summary = `${pluralize(totals.files, 'file')}${moved.length > 0 ? ` · ${moved.join(', ')}` : ''}`
package/src/index.mjs CHANGED
@@ -359,9 +359,13 @@ const COMMAND_HELP = {
359
359
  sync: [
360
360
  'Reconcile this repo and the cloud in both directions, in one command.',
361
361
  '',
362
- 'Every file and every key that exists on one side only is copied to the',
363
- 'other. Nothing is ever deleted — sync does not prune in either direction —',
364
- 'so the worst a wrong guess costs is a key coming back, never one lost.',
362
+ 'Every key that exists on one side only is copied to the other. No key is',
363
+ 'ever pruned, so the worst a wrong guess costs is a key coming back, never',
364
+ 'one lost.',
365
+ '',
366
+ 'Whole files follow your deletions: a file this machine has synced before',
367
+ 'and you have since deleted is removed from the cloud. A cloud file this',
368
+ 'machine has never had is pulled, so a fresh clone never deletes anything.',
365
369
  '',
366
370
  'A key that exists on both sides with different values is a conflict: there',
367
371
  'is no record of the last sync, so nothing here knows which side moved. Both',
@@ -444,8 +448,8 @@ const COMMAND_HELP = {
444
448
  '',
445
449
  ' { "discover": [".env*", ".config", ".agents"] }',
446
450
  '',
447
- '".env*" is the dotenv files in the repo root; anything else is a directory',
448
- 'whose git-ignored files are synced.',
451
+ '".env*" is the dotenv files in the repo root, minus templates and .local',
452
+ 'overrides; anything else is a directory whose git-ignored files are synced.',
449
453
  '',
450
454
  ' envs config',
451
455
  ' envs config --json',
package/src/project.mjs CHANGED
@@ -57,6 +57,17 @@ function runGit(args, { cwd, input } = {}) {
57
57
  */
58
58
  const TEMPLATE_SUFFIXES = ['.example', '.sample', '.template', '.dist']
59
59
 
60
+ /**
61
+ * The per-checkout override layer. Every dotenv loader that layers files agrees
62
+ * on the stack — `.env`, `.env.<mode>`, `.env.local`, `.env.<mode>.local`, then
63
+ * the real environment — and `.local` is, by name, what a developer writes to
64
+ * differ from the shared values on this one checkout. A tool that distributes
65
+ * the shared values fills the lower layers and leaves this one alone: syncing
66
+ * it would leave no layer that can differ between machines. Listing the file in
67
+ * the link file or naming it on the command line still syncs it.
68
+ */
69
+ const LOCAL_SUFFIX = '.local'
70
+
60
71
  /**
61
72
  * Walk up from `startDir` looking for a repo root.
62
73
  *
@@ -165,13 +176,14 @@ export function guessProjectName(repoRoot) {
165
176
  }
166
177
 
167
178
  /**
168
- * Discover `.env*` files in the repo root.
179
+ * The dotenv files in the repo root that could hold a real value: `.env` and
180
+ * `.env.*`, minus templates and type declarations, regular files only.
169
181
  *
170
182
  * Deliberately not recursive: walking a whole tree would wander into
171
183
  * node_modules and vendored fixtures, and a monorepo's nested files are better
172
184
  * declared explicitly in the link file than guessed at.
173
185
  */
174
- export function discoverEnvFiles(repoRoot) {
186
+ function rootEnvFiles(repoRoot) {
175
187
  let names
176
188
 
177
189
  try {
@@ -196,6 +208,19 @@ export function discoverEnvFiles(repoRoot) {
196
208
  .sort()
197
209
  }
198
210
 
211
+ /** Discover `.env*` files in the repo root — the shared layers, not `.local`. */
212
+ export function discoverEnvFiles(repoRoot) {
213
+ return rootEnvFiles(repoRoot).filter(name => !name.endsWith(LOCAL_SUFFIX))
214
+ }
215
+
216
+ /**
217
+ * The `.local` files discovery left behind, so a command can say why they are
218
+ * missing from a list it just decided.
219
+ */
220
+ export function localEnvFiles(repoRoot) {
221
+ return rootEnvFiles(repoRoot).filter(name => name.endsWith(LOCAL_SUFFIX))
222
+ }
223
+
199
224
  /**
200
225
  * Where credential *files* live, by convention: a service-account JSON, a cert, a
201
226
  * private key — the things that are secrets but are not `KEY=value`.
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from './atomic-write.mjs'
5
+ import { configDir } from './config.mjs'
6
+
7
+ /**
8
+ * Which files this machine has had on both sides — the record `envs sync`
9
+ * needs to tell a deletion from a file it has simply never pulled.
10
+ *
11
+ * Without it, a file that exists only in the cloud is ambiguous: either this
12
+ * machine never had it, or it had it and the developer deleted it. The first
13
+ * wants a pull and the second wants the cloud to follow, and the comparison
14
+ * alone cannot say which. So every command that leaves a file present on both
15
+ * sides writes the name down here, and sync reads the note back: a cloud-only
16
+ * file that is on it was deleted here, and sync removes it from the cloud.
17
+ *
18
+ * Per machine, not per repo: it lives beside the credentials rather than in
19
+ * `.envstash.json`, because "this checkout once had the file" is a fact about
20
+ * this checkout, and a link file travels with the clone. It is keyed by host,
21
+ * scope and repo path so a second clone of the same project starts with no
22
+ * record and pulls — the conservative guess — rather than inheriting the
23
+ * first clone's history.
24
+ *
25
+ * Names only, never values, so the file is as sensitive as the link file. It
26
+ * is still written `0600` in the `0700` config directory, like everything else
27
+ * there.
28
+ */
29
+ export function syncRecordPath() {
30
+ return join(configDir(), 'synced.json')
31
+ }
32
+
33
+ /** The names this machine has synced for one host, scope and repo. */
34
+ export function readSyncedFiles(context, scope) {
35
+ const entry = readRecord()[recordKey(context, scope)]
36
+
37
+ if (!entry || typeof entry !== 'object') {
38
+ return new Set()
39
+ }
40
+
41
+ return new Set(Object.keys(entry))
42
+ }
43
+
44
+ /**
45
+ * Note that `synced` are now on both sides, and `forgotten` are not.
46
+ *
47
+ * Forgetting matters as much as remembering: a file `envs rm` took out of the
48
+ * cloud, or sync removed, must not be treated as "deleted here" the next time
49
+ * a teammate adds a file by the same name.
50
+ */
51
+ export function recordSyncedFiles(context, scope, { synced = [], forgotten = [] }) {
52
+ const record = readRecord()
53
+ const key = recordKey(context, scope)
54
+ const entry = { ...(record[key] ?? {}) }
55
+ const now = new Date().toISOString()
56
+
57
+ for (const name of synced) {
58
+ entry[name] = now
59
+ }
60
+
61
+ for (const name of forgotten) {
62
+ delete entry[name]
63
+ }
64
+
65
+ if (Object.keys(entry).length === 0) {
66
+ delete record[key]
67
+ } else {
68
+ record[key] = entry
69
+ }
70
+
71
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 })
72
+ writeFileAtomic(syncRecordPath(), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
73
+ }
74
+
75
+ function recordKey(context, scope) {
76
+ return `${context.host} ${scope} ${context.repoRoot}`
77
+ }
78
+
79
+ /**
80
+ * A record that cannot be read counts as empty. Empty is the safe reading —
81
+ * every cloud-only file becomes a pull — and a corrupt note about past syncs
82
+ * is not worth failing a sync over.
83
+ */
84
+ function readRecord() {
85
+ const path = syncRecordPath()
86
+
87
+ if (!existsSync(path)) {
88
+ return {}
89
+ }
90
+
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
93
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
94
+ } catch {
95
+ return {}
96
+ }
97
+ }
package/src/sync.mjs CHANGED
@@ -17,6 +17,8 @@ export const SYNC_ACTION = {
17
17
  MERGE: 'merge',
18
18
  /** An opaque file that differs. Bytes do not merge — a human has to choose. */
19
19
  CONFLICT: 'conflict',
20
+ /** This machine had it and no longer does — remove it from the cloud. */
21
+ DELETE: 'delete',
20
22
  }
21
23
 
22
24
  /** `--prefer` values. */
@@ -27,14 +29,20 @@ export const PREFER = { LOCAL: 'local', CLOUD: 'cloud' }
27
29
  *
28
30
  * The rule, and the reason for it:
29
31
  *
30
- * There is no record of the last sync, so "which side moved" is unknowable —
31
- * the same fact that makes `envs status` say `local_only` and `cloud_only`
32
- * rather than "added" and "deleted". Sync therefore performs the one merge
33
- * that cannot lose anything: every key that exists on one side only is copied
34
- * to the other, in whichever direction that implies, and nothing is ever
35
- * deleted or pruned. When the guess is wrong, the cost is a key coming back
36
- * that a teammate had removed — visible in the next `envs status`, and fixed
37
- * with `envs rm` — rather than a secret that exists nowhere any more.
32
+ * There is no record of the last sync at key level, so "which side moved" is
33
+ * unknowable — the same fact that makes `envs status` say `local_only` and
34
+ * `cloud_only` rather than "added" and "deleted". Sync therefore performs the
35
+ * one merge that cannot lose anything: every key that exists on one side only
36
+ * is copied to the other, in whichever direction that implies, and no key is
37
+ * ever pruned. When the guess is wrong, the cost is a key coming back that a
38
+ * teammate had removed — visible in the next `envs status`, and fixed with
39
+ * `envs rm` — rather than a secret that exists nowhere any more.
40
+ *
41
+ * Whole files are the one place a record does exist. `synced` names the files
42
+ * this machine has had on both sides (see sync-record.mjs), so a cloud-only
43
+ * file on that list is not one to pull back: it was deleted here, and the
44
+ * cloud copy is removed to match. A cloud-only file not on the list is still
45
+ * pulled — this machine may simply never have had it.
38
46
  *
39
47
  * A key that exists on both sides with different values is the one case where
40
48
  * copying would destroy something, so sync refuses to guess: it is reported as
@@ -42,11 +50,11 @@ export const PREFER = { LOCAL: 'local', CLOUD: 'cloud' }
42
50
  * `--prefer cloud` is how a developer says which side wins, and it is
43
51
  * deliberately a separate, typed-out act.
44
52
  */
45
- export function planSync(files, { prefer } = {}) {
46
- return files.map(file => planFile(file, prefer))
53
+ export function planSync(files, { prefer, synced = new Set() } = {}) {
54
+ return files.map(file => planFile(file, prefer, synced))
47
55
  }
48
56
 
49
- function planFile(file, prefer) {
57
+ function planFile(file, prefer, synced) {
50
58
  const plan = {
51
59
  name: file.name,
52
60
  kind: file.kind,
@@ -64,7 +72,7 @@ function planFile(file, prefer) {
64
72
  }
65
73
 
66
74
  if (file.state === FILE_STATE.CLOUD_ONLY) {
67
- return { ...plan, action: SYNC_ACTION.PULL }
75
+ return { ...plan, action: synced.has(file.name) ? SYNC_ACTION.DELETE : SYNC_ACTION.PULL }
68
76
  }
69
77
 
70
78
  if (file.state === FILE_STATE.UNTRACKED) {
@@ -117,6 +125,7 @@ export function isChanging(plan) {
117
125
  return (
118
126
  plan.action === SYNC_ACTION.PULL ||
119
127
  plan.action === SYNC_ACTION.PUSH ||
128
+ plan.action === SYNC_ACTION.DELETE ||
120
129
  plan.upload.length > 0 ||
121
130
  plan.download.length > 0
122
131
  )