envstash-cli 0.11.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
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.11.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
+ }
@@ -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.')}`
@@ -20,6 +20,7 @@ import {
20
20
  import { resolveRepoFile } from '../repo-path.mjs'
21
21
  import { readSettings } from '../settings.mjs'
22
22
  import { compareProject, FILE_STATE } from '../status.mjs'
23
+ import { recordSyncedFiles } from '../sync-record.mjs'
23
24
  import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
24
25
 
25
26
  /**
@@ -249,6 +250,13 @@ export async function push(flags, args) {
249
250
 
250
251
  out()
251
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
+
252
260
  if (!flags['dry-run'] && pushed > 0) {
253
261
  // The shared scope's internal name is not for printing — say what it means.
254
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,6 +13,7 @@ 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
19
  import {
@@ -38,9 +39,15 @@ import {
38
39
  * copies are left untouched, and the command exits 1. `--prefer local` or
39
40
  * `--prefer cloud` resolves them in one direction, as a separate deliberate act.
40
41
  *
41
- * 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
42
43
  * exists on one side only is copied rather than removed, so the worst a wrong
43
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.
44
51
  */
45
52
  export async function sync(flags, args) {
46
53
  const shared = Boolean(flags.shared)
@@ -94,7 +101,7 @@ export async function sync(flags, args) {
94
101
  return 0
95
102
  }
96
103
 
97
- const plans = planSync(comparison, { prefer })
104
+ const plans = planSync(comparison, { prefer, synced: readSyncedFiles(context, scope) })
98
105
  const byName = new Map(comparison.map(file => [file.name, file]))
99
106
 
100
107
  // The same listing — and, when `confirmNewFiles` is set, the same gate —
@@ -111,7 +118,7 @@ export async function sync(flags, args) {
111
118
  out()
112
119
 
113
120
  const width = Math.max(...plans.map(plan => plan.name.length)) + 2
114
- const totals = { pulled: 0, pushed: 0, files: 0 }
121
+ const totals = { pulled: 0, pushed: 0, removed: 0, files: 0 }
115
122
 
116
123
  for (const plan of plans) {
117
124
  const applied = await applyPlan(flags, context, scope, plan, byName.get(plan.name), localFiles)
@@ -121,10 +128,23 @@ export async function sync(flags, args) {
121
128
  if (applied.changed) {
122
129
  totals.pulled += applied.pulled
123
130
  totals.pushed += applied.pushed
131
+ totals.removed += plan.action === SYNC_ACTION.DELETE ? 1 : 0
124
132
  totals.files += 1
125
133
  }
126
134
  }
127
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
+
128
148
  out()
129
149
  renderSummary(flags, plans, totals, prefer)
130
150
 
@@ -175,6 +195,10 @@ async function applyPlan(flags, context, scope, plan, file, localFiles) {
175
195
  return await pushWholeFile(flags, context, scope, plan, file, localFiles.get(plan.name))
176
196
  }
177
197
 
198
+ if (plan.action === SYNC_ACTION.DELETE) {
199
+ return await deleteWholeFile(flags, context, scope, plan)
200
+ }
201
+
178
202
  return await mergeOneFile(flags, context, scope, plan, localFiles.get(plan.name) ?? [])
179
203
  }
180
204
 
@@ -200,6 +224,32 @@ async function pullWholeFile(flags, context, scope, plan) {
200
224
  return { changed: true, pulled: count, pushed: 0, detail }
201
225
  }
202
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
+
203
253
  /** A dotenv file's text on disk, sent with its keys as the server's layout —
204
254
  * or nothing, for a file that is not there to read. */
205
255
  function localText(context, name) {
@@ -324,7 +374,11 @@ function render(flags, plan, applied, width) {
324
374
  const name = pad(plan.name, width)
325
375
 
326
376
  if (applied.detail) {
327
- 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('✓')
328
382
  const verb = flags['dry-run'] ? 'would be ' : ''
329
383
  // Named on the same line as the work that did happen, so a merge that was
330
384
  // three-quarters successful does not read as wholly successful.
@@ -377,6 +431,7 @@ function renderSummary(flags, plans, totals, prefer) {
377
431
  const moved = [
378
432
  totals.pulled > 0 ? `${pluralize(totals.pulled, 'key')} down` : null,
379
433
  totals.pushed > 0 ? `${pluralize(totals.pushed, 'key')} up` : null,
434
+ totals.removed > 0 ? `${pluralize(totals.removed, 'file')} removed from the cloud` : null,
380
435
  ].filter(Boolean)
381
436
 
382
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',
@@ -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
  )