envstash-cli 0.10.0 → 0.11.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
@@ -244,7 +244,7 @@ on the dashboard under **Shared Files**, not inside any project.
244
244
  ```json
245
245
  {
246
246
  "project": "demo-app",
247
- "files": [".env.local", ".env.production"]
247
+ "files": [".env", ".env.production"]
248
248
  }
249
249
  ```
250
250
 
@@ -259,6 +259,16 @@ and `.agents/` (see below). That list is the default, and
259
259
  `.dist` are ignored, since those are meant to be committed and hold placeholder
260
260
  values.
261
261
 
262
+ `.env.local` and `.env.*.local` are not discovered either. Every dotenv loader
263
+ that layers files puts `.local` on top — `.env`, then `.env.<mode>`, then
264
+ `.env.local`, then the real environment — so by name it is the override a
265
+ developer writes to differ from the shared values on this one checkout. A tool
266
+ that distributes the shared values fills the lower layers and leaves that one
267
+ alone; syncing it would leave no layer that can differ between machines.
268
+ `init`, `push`, and `sync` say when they skip one. To sync it anyway, list it in
269
+ `.envstash.json` or name it on the command line — that is taken as written. A
270
+ `.local` file already in the cloud keeps syncing until you `envs rm` it.
271
+
262
272
  ### Files in `.config/`
263
273
 
264
274
  A service-account key or a cert is a secret that is not `KEY=value`, and it does
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -15,7 +15,7 @@ import {
15
15
  import { requireHostToken, resolveHostFor, resolveHostSource } from '../session.mjs'
16
16
  import { DEFAULT_SETTINGS, readSettings, settingsPath } from '../settings.mjs'
17
17
  import { out, pad, pluralize, sanitize, style } from '../ui.mjs'
18
- import { announceDiscoveryDirs } from './push.mjs'
18
+ import { announceDiscoveryDirs, announceLocalEnvFiles } from './push.mjs'
19
19
 
20
20
  /**
21
21
  * `envs version` — the version, and the environment it would act in.
@@ -266,6 +266,7 @@ export async function init(flags, args) {
266
266
  const files = existing?.files?.length
267
267
  ? existing.files
268
268
  : discoverTrackedFiles(repoRoot, readSettings().discover)
269
+ announceLocalEnvFiles(repoRoot, { discovering: !existing?.files?.length, covered: files })
269
270
  const path = writeLink(repoRoot, {
270
271
  project: name,
271
272
  files,
@@ -332,6 +333,7 @@ export async function link(flags, args) {
332
333
  const files = existing?.files?.length
333
334
  ? existing.files
334
335
  : discoverTrackedFiles(repoRoot, readSettings().discover)
336
+ announceLocalEnvFiles(repoRoot, { discovering: !existing?.files?.length, covered: files })
335
337
  const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
336
338
 
337
339
  out()
@@ -13,6 +13,8 @@ import {
13
13
  ensureSecretDirIgnored,
14
14
  ENV_FILES_TARGET,
15
15
  lacksIgnoreRule,
16
+ LINK_FILE,
17
+ localEnvFiles,
16
18
  SECRET_FILE_DIR,
17
19
  } from '../project.mjs'
18
20
  import { resolveRepoFile } from '../repo-path.mjs'
@@ -62,6 +64,39 @@ export function announceDiscoveryDirs(repoRoot, { discovering = true } = {}) {
62
64
  }
63
65
  }
64
66
 
67
+ /**
68
+ * Say which `.local` files the dotenv scan left behind, and how to list one.
69
+ *
70
+ * The scan skips them by name: `.local` is the per-checkout override layer, not
71
+ * the shared values. A developer whose secrets live in `.env.local` would
72
+ * otherwise see it silently missing from the list a command just decided.
73
+ *
74
+ * `covered` is that list. A `.local` file already in the cloud — pushed by an
75
+ * older version, or by name — is still synced through the cloud listing until
76
+ * `envs rm`, and saying otherwise would be false. Only when discovery decided
77
+ * the list (`discovering`) and the scan is on is there anything to point out.
78
+ */
79
+ export function announceLocalEnvFiles(repoRoot, { discovering = true, covered = [] } = {}) {
80
+ if (!discovering || !readSettings().discover.includes(ENV_FILES_TARGET)) {
81
+ return
82
+ }
83
+
84
+ const left = localEnvFiles(repoRoot).filter(name => !covered.includes(name))
85
+
86
+ if (left.length === 0) {
87
+ return
88
+ }
89
+
90
+ out()
91
+
92
+ for (const name of left) {
93
+ out(
94
+ ` ${style.dim('·')} ${style.bold(name)} is a local override, so it is not synced ` +
95
+ style.dim(`— list it in ${LINK_FILE} to sync it`)
96
+ )
97
+ }
98
+ }
99
+
65
100
  /**
66
101
  * `envs push` — send local files up.
67
102
  *
@@ -106,6 +141,11 @@ export async function push(flags, args) {
106
141
  requested: args,
107
142
  })
108
143
 
144
+ announceLocalEnvFiles(context.repoRoot, {
145
+ discovering: args.length === 0 && !context.link?.files?.length,
146
+ covered: names,
147
+ })
148
+
109
149
  const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
110
150
  const presentNames = names.filter(name => localFiles.get(name) !== null)
111
151
 
@@ -15,7 +15,13 @@ import { resolveRepoFile } from '../repo-path.mjs'
15
15
  import { compareProject } from '../status.mjs'
16
16
  import { conflictExitCode, isBlocked, isChanging, planSync, PREFER, SYNC_ACTION } from '../sync.mjs'
17
17
  import { CliError, out, pad, pluralize, sanitize, style } from '../ui.mjs'
18
- import { announceDiscoveryDirs, confirmNewFiles, fetchCloudFiles, pushOneBlob } from './push.mjs'
18
+ import {
19
+ announceDiscoveryDirs,
20
+ announceLocalEnvFiles,
21
+ confirmNewFiles,
22
+ fetchCloudFiles,
23
+ pushOneBlob,
24
+ } from './push.mjs'
19
25
 
20
26
  /**
21
27
  * `envs sync` — reconcile this repo with the cloud, in both directions at once.
@@ -70,6 +76,11 @@ export async function sync(flags, args) {
70
76
  sink: 'write',
71
77
  })
72
78
 
79
+ announceLocalEnvFiles(context.repoRoot, {
80
+ discovering: args.length === 0 && !context.link?.files?.length,
81
+ covered: names,
82
+ })
83
+
73
84
  const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
74
85
  const comparison = compareProject({
75
86
  cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
package/src/index.mjs CHANGED
@@ -444,8 +444,8 @@ const COMMAND_HELP = {
444
444
  '',
445
445
  ' { "discover": [".env*", ".config", ".agents"] }',
446
446
  '',
447
- '".env*" is the dotenv files in the repo root; anything else is a directory',
448
- 'whose git-ignored files are synced.',
447
+ '".env*" is the dotenv files in the repo root, minus templates and .local',
448
+ 'overrides; anything else is a directory whose git-ignored files are synced.',
449
449
  '',
450
450
  ' envs config',
451
451
  ' envs config --json',
package/src/project.mjs CHANGED
@@ -57,6 +57,17 @@ function runGit(args, { cwd, input } = {}) {
57
57
  */
58
58
  const TEMPLATE_SUFFIXES = ['.example', '.sample', '.template', '.dist']
59
59
 
60
+ /**
61
+ * The per-checkout override layer. Every dotenv loader that layers files agrees
62
+ * on the stack — `.env`, `.env.<mode>`, `.env.local`, `.env.<mode>.local`, then
63
+ * the real environment — and `.local` is, by name, what a developer writes to
64
+ * differ from the shared values on this one checkout. A tool that distributes
65
+ * the shared values fills the lower layers and leaves this one alone: syncing
66
+ * it would leave no layer that can differ between machines. Listing the file in
67
+ * the link file or naming it on the command line still syncs it.
68
+ */
69
+ const LOCAL_SUFFIX = '.local'
70
+
60
71
  /**
61
72
  * Walk up from `startDir` looking for a repo root.
62
73
  *
@@ -165,13 +176,14 @@ export function guessProjectName(repoRoot) {
165
176
  }
166
177
 
167
178
  /**
168
- * Discover `.env*` files in the repo root.
179
+ * The dotenv files in the repo root that could hold a real value: `.env` and
180
+ * `.env.*`, minus templates and type declarations, regular files only.
169
181
  *
170
182
  * Deliberately not recursive: walking a whole tree would wander into
171
183
  * node_modules and vendored fixtures, and a monorepo's nested files are better
172
184
  * declared explicitly in the link file than guessed at.
173
185
  */
174
- export function discoverEnvFiles(repoRoot) {
186
+ function rootEnvFiles(repoRoot) {
175
187
  let names
176
188
 
177
189
  try {
@@ -196,6 +208,19 @@ export function discoverEnvFiles(repoRoot) {
196
208
  .sort()
197
209
  }
198
210
 
211
+ /** Discover `.env*` files in the repo root — the shared layers, not `.local`. */
212
+ export function discoverEnvFiles(repoRoot) {
213
+ return rootEnvFiles(repoRoot).filter(name => !name.endsWith(LOCAL_SUFFIX))
214
+ }
215
+
216
+ /**
217
+ * The `.local` files discovery left behind, so a command can say why they are
218
+ * missing from a list it just decided.
219
+ */
220
+ export function localEnvFiles(repoRoot) {
221
+ return rootEnvFiles(repoRoot).filter(name => name.endsWith(LOCAL_SUFFIX))
222
+ }
223
+
199
224
  /**
200
225
  * Where credential *files* live, by convention: a service-account JSON, a cert, a
201
226
  * private key — the things that are secrets but are not `KEY=value`.