@hearthkit/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +45 -0
  2. package/src/cli-contract.ts +423 -0
  3. package/src/cli-failure-results.ts +175 -0
  4. package/src/cli-output-streams.ts +14 -0
  5. package/src/cli-runtime-context.ts +8 -0
  6. package/src/default-backup-file-path.ts +28 -0
  7. package/src/derive-hearthkit-project-name.ts +27 -0
  8. package/src/derive-local-storage-bucket-name.test.ts +89 -0
  9. package/src/derive-local-storage-bucket-name.ts +24 -0
  10. package/src/docker-compose-commands.ts +100 -0
  11. package/src/format-doctor-report.ts +25 -0
  12. package/src/generate-local-infra-compose.test.ts +215 -0
  13. package/src/generate-local-infra-compose.ts +154 -0
  14. package/src/hearthkit-bin-entry.js +19 -0
  15. package/src/hearthkit-bin-execution.test.ts +136 -0
  16. package/src/hearthkit-bin.ts +8 -0
  17. package/src/index.ts +107 -0
  18. package/src/load-payments-catalog-module.ts +60 -0
  19. package/src/parse-cli-invocation.ts +365 -0
  20. package/src/read-environment-variable-value.ts +14 -0
  21. package/src/read-project-infra-manifest.ts +99 -0
  22. package/src/report-cli-outcome.ts +139 -0
  23. package/src/resolve-admin-database-url.ts +72 -0
  24. package/src/resolve-local-infra-compose-file.ts +80 -0
  25. package/src/run-child-process-command.ts +90 -0
  26. package/src/run-db-lifecycle-command.ts +103 -0
  27. package/src/run-dev-command.ts +104 -0
  28. package/src/run-dev-infra-command.ts +115 -0
  29. package/src/run-doctor-checks.ts +339 -0
  30. package/src/run-hearthkit-cli-db-commands.test.ts +330 -0
  31. package/src/run-hearthkit-cli-dev-infra-bucket.test.ts +254 -0
  32. package/src/run-hearthkit-cli-dev-infra.test.ts +616 -0
  33. package/src/run-hearthkit-cli-doctor.test.ts +75 -0
  34. package/src/run-hearthkit-cli-payments-sync.test.ts +180 -0
  35. package/src/run-hearthkit-cli.ts +62 -0
  36. package/src/run-payments-sync-command.ts +113 -0
@@ -0,0 +1,365 @@
1
+ import { isAbsolute, resolve } from 'node:path'
2
+ import { postgresConnectionStringSchema, projectDatabaseNameSchema } from '@hearthkit/db'
3
+ import {
4
+ cliCommandPathSchema,
5
+ defaultMigrationsFolderPath,
6
+ defaultPaymentsCatalogPath,
7
+ type CliCommandInvocation,
8
+ type CliCommandPath,
9
+ type CliFailure,
10
+ } from './cli-contract.ts'
11
+ import {
12
+ cliUsageInvalidFailure,
13
+ databaseUrlInvalidFailure,
14
+ databaseUrlMissingFailure,
15
+ } from './cli-failure-results.ts'
16
+ import type { CliRuntimeContext } from './cli-runtime-context.ts'
17
+ import { buildDefaultBackupFilePath } from './default-backup-file-path.ts'
18
+ import { readEnvironmentVariableValue } from './read-environment-variable-value.ts'
19
+ import { resolveAdminDatabaseUrl } from './resolve-admin-database-url.ts'
20
+
21
+ /** The project-scoped connection db migrate falls back to; never the admin connection, per the db contract. */
22
+ const projectDatabaseUrlEnvVariableName = 'DATABASE_URL'
23
+
24
+ /** Flags that take the next word as their value; every other double-dash word is either a boolean flag or unknown. */
25
+ const valueFlagNames = [
26
+ '--admin-database-url',
27
+ '--database-url',
28
+ '--migrations-folder',
29
+ '--backup-file',
30
+ '--catalog',
31
+ ] as const
32
+
33
+ /** Flags that stand alone; giving one a value is a usage failure rather than a silently ignored word. */
34
+ const booleanFlagNames = ['--json'] as const
35
+
36
+ /** Which flags each command accepts, so --json on db create is rejected instead of quietly doing nothing. */
37
+ const allowedFlagNamesByCommandPath: Record<CliCommandPath, readonly string[]> = {
38
+ 'db create': ['--admin-database-url'],
39
+ 'db drop': ['--admin-database-url'],
40
+ 'db migrate': ['--database-url', '--migrations-folder'],
41
+ 'db backup': ['--admin-database-url', '--backup-file'],
42
+ 'db restore': ['--admin-database-url'],
43
+ dev: [],
44
+ 'dev infra up': [],
45
+ 'dev infra down': [],
46
+ doctor: ['--json'],
47
+ 'payments sync': ['--catalog'],
48
+ }
49
+
50
+ /** How many words follow the command itself; checked before any word is handed to a schema. */
51
+ const commandArgumentCountByPath: Record<CliCommandPath, number> = {
52
+ 'db create': 1,
53
+ 'db drop': 1,
54
+ 'db migrate': 0,
55
+ 'db backup': 1,
56
+ 'db restore': 2,
57
+ dev: 0,
58
+ 'dev infra up': 0,
59
+ 'dev infra down': 0,
60
+ doctor: 0,
61
+ 'payments sync': 0,
62
+ }
63
+
64
+ /** A parsed invocation ready to run, or the failure to report; parsing never throws and never runs a command. */
65
+ export type CliInvocationParse =
66
+ | { kind: 'cli-invocation-parsed'; invocation: CliCommandInvocation }
67
+ | { kind: 'cli-invocation-rejected'; failure: CliFailure }
68
+
69
+ /** argv split into command words and flags before any command is known; unknown flags are rejected here. */
70
+ type CliArgumentSplit =
71
+ | {
72
+ kind: 'cli-arguments-split'
73
+ commandWords: string[]
74
+ flagValues: Map<string, string>
75
+ presentBooleanFlagNames: Set<string>
76
+ givenFlagNames: Set<string>
77
+ }
78
+ | { kind: 'cli-arguments-rejected'; failure: CliFailure }
79
+
80
+ /**
81
+ * Turns argv plus the environment into one runnable invocation. Word-shaped mistakes are usage
82
+ * failures (exit 2); a well-formed command whose connection URL does not hold up is an operational
83
+ * failure (exit 1), which is why URL resolution happens here rather than inside a command handler.
84
+ */
85
+ export function parseCliInvocation(options: {
86
+ argv: readonly string[]
87
+ context: CliRuntimeContext
88
+ }): CliInvocationParse {
89
+ const split = splitCliArguments(options.argv)
90
+ if (split.kind === 'cli-arguments-rejected') {
91
+ return { kind: 'cli-invocation-rejected', failure: split.failure }
92
+ }
93
+
94
+ const match = matchCommandPath(split.commandWords)
95
+ if (match.kind === 'command-path-unknown') {
96
+ return rejectedInvocation(
97
+ cliUsageInvalidFailure(
98
+ `unknown command ${JSON.stringify(split.commandWords.join(' '))}; expected one of ${cliCommandPathSchema.options.join(', ')}`,
99
+ ),
100
+ )
101
+ }
102
+ if (match.kind === 'command-argument-count-wrong') {
103
+ return rejectedInvocation(
104
+ cliUsageInvalidFailure(
105
+ `${match.commandPath} takes ${commandArgumentCountByPath[match.commandPath]} argument(s), ${match.commandArguments.length} given`,
106
+ ),
107
+ )
108
+ }
109
+
110
+ const unknownFlagName = [...split.givenFlagNames].find(
111
+ (flagName) => !allowedFlagNamesByCommandPath[match.commandPath].includes(flagName),
112
+ )
113
+ if (unknownFlagName !== undefined) {
114
+ return rejectedInvocation(
115
+ cliUsageInvalidFailure(`unknown flag ${unknownFlagName} for hearthkit ${match.commandPath}`),
116
+ )
117
+ }
118
+
119
+ return buildCliInvocation({
120
+ commandPath: match.commandPath,
121
+ commandArguments: match.commandArguments,
122
+ flagValues: split.flagValues,
123
+ presentBooleanFlagNames: split.presentBooleanFlagNames,
124
+ context: options.context,
125
+ })
126
+ }
127
+
128
+ /** Wraps a failure as the rejected branch, so every early return below stays one line. */
129
+ function rejectedInvocation(failure: CliFailure): CliInvocationParse {
130
+ return { kind: 'cli-invocation-rejected', failure }
131
+ }
132
+
133
+ /** Reads a word the arity check already guaranteed; an empty string still fails its schema below. */
134
+ function commandArgumentAt(commandArguments: readonly string[], index: number): string {
135
+ return commandArguments[index] ?? ''
136
+ }
137
+
138
+ /**
139
+ * Separates flags from command words in one pass. Both --flag value and --flag=value are accepted;
140
+ * a lone dash-prefixed word that is in neither catalogue is an unknown flag, never a command word.
141
+ */
142
+ function splitCliArguments(argv: readonly string[]): CliArgumentSplit {
143
+ const commandWords: string[] = []
144
+ const flagValues = new Map<string, string>()
145
+ const presentBooleanFlagNames = new Set<string>()
146
+ const givenFlagNames = new Set<string>()
147
+
148
+ for (let index = 0; index < argv.length; index += 1) {
149
+ const token = argv[index] ?? ''
150
+ if (!token.startsWith('-')) {
151
+ commandWords.push(token)
152
+ continue
153
+ }
154
+
155
+ const equalsIndex = token.indexOf('=')
156
+ const flagName = equalsIndex === -1 ? token : token.slice(0, equalsIndex)
157
+ const inlineValue = equalsIndex === -1 ? undefined : token.slice(equalsIndex + 1)
158
+
159
+ if (isValueFlagName(flagName)) {
160
+ const flagValue = inlineValue ?? argv[index + 1]
161
+ if (inlineValue === undefined) {
162
+ index += 1
163
+ }
164
+ if (flagValue === undefined || flagValue === '') {
165
+ return {
166
+ kind: 'cli-arguments-rejected',
167
+ failure: cliUsageInvalidFailure(`${flagName} needs a value`),
168
+ }
169
+ }
170
+ flagValues.set(flagName, flagValue)
171
+ givenFlagNames.add(flagName)
172
+ continue
173
+ }
174
+
175
+ if (isBooleanFlagName(flagName)) {
176
+ if (inlineValue !== undefined) {
177
+ return {
178
+ kind: 'cli-arguments-rejected',
179
+ failure: cliUsageInvalidFailure(`${flagName} takes no value`),
180
+ }
181
+ }
182
+ presentBooleanFlagNames.add(flagName)
183
+ givenFlagNames.add(flagName)
184
+ continue
185
+ }
186
+
187
+ return {
188
+ kind: 'cli-arguments-rejected',
189
+ failure: cliUsageInvalidFailure(`unknown flag ${flagName}`),
190
+ }
191
+ }
192
+
193
+ return {
194
+ kind: 'cli-arguments-split',
195
+ commandWords,
196
+ flagValues,
197
+ presentBooleanFlagNames,
198
+ givenFlagNames,
199
+ }
200
+ }
201
+
202
+ /** True when the word is one of the flags that consumes the next argv word. */
203
+ function isValueFlagName(flagName: string): boolean {
204
+ return (valueFlagNames as readonly string[]).includes(flagName)
205
+ }
206
+
207
+ /** True when the word is one of the standalone flags. */
208
+ function isBooleanFlagName(flagName: string): boolean {
209
+ return (booleanFlagNames as readonly string[]).includes(flagName)
210
+ }
211
+
212
+ /** Which command the leading words name, and the words left over for it to consume. */
213
+ type CommandPathMatch =
214
+ | { kind: 'command-path-matched'; commandPath: CliCommandPath; commandArguments: string[] }
215
+ | {
216
+ kind: 'command-argument-count-wrong'
217
+ commandPath: CliCommandPath
218
+ commandArguments: string[]
219
+ }
220
+ | { kind: 'command-path-unknown' }
221
+
222
+ /**
223
+ * Matches the longest command path first, so `dev infra up` wins over `dev` and later phases can
224
+ * append longer paths to the enum without disturbing the ones already here.
225
+ */
226
+ function matchCommandPath(commandWords: readonly string[]): CommandPathMatch {
227
+ const commandPathsByLength = cliCommandPathSchema.options.toSorted(
228
+ (left, right) => right.split(' ').length - left.split(' ').length,
229
+ )
230
+
231
+ for (const commandPath of commandPathsByLength) {
232
+ const pathWords = commandPath.split(' ')
233
+ const isPrefix = pathWords.every((pathWord, index) => commandWords[index] === pathWord)
234
+ if (!isPrefix) {
235
+ continue
236
+ }
237
+ const commandArguments = commandWords.slice(pathWords.length)
238
+ if (commandArguments.length !== commandArgumentCountByPath[commandPath]) {
239
+ return { kind: 'command-argument-count-wrong', commandPath, commandArguments }
240
+ }
241
+ return { kind: 'command-path-matched', commandPath, commandArguments }
242
+ }
243
+
244
+ return { kind: 'command-path-unknown' }
245
+ }
246
+
247
+ /** Turns a matched command plus its flags into the invocation shape the handlers receive. */
248
+ function buildCliInvocation(options: {
249
+ commandPath: CliCommandPath
250
+ commandArguments: readonly string[]
251
+ flagValues: Map<string, string>
252
+ presentBooleanFlagNames: ReadonlySet<string>
253
+ context: CliRuntimeContext
254
+ }): CliInvocationParse {
255
+ const { commandPath, commandArguments, flagValues, context } = options
256
+
257
+ if (commandPath === 'dev' || commandPath === 'dev infra up' || commandPath === 'dev infra down') {
258
+ return { kind: 'cli-invocation-parsed', invocation: { commandPath } }
259
+ }
260
+
261
+ if (commandPath === 'doctor') {
262
+ return {
263
+ kind: 'cli-invocation-parsed',
264
+ invocation: { commandPath, jsonOutput: options.presentBooleanFlagNames.has('--json') },
265
+ }
266
+ }
267
+
268
+ if (commandPath === 'payments sync') {
269
+ const givenCatalogPath = flagValues.get('--catalog') ?? defaultPaymentsCatalogPath
270
+ return {
271
+ kind: 'cli-invocation-parsed',
272
+ invocation: {
273
+ commandPath,
274
+ catalogPath: isAbsolute(givenCatalogPath)
275
+ ? givenCatalogPath
276
+ : resolve(context.workingDirectoryPath, givenCatalogPath),
277
+ },
278
+ }
279
+ }
280
+
281
+ if (commandPath === 'db migrate') {
282
+ const databaseUrlCandidate =
283
+ flagValues.get('--database-url') ??
284
+ readEnvironmentVariableValue(context.environmentVariables, projectDatabaseUrlEnvVariableName)
285
+ if (databaseUrlCandidate === undefined) {
286
+ return rejectedInvocation(
287
+ databaseUrlMissingFailure(
288
+ 'is not set and --database-url was not given; hearthkit db migrate needs the project connection',
289
+ ),
290
+ )
291
+ }
292
+ const parsedDatabaseUrl = postgresConnectionStringSchema.safeParse(databaseUrlCandidate)
293
+ if (!parsedDatabaseUrl.success) {
294
+ return rejectedInvocation(
295
+ databaseUrlInvalidFailure(
296
+ `not a postgres:// or postgresql:// url (given: ${databaseUrlCandidate})`,
297
+ ),
298
+ )
299
+ }
300
+ return {
301
+ kind: 'cli-invocation-parsed',
302
+ invocation: {
303
+ commandPath,
304
+ databaseUrl: parsedDatabaseUrl.data,
305
+ migrationsFolderPath: flagValues.get('--migrations-folder') ?? defaultMigrationsFolderPath,
306
+ },
307
+ }
308
+ }
309
+
310
+ const parsedDatabaseName = projectDatabaseNameSchema.safeParse(
311
+ commandArgumentAt(commandArguments, 0),
312
+ )
313
+ if (!parsedDatabaseName.success) {
314
+ return rejectedInvocation(
315
+ cliUsageInvalidFailure(
316
+ `${commandPath} needs a lowercase snake_case database name of at most 63 characters (given: ${JSON.stringify(commandArgumentAt(commandArguments, 0))})`,
317
+ ),
318
+ )
319
+ }
320
+
321
+ const adminResolution = resolveAdminDatabaseUrl({
322
+ adminDatabaseUrlFlagValue: flagValues.get('--admin-database-url'),
323
+ environmentVariables: context.environmentVariables,
324
+ })
325
+ if (adminResolution.kind === 'admin-database-url-rejected') {
326
+ return rejectedInvocation(adminResolution.failure)
327
+ }
328
+
329
+ const projectDatabaseName = parsedDatabaseName.data
330
+ const adminDatabaseUrl = adminResolution.adminDatabaseUrl
331
+
332
+ if (commandPath === 'db create' || commandPath === 'db drop') {
333
+ return {
334
+ kind: 'cli-invocation-parsed',
335
+ invocation: { commandPath, projectDatabaseName, adminDatabaseUrl },
336
+ }
337
+ }
338
+
339
+ if (commandPath === 'db backup') {
340
+ return {
341
+ kind: 'cli-invocation-parsed',
342
+ invocation: {
343
+ commandPath,
344
+ projectDatabaseName,
345
+ adminDatabaseUrl,
346
+ backupFilePath:
347
+ flagValues.get('--backup-file') ??
348
+ buildDefaultBackupFilePath({
349
+ workingDirectoryPath: context.workingDirectoryPath,
350
+ projectDatabaseName,
351
+ }),
352
+ },
353
+ }
354
+ }
355
+
356
+ return {
357
+ kind: 'cli-invocation-parsed',
358
+ invocation: {
359
+ commandPath,
360
+ projectDatabaseName,
361
+ adminDatabaseUrl,
362
+ backupFilePath: commandArgumentAt(commandArguments, 1),
363
+ },
364
+ }
365
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Reads one variable from the CLI's environment. An empty string counts as unset, matching the rule
3
+ * @hearthkit/config applies at boot, so `HEARTHKIT_ADMIN_DATABASE_URL=` falls through to the default.
4
+ */
5
+ export function readEnvironmentVariableValue(
6
+ environmentVariables: Record<string, string | undefined>,
7
+ variableName: string,
8
+ ): string | undefined {
9
+ const value = environmentVariables[variableName]
10
+ if (value === undefined || value.trim() === '') {
11
+ return undefined
12
+ }
13
+ return value
14
+ }
@@ -0,0 +1,99 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { z } from 'zod'
4
+ import {
5
+ localInfraServiceNameSchema,
6
+ localInfraServicesByHearthkitPackage,
7
+ type HearthkitProjectName,
8
+ type LocalInfraServiceName,
9
+ } from './cli-contract.ts'
10
+ import type { CliRuntimeContext } from './cli-runtime-context.ts'
11
+ import { deriveHearthkitProjectName } from './derive-hearthkit-project-name.ts'
12
+
13
+ /** The only fields of a project's package.json this package looks at; anything else is ignored, not rejected. */
14
+ const projectManifestSchema = z.object({
15
+ name: z.string().optional(),
16
+ dependencies: z.record(z.string(), z.string()).optional(),
17
+ devDependencies: z.record(z.string(), z.string()).optional(),
18
+ })
19
+
20
+ /** What the working directory's package.json said, or why it could not be used as a manifest. */
21
+ export type ProjectInfraManifestOutcome =
22
+ | {
23
+ kind: 'project-infra-manifest-read'
24
+ manifestPath: string
25
+ hearthkitProjectName: HearthkitProjectName
26
+ infraServices: LocalInfraServiceName[]
27
+ }
28
+ | { kind: 'project-infra-manifest-unreadable'; manifestPath: string; detail: string }
29
+
30
+ /**
31
+ * Reads ./package.json and answers which local infra services the installed hearthkit packages ask
32
+ * for. A manifest that is absent, unreadable, or not an object is one outcome: there is nothing to
33
+ * derive services from either way, and the caller reports it as project-manifest-missing.
34
+ */
35
+ export async function readProjectInfraManifest(
36
+ context: CliRuntimeContext,
37
+ ): Promise<ProjectInfraManifestOutcome> {
38
+ const manifestPath = join(context.workingDirectoryPath, 'package.json')
39
+
40
+ let manifestFileContent: string
41
+ try {
42
+ manifestFileContent = await readFile(manifestPath, 'utf8')
43
+ } catch (error) {
44
+ return {
45
+ kind: 'project-infra-manifest-unreadable',
46
+ manifestPath,
47
+ detail: `could not be read (${error instanceof Error ? error.message : String(error)})`,
48
+ }
49
+ }
50
+
51
+ let manifestValue: unknown
52
+ try {
53
+ manifestValue = JSON.parse(manifestFileContent)
54
+ } catch (error) {
55
+ return {
56
+ kind: 'project-infra-manifest-unreadable',
57
+ manifestPath,
58
+ detail: `is not valid json (${error instanceof Error ? error.message : String(error)})`,
59
+ }
60
+ }
61
+
62
+ const parsedManifest = projectManifestSchema.safeParse(manifestValue)
63
+ if (!parsedManifest.success) {
64
+ return {
65
+ kind: 'project-infra-manifest-unreadable',
66
+ manifestPath,
67
+ detail: 'is not a package manifest with name and dependency fields',
68
+ }
69
+ }
70
+
71
+ return {
72
+ kind: 'project-infra-manifest-read',
73
+ manifestPath,
74
+ hearthkitProjectName: deriveHearthkitProjectName(parsedManifest.data.name),
75
+ infraServices: readInfraServicesFromDependencies({
76
+ ...parsedManifest.data.dependencies,
77
+ ...parsedManifest.data.devDependencies,
78
+ }),
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Maps installed hearthkit packages to the services they need. The map is one-to-many, so two
84
+ * packages can name the same service and the Set collapses it to one. The returned order comes from
85
+ * localInfraServiceNameSchema.options and never from the map or the manifest, which is what keeps one
86
+ * manifest producing one compose file.
87
+ */
88
+ function readInfraServicesFromDependencies(
89
+ dependencyVersionByName: Record<string, string>,
90
+ ): LocalInfraServiceName[] {
91
+ const neededServiceNames = new Set<LocalInfraServiceName>(
92
+ Object.entries(localInfraServicesByHearthkitPackage)
93
+ .filter(([packageName]) => dependencyVersionByName[packageName] !== undefined)
94
+ .flatMap(([, serviceNames]) => serviceNames),
95
+ )
96
+ return localInfraServiceNameSchema.options.filter((serviceName) =>
97
+ neededServiceNames.has(serviceName),
98
+ )
99
+ }
@@ -0,0 +1,139 @@
1
+ import {
2
+ cliDbBackupCompleteLinePrefix,
3
+ cliDbCreateCredentialsWarningPrefix,
4
+ cliDbDropCompleteLinePrefix,
5
+ cliDbMigrateCompleteLinePrefix,
6
+ cliDbRestoreCompleteLinePrefix,
7
+ cliDevInfraDownCompleteLinePrefix,
8
+ cliDevInfraUpCompleteLinePrefix,
9
+ cliPaymentsSyncCompleteLinePrefix,
10
+ stripeSecretKeyEnvVariableName,
11
+ type CliCommandInvocation,
12
+ type CliCommandResult,
13
+ type CliExitCode,
14
+ type DoctorCheckResult,
15
+ } from './cli-contract.ts'
16
+ import { writeStandardErrorLine, writeStandardOutputLine } from './cli-output-streams.ts'
17
+ import { formatDoctorCheckTable, formatDoctorJsonReport } from './format-doctor-report.ts'
18
+
19
+ /** Exit code for a command that did what it was asked. */
20
+ const successExitCode = 0
21
+
22
+ /** Exit code for a command that was understood but could not be carried out. */
23
+ const operationalFailureExitCode = 1
24
+
25
+ /** Exit code for a command line that could not be understood at all. */
26
+ const usageFailureExitCode = 2
27
+
28
+ /**
29
+ * Writes everything one command run prints and returns the code the process should exit with. All
30
+ * stream writes live here, so the single stdout line per command and the stderr-only failure rule
31
+ * are enforced in one place rather than in every handler.
32
+ */
33
+ export function reportCliOutcome(options: {
34
+ result: CliCommandResult
35
+ invocation: CliCommandInvocation | undefined
36
+ }): CliExitCode {
37
+ const { result, invocation } = options
38
+
39
+ switch (result.kind) {
40
+ case 'db-create-command-succeeded':
41
+ writeStandardErrorLine(
42
+ `${cliDbCreateCredentialsWarningPrefix} the connection string below holds a generated password shown this once and never stored; copy it into your .env.local now`,
43
+ )
44
+ writeStandardOutputLine(result.connectionString)
45
+ return successExitCode
46
+
47
+ case 'db-drop-command-succeeded':
48
+ writeStandardOutputLine(
49
+ `${cliDbDropCompleteLinePrefix} ${result.projectDatabaseName} and its role are gone`,
50
+ )
51
+ return successExitCode
52
+
53
+ case 'db-migrate-command-succeeded':
54
+ writeStandardOutputLine(
55
+ `${cliDbMigrateCompleteLinePrefix} applied ${result.appliedMigrationCount} migration(s)`,
56
+ )
57
+ return successExitCode
58
+
59
+ case 'db-backup-command-succeeded':
60
+ writeStandardOutputLine(
61
+ `${cliDbBackupCompleteLinePrefix} ${result.projectDatabaseName} written to ${result.backupFilePath} (${result.backupByteCount} bytes)`,
62
+ )
63
+ return successExitCode
64
+
65
+ case 'db-restore-command-succeeded':
66
+ writeStandardOutputLine(
67
+ `${cliDbRestoreCompleteLinePrefix} ${result.projectDatabaseName} restored from ${result.backupFilePath}`,
68
+ )
69
+ return successExitCode
70
+
71
+ case 'dev-infra-up-succeeded':
72
+ writeStandardOutputLine(
73
+ `${cliDevInfraUpCompleteLinePrefix} ${
74
+ result.startedInfraServices.length === 0
75
+ ? 'no local infra services needed'
76
+ : `started ${result.startedInfraServices.join(', ')}`
77
+ }`,
78
+ )
79
+ return successExitCode
80
+
81
+ case 'dev-infra-down-succeeded':
82
+ writeStandardOutputLine(`${cliDevInfraDownCompleteLinePrefix} local infra services stopped`)
83
+ return successExitCode
84
+
85
+ // next dev already streamed its own stdio, so there is nothing left to print for it.
86
+ case 'dev-command-exited':
87
+ return result.nextDevExitCode
88
+
89
+ case 'doctor-report':
90
+ printDoctorReport(result.checks, true, readDoctorJsonOutput(invocation))
91
+ return successExitCode
92
+
93
+ case 'doctor-checks-failed':
94
+ printDoctorReport(result.checks, false, readDoctorJsonOutput(invocation))
95
+ writeStandardErrorLine(result.message)
96
+ return operationalFailureExitCode
97
+
98
+ case 'payments-sync-command-succeeded':
99
+ writeStandardOutputLine(
100
+ `${cliPaymentsSyncCompleteLinePrefix} ${String(result.createdPriceCount)} created, ${String(result.replacedPriceCount)} replaced, ${String(result.unchangedPriceCount)} unchanged from ${result.catalogPath}`,
101
+ )
102
+ return successExitCode
103
+
104
+ case 'cli-usage-invalid':
105
+ writeStandardErrorLine(result.message)
106
+ return usageFailureExitCode
107
+
108
+ // The payments message names the field it rejected, never the variable, so the guidance above it
109
+ // does: an unset key is the only way the env object can be wrong here.
110
+ case 'cli-payments-sync-failed':
111
+ writeStandardErrorLine(
112
+ `hearthkit payments sync needs a Stripe test-mode key in ${stripeSecretKeyEnvVariableName}; the message below is @hearthkit/payments' own`,
113
+ )
114
+ writeStandardErrorLine(result.message)
115
+ return operationalFailureExitCode
116
+
117
+ default:
118
+ writeStandardErrorLine(result.message)
119
+ return operationalFailureExitCode
120
+ }
121
+ }
122
+
123
+ /** Whether doctor was asked for JSON; any other command's invocation means the question does not apply. */
124
+ function readDoctorJsonOutput(invocation: CliCommandInvocation | undefined): boolean {
125
+ return invocation !== undefined && invocation.commandPath === 'doctor' && invocation.jsonOutput
126
+ }
127
+
128
+ /** Prints the report on stdout in the shape the operator asked for, whether or not every check passed. */
129
+ function printDoctorReport(
130
+ checks: DoctorCheckResult[],
131
+ allDoctorChecksPassed: boolean,
132
+ jsonOutput: boolean,
133
+ ): void {
134
+ writeStandardOutputLine(
135
+ jsonOutput
136
+ ? formatDoctorJsonReport({ checks, allDoctorChecksPassed })
137
+ : formatDoctorCheckTable(checks),
138
+ )
139
+ }
@@ -0,0 +1,72 @@
1
+ import { postgresConnectionStringSchema, type PostgresConnectionString } from '@hearthkit/db'
2
+ import {
3
+ adminDatabaseUrlEnvVariableName,
4
+ defaultLocalAdminDatabaseUrl,
5
+ type CliFailure,
6
+ } from './cli-contract.ts'
7
+ import { adminDatabaseUrlInvalidFailure } from './cli-failure-results.ts'
8
+ import { readEnvironmentVariableValue } from './read-environment-variable-value.ts'
9
+
10
+ /** Where the admin connection came from, kept on the resolution so a failure can name the source the operator must fix. */
11
+ export type AdminDatabaseUrlSource = 'flag' | 'environment' | 'default'
12
+
13
+ /** A resolved admin connection, or the failure to report when the winning candidate is not a postgres(ql) URL. */
14
+ export type AdminDatabaseUrlResolution =
15
+ | {
16
+ kind: 'admin-database-url-resolved'
17
+ adminDatabaseUrl: PostgresConnectionString
18
+ adminDatabaseUrlSource: AdminDatabaseUrlSource
19
+ }
20
+ | {
21
+ kind: 'admin-database-url-rejected'
22
+ failure: Extract<CliFailure, { kind: 'admin-database-url-invalid' }>
23
+ }
24
+
25
+ /**
26
+ * Applies the contract's precedence: --admin-database-url, then HEARTHKIT_ADMIN_DATABASE_URL, then
27
+ * the local compose default. Only the winning candidate is validated, so a broken environment
28
+ * variable cannot spoil a run that passed the flag.
29
+ */
30
+ export function resolveAdminDatabaseUrl(options: {
31
+ adminDatabaseUrlFlagValue: string | undefined
32
+ environmentVariables: Record<string, string | undefined>
33
+ }): AdminDatabaseUrlResolution {
34
+ const environmentValue = readEnvironmentVariableValue(
35
+ options.environmentVariables,
36
+ adminDatabaseUrlEnvVariableName,
37
+ )
38
+
39
+ const candidate =
40
+ options.adminDatabaseUrlFlagValue !== undefined
41
+ ? { value: options.adminDatabaseUrlFlagValue, source: 'flag' as const }
42
+ : environmentValue !== undefined
43
+ ? { value: environmentValue, source: 'environment' as const }
44
+ : { value: defaultLocalAdminDatabaseUrl as string, source: 'default' as const }
45
+
46
+ const parsed = postgresConnectionStringSchema.safeParse(candidate.value)
47
+ if (!parsed.success) {
48
+ return {
49
+ kind: 'admin-database-url-rejected',
50
+ failure: adminDatabaseUrlInvalidFailure(
51
+ `${describeAdminDatabaseUrlSource(candidate.source)} is not a postgres:// or postgresql:// url (given: ${candidate.value})`,
52
+ ),
53
+ }
54
+ }
55
+
56
+ return {
57
+ kind: 'admin-database-url-resolved',
58
+ adminDatabaseUrl: parsed.data,
59
+ adminDatabaseUrlSource: candidate.source,
60
+ }
61
+ }
62
+
63
+ /** Names the losing source in operator words, so the message says which knob to turn. */
64
+ function describeAdminDatabaseUrlSource(source: AdminDatabaseUrlSource): string {
65
+ if (source === 'flag') {
66
+ return '--admin-database-url'
67
+ }
68
+ if (source === 'environment') {
69
+ return adminDatabaseUrlEnvVariableName
70
+ }
71
+ return 'the default local admin url'
72
+ }