envstash-cli 0.1.1 → 0.3.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 +99 -12
- package/package.json +1 -1
- package/src/api.mjs +17 -2
- package/src/blob.mjs +39 -0
- package/src/commands/diff.mjs +19 -3
- package/src/commands/misc.mjs +9 -3
- package/src/commands/pull.mjs +17 -10
- package/src/commands/push.mjs +51 -5
- package/src/commands/status.mjs +22 -3
- package/src/context.mjs +24 -4
- package/src/dev-host.mjs +50 -0
- package/src/index.mjs +55 -1
- package/src/manifest.mjs +5 -1
- package/src/project.mjs +152 -7
- package/src/session.mjs +60 -12
- package/src/status.mjs +54 -2
package/README.md
CHANGED
|
@@ -41,6 +41,12 @@ envs pull
|
|
|
41
41
|
|
|
42
42
|
Run `envs help <command>` for details on any one of them.
|
|
43
43
|
|
|
44
|
+
Every command accepts `-C <dir>` (or `--cwd <dir>`) to change to that directory
|
|
45
|
+
first, like `git -C` — the repo, link file, and relative file arguments all
|
|
46
|
+
resolve from there. Handy when the CLI itself must run from elsewhere, e.g.
|
|
47
|
+
debugging with `pnpm run cli:dev status -C ~/projects/my-app` from the
|
|
48
|
+
envstash checkout.
|
|
49
|
+
|
|
44
50
|
## `envs status`
|
|
45
51
|
|
|
46
52
|
Checks every file in your cloud account against what is on disk.
|
|
@@ -125,9 +131,42 @@ files to track — and it is what lets a teammate clone the repo and run
|
|
|
125
131
|
`envs pull` with no further setup.
|
|
126
132
|
|
|
127
133
|
Without a link file the CLI guesses the project from your git remote's repo
|
|
128
|
-
name and discovers `.env*` files in the repo root
|
|
129
|
-
`.sample`, `.template`, and
|
|
130
|
-
committed and hold placeholder
|
|
134
|
+
name and discovers `.env*` files in the repo root, plus files in `.config/`
|
|
135
|
+
(see below). Dotenv files ending in `.example`, `.sample`, `.template`, and
|
|
136
|
+
`.dist` are ignored, since those are meant to be committed and hold placeholder
|
|
137
|
+
values.
|
|
138
|
+
|
|
139
|
+
### Files in `.config/`
|
|
140
|
+
|
|
141
|
+
A service-account key or a cert is a secret that is not `KEY=value`, and it does
|
|
142
|
+
not live in the repo root — so the dotenv scan cannot find it. When a `.config/`
|
|
143
|
+
directory exists, discovery offers **everything in it**, subdirectories
|
|
144
|
+
included — no extension allowlist, because a credential does not always look
|
|
145
|
+
like one (an extensionless keyfile). A file is offered when **all** of these
|
|
146
|
+
hold:
|
|
147
|
+
|
|
148
|
+
- git ignores it,
|
|
149
|
+
- it is not a document (`.md`, `.txt`, `.doc`, `.pdf`, and the like — notes,
|
|
150
|
+
not credentials),
|
|
151
|
+
- it is under the 1 MB blob limit, and
|
|
152
|
+
- its name is one the sync commands accept (`[A-Za-z0-9._-]` per segment).
|
|
153
|
+
|
|
154
|
+
Being git-ignored is the signal for "secret": it is the repo's own statement
|
|
155
|
+
that a file is not for sharing through git, which is exactly the population
|
|
156
|
+
envstash exists to carry instead. A committed `.config/defaults.json` is left
|
|
157
|
+
alone.
|
|
158
|
+
|
|
159
|
+
Every git call the CLI makes clears `GIT_DIR`, `GIT_WORK_TREE` and the other
|
|
160
|
+
location variables first. Git exports those to each hook it runs, and they
|
|
161
|
+
override the working directory — so without clearing them, `envs status` in a
|
|
162
|
+
pre-commit hook would answer about whichever repository invoked the hook rather
|
|
163
|
+
than the one you are standing in.
|
|
164
|
+
|
|
165
|
+
Outside a git repository, or if `git` is not installed, discovery adds nothing
|
|
166
|
+
from `.config/` rather than guessing — over-discovery would upload a file nobody
|
|
167
|
+
chose to share, while under-discovery just means listing it in `.envstash.json`
|
|
168
|
+
by hand. The scan covers `.config/` and everything under it; files anywhere else
|
|
169
|
+
are declared explicitly.
|
|
131
170
|
|
|
132
171
|
Monorepos can track nested paths by listing them explicitly:
|
|
133
172
|
|
|
@@ -135,14 +174,36 @@ Monorepos can track nested paths by listing them explicitly:
|
|
|
135
174
|
{ "project": "my-monorepo", "files": ["apps/web/.env.local", "apps/api/.env"] }
|
|
136
175
|
```
|
|
137
176
|
|
|
177
|
+
## Opaque files (service-account keys, certs)
|
|
178
|
+
|
|
179
|
+
A tracked name whose basename is not `.env` or `.env.*` is stored **whole**, as
|
|
180
|
+
one encrypted file, rather than parsed into keys:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{ "project": "demo-app", "files": [".env", ".config/service-account.json"] }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Every command treats it as a single unit. `status` compares a digest of the raw
|
|
187
|
+
bytes (never the bytes themselves), `push` sends the file when that digest
|
|
188
|
+
changed, `pull` writes it back byte-for-byte with mode `0600`, and `diff`
|
|
189
|
+
reports _that_ it changed but never prints the contents — `--show-values`
|
|
190
|
+
included, because binary in a terminal helps no one. Opaque files are capped at
|
|
191
|
+
1 MB: a stashed file should be a key or a cert, not an archive.
|
|
192
|
+
|
|
193
|
+
Opaque files in `.config/` are auto-discovered (see above); one anywhere else
|
|
194
|
+
enters only by being listed in `.envstash.json` or named as an argument:
|
|
195
|
+
`envs push certs/server.pem`. For a name the cloud already knows, its recorded
|
|
196
|
+
kind wins — a file cannot silently change identity between dotenv and opaque.
|
|
197
|
+
|
|
138
198
|
## Configuration
|
|
139
199
|
|
|
140
200
|
| Variable | Purpose |
|
|
141
201
|
| --------------------- | --------------------------------------------------------- |
|
|
142
202
|
| `ENVSTASH_TOKEN` | Use this token instead of the stored one — for CI |
|
|
143
203
|
| `ENVSTASH_HOST` | Target another server (default `https://www.envstash.co`) |
|
|
204
|
+
| `ENVSTASH_PORT` | Target a dev server on localhost at this port |
|
|
144
205
|
| `ENVSTASH_CONFIG_DIR` | Where credentials live (default `~/.envstash`) |
|
|
145
|
-
| `ENVSTASH_DEV` |
|
|
206
|
+
| `ENVSTASH_DEV` | Look for a local dev server to log in to |
|
|
146
207
|
| `ENVSTASH_DEBUG` | Print stack traces on failure |
|
|
147
208
|
| `NO_COLOR` | Disable colour (also off automatically when piped) |
|
|
148
209
|
|
|
@@ -157,16 +218,42 @@ checkout, and without this rule a repo could name a server and be handed your
|
|
|
157
218
|
credential. If a repo pins a host you have not used, the CLI says so and tells
|
|
158
219
|
you the `--host` to pass if you meant it.
|
|
159
220
|
|
|
160
|
-
###
|
|
221
|
+
### Working against a local server
|
|
222
|
+
|
|
223
|
+
Every command takes `--port` as shorthand for a server on this machine, so
|
|
224
|
+
`--port 3001` says what `--host http://localhost:3001` says:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
envs status --port 3001
|
|
228
|
+
envs pull --port 3001
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`ENVSTASH_PORT=3001` does the same for a whole shell. Passing `--host` and
|
|
232
|
+
`--port` together is refused rather than resolved — they are two ways of naming
|
|
233
|
+
one server, and picking one silently is how a command ends up talking to the
|
|
234
|
+
server you meant to exclude.
|
|
235
|
+
|
|
236
|
+
`envs login` is deliberately not affected by any of this. **It signs in to
|
|
237
|
+
`https://www.envstash.co` unless you say otherwise**, even if your last login
|
|
238
|
+
was to a dev server: where the previous login went is not an instruction about
|
|
239
|
+
where the next one should go.
|
|
240
|
+
|
|
241
|
+
To sign in to a local server, name it — `envs login --port 3001` — or let the
|
|
242
|
+
CLI find it:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
envs login --dev
|
|
246
|
+
```
|
|
161
247
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
wins over detection.
|
|
248
|
+
`--dev` (or `ENVSTASH_DEV=1`) looks for an EnvStash dev server on ports 3000,
|
|
249
|
+
3001, 3002 and 3003 and logs in to the first one that answers. Production is the
|
|
250
|
+
fallback when none answer, and an explicit `--host` or `--port` always wins over
|
|
251
|
+
detection.
|
|
167
252
|
|
|
168
|
-
A port is only accepted if it responds as an EnvStash server, so an
|
|
169
|
-
process holding 3000 is skipped rather than logged in to.
|
|
253
|
+
A port is only accepted by detection if it responds as an EnvStash server, so an
|
|
254
|
+
unrelated process holding 3000 is skipped rather than logged in to. `--port`
|
|
255
|
+
does not probe: naming a port is you being explicit, and that is not
|
|
256
|
+
second-guessed.
|
|
170
257
|
|
|
171
258
|
## Notes
|
|
172
259
|
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -18,8 +18,12 @@ export class ApiError extends CliError {
|
|
|
18
18
|
* is not JSON (a proxy error page, a 502 from the edge) is reported as a
|
|
19
19
|
* transport problem rather than crashing on a parse.
|
|
20
20
|
*/
|
|
21
|
-
export async function request(
|
|
22
|
-
|
|
21
|
+
export async function request(
|
|
22
|
+
host,
|
|
23
|
+
path,
|
|
24
|
+
{ method = 'GET', token, body, signal, headers: extraHeaders } = {}
|
|
25
|
+
) {
|
|
26
|
+
const headers = { accept: 'application/json', ...extraHeaders }
|
|
23
27
|
|
|
24
28
|
if (token) {
|
|
25
29
|
headers.authorization = `Bearer ${token}`
|
|
@@ -136,6 +140,10 @@ export const api = {
|
|
|
136
140
|
getFile: (host, token, project, file) =>
|
|
137
141
|
request(host, `/api/cli/projects/${encodeURIComponent(project)}/files/${encodePath(file)}`, {
|
|
138
142
|
token,
|
|
143
|
+
// Declares that this CLI understands opaque file responses. The server
|
|
144
|
+
// refuses to send a blob to a client without it, because an older CLI
|
|
145
|
+
// would write it to disk as an empty dotenv file.
|
|
146
|
+
headers: { 'x-envstash-file-kinds': 'env,file' },
|
|
139
147
|
}),
|
|
140
148
|
|
|
141
149
|
putFile: (host, token, project, file, entries, prune) =>
|
|
@@ -144,6 +152,13 @@ export const api = {
|
|
|
144
152
|
token,
|
|
145
153
|
body: { entries, prune },
|
|
146
154
|
}),
|
|
155
|
+
|
|
156
|
+
putBlob: (host, token, project, file, contentBase64) =>
|
|
157
|
+
request(host, `/api/cli/projects/${encodeURIComponent(project)}/files/${encodePath(file)}`, {
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
token,
|
|
160
|
+
body: { kind: 'file', contentBase64 },
|
|
161
|
+
}),
|
|
147
162
|
}
|
|
148
163
|
|
|
149
164
|
/** Encode each path segment but keep the "/" separators that address a nested file. */
|
package/src/blob.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a tracked name is classified when the cloud has no say yet: `.env` and
|
|
6
|
+
* `.env.*` basenames are dotenv files parsed into keys; everything else — a
|
|
7
|
+
* service-account JSON, a cert — is an opaque file stored whole. For names the
|
|
8
|
+
* cloud already knows, its recorded kind wins; this function only decides the
|
|
9
|
+
* kind of a file's first push.
|
|
10
|
+
*/
|
|
11
|
+
export function isEnvName(name) {
|
|
12
|
+
const base = basename(name)
|
|
13
|
+
return base === '.env' || base.startsWith('.env.')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whole-file digest for opaque files: sha256 over raw bytes, truncated like the
|
|
18
|
+
* per-key digest. Must stay byte-identical to `fileDigest` in src/lib/crypto.ts.
|
|
19
|
+
*/
|
|
20
|
+
export function digestFileBytes(bytes) {
|
|
21
|
+
return createHash('sha256').update(bytes).digest('hex').slice(0, 12)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Mirror of MAX_BLOB_BYTES in src/lib/secrets/store.ts, checked before upload
|
|
25
|
+
* so an oversized file fails locally with a clear message, not after a round trip. */
|
|
26
|
+
export const MAX_BLOB_BYTES = 1024 * 1024
|
|
27
|
+
|
|
28
|
+
/** "2.3 KB" for listings. Bytes below 1 KB print exact. */
|
|
29
|
+
export function formatSize(bytes) {
|
|
30
|
+
if (bytes < 1024) {
|
|
31
|
+
return `${bytes} B`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (bytes < 1024 * 1024) {
|
|
35
|
+
return `${(bytes / 1024).toFixed(1)} KB`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
39
|
+
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { api, ApiError } from '../api.mjs'
|
|
2
|
-
import {
|
|
2
|
+
import { formatSize } from '../blob.mjs'
|
|
3
|
+
import { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
|
|
3
4
|
import { entriesToMap } from '../env-file.mjs'
|
|
4
5
|
import { compareProject, driftExitCode, FILE_STATE, KEY_STATE } from '../status.mjs'
|
|
5
6
|
import { maskValue, out, style } from '../ui.mjs'
|
|
@@ -23,7 +24,7 @@ export async function diff(flags, args) {
|
|
|
23
24
|
requested: args,
|
|
24
25
|
})
|
|
25
26
|
|
|
26
|
-
const localFiles = loadLocalFiles(context.repoRoot, names)
|
|
27
|
+
const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
|
|
27
28
|
const files = compareProject({
|
|
28
29
|
cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
|
|
29
30
|
localFiles,
|
|
@@ -44,7 +45,7 @@ export async function diff(flags, args) {
|
|
|
44
45
|
|
|
45
46
|
if (flags['show-values']) {
|
|
46
47
|
for (const file of changed) {
|
|
47
|
-
if (file.state === FILE_STATE.UNTRACKED) {
|
|
48
|
+
if (file.state === FILE_STATE.UNTRACKED || file.kind === 'file') {
|
|
48
49
|
continue
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -63,6 +64,21 @@ export async function diff(flags, args) {
|
|
|
63
64
|
for (const file of changed) {
|
|
64
65
|
out(` ${style.bold(file.name)} ${style.dim(`— ${describeFile(file)}`)}`)
|
|
65
66
|
|
|
67
|
+
if (file.kind === 'file') {
|
|
68
|
+
// A blob diffs as a whole: the digests already said the bytes differ, and
|
|
69
|
+
// dumping binary to a terminal helps no one — --show-values included.
|
|
70
|
+
if (file.state === FILE_STATE.DIFFERS) {
|
|
71
|
+
out(
|
|
72
|
+
` ${style.yellow('~')} ${style.dim(
|
|
73
|
+
`file contents differ${file.size === undefined ? '' : ` (local ${formatSize(file.size)})`}` +
|
|
74
|
+
' — binary, not shown'
|
|
75
|
+
)}`
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
out()
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
const local = entriesToMap(localFiles.get(file.name) ?? [])
|
|
67
83
|
const cloud = cloudValues.get(file.name)
|
|
68
84
|
|
package/src/commands/misc.mjs
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { api } from '../api.mjs'
|
|
2
2
|
import { getStoredEmail } from '../config.mjs'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
discoverTrackedFiles,
|
|
5
|
+
findRepoRoot,
|
|
6
|
+
readLink,
|
|
7
|
+
resolveProject,
|
|
8
|
+
writeLink,
|
|
9
|
+
} from '../project.mjs'
|
|
4
10
|
import { requireHostToken, resolveHostFor } from '../session.mjs'
|
|
5
11
|
import { out, pad, pluralize, style } from '../ui.mjs'
|
|
6
12
|
|
|
@@ -69,7 +75,7 @@ export async function init(flags, args) {
|
|
|
69
75
|
|
|
70
76
|
await api.createProject(host, token, name)
|
|
71
77
|
|
|
72
|
-
const files = existing?.files?.length ? existing.files :
|
|
78
|
+
const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
|
|
73
79
|
const path = writeLink(repoRoot, {
|
|
74
80
|
project: name,
|
|
75
81
|
files,
|
|
@@ -130,7 +136,7 @@ export async function link(flags, args) {
|
|
|
130
136
|
// that points at nothing.
|
|
131
137
|
await api.projectStatus(host, token, name)
|
|
132
138
|
|
|
133
|
-
const files = existing?.files?.length ? existing.files :
|
|
139
|
+
const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
|
|
134
140
|
const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
|
|
135
141
|
|
|
136
142
|
out()
|
package/src/commands/pull.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import { mkdirSync, writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { api, ApiError } from '../api.mjs'
|
|
5
|
-
import {
|
|
5
|
+
import { formatSize } from '../blob.mjs'
|
|
6
|
+
import { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
|
|
6
7
|
import { serializeEnv } from '../env-file.mjs'
|
|
7
8
|
import { resolveInsideRepo } from '../repo-path.mjs'
|
|
8
9
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
@@ -50,7 +51,7 @@ export async function pull(flags, args) {
|
|
|
50
51
|
return 0
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const localFiles = loadLocalFiles(context.repoRoot, names)
|
|
54
|
+
const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
|
|
54
55
|
const comparison = compareProject({
|
|
55
56
|
cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
|
|
56
57
|
localFiles,
|
|
@@ -80,28 +81,34 @@ export async function pull(flags, args) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
const response = await api.getFile(context.host, context.token, context.project.name, file.name)
|
|
83
|
-
|
|
84
|
+
|
|
85
|
+
// A blob comes back whole; a dotenv file is regenerated from entries. The
|
|
86
|
+
// branch is on the response's own kind — the server is the authority.
|
|
87
|
+
const isBlob = response.kind === 'file'
|
|
88
|
+
const body = isBlob
|
|
89
|
+
? Buffer.from(response.contentBase64 ?? '', 'base64')
|
|
90
|
+
: serializeEnv(response.entries ?? [])
|
|
91
|
+
const detail = isBlob
|
|
92
|
+
? `(file, ${formatSize(body.length)})`
|
|
93
|
+
: `(${pluralize(response.entries?.length ?? 0, 'key')})`
|
|
94
|
+
|
|
84
95
|
// Re-checked at the sink, not only where the name entered. This is the call
|
|
85
96
|
// that creates directories and writes bytes, so it is the one that has to
|
|
86
97
|
// be certain the path is inside the repo.
|
|
87
98
|
const path = resolveInsideRepo(context.repoRoot, file.name)
|
|
88
99
|
|
|
89
100
|
if (flags['dry-run']) {
|
|
90
|
-
out(
|
|
91
|
-
` ${style.dim('would write')} ${file.name} ${style.dim(`(${pluralize(response.entries?.length ?? 0, 'key')})`)}`
|
|
92
|
-
)
|
|
101
|
+
out(` ${style.dim('would write')} ${file.name} ${style.dim(detail)}`)
|
|
93
102
|
continue
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
mkdirSync(dirname(path), { recursive: true })
|
|
97
|
-
// 0600: a pulled
|
|
106
|
+
// 0600: a pulled secret holds live credentials and has no business being
|
|
98
107
|
// readable by other accounts on a shared machine.
|
|
99
108
|
writeFileSync(path, body, { mode: 0o600 })
|
|
100
109
|
written += 1
|
|
101
110
|
|
|
102
|
-
out(
|
|
103
|
-
` ${style.green('✓')} ${file.name} ${style.dim(`(${pluralize(response.entries?.length ?? 0, 'key')})`)}`
|
|
104
|
-
)
|
|
111
|
+
out(` ${style.green('✓')} ${file.name} ${style.dim(detail)}`)
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
out()
|
package/src/commands/push.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { api, ApiError } from '../api.mjs'
|
|
2
|
-
import {
|
|
2
|
+
import { formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
|
|
3
|
+
import { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
|
|
3
4
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
4
5
|
import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
5
6
|
|
|
@@ -22,11 +23,11 @@ export async function push(flags, args) {
|
|
|
22
23
|
requested: args,
|
|
23
24
|
})
|
|
24
25
|
|
|
25
|
-
const localFiles = loadLocalFiles(context.repoRoot, names)
|
|
26
|
+
const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
|
|
26
27
|
const presentNames = names.filter(name => localFiles.get(name) !== null)
|
|
27
28
|
|
|
28
29
|
if (presentNames.length === 0) {
|
|
29
|
-
throw new CliError('No local
|
|
30
|
+
throw new CliError('No local files to push.', {
|
|
30
31
|
hint: 'Create a .env file, or name one explicitly: `envs push .env.local`',
|
|
31
32
|
})
|
|
32
33
|
}
|
|
@@ -41,13 +42,17 @@ export async function push(flags, args) {
|
|
|
41
42
|
let pushed = 0
|
|
42
43
|
|
|
43
44
|
for (const file of comparison) {
|
|
44
|
-
const entries = localFiles.get(file.name) ?? []
|
|
45
|
-
|
|
46
45
|
if (file.state === FILE_STATE.IN_SYNC && !flags.force) {
|
|
47
46
|
out(` ${style.dim('=')} ${file.name} ${style.dim('already up to date')}`)
|
|
48
47
|
continue
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
if (file.kind === 'file') {
|
|
51
|
+
pushed += await pushOneBlob(flags, context, file, localFiles.get(file.name))
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const entries = localFiles.get(file.name) ?? []
|
|
51
56
|
const willDelete = flags.prune ? file.counts.cloud_only : 0
|
|
52
57
|
|
|
53
58
|
if (flags['dry-run']) {
|
|
@@ -110,6 +115,47 @@ export async function push(flags, args) {
|
|
|
110
115
|
return 0
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Push one opaque file. Whole-file semantics: no prune, no per-key counts —
|
|
120
|
+
* the report is created / updated / unchanged, with the size, because "what
|
|
121
|
+
* went up" for a blob is the file itself.
|
|
122
|
+
*/
|
|
123
|
+
async function pushOneBlob(flags, context, file, local) {
|
|
124
|
+
if (local.bytes.length > MAX_BLOB_BYTES) {
|
|
125
|
+
throw new CliError(
|
|
126
|
+
`${file.name} is ${formatSize(local.bytes.length)} — files are capped at ${formatSize(MAX_BLOB_BYTES)}.`,
|
|
127
|
+
{ hint: 'A stashed file should be a key or a cert, not an archive.' }
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (flags['dry-run']) {
|
|
132
|
+
out(
|
|
133
|
+
` ${style.dim('would push')} ${file.name} ${style.dim(`(file, ${formatSize(local.bytes.length)})`)}`
|
|
134
|
+
)
|
|
135
|
+
return 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = await api.putBlob(
|
|
139
|
+
context.host,
|
|
140
|
+
context.token,
|
|
141
|
+
context.project.name,
|
|
142
|
+
file.name,
|
|
143
|
+
local.bytes.toString('base64')
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const note = result.created
|
|
147
|
+
? style.green('new file')
|
|
148
|
+
: result.changed
|
|
149
|
+
? style.yellow('updated')
|
|
150
|
+
: style.dim('no changes')
|
|
151
|
+
|
|
152
|
+
out(
|
|
153
|
+
` ${style.green('✓')} ${file.name} ${note}${style.dim(`, ${formatSize(local.bytes.length)}`)}`
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return 1
|
|
157
|
+
}
|
|
158
|
+
|
|
113
159
|
async function fetchCloudFiles(context) {
|
|
114
160
|
try {
|
|
115
161
|
const response = await api.projectStatus(context.host, context.token, context.project.name)
|
package/src/commands/status.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { api, ApiError } from '../api.mjs'
|
|
2
|
-
import {
|
|
2
|
+
import { formatSize } from '../blob.mjs'
|
|
3
|
+
import { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
|
|
3
4
|
import {
|
|
4
5
|
compareProject,
|
|
5
6
|
driftExitCode,
|
|
@@ -34,7 +35,7 @@ export async function status(flags, args) {
|
|
|
34
35
|
requested: args,
|
|
35
36
|
})
|
|
36
37
|
|
|
37
|
-
const localFiles = loadLocalFiles(context.repoRoot, names)
|
|
38
|
+
const localFiles = loadLocalFiles(context.repoRoot, names, cloudKinds(cloudFiles))
|
|
38
39
|
const files = compareProject({
|
|
39
40
|
cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
|
|
40
41
|
localFiles,
|
|
@@ -80,7 +81,7 @@ function render(context, files) {
|
|
|
80
81
|
out()
|
|
81
82
|
|
|
82
83
|
if (files.length === 0) {
|
|
83
|
-
out(` ${style.dim('No
|
|
84
|
+
out(` ${style.dim('No files found locally or in the cloud.')}`)
|
|
84
85
|
out()
|
|
85
86
|
out(` ${style.dim('Create some, then run')} envs push`)
|
|
86
87
|
out()
|
|
@@ -119,6 +120,24 @@ function render(context, files) {
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
function fileSummary(file) {
|
|
123
|
+
if (file.kind === 'file') {
|
|
124
|
+
const size = file.size === undefined ? 'file' : `file, ${formatSize(file.size)}`
|
|
125
|
+
|
|
126
|
+
if (file.state === FILE_STATE.CLOUD_ONLY) {
|
|
127
|
+
return `${style.red('cloud only')} ${style.dim(`— ${size}, not on disk`)}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (file.state === FILE_STATE.UNTRACKED) {
|
|
131
|
+
return `${style.green('untracked')} ${style.dim(`— ${size}, not in the cloud`)}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (file.state === FILE_STATE.IN_SYNC) {
|
|
135
|
+
return style.dim(`up to date (${size})`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return style.yellow('local file differs')
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
if (file.state === FILE_STATE.CLOUD_ONLY) {
|
|
123
142
|
return `${style.red('cloud only')} ${style.dim(`— ${pluralize(file.counts.cloud_only, 'key')}, not on disk`)}`
|
|
124
143
|
}
|
package/src/context.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
+
import { isEnvName } from './blob.mjs'
|
|
3
4
|
import { parseEnvFile } from './env-file.mjs'
|
|
4
|
-
import {
|
|
5
|
+
import { discoverTrackedFiles, findRepoRoot, readLink, resolveProject } from './project.mjs'
|
|
5
6
|
import { resolveInsideRepo } from './repo-path.mjs'
|
|
6
7
|
import { requireHostToken } from './session.mjs'
|
|
7
8
|
import { errOut, pluralize, style } from './ui.mjs'
|
|
@@ -25,7 +26,8 @@ export function resolveContext(flags) {
|
|
|
25
26
|
* Explicit arguments win. Otherwise the link file's list is authoritative if
|
|
26
27
|
* present — an explicit list is the only way to include a nested path like
|
|
27
28
|
* `apps/web/.env.local`, which discovery does not scan for. With no link file,
|
|
28
|
-
* fall back to the union of
|
|
29
|
+
* fall back to the union of what discovery finds — root `.env*` files plus git-ignored
|
|
30
|
+
* files in `.config/` — and whatever the cloud holds.
|
|
29
31
|
*/
|
|
30
32
|
export function resolveFileNames({ repoRoot, link, cloudFiles, requested }) {
|
|
31
33
|
if (requested?.length) {
|
|
@@ -36,7 +38,7 @@ export function resolveFileNames({ repoRoot, link, cloudFiles, requested }) {
|
|
|
36
38
|
return check([...new Set(link.files)].sort())
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
const names = new Set([...
|
|
41
|
+
const names = new Set([...discoverTrackedFiles(repoRoot), ...cloudFiles.map(file => file.name)])
|
|
40
42
|
|
|
41
43
|
return check([...names].sort())
|
|
42
44
|
}
|
|
@@ -61,8 +63,14 @@ function check(names) {
|
|
|
61
63
|
/**
|
|
62
64
|
* Read the named files off disk. A missing file maps to null rather than being
|
|
63
65
|
* skipped, so the comparison can distinguish "not on disk" from "empty".
|
|
66
|
+
*
|
|
67
|
+
* `kinds` carries the cloud's recorded kind per name; a name the cloud has not
|
|
68
|
+
* seen is classified by `isEnvName`. Dotenv files parse into entry arrays; an
|
|
69
|
+
* opaque file reads as raw bytes and maps to `{ kind: 'file', bytes }` — no
|
|
70
|
+
* parsing, no warnings about lines, because a JSON key has no KEY=value lines
|
|
71
|
+
* and that is not a defect.
|
|
64
72
|
*/
|
|
65
|
-
export function loadLocalFiles(repoRoot, names) {
|
|
73
|
+
export function loadLocalFiles(repoRoot, names, kinds = new Map()) {
|
|
66
74
|
const files = new Map()
|
|
67
75
|
|
|
68
76
|
for (const name of names) {
|
|
@@ -75,6 +83,13 @@ export function loadLocalFiles(repoRoot, names) {
|
|
|
75
83
|
continue
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
const kind = kinds.get(name) ?? (isEnvName(name) ? 'env' : 'file')
|
|
87
|
+
|
|
88
|
+
if (kind === 'file') {
|
|
89
|
+
files.set(name, { kind: 'file', bytes: readFileSync(path) })
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
const { entries, skipped } = parseEnvFile(readFileSync(path, 'utf8'))
|
|
79
94
|
|
|
80
95
|
files.set(name, entries)
|
|
@@ -87,6 +102,11 @@ export function loadLocalFiles(repoRoot, names) {
|
|
|
87
102
|
return files
|
|
88
103
|
}
|
|
89
104
|
|
|
105
|
+
/** The cloud's recorded kind per file name, for `loadLocalFiles`. */
|
|
106
|
+
export function cloudKinds(cloudFiles) {
|
|
107
|
+
return new Map(cloudFiles.map(file => [file.name, file.kind ?? 'env']))
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
/**
|
|
91
111
|
* Say out loud that part of a file was not understood.
|
|
92
112
|
*
|
package/src/dev-host.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* mean "dev".
|
|
9
9
|
*/
|
|
10
10
|
import { DEFAULT_HOST } from './config.mjs'
|
|
11
|
+
import { CliError } from './ui.mjs'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The ports `scripts/dev-with-port.mjs` hands out, which are in turn the ports
|
|
@@ -82,3 +83,52 @@ export function isDevMode(flags) {
|
|
|
82
83
|
|
|
83
84
|
/** The host used when dev mode finds nothing — production, the last resort. */
|
|
84
85
|
export const FALLBACK_HOST = DEFAULT_HOST
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The host a `--port` (or `ENVSTASH_PORT`) asked for, or null when neither did.
|
|
89
|
+
*
|
|
90
|
+
* A port with no host can only mean a server on this machine, so it expands to
|
|
91
|
+
* the same `http://localhost:<port>` shape detection produces. It exists because
|
|
92
|
+
* the alternative for every non-login command was spelling out
|
|
93
|
+
* `--host http://localhost:3001` — long enough that people export ENVSTASH_HOST
|
|
94
|
+
* for the shell instead, and then forget it is set when they go back to
|
|
95
|
+
* production.
|
|
96
|
+
*
|
|
97
|
+
* Unlike detection this does not probe: naming a port is the developer being
|
|
98
|
+
* explicit, and second-guessing an explicit answer is how `--host` would behave
|
|
99
|
+
* if it checked too.
|
|
100
|
+
*/
|
|
101
|
+
export function resolvePortHost(flags) {
|
|
102
|
+
// `hasOwn` rather than a truthiness check: `--port` with nothing after it
|
|
103
|
+
// parses to undefined, and that is a mistake worth naming rather than a
|
|
104
|
+
// reason to fall through to the environment.
|
|
105
|
+
const fromFlag = Boolean(flags) && Object.hasOwn(flags, 'port')
|
|
106
|
+
const raw = fromFlag ? flags.port : process.env.ENVSTASH_PORT
|
|
107
|
+
|
|
108
|
+
if (!fromFlag && !raw) {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { host: `http://localhost:${parsePort(raw, fromFlag)}`, source: fromFlag ? 'flag' : 'env' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A port is an integer in 1–65535 and nothing else.
|
|
117
|
+
*
|
|
118
|
+
* Refusing the rest matters more here than it looks: every value that slips
|
|
119
|
+
* through becomes a host, and a host that is quietly wrong fails later, as a
|
|
120
|
+
* connection error against a server the developer never meant to name.
|
|
121
|
+
*/
|
|
122
|
+
function parsePort(value, fromFlag) {
|
|
123
|
+
const text = String(value ?? '').trim()
|
|
124
|
+
const port = Number(text)
|
|
125
|
+
|
|
126
|
+
if (!text || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
127
|
+
throw new CliError(
|
|
128
|
+
`${fromFlag ? '--port' : 'ENVSTASH_PORT'} needs a port number${text ? `, not "${text}"` : ''}.`,
|
|
129
|
+
{ hint: 'For example: --port 3001' }
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return port
|
|
134
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -16,7 +16,10 @@ import { CliError, errOut, out, style } from './ui.mjs'
|
|
|
16
16
|
* Flags that take a value. Everything else is a boolean, which is what lets
|
|
17
17
|
* `--force` and `--project my-app` be parsed without a schema per command.
|
|
18
18
|
*/
|
|
19
|
-
const VALUE_FLAGS = new Set(['host', 'project', 'label'])
|
|
19
|
+
const VALUE_FLAGS = new Set(['host', 'port', 'project', 'label', 'cwd'])
|
|
20
|
+
|
|
21
|
+
/** Git-style `-C <dir>`: the one short flag that takes a value. */
|
|
22
|
+
const SHORT_VALUE_ALIASES = { C: 'cwd' }
|
|
20
23
|
|
|
21
24
|
/** The implementation of each command, by name. */
|
|
22
25
|
const HANDLERS = { login, logout, whoami, init, link, list, status, diff, pull, push }
|
|
@@ -85,6 +88,14 @@ export function parseArgs(argv) {
|
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
if (token.startsWith('-') && token.length > 1) {
|
|
91
|
+
const alias = SHORT_VALUE_ALIASES[token.slice(1)]
|
|
92
|
+
|
|
93
|
+
if (alias) {
|
|
94
|
+
flags[alias] = argv[index + 1]
|
|
95
|
+
index += 2
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
flags[token.slice(1)] = true
|
|
89
100
|
index += 1
|
|
90
101
|
continue
|
|
@@ -136,6 +147,7 @@ export async function run(argv) {
|
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
try {
|
|
150
|
+
changeDirectory(flags)
|
|
139
151
|
const code = await command.run(flags, rest)
|
|
140
152
|
return finish(code ?? 0)
|
|
141
153
|
} catch (error) {
|
|
@@ -166,6 +178,37 @@ export async function run(argv) {
|
|
|
166
178
|
}
|
|
167
179
|
}
|
|
168
180
|
|
|
181
|
+
/**
|
|
182
|
+
* `-C <dir>` / `--cwd <dir>`: run as if launched from that directory.
|
|
183
|
+
*
|
|
184
|
+
* Every command resolves its repo from the working directory, which is wrong by
|
|
185
|
+
* definition when the CLI is run from a different repo — the motivating case is
|
|
186
|
+
* `pnpm run cli:dev`, which must execute from the envstash checkout while
|
|
187
|
+
* answering about some other project. Changing directory before dispatch covers
|
|
188
|
+
* every command at once, exactly like `git -C`.
|
|
189
|
+
*/
|
|
190
|
+
function changeDirectory(flags) {
|
|
191
|
+
if (!('cwd' in flags)) {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const target = flags.cwd
|
|
196
|
+
|
|
197
|
+
if (typeof target !== 'string' || target === '') {
|
|
198
|
+
throw new CliError('The -C / --cwd option needs a directory.', {
|
|
199
|
+
hint: 'Example: envs status -C ~/projects/my-app',
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
process.chdir(target)
|
|
205
|
+
} catch {
|
|
206
|
+
throw new CliError(`Cannot change directory to ${JSON.stringify(target)}.`, {
|
|
207
|
+
hint: 'Check that it exists and is a directory.',
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
169
212
|
/**
|
|
170
213
|
* Set the exit code rather than calling process.exit, so buffered stdout is
|
|
171
214
|
* flushed before the process ends. `process.exit` truncates output when stdout
|
|
@@ -195,13 +238,19 @@ function printHelp(topic) {
|
|
|
195
238
|
|
|
196
239
|
out()
|
|
197
240
|
out(` ${style.bold('Common options')}`)
|
|
241
|
+
out(` -C, --cwd <dir> ${style.dim('Change to this directory before doing anything')}`)
|
|
198
242
|
out(` --project <name> ${style.dim('Target a project other than the linked one')}`)
|
|
199
243
|
out(` --host <url> ${style.dim('Target another EnvStash server')}`)
|
|
244
|
+
out(` --port <n> ${style.dim('Target a dev server on localhost at this port')}`)
|
|
200
245
|
out(` --json ${style.dim('Machine-readable output (status, list, whoami)')}`)
|
|
201
246
|
out(` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff)')}`)
|
|
202
247
|
out(` --dry-run ${style.dim('Show what would happen (pull, push)')}`)
|
|
203
248
|
out(` --dev ${style.dim('Log in to a local dev server if one is running')}`)
|
|
204
249
|
out()
|
|
250
|
+
out(
|
|
251
|
+
` ${style.dim('login always signs in to')} ${DEFAULT_HOST} ${style.dim('unless one of those says otherwise.')}`
|
|
252
|
+
)
|
|
253
|
+
out()
|
|
205
254
|
out(` ${style.bold('Environment')}`)
|
|
206
255
|
|
|
207
256
|
const envPad =
|
|
@@ -265,9 +314,14 @@ const COMMAND_HELP = {
|
|
|
265
314
|
login: [
|
|
266
315
|
'Authorize this machine using a browser device code.',
|
|
267
316
|
'',
|
|
317
|
+
'Signs in to production unless you say otherwise — a previous login to a dev',
|
|
318
|
+
'server does not change where the next one goes.',
|
|
319
|
+
'',
|
|
268
320
|
' envs login',
|
|
269
321
|
' envs login --no-browser Print the URL instead of opening it',
|
|
270
322
|
' envs login --host <url> Sign in to another server',
|
|
323
|
+
' envs login --port 3001 Sign in to a dev server on localhost:3001',
|
|
324
|
+
' envs login --dev Find a local dev server and sign in to it',
|
|
271
325
|
],
|
|
272
326
|
logout: [
|
|
273
327
|
'Revoke this machine’s token and remove it locally.',
|
package/src/manifest.mjs
CHANGED
|
@@ -51,13 +51,17 @@ export const CLI_ALIASES = Object.fromEntries(
|
|
|
51
51
|
export const CLI_ENV_VARS = [
|
|
52
52
|
{ name: 'ENVSTASH_TOKEN', purpose: () => 'Use this token instead of the stored one — for CI' },
|
|
53
53
|
{ name: 'ENVSTASH_HOST', purpose: host => `Target another server (default ${host})` },
|
|
54
|
+
{
|
|
55
|
+
name: 'ENVSTASH_PORT',
|
|
56
|
+
purpose: () => 'Target a dev server on localhost at this port (same as --port)',
|
|
57
|
+
},
|
|
54
58
|
{
|
|
55
59
|
name: 'ENVSTASH_CONFIG_DIR',
|
|
56
60
|
purpose: () => 'Where credentials live (default ~/.envstash)',
|
|
57
61
|
},
|
|
58
62
|
{
|
|
59
63
|
name: 'ENVSTASH_DEV',
|
|
60
|
-
purpose: () => '
|
|
64
|
+
purpose: () => 'Look for a local dev server to log in to (same as --dev)',
|
|
61
65
|
},
|
|
62
66
|
{ name: 'ENVSTASH_DEBUG', purpose: () => 'Print stack traces on failure' },
|
|
63
67
|
{ name: 'NO_COLOR', purpose: () => 'Disable colour (also off automatically when piped)' },
|
package/src/project.mjs
CHANGED
|
@@ -1,13 +1,55 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { basename, dirname, join, resolve } from 'node:path'
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { MAX_BLOB_BYTES } from './blob.mjs'
|
|
6
|
+
import { isSafeRepoPath, keepSafeRepoPaths } from './repo-path.mjs'
|
|
6
7
|
import { CliError } from './ui.mjs'
|
|
7
8
|
|
|
8
9
|
/** The per-repo link file. Safe to commit — it names files, never values. */
|
|
9
10
|
export const LINK_FILE = '.envstash.json'
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Environment variables by which git decides *which repository it is operating on*,
|
|
14
|
+
* overriding both the working directory and `-C`.
|
|
15
|
+
*
|
|
16
|
+
* Git exports these to every hook it runs. The README recommends `envs status` in a
|
|
17
|
+
* pre-commit hook, so this is not a hypothetical: with `GIT_DIR` inherited, a `git`
|
|
18
|
+
* call meant to ask about `repoRoot` silently answers about — or writes to — whatever
|
|
19
|
+
* repository invoked the hook, no matter what `cwd` says.
|
|
20
|
+
*/
|
|
21
|
+
const GIT_LOCATION_VARS = [
|
|
22
|
+
'GIT_DIR',
|
|
23
|
+
'GIT_WORK_TREE',
|
|
24
|
+
'GIT_COMMON_DIR',
|
|
25
|
+
'GIT_INDEX_FILE',
|
|
26
|
+
'GIT_OBJECT_DIRECTORY',
|
|
27
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
28
|
+
'GIT_NAMESPACE',
|
|
29
|
+
'GIT_PREFIX',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run git against `cwd` and mean it.
|
|
34
|
+
*
|
|
35
|
+
* Every git call here is a question about one specific directory, so the inherited
|
|
36
|
+
* location variables are cleared and `cwd` is left as the only thing that decides.
|
|
37
|
+
*/
|
|
38
|
+
function runGit(args, { cwd, input } = {}) {
|
|
39
|
+
const env = { ...process.env }
|
|
40
|
+
for (const name of GIT_LOCATION_VARS) {
|
|
41
|
+
delete env[name]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return execFileSync('git', args, {
|
|
45
|
+
cwd,
|
|
46
|
+
env,
|
|
47
|
+
...(input === undefined ? {} : { input }),
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
stdio: input === undefined ? ['ignore', 'pipe', 'ignore'] : ['pipe', 'pipe', 'ignore'],
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
11
53
|
/**
|
|
12
54
|
* Files that look like `.env` but are meant to be committed and contain no real
|
|
13
55
|
* secrets. Auto-including them would push placeholder values over the top of a
|
|
@@ -90,11 +132,7 @@ export function writeLink(repoRoot, link) {
|
|
|
90
132
|
*/
|
|
91
133
|
export function guessProjectName(repoRoot) {
|
|
92
134
|
try {
|
|
93
|
-
const remote =
|
|
94
|
-
cwd: repoRoot,
|
|
95
|
-
encoding: 'utf8',
|
|
96
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
97
|
-
}).trim()
|
|
135
|
+
const remote = runGit(['config', '--get', 'remote.origin.url'], { cwd: repoRoot }).trim()
|
|
98
136
|
|
|
99
137
|
if (remote) {
|
|
100
138
|
const name = basename(remote.replace(/\.git$/, ''))
|
|
@@ -140,6 +178,113 @@ export function discoverEnvFiles(repoRoot) {
|
|
|
140
178
|
.sort()
|
|
141
179
|
}
|
|
142
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Where credential *files* live, by convention: a service-account JSON, a cert, a
|
|
183
|
+
* private key — the things that are secrets but are not `KEY=value`.
|
|
184
|
+
*/
|
|
185
|
+
export const SECRET_FILE_DIR = '.config'
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Which of `names` git ignores, as a Set.
|
|
189
|
+
*
|
|
190
|
+
* Being ignored is the signal this uses for "secret". It is the repo's own
|
|
191
|
+
* declaration that a file is not for sharing through git, which is exactly the
|
|
192
|
+
* population envstash exists to carry instead — and it means a committed
|
|
193
|
+
* `.config/defaults.json` is left alone rather than uploaded.
|
|
194
|
+
*
|
|
195
|
+
* Any failure yields the empty set, so discovery adds nothing. That is the
|
|
196
|
+
* conservative direction here: over-discovery uploads a file the developer never
|
|
197
|
+
* chose to share, while under-discovery only means they list it by hand, which is
|
|
198
|
+
* what they do today.
|
|
199
|
+
*/
|
|
200
|
+
function gitIgnoredNames(repoRoot, names) {
|
|
201
|
+
if (names.length === 0) {
|
|
202
|
+
return new Set()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const output = runGit(['check-ignore', '--stdin'], { cwd: repoRoot, input: names.join('\n') })
|
|
207
|
+
return new Set(output.split('\n').filter(Boolean))
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// `git check-ignore` exits 1 when nothing matched — a real answer, not a
|
|
210
|
+
// failure, and stdout still holds whatever did match.
|
|
211
|
+
if (error?.status === 1 && typeof error.stdout === 'string') {
|
|
212
|
+
return new Set(error.stdout.split('\n').filter(Boolean))
|
|
213
|
+
}
|
|
214
|
+
return new Set()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Documentation, not credentials. A `.config/` directory often carries a README
|
|
220
|
+
* or notes alongside the keys; pushing those adds noise without protecting a
|
|
221
|
+
* secret.
|
|
222
|
+
*/
|
|
223
|
+
const DOCUMENT_SUFFIXES = [
|
|
224
|
+
'.md',
|
|
225
|
+
'.markdown',
|
|
226
|
+
'.rst',
|
|
227
|
+
'.txt',
|
|
228
|
+
'.rtf',
|
|
229
|
+
'.doc',
|
|
230
|
+
'.docx',
|
|
231
|
+
'.odt',
|
|
232
|
+
'.pdf',
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Discover files in `.config/` — all of them, except documents.
|
|
237
|
+
*
|
|
238
|
+
* The dotenv scan above cannot find these: they are not named `.env*` and they do
|
|
239
|
+
* not live in the repo root. Before this existed, a project with a service-account
|
|
240
|
+
* key had to hand-write it into `.envstash.json` and remember to do so again on the
|
|
241
|
+
* next one.
|
|
242
|
+
*
|
|
243
|
+
* No extension allowlist: a credential does not always look like one (an
|
|
244
|
+
* extensionless keyfile), so when the directory exists, everything in it is
|
|
245
|
+
* offered — recursively, since credential tooling nests (`.config/gcloud/…`) —
|
|
246
|
+
* minus document files, which are never credentials. Git-ignore remains the gate:
|
|
247
|
+
* a file is offered only when it is git-ignored, small enough to push, and has a
|
|
248
|
+
* name the sync commands will accept.
|
|
249
|
+
*/
|
|
250
|
+
export function discoverSecretFiles(repoRoot) {
|
|
251
|
+
const directory = join(repoRoot, SECRET_FILE_DIR)
|
|
252
|
+
let entries
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
entries = readdirSync(directory, { recursive: true })
|
|
256
|
+
} catch {
|
|
257
|
+
return []
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const candidates = entries
|
|
261
|
+
// Nested entries come back joined with the platform separator; names are POSIX.
|
|
262
|
+
.map(entry => `${SECRET_FILE_DIR}/${entry.split(sep).join('/')}`)
|
|
263
|
+
.filter(name => !DOCUMENT_SUFFIXES.some(suffix => name.toLowerCase().endsWith(suffix)))
|
|
264
|
+
// The grammar the server and `resolveInsideRepo` both enforce. Discovering a
|
|
265
|
+
// name they would refuse produces a link file that cannot be pushed.
|
|
266
|
+
.filter(name => isSafeRepoPath(name))
|
|
267
|
+
.filter(name => {
|
|
268
|
+
try {
|
|
269
|
+
const stats = statSync(join(repoRoot, name))
|
|
270
|
+
// Skipping oversized files here means `envs init` never lists something
|
|
271
|
+
// whose first push would fail.
|
|
272
|
+
return stats.isFile() && stats.size <= MAX_BLOB_BYTES
|
|
273
|
+
} catch {
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const ignored = gitIgnoredNames(repoRoot, candidates)
|
|
279
|
+
|
|
280
|
+
return candidates.filter(name => ignored.has(name)).sort()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Everything auto-discovery offers to track: root dotenv files, then `.config/` files. */
|
|
284
|
+
export function discoverTrackedFiles(repoRoot) {
|
|
285
|
+
return [...discoverEnvFiles(repoRoot), ...discoverSecretFiles(repoRoot)]
|
|
286
|
+
}
|
|
287
|
+
|
|
143
288
|
/**
|
|
144
289
|
* Resolve which project this invocation targets: `--project` wins, then the link
|
|
145
290
|
* file, then the repo-name guess.
|
package/src/session.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { requireToken, resolveHost } from './config.mjs'
|
|
2
|
-
import { detectDevHost, isDevMode } from './dev-host.mjs'
|
|
1
|
+
import { DEFAULT_HOST, requireToken, resolveHost } from './config.mjs'
|
|
2
|
+
import { detectDevHost, isDevMode, resolvePortHost } from './dev-host.mjs'
|
|
3
3
|
import { findRepoRoot, readLink } from './project.mjs'
|
|
4
|
+
import { CliError } from './ui.mjs'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Resolve host and token together.
|
|
@@ -18,6 +19,39 @@ export function requireHostToken(flags) {
|
|
|
18
19
|
return { host, token: requireToken(host, { allowEnvToken: source !== 'link' }) }
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The host the developer named for this command, or null when they named none.
|
|
24
|
+
*
|
|
25
|
+
* The ladder is the usual one — what you typed beats what your shell exported —
|
|
26
|
+
* with `--port` sitting beside `--host` at each rung, because they are two ways
|
|
27
|
+
* of saying the same thing. Passing both is therefore a contradiction rather
|
|
28
|
+
* than a precedence question, and it is refused: silently picking one is how a
|
|
29
|
+
* command ends up talking to a server the developer explicitly excluded.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveExplicitHost(flags) {
|
|
32
|
+
const port = resolvePortHost(flags)
|
|
33
|
+
|
|
34
|
+
if (flags?.host && port?.source === 'flag') {
|
|
35
|
+
throw new CliError(`--host and --port both name a server: ${flags.host} and ${port.host}.`, {
|
|
36
|
+
hint: 'Pass one of them.',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (flags?.host) {
|
|
41
|
+
return { host: resolveHost(flags.host), source: 'flag' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (port?.source === 'flag') {
|
|
45
|
+
return port
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (process.env.ENVSTASH_HOST) {
|
|
49
|
+
return { host: resolveHost(undefined), source: 'env' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return port
|
|
53
|
+
}
|
|
54
|
+
|
|
21
55
|
/**
|
|
22
56
|
* Resolve the host and say where the decision came from.
|
|
23
57
|
*
|
|
@@ -27,8 +61,10 @@ export function requireHostToken(flags) {
|
|
|
27
61
|
* less trust.
|
|
28
62
|
*/
|
|
29
63
|
export function resolveHostSource(flags) {
|
|
30
|
-
|
|
31
|
-
|
|
64
|
+
const explicit = resolveExplicitHost(flags)
|
|
65
|
+
|
|
66
|
+
if (explicit) {
|
|
67
|
+
return explicit
|
|
32
68
|
}
|
|
33
69
|
|
|
34
70
|
try {
|
|
@@ -64,18 +100,28 @@ export function isLinkChosenHost(flags, host) {
|
|
|
64
100
|
/**
|
|
65
101
|
* Host for an interactive login.
|
|
66
102
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* production
|
|
103
|
+
* Login does not inherit the stored default the way every other command does,
|
|
104
|
+
* and that is the point: the stored default is wherever the *last* login went,
|
|
105
|
+
* so one `pnpm cli:dev login` used to make every later `pnpm envs login` sign in
|
|
106
|
+
* to localhost — which looks like it worked, right up to the point where a
|
|
107
|
+
* production command finds no credential. Production is the default here, and
|
|
108
|
+
* reaching a dev server is something you ask for.
|
|
109
|
+
*
|
|
110
|
+
* Above that: in dev mode a running local server beats production, so
|
|
111
|
+
* `pnpm cli:dev login` opens the server the developer is running. An explicit
|
|
112
|
+
* `--host`, `--port`, or `ENVSTASH_HOST` wins over detection — asking for a
|
|
113
|
+
* specific server should never be second-guessed. A host this repo's link file
|
|
114
|
+
* pinned still applies, since that is a deliberate per-repo choice, and
|
|
115
|
+
* `login` already refuses one it has never signed in to.
|
|
72
116
|
*
|
|
73
117
|
* `onDevHost` reports what detection found so the command can say so. Deciding
|
|
74
118
|
* and printing are split because the decision is what the tests care about.
|
|
75
119
|
*/
|
|
76
120
|
export async function resolveLoginHost(flags, { onDevHost } = {}) {
|
|
77
|
-
|
|
78
|
-
|
|
121
|
+
const explicit = resolveExplicitHost(flags)
|
|
122
|
+
|
|
123
|
+
if (explicit) {
|
|
124
|
+
return explicit.host
|
|
79
125
|
}
|
|
80
126
|
|
|
81
127
|
if (isDevMode(flags)) {
|
|
@@ -88,5 +134,7 @@ export async function resolveLoginHost(flags, { onDevHost } = {}) {
|
|
|
88
134
|
}
|
|
89
135
|
}
|
|
90
136
|
|
|
91
|
-
|
|
137
|
+
const resolved = resolveHostSource(flags)
|
|
138
|
+
|
|
139
|
+
return resolved.source === 'link' ? resolved.host : DEFAULT_HOST
|
|
92
140
|
}
|
package/src/status.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { digestFileBytes } from './blob.mjs'
|
|
1
2
|
import { digestValue } from './env-file.mjs'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -64,7 +65,48 @@ export function compareFile(name, localEntries, cloudKeys) {
|
|
|
64
65
|
|
|
65
66
|
const counts = countStates(keys)
|
|
66
67
|
|
|
67
|
-
return { name, state: fileState(localEntries, cloudKeys, counts), keys, counts }
|
|
68
|
+
return { name, kind: 'env', state: fileState(localEntries, cloudKeys, counts), keys, counts }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compare one opaque file. The whole file is the unit, so the counts record it
|
|
73
|
+
* as a single entry — which keeps `summarize` and the drift exit code honest
|
|
74
|
+
* without a special case: one drifted blob counts exactly one drifted thing.
|
|
75
|
+
*
|
|
76
|
+
* `local` is `{ kind: 'file', bytes }` or null; `cloud` is the status entry
|
|
77
|
+
* (with `digest` and `size`) or null.
|
|
78
|
+
*/
|
|
79
|
+
export function compareBlobFile(name, local, cloud) {
|
|
80
|
+
if (local === null && !cloud) {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const localDigest = local ? digestFileBytes(local.bytes) : null
|
|
85
|
+
const counts = { same: 0, modified: 0, local_only: 0, cloud_only: 0 }
|
|
86
|
+
let state
|
|
87
|
+
|
|
88
|
+
if (!cloud) {
|
|
89
|
+
state = FILE_STATE.UNTRACKED
|
|
90
|
+
counts.local_only = 1
|
|
91
|
+
} else if (local === null) {
|
|
92
|
+
state = FILE_STATE.CLOUD_ONLY
|
|
93
|
+
counts.cloud_only = 1
|
|
94
|
+
} else if (localDigest === cloud.digest) {
|
|
95
|
+
state = FILE_STATE.IN_SYNC
|
|
96
|
+
counts.same = 1
|
|
97
|
+
} else {
|
|
98
|
+
state = FILE_STATE.DIFFERS
|
|
99
|
+
counts.modified = 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
kind: 'file',
|
|
105
|
+
state,
|
|
106
|
+
keys: [],
|
|
107
|
+
counts,
|
|
108
|
+
size: local?.bytes.length ?? cloud?.size,
|
|
109
|
+
}
|
|
68
110
|
}
|
|
69
111
|
|
|
70
112
|
function countStates(keys) {
|
|
@@ -105,7 +147,17 @@ export function compareProject({ cloudFiles, localFiles }) {
|
|
|
105
147
|
|
|
106
148
|
for (const name of [...names].sort()) {
|
|
107
149
|
const cloud = cloudFiles.find(file => file.name === name)
|
|
108
|
-
const
|
|
150
|
+
const local = localFiles.get(name) ?? null
|
|
151
|
+
|
|
152
|
+
// The cloud's recorded kind wins; a file it has never seen is classified by
|
|
153
|
+
// how `loadLocalFiles` read it. The two agree by construction — local blobs
|
|
154
|
+
// are only produced for names the cloud calls 'file' or names not in the
|
|
155
|
+
// cloud that fail the dotenv naming test.
|
|
156
|
+
const isBlob = cloud?.kind === 'file' || (local !== null && local.kind === 'file')
|
|
157
|
+
|
|
158
|
+
const comparison = isBlob
|
|
159
|
+
? compareBlobFile(name, local, cloud ?? null)
|
|
160
|
+
: compareFile(name, local, cloud?.keys ?? null)
|
|
109
161
|
|
|
110
162
|
if (comparison) {
|
|
111
163
|
files.push(comparison)
|