0
0
# Template System Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `--template` flag to `hatk new` so users can scaffold from bundled starter apps (starting with statusphere).
**Architecture:** Templates are directories in `packages/appview/templates/<name>/` with a `template.json` manifest. The `hatk new` command runs the normal scaffold first, then overlays template files on top, merging config and dependencies from the manifest.
**Tech Stack:** Node.js fs APIs, YAML (already a dependency), JSON deep merge
---
## File Structure
| File | Action | Purpose |
|------|--------|---------|
| `packages/appview/src/cli.ts` | Modify | Add `--template` flag parsing, template loading, overlay logic |
| `packages/appview/templates/statusphere/template.json` | Create | Template manifest |
| `packages/appview/templates/statusphere/lexicons/` | Create | Custom lexicon schemas |
| `packages/appview/templates/statusphere/feeds/recent.ts` | Create | Feed generator |
| `packages/appview/templates/statusphere/xrpc/xyz/statusphere/getProfile.ts` | Create | XRPC handler |
| `packages/appview/templates/statusphere/seeds/seed.ts` | Create | Seed data |
| `packages/appview/templates/statusphere/test/` | Create | Tests + fixtures |
| `packages/appview/templates/statusphere/src/` | Create | SvelteKit frontend |
---
## Chunk 1: Template Infrastructure in CLI
### Task 1: Add --template flag parsing and usage text
**Files:**
- Modify: `packages/appview/src/cli.ts:39-77` (usage function)
- Modify: `packages/appview/src/cli.ts:307-315` (new command args)
- [ ] **Step 1: Update usage text**
In `usage()` (line 41), change:
```
new <name> [--svelte] Create a new hatk project
```
to:
```
new <name> [--svelte] [--template <t>] Create a new hatk project
```
- [ ] **Step 2: Parse --template flag**
After line 314 (`const withSvelte = args.includes('--svelte')`), add:
```typescript
const templateIdx = args.indexOf('--template')
const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null
if (templateIdx !== -1 && !templateName) {
console.error('Usage: hatk new <name> --template <template-name>')
process.exit(1)
}
```
- [ ] **Step 3: Validate template exists**
After the template parsing, add:
```typescript
let template: { description?: string; svelte?: boolean; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; config?: Record<string, any> } | null = null
let templateDir: string | null = null
if (templateName) {
templateDir = join(import.meta.dirname, '..', 'templates', templateName)
if (!existsSync(templateDir)) {
const available = readdirSync(join(import.meta.dirname, '..', 'templates')).filter(f => existsSync(join(import.meta.dirname, '..', 'templates', f, 'template.json')))
console.error(`Unknown template: ${templateName}`)
if (available.length) console.error(`Available templates: ${available.join(', ')}`)
process.exit(1)
}
template = JSON.parse(readFileSync(join(templateDir, 'template.json'), 'utf-8'))
}
```
- [ ] **Step 4: Add readFileSync to imports**
At line 2, add `readFileSync` to the existing import:
```typescript
import { mkdirSync, writeFileSync, existsSync, unlinkSync, readdirSync, readFileSync, cpSync } from 'node:fs'
```
- [ ] **Step 5: Make template's svelte flag apply**
Change `const withSvelte = args.includes('--svelte')` to:
```typescript
const withSvelte = args.includes('--svelte') || template?.svelte === true
```
Note: this line must come AFTER the template parsing, so move the `--svelte` check down.
- [ ] **Step 6: Commit**
```bash
git add packages/appview/src/cli.ts
git commit -m "feat: add --template flag parsing to hatk new"
```
---
### Task 2: Add template overlay logic
**Files:**
- Modify: `packages/appview/src/cli.ts` (after scaffold, before `execSync('npx hatk generate types'...)`)
- [ ] **Step 1: Add overlay logic before the final type generation**
Before line 1199 (`execSync('npx hatk generate types'...)`), add:
```typescript
// Apply template overlay
if (template && templateDir) {
// Merge dependencies into package.json
if (template.dependencies || template.devDependencies) {
const pkgPath = join(dir, 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
if (template.dependencies) Object.assign(pkg.dependencies, template.dependencies)
if (template.devDependencies) Object.assign(pkg.devDependencies, template.devDependencies)
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}
// Merge config into config.yaml
if (template.config) {
const configPath = join(dir, 'config.yaml')
const { parse, stringify } = await import('yaml')
const config = parse(readFileSync(configPath, 'utf-8'))
deepMerge(config, template.config)
writeFileSync(configPath, stringify(config))
}
// Copy template files (skip template.json)
const entries = readdirSync(templateDir)
for (const entry of entries) {
if (entry === 'template.json') continue
cpSync(join(templateDir, entry), join(dir, entry), { recursive: true, force: true })
}
}
```
- [ ] **Step 2: Add deepMerge helper**
Add before the `usage()` function (around line 38):
```typescript
function deepMerge(target: Record<string, any>, source: Record<string, any>): void {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
deepMerge(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
```
- [ ] **Step 3: Add cpSync to imports**
Ensure `cpSync` is in the import on line 2 (should already be there from Step 4 of Task 1).
- [ ] **Step 4: Commit**
```bash
git add packages/appview/src/cli.ts
git commit -m "feat: add template overlay logic (config merge, deps merge, file copy)"
```
---
## Chunk 2: Statusphere Template Files
### Task 3: Create template manifest and lexicons
**Files:**
- Create: `packages/appview/templates/statusphere/template.json`
- Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/defs.json`
- Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/status.json`
- Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/getProfile.json`
- Create: `packages/appview/templates/statusphere/lexicons/app/bsky/actor/profile.json`
- [ ] **Step 1: Create template.json**
```json
{
"description": "Statusphere example app",
"svelte": true,
"dependencies": {
"@tanstack/svelte-query": "^5"
},
"config": {
"oauth": {
"scopes": [
"atproto",
"repo:xyz.statusphere.status?action=create&action=delete"
],
"clients": [
{
"client_id": "http://127.0.0.1:3000/oauth-client-metadata.json",
"client_name": "statusphere",
"scope": "atproto repo:xyz.statusphere.status?action=create&action=delete",
"redirect_uris": ["http://127.0.0.1:3000/oauth/callback"]
}
]
}
}
}
```
- [ ] **Step 2: Create lexicon files**
Copy the following from exercise 10 as-is (NSIDs stay as `xyz.statusphere.*`):
- `lexicons/xyz/statusphere/defs.json` — profileView and statusView definitions
- `lexicons/xyz/statusphere/status.json` — status record schema
- `lexicons/xyz/statusphere/getProfile.json` — getProfile query
- `lexicons/app/bsky/actor/profile.json` — Bluesky profile record
- [ ] **Step 3: Commit**
```bash
git add packages/appview/templates/statusphere/
git commit -m "feat: add statusphere template manifest and lexicons"
```
---
### Task 4: Create template backend files
**Files:**
- Create: `packages/appview/templates/statusphere/feeds/recent.ts`
- Create: `packages/appview/templates/statusphere/xrpc/xyz/statusphere/getProfile.ts`
- Create: `packages/appview/templates/statusphere/seeds/seed.ts`
- [ ] **Step 1: Create feeds/recent.ts**
Copy from exercise 10 but update imports: `'../appview.generated.ts'` → `'../hatk.generated.ts'`
```typescript
import { defineFeed, views, type Status, type Profile, type HydrateContext } from '../hatk.generated.ts'
export default defineFeed({
collection: 'xyz.statusphere.status',
label: 'Recent',
hydrate: (ctx) => hydrateStatuses(ctx),
async generate(ctx) {
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
`SELECT uri, cid, indexed_at FROM "xyz.statusphere.status"`,
)
return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
},
})
async function hydrateStatuses(ctx: HydrateContext<Status>) {
const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))]
const profiles = await ctx.lookup<Profile>('app.bsky.actor.profile', 'did', dids)
return ctx.items.map((item) => {
const author = profiles.get(item.did)
return views.statusView({
uri: item.uri,
status: item.value.status,
createdAt: item.value.createdAt,
indexedAt: item.indexed_at,
author: views.profileView({
did: item.did,
handle: item.handle || item.did,
displayName: author?.value.displayName,
avatar: author ? ctx.blobUrl(author.did, author.value.avatar, 'avatar') : undefined,
}),
})
})
}
```
- [ ] **Step 2: Create xrpc/xyz/statusphere/getProfile.ts**
Copy from exercise 10 but update imports: `'../../../appview.generated.ts'` → `'../../../hatk.generated.ts'`
```typescript
import { defineQuery, views, type Profile } from '../../../hatk.generated.ts'
export default defineQuery('xyz.statusphere.getProfile', async (ctx) => {
const { ok, params, lookup, blobUrl } = ctx
const actor = params.actor as string
const profiles = await lookup<Profile>('app.bsky.actor.profile', 'did', [actor])
const profile = profiles.get(actor)
if (!profile) {
return ok(views.profileView({ did: actor, handle: actor }))
}
return ok(views.profileView({
did: actor,
handle: profile.handle || actor,
displayName: profile.value.displayName,
description: profile.value.description,
avatar: blobUrl(profile.did, profile.value.avatar, 'avatar'),
}))
})
```
- [ ] **Step 3: Create seeds/seed.ts**
Copy from exercise 10 but update import: `'../appview.generated.ts'` → `'../hatk.generated.ts'`
```typescript
import { seed } from '../hatk.generated.ts'
const { createAccount, createRecord } = seed()
const alice = await createAccount('alice.test')
const bob = await createAccount('bob.test')
const carol = await createAccount('carol.test')
await createRecord(alice, 'app.bsky.actor.profile', {
displayName: 'Alice',
description: 'Emoji enthusiast',
}, { rkey: 'self' })
await createRecord(bob, 'app.bsky.actor.profile', {
displayName: 'Bob',
description: 'Coffee lover',
}, { rkey: 'self' })
await createRecord(carol, 'app.bsky.actor.profile', {
displayName: 'Carol',
description: 'Butterfly chaser',
}, { rkey: 'self' })
const now = Date.now()
const ago = (minutes: number) => new Date(now - minutes * 60_000).toISOString()
await createRecord(alice, 'xyz.statusphere.status', {
status: '🚀',
createdAt: ago(5),
}, { rkey: 'alice-1' })
await createRecord(alice, 'xyz.statusphere.status', {
status: '😎',
createdAt: ago(60),
}, { rkey: 'alice-2' })
await createRecord(bob, 'xyz.statusphere.status', {
status: '🧑💻',
createdAt: ago(10),
}, { rkey: 'bob-1' })
await createRecord(bob, 'xyz.statusphere.status', {
status: '☕',
createdAt: ago(120),
}, { rkey: 'bob-2' })
await createRecord(carol, 'xyz.statusphere.status', {
status: '🦋',
createdAt: ago(15),
}, { rkey: 'carol-1' })
await createRecord(carol, 'xyz.statusphere.status', {
status: '💙',
createdAt: ago(90),
}, { rkey: 'carol-2' })
console.log('\n[seed] Done!')
```
- [ ] **Step 4: Commit**
```bash
git add packages/appview/templates/statusphere/
git commit -m "feat: add statusphere template backend files"
```
---
### Task 5: Create template test files
**Files:**
- Create: `packages/appview/templates/statusphere/test/feeds/recent.test.ts`
- Create: `packages/appview/templates/statusphere/test/fixtures/_repos.yaml`
- Create: `packages/appview/templates/statusphere/test/fixtures/app.bsky.actor.profile.yaml`
- Create: `packages/appview/templates/statusphere/test/fixtures/xyz.statusphere.status.yaml`
- [ ] **Step 1: Create test/feeds/recent.test.ts**
Copy from exercise 10 but update import: `'appview/test'` → `'hatk/test'`
```typescript
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
import { createTestContext } from 'hatk/test'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
await ctx.loadFixtures()
})
afterAll(async () => ctx?.close())
describe('recent feed', () => {
test('returns all statuses', async () => {
const feed = ctx.loadFeed('recent')
const result = await feed.generate(ctx.feedContext({ limit: 10 }))
expect(result.items).toHaveLength(6)
})
test('each item has required fields', async () => {
const feed = ctx.loadFeed('recent')
const result = await feed.generate(ctx.feedContext({ limit: 10 }))
for (const item of result.items as any[]) {
expect(item.uri).toMatch(/^at:\/\//)
expect(item.status).toBeDefined()
expect(item.createdAt).toBeDefined()
expect(item.author).toBeDefined()
expect(item.author.did).toBeDefined()
expect(item.author.handle).toBeDefined()
}
})
test('includes displayName from profile', async () => {
const feed = ctx.loadFeed('recent')
const result = await feed.generate(ctx.feedContext({ limit: 10 }))
const aliceStatus = (result.items as any[]).find((s) => s.author.handle === 'alice.test')
expect(aliceStatus?.author.displayName).toBe('Alice')
})
test('respects limit', async () => {
const feed = ctx.loadFeed('recent')
const result = await feed.generate(ctx.feedContext({ limit: 2 }))
expect(result.items).toHaveLength(2)
expect(result.cursor).toBeDefined()
})
test('cursor pagination returns next page', async () => {
const feed = ctx.loadFeed('recent')
const page1 = await feed.generate(ctx.feedContext({ limit: 3 }))
expect(page1.items).toHaveLength(3)
expect(page1.cursor).toBeDefined()
const page2 = await feed.generate(ctx.feedContext({ limit: 3, cursor: page1.cursor }))
expect(page2.items).toHaveLength(3)
expect(page2.cursor).toBeUndefined()
const allUris = [...(page1.items as any[]), ...(page2.items as any[])].map((s) => s.uri)
expect(new Set(allUris).size).toBe(6)
})
test('returns no cursor when all results fit', async () => {
const feed = ctx.loadFeed('recent')
const result = await feed.generate(ctx.feedContext({ limit: 30 }))
expect(result.items).toHaveLength(6)
expect(result.cursor).toBeUndefined()
})
})
```
- [ ] **Step 2: Create fixture YAML files**
Copy from exercise 10 as-is:
- `test/fixtures/_repos.yaml`
- `test/fixtures/app.bsky.actor.profile.yaml`
- `test/fixtures/xyz.statusphere.status.yaml`
- [ ] **Step 3: Commit**
```bash
git add packages/appview/templates/statusphere/test/
git commit -m "feat: add statusphere template test files and fixtures"
```
---
### Task 6: Create template frontend files
**Files:**
- Create: `packages/appview/templates/statusphere/src/routes/+page.svelte`
- Create: `packages/appview/templates/statusphere/src/routes/+layout.svelte`
- Create: `packages/appview/templates/statusphere/src/routes/oauth/callback/+page.svelte`
- Create: `packages/appview/templates/statusphere/src/lib/api.ts`
- Create: `packages/appview/templates/statusphere/src/lib/auth.ts`
- Create: `packages/appview/templates/statusphere/src/lib/query.ts`
- Create: `packages/appview/templates/statusphere/src/app.html`
- Create: `packages/appview/templates/statusphere/src/app.css`
- Create: `packages/appview/templates/statusphere/src/error.html`
- [ ] **Step 1: Create frontend files**
Copy from exercise 10 with these updates:
- `src/lib/api.ts`: `'appview/xrpc-client'` → `'hatk/xrpc-client'`, `'$appview'` → `'$hatk'`
- `src/lib/auth.ts`: `'@appview/oauth-client'` → `'@hatk/oauth-client'`
- `src/routes/+page.svelte`: `'xyz.appview.getFeed'` → `'dev.hatk.getFeed'`, `'xyz.appview.createRecord'` → `'dev.hatk.createRecord'`, `'xyz.appview.deleteRecord'` → `'dev.hatk.deleteRecord'`, `'$appview'` → `'$hatk'`
- `src/routes/+layout.svelte`: no changes needed (already uses `$lib/` imports)
- `src/app.html`: title stays as `Statusphere`
- `src/app.css`: use the statusphere styling (with `--primary: #6366f1` instead of default teal)
- `src/error.html`: title reference stays as statusphere
- [ ] **Step 2: Commit**
```bash
git add packages/appview/templates/statusphere/src/
git commit -m "feat: add statusphere template frontend files"
```
---
## Chunk 3: Verification
### Task 7: Manual verification
- [ ] **Step 1: Test bare scaffold still works**
```bash
cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-bare
```
Verify: directory created with standard scaffold, no template files.
- [ ] **Step 2: Test statusphere template**
```bash
cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-statusphere --template statusphere
```
Verify:
- Has config.yaml with OAuth settings merged in
- Has custom lexicons under `lexicons/xyz/statusphere/`
- Has `feeds/recent.ts`, `xrpc/xyz/statusphere/getProfile.ts`, `seeds/seed.ts`
- Has test files and fixtures
- Has SvelteKit frontend files in `src/`
- `package.json` includes `@tanstack/svelte-query` dependency
- `hatk.generated.ts` includes StatusView and ProfileView types
- [ ] **Step 3: Test invalid template name**
```bash
cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-bad --template nonexistent
```
Verify: error message listing available templates.
- [ ] **Step 4: Clean up**
```bash
rm -rf /tmp/test-bare /tmp/test-statusphere /tmp/test-bad
```