envstash-cli 0.8.0 → 0.10.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
@@ -46,6 +46,7 @@ envs sync
46
46
  | `push` | Send local files to the cloud |
47
47
  | `sync` | Reconcile both directions at once, keeping every key |
48
48
  | `remove` | Delete files from the cloud (local copies stay) |
49
+ | `config` | Show the settings in effect and where each came from |
49
50
  | `version` | Show the CLI version and its environment |
50
51
 
51
52
  Run `envs help <command>` for details on any one of them.
@@ -119,11 +120,10 @@ definition and therefore has no undo:
119
120
  file are left alone, so pushing a partial `.env` cannot destroy a teammate's
120
121
  key. Use `--prune` when you do want the cloud to mirror local exactly.
121
122
  - **`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
123
+ cloud yet is shown, with its size, before the first upload, and files that
123
124
  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.
125
+ `.envstash.json` — are marked `(discovered)`. They are then uploaded. To be
126
+ asked first, turn on [`confirmNewFiles`](#settings).
127
127
  - **`diff` masks values**, showing at most the first 4 characters and the
128
128
  length, and nothing at all of a value 12 characters or shorter. Use
129
129
  `--show-values` to reveal them.
@@ -254,7 +254,8 @@ files to track — and it is what lets a teammate clone the repo and run
254
254
 
255
255
  Without a link file the CLI guesses the project from your git remote's repo
256
256
  name and discovers `.env*` files in the repo root, plus files in `.config/`
257
- (see below). Dotenv files ending in `.example`, `.sample`, `.template`, and
257
+ and `.agents/` (see below). That list is the default, and
258
+ [`discover`](#settings) changes it. Dotenv files ending in `.example`, `.sample`, `.template`, and
258
259
  `.dist` are ignored, since those are meant to be committed and hold placeholder
259
260
  values.
260
261
 
@@ -288,8 +289,40 @@ than the one you are standing in.
288
289
  Outside a git repository, or if `git` is not installed, discovery adds nothing
289
290
  from `.config/` rather than guessing — over-discovery would upload a file nobody
290
291
  chose to share, while under-discovery just means listing it in `.envstash.json`
291
- by hand. The scan covers `.config/` and everything under it; files anywhere else
292
- are declared explicitly.
292
+ by hand. The scan covers each discovered directory and everything under it;
293
+ files anywhere else are declared explicitly.
294
+
295
+ ### Files in `.agents/`
296
+
297
+ `.agents/` is where a repo keeps what its AI coding agents read — skills, rules,
298
+ per-tool settings. When the directory exists it is discovered the same way as
299
+ `.config/`, with two differences:
300
+
301
+ - **Documents are kept.** Skills and rules are markdown, so the rule that keeps
302
+ a README out of `.config/` does not apply.
303
+ - **It is never added to `.gitignore` for you.** Plenty of repos commit
304
+ `.agents/`, and ignoring it on the way past would make the next `git add -A`
305
+ silently leave it behind.
306
+
307
+ Git-ignore is still the gate, so there are three cases:
308
+
309
+ | `.agents/` is… | What `push` and `sync` do |
310
+ | ----------------------------- | ------------------------------------------------- |
311
+ | git-ignored | Sync its files |
312
+ | committed | Nothing — git already carries it |
313
+ | neither ignored nor committed | Nothing, and say so, so you can pick one of those |
314
+
315
+ A `.agents` that is a symlink (to `.claude`, say) is skipped, like every other
316
+ link.
317
+
318
+ ### Choosing what is discovered
319
+
320
+ Discovery only decides the file list for a repo that has none of its own. Once
321
+ `.envstash.json` lists `files`, that list is used as written — add
322
+ `.agents/…` paths to it by hand, or remove `files` to go back to discovery.
323
+
324
+ To change the defaults on this machine, set [`discover`](#settings). Run
325
+ `envs config` to see the list in effect and the files it currently finds.
293
326
 
294
327
  Monorepos can track nested paths by listing them explicitly:
295
328
 
@@ -321,15 +354,15 @@ kind wins — a file cannot silently change identity between dotenv and opaque.
321
354
 
322
355
  ## Configuration
323
356
 
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) |
357
+ | Variable | Purpose |
358
+ | --------------------- | ----------------------------------------------------------- |
359
+ | `ENVSTASH_TOKEN` | Use this token instead of the stored one — for CI |
360
+ | `ENVSTASH_HOST` | Target another server (default `https://www.envstash.co`) |
361
+ | `ENVSTASH_PORT` | Target a dev server on localhost at this port |
362
+ | `ENVSTASH_CONFIG_DIR` | Where credentials and settings live (default `~/.envstash`) |
363
+ | `ENVSTASH_DEV` | Look for a local dev server to log in to |
364
+ | `ENVSTASH_DEBUG` | Print stack traces on failure |
365
+ | `NO_COLOR` | Disable colour (also off automatically when piped) |
333
366
 
334
367
  In CI, set `ENVSTASH_TOKEN` from your platform's secret store **and set
335
368
  `ENVSTASH_HOST`**, then run `envs pull` — no interactive login required.
@@ -342,6 +375,45 @@ checkout, and without this rule a repo could name a server and be handed your
342
375
  credential. If a repo pins a host you have not used, the CLI says so and tells
343
376
  you the `--host` to pass if you meant it.
344
377
 
378
+ ### Settings
379
+
380
+ Preferences for this machine live in `~/.envstash/settings.json` — a separate
381
+ file from `config.json`, which holds your tokens and is not meant to be edited
382
+ by hand. The file is optional; create it to change a default.
383
+
384
+ ```json
385
+ {
386
+ "confirmNewFiles": true,
387
+ "discover": [".env*", ".config", ".agents"]
388
+ }
389
+ ```
390
+
391
+ - **`confirmNewFiles`** (default `false`) — ask before `push` or `sync` uploads
392
+ a file that discovery found for the first time.
393
+ - **`discover`** (default `[".env*", ".config", ".agents"]`) — what is synced in
394
+ a repo that does not list its own files.
395
+
396
+ `discover` **replaces** the default list, so leaving an entry out turns it off:
397
+ `[".env*", ".config"]` stops syncing `.agents/`, and `[]` discovers nothing.
398
+ `".env*"` means the dotenv files in the repo root. Any other entry is a
399
+ directory inside the repo, whose git-ignored files are synced — add `".claude"`
400
+ to carry local agent settings, for example. `.git`, `node_modules`, and paths
401
+ that leave the repo are refused.
402
+
403
+ It lives here rather than in `.envstash.json` on purpose: that file arrives
404
+ with a clone, and a repo should not get to choose which of your directories are
405
+ uploaded. Taking a directory out of `discover` stops new files being found; files
406
+ already in the cloud keep syncing until you `envs rm` them.
407
+
408
+ `envs config` prints every setting in effect and whether it came from this file
409
+ or is a default, along with the server, account, and this repo's project and
410
+ files. It never prints a token.
411
+
412
+ With `confirmNewFiles` on, `push` and `sync` ask on a terminal, and anywhere
413
+ else (CI, a pipe) they stop unless you pass `--yes` (`-y`). Files you named on
414
+ the command line, files listed in `.envstash.json`, and files already in the
415
+ cloud are never asked about.
416
+
345
417
  ### Working against a local server
346
418
 
347
419
  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.10.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",
@@ -1,19 +1,21 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
5
  import { api } from '../api.mjs'
6
- import { configDir, getStoredEmail } from '../config.mjs'
6
+ import { configDir, configPath, getStoredEmail, getToken } from '../config.mjs'
7
7
  import {
8
8
  discoverTrackedFiles,
9
9
  findRepoRoot,
10
+ LINK_FILE,
10
11
  readLink,
11
12
  resolveProject,
12
13
  writeLink,
13
14
  } from '../project.mjs'
14
15
  import { requireHostToken, resolveHostFor, resolveHostSource } from '../session.mjs'
16
+ import { DEFAULT_SETTINGS, readSettings, settingsPath } from '../settings.mjs'
15
17
  import { out, pad, pluralize, sanitize, style } from '../ui.mjs'
16
- import { announceIgnoredSecretDir } from './push.mjs'
18
+ import { announceDiscoveryDirs } from './push.mjs'
17
19
 
18
20
  /**
19
21
  * `envs version` — the version, and the environment it would act in.
@@ -76,6 +78,120 @@ function sourceLabel(source) {
76
78
  )
77
79
  }
78
80
 
81
+ /**
82
+ * `envs config` — every setting in effect, and where each one came from.
83
+ *
84
+ * The CLI's behaviour is decided in four places — `settings.json`, the
85
+ * credential file, the environment, and the repo's link file — and none of them
86
+ * is on screen when a command does something unexpected. This prints the
87
+ * resolved answer from all four, read through the same functions the sync
88
+ * commands use, so it cannot describe a configuration they would not act on.
89
+ *
90
+ * Never the token: only whether there is one, and which place supplied it.
91
+ * Nothing here touches the network, so it works signed out and offline.
92
+ */
93
+ export async function config(flags) {
94
+ const settings = readSettings()
95
+ const { host, source } = resolveHostSource(flags)
96
+ const repoRoot = findRepoRoot()
97
+ const link = readLink(repoRoot)
98
+ const project = resolveProject(repoRoot, flags.project)
99
+ const linked = Boolean(link?.files?.length)
100
+
101
+ const details = {
102
+ configDir: configDir(),
103
+ settingsFile: { path: settingsPath(), exists: existsSync(settingsPath()) },
104
+ settings,
105
+ // Which settings the file changed, so a default is never mistaken for a choice.
106
+ changed: Object.keys(settings).filter(
107
+ key => JSON.stringify(settings[key]) !== JSON.stringify(DEFAULT_SETTINGS[key])
108
+ ),
109
+ credentialsFile: { path: configPath(), exists: existsSync(configPath()) },
110
+ host,
111
+ hostSource: source,
112
+ account: getStoredEmail(host),
113
+ token: tokenSource(host, source),
114
+ repo: {
115
+ root: repoRoot,
116
+ linkFile: link ? LINK_FILE : null,
117
+ project: project.name,
118
+ projectSource: project.source,
119
+ files: linked ? link.files : discoverTrackedFiles(repoRoot, settings.discover),
120
+ filesSource: linked ? 'link' : 'discovery',
121
+ },
122
+ }
123
+
124
+ if (flags.json) {
125
+ out(JSON.stringify(details, null, 2))
126
+ return 0
127
+ }
128
+
129
+ const width = 18
130
+ const row = (label, value, note) =>
131
+ out(` ${pad(label, width)}${value}${note ? style.dim(` (${note})`) : ''}`)
132
+ const origin = key => (details.changed.includes(key) ? 'settings.json' : 'default')
133
+
134
+ out()
135
+ out(` ${style.bold('Settings')} ${style.dim(details.settingsFile.path)}`)
136
+ if (!details.settingsFile.exists) {
137
+ out(` ${style.dim('No settings file — every value below is a default.')}`)
138
+ }
139
+ out()
140
+ for (const [key, value] of Object.entries(settings)) {
141
+ row(key, Array.isArray(value) ? value.join(', ') || style.dim('nothing') : value, origin(key))
142
+ }
143
+
144
+ out()
145
+ out(` ${style.bold('Server')}`)
146
+ out()
147
+ row('host', host, sourceLabel(source))
148
+ row('account', details.account ?? style.dim('not signed in'))
149
+ row('token', TOKEN_LABELS[details.token])
150
+ row('credentials', style.dim(details.credentialsFile.path))
151
+
152
+ out()
153
+ out(` ${style.bold('This repo')} ${style.dim(repoRoot)}`)
154
+ out()
155
+ row('project', project.name, PROJECT_LABELS[project.source])
156
+ row('link file', link ? LINK_FILE : style.dim('none'))
157
+ row(
158
+ 'files',
159
+ details.repo.files.length ? pluralize(details.repo.files.length, 'file') : style.dim('none'),
160
+ linked ? `listed in ${LINK_FILE}` : 'found by discovery'
161
+ )
162
+ for (const file of details.repo.files) {
163
+ out(` ${style.dim('·')} ${file}`)
164
+ }
165
+ out()
166
+
167
+ return 0
168
+ }
169
+
170
+ /**
171
+ * Where this command's token would come from, without revealing it. Mirrors
172
+ * `requireHostToken`: the environment's token is not spent on a host the link
173
+ * file chose.
174
+ */
175
+ function tokenSource(host, hostSource) {
176
+ if (hostSource !== 'link' && process.env.ENVSTASH_TOKEN) {
177
+ return 'env'
178
+ }
179
+
180
+ return getToken(host, { allowEnvToken: false }) ? 'stored' : 'none'
181
+ }
182
+
183
+ const TOKEN_LABELS = {
184
+ env: 'from ENVSTASH_TOKEN',
185
+ stored: 'stored on this machine',
186
+ none: 'none',
187
+ }
188
+
189
+ const PROJECT_LABELS = {
190
+ flag: 'from --project',
191
+ link: `from ${LINK_FILE}`,
192
+ guess: 'guessed from the repo name',
193
+ }
194
+
79
195
  /** `envs whoami` — which account this machine is signed in as. */
80
196
  export async function whoami(flags) {
81
197
  const { host, token } = await requireHostToken(flags)
@@ -145,9 +261,11 @@ export async function init(flags, args) {
145
261
 
146
262
  // Before discovery, so a not-yet-ignored .config/ lands in the link file
147
263
  // this init writes rather than being silently absent from it.
148
- announceIgnoredSecretDir(repoRoot)
264
+ announceDiscoveryDirs(repoRoot, { discovering: !existing?.files?.length })
149
265
 
150
- const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
266
+ const files = existing?.files?.length
267
+ ? existing.files
268
+ : discoverTrackedFiles(repoRoot, readSettings().discover)
151
269
  const path = writeLink(repoRoot, {
152
270
  project: name,
153
271
  files,
@@ -209,9 +327,11 @@ export async function link(flags, args) {
209
327
  await api.projectStatus(host, token, name)
210
328
 
211
329
  // Same moment as in init: discovery is about to decide the link file's list.
212
- announceIgnoredSecretDir(repoRoot)
330
+ announceDiscoveryDirs(repoRoot, { discovering: !existing?.files?.length })
213
331
 
214
- const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
332
+ const files = existing?.files?.length
333
+ ? existing.files
334
+ : discoverTrackedFiles(repoRoot, readSettings().discover)
215
335
  const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
216
336
 
217
337
  out()
@@ -9,22 +9,57 @@ import {
9
9
  resolveFileNames,
10
10
  SHARED_SCOPE,
11
11
  } from '../context.mjs'
12
- import { ensureSecretDirIgnored, SECRET_FILE_DIR } from '../project.mjs'
12
+ import {
13
+ ensureSecretDirIgnored,
14
+ ENV_FILES_TARGET,
15
+ lacksIgnoreRule,
16
+ SECRET_FILE_DIR,
17
+ } from '../project.mjs'
13
18
  import { resolveRepoFile } from '../repo-path.mjs'
19
+ import { readSettings } from '../settings.mjs'
14
20
  import { compareProject, FILE_STATE } from '../status.mjs'
15
21
  import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
16
22
 
17
- /** Say that .gitignore was edited — quietly rewriting a user's file is worse. */
18
- export function announceIgnoredSecretDir(repoRoot) {
19
- if (!ensureSecretDirIgnored(repoRoot)) {
20
- return
23
+ /**
24
+ * Get the discovered directories ready, and say what was done or left undone.
25
+ *
26
+ * Discovery only offers files git ignores, so a directory with contents that
27
+ * nothing tracks and nothing ignores is carried by neither git nor envstash.
28
+ * For `.config/` that is closed by adding the ignore rule — and saying so, since
29
+ * quietly rewriting a user's file is worse. For any other directory it is only
30
+ * pointed out: `.agents/` is committed in plenty of repos, and ignoring it on
31
+ * the way past would make the next `git add -A` silently leave it behind.
32
+ *
33
+ * A directory the developer took out of `discover` is not touched or mentioned.
34
+ * `discovering` is false when this run's files were chosen by name or by the
35
+ * link file: discovery is not deciding anything then, so there is nothing to
36
+ * point out.
37
+ */
38
+ export function announceDiscoveryDirs(repoRoot, { discovering = true } = {}) {
39
+ const dirs = readSettings().discover.filter(target => target !== ENV_FILES_TARGET)
40
+
41
+ if (dirs.includes(SECRET_FILE_DIR) && ensureSecretDirIgnored(repoRoot)) {
42
+ out()
43
+ out(
44
+ ` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
45
+ style.dim('— secrets sync through EnvStash, not git')
46
+ )
21
47
  }
22
48
 
23
- out()
24
- out(
25
- ` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
26
- style.dim('— secrets sync through EnvStash, not git')
27
- )
49
+ const leftToGit = discovering
50
+ ? dirs.filter(dir => dir !== SECRET_FILE_DIR && lacksIgnoreRule(repoRoot, dir))
51
+ : []
52
+
53
+ if (leftToGit.length > 0) {
54
+ out()
55
+ }
56
+
57
+ for (const dir of leftToGit) {
58
+ out(
59
+ ` ${style.yellow('!')} ${style.bold(`${dir}/`)} is not git-ignored, so it is not synced ` +
60
+ style.dim('— add it to .gitignore to sync it through EnvStash, or commit it')
61
+ )
62
+ }
28
63
  }
29
64
 
30
65
  /**
@@ -57,7 +92,9 @@ export async function push(flags, args) {
57
92
  // this very push rather than the next one. Not under --dry-run: a preview
58
93
  // that edits files is not a preview.
59
94
  if (!flags['dry-run']) {
60
- announceIgnoredSecretDir(context.repoRoot)
95
+ announceDiscoveryDirs(context.repoRoot, {
96
+ discovering: args.length === 0 && !context.link?.files?.length,
97
+ })
61
98
  }
62
99
 
63
100
  const cloudFiles = await fetchCloudFiles(context, scope)
@@ -183,18 +220,25 @@ export async function push(flags, args) {
183
220
  }
184
221
 
185
222
  /**
186
- * Show — and for discovered files, ask about — every file this push would
187
- * create in the cloud, before the first byte goes up.
223
+ * Show every file this push would create in the cloud, before the first byte
224
+ * goes up — and, when the developer has asked to be asked, get a yes for the
225
+ * ones discovery found.
188
226
  *
189
227
  * 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.
228
+ * `.config/` and `.agents/`, recursively, and used to upload the lot and only
229
+ * then list it.
230
+ * The list now always comes first, marking which files were discovered.
231
+ *
232
+ * Uploading them is the default: a developer who runs `envs push` in their own
233
+ * repo means "send my files up", and a question on every new file trained the
234
+ * answer to be `y`. Setting `"confirmNewFiles": true` in
235
+ * `~/.envstash/settings.json` restores the gate. A file already in the cloud,
236
+ * named on the command line, or listed in `.envstash.json` was chosen by
237
+ * someone; a file discovery found for the first time was not, so that is the
238
+ * set the gate asks about.
195
239
  *
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.
240
+ * With the gate on: asked on a terminal; refused without `--yes` anywhere
241
+ * else, since a CI job cannot answer.
198
242
  *
199
243
  * `envs sync` uploads new files too, and runs the same gate — `command` only
200
244
  * changes which command name the messages tell the developer to re-run.
@@ -224,6 +268,10 @@ export async function confirmNewFiles(flags, context, args, comparison, { comman
224
268
  return
225
269
  }
226
270
 
271
+ if (!readSettings().confirmNewFiles) {
272
+ return
273
+ }
274
+
227
275
  const count = pluralize(discovered.length, 'discovered file')
228
276
 
229
277
  if (isInteractive()) {
@@ -15,7 +15,7 @@ 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 { announceIgnoredSecretDir, confirmNewFiles, fetchCloudFiles, pushOneBlob } from './push.mjs'
18
+ import { announceDiscoveryDirs, confirmNewFiles, fetchCloudFiles, pushOneBlob } from './push.mjs'
19
19
 
20
20
  /**
21
21
  * `envs sync` — reconcile this repo with the cloud, in both directions at once.
@@ -53,7 +53,9 @@ export async function sync(flags, args) {
53
53
  // .config/ is covered by this run rather than the next one, and never under
54
54
  // --dry-run, because a preview that edits files is not a preview.
55
55
  if (!flags['dry-run']) {
56
- announceIgnoredSecretDir(context.repoRoot)
56
+ announceDiscoveryDirs(context.repoRoot, {
57
+ discovering: args.length === 0 && !context.link?.files?.length,
58
+ })
57
59
  }
58
60
 
59
61
  const cloudFiles = await fetchCloudFiles(context, scope)
@@ -84,9 +86,9 @@ export async function sync(flags, args) {
84
86
  const plans = planSync(comparison, { prefer })
85
87
  const byName = new Map(comparison.map(file => [file.name, file]))
86
88
 
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.
89
+ // The same listing — and, when `confirmNewFiles` is set, the same gate —
90
+ // `push` puts in front of a file discovery found on its own. Sync uploads
91
+ // new files too.
90
92
  await confirmNewFiles(flags, context, args, comparison, { command: 'sync' })
91
93
 
92
94
  out()
package/src/context.mjs CHANGED
@@ -5,6 +5,7 @@ import { parseEnvFile } from './env-file.mjs'
5
5
  import { discoverTrackedFiles, findRepoRoot, readLink, resolveProject } from './project.mjs'
6
6
  import { findSymlink, resolveInsideRepo, resolveRepoFile } from './repo-path.mjs'
7
7
  import { requireHostToken } from './session.mjs'
8
+ import { readSettings } from './settings.mjs'
8
9
  import { errOut, pluralize, style } from './ui.mjs'
9
10
 
10
11
  /**
@@ -35,8 +36,9 @@ export async function resolveContext(flags) {
35
36
  * Explicit arguments win. Otherwise the link file's list is authoritative if
36
37
  * present — an explicit list is the only way to include a nested path like
37
38
  * `apps/web/.env.local`, which discovery does not scan for. With no link file,
38
- * fall back to the union of what discovery finds — root `.env*` files plus git-ignored
39
- * files in `.config/` — and whatever the cloud holds. Those inferred names skip
39
+ * fall back to the union of what discovery finds — by default root `.env*` files plus
40
+ * git-ignored files in `.config/` and `.agents/`; the `discover` setting says otherwise —
41
+ * and whatever the cloud holds. Those inferred names skip
40
42
  * symbolic links with a warning rather than refusing (see `skipSymlinks`);
41
43
  * `sink` is `'write'` for `pull`, so the warning says the file is not written.
42
44
  */
@@ -49,7 +51,10 @@ export function resolveFileNames({ repoRoot, link, cloudFiles, requested, sink =
49
51
  return check([...new Set(link.files)].sort())
50
52
  }
51
53
 
52
- const names = new Set([...discoverTrackedFiles(repoRoot), ...cloudFiles.map(file => file.name)])
54
+ const names = new Set([
55
+ ...discoverTrackedFiles(repoRoot, readSettings().discover),
56
+ ...cloudFiles.map(file => file.name),
57
+ ])
53
58
 
54
59
  return skipSymlinks(repoRoot, check([...names].sort()), sink)
55
60
  }
package/src/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
5
5
  import { abandonedConnection } from './api.mjs'
6
6
  import { diff } from './commands/diff.mjs'
7
7
  import { login, logout } from './commands/login.mjs'
8
- import { init, link, list, version as versionCommand, whoami } from './commands/misc.mjs'
8
+ import { config, init, link, list, version as versionCommand, whoami } from './commands/misc.mjs'
9
9
  import { pull } from './commands/pull.mjs'
10
10
  import { push } from './commands/push.mjs'
11
11
  import { remove } from './commands/remove.mjs'
@@ -38,6 +38,7 @@ const HANDLERS = {
38
38
  push,
39
39
  sync,
40
40
  remove,
41
+ config,
41
42
  version: versionCommand,
42
43
  }
43
44
 
@@ -281,14 +282,16 @@ function printHelp(topic) {
281
282
  out(` --host <url> ${style.dim('Target another EnvStash server')}`)
282
283
  out(` --port <n> ${style.dim('Target a dev server on localhost at this port')}`)
283
284
  out(
284
- ` --json ${style.dim('Machine-readable output (status, list, whoami, version)')}`
285
+ ` --json ${style.dim('Machine-readable output (status, list, whoami, config, version)')}`
285
286
  )
286
287
  out(
287
288
  ` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff, sync)')}`
288
289
  )
289
290
  out(` --prefer <side> ${style.dim('Resolve conflicts as "local" or "cloud" (sync)')}`)
290
291
  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)')}`)
292
+ out(
293
+ ` -y, --yes ${style.dim('Skip the new-file question, if you turned it on (push, sync)')}`
294
+ )
292
295
  out(
293
296
  ` --shared ${style.dim('Target your account-wide shared files (pull, push, sync, remove)')}`
294
297
  )
@@ -378,9 +381,10 @@ const COMMAND_HELP = {
378
381
  'Keys in the cloud that are absent locally are left alone unless --prune is',
379
382
  'given, so pushing a partial .env cannot delete a teammate’s key.',
380
383
  '',
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.',
384
+ 'Files that would be new in the cloud are listed before anything uploads,',
385
+ 'then uploaded. To be asked first about ones found by discovery — not',
386
+ 'named, not in .envstash.json — put { "confirmNewFiles": true } in',
387
+ '~/.envstash/settings.json; --yes (-y) then answers for one run.',
384
388
  '',
385
389
  '--shared sends the named files to your account-wide shared files — for',
386
390
  'credentials that follow you across projects, like a global .npmrc. Pull',
@@ -388,7 +392,7 @@ const COMMAND_HELP = {
388
392
  '',
389
393
  ' envs push',
390
394
  ' envs push --dry-run',
391
- ' envs push --yes Upload newly discovered files without asking',
395
+ ' envs push --yes Skip the new-file question, if you turned it on',
392
396
  ' envs push --prune',
393
397
  ' envs push --shared .npmrc',
394
398
  ],
@@ -430,6 +434,22 @@ const COMMAND_HELP = {
430
434
  link: ['Link this repo to a project that already exists.', '', ' envs link my-app'],
431
435
  list: ['List every project in your account.', '', ' envs list', ' envs list --json'],
432
436
  whoami: ['Show which account this machine is signed in as.', '', ' envs whoami'],
437
+ config: [
438
+ 'Print every setting in effect and where each one came from: your',
439
+ '~/.envstash/settings.json, the server and account commands would use, and',
440
+ 'this repo’s project and files. Tokens are never printed. Works offline.',
441
+ '',
442
+ 'To change what is synced by default, set "discover" in settings.json. It',
443
+ 'replaces the default list, so leave an entry out to turn it off:',
444
+ '',
445
+ ' { "discover": [".env*", ".config", ".agents"] }',
446
+ '',
447
+ '".env*" is the dotenv files in the repo root; anything else is a directory',
448
+ 'whose git-ignored files are synced.',
449
+ '',
450
+ ' envs config',
451
+ ' envs config --json',
452
+ ],
433
453
  version: [
434
454
  'Show the version, runtime, install path, and which server commands would',
435
455
  'talk to. --version and -v print the bare version for scripts.',
package/src/manifest.mjs CHANGED
@@ -29,6 +29,7 @@ export const CLI_COMMANDS = [
29
29
  { name: 'push', summary: 'Send local files to the cloud' },
30
30
  { name: 'sync', summary: 'Reconcile both directions at once, keeping every key' },
31
31
  { name: 'remove', summary: 'Delete files from the cloud (local copies stay)', aliases: ['rm'] },
32
+ { name: 'config', summary: 'Show the settings in effect and where each came from' },
32
33
  { name: 'version', summary: 'Show the CLI version and its environment' },
33
34
  ]
34
35
 
package/src/project.mjs CHANGED
@@ -202,6 +202,26 @@ export function discoverEnvFiles(repoRoot) {
202
202
  */
203
203
  export const SECRET_FILE_DIR = '.config'
204
204
 
205
+ /**
206
+ * Where a repo keeps what its AI coding agents read — skills, rules, per-tool
207
+ * settings. Not a credential, but the same population as one: files a developer
208
+ * needs on every machine and, when the repo ignores them, cannot get from git.
209
+ */
210
+ export const AGENTS_DIR = '.agents'
211
+
212
+ /**
213
+ * The discovery target that means "dotenv files in the repo root". Every other
214
+ * target names a directory; this one names the scan in `discoverEnvFiles`, which
215
+ * is a naming rule rather than a place.
216
+ */
217
+ export const ENV_FILES_TARGET = '.env*'
218
+
219
+ /**
220
+ * What discovery looks at when the developer has not said otherwise — the
221
+ * `discover` list in `~/.envstash/settings.json` replaces it wholesale.
222
+ */
223
+ export const DEFAULT_DISCOVER = [ENV_FILES_TARGET, SECRET_FILE_DIR, AGENTS_DIR]
224
+
205
225
  /**
206
226
  * Which of `names` git ignores, as a Set.
207
227
  *
@@ -251,7 +271,9 @@ const DOCUMENT_SUFFIXES = [
251
271
  ]
252
272
 
253
273
  /**
254
- * Discover files in `.config/` — all of them, except documents.
274
+ * Discover the files in one directory that git ignores.
275
+ *
276
+ * `.config/` is where this started, and the reasoning below is written for it:
255
277
  *
256
278
  * The dotenv scan above cannot find these: they are not named `.env*` and they do
257
279
  * not live in the repo root. Before this existed, a project with a service-account
@@ -265,8 +287,12 @@ const DOCUMENT_SUFFIXES = [
265
287
  * a file is offered only when it is git-ignored, small enough to push, and has a
266
288
  * name the sync commands will accept.
267
289
  */
268
- export function discoverSecretFiles(repoRoot) {
269
- const directory = join(repoRoot, SECRET_FILE_DIR)
290
+ export function discoverDirFiles(repoRoot, dir) {
291
+ const directory = join(repoRoot, dir)
292
+ // Only `.config/` drops documents. There a README is noise beside the keys;
293
+ // in `.agents/` the markdown *is* the content, and a directory the developer
294
+ // listed themselves is taken at their word.
295
+ const skipDocuments = dir === SECRET_FILE_DIR
270
296
  let entries
271
297
 
272
298
  try {
@@ -277,15 +303,18 @@ export function discoverSecretFiles(repoRoot) {
277
303
 
278
304
  const candidates = entries
279
305
  // Nested entries come back joined with the platform separator; names are POSIX.
280
- .map(entry => `${SECRET_FILE_DIR}/${entry.split(sep).join('/')}`)
281
- .filter(name => !DOCUMENT_SUFFIXES.some(suffix => name.toLowerCase().endsWith(suffix)))
306
+ .map(entry => `${dir}/${entry.split(sep).join('/')}`)
307
+ .filter(
308
+ name =>
309
+ !skipDocuments || !DOCUMENT_SUFFIXES.some(suffix => name.toLowerCase().endsWith(suffix))
310
+ )
282
311
  // The grammar the server and `resolveInsideRepo` both enforce. Discovering a
283
312
  // name they would refuse produces a link file that cannot be pushed.
284
313
  .filter(name => isSafeRepoPath(name))
285
314
  .filter(name => {
286
315
  try {
287
- // A link anywhere on the path — `.config` itself, a subdirectory, or the
288
- // file — could point outside the repo, so it is not offered.
316
+ // A link anywhere on the path — the directory itself, a subdirectory, or
317
+ // the file — could point outside the repo, so it is not offered.
289
318
  if (findSymlink(repoRoot, name)) {
290
319
  return false
291
320
  }
@@ -304,26 +333,25 @@ export function discoverSecretFiles(repoRoot) {
304
333
  return candidates.filter(name => ignored.has(name)).sort()
305
334
  }
306
335
 
336
+ /** Discover files in `.config/` — all of them, except documents. */
337
+ export function discoverSecretFiles(repoRoot) {
338
+ return discoverDirFiles(repoRoot, SECRET_FILE_DIR)
339
+ }
340
+
307
341
  /**
308
- * Make sure `.config/` is git-ignored before discovery asks about it.
309
- *
310
- * Being ignored is discovery's signal for "secret", which makes an unignored
311
- * `.config/` silently invisible to `envs push` — a real footgun: the developer
312
- * sees a directory full of credentials and a push that ignores them, with
313
- * nothing saying why. Rather than documenting that, push and init close it:
314
- * when the directory has contents, nothing in it is tracked by git, and it is
315
- * not already ignored, `.config/` is added to .gitignore. A `.config/` with
316
- * tracked files is a repo that commits configuration there on purpose, and is
317
- * left alone.
342
+ * True when `dir` has contents that neither git nor envstash will carry:
343
+ * nothing in it is tracked, and nothing ignores it.
318
344
  *
319
- * Returns true when .gitignore was changed — the caller must say so out loud,
320
- * because a CLI editing a file it does not own should never do it silently.
345
+ * Being ignored is discovery's signal, which makes a directory in this state
346
+ * silently invisible to `envs push`. What to do about it differs by directory —
347
+ * see `ensureSecretDirIgnored` and the note `push` prints for the others — but
348
+ * the question is the same one.
321
349
  */
322
- export function ensureSecretDirIgnored(repoRoot) {
350
+ export function lacksIgnoreRule(repoRoot, dir) {
323
351
  let entries
324
352
 
325
353
  try {
326
- entries = readdirSync(join(repoRoot, SECRET_FILE_DIR))
354
+ entries = readdirSync(join(repoRoot, dir))
327
355
  } catch {
328
356
  return false
329
357
  }
@@ -337,20 +365,40 @@ export function ensureSecretDirIgnored(repoRoot) {
337
365
  let tracked
338
366
 
339
367
  try {
340
- tracked = runGit(['ls-files', '--', SECRET_FILE_DIR], { cwd: repoRoot }).trim()
368
+ tracked = runGit(['ls-files', '--', dir], { cwd: repoRoot }).trim()
341
369
  } catch {
342
370
  return false
343
371
  }
344
372
 
373
+ // Tracked content means the repo commits this directory on purpose.
345
374
  if (tracked !== '') {
346
375
  return false
347
376
  }
348
377
 
349
378
  // A synthetic name, so one question covers the directory: any rule broad
350
379
  // enough to ignore this probe ignores everything discovery would find.
351
- const probe = `${SECRET_FILE_DIR}/envstash-ignore-probe`
380
+ const probe = `${dir}/envstash-ignore-probe`
381
+
382
+ return !gitIgnoredNames(repoRoot, [probe]).has(probe)
383
+ }
352
384
 
353
- if (gitIgnoredNames(repoRoot, [probe]).has(probe)) {
385
+ /**
386
+ * Make sure `.config/` is git-ignored before discovery asks about it.
387
+ *
388
+ * Being ignored is discovery's signal for "secret", which makes an unignored
389
+ * `.config/` silently invisible to `envs push` — a real footgun: the developer
390
+ * sees a directory full of credentials and a push that ignores them, with
391
+ * nothing saying why. Rather than documenting that, push and init close it:
392
+ * when the directory has contents, nothing in it is tracked by git, and it is
393
+ * not already ignored, `.config/` is added to .gitignore. A `.config/` with
394
+ * tracked files is a repo that commits configuration there on purpose, and is
395
+ * left alone.
396
+ *
397
+ * Returns true when .gitignore was changed — the caller must say so out loud,
398
+ * because a CLI editing a file it does not own should never do it silently.
399
+ */
400
+ export function ensureSecretDirIgnored(repoRoot) {
401
+ if (!lacksIgnoreRule(repoRoot, SECRET_FILE_DIR)) {
354
402
  return false
355
403
  }
356
404
 
@@ -379,9 +427,21 @@ export function ensureSecretDirIgnored(repoRoot) {
379
427
  return true
380
428
  }
381
429
 
382
- /** Everything auto-discovery offers to track: root dotenv files, then `.config/` files. */
383
- export function discoverTrackedFiles(repoRoot) {
384
- return [...discoverEnvFiles(repoRoot), ...discoverSecretFiles(repoRoot)]
430
+ /**
431
+ * Everything auto-discovery offers to track, target by target in the order
432
+ * given: root dotenv files for `.env*`, the git-ignored files of each directory
433
+ * otherwise.
434
+ *
435
+ * `targets` is passed in rather than read from settings here, so this stays a
436
+ * function of the repo alone; callers hand it `readSettings().discover`.
437
+ */
438
+ export function discoverTrackedFiles(repoRoot, targets = DEFAULT_DISCOVER) {
439
+ const names = targets.flatMap(target =>
440
+ target === ENV_FILES_TARGET ? discoverEnvFiles(repoRoot) : discoverDirFiles(repoRoot, target)
441
+ )
442
+
443
+ // Two targets can overlap (`.config` and `.config/gcloud`); each file once.
444
+ return [...new Set(names)]
385
445
  }
386
446
 
387
447
  /**
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { configDir } from './config.mjs'
5
+ import { DEFAULT_DISCOVER, ENV_FILES_TARGET } from './project.mjs'
6
+ import { isSafeRepoPath } from './repo-path.mjs'
7
+ import { CliError } from './ui.mjs'
8
+
9
+ /**
10
+ * Preferences for this machine, next to — and deliberately not inside — the
11
+ * credential file. `config.json` holds every token and is rewritten by login
12
+ * and logout; a file meant to be edited by hand should not be one where a
13
+ * stray comma signs the developer out of everything.
14
+ *
15
+ * Per machine rather than per repo: `.envstash.json` travels with a clone, and
16
+ * how much a developer wants to be asked — or which of their directories get
17
+ * swept up — is theirs to decide, not the repo's. A cloned repo that could say
18
+ * "also discover this directory" would be choosing what leaves the machine.
19
+ *
20
+ * {
21
+ * "confirmNewFiles": true,
22
+ * "discover": [".env*", ".config"]
23
+ * }
24
+ */
25
+ export const DEFAULT_SETTINGS = Object.freeze({
26
+ /** Ask before uploading a file that discovery found for the first time. */
27
+ confirmNewFiles: false,
28
+ /**
29
+ * What discovery looks at in a repo with no file list of its own: `.env*` for
30
+ * root dotenv files, and directories whose git-ignored files are offered.
31
+ * Replaces the default list rather than adding to it, so leaving an entry out
32
+ * is how it is turned off.
33
+ */
34
+ discover: Object.freeze([...DEFAULT_DISCOVER]),
35
+ })
36
+
37
+ /**
38
+ * One reader per setting: takes the value from the file and returns the value
39
+ * to use, or calls `refuse` — which throws — with what the value must be and a
40
+ * hint. A value of the
41
+ * wrong type is refused rather than coerced: `"false"` is a truthy string, and
42
+ * reading it as "yes, ask me" or as "no, don't" would both be a guess about a
43
+ * safety preference.
44
+ */
45
+ const READERS = {
46
+ confirmNewFiles(value, refuse) {
47
+ if (typeof value !== 'boolean') {
48
+ refuse('must be true or false', 'Example: { "confirmNewFiles": true }')
49
+ }
50
+
51
+ return value
52
+ },
53
+
54
+ discover(value, refuse) {
55
+ const example = `Example: { "discover": ${JSON.stringify(DEFAULT_DISCOVER)} }`
56
+
57
+ if (!Array.isArray(value)) {
58
+ refuse('must be a list', example)
59
+ }
60
+
61
+ const targets = value.map(entry => {
62
+ // `.agents/` and `.agents` are the same directory to anyone typing it.
63
+ const target = typeof entry === 'string' ? entry.replace(/\/+$/, '') : entry
64
+
65
+ // The same grammar every synced name passes, which also keeps `.git`,
66
+ // `node_modules`, `..` and absolute paths out of the sweep.
67
+ if (target !== ENV_FILES_TARGET && !isSafeRepoPath(target)) {
68
+ refuse(
69
+ `lists ${JSON.stringify(entry)}, which is not something envstash can discover`,
70
+ `Use "${ENV_FILES_TARGET}" for root dotenv files, or a directory inside the repo. ${example}`
71
+ )
72
+ }
73
+
74
+ return target
75
+ })
76
+
77
+ return [...new Set(targets)]
78
+ },
79
+ }
80
+
81
+ export function settingsPath() {
82
+ return join(configDir(), 'settings.json')
83
+ }
84
+
85
+ /**
86
+ * The settings, with defaults for whatever the file does not say — or for a
87
+ * file that is not there, which is the normal case.
88
+ */
89
+ export function readSettings() {
90
+ const path = settingsPath()
91
+ const settings = { ...DEFAULT_SETTINGS, discover: [...DEFAULT_SETTINGS.discover] }
92
+
93
+ if (!existsSync(path)) {
94
+ return settings
95
+ }
96
+
97
+ let parsed
98
+
99
+ try {
100
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
101
+ } catch {
102
+ throw new CliError(`Could not read ${path} — it is not valid JSON.`, {
103
+ hint: 'Fix the file, or delete it to go back to the defaults.',
104
+ })
105
+ }
106
+
107
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
108
+ throw new CliError(`${path} must hold a JSON object.`, {
109
+ hint: 'Example: { "confirmNewFiles": true }',
110
+ })
111
+ }
112
+
113
+ for (const [key, read] of Object.entries(READERS)) {
114
+ if (!(key in parsed)) {
115
+ continue
116
+ }
117
+
118
+ settings[key] = read(parsed[key], (must, hint) => {
119
+ throw new CliError(`"${key}" in ${path} ${must}.`, { hint })
120
+ })
121
+ }
122
+
123
+ return settings
124
+ }