@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.
- package/package.json +45 -0
- package/src/cli-contract.ts +423 -0
- package/src/cli-failure-results.ts +175 -0
- package/src/cli-output-streams.ts +14 -0
- package/src/cli-runtime-context.ts +8 -0
- package/src/default-backup-file-path.ts +28 -0
- package/src/derive-hearthkit-project-name.ts +27 -0
- package/src/derive-local-storage-bucket-name.test.ts +89 -0
- package/src/derive-local-storage-bucket-name.ts +24 -0
- package/src/docker-compose-commands.ts +100 -0
- package/src/format-doctor-report.ts +25 -0
- package/src/generate-local-infra-compose.test.ts +215 -0
- package/src/generate-local-infra-compose.ts +154 -0
- package/src/hearthkit-bin-entry.js +19 -0
- package/src/hearthkit-bin-execution.test.ts +136 -0
- package/src/hearthkit-bin.ts +8 -0
- package/src/index.ts +107 -0
- package/src/load-payments-catalog-module.ts +60 -0
- package/src/parse-cli-invocation.ts +365 -0
- package/src/read-environment-variable-value.ts +14 -0
- package/src/read-project-infra-manifest.ts +99 -0
- package/src/report-cli-outcome.ts +139 -0
- package/src/resolve-admin-database-url.ts +72 -0
- package/src/resolve-local-infra-compose-file.ts +80 -0
- package/src/run-child-process-command.ts +90 -0
- package/src/run-db-lifecycle-command.ts +103 -0
- package/src/run-dev-command.ts +104 -0
- package/src/run-dev-infra-command.ts +115 -0
- package/src/run-doctor-checks.ts +339 -0
- package/src/run-hearthkit-cli-db-commands.test.ts +330 -0
- package/src/run-hearthkit-cli-dev-infra-bucket.test.ts +254 -0
- package/src/run-hearthkit-cli-dev-infra.test.ts +616 -0
- package/src/run-hearthkit-cli-doctor.test.ts +75 -0
- package/src/run-hearthkit-cli-payments-sync.test.ts +180 -0
- package/src/run-hearthkit-cli.ts +62 -0
- 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
|
+
})
|