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/env-file.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/manifest.mjs
ADDED
|
@@ -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
|