envstash-cli 0.4.3 → 0.5.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 +15 -0
- package/package.json +1 -1
- package/src/commands/misc.mjs +8 -0
- package/src/commands/pull.mjs +40 -6
- package/src/commands/push.mjs +56 -10
- package/src/context.mjs +9 -0
- package/src/index.mjs +10 -0
- package/src/project.mjs +68 -0
package/README.md
CHANGED
|
@@ -116,6 +116,21 @@ definition and therefore has no undo:
|
|
|
116
116
|
|
|
117
117
|
Preview either direction with `--dry-run`.
|
|
118
118
|
|
|
119
|
+
## Shared files
|
|
120
|
+
|
|
121
|
+
Some credentials follow you rather than a repo — a global `.npmrc`, an AI-tool
|
|
122
|
+
key file. Push those once to your account-wide shared files, then pull them into
|
|
123
|
+
whichever repo needs them:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
envs push --shared .npmrc # from any repo, once
|
|
127
|
+
envs pull --shared .npmrc # in every repo that needs it
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`--shared` always needs the files named: discovery sweeps up a repo's own
|
|
131
|
+
files, and the shared scope is deliberately outside that. Shared files appear
|
|
132
|
+
on the dashboard under **Shared Files**, not inside any project.
|
|
133
|
+
|
|
119
134
|
## Project linking
|
|
120
135
|
|
|
121
136
|
`envs init` writes `.envstash.json` in your repo root:
|
package/package.json
CHANGED
package/src/commands/misc.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '../project.mjs'
|
|
14
14
|
import { requireHostToken, resolveHostFor, resolveHostSource } from '../session.mjs'
|
|
15
15
|
import { out, pad, pluralize, style } from '../ui.mjs'
|
|
16
|
+
import { announceIgnoredSecretDir } from './push.mjs'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* `envs version` — the version, and the environment it would act in.
|
|
@@ -140,6 +141,10 @@ export async function init(flags, args) {
|
|
|
140
141
|
|
|
141
142
|
await api.createProject(host, token, name)
|
|
142
143
|
|
|
144
|
+
// Before discovery, so a not-yet-ignored .config/ lands in the link file
|
|
145
|
+
// this init writes rather than being silently absent from it.
|
|
146
|
+
announceIgnoredSecretDir(repoRoot)
|
|
147
|
+
|
|
143
148
|
const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
|
|
144
149
|
const path = writeLink(repoRoot, {
|
|
145
150
|
project: name,
|
|
@@ -201,6 +206,9 @@ export async function link(flags, args) {
|
|
|
201
206
|
// that points at nothing.
|
|
202
207
|
await api.projectStatus(host, token, name)
|
|
203
208
|
|
|
209
|
+
// Same moment as in init: discovery is about to decide the link file's list.
|
|
210
|
+
announceIgnoredSecretDir(repoRoot)
|
|
211
|
+
|
|
204
212
|
const files = existing?.files?.length ? existing.files : discoverTrackedFiles(repoRoot)
|
|
205
213
|
const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
|
|
206
214
|
|
package/src/commands/pull.mjs
CHANGED
|
@@ -3,7 +3,13 @@ import { dirname } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { api, ApiError } from '../api.mjs'
|
|
5
5
|
import { formatSize } from '../blob.mjs'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
cloudKinds,
|
|
8
|
+
loadLocalFiles,
|
|
9
|
+
resolveContext,
|
|
10
|
+
resolveFileNames,
|
|
11
|
+
SHARED_SCOPE,
|
|
12
|
+
} from '../context.mjs'
|
|
7
13
|
import { serializeEnv } from '../env-file.mjs'
|
|
8
14
|
import { resolveInsideRepo } from '../repo-path.mjs'
|
|
9
15
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
@@ -16,17 +22,38 @@ import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
|
16
22
|
* most likely to be run reflexively on a new machine, and silently replacing an
|
|
17
23
|
* uncommitted `.env` destroys work that exists nowhere else — `.env` files are
|
|
18
24
|
* git-ignored by definition, so there is no undo.
|
|
25
|
+
*
|
|
26
|
+
* `--shared` pulls the named files from the account-wide shared scope instead
|
|
27
|
+
* of this repo's project. Names are required, mirroring `push --shared`: which
|
|
28
|
+
* of your shared files belong in this particular repo is a per-repo decision,
|
|
29
|
+
* not something to infer from the scope's whole contents.
|
|
19
30
|
*/
|
|
20
31
|
export async function pull(flags, args) {
|
|
32
|
+
const shared = Boolean(flags.shared)
|
|
33
|
+
|
|
34
|
+
if (shared && args.length === 0) {
|
|
35
|
+
throw new CliError('Pulling shared files needs their names.', {
|
|
36
|
+
hint: 'Example: envs pull --shared .npmrc',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
const context = resolveContext(flags)
|
|
41
|
+
const scope = shared ? SHARED_SCOPE : context.project.name
|
|
22
42
|
|
|
23
43
|
let cloudFiles
|
|
24
44
|
|
|
25
45
|
try {
|
|
26
|
-
const response = await api.projectStatus(context.host, context.token,
|
|
46
|
+
const response = await api.projectStatus(context.host, context.token, scope)
|
|
27
47
|
cloudFiles = response.files ?? []
|
|
28
48
|
} catch (error) {
|
|
29
49
|
if (error instanceof ApiError && error.status === 404) {
|
|
50
|
+
if (shared) {
|
|
51
|
+
// The scope is created by the first shared push; until then it 404s.
|
|
52
|
+
throw new CliError('You have no shared files yet.', {
|
|
53
|
+
hint: 'Push one first: envs push --shared <file>',
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
throw new CliError(`No project named "${context.project.name}" in your account.`, {
|
|
31
58
|
hint: 'Run `envs init` to create it, or pass --project to target another one.',
|
|
32
59
|
})
|
|
@@ -44,9 +71,16 @@ export async function pull(flags, args) {
|
|
|
44
71
|
|
|
45
72
|
if (names.length === 0) {
|
|
46
73
|
out()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
74
|
+
|
|
75
|
+
if (shared) {
|
|
76
|
+
out(` ${style.dim('Nothing to pull — none of those names are in your shared files.')}`)
|
|
77
|
+
out(` ${style.dim('See what is there on the dashboard, or push with')} envs push --shared`)
|
|
78
|
+
} else {
|
|
79
|
+
out(
|
|
80
|
+
` ${style.dim('Nothing to pull —')} ${style.bold(context.project.name)} ${style.dim('has no files in the cloud.')}`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
50
84
|
out()
|
|
51
85
|
return 0
|
|
52
86
|
}
|
|
@@ -80,7 +114,7 @@ export async function pull(flags, args) {
|
|
|
80
114
|
continue
|
|
81
115
|
}
|
|
82
116
|
|
|
83
|
-
const response = await api.getFile(context.host, context.token,
|
|
117
|
+
const response = await api.getFile(context.host, context.token, scope, file.name)
|
|
84
118
|
|
|
85
119
|
// A blob comes back whole; a dotenv file is regenerated from entries. The
|
|
86
120
|
// branch is on the response's own kind — the server is the authority.
|
package/src/commands/push.mjs
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { api, ApiError } from '../api.mjs'
|
|
2
2
|
import { formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
cloudKinds,
|
|
5
|
+
loadLocalFiles,
|
|
6
|
+
resolveContext,
|
|
7
|
+
resolveFileNames,
|
|
8
|
+
SHARED_SCOPE,
|
|
9
|
+
} from '../context.mjs'
|
|
10
|
+
import { ensureSecretDirIgnored, SECRET_FILE_DIR } from '../project.mjs'
|
|
4
11
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
5
12
|
import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
6
13
|
|
|
14
|
+
/** Say that .gitignore was edited — quietly rewriting a user's file is worse. */
|
|
15
|
+
export function announceIgnoredSecretDir(repoRoot) {
|
|
16
|
+
if (!ensureSecretDirIgnored(repoRoot)) {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
out()
|
|
21
|
+
out(
|
|
22
|
+
` ${style.green('+')} Added ${style.bold(`${SECRET_FILE_DIR}/`)} to .gitignore ` +
|
|
23
|
+
style.dim('— secrets sync through EnvStash, not git')
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
/**
|
|
8
28
|
* `envs push` — send local files up.
|
|
9
29
|
*
|
|
@@ -11,10 +31,33 @@ import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
|
11
31
|
* is passed. Pushing a partial `.env` is normal — a developer working on one
|
|
12
32
|
* feature often has only the keys they need — and treating absence as deletion
|
|
13
33
|
* would let that quietly destroy a teammate's key.
|
|
34
|
+
*
|
|
35
|
+
* `--shared` sends the named files to the account-wide shared scope instead of
|
|
36
|
+
* this repo's project — for credentials that follow the developer rather than
|
|
37
|
+
* a repo (a global `.npmrc`, an AI-tool key file). Names are required there:
|
|
38
|
+
* discovery exists to sweep up a repo's own files, and sweeping a repo's files
|
|
39
|
+
* into the account-wide scope is exactly the mistake it would make.
|
|
14
40
|
*/
|
|
15
41
|
export async function push(flags, args) {
|
|
42
|
+
const shared = Boolean(flags.shared)
|
|
43
|
+
|
|
44
|
+
if (shared && args.length === 0) {
|
|
45
|
+
throw new CliError('Pushing shared files needs their names.', {
|
|
46
|
+
hint: 'Example: envs push --shared .npmrc',
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
16
50
|
const context = resolveContext(flags)
|
|
17
|
-
const
|
|
51
|
+
const scope = shared ? SHARED_SCOPE : context.project.name
|
|
52
|
+
|
|
53
|
+
// Before discovery, so files in a not-yet-ignored .config/ are picked up by
|
|
54
|
+
// this very push rather than the next one. Not under --dry-run: a preview
|
|
55
|
+
// that edits files is not a preview.
|
|
56
|
+
if (!flags['dry-run']) {
|
|
57
|
+
announceIgnoredSecretDir(context.repoRoot)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cloudFiles = await fetchCloudFiles(context, scope)
|
|
18
61
|
|
|
19
62
|
const names = resolveFileNames({
|
|
20
63
|
repoRoot: context.repoRoot,
|
|
@@ -48,7 +91,7 @@ export async function push(flags, args) {
|
|
|
48
91
|
}
|
|
49
92
|
|
|
50
93
|
if (file.kind === 'file') {
|
|
51
|
-
pushed += await pushOneBlob(flags, context, file, localFiles.get(file.name))
|
|
94
|
+
pushed += await pushOneBlob(flags, context, scope, file, localFiles.get(file.name))
|
|
52
95
|
continue
|
|
53
96
|
}
|
|
54
97
|
|
|
@@ -68,7 +111,7 @@ export async function push(flags, args) {
|
|
|
68
111
|
const result = await api.putFile(
|
|
69
112
|
context.host,
|
|
70
113
|
context.token,
|
|
71
|
-
|
|
114
|
+
scope,
|
|
72
115
|
file.name,
|
|
73
116
|
entries,
|
|
74
117
|
Boolean(flags.prune)
|
|
@@ -108,7 +151,9 @@ export async function push(flags, args) {
|
|
|
108
151
|
out()
|
|
109
152
|
|
|
110
153
|
if (!flags['dry-run'] && pushed > 0) {
|
|
111
|
-
|
|
154
|
+
// The shared scope's internal name is not for printing — say what it means.
|
|
155
|
+
const destination = shared ? 'your shared files' : context.project.name
|
|
156
|
+
out(` ${style.dim(`Pushed ${pluralize(pushed, 'file')} to ${destination}.`)}`)
|
|
112
157
|
out()
|
|
113
158
|
}
|
|
114
159
|
|
|
@@ -120,7 +165,7 @@ export async function push(flags, args) {
|
|
|
120
165
|
* the report is created / updated / unchanged, with the size, because "what
|
|
121
166
|
* went up" for a blob is the file itself.
|
|
122
167
|
*/
|
|
123
|
-
async function pushOneBlob(flags, context, file, local) {
|
|
168
|
+
async function pushOneBlob(flags, context, scope, file, local) {
|
|
124
169
|
if (local.bytes.length > MAX_BLOB_BYTES) {
|
|
125
170
|
throw new CliError(
|
|
126
171
|
`${file.name} is ${formatSize(local.bytes.length)} — files are capped at ${formatSize(MAX_BLOB_BYTES)}.`,
|
|
@@ -138,7 +183,7 @@ async function pushOneBlob(flags, context, file, local) {
|
|
|
138
183
|
const result = await api.putBlob(
|
|
139
184
|
context.host,
|
|
140
185
|
context.token,
|
|
141
|
-
|
|
186
|
+
scope,
|
|
142
187
|
file.name,
|
|
143
188
|
local.bytes.toString('base64')
|
|
144
189
|
)
|
|
@@ -156,13 +201,14 @@ async function pushOneBlob(flags, context, file, local) {
|
|
|
156
201
|
return 1
|
|
157
202
|
}
|
|
158
203
|
|
|
159
|
-
async function fetchCloudFiles(context) {
|
|
204
|
+
async function fetchCloudFiles(context, scope) {
|
|
160
205
|
try {
|
|
161
|
-
const response = await api.projectStatus(context.host, context.token,
|
|
206
|
+
const response = await api.projectStatus(context.host, context.token, scope)
|
|
162
207
|
return response.files ?? []
|
|
163
208
|
} catch (error) {
|
|
164
209
|
if (error instanceof ApiError && error.status === 404) {
|
|
165
|
-
// First push to a new project
|
|
210
|
+
// First push to a new project — or to a shared scope that has never
|
|
211
|
+
// held a file: the server creates either on write.
|
|
166
212
|
return []
|
|
167
213
|
}
|
|
168
214
|
|
package/src/context.mjs
CHANGED
|
@@ -7,6 +7,15 @@ import { resolveInsideRepo } from './repo-path.mjs'
|
|
|
7
7
|
import { requireHostToken } from './session.mjs'
|
|
8
8
|
import { errOut, pluralize, style } from './ui.mjs'
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The server-side name of the per-user shared-files scope, mirrored from
|
|
12
|
+
* SHARED_SCOPE in src/lib/secrets/store.ts. `--shared` sends files here instead
|
|
13
|
+
* of to a project: same routes, this name in the project segment. The `~` is
|
|
14
|
+
* what makes it unclaimable — project names cannot contain one, so no project
|
|
15
|
+
* of the user's can ever be shadowed by it.
|
|
16
|
+
*/
|
|
17
|
+
export const SHARED_SCOPE = '~shared'
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* Everything the sync commands need, resolved once: which server, which token,
|
|
12
21
|
* which repo, which project.
|
package/src/index.mjs
CHANGED
|
@@ -261,6 +261,7 @@ function printHelp(topic) {
|
|
|
261
261
|
)
|
|
262
262
|
out(` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff)')}`)
|
|
263
263
|
out(` --dry-run ${style.dim('Show what would happen (pull, push)')}`)
|
|
264
|
+
out(` --shared ${style.dim('Target your account-wide shared files (pull, push)')}`)
|
|
264
265
|
out(` --dev ${style.dim('Log in to a local dev server if one is running')}`)
|
|
265
266
|
out()
|
|
266
267
|
out(
|
|
@@ -313,9 +314,13 @@ const COMMAND_HELP = {
|
|
|
313
314
|
'Files with local changes are skipped unless --force is given, because a',
|
|
314
315
|
'git-ignored .env that gets overwritten has no undo.',
|
|
315
316
|
'',
|
|
317
|
+
'--shared pulls from your account-wide shared files instead of this repo’s',
|
|
318
|
+
'project, and needs the file named.',
|
|
319
|
+
'',
|
|
316
320
|
' envs pull',
|
|
317
321
|
' envs pull --dry-run',
|
|
318
322
|
' envs pull --force',
|
|
323
|
+
' envs pull --shared .npmrc',
|
|
319
324
|
],
|
|
320
325
|
push: [
|
|
321
326
|
'Send local files to the cloud.',
|
|
@@ -323,9 +328,14 @@ const COMMAND_HELP = {
|
|
|
323
328
|
'Keys in the cloud that are absent locally are left alone unless --prune is',
|
|
324
329
|
'given, so pushing a partial .env cannot delete a teammate’s key.',
|
|
325
330
|
'',
|
|
331
|
+
'--shared sends the named files to your account-wide shared files — for',
|
|
332
|
+
'credentials that follow you across projects, like a global .npmrc. Pull',
|
|
333
|
+
'them into any repo with `envs pull --shared <file>`.',
|
|
334
|
+
'',
|
|
326
335
|
' envs push',
|
|
327
336
|
' envs push --dry-run',
|
|
328
337
|
' envs push --prune',
|
|
338
|
+
' envs push --shared .npmrc',
|
|
329
339
|
],
|
|
330
340
|
login: [
|
|
331
341
|
'Authorize this machine using a browser device code.',
|
package/src/project.mjs
CHANGED
|
@@ -280,6 +280,74 @@ export function discoverSecretFiles(repoRoot) {
|
|
|
280
280
|
return candidates.filter(name => ignored.has(name)).sort()
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Make sure `.config/` is git-ignored before discovery asks about it.
|
|
285
|
+
*
|
|
286
|
+
* Being ignored is discovery's signal for "secret", which makes an unignored
|
|
287
|
+
* `.config/` silently invisible to `envs push` — a real footgun: the developer
|
|
288
|
+
* sees a directory full of credentials and a push that ignores them, with
|
|
289
|
+
* nothing saying why. Rather than documenting that, push and init close it:
|
|
290
|
+
* when the directory has contents, nothing in it is tracked by git, and it is
|
|
291
|
+
* not already ignored, `.config/` is added to .gitignore. A `.config/` with
|
|
292
|
+
* tracked files is a repo that commits configuration there on purpose, and is
|
|
293
|
+
* left alone.
|
|
294
|
+
*
|
|
295
|
+
* Returns true when .gitignore was changed — the caller must say so out loud,
|
|
296
|
+
* because a CLI editing a file it does not own should never do it silently.
|
|
297
|
+
*/
|
|
298
|
+
export function ensureSecretDirIgnored(repoRoot) {
|
|
299
|
+
let entries
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
entries = readdirSync(join(repoRoot, SECRET_FILE_DIR))
|
|
303
|
+
} catch {
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (entries.length === 0) {
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Also the git-and-repo probe: outside a repository (or without git) this
|
|
312
|
+
// throws, and an ignore rule would mean nothing there anyway.
|
|
313
|
+
let tracked
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
tracked = runGit(['ls-files', '--', SECRET_FILE_DIR], { cwd: repoRoot }).trim()
|
|
317
|
+
} catch {
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (tracked !== '') {
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A synthetic name, so one question covers the directory: any rule broad
|
|
326
|
+
// enough to ignore this probe ignores everything discovery would find.
|
|
327
|
+
const probe = `${SECRET_FILE_DIR}/envstash-ignore-probe`
|
|
328
|
+
|
|
329
|
+
if (gitIgnoredNames(repoRoot, [probe]).has(probe)) {
|
|
330
|
+
return false
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const path = join(repoRoot, '.gitignore')
|
|
334
|
+
let existing = ''
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
existing = readFileSync(path, 'utf8')
|
|
338
|
+
} catch {
|
|
339
|
+
// No .gitignore yet — the addition below becomes the whole file.
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const addition = `# secret files — synced through EnvStash, not git\n${SECRET_FILE_DIR}/\n`
|
|
343
|
+
const body =
|
|
344
|
+
existing === '' ? addition : `${existing}${existing.endsWith('\n') ? '' : '\n'}\n${addition}`
|
|
345
|
+
|
|
346
|
+
writeFileSync(path, body)
|
|
347
|
+
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
283
351
|
/** Everything auto-discovery offers to track: root dotenv files, then `.config/` files. */
|
|
284
352
|
export function discoverTrackedFiles(repoRoot) {
|
|
285
353
|
return [...discoverEnvFiles(repoRoot), ...discoverSecretFiles(repoRoot)]
|