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 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. Files ending in `.example`,
129
- `.sample`, `.template`, and `.dist` are ignored, since those are meant to be
130
- committed and hold placeholder values.
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` | Log in to a local dev server when one is running |
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
- ### Logging in to a local server
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
- `envs login --dev` (or `ENVSTASH_DEV=1`) looks for an EnvStash dev server on
163
- ports 3000, 3001, 3002 and 3003 and logs in to the first one that answers, so the
164
- link it opens points at the server you are running rather than production.
165
- Production is the fallback when none answer, and an explicit `--host` always
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 unrelated
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Git-style CLI for EnvStash — keep your .env files in sync with your cloud account.",
5
5
  "keywords": [
6
6
  "env",
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(host, path, { method = 'GET', token, body, signal } = {}) {
22
- const headers = { accept: 'application/json' }
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
+ }
@@ -1,5 +1,6 @@
1
1
  import { api, ApiError } from '../api.mjs'
2
- import { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
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
 
@@ -1,6 +1,12 @@
1
1
  import { api } from '../api.mjs'
2
2
  import { getStoredEmail } from '../config.mjs'
3
- import { discoverEnvFiles, findRepoRoot, readLink, resolveProject, writeLink } from '../project.mjs'
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 : discoverEnvFiles(repoRoot)
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 : discoverEnvFiles(repoRoot)
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()
@@ -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 { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
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
- const body = serializeEnv(response.entries ?? [])
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 .env holds live credentials and has no business being
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()
@@ -1,5 +1,6 @@
1
1
  import { api, ApiError } from '../api.mjs'
2
- import { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
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 env files to push.', {
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)
@@ -1,5 +1,6 @@
1
1
  import { api, ApiError } from '../api.mjs'
2
- import { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
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 env files found locally or in the cloud.')}`)
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 { discoverEnvFiles, findRepoRoot, readLink, resolveProject } from './project.mjs'
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 local `.env*` files and whatever the cloud holds.
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([...discoverEnvFiles(repoRoot), ...cloudFiles.map(file => file.name)])
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: () => 'Log in to a local dev server when one is running (same as --dev)',
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 { keepSafeRepoPaths } from './repo-path.mjs'
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 = execFileSync('git', ['config', '--get', 'remote.origin.url'], {
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
- if (flags?.host || process.env.ENVSTASH_HOST) {
31
- return { host: resolveHost(flags?.host), source: flags?.host ? 'flag' : 'env' }
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
- * The usual precedence with one step inserted: in dev mode a running local
68
- * server beats the stored default, so `pnpm cli:dev login` opens the server the
69
- * developer is running. An explicit `--host` or `ENVSTASH_HOST` still wins over
70
- * detection — asking for a specific server should never be second-guessed — and
71
- * production remains the last resort when no dev server answers.
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
- if (flags?.host || process.env.ENVSTASH_HOST) {
78
- return resolveHost(flags?.host)
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
- return resolveHostFor(flags)
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 comparison = compareFile(name, localFiles.get(name) ?? null, cloud?.keys ?? null)
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)