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.
Files changed (3) hide show
  1. package/README.md +66 -0
  2. package/index.mjs +360 -0
  3. 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
+ ![CLI output](https://i.postimg.cc/BnmwR9sF/Screenshot-2025-09-29-at-13-57-53.png)
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
+ }