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,286 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { hostname, userInfo } from 'node:os'
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
|
+
|
|
5
|
+
import { api } from '../api.mjs'
|
|
6
|
+
import {
|
|
7
|
+
clearAllCredentials,
|
|
8
|
+
clearToken,
|
|
9
|
+
getStoredEmail,
|
|
10
|
+
getToken,
|
|
11
|
+
isKnownHost,
|
|
12
|
+
readConfig,
|
|
13
|
+
saveToken,
|
|
14
|
+
} from '../config.mjs'
|
|
15
|
+
import { DEV_PORTS } from '../dev-host.mjs'
|
|
16
|
+
import { isLinkChosenHost, resolveHostFor, resolveLoginHost } from '../session.mjs'
|
|
17
|
+
import { CliError, out, style } from '../ui.mjs'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `envs login` — the OAuth device flow.
|
|
21
|
+
*
|
|
22
|
+
* The CLI never handles a password or an OAuth callback: it asks the server for
|
|
23
|
+
* a code, the human approves that code in an already-authenticated browser, and
|
|
24
|
+
* the CLI polls until a token appears. That means no local web server on a
|
|
25
|
+
* random port and no redirect URI to register, so it works identically over SSH
|
|
26
|
+
* and inside a container where no browser can be opened at all.
|
|
27
|
+
*/
|
|
28
|
+
export async function login(flags) {
|
|
29
|
+
// Printed before the code and the link, so the host is on screen before the
|
|
30
|
+
// browser opens rather than only in the success line afterwards.
|
|
31
|
+
const host = await resolveLoginHost(flags, {
|
|
32
|
+
onDevHost: devHost => {
|
|
33
|
+
out()
|
|
34
|
+
out(
|
|
35
|
+
devHost
|
|
36
|
+
? ` ${style.dim('Dev server:')} ${style.bold(devHost)}`
|
|
37
|
+
: ` ${style.dim(`No dev server on ${DEV_PORTS.join(', ')} — using production.`)}`
|
|
38
|
+
)
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const fromLink = isLinkChosenHost(flags, host)
|
|
43
|
+
|
|
44
|
+
assertLoginHostIsTrusted(host, fromLink)
|
|
45
|
+
|
|
46
|
+
const label = flags.label ?? defaultLabel()
|
|
47
|
+
|
|
48
|
+
const started = await api.startDeviceAuth(host, label)
|
|
49
|
+
// The server picks this URL and the CLI is about to hand it to the operating
|
|
50
|
+
// system, so it is checked before it is shown or opened rather than after.
|
|
51
|
+
const verificationUrl = safeVerificationUrl(started.verificationUrlComplete, host)
|
|
52
|
+
|
|
53
|
+
out()
|
|
54
|
+
out(` Your code: ${style.bold(style.cyan(started.userCode))}`)
|
|
55
|
+
out()
|
|
56
|
+
out(` ${style.dim('Open')} ${style.blue(verificationUrl ?? `${host}/cli`)}`)
|
|
57
|
+
out()
|
|
58
|
+
|
|
59
|
+
if (!verificationUrl) {
|
|
60
|
+
out(` ${style.yellow('!')} ${style.dim(`${host} returned a link pointing somewhere else.`)}`)
|
|
61
|
+
out(` ${style.dim('Enter the code above by hand instead.')}`)
|
|
62
|
+
out()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const opened =
|
|
66
|
+
flags['no-browser'] || !verificationUrl ? false : await openBrowser(verificationUrl)
|
|
67
|
+
|
|
68
|
+
if (!opened && !flags['no-browser'] && verificationUrl) {
|
|
69
|
+
out(` ${style.dim('Could not open a browser — visit the link above.')}`)
|
|
70
|
+
out()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// On a TTY the waiting line is erased once approval lands. When stdout is
|
|
74
|
+
// redirected there is no cursor to move, so print a plain line instead —
|
|
75
|
+
// otherwise the escape sequence lands in the log as a literal "[K".
|
|
76
|
+
const interactive = Boolean(process.stdout.isTTY)
|
|
77
|
+
|
|
78
|
+
if (interactive) {
|
|
79
|
+
process.stdout.write(` ${style.dim('Waiting for approval…')}`)
|
|
80
|
+
} else {
|
|
81
|
+
out(' Waiting for approval…')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const token = await pollForToken(host, started)
|
|
85
|
+
|
|
86
|
+
if (interactive) {
|
|
87
|
+
process.stdout.write('\r\u001b[K')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const who = await api.whoami(host, token)
|
|
91
|
+
// A host this repo chose is not promoted to the machine-wide default. That
|
|
92
|
+
// promotion is what previously turned one hostile repo into every later
|
|
93
|
+
// command in every other repo talking to it.
|
|
94
|
+
saveToken(host, token, who.email, { makeDefault: !fromLink })
|
|
95
|
+
|
|
96
|
+
out(` ${style.green('✓')} Signed in to ${style.bold(host)} as ${style.bold(who.email)}`)
|
|
97
|
+
out()
|
|
98
|
+
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Poll until the browser decides. `pending` is the expected answer for most of
|
|
104
|
+
* this loop; anything else is terminal and `request` has already turned it into
|
|
105
|
+
* a CliError with the server's own wording.
|
|
106
|
+
*/
|
|
107
|
+
async function pollForToken(host, started) {
|
|
108
|
+
const intervalMs = Math.max(1, started.intervalSeconds ?? 2) * 1000
|
|
109
|
+
const deadline = new Date(started.expiresAt).getTime()
|
|
110
|
+
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
await sleep(intervalMs)
|
|
113
|
+
|
|
114
|
+
const result = await api.pollDeviceAuth(host, started.deviceCode)
|
|
115
|
+
|
|
116
|
+
if (result.status === 'approved' && result.token) {
|
|
117
|
+
return result.token
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
throw new CliError('That login code expired before it was approved.', {
|
|
122
|
+
hint: 'Run `envs login` again.',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Name the device so the approval screen and the token list are recognisable. */
|
|
127
|
+
function defaultLabel() {
|
|
128
|
+
try {
|
|
129
|
+
return `${userInfo().username}@${hostname()}`
|
|
130
|
+
} catch {
|
|
131
|
+
return 'unknown device'
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Refuse to sign in to a host that only a cloned repo asked for.
|
|
137
|
+
*
|
|
138
|
+
* A link file is committed so a teammate can clone and sync with no setup, and
|
|
139
|
+
* that is worth keeping — but it means the host can come from a file the
|
|
140
|
+
* developer never wrote. Signing in is the one moment where that matters most,
|
|
141
|
+
* because it is the moment a fresh credential is created. Refusing rather than
|
|
142
|
+
* prompting keeps this working the same way in CI and in a terminal, and fails
|
|
143
|
+
* closed either way. A host already in the config has been agreed to before, so
|
|
144
|
+
* it goes through untouched.
|
|
145
|
+
*/
|
|
146
|
+
function assertLoginHostIsTrusted(host, fromLink) {
|
|
147
|
+
if (!fromLink || isKnownHost(host)) {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw new CliError(`.envstash.json in this repo points at ${host}.`, {
|
|
152
|
+
hint: `You have not signed in there before. If you trust it, run: envs login --host ${host}`,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The verification link, if the server is entitled to send it.
|
|
158
|
+
*
|
|
159
|
+
* Requires http/https and the same origin the CLI dialled. The honest server
|
|
160
|
+
* builds this URL from the incoming request, so a legitimate response always
|
|
161
|
+
* matches; a mismatch means the answer came from somewhere that wants the
|
|
162
|
+
* browser pointed elsewhere. Returning null rather than throwing keeps a login
|
|
163
|
+
* usable — the code is on screen and can be typed in by hand.
|
|
164
|
+
*/
|
|
165
|
+
export function safeVerificationUrl(raw, host) {
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(String(raw))
|
|
168
|
+
|
|
169
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return url.origin === new URL(host).origin ? url.toString() : null
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Open a URL in the user's browser, resolving false when that is not possible —
|
|
181
|
+
* over SSH, in Docker, in CI. The link is always printed first, so a failure
|
|
182
|
+
* here costs the user a copy-paste, not the login.
|
|
183
|
+
*/
|
|
184
|
+
const OPEN_TIMEOUT_MS = 2000
|
|
185
|
+
|
|
186
|
+
function openBrowser(url) {
|
|
187
|
+
const command =
|
|
188
|
+
process.platform === 'darwin'
|
|
189
|
+
? { file: 'open', args: [url] }
|
|
190
|
+
: process.platform === 'win32'
|
|
191
|
+
? // Not `cmd /c start`: cmd re-parses the command line it is given, and
|
|
192
|
+
// Node only quotes arguments containing a space, so a URL carrying an
|
|
193
|
+
// `&` would split into a second command. rundll32 takes the URL as a
|
|
194
|
+
// plain argument and no shell ever sees it.
|
|
195
|
+
{ file: 'rundll32', args: ['url.dll,FileProtocolHandler', url] }
|
|
196
|
+
: { file: 'xdg-open', args: [url] }
|
|
197
|
+
|
|
198
|
+
return new Promise(resolve => {
|
|
199
|
+
let child = null
|
|
200
|
+
let settled = false
|
|
201
|
+
let timer = null
|
|
202
|
+
|
|
203
|
+
const settle = opened => {
|
|
204
|
+
if (settled) {
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
settled = true
|
|
209
|
+
clearTimeout(timer)
|
|
210
|
+
|
|
211
|
+
// Detach only once this promise has settled. Unref-ing a child we are
|
|
212
|
+
// still awaiting lets the event loop empty while the await is pending,
|
|
213
|
+
// and Node exits with status 13 and no message — `envs login` dying
|
|
214
|
+
// silently before it ever printed "Waiting for approval".
|
|
215
|
+
child?.unref?.()
|
|
216
|
+
resolve(opened)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
child = execFile(command.file, command.args, { stdio: 'ignore' }, error => settle(!error))
|
|
221
|
+
child.on('error', () => settle(false))
|
|
222
|
+
} catch {
|
|
223
|
+
settle(false)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Some openers do not return until the browser itself is closed. Once the
|
|
228
|
+
// URL has been handed over, that counts as opened: waiting for a window to
|
|
229
|
+
// be closed would stall the login behind it forever.
|
|
230
|
+
timer = setTimeout(() => settle(true), OPEN_TIMEOUT_MS)
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `envs logout` lives here too — it is the other half of the same credential. */
|
|
235
|
+
export async function logout(flags) {
|
|
236
|
+
const host = resolveHostFor(flags)
|
|
237
|
+
|
|
238
|
+
if (flags.all) {
|
|
239
|
+
// Revoke every host before deleting anything. Revocation needs the raw
|
|
240
|
+
// token, and these are the only copies of them — clearing first left the
|
|
241
|
+
// tokens live for good with no way to reach them, while telling the user
|
|
242
|
+
// they had signed out everywhere.
|
|
243
|
+
for (const [storedHost, entry] of Object.entries(readConfig().hosts)) {
|
|
244
|
+
if (!entry?.token) {
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await api.logout(storedHost, entry.token)
|
|
250
|
+
} catch {
|
|
251
|
+
out(
|
|
252
|
+
` ${style.yellow('!')} Could not reach ${storedHost} to revoke its token; removing it locally.`
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const removed = clearAllCredentials()
|
|
258
|
+
out(removed ? ` ${style.green('✓')} Removed all stored credentials.` : ' Nothing to remove.')
|
|
259
|
+
return 0
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const token = getToken(host)
|
|
263
|
+
|
|
264
|
+
// Revoke server-side first. A local delete alone leaves a live token that
|
|
265
|
+
// still works for anyone who copied the config file.
|
|
266
|
+
if (token) {
|
|
267
|
+
try {
|
|
268
|
+
await api.logout(host, token)
|
|
269
|
+
} catch {
|
|
270
|
+
out(
|
|
271
|
+
` ${style.yellow('!')} Could not reach ${host} to revoke the token; removing it locally.`
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const email = getStoredEmail(host)
|
|
277
|
+
const removed = clearToken(host)
|
|
278
|
+
|
|
279
|
+
out(
|
|
280
|
+
removed
|
|
281
|
+
? ` ${style.green('✓')} Signed out of ${host}${email ? ` (${email})` : ''}.`
|
|
282
|
+
: ` Not signed in to ${host}.`
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return 0
|
|
286
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { api } from '../api.mjs'
|
|
2
|
+
import { getStoredEmail } from '../config.mjs'
|
|
3
|
+
import { discoverEnvFiles, findRepoRoot, readLink, resolveProject, writeLink } from '../project.mjs'
|
|
4
|
+
import { requireHostToken, resolveHostFor } from '../session.mjs'
|
|
5
|
+
import { out, pad, pluralize, style } from '../ui.mjs'
|
|
6
|
+
|
|
7
|
+
/** `envs whoami` — which account this machine is signed in as. */
|
|
8
|
+
export async function whoami(flags) {
|
|
9
|
+
const { host, token } = requireHostToken(flags)
|
|
10
|
+
const who = await api.whoami(host, token)
|
|
11
|
+
|
|
12
|
+
if (flags.json) {
|
|
13
|
+
out(JSON.stringify({ host, ...who }, null, 2))
|
|
14
|
+
return 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
out()
|
|
18
|
+
out(` ${style.bold(who.email)}${who.name ? style.dim(` (${who.name})`) : ''}`)
|
|
19
|
+
out(` ${style.dim(host)}`)
|
|
20
|
+
out()
|
|
21
|
+
|
|
22
|
+
return 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `envs list` — every project in the account. */
|
|
26
|
+
export async function list(flags) {
|
|
27
|
+
const { host, token } = requireHostToken(flags)
|
|
28
|
+
const { projects } = await api.listProjects(host, token)
|
|
29
|
+
|
|
30
|
+
if (flags.json) {
|
|
31
|
+
out(JSON.stringify(projects, null, 2))
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
out()
|
|
36
|
+
|
|
37
|
+
if (!projects || projects.length === 0) {
|
|
38
|
+
out(` ${style.dim('No projects yet. Run')} envs init ${style.dim('in a repo to create one.')}`)
|
|
39
|
+
out()
|
|
40
|
+
return 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const width = Math.max(...projects.map(project => project.name.length)) + 4
|
|
44
|
+
|
|
45
|
+
for (const project of projects) {
|
|
46
|
+
out(
|
|
47
|
+
` ${pad(style.bold(project.name), width)}${style.dim(
|
|
48
|
+
`${pluralize(project.fileCount, 'file')}, ${pluralize(project.secretCount, 'key')}`
|
|
49
|
+
)}`
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
out()
|
|
54
|
+
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `envs init` — create the project and write the link file.
|
|
60
|
+
*
|
|
61
|
+
* The link file is committed, which is the point: a teammate cloning the repo
|
|
62
|
+
* gets the project name and file list for free and only has to run `envs pull`.
|
|
63
|
+
*/
|
|
64
|
+
export async function init(flags, args) {
|
|
65
|
+
const { host, token } = requireHostToken(flags)
|
|
66
|
+
const repoRoot = findRepoRoot()
|
|
67
|
+
const existing = readLink(repoRoot)
|
|
68
|
+
const name = args[0] ?? flags.project ?? resolveProject(repoRoot, undefined).name
|
|
69
|
+
|
|
70
|
+
await api.createProject(host, token, name)
|
|
71
|
+
|
|
72
|
+
const files = existing?.files?.length ? existing.files : discoverEnvFiles(repoRoot)
|
|
73
|
+
const path = writeLink(repoRoot, {
|
|
74
|
+
project: name,
|
|
75
|
+
files,
|
|
76
|
+
host: flags.host ? resolveHostFor(flags) : (existing?.host ?? null),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
out()
|
|
80
|
+
out(` ${style.green('✓')} Project ${style.bold(name)} ready on ${style.dim(host)}`)
|
|
81
|
+
out(` ${style.dim('Wrote')} ${path.replace(`${repoRoot}/`, '')}`)
|
|
82
|
+
|
|
83
|
+
if (files.length > 0) {
|
|
84
|
+
out()
|
|
85
|
+
out(` ${style.dim('Tracking:')}`)
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
out(` ${style.dim('·')} ${file}`)
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
out()
|
|
91
|
+
out(` ${style.dim('No .env files found yet. Create one, then run')} envs push`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
out()
|
|
95
|
+
out(` ${style.dim('Next:')} envs push`)
|
|
96
|
+
out()
|
|
97
|
+
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `envs link` — point this repo at an existing project without creating one.
|
|
103
|
+
* The counterpart to `init` for a repo whose secrets are already in the cloud.
|
|
104
|
+
*/
|
|
105
|
+
export async function link(flags, args) {
|
|
106
|
+
const { host, token } = requireHostToken(flags)
|
|
107
|
+
const repoRoot = findRepoRoot()
|
|
108
|
+
const existing = readLink(repoRoot)
|
|
109
|
+
const name = args[0] ?? flags.project
|
|
110
|
+
|
|
111
|
+
if (!name) {
|
|
112
|
+
const { projects } = await api.listProjects(host, token)
|
|
113
|
+
|
|
114
|
+
out()
|
|
115
|
+
out(` ${style.dim('Usage:')} envs link <project>`)
|
|
116
|
+
out()
|
|
117
|
+
|
|
118
|
+
if (projects?.length) {
|
|
119
|
+
out(` ${style.dim('Available projects:')}`)
|
|
120
|
+
for (const project of projects) {
|
|
121
|
+
out(` ${style.dim('·')} ${project.name}`)
|
|
122
|
+
}
|
|
123
|
+
out()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fails loudly if the project does not exist, rather than writing a link file
|
|
130
|
+
// that points at nothing.
|
|
131
|
+
await api.projectStatus(host, token, name)
|
|
132
|
+
|
|
133
|
+
const files = existing?.files?.length ? existing.files : discoverEnvFiles(repoRoot)
|
|
134
|
+
const path = writeLink(repoRoot, { project: name, files, host: existing?.host ?? null })
|
|
135
|
+
|
|
136
|
+
out()
|
|
137
|
+
out(` ${style.green('✓')} Linked to ${style.bold(name)}`)
|
|
138
|
+
out(` ${style.dim('Wrote')} ${path.replace(`${repoRoot}/`, '')}`)
|
|
139
|
+
out()
|
|
140
|
+
out(` ${style.dim('Next:')} envs pull`)
|
|
141
|
+
out()
|
|
142
|
+
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Shown by `envs` with no arguments when a credential already exists. */
|
|
147
|
+
export function storedEmailFor(flags) {
|
|
148
|
+
return getStoredEmail(resolveHostFor(flags))
|
|
149
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { api, ApiError } from '../api.mjs'
|
|
5
|
+
import { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
|
|
6
|
+
import { serializeEnv } from '../env-file.mjs'
|
|
7
|
+
import { resolveInsideRepo } from '../repo-path.mjs'
|
|
8
|
+
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
9
|
+
import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `envs pull` — write the cloud's version of each file to disk.
|
|
13
|
+
*
|
|
14
|
+
* A file with local changes is skipped, not overwritten. Pulling is the command
|
|
15
|
+
* most likely to be run reflexively on a new machine, and silently replacing an
|
|
16
|
+
* uncommitted `.env` destroys work that exists nowhere else — `.env` files are
|
|
17
|
+
* git-ignored by definition, so there is no undo.
|
|
18
|
+
*/
|
|
19
|
+
export async function pull(flags, args) {
|
|
20
|
+
const context = resolveContext(flags)
|
|
21
|
+
|
|
22
|
+
let cloudFiles
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await api.projectStatus(context.host, context.token, context.project.name)
|
|
26
|
+
cloudFiles = response.files ?? []
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
29
|
+
throw new CliError(`No project named "${context.project.name}" in your account.`, {
|
|
30
|
+
hint: 'Run `envs init` to create it, or pass --project to target another one.',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const names = resolveFileNames({
|
|
38
|
+
repoRoot: context.repoRoot,
|
|
39
|
+
link: context.link,
|
|
40
|
+
cloudFiles,
|
|
41
|
+
requested: args,
|
|
42
|
+
}).filter(name => cloudFiles.some(file => file.name === name))
|
|
43
|
+
|
|
44
|
+
if (names.length === 0) {
|
|
45
|
+
out()
|
|
46
|
+
out(
|
|
47
|
+
` ${style.dim('Nothing to pull —')} ${style.bold(context.project.name)} ${style.dim('has no files in the cloud.')}`
|
|
48
|
+
)
|
|
49
|
+
out()
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const localFiles = loadLocalFiles(context.repoRoot, names)
|
|
54
|
+
const comparison = compareProject({
|
|
55
|
+
cloudFiles: cloudFiles.filter(file => names.includes(file.name)),
|
|
56
|
+
localFiles,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
out()
|
|
60
|
+
|
|
61
|
+
let written = 0
|
|
62
|
+
let skipped = 0
|
|
63
|
+
|
|
64
|
+
for (const file of comparison) {
|
|
65
|
+
const hasLocalWork = file.counts.modified > 0 || file.counts.local_only > 0
|
|
66
|
+
|
|
67
|
+
if (file.state === FILE_STATE.IN_SYNC) {
|
|
68
|
+
out(` ${style.dim('=')} ${file.name} ${style.dim('already up to date')}`)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (hasLocalWork && !flags.force) {
|
|
73
|
+
skipped += 1
|
|
74
|
+
out(
|
|
75
|
+
` ${style.yellow('!')} ${file.name} ${style.dim(
|
|
76
|
+
`has local changes (${file.counts.modified} changed, ${file.counts.local_only} local-only) — skipped`
|
|
77
|
+
)}`
|
|
78
|
+
)
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const response = await api.getFile(context.host, context.token, context.project.name, file.name)
|
|
83
|
+
const body = serializeEnv(response.entries ?? [])
|
|
84
|
+
// Re-checked at the sink, not only where the name entered. This is the call
|
|
85
|
+
// that creates directories and writes bytes, so it is the one that has to
|
|
86
|
+
// be certain the path is inside the repo.
|
|
87
|
+
const path = resolveInsideRepo(context.repoRoot, file.name)
|
|
88
|
+
|
|
89
|
+
if (flags['dry-run']) {
|
|
90
|
+
out(
|
|
91
|
+
` ${style.dim('would write')} ${file.name} ${style.dim(`(${pluralize(response.entries?.length ?? 0, 'key')})`)}`
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
97
|
+
// 0600: a pulled .env holds live credentials and has no business being
|
|
98
|
+
// readable by other accounts on a shared machine.
|
|
99
|
+
writeFileSync(path, body, { mode: 0o600 })
|
|
100
|
+
written += 1
|
|
101
|
+
|
|
102
|
+
out(
|
|
103
|
+
` ${style.green('✓')} ${file.name} ${style.dim(`(${pluralize(response.entries?.length ?? 0, 'key')})`)}`
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
out()
|
|
108
|
+
|
|
109
|
+
if (skipped > 0) {
|
|
110
|
+
out(
|
|
111
|
+
` ${style.dim('Skipped')} ${pluralize(skipped, 'file')} ${style.dim('with local changes.')}`
|
|
112
|
+
)
|
|
113
|
+
out(
|
|
114
|
+
` ${style.dim('Review with')} envs diff${style.dim(', then')} envs pull --force ${style.dim('to overwrite.')}`
|
|
115
|
+
)
|
|
116
|
+
out()
|
|
117
|
+
} else if (!flags['dry-run'] && written > 0) {
|
|
118
|
+
out(` ${style.dim(`Wrote ${pluralize(written, 'file')}.`)}`)
|
|
119
|
+
out()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return 0
|
|
123
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { api, ApiError } from '../api.mjs'
|
|
2
|
+
import { loadLocalFiles, resolveContext, resolveFileNames } from '../context.mjs'
|
|
3
|
+
import { compareProject, FILE_STATE } from '../status.mjs'
|
|
4
|
+
import { CliError, out, pluralize, style } from '../ui.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `envs push` — send local files up.
|
|
8
|
+
*
|
|
9
|
+
* Keys present in the cloud but absent locally are left alone unless `--prune`
|
|
10
|
+
* is passed. Pushing a partial `.env` is normal — a developer working on one
|
|
11
|
+
* feature often has only the keys they need — and treating absence as deletion
|
|
12
|
+
* would let that quietly destroy a teammate's key.
|
|
13
|
+
*/
|
|
14
|
+
export async function push(flags, args) {
|
|
15
|
+
const context = resolveContext(flags)
|
|
16
|
+
const cloudFiles = await fetchCloudFiles(context)
|
|
17
|
+
|
|
18
|
+
const names = resolveFileNames({
|
|
19
|
+
repoRoot: context.repoRoot,
|
|
20
|
+
link: context.link,
|
|
21
|
+
cloudFiles,
|
|
22
|
+
requested: args,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const localFiles = loadLocalFiles(context.repoRoot, names)
|
|
26
|
+
const presentNames = names.filter(name => localFiles.get(name) !== null)
|
|
27
|
+
|
|
28
|
+
if (presentNames.length === 0) {
|
|
29
|
+
throw new CliError('No local env files to push.', {
|
|
30
|
+
hint: 'Create a .env file, or name one explicitly: `envs push .env.local`',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const comparison = compareProject({
|
|
35
|
+
cloudFiles: cloudFiles.filter(file => presentNames.includes(file.name)),
|
|
36
|
+
localFiles: new Map(presentNames.map(name => [name, localFiles.get(name)])),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
out()
|
|
40
|
+
|
|
41
|
+
let pushed = 0
|
|
42
|
+
|
|
43
|
+
for (const file of comparison) {
|
|
44
|
+
const entries = localFiles.get(file.name) ?? []
|
|
45
|
+
|
|
46
|
+
if (file.state === FILE_STATE.IN_SYNC && !flags.force) {
|
|
47
|
+
out(` ${style.dim('=')} ${file.name} ${style.dim('already up to date')}`)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const willDelete = flags.prune ? file.counts.cloud_only : 0
|
|
52
|
+
|
|
53
|
+
if (flags['dry-run']) {
|
|
54
|
+
out(
|
|
55
|
+
` ${style.dim('would push')} ${file.name} ${style.dim(
|
|
56
|
+
`(${file.counts.local_only} new, ${file.counts.modified} changed` +
|
|
57
|
+
`${willDelete ? `, ${willDelete} deleted` : ''})`
|
|
58
|
+
)}`
|
|
59
|
+
)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await api.putFile(
|
|
64
|
+
context.host,
|
|
65
|
+
context.token,
|
|
66
|
+
context.project.name,
|
|
67
|
+
file.name,
|
|
68
|
+
entries,
|
|
69
|
+
Boolean(flags.prune)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
pushed += 1
|
|
73
|
+
|
|
74
|
+
const parts = []
|
|
75
|
+
|
|
76
|
+
if (result.created) {
|
|
77
|
+
parts.push(style.green(`${result.created} new`))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (result.updated) {
|
|
81
|
+
parts.push(style.yellow(`${result.updated} changed`))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (result.deleted) {
|
|
85
|
+
parts.push(style.red(`${result.deleted} deleted`))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (parts.length === 0) {
|
|
89
|
+
parts.push(style.dim('no changes'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
out(` ${style.green('✓')} ${file.name} ${parts.join(style.dim(', '))}`)
|
|
93
|
+
|
|
94
|
+
if (!flags.prune && file.counts.cloud_only > 0) {
|
|
95
|
+
out(
|
|
96
|
+
` ${style.dim(
|
|
97
|
+
`${pluralize(file.counts.cloud_only, 'key')} in the cloud not present locally — left untouched (--prune to remove)`
|
|
98
|
+
)}`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
out()
|
|
104
|
+
|
|
105
|
+
if (!flags['dry-run'] && pushed > 0) {
|
|
106
|
+
out(` ${style.dim(`Pushed ${pluralize(pushed, 'file')} to ${context.project.name}.`)}`)
|
|
107
|
+
out()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchCloudFiles(context) {
|
|
114
|
+
try {
|
|
115
|
+
const response = await api.projectStatus(context.host, context.token, context.project.name)
|
|
116
|
+
return response.files ?? []
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
119
|
+
// First push to a new project: the server creates it on write.
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw error
|
|
124
|
+
}
|
|
125
|
+
}
|