@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,28 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { ProjectDatabaseName } from '@hearthkit/db'
|
|
3
|
+
import { defaultBackupDirectoryPath } from './cli-contract.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where db backup writes when --backup-file is absent: ./backups/<name>-<YYYYMMDDTHHMMSSZ>.dump,
|
|
7
|
+
* returned absolute so the printed path stays correct whatever directory the operator reads it in.
|
|
8
|
+
*/
|
|
9
|
+
export function buildDefaultBackupFilePath(options: {
|
|
10
|
+
workingDirectoryPath: string
|
|
11
|
+
projectDatabaseName: ProjectDatabaseName
|
|
12
|
+
backupInstant?: Date
|
|
13
|
+
}): string {
|
|
14
|
+
const stamp = formatBackupFileTimestamp(options.backupInstant ?? new Date())
|
|
15
|
+
return resolve(
|
|
16
|
+
options.workingDirectoryPath,
|
|
17
|
+
defaultBackupDirectoryPath,
|
|
18
|
+
`${options.projectDatabaseName}-${stamp}.dump`,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Compacts an instant to the UTC stamp the default file name carries: 20260827T154500Z, no separators, no milliseconds. */
|
|
23
|
+
function formatBackupFileTimestamp(backupInstant: Date): string {
|
|
24
|
+
return backupInstant
|
|
25
|
+
.toISOString()
|
|
26
|
+
.replaceAll(/[-:]/g, '')
|
|
27
|
+
.replace(/\.\d+Z$/, 'Z')
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { hearthkitProjectNameSchema, type HearthkitProjectName } from './cli-contract.ts'
|
|
2
|
+
|
|
3
|
+
/** What a manifest name becomes when nothing usable survives sanitising, so compose always has a project name. */
|
|
4
|
+
const fallbackHearthkitProjectName = hearthkitProjectNameSchema.parse('hearthkit-app')
|
|
5
|
+
|
|
6
|
+
/** Compose project names, like the container and volume names built from them, cap at 63 characters. */
|
|
7
|
+
const hearthkitProjectNameLengthLimit = 63
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Turns a package.json name into the name compose, containers, and volumes are built from: the
|
|
11
|
+
* leading @scope/ is dropped, the rest is lowercased, and anything outside [a-z0-9-] becomes a dash.
|
|
12
|
+
* A result that still cannot be a project name (empty, or not starting with a letter) falls back.
|
|
13
|
+
*/
|
|
14
|
+
export function deriveHearthkitProjectName(manifestName: string | undefined): HearthkitProjectName {
|
|
15
|
+
if (manifestName === undefined) {
|
|
16
|
+
return fallbackHearthkitProjectName
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sanitized = manifestName
|
|
20
|
+
.replace(/^@[^/]+\//, '')
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replaceAll(/[^a-z0-9-]/g, '-')
|
|
23
|
+
.slice(0, hearthkitProjectNameLengthLimit)
|
|
24
|
+
|
|
25
|
+
const parsed = hearthkitProjectNameSchema.safeParse(sanitized)
|
|
26
|
+
return parsed.success ? parsed.data : fallbackHearthkitProjectName
|
|
27
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { loadHearthkitCliBucketExports } from '../test-fixtures/hearthkit-cli-bucket-exports.ts'
|
|
3
|
+
import { hearthkitProjectNameSchema } from './cli-contract.ts'
|
|
4
|
+
|
|
5
|
+
/** The shortest and longest bucket names the derivation can ever produce, per the contract's totality claim. */
|
|
6
|
+
const shortestPossibleBucketNameLength = 9
|
|
7
|
+
const longestPossibleBucketNameLength = 63
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The project names that decide whether the function is total. hearthkitProjectNameSchema is
|
|
11
|
+
* /^[a-z][a-z0-9-]*$/ with max(63), so a project name may be one character, may end in a hyphen, and
|
|
12
|
+
* may be 63 characters — and the last case may be 63 characters of which 62 are hyphens.
|
|
13
|
+
*/
|
|
14
|
+
const bucketNameEdgeCases = [
|
|
15
|
+
{
|
|
16
|
+
hearthkitProjectName: 'a',
|
|
17
|
+
expectedBucketName: 'a-uploads',
|
|
18
|
+
edge: 'the shortest project name the schema accepts',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
hearthkitProjectName: 'my-app-',
|
|
22
|
+
expectedBucketName: 'my-app-uploads',
|
|
23
|
+
edge: 'a trailing hyphen, which must not become my-app--uploads',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
hearthkitProjectName: 'a'.repeat(63),
|
|
27
|
+
expectedBucketName: `${'a'.repeat(55)}-uploads`,
|
|
28
|
+
edge: 'the longest project name the schema accepts, cut to 55 so the bucket name lands on exactly 63',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
hearthkitProjectName: `a${'-'.repeat(62)}`,
|
|
32
|
+
expectedBucketName: 'a-uploads',
|
|
33
|
+
edge: 'truncation leaving nothing but hyphens, which must collapse rather than yield a trailing-hyphen name',
|
|
34
|
+
},
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
describe('deriveLocalStorageBucketName', () => {
|
|
38
|
+
it('derives the project-uploads bucket name the generated compose file creates in MinIO', async () => {
|
|
39
|
+
const { deriveLocalStorageBucketName, localStorageBucketNameSchema } =
|
|
40
|
+
await loadHearthkitCliBucketExports()
|
|
41
|
+
|
|
42
|
+
const localStorageBucketName = deriveLocalStorageBucketName(
|
|
43
|
+
hearthkitProjectNameSchema.parse('myapp'),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(localStorageBucketName).toBe('myapp-uploads')
|
|
47
|
+
expect(localStorageBucketNameSchema.parse(localStorageBucketName)).toBe('myapp-uploads')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it.each(bucketNameEdgeCases)(
|
|
51
|
+
'stays total for a project name that is $edge',
|
|
52
|
+
async ({ hearthkitProjectName, expectedBucketName }) => {
|
|
53
|
+
const { deriveLocalStorageBucketName, localStorageBucketNameSchema } =
|
|
54
|
+
await loadHearthkitCliBucketExports()
|
|
55
|
+
// Parsing first proves the gate is feeding the function an input the contract really allows,
|
|
56
|
+
// rather than inventing one the schema would have rejected anyway.
|
|
57
|
+
const parsedProjectName = hearthkitProjectNameSchema.parse(hearthkitProjectName)
|
|
58
|
+
|
|
59
|
+
const localStorageBucketName = deriveLocalStorageBucketName(parsedProjectName)
|
|
60
|
+
|
|
61
|
+
expect(localStorageBucketName).toBe(expectedBucketName)
|
|
62
|
+
// Totality is the reason the function has no failure mode: every accepted project name has to
|
|
63
|
+
// produce a name localStorageBucketNameSchema accepts, within the 9-to-63 range the contract states.
|
|
64
|
+
expect(localStorageBucketNameSchema.parse(localStorageBucketName)).toBe(expectedBucketName)
|
|
65
|
+
expect(localStorageBucketName.length).toBeGreaterThanOrEqual(shortestPossibleBucketNameLength)
|
|
66
|
+
expect(localStorageBucketName.length).toBeLessThanOrEqual(longestPossibleBucketNameLength)
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
it('reaches the same project name, and so the same bucket name, the CLI derives from a package.json name', async () => {
|
|
71
|
+
const {
|
|
72
|
+
deriveHearthkitProjectName,
|
|
73
|
+
deriveLocalStorageBucketName,
|
|
74
|
+
localStorageBucketNameSchema,
|
|
75
|
+
} = await loadHearthkitCliBucketExports()
|
|
76
|
+
|
|
77
|
+
// Promoted to the public surface by this amendment: without it @hearthkit/create cannot reach
|
|
78
|
+
// the project name the bucket name is built from, so it would re-implement the sanitiser.
|
|
79
|
+
expect(deriveHearthkitProjectName('@gate/My_App')).toBe('my-app')
|
|
80
|
+
expect(deriveHearthkitProjectName(undefined)).toBe('hearthkit-app')
|
|
81
|
+
|
|
82
|
+
const localStorageBucketName = deriveLocalStorageBucketName(
|
|
83
|
+
deriveHearthkitProjectName('@gate/My_App'),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(localStorageBucketName).toBe('my-app-uploads')
|
|
87
|
+
expect(localStorageBucketNameSchema.parse(localStorageBucketName)).toBe('my-app-uploads')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { localStorageBucketNameSchema, type DeriveLocalStorageBucketName } from './cli-contract.ts'
|
|
2
|
+
|
|
3
|
+
/** What every derived bucket name ends in, so a reader of the compose file knows at a glance what the bucket holds. */
|
|
4
|
+
const localStorageBucketNameSuffix = '-uploads'
|
|
5
|
+
|
|
6
|
+
/** Longest project name that still leaves room for the suffix inside the 63-character bucket name limit. */
|
|
7
|
+
const truncatedProjectNameLengthLimit = 63 - localStorageBucketNameSuffix.length
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Derives the bucket name the generated compose file creates in MinIO and @hearthkit/create writes to
|
|
11
|
+
* STORAGE_BUCKET, so the two cannot drift. Total by construction rather than by validation: the name
|
|
12
|
+
* is cut to 55 characters and any hyphens left trailing are dropped before the suffix is appended, so
|
|
13
|
+
* every project name hearthkitProjectNameSchema accepts — one character, ending in a hyphen, or 63
|
|
14
|
+
* characters of which 62 are hyphens — yields a name localStorageBucketNameSchema accepts.
|
|
15
|
+
*/
|
|
16
|
+
export const deriveLocalStorageBucketName: DeriveLocalStorageBucketName = (
|
|
17
|
+
hearthkitProjectName,
|
|
18
|
+
) => {
|
|
19
|
+
const truncated = hearthkitProjectName.slice(0, truncatedProjectNameLengthLimit)
|
|
20
|
+
const withoutTrailingHyphens = truncated.replace(/-+$/, '')
|
|
21
|
+
return localStorageBucketNameSchema.parse(
|
|
22
|
+
`${withoutTrailingHyphens}${localStorageBucketNameSuffix}`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { localInfraServiceNameSchema, type LocalInfraServiceName } from './cli-contract.ts'
|
|
2
|
+
import type { CliRuntimeContext } from './cli-runtime-context.ts'
|
|
3
|
+
import { runChildProcessCommand, type ChildProcessOutcome } from './run-child-process-command.ts'
|
|
4
|
+
|
|
5
|
+
/** Whether docker can be used at all right now: on PATH and with a daemon that answers. */
|
|
6
|
+
export type DockerAvailability =
|
|
7
|
+
| { kind: 'docker-available'; serverVersion: string }
|
|
8
|
+
| { kind: 'docker-unavailable'; detail: string }
|
|
9
|
+
|
|
10
|
+
/** A wedged daemon should report rather than hang the CLI, so the availability probe is bounded. */
|
|
11
|
+
const dockerProbeTimeoutMilliseconds = 30_000
|
|
12
|
+
|
|
13
|
+
/** How long docker compose up may spend waiting for services to be running or healthy before it gives up. */
|
|
14
|
+
const composeWaitTimeoutSeconds = 90
|
|
15
|
+
|
|
16
|
+
/** How much of a failed docker probe's own words to keep when explaining why docker is unusable. */
|
|
17
|
+
const dockerProbeDetailLimit = 400
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Probes docker the way the contract defines availability: the executable resolves on the caller's
|
|
21
|
+
* PATH and `docker info` exits zero. Both halves are one question, because a CLI without a daemon
|
|
22
|
+
* is just as unusable as no CLI at all.
|
|
23
|
+
*/
|
|
24
|
+
export async function checkDockerAvailability(
|
|
25
|
+
context: CliRuntimeContext,
|
|
26
|
+
): Promise<DockerAvailability> {
|
|
27
|
+
const outcome = await runChildProcessCommand({
|
|
28
|
+
commandName: 'docker',
|
|
29
|
+
commandArguments: ['info', '--format', '{{.ServerVersion}}'],
|
|
30
|
+
context,
|
|
31
|
+
timeoutMilliseconds: dockerProbeTimeoutMilliseconds,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (outcome.kind === 'child-process-not-on-path') {
|
|
35
|
+
return {
|
|
36
|
+
kind: 'docker-unavailable',
|
|
37
|
+
detail: 'docker was not found on PATH; install Docker Desktop or the docker engine',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (outcome.exitCode !== 0) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'docker-unavailable',
|
|
44
|
+
detail: `docker info exited ${outcome.exitCode}; the daemon is not running (${outcome.standardError.trim().slice(0, dockerProbeDetailLimit)})`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { kind: 'docker-available', serverVersion: outcome.standardOutput.trim() }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runs one docker compose subcommand against an explicit file, so which compose file is in play is
|
|
53
|
+
* never guessed from the directory. Compose's stderr is forwarded live because pulls and health
|
|
54
|
+
* waits are slow enough that silence looks like a hang.
|
|
55
|
+
*/
|
|
56
|
+
export async function runDockerComposeCommand(options: {
|
|
57
|
+
context: CliRuntimeContext
|
|
58
|
+
composeFilePath: string
|
|
59
|
+
composeArguments: readonly string[]
|
|
60
|
+
forwardStandardError?: boolean
|
|
61
|
+
}): Promise<ChildProcessOutcome> {
|
|
62
|
+
return runChildProcessCommand({
|
|
63
|
+
commandName: 'docker',
|
|
64
|
+
commandArguments: ['compose', '--file', options.composeFilePath, ...options.composeArguments],
|
|
65
|
+
context: options.context,
|
|
66
|
+
forwardStandardError: options.forwardStandardError,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The arguments that start every service in the file and wait for it to be running or healthy. */
|
|
71
|
+
export const composeUpArguments = [
|
|
72
|
+
'up',
|
|
73
|
+
'--detach',
|
|
74
|
+
'--wait',
|
|
75
|
+
'--wait-timeout',
|
|
76
|
+
String(composeWaitTimeoutSeconds),
|
|
77
|
+
] as const
|
|
78
|
+
|
|
79
|
+
/** The arguments that stop and remove the file's services while keeping their named volumes. */
|
|
80
|
+
export const composeDownArguments = ['down'] as const
|
|
81
|
+
|
|
82
|
+
/** The arguments that list the services the file declares, whether or not hearthkit generated it. */
|
|
83
|
+
export const composeServicesArguments = ['config', '--services'] as const
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Keeps only the services this package knows how to run. A hand-written compose file naming its own
|
|
87
|
+
* services therefore reports an empty list even though compose did start them, which is what the
|
|
88
|
+
* contract says startedInfraServices means.
|
|
89
|
+
*/
|
|
90
|
+
export function readKnownInfraServiceNames(composeServicesOutput: string): LocalInfraServiceName[] {
|
|
91
|
+
const declaredServiceNames = new Set(
|
|
92
|
+
composeServicesOutput
|
|
93
|
+
.split('\n')
|
|
94
|
+
.map((line) => line.trim())
|
|
95
|
+
.filter((line) => line !== ''),
|
|
96
|
+
)
|
|
97
|
+
return localInfraServiceNameSchema.options.filter((serviceName) =>
|
|
98
|
+
declaredServiceNames.has(serviceName),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
doctorCheckNameSchema,
|
|
3
|
+
doctorJsonReportSchema,
|
|
4
|
+
type DoctorCheckResult,
|
|
5
|
+
type DoctorJsonReport,
|
|
6
|
+
} from './cli-contract.ts'
|
|
7
|
+
|
|
8
|
+
/** Width of the name column in the human table; the longest check name plus breathing room. */
|
|
9
|
+
const doctorCheckNameColumnWidth =
|
|
10
|
+
Math.max(...doctorCheckNameSchema.options.map((checkName) => checkName.length)) + 2
|
|
11
|
+
|
|
12
|
+
/** The exact JSON envelope doctor --json prints, validated on the way out so the printed text always matches the schema. */
|
|
13
|
+
export function formatDoctorJsonReport(report: DoctorJsonReport): string {
|
|
14
|
+
return JSON.stringify(doctorJsonReportSchema.parse(report))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The human table doctor prints without --json: one line per check, status first so failures are scannable. */
|
|
18
|
+
export function formatDoctorCheckTable(checks: readonly DoctorCheckResult[]): string {
|
|
19
|
+
return checks
|
|
20
|
+
.map(
|
|
21
|
+
(check) =>
|
|
22
|
+
`${check.status.padEnd(4)} ${check.checkName.padEnd(doctorCheckNameColumnWidth)}${check.detail}`,
|
|
23
|
+
)
|
|
24
|
+
.join('\n')
|
|
25
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { loadHearthkitCliBucketExports } from '../test-fixtures/hearthkit-cli-bucket-exports.ts'
|
|
3
|
+
import { loadHearthkitCliEntry } from '../test-fixtures/hearthkit-cli-entry.ts'
|
|
4
|
+
import {
|
|
5
|
+
generateLocalInfraComposeOptionsSchema,
|
|
6
|
+
hearthkitProjectNameSchema,
|
|
7
|
+
localInfraServiceImageByName,
|
|
8
|
+
localInfraServiceNameSchema,
|
|
9
|
+
} from './cli-contract.ts'
|
|
10
|
+
|
|
11
|
+
// The function is pure, so a fixed project name is safe: nothing it writes touches the filesystem
|
|
12
|
+
// or the docker daemon, and two gate runs cannot collide.
|
|
13
|
+
const hearthkitProjectName = 'hearthkit-gate-compose'
|
|
14
|
+
|
|
15
|
+
/** The emitted lines of one service: its key line plus every line indented deeper than it, so a gate can ask what one service does and does not carry. */
|
|
16
|
+
function generatedServiceBlock(composeFileContent: string, serviceName: string): string {
|
|
17
|
+
const lines = composeFileContent.split('\n')
|
|
18
|
+
const headerIndex = lines.indexOf(` ${serviceName}:`)
|
|
19
|
+
if (headerIndex === -1) {
|
|
20
|
+
throw new Error(`gate expected the generated compose file to declare a ${serviceName} service`)
|
|
21
|
+
}
|
|
22
|
+
const blockLines = [lines[headerIndex] as string]
|
|
23
|
+
for (const line of lines.slice(headerIndex + 1)) {
|
|
24
|
+
if (!line.startsWith(' ')) {
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
blockLines.push(line)
|
|
28
|
+
}
|
|
29
|
+
return blockLines.join('\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Where a service key sits in the emitted text, so a gate can pin the fixed order the contract calls deterministic. */
|
|
33
|
+
function generatedServiceKeyPosition(composeFileContent: string, serviceName: string): number {
|
|
34
|
+
const keyPosition = composeFileContent.indexOf(`\n ${serviceName}:\n`)
|
|
35
|
+
if (keyPosition === -1) {
|
|
36
|
+
throw new Error(`gate expected the generated compose file to declare a ${serviceName} service`)
|
|
37
|
+
}
|
|
38
|
+
return keyPosition
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Generates the compose file for one set of services, going through the options schema the contract publishes. */
|
|
42
|
+
async function generateGateComposeFileContent(infraServices: readonly string[]): Promise<string> {
|
|
43
|
+
const { generateLocalInfraCompose } = await loadHearthkitCliEntry()
|
|
44
|
+
return generateLocalInfraCompose(
|
|
45
|
+
generateLocalInfraComposeOptionsSchema.parse({ hearthkitProjectName, infraServices }),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('generateLocalInfraCompose', () => {
|
|
50
|
+
it('returns identical yaml for identical input and pins every image from localInfraServiceImageByName', async () => {
|
|
51
|
+
const { generateLocalInfraCompose } = await loadHearthkitCliEntry()
|
|
52
|
+
const options = generateLocalInfraComposeOptionsSchema.parse({
|
|
53
|
+
hearthkitProjectName,
|
|
54
|
+
infraServices: [...localInfraServiceNameSchema.options],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const composeFileContent = generateLocalInfraCompose(options)
|
|
58
|
+
expect(generateLocalInfraCompose(options)).toBe(composeFileContent)
|
|
59
|
+
|
|
60
|
+
for (const image of Object.values(localInfraServiceImageByName)) {
|
|
61
|
+
expect(composeFileContent).toContain(image)
|
|
62
|
+
}
|
|
63
|
+
for (const serviceName of localInfraServiceNameSchema.options) {
|
|
64
|
+
expect(composeFileContent).toContain(`${hearthkitProjectName}-${serviceName}`)
|
|
65
|
+
}
|
|
66
|
+
// Host ports are fixed per the contract table.
|
|
67
|
+
for (const publishedPort of ['5432:5432', '9000:9000', '9001:9001', '1025:1025', '8025:8025']) {
|
|
68
|
+
expect(composeFileContent).toContain(publishedPort)
|
|
69
|
+
}
|
|
70
|
+
// Postgres and minio keep a named volume; mailpit has none.
|
|
71
|
+
expect(composeFileContent).toContain(`${hearthkitProjectName}-postgres-data`)
|
|
72
|
+
expect(composeFileContent).toContain(`${hearthkitProjectName}-minio-data`)
|
|
73
|
+
expect(composeFileContent).not.toContain(`${hearthkitProjectName}-mailpit-data`)
|
|
74
|
+
// Postgres credentials have to match defaultLocalAdminDatabaseUrl, and the service is health checked.
|
|
75
|
+
expect(composeFileContent).toContain('POSTGRES_USER')
|
|
76
|
+
expect(composeFileContent).toContain('POSTGRES_PASSWORD')
|
|
77
|
+
expect(composeFileContent).toContain('POSTGRES_DB')
|
|
78
|
+
expect(composeFileContent).toContain('pg_isready')
|
|
79
|
+
expect(composeFileContent).toContain('MINIO_ROOT_USER')
|
|
80
|
+
expect(composeFileContent).toContain('MINIO_ROOT_PASSWORD')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('emits the bucket init container immediately after minio and before mailpit, still byte-identical for identical input', async () => {
|
|
84
|
+
const { localStorageBucketInitServiceName } = await loadHearthkitCliBucketExports()
|
|
85
|
+
const composeFileContent = await generateGateComposeFileContent([
|
|
86
|
+
...localInfraServiceNameSchema.options,
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
expect(await generateGateComposeFileContent([...localInfraServiceNameSchema.options])).toBe(
|
|
90
|
+
composeFileContent,
|
|
91
|
+
)
|
|
92
|
+
// Fixed order: minio, then the container that creates its bucket, then mailpit. The contract
|
|
93
|
+
// calls the function deterministic, so the position is part of the output, not an accident.
|
|
94
|
+
expect(generatedServiceKeyPosition(composeFileContent, 'minio')).toBeLessThan(
|
|
95
|
+
generatedServiceKeyPosition(composeFileContent, localStorageBucketInitServiceName),
|
|
96
|
+
)
|
|
97
|
+
expect(
|
|
98
|
+
generatedServiceKeyPosition(composeFileContent, localStorageBucketInitServiceName),
|
|
99
|
+
).toBeLessThan(generatedServiceKeyPosition(composeFileContent, 'mailpit'))
|
|
100
|
+
expect(composeFileContent).toContain(
|
|
101
|
+
`${hearthkitProjectName}-${localStorageBucketInitServiceName}`,
|
|
102
|
+
)
|
|
103
|
+
// It is a container the generator adds, never a service a project selects, so it is neither a
|
|
104
|
+
// LocalInfraServiceName nor an accepted infraServices value.
|
|
105
|
+
expect(localInfraServiceNameSchema.options as readonly string[]).not.toContain(
|
|
106
|
+
localStorageBucketInitServiceName,
|
|
107
|
+
)
|
|
108
|
+
expect(
|
|
109
|
+
generateLocalInfraComposeOptionsSchema.safeParse({
|
|
110
|
+
hearthkitProjectName,
|
|
111
|
+
infraServices: [localStorageBucketInitServiceName],
|
|
112
|
+
}).success,
|
|
113
|
+
).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('leaves the bucket init container out entirely when minio is not one of the selected services', async () => {
|
|
117
|
+
const {
|
|
118
|
+
deriveLocalStorageBucketName,
|
|
119
|
+
localStorageBucketInitServiceName,
|
|
120
|
+
localStorageBucketInitImage,
|
|
121
|
+
} = await loadHearthkitCliBucketExports()
|
|
122
|
+
const composeFileContent = await generateGateComposeFileContent(['postgres', 'mailpit'])
|
|
123
|
+
|
|
124
|
+
expect(composeFileContent).not.toContain(localStorageBucketInitServiceName)
|
|
125
|
+
expect(composeFileContent).not.toContain(localStorageBucketInitImage)
|
|
126
|
+
expect(composeFileContent).not.toContain(
|
|
127
|
+
deriveLocalStorageBucketName(hearthkitProjectNameSchema.parse(hearthkitProjectName)),
|
|
128
|
+
)
|
|
129
|
+
expect(composeFileContent).not.toContain('mc mb')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('gives the bucket init container the pinned mc image, a long-syntax wait for a healthy minio, and no restart, ports, volumes or environment', async () => {
|
|
133
|
+
const { localStorageBucketInitServiceName, localStorageBucketInitImage } =
|
|
134
|
+
await loadHearthkitCliBucketExports()
|
|
135
|
+
const composeFileContent = await generateGateComposeFileContent(['minio'])
|
|
136
|
+
const bucketInitBlock = generatedServiceBlock(
|
|
137
|
+
composeFileContent,
|
|
138
|
+
localStorageBucketInitServiceName,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// The image comes from its own exported pin, deliberately not from the map of selectable
|
|
142
|
+
// service images, so the two can be bumped in one place each rather than retyped here.
|
|
143
|
+
expect(bucketInitBlock).toContain(localStorageBucketInitImage)
|
|
144
|
+
expect(Object.values(localInfraServiceImageByName) as readonly string[]).not.toContain(
|
|
145
|
+
localStorageBucketInitImage,
|
|
146
|
+
)
|
|
147
|
+
expect(bucketInitBlock).toMatch(
|
|
148
|
+
new RegExp(
|
|
149
|
+
`container_name:\\s*['"]?${hearthkitProjectName}-${localStorageBucketInitServiceName}['"]?\\s*$`,
|
|
150
|
+
'm',
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
// Long syntax, so the container waits for the healthcheck rather than racing the server.
|
|
154
|
+
expect(bucketInitBlock).toMatch(/depends_on:\s*\n\s+minio:\s*\n\s+condition:\s*service_healthy/)
|
|
155
|
+
// No restart key, so compose's default no applies; and none of the three blocks the
|
|
156
|
+
// long-running services carry, because this container publishes and stores nothing. Each is
|
|
157
|
+
// checked as an absent key rather than an absent word, so the explanatory comment the contract
|
|
158
|
+
// asks for above this service cannot fail the gate by mentioning one of them.
|
|
159
|
+
expect(bucketInitBlock).not.toMatch(/^\s+restart:/m)
|
|
160
|
+
expect(bucketInitBlock).not.toMatch(/^\s+ports:/m)
|
|
161
|
+
expect(bucketInitBlock).not.toMatch(/^\s+volumes:/m)
|
|
162
|
+
expect(bucketInitBlock).not.toMatch(/^\s+environment:/m)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('ends the bucket init entrypoint with tail -f /dev/null so the container never exits under docker compose up --wait', async () => {
|
|
166
|
+
const { deriveLocalStorageBucketName, localStorageBucketInitServiceName } =
|
|
167
|
+
await loadHearthkitCliBucketExports()
|
|
168
|
+
const composeFileContent = await generateGateComposeFileContent(['minio'])
|
|
169
|
+
const bucketInitBlock = generatedServiceBlock(
|
|
170
|
+
composeFileContent,
|
|
171
|
+
localStorageBucketInitServiceName,
|
|
172
|
+
)
|
|
173
|
+
// The bucket name is taken from the exported derivation rather than retyped, so the name in the
|
|
174
|
+
// compose file and the name @hearthkit/create writes to STORAGE_BUCKET cannot drift apart.
|
|
175
|
+
const localStorageBucketName = deriveLocalStorageBucketName(
|
|
176
|
+
hearthkitProjectNameSchema.parse(hearthkitProjectName),
|
|
177
|
+
)
|
|
178
|
+
const bucketInitEntrypointCommand = `mc alias set local http://minio:9000 hearthkit hearthkit && mc mb --ignore-existing local/${localStorageBucketName} && tail -f /dev/null`
|
|
179
|
+
|
|
180
|
+
expect(bucketInitBlock).toContain(bucketInitEntrypointCommand)
|
|
181
|
+
const entrypointPosition = bucketInitBlock.indexOf('entrypoint:')
|
|
182
|
+
expect(entrypointPosition).toBeGreaterThanOrEqual(0)
|
|
183
|
+
const entrypointArgumentText = bucketInitBlock.slice(
|
|
184
|
+
entrypointPosition,
|
|
185
|
+
bucketInitBlock.indexOf(bucketInitEntrypointCommand),
|
|
186
|
+
)
|
|
187
|
+
expect(entrypointArgumentText).toMatch(/['"]sh['"]/)
|
|
188
|
+
expect(entrypointArgumentText).toMatch(/['"]-c['"]/)
|
|
189
|
+
|
|
190
|
+
// tail -f /dev/null is the whole fix and is pinned on its own. docker compose up --wait exits 1
|
|
191
|
+
// when any service it started has exited, whatever the exit code, and dev infra up always
|
|
192
|
+
// passes --wait; an init container that did its work and exited 0 would make every dev infra
|
|
193
|
+
// up report infra-compose-failed with the bucket created perfectly. Nothing may follow it in
|
|
194
|
+
// the chain, or the container stops being the last thing running.
|
|
195
|
+
const tailStepPosition = bucketInitBlock.indexOf('tail -f /dev/null')
|
|
196
|
+
expect(tailStepPosition).toBeGreaterThan(bucketInitBlock.indexOf('mc mb --ignore-existing'))
|
|
197
|
+
expect(bucketInitBlock.slice(tailStepPosition)).not.toContain('&&')
|
|
198
|
+
// --ignore-existing is what makes a second dev infra up a no-op instead of a failure.
|
|
199
|
+
expect(bucketInitBlock).toContain('--ignore-existing')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('health checks the minio service with mc ready local so the bucket init container has a healthy state to wait for', async () => {
|
|
203
|
+
const composeFileContent = await generateGateComposeFileContent(['minio'])
|
|
204
|
+
const minioBlock = generatedServiceBlock(composeFileContent, 'minio')
|
|
205
|
+
|
|
206
|
+
expect(minioBlock).toContain('healthcheck:')
|
|
207
|
+
expect(minioBlock).toMatch(
|
|
208
|
+
/test:\s*\[\s*['"]CMD['"],\s*['"]mc['"],\s*['"]ready['"],\s*['"]local['"]\s*\]/,
|
|
209
|
+
)
|
|
210
|
+
// Interval and retry count match the postgres service already in the file.
|
|
211
|
+
expect(minioBlock).toMatch(/interval:\s*5s/)
|
|
212
|
+
expect(minioBlock).toMatch(/timeout:\s*5s/)
|
|
213
|
+
expect(minioBlock).toMatch(/retries:\s*20/)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateLocalInfraComposeOptionsSchema,
|
|
3
|
+
localInfraServiceImageByName,
|
|
4
|
+
localInfraServiceNameSchema,
|
|
5
|
+
localStorageBucketInitImage,
|
|
6
|
+
localStorageBucketInitServiceName,
|
|
7
|
+
type GenerateLocalInfraCompose,
|
|
8
|
+
type HearthkitProjectName,
|
|
9
|
+
type LocalInfraServiceName,
|
|
10
|
+
} from './cli-contract.ts'
|
|
11
|
+
import { deriveLocalStorageBucketName } from './derive-local-storage-bucket-name.ts'
|
|
12
|
+
|
|
13
|
+
/** Credentials baked into the generated Postgres and MinIO services; they match defaultLocalAdminDatabaseUrl. */
|
|
14
|
+
const localInfraCredential = 'hearthkit'
|
|
15
|
+
|
|
16
|
+
/** The address the bucket init container reaches MinIO on: inside the compose network, so it never depends on a published host port. */
|
|
17
|
+
const localStorageServiceEndpoint = 'http://minio:9000'
|
|
18
|
+
|
|
19
|
+
/** Services whose data survives a compose down, each with one named volume derived from the project name. */
|
|
20
|
+
const volumeBackedServiceNames = [
|
|
21
|
+
'postgres',
|
|
22
|
+
'minio',
|
|
23
|
+
] as const satisfies readonly LocalInfraServiceName[]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds the local infra compose file. Pure and deterministic: the same options always produce the
|
|
27
|
+
* same bytes, services are emitted in a fixed order whatever order the caller listed them in, and
|
|
28
|
+
* every image comes from localInfraServiceImageByName so there is one place to bump a version. The
|
|
29
|
+
* bucket init container follows minio whenever minio is emitted, and only then.
|
|
30
|
+
*/
|
|
31
|
+
export const generateLocalInfraCompose: GenerateLocalInfraCompose = (options) => {
|
|
32
|
+
const { hearthkitProjectName, infraServices } =
|
|
33
|
+
generateLocalInfraComposeOptionsSchema.parse(options)
|
|
34
|
+
const requestedServiceNames = new Set(infraServices)
|
|
35
|
+
const emittedServiceNames = localInfraServiceNameSchema.options.filter((serviceName) =>
|
|
36
|
+
requestedServiceNames.has(serviceName),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const serviceBlocks = emittedServiceNames.map((serviceName) =>
|
|
40
|
+
serviceName === 'minio'
|
|
41
|
+
? [
|
|
42
|
+
...buildServiceBlock(serviceName, hearthkitProjectName),
|
|
43
|
+
...buildBucketInitBlock(hearthkitProjectName),
|
|
44
|
+
]
|
|
45
|
+
: buildServiceBlock(serviceName, hearthkitProjectName),
|
|
46
|
+
)
|
|
47
|
+
const volumeNames = volumeBackedServiceNames
|
|
48
|
+
.filter((serviceName) => requestedServiceNames.has(serviceName))
|
|
49
|
+
.map((serviceName) => namedVolumeFor(hearthkitProjectName, serviceName))
|
|
50
|
+
|
|
51
|
+
const lines = [
|
|
52
|
+
'# Generated by hearthkit. This file is yours: hearthkit never overwrites an existing docker-compose.yml.',
|
|
53
|
+
`name: ${hearthkitProjectName}`,
|
|
54
|
+
'',
|
|
55
|
+
'services:',
|
|
56
|
+
...serviceBlocks.flat(),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
if (volumeNames.length > 0) {
|
|
60
|
+
lines.push('', 'volumes:', ...volumeNames.map((volumeName) => ` ${volumeName}:`))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return `${lines.join('\n')}\n`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The named volume a service keeps its data in; prefixed with the project so two projects never share one. */
|
|
67
|
+
function namedVolumeFor(
|
|
68
|
+
hearthkitProjectName: HearthkitProjectName,
|
|
69
|
+
serviceName: LocalInfraServiceName,
|
|
70
|
+
): string {
|
|
71
|
+
return `${hearthkitProjectName}-${serviceName}-data`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The compose lines for one service, indented ready to sit under the services key. */
|
|
75
|
+
function buildServiceBlock(
|
|
76
|
+
serviceName: LocalInfraServiceName,
|
|
77
|
+
hearthkitProjectName: HearthkitProjectName,
|
|
78
|
+
): string[] {
|
|
79
|
+
const header = [
|
|
80
|
+
` ${serviceName}:`,
|
|
81
|
+
` image: '${localInfraServiceImageByName[serviceName]}'`,
|
|
82
|
+
` container_name: ${hearthkitProjectName}-${serviceName}`,
|
|
83
|
+
' restart: unless-stopped',
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
if (serviceName === 'postgres') {
|
|
87
|
+
return [
|
|
88
|
+
...header,
|
|
89
|
+
' environment:',
|
|
90
|
+
` POSTGRES_USER: ${localInfraCredential}`,
|
|
91
|
+
` POSTGRES_PASSWORD: ${localInfraCredential}`,
|
|
92
|
+
` POSTGRES_DB: ${localInfraCredential}`,
|
|
93
|
+
' ports:',
|
|
94
|
+
" - '5432:5432'",
|
|
95
|
+
' volumes:',
|
|
96
|
+
` - ${namedVolumeFor(hearthkitProjectName, serviceName)}:/var/lib/postgresql/data`,
|
|
97
|
+
' healthcheck:',
|
|
98
|
+
` test: ['CMD-SHELL', 'pg_isready -U ${localInfraCredential} -d ${localInfraCredential}']`,
|
|
99
|
+
' interval: 5s',
|
|
100
|
+
' timeout: 5s',
|
|
101
|
+
' retries: 20',
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (serviceName === 'minio') {
|
|
106
|
+
return [
|
|
107
|
+
...header,
|
|
108
|
+
" command: ['server', '/data', '--console-address', ':9001']",
|
|
109
|
+
' environment:',
|
|
110
|
+
` MINIO_ROOT_USER: ${localInfraCredential}`,
|
|
111
|
+
` MINIO_ROOT_PASSWORD: ${localInfraCredential}`,
|
|
112
|
+
' ports:',
|
|
113
|
+
" - '9000:9000'",
|
|
114
|
+
" - '9001:9001'",
|
|
115
|
+
' volumes:',
|
|
116
|
+
` - ${namedVolumeFor(hearthkitProjectName, serviceName)}:/data`,
|
|
117
|
+
' healthcheck:',
|
|
118
|
+
" test: ['CMD', 'mc', 'ready', 'local']",
|
|
119
|
+
' interval: 5s',
|
|
120
|
+
' timeout: 5s',
|
|
121
|
+
' retries: 20',
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [...header, ' ports:', " - '1025:1025'", " - '8025:8025'"]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The compose lines for the container that creates the local storage bucket. It carries no restart,
|
|
130
|
+
* ports, volumes or environment keys on purpose: it publishes and stores nothing, and compose's
|
|
131
|
+
* default restart policy of no is the right one for a container whose work is already done.
|
|
132
|
+
*/
|
|
133
|
+
function buildBucketInitBlock(hearthkitProjectName: HearthkitProjectName): string[] {
|
|
134
|
+
const localStorageBucketName = deriveLocalStorageBucketName(hearthkitProjectName)
|
|
135
|
+
const bucketInitCommand = [
|
|
136
|
+
`mc alias set local ${localStorageServiceEndpoint} ${localInfraCredential} ${localInfraCredential}`,
|
|
137
|
+
`mc mb --ignore-existing local/${localStorageBucketName}`,
|
|
138
|
+
'tail -f /dev/null',
|
|
139
|
+
].join(' && ')
|
|
140
|
+
|
|
141
|
+
return [
|
|
142
|
+
' # Creates the bucket, then idles on purpose. It is not stuck. hearthkit dev infra up always',
|
|
143
|
+
' # runs docker compose up --wait. That command fails if a service has exited, whatever its',
|
|
144
|
+
' # exit code. Remove the tail -f /dev/null below and every dev infra up fails, even though',
|
|
145
|
+
' # the bucket is created. A failed step stops the && chain before tail and exits nonzero.',
|
|
146
|
+
` ${localStorageBucketInitServiceName}:`,
|
|
147
|
+
` image: '${localStorageBucketInitImage}'`,
|
|
148
|
+
` container_name: ${hearthkitProjectName}-${localStorageBucketInitServiceName}`,
|
|
149
|
+
' depends_on:',
|
|
150
|
+
' minio:',
|
|
151
|
+
' condition: service_healthy',
|
|
152
|
+
` entrypoint: ['sh', '-c', '${bucketInitCommand}']`,
|
|
153
|
+
]
|
|
154
|
+
}
|