envstash-cli 0.4.2 → 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.2",
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",
@@ -1,4 +1,4 @@
1
- import { execFile } from 'node:child_process'
1
+ import { spawn } from 'node:child_process'
2
2
  import { hostname, userInfo } from 'node:os'
3
3
  import { setTimeout as sleep } from 'node:timers/promises'
4
4
 
@@ -53,7 +53,12 @@ export async function login(flags) {
53
53
  out()
54
54
  out(` Your code: ${style.bold(style.cyan(started.userCode))}`)
55
55
  out()
56
- out(` ${style.dim('Open')} ${style.blue(verificationUrl ?? `${host}/cli`)}`)
56
+ // The URL on its own line, in the same visible cyan as the code above it:
57
+ // blue vanishes on dark terminals, and a line that is only the URL is what
58
+ // lets a terminal's double-click and link detection grab the whole thing.
59
+ out(` ${style.dim('Open this link to approve:')}`)
60
+ out()
61
+ out(` ${style.cyan(verificationUrl ?? `${host}/cli`)}`)
57
62
  out()
58
63
 
59
64
  if (!verificationUrl) {
@@ -217,8 +222,23 @@ function openBrowser(url) {
217
222
  }
218
223
 
219
224
  try {
220
- child = execFile(command.file, command.args, { stdio: 'ignore' }, error => settle(!error))
225
+ // spawn, not execFile: execFile builds its own options object for spawn
226
+ // and does not forward `stdio`, so asking it for 'ignore' silently got
227
+ // the default three pipes instead. The opener passes those on to the
228
+ // browser it exec-s, and child.unref() releases the process handle but
229
+ // not the pipe handles — so the event loop stayed alive on a browser that
230
+ // could outlive the terminal, and `envs login` printed "✓ Signed in" and
231
+ // then hung at the prompt. Detaching as well puts the browser in its own
232
+ // process group, so Ctrl-C on a still-waiting login does not take it down
233
+ // (not on Windows, where detached means a new console window).
234
+ child = spawn(command.file, command.args, {
235
+ stdio: 'ignore',
236
+ detached: process.platform !== 'win32',
237
+ windowsHide: true,
238
+ })
239
+
221
240
  child.on('error', () => settle(false))
241
+ child.on('exit', code => settle(code === 0))
222
242
  } catch {
223
243
  settle(false)
224
244
  return
@@ -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)]