envstash-cli 0.4.3 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Git-style CLI for EnvStash — keep your .env files in sync with your cloud account.",
5
5
  "keywords": [
6
6
  "env",
@@ -13,6 +13,7 @@ import {
13
13
  } from '../project.mjs'
14
14
  import { requireHostToken, resolveHostFor, resolveHostSource } from '../session.mjs'
15
15
  import { out, pad, pluralize, style } from '../ui.mjs'
16
+ import { announceIgnoredSecretDir } from './push.mjs'
16
17
 
17
18
  /**
18
19
  * `envs version` — the version, and the environment it would act in.
@@ -140,6 +141,10 @@ export async function init(flags, args) {
140
141
 
141
142
  await api.createProject(host, token, name)
142
143
 
144
+ // Before discovery, so a not-yet-ignored .config/ lands in the link file
145
+ // this init writes rather than being silently absent from it.
146
+ announceIgnoredSecretDir(repoRoot)
147
+
143
148
  const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
144
149
  const path = writeLink(repoRoot, {
145
150
  project: name,
@@ -201,6 +206,9 @@ export async function link(flags, args) {
201
206
  // that points at nothing.
202
207
  await api.projectStatus(host, token, name)
203
208
 
209
+ // Same moment as in init: discovery is about to decide the link file's list.
210
+ announceIgnoredSecretDir(repoRoot)
211
+
204
212
  const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
205
213
  const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
206
214
 
@@ -1,9 +1,23 @@
1
1
  import { api, ApiError } from '../api.mjs'
2
2
  import { formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
3
3
  import { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
4
+ import { ensureSecretDirIgnored, SECRET_FILE_DIR } from '../project.mjs'
4
5
  import { compareProject, FILE_STATE } from '../status.mjs'
5
6
  import { CliError, out, pluralize, style } from '../ui.mjs'
6
7
 
8
+ /** Say that .gitignore was edited — quietly rewriting a user's file is worse. */
9
+ export function announceIgnoredSecretDir(repoRoot) {
10
+ if (!ensureSecretDirIgnored(repoRoot)) {
11
+ return
12
+ }
13
+
14
+ out()
15
+ out(
16
+ ` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
17
+ style.dim('— secrets sync through EnvStash, not git')
18
+ )
19
+ }
20
+
7
21
  /**
8
22
  * `envs push` — send local files up.
9
23
  *
@@ -14,6 +28,14 @@ import { CliError, out, pluralize, style } from '../ui.mjs'
14
28
  */
15
29
  export async function push(flags, args) {
16
30
  const context = resolveContext(flags)
31
+
32
+ // Before discovery, so files in a not-yet-ignored .config/ are picked up by
33
+ // this very push rather than the next one. Not under --dry-run: a preview
34
+ // that edits files is not a preview.
35
+ if (!flags['dry-run']) {
36
+ announceIgnoredSecretDir(context.repoRoot)
37
+ }
38
+
17
39
  const cloudFiles = await fetchCloudFiles(context)
18
40
 
19
41
  const names = resolveFileNames({
package/src/project.mjs CHANGED
@@ -280,6 +280,74 @@ export function discoverSecretFiles(repoRoot) {
280
280
  return candidates.filter(name => ignored.has(name)).sort()
281
281
  }
282
282
 
283
+ /**
284
+ * Make sure `.config/` is git-ignored before discovery asks about it.
285
+ *
286
+ * Being ignored is discovery's signal for "secret", which makes an unignored
287
+ * `.config/` silently invisible to `envs push` — a real footgun: the developer
288
+ * sees a directory full of credentials and a push that ignores them, with
289
+ * nothing saying why. Rather than documenting that, push and init close it:
290
+ * when the directory has contents, nothing in it is tracked by git, and it is
291
+ * not already ignored, `.config/` is added to .gitignore. A `.config/` with
292
+ * tracked files is a repo that commits configuration there on purpose, and is
293
+ * left alone.
294
+ *
295
+ * Returns true when .gitignore was changed — the caller must say so out loud,
296
+ * because a CLI editing a file it does not own should never do it silently.
297
+ */
298
+ export function ensureSecretDirIgnored(repoRoot) {
299
+ let entries
300
+
301
+ try {
302
+ entries = readdirSync(join(repoRoot, SECRET_FILE_DIR))
303
+ } catch {
304
+ return false
305
+ }
306
+
307
+ if (entries.length === 0) {
308
+ return false
309
+ }
310
+
311
+ // Also the git-and-repo probe: outside a repository (or without git) this
312
+ // throws, and an ignore rule would mean nothing there anyway.
313
+ let tracked
314
+
315
+ try {
316
+ tracked = runGit(['ls-files', '--', SECRET_FILE_DIR], { cwd: repoRoot }).trim()
317
+ } catch {
318
+ return false
319
+ }
320
+
321
+ if (tracked !== '') {
322
+ return false
323
+ }
324
+
325
+ // A synthetic name, so one question covers the directory: any rule broad
326
+ // enough to ignore this probe ignores everything discovery would find.
327
+ const probe = `${SECRET_FILE_DIR}/envstash-ignore-probe`
328
+
329
+ if (gitIgnoredNames(repoRoot, [probe]).has(probe)) {
330
+ return false
331
+ }
332
+
333
+ const path = join(repoRoot, '.gitignore')
334
+ let existing = ''
335
+
336
+ try {
337
+ existing = readFileSync(path, 'utf8')
338
+ } catch {
339
+ // No .gitignore yet — the addition below becomes the whole file.
340
+ }
341
+
342
+ const addition = `# secret files — synced through EnvStash, not git\n${SECRET_FILE_DIR}/\n`
343
+ const body =
344
+ existing === '' ? addition : `${existing}${existing.endsWith('\n') ? '' : '\n'}\n${addition}`
345
+
346
+ writeFileSync(path, body)
347
+
348
+ return true
349
+ }
350
+
283
351
  /** Everything auto-discovery offers to track: root dotenv files, then `.config/` files. */
284
352
  export function discoverTrackedFiles(repoRoot) {
285
353
  return [...discoverEnvFiles(repoRoot), ...discoverSecretFiles(repoRoot)]