@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,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
|
+
}
|