diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 3263419ae..404baeed9 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -165,6 +165,16 @@ export type DomainResponse = { status: string; created_at: string; cname_target?: string; + ownership_verification?: { + type: 'txt'; + name: string; + value: string; + }; + ssl_validation_records?: Array<{ + txt_name?: string; + txt_value?: string; + status?: string; + }>; }; export type DomainStatusResponse = DomainResponse & { diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index f2d139e10..bb12cc98a 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -54,7 +54,32 @@ async function domainAdd( ): Promise { const domain = await client.addDomain(projectId, hostname); log.success(`Domain ${hostname} added (status: ${domain.status})`); - note(`Add a CNAME record:\n ${hostname} → ${domain.cname_target}`, 'DNS Setup'); + + const sections: Array = []; + + if (domain.cname_target) { + sections.push(`CNAME (traffic)\n ${hostname} → ${domain.cname_target}`); + } + + if (domain.ownership_verification) { + sections.push( + `TXT (ownership verification)\n ${domain.ownership_verification.name} → ${domain.ownership_verification.value}`, + ); + } + + const sslRecords = (domain.ssl_validation_records ?? []).filter( + (r): r is { txt_name: string; txt_value: string; status?: string } => + Boolean(r.txt_name) && Boolean(r.txt_value), + ); + if (sslRecords.length > 0) { + const lines = sslRecords.map((r) => ` ${r.txt_name} → ${r.txt_value}`).join('\n'); + sections.push(`TXT (SSL validation)\n${lines}`); + } + + if (sections.length > 0) { + note(sections.join('\n\n'), 'DNS Setup'); + } + log.info('Run `void domain status ' + hostname + '` to check verification progress.'); } @@ -107,8 +132,51 @@ async function domainStatus( `Status: ${status.status}`, `SSL Status: ${status.ssl_status}`, ]; + if (status.ownership_verification) { + lines.push(`Ownership: pending`); + } if (status.verification_errors && status.verification_errors.length > 0) { lines.push(`Errors: ${status.verification_errors.join(', ')}`); } note(lines.join('\n'), 'Domain Status'); + + // Gate on actual validation states, not the aggregate `status.status`. + // Once the DB row is flipped to `active`, the API keeps returning + // `status: 'active'` even if CF later reports SSL/ownership re-validation + // (cert renewal, ownership re-check). Keying off the per-field states + // ensures pending records still surface in that window. CF stops returning + // `ownership_verification` once ownership is verified, so its presence is + // itself the pending signal. + const ownershipPending = Boolean(status.ownership_verification); + const sslPending = status.ssl_status !== 'active'; + + if (ownershipPending || sslPending) { + const pendingSections: Array = []; + + if (ownershipPending && status.ownership_verification) { + pendingSections.push( + `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`, + ); + } + + if (sslPending) { + // Defensive: drop any per-record entries CF has already marked active. + // CF normally clears `ssl.validation_records` once SSL is active, but + // during cert renewals or partial states a stale `active` row can sit + // alongside a pending one — we only want to surface what the user still + // needs to add. + const sslRecords = (status.ssl_validation_records ?? []).filter( + (r): r is { txt_name: string; txt_value: string; status?: string } => + Boolean(r.txt_name) && Boolean(r.txt_value) && r.status !== 'active', + ); + if (sslRecords.length > 0) { + const lines = sslRecords.map((r) => ` ${r.txt_name} → ${r.txt_value}`).join('\n'); + pendingSections.push(`TXT (SSL validation)\n${lines}`); + } + } + + if (pendingSections.length > 0) { + note(pendingSections.join('\n\n'), 'Add these DNS records to your provider'); + } + } } diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 44a5f2032..6d6f38754 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -250,6 +250,15 @@ function generateWrapperSource( lines.push('async function wrappedFetch(request, env, ctx) {'); lines.push(' const url = new URL(request.url);'); + // Readiness probe — platform polls this to confirm the worker is live + // after WfP upload. Handled here (before original.fetch) so the user's + // framework handler doesn't route it to env.ASSETS, which returns CF's + // "Assets have not yet been deployed" stub during asset propagation + // and would trip the platform's probe. + lines.push(' if (request.method === "GET" && url.pathname === "/__void/ready") {'); + lines.push(' return Response.json({ ready: true });'); + lines.push(' }'); + // Migration handler if (migrations && migrations.length > 0) { lines.push(' if (request.method === "POST" && url.pathname === "/__void/migrate") {'); diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts new file mode 100644 index 000000000..54295fdfa --- /dev/null +++ b/packages/void/test/unit/domain-command.test.ts @@ -0,0 +1,485 @@ +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { DomainArgs } from '../../src/cli/domain-args.ts'; +import { runDomainCommand } from '../../src/cli/domain.ts'; + +const mockLogError = vi.fn(); +const mockLogSuccess = vi.fn(); +const mockLogInfo = vi.fn(); +const mockNote = vi.fn(); +class ProcessExitError extends Error { + constructor(public code: number | undefined) { + super(`process.exit(${code})`); + } +} +const mockExit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ProcessExitError(code); +}) as never); + +async function runExpectingExit(rt: string, a: DomainArgs): Promise { + await expect(runDomainCommand(rt, a)).rejects.toBeInstanceOf(ProcessExitError); +} + +const mockGetToken = vi.fn(); +const mockReadProjectConfig = vi.fn(); +const mockListProjects = vi.fn(); +const mockAddDomain = vi.fn(); +const mockListDomains = vi.fn(); +const mockGetDomainStatus = vi.fn(); +const mockRemoveDomain = vi.fn(); + +vi.mock('@clack/prompts', () => ({ + log: { + error: (...args: Array) => mockLogError(...args), + success: (...args: Array) => mockLogSuccess(...args), + info: (...args: Array) => mockLogInfo(...args), + }, + note: (...args: Array) => mockNote(...args), +})); + +vi.mock('../../src/cli/auth', () => ({ + getToken: (...args: Array) => mockGetToken(...args), +})); + +vi.mock('../../src/cli/project', () => ({ + readProjectConfig: (...args: Array) => mockReadProjectConfig(...args), +})); + +vi.mock('../../src/cli/client', () => ({ + PlatformClient: class { + listProjects(...args: Array) { + return mockListProjects(...args); + } + addDomain(...args: Array) { + return mockAddDomain(...args); + } + listDomains(...args: Array) { + return mockListDomains(...args); + } + getDomainStatus(...args: Array) { + return mockGetDomainStatus(...args); + } + removeDomain(...args: Array) { + return mockRemoveDomain(...args); + } + }, +})); + +const root = '/project'; + +function args(overrides: Partial & Pick): DomainArgs { + return { ...overrides }; +} + +beforeEach(() => { + mockLogError.mockReset(); + mockLogSuccess.mockReset(); + mockLogInfo.mockReset(); + mockNote.mockReset(); + mockExit.mockClear(); + + mockGetToken.mockReset(); + mockReadProjectConfig.mockReset(); + mockListProjects.mockReset(); + mockAddDomain.mockReset(); + mockListDomains.mockReset(); + mockGetDomainStatus.mockReset(); + mockRemoveDomain.mockReset(); + + mockGetToken.mockReturnValue('test-token'); + mockReadProjectConfig.mockReturnValue({ projectId: 'proj_default', slug: 'default' }); + mockListProjects.mockResolvedValue([]); + mockListDomains.mockResolvedValue([]); + delete process.env.VOID_PROJECT; +}); + +describe('runDomainCommand top-level guards', () => { + it('fails when no token is found', async () => { + mockGetToken.mockReturnValue(null); + + await runExpectingExit(root, args({ subcommand: 'list' })); + + expect(mockLogError).toHaveBeenCalledWith('Not logged in. Run `void auth login` first.'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('resolves project from --project flag', async () => { + mockListProjects.mockResolvedValue([{ slug: 'foo', id: 'proj_foo' }]); + mockListDomains.mockResolvedValue([]); + + await runDomainCommand(root, args({ subcommand: 'list', projectSlug: 'foo' })); + + expect(mockListDomains).toHaveBeenCalledWith('proj_foo'); + }); + + it('resolves project from readProjectConfig when no slug', async () => { + mockReadProjectConfig.mockReturnValue({ projectId: 'proj_linked', slug: 'linked' }); + mockListDomains.mockResolvedValue([]); + + await runDomainCommand(root, args({ subcommand: 'list' })); + + expect(mockListDomains).toHaveBeenCalledWith('proj_linked'); + }); + + it('errors when no project resolvable', async () => { + mockReadProjectConfig.mockReturnValue(null); + + await runExpectingExit(root, args({ subcommand: 'list' })); + + expect(mockLogError).toHaveBeenCalledWith( + 'No linked project. Run `void project link` or pass --project .', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); +}); + +describe('domainAdd', () => { + it('prints CNAME + ownership TXT + SSL TXT when all three records returned', async () => { + mockAddDomain.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cname_target: 'foo.example.com.cdn.cloudflare.net', + ownership_verification: { type: 'txt', name: '_cf-custom-hostname.foo', value: 'own-token' }, + ssl_validation_records: [{ txt_name: '_acme-challenge.foo', txt_value: 'ssl-token' }], + }); + + await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(1); + const content = mockNote.mock.calls[0][0] as string; + expect(content).toContain('CNAME (traffic)'); + expect(content).toContain('foo.example.com → foo.example.com.cdn.cloudflare.net'); + expect(content).toContain('TXT (ownership verification)'); + expect(content).toContain('_cf-custom-hostname.foo → own-token'); + expect(content).toContain('TXT (SSL validation)'); + expect(content).toContain('_acme-challenge.foo → ssl-token'); + }); + + it('prints multiple SSL records as separate lines under one section', async () => { + mockAddDomain.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cname_target: 'cname.example', + ssl_validation_records: [ + { txt_name: 'a', txt_value: 'b' }, + { txt_name: 'c', txt_value: 'd' }, + ], + }); + + await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); + + const content = mockNote.mock.calls[0][0] as string; + expect(content).toContain('a → b'); + expect(content).toContain('c → d'); + // Only one SSL section header, despite two records + expect(content.match(/TXT \(SSL validation\)/g)).toHaveLength(1); + }); + + it('skips ownership section when ownership_verification is missing', async () => { + mockAddDomain.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cname_target: 'cname.example', + ssl_validation_records: [{ txt_name: 'a', txt_value: 'b' }], + }); + + await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); + + const content = mockNote.mock.calls[0][0] as string; + expect(content).toContain('CNAME (traffic)'); + expect(content).toContain('TXT (SSL validation)'); + expect(content).not.toContain('TXT (ownership verification)'); + }); + + it('skips SSL section when no record has both txt_name and txt_value', async () => { + mockAddDomain.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cname_target: 'cname.example', + ssl_validation_records: [{ txt_name: 'a' }], + }); + + await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); + + const content = mockNote.mock.calls[0][0] as string; + expect(content).toContain('CNAME (traffic)'); + expect(content).not.toContain('TXT (SSL validation)'); + }); + + it('always prints the `void domain status …` info line', async () => { + mockAddDomain.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cname_target: 'cname.example', + }); + + await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); + + expect(mockLogInfo).toHaveBeenCalledWith( + 'Run `void domain status foo.example.com` to check verification progress.', + ); + }); +}); + +describe('domainStatus', () => { + it('errors when hostname not found on project', async () => { + mockListDomains.mockResolvedValue([]); + + await runExpectingExit(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockLogError).toHaveBeenCalledWith( + "domain: Domain 'foo.example.com' not found on this project.", + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('prints `Ownership: pending` when CF returns ownership_verification', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + ownership_verification: { type: 'txt', name: '_cf-custom-hostname.foo', value: 'own-token' }, + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Ownership: pending'); + }); + + it('omits the Ownership line when CF does not return ownership_verification', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'active' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'active', + created_at: 'now', + ssl_status: 'active', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).not.toContain('Ownership:'); + }); + + it('shows pending ownership record ONLY when ownership is pending', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'active', + ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(2); + const pendingNote = mockNote.mock.calls[1][0] as string; + expect(pendingNote).toContain('TXT (ownership verification)'); + expect(pendingNote).toContain('own-name → own-value'); + expect(pendingNote).not.toContain('TXT (SSL validation)'); + }); + + it('shows pending SSL record ONLY when SSL is pending and ownership is verified', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(2); + const pendingNote = mockNote.mock.calls[1][0] as string; + expect(pendingNote).toContain('TXT (SSL validation)'); + expect(pendingNote).toContain('ssl-name → ssl-value'); + expect(pendingNote).not.toContain('TXT (ownership verification)'); + }); + + it('shows both records when both pending', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, + ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(2); + const pendingNote = mockNote.mock.calls[1][0] as string; + expect(pendingNote).toContain('TXT (ownership verification)'); + expect(pendingNote).toContain('own-name → own-value'); + expect(pendingNote).toContain('TXT (SSL validation)'); + expect(pendingNote).toContain('ssl-name → ssl-value'); + }); + + it('skips the pending block when status is fully verified', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'active' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'active', + created_at: 'now', + ssl_status: 'active', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(1); + // The single note call is the Domain Status block; no pending block follows. + expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); + }); + + it('shows multiple SSL records under one section in pending note', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + ssl_validation_records: [ + { txt_name: 'a', txt_value: 'b' }, + { txt_name: 'c', txt_value: 'd' }, + ], + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const pendingNote = mockNote.mock.calls[1][0] as string; + expect(pendingNote).toContain('a → b'); + expect(pendingNote).toContain('c → d'); + expect(pendingNote.match(/TXT \(SSL validation\)/g)).toHaveLength(1); + }); + + it('drops per-record SSL entries already marked active', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + ssl_validation_records: [ + { txt_name: 'done', txt_value: 'done-val', status: 'active' }, + { txt_name: 'pending', txt_value: 'pending-val', status: 'pending_validation' }, + ], + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const pendingNote = mockNote.mock.calls[1][0] as string; + expect(pendingNote).toContain('pending → pending-val'); + expect(pendingNote).not.toContain('done → done-val'); + }); + + it('exit code stays 0 even when pending', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, + ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockExit).not.toHaveBeenCalled(); + }); +}); + +describe('domainList', () => { + it("prints 'No custom domains configured.' info line for empty list", async () => { + mockListDomains.mockResolvedValue([]); + + await runDomainCommand(root, args({ subcommand: 'list' })); + + expect(mockLogInfo).toHaveBeenCalledWith('No custom domains configured.'); + }); +}); + +describe('domainRemove', () => { + it('errors with not-found message when hostname not in list', async () => { + mockListDomains.mockResolvedValue([]); + + await runExpectingExit(root, args({ subcommand: 'delete', hostname: 'foo.example.com' })); + + expect(mockLogError).toHaveBeenCalledWith( + "domain: Domain 'foo.example.com' not found on this project.", + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index a38ec4f7d..52e81dddc 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -153,6 +153,42 @@ describe('generateWrapper with migrations', () => { expect(existsSync(join(workerDir, '__void_migration_handler.mjs'))).toBe(true); }); + it('intercepts GET /__void/ready with {ready:true} when wrapper is generated', async () => { + // Platform's deploy-time readiness probe (packages/api/src/routes/deploy.ts) + // expects a JSON 200 from GET /__void/ready before dispatching migrations. + // Without this, the request falls through to the user's framework handler + // (e.g. SvelteKit -> env.ASSETS), which returns CF's "Assets have not yet + // been deployed to this script." stub during WfP propagation, causing the + // probe to retry until exhaustion. + mkdirSync(workerDir, { recursive: true }); + writeFileSync( + join(workerDir, 'index.mjs'), + 'export default { fetch() { return new Response("ok"); } };', + ); + + mkdirSync(join(projectRoot, 'db', 'migrations'), { recursive: true }); + writeFileSync( + join(projectRoot, 'db', 'migrations', '001_test.sql'), + 'CREATE TABLE test (id INTEGER PRIMARY KEY);', + ); + + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'sqlite', + }); + + const wrapper = readFileSync(join(workerDir, 'index.mjs'), 'utf-8'); + expect(wrapper).toContain('/__void/ready'); + expect(wrapper).toMatch(/Response\.json\(\s*\{\s*ready:\s*true\s*\}\s*\)/); + // Must be checked before delegating to original.fetch, otherwise the + // user's framework handler will see the request. + const readyIdx = wrapper.indexOf('/__void/ready'); + const originalIdx = wrapper.indexOf('original.fetch'); + expect(readyIdx).toBeGreaterThanOrEqual(0); + expect(originalIdx).toBeGreaterThan(readyIdx); + }); + it('does not generate migration handler when hasMigrations is false', async () => { mkdirSync(workerDir, { recursive: true }); writeFileSync(