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 +76 -15
- package/package.json +1 -1
- package/src/api.mjs +17 -2
- package/src/blob.mjs +39 -0
- package/src/commands/diff.mjs +19 -3
- package/src/commands/misc.mjs +76 -5
- package/src/commands/pull.mjs +17 -10
- package/src/commands/push.mjs +51 -5
- package/src/commands/status.mjs +22 -3
- package/src/context.mjs +24 -4
- package/src/index.mjs +72 -5
- package/src/manifest.mjs +1 -0
- package/src/project.mjs +152 -7
- package/src/status.mjs +54 -2
package/README.md
CHANGED
|
@@ -26,21 +26,28 @@ envs pull
|
|
|
26
26
|
|
|
27
27
|
## Commands
|
|
28
28
|
|
|
29
|
-
| Command
|
|
30
|
-
|
|
|
31
|
-
| `login`
|
|
32
|
-
| `logout`
|
|
33
|
-
| `whoami`
|
|
34
|
-
| `init`
|
|
35
|
-
| `link`
|
|
36
|
-
| `list`
|
|
37
|
-
| `status`
|
|
38
|
-
| `diff`
|
|
39
|
-
| `pull`
|
|
40
|
-
| `push`
|
|
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
|
|
129
|
-
`.sample`, `.template`, and
|
|
130
|
-
committed and hold placeholder
|
|
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
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(
|
|
22
|
-
|
|
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
|
+
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { api, ApiError } from '../api.mjs'
|
|
2
|
-
import {
|
|
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
|
|
package/src/commands/misc.mjs
CHANGED
|
@@ -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 {
|
|
4
|
-
|
|
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 :
|
|
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 :
|
|
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()
|
package/src/commands/pull.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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()
|
package/src/commands/push.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { api, ApiError } from '../api.mjs'
|
|
2
|
-
import {
|
|
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
|
|
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)
|
package/src/commands/status.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { api, ApiError } from '../api.mjs'
|
|
2
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
|
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([...
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 =
|
|
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
|
|
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)
|