envstash-cli 0.5.2 → 0.6.1

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.
@@ -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
- if (result.created) {
125
- parts.push(style.green(`${result.created} new`))
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 (result.updated) {
129
- parts.push(style.yellow(`${result.updated} changed`))
139
+ if (updated) {
140
+ parts.push(style.yellow(`${updated} changed`))
130
141
  }
131
142
 
132
- if (result.deleted) {
133
- parts.push(style.red(`${result.deleted} deleted`))
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 ?? []
@@ -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(` ${mark.color(mark.mark)} ${pad(entry.key, 28)}${style.dim(`(${mark.note})`)}`)
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, writeFileSync } from 'node:fs'
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 after writing rather than relying on the process
57
- * umask: a permissive umask (common in containers and CI images) would otherwise
58
- * leave a long-lived token world-readable.
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
- const path = configPath()
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
- /** Normalize a host so `envstash.co`, with a trailing slash, and https all agree. */
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 withScheme = /^https?:\/\//i.test(host) ? host : `https://${host}`
80
- return withScheme.replace(/\/+$/, '')
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
  /**