第 1 章: ゼロから出荷できるアプリへ
この章では Guren アプリを雛形生成し、雛形が何を用意してくれたのかを読み、テストを先に置いた上で変更をひとつ手で加え、変更をひとつコーディングエージェントに委ねてハーネスがその仕事を検査する様子を見届け、最後にどこでも動かせるコンテナイメージを手にします。以降のすべての章も同じ形で終わります。ゲートが緑で、コミット済みで、出荷できる状態です。
第 1 章: ゼロから出荷できるアプリへ
この章では Guren アプリを雛形生成し、雛形が何を用意してくれたのかを読み、テストを先に置いた上で変更をひとつ手で加え、変更をひとつコーディングエージェントに委ねてハーネスがその仕事を検査する様子を見届け、最後にどこでも動かせるコンテナイメージを手にします。以降のすべての章も同じ形で終わります。ゲートが緑で、コミット済みで、出荷できる状態です。
この章だけは 4 拍子 に従いません。準備の章なので、まだ手で組むものがありません。代わりに、以降のすべての章が前提にする道具を覚えます。テストランナー、guren gate、そしてエージェントハーネスです。
この章で学ぶこと:
- すべての選択をあらかじめ指定してアプリを雛形生成し、本文と同じアプリにする方法
- 新規アプリに何が同梱されているか: テスト、CI ワークフロー、エージェントハーネス
bunx guren gateが何を実行するのか、そしてそれがなぜ CI と同じコマンドなのか.claude/settings.jsonの 3 つの hook がguren checkとguren 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_KEY と DATABASE_URL=./data/guren.db を持つ .env を書き、依存関係をインストールします。アプリに入りましょう。
cd guren-blog
2. 動かす
bun run dev
チェックポイント: http://localhost:3333 を開きます。「Welcome to Guren Blog!」と見出しの付いたウェルカムページと、その下に 6 枚の機能カードが見えるはずです。

dev スクリプトは 3 つのことをします。.guren/ 配下の型付きマニフェストを再生成し(bun run codegen)、GUREN_MCP=1 と GUREN_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(型付きマニフェスト)、typecheck、lint、check、audit、test です。うち 2 つは Guren 固有のものです。
guren checkは動いているアプリではなくコードを読み、すべてのルートが実在するコントローラーメソッドを指しているか、すべてのpages.Xが実在するページファイルを指しているか、各ページの props がコントローラーの送るものと一致しているか、その他、放っておけば実行時に落ちる十数項目を検証します。guren auditは静的なセキュリティレビューです。バリデーションや認証の無い変更系ルート、生 SQL、ソース内のシークレット、マスアサインメント。新規アプリでは何も言いません。
このコースはこれらのステージのひとつの性質に寄りかかっています。コードを書いたのが人間でもエージェントでも、ステージは同じだということです。それが次の節を可能にしています。
5. ハーネス
--agents claude は CLAUDE.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.tsがguren checkを実行し、指摘をそのままエージェントに返します。修正は同じターンの中で起こります。Stopは、エージェントが未コミットの変更を残したままターンを終えようとしたときに走ります。.claude/hooks/gate-on-stop.tsがguren gateを実行し、どれかのステージが失敗すれば停止は一度ブロックされ、指摘が返ってきます。ゲートが赤いうちは、エージェントは変更を完了と宣言できません。
エージェントに見えているものを見てみましょう。
bunx guren context
.claude/ の残りは、開始時ではなく必要になったときに読まれます。
rules/には領域ごとに検証済みの API ルールがあります(orm-models.md、controllers-http.md、routes-codegen.md、testing.md、docs-and-spec.md、comments.md)。それぞれが適用対象のファイル glob を宣言しているので、エージェントはルートを編集するときにroutes-codegen.mdを読み込み、それまでは読みません。skills/はエージェントが求めに応じて従う手順です。scaffold(ファイルを手打ちせずbunx guren make:*に手を伸ばす)、feature、db-manage、guren-api、agent-interface、plugin-authoring、dev-workflow。agents/は独自の brief を持つ 2 つの subagent、code-reviewとtest-writerです。.mcp.jsonはdevスクリプトがマウントした開発用 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 を置き換えます。変更点は Props の tagline フィールドと、それをレンダリングする段落の 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 gate は typecheck で止まっていました。コントローラーの呼び出しは 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 contextreport, which hook runs when you editroutes/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
HomeControllerintolang/en/messages.jsonasmessages.tagline, and read it throughthis.t()like the welcome message. Keep the tests unchanged and green.
トランスクリプトで 2 つのことを見守ってください。
- エージェントが
HomeController.tsを編集すると、PostToolUsehook がguren checkを実行して報告します。きれいな編集なら何も言いません。エージェントがキーを打ち間違えていれば、checkの指摘がエージェントの次の一手より先に届きます。 - エージェントが終えようとすると、
Stophook が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.jsonにtaglineキーがある。それ以外は変わっていない。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=production で bun 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 checkとguren gateの指摘を、あなたが見るより先にエージェントへ返すハーネス。- Dockerfile。
よくあるつまずき
bunx create-guren-appが質問してきた。 4 つのフラグのどれかが抜けているか綴りが違います。上のコマンドはすべて指定しています。非対話シェルで--gitを省くとリポジトリは作られず、この章のコミットが「not a git repository」で失敗します。git commitが「Please tell me who you are」で失敗する。git config user.nameとgit config user.emailを一度設定して、コミットをやり直してください。- 手順 6 で何も変えていないのに
bun testが通る。 赤のステップを走らせる前にHome.tsxを置き換えています。順序が大事です。テストが先、失敗を見届けて、それから変更です。 - エージェントの
Stophook が走らなかった。 走るのはツリーに未コミットの変更があるときだけです。ターンを終える前にコミットするエージェントは hook にゲートされません。だからこの章では、コミットの前に自分でbunx guren gateを実行しています。 - ポート 3333 が使用中。 開発サーバーは次の空きポートへ進み、実際に束縛したポートを表示します。決めつけずに起動時の表示を読んでください。
演習
以下はどれも後の章が必要とするものではありません。ファイルを変える演習はブランチ (git switch -c exercise/…) で行い、終わったらブランチを削除してください。次の章がこの章の残した状態から始まるようにするためです。
- 雛形が書いた workflow は
bunx guren gate --depsを走らせます。あなたが走らせてきたのはbunx guren gateです。長いほうを走らせてください。--depsは何を足していますか。ネットワークの無いマシンではどうなりますか。 bunx guren doctor --nextを走らせてください。報告されたもののうちひとつを選び、それを直すと何が得られるかを説明してください。まだ作っていない本番アプリ向けの提案も混ざっています。どれがそうかも答えてください。
次へ
第 2 章: リクエストをひとつ、手で では、空のファイルからルート、コントローラー、ページをテストを先に置いて組み上げ、2 つ目のページをエージェントに委ねます。