fly-secrets-diff 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 +66 -0
- package/index.mjs +360 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# fly-secrets-diff
|
|
2
|
+
|
|
3
|
+
An easy way to diff fly.io machine secrets with your local secrets. Fly's
|
|
4
|
+
secrets management is largely a manual process, sometimes features get
|
|
5
|
+
deprecated or services change, and some secrets linger around unused. Or you add
|
|
6
|
+
a new feature and it works fine locally but the health-check won't pass because
|
|
7
|
+
of a missing secret.
|
|
8
|
+
|
|
9
|
+
This package prints a nice looking diff with `console.table()`:
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
You need to have `flyctl` installed in your machine: `brew install flyctl`.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm add fly-secrets-diff
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or just copy it over to you project, it's a single JavaScript file with no deps.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Diff your fly.io app secrets with your local .env file.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
Basic:
|
|
30
|
+
$ fly-secrets-diff --env-file ./myApp/.env --app my-app
|
|
31
|
+
Shorthand:
|
|
32
|
+
$ fsd --env-file ./myApp/.env --app my-app
|
|
33
|
+
|
|
34
|
+
Exclude env vars which are not secrets:
|
|
35
|
+
$ fsd -a my-app -f NODE_ENV -f PORT -f TZ
|
|
36
|
+
|
|
37
|
+
Or use the custom pattern matcher, it only uses star and does three things:
|
|
38
|
+
$ fsd -a my-app -f LOCAL_* # Prefix
|
|
39
|
+
$ fsd -a my-app -f *_FOO # Suffix
|
|
40
|
+
$ fsd -a my-app -f *FOO* # Contains
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
fly-secrets-diff [flags]
|
|
44
|
+
fsd [flags]
|
|
45
|
+
|
|
46
|
+
Flags:
|
|
47
|
+
|
|
48
|
+
-a, --app : Name of your fly app
|
|
49
|
+
-e, --env-file : Absolute or relative path to your .env file, default ./.env
|
|
50
|
+
-f, --filter : Multiple values of strings or a pattern: FOO_*, *_FOO, or
|
|
51
|
+
*FOO* of keys you want to exclude from the check
|
|
52
|
+
-r, --reveal : Should the secrets be logged into std out, normally they
|
|
53
|
+
are obfuscated
|
|
54
|
+
-h, --help : Show help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pnpm i
|
|
61
|
+
pnpm format
|
|
62
|
+
pnpm lint
|
|
63
|
+
pnpm test
|
|
64
|
+
pnpm build
|
|
65
|
+
pnpm ncu # Update packages
|
|
66
|
+
```
|
package/index.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import child_process from 'node:child_process'
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { parseArgs, parseEnv, styleText } from 'node:util'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef Args
|
|
10
|
+
* @type {object}
|
|
11
|
+
* @property {string} app
|
|
12
|
+
* @property {boolean} [help]
|
|
13
|
+
* @property {string[]} [filter]
|
|
14
|
+
* @property {string} env-file
|
|
15
|
+
* @property {boolean} reveal
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{[k:string]: string}} Obj
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function usage() {
|
|
26
|
+
const usageText = `
|
|
27
|
+
Diff your fly.io app secrets with your local .env file.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
Basic:
|
|
31
|
+
$ fly-secrets-diff --env-file ./myApp/.env --app my-app
|
|
32
|
+
Shorthand:
|
|
33
|
+
$ fsd --env-file ./myApp/.env --app my-app
|
|
34
|
+
|
|
35
|
+
Exclude env vars which are not secrets:
|
|
36
|
+
$ fsd -a my-app -f NODE_ENV -f PORT -f TZ
|
|
37
|
+
|
|
38
|
+
Or use the custom pattern matcher, it only uses star and does three things:
|
|
39
|
+
$ fsd -a my-app -f LOCAL_* # Prefix
|
|
40
|
+
$ fsd -a my-app -f *_FOO # Suffix
|
|
41
|
+
$ fsd -a my-app -f *FOO* # Contains
|
|
42
|
+
|
|
43
|
+
Usage:
|
|
44
|
+
fly-secrets-diff [flags]
|
|
45
|
+
fsd [flags]
|
|
46
|
+
|
|
47
|
+
Flags:
|
|
48
|
+
|
|
49
|
+
-a, --app : Name of your fly app
|
|
50
|
+
-e, --env-file : Absolute or relative path to your .env file, default ./.env
|
|
51
|
+
-f, --filter : Multiple values of strings or a pattern: FOO_*, *_FOO, or
|
|
52
|
+
*FOO* of keys you want to exclude from the check
|
|
53
|
+
-r, --reveal : Should the secrets be logged into std out, normally they
|
|
54
|
+
are obfuscated
|
|
55
|
+
-h, --help : Show help
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
console.log(usageText)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {Args} */
|
|
62
|
+
function args() {
|
|
63
|
+
const {
|
|
64
|
+
values: { app, ...values },
|
|
65
|
+
} = parseArgs({
|
|
66
|
+
options: {
|
|
67
|
+
app: { type: 'string', short: 'a' },
|
|
68
|
+
help: { type: 'boolean', short: 'h' },
|
|
69
|
+
filter: { type: 'string', short: 'f', multiple: true },
|
|
70
|
+
'env-file': { type: 'string', short: 'e', default: '.env' },
|
|
71
|
+
reveal: { type: 'boolean', short: 'r', default: false },
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (!app) throw bail('--app is a required arg', null, true)
|
|
76
|
+
|
|
77
|
+
return { app, ...values }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An async spawn, waits for the child process to finish. Third param toggles
|
|
82
|
+
* the interactive mode, which logs the command output as it runs.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} cmd
|
|
85
|
+
* @param {ReadonlyArray<string>} args
|
|
86
|
+
* @param {boolean} interactive - log stdout during execution
|
|
87
|
+
* @returns {Promise<string>}
|
|
88
|
+
*/
|
|
89
|
+
export function $(cmd, args, interactive = true) {
|
|
90
|
+
const { promise, resolve, reject } =
|
|
91
|
+
/** @type {PromiseWithResolvers<string>} */ (Promise.withResolvers())
|
|
92
|
+
const spawn = child_process.spawn(cmd, args, { env: process.env })
|
|
93
|
+
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
const stdoutChunks = []
|
|
96
|
+
/** @type {string[]} */
|
|
97
|
+
const stderrChunks = []
|
|
98
|
+
|
|
99
|
+
spawn.stdout.on('data', data => {
|
|
100
|
+
/** @type {string} */
|
|
101
|
+
const dataStr = data.toString()
|
|
102
|
+
if (interactive) console.log(dataStr.trim())
|
|
103
|
+
stdoutChunks.push(dataStr)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
spawn.stderr.on('data', data => {
|
|
107
|
+
/** @type {string} */
|
|
108
|
+
const dataStr = data.toString()
|
|
109
|
+
if (interactive) console.error(dataStr.trim())
|
|
110
|
+
stderrChunks.push(dataStr)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
spawn.on('error', err => {
|
|
114
|
+
// This event is emitted when the process could not be spawned,
|
|
115
|
+
// or killed, or sending a message to a child process failed.
|
|
116
|
+
reject(new Error(`Failed to spawn process: ${err.message}`))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
spawn.on('exit', (code, signal) => {
|
|
120
|
+
const stdout = stdoutChunks.join('')
|
|
121
|
+
const stderr = stderrChunks.join('')
|
|
122
|
+
|
|
123
|
+
if (code !== 0) {
|
|
124
|
+
const errorMessage =
|
|
125
|
+
stderr.length > 0
|
|
126
|
+
? `Command failed with exit code ${code} (signal: ${signal}):\n${stderr}`
|
|
127
|
+
: `Command failed with exit code ${code} (signal: ${signal}).`
|
|
128
|
+
reject(new Error(errorMessage))
|
|
129
|
+
} else {
|
|
130
|
+
resolve(stdout)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return promise
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @typedef {Object} Logger
|
|
139
|
+
* @property {( ...args: any[] ) => void} info - Logs information messages in blue
|
|
140
|
+
* @property {( ...args: any[] ) => void} error - Logs error messages in red
|
|
141
|
+
* @property {( ...args: any[] ) => void} success - Logs success messages in green
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A utility for styled console logs
|
|
146
|
+
* @type {Logger}
|
|
147
|
+
*/
|
|
148
|
+
const log = {
|
|
149
|
+
info: (...x) => console.info(styleText('blue', x.join(' '))),
|
|
150
|
+
error: (...x) => console.error(styleText('red', x.join(' '))),
|
|
151
|
+
success: (...x) => console.log(styleText('green', x.join(' '))),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Logs message, an optional error, and usage if defined, then exits with 1
|
|
156
|
+
* @param {unknown} message
|
|
157
|
+
* @param {unknown} err
|
|
158
|
+
* @param {boolean} [withUsage]
|
|
159
|
+
* @returns {void}
|
|
160
|
+
*/
|
|
161
|
+
function bail(message, err = '', withUsage = false) {
|
|
162
|
+
log.error('Err:', message)
|
|
163
|
+
if (err) log.error(err)
|
|
164
|
+
if (withUsage) usage()
|
|
165
|
+
process.exit(1)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Validates the path to the .env file and returns an absolute path
|
|
170
|
+
* @param {string} str
|
|
171
|
+
* @returns {Promise<string>} - can be a void, actually
|
|
172
|
+
*/
|
|
173
|
+
export async function validatePath(str) {
|
|
174
|
+
const envPath = path.isAbsolute(str) ? str : path.resolve(process.cwd(), str)
|
|
175
|
+
try {
|
|
176
|
+
// Check if the path exists
|
|
177
|
+
await fs.access(envPath)
|
|
178
|
+
return envPath
|
|
179
|
+
} catch (err) {
|
|
180
|
+
bail(`The path ${envPath} doesn’t exists`, err)
|
|
181
|
+
return ''
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @typedef {{ name: string, digest: string }} FlySecret */
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {Args} args
|
|
189
|
+
* @returns {Promise<string[]>}
|
|
190
|
+
*/
|
|
191
|
+
export async function getRemoteSecrets(args) {
|
|
192
|
+
try {
|
|
193
|
+
const list = await $('flyctl', [
|
|
194
|
+
'secrets',
|
|
195
|
+
'list',
|
|
196
|
+
'--json',
|
|
197
|
+
'--app',
|
|
198
|
+
args.app,
|
|
199
|
+
])
|
|
200
|
+
const flySecrets = /** @type {FlySecret[]} */ (JSON.parse(list))
|
|
201
|
+
|
|
202
|
+
return flySecrets.map(x => x.name)
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw bail(`unable to get remote secrets from the app ${args.app}`, err)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Super simple pattern matcher.
|
|
210
|
+
* FOO -> exact
|
|
211
|
+
* FOO_* -> prefix
|
|
212
|
+
* *_BAR -> suffix
|
|
213
|
+
* *MID* -> contains
|
|
214
|
+
* @param {string} pattern
|
|
215
|
+
* @param {string} str
|
|
216
|
+
* @return {boolean}
|
|
217
|
+
*/
|
|
218
|
+
export function matches(pattern, str) {
|
|
219
|
+
const starts = pattern.startsWith('*')
|
|
220
|
+
const ends = pattern.endsWith('*')
|
|
221
|
+
if (starts && ends) return str.includes(pattern.slice(1, -1))
|
|
222
|
+
if (starts) return str.endsWith(pattern.slice(1))
|
|
223
|
+
if (ends) return str.startsWith(pattern.slice(0, -1))
|
|
224
|
+
return str === pattern
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read the .env file and parses to JSON
|
|
229
|
+
* @param {Args} args
|
|
230
|
+
* @returns {Promise<Obj>}
|
|
231
|
+
*/
|
|
232
|
+
export async function parseEnvFile(args) {
|
|
233
|
+
const path = await validatePath(args['env-file'])
|
|
234
|
+
const contents = (await fs.readFile(path)).toString()
|
|
235
|
+
const parsedEnv = /** @type {Obj} */ (parseEnv(contents))
|
|
236
|
+
|
|
237
|
+
if (!args.filter) return parsedEnv
|
|
238
|
+
|
|
239
|
+
/** @type {Obj} */
|
|
240
|
+
const filteredEnv = {}
|
|
241
|
+
for (const [envVar, val] of Object.entries(parsedEnv)) {
|
|
242
|
+
if (!args.filter.some(p => matches(p, envVar))) filteredEnv[envVar] = val
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return filteredEnv
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Returns the diff between local and remote env vars
|
|
250
|
+
* @param {string[]} local
|
|
251
|
+
* @param {string[]} remote
|
|
252
|
+
* @returns {{ localDiff: string[], remoteDiff: string[] }}
|
|
253
|
+
*/
|
|
254
|
+
export function getDiff(local, remote) {
|
|
255
|
+
const localDiff = local.filter(x => !remote.includes(x))
|
|
256
|
+
const remoteDiff = remote.filter(x => !local.includes(x))
|
|
257
|
+
|
|
258
|
+
return { localDiff, remoteDiff }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Uses `console.table` to print out a nicely formatted diff between the local
|
|
263
|
+
* and remote env vars
|
|
264
|
+
* @param {Args} args
|
|
265
|
+
* @returns {Promise<void>}
|
|
266
|
+
*/
|
|
267
|
+
export async function printDiff(args) {
|
|
268
|
+
const remote = await getRemoteSecrets(args)
|
|
269
|
+
const local = await parseEnvFile(args)
|
|
270
|
+
const localKeys = Object.keys(local)
|
|
271
|
+
|
|
272
|
+
const both = [...new Set([...localKeys, ...remote])].toSorted()
|
|
273
|
+
const { localDiff, remoteDiff } = getDiff(localKeys, remote)
|
|
274
|
+
|
|
275
|
+
const diffTable = both.map(key => ({
|
|
276
|
+
[`${args['env-file']} (local)`]: remoteDiff.includes(key) ? '❌' : key,
|
|
277
|
+
[`${args.app} (remote)`]: localDiff.includes(key) ? '❌' : key,
|
|
278
|
+
}))
|
|
279
|
+
|
|
280
|
+
console.table(diffTable)
|
|
281
|
+
|
|
282
|
+
const { missingArr, missingStr } = getMissing(local, remote)
|
|
283
|
+
const { unusedArr, unusedStr } = getUnused(local, remote)
|
|
284
|
+
|
|
285
|
+
const missing = args.reveal
|
|
286
|
+
? missingStr
|
|
287
|
+
: `${missingArr.join('="*****" \\ \n ')}="*****"`
|
|
288
|
+
|
|
289
|
+
if (missingArr.length > 0) {
|
|
290
|
+
log.info(
|
|
291
|
+
`\n 🟢 Add missing:
|
|
292
|
+
fly secrets set --app=${args.app} \\
|
|
293
|
+
${missing}`
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (unusedArr.length > 0) {
|
|
298
|
+
log.info(
|
|
299
|
+
`\n 🔴 Remove unused:
|
|
300
|
+
fly secrets unset --app=${args.app} \\
|
|
301
|
+
${unusedStr}`
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Serializes key/value pairs
|
|
308
|
+
* @param {Obj} local
|
|
309
|
+
* @returns string
|
|
310
|
+
*/
|
|
311
|
+
export function serialize(local) {
|
|
312
|
+
/** @param {string} key */
|
|
313
|
+
return key => `${key}="${local[key]}"`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {Obj} local
|
|
318
|
+
* @param {string[]} remote
|
|
319
|
+
* @returns {{missingArr: string[], missingStr: string}}
|
|
320
|
+
*/
|
|
321
|
+
export function getMissing(local, remote) {
|
|
322
|
+
const { localDiff } = getDiff(Object.keys(local), remote)
|
|
323
|
+
return {
|
|
324
|
+
missingStr: localDiff.map(serialize(local)).join(' \\ \n '),
|
|
325
|
+
missingArr: localDiff,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {Obj} local
|
|
331
|
+
* @param {string[]} remote
|
|
332
|
+
* @returns {{unusedArr: string[], unusedStr: string}}
|
|
333
|
+
*/
|
|
334
|
+
export function getUnused(local, remote) {
|
|
335
|
+
const { remoteDiff } = getDiff(Object.keys(local), remote)
|
|
336
|
+
return {
|
|
337
|
+
unusedStr: remoteDiff.join(' \\ \n '),
|
|
338
|
+
unusedArr: remoteDiff,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Runs the script
|
|
344
|
+
* @param {Args} args
|
|
345
|
+
*/
|
|
346
|
+
async function run(args) {
|
|
347
|
+
if (args.help) {
|
|
348
|
+
usage()
|
|
349
|
+
process.exit(1)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await printDiff(args)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const runAsScript = process.argv[1] === import.meta.filename
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Execute the cli
|
|
359
|
+
*/
|
|
360
|
+
if (runAsScript) await run(args())
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fly-secrets-diff",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI tool to diff fly.io machine secrets and local secrets",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"diff",
|
|
7
|
+
"fly.io"
|
|
8
|
+
],
|
|
9
|
+
"license": "ISC",
|
|
10
|
+
"author": "hilja",
|
|
11
|
+
"repository": {
|
|
12
|
+
"url": "git+https://github.com/hilja/fly-secrets-diff.git"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"fly-secrets-diff": "./index.mjs",
|
|
16
|
+
"fsd": "./index.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.mjs"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "index.mjs",
|
|
24
|
+
"exports": {
|
|
25
|
+
"./package.json": "./package.json",
|
|
26
|
+
".": "./index.mjs"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "25.5.2",
|
|
30
|
+
"@typescript/native-preview": "7.0.0-dev.20260405.1",
|
|
31
|
+
"oxfmt": "0.43.0",
|
|
32
|
+
"oxlint": "1.58.0",
|
|
33
|
+
"oxlint-tsgolint": "0.19.0",
|
|
34
|
+
"vitest": "4.1.2"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"format": "oxfmt --write",
|
|
38
|
+
"lint": "oxlint --type-check",
|
|
39
|
+
"ncu": "pnpmx npm-check-updates --format=group,repo -i -p pnpm",
|
|
40
|
+
"pub": "(npm whoami || npm login) && pnpm publish --access=public --tag=latest",
|
|
41
|
+
"test": "vitest"
|
|
42
|
+
}
|
|
43
|
+
}
|