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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envstash-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.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
@@ -325,11 +325,13 @@ export const api = {
325
325
  headers: { 'x-envstash-file-kinds': 'env,file' },
326
326
  }),
327
327
 
328
- putFile: (host, token, project, file, entries, prune) =>
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) =>
@@ -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
- if (file.state === FILE_STATE.IN_SYNC && !flags.force) {
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)
@@ -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(context.host, context.token, scope, plan.name, entries, false)
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
- await api.putFile(context.host, context.token, scope, plan.name, entries, false)
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