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/README.md +126 -19
- package/package.json +1 -1
- package/src/api.mjs +10 -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/remove.mjs +72 -0
- 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 +57 -9
- package/src/local-write.mjs +34 -0
- package/src/manifest.mjs +2 -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/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
|
/**
|
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
|
@@ -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(
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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,
|
|
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
|
|