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 +70 -8
- package/package.json +1 -1
- package/src/commands/misc.mjs +129 -7
- package/src/commands/push.mjs +87 -10
- package/src/commands/sync.mjs +15 -2
- package/src/context.mjs +8 -3
- package/src/index.mjs +19 -2
- package/src/manifest.mjs +1 -0
- package/src/project.mjs +114 -29
- package/src/settings.mjs +65 -19
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
|
|
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).
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
package/src/commands/misc.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
264
|
+
announceDiscoveryDirs(repoRoot, { discovering: !existing?.files?.length })
|
|
149
265
|
|
|
150
|
-
const files = existing?.files?.length
|
|
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
|
-
|
|
331
|
+
announceDiscoveryDirs(repoRoot, { discovering: !existing?.files?.length })
|
|
213
332
|
|
|
214
|
-
const files = existing?.files?.length
|
|
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()
|
package/src/commands/push.mjs
CHANGED
|
@@ -9,23 +9,92 @@ import {
|
|
|
9
9
|
resolveFileNames,
|
|
10
10
|
SHARED_SCOPE,
|
|
11
11
|
} from '../context.mjs'
|
|
12
|
-
import {
|
|
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
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
39
|
-
* files in `.config/`
|
|
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([
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
269
|
-
const directory = join(repoRoot,
|
|
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 => `${
|
|
281
|
-
.filter(
|
|
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 —
|
|
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
|
-
*
|
|
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
|
-
*
|
|
320
|
-
*
|
|
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
|
|
375
|
+
export function lacksIgnoreRule(repoRoot, dir) {
|
|
323
376
|
let entries
|
|
324
377
|
|
|
325
378
|
try {
|
|
326
|
-
entries = readdirSync(join(repoRoot,
|
|
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', '--',
|
|
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 = `${
|
|
405
|
+
const probe = `${dir}/envstash-ignore-probe`
|
|
352
406
|
|
|
353
|
-
|
|
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
|
-
/**
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
68
|
-
throw new CliError(
|
|
69
|
-
|
|
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
|