envstash-cli 0.5.2 → 0.6.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 +123 -20
- package/package.json +1 -1
- package/src/api.mjs +4 -2
- package/src/atomic-write.mjs +112 -0
- package/src/commands/diff.mjs +16 -5
- package/src/commands/login.mjs +29 -5
- package/src/commands/misc.mjs +8 -6
- package/src/commands/pull.mjs +3 -13
- package/src/commands/push.mjs +101 -9
- package/src/commands/status.mjs +4 -2
- package/src/commands/sync.mjs +371 -0
- package/src/config.mjs +53 -10
- package/src/context.mjs +43 -6
- package/src/env-file.mjs +100 -19
- package/src/index.mjs +42 -9
- package/src/local-write.mjs +34 -0
- package/src/manifest.mjs +1 -0
- package/src/project.mjs +38 -7
- package/src/repo-path.mjs +103 -1
- package/src/session.mjs +10 -5
- package/src/status.mjs +10 -2
- package/src/sync.mjs +137 -0
- package/src/ui.mjs +84 -5
package/src/commands/push.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
import { api, ApiError } from '../api.mjs'
|
|
2
4
|
import { formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
|
|
3
5
|
import {
|
|
@@ -8,8 +10,9 @@ import {
|
|
|
8
10
|
SHARED_SCOPE,
|
|
9
11
|
} from '../context.mjs'
|
|
10
12
|
import { ensureSecretDirIgnored, SECRET_FILE_DIR } from '../project.mjs'
|
|
13
|
+
import { resolveRepoFile } from '../repo-path.mjs'
|
|
11
14
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
12
|
-
import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
15
|
+
import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
|
|
13
16
|
|
|
14
17
|
/** Say that .gitignore was edited — quietly rewriting a user's file is worse. */
|
|
15
18
|
export function announceIgnoredSecretDir(repoRoot) {
|
|
@@ -80,6 +83,8 @@ export async function push(flags, args) {
|
|
|
80
83
|
localFiles: new Map(presentNames.map(name => [name, localFiles.get(name)])),
|
|
81
84
|
})
|
|
82
85
|
|
|
86
|
+
await confirmNewFiles(flags, context, args, comparison)
|
|
87
|
+
|
|
83
88
|
out()
|
|
84
89
|
|
|
85
90
|
let pushed = 0
|
|
@@ -121,16 +126,22 @@ export async function push(flags, args) {
|
|
|
121
126
|
|
|
122
127
|
const parts = []
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
// Numbers, not whatever the server sent: these are interpolated straight
|
|
130
|
+
// into the terminal.
|
|
131
|
+
const created = Number(result.created) || 0
|
|
132
|
+
const updated = Number(result.updated) || 0
|
|
133
|
+
const deleted = Number(result.deleted) || 0
|
|
134
|
+
|
|
135
|
+
if (created) {
|
|
136
|
+
parts.push(style.green(`${created} new`))
|
|
126
137
|
}
|
|
127
138
|
|
|
128
|
-
if (
|
|
129
|
-
parts.push(style.yellow(`${
|
|
139
|
+
if (updated) {
|
|
140
|
+
parts.push(style.yellow(`${updated} changed`))
|
|
130
141
|
}
|
|
131
142
|
|
|
132
|
-
if (
|
|
133
|
-
parts.push(style.red(`${
|
|
143
|
+
if (deleted) {
|
|
144
|
+
parts.push(style.red(`${deleted} deleted`))
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
if (parts.length === 0) {
|
|
@@ -160,12 +171,93 @@ export async function push(flags, args) {
|
|
|
160
171
|
return 0
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Show — and for discovered files, ask about — every file this push would
|
|
176
|
+
* create in the cloud, before the first byte goes up.
|
|
177
|
+
*
|
|
178
|
+
* Discovery sweeps root `.env*` files and everything git-ignored under
|
|
179
|
+
* `.config/`, recursively, and used to upload the lot and only then list it. A
|
|
180
|
+
* gcloud token cache or a stray SQLite file was in the cloud before the
|
|
181
|
+
* developer saw its name. A file already in the cloud, named on the command
|
|
182
|
+
* line, or listed in `.envstash.json` was chosen by someone; a file discovery
|
|
183
|
+
* found for the first time was not, so that is the set that needs a yes.
|
|
184
|
+
*
|
|
185
|
+
* Asked on a terminal; refused without `--yes` anywhere else, since a CI job
|
|
186
|
+
* cannot answer and silently uploading is the thing being fixed.
|
|
187
|
+
*
|
|
188
|
+
* `envs sync` uploads new files too, and runs the same gate — `command` only
|
|
189
|
+
* changes which command name the messages tell the developer to re-run.
|
|
190
|
+
*/
|
|
191
|
+
export async function confirmNewFiles(flags, context, args, comparison, { command = 'push' } = {}) {
|
|
192
|
+
const created = comparison.filter(file => file.state === FILE_STATE.UNTRACKED)
|
|
193
|
+
|
|
194
|
+
if (created.length === 0) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const chosen = new Set([...args, ...(context.link?.files ?? [])])
|
|
199
|
+
const discovered = created.filter(file => !chosen.has(file.name))
|
|
200
|
+
const width = Math.max(...created.map(file => file.name.length)) + 2
|
|
201
|
+
|
|
202
|
+
out()
|
|
203
|
+
out(` ${style.bold(`New in the cloud — ${pluralize(created.length, 'file')}:`)}`)
|
|
204
|
+
|
|
205
|
+
for (const file of created) {
|
|
206
|
+
const note = discovered.includes(file) ? style.dim(' (discovered)') : ''
|
|
207
|
+
out(
|
|
208
|
+
` ${style.green('+')} ${pad(file.name, width)}${style.dim(describeNew(context, file))}${note}`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (discovered.length === 0 || flags['dry-run'] || flags.yes || flags.y) {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const count = pluralize(discovered.length, 'discovered file')
|
|
217
|
+
|
|
218
|
+
if (isInteractive()) {
|
|
219
|
+
out()
|
|
220
|
+
|
|
221
|
+
if (await confirm(` Upload ${count}? [y/N] `)) {
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
throw new CliError(`${titleCase(command)} cancelled — nothing was uploaded.`, {
|
|
226
|
+
hint: `Name the files you want (\`envs ${command} .env.local\`), or list them in .envstash.json.`,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
throw new CliError(
|
|
231
|
+
`Not uploading ${count} without confirmation: ${discovered.map(file => file.name).join(', ')}.`,
|
|
232
|
+
{
|
|
233
|
+
hint: `Re-run with --yes to upload them, name the ones you want (\`envs ${command} .env.local\`), or list them in .envstash.json.`,
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function titleCase(word) {
|
|
239
|
+
return word.charAt(0).toUpperCase() + word.slice(1)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** "3 keys, 120 B" or "file, 2.1 KB" — what a new file would put in the cloud. */
|
|
243
|
+
function describeNew(context, file) {
|
|
244
|
+
let size = ''
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
size = `, ${formatSize(statSync(resolveRepoFile(context.repoRoot, file.name)).size)}`
|
|
248
|
+
} catch {
|
|
249
|
+
// Read a moment ago, so this only fails if it vanished; the name is enough.
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return file.kind === 'file' ? `file${size}` : `${pluralize(file.counts.local_only, 'key')}${size}`
|
|
253
|
+
}
|
|
254
|
+
|
|
163
255
|
/**
|
|
164
256
|
* Push one opaque file. Whole-file semantics: no prune, no per-key counts —
|
|
165
257
|
* the report is created / updated / unchanged, with the size, because "what
|
|
166
258
|
* went up" for a blob is the file itself.
|
|
167
259
|
*/
|
|
168
|
-
async function pushOneBlob(flags, context, scope, file, local) {
|
|
260
|
+
export async function pushOneBlob(flags, context, scope, file, local) {
|
|
169
261
|
if (local.bytes.length > MAX_BLOB_BYTES) {
|
|
170
262
|
throw new CliError(
|
|
171
263
|
`${file.name} is ${formatSize(local.bytes.length)} — files are capped at ${formatSize(MAX_BLOB_BYTES)}.`,
|
|
@@ -201,7 +293,7 @@ async function pushOneBlob(flags, context, scope, file, local) {
|
|
|
201
293
|
return 1
|
|
202
294
|
}
|
|
203
295
|
|
|
204
|
-
async function fetchCloudFiles(context, scope) {
|
|
296
|
+
export async function fetchCloudFiles(context, scope) {
|
|
205
297
|
try {
|
|
206
298
|
const response = await api.projectStatus(context.host, context.token, scope)
|
|
207
299
|
return response.files ?? []
|
package/src/commands/status.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
KEY_STATE,
|
|
10
10
|
summarize,
|
|
11
11
|
} from '../status.mjs'
|
|
12
|
-
import { out, pad, pluralize, style } from '../ui.mjs'
|
|
12
|
+
import { out, pad, pluralize, sanitize, style } from '../ui.mjs'
|
|
13
13
|
|
|
14
14
|
const KEY_MARK = {
|
|
15
15
|
[KEY_STATE.MODIFIED]: { mark: '~', color: style.yellow, note: 'local differs' },
|
|
@@ -100,7 +100,9 @@ function render(context, files) {
|
|
|
100
100
|
continue
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
out(
|
|
103
|
+
out(
|
|
104
|
+
` ${mark.color(mark.mark)} ${pad(sanitize(entry.key), 28)}${style.dim(`(${mark.note})`)}`
|
|
105
|
+
)
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { api } from '../api.mjs'
|
|
4
|
+
import { formatSize } from '../blob.mjs'
|
|
5
|
+
import {
|
|
6
|
+
cloudKinds,
|
|
7
|
+
loadLocalFiles,
|
|
8
|
+
resolveContext,
|
|
9
|
+
resolveFileNames,
|
|
10
|
+
SHARED_SCOPE,
|
|
11
|
+
} from '../context.mjs'
|
|
12
|
+
import { entriesToMap, mergeEnvText, serializeEnv } from '../env-file.mjs'
|
|
13
|
+
import { writeSecretFile } from '../local-write.mjs'
|
|
14
|
+
import { resolveRepoFile } from '../repo-path.mjs'
|
|
15
|
+
import { compareProject } from '../status.mjs'
|
|
16
|
+
import { conflictExitCode, isBlocked, isChanging, planSync, PREFER, SYNC_ACTION } from '../sync.mjs'
|
|
17
|
+
import { CliError, out, pad, pluralize, sanitize, style } from '../ui.mjs'
|
|
18
|
+
import { announceIgnoredSecretDir, confirmNewFiles, fetchCloudFiles, pushOneBlob } from './push.mjs'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `envs sync` — reconcile this repo with the cloud, in both directions at once.
|
|
22
|
+
*
|
|
23
|
+
* `pull` and `push` each ask the developer to have already decided which side
|
|
24
|
+
* is right. Most of the time neither is: a teammate added a key while you added
|
|
25
|
+
* a different one, and what you want is both. Sync is that — the union of the
|
|
26
|
+
* two sides, applied in whichever direction each difference implies.
|
|
27
|
+
*
|
|
28
|
+
* What it will not do is choose between two different values for the same key,
|
|
29
|
+
* because there is no record of the last sync and therefore no way to know
|
|
30
|
+
* which side moved (the same limit `envs status` acknowledges by saying
|
|
31
|
+
* `local_only` rather than `added`). Those are reported as conflicts, both
|
|
32
|
+
* copies are left untouched, and the command exits 1. `--prefer local` or
|
|
33
|
+
* `--prefer cloud` resolves them in one direction, as a separate deliberate act.
|
|
34
|
+
*
|
|
35
|
+
* Nothing is ever deleted: sync never prunes, in either direction. A key that
|
|
36
|
+
* exists on one side only is copied rather than removed, so the worst a wrong
|
|
37
|
+
* guess costs is a key coming back — never one that exists nowhere any more.
|
|
38
|
+
*/
|
|
39
|
+
export async function sync(flags, args) {
|
|
40
|
+
const shared = Boolean(flags.shared)
|
|
41
|
+
|
|
42
|
+
if (shared && args.length === 0) {
|
|
43
|
+
throw new CliError('Syncing shared files needs their names.', {
|
|
44
|
+
hint: 'Example: envs sync --shared .npmrc',
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const prefer = resolvePrefer(flags)
|
|
49
|
+
const context = resolveContext(flags)
|
|
50
|
+
const scope = shared ? SHARED_SCOPE : context.project.name
|
|
51
|
+
|
|
52
|
+
// Same reasoning as `push`: before discovery, so a file in a not-yet-ignored
|
|
53
|
+
// .config/ is covered by this run rather than the next one, and never under
|
|
54
|
+
// --dry-run, because a preview that edits files is not a preview.
|
|
55
|
+
if (!flags['dry-run']) {
|
|
56
|
+
announceIgnoredSecretDir(context.repoRoot)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cloudFiles = await fetchCloudFiles(context, scope)
|
|
60
|
+
|
|
61
|
+
const names = resolveFileNames({
|
|
62
|
+
repoRoot: context.repoRoot,
|
|
63
|
+
link: context.link,
|
|
64
|
+
cloudFiles,
|
|
65
|
+
requested: args,
|
|
66
|
+
// Sync writes as well as reads, so a skipped symlink is reported the way
|
|
67
|
+
// `pull` reports one.
|
|
68
|
+
sink: 'write',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
|
|
72
|
+
const comparison = compareProject({
|
|
73
|
+
cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
|
|
74
|
+
localFiles,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (comparison.length === 0) {
|
|
78
|
+
out()
|
|
79
|
+
out(` ${style.dim('Nothing to sync — no files found locally or in the cloud.')}`)
|
|
80
|
+
out()
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const plans = planSync(comparison, { prefer })
|
|
85
|
+
const byName = new Map(comparison.map(file => [file.name, file]))
|
|
86
|
+
|
|
87
|
+
// The same gate `push` puts in front of a file discovery found on its own.
|
|
88
|
+
// Sync uploads new files too, and "it synced" is no more of an excuse for a
|
|
89
|
+
// gcloud token cache reaching the cloud unasked than "it pushed" was.
|
|
90
|
+
await confirmNewFiles(flags, context, args, comparison, { command: 'sync' })
|
|
91
|
+
|
|
92
|
+
out()
|
|
93
|
+
out(
|
|
94
|
+
` ${style.dim(shared ? 'Shared files' : 'Project')} ${style.bold(
|
|
95
|
+
shared ? 'in your account' : context.project.name
|
|
96
|
+
)} ${style.dim(`· ${context.host}`)}`
|
|
97
|
+
)
|
|
98
|
+
out()
|
|
99
|
+
|
|
100
|
+
const width = Math.max(...plans.map(plan => plan.name.length)) + 2
|
|
101
|
+
const totals = { pulled: 0, pushed: 0, files: 0 }
|
|
102
|
+
|
|
103
|
+
for (const plan of plans) {
|
|
104
|
+
const applied = await applyPlan(flags, context, scope, plan, byName.get(plan.name), localFiles)
|
|
105
|
+
|
|
106
|
+
render(flags, plan, applied, width)
|
|
107
|
+
|
|
108
|
+
if (applied.changed) {
|
|
109
|
+
totals.pulled += applied.pulled
|
|
110
|
+
totals.pushed += applied.pushed
|
|
111
|
+
totals.files += 1
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
out()
|
|
116
|
+
renderSummary(flags, plans, totals, prefer)
|
|
117
|
+
|
|
118
|
+
return conflictExitCode(flags, plans)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `--prefer local` / `--prefer cloud`, validated before anything is fetched.
|
|
123
|
+
*
|
|
124
|
+
* A typo here — `--prefer mine` — would otherwise read as "no preference" and
|
|
125
|
+
* quietly report conflicts the developer believed they had just resolved.
|
|
126
|
+
*/
|
|
127
|
+
function resolvePrefer(flags) {
|
|
128
|
+
if (!('prefer' in flags)) {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (flags.prefer === PREFER.LOCAL || flags.prefer === PREFER.CLOUD) {
|
|
133
|
+
return flags.prefer
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new CliError(`--prefer takes "local" or "cloud", not ${JSON.stringify(flags.prefer)}.`, {
|
|
137
|
+
hint: 'envs sync --prefer local keeps your values; --prefer cloud keeps the cloud’s.',
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What `applyPlan` returns when it did not touch anything. */
|
|
142
|
+
const NOTHING = { changed: false, pulled: 0, pushed: 0, detail: null }
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Carry out one file's plan.
|
|
146
|
+
*
|
|
147
|
+
* Returns what actually happened rather than printing as it goes, so the report
|
|
148
|
+
* can be laid out in one place and a dry run produces the same shape without
|
|
149
|
+
* touching anything. `pulled` and `pushed` count keys and are therefore zero
|
|
150
|
+
* for an opaque file, which has none; `changed` is what says a file moved.
|
|
151
|
+
*/
|
|
152
|
+
async function applyPlan(flags, context, scope, plan, file, localFiles) {
|
|
153
|
+
if (plan.action === SYNC_ACTION.NONE || plan.action === SYNC_ACTION.CONFLICT) {
|
|
154
|
+
return NOTHING
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (plan.action === SYNC_ACTION.PULL) {
|
|
158
|
+
return await pullWholeFile(flags, context, scope, plan)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (plan.action === SYNC_ACTION.PUSH) {
|
|
162
|
+
return await pushWholeFile(flags, context, scope, plan, file, localFiles.get(plan.name))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return await mergeOneFile(flags, context, scope, plan, localFiles.get(plan.name) ?? [])
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A file the cloud has and disk does not: written whole, exactly as `pull` would. */
|
|
169
|
+
async function pullWholeFile(flags, context, scope, plan) {
|
|
170
|
+
const response = await api.getFile(context.host, context.token, scope, plan.name)
|
|
171
|
+
|
|
172
|
+
// Branching on the response's own kind, not the local guess: the server is
|
|
173
|
+
// the authority on what it stored.
|
|
174
|
+
const isBlob = response.kind === 'file'
|
|
175
|
+
const body = isBlob
|
|
176
|
+
? Buffer.from(response.contentBase64 ?? '', 'base64')
|
|
177
|
+
: serializeEnv(response.entries ?? [])
|
|
178
|
+
const count = isBlob ? 0 : (response.entries?.length ?? 0)
|
|
179
|
+
const detail = isBlob
|
|
180
|
+
? `new from the cloud (file, ${formatSize(body.length)})`
|
|
181
|
+
: `new from the cloud (${pluralize(count, 'key')})`
|
|
182
|
+
|
|
183
|
+
if (!flags['dry-run']) {
|
|
184
|
+
writeSecretFile(context.repoRoot, plan.name, body)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { changed: true, pulled: count, pushed: 0, detail }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A file on disk the cloud has never seen — or one `--prefer local` settles. */
|
|
191
|
+
async function pushWholeFile(flags, context, scope, plan, file, local) {
|
|
192
|
+
if (plan.kind === 'file') {
|
|
193
|
+
// `pushOneBlob` enforces the size cap and prints its own line, so nothing
|
|
194
|
+
// is reported for it here.
|
|
195
|
+
await pushOneBlob(flags, context, scope, file, local)
|
|
196
|
+
|
|
197
|
+
return { changed: true, pulled: 0, pushed: 0, detail: null }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const entries = local ?? []
|
|
201
|
+
|
|
202
|
+
if (!flags['dry-run']) {
|
|
203
|
+
// Never prune. Sync's whole contract is that it does not delete, and this
|
|
204
|
+
// path also runs for `--prefer local` on a file that already exists.
|
|
205
|
+
await api.putFile(context.host, context.token, scope, plan.name, entries, false)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
changed: true,
|
|
210
|
+
pulled: 0,
|
|
211
|
+
pushed: entries.length,
|
|
212
|
+
detail: `sent up (${pluralize(entries.length, 'key')})`,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Both sides moved: copy each side's keys to the other.
|
|
218
|
+
*
|
|
219
|
+
* The local file is edited in place rather than regenerated — `mergeEnvText`
|
|
220
|
+
* keeps its comments and layout — because a merge has an opinion about a few
|
|
221
|
+
* keys and none at all about the rest of the file.
|
|
222
|
+
*/
|
|
223
|
+
async function mergeOneFile(flags, context, scope, plan, local) {
|
|
224
|
+
const localValues = entriesToMap(local)
|
|
225
|
+
let pulled = 0
|
|
226
|
+
let pushed = 0
|
|
227
|
+
|
|
228
|
+
if (plan.download.length > 0) {
|
|
229
|
+
const response = await api.getFile(context.host, context.token, scope, plan.name)
|
|
230
|
+
const cloudValues = entriesToMap(response.entries ?? [])
|
|
231
|
+
|
|
232
|
+
// A key the status listing promised but the file no longer has is dropped,
|
|
233
|
+
// not written as an empty string: the two calls are not one transaction.
|
|
234
|
+
const changes = plan.download
|
|
235
|
+
.filter(key => cloudValues.has(key))
|
|
236
|
+
.map(key => ({ key, value: cloudValues.get(key) }))
|
|
237
|
+
|
|
238
|
+
if (changes.length > 0 && !flags['dry-run']) {
|
|
239
|
+
const path = resolveRepoFile(context.repoRoot, plan.name)
|
|
240
|
+
|
|
241
|
+
writeSecretFile(
|
|
242
|
+
context.repoRoot,
|
|
243
|
+
plan.name,
|
|
244
|
+
mergeEnvText(readFileSync(path, 'utf8'), changes)
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
pulled = changes.length
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (plan.upload.length > 0) {
|
|
252
|
+
// Only the keys this merge decided on — not the whole local file. Pushing
|
|
253
|
+
// everything would overwrite the cloud's value for every conflicting key,
|
|
254
|
+
// which is precisely the decision sync refuses to make on its own.
|
|
255
|
+
const entries = plan.upload
|
|
256
|
+
.filter(key => localValues.has(key))
|
|
257
|
+
.map(key => ({ key, value: localValues.get(key) }))
|
|
258
|
+
|
|
259
|
+
if (entries.length > 0 && !flags['dry-run']) {
|
|
260
|
+
await api.putFile(context.host, context.token, scope, plan.name, entries, false)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
pushed = entries.length
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const parts = []
|
|
267
|
+
|
|
268
|
+
if (pulled > 0) {
|
|
269
|
+
parts.push(`${pluralize(pulled, 'key')} pulled`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (pushed > 0) {
|
|
273
|
+
parts.push(`${pluralize(pushed, 'key')} pushed`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
changed: pulled > 0 || pushed > 0,
|
|
278
|
+
pulled,
|
|
279
|
+
pushed,
|
|
280
|
+
detail: parts.join(', ') || null,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One line per file, plus an indented line per conflicting key. */
|
|
285
|
+
function render(flags, plan, applied, width) {
|
|
286
|
+
const name = pad(plan.name, width)
|
|
287
|
+
|
|
288
|
+
if (applied.detail) {
|
|
289
|
+
const mark = flags['dry-run'] ? style.dim('·') : style.green('✓')
|
|
290
|
+
const verb = flags['dry-run'] ? 'would be ' : ''
|
|
291
|
+
// Named on the same line as the work that did happen, so a merge that was
|
|
292
|
+
// three-quarters successful does not read as wholly successful.
|
|
293
|
+
const note =
|
|
294
|
+
plan.conflicts.length > 0
|
|
295
|
+
? `${style.dim(' · ')}${style.yellow(pluralize(plan.conflicts.length, 'conflict'))}`
|
|
296
|
+
: ''
|
|
297
|
+
|
|
298
|
+
out(` ${mark} ${name}${style.dim(`${verb}${applied.detail}`)}${note}`)
|
|
299
|
+
} else if (!isChanging(plan) && !isBlocked(plan)) {
|
|
300
|
+
out(` ${style.dim('=')} ${name}${style.dim('already in sync')}`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (plan.action === SYNC_ACTION.CONFLICT) {
|
|
304
|
+
out(
|
|
305
|
+
` ${style.yellow('!')} ${name}${style.yellow('conflict')} ${style.dim(
|
|
306
|
+
'— the file differs on both sides and its contents do not merge'
|
|
307
|
+
)}`
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (plan.conflicts.length === 0) {
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!applied.detail) {
|
|
317
|
+
out(
|
|
318
|
+
` ${style.yellow('!')} ${name}${style.yellow(pluralize(plan.conflicts.length, 'conflict'))}`
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const key of plan.conflicts) {
|
|
323
|
+
out(
|
|
324
|
+
` ${style.yellow('~')} ${pad(sanitize(key), 28)}${style.dim('(differs on both sides)')}`
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderSummary(flags, plans, totals, prefer) {
|
|
330
|
+
const blocked = plans.filter(isBlocked)
|
|
331
|
+
|
|
332
|
+
if (totals.files === 0 && blocked.length === 0) {
|
|
333
|
+
out(` ${style.dim('Everything is already in sync.')}`)
|
|
334
|
+
out()
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (totals.files > 0) {
|
|
339
|
+
const moved = [
|
|
340
|
+
totals.pulled > 0 ? `${pluralize(totals.pulled, 'key')} down` : null,
|
|
341
|
+
totals.pushed > 0 ? `${pluralize(totals.pushed, 'key')} up` : null,
|
|
342
|
+
].filter(Boolean)
|
|
343
|
+
|
|
344
|
+
const summary = `${pluralize(totals.files, 'file')}${moved.length > 0 ? ` · ${moved.join(', ')}` : ''}`
|
|
345
|
+
|
|
346
|
+
out(` ${style.dim(flags['dry-run'] ? `Would sync ${summary}.` : `Synced ${summary}.`)}`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (blocked.length === 0) {
|
|
350
|
+
out()
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
out(
|
|
355
|
+
` ${style.yellow(pluralize(blocked.length, 'file'))} ${style.dim(
|
|
356
|
+
'left alone — the same key holds different values on each side.'
|
|
357
|
+
)}`
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
// Nothing to suggest when they already said which side wins and the answer
|
|
361
|
+
// is still "it conflicts" — that only happens for an opaque file, and there
|
|
362
|
+
// --prefer did settle it.
|
|
363
|
+
if (!prefer) {
|
|
364
|
+
out(
|
|
365
|
+
` ${style.dim('See them with')} envs diff${style.dim(', then choose a side:')} ` +
|
|
366
|
+
`envs sync --prefer local ${style.dim('or')} envs sync --prefer cloud`
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
out()
|
|
371
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
|
|
5
|
+
import { writeFileAtomic } from './atomic-write.mjs'
|
|
5
6
|
import { CliError } from './ui.mjs'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -53,31 +54,73 @@ export function readConfig() {
|
|
|
53
54
|
/**
|
|
54
55
|
* Write credentials with 0600 on the file and 0700 on the directory.
|
|
55
56
|
*
|
|
56
|
-
* The mode is set explicitly
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* The mode is set explicitly rather than relying on the process umask: a
|
|
58
|
+
* permissive umask (common in containers and CI images) would otherwise leave a
|
|
59
|
+
* long-lived token world-readable. Atomic, because this one file holds every
|
|
60
|
+
* token on the machine, and writing it in place meant a crash mid-write lost
|
|
61
|
+
* all of them.
|
|
59
62
|
*/
|
|
60
63
|
export function writeConfig(config) {
|
|
61
64
|
const dir = configDir()
|
|
62
65
|
|
|
63
66
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
|
68
|
+
writeFileAtomic(configPath(), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
|
67
69
|
|
|
68
70
|
try {
|
|
69
71
|
chmodSync(dir, 0o700)
|
|
70
|
-
chmodSync(path, 0o600)
|
|
71
72
|
} catch {
|
|
72
73
|
// Windows and some network filesystems do not support POSIX modes. The
|
|
73
74
|
// write already succeeded; permissions are best-effort there.
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Normalize a host so `envstash.co`, with a trailing slash, and https all agree.
|
|
80
|
+
*
|
|
81
|
+
* Plain `http://` is refused except on this machine. Every request carries a
|
|
82
|
+
* bearer token and most carry secrets, so a typo'd `--host http://…` or a cloned
|
|
83
|
+
* repo's link file saying so would otherwise send both across the network in the
|
|
84
|
+
* clear. A dev server on localhost is the one legitimate use, and it never
|
|
85
|
+
* leaves the machine.
|
|
86
|
+
*/
|
|
78
87
|
export function normalizeHost(host) {
|
|
79
|
-
const
|
|
80
|
-
|
|
88
|
+
const raw = String(host)
|
|
89
|
+
|
|
90
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(raw)) {
|
|
91
|
+
throw new CliError('Refusing a server address that contains control characters.', {
|
|
92
|
+
hint: 'Check --host, ENVSTASH_HOST, and the host in .envstash.json.',
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`
|
|
97
|
+
const normalized = withScheme.replace(/\/+$/, '')
|
|
98
|
+
|
|
99
|
+
if (/^http:/i.test(normalized) && !isLoopbackUrl(normalized)) {
|
|
100
|
+
throw new CliError(`Refusing to use ${normalized} over plain http.`, {
|
|
101
|
+
hint: 'Use https:// — plain http is only allowed for localhost, where nothing leaves this machine.',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return normalized
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** localhost, *.localhost, 127.0.0.0/8 and ::1 — the addresses that stay on this machine. */
|
|
109
|
+
export function isLoopbackUrl(url) {
|
|
110
|
+
let hostname
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
hostname = new URL(url).hostname.toLowerCase()
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
hostname === 'localhost' ||
|
|
120
|
+
hostname.endsWith('.localhost') ||
|
|
121
|
+
hostname === '[::1]' ||
|
|
122
|
+
/^127(?:\.\d{1,3}){3}$/.test(hostname)
|
|
123
|
+
)
|
|
81
124
|
}
|
|
82
125
|
|
|
83
126
|
/**
|