@actuate-media/cli 0.6.0 → 0.8.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 (61) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +27 -25
  3. package/CHANGELOG.md +19 -0
  4. package/dist/__tests__/db-sync.test.js +32 -1
  5. package/dist/__tests__/db-sync.test.js.map +1 -1
  6. package/dist/__tests__/deployment-diagnostics.test.js +42 -1
  7. package/dist/__tests__/deployment-diagnostics.test.js.map +1 -1
  8. package/dist/__tests__/vercel-env-matrix.test.d.ts +2 -0
  9. package/dist/__tests__/vercel-env-matrix.test.d.ts.map +1 -0
  10. package/dist/__tests__/vercel-env-matrix.test.js +48 -0
  11. package/dist/__tests__/vercel-env-matrix.test.js.map +1 -0
  12. package/dist/commands/db-sync.d.ts +22 -0
  13. package/dist/commands/db-sync.d.ts.map +1 -1
  14. package/dist/commands/db-sync.js +94 -0
  15. package/dist/commands/db-sync.js.map +1 -1
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +70 -3
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/migrate-sections.d.ts +3 -0
  20. package/dist/commands/migrate-sections.d.ts.map +1 -0
  21. package/dist/commands/migrate-sections.js +56 -0
  22. package/dist/commands/migrate-sections.js.map +1 -0
  23. package/dist/commands/seed.d.ts.map +1 -1
  24. package/dist/commands/seed.js +2 -40
  25. package/dist/commands/seed.js.map +1 -1
  26. package/dist/commands/vercel-blob-link.d.ts +3 -0
  27. package/dist/commands/vercel-blob-link.d.ts.map +1 -0
  28. package/dist/commands/vercel-blob-link.js +82 -0
  29. package/dist/commands/vercel-blob-link.js.map +1 -0
  30. package/dist/deployment/diagnostics.d.ts +13 -0
  31. package/dist/deployment/diagnostics.d.ts.map +1 -1
  32. package/dist/deployment/diagnostics.js +55 -0
  33. package/dist/deployment/diagnostics.js.map +1 -1
  34. package/dist/index.js +4 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/utils/database.d.ts +19 -0
  37. package/dist/utils/database.d.ts.map +1 -0
  38. package/dist/utils/database.js +58 -0
  39. package/dist/utils/database.js.map +1 -0
  40. package/dist/vercel/client.d.ts +32 -0
  41. package/dist/vercel/client.d.ts.map +1 -0
  42. package/dist/vercel/client.js +74 -0
  43. package/dist/vercel/client.js.map +1 -0
  44. package/dist/vercel/env-matrix.d.ts +34 -0
  45. package/dist/vercel/env-matrix.d.ts.map +1 -0
  46. package/dist/vercel/env-matrix.js +57 -0
  47. package/dist/vercel/env-matrix.js.map +1 -0
  48. package/package.json +2 -2
  49. package/src/__tests__/db-sync.test.ts +55 -1
  50. package/src/__tests__/deployment-diagnostics.test.ts +51 -0
  51. package/src/__tests__/vercel-env-matrix.test.ts +56 -0
  52. package/src/commands/db-sync.ts +116 -0
  53. package/src/commands/doctor.ts +118 -10
  54. package/src/commands/migrate-sections.ts +73 -0
  55. package/src/commands/seed.ts +2 -56
  56. package/src/commands/vercel-blob-link.ts +115 -0
  57. package/src/deployment/diagnostics.ts +70 -0
  58. package/src/index.ts +4 -0
  59. package/src/utils/database.ts +77 -0
  60. package/src/vercel/client.ts +112 -0
  61. package/src/vercel/env-matrix.ts +101 -0
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander'
3
3
  import { registerMigrateCommand } from './commands/migrate.js'
4
+ import { registerMigrateSectionsCommand } from './commands/migrate-sections.js'
4
5
  import { registerGenerateCommand } from './commands/generate.js'
5
6
  import { registerSeedCommand } from './commands/seed.js'
6
7
  import { registerImportCommand } from './commands/import.js'
@@ -16,6 +17,7 @@ import {
16
17
  registerDoctorCommand,
17
18
  registerVerifyCommand,
18
19
  } from './commands/doctor.js'
20
+ import { registerVercelBlobLinkCommand } from './commands/vercel-blob-link.js'
19
21
 
20
22
  const program = new Command()
21
23
 
@@ -25,6 +27,7 @@ program
25
27
  .version('0.1.0')
26
28
 
27
29
  registerMigrateCommand(program)
30
+ registerMigrateSectionsCommand(program)
28
31
  registerGenerateCommand(program)
29
32
  registerSeedCommand(program)
30
33
  registerImportCommand(program)
@@ -38,5 +41,6 @@ registerInitCommand(program)
38
41
  registerDoctorCommand(program)
39
42
  registerDeployCheckCommand(program)
40
43
  registerVerifyCommand(program)
44
+ registerVercelBlobLinkCommand(program)
41
45
 
42
46
  program.parse()
@@ -0,0 +1,77 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ /**
7
+ * Shared helper for CLI commands that need a live connection to the consumer
8
+ * project's database (seed, populate, data migrations…).
9
+ *
10
+ * Resolution order mirrors how a scaffolded Actuate project exposes Prisma:
11
+ * 1. If `cms-core` already has an initialized client (e.g. set up by the host
12
+ * app in-process), reuse it.
13
+ * 2. Otherwise build a client from the project's generated Prisma client
14
+ * (`generated/prisma/client.ts`, Prisma 7 + pg driver adapter).
15
+ * 3. Fall back to a plain `@prisma/client` resolved from the project.
16
+ *
17
+ * The returned `disconnect()` only tears down clients this helper created — a
18
+ * pre-initialized in-process client is left untouched.
19
+ */
20
+ export async function connectProjectDatabase(): Promise<{
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Prisma client shape varies by project; callers treat it structurally.
22
+ db: any
23
+ disconnect: () => Promise<void>
24
+ }> {
25
+ const { getDB, initDB, isDBInitialized } = await import('@actuate-media/cms-core')
26
+
27
+ if (isDBInitialized()) {
28
+ return { db: getDB<any>(), disconnect: async () => {} }
29
+ }
30
+
31
+ const db = await createProjectPrismaClient()
32
+ initDB(db)
33
+ return {
34
+ db,
35
+ disconnect: async () => {
36
+ if (typeof db.$disconnect === 'function') {
37
+ await db.$disconnect()
38
+ }
39
+ },
40
+ }
41
+ }
42
+
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see above.
44
+ async function createProjectPrismaClient(): Promise<any> {
45
+ if (!process.env.DATABASE_URL) {
46
+ throw new Error('DATABASE_URL is required to connect to the project database.')
47
+ }
48
+
49
+ const requireFromProject = createRequire(path.join(process.cwd(), 'package.json'))
50
+ const generatedClient = path.resolve('generated', 'prisma', 'client.ts')
51
+
52
+ if (existsSync(generatedClient)) {
53
+ const [{ tsImport }, adapterModule, pgModule] = await Promise.all([
54
+ import('tsx/esm/api'),
55
+ import(pathToFileURL(requireFromProject.resolve('@prisma/adapter-pg')).href),
56
+ import(pathToFileURL(requireFromProject.resolve('pg')).href),
57
+ ])
58
+ const { PrismaClient } = (await tsImport(
59
+ pathToFileURL(generatedClient).href,
60
+ import.meta.url,
61
+ )) as {
62
+ PrismaClient: new (options?: unknown) => any
63
+ }
64
+ const { PrismaPg } = adapterModule as { PrismaPg: new (pool: unknown) => unknown }
65
+ const pg = (pgModule as { default?: typeof pgModule }).default ?? pgModule
66
+ const pool = new (pg as any).Pool({ connectionString: process.env.DATABASE_URL })
67
+ const adapter = new PrismaPg(pool)
68
+ return new PrismaClient({ adapter } as any)
69
+ }
70
+
71
+ const clientModule = (await import(
72
+ pathToFileURL(requireFromProject.resolve('@prisma/client')).href
73
+ )) as {
74
+ PrismaClient: new () => any
75
+ }
76
+ return new clientModule.PrismaClient()
77
+ }
@@ -0,0 +1,112 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Minimal Vercel REST client for the CLI. Scope is deliberately small: read a
6
+ * project's environment variables so we can validate Blob wiring and print a
7
+ * per-environment readiness matrix. No write operations.
8
+ *
9
+ * Auth: a Vercel access token via `--token`, `VERCEL_TOKEN`, or
10
+ * `VERCEL_API_TOKEN`. Project/team identity from `.vercel/project.json` (written
11
+ * by `vercel link`) or explicit overrides. The client is fetch-injectable so the
12
+ * evaluation logic can be unit-tested without network access.
13
+ */
14
+
15
+ const DEFAULT_BASE_URL = 'https://api.vercel.com'
16
+
17
+ export const VERCEL_TARGETS = ['production', 'preview', 'development'] as const
18
+ export type VercelTarget = (typeof VERCEL_TARGETS)[number]
19
+
20
+ export interface VercelLink {
21
+ projectId: string
22
+ orgId?: string
23
+ }
24
+
25
+ export interface VercelEnvVar {
26
+ key: string
27
+ /** Targets this var applies to, e.g. ['production', 'preview']. */
28
+ target: VercelTarget[]
29
+ type?: string
30
+ }
31
+
32
+ export class VercelApiError extends Error {
33
+ readonly status: number
34
+
35
+ constructor(message: string, status: number) {
36
+ super(message)
37
+ this.name = 'VercelApiError'
38
+ this.status = status
39
+ }
40
+ }
41
+
42
+ /** Read `.vercel/project.json` (written by `vercel link`). Returns null if absent. */
43
+ export async function readVercelLink(cwd: string): Promise<VercelLink | null> {
44
+ try {
45
+ const raw = await readFile(join(cwd, '.vercel', 'project.json'), 'utf-8')
46
+ const parsed = JSON.parse(raw) as { projectId?: string; orgId?: string }
47
+ if (!parsed.projectId) return null
48
+ return { projectId: parsed.projectId, orgId: parsed.orgId }
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ /** Resolve a Vercel token from an explicit flag or the standard env vars. */
55
+ export function resolveVercelToken(explicit?: string): string | undefined {
56
+ return explicit ?? process.env.VERCEL_TOKEN ?? process.env.VERCEL_API_TOKEN ?? undefined
57
+ }
58
+
59
+ export interface VercelClientOptions {
60
+ token: string
61
+ /** Team / org id, appended as `teamId` when the project belongs to a team. */
62
+ teamId?: string
63
+ fetchImpl?: typeof fetch
64
+ baseUrl?: string
65
+ }
66
+
67
+ export interface VercelClient {
68
+ listProjectEnv(projectId: string): Promise<VercelEnvVar[]>
69
+ }
70
+
71
+ export function createVercelClient(options: VercelClientOptions): VercelClient {
72
+ const fetchImpl = options.fetchImpl ?? fetch
73
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
74
+
75
+ async function request<T>(path: string): Promise<T> {
76
+ const url = new URL(path, baseUrl)
77
+ if (options.teamId) url.searchParams.set('teamId', options.teamId)
78
+
79
+ const response = await fetchImpl(url.toString(), {
80
+ headers: { Authorization: `Bearer ${options.token}` },
81
+ })
82
+
83
+ if (!response.ok) {
84
+ let detail = ''
85
+ try {
86
+ const body = (await response.json()) as { error?: { message?: string } }
87
+ detail = body?.error?.message ? `: ${body.error.message}` : ''
88
+ } catch {
89
+ // non-JSON error body — status alone is enough context
90
+ }
91
+ throw new VercelApiError(
92
+ `Vercel API request failed (HTTP ${response.status})${detail}`,
93
+ response.status,
94
+ )
95
+ }
96
+
97
+ return (await response.json()) as T
98
+ }
99
+
100
+ return {
101
+ async listProjectEnv(projectId: string): Promise<VercelEnvVar[]> {
102
+ const data = await request<{ envs?: VercelEnvVar[] }>(
103
+ `/v9/projects/${encodeURIComponent(projectId)}/env`,
104
+ )
105
+ return (data.envs ?? []).map((env) => ({
106
+ key: env.key,
107
+ target: Array.isArray(env.target) ? env.target : [],
108
+ type: env.type,
109
+ }))
110
+ },
111
+ }
112
+ }
@@ -0,0 +1,101 @@
1
+ import { VERCEL_TARGETS, type VercelEnvVar, type VercelTarget } from './client.js'
2
+
3
+ /**
4
+ * Pure evaluation helpers over a project's Vercel env vars. Kept free of I/O so
5
+ * the matrix / blob logic is unit-testable without hitting the Vercel API.
6
+ */
7
+
8
+ export type TargetPresence = Record<VercelTarget, boolean>
9
+
10
+ function emptyPresence(): TargetPresence {
11
+ return { production: false, preview: false, development: false }
12
+ }
13
+
14
+ /** Which targets a given env var key is defined for. */
15
+ export function presenceForKey(envVars: VercelEnvVar[], key: string): TargetPresence {
16
+ const presence = emptyPresence()
17
+ for (const env of envVars) {
18
+ if (env.key !== key) continue
19
+ for (const target of env.target) {
20
+ if ((VERCEL_TARGETS as readonly string[]).includes(target)) {
21
+ presence[target as VercelTarget] = true
22
+ }
23
+ }
24
+ }
25
+ return presence
26
+ }
27
+
28
+ export interface EnvMatrixRow {
29
+ key: string
30
+ required: boolean
31
+ presence: TargetPresence
32
+ }
33
+
34
+ export interface EnvMatrix {
35
+ rows: EnvMatrixRow[]
36
+ /** Required vars missing on production or preview (deploy-blocking). */
37
+ missingCritical: { key: string; targets: VercelTarget[] }[]
38
+ }
39
+
40
+ /** Production and preview are the deploy-blocking targets; development is local-only. */
41
+ const DEPLOY_TARGETS: VercelTarget[] = ['production', 'preview']
42
+
43
+ export function buildEnvMatrix(
44
+ envVars: VercelEnvVar[],
45
+ requiredKeys: readonly string[],
46
+ optionalKeys: readonly string[] = [],
47
+ ): EnvMatrix {
48
+ const rows: EnvMatrixRow[] = []
49
+ const seen = new Set<string>()
50
+ const missingCritical: { key: string; targets: VercelTarget[] }[] = []
51
+
52
+ for (const key of requiredKeys) {
53
+ seen.add(key)
54
+ const presence = presenceForKey(envVars, key)
55
+ rows.push({ key, required: true, presence })
56
+ const missing = DEPLOY_TARGETS.filter((t) => !presence[t])
57
+ if (missing.length > 0) missingCritical.push({ key, targets: missing })
58
+ }
59
+
60
+ for (const key of optionalKeys) {
61
+ if (seen.has(key)) continue
62
+ seen.add(key)
63
+ rows.push({ key, required: false, presence: presenceForKey(envVars, key) })
64
+ }
65
+
66
+ return { rows, missingCritical }
67
+ }
68
+
69
+ export type BlobLinkStatus = 'ok' | 'partial' | 'none'
70
+
71
+ export interface BlobLinkReport {
72
+ /** True when any BLOB_* var indicates a store is connected to the project. */
73
+ linked: boolean
74
+ tokenByTarget: TargetPresence
75
+ /** Targets (any) where BLOB_READ_WRITE_TOKEN is absent while linked. */
76
+ missingTargets: VercelTarget[]
77
+ /** 'partial' when production/preview lack the token (deploy-blocking). */
78
+ status: BlobLinkStatus
79
+ }
80
+
81
+ const BLOB_LINK_SIGNALS = ['BLOB_STORE_ID', 'BLOB_WEBHOOK_PUBLIC_KEY', 'BLOB_READ_WRITE_TOKEN']
82
+
83
+ export function evaluateBlobLink(envVars: VercelEnvVar[]): BlobLinkReport {
84
+ const linked = envVars.some(
85
+ (env) => BLOB_LINK_SIGNALS.includes(env.key) || env.key.startsWith('BLOB_'),
86
+ )
87
+ const tokenByTarget = presenceForKey(envVars, 'BLOB_READ_WRITE_TOKEN')
88
+
89
+ if (!linked) {
90
+ return { linked: false, tokenByTarget, missingTargets: [], status: 'none' }
91
+ }
92
+
93
+ const missingTargets = VERCEL_TARGETS.filter((t) => !tokenByTarget[t])
94
+ const criticalMissing = DEPLOY_TARGETS.some((t) => !tokenByTarget[t])
95
+ return {
96
+ linked: true,
97
+ tokenByTarget,
98
+ missingTargets,
99
+ status: criticalMissing ? 'partial' : 'ok',
100
+ }
101
+ }