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 +182 -0
- package/bin/envs.mjs +7 -0
- package/bin/envstash.mjs +4 -0
- package/package.json +42 -0
- package/src/api.mjs +155 -0
- package/src/commands/diff.mjs +137 -0
- package/src/commands/login.mjs +286 -0
- package/src/commands/misc.mjs +149 -0
- package/src/commands/pull.mjs +123 -0
- package/src/commands/push.mjs +125 -0
- package/src/commands/status.mjs +149 -0
- package/src/config.mjs +188 -0
- package/src/context.mjs +118 -0
- package/src/dev-host.mjs +84 -0
- package/src/env-file.mjs +243 -0
- package/src/index.mjs +299 -0
- package/src/manifest.mjs +68 -0
- package/src/project.mjs +159 -0
- package/src/repo-path.mjs +78 -0
- package/src/session.mjs +92 -0
- package/src/status.mjs +152 -0
- package/src/ui.mjs +82 -0
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))
|
package/bin/envstash.mjs
ADDED
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
|
+
}
|