envstash-cli 0.7.0 → 0.9.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 +37 -13
- package/package.json +1 -1
- package/src/api.mjs +4 -2
- package/src/commands/push.mjs +52 -14
- package/src/commands/sync.mjs +31 -6
- package/src/index.mjs +8 -5
- package/src/settings.mjs +78 -0
package/README.md
CHANGED
|
@@ -119,11 +119,10 @@ definition and therefore has no undo:
|
|
|
119
119
|
file are left alone, so pushing a partial `.env` cannot destroy a teammate's
|
|
120
120
|
key. Use `--prune` when you do want the cloud to mirror local exactly.
|
|
121
121
|
- **`push` lists new files before uploading them.** Any file that is not in the
|
|
122
|
-
cloud yet is shown, with its size, before the first upload
|
|
122
|
+
cloud yet is shown, with its size, before the first upload, and files that
|
|
123
123
|
discovery found — not named on the command line and not listed in
|
|
124
|
-
`.envstash.json` —
|
|
125
|
-
|
|
126
|
-
cloud, later pushes of it go through without asking.
|
|
124
|
+
`.envstash.json` — are marked `(discovered)`. They are then uploaded. To be
|
|
125
|
+
asked first, turn on [`confirmNewFiles`](#settings).
|
|
127
126
|
- **`diff` masks values**, showing at most the first 4 characters and the
|
|
128
127
|
length, and nothing at all of a value 12 characters or shorter. Use
|
|
129
128
|
`--show-values` to reveal them.
|
|
@@ -321,15 +320,15 @@ kind wins — a file cannot silently change identity between dotenv and opaque.
|
|
|
321
320
|
|
|
322
321
|
## Configuration
|
|
323
322
|
|
|
324
|
-
| Variable | Purpose
|
|
325
|
-
| --------------------- |
|
|
326
|
-
| `ENVSTASH_TOKEN` | Use this token instead of the stored one — for CI
|
|
327
|
-
| `ENVSTASH_HOST` | Target another server (default `https://www.envstash.co`)
|
|
328
|
-
| `ENVSTASH_PORT` | Target a dev server on localhost at this port
|
|
329
|
-
| `ENVSTASH_CONFIG_DIR` | Where credentials live (default `~/.envstash`)
|
|
330
|
-
| `ENVSTASH_DEV` | Look for a local dev server to log in to
|
|
331
|
-
| `ENVSTASH_DEBUG` | Print stack traces on failure
|
|
332
|
-
| `NO_COLOR` | Disable colour (also off automatically when piped)
|
|
323
|
+
| Variable | Purpose |
|
|
324
|
+
| --------------------- | ----------------------------------------------------------- |
|
|
325
|
+
| `ENVSTASH_TOKEN` | Use this token instead of the stored one — for CI |
|
|
326
|
+
| `ENVSTASH_HOST` | Target another server (default `https://www.envstash.co`) |
|
|
327
|
+
| `ENVSTASH_PORT` | Target a dev server on localhost at this port |
|
|
328
|
+
| `ENVSTASH_CONFIG_DIR` | Where credentials and settings live (default `~/.envstash`) |
|
|
329
|
+
| `ENVSTASH_DEV` | Look for a local dev server to log in to |
|
|
330
|
+
| `ENVSTASH_DEBUG` | Print stack traces on failure |
|
|
331
|
+
| `NO_COLOR` | Disable colour (also off automatically when piped) |
|
|
333
332
|
|
|
334
333
|
In CI, set `ENVSTASH_TOKEN` from your platform's secret store **and set
|
|
335
334
|
`ENVSTASH_HOST`**, then run `envs pull` — no interactive login required.
|
|
@@ -342,6 +341,27 @@ checkout, and without this rule a repo could name a server and be handed your
|
|
|
342
341
|
credential. If a repo pins a host you have not used, the CLI says so and tells
|
|
343
342
|
you the `--host` to pass if you meant it.
|
|
344
343
|
|
|
344
|
+
### Settings
|
|
345
|
+
|
|
346
|
+
Preferences for this machine live in `~/.envstash/settings.json` — a separate
|
|
347
|
+
file from `config.json`, which holds your tokens and is not meant to be edited
|
|
348
|
+
by hand. The file is optional; create it to change a default.
|
|
349
|
+
|
|
350
|
+
```json
|
|
351
|
+
{
|
|
352
|
+
"confirmNewFiles": true
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
| Setting | Default | Purpose |
|
|
357
|
+
| ----------------- | ------- | ---------------------------------------------------------------------------------- |
|
|
358
|
+
| `confirmNewFiles` | `false` | Ask before `push` or `sync` uploads a file that discovery found for the first time |
|
|
359
|
+
|
|
360
|
+
With `confirmNewFiles` on, `push` and `sync` ask on a terminal, and anywhere
|
|
361
|
+
else (CI, a pipe) they stop unless you pass `--yes` (`-y`). Files you named on
|
|
362
|
+
the command line, files listed in `.envstash.json`, and files already in the
|
|
363
|
+
cloud are never asked about.
|
|
364
|
+
|
|
345
365
|
### Working against a local server
|
|
346
366
|
|
|
347
367
|
Every command takes `--port` as shorthand for a server on this machine, so
|
|
@@ -406,6 +426,10 @@ second-guessed.
|
|
|
406
426
|
regenerated rather than reproduced byte for byte: keys come back sorted, and
|
|
407
427
|
comments and blank lines are not preserved. The values themselves round-trip
|
|
408
428
|
exactly, including multi-line values such as PEM keys.
|
|
429
|
+
- `envs push` also sends the file's text, encrypted like the values, so the
|
|
430
|
+
dashboard's raw view shows the file as you wrote it — comments, blank lines
|
|
431
|
+
and key order — with the current values in place. An edit that touches only
|
|
432
|
+
comments is pushed too.
|
|
409
433
|
|
|
410
434
|
## License
|
|
411
435
|
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -325,11 +325,13 @@ export const api = {
|
|
|
325
325
|
headers: { 'x-envstash-file-kinds': 'env,file' },
|
|
326
326
|
}),
|
|
327
327
|
|
|
328
|
-
|
|
328
|
+
// `layout` is the file's own text, kept server-side so the dashboard can
|
|
329
|
+
// show the file with its comments and key order. Older servers ignore it.
|
|
330
|
+
putFile: (host, token, project, file, entries, prune, layout) =>
|
|
329
331
|
request(host, `/api/cli/projects/${encodeURIComponent(project)}/files/${encodePath(file)}`, {
|
|
330
332
|
method: 'PUT',
|
|
331
333
|
token,
|
|
332
|
-
body: { entries, prune },
|
|
334
|
+
body: layout === undefined ? { entries, prune } : { entries, prune, layout },
|
|
333
335
|
}),
|
|
334
336
|
|
|
335
337
|
putBlob: (host, token, project, file, contentBase64) =>
|
package/src/commands/push.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { statSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
2
|
|
|
3
3
|
import { api, ApiError } from '../api.mjs'
|
|
4
|
-
import { formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
|
|
4
|
+
import { digestFileBytes, formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
|
|
5
5
|
import {
|
|
6
6
|
cloudKinds,
|
|
7
7
|
loadLocalFiles,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../context.mjs'
|
|
12
12
|
import { ensureSecretDirIgnored, SECRET_FILE_DIR } from '../project.mjs'
|
|
13
13
|
import { resolveRepoFile } from '../repo-path.mjs'
|
|
14
|
+
import { readSettings } from '../settings.mjs'
|
|
14
15
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
15
16
|
import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
|
|
16
17
|
|
|
@@ -87,10 +88,20 @@ export async function push(flags, args) {
|
|
|
87
88
|
|
|
88
89
|
out()
|
|
89
90
|
|
|
91
|
+
const cloudByName = new Map(cloudFiles.map(file => [file.name, file]))
|
|
90
92
|
let pushed = 0
|
|
91
93
|
|
|
92
94
|
for (const file of comparison) {
|
|
93
|
-
|
|
95
|
+
// A dotenv file's text travels with its keys, so the dashboard can show it
|
|
96
|
+
// as written — comments, order and all. Read here rather than threaded
|
|
97
|
+
// through `loadLocalFiles`, whose entries every other command consumes.
|
|
98
|
+
const layout =
|
|
99
|
+
file.kind === 'file'
|
|
100
|
+
? undefined
|
|
101
|
+
: readFileSync(resolveRepoFile(context.repoRoot, file.name), 'utf8')
|
|
102
|
+
const layoutStale = isLayoutStale(cloudByName.get(file.name), layout)
|
|
103
|
+
|
|
104
|
+
if (file.state === FILE_STATE.IN_SYNC && !layoutStale && !flags.force) {
|
|
94
105
|
out(` ${style.dim('=')} ${file.name} ${style.dim('already up to date')}`)
|
|
95
106
|
continue
|
|
96
107
|
}
|
|
@@ -119,7 +130,8 @@ export async function push(flags, args) {
|
|
|
119
130
|
scope,
|
|
120
131
|
file.name,
|
|
121
132
|
entries,
|
|
122
|
-
Boolean(flags.prune)
|
|
133
|
+
Boolean(flags.prune),
|
|
134
|
+
layout
|
|
123
135
|
)
|
|
124
136
|
|
|
125
137
|
pushed += 1
|
|
@@ -145,7 +157,7 @@ export async function push(flags, args) {
|
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
if (parts.length === 0) {
|
|
148
|
-
parts.push(style.dim('no changes'))
|
|
160
|
+
parts.push(style.dim(layoutStale ? 'comments and layout updated' : 'no changes'))
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
out(` ${style.green('✓')} ${file.name} ${parts.join(style.dim(', '))}`)
|
|
@@ -172,18 +184,24 @@ export async function push(flags, args) {
|
|
|
172
184
|
}
|
|
173
185
|
|
|
174
186
|
/**
|
|
175
|
-
* Show
|
|
176
|
-
*
|
|
187
|
+
* Show every file this push would create in the cloud, before the first byte
|
|
188
|
+
* goes up — and, when the developer has asked to be asked, get a yes for the
|
|
189
|
+
* ones discovery found.
|
|
177
190
|
*
|
|
178
191
|
* Discovery sweeps root `.env*` files and everything git-ignored under
|
|
179
|
-
* `.config/`, recursively, and used to upload the lot and only then list it.
|
|
180
|
-
*
|
|
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.
|
|
192
|
+
* `.config/`, recursively, and used to upload the lot and only then list it.
|
|
193
|
+
* The list now always comes first, marking which files were discovered.
|
|
184
194
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
195
|
+
* Uploading them is the default: a developer who runs `envs push` in their own
|
|
196
|
+
* repo means "send my files up", and a question on every new file trained the
|
|
197
|
+
* answer to be `y`. Setting `"confirmNewFiles": true` in
|
|
198
|
+
* `~/.envstash/settings.json` restores the gate. A file already in the cloud,
|
|
199
|
+
* named on the command line, or listed in `.envstash.json` was chosen by
|
|
200
|
+
* someone; a file discovery found for the first time was not, so that is the
|
|
201
|
+
* set the gate asks about.
|
|
202
|
+
*
|
|
203
|
+
* With the gate on: asked on a terminal; refused without `--yes` anywhere
|
|
204
|
+
* else, since a CI job cannot answer.
|
|
187
205
|
*
|
|
188
206
|
* `envs sync` uploads new files too, and runs the same gate — `command` only
|
|
189
207
|
* changes which command name the messages tell the developer to re-run.
|
|
@@ -213,6 +231,10 @@ export async function confirmNewFiles(flags, context, args, comparison, { comman
|
|
|
213
231
|
return
|
|
214
232
|
}
|
|
215
233
|
|
|
234
|
+
if (!readSettings().confirmNewFiles) {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
216
238
|
const count = pluralize(discovered.length, 'discovered file')
|
|
217
239
|
|
|
218
240
|
if (isInteractive()) {
|
|
@@ -293,6 +315,22 @@ export async function pushOneBlob(flags, context, scope, file, local) {
|
|
|
293
315
|
return 1
|
|
294
316
|
}
|
|
295
317
|
|
|
318
|
+
/**
|
|
319
|
+
* True when the server keeps layouts and its copy of this file's text is not
|
|
320
|
+
* the local one — a comment or a reordering, which moves no value and so
|
|
321
|
+
* leaves the file "in sync" by the key comparison alone.
|
|
322
|
+
*
|
|
323
|
+
* A server without `layoutDigest` in its listing (before migration 0010)
|
|
324
|
+
* keeps no layouts, and re-pushing for one would never settle.
|
|
325
|
+
*/
|
|
326
|
+
export function isLayoutStale(cloudFile, layout) {
|
|
327
|
+
if (layout === undefined || !cloudFile || !('layoutDigest' in cloudFile)) {
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return cloudFile.layoutDigest !== digestFileBytes(Buffer.from(layout, 'utf8'))
|
|
332
|
+
}
|
|
333
|
+
|
|
296
334
|
export async function fetchCloudFiles(context, scope) {
|
|
297
335
|
try {
|
|
298
336
|
const response = await api.projectStatus(context.host, context.token, scope)
|
package/src/commands/sync.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
3
|
import { api } from '../api.mjs'
|
|
4
4
|
import { formatSize } from '../blob.mjs'
|
|
@@ -84,9 +84,9 @@ export async function sync(flags, args) {
|
|
|
84
84
|
const plans = planSync(comparison, { prefer })
|
|
85
85
|
const byName = new Map(comparison.map(file => [file.name, file]))
|
|
86
86
|
|
|
87
|
-
// The same
|
|
88
|
-
//
|
|
89
|
-
//
|
|
87
|
+
// The same listing — and, when `confirmNewFiles` is set, the same gate —
|
|
88
|
+
// `push` puts in front of a file discovery found on its own. Sync uploads
|
|
89
|
+
// new files too.
|
|
90
90
|
await confirmNewFiles(flags, context, args, comparison, { command: 'sync' })
|
|
91
91
|
|
|
92
92
|
out()
|
|
@@ -187,6 +187,13 @@ async function pullWholeFile(flags, context, scope, plan) {
|
|
|
187
187
|
return { changed: true, pulled: count, pushed: 0, detail }
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** A dotenv file's text on disk, sent with its keys as the server's layout —
|
|
191
|
+
* or nothing, for a file that is not there to read. */
|
|
192
|
+
function localText(context, name) {
|
|
193
|
+
const path = resolveRepoFile(context.repoRoot, name)
|
|
194
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
190
197
|
/** A file on disk the cloud has never seen — or one `--prefer local` settles. */
|
|
191
198
|
async function pushWholeFile(flags, context, scope, plan, file, local) {
|
|
192
199
|
if (plan.kind === 'file') {
|
|
@@ -202,7 +209,15 @@ async function pushWholeFile(flags, context, scope, plan, file, local) {
|
|
|
202
209
|
if (!flags['dry-run']) {
|
|
203
210
|
// Never prune. Sync's whole contract is that it does not delete, and this
|
|
204
211
|
// path also runs for `--prefer local` on a file that already exists.
|
|
205
|
-
await api.putFile(
|
|
212
|
+
await api.putFile(
|
|
213
|
+
context.host,
|
|
214
|
+
context.token,
|
|
215
|
+
scope,
|
|
216
|
+
plan.name,
|
|
217
|
+
entries,
|
|
218
|
+
false,
|
|
219
|
+
localText(context, plan.name)
|
|
220
|
+
)
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
return {
|
|
@@ -257,7 +272,17 @@ async function mergeOneFile(flags, context, scope, plan, local) {
|
|
|
257
272
|
.map(key => ({ key, value: localValues.get(key) }))
|
|
258
273
|
|
|
259
274
|
if (entries.length > 0 && !flags['dry-run']) {
|
|
260
|
-
|
|
275
|
+
// The text as it stands after the download half rewrote it, so the
|
|
276
|
+
// server's layout is the merged file, not the pre-merge one.
|
|
277
|
+
await api.putFile(
|
|
278
|
+
context.host,
|
|
279
|
+
context.token,
|
|
280
|
+
scope,
|
|
281
|
+
plan.name,
|
|
282
|
+
entries,
|
|
283
|
+
false,
|
|
284
|
+
localText(context, plan.name)
|
|
285
|
+
)
|
|
261
286
|
}
|
|
262
287
|
|
|
263
288
|
pushed = entries.length
|
package/src/index.mjs
CHANGED
|
@@ -288,7 +288,9 @@ function printHelp(topic) {
|
|
|
288
288
|
)
|
|
289
289
|
out(` --prefer <side> ${style.dim('Resolve conflicts as "local" or "cloud" (sync)')}`)
|
|
290
290
|
out(` --dry-run ${style.dim('Show what would happen (pull, push, sync, remove)')}`)
|
|
291
|
-
out(
|
|
291
|
+
out(
|
|
292
|
+
` -y, --yes ${style.dim('Skip the new-file question, if you turned it on (push, sync)')}`
|
|
293
|
+
)
|
|
292
294
|
out(
|
|
293
295
|
` --shared ${style.dim('Target your account-wide shared files (pull, push, sync, remove)')}`
|
|
294
296
|
)
|
|
@@ -378,9 +380,10 @@ const COMMAND_HELP = {
|
|
|
378
380
|
'Keys in the cloud that are absent locally are left alone unless --prune is',
|
|
379
381
|
'given, so pushing a partial .env cannot delete a teammate’s key.',
|
|
380
382
|
'',
|
|
381
|
-
'Files that would be new in the cloud are listed before anything uploads
|
|
382
|
-
'
|
|
383
|
-
'
|
|
383
|
+
'Files that would be new in the cloud are listed before anything uploads,',
|
|
384
|
+
'then uploaded. To be asked first about ones found by discovery — not',
|
|
385
|
+
'named, not in .envstash.json — put { "confirmNewFiles": true } in',
|
|
386
|
+
'~/.envstash/settings.json; --yes (-y) then answers for one run.',
|
|
384
387
|
'',
|
|
385
388
|
'--shared sends the named files to your account-wide shared files — for',
|
|
386
389
|
'credentials that follow you across projects, like a global .npmrc. Pull',
|
|
@@ -388,7 +391,7 @@ const COMMAND_HELP = {
|
|
|
388
391
|
'',
|
|
389
392
|
' envs push',
|
|
390
393
|
' envs push --dry-run',
|
|
391
|
-
' envs push --yes
|
|
394
|
+
' envs push --yes Skip the new-file question, if you turned it on',
|
|
392
395
|
' envs push --prune',
|
|
393
396
|
' envs push --shared .npmrc',
|
|
394
397
|
],
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { configDir } from './config.mjs'
|
|
5
|
+
import { CliError } from './ui.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Preferences for this machine, next to — and deliberately not inside — the
|
|
9
|
+
* credential file. `config.json` holds every token and is rewritten by login
|
|
10
|
+
* and logout; a file meant to be edited by hand should not be one where a
|
|
11
|
+
* stray comma signs the developer out of everything.
|
|
12
|
+
*
|
|
13
|
+
* Per machine rather than per repo: `.envstash.json` travels with a clone, and
|
|
14
|
+
* how much a developer wants to be asked is theirs to decide, not the repo's.
|
|
15
|
+
*
|
|
16
|
+
* {
|
|
17
|
+
* "confirmNewFiles": true
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULTS = {
|
|
21
|
+
/** Ask before uploading a file that discovery found for the first time. */
|
|
22
|
+
confirmNewFiles: false,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function settingsPath() {
|
|
26
|
+
return join(configDir(), 'settings.json')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The settings, with defaults for whatever the file does not say — or for a
|
|
31
|
+
* file that is not there, which is the normal case.
|
|
32
|
+
*
|
|
33
|
+
* A value of the wrong type is refused rather than coerced: `"false"` is a
|
|
34
|
+
* truthy string, and reading it as "yes, ask me" or as "no, don't" would both
|
|
35
|
+
* be a guess about a safety preference.
|
|
36
|
+
*/
|
|
37
|
+
export function readSettings() {
|
|
38
|
+
const path = settingsPath()
|
|
39
|
+
|
|
40
|
+
if (!existsSync(path)) {
|
|
41
|
+
return { ...DEFAULTS }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let parsed
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
48
|
+
} catch {
|
|
49
|
+
throw new CliError(`Could not read ${path} — it is not valid JSON.`, {
|
|
50
|
+
hint: 'Fix the file, or delete it to go back to the defaults.',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
55
|
+
throw new CliError(`${path} must hold a JSON object.`, {
|
|
56
|
+
hint: 'Example: { "confirmNewFiles": true }',
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const settings = { ...DEFAULTS }
|
|
61
|
+
|
|
62
|
+
for (const [key, fallback] of Object.entries(DEFAULTS)) {
|
|
63
|
+
if (!(key in parsed)) {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof parsed[key] !== typeof fallback) {
|
|
68
|
+
throw new CliError(
|
|
69
|
+
`"${key}" in ${path} must be ${typeof fallback === 'boolean' ? 'true or false' : `a ${typeof fallback}`}.`,
|
|
70
|
+
{ hint: `Example: { "${key}": ${JSON.stringify(!fallback)} }` }
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
settings[key] = parsed[key]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return settings
|
|
78
|
+
}
|