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 +41 -18
- package/bin/envs.mjs +0 -0
- package/bin/envstash.mjs +0 -0
- package/package.json +6 -5
- package/src/commands/misc.mjs +3 -1
- package/src/commands/pull.mjs +8 -0
- package/src/commands/push.mjs +48 -0
- package/src/commands/remove.mjs +8 -0
- package/src/commands/sync.mjs +72 -6
- package/src/index.mjs +9 -5
- package/src/project.mjs +27 -2
- 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
|
|
@@ -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
|
|
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.
|
|
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/misc.mjs
CHANGED
|
@@ -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()
|
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
|
@@ -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
|
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,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 {
|
|
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
|
-
*
|
|
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']
|
|
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
|
|
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',
|
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
)
|