Switch config.yaml to hatk.config.ts — Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace config.yaml with hatk.config.ts so users get type safety and autocompletion when configuring hatk.
Architecture: Add defineConfig identity function for type inference. Rewrite loadConfig to use dynamic import() instead of YAML parsing. Update all call sites (main.ts, cli.ts, test.ts, vite-plugin.ts) and the hatk new scaffolder.
Tech Stack: TypeScript, dynamic import(), no new dependencies.
Note: The yaml package stays in dependencies — test.ts uses it for loading YAML fixture files in loadFixtures(). Only config.ts stops using it.
Task 1: Rewrite config.ts — add defineConfig, make loadConfig async#
Files:
- Modify:
packages/hatk/src/config.ts
Step 1: Rewrite config.ts
Replace the entire file. Remove readFileSync and YAML imports. Add defineConfig. Make loadConfig async with import().
import { resolve, dirname } from 'node:path'
import { existsSync } from 'node:fs'
export interface LabelLocale {
lang: string
name: string
description: string
}
export interface LabelDefinition {
identifier: string
severity: 'alert' | 'inform' | 'none'
blurs: 'media' | 'content' | 'none'
defaultSetting: 'warn' | 'hide' | 'ignore'
locales?: LabelLocale[]
}
export interface OAuthClientConfig {
client_id: string
client_name: string
redirect_uris: string[]
scope?: string
}
export interface OAuthConfig {
issuer: string
scopes: string[]
clients: OAuthClientConfig[]
}
export interface BackfillConfig {
signalCollections?: string[] // defaults to top-level collections
repos?: string[] // pin specific DIDs to backfill
fullNetwork: boolean
parallelism: number
fetchTimeout: number // seconds
maxRetries: number // max retry attempts for failed repos (default 5)
}
export interface HatkConfig {
relay: string
plc: string // PLC directory URL for DID resolution
port: number
database: string // DuckDB file path (replaces :memory:)
publicDir: string | null // static file directory (null to disable)
collections: string[] // optional — auto-derived from lexicons if empty
backfill: BackfillConfig
ftsRebuildInterval: number // rebuild FTS index every N writes (lower = fresher search)
oauth: OAuthConfig | null
admins: string[] // DIDs allowed to access /admin/* endpoints
}
/** Identity function that provides type inference for hatk config files. */
export function defineConfig(config: Partial<HatkConfig>): Partial<HatkConfig> {
return config
}
/** Derive HTTP URL from relay WebSocket URL (ws://host → http://host) */
export function relayHttpUrl(relay: string): string {
return relay.replace(/^ws(s?):\/\//, 'http$1://')
}
export async function loadConfig(configPath: string): Promise<HatkConfig> {
const resolved = resolve(configPath)
if (!existsSync(resolved)) {
console.error(`Config file not found: ${resolved}`)
console.error(`hatk now uses hatk.config.ts instead of config.yaml.`)
console.error(`Create a hatk.config.ts file or run 'hatk new' to scaffold a project.`)
process.exit(1)
}
const configDir = dirname(resolved)
const mod = await import(resolved)
const parsed: Partial<HatkConfig> & Record<string, any> = mod.default || {}
const backfillRaw = parsed.backfill || ({} as Partial<BackfillConfig>)
const env = process.env
const database = env.DATABASE || parsed.database
const config: HatkConfig = {
relay: env.RELAY || parsed.relay || 'ws://localhost:2583',
plc: env.DID_PLC_URL || parsed.plc || 'https://plc.directory',
port: parseInt(env.PORT || '') || parsed.port || 3000,
database: database ? resolve(configDir, database) : ':memory:',
publicDir: parsed.publicDir === null ? null : resolve(configDir, (parsed as any).public || parsed.publicDir || './public'),
collections: parsed.collections || [],
backfill: {
signalCollections: backfillRaw.signalCollections || undefined,
repos: env.BACKFILL_REPOS ? env.BACKFILL_REPOS.split(',').map((s) => s.trim()) : backfillRaw.repos || undefined,
fullNetwork: env.BACKFILL_FULL_NETWORK ? env.BACKFILL_FULL_NETWORK === 'true' : backfillRaw.fullNetwork || false,
parallelism: parseInt(env.BACKFILL_PARALLELISM || '') || backfillRaw.parallelism || 3,
fetchTimeout: parseInt(env.BACKFILL_FETCH_TIMEOUT || '') || backfillRaw.fetchTimeout || 300,
maxRetries: parseInt(env.BACKFILL_MAX_RETRIES || '') || backfillRaw.maxRetries || 5,
},
ftsRebuildInterval: parseInt(env.FTS_REBUILD_INTERVAL || '') || parsed.ftsRebuildInterval || 5000,
oauth: null,
admins: env.ADMINS ? env.ADMINS.split(',').map((s) => s.trim()) : parsed.admins || [],
}
const oauthRaw = parsed.oauth
if (oauthRaw) {
config.oauth = {
issuer: process.env.OAUTH_ISSUER || oauthRaw.issuer || `http://127.0.0.1:${config.port}`,
scopes: oauthRaw.scopes || ['atproto'],
clients: oauthRaw.clients || [],
}
}
return config
}
Step 2: Verify the file compiles
Run: cd packages/hatk && npx tsc --noEmit src/config.ts
Task 2: Update main.ts — await loadConfig, change default path#
Files:
- Modify:
packages/hatk/src/main.ts:34,40
Step 1: Update the config path default and await loadConfig
Change line 34 from:
const configPath = process.argv[2] || 'config.yaml'
to:
const configPath = process.argv[2] || 'hatk.config.ts'
Change line 40 from:
const config = loadConfig(configPath)
to:
const config = await loadConfig(configPath)
Step 2: Verify it compiles
Run: cd packages/hatk && npx tsc --noEmit src/main.ts
Task 3: Update cli.ts — all config.yaml references to hatk.config.ts, await loadConfig#
Files:
- Modify:
packages/hatk/src/cli.ts
There are 8 references to config.yaml in cli.ts. Change all of them:
Step 1: Update hatk new scaffolder (line 378)
Change the file creation from writing config.yaml to writing hatk.config.ts:
writeFileSync(
join(dir, 'hatk.config.ts'),
`import { defineConfig } from 'hatk'
export default defineConfig({
relay: 'ws://localhost:2583',
plc: 'http://localhost:2582',
port: 3000,
database: 'data/hatk.db',
admins: [],
backfill: {
parallelism: 10,
},
})
`,
)
Step 2: Update Dockerfile template (line 988)
Change:
CMD ["node", "--max-old-space-size=512", "node_modules/@hatk/hatk/dist/main.js", "config.yaml"]
to:
CMD ["node", "--max-old-space-size=512", "node_modules/@hatk/hatk/dist/main.js", "hatk.config.ts"]
Step 3: Update scaffolding output message (line 1297)
Change:
console.log(` config.yaml`)
to:
console.log(` hatk.config.ts`)
Step 4: Update hatk dev command (line 1740)
Change:
execSync(`npx tsx ${mainPath} config.yaml`, {
to:
execSync(`npx tsx ${mainPath} hatk.config.ts`, {
Step 5: Update hatk reset command (line 1763)
Change:
const config = loadConfig(resolve('config.yaml'))
to:
const config = await loadConfig(resolve('hatk.config.ts'))
Step 6: Update hatk schema command (line 1925)
Change:
const config = loadConfig(resolve('config.yaml'))
to:
const config = await loadConfig(resolve('hatk.config.ts'))
Step 7: Update hatk start command (line 1963)
Change:
execSync(`npx tsx ${mainPath} config.yaml`, { stdio: 'inherit', cwd: process.cwd() })
to:
execSync(`npx tsx ${mainPath} hatk.config.ts`, { stdio: 'inherit', cwd: process.cwd() })
Step 8: Verify it compiles
Run: cd packages/hatk && npx tsc --noEmit src/cli.ts
Task 4: Update test.ts — await loadConfig, change default path#
Files:
- Modify:
packages/hatk/src/test.ts:54-74
Step 1: Update findConfigPath to look for hatk.config.ts
Change lines 57-61 from:
function findConfigPath(): string {
const explicit = process.env.APPVIEW_CONFIG
if (explicit) return resolve(explicit)
return resolve('config.yaml')
}
to:
function findConfigPath(): string {
const explicit = process.env.APPVIEW_CONFIG
if (explicit) return resolve(explicit)
return resolve('hatk.config.ts')
}
Step 2: Update createTestContext to await loadConfig
Change line 74 from:
const config = loadConfig(configPath)
to:
const config = await loadConfig(configPath)
Step 3: Verify it compiles
Run: cd packages/hatk && npx tsc --noEmit src/test.ts
Task 5: Update vite-plugin.ts — change spawned server argument#
Files:
- Modify:
packages/hatk/src/vite-plugin.ts:71
Step 1: Change config.yaml to hatk.config.ts
Change line 71 from:
serverProcess = spawn('npx', ['tsx', 'watch', ...watchArgs, mainPath, 'config.yaml'], {
to:
serverProcess = spawn('npx', ['tsx', 'watch', ...watchArgs, mainPath, 'hatk.config.ts'], {
Step 2: Verify it compiles
Run: cd packages/hatk && npx tsc --noEmit src/vite-plugin.ts
Task 6: Add package export for defineConfig and HatkConfig#
Files:
- Modify:
packages/hatk/package.json
Step 1: Add config export to package.json exports field
Add this entry to the "exports" object:
"./config": "./dist/config.js",
This allows users to write import { defineConfig } from 'hatk/config' (or the package could also re-export from a root entry if one exists).
Note: Since the package name is @hatk/hatk and users write import { defineConfig } from 'hatk' (which is a different package or alias), check how the project resolves this. The scaffolded hatk.config.ts uses from 'hatk' — if hatk is an alias for @hatk/hatk, the export should work. If not, the import path in the scaffold template may need to be from '@hatk/hatk/config'.
Task 7: Update documentation#
Files:
- Modify:
docs/site/src/content/docs/getting-started/configuration.mdx
Step 1: Rewrite the docs page for TypeScript config
Replace the entire file with TypeScript-based configuration documentation. Change the YAML example to a TypeScript defineConfig() example. Update the frontmatter description. Keep all the options reference (relay, plc, port, database, etc.) and env var documentation intact.
Key changes:
- Title/description: reference
hatk.config.tsinstead ofconfig.yaml - Complete example: TypeScript with
defineConfig()instead of YAML - All option docs stay the same, just the format changes
Task 8: Full build verification#
Step 1: Run full TypeScript check
Run: cd packages/hatk && npx tsc --noEmit
Expected: No errors.
Step 2: Search for any remaining config.yaml references
Run: grep -r "config\.yaml" packages/hatk/src/
Expected: No results (test.ts still uses YAML for fixtures but doesn't reference config.yaml as a filename).
Step 3: Verify the build
Run: cd packages/hatk && npm run build
Expected: Clean build, dist/ output.