envstash-cli 0.5.2 → 0.6.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
@@ -14,8 +14,8 @@ short form `envs`. They are the same program.
14
14
 
15
15
  ```bash
16
16
  envs login # authorize this machine in the browser
17
- envs init # create a project, named after your git remote
18
- envs push # send your .env files up
17
+ envs init # create a project, named after your git remote, listing your .env files
18
+ envs push # send those files up
19
19
  ```
20
20
 
21
21
  On another machine, or a fresh clone:
@@ -24,22 +24,29 @@ On another machine, or a fresh clone:
24
24
  envs pull
25
25
  ```
26
26
 
27
+ Day to day, when both sides have moved:
28
+
29
+ ```bash
30
+ envs sync
31
+ ```
32
+
27
33
  ## Commands
28
34
 
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
- | `remove` | Delete files from the cloud (local copies stay) |
42
- | `version` | Show the CLI version and its environment |
35
+ | Command | What it does |
36
+ | --------- | ---------------------------------------------------- |
37
+ | `login` | Authorize this machine with a browser device code |
38
+ | `logout` | Revoke this machine's token |
39
+ | `whoami` | Show the signed-in account |
40
+ | `init` | Create a project and link this repo to it |
41
+ | `link` | Link this repo to a project that already exists |
42
+ | `list` | List every project in your account |
43
+ | `status` | Compare cloud files against local files |
44
+ | `diff` | Show which keys differ, and how |
45
+ | `pull` | Write the cloud's version of each file to disk |
46
+ | `push` | Send local files to the cloud |
47
+ | `sync` | Reconcile both directions at once, keeping every key |
48
+ | `remove` | Delete files from the cloud (local copies stay) |
49
+ | `version` | Show the CLI version and its environment |
43
50
 
44
51
  Run `envs help <command>` for details on any one of them.
45
52
 
@@ -111,14 +118,109 @@ definition and therefore has no undo:
111
118
  - **`push` never deletes.** Keys in the cloud that are absent from your local
112
119
  file are left alone, so pushing a partial `.env` cannot destroy a teammate's
113
120
  key. Use `--prune` when you do want the cloud to mirror local exactly.
114
- - **`diff` masks values.** Use `--show-values` to reveal them.
121
+ - **`push` lists new files before uploading them.** Any file that is not in the
122
+ cloud yet is shown, with its size, before the first upload. Files that
123
+ discovery found — not named on the command line and not listed in
124
+ `.envstash.json` — need a yes: `push` asks on a terminal, and anywhere else
125
+ (CI, a pipe) it stops unless you pass `--yes` (`-y`). Once a file is in the
126
+ cloud, later pushes of it go through without asking.
127
+ - **`diff` masks values**, showing at most the first 4 characters and the
128
+ length, and nothing at all of a value 12 characters or shorter. Use
129
+ `--show-values` to reveal them.
130
+ - **`sync` never deletes** in either direction, and never overwrites a value
131
+ that differs on both sides without being told which side wins.
115
132
  - **`remove` needs every file named** — there is no "remove everything"
116
133
  default — and it only touches the cloud copy; files on your machines stay.
117
- - Pulled files are written `0600`, and `~/.envstash/config.json` is `0600`
118
- inside a `0700` directory.
134
+ - Pulled files are written `0600` — replaced atomically, so a file that
135
+ already existed ends up `0600` too and a failed write leaves the old one —
136
+ and `~/.envstash/config.json` is written the same way, `0600` inside a `0700`
137
+ directory.
138
+ - **Symlinks are never followed.** A tracked file, or any directory between it
139
+ and the repo root, that is a symbolic link is refused by every command that
140
+ reads or writes it, and discovery skips it — so a cloned repo cannot point
141
+ `.env.local` at `~/.ssh/id_rsa` and have `push` upload it.
142
+ - **Some paths are never written**, whatever the server says: anything under
143
+ `.git/`, `node_modules/` or `.husky/`, and any `.envstash.json`. Files there
144
+ run code or redirect the CLI.
145
+ - **Plain `http://` is refused** except for `localhost`, `*.localhost`,
146
+ `127.0.0.1` and `::1`, since every request carries your token.
147
+ - Values containing `$` or a backtick are written single-quoted
148
+ (`PASS='p$ss'`), so bash, docker compose and dotenv-expand read them the
149
+ same way envstash does.
119
150
 
120
151
  Preview either direction with `--dry-run`.
121
152
 
153
+ ## `envs sync`
154
+
155
+ `pull` and `push` each ask you to have already decided which side is right.
156
+ Often neither is — a teammate added a key while you added a different one, and
157
+ what you want is both. That is `sync`:
158
+
159
+ ```
160
+ $ envs sync
161
+
162
+ Project demo-app · https://www.envstash.co
163
+
164
+ ✓ .env.local 1 key pulled, 1 key pushed · 1 conflict
165
+ ~ SESSION_SECRET (differs on both sides)
166
+ ✓ .env.staging new from the cloud (4 keys)
167
+ = .env.production already in sync
168
+
169
+ Synced 2 files · 5 keys down, 1 key up.
170
+ 1 file left alone — the same key holds different values on each side.
171
+ See them with envs diff, then choose a side: envs sync --prefer local or envs sync --prefer cloud
172
+ ```
173
+
174
+ | Situation | What `sync` does |
175
+ | ---------------------------------- | ----------------------------------- |
176
+ | File on disk, not in the cloud | pushes it |
177
+ | File in the cloud, not on disk | pulls it |
178
+ | Key on one side only | copies it to the other side |
179
+ | Key on both sides, different value | **conflict** — left alone, reported |
180
+ | Everything matches | nothing |
181
+
182
+ **It never deletes.** Sync does not prune in either direction. There is no
183
+ record of the last sync, so nothing can know whether a key that exists on one
184
+ side only is one you just added or one a teammate removed — the same limit
185
+ `envs status` acknowledges by saying `local-only` rather than `added`. Copying
186
+ is the guess whose failure mode is a key coming back, which the next
187
+ `envs status` shows you and `envs rm` undoes. Deleting is the guess whose
188
+ failure mode is a secret that exists nowhere any more.
189
+
190
+ **A conflict is never guessed at either.** When the same key holds different
191
+ values on each side, both copies are left exactly as they are, the key is
192
+ named, and `sync` exits **1** — so a hook or a CI job hears the difference
193
+ between "sync ran" and "the repo is in sync". `--exit-zero` opts out, as it
194
+ does for `status` and `diff`.
195
+
196
+ Resolve conflicts by naming a winner:
197
+
198
+ ```bash
199
+ envs sync --prefer local # your value wins
200
+ envs sync --prefer cloud # the cloud's value wins
201
+ ```
202
+
203
+ `--prefer` applies to conflicting keys only. Everything else still merges both
204
+ ways, so `--prefer cloud` is not `pull --force`: your local-only keys still go
205
+ up.
206
+
207
+ An opaque file (see below) that differs on both sides is a whole-file conflict
208
+ — half a certificate is not a certificate — so `--prefer` is the only way to
209
+ settle one.
210
+
211
+ Merging into a local file edits it in place: comments, blank lines and grouping
212
+ survive, and only the keys that moved are rewritten. (`pull` still regenerates
213
+ a file wholesale, because there the cloud's copy _is_ the answer.)
214
+
215
+ Preview with `--dry-run`, narrow to one file by naming it, and reach your
216
+ account-wide files with `--shared`:
217
+
218
+ ```bash
219
+ envs sync --dry-run
220
+ envs sync .env.local
221
+ envs sync --shared .npmrc
222
+ ```
223
+
122
224
  ## Shared files
123
225
 
124
226
  Some credentials follow you rather than a repo — a global `.npmrc`, an AI-tool
@@ -169,7 +271,8 @@ hold:
169
271
  - it is not a document (`.md`, `.txt`, `.doc`, `.pdf`, and the like — notes,
170
272
  not credentials),
171
273
  - it is under the 1 MB blob limit, and
172
- - its name is one the sync commands accept (`[A-Za-z0-9._-]` per segment).
274
+ - its name is one the sync commands accept (`[A-Za-z0-9._-]` per segment), and
275
+ - neither it nor any directory above it (up to the repo root) is a symlink.
173
276
 
174
277
  Being git-ignored is the signal for "secret": it is the repo's own statement
175
278
  that a file is not for sharing through git, which is exactly the population
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.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",
package/src/api.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { CliError } from './ui.mjs'
1
+ import { CliError, sanitize } from './ui.mjs'
2
2
 
3
3
  /** An error the server described in its JSON body. */
4
4
  export class ApiError extends CliError {
@@ -88,10 +88,12 @@ export async function request(
88
88
  )
89
89
  }
90
90
 
91
+ // Sanitized at the source as well as where errors are printed: this is the
92
+ // server's own wording, and anything that shows it should get it inert.
91
93
  throw new ApiError(
92
94
  response.status,
93
95
  payload.error ?? 'error',
94
- payload.message,
96
+ sanitize(payload.message),
95
97
  response.status === 401 ? 'Run `envs login` to sign in again.' : undefined
96
98
  )
97
99
  }
@@ -0,0 +1,112 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fsyncSync,
6
+ openSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { basename, dirname, join } from 'node:path'
12
+
13
+ /**
14
+ * Replace `path` with `body` all at once, with exactly `mode`.
15
+ *
16
+ * Two problems with writing in place, both about files that hold credentials.
17
+ * `writeFileSync`'s `mode` only applies when it creates the file, so pulling
18
+ * over an existing 0644 `.env` left it 0644 however carefully the new contents
19
+ * were chosen. And a crash, a full disk or Ctrl-C part-way through left half a
20
+ * file — for `config.json`, every stored token gone.
21
+ *
22
+ * So: write a sibling temp file (same directory, so the rename cannot cross a
23
+ * filesystem), flush it, set the mode, and rename it over the target. The rename
24
+ * replaces a directory entry, so readers see the old file or the new one, never
25
+ * a torn one, and the result has the temp file's mode regardless of what was
26
+ * there. `wx` means the temp name is created fresh and never opened through
27
+ * anything already sitting at that path.
28
+ *
29
+ * `rename`, `sleep` and `platform` are seams for tests; callers pass only `mode`.
30
+ */
31
+ export function writeFileAtomic(
32
+ path,
33
+ body,
34
+ { mode = 0o600, rename = renameSync, sleep = sleepSync, platform = process.platform } = {}
35
+ ) {
36
+ const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`)
37
+
38
+ try {
39
+ const fd = openSync(temp, 'wx', mode)
40
+
41
+ try {
42
+ writeFileSync(fd, body)
43
+ fsyncSync(fd)
44
+ } finally {
45
+ closeSync(fd)
46
+ }
47
+
48
+ try {
49
+ // Explicit, because `mode` on create is filtered through the umask.
50
+ chmodSync(temp, mode)
51
+ } catch {
52
+ // Windows and some network filesystems do not support POSIX modes.
53
+ }
54
+
55
+ renameWithRetry(temp, path, { rename, sleep, platform })
56
+ } catch (error) {
57
+ rmSync(temp, { force: true })
58
+ throw error
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Codes Windows returns, transiently, when renaming over a file that an editor,
64
+ * indexer or antivirus scanner has open at that moment. The same retry
65
+ * write-file-atomic and graceful-fs do.
66
+ */
67
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
68
+
69
+ /** Waits between the five attempts: about half a second in all. */
70
+ const RENAME_BACKOFF_MS = [50, 100, 150, 200]
71
+
72
+ /**
73
+ * Rename, retrying the transient Windows failures with a short backoff.
74
+ *
75
+ * Windows only: on POSIX those codes mean a real permission problem, and
76
+ * retrying would just delay the same error by half a second. Anything else, or
77
+ * the last attempt's failure, is thrown for the caller to clean up after.
78
+ */
79
+ export function renameWithRetry(
80
+ from,
81
+ to,
82
+ { rename = renameSync, sleep = sleepSync, platform = process.platform } = {}
83
+ ) {
84
+ for (let attempt = 0; ; attempt += 1) {
85
+ try {
86
+ rename(from, to)
87
+ return
88
+ } catch (error) {
89
+ const retryable =
90
+ platform === 'win32' &&
91
+ TRANSIENT_RENAME_CODES.has(error?.code) &&
92
+ attempt < RENAME_BACKOFF_MS.length
93
+
94
+ if (!retryable) {
95
+ throw error
96
+ }
97
+
98
+ sleep(RENAME_BACKOFF_MS[attempt])
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Block for `ms` without spinning. Synchronous on purpose: every caller of
105
+ * `writeFileAtomic` is synchronous (the config writes behind `saveToken` and
106
+ * `clearToken` are called from sync code throughout), and making the whole
107
+ * chain async for a wait that only happens on a Windows file lock is not worth
108
+ * it. `Atomics.wait` on a private buffer that nothing ever notifies is a sleep.
109
+ */
110
+ function sleepSync(ms) {
111
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
112
+ }
@@ -3,7 +3,7 @@ import { formatSize } from '../blob.mjs'
3
3
  import { cloudKinds, loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
4
4
  import { entriesToMap } from '../env-file.mjs'
5
5
  import { compareProject, driftExitCode, FILE_STATE, KEY_STATE } from '../status.mjs'
6
- import { maskValue, out, style } from '../ui.mjs'
6
+ import { maskValue, out, sanitize, style } from '../ui.mjs'
7
7
 
8
8
  /**
9
9
  * `envs diff` — the same comparison as `status`, with values.
@@ -102,10 +102,11 @@ export async function diff(flags, args) {
102
102
  }
103
103
 
104
104
  function renderKey(entry, local, cloud, showValues) {
105
- const reveal = value => (showValues ? value : maskValue(value))
105
+ const reveal = value => (showValues ? showValue(value) : maskValue(value))
106
+ const key = sanitize(entry.key)
106
107
 
107
108
  if (entry.state === KEY_STATE.MODIFIED) {
108
- out(` ${style.yellow('~')} ${entry.key}`)
109
+ out(` ${style.yellow('~')} ${key}`)
109
110
  out(
110
111
  ` ${style.red('cloud')} ${cloud ? reveal(cloud.get(entry.key) ?? '') : style.dim('•'.repeat(8))}`
111
112
  )
@@ -115,18 +116,28 @@ function renderKey(entry, local, cloud, showValues) {
115
116
 
116
117
  if (entry.state === KEY_STATE.LOCAL_ONLY) {
117
118
  out(
118
- ` ${style.green('+')} ${entry.key} ${reveal(local.get(entry.key) ?? '')} ${style.dim('(local only)')}`
119
+ ` ${style.green('+')} ${key} ${reveal(local.get(entry.key) ?? '')} ${style.dim('(local only)')}`
119
120
  )
120
121
  return
121
122
  }
122
123
 
123
124
  out(
124
- ` ${style.red('-')} ${entry.key} ${
125
+ ` ${style.red('-')} ${key} ${
125
126
  cloud ? reveal(cloud.get(entry.key) ?? '') : style.dim('•'.repeat(8))
126
127
  } ${style.dim('(cloud only)')}`
127
128
  )
128
129
  }
129
130
 
131
+ /**
132
+ * A revealed value, one line and inert. Values come from the server as well as
133
+ * the disk, so escape sequences are stripped; line breaks in a multi-line value
134
+ * (a PEM key) are shown as `\n` rather than breaking the diff's layout — or
135
+ * forging a line of it.
136
+ */
137
+ function showValue(value) {
138
+ return sanitize(value.replace(/\r/g, '\\r').replace(/\n/g, '\\n'))
139
+ }
140
+
130
141
  function describeFile(file) {
131
142
  if (file.state === FILE_STATE.CLOUD_ONLY) {
132
143
  return 'in the cloud, not on disk'
@@ -14,7 +14,7 @@ import {
14
14
  } from '../config.mjs'
15
15
  import { DEV_PORTS } from '../dev-host.mjs'
16
16
  import { isLinkChosenHost, resolveHostFor, resolveLoginHost } from '../session.mjs'
17
- import { CliError, out, style } from '../ui.mjs'
17
+ import { CliError, out, sanitize, style } from '../ui.mjs'
18
18
 
19
19
  /**
20
20
  * `envs login` — the OAuth device flow.
@@ -51,7 +51,7 @@ export async function login(flags) {
51
51
  const verificationUrl = safeVerificationUrl(started.verificationUrlComplete, host)
52
52
 
53
53
  out()
54
- out(` Your code: ${style.bold(style.cyan(started.userCode))}`)
54
+ out(` Your code: ${style.bold(style.cyan(sanitize(started.userCode)))}`)
55
55
  out()
56
56
  // The URL on its own line, in the same visible cyan as the code above it:
57
57
  // blue vanishes on dark terminals, and a line that is only the URL is what
@@ -98,7 +98,9 @@ export async function login(flags) {
98
98
  // command in every other repo talking to it.
99
99
  saveToken(host, token, who.email, { makeDefault: !fromLink })
100
100
 
101
- out(` ${style.green('✓')} Signed in to ${style.bold(host)} as ${style.bold(who.email)}`)
101
+ out(
102
+ ` ${style.green('✓')} Signed in to ${style.bold(host)} as ${style.bold(sanitize(who.email))}`
103
+ )
102
104
  out()
103
105
 
104
106
  return 0
@@ -110,8 +112,7 @@ export async function login(flags) {
110
112
  * a CliError with the server's own wording.
111
113
  */
112
114
  async function pollForToken(host, started) {
113
- const intervalMs = Math.max(1, started.intervalSeconds ?? 2) * 1000
114
- const deadline = new Date(started.expiresAt).getTime()
115
+ const { intervalMs, deadline } = pollSchedule(started)
115
116
 
116
117
  while (Date.now() < deadline) {
117
118
  await sleep(intervalMs)
@@ -128,6 +129,29 @@ async function pollForToken(host, started) {
128
129
  })
129
130
  }
130
131
 
132
+ /** The server's codes live 15 minutes; nothing legitimate needs longer. */
133
+ export const MAX_POLL_MS = 15 * 60 * 1000
134
+
135
+ const MAX_INTERVAL_MS = 30 * 1000
136
+
137
+ /**
138
+ * How often and until when to poll, from the server's answer — bounded here,
139
+ * because the server chose both numbers. An `expiresAt` a year out (or an
140
+ * unparseable one) would otherwise leave a terminal polling for a year, and an
141
+ * `intervalSeconds` of a million would leave it asleep.
142
+ */
143
+ export function pollSchedule(started, now = Date.now()) {
144
+ const seconds = Number(started.intervalSeconds ?? 2)
145
+ const intervalMs = Math.min(
146
+ MAX_INTERVAL_MS,
147
+ Math.max(1000, Number.isFinite(seconds) ? seconds * 1000 : 2000)
148
+ )
149
+ const cap = now + MAX_POLL_MS
150
+ const expires = new Date(started.expiresAt).getTime()
151
+
152
+ return { intervalMs, deadline: Number.isFinite(expires) ? Math.min(expires, cap) : cap }
153
+ }
154
+
131
155
  /** Name the device so the approval screen and the token list are recognisable. */
132
156
  function defaultLabel() {
133
157
  try {
@@ -12,7 +12,7 @@ import {
12
12
  writeLink,
13
13
  } from '../project.mjs'
14
14
  import { requireHostToken, resolveHostFor, resolveHostSource } from '../session.mjs'
15
- import { out, pad, pluralize, style } from '../ui.mjs'
15
+ import { out, pad, pluralize, sanitize, style } from '../ui.mjs'
16
16
  import { announceIgnoredSecretDir } from './push.mjs'
17
17
 
18
18
  /**
@@ -87,7 +87,9 @@ export async function whoami(flags) {
87
87
  }
88
88
 
89
89
  out()
90
- out(` ${style.bold(who.email)}${who.name ? style.dim(` (${who.name})`) : ''}`)
90
+ out(
91
+ ` ${style.bold(sanitize(who.email))}${who.name ? style.dim(` (${sanitize(who.name)})`) : ''}`
92
+ )
91
93
  out(` ${style.dim(host)}`)
92
94
  out()
93
95
 
@@ -112,12 +114,12 @@ export async function list(flags) {
112
114
  return 0
113
115
  }
114
116
 
115
- const width = Math.max(...projects.map(project => project.name.length)) + 4
117
+ const width = Math.max(...projects.map(project => sanitize(project.name).length)) + 4
116
118
 
117
119
  for (const project of projects) {
118
120
  out(
119
- ` ${pad(style.bold(project.name), width)}${style.dim(
120
- `${pluralize(project.fileCount, 'file')}, ${pluralize(project.secretCount, 'key')}`
121
+ ` ${pad(style.bold(sanitize(project.name)), width)}${style.dim(
122
+ `${pluralize(Number(project.fileCount) || 0, 'file')}, ${pluralize(Number(project.secretCount) || 0, 'key')}`
121
123
  )}`
122
124
  )
123
125
  }
@@ -194,7 +196,7 @@ export async function link(flags, args) {
194
196
  if (projects?.length) {
195
197
  out(` ${style.dim('Available projects:')}`)
196
198
  for (const project of projects) {
197
- out(` ${style.dim('·')} ${project.name}`)
199
+ out(` ${style.dim('·')} ${sanitize(project.name)}`)
198
200
  }
199
201
  out()
200
202
  }
@@ -1,6 +1,3 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
2
- import { dirname } from 'node:path'
3
-
4
1
  import { api, ApiError } from '../api.mjs'
5
2
  import { formatSize } from '../blob.mjs'
6
3
  import {
@@ -11,7 +8,7 @@ import {
11
8
  SHARED_SCOPE,
12
9
  } from '../context.mjs'
13
10
  import { serializeEnv } from '../env-file.mjs'
14
- import { resolveInsideRepo } from '../repo-path.mjs'
11
+ import { writeSecretFile } from '../local-write.mjs'
15
12
  import { compareProject, FILE_STATE } from '../status.mjs'
16
13
  import { CliError, out, pluralize, style } from '../ui.mjs'
17
14
 
@@ -67,6 +64,7 @@ export async function pull(flags, args) {
67
64
  link: context.link,
68
65
  cloudFiles,
69
66
  requested: args,
67
+ sink: 'write',
70
68
  }).filter(name => cloudFiles.some(file => file.name === name))
71
69
 
72
70
  if (names.length === 0) {
@@ -126,20 +124,12 @@ export async function pull(flags, args) {
126
124
  ? `(file, ${formatSize(body.length)})`
127
125
  : `(${pluralize(response.entries?.length ?? 0, 'key')})`
128
126
 
129
- // Re-checked at the sink, not only where the name entered. This is the call
130
- // that creates directories and writes bytes, so it is the one that has to
131
- // be certain the path is inside the repo.
132
- const path = resolveInsideRepo(context.repoRoot, file.name)
133
-
134
127
  if (flags['dry-run']) {
135
128
  out(` ${style.dim('would write')} ${file.name} ${style.dim(detail)}`)
136
129
  continue
137
130
  }
138
131
 
139
- mkdirSync(dirname(path), { recursive: true })
140
- // 0600: a pulled secret holds live credentials and has no business being
141
- // readable by other accounts on a shared machine.
142
- writeFileSync(path, body, { mode: 0o600 })
132
+ writeSecretFile(context.repoRoot, file.name, body)
143
133
  written += 1
144
134
 
145
135
  out(` ${style.green('✓')} ${file.name} ${style.dim(detail)}`)