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.
@@ -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
+ }