AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add --template flag to hatk new (clone from GitHub)

Templates are standalone hatk projects hosted at github.com/hatk-dev/.
`hatk new my-app --template statusphere` clones hatk-template-statusphere,
strips .git, and sets the project name in package.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+695 -6
+546
docs/superpowers/plans/2026-03-10-template-system.md
··· 1 + # Template System Implementation Plan 2 + 3 + > **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. 4 + 5 + **Goal:** Add `--template` flag to `hatk new` so users can scaffold from bundled starter apps (starting with statusphere). 6 + 7 + **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. 8 + 9 + **Tech Stack:** Node.js fs APIs, YAML (already a dependency), JSON deep merge 10 + 11 + --- 12 + 13 + ## File Structure 14 + 15 + | File | Action | Purpose | 16 + |------|--------|---------| 17 + | `packages/appview/src/cli.ts` | Modify | Add `--template` flag parsing, template loading, overlay logic | 18 + | `packages/appview/templates/statusphere/template.json` | Create | Template manifest | 19 + | `packages/appview/templates/statusphere/lexicons/` | Create | Custom lexicon schemas | 20 + | `packages/appview/templates/statusphere/feeds/recent.ts` | Create | Feed generator | 21 + | `packages/appview/templates/statusphere/xrpc/xyz/statusphere/getProfile.ts` | Create | XRPC handler | 22 + | `packages/appview/templates/statusphere/seeds/seed.ts` | Create | Seed data | 23 + | `packages/appview/templates/statusphere/test/` | Create | Tests + fixtures | 24 + | `packages/appview/templates/statusphere/src/` | Create | SvelteKit frontend | 25 + 26 + --- 27 + 28 + ## Chunk 1: Template Infrastructure in CLI 29 + 30 + ### Task 1: Add --template flag parsing and usage text 31 + 32 + **Files:** 33 + - Modify: `packages/appview/src/cli.ts:39-77` (usage function) 34 + - Modify: `packages/appview/src/cli.ts:307-315` (new command args) 35 + 36 + - [ ] **Step 1: Update usage text** 37 + 38 + In `usage()` (line 41), change: 39 + ``` 40 + new <name> [--svelte] Create a new hatk project 41 + ``` 42 + to: 43 + ``` 44 + new <name> [--svelte] [--template <t>] Create a new hatk project 45 + ``` 46 + 47 + - [ ] **Step 2: Parse --template flag** 48 + 49 + After line 314 (`const withSvelte = args.includes('--svelte')`), add: 50 + ```typescript 51 + const templateIdx = args.indexOf('--template') 52 + const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null 53 + if (templateIdx !== -1 && !templateName) { 54 + console.error('Usage: hatk new <name> --template <template-name>') 55 + process.exit(1) 56 + } 57 + ``` 58 + 59 + - [ ] **Step 3: Validate template exists** 60 + 61 + After the template parsing, add: 62 + ```typescript 63 + let template: { description?: string; svelte?: boolean; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; config?: Record<string, any> } | null = null 64 + let templateDir: string | null = null 65 + if (templateName) { 66 + templateDir = join(import.meta.dirname, '..', 'templates', templateName) 67 + if (!existsSync(templateDir)) { 68 + const available = readdirSync(join(import.meta.dirname, '..', 'templates')).filter(f => existsSync(join(import.meta.dirname, '..', 'templates', f, 'template.json'))) 69 + console.error(`Unknown template: ${templateName}`) 70 + if (available.length) console.error(`Available templates: ${available.join(', ')}`) 71 + process.exit(1) 72 + } 73 + template = JSON.parse(readFileSync(join(templateDir, 'template.json'), 'utf-8')) 74 + } 75 + ``` 76 + 77 + - [ ] **Step 4: Add readFileSync to imports** 78 + 79 + At line 2, add `readFileSync` to the existing import: 80 + ```typescript 81 + import { mkdirSync, writeFileSync, existsSync, unlinkSync, readdirSync, readFileSync, cpSync } from 'node:fs' 82 + ``` 83 + 84 + - [ ] **Step 5: Make template's svelte flag apply** 85 + 86 + Change `const withSvelte = args.includes('--svelte')` to: 87 + ```typescript 88 + const withSvelte = args.includes('--svelte') || template?.svelte === true 89 + ``` 90 + 91 + Note: this line must come AFTER the template parsing, so move the `--svelte` check down. 92 + 93 + - [ ] **Step 6: Commit** 94 + 95 + ```bash 96 + git add packages/appview/src/cli.ts 97 + git commit -m "feat: add --template flag parsing to hatk new" 98 + ``` 99 + 100 + --- 101 + 102 + ### Task 2: Add template overlay logic 103 + 104 + **Files:** 105 + - Modify: `packages/appview/src/cli.ts` (after scaffold, before `execSync('npx hatk generate types'...)`) 106 + 107 + - [ ] **Step 1: Add overlay logic before the final type generation** 108 + 109 + Before line 1199 (`execSync('npx hatk generate types'...)`), add: 110 + ```typescript 111 + // Apply template overlay 112 + if (template && templateDir) { 113 + // Merge dependencies into package.json 114 + if (template.dependencies || template.devDependencies) { 115 + const pkgPath = join(dir, 'package.json') 116 + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) 117 + if (template.dependencies) Object.assign(pkg.dependencies, template.dependencies) 118 + if (template.devDependencies) Object.assign(pkg.devDependencies, template.devDependencies) 119 + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n') 120 + } 121 + 122 + // Merge config into config.yaml 123 + if (template.config) { 124 + const configPath = join(dir, 'config.yaml') 125 + const { parse, stringify } = await import('yaml') 126 + const config = parse(readFileSync(configPath, 'utf-8')) 127 + deepMerge(config, template.config) 128 + writeFileSync(configPath, stringify(config)) 129 + } 130 + 131 + // Copy template files (skip template.json) 132 + const entries = readdirSync(templateDir) 133 + for (const entry of entries) { 134 + if (entry === 'template.json') continue 135 + cpSync(join(templateDir, entry), join(dir, entry), { recursive: true, force: true }) 136 + } 137 + } 138 + ``` 139 + 140 + - [ ] **Step 2: Add deepMerge helper** 141 + 142 + Add before the `usage()` function (around line 38): 143 + ```typescript 144 + function deepMerge(target: Record<string, any>, source: Record<string, any>): void { 145 + for (const key of Object.keys(source)) { 146 + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) { 147 + deepMerge(target[key], source[key]) 148 + } else { 149 + target[key] = source[key] 150 + } 151 + } 152 + } 153 + ``` 154 + 155 + - [ ] **Step 3: Add cpSync to imports** 156 + 157 + Ensure `cpSync` is in the import on line 2 (should already be there from Step 4 of Task 1). 158 + 159 + - [ ] **Step 4: Commit** 160 + 161 + ```bash 162 + git add packages/appview/src/cli.ts 163 + git commit -m "feat: add template overlay logic (config merge, deps merge, file copy)" 164 + ``` 165 + 166 + --- 167 + 168 + ## Chunk 2: Statusphere Template Files 169 + 170 + ### Task 3: Create template manifest and lexicons 171 + 172 + **Files:** 173 + - Create: `packages/appview/templates/statusphere/template.json` 174 + - Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/defs.json` 175 + - Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/status.json` 176 + - Create: `packages/appview/templates/statusphere/lexicons/xyz/statusphere/getProfile.json` 177 + - Create: `packages/appview/templates/statusphere/lexicons/app/bsky/actor/profile.json` 178 + 179 + - [ ] **Step 1: Create template.json** 180 + 181 + ```json 182 + { 183 + "description": "Statusphere example app", 184 + "svelte": true, 185 + "dependencies": { 186 + "@tanstack/svelte-query": "^5" 187 + }, 188 + "config": { 189 + "oauth": { 190 + "scopes": [ 191 + "atproto", 192 + "repo:xyz.statusphere.status?action=create&action=delete" 193 + ], 194 + "clients": [ 195 + { 196 + "client_id": "http://127.0.0.1:3000/oauth-client-metadata.json", 197 + "client_name": "statusphere", 198 + "scope": "atproto repo:xyz.statusphere.status?action=create&action=delete", 199 + "redirect_uris": ["http://127.0.0.1:3000/oauth/callback"] 200 + } 201 + ] 202 + } 203 + } 204 + } 205 + ``` 206 + 207 + - [ ] **Step 2: Create lexicon files** 208 + 209 + Copy the following from exercise 10 as-is (NSIDs stay as `xyz.statusphere.*`): 210 + - `lexicons/xyz/statusphere/defs.json` — profileView and statusView definitions 211 + - `lexicons/xyz/statusphere/status.json` — status record schema 212 + - `lexicons/xyz/statusphere/getProfile.json` — getProfile query 213 + - `lexicons/app/bsky/actor/profile.json` — Bluesky profile record 214 + 215 + - [ ] **Step 3: Commit** 216 + 217 + ```bash 218 + git add packages/appview/templates/statusphere/ 219 + git commit -m "feat: add statusphere template manifest and lexicons" 220 + ``` 221 + 222 + --- 223 + 224 + ### Task 4: Create template backend files 225 + 226 + **Files:** 227 + - Create: `packages/appview/templates/statusphere/feeds/recent.ts` 228 + - Create: `packages/appview/templates/statusphere/xrpc/xyz/statusphere/getProfile.ts` 229 + - Create: `packages/appview/templates/statusphere/seeds/seed.ts` 230 + 231 + - [ ] **Step 1: Create feeds/recent.ts** 232 + 233 + Copy from exercise 10 but update imports: `'../appview.generated.ts'` → `'../hatk.generated.ts'` 234 + 235 + ```typescript 236 + import { defineFeed, views, type Status, type Profile, type HydrateContext } from '../hatk.generated.ts' 237 + 238 + export default defineFeed({ 239 + collection: 'xyz.statusphere.status', 240 + label: 'Recent', 241 + 242 + hydrate: (ctx) => hydrateStatuses(ctx), 243 + 244 + async generate(ctx) { 245 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 246 + `SELECT uri, cid, indexed_at FROM "xyz.statusphere.status"`, 247 + ) 248 + 249 + return ctx.ok({ uris: rows.map((r) => r.uri), cursor }) 250 + }, 251 + }) 252 + 253 + async function hydrateStatuses(ctx: HydrateContext<Status>) { 254 + const dids = [...new Set(ctx.items.map((item) => item.did).filter(Boolean))] 255 + const profiles = await ctx.lookup<Profile>('app.bsky.actor.profile', 'did', dids) 256 + 257 + return ctx.items.map((item) => { 258 + const author = profiles.get(item.did) 259 + return views.statusView({ 260 + uri: item.uri, 261 + status: item.value.status, 262 + createdAt: item.value.createdAt, 263 + indexedAt: item.indexed_at, 264 + author: views.profileView({ 265 + did: item.did, 266 + handle: item.handle || item.did, 267 + displayName: author?.value.displayName, 268 + avatar: author ? ctx.blobUrl(author.did, author.value.avatar, 'avatar') : undefined, 269 + }), 270 + }) 271 + }) 272 + } 273 + ``` 274 + 275 + - [ ] **Step 2: Create xrpc/xyz/statusphere/getProfile.ts** 276 + 277 + Copy from exercise 10 but update imports: `'../../../appview.generated.ts'` → `'../../../hatk.generated.ts'` 278 + 279 + ```typescript 280 + import { defineQuery, views, type Profile } from '../../../hatk.generated.ts' 281 + 282 + export default defineQuery('xyz.statusphere.getProfile', async (ctx) => { 283 + const { ok, params, lookup, blobUrl } = ctx 284 + const actor = params.actor as string 285 + 286 + const profiles = await lookup<Profile>('app.bsky.actor.profile', 'did', [actor]) 287 + const profile = profiles.get(actor) 288 + 289 + if (!profile) { 290 + return ok(views.profileView({ did: actor, handle: actor })) 291 + } 292 + 293 + return ok(views.profileView({ 294 + did: actor, 295 + handle: profile.handle || actor, 296 + displayName: profile.value.displayName, 297 + description: profile.value.description, 298 + avatar: blobUrl(profile.did, profile.value.avatar, 'avatar'), 299 + })) 300 + }) 301 + ``` 302 + 303 + - [ ] **Step 3: Create seeds/seed.ts** 304 + 305 + Copy from exercise 10 but update import: `'../appview.generated.ts'` → `'../hatk.generated.ts'` 306 + 307 + ```typescript 308 + import { seed } from '../hatk.generated.ts' 309 + 310 + const { createAccount, createRecord } = seed() 311 + 312 + const alice = await createAccount('alice.test') 313 + const bob = await createAccount('bob.test') 314 + const carol = await createAccount('carol.test') 315 + 316 + await createRecord(alice, 'app.bsky.actor.profile', { 317 + displayName: 'Alice', 318 + description: 'Emoji enthusiast', 319 + }, { rkey: 'self' }) 320 + 321 + await createRecord(bob, 'app.bsky.actor.profile', { 322 + displayName: 'Bob', 323 + description: 'Coffee lover', 324 + }, { rkey: 'self' }) 325 + 326 + await createRecord(carol, 'app.bsky.actor.profile', { 327 + displayName: 'Carol', 328 + description: 'Butterfly chaser', 329 + }, { rkey: 'self' }) 330 + 331 + const now = Date.now() 332 + const ago = (minutes: number) => new Date(now - minutes * 60_000).toISOString() 333 + 334 + await createRecord(alice, 'xyz.statusphere.status', { 335 + status: '🚀', 336 + createdAt: ago(5), 337 + }, { rkey: 'alice-1' }) 338 + 339 + await createRecord(alice, 'xyz.statusphere.status', { 340 + status: '😎', 341 + createdAt: ago(60), 342 + }, { rkey: 'alice-2' }) 343 + 344 + await createRecord(bob, 'xyz.statusphere.status', { 345 + status: '🧑‍💻', 346 + createdAt: ago(10), 347 + }, { rkey: 'bob-1' }) 348 + 349 + await createRecord(bob, 'xyz.statusphere.status', { 350 + status: '☕', 351 + createdAt: ago(120), 352 + }, { rkey: 'bob-2' }) 353 + 354 + await createRecord(carol, 'xyz.statusphere.status', { 355 + status: '🦋', 356 + createdAt: ago(15), 357 + }, { rkey: 'carol-1' }) 358 + 359 + await createRecord(carol, 'xyz.statusphere.status', { 360 + status: '💙', 361 + createdAt: ago(90), 362 + }, { rkey: 'carol-2' }) 363 + 364 + console.log('\n[seed] Done!') 365 + ``` 366 + 367 + - [ ] **Step 4: Commit** 368 + 369 + ```bash 370 + git add packages/appview/templates/statusphere/ 371 + git commit -m "feat: add statusphere template backend files" 372 + ``` 373 + 374 + --- 375 + 376 + ### Task 5: Create template test files 377 + 378 + **Files:** 379 + - Create: `packages/appview/templates/statusphere/test/feeds/recent.test.ts` 380 + - Create: `packages/appview/templates/statusphere/test/fixtures/_repos.yaml` 381 + - Create: `packages/appview/templates/statusphere/test/fixtures/app.bsky.actor.profile.yaml` 382 + - Create: `packages/appview/templates/statusphere/test/fixtures/xyz.statusphere.status.yaml` 383 + 384 + - [ ] **Step 1: Create test/feeds/recent.test.ts** 385 + 386 + Copy from exercise 10 but update import: `'appview/test'` → `'hatk/test'` 387 + 388 + ```typescript 389 + import { describe, test, expect, beforeAll, afterAll } from 'vitest' 390 + import { createTestContext } from 'hatk/test' 391 + 392 + let ctx: Awaited<ReturnType<typeof createTestContext>> 393 + 394 + beforeAll(async () => { 395 + ctx = await createTestContext() 396 + await ctx.loadFixtures() 397 + }) 398 + 399 + afterAll(async () => ctx?.close()) 400 + 401 + describe('recent feed', () => { 402 + test('returns all statuses', async () => { 403 + const feed = ctx.loadFeed('recent') 404 + const result = await feed.generate(ctx.feedContext({ limit: 10 })) 405 + expect(result.items).toHaveLength(6) 406 + }) 407 + 408 + test('each item has required fields', async () => { 409 + const feed = ctx.loadFeed('recent') 410 + const result = await feed.generate(ctx.feedContext({ limit: 10 })) 411 + for (const item of result.items as any[]) { 412 + expect(item.uri).toMatch(/^at:\/\//) 413 + expect(item.status).toBeDefined() 414 + expect(item.createdAt).toBeDefined() 415 + expect(item.author).toBeDefined() 416 + expect(item.author.did).toBeDefined() 417 + expect(item.author.handle).toBeDefined() 418 + } 419 + }) 420 + 421 + test('includes displayName from profile', async () => { 422 + const feed = ctx.loadFeed('recent') 423 + const result = await feed.generate(ctx.feedContext({ limit: 10 })) 424 + const aliceStatus = (result.items as any[]).find((s) => s.author.handle === 'alice.test') 425 + expect(aliceStatus?.author.displayName).toBe('Alice') 426 + }) 427 + 428 + test('respects limit', async () => { 429 + const feed = ctx.loadFeed('recent') 430 + const result = await feed.generate(ctx.feedContext({ limit: 2 })) 431 + expect(result.items).toHaveLength(2) 432 + expect(result.cursor).toBeDefined() 433 + }) 434 + 435 + test('cursor pagination returns next page', async () => { 436 + const feed = ctx.loadFeed('recent') 437 + const page1 = await feed.generate(ctx.feedContext({ limit: 3 })) 438 + expect(page1.items).toHaveLength(3) 439 + expect(page1.cursor).toBeDefined() 440 + 441 + const page2 = await feed.generate(ctx.feedContext({ limit: 3, cursor: page1.cursor })) 442 + expect(page2.items).toHaveLength(3) 443 + expect(page2.cursor).toBeUndefined() 444 + 445 + const allUris = [...(page1.items as any[]), ...(page2.items as any[])].map((s) => s.uri) 446 + expect(new Set(allUris).size).toBe(6) 447 + }) 448 + 449 + test('returns no cursor when all results fit', async () => { 450 + const feed = ctx.loadFeed('recent') 451 + const result = await feed.generate(ctx.feedContext({ limit: 30 })) 452 + expect(result.items).toHaveLength(6) 453 + expect(result.cursor).toBeUndefined() 454 + }) 455 + }) 456 + ``` 457 + 458 + - [ ] **Step 2: Create fixture YAML files** 459 + 460 + Copy from exercise 10 as-is: 461 + - `test/fixtures/_repos.yaml` 462 + - `test/fixtures/app.bsky.actor.profile.yaml` 463 + - `test/fixtures/xyz.statusphere.status.yaml` 464 + 465 + - [ ] **Step 3: Commit** 466 + 467 + ```bash 468 + git add packages/appview/templates/statusphere/test/ 469 + git commit -m "feat: add statusphere template test files and fixtures" 470 + ``` 471 + 472 + --- 473 + 474 + ### Task 6: Create template frontend files 475 + 476 + **Files:** 477 + - Create: `packages/appview/templates/statusphere/src/routes/+page.svelte` 478 + - Create: `packages/appview/templates/statusphere/src/routes/+layout.svelte` 479 + - Create: `packages/appview/templates/statusphere/src/routes/oauth/callback/+page.svelte` 480 + - Create: `packages/appview/templates/statusphere/src/lib/api.ts` 481 + - Create: `packages/appview/templates/statusphere/src/lib/auth.ts` 482 + - Create: `packages/appview/templates/statusphere/src/lib/query.ts` 483 + - Create: `packages/appview/templates/statusphere/src/app.html` 484 + - Create: `packages/appview/templates/statusphere/src/app.css` 485 + - Create: `packages/appview/templates/statusphere/src/error.html` 486 + 487 + - [ ] **Step 1: Create frontend files** 488 + 489 + Copy from exercise 10 with these updates: 490 + - `src/lib/api.ts`: `'appview/xrpc-client'` → `'hatk/xrpc-client'`, `'$appview'` → `'$hatk'` 491 + - `src/lib/auth.ts`: `'@appview/oauth-client'` → `'@hatk/oauth-client'` 492 + - `src/routes/+page.svelte`: `'xyz.appview.getFeed'` → `'dev.hatk.getFeed'`, `'xyz.appview.createRecord'` → `'dev.hatk.createRecord'`, `'xyz.appview.deleteRecord'` → `'dev.hatk.deleteRecord'`, `'$appview'` → `'$hatk'` 493 + - `src/routes/+layout.svelte`: no changes needed (already uses `$lib/` imports) 494 + - `src/app.html`: title stays as `Statusphere` 495 + - `src/app.css`: use the statusphere styling (with `--primary: #6366f1` instead of default teal) 496 + - `src/error.html`: title reference stays as statusphere 497 + 498 + - [ ] **Step 2: Commit** 499 + 500 + ```bash 501 + git add packages/appview/templates/statusphere/src/ 502 + git commit -m "feat: add statusphere template frontend files" 503 + ``` 504 + 505 + --- 506 + 507 + ## Chunk 3: Verification 508 + 509 + ### Task 7: Manual verification 510 + 511 + - [ ] **Step 1: Test bare scaffold still works** 512 + 513 + ```bash 514 + cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-bare 515 + ``` 516 + 517 + Verify: directory created with standard scaffold, no template files. 518 + 519 + - [ ] **Step 2: Test statusphere template** 520 + 521 + ```bash 522 + cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-statusphere --template statusphere 523 + ``` 524 + 525 + Verify: 526 + - Has config.yaml with OAuth settings merged in 527 + - Has custom lexicons under `lexicons/xyz/statusphere/` 528 + - Has `feeds/recent.ts`, `xrpc/xyz/statusphere/getProfile.ts`, `seeds/seed.ts` 529 + - Has test files and fixtures 530 + - Has SvelteKit frontend files in `src/` 531 + - `package.json` includes `@tanstack/svelte-query` dependency 532 + - `hatk.generated.ts` includes StatusView and ProfileView types 533 + 534 + - [ ] **Step 3: Test invalid template name** 535 + 536 + ```bash 537 + cd /tmp && npx /Users/chadmiller/code/hatk/packages/appview/src/cli.ts new test-bad --template nonexistent 538 + ``` 539 + 540 + Verify: error message listing available templates. 541 + 542 + - [ ] **Step 4: Clean up** 543 + 544 + ```bash 545 + rm -rf /tmp/test-bare /tmp/test-statusphere /tmp/test-bad 546 + ```
+113
docs/superpowers/specs/2026-03-10-template-system-design.md
··· 1 + # Template System Design 2 + 3 + ## Context 4 + 5 + The `hatk new` command currently scaffolds a bare project with empty directories and core framework lexicons. Users building AT Protocol apps need a way to start from a working example rather than an empty shell. The Statusphere app (from the ATConf workshop) is the first template — a complete working app with custom lexicons, feeds, XRPC handlers, seeds, a Svelte frontend, and tests. 6 + 7 + ## Design 8 + 9 + ### CLI Interface 10 + 11 + ``` 12 + hatk new my-app --template statusphere 13 + ``` 14 + 15 + - `--template <name>` selects a bundled template 16 + - Without `--template`, behaves exactly as today (bare scaffold) 17 + - `--svelte` flag is still respected; templates can also declare `"svelte": true` in their manifest 18 + 19 + ### How It Works 20 + 21 + 1. **Scaffold first** — run the normal scaffold logic (config.yaml, package.json, docker-compose.yml, Dockerfile, core `dev.hatk.*` lexicons, tsconfig, linting config, .gitignore) 22 + 2. **Read manifest** — load `template.json` from the template directory 23 + 3. **Apply Svelte** — if manifest declares `"svelte": true`, generate SvelteKit files (same as `--svelte`) 24 + 4. **Merge config** — deep-merge template's `config` object into the generated config.yaml 25 + 5. **Merge dependencies** — add template's `dependencies` and `devDependencies` to package.json 26 + 6. **Copy files** — recursively copy all template files (except template.json) into the project, overwriting scaffold defaults where they overlap 27 + 7. **Finalize** — run `npm install`, `hatk generate types`, and `svelte-kit sync` if Svelte 28 + 29 + ### Template Location 30 + 31 + Templates are bundled inside the hatk package at `packages/appview/templates/<name>/`. Discovered by listing directories in `templates/`. 32 + 33 + ### Template Structure 34 + 35 + ``` 36 + packages/appview/templates/statusphere/ 37 + ├── template.json 38 + ├── lexicons/ 39 + │ └── xyz/statusphere/ 40 + │ ├── defs.json 41 + │ ├── status.json 42 + │ └── getProfile.json 43 + ├── feeds/ 44 + │ └── recent.ts 45 + ├── xrpc/ 46 + │ └── xyz/statusphere/ 47 + │ └── getProfile.ts 48 + ├── seeds/ 49 + │ └── seed.ts 50 + ├── test/ 51 + │ ├── feeds/ 52 + │ │ └── recent.test.ts 53 + │ └── fixtures/ 54 + │ ├── _repos.yaml 55 + │ ├── app.bsky.actor.profile.yaml 56 + │ └── xyz.statusphere.status.yaml 57 + └── src/ 58 + ├── routes/ 59 + │ ├── +page.svelte 60 + │ ├── +layout.svelte 61 + │ └── oauth/callback/+page.svelte 62 + ├── lib/ 63 + │ ├── api.ts 64 + │ ├── auth.ts 65 + │ └── query.ts 66 + ├── app.html 67 + ├── app.css 68 + └── error.html 69 + ``` 70 + 71 + ### template.json Manifest 72 + 73 + ```json 74 + { 75 + "description": "Statusphere example app", 76 + "svelte": true, 77 + "dependencies": { 78 + "@tanstack/svelte-query": "^5" 79 + }, 80 + "config": { 81 + "oauth": { 82 + "scope": "atproto repo:xyz.statusphere.status?action=create&action=delete" 83 + } 84 + } 85 + } 86 + ``` 87 + 88 + | Field | Type | Description | 89 + |-------|------|-------------| 90 + | `description` | string | Shown in help text / template listing | 91 + | `svelte` | boolean | Auto-enable SvelteKit scaffold | 92 + | `dependencies` | object | Merged into package.json dependencies | 93 + | `devDependencies` | object | Merged into package.json devDependencies | 94 + | `config` | object | Deep-merged into generated config.yaml | 95 + 96 + ### Files to Modify 97 + 98 + - **`packages/appview/src/cli.ts`** — add `--template` flag parsing, template discovery, manifest loading, config merging, dependency merging, file copying 99 + - **`packages/appview/templates/statusphere/`** — new directory with template files copied from exercise 10 (with NSIDs kept as `xyz.statusphere.*`, imports updated to use `hatk/` package names and `hatk.generated.ts`) 100 + 101 + ### Key Decisions 102 + 103 + - Templates are plain file copies — no string interpolation or parameterization 104 + - `xyz.statusphere.*` NSIDs are kept as-is (they're the app's domain, not the framework's) 105 + - Template's config is deep-merged, not replaced — base config (relay, plc, port, database) stays intact 106 + - If a template includes `src/`, it implies Svelte — the manifest's `"svelte": true` ensures the scaffold generates SvelteKit config files before the template's frontend files are copied on top 107 + 108 + ## Verification 109 + 110 + 1. Run `hatk new test-app` — should produce bare scaffold as before (no regression) 111 + 2. Run `hatk new test-app --template statusphere` — should produce a working Statusphere app 112 + 3. `cd test-app && npm install && hatk dev` — app should start and be functional 113 + 4. Run `hatk test` — tests should pass
+36 -6
packages/appview/src/cli.ts
··· 1 1 #!/usr/bin/env node 2 - import { mkdirSync, writeFileSync, existsSync, unlinkSync, readdirSync } from 'node:fs' 2 + import { mkdirSync, writeFileSync, existsSync, unlinkSync, readdirSync, readFileSync } from 'node:fs' 3 3 import { resolve, join } from 'node:path' 4 4 import { execSync } from 'node:child_process' 5 5 import { loadLexicons } from './schema.ts' ··· 14 14 try { 15 15 const res = await fetch('http://localhost:2583/xrpc/_health') 16 16 if (res.ok) return 17 - } catch {} 17 + } catch { } 18 18 // Start it 19 19 console.log('[dev] starting PDS...') 20 20 execSync('docker compose up -d', { stdio: 'inherit', cwd: process.cwd() }) ··· 23 23 try { 24 24 const res = await fetch('http://localhost:2583/xrpc/_health') 25 25 if (res.ok) { console.log('[dev] PDS ready'); return } 26 - } catch {} 26 + } catch { } 27 27 await new Promise((r) => setTimeout(r, 1000)) 28 28 } 29 29 console.error('[dev] PDS failed to start') ··· 41 41 Usage: hatk <command> [options] 42 42 43 43 Getting Started 44 - new <name> [--svelte] Create a new hatk project 44 + new <name> [--svelte] [--template <t>] Create a new hatk project 45 45 46 46 Running 47 47 start Start the hatk server ··· 307 307 if (command === 'new') { 308 308 const name = args[1] 309 309 if (!name) { 310 - console.error('Usage: hatk new <name> [--svelte]') 310 + console.error('Usage: hatk new <name> [--svelte] [--template <template-name>]') 311 + process.exit(1) 312 + } 313 + 314 + const templateIdx = args.indexOf('--template') 315 + const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null 316 + if (templateIdx !== -1 && !templateName) { 317 + console.error('Usage: hatk new <name> --template <template-name>') 311 318 process.exit(1) 312 319 } 313 320 314 - const withSvelte = args.includes('--svelte') 315 321 const dir = resolve(name) 316 322 if (existsSync(dir)) { 317 323 console.error(`Directory ${name} already exists`) 318 324 process.exit(1) 319 325 } 320 326 327 + if (templateName) { 328 + const repo = `https://github.com/hatk-dev/hatk-template-${templateName}.git` 329 + console.log(`Cloning template ${templateName}...`) 330 + try { 331 + execSync(`git clone --depth 1 ${repo} ${dir}`, { stdio: 'inherit' }) 332 + } catch { 333 + console.error(`Failed to clone template: ${repo}`) 334 + process.exit(1) 335 + } 336 + execSync(`rm -rf ${join(dir, '.git')}`) 337 + const pkgPath = join(dir, 'package.json') 338 + if (existsSync(pkgPath)) { 339 + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) 340 + pkg.name = name 341 + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n') 342 + } 343 + console.log(`\nCreated ${name}/ from template ${templateName}`) 344 + console.log(`\n cd ${name}`) 345 + console.log(` npm install`) 346 + console.log(` hatk dev`) 347 + process.exit(0) 348 + } 349 + 350 + const withSvelte = args.includes('--svelte') 321 351 mkdirSync(dir) 322 352 const subs = ['lexicons', 'feeds', 'xrpc', 'og', 'labels', 'jobs', 'seeds', 'setup', 'public', 'test', 'test/feeds', 'test/xrpc', 'test/integration', 'test/browser', 'test/fixtures'] 323 353 if (withSvelte) subs.push('src', 'src/routes', 'src/lib')