~ knotless Search/
↑↓ move↵ open⇧↵ all results

peterullrich.com/hatk

AT Protocol app framework

Forked from chadtmiller.com/hatk

0
0
main
546 lines · 17.9 KB Raw History

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

FileActionPurpose
packages/appview/src/cli.tsModifyAdd --template flag parsing, template loading, overlay logic
packages/appview/templates/statusphere/template.jsonCreateTemplate manifest
packages/appview/templates/statusphere/lexicons/CreateCustom lexicon schemas
packages/appview/templates/statusphere/feeds/recent.tsCreateFeed generator
packages/appview/templates/statusphere/xrpc/xyz/statusphere/getProfile.tsCreateXRPC handler
packages/appview/templates/statusphere/seeds/seed.tsCreateSeed data
packages/appview/templates/statusphere/test/CreateTests + fixtures
packages/appview/templates/statusphere/src/CreateSvelteKit 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:

  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:

  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:

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:

  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
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:

  // 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):

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
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

{
  "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

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'

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'

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'

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
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'

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

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

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
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
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

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
rm -rf /tmp/test-bare /tmp/test-statusphere /tmp/test-bad