envstash-cli 0.2.0 → 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,6 +174,27 @@ 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.2.0",
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/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', 'port', '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,6 +238,7 @@ 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')}`)
200
244
  out(` --port <n> ${style.dim('Target a dev server on localhost at this port')}`)
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/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)