envstash-cli 0.5.1 → 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/README.md +126 -19
- package/package.json +1 -1
- package/src/api.mjs +10 -2
- package/src/atomic-write.mjs +112 -0
- package/src/commands/diff.mjs +16 -5
- package/src/commands/login.mjs +29 -5
- package/src/commands/misc.mjs +8 -6
- package/src/commands/pull.mjs +3 -13
- package/src/commands/push.mjs +101 -9
- package/src/commands/remove.mjs +72 -0
- package/src/commands/status.mjs +4 -2
- package/src/commands/sync.mjs +371 -0
- package/src/config.mjs +53 -10
- package/src/context.mjs +43 -6
- package/src/env-file.mjs +100 -19
- package/src/index.mjs +57 -9
- package/src/local-write.mjs +34 -0
- package/src/manifest.mjs +2 -0
- package/src/project.mjs +38 -7
- package/src/repo-path.mjs +103 -1
- package/src/session.mjs +10 -5
- package/src/status.mjs +10 -2
- package/src/sync.mjs +137 -0
- package/src/ui.mjs +84 -5
package/src/repo-path.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
2
3
|
|
|
3
4
|
import { CliError } from './ui.mjs'
|
|
4
5
|
|
|
@@ -24,7 +25,44 @@ const SEGMENT = /^[A-Za-z0-9._-]+$/
|
|
|
24
25
|
|
|
25
26
|
const MAX_LENGTH = 255
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Directories whose contents run code, and the file that decides where this CLI
|
|
30
|
+
* sends credentials. The grammar keeps a name inside the repo, but inside the
|
|
31
|
+
* repo is not the same as harmless: a server that answers `.git/config` with an
|
|
32
|
+
* `fsmonitor` line, `.husky/pre-commit`, or `node_modules/.bin/next` gets a
|
|
33
|
+
* command run by the next `git status` or `pnpm dev`, and one that answers
|
|
34
|
+
* `.envstash.json` gets to repoint every later command at itself.
|
|
35
|
+
*
|
|
36
|
+
* Denied at any depth rather than only as the first segment: a nested package
|
|
37
|
+
* has its own `node_modules`, a vendored repo its own `.git`, and a nested link
|
|
38
|
+
* file is found first when commands run from that package. Compared without
|
|
39
|
+
* case, because on macOS and Windows `.GIT/config` is `.git/config`.
|
|
40
|
+
*/
|
|
41
|
+
const DENIED_SEGMENTS = new Set(['.git', 'node_modules', '.husky'])
|
|
42
|
+
const DENIED_FILE = '.envstash.json'
|
|
43
|
+
|
|
27
44
|
export function isSafeRepoPath(name) {
|
|
45
|
+
return isWellFormedRepoPath(name) && !isDeniedRepoPath(name)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for a well-formed name that lands somewhere envstash never writes. */
|
|
49
|
+
export function isDeniedRepoPath(name) {
|
|
50
|
+
const segments = String(name).toLowerCase().split('/').map(stripWindowsTrailing)
|
|
51
|
+
|
|
52
|
+
return segments.some(segment => DENIED_SEGMENTS.has(segment)) || segments.at(-1) === DENIED_FILE
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Win32 path normalisation drops trailing dots and spaces from every component,
|
|
57
|
+
* so on NTFS `.git./hooks/pre-commit` and `.git /hooks` both land in `.git/hooks`.
|
|
58
|
+
* `.git.` passes the segment grammar, so the deny list compares what Windows
|
|
59
|
+
* would actually open. Harmless elsewhere: no legitimate name ends in a dot.
|
|
60
|
+
*/
|
|
61
|
+
function stripWindowsTrailing(segment) {
|
|
62
|
+
return segment.replace(/[. ]+$/, '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isWellFormedRepoPath(name) {
|
|
28
66
|
if (typeof name !== 'string' || name === '' || name.length > MAX_LENGTH) {
|
|
29
67
|
return false
|
|
30
68
|
}
|
|
@@ -66,12 +104,76 @@ export function resolveInsideRepo(repoRoot, name) {
|
|
|
66
104
|
return target
|
|
67
105
|
}
|
|
68
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Resolve `name` under `repoRoot` for an actual read or write, or refuse.
|
|
109
|
+
*
|
|
110
|
+
* `resolveInsideRepo` is lexical: it proves the *name* stays inside, but a
|
|
111
|
+
* symbolic link does not care what its name is. A cloned repo can ship
|
|
112
|
+
* `.env.local -> ~/.ssh/id_rsa`, and `envs push` would upload the key; `pull`
|
|
113
|
+
* would write through the same link. So every component from the repo root down
|
|
114
|
+
* to the file is lstat-ed, and any link — the file itself or a directory on the
|
|
115
|
+
* way — is refused. Components that do not exist yet are fine: `pull` creates
|
|
116
|
+
* them, as real directories.
|
|
117
|
+
*
|
|
118
|
+
* Refusing rather than following even a link that points inside the repo: this
|
|
119
|
+
* only ever handles `.env` files and credentials, which have no reason to be
|
|
120
|
+
* links, and "inside" is exactly the judgement an attacker gets to race.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveRepoFile(repoRoot, name) {
|
|
123
|
+
const target = resolveInsideRepo(repoRoot, name)
|
|
124
|
+
const link = findSymlink(repoRoot, name)
|
|
125
|
+
|
|
126
|
+
if (link) {
|
|
127
|
+
throw new CliError(`Refusing to follow a symbolic link: ${link}.`, {
|
|
128
|
+
hint:
|
|
129
|
+
link === name
|
|
130
|
+
? `${name} is a link to somewhere else. envstash only reads and writes real files, so a link cannot redirect a secret. Replace it with the file itself.`
|
|
131
|
+
: `${name} is inside ${link}, which is a link to somewhere else. envstash only reads and writes real files inside the repo.`,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return target
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The first symbolic link on the way from `repoRoot` to `name` (as a repo-relative
|
|
140
|
+
* path), or null. Links above the repo root are not this CLI's business — a home
|
|
141
|
+
* directory on a symlinked volume is ordinary.
|
|
142
|
+
*/
|
|
143
|
+
export function findSymlink(repoRoot, name) {
|
|
144
|
+
const segments = name.split('/')
|
|
145
|
+
|
|
146
|
+
for (let depth = 1; depth <= segments.length; depth += 1) {
|
|
147
|
+
const partial = segments.slice(0, depth).join('/')
|
|
148
|
+
let stats
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
stats = lstatSync(join(repoRoot, partial))
|
|
152
|
+
} catch {
|
|
153
|
+
// Nothing here yet, so nothing below it either.
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (stats.isSymbolicLink()) {
|
|
158
|
+
return partial
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
69
165
|
/** Drop the names that are not safe, for the paths where refusing is too blunt. */
|
|
70
166
|
export function keepSafeRepoPaths(names) {
|
|
71
167
|
return names.filter(name => isSafeRepoPath(name))
|
|
72
168
|
}
|
|
73
169
|
|
|
74
170
|
function refuse(name) {
|
|
171
|
+
if (isWellFormedRepoPath(name) && isDeniedRepoPath(name)) {
|
|
172
|
+
return new CliError(`Refusing to use the file name ${JSON.stringify(String(name))}.`, {
|
|
173
|
+
hint: 'envstash never reads or writes .git/, node_modules/, .husky/ or .envstash.json — files there run code or redirect the CLI. Report this if it came from your own server.',
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
75
177
|
return new CliError(`Refusing to use the file name ${JSON.stringify(String(name))}.`, {
|
|
76
178
|
hint: 'A file name must stay inside the repo. Report this if it came from your own server.',
|
|
77
179
|
})
|
package/src/session.mjs
CHANGED
|
@@ -67,16 +67,21 @@ export function resolveHostSource(flags) {
|
|
|
67
67
|
return explicit
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
const link = readLink(findRepoRoot())
|
|
70
|
+
let link = null
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
72
|
+
try {
|
|
73
|
+
link = readLink(findRepoRoot())
|
|
76
74
|
} catch {
|
|
77
75
|
// A malformed link file is reported by the commands that actually read it.
|
|
78
76
|
}
|
|
79
77
|
|
|
78
|
+
// Outside the try: a host the CLI refuses (plain http to another machine) is
|
|
79
|
+
// an answer, not a malformed file, and falling back to the default instead
|
|
80
|
+
// would quietly run this repo's commands against a different server.
|
|
81
|
+
if (link?.host) {
|
|
82
|
+
return { host: resolveHost(link.host), source: 'link' }
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
return { host: resolveHost(undefined), source: 'default' }
|
|
81
86
|
}
|
|
82
87
|
|
package/src/status.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { digestFileBytes } from './blob.mjs'
|
|
2
|
-
import { digestValue } from './env-file.mjs'
|
|
2
|
+
import { digestValue, KEY_PATTERN } from './env-file.mjs'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Per-key verdicts.
|
|
@@ -39,7 +39,15 @@ export function compareFile(name, localEntries, cloudKeys) {
|
|
|
39
39
|
|
|
40
40
|
const keys = []
|
|
41
41
|
const localMap = new Map((localEntries ?? []).map(entry => [entry.key, entry.value]))
|
|
42
|
-
|
|
42
|
+
// A cloud key outside the variable-name grammar is dropped, not compared. The
|
|
43
|
+
// server enforces the same grammar on the way in, so only a hostile or broken
|
|
44
|
+
// server sends one — and a key name is printed by status and diff, which makes
|
|
45
|
+
// it a way to put escape sequences on the developer's terminal.
|
|
46
|
+
const cloudMap = new Map(
|
|
47
|
+
(cloudKeys ?? [])
|
|
48
|
+
.filter(entry => typeof entry?.key === 'string' && KEY_PATTERN.test(entry.key))
|
|
49
|
+
.map(entry => [entry.key, entry.digest])
|
|
50
|
+
)
|
|
43
51
|
|
|
44
52
|
for (const [key, value] of localMap) {
|
|
45
53
|
const cloudDigest = cloudMap.get(key)
|
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:
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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(
|
|
91
|
+
return style.dim(MASK)
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
return `${value.slice(0, 4)}${style.dim(
|
|
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. */
|