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,243 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { CliError } from './ui.mjs'
4
+
5
+ /**
6
+ * Parse a `.env` file into ordered key/value entries.
7
+ *
8
+ * This follows the dotenv conventions developers already expect:
9
+ *
10
+ * KEY=value unquoted, trimmed, trailing `# comment` stripped
11
+ * KEY="value" double-quoted, \n \r \t \\ \" escapes expanded
12
+ * KEY='value' single-quoted, entirely literal
13
+ * export KEY=value the `export ` prefix is ignored
14
+ * KEY= empty string
15
+ * # comment skipped
16
+ *
17
+ * Quoted values may span multiple lines, which matters because private keys and
18
+ * certificates are routinely stored that way and a line-at-a-time parser silently
19
+ * truncates them.
20
+ *
21
+ * Later assignments win, matching how a shell sources a file.
22
+ */
23
+ export function parseEnv(contents) {
24
+ return parseEnvFile(contents).entries
25
+ }
26
+
27
+ /**
28
+ * `parseEnv`, plus the 1-based numbers of the lines it could not read.
29
+ *
30
+ * Skipping a stray line is the right default — see below — but doing it
31
+ * silently is not, because the degenerate case is invisible: a `.env` that is
32
+ * actually JavaScript (`export const A = 'b'`) parses to zero entries, and
33
+ * `push` then reports a perfectly successful push of nothing at all. Callers
34
+ * use `skipped` to say so. Line numbers rather than line contents: a line the
35
+ * parser rejected may still hold a secret.
36
+ */
37
+ export function parseEnvFile(contents) {
38
+ const entries = []
39
+ const skipped = []
40
+ const seen = new Map()
41
+
42
+ let index = 0
43
+ let lineNumber = 1
44
+ const text = contents.replace(/^/, '')
45
+
46
+ while (index < text.length) {
47
+ const lineEnd = findLineEnd(text, index)
48
+ const line = text.slice(index, lineEnd)
49
+ const trimmed = line.trim()
50
+
51
+ if (trimmed === '' || trimmed.startsWith('#')) {
52
+ index = lineEnd + 1
53
+ lineNumber += 1
54
+ continue
55
+ }
56
+
57
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/.exec(line)
58
+
59
+ if (!match) {
60
+ // Not an assignment. Skip rather than fail: a `.env` with a stray line is
61
+ // still worth reading, and refusing to parse would block `envs status`
62
+ // over something cosmetic.
63
+ skipped.push(lineNumber)
64
+ index = lineEnd + 1
65
+ lineNumber += 1
66
+ continue
67
+ }
68
+
69
+ const key = match[1]
70
+ const valueStart = index + match[0].length
71
+ const quote = text[valueStart]
72
+
73
+ let value
74
+ let nextIndex
75
+
76
+ if (quote === '"' || quote === "'") {
77
+ const parsed = readQuoted(text, valueStart, quote)
78
+ value = parsed.value
79
+ nextIndex = findLineEnd(text, parsed.endIndex) + 1
80
+ } else {
81
+ const raw = text.slice(valueStart, lineEnd)
82
+ value = stripInlineComment(raw).trim()
83
+ nextIndex = lineEnd + 1
84
+ }
85
+
86
+ if (seen.has(key)) {
87
+ entries[seen.get(key)].value = value
88
+ } else {
89
+ seen.set(key, entries.length)
90
+ entries.push({ key, value })
91
+ }
92
+
93
+ // Counted rather than incremented: a quoted value may have spanned several
94
+ // lines, and a later skipped line has to report the number a human sees.
95
+ lineNumber += countNewlines(text, index, nextIndex)
96
+ index = nextIndex
97
+ }
98
+
99
+ return { entries, skipped }
100
+ }
101
+
102
+ function findLineEnd(text, from) {
103
+ const newline = text.indexOf('\n', from)
104
+ return newline === -1 ? text.length : newline
105
+ }
106
+
107
+ function countNewlines(text, from, to) {
108
+ let count = 0
109
+
110
+ for (let index = from; index < to && index < text.length; index += 1) {
111
+ if (text[index] === '\n') {
112
+ count += 1
113
+ }
114
+ }
115
+
116
+ return count
117
+ }
118
+
119
+ /**
120
+ * Read a quoted value starting at the opening quote. Returns the decoded value
121
+ * and the index just past the closing quote. An unterminated quote consumes to
122
+ * end of input rather than throwing — same reasoning as the skipped-line case.
123
+ */
124
+ function readQuoted(text, start, quote) {
125
+ let value = ''
126
+ let index = start + 1
127
+
128
+ while (index < text.length) {
129
+ const char = text[index]
130
+
131
+ if (char === '\\' && quote === '"') {
132
+ const next = text[index + 1]
133
+ value += unescape(next)
134
+ index += 2
135
+ continue
136
+ }
137
+
138
+ if (char === quote) {
139
+ return { value, endIndex: index + 1 }
140
+ }
141
+
142
+ value += char
143
+ index += 1
144
+ }
145
+
146
+ return { value, endIndex: index }
147
+ }
148
+
149
+ function unescape(char) {
150
+ switch (char) {
151
+ case 'n':
152
+ return '\n'
153
+ case 'r':
154
+ return '\r'
155
+ case 't':
156
+ return '\t'
157
+ case '\\':
158
+ return '\\'
159
+ case '"':
160
+ return '"'
161
+ case undefined:
162
+ return '\\'
163
+ default:
164
+ return `\\${char}`
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Strip a trailing `# comment` from an unquoted value.
170
+ *
171
+ * The `#` only starts a comment when preceded by whitespace or at the start, so
172
+ * a value like `pass#word` survives intact — losing half a password to a comment
173
+ * rule is the kind of bug that costs an afternoon.
174
+ */
175
+ function stripInlineComment(raw) {
176
+ for (let index = 0; index < raw.length; index += 1) {
177
+ if (raw[index] !== '#') {
178
+ continue
179
+ }
180
+
181
+ if (index === 0 || /\s/.test(raw[index - 1])) {
182
+ return raw.slice(0, index)
183
+ }
184
+ }
185
+
186
+ return raw
187
+ }
188
+
189
+ /** True when a value cannot be written bare and must be quoted. */
190
+ function needsQuoting(value) {
191
+ return value === '' || /[\s"'#\\$`]/.test(value)
192
+ }
193
+
194
+ /**
195
+ * A shell-safe environment variable name — the same grammar the server enforces
196
+ * in `isValidKey`.
197
+ *
198
+ * Values are escaped on the way out, but the key was interpolated raw, so a key
199
+ * containing a newline let whoever supplied it write arbitrary lines into the
200
+ * file. That matters because the entries handed to `envs pull` come from the
201
+ * server, not from the developer.
202
+ */
203
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
204
+
205
+ /** Serialize entries back to `.env` text. Round-trips with `parseEnv`. */
206
+ export function serializeEnv(entries) {
207
+ const lines = entries.map(({ key, value }) => {
208
+ if (!KEY_PATTERN.test(key)) {
209
+ throw new CliError(`Refusing to write the key ${JSON.stringify(String(key))}.`, {
210
+ hint: 'An environment variable name must be letters, digits and underscores.',
211
+ })
212
+ }
213
+
214
+ if (!needsQuoting(value)) {
215
+ return `${key}=${value}`
216
+ }
217
+
218
+ const escaped = value
219
+ .replace(/\\/g, '\\\\')
220
+ .replace(/"/g, '\\"')
221
+ .replace(/\n/g, '\\n')
222
+ .replace(/\r/g, '\\r')
223
+
224
+ return `${key}="${escaped}"`
225
+ })
226
+
227
+ return lines.length > 0 ? `${lines.join('\n')}\n` : ''
228
+ }
229
+
230
+ /**
231
+ * The digest `envs status` compares against the server's.
232
+ *
233
+ * Must stay byte-identical to `valueDigest` in src/lib/crypto.ts — the whole
234
+ * comparison rests on both sides hashing the same way.
235
+ */
236
+ export function digestValue(value) {
237
+ return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 12)
238
+ }
239
+
240
+ /** Entries as a Map, for lookup-heavy code like the status comparison. */
241
+ export function entriesToMap(entries) {
242
+ return new Map(entries.map(entry => [entry.key, entry.value]))
243
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,299 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { diff } from './commands/diff.mjs'
6
+ import { login, logout } from './commands/login.mjs'
7
+ import { init, link, list, whoami } from './commands/misc.mjs'
8
+ import { pull } from './commands/pull.mjs'
9
+ import { push } from './commands/push.mjs'
10
+ import { status } from './commands/status.mjs'
11
+ import { DEFAULT_HOST } from './config.mjs'
12
+ import { CLI_ALIASES, CLI_COMMAND_PAD, CLI_COMMANDS, CLI_ENV_VARS } from './manifest.mjs'
13
+ import { CliError, errOut, out, style } from './ui.mjs'
14
+
15
+ /**
16
+ * Flags that take a value. Everything else is a boolean, which is what lets
17
+ * `--force` and `--project my-app` be parsed without a schema per command.
18
+ */
19
+ const VALUE_FLAGS = new Set(['host', 'project', 'label'])
20
+
21
+ /** The implementation of each command, by name. */
22
+ const HANDLERS = { login, logout, whoami, init, link, list, status, diff, pull, push }
23
+
24
+ /**
25
+ * Summaries and aliases come from the manifest rather than being repeated here,
26
+ * so `envs --help` and the dashboard's CLI page cannot describe different
27
+ * products. A name in the manifest with no handler is a startup error below,
28
+ * which is the failure mode worth having: loud, immediate, and impossible to
29
+ * ship past.
30
+ */
31
+ const COMMANDS = Object.fromEntries(
32
+ CLI_COMMANDS.map(command => {
33
+ const run = HANDLERS[command.name]
34
+
35
+ if (!run) {
36
+ throw new Error(`manifest.mjs lists "${command.name}" but no handler implements it`)
37
+ }
38
+
39
+ return [command.name, { run, summary: command.summary }]
40
+ })
41
+ )
42
+
43
+ const ALIASES = CLI_ALIASES
44
+
45
+ /**
46
+ * Parse argv into flags and positionals.
47
+ *
48
+ * Everything after `--` is treated as a positional, so a file literally named
49
+ * `--weird` can still be addressed.
50
+ */
51
+ export function parseArgs(argv) {
52
+ const flags = {}
53
+ const args = []
54
+
55
+ let index = 0
56
+ let literal = false
57
+
58
+ while (index < argv.length) {
59
+ const token = argv[index]
60
+
61
+ if (literal) {
62
+ args.push(token)
63
+ index += 1
64
+ continue
65
+ }
66
+
67
+ if (token === '--') {
68
+ literal = true
69
+ index += 1
70
+ continue
71
+ }
72
+
73
+ if (token.startsWith('--')) {
74
+ const [name, inlineValue] = splitFlag(token.slice(2))
75
+
76
+ if (VALUE_FLAGS.has(name)) {
77
+ flags[name] = inlineValue ?? argv[index + 1]
78
+ index += inlineValue === undefined ? 2 : 1
79
+ continue
80
+ }
81
+
82
+ flags[name] = inlineValue === undefined ? true : inlineValue
83
+ index += 1
84
+ continue
85
+ }
86
+
87
+ if (token.startsWith('-') && token.length > 1) {
88
+ flags[token.slice(1)] = true
89
+ index += 1
90
+ continue
91
+ }
92
+
93
+ args.push(token)
94
+ index += 1
95
+ }
96
+
97
+ return { flags, args }
98
+ }
99
+
100
+ function splitFlag(text) {
101
+ const equals = text.indexOf('=')
102
+
103
+ return equals === -1 ? [text, undefined] : [text.slice(0, equals), text.slice(equals + 1)]
104
+ }
105
+
106
+ function version() {
107
+ try {
108
+ const here = dirname(fileURLToPath(import.meta.url))
109
+ return JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version
110
+ } catch {
111
+ return '0.0.0'
112
+ }
113
+ }
114
+
115
+ export async function run(argv) {
116
+ const { flags, args } = parseArgs(argv)
117
+ const [name, ...rest] = args
118
+
119
+ if (flags.version || flags.v || name === 'version') {
120
+ out(version())
121
+ return finish(0)
122
+ }
123
+
124
+ if (!name || flags.help || flags.h || name === 'help') {
125
+ printHelp(rest[0] ?? (name === 'help' ? rest[0] : undefined))
126
+ return finish(name && name !== 'help' ? 0 : 0)
127
+ }
128
+
129
+ const resolved = ALIASES[name] ?? name
130
+ const command = COMMANDS[resolved]
131
+
132
+ if (!command) {
133
+ errOut(`envs: unknown command "${name}"`)
134
+ errOut(`Run ${style.bold('envs --help')} to see the available commands.`)
135
+ return finish(1)
136
+ }
137
+
138
+ try {
139
+ const code = await command.run(flags, rest)
140
+ return finish(code ?? 0)
141
+ } catch (error) {
142
+ if (error instanceof CliError) {
143
+ errOut()
144
+ errOut(` ${style.red('✗')} ${error.message}`)
145
+
146
+ if (error.hint) {
147
+ errOut(` ${style.dim(error.hint)}`)
148
+ }
149
+
150
+ errOut()
151
+ return finish(error.exitCode ?? 1)
152
+ }
153
+
154
+ errOut()
155
+ errOut(` ${style.red('✗')} ${error?.message ?? 'Unexpected error'}`)
156
+ errOut()
157
+
158
+ if (process.env.ENVSTASH_DEBUG) {
159
+ errOut(error?.stack ?? '')
160
+ } else {
161
+ errOut(` ${style.dim('Set ENVSTASH_DEBUG=1 for a stack trace.')}`)
162
+ errOut()
163
+ }
164
+
165
+ return finish(1)
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Set the exit code rather than calling process.exit, so buffered stdout is
171
+ * flushed before the process ends. `process.exit` truncates output when stdout
172
+ * is a pipe — which is exactly the case when someone runs `envs status | less`.
173
+ */
174
+ function finish(code) {
175
+ process.exitCode = code
176
+ return code
177
+ }
178
+
179
+ function printHelp(topic) {
180
+ if (topic && COMMANDS[ALIASES[topic] ?? topic]) {
181
+ printCommandHelp(ALIASES[topic] ?? topic)
182
+ return
183
+ }
184
+
185
+ out()
186
+ out(` ${style.bold('envstash')} ${style.dim(`v${version()}`)} — keep your .env files in sync`)
187
+ out()
188
+ out(` ${style.dim('Usage:')} envs <command> [files…] [options]`)
189
+ out()
190
+ out(` ${style.bold('Commands')}`)
191
+
192
+ for (const [name, command] of Object.entries(COMMANDS)) {
193
+ out(` ${name.padEnd(CLI_COMMAND_PAD)}${style.dim(command.summary)}`)
194
+ }
195
+
196
+ out()
197
+ out(` ${style.bold('Common options')}`)
198
+ out(` --project <name> ${style.dim('Target a project other than the linked one')}`)
199
+ out(` --host <url> ${style.dim('Target another EnvStash server')}`)
200
+ out(` --json ${style.dim('Machine-readable output (status, list, whoami)')}`)
201
+ out(` --exit-zero ${style.dim('Always exit 0, even when out of sync (status, diff)')}`)
202
+ out(` --dry-run ${style.dim('Show what would happen (pull, push)')}`)
203
+ out(` --dev ${style.dim('Log in to a local dev server if one is running')}`)
204
+ out()
205
+ out(` ${style.bold('Environment')}`)
206
+
207
+ const envPad =
208
+ CLI_ENV_VARS.reduce((widest, variable) => Math.max(widest, variable.name.length), 0) + 2
209
+
210
+ for (const variable of CLI_ENV_VARS) {
211
+ out(` ${variable.name.padEnd(envPad)}${style.dim(variable.purpose(DEFAULT_HOST))}`)
212
+ }
213
+
214
+ out()
215
+ out(` ${style.dim('Run')} envs help <command> ${style.dim('for details on one command.')}`)
216
+ out()
217
+ }
218
+
219
+ const COMMAND_HELP = {
220
+ status: [
221
+ 'Compare every file in your cloud account against what is on disk.',
222
+ '',
223
+ 'Only key names and value digests are sent — never plaintext — so this is',
224
+ 'safe to run in a shared terminal or a CI log.',
225
+ '',
226
+ 'Exits 1 when anything is out of sync, so a hook or a CI job catches drift',
227
+ 'without extra flags. Pass --exit-zero in scripts that must not fail.',
228
+ '',
229
+ ' envs status Compare every tracked file',
230
+ ' envs status .env.local Compare one file',
231
+ ' envs status --json Machine-readable output',
232
+ ' envs status --exit-zero Always exit 0',
233
+ ],
234
+ diff: [
235
+ 'Show which keys differ and what they differ to.',
236
+ '',
237
+ 'Values are masked by default; --show-values reveals them.',
238
+ '',
239
+ 'Like status, exits 1 when anything is out of sync; --exit-zero opts out.',
240
+ '',
241
+ ' envs diff',
242
+ ' envs diff --show-values',
243
+ ' envs diff .env.production',
244
+ ],
245
+ pull: [
246
+ 'Write the cloud’s version of each file to disk.',
247
+ '',
248
+ 'Files with local changes are skipped unless --force is given, because a',
249
+ 'git-ignored .env that gets overwritten has no undo.',
250
+ '',
251
+ ' envs pull',
252
+ ' envs pull --dry-run',
253
+ ' envs pull --force',
254
+ ],
255
+ push: [
256
+ 'Send local files to the cloud.',
257
+ '',
258
+ 'Keys in the cloud that are absent locally are left alone unless --prune is',
259
+ 'given, so pushing a partial .env cannot delete a teammate’s key.',
260
+ '',
261
+ ' envs push',
262
+ ' envs push --dry-run',
263
+ ' envs push --prune',
264
+ ],
265
+ login: [
266
+ 'Authorize this machine using a browser device code.',
267
+ '',
268
+ ' envs login',
269
+ ' envs login --no-browser Print the URL instead of opening it',
270
+ ' envs login --host <url> Sign in to another server',
271
+ ],
272
+ logout: [
273
+ 'Revoke this machine’s token and remove it locally.',
274
+ '',
275
+ ' envs logout',
276
+ ' envs logout --all Remove credentials for every host',
277
+ ],
278
+ init: [
279
+ 'Create a project and write .envstash.json linking this repo to it.',
280
+ '',
281
+ ' envs init Name it after the git remote',
282
+ ' envs init my-app',
283
+ ],
284
+ link: ['Link this repo to a project that already exists.', '', ' envs link my-app'],
285
+ list: ['List every project in your account.', '', ' envs list', ' envs list --json'],
286
+ whoami: ['Show which account this machine is signed in as.', '', ' envs whoami'],
287
+ }
288
+
289
+ function printCommandHelp(name) {
290
+ out()
291
+ out(` ${style.bold(`envs ${name}`)} — ${COMMANDS[name].summary}`)
292
+ out()
293
+
294
+ for (const line of COMMAND_HELP[name] ?? []) {
295
+ out(line ? ` ${line}` : '')
296
+ }
297
+
298
+ out()
299
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The single description of what the CLI offers.
3
+ *
4
+ * This list was previously written out four times — in the CLI's own help, in
5
+ * the dashboard's CLI page, and in two READMEs — which is three opportunities
6
+ * for the product to describe a command it no longer has. Everything that
7
+ * renders a command list or an environment table now derives it from here:
8
+ *
9
+ * packages/cli/src/index.mjs `envs --help`
10
+ * src/config/cli/cli-manifest the typed view the dashboard renders
11
+ *
12
+ * Plain ESM with no dependencies, because the CLI ships as plain ESM with no
13
+ * dependencies and the app can import it; the reverse is not true.
14
+ */
15
+
16
+ /** @typedef {{ name: string, summary: string, aliases?: string[] }} CommandSpec */
17
+
18
+ /** @type {CommandSpec[]} */
19
+ export const CLI_COMMANDS = [
20
+ { name: 'login', summary: 'Authorize this machine against your account', aliases: ['sign-in'] },
21
+ { name: 'logout', summary: 'Revoke this machine’s token', aliases: ['sign-out'] },
22
+ { name: 'whoami', summary: 'Show the signed-in account' },
23
+ { name: 'init', summary: 'Create a project and link this repo to it' },
24
+ { name: 'link', summary: 'Link this repo to an existing project' },
25
+ { name: 'list', summary: 'List every project in your account', aliases: ['ls'] },
26
+ { name: 'status', summary: 'Compare cloud files against local files', aliases: ['st'] },
27
+ { name: 'diff', summary: 'Show which keys differ, and how' },
28
+ { name: 'pull', summary: 'Write the cloud’s version of each file to disk' },
29
+ { name: 'push', summary: 'Send local files to the cloud' },
30
+ ]
31
+
32
+ /**
33
+ * Alias -> canonical name, built from the same list the help text is built from,
34
+ * so an alias can never be advertised without being routable.
35
+ *
36
+ * @type {Record<string, string>}
37
+ */
38
+ export const CLI_ALIASES = Object.fromEntries(
39
+ CLI_COMMANDS.flatMap(command => (command.aliases ?? []).map(alias => [alias, command.name]))
40
+ )
41
+
42
+ /** @typedef {{ name: string, purpose: string }} EnvVarSpec */
43
+
44
+ /**
45
+ * `purpose` takes the default host as an argument rather than baking it in, so
46
+ * the dashboard can render its own domain and the CLI can render the one it
47
+ * ships with, from one sentence.
48
+ *
49
+ * @type {Array<{ name: string, purpose: (defaultHost: string) => string }>}
50
+ */
51
+ export const CLI_ENV_VARS = [
52
+ { name: 'ENVSTASH_TOKEN', purpose: () => 'Use this token instead of the stored one — for CI' },
53
+ { name: 'ENVSTASH_HOST', purpose: host => `Target another server (default ${host})` },
54
+ {
55
+ name: 'ENVSTASH_CONFIG_DIR',
56
+ purpose: () => 'Where credentials live (default ~/.envstash)',
57
+ },
58
+ {
59
+ name: 'ENVSTASH_DEV',
60
+ purpose: () => 'Log in to a local dev server when one is running (same as --dev)',
61
+ },
62
+ { name: 'ENVSTASH_DEBUG', purpose: () => 'Print stack traces on failure' },
63
+ { name: 'NO_COLOR', purpose: () => 'Disable colour (also off automatically when piped)' },
64
+ ]
65
+
66
+ /** Widest command name, so help output can align without a magic number. */
67
+ export const CLI_COMMAND_PAD =
68
+ CLI_COMMANDS.reduce((widest, command) => Math.max(widest, command.name.length), 0) + 2