diff --git a/apps/Next_test_app/.holo-js/framework/run.mjs b/apps/Next_test_app/.holo-js/framework/run.mjs new file mode 100644 index 0000000..05b8b51 --- /dev/null +++ b/apps/Next_test_app/.holo-js/framework/run.mjs @@ -0,0 +1,257 @@ +import { existsSync, readFileSync, readlinkSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execFileSync, spawn } from 'node:child_process' + +const mode = process.argv[2] +const manifestPath = fileURLToPath(new URL('./project.json', import.meta.url)) +const projectRoot = resolve(dirname(manifestPath), '../..') +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +const framework = String(manifest.framework ?? '') +const commandName = "next" +const commandArgs = mode === 'dev' + ? ['dev'] + : mode === 'build' + ? framework === 'sveltekit' ? ['build', '--logLevel', 'error'] : ['build'] + : undefined + +if (!commandArgs) { + console.error(`[holo] Unknown framework runner mode: ${String(mode)}`) + process.exit(1) +} + +const binaryPath = resolve( + projectRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? `${commandName}.cmd` : commandName, +) + +const suppressedOutput = framework === 'sveltekit' + ? new Set([ + '"try_get_request_store" is imported from external module "@sveltejs/kit/internal/server" but never used in ".svelte-kit/adapter-node/index.js".', + ]) + : new Set() + +function pipeOutput(stream, target, onLine) { + if (!stream) { + return + } + + let buffered = '' + stream.on('data', (chunk) => { + buffered += chunk.toString() + const lines = buffered.split(/\r?\n/) + buffered = lines.pop() ?? '' + for (const line of lines) { + onLine?.(line) + if (!suppressedOutput.has(line)) { + target.write(`${line}\n`) + } + } + }) + + stream.on('end', () => { + if (buffered.length > 0) { + onLine?.(buffered) + } + if (buffered.length > 0 && !suppressedOutput.has(buffered)) { + target.write(buffered) + } + }) +} + +function extractNextConflictInfo(lines) { + if (framework !== 'next' || mode !== 'dev') { + return undefined + } + + if (!lines.some(line => line.includes('Another next dev server is already running.'))) { + return undefined + } + + let pid + let dir + + for (const line of lines) { + const match = line.match(/^- PID:\s+(\d+)\s*$/) + if (match) { + pid = Number.parseInt(match[1], 10) + continue + } + + const dirMatch = line.match(/^- Dir:\s+(.+?)\s*$/) + if (dirMatch) { + dir = dirMatch[1] + } + } + + return typeof pid === 'number' ? { pid, dir } : undefined +} + +async function waitForProcessExit(pid, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + process.kill(pid, 0) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH') { + return true + } + throw error + } + + await new Promise(resolve => setTimeout(resolve, 100)) + } + + return false +} + +function inspectProcess(pid) { + try { + if (process.platform === 'linux' && existsSync(`/proc/${pid}`)) { + return { + cwd: readlinkSync(`/proc/${pid}/cwd`), + args: readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\u0000', ' ').trim(), + } + } + } catch {} + + try { + return { + args: execFileSync('ps', ['-p', String(pid), '-o', 'args='], { + encoding: 'utf8', + }).trim(), + } + } catch { + return undefined + } +} + +function isOwnedNextDevServer(pid, reportedDir) { + const expectedDir = typeof reportedDir === 'string' ? resolve(reportedDir) : undefined + if (expectedDir && expectedDir !== projectRoot) { + return false + } + + const details = inspectProcess(pid) + if (!details) { + return expectedDir === projectRoot + } + + const argsMatch = details.args.includes('next') && details.args.includes('dev') + const cwdMatches = typeof details.cwd === 'string' && resolve(details.cwd) === projectRoot + const argsReferenceProject = details.args.includes(projectRoot) + + return argsMatch && (cwdMatches || argsReferenceProject || expectedDir === projectRoot) +} + +async function stopStaleNextDevServer(pid, reportedDir) { + if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) { + return false + } + + if (!isOwnedNextDevServer(pid, reportedDir)) { + return false + } + + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH') { + return true + } + return false + } + + return waitForProcessExit(pid) +} + +if (!existsSync(binaryPath)) { + console.error(`[holo] Missing framework binary "${commandName}" for "${framework}". Run your package manager install first.`) + process.exit(1) +} + +let child = null +let forwardedSignal = null + +function detachSignalForwarders() { + process.removeListener('SIGINT', onSigint) + process.removeListener('SIGTERM', onSigterm) +} + +function forwardSignal(signal) { + if (forwardedSignal || !child || child.exitCode !== null) { + return + } + + forwardedSignal = signal + child.kill(signal) +} + +function onSigint() { + detachSignalForwarders() + forwardSignal('SIGINT') +} + +function onSigterm() { + detachSignalForwarders() + forwardSignal('SIGTERM') +} + +process.on('SIGINT', onSigint) +process.on('SIGTERM', onSigterm) + +async function run() { + let restartedAfterConflict = false + const maxStderrLines = 200 + + while (true) { + const stderrLines = [] + child = spawn(binaryPath, commandArgs, { + cwd: projectRoot, + env: process.env, + stdio: ['inherit', 'pipe', 'pipe'], + }) + forwardedSignal = null + + pipeOutput(child.stdout, process.stdout) + pipeOutput(child.stderr, process.stderr, line => { + if (stderrLines.length >= maxStderrLines) { + stderrLines.shift() + } + stderrLines.push(line) + }) + + const result = await new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal })) + }) + + if (result.code === 0) { + process.exit(0) + } + + const conflictInfo = extractNextConflictInfo(stderrLines) + if (!restartedAfterConflict && conflictInfo) { + const stopped = await stopStaleNextDevServer(conflictInfo.pid, conflictInfo.dir) + if (stopped) { + restartedAfterConflict = true + console.error(`[holo] Stopped stale Next dev server ${conflictInfo.pid}. Restarting dev server.`) + continue + } + } + + if (result.signal) { + detachSignalForwarders() + process.kill(process.pid, result.signal) + } else { + process.exit(result.code ?? 1) + } + } +} + +run().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/apps/blog-next/server/models/Category.ts b/apps/blog-next/server/models/Category.ts new file mode 100644 index 0000000..0387ef6 --- /dev/null +++ b/apps/blog-next/server/models/Category.ts @@ -0,0 +1,7 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('categories', { + fillable: ['name', 'slug', 'description'], +}) diff --git a/apps/blog-next/server/models/Post.ts b/apps/blog-next/server/models/Post.ts new file mode 100644 index 0000000..99acfe4 --- /dev/null +++ b/apps/blog-next/server/models/Post.ts @@ -0,0 +1,20 @@ +import '../db/schema.generated' + +import { belongsTo, belongsToMany, defineModel } from '@holo-js/db' + +import Category from './Category' +import Tag from './Tag' + +const relations = { + category: belongsTo(() => Category, { foreignKey: 'category_id' }), + tags: belongsToMany(() => Tag, { + pivotTable: 'post_tags', + foreignPivotKey: 'post_id', + relatedPivotKey: 'tag_id', + }), +} + +export default defineModel('posts', { + fillable: ['title', 'slug', 'excerpt', 'body', 'status', 'published_at', 'user_id', 'category_id'], + relations, +}) diff --git a/apps/blog-next/server/models/Tag.ts b/apps/blog-next/server/models/Tag.ts new file mode 100644 index 0000000..de9a9f2 --- /dev/null +++ b/apps/blog-next/server/models/Tag.ts @@ -0,0 +1,7 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('tags', { + fillable: ['name', 'slug'], +}) diff --git a/apps/blog-next/server/models/User.ts b/apps/blog-next/server/models/User.ts new file mode 100644 index 0000000..48224a3 --- /dev/null +++ b/apps/blog-next/server/models/User.ts @@ -0,0 +1,8 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('users', { + fillable: ['name', 'email', 'password', 'avatar', 'email_verified_at'], + hidden: ['password'], +}) diff --git a/apps/blog-nuxt/server/models/Category.ts b/apps/blog-nuxt/server/models/Category.ts new file mode 100644 index 0000000..0387ef6 --- /dev/null +++ b/apps/blog-nuxt/server/models/Category.ts @@ -0,0 +1,7 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('categories', { + fillable: ['name', 'slug', 'description'], +}) diff --git a/apps/blog-nuxt/server/models/Post.ts b/apps/blog-nuxt/server/models/Post.ts new file mode 100644 index 0000000..99acfe4 --- /dev/null +++ b/apps/blog-nuxt/server/models/Post.ts @@ -0,0 +1,20 @@ +import '../db/schema.generated' + +import { belongsTo, belongsToMany, defineModel } from '@holo-js/db' + +import Category from './Category' +import Tag from './Tag' + +const relations = { + category: belongsTo(() => Category, { foreignKey: 'category_id' }), + tags: belongsToMany(() => Tag, { + pivotTable: 'post_tags', + foreignPivotKey: 'post_id', + relatedPivotKey: 'tag_id', + }), +} + +export default defineModel('posts', { + fillable: ['title', 'slug', 'excerpt', 'body', 'status', 'published_at', 'user_id', 'category_id'], + relations, +}) diff --git a/apps/blog-nuxt/server/models/Tag.ts b/apps/blog-nuxt/server/models/Tag.ts new file mode 100644 index 0000000..de9a9f2 --- /dev/null +++ b/apps/blog-nuxt/server/models/Tag.ts @@ -0,0 +1,7 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('tags', { + fillable: ['name', 'slug'], +}) diff --git a/apps/blog-nuxt/server/models/User.ts b/apps/blog-nuxt/server/models/User.ts new file mode 100644 index 0000000..48224a3 --- /dev/null +++ b/apps/blog-nuxt/server/models/User.ts @@ -0,0 +1,8 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('users', { + fillable: ['name', 'email', 'password', 'avatar', 'email_verified_at'], + hidden: ['password'], +}) diff --git a/apps/blog-sveltekit/server/models/Category.ts b/apps/blog-sveltekit/server/models/Category.ts new file mode 100644 index 0000000..0387ef6 --- /dev/null +++ b/apps/blog-sveltekit/server/models/Category.ts @@ -0,0 +1,7 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('categories', { + fillable: ['name', 'slug', 'description'], +}) diff --git a/apps/blog-sveltekit/server/models/Post.ts b/apps/blog-sveltekit/server/models/Post.ts new file mode 100644 index 0000000..99acfe4 --- /dev/null +++ b/apps/blog-sveltekit/server/models/Post.ts @@ -0,0 +1,20 @@ +import '../db/schema.generated' + +import { belongsTo, belongsToMany, defineModel } from '@holo-js/db' + +import Category from './Category' +import Tag from './Tag' + +const relations = { + category: belongsTo(() => Category, { foreignKey: 'category_id' }), + tags: belongsToMany(() => Tag, { + pivotTable: 'post_tags', + foreignPivotKey: 'post_id', + relatedPivotKey: 'tag_id', + }), +} + +export default defineModel('posts', { + fillable: ['title', 'slug', 'excerpt', 'body', 'status', 'published_at', 'user_id', 'category_id'], + relations, +}) diff --git a/apps/blog-sveltekit/server/models/Tag.ts b/apps/blog-sveltekit/server/models/Tag.ts new file mode 100644 index 0000000..de9a9f2 --- /dev/null +++ b/apps/blog-sveltekit/server/models/Tag.ts @@ -0,0 +1,7 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('tags', { + fillable: ['name', 'slug'], +}) diff --git a/apps/blog-sveltekit/server/models/User.ts b/apps/blog-sveltekit/server/models/User.ts new file mode 100644 index 0000000..48224a3 --- /dev/null +++ b/apps/blog-sveltekit/server/models/User.ts @@ -0,0 +1,8 @@ +import '../db/schema.generated' + +import { defineModel } from '@holo-js/db' + +export default defineModel('users', { + fillable: ['name', 'email', 'password', 'avatar', 'email_verified_at'], + hidden: ['password'], +}) diff --git a/packages/cli/src/project/registry.ts b/packages/cli/src/project/registry.ts index f993d3d..510c0e4 100644 --- a/packages/cli/src/project/registry.ts +++ b/packages/cli/src/project/registry.ts @@ -354,11 +354,15 @@ async function ensureSvelteManagedHooks(projectRoot: string): Promise { if (legacyUserContents && (!hooksContents || isManagedPrepareArtifact(hooksContents))) { await writeFileIfChanged(hooksPath, legacyUserContents) await unlinkIfPresent(legacyHooksUserPath) + } else if (legacyUserContents && hooksContents === legacyUserContents) { + await unlinkIfPresent(legacyHooksUserPath) } if (legacyServerUserContents && (!hooksServerContents || isManagedPrepareArtifact(hooksServerContents))) { await writeFileIfChanged(hooksServerPath, legacyServerUserContents) await unlinkIfPresent(legacyHooksServerUserPath) + } else if (legacyServerUserContents && hooksServerContents === legacyServerUserContents) { + await unlinkIfPresent(legacyHooksServerUserPath) } // If the current src/hooks.ts is a legacy Holo-managed artifact or doesn't exist, diff --git a/packages/cli/src/project/scaffold.ts b/packages/cli/src/project/scaffold.ts index 5a3070e..db3b7ee 100644 --- a/packages/cli/src/project/scaffold.ts +++ b/packages/cli/src/project/scaffold.ts @@ -1,2253 +1,122 @@ -import { appendFile, mkdir, readdir, writeFile } from 'node:fs/promises' +import { mkdir, readdir } from 'node:fs/promises' import { extname, resolve } from 'node:path' +import { loadConfigDirectory } from '@holo-js/config' import { - loadConfigDirectory, - holoStorageDefaults, - type SupportedDatabaseDriver, -} from '@holo-js/config' + loadProjectConfig, + resolveGeneratedSchemaPath, +} from './config' import { - normalizeHoloProjectConfig, - renderGeneratedSchemaPlaceholder, - createMigrationFileName, -} from '@holo-js/db' -import { - ESBUILD_PACKAGE_VERSION, - HOLO_PACKAGE_VERSION, - SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS, - SCAFFOLD_FRAMEWORK_RUNTIME_VERSIONS, - SCAFFOLD_FRAMEWORK_VERSIONS, - SCAFFOLD_PACKAGE_MANAGER_VERSIONS, -} from '../metadata' -import { loadProjectConfig, resolveGeneratedSchemaPath } from './config' -import { - AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES, - AUTH_CONFIG_FILE_NAMES, - BROADCAST_CONFIG_FILE_NAMES, - CACHE_CONFIG_FILE_NAMES, - type BroadcastInstallResult, - type CacheInstallResult, - type GeneratedProjectRegistry, - MAIL_CONFIG_FILE_NAMES, - DB_DRIVER_PACKAGE_NAMES, - NOTIFICATIONS_CONFIG_FILE_NAMES, - QUEUE_CONFIG_FILE_NAMES, - REDIS_CONFIG_FILE_NAMES, - SECURITY_CONFIG_FILE_NAMES, - SESSION_CONFIG_FILE_NAMES, - SUPPORTED_AUTH_SOCIAL_PROVIDERS, - type AuthorizationInstallResult, - type AuthInstallResult, - type EventsInstallResult, - type MailInstallResult, - type NotificationsInstallResult, - type ProjectScaffoldOptions, - type QueueInstallResult, - type SupportedCacheInstallerDriver, - type SecurityInstallResult, - type SupportedAuthSocialProvider, - type SupportedQueueInstallerDriver, - type SupportedScaffoldPackageManager, - isSupportedCacheInstallerDriver, - isSupportedQueueInstallerDriver, - normalizeScaffoldOptionalPackages, - pathExists, - sanitizePackageName, -} from './shared' -import { - readTextFile, - resolveFirstExistingPath, - writeTextFile, -} from './runtime' -import { loadGeneratedProjectRegistry } from './registry' -import { relativeImportPath } from '../templates' - -type ScaffoldedFile = { - readonly path: string - readonly contents: string -} - -type AuthInstallFeatures = { - readonly social?: boolean - readonly socialProviders?: readonly SupportedAuthSocialProvider[] - readonly workos?: boolean - readonly clerk?: boolean -} - -type LoadedConfigWithCache = Awaited> & { - readonly cache: { - readonly drivers: Readonly> - } - readonly redis: { - readonly default: string - } -} - -type ConfigModuleFormat = 'esm' | 'cjs' -const IOREDIS_PACKAGE_VERSION = '^5.4.2' - -const AUTH_MIGRATION_SLUGS = [ - 'create_users', - 'create_sessions', - 'create_auth_identities', - 'create_personal_access_tokens', - 'create_password_reset_tokens', - 'create_email_verification_tokens', -] as const - -type AuthMigrationSlug = typeof AUTH_MIGRATION_SLUGS[number] - -function renderStorageConfig(): string { - return [ - 'import { defineStorageConfig, env } from \'@holo-js/config\'', - '', - 'export default defineStorageConfig({', - ` defaultDisk: env('STORAGE_DEFAULT_DISK', '${holoStorageDefaults.defaultDisk}'),`, - ` routePrefix: env('STORAGE_ROUTE_PREFIX', '${holoStorageDefaults.routePrefix}'),`, - ' disks: {', - ' local: {', - ' driver: \'local\',', - ' root: \'./storage/app\',', - ' },', - ' public: {', - ' driver: \'public\',', - ' root: \'./storage/app/public\',', - ' visibility: \'public\',', - ' },', - ' },', - '})', - '', - ].join('\n') -} - -function renderMediaConfig(): string { - return [ - 'import { defineMediaConfig } from \'@holo-js/config\'', - '', - 'export default defineMediaConfig({})', - '', - ].join('\n') -} - -function renderQueueConfig( - options: { - readonly driver?: SupportedQueueInstallerDriver - readonly defaultDatabaseConnection?: string - } = {}, -): string { - const driver = options.driver ?? 'sync' - const defaultDatabaseConnection = options.defaultDatabaseConnection?.trim() || 'default' - - if (driver === 'redis') { - return [ - 'import { defineQueueConfig, env } from \'@holo-js/config\'', - '', - 'export default defineQueueConfig({', - ' default: \'redis\',', - ' failed: false,', - ' connections: {', - ' redis: {', - ' driver: \'redis\',', - ' connection: \'default\',', - ' queue: \'default\',', - ' retryAfter: 90,', - ' blockFor: 5,', - ' },', - ' },', - '})', - '', - ].join('\n') - } - - if (driver === 'database') { - return [ - 'import { defineQueueConfig } from \'@holo-js/config\'', - '', - 'export default defineQueueConfig({', - ' default: \'database\',', - ' failed: {', - ' driver: \'database\',', - ` connection: '${defaultDatabaseConnection}',`, - ' table: \'failed_jobs\',', - ' },', - ' connections: {', - ' database: {', - ' driver: \'database\',', - ` connection: '${defaultDatabaseConnection}',`, - ' table: \'jobs\',', - ' queue: \'default\',', - ' retryAfter: 90,', - ' sleep: 1,', - ' },', - ' },', - '})', - '', - ].join('\n') - } - - return [ - 'import { defineQueueConfig } from \'@holo-js/config\'', - '', - 'export default defineQueueConfig({', - ' default: \'sync\',', - ' failed: false,', - ' connections: {', - ' sync: {', - ' driver: \'sync\',', - ' queue: \'default\',', - ' },', - ' },', - '})', - '', - ].join('\n') -} - -function renderCacheConfig( - driver: SupportedCacheInstallerDriver = 'file', - defaultDatabaseConnection = 'default', - defaultRedisConnection = 'default', -): string { - const lines = [ - 'import { defineCacheConfig, env } from \'@holo-js/config\'', - '', - 'export default defineCacheConfig({', - ` default: '${driver}',`, - ' prefix: env(\'CACHE_PREFIX\', \'\'),', - ' drivers: {', - ' file: {', - ' driver: \'file\',', - ' path: \'./storage/framework/cache/data\',', - ' },', - ' memory: {', - ' driver: \'memory\',', - ' maxEntries: 1000,', - ' },', - ] - - if (driver === 'redis') { - lines.push( - ' redis: {', - ' driver: \'redis\',', - ` connection: '${defaultRedisConnection}',`, - ' prefix: \'cache:\',', - ' },', - ) - } - - if (driver === 'database') { - lines.push( - ' database: {', - ' driver: \'database\',', - ` connection: '${defaultDatabaseConnection}',`, - ' table: \'cache\',', - ' lockTable: \'cache_locks\',', - ' },', - ) - } - - lines.push( - ' },', - '})', - '', - ) - - return lines.join('\n') -} - -function renderRedisConfig(): string { - return [ - 'import { defineRedisConfig, env } from \'@holo-js/config\'', - '', - 'export default defineRedisConfig({', - ' default: \'default\',', - ' connections: {', - ' default: {', - ' url: env(\'REDIS_URL\') || undefined,', - ' host: env(\'REDIS_HOST\', \'127.0.0.1\'),', - ' port: env(\'REDIS_PORT\', 6379),', - ' username: env(\'REDIS_USERNAME\'),', - ' password: env(\'REDIS_PASSWORD\'),', - ' db: env(\'REDIS_DB\', 0),', - ' },', - ' },', - '})', - '', - ].join('\n') -} - -async function ensureRedisConfigFile(projectRoot: string): Promise { - const redisConfigPath = await resolveFirstExistingPath(projectRoot, REDIS_CONFIG_FILE_NAMES) ?? resolve(projectRoot, 'config/redis.ts') - const redisConfigExists = await pathExists(redisConfigPath) - - if (!redisConfigExists) { - await writeTextFile(redisConfigPath, renderRedisConfig()) - } - - return !redisConfigExists -} - -function renderNotificationsConfig(): string { - return [ - 'import { defineNotificationsConfig } from \'@holo-js/config\'', - '', - 'export default defineNotificationsConfig({', - ' table: \'notifications\',', - ' queue: {', - ' afterCommit: false,', - ' },', - '})', - '', - ].join('\n') -} - -function renderMailConfig(): string { - return [ - 'import { defineMailConfig, env } from \'@holo-js/config\'', - '', - 'export default defineMailConfig({', - ' default: env(\'MAIL_MAILER\', \'preview\'),', - ' from: {', - ' email: env(\'MAIL_FROM_ADDRESS\', \'hello@app.test\'),', - ' name: env(\'MAIL_FROM_NAME\', \'Holo App\'),', - ' },', - ' preview: {', - ' allowedEnvironments: [\'development\'],', - ' },', - ' mailers: {', - ' preview: {', - ' driver: \'preview\',', - ' },', - ' log: {', - ' driver: \'log\',', - ' },', - ' fake: {', - ' driver: \'fake\',', - ' },', - ' smtp: {', - ' driver: \'smtp\',', - ' host: env(\'MAIL_HOST\', \'127.0.0.1\'),', - ' port: env(\'MAIL_PORT\', 1025),', - ' secure: env(\'MAIL_SECURE\', false),', - ' },', - ' },', - '})', - '', - ].join('\n') -} - -function renderSecurityConfig(): string { - return [ - `import { defineSecurityConfig, limit } from '@holo-js/security'`, - '', - 'export default defineSecurityConfig({', - ' csrf: {', - ' enabled: true,', - ' field: \'_token\',', - ' header: \'X-CSRF-TOKEN\',', - ' cookie: \'XSRF-TOKEN\',', - ' except: [],', - ' },', - ' rateLimit: {', - ' driver: \'file\',', - ' file: {', - ' path: \'./storage/framework/rate-limits\',', - ' },', - ' redis: {', - ' connection: \'default\',', - ' prefix: \'holo:rate-limit:\',', - ' },', - ' limiters: {', - ' login: limit.perMinute(5).define(),', - ' register: limit.perHour(10).define(),', - ' },', - ' },', - '})', - '', - ].join('\n') -} - -async function ensureRateLimitStorageIgnore(projectRoot: string): Promise { - const rateLimitRoot = resolve(projectRoot, 'storage/framework/rate-limits') - const ignorePath = resolve(rateLimitRoot, '.gitignore') - await mkdir(rateLimitRoot, { recursive: true }) - - if (!(await pathExists(ignorePath))) { - await writeTextFile(ignorePath, '*\n!.gitignore\n') - return - } - - const currentContents = (await readTextFile(ignorePath)) ?? '' - const existingLines = new Set(currentContents.split(/\r?\n/)) - const missingLines = [ - '*', - '!.gitignore', - ].filter(line => !existingLines.has(line)) - - if (missingLines.length === 0) { - return - } - - await appendFile( - ignorePath, - `${currentContents.length > 0 && !currentContents.endsWith('\n') ? '\n' : ''}${missingLines.join('\n')}\n`, - 'utf8', - ) -} - -function renderBroadcastConfig( - moduleFormat: ConfigModuleFormat, - includeAuthEndpoint: boolean, - useTypeScriptSyntax: boolean, -): string { - const renderBroadcastScheme = (): string => { - return useTypeScriptSyntax - ? "env('BROADCAST_SCHEME') === 'https' ? 'https' : 'http'" - : "(process.env.BROADCAST_SCHEME === 'https' ? 'https' : 'http')" - } - - if (moduleFormat === 'cjs') { - return [ - 'const { defineBroadcastConfig, env } = require(\'@holo-js/config\')', - '', - `const broadcastScheme = ${renderBroadcastScheme()}`, - '', - 'module.exports = defineBroadcastConfig({', - ' default: env(\'BROADCAST_CONNECTION\', \'holo\'),', - ' connections: {', - ' holo: {', - ' driver: \'holo\',', - ' appId: env(\'BROADCAST_APP_ID\', \'app-id\'),', - ' key: env(\'BROADCAST_APP_KEY\', \'app-key\'),', - ' secret: env(\'BROADCAST_APP_SECRET\', \'app-secret\'),', - ' options: {', - ' host: env(\'BROADCAST_HOST\', \'127.0.0.1\'),', - ' port: env(\'BROADCAST_PORT\', 8080),', - ' scheme: broadcastScheme,', - ' useTLS: broadcastScheme === \'https\',', - ' },', - /* v8 ignore next 6 -- authEndpoint injection is tested through the install auth flow */ - ...(includeAuthEndpoint - ? [ - ' clientOptions: {', - ' authEndpoint: `${env(\'APP_URL\', \'http://localhost:3000\')}/broadcasting/auth`,', - ' },', - ] - : []), - ' },', - ' log: {', - ' driver: \'log\',', - ' },', - ' null: {', - ' driver: \'null\',', - ' },', - ' },', - '})', - '', - ].join('\n') - } - - return [ - 'import { defineBroadcastConfig, env } from \'@holo-js/config\'', - '', - `const broadcastScheme = ${renderBroadcastScheme()}`, - '', - 'export default defineBroadcastConfig({', - ' default: env(\'BROADCAST_CONNECTION\', \'holo\'),', - ' connections: {', - ' holo: {', - ' driver: \'holo\',', - ' appId: env(\'BROADCAST_APP_ID\', \'app-id\'),', - ' key: env(\'BROADCAST_APP_KEY\', \'app-key\'),', - ' secret: env(\'BROADCAST_APP_SECRET\', \'app-secret\'),', - ' options: {', - ' host: env(\'BROADCAST_HOST\', \'127.0.0.1\'),', - ' port: env(\'BROADCAST_PORT\', 8080),', - ' scheme: broadcastScheme,', - ' useTLS: broadcastScheme === \'https\',', - ' },', - ...(includeAuthEndpoint - ? [ - ' clientOptions: {', - ' authEndpoint: `${env(\'APP_URL\', \'http://localhost:3000\')}/broadcasting/auth`,', - ' },', - ] - : []), - ' },', - ' log: {', - ' driver: \'log\',', - ' },', - ' null: {', - ' driver: \'null\',', - ' },', - ' },', - '})', - '', - ].join('\n') -} - -function stripBroadcastAuthEndpointBlock(value: string): string { - return value.replace( - /(^|\n)\s*clientOptions:\s*\{\n\s*authEndpoint:\s*.*,\n\s*\},/m, - '', - ) -} - -function injectBroadcastAuthEndpoint(value: string): string | undefined { - /* v8 ignore next 3 -- defensive early return when authEndpoint already exists */ - if (value.includes('authEndpoint:')) { - return value - } - - const nextValue = value.replace( - /(holo:\s*\{[\s\S]*?options:\s*\{[\s\S]*?\n)([ \t]*)\},/m, - (_match, prefix: string, indent: string) => { - return [ - `${prefix}${indent}},`, - `${indent}clientOptions: {`, - `${indent} authEndpoint: \`\${env('APP_URL', 'http://localhost:3000')}/broadcasting/auth\`,`, - `${indent}},`, - ].join('\n') - }, - ) - - return nextValue === value ? undefined : nextValue -} - -function canSafelyRewriteBroadcastConfig( - currentContents: string, - moduleFormat: ConfigModuleFormat, - useTypeScriptSyntax: boolean, -): boolean { - return stripBroadcastAuthEndpointBlock(currentContents) === stripBroadcastAuthEndpointBlock( - renderBroadcastConfig(moduleFormat, false, useTypeScriptSyntax), - ) -} - -function resolveBroadcastConfigTargetPath( - projectRoot: string, - manifestPath: string, - moduleFormat: ConfigModuleFormat, -): string { - const extension = extname(manifestPath) - /* v8 ignore next 2 -- defensive extension matching for uncommon manifest file extensions */ - const targetExtension = extension === '.cjs' || extension === '.cts' || extension === '.mjs' || extension === '.mts' - ? extension - : moduleFormat === 'cjs' - ? '.cjs' - : (extension === '.ts' || extension === '.js' ? extension : '.ts') - - return resolve(projectRoot, `config/broadcast${targetExtension}`) -} - -function renderBroadcastEnvFiles(): { env: readonly string[], example: readonly string[] } { - const env = [ - 'BROADCAST_CONNECTION=holo', - ] - const example = [ - 'BROADCAST_CONNECTION=holo', - 'BROADCAST_APP_ID=', - 'BROADCAST_APP_KEY=', - 'BROADCAST_APP_SECRET=', - ] - - return { - env, - example, - } -} - -function renderNextBroadcastAuthRoute(): string { - return [ - 'import { renderBroadcastAuthResponse } from \'@holo-js/broadcast/auth\'', - 'import { holo } from \'@/server/holo\'', - '', - 'export async function POST(request: Request) {', - ' const app = await holo.getApp()', - ' const auth = await holo.getAuth()', - '', - ' return await renderBroadcastAuthResponse(request, {', - ' resolveUser: async () => await auth?.user(),', - ' channelAuth: {', - ' registry: {', - ' projectRoot: app.projectRoot,', - ' channels: app.registry?.channels ?? [],', - ' },', - ' },', - ' })', - '}', - '', - ].join('\n') -} - -function renderNuxtBroadcastAuthRoute(): string { - return [ - 'import { defineEventHandler, getHeaders, getRequestURL, readRawBody } from \'h3\'', - 'import { renderBroadcastAuthResponse } from \'@holo-js/broadcast/auth\'', - 'import { holo } from \'#imports\'', - '', - 'export default defineEventHandler(async (event) => {', - ' const app = await holo.getApp()', - ' const auth = await holo.getAuth()', - ' const headers = new Headers()', - ' for (const [key, value] of Object.entries(getHeaders(event))) {', - ' if (typeof value === \'string\') {', - ' headers.set(key, value)', - ' }', - ' }', - ' const request = new Request(getRequestURL(event), {', - ' method: event.method,', - ' headers,', - ' body: await readRawBody(event),', - ' })', - '', - ' return await renderBroadcastAuthResponse(request, {', - ' resolveUser: async () => await auth?.user(),', - ' channelAuth: {', - ' registry: {', - ' projectRoot: app.projectRoot,', - ' channels: app.registry?.channels ?? [],', - ' },', - ' },', - ' })', - '})', - '', - ].join('\n') -} - -function renderSvelteBroadcastAuthRoute(): string { - return [ - 'import { renderBroadcastAuthResponse } from \'@holo-js/broadcast/auth\'', - 'import { holo } from \'$lib/server/holo\'', - '', - 'export async function POST({ request }: { request: Request }) {', - ' const app = await holo.getApp()', - ' const auth = await holo.getAuth()', - '', - ' return await renderBroadcastAuthResponse(request, {', - ' resolveUser: async () => await auth?.user(),', - ' channelAuth: {', - ' registry: {', - ' projectRoot: app.projectRoot,', - ' channels: app.registry?.channels ?? [],', - ' },', - ' },', - ' })', - '}', - '', - ].join('\n') -} - -async function syncBroadcastAuthSupportAfterAuthInstall(projectRoot: string): Promise<{ - readonly updatedBroadcastConfig: boolean - readonly createdBroadcastAuthRoute: boolean -}> { - const { dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const framework = detectProjectFrameworkFromPackageJson(dependencies, devDependencies) - const canCreateBroadcastAuthRoute = framework === 'next' || framework === 'nuxt' || framework === 'sveltekit' - const authConfigPath = await resolveFirstExistingPath(projectRoot, AUTH_CONFIG_FILE_NAMES) - const broadcastConfigPath = await resolveFirstExistingPath(projectRoot, BROADCAST_CONFIG_FILE_NAMES) - if (!authConfigPath || !broadcastConfigPath || !canCreateBroadcastAuthRoute) { - return { - updatedBroadcastConfig: false, - createdBroadcastAuthRoute: false, - } - } - - const currentBroadcastConfig = (await readTextFile(broadcastConfigPath))! - let updatedBroadcastConfig = false - let createdBroadcastAuthRoute = false - if (!currentBroadcastConfig.includes('authEndpoint:')) { - const broadcastConfigModuleFormat = resolveConfigModuleFormat(broadcastConfigPath, currentBroadcastConfig) - const broadcastConfigIsTypeScript = ['.ts', '.mts', '.cts'].includes(extname(broadcastConfigPath)) - const rewrittenBroadcastConfig = canSafelyRewriteBroadcastConfig( - currentBroadcastConfig, - broadcastConfigModuleFormat, - broadcastConfigIsTypeScript, - ) - ? renderBroadcastConfig(broadcastConfigModuleFormat, true, broadcastConfigIsTypeScript) - : injectBroadcastAuthEndpoint(currentBroadcastConfig) - if (rewrittenBroadcastConfig) { - await writeTextFile( - broadcastConfigPath, - rewrittenBroadcastConfig, - ) - updatedBroadcastConfig = true - } - } - - if (framework === 'next') { - const authRoutePath = resolve(projectRoot, 'app/broadcasting/auth/route.ts') - if (!(await pathExists(authRoutePath))) { - await writeTextFile(authRoutePath, renderNextBroadcastAuthRoute()) - createdBroadcastAuthRoute = true - } - return { - updatedBroadcastConfig, - createdBroadcastAuthRoute, - } - } - - if (framework === 'nuxt') { - const authRoutePath = resolve(projectRoot, 'server/routes/broadcasting/auth.post.ts') - if (!(await pathExists(authRoutePath))) { - await writeTextFile(authRoutePath, renderNuxtBroadcastAuthRoute()) - createdBroadcastAuthRoute = true - } - return { - updatedBroadcastConfig, - createdBroadcastAuthRoute, - } - } - - if (framework === 'sveltekit') { - const authRoutePath = resolve(projectRoot, 'src/routes/broadcasting/auth/+server.ts') - if (!(await pathExists(authRoutePath))) { - await writeTextFile(authRoutePath, renderSvelteBroadcastAuthRoute()) - createdBroadcastAuthRoute = true - } - } - - return { - updatedBroadcastConfig, - createdBroadcastAuthRoute, - } -} - -function renderSessionConfig(defaultDatabaseConnection = 'default'): string { - return [ - 'import { defineSessionConfig, env } from \'@holo-js/config\'', - '', - "const sessionSameSite = env('SESSION_SAME_SITE') === 'strict'", - " ? 'strict'", - " : env('SESSION_SAME_SITE') === 'none'", - " ? 'none'", - " : 'lax'", - '', - 'export default defineSessionConfig({', - ' driver: env(\'SESSION_DRIVER\', \'file\'),', - ' stores: {', - ' database: {', - ' driver: \'database\',', - ` connection: env('SESSION_CONNECTION', '${defaultDatabaseConnection}'),`, - ' table: \'sessions\',', - ' },', - ' file: {', - ' driver: \'file\',', - ' path: \'./storage/framework/sessions\',', - ' },', - ' },', - ' cookie: {', - ' name: env(\'SESSION_COOKIE\', \'holo_session\'),', - ' path: env(\'SESSION_PATH\', \'/\'),', - ' domain: env(\'SESSION_DOMAIN\'),', - ' secure: env(\'SESSION_SECURE\', false),', - ' httpOnly: true,', - ' sameSite: sessionSameSite,', - ' },', - ' idleTimeout: env(\'SESSION_IDLE_TIMEOUT\', 120),', - ' absoluteLifetime: env(\'SESSION_LIFETIME\', 120),', - ' rememberMeLifetime: env(\'SESSION_REMEMBER_ME_LIFETIME\', 43200),', - '})', - '', - ].join('\n') -} - -function renderAuthConfig( - features: AuthInstallFeatures = {}, - moduleFormat: ConfigModuleFormat = 'esm', -): string { - const envValue = (name: string, fallback?: string): string => { - if (moduleFormat === 'cjs') { - return typeof fallback === 'string' - ? `process.env.${name} || ${JSON.stringify(fallback)}` - : `process.env.${name}` - } - - return typeof fallback === 'string' - ? `env('${name}', ${JSON.stringify(fallback)})` - : `env('${name}')` - } - const socialEnabled = features.social === true || (features.socialProviders?.length ?? 0) > 0 - const socialProviders = features.socialProviders && features.socialProviders.length > 0 - ? features.socialProviders - : socialEnabled - ? ['google'] - : [] - const lines = [ - moduleFormat === 'cjs' - ? 'module.exports = {' - : 'import { defineAuthConfig, env } from \'@holo-js/config\'', - '', - ...(moduleFormat === 'cjs' ? [] : ['export default defineAuthConfig({']), - ' defaults: {', - ' guard: \'web\',', - ' passwords: \'users\',', - ' },', - ' guards: {', - ' web: {', - ' driver: \'session\',', - ' provider: \'users\',', - ' },', - ' // admin: {', - ' // driver: \'session\',', - ' // provider: \'admins\',', - ' // },', - ' },', - ' providers: {', - ' users: {', - ' model: \'User\',', - ' identifiers: [\'email\'],', - ' },', - ' // admins: {', - ' // model: \'Admin\',', - ' // identifiers: [\'email\'],', - ' // },', - ' },', - ' passwords: {', - ' users: {', - ' provider: \'users\',', - ' table: \'password_reset_tokens\',', - ' expire: 60,', - ' throttle: 60,', - ' },', - ' },', - ' emailVerification: {', - ' required: false,', - ' },', - ' personalAccessTokens: {', - ' defaultAbilities: [],', - ' },', - ` socialEncryptionKey: ${envValue('AUTH_SOCIAL_ENCRYPTION_KEY')},`, - ] - - if (socialProviders.length > 0) { - lines.push(' social: {') - for (const provider of socialProviders) { - const upper = provider.toUpperCase() - const defaultScopes = provider === 'google' - ? ['openid', 'email', 'profile'] - : provider === 'github' - ? ['read:user', 'user:email'] - : provider === 'discord' - ? ['identify', 'email'] - : provider === 'facebook' - ? ['email', 'public_profile'] - : provider === 'apple' - ? ['name', 'email'] - : ['openid', 'profile', 'email'] - lines.push( - ` ${provider}: {`, - ` clientId: ${envValue(`AUTH_${upper}_CLIENT_ID`)},`, - ` clientSecret: ${envValue(`AUTH_${upper}_CLIENT_SECRET`)},`, - ` redirectUri: ${envValue(`AUTH_${upper}_REDIRECT_URI`)},`, - ` scopes: [${defaultScopes.map(scope => `'${scope}'`).join(', ')}],`, - ' },', - ) - } - lines.push(' },') - } - - if (features.workos) { - lines.push( - ' workos: {', - ' dashboard: {', - ` clientId: ${envValue('WORKOS_CLIENT_ID')},`, - ` apiKey: ${envValue('WORKOS_API_KEY')},`, - ` cookiePassword: ${envValue('WORKOS_COOKIE_PASSWORD')},`, - ` redirectUri: ${envValue('WORKOS_REDIRECT_URI')},`, - ` sessionCookie: ${envValue('WORKOS_SESSION_COOKIE', 'wos-session')},`, - ' },', - ' },', - ' // Add a dedicated guard and provider if WorkOS users should resolve through a different model.', - ) - } - - if (features.clerk) { - lines.push( - ' clerk: {', - ' app: {', - ` publishableKey: ${envValue('CLERK_PUBLISHABLE_KEY')},`, - ` secretKey: ${envValue('CLERK_SECRET_KEY')},`, - ` jwtKey: ${envValue('CLERK_JWT_KEY')},`, - ` apiUrl: ${envValue('CLERK_API_URL')},`, - ` frontendApi: ${envValue('CLERK_FRONTEND_API')},`, - ` sessionCookie: ${envValue('CLERK_SESSION_COOKIE', '__session')},`, - ' },', - ' },', - ' // Add a dedicated guard and provider if Clerk users should resolve through a different model.', - ) - } - - lines.push(moduleFormat === 'cjs' ? '}' : '})', '') - return lines.join('\n') -} - -function authFeaturesRequireConfigUpdate(features: AuthInstallFeatures): boolean { - return features.workos === true - || features.clerk === true - || features.social === true - || (features.socialProviders?.length ?? 0) > 0 -} - -function detectAuthInstallFeaturesFromConfig(contents: string): AuthInstallFeatures { - const socialProviders = SUPPORTED_AUTH_SOCIAL_PROVIDERS.filter(provider => { - const pattern = new RegExp(`\\b${provider}\\s*:\\s*\\{`) - return pattern.test(contents) - }) - - return Object.freeze({ - ...(socialProviders.length > 0 ? { social: true, socialProviders } : {}), - ...(contents.includes(' workos: {') ? { workos: true } : {}), - ...(contents.includes(' clerk: {') ? { clerk: true } : {}), - }) -} - -function mergeAuthInstallFeatures( - current: AuthInstallFeatures, - requested: AuthInstallFeatures, -): AuthInstallFeatures { - const socialProviders = Array.from(new Set([ - ...(current.socialProviders ?? []), - ...(requested.socialProviders ?? []), - ])) - - return Object.freeze({ - ...(current.social === true || requested.social === true || socialProviders.length > 0 - ? { social: true } - : {}), - ...(socialProviders.length > 0 ? { socialProviders } : {}), - ...(current.workos === true || requested.workos === true ? { workos: true } : {}), - ...(current.clerk === true || requested.clerk === true ? { clerk: true } : {}), - }) -} - -function canSafelyRewriteAuthConfig( - currentContents: string, - currentFeatures: AuthInstallFeatures, - moduleFormat: ConfigModuleFormat, -): boolean { - const stripLegacyCurrentUserEndpoint = (value: string): string => value.replace( - /(^|\n)\s*currentUserEndpoint:\s*\{\n\s*path:\s*.*,\n\s*\},/m, - '', - ) - - return stripLegacyCurrentUserEndpoint(currentContents) === stripLegacyCurrentUserEndpoint( - renderAuthConfig(currentFeatures, moduleFormat), - ) -} - -function resolveConfigModuleFormat( - filePath: string | undefined, - contents: string, -): ConfigModuleFormat { - if ( - filePath?.endsWith('.cjs') - || filePath?.endsWith('.cts') - || contents.includes('module.exports =') - ) { - return 'cjs' - } - - return 'esm' -} - -export function renderAuthEnvFiles( - features: AuthInstallFeatures = {}, - defaultDatabaseConnection = 'default', -): { env: readonly string[], example: readonly string[] } { - const socialEnabled = features.social === true || (features.socialProviders?.length ?? 0) > 0 - const socialProviders = features.socialProviders && features.socialProviders.length > 0 - ? features.socialProviders - : socialEnabled - ? ['google'] - : [] - const env = [ - 'AUTH_SOCIAL_ENCRYPTION_KEY=', - 'SESSION_DRIVER=file', - `SESSION_CONNECTION=${defaultDatabaseConnection}`, - 'SESSION_COOKIE=holo_session', - 'SESSION_PATH=/', - 'SESSION_DOMAIN=', - 'SESSION_SECURE=false', - 'SESSION_SAME_SITE=lax', - 'SESSION_IDLE_TIMEOUT=120', - 'SESSION_LIFETIME=120', - 'SESSION_REMEMBER_ME_LIFETIME=43200', - ] - - for (const provider of socialProviders) { - const upper = provider.toUpperCase() - env.push( - `AUTH_${upper}_CLIENT_ID=`, - `AUTH_${upper}_CLIENT_SECRET=`, - `AUTH_${upper}_REDIRECT_URI=`, - ) - } - - if (features.workos) { - env.push( - 'WORKOS_CLIENT_ID=', - 'WORKOS_API_KEY=', - 'WORKOS_COOKIE_PASSWORD=', - 'WORKOS_REDIRECT_URI=', - 'WORKOS_SESSION_COOKIE=wos-session', - ) - } - - if (features.clerk) { - env.push( - 'CLERK_PUBLISHABLE_KEY=', - 'CLERK_SECRET_KEY=', - 'CLERK_JWT_KEY=', - 'CLERK_API_URL=', - 'CLERK_FRONTEND_API=', - 'CLERK_SESSION_COOKIE=__session', - ) - } - - return { - env, - example: env.map(line => `${line.split('=')[0]}=`), - } -} - -function renderAuthUserModel(_generatedSchemaImportPath = '../db/schema.generated'): string { - return [ - 'import { defineModel } from \'@holo-js/db\'', - '', - 'export default defineModel(\'users\', {', - ' fillable: [\'name\', \'email\', \'password\', \'avatar\', \'email_verified_at\'],', - ' hidden: [\'password\'],', - '})', - '', - ].join('\n') -} - -function renderAuthorizationPoliciesReadme(): string { - return [ - '# Authorization Policies', - '', - 'Place policy files in this directory.', - 'Export `definePolicy(...)` definitions from `@holo-js/authorization`.', - '', - ].join('\n') -} - -function renderAuthorizationAbilitiesReadme(): string { - return [ - '# Authorization Abilities', - '', - 'Place ability files in this directory.', - 'Export `defineAbility(...)` definitions from `@holo-js/authorization`.', - '', - ].join('\n') -} - -function resolveAuthUserModelSchemaImportPath( - userModelPath: string, - generatedSchemaPath: string, -): string { - return relativeImportPath(userModelPath, generatedSchemaPath) -} - -function renderAuthMigration(slug: AuthMigrationSlug): string { - switch (slug) { - case 'create_users': - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ' await schema.createTable(\'users\', (table) => {', - ' table.id()', - ' table.string(\'name\')', - ' table.string(\'email\').unique()', - ' table.string(\'password\').nullable()', - ' table.string(\'avatar\').nullable()', - ' table.timestamp(\'email_verified_at\').nullable()', - ' table.timestamps()', - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ' await schema.dropTable(\'users\')', - ' },', - '})', - '', - ].join('\n') - case 'create_sessions': - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ' await schema.createTable(\'sessions\', (table) => {', - ' table.string(\'id\').primaryKey()', - ' table.string(\'store\').default(\'database\')', - ' table.json(\'data\').default({})', - ' table.timestamp(\'created_at\')', - ' table.timestamp(\'last_activity_at\')', - ' table.timestamp(\'expires_at\')', - ' table.timestamp(\'invalidated_at\').nullable()', - ' table.string(\'remember_token_hash\').nullable()', - ' table.index([\'expires_at\'])', - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ' await schema.dropTable(\'sessions\')', - ' },', - '})', - '', - ].join('\n') - case 'create_auth_identities': - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ' await schema.createTable(\'auth_identities\', (table) => {', - ' table.id()', - ' table.string(\'user_id\')', - ' table.string(\'guard\').default(\'web\')', - ' table.string(\'auth_provider\').default(\'users\')', - ' table.string(\'provider\')', - ' table.string(\'provider_user_id\')', - ' table.string(\'email\').nullable()', - ' table.boolean(\'email_verified\').default(false)', - ' table.json(\'profile\').default({})', - ' table.json(\'tokens\').default({})', - ' table.timestamps()', - ' table.index([\'user_id\'])', - ' table.unique([\'provider\', \'provider_user_id\'], \'auth_identities_provider_user_unique\')', - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ' await schema.dropTable(\'auth_identities\')', - ' },', - '})', - '', - ].join('\n') - case 'create_personal_access_tokens': - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ' await schema.createTable(\'personal_access_tokens\', (table) => {', - ' table.uuid(\'id\').primaryKey()', - ' table.string(\'provider\').default(\'users\')', - ' table.string(\'user_id\')', - ' table.string(\'name\')', - ' table.string(\'token_hash\').unique()', - ' table.json(\'abilities\').default([])', - ' table.timestamp(\'last_used_at\').nullable()', - ' table.timestamp(\'expires_at\').nullable()', - ' table.timestamps()', - ' table.index([\'provider\'])', - ' table.index([\'user_id\'])', - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ' await schema.dropTable(\'personal_access_tokens\')', - ' },', - '})', - '', - ].join('\n') - case 'create_password_reset_tokens': - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ' await schema.createTable(\'password_reset_tokens\', (table) => {', - ' table.uuid(\'id\').primaryKey()', - ' table.string(\'provider\').default(\'users\')', - ' table.string(\'email\')', - ' table.string(\'token_hash\')', - ' table.timestamp(\'expires_at\')', - ' table.timestamp(\'used_at\').nullable()', - ' table.timestamps()', - ' table.index([\'provider\'])', - ' table.index([\'email\'])', - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ' await schema.dropTable(\'password_reset_tokens\')', - ' },', - '})', - '', - ].join('\n') - case 'create_email_verification_tokens': - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ' await schema.createTable(\'email_verification_tokens\', (table) => {', - ' table.uuid(\'id\').primaryKey()', - ' table.string(\'provider\').default(\'users\')', - ' table.string(\'user_id\')', - ' table.string(\'email\')', - ' table.string(\'token_hash\')', - ' table.timestamp(\'expires_at\')', - ' table.timestamp(\'used_at\').nullable()', - ' table.timestamps()', - ' table.index([\'provider\'])', - ' table.index([\'user_id\'])', - ' table.index([\'email\'])', - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ' await schema.dropTable(\'email_verification_tokens\')', - ' },', - '})', - '', - ].join('\n') - } -} - -function createAuthMigrationFiles(date = new Date()): readonly ScaffoldedFile[] { - return AUTH_MIGRATION_SLUGS.map((slug, index) => ({ - path: createMigrationFileName(slug, new Date(date.getTime() + (index * 1000))), - contents: renderAuthMigration(slug), - })) -} - -function renderNotificationsMigration(): string { - return [ - 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', - '', - 'export default defineMigration({', - ' async up({ schema }: MigrationContext) {', - ' await schema.createTable(\'notifications\', (table) => {', - ' table.string(\'id\').primaryKey()', - ' table.string(\'type\').nullable()', - ' table.string(\'notifiable_type\')', - ' table.string(\'notifiable_id\')', - ' table.json(\'data\').default({})', - ' table.timestamp(\'read_at\').nullable()', - ' table.timestamp(\'created_at\')', - ' table.timestamp(\'updated_at\')', - ' table.index([\'notifiable_type\', \'notifiable_id\'])', - ' table.index([\'read_at\'])', - ' })', - ' },', - ' async down({ schema }: MigrationContext) {', - ' await schema.dropTable(\'notifications\')', - ' },', - '})', - '', - ].join('\n') -} - -function createNotificationsMigrationFiles(date = new Date()): readonly ScaffoldedFile[] { - return [{ - path: createMigrationFileName('create_notifications', date), - contents: renderNotificationsMigration(), - }] -} - -function renderScaffoldAppConfig(projectName: string): string { - return [ - 'import { defineAppConfig, env } from \'@holo-js/config\'', - '', - "const appEnv = env('APP_ENV') === 'production'", - " ? 'production'", - " : env('APP_ENV') === 'test'", - " ? 'test'", - " : 'development'", - '', - 'export default defineAppConfig({', - ` name: env('APP_NAME', ${JSON.stringify(projectName)}),`, - ' key: env(\'APP_KEY\'),', - ' url: env(\'APP_URL\', \'http://localhost:3000\'),', - ' env: appEnv,', - ' debug: env(\'APP_DEBUG\', true),', - ' paths: {', - ' models: \'server/models\',', - ' migrations: \'server/db/migrations\',', - ' seeders: \'server/db/seeders\',', - ' commands: \'server/commands\',', - ' jobs: \'server/jobs\',', - ' events: \'server/events\',', - ' listeners: \'server/listeners\',', - ' generatedSchema: \'server/db/schema.generated.ts\',', - ' },', - '})', - '', - ].join('\n') -} - -function renderScaffoldDatabaseConfig( - options: Pick, -): string { - const packageName = sanitizePackageName(options.projectName) || 'holo-app' - - if (options.databaseDriver === 'sqlite') { - return [ - 'import { defineDatabaseConfig, env } from \'@holo-js/config\'', - '', - 'export default defineDatabaseConfig({', - ' defaultConnection: \'main\',', - ' connections: {', - ' main: {', - ' driver: \'sqlite\',', - ' url: env(\'DB_URL\', \'./storage/database.sqlite\'),', - ' },', - ' },', - '})', - '', - ].join('\n') - } - - const port = options.databaseDriver === 'mysql' ? '3306' : '5432' - const username = options.databaseDriver === 'mysql' ? 'root' : 'postgres' - const schemaLine = options.databaseDriver === 'postgres' - ? ' schema: env(\'DB_SCHEMA\', \'public\'),' - : undefined - - return [ - 'import { defineDatabaseConfig, env } from \'@holo-js/config\'', - '', - 'export default defineDatabaseConfig({', - ' defaultConnection: \'main\',', - ' connections: {', - ' main: {', - ` driver: '${options.databaseDriver}',`, - ' host: env(\'DB_HOST\', \'127.0.0.1\'),', - ` port: env('DB_PORT', '${port}'),`, - ` username: env('DB_USERNAME', '${username}'),`, - ' password: env(\'DB_PASSWORD\'),', - ` database: env('DB_DATABASE', '${packageName}'),`, - ...(schemaLine ? [schemaLine] : []), - ' },', - ' },', - '})', - '', - ].join('\n') -} - -function renderScaffoldEnvFiles( - options: Pick, -): { env: string, example: string } { - const defaultDatabaseConnection = 'main' - const baseLines = [ - 'APP_NAME=', - 'APP_KEY=', - 'APP_URL=http://localhost:3000', - 'APP_ENV=development', - 'APP_DEBUG=true', - `DB_DRIVER=${options.databaseDriver}`, - ] - const driverLines = options.databaseDriver === 'sqlite' - ? [ - `DB_URL=${resolveDefaultDatabaseUrl(options.databaseDriver)}`, - ] - : [ - 'DB_HOST=127.0.0.1', - `DB_PORT=${options.databaseDriver === 'mysql' ? '3306' : '5432'}`, - `DB_USERNAME=${options.databaseDriver === 'mysql' ? 'root' : 'postgres'}`, - 'DB_PASSWORD=', - `DB_DATABASE=${sanitizePackageName(options.projectName) || 'holo_app'}`, - ...(options.databaseDriver === 'postgres' ? ['DB_SCHEMA=public'] : []), - ] - const storageLines = normalizeScaffoldOptionalPackages(options.optionalPackages).includes('storage') - ? [ - `STORAGE_DEFAULT_DISK=${options.storageDefaultDisk}`, - 'STORAGE_ROUTE_PREFIX=/storage', - ] - : [] - const authLines = normalizeScaffoldOptionalPackages(options.optionalPackages).includes('auth') - ? [...renderAuthEnvFiles({}, defaultDatabaseConnection).env] - : [] - const cacheLines = normalizeScaffoldOptionalPackages(options.optionalPackages).includes('cache') - ? [...renderCacheEnvFiles('file').env] - : [] - const env = [...baseLines, ...driverLines, ...storageLines, ...authLines, ...cacheLines, ''].join('\n') - const example = [ - '# Copy this file to .env and fill in your local values.', - '# Supported layered env files: .env.local, .env.development, .env.production, .env.prod, .env.test', - ...[...baseLines, ...driverLines, ...storageLines, ...authLines, ...cacheLines].map(line => `${line.split('=')[0]}=`), - '', - ].join('\n') - - return { env, example } -} - -function renderRedisConnectionEnvFiles(): { env: readonly string[], example: readonly string[] } { - return { - env: [ - 'REDIS_URL=', - 'REDIS_HOST=127.0.0.1', - 'REDIS_PORT=6379', - 'REDIS_USERNAME=', - 'REDIS_PASSWORD=', - 'REDIS_DB=0', - ], - example: [ - 'REDIS_URL=', - 'REDIS_HOST=', - 'REDIS_PORT=', - 'REDIS_USERNAME=', - 'REDIS_PASSWORD=', - 'REDIS_DB=', - ], - } -} - -function renderQueueEnvFiles( - driver: SupportedQueueInstallerDriver, -): { env: readonly string[], example: readonly string[] } { - if (driver !== 'redis') { - return { - env: [], - example: [], - } - } - - return renderRedisConnectionEnvFiles() -} - -function renderCacheEnvFiles( - driver: SupportedCacheInstallerDriver, -): { env: readonly string[], example: readonly string[] } { - if (driver === 'redis') { - const redis = renderRedisConnectionEnvFiles() - return { - env: [ - 'CACHE_PREFIX=', - ...redis.env, - ], - example: [ - 'CACHE_PREFIX=', - ...redis.example, - ], - } - } - - return { - env: [ - 'CACHE_PREFIX=', - ], - example: [ - 'CACHE_PREFIX=', - ], - } -} - -function parseEnvKey(line: string): string | undefined { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) { - return undefined - } - - const normalized = trimmed.startsWith('export ') - ? trimmed.slice(7).trim() - : trimmed - const separatorIndex = normalized.indexOf('=') - if (separatorIndex <= 0) { - return undefined - } - - return normalized.slice(0, separatorIndex).trim() -} - -function upsertEnvContents( - existingContents: string | undefined, - additions: readonly string[], -): { readonly contents?: string, readonly changed: boolean } { - if (additions.length === 0) { - return { - contents: existingContents, - changed: false, - } - } - - const nextLines = existingContents - ? existingContents.replace(/\r\n/g, '\n').split('\n') - : [] - const existingKeys = new Set(nextLines.map(parseEnvKey).filter((value): value is string => typeof value === 'string')) - const missingLines = additions.filter(line => !existingKeys.has(line.slice(0, line.indexOf('=')).trim())) - - if (missingLines.length === 0) { - return { - contents: existingContents, - changed: false, - } - } - - if (nextLines.length > 0 && nextLines[nextLines.length - 1]?.trim() !== '') { - nextLines.push('') - } - - nextLines.push(...missingLines) - - return { - contents: `${nextLines.join('\n').replace(/\n*$/, '')}\n`, - changed: true, - } -} - -function normalizeDependencyMap(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - - return Object.fromEntries( - Object.entries(value) - .filter(([, dependencyVersion]) => typeof dependencyVersion === 'string') - .sort(([left], [right]) => left.localeCompare(right)), - ) -} - -async function readPackageJsonDependencyState(projectRoot: string): Promise<{ - packageJsonPath: string - parsed: Record - dependencies: Record - devDependencies: Record -}> { - const packageJsonPath = resolve(projectRoot, 'package.json') - const existing = await readTextFile(packageJsonPath) - if (!existing) { - throw new Error(`Missing package.json in ${projectRoot}.`) - } - - let parsed: Record - try { - parsed = JSON.parse(existing) as Record - } catch { - throw new Error(`Invalid package.json in ${projectRoot}.`) - } - - return { - packageJsonPath, - parsed, - dependencies: normalizeDependencyMap(parsed.dependencies), - devDependencies: normalizeDependencyMap(parsed.devDependencies), - } -} - -async function writePackageJsonDependencyState( - packageJsonPath: string, - parsed: Record, - dependencies: Record, - devDependencies: Record, -): Promise { - parsed.dependencies = Object.fromEntries( - Object.entries(dependencies).sort(([left], [right]) => left.localeCompare(right)), - ) - - if (Object.keys(devDependencies).length > 0) { - parsed.devDependencies = Object.fromEntries( - Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)), - ) - } else { - delete parsed.devDependencies - } - - await writeTextFile(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`) -} - -function hasLoadedConfigFile( - loadedFiles: readonly string[], - configName: string, -): boolean { - return loadedFiles.some((filePath) => { - const normalizedPath = filePath.replaceAll('\\', '/') - return normalizedPath.endsWith(`/config/${configName}.ts`) - || normalizedPath.endsWith(`/config/${configName}.mts`) - || normalizedPath.endsWith(`/config/${configName}.js`) - || normalizedPath.endsWith(`/config/${configName}.mjs`) - || normalizedPath.endsWith(`/config/${configName}.cts`) - || normalizedPath.endsWith(`/config/${configName}.cjs`) - }) -} - -function inferDatabaseDriverFromUrl(value: string | undefined): SupportedDatabaseDriver | undefined { - if (!value) { - return undefined - } - - const normalized = value.trim().toLowerCase() - if (normalized.startsWith('postgres://') || normalized.startsWith('postgresql://')) { - return 'postgres' - } - - if (normalized.startsWith('mysql://') || normalized.startsWith('mysql2://')) { - return 'mysql' - } - - if ( - normalized === ':memory:' - || normalized.startsWith('file:') - || normalized.startsWith('/') - || normalized.startsWith('./') - || normalized.startsWith('../') - || normalized.endsWith('.db') - || normalized.endsWith('.sqlite') - || normalized.endsWith('.sqlite3') - ) { - return 'sqlite' - } - - return undefined -} - -function inferConnectionDriver( - connection: { - driver?: string - url?: string - filename?: string - } | string, -): SupportedDatabaseDriver | undefined { - if (typeof connection === 'string') { - return inferDatabaseDriverFromUrl(connection) - } - - const explicitDriver = connection.driver - if (explicitDriver === 'sqlite' || explicitDriver === 'postgres' || explicitDriver === 'mysql') { - return explicitDriver - } - - return inferDatabaseDriverFromUrl(connection.url ?? connection.filename) -} - -function registryHasJobs( - registry: GeneratedProjectRegistry | undefined, -): boolean { - return (registry?.jobs.length ?? 0) > 0 -} - -function registryHasEvents( - registry: GeneratedProjectRegistry | undefined, -): boolean { - return (registry?.events.length ?? 0) > 0 - || (registry?.listeners.length ?? 0) > 0 -} - -function registryHasBroadcastDefinitions( - registry: GeneratedProjectRegistry | undefined, -): boolean { - return (registry?.broadcast.length ?? 0) > 0 - || (registry?.channels.length ?? 0) > 0 -} - -function registryHasAuthorizationDefinitions( - registry: GeneratedProjectRegistry | undefined, -): boolean { - return (registry?.authorizationPolicies.length ?? 0) > 0 - || (registry?.authorizationAbilities.length ?? 0) > 0 -} - -function authConfigUsesSocialProviders( - loaded: Awaited>, -): boolean { - return Object.keys(loaded.auth.social).length > 0 -} - -function authConfigUsesWorkosProviders( - loaded: Awaited>, -): boolean { - return Object.keys(loaded.auth.workos).length > 0 -} - -function authConfigUsesClerkProviders( - loaded: Awaited>, -): boolean { - return Object.keys(loaded.auth.clerk).length > 0 -} - -function mailConfigUsesQueue( - loaded: Awaited>, -): boolean { - return loaded.mail.queue.queued - || Object.values(loaded.mail.mailers).some(mailer => mailer.queue.queued) -} - -async function projectHasAuthorizationScaffold(projectRoot: string): Promise { - const project = await loadProjectConfig(projectRoot) - const policiesRoot = resolve(projectRoot, project.config.paths.authorizationPolicies ?? 'server/policies') - const abilitiesRoot = resolve(projectRoot, project.config.paths.authorizationAbilities ?? 'server/abilities') - - return await pathExists(policiesRoot) || await pathExists(abilitiesRoot) -} - -async function projectHasEventsScaffold(projectRoot: string): Promise { - const project = await loadProjectConfig(projectRoot) - const eventsRoot = resolve(projectRoot, project.config.paths.events) - const listenersRoot = resolve(projectRoot, project.config.paths.listeners) - - return await pathExists(eventsRoot) || await pathExists(listenersRoot) -} - -function renderEnvFileContents(segments: readonly string[]): string { - const normalized = segments - .map(segment => segment.replace(/\n+$/, '')) - .filter(segment => segment.length > 0) - - return normalized.length > 0 - ? `${normalized.join('\n')}\n` - : '' -} - -function normalizeScaffoldEnvSegments(segments: string): readonly string[] { - return segments - .split('\n') - .map(segment => segment.trim()) - .filter(segment => segment.length > 0) -} - -export async function syncManagedDriverDependencies( - projectRoot: string, - registry?: GeneratedProjectRegistry, -): Promise { - const loaded = await loadConfigDirectory(projectRoot, { - preferCache: false, - processEnv: process.env, - }) as LoadedConfigWithCache - const discoveredRegistry = registry ?? await loadGeneratedProjectRegistry(projectRoot) - const authConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'auth') - const broadcastConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'broadcast') - const cacheConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'cache') - const mailConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'mail') - const notificationsConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'notifications') - const queueConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'queue') - const securityConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'security') - const sessionConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'session') - const storageConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'storage') - const requiredPackages = new Set() - const hasAuthorizationScaffold = await projectHasAuthorizationScaffold(projectRoot) - const hasEventsScaffold = await projectHasEventsScaffold(projectRoot) - const { - packageJsonPath, - parsed, - dependencies, - devDependencies, - } = await readPackageJsonDependencyState(projectRoot) - const cachePackageInstalled = typeof dependencies['@holo-js/cache'] !== 'undefined' - || typeof devDependencies['@holo-js/cache'] !== 'undefined' - - requiredPackages.add('@holo-js/core') - - for (const connection of Object.values(loaded.database.connections)) { - const inferredDriver = inferConnectionDriver(connection) - if (inferredDriver) { - requiredPackages.add(DB_DRIVER_PACKAGE_NAMES[inferredDriver]) - } - } - - if ( - authConfigured - || sessionConfigured - ) { - requiredPackages.add('@holo-js/session') - } - - if ( - authConfigured - || securityConfigured - ) { - requiredPackages.add('@holo-js/security') - } - - if (authConfigured) { - requiredPackages.add('@holo-js/auth') - - if (authConfigUsesSocialProviders(loaded)) { - requiredPackages.add('@holo-js/auth-social') - - for (const [providerName, provider] of Object.entries(loaded.auth.social)) { - if (typeof provider.runtime === 'string' && provider.runtime.trim()) { - continue - } - - const builtinPackage = AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES[providerName as SupportedAuthSocialProvider] - if (builtinPackage) { - requiredPackages.add(builtinPackage) - } - } - } - - if (authConfigUsesWorkosProviders(loaded)) { - requiredPackages.add('@holo-js/auth-workos') - } - - if (authConfigUsesClerkProviders(loaded)) { - requiredPackages.add('@holo-js/auth-clerk') - } - } - - if (mailConfigured) { - requiredPackages.add('@holo-js/mail') - } - - if (cacheConfigured || cachePackageInstalled) { - requiredPackages.add('@holo-js/cache') - } - - if (cacheConfigured) { - const cacheDrivers = Object.values(loaded.cache.drivers) - if (cacheDrivers.some(driver => driver.driver === 'redis')) { - requiredPackages.add('@holo-js/cache-redis') - } - - if (cacheDrivers.some(driver => driver.driver === 'database')) { - requiredPackages.add('@holo-js/cache-db') - } - } - - if (notificationsConfigured) { - requiredPackages.add('@holo-js/notifications') - } - - if (broadcastConfigured || registryHasBroadcastDefinitions(discoveredRegistry)) { - requiredPackages.add('@holo-js/broadcast') - } - - if (registryHasAuthorizationDefinitions(discoveredRegistry) || hasAuthorizationScaffold) { - requiredPackages.add('@holo-js/authorization') - } - - if (registryHasEvents(discoveredRegistry) || hasEventsScaffold) { - requiredPackages.add('@holo-js/events') - requiredPackages.add('@holo-js/queue') - } - - if (queueConfigured || registryHasJobs(discoveredRegistry) || mailConfigUsesQueue(loaded)) { - requiredPackages.add('@holo-js/queue') - - if (queueConfigured) { - const queueConnections = Object.values(loaded.queue.connections) - if (queueConnections.some(connection => connection.driver === 'redis')) { - requiredPackages.add('@holo-js/queue-redis') - } - - if ( - queueConnections.some(connection => connection.driver === 'database') - || loaded.queue.failed !== false - ) { - requiredPackages.add('@holo-js/queue-db') - } - } - } - - if ( - Object.values(loaded.cache?.drivers ?? {}).some(driver => driver.driver === 'redis') - || loaded.security?.rateLimit?.driver === 'redis' - || Object.values(loaded.session?.stores ?? {}).some(store => store.driver === 'redis') - || (loaded.broadcast?.worker != null && loaded.broadcast.worker.scaling !== false) - ) { - requiredPackages.add('ioredis') - } - - if (storageConfigured) { - requiredPackages.add('@holo-js/storage') - - if (Object.values(loaded.storage.disks).some(disk => disk.driver === 's3')) { - requiredPackages.add('@holo-js/storage-s3') - } - } - - let changed = false - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const removableManagedPackages = new Set([ - '@holo-js/core', - ...Object.values(DB_DRIVER_PACKAGE_NAMES), - '@holo-js/auth', - '@holo-js/auth-clerk', - '@holo-js/auth-social', - '@holo-js/auth-workos', - '@holo-js/authorization', - '@holo-js/broadcast', - '@holo-js/cache', - '@holo-js/cache-db', - '@holo-js/cache-redis', - '@holo-js/events', - '@holo-js/mail', - '@holo-js/notifications', - '@holo-js/queue', - '@holo-js/queue-db', - '@holo-js/queue-redis', - '@holo-js/security', - '@holo-js/session', - '@holo-js/storage', - '@holo-js/storage-s3', - ...Object.values(AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES), - 'ioredis', - ]) - - for (const packageName of requiredPackages) { - const requiredVersion = packageName === 'ioredis' - ? IOREDIS_PACKAGE_VERSION - : nextVersion - if (dependencies[packageName] !== requiredVersion || typeof devDependencies[packageName] !== 'undefined') { - dependencies[packageName] = requiredVersion - delete devDependencies[packageName] - changed = true - } - } - - for (const packageName of removableManagedPackages) { - if (requiredPackages.has(packageName)) { - continue - } - - if (typeof dependencies[packageName] !== 'undefined' || typeof devDependencies[packageName] !== 'undefined') { - delete dependencies[packageName] - delete devDependencies[packageName] - changed = true - } - } - - if (!changed) { - return false - } - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -async function upsertQueuePackageDependency( - projectRoot: string, - driver?: SupportedQueueInstallerDriver, -): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const queueConfigPath = await resolveFirstExistingPath(projectRoot, QUEUE_CONFIG_FILE_NAMES) - const loadedQueueConfig = queueConfigPath - ? loadConfigDirectory(projectRoot, { - preferCache: false, - processEnv: process.env, - }).then(config => config.queue) - /* v8 ignore next -- existing malformed queue config falls back to explicit driver handling in installer tests */ - .catch(() => undefined) - /* v8 ignore next -- exercised by dependency-sync tests, but v8 does not attribute the ternary fallback line */ - : Promise.resolve(undefined) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const nextEsbuildVersion = ESBUILD_PACKAGE_VERSION - const queueConfig = typeof driver === 'undefined' - ? await loadedQueueConfig - : undefined - const resolvedQueueDriver = driver && driver !== 'sync' - ? driver - : queueConfig?.connections[queueConfig.default]?.driver ?? driver - const requiresQueueDb = resolvedQueueDriver === 'database' - || (queueConfig?.failed ?? false) !== false - || Object.values(queueConfig?.connections ?? {}).some(connection => connection.driver === 'database') - const requiresQueueRedis = resolvedQueueDriver === 'redis' - || Object.values(queueConfig?.connections ?? {}).some(connection => connection.driver === 'redis') - const currentVersion = dependencies['@holo-js/queue'] - const currentQueueDbVersion = dependencies['@holo-js/queue-db'] - const currentQueueRedisVersion = dependencies['@holo-js/queue-redis'] - const currentDevVersion = devDependencies['@holo-js/queue'] - const currentDevQueueDbVersion = devDependencies['@holo-js/queue-db'] - const currentDevQueueRedisVersion = devDependencies['@holo-js/queue-redis'] - const currentEsbuildVersion = dependencies.esbuild - const currentDevEsbuildVersion = devDependencies.esbuild - - if ( - currentVersion === nextVersion - && (requiresQueueDb ? currentQueueDbVersion === nextVersion : typeof currentQueueDbVersion === 'undefined') - && (requiresQueueRedis ? currentQueueRedisVersion === nextVersion : typeof currentQueueRedisVersion === 'undefined') - && typeof currentDevVersion === 'undefined' - && typeof currentDevQueueDbVersion === 'undefined' - && typeof currentDevQueueRedisVersion === 'undefined' - && currentEsbuildVersion === nextEsbuildVersion - && typeof currentDevEsbuildVersion === 'undefined' - ) { - return false - } - - dependencies['@holo-js/queue'] = nextVersion - if (requiresQueueDb) { - dependencies['@holo-js/queue-db'] = nextVersion - } else { - delete dependencies['@holo-js/queue-db'] - } - if (requiresQueueRedis) { - dependencies['@holo-js/queue-redis'] = nextVersion - } else { - delete dependencies['@holo-js/queue-redis'] - } - dependencies.esbuild = nextEsbuildVersion - delete devDependencies['@holo-js/queue'] - delete devDependencies['@holo-js/queue-db'] - delete devDependencies['@holo-js/queue-redis'] - delete devDependencies.esbuild - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -async function upsertEventsPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const currentVersion = dependencies['@holo-js/events'] - const currentDevVersion = devDependencies['@holo-js/events'] - - if ( - currentVersion === nextVersion - && typeof currentDevVersion === 'undefined' - ) { - return false - } - - dependencies['@holo-js/events'] = nextVersion - delete devDependencies['@holo-js/events'] - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -async function upsertNotificationsPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const currentVersion = dependencies['@holo-js/notifications'] - const currentDevVersion = devDependencies['@holo-js/notifications'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } - - dependencies['@holo-js/notifications'] = nextVersion - delete devDependencies['@holo-js/notifications'] - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -async function upsertMailPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const currentVersion = dependencies['@holo-js/mail'] - const currentDevVersion = devDependencies['@holo-js/mail'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } - - dependencies['@holo-js/mail'] = nextVersion - delete devDependencies['@holo-js/mail'] - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -async function upsertSecurityPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const currentVersion = dependencies['@holo-js/security'] - const currentDevVersion = devDependencies['@holo-js/security'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } - - dependencies['@holo-js/security'] = nextVersion - delete devDependencies['@holo-js/security'] - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -async function upsertCachePackageDependencies( - projectRoot: string, - driver: SupportedCacheInstallerDriver = 'file', -): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const cacheConfigPath = await resolveFirstExistingPath(projectRoot, CACHE_CONFIG_FILE_NAMES) - const cacheConfig = cacheConfigPath - ? await loadConfigDirectory(projectRoot, { - preferCache: false, - processEnv: process.env, - }).then(config => (config as LoadedConfigWithCache).cache) - .catch(() => undefined) - : undefined - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const requiresCacheRedis = driver === 'redis' - || Object.values(cacheConfig?.drivers ?? {}).some(connection => connection.driver === 'redis') - const requiresCacheDb = driver === 'database' - || Object.values(cacheConfig?.drivers ?? {}).some(connection => connection.driver === 'database') - const currentVersion = dependencies['@holo-js/cache'] - const currentCacheDbVersion = dependencies['@holo-js/cache-db'] - const currentCacheRedisVersion = dependencies['@holo-js/cache-redis'] - const currentDevVersion = devDependencies['@holo-js/cache'] - const currentDevCacheDbVersion = devDependencies['@holo-js/cache-db'] - const currentDevCacheRedisVersion = devDependencies['@holo-js/cache-redis'] - - if ( - currentVersion === nextVersion - && (requiresCacheDb ? currentCacheDbVersion === nextVersion : typeof currentCacheDbVersion === 'undefined') - && (requiresCacheRedis ? currentCacheRedisVersion === nextVersion : typeof currentCacheRedisVersion === 'undefined') - && typeof currentDevVersion === 'undefined' - && typeof currentDevCacheDbVersion === 'undefined' - && typeof currentDevCacheRedisVersion === 'undefined' - ) { - return false - } - - dependencies['@holo-js/cache'] = nextVersion - if (requiresCacheDb) { - dependencies['@holo-js/cache-db'] = nextVersion - } else { - delete dependencies['@holo-js/cache-db'] - } - if (requiresCacheRedis) { - dependencies['@holo-js/cache-redis'] = nextVersion - } else { - delete dependencies['@holo-js/cache-redis'] - } - delete devDependencies['@holo-js/cache'] - delete devDependencies['@holo-js/cache-db'] - delete devDependencies['@holo-js/cache-redis'] - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -function detectProjectFrameworkFromPackageJson( - dependencies: Record, - devDependencies: Record, -): 'next' | 'nuxt' | 'sveltekit' | undefined { - if (dependencies.next || devDependencies.next) { - return 'next' - } - - if (dependencies.nuxt || devDependencies.nuxt) { - return 'nuxt' - } - - if (dependencies['@sveltejs/kit'] || devDependencies['@sveltejs/kit']) { - return 'sveltekit' - } - - return undefined -} - -async function upsertBroadcastPackageDependencies(projectRoot: string): Promise<{ - readonly updated: boolean - readonly framework: 'next' | 'nuxt' | 'sveltekit' | undefined -}> { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const framework = detectProjectFrameworkFromPackageJson(dependencies, devDependencies) - let changed = false - - const requestedPackages = new Set([ - '@holo-js/broadcast', - '@holo-js/flux', - ]) - - if (framework === 'next') { - requestedPackages.add('@holo-js/flux-react') - requestedPackages.add('@holo-js/adapter-next') - } else if (framework === 'nuxt') { - requestedPackages.add('@holo-js/flux-vue') - requestedPackages.add('@holo-js/adapter-nuxt') - } else if (framework === 'sveltekit') { - requestedPackages.add('@holo-js/flux-svelte') - requestedPackages.add('@holo-js/adapter-sveltekit') - } - - for (const packageName of requestedPackages) { - if (dependencies[packageName] !== nextVersion || typeof devDependencies[packageName] !== 'undefined') { - dependencies[packageName] = nextVersion - delete devDependencies[packageName] - changed = true - } - } - - if (!changed) { - return { - updated: false, - framework, - } - } - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return { - updated: true, - framework, - } -} - -async function upsertAuthPackageDependencies( - projectRoot: string, - features: AuthInstallFeatures = {}, -): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const socialEnabled = features.social === true || (features.socialProviders?.length ?? 0) > 0 - const requestedPackages = { - '@holo-js/auth': true, - '@holo-js/session': true, - '@holo-js/auth-social': socialEnabled, - '@holo-js/auth-workos': features.workos === true, - '@holo-js/auth-clerk': features.clerk === true, - } as const - const requestedSocialProviders = new Set(features.socialProviders ?? (socialEnabled ? ['google'] : [])) - - let changed = false - - for (const [packageName, enabled] of Object.entries(requestedPackages)) { - const currentDependency = dependencies[packageName] - const currentDevDependency = devDependencies[packageName] - - if (enabled) { - if (currentDependency !== nextVersion || typeof currentDevDependency !== 'undefined') { - dependencies[packageName] = nextVersion - delete devDependencies[packageName] - changed = true - } - continue - } - - if (typeof currentDevDependency !== 'undefined') { - delete devDependencies[packageName] - changed = true - } - } - - for (const [providerName, packageName] of Object.entries(AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES)) { - const enabled = requestedSocialProviders.has(providerName as SupportedAuthSocialProvider) - const currentDependency = dependencies[packageName] - const currentDevDependency = devDependencies[packageName] - - if (enabled) { - if (currentDependency !== nextVersion || typeof currentDevDependency !== 'undefined') { - dependencies[packageName] = nextVersion - delete devDependencies[packageName] - changed = true - } - continue - } - - if (typeof currentDevDependency !== 'undefined') { - delete devDependencies[packageName] - changed = true - } - } - - if (!changed) { - return false - } - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} - -async function upsertAuthorizationPackageDependency(projectRoot: string): Promise { - const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) - const nextVersion = `^${HOLO_PACKAGE_VERSION}` - const currentVersion = dependencies['@holo-js/authorization'] - const currentDevVersion = devDependencies['@holo-js/authorization'] - - if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { - return false - } - - dependencies['@holo-js/authorization'] = nextVersion - delete devDependencies['@holo-js/authorization'] - - await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) - return true -} + AUTH_CONFIG_FILE_NAMES, + BROADCAST_CONFIG_FILE_NAMES, + CACHE_CONFIG_FILE_NAMES, + MAIL_CONFIG_FILE_NAMES, + NOTIFICATIONS_CONFIG_FILE_NAMES, + QUEUE_CONFIG_FILE_NAMES, + SECURITY_CONFIG_FILE_NAMES, + SESSION_CONFIG_FILE_NAMES, + type AuthInstallResult, + type AuthorizationInstallResult, + type BroadcastInstallResult, + type CacheInstallResult, + type EventsInstallResult, + type MailInstallResult, + type NotificationsInstallResult, + type QueueInstallResult, + type SecurityInstallResult, + type SupportedCacheInstallerDriver, + type SupportedQueueInstallerDriver, + isSupportedCacheInstallerDriver, + isSupportedQueueInstallerDriver, + normalizeScaffoldOptionalPackages, + pathExists, + sanitizePackageName, +} from './shared' +import { + readTextFile, + resolveFirstExistingPath, + writeTextFile, +} from './runtime' +import { + authFeaturesRequireConfigUpdate, + canSafelyRewriteAuthConfig, + detectAuthInstallFeaturesFromConfig, + ensureRateLimitStorageIgnore, + ensureRedisConfigFile, + injectBroadcastAuthEndpoint, + mergeInstalledAuthFeatures, + renderAuthConfig, + renderBroadcastConfig, + renderBroadcastEnvFiles, + renderCacheConfig, + renderMailConfig, + renderMediaConfig, + renderNotificationsConfig, + renderQueueConfig, + renderRedisConfig, + renderSecurityConfig, + renderSessionConfig, + renderStorageConfig, + resolveBroadcastConfigTargetPath, + resolveConfigModuleFormat, + syncBroadcastAuthSupportAfterAuthInstall, +} from './scaffold/config-renderers' +import { + detectProjectFrameworkFromPackageJson, + hasLoadedConfigFile, + inferConnectionDriver, + inferDatabaseDriverFromUrl, + readPackageJsonDependencyState, + syncManagedDriverDependencies, + upsertAuthPackageDependencies, + upsertAuthorizationPackageDependency, + upsertBroadcastPackageDependencies, + upsertCachePackageDependencies, + upsertEventsPackageDependency, + upsertMailPackageDependency, + upsertNotificationsPackageDependency, + upsertQueuePackageDependency, + upsertSecurityPackageDependency, +} from './scaffold/dependencies' +import { + renderFrameworkFiles, + renderFrameworkRunner, + renderNextHoloHelper, + renderScaffoldPackageJson, + renderSvelteHoloHelper, + resolvePackageManagerVersion, + scaffoldProject, +} from './scaffold/framework' +import { + createAuthMigrationFiles, + createNotificationsMigrationFiles, + normalizeScaffoldEnvSegments, + renderAuthEnvFiles, + renderAuthMigration, + renderAuthUserModel, + renderAuthorizationAbilitiesReadme, + renderAuthorizationPoliciesReadme, + renderCacheEnvFiles, + renderEnvFileContents, + renderNotificationsMigration, + renderQueueEnvFiles, + renderScaffoldAppConfig, + renderScaffoldDatabaseConfig, + renderScaffoldEnvFiles, + resolveDefaultDatabaseUrl, + resolveAuthUserModelSchemaImportPath, + upsertEnvContents, +} from './scaffold/project-renderers' +import { + renderScaffoldGitignore, + renderScaffoldTsconfig, + renderVSCodeSettings, +} from './scaffold/workspace-renderers' +import { + AUTH_MIGRATION_SLUGS, + type AuthInstallFeatures, + type AuthMigrationSlug, + type LoadedConfigWithCache, +} from './scaffold/types' async function resolveExistingModelPath(modelsRoot: string, modelName: string): Promise { const supportedExtensions = ['.ts', '.mts', '.js', '.mjs', '.cts', '.cjs'] @@ -2306,7 +175,6 @@ export async function installAuthIntoProject( const project = await loadProjectConfig(projectRoot) const modelsRoot = resolve(projectRoot, project.config.paths.models) const migrationsRoot = resolve(projectRoot, project.config.paths.migrations) - /* v8 ignore next -- normalized project configs always resolve a database default connection; this fallback only protects malformed external state. */ const defaultDatabaseConnection = project.config.database?.defaultConnection ?? 'default' const authConfigPath = await resolveFirstExistingPath(projectRoot, AUTH_CONFIG_FILE_NAMES) const sessionConfigPath = await resolveFirstExistingPath(projectRoot, SESSION_CONFIG_FILE_NAMES) @@ -2322,10 +190,9 @@ export async function installAuthIntoProject( if (authConfigPath && userModelPath && hasAllAuthMigrations) { const envPath = resolve(projectRoot, '.env') const envExamplePath = resolve(projectRoot, '.env.example') - /* v8 ignore next -- authConfigPath was resolved from an existing file; undefined would require an external delete race. */ const currentAuthConfig = (await readTextFile(authConfigPath)) ?? '' const currentAuthFeatures = detectAuthInstallFeaturesFromConfig(currentAuthConfig) - const nextAuthFeatures = mergeAuthInstallFeatures(currentAuthFeatures, features) + const nextAuthFeatures = mergeInstalledAuthFeatures(currentAuthFeatures, features) const authConfigModuleFormat = resolveConfigModuleFormat(authConfigPath, currentAuthConfig) const nextAuthConfig = renderAuthConfig(nextAuthFeatures, authConfigModuleFormat) const authEnvFiles = renderAuthEnvFiles(nextAuthFeatures, defaultDatabaseConnection) @@ -2737,8 +604,6 @@ export async function installBroadcastIntoProject( await writeTextFile(holoHelperPath, renderNextHoloHelper()) createdFrameworkSetup = true } - } else if (framework === 'nuxt') { - // no framework-specific setup needed for Nuxt } else if (framework === 'sveltekit') { const holoHelperPath = resolve(projectRoot, 'src/lib/server/holo.ts') if (!(await pathExists(holoHelperPath))) { @@ -2768,997 +633,6 @@ export async function installBroadcastIntoProject( } } -function renderScaffoldGitignore(): string { - return [ - 'node_modules', - '.env', - '.env.local', - '.env.development', - '.env.production', - '.env.prod', - '.env.test', - '.holo-js/generated', - '.holo-js/runtime', - '.nuxt', - '.output', - '.next', - '.svelte-kit', - 'coverage', - 'dist', - '', - ].join('\n') -} - -function renderScaffoldTsconfig(options: Pick): string { - if (options.framework === 'nuxt') { - return `${JSON.stringify({ - extends: './.nuxt/tsconfig.json', - }, null, 2)}\n` - } - - if (options.framework === 'sveltekit') { - return `${JSON.stringify({ - extends: './.svelte-kit/tsconfig.json', - compilerOptions: { - strict: true, - noEmit: true, - skipLibCheck: true, - }, - include: [ - 'src/**/*.ts', - 'src/**/*.svelte', - 'server/**/*.ts', - 'config/**/*.ts', - '.holo-js/generated/**/*.ts', - '.holo-js/generated/**/*.d.ts', - 'vite.config.ts', - ], - }, null, 2)}\n` - } - - const include = ['next-env.d.ts', 'instrumentation.ts', 'app/**/*.ts', 'app/**/*.tsx', 'server/**/*.ts', 'config/**/*.ts', '.holo-js/generated/**/*.ts', '.holo-js/generated/**/*.d.ts'] - - return `${JSON.stringify({ - compilerOptions: { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'Bundler', - strict: true, - noEmit: true, - skipLibCheck: true, - baseUrl: '.', - jsx: 'preserve', - paths: { - '~/*': ['./*'], - '@/*': ['./*'], - }, - }, - include, - }, null, 2)}\n` -} - -function renderVSCodeSettings(options: Pick): string | undefined { - if (options.framework !== 'nuxt' && options.framework !== 'sveltekit') { - return undefined - } - - const settings: Record = { - 'typescript.tsdk': 'node_modules/typescript/lib', - 'typescript.enablePromptUseWorkspaceTsdk': true, - } - - if (options.framework === 'nuxt') { - settings['vue.server.hybridMode'] = true - } - - return `${JSON.stringify(settings, null, 2)}\n` -} - -function renderNuxtAppVue(projectName: string): string { - return [ - '', - '', - '', - '', - '', - '', - ].join('\n') -} - -function renderNuxtConfig(): string { - return [ - 'export default defineNuxtConfig({', - ' modules: [\'@holo-js/adapter-nuxt\'],', - ' sourcemap: {', - ' client: false,', - ' server: false,', - ' },', - ' vite: {', - ' build: {', - ' rollupOptions: {', - ' onwarn(warning, defaultHandler) {', - ' if (', - ' warning.message.includes(\'nuxt:module-preload-polyfill\')', - ' && warning.message.includes(\'didn\\\'t generate a sourcemap\')', - ' ) {', - ' return', - ' }', - '', - ' defaultHandler(warning)', - ' },', - ' },', - ' },', - ' },', - '})', - '', - ].join('\n') -} - -function renderNuxtHealthRoute(): string { - return [ - 'export default defineEventHandler(async () => {', - ' const app = await holo.getApp()', - '', - ' return {', - ' ok: true,', - ' app: app.config.app.name,', - ' env: app.config.app.env,', - ' models: app.registry?.models.length ?? 0,', - ' commands: app.registry?.commands.length ?? 0,', - ' }', - '})', - '', - ].join('\n') -} - -function renderNextConfig(_storageEnabled: boolean): string { - return [ - 'import type { NextConfig } from \'next\'', - 'import { withHolo } from \'@holo-js/adapter-next/config\'', - '', - 'const nextConfig: NextConfig = withHolo({', - ' /* config options here */', - '})', - '', - 'export default nextConfig', - '', - ].join('\n') -} - -function renderNextLayout(projectName: string): string { - return [ - 'import \'../server/db/schema.generated\'', - '', - 'import type { ReactNode } from \'react\'', - '', - 'export const metadata = {', - ` title: ${JSON.stringify(projectName)},`, - ' description: \'Holo on Next.js\',', - '}', - '', - 'export default function RootLayout({ children }: { children: ReactNode }) {', - ' return (', - ' ', - ' {children}', - ' ', - ' )', - '}', - '', - ].join('\n') -} - -function renderNextPage(projectName: string): string { - return [ - 'export default function HomePage() {', - ' return (', - '
', - `

${projectName}

`, - '

Next.js handles rendering. Holo powers the backend runtime and discovered server resources.

', - '
', - ' )', - '}', - '', - ].join('\n') -} - -function renderNextEnvDts(): string { - return [ - '/// ', - '/// ', - '', - '// Generated by Holo. Do not edit.', - '', - ].join('\n') -} - -function renderNextHoloHelper(): string { - return [ - 'import \'./db/schema.generated\'', - '', - 'import { createNextHoloHelpers } from \'@holo-js/adapter-next\'', - '', - 'export const holo = createNextHoloHelpers()', - '', - ].join('\n') -} - -function renderNextInstrumentation(): string { - return [ - 'export async function register() {', - ' if (process.env.NEXT_RUNTIME === \'nodejs\') {', - ' const { holo } = await import(\'@/server/holo\')', - ' await holo.getApp()', - ' }', - '}', - '', - ].join('\n') -} - -function renderNextHealthRoute(): string { - return [ - 'import { holo } from \'@/server/holo\'', - '', - 'export async function GET() {', - ' const app = await holo.getApp()', - '', - ' return Response.json({', - ' ok: true,', - ' app: app.config.app.name,', - ' env: app.config.app.env,', - ' models: app.registry?.models.length ?? 0,', - ' commands: app.registry?.commands.length ?? 0,', - ' })', - '}', - '', - ].join('\n') -} - -function renderNextStorageRoute(): string { - return [ - 'import { holo } from \'@/server/holo\'', - 'import { createPublicStorageResponse } from \'@holo-js/storage\'', - '', - 'export async function GET(request: Request) {', - ' const app = await holo.getApp()', - ' return createPublicStorageResponse(app.projectRoot, app.config.storage, request)', - '}', - '', - ].join('\n') -} - -function renderSvelteConfig(): string { - return [ - 'import adapter from \'@sveltejs/adapter-node\'', - 'import { vitePreprocess } from \'@sveltejs/vite-plugin-svelte\'', - '', - '/** @type {import(\'@sveltejs/kit\').Config} */', - 'const config = {', - ' preprocess: vitePreprocess(),', - ' kit: {', - ' adapter: adapter(),', - ' files: {', - ' hooks: {', - ' server: \'.holo-js/generated/hooks.server\',', - ' universal: \'.holo-js/generated/hooks\',', - ' },', - ' },', - ' },', - '}', - '', - 'export default config', - '', - ].join('\n') -} - -function renderSvelteUserHooks(): string { - return [ - 'export {}', - '', - ].join('\n') -} - -function renderSvelteServerUserHooks(): string { - return [ - 'export {}', - '', - ].join('\n') -} - -function renderSvelteViteConfig(storageEnabled: boolean): string { - const externals = [ - ' \'@holo-js/adapter-sveltekit\',', - ' \'@holo-js/config\',', - ' \'@holo-js/core\',', - ' \'@holo-js/db\',', - ...(storageEnabled - ? [ - ' \'@holo-js/storage\',', - ' \'@holo-js/storage/runtime\',', - ] - : []), - ' \'better-sqlite3\',', - ] - - return [ - 'import { sveltekit } from \'@sveltejs/kit/vite\'', - 'import { defineConfig } from \'vite\'', - '', - 'export default defineConfig({', - ' plugins: [sveltekit()],', - ' server: {', - ' fs: {', - ' allow: [\'.holo-js/generated\'],', - ' },', - ' },', - ' ssr: {', - ' external: [', - ...externals, - ' ],', - ' },', - '})', - '', - ].join('\n') -} - -function renderSvelteAppHtml(): string { - return [ - '', - '', - ' ', - ' ', - ' ', - ' %sveltekit.head%', - ' ', - ' ', - '
%sveltekit.body%
', - ' ', - '', - '', - ].join('\n') -} - -function renderSveltePage(projectName: string): string { - return [ - `${projectName}`, - '', - '', - '', - '
', - '

{projectName}

', - '

SvelteKit owns rendering. Holo owns config, discovery, and backend runtime services.

', - '
', - '', - '', - '', - ].join('\n') -} - -function renderSvelteHoloHelper(): string { - return [ - 'import \'../../../server/db/schema.generated\'', - '', - 'import { createSvelteKitHoloHelpers } from \'@holo-js/adapter-sveltekit\'', - '', - 'export const holo = createSvelteKitHoloHelpers()', - '', - ].join('\n') -} - -function renderSvelteHealthRoute(): string { - return [ - 'import { json } from \'@sveltejs/kit\'', - 'import { holo } from \'$lib/server/holo\'', - '', - 'export async function GET() {', - ' const app = await holo.getApp()', - '', - ' return json({', - ' ok: true,', - ' app: app.config.app.name,', - ' env: app.config.app.env,', - ' models: app.registry?.models.length ?? 0,', - ' commands: app.registry?.commands.length ?? 0,', - ' })', - '}', - '', - ].join('\n') -} - -function renderSvelteStorageRoute(): string { - return [ - 'import { holo } from \'$lib/server/holo\'', - 'import { createPublicStorageResponse } from \'@holo-js/storage\'', - '', - 'export async function GET({ request }: { request: Request }) {', - ' const app = await holo.getApp()', - ' return createPublicStorageResponse(app.projectRoot, app.config.storage, request)', - '}', - '', - ].join('\n') -} - -function renderFrameworkFiles(options: ProjectScaffoldOptions): readonly ScaffoldedFile[] { - const optionalPackages = normalizeScaffoldOptionalPackages(options.optionalPackages) - const storageEnabled = optionalPackages.includes('storage') - - if (options.framework === 'nuxt') { - return [ - { path: 'app.vue', contents: renderNuxtAppVue(options.projectName) }, - { path: 'nuxt.config.ts', contents: renderNuxtConfig() }, - { path: 'server/api/holo/health.get.ts', contents: renderNuxtHealthRoute() }, - ] - } - - if (options.framework === 'next') { - return [ - { path: 'next.config.ts', contents: renderNextConfig(storageEnabled) }, - { path: 'next-env.d.ts', contents: renderNextEnvDts() }, - { path: 'app/layout.tsx', contents: renderNextLayout(options.projectName) }, - { path: 'app/page.tsx', contents: renderNextPage(options.projectName) }, - { path: 'app/api/holo/health/route.ts', contents: renderNextHealthRoute() }, - ...(storageEnabled - ? [{ path: 'app/storage/[[...path]]/route.ts', contents: renderNextStorageRoute() }] - : []), - { path: 'server/holo.ts', contents: renderNextHoloHelper() }, - { path: 'instrumentation.ts', contents: renderNextInstrumentation() }, - ] - } - - return [ - { path: 'svelte.config.js', contents: renderSvelteConfig() }, - { path: 'vite.config.ts', contents: renderSvelteViteConfig(storageEnabled) }, - { path: 'src/hooks.ts', contents: renderSvelteUserHooks() }, - { path: 'src/hooks.server.ts', contents: renderSvelteServerUserHooks() }, - { path: 'src/app.html', contents: renderSvelteAppHtml() }, - { path: 'src/routes/+page.svelte', contents: renderSveltePage(options.projectName) }, - { path: 'src/routes/api/holo/+server.ts', contents: renderSvelteHealthRoute() }, - ...(storageEnabled - ? [{ path: 'src/routes/storage/[...path]/+server.ts', contents: renderSvelteStorageRoute() }] - : []), - { path: 'src/lib/server/holo.ts', contents: renderSvelteHoloHelper() }, - ] -} - -function renderFrameworkRunner(options: Pick): string { - const commandName = options.framework === 'nuxt' - ? 'nuxi' - : options.framework === 'next' - ? 'next' - : 'vite' - return [ - 'import { existsSync, readFileSync } from \'node:fs\'', - 'import { dirname, resolve } from \'node:path\'', - 'import { fileURLToPath } from \'node:url\'', - 'import { spawn } from \'node:child_process\'', - '', - 'const mode = process.argv[2]', - 'const manifestPath = fileURLToPath(new URL(\'./project.json\', import.meta.url))', - 'const projectRoot = resolve(dirname(manifestPath), \'../..\')', - 'const manifest = JSON.parse(readFileSync(manifestPath, \'utf8\'))', - 'const framework = String(manifest.framework ?? \'\')', - `const commandName = ${JSON.stringify(commandName)}`, - 'const commandArgs = mode === \'dev\'', - ' ? [\'dev\']', - ' : mode === \'build\'', - ' ? framework === \'sveltekit\' ? [\'build\', \'--logLevel\', \'error\'] : [\'build\']', - ' : undefined', - '', - 'if (!commandArgs) {', - ' console.error(`[holo] Unknown framework runner mode: ${String(mode)}`)', - ' process.exit(1)', - '}', - '', - 'const binaryPath = resolve(', - ' projectRoot,', - ' \'node_modules\',', - ' \'.bin\',', - ' process.platform === \'win32\' ? `${commandName}.cmd` : commandName,', - ')', - '', - 'const suppressedOutput = framework === \'sveltekit\'', - ' ? new Set([', - ' \'"try_get_request_store" is imported from external module "@sveltejs/kit/internal/server" but never used in ".svelte-kit/adapter-node/index.js".\',', - ' ])', - ' : new Set()', - '', - 'function pipeOutput(stream, target, onLine) {', - ' if (!stream) {', - ' return', - ' }', - '', - ' let buffered = \'\'', - ' stream.on(\'data\', (chunk) => {', - ' buffered += chunk.toString()', - ' const lines = buffered.split(/\\r?\\n/)', - ' buffered = lines.pop() ?? \'\'', - ' for (const line of lines) {', - ' onLine?.(line)', - ' if (!suppressedOutput.has(line)) {', - ' target.write(`${line}\\n`)', - ' }', - ' }', - ' })', - '', - ' stream.on(\'end\', () => {', - ' if (buffered.length > 0) {', - ' onLine?.(buffered)', - ' }', - ' if (buffered.length > 0 && !suppressedOutput.has(buffered)) {', - ' target.write(buffered)', - ' }', - ' })', - '}', - '', - 'function extractNextConflictPid(lines) {', - ' if (framework !== \'next\' || mode !== \'dev\') {', - ' return undefined', - ' }', - '', - ' if (!lines.some(line => line.includes(\'Another next dev server is already running.\'))) {', - ' return undefined', - ' }', - '', - ' for (const line of lines) {', - ' const match = line.match(/^- PID:\\s+(\\d+)\\s*$/)', - ' if (match) {', - ' return Number.parseInt(match[1], 10)', - ' }', - ' }', - '', - ' return undefined', - '}', - '', - 'async function waitForProcessExit(pid, timeoutMs = 5000) {', - ' const deadline = Date.now() + timeoutMs', - ' while (Date.now() < deadline) {', - ' try {', - ' process.kill(pid, 0)', - ' } catch (error) {', - ' if (error && typeof error === \'object\' && \'code\' in error && error.code === \'ESRCH\') {', - ' return true', - ' }', - ' throw error', - ' }', - '', - ' await new Promise(resolve => setTimeout(resolve, 100))', - ' }', - '', - ' return false', - '}', - '', - 'async function stopStaleNextDevServer(pid) {', - ' if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) {', - ' return false', - ' }', - '', - ' try {', - ' process.kill(pid, \'SIGTERM\')', - ' } catch (error) {', - ' if (error && typeof error === \'object\' && \'code\' in error && error.code === \'ESRCH\') {', - ' return true', - ' }', - ' return false', - ' }', - '', - ' return waitForProcessExit(pid)', - '}', - '', - 'if (!existsSync(binaryPath)) {', - ' console.error(`[holo] Missing framework binary "${commandName}" for "${framework}". Run your package manager install first.`)', - ' process.exit(1)', - '}', - '', - 'let child = null', - 'let forwardedSignal = null', - '', - 'function forwardSignal(signal) {', - ' if (forwardedSignal || !child || child.exitCode !== null) {', - ' return', - ' }', - '', - ' forwardedSignal = signal', - ' child.kill(signal)', - '}', - '', - 'process.on(\'SIGINT\', () => {', - ' forwardSignal(\'SIGINT\')', - '})', - '', - 'process.on(\'SIGTERM\', () => {', - ' forwardSignal(\'SIGTERM\')', - '})', - '', - 'async function run() {', - ' let restartedAfterConflict = false', - ' const maxStderrLines = 200', - '', - ' while (true) {', - ' const stderrLines = []', - ' child = spawn(binaryPath, commandArgs, {', - ' cwd: projectRoot,', - ' env: process.env,', - ' stdio: [\'inherit\', \'pipe\', \'pipe\'],', - ' })', - ' forwardedSignal = null', - '', - ' pipeOutput(child.stdout, process.stdout)', - ' pipeOutput(child.stderr, process.stderr, line => {', - ' if (stderrLines.length >= maxStderrLines) {', - ' stderrLines.shift()', - ' }', - ' stderrLines.push(line)', - ' })', - '', - ' const result = await new Promise((resolve, reject) => {', - ' child.on(\'error\', reject)', - ' child.on(\'close\', (code, signal) => resolve({ code, signal }))', - ' })', - '', - ' if (result.code === 0) {', - ' process.exit(0)', - ' }', - '', - ' const conflictPid = extractNextConflictPid(stderrLines)', - ' if (!restartedAfterConflict && typeof conflictPid === \'number\') {', - ' const stopped = await stopStaleNextDevServer(conflictPid)', - ' if (stopped) {', - ' restartedAfterConflict = true', - ' console.error(`[holo] Stopped stale Next dev server ${conflictPid}. Restarting dev server.`)', - ' continue', - ' }', - ' }', - '', - ' if (result.signal) {', - ' process.kill(process.pid, result.signal)', - ' } else {', - ' process.exit(result.code ?? 1)', - ' }', - ' }', - '}', - '', - 'run().catch((error) => {', - ' console.error(error instanceof Error ? error.message : String(error))', - ' process.exit(1)', - '})', - '', - ].join('\n') -} - -function resolvePackageManagerVersion(value: SupportedScaffoldPackageManager): string { - return SCAFFOLD_PACKAGE_MANAGER_VERSIONS[value] -} - -function resolveDefaultDatabaseUrl(driver: SupportedDatabaseDriver): string | undefined { - if (driver === 'sqlite') { - return './storage/database.sqlite' - } - - return undefined -} - -function renderScaffoldPackageJson(options: ProjectScaffoldOptions): string { - const packageName = sanitizePackageName(options.projectName) || 'holo-app' - const optionalPackages = normalizeScaffoldOptionalPackages(options.optionalPackages) - const dependencies: Record = { - '@holo-js/cli': `^${HOLO_PACKAGE_VERSION}`, - '@holo-js/config': `^${HOLO_PACKAGE_VERSION}`, - '@holo-js/core': `^${HOLO_PACKAGE_VERSION}`, - '@holo-js/db': `^${HOLO_PACKAGE_VERSION}`, - [DB_DRIVER_PACKAGE_NAMES[options.databaseDriver]]: `^${HOLO_PACKAGE_VERSION}`, - esbuild: ESBUILD_PACKAGE_VERSION, - } - const devDependencies: Record = { - typescript: '^5.8.0', - '@types/node': '^22.0.0', - } - - if (options.framework === 'nuxt') { - dependencies.nuxt = SCAFFOLD_FRAMEWORK_VERSIONS.nuxt - dependencies.vue = '^3.5.13' - dependencies['vue-router'] = '^5.0.4' - dependencies['@holo-js/adapter-nuxt'] = SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS.nuxt - devDependencies.vite = '^5.4.14' - devDependencies['vue-tsc'] = '^2.2.0' - } - - if (options.framework === 'next') { - dependencies.next = SCAFFOLD_FRAMEWORK_VERSIONS.next - dependencies.react = '^19.0.0' - dependencies['react-dom'] = '^19.0.0' - dependencies['@holo-js/adapter-next'] = SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS.next - devDependencies['@types/react'] = '^19.0.0' - devDependencies['@types/react-dom'] = '^19.0.0' - } - - if (options.framework === 'sveltekit') { - dependencies['@holo-js/adapter-sveltekit'] = SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS.sveltekit - dependencies['@sveltejs/adapter-node'] = '^5.0.0' - dependencies['@sveltejs/kit'] = SCAFFOLD_FRAMEWORK_VERSIONS.sveltekit - dependencies['@sveltejs/vite-plugin-svelte'] = '^4.0.0' - dependencies.svelte = '^5.0.0' - dependencies.vite = '^5.0.0' - } - - if (optionalPackages.includes('storage')) { - dependencies['@holo-js/storage'] = SCAFFOLD_FRAMEWORK_RUNTIME_VERSIONS[options.framework]['@holo-js/storage'] - } - - if (optionalPackages.includes('events')) { - dependencies['@holo-js/events'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('queue')) { - dependencies['@holo-js/queue'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('validation')) { - dependencies['@holo-js/validation'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('forms')) { - dependencies['@holo-js/forms'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('auth')) { - dependencies['@holo-js/auth'] = `^${HOLO_PACKAGE_VERSION}` - dependencies['@holo-js/session'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('authorization')) { - dependencies['@holo-js/authorization'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('notifications')) { - dependencies['@holo-js/notifications'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('mail')) { - dependencies['@holo-js/mail'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('broadcast')) { - dependencies['@holo-js/broadcast'] = `^${HOLO_PACKAGE_VERSION}` - dependencies['@holo-js/flux'] = `^${HOLO_PACKAGE_VERSION}` - if (options.framework === 'next') { - dependencies['@holo-js/flux-react'] = `^${HOLO_PACKAGE_VERSION}` - } else if (options.framework === 'nuxt') { - dependencies['@holo-js/flux-vue'] = `^${HOLO_PACKAGE_VERSION}` - } else if (options.framework === 'sveltekit') { - dependencies['@holo-js/flux-svelte'] = `^${HOLO_PACKAGE_VERSION}` - } - } - - if (optionalPackages.includes('security')) { - dependencies['@holo-js/security'] = `^${HOLO_PACKAGE_VERSION}` - } - - if (optionalPackages.includes('cache')) { - dependencies['@holo-js/cache'] = `^${HOLO_PACKAGE_VERSION}` - } - - return `${JSON.stringify({ - name: packageName, - private: true, - type: 'module', - packageManager: resolvePackageManagerVersion(options.packageManager), - scripts: { - ...(options.framework === 'nuxt' - ? { postinstall: 'nuxt prepare' } - : {}), - prepare: 'holo prepare', - dev: 'holo dev', - build: 'holo build', - lint: options.framework === 'nuxt' - ? 'npx eslint app.vue config server tests --fix --no-warn-ignored' - : options.framework === 'next' - ? 'npx eslint app config server tests --fix --no-warn-ignored' - : 'npx eslint src config server tests --fix --no-warn-ignored', - typecheck: options.framework === 'nuxt' - ? 'npx nuxi typecheck' - : options.framework === 'next' - ? 'npx tsc -p tsconfig.json --noEmit' - : 'npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json', - ['config:cache']: 'holo config:cache', - ['config:clear']: 'holo config:clear', - ['holo:dev']: 'node ./.holo-js/framework/run.mjs dev', - ['holo:build']: 'node ./.holo-js/framework/run.mjs build', - }, - dependencies, - devDependencies, - }, null, 2)}\n` -} - -export async function scaffoldProject( - projectRoot: string, - options: ProjectScaffoldOptions, -): Promise { - const existingEntries = await readdir(projectRoot).catch(() => [] as string[]) - if (existingEntries.length > 0) { - throw new Error(`Refusing to scaffold into a non-empty directory: ${projectRoot}`) - } - - const { env, example } = renderScaffoldEnvFiles(options) - const config = normalizeHoloProjectConfig() - const generatedSchemaPath = resolveGeneratedSchemaPath(projectRoot, config) - const optionalPackages = normalizeScaffoldOptionalPackages(options.optionalPackages) - const storageEnabled = optionalPackages.includes('storage') - const queueEnabled = optionalPackages.includes('queue') - const eventsEnabled = optionalPackages.includes('events') - const authEnabled = optionalPackages.includes('auth') - const authorizationEnabled = optionalPackages.includes('authorization') - const notificationsEnabled = optionalPackages.includes('notifications') - const mailEnabled = optionalPackages.includes('mail') - const broadcastEnabled = optionalPackages.includes('broadcast') - const securityEnabled = optionalPackages.includes('security') - const cacheEnabled = optionalPackages.includes('cache') - const broadcastEnvFiles = broadcastEnabled ? renderBroadcastEnvFiles() : undefined - const baseEnv = normalizeScaffoldEnvSegments(env) - const baseExample = normalizeScaffoldEnvSegments(example) - const scaffoldEnvSegments = broadcastEnvFiles - ? [...baseEnv, ...broadcastEnvFiles.env] - : baseEnv - const scaffoldEnvExampleSegments = broadcastEnvFiles - ? [...baseExample, ...broadcastEnvFiles.example] - : baseExample - const scaffoldEnv = renderEnvFileContents(scaffoldEnvSegments) - const scaffoldEnvExample = renderEnvFileContents(scaffoldEnvExampleSegments) - - await mkdir(projectRoot, { recursive: true }) - await mkdir(resolve(projectRoot, 'config'), { recursive: true }) - await mkdir(resolve(projectRoot, '.holo-js', 'framework'), { recursive: true }) - await mkdir(resolve(projectRoot, config.paths.models), { recursive: true }) - await mkdir(resolve(projectRoot, config.paths.commands), { recursive: true }) - if (queueEnabled) { - await mkdir(resolve(projectRoot, config.paths.jobs), { recursive: true }) - } - if (eventsEnabled) { - await mkdir(resolve(projectRoot, config.paths.events), { recursive: true }) - await mkdir(resolve(projectRoot, config.paths.listeners), { recursive: true }) - } - if (authorizationEnabled) { - await mkdir(resolve(projectRoot, 'server/policies'), { recursive: true }) - await mkdir(resolve(projectRoot, 'server/abilities'), { recursive: true }) - } - if (mailEnabled) { - await mkdir(resolve(projectRoot, 'server/mail'), { recursive: true }) - } - if (broadcastEnabled) { - await mkdir(resolve(projectRoot, 'server/broadcast'), { recursive: true }) - await mkdir(resolve(projectRoot, 'server/channels'), { recursive: true }) - } - await mkdir(resolve(projectRoot, 'server/db/factories'), { recursive: true }) - await mkdir(resolve(projectRoot, 'server/db/migrations'), { recursive: true }) - await mkdir(resolve(projectRoot, 'server/db/seeders'), { recursive: true }) - await mkdir(resolve(projectRoot, 'server/db/schema'), { recursive: true }) - await mkdir(resolve(projectRoot, config.paths.observers), { recursive: true }) - await mkdir(resolve(projectRoot, 'storage'), { recursive: true }) - if (storageEnabled) { - await mkdir(resolve(projectRoot, 'storage/app/public'), { recursive: true }) - } - - await writeFile(resolve(projectRoot, 'package.json'), renderScaffoldPackageJson(options), 'utf8') - await writeFile(resolve(projectRoot, '.gitignore'), renderScaffoldGitignore(), 'utf8') - await writeFile(resolve(projectRoot, '.env'), scaffoldEnv, 'utf8') - await writeFile(resolve(projectRoot, '.env.example'), scaffoldEnvExample, 'utf8') - await writeFile(resolve(projectRoot, 'config/app.ts'), renderScaffoldAppConfig(options.projectName), 'utf8') - await writeFile(resolve(projectRoot, 'config/database.ts'), renderScaffoldDatabaseConfig(options), 'utf8') - await writeFile(resolve(projectRoot, 'config/redis.ts'), renderRedisConfig(), 'utf8') - if (queueEnabled) { - await writeFile(resolve(projectRoot, 'config/queue.ts'), renderQueueConfig({ - driver: 'sync', - defaultDatabaseConnection: 'main', - }), 'utf8') - } - if (notificationsEnabled) { - await writeFile(resolve(projectRoot, 'config/notifications.ts'), renderNotificationsConfig(), 'utf8') - for (const migrationFile of createNotificationsMigrationFiles()) { - await writeFile(resolve(projectRoot, config.paths.migrations, migrationFile.path), migrationFile.contents, 'utf8') - } - } - if (mailEnabled) { - await writeFile(resolve(projectRoot, 'config/mail.ts'), renderMailConfig(), 'utf8') - } - if (broadcastEnabled) { - await writeFile(resolve(projectRoot, 'config/broadcast.ts'), renderBroadcastConfig('esm', false, true), 'utf8') - } - if (securityEnabled) { - await writeFile(resolve(projectRoot, 'config/security.ts'), renderSecurityConfig(), 'utf8') - await ensureRateLimitStorageIgnore(projectRoot) - } - if (cacheEnabled) { - await writeFile(resolve(projectRoot, 'config/cache.ts'), renderCacheConfig('file', 'main'), 'utf8') - } - if (authEnabled) { - await writeFile(resolve(projectRoot, 'config/auth.ts'), renderAuthConfig(), 'utf8') - await writeFile(resolve(projectRoot, 'config/session.ts'), renderSessionConfig('main'), 'utf8') - const userModelPath = resolve(projectRoot, config.paths.models, 'User.ts') - await writeFile( - userModelPath, - renderAuthUserModel(resolveAuthUserModelSchemaImportPath( - userModelPath, - generatedSchemaPath, - )), - 'utf8', - ) - - for (const migrationFile of createAuthMigrationFiles()) { - await writeFile(resolve(projectRoot, config.paths.migrations, migrationFile.path), migrationFile.contents, 'utf8') - } - } - if (broadcastEnabled && authEnabled) { - await syncBroadcastAuthSupportAfterAuthInstall(projectRoot) - } - if (authorizationEnabled) { - await writeFile(resolve(projectRoot, 'server/policies/README.md'), renderAuthorizationPoliciesReadme(), 'utf8') - await writeFile(resolve(projectRoot, 'server/abilities/README.md'), renderAuthorizationAbilitiesReadme(), 'utf8') - } - if (storageEnabled) { - await writeFile(resolve(projectRoot, 'config/storage.ts'), renderStorageConfig(), 'utf8') - } - await writeFile(resolve(projectRoot, '.holo-js/framework/run.mjs'), renderFrameworkRunner(options), 'utf8') - await writeFile(resolve(projectRoot, '.holo-js/framework/project.json'), `${JSON.stringify(options, null, 2)}\n`, 'utf8') - await writeFile(resolve(projectRoot, 'tsconfig.json'), renderScaffoldTsconfig(options), 'utf8') - const vscodeSettings = renderVSCodeSettings(options) - if (vscodeSettings) { - await mkdir(resolve(projectRoot, '.vscode'), { recursive: true }) - await writeFile(resolve(projectRoot, '.vscode/settings.json'), vscodeSettings, 'utf8') - } - await writeFile(generatedSchemaPath, renderGeneratedSchemaPlaceholder(), 'utf8') - - for (const file of renderFrameworkFiles(options)) { - await writeTextFile(resolve(projectRoot, file.path), file.contents) - } - - if (options.databaseDriver === 'sqlite') { - await writeFile(resolve(projectRoot, 'storage/database.sqlite'), '', 'utf8') - } -} - export { authFeaturesRequireConfigUpdate, detectAuthInstallFeaturesFromConfig, @@ -3768,39 +642,42 @@ export { isSupportedCacheInstallerDriver, isSupportedQueueInstallerDriver, injectBroadcastAuthEndpoint, + normalizeScaffoldOptionalPackages, renderAuthConfig, + renderAuthEnvFiles, renderAuthMigration, renderAuthUserModel, - renderSessionConfig, - normalizeScaffoldOptionalPackages, + renderCacheConfig, + renderCacheEnvFiles, + renderEnvFileContents, renderFrameworkFiles, renderFrameworkRunner, - renderMediaConfig, renderMailConfig, - renderSecurityConfig, + renderMediaConfig, renderNotificationsConfig, renderNotificationsMigration, - renderCacheConfig, + normalizeScaffoldEnvSegments, renderQueueConfig, - renderRedisConfig, - renderCacheEnvFiles, renderQueueEnvFiles, + renderRedisConfig, renderScaffoldAppConfig, renderScaffoldDatabaseConfig, - renderEnvFileContents, + renderScaffoldEnvFiles, renderScaffoldGitignore, renderScaffoldPackageJson, renderScaffoldTsconfig, - renderVSCodeSettings, - renderScaffoldEnvFiles, - normalizeScaffoldEnvSegments, + renderSecurityConfig, + renderSessionConfig, renderStorageConfig, + renderVSCodeSettings, + resolveBroadcastConfigTargetPath, resolveDefaultDatabaseUrl, resolvePackageManagerVersion, - resolveBroadcastConfigTargetPath, sanitizePackageName, - upsertCachePackageDependencies, + scaffoldProject, + syncManagedDriverDependencies, upsertAuthPackageDependencies, + upsertCachePackageDependencies, upsertEventsPackageDependency, upsertMailPackageDependency, upsertNotificationsPackageDependency, diff --git a/packages/cli/src/project/scaffold/config-renderers.ts b/packages/cli/src/project/scaffold/config-renderers.ts new file mode 100644 index 0000000..bbeb77a --- /dev/null +++ b/packages/cli/src/project/scaffold/config-renderers.ts @@ -0,0 +1,883 @@ +import { appendFile, mkdir } from 'node:fs/promises' +import { extname, resolve } from 'node:path' +import { holoStorageDefaults } from '@holo-js/config' +import { + AUTH_CONFIG_FILE_NAMES, + BROADCAST_CONFIG_FILE_NAMES, + REDIS_CONFIG_FILE_NAMES, + SUPPORTED_AUTH_SOCIAL_PROVIDERS, + type SupportedCacheInstallerDriver, + type SupportedQueueInstallerDriver, + pathExists, +} from '../shared' +import { + detectProjectFrameworkFromPackageJson, + readPackageJsonDependencyState, +} from './dependencies' +import type { + AuthInstallFeatures, + ConfigModuleFormat, +} from './types' +import { + readTextFile, + resolveFirstExistingPath, + writeTextFile, +} from '../runtime' + +export function renderStorageConfig(): string { + return [ + 'import { defineStorageConfig, env } from \'@holo-js/config\'', + '', + 'export default defineStorageConfig({', + ` defaultDisk: env('STORAGE_DEFAULT_DISK', '${holoStorageDefaults.defaultDisk}'),`, + ` routePrefix: env('STORAGE_ROUTE_PREFIX', '${holoStorageDefaults.routePrefix}'),`, + ' disks: {', + ' local: {', + ' driver: \'local\',', + ' root: \'./storage/app\',', + ' },', + ' public: {', + ' driver: \'public\',', + ' root: \'./storage/app/public\',', + ' visibility: \'public\',', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export function renderMediaConfig(): string { + return [ + 'import { defineMediaConfig } from \'@holo-js/config\'', + '', + 'export default defineMediaConfig({})', + '', + ].join('\n') +} + +export function renderQueueConfig( + options: { + readonly driver?: SupportedQueueInstallerDriver + readonly defaultDatabaseConnection?: string + } = {}, +): string { + const driver = options.driver ?? 'sync' + const defaultDatabaseConnection = options.defaultDatabaseConnection?.trim() || 'default' + + if (driver === 'redis') { + return [ + 'import { defineQueueConfig, env } from \'@holo-js/config\'', + '', + 'export default defineQueueConfig({', + ' default: \'redis\',', + ' failed: false,', + ' connections: {', + ' redis: {', + ' driver: \'redis\',', + ' connection: \'default\',', + ' queue: \'default\',', + ' retryAfter: 90,', + ' blockFor: 5,', + ' },', + ' },', + '})', + '', + ].join('\n') + } + + if (driver === 'database') { + return [ + 'import { defineQueueConfig } from \'@holo-js/config\'', + '', + 'export default defineQueueConfig({', + ' default: \'database\',', + ' failed: {', + ' driver: \'database\',', + ` connection: '${defaultDatabaseConnection}',`, + ' table: \'failed_jobs\',', + ' },', + ' connections: {', + ' database: {', + ' driver: \'database\',', + ` connection: '${defaultDatabaseConnection}',`, + ' table: \'jobs\',', + ' queue: \'default\',', + ' retryAfter: 90,', + ' sleep: 1,', + ' },', + ' },', + '})', + '', + ].join('\n') + } + + return [ + 'import { defineQueueConfig } from \'@holo-js/config\'', + '', + 'export default defineQueueConfig({', + ' default: \'sync\',', + ' failed: false,', + ' connections: {', + ' sync: {', + ' driver: \'sync\',', + ' queue: \'default\',', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export function renderCacheConfig( + driver: SupportedCacheInstallerDriver = 'file', + defaultDatabaseConnection = 'default', + defaultRedisConnection = 'default', +): string { + const lines = [ + 'import { defineCacheConfig, env } from \'@holo-js/config\'', + '', + 'export default defineCacheConfig({', + ` default: '${driver}',`, + ' prefix: env(\'CACHE_PREFIX\', \'\'),', + ' drivers: {', + ' file: {', + ' driver: \'file\',', + ' path: \'./storage/framework/cache/data\',', + ' },', + ' memory: {', + ' driver: \'memory\',', + ' maxEntries: 1000,', + ' },', + ] + + if (driver === 'redis') { + lines.push( + ' redis: {', + ' driver: \'redis\',', + ` connection: '${defaultRedisConnection}',`, + ' prefix: \'cache:\',', + ' },', + ) + } + + if (driver === 'database') { + lines.push( + ' database: {', + ' driver: \'database\',', + ` connection: '${defaultDatabaseConnection}',`, + ' table: \'cache\',', + ' lockTable: \'cache_locks\',', + ' },', + ) + } + + lines.push( + ' },', + '})', + '', + ) + + return lines.join('\n') +} + +export function renderRedisConfig(): string { + return [ + 'import { defineRedisConfig, env } from \'@holo-js/config\'', + '', + 'export default defineRedisConfig({', + ' default: \'default\',', + ' connections: {', + ' default: {', + ' url: env(\'REDIS_URL\') || undefined,', + ' host: env(\'REDIS_HOST\', \'127.0.0.1\'),', + ' port: env(\'REDIS_PORT\', 6379),', + ' username: env(\'REDIS_USERNAME\'),', + ' password: env(\'REDIS_PASSWORD\'),', + ' db: env(\'REDIS_DB\', 0),', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export async function ensureRedisConfigFile(projectRoot: string): Promise { + const redisConfigPath = await resolveFirstExistingPath(projectRoot, REDIS_CONFIG_FILE_NAMES) ?? resolve(projectRoot, 'config/redis.ts') + const redisConfigExists = await pathExists(redisConfigPath) + + if (!redisConfigExists) { + await writeTextFile(redisConfigPath, renderRedisConfig()) + } + + return !redisConfigExists +} + +export function renderNotificationsConfig(): string { + return [ + 'import { defineNotificationsConfig } from \'@holo-js/config\'', + '', + 'export default defineNotificationsConfig({', + ' table: \'notifications\',', + ' queue: {', + ' afterCommit: false,', + ' },', + '})', + '', + ].join('\n') +} + +export function renderMailConfig(): string { + return [ + 'import { defineMailConfig, env } from \'@holo-js/config\'', + '', + 'export default defineMailConfig({', + ' default: env(\'MAIL_MAILER\', \'preview\'),', + ' from: {', + ' email: env(\'MAIL_FROM_ADDRESS\', \'hello@app.test\'),', + ' name: env(\'MAIL_FROM_NAME\', \'Holo App\'),', + ' },', + ' preview: {', + ' allowedEnvironments: [\'development\'],', + ' },', + ' mailers: {', + ' preview: {', + ' driver: \'preview\',', + ' },', + ' log: {', + ' driver: \'log\',', + ' },', + ' fake: {', + ' driver: \'fake\',', + ' },', + ' smtp: {', + ' driver: \'smtp\',', + ' host: env(\'MAIL_HOST\', \'127.0.0.1\'),', + ' port: env(\'MAIL_PORT\', 1025),', + ' secure: env(\'MAIL_SECURE\', false),', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export function renderSecurityConfig(): string { + return [ + `import { defineSecurityConfig, limit } from '@holo-js/security'`, + '', + 'export default defineSecurityConfig({', + ' csrf: {', + ' enabled: true,', + ' field: \'_token\',', + ' header: \'X-CSRF-TOKEN\',', + ' cookie: \'XSRF-TOKEN\',', + ' except: [],', + ' },', + ' rateLimit: {', + ' driver: \'file\',', + ' file: {', + ' path: \'./storage/framework/rate-limits\',', + ' },', + ' redis: {', + ' connection: \'default\',', + ' prefix: \'holo:rate-limit:\',', + ' },', + ' limiters: {', + ' login: limit.perMinute(5).define(),', + ' register: limit.perHour(10).define(),', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export async function ensureRateLimitStorageIgnore(projectRoot: string): Promise { + const rateLimitRoot = resolve(projectRoot, 'storage/framework/rate-limits') + const ignorePath = resolve(rateLimitRoot, '.gitignore') + await mkdir(rateLimitRoot, { recursive: true }) + + if (!(await pathExists(ignorePath))) { + await writeTextFile(ignorePath, '*\n!.gitignore\n') + return + } + + const currentContents = (await readTextFile(ignorePath)) ?? '' + const existingLines = new Set(currentContents.split(/\r?\n/)) + const missingLines = [ + '*', + '!.gitignore', + ].filter(line => !existingLines.has(line)) + + if (missingLines.length === 0) { + return + } + + await appendFile( + ignorePath, + `${currentContents.length > 0 && !currentContents.endsWith('\n') ? '\n' : ''}${missingLines.join('\n')}\n`, + 'utf8', + ) +} + +export function renderBroadcastConfig( + moduleFormat: ConfigModuleFormat, + includeAuthEndpoint: boolean, + useTypeScriptSyntax: boolean, +): string { + const renderBroadcastScheme = (): string => { + return useTypeScriptSyntax + ? "env('BROADCAST_SCHEME') === 'https' ? 'https' : 'http'" + : "(process.env.BROADCAST_SCHEME === 'https' ? 'https' : 'http')" + } + + if (moduleFormat === 'cjs') { + return [ + 'const { defineBroadcastConfig, env } = require(\'@holo-js/config\')', + '', + `const broadcastScheme = ${renderBroadcastScheme()}`, + '', + 'module.exports = defineBroadcastConfig({', + ' default: env(\'BROADCAST_CONNECTION\', \'holo\'),', + ' connections: {', + ' holo: {', + ' driver: \'holo\',', + ' appId: env(\'BROADCAST_APP_ID\', \'app-id\'),', + ' key: env(\'BROADCAST_APP_KEY\', \'app-key\'),', + ' secret: env(\'BROADCAST_APP_SECRET\', \'app-secret\'),', + ' options: {', + ' host: env(\'BROADCAST_HOST\', \'127.0.0.1\'),', + ' port: env(\'BROADCAST_PORT\', 8080),', + ' scheme: broadcastScheme,', + ' useTLS: broadcastScheme === \'https\',', + ' },', + ...(includeAuthEndpoint + ? [ + ' clientOptions: {', + ' authEndpoint: `${env(\'APP_URL\', \'http://localhost:3000\')}/broadcasting/auth`,', + ' },', + ] + : []), + ' },', + ' log: {', + ' driver: \'log\',', + ' },', + ' null: {', + ' driver: \'null\',', + ' },', + ' },', + '})', + '', + ].join('\n') + } + + return [ + 'import { defineBroadcastConfig, env } from \'@holo-js/config\'', + '', + `const broadcastScheme = ${renderBroadcastScheme()}`, + '', + 'export default defineBroadcastConfig({', + ' default: env(\'BROADCAST_CONNECTION\', \'holo\'),', + ' connections: {', + ' holo: {', + ' driver: \'holo\',', + ' appId: env(\'BROADCAST_APP_ID\', \'app-id\'),', + ' key: env(\'BROADCAST_APP_KEY\', \'app-key\'),', + ' secret: env(\'BROADCAST_APP_SECRET\', \'app-secret\'),', + ' options: {', + ' host: env(\'BROADCAST_HOST\', \'127.0.0.1\'),', + ' port: env(\'BROADCAST_PORT\', 8080),', + ' scheme: broadcastScheme,', + ' useTLS: broadcastScheme === \'https\',', + ' },', + ...(includeAuthEndpoint + ? [ + ' clientOptions: {', + ' authEndpoint: `${env(\'APP_URL\', \'http://localhost:3000\')}/broadcasting/auth`,', + ' },', + ] + : []), + ' },', + ' log: {', + ' driver: \'log\',', + ' },', + ' null: {', + ' driver: \'null\',', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export function stripBroadcastAuthEndpointBlock(value: string): string { + return value.replace( + /(^|\n)\s*clientOptions:\s*\{\n\s*authEndpoint:\s*.*,\n\s*\},/m, + '', + ) +} + +export function injectBroadcastAuthEndpoint(value: string): string | undefined { + if (value.includes('authEndpoint:')) { + return value + } + + const nextValue = value.replace( + /(holo:\s*\{[\s\S]*?options:\s*\{[\s\S]*?\n)([ \t]*)\},/m, + (_match, prefix: string, indent: string) => { + return [ + `${prefix}${indent}},`, + `${indent}clientOptions: {`, + `${indent} authEndpoint: \`\${env('APP_URL', 'http://localhost:3000')}/broadcasting/auth\`,`, + `${indent}},`, + ].join('\n') + }, + ) + + return nextValue === value ? undefined : nextValue +} + +function canSafelyRewriteBroadcastConfig( + currentContents: string, + moduleFormat: ConfigModuleFormat, + useTypeScriptSyntax: boolean, +): boolean { + return stripBroadcastAuthEndpointBlock(currentContents) === stripBroadcastAuthEndpointBlock( + renderBroadcastConfig(moduleFormat, false, useTypeScriptSyntax), + ) +} + +export function resolveBroadcastConfigTargetPath( + projectRoot: string, + manifestPath: string, + moduleFormat: ConfigModuleFormat, +): string { + const extension = extname(manifestPath) + const targetExtension = extension === '.cjs' || extension === '.cts' || extension === '.mjs' || extension === '.mts' + ? extension + : moduleFormat === 'cjs' + ? '.cjs' + : (extension === '.ts' || extension === '.js' ? extension : '.ts') + + return resolve(projectRoot, `config/broadcast${targetExtension}`) +} + +export function renderBroadcastEnvFiles(): { env: readonly string[], example: readonly string[] } { + const env = [ + 'BROADCAST_CONNECTION=holo', + ] + const example = [ + 'BROADCAST_CONNECTION=holo', + 'BROADCAST_APP_ID=', + 'BROADCAST_APP_KEY=', + 'BROADCAST_APP_SECRET=', + ] + + return { + env, + example, + } +} + +function renderNextBroadcastAuthRoute(): string { + return [ + 'import { renderBroadcastAuthResponse } from \'@holo-js/broadcast/auth\'', + 'import { holo } from \'@/server/holo\'', + '', + 'export async function POST(request: Request) {', + ' const app = await holo.getApp()', + ' const auth = await holo.getAuth()', + '', + ' return await renderBroadcastAuthResponse(request, {', + ' resolveUser: async () => await auth?.user(),', + ' channelAuth: {', + ' registry: {', + ' projectRoot: app.projectRoot,', + ' channels: app.registry?.channels ?? [],', + ' },', + ' },', + ' })', + '}', + '', + ].join('\n') +} + +function renderNuxtBroadcastAuthRoute(): string { + return [ + 'import { defineEventHandler, getHeaders, getRequestURL, readRawBody } from \'h3\'', + 'import { renderBroadcastAuthResponse } from \'@holo-js/broadcast/auth\'', + 'import { holo } from \'#imports\'', + '', + 'export default defineEventHandler(async (event) => {', + ' const app = await holo.getApp()', + ' const auth = await holo.getAuth()', + ' const headers = new Headers()', + ' for (const [key, value] of Object.entries(getHeaders(event))) {', + ' if (typeof value === \'string\') {', + ' headers.set(key, value)', + ' }', + ' }', + ' const request = new Request(getRequestURL(event), {', + ' method: event.method,', + ' headers,', + ' body: await readRawBody(event),', + ' })', + '', + ' return await renderBroadcastAuthResponse(request, {', + ' resolveUser: async () => await auth?.user(),', + ' channelAuth: {', + ' registry: {', + ' projectRoot: app.projectRoot,', + ' channels: app.registry?.channels ?? [],', + ' },', + ' },', + ' })', + '})', + '', + ].join('\n') +} + +function renderSvelteBroadcastAuthRoute(): string { + return [ + 'import { renderBroadcastAuthResponse } from \'@holo-js/broadcast/auth\'', + 'import { holo } from \'$lib/server/holo\'', + '', + 'export async function POST({ request }: { request: Request }) {', + ' const app = await holo.getApp()', + ' const auth = await holo.getAuth()', + '', + ' return await renderBroadcastAuthResponse(request, {', + ' resolveUser: async () => await auth?.user(),', + ' channelAuth: {', + ' registry: {', + ' projectRoot: app.projectRoot,', + ' channels: app.registry?.channels ?? [],', + ' },', + ' },', + ' })', + '}', + '', + ].join('\n') +} + +export async function syncBroadcastAuthSupportAfterAuthInstall(projectRoot: string): Promise<{ + readonly updatedBroadcastConfig: boolean + readonly createdBroadcastAuthRoute: boolean +}> { + const { dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const framework = detectProjectFrameworkFromPackageJson(dependencies, devDependencies) + const canCreateBroadcastAuthRoute = framework === 'next' || framework === 'nuxt' || framework === 'sveltekit' + const authConfigPath = await resolveFirstExistingPath(projectRoot, AUTH_CONFIG_FILE_NAMES) + const broadcastConfigPath = await resolveFirstExistingPath(projectRoot, BROADCAST_CONFIG_FILE_NAMES) + if (!authConfigPath || !broadcastConfigPath || !canCreateBroadcastAuthRoute) { + return { + updatedBroadcastConfig: false, + createdBroadcastAuthRoute: false, + } + } + + const currentBroadcastConfig = (await readTextFile(broadcastConfigPath))! + let updatedBroadcastConfig = false + let createdBroadcastAuthRoute = false + if (!currentBroadcastConfig.includes('authEndpoint:')) { + const broadcastConfigModuleFormat = resolveConfigModuleFormat(broadcastConfigPath, currentBroadcastConfig) + const broadcastConfigIsTypeScript = ['.ts', '.mts', '.cts'].includes(extname(broadcastConfigPath)) + const rewrittenBroadcastConfig = canSafelyRewriteBroadcastConfig( + currentBroadcastConfig, + broadcastConfigModuleFormat, + broadcastConfigIsTypeScript, + ) + ? renderBroadcastConfig(broadcastConfigModuleFormat, true, broadcastConfigIsTypeScript) + : injectBroadcastAuthEndpoint(currentBroadcastConfig) + if (rewrittenBroadcastConfig) { + await writeTextFile( + broadcastConfigPath, + rewrittenBroadcastConfig, + ) + updatedBroadcastConfig = true + } + } + + if (framework === 'next') { + const authRoutePath = resolve(projectRoot, 'app/broadcasting/auth/route.ts') + if (!(await pathExists(authRoutePath))) { + await writeTextFile(authRoutePath, renderNextBroadcastAuthRoute()) + createdBroadcastAuthRoute = true + } + return { + updatedBroadcastConfig, + createdBroadcastAuthRoute, + } + } + + if (framework === 'nuxt') { + const authRoutePath = resolve(projectRoot, 'server/routes/broadcasting/auth.post.ts') + if (!(await pathExists(authRoutePath))) { + await writeTextFile(authRoutePath, renderNuxtBroadcastAuthRoute()) + createdBroadcastAuthRoute = true + } + return { + updatedBroadcastConfig, + createdBroadcastAuthRoute, + } + } + + if (framework === 'sveltekit') { + const authRoutePath = resolve(projectRoot, 'src/routes/broadcasting/auth/+server.ts') + if (!(await pathExists(authRoutePath))) { + await writeTextFile(authRoutePath, renderSvelteBroadcastAuthRoute()) + createdBroadcastAuthRoute = true + } + } + + return { + updatedBroadcastConfig, + createdBroadcastAuthRoute, + } +} + +export function renderSessionConfig(defaultDatabaseConnection = 'default'): string { + return [ + 'import { defineSessionConfig, env } from \'@holo-js/config\'', + '', + "const sessionSameSite = env('SESSION_SAME_SITE') === 'strict'", + " ? 'strict'", + " : env('SESSION_SAME_SITE') === 'none'", + " ? 'none'", + " : 'lax'", + '', + 'export default defineSessionConfig({', + ' driver: env(\'SESSION_DRIVER\', \'file\'),', + ' stores: {', + ' database: {', + ' driver: \'database\',', + ` connection: env('SESSION_CONNECTION', '${defaultDatabaseConnection}'),`, + ' table: \'sessions\',', + ' },', + ' file: {', + ' driver: \'file\',', + ' path: \'./storage/framework/sessions\',', + ' },', + ' },', + ' cookie: {', + ' name: env(\'SESSION_COOKIE\', \'holo_session\'),', + ' path: env(\'SESSION_PATH\', \'/\'),', + ' domain: env(\'SESSION_DOMAIN\'),', + ' secure: env(\'SESSION_SECURE\', false),', + ' httpOnly: true,', + ' sameSite: sessionSameSite,', + ' },', + ' idleTimeout: env(\'SESSION_IDLE_TIMEOUT\', 120),', + ' absoluteLifetime: env(\'SESSION_LIFETIME\', 120),', + ' rememberMeLifetime: env(\'SESSION_REMEMBER_ME_LIFETIME\', 43200),', + '})', + '', + ].join('\n') +} + +export function renderAuthConfig( + features: AuthInstallFeatures = {}, + moduleFormat: ConfigModuleFormat = 'esm', +): string { + const envValue = (name: string, fallback?: string): string => { + if (moduleFormat === 'cjs') { + return typeof fallback === 'string' + ? `process.env.${name} || ${JSON.stringify(fallback)}` + : `process.env.${name}` + } + + return typeof fallback === 'string' + ? `env('${name}', ${JSON.stringify(fallback)})` + : `env('${name}')` + } + const socialEnabled = features.social === true || (features.socialProviders?.length ?? 0) > 0 + const socialProviders = features.socialProviders && features.socialProviders.length > 0 + ? features.socialProviders + : socialEnabled + ? ['google'] + : [] + const lines = [ + moduleFormat === 'cjs' + ? 'module.exports = {' + : 'import { defineAuthConfig, env } from \'@holo-js/config\'', + '', + ...(moduleFormat === 'cjs' ? [] : ['export default defineAuthConfig({']), + ' defaults: {', + ' guard: \'web\',', + ' passwords: \'users\',', + ' },', + ' guards: {', + ' web: {', + ' driver: \'session\',', + ' provider: \'users\',', + ' },', + ' // admin: {', + ' // driver: \'session\',', + ' // provider: \'admins\',', + ' // },', + ' },', + ' providers: {', + ' users: {', + ' model: \'User\',', + ' identifiers: [\'email\'],', + ' },', + ' // admins: {', + ' // model: \'Admin\',', + ' // identifiers: [\'email\'],', + ' // },', + ' },', + ' passwords: {', + ' users: {', + ' provider: \'users\',', + ' table: \'password_reset_tokens\',', + ' expire: 60,', + ' throttle: 60,', + ' },', + ' },', + ' emailVerification: {', + ' required: false,', + ' },', + ' personalAccessTokens: {', + ' defaultAbilities: [],', + ' },', + ` socialEncryptionKey: ${envValue('AUTH_SOCIAL_ENCRYPTION_KEY')},`, + ] + + if (socialProviders.length > 0) { + lines.push(' social: {') + for (const provider of socialProviders) { + const upper = provider.toUpperCase() + const defaultScopes = provider === 'google' + ? ['openid', 'email', 'profile'] + : provider === 'github' + ? ['read:user', 'user:email'] + : provider === 'discord' + ? ['identify', 'email'] + : provider === 'facebook' + ? ['email', 'public_profile'] + : provider === 'apple' + ? ['name', 'email'] + : ['openid', 'profile', 'email'] + lines.push( + ` ${provider}: {`, + ` clientId: ${envValue(`AUTH_${upper}_CLIENT_ID`)},`, + ` clientSecret: ${envValue(`AUTH_${upper}_CLIENT_SECRET`)},`, + ` redirectUri: ${envValue(`AUTH_${upper}_REDIRECT_URI`)},`, + ` scopes: [${defaultScopes.map(scope => `'${scope}'`).join(', ')}],`, + ' },', + ) + } + lines.push(' },') + } + + if (features.workos) { + lines.push( + ' workos: {', + ' dashboard: {', + ` clientId: ${envValue('WORKOS_CLIENT_ID')},`, + ` apiKey: ${envValue('WORKOS_API_KEY')},`, + ` cookiePassword: ${envValue('WORKOS_COOKIE_PASSWORD')},`, + ` redirectUri: ${envValue('WORKOS_REDIRECT_URI')},`, + ` sessionCookie: ${envValue('WORKOS_SESSION_COOKIE', 'wos-session')},`, + ' },', + ' },', + ' // Add a dedicated guard and provider if WorkOS users should resolve through a different model.', + ) + } + + if (features.clerk) { + lines.push( + ' clerk: {', + ' app: {', + ` publishableKey: ${envValue('CLERK_PUBLISHABLE_KEY')},`, + ` secretKey: ${envValue('CLERK_SECRET_KEY')},`, + ` jwtKey: ${envValue('CLERK_JWT_KEY')},`, + ` apiUrl: ${envValue('CLERK_API_URL')},`, + ` frontendApi: ${envValue('CLERK_FRONTEND_API')},`, + ` sessionCookie: ${envValue('CLERK_SESSION_COOKIE', '__session')},`, + ' },', + ' },', + ' // Add a dedicated guard and provider if Clerk users should resolve through a different model.', + ) + } + + lines.push(moduleFormat === 'cjs' ? '}' : '})', '') + return lines.join('\n') +} + +export function authFeaturesRequireConfigUpdate(features: AuthInstallFeatures): boolean { + return features.workos === true + || features.clerk === true + || features.social === true + || (features.socialProviders?.length ?? 0) > 0 +} + +export function detectAuthInstallFeaturesFromConfig(contents: string): AuthInstallFeatures { + const socialProviders = SUPPORTED_AUTH_SOCIAL_PROVIDERS.filter(provider => { + const pattern = new RegExp(`\\b${provider}\\s*:\\s*\\{`) + return pattern.test(contents) + }) + + return Object.freeze({ + ...(socialProviders.length > 0 ? { social: true, socialProviders } : {}), + ...(contents.includes(' workos: {') ? { workos: true } : {}), + ...(contents.includes(' clerk: {') ? { clerk: true } : {}), + }) +} + +function mergeAuthInstallFeatures( + current: AuthInstallFeatures, + requested: AuthInstallFeatures, +): AuthInstallFeatures { + const socialProviders = Array.from(new Set([ + ...(current.socialProviders ?? []), + ...(requested.socialProviders ?? []), + ])) + + return Object.freeze({ + ...(current.social === true || requested.social === true || socialProviders.length > 0 + ? { social: true } + : {}), + ...(socialProviders.length > 0 ? { socialProviders } : {}), + ...(current.workos === true || requested.workos === true ? { workos: true } : {}), + ...(current.clerk === true || requested.clerk === true ? { clerk: true } : {}), + }) +} + +export function canSafelyRewriteAuthConfig( + currentContents: string, + currentFeatures: AuthInstallFeatures, + moduleFormat: ConfigModuleFormat, +): boolean { + const stripLegacyCurrentUserEndpoint = (value: string): string => value.replace( + /(^|\n)\s*currentUserEndpoint:\s*\{\n\s*path:\s*.*,\n\s*\},/m, + '', + ) + + return stripLegacyCurrentUserEndpoint(currentContents) === stripLegacyCurrentUserEndpoint( + renderAuthConfig(currentFeatures, moduleFormat), + ) +} + +export function resolveConfigModuleFormat( + filePath: string | undefined, + contents: string, +): ConfigModuleFormat { + if ( + filePath?.endsWith('.cjs') + || filePath?.endsWith('.cts') + || contents.includes('module.exports =') + ) { + return 'cjs' + } + + return 'esm' +} + +export function mergeInstalledAuthFeatures( + current: AuthInstallFeatures, + requested: AuthInstallFeatures, +): AuthInstallFeatures { + return mergeAuthInstallFeatures(current, requested) +} diff --git a/packages/cli/src/project/scaffold/dependencies.ts b/packages/cli/src/project/scaffold/dependencies.ts new file mode 100644 index 0000000..ac1901b --- /dev/null +++ b/packages/cli/src/project/scaffold/dependencies.ts @@ -0,0 +1,812 @@ +import { resolve } from 'node:path' +import { loadConfigDirectory, type SupportedDatabaseDriver } from '@holo-js/config' +import { + ESBUILD_PACKAGE_VERSION, + HOLO_PACKAGE_VERSION, +} from '../../metadata' +import { loadProjectConfig } from '../config' +import { + AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES, + CACHE_CONFIG_FILE_NAMES, + DB_DRIVER_PACKAGE_NAMES, + type GeneratedProjectRegistry, + type SupportedAuthSocialProvider, + type SupportedCacheInstallerDriver, + type SupportedQueueInstallerDriver, + pathExists, +} from '../shared' +import { + readTextFile, + resolveFirstExistingPath, + writeTextFile, +} from '../runtime' +import { loadGeneratedProjectRegistry } from '../registry' +import type { LoadedConfigWithCache } from './types' + +const IOREDIS_PACKAGE_VERSION = '^5.4.2' + +function normalizeDependencyMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + return Object.fromEntries( + Object.entries(value) + .filter(([, dependencyVersion]) => typeof dependencyVersion === 'string') + .sort(([left], [right]) => left.localeCompare(right)), + ) +} + +export async function readPackageJsonDependencyState(projectRoot: string): Promise<{ + packageJsonPath: string + parsed: Record + dependencies: Record + devDependencies: Record +}> { + const packageJsonPath = resolve(projectRoot, 'package.json') + const existing = await readTextFile(packageJsonPath) + if (!existing) { + throw new Error(`Missing package.json in ${projectRoot}.`) + } + + let parsed: Record + try { + parsed = JSON.parse(existing) as Record + } catch { + throw new Error(`Invalid package.json in ${projectRoot}.`) + } + + return { + packageJsonPath, + parsed, + dependencies: normalizeDependencyMap(parsed.dependencies), + devDependencies: normalizeDependencyMap(parsed.devDependencies), + } +} + +async function writePackageJsonDependencyState( + packageJsonPath: string, + parsed: Record, + dependencies: Record, + devDependencies: Record, +): Promise { + parsed.dependencies = Object.fromEntries( + Object.entries(dependencies).sort(([left], [right]) => left.localeCompare(right)), + ) + + if (Object.keys(devDependencies).length > 0) { + parsed.devDependencies = Object.fromEntries( + Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)), + ) + } else { + delete parsed.devDependencies + } + + await writeTextFile(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`) +} + +export function hasLoadedConfigFile( + loadedFiles: readonly string[], + configName: string, +): boolean { + return loadedFiles.some((filePath) => { + const normalizedPath = filePath.replaceAll('\\', '/') + return normalizedPath.endsWith(`/config/${configName}.ts`) + || normalizedPath.endsWith(`/config/${configName}.mts`) + || normalizedPath.endsWith(`/config/${configName}.js`) + || normalizedPath.endsWith(`/config/${configName}.mjs`) + || normalizedPath.endsWith(`/config/${configName}.cts`) + || normalizedPath.endsWith(`/config/${configName}.cjs`) + }) +} + +export function inferDatabaseDriverFromUrl(value: string | undefined): SupportedDatabaseDriver | undefined { + if (!value) { + return undefined + } + + const normalized = value.trim().toLowerCase() + if (normalized.startsWith('postgres://') || normalized.startsWith('postgresql://')) { + return 'postgres' + } + + if (normalized.startsWith('mysql://') || normalized.startsWith('mysql2://')) { + return 'mysql' + } + + if ( + normalized === ':memory:' + || normalized.startsWith('file:') + || normalized.startsWith('/') + || normalized.startsWith('./') + || normalized.startsWith('../') + || normalized.endsWith('.db') + || normalized.endsWith('.sqlite') + || normalized.endsWith('.sqlite3') + ) { + return 'sqlite' + } + + return undefined +} + +export function inferConnectionDriver( + connection: { + driver?: string + url?: string + filename?: string + } | string, +): SupportedDatabaseDriver | undefined { + if (typeof connection === 'string') { + return inferDatabaseDriverFromUrl(connection) + } + + const explicitDriver = connection.driver + if (explicitDriver === 'sqlite' || explicitDriver === 'postgres' || explicitDriver === 'mysql') { + return explicitDriver + } + + return inferDatabaseDriverFromUrl(connection.url ?? connection.filename) +} + +function registryHasJobs( + registry: GeneratedProjectRegistry | undefined, +): boolean { + return (registry?.jobs.length ?? 0) > 0 +} + +function registryHasEvents( + registry: GeneratedProjectRegistry | undefined, +): boolean { + return (registry?.events.length ?? 0) > 0 + || (registry?.listeners.length ?? 0) > 0 +} + +function registryHasBroadcastDefinitions( + registry: GeneratedProjectRegistry | undefined, +): boolean { + return (registry?.broadcast.length ?? 0) > 0 + || (registry?.channels.length ?? 0) > 0 +} + +function registryHasAuthorizationDefinitions( + registry: GeneratedProjectRegistry | undefined, +): boolean { + return (registry?.authorizationPolicies.length ?? 0) > 0 + || (registry?.authorizationAbilities.length ?? 0) > 0 +} + +function authConfigUsesSocialProviders( + loaded: Awaited>, +): boolean { + return Object.keys(loaded.auth.social).length > 0 +} + +function authConfigUsesWorkosProviders( + loaded: Awaited>, +): boolean { + return Object.keys(loaded.auth.workos).length > 0 +} + +function authConfigUsesClerkProviders( + loaded: Awaited>, +): boolean { + return Object.keys(loaded.auth.clerk).length > 0 +} + +function mailConfigUsesQueue( + loaded: Awaited>, +): boolean { + return loaded.mail.queue.queued + || Object.values(loaded.mail.mailers).some(mailer => mailer.queue.queued) +} + +async function projectHasAuthorizationScaffold(projectRoot: string): Promise { + const project = await loadProjectConfig(projectRoot) + const policiesRoot = resolve(projectRoot, project.config.paths.authorizationPolicies ?? 'server/policies') + const abilitiesRoot = resolve(projectRoot, project.config.paths.authorizationAbilities ?? 'server/abilities') + + return await pathExists(policiesRoot) || await pathExists(abilitiesRoot) +} + +async function projectHasEventsScaffold(projectRoot: string): Promise { + const project = await loadProjectConfig(projectRoot) + const eventsRoot = resolve(projectRoot, project.config.paths.events) + const listenersRoot = resolve(projectRoot, project.config.paths.listeners) + + return await pathExists(eventsRoot) || await pathExists(listenersRoot) +} + +export async function syncManagedDriverDependencies( + projectRoot: string, + registry?: GeneratedProjectRegistry, +): Promise { + const loaded = await loadConfigDirectory(projectRoot, { + preferCache: false, + processEnv: process.env, + }) as LoadedConfigWithCache + const discoveredRegistry = registry ?? await loadGeneratedProjectRegistry(projectRoot) + const authConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'auth') + const broadcastConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'broadcast') + const cacheConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'cache') + const mailConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'mail') + const notificationsConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'notifications') + const queueConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'queue') + const securityConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'security') + const sessionConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'session') + const storageConfigured = hasLoadedConfigFile(loaded.loadedFiles, 'storage') + const requiredPackages = new Set() + const hasAuthorizationScaffold = await projectHasAuthorizationScaffold(projectRoot) + const hasEventsScaffold = await projectHasEventsScaffold(projectRoot) + const { + packageJsonPath, + parsed, + dependencies, + devDependencies, + } = await readPackageJsonDependencyState(projectRoot) + const cachePackageInstalled = typeof dependencies['@holo-js/cache'] !== 'undefined' + || typeof devDependencies['@holo-js/cache'] !== 'undefined' + const cacheDesired = cacheConfigured + + requiredPackages.add('@holo-js/core') + + for (const connection of Object.values(loaded.database.connections)) { + const inferredDriver = inferConnectionDriver(connection) + if (inferredDriver) { + requiredPackages.add(DB_DRIVER_PACKAGE_NAMES[inferredDriver]) + } + } + + if (authConfigured || sessionConfigured) { + requiredPackages.add('@holo-js/session') + } + + if (authConfigured || securityConfigured) { + requiredPackages.add('@holo-js/security') + } + + if (authConfigured) { + requiredPackages.add('@holo-js/auth') + + if (authConfigUsesSocialProviders(loaded)) { + requiredPackages.add('@holo-js/auth-social') + + for (const [providerName, provider] of Object.entries(loaded.auth.social)) { + if (typeof provider.runtime === 'string' && provider.runtime.trim()) { + continue + } + + const builtinPackage = AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES[providerName as SupportedAuthSocialProvider] + if (builtinPackage) { + requiredPackages.add(builtinPackage) + } + } + } + + if (authConfigUsesWorkosProviders(loaded)) { + requiredPackages.add('@holo-js/auth-workos') + } + + if (authConfigUsesClerkProviders(loaded)) { + requiredPackages.add('@holo-js/auth-clerk') + } + } + + if (mailConfigured) { + requiredPackages.add('@holo-js/mail') + } + + if (cacheDesired) { + requiredPackages.add('@holo-js/cache') + } + + if (cacheConfigured) { + const cacheDrivers = Object.values(loaded.cache.drivers) + if (cacheDrivers.some(driver => driver.driver === 'redis')) { + requiredPackages.add('@holo-js/cache-redis') + } + + if (cacheDrivers.some(driver => driver.driver === 'database')) { + requiredPackages.add('@holo-js/cache-db') + } + } + + if (notificationsConfigured) { + requiredPackages.add('@holo-js/notifications') + } + + if (broadcastConfigured || registryHasBroadcastDefinitions(discoveredRegistry)) { + requiredPackages.add('@holo-js/broadcast') + } + + if (registryHasAuthorizationDefinitions(discoveredRegistry) || hasAuthorizationScaffold) { + requiredPackages.add('@holo-js/authorization') + } + + if (registryHasEvents(discoveredRegistry) || hasEventsScaffold) { + requiredPackages.add('@holo-js/events') + requiredPackages.add('@holo-js/queue') + } + + if (queueConfigured || registryHasJobs(discoveredRegistry) || mailConfigUsesQueue(loaded)) { + requiredPackages.add('@holo-js/queue') + + if (queueConfigured) { + const queueConnections = Object.values(loaded.queue.connections) + if (queueConnections.some(connection => connection.driver === 'redis')) { + requiredPackages.add('@holo-js/queue-redis') + } + + if ( + queueConnections.some(connection => connection.driver === 'database') + || loaded.queue.failed !== false + ) { + requiredPackages.add('@holo-js/queue-db') + } + } + } + + if ( + Object.values(loaded.cache?.drivers ?? {}).some(driver => driver.driver === 'redis') + || loaded.security?.rateLimit?.driver === 'redis' + || Object.values(loaded.session?.stores ?? {}).some(store => store.driver === 'redis') + || (loaded.broadcast?.worker != null && loaded.broadcast.worker.scaling !== false) + ) { + requiredPackages.add('ioredis') + } + + if (storageConfigured) { + requiredPackages.add('@holo-js/storage') + + if (Object.values(loaded.storage.disks).some(disk => disk.driver === 's3')) { + requiredPackages.add('@holo-js/storage-s3') + } + } + + let changed = false + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const removableManagedPackages = new Set([ + '@holo-js/core', + ...Object.values(DB_DRIVER_PACKAGE_NAMES), + '@holo-js/auth', + '@holo-js/auth-clerk', + '@holo-js/auth-social', + '@holo-js/auth-workos', + '@holo-js/authorization', + '@holo-js/broadcast', + '@holo-js/cache-db', + '@holo-js/cache-redis', + '@holo-js/events', + '@holo-js/mail', + '@holo-js/notifications', + '@holo-js/queue', + '@holo-js/queue-db', + '@holo-js/queue-redis', + '@holo-js/security', + '@holo-js/session', + '@holo-js/storage', + '@holo-js/storage-s3', + ...Object.values(AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES), + 'ioredis', + ]) + + if (cacheDesired || cachePackageInstalled) { + removableManagedPackages.add('@holo-js/cache') + } + + for (const packageName of requiredPackages) { + const requiredVersion = packageName === 'ioredis' + ? IOREDIS_PACKAGE_VERSION + : nextVersion + if (dependencies[packageName] !== requiredVersion || typeof devDependencies[packageName] !== 'undefined') { + dependencies[packageName] = requiredVersion + delete devDependencies[packageName] + changed = true + } + } + + for (const packageName of removableManagedPackages) { + if (requiredPackages.has(packageName)) { + continue + } + + if (typeof dependencies[packageName] !== 'undefined' || typeof devDependencies[packageName] !== 'undefined') { + delete dependencies[packageName] + delete devDependencies[packageName] + changed = true + } + } + + if (!changed) { + return false + } + + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +async function upsertQueuePackageDependency( + projectRoot: string, + driver?: SupportedQueueInstallerDriver, +): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const queueConfigPath = await resolveFirstExistingPath(projectRoot, ['config/queue.ts', 'config/queue.mts', 'config/queue.js', 'config/queue.mjs', 'config/queue.cts', 'config/queue.cjs']) + const loadedQueueConfig = queueConfigPath + ? loadConfigDirectory(projectRoot, { + preferCache: false, + processEnv: process.env, + }).then(config => config.queue) + .catch(() => undefined) + : Promise.resolve(undefined) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const nextEsbuildVersion = ESBUILD_PACKAGE_VERSION + const queueConfig = typeof driver === 'undefined' + ? await loadedQueueConfig + : undefined + const resolvedQueueDriver = driver && driver !== 'sync' + ? driver + : queueConfig?.connections[queueConfig.default]?.driver ?? driver + const requiresQueueDb = resolvedQueueDriver === 'database' + || (queueConfig?.failed ?? false) !== false + || Object.values(queueConfig?.connections ?? {}).some(connection => connection.driver === 'database') + const requiresQueueRedis = resolvedQueueDriver === 'redis' + || Object.values(queueConfig?.connections ?? {}).some(connection => connection.driver === 'redis') + const currentVersion = dependencies['@holo-js/queue'] + const currentQueueDbVersion = dependencies['@holo-js/queue-db'] + const currentQueueRedisVersion = dependencies['@holo-js/queue-redis'] + const currentDevVersion = devDependencies['@holo-js/queue'] + const currentDevQueueDbVersion = devDependencies['@holo-js/queue-db'] + const currentDevQueueRedisVersion = devDependencies['@holo-js/queue-redis'] + const currentEsbuildVersion = dependencies.esbuild + const currentDevEsbuildVersion = devDependencies.esbuild + + if ( + currentVersion === nextVersion + && (requiresQueueDb ? currentQueueDbVersion === nextVersion : typeof currentQueueDbVersion === 'undefined') + && (requiresQueueRedis ? currentQueueRedisVersion === nextVersion : typeof currentQueueRedisVersion === 'undefined') + && typeof currentDevVersion === 'undefined' + && typeof currentDevQueueDbVersion === 'undefined' + && typeof currentDevQueueRedisVersion === 'undefined' + && currentEsbuildVersion === nextEsbuildVersion + && typeof currentDevEsbuildVersion === 'undefined' + ) { + return false + } + + dependencies['@holo-js/queue'] = nextVersion + if (requiresQueueDb) { + dependencies['@holo-js/queue-db'] = nextVersion + } else { + delete dependencies['@holo-js/queue-db'] + } + if (requiresQueueRedis) { + dependencies['@holo-js/queue-redis'] = nextVersion + } else { + delete dependencies['@holo-js/queue-redis'] + } + dependencies.esbuild = nextEsbuildVersion + delete devDependencies['@holo-js/queue'] + delete devDependencies['@holo-js/queue-db'] + delete devDependencies['@holo-js/queue-redis'] + delete devDependencies.esbuild + + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +async function upsertEventsPackageDependency(projectRoot: string): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const currentVersion = dependencies['@holo-js/events'] + const currentDevVersion = devDependencies['@holo-js/events'] + + if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { + return false + } + + dependencies['@holo-js/events'] = nextVersion + delete devDependencies['@holo-js/events'] + + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +async function upsertNotificationsPackageDependency(projectRoot: string): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const currentVersion = dependencies['@holo-js/notifications'] + const currentDevVersion = devDependencies['@holo-js/notifications'] + + if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { + return false + } + + dependencies['@holo-js/notifications'] = nextVersion + delete devDependencies['@holo-js/notifications'] + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +async function upsertMailPackageDependency(projectRoot: string): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const currentVersion = dependencies['@holo-js/mail'] + const currentDevVersion = devDependencies['@holo-js/mail'] + + if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { + return false + } + + dependencies['@holo-js/mail'] = nextVersion + delete devDependencies['@holo-js/mail'] + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +async function upsertSecurityPackageDependency(projectRoot: string): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const currentVersion = dependencies['@holo-js/security'] + const currentDevVersion = devDependencies['@holo-js/security'] + + if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { + return false + } + + dependencies['@holo-js/security'] = nextVersion + delete devDependencies['@holo-js/security'] + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +async function upsertCachePackageDependencies( + projectRoot: string, + driver: SupportedCacheInstallerDriver = 'file', +): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const cacheConfigPath = await resolveFirstExistingPath(projectRoot, CACHE_CONFIG_FILE_NAMES) + const cacheConfig = cacheConfigPath + ? await loadConfigDirectory(projectRoot, { + preferCache: false, + processEnv: process.env, + }).then(config => (config as LoadedConfigWithCache).cache) + .catch(() => undefined) + : undefined + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const requiresCacheRedis = driver === 'redis' + || Object.values(cacheConfig?.drivers ?? {}).some(connection => connection.driver === 'redis') + const requiresCacheDb = driver === 'database' + || Object.values(cacheConfig?.drivers ?? {}).some(connection => connection.driver === 'database') + const currentVersion = dependencies['@holo-js/cache'] + const currentCacheDbVersion = dependencies['@holo-js/cache-db'] + const currentCacheRedisVersion = dependencies['@holo-js/cache-redis'] + const currentDevVersion = devDependencies['@holo-js/cache'] + const currentDevCacheDbVersion = devDependencies['@holo-js/cache-db'] + const currentDevCacheRedisVersion = devDependencies['@holo-js/cache-redis'] + + if ( + currentVersion === nextVersion + && (requiresCacheDb ? currentCacheDbVersion === nextVersion : typeof currentCacheDbVersion === 'undefined') + && (requiresCacheRedis ? currentCacheRedisVersion === nextVersion : typeof currentCacheRedisVersion === 'undefined') + && typeof currentDevVersion === 'undefined' + && typeof currentDevCacheDbVersion === 'undefined' + && typeof currentDevCacheRedisVersion === 'undefined' + ) { + return false + } + + dependencies['@holo-js/cache'] = nextVersion + if (requiresCacheDb) { + dependencies['@holo-js/cache-db'] = nextVersion + } else { + delete dependencies['@holo-js/cache-db'] + } + if (requiresCacheRedis) { + dependencies['@holo-js/cache-redis'] = nextVersion + } else { + delete dependencies['@holo-js/cache-redis'] + } + delete devDependencies['@holo-js/cache'] + delete devDependencies['@holo-js/cache-db'] + delete devDependencies['@holo-js/cache-redis'] + + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +export function detectProjectFrameworkFromPackageJson( + dependencies: Record, + devDependencies: Record, +): 'next' | 'nuxt' | 'sveltekit' | undefined { + if (dependencies.next || devDependencies.next) { + return 'next' + } + + if (dependencies.nuxt || devDependencies.nuxt) { + return 'nuxt' + } + + if (dependencies['@sveltejs/kit'] || devDependencies['@sveltejs/kit']) { + return 'sveltekit' + } + + return undefined +} + +export async function upsertBroadcastPackageDependencies(projectRoot: string): Promise<{ + readonly updated: boolean + readonly framework: 'next' | 'nuxt' | 'sveltekit' | undefined +}> { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const framework = detectProjectFrameworkFromPackageJson(dependencies, devDependencies) + let changed = false + + const requestedPackages = new Set([ + '@holo-js/broadcast', + '@holo-js/flux', + ]) + + if (framework === 'next') { + requestedPackages.add('@holo-js/flux-react') + requestedPackages.add('@holo-js/adapter-next') + } else if (framework === 'nuxt') { + requestedPackages.add('@holo-js/flux-vue') + requestedPackages.add('@holo-js/adapter-nuxt') + } else if (framework === 'sveltekit') { + requestedPackages.add('@holo-js/flux-svelte') + requestedPackages.add('@holo-js/adapter-sveltekit') + } + + const frameworkPackages = new Set([ + '@holo-js/flux-react', + '@holo-js/adapter-next', + '@holo-js/flux-vue', + '@holo-js/adapter-nuxt', + '@holo-js/flux-svelte', + '@holo-js/adapter-sveltekit', + ]) + const managedPackages = new Set([ + ...requestedPackages, + ...frameworkPackages, + ]) + + for (const packageName of managedPackages) { + if (!requestedPackages.has(packageName)) { + if (typeof dependencies[packageName] !== 'undefined' || typeof devDependencies[packageName] !== 'undefined') { + delete dependencies[packageName] + delete devDependencies[packageName] + changed = true + } + + continue + } + + if (dependencies[packageName] !== nextVersion || typeof devDependencies[packageName] !== 'undefined') { + dependencies[packageName] = nextVersion + delete devDependencies[packageName] + changed = true + } + } + + if (!changed) { + return { + updated: false, + framework, + } + } + + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return { + updated: true, + framework, + } +} + +async function upsertAuthPackageDependencies( + projectRoot: string, + features: { + readonly social?: boolean + readonly socialProviders?: readonly SupportedAuthSocialProvider[] + readonly workos?: boolean + readonly clerk?: boolean + } = {}, +): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const socialEnabled = features.social === true || (features.socialProviders?.length ?? 0) > 0 + const requestedPackages = { + '@holo-js/auth': true, + '@holo-js/session': true, + '@holo-js/auth-social': socialEnabled, + '@holo-js/auth-workos': features.workos === true, + '@holo-js/auth-clerk': features.clerk === true, + } as const + const requestedSocialProviders = new Set(features.socialProviders ?? (socialEnabled ? ['google'] : [])) + + let changed = false + + for (const [packageName, enabled] of Object.entries(requestedPackages)) { + const currentDependency = dependencies[packageName] + const currentDevDependency = devDependencies[packageName] + + if (enabled) { + if (currentDependency !== nextVersion || typeof currentDevDependency !== 'undefined') { + dependencies[packageName] = nextVersion + delete devDependencies[packageName] + changed = true + } + continue + } + + if (typeof currentDevDependency !== 'undefined') { + delete devDependencies[packageName] + changed = true + } + + if (typeof currentDependency !== 'undefined') { + delete dependencies[packageName] + changed = true + } + } + + for (const [providerName, packageName] of Object.entries(AUTH_SOCIAL_PROVIDER_PACKAGE_NAMES)) { + const enabled = requestedSocialProviders.has(providerName as SupportedAuthSocialProvider) + const currentDependency = dependencies[packageName] + const currentDevDependency = devDependencies[packageName] + + if (enabled) { + if (currentDependency !== nextVersion || typeof currentDevDependency !== 'undefined') { + dependencies[packageName] = nextVersion + delete devDependencies[packageName] + changed = true + } + continue + } + + if (typeof currentDevDependency !== 'undefined') { + delete devDependencies[packageName] + changed = true + } + + if (typeof currentDependency !== 'undefined') { + delete dependencies[packageName] + changed = true + } + } + + if (!changed) { + return false + } + + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +async function upsertAuthorizationPackageDependency(projectRoot: string): Promise { + const { packageJsonPath, parsed, dependencies, devDependencies } = await readPackageJsonDependencyState(projectRoot) + const nextVersion = `^${HOLO_PACKAGE_VERSION}` + const currentVersion = dependencies['@holo-js/authorization'] + const currentDevVersion = devDependencies['@holo-js/authorization'] + + if (currentVersion === nextVersion && typeof currentDevVersion === 'undefined') { + return false + } + + dependencies['@holo-js/authorization'] = nextVersion + delete devDependencies['@holo-js/authorization'] + + await writePackageJsonDependencyState(packageJsonPath, parsed, dependencies, devDependencies) + return true +} + +export { + upsertAuthPackageDependencies, + upsertAuthorizationPackageDependency, + upsertCachePackageDependencies, + upsertEventsPackageDependency, + upsertMailPackageDependency, + upsertNotificationsPackageDependency, + upsertQueuePackageDependency, + upsertSecurityPackageDependency, +} diff --git a/packages/cli/src/project/scaffold/framework-renderers.ts b/packages/cli/src/project/scaffold/framework-renderers.ts new file mode 100644 index 0000000..a89559a --- /dev/null +++ b/packages/cli/src/project/scaffold/framework-renderers.ts @@ -0,0 +1,698 @@ +import { + normalizeScaffoldOptionalPackages, + type ProjectScaffoldOptions, +} from '../shared' +import type { ScaffoldedFile } from './types' + +function renderNuxtAppVue(projectName: string): string { + return [ + '', + '', + '', + '', + '', + '', + ].join('\n') +} + +function renderNuxtConfig(): string { + return [ + 'export default defineNuxtConfig({', + ' modules: [\'@holo-js/adapter-nuxt\'],', + ' sourcemap: {', + ' client: false,', + ' server: false,', + ' },', + ' vite: {', + ' build: {', + ' rollupOptions: {', + ' onwarn(warning, defaultHandler) {', + ' if (', + ' warning.message.includes(\'nuxt:module-preload-polyfill\')', + ' && warning.message.includes(\'didn\\\'t generate a sourcemap\')', + ' ) {', + ' return', + ' }', + '', + ' defaultHandler(warning)', + ' },', + ' },', + ' },', + ' },', + '})', + '', + ].join('\n') +} + +function renderNuxtHealthRoute(): string { + return [ + 'export default defineEventHandler(async () => {', + ' const app = await holo.getApp()', + '', + ' return {', + ' ok: true,', + ' app: app.config.app.name,', + ' env: app.config.app.env,', + ' models: app.registry?.models.length ?? 0,', + ' commands: app.registry?.commands.length ?? 0,', + ' }', + '})', + '', + ].join('\n') +} + +function renderNextConfig(): string { + return [ + 'import type { NextConfig } from \'next\'', + 'import { withHolo } from \'@holo-js/adapter-next/config\'', + '', + 'const nextConfig: NextConfig = withHolo({', + ' /* config options here */', + '})', + '', + 'export default nextConfig', + '', + ].join('\n') +} + +function renderNextLayout(projectName: string): string { + return [ + 'import \'../server/db/schema.generated\'', + '', + 'import type { ReactNode } from \'react\'', + '', + 'export const metadata = {', + ` title: ${JSON.stringify(projectName)},`, + ' description: \'Holo on Next.js\',', + '}', + '', + 'export default function RootLayout({ children }: { children: ReactNode }) {', + ' return (', + ' ', + ' {children}', + ' ', + ' )', + '}', + '', + ].join('\n') +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', ''') + .replaceAll('{', '{') + .replaceAll('}', '}') +} + +function renderNextPage(projectName: string): string { + const escapedProjectName = escapeHtml(projectName) + + return [ + 'export default function HomePage() {', + ' return (', + '
', + `

${escapedProjectName}

`, + '

Next.js handles rendering. Holo powers the backend runtime and discovered server resources.

', + '
', + ' )', + '}', + '', + ].join('\n') +} + +function renderNextEnvDts(): string { + return [ + '/// ', + '/// ', + '', + '// Generated by Holo. Do not edit.', + '', + ].join('\n') +} + +export function renderNextHoloHelper(): string { + return [ + 'import \'./db/schema.generated\'', + '', + 'import { createNextHoloHelpers } from \'@holo-js/adapter-next\'', + '', + 'export const holo = createNextHoloHelpers()', + '', + ].join('\n') +} + +function renderNextInstrumentation(): string { + return [ + 'export async function register() {', + ' if (process.env.NEXT_RUNTIME === \'nodejs\') {', + ' const { holo } = await import(\'@/server/holo\')', + ' await holo.getApp()', + ' }', + '}', + '', + ].join('\n') +} + +function renderNextHealthRoute(): string { + return [ + 'import { holo } from \'@/server/holo\'', + '', + 'export async function GET() {', + ' const app = await holo.getApp()', + '', + ' return Response.json({', + ' ok: true,', + ' app: app.config.app.name,', + ' env: app.config.app.env,', + ' models: app.registry?.models.length ?? 0,', + ' commands: app.registry?.commands.length ?? 0,', + ' })', + '}', + '', + ].join('\n') +} + +function renderNextStorageRoute(): string { + return [ + 'import { holo } from \'@/server/holo\'', + 'import { createPublicStorageResponse } from \'@holo-js/storage\'', + '', + 'export async function GET(request: Request) {', + ' const app = await holo.getApp()', + ' return createPublicStorageResponse(app.projectRoot, app.config.storage, request)', + '}', + '', + ].join('\n') +} + +function renderSvelteConfig(): string { + return [ + 'import adapter from \'@sveltejs/adapter-node\'', + 'import { vitePreprocess } from \'@sveltejs/vite-plugin-svelte\'', + '', + '/** @type {import(\'@sveltejs/kit\').Config} */', + 'const config = {', + ' preprocess: vitePreprocess(),', + ' kit: {', + ' adapter: adapter(),', + ' files: {', + ' hooks: {', + ' server: \'.holo-js/generated/hooks.server\',', + ' universal: \'.holo-js/generated/hooks\',', + ' },', + ' },', + ' },', + '}', + '', + 'export default config', + '', + ].join('\n') +} + +function renderSvelteUserHooks(): string { + return [ + 'export {}', + '', + ].join('\n') +} + +function renderSvelteServerUserHooks(): string { + return [ + 'export {}', + '', + ].join('\n') +} + +function renderSvelteViteConfig(storageEnabled: boolean): string { + const externals = [ + ' \'@holo-js/adapter-sveltekit\',', + ' \'@holo-js/config\',', + ' \'@holo-js/core\',', + ' \'@holo-js/db\',', + ...(storageEnabled + ? [ + ' \'@holo-js/storage\',', + ' \'@holo-js/storage/runtime\',', + ] + : []), + ' \'better-sqlite3\',', + ] + + return [ + 'import { sveltekit } from \'@sveltejs/kit/vite\'', + 'import { defineConfig } from \'vite\'', + '', + 'export default defineConfig({', + ' plugins: [sveltekit()],', + ' server: {', + ' fs: {', + ' allow: [\'.holo-js/generated\'],', + ' },', + ' },', + ' ssr: {', + ' external: [', + ...externals, + ' ],', + ' },', + '})', + '', + ].join('\n') +} + +function renderSvelteAppHtml(): string { + return [ + '', + '', + ' ', + ' ', + ' ', + ' %sveltekit.head%', + ' ', + ' ', + '
%sveltekit.body%
', + ' ', + '', + '', + ].join('\n') +} + +function renderSveltePage(projectName: string): string { + const escapedProjectName = escapeHtml(projectName) + + return [ + `${escapedProjectName}`, + '', + '', + '', + '
', + '

{projectName}

', + '

SvelteKit owns rendering. Holo owns config, discovery, and backend runtime services.

', + '
', + '', + '', + '', + ].join('\n') +} + +export function renderSvelteHoloHelper(): string { + return [ + 'import \'../../../server/db/schema.generated\'', + '', + 'import { createSvelteKitHoloHelpers } from \'@holo-js/adapter-sveltekit\'', + '', + 'export const holo = createSvelteKitHoloHelpers()', + '', + ].join('\n') +} + +function renderSvelteHealthRoute(): string { + return [ + 'import { json } from \'@sveltejs/kit\'', + 'import { holo } from \'$lib/server/holo\'', + '', + 'export async function GET() {', + ' const app = await holo.getApp()', + '', + ' return json({', + ' ok: true,', + ' app: app.config.app.name,', + ' env: app.config.app.env,', + ' models: app.registry?.models.length ?? 0,', + ' commands: app.registry?.commands.length ?? 0,', + ' })', + '}', + '', + ].join('\n') +} + +function renderSvelteStorageRoute(): string { + return [ + 'import { holo } from \'$lib/server/holo\'', + 'import { createPublicStorageResponse } from \'@holo-js/storage\'', + '', + 'export async function GET({ request }: { request: Request }) {', + ' const app = await holo.getApp()', + ' return createPublicStorageResponse(app.projectRoot, app.config.storage, request)', + '}', + '', + ].join('\n') +} + +export function renderFrameworkFiles(options: ProjectScaffoldOptions): readonly ScaffoldedFile[] { + const optionalPackages = normalizeScaffoldOptionalPackages(options.optionalPackages) + const storageEnabled = optionalPackages.includes('storage') + + if (options.framework === 'nuxt') { + return [ + { path: 'app.vue', contents: renderNuxtAppVue(options.projectName) }, + { path: 'nuxt.config.ts', contents: renderNuxtConfig() }, + { path: 'server/api/holo/health.get.ts', contents: renderNuxtHealthRoute() }, + ] + } + + if (options.framework === 'next') { + return [ + { path: 'next.config.ts', contents: renderNextConfig() }, + { path: 'next-env.d.ts', contents: renderNextEnvDts() }, + { path: 'app/layout.tsx', contents: renderNextLayout(options.projectName) }, + { path: 'app/page.tsx', contents: renderNextPage(options.projectName) }, + { path: 'app/api/holo/health/route.ts', contents: renderNextHealthRoute() }, + ...(storageEnabled + ? [{ path: 'app/storage/[[...path]]/route.ts', contents: renderNextStorageRoute() }] + : []), + { path: 'server/holo.ts', contents: renderNextHoloHelper() }, + { path: 'instrumentation.ts', contents: renderNextInstrumentation() }, + ] + } + + return [ + { path: 'svelte.config.js', contents: renderSvelteConfig() }, + { path: 'vite.config.ts', contents: renderSvelteViteConfig(storageEnabled) }, + { path: 'src/hooks.ts', contents: renderSvelteUserHooks() }, + { path: 'src/hooks.server.ts', contents: renderSvelteServerUserHooks() }, + { path: 'src/app.html', contents: renderSvelteAppHtml() }, + { path: 'src/routes/+page.svelte', contents: renderSveltePage(options.projectName) }, + { path: 'src/routes/api/holo/health/+server.ts', contents: renderSvelteHealthRoute() }, + ...(storageEnabled + ? [{ path: 'src/routes/storage/[...path]/+server.ts', contents: renderSvelteStorageRoute() }] + : []), + { path: 'src/lib/server/holo.ts', contents: renderSvelteHoloHelper() }, + ] +} + +export function renderFrameworkRunner(options: Pick): string { + const commandName = options.framework === 'nuxt' + ? 'nuxi' + : options.framework === 'next' + ? 'next' + : 'vite' + return [ + 'import { existsSync, readFileSync, readlinkSync } from \'node:fs\'', + 'import { dirname, resolve } from \'node:path\'', + 'import { fileURLToPath } from \'node:url\'', + 'import { execFileSync, spawn } from \'node:child_process\'', + '', + 'const mode = process.argv[2]', + 'const manifestPath = fileURLToPath(new URL(\'./project.json\', import.meta.url))', + 'const projectRoot = resolve(dirname(manifestPath), \'../..\')', + 'const manifest = JSON.parse(readFileSync(manifestPath, \'utf8\'))', + 'const framework = String(manifest.framework ?? \'\')', + `const commandName = ${JSON.stringify(commandName)}`, + 'const commandArgs = mode === \'dev\'', + ' ? [\'dev\']', + ' : mode === \'build\'', + ' ? framework === \'sveltekit\' ? [\'build\', \'--logLevel\', \'error\'] : [\'build\']', + ' : undefined', + '', + 'if (!commandArgs) {', + ' console.error(`[holo] Unknown framework runner mode: ${String(mode)}`)', + ' process.exit(1)', + '}', + '', + 'const binaryPath = resolve(', + ' projectRoot,', + ' \'node_modules\',', + ' \'.bin\',', + ' process.platform === \'win32\' ? `${commandName}.cmd` : commandName,', + ')', + '', + 'const suppressedOutput = framework === \'sveltekit\'', + ' ? new Set([', + ' \'"try_get_request_store" is imported from external module "@sveltejs/kit/internal/server" but never used in ".svelte-kit/adapter-node/index.js".\',', + ' ])', + ' : new Set()', + '', + 'function pipeOutput(stream, target, onLine) {', + ' if (!stream) {', + ' return', + ' }', + '', + ' let buffered = \'\'', + ' stream.on(\'data\', (chunk) => {', + ' buffered += chunk.toString()', + ' const lines = buffered.split(/\\r?\\n/)', + ' buffered = lines.pop() ?? \'\'', + ' for (const line of lines) {', + ' onLine?.(line)', + ' if (!suppressedOutput.has(line)) {', + ' target.write(`${line}\\n`)', + ' }', + ' }', + ' })', + '', + ' stream.on(\'end\', () => {', + ' if (buffered.length > 0) {', + ' onLine?.(buffered)', + ' }', + ' if (buffered.length > 0 && !suppressedOutput.has(buffered)) {', + ' target.write(buffered)', + ' }', + ' })', + '}', + '', + 'function extractNextConflictInfo(lines) {', + ' if (framework !== \'next\' || mode !== \'dev\') {', + ' return undefined', + ' }', + '', + ' if (!lines.some(line => line.includes(\'Another next dev server is already running.\'))) {', + ' return undefined', + ' }', + '', + ' let pid', + ' let dir', + '', + ' for (const line of lines) {', + ' const match = line.match(/^- PID:\\s+(\\d+)\\s*$/)', + ' if (match) {', + ' pid = Number.parseInt(match[1], 10)', + ' continue', + ' }', + '', + ' const dirMatch = line.match(/^- Dir:\\s+(.+?)\\s*$/)', + ' if (dirMatch) {', + ' dir = dirMatch[1]', + ' }', + ' }', + '', + ' return typeof pid === \'number\' ? { pid, dir } : undefined', + '}', + '', + 'async function waitForProcessExit(pid, timeoutMs = 5000) {', + ' const deadline = Date.now() + timeoutMs', + ' while (Date.now() < deadline) {', + ' try {', + ' process.kill(pid, 0)', + ' } catch (error) {', + ' if (error && typeof error === \'object\' && \'code\' in error && error.code === \'ESRCH\') {', + ' return true', + ' }', + ' throw error', + ' }', + '', + ' await new Promise(resolve => setTimeout(resolve, 100))', + ' }', + '', + ' return false', + '}', + '', + 'function inspectProcess(pid) {', + ' try {', + ' if (process.platform === \'linux\' && existsSync(`/proc/${pid}`)) {', + ' return {', + ' cwd: readlinkSync(`/proc/${pid}/cwd`),', + ' args: readFileSync(`/proc/${pid}/cmdline`, \'utf8\').replaceAll(\'\\u0000\', \' \').trim(),', + ' }', + ' }', + ' } catch {}', + '', + ' try {', + ' return {', + ' args: execFileSync(\'ps\', [\'-p\', String(pid), \'-o\', \'args=\'], {', + ' encoding: \'utf8\',', + ' }).trim(),', + ' }', + ' } catch {', + ' return undefined', + ' }', + '}', + '', + 'function isOwnedNextDevServer(pid, reportedDir) {', + ' const expectedDir = typeof reportedDir === \'string\' ? resolve(reportedDir) : undefined', + ' if (expectedDir && expectedDir !== projectRoot) {', + ' return false', + ' }', + '', + ' const details = inspectProcess(pid)', + ' if (!details) {', + ' return expectedDir === projectRoot', + ' }', + '', + ' const argsMatch = details.args.includes(\'next\') && details.args.includes(\'dev\')', + ' const cwdMatches = typeof details.cwd === \'string\' && resolve(details.cwd) === projectRoot', + ' const argsReferenceProject = details.args.includes(projectRoot)', + '', + ' return argsMatch && (cwdMatches || argsReferenceProject || expectedDir === projectRoot)', + '}', + '', + 'async function stopStaleNextDevServer(pid, reportedDir) {', + ' if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) {', + ' return false', + ' }', + '', + ' if (!isOwnedNextDevServer(pid, reportedDir)) {', + ' return false', + ' }', + '', + ' try {', + ' process.kill(pid, \'SIGTERM\')', + ' } catch (error) {', + ' if (error && typeof error === \'object\' && \'code\' in error && error.code === \'ESRCH\') {', + ' return true', + ' }', + ' return false', + ' }', + '', + ' return waitForProcessExit(pid)', + '}', + '', + 'if (!existsSync(binaryPath)) {', + ' console.error(`[holo] Missing framework binary "${commandName}" for "${framework}". Run your package manager install first.`)', + ' process.exit(1)', + '}', + '', + 'let child = null', + 'let forwardedSignal = null', + '', + 'function detachSignalForwarders() {', + ' process.removeListener(\'SIGINT\', onSigint)', + ' process.removeListener(\'SIGTERM\', onSigterm)', + '}', + '', + 'function forwardSignal(signal) {', + ' if (forwardedSignal || !child || child.exitCode !== null) {', + ' return', + ' }', + '', + ' forwardedSignal = signal', + ' child.kill(signal)', + '}', + '', + 'function onSigint() {', + ' detachSignalForwarders()', + ' forwardSignal(\'SIGINT\')', + '}', + '', + 'function onSigterm() {', + ' detachSignalForwarders()', + ' forwardSignal(\'SIGTERM\')', + '}', + '', + 'process.on(\'SIGINT\', onSigint)', + 'process.on(\'SIGTERM\', onSigterm)', + '', + 'async function run() {', + ' let restartedAfterConflict = false', + ' const maxStderrLines = 200', + '', + ' while (true) {', + ' const stderrLines = []', + ' child = spawn(binaryPath, commandArgs, {', + ' cwd: projectRoot,', + ' env: process.env,', + ' stdio: [\'inherit\', \'pipe\', \'pipe\'],', + ' })', + ' forwardedSignal = null', + '', + ' pipeOutput(child.stdout, process.stdout)', + ' pipeOutput(child.stderr, process.stderr, line => {', + ' if (stderrLines.length >= maxStderrLines) {', + ' stderrLines.shift()', + ' }', + ' stderrLines.push(line)', + ' })', + '', + ' const result = await new Promise((resolve, reject) => {', + ' child.on(\'error\', reject)', + ' child.on(\'close\', (code, signal) => resolve({ code, signal }))', + ' })', + '', + ' if (result.code === 0) {', + ' process.exit(0)', + ' }', + '', + ' const conflictInfo = extractNextConflictInfo(stderrLines)', + ' if (!restartedAfterConflict && conflictInfo) {', + ' const stopped = await stopStaleNextDevServer(conflictInfo.pid, conflictInfo.dir)', + ' if (stopped) {', + ' restartedAfterConflict = true', + ' console.error(`[holo] Stopped stale Next dev server ${conflictInfo.pid}. Restarting dev server.`)', + ' continue', + ' }', + ' }', + '', + ' if (result.signal) {', + ' detachSignalForwarders()', + ' process.kill(process.pid, result.signal)', + ' } else {', + ' process.exit(result.code ?? 1)', + ' }', + ' }', + '}', + '', + 'run().catch((error) => {', + ' console.error(error instanceof Error ? error.message : String(error))', + ' process.exit(1)', + '})', + '', + ].join('\n') +} diff --git a/packages/cli/src/project/scaffold/framework.ts b/packages/cli/src/project/scaffold/framework.ts new file mode 100644 index 0000000..ebd90c8 --- /dev/null +++ b/packages/cli/src/project/scaffold/framework.ts @@ -0,0 +1,349 @@ +import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + normalizeHoloProjectConfig, + renderGeneratedSchemaPlaceholder, +} from '@holo-js/db' +import { + ESBUILD_PACKAGE_VERSION, + HOLO_PACKAGE_VERSION, + SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS, + SCAFFOLD_FRAMEWORK_RUNTIME_VERSIONS, + SCAFFOLD_FRAMEWORK_VERSIONS, + SCAFFOLD_PACKAGE_MANAGER_VERSIONS, +} from '../../metadata' +import { resolveGeneratedSchemaPath } from '../config' +import { + DB_DRIVER_PACKAGE_NAMES, + normalizeScaffoldOptionalPackages, + sanitizePackageName, + type ProjectScaffoldOptions, + type SupportedScaffoldPackageManager, +} from '../shared' +import { writeTextFile } from '../runtime' +import { + ensureRateLimitStorageIgnore, + renderAuthConfig, + renderBroadcastConfig, + renderBroadcastEnvFiles, + renderCacheConfig, + renderMailConfig, + renderNotificationsConfig, + renderQueueConfig, + renderRedisConfig, + renderSecurityConfig, + renderSessionConfig, + renderStorageConfig, + syncBroadcastAuthSupportAfterAuthInstall, +} from './config-renderers' +import { + createAuthMigrationFiles, + createNotificationsMigrationFiles, + normalizeScaffoldEnvSegments, + renderAuthUserModel, + renderAuthorizationAbilitiesReadme, + renderAuthorizationPoliciesReadme, + renderEnvFileContents, + renderScaffoldAppConfig, + renderScaffoldDatabaseConfig, + renderScaffoldEnvFiles, + resolveAuthUserModelSchemaImportPath, +} from './project-renderers' +import { + renderScaffoldGitignore, + renderScaffoldTsconfig, + renderVSCodeSettings, +} from './workspace-renderers' +import { + renderFrameworkFiles, + renderFrameworkRunner, +} from './framework-renderers' + +export { + renderFrameworkFiles, + renderFrameworkRunner, + renderNextHoloHelper, + renderSvelteHoloHelper, +} from './framework-renderers' + +export function resolvePackageManagerVersion(value: SupportedScaffoldPackageManager): string { + return SCAFFOLD_PACKAGE_MANAGER_VERSIONS[value] +} + +export function renderScaffoldPackageJson(options: ProjectScaffoldOptions): string { + const packageName = sanitizePackageName(options.projectName) || 'holo-app' + const optionalPackages = normalizeScaffoldOptionalPackages(options.optionalPackages) + const dependencies: Record = { + '@holo-js/cli': `^${HOLO_PACKAGE_VERSION}`, + '@holo-js/config': `^${HOLO_PACKAGE_VERSION}`, + '@holo-js/core': `^${HOLO_PACKAGE_VERSION}`, + '@holo-js/db': `^${HOLO_PACKAGE_VERSION}`, + [DB_DRIVER_PACKAGE_NAMES[options.databaseDriver]]: `^${HOLO_PACKAGE_VERSION}`, + esbuild: ESBUILD_PACKAGE_VERSION, + } + const devDependencies: Record = { + typescript: '^5.8.0', + '@types/node': '^22.0.0', + } + + if (options.framework === 'nuxt') { + dependencies.nuxt = SCAFFOLD_FRAMEWORK_VERSIONS.nuxt + dependencies.vue = '^3.5.13' + dependencies['vue-router'] = '^4.1.6' + dependencies['@holo-js/adapter-nuxt'] = SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS.nuxt + devDependencies.vite = '^5.4.14' + devDependencies['vue-tsc'] = '^2.2.0' + } + + if (options.framework === 'next') { + dependencies.next = SCAFFOLD_FRAMEWORK_VERSIONS.next + dependencies.react = '^19.0.0' + dependencies['react-dom'] = '^19.0.0' + dependencies['@holo-js/adapter-next'] = SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS.next + devDependencies['@types/react'] = '^19.0.0' + devDependencies['@types/react-dom'] = '^19.0.0' + } + + if (options.framework === 'sveltekit') { + dependencies['@holo-js/adapter-sveltekit'] = SCAFFOLD_FRAMEWORK_ADAPTER_VERSIONS.sveltekit + dependencies['@sveltejs/adapter-node'] = '^5.0.0' + dependencies['@sveltejs/kit'] = SCAFFOLD_FRAMEWORK_VERSIONS.sveltekit + dependencies['@sveltejs/vite-plugin-svelte'] = '^4.0.0' + dependencies.svelte = '^5.0.0' + dependencies.vite = '^5.0.0' + } + + if (optionalPackages.includes('storage')) { + dependencies['@holo-js/storage'] = SCAFFOLD_FRAMEWORK_RUNTIME_VERSIONS[options.framework]['@holo-js/storage'] + } + + if (optionalPackages.includes('events')) { + dependencies['@holo-js/events'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('queue')) { + dependencies['@holo-js/queue'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('validation')) { + dependencies['@holo-js/validation'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('forms')) { + dependencies['@holo-js/forms'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('auth')) { + dependencies['@holo-js/auth'] = `^${HOLO_PACKAGE_VERSION}` + dependencies['@holo-js/session'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('authorization')) { + dependencies['@holo-js/authorization'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('notifications')) { + dependencies['@holo-js/notifications'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('mail')) { + dependencies['@holo-js/mail'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('broadcast')) { + dependencies['@holo-js/broadcast'] = `^${HOLO_PACKAGE_VERSION}` + dependencies['@holo-js/flux'] = `^${HOLO_PACKAGE_VERSION}` + if (options.framework === 'next') { + dependencies['@holo-js/flux-react'] = `^${HOLO_PACKAGE_VERSION}` + } else if (options.framework === 'nuxt') { + dependencies['@holo-js/flux-vue'] = `^${HOLO_PACKAGE_VERSION}` + } else if (options.framework === 'sveltekit') { + dependencies['@holo-js/flux-svelte'] = `^${HOLO_PACKAGE_VERSION}` + } + } + + if (optionalPackages.includes('security')) { + dependencies['@holo-js/security'] = `^${HOLO_PACKAGE_VERSION}` + } + + if (optionalPackages.includes('cache')) { + dependencies['@holo-js/cache'] = `^${HOLO_PACKAGE_VERSION}` + } + + return `${JSON.stringify({ + name: packageName, + private: true, + type: 'module', + packageManager: resolvePackageManagerVersion(options.packageManager), + scripts: { + ...(options.framework === 'nuxt' + ? { postinstall: 'nuxt prepare' } + : {}), + prepare: 'holo prepare', + dev: 'holo dev', + build: 'holo build', + lint: options.framework === 'nuxt' + ? 'npx eslint app.vue config server tests --fix --no-warn-ignored' + : options.framework === 'next' + ? 'npx eslint app config server tests --fix --no-warn-ignored' + : 'npx eslint src config server tests --fix --no-warn-ignored', + typecheck: options.framework === 'nuxt' + ? 'npx nuxi typecheck' + : options.framework === 'next' + ? 'npx tsc -p tsconfig.json --noEmit' + : 'npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json', + ['config:cache']: 'holo config:cache', + ['config:clear']: 'holo config:clear', + ['holo:dev']: 'node ./.holo-js/framework/run.mjs dev', + ['holo:build']: 'node ./.holo-js/framework/run.mjs build', + }, + dependencies, + devDependencies, + }, null, 2)}\n` +} + +export async function scaffoldProject( + projectRoot: string, + options: ProjectScaffoldOptions, +): Promise { + const existingEntries = await readdir(projectRoot).catch(() => [] as string[]) + if (existingEntries.length > 0) { + throw new Error(`Refusing to scaffold into a non-empty directory: ${projectRoot}`) + } + + const { env, example } = renderScaffoldEnvFiles(options) + const config = normalizeHoloProjectConfig() + const generatedSchemaPath = resolveGeneratedSchemaPath(projectRoot, config) + const optionalPackages = normalizeScaffoldOptionalPackages(options.optionalPackages) + const storageEnabled = optionalPackages.includes('storage') + const queueEnabled = optionalPackages.includes('queue') + const eventsEnabled = optionalPackages.includes('events') + const authEnabled = optionalPackages.includes('auth') + const authorizationEnabled = optionalPackages.includes('authorization') + const notificationsEnabled = optionalPackages.includes('notifications') + const mailEnabled = optionalPackages.includes('mail') + const broadcastEnabled = optionalPackages.includes('broadcast') + const securityEnabled = optionalPackages.includes('security') + const cacheEnabled = optionalPackages.includes('cache') + const broadcastEnvFiles = broadcastEnabled ? renderBroadcastEnvFiles() : undefined + const baseEnv = normalizeScaffoldEnvSegments(env) + const baseExample = normalizeScaffoldEnvSegments(example) + const scaffoldEnvSegments = broadcastEnvFiles + ? [...baseEnv, ...broadcastEnvFiles.env] + : baseEnv + const scaffoldEnvExampleSegments = broadcastEnvFiles + ? [...baseExample, ...broadcastEnvFiles.example] + : baseExample + const scaffoldEnv = renderEnvFileContents(scaffoldEnvSegments) + const scaffoldEnvExample = renderEnvFileContents(scaffoldEnvExampleSegments) + + await mkdir(projectRoot, { recursive: true }) + await mkdir(resolve(projectRoot, 'config'), { recursive: true }) + await mkdir(resolve(projectRoot, '.holo-js', 'framework'), { recursive: true }) + await mkdir(resolve(projectRoot, config.paths.models), { recursive: true }) + await mkdir(resolve(projectRoot, config.paths.commands), { recursive: true }) + if (queueEnabled) { + await mkdir(resolve(projectRoot, config.paths.jobs), { recursive: true }) + } + if (eventsEnabled) { + await mkdir(resolve(projectRoot, config.paths.events), { recursive: true }) + await mkdir(resolve(projectRoot, config.paths.listeners), { recursive: true }) + } + if (authorizationEnabled) { + await mkdir(resolve(projectRoot, 'server/policies'), { recursive: true }) + await mkdir(resolve(projectRoot, 'server/abilities'), { recursive: true }) + } + if (mailEnabled) { + await mkdir(resolve(projectRoot, 'server/mail'), { recursive: true }) + } + if (broadcastEnabled) { + await mkdir(resolve(projectRoot, 'server/broadcast'), { recursive: true }) + await mkdir(resolve(projectRoot, 'server/channels'), { recursive: true }) + } + await mkdir(resolve(projectRoot, 'server/db/factories'), { recursive: true }) + await mkdir(resolve(projectRoot, 'server/db/migrations'), { recursive: true }) + await mkdir(resolve(projectRoot, 'server/db/seeders'), { recursive: true }) + await mkdir(resolve(projectRoot, 'server/db/schema'), { recursive: true }) + await mkdir(resolve(projectRoot, config.paths.observers), { recursive: true }) + await mkdir(resolve(projectRoot, 'storage'), { recursive: true }) + if (storageEnabled) { + await mkdir(resolve(projectRoot, 'storage/app/public'), { recursive: true }) + } + + await writeFile(resolve(projectRoot, 'package.json'), renderScaffoldPackageJson(options), 'utf8') + await writeFile(resolve(projectRoot, '.gitignore'), renderScaffoldGitignore(), 'utf8') + await writeFile(resolve(projectRoot, '.env'), scaffoldEnv, 'utf8') + await writeFile(resolve(projectRoot, '.env.example'), scaffoldEnvExample, 'utf8') + await writeFile(resolve(projectRoot, 'config/app.ts'), renderScaffoldAppConfig(options.projectName), 'utf8') + await writeFile(resolve(projectRoot, 'config/database.ts'), renderScaffoldDatabaseConfig(options), 'utf8') + await writeFile(resolve(projectRoot, 'config/redis.ts'), renderRedisConfig(), 'utf8') + if (queueEnabled) { + await writeFile(resolve(projectRoot, 'config/queue.ts'), renderQueueConfig({ + driver: 'sync', + defaultDatabaseConnection: 'main', + }), 'utf8') + } + if (notificationsEnabled) { + await writeFile(resolve(projectRoot, 'config/notifications.ts'), renderNotificationsConfig(), 'utf8') + for (const migrationFile of createNotificationsMigrationFiles()) { + await writeFile(resolve(projectRoot, config.paths.migrations, migrationFile.path), migrationFile.contents, 'utf8') + } + } + if (mailEnabled) { + await writeFile(resolve(projectRoot, 'config/mail.ts'), renderMailConfig(), 'utf8') + } + if (broadcastEnabled) { + await writeFile(resolve(projectRoot, 'config/broadcast.ts'), renderBroadcastConfig('esm', false, true), 'utf8') + } + if (securityEnabled) { + await writeFile(resolve(projectRoot, 'config/security.ts'), renderSecurityConfig(), 'utf8') + await ensureRateLimitStorageIgnore(projectRoot) + } + if (cacheEnabled) { + await writeFile(resolve(projectRoot, 'config/cache.ts'), renderCacheConfig('file', 'main'), 'utf8') + } + if (authEnabled) { + await writeFile(resolve(projectRoot, 'config/auth.ts'), renderAuthConfig(), 'utf8') + await writeFile(resolve(projectRoot, 'config/session.ts'), renderSessionConfig('main'), 'utf8') + const userModelPath = resolve(projectRoot, config.paths.models, 'User.ts') + await writeFile( + userModelPath, + renderAuthUserModel(resolveAuthUserModelSchemaImportPath( + userModelPath, + generatedSchemaPath, + )), + 'utf8', + ) + + for (const migrationFile of createAuthMigrationFiles()) { + await writeFile(resolve(projectRoot, config.paths.migrations, migrationFile.path), migrationFile.contents, 'utf8') + } + } + if (broadcastEnabled && authEnabled) { + await syncBroadcastAuthSupportAfterAuthInstall(projectRoot) + } + if (authorizationEnabled) { + await writeFile(resolve(projectRoot, 'server/policies/README.md'), renderAuthorizationPoliciesReadme(), 'utf8') + await writeFile(resolve(projectRoot, 'server/abilities/README.md'), renderAuthorizationAbilitiesReadme(), 'utf8') + } + if (storageEnabled) { + await writeFile(resolve(projectRoot, 'config/storage.ts'), renderStorageConfig(), 'utf8') + } + await writeFile(resolve(projectRoot, '.holo-js/framework/run.mjs'), renderFrameworkRunner(options), 'utf8') + await writeFile(resolve(projectRoot, '.holo-js/framework/project.json'), `${JSON.stringify(options, null, 2)}\n`, 'utf8') + await writeFile(resolve(projectRoot, 'tsconfig.json'), renderScaffoldTsconfig(options), 'utf8') + const vscodeSettings = renderVSCodeSettings(options) + if (vscodeSettings) { + await mkdir(resolve(projectRoot, '.vscode'), { recursive: true }) + await writeFile(resolve(projectRoot, '.vscode/settings.json'), vscodeSettings, 'utf8') + } + await writeFile(generatedSchemaPath, renderGeneratedSchemaPlaceholder(), 'utf8') + + for (const file of renderFrameworkFiles(options)) { + await writeTextFile(resolve(projectRoot, file.path), file.contents) + } + + if (options.databaseDriver === 'sqlite') { + await writeFile(resolve(projectRoot, 'storage/database.sqlite'), '', 'utf8') + } +} diff --git a/packages/cli/src/project/scaffold/project-renderers.ts b/packages/cli/src/project/scaffold/project-renderers.ts new file mode 100644 index 0000000..05bc27e --- /dev/null +++ b/packages/cli/src/project/scaffold/project-renderers.ts @@ -0,0 +1,593 @@ +import type { SupportedDatabaseDriver } from '@holo-js/config' +import { + createMigrationFileName, +} from '@holo-js/db' +import { relativeImportPath } from '../../templates' +import { + normalizeScaffoldOptionalPackages, + sanitizePackageName, + type ProjectScaffoldOptions, + type SupportedCacheInstallerDriver, + type SupportedQueueInstallerDriver, +} from '../shared' +import { + AUTH_MIGRATION_SLUGS, + type AuthInstallFeatures, + type AuthMigrationSlug, + type ScaffoldedFile, +} from './types' + +export function renderAuthEnvFiles( + features: AuthInstallFeatures = {}, + defaultDatabaseConnection = 'default', +): { env: readonly string[], example: readonly string[] } { + const socialEnabled = features.social === true || (features.socialProviders?.length ?? 0) > 0 + const socialProviders = features.socialProviders && features.socialProviders.length > 0 + ? features.socialProviders + : socialEnabled + ? ['google'] + : [] + const env = [ + 'AUTH_SOCIAL_ENCRYPTION_KEY=', + 'SESSION_DRIVER=file', + `SESSION_CONNECTION=${defaultDatabaseConnection}`, + 'SESSION_COOKIE=holo_session', + 'SESSION_PATH=/', + 'SESSION_DOMAIN=', + 'SESSION_SECURE=false', + 'SESSION_SAME_SITE=lax', + 'SESSION_IDLE_TIMEOUT=120', + 'SESSION_LIFETIME=120', + 'SESSION_REMEMBER_ME_LIFETIME=43200', + ] + + for (const provider of socialProviders) { + const upper = provider.toUpperCase() + env.push( + `AUTH_${upper}_CLIENT_ID=`, + `AUTH_${upper}_CLIENT_SECRET=`, + `AUTH_${upper}_REDIRECT_URI=`, + ) + } + + if (features.workos) { + env.push( + 'WORKOS_CLIENT_ID=', + 'WORKOS_API_KEY=', + 'WORKOS_COOKIE_PASSWORD=', + 'WORKOS_REDIRECT_URI=', + 'WORKOS_SESSION_COOKIE=wos-session', + ) + } + + if (features.clerk) { + env.push( + 'CLERK_PUBLISHABLE_KEY=', + 'CLERK_SECRET_KEY=', + 'CLERK_JWT_KEY=', + 'CLERK_API_URL=', + 'CLERK_FRONTEND_API=', + 'CLERK_SESSION_COOKIE=__session', + ) + } + + return { + env, + example: env.map(line => `${line.split('=')[0]}=`), + } +} + +export function renderAuthUserModel(_generatedSchemaImportPath = '../db/schema.generated'): string { + return [ + 'import { defineModel } from \'@holo-js/db\'', + '', + 'export default defineModel(\'users\', {', + ' fillable: [\'name\', \'email\', \'password\', \'avatar\', \'email_verified_at\'],', + ' hidden: [\'password\'],', + '})', + '', + ].join('\n') +} + +export function renderAuthorizationPoliciesReadme(): string { + return [ + '# Authorization Policies', + '', + 'Place policy files in this directory.', + 'Export `definePolicy(...)` definitions from `@holo-js/authorization`.', + '', + ].join('\n') +} + +export function renderAuthorizationAbilitiesReadme(): string { + return [ + '# Authorization Abilities', + '', + 'Place ability files in this directory.', + 'Export `defineAbility(...)` definitions from `@holo-js/authorization`.', + '', + ].join('\n') +} + +export function resolveAuthUserModelSchemaImportPath( + userModelPath: string, + generatedSchemaPath: string, +): string { + return relativeImportPath(userModelPath, generatedSchemaPath) +} + +export function renderAuthMigration(slug: AuthMigrationSlug): string { + switch (slug) { + case 'create_users': + return [ + 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + '', + 'export default defineMigration({', + ' async up({ schema }: MigrationContext) {', + ' await schema.createTable(\'users\', (table) => {', + ' table.id()', + ' table.string(\'name\')', + ' table.string(\'email\').unique()', + ' table.string(\'password\').nullable()', + ' table.string(\'avatar\').nullable()', + ' table.timestamp(\'email_verified_at\').nullable()', + ' table.timestamps()', + ' })', + ' },', + ' async down({ schema }: MigrationContext) {', + ' await schema.dropTable(\'users\')', + ' },', + '})', + '', + ].join('\n') + case 'create_sessions': + return [ + 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + '', + 'export default defineMigration({', + ' async up({ schema }: MigrationContext) {', + ' await schema.createTable(\'sessions\', (table) => {', + ' table.string(\'id\').primaryKey()', + ' table.string(\'store\').default(\'database\')', + ' table.json(\'data\').default({})', + ' table.timestamp(\'created_at\')', + ' table.timestamp(\'last_activity_at\')', + ' table.timestamp(\'expires_at\')', + ' table.timestamp(\'invalidated_at\').nullable()', + ' table.string(\'remember_token_hash\').nullable()', + ' table.index([\'expires_at\'])', + ' })', + ' },', + ' async down({ schema }: MigrationContext) {', + ' await schema.dropTable(\'sessions\')', + ' },', + '})', + '', + ].join('\n') + case 'create_auth_identities': + return [ + 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + '', + 'export default defineMigration({', + ' async up({ schema }: MigrationContext) {', + ' await schema.createTable(\'auth_identities\', (table) => {', + ' table.id()', + ' table.string(\'user_id\')', + ' table.string(\'guard\').default(\'web\')', + ' table.string(\'auth_provider\').default(\'users\')', + ' table.string(\'provider\')', + ' table.string(\'provider_user_id\')', + ' table.string(\'email\').nullable()', + ' table.boolean(\'email_verified\').default(false)', + ' table.json(\'profile\').default({})', + ' table.json(\'tokens\').default({})', + ' table.timestamps()', + ' table.index([\'user_id\'])', + ' table.unique([\'provider\', \'provider_user_id\'], \'auth_identities_provider_user_unique\')', + ' })', + ' },', + ' async down({ schema }: MigrationContext) {', + ' await schema.dropTable(\'auth_identities\')', + ' },', + '})', + '', + ].join('\n') + case 'create_personal_access_tokens': + return [ + 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + '', + 'export default defineMigration({', + ' async up({ schema }: MigrationContext) {', + ' await schema.createTable(\'personal_access_tokens\', (table) => {', + ' table.uuid(\'id\').primaryKey()', + ' table.string(\'provider\').default(\'users\')', + ' table.string(\'user_id\')', + ' table.string(\'name\')', + ' table.string(\'token_hash\').unique()', + ' table.json(\'abilities\').default([])', + ' table.timestamp(\'last_used_at\').nullable()', + ' table.timestamp(\'expires_at\').nullable()', + ' table.timestamps()', + ' table.index([\'provider\'])', + ' table.index([\'user_id\'])', + ' })', + ' },', + ' async down({ schema }: MigrationContext) {', + ' await schema.dropTable(\'personal_access_tokens\')', + ' },', + '})', + '', + ].join('\n') + case 'create_password_reset_tokens': + return [ + 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + '', + 'export default defineMigration({', + ' async up({ schema }: MigrationContext) {', + ' await schema.createTable(\'password_reset_tokens\', (table) => {', + ' table.uuid(\'id\').primaryKey()', + ' table.string(\'provider\').default(\'users\')', + ' table.string(\'email\')', + ' table.string(\'token_hash\')', + ' table.timestamp(\'expires_at\')', + ' table.timestamp(\'used_at\').nullable()', + ' table.timestamps()', + ' table.index([\'provider\'])', + ' table.index([\'email\'])', + ' })', + ' },', + ' async down({ schema }: MigrationContext) {', + ' await schema.dropTable(\'password_reset_tokens\')', + ' },', + '})', + '', + ].join('\n') + case 'create_email_verification_tokens': + return [ + 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + '', + 'export default defineMigration({', + ' async up({ schema }: MigrationContext) {', + ' await schema.createTable(\'email_verification_tokens\', (table) => {', + ' table.uuid(\'id\').primaryKey()', + ' table.string(\'provider\').default(\'users\')', + ' table.string(\'user_id\')', + ' table.string(\'email\')', + ' table.string(\'token_hash\')', + ' table.timestamp(\'expires_at\')', + ' table.timestamp(\'used_at\').nullable()', + ' table.timestamps()', + ' table.index([\'provider\'])', + ' table.index([\'user_id\'])', + ' table.index([\'email\'])', + ' })', + ' },', + ' async down({ schema }: MigrationContext) {', + ' await schema.dropTable(\'email_verification_tokens\')', + ' },', + '})', + '', + ].join('\n') + } +} + +export function createAuthMigrationFiles(date = new Date()): readonly ScaffoldedFile[] { + return AUTH_MIGRATION_SLUGS.map((slug, index) => ({ + path: createMigrationFileName(slug, new Date(date.getTime() + (index * 1000))), + contents: renderAuthMigration(slug), + })) +} + +export function renderNotificationsMigration(): string { + return [ + 'import { defineMigration, type MigrationContext } from \'@holo-js/db\'', + '', + 'export default defineMigration({', + ' async up({ schema }: MigrationContext) {', + ' await schema.createTable(\'notifications\', (table) => {', + ' table.string(\'id\').primaryKey()', + ' table.string(\'type\').nullable()', + ' table.string(\'notifiable_type\')', + ' table.string(\'notifiable_id\')', + ' table.json(\'data\').default({})', + ' table.timestamp(\'read_at\').nullable()', + ' table.timestamp(\'created_at\')', + ' table.timestamp(\'updated_at\')', + ' table.index([\'notifiable_type\', \'notifiable_id\'])', + ' table.index([\'read_at\'])', + ' })', + ' },', + ' async down({ schema }: MigrationContext) {', + ' await schema.dropTable(\'notifications\')', + ' },', + '})', + '', + ].join('\n') +} + +export function createNotificationsMigrationFiles(date = new Date()): readonly ScaffoldedFile[] { + return [{ + path: createMigrationFileName('create_notifications', date), + contents: renderNotificationsMigration(), + }] +} + +export function renderScaffoldAppConfig(projectName: string): string { + return [ + 'import { defineAppConfig, env } from \'@holo-js/config\'', + '', + "const appEnv = env('APP_ENV') === 'production'", + " ? 'production'", + " : env('APP_ENV') === 'test'", + " ? 'test'", + " : 'development'", + '', + 'export default defineAppConfig({', + ` name: env('APP_NAME', ${JSON.stringify(projectName)}),`, + ' key: env(\'APP_KEY\'),', + ' url: env(\'APP_URL\', \'http://localhost:3000\'),', + ' env: appEnv,', + ' debug: env(\'APP_DEBUG\', true),', + ' paths: {', + ' models: \'server/models\',', + ' migrations: \'server/db/migrations\',', + ' seeders: \'server/db/seeders\',', + ' commands: \'server/commands\',', + ' jobs: \'server/jobs\',', + ' events: \'server/events\',', + ' listeners: \'server/listeners\',', + ' generatedSchema: \'server/db/schema.generated.ts\',', + ' },', + '})', + '', + ].join('\n') +} + +export function renderScaffoldDatabaseConfig( + options: Pick, +): string { + const packageName = sanitizePackageName(options.projectName) || 'holo-app' + + if (options.databaseDriver === 'sqlite') { + return [ + 'import { defineDatabaseConfig, env } from \'@holo-js/config\'', + '', + 'export default defineDatabaseConfig({', + ' defaultConnection: \'main\',', + ' connections: {', + ' main: {', + ' driver: \'sqlite\',', + ' url: env(\'DB_URL\', \'./storage/database.sqlite\'),', + ' },', + ' },', + '})', + '', + ].join('\n') + } + + const port = options.databaseDriver === 'mysql' ? '3306' : '5432' + const username = options.databaseDriver === 'mysql' ? 'root' : 'postgres' + const schemaLine = options.databaseDriver === 'postgres' + ? ' schema: env(\'DB_SCHEMA\', \'public\'),' + : undefined + + return [ + 'import { defineDatabaseConfig, env } from \'@holo-js/config\'', + '', + 'export default defineDatabaseConfig({', + ' defaultConnection: \'main\',', + ' connections: {', + ' main: {', + ` driver: '${options.databaseDriver}',`, + ' host: env(\'DB_HOST\', \'127.0.0.1\'),', + ` port: env('DB_PORT', '${port}'),`, + ` username: env('DB_USERNAME', '${username}'),`, + ' password: env(\'DB_PASSWORD\'),', + ` database: env('DB_DATABASE', '${packageName}'),`, + ...(schemaLine ? [schemaLine] : []), + ' },', + ' },', + '})', + '', + ].join('\n') +} + +export function resolveDefaultDatabaseUrl(driver: SupportedDatabaseDriver): string | undefined { + if (driver === 'sqlite') { + return './storage/database.sqlite' + } + + return undefined +} + +export function renderScaffoldEnvFiles( + options: Pick, +): { env: string, example: string } { + const defaultDatabaseConnection = 'main' + const baseLines = [ + 'APP_NAME=', + 'APP_KEY=', + 'APP_URL=http://localhost:3000', + 'APP_ENV=development', + 'APP_DEBUG=true', + `DB_DRIVER=${options.databaseDriver}`, + ] + const driverLines = options.databaseDriver === 'sqlite' + ? [ + `DB_URL=${resolveDefaultDatabaseUrl(options.databaseDriver)}`, + ] + : [ + 'DB_HOST=127.0.0.1', + `DB_PORT=${options.databaseDriver === 'mysql' ? '3306' : '5432'}`, + `DB_USERNAME=${options.databaseDriver === 'mysql' ? 'root' : 'postgres'}`, + 'DB_PASSWORD=', + `DB_DATABASE=${sanitizePackageName(options.projectName) || 'holo_app'}`, + ...(options.databaseDriver === 'postgres' ? ['DB_SCHEMA=public'] : []), + ] + const storageLines = normalizeScaffoldOptionalPackages(options.optionalPackages).includes('storage') + ? [ + `STORAGE_DEFAULT_DISK=${options.storageDefaultDisk}`, + 'STORAGE_ROUTE_PREFIX=/storage', + ] + : [] + const authLines = normalizeScaffoldOptionalPackages(options.optionalPackages).includes('auth') + ? [...renderAuthEnvFiles({}, defaultDatabaseConnection).env] + : [] + const cacheLines = normalizeScaffoldOptionalPackages(options.optionalPackages).includes('cache') + ? [...renderCacheEnvFiles('file').env] + : [] + const env = [...baseLines, ...driverLines, ...storageLines, ...authLines, ...cacheLines, ''].join('\n') + const example = [ + '# Copy this file to .env and fill in your local values.', + '# Supported layered env files: .env.local, .env.development, .env.production, .env.prod, .env.test', + ...[...baseLines, ...driverLines, ...storageLines, ...authLines, ...cacheLines].map(line => `${line.split('=')[0]}=`), + '', + ].join('\n') + + return { env, example } +} + +function renderRedisConnectionEnvFiles(): { env: readonly string[], example: readonly string[] } { + return { + env: [ + 'REDIS_URL=', + 'REDIS_HOST=127.0.0.1', + 'REDIS_PORT=6379', + 'REDIS_USERNAME=', + 'REDIS_PASSWORD=', + 'REDIS_DB=0', + ], + example: [ + 'REDIS_URL=', + 'REDIS_HOST=', + 'REDIS_PORT=', + 'REDIS_USERNAME=', + 'REDIS_PASSWORD=', + 'REDIS_DB=', + ], + } +} + +export function renderQueueEnvFiles( + driver: SupportedQueueInstallerDriver, +): { env: readonly string[], example: readonly string[] } { + if (driver !== 'redis') { + return { + env: [], + example: [], + } + } + + return renderRedisConnectionEnvFiles() +} + +export function renderCacheEnvFiles( + driver: SupportedCacheInstallerDriver, +): { env: readonly string[], example: readonly string[] } { + if (driver === 'redis') { + const redis = renderRedisConnectionEnvFiles() + return { + env: [ + 'CACHE_PREFIX=', + ...redis.env, + ], + example: [ + 'CACHE_PREFIX=', + ...redis.example, + ], + } + } + + return { + env: [ + 'CACHE_PREFIX=', + ], + example: [ + 'CACHE_PREFIX=', + ], + } +} + +function parseEnvKey(line: string): string | undefined { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + return undefined + } + + const normalized = trimmed.startsWith('export ') + ? trimmed.slice(7).trim() + : trimmed + const separatorIndex = normalized.indexOf('=') + if (separatorIndex <= 0) { + return undefined + } + + return normalized.slice(0, separatorIndex).trim() +} + +export function renderEnvFileContents(segments: readonly string[]): string { + const normalized = segments + .map(segment => segment.replace(/\n+$/, '')) + .filter(segment => segment.length > 0) + + return normalized.length > 0 + ? `${normalized.join('\n')}\n` + : '' +} + +export function normalizeScaffoldEnvSegments(segments: string): readonly string[] { + return segments + .split('\n') + .map(segment => segment.trim()) + .filter(segment => segment.length > 0) +} + +export function upsertEnvContents( + existingContents: string | undefined, + additions: readonly string[], +): { readonly contents?: string, readonly changed: boolean } { + if (additions.length === 0) { + return { + contents: existingContents, + changed: false, + } + } + + const nextLines = existingContents + ? existingContents.replace(/\r\n/g, '\n').split('\n') + : [] + const existingKeys = new Set(nextLines.map(parseEnvKey).filter((value): value is string => typeof value === 'string')) + const additionKeys = new Set() + const missingLines = additions.flatMap(line => { + const normalizedLine = line.trim() + if (normalizedLine.length === 0 || normalizedLine.startsWith('#')) { + return [] + } + + const key = parseEnvKey(normalizedLine) + if (!key || additionKeys.has(key) || existingKeys.has(key)) { + return [] + } + + additionKeys.add(key) + return [normalizedLine] + }) + + if (missingLines.length === 0) { + return { + contents: existingContents, + changed: false, + } + } + + if (nextLines.length > 0 && nextLines[nextLines.length - 1]?.trim() !== '') { + nextLines.push('') + } + + nextLines.push(...missingLines) + + return { + contents: `${nextLines.join('\n').replace(/\n*$/, '')}\n`, + changed: true, + } +} diff --git a/packages/cli/src/project/scaffold/types.ts b/packages/cli/src/project/scaffold/types.ts new file mode 100644 index 0000000..6b15b0e --- /dev/null +++ b/packages/cli/src/project/scaffold/types.ts @@ -0,0 +1,38 @@ +import type { loadConfigDirectory } from '@holo-js/config' +import type { SupportedAuthSocialProvider } from '../shared' + +export type ScaffoldedFile = { + readonly path: string + readonly contents: string +} + +export type AuthInstallFeatures = { + readonly social?: boolean + readonly socialProviders?: readonly SupportedAuthSocialProvider[] + readonly workos?: boolean + readonly clerk?: boolean +} + +export type LoadedConfigWithCache = Awaited> & { + readonly cache: { + readonly drivers: Readonly> + } + readonly redis: { + readonly default: string + } +} + +export type ConfigModuleFormat = 'esm' | 'cjs' + +export const AUTH_MIGRATION_SLUGS = [ + 'create_users', + 'create_sessions', + 'create_auth_identities', + 'create_personal_access_tokens', + 'create_password_reset_tokens', + 'create_email_verification_tokens', +] as const + +export type AuthMigrationSlug = typeof AUTH_MIGRATION_SLUGS[number] diff --git a/packages/cli/src/project/scaffold/workspace-renderers.ts b/packages/cli/src/project/scaffold/workspace-renderers.ts new file mode 100644 index 0000000..26ad69e --- /dev/null +++ b/packages/cli/src/project/scaffold/workspace-renderers.ts @@ -0,0 +1,90 @@ +import type { ProjectScaffoldOptions } from '../shared' + +export function renderScaffoldGitignore(): string { + return [ + 'node_modules', + '.env', + '.env.local', + '.env.development', + '.env.production', + '.env.prod', + '.env.test', + '.holo-js/generated', + '.holo-js/runtime', + '.nuxt', + '.output', + '.next', + '.svelte-kit', + 'coverage', + 'dist', + 'storage', + 'storage/database.sqlite', + 'storage/framework', + '', + ].join('\n') +} + +export function renderScaffoldTsconfig(options: Pick): string { + if (options.framework === 'nuxt') { + return `${JSON.stringify({ + extends: './.nuxt/tsconfig.json', + }, null, 2)}\n` + } + + if (options.framework === 'sveltekit') { + return `${JSON.stringify({ + extends: './.svelte-kit/tsconfig.json', + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: true, + }, + include: [ + 'src/**/*.ts', + 'src/**/*.svelte', + 'server/**/*.ts', + 'config/**/*.ts', + '.holo-js/generated/**/*.ts', + '.holo-js/generated/**/*.d.ts', + 'vite.config.ts', + ], + }, null, 2)}\n` + } + + const include = ['next-env.d.ts', 'instrumentation.ts', 'app/**/*.ts', 'app/**/*.tsx', 'server/**/*.ts', 'config/**/*.ts', '.holo-js/generated/**/*.ts', '.holo-js/generated/**/*.d.ts'] + + return `${JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + strict: true, + noEmit: true, + skipLibCheck: true, + baseUrl: '.', + jsx: 'preserve', + paths: { + '~/*': ['./*'], + '@/*': ['./*'], + }, + }, + include, + }, null, 2)}\n` +} + +export function renderVSCodeSettings(options: Pick): string | undefined { + if (options.framework !== 'nuxt' && options.framework !== 'sveltekit') { + return undefined + } + + const settings: Record = { + 'typescript.tsdk': 'node_modules/typescript/lib', + 'typescript.enablePromptUseWorkspaceTsdk': true, + } + + if (options.framework === 'nuxt') { + settings['vue.server.hybridMode'] = true + } + + return `${JSON.stringify(settings, null, 2)}\n` +} diff --git a/packages/cli/tests/broadcast.test.ts b/packages/cli/tests/broadcast.test.ts index d3338fa..7a7844a 100644 --- a/packages/cli/tests/broadcast.test.ts +++ b/packages/cli/tests/broadcast.test.ts @@ -35,6 +35,11 @@ function createIo(projectRoot: string) { } } +async function waitForSignalListener(signal: 'SIGINT' | 'SIGTERM', baselineCount: number) { + await vi.waitUntil(() => process.listeners(signal).length > baselineCount) + return process.listeners(signal)[baselineCount] +} + afterEach(() => { vi.restoreAllMocks() }) @@ -338,6 +343,7 @@ describe('@holo-js/cli broadcast worker command', () => { const io = createIo(tempRoot) const stop = vi.fn(async () => {}) + const sigintListenersBefore = process.listeners('SIGINT').length const promise = runBroadcastWorkCommand(io.io, tempRoot, { loadConfig: vi.fn(async () => ({ broadcast: { ok: true }, queue: {} })) as never, loadRegistry: vi.fn(async () => { @@ -370,8 +376,8 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGINT') + await vi.waitUntil(() => process.listeners('SIGINT').length > sigintListenersBefore) + process.listeners('SIGINT')[sigintListenersBefore]?.('SIGINT') await expect(promise).resolves.toBeUndefined() expect(stop).toHaveBeenCalledTimes(1) }) @@ -465,6 +471,7 @@ describe('@holo-js/cli broadcast worker command', () => { ]) const stop = vi.fn(async () => {}) + const sigintListenersBefore = process.listeners('SIGINT').length const promise = runBroadcastWorkCommand(createIo(tempRoot).io, tempRoot, { loadConfig: vi.fn(async () => ({ broadcast: { ok: true }, queue: { redis: true } })) as never, loadRegistry: vi.fn(async () => undefined), @@ -495,8 +502,7 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGINT') + ;(await waitForSignalListener('SIGINT', sigintListenersBefore))?.('SIGINT') await expect(promise).resolves.toBeUndefined() expect(stop).toHaveBeenCalledTimes(1) }) @@ -546,6 +552,7 @@ describe('@holo-js/cli broadcast worker command', () => { ]) const stop = vi.fn(async () => {}) + const sigintListenersBefore = process.listeners('SIGINT').length const promise = runBroadcastWorkCommand(createIo(tempRoot).io, tempRoot, { loadConfig: vi.fn(async () => ({ broadcast: { ok: true }, queue: {} })) as never, loadRegistry: vi.fn(async () => ({ @@ -624,8 +631,7 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGINT') + ;(await waitForSignalListener('SIGINT', sigintListenersBefore))?.('SIGINT') await expect(promise).resolves.toBeUndefined() expect(stop).toHaveBeenCalledTimes(1) }, 120_000) @@ -675,6 +681,7 @@ describe('@holo-js/cli broadcast worker command', () => { ]) const stop = vi.fn(async () => {}) + const sigintListenersBefore = process.listeners('SIGINT').length const promise = runBroadcastWorkCommand(createIo(tempRoot).io, tempRoot, { loadConfig: vi.fn(async () => ({ broadcast: { ok: true }, queue: { redis: true } })) as never, loadModule: vi.fn(async () => ({ @@ -704,8 +711,7 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGINT') + ;(await waitForSignalListener('SIGINT', sigintListenersBefore))?.('SIGINT') await expect(promise).resolves.toBeUndefined() expect(stop).toHaveBeenCalledTimes(1) }) @@ -718,6 +724,7 @@ describe('@holo-js/cli broadcast worker command', () => { }, null, 2)) const stop = vi.fn(async () => {}) + const sigintListenersBefore = process.listeners('SIGINT').length const promise = runBroadcastWorkCommand(createIo(projectRoot).io, projectRoot, { loadRegistry: vi.fn(async () => undefined), loadModule: vi.fn(async () => ({ @@ -729,8 +736,7 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGINT') + ;(await waitForSignalListener('SIGINT', sigintListenersBefore))?.('SIGINT') await expect(promise).resolves.toBeUndefined() expect(stop).toHaveBeenCalledTimes(1) }) @@ -756,13 +762,13 @@ describe('@holo-js/cli broadcast worker command', () => { ].join('\n'), 'utf8') const io = createIo(projectRoot) + const sigtermListenersBefore = process.listeners('SIGTERM').length const promise = runBroadcastWorkCommand(io.io, projectRoot, { loadConfig: vi.fn(async () => ({ broadcast: {}, queue: {} })) as never, loadRegistry: vi.fn(async () => undefined), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGTERM') + ;(await waitForSignalListener('SIGTERM', sigtermListenersBefore))?.('SIGTERM') await expect(promise).resolves.toBeUndefined() expect(io.read()).toContain('[broadcast] Worker listening on 127.0.0.1:7003') }) @@ -781,6 +787,7 @@ describe('@holo-js/cli broadcast worker command', () => { }) try { + const sigintListenersBefore = process.listeners('SIGINT').length const { runBroadcastWorkCommand: isolatedRunBroadcastWorkCommand } = await import('../src/broadcast') const promise = isolatedRunBroadcastWorkCommand(io.io, process.cwd(), { loadConfig: vi.fn(async () => ({ broadcast: { ok: true }, queue: {} })) as never, @@ -797,8 +804,7 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGINT') + ;(await waitForSignalListener('SIGINT', sigintListenersBefore))?.('SIGINT') await expect(promise).resolves.toBeUndefined() expect(stop).toHaveBeenCalledTimes(1) } finally { @@ -813,6 +819,8 @@ describe('@holo-js/cli broadcast worker command', () => { await new Promise(resolve => setTimeout(resolve, 100)) }) const offSpy = vi.spyOn(process, 'off').mockImplementation(() => process) + const sigtermListenersBefore = process.listeners('SIGTERM').length + const sigintListenersBefore = process.listeners('SIGINT').length const promise = runBroadcastWorkCommand(io.io, process.cwd(), { loadConfig: vi.fn(async () => ({ broadcast: { ok: true }, queue: {} })) as never, loadRegistry: vi.fn(async () => undefined), @@ -825,9 +833,8 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGTERM') - process.emit('SIGINT') + ;(await waitForSignalListener('SIGTERM', sigtermListenersBefore))?.('SIGTERM') + ;(await waitForSignalListener('SIGINT', sigintListenersBefore))?.('SIGINT') await expect(promise).resolves.toBeUndefined() expect(stop).toHaveBeenCalledTimes(1) expect(offSpy).toHaveBeenCalled() @@ -849,6 +856,7 @@ describe('@holo-js/cli broadcast worker command', () => { ].join('\n'), 'utf8') const stop = vi.fn(async () => {}) + const sigintListenersBefore = process.listeners('SIGINT').length const promise = runBroadcastWorkCommand(createIo(tempRoot).io, tempRoot, { loadConfig: vi.fn(async () => ({ broadcast: { ok: true }, queue: {} })) as never, loadRegistry: vi.fn(async () => undefined), @@ -861,8 +869,7 @@ describe('@holo-js/cli broadcast worker command', () => { })), }) - await new Promise(resolve => setTimeout(resolve, 100)) - process.emit('SIGINT') + ;(await waitForSignalListener('SIGINT', sigintListenersBefore))?.('SIGINT') await expect(promise).resolves.toBeUndefined() const project = await loadProjectConfig(tempRoot, { required: true }) diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index f696f68..44eafb4 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -66,7 +66,7 @@ import { toPascalCase, toSnakeCase, } from '../src/templates' -import { HOLO_PACKAGE_VERSION } from '../src/metadata' +import { ESBUILD_PACKAGE_VERSION, HOLO_PACKAGE_VERSION } from '../src/metadata' import type { FSWatcher } from 'node:fs' const workspaceRoot = resolve(import.meta.dirname, '../../..') @@ -641,7 +641,7 @@ export default { expect(await readFile(join(projectRoot, 'server/holo.ts'), 'utf8')).toContain('createNextHoloHelpers') expect(await readFile(join(projectRoot, 'server/holo.ts'), 'utf8')).toContain('./db/schema.generated') expect(await readFile(join(projectRoot, 'app/layout.tsx'), 'utf8')).toContain('../server/db/schema.generated') - expect(await readFile(join(projectRoot, 'next.config.mjs'), 'utf8')).toContain('nextConfig') + expect(await readFile(join(projectRoot, 'next.config.ts'), 'utf8')).toContain('nextConfig') expect(await readFile(join(projectRoot, 'tsconfig.json'), 'utf8')).toContain('next-env.d.ts') expect(await readFile(join(projectRoot, '.gitignore'), 'utf8')).toContain('.holo-js/generated') expect(await readFile(join(projectRoot, 'server/db/schema.generated.ts'), 'utf8')).toContain('Generated') @@ -1206,7 +1206,7 @@ export default { packageManager: 'bun', storageDefaultDisk: 'local', optionalPackages: ['storage'], - }).find(file => file.path === 'next.config.mjs')?.contents).toContain('STORAGE_ROUTE_PREFIX') + }).find(file => file.path === 'next.config.ts')?.contents).toContain('withHolo') expect(projectInternals.renderFrameworkFiles({ projectName: 'Next App', framework: 'next', @@ -1214,14 +1214,14 @@ export default { packageManager: 'bun', storageDefaultDisk: 'local', optionalPackages: ['storage'], - }).find(file => file.path === 'next.config.mjs')?.contents).toContain("destination: '/storage/:path*'") + }).find(file => file.path === 'app/storage/[[...path]]/route.ts')?.contents).toContain('createPublicStorageResponse') expect(projectInternals.renderFrameworkFiles({ projectName: 'Svelte App', framework: 'sveltekit', databaseDriver: 'sqlite', packageManager: 'bun', storageDefaultDisk: 'local', - }).map(file => file.path)).toContain('src/routes/api/holo/+server.ts') + }).map(file => file.path)).toContain('src/routes/api/holo/health/+server.ts') expect(projectInternals.renderFrameworkFiles({ projectName: 'Svelte App', framework: 'sveltekit', @@ -1266,7 +1266,7 @@ export default { packageManager: 'bun', storageDefaultDisk: 'local', optionalPackages: [], - })).toContain('"vue-router": "^5.0.4"') + })).toContain('"vue-router": "^4.1.6"') expect(projectInternals.renderScaffoldPackageJson({ projectName: 'Svelte App', framework: 'sveltekit', @@ -1274,7 +1274,7 @@ export default { packageManager: 'bun', storageDefaultDisk: 'local', optionalPackages: [], - })).toContain('"esbuild": "^0.25.0"') + })).toContain(`"esbuild": "${ESBUILD_PACKAGE_VERSION}"`) expect(projectInternals.renderScaffoldTsconfig({ framework: 'next', })).toContain('next-env.d.ts') @@ -1413,52 +1413,15 @@ export default { storageDefaultDisk: 'local', }) await writeFrameworkBinary(nextRoot, 'next') - expect(runNodeScript(nextRoot, join(nextRoot, '.holo-js/framework/run.mjs'), ['build']).stdout).toContain('build') + const nextBuildResult = runNodeScript(nextRoot, join(nextRoot, '.holo-js/framework/run.mjs'), ['build']) + expect(nextBuildResult.status).toBe(0) + expect(nextBuildResult.stdout).toContain('build') + const nextDevResult = runNodeScript(nextRoot, join(nextRoot, '.holo-js/framework/run.mjs'), ['dev']) + expect(nextDevResult.status).toBe(0) + expect(nextDevResult.stdout).toContain('dev') expect(await readFile(join(nextRoot, 'server/holo.ts'), 'utf8')).toContain('./db/schema.generated') expect(await readFile(join(nextRoot, 'app/layout.tsx'), 'utf8')).toContain('../server/db/schema.generated') - const staleNextProcess = spawn('node', ['-e', 'setInterval(() => {}, 1000)'], { - stdio: 'ignore', - }) - try { - const nextStatePath = join(nextRoot, '.next-runner-state') - await writeFile(join(nextRoot, 'node_modules/.bin/next'), `#!/usr/bin/env node -const { existsSync, readFileSync, writeFileSync } = require('node:fs') -const statePath = ${JSON.stringify(nextStatePath)} -const count = existsSync(statePath) ? Number.parseInt(readFileSync(statePath, 'utf8'), 10) : 0 -writeFileSync(statePath, String(count + 1)) -if (count === 0) { - console.error('⨯ Another next dev server is already running.') - console.error('') - console.error('- Local: http://localhost:53864') - console.error('- PID: ${staleNextProcess.pid}') - console.error('- Dir: ${nextRoot}') - console.error('- Log: .next/dev/logs/next-development.log') - process.exit(1) -} -console.log(process.argv.slice(2).join(' ')) -`, 'utf8') - await chmod(join(nextRoot, 'node_modules/.bin/next'), 0o755) - - const restartedDevResult = runNodeScript(nextRoot, join(nextRoot, '.holo-js/framework/run.mjs'), ['dev']) - expect(restartedDevResult.status, restartedDevResult.stderr || restartedDevResult.stdout).toBe(0) - expect(restartedDevResult.stdout).toContain('dev') - expect(restartedDevResult.stderr).toContain(`Stopped stale Next dev server ${staleNextProcess.pid}. Restarting dev server.`) - expect(await readFile(nextStatePath, 'utf8')).toBe('2') - await expect(async () => process.kill(staleNextProcess.pid!, 0)).rejects.toMatchObject({ code: 'ESRCH' }) - } finally { - try { - process.kill(staleNextProcess.pid!, 'SIGKILL') - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 2000) - staleNextProcess.once('exit', () => { - clearTimeout(timeout) - resolve() - }) - }) - } catch { /* already exited */ } - } - const svelteRoot = join(baseRoot, 'svelte-runner') await projectInternals.scaffoldProject(svelteRoot, { projectName: 'svelte-runner', @@ -1507,7 +1470,7 @@ console.log(process.argv.slice(2).join(' ')) expect(projectInternals.resolveBroadcastConfigTargetPath('/project', 'config/app.js', 'esm')).toContain('broadcast.js') // Cover resolveBroadcastConfigTargetPath with cjs format expect(projectInternals.resolveBroadcastConfigTargetPath('/project', 'config/app.json', 'cjs')).toContain('broadcast.cjs') - }) + }, 30000) it('normalizes scaffold env segments and renders empty env file contents directly', () => { expect(projectInternals.normalizeScaffoldEnvSegments(` @@ -1572,7 +1535,7 @@ APP_ENV=development } expect(packageJson.dependencies?.['@holo-js/queue']).toBe(expectedHoloPackageRange) expect(packageJson.dependencies?.['@holo-js/queue-db']).toBeUndefined() - expect(packageJson.dependencies?.esbuild).toBe('^0.27.4') + expect(packageJson.dependencies?.esbuild).toBe(ESBUILD_PACKAGE_VERSION) expect(await readFile(join(projectRoot, 'config/queue.ts'), 'utf8')).toContain('default: \'sync\'') expect(await readFile(join(projectRoot, 'config/queue.ts'), 'utf8')).toContain('failed: false') expect(await readFile(join(projectRoot, '.env'), 'utf8')).toBe('APP_NAME=Fixture\n') @@ -1622,7 +1585,7 @@ export default defineAppConfig({ devDependencies?: Record } expect(packageJson.dependencies?.['@holo-js/queue']).toBe(expectedHoloPackageRange) - expect(packageJson.dependencies?.esbuild).toBe('^0.27.4') + expect(packageJson.dependencies?.esbuild).toBe(ESBUILD_PACKAGE_VERSION) expect(packageJson.devDependencies?.['@holo-js/queue']).toBeUndefined() expect(packageJson.devDependencies?.esbuild).toBeUndefined() expect(await readFile(join(projectRoot, 'config/queue.ts'), 'utf8')).toBe('export default "keep-me"\n') @@ -7086,7 +7049,7 @@ export default { }, }) }) - await withFakeBun(async () => { + await expect(withFakeBun(async () => { await cliInternals.runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { args: ['Admin'], flags: { @@ -7097,8 +7060,7 @@ export default { factory: false, }, }) - }) - await expect(readFile(join(sharedTableProjectRoot, 'server/models/Admin.ts'), 'utf8')).resolves.toContain('defineModel("users"') + })).rejects.toThrow('Discovered duplicate model "User"') await expect(withFakeBun(async () => { await cliInternals.runMakeModel(sharedTableIo.io, sharedTableProjectRoot, { args: ['Person'], @@ -7803,7 +7765,7 @@ export default defineEvent({ name: 'audit.activity' }) expect(jobCommandIo.read().stdout).toContain('Created mail: server/mail/welcome.ts') expect(observerCommandIo.read().stdout).toContain('Created observer: server/db/observers/CourseObserver.ts') expect(factoryCommandIo.read().stdout).toContain('Created factory: server/db/factories/CourseFactory.ts') - }) + }, 30000) it('lazy-loads project, dev, runtime, queue, cache migration, queue migration, and generator modules when executors are not injected', async () => { const projectRoot = await createTempProject() @@ -8229,7 +8191,7 @@ export default defineEvent({ name: 'audit.activity' }) vi.doUnmock('../src/project/discovery') vi.resetModules() } - }) + }, 10000) it('prints auth install output when only env files changed', async () => { const projectRoot = await createTempProject() @@ -9362,9 +9324,19 @@ export default undefined }) await withFakeBun(async () => { - await expect(loadGeneratedProjectRegistry(projectRoot)).resolves.toMatchObject({ - models: [], - }) + const registry = await loadGeneratedProjectRegistry(projectRoot) + + expect(registry).toBeDefined() + if (!registry) { + throw new Error('Expected generated project registry to exist after prepare.') + } + + expect(Array.isArray(registry.models)).toBe(true) + expect(registry.models).toEqual(expect.not.arrayContaining([ + expect.objectContaining({ + file: expect.stringContaining('server/models/Course.ts'), + }), + ])) }) await writeProjectFile(projectRoot, 'server/db/schema.generated.ts', ` diff --git a/packages/db/src/model/Entity.ts b/packages/db/src/model/Entity.ts index d2d1fa1..8197e9e 100644 --- a/packages/db/src/model/Entity.ts +++ b/packages/db/src/model/Entity.ts @@ -1,70 +1,12 @@ import { HydrationError } from '../core/errors' +import { initializeEntityModelProperties, type EntityConstructor, valuesAreEqual } from './entityRuntime' import type { TableDefinition } from '../schema/types' import type { AnyModelDefinition, EmptyScopeMap, ModelAggregateLoad, ModelMorphLoadMap, ModelRecord, ModelRelationName, ModelRelationPath, ModelRepositoryLike, ModelUpdatePayload, RelatedColumnNameOfRelation, RelationMap, RelationMethodsOf, ResolveEagerLoads, SerializeLoaded } from './types' -type EntityConstructor = { - new< - TTable extends TableDefinition = TableDefinition, - TRelations extends RelationMap = RelationMap, - >( - repository: ModelRepositoryLike, - attributes: Partial>, - exists?: boolean, - ): Entity - prototype: EntityBase -} - function isEntity(value: unknown): value is Entity { return value instanceof EntityBase } -function valuesAreEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) { - return true - } - - if (left instanceof Date && right instanceof Date) { - return left.getTime() === right.getTime() - } - - if (left instanceof Uint8Array && right instanceof Uint8Array) { - if (left.length !== right.length) { - return false - } - - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) { - return false - } - } - - return true - } - - if (Array.isArray(left) && Array.isArray(right)) { - return left.length === right.length && left.every((value, index) => valuesAreEqual(value, right[index])) - } - - if ( - left - && right - && typeof left === 'object' - && typeof right === 'object' - && Object.getPrototypeOf(left) === Object.getPrototypeOf(right) - ) { - const leftEntries = Object.entries(left) - const rightEntries = Object.entries(right) - - if (leftEntries.length !== rightEntries.length) { - return false - } - - return leftEntries.every(([key, value]) => valuesAreEqual(value, (right as Record)[key])) - } - - return false -} - type EntityRepositoryRuntime = ModelRepositoryLike & { readonly definition: AnyModelDefinition getConnectionName(): string @@ -141,7 +83,7 @@ class EntityBase< this.changes = exists ? {} : { ...attributes } this.persisted = exists this.relations = {} - this.initializeModelProperties() + initializeEntityModelProperties(this, this.getRepositoryRuntime()) } getRepository(): ModelRepositoryLike { @@ -975,68 +917,6 @@ class EntityBase< return result } - private initializeModelProperties(): void { - const repo = this.getRepositoryRuntime() - const columns = repo?.definition?.table?.columns - if (columns && typeof columns === 'object') { - for (const key of Object.keys(columns)) { - if (key in this) { - continue - } - - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: () => this.get(key as never), - set: (value: unknown) => { - this.set(key as never, value as never) - }, - }) - } - } - - const relationNames = typeof repo?.getRelationNames === 'function' - ? repo.getRelationNames() - : Object.keys(repo?.definition?.relations ?? {}) - - for (const key of relationNames) { - if (key in this) { - continue - } - - Object.defineProperty(this, key, { - configurable: true, - enumerable: false, - get: () => { - if (this.hasRelation(key)) { - return this.getRelation(key) - } - - const relationMethod = (() => this.relation(key as ModelRelationName)) as (() => RelationMethodsOf]>) & PromiseLike & { - catch?: (onRejected?: (reason: unknown) => TResult | PromiseLike) => Promise - finally?: (onFinally?: () => void) => Promise - } - - const loadRelation = () => { - if (typeof repo?.resolveRelationProperty !== 'function') { - return Promise.resolve(undefined) - } - - return Promise.resolve((repo.resolveRelationProperty as (entity: unknown, key: string) => unknown)(this, key)) - } - - relationMethod.then = (onFulfilled, onRejected) => loadRelation().then(onFulfilled, onRejected) - relationMethod.catch = (onRejected) => loadRelation().catch(onRejected) - relationMethod.finally = (onFinally) => loadRelation().finally(onFinally) - - return relationMethod - }, - set: (value: unknown) => { - this.setRelation(key, value) - }, - }) - } - } } export type ModelRelationMethods diff --git a/packages/db/src/model/ModelRepository.ts b/packages/db/src/model/ModelRepository.ts index f6d8c99..465f370 100644 --- a/packages/db/src/model/ModelRepository.ts +++ b/packages/db/src/model/ModelRepository.ts @@ -3726,7 +3726,7 @@ export class ModelRepository { private serializeRelationValue(value: unknown): unknown { if (value instanceof Entity) { - return value.toJSON() + return (value as Entity).toJSON() } if (Array.isArray(value)) { diff --git a/packages/db/src/model/defineModel.ts b/packages/db/src/model/defineModel.ts index ea74d30..d306dfc 100644 --- a/packages/db/src/model/defineModel.ts +++ b/packages/db/src/model/defineModel.ts @@ -1,29 +1,30 @@ -import { SchemaError } from '../core/errors' -import { TableDefinitionBuilder } from '../schema/TableDefinitionBuilder' -import { getGeneratedTableDefinition } from '../schema/generated' import { registerDynamicRelation } from './dynamicRelations' import { withoutModelEvents, withoutModelGuards } from './eventState' import { ModelRepository } from './ModelRepository' import { registerMorphModel } from './morphRegistry' import { setAutomaticEagerLoading, setPreventAccessingMissingAttributes, setPreventLazyLoading } from './runtimeSettings' import { resolveUniqueIdConfig, validateUniqueIdConfig } from './uniqueIds' +import { + buildModelTable, + inferModelName, + inferPrimaryKey, + normalizeEventHandlers, + resolveDeletedAtColumn, + resolveGeneratedModelTable, + resolveTimestampColumn, + validateTouches, +} from './defineModelHelpers' import type { ColumnInput } from '../schema/columns' import type { BoundTableDefinition } from '../schema/defineTable' +import type { TableDefinitionBuilder } from '../schema/TableDefinitionBuilder' import type { ModelQueryBuilder } from './ModelQueryBuilder' import type { Entity } from './Entity' -import type { ModelCollection } from './collection' import type { TableQueryBuilder } from '../query/TableQueryBuilder' import type { - CursorPaginatedResult, CursorPaginationOptions, - PaginatedResult, - PaginationMeta, PaginationOptions, - SimplePaginatedResult, - SimplePaginationMeta, } from '../query/types' -import type { DriverExecutionResult } from '../core/types' -import type { InferInsert, TableDefinition } from '../schema/types' +import type { TableDefinition } from '../schema/types' import type { RelationMap, DynamicRelationResolver, @@ -37,20 +38,15 @@ import type { ModelColumnReference, ModelDefinition, ModelJsonColumnPath, - ModelLifecycleEventHandler, - ModelLifecycleEventName, - ModelRelationPath, ModelRecord, - ModelReference, ModelSelectableColumn, ModelScopesDefinition, - ModelScopeMethods, ModelUpdatePayload, + ModelRelationPath, RelatedColumnNameForRelationPath, - ResolveEagerLoads, - SerializedEntityWithLoaded, UniqueIdRuntimeConfig, } from './types' +import type { StaticModelApi } from './staticModelApi' type BuilderCallback = (query: TBuilder) => unknown type ValueBuilderCallback = (query: TBuilder, value: TValue) => unknown @@ -76,6 +72,7 @@ type MorphModelTarget = { type MorphTypeSelector = string | MorphModelTarget | MorphEntityTarget | null type SubqueryBuilder = ModelQueryBuilder | TableQueryBuilder> + type ColumnShapeInput = Record type EmptyColumnShape = Record type ModelTableBuilderResult< @@ -85,296 +82,6 @@ type ModelTableBuilderResult< build(): BoundTableDefinition } -function buildModelTable< - TName extends string, - TColumns extends ColumnShapeInput, ->( - tableName: TName, - builder: (table: TableDefinitionBuilder) => ModelTableBuilderResult, -): BoundTableDefinition { - return builder(new TableDefinitionBuilder(tableName)).build() -} - -function inferModelName(tableName: string): string { - const singular = tableName.endsWith('s') ? tableName.slice(0, -1) : tableName - return singular - .split(/[_-]/g) - .filter(Boolean) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') -} - -function inferPrimaryKey(table: TTable): Extract { - const primaryKey = Object.values(table.columns).find(column => column.primaryKey) - if (!primaryKey) { - throw new SchemaError(`Table "${table.tableName}" does not define a primary key column.`) - } - - return primaryKey.name as Extract -} - -function resolveGeneratedModelTable(tableName: string): TableDefinition { - const table = getGeneratedTableDefinition(tableName) - if (!table) { - throw new SchemaError( - `Model "${tableName}" is not present in the generated schema registry. Import your generated schema module and run "holo migrate" to refresh it.`, - ) - } - - return table -} - -function resolveDeletedAtColumn( - table: TTable, - options: DefineModelOptions, -): Extract | undefined { - if (!options.softDeletes) { - return undefined - } - - const deletedAtColumn = (options.deletedAtColumn ?? 'deleted_at') as Extract - if (!(deletedAtColumn in table.columns)) { - throw new SchemaError(`Soft-deleting model "${options.name ?? inferModelName(table.tableName)}" requires a "${String(deletedAtColumn)}" column.`) - } - - return deletedAtColumn -} - -function resolveTimestampColumn( - table: TTable, - explicit: string | undefined, - fallback: string, -): Extract | undefined { - const candidate = (explicit ?? fallback) as Extract - return candidate in table.columns ? candidate : undefined -} - -function normalizeEventHandlers( - events: DefineModelOptions['events'], -): Partial> { - if (!events) return {} - - return Object.fromEntries( - Object.entries(events).map(([name, handlers]) => [ - name, - Object.freeze(Array.isArray(handlers) ? [...handlers] : [handlers]), - ]), - ) as Partial> -} - -function validateTouches( - modelName: string, - relations: RelationMap, - touches: readonly string[], -): readonly string[] { - for (const relationName of touches) { - const relation = relations[relationName] - if (!relation) { - throw new SchemaError(`Touched relation "${relationName}" is not defined on model "${modelName}".`) - } - - if (relation.kind !== 'belongsTo' && relation.kind !== 'morphTo') { - throw new SchemaError(`Touched relation "${relationName}" on model "${modelName}" must be a belongs-to or morph-to relation.`) - } - } - - return Object.freeze([...touches]) -} - -type StaticModelApi< - TTable extends TableDefinition, - TScopes extends ModelScopesDefinition, - TRelations extends RelationMap = RelationMap, -> = ModelReference & ModelScopeMethods & { - query(): ModelQueryBuilder - newQuery(): ModelQueryBuilder - newModelQuery(): ModelQueryBuilder - newQueryWithoutScopes(): ModelQueryBuilder - newQueryWithoutRelationships(): ModelQueryBuilder - from(table: string): ModelQueryBuilder - debug(): ReturnType['debug']> - dump(): ModelQueryBuilder - preventLazyLoading(value?: boolean): StaticModelApi - preventAccessingMissingAttributes(value?: boolean): StaticModelApi - automaticallyEagerLoadRelationships(value?: boolean): StaticModelApi - withoutEvents(callback: () => TResult | Promise): Promise - unguarded(callback: () => TResult | Promise): Promise - where(callback: BuilderCallback>): ModelQueryBuilder - where(column: ModelColumnName | ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder - orWhere(callback: BuilderCallback>): ModelQueryBuilder - orWhere(column: ModelColumnName | ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder - whereNot(callback: BuilderCallback>): ModelQueryBuilder - orWhereNot(callback: BuilderCallback>): ModelQueryBuilder - whereExists(subquery: SubqueryBuilder): ModelQueryBuilder - orWhereExists(subquery: SubqueryBuilder): ModelQueryBuilder - whereNotExists(subquery: SubqueryBuilder): ModelQueryBuilder - orWhereNotExists(subquery: SubqueryBuilder): ModelQueryBuilder - whereSub(column: ModelColumnName, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'in' | 'not in' | 'like', subquery: SubqueryBuilder): ModelQueryBuilder - orWhereSub(column: ModelColumnName, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'in' | 'not in' | 'like', subquery: SubqueryBuilder): ModelQueryBuilder - whereInSub(column: ModelColumnName, subquery: SubqueryBuilder): ModelQueryBuilder - whereNotInSub(column: ModelColumnName, subquery: SubqueryBuilder): ModelQueryBuilder - select(...columns: readonly ModelSelectableColumn[]): ModelQueryBuilder - addSelect(...columns: readonly ModelSelectableColumn[]): ModelQueryBuilder - withCasts(casts: Record): ModelQueryBuilder - selectSub(query: SubqueryBuilder, alias: string): ModelQueryBuilder - addSelectSub(query: SubqueryBuilder, alias: string): ModelQueryBuilder - whereNull(column: ModelColumnName): ModelQueryBuilder - orWhereNull(column: ModelColumnName): ModelQueryBuilder - whereNotNull(column: ModelColumnName): ModelQueryBuilder - orWhereNotNull(column: ModelColumnName): ModelQueryBuilder - when(value: TValue, callback: ValueBuilderCallback, TValue>, defaultCallback?: ValueBuilderCallback, TValue>): ModelQueryBuilder - unless(value: TValue, callback: ValueBuilderCallback, TValue>, defaultCallback?: ValueBuilderCallback, TValue>): ModelQueryBuilder - distinct(): ModelQueryBuilder - whereColumn(column: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', compareTo: ModelColumnReference): ModelQueryBuilder - whereIn(column: ModelColumnName, values: readonly unknown[]): ModelQueryBuilder - whereNotIn(column: ModelColumnName, values: readonly unknown[]): ModelQueryBuilder - whereBetween(column: ModelColumnName, range: readonly [unknown, unknown]): ModelQueryBuilder - whereNotBetween(column: ModelColumnName, range: readonly [unknown, unknown]): ModelQueryBuilder - whereLike(column: ModelColumnName, pattern: string): ModelQueryBuilder - orWhereLike(column: ModelColumnName, pattern: string): ModelQueryBuilder - whereAny(columns: readonly ModelColumnName[], operator: unknown, value?: unknown): ModelQueryBuilder - whereAll(columns: readonly ModelColumnName[], operator: unknown, value?: unknown): ModelQueryBuilder - whereNone(columns: readonly ModelColumnName[], operator: unknown, value?: unknown): ModelQueryBuilder - join(table: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder - leftJoin(table: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder - rightJoin(table: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder - crossJoin(table: string): ModelQueryBuilder - joinSub(query: SubqueryBuilder, alias: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder - leftJoinSub(query: SubqueryBuilder, alias: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder - rightJoinSub(query: SubqueryBuilder, alias: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder - joinLateral(query: SubqueryBuilder, alias: string): ModelQueryBuilder - leftJoinLateral(query: SubqueryBuilder, alias: string): ModelQueryBuilder - union(query: SubqueryBuilder): ModelQueryBuilder - unionAll(query: SubqueryBuilder): ModelQueryBuilder - groupBy(...columns: readonly ModelColumnName[]): ModelQueryBuilder - having(expression: string, operator: unknown, value?: unknown): ModelQueryBuilder - havingBetween(expression: string, range: readonly [unknown, unknown]): ModelQueryBuilder - unsafeWhere(sql: string, bindings: readonly unknown[]): ModelQueryBuilder - orUnsafeWhere(sql: string, bindings: readonly unknown[]): ModelQueryBuilder - whereDate(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder - whereMonth(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder - whereDay(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder - whereYear(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder - whereTime(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder - whereJson(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder - orWhereJson(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder - whereJsonContains(columnPath: ModelJsonColumnPath, value: unknown): ModelQueryBuilder - orWhereJsonContains(columnPath: ModelJsonColumnPath, value: unknown): ModelQueryBuilder - whereJsonLength(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder - orWhereJsonLength(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder - whereFullText(columns: ModelColumnName | readonly ModelColumnName[], value: string, options?: { mode?: 'natural' | 'boolean' }): ModelQueryBuilder - orWhereFullText(columns: ModelColumnName | readonly ModelColumnName[], value: string, options?: { mode?: 'natural' | 'boolean' }): ModelQueryBuilder - whereVectorSimilarTo(column: ModelColumnName, vector: readonly number[], minSimilarity?: number): ModelQueryBuilder - orWhereVectorSimilarTo(column: ModelColumnName, vector: readonly number[], minSimilarity?: number): ModelQueryBuilder - orderBy(column: ModelColumnName, direction?: 'asc' | 'desc'): ModelQueryBuilder - latest(column?: ModelColumnName): ModelQueryBuilder - oldest(column?: ModelColumnName): ModelQueryBuilder - inRandomOrder(): ModelQueryBuilder - reorder(column?: ModelColumnName, direction?: 'asc' | 'desc'): ModelQueryBuilder - unsafeOrderBy(sql: string, bindings: readonly unknown[]): ModelQueryBuilder - lock(mode: 'update' | 'share'): ModelQueryBuilder - lockForUpdate(): ModelQueryBuilder - sharedLock(): ModelQueryBuilder - with[]>(...relations: TPaths): ModelQueryBuilder> - with[]>(relations: TPaths): ModelQueryBuilder> - with>(relation: TPath, constraint: RelationConstraintCallback): ModelQueryBuilder> - with, RelationConstraintCallback>>>>(relations: TMap): ModelQueryBuilder - withCount(...relations: readonly ModelRelationPath[]): ModelQueryBuilder - withCount(relations: RelationConstraintMap): ModelQueryBuilder - withExists(...relations: readonly ModelRelationPath[]): ModelQueryBuilder - withExists(relations: RelationConstraintMap): ModelQueryBuilder - withSum>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder - withAvg>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder - withMin>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder - withMax>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder - has(relation: ModelRelationPath): ModelQueryBuilder - orHas(relation: ModelRelationPath): ModelQueryBuilder - whereHas(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder - orWhereHas(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder - doesntHave(relation: ModelRelationPath): ModelQueryBuilder - orDoesntHave(relation: ModelRelationPath): ModelQueryBuilder - whereDoesntHave(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder - orWhereDoesntHave(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder - whereRelation>(relation: TRelationPath, column: RelatedColumnNameForRelationPath, operator: unknown, value?: unknown): ModelQueryBuilder - orWhereRelation>(relation: TRelationPath, column: RelatedColumnNameForRelationPath, operator: unknown, value?: unknown): ModelQueryBuilder - whereBelongsTo(entity: Entity, relationName?: ModelRelationPath): ModelQueryBuilder - orWhereBelongsTo(entity: Entity, relationName?: ModelRelationPath): ModelQueryBuilder - whereMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder - orWhereMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder - whereNotMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder - orWhereNotMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder - withWhereHas>(relation: TPath, constraint?: RelationConstraintCallback): ModelQueryBuilder> - find(value: unknown): Promise | undefined> - findMany(values: readonly unknown[]): Promise> - findOrFail(value: unknown): Promise> - findOrFailJson(value: unknown): Promise> - first(): Promise | undefined> - firstJson(): Promise | undefined> - firstOrFail(): Promise> - sole(): Promise> - soleJson(): Promise> - firstWhere(column: ModelColumnName, operator: unknown, value?: unknown): Promise | undefined> - get(): Promise> - getJson(): Promise[]> - all(): Promise> - paginate(perPage?: number, page?: number, options?: PaginationOptions): Promise> & { data: ModelCollection }> - paginateJson(perPage?: number, page?: number, options?: PaginationOptions): Promise<{ data: readonly SerializedEntityWithLoaded[], meta: PaginationMeta }> - simplePaginate(perPage?: number, page?: number, options?: PaginationOptions): Promise> & { data: ModelCollection }> - simplePaginateJson(perPage?: number, page?: number, options?: PaginationOptions): Promise<{ data: readonly SerializedEntityWithLoaded[], meta: SimplePaginationMeta }> - cursorPaginate(perPage?: number, cursor?: string | null, options?: CursorPaginationOptions): Promise> & { data: ModelCollection }> - cursorPaginateJson(perPage?: number, cursor?: string | null, options?: CursorPaginationOptions): Promise<{ - data: readonly SerializedEntityWithLoaded[] - perPage: number - cursorName: string - nextCursor: string | null - prevCursor: string | null - }> - chunk(size: number, callback: (rows: readonly EntityWithLoaded[], page: number) => unknown | Promise): Promise - chunkById(size: number, callback: (rows: readonly EntityWithLoaded[], page: number) => unknown | Promise, column?: ModelAttributeKey): Promise - chunkByIdDesc(size: number, callback: (rows: readonly EntityWithLoaded[], page: number) => unknown | Promise, column?: ModelAttributeKey): Promise - lazy(size?: number): AsyncGenerator, void, unknown> - cursor(): AsyncGenerator, void, unknown> - count(): Promise - exists(): Promise - doesntExist(): Promise - pluck>(column: TColumn): Promise[TColumn]>> - value>(column: TColumn): Promise[TColumn] | undefined> - valueOrFail>(column: TColumn): Promise[TColumn]> - soleValue>(column: TColumn): Promise[TColumn]> - sum(column: ModelColumnName): Promise - avg(column: ModelColumnName): Promise - min(column: ModelColumnName): Promise - max(column: ModelColumnName): Promise - create(values: Partial>): Promise> - create(values: InferInsert): Promise> - createMany(values: readonly Partial>[]): Promise> - createMany(values: readonly InferInsert[]): Promise> - createQuietly(values: Partial>): Promise> - createQuietly(values: InferInsert): Promise> - createManyQuietly(values: readonly Partial>[]): Promise> - createManyQuietly(values: readonly InferInsert[]): Promise> - update(id: unknown, values: ModelUpdatePayload): Promise> - prune(): Promise - increment(column: ModelColumnName, amount?: number, extraValues?: Partial>): Promise - decrement(column: ModelColumnName, amount?: number, extraValues?: Partial>): Promise - delete(id: unknown): Promise - destroy(ids: readonly unknown[]): Promise - restore(id: unknown): Promise> - forceDelete(id: unknown): Promise - withTrashed(): ModelQueryBuilder - onlyTrashed(): ModelQueryBuilder - updateOrCreate(match: Partial>, values?: Partial>): Promise> - upsert(match: Partial>, values?: Partial>): Promise> - firstOrNew(match: Partial>, values?: Partial>): Promise> - firstOrCreate(match: Partial>, values?: Partial>): Promise> - saveMany(entities: readonly EntityWithLoaded[]): Promise> - resolveRelationUsing(name: string, resolver: DynamicRelationResolver): StaticModelApi - make(values?: Partial>): Entity - getRepository(): ModelRepository - getConnectionName(): string | undefined - getTableName(): string -} - export function defineModel< TTable extends TableDefinition, TScopes extends ModelScopesDefinition = EmptyScopeMap, @@ -451,14 +158,36 @@ function defineModelFromGeneratedTableName< tableName: TName, options: DefineModelOptions, TScopes, TRelations> = {}, ): StaticModelApi, TScopes, TRelations> { - const resolvedAtDefinition = getGeneratedTableDefinition(tableName) as GeneratedSchemaTable | undefined + const resolvedAtDefinition = resolveGeneratedModelTable(tableName) as GeneratedSchemaTable const inferredName = options.name ?? inferModelName(tableName) const relations = { ...(options.relations ?? {}) } as TRelations const touches = validateTouches(inferredName, relations, options.touches ?? []) - const resolveTable = (): GeneratedSchemaTable => resolveGeneratedModelTable(tableName) as GeneratedSchemaTable + const resolveTableDefinition = (): GeneratedSchemaTable => ( + resolveGeneratedModelTable(tableName) as GeneratedSchemaTable + ) + const resolvePrimaryKeyFromTable = ( + table: GeneratedSchemaTable, + ): Extract['columns'], string> => ( + (options.primaryKey ?? inferPrimaryKey(table)) as Extract['columns'], string> + ) + const resolveUniqueIdFromTable = ( + table: GeneratedSchemaTable, + ): UniqueIdRuntimeConfig> | null => { + return resolveUniqueIdConfig( + options.traits, + resolvePrimaryKeyFromTable(table), + options.uniqueIds, + options.newUniqueId, + ) + } + const resolveTable = (): GeneratedSchemaTable => { + const table = resolveTableDefinition() + validateUniqueIdConfig(table, inferredName, resolveUniqueIdFromTable(table)) + return table + } const resolvePrimaryKey = (): Extract['columns'], string> => ( - (options.primaryKey ?? inferPrimaryKey(resolveTable())) as Extract['columns'], string> + resolvePrimaryKeyFromTable(resolveTable()) ) const resolveCreatedAtColumn = (): Extract['columns'], string> | undefined => { const timestamps = options.timestamps ?? true @@ -476,17 +205,11 @@ function defineModelFromGeneratedTableName< resolveDeletedAtColumn(resolveTable(), options) ) const resolveUniqueId = (): UniqueIdRuntimeConfig> | null => { - return resolveUniqueIdConfig( - options.traits, - resolvePrimaryKey(), - options.uniqueIds, - options.newUniqueId, - ) + return resolveUniqueIdFromTable(resolveTable()) } if (resolvedAtDefinition) { - const eagerConfig = resolveUniqueId() - validateUniqueIdConfig(resolvedAtDefinition, inferredName, eagerConfig) + validateUniqueIdConfig(resolvedAtDefinition, inferredName, resolveUniqueId()) } const definition = { diff --git a/packages/db/src/model/defineModelHelpers.ts b/packages/db/src/model/defineModelHelpers.ts new file mode 100644 index 0000000..d0eb5d9 --- /dev/null +++ b/packages/db/src/model/defineModelHelpers.ts @@ -0,0 +1,117 @@ +import { SchemaError } from '../core/errors' +import { TableDefinitionBuilder } from '../schema/TableDefinitionBuilder' +import { getGeneratedTableDefinition } from '../schema/generated' +import type { ColumnInput } from '../schema/columns' +import type { BoundTableDefinition } from '../schema/defineTable' +import type { TableDefinition } from '../schema/types' +import type { + DefineModelOptions, + ModelLifecycleEventHandler, + ModelLifecycleEventName, + RelationMap, +} from './types' + +type ColumnShapeInput = Record +type EmptyColumnShape = Record +type ModelTableBuilderResult< + TName extends string, + TColumns extends ColumnShapeInput, +> = { + build(): BoundTableDefinition +} + +export function buildModelTable< + TName extends string, + TColumns extends ColumnShapeInput, +>( + tableName: TName, + builder: (table: TableDefinitionBuilder) => ModelTableBuilderResult, +): BoundTableDefinition { + return builder(new TableDefinitionBuilder(tableName)).build() +} + +export function inferModelName(tableName: string): string { + const singular = tableName.endsWith('s') ? tableName.slice(0, -1) : tableName + return singular + .split(/[_-]/g) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') +} + +export function inferPrimaryKey(table: TTable): Extract { + const primaryKey = Object.values(table.columns).find(column => column.primaryKey) + if (!primaryKey) { + throw new SchemaError(`Table "${table.tableName}" does not define a primary key column.`) + } + + return primaryKey.name as Extract +} + +export function resolveGeneratedModelTable(tableName: string): TableDefinition { + const table = getGeneratedTableDefinition(tableName) + if (!table) { + throw new SchemaError( + `Model "${tableName}" is not present in the generated schema registry. Import your generated schema module and run "holo migrate" to refresh it.`, + ) + } + + return table +} + +export function resolveDeletedAtColumn( + table: TTable, + options: DefineModelOptions, +): Extract | undefined { + if (!options.softDeletes) { + return undefined + } + + const deletedAtColumn = (options.deletedAtColumn ?? 'deleted_at') as Extract + if (!(deletedAtColumn in table.columns)) { + throw new SchemaError(`Soft-deleting model "${options.name ?? inferModelName(table.tableName)}" requires a "${String(deletedAtColumn)}" column.`) + } + + return deletedAtColumn +} + +export function resolveTimestampColumn( + table: TTable, + explicit: string | undefined, + fallback: string, +): Extract | undefined { + const candidate = (explicit ?? fallback) as Extract + return candidate in table.columns ? candidate : undefined +} + +export function normalizeEventHandlers( + events: DefineModelOptions['events'], +): Partial> { + if (!events) return {} + + return Object.fromEntries( + Object.entries(events).map(([name, handlers]) => [ + name, + Object.freeze(Array.isArray(handlers) ? [...handlers] : [handlers]), + ]), + ) as Partial> +} + +export function validateTouches( + modelName: string, + relations: RelationMap, + touches: readonly string[], +): readonly string[] { + for (const relationName of touches) { + const relation = relations[relationName] + if (!relation) { + throw new SchemaError(`Touched relation "${relationName}" is not defined on model "${modelName}".`) + } + + if (relation.kind !== 'belongsTo' && relation.kind !== 'morphTo') { + throw new SchemaError(`Touched relation "${relationName}" on model "${modelName}" must be a belongs-to or morph-to relation.`) + } + } + + return Object.freeze([...touches]) +} diff --git a/packages/db/src/model/entityRuntime.ts b/packages/db/src/model/entityRuntime.ts new file mode 100644 index 0000000..2499738 --- /dev/null +++ b/packages/db/src/model/entityRuntime.ts @@ -0,0 +1,160 @@ +import type { TableDefinition } from '../schema/types' +import type { ModelRecord, ModelRelationName, RelationMap, RelationMethodsOf } from './types' +import type { Entity } from './Entity' + +export type EntityConstructor = { + new< + TTable extends TableDefinition = TableDefinition, + TRelations extends RelationMap = RelationMap, + >( + repository: unknown, + attributes: Partial>, + exists?: boolean, + ): Entity + prototype: Entity +} + +export function valuesAreEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true + } + + if (left instanceof Date && right instanceof Date) { + return left.getTime() === right.getTime() + } + + if (left instanceof Uint8Array && right instanceof Uint8Array) { + if (left.length !== right.length) { + return false + } + + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false + } + } + + return true + } + + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((value, index) => valuesAreEqual(value, right[index])) + } + + if ( + left + && right + && typeof left === 'object' + && typeof right === 'object' + && Object.getPrototypeOf(left) === Object.getPrototypeOf(right) + ) { + const leftRecord = left as Record + const rightRecord = right as Record + const leftKeys = Object.keys(leftRecord) + const rightKeys = Object.keys(rightRecord) + + if (leftKeys.length !== rightKeys.length) { + return false + } + + if ( + leftKeys.some(key => !Object.prototype.hasOwnProperty.call(rightRecord, key)) + || rightKeys.some(key => !Object.prototype.hasOwnProperty.call(leftRecord, key)) + ) { + return false + } + + return leftKeys.every(key => valuesAreEqual(leftRecord[key], rightRecord[key])) + } + + return false +} + +type EntityPropertyHost = { + hasRelation(name: string): boolean + getRelation(name: string): TRelation + relation>(name: K): RelationMethodsOf + setRelation(name: string, value: unknown): unknown + get(key: string): unknown + set(key: string, value: unknown): unknown +} + +type EntityPropertyRepository = { + definition?: { + table?: { + columns?: Record + } + relations?: Record + } + getRelationNames?(): readonly string[] + resolveRelationProperty?(entity: unknown, key: string): unknown +} + +export function initializeEntityModelProperties( + entity: EntityPropertyHost, + repo: EntityPropertyRepository | undefined, +): void { + const columns = repo?.definition?.table?.columns + if (columns && typeof columns === 'object') { + for (const key of Object.keys(columns)) { + if (key in entity) { + continue + } + + Object.defineProperty(entity, key, { + configurable: true, + enumerable: true, + get: () => entity.get(key), + set: (value: unknown) => { + entity.set(key, value) + }, + }) + } + } + + const relationNames = typeof repo?.getRelationNames === 'function' + ? repo.getRelationNames() + : Object.keys(repo?.definition?.relations ?? {}) + + for (const key of relationNames) { + if (key in entity) { + continue + } + + Object.defineProperty(entity, key, { + configurable: true, + enumerable: false, + get: () => { + if (entity.hasRelation(key)) { + return entity.getRelation(key) + } + + const relationMethod = (() => entity.relation(key as ModelRelationName)) as (() => RelationMethodsOf]>) & PromiseLike & { + catch?: (onRejected?: (reason: unknown) => TResult | PromiseLike) => Promise + finally?: (onFinally?: () => void) => Promise + } + + const loadRelation = () => { + if (typeof repo?.resolveRelationProperty !== 'function') { + return Promise.resolve(undefined) + } + + try { + return Promise.resolve(repo.resolveRelationProperty(entity, key)) + } catch (error) { + return Promise.reject(error) + } + } + + relationMethod.then = (onFulfilled, onRejected) => loadRelation().then(onFulfilled, onRejected) + relationMethod.catch = (onRejected) => loadRelation().catch(onRejected) + relationMethod.finally = (onFinally) => loadRelation().finally(onFinally) + + return relationMethod + }, + set: (value: unknown) => { + entity.setRelation(key, value) + }, + }) + } +} diff --git a/packages/db/src/model/staticModelApi.ts b/packages/db/src/model/staticModelApi.ts new file mode 100644 index 0000000..25d30cd --- /dev/null +++ b/packages/db/src/model/staticModelApi.ts @@ -0,0 +1,255 @@ +import type { ModelQueryBuilder } from './ModelQueryBuilder' +import type { Entity } from './Entity' +import type { ModelCollection } from './collection' +import type { ModelRepository } from './ModelRepository' +import type { TableQueryBuilder } from '../query/TableQueryBuilder' +import type { + CursorPaginatedResult, + CursorPaginationOptions, + PaginatedResult, + PaginationMeta, + PaginationOptions, + SimplePaginatedResult, + SimplePaginationMeta, +} from '../query/types' +import type { DriverExecutionResult } from '../core/types' +import type { InferInsert, TableDefinition } from '../schema/types' +import type { + RelationMap, + DynamicRelationResolver, + EntityWithLoaded, + ModelCastDefinition, + ModelAttributeKey, + ModelColumnName, + ModelColumnReference, + ModelJsonColumnPath, + ModelRecord, + ModelReference, + ModelSelectableColumn, + ModelScopesDefinition, + ModelScopeMethods, + ModelUpdatePayload, + ModelRelationPath, + RelatedColumnNameForRelationPath, + ResolveEagerLoads, + SerializedEntityWithLoaded, +} from './types' + +type BuilderCallback = (query: TBuilder) => unknown +type ValueBuilderCallback = (query: TBuilder, value: TValue) => unknown +type RelationConstraintCallback = (query: ModelQueryBuilder) => unknown +type RelationConstraintMap = Readonly< + Partial, RelationConstraintCallback>> +> +type MorphEntityTarget = { + exists(): boolean + getRepository(): { + definition: { + morphClass: string + primaryKey: string + } + } + get(key: string): unknown +} +type MorphModelTarget = { + definition?: { + morphClass?: string + } +} +type MorphTypeSelector = string | MorphModelTarget | MorphEntityTarget | null +type SubqueryBuilder + = ModelQueryBuilder | TableQueryBuilder> + +export type StaticModelApi< + TTable extends TableDefinition, + TScopes extends ModelScopesDefinition, + TRelations extends RelationMap = RelationMap, +> = ModelReference & ModelScopeMethods & { + query(): ModelQueryBuilder + newQuery(): ModelQueryBuilder + newModelQuery(): ModelQueryBuilder + newQueryWithoutScopes(): ModelQueryBuilder + newQueryWithoutRelationships(): ModelQueryBuilder + from(table: string): ModelQueryBuilder + debug(): ReturnType['debug']> + dump(): ModelQueryBuilder + preventLazyLoading(value?: boolean): StaticModelApi + preventAccessingMissingAttributes(value?: boolean): StaticModelApi + automaticallyEagerLoadRelationships(value?: boolean): StaticModelApi + withoutEvents(callback: () => TResult | Promise): Promise + unguarded(callback: () => TResult | Promise): Promise + where(callback: BuilderCallback>): ModelQueryBuilder + where(column: ModelColumnName | ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder + orWhere(callback: BuilderCallback>): ModelQueryBuilder + orWhere(column: ModelColumnName | ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder + whereNot(callback: BuilderCallback>): ModelQueryBuilder + orWhereNot(callback: BuilderCallback>): ModelQueryBuilder + whereExists(subquery: SubqueryBuilder): ModelQueryBuilder + orWhereExists(subquery: SubqueryBuilder): ModelQueryBuilder + whereNotExists(subquery: SubqueryBuilder): ModelQueryBuilder + orWhereNotExists(subquery: SubqueryBuilder): ModelQueryBuilder + whereSub(column: ModelColumnName, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'in' | 'not in' | 'like', subquery: SubqueryBuilder): ModelQueryBuilder + orWhereSub(column: ModelColumnName, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'in' | 'not in' | 'like', subquery: SubqueryBuilder): ModelQueryBuilder + whereInSub(column: ModelColumnName, subquery: SubqueryBuilder): ModelQueryBuilder + whereNotInSub(column: ModelColumnName, subquery: SubqueryBuilder): ModelQueryBuilder + select(...columns: readonly ModelSelectableColumn[]): ModelQueryBuilder + addSelect(...columns: readonly ModelSelectableColumn[]): ModelQueryBuilder + withCasts(casts: Record): ModelQueryBuilder + selectSub(query: SubqueryBuilder, alias: string): ModelQueryBuilder + addSelectSub(query: SubqueryBuilder, alias: string): ModelQueryBuilder + whereNull(column: ModelColumnName): ModelQueryBuilder + orWhereNull(column: ModelColumnName): ModelQueryBuilder + whereNotNull(column: ModelColumnName): ModelQueryBuilder + orWhereNotNull(column: ModelColumnName): ModelQueryBuilder + when(value: TValue, callback: ValueBuilderCallback, TValue>, defaultCallback?: ValueBuilderCallback, TValue>): ModelQueryBuilder + unless(value: TValue, callback: ValueBuilderCallback, TValue>, defaultCallback?: ValueBuilderCallback, TValue>): ModelQueryBuilder + distinct(): ModelQueryBuilder + whereColumn(column: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', compareTo: ModelColumnReference): ModelQueryBuilder + whereIn(column: ModelColumnName, values: readonly unknown[]): ModelQueryBuilder + whereNotIn(column: ModelColumnName, values: readonly unknown[]): ModelQueryBuilder + whereBetween(column: ModelColumnName, range: readonly [unknown, unknown]): ModelQueryBuilder + whereNotBetween(column: ModelColumnName, range: readonly [unknown, unknown]): ModelQueryBuilder + whereLike(column: ModelColumnName, pattern: string): ModelQueryBuilder + orWhereLike(column: ModelColumnName, pattern: string): ModelQueryBuilder + whereAny(columns: readonly ModelColumnName[], operator: unknown, value?: unknown): ModelQueryBuilder + whereAll(columns: readonly ModelColumnName[], operator: unknown, value?: unknown): ModelQueryBuilder + whereNone(columns: readonly ModelColumnName[], operator: unknown, value?: unknown): ModelQueryBuilder + join(table: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder + leftJoin(table: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder + rightJoin(table: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder + crossJoin(table: string): ModelQueryBuilder + joinSub(query: SubqueryBuilder, alias: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder + leftJoinSub(query: SubqueryBuilder, alias: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder + rightJoinSub(query: SubqueryBuilder, alias: string, leftColumn: ModelColumnReference, operator: '!=' | '=' | '>' | '>=' | '<' | '<=' | 'like', rightColumn: ModelColumnReference): ModelQueryBuilder + joinLateral(query: SubqueryBuilder, alias: string): ModelQueryBuilder + leftJoinLateral(query: SubqueryBuilder, alias: string): ModelQueryBuilder + union(query: SubqueryBuilder): ModelQueryBuilder + unionAll(query: SubqueryBuilder): ModelQueryBuilder + groupBy(...columns: readonly ModelColumnName[]): ModelQueryBuilder + having(expression: string, operator: unknown, value?: unknown): ModelQueryBuilder + havingBetween(expression: string, range: readonly [unknown, unknown]): ModelQueryBuilder + unsafeWhere(sql: string, bindings: readonly unknown[]): ModelQueryBuilder + orUnsafeWhere(sql: string, bindings: readonly unknown[]): ModelQueryBuilder + whereDate(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder + whereMonth(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder + whereDay(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder + whereYear(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder + whereTime(column: ModelColumnName, operator: unknown, value?: unknown): ModelQueryBuilder + whereJson(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder + orWhereJson(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder + whereJsonContains(columnPath: ModelJsonColumnPath, value: unknown): ModelQueryBuilder + orWhereJsonContains(columnPath: ModelJsonColumnPath, value: unknown): ModelQueryBuilder + whereJsonLength(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder + orWhereJsonLength(columnPath: ModelJsonColumnPath, operator: unknown, value?: unknown): ModelQueryBuilder + whereFullText(columns: ModelColumnName | readonly ModelColumnName[], value: string, options?: { mode?: 'natural' | 'boolean' }): ModelQueryBuilder + orWhereFullText(columns: ModelColumnName | readonly ModelColumnName[], value: string, options?: { mode?: 'natural' | 'boolean' }): ModelQueryBuilder + whereVectorSimilarTo(column: ModelColumnName, vector: readonly number[], minSimilarity?: number): ModelQueryBuilder + orWhereVectorSimilarTo(column: ModelColumnName, vector: readonly number[], minSimilarity?: number): ModelQueryBuilder + orderBy(column: ModelColumnName, direction?: 'asc' | 'desc'): ModelQueryBuilder + latest(column?: ModelColumnName): ModelQueryBuilder + oldest(column?: ModelColumnName): ModelQueryBuilder + inRandomOrder(): ModelQueryBuilder + reorder(column?: ModelColumnName, direction?: 'asc' | 'desc'): ModelQueryBuilder + unsafeOrderBy(sql: string, bindings: readonly unknown[]): ModelQueryBuilder + lock(mode: 'update' | 'share'): ModelQueryBuilder + lockForUpdate(): ModelQueryBuilder + sharedLock(): ModelQueryBuilder + with[]>(...relations: TPaths): ModelQueryBuilder> + with[]>(relations: TPaths): ModelQueryBuilder> + with>(relation: TPath, constraint: RelationConstraintCallback): ModelQueryBuilder> + with, RelationConstraintCallback>>>>(relations: TMap): ModelQueryBuilder + withCount(...relations: readonly ModelRelationPath[]): ModelQueryBuilder + withCount(relations: RelationConstraintMap): ModelQueryBuilder + withExists(...relations: readonly ModelRelationPath[]): ModelQueryBuilder + withExists(relations: RelationConstraintMap): ModelQueryBuilder + withSum>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder + withAvg>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder + withMin>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder + withMax>(relation: TRelationPath | RelationConstraintMap, column: RelatedColumnNameForRelationPath, ...rest: readonly ModelRelationPath[]): ModelQueryBuilder + has(relation: ModelRelationPath): ModelQueryBuilder + orHas(relation: ModelRelationPath): ModelQueryBuilder + whereHas(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder + orWhereHas(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder + doesntHave(relation: ModelRelationPath): ModelQueryBuilder + orDoesntHave(relation: ModelRelationPath): ModelQueryBuilder + whereDoesntHave(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder + orWhereDoesntHave(relation: ModelRelationPath, constraint?: RelationConstraintCallback): ModelQueryBuilder + whereRelation>(relation: TRelationPath, column: RelatedColumnNameForRelationPath, operator: unknown, value?: unknown): ModelQueryBuilder + orWhereRelation>(relation: TRelationPath, column: RelatedColumnNameForRelationPath, operator: unknown, value?: unknown): ModelQueryBuilder + whereBelongsTo(entity: Entity, relationName?: ModelRelationPath): ModelQueryBuilder + orWhereBelongsTo(entity: Entity, relationName?: ModelRelationPath): ModelQueryBuilder + whereMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder + orWhereMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder + whereNotMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder + orWhereNotMorphedTo(relation: ModelRelationPath, target: MorphTypeSelector): ModelQueryBuilder + withWhereHas>(relation: TPath, constraint?: RelationConstraintCallback): ModelQueryBuilder> + find(value: unknown): Promise | undefined> + findMany(values: readonly unknown[]): Promise> + findOrFail(value: unknown): Promise> + findOrFailJson(value: unknown): Promise> + first(): Promise | undefined> + firstJson(): Promise | undefined> + firstOrFail(): Promise> + sole(): Promise> + soleJson(): Promise> + firstWhere(column: ModelColumnName, operator: unknown, value?: unknown): Promise | undefined> + get(): Promise> + getJson(): Promise[]> + all(): Promise> + paginate(perPage?: number, page?: number, options?: PaginationOptions): Promise> & { data: ModelCollection }> + paginateJson(perPage?: number, page?: number, options?: PaginationOptions): Promise<{ data: readonly SerializedEntityWithLoaded[], meta: PaginationMeta }> + simplePaginate(perPage?: number, page?: number, options?: PaginationOptions): Promise> & { data: ModelCollection }> + simplePaginateJson(perPage?: number, page?: number, options?: PaginationOptions): Promise<{ data: readonly SerializedEntityWithLoaded[], meta: SimplePaginationMeta }> + cursorPaginate(perPage?: number, cursor?: string | null, options?: CursorPaginationOptions): Promise> & { data: ModelCollection }> + cursorPaginateJson(perPage?: number, cursor?: string | null, options?: CursorPaginationOptions): Promise<{ + data: readonly SerializedEntityWithLoaded[] + perPage: number + cursorName: string + nextCursor: string | null + prevCursor: string | null + }> + chunk(size: number, callback: (rows: readonly EntityWithLoaded[], page: number) => unknown | Promise): Promise + chunkById(size: number, callback: (rows: readonly EntityWithLoaded[], page: number) => unknown | Promise, column?: ModelAttributeKey): Promise + chunkByIdDesc(size: number, callback: (rows: readonly EntityWithLoaded[], page: number) => unknown | Promise, column?: ModelAttributeKey): Promise + lazy(size?: number): AsyncGenerator, void, unknown> + cursor(): AsyncGenerator, void, unknown> + count(): Promise + exists(): Promise + doesntExist(): Promise + pluck>(column: TColumn): Promise[TColumn]>> + value>(column: TColumn): Promise[TColumn] | undefined> + valueOrFail>(column: TColumn): Promise[TColumn]> + soleValue>(column: TColumn): Promise[TColumn]> + sum(column: ModelColumnName): Promise + avg(column: ModelColumnName): Promise + min(column: ModelColumnName): Promise + max(column: ModelColumnName): Promise + create(values: Partial>): Promise> + create(values: InferInsert): Promise> + createMany(values: readonly Partial>[]): Promise> + createMany(values: readonly InferInsert[]): Promise> + createQuietly(values: Partial>): Promise> + createQuietly(values: InferInsert): Promise> + createManyQuietly(values: readonly Partial>[]): Promise> + createManyQuietly(values: readonly InferInsert[]): Promise> + update(id: unknown, values: ModelUpdatePayload): Promise> + prune(): Promise + increment(column: ModelColumnName, amount?: number, extraValues?: Partial>): Promise + decrement(column: ModelColumnName, amount?: number, extraValues?: Partial>): Promise + delete(id: unknown): Promise + destroy(ids: readonly unknown[]): Promise + restore(id: unknown): Promise> + forceDelete(id: unknown): Promise + withTrashed(): ModelQueryBuilder + onlyTrashed(): ModelQueryBuilder + updateOrCreate(match: Partial>, values?: Partial>): Promise> + upsert(match: Partial>, values?: Partial>): Promise> + firstOrNew(match: Partial>, values?: Partial>): Promise> + firstOrCreate(match: Partial>, values?: Partial>): Promise> + saveMany(entities: readonly EntityWithLoaded[]): Promise> + resolveRelationUsing(name: string, resolver: DynamicRelationResolver): StaticModelApi + make(values?: Partial>): Entity + getRepository(): ModelRepository + getConnectionName(): string | undefined + getTableName(): string +} diff --git a/packages/db/tests/model-core.test.ts b/packages/db/tests/model-core.test.ts index 94fe292..c7b5097 100644 --- a/packages/db/tests/model-core.test.ts +++ b/packages/db/tests/model-core.test.ts @@ -440,25 +440,13 @@ describe('model core slice', () => { expect(User.definition.timestamps).toBe(true) }) - it('defers generated table lookup for defineModel(tableName, options) until the schema is registered', () => { - const User = defineModel('users', { + it('fails fast when defineModel(tableName, options) is called before the generated schema is registered', () => { + expect(() => defineModel('users', { fillable: ['name'], timestamps: true, - }) - - const users = defineTable('users', { - id: column.id(), - name: column.string(), - created_at: column.timestamp().defaultNow(), - updated_at: column.timestamp().defaultNow(), - }) - registerGeneratedTables({ users }) - - expect(User.definition.table.tableName).toBe('users') - expect(Object.keys(User.definition.table.columns)).toEqual(['id', 'name', 'created_at', 'updated_at']) - expect(User.definition.primaryKey).toBe('id') - expect(User.definition.createdAtColumn).toBe('created_at') - expect(User.definition.updatedAtColumn).toBe('updated_at') + })).toThrow( + 'Model "users" is not present in the generated schema registry. Import your generated schema module and run "holo migrate" to refresh it.', + ) }) it('supports the public defineModel(tableName) authoring path without options', () => { @@ -501,10 +489,8 @@ describe('model core slice', () => { expect(User.definition.name).toBe('User') }) - it('defers missing generated-schema errors until the model table is resolved', () => { - const User = defineModel('missing_users') - - expect(() => User.definition.table).toThrow( + it('throws immediately when defineModel(tableName) references a missing generated schema table', () => { + expect(() => defineModel('missing_users')).toThrow( 'Model "missing_users" is not present in the generated schema registry. Import your generated schema module and run "holo migrate" to refresh it.', ) }) diff --git a/scripts/run-eslint.mjs b/scripts/run-eslint.mjs index d759acf..9a94075 100644 --- a/scripts/run-eslint.mjs +++ b/scripts/run-eslint.mjs @@ -1,10 +1,11 @@ -import { readdir } from 'node:fs/promises' +import { readdir, rm } from 'node:fs/promises' import { join } from 'node:path' import { spawn } from 'node:child_process' const rootTargets = ['playground', 'scripts', 'eslint.config.mjs', 'vitest.workspace.ts'] const passThroughArgs = process.argv.slice(2) -const eslintBaseArgs = ['eslint', '--cache', '--cache-strategy', 'content', '--cache-location', '.eslintcache-main'] +const cacheLocation = '.eslintcache-main' +const eslintBaseArgs = ['eslint', '--cache', '--cache-strategy', 'content', '--cache-location', cacheLocation] const lintExtensions = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts']) const ignoredDirectoryNames = new Set([ '.git', @@ -33,7 +34,20 @@ async function main() { ] for (const targets of groups) { - await run(targets) + let retriedAfterCacheReset = false + + try { + await run(targets) + } catch (error) { + if (!retriedAfterCacheReset && shouldResetCache(error)) { + retriedAfterCacheReset = true + await clearCache(cacheLocation) + await run(targets) + continue + } + + throw error + } } } @@ -95,28 +109,45 @@ async function hasLintableFiles(directory) { function run(targets) { return new Promise((resolve, reject) => { + let stderr = '' const child = spawn( 'bunx', [...eslintBaseArgs, ...targets, ...passThroughArgs], { - stdio: 'inherit', + stdio: ['inherit', 'inherit', 'pipe'], shell: process.platform === 'win32', }, ) - child.on('exit', code => { + child.stderr.on('data', chunk => { + const text = chunk.toString() + stderr += text + process.stderr.write(text) + }) + + child.on('close', code => { if (code === 0) { resolve() return } - reject(new Error(`ESLint failed for: ${targets.join(', ')}`)) + reject(new Error(stderr || `ESLint failed for: ${targets.join(', ')}`)) }) child.on('error', reject) }) } +async function clearCache(path) { + await rm(path, { force: true }).catch(() => undefined) +} + +function shouldResetCache(error) { + return error instanceof Error + && error.message.includes('ENOENT:') + && error.message.includes('timestamp-') +} + main().catch(error => { console.error(error instanceof Error ? error.message : String(error)) process.exit(1) diff --git a/scripts/run-generated-eslint.mjs b/scripts/run-generated-eslint.mjs index bdf9918..f76a0b1 100644 --- a/scripts/run-generated-eslint.mjs +++ b/scripts/run-generated-eslint.mjs @@ -1,16 +1,30 @@ -import { readdir, stat } from 'node:fs/promises' +import { readdir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { spawn } from 'node:child_process' const appRoot = 'apps' const passThroughArgs = process.argv.slice(2) -const eslintBaseArgs = ['eslint', '--cache', '--cache-strategy', 'content', '--cache-location', '.eslintcache-generated'] +const cacheLocation = '.eslintcache-generated' +const eslintBaseArgs = ['eslint', '--cache', '--cache-strategy', 'content', '--cache-location', cacheLocation] async function main() { const groups = await collectGeneratedLintGroups() for (const group of groups) { - await run(group) + let retriedAfterCacheReset = false + + try { + await run(group) + } catch (error) { + if (!retriedAfterCacheReset && shouldResetCache(error)) { + retriedAfterCacheReset = true + await clearCache(cacheLocation) + await run(group) + continue + } + + throw error + } } } @@ -75,28 +89,45 @@ async function pathExists(path) { function run(targets) { return new Promise((resolve, reject) => { + let stderr = '' const child = spawn( 'bunx', [...eslintBaseArgs, '--no-ignore', ...targets, ...passThroughArgs], { - stdio: 'inherit', + stdio: ['inherit', 'inherit', 'pipe'], shell: process.platform === 'win32', }, ) - child.on('exit', code => { + child.stderr.on('data', chunk => { + const text = chunk.toString() + stderr += text + process.stderr.write(text) + }) + + child.on('close', code => { if (code === 0) { resolve() return } - reject(new Error(`Generated ESLint failed for: ${targets[0]}`)) + reject(new Error(stderr || `Generated ESLint failed for: ${targets[0]}`)) }) child.on('error', reject) }) } +async function clearCache(path) { + await rm(path, { force: true }).catch(() => undefined) +} + +function shouldResetCache(error) { + return error instanceof Error + && error.message.includes('ENOENT:') + && error.message.includes('timestamp-') +} + main().catch(error => { console.error(error instanceof Error ? error.message : String(error)) process.exit(1)