envstash-cli 0.6.1 → 0.7.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 +19 -0
- package/package.json +1 -1
- package/src/api.mjs +195 -18
- package/src/commands/diff.mjs +1 -1
- package/src/commands/login.mjs +10 -5
- package/src/commands/misc.mjs +4 -4
- package/src/commands/pull.mjs +1 -1
- package/src/commands/push.mjs +1 -1
- package/src/commands/remove.mjs +1 -1
- package/src/commands/status.mjs +1 -1
- package/src/commands/sync.mjs +1 -1
- package/src/config.mjs +107 -8
- package/src/context.mjs +2 -2
- package/src/index.mjs +10 -0
- package/src/reachability.mjs +70 -0
- package/src/session.mjs +38 -6
package/README.md
CHANGED
|
@@ -362,6 +362,25 @@ server you meant to exclude.
|
|
|
362
362
|
was to a dev server: where the previous login went is not an instruction about
|
|
363
363
|
where the next one should go.
|
|
364
364
|
|
|
365
|
+
`https://www.envstash.co` is always tried first. If it cannot be reached — DNS
|
|
366
|
+
fails, the connection is refused, or nothing completes a TLS handshake within 3
|
|
367
|
+
seconds — the CLI uses `https://envstash-next.vercel.app` instead, the same
|
|
368
|
+
production deployment under Vercel's domain, and says so once on stderr. The
|
|
369
|
+
check sends no request, so it is as safe ahead of a write as a read. Its answer
|
|
370
|
+
is remembered for 10 minutes, so the commands after it go straight to whichever
|
|
371
|
+
host works; a domain that shakes hands and then never answers is caught by a
|
|
372
|
+
5-second limit on reads as well.
|
|
373
|
+
|
|
374
|
+
The two production addresses share one sign-in: signing in on either works for
|
|
375
|
+
both, so there is no `--host` to pass for the fallback. Only the default host
|
|
376
|
+
falls back; a `--host`, `--port`, `ENVSTASH_HOST`, or a repo's link file is used
|
|
377
|
+
as given. An HTTP error is an answer and is not retried, and neither is a write
|
|
378
|
+
whose connection drops after it was sent.
|
|
379
|
+
|
|
380
|
+
A command that needs a sign-in and finds none signs in first when it is run at a
|
|
381
|
+
terminal, then carries on. In CI, piped, with `--json`, or for a host a cloned
|
|
382
|
+
repo's link file chose, it stops with "Not signed in" instead.
|
|
383
|
+
|
|
365
384
|
To sign in to a local server, name it — `envs login --port 3001` — or let the
|
|
366
385
|
CLI find it:
|
|
367
386
|
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -1,4 +1,120 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_HOST,
|
|
3
|
+
PRODUCTION_FALLBACK_HOST,
|
|
4
|
+
rememberedRoute,
|
|
5
|
+
rememberRoute,
|
|
6
|
+
} from './config.mjs'
|
|
7
|
+
import { canReach } from './reachability.mjs'
|
|
8
|
+
import { CliError, errOut, sanitize, style } from './ui.mjs'
|
|
9
|
+
|
|
10
|
+
export { PRODUCTION_FALLBACK_HOST }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Failures where the request never reached a server, so repeating it against
|
|
14
|
+
* the fallback cannot apply a write twice. A reset or a timeout mid-response is
|
|
15
|
+
* deliberately absent: the first server may already have acted on it.
|
|
16
|
+
*/
|
|
17
|
+
const UNREACHABLE_CODES = new Set([
|
|
18
|
+
'ENOTFOUND',
|
|
19
|
+
'EAI_AGAIN',
|
|
20
|
+
'ECONNREFUSED',
|
|
21
|
+
'EHOSTUNREACH',
|
|
22
|
+
'ENETUNREACH',
|
|
23
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How long the default host gets to complete a TLS handshake before a command
|
|
28
|
+
* goes to the fallback instead. Nothing is sent, so this decides the route for
|
|
29
|
+
* writes as safely as for reads.
|
|
30
|
+
*/
|
|
31
|
+
export const PROBE_TIMEOUT_MS = 3000
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long the default host gets to start answering a request, for a domain
|
|
35
|
+
* that shakes hands and then says nothing. Only requests that are safe to send
|
|
36
|
+
* twice get this budget, because an abort cannot tell "never arrived" from
|
|
37
|
+
* "arrived and still working".
|
|
38
|
+
*/
|
|
39
|
+
export const RESPONSE_TIMEOUT_MS = 5000
|
|
40
|
+
|
|
41
|
+
/** How the default host is reached this process: 'direct', 'fallback', or undecided. */
|
|
42
|
+
let route = null
|
|
43
|
+
/** The probe in flight, so concurrent first requests share one. */
|
|
44
|
+
let deciding = null
|
|
45
|
+
/** Swappable so tests never touch the network. */
|
|
46
|
+
let probe = canReach
|
|
47
|
+
/** Set when a request was given up on while its connection was still open. */
|
|
48
|
+
let abandoned = false
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True once a request to the default host was abandoned mid-connect. Aborting
|
|
52
|
+
* rejects the fetch on time, but the socket — or a system DNS lookup behind
|
|
53
|
+
* it, which cannot be cancelled at all — holds the process open well after the
|
|
54
|
+
* work is done.
|
|
55
|
+
*/
|
|
56
|
+
export function abandonedConnection() {
|
|
57
|
+
return abandoned
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onFallback() {
|
|
61
|
+
if (route === null) {
|
|
62
|
+
route = rememberedRoute()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return route === 'fallback'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Switch the rest of this command, and the next few, to the fallback. */
|
|
69
|
+
function fallBack(host) {
|
|
70
|
+
route = 'fallback'
|
|
71
|
+
rememberRoute('fallback')
|
|
72
|
+
errOut(` ${style.dim(`${host} is not answering — using ${PRODUCTION_FALLBACK_HOST}.`)}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Settle the route before the first request to the default host: from an
|
|
77
|
+
* earlier command's memory, or by probing. Deciding up front is what keeps a
|
|
78
|
+
* dead domain from ever reaching fetch, whose DNS lookup could not be undone.
|
|
79
|
+
*/
|
|
80
|
+
async function decideRoute(host) {
|
|
81
|
+
if (host !== DEFAULT_HOST || onFallback() || route === 'direct') {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
deciding ??= (async () => {
|
|
86
|
+
if (await probe(DEFAULT_HOST, PROBE_TIMEOUT_MS)) {
|
|
87
|
+
route = 'direct'
|
|
88
|
+
rememberRoute('direct')
|
|
89
|
+
} else {
|
|
90
|
+
fallBack(DEFAULT_HOST)
|
|
91
|
+
}
|
|
92
|
+
})()
|
|
93
|
+
|
|
94
|
+
await deciding
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The origin requests for `host` are actually going to. Differs from `host`
|
|
99
|
+
* only after the default host was found unreachable — which is what login needs
|
|
100
|
+
* to know, since the approval link it is handed comes from whichever answered.
|
|
101
|
+
*/
|
|
102
|
+
export function effectiveHost(host) {
|
|
103
|
+
return host === DEFAULT_HOST && onFallback() ? PRODUCTION_FALLBACK_HOST : host
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Test hook: forget every routing decision, here and on disk, and set the probe. */
|
|
107
|
+
export function resetHostFallback({ probe: nextProbe = canReach } = {}) {
|
|
108
|
+
route = null
|
|
109
|
+
deciding = null
|
|
110
|
+
probe = nextProbe
|
|
111
|
+
abandoned = false
|
|
112
|
+
rememberRoute(null)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isUnreachable(error) {
|
|
116
|
+
return UNREACHABLE_CODES.has(error?.cause?.code) || UNREACHABLE_CODES.has(error?.code)
|
|
117
|
+
}
|
|
2
118
|
|
|
3
119
|
/** An error the server described in its JSON body. */
|
|
4
120
|
export class ApiError extends CliError {
|
|
@@ -18,11 +134,8 @@ export class ApiError extends CliError {
|
|
|
18
134
|
* is not JSON (a proxy error page, a 502 from the edge) is reported as a
|
|
19
135
|
* transport problem rather than crashing on a parse.
|
|
20
136
|
*/
|
|
21
|
-
export async function request(
|
|
22
|
-
|
|
23
|
-
path,
|
|
24
|
-
{ method = 'GET', token, body, signal, headers: extraHeaders } = {}
|
|
25
|
-
) {
|
|
137
|
+
export async function request(host, path, options = {}) {
|
|
138
|
+
const { method = 'GET', token, body, signal, headers: extraHeaders, repeatable } = options
|
|
26
139
|
const headers = { accept: 'application/json', ...extraHeaders }
|
|
27
140
|
|
|
28
141
|
if (token) {
|
|
@@ -33,23 +146,47 @@ export async function request(
|
|
|
33
146
|
headers['content-type'] = 'application/json'
|
|
34
147
|
}
|
|
35
148
|
|
|
149
|
+
await decideRoute(host)
|
|
150
|
+
|
|
151
|
+
// Not once on the fallback: there is nowhere further to go, and a slow
|
|
152
|
+
// answer is better than none.
|
|
153
|
+
const budgeted =
|
|
154
|
+
host === DEFAULT_HOST && !onFallback() && (method === 'GET' || repeatable === true)
|
|
155
|
+
const deadline = budgeted ? withDeadline(signal, RESPONSE_TIMEOUT_MS) : null
|
|
156
|
+
|
|
157
|
+
const init = {
|
|
158
|
+
method,
|
|
159
|
+
headers,
|
|
160
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
161
|
+
signal: deadline?.signal ?? signal,
|
|
162
|
+
}
|
|
163
|
+
|
|
36
164
|
let response
|
|
37
165
|
|
|
38
166
|
try {
|
|
39
|
-
response = await fetch(`${host}${path}`,
|
|
40
|
-
method,
|
|
41
|
-
headers,
|
|
42
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
43
|
-
signal,
|
|
44
|
-
})
|
|
167
|
+
response = await fetch(`${effectiveHost(host)}${path}`, init)
|
|
45
168
|
} catch (error) {
|
|
46
|
-
|
|
169
|
+
const timedOut = deadline?.expired() === true
|
|
170
|
+
|
|
171
|
+
if (error?.name === 'AbortError' && !timedOut) {
|
|
47
172
|
throw error
|
|
48
173
|
}
|
|
49
174
|
|
|
50
|
-
|
|
175
|
+
// The backstop for a domain that passed the probe and then failed anyway.
|
|
176
|
+
if (host === DEFAULT_HOST && !onFallback() && (timedOut || isUnreachable(error))) {
|
|
177
|
+
abandoned ||= timedOut
|
|
178
|
+
fallBack(host)
|
|
179
|
+
|
|
180
|
+
return request(host, path, options)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new CliError(`Could not reach ${effectiveHost(host)}.`, {
|
|
51
184
|
hint: 'Check your connection, or pass --host if you meant a different server.',
|
|
52
185
|
})
|
|
186
|
+
} finally {
|
|
187
|
+
// Stopped once the headers are in: the budget is for starting to answer,
|
|
188
|
+
// and a large body still arriving is an answer.
|
|
189
|
+
deadline?.clear()
|
|
53
190
|
}
|
|
54
191
|
|
|
55
192
|
const text = await response.text()
|
|
@@ -69,7 +206,7 @@ export async function request(
|
|
|
69
206
|
// header when a redirect crosses origins, so the server never saw it. Say
|
|
70
207
|
// that instead of sending the user round the login loop again.
|
|
71
208
|
const crossOriginRedirect =
|
|
72
|
-
token && response.status === 401 && redirectedAcrossOrigin(host, response)
|
|
209
|
+
token && response.status === 401 && redirectedAcrossOrigin(effectiveHost(host), response)
|
|
73
210
|
|
|
74
211
|
if (crossOriginRedirect) {
|
|
75
212
|
throw new ApiError(
|
|
@@ -101,6 +238,37 @@ export async function request(
|
|
|
101
238
|
return payload ?? {}
|
|
102
239
|
}
|
|
103
240
|
|
|
241
|
+
/**
|
|
242
|
+
* An abort signal that fires after `ms`, or when the caller's own signal does.
|
|
243
|
+
* Built by hand rather than with AbortSignal.any/timeout so it runs on the
|
|
244
|
+
* Node 18 the package still supports, and so a caller's abort stays
|
|
245
|
+
* distinguishable from the deadline.
|
|
246
|
+
*/
|
|
247
|
+
function withDeadline(outer, ms) {
|
|
248
|
+
const controller = new AbortController()
|
|
249
|
+
let expired = false
|
|
250
|
+
|
|
251
|
+
const onOuterAbort = () => controller.abort(outer.reason)
|
|
252
|
+
const timer = setTimeout(() => {
|
|
253
|
+
expired = true
|
|
254
|
+
controller.abort()
|
|
255
|
+
}, ms)
|
|
256
|
+
|
|
257
|
+
if (outer?.aborted) {
|
|
258
|
+
onOuterAbort()
|
|
259
|
+
} else {
|
|
260
|
+
outer?.addEventListener('abort', onOuterAbort, { once: true })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
signal: controller.signal,
|
|
265
|
+
expired: () => expired,
|
|
266
|
+
// Only the timer: the caller's own abort still has to reach a body that
|
|
267
|
+
// is mid-read.
|
|
268
|
+
clear: () => clearTimeout(timer),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
104
272
|
/**
|
|
105
273
|
* The origin a request ended up on, when following redirects moved it off the
|
|
106
274
|
* origin it started on. Returns null otherwise — including when the response
|
|
@@ -121,13 +289,22 @@ function redirectedAcrossOrigin(host, response) {
|
|
|
121
289
|
}
|
|
122
290
|
|
|
123
291
|
export const api = {
|
|
292
|
+
// A repeat of these is harmless — a second code nobody approves, a second
|
|
293
|
+
// look at the same one, a second revocation — so they may be retried on the
|
|
294
|
+
// fallback after a timeout, like reads.
|
|
124
295
|
startDeviceAuth: (host, label) =>
|
|
125
|
-
request(host, '/api/cli/auth/device', { method: 'POST', body: { label } }),
|
|
296
|
+
request(host, '/api/cli/auth/device', { method: 'POST', body: { label }, repeatable: true }),
|
|
126
297
|
|
|
127
298
|
pollDeviceAuth: (host, deviceCode, signal) =>
|
|
128
|
-
request(host, '/api/cli/auth/token', {
|
|
299
|
+
request(host, '/api/cli/auth/token', {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
body: { deviceCode },
|
|
302
|
+
signal,
|
|
303
|
+
repeatable: true,
|
|
304
|
+
}),
|
|
129
305
|
|
|
130
|
-
logout: (host, token) =>
|
|
306
|
+
logout: (host, token) =>
|
|
307
|
+
request(host, '/api/cli/auth/logout', { method: 'POST', token, repeatable: true }),
|
|
131
308
|
|
|
132
309
|
whoami: (host, token) => request(host, '/api/cli/whoami', { token }),
|
|
133
310
|
|
package/src/commands/diff.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { maskValue, out, sanitize, style } from '../ui.mjs'
|
|
|
14
14
|
* revealing a secret a deliberate act.
|
|
15
15
|
*/
|
|
16
16
|
export async function diff(flags, args) {
|
|
17
|
-
const context = resolveContext(flags)
|
|
17
|
+
const context = await resolveContext(flags)
|
|
18
18
|
const cloudFiles = await fetchCloudFiles(context)
|
|
19
19
|
|
|
20
20
|
const names = resolveFileNames({
|
package/src/commands/login.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'
|
|
|
2
2
|
import { hostname, userInfo } from 'node:os'
|
|
3
3
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
4
|
|
|
5
|
-
import { api } from '../api.mjs'
|
|
5
|
+
import { api, effectiveHost } from '../api.mjs'
|
|
6
6
|
import {
|
|
7
7
|
clearAllCredentials,
|
|
8
8
|
clearToken,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isKnownHost,
|
|
12
12
|
readConfig,
|
|
13
13
|
saveToken,
|
|
14
|
+
storedTokens,
|
|
14
15
|
} from '../config.mjs'
|
|
15
16
|
import { DEV_PORTS } from '../dev-host.mjs'
|
|
16
17
|
import { isLinkChosenHost, resolveHostFor, resolveLoginHost } from '../session.mjs'
|
|
@@ -48,7 +49,9 @@ export async function login(flags) {
|
|
|
48
49
|
const started = await api.startDeviceAuth(host, label)
|
|
49
50
|
// The server picks this URL and the CLI is about to hand it to the operating
|
|
50
51
|
// system, so it is checked before it is shown or opened rather than after.
|
|
51
|
-
|
|
52
|
+
// Against the host that answered: if production fell back to its Vercel
|
|
53
|
+
// domain, the link legitimately points there.
|
|
54
|
+
const verificationUrl = safeVerificationUrl(started.verificationUrlComplete, effectiveHost(host))
|
|
52
55
|
|
|
53
56
|
out()
|
|
54
57
|
out(` Your code: ${style.bold(style.cyan(sanitize(started.userCode)))}`)
|
|
@@ -58,7 +61,7 @@ export async function login(flags) {
|
|
|
58
61
|
// lets a terminal's double-click and link detection grab the whole thing.
|
|
59
62
|
out(` ${style.dim('Open this link to approve:')}`)
|
|
60
63
|
out()
|
|
61
|
-
out(` ${style.cyan(verificationUrl ?? `${host}/cli`)}`)
|
|
64
|
+
out(` ${style.cyan(verificationUrl ?? `${effectiveHost(host)}/cli`)}`)
|
|
62
65
|
out()
|
|
63
66
|
|
|
64
67
|
if (!verificationUrl) {
|
|
@@ -303,11 +306,13 @@ export async function logout(flags) {
|
|
|
303
306
|
return 0
|
|
304
307
|
}
|
|
305
308
|
|
|
306
|
-
|
|
309
|
+
// Every token for this server: the two production names share one, but a
|
|
310
|
+
// machine that signed in under each before they did holds two.
|
|
311
|
+
const tokens = new Set([getToken(host), ...storedTokens(host)].filter(Boolean))
|
|
307
312
|
|
|
308
313
|
// Revoke server-side first. A local delete alone leaves a live token that
|
|
309
314
|
// still works for anyone who copied the config file.
|
|
310
|
-
|
|
315
|
+
for (const token of tokens) {
|
|
311
316
|
try {
|
|
312
317
|
await api.logout(host, token)
|
|
313
318
|
} catch {
|
package/src/commands/misc.mjs
CHANGED
|
@@ -78,7 +78,7 @@ function sourceLabel(source) {
|
|
|
78
78
|
|
|
79
79
|
/** `envs whoami` — which account this machine is signed in as. */
|
|
80
80
|
export async function whoami(flags) {
|
|
81
|
-
const { host, token } = requireHostToken(flags)
|
|
81
|
+
const { host, token } = await requireHostToken(flags)
|
|
82
82
|
const who = await api.whoami(host, token)
|
|
83
83
|
|
|
84
84
|
if (flags.json) {
|
|
@@ -98,7 +98,7 @@ export async function whoami(flags) {
|
|
|
98
98
|
|
|
99
99
|
/** `envs list` — every project in the account. */
|
|
100
100
|
export async function list(flags) {
|
|
101
|
-
const { host, token } = requireHostToken(flags)
|
|
101
|
+
const { host, token } = await requireHostToken(flags)
|
|
102
102
|
const { projects } = await api.listProjects(host, token)
|
|
103
103
|
|
|
104
104
|
if (flags.json) {
|
|
@@ -136,7 +136,7 @@ export async function list(flags) {
|
|
|
136
136
|
* gets the project name and file list for free and only has to run `envs pull`.
|
|
137
137
|
*/
|
|
138
138
|
export async function init(flags, args) {
|
|
139
|
-
const { host, token } = requireHostToken(flags)
|
|
139
|
+
const { host, token } = await requireHostToken(flags)
|
|
140
140
|
const repoRoot = findRepoRoot()
|
|
141
141
|
const existing = readLink(repoRoot)
|
|
142
142
|
const name = args[0] ?? flags.project ?? resolveProject(repoRoot, undefined).name
|
|
@@ -181,7 +181,7 @@ export async function init(flags, args) {
|
|
|
181
181
|
* The counterpart to `init` for a repo whose secrets are already in the cloud.
|
|
182
182
|
*/
|
|
183
183
|
export async function link(flags, args) {
|
|
184
|
-
const { host, token } = requireHostToken(flags)
|
|
184
|
+
const { host, token } = await requireHostToken(flags)
|
|
185
185
|
const repoRoot = findRepoRoot()
|
|
186
186
|
const existing = readLink(repoRoot)
|
|
187
187
|
const name = args[0] ?? flags.project
|
package/src/commands/pull.mjs
CHANGED
package/src/commands/push.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export async function push(flags, args) {
|
|
|
50
50
|
})
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
const context = resolveContext(flags)
|
|
53
|
+
const context = await resolveContext(flags)
|
|
54
54
|
const scope = shared ? SHARED_SCOPE : context.project.name
|
|
55
55
|
|
|
56
56
|
// Before discovery, so files in a not-yet-ignored .config/ are picked up by
|
package/src/commands/remove.mjs
CHANGED
package/src/commands/status.mjs
CHANGED
|
@@ -25,7 +25,7 @@ const KEY_MARK = {
|
|
|
25
25
|
* ever knowing what the value is.
|
|
26
26
|
*/
|
|
27
27
|
export async function status(flags, args) {
|
|
28
|
-
const context = resolveContext(flags)
|
|
28
|
+
const context = await resolveContext(flags)
|
|
29
29
|
const cloudFiles = await fetchCloudFiles(context)
|
|
30
30
|
|
|
31
31
|
const names = resolveFileNames({
|
package/src/commands/sync.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export async function sync(flags, args) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const prefer = resolvePrefer(flags)
|
|
49
|
-
const context = resolveContext(flags)
|
|
49
|
+
const context = await resolveContext(flags)
|
|
50
50
|
const scope = shared ? SHARED_SCOPE : context.project.name
|
|
51
51
|
|
|
52
52
|
// Same reasoning as `push`: before discovery, so a file in a not-yet-ignored
|
package/src/config.mjs
CHANGED
|
@@ -13,6 +13,51 @@ import { CliError } from './ui.mjs'
|
|
|
13
13
|
*/
|
|
14
14
|
export const DEFAULT_HOST = 'https://www.envstash.co'
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The same production deployment under Vercel's own domain. It exists so a
|
|
18
|
+
* problem with the custom domain — DNS, a lapsed registration, a certificate —
|
|
19
|
+
* does not take the CLI down with it while the app itself is healthy.
|
|
20
|
+
*
|
|
21
|
+
* Only the default host falls back, and only to this fixed address: a host the
|
|
22
|
+
* developer or a repo named is theirs to reach or not, and a fallback that
|
|
23
|
+
* followed it anywhere would be a way to redirect their token.
|
|
24
|
+
*/
|
|
25
|
+
export const PRODUCTION_FALLBACK_HOST = 'https://envstash-next.vercel.app'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The key a host's credential is stored under. The two production names are
|
|
29
|
+
* one server with one database, so they share one credential: signing in on
|
|
30
|
+
* either works for both, and a fallback never finds itself signed out.
|
|
31
|
+
*/
|
|
32
|
+
function credentialKey(host) {
|
|
33
|
+
return host === PRODUCTION_FALLBACK_HOST ? DEFAULT_HOST : host
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The stored entry for a host. Also reads the Vercel domain's own key, where a
|
|
38
|
+
* login from before the two were merged left its credential.
|
|
39
|
+
*/
|
|
40
|
+
function storedEntry(hosts, host) {
|
|
41
|
+
return (
|
|
42
|
+
hosts[credentialKey(host)] ??
|
|
43
|
+
(credentialKey(host) === DEFAULT_HOST ? hosts[PRODUCTION_FALLBACK_HOST] : undefined) ??
|
|
44
|
+
null
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every distinct token stored for a host's server, so a logout can revoke all
|
|
50
|
+
* of them — a machine that signed in under both production names before they
|
|
51
|
+
* shared a key holds two.
|
|
52
|
+
*/
|
|
53
|
+
export function storedTokens(host) {
|
|
54
|
+
const { hosts } = readConfig()
|
|
55
|
+
const keys =
|
|
56
|
+
credentialKey(host) === DEFAULT_HOST ? [DEFAULT_HOST, PRODUCTION_FALLBACK_HOST] : [host]
|
|
57
|
+
|
|
58
|
+
return [...new Set(keys.map(key => hosts[key]?.token).filter(Boolean))]
|
|
59
|
+
}
|
|
60
|
+
|
|
16
61
|
/** Where credentials live. Overridable so tests never touch a real ~/.envstash. */
|
|
17
62
|
export function configDir() {
|
|
18
63
|
return process.env.ENVSTASH_CONFIG_DIR ?? join(homedir(), '.envstash')
|
|
@@ -136,7 +181,9 @@ export function resolveHost(flagHost) {
|
|
|
136
181
|
return normalizeHost(process.env.ENVSTASH_HOST)
|
|
137
182
|
}
|
|
138
183
|
|
|
139
|
-
|
|
184
|
+
// The custom domain is always tried first. A default saved by signing in on
|
|
185
|
+
// the Vercel domain means production, not "skip the custom domain for good".
|
|
186
|
+
return credentialKey(normalizeHost(readConfig().defaultHost))
|
|
140
187
|
}
|
|
141
188
|
|
|
142
189
|
/**
|
|
@@ -155,7 +202,7 @@ export function getToken(host, { allowEnvToken = true } = {}) {
|
|
|
155
202
|
return process.env.ENVSTASH_TOKEN
|
|
156
203
|
}
|
|
157
204
|
|
|
158
|
-
return readConfig().hosts
|
|
205
|
+
return storedEntry(readConfig().hosts, host)?.token ?? null
|
|
159
206
|
}
|
|
160
207
|
|
|
161
208
|
export function requireToken(host, { allowEnvToken = true } = {}) {
|
|
@@ -184,10 +231,14 @@ export function requireToken(host, { allowEnvToken = true } = {}) {
|
|
|
184
231
|
export function saveToken(host, token, email, { makeDefault = true } = {}) {
|
|
185
232
|
const config = readConfig()
|
|
186
233
|
|
|
187
|
-
config.hosts[host] = { token, email }
|
|
234
|
+
config.hosts[credentialKey(host)] = { token, email }
|
|
235
|
+
|
|
236
|
+
if (credentialKey(host) === DEFAULT_HOST) {
|
|
237
|
+
delete config.hosts[PRODUCTION_FALLBACK_HOST]
|
|
238
|
+
}
|
|
188
239
|
|
|
189
240
|
if (makeDefault) {
|
|
190
|
-
config.defaultHost = host
|
|
241
|
+
config.defaultHost = credentialKey(host)
|
|
191
242
|
}
|
|
192
243
|
|
|
193
244
|
writeConfig(config)
|
|
@@ -195,12 +246,17 @@ export function saveToken(host, token, email, { makeDefault = true } = {}) {
|
|
|
195
246
|
|
|
196
247
|
export function clearToken(host) {
|
|
197
248
|
const config = readConfig()
|
|
249
|
+
const keys =
|
|
250
|
+
credentialKey(host) === DEFAULT_HOST ? [DEFAULT_HOST, PRODUCTION_FALLBACK_HOST] : [host]
|
|
198
251
|
|
|
199
|
-
if (!config.hosts[
|
|
252
|
+
if (!keys.some(key => config.hosts[key])) {
|
|
200
253
|
return false
|
|
201
254
|
}
|
|
202
255
|
|
|
203
|
-
|
|
256
|
+
for (const key of keys) {
|
|
257
|
+
delete config.hosts[key]
|
|
258
|
+
}
|
|
259
|
+
|
|
204
260
|
writeConfig(config)
|
|
205
261
|
|
|
206
262
|
return true
|
|
@@ -208,11 +264,54 @@ export function clearToken(host) {
|
|
|
208
264
|
|
|
209
265
|
/** True when the developer has signed in to this host before. */
|
|
210
266
|
export function isKnownHost(host) {
|
|
211
|
-
return Boolean(readConfig().hosts
|
|
267
|
+
return Boolean(storedEntry(readConfig().hosts, host))
|
|
212
268
|
}
|
|
213
269
|
|
|
214
270
|
export function getStoredEmail(host) {
|
|
215
|
-
return readConfig().hosts
|
|
271
|
+
return storedEntry(readConfig().hosts, host)?.email ?? null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* How long the route to production is remembered across commands, either way.
|
|
276
|
+
* Long enough that a run of commands pays for deciding once; short enough that
|
|
277
|
+
* a domain that recovers — or dies — is noticed soon after.
|
|
278
|
+
*/
|
|
279
|
+
export const ROUTE_MEMORY_MS = 10 * 60 * 1000
|
|
280
|
+
|
|
281
|
+
function routePath() {
|
|
282
|
+
return join(configDir(), 'route.json')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `'direct'` or `'fallback'` while an earlier command's decision holds; otherwise null. */
|
|
286
|
+
export function rememberedRoute(now = Date.now()) {
|
|
287
|
+
try {
|
|
288
|
+
const { route, until } = JSON.parse(readFileSync(routePath(), 'utf8'))
|
|
289
|
+
const fresh = Number.isFinite(until) && now < until && until - now <= ROUTE_MEMORY_MS
|
|
290
|
+
|
|
291
|
+
return fresh && (route === 'direct' || route === 'fallback') ? route : null
|
|
292
|
+
} catch {
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Remember how production was reached, or forget it with null. Best-effort: a
|
|
299
|
+
* read-only home directory costs the next command a probe, not its run.
|
|
300
|
+
*/
|
|
301
|
+
export function rememberRoute(route, now = Date.now()) {
|
|
302
|
+
try {
|
|
303
|
+
if (!route) {
|
|
304
|
+
rmSync(routePath(), { force: true })
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 })
|
|
309
|
+
writeFileAtomic(routePath(), `${JSON.stringify({ route, until: now + ROUTE_MEMORY_MS })}\n`, {
|
|
310
|
+
mode: 0o600,
|
|
311
|
+
})
|
|
312
|
+
} catch {
|
|
313
|
+
// Nothing to do: the decision still holds for this process.
|
|
314
|
+
}
|
|
216
315
|
}
|
|
217
316
|
|
|
218
317
|
/** Remove the whole credential file. Backs `envs logout --all`. */
|
package/src/context.mjs
CHANGED
|
@@ -20,8 +20,8 @@ export const SHARED_SCOPE = '~shared'
|
|
|
20
20
|
* Everything the sync commands need, resolved once: which server, which token,
|
|
21
21
|
* which repo, which project.
|
|
22
22
|
*/
|
|
23
|
-
export function resolveContext(flags) {
|
|
24
|
-
const { host, token } = requireHostToken(flags)
|
|
23
|
+
export async function resolveContext(flags) {
|
|
24
|
+
const { host, token } = await requireHostToken(flags)
|
|
25
25
|
const repoRoot = findRepoRoot()
|
|
26
26
|
const link = readLink(repoRoot)
|
|
27
27
|
const project = resolveProject(repoRoot, flags.project)
|
package/src/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
|
|
5
|
+
import { abandonedConnection } from './api.mjs'
|
|
5
6
|
import { diff } from './commands/diff.mjs'
|
|
6
7
|
import { login, logout } from './commands/login.mjs'
|
|
7
8
|
import { init, link, list, version as versionCommand, whoami } from './commands/misc.mjs'
|
|
@@ -244,6 +245,15 @@ function changeDirectory(flags) {
|
|
|
244
245
|
*/
|
|
245
246
|
function finish(code) {
|
|
246
247
|
process.exitCode = code
|
|
248
|
+
|
|
249
|
+
// The one exception: a connection given up on for the fallback holds the
|
|
250
|
+
// process open for seconds after the work is done. Exit once both streams
|
|
251
|
+
// have flushed — the write callbacks fire after everything queued before
|
|
252
|
+
// them — so nothing is truncated and nobody waits on a dead socket.
|
|
253
|
+
if (abandonedConnection()) {
|
|
254
|
+
process.stdout.write('', () => process.stderr.write('', () => process.exit(code)))
|
|
255
|
+
}
|
|
256
|
+
|
|
247
257
|
return code
|
|
248
258
|
}
|
|
249
259
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { promises as dns } from 'node:dns'
|
|
2
|
+
import { connect } from 'node:tls'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether `origin` completes a TLS handshake within `timeoutMs`.
|
|
6
|
+
*
|
|
7
|
+
* A probe rather than a request: nothing is sent, so a yes or a no is equally
|
|
8
|
+
* safe ahead of a write. And every part of it can be abandoned. Resolution goes
|
|
9
|
+
* through c-ares (`dns.Resolver`) rather than the system's getaddrinfo, which
|
|
10
|
+
* is what fetch uses — getaddrinfo runs on libuv's threadpool, and a lookup
|
|
11
|
+
* stuck there cannot be cancelled and holds the process open at exit until it
|
|
12
|
+
* returns, even after the command has finished. The handshake validates the
|
|
13
|
+
* certificate for the hostname, so a lapsed certificate counts as unreachable
|
|
14
|
+
* too, which is what the fallback exists for.
|
|
15
|
+
*/
|
|
16
|
+
export async function canReach(origin, timeoutMs) {
|
|
17
|
+
let url
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(origin)
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const resolver = new dns.Resolver({ timeout: timeoutMs, tries: 1 })
|
|
26
|
+
const started = Date.now()
|
|
27
|
+
let timer = null
|
|
28
|
+
|
|
29
|
+
const deadline = new Promise(resolve => {
|
|
30
|
+
timer = setTimeout(() => {
|
|
31
|
+
resolver.cancel()
|
|
32
|
+
resolve(false)
|
|
33
|
+
}, timeoutMs)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const attempt = (async () => {
|
|
37
|
+
const [address] = await resolver.resolve4(url.hostname)
|
|
38
|
+
|
|
39
|
+
if (!address) {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return handshake(address, url, Math.max(1, timeoutMs - (Date.now() - started)))
|
|
44
|
+
})().catch(() => false)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return await Promise.race([attempt, deadline])
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function handshake(address, url, timeoutMs) {
|
|
54
|
+
return new Promise(resolve => {
|
|
55
|
+
const socket = connect({
|
|
56
|
+
host: address,
|
|
57
|
+
port: Number(url.port) || 443,
|
|
58
|
+
servername: url.hostname,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const settle = reachable => {
|
|
62
|
+
socket.destroy()
|
|
63
|
+
resolve(reachable)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
socket.setTimeout(timeoutMs, () => settle(false))
|
|
67
|
+
socket.once('secureConnect', () => settle(socket.authorized))
|
|
68
|
+
socket.once('error', () => settle(false))
|
|
69
|
+
})
|
|
70
|
+
}
|
package/src/session.mjs
CHANGED
|
@@ -1,22 +1,54 @@
|
|
|
1
|
-
import { DEFAULT_HOST, requireToken, resolveHost } from './config.mjs'
|
|
1
|
+
import { DEFAULT_HOST, getToken, requireToken, resolveHost } from './config.mjs'
|
|
2
2
|
import { detectDevHost, isDevMode, resolvePortHost } from './dev-host.mjs'
|
|
3
3
|
import { findRepoRoot, readLink } from './project.mjs'
|
|
4
|
-
import { CliError } from './ui.mjs'
|
|
4
|
+
import { CliError, errOut, style } from './ui.mjs'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Resolve host and token together.
|
|
7
|
+
* Resolve host and token together, signing in first when there is no token.
|
|
8
8
|
*
|
|
9
9
|
* A repo's link file may pin a host, which is what lets one machine work against
|
|
10
10
|
* a self-hosted or local instance for one project and production for another.
|
|
11
11
|
* The `--host` flag and `ENVSTASH_HOST` still override it.
|
|
12
12
|
*/
|
|
13
|
-
export function requireHostToken(flags) {
|
|
13
|
+
export async function requireHostToken(flags) {
|
|
14
14
|
const { host, source } = resolveHostSource(flags)
|
|
15
|
-
|
|
16
15
|
// A host the repo chose does not get to spend a credential the developer
|
|
17
16
|
// supplied. Stored tokens are keyed by host and so are already safe; this is
|
|
18
17
|
// what stops ENVSTASH_TOKEN from following a cloned repo to its own server.
|
|
19
|
-
|
|
18
|
+
const allowEnvToken = source !== 'link'
|
|
19
|
+
|
|
20
|
+
if (!getToken(host, { allowEnvToken }) && shouldSignInFirst(source, flags)) {
|
|
21
|
+
errOut()
|
|
22
|
+
errOut(` ${style.dim(`Not signed in to ${host} — signing in first.`)}`)
|
|
23
|
+
|
|
24
|
+
// Imported here, not at the top: login resolves hosts through this module.
|
|
25
|
+
const { login } = await import('./commands/login.mjs')
|
|
26
|
+
|
|
27
|
+
// Named explicitly so login signs in exactly where this command is going.
|
|
28
|
+
// --port is removed, not blanked: the host already says the same thing,
|
|
29
|
+
// and a present-but-empty --port is refused as a missing number.
|
|
30
|
+
const loginFlags = { ...flags, host }
|
|
31
|
+
delete loginFlags.port
|
|
32
|
+
|
|
33
|
+
await login(loginFlags)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { host, token: requireToken(host, { allowEnvToken }) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a command with no token should run the login flow rather than stop.
|
|
41
|
+
*
|
|
42
|
+
* Only with a person at a terminal to approve it — CI and piped runs get the
|
|
43
|
+
* plain "not signed in" error they can act on. Not for a host a repo's link
|
|
44
|
+
* file chose, since starting a sign-in there is exactly what `envs login`
|
|
45
|
+
* refuses. And not under --json, whose stdout belongs to the JSON.
|
|
46
|
+
*/
|
|
47
|
+
export function shouldSignInFirst(source, flags, { tty, env } = {}) {
|
|
48
|
+
const interactive = tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
49
|
+
const environment = env ?? process.env
|
|
50
|
+
|
|
51
|
+
return interactive && !environment.CI && source !== 'link' && !flags?.json
|
|
20
52
|
}
|
|
21
53
|
|
|
22
54
|
/**
|