@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,180 @@
1
+ import { isAbsolute, resolve } from 'node:path'
2
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
3
+ import {
4
+ expectCliFailure,
5
+ expectCliSuccess,
6
+ runHearthkitCliGate,
7
+ singleStandardOutputLine,
8
+ } from '../test-fixtures/cli-run-expectations.ts'
9
+ import {
10
+ createGateDirectory,
11
+ removeGateDirectory,
12
+ } from '../test-fixtures/gate-project-directories.ts'
13
+ import { loadHearthkitCliPaymentsExports } from '../test-fixtures/hearthkit-cli-payments-exports.ts'
14
+ import {
15
+ uniqueGateCliCatalogNames,
16
+ writeGateCliCatalogFile,
17
+ writeGateCliUnusableCatalogFile,
18
+ type GateCliCatalogNames,
19
+ } from '../test-fixtures/payments-sync-gate-catalog.ts'
20
+ import {
21
+ archiveGateStripeSyncedPrices,
22
+ assertGateStripeSyncWasTestMode,
23
+ readGateStripeSecretKey,
24
+ } from '../test-fixtures/stripe-gate-secret-key.ts'
25
+
26
+ /**
27
+ * hearthkit payments sync. One live gate against Stripe test mode, and the three file and
28
+ * environment paths that reach no service at all.
29
+ *
30
+ * The command is specified to read exactly one environment variable, so every gate here hands it an
31
+ * environment holding only that variable or nothing. It opens no Postgres connection either — the
32
+ * CLI builds the payments client with a database URL nothing listens on — so a sync that started
33
+ * querying would fail these gates rather than pass them.
34
+ */
35
+
36
+ /** Prices this run created in the shared test-mode Stripe account, archived in afterAll. */
37
+ const createdStripePrices: { stripePriceId: unknown; stripeProductId: unknown }[] = []
38
+
39
+ let gateDirectoryPath: string
40
+ let liveCatalogNames: GateCliCatalogNames
41
+ let gateStripeSecretKey: string | undefined
42
+
43
+ beforeAll(async () => {
44
+ gateDirectoryPath = await createGateDirectory('payments-sync')
45
+ liveCatalogNames = uniqueGateCliCatalogNames('sync')
46
+ })
47
+
48
+ afterAll(async () => {
49
+ if (gateStripeSecretKey !== undefined && createdStripePrices.length > 0) {
50
+ await archiveGateStripeSyncedPrices(gateStripeSecretKey, createdStripePrices)
51
+ }
52
+ await removeGateDirectory(gateDirectoryPath)
53
+ })
54
+
55
+ describe('hearthkit payments sync', () => {
56
+ it('pushes the project catalog to Stripe test mode and reports one created price per catalog price', async () => {
57
+ const { cliPaymentsSyncCompleteLinePrefix, defaultPaymentsCatalogPath } =
58
+ await loadHearthkitCliPaymentsExports()
59
+ // Not skipped when the key is absent: this is the only gate that proves the command reaches
60
+ // Stripe at all, so a missing key has to fail loudly. The helper names both places to put it.
61
+ gateStripeSecretKey = await readGateStripeSecretKey()
62
+ const catalogFilePath = await writeGateCliCatalogFile({
63
+ directoryPath: gateDirectoryPath,
64
+ fileName: 'payments-catalog.ts',
65
+ catalogNames: liveCatalogNames,
66
+ })
67
+
68
+ const run = await runHearthkitCliGate({
69
+ argv: ['payments', 'sync'],
70
+ cwd: gateDirectoryPath,
71
+ // Only STRIPE_SECRET_KEY, because the contract says the command reads nothing else.
72
+ env: { STRIPE_SECRET_KEY: gateStripeSecretKey },
73
+ })
74
+
75
+ const success = expectCliSuccess(run, 'payments-sync-command-succeeded', 0)
76
+ // The measured test-mode guard, before this run's objects are touched again for any reason.
77
+ assertGateStripeSyncWasTestMode(success.stripeLivemode)
78
+ expect(success.stripeLivemode).toBe(false)
79
+ createdStripePrices.push(...success.syncedPrices)
80
+
81
+ // No --catalog flag, so the path is the default resolved against cwd.
82
+ expect(success.catalogPath).toBe(resolve(gateDirectoryPath, defaultPaymentsCatalogPath))
83
+ expect(success.catalogPath).toBe(catalogFilePath)
84
+ expect([...success.syncedPrices].map((price) => String(price.priceName)).toSorted()).toEqual(
85
+ [liveCatalogNames.subscriptionPriceName, liveCatalogNames.oneTimePriceName].toSorted(),
86
+ )
87
+ // Every price name is unique to this run, so the first sync of it can only create.
88
+ expect(success.syncedPrices.every((price) => price.syncAction === 'created')).toBe(true)
89
+ expect(success.createdPriceCount).toBe(success.syncedPrices.length)
90
+ expect(success.replacedPriceCount).toBe(0)
91
+ expect(success.unchangedPriceCount).toBe(0)
92
+ expect(
93
+ success.createdPriceCount + success.replacedPriceCount + success.unchangedPriceCount,
94
+ ).toBe(success.syncedPrices.length)
95
+
96
+ // One machine-readable line on stdout; only its prefix and the counts it carries are contract.
97
+ const printedLine = singleStandardOutputLine(run)
98
+ expect(printedLine.startsWith(cliPaymentsSyncCompleteLinePrefix)).toBe(true)
99
+ expect(printedLine).toContain(String(success.createdPriceCount))
100
+ })
101
+
102
+ it('fails with cli-payments-catalog-not-found and the absolute default path when the project has no catalog file', async () => {
103
+ const { cliPaymentsCatalogNotFoundErrorPrefix, defaultPaymentsCatalogPath } =
104
+ await loadHearthkitCliPaymentsExports()
105
+ // A directory of its own, so the live gate's catalog file cannot make this one pass.
106
+ const emptyDirectoryPath = await createGateDirectory('payments-sync-empty')
107
+
108
+ try {
109
+ // No Stripe key at all: the file check comes first, so this path needs no service and no key.
110
+ const run = await runHearthkitCliGate({
111
+ argv: ['payments', 'sync'],
112
+ cwd: emptyDirectoryPath,
113
+ env: {},
114
+ })
115
+
116
+ const failure = expectCliFailure(run, 'cli-payments-catalog-not-found', 1)
117
+ expect(isAbsolute(failure.catalogPath)).toBe(true)
118
+ expect(failure.catalogPath).toBe(resolve(emptyDirectoryPath, defaultPaymentsCatalogPath))
119
+ expect(failure.message.startsWith(cliPaymentsCatalogNotFoundErrorPrefix)).toBe(true)
120
+ expect(run.standardError).toContain(failure.message)
121
+ expect(run.standardOutput.trim()).toBe('')
122
+ } finally {
123
+ await removeGateDirectory(emptyDirectoryPath)
124
+ }
125
+ })
126
+
127
+ it('fails with cli-payments-catalog-unloadable when the --catalog file exists but exports no catalog', async () => {
128
+ const { cliPaymentsCatalogUnloadableErrorPrefix } = await loadHearthkitCliPaymentsExports()
129
+ const unusableCatalogFileName = 'gate-unusable-catalog.ts'
130
+ const unusableCatalogFilePath = await writeGateCliUnusableCatalogFile({
131
+ directoryPath: gateDirectoryPath,
132
+ fileName: unusableCatalogFileName,
133
+ })
134
+
135
+ const run = await runHearthkitCliGate({
136
+ argv: ['payments', 'sync', '--catalog', `./${unusableCatalogFileName}`],
137
+ cwd: gateDirectoryPath,
138
+ env: {},
139
+ })
140
+
141
+ const failure = expectCliFailure(run, 'cli-payments-catalog-unloadable', 1)
142
+ // The file is there, so this is not "not found", and the flag value is resolved against cwd.
143
+ expect(failure.catalogPath).toBe(unusableCatalogFilePath)
144
+ expect(failure.loadFailureDetail.length).toBeGreaterThan(0)
145
+ expect(failure.message.startsWith(cliPaymentsCatalogUnloadableErrorPrefix)).toBe(true)
146
+ expect(run.standardError).toContain(failure.message)
147
+ })
148
+
149
+ it('wraps the payments failure as cli-payments-sync-failed when STRIPE_SECRET_KEY is unset', async () => {
150
+ const { cliPaymentsSyncFailedErrorPrefix, stripeSecretKeyEnvVariableName } =
151
+ await loadHearthkitCliPaymentsExports()
152
+ const catalogNames = uniqueGateCliCatalogNames('unset-key')
153
+ const unsetKeyCatalogFileName = 'gate-unset-key-catalog.ts'
154
+ await writeGateCliCatalogFile({
155
+ directoryPath: gateDirectoryPath,
156
+ fileName: unsetKeyCatalogFileName,
157
+ catalogNames,
158
+ })
159
+
160
+ // The catalog loads, so the run gets as far as createPaymentsClient, which rejects the env
161
+ // object. A missing key is deliberately not a CLI failure kind; it arrives wrapped.
162
+ const run = await runHearthkitCliGate({
163
+ argv: ['payments', 'sync', '--catalog', `./${unsetKeyCatalogFileName}`],
164
+ cwd: gateDirectoryPath,
165
+ env: {},
166
+ })
167
+
168
+ const failure = expectCliFailure(run, 'cli-payments-sync-failed', 1)
169
+ expect(failure.paymentsFailure.kind).toBe('payments-input-invalid')
170
+ if (failure.paymentsFailure.kind === 'payments-input-invalid') {
171
+ expect(failure.paymentsFailure.invalidFieldName).toBe('payments-env')
172
+ }
173
+ expect(failure.message.startsWith(cliPaymentsSyncFailedErrorPrefix)).toBe(true)
174
+ // Both prefixes stay greppable: the payments message follows the CLI prefix unchanged.
175
+ expect(failure.message).toContain(failure.paymentsFailure.message)
176
+ // The payments message names the field, not the variable, so the CLI's guidance names it.
177
+ expect(run.standardError).toContain(stripeSecretKeyEnvVariableName)
178
+ expect(run.standardError).toContain(failure.message)
179
+ })
180
+ })
@@ -0,0 +1,62 @@
1
+ import type { CliCommandInvocation, CliCommandResult, RunHearthkitCli } from './cli-contract.ts'
2
+ import type { CliRuntimeContext } from './cli-runtime-context.ts'
3
+ import { parseCliInvocation } from './parse-cli-invocation.ts'
4
+ import { reportCliOutcome } from './report-cli-outcome.ts'
5
+ import { runDbLifecycleCommand } from './run-db-lifecycle-command.ts'
6
+ import { runDevCommand } from './run-dev-command.ts'
7
+ import { runDevInfraDownCommand, runDevInfraUpCommand } from './run-dev-infra-command.ts'
8
+ import { runDoctorCommand } from './run-doctor-checks.ts'
9
+ import { runPaymentsSyncCommand } from './run-payments-sync-command.ts'
10
+
11
+ /**
12
+ * Runs one hearthkit command end to end: resolve the working directory and environment, parse argv,
13
+ * hand the invocation to the package that owns the work, then print and score the outcome. Every
14
+ * contract failure comes back as a result with an exit code; nothing here throws to report one.
15
+ */
16
+ export const runHearthkitCli: RunHearthkitCli = async (options) => {
17
+ const context: CliRuntimeContext = {
18
+ workingDirectoryPath: options.cwd ?? process.cwd(),
19
+ environmentVariables: options.env ?? process.env,
20
+ }
21
+
22
+ const parse = parseCliInvocation({ argv: options.argv, context })
23
+ if (parse.kind === 'cli-invocation-rejected') {
24
+ return {
25
+ exitCode: reportCliOutcome({ result: parse.failure, invocation: undefined }),
26
+ result: parse.failure,
27
+ }
28
+ }
29
+
30
+ const result = await runCliCommandInvocation({ invocation: parse.invocation, context })
31
+ return {
32
+ exitCode: reportCliOutcome({ result, invocation: parse.invocation }),
33
+ result,
34
+ }
35
+ }
36
+
37
+ /** Dispatches a parsed invocation to the one handler that owns it; the switch is the whole command registry. */
38
+ async function runCliCommandInvocation(options: {
39
+ invocation: CliCommandInvocation
40
+ context: CliRuntimeContext
41
+ }): Promise<CliCommandResult> {
42
+ const { invocation, context } = options
43
+
44
+ if (invocation.commandPath === 'dev') {
45
+ return runDevCommand(context)
46
+ }
47
+ if (invocation.commandPath === 'dev infra up') {
48
+ return runDevInfraUpCommand(context)
49
+ }
50
+ if (invocation.commandPath === 'dev infra down') {
51
+ return runDevInfraDownCommand(context)
52
+ }
53
+ if (invocation.commandPath === 'doctor') {
54
+ return runDoctorCommand(context)
55
+ }
56
+ if (invocation.commandPath === 'payments sync') {
57
+ return runPaymentsSyncCommand({ catalogPath: invocation.catalogPath, context })
58
+ }
59
+ // Everything left is a db command, and it has to be: a command path added to the enum without a
60
+ // handler here fails to typecheck against DbLifecycleInvocation rather than falling through.
61
+ return runDbLifecycleCommand({ invocation, context })
62
+ }
@@ -0,0 +1,113 @@
1
+ import { createDrizzleClient, postgresConnectionStringSchema } from '@hearthkit/db'
2
+ import type { PaymentsCatalog, PaymentsEnvValues } from '@hearthkit/payments/payments-contract'
3
+ import { z } from 'zod'
4
+ import { stripeSecretKeyEnvVariableName, type CliCommandResult } from './cli-contract.ts'
5
+ import {
6
+ paymentsCatalogNotFoundFailure,
7
+ paymentsCatalogUnloadableFailure,
8
+ paymentsSyncFailedFailure,
9
+ } from './cli-failure-results.ts'
10
+ import type { CliRuntimeContext } from './cli-runtime-context.ts'
11
+ import { loadPaymentsCatalogModule } from './load-payments-catalog-module.ts'
12
+ import { readEnvironmentVariableValue } from './read-environment-variable-value.ts'
13
+
14
+ /**
15
+ * hearthkit payments sync: resolve the catalog file, hand it to @hearthkit/payments, map the result.
16
+ *
17
+ * The file checks come first so a project with no catalog fails without a Stripe key. Everything
18
+ * after that belongs to payments: the catalog value is passed on unvalidated, and a missing
19
+ * STRIPE_SECRET_KEY is not a CLI failure kind — it arrives as the payments env failure, wrapped.
20
+ *
21
+ * @hearthkit/payments is imported inside the function rather than at the top of the file, so the one
22
+ * command that syncs a catalog is the only one that pays for loading the Stripe SDK. Every other
23
+ * command, and the bin's own startup, stays free of it.
24
+ */
25
+
26
+ // Sync verifies no signature, runs no query and writes no customer row, so the CLI supplies the rest
27
+ // of the payments client itself rather than asking the operator for it: a placeholder webhook secret,
28
+ // a Drizzle client aimed at a port nothing listens on, and organizationsEnabled false. A sync that
29
+ // ever grew a query would report a wrapped payments-database-unavailable rather than touch a real
30
+ // database, which is the direction this is meant to fail in.
31
+ /** Webhook secret handed to createPaymentsClient because its env fragment requires one; sync never verifies a signature. */
32
+ const unusedWebhookSecretPlaceholder = 'hearthkit-cli-payments-sync-unused-webhook-secret'
33
+
34
+ /** Connection string the throwaway Drizzle client points at; a pool opens no connection until a query runs, and sync runs none. */
35
+ const unusedProjectDatabaseUrl = postgresConnectionStringSchema.parse(
36
+ 'postgresql://hearthkit:hearthkit@127.0.0.1:1/hearthkit-cli-payments-sync-unused',
37
+ )
38
+
39
+ // Both of these pass a value straight through with the type @hearthkit/payments declares and no check
40
+ // of their own. That is the contract: createPaymentsClient is what validates a catalog and an env
41
+ // object, so a bad catalog comes back as payments-catalog-invalid and an unset key as
42
+ // payments-input-invalid with payments-env, rather than as a second opinion formed here.
43
+ /** Passes the catalog module's export through unvalidated, because createPaymentsClient validates it. */
44
+ const unvalidatedPaymentsCatalogSchema = z.custom<PaymentsCatalog>()
45
+
46
+ /** Passes the env object through unvalidated, because createPaymentsClient rejects a missing key as payments-env. */
47
+ const unvalidatedPaymentsEnvSchema = z.custom<PaymentsEnvValues>()
48
+
49
+ /** Runs one payments sync invocation and returns the success shape or the CLI failure that wraps what payments said. */
50
+ export async function runPaymentsSyncCommand(options: {
51
+ catalogPath: string
52
+ context: CliRuntimeContext
53
+ }): Promise<CliCommandResult> {
54
+ const { catalogPath, context } = options
55
+
56
+ const catalogLoad = await loadPaymentsCatalogModule(catalogPath)
57
+ if (catalogLoad.kind === 'payments-catalog-file-absent') {
58
+ return paymentsCatalogNotFoundFailure(catalogPath)
59
+ }
60
+ if (catalogLoad.kind === 'payments-catalog-unloadable') {
61
+ return paymentsCatalogUnloadableFailure(catalogPath, catalogLoad.loadFailureDetail)
62
+ }
63
+
64
+ const { createPaymentsClient, syncPaymentsCatalog } = await import('@hearthkit/payments')
65
+
66
+ const stripeSecretKey =
67
+ readEnvironmentVariableValue(context.environmentVariables, stripeSecretKeyEnvVariableName) ?? ''
68
+
69
+ const { drizzleClient, closeDatabaseClient } = createDrizzleClient<Record<string, unknown>>({
70
+ databaseUrl: unusedProjectDatabaseUrl,
71
+ schema: {},
72
+ })
73
+
74
+ try {
75
+ const clientResult = createPaymentsClient({
76
+ paymentsEnv: unvalidatedPaymentsEnvSchema.parse({
77
+ STRIPE_SECRET_KEY: stripeSecretKey,
78
+ STRIPE_WEBHOOK_SECRET: unusedWebhookSecretPlaceholder,
79
+ }),
80
+ drizzleClient,
81
+ paymentsCatalog: unvalidatedPaymentsCatalogSchema.parse(catalogLoad.catalogValue),
82
+ organizationsEnabled: false,
83
+ })
84
+ if (clientResult.kind !== 'payments-client-created') {
85
+ return paymentsSyncFailedFailure(clientResult)
86
+ }
87
+
88
+ const syncResult = await syncPaymentsCatalog({ paymentsClient: clientResult.paymentsClient })
89
+ if (syncResult.kind !== 'payments-catalog-synced') {
90
+ return paymentsSyncFailedFailure(syncResult)
91
+ }
92
+
93
+ return {
94
+ kind: 'payments-sync-command-succeeded',
95
+ catalogPath,
96
+ syncedPrices: syncResult.syncedPrices,
97
+ createdPriceCount: countPricesSyncedBy(syncResult.syncedPrices, 'created'),
98
+ replacedPriceCount: countPricesSyncedBy(syncResult.syncedPrices, 'replaced'),
99
+ unchangedPriceCount: countPricesSyncedBy(syncResult.syncedPrices, 'unchanged'),
100
+ stripeLivemode: syncResult.stripeLivemode,
101
+ }
102
+ } finally {
103
+ await closeDatabaseClient()
104
+ }
105
+ }
106
+
107
+ /** How many synced prices took one action; the three words are payments' own syncAction values. */
108
+ function countPricesSyncedBy(
109
+ syncedPrices: readonly { syncAction: string }[],
110
+ syncAction: string,
111
+ ): number {
112
+ return syncedPrices.filter((syncedPrice) => syncedPrice.syncAction === syncAction).length
113
+ }