@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,80 @@
|
|
|
1
|
+
import { access, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { constants as fileSystemConstants } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { CliFailure } from './cli-contract.ts'
|
|
5
|
+
import {
|
|
6
|
+
composeFileUnwritableFailure,
|
|
7
|
+
projectManifestMissingFailure,
|
|
8
|
+
} from './cli-failure-results.ts'
|
|
9
|
+
import type { CliRuntimeContext } from './cli-runtime-context.ts'
|
|
10
|
+
import { generateLocalInfraCompose } from './generate-local-infra-compose.ts'
|
|
11
|
+
import { readProjectInfraManifest } from './read-project-infra-manifest.ts'
|
|
12
|
+
|
|
13
|
+
/** The one compose file name this package reads and, when it is absent, writes. */
|
|
14
|
+
const localInfraComposeFileName = 'docker-compose.yml'
|
|
15
|
+
|
|
16
|
+
/** Which compose file to run, that none is needed, or why one could not be produced. */
|
|
17
|
+
export type LocalInfraComposeResolution =
|
|
18
|
+
| { kind: 'local-infra-compose-ready'; composeFilePath: string }
|
|
19
|
+
| { kind: 'local-infra-compose-not-needed' }
|
|
20
|
+
| { kind: 'local-infra-compose-rejected'; failure: CliFailure }
|
|
21
|
+
|
|
22
|
+
/** Where the working directory's compose file lives, whether or not it exists yet. */
|
|
23
|
+
export function localInfraComposeFilePath(context: CliRuntimeContext): string {
|
|
24
|
+
return join(context.workingDirectoryPath, localInfraComposeFileName)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when the working directory already holds a compose file, which dev infra down needs to know before touching docker. */
|
|
28
|
+
export async function hasLocalInfraComposeFile(context: CliRuntimeContext): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
await access(localInfraComposeFilePath(context), fileSystemConstants.F_OK)
|
|
31
|
+
return true
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Answers which compose file dev infra up should run. An existing file is used exactly as written
|
|
39
|
+
* and never overwritten; otherwise the project's manifest decides which services are needed and the
|
|
40
|
+
* file is generated. A project needing no local infra is a success with nothing written.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveLocalInfraComposeFile(
|
|
43
|
+
context: CliRuntimeContext,
|
|
44
|
+
): Promise<LocalInfraComposeResolution> {
|
|
45
|
+
const composeFilePath = localInfraComposeFilePath(context)
|
|
46
|
+
if (await hasLocalInfraComposeFile(context)) {
|
|
47
|
+
return { kind: 'local-infra-compose-ready', composeFilePath }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const manifest = await readProjectInfraManifest(context)
|
|
51
|
+
if (manifest.kind === 'project-infra-manifest-unreadable') {
|
|
52
|
+
return {
|
|
53
|
+
kind: 'local-infra-compose-rejected',
|
|
54
|
+
failure: projectManifestMissingFailure(manifest.manifestPath, manifest.detail),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (manifest.infraServices.length === 0) {
|
|
59
|
+
return { kind: 'local-infra-compose-not-needed' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const composeFileContent = generateLocalInfraCompose({
|
|
63
|
+
hearthkitProjectName: manifest.hearthkitProjectName,
|
|
64
|
+
infraServices: manifest.infraServices,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await writeFile(composeFilePath, composeFileContent, 'utf8')
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'local-infra-compose-rejected',
|
|
72
|
+
failure: composeFileUnwritableFailure(
|
|
73
|
+
composeFilePath,
|
|
74
|
+
`could not be written (${error instanceof Error ? error.message : String(error)})`,
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { kind: 'local-infra-compose-ready', composeFilePath }
|
|
80
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { writeStandardErrorChunk } from './cli-output-streams.ts'
|
|
3
|
+
import type { CliRuntimeContext } from './cli-runtime-context.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What spawning one external command produced. A binary that is not on PATH is an outcome rather
|
|
7
|
+
* than an exception, because "docker is not installed" is a named failure mode of this package.
|
|
8
|
+
*/
|
|
9
|
+
export type ChildProcessOutcome =
|
|
10
|
+
| {
|
|
11
|
+
kind: 'child-process-exited'
|
|
12
|
+
exitCode: number
|
|
13
|
+
standardOutput: string
|
|
14
|
+
standardError: string
|
|
15
|
+
}
|
|
16
|
+
| { kind: 'child-process-not-on-path' }
|
|
17
|
+
|
|
18
|
+
/** How to run one external command; the binary is named bare so the context's PATH decides which one runs. */
|
|
19
|
+
export type ChildProcessCommandOptions = {
|
|
20
|
+
commandName: string
|
|
21
|
+
commandArguments: readonly string[]
|
|
22
|
+
context: CliRuntimeContext
|
|
23
|
+
forwardStandardError?: boolean
|
|
24
|
+
timeoutMilliseconds?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Long enough for docker to pull an image over a slow link, short enough that a wedged daemon still returns. */
|
|
28
|
+
const defaultChildProcessTimeoutMilliseconds = 600_000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Spawns one external command with the caller's working directory and environment, so PATH lookup
|
|
32
|
+
* happens against the environment the CLI was given rather than the one this process was started in.
|
|
33
|
+
* Never rejects: a missing binary and a nonzero exit are both outcomes.
|
|
34
|
+
*/
|
|
35
|
+
export async function runChildProcessCommand(
|
|
36
|
+
options: ChildProcessCommandOptions,
|
|
37
|
+
): Promise<ChildProcessOutcome> {
|
|
38
|
+
return new Promise<ChildProcessOutcome>((resolve) => {
|
|
39
|
+
const child = spawn(options.commandName, [...options.commandArguments], {
|
|
40
|
+
cwd: options.context.workingDirectoryPath,
|
|
41
|
+
env: options.context.environmentVariables,
|
|
42
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
timeout: options.timeoutMilliseconds ?? defaultChildProcessTimeoutMilliseconds,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const standardOutputChunks: string[] = []
|
|
47
|
+
const standardErrorChunks: string[] = []
|
|
48
|
+
let settled = false
|
|
49
|
+
|
|
50
|
+
child.stdout.setEncoding('utf8')
|
|
51
|
+
child.stdout.on('data', (chunk: string) => standardOutputChunks.push(chunk))
|
|
52
|
+
child.stderr.setEncoding('utf8')
|
|
53
|
+
child.stderr.on('data', (chunk: string) => {
|
|
54
|
+
standardErrorChunks.push(chunk)
|
|
55
|
+
if (options.forwardStandardError === true) {
|
|
56
|
+
writeStandardErrorChunk(chunk)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
child.on('error', (error: NodeJS.ErrnoException) => {
|
|
61
|
+
if (settled) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
settled = true
|
|
65
|
+
if (error.code === 'ENOENT') {
|
|
66
|
+
resolve({ kind: 'child-process-not-on-path' })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
resolve({
|
|
70
|
+
kind: 'child-process-exited',
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
standardOutput: standardOutputChunks.join(''),
|
|
73
|
+
standardError: `${standardErrorChunks.join('')}${error.message}`,
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
child.on('close', (exitCode) => {
|
|
78
|
+
if (settled) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
settled = true
|
|
82
|
+
resolve({
|
|
83
|
+
kind: 'child-process-exited',
|
|
84
|
+
exitCode: exitCode ?? 1,
|
|
85
|
+
standardOutput: standardOutputChunks.join(''),
|
|
86
|
+
standardError: standardErrorChunks.join(''),
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import {
|
|
3
|
+
backupProjectDatabase,
|
|
4
|
+
createProjectDatabase,
|
|
5
|
+
dropProjectDatabase,
|
|
6
|
+
restoreProjectDatabase,
|
|
7
|
+
runDatabaseMigrations,
|
|
8
|
+
} from '@hearthkit/db'
|
|
9
|
+
import type { CliCommandInvocation, CliCommandResult } from './cli-contract.ts'
|
|
10
|
+
import { dbCommandFailedFailure } from './cli-failure-results.ts'
|
|
11
|
+
import type { CliRuntimeContext } from './cli-runtime-context.ts'
|
|
12
|
+
|
|
13
|
+
/** The five invocations that are only a thin call into @hearthkit/db; no database logic lives in this package. */
|
|
14
|
+
export type DbLifecycleInvocation = Extract<
|
|
15
|
+
CliCommandInvocation,
|
|
16
|
+
{ commandPath: 'db create' | 'db drop' | 'db migrate' | 'db backup' | 'db restore' }
|
|
17
|
+
>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs one db command through @hearthkit/db and translates its result. Any DbFailure is carried out
|
|
21
|
+
* verbatim, so the underlying message and its prefix reach the operator unchanged. Paths given on
|
|
22
|
+
* the command line are reported exactly as typed but resolved against the working directory first.
|
|
23
|
+
*/
|
|
24
|
+
export async function runDbLifecycleCommand(options: {
|
|
25
|
+
invocation: DbLifecycleInvocation
|
|
26
|
+
context: CliRuntimeContext
|
|
27
|
+
}): Promise<CliCommandResult> {
|
|
28
|
+
const { invocation, context } = options
|
|
29
|
+
|
|
30
|
+
if (invocation.commandPath === 'db create') {
|
|
31
|
+
const result = await createProjectDatabase({
|
|
32
|
+
adminDatabaseUrl: invocation.adminDatabaseUrl,
|
|
33
|
+
projectDatabaseName: invocation.projectDatabaseName,
|
|
34
|
+
})
|
|
35
|
+
if (result.kind !== 'project-database-created') {
|
|
36
|
+
return dbCommandFailedFailure(result)
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
kind: 'db-create-command-succeeded',
|
|
40
|
+
projectDatabaseName: result.projectDatabaseName,
|
|
41
|
+
connectionString: result.connectionString,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (invocation.commandPath === 'db drop') {
|
|
46
|
+
const result = await dropProjectDatabase({
|
|
47
|
+
adminDatabaseUrl: invocation.adminDatabaseUrl,
|
|
48
|
+
projectDatabaseName: invocation.projectDatabaseName,
|
|
49
|
+
})
|
|
50
|
+
if (result.kind !== 'project-database-dropped') {
|
|
51
|
+
return dbCommandFailedFailure(result)
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
kind: 'db-drop-command-succeeded',
|
|
55
|
+
projectDatabaseName: result.projectDatabaseName,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (invocation.commandPath === 'db migrate') {
|
|
60
|
+
const result = await runDatabaseMigrations({
|
|
61
|
+
databaseUrl: invocation.databaseUrl,
|
|
62
|
+
migrationsFolderPath: resolve(context.workingDirectoryPath, invocation.migrationsFolderPath),
|
|
63
|
+
})
|
|
64
|
+
if (result.kind !== 'database-migrations-applied') {
|
|
65
|
+
return dbCommandFailedFailure(result)
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
kind: 'db-migrate-command-succeeded',
|
|
69
|
+
appliedMigrationCount: result.appliedMigrationCount,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (invocation.commandPath === 'db backup') {
|
|
74
|
+
const result = await backupProjectDatabase({
|
|
75
|
+
adminDatabaseUrl: invocation.adminDatabaseUrl,
|
|
76
|
+
projectDatabaseName: invocation.projectDatabaseName,
|
|
77
|
+
backupFilePath: resolve(context.workingDirectoryPath, invocation.backupFilePath),
|
|
78
|
+
})
|
|
79
|
+
if (result.kind !== 'project-database-backed-up') {
|
|
80
|
+
return dbCommandFailedFailure(result)
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
kind: 'db-backup-command-succeeded',
|
|
84
|
+
projectDatabaseName: result.projectDatabaseName,
|
|
85
|
+
backupFilePath: invocation.backupFilePath,
|
|
86
|
+
backupByteCount: result.backupByteCount,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await restoreProjectDatabase({
|
|
91
|
+
adminDatabaseUrl: invocation.adminDatabaseUrl,
|
|
92
|
+
projectDatabaseName: invocation.projectDatabaseName,
|
|
93
|
+
backupFilePath: resolve(context.workingDirectoryPath, invocation.backupFilePath),
|
|
94
|
+
})
|
|
95
|
+
if (result.kind !== 'project-database-restored') {
|
|
96
|
+
return dbCommandFailedFailure(result)
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
kind: 'db-restore-command-succeeded',
|
|
100
|
+
projectDatabaseName: result.projectDatabaseName,
|
|
101
|
+
backupFilePath: invocation.backupFilePath,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { access } from 'node:fs/promises'
|
|
3
|
+
import { constants as fileSystemConstants } from 'node:fs'
|
|
4
|
+
import { constants as osConstants } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { cliDevInfraUpCompleteLinePrefix, type CliCommandResult } from './cli-contract.ts'
|
|
7
|
+
import { nextDevUnavailableFailure } from './cli-failure-results.ts'
|
|
8
|
+
import { writeStandardErrorLine } from './cli-output-streams.ts'
|
|
9
|
+
import type { CliRuntimeContext } from './cli-runtime-context.ts'
|
|
10
|
+
import { runDevInfraUpCommand } from './run-dev-infra-command.ts'
|
|
11
|
+
|
|
12
|
+
/** The project's own Next.js binary; hearthkit never installs or bundles one of its own. */
|
|
13
|
+
const projectNextBinaryRelativePath = join('node_modules', '.bin', 'next')
|
|
14
|
+
|
|
15
|
+
/** The exit code range a process may report; a child killed by a signal is folded into it as 128 + signal. */
|
|
16
|
+
const maximumProcessExitCode = 255
|
|
17
|
+
|
|
18
|
+
/** How the next dev child ended, or why it could not be started at all. */
|
|
19
|
+
type NextDevOutcome =
|
|
20
|
+
| { kind: 'next-dev-exited'; nextDevExitCode: number }
|
|
21
|
+
| { kind: 'next-dev-not-spawnable'; detail: string }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Brings local infra up and then hands the terminal to the project's own next dev, whose stdio is
|
|
25
|
+
* inherited rather than captured so Next's interactive output behaves exactly as it does directly.
|
|
26
|
+
* The command's exit code is the child's, which is why it is not always 0, 1, or 2.
|
|
27
|
+
*/
|
|
28
|
+
export async function runDevCommand(context: CliRuntimeContext): Promise<CliCommandResult> {
|
|
29
|
+
const infraResult = await runDevInfraUpCommand(context)
|
|
30
|
+
if (infraResult.kind !== 'dev-infra-up-succeeded') {
|
|
31
|
+
return infraResult
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Progress, not machine-readable output: stdout belongs to next dev from here on.
|
|
35
|
+
writeStandardErrorLine(
|
|
36
|
+
`${cliDevInfraUpCompleteLinePrefix} ${describeStartedServices(infraResult.startedInfraServices)}`,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const nextBinaryPath = join(context.workingDirectoryPath, projectNextBinaryRelativePath)
|
|
40
|
+
try {
|
|
41
|
+
await access(nextBinaryPath, fileSystemConstants.X_OK)
|
|
42
|
+
} catch {
|
|
43
|
+
return nextDevUnavailableFailure(
|
|
44
|
+
`no runnable next binary at ${nextBinaryPath}; install next in this project first`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const outcome = await runNextDevProcess(nextBinaryPath, context)
|
|
49
|
+
if (outcome.kind === 'next-dev-not-spawnable') {
|
|
50
|
+
return nextDevUnavailableFailure(`${nextBinaryPath} could not be started (${outcome.detail})`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { kind: 'dev-command-exited', nextDevExitCode: outcome.nextDevExitCode }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Names the started services for the progress line, or says plainly that none were needed. */
|
|
57
|
+
function describeStartedServices(startedInfraServices: readonly string[]): string {
|
|
58
|
+
return startedInfraServices.length === 0
|
|
59
|
+
? 'no local infra services needed'
|
|
60
|
+
: `started ${startedInfraServices.join(', ')}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Runs next dev to completion with inherited stdio and reports the code the shell would have seen. */
|
|
64
|
+
async function runNextDevProcess(
|
|
65
|
+
nextBinaryPath: string,
|
|
66
|
+
context: CliRuntimeContext,
|
|
67
|
+
): Promise<NextDevOutcome> {
|
|
68
|
+
return new Promise<NextDevOutcome>((resolve) => {
|
|
69
|
+
const child = spawn(nextBinaryPath, ['dev'], {
|
|
70
|
+
cwd: context.workingDirectoryPath,
|
|
71
|
+
env: context.environmentVariables,
|
|
72
|
+
stdio: 'inherit',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
let settled = false
|
|
76
|
+
|
|
77
|
+
child.on('error', (error: Error) => {
|
|
78
|
+
if (settled) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
settled = true
|
|
82
|
+
resolve({ kind: 'next-dev-not-spawnable', detail: error.message })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
child.on('close', (exitCode, signal) => {
|
|
86
|
+
if (settled) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
settled = true
|
|
90
|
+
resolve({ kind: 'next-dev-exited', nextDevExitCode: readChildExitCode(exitCode, signal) })
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Folds an exit code or terminating signal into the 0 to 255 range the contract allows. */
|
|
96
|
+
function readChildExitCode(exitCode: number | null, signal: NodeJS.Signals | null): number {
|
|
97
|
+
if (exitCode !== null) {
|
|
98
|
+
return Math.min(maximumProcessExitCode, Math.max(0, exitCode))
|
|
99
|
+
}
|
|
100
|
+
if (signal !== null) {
|
|
101
|
+
return Math.min(maximumProcessExitCode, 128 + (osConstants.signals[signal] ?? 0))
|
|
102
|
+
}
|
|
103
|
+
return 1
|
|
104
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CliCommandResult, CliFailure } from './cli-contract.ts'
|
|
2
|
+
import { dockerUnavailableFailure, infraComposeFailedFailure } from './cli-failure-results.ts'
|
|
3
|
+
import type { CliRuntimeContext } from './cli-runtime-context.ts'
|
|
4
|
+
import {
|
|
5
|
+
checkDockerAvailability,
|
|
6
|
+
composeDownArguments,
|
|
7
|
+
composeServicesArguments,
|
|
8
|
+
composeUpArguments,
|
|
9
|
+
readKnownInfraServiceNames,
|
|
10
|
+
runDockerComposeCommand,
|
|
11
|
+
} from './docker-compose-commands.ts'
|
|
12
|
+
import type { ChildProcessOutcome } from './run-child-process-command.ts'
|
|
13
|
+
import {
|
|
14
|
+
hasLocalInfraComposeFile,
|
|
15
|
+
localInfraComposeFilePath,
|
|
16
|
+
resolveLocalInfraComposeFile,
|
|
17
|
+
} from './resolve-local-infra-compose-file.ts'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Starts the project's local infra. Docker is checked before anything is read or written, then the
|
|
21
|
+
* compose file is resolved (used as found, or generated from package.json), then compose runs. A
|
|
22
|
+
* project that needs no local services succeeds having started nothing and written nothing.
|
|
23
|
+
*/
|
|
24
|
+
export async function runDevInfraUpCommand(context: CliRuntimeContext): Promise<CliCommandResult> {
|
|
25
|
+
const availability = await checkDockerAvailability(context)
|
|
26
|
+
if (availability.kind === 'docker-unavailable') {
|
|
27
|
+
return dockerUnavailableFailure(availability.detail)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const resolution = await resolveLocalInfraComposeFile(context)
|
|
31
|
+
if (resolution.kind === 'local-infra-compose-rejected') {
|
|
32
|
+
return resolution.failure
|
|
33
|
+
}
|
|
34
|
+
if (resolution.kind === 'local-infra-compose-not-needed') {
|
|
35
|
+
return { kind: 'dev-infra-up-succeeded', startedInfraServices: [] }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const upOutcome = await runDockerComposeCommand({
|
|
39
|
+
context,
|
|
40
|
+
composeFilePath: resolution.composeFilePath,
|
|
41
|
+
composeArguments: composeUpArguments,
|
|
42
|
+
forwardStandardError: true,
|
|
43
|
+
})
|
|
44
|
+
const upFailure = readComposeCommandFailure(upOutcome, composeUpArguments)
|
|
45
|
+
if (upFailure !== undefined) {
|
|
46
|
+
return upFailure
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const servicesOutcome = await runDockerComposeCommand({
|
|
50
|
+
context,
|
|
51
|
+
composeFilePath: resolution.composeFilePath,
|
|
52
|
+
composeArguments: composeServicesArguments,
|
|
53
|
+
})
|
|
54
|
+
const servicesFailure = readComposeCommandFailure(servicesOutcome, composeServicesArguments)
|
|
55
|
+
if (servicesFailure !== undefined) {
|
|
56
|
+
return servicesFailure
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
kind: 'dev-infra-up-succeeded',
|
|
61
|
+
startedInfraServices:
|
|
62
|
+
servicesOutcome.kind === 'child-process-exited'
|
|
63
|
+
? readKnownInfraServiceNames(servicesOutcome.standardOutput)
|
|
64
|
+
: [],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Stops the project's local infra, keeping named volumes so data survives. A working directory with
|
|
70
|
+
* no compose file has nothing to stop, and that is a success that never needs docker at all.
|
|
71
|
+
*/
|
|
72
|
+
export async function runDevInfraDownCommand(
|
|
73
|
+
context: CliRuntimeContext,
|
|
74
|
+
): Promise<CliCommandResult> {
|
|
75
|
+
if (!(await hasLocalInfraComposeFile(context))) {
|
|
76
|
+
return { kind: 'dev-infra-down-succeeded' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const availability = await checkDockerAvailability(context)
|
|
80
|
+
if (availability.kind === 'docker-unavailable') {
|
|
81
|
+
return dockerUnavailableFailure(availability.detail)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const downOutcome = await runDockerComposeCommand({
|
|
85
|
+
context,
|
|
86
|
+
composeFilePath: localInfraComposeFilePath(context),
|
|
87
|
+
composeArguments: composeDownArguments,
|
|
88
|
+
forwardStandardError: true,
|
|
89
|
+
})
|
|
90
|
+
const downFailure = readComposeCommandFailure(downOutcome, composeDownArguments)
|
|
91
|
+
if (downFailure !== undefined) {
|
|
92
|
+
return downFailure
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { kind: 'dev-infra-down-succeeded' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Turns a compose invocation that did not work into the matching failure, or undefined when it did. */
|
|
99
|
+
function readComposeCommandFailure(
|
|
100
|
+
outcome: ChildProcessOutcome,
|
|
101
|
+
composeArguments: readonly string[],
|
|
102
|
+
): CliFailure | undefined {
|
|
103
|
+
if (outcome.kind === 'child-process-not-on-path') {
|
|
104
|
+
return dockerUnavailableFailure('docker left PATH between the availability check and this call')
|
|
105
|
+
}
|
|
106
|
+
if (outcome.exitCode !== 0) {
|
|
107
|
+
return infraComposeFailedFailure({
|
|
108
|
+
composeArguments,
|
|
109
|
+
composeExitCode: outcome.exitCode,
|
|
110
|
+
composeStandardError:
|
|
111
|
+
outcome.standardError.trim() === '' ? outcome.standardOutput : outcome.standardError,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
return undefined
|
|
115
|
+
}
|