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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.7.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",
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
- putFile: (host, token, project, file, entries, prune) =>
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) =>
@@ -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
- if (file.state === FILE_STATE.IN_SYNC && !flags.force) {
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 — and for discovered files, ask about — every file this push would
176
- * 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.
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. A
180
- * gcloud token cache or a stray SQLite file was in the cloud before the
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
- * Asked on a terminal; refused without `--yes` anywhere else, since a CI job
186
- * 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.
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)
@@ -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 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()
@@ -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(context.host, context.token, scope, plan.name, entries, false)
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
- await api.putFile(context.host, context.token, scope, plan.name, entries, false)
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(` -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
+ }