@factorypure/devdb 0.0.0-stage → 0.1.1

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/bin/devdb.js +559 -0
  2. package/package.json +14 -4
  3. package/README.md +0 -3
package/bin/devdb.js ADDED
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn, spawnSync } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const HOME = process.env.DEVDB_HOME ?? path.join(os.homedir(), '.devdb')
8
+ const GOLDENS = path.join(HOME, 'goldens')
9
+ const CLONES = path.join(HOME, 'clones')
10
+ const DUMPS = path.join(HOME, 'dumps')
11
+ const RUN = path.join(HOME, 'run')
12
+ const STATE_FILE = path.join(HOME, 'state.json')
13
+
14
+ const BUCKET = 'factorypure-dev-db-snapshots'
15
+ const LATEST_KEY = 'dev-db/latest.json'
16
+ // apps hardcode port 3306 and only swap the host (see setupENVVars.js)
17
+ const PORT = Number(process.env.DEVDB_PORT ?? 3306)
18
+ const LOAD_PORT = 3399
19
+ const BUFFER_POOL = process.env.DEVDB_BUFFER_POOL ?? '2G'
20
+ const KEEP_GOLDENS = 2
21
+ const MYSQL_BIN = process.env.DEVDB_MYSQL_BIN ?? '/opt/homebrew/opt/mysql@8.4/bin'
22
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,40}$/i
23
+
24
+ const HELP = `devdb - local copies of the prod database (refreshed nightly by fpjobs dev-db-snapshot)
25
+
26
+ devdb pull [--env FILE] [--force] download the latest snapshot and load it into a new golden copy
27
+ (--env: create the DB_USER/DB_PASSWORD app user from that .env;
28
+ defaults to ./.env when present)
29
+ devdb new NAME instant copy-on-write clone of the newest golden, started on :${PORT}
30
+ devdb use NAME stop the active clone and start NAME
31
+ devdb reset [NAME] throw away NAME (default: active) and re-clone the newest golden
32
+ devdb rm NAME delete a clone
33
+ devdb start | stop start/stop the active clone
34
+ devdb user [--env FILE] create the app user from a .env on the running clone
35
+ devdb list goldens and clones
36
+ devdb doctor check prerequisites
37
+
38
+ Point apps at it with DB_DEVELOPMENT_HOST = 127.0.0.1, then run the branch's pending migrations.
39
+ Requires: brew install mysql@8.4 zstd, and AWS SSO credentials (aws sso login).`
40
+
41
+ const die = (message) => {
42
+ console.error(`devdb: ${message}`)
43
+ process.exit(1)
44
+ }
45
+
46
+ const bin = (name) => path.join(MYSQL_BIN, name)
47
+ const run = (command, args, options = {}) => execFileSync(command, args, { stdio: 'inherit', ...options })
48
+ const capture = (command, args) => execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] })
49
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
50
+ const listDirs = (dir) =>
51
+ fs.existsSync(dir)
52
+ ? fs
53
+ .readdirSync(dir, { withFileTypes: true })
54
+ .filter((e) => e.isDirectory())
55
+ .map((e) => e.name)
56
+ .sort()
57
+ : []
58
+
59
+ function readState() {
60
+ return fs.existsSync(STATE_FILE) ? JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) : { active: null, clones: {} }
61
+ }
62
+
63
+ function writeState(state) {
64
+ fs.mkdirSync(HOME, { recursive: true })
65
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
66
+ }
67
+
68
+ const completedGoldens = () => listDirs(GOLDENS).filter((name) => !name.endsWith('.loading'))
69
+
70
+ function latestGolden() {
71
+ const golden = completedGoldens().at(-1)
72
+ if (!golden) {
73
+ die('no golden copy yet, run `devdb pull` first')
74
+ }
75
+ return golden
76
+ }
77
+
78
+ function serverPaths(name, datadir) {
79
+ fs.mkdirSync(RUN, { recursive: true })
80
+ return {
81
+ name,
82
+ datadir,
83
+ socket: path.join(RUN, `${name}.sock`),
84
+ pid: path.join(RUN, `${name}.pid`),
85
+ log: path.join(RUN, `${name}.log`),
86
+ }
87
+ }
88
+
89
+ const clonePaths = (name) => serverPaths(name, path.join(CLONES, name))
90
+
91
+ function validateName(name) {
92
+ if (!name || !NAME_PATTERN.test(name)) {
93
+ die('clone names may use letters, digits, ".", "_" and "-"')
94
+ }
95
+ }
96
+
97
+ function isRunning(paths) {
98
+ if (!fs.existsSync(paths.pid)) {
99
+ return false
100
+ }
101
+ try {
102
+ process.kill(Number(fs.readFileSync(paths.pid, 'utf8')), 0)
103
+ return true
104
+ } catch {
105
+ return false
106
+ }
107
+ }
108
+
109
+ function assertPortFree(port) {
110
+ const result = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8' })
111
+ if (result.stdout.trim()) {
112
+ die(`port ${port} is already in use (pid ${result.stdout.trim()}); stop that server first (e.g. brew services stop mysql@8.4)`)
113
+ }
114
+ }
115
+
116
+ const ping = (paths) =>
117
+ spawnSync(bin('mysqladmin'), ['--no-defaults', `--socket=${paths.socket}`, '-uroot', 'ping'], { stdio: 'ignore' })
118
+ .status === 0
119
+
120
+ async function startServer(paths, port, { loading = false } = {}) {
121
+ if (isRunning(paths)) {
122
+ return
123
+ }
124
+ assertPortFree(port)
125
+ const args = [
126
+ '--no-defaults',
127
+ `--datadir=${paths.datadir}`,
128
+ `--port=${port}`,
129
+ '--bind-address=127.0.0.1',
130
+ `--socket=${paths.socket}`,
131
+ `--pid-file=${paths.pid}`,
132
+ `--log-error=${paths.log}`,
133
+ '--mysqlx=OFF',
134
+ '--skip-log-bin',
135
+ // match prod
136
+ '--sql-mode=IGNORE_SPACE,NO_ENGINE_SUBSTITUTION',
137
+ '--default-time-zone=+00:00',
138
+ '--log-bin-trust-function-creators=1',
139
+ // prod has foreign keys onto non-unique keys (e.g. draft_order_line_items -> draft_orders), which 8.4 rejects by default
140
+ '--restrict-fk-on-non-standard-key=OFF',
141
+ // the apps' `mysql` npm driver can't do 8.4's default caching_sha2_password
142
+ '--mysql-native-password=ON',
143
+ '--character-set-server=utf8mb4',
144
+ '--collation-server=utf8mb4_0900_ai_ci',
145
+ '--max-connections=500',
146
+ `--innodb-buffer-pool-size=${BUFFER_POOL}`,
147
+ '--innodb-redo-log-capacity=2G',
148
+ // durability doesn't matter for a throwaway copy; these make loading much faster
149
+ `--innodb-flush-log-at-trx-commit=${loading ? 0 : 2}`,
150
+ ...(loading ? ['--innodb-doublewrite=OFF'] : []),
151
+ ]
152
+ spawn(bin('mysqld'), args, { detached: true, stdio: 'ignore' }).unref()
153
+ for (let i = 0; i < 240; i++) {
154
+ if (ping(paths)) {
155
+ return
156
+ }
157
+ await sleep(500)
158
+ }
159
+ const log = fs.existsSync(paths.log) ? fs.readFileSync(paths.log, 'utf8').split('\n').slice(-20).join('\n') : ''
160
+ die(`mysqld for ${paths.name} didn't start. Last log lines:\n${log}`)
161
+ }
162
+
163
+ async function stopServer(paths) {
164
+ if (!isRunning(paths)) {
165
+ return
166
+ }
167
+ spawnSync(bin('mysqladmin'), ['--no-defaults', `--socket=${paths.socket}`, '-uroot', 'shutdown'], { stdio: 'inherit' })
168
+ for (let i = 0; i < 600 && isRunning(paths); i++) {
169
+ await sleep(500)
170
+ }
171
+ if (isRunning(paths)) {
172
+ die(`mysqld for ${paths.name} didn't stop, see ${paths.log}`)
173
+ }
174
+ }
175
+
176
+ const sqlString = (value) => `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
177
+
178
+ function mysqlRoot(paths, sql) {
179
+ const result = spawnSync(bin('mysql'), ['--no-defaults', `--socket=${paths.socket}`, '-uroot'], {
180
+ input: sql,
181
+ stdio: ['pipe', 'inherit', 'inherit'],
182
+ })
183
+ if (result.status !== 0) {
184
+ die('mysql command failed')
185
+ }
186
+ }
187
+
188
+ function parseEnvFile(file) {
189
+ const values = {}
190
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
191
+ const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*?)\s*$/)
192
+ if (match) {
193
+ values[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2')
194
+ }
195
+ }
196
+ return values
197
+ }
198
+
199
+ function appCredentials(flags) {
200
+ const file = flags.env ?? (fs.existsSync('.env') ? '.env' : null)
201
+ if (!file) {
202
+ return null
203
+ }
204
+ const { DB_USER, DB_PASSWORD } = parseEnvFile(file)
205
+ if (!DB_USER || !DB_PASSWORD) {
206
+ die(`${file} has no DB_USER/DB_PASSWORD`)
207
+ }
208
+ return { user: DB_USER, password: DB_PASSWORD, file }
209
+ }
210
+
211
+ function createAppUser(paths, credentials) {
212
+ const account = `${sqlString(credentials.user)}@'%'`
213
+ mysqlRoot(
214
+ paths,
215
+ `CREATE USER IF NOT EXISTS ${account} IDENTIFIED WITH mysql_native_password BY ${sqlString(credentials.password)};
216
+ ALTER USER ${account} IDENTIFIED WITH mysql_native_password BY ${sqlString(credentials.password)};
217
+ GRANT ALL ON *.* TO ${account};`
218
+ )
219
+ console.log(`App user ${credentials.user} created from ${credentials.file}`)
220
+ }
221
+
222
+ function cloneFromGolden(name, golden) {
223
+ fs.mkdirSync(CLONES, { recursive: true })
224
+ // -c uses APFS clonefile(2): instant, and blocks are only copied once either side changes
225
+ run('cp', ['-c', '-R', path.join(GOLDENS, golden), path.join(CLONES, name)])
226
+ }
227
+
228
+ function ensureAppUser(paths, flags) {
229
+ const credentials = appCredentials(flags)
230
+ if (credentials) {
231
+ createAppUser(paths, credentials)
232
+ } else {
233
+ console.log('No .env here: run `devdb user --env path/to/.env` so the apps can log in')
234
+ }
235
+ }
236
+
237
+ // Runs one mydumper file (zstd-compressed SQL with its own SET header) through the mysql client.
238
+ function sqlFile(paths, file, database = 'main') {
239
+ return new Promise((resolve, reject) => {
240
+ const zstd = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'pipe'] })
241
+ const mysql = spawn(
242
+ bin('mysql'),
243
+ ['--no-defaults', `--socket=${paths.socket}`, '-uroot', '--max-allowed-packet=1G', ...(database ? [database] : [])],
244
+ { stdio: ['pipe', 'ignore', 'pipe'] }
245
+ )
246
+ zstd.stdout.pipe(mysql.stdin)
247
+ let stderr = ''
248
+ zstd.stderr.on('data', (d) => (stderr += d))
249
+ mysql.stderr.on('data', (d) => (stderr += d))
250
+ mysql.on('close', (code) =>
251
+ code === 0 ? resolve() : reject(new Error(`${path.basename(file)}: ${stderr.trim()}`))
252
+ )
253
+ })
254
+ }
255
+
256
+ async function inParallel(items, concurrency, task) {
257
+ let next = 0
258
+ const worker = async () => {
259
+ while (next < items.length) {
260
+ await task(items[next++])
261
+ }
262
+ }
263
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
264
+ }
265
+
266
+ function sqlText(paths, text) {
267
+ const result = spawnSync(
268
+ bin('mysql'),
269
+ ['--no-defaults', `--socket=${paths.socket}`, '-uroot', 'main'],
270
+ { input: text, encoding: 'utf8' }
271
+ )
272
+ if (result.status !== 0) {
273
+ return Promise.reject(new Error(result.stderr.trim()))
274
+ }
275
+ return Promise.resolve()
276
+ }
277
+
278
+ // mydumper trigger files rely on myloader to split statements; the mysql client needs DELIMITER around bodies with `;`.
279
+ function withTriggerDelimiters(sql) {
280
+ const out = []
281
+ let depth = 0
282
+ for (const line of sql.split('\n')) {
283
+ if (depth === 0 && /^CREATE\b.*\bTRIGGER\b.*\bBEGIN\b/i.test(line)) {
284
+ out.push('DELIMITER ;;')
285
+ }
286
+ const opened = depth > 0 || /^CREATE\b.*\bTRIGGER\b/i.test(line)
287
+ depth += opened ? (line.match(/\bBEGIN\b/gi) ?? []).length : 0
288
+ if (depth > 0 && /^\s*END;\s*$/i.test(line) && --depth === 0) {
289
+ out.push(line.replace(/;\s*$/, ';;'), 'DELIMITER ;')
290
+ continue
291
+ }
292
+ out.push(line)
293
+ }
294
+ return out.join('\n')
295
+ }
296
+
297
+ // myloader 1.0.5 stalls after creating a handful of tables from this dump, so the files are loaded directly.
298
+ async function loadDump(dumpDir, paths) {
299
+ const files = fs.readdirSync(dumpDir)
300
+ const full = (f) => path.join(dumpDir, f)
301
+ const isView = new Set(files.filter((f) => f.endsWith('-schema-view.sql.zst')).map((f) => f.replace('-schema-view', '-schema')))
302
+ // view placeholder tables are skipped; the real views are created after the data
303
+ const tables = files.filter((f) => f.endsWith('-schema.sql.zst') && f !== 'main-schema-create.sql.zst' && !isView.has(f))
304
+ const views = files.filter((f) => f.endsWith('-schema-view.sql.zst'))
305
+ const triggers = files.filter((f) => f.endsWith('-schema-triggers.sql.zst'))
306
+ const data = files
307
+ .filter((f) => /\.\d+\.sql\.zst$/.test(f))
308
+ .map((f) => ({ f, size: fs.statSync(full(f)).size }))
309
+ .sort((a, b) => b.size - a.size)
310
+ const totalBytes = data.reduce((sum, d) => sum + d.size, 0)
311
+ const threads = os.cpus().length
312
+
313
+ await sqlFile(paths, full('main-schema-create.sql.zst'), null)
314
+ // one at a time: concurrent CREATE TABLEs with foreign keys deadlock on the data dictionary
315
+ await inParallel(tables, 1, (f) => sqlFile(paths, full(f)))
316
+ console.log(`Created ${tables.length} tables, loading ${data.length} data files (${(totalBytes / 1024 ** 3).toFixed(1)} GB compressed)...`)
317
+
318
+ const startedAt = Date.now()
319
+ let doneBytes = 0
320
+ let doneFiles = 0
321
+ const progress = setInterval(() => {
322
+ const minutes = ((Date.now() - startedAt) / 60000).toFixed(1)
323
+ console.log(` ${doneFiles}/${data.length} files, ${Math.round((doneBytes / totalBytes) * 100)}% of bytes, ${minutes} min`)
324
+ }, 30000)
325
+ try {
326
+ await inParallel(data, threads, async ({ f, size }) => {
327
+ await sqlFile(paths, full(f))
328
+ doneBytes += size
329
+ doneFiles++
330
+ })
331
+ } finally {
332
+ clearInterval(progress)
333
+ }
334
+ console.log(`Data loaded in ${((Date.now() - startedAt) / 60000).toFixed(1)} min`)
335
+
336
+ // a view can select from another view, so failures get retried once the rest exist
337
+ let pending = views
338
+ for (let pass = 0; pending.length && pass < 5; pass++) {
339
+ const failed = []
340
+ for (const f of pending) {
341
+ await sqlFile(paths, full(f)).catch((err) => failed.push({ f, err }))
342
+ }
343
+ if (failed.length === pending.length) {
344
+ die(`could not create views:\n${failed.map((x) => x.err.message).join('\n')}`)
345
+ }
346
+ pending = failed.map((x) => x.f)
347
+ }
348
+ if (pending.length) {
349
+ die(`views still failing after retries: ${pending.join(', ')}`)
350
+ }
351
+ // created last so they don't fire for every loaded row
352
+ for (const f of triggers) {
353
+ await sqlText(paths, withTriggerDelimiters(execFileSync('zstd', ['-dc', full(f)], { encoding: 'utf8' })))
354
+ }
355
+ console.log(`Created ${views.length} views and ${triggers.length} trigger files`)
356
+ }
357
+
358
+ function parseFlags(args) {
359
+ const flags = {}
360
+ const positional = []
361
+ for (let i = 0; i < args.length; i++) {
362
+ if (args[i] === '--force') {
363
+ flags.force = true
364
+ } else if (args[i] === '--env') {
365
+ flags.env = args[++i]
366
+ } else {
367
+ positional.push(args[i])
368
+ }
369
+ }
370
+ return { flags, positional }
371
+ }
372
+
373
+ async function stopActive(state) {
374
+ if (state.active) {
375
+ await stopServer(clonePaths(state.active))
376
+ }
377
+ }
378
+
379
+ const commands = {
380
+ async pull(flags) {
381
+ const startedAt = Date.now()
382
+ const manifest = JSON.parse(capture('aws', ['s3', 'cp', `s3://${BUCKET}/${LATEST_KEY}`, '-']))
383
+ const { stamp } = manifest
384
+ if (completedGoldens().includes(stamp) && !flags.force) {
385
+ console.log(`Already have the latest snapshot (${stamp}).`)
386
+ return
387
+ }
388
+ const credentials = appCredentials(flags)
389
+ console.log(
390
+ `Downloading ${stamp} (${(manifest.bytes / 1024 ** 3).toFixed(1)} GB, prod snapshot from ${manifest.snapshotCreatedAt})`
391
+ )
392
+ const dumpDir = path.join(DUMPS, stamp)
393
+ run('aws', ['s3', 'sync', `s3://${BUCKET}/${manifest.prefix}`, dumpDir, '--only-show-errors'])
394
+
395
+ const loadingDir = path.join(GOLDENS, `${stamp}.loading`)
396
+ fs.rmSync(loadingDir, { recursive: true, force: true })
397
+ fs.mkdirSync(GOLDENS, { recursive: true })
398
+ run(bin('mysqld'), ['--no-defaults', '--initialize-insecure', `--datadir=${loadingDir}`])
399
+
400
+ const loader = serverPaths('loader', loadingDir)
401
+ await startServer(loader, LOAD_PORT, { loading: true })
402
+ try {
403
+ await loadDump(dumpDir, loader)
404
+ if (credentials) {
405
+ createAppUser(loader, credentials)
406
+ }
407
+ } finally {
408
+ await stopServer(loader)
409
+ }
410
+ fs.renameSync(loadingDir, path.join(GOLDENS, stamp))
411
+ fs.rmSync(dumpDir, { recursive: true, force: true })
412
+
413
+ for (const old of completedGoldens().slice(0, -KEEP_GOLDENS)) {
414
+ fs.rmSync(path.join(GOLDENS, old), { recursive: true, force: true })
415
+ }
416
+ const minutes = Math.round((Date.now() - startedAt) / 60000)
417
+ const { active } = readState()
418
+ console.log(
419
+ `Golden ${stamp} ready in ${minutes} min. ${active ? `Run \`devdb reset\` to move ${active} onto it` : 'Run `devdb new <name>` to create a clone'}.`
420
+ )
421
+ },
422
+
423
+ async new(flags, [name]) {
424
+ validateName(name)
425
+ const state = readState()
426
+ if (fs.existsSync(path.join(CLONES, name))) {
427
+ die(`${name} already exists (use \`devdb use ${name}\` or \`devdb reset ${name}\`)`)
428
+ }
429
+ const golden = latestGolden()
430
+ await stopActive(state)
431
+ cloneFromGolden(name, golden)
432
+ await startServer(clonePaths(name), PORT)
433
+ ensureAppUser(clonePaths(name), flags)
434
+ state.clones[name] = { golden, createdAt: new Date().toISOString() }
435
+ state.active = name
436
+ writeState(state)
437
+ console.log(`${name} (from ${golden}) is running on 127.0.0.1:${PORT}`)
438
+ },
439
+
440
+ async use(flags, [name]) {
441
+ validateName(name)
442
+ const state = readState()
443
+ if (!fs.existsSync(path.join(CLONES, name))) {
444
+ die(`no clone named ${name}`)
445
+ }
446
+ if (state.active !== name) {
447
+ await stopActive(state)
448
+ }
449
+ await startServer(clonePaths(name), PORT)
450
+ state.active = name
451
+ writeState(state)
452
+ console.log(`${name} is running on 127.0.0.1:${PORT}`)
453
+ },
454
+
455
+ async reset(flags, [name]) {
456
+ const state = readState()
457
+ name ??= state.active
458
+ validateName(name)
459
+ const golden = latestGolden()
460
+ await stopServer(clonePaths(name))
461
+ fs.rmSync(path.join(CLONES, name), { recursive: true, force: true })
462
+ if (state.active && state.active !== name) {
463
+ await stopActive(state)
464
+ }
465
+ cloneFromGolden(name, golden)
466
+ await startServer(clonePaths(name), PORT)
467
+ ensureAppUser(clonePaths(name), flags)
468
+ state.clones[name] = { golden, createdAt: new Date().toISOString() }
469
+ state.active = name
470
+ writeState(state)
471
+ console.log(`${name} reset to ${golden} and running on 127.0.0.1:${PORT}`)
472
+ },
473
+
474
+ async rm(flags, [name]) {
475
+ validateName(name)
476
+ const state = readState()
477
+ await stopServer(clonePaths(name))
478
+ fs.rmSync(path.join(CLONES, name), { recursive: true, force: true })
479
+ delete state.clones[name]
480
+ if (state.active === name) {
481
+ state.active = null
482
+ }
483
+ writeState(state)
484
+ console.log(`Removed ${name}`)
485
+ },
486
+
487
+ async start() {
488
+ const { active } = readState()
489
+ if (!active) {
490
+ die('no active clone, run `devdb new <name>`')
491
+ }
492
+ await startServer(clonePaths(active), PORT)
493
+ console.log(`${active} is running on 127.0.0.1:${PORT}`)
494
+ },
495
+
496
+ async stop() {
497
+ const { active } = readState()
498
+ if (active) {
499
+ await stopServer(clonePaths(active))
500
+ console.log(`Stopped ${active}`)
501
+ }
502
+ },
503
+
504
+ async user(flags) {
505
+ const { active } = readState()
506
+ const paths = active && clonePaths(active)
507
+ if (!paths || !isRunning(paths)) {
508
+ die('start a clone first')
509
+ }
510
+ const credentials = appCredentials(flags)
511
+ if (!credentials) {
512
+ die('pass --env path/to/.env')
513
+ }
514
+ createAppUser(paths, credentials)
515
+ },
516
+
517
+ async list() {
518
+ const state = readState()
519
+ console.log('Goldens:')
520
+ for (const golden of completedGoldens()) {
521
+ console.log(` ${golden}`)
522
+ }
523
+ console.log('Clones:')
524
+ for (const name of listDirs(CLONES)) {
525
+ const running = isRunning(clonePaths(name)) ? `running on :${PORT}` : 'stopped'
526
+ const marker = state.active === name ? '*' : ' '
527
+ console.log(` ${marker}${name} from ${state.clones[name]?.golden ?? '?'} ${running}`)
528
+ }
529
+ },
530
+
531
+ async doctor() {
532
+ const checks = [
533
+ ['mysqld (brew install mysql@8.4)', () => capture(bin('mysqld'), ['--version'])],
534
+ ['zstd (brew install zstd)', () => capture('zstd', ['--version'])],
535
+ ['aws cli', () => capture('aws', ['--version'])],
536
+ // `aws s3 ls` exits 1 on an empty prefix; list-objects-v2 doesn't
537
+ ['AWS credentials (aws sso login)', () => capture('aws', ['s3api', 'list-objects-v2', '--bucket', BUCKET, '--prefix', 'dev-db/', '--max-keys', '1'])],
538
+ ]
539
+ let ok = true
540
+ for (const [label, check] of checks) {
541
+ try {
542
+ check()
543
+ console.log(`ok ${label}`)
544
+ } catch {
545
+ ok = false
546
+ console.log(`FAIL ${label}`)
547
+ }
548
+ }
549
+ process.exitCode = ok ? 0 : 1
550
+ },
551
+ }
552
+
553
+ const [command, ...rest] = process.argv.slice(2)
554
+ if (!command || command === 'help' || command === '--help' || !commands[command]) {
555
+ console.log(HELP)
556
+ process.exit(command && !['help', '--help'].includes(command) ? 1 : 0)
557
+ }
558
+ const { flags, positional } = parseFlags(rest)
559
+ await commands[command](flags, positional)
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "@factorypure/devdb",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Local copies of the prod MySQL database for development",
5
+ "type": "module",
6
+ "bin": {
7
+ "devdb": "bin/devdb.js"
8
+ },
9
+ "files": [
10
+ "bin"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "license": "ISC"
16
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.