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.
- package/README.md +182 -0
- package/bin/envs.mjs +7 -0
- package/bin/envstash.mjs +4 -0
- package/package.json +42 -0
- package/src/api.mjs +155 -0
- package/src/commands/diff.mjs +137 -0
- package/src/commands/login.mjs +286 -0
- package/src/commands/misc.mjs +149 -0
- package/src/commands/pull.mjs +123 -0
- package/src/commands/push.mjs +125 -0
- package/src/commands/status.mjs +149 -0
- package/src/config.mjs +188 -0
- package/src/context.mjs +118 -0
- package/src/dev-host.mjs +84 -0
- package/src/env-file.mjs +243 -0
- package/src/index.mjs +299 -0
- package/src/manifest.mjs +68 -0
- package/src/project.mjs +159 -0
- package/src/repo-path.mjs +78 -0
- package/src/session.mjs +92 -0
- package/src/status.mjs +152 -0
- package/src/ui.mjs +82 -0
package/src/project.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { keepSafeRepoPaths } from './repo-path.mjs'
|
|
6
|
+
import { CliError } from './ui.mjs'
|
|
7
|
+
|
|
8
|
+
/** The per-repo link file. Safe to commit — it names files, never values. */
|
|
9
|
+
export const LINK_FILE = '.envstash.json'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Files that look like `.env` but are meant to be committed and contain no real
|
|
13
|
+
* secrets. Auto-including them would push placeholder values over the top of a
|
|
14
|
+
* teammate's real ones.
|
|
15
|
+
*/
|
|
16
|
+
const TEMPLATE_SUFFIXES = ['.example', '.sample', '.template', '.dist']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk up from `startDir` looking for a repo root.
|
|
20
|
+
*
|
|
21
|
+
* The link file is checked before `.git` so a nested package inside a monorepo
|
|
22
|
+
* can own its own project; falling back to `.git` then `package.json` covers the
|
|
23
|
+
* ordinary single-repo case.
|
|
24
|
+
*/
|
|
25
|
+
export function findRepoRoot(startDir = process.cwd()) {
|
|
26
|
+
let current = resolve(startDir)
|
|
27
|
+
|
|
28
|
+
for (const marker of [LINK_FILE, '.git', 'package.json']) {
|
|
29
|
+
let cursor = current
|
|
30
|
+
|
|
31
|
+
while (true) {
|
|
32
|
+
if (existsSync(join(cursor, marker))) {
|
|
33
|
+
return cursor
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parent = dirname(cursor)
|
|
37
|
+
|
|
38
|
+
if (parent === cursor) {
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
cursor = parent
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return current
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readLink(repoRoot) {
|
|
50
|
+
const path = join(repoRoot, LINK_FILE)
|
|
51
|
+
|
|
52
|
+
if (!existsSync(path)) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
58
|
+
return {
|
|
59
|
+
project: typeof parsed.project === 'string' ? parsed.project : null,
|
|
60
|
+
host: typeof parsed.host === 'string' ? parsed.host : null,
|
|
61
|
+
// A link file arrives with a cloned repo, so its file list is untrusted
|
|
62
|
+
// input. Names that would escape the repo are dropped at the door rather
|
|
63
|
+
// than carried around and rejected later.
|
|
64
|
+
files: Array.isArray(parsed.files) ? keepSafeRepoPaths(parsed.files) : [],
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
throw new CliError(`${path} is not valid JSON.`, {
|
|
68
|
+
hint: 'Fix it by hand, or delete it and run `envs init`.',
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function writeLink(repoRoot, link) {
|
|
74
|
+
const path = join(repoRoot, LINK_FILE)
|
|
75
|
+
const body = {
|
|
76
|
+
project: link.project,
|
|
77
|
+
files: link.files,
|
|
78
|
+
...(link.host ? { host: link.host } : {}),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`)
|
|
82
|
+
|
|
83
|
+
return path
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Best guess at a project name: the git remote's repo name, falling back to the
|
|
88
|
+
* directory name. Matching the repo name is what makes `envs pull` work on a
|
|
89
|
+
* fresh clone with no configuration.
|
|
90
|
+
*/
|
|
91
|
+
export function guessProjectName(repoRoot) {
|
|
92
|
+
try {
|
|
93
|
+
const remote = execFileSync('git', ['config', '--get', 'remote.origin.url'], {
|
|
94
|
+
cwd: repoRoot,
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
97
|
+
}).trim()
|
|
98
|
+
|
|
99
|
+
if (remote) {
|
|
100
|
+
const name = basename(remote.replace(/\.git$/, ''))
|
|
101
|
+
|
|
102
|
+
if (name) {
|
|
103
|
+
return name
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// No git, no remote, or git not installed — fall through to the directory.
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return basename(repoRoot)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Discover `.env*` files in the repo root.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately not recursive: walking a whole tree would wander into
|
|
117
|
+
* node_modules and vendored fixtures, and a monorepo's nested files are better
|
|
118
|
+
* declared explicitly in the link file than guessed at.
|
|
119
|
+
*/
|
|
120
|
+
export function discoverEnvFiles(repoRoot) {
|
|
121
|
+
let names
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
names = readdirSync(repoRoot)
|
|
125
|
+
} catch {
|
|
126
|
+
return []
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return names
|
|
130
|
+
.filter(name => name === '.env' || name.startsWith('.env.'))
|
|
131
|
+
.filter(name => !TEMPLATE_SUFFIXES.some(suffix => name.endsWith(suffix)))
|
|
132
|
+
.filter(name => !name.endsWith('.d.ts'))
|
|
133
|
+
.filter(name => {
|
|
134
|
+
try {
|
|
135
|
+
return statSync(join(repoRoot, name)).isFile()
|
|
136
|
+
} catch {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
.sort()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve which project this invocation targets: `--project` wins, then the link
|
|
145
|
+
* file, then the repo-name guess.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveProject(repoRoot, flagProject) {
|
|
148
|
+
if (flagProject) {
|
|
149
|
+
return { name: flagProject, source: 'flag' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const link = readLink(repoRoot)
|
|
153
|
+
|
|
154
|
+
if (link?.project) {
|
|
155
|
+
return { name: link.project, source: 'link' }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { name: guessProjectName(repoRoot), source: 'guess' }
|
|
159
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { CliError } from './ui.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The one place the CLI decides whether a file name may touch the disk.
|
|
7
|
+
*
|
|
8
|
+
* Every name the sync commands handle arrives from somewhere the developer did
|
|
9
|
+
* not type: the server's status response, or a `.envstash.json` that came with a
|
|
10
|
+
* cloned repo. Both were previously joined to the repo root and read or written
|
|
11
|
+
* unchecked, so either could reach `../../.ssh/authorized_keys`.
|
|
12
|
+
*
|
|
13
|
+
* The server has always validated names on the way in — `isValidFileName` in
|
|
14
|
+
* src/lib/secrets/store.ts, whose comment names this exact attack. But that
|
|
15
|
+
* check protects the server's data, not the developer's machine, and it says
|
|
16
|
+
* nothing about what a *different* server returns. This is the same rule on the
|
|
17
|
+
* side of the trust boundary that owns the filesystem.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately identical to the server's grammar, so nothing legitimately
|
|
20
|
+
* stored can be rejected here: POSIX-relative, and every segment drawn from
|
|
21
|
+
* `[A-Za-z0-9._-]`.
|
|
22
|
+
*/
|
|
23
|
+
const SEGMENT = /^[A-Za-z0-9._-]+$/
|
|
24
|
+
|
|
25
|
+
const MAX_LENGTH = 255
|
|
26
|
+
|
|
27
|
+
export function isSafeRepoPath(name) {
|
|
28
|
+
if (typeof name !== 'string' || name === '' || name.length > MAX_LENGTH) {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Backslash before anything else: on Windows it separates paths, and `..\..`
|
|
33
|
+
// traverses just as well as `../..` while containing no forward slash at all.
|
|
34
|
+
if (name.includes('\\') || name.includes('\0') || isAbsolute(name)) {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return name
|
|
39
|
+
.split('/')
|
|
40
|
+
.every(
|
|
41
|
+
segment => segment !== '' && segment !== '.' && segment !== '..' && SEGMENT.test(segment)
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve `name` under `repoRoot`, or refuse.
|
|
47
|
+
*
|
|
48
|
+
* Checked twice on purpose. The grammar above rejects the names an attacker
|
|
49
|
+
* would choose; the containment check below is what still holds if the grammar
|
|
50
|
+
* is ever loosened, because it compares resolved paths rather than trusting a
|
|
51
|
+
* pattern.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveInsideRepo(repoRoot, name) {
|
|
54
|
+
if (!isSafeRepoPath(name)) {
|
|
55
|
+
throw refuse(name)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const root = resolve(repoRoot)
|
|
59
|
+
const target = resolve(root, name)
|
|
60
|
+
const inside = relative(root, target)
|
|
61
|
+
|
|
62
|
+
if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
|
|
63
|
+
throw refuse(name)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return target
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drop the names that are not safe, for the paths where refusing is too blunt. */
|
|
70
|
+
export function keepSafeRepoPaths(names) {
|
|
71
|
+
return names.filter(name => isSafeRepoPath(name))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function refuse(name) {
|
|
75
|
+
return new CliError(`Refusing to use the file name ${JSON.stringify(String(name))}.`, {
|
|
76
|
+
hint: 'A file name must stay inside the repo. Report this if it came from your own server.',
|
|
77
|
+
})
|
|
78
|
+
}
|
package/src/session.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { requireToken, resolveHost } from './config.mjs'
|
|
2
|
+
import { detectDevHost, isDevMode } from './dev-host.mjs'
|
|
3
|
+
import { findRepoRoot, readLink } from './project.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve host and token together.
|
|
7
|
+
*
|
|
8
|
+
* A repo's link file may pin a host, which is what lets one machine work against
|
|
9
|
+
* a self-hosted or local instance for one project and production for another.
|
|
10
|
+
* The `--host` flag and `ENVSTASH_HOST` still override it.
|
|
11
|
+
*/
|
|
12
|
+
export function requireHostToken(flags) {
|
|
13
|
+
const { host, source } = resolveHostSource(flags)
|
|
14
|
+
|
|
15
|
+
// A host the repo chose does not get to spend a credential the developer
|
|
16
|
+
// supplied. Stored tokens are keyed by host and so are already safe; this is
|
|
17
|
+
// what stops ENVSTASH_TOKEN from following a cloned repo to its own server.
|
|
18
|
+
return { host, token: requireToken(host, { allowEnvToken: source !== 'link' }) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the host and say where the decision came from.
|
|
23
|
+
*
|
|
24
|
+
* The source matters as much as the value: `flag` and `env` are the developer
|
|
25
|
+
* speaking, `default` is a host they signed in to on purpose, and `link` is a
|
|
26
|
+
* file that arrived with somebody else's repo. Callers treat the last one with
|
|
27
|
+
* less trust.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveHostSource(flags) {
|
|
30
|
+
if (flags?.host || process.env.ENVSTASH_HOST) {
|
|
31
|
+
return { host: resolveHost(flags?.host), source: flags?.host ? 'flag' : 'env' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const link = readLink(findRepoRoot())
|
|
36
|
+
|
|
37
|
+
if (link?.host) {
|
|
38
|
+
return { host: resolveHost(link.host), source: 'link' }
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// A malformed link file is reported by the commands that actually read it.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { host: resolveHost(undefined), source: 'default' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveHostFor(flags) {
|
|
48
|
+
return resolveHostSource(flags).host
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `host` is the one this repo's link file asked for.
|
|
53
|
+
*
|
|
54
|
+
* Compared against the resolved host rather than read straight from the link,
|
|
55
|
+
* so a host that dev-mode detection or a flag settled on is correctly reported
|
|
56
|
+
* as the developer's choice even when a link file also names one.
|
|
57
|
+
*/
|
|
58
|
+
export function isLinkChosenHost(flags, host) {
|
|
59
|
+
const resolved = resolveHostSource(flags)
|
|
60
|
+
|
|
61
|
+
return resolved.source === 'link' && resolved.host === host
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Host for an interactive login.
|
|
66
|
+
*
|
|
67
|
+
* The usual precedence with one step inserted: in dev mode a running local
|
|
68
|
+
* server beats the stored default, so `pnpm cli:dev login` opens the server the
|
|
69
|
+
* developer is running. An explicit `--host` or `ENVSTASH_HOST` still wins over
|
|
70
|
+
* detection — asking for a specific server should never be second-guessed — and
|
|
71
|
+
* production remains the last resort when no dev server answers.
|
|
72
|
+
*
|
|
73
|
+
* `onDevHost` reports what detection found so the command can say so. Deciding
|
|
74
|
+
* and printing are split because the decision is what the tests care about.
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveLoginHost(flags, { onDevHost } = {}) {
|
|
77
|
+
if (flags?.host || process.env.ENVSTASH_HOST) {
|
|
78
|
+
return resolveHost(flags?.host)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isDevMode(flags)) {
|
|
82
|
+
const devHost = await detectDevHost()
|
|
83
|
+
|
|
84
|
+
onDevHost?.(devHost)
|
|
85
|
+
|
|
86
|
+
if (devHost) {
|
|
87
|
+
return devHost
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return resolveHostFor(flags)
|
|
92
|
+
}
|
package/src/status.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { digestValue } from './env-file.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-key verdicts.
|
|
5
|
+
*
|
|
6
|
+
* `local_only` and `cloud_only` are deliberately not called "added" and
|
|
7
|
+
* "deleted". Without a record of the last sync there is no way to know which
|
|
8
|
+
* side moved: a key present only locally is either one you just added or one a
|
|
9
|
+
* teammate removed. Naming the observation instead of inventing an intent keeps
|
|
10
|
+
* `envs status` from lying.
|
|
11
|
+
*/
|
|
12
|
+
export const KEY_STATE = {
|
|
13
|
+
SAME: 'same',
|
|
14
|
+
MODIFIED: 'modified',
|
|
15
|
+
LOCAL_ONLY: 'local_only',
|
|
16
|
+
CLOUD_ONLY: 'cloud_only',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const FILE_STATE = {
|
|
20
|
+
IN_SYNC: 'in_sync',
|
|
21
|
+
DIFFERS: 'differs',
|
|
22
|
+
/** Exists in the cloud, absent on disk — `envs pull` would create it. */
|
|
23
|
+
CLOUD_ONLY: 'cloud_only',
|
|
24
|
+
/** Exists on disk, absent from the cloud — `envs push` would create it. */
|
|
25
|
+
UNTRACKED: 'untracked',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compare one file's local entries against the cloud's key digests.
|
|
30
|
+
*
|
|
31
|
+
* `localEntries` is null when the file does not exist on disk; `cloudKeys` is
|
|
32
|
+
* null when the cloud has no such file.
|
|
33
|
+
*/
|
|
34
|
+
export function compareFile(name, localEntries, cloudKeys) {
|
|
35
|
+
if (localEntries === null && cloudKeys === null) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const keys = []
|
|
40
|
+
const localMap = new Map((localEntries ?? []).map(entry => [entry.key, entry.value]))
|
|
41
|
+
const cloudMap = new Map((cloudKeys ?? []).map(entry => [entry.key, entry.digest]))
|
|
42
|
+
|
|
43
|
+
for (const [key, value] of localMap) {
|
|
44
|
+
const cloudDigest = cloudMap.get(key)
|
|
45
|
+
|
|
46
|
+
if (cloudDigest === undefined) {
|
|
47
|
+
keys.push({ key, state: KEY_STATE.LOCAL_ONLY })
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
keys.push({
|
|
52
|
+
key,
|
|
53
|
+
state: digestValue(value) === cloudDigest ? KEY_STATE.SAME : KEY_STATE.MODIFIED,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const key of cloudMap.keys()) {
|
|
58
|
+
if (!localMap.has(key)) {
|
|
59
|
+
keys.push({ key, state: KEY_STATE.CLOUD_ONLY })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
keys.sort((a, b) => a.key.localeCompare(b.key))
|
|
64
|
+
|
|
65
|
+
const counts = countStates(keys)
|
|
66
|
+
|
|
67
|
+
return { name, state: fileState(localEntries, cloudKeys, counts), keys, counts }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function countStates(keys) {
|
|
71
|
+
const counts = { same: 0, modified: 0, local_only: 0, cloud_only: 0 }
|
|
72
|
+
|
|
73
|
+
for (const entry of keys) {
|
|
74
|
+
counts[entry.state] += 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return counts
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fileState(localEntries, cloudKeys, counts) {
|
|
81
|
+
if (cloudKeys === null) {
|
|
82
|
+
return FILE_STATE.UNTRACKED
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (localEntries === null) {
|
|
86
|
+
return FILE_STATE.CLOUD_ONLY
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const drifted = counts.modified + counts.local_only + counts.cloud_only
|
|
90
|
+
|
|
91
|
+
return drifted === 0 ? FILE_STATE.IN_SYNC : FILE_STATE.DIFFERS
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compare a whole project.
|
|
96
|
+
*
|
|
97
|
+
* The file set is the union of what the cloud knows about and what is on disk,
|
|
98
|
+
* so a file that exists on only one side still shows up — that is precisely the
|
|
99
|
+
* case a developer needs to be told about.
|
|
100
|
+
*/
|
|
101
|
+
export function compareProject({ cloudFiles, localFiles }) {
|
|
102
|
+
const names = new Set([...cloudFiles.map(file => file.name), ...localFiles.keys()])
|
|
103
|
+
|
|
104
|
+
const files = []
|
|
105
|
+
|
|
106
|
+
for (const name of [...names].sort()) {
|
|
107
|
+
const cloud = cloudFiles.find(file => file.name === name)
|
|
108
|
+
const comparison = compareFile(name, localFiles.get(name) ?? null, cloud?.keys ?? null)
|
|
109
|
+
|
|
110
|
+
if (comparison) {
|
|
111
|
+
files.push(comparison)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return files
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when anything at all is out of sync. */
|
|
119
|
+
export function isOutOfSync(files) {
|
|
120
|
+
return files.some(file => file.state !== FILE_STATE.IN_SYNC)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Exit code for the read-only commands: 1 when anything is out of sync.
|
|
125
|
+
*
|
|
126
|
+
* This diverges from `git status`, which always exits 0 and makes signalling
|
|
127
|
+
* opt-in via `git diff --exit-code`. Drift in a secrets store is a condition you
|
|
128
|
+
* almost always want a hook or a CI job to catch, so it fails by default and
|
|
129
|
+
* `--exit-zero` opts out for scripts running under `set -e`.
|
|
130
|
+
*
|
|
131
|
+
* `--exit-code` is still accepted, and is now a no-op that names the default.
|
|
132
|
+
*/
|
|
133
|
+
export function driftExitCode(flags, files) {
|
|
134
|
+
if (flags['exit-zero'] || flags['no-exit-code']) {
|
|
135
|
+
return 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return isOutOfSync(files) ? 1 : 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function summarize(files) {
|
|
142
|
+
const totals = { files: files.length, modified: 0, local_only: 0, cloud_only: 0, same: 0 }
|
|
143
|
+
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
totals.modified += file.counts.modified
|
|
146
|
+
totals.local_only += file.counts.local_only
|
|
147
|
+
totals.cloud_only += file.counts.cloud_only
|
|
148
|
+
totals.same += file.counts.same
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return totals
|
|
152
|
+
}
|
package/src/ui.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output helpers.
|
|
3
|
+
*
|
|
4
|
+
* Colour is opt-out via NO_COLOR and opt-in via FORCE_COLOR, and is disabled
|
|
5
|
+
* whenever stdout is not a TTY — so `envs status > report.txt` and CI logs get
|
|
6
|
+
* clean text instead of escape sequences.
|
|
7
|
+
*/
|
|
8
|
+
const colorEnabled = (() => {
|
|
9
|
+
if (process.env.NO_COLOR) {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb'
|
|
18
|
+
})()
|
|
19
|
+
|
|
20
|
+
function wrap(open, close) {
|
|
21
|
+
return text => (colorEnabled ? `\u001b[${open}m${text}\u001b[${close}m` : String(text))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const style = {
|
|
25
|
+
bold: wrap(1, 22),
|
|
26
|
+
dim: wrap(2, 22),
|
|
27
|
+
red: wrap(31, 39),
|
|
28
|
+
green: wrap(32, 39),
|
|
29
|
+
yellow: wrap(33, 39),
|
|
30
|
+
blue: wrap(34, 39),
|
|
31
|
+
magenta: wrap(35, 39),
|
|
32
|
+
cyan: wrap(36, 39),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function out(message = '') {
|
|
36
|
+
process.stdout.write(`${message}\n`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function errOut(message = '') {
|
|
40
|
+
process.stderr.write(`${message}\n`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A recoverable, already-explained failure. `run` prints these without a stack. */
|
|
44
|
+
export class CliError extends Error {
|
|
45
|
+
constructor(message, { hint, exitCode = 1 } = {}) {
|
|
46
|
+
super(message)
|
|
47
|
+
this.name = 'CliError'
|
|
48
|
+
this.hint = hint
|
|
49
|
+
this.exitCode = exitCode
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function fail(message, options) {
|
|
54
|
+
throw new CliError(message, options)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mask a secret for display: keep a little of the head and tail so a human can
|
|
59
|
+
* recognise which value it is, hide the middle. Short values are hidden whole —
|
|
60
|
+
* showing 2 of 6 characters of a PIN is worse than showing none.
|
|
61
|
+
*/
|
|
62
|
+
export function maskValue(value) {
|
|
63
|
+
if (value === '') {
|
|
64
|
+
return style.dim('(empty)')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (value.length <= 12) {
|
|
68
|
+
return style.dim('•'.repeat(8))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return `${value.slice(0, 4)}${style.dim('•'.repeat(8))}${value.slice(-4)}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pad to a display width, ignoring colour codes. */
|
|
75
|
+
export function pad(text, width) {
|
|
76
|
+
const visibleLength = String(text).replace(/\u001b\[\d+m/g, '').length
|
|
77
|
+
return `${text}${' '.repeat(Math.max(0, width - visibleLength))}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pluralize(count, singular, plural = `${singular}s`) {
|
|
81
|
+
return `${count} ${count === 1 ? singular : plural}`
|
|
82
|
+
}
|