envstash-cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -16
- package/package.json +1 -1
- package/src/commands/misc.mjs +127 -7
- package/src/commands/push.mjs +68 -20
- package/src/commands/sync.mjs +7 -5
- package/src/context.mjs +8 -3
- package/src/index.mjs +27 -7
- package/src/manifest.mjs +1 -0
- package/src/project.mjs +87 -27
- package/src/settings.mjs +124 -0
package/README.md
CHANGED
|
@@ -46,6 +46,7 @@ envs sync
|
|
|
46
46
|
| `push` | Send local files to the cloud |
|
|
47
47
|
| `sync` | Reconcile both directions at once, keeping every key |
|
|
48
48
|
| `remove` | Delete files from the cloud (local copies stay) |
|
|
49
|
+
| `config` | Show the settings in effect and where each came from |
|
|
49
50
|
| `version` | Show the CLI version and its environment |
|
|
50
51
|
|
|
51
52
|
Run `envs help <command>` for details on any one of them.
|
|
@@ -119,11 +120,10 @@ definition and therefore has no undo:
|
|
|
119
120
|
file are left alone, so pushing a partial `.env` cannot destroy a teammate's
|
|
120
121
|
key. Use `--prune` when you do want the cloud to mirror local exactly.
|
|
121
122
|
- **`push` lists new files before uploading them.** Any file that is not in the
|
|
122
|
-
cloud yet is shown, with its size, before the first upload
|
|
123
|
+
cloud yet is shown, with its size, before the first upload, and files that
|
|
123
124
|
discovery found — not named on the command line and not listed in
|
|
124
|
-
`.envstash.json` —
|
|
125
|
-
|
|
126
|
-
cloud, later pushes of it go through without asking.
|
|
125
|
+
`.envstash.json` — are marked `(discovered)`. They are then uploaded. To be
|
|
126
|
+
asked first, turn on [`confirmNewFiles`](#settings).
|
|
127
127
|
- **`diff` masks values**, showing at most the first 4 characters and the
|
|
128
128
|
length, and nothing at all of a value 12 characters or shorter. Use
|
|
129
129
|
`--show-values` to reveal them.
|
|
@@ -254,7 +254,8 @@ files to track — and it is what lets a teammate clone the repo and run
|
|
|
254
254
|
|
|
255
255
|
Without a link file the CLI guesses the project from your git remote's repo
|
|
256
256
|
name and discovers `.env*` files in the repo root, plus files in `.config/`
|
|
257
|
-
(see below).
|
|
257
|
+
and `.agents/` (see below). That list is the default, and
|
|
258
|
+
[`discover`](#settings) changes it. Dotenv files ending in `.example`, `.sample`, `.template`, and
|
|
258
259
|
`.dist` are ignored, since those are meant to be committed and hold placeholder
|
|
259
260
|
values.
|
|
260
261
|
|
|
@@ -288,8 +289,40 @@ than the one you are standing in.
|
|
|
288
289
|
Outside a git repository, or if `git` is not installed, discovery adds nothing
|
|
289
290
|
from `.config/` rather than guessing — over-discovery would upload a file nobody
|
|
290
291
|
chose to share, while under-discovery just means listing it in `.envstash.json`
|
|
291
|
-
by hand. The scan covers
|
|
292
|
-
are declared explicitly.
|
|
292
|
+
by hand. The scan covers each discovered directory and everything under it;
|
|
293
|
+
files anywhere else are declared explicitly.
|
|
294
|
+
|
|
295
|
+
### Files in `.agents/`
|
|
296
|
+
|
|
297
|
+
`.agents/` is where a repo keeps what its AI coding agents read — skills, rules,
|
|
298
|
+
per-tool settings. When the directory exists it is discovered the same way as
|
|
299
|
+
`.config/`, with two differences:
|
|
300
|
+
|
|
301
|
+
- **Documents are kept.** Skills and rules are markdown, so the rule that keeps
|
|
302
|
+
a README out of `.config/` does not apply.
|
|
303
|
+
- **It is never added to `.gitignore` for you.** Plenty of repos commit
|
|
304
|
+
`.agents/`, and ignoring it on the way past would make the next `git add -A`
|
|
305
|
+
silently leave it behind.
|
|
306
|
+
|
|
307
|
+
Git-ignore is still the gate, so there are three cases:
|
|
308
|
+
|
|
309
|
+
| `.agents/` is… | What `push` and `sync` do |
|
|
310
|
+
| ----------------------------- | ------------------------------------------------- |
|
|
311
|
+
| git-ignored | Sync its files |
|
|
312
|
+
| committed | Nothing — git already carries it |
|
|
313
|
+
| neither ignored nor committed | Nothing, and say so, so you can pick one of those |
|
|
314
|
+
|
|
315
|
+
A `.agents` that is a symlink (to `.claude`, say) is skipped, like every other
|
|
316
|
+
link.
|
|
317
|
+
|
|
318
|
+
### Choosing what is discovered
|
|
319
|
+
|
|
320
|
+
Discovery only decides the file list for a repo that has none of its own. Once
|
|
321
|
+
`.envstash.json` lists `files`, that list is used as written — add
|
|
322
|
+
`.agents/…` paths to it by hand, or remove `files` to go back to discovery.
|
|
323
|
+
|
|
324
|
+
To change the defaults on this machine, set [`discover`](#settings). Run
|
|
325
|
+
`envs config` to see the list in effect and the files it currently finds.
|
|
293
326
|
|
|
294
327
|
Monorepos can track nested paths by listing them explicitly:
|
|
295
328
|
|
|
@@ -321,15 +354,15 @@ kind wins — a file cannot silently change identity between dotenv and opaque.
|
|
|
321
354
|
|
|
322
355
|
## Configuration
|
|
323
356
|
|
|
324
|
-
| Variable | Purpose
|
|
325
|
-
| --------------------- |
|
|
326
|
-
| `ENVSTASH_TOKEN` | Use this token instead of the stored one — for CI
|
|
327
|
-
| `ENVSTASH_HOST` | Target another server (default `https://www.envstash.co`)
|
|
328
|
-
| `ENVSTASH_PORT` | Target a dev server on localhost at this port
|
|
329
|
-
| `ENVSTASH_CONFIG_DIR` | Where credentials live (default `~/.envstash`)
|
|
330
|
-
| `ENVSTASH_DEV` | Look for a local dev server to log in to
|
|
331
|
-
| `ENVSTASH_DEBUG` | Print stack traces on failure
|
|
332
|
-
| `NO_COLOR` | Disable colour (also off automatically when piped)
|
|
357
|
+
| Variable | Purpose |
|
|
358
|
+
| --------------------- | ----------------------------------------------------------- |
|
|
359
|
+
| `ENVSTASH_TOKEN` | Use this token instead of the stored one — for CI |
|
|
360
|
+
| `ENVSTASH_HOST` | Target another server (default `https://www.envstash.co`) |
|
|
361
|
+
| `ENVSTASH_PORT` | Target a dev server on localhost at this port |
|
|
362
|
+
| `ENVSTASH_CONFIG_DIR` | Where credentials and settings live (default `~/.envstash`) |
|
|
363
|
+
| `ENVSTASH_DEV` | Look for a local dev server to log in to |
|
|
364
|
+
| `ENVSTASH_DEBUG` | Print stack traces on failure |
|
|
365
|
+
| `NO_COLOR` | Disable colour (also off automatically when piped) |
|
|
333
366
|
|
|
334
367
|
In CI, set `ENVSTASH_TOKEN` from your platform's secret store **and set
|
|
335
368
|
`ENVSTASH_HOST`**, then run `envs pull` — no interactive login required.
|
|
@@ -342,6 +375,45 @@ checkout, and without this rule a repo could name a server and be handed your
|
|
|
342
375
|
credential. If a repo pins a host you have not used, the CLI says so and tells
|
|
343
376
|
you the `--host` to pass if you meant it.
|
|
344
377
|
|
|
378
|
+
### Settings
|
|
379
|
+
|
|
380
|
+
Preferences for this machine live in `~/.envstash/settings.json` — a separate
|
|
381
|
+
file from `config.json`, which holds your tokens and is not meant to be edited
|
|
382
|
+
by hand. The file is optional; create it to change a default.
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"confirmNewFiles": true,
|
|
387
|
+
"discover": [".env*", ".config", ".agents"]
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
- **`confirmNewFiles`** (default `false`) — ask before `push` or `sync` uploads
|
|
392
|
+
a file that discovery found for the first time.
|
|
393
|
+
- **`discover`** (default `[".env*", ".config", ".agents"]`) — what is synced in
|
|
394
|
+
a repo that does not list its own files.
|
|
395
|
+
|
|
396
|
+
`discover` **replaces** the default list, so leaving an entry out turns it off:
|
|
397
|
+
`[".env*", ".config"]` stops syncing `.agents/`, and `[]` discovers nothing.
|
|
398
|
+
`".env*"` means the dotenv files in the repo root. Any other entry is a
|
|
399
|
+
directory inside the repo, whose git-ignored files are synced — add `".claude"`
|
|
400
|
+
to carry local agent settings, for example. `.git`, `node_modules`, and paths
|
|
401
|
+
that leave the repo are refused.
|
|
402
|
+
|
|
403
|
+
It lives here rather than in `.envstash.json` on purpose: that file arrives
|
|
404
|
+
with a clone, and a repo should not get to choose which of your directories are
|
|
405
|
+
uploaded. Taking a directory out of `discover` stops new files being found; files
|
|
406
|
+
already in the cloud keep syncing until you `envs rm` them.
|
|
407
|
+
|
|
408
|
+
`envs config` prints every setting in effect and whether it came from this file
|
|
409
|
+
or is a default, along with the server, account, and this repo's project and
|
|
410
|
+
files. It never prints a token.
|
|
411
|
+
|
|
412
|
+
With `confirmNewFiles` on, `push` and `sync` ask on a terminal, and anywhere
|
|
413
|
+
else (CI, a pipe) they stop unless you pass `--yes` (`-y`). Files you named on
|
|
414
|
+
the command line, files listed in `.envstash.json`, and files already in the
|
|
415
|
+
cloud are never asked about.
|
|
416
|
+
|
|
345
417
|
### Working against a local server
|
|
346
418
|
|
|
347
419
|
Every command takes `--port` as shorthand for a server on this machine, so
|
package/package.json
CHANGED
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 } 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
|
-
|
|
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)
|
|
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
|
-
|
|
330
|
+
announceDiscoveryDirs(repoRoot, { discovering: !existing?.files?.length })
|
|
213
331
|
|
|
214
|
-
const files = existing?.files?.length
|
|
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()
|
package/src/commands/push.mjs
CHANGED
|
@@ -9,22 +9,57 @@ 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
|
+
SECRET_FILE_DIR,
|
|
17
|
+
} from '../project.mjs'
|
|
13
18
|
import { resolveRepoFile } from '../repo-path.mjs'
|
|
19
|
+
import { readSettings } from '../settings.mjs'
|
|
14
20
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
15
21
|
import { CliError, confirm, isInteractive, out, pad, pluralize, style } from '../ui.mjs'
|
|
16
22
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Get the discovered directories ready, and say what was done or left undone.
|
|
25
|
+
*
|
|
26
|
+
* Discovery only offers files git ignores, so a directory with contents that
|
|
27
|
+
* nothing tracks and nothing ignores is carried by neither git nor envstash.
|
|
28
|
+
* For `.config/` that is closed by adding the ignore rule — and saying so, since
|
|
29
|
+
* quietly rewriting a user's file is worse. For any other directory it is only
|
|
30
|
+
* pointed out: `.agents/` is committed in plenty of repos, and ignoring it on
|
|
31
|
+
* the way past would make the next `git add -A` silently leave it behind.
|
|
32
|
+
*
|
|
33
|
+
* A directory the developer took out of `discover` is not touched or mentioned.
|
|
34
|
+
* `discovering` is false when this run's files were chosen by name or by the
|
|
35
|
+
* link file: discovery is not deciding anything then, so there is nothing to
|
|
36
|
+
* point out.
|
|
37
|
+
*/
|
|
38
|
+
export function announceDiscoveryDirs(repoRoot, { discovering = true } = {}) {
|
|
39
|
+
const dirs = readSettings().discover.filter(target => target !== ENV_FILES_TARGET)
|
|
40
|
+
|
|
41
|
+
if (dirs.includes(SECRET_FILE_DIR) && ensureSecretDirIgnored(repoRoot)) {
|
|
42
|
+
out()
|
|
43
|
+
out(
|
|
44
|
+
` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
|
|
45
|
+
style.dim('— secrets sync through EnvStash, not git')
|
|
46
|
+
)
|
|
21
47
|
}
|
|
22
48
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
49
|
+
const leftToGit = discovering
|
|
50
|
+
? dirs.filter(dir => dir !== SECRET_FILE_DIR && lacksIgnoreRule(repoRoot, dir))
|
|
51
|
+
: []
|
|
52
|
+
|
|
53
|
+
if (leftToGit.length > 0) {
|
|
54
|
+
out()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const dir of leftToGit) {
|
|
58
|
+
out(
|
|
59
|
+
` ${style.yellow('!')} ${style.bold(`${dir}/`)} is not git-ignored, so it is not synced ` +
|
|
60
|
+
style.dim('— add it to .gitignore to sync it through EnvStash, or commit it')
|
|
61
|
+
)
|
|
62
|
+
}
|
|
28
63
|
}
|
|
29
64
|
|
|
30
65
|
/**
|
|
@@ -57,7 +92,9 @@ export async function push(flags, args) {
|
|
|
57
92
|
// this very push rather than the next one. Not under --dry-run: a preview
|
|
58
93
|
// that edits files is not a preview.
|
|
59
94
|
if (!flags['dry-run']) {
|
|
60
|
-
|
|
95
|
+
announceDiscoveryDirs(context.repoRoot, {
|
|
96
|
+
discovering: args.length === 0 && !context.link?.files?.length,
|
|
97
|
+
})
|
|
61
98
|
}
|
|
62
99
|
|
|
63
100
|
const cloudFiles = await fetchCloudFiles(context, scope)
|
|
@@ -183,18 +220,25 @@ export async function push(flags, args) {
|
|
|
183
220
|
}
|
|
184
221
|
|
|
185
222
|
/**
|
|
186
|
-
* Show
|
|
187
|
-
*
|
|
223
|
+
* Show every file this push would create in the cloud, before the first byte
|
|
224
|
+
* goes up — and, when the developer has asked to be asked, get a yes for the
|
|
225
|
+
* ones discovery found.
|
|
188
226
|
*
|
|
189
227
|
* Discovery sweeps root `.env*` files and everything git-ignored under
|
|
190
|
-
* `.config/`, recursively, and used to upload the lot and only
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
228
|
+
* `.config/` and `.agents/`, recursively, and used to upload the lot and only
|
|
229
|
+
* then list it.
|
|
230
|
+
* The list now always comes first, marking which files were discovered.
|
|
231
|
+
*
|
|
232
|
+
* Uploading them is the default: a developer who runs `envs push` in their own
|
|
233
|
+
* repo means "send my files up", and a question on every new file trained the
|
|
234
|
+
* answer to be `y`. Setting `"confirmNewFiles": true` in
|
|
235
|
+
* `~/.envstash/settings.json` restores the gate. A file already in the cloud,
|
|
236
|
+
* named on the command line, or listed in `.envstash.json` was chosen by
|
|
237
|
+
* someone; a file discovery found for the first time was not, so that is the
|
|
238
|
+
* set the gate asks about.
|
|
195
239
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
240
|
+
* With the gate on: asked on a terminal; refused without `--yes` anywhere
|
|
241
|
+
* else, since a CI job cannot answer.
|
|
198
242
|
*
|
|
199
243
|
* `envs sync` uploads new files too, and runs the same gate — `command` only
|
|
200
244
|
* changes which command name the messages tell the developer to re-run.
|
|
@@ -224,6 +268,10 @@ export async function confirmNewFiles(flags, context, args, comparison, { comman
|
|
|
224
268
|
return
|
|
225
269
|
}
|
|
226
270
|
|
|
271
|
+
if (!readSettings().confirmNewFiles) {
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
227
275
|
const count = pluralize(discovered.length, 'discovered file')
|
|
228
276
|
|
|
229
277
|
if (isInteractive()) {
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
56
|
+
announceDiscoveryDirs(context.repoRoot, {
|
|
57
|
+
discovering: args.length === 0 && !context.link?.files?.length,
|
|
58
|
+
})
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
const cloudFiles = await fetchCloudFiles(context, scope)
|
|
@@ -84,9 +86,9 @@ export async function sync(flags, args) {
|
|
|
84
86
|
const plans = planSync(comparison, { prefer })
|
|
85
87
|
const byName = new Map(comparison.map(file => [file.name, file]))
|
|
86
88
|
|
|
87
|
-
// The same
|
|
88
|
-
//
|
|
89
|
-
//
|
|
89
|
+
// The same listing — and, when `confirmNewFiles` is set, the same gate —
|
|
90
|
+
// `push` puts in front of a file discovery found on its own. Sync uploads
|
|
91
|
+
// new files too.
|
|
90
92
|
await confirmNewFiles(flags, context, args, comparison, { command: 'sync' })
|
|
91
93
|
|
|
92
94
|
out()
|
package/src/context.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { parseEnvFile } from './env-file.mjs'
|
|
|
5
5
|
import { discoverTrackedFiles, findRepoRoot, readLink, resolveProject } from './project.mjs'
|
|
6
6
|
import { findSymlink, resolveInsideRepo, resolveRepoFile } from './repo-path.mjs'
|
|
7
7
|
import { requireHostToken } from './session.mjs'
|
|
8
|
+
import { readSettings } from './settings.mjs'
|
|
8
9
|
import { errOut, pluralize, style } from './ui.mjs'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -35,8 +36,9 @@ export async function resolveContext(flags) {
|
|
|
35
36
|
* Explicit arguments win. Otherwise the link file's list is authoritative if
|
|
36
37
|
* present — an explicit list is the only way to include a nested path like
|
|
37
38
|
* `apps/web/.env.local`, which discovery does not scan for. With no link file,
|
|
38
|
-
* fall back to the union of what discovery finds — root `.env*` files plus
|
|
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,14 +282,16 @@ function printHelp(topic) {
|
|
|
281
282
|
out(` --host <url> ${style.dim('Target another EnvStash server')}`)
|
|
282
283
|
out(` --port <n> ${style.dim('Target a dev server on localhost at this port')}`)
|
|
283
284
|
out(
|
|
284
|
-
` --json ${style.dim('Machine-readable output (status, list, whoami, version)')}`
|
|
285
|
+
` --json ${style.dim('Machine-readable output (status, list, whoami, config, version)')}`
|
|
285
286
|
)
|
|
286
287
|
out(
|
|
287
288
|
` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff, sync)')}`
|
|
288
289
|
)
|
|
289
290
|
out(` --prefer <side> ${style.dim('Resolve conflicts as "local" or "cloud" (sync)')}`)
|
|
290
291
|
out(` --dry-run ${style.dim('Show what would happen (pull, push, sync, remove)')}`)
|
|
291
|
-
out(
|
|
292
|
+
out(
|
|
293
|
+
` -y, --yes ${style.dim('Skip the new-file question, if you turned it on (push, sync)')}`
|
|
294
|
+
)
|
|
292
295
|
out(
|
|
293
296
|
` --shared ${style.dim('Target your account-wide shared files (pull, push, sync, remove)')}`
|
|
294
297
|
)
|
|
@@ -378,9 +381,10 @@ const COMMAND_HELP = {
|
|
|
378
381
|
'Keys in the cloud that are absent locally are left alone unless --prune is',
|
|
379
382
|
'given, so pushing a partial .env cannot delete a teammate’s key.',
|
|
380
383
|
'',
|
|
381
|
-
'Files that would be new in the cloud are listed before anything uploads
|
|
382
|
-
'
|
|
383
|
-
'
|
|
384
|
+
'Files that would be new in the cloud are listed before anything uploads,',
|
|
385
|
+
'then uploaded. To be asked first about ones found by discovery — not',
|
|
386
|
+
'named, not in .envstash.json — put { "confirmNewFiles": true } in',
|
|
387
|
+
'~/.envstash/settings.json; --yes (-y) then answers for one run.',
|
|
384
388
|
'',
|
|
385
389
|
'--shared sends the named files to your account-wide shared files — for',
|
|
386
390
|
'credentials that follow you across projects, like a global .npmrc. Pull',
|
|
@@ -388,7 +392,7 @@ const COMMAND_HELP = {
|
|
|
388
392
|
'',
|
|
389
393
|
' envs push',
|
|
390
394
|
' envs push --dry-run',
|
|
391
|
-
' envs push --yes
|
|
395
|
+
' envs push --yes Skip the new-file question, if you turned it on',
|
|
392
396
|
' envs push --prune',
|
|
393
397
|
' envs push --shared .npmrc',
|
|
394
398
|
],
|
|
@@ -430,6 +434,22 @@ const COMMAND_HELP = {
|
|
|
430
434
|
link: ['Link this repo to a project that already exists.', '', ' envs link my-app'],
|
|
431
435
|
list: ['List every project in your account.', '', ' envs list', ' envs list --json'],
|
|
432
436
|
whoami: ['Show which account this machine is signed in as.', '', ' envs whoami'],
|
|
437
|
+
config: [
|
|
438
|
+
'Print every setting in effect and where each one came from: your',
|
|
439
|
+
'~/.envstash/settings.json, the server and account commands would use, and',
|
|
440
|
+
'this repo’s project and files. Tokens are never printed. Works offline.',
|
|
441
|
+
'',
|
|
442
|
+
'To change what is synced by default, set "discover" in settings.json. It',
|
|
443
|
+
'replaces the default list, so leave an entry out to turn it off:',
|
|
444
|
+
'',
|
|
445
|
+
' { "discover": [".env*", ".config", ".agents"] }',
|
|
446
|
+
'',
|
|
447
|
+
'".env*" is the dotenv files in the repo root; anything else is a directory',
|
|
448
|
+
'whose git-ignored files are synced.',
|
|
449
|
+
'',
|
|
450
|
+
' envs config',
|
|
451
|
+
' envs config --json',
|
|
452
|
+
],
|
|
433
453
|
version: [
|
|
434
454
|
'Show the version, runtime, install path, and which server commands would',
|
|
435
455
|
'talk to. --version and -v print the bare version for scripts.',
|
package/src/manifest.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export const CLI_COMMANDS = [
|
|
|
29
29
|
{ name: 'push', summary: 'Send local files to the cloud' },
|
|
30
30
|
{ name: 'sync', summary: 'Reconcile both directions at once, keeping every key' },
|
|
31
31
|
{ name: 'remove', summary: 'Delete files from the cloud (local copies stay)', aliases: ['rm'] },
|
|
32
|
+
{ name: 'config', summary: 'Show the settings in effect and where each came from' },
|
|
32
33
|
{ name: 'version', summary: 'Show the CLI version and its environment' },
|
|
33
34
|
]
|
|
34
35
|
|
package/src/project.mjs
CHANGED
|
@@ -202,6 +202,26 @@ export function discoverEnvFiles(repoRoot) {
|
|
|
202
202
|
*/
|
|
203
203
|
export const SECRET_FILE_DIR = '.config'
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Where a repo keeps what its AI coding agents read — skills, rules, per-tool
|
|
207
|
+
* settings. Not a credential, but the same population as one: files a developer
|
|
208
|
+
* needs on every machine and, when the repo ignores them, cannot get from git.
|
|
209
|
+
*/
|
|
210
|
+
export const AGENTS_DIR = '.agents'
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The discovery target that means "dotenv files in the repo root". Every other
|
|
214
|
+
* target names a directory; this one names the scan in `discoverEnvFiles`, which
|
|
215
|
+
* is a naming rule rather than a place.
|
|
216
|
+
*/
|
|
217
|
+
export const ENV_FILES_TARGET = '.env*'
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* What discovery looks at when the developer has not said otherwise — the
|
|
221
|
+
* `discover` list in `~/.envstash/settings.json` replaces it wholesale.
|
|
222
|
+
*/
|
|
223
|
+
export const DEFAULT_DISCOVER = [ENV_FILES_TARGET, SECRET_FILE_DIR, AGENTS_DIR]
|
|
224
|
+
|
|
205
225
|
/**
|
|
206
226
|
* Which of `names` git ignores, as a Set.
|
|
207
227
|
*
|
|
@@ -251,7 +271,9 @@ const DOCUMENT_SUFFIXES = [
|
|
|
251
271
|
]
|
|
252
272
|
|
|
253
273
|
/**
|
|
254
|
-
* Discover files in
|
|
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
|
|
269
|
-
const directory = join(repoRoot,
|
|
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 => `${
|
|
281
|
-
.filter(
|
|
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 —
|
|
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
|
-
*
|
|
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
|
-
*
|
|
320
|
-
*
|
|
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
|
|
350
|
+
export function lacksIgnoreRule(repoRoot, dir) {
|
|
323
351
|
let entries
|
|
324
352
|
|
|
325
353
|
try {
|
|
326
|
-
entries = readdirSync(join(repoRoot,
|
|
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', '--',
|
|
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 = `${
|
|
380
|
+
const probe = `${dir}/envstash-ignore-probe`
|
|
381
|
+
|
|
382
|
+
return !gitIgnoredNames(repoRoot, [probe]).has(probe)
|
|
383
|
+
}
|
|
352
384
|
|
|
353
|
-
|
|
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
|
-
/**
|
|
383
|
-
|
|
384
|
-
|
|
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
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { configDir } from './config.mjs'
|
|
5
|
+
import { DEFAULT_DISCOVER, ENV_FILES_TARGET } from './project.mjs'
|
|
6
|
+
import { isSafeRepoPath } from './repo-path.mjs'
|
|
7
|
+
import { CliError } from './ui.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Preferences for this machine, next to — and deliberately not inside — the
|
|
11
|
+
* credential file. `config.json` holds every token and is rewritten by login
|
|
12
|
+
* and logout; a file meant to be edited by hand should not be one where a
|
|
13
|
+
* stray comma signs the developer out of everything.
|
|
14
|
+
*
|
|
15
|
+
* Per machine rather than per repo: `.envstash.json` travels with a clone, and
|
|
16
|
+
* how much a developer wants to be asked — or which of their directories get
|
|
17
|
+
* swept up — is theirs to decide, not the repo's. A cloned repo that could say
|
|
18
|
+
* "also discover this directory" would be choosing what leaves the machine.
|
|
19
|
+
*
|
|
20
|
+
* {
|
|
21
|
+
* "confirmNewFiles": true,
|
|
22
|
+
* "discover": [".env*", ".config"]
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
26
|
+
/** Ask before uploading a file that discovery found for the first time. */
|
|
27
|
+
confirmNewFiles: false,
|
|
28
|
+
/**
|
|
29
|
+
* What discovery looks at in a repo with no file list of its own: `.env*` for
|
|
30
|
+
* root dotenv files, and directories whose git-ignored files are offered.
|
|
31
|
+
* Replaces the default list rather than adding to it, so leaving an entry out
|
|
32
|
+
* is how it is turned off.
|
|
33
|
+
*/
|
|
34
|
+
discover: Object.freeze([...DEFAULT_DISCOVER]),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One reader per setting: takes the value from the file and returns the value
|
|
39
|
+
* to use, or calls `refuse` — which throws — with what the value must be and a
|
|
40
|
+
* hint. A value of the
|
|
41
|
+
* wrong type is refused rather than coerced: `"false"` is a truthy string, and
|
|
42
|
+
* reading it as "yes, ask me" or as "no, don't" would both be a guess about a
|
|
43
|
+
* safety preference.
|
|
44
|
+
*/
|
|
45
|
+
const READERS = {
|
|
46
|
+
confirmNewFiles(value, refuse) {
|
|
47
|
+
if (typeof value !== 'boolean') {
|
|
48
|
+
refuse('must be true or false', 'Example: { "confirmNewFiles": true }')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return value
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
discover(value, refuse) {
|
|
55
|
+
const example = `Example: { "discover": ${JSON.stringify(DEFAULT_DISCOVER)} }`
|
|
56
|
+
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
refuse('must be a list', example)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const targets = value.map(entry => {
|
|
62
|
+
// `.agents/` and `.agents` are the same directory to anyone typing it.
|
|
63
|
+
const target = typeof entry === 'string' ? entry.replace(/\/+$/, '') : entry
|
|
64
|
+
|
|
65
|
+
// The same grammar every synced name passes, which also keeps `.git`,
|
|
66
|
+
// `node_modules`, `..` and absolute paths out of the sweep.
|
|
67
|
+
if (target !== ENV_FILES_TARGET && !isSafeRepoPath(target)) {
|
|
68
|
+
refuse(
|
|
69
|
+
`lists ${JSON.stringify(entry)}, which is not something envstash can discover`,
|
|
70
|
+
`Use "${ENV_FILES_TARGET}" for root dotenv files, or a directory inside the repo. ${example}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return target
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return [...new Set(targets)]
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function settingsPath() {
|
|
82
|
+
return join(configDir(), 'settings.json')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The settings, with defaults for whatever the file does not say — or for a
|
|
87
|
+
* file that is not there, which is the normal case.
|
|
88
|
+
*/
|
|
89
|
+
export function readSettings() {
|
|
90
|
+
const path = settingsPath()
|
|
91
|
+
const settings = { ...DEFAULT_SETTINGS, discover: [...DEFAULT_SETTINGS.discover] }
|
|
92
|
+
|
|
93
|
+
if (!existsSync(path)) {
|
|
94
|
+
return settings
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let parsed
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
101
|
+
} catch {
|
|
102
|
+
throw new CliError(`Could not read ${path} — it is not valid JSON.`, {
|
|
103
|
+
hint: 'Fix the file, or delete it to go back to the defaults.',
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
108
|
+
throw new CliError(`${path} must hold a JSON object.`, {
|
|
109
|
+
hint: 'Example: { "confirmNewFiles": true }',
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [key, read] of Object.entries(READERS)) {
|
|
114
|
+
if (!(key in parsed)) {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
settings[key] = read(parsed[key], (must, hint) => {
|
|
119
|
+
throw new CliError(`"${key}" in ${path} ${must}.`, { hint })
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return settings
|
|
124
|
+
}
|