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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Git-style CLI for EnvStash — keep your .env files in sync with your cloud account.",
5
5
  "keywords": [
6
6
  "env",
@@ -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 { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
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, context.project.name)
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
- out(
48
- ` ${style.dim('Nothing to pull —')} ${style.bold(context.project.name)} ${style.dim('has no files in the cloud.')}`
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, context.project.name, file.name)
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.
@@ -1,6 +1,12 @@
1
1
  import { api, ApiError } from '../api.mjs'
2
2
  import { formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
3
- import { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
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
- context.project.name,
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
- out(` ${style.dim(`Pushed ${pluralize(pushed, 'file')} to ${context.project.name}.`)}`)
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
- context.project.name,
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, context.project.name)
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: the server creates it on write.
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.',