envstash-cli 0.5.2 → 0.6.1
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 +123 -20
- package/package.json +1 -1
- package/src/api.mjs +4 -2
- package/src/atomic-write.mjs +112 -0
- package/src/commands/diff.mjs +16 -5
- package/src/commands/login.mjs +29 -5
- package/src/commands/misc.mjs +8 -6
- package/src/commands/pull.mjs +3 -13
- package/src/commands/push.mjs +101 -9
- package/src/commands/status.mjs +4 -2
- package/src/commands/sync.mjs +371 -0
- package/src/config.mjs +53 -10
- package/src/context.mjs +43 -6
- package/src/env-file.mjs +100 -19
- package/src/index.mjs +42 -9
- package/src/local-write.mjs +34 -0
- package/src/manifest.mjs +1 -0
- package/src/project.mjs +38 -7
- package/src/repo-path.mjs +103 -1
- package/src/session.mjs +10 -5
- package/src/status.mjs +10 -2
- package/src/sync.mjs +137 -0
- package/src/ui.mjs +84 -5
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
|
|
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
|
-
| `
|
|
42
|
-
| `
|
|
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
|
-
- **`
|
|
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
|
|
118
|
-
|
|
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
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
|
+
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -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('~')} ${
|
|
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('+')} ${
|
|
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('-')} ${
|
|
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'
|
package/src/commands/login.mjs
CHANGED
|
@@ -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(
|
|
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 =
|
|
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 {
|
package/src/commands/misc.mjs
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/commands/pull.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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)}`)
|