envstash-cli 0.5.1 → 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/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
  /**
package/src/context.mjs CHANGED
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { isEnvName } from './blob.mjs'
4
4
  import { parseEnvFile } from './env-file.mjs'
5
5
  import { discoverTrackedFiles, findRepoRoot, readLink, resolveProject } from './project.mjs'
6
- import { resolveInsideRepo } from './repo-path.mjs'
6
+ import { findSymlink, resolveInsideRepo, resolveRepoFile } from './repo-path.mjs'
7
7
  import { requireHostToken } from './session.mjs'
8
8
  import { errOut, pluralize, style } from './ui.mjs'
9
9
 
@@ -36,9 +36,11 @@ export function resolveContext(flags) {
36
36
  * present — an explicit list is the only way to include a nested path like
37
37
  * `apps/web/.env.local`, which discovery does not scan for. With no link file,
38
38
  * fall back to the union of what discovery finds — root `.env*` files plus git-ignored
39
- * files in `.config/` — and whatever the cloud holds.
39
+ * files in `.config/` — and whatever the cloud holds. Those inferred names skip
40
+ * symbolic links with a warning rather than refusing (see `skipSymlinks`);
41
+ * `sink` is `'write'` for `pull`, so the warning says the file is not written.
40
42
  */
41
- export function resolveFileNames({ repoRoot, link, cloudFiles, requested }) {
43
+ export function resolveFileNames({ repoRoot, link, cloudFiles, requested, sink = 'read' }) {
42
44
  if (requested?.length) {
43
45
  return check([...new Set(requested)].sort())
44
46
  }
@@ -49,7 +51,41 @@ export function resolveFileNames({ repoRoot, link, cloudFiles, requested }) {
49
51
 
50
52
  const names = new Set([...discoverTrackedFiles(repoRoot), ...cloudFiles.map(file => file.name)])
51
53
 
52
- return check([...names].sort())
54
+ return skipSymlinks(repoRoot, check([...names].sort()), sink)
55
+ }
56
+
57
+ /**
58
+ * Drop, with a warning, the inferred names that are symbolic links on disk.
59
+ *
60
+ * Only for names nobody asked for. An explicit argument or a link-file entry
61
+ * that is a link still stops the command in `resolveRepoFile` — someone chose
62
+ * that name, so the refusal is the answer. But a name that is here only because
63
+ * the cloud holds a file by it says nothing about the developer's intent, and a
64
+ * `.env -> .env.development` or a worktree link is ordinary. Refusing there
65
+ * would take every sync command down over one file the developer never
66
+ * mentioned; skipping it leaves the link and whatever it points at untouched.
67
+ * (Discovery already leaves links out, so in practice these come from the
68
+ * cloud listing.)
69
+ */
70
+ function skipSymlinks(repoRoot, names, sink) {
71
+ return names.filter(name => {
72
+ const link = findSymlink(repoRoot, name)
73
+
74
+ if (!link) {
75
+ return true
76
+ }
77
+
78
+ const why = link === name ? 'it is a symbolic link' : `${link} is a symbolic link`
79
+ const effect =
80
+ sink === 'write'
81
+ ? `Not written: envstash never writes through a link. Replace ${link} with a real file to pull it.`
82
+ : `Not read: envstash never reads through a link. Replace ${link} with a real file to sync it.`
83
+
84
+ errOut(` ${style.yellow('!')} skipping ${name}: ${why}`)
85
+ errOut(` ${style.dim(effect)}`)
86
+
87
+ return false
88
+ })
53
89
  }
54
90
 
55
91
  /**
@@ -84,8 +120,9 @@ export function loadLocalFiles(repoRoot, names, kinds = new Map()) {
84
120
 
85
121
  for (const name of names) {
86
122
  // Resolved rather than joined: `join` happily walks out of the repo, and
87
- // this is the read side of the same hole `pull` had on the write side.
88
- const path = resolveInsideRepo(repoRoot, name)
123
+ // this is the read side of the same hole `pull` had on the write side. A
124
+ // symlink is refused here too, or `.env.local -> ~/.ssh/id_rsa` uploads the key.
125
+ const path = resolveRepoFile(repoRoot, name)
89
126
 
90
127
  if (!existsSync(path)) {
91
128
  files.set(name, null)
package/src/env-file.mjs CHANGED
@@ -38,10 +38,11 @@ export function parseEnvFile(contents) {
38
38
  const entries = []
39
39
  const skipped = []
40
40
  const seen = new Map()
41
+ const spans = new Map()
41
42
 
42
43
  let index = 0
43
44
  let lineNumber = 1
44
- const text = contents.replace(/^/, '')
45
+ const text = stripBom(contents)
45
46
 
46
47
  while (index < text.length) {
47
48
  const lineEnd = findLineEnd(text, index)
@@ -90,13 +91,29 @@ export function parseEnvFile(contents) {
90
91
  entries.push({ key, value })
91
92
  }
92
93
 
94
+ // Where this key's assignment sits in `text`, for `mergeEnvText`. A repeated
95
+ // key overwrites the earlier span, just as it overwrote the earlier value:
96
+ // the assignment that wins is the one a rewrite has to replace.
97
+ spans.set(key, { start: index, end: Math.min(nextIndex, text.length) })
98
+
93
99
  // Counted rather than incremented: a quoted value may have spanned several
94
100
  // lines, and a later skipped line has to report the number a human sees.
95
101
  lineNumber += countNewlines(text, index, nextIndex)
96
102
  index = nextIndex
97
103
  }
98
104
 
99
- return { entries, skipped }
105
+ return { entries, skipped, spans }
106
+ }
107
+
108
+ /**
109
+ * Drop a UTF-8 BOM.
110
+ *
111
+ * A function rather than a repeated regex because the offsets in `spans` are
112
+ * relative to its result: anything that rewrites the text has to start from
113
+ * byte zero of the same string the parser measured.
114
+ */
115
+ function stripBom(contents) {
116
+ return String(contents).replace(/^\ufeff/, '')
100
117
  }
101
118
 
102
119
  function findLineEnd(text, from) {
@@ -200,31 +217,95 @@ function needsQuoting(value) {
200
217
  * file. That matters because the entries handed to `envs pull` come from the
201
218
  * server, not from the developer.
202
219
  */
203
- const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
220
+ export const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
204
221
 
205
222
  /** Serialize entries back to `.env` text. Round-trips with `parseEnv`. */
206
223
  export function serializeEnv(entries) {
207
- const lines = entries.map(({ key, value }) => {
208
- if (!KEY_PATTERN.test(key)) {
209
- throw new CliError(`Refusing to write the key ${JSON.stringify(String(key))}.`, {
210
- hint: 'An environment variable name must be letters, digits and underscores.',
211
- })
212
- }
224
+ const lines = entries.map(serializeEntry)
225
+
226
+ return lines.length > 0 ? `${lines.join('\n')}\n` : ''
227
+ }
228
+
229
+ /** One `KEY=value` line, quoted as the value requires. No trailing newline. */
230
+ export function serializeEntry({ key, value }) {
231
+ if (!KEY_PATTERN.test(key)) {
232
+ throw new CliError(`Refusing to write the key ${JSON.stringify(String(key))}.`, {
233
+ hint: 'An environment variable name must be letters, digits and underscores.',
234
+ })
235
+ }
236
+
237
+ if (!needsQuoting(value)) {
238
+ return `${key}=${value}`
239
+ }
240
+
241
+ // `$` and backticks mean nothing to this parser inside double quotes, but
242
+ // bash, docker compose and dotenv-expand all expand them there — so
243
+ // `PASS='p$ss'` pulled back as `PASS="p$ss"` becomes `p` everywhere else.
244
+ // Single quotes are literal to all of them. They cannot hold a `'` or span
245
+ // lines without escapes, which single quotes do not have, so those values
246
+ // stay double-quoted.
247
+ if (/[$`]/.test(value) && !/['\n\r]/.test(value)) {
248
+ return `${key}='${value}'`
249
+ }
250
+
251
+ const escaped = value
252
+ .replace(/\\/g, '\\\\')
253
+ .replace(/"/g, '\\"')
254
+ .replace(/\n/g, '\\n')
255
+ .replace(/\r/g, '\\r')
213
256
 
214
- if (!needsQuoting(value)) {
215
- return `${key}=${value}`
257
+ return `${key}="${escaped}"`
258
+ }
259
+
260
+ /**
261
+ * Apply key/value changes to existing `.env` text, editing it in place.
262
+ *
263
+ * This is what makes `envs sync` safe to run on a file someone maintains by
264
+ * hand. `pull` regenerates the whole file from the cloud's entries, which is
265
+ * correct when the cloud's copy *is* the answer — but a merge only has an
266
+ * opinion about the handful of keys that moved, and rewriting the file would
267
+ * silently throw away every comment, blank line and grouping around them.
268
+ *
269
+ * A key that is already assigned has its assignment line replaced where it
270
+ * stands; a key that is new is appended. Replacements are applied back to
271
+ * front so that earlier offsets stay valid as the text changes length.
272
+ *
273
+ * Note that a replaced line loses an `export ` prefix or a trailing
274
+ * `# comment` on that one line: the span covers the whole assignment, and
275
+ * reproducing decoration around a value nobody can see is not worth a parser
276
+ * that has to understand it.
277
+ */
278
+ export function mergeEnvText(contents, changes) {
279
+ const { spans } = parseEnvFile(contents)
280
+
281
+ const replacements = []
282
+ const additions = []
283
+
284
+ for (const change of changes) {
285
+ const span = spans.get(change.key)
286
+
287
+ if (span) {
288
+ replacements.push({ span, line: serializeEntry(change) })
289
+ } else {
290
+ additions.push(change)
216
291
  }
292
+ }
293
+
294
+ let merged = stripBom(contents)
217
295
 
218
- const escaped = value
219
- .replace(/\\/g, '\\\\')
220
- .replace(/"/g, '\\"')
221
- .replace(/\n/g, '\\n')
222
- .replace(/\r/g, '\\r')
296
+ for (const { span, line } of replacements.sort((a, b) => b.span.start - a.span.start)) {
297
+ merged = `${merged.slice(0, span.start)}${line}\n${merged.slice(span.end)}`
298
+ }
223
299
 
224
- return `${key}="${escaped}"`
225
- })
300
+ if (additions.length > 0) {
301
+ // A file that does not end in a newline would otherwise get the first new
302
+ // key welded onto its last value.
303
+ const separator = merged === '' || merged.endsWith('\n') ? '' : '\n'
226
304
 
227
- return lines.length > 0 ? `${lines.join('\n')}\n` : ''
305
+ merged = `${merged}${separator}${additions.map(serializeEntry).join('\n')}\n`
306
+ }
307
+
308
+ return merged
228
309
  }
229
310
 
230
311
  /**
package/src/index.mjs CHANGED
@@ -7,16 +7,18 @@ import { login, logout } from './commands/login.mjs'
7
7
  import { init, link, list, version as versionCommand, whoami } from './commands/misc.mjs'
8
8
  import { pull } from './commands/pull.mjs'
9
9
  import { push } from './commands/push.mjs'
10
+ import { remove } from './commands/remove.mjs'
10
11
  import { status } from './commands/status.mjs'
12
+ import { sync } from './commands/sync.mjs'
11
13
  import { DEFAULT_HOST } from './config.mjs'
12
14
  import { CLI_ALIASES, CLI_COMMAND_PAD, CLI_COMMANDS, CLI_ENV_VARS } from './manifest.mjs'
13
- import { CliError, errOut, out, style } from './ui.mjs'
15
+ import { CliError, errOut, out, sanitize, style } from './ui.mjs'
14
16
 
15
17
  /**
16
18
  * Flags that take a value. Everything else is a boolean, which is what lets
17
19
  * `--force` and `--project my-app` be parsed without a schema per command.
18
20
  */
19
- const VALUE_FLAGS = new Set(['host', 'port', 'project', 'label', 'cwd'])
21
+ const VALUE_FLAGS = new Set(['host', 'port', 'project', 'label', 'cwd', 'prefer'])
20
22
 
21
23
  /** Git-style `-C <dir>`: the one short flag that takes a value. */
22
24
  const SHORT_VALUE_ALIASES = { C: 'cwd' }
@@ -33,6 +35,8 @@ const HANDLERS = {
33
35
  diff,
34
36
  pull,
35
37
  push,
38
+ sync,
39
+ remove,
36
40
  version: versionCommand,
37
41
  }
38
42
 
@@ -173,12 +177,14 @@ export async function run(argv) {
173
177
  const code = await command.run(flags, rest)
174
178
  return finish(code ?? 0)
175
179
  } catch (error) {
180
+ // Sanitized: an ApiError's message and a refused name both carry text the
181
+ // server chose, and this is where it reaches the terminal.
176
182
  if (error instanceof CliError) {
177
183
  errOut()
178
- errOut(` ${style.red('✗')} ${error.message}`)
184
+ errOut(` ${style.red('✗')} ${sanitize(error.message)}`)
179
185
 
180
186
  if (error.hint) {
181
- errOut(` ${style.dim(error.hint)}`)
187
+ errOut(` ${style.dim(sanitize(error.hint))}`)
182
188
  }
183
189
 
184
190
  errOut()
@@ -186,7 +192,7 @@ export async function run(argv) {
186
192
  }
187
193
 
188
194
  errOut()
189
- errOut(` ${style.red('✗')} ${error?.message ?? 'Unexpected error'}`)
195
+ errOut(` ${style.red('✗')} ${sanitize(error?.message ?? 'Unexpected error')}`)
190
196
  errOut()
191
197
 
192
198
  if (process.env.ENVSTASH_DEBUG) {
@@ -267,9 +273,15 @@ function printHelp(topic) {
267
273
  out(
268
274
  ` --json ${style.dim('Machine-readable output (status, list, whoami, version)')}`
269
275
  )
270
- out(` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff)')}`)
271
- out(` --dry-run ${style.dim('Show what would happen (pull, push)')}`)
272
- out(` --shared ${style.dim('Target your account-wide shared files (pull, push)')}`)
276
+ out(
277
+ ` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff, sync)')}`
278
+ )
279
+ out(` --prefer <side> ${style.dim('Resolve conflicts as "local" or "cloud" (sync)')}`)
280
+ out(` --dry-run ${style.dim('Show what would happen (pull, push, sync, remove)')}`)
281
+ out(` -y, --yes ${style.dim('Upload newly discovered files without asking (push)')}`)
282
+ out(
283
+ ` --shared ${style.dim('Target your account-wide shared files (pull, push, sync, remove)')}`
284
+ )
273
285
  out(` --dev ${style.dim('Log in to a local dev server if one is running')}`)
274
286
  out()
275
287
  out(
@@ -308,7 +320,8 @@ const COMMAND_HELP = {
308
320
  diff: [
309
321
  'Show which keys differ and what they differ to.',
310
322
  '',
311
- 'Values are masked by default; --show-values reveals them.',
323
+ 'Values are masked by default — at most the first 4 characters and the',
324
+ 'length — and --show-values reveals them.',
312
325
  '',
313
326
  'Like status, exits 1 when anything is out of sync; --exit-zero opts out.',
314
327
  '',
@@ -330,21 +343,56 @@ const COMMAND_HELP = {
330
343
  ' envs pull --force',
331
344
  ' envs pull --shared .npmrc',
332
345
  ],
346
+ sync: [
347
+ 'Reconcile this repo and the cloud in both directions, in one command.',
348
+ '',
349
+ 'Every file and every key that exists on one side only is copied to the',
350
+ 'other. Nothing is ever deleted — sync does not prune in either direction —',
351
+ 'so the worst a wrong guess costs is a key coming back, never one lost.',
352
+ '',
353
+ 'A key that exists on both sides with different values is a conflict: there',
354
+ 'is no record of the last sync, so nothing here knows which side moved. Both',
355
+ 'copies are left untouched, the key is named, and sync exits 1. Choose a side',
356
+ 'with --prefer local or --prefer cloud.',
357
+ '',
358
+ ' envs sync Merge everything both ways',
359
+ ' envs sync --dry-run Show what it would do',
360
+ ' envs sync --prefer local Conflicting keys: your value wins',
361
+ ' envs sync --prefer cloud Conflicting keys: the cloud’s value wins',
362
+ ' envs sync .env.local Just one file',
363
+ ' envs sync --shared .npmrc Merge against your account-wide shared files',
364
+ ],
333
365
  push: [
334
366
  'Send local files to the cloud.',
335
367
  '',
336
368
  'Keys in the cloud that are absent locally are left alone unless --prune is',
337
369
  'given, so pushing a partial .env cannot delete a teammate’s key.',
338
370
  '',
371
+ 'Files that would be new in the cloud are listed before anything uploads.',
372
+ 'Ones found by discovery — not named, not in .envstash.json — need a yes:',
373
+ 'asked on a terminal, --yes (-y) anywhere else.',
374
+ '',
339
375
  '--shared sends the named files to your account-wide shared files — for',
340
376
  'credentials that follow you across projects, like a global .npmrc. Pull',
341
377
  'them into any repo with `envs pull --shared <file>`.',
342
378
  '',
343
379
  ' envs push',
344
380
  ' envs push --dry-run',
381
+ ' envs push --yes Upload newly discovered files without asking',
345
382
  ' envs push --prune',
346
383
  ' envs push --shared .npmrc',
347
384
  ],
385
+ remove: [
386
+ 'Delete files from the cloud. Copies on your machines are untouched, so a',
387
+ 'removed file is one `envs push` away as long as a local copy exists.',
388
+ '',
389
+ 'Names are always required — there is no "remove everything" default.',
390
+ 'A name that is not in the cloud is reported and skipped, not an error.',
391
+ '',
392
+ ' envs rm .env.local',
393
+ ' envs rm --shared .npmrc',
394
+ ' envs rm --dry-run .env.local',
395
+ ],
348
396
  login: [
349
397
  'Authorize this machine using a browser device code.',
350
398
  '',
@@ -0,0 +1,34 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { writeFileAtomic } from './atomic-write.mjs'
5
+ import { resolveRepoFile } from './repo-path.mjs'
6
+
7
+ /**
8
+ * Write a synced file to disk, with the care every such write needs.
9
+ *
10
+ * One function rather than the same six lines in `pull` and `sync`, because
11
+ * each of those lines is load-bearing and a copy that quietly drops one is not
12
+ * obviously wrong on sight:
13
+ *
14
+ * - The path is resolved at the sink, not only where the name entered. This
15
+ * is the call that creates directories and writes bytes, so it is the one
16
+ * that has to be certain the name is inside the repo and that no symlink on
17
+ * the way turns "inside" into somewhere else.
18
+ * - It is resolved *again* after the directories exist: the window between
19
+ * the first check and the write is where a link could appear.
20
+ * - The result is 0600. A synced file holds live credentials and has no
21
+ * business being readable by other accounts on a shared machine.
22
+ * - The write is atomic, so that holds for a file that already existed too,
23
+ * and a failed write leaves the old one intact rather than a truncated one.
24
+ */
25
+ export function writeSecretFile(repoRoot, name, body) {
26
+ const path = resolveRepoFile(repoRoot, name)
27
+
28
+ mkdirSync(dirname(path), { recursive: true })
29
+ resolveRepoFile(repoRoot, name)
30
+
31
+ writeFileAtomic(path, body, { mode: 0o600 })
32
+
33
+ return path
34
+ }
package/src/manifest.mjs CHANGED
@@ -27,6 +27,8 @@ export const CLI_COMMANDS = [
27
27
  { name: 'diff', summary: 'Show which keys differ, and how' },
28
28
  { name: 'pull', summary: 'Write the cloud’s version of each file to disk' },
29
29
  { name: 'push', summary: 'Send local files to the cloud' },
30
+ { name: 'sync', summary: 'Reconcile both directions at once, keeping every key' },
31
+ { name: 'remove', summary: 'Delete files from the cloud (local copies stay)', aliases: ['rm'] },
30
32
  { name: 'version', summary: 'Show the CLI version and its environment' },
31
33
  ]
32
34
 
package/src/project.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process'
2
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { basename, dirname, join, resolve, sep } from 'node:path'
4
4
 
5
5
  import { MAX_BLOB_BYTES } from './blob.mjs'
6
- import { isSafeRepoPath, keepSafeRepoPaths } from './repo-path.mjs'
7
- import { CliError } from './ui.mjs'
6
+ import { findSymlink, isSafeRepoPath, keepSafeRepoPaths } from './repo-path.mjs'
7
+ import { CliError, sanitize } from './ui.mjs'
8
8
 
9
9
  /** The per-repo link file. Safe to commit — it names files, never values. */
10
10
  export const LINK_FILE = '.envstash.json'
@@ -98,7 +98,8 @@ export function readLink(repoRoot) {
98
98
  try {
99
99
  const parsed = JSON.parse(readFileSync(path, 'utf8'))
100
100
  return {
101
- project: typeof parsed.project === 'string' ? parsed.project : null,
101
+ // Printed in every command's header, and it came with someone else's repo.
102
+ project: typeof parsed.project === 'string' ? sanitize(parsed.project) : null,
102
103
  host: typeof parsed.host === 'string' ? parsed.host : null,
103
104
  // A link file arrives with a cloned repo, so its file list is untrusted
104
105
  // input. Names that would escape the repo are dropped at the door rather
@@ -113,7 +114,9 @@ export function readLink(repoRoot) {
113
114
  }
114
115
 
115
116
  export function writeLink(repoRoot, link) {
116
- const path = join(repoRoot, LINK_FILE)
117
+ // Not `join`: a cloned repo's `.envstash.json` can be a link to a file outside
118
+ // it, and init would then overwrite whatever that is.
119
+ const path = assertRegularTarget(repoRoot, LINK_FILE)
117
120
  const body = {
118
121
  project: link.project,
119
122
  files: link.files,
@@ -125,6 +128,19 @@ export function writeLink(repoRoot, link) {
125
128
  return path
126
129
  }
127
130
 
131
+ /** Same refusal as the sync commands, for the two files the CLI writes itself. */
132
+ function assertRegularTarget(repoRoot, name) {
133
+ const link = findSymlink(repoRoot, name)
134
+
135
+ if (link) {
136
+ throw new CliError(`Refusing to write ${name} through a symbolic link.`, {
137
+ hint: `${name} in this repo points somewhere else. Replace it with a real file.`,
138
+ })
139
+ }
140
+
141
+ return join(repoRoot, name)
142
+ }
143
+
128
144
  /**
129
145
  * Best guess at a project name: the git remote's repo name, falling back to the
130
146
  * directory name. Matching the repo name is what makes `envs pull` work on a
@@ -170,7 +186,9 @@ export function discoverEnvFiles(repoRoot) {
170
186
  .filter(name => !name.endsWith('.d.ts'))
171
187
  .filter(name => {
172
188
  try {
173
- return statSync(join(repoRoot, name)).isFile()
189
+ // lstat, not stat: a `.env.local` that is a link to `~/.ssh/id_rsa` is a
190
+ // link, not a file, and discovery must not offer it for upload.
191
+ return lstatSync(join(repoRoot, name)).isFile()
174
192
  } catch {
175
193
  return false
176
194
  }
@@ -266,7 +284,13 @@ export function discoverSecretFiles(repoRoot) {
266
284
  .filter(name => isSafeRepoPath(name))
267
285
  .filter(name => {
268
286
  try {
269
- const stats = statSync(join(repoRoot, name))
287
+ // A link anywhere on the path — `.config` itself, a subdirectory, or the
288
+ // file — could point outside the repo, so it is not offered.
289
+ if (findSymlink(repoRoot, name)) {
290
+ return false
291
+ }
292
+
293
+ const stats = lstatSync(join(repoRoot, name))
270
294
  // Skipping oversized files here means `envs init` never lists something
271
295
  // whose first push would fail.
272
296
  return stats.isFile() && stats.size <= MAX_BLOB_BYTES
@@ -330,6 +354,13 @@ export function ensureSecretDirIgnored(repoRoot) {
330
354
  return false
331
355
  }
332
356
 
357
+ // A .gitignore that is a link points outside the repo, or is shared with other
358
+ // repos; either way it is not this CLI's file to append to. Leaving `.config/`
359
+ // unignored only means discovery finds nothing there, the conservative side.
360
+ if (findSymlink(repoRoot, '.gitignore')) {
361
+ return false
362
+ }
363
+
333
364
  const path = join(repoRoot, '.gitignore')
334
365
  let existing = ''
335
366