@hearthkit/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.
Files changed (36) hide show
  1. package/package.json +45 -0
  2. package/src/cli-contract.ts +423 -0
  3. package/src/cli-failure-results.ts +175 -0
  4. package/src/cli-output-streams.ts +14 -0
  5. package/src/cli-runtime-context.ts +8 -0
  6. package/src/default-backup-file-path.ts +28 -0
  7. package/src/derive-hearthkit-project-name.ts +27 -0
  8. package/src/derive-local-storage-bucket-name.test.ts +89 -0
  9. package/src/derive-local-storage-bucket-name.ts +24 -0
  10. package/src/docker-compose-commands.ts +100 -0
  11. package/src/format-doctor-report.ts +25 -0
  12. package/src/generate-local-infra-compose.test.ts +215 -0
  13. package/src/generate-local-infra-compose.ts +154 -0
  14. package/src/hearthkit-bin-entry.js +19 -0
  15. package/src/hearthkit-bin-execution.test.ts +136 -0
  16. package/src/hearthkit-bin.ts +8 -0
  17. package/src/index.ts +107 -0
  18. package/src/load-payments-catalog-module.ts +60 -0
  19. package/src/parse-cli-invocation.ts +365 -0
  20. package/src/read-environment-variable-value.ts +14 -0
  21. package/src/read-project-infra-manifest.ts +99 -0
  22. package/src/report-cli-outcome.ts +139 -0
  23. package/src/resolve-admin-database-url.ts +72 -0
  24. package/src/resolve-local-infra-compose-file.ts +80 -0
  25. package/src/run-child-process-command.ts +90 -0
  26. package/src/run-db-lifecycle-command.ts +103 -0
  27. package/src/run-dev-command.ts +104 -0
  28. package/src/run-dev-infra-command.ts +115 -0
  29. package/src/run-doctor-checks.ts +339 -0
  30. package/src/run-hearthkit-cli-db-commands.test.ts +330 -0
  31. package/src/run-hearthkit-cli-dev-infra-bucket.test.ts +254 -0
  32. package/src/run-hearthkit-cli-dev-infra.test.ts +616 -0
  33. package/src/run-hearthkit-cli-doctor.test.ts +75 -0
  34. package/src/run-hearthkit-cli-payments-sync.test.ts +180 -0
  35. package/src/run-hearthkit-cli.ts +62 -0
  36. package/src/run-payments-sync-command.ts +113 -0
@@ -0,0 +1,616 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+ import { afterAll, describe, expect, it } from 'vitest'
4
+ import {
5
+ expectCliFailure,
6
+ expectCliSuccess,
7
+ runHearthkitCliGate,
8
+ singleStandardOutputLine,
9
+ } from '../test-fixtures/cli-run-expectations.ts'
10
+ import {
11
+ gateContainerExists,
12
+ gateContainerIsRunning,
13
+ removeGateComposeNetworks,
14
+ removeGateContainer,
15
+ } from '../test-fixtures/docker-gate-containers.ts'
16
+ import {
17
+ remapGeneratedMailpitHostPorts,
18
+ remapGeneratedPostgresHostPort,
19
+ removeGateComposeProject,
20
+ reserveFreeHostPort,
21
+ } from '../test-fixtures/gate-compose-project-runs.ts'
22
+ import {
23
+ createEmptyPathDirectory,
24
+ createGateDirectory,
25
+ existingComposeFileContent,
26
+ gateEnvironment,
27
+ makeGateDirectoryReadOnly,
28
+ removeGateDirectory,
29
+ unbuildableComposeFileContent,
30
+ uniqueGateProjectName,
31
+ withPathReplaced,
32
+ writeFakeNextBinary,
33
+ writeGateComposeFile,
34
+ writeGateProjectManifest,
35
+ } from '../test-fixtures/gate-project-directories.ts'
36
+ import { loadHearthkitCliEntry } from '../test-fixtures/hearthkit-cli-entry.ts'
37
+ import { loadHearthkitCliInfraServiceMap } from '../test-fixtures/hearthkit-cli-infra-service-map.ts'
38
+ import {
39
+ cliComposeFileUnwritableErrorPrefix,
40
+ cliDevInfraDownCompleteLinePrefix,
41
+ cliDevInfraUpCompleteLinePrefix,
42
+ cliDockerUnavailableErrorPrefix,
43
+ cliInfraComposeFailedErrorPrefix,
44
+ cliNextDevUnavailableErrorPrefix,
45
+ cliProjectManifestMissingErrorPrefix,
46
+ generateLocalInfraComposeOptionsSchema,
47
+ localInfraServiceImageByName,
48
+ localInfraServiceNameSchema,
49
+ type LocalInfraServiceName,
50
+ } from './cli-contract.ts'
51
+
52
+ const directoriesToRemove: string[] = []
53
+ const containersToRemove: string[] = []
54
+ const composeProjectNamesToClean: string[] = []
55
+
56
+ /** A throwaway project directory this file will remove, whatever the gate managed to write into it. */
57
+ async function gateProjectDirectory(purpose: string): Promise<string> {
58
+ const directoryPath = await createGateDirectory(purpose)
59
+ directoriesToRemove.push(directoryPath)
60
+ return directoryPath
61
+ }
62
+
63
+ /** Registers a container name for the safety-net cleanup; each gate still removes its own before finishing. */
64
+ function gateContainerName(containerName: string): string {
65
+ containersToRemove.push(containerName)
66
+ return containerName
67
+ }
68
+
69
+ /**
70
+ * A unique compose project name that is also registered for network cleanup. Compose names its
71
+ * network after the project, which is the generated name when the CLI wrote the compose file and the
72
+ * working directory name when it used one already there, so both candidates get registered.
73
+ */
74
+ function gateComposeProjectName(purpose: string): string {
75
+ const hearthkitProjectName = uniqueGateProjectName(purpose)
76
+ composeProjectNamesToClean.push(hearthkitProjectName)
77
+ return hearthkitProjectName
78
+ }
79
+
80
+ /**
81
+ * One compose file with only its published mailpit host ports moved onto ports this run reserved.
82
+ * Nothing else in the file changes: mailpit still listens on 1025 and 8025 inside the compose
83
+ * network, so anything addressing it as mailpit:1025 is unaffected, and only the host side moves.
84
+ */
85
+ async function remapMailpitHostPortsOntoFreePorts(composeFileContent: string): Promise<string> {
86
+ const [smtpHostPort, webHostPort] = await Promise.all([
87
+ reserveFreeHostPort(),
88
+ reserveFreeHostPort(),
89
+ ])
90
+ if (smtpHostPort === webHostPort) {
91
+ throw new Error('gate reserved the same host port twice for one Mailpit stack')
92
+ }
93
+ return remapGeneratedMailpitHostPorts({ composeFileContent, smtpHostPort, webHostPort })
94
+ }
95
+
96
+ /**
97
+ * Writes the real generated mailpit compose file into a gate's project directory, published on
98
+ * reserved ports. dev infra up never overwrites a compose file it finds, so these are exactly the
99
+ * bytes it starts. Gates that claim the CLI writes this file generate it with the CLI instead.
100
+ */
101
+ async function writeRemappedMailpitComposeFile(options: {
102
+ directoryPath: string
103
+ hearthkitProjectName: string
104
+ }): Promise<string> {
105
+ const { generateLocalInfraCompose } = await loadHearthkitCliEntry()
106
+ const generatedComposeFileContent = generateLocalInfraCompose(
107
+ generateLocalInfraComposeOptionsSchema.parse({
108
+ hearthkitProjectName: options.hearthkitProjectName,
109
+ infraServices: ['mailpit'],
110
+ }),
111
+ )
112
+ return writeGateComposeFile(
113
+ options.directoryPath,
114
+ await remapMailpitHostPortsOntoFreePorts(generatedComposeFileContent),
115
+ )
116
+ }
117
+
118
+ afterAll(async () => {
119
+ for (const containerName of containersToRemove) {
120
+ await removeGateContainer(containerName)
121
+ }
122
+ await removeGateComposeNetworks([
123
+ ...composeProjectNamesToClean,
124
+ ...directoriesToRemove.map((directoryPath) => basename(directoryPath)),
125
+ ])
126
+ for (const directoryPath of directoriesToRemove) {
127
+ await removeGateDirectory(directoryPath)
128
+ }
129
+ })
130
+
131
+ // Every gate here uses mailpit rather than postgres, and every gate that needs a started service
132
+ // publishes it on a host port this run reserved. The generated compose file publishes fixed host
133
+ // ports for all three services, and the repo-root compose holds every one of them: 5432 for
134
+ // postgres, 9000 and 9001 for minio, and 1025 and 8025 for mailpit since the email package's gates
135
+ // arrived. Only the published host ports move; the ports the services listen on inside the compose
136
+ // network are the generated ones, so mailpit:1025 keeps meaning what it means in a real project.
137
+ describe('hearthkit dev and dev infra', () => {
138
+ it('generates docker-compose.yml from the manifest and starts the services it names', async () => {
139
+ const directoryPath = await gateProjectDirectory('infra-up')
140
+ const hearthkitProjectName = gateComposeProjectName('up')
141
+ await writeGateProjectManifest({
142
+ directoryPath,
143
+ manifestName: `@gate/${hearthkitProjectName}`,
144
+ hearthkitDependencies: ['@hearthkit/email'],
145
+ })
146
+ const containerName = gateContainerName(`${hearthkitProjectName}-mailpit`)
147
+ const composeFilePath = join(directoryPath, 'docker-compose.yml')
148
+ const { generateLocalInfraCompose } = await loadHearthkitCliEntry()
149
+
150
+ try {
151
+ // The generating half. This run writes docker-compose.yml from the manifest and then asks
152
+ // compose to start it on the generated host ports 1025 and 8025, which the repo's own Mailpit
153
+ // holds — so whether compose can bind them depends on what else is running, and the exit code
154
+ // is not this gate's claim. The bytes the CLI wrote do not depend on that, and they are
155
+ // asserted in full below; a run that failed any earlier would leave no file to read.
156
+ const generatingRun = await runHearthkitCliGate({
157
+ argv: ['dev', 'infra', 'up'],
158
+ cwd: directoryPath,
159
+ env: gateEnvironment(),
160
+ })
161
+ expect(['dev-infra-up-succeeded', 'infra-compose-failed']).toContain(
162
+ generatingRun.outcome.result.kind,
163
+ )
164
+
165
+ const composeFileContent = await readFile(composeFilePath, 'utf8')
166
+ expect(composeFileContent).toContain(localInfraServiceImageByName.mailpit)
167
+ expect(composeFileContent).not.toContain(localInfraServiceImageByName.postgres)
168
+ // Byte for byte what the public generator emits for the one service @hearthkit/email selects,
169
+ // under the project name the scoped manifest name @gate/<name> derives to.
170
+ expect(composeFileContent).toBe(
171
+ generateLocalInfraCompose(
172
+ generateLocalInfraComposeOptionsSchema.parse({
173
+ hearthkitProjectName,
174
+ infraServices: ['mailpit'],
175
+ }),
176
+ ),
177
+ )
178
+
179
+ // The starting half. dev infra up never overwrites a compose file it finds, so the second run
180
+ // starts exactly the file the first run generated, with only its published ports moved.
181
+ await writeGateComposeFile(
182
+ directoryPath,
183
+ await remapMailpitHostPortsOntoFreePorts(composeFileContent),
184
+ )
185
+ const run = await runHearthkitCliGate({
186
+ argv: ['dev', 'infra', 'up'],
187
+ cwd: directoryPath,
188
+ env: gateEnvironment(),
189
+ })
190
+
191
+ const success = expectCliSuccess(run, 'dev-infra-up-succeeded', 0)
192
+ expect(success.startedInfraServices).toEqual(['mailpit'])
193
+ expect(await gateContainerIsRunning(containerName)).toBe(true)
194
+ const line = singleStandardOutputLine(run)
195
+ expect(line.startsWith(cliDevInfraUpCompleteLinePrefix)).toBe(true)
196
+ expect(line).toContain('mailpit')
197
+ } finally {
198
+ await removeGateContainer(containerName)
199
+ }
200
+ })
201
+
202
+ it('never overwrites a docker-compose.yml that is already in the working directory', async () => {
203
+ const directoryPath = await gateProjectDirectory('infra-existing')
204
+ const hearthkitProjectName = gateComposeProjectName('existing')
205
+ await writeGateProjectManifest({
206
+ directoryPath,
207
+ manifestName: hearthkitProjectName,
208
+ hearthkitDependencies: ['@hearthkit/email'],
209
+ })
210
+ const containerName = gateContainerName(`${hearthkitProjectName}-existing-mailpit`)
211
+ const writtenComposeFileContent = existingComposeFileContent(containerName)
212
+ const composeFilePath = await writeGateComposeFile(directoryPath, writtenComposeFileContent)
213
+
214
+ try {
215
+ const run = await runHearthkitCliGate({
216
+ argv: ['dev', 'infra', 'up'],
217
+ cwd: directoryPath,
218
+ env: gateEnvironment(),
219
+ })
220
+
221
+ const success = expectCliSuccess(run, 'dev-infra-up-succeeded', 0)
222
+ // The hand-written file names one service, mailpit, which is a known local infra service, so
223
+ // the compose service list filtered to known names is exactly that one.
224
+ expect(success.startedInfraServices).toEqual(['mailpit'])
225
+ expect(await readFile(composeFilePath, 'utf8')).toBe(writtenComposeFileContent)
226
+ expect(await gateContainerIsRunning(containerName)).toBe(true)
227
+ } finally {
228
+ await removeGateContainer(containerName)
229
+ }
230
+ })
231
+
232
+ it('stops and removes the services dev infra up started', async () => {
233
+ const directoryPath = await gateProjectDirectory('infra-down')
234
+ const hearthkitProjectName = gateComposeProjectName('down')
235
+ await writeGateProjectManifest({
236
+ directoryPath,
237
+ manifestName: hearthkitProjectName,
238
+ hearthkitDependencies: ['@hearthkit/email'],
239
+ })
240
+ // The generated file with only its published mailpit ports moved: what dev infra up starts here
241
+ // is the real generated service, and what dev infra down has to remove.
242
+ await writeRemappedMailpitComposeFile({ directoryPath, hearthkitProjectName })
243
+ const containerName = gateContainerName(`${hearthkitProjectName}-mailpit`)
244
+
245
+ try {
246
+ expectCliSuccess(
247
+ await runHearthkitCliGate({
248
+ argv: ['dev', 'infra', 'up'],
249
+ cwd: directoryPath,
250
+ env: gateEnvironment(),
251
+ }),
252
+ 'dev-infra-up-succeeded',
253
+ 0,
254
+ )
255
+ expect(await gateContainerIsRunning(containerName)).toBe(true)
256
+
257
+ const run = await runHearthkitCliGate({
258
+ argv: ['dev', 'infra', 'down'],
259
+ cwd: directoryPath,
260
+ env: gateEnvironment(),
261
+ })
262
+
263
+ expectCliSuccess(run, 'dev-infra-down-succeeded', 0)
264
+ expect(singleStandardOutputLine(run).startsWith(cliDevInfraDownCompleteLinePrefix)).toBe(true)
265
+ expect(await gateContainerExists(containerName)).toBe(false)
266
+ } finally {
267
+ await removeGateContainer(containerName)
268
+ }
269
+ })
270
+
271
+ it('succeeds without doing anything when dev infra down finds no compose file in the directory', async () => {
272
+ const directoryPath = await gateProjectDirectory('infra-down-noop')
273
+
274
+ const run = await runHearthkitCliGate({
275
+ argv: ['dev', 'infra', 'down'],
276
+ cwd: directoryPath,
277
+ env: gateEnvironment(),
278
+ })
279
+
280
+ expectCliSuccess(run, 'dev-infra-down-succeeded', 0)
281
+ expect(singleStandardOutputLine(run).startsWith(cliDevInfraDownCompleteLinePrefix)).toBe(true)
282
+ })
283
+
284
+ it('brings infra up, runs the project next binary and exits with the code that binary exited with', async () => {
285
+ const directoryPath = await gateProjectDirectory('dev-command')
286
+ const hearthkitProjectName = gateComposeProjectName('dev')
287
+ await writeGateProjectManifest({
288
+ directoryPath,
289
+ manifestName: hearthkitProjectName,
290
+ hearthkitDependencies: ['@hearthkit/email'],
291
+ })
292
+ // The generated file with only its published mailpit ports moved, so dev brings up the real
293
+ // generated service before it reaches the next binary.
294
+ await writeRemappedMailpitComposeFile({ directoryPath, hearthkitProjectName })
295
+ // A stand-in binary, not a mock of hearthkit code: it proves the exec happened and the exit code
296
+ // travelled back without installing Next.js into a gate fixture.
297
+ const { markerFilePath } = await writeFakeNextBinary({ directoryPath, exitCode: 7 })
298
+ const containerName = gateContainerName(`${hearthkitProjectName}-mailpit`)
299
+
300
+ try {
301
+ const run = await runHearthkitCliGate({
302
+ argv: ['dev'],
303
+ cwd: directoryPath,
304
+ env: gateEnvironment(),
305
+ })
306
+
307
+ const success = expectCliSuccess(run, 'dev-command-exited', 7)
308
+ expect(success.nextDevExitCode).toBe(7)
309
+ expect(await gateContainerIsRunning(containerName)).toBe(true)
310
+ expect(await readFile(markerFilePath, 'utf8')).toContain('dev')
311
+ } finally {
312
+ await removeGateContainer(containerName)
313
+ }
314
+ })
315
+
316
+ it('fails with docker-unavailable when no docker executable is on the path', async () => {
317
+ const directoryPath = await gateProjectDirectory('no-docker')
318
+ await writeGateProjectManifest({
319
+ directoryPath,
320
+ manifestName: uniqueGateProjectName('no-docker'),
321
+ hearthkitDependencies: ['@hearthkit/email'],
322
+ })
323
+ const emptyPathDirectoryPath = await createEmptyPathDirectory()
324
+ directoriesToRemove.push(emptyPathDirectoryPath)
325
+
326
+ const run = await withPathReplaced(emptyPathDirectoryPath, () =>
327
+ runHearthkitCliGate({
328
+ argv: ['dev', 'infra', 'up'],
329
+ cwd: directoryPath,
330
+ env: gateEnvironment({ PATH: emptyPathDirectoryPath }),
331
+ }),
332
+ )
333
+
334
+ const failure = expectCliFailure(run, 'docker-unavailable', 1)
335
+ expect(failure.message.startsWith(cliDockerUnavailableErrorPrefix)).toBe(true)
336
+ expect(run.standardError).toContain(failure.message)
337
+ })
338
+
339
+ it('fails with infra-compose-failed and carries the compose exit code when compose exits nonzero', async () => {
340
+ const directoryPath = await gateProjectDirectory('compose-failed')
341
+ await writeGateProjectManifest({
342
+ directoryPath,
343
+ manifestName: uniqueGateProjectName('compose-failed'),
344
+ hearthkitDependencies: ['@hearthkit/email'],
345
+ })
346
+ await writeGateComposeFile(directoryPath, unbuildableComposeFileContent)
347
+
348
+ const run = await runHearthkitCliGate({
349
+ argv: ['dev', 'infra', 'up'],
350
+ cwd: directoryPath,
351
+ env: gateEnvironment(),
352
+ })
353
+
354
+ const failure = expectCliFailure(run, 'infra-compose-failed', 1)
355
+ expect(failure.composeExitCode).not.toBe(0)
356
+ expect(failure.composeStderrExcerpt.length).toBeGreaterThan(0)
357
+ expect(failure.message.startsWith(cliInfraComposeFailedErrorPrefix)).toBe(true)
358
+ expect(run.standardError).toContain(failure.message)
359
+ })
360
+
361
+ it('fails with project-manifest-missing when the working directory has no package.json', async () => {
362
+ const directoryPath = await gateProjectDirectory('no-manifest')
363
+
364
+ const run = await runHearthkitCliGate({
365
+ argv: ['dev', 'infra', 'up'],
366
+ cwd: directoryPath,
367
+ env: gateEnvironment(),
368
+ })
369
+
370
+ const failure = expectCliFailure(run, 'project-manifest-missing', 1)
371
+ expect(basename(failure.manifestPath)).toBe('package.json')
372
+ expect(failure.manifestPath).toContain(basename(directoryPath))
373
+ expect(failure.message.startsWith(cliProjectManifestMissingErrorPrefix)).toBe(true)
374
+ expect(run.standardError).toContain(failure.message)
375
+ })
376
+
377
+ it('fails with compose-file-unwritable when the generated compose file cannot be written', async () => {
378
+ const directoryPath = await gateProjectDirectory('read-only')
379
+ await writeGateProjectManifest({
380
+ directoryPath,
381
+ manifestName: uniqueGateProjectName('read-only'),
382
+ hearthkitDependencies: ['@hearthkit/email'],
383
+ })
384
+ await makeGateDirectoryReadOnly(directoryPath)
385
+
386
+ const run = await runHearthkitCliGate({
387
+ argv: ['dev', 'infra', 'up'],
388
+ cwd: directoryPath,
389
+ env: gateEnvironment(),
390
+ })
391
+
392
+ const failure = expectCliFailure(run, 'compose-file-unwritable', 1)
393
+ expect(basename(failure.composeFilePath)).toBe('docker-compose.yml')
394
+ expect(failure.message.startsWith(cliComposeFileUnwritableErrorPrefix)).toBe(true)
395
+ expect(run.standardError).toContain(failure.message)
396
+ })
397
+
398
+ it('fails with next-dev-unavailable when the project has no next binary in node_modules', async () => {
399
+ const directoryPath = await gateProjectDirectory('no-next')
400
+ await writeGateProjectManifest({
401
+ directoryPath,
402
+ manifestName: uniqueGateProjectName('no-next'),
403
+ })
404
+
405
+ const run = await runHearthkitCliGate({
406
+ argv: ['dev'],
407
+ cwd: directoryPath,
408
+ env: gateEnvironment(),
409
+ })
410
+
411
+ const failure = expectCliFailure(run, 'next-dev-unavailable', 1)
412
+ expect(failure.message.startsWith(cliNextDevUnavailableErrorPrefix)).toBe(true)
413
+ expect(run.standardError).toContain(failure.message)
414
+ })
415
+ })
416
+
417
+ /** Starting a generated postgres means initdb plus a healthcheck compose waits on, after a first compose run that has to fail on the fixed host ports; the file-wide 120s covers one of those, not both. */
418
+ const startedPostgresGateTimeoutMilliseconds = 180_000
419
+
420
+ /**
421
+ * The known local infra services the generated compose file declares, in the order the text declares
422
+ * them and with repeats kept, so one list answers both what the emission order is and whether a
423
+ * service asked for twice was emitted twice. The bucket init container and the named volumes are
424
+ * excluded by the same rule startedInfraServices uses: only localInfraServiceName values count.
425
+ */
426
+ function generatedInfraServiceOrder(composeFileContent: string): LocalInfraServiceName[] {
427
+ const knownServiceNames = localInfraServiceNameSchema.options as readonly string[]
428
+ return composeFileContent
429
+ .split('\n')
430
+ .map((line) => /^ {2}([a-z0-9-]+):$/.exec(line)?.[1])
431
+ .filter(
432
+ (serviceName): serviceName is LocalInfraServiceName =>
433
+ serviceName !== undefined && knownServiceNames.includes(serviceName),
434
+ )
435
+ }
436
+
437
+ // Which services a manifest pulls in, through localInfraServicesByHearthkitPackage. None of these
438
+ // gates writes a compose file of its own: dev infra up has to derive the services and generate the
439
+ // file, which is the whole path under test. Only the first starts containers, on postgres and
440
+ // mailpit host ports this run reserved. The other two let compose fail on the generated fixed ports
441
+ // — 5432, 9000, 9001, 1025 and 8025, every one of them held by the repo's own stack — so they cost
442
+ // one compose invocation and start nothing, and the file dev infra up wrote is their claim. Each
443
+ // tears its compose project down with volumes, because a generated postgres or minio service keeps
444
+ // a named volume that dev infra down deliberately leaves behind.
445
+ describe('hearthkit dev infra up services derived from the project manifest', () => {
446
+ it(
447
+ 'derives both postgres and mailpit from a manifest that lists @hearthkit/auth and nothing else',
448
+ async () => {
449
+ const directoryPath = await gateProjectDirectory('infra-auth')
450
+ const hearthkitProjectName = gateComposeProjectName('auth')
451
+ // A hand-written manifest naming auth alone, which is the defect this closes: only direct
452
+ // dependencies are read, so the db and email packages auth uses internally are not in this
453
+ // file and cannot help. It derived zero services, dev infra up succeeded having started
454
+ // nothing, and auth cannot be constructed without Postgres for its seven tables and Mailpit
455
+ // for its magic-link mail.
456
+ await writeGateProjectManifest({
457
+ directoryPath,
458
+ manifestName: hearthkitProjectName,
459
+ hearthkitDependencies: ['@hearthkit/auth'],
460
+ })
461
+ const postgresContainerName = gateContainerName(`${hearthkitProjectName}-postgres`)
462
+ const mailpitContainerName = gateContainerName(`${hearthkitProjectName}-mailpit`)
463
+ const composeFilePath = join(directoryPath, 'docker-compose.yml')
464
+ const { generateLocalInfraCompose } = await loadHearthkitCliEntry()
465
+
466
+ try {
467
+ // The generating half, as in the manifest gate above. This run writes docker-compose.yml
468
+ // and then asks compose to start it on the generated host ports 5432, 1025 and 8025, which
469
+ // the repo's own Postgres and Mailpit hold, so the exit code is not this gate's claim. The
470
+ // bytes it wrote do not depend on that, and a run that failed any earlier would leave no
471
+ // file to read.
472
+ const generatingRun = await runHearthkitCliGate({
473
+ argv: ['dev', 'infra', 'up'],
474
+ cwd: directoryPath,
475
+ env: gateEnvironment(),
476
+ })
477
+ expect(['dev-infra-up-succeeded', 'infra-compose-failed']).toContain(
478
+ generatingRun.outcome.result.kind,
479
+ )
480
+
481
+ // Byte for byte what the public generator emits for the two services @hearthkit/auth
482
+ // selects, and no third: auth pulls in no storage.
483
+ const composeFileContent = await readFile(composeFilePath, 'utf8')
484
+ expect(composeFileContent).toBe(
485
+ generateLocalInfraCompose(
486
+ generateLocalInfraComposeOptionsSchema.parse({
487
+ hearthkitProjectName,
488
+ infraServices: ['postgres', 'mailpit'],
489
+ }),
490
+ ),
491
+ )
492
+ expect(composeFileContent).toContain(localInfraServiceImageByName.postgres)
493
+ expect(composeFileContent).toContain(localInfraServiceImageByName.mailpit)
494
+ expect(composeFileContent).not.toContain(localInfraServiceImageByName.minio)
495
+
496
+ // The starting half. dev infra up never overwrites a compose file it finds, so the second
497
+ // run starts exactly the file the first run generated, with only its published ports moved.
498
+ await writeGateComposeFile(
499
+ directoryPath,
500
+ remapGeneratedPostgresHostPort({
501
+ composeFileContent: await remapMailpitHostPortsOntoFreePorts(composeFileContent),
502
+ postgresHostPort: await reserveFreeHostPort(),
503
+ }),
504
+ )
505
+ const run = await runHearthkitCliGate({
506
+ argv: ['dev', 'infra', 'up'],
507
+ cwd: directoryPath,
508
+ env: gateEnvironment(),
509
+ })
510
+
511
+ const success = expectCliSuccess(run, 'dev-infra-up-succeeded', 0)
512
+ expect(success.startedInfraServices).toEqual(['postgres', 'mailpit'])
513
+ expect(await gateContainerIsRunning(postgresContainerName)).toBe(true)
514
+ expect(await gateContainerIsRunning(mailpitContainerName)).toBe(true)
515
+ const line = singleStandardOutputLine(run)
516
+ expect(line.startsWith(cliDevInfraUpCompleteLinePrefix)).toBe(true)
517
+ expect(line).toContain('postgres')
518
+ expect(line).toContain('mailpit')
519
+ } finally {
520
+ await removeGateComposeProject(composeFilePath)
521
+ await removeGateContainer(postgresContainerName)
522
+ await removeGateContainer(mailpitContainerName)
523
+ }
524
+ },
525
+ startedPostgresGateTimeoutMilliseconds,
526
+ )
527
+
528
+ it('asks for postgres once when the manifest lists both @hearthkit/auth and @hearthkit/db', async () => {
529
+ const directoryPath = await gateProjectDirectory('infra-auth-db')
530
+ const hearthkitProjectName = gateComposeProjectName('authdb')
531
+ // Two rows of a one-to-many map that both name postgres, so the derivation is asked for it
532
+ // twice and must emit it once. Registered for the afterAll safety net: compose can leave a
533
+ // created container behind when it fails to publish a port.
534
+ await writeGateProjectManifest({
535
+ directoryPath,
536
+ manifestName: hearthkitProjectName,
537
+ hearthkitDependencies: ['@hearthkit/auth', '@hearthkit/db'],
538
+ })
539
+ gateContainerName(`${hearthkitProjectName}-postgres`)
540
+ gateContainerName(`${hearthkitProjectName}-mailpit`)
541
+ const composeFilePath = join(directoryPath, 'docker-compose.yml')
542
+
543
+ try {
544
+ const run = await runHearthkitCliGate({
545
+ argv: ['dev', 'infra', 'up'],
546
+ cwd: directoryPath,
547
+ env: gateEnvironment(),
548
+ })
549
+ // Either outcome proves the run reached generation; the file it wrote is the claim.
550
+ expect(['dev-infra-up-succeeded', 'infra-compose-failed']).toContain(run.outcome.result.kind)
551
+
552
+ const composeFileContent = await readFile(composeFilePath, 'utf8')
553
+ // postgres once, not twice. Both packages asked for it and the derived list collapsed them.
554
+ expect(generatedInfraServiceOrder(composeFileContent)).toEqual(['postgres', 'mailpit'])
555
+ expect(composeFileContent).not.toContain(localInfraServiceImageByName.minio)
556
+ } finally {
557
+ await removeGateComposeProject(composeFilePath)
558
+ }
559
+ })
560
+
561
+ it('emits derived services in localInfraServiceNameSchema option order, not the order localInfraServicesByHearthkitPackage writes them', async () => {
562
+ const localInfraServicesByHearthkitPackage = await loadHearthkitCliInfraServiceMap()
563
+ const directoryPath = await gateProjectDirectory('infra-service-order')
564
+ const hearthkitProjectName = gateComposeProjectName('order')
565
+ // This pair discriminates where @hearthkit/auth alone cannot: auth's own list is written
566
+ // postgres, mailpit, which is already schema order, so a manifest naming only auth cannot tell
567
+ // the two orders apart. Read through the map, storage's row comes before auth's and asks for
568
+ // minio first and postgres last; the manifest lists them in that same order, so one expectation
569
+ // rules out both a map-key order and a manifest-key order reaching the output.
570
+ const manifestPackageNames = ['@hearthkit/storage', '@hearthkit/auth']
571
+ await writeGateProjectManifest({
572
+ directoryPath,
573
+ manifestName: hearthkitProjectName,
574
+ hearthkitDependencies: manifestPackageNames,
575
+ })
576
+ gateContainerName(`${hearthkitProjectName}-postgres`)
577
+ gateContainerName(`${hearthkitProjectName}-minio`)
578
+ gateContainerName(`${hearthkitProjectName}-mailpit`)
579
+ const composeFilePath = join(directoryPath, 'docker-compose.yml')
580
+
581
+ const mapWrittenServiceOrder = [
582
+ ...new Set(
583
+ Object.entries(localInfraServicesByHearthkitPackage)
584
+ .filter(([packageName]) => manifestPackageNames.includes(packageName))
585
+ .flatMap(([, serviceNames]) => serviceNames),
586
+ ),
587
+ ]
588
+ const schemaOptionServiceOrder = localInfraServiceNameSchema.options.filter((serviceName) =>
589
+ mapWrittenServiceOrder.includes(serviceName),
590
+ )
591
+ if (mapWrittenServiceOrder.join(',') === schemaOptionServiceOrder.join(',')) {
592
+ throw new Error(
593
+ `gate needs two packages whose map-written service order differs from localInfraServiceNameSchema option order; ${manifestPackageNames.join(' and ')} no longer tell the two apart`,
594
+ )
595
+ }
596
+
597
+ try {
598
+ const run = await runHearthkitCliGate({
599
+ argv: ['dev', 'infra', 'up'],
600
+ cwd: directoryPath,
601
+ env: gateEnvironment(),
602
+ })
603
+ expect(['dev-infra-up-succeeded', 'infra-compose-failed']).toContain(run.outcome.result.kind)
604
+
605
+ const composeFileContent = await readFile(composeFilePath, 'utf8')
606
+ expect(generatedInfraServiceOrder(composeFileContent)).toEqual(schemaOptionServiceOrder)
607
+ expect(generatedInfraServiceOrder(composeFileContent)).toEqual([
608
+ 'postgres',
609
+ 'minio',
610
+ 'mailpit',
611
+ ])
612
+ } finally {
613
+ await removeGateComposeProject(composeFilePath)
614
+ }
615
+ })
616
+ })
@@ -0,0 +1,75 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
2
+ import {
3
+ expectCliFailure,
4
+ expectCliSuccess,
5
+ runHearthkitCliGate,
6
+ } from '../test-fixtures/cli-run-expectations.ts'
7
+ import {
8
+ createGateDirectory,
9
+ gateEnvironment,
10
+ removeGateDirectory,
11
+ } from '../test-fixtures/gate-project-directories.ts'
12
+ import { unreachableAdminDatabaseUrl } from '../test-fixtures/postgres-gate-psql.ts'
13
+ import {
14
+ adminDatabaseUrlEnvVariableName,
15
+ cliDoctorFailedErrorPrefix,
16
+ doctorCheckNameSchema,
17
+ doctorJsonReportSchema,
18
+ } from './cli-contract.ts'
19
+
20
+ let workingDirectoryPath: string
21
+
22
+ beforeAll(async () => {
23
+ workingDirectoryPath = await createGateDirectory('doctor')
24
+ })
25
+
26
+ afterAll(async () => {
27
+ await removeGateDirectory(workingDirectoryPath)
28
+ })
29
+
30
+ describe('hearthkit doctor', () => {
31
+ it('reports every check passing and prints the json report when --json is given', async () => {
32
+ // No CLI variable is set, so the reachability check uses the default local admin url and the
33
+ // env variable check passes on both variables being unset.
34
+ const run = await runHearthkitCliGate({
35
+ argv: ['doctor', '--json'],
36
+ cwd: workingDirectoryPath,
37
+ env: gateEnvironment(),
38
+ })
39
+
40
+ const success = expectCliSuccess(run, 'doctor-report', 0)
41
+ expect(success.allDoctorChecksPassed).toBe(true)
42
+ expect([...success.checks].map((check) => check.checkName).toSorted()).toEqual(
43
+ [...doctorCheckNameSchema.options].toSorted(),
44
+ )
45
+ expect(success.checks.filter((check) => check.status !== 'pass')).toEqual([])
46
+ expect(success.checks.every((check) => check.detail.length > 0)).toBe(true)
47
+
48
+ const printedReport = doctorJsonReportSchema.parse(JSON.parse(run.standardOutput))
49
+ expect(printedReport.allDoctorChecksPassed).toBe(true)
50
+ expect(printedReport.checks).toEqual(success.checks)
51
+ })
52
+
53
+ it('exits 1 and names the failed check when the admin database url is unreachable', async () => {
54
+ const run = await runHearthkitCliGate({
55
+ argv: ['doctor'],
56
+ cwd: workingDirectoryPath,
57
+ env: gateEnvironment({
58
+ [adminDatabaseUrlEnvVariableName]: unreachableAdminDatabaseUrl,
59
+ }),
60
+ })
61
+
62
+ const failure = expectCliFailure(run, 'doctor-checks-failed', 1)
63
+ expect(failure.failedCheckNames).toContain('admin-database-reachable')
64
+ expect([...failure.checks].map((check) => check.checkName).toSorted()).toEqual(
65
+ [...doctorCheckNameSchema.options].toSorted(),
66
+ )
67
+ expect(
68
+ failure.checks.find((check) => check.checkName === 'admin-database-reachable')?.status,
69
+ ).toBe('fail')
70
+ expect(failure.message.startsWith(cliDoctorFailedErrorPrefix)).toBe(true)
71
+ // The report still prints even though the command failed.
72
+ expect(run.standardOutput).toContain('admin-database-reachable')
73
+ expect(run.standardError).toContain(failure.message)
74
+ })
75
+ })