envstash-cli 0.1.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 +182 -0
- package/bin/envs.mjs +7 -0
- package/bin/envstash.mjs +4 -0
- package/package.json +42 -0
- package/src/api.mjs +155 -0
- package/src/commands/diff.mjs +137 -0
- package/src/commands/login.mjs +286 -0
- package/src/commands/misc.mjs +149 -0
- package/src/commands/pull.mjs +123 -0
- package/src/commands/push.mjs +125 -0
- package/src/commands/status.mjs +149 -0
- package/src/config.mjs +188 -0
- package/src/context.mjs +118 -0
- package/src/dev-host.mjs +84 -0
- package/src/env-file.mjs +243 -0
- package/src/index.mjs +299 -0
- package/src/manifest.mjs +68 -0
- package/src/project.mjs +159 -0
- package/src/repo-path.mjs +78 -0
- package/src/session.mjs +92 -0
- package/src/status.mjs +152 -0
- package/src/ui.mjs +82 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { api, ApiError } from '../api.mjs'
|
|
2
|
+
import { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
|
|
3
|
+
import {
|
|
4
|
+
compareProject,
|
|
5
|
+
driftExitCode,
|
|
6
|
+
FILE_STATE,
|
|
7
|
+
isOutOfSync,
|
|
8
|
+
KEY_STATE,
|
|
9
|
+
summarize,
|
|
10
|
+
} from '../status.mjs'
|
|
11
|
+
import { out, pad, pluralize, style } from '../ui.mjs'
|
|
12
|
+
|
|
13
|
+
const KEY_MARK = {
|
|
14
|
+
[KEY_STATE.MODIFIED]: { mark: '~', color: style.yellow, note: 'local differs' },
|
|
15
|
+
[KEY_STATE.LOCAL_ONLY]: { mark: '+', color: style.green, note: 'local only' },
|
|
16
|
+
[KEY_STATE.CLOUD_ONLY]: { mark: '-', color: style.red, note: 'cloud only' },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `envs status` — every file in the cloud account against what is on disk.
|
|
21
|
+
*
|
|
22
|
+
* Only key names and value digests cross the wire, so this is safe to run in a
|
|
23
|
+
* shared terminal or pipe into a CI log: it can tell you a value changed without
|
|
24
|
+
* ever knowing what the value is.
|
|
25
|
+
*/
|
|
26
|
+
export async function status(flags, args) {
|
|
27
|
+
const context = resolveContext(flags)
|
|
28
|
+
const cloudFiles = await fetchCloudFiles(context)
|
|
29
|
+
|
|
30
|
+
const names = resolveFileNames({
|
|
31
|
+
repoRoot: context.repoRoot,
|
|
32
|
+
link: context.link,
|
|
33
|
+
cloudFiles,
|
|
34
|
+
requested: args,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const localFiles = loadLocalFiles(context.repoRoot, names)
|
|
38
|
+
const files = compareProject({
|
|
39
|
+
cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
|
|
40
|
+
localFiles,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (flags.json) {
|
|
44
|
+
out(JSON.stringify({ project: context.project.name, host: context.host, files }, null, 2))
|
|
45
|
+
return driftExitCode(flags, files)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
render(context, files)
|
|
49
|
+
|
|
50
|
+
return driftExitCode(flags, files)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A project that does not exist yet is not an error for `status` — it is the
|
|
55
|
+
* normal state of a repo before its first push, and reporting it as a failure
|
|
56
|
+
* would make `envs status` useless as a "what would happen if I pushed" check.
|
|
57
|
+
*/
|
|
58
|
+
async function fetchCloudFiles(context) {
|
|
59
|
+
try {
|
|
60
|
+
const response = await api.projectStatus(context.host, context.token, context.project.name)
|
|
61
|
+
return response.files ?? []
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function render(context, files) {
|
|
72
|
+
const totals = summarize(files)
|
|
73
|
+
|
|
74
|
+
out()
|
|
75
|
+
out(
|
|
76
|
+
` ${style.dim('Project')} ${style.bold(context.project.name)} ${style.dim(
|
|
77
|
+
`· ${context.host}`
|
|
78
|
+
)}${context.project.source === 'guess' ? style.dim(' (guessed from the repo name)') : ''}`
|
|
79
|
+
)
|
|
80
|
+
out()
|
|
81
|
+
|
|
82
|
+
if (files.length === 0) {
|
|
83
|
+
out(` ${style.dim('No env files found locally or in the cloud.')}`)
|
|
84
|
+
out()
|
|
85
|
+
out(` ${style.dim('Create some, then run')} envs push`)
|
|
86
|
+
out()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const width = Math.max(...files.map(file => file.name.length)) + 2
|
|
91
|
+
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
out(` ${pad(style.bold(file.name), width)}${fileSummary(file)}`)
|
|
94
|
+
|
|
95
|
+
for (const entry of file.keys) {
|
|
96
|
+
const mark = KEY_MARK[entry.state]
|
|
97
|
+
|
|
98
|
+
if (!mark) {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
out(` ${mark.color(mark.mark)} ${pad(entry.key, 28)}${style.dim(`(${mark.note})`)}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
out()
|
|
107
|
+
out(
|
|
108
|
+
` ${style.dim(
|
|
109
|
+
`${pluralize(totals.files, 'file')} · ${totals.same} in sync · ` +
|
|
110
|
+
`${totals.modified} changed · ${totals.local_only} local-only · ${totals.cloud_only} cloud-only`
|
|
111
|
+
)}`
|
|
112
|
+
)
|
|
113
|
+
out()
|
|
114
|
+
|
|
115
|
+
if (isOutOfSync(files)) {
|
|
116
|
+
out(` ${style.dim('Run')} envs diff ${style.dim('to inspect, or')} envs pull / envs push`)
|
|
117
|
+
out()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fileSummary(file) {
|
|
122
|
+
if (file.state === FILE_STATE.CLOUD_ONLY) {
|
|
123
|
+
return `${style.red('cloud only')} ${style.dim(`— ${pluralize(file.counts.cloud_only, 'key')}, not on disk`)}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (file.state === FILE_STATE.UNTRACKED) {
|
|
127
|
+
return `${style.green('untracked')} ${style.dim(`— ${pluralize(file.counts.local_only, 'key')}, not in the cloud`)}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (file.state === FILE_STATE.IN_SYNC) {
|
|
131
|
+
return style.dim(`up to date (${pluralize(file.counts.same, 'key')})`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const parts = []
|
|
135
|
+
|
|
136
|
+
if (file.counts.modified) {
|
|
137
|
+
parts.push(style.yellow(`${file.counts.modified} changed`))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (file.counts.local_only) {
|
|
141
|
+
parts.push(style.green(`${file.counts.local_only} local-only`))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (file.counts.cloud_only) {
|
|
145
|
+
parts.push(style.red(`${file.counts.cloud_only} cloud-only`))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return parts.join(style.dim(', '))
|
|
149
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { CliError } from './ui.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The canonical origin, www and not the apex: envstash.co 308-redirects to
|
|
9
|
+
* www.envstash.co, and fetch drops the Authorization header when a redirect
|
|
10
|
+
* crosses origins. Pointing at the apex therefore signs in fine and then fails
|
|
11
|
+
* every authenticated call with a 401.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_HOST = 'https://www.envstash.co'
|
|
14
|
+
|
|
15
|
+
/** Where credentials live. Overridable so tests never touch a real ~/.envstash. */
|
|
16
|
+
export function configDir() {
|
|
17
|
+
return process.env.ENVSTASH_CONFIG_DIR ?? join(homedir(), '.envstash')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function configPath() {
|
|
21
|
+
return join(configDir(), 'config.json')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Credentials are keyed by host so a developer can be signed in to production
|
|
26
|
+
* and a local dev server at the same time without one clobbering the other.
|
|
27
|
+
*
|
|
28
|
+
* {
|
|
29
|
+
* "defaultHost": "https://www.envstash.co",
|
|
30
|
+
* "hosts": { "https://www.envstash.co": { "token": "envs_…", "email": "…" } }
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
export function readConfig() {
|
|
34
|
+
const path = configPath()
|
|
35
|
+
|
|
36
|
+
if (!existsSync(path)) {
|
|
37
|
+
return { defaultHost: DEFAULT_HOST, hosts: {} }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
42
|
+
return {
|
|
43
|
+
defaultHost: parsed.defaultHost ?? DEFAULT_HOST,
|
|
44
|
+
hosts: parsed.hosts ?? {},
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
throw new CliError(`Could not read ${path} — it is not valid JSON.`, {
|
|
48
|
+
hint: 'Delete the file and run `envs login` again.',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write credentials with 0600 on the file and 0700 on the directory.
|
|
55
|
+
*
|
|
56
|
+
* The mode is set explicitly after writing rather than relying on the process
|
|
57
|
+
* umask: a permissive umask (common in containers and CI images) would otherwise
|
|
58
|
+
* leave a long-lived token world-readable.
|
|
59
|
+
*/
|
|
60
|
+
export function writeConfig(config) {
|
|
61
|
+
const dir = configDir()
|
|
62
|
+
|
|
63
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
64
|
+
|
|
65
|
+
const path = configPath()
|
|
66
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
chmodSync(dir, 0o700)
|
|
70
|
+
chmodSync(path, 0o600)
|
|
71
|
+
} catch {
|
|
72
|
+
// Windows and some network filesystems do not support POSIX modes. The
|
|
73
|
+
// write already succeeded; permissions are best-effort there.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Normalize a host so `envstash.co`, with a trailing slash, and https all agree. */
|
|
78
|
+
export function normalizeHost(host) {
|
|
79
|
+
const withScheme = /^https?:\/\//i.test(host) ? host : `https://${host}`
|
|
80
|
+
return withScheme.replace(/\/+$/, '')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve which host to talk to. Explicit flag beats environment, which beats
|
|
85
|
+
* the stored default — the usual precedence, so `--host` always wins.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveHost(flagHost) {
|
|
88
|
+
if (flagHost) {
|
|
89
|
+
return normalizeHost(flagHost)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (process.env.ENVSTASH_HOST) {
|
|
93
|
+
return normalizeHost(process.env.ENVSTASH_HOST)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return normalizeHost(readConfig().defaultHost)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The token for a host. `ENVSTASH_TOKEN` wins so CI can inject a token without
|
|
101
|
+
* running an interactive login.
|
|
102
|
+
*
|
|
103
|
+
* `allowEnvToken` is how the confused deputy is closed. Stored tokens have
|
|
104
|
+
* always been keyed by host, so an unknown host simply has none — but the
|
|
105
|
+
* environment token used to be returned for *any* host, which meant a
|
|
106
|
+
* `.envstash.json` that came with a cloned repo could name a server and be
|
|
107
|
+
* handed CI's production credential. Callers that resolved the host from a repo
|
|
108
|
+
* file pass false; nothing else changes.
|
|
109
|
+
*/
|
|
110
|
+
export function getToken(host, { allowEnvToken = true } = {}) {
|
|
111
|
+
if (allowEnvToken && process.env.ENVSTASH_TOKEN) {
|
|
112
|
+
return process.env.ENVSTASH_TOKEN
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return readConfig().hosts[host]?.token ?? null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function requireToken(host, { allowEnvToken = true } = {}) {
|
|
119
|
+
const token = getToken(host, { allowEnvToken })
|
|
120
|
+
|
|
121
|
+
if (!token) {
|
|
122
|
+
if (!allowEnvToken && process.env.ENVSTASH_TOKEN) {
|
|
123
|
+
throw new CliError(`Refusing to send ENVSTASH_TOKEN to ${host}.`, {
|
|
124
|
+
hint: `.envstash.json in this repo chose that host. Pass --host ${host} if you meant it.`,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new CliError(`Not signed in to ${host}.`, { hint: 'Run `envs login` first.' })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return token
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Store a credential.
|
|
136
|
+
*
|
|
137
|
+
* `makeDefault` exists so a host that a repo file chose cannot quietly become
|
|
138
|
+
* the default for every other repo on the machine. Signing in somewhere is a
|
|
139
|
+
* deliberate act; being pointed somewhere by a file you cloned is not.
|
|
140
|
+
*/
|
|
141
|
+
export function saveToken(host, token, email, { makeDefault = true } = {}) {
|
|
142
|
+
const config = readConfig()
|
|
143
|
+
|
|
144
|
+
config.hosts[host] = { token, email }
|
|
145
|
+
|
|
146
|
+
if (makeDefault) {
|
|
147
|
+
config.defaultHost = host
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
writeConfig(config)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function clearToken(host) {
|
|
154
|
+
const config = readConfig()
|
|
155
|
+
|
|
156
|
+
if (!config.hosts[host]) {
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
delete config.hosts[host]
|
|
161
|
+
writeConfig(config)
|
|
162
|
+
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** True when the developer has signed in to this host before. */
|
|
167
|
+
export function isKnownHost(host) {
|
|
168
|
+
return Boolean(readConfig().hosts[host])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getStoredEmail(host) {
|
|
172
|
+
return readConfig().hosts[host]?.email ?? null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Remove the whole credential file. Backs `envs logout --all`. */
|
|
176
|
+
export function clearAllCredentials() {
|
|
177
|
+
const path = configPath()
|
|
178
|
+
|
|
179
|
+
if (!existsSync(path)) {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
rmSync(path)
|
|
184
|
+
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { dirname }
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { parseEnvFile } from './env-file.mjs'
|
|
4
|
+
import { discoverEnvFiles, findRepoRoot, readLink, resolveProject } from './project.mjs'
|
|
5
|
+
import { resolveInsideRepo } from './repo-path.mjs'
|
|
6
|
+
import { requireHostToken } from './session.mjs'
|
|
7
|
+
import { errOut, pluralize, style } from './ui.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Everything the sync commands need, resolved once: which server, which token,
|
|
11
|
+
* which repo, which project.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveContext(flags) {
|
|
14
|
+
const { host, token } = requireHostToken(flags)
|
|
15
|
+
const repoRoot = findRepoRoot()
|
|
16
|
+
const link = readLink(repoRoot)
|
|
17
|
+
const project = resolveProject(repoRoot, flags.project)
|
|
18
|
+
|
|
19
|
+
return { host, token, repoRoot, link, project }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decide which files this command covers.
|
|
24
|
+
*
|
|
25
|
+
* Explicit arguments win. Otherwise the link file's list is authoritative if
|
|
26
|
+
* present — an explicit list is the only way to include a nested path like
|
|
27
|
+
* `apps/web/.env.local`, which discovery does not scan for. With no link file,
|
|
28
|
+
* fall back to the union of local `.env*` files and whatever the cloud holds.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveFileNames({ repoRoot, link, cloudFiles, requested }) {
|
|
31
|
+
if (requested?.length) {
|
|
32
|
+
return check([...new Set(requested)].sort())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (link?.files?.length) {
|
|
36
|
+
return check([...new Set(link.files)].sort())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const names = new Set([...discoverEnvFiles(repoRoot), ...cloudFiles.map(file => file.name)])
|
|
40
|
+
|
|
41
|
+
return check([...names].sort())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate the whole set before any of it is used.
|
|
46
|
+
*
|
|
47
|
+
* Refusing rather than silently dropping: a name that escapes the repo is
|
|
48
|
+
* either a server doing something it has no business doing or a link file that
|
|
49
|
+
* came with someone else's repo, and both are worth stopping the command for.
|
|
50
|
+
* Checked here so a bad name never reaches the disk, the network, or the
|
|
51
|
+
* comparison.
|
|
52
|
+
*/
|
|
53
|
+
function check(names) {
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
resolveInsideRepo('/', name)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return names
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the named files off disk. A missing file maps to null rather than being
|
|
63
|
+
* skipped, so the comparison can distinguish "not on disk" from "empty".
|
|
64
|
+
*/
|
|
65
|
+
export function loadLocalFiles(repoRoot, names) {
|
|
66
|
+
const files = new Map()
|
|
67
|
+
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
// Resolved rather than joined: `join` happily walks out of the repo, and
|
|
70
|
+
// this is the read side of the same hole `pull` had on the write side.
|
|
71
|
+
const path = resolveInsideRepo(repoRoot, name)
|
|
72
|
+
|
|
73
|
+
if (!existsSync(path)) {
|
|
74
|
+
files.set(name, null)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { entries, skipped } = parseEnvFile(readFileSync(path, 'utf8'))
|
|
79
|
+
|
|
80
|
+
files.set(name, entries)
|
|
81
|
+
|
|
82
|
+
if (skipped.length > 0) {
|
|
83
|
+
warnUnreadableLines(name, entries, skipped)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return files
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Say out loud that part of a file was not understood.
|
|
92
|
+
*
|
|
93
|
+
* It belongs here rather than in each command because this is the one place a
|
|
94
|
+
* local file is read, and every sync command comes through it. To stderr, so
|
|
95
|
+
* the warning is visible on a terminal without getting mixed into the report
|
|
96
|
+
* that `envs status > report.txt` is meant to capture.
|
|
97
|
+
*/
|
|
98
|
+
function warnUnreadableLines(name, entries, skipped) {
|
|
99
|
+
const shown = skipped.length > 10 ? `${skipped.slice(0, 10).join(', ')}, …` : skipped.join(', ')
|
|
100
|
+
const where = `${skipped.length === 1 ? 'Line' : 'Lines'} ${shown}`
|
|
101
|
+
|
|
102
|
+
errOut()
|
|
103
|
+
|
|
104
|
+
if (entries.length === 0) {
|
|
105
|
+
errOut(` ${style.yellow('!')} ${name} has no ${style.bold('KEY=value')} lines — it is empty`)
|
|
106
|
+
errOut(` ${style.dim(`as far as EnvStash is concerned. ${where} could not be read.`)}`)
|
|
107
|
+
} else {
|
|
108
|
+
errOut(
|
|
109
|
+
` ${style.yellow('!')} ${name}: ${pluralize(skipped.length, 'line')} not in ` +
|
|
110
|
+
`${style.bold('KEY=value')} form, skipped.`
|
|
111
|
+
)
|
|
112
|
+
errOut(
|
|
113
|
+
` ${style.dim(`${where}. Nothing on ${skipped.length === 1 ? 'it' : 'them'} is synced.`)}`
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
errOut()
|
|
118
|
+
}
|
package/src/dev-host.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the local dev server, so `envs login` in this repo prints an "Open …"
|
|
3
|
+
* link pointing at the server the developer is actually running.
|
|
4
|
+
*
|
|
5
|
+
* Without this, `pnpm cli:dev login` opens production, the approval lands
|
|
6
|
+
* against production, and the token saved is a production token — all of which
|
|
7
|
+
* looks like it worked. Detecting the dev server is what makes the dev script
|
|
8
|
+
* mean "dev".
|
|
9
|
+
*/
|
|
10
|
+
import { DEFAULT_HOST } from './config.mjs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The ports `scripts/dev-with-port.mjs` hands out, which are in turn the ports
|
|
14
|
+
* registered as callback URLs on the GitHub App. Keep the three lists in step:
|
|
15
|
+
* an unregistered port does not fail at startup, it fails at login.
|
|
16
|
+
*/
|
|
17
|
+
export const DEV_PORTS = [3000, 3001, 3002, 3003]
|
|
18
|
+
|
|
19
|
+
/** Short enough that probing four closed ports is not a visible pause. */
|
|
20
|
+
const PROBE_TIMEOUT_MS = 400
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A dev server is identified by the CLI's own health endpoint answering in the
|
|
24
|
+
* documented error shape, not by the port being open.
|
|
25
|
+
*
|
|
26
|
+
* Anything can hold 3000 — another framework's dev server, a stale container —
|
|
27
|
+
* and pointing the login at it would turn "no dev server running" into a
|
|
28
|
+
* confusing failure several steps later, after the browser has already opened.
|
|
29
|
+
*/
|
|
30
|
+
async function isEnvstashServer(origin, timeoutMs) {
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`${origin}/api/cli/whoami`, {
|
|
33
|
+
headers: { accept: 'application/json' },
|
|
34
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Unauthenticated by design: no token is sent, so an EnvStash server answers
|
|
38
|
+
// 401 in the shape every CLI route uses.
|
|
39
|
+
if (response.status !== 401) {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body = await response.json()
|
|
44
|
+
|
|
45
|
+
return body?.error === 'unauthorized'
|
|
46
|
+
} catch {
|
|
47
|
+
// Connection refused, timeout, HTML where JSON was expected — all the same
|
|
48
|
+
// answer: there is no EnvStash dev server on this port.
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* First dev port answering as an EnvStash server, or null when none do.
|
|
55
|
+
*
|
|
56
|
+
* Probed in order rather than in parallel: the ports express a preference, and
|
|
57
|
+
* whichever one a developer's primary server claimed should win over a second
|
|
58
|
+
* server they left running in another terminal.
|
|
59
|
+
*/
|
|
60
|
+
export async function detectDevHost({ ports = DEV_PORTS, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
|
61
|
+
for (const port of ports) {
|
|
62
|
+
const origin = `http://localhost:${port}`
|
|
63
|
+
|
|
64
|
+
if (await isEnvstashServer(origin, timeoutMs)) {
|
|
65
|
+
return origin
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether to look for a dev server at all.
|
|
74
|
+
*
|
|
75
|
+
* Opt-in on purpose. Detection that ran for every user would mean a stray
|
|
76
|
+
* process on port 3000 could silently redirect a production login, so the
|
|
77
|
+
* default stays production and the repo's own `cli:dev` script asks for dev.
|
|
78
|
+
*/
|
|
79
|
+
export function isDevMode(flags) {
|
|
80
|
+
return Boolean(flags?.dev) || Boolean(process.env.ENVSTASH_DEV)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The host used when dev mode finds nothing — production, the last resort. */
|
|
84
|
+
export const FALLBACK_HOST = DEFAULT_HOST
|