envstash-cli 0.4.4 → 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/pull.mjs +40 -6
- package/src/commands/push.mjs +34 -10
- package/src/context.mjs +9 -0
- package/src/index.mjs +10 -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/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,6 +1,12 @@
|
|
|
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'
|
|
4
10
|
import { ensureSecretDirIgnored, SECRET_FILE_DIR } from '../project.mjs'
|
|
5
11
|
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
6
12
|
import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
@@ -25,9 +31,24 @@ export function announceIgnoredSecretDir(repoRoot) {
|
|
|
25
31
|
* is passed. Pushing a partial `.env` is normal — a developer working on one
|
|
26
32
|
* feature often has only the keys they need — and treating absence as deletion
|
|
27
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.
|
|
28
40
|
*/
|
|
29
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
|
+
|
|
30
50
|
const context = resolveContext(flags)
|
|
51
|
+
const scope = shared ? SHARED_SCOPE : context.project.name
|
|
31
52
|
|
|
32
53
|
// Before discovery, so files in a not-yet-ignored .config/ are picked up by
|
|
33
54
|
// this very push rather than the next one. Not under --dry-run: a preview
|
|
@@ -36,7 +57,7 @@ export async function push(flags, args) {
|
|
|
36
57
|
announceIgnoredSecretDir(context.repoRoot)
|
|
37
58
|
}
|
|
38
59
|
|
|
39
|
-
const cloudFiles = await fetchCloudFiles(context)
|
|
60
|
+
const cloudFiles = await fetchCloudFiles(context, scope)
|
|
40
61
|
|
|
41
62
|
const names = resolveFileNames({
|
|
42
63
|
repoRoot: context.repoRoot,
|
|
@@ -70,7 +91,7 @@ export async function push(flags, args) {
|
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
if (file.kind === 'file') {
|
|
73
|
-
pushed += await pushOneBlob(flags, context, file, localFiles.get(file.name))
|
|
94
|
+
pushed += await pushOneBlob(flags, context, scope, file, localFiles.get(file.name))
|
|
74
95
|
continue
|
|
75
96
|
}
|
|
76
97
|
|
|
@@ -90,7 +111,7 @@ export async function push(flags, args) {
|
|
|
90
111
|
const result = await api.putFile(
|
|
91
112
|
context.host,
|
|
92
113
|
context.token,
|
|
93
|
-
|
|
114
|
+
scope,
|
|
94
115
|
file.name,
|
|
95
116
|
entries,
|
|
96
117
|
Boolean(flags.prune)
|
|
@@ -130,7 +151,9 @@ export async function push(flags, args) {
|
|
|
130
151
|
out()
|
|
131
152
|
|
|
132
153
|
if (!flags['dry-run'] && pushed > 0) {
|
|
133
|
-
|
|
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}.`)}`)
|
|
134
157
|
out()
|
|
135
158
|
}
|
|
136
159
|
|
|
@@ -142,7 +165,7 @@ export async function push(flags, args) {
|
|
|
142
165
|
* the report is created / updated / unchanged, with the size, because "what
|
|
143
166
|
* went up" for a blob is the file itself.
|
|
144
167
|
*/
|
|
145
|
-
async function pushOneBlob(flags, context, file, local) {
|
|
168
|
+
async function pushOneBlob(flags, context, scope, file, local) {
|
|
146
169
|
if (local.bytes.length > MAX_BLOB_BYTES) {
|
|
147
170
|
throw new CliError(
|
|
148
171
|
`${file.name} is ${formatSize(local.bytes.length)} — files are capped at ${formatSize(MAX_BLOB_BYTES)}.`,
|
|
@@ -160,7 +183,7 @@ async function pushOneBlob(flags, context, file, local) {
|
|
|
160
183
|
const result = await api.putBlob(
|
|
161
184
|
context.host,
|
|
162
185
|
context.token,
|
|
163
|
-
|
|
186
|
+
scope,
|
|
164
187
|
file.name,
|
|
165
188
|
local.bytes.toString('base64')
|
|
166
189
|
)
|
|
@@ -178,13 +201,14 @@ async function pushOneBlob(flags, context, file, local) {
|
|
|
178
201
|
return 1
|
|
179
202
|
}
|
|
180
203
|
|
|
181
|
-
async function fetchCloudFiles(context) {
|
|
204
|
+
async function fetchCloudFiles(context, scope) {
|
|
182
205
|
try {
|
|
183
|
-
const response = await api.projectStatus(context.host, context.token,
|
|
206
|
+
const response = await api.projectStatus(context.host, context.token, scope)
|
|
184
207
|
return response.files ?? []
|
|
185
208
|
} catch (error) {
|
|
186
209
|
if (error instanceof ApiError && error.status === 404) {
|
|
187
|
-
// 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.
|
|
188
212
|
return []
|
|
189
213
|
}
|
|
190
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.',
|