envstash-cli 0.6.0 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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,120 @@
1
- import { CliError, sanitize } from './ui.mjs'
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
- host,
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
- if (error?.name === 'AbortError') {
169
+ const timedOut = deadline?.expired() === true
170
+
171
+ if (error?.name === 'AbortError' && !timedOut) {
47
172
  throw error
48
173
  }
49
174
 
50
- throw new CliError(`Could not reach ${host}.`, {
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', { method: 'POST', body: { deviceCode }, signal }),
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) => request(host, '/api/cli/auth/logout', { method: 'POST', 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
 
@@ -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({
@@ -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
- const verificationUrl = safeVerificationUrl(started.verificationUrlComplete, host)
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
- const token = getToken(host)
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
- if (token) {
315
+ for (const token of tokens) {
311
316
  try {
312
317
  await api.logout(host, token)
313
318
  } catch {
@@ -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
@@ -34,7 +34,7 @@ export async function pull(flags, args) {
34
34
  })
35
35
  }
36
36
 
37
- const context = resolveContext(flags)
37
+ const context = await resolveContext(flags)
38
38
  const scope = shared ? SHARED_SCOPE : context.project.name
39
39
 
40
40
  let cloudFiles
@@ -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
@@ -26,7 +26,7 @@ export async function remove(flags, args) {
26
26
  })
27
27
  }
28
28
 
29
- const context = resolveContext(flags)
29
+ const context = await resolveContext(flags)
30
30
  const scope = shared ? SHARED_SCOPE : context.project.name
31
31
 
32
32
  out()
@@ -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({
@@ -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
- return normalizeHost(readConfig().defaultHost)
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[host]?.token ?? null
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[host]) {
252
+ if (!keys.some(key => config.hosts[key])) {
200
253
  return false
201
254
  }
202
255
 
203
- delete config.hosts[host]
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[host])
267
+ return Boolean(storedEntry(readConfig().hosts, host))
212
268
  }
213
269
 
214
270
  export function getStoredEmail(host) {
215
- return readConfig().hosts[host]?.email ?? null
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
- return { host, token: requireToken(host, { allowEnvToken: source !== 'link' }) }
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
  /**