Tutorial/tutorials

第 1 章: ゼロから出荷できるアプリへ

この章では Guren アプリを雛形生成し、雛形が何を用意してくれたのかを読み、テストを先に置いた上で変更をひとつ手で加え、変更をひとつコーディングエージェントに委ねてハーネスがその仕事を検査する様子を見届け、最後にどこでも動かせるコンテナイメージを手にします。以降のすべての章も同じ形で終わります。ゲートが緑で、コミット済みで、出荷できる状態です。

第 1 章: ゼロから出荷できるアプリへ

この章では Guren アプリを雛形生成し、雛形が何を用意してくれたのかを読み、テストを先に置いた上で変更をひとつ手で加え、変更をひとつコーディングエージェントに委ねてハーネスがその仕事を検査する様子を見届け、最後にどこでも動かせるコンテナイメージを手にします。以降のすべての章も同じ形で終わります。ゲートが緑で、コミット済みで、出荷できる状態です。

この章だけは 4 拍子 に従いません。準備の章なので、まだ手で組むものがありません。代わりに、以降のすべての章が前提にする道具を覚えます。テストランナー、guren gate、そしてエージェントハーネスです。

この章で学ぶこと:

  • すべての選択をあらかじめ指定してアプリを雛形生成し、本文と同じアプリにする方法
  • 新規アプリに何が同梱されているか: テスト、CI ワークフロー、エージェントハーネス
  • bunx guren gate が何を実行するのか、そしてそれがなぜ CI と同じコマンドなのか
  • .claude/settings.json の 3 つの hook が guren checkguren gate の結果をどうエージェントに返すのか
  • 変更の前に失敗するテストを書き、それを通す方法
  • guren deploy でアプリをコンテナイメージにする方法

1. アプリを雛形生成する

対話的に実行すると、雛形生成ツールは 4 つの質問をします。代わりにコマンドラインで答えてしまい、このコースが説明するアプリと同じものにしましょう。

bunx create-guren-app guren-blog --mode ssr --db sqlite --agents claude --git
  • --mode ssr はページをまずサーバー側でレンダリングします。もうひとつの spa は空の殻を送ってブラウザ側でレンダリングします。
  • --db sqlite はデータベースサーバーを必要としません。ファイルは最初に開かれたときに ./data/ 配下に作られます。第 14 章で同じアプリを Postgres に載せ替えます。
  • --agents claude は Claude Code 向けのエージェントハーネスを導入します。--agents all なら Claude Code、Codex、Cursor、Copilot、OpenCode 向けを一度に導入し、none なら省略します。このコースの内容はどのエージェントでも成り立ちます。それを成り立たせているのがハーネスです。
  • --git はリポジトリを初期化して最初のコミットを作ります。これで各章をコミットで締められます。

雛形生成ツールはテンプレートをコピーし、生成した APP_KEYDATABASE_URL=./data/guren.db を持つ .env を書き、依存関係をインストールします。アプリに入りましょう。

cd guren-blog

2. 動かす

bun run dev

チェックポイント: http://localhost:3333 を開きます。「Welcome to Guren Blog!」と見出しの付いたウェルカムページと、その下に 6 枚の機能カードが見えるはずです。

雛形のウェルカムページ。見出し「Welcome to Guren Blog!」の上に「Powered by Bun + Hono」、タグラインは「A blog, built the Guren way」。Documentation と GitHub のボタン、そしてルーティング、ORM、Inertia、認証、キューとメール、SQLite を挙げた 6 枚のカード。

dev スクリプトは 3 つのことをします。.guren/ 配下の型付きマニフェストを再生成し(bun run codegen)、GUREN_MCP=1GUREN_DOCS=1 を付けてサーバーを起動します。この 2 つのフラグは、開発時専用の MCP エンドポイントを /_guren/mcp に、Docs Graph ビューアを /_guren/docs にマウントします。第 8 章で前者にエージェントを接続し、第 13 章で後者を埋めます。どちらも本番には存在しません。

このターミナルでは開発サーバーを動かしたままにしてください。以降のコマンドはすべて、guren-blog の中で、別のターミナルから実行します。

3. 用意されたものを読む

新規アプリは一度に読み切れる大きさです。この章で触れるファイルは次のとおりです。

guren-blog/
├── app/Http/Controllers/HomeController.ts   # 唯一のコントローラー
├── resources/js/pages/Home.tsx              # 唯一のページ
├── routes/web.ts                            # 2 本のルート
├── lang/en/messages.json                    # 翻訳カタログ
├── tests/HomeController.test.ts             # 唯一のテスト
├── .github/workflows/ci.yml                 # CI: ゲートひとつ
├── CLAUDE.md                                # エージェントが最初に読むもの
├── .claude/                                 # rules、skills、agents、hooks
└── .mcp.json                                # 開発用 MCP エンドポイント

リクエストの経路

routes/web.ts は 2 つの URL を対応付けます。ひとつ目はコントローラーのメソッドを指名し、ふたつ目はインラインで書いたハンドラーです。一行で済むものならこれで構いませんが、それより大きいものには向きません。

import { Router } from '@guren/core'
import HomeController from '../app/Http/Controllers/HomeController.js'

export function registerWebRoutes(router: Router): void {
  router.get('/', [HomeController, 'index'])

  // Health check endpoint for load balancers and uptime monitors
  router.get('/health', (c) => c.json({ status: 'ok' }))
}

HomeController.index はページの props を組み立ててページをレンダリングします。pages.Home は文字列ではありません。resources/js/pages/ 配下のファイルから生成された型付きの参照で、受け取れる props はページコンポーネントの Props インターフェースそのものです。ページが宣言していない prop を渡したり、必須の prop を落としたりすると、bun run typecheck が失敗します。

import { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'

export default class HomeController extends Controller {
  async index(): Promise<Response> {
    const props = {
      // Message text lives in lang/en/messages.json (key typed by codegen).
      message: this.t('messages.welcome', { name: 'Guren Blog' }),
    }

    return this.inertia(pages.Home, props, { title: 'Guren Blog' })
  }
}

this.t()lang/en/messages.json を読み、そのキーも型付きです。messages.welcome は存在し、messages.hello はコンパイルが通りません。Guren が実行時の間違いをコンパイルエラーに変える、数多くの場所の最初のひとつです。

テスト

tests/HomeController.test.ts は本物の src/app.ts を起動し、ポートもブラウザも使わずにリクエストを投げます。

import { beforeAll, describe, it } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'

// Boots the real src/app.ts so tests share its configuration.
describe('app', () => {
  let http: TestApp

  beforeAll(async () => {
    http = await TestApp.fromApp(app)
  })

  it('serves the translated home page', async () => {
    const response = await http.get('/').assertOk()
    await response.assertBodyContains('Welcome to')
  })

  it('answers the health check', async () => {
    await http.get('/health').assertOk()
  })
})

実行します。

bun test

テストは 2 件、どちらも緑です。第 2 章からは、このようなテストをコードより先に書きます。

CI ワークフロー

.github/workflows/ci.yml で重要なステップはひとつだけです。

      - name: Gate
        run: bunx guren gate --deps

これが CI のすべてです。CI が検査するものは、同じコマンドで手元でも実行できます。

4. ゲート

bunx guren gate

gate は 6 つのステージを順に実行し、最初の失敗で止まります。codegen(型付きマニフェスト)、typechecklintcheckaudittest です。うち 2 つは Guren 固有のものです。

  • guren check は動いているアプリではなくコードを読み、すべてのルートが実在するコントローラーメソッドを指しているか、すべての pages.X が実在するページファイルを指しているか、各ページの props がコントローラーの送るものと一致しているか、その他、放っておけば実行時に落ちる十数項目を検証します。
  • guren audit は静的なセキュリティレビューです。バリデーションや認証の無い変更系ルート、生 SQL、ソース内のシークレット、マスアサインメント。新規アプリでは何も言いません。

このコースはこれらのステージのひとつの性質に寄りかかっています。コードを書いたのが人間でもエージェントでも、ステージは同じだということです。それが次の節を可能にしています。

5. ハーネス

--agents claudeCLAUDE.md.claude/.mcp.json を書きました。これらをまとめてエージェントハーネスと呼びます。エージェントが書く前に読むもの、編集した後に走るもの、停止を許される前に走るものです。

.claude/settings.json を開いてください。重要なのは 3 つの hook です。

{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "bunx guren context 2>/dev/null || true" }] }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [{ "type": "command", "command": "bun .claude/hooks/check-after-edit.ts" }]
      }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "bun .claude/hooks/gate-on-stop.ts", "timeout": 300 }] }
    ]
  }
}
  • SessionStart は、エージェントの最初のターンの前に bunx guren context の出力をコンテキストへ注入します。すべてのモデル、ルート、コントローラー、ページの地図で、末尾にはフレームワークの API シグネチャのダイジェストが付きます。エージェントは node_modules を読むことなく、プロジェクトが何かを知った状態で始まります。
  • PostToolUse はファイル編集のたびに走ります。そのファイルがルート、コントローラー、モデル、スキーマ、ページのいずれかなら、.claude/hooks/check-after-edit.tsguren check を実行し、指摘をそのままエージェントに返します。修正は同じターンの中で起こります。
  • Stop は、エージェントが未コミットの変更を残したままターンを終えようとしたときに走ります。.claude/hooks/gate-on-stop.tsguren gate を実行し、どれかのステージが失敗すれば停止は一度ブロックされ、指摘が返ってきます。ゲートが赤いうちは、エージェントは変更を完了と宣言できません。

エージェントに見えているものを見てみましょう。

bunx guren context

.claude/ の残りは、開始時ではなく必要になったときに読まれます。

  • rules/ には領域ごとに検証済みの API ルールがあります(orm-models.mdcontrollers-http.mdroutes-codegen.mdtesting.mddocs-and-spec.mdcomments.md)。それぞれが適用対象のファイル glob を宣言しているので、エージェントはルートを編集するときに routes-codegen.md を読み込み、それまでは読みません。
  • skills/ はエージェントが求めに応じて従う手順です。scaffold(ファイルを手打ちせず bunx guren make:* に手を伸ばす)、featuredb-manageguren-apiagent-interfaceplugin-authoringdev-workflow
  • agents/ は独自の brief を持つ 2 つの subagent、code-reviewtest-writer です。
  • .mcp.jsondev スクリプトがマウントした開発用 MCP エンドポイントをエージェントに指し示し、動いているアプリに問い合わせられるようにします。

以降の各章でこれらをひとつずつ使い、第 8 章では自分で書きます。今は、この後で見守ることになる 2 つの hook に注目してください。

6. 最初の変更を、手で

ホームページにタグラインを付けます。以降のすべての章と同じ順序で進めます。まずテスト、それから変更です。

タグラインも期待するようにテストファイルを置き換えます。

tests/HomeController.test.tsimport { beforeAll, describe, it } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'

// Boots the real src/app.ts so tests share its configuration.
describe('app', () => {
  let http: TestApp

  beforeAll(async () => {
    http = await TestApp.fromApp(app)
  })

  it('serves the translated home page', async () => {
    const response = await http.get('/').assertOk()
    await response.assertBodyContains('Welcome to')
  })

  it('shows the tagline', async () => {
    const response = await http.get('/').assertOk()
    await response.assertBodyContains('A blog, built the Guren way')
  })

  it('answers the health check', async () => {
    await http.get('/health').assertOk()
  })
})

実行して、失敗するのを見届けてください。これは意図的です。一度も失敗したことのないテストは、何も証明していません。

bun test

では通しましょう。タグラインはウェルカムメッセージと同じく prop です。コントローラーが送り、ページが Props で宣言してレンダリングします。app/Http/Controllers/HomeController.ts を置き換えます。

app/Http/Controllers/HomeController.tsimport { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'

export default class HomeController extends Controller {
  async index(): Promise<Response> {
    const props = {
      // Message text lives in lang/en/messages.json (key typed by codegen).
      message: this.t('messages.welcome', { name: 'Guren Blog' }),
      tagline: 'A blog, built the Guren way',
    }

    return this.inertia(pages.Home, props, { title: 'Guren Blog' })
  }
}

そして resources/js/pages/Home.tsx を置き換えます。変更点は Propstagline フィールドと、それをレンダリングする段落の 2 か所で、残りは雛形のページそのままです。

resources/js/pages/Home.tsximport { Head } from '@inertiajs/react'
interface Props {
  message: string
  tagline: string
}

const features = [
  { title: 'Routing & Controllers', desc: 'Laravel-style MVC with type-safe route helpers' },
  { title: 'Eloquent-style ORM', desc: 'Drizzle-powered models with relations, scopes, and soft deletes' },
  { title: 'Inertia + React', desc: 'SPA-like UX without maintaining a separate frontend' },
  { title: 'Auth & Sessions', desc: 'Built-in authentication with guards, policies, and API tokens' },
  { title: 'Queue & Mail', desc: 'Background jobs, email sending, and event broadcasting' },
  { title: 'Zero-config SQLite', desc: 'No Docker needed — just bun install && bun run dev' },
]

export default function Home({ message, tagline }: Props) {
  return (
    <>
      <Head title="Guren Blog" />
      <main className="min-h-screen bg-g-page font-sans text-g-text">
        <div className="mx-auto max-w-3xl px-6 py-20">
          <p className="mb-5 font-mono text-xs tracking-[0.18em] uppercase text-g-text-2">
            Powered by Bun + Hono
          </p>
          <h1 className="mb-4 flex items-center gap-4 text-5xl font-bold tracking-tight text-g-heading">
            <span aria-hidden className="h-10 w-[3px] shrink-0 rounded-full bg-[image:var(--g-tick)]" />
            {message}
          </h1>
          <p className="mb-8 text-lg text-g-text-2">{tagline}</p>

          <div className="mb-12 flex flex-wrap gap-3">
            <a
              href="https://guren.dev/docs"
              className="inline-flex items-center rounded-g-ctl bg-g-accent px-4 py-2 text-sm font-bold text-g-on-accent transition hover:bg-g-accent-down"
            >
              Documentation
            </a>
            <a
              href="https://github.com/gurenjs/guren"
              className="inline-flex items-center rounded-g-ctl border border-g-line-strong bg-g-panel px-4 py-2 text-sm font-bold text-g-text transition hover:border-g-muted"
            >
              GitHub
            </a>
          </div>

          <div className="grid gap-4 sm:grid-cols-2">
            {features.map((f) => (
              <div
                key={f.title}
                className="rounded-g-card border border-g-line bg-g-panel p-5 shadow-g-card"
              >
                <h3 className="mb-1 font-bold text-g-heading">{f.title}</h3>
                <p className="text-sm text-g-text-2">{f.desc}</p>
              </div>
            ))}
          </div>

          <div className="mt-12 rounded-g-card bg-g-ink p-6">
            <h2 className="mb-3 font-mono text-xs tracking-[0.18em] uppercase text-g-on-ink-muted">
              Next steps
            </h2>
            <div className="space-y-2 font-mono text-sm text-g-on-ink">
              <p><span className="text-g-on-ink-muted">$</span> bunx guren add auth</p>
              <p><span className="text-g-on-ink-muted">$</span> bunx guren add resource posts</p>
              <p><span className="text-g-on-ink-muted">$</span> bunx guren make:model Post</p>
            </div>
          </div>
        </div>
      </main>
    </>
  )
}
bun test

テストは 3 件、緑です。ブラウザをリロードすると、タグラインが表示されています。もし tagline をコントローラーに足してページに足し忘れていたら、あるいはその逆なら、bunx guren gatetypecheck で止まっていました。コントローラーの呼び出しは Props インターフェースに対して検査されるからです。変更全体が成り立っていることをゲートで確かめ、コミットします。

bunx guren gate
git add -A
git commit -m "feat: add a tagline to the home page"

7. 変更をエージェントに委ねる

今度は同じ種類の変更をエージェントにやらせ、hook の動きを見守ります。guren-blog の中でエージェントを起動してください(Claude Code なら claude)。SessionStart hook のおかげで、最初のメッセージには手順 5 で表示したプロジェクトの地図がすでに載っています。こう尋ねます。

Explain this project: what does bunx guren context report, which hook runs when you edit routes/web.ts, and which one runs when you end a turn with uncommitted changes?

答えを .claude/settings.json と照らして読んでください。3 つの hook すべてと、それぞれが実行するものを挙げているはずです。guren gate に触れていなければ、CLAUDE.md を読んでいません。仕事を任せる前に、自分のエージェントについて知っておく価値のあることです。

次に仕事を任せます。

Move the tagline text out of HomeController into lang/en/messages.json as messages.tagline, and read it through this.t() like the welcome message. Keep the tests unchanged and green.

トランスクリプトで 2 つのことを見守ってください。

  1. エージェントが HomeController.ts を編集すると、PostToolUse hook が guren check を実行して報告します。きれいな編集なら何も言いません。エージェントがキーを打ち間違えていれば、check の指摘がエージェントの次の一手より先に届きます。
  2. エージェントが終えようとすると、Stop hook が guren gate を実行します。codegen が型付き翻訳キーを再生成し、typecheck が messages.tagline の存在を確かめ、テストが走ります。すべてのステージが緑になって初めてターンが終わります。

手元にエージェントが無い場合は、 同じ変更を手で加えてください。対象は 2 ファイルです。

lang/en/messages.json{
  "welcome": "Welcome to :name!",
  "tagline": "A blog, built the Guren way"
}
app/Http/Controllers/HomeController.tsimport { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'

export default class HomeController extends Controller {
  async index(): Promise<Response> {
    const props = {
      // Message text lives in lang/en/messages.json (keys typed by codegen).
      message: this.t('messages.welcome', { name: 'Guren Blog' }),
      tagline: this.t('messages.tagline'),
    }

    return this.inertia(pages.Home, props, { title: 'Guren Blog' })
  }
}

どちらの場合も、受け入れる前に結果をレビューしてください。これが以降のすべての章でエージェントの出力に対して示される rubric で、最初のものは短いです。

  • HomeController.ts はタグラインを this.t('messages.tagline') で読み、英語の文言を含んでいない。
  • lang/en/messages.jsontagline キーがある。それ以外は変わっていない。
  • tests/HomeController.test.ts は手つかずで緑。
  • bunx guren gate が緑。
bunx guren gate
git add -A
git commit -m "refactor: read the tagline from the translation catalog"

これで、このコースの残りが交互に繰り返す 2 つのことを両方やりました。テストを先に置いて自分で書いた変更と、仕様化して委ね、検証した変更です。

8. 出荷する

Guren は本番用の Dockerfile を書いてくれます。

bunx guren deploy --target docker

書き出された Dockerfile を開いてください。2 段階のビルドです。第 1 段階ですべてをインストールして bun run build を実行し、第 2 段階では実行時に必要なディレクトリ(bin/src/app/config/routes/public/db/.guren/)だけをスリムなイメージにコピーして、NODE_ENV=productionbun bin/serve.ts を起動します。Docker が入っていれば、イメージをビルドして動かしてみましょう。

docker build -t guren-blog .
docker run --rm -p 3333:3333 --env-file .env guren-blog

もう一度 http://localhost:3333 を開きます。同じページですが、今度はコンテナの中で動くアプリの本番ビルドが返しています。誰のマシンでも同じように動くものです。Ctrl-C で止めてください。注意点が 2 つあり、どちらも第 14 章で解決します。コンテナは開発用の .env を読んでいること、そして SQLite ファイルがコンテナの中にあるので、止めるとすべて忘れることです。

レシピをコミットします。

git add -A
git commit -m "chore: add the Docker recipe"

どこでホストするかはあなたの選択で、コースはそれに依存しません。bunx guren deploy --target fly--target railway は、同じ Dockerfile の隣にそれぞれのプラットフォームが求める追加設定を書きます。コンテナイメージが動くホスト(Render、Koyeb、Docker の入った VPS)なら Dockerfile だけで動きます。第 14 章で、Postgres、データベースバックのセッション、その前に立つ CI ゲートを備えた本物のデプロイを一通り行います。

いまいる場所

  • SSR と SQLite で動く Guren アプリ。git 管理下で、自分のコミットが 3 つ。
  • 赤と緑の両方を見たテストスイート。
  • CI が実行するゲート、そしてそれが自分の実行するものと同じだという知識。
  • guren checkguren gate の指摘を、あなたが見るより先にエージェントへ返すハーネス。
  • Dockerfile。

よくあるつまずき

  • bunx create-guren-app が質問してきた。 4 つのフラグのどれかが抜けているか綴りが違います。上のコマンドはすべて指定しています。非対話シェルで --git を省くとリポジトリは作られず、この章のコミットが「not a git repository」で失敗します。
  • git commit が「Please tell me who you are」で失敗する。 git config user.namegit config user.email を一度設定して、コミットをやり直してください。
  • 手順 6 で何も変えていないのに bun test が通る。 赤のステップを走らせる前に Home.tsx を置き換えています。順序が大事です。テストが先、失敗を見届けて、それから変更です。
  • エージェントの Stop hook が走らなかった。 走るのはツリーに未コミットの変更があるときだけです。ターンを終える前にコミットするエージェントは hook にゲートされません。だからこの章では、コミットの前に自分で bunx guren gate を実行しています。
  • ポート 3333 が使用中。 開発サーバーは次の空きポートへ進み、実際に束縛したポートを表示します。決めつけずに起動時の表示を読んでください。

演習

以下はどれも後の章が必要とするものではありません。ファイルを変える演習はブランチ (git switch -c exercise/…) で行い、終わったらブランチを削除してください。次の章がこの章の残した状態から始まるようにするためです。

  1. 雛形が書いた workflow は bunx guren gate --deps を走らせます。あなたが走らせてきたのは bunx guren gate です。長いほうを走らせてください。--deps は何を足していますか。ネットワークの無いマシンではどうなりますか。
  2. bunx guren doctor --next を走らせてください。報告されたもののうちひとつを選び、それを直すと何が得られるかを説明してください。まだ作っていない本番アプリ向けの提案も混ざっています。どれがそうかも答えてください。

次へ

第 2 章: リクエストをひとつ、手で では、空のファイルからルート、コントローラー、ページをテストを先に置いて組み上げ、2 つ目のページをエージェントに委ねます。