envstash-cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.
@@ -243,7 +244,7 @@ on the dashboard under **Shared Files**, not inside any project.
243
244
  ```json
244
245
  {
245
246
  "project": "demo-app",
246
- "files": [".env.local", ".env.production"]
247
+ "files": [".env", ".env.production"]
247
248
  }
248
249
  ```
249
250
 
@@ -253,10 +254,21 @@ 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
 
262
+ `.env.local` and `.env.*.local` are not discovered either. Every dotenv loader
263
+ that layers files puts `.local` on top — `.env`, then `.env.<mode>`, then
264
+ `.env.local`, then the real environment — so by name it is the override a
265
+ developer writes to differ from the shared values on this one checkout. A tool
266
+ that distributes the shared values fills the lower layers and leaves that one
267
+ alone; syncing it would leave no layer that can differ between machines.
268
+ `init`, `push`, and `sync` say when they skip one. To sync it anyway, list it in
269
+ `.envstash.json` or name it on the command line — that is taken as written. A
270
+ `.local` file already in the cloud keeps syncing until you `envs rm` it.
271
+
260
272
  ### Files in `.config/`
261
273
 
262
274
  A service-account key or a cert is a secret that is not `KEY=value`, and it does
@@ -287,8 +299,40 @@ than the one you are standing in.
287
299
  Outside a git repository, or if `git` is not installed, discovery adds nothing
288
300
  from `.config/` rather than guessing — over-discovery would upload a file nobody
289
301
  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.
302
+ by hand. The scan covers each discovered directory and everything under it;
303
+ files anywhere else are declared explicitly.
304
+
305
+ ### Files in `.agents/`
306
+
307
+ `.agents/` is where a repo keeps what its AI coding agents read — skills, rules,
308
+ per-tool settings. When the directory exists it is discovered the same way as
309
+ `.config/`, with two differences:
310
+
311
+ - **Documents are kept.** Skills and rules are markdown, so the rule that keeps
312
+ a README out of `.config/` does not apply.
313
+ - **It is never added to `.gitignore` for you.** Plenty of repos commit
314
+ `.agents/`, and ignoring it on the way past would make the next `git add -A`
315
+ silently leave it behind.
316
+
317
+ Git-ignore is still the gate, so there are three cases:
318
+
319
+ | `.agents/` is… | What `push` and `sync` do |
320
+ | ----------------------------- | ------------------------------------------------- |
321
+ | git-ignored | Sync its files |
322
+ | committed | Nothing — git already carries it |
323
+ | neither ignored nor committed | Nothing, and say so, so you can pick one of those |
324
+
325
+ A `.agents` that is a symlink (to `.claude`, say) is skipped, like every other
326
+ link.
327
+
328
+ ### Choosing what is discovered
329
+
330
+ Discovery only decides the file list for a repo that has none of its own. Once
331
+ `.envstash.json` lists `files`, that list is used as written — add
332
+ `.agents/…` paths to it by hand, or remove `files` to go back to discovery.
333
+
334
+ To change the defaults on this machine, set [`discover`](#settings). Run
335
+ `envs config` to see the list in effect and the files it currently finds.
292
336
 
293
337
  Monorepos can track nested paths by listing them explicitly:
294
338
 
@@ -349,13 +393,31 @@ by hand. The file is optional; create it to change a default.
349
393
 
350
394
  ```json
351
395
  {
352
- "confirmNewFiles": true
396
+ "confirmNewFiles": true,
397
+ "discover": [".env*", ".config", ".agents"]
353
398
  }
354
399
  ```
355
400
 
356
- | Setting | Default | Purpose |
357
- | ----------------- | ------- | ---------------------------------------------------------------------------------- |
358
- | `confirmNewFiles` | `false` | Ask before `push` or `sync` uploads a file that discovery found for the first time |
401
+ - **`confirmNewFiles`** (default `false`) — ask before `push` or `sync` uploads
402
+ a file that discovery found for the first time.
403
+ - **`discover`** (default `[".env*", ".config", ".agents"]`) — what is synced in
404
+ a repo that does not list its own files.
405
+
406
+ `discover` **replaces** the default list, so leaving an entry out turns it off:
407
+ `[".env*", ".config"]` stops syncing `.agents/`, and `[]` discovers nothing.
408
+ `".env*"` means the dotenv files in the repo root. Any other entry is a
409
+ directory inside the repo, whose git-ignored files are synced — add `".claude"`
410
+ to carry local agent settings, for example. `.git`, `node_modules`, and paths
411
+ that leave the repo are refused.
412
+
413
+ It lives here rather than in `.envstash.json` on purpose: that file arrives
414
+ with a clone, and a repo should not get to choose which of your directories are
415
+ uploaded. Taking a directory out of `discover` stops new files being found; files
416
+ already in the cloud keep syncing until you `envs rm` them.
417
+
418
+ `envs config` prints every setting in effect and whether it came from this file
419
+ or is a default, along with the server, account, and this repo's project and
420
+ files. It never prints a token.
359
421
 
360
422
  With `confirmNewFiles` on, `push` and `sync` ask on a terminal, and anywhere
361
423
  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.11.0",
4
4
  "description": "Git-style CLI for EnvStash — keep your .env files in sync with your cloud account.",
5
5
  "keywords": [
6
6
  "env",
@@ -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, announceLocalEnvFiles } 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,12 @@ 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)
269
+ announceLocalEnvFiles(repoRoot, { discovering: !existing?.files?.length, covered: files })
151
270
  const path = writeLink(repoRoot, {
152
271
  project: name,
153
272
  files,
@@ -209,9 +328,12 @@ export async function link(flags, args) {
209
328
  await api.projectStatus(host, token, name)
210
329
 
211
330
  // Same moment as in init: discovery is about to decide the link file's list.
212
- announceIgnoredSecretDir(repoRoot)
331
+ announceDiscoveryDirs(repoRoot, { discovering: !existing?.files?.length })
213
332
 
214
- const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
333
+ const files = existing?.files?.length
334
+ ? existing.files
335
+ : discoverTrackedFiles(repoRoot, readSettings().discover)
336
+ announceLocalEnvFiles(repoRoot, { discovering: !existing?.files?.length, covered: files })
215
337
  const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
216
338
 
217
339
  out()
@@ -9,23 +9,92 @@ 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
+ LINK_FILE,
17
+ localEnvFiles,
18
+ SECRET_FILE_DIR,
19
+ } from '../project.mjs'
13
20
  import { resolveRepoFile } from '../repo-path.mjs'
14
21
  import { readSettings } from '../settings.mjs'
15
22
  import { compareProject, FILE_STATE } from '../status.mjs'
16
23
  import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
17
24
 
18
- /** Say that .gitignore was edited — quietly rewriting a user's file is worse. */
19
- export function announceIgnoredSecretDir(repoRoot) {
20
- if (!ensureSecretDirIgnored(repoRoot)) {
25
+ /**
26
+ * Get the discovered directories ready, and say what was done or left undone.
27
+ *
28
+ * Discovery only offers files git ignores, so a directory with contents that
29
+ * nothing tracks and nothing ignores is carried by neither git nor envstash.
30
+ * For `.config/` that is closed by adding the ignore rule — and saying so, since
31
+ * quietly rewriting a user's file is worse. For any other directory it is only
32
+ * pointed out: `.agents/` is committed in plenty of repos, and ignoring it on
33
+ * the way past would make the next `git add -A` silently leave it behind.
34
+ *
35
+ * A directory the developer took out of `discover` is not touched or mentioned.
36
+ * `discovering` is false when this run's files were chosen by name or by the
37
+ * link file: discovery is not deciding anything then, so there is nothing to
38
+ * point out.
39
+ */
40
+ export function announceDiscoveryDirs(repoRoot, { discovering = true } = {}) {
41
+ const dirs = readSettings().discover.filter(target => target !== ENV_FILES_TARGET)
42
+
43
+ if (dirs.includes(SECRET_FILE_DIR) && ensureSecretDirIgnored(repoRoot)) {
44
+ out()
45
+ out(
46
+ ` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
47
+ style.dim('— secrets sync through EnvStash, not git')
48
+ )
49
+ }
50
+
51
+ const leftToGit = discovering
52
+ ? dirs.filter(dir => dir !== SECRET_FILE_DIR && lacksIgnoreRule(repoRoot, dir))
53
+ : []
54
+
55
+ if (leftToGit.length > 0) {
56
+ out()
57
+ }
58
+
59
+ for (const dir of leftToGit) {
60
+ out(
61
+ ` ${style.yellow('!')} ${style.bold(`${dir}/`)} is not git-ignored, so it is not synced ` +
62
+ style.dim('— add it to .gitignore to sync it through EnvStash, or commit it')
63
+ )
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Say which `.local` files the dotenv scan left behind, and how to list one.
69
+ *
70
+ * The scan skips them by name: `.local` is the per-checkout override layer, not
71
+ * the shared values. A developer whose secrets live in `.env.local` would
72
+ * otherwise see it silently missing from the list a command just decided.
73
+ *
74
+ * `covered` is that list. A `.local` file already in the cloud — pushed by an
75
+ * older version, or by name — is still synced through the cloud listing until
76
+ * `envs rm`, and saying otherwise would be false. Only when discovery decided
77
+ * the list (`discovering`) and the scan is on is there anything to point out.
78
+ */
79
+ export function announceLocalEnvFiles(repoRoot, { discovering = true, covered = [] } = {}) {
80
+ if (!discovering || !readSettings().discover.includes(ENV_FILES_TARGET)) {
81
+ return
82
+ }
83
+
84
+ const left = localEnvFiles(repoRoot).filter(name => !covered.includes(name))
85
+
86
+ if (left.length === 0) {
21
87
  return
22
88
  }
23
89
 
24
90
  out()
25
- out(
26
- ` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
27
- style.dim('— secrets sync through EnvStash, not git')
28
- )
91
+
92
+ for (const name of left) {
93
+ out(
94
+ ` ${style.dim('·')} ${style.bold(name)} is a local override, so it is not synced ` +
95
+ style.dim(`— list it in ${LINK_FILE} to sync it`)
96
+ )
97
+ }
29
98
  }
30
99
 
31
100
  /**
@@ -58,7 +127,9 @@ export async function push(flags, args) {
58
127
  // this very push rather than the next one. Not under --dry-run: a preview
59
128
  // that edits files is not a preview.
60
129
  if (!flags['dry-run']) {
61
- announceIgnoredSecretDir(context.repoRoot)
130
+ announceDiscoveryDirs(context.repoRoot, {
131
+ discovering: args.length === 0 && !context.link?.files?.length,
132
+ })
62
133
  }
63
134
 
64
135
  const cloudFiles = await fetchCloudFiles(context, scope)
@@ -70,6 +141,11 @@ export async function push(flags, args) {
70
141
  requested: args,
71
142
  })
72
143
 
144
+ announceLocalEnvFiles(context.repoRoot, {
145
+ discovering: args.length === 0 && !context.link?.files?.length,
146
+ covered: names,
147
+ })
148
+
73
149
  const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
74
150
  const presentNames = names.filter(name => localFiles.get(name) !== null)
75
151
 
@@ -189,7 +265,8 @@ export async function push(flags, args) {
189
265
  * ones discovery found.
190
266
  *
191
267
  * Discovery sweeps root `.env*` files and everything git-ignored under
192
- * `.config/`, recursively, and used to upload the lot and only then list it.
268
+ * `.config/` and `.agents/`, recursively, and used to upload the lot and only
269
+ * then list it.
193
270
  * The list now always comes first, marking which files were discovered.
194
271
  *
195
272
  * Uploading them is the default: a developer who runs `envs push` in their own
@@ -15,7 +15,13 @@ import { resolveRepoFile } from '../repo-path.mjs'
15
15
  import { compareProject } from '../status.mjs'
16
16
  import { conflictExitCode, isBlocked, isChanging, planSync, PREFER, SYNC_ACTION } from '../sync.mjs'
17
17
  import { CliError, out, pad, pluralize, sanitize, style } from '../ui.mjs'
18
- import { announceIgnoredSecretDir, confirmNewFiles, fetchCloudFiles, pushOneBlob } from './push.mjs'
18
+ import {
19
+ announceDiscoveryDirs,
20
+ announceLocalEnvFiles,
21
+ confirmNewFiles,
22
+ fetchCloudFiles,
23
+ pushOneBlob,
24
+ } from './push.mjs'
19
25
 
20
26
  /**
21
27
  * `envs sync` — reconcile this repo with the cloud, in both directions at once.
@@ -53,7 +59,9 @@ export async function sync(flags, args) {
53
59
  // .config/ is covered by this run rather than the next one, and never under
54
60
  // --dry-run, because a preview that edits files is not a preview.
55
61
  if (!flags['dry-run']) {
56
- announceIgnoredSecretDir(context.repoRoot)
62
+ announceDiscoveryDirs(context.repoRoot, {
63
+ discovering: args.length === 0 && !context.link?.files?.length,
64
+ })
57
65
  }
58
66
 
59
67
  const cloudFiles = await fetchCloudFiles(context, scope)
@@ -68,6 +76,11 @@ export async function sync(flags, args) {
68
76
  sink: 'write',
69
77
  })
70
78
 
79
+ announceLocalEnvFiles(context.repoRoot, {
80
+ discovering: args.length === 0 && !context.link?.files?.length,
81
+ covered: names,
82
+ })
83
+
71
84
  const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
72
85
  const comparison = compareProject({
73
86
  cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
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, minus templates and .local',
448
+ 'overrides; anything else is a directory 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
@@ -57,6 +57,17 @@ function runGit(args, { cwd, input } = {}) {
57
57
  */
58
58
  const TEMPLATE_SUFFIXES = ['.example', '.sample', '.template', '.dist']
59
59
 
60
+ /**
61
+ * The per-checkout override layer. Every dotenv loader that layers files agrees
62
+ * on the stack — `.env`, `.env.<mode>`, `.env.local`, `.env.<mode>.local`, then
63
+ * the real environment — and `.local` is, by name, what a developer writes to
64
+ * differ from the shared values on this one checkout. A tool that distributes
65
+ * the shared values fills the lower layers and leaves this one alone: syncing
66
+ * it would leave no layer that can differ between machines. Listing the file in
67
+ * the link file or naming it on the command line still syncs it.
68
+ */
69
+ const LOCAL_SUFFIX = '.local'
70
+
60
71
  /**
61
72
  * Walk up from `startDir` looking for a repo root.
62
73
  *
@@ -165,13 +176,14 @@ export function guessProjectName(repoRoot) {
165
176
  }
166
177
 
167
178
  /**
168
- * Discover `.env*` files in the repo root.
179
+ * The dotenv files in the repo root that could hold a real value: `.env` and
180
+ * `.env.*`, minus templates and type declarations, regular files only.
169
181
  *
170
182
  * Deliberately not recursive: walking a whole tree would wander into
171
183
  * node_modules and vendored fixtures, and a monorepo's nested files are better
172
184
  * declared explicitly in the link file than guessed at.
173
185
  */
174
- export function discoverEnvFiles(repoRoot) {
186
+ function rootEnvFiles(repoRoot) {
175
187
  let names
176
188
 
177
189
  try {
@@ -196,12 +208,45 @@ export function discoverEnvFiles(repoRoot) {
196
208
  .sort()
197
209
  }
198
210
 
211
+ /** Discover `.env*` files in the repo root — the shared layers, not `.local`. */
212
+ export function discoverEnvFiles(repoRoot) {
213
+ return rootEnvFiles(repoRoot).filter(name => !name.endsWith(LOCAL_SUFFIX))
214
+ }
215
+
216
+ /**
217
+ * The `.local` files discovery left behind, so a command can say why they are
218
+ * missing from a list it just decided.
219
+ */
220
+ export function localEnvFiles(repoRoot) {
221
+ return rootEnvFiles(repoRoot).filter(name => name.endsWith(LOCAL_SUFFIX))
222
+ }
223
+
199
224
  /**
200
225
  * Where credential *files* live, by convention: a service-account JSON, a cert, a
201
226
  * private key — the things that are secrets but are not `KEY=value`.
202
227
  */
203
228
  export const SECRET_FILE_DIR = '.config'
204
229
 
230
+ /**
231
+ * Where a repo keeps what its AI coding agents read — skills, rules, per-tool
232
+ * settings. Not a credential, but the same population as one: files a developer
233
+ * needs on every machine and, when the repo ignores them, cannot get from git.
234
+ */
235
+ export const AGENTS_DIR = '.agents'
236
+
237
+ /**
238
+ * The discovery target that means "dotenv files in the repo root". Every other
239
+ * target names a directory; this one names the scan in `discoverEnvFiles`, which
240
+ * is a naming rule rather than a place.
241
+ */
242
+ export const ENV_FILES_TARGET = '.env*'
243
+
244
+ /**
245
+ * What discovery looks at when the developer has not said otherwise — the
246
+ * `discover` list in `~/.envstash/settings.json` replaces it wholesale.
247
+ */
248
+ export const DEFAULT_DISCOVER = [ENV_FILES_TARGET, SECRET_FILE_DIR, AGENTS_DIR]
249
+
205
250
  /**
206
251
  * Which of `names` git ignores, as a Set.
207
252
  *
@@ -251,7 +296,9 @@ const DOCUMENT_SUFFIXES = [
251
296
  ]
252
297
 
253
298
  /**
254
- * Discover files in `.config/` — all of them, except documents.
299
+ * Discover the files in one directory that git ignores.
300
+ *
301
+ * `.config/` is where this started, and the reasoning below is written for it:
255
302
  *
256
303
  * The dotenv scan above cannot find these: they are not named `.env*` and they do
257
304
  * not live in the repo root. Before this existed, a project with a service-account
@@ -265,8 +312,12 @@ const DOCUMENT_SUFFIXES = [
265
312
  * a file is offered only when it is git-ignored, small enough to push, and has a
266
313
  * name the sync commands will accept.
267
314
  */
268
- export function discoverSecretFiles(repoRoot) {
269
- const directory = join(repoRoot, SECRET_FILE_DIR)
315
+ export function discoverDirFiles(repoRoot, dir) {
316
+ const directory = join(repoRoot, dir)
317
+ // Only `.config/` drops documents. There a README is noise beside the keys;
318
+ // in `.agents/` the markdown *is* the content, and a directory the developer
319
+ // listed themselves is taken at their word.
320
+ const skipDocuments = dir === SECRET_FILE_DIR
270
321
  let entries
271
322
 
272
323
  try {
@@ -277,15 +328,18 @@ export function discoverSecretFiles(repoRoot) {
277
328
 
278
329
  const candidates = entries
279
330
  // 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)))
331
+ .map(entry => `${dir}/${entry.split(sep).join('/')}`)
332
+ .filter(
333
+ name =>
334
+ !skipDocuments || !DOCUMENT_SUFFIXES.some(suffix => name.toLowerCase().endsWith(suffix))
335
+ )
282
336
  // The grammar the server and `resolveInsideRepo` both enforce. Discovering a
283
337
  // name they would refuse produces a link file that cannot be pushed.
284
338
  .filter(name => isSafeRepoPath(name))
285
339
  .filter(name => {
286
340
  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.
341
+ // A link anywhere on the path — the directory itself, a subdirectory, or
342
+ // the file — could point outside the repo, so it is not offered.
289
343
  if (findSymlink(repoRoot, name)) {
290
344
  return false
291
345
  }
@@ -304,26 +358,25 @@ export function discoverSecretFiles(repoRoot) {
304
358
  return candidates.filter(name => ignored.has(name)).sort()
305
359
  }
306
360
 
361
+ /** Discover files in `.config/` — all of them, except documents. */
362
+ export function discoverSecretFiles(repoRoot) {
363
+ return discoverDirFiles(repoRoot, SECRET_FILE_DIR)
364
+ }
365
+
307
366
  /**
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.
367
+ * True when `dir` has contents that neither git nor envstash will carry:
368
+ * nothing in it is tracked, and nothing ignores it.
318
369
  *
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.
370
+ * Being ignored is discovery's signal, which makes a directory in this state
371
+ * silently invisible to `envs push`. What to do about it differs by directory —
372
+ * see `ensureSecretDirIgnored` and the note `push` prints for the others — but
373
+ * the question is the same one.
321
374
  */
322
- export function ensureSecretDirIgnored(repoRoot) {
375
+ export function lacksIgnoreRule(repoRoot, dir) {
323
376
  let entries
324
377
 
325
378
  try {
326
- entries = readdirSync(join(repoRoot, SECRET_FILE_DIR))
379
+ entries = readdirSync(join(repoRoot, dir))
327
380
  } catch {
328
381
  return false
329
382
  }
@@ -337,20 +390,40 @@ export function ensureSecretDirIgnored(repoRoot) {
337
390
  let tracked
338
391
 
339
392
  try {
340
- tracked = runGit(['ls-files', '--', SECRET_FILE_DIR], { cwd: repoRoot }).trim()
393
+ tracked = runGit(['ls-files', '--', dir], { cwd: repoRoot }).trim()
341
394
  } catch {
342
395
  return false
343
396
  }
344
397
 
398
+ // Tracked content means the repo commits this directory on purpose.
345
399
  if (tracked !== '') {
346
400
  return false
347
401
  }
348
402
 
349
403
  // A synthetic name, so one question covers the directory: any rule broad
350
404
  // enough to ignore this probe ignores everything discovery would find.
351
- const probe = `${SECRET_FILE_DIR}/envstash-ignore-probe`
405
+ const probe = `${dir}/envstash-ignore-probe`
352
406
 
353
- if (gitIgnoredNames(repoRoot, [probe]).has(probe)) {
407
+ return !gitIgnoredNames(repoRoot, [probe]).has(probe)
408
+ }
409
+
410
+ /**
411
+ * Make sure `.config/` is git-ignored before discovery asks about it.
412
+ *
413
+ * Being ignored is discovery's signal for "secret", which makes an unignored
414
+ * `.config/` silently invisible to `envs push` — a real footgun: the developer
415
+ * sees a directory full of credentials and a push that ignores them, with
416
+ * nothing saying why. Rather than documenting that, push and init close it:
417
+ * when the directory has contents, nothing in it is tracked by git, and it is
418
+ * not already ignored, `.config/` is added to .gitignore. A `.config/` with
419
+ * tracked files is a repo that commits configuration there on purpose, and is
420
+ * left alone.
421
+ *
422
+ * Returns true when .gitignore was changed — the caller must say so out loud,
423
+ * because a CLI editing a file it does not own should never do it silently.
424
+ */
425
+ export function ensureSecretDirIgnored(repoRoot) {
426
+ if (!lacksIgnoreRule(repoRoot, SECRET_FILE_DIR)) {
354
427
  return false
355
428
  }
356
429
 
@@ -379,9 +452,21 @@ export function ensureSecretDirIgnored(repoRoot) {
379
452
  return true
380
453
  }
381
454
 
382
- /** Everything auto-discovery offers to track: root dotenv files, then `.config/` files. */
383
- export function discoverTrackedFiles(repoRoot) {
384
- return [...discoverEnvFiles(repoRoot), ...discoverSecretFiles(repoRoot)]
455
+ /**
456
+ * Everything auto-discovery offers to track, target by target in the order
457
+ * given: root dotenv files for `.env*`, the git-ignored files of each directory
458
+ * otherwise.
459
+ *
460
+ * `targets` is passed in rather than read from settings here, so this stays a
461
+ * function of the repo alone; callers hand it `readSettings().discover`.
462
+ */
463
+ export function discoverTrackedFiles(repoRoot, targets = DEFAULT_DISCOVER) {
464
+ const names = targets.flatMap(target =>
465
+ target === ENV_FILES_TARGET ? discoverEnvFiles(repoRoot) : discoverDirFiles(repoRoot, target)
466
+ )
467
+
468
+ // Two targets can overlap (`.config` and `.config/gcloud`); each file once.
469
+ return [...new Set(names)]
385
470
  }
386
471
 
387
472
  /**
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