envstash-cli 0.9.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.
@@ -253,7 +254,8 @@ files to track — and it is what lets a teammate clone the repo and run
253
254
 
254
255
  Without a link file the CLI guesses the project from your git remote's repo
255
256
  name and discovers `.env*` files in the repo root, plus files in `.config/`
256
- (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
257
259
  `.dist` are ignored, since those are meant to be committed and hold placeholder
258
260
  values.
259
261
 
@@ -287,8 +289,40 @@ than the one you are standing in.
287
289
  Outside a git repository, or if `git` is not installed, discovery adds nothing
288
290
  from `.config/` rather than guessing — over-discovery would upload a file nobody
289
291
  chose to share, while under-discovery just means listing it in `.envstash.json`
290
- by hand. The scan covers `.config/` and everything under it; files anywhere else
291
- 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.
292
326
 
293
327
  Monorepos can track nested paths by listing them explicitly:
294
328
 
@@ -349,13 +383,31 @@ by hand. The file is optional; create it to change a default.
349
383
 
350
384
  ```json
351
385
  {
352
- "confirmNewFiles": true
386
+ "confirmNewFiles": true,
387
+ "discover": [".env*", ".config", ".agents"]
353
388
  }
354
389
  ```
355
390
 
356
- | Setting | Default | Purpose |
357
- | ----------------- | ------- | ---------------------------------------------------------------------------------- |
358
- | `confirmNewFiles` | `false` | Ask before `push` or `sync` uploads a file that discovery found for the first time |
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.
359
411
 
360
412
  With `confirmNewFiles` on, `push` and `sync` ask on a terminal, and anywhere
361
413
  else (CI, a pipe) they stop unless you pass `--yes` (`-y`). Files you named on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.9.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,23 +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'
14
19
  import { readSettings } from '../settings.mjs'
15
20
  import { compareProject, FILE_STATE } from '../status.mjs'
16
21
  import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
17
22
 
18
- /** Say that .gitignore was edited — quietly rewriting a user's file is worse. */
19
- export function announceIgnoredSecretDir(repoRoot) {
20
- if (!ensureSecretDirIgnored(repoRoot)) {
21
- 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
+ )
22
47
  }
23
48
 
24
- out()
25
- out(
26
- ` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
27
- style.dim('— secrets sync through EnvStash, not git')
28
- )
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
+ }
29
63
  }
30
64
 
31
65
  /**
@@ -58,7 +92,9 @@ export async function push(flags, args) {
58
92
  // this very push rather than the next one. Not under --dry-run: a preview
59
93
  // that edits files is not a preview.
60
94
  if (!flags['dry-run']) {
61
- announceIgnoredSecretDir(context.repoRoot)
95
+ announceDiscoveryDirs(context.repoRoot, {
96
+ discovering: args.length === 0 && !context.link?.files?.length,
97
+ })
62
98
  }
63
99
 
64
100
  const cloudFiles = await fetchCloudFiles(context, scope)
@@ -189,7 +225,8 @@ export async function push(flags, args) {
189
225
  * ones discovery found.
190
226
  *
191
227
  * Discovery sweeps root `.env*` files and everything git-ignored under
192
- * `.config/`, recursively, and used to upload the lot and only then list it.
228
+ * `.config/` and `.agents/`, recursively, and used to upload the lot and only
229
+ * then list it.
193
230
  * The list now always comes first, marking which files were discovered.
194
231
  *
195
232
  * Uploading them is the default: a developer who runs `envs push` in their own
@@ -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)
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,7 +282,7 @@ 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)')}`
@@ -433,6 +434,22 @@ const COMMAND_HELP = {
433
434
  link: ['Link this repo to a project that already exists.', '', ' envs link my-app'],
434
435
  list: ['List every project in your account.', '', ' envs list', ' envs list --json'],
435
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
+ ],
436
453
  version: [
437
454
  'Show the version, runtime, install path, and which server commands would',
438
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
  /**
package/src/settings.mjs CHANGED
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import { configDir } from './config.mjs'
5
+ import { DEFAULT_DISCOVER, ENV_FILES_TARGET } from './project.mjs'
6
+ import { isSafeRepoPath } from './repo-path.mjs'
5
7
  import { CliError } from './ui.mjs'
6
8
 
7
9
  /**
@@ -11,15 +13,69 @@ import { CliError } from './ui.mjs'
11
13
  * stray comma signs the developer out of everything.
12
14
  *
13
15
  * 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.
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.
15
19
  *
16
20
  * {
17
- * "confirmNewFiles": true
21
+ * "confirmNewFiles": true,
22
+ * "discover": [".env*", ".config"]
18
23
  * }
19
24
  */
20
- const DEFAULTS = {
25
+ export const DEFAULT_SETTINGS = Object.freeze({
21
26
  /** Ask before uploading a file that discovery found for the first time. */
22
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
+ },
23
79
  }
24
80
 
25
81
  export function settingsPath() {
@@ -29,16 +85,13 @@ export function settingsPath() {
29
85
  /**
30
86
  * The settings, with defaults for whatever the file does not say — or for a
31
87
  * 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
88
  */
37
89
  export function readSettings() {
38
90
  const path = settingsPath()
91
+ const settings = { ...DEFAULT_SETTINGS, discover: [...DEFAULT_SETTINGS.discover] }
39
92
 
40
93
  if (!existsSync(path)) {
41
- return { ...DEFAULTS }
94
+ return settings
42
95
  }
43
96
 
44
97
  let parsed
@@ -57,21 +110,14 @@ export function readSettings() {
57
110
  })
58
111
  }
59
112
 
60
- const settings = { ...DEFAULTS }
61
-
62
- for (const [key, fallback] of Object.entries(DEFAULTS)) {
113
+ for (const [key, read] of Object.entries(READERS)) {
63
114
  if (!(key in parsed)) {
64
115
  continue
65
116
  }
66
117
 
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]
118
+ settings[key] = read(parsed[key], (must, hint) => {
119
+ throw new CliError(`"${key}" in ${path} ${must}.`, { hint })
120
+ })
75
121
  }
76
122
 
77
123
  return settings