@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,339 @@
1
+ import { createDrizzleClient, postgresConnectionStringSchema } from '@hearthkit/db'
2
+ import {
3
+ adminDatabaseUrlEnvVariableName,
4
+ type CliCommandResult,
5
+ type DoctorCheckName,
6
+ type DoctorCheckResult,
7
+ } from './cli-contract.ts'
8
+ import { doctorChecksFailedFailure } from './cli-failure-results.ts'
9
+ import type { CliRuntimeContext } from './cli-runtime-context.ts'
10
+ import { readEnvironmentVariableValue } from './read-environment-variable-value.ts'
11
+ import { resolveAdminDatabaseUrl } from './resolve-admin-database-url.ts'
12
+ import { runChildProcessCommand } from './run-child-process-command.ts'
13
+
14
+ /** The Node major this stack is built and gated on; older majors are a fail, not a warning. */
15
+ const minimumNodeMajorVersion = 24
16
+
17
+ /** The Postgres client major @hearthkit/db shells out to; a v16 client shadowing v17 fails by design. */
18
+ const requiredPostgresClientMajorVersion = 17
19
+
20
+ /** Doctor is a diagnosis, not a wait: each probe gets a short leash so one hung tool cannot stall the report. */
21
+ const doctorProbeTimeoutMilliseconds = 20_000
22
+
23
+ /** The two variables this CLI reads; both are optional, and both must be postgres URLs when present. */
24
+ const cliEnvVariableNames = [adminDatabaseUrlEnvVariableName, 'DATABASE_URL'] as const
25
+
26
+ /**
27
+ * Runs every doctor check and reports the whole set. Checks are independent except where one cannot
28
+ * be answered without another (the docker daemon and compose plugin need the docker CLI), and those
29
+ * are skipped rather than guessed. A skip counts as not-passed, exactly like a fail.
30
+ */
31
+ export async function runDoctorCommand(context: CliRuntimeContext): Promise<CliCommandResult> {
32
+ const dockerCliCheck = await readCommandAvailabilityCheck({
33
+ checkName: 'docker-cli-available',
34
+ commandName: 'docker',
35
+ commandArguments: ['--version'],
36
+ context,
37
+ })
38
+ const dockerCliUsable = dockerCliCheck.status === 'pass'
39
+
40
+ const checks: DoctorCheckResult[] = [
41
+ readNodeVersionCheck(),
42
+ await readCommandAvailabilityCheck({
43
+ checkName: 'pnpm-command-available',
44
+ commandName: 'pnpm',
45
+ commandArguments: ['--version'],
46
+ context,
47
+ }),
48
+ dockerCliCheck,
49
+ dockerCliUsable
50
+ ? await readDockerDaemonCheck(context)
51
+ : skippedCheck('docker-daemon-running', 'docker cli is not available'),
52
+ dockerCliUsable
53
+ ? await readComposePluginCheck(context)
54
+ : skippedCheck('docker-compose-plugin-available', 'docker cli is not available'),
55
+ await readPostgresClientToolsCheck(context),
56
+ await readAdminDatabaseReachableCheck(context),
57
+ readCliEnvVariablesCheck(context),
58
+ ]
59
+
60
+ const singleLineChecks = checks.map((check) => ({
61
+ ...check,
62
+ detail: collapseToSingleLine(check.detail),
63
+ }))
64
+ const failedCheckNames = singleLineChecks
65
+ .filter((check) => check.status !== 'pass')
66
+ .map((check) => check.checkName)
67
+
68
+ if (failedCheckNames.length > 0) {
69
+ return doctorChecksFailedFailure(singleLineChecks, failedCheckNames)
70
+ }
71
+
72
+ return { kind: 'doctor-report', checks: singleLineChecks, allDoctorChecksPassed: true }
73
+ }
74
+
75
+ /** Squeezes a detail onto one line, since a driver error or tool banner may arrive with newlines in it. */
76
+ function collapseToSingleLine(detail: string): string {
77
+ return detail.replaceAll(/\s+/g, ' ').trim()
78
+ }
79
+
80
+ /** A check that could not be asked because the one it depends on failed; the detail names that reason. */
81
+ function skippedCheck(checkName: DoctorCheckName, reason: string): DoctorCheckResult {
82
+ return { checkName, status: 'skip', detail: `not checked because ${reason}` }
83
+ }
84
+
85
+ /** The Node major running this process, which is the one that will run the app's scripts too. */
86
+ function readNodeVersionCheck(): DoctorCheckResult {
87
+ const nodeMajorVersion = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10)
88
+ const supported = Number.isFinite(nodeMajorVersion) && nodeMajorVersion >= minimumNodeMajorVersion
89
+ return {
90
+ checkName: 'node-version-supported',
91
+ status: supported ? 'pass' : 'fail',
92
+ detail: supported
93
+ ? `node v${process.versions.node} meets the required major ${minimumNodeMajorVersion}`
94
+ : `node v${process.versions.node} is older than the required major ${minimumNodeMajorVersion}`,
95
+ }
96
+ }
97
+
98
+ /** Whether one command answers on PATH, using its own version output as the detail. */
99
+ async function readCommandAvailabilityCheck(options: {
100
+ checkName: DoctorCheckName
101
+ commandName: string
102
+ commandArguments: readonly string[]
103
+ context: CliRuntimeContext
104
+ }): Promise<DoctorCheckResult> {
105
+ const outcome = await runChildProcessCommand({
106
+ commandName: options.commandName,
107
+ commandArguments: options.commandArguments,
108
+ context: options.context,
109
+ timeoutMilliseconds: doctorProbeTimeoutMilliseconds,
110
+ })
111
+
112
+ if (outcome.kind === 'child-process-not-on-path') {
113
+ return {
114
+ checkName: options.checkName,
115
+ status: 'fail',
116
+ detail: `${options.commandName} was not found on PATH`,
117
+ }
118
+ }
119
+ if (outcome.exitCode !== 0) {
120
+ return {
121
+ checkName: options.checkName,
122
+ status: 'fail',
123
+ detail: `${options.commandName} ${options.commandArguments.join(' ')} exited ${outcome.exitCode}: ${firstLineOf(outcome.standardError)}`,
124
+ }
125
+ }
126
+ const versionLine = firstLineOf(outcome.standardOutput)
127
+ return {
128
+ checkName: options.checkName,
129
+ status: 'pass',
130
+ detail:
131
+ versionLine === ''
132
+ ? `${options.commandName} is on PATH`
133
+ : nameVersionLine(options.commandName, versionLine),
134
+ }
135
+ }
136
+
137
+ /** Keeps the tool's own version wording but makes sure the detail says which tool reported it. */
138
+ function nameVersionLine(commandName: string, versionLine: string): string {
139
+ return versionLine.toLowerCase().includes(commandName.toLowerCase())
140
+ ? versionLine
141
+ : `${commandName} ${versionLine}`
142
+ }
143
+
144
+ /** Whether the docker daemon answers, which is a different question from whether the CLI is installed. */
145
+ async function readDockerDaemonCheck(context: CliRuntimeContext): Promise<DoctorCheckResult> {
146
+ const outcome = await runChildProcessCommand({
147
+ commandName: 'docker',
148
+ commandArguments: ['info', '--format', '{{.ServerVersion}}'],
149
+ context,
150
+ timeoutMilliseconds: doctorProbeTimeoutMilliseconds,
151
+ })
152
+
153
+ if (outcome.kind === 'child-process-not-on-path') {
154
+ return {
155
+ checkName: 'docker-daemon-running',
156
+ status: 'fail',
157
+ detail: 'docker left PATH mid-report',
158
+ }
159
+ }
160
+ if (outcome.exitCode !== 0) {
161
+ return {
162
+ checkName: 'docker-daemon-running',
163
+ status: 'fail',
164
+ detail: `docker info exited ${outcome.exitCode}; start Docker Desktop or the docker engine (${firstLineOf(outcome.standardError)})`,
165
+ }
166
+ }
167
+ return {
168
+ checkName: 'docker-daemon-running',
169
+ status: 'pass',
170
+ detail: `docker daemon is running, server version ${firstLineOf(outcome.standardOutput)}`,
171
+ }
172
+ }
173
+
174
+ /** Whether the compose plugin is installed; hearthkit only ever calls compose as a docker subcommand. */
175
+ async function readComposePluginCheck(context: CliRuntimeContext): Promise<DoctorCheckResult> {
176
+ return readCommandAvailabilityCheck({
177
+ checkName: 'docker-compose-plugin-available',
178
+ commandName: 'docker',
179
+ commandArguments: ['compose', 'version'],
180
+ context,
181
+ })
182
+ }
183
+
184
+ /**
185
+ * Whether pg_dump and pg_restore are both on PATH at the major @hearthkit/db needs. An older client
186
+ * shadowing a newer server is the failure this catches: it reads fine and then writes archives the
187
+ * server cannot restore.
188
+ */
189
+ async function readPostgresClientToolsCheck(
190
+ context: CliRuntimeContext,
191
+ ): Promise<DoctorCheckResult> {
192
+ const toolNames = ['pg_dump', 'pg_restore'] as const
193
+ const details: string[] = []
194
+
195
+ for (const toolName of toolNames) {
196
+ const outcome = await runChildProcessCommand({
197
+ commandName: toolName,
198
+ commandArguments: ['--version'],
199
+ context,
200
+ timeoutMilliseconds: doctorProbeTimeoutMilliseconds,
201
+ })
202
+
203
+ if (outcome.kind === 'child-process-not-on-path') {
204
+ return {
205
+ checkName: 'postgres-client-tools-version',
206
+ status: 'fail',
207
+ detail: `${toolName} was not found on PATH; install the Postgres ${requiredPostgresClientMajorVersion} client tools`,
208
+ }
209
+ }
210
+ if (outcome.exitCode !== 0) {
211
+ return {
212
+ checkName: 'postgres-client-tools-version',
213
+ status: 'fail',
214
+ detail: `${toolName} --version exited ${outcome.exitCode}: ${firstLineOf(outcome.standardError)}`,
215
+ }
216
+ }
217
+
218
+ const versionText = firstLineOf(outcome.standardOutput)
219
+ const majorVersion = readToolMajorVersion(versionText)
220
+ if (majorVersion !== requiredPostgresClientMajorVersion) {
221
+ return {
222
+ checkName: 'postgres-client-tools-version',
223
+ status: 'fail',
224
+ detail: `${versionText} is not major ${requiredPostgresClientMajorVersion}; an older client on PATH shadows the one hearthkit needs`,
225
+ }
226
+ }
227
+ details.push(versionText)
228
+ }
229
+
230
+ return {
231
+ checkName: 'postgres-client-tools-version',
232
+ status: 'pass',
233
+ detail: details.join('; '),
234
+ }
235
+ }
236
+
237
+ /** Whether the resolved admin connection answers a trivial query, using @hearthkit/db's own client. */
238
+ async function readAdminDatabaseReachableCheck(
239
+ context: CliRuntimeContext,
240
+ ): Promise<DoctorCheckResult> {
241
+ const resolution = resolveAdminDatabaseUrl({
242
+ adminDatabaseUrlFlagValue: undefined,
243
+ environmentVariables: context.environmentVariables,
244
+ })
245
+ if (resolution.kind === 'admin-database-url-rejected') {
246
+ return {
247
+ checkName: 'admin-database-reachable',
248
+ status: 'fail',
249
+ detail: resolution.failure.message,
250
+ }
251
+ }
252
+
253
+ const { drizzleClient, closeDatabaseClient } = createDrizzleClient({
254
+ databaseUrl: resolution.adminDatabaseUrl,
255
+ })
256
+ try {
257
+ await drizzleClient.execute('select 1')
258
+ return {
259
+ checkName: 'admin-database-reachable',
260
+ status: 'pass',
261
+ detail: `${redactConnectionPassword(resolution.adminDatabaseUrl)} answered select 1 (from ${resolution.adminDatabaseUrlSource})`,
262
+ }
263
+ } catch (error) {
264
+ return {
265
+ checkName: 'admin-database-reachable',
266
+ status: 'fail',
267
+ detail: `${redactConnectionPassword(resolution.adminDatabaseUrl)} did not answer select 1: ${describeQueryError(error)}`,
268
+ }
269
+ } finally {
270
+ await closeDatabaseClient().catch(() => undefined)
271
+ }
272
+ }
273
+
274
+ /** Whether the two variables this CLI reads hold postgres URLs; unset is a pass, since both are optional. */
275
+ function readCliEnvVariablesCheck(context: CliRuntimeContext): DoctorCheckResult {
276
+ const invalidVariableNames = cliEnvVariableNames.filter((variableName) => {
277
+ const value = readEnvironmentVariableValue(context.environmentVariables, variableName)
278
+ return value !== undefined && !postgresConnectionStringSchema.safeParse(value).success
279
+ })
280
+
281
+ if (invalidVariableNames.length > 0) {
282
+ return {
283
+ checkName: 'cli-env-variables-valid',
284
+ status: 'fail',
285
+ detail: `${invalidVariableNames.join(', ')} is not a postgres:// or postgresql:// url`,
286
+ }
287
+ }
288
+
289
+ const setVariableNames = cliEnvVariableNames.filter(
290
+ (variableName) =>
291
+ readEnvironmentVariableValue(context.environmentVariables, variableName) !== undefined,
292
+ )
293
+ return {
294
+ checkName: 'cli-env-variables-valid',
295
+ status: 'pass',
296
+ detail:
297
+ setVariableNames.length === 0
298
+ ? `neither ${cliEnvVariableNames.join(' nor ')} is set, which is allowed`
299
+ : `set and holding a postgres url: ${setVariableNames.join(', ')}`,
300
+ }
301
+ }
302
+
303
+ /** A query error in one readable phrase; the driver's cause carries the refusal, the wrapper only says which query. */
304
+ function describeQueryError(error: unknown): string {
305
+ const message = error instanceof Error ? error.message : String(error)
306
+ const causeMessage =
307
+ error instanceof Error && error.cause instanceof Error ? error.cause.message : ''
308
+ return causeMessage === '' ? message : `${message} (${causeMessage})`
309
+ }
310
+
311
+ /** The major version in a tool's own version line, or null when it reports something unparseable. */
312
+ function readToolMajorVersion(versionText: string): number | null {
313
+ const dottedMatch = /(\d+)(?:\.\d+)+/.exec(versionText)
314
+ const bareMatch = dottedMatch ?? /(\d+)/.exec(versionText)
315
+ const majorText = bareMatch?.[1]
316
+ if (majorText === undefined) {
317
+ return null
318
+ }
319
+ const majorVersion = Number.parseInt(majorText, 10)
320
+ return Number.isFinite(majorVersion) ? majorVersion : null
321
+ }
322
+
323
+ /** Keeps a check detail to one line, since the report is a table. */
324
+ function firstLineOf(text: string): string {
325
+ return text.trim().split('\n')[0]?.trim() ?? ''
326
+ }
327
+
328
+ /** Replaces the password in a connection URL so a printed report can be pasted into an issue. */
329
+ function redactConnectionPassword(connectionString: string): string {
330
+ try {
331
+ const url = new URL(connectionString)
332
+ if (url.password !== '') {
333
+ url.password = '***'
334
+ }
335
+ return url.toString()
336
+ } catch {
337
+ return connectionString
338
+ }
339
+ }
@@ -0,0 +1,330 @@
1
+ import { stat } from 'node:fs/promises'
2
+ import { basename, isAbsolute, join, resolve } from 'node:path'
3
+ import type { ProjectDatabaseName } from '@hearthkit/db'
4
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
5
+ import {
6
+ expectCliFailure,
7
+ expectCliSuccess,
8
+ runHearthkitCliGate,
9
+ singleStandardOutputLine,
10
+ } from '../test-fixtures/cli-run-expectations.ts'
11
+ import {
12
+ createGateDirectory,
13
+ gateEnvironment,
14
+ removeGateDirectory,
15
+ } from '../test-fixtures/gate-project-directories.ts'
16
+ import {
17
+ gateCliBaselineMigrationCount,
18
+ gateCliMigrationsFolderPath,
19
+ } from '../test-fixtures/gate-migrations-folder.ts'
20
+ import {
21
+ createAdminOwnedGateDatabase,
22
+ gateAdminDatabaseUrl,
23
+ gateDatabaseExists,
24
+ queryRowsAs,
25
+ removeGateDatabase,
26
+ runPsqlStatement,
27
+ uniqueGateDatabaseName,
28
+ } from '../test-fixtures/postgres-gate-psql.ts'
29
+ import {
30
+ adminDatabaseUrlEnvVariableName,
31
+ cliAdminUrlInvalidErrorPrefix,
32
+ cliDatabaseUrlInvalidErrorPrefix,
33
+ cliDatabaseUrlMissingErrorPrefix,
34
+ cliDbBackupCompleteLinePrefix,
35
+ cliDbCreateCredentialsWarningPrefix,
36
+ cliDbDropCompleteLinePrefix,
37
+ cliDbMigrateCompleteLinePrefix,
38
+ cliDbRestoreCompleteLinePrefix,
39
+ cliUsageErrorPrefix,
40
+ defaultLocalAdminDatabaseUrl,
41
+ } from './cli-contract.ts'
42
+
43
+ const namesToRemove: ProjectDatabaseName[] = []
44
+ let workingDirectoryPath: string
45
+
46
+ /** A fresh database name that afterAll will clean up whether or not the gate managed to create it. */
47
+ function gateDatabaseName(purpose: string): ProjectDatabaseName {
48
+ const projectDatabaseName = uniqueGateDatabaseName(purpose)
49
+ namesToRemove.push(projectDatabaseName)
50
+ return projectDatabaseName
51
+ }
52
+
53
+ beforeAll(async () => {
54
+ workingDirectoryPath = await createGateDirectory('db-commands')
55
+ })
56
+
57
+ afterAll(async () => {
58
+ for (const projectDatabaseName of namesToRemove) {
59
+ await removeGateDatabase(projectDatabaseName)
60
+ }
61
+ await removeGateDirectory(workingDirectoryPath)
62
+ })
63
+
64
+ describe('hearthkit db commands', () => {
65
+ it('prints exactly the project connection string on stdout and creates a database that connects', async () => {
66
+ const projectDatabaseName = gateDatabaseName('create')
67
+
68
+ // Neither the flag nor the env variable is set, so the admin URL falls back to the local default.
69
+ const run = await runHearthkitCliGate({
70
+ argv: ['db', 'create', projectDatabaseName],
71
+ cwd: workingDirectoryPath,
72
+ env: gateEnvironment(),
73
+ })
74
+
75
+ const success = expectCliSuccess(run, 'db-create-command-succeeded', 0)
76
+ expect(success.projectDatabaseName).toBe(projectDatabaseName)
77
+ expect(singleStandardOutputLine(run)).toBe(success.connectionString)
78
+ expect(success.connectionString).toContain(new URL(defaultLocalAdminDatabaseUrl).host)
79
+ expect(success.connectionString).toContain(projectDatabaseName)
80
+ // The credentials are shown once and never persisted, so the warning has to be on stderr.
81
+ expect(
82
+ run.standardError
83
+ .split('\n')
84
+ .find((line) => line.startsWith(cliDbCreateCredentialsWarningPrefix)),
85
+ ).toBeDefined()
86
+ expect(await gateDatabaseExists(projectDatabaseName)).toBe(true)
87
+ expect(await queryRowsAs(success.connectionString, 'select 1')).toEqual(['1'])
88
+ })
89
+
90
+ it('drops the database named on the command line using the admin url flag', async () => {
91
+ const projectDatabaseName = gateDatabaseName('drop')
92
+ await createAdminOwnedGateDatabase(projectDatabaseName)
93
+
94
+ const run = await runHearthkitCliGate({
95
+ argv: ['db', 'drop', projectDatabaseName, '--admin-database-url', gateAdminDatabaseUrl],
96
+ cwd: workingDirectoryPath,
97
+ env: gateEnvironment(),
98
+ })
99
+
100
+ const success = expectCliSuccess(run, 'db-drop-command-succeeded', 0)
101
+ expect(success.projectDatabaseName).toBe(projectDatabaseName)
102
+ const line = singleStandardOutputLine(run)
103
+ expect(line.startsWith(cliDbDropCompleteLinePrefix)).toBe(true)
104
+ expect(line).toContain(projectDatabaseName)
105
+ expect(await gateDatabaseExists(projectDatabaseName)).toBe(false)
106
+ })
107
+
108
+ it('applies every migration in the folder once and reports zero applied on a second run', async () => {
109
+ const projectDatabaseName = gateDatabaseName('migrate')
110
+ const databaseUrl = await createAdminOwnedGateDatabase(projectDatabaseName)
111
+
112
+ const firstRun = await runHearthkitCliGate({
113
+ argv: [
114
+ 'db',
115
+ 'migrate',
116
+ '--database-url',
117
+ databaseUrl,
118
+ '--migrations-folder',
119
+ gateCliMigrationsFolderPath(),
120
+ ],
121
+ cwd: workingDirectoryPath,
122
+ env: gateEnvironment(),
123
+ })
124
+
125
+ const firstSuccess = expectCliSuccess(firstRun, 'db-migrate-command-succeeded', 0)
126
+ expect(firstSuccess.appliedMigrationCount).toBe(gateCliBaselineMigrationCount)
127
+ const firstLine = singleStandardOutputLine(firstRun)
128
+ expect(firstLine.startsWith(cliDbMigrateCompleteLinePrefix)).toBe(true)
129
+ expect(firstLine).toContain(String(gateCliBaselineMigrationCount))
130
+ expect(await queryRowsAs(databaseUrl, 'select title from gate_cli_notes order by id')).toEqual([
131
+ 'first note',
132
+ ])
133
+
134
+ // The second run resolves the connection from DATABASE_URL instead of the flag.
135
+ const secondRun = await runHearthkitCliGate({
136
+ argv: ['db', 'migrate', '--migrations-folder', gateCliMigrationsFolderPath()],
137
+ cwd: workingDirectoryPath,
138
+ env: gateEnvironment({ DATABASE_URL: databaseUrl }),
139
+ })
140
+
141
+ expect(
142
+ expectCliSuccess(secondRun, 'db-migrate-command-succeeded', 0).appliedMigrationCount,
143
+ ).toBe(0)
144
+ })
145
+
146
+ it('writes the backup archive to the default backups directory with the byte count it reports', async () => {
147
+ const projectDatabaseName = gateDatabaseName('backup')
148
+ const databaseUrl = await createAdminOwnedGateDatabase(projectDatabaseName)
149
+ await runPsqlStatement(
150
+ databaseUrl,
151
+ "create table gate_cli_orders (id integer primary key, item text not null); insert into gate_cli_orders values (1, 'kettle')",
152
+ )
153
+
154
+ // The admin URL comes from the operator environment variable this time, not from a flag.
155
+ const run = await runHearthkitCliGate({
156
+ argv: ['db', 'backup', projectDatabaseName],
157
+ cwd: workingDirectoryPath,
158
+ env: gateEnvironment({ [adminDatabaseUrlEnvVariableName]: gateAdminDatabaseUrl }),
159
+ })
160
+
161
+ const success = expectCliSuccess(run, 'db-backup-command-succeeded', 0)
162
+ expect(success.projectDatabaseName).toBe(projectDatabaseName)
163
+ // The default path is returned absolute, resolved from the working directory.
164
+ expect(isAbsolute(success.backupFilePath)).toBe(true)
165
+ expect(success.backupFilePath).toContain('backups')
166
+ expect(basename(success.backupFilePath)).toMatch(
167
+ new RegExp(`^${projectDatabaseName}-\\d{8}T\\d{6}Z\\.dump$`),
168
+ )
169
+ const archiveStats = await stat(resolve(workingDirectoryPath, success.backupFilePath))
170
+ expect(archiveStats.size).toBe(success.backupByteCount)
171
+ const line = singleStandardOutputLine(run)
172
+ expect(line.startsWith(cliDbBackupCompleteLinePrefix)).toBe(true)
173
+ expect(line).toContain(String(success.backupByteCount))
174
+ })
175
+
176
+ it('backs up a database, drops it, creates it again, restores the archive and finds the rows', async () => {
177
+ const projectDatabaseName = gateDatabaseName('restore')
178
+ const environment = gateEnvironment({
179
+ [adminDatabaseUrlEnvVariableName]: gateAdminDatabaseUrl,
180
+ })
181
+ const backupFilePath = join(workingDirectoryPath, `${projectDatabaseName}.dump`)
182
+
183
+ const created = expectCliSuccess(
184
+ await runHearthkitCliGate({
185
+ argv: ['db', 'create', projectDatabaseName],
186
+ cwd: workingDirectoryPath,
187
+ env: environment,
188
+ }),
189
+ 'db-create-command-succeeded',
190
+ 0,
191
+ )
192
+ await runPsqlStatement(
193
+ created.connectionString,
194
+ "create table gate_cli_orders (id integer primary key, item text not null); insert into gate_cli_orders values (1, 'kettle'), (2, 'hearth')",
195
+ )
196
+
197
+ const backedUp = expectCliSuccess(
198
+ await runHearthkitCliGate({
199
+ argv: ['db', 'backup', projectDatabaseName, '--backup-file', backupFilePath],
200
+ cwd: workingDirectoryPath,
201
+ env: environment,
202
+ }),
203
+ 'db-backup-command-succeeded',
204
+ 0,
205
+ )
206
+ expect(backedUp.backupFilePath).toBe(backupFilePath)
207
+
208
+ expectCliSuccess(
209
+ await runHearthkitCliGate({
210
+ argv: ['db', 'drop', projectDatabaseName],
211
+ cwd: workingDirectoryPath,
212
+ env: environment,
213
+ }),
214
+ 'db-drop-command-succeeded',
215
+ 0,
216
+ )
217
+ expect(await gateDatabaseExists(projectDatabaseName)).toBe(false)
218
+
219
+ // Restore needs the target database back first; the archive holds objects, not the database.
220
+ const recreated = expectCliSuccess(
221
+ await runHearthkitCliGate({
222
+ argv: ['db', 'create', projectDatabaseName],
223
+ cwd: workingDirectoryPath,
224
+ env: environment,
225
+ }),
226
+ 'db-create-command-succeeded',
227
+ 0,
228
+ )
229
+
230
+ const restoreRun = await runHearthkitCliGate({
231
+ argv: ['db', 'restore', projectDatabaseName, backupFilePath],
232
+ cwd: workingDirectoryPath,
233
+ env: environment,
234
+ })
235
+
236
+ const restored = expectCliSuccess(restoreRun, 'db-restore-command-succeeded', 0)
237
+ expect(restored.projectDatabaseName).toBe(projectDatabaseName)
238
+ expect(restored.backupFilePath).toBe(backupFilePath)
239
+ const line = singleStandardOutputLine(restoreRun)
240
+ expect(line.startsWith(cliDbRestoreCompleteLinePrefix)).toBe(true)
241
+ expect(line).toContain(projectDatabaseName)
242
+ expect(
243
+ await queryRowsAs(recreated.connectionString, 'select item from gate_cli_orders order by id'),
244
+ ).toEqual(['kettle', 'hearth'])
245
+ })
246
+
247
+ it('fails with exit code 2 when the database name argument is not a project database name', async () => {
248
+ const run = await runHearthkitCliGate({
249
+ argv: ['db', 'create', 'Gate_Uppercase_Name'],
250
+ cwd: workingDirectoryPath,
251
+ env: gateEnvironment(),
252
+ })
253
+
254
+ const failure = expectCliFailure(run, 'cli-usage-invalid', 2)
255
+ expect(failure.message.startsWith(cliUsageErrorPrefix)).toBe(true)
256
+ expect(run.standardError).toContain(failure.message)
257
+ expect(run.standardOutput.trim()).toBe('')
258
+ expect(await gateDatabaseExists('Gate_Uppercase_Name')).toBe(false)
259
+ })
260
+
261
+ it('fails with admin-database-url-invalid when the admin url flag is not a postgres url', async () => {
262
+ const projectDatabaseName = gateDatabaseName('bad_admin_url')
263
+
264
+ const run = await runHearthkitCliGate({
265
+ argv: [
266
+ 'db',
267
+ 'create',
268
+ projectDatabaseName,
269
+ '--admin-database-url',
270
+ 'not-a-postgres-url://hearthkit',
271
+ ],
272
+ cwd: workingDirectoryPath,
273
+ env: gateEnvironment(),
274
+ })
275
+
276
+ const failure = expectCliFailure(run, 'admin-database-url-invalid', 1)
277
+ expect(failure.message.startsWith(cliAdminUrlInvalidErrorPrefix)).toBe(true)
278
+ expect(run.standardError).toContain(failure.message)
279
+ expect(await gateDatabaseExists(projectDatabaseName)).toBe(false)
280
+ })
281
+
282
+ it('fails with database-url-missing when db migrate has neither the flag nor DATABASE_URL', async () => {
283
+ const run = await runHearthkitCliGate({
284
+ argv: ['db', 'migrate', '--migrations-folder', gateCliMigrationsFolderPath()],
285
+ cwd: workingDirectoryPath,
286
+ env: gateEnvironment(),
287
+ })
288
+
289
+ const failure = expectCliFailure(run, 'database-url-missing', 1)
290
+ expect(failure.message.startsWith(cliDatabaseUrlMissingErrorPrefix)).toBe(true)
291
+ expect(failure.message).toContain('DATABASE_URL')
292
+ expect(run.standardError).toContain(failure.message)
293
+ })
294
+
295
+ it('fails with database-url-invalid when the given project database url is not a postgres url', async () => {
296
+ const run = await runHearthkitCliGate({
297
+ argv: [
298
+ 'db',
299
+ 'migrate',
300
+ '--database-url',
301
+ 'http://localhost:5432/gate',
302
+ '--migrations-folder',
303
+ gateCliMigrationsFolderPath(),
304
+ ],
305
+ cwd: workingDirectoryPath,
306
+ env: gateEnvironment(),
307
+ })
308
+
309
+ const failure = expectCliFailure(run, 'database-url-invalid', 1)
310
+ expect(failure.message.startsWith(cliDatabaseUrlInvalidErrorPrefix)).toBe(true)
311
+ expect(run.standardError).toContain(failure.message)
312
+ })
313
+
314
+ it('carries the db failure verbatim when dropping a database that is not on the server', async () => {
315
+ const projectDatabaseName = uniqueGateDatabaseName('absent')
316
+
317
+ const run = await runHearthkitCliGate({
318
+ argv: ['db', 'drop', projectDatabaseName],
319
+ cwd: workingDirectoryPath,
320
+ env: gateEnvironment({ [adminDatabaseUrlEnvVariableName]: gateAdminDatabaseUrl }),
321
+ })
322
+
323
+ const failure = expectCliFailure(run, 'db-command-failed', 1)
324
+ expect(failure.dbFailure.kind).toBe('project-database-not-found')
325
+ // The CLI never re-words a db failure, so its own message is the db message unchanged.
326
+ expect(failure.message).toBe(failure.dbFailure.message)
327
+ expect(failure.message.startsWith('hearthkit db ')).toBe(true)
328
+ expect(run.standardError).toContain(failure.message)
329
+ })
330
+ })