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 +30 -17
- package/bin/envs.mjs +0 -0
- package/bin/envstash.mjs +0 -0
- package/package.json +6 -5
- package/src/commands/pull.mjs +8 -0
- package/src/commands/push.mjs +8 -0
- package/src/commands/remove.mjs +8 -0
- package/src/commands/sync.mjs +60 -5
- package/src/index.mjs +7 -3
- package/src/sync-record.mjs +97 -0
- package/src/sync.mjs +21 -12
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
|
|
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
|
|
175
|
-
|
|
|
176
|
-
| File on disk, not in the cloud
|
|
177
|
-
| File in the cloud, not on disk
|
|
178
|
-
|
|
|
179
|
-
| Key on
|
|
180
|
-
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
failure mode is a secret that
|
|
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.
|
|
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
|
+
}
|
package/src/commands/pull.mjs
CHANGED
|
@@ -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.')}`
|
package/src/commands/push.mjs
CHANGED
|
@@ -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
|
package/src/commands/remove.mjs
CHANGED
|
@@ -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(
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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']
|
|
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
|
|
363
|
-
'
|
|
364
|
-
'
|
|
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
|
|
31
|
-
* the same fact that makes `envs status` say `local_only` and
|
|
32
|
-
* rather than "added" and "deleted". Sync therefore performs the
|
|
33
|
-
* that cannot lose anything: every key that exists on one side only
|
|
34
|
-
* to the other, in whichever direction that implies, and
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
)
|