envstash-cli 0.5.2 → 0.6.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/src/sync.mjs ADDED
@@ -0,0 +1,137 @@
1
+ import { FILE_STATE, KEY_STATE } from './status.mjs'
2
+
3
+ /**
4
+ * What `envs sync` decided to do about one file.
5
+ *
6
+ * `MERGE` is the interesting one and the reason the command exists: both sides
7
+ * moved, and neither is wholly right.
8
+ */
9
+ export const SYNC_ACTION = {
10
+ /** Already identical. */
11
+ NONE: 'none',
12
+ /** Only the cloud has it — write it to disk. */
13
+ PULL: 'pull',
14
+ /** Only disk has it — send it up. */
15
+ PUSH: 'push',
16
+ /** Both sides moved; copy each side's additions to the other. */
17
+ MERGE: 'merge',
18
+ /** An opaque file that differs. Bytes do not merge — a human has to choose. */
19
+ CONFLICT: 'conflict',
20
+ }
21
+
22
+ /** `--prefer` values. */
23
+ export const PREFER = { LOCAL: 'local', CLOUD: 'cloud' }
24
+
25
+ /**
26
+ * Decide what sync does, for a whole comparison. Pure: no network, no disk.
27
+ *
28
+ * The rule, and the reason for it:
29
+ *
30
+ * There is no record of the last sync, so "which side moved" is unknowable —
31
+ * the same fact that makes `envs status` say `local_only` and `cloud_only`
32
+ * rather than "added" and "deleted". Sync therefore performs the one merge
33
+ * that cannot lose anything: every key that exists on one side only is copied
34
+ * to the other, in whichever direction that implies, and nothing is ever
35
+ * deleted or pruned. When the guess is wrong, the cost is a key coming back
36
+ * that a teammate had removed — visible in the next `envs status`, and fixed
37
+ * with `envs rm` — rather than a secret that exists nowhere any more.
38
+ *
39
+ * A key that exists on both sides with different values is the one case where
40
+ * copying would destroy something, so sync refuses to guess: it is reported as
41
+ * a conflict and both sides are left exactly as they are. `--prefer local` or
42
+ * `--prefer cloud` is how a developer says which side wins, and it is
43
+ * deliberately a separate, typed-out act.
44
+ */
45
+ export function planSync(files, { prefer } = {}) {
46
+ return files.map(file => planFile(file, prefer))
47
+ }
48
+
49
+ function planFile(file, prefer) {
50
+ const plan = {
51
+ name: file.name,
52
+ kind: file.kind,
53
+ action: SYNC_ACTION.NONE,
54
+ /** Keys to send up. */
55
+ upload: [],
56
+ /** Keys to bring down. */
57
+ download: [],
58
+ /** Keys that differ on both sides and nobody has chosen between. */
59
+ conflicts: [],
60
+ }
61
+
62
+ if (file.state === FILE_STATE.IN_SYNC) {
63
+ return plan
64
+ }
65
+
66
+ if (file.state === FILE_STATE.CLOUD_ONLY) {
67
+ return { ...plan, action: SYNC_ACTION.PULL }
68
+ }
69
+
70
+ if (file.state === FILE_STATE.UNTRACKED) {
71
+ return { ...plan, action: SYNC_ACTION.PUSH }
72
+ }
73
+
74
+ // DIFFERS. An opaque file has no keys to merge — half a certificate is not a
75
+ // certificate — so the whole file is the unit, and the whole file is the
76
+ // conflict until someone names a winner.
77
+ if (file.kind === 'file') {
78
+ if (prefer === PREFER.LOCAL) {
79
+ return { ...plan, action: SYNC_ACTION.PUSH }
80
+ }
81
+
82
+ if (prefer === PREFER.CLOUD) {
83
+ return { ...plan, action: SYNC_ACTION.PULL }
84
+ }
85
+
86
+ return { ...plan, action: SYNC_ACTION.CONFLICT }
87
+ }
88
+
89
+ const keysIn = state => file.keys.filter(entry => entry.state === state).map(entry => entry.key)
90
+ const modified = keysIn(KEY_STATE.MODIFIED)
91
+
92
+ const upload = keysIn(KEY_STATE.LOCAL_ONLY)
93
+ const download = keysIn(KEY_STATE.CLOUD_ONLY)
94
+
95
+ if (prefer === PREFER.LOCAL) {
96
+ upload.push(...modified)
97
+ } else if (prefer === PREFER.CLOUD) {
98
+ download.push(...modified)
99
+ }
100
+
101
+ return {
102
+ ...plan,
103
+ action: SYNC_ACTION.MERGE,
104
+ upload: upload.sort(),
105
+ download: download.sort(),
106
+ conflicts: prefer ? [] : modified,
107
+ }
108
+ }
109
+
110
+ /** True when this file needs a decision sync will not make on its own. */
111
+ export function isBlocked(plan) {
112
+ return plan.action === SYNC_ACTION.CONFLICT || plan.conflicts.length > 0
113
+ }
114
+
115
+ /** True when the plan would touch anything at all. */
116
+ export function isChanging(plan) {
117
+ return (
118
+ plan.action === SYNC_ACTION.PULL ||
119
+ plan.action === SYNC_ACTION.PUSH ||
120
+ plan.upload.length > 0 ||
121
+ plan.download.length > 0
122
+ )
123
+ }
124
+
125
+ /**
126
+ * Exit 1 when a conflict is left unresolved, the way `status` and `diff` exit 1
127
+ * on drift: sync having run is not the same as the repo being in sync, and a
128
+ * hook or a CI job should hear the difference. `--exit-zero` opts out, as it
129
+ * does there.
130
+ */
131
+ export function conflictExitCode(flags, plans) {
132
+ if (flags['exit-zero']) {
133
+ return 0
134
+ }
135
+
136
+ return plans.some(isBlocked) ? 1 : 0
137
+ }
package/src/ui.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { createInterface } from 'node:readline'
2
+
1
3
  /**
2
4
  * Terminal output helpers.
3
5
  *
@@ -71,20 +73,97 @@ export function fail(message, options) {
71
73
  }
72
74
 
73
75
  /**
74
- * Mask a secret for display: keep a little of the head and tail so a human can
75
- * recognise which value it is, hide the middle. Short values are hidden whole —
76
- * showing 2 of 6 characters of a PIN is worse than showing none.
76
+ * Mask a secret for display: a little of the head, so a human can tell which
77
+ * value it is, and the length. Never the tail — the end of a key is often the
78
+ * part a provider prints as its fingerprint, and head plus tail together gave
79
+ * away 8 characters of every secret on screen. Short values are hidden whole,
80
+ * length included — showing 2 of 6 characters of a PIN, or that it has 6, is
81
+ * worse than showing nothing.
77
82
  */
83
+ const MASK = '•'.repeat(8)
84
+
78
85
  export function maskValue(value) {
79
86
  if (value === '') {
80
87
  return style.dim('(empty)')
81
88
  }
82
89
 
83
90
  if (value.length <= 12) {
84
- return style.dim('•'.repeat(8))
91
+ return style.dim(MASK)
85
92
  }
86
93
 
87
- return `${value.slice(0, 4)}${style.dim('•'.repeat(8))}${value.slice(-4)}`
94
+ return `${sanitize(value.slice(0, 4))}${style.dim(`${MASK} (${value.length} chars)`)}`
95
+ }
96
+
97
+ /**
98
+ * Terminal control sequences, whole: CSI (`ESC [ … final`), OSC and the other
99
+ * string commands (`ESC ] … BEL` or `… ESC \\`), and two-byte escapes. Removed
100
+ * as units so a stripped sequence does not leave its `[2J` behind as litter.
101
+ */
102
+ const ESCAPE_SEQUENCE =
103
+ /\u001b(?:\[[0-?]*[ -/]*[@-~]|[\]PX^_][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-Z\\-_]?)/g
104
+
105
+ /**
106
+ * Everything else a terminal acts on rather than prints: C0 controls, DEL, the
107
+ * C1 range (U+009B is a one-character CSI), and the Unicode line separators and
108
+ * bidi overrides that can reorder or break a line visually.
109
+ */
110
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g
111
+
112
+ /**
113
+ * Make a string safe to print when someone else chose it.
114
+ *
115
+ * Key names, error messages and account details come from the server, and a
116
+ * hostile one could otherwise send escape sequences that clear the screen and
117
+ * draw a convincing "all in sync" over the real output. Newlines become spaces
118
+ * by default, because a forged line is the attack; pass `{ newlines: true }`
119
+ * where a line break is meant.
120
+ */
121
+ export function sanitize(text, { newlines = false } = {}) {
122
+ const stripped = String(text ?? '').replace(ESCAPE_SEQUENCE, '')
123
+
124
+ return stripped.replace(CONTROL, char => {
125
+ if (char === '\n') {
126
+ return newlines ? '\n' : ' '
127
+ }
128
+
129
+ return char === '\t' ? ' ' : ''
130
+ })
131
+ }
132
+
133
+ /**
134
+ * True when a human is there to answer a question: both ends are a terminal.
135
+ * Checking stdin alone is not enough — `envs push > log.txt` has a keyboard but
136
+ * no screen to show the question on.
137
+ */
138
+ export function isInteractive() {
139
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
140
+ }
141
+
142
+ /**
143
+ * Ask a yes/no question; anything but y or yes is no.
144
+ *
145
+ * Resolves false if input ends before an answer (Ctrl-D, a closed pipe), so an
146
+ * unanswered question can never be read as consent.
147
+ */
148
+ export async function confirm(question, { input = process.stdin, output = process.stdout } = {}) {
149
+ const rl = createInterface({ input, output })
150
+
151
+ return await new Promise(resolve => {
152
+ let answered = false
153
+
154
+ rl.once('close', () => {
155
+ if (!answered) {
156
+ output.write('\n')
157
+ resolve(false)
158
+ }
159
+ })
160
+
161
+ rl.question(question, answer => {
162
+ answered = true
163
+ rl.close()
164
+ resolve(/^y(es)?$/i.test(answer.trim()))
165
+ })
166
+ })
88
167
  }
89
168
 
90
169
  /** Pad to a display width, ignoring colour codes. */