envstash-cli 0.2.0 → 0.4.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
@@ -26,21 +26,28 @@ envs pull
26
26
 
27
27
  ## Commands
28
28
 
29
- | Command | What it does |
30
- | -------- | ------------------------------------------------- |
31
- | `login` | Authorize this machine with a browser device code |
32
- | `logout` | Revoke this machine's token |
33
- | `whoami` | Show the signed-in account |
34
- | `init` | Create a project and link this repo to it |
35
- | `link` | Link this repo to a project that already exists |
36
- | `list` | List every project in your account |
37
- | `status` | Compare cloud files against local files |
38
- | `diff` | Show which keys differ, and how |
39
- | `pull` | Write the cloud's version of each file to disk |
40
- | `push` | Send local files to the cloud |
29
+ | Command | What it does |
30
+ | --------- | ------------------------------------------------- |
31
+ | `login` | Authorize this machine with a browser device code |
32
+ | `logout` | Revoke this machine's token |
33
+ | `whoami` | Show the signed-in account |
34
+ | `init` | Create a project and link this repo to it |
35
+ | `link` | Link this repo to a project that already exists |
36
+ | `list` | List every project in your account |
37
+ | `status` | Compare cloud files against local files |
38
+ | `diff` | Show which keys differ, and how |
39
+ | `pull` | Write the cloud's version of each file to disk |
40
+ | `push` | Send local files to the cloud |
41
+ | `version` | Show the CLI version and its environment |
41
42
 
42
43
  Run `envs help <command>` for details on any one of them.
43
44
 
45
+ Every command accepts `-C <dir>` (or `--cwd <dir>`) to change to that directory
46
+ first, like `git -C` — the repo, link file, and relative file arguments all
47
+ resolve from there. Handy when the CLI itself must run from elsewhere, e.g.
48
+ debugging with `pnpm run cli:dev status -C ~/projects/my-app` from the
49
+ envstash checkout.
50
+
44
51
  ## `envs status`
45
52
 
46
53
  Checks every file in your cloud account against what is on disk.
@@ -125,9 +132,42 @@ files to track — and it is what lets a teammate clone the repo and run
125
132
  `envs pull` with no further setup.
126
133
 
127
134
  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.
135
+ name and discovers `.env*` files in the repo root, plus files in `.config/`
136
+ (see below). Dotenv files ending in `.example`, `.sample`, `.template`, and
137
+ `.dist` are ignored, since those are meant to be committed and hold placeholder
138
+ values.
139
+
140
+ ### Files in `.config/`
141
+
142
+ A service-account key or a cert is a secret that is not `KEY=value`, and it does
143
+ not live in the repo root — so the dotenv scan cannot find it. When a `.config/`
144
+ directory exists, discovery offers **everything in it**, subdirectories
145
+ included — no extension allowlist, because a credential does not always look
146
+ like one (an extensionless keyfile). A file is offered when **all** of these
147
+ hold:
148
+
149
+ - git ignores it,
150
+ - it is not a document (`.md`, `.txt`, `.doc`, `.pdf`, and the like — notes,
151
+ not credentials),
152
+ - it is under the 1 MB blob limit, and
153
+ - its name is one the sync commands accept (`[A-Za-z0-9._-]` per segment).
154
+
155
+ Being git-ignored is the signal for "secret": it is the repo's own statement
156
+ that a file is not for sharing through git, which is exactly the population
157
+ envstash exists to carry instead. A committed `.config/defaults.json` is left
158
+ alone.
159
+
160
+ Every git call the CLI makes clears `GIT_DIR`, `GIT_WORK_TREE` and the other
161
+ location variables first. Git exports those to each hook it runs, and they
162
+ override the working directory — so without clearing them, `envs status` in a
163
+ pre-commit hook would answer about whichever repository invoked the hook rather
164
+ than the one you are standing in.
165
+
166
+ Outside a git repository, or if `git` is not installed, discovery adds nothing
167
+ from `.config/` rather than guessing — over-discovery would upload a file nobody
168
+ chose to share, while under-discovery just means listing it in `.envstash.json`
169
+ by hand. The scan covers `.config/` and everything under it; files anywhere else
170
+ are declared explicitly.
131
171
 
132
172
  Monorepos can track nested paths by listing them explicitly:
133
173
 
@@ -135,6 +175,27 @@ Monorepos can track nested paths by listing them explicitly:
135
175
  { "project": "my-monorepo", "files": ["apps/web/.env.local", "apps/api/.env"] }
136
176
  ```
137
177
 
178
+ ## Opaque files (service-account keys, certs)
179
+
180
+ A tracked name whose basename is not `.env` or `.env.*` is stored **whole**, as
181
+ one encrypted file, rather than parsed into keys:
182
+
183
+ ```json
184
+ { "project": "demo-app", "files": [".env", ".config/service-account.json"] }
185
+ ```
186
+
187
+ Every command treats it as a single unit. `status` compares a digest of the raw
188
+ bytes (never the bytes themselves), `push` sends the file when that digest
189
+ changed, `pull` writes it back byte-for-byte with mode `0600`, and `diff`
190
+ reports _that_ it changed but never prints the contents — `--show-values`
191
+ included, because binary in a terminal helps no one. Opaque files are capped at
192
+ 1 MB: a stashed file should be a key or a cert, not an archive.
193
+
194
+ Opaque files in `.config/` are auto-discovered (see above); one anywhere else
195
+ enters only by being listed in `.envstash.json` or named as an argument:
196
+ `envs push certs/server.pem`. For a name the cloud already knows, its recorded
197
+ kind wins — a file cannot silently change identity between dotenv and opaque.
198
+
138
199
  ## Configuration
139
200
 
140
201
  | 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.4.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,9 +1,80 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
1
5
  import { api } from '../api.mjs'
2
- import { getStoredEmail } from '../config.mjs'
3
- import { discoverEnvFiles, findRepoRoot, readLink, resolveProject, writeLink } from '../project.mjs'
4
- import { requireHostToken, resolveHostFor } from '../session.mjs'
6
+ import { configDir, getStoredEmail } from '../config.mjs'
7
+ import {
8
+ discoverTrackedFiles,
9
+ findRepoRoot,
10
+ readLink,
11
+ resolveProject,
12
+ writeLink,
13
+ } from '../project.mjs'
14
+ import { requireHostToken, resolveHostFor, resolveHostSource } from '../session.mjs'
5
15
  import { out, pad, pluralize, style } from '../ui.mjs'
6
16
 
17
+ /**
18
+ * `envs version` — the version, and the environment it would act in.
19
+ *
20
+ * `--version` and `-v` stay a bare semver for scripts to parse; this command is
21
+ * the human-facing one. The host line goes through the same resolution the sync
22
+ * commands use, so "which server would this talk to" is answered by the same
23
+ * code that would talk to it.
24
+ */
25
+ export async function version(flags) {
26
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
27
+
28
+ let manifest = {}
29
+ try {
30
+ manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
31
+ } catch {
32
+ // A broken install still deserves an answer; every field below has a fallback.
33
+ }
34
+
35
+ const { host, source } = resolveHostSource(flags)
36
+ const details = {
37
+ name: manifest.name ?? 'envstash-cli',
38
+ version: manifest.version ?? '0.0.0',
39
+ node: process.version,
40
+ platform: `${process.platform} ${process.arch}`,
41
+ installed: packageRoot,
42
+ host,
43
+ hostSource: source,
44
+ configDir: configDir(),
45
+ }
46
+
47
+ if (flags.json) {
48
+ out(JSON.stringify(details, null, 2))
49
+ return 0
50
+ }
51
+
52
+ const width = 12
53
+
54
+ out()
55
+ out(` ${style.bold(details.name)} ${style.dim(`v${details.version}`)}`)
56
+ out()
57
+ out(` ${pad('node', width)}${style.dim(details.node)}`)
58
+ out(` ${pad('platform', width)}${style.dim(details.platform)}`)
59
+ out(` ${pad('installed', width)}${style.dim(details.installed)}`)
60
+ out(` ${pad('host', width)}${style.dim(`${details.host} (${sourceLabel(source)})`)}`)
61
+ out(` ${pad('config', width)}${style.dim(details.configDir)}`)
62
+ out()
63
+
64
+ return 0
65
+ }
66
+
67
+ function sourceLabel(source) {
68
+ return (
69
+ {
70
+ flag: 'from --host or --port',
71
+ env: 'from the environment',
72
+ link: 'from .envstash.json',
73
+ default: 'default',
74
+ }[source] ?? source
75
+ )
76
+ }
77
+
7
78
  /** `envs whoami` — which account this machine is signed in as. */
8
79
  export async function whoami(flags) {
9
80
  const { host, token } = requireHostToken(flags)
@@ -69,7 +140,7 @@ export async function init(flags, args) {
69
140
 
70
141
  await api.createProject(host, token, name)
71
142
 
72
- const files = existing?.files?.length ? existing.files : discoverEnvFiles(repoRoot)
143
+ const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
73
144
  const path = writeLink(repoRoot, {
74
145
  project: name,
75
146
  files,
@@ -130,7 +201,7 @@ export async function link(flags, args) {
130
201
  // that points at nothing.
131
202
  await api.projectStatus(host, token, name)
132
203
 
133
- const files = existing?.files?.length ? existing.files : discoverEnvFiles(repoRoot)
204
+ const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
134
205
  const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
135
206
 
136
207
  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
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
 
5
5
  import { diff } from './commands/diff.mjs'
6
6
  import { login, logout } from './commands/login.mjs'
7
- import { init, link, list, whoami } from './commands/misc.mjs'
7
+ import { init, link, list, version as versionCommand, whoami } from './commands/misc.mjs'
8
8
  import { pull } from './commands/pull.mjs'
9
9
  import { push } from './commands/push.mjs'
10
10
  import { status } from './commands/status.mjs'
@@ -16,10 +16,25 @@ 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
- const HANDLERS = { login, logout, whoami, init, link, list, status, diff, pull, push }
25
+ const HANDLERS = {
26
+ login,
27
+ logout,
28
+ whoami,
29
+ init,
30
+ link,
31
+ list,
32
+ status,
33
+ diff,
34
+ pull,
35
+ push,
36
+ version: versionCommand,
37
+ }
23
38
 
24
39
  /**
25
40
  * Summaries and aliases come from the manifest rather than being repeated here,
@@ -85,6 +100,14 @@ export function parseArgs(argv) {
85
100
  }
86
101
 
87
102
  if (token.startsWith('-') && token.length > 1) {
103
+ const alias = SHORT_VALUE_ALIASES[token.slice(1)]
104
+
105
+ if (alias) {
106
+ flags[alias] = argv[index + 1]
107
+ index += 2
108
+ continue
109
+ }
110
+
88
111
  flags[token.slice(1)] = true
89
112
  index += 1
90
113
  continue
@@ -116,7 +139,9 @@ export async function run(argv) {
116
139
  const { flags, args } = parseArgs(argv)
117
140
  const [name, ...rest] = args
118
141
 
119
- if (flags.version || flags.v || name === 'version') {
142
+ // Bare semver only, for scripts to parse. `envs version` — the command — is
143
+ // the detailed, human-facing answer.
144
+ if (flags.version || flags.v) {
120
145
  out(version())
121
146
  return finish(0)
122
147
  }
@@ -136,6 +161,7 @@ export async function run(argv) {
136
161
  }
137
162
 
138
163
  try {
164
+ changeDirectory(flags)
139
165
  const code = await command.run(flags, rest)
140
166
  return finish(code ?? 0)
141
167
  } catch (error) {
@@ -166,6 +192,37 @@ export async function run(argv) {
166
192
  }
167
193
  }
168
194
 
195
+ /**
196
+ * `-C <dir>` / `--cwd <dir>`: run as if launched from that directory.
197
+ *
198
+ * Every command resolves its repo from the working directory, which is wrong by
199
+ * definition when the CLI is run from a different repo — the motivating case is
200
+ * `pnpm run cli:dev`, which must execute from the envstash checkout while
201
+ * answering about some other project. Changing directory before dispatch covers
202
+ * every command at once, exactly like `git -C`.
203
+ */
204
+ function changeDirectory(flags) {
205
+ if (!('cwd' in flags)) {
206
+ return
207
+ }
208
+
209
+ const target = flags.cwd
210
+
211
+ if (typeof target !== 'string' || target === '') {
212
+ throw new CliError('The -C / --cwd option needs a directory.', {
213
+ hint: 'Example: envs status -C ~/projects/my-app',
214
+ })
215
+ }
216
+
217
+ try {
218
+ process.chdir(target)
219
+ } catch {
220
+ throw new CliError(`Cannot change directory to ${JSON.stringify(target)}.`, {
221
+ hint: 'Check that it exists and is a directory.',
222
+ })
223
+ }
224
+ }
225
+
169
226
  /**
170
227
  * Set the exit code rather than calling process.exit, so buffered stdout is
171
228
  * flushed before the process ends. `process.exit` truncates output when stdout
@@ -195,10 +252,13 @@ function printHelp(topic) {
195
252
 
196
253
  out()
197
254
  out(` ${style.bold('Common options')}`)
255
+ out(` -C, --cwd <dir> ${style.dim('Change to this directory before doing anything')}`)
198
256
  out(` --project <name> ${style.dim('Target a project other than the linked one')}`)
199
257
  out(` --host <url> ${style.dim('Target another EnvStash server')}`)
200
258
  out(` --port <n> ${style.dim('Target a dev server on localhost at this port')}`)
201
- out(` --json ${style.dim('Machine-readable output (status, list, whoami)')}`)
259
+ out(
260
+ ` --json ${style.dim('Machine-readable output (status, list, whoami, version)')}`
261
+ )
202
262
  out(` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff)')}`)
203
263
  out(` --dry-run ${style.dim('Show what would happen (pull, push)')}`)
204
264
  out(` --dev ${style.dim('Log in to a local dev server if one is running')}`)
@@ -294,6 +354,13 @@ const COMMAND_HELP = {
294
354
  link: ['Link this repo to a project that already exists.', '', ' envs link my-app'],
295
355
  list: ['List every project in your account.', '', ' envs list', ' envs list --json'],
296
356
  whoami: ['Show which account this machine is signed in as.', '', ' envs whoami'],
357
+ version: [
358
+ 'Show the version, runtime, install path, and which server commands would',
359
+ 'talk to. --version and -v print the bare version for scripts.',
360
+ '',
361
+ ' envs version',
362
+ ' envs version --json',
363
+ ],
297
364
  }
298
365
 
299
366
  function printCommandHelp(name) {
package/src/manifest.mjs CHANGED
@@ -27,6 +27,7 @@ export const CLI_COMMANDS = [
27
27
  { name: 'diff', summary: 'Show which keys differ, and how' },
28
28
  { name: 'pull', summary: 'Write the cloud’s version of each file to disk' },
29
29
  { name: 'push', summary: 'Send local files to the cloud' },
30
+ { name: 'version', summary: 'Show the CLI version and its environment' },
30
31
  ]
31
32
 
32
33
  /**
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)