envstash-cli 0.1.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 ADDED
@@ -0,0 +1,182 @@
1
+ # envstash
2
+
3
+ Keep your `.env` files in sync with your EnvStash account, with commands that
4
+ behave the way `git` taught you to expect.
5
+
6
+ ```bash
7
+ pnpm add -g envstash-cli
8
+ ```
9
+
10
+ Ships two binaries — `envstash` and the short form `envs`. They are the same
11
+ program.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ envs login # authorize this machine in the browser
17
+ envs init # create a project, named after your git remote
18
+ envs push # send your .env files up
19
+ ```
20
+
21
+ On another machine, or a fresh clone:
22
+
23
+ ```bash
24
+ envs pull
25
+ ```
26
+
27
+ ## Commands
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 |
41
+
42
+ Run `envs help <command>` for details on any one of them.
43
+
44
+ ## `envs status`
45
+
46
+ Checks every file in your cloud account against what is on disk.
47
+
48
+ ```
49
+ $ envs status
50
+
51
+ Project demo-app · https://www.envstash.co
52
+
53
+ .env.local 1 changed, 1 local-only, 1 cloud-only
54
+ - PORT (cloud only)
55
+ ~ SESSION_SECRET (local differs)
56
+ + SMTP_PASSWORD (local only)
57
+ .env.production up to date (2 keys)
58
+
59
+ 2 files · 4 in sync · 1 changed · 1 local-only · 1 cloud-only
60
+ ```
61
+
62
+ | Mark | Meaning |
63
+ | ---- | -------------------------------------------------- |
64
+ | `~` | The key exists on both sides with different values |
65
+ | `+` | The key exists only on disk |
66
+ | `-` | The key exists only in the cloud |
67
+
68
+ **No secret values are sent during a status check.** The server returns key
69
+ names and a truncated SHA-256 digest of each value; the CLI hashes your local
70
+ values and compares. That makes `envs status` safe to run on a shared screen or
71
+ pipe into a CI log — it can tell you a value changed without either side
72
+ transmitting what it is.
73
+
74
+ ### Exit codes
75
+
76
+ `envs status` exits **1 when anything is out of sync** and 0 when everything
77
+ matches, so a pre-commit hook or a CI job catches drift with no extra flags:
78
+
79
+ ```bash
80
+ envs status || echo "your .env has drifted from the cloud"
81
+ ```
82
+
83
+ This deliberately differs from `git status`, which always exits 0 and makes
84
+ signalling opt-in via `git diff --exit-code`. Drift in a secrets store is
85
+ something you almost always want to be told about loudly.
86
+
87
+ Pass `--exit-zero` when a script must not fail on drift — most often inside a
88
+ shell running under `set -e`:
89
+
90
+ ```bash
91
+ envs status --exit-zero
92
+ ```
93
+
94
+ `envs diff` follows the same rule. `--exit-code` is still accepted and is now a
95
+ no-op, since it names the default.
96
+
97
+ ## Safety defaults
98
+
99
+ Both sync directions are conservative, because a `.env` file is git-ignored by
100
+ definition and therefore has no undo:
101
+
102
+ - **`pull` skips files with local changes.** Use `--force` to overwrite.
103
+ - **`push` never deletes.** Keys in the cloud that are absent from your local
104
+ file are left alone, so pushing a partial `.env` cannot destroy a teammate's
105
+ key. Use `--prune` when you do want the cloud to mirror local exactly.
106
+ - **`diff` masks values.** Use `--show-values` to reveal them.
107
+ - Pulled files are written `0600`, and `~/.envstash/config.json` is `0600`
108
+ inside a `0700` directory.
109
+
110
+ Preview either direction with `--dry-run`.
111
+
112
+ ## Project linking
113
+
114
+ `envs init` writes `.envstash.json` in your repo root:
115
+
116
+ ```json
117
+ {
118
+ "project": "demo-app",
119
+ "files": [".env.local", ".env.production"]
120
+ }
121
+ ```
122
+
123
+ **Commit this file.** It contains no secrets — only the project name and which
124
+ files to track — and it is what lets a teammate clone the repo and run
125
+ `envs pull` with no further setup.
126
+
127
+ 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.
131
+
132
+ Monorepos can track nested paths by listing them explicitly:
133
+
134
+ ```json
135
+ { "project": "my-monorepo", "files": ["apps/web/.env.local", "apps/api/.env"] }
136
+ ```
137
+
138
+ ## Configuration
139
+
140
+ | Variable | Purpose |
141
+ | --------------------- | --------------------------------------------------------- |
142
+ | `ENVSTASH_TOKEN` | Use this token instead of the stored one — for CI |
143
+ | `ENVSTASH_HOST` | Target another server (default `https://www.envstash.co`) |
144
+ | `ENVSTASH_CONFIG_DIR` | Where credentials live (default `~/.envstash`) |
145
+ | `ENVSTASH_DEV` | Log in to a local dev server when one is running |
146
+ | `ENVSTASH_DEBUG` | Print stack traces on failure |
147
+ | `NO_COLOR` | Disable colour (also off automatically when piped) |
148
+
149
+ In CI, set `ENVSTASH_TOKEN` from your platform's secret store **and set
150
+ `ENVSTASH_HOST`**, then run `envs pull` — no interactive login required.
151
+
152
+ Naming the host is what keeps the token yours. `ENVSTASH_TOKEN` is only sent to
153
+ a host the environment chose (`ENVSTASH_HOST` or `--host`) or one you have
154
+ signed in to before — never to a host that `.envstash.json` asked for. That file
155
+ is committed, so on a fork build or an untrusted branch it arrives with the
156
+ checkout, and without this rule a repo could name a server and be handed your
157
+ credential. If a repo pins a host you have not used, the CLI says so and tells
158
+ you the `--host` to pass if you meant it.
159
+
160
+ ### Logging in to a local server
161
+
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.
167
+
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.
170
+
171
+ ## Notes
172
+
173
+ - Requires Node 18.17 or newer.
174
+ - Zero runtime dependencies.
175
+ - Values are stored per key, not per file. A pulled file is therefore
176
+ regenerated rather than reproduced byte for byte: keys come back sorted, and
177
+ comments and blank lines are not preserved. The values themselves round-trip
178
+ exactly, including multi-line values such as PEM keys.
179
+
180
+ ## License
181
+
182
+ MIT
package/bin/envs.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // `envs` is the short form of `envstash`. Both bins are declared in package.json
3
+ // so npm links each one; this is a real executable, not a shell alias, which
4
+ // means it also works from CI and from npx.
5
+ import { run } from '../src/index.mjs'
6
+
7
+ await run(process.argv.slice(2))
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/index.mjs'
3
+
4
+ await run(process.argv.slice(2))
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "envstash-cli",
3
+ "version": "0.1.0",
4
+ "description": "Git-style CLI for EnvStash — keep your .env files in sync with your cloud account.",
5
+ "keywords": [
6
+ "env",
7
+ "dotenv",
8
+ "secrets",
9
+ "environment-variables",
10
+ "cli",
11
+ "envstash"
12
+ ],
13
+ "homepage": "https://envstash.co",
14
+ "bugs": "https://github.com/masaok/envstash-next/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/masaok/envstash-next.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "bin": {
23
+ "envstash": "bin/envstash.mjs",
24
+ "envs": "bin/envs.mjs"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18.17"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "pnpm pack --dry-run",
39
+ "test": "node --test \"test/*.test.mjs\"",
40
+ "release": "pnpm publish"
41
+ }
42
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,155 @@
1
+ import { CliError } from './ui.mjs'
2
+
3
+ /** An error the server described in its JSON body. */
4
+ export class ApiError extends CliError {
5
+ constructor(status, code, message, hint) {
6
+ super(message, { hint })
7
+ this.name = 'ApiError'
8
+ this.status = status
9
+ this.code = code
10
+ }
11
+ }
12
+
13
+ /**
14
+ * One request to the EnvStash API.
15
+ *
16
+ * Errors are normalized here so commands never branch on status codes: the
17
+ * server always answers failures with `{ error, message }`, and anything that
18
+ * is not JSON (a proxy error page, a 502 from the edge) is reported as a
19
+ * transport problem rather than crashing on a parse.
20
+ */
21
+ export async function request(host, path, { method = 'GET', token, body, signal } = {}) {
22
+ const headers = { accept: 'application/json' }
23
+
24
+ if (token) {
25
+ headers.authorization = `Bearer ${token}`
26
+ }
27
+
28
+ if (body !== undefined) {
29
+ headers['content-type'] = 'application/json'
30
+ }
31
+
32
+ let response
33
+
34
+ try {
35
+ response = await fetch(`${host}${path}`, {
36
+ method,
37
+ headers,
38
+ body: body === undefined ? undefined : JSON.stringify(body),
39
+ signal,
40
+ })
41
+ } catch (error) {
42
+ if (error?.name === 'AbortError') {
43
+ throw error
44
+ }
45
+
46
+ throw new CliError(`Could not reach ${host}.`, {
47
+ hint: 'Check your connection, or pass --host if you meant a different server.',
48
+ })
49
+ }
50
+
51
+ const text = await response.text()
52
+ let payload = null
53
+
54
+ if (text) {
55
+ try {
56
+ payload = JSON.parse(text)
57
+ } catch {
58
+ payload = null
59
+ }
60
+ }
61
+
62
+ if (!response.ok) {
63
+ // A 401 on a request that *did* carry a token, after a redirect to another
64
+ // origin, is almost never a stale token: fetch strips the Authorization
65
+ // header when a redirect crosses origins, so the server never saw it. Say
66
+ // that instead of sending the user round the login loop again.
67
+ const crossOriginRedirect =
68
+ token && response.status === 401 && redirectedAcrossOrigin(host, response)
69
+
70
+ if (crossOriginRedirect) {
71
+ throw new ApiError(
72
+ 401,
73
+ 'unauthorized',
74
+ `${host} redirected to ${crossOriginRedirect}, which drops the credentials.`,
75
+ `Use --host ${crossOriginRedirect} (or set ENVSTASH_HOST) — a redirect across origins strips the Authorization header.`
76
+ )
77
+ }
78
+
79
+ if (!payload?.message) {
80
+ throw new ApiError(
81
+ response.status,
82
+ 'http_error',
83
+ `${host}${path} returned HTTP ${response.status}.`
84
+ )
85
+ }
86
+
87
+ throw new ApiError(
88
+ response.status,
89
+ payload.error ?? 'error',
90
+ payload.message,
91
+ response.status === 401 ? 'Run `envs login` to sign in again.' : undefined
92
+ )
93
+ }
94
+
95
+ return payload ?? {}
96
+ }
97
+
98
+ /**
99
+ * The origin a request ended up on, when following redirects moved it off the
100
+ * origin it started on. Returns null otherwise — including when the response
101
+ * carries no URL, as the stubbed responses in the tests do.
102
+ */
103
+ function redirectedAcrossOrigin(host, response) {
104
+ if (!response.redirected || !response.url) {
105
+ return null
106
+ }
107
+
108
+ try {
109
+ const finalOrigin = new URL(response.url).origin
110
+
111
+ return finalOrigin === new URL(host).origin ? null : finalOrigin
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ export const api = {
118
+ startDeviceAuth: (host, label) =>
119
+ request(host, '/api/cli/auth/device', { method: 'POST', body: { label } }),
120
+
121
+ pollDeviceAuth: (host, deviceCode, signal) =>
122
+ request(host, '/api/cli/auth/token', { method: 'POST', body: { deviceCode }, signal }),
123
+
124
+ logout: (host, token) => request(host, '/api/cli/auth/logout', { method: 'POST', token }),
125
+
126
+ whoami: (host, token) => request(host, '/api/cli/whoami', { token }),
127
+
128
+ listProjects: (host, token) => request(host, '/api/cli/projects', { token }),
129
+
130
+ createProject: (host, token, name) =>
131
+ request(host, '/api/cli/projects', { method: 'POST', token, body: { name } }),
132
+
133
+ projectStatus: (host, token, project) =>
134
+ request(host, `/api/cli/projects/${encodeURIComponent(project)}/status`, { token }),
135
+
136
+ getFile: (host, token, project, file) =>
137
+ request(host, `/api/cli/projects/${encodeURIComponent(project)}/files/${encodePath(file)}`, {
138
+ token,
139
+ }),
140
+
141
+ putFile: (host, token, project, file, entries, prune) =>
142
+ request(host, `/api/cli/projects/${encodeURIComponent(project)}/files/${encodePath(file)}`, {
143
+ method: 'PUT',
144
+ token,
145
+ body: { entries, prune },
146
+ }),
147
+ }
148
+
149
+ /** Encode each path segment but keep the "/" separators that address a nested file. */
150
+ function encodePath(file) {
151
+ return file
152
+ .split('/')
153
+ .map(segment => encodeURIComponent(segment))
154
+ .join('/')
155
+ }
@@ -0,0 +1,137 @@
1
+ import { api, ApiError } from '../api.mjs'
2
+ import { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
3
+ import { entriesToMap } from '../env-file.mjs'
4
+ import { compareProject, driftExitCode, FILE_STATE, KEY_STATE } from '../status.mjs'
5
+ import { maskValue, out, style } from '../ui.mjs'
6
+
7
+ /**
8
+ * `envs diff` — the same comparison as `status`, with values.
9
+ *
10
+ * Values are masked unless `--show-values` is passed. A diff is the one command
11
+ * whose whole purpose is to show what changed, and it is also the one most
12
+ * likely to be run with someone watching the screen; masking by default makes
13
+ * revealing a secret a deliberate act.
14
+ */
15
+ export async function diff(flags, args) {
16
+ const context = resolveContext(flags)
17
+ const cloudFiles = await fetchCloudFiles(context)
18
+
19
+ const names = resolveFileNames({
20
+ repoRoot: context.repoRoot,
21
+ link: context.link,
22
+ cloudFiles,
23
+ requested: args,
24
+ })
25
+
26
+ const localFiles = loadLocalFiles(context.repoRoot, names)
27
+ const files = compareProject({
28
+ cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
29
+ localFiles,
30
+ })
31
+
32
+ const changed = files.filter(file => file.state !== FILE_STATE.IN_SYNC)
33
+
34
+ if (changed.length === 0) {
35
+ out()
36
+ out(` ${style.dim('No differences.')} ${style.bold(context.project.name)} is up to date.`)
37
+ out()
38
+ return 0
39
+ }
40
+
41
+ // Only fetch plaintext for files that actually differ, and only when the user
42
+ // asked to see values.
43
+ const cloudValues = new Map()
44
+
45
+ if (flags['show-values']) {
46
+ for (const file of changed) {
47
+ if (file.state === FILE_STATE.UNTRACKED) {
48
+ continue
49
+ }
50
+
51
+ const response = await api.getFile(
52
+ context.host,
53
+ context.token,
54
+ context.project.name,
55
+ file.name
56
+ )
57
+ cloudValues.set(file.name, entriesToMap(response.entries ?? []))
58
+ }
59
+ }
60
+
61
+ out()
62
+
63
+ for (const file of changed) {
64
+ out(` ${style.bold(file.name)} ${style.dim(`— ${describeFile(file)}`)}`)
65
+
66
+ const local = entriesToMap(localFiles.get(file.name) ?? [])
67
+ const cloud = cloudValues.get(file.name)
68
+
69
+ for (const entry of file.keys) {
70
+ if (entry.state === KEY_STATE.SAME) {
71
+ continue
72
+ }
73
+
74
+ renderKey(entry, local, cloud, Boolean(flags['show-values']))
75
+ }
76
+
77
+ out()
78
+ }
79
+
80
+ if (!flags['show-values']) {
81
+ out(` ${style.dim('Values are masked. Pass --show-values to reveal them.')}`)
82
+ out()
83
+ }
84
+
85
+ return driftExitCode(flags, files)
86
+ }
87
+
88
+ function renderKey(entry, local, cloud, showValues) {
89
+ const reveal = value => (showValues ? value : maskValue(value))
90
+
91
+ if (entry.state === KEY_STATE.MODIFIED) {
92
+ out(` ${style.yellow('~')} ${entry.key}`)
93
+ out(
94
+ ` ${style.red('cloud')} ${cloud ? reveal(cloud.get(entry.key) ?? '') : style.dim('•'.repeat(8))}`
95
+ )
96
+ out(` ${style.green('local')} ${reveal(local.get(entry.key) ?? '')}`)
97
+ return
98
+ }
99
+
100
+ if (entry.state === KEY_STATE.LOCAL_ONLY) {
101
+ out(
102
+ ` ${style.green('+')} ${entry.key} ${reveal(local.get(entry.key) ?? '')} ${style.dim('(local only)')}`
103
+ )
104
+ return
105
+ }
106
+
107
+ out(
108
+ ` ${style.red('-')} ${entry.key} ${
109
+ cloud ? reveal(cloud.get(entry.key) ?? '') : style.dim('•'.repeat(8))
110
+ } ${style.dim('(cloud only)')}`
111
+ )
112
+ }
113
+
114
+ function describeFile(file) {
115
+ if (file.state === FILE_STATE.CLOUD_ONLY) {
116
+ return 'in the cloud, not on disk'
117
+ }
118
+
119
+ if (file.state === FILE_STATE.UNTRACKED) {
120
+ return 'on disk, not in the cloud'
121
+ }
122
+
123
+ return 'differs'
124
+ }
125
+
126
+ async function fetchCloudFiles(context) {
127
+ try {
128
+ const response = await api.projectStatus(context.host, context.token, context.project.name)
129
+ return response.files ?? []
130
+ } catch (error) {
131
+ if (error instanceof ApiError && error.status === 404) {
132
+ return []
133
+ }
134
+
135
+ throw error
136
+ }
137
+ }