envstash-cli 0.7.0 → 0.8.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 +4 -0
- package/package.json +1 -1
- package/src/api.mjs +4 -2
- package/src/commands/push.mjs +32 -5
- package/src/commands/sync.mjs +28 -3
package/README.md
CHANGED
|
@@ -406,6 +406,10 @@ second-guessed.
|
|
|
406
406
|
regenerated rather than reproduced byte for byte: keys come back sorted, and
|
|
407
407
|
comments and blank lines are not preserved. The values themselves round-trip
|
|
408
408
|
exactly, including multi-line values such as PEM keys.
|
|
409
|
+
- `envs push` also sends the file's text, encrypted like the values, so the
|
|
410
|
+
dashboard's raw view shows the file as you wrote it — comments, blank lines
|
|
411
|
+
and key order — with the current values in place. An edit that touches only
|
|
412
|
+
comments is pushed too.
|
|
409
413
|
|
|
410
414
|
## License
|
|
411
415
|
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -325,11 +325,13 @@ export const api = {
|
|
|
325
325
|
headers: { 'x-envstash-file-kinds': 'env,file' },
|
|
326
326
|
}),
|
|
327
327
|
|
|
328
|
-
|
|
328
|
+
// `layout` is the file's own text, kept server-side so the dashboard can
|
|
329
|
+
// show the file with its comments and key order. Older servers ignore it.
|
|
330
|
+
putFile: (host, token, project, file, entries, prune, layout) =>
|
|
329
331
|
request(host, `/api/cli/projects/${encodeURIComponent(project)}/files/${encodePath(file)}`, {
|
|
330
332
|
method: 'PUT',
|
|
331
333
|
token,
|
|
332
|
-
body: { entries, prune },
|
|
334
|
+
body: layout === undefined ? { entries, prune } : { entries, prune, layout },
|
|
333
335
|
}),
|
|
334
336
|
|
|
335
337
|
putBlob: (host, token, project, file, contentBase64) =>
|
package/src/commands/push.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { statSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
2
|
|
|
3
3
|
import { api, ApiError } from '../api.mjs'
|
|
4
|
-
import { formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
|
|
4
|
+
import { digestFileBytes, formatSize, MAX_BLOB_BYTES } from '../blob.mjs'
|
|
5
5
|
import {
|
|
6
6
|
cloudKinds,
|
|
7
7
|
loadLocalFiles,
|
|
@@ -87,10 +87,20 @@ export async function push(flags, args) {
|
|
|
87
87
|
|
|
88
88
|
out()
|
|
89
89
|
|
|
90
|
+
const cloudByName = new Map(cloudFiles.map(file => [file.name, file]))
|
|
90
91
|
let pushed = 0
|
|
91
92
|
|
|
92
93
|
for (const file of comparison) {
|
|
93
|
-
|
|
94
|
+
// A dotenv file's text travels with its keys, so the dashboard can show it
|
|
95
|
+
// as written — comments, order and all. Read here rather than threaded
|
|
96
|
+
// through `loadLocalFiles`, whose entries every other command consumes.
|
|
97
|
+
const layout =
|
|
98
|
+
file.kind === 'file'
|
|
99
|
+
? undefined
|
|
100
|
+
: readFileSync(resolveRepoFile(context.repoRoot, file.name), 'utf8')
|
|
101
|
+
const layoutStale = isLayoutStale(cloudByName.get(file.name), layout)
|
|
102
|
+
|
|
103
|
+
if (file.state === FILE_STATE.IN_SYNC && !layoutStale && !flags.force) {
|
|
94
104
|
out(` ${style.dim('=')} ${file.name} ${style.dim('already up to date')}`)
|
|
95
105
|
continue
|
|
96
106
|
}
|
|
@@ -119,7 +129,8 @@ export async function push(flags, args) {
|
|
|
119
129
|
scope,
|
|
120
130
|
file.name,
|
|
121
131
|
entries,
|
|
122
|
-
Boolean(flags.prune)
|
|
132
|
+
Boolean(flags.prune),
|
|
133
|
+
layout
|
|
123
134
|
)
|
|
124
135
|
|
|
125
136
|
pushed += 1
|
|
@@ -145,7 +156,7 @@ export async function push(flags, args) {
|
|
|
145
156
|
}
|
|
146
157
|
|
|
147
158
|
if (parts.length === 0) {
|
|
148
|
-
parts.push(style.dim('no changes'))
|
|
159
|
+
parts.push(style.dim(layoutStale ? 'comments and layout updated' : 'no changes'))
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
out(` ${style.green('✓')} ${file.name} ${parts.join(style.dim(', '))}`)
|
|
@@ -293,6 +304,22 @@ export async function pushOneBlob(flags, context, scope, file, local) {
|
|
|
293
304
|
return 1
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
/**
|
|
308
|
+
* True when the server keeps layouts and its copy of this file's text is not
|
|
309
|
+
* the local one — a comment or a reordering, which moves no value and so
|
|
310
|
+
* leaves the file "in sync" by the key comparison alone.
|
|
311
|
+
*
|
|
312
|
+
* A server without `layoutDigest` in its listing (before migration 0010)
|
|
313
|
+
* keeps no layouts, and re-pushing for one would never settle.
|
|
314
|
+
*/
|
|
315
|
+
export function isLayoutStale(cloudFile, layout) {
|
|
316
|
+
if (layout === undefined || !cloudFile || !('layoutDigest' in cloudFile)) {
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return cloudFile.layoutDigest !== digestFileBytes(Buffer.from(layout, 'utf8'))
|
|
321
|
+
}
|
|
322
|
+
|
|
296
323
|
export async function fetchCloudFiles(context, scope) {
|
|
297
324
|
try {
|
|
298
325
|
const response = await api.projectStatus(context.host, context.token, scope)
|
package/src/commands/sync.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
3
|
import { api } from '../api.mjs'
|
|
4
4
|
import { formatSize } from '../blob.mjs'
|
|
@@ -187,6 +187,13 @@ async function pullWholeFile(flags, context, scope, plan) {
|
|
|
187
187
|
return { changed: true, pulled: count, pushed: 0, detail }
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** A dotenv file's text on disk, sent with its keys as the server's layout —
|
|
191
|
+
* or nothing, for a file that is not there to read. */
|
|
192
|
+
function localText(context, name) {
|
|
193
|
+
const path = resolveRepoFile(context.repoRoot, name)
|
|
194
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
190
197
|
/** A file on disk the cloud has never seen — or one `--prefer local` settles. */
|
|
191
198
|
async function pushWholeFile(flags, context, scope, plan, file, local) {
|
|
192
199
|
if (plan.kind === 'file') {
|
|
@@ -202,7 +209,15 @@ async function pushWholeFile(flags, context, scope, plan, file, local) {
|
|
|
202
209
|
if (!flags['dry-run']) {
|
|
203
210
|
// Never prune. Sync's whole contract is that it does not delete, and this
|
|
204
211
|
// path also runs for `--prefer local` on a file that already exists.
|
|
205
|
-
await api.putFile(
|
|
212
|
+
await api.putFile(
|
|
213
|
+
context.host,
|
|
214
|
+
context.token,
|
|
215
|
+
scope,
|
|
216
|
+
plan.name,
|
|
217
|
+
entries,
|
|
218
|
+
false,
|
|
219
|
+
localText(context, plan.name)
|
|
220
|
+
)
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
return {
|
|
@@ -257,7 +272,17 @@ async function mergeOneFile(flags, context, scope, plan, local) {
|
|
|
257
272
|
.map(key => ({ key, value: localValues.get(key) }))
|
|
258
273
|
|
|
259
274
|
if (entries.length > 0 && !flags['dry-run']) {
|
|
260
|
-
|
|
275
|
+
// The text as it stands after the download half rewrote it, so the
|
|
276
|
+
// server's layout is the merged file, not the pre-merge one.
|
|
277
|
+
await api.putFile(
|
|
278
|
+
context.host,
|
|
279
|
+
context.token,
|
|
280
|
+
scope,
|
|
281
|
+
plan.name,
|
|
282
|
+
entries,
|
|
283
|
+
false,
|
|
284
|
+
localText(context, plan.name)
|
|
285
|
+
)
|
|
261
286
|
}
|
|
262
287
|
|
|
263
288
|
pushed = entries.length
|