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.
@@ -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
+ }
@@ -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
+ }