envstash-cli 0.8.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 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. Files that
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` — need a yes: `push` asks on a terminal, and anywhere else
125
- (CI, a pipe) it stops unless you pass `--yes` (`-y`). Once a file is in the
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Git-style CLI for EnvStash — keep your .env files in sync with your cloud account.",
5
5
  "keywords": [
6
6
  "env",
@@ -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
 
@@ -183,18 +184,24 @@ export async function push(flags, args) {
183
184
  }
184
185
 
185
186
  /**
186
- * Show — and for discovered files, ask about — every file this push would
187
- * create in the cloud, before the first byte goes up.
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.
188
190
  *
189
191
  * Discovery sweeps root `.env*` files and everything git-ignored under
190
- * `.config/`, recursively, and used to upload the lot and only then list it. A
191
- * gcloud token cache or a stray SQLite file was in the cloud before the
192
- * developer saw its name. A file already in the cloud, named on the command
193
- * line, or listed in `.envstash.json` was chosen by someone; a file discovery
194
- * 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.
195
194
  *
196
- * Asked on a terminal; refused without `--yes` anywhere else, since a CI job
197
- * cannot answer and silently uploading is the thing being fixed.
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.
198
205
  *
199
206
  * `envs sync` uploads new files too, and runs the same gate — `command` only
200
207
  * changes which command name the messages tell the developer to re-run.
@@ -224,6 +231,10 @@ export async function confirmNewFiles(flags, context, args, comparison, { comman
224
231
  return
225
232
  }
226
233
 
234
+ if (!readSettings().confirmNewFiles) {
235
+ return
236
+ }
237
+
227
238
  const count = pluralize(discovered.length, 'discovered file')
228
239
 
229
240
  if (isInteractive()) {
@@ -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 gate `push` puts in front of a file discovery found on its own.
88
- // Sync uploads new files too, and "it synced" is no more of an excuse for a
89
- // gcloud token cache reaching the cloud unasked than "it pushed" was.
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()
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(` -y, --yes ${style.dim('Upload newly discovered files without asking (push)')}`)
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
- 'Ones found by discovery — not named, not in .envstash.json — need a yes:',
383
- 'asked on a terminal, --yes (-y) anywhere else.',
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 Upload newly discovered files without asking',
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
  ],
@@ -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
+ }