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.
- 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/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
|
-
|
|
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
|
|
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(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -9,15 +9,16 @@ import { pull } from './commands/pull.mjs'
|
|
|
9
9
|
import { push } from './commands/push.mjs'
|
|
10
10
|
import { remove } from './commands/remove.mjs'
|
|
11
11
|
import { status } from './commands/status.mjs'
|
|
12
|
+
import { sync } from './commands/sync.mjs'
|
|
12
13
|
import { DEFAULT_HOST } from './config.mjs'
|
|
13
14
|
import { CLI_ALIASES, CLI_COMMAND_PAD, CLI_COMMANDS, CLI_ENV_VARS } from './manifest.mjs'
|
|
14
|
-
import { CliError, errOut, out, style } from './ui.mjs'
|
|
15
|
+
import { CliError, errOut, out, sanitize, style } from './ui.mjs'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Flags that take a value. Everything else is a boolean, which is what lets
|
|
18
19
|
* `--force` and `--project my-app` be parsed without a schema per command.
|
|
19
20
|
*/
|
|
20
|
-
const VALUE_FLAGS = new Set(['host', 'port', 'project', 'label', 'cwd'])
|
|
21
|
+
const VALUE_FLAGS = new Set(['host', 'port', 'project', 'label', 'cwd', 'prefer'])
|
|
21
22
|
|
|
22
23
|
/** Git-style `-C <dir>`: the one short flag that takes a value. */
|
|
23
24
|
const SHORT_VALUE_ALIASES = { C: 'cwd' }
|
|
@@ -34,6 +35,7 @@ const HANDLERS = {
|
|
|
34
35
|
diff,
|
|
35
36
|
pull,
|
|
36
37
|
push,
|
|
38
|
+
sync,
|
|
37
39
|
remove,
|
|
38
40
|
version: versionCommand,
|
|
39
41
|
}
|
|
@@ -175,12 +177,14 @@ export async function run(argv) {
|
|
|
175
177
|
const code = await command.run(flags, rest)
|
|
176
178
|
return finish(code ?? 0)
|
|
177
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.
|
|
178
182
|
if (error instanceof CliError) {
|
|
179
183
|
errOut()
|
|
180
|
-
errOut(` ${style.red('✗')} ${error.message}`)
|
|
184
|
+
errOut(` ${style.red('✗')} ${sanitize(error.message)}`)
|
|
181
185
|
|
|
182
186
|
if (error.hint) {
|
|
183
|
-
errOut(` ${style.dim(error.hint)}`)
|
|
187
|
+
errOut(` ${style.dim(sanitize(error.hint))}`)
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
errOut()
|
|
@@ -188,7 +192,7 @@ export async function run(argv) {
|
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
errOut()
|
|
191
|
-
errOut(` ${style.red('✗')} ${error?.message ?? 'Unexpected error'}`)
|
|
195
|
+
errOut(` ${style.red('✗')} ${sanitize(error?.message ?? 'Unexpected error')}`)
|
|
192
196
|
errOut()
|
|
193
197
|
|
|
194
198
|
if (process.env.ENVSTASH_DEBUG) {
|
|
@@ -269,10 +273,14 @@ function printHelp(topic) {
|
|
|
269
273
|
out(
|
|
270
274
|
` --json ${style.dim('Machine-readable output (status, list, whoami, version)')}`
|
|
271
275
|
)
|
|
272
|
-
out(` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff)')}`)
|
|
273
|
-
out(` --dry-run ${style.dim('Show what would happen (pull, push, remove)')}`)
|
|
274
276
|
out(
|
|
275
|
-
` --
|
|
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)')}`
|
|
276
284
|
)
|
|
277
285
|
out(` --dev ${style.dim('Log in to a local dev server if one is running')}`)
|
|
278
286
|
out()
|
|
@@ -312,7 +320,8 @@ const COMMAND_HELP = {
|
|
|
312
320
|
diff: [
|
|
313
321
|
'Show which keys differ and what they differ to.',
|
|
314
322
|
'',
|
|
315
|
-
'Values are masked by default
|
|
323
|
+
'Values are masked by default — at most the first 4 characters and the',
|
|
324
|
+
'length — and --show-values reveals them.',
|
|
316
325
|
'',
|
|
317
326
|
'Like status, exits 1 when anything is out of sync; --exit-zero opts out.',
|
|
318
327
|
'',
|
|
@@ -334,18 +343,42 @@ const COMMAND_HELP = {
|
|
|
334
343
|
' envs pull --force',
|
|
335
344
|
' envs pull --shared .npmrc',
|
|
336
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
|
+
],
|
|
337
365
|
push: [
|
|
338
366
|
'Send local files to the cloud.',
|
|
339
367
|
'',
|
|
340
368
|
'Keys in the cloud that are absent locally are left alone unless --prune is',
|
|
341
369
|
'given, so pushing a partial .env cannot delete a teammate’s key.',
|
|
342
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
|
+
'',
|
|
343
375
|
'--shared sends the named files to your account-wide shared files — for',
|
|
344
376
|
'credentials that follow you across projects, like a global .npmrc. Pull',
|
|
345
377
|
'them into any repo with `envs pull --shared <file>`.',
|
|
346
378
|
'',
|
|
347
379
|
' envs push',
|
|
348
380
|
' envs push --dry-run',
|
|
381
|
+
' envs push --yes Upload newly discovered files without asking',
|
|
349
382
|
' envs push --prune',
|
|
350
383
|
' envs push --shared .npmrc',
|
|
351
384
|
],
|
|
@@ -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,7 @@ 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' },
|
|
30
31
|
{ name: 'remove', summary: 'Delete files from the cloud (local copies stay)', aliases: ['rm'] },
|
|
31
32
|
{ name: 'version', summary: 'Show the CLI version and its environment' },
|
|
32
33
|
]
|
package/src/project.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import { existsSync, readdirSync, readFileSync,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/repo-path.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
2
3
|
|
|
3
4
|
import { CliError } from './ui.mjs'
|
|
4
5
|
|
|
@@ -24,7 +25,44 @@ const SEGMENT = /^[A-Za-z0-9._-]+$/
|
|
|
24
25
|
|
|
25
26
|
const MAX_LENGTH = 255
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Directories whose contents run code, and the file that decides where this CLI
|
|
30
|
+
* sends credentials. The grammar keeps a name inside the repo, but inside the
|
|
31
|
+
* repo is not the same as harmless: a server that answers `.git/config` with an
|
|
32
|
+
* `fsmonitor` line, `.husky/pre-commit`, or `node_modules/.bin/next` gets a
|
|
33
|
+
* command run by the next `git status` or `pnpm dev`, and one that answers
|
|
34
|
+
* `.envstash.json` gets to repoint every later command at itself.
|
|
35
|
+
*
|
|
36
|
+
* Denied at any depth rather than only as the first segment: a nested package
|
|
37
|
+
* has its own `node_modules`, a vendored repo its own `.git`, and a nested link
|
|
38
|
+
* file is found first when commands run from that package. Compared without
|
|
39
|
+
* case, because on macOS and Windows `.GIT/config` is `.git/config`.
|
|
40
|
+
*/
|
|
41
|
+
const DENIED_SEGMENTS = new Set(['.git', 'node_modules', '.husky'])
|
|
42
|
+
const DENIED_FILE = '.envstash.json'
|
|
43
|
+
|
|
27
44
|
export function isSafeRepoPath(name) {
|
|
45
|
+
return isWellFormedRepoPath(name) && !isDeniedRepoPath(name)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for a well-formed name that lands somewhere envstash never writes. */
|
|
49
|
+
export function isDeniedRepoPath(name) {
|
|
50
|
+
const segments = String(name).toLowerCase().split('/').map(stripWindowsTrailing)
|
|
51
|
+
|
|
52
|
+
return segments.some(segment => DENIED_SEGMENTS.has(segment)) || segments.at(-1) === DENIED_FILE
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Win32 path normalisation drops trailing dots and spaces from every component,
|
|
57
|
+
* so on NTFS `.git./hooks/pre-commit` and `.git /hooks` both land in `.git/hooks`.
|
|
58
|
+
* `.git.` passes the segment grammar, so the deny list compares what Windows
|
|
59
|
+
* would actually open. Harmless elsewhere: no legitimate name ends in a dot.
|
|
60
|
+
*/
|
|
61
|
+
function stripWindowsTrailing(segment) {
|
|
62
|
+
return segment.replace(/[. ]+$/, '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isWellFormedRepoPath(name) {
|
|
28
66
|
if (typeof name !== 'string' || name === '' || name.length > MAX_LENGTH) {
|
|
29
67
|
return false
|
|
30
68
|
}
|
|
@@ -66,12 +104,76 @@ export function resolveInsideRepo(repoRoot, name) {
|
|
|
66
104
|
return target
|
|
67
105
|
}
|
|
68
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Resolve `name` under `repoRoot` for an actual read or write, or refuse.
|
|
109
|
+
*
|
|
110
|
+
* `resolveInsideRepo` is lexical: it proves the *name* stays inside, but a
|
|
111
|
+
* symbolic link does not care what its name is. A cloned repo can ship
|
|
112
|
+
* `.env.local -> ~/.ssh/id_rsa`, and `envs push` would upload the key; `pull`
|
|
113
|
+
* would write through the same link. So every component from the repo root down
|
|
114
|
+
* to the file is lstat-ed, and any link — the file itself or a directory on the
|
|
115
|
+
* way — is refused. Components that do not exist yet are fine: `pull` creates
|
|
116
|
+
* them, as real directories.
|
|
117
|
+
*
|
|
118
|
+
* Refusing rather than following even a link that points inside the repo: this
|
|
119
|
+
* only ever handles `.env` files and credentials, which have no reason to be
|
|
120
|
+
* links, and "inside" is exactly the judgement an attacker gets to race.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveRepoFile(repoRoot, name) {
|
|
123
|
+
const target = resolveInsideRepo(repoRoot, name)
|
|
124
|
+
const link = findSymlink(repoRoot, name)
|
|
125
|
+
|
|
126
|
+
if (link) {
|
|
127
|
+
throw new CliError(`Refusing to follow a symbolic link: ${link}.`, {
|
|
128
|
+
hint:
|
|
129
|
+
link === name
|
|
130
|
+
? `${name} is a link to somewhere else. envstash only reads and writes real files, so a link cannot redirect a secret. Replace it with the file itself.`
|
|
131
|
+
: `${name} is inside ${link}, which is a link to somewhere else. envstash only reads and writes real files inside the repo.`,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return target
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The first symbolic link on the way from `repoRoot` to `name` (as a repo-relative
|
|
140
|
+
* path), or null. Links above the repo root are not this CLI's business — a home
|
|
141
|
+
* directory on a symlinked volume is ordinary.
|
|
142
|
+
*/
|
|
143
|
+
export function findSymlink(repoRoot, name) {
|
|
144
|
+
const segments = name.split('/')
|
|
145
|
+
|
|
146
|
+
for (let depth = 1; depth <= segments.length; depth += 1) {
|
|
147
|
+
const partial = segments.slice(0, depth).join('/')
|
|
148
|
+
let stats
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
stats = lstatSync(join(repoRoot, partial))
|
|
152
|
+
} catch {
|
|
153
|
+
// Nothing here yet, so nothing below it either.
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (stats.isSymbolicLink()) {
|
|
158
|
+
return partial
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
69
165
|
/** Drop the names that are not safe, for the paths where refusing is too blunt. */
|
|
70
166
|
export function keepSafeRepoPaths(names) {
|
|
71
167
|
return names.filter(name => isSafeRepoPath(name))
|
|
72
168
|
}
|
|
73
169
|
|
|
74
170
|
function refuse(name) {
|
|
171
|
+
if (isWellFormedRepoPath(name) && isDeniedRepoPath(name)) {
|
|
172
|
+
return new CliError(`Refusing to use the file name ${JSON.stringify(String(name))}.`, {
|
|
173
|
+
hint: 'envstash never reads or writes .git/, node_modules/, .husky/ or .envstash.json — files there run code or redirect the CLI. Report this if it came from your own server.',
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
75
177
|
return new CliError(`Refusing to use the file name ${JSON.stringify(String(name))}.`, {
|
|
76
178
|
hint: 'A file name must stay inside the repo. Report this if it came from your own server.',
|
|
77
179
|
})
|
package/src/session.mjs
CHANGED
|
@@ -67,16 +67,21 @@ export function resolveHostSource(flags) {
|
|
|
67
67
|
return explicit
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
const link = readLink(findRepoRoot())
|
|
70
|
+
let link = null
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
72
|
+
try {
|
|
73
|
+
link = readLink(findRepoRoot())
|
|
76
74
|
} catch {
|
|
77
75
|
// A malformed link file is reported by the commands that actually read it.
|
|
78
76
|
}
|
|
79
77
|
|
|
78
|
+
// Outside the try: a host the CLI refuses (plain http to another machine) is
|
|
79
|
+
// an answer, not a malformed file, and falling back to the default instead
|
|
80
|
+
// would quietly run this repo's commands against a different server.
|
|
81
|
+
if (link?.host) {
|
|
82
|
+
return { host: resolveHost(link.host), source: 'link' }
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
return { host: resolveHost(undefined), source: 'default' }
|
|
81
86
|
}
|
|
82
87
|
|
package/src/status.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { digestFileBytes } from './blob.mjs'
|
|
2
|
-
import { digestValue } from './env-file.mjs'
|
|
2
|
+
import { digestValue, KEY_PATTERN } from './env-file.mjs'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Per-key verdicts.
|
|
@@ -39,7 +39,15 @@ export function compareFile(name, localEntries, cloudKeys) {
|
|
|
39
39
|
|
|
40
40
|
const keys = []
|
|
41
41
|
const localMap = new Map((localEntries ?? []).map(entry => [entry.key, entry.value]))
|
|
42
|
-
|
|
42
|
+
// A cloud key outside the variable-name grammar is dropped, not compared. The
|
|
43
|
+
// server enforces the same grammar on the way in, so only a hostile or broken
|
|
44
|
+
// server sends one — and a key name is printed by status and diff, which makes
|
|
45
|
+
// it a way to put escape sequences on the developer's terminal.
|
|
46
|
+
const cloudMap = new Map(
|
|
47
|
+
(cloudKeys ?? [])
|
|
48
|
+
.filter(entry => typeof entry?.key === 'string' && KEY_PATTERN.test(entry.key))
|
|
49
|
+
.map(entry => [entry.key, entry.digest])
|
|
50
|
+
)
|
|
43
51
|
|
|
44
52
|
for (const [key, value] of localMap) {
|
|
45
53
|
const cloudDigest = cloudMap.get(key)
|