@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,254 @@
1
+ import { afterAll, describe, expect, it } from 'vitest'
2
+ import { expectCliSuccess, runHearthkitCliGate } from '../test-fixtures/cli-run-expectations.ts'
3
+ import {
4
+ gateContainerExists,
5
+ gateContainerIsRunning,
6
+ removeGateComposeNetworks,
7
+ removeGateContainer,
8
+ } from '../test-fixtures/docker-gate-containers.ts'
9
+ import {
10
+ remapGeneratedMinioHostPorts,
11
+ removeGateComposeProject,
12
+ reserveFreeHostPort,
13
+ } from '../test-fixtures/gate-compose-project-runs.ts'
14
+ import {
15
+ createGateDirectory,
16
+ gateEnvironment,
17
+ removeGateDirectory,
18
+ uniqueGateProjectName,
19
+ writeGateComposeFile,
20
+ writeGateProjectManifest,
21
+ } from '../test-fixtures/gate-project-directories.ts'
22
+ import { loadHearthkitCliBucketExports } from '../test-fixtures/hearthkit-cli-bucket-exports.ts'
23
+ import { loadHearthkitCliEntry } from '../test-fixtures/hearthkit-cli-entry.ts'
24
+ import {
25
+ getMinioObjectWithSignedUrl,
26
+ putMinioObjectWithSignedUrl,
27
+ waitForMinioS3Endpoint,
28
+ } from '../test-fixtures/minio-s3-gate-requests.ts'
29
+ import {
30
+ generateLocalInfraComposeOptionsSchema,
31
+ hearthkitProjectNameSchema,
32
+ } from './cli-contract.ts'
33
+
34
+ /** Pulling images, waiting for a MinIO healthcheck and tearing the stack down again is far past the file-wide timeout. */
35
+ const dockerGateTimeoutMilliseconds = 300_000
36
+
37
+ const directoriesToRemove: string[] = []
38
+ const composeFilePathsToRemove: string[] = []
39
+ const containersToRemove: string[] = []
40
+ const composeProjectNamesToClean: string[] = []
41
+
42
+ /** Everything one gate needs to drive and probe its own throwaway MinIO stack. */
43
+ type BucketGateStack = {
44
+ directoryPath: string
45
+ composeFilePath: string
46
+ hearthkitProjectName: string
47
+ minioContainerName: string
48
+ bucketInitContainerName: string
49
+ s3HostPort: number
50
+ }
51
+
52
+ /**
53
+ * Writes the real generated compose file into a throwaway project directory and hands back the
54
+ * names and the port a gate needs to observe it. Two things are deliberate. The file is written
55
+ * before the CLI runs, because resolveLocalInfraComposeFile never overwrites an existing
56
+ * docker-compose.yml, so `dev infra up` runs exactly these generated bytes. And the published minio
57
+ * ports are moved onto reserved free ports, because the contract fixes them at 9000 and 9001, which
58
+ * the repo's own MinIO and a developer's running stack both hold.
59
+ */
60
+ async function prepareBucketGateStack(purpose: string): Promise<BucketGateStack> {
61
+ const { generateLocalInfraCompose } = await loadHearthkitCliEntry()
62
+ const { localStorageBucketInitServiceName } = await loadHearthkitCliBucketExports()
63
+
64
+ const directoryPath = await createGateDirectory(purpose)
65
+ directoriesToRemove.push(directoryPath)
66
+ const hearthkitProjectName = uniqueGateProjectName(purpose)
67
+ composeProjectNamesToClean.push(hearthkitProjectName)
68
+
69
+ const generatedComposeFileContent = generateLocalInfraCompose(
70
+ generateLocalInfraComposeOptionsSchema.parse({
71
+ hearthkitProjectName,
72
+ infraServices: ['minio'],
73
+ }),
74
+ )
75
+ const [s3HostPort, consoleHostPort] = await Promise.all([
76
+ reserveFreeHostPort(),
77
+ reserveFreeHostPort(),
78
+ ])
79
+ if (s3HostPort === consoleHostPort) {
80
+ throw new Error('gate reserved the same host port twice for one MinIO stack')
81
+ }
82
+
83
+ const composeFilePath = await writeGateComposeFile(
84
+ directoryPath,
85
+ remapGeneratedMinioHostPorts({
86
+ composeFileContent: generatedComposeFileContent,
87
+ s3HostPort,
88
+ consoleHostPort,
89
+ }),
90
+ )
91
+ composeFilePathsToRemove.push(composeFilePath)
92
+ // What a project that installed @hearthkit/storage really carries. The compose file is already
93
+ // there, so the CLI never reads it; it is written so the directory is the situation being gated.
94
+ await writeGateProjectManifest({
95
+ directoryPath,
96
+ manifestName: hearthkitProjectName,
97
+ hearthkitDependencies: ['@hearthkit/storage'],
98
+ })
99
+
100
+ const minioContainerName = `${hearthkitProjectName}-minio`
101
+ const bucketInitContainerName = `${hearthkitProjectName}-${localStorageBucketInitServiceName}`
102
+ containersToRemove.push(minioContainerName, bucketInitContainerName)
103
+
104
+ return {
105
+ directoryPath,
106
+ composeFilePath,
107
+ hearthkitProjectName,
108
+ minioContainerName,
109
+ bucketInitContainerName,
110
+ s3HostPort,
111
+ }
112
+ }
113
+
114
+ afterAll(async () => {
115
+ for (const composeFilePath of composeFilePathsToRemove) {
116
+ await removeGateComposeProject(composeFilePath)
117
+ }
118
+ for (const containerName of containersToRemove) {
119
+ await removeGateContainer(containerName)
120
+ }
121
+ await removeGateComposeNetworks(composeProjectNamesToClean)
122
+ for (const directoryPath of directoriesToRemove) {
123
+ await removeGateDirectory(directoryPath)
124
+ }
125
+ })
126
+
127
+ describe('hearthkit dev infra with a local storage bucket', () => {
128
+ it(
129
+ 'starts the generated minio and bucket init containers with dev infra up succeeding on a fresh run and again on a repeat run',
130
+ async () => {
131
+ const stack = await prepareBucketGateStack('bucket-up')
132
+
133
+ try {
134
+ const freshRun = await runHearthkitCliGate({
135
+ argv: ['dev', 'infra', 'up'],
136
+ cwd: stack.directoryPath,
137
+ env: gateEnvironment(),
138
+ })
139
+
140
+ const freshSuccess = expectCliSuccess(freshRun, 'dev-infra-up-succeeded', 0)
141
+ // The init container is not a LocalInfraServiceName, so compose starting it never widens
142
+ // the reported list.
143
+ expect(freshSuccess.startedInfraServices).toEqual(['minio'])
144
+ expect(await gateContainerIsRunning(stack.minioContainerName)).toBe(true)
145
+ // The container that created the bucket is still running, and it has to be. dev infra up
146
+ // passes --wait unconditionally, and docker compose up --wait exits 1 when any service it
147
+ // started has exited, whatever the exit code — so an init container that did its work and
148
+ // exited 0 would have made the run above report infra-compose-failed instead.
149
+ expect(await gateContainerIsRunning(stack.bucketInitContainerName)).toBe(true)
150
+
151
+ const repeatRun = await runHearthkitCliGate({
152
+ argv: ['dev', 'infra', 'up'],
153
+ cwd: stack.directoryPath,
154
+ env: gateEnvironment(),
155
+ })
156
+
157
+ // mc mb --ignore-existing is what makes the second run a no-op rather than a failure on a
158
+ // bucket that is already there.
159
+ const repeatSuccess = expectCliSuccess(repeatRun, 'dev-infra-up-succeeded', 0)
160
+ expect(repeatSuccess.startedInfraServices).toEqual(['minio'])
161
+ expect(await gateContainerIsRunning(stack.minioContainerName)).toBe(true)
162
+ expect(await gateContainerIsRunning(stack.bucketInitContainerName)).toBe(true)
163
+ } finally {
164
+ await removeGateComposeProject(stack.composeFilePath)
165
+ }
166
+ },
167
+ dockerGateTimeoutMilliseconds,
168
+ )
169
+
170
+ it(
171
+ 'leaves a bucket in MinIO that a real signed S3 request can upload to and read back',
172
+ async () => {
173
+ const stack = await prepareBucketGateStack('bucket-s3')
174
+ const { deriveLocalStorageBucketName } = await loadHearthkitCliBucketExports()
175
+ const localStorageBucketName = deriveLocalStorageBucketName(
176
+ hearthkitProjectNameSchema.parse(stack.hearthkitProjectName),
177
+ )
178
+
179
+ try {
180
+ expectCliSuccess(
181
+ await runHearthkitCliGate({
182
+ argv: ['dev', 'infra', 'up'],
183
+ cwd: stack.directoryPath,
184
+ env: gateEnvironment(),
185
+ }),
186
+ 'dev-infra-up-succeeded',
187
+ 0,
188
+ )
189
+ await waitForMinioS3Endpoint({ s3HostPort: stack.s3HostPort })
190
+
191
+ const objectKey = 'hearthkit-gate-upload.txt'
192
+ const objectBody = `hearthkit gate body for ${stack.hearthkitProjectName}`
193
+ const upload = await putMinioObjectWithSignedUrl({
194
+ s3HostPort: stack.s3HostPort,
195
+ bucketName: localStorageBucketName,
196
+ objectKey,
197
+ objectBody,
198
+ })
199
+
200
+ // A signed request is the first thing that can see a missing bucket: signing never contacts
201
+ // the server, so a MinIO with no bucket answers the upload with 404 NoSuchBucket. Checking
202
+ // the bucket exists is not the same claim as checking it is usable over the S3 API.
203
+ expect(upload.status).toBe(200)
204
+
205
+ const download = await getMinioObjectWithSignedUrl({
206
+ s3HostPort: stack.s3HostPort,
207
+ bucketName: localStorageBucketName,
208
+ objectKey,
209
+ })
210
+ expect(download.status).toBe(200)
211
+ expect(download.body).toBe(objectBody)
212
+ } finally {
213
+ await removeGateComposeProject(stack.composeFilePath)
214
+ }
215
+ },
216
+ dockerGateTimeoutMilliseconds,
217
+ )
218
+
219
+ it(
220
+ 'removes the still-running bucket init container along with minio when dev infra down runs',
221
+ async () => {
222
+ const stack = await prepareBucketGateStack('bucket-down')
223
+
224
+ try {
225
+ expectCliSuccess(
226
+ await runHearthkitCliGate({
227
+ argv: ['dev', 'infra', 'up'],
228
+ cwd: stack.directoryPath,
229
+ env: gateEnvironment(),
230
+ }),
231
+ 'dev-infra-up-succeeded',
232
+ 0,
233
+ )
234
+ expect(await gateContainerIsRunning(stack.minioContainerName)).toBe(true)
235
+ expect(await gateContainerIsRunning(stack.bucketInitContainerName)).toBe(true)
236
+
237
+ const downRun = await runHearthkitCliGate({
238
+ argv: ['dev', 'infra', 'down'],
239
+ cwd: stack.directoryPath,
240
+ env: gateEnvironment(),
241
+ })
242
+
243
+ // dev infra down is unchanged by the amendment: docker compose down removes every container
244
+ // the file defines, the idle init container included.
245
+ expectCliSuccess(downRun, 'dev-infra-down-succeeded', 0)
246
+ expect(await gateContainerExists(stack.minioContainerName)).toBe(false)
247
+ expect(await gateContainerExists(stack.bucketInitContainerName)).toBe(false)
248
+ } finally {
249
+ await removeGateComposeProject(stack.composeFilePath)
250
+ }
251
+ },
252
+ dockerGateTimeoutMilliseconds,
253
+ )
254
+ })