@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,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `hearthkit` bin, and plain JavaScript for the reason spelled out in full in
|
|
3
|
+
// @hearthkit/config's `src/register-node-modules-type-stripping.js`: Node 24.20.0 refuses to strip
|
|
4
|
+
// types from a `.ts` file under `node_modules`, so the bin an installed project runs cannot itself
|
|
5
|
+
// be TypeScript. The hook lives in @hearthkit/config, which this package declares as a direct
|
|
6
|
+
// dependency for exactly this import, so there is one copy of it rather than one per entry point.
|
|
7
|
+
//
|
|
8
|
+
// `hearthkit-bin.ts` is reached by a dynamic import, and that is load-bearing rather than a style
|
|
9
|
+
// choice. Node loads the source of an entire static module graph before it evaluates any of it, so
|
|
10
|
+
// a static `import './hearthkit-bin.ts'` would be read off disk while the hook module below is
|
|
11
|
+
// still unevaluated, and would throw ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. The dynamic
|
|
12
|
+
// import runs after this module body, by which time the hook is registered.
|
|
13
|
+
//
|
|
14
|
+
// API reference checked against the current Node 24 docs on 2026-09-07:
|
|
15
|
+
// https://nodejs.org/docs/latest-v24.x/api/module.html
|
|
16
|
+
|
|
17
|
+
import '@hearthkit/config/register-node-modules-type-stripping'
|
|
18
|
+
|
|
19
|
+
await import('./hearthkit-bin.ts')
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { access, readFile } from 'node:fs/promises'
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { dirname, resolve } from 'node:path'
|
|
6
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
createGateDirectory,
|
|
9
|
+
gateEnvironment,
|
|
10
|
+
removeGateDirectory,
|
|
11
|
+
} from '../test-fixtures/gate-project-directories.ts'
|
|
12
|
+
import { cliDevInfraDownCompleteLinePrefix, cliUsageErrorPrefix } from './cli-contract.ts'
|
|
13
|
+
|
|
14
|
+
const packageDirectoryPath = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
15
|
+
const directoriesToRemove: string[] = []
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
for (const directoryPath of directoriesToRemove) {
|
|
19
|
+
await removeGateDirectory(directoryPath)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The path package.json advertises as the hearthkit binary, resolved from the bin field rather than
|
|
25
|
+
* hardcoded, so this gate fails when the bin wiring drifts instead of testing a file nobody installs.
|
|
26
|
+
*/
|
|
27
|
+
async function resolveAdvertisedBinPath(): Promise<string> {
|
|
28
|
+
const manifest: unknown = JSON.parse(
|
|
29
|
+
await readFile(resolve(packageDirectoryPath, 'package.json'), 'utf8'),
|
|
30
|
+
)
|
|
31
|
+
const binField =
|
|
32
|
+
typeof manifest === 'object' && manifest !== null && 'bin' in manifest
|
|
33
|
+
? manifest.bin
|
|
34
|
+
: undefined
|
|
35
|
+
const advertisedBinEntry =
|
|
36
|
+
typeof binField === 'object' && binField !== null && 'hearthkit' in binField
|
|
37
|
+
? binField.hearthkit
|
|
38
|
+
: undefined
|
|
39
|
+
if (typeof advertisedBinEntry !== 'string') {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`gate expected packages/cli/package.json to declare bin.hearthkit as a path string, received ${JSON.stringify(binField)}`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
const binPath = resolve(packageDirectoryPath, advertisedBinEntry)
|
|
45
|
+
await access(binPath, constants.X_OK).catch(() => {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`gate expected the advertised bin ${advertisedBinEntry} to exist and be executable at ${binPath}`,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
return binPath
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Runs one command as a real child process and reports only what an operator sees: exit code and the two streams. */
|
|
54
|
+
async function spawnCliProcess(options: {
|
|
55
|
+
command: string
|
|
56
|
+
commandArguments: string[]
|
|
57
|
+
cwd: string
|
|
58
|
+
}): Promise<{ exitCode: number; standardOutput: string; standardError: string }> {
|
|
59
|
+
return new Promise((resolveResult, rejectResult) => {
|
|
60
|
+
const child = spawn(options.command, options.commandArguments, {
|
|
61
|
+
cwd: options.cwd,
|
|
62
|
+
env: gateEnvironment(),
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
})
|
|
65
|
+
const standardOutputChunks: string[] = []
|
|
66
|
+
const standardErrorChunks: string[] = []
|
|
67
|
+
child.stdout.on('data', (chunk: Buffer) => standardOutputChunks.push(chunk.toString('utf8')))
|
|
68
|
+
child.stderr.on('data', (chunk: Buffer) => standardErrorChunks.push(chunk.toString('utf8')))
|
|
69
|
+
child.on('error', (error) =>
|
|
70
|
+
rejectResult(
|
|
71
|
+
new Error(`gate could not spawn ${options.command}: ${error.message}`, { cause: error }),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
child.on('close', (code, signal) => {
|
|
75
|
+
if (code === null) {
|
|
76
|
+
rejectResult(new Error(`gate expected an exit code, the process was killed by ${signal}`))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
resolveResult({
|
|
80
|
+
exitCode: code,
|
|
81
|
+
standardOutput: standardOutputChunks.join(''),
|
|
82
|
+
standardError: standardErrorChunks.join(''),
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The non-empty lines of a stream, so a gate can name the one line it expects without matching whitespace. */
|
|
89
|
+
function nonEmptyLines(streamText: string): string[] {
|
|
90
|
+
return streamText.split('\n').filter((line) => line.trim().length > 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// These two gates are the only place the binary itself is exercised: every other CLI gate calls
|
|
94
|
+
// runHearthkitCli in process, which cannot catch a broken bin field, a missing executable bit or an
|
|
95
|
+
// exit code the wrapper failed to propagate.
|
|
96
|
+
describe('the hearthkit binary as a child process', () => {
|
|
97
|
+
it('runs through its own shebang and exits 0 on the dev infra down no-op', async () => {
|
|
98
|
+
const binPath = await resolveAdvertisedBinPath()
|
|
99
|
+
const directoryPath = await createGateDirectory('bin-exit-zero')
|
|
100
|
+
directoriesToRemove.push(directoryPath)
|
|
101
|
+
|
|
102
|
+
// Executed directly, with no interpreter in front of it: this holds the shebang and the
|
|
103
|
+
// executable bit, which is what makes the file runnable once a package manager links it.
|
|
104
|
+
const run = await spawnCliProcess({
|
|
105
|
+
command: binPath,
|
|
106
|
+
commandArguments: ['dev', 'infra', 'down'],
|
|
107
|
+
cwd: directoryPath,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
expect(run.exitCode).toBe(0)
|
|
111
|
+
expect(nonEmptyLines(run.standardError)).toEqual([])
|
|
112
|
+
const printedLines = nonEmptyLines(run.standardOutput)
|
|
113
|
+
expect(printedLines).toHaveLength(1)
|
|
114
|
+
expect(printedLines[0]?.startsWith(cliDevInfraDownCompleteLinePrefix)).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('exits 2 with the usage prefix on stderr when the command path is unknown', async () => {
|
|
118
|
+
const binPath = await resolveAdvertisedBinPath()
|
|
119
|
+
const directoryPath = await createGateDirectory('bin-exit-two')
|
|
120
|
+
directoriesToRemove.push(directoryPath)
|
|
121
|
+
|
|
122
|
+
// Invoked as node <bin>, the way a package manager bin shim reads the shebang and calls the
|
|
123
|
+
// interpreter, so both entry routes into the same wrapper are covered by this file.
|
|
124
|
+
const run = await spawnCliProcess({
|
|
125
|
+
command: process.execPath,
|
|
126
|
+
commandArguments: [binPath, 'db', 'frobnicate'],
|
|
127
|
+
cwd: directoryPath,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(run.exitCode).toBe(2)
|
|
131
|
+
expect(nonEmptyLines(run.standardOutput)).toEqual([])
|
|
132
|
+
const reportedLines = nonEmptyLines(run.standardError)
|
|
133
|
+
const lastReportedLine = reportedLines.at(-1)
|
|
134
|
+
expect(lastReportedLine?.startsWith(cliUsageErrorPrefix)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The hearthkit command itself. Reached through hearthkit-bin-entry.js, never named in the bin
|
|
2
|
+
// field directly, because Node refuses to strip types from a .ts file under node_modules and the
|
|
3
|
+
// entry shim is what installs the loader hook that does it. The relative imports below name .ts
|
|
4
|
+
// files because that is what actually ships: there is still no build step.
|
|
5
|
+
import { runHearthkitCli } from './run-hearthkit-cli.ts'
|
|
6
|
+
|
|
7
|
+
const { exitCode } = await runHearthkitCli({ argv: process.argv.slice(2) })
|
|
8
|
+
process.exitCode = exitCode
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Public entry point of @hearthkit/cli: a named re-export of exactly the surface the contract lists, so no internal module is importable by consumers. */
|
|
2
|
+
|
|
3
|
+
/** Runs one hearthkit command and returns its exit code and structured result; the bin is a wrapper around this. */
|
|
4
|
+
export { runHearthkitCli } from './run-hearthkit-cli.ts'
|
|
5
|
+
|
|
6
|
+
/** Builds the local infra compose file; pure, so the scaffolder can reuse it instead of copying a template. */
|
|
7
|
+
export { generateLocalInfraCompose } from './generate-local-infra-compose.ts'
|
|
8
|
+
|
|
9
|
+
/** Derives the bucket name the generated compose file creates in MinIO; pure and total, so the scaffolder writes the identical string to STORAGE_BUCKET. */
|
|
10
|
+
export { deriveLocalStorageBucketName } from './derive-local-storage-bucket-name.ts'
|
|
11
|
+
|
|
12
|
+
/** Turns a package.json name into the project name compose, container, volume, and bucket names are built from; falls back to hearthkit-app. */
|
|
13
|
+
export { deriveHearthkitProjectName } from './derive-hearthkit-project-name.ts'
|
|
14
|
+
|
|
15
|
+
/** Contract values: the unique literal prefix every failure message starts with. */
|
|
16
|
+
export {
|
|
17
|
+
cliAdminUrlInvalidErrorPrefix,
|
|
18
|
+
cliComposeFileUnwritableErrorPrefix,
|
|
19
|
+
cliDatabaseUrlInvalidErrorPrefix,
|
|
20
|
+
cliDatabaseUrlMissingErrorPrefix,
|
|
21
|
+
cliDockerUnavailableErrorPrefix,
|
|
22
|
+
cliDoctorFailedErrorPrefix,
|
|
23
|
+
cliInfraComposeFailedErrorPrefix,
|
|
24
|
+
cliNextDevUnavailableErrorPrefix,
|
|
25
|
+
cliPaymentsCatalogNotFoundErrorPrefix,
|
|
26
|
+
cliPaymentsCatalogUnloadableErrorPrefix,
|
|
27
|
+
cliPaymentsSyncFailedErrorPrefix,
|
|
28
|
+
cliProjectManifestMissingErrorPrefix,
|
|
29
|
+
cliUsageErrorPrefix,
|
|
30
|
+
} from './cli-contract.ts'
|
|
31
|
+
|
|
32
|
+
/** Contract values: the unique literal prefix of each command's single stdout line and of the db create warning. */
|
|
33
|
+
export {
|
|
34
|
+
cliDbBackupCompleteLinePrefix,
|
|
35
|
+
cliDbCreateCredentialsWarningPrefix,
|
|
36
|
+
cliDbDropCompleteLinePrefix,
|
|
37
|
+
cliDbMigrateCompleteLinePrefix,
|
|
38
|
+
cliDbRestoreCompleteLinePrefix,
|
|
39
|
+
cliDevInfraDownCompleteLinePrefix,
|
|
40
|
+
cliDevInfraUpCompleteLinePrefix,
|
|
41
|
+
cliPaymentsSyncCompleteLinePrefix,
|
|
42
|
+
} from './cli-contract.ts'
|
|
43
|
+
|
|
44
|
+
/** Contract values: this package's env fragment, the defaults its resolution rules fall back to, and the catalog vocabulary payments sync reads. */
|
|
45
|
+
export {
|
|
46
|
+
adminDatabaseUrlEnvVariableName,
|
|
47
|
+
cliEnvSchemaFragment,
|
|
48
|
+
defaultBackupDirectoryPath,
|
|
49
|
+
defaultLocalAdminDatabaseUrl,
|
|
50
|
+
defaultMigrationsFolderPath,
|
|
51
|
+
defaultPaymentsCatalogPath,
|
|
52
|
+
paymentsCatalogModuleExportName,
|
|
53
|
+
stripeSecretKeyEnvVariableName,
|
|
54
|
+
} from './cli-contract.ts'
|
|
55
|
+
|
|
56
|
+
/** Contract values: the local infra vocabulary, including the single place each service image is pinned. */
|
|
57
|
+
export {
|
|
58
|
+
generateLocalInfraComposeOptionsSchema,
|
|
59
|
+
hearthkitProjectNameSchema,
|
|
60
|
+
localInfraServiceImageByName,
|
|
61
|
+
localInfraServiceNameSchema,
|
|
62
|
+
localInfraServicesByHearthkitPackage,
|
|
63
|
+
} from './cli-contract.ts'
|
|
64
|
+
|
|
65
|
+
/** Contract values: the bucket init container's service key and image pin, and the name schema the derived bucket name satisfies. */
|
|
66
|
+
export {
|
|
67
|
+
localStorageBucketInitImage,
|
|
68
|
+
localStorageBucketInitServiceName,
|
|
69
|
+
localStorageBucketNameSchema,
|
|
70
|
+
} from './cli-contract.ts'
|
|
71
|
+
|
|
72
|
+
/** Contract values: the command registry, result, failure, and doctor schemas gates and consumers parse with. */
|
|
73
|
+
export {
|
|
74
|
+
cliCommandInvocationSchema,
|
|
75
|
+
cliCommandPathSchema,
|
|
76
|
+
cliCommandResultSchema,
|
|
77
|
+
cliCommandSuccessSchema,
|
|
78
|
+
cliExitCodeSchema,
|
|
79
|
+
cliFailureSchema,
|
|
80
|
+
cliRunOutcomeSchema,
|
|
81
|
+
doctorCheckNameSchema,
|
|
82
|
+
doctorCheckResultSchema,
|
|
83
|
+
doctorJsonReportSchema,
|
|
84
|
+
runHearthkitCliOptionsSchema,
|
|
85
|
+
} from './cli-contract.ts'
|
|
86
|
+
|
|
87
|
+
/** Contract types: the vocabulary a consumer needs to hold a run's outcome without re-deriving it. */
|
|
88
|
+
export type {
|
|
89
|
+
CliCommandInvocation,
|
|
90
|
+
CliCommandPath,
|
|
91
|
+
CliCommandResult,
|
|
92
|
+
CliCommandSuccess,
|
|
93
|
+
CliExitCode,
|
|
94
|
+
CliFailure,
|
|
95
|
+
CliRunOutcome,
|
|
96
|
+
DeriveLocalStorageBucketName,
|
|
97
|
+
DoctorCheckName,
|
|
98
|
+
DoctorCheckResult,
|
|
99
|
+
DoctorJsonReport,
|
|
100
|
+
GenerateLocalInfraCompose,
|
|
101
|
+
GenerateLocalInfraComposeOptions,
|
|
102
|
+
HearthkitProjectName,
|
|
103
|
+
LocalInfraServiceName,
|
|
104
|
+
LocalStorageBucketName,
|
|
105
|
+
RunHearthkitCli,
|
|
106
|
+
RunHearthkitCliOptions,
|
|
107
|
+
} from './cli-contract.ts'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { paymentsCatalogModuleExportName } from './cli-contract.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the project's catalog module off disk without validating what it exports.
|
|
7
|
+
*
|
|
8
|
+
* Node 24 strips types natively, so a `.ts` catalog needs no build step, and the file's own
|
|
9
|
+
* `@hearthkit/payments` import resolves from the project's `node_modules` rather than from this
|
|
10
|
+
* package. The exported value travels on as `unknown` on purpose: `createPaymentsClient` is what
|
|
11
|
+
* validates a catalog, so a bad shape comes back as the payments failure rather than as a CLI one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What loading one catalog module came to; the two failing branches carry the detail the CLI reports. */
|
|
15
|
+
export type PaymentsCatalogModuleLoad =
|
|
16
|
+
| { kind: 'payments-catalog-loaded'; catalogValue: unknown }
|
|
17
|
+
| { kind: 'payments-catalog-file-absent' }
|
|
18
|
+
| { kind: 'payments-catalog-unloadable'; loadFailureDetail: string }
|
|
19
|
+
|
|
20
|
+
/** True when a readable file sits at the path; a directory is treated as no catalog at all. */
|
|
21
|
+
async function catalogFileExists(catalogPath: string): Promise<boolean> {
|
|
22
|
+
return stat(catalogPath).then(
|
|
23
|
+
(entry) => entry.isFile(),
|
|
24
|
+
() => false,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Imports the catalog module at an absolute path and reads appPaymentsCatalog, falling back to the default export. */
|
|
29
|
+
export async function loadPaymentsCatalogModule(
|
|
30
|
+
catalogPath: string,
|
|
31
|
+
): Promise<PaymentsCatalogModuleLoad> {
|
|
32
|
+
if (!(await catalogFileExists(catalogPath))) {
|
|
33
|
+
return { kind: 'payments-catalog-file-absent' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let catalogModule: Record<string, unknown>
|
|
37
|
+
try {
|
|
38
|
+
catalogModule = await import(pathToFileURL(catalogPath).href)
|
|
39
|
+
} catch (thrownValue) {
|
|
40
|
+
return {
|
|
41
|
+
kind: 'payments-catalog-unloadable',
|
|
42
|
+
loadFailureDetail: thrownValue instanceof Error ? thrownValue.message : String(thrownValue),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const namedExport = catalogModule[paymentsCatalogModuleExportName]
|
|
47
|
+
if (namedExport !== undefined) {
|
|
48
|
+
return { kind: 'payments-catalog-loaded', catalogValue: namedExport }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const defaultExport = catalogModule.default
|
|
52
|
+
if (defaultExport !== undefined) {
|
|
53
|
+
return { kind: 'payments-catalog-loaded', catalogValue: defaultExport }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
kind: 'payments-catalog-unloadable',
|
|
58
|
+
loadFailureDetail: `the module exports neither ${paymentsCatalogModuleExportName} nor a default export`,
|
|
59
|
+
}
|
|
60
|
+
}
|