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 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.3",
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",
@@ -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
 
@@ -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,9 +1,29 @@
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'
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 cloudFiles = await fetchCloudFiles(context)
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
- context.project.name,
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
- 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}.`)}`)
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
- context.project.name,
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, context.project.name)
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: 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.
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)]