@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
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hearthkit/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The hearthkit binary: db lifecycle, dev infra, and doctor commands over @hearthkit/db",
|
|
5
|
+
"homepage": "https://github.com/chrisdevelops/hearthkit/tree/main/packages/cli",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/chrisdevelops/hearthkit.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"default": "./src/index.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"hearthkit": "./src/hearthkit-bin-entry.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": "24.20.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"zod": "4.4.3",
|
|
32
|
+
"@hearthkit/config": "0.1.0",
|
|
33
|
+
"@hearthkit/payments": "0.1.0",
|
|
34
|
+
"@hearthkit/db": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "24.13.3",
|
|
38
|
+
"typescript": "7.0.2",
|
|
39
|
+
"vitest": "4.1.11"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dbFailureSchema,
|
|
3
|
+
postgresConnectionStringSchema,
|
|
4
|
+
projectDatabaseNameSchema,
|
|
5
|
+
} from '@hearthkit/db'
|
|
6
|
+
import {
|
|
7
|
+
paymentsFailureSchema,
|
|
8
|
+
paymentsSyncedPriceSchema,
|
|
9
|
+
} from '@hearthkit/payments/payments-contract'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
|
|
12
|
+
/** Unique literal prefix of the error message for an unknown command, unknown flag, or schema-invalid argument; exit code 2. */
|
|
13
|
+
export const cliUsageErrorPrefix = 'hearthkit cli usage:'
|
|
14
|
+
|
|
15
|
+
/** Unique literal prefix of the error message when the resolved admin database URL is not a postgres(ql) URL. */
|
|
16
|
+
export const cliAdminUrlInvalidErrorPrefix = 'hearthkit cli admin url invalid:'
|
|
17
|
+
|
|
18
|
+
/** Unique literal prefix of the error message when db migrate has neither a --database-url flag nor DATABASE_URL set; the message names the literal DATABASE_URL variable after the prefix. */
|
|
19
|
+
export const cliDatabaseUrlMissingErrorPrefix = 'hearthkit cli database url missing:'
|
|
20
|
+
|
|
21
|
+
/** Unique literal prefix of the error message when the provided project database URL is not a postgres(ql) URL. */
|
|
22
|
+
export const cliDatabaseUrlInvalidErrorPrefix = 'hearthkit cli database url invalid:'
|
|
23
|
+
|
|
24
|
+
/** Unique literal prefix of the error message when the docker CLI is missing from PATH or the daemon is not running. */
|
|
25
|
+
export const cliDockerUnavailableErrorPrefix = 'hearthkit cli docker unavailable:'
|
|
26
|
+
|
|
27
|
+
/** Unique literal prefix of the error message when a docker compose invocation exits nonzero. */
|
|
28
|
+
export const cliInfraComposeFailedErrorPrefix = 'hearthkit cli infra compose failed:'
|
|
29
|
+
|
|
30
|
+
/** Unique literal prefix of the error message when compose generation is needed but the working directory has no package.json. */
|
|
31
|
+
export const cliProjectManifestMissingErrorPrefix = 'hearthkit cli project manifest missing:'
|
|
32
|
+
|
|
33
|
+
/** Unique literal prefix of the error message when the generated docker-compose.yml cannot be written. */
|
|
34
|
+
export const cliComposeFileUnwritableErrorPrefix = 'hearthkit cli compose file unwritable:'
|
|
35
|
+
|
|
36
|
+
/** Unique literal prefix of the error message when hearthkit dev finds no runnable next binary in the project. */
|
|
37
|
+
export const cliNextDevUnavailableErrorPrefix = 'hearthkit cli next dev unavailable:'
|
|
38
|
+
|
|
39
|
+
/** Unique literal prefix of the stderr line printed when at least one doctor check did not pass; exit code 1. */
|
|
40
|
+
export const cliDoctorFailedErrorPrefix = 'hearthkit doctor failed:'
|
|
41
|
+
|
|
42
|
+
/** Unique literal prefix of the error message when the payments catalog file (--catalog or ./payments-catalog.ts) does not exist; the message carries the resolved path. */
|
|
43
|
+
export const cliPaymentsCatalogNotFoundErrorPrefix = 'hearthkit cli payments catalog not found:'
|
|
44
|
+
|
|
45
|
+
/** Unique literal prefix of the error message when the payments catalog file exists but cannot be imported or exports no catalog under the expected name. */
|
|
46
|
+
export const cliPaymentsCatalogUnloadableErrorPrefix = 'hearthkit cli payments catalog unloadable:'
|
|
47
|
+
|
|
48
|
+
/** Unique literal prefix of the error message when @hearthkit/payments returned a failure while building the client or syncing; the payments message follows the prefix. */
|
|
49
|
+
export const cliPaymentsSyncFailedErrorPrefix = 'hearthkit cli payments sync failed:'
|
|
50
|
+
|
|
51
|
+
/** Unique literal prefix of the one-time stderr warning printed by hearthkit db create that the credentials are shown once and never persisted. */
|
|
52
|
+
export const cliDbCreateCredentialsWarningPrefix = 'hearthkit db create warning:'
|
|
53
|
+
|
|
54
|
+
/** Unique literal prefix of the single stdout success line of hearthkit db drop. */
|
|
55
|
+
export const cliDbDropCompleteLinePrefix = 'hearthkit db drop complete:'
|
|
56
|
+
|
|
57
|
+
/** Unique literal prefix of the single stdout success line of hearthkit db migrate. */
|
|
58
|
+
export const cliDbMigrateCompleteLinePrefix = 'hearthkit db migrate complete:'
|
|
59
|
+
|
|
60
|
+
/** Unique literal prefix of the single stdout success line of hearthkit db backup. */
|
|
61
|
+
export const cliDbBackupCompleteLinePrefix = 'hearthkit db backup complete:'
|
|
62
|
+
|
|
63
|
+
/** Unique literal prefix of the single stdout success line of hearthkit db restore. */
|
|
64
|
+
export const cliDbRestoreCompleteLinePrefix = 'hearthkit db restore complete:'
|
|
65
|
+
|
|
66
|
+
/** Unique literal prefix of the single stdout success line of hearthkit dev infra up. */
|
|
67
|
+
export const cliDevInfraUpCompleteLinePrefix = 'hearthkit dev infra up complete:'
|
|
68
|
+
|
|
69
|
+
/** Unique literal prefix of the single stdout success line of hearthkit dev infra down. */
|
|
70
|
+
export const cliDevInfraDownCompleteLinePrefix = 'hearthkit dev infra down complete:'
|
|
71
|
+
|
|
72
|
+
/** Unique literal prefix of the single stdout success line of hearthkit payments sync; the line carries the created, replaced and unchanged price counts. */
|
|
73
|
+
export const cliPaymentsSyncCompleteLinePrefix = 'hearthkit payments sync complete:'
|
|
74
|
+
|
|
75
|
+
/** Name of the env variable hearthkit payments sync reads for the Stripe key; the same variable @hearthkit/payments declares, read with the empty-string-is-unset rule. */
|
|
76
|
+
export const stripeSecretKeyEnvVariableName = 'STRIPE_SECRET_KEY'
|
|
77
|
+
|
|
78
|
+
/** Default catalog module path, relative to cwd, when hearthkit payments sync is run without --catalog; the file templates/app ships. */
|
|
79
|
+
export const defaultPaymentsCatalogPath = './payments-catalog.ts'
|
|
80
|
+
|
|
81
|
+
/** Named export hearthkit payments sync reads off the catalog module first, matching templates/app/payments-catalog.ts; a default export is the fallback when this name is absent. */
|
|
82
|
+
export const paymentsCatalogModuleExportName = 'appPaymentsCatalog'
|
|
83
|
+
|
|
84
|
+
/** Name of the operator env variable holding the admin connection; overridden by --admin-database-url, overrides the local default. */
|
|
85
|
+
export const adminDatabaseUrlEnvVariableName = 'HEARTHKIT_ADMIN_DATABASE_URL'
|
|
86
|
+
|
|
87
|
+
/** Fallback admin connection when neither flag nor env provides one; matches the repo and generated compose Postgres service. */
|
|
88
|
+
export const defaultLocalAdminDatabaseUrl = postgresConnectionStringSchema.parse(
|
|
89
|
+
'postgresql://hearthkit:hearthkit@localhost:5432/hearthkit',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
/** Default drizzle-kit output folder used by hearthkit db migrate when --migrations-folder is not given. */
|
|
93
|
+
export const defaultMigrationsFolderPath = './drizzle'
|
|
94
|
+
|
|
95
|
+
/** Default directory (relative to cwd) where hearthkit db backup writes archives when --backup-file is not given. */
|
|
96
|
+
export const defaultBackupDirectoryPath = './backups'
|
|
97
|
+
|
|
98
|
+
/** Env schema fragment this package owns; operator-time variable read by the CLI itself, never composed into app boot config. */
|
|
99
|
+
export const cliEnvSchemaFragment = z.object({
|
|
100
|
+
HEARTHKIT_ADMIN_DATABASE_URL: postgresConnectionStringSchema.optional(),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** Hearthkit project name; lowercase kebab-case used for compose project, container, and volume names, distinct from ProjectDatabaseName. */
|
|
104
|
+
export const hearthkitProjectNameSchema = z
|
|
105
|
+
.string()
|
|
106
|
+
.regex(/^[a-z][a-z0-9-]*$/)
|
|
107
|
+
.max(63)
|
|
108
|
+
.brand<'HearthkitProjectName'>()
|
|
109
|
+
|
|
110
|
+
/** Branded hearthkit project name; derived from package.json name by stripping the scope and sanitizing to kebab-case. */
|
|
111
|
+
export type HearthkitProjectName = z.infer<typeof hearthkitProjectNameSchema>
|
|
112
|
+
|
|
113
|
+
/** The three local infra services from plan section 6; the only values infraServices accepts, the only names startedInfraServices reports, and the fixed order derived services are emitted in, which is never the key order of localInfraServicesByHearthkitPackage. */
|
|
114
|
+
export const localInfraServiceNameSchema = z.enum(['postgres', 'minio', 'mailpit'])
|
|
115
|
+
|
|
116
|
+
/** Local infra service name; postgres for db and auth, minio for storage, mailpit for email and auth. */
|
|
117
|
+
export type LocalInfraServiceName = z.infer<typeof localInfraServiceNameSchema>
|
|
118
|
+
|
|
119
|
+
/** Pinned container image per selectable local infra service; the one place to bump these three (the bucket init container is pinned by localStorageBucketInitImage). */
|
|
120
|
+
export const localInfraServiceImageByName = {
|
|
121
|
+
postgres: 'postgres:17',
|
|
122
|
+
minio: 'minio/minio:RELEASE.2025-09-07T16-13-09Z',
|
|
123
|
+
mailpit: 'axllent/mailpit:v1.31',
|
|
124
|
+
} as const satisfies Record<LocalInfraServiceName, string>
|
|
125
|
+
|
|
126
|
+
/** Which local infra services each hearthkit package pulls in when dev infra up derives services from package.json; the derived set is deduplicated and emitted in localInfraServiceNameSchema option order. */
|
|
127
|
+
export const localInfraServicesByHearthkitPackage = {
|
|
128
|
+
'@hearthkit/db': ['postgres'],
|
|
129
|
+
'@hearthkit/storage': ['minio'],
|
|
130
|
+
'@hearthkit/email': ['mailpit'],
|
|
131
|
+
'@hearthkit/auth': ['postgres', 'mailpit'],
|
|
132
|
+
} as const satisfies Record<string, readonly LocalInfraServiceName[]>
|
|
133
|
+
|
|
134
|
+
/** Compose service key of the container that creates the local storage bucket and then stays running on purpose, because docker compose up --wait exits 1 when a service it started has exited; deliberately not a LocalInfraServiceName because no project selects it. */
|
|
135
|
+
export const localStorageBucketInitServiceName = 'minio-init'
|
|
136
|
+
|
|
137
|
+
/** Pinned image of the bucket init container; the one place to bump it, kept out of localInfraServiceImageByName because that map is keyed by selectable services. */
|
|
138
|
+
export const localStorageBucketInitImage = 'minio/mc:RELEASE.2025-08-13T08-35-41Z'
|
|
139
|
+
|
|
140
|
+
/** Local development bucket name; the S3 and R2 intersection, so the same string is valid against either and against MinIO. */
|
|
141
|
+
export const localStorageBucketNameSchema = z
|
|
142
|
+
.string()
|
|
143
|
+
.min(3)
|
|
144
|
+
.max(63)
|
|
145
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/)
|
|
146
|
+
.brand<'LocalStorageBucketName'>()
|
|
147
|
+
|
|
148
|
+
/** Branded local storage bucket name; what dev infra up creates in MinIO and what STORAGE_BUCKET holds during local development. */
|
|
149
|
+
export type LocalStorageBucketName = z.infer<typeof localStorageBucketNameSchema>
|
|
150
|
+
|
|
151
|
+
/** Signature of deriveLocalStorageBucketName: pure and total, truncating the project name so every valid project name yields a valid bucket name. */
|
|
152
|
+
export type DeriveLocalStorageBucketName = (
|
|
153
|
+
hearthkitProjectName: HearthkitProjectName,
|
|
154
|
+
) => LocalStorageBucketName
|
|
155
|
+
|
|
156
|
+
/** Options for generateLocalInfraCompose; infraServices must name at least one service, duplicates are a caller bug, and the bucket name is derived from hearthkitProjectName rather than passed in. */
|
|
157
|
+
export const generateLocalInfraComposeOptionsSchema = z.object({
|
|
158
|
+
hearthkitProjectName: hearthkitProjectNameSchema,
|
|
159
|
+
infraServices: z.array(localInfraServiceNameSchema).min(1),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
/** Options type for generateLocalInfraCompose. */
|
|
163
|
+
export type GenerateLocalInfraComposeOptions = z.infer<
|
|
164
|
+
typeof generateLocalInfraComposeOptionsSchema
|
|
165
|
+
>
|
|
166
|
+
|
|
167
|
+
/** Signature of generateLocalInfraCompose: pure and deterministic, returns compose YAML using the localInfraServiceImageByName and localStorageBucketInitImage pins. */
|
|
168
|
+
export type GenerateLocalInfraCompose = (options: GenerateLocalInfraComposeOptions) => string
|
|
169
|
+
|
|
170
|
+
/** Every command path the CLI dispatches; later phases append infra apply and vps bootstrap additively. */
|
|
171
|
+
export const cliCommandPathSchema = z.enum([
|
|
172
|
+
'db create',
|
|
173
|
+
'db drop',
|
|
174
|
+
'db migrate',
|
|
175
|
+
'db backup',
|
|
176
|
+
'db restore',
|
|
177
|
+
'dev',
|
|
178
|
+
'dev infra up',
|
|
179
|
+
'dev infra down',
|
|
180
|
+
'doctor',
|
|
181
|
+
'payments sync',
|
|
182
|
+
])
|
|
183
|
+
|
|
184
|
+
/** Command path union; unknown paths are a cli-usage-invalid failure, never a silent no-op. */
|
|
185
|
+
export type CliCommandPath = z.infer<typeof cliCommandPathSchema>
|
|
186
|
+
|
|
187
|
+
/** A parsed invocation after flag and env resolution; admin and database URLs are already resolved per the contract's precedence, and catalogPath is absolute. */
|
|
188
|
+
export const cliCommandInvocationSchema = z.discriminatedUnion('commandPath', [
|
|
189
|
+
z.object({
|
|
190
|
+
commandPath: z.literal('db create'),
|
|
191
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
192
|
+
adminDatabaseUrl: postgresConnectionStringSchema,
|
|
193
|
+
}),
|
|
194
|
+
z.object({
|
|
195
|
+
commandPath: z.literal('db drop'),
|
|
196
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
197
|
+
adminDatabaseUrl: postgresConnectionStringSchema,
|
|
198
|
+
}),
|
|
199
|
+
z.object({
|
|
200
|
+
commandPath: z.literal('db migrate'),
|
|
201
|
+
databaseUrl: postgresConnectionStringSchema,
|
|
202
|
+
migrationsFolderPath: z.string().min(1),
|
|
203
|
+
}),
|
|
204
|
+
z.object({
|
|
205
|
+
commandPath: z.literal('db backup'),
|
|
206
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
207
|
+
adminDatabaseUrl: postgresConnectionStringSchema,
|
|
208
|
+
backupFilePath: z.string().min(1),
|
|
209
|
+
}),
|
|
210
|
+
z.object({
|
|
211
|
+
commandPath: z.literal('db restore'),
|
|
212
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
213
|
+
adminDatabaseUrl: postgresConnectionStringSchema,
|
|
214
|
+
backupFilePath: z.string().min(1),
|
|
215
|
+
}),
|
|
216
|
+
z.object({ commandPath: z.literal('dev') }),
|
|
217
|
+
z.object({ commandPath: z.literal('dev infra up') }),
|
|
218
|
+
z.object({ commandPath: z.literal('dev infra down') }),
|
|
219
|
+
z.object({ commandPath: z.literal('doctor'), jsonOutput: z.boolean() }),
|
|
220
|
+
z.object({
|
|
221
|
+
commandPath: z.literal('payments sync'),
|
|
222
|
+
catalogPath: z.string().min(1),
|
|
223
|
+
}),
|
|
224
|
+
])
|
|
225
|
+
|
|
226
|
+
/** Parsed invocation union; the shape command handlers receive after resolution succeeds. */
|
|
227
|
+
export type CliCommandInvocation = z.infer<typeof cliCommandInvocationSchema>
|
|
228
|
+
|
|
229
|
+
/** The eight doctor check names; a check may be skipped only when its prerequisite check failed. */
|
|
230
|
+
export const doctorCheckNameSchema = z.enum([
|
|
231
|
+
'node-version-supported',
|
|
232
|
+
'pnpm-command-available',
|
|
233
|
+
'docker-cli-available',
|
|
234
|
+
'docker-daemon-running',
|
|
235
|
+
'docker-compose-plugin-available',
|
|
236
|
+
'postgres-client-tools-version',
|
|
237
|
+
'admin-database-reachable',
|
|
238
|
+
'cli-env-variables-valid',
|
|
239
|
+
])
|
|
240
|
+
|
|
241
|
+
/** Doctor check name union; stable identifiers gates and --json consumers key on. */
|
|
242
|
+
export type DoctorCheckName = z.infer<typeof doctorCheckNameSchema>
|
|
243
|
+
|
|
244
|
+
/** One doctor check outcome; detail is a single human-readable line (found version, error summary, or skip reason). */
|
|
245
|
+
export const doctorCheckResultSchema = z.object({
|
|
246
|
+
checkName: doctorCheckNameSchema,
|
|
247
|
+
status: z.enum(['pass', 'fail', 'skip']),
|
|
248
|
+
detail: z.string(),
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
/** Doctor check result; skip counts as not-passed for the exit code. */
|
|
252
|
+
export type DoctorCheckResult = z.infer<typeof doctorCheckResultSchema>
|
|
253
|
+
|
|
254
|
+
/** Exact object doctor --json prints as JSON on stdout, on success and on doctor-checks-failed alike (then allDoctorChecksPassed is false). */
|
|
255
|
+
export const doctorJsonReportSchema = z.object({
|
|
256
|
+
checks: z.array(doctorCheckResultSchema).min(1),
|
|
257
|
+
allDoctorChecksPassed: z.boolean(),
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/** Doctor --json stdout envelope type; the only machine-readable doctor output shape. */
|
|
261
|
+
export type DoctorJsonReport = z.infer<typeof doctorJsonReportSchema>
|
|
262
|
+
|
|
263
|
+
/** Every way a CLI command can fail; each variant's message starts with its unique prefix and is the last stderr line. */
|
|
264
|
+
export const cliFailureSchema = z.discriminatedUnion('kind', [
|
|
265
|
+
z.object({
|
|
266
|
+
kind: z.literal('cli-usage-invalid'),
|
|
267
|
+
message: z.string().startsWith(cliUsageErrorPrefix),
|
|
268
|
+
}),
|
|
269
|
+
z.object({
|
|
270
|
+
kind: z.literal('admin-database-url-invalid'),
|
|
271
|
+
message: z.string().startsWith(cliAdminUrlInvalidErrorPrefix),
|
|
272
|
+
}),
|
|
273
|
+
z.object({
|
|
274
|
+
kind: z.literal('database-url-missing'),
|
|
275
|
+
message: z.string().startsWith(cliDatabaseUrlMissingErrorPrefix),
|
|
276
|
+
}),
|
|
277
|
+
z.object({
|
|
278
|
+
kind: z.literal('database-url-invalid'),
|
|
279
|
+
message: z.string().startsWith(cliDatabaseUrlInvalidErrorPrefix),
|
|
280
|
+
}),
|
|
281
|
+
z.object({
|
|
282
|
+
kind: z.literal('db-command-failed'),
|
|
283
|
+
dbFailure: dbFailureSchema,
|
|
284
|
+
message: z.string().startsWith('hearthkit db '),
|
|
285
|
+
}),
|
|
286
|
+
z.object({
|
|
287
|
+
kind: z.literal('docker-unavailable'),
|
|
288
|
+
message: z.string().startsWith(cliDockerUnavailableErrorPrefix),
|
|
289
|
+
}),
|
|
290
|
+
z.object({
|
|
291
|
+
kind: z.literal('infra-compose-failed'),
|
|
292
|
+
composeExitCode: z.number().int(),
|
|
293
|
+
composeStderrExcerpt: z.string(),
|
|
294
|
+
message: z.string().startsWith(cliInfraComposeFailedErrorPrefix),
|
|
295
|
+
}),
|
|
296
|
+
z.object({
|
|
297
|
+
kind: z.literal('project-manifest-missing'),
|
|
298
|
+
manifestPath: z.string().min(1),
|
|
299
|
+
message: z.string().startsWith(cliProjectManifestMissingErrorPrefix),
|
|
300
|
+
}),
|
|
301
|
+
z.object({
|
|
302
|
+
kind: z.literal('compose-file-unwritable'),
|
|
303
|
+
composeFilePath: z.string().min(1),
|
|
304
|
+
message: z.string().startsWith(cliComposeFileUnwritableErrorPrefix),
|
|
305
|
+
}),
|
|
306
|
+
z.object({
|
|
307
|
+
kind: z.literal('next-dev-unavailable'),
|
|
308
|
+
message: z.string().startsWith(cliNextDevUnavailableErrorPrefix),
|
|
309
|
+
}),
|
|
310
|
+
z.object({
|
|
311
|
+
kind: z.literal('doctor-checks-failed'),
|
|
312
|
+
checks: z.array(doctorCheckResultSchema).min(1),
|
|
313
|
+
failedCheckNames: z.array(doctorCheckNameSchema).min(1),
|
|
314
|
+
message: z.string().startsWith(cliDoctorFailedErrorPrefix),
|
|
315
|
+
}),
|
|
316
|
+
z.object({
|
|
317
|
+
kind: z.literal('cli-payments-catalog-not-found'),
|
|
318
|
+
catalogPath: z.string().min(1),
|
|
319
|
+
message: z.string().startsWith(cliPaymentsCatalogNotFoundErrorPrefix),
|
|
320
|
+
}),
|
|
321
|
+
z.object({
|
|
322
|
+
kind: z.literal('cli-payments-catalog-unloadable'),
|
|
323
|
+
catalogPath: z.string().min(1),
|
|
324
|
+
loadFailureDetail: z.string().min(1),
|
|
325
|
+
message: z.string().startsWith(cliPaymentsCatalogUnloadableErrorPrefix),
|
|
326
|
+
}),
|
|
327
|
+
z.object({
|
|
328
|
+
kind: z.literal('cli-payments-sync-failed'),
|
|
329
|
+
paymentsFailure: paymentsFailureSchema,
|
|
330
|
+
message: z.string().startsWith(cliPaymentsSyncFailedErrorPrefix),
|
|
331
|
+
}),
|
|
332
|
+
])
|
|
333
|
+
|
|
334
|
+
/** Discriminated failure union; db-command-failed wraps the DbFailure verbatim with the db message unchanged, cli-payments-sync-failed wraps the PaymentsFailure verbatim behind its own prefix. */
|
|
335
|
+
export type CliFailure = z.infer<typeof cliFailureSchema>
|
|
336
|
+
|
|
337
|
+
/** Success shapes per command; each mirrors what the single stdout line reports so gates can check either channel. */
|
|
338
|
+
export const cliCommandSuccessSchema = z.discriminatedUnion('kind', [
|
|
339
|
+
z.object({
|
|
340
|
+
kind: z.literal('db-create-command-succeeded'),
|
|
341
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
342
|
+
connectionString: postgresConnectionStringSchema,
|
|
343
|
+
}),
|
|
344
|
+
z.object({
|
|
345
|
+
kind: z.literal('db-drop-command-succeeded'),
|
|
346
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
347
|
+
}),
|
|
348
|
+
z.object({
|
|
349
|
+
kind: z.literal('db-migrate-command-succeeded'),
|
|
350
|
+
appliedMigrationCount: z.number().int().min(0),
|
|
351
|
+
}),
|
|
352
|
+
z.object({
|
|
353
|
+
kind: z.literal('db-backup-command-succeeded'),
|
|
354
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
355
|
+
backupFilePath: z.string().min(1),
|
|
356
|
+
backupByteCount: z.number().int().positive(),
|
|
357
|
+
}),
|
|
358
|
+
z.object({
|
|
359
|
+
kind: z.literal('db-restore-command-succeeded'),
|
|
360
|
+
projectDatabaseName: projectDatabaseNameSchema,
|
|
361
|
+
backupFilePath: z.string().min(1),
|
|
362
|
+
}),
|
|
363
|
+
z.object({
|
|
364
|
+
kind: z.literal('dev-infra-up-succeeded'),
|
|
365
|
+
startedInfraServices: z.array(localInfraServiceNameSchema),
|
|
366
|
+
}),
|
|
367
|
+
z.object({ kind: z.literal('dev-infra-down-succeeded') }),
|
|
368
|
+
z.object({
|
|
369
|
+
kind: z.literal('dev-command-exited'),
|
|
370
|
+
nextDevExitCode: z.number().int().min(0).max(255),
|
|
371
|
+
}),
|
|
372
|
+
z.object({
|
|
373
|
+
kind: z.literal('doctor-report'),
|
|
374
|
+
checks: z.array(doctorCheckResultSchema).min(1),
|
|
375
|
+
allDoctorChecksPassed: z.literal(true),
|
|
376
|
+
}),
|
|
377
|
+
z.object({
|
|
378
|
+
kind: z.literal('payments-sync-command-succeeded'),
|
|
379
|
+
catalogPath: z.string().min(1),
|
|
380
|
+
syncedPrices: z.array(paymentsSyncedPriceSchema).min(1),
|
|
381
|
+
createdPriceCount: z.number().int().min(0),
|
|
382
|
+
replacedPriceCount: z.number().int().min(0),
|
|
383
|
+
unchangedPriceCount: z.number().int().min(0),
|
|
384
|
+
stripeLivemode: z.boolean(),
|
|
385
|
+
}),
|
|
386
|
+
])
|
|
387
|
+
|
|
388
|
+
/** Command success union; doctor-report appears here only when every check passed, otherwise doctor-checks-failed is returned. */
|
|
389
|
+
export type CliCommandSuccess = z.infer<typeof cliCommandSuccessSchema>
|
|
390
|
+
|
|
391
|
+
/** Full result union of one CLI command run, for runtime validation in gates. */
|
|
392
|
+
export const cliCommandResultSchema = z.union([cliCommandSuccessSchema, cliFailureSchema])
|
|
393
|
+
|
|
394
|
+
/** Result type of one CLI command run: a success variant or a CliFailure. */
|
|
395
|
+
export type CliCommandResult = z.infer<typeof cliCommandResultSchema>
|
|
396
|
+
|
|
397
|
+
/** Process exit code of the bin: 0 success, 1 operational failure, 2 usage; hearthkit dev propagates next dev's code. */
|
|
398
|
+
export const cliExitCodeSchema = z.number().int().min(0).max(255)
|
|
399
|
+
|
|
400
|
+
/** Exit code type returned by runHearthkitCli and used by the bin wrapper. */
|
|
401
|
+
export type CliExitCode = z.infer<typeof cliExitCodeSchema>
|
|
402
|
+
|
|
403
|
+
/** Runtime shape of runHearthkitCli options; argv is command words and flags only, without the node and bin prefix. */
|
|
404
|
+
export const runHearthkitCliOptionsSchema = z.object({
|
|
405
|
+
argv: z.array(z.string()),
|
|
406
|
+
cwd: z.string().min(1).optional(),
|
|
407
|
+
env: z.record(z.string(), z.string().optional()).optional(),
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
/** Options type for runHearthkitCli; cwd defaults to process.cwd() and env to process.env. */
|
|
411
|
+
export type RunHearthkitCliOptions = z.infer<typeof runHearthkitCliOptionsSchema>
|
|
412
|
+
|
|
413
|
+
/** What one CLI run produced; exitCode is what the bin exits with and result is the structured outcome gates validate. */
|
|
414
|
+
export const cliRunOutcomeSchema = z.object({
|
|
415
|
+
exitCode: cliExitCodeSchema,
|
|
416
|
+
result: cliCommandResultSchema,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
/** Outcome type of runHearthkitCli. */
|
|
420
|
+
export type CliRunOutcome = z.infer<typeof cliRunOutcomeSchema>
|
|
421
|
+
|
|
422
|
+
/** Signature of runHearthkitCli: parses argv, runs the command, writes stdout/stderr, never throws for a contract failure mode. */
|
|
423
|
+
export type RunHearthkitCli = (options: RunHearthkitCliOptions) => Promise<CliRunOutcome>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { DbFailure } from '@hearthkit/db'
|
|
2
|
+
import type { PaymentsFailure } from '@hearthkit/payments/payments-contract'
|
|
3
|
+
import {
|
|
4
|
+
cliAdminUrlInvalidErrorPrefix,
|
|
5
|
+
cliComposeFileUnwritableErrorPrefix,
|
|
6
|
+
cliDatabaseUrlInvalidErrorPrefix,
|
|
7
|
+
cliDatabaseUrlMissingErrorPrefix,
|
|
8
|
+
cliDockerUnavailableErrorPrefix,
|
|
9
|
+
cliDoctorFailedErrorPrefix,
|
|
10
|
+
cliInfraComposeFailedErrorPrefix,
|
|
11
|
+
cliNextDevUnavailableErrorPrefix,
|
|
12
|
+
cliPaymentsCatalogNotFoundErrorPrefix,
|
|
13
|
+
cliPaymentsCatalogUnloadableErrorPrefix,
|
|
14
|
+
cliPaymentsSyncFailedErrorPrefix,
|
|
15
|
+
cliProjectManifestMissingErrorPrefix,
|
|
16
|
+
cliUsageErrorPrefix,
|
|
17
|
+
type CliFailure,
|
|
18
|
+
type DoctorCheckName,
|
|
19
|
+
type DoctorCheckResult,
|
|
20
|
+
} from './cli-contract.ts'
|
|
21
|
+
|
|
22
|
+
/** Every failure this package returns is built here, so each message keeps its unique literal prefix in one place. */
|
|
23
|
+
type CliFailureOf<TKind extends CliFailure['kind']> = Extract<CliFailure, { kind: TKind }>
|
|
24
|
+
|
|
25
|
+
/** How many characters of a compose stderr stream travel back in the failure; enough to name the cause, short enough to print. */
|
|
26
|
+
const composeStandardErrorExcerptLimit = 2000
|
|
27
|
+
|
|
28
|
+
/** Unknown command, unknown flag, missing argument, or an argument that fails its schema; the only exit-2 failure. */
|
|
29
|
+
export function cliUsageInvalidFailure(detail: string): CliFailureOf<'cli-usage-invalid'> {
|
|
30
|
+
return {
|
|
31
|
+
kind: 'cli-usage-invalid',
|
|
32
|
+
message: `${cliUsageErrorPrefix} ${detail}`,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The admin URL resolved from the flag or the environment is not a postgres(ql) URL, so no database work was attempted. */
|
|
37
|
+
export function adminDatabaseUrlInvalidFailure(
|
|
38
|
+
detail: string,
|
|
39
|
+
): CliFailureOf<'admin-database-url-invalid'> {
|
|
40
|
+
return {
|
|
41
|
+
kind: 'admin-database-url-invalid',
|
|
42
|
+
message: `${cliAdminUrlInvalidErrorPrefix} ${detail}`,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** db migrate ran with neither --database-url nor DATABASE_URL; the message names the variable right after the prefix. */
|
|
47
|
+
export function databaseUrlMissingFailure(detail: string): CliFailureOf<'database-url-missing'> {
|
|
48
|
+
return {
|
|
49
|
+
kind: 'database-url-missing',
|
|
50
|
+
message: `${cliDatabaseUrlMissingErrorPrefix} DATABASE_URL ${detail}`,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The project-scoped database URL given on the command line or in the environment is not a postgres(ql) URL. */
|
|
55
|
+
export function databaseUrlInvalidFailure(detail: string): CliFailureOf<'database-url-invalid'> {
|
|
56
|
+
return {
|
|
57
|
+
kind: 'database-url-invalid',
|
|
58
|
+
message: `${cliDatabaseUrlInvalidErrorPrefix} ${detail}`,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Carries a @hearthkit/db failure out unchanged; the CLI never re-words it, so the db prefix stays greppable. */
|
|
63
|
+
export function dbCommandFailedFailure(dbFailure: DbFailure): CliFailureOf<'db-command-failed'> {
|
|
64
|
+
return {
|
|
65
|
+
kind: 'db-command-failed',
|
|
66
|
+
dbFailure,
|
|
67
|
+
message: dbFailure.message,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** docker is not on PATH, or it is but the daemon did not answer docker info. */
|
|
72
|
+
export function dockerUnavailableFailure(detail: string): CliFailureOf<'docker-unavailable'> {
|
|
73
|
+
return {
|
|
74
|
+
kind: 'docker-unavailable',
|
|
75
|
+
message: `${cliDockerUnavailableErrorPrefix} ${detail}`,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A docker compose invocation exited nonzero; the excerpt is compose's own words, truncated to stay printable. */
|
|
80
|
+
export function infraComposeFailedFailure(options: {
|
|
81
|
+
composeArguments: readonly string[]
|
|
82
|
+
composeExitCode: number
|
|
83
|
+
composeStandardError: string
|
|
84
|
+
}): CliFailureOf<'infra-compose-failed'> {
|
|
85
|
+
const reportedText = options.composeStandardError.trim()
|
|
86
|
+
const composeStderrExcerpt = (
|
|
87
|
+
reportedText === '' ? 'docker compose produced no diagnostic output' : reportedText
|
|
88
|
+
).slice(0, composeStandardErrorExcerptLimit)
|
|
89
|
+
return {
|
|
90
|
+
kind: 'infra-compose-failed',
|
|
91
|
+
composeExitCode: options.composeExitCode,
|
|
92
|
+
composeStderrExcerpt,
|
|
93
|
+
message: `${cliInfraComposeFailedErrorPrefix} docker compose ${options.composeArguments.join(' ')} exited ${options.composeExitCode}: ${composeStderrExcerpt}`,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Compose generation was needed but the working directory holds no package.json to read the services from. */
|
|
98
|
+
export function projectManifestMissingFailure(
|
|
99
|
+
manifestPath: string,
|
|
100
|
+
detail: string,
|
|
101
|
+
): CliFailureOf<'project-manifest-missing'> {
|
|
102
|
+
return {
|
|
103
|
+
kind: 'project-manifest-missing',
|
|
104
|
+
manifestPath,
|
|
105
|
+
message: `${cliProjectManifestMissingErrorPrefix} ${manifestPath} ${detail}`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The generated docker-compose.yml could not be written, so nothing was started. */
|
|
110
|
+
export function composeFileUnwritableFailure(
|
|
111
|
+
composeFilePath: string,
|
|
112
|
+
detail: string,
|
|
113
|
+
): CliFailureOf<'compose-file-unwritable'> {
|
|
114
|
+
return {
|
|
115
|
+
kind: 'compose-file-unwritable',
|
|
116
|
+
composeFilePath,
|
|
117
|
+
message: `${cliComposeFileUnwritableErrorPrefix} ${composeFilePath} ${detail}`,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** hearthkit dev found no runnable next binary in the project's node_modules/.bin. */
|
|
122
|
+
export function nextDevUnavailableFailure(detail: string): CliFailureOf<'next-dev-unavailable'> {
|
|
123
|
+
return {
|
|
124
|
+
kind: 'next-dev-unavailable',
|
|
125
|
+
message: `${cliNextDevUnavailableErrorPrefix} ${detail}`,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** At least one doctor check did not pass; the whole report travels with the failure and still prints. */
|
|
130
|
+
export function doctorChecksFailedFailure(
|
|
131
|
+
checks: DoctorCheckResult[],
|
|
132
|
+
failedCheckNames: DoctorCheckName[],
|
|
133
|
+
): CliFailureOf<'doctor-checks-failed'> {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'doctor-checks-failed',
|
|
136
|
+
checks,
|
|
137
|
+
failedCheckNames,
|
|
138
|
+
message: `${cliDoctorFailedErrorPrefix} ${failedCheckNames.join(', ')}`,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** No file sits at the resolved catalog path, so nothing was imported and no Stripe key was read. */
|
|
143
|
+
export function paymentsCatalogNotFoundFailure(
|
|
144
|
+
catalogPath: string,
|
|
145
|
+
): CliFailureOf<'cli-payments-catalog-not-found'> {
|
|
146
|
+
return {
|
|
147
|
+
kind: 'cli-payments-catalog-not-found',
|
|
148
|
+
catalogPath,
|
|
149
|
+
message: `${cliPaymentsCatalogNotFoundErrorPrefix} ${catalogPath} does not exist; pass --catalog or add the file`,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The catalog file exists but importing it threw, or it exports no catalog under either accepted name. */
|
|
154
|
+
export function paymentsCatalogUnloadableFailure(
|
|
155
|
+
catalogPath: string,
|
|
156
|
+
loadFailureDetail: string,
|
|
157
|
+
): CliFailureOf<'cli-payments-catalog-unloadable'> {
|
|
158
|
+
return {
|
|
159
|
+
kind: 'cli-payments-catalog-unloadable',
|
|
160
|
+
catalogPath,
|
|
161
|
+
loadFailureDetail,
|
|
162
|
+
message: `${cliPaymentsCatalogUnloadableErrorPrefix} ${catalogPath}: ${loadFailureDetail}`,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Carries a @hearthkit/payments failure out behind this package's own prefix, with the payments message unchanged after it. */
|
|
167
|
+
export function paymentsSyncFailedFailure(
|
|
168
|
+
paymentsFailure: PaymentsFailure,
|
|
169
|
+
): CliFailureOf<'cli-payments-sync-failed'> {
|
|
170
|
+
return {
|
|
171
|
+
kind: 'cli-payments-sync-failed',
|
|
172
|
+
paymentsFailure,
|
|
173
|
+
message: `${cliPaymentsSyncFailedErrorPrefix} ${paymentsFailure.message}`,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Writes the one machine-readable line a successful command prints; stdout carries nothing else. */
|
|
2
|
+
export function writeStandardOutputLine(line: string): void {
|
|
3
|
+
process.stdout.write(`${line}\n`)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Writes one human line to stderr: guidance, progress, or the failure message, never machine-readable output. */
|
|
7
|
+
export function writeStandardErrorLine(line: string): void {
|
|
8
|
+
process.stderr.write(`${line}\n`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Forwards a child process's stderr through unchanged, so docker compose progress reaches the operator as it happens. */
|
|
12
|
+
export function writeStandardErrorChunk(chunk: string): void {
|
|
13
|
+
process.stderr.write(chunk)
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where one command runs and what it may read. runHearthkitCli resolves this once from its options,
|
|
3
|
+
* so no handler below it reaches for process.cwd() or process.env and every gate can redirect both.
|
|
4
|
+
*/
|
|
5
|
+
export type CliRuntimeContext = {
|
|
6
|
+
workingDirectoryPath: string
|
|
7
|
+
environmentVariables: Record<string, string | undefined>
|
|
8
|
+
}
|