コンテンツにスキップ

開発環境

  • Node.js 24 (.nvmrc のバージョン)。nvm があれば nvm use。
  • pnpm 11 — packageManager が固定している版。corepack enable で入る。
  • Apple Container と container-compose (どちらも Homebrew)— スタックに必要。ホストだけの開発には不要。macOS 専用です。
  • curl と git が PATH にあること。
  • DATABASE_URL で届く Postgres。Compose スタックが立ち上げます。
  • 実際に取り込むには BrowserHive と、それを回す Windmill の flow。capture-ledger は もう BrowserHive の在り処を持たず、CAPTURE_LEDGER_CRAWL_WEBHOOK_URL へ投げるだけです。 flow は capture-scheduler に居ます。スタックが今も BrowserHive を build するのは flow が要るからで、固定バージョンは BrowserHive の更新を参照。
ターミナルウィンドウ
git clone https://github.com/<you>/capture-ledger.git
cd capture-ledger
nvm use
pnpm install
sudo container system dns create capture-ledger # マシンごとに 1 回
git submodule update --init --recursive # 上流のソースを .upstream/ に
cp -n .env.example .env # 設定の雛形をそのまま写す
pnpm run check # push の前に走らせる検査の全部(中身は package.json の check)

スタックは container-compose を直接叩かず、pnpm run stack:up で起動してください。 起動の前に、道具と capture-ledger DNS ドメイン、すべての build context が指す .upstream/ の submodule、そして共有 store が起きているかを確かめ、足りなければ 名前を挙げて止まります。

成果物を置く store(SeaweedFS)は、このスタックには入っていません。crawler の 3 つの repo が 共有する 1 つの store(seaweedfs.crawler-storage)で、submodule から起こします:

ターミナルウィンドウ
sudo container system dns create crawler-storage # マシンごとに 1 度だけ
sh .upstream/seaweedfs/scripts/stack.sh up

中身を消す・見る・store ごと作り直す手順は、 seaweedfs の運用ガイド に 1 つだけ置いてあります(この repo の bucket を空にするだけなら pnpm run store:wipe)。

コードが読む環境変数は、読み取りの仕組みが 3 つに分かれています。 src/config/ の required()/optional()、commander の .env()(こちらは --help にも出ます)、そして素の process.env[…](これは scripts/ にもあります)。 数は pnpm run check-env が数えて出します。必須のものは src/config/env.ts の REQUIRED_ENV に並んでいます。

一覧は .env.example の 1 か所だけで、.env はその写しです(cp -n .env.example .env)。 値を調べて .env を作るものはありません —— 一覧が 2 つあれば必ずずれるからです。 OpenFGA の 2 つの ID は、走らせてみないと決まらないので雛形には名前しかありません (pnpm run fga:deploy が .env.local に書きます。 アーカイブ台帳を参照)。

scripts/check-env.mjs(pnpm run check に含まれ、CI では独立したステップ)が、 コードの読み取りと .env.example の宣言を両方向で突き合わせます。古い雛形は 雛形が無いより悪い —— 信用して使われるので、足りないときに疑う先が残りません。

実行系の script は .env と .env.local をこの順で node に渡します (--env-file-if-exists を 2 つ)。後に渡したほうが勝つので、同じ名前が両方に 在れば .env.local の値が効きます。

ファイル 持ち主 中身
.env 人 .env.example の写し。手で直すのはこちらだけ
.env.local 道具 走らせてみないと決まらない値。git は無視する(生成物)

.env.local に書く道具は 2 つです。pnpm run fga:deploy(OpenFGA の store id と model id)と、pnpm run connect(capture-scheduler が渡す 4 行 —— クイックスタート §7)。 分けてあるのは持ち主が違うからで、道具が人の書いた行を並べ替えたり消したりすると、 次に何が起きたのか追えなくなります。

どちらが勝っているかは、API の起動ログの 1 行目が言います:

config: .env (16 names), .env.local (6 names) — .env.local wins for CAPTURE_LEDGER_FGA_STORE_ID, … (a value set in the shell beats the files)

「.env を直したのに効かない」ときは、この行に名前が出ていないかを見ます。出ていれば .env.local が勝っています。古い .env.local を疑うなら 作り直すのがいちばん速い —— fga:deploy も connect も、何度打っても構いません。

この行が出すのは名前だけで、値は出しません。token が混じるうえ、出どころの説明に 値は要らないからです。端末で渡した値は、どちらのファイルにも勝ちます。

空文字は「値が無い」とは別の状態

Section titled “空文字は「値が無い」とは別の状態”

.env に FOO= と書くと FOO は空文字になります。行ごと無ければ未設定です。 この 2 つは別の状態で、POSIX はそれぞれに別の記法を与えています —— ${FOO:-default} は両方で既定値に落ち、${FOO-default} は未設定のときだけです。

この repo は前者に揃えてあります。空文字は「無い」と同じ意味です。env は optional()(または need())で読み、process.env[…] ?? default は使いません —— それは後者の意味で、空文字をそのまま値にしてしまいます。

そして「空で設定されている」はほぼ必ず打ち間違いなので、src/config/env.ts の 起動時検査は黙って既定値に戻さず、名前を挙げて止まります。これには理由があって、 空文字は「無い」より厳密に悪い状態でした —— DATABASE_URL= は commander の必須 チェックを通り、API は起動し、/healthz は 200 を返し、最初のクエリで DATABASE_URL という語を一度も出さない SASL エラーになっていました。

そのため .env.example の行は 3 種類しかありません。

ターミナルウィンドウ
NAME=value # 値を渡す
#NAME=value # 既定値を見せるだけ(使うならコメントを外して値を書く)
#NAME= # 起動前には値が決まらないもの(道具が .env.local に書く)

生の NAME= を書けるのは必須のもの(REQUIRED_ENV)だけです。必須の空は collectEnv が 「不足」として報告します。この規則も check-env.mjs が検査しています。

コマンド 内容
pnpm run dev:up 立ち上げの段を順に。--dry-run で一覧、--from <段> で途中から。
pnpm run dev:status いま何が立っているか。何も変えない。
pnpm run dev:down ホストで動かしたもの → 両 repo のコンテナ。共有 store は落とさない。
pnpm run api ビルドしてから API を実行 (tsc → node dist/api/server.js)。
pnpm run build tsconfig.build.json で dist/ に JS/d.ts を出力。
pnpm run typecheck tsc --noEmit。テストと *.config.ts も含む。
pnpm run lint / lint:fix ESLint flat config (typescript-eslint recommendedTypeChecked)。
pnpm run format / format:check Prettier。.prettierignore が dist/ と src/rpc/generated/ を除外。
pnpm test / test:watch test/ 配下の Vitest ユニットテスト。
pnpm run audit 既知の脆弱性 (pnpm-lock.yaml)。CI では毎日も走る。
pnpm run check push の前に走らせる検査の全部。中身と並びは package.json の check(CI の check job と同じ)。
pnpm run db:migrate / db:migrate:down DATABASE_URL に対する Kysely マイグレーション。
pnpm run db:seed / db:seed:down src/db/seeds/ の seed。
pnpm run targets 撮る対象(capture_targets)を足す・見る・外す・消す。DB に直接書く開発用の道具。
pnpm run scripts ページで走らせるスクリプト(scripts)を取り込む・足す・見る・読む・外す・消す。受け皿の型(host_types)も出す。DB に直接書く。
pnpm run openapi:generate 固定した submodule から BrowserHive の openapi.json を取り直し、型を再生成 (openapi-typescript)。
pnpm run openapi:check 型を再生成して git diff --exit-code (CI のドリフト検査)。
pnpm run openapi:sync 固定した submodule から openapi.json を取り直すだけ。
pnpm run site:dev / site:build このドキュメントサイト。
pnpm run site:check サイトをビルドし、参照の整合を検証。
pnpm run docs:shots docs に載せる画面の絵を撮り直す (スタックは要らない)。

スタックに入っていないものがあります。pnpm run oidc:issuer (9099)・ pnpm run api (7070)・wacz-validator の daemon (7180)・dashboard の pnpm run dev (7080) はホストのプロセスで、コンテナではありません。

検証の daemon には store の鍵を渡していません。読むのは台帳が署名した URL だけなので要らず、渡せば「鍵を持つ相手を増やさない」という選択の意味が消えます。 daemon の既定の port は 0(OS 任せ)なので、--port 7180 で固定してあります —— 固定しないと下の 2 つが見つけられません(どちらも port から持ち主を引くため)。

daemon は 自分で建て直りません。wacz-validator を出しても、7180 は起こした時の build のまま動き続けます(2026-09-26 に見たとき、v0.28.1 が 2 日動いていて、その間に 出た v0.29.0・v0.30.0 の rule はどれも dashboard に出ていませんでした)。pnpm run dev:status が、 動いている build(/healthz が名乗る commit)を wacz-validator の checkout の HEAD と 比べます。違えば、checkout が先に進んだのか(古い build)、それ以外か(違う build)を言い、 起こし直す 1 行を添えます。

dashboard が 7000 ではなく 7080 なのは、macOS の ControlCenter (AirPlay Receiver) が *:7000 を握っているためです(実測)。127.0.0.1:7000 に bind できてしまうので 気づきにくいのですが、port から持ち主を引く下の道具が毎回「別のものが握っている」と 言うことになります。

だから container-compose down では消えません。2026-09-20 に EADDRINUSE: address already in use 127.0.0.1:9099 で止まったのはこれで、前の日に 別のターミナルで起こした issuer が残っていました。スタックを落としたのに港が 塞がっている、という形で出ます。

ターミナルウィンドウ
pnpm run dev:status # pid・起動時刻・port。validator は動いている build も。何も変えない
pnpm run dev:down # ホストで動かしたもの → 両 repo のコンテナ

dev:down は port を持っている相手が自分のものか を先に確かめます —— コマンド行が この repo の entry を動かしていて、かつプロセスの cwd がこの repo の根であること。 違えば 止めずに名指しします(番号は使い回されるので、port から引いた PID を そのまま kill してはいけません)。

ターミナルウィンドウ
pnpm run stack:up
# 準備完了の判定は GET /status (BrowserHive の HTTP API)。
# browserhive-2 も同じ (localhost:50052)。
until curl -fsS http://localhost:50051/status >/dev/null 2>&1; do sleep 1; done

capture-ledger のコマンドは、この repo の端末でそのまま打ちます。 capture-ledger はホストで動き、 スタックが 127.0.0.1 に publish した口を通してコンテナ側に繋がります。 接続文字列は雛形に入っているので、写した .env にそのまま在ります。

ターミナルウィンドウ
DATABASE_URL=postgres://capture_ledger:capture_ledger@127.0.0.1:5432/capture_ledger

BrowserHive の在り処はもうここにありません。スタックが公開している 2 つの HTTP の 口 localhost:50051 と localhost:50052(Chromium 1 台に BrowserHive 1 つ)は、 curl と flow のためのもので、capture-ledger のためではありません。

pnpm run 系のコマンドはこの .env を自分で読みます (node --env-file-if-exists=.env)。シェルで export する必要はありません。 すでに環境にある変数のほうが優先されるので、一時的に別の DB を向きたいときは DATABASE_URL=... pnpm run db:migrate と前置きすれば効きます。node dist/... を 直接叩く場合は読まれないので、そのときは自分で渡してください。

Postgres は 127.0.0.1:5432 にも公開しているので localhost でも繋がります。

Docker Compose から来た場合、日常のコマンドはこう対応します。

Docker Compose このスタックでは
docker compose up -d --build pnpm run stack:up
docker compose down pnpm run stack:down
docker compose ps container ls
docker compose logs browserhive-1 container logs browserhive-1.capture-ledger
docker compose exec <svc> sh container exec -it <svc>.capture-ledger sh
docker compose run --rm <svc> … container run --rm <image> …

起動と停止だけ container-compose ではないのは、scripts/stack.sh が渡すものが 3 つあるから です —— 署名の profile と env ファイルの対、submodule の版(渡らないで建った image は BrowserHive が起動を拒みます)、そして起動の前の点検です。

Chromium は 2 台とも headless です。描画を見たいときは、ローカルの Chrome で chrome://inspect を開き、Configure… に localhost:9222 と localhost:9223 を登録してターゲットを inspect します。

本番イメージのスモークテスト

Section titled “本番イメージのスモークテスト”
ターミナルウィンドウ
./scripts/prod-smoke.sh

スタックを起動し、BrowserHive が 2 つとも GET /status に応答するまでポーリングし、 capture-ledger:latest をビルドしてから migrate → seed → API を container run --rm で 順に実行し、API に /healthz を訊き、EXIT トラップでスタックを片付け、 終了コードを自分の終了コードとして返します。

もう取り込みはしません。 capture-ledger は BrowserHive と話さないので、 このスクリプトが示すのは「イメージが起動すること」—— migration が当たり、seed が 入り、API が答えること —— です。取り込みの経路は capture-scheduler の pnpm run test:e2e が 端から端まで見ます(あちらは Windmill も要ります)。

一発ジョブが素の container run なのは、container-compose に run が無いから です。これにより、以前の --profile run --exit-code-from capture-ledger の回避策も 不要になりました — 回避対象だった Docker Compose の挙動(migrator の正当な exit 0 でスタック全体が停止する)が、こちらには存在しないためです。

ターミナルウィンドウ
DATABASE_URL=postgres://user:pass@db.host:5432/capture_ledger \
pnpm run db:migrate
DATABASE_URL=postgres://user:pass@db.host:5432/capture_ledger \
CAPTURE_LEDGER_CRAWL_WEBHOOK_URL=https://windmill.example/api/w/…/jobs/run/f/f/waggle/crawl_level \
CAPTURE_LEDGER_CRAWL_WEBHOOK_TOKEN=… \
pnpm run api

webhook の URL の … は Windmill の workspace の id です(同梱の構成なら crawler)。 末尾の f/waggle/crawl_level は flow のパスで、決めているのは capture-scheduler です。 名前が違っていても API は起動し、POST /api/crawls も 202 を返します。 分かるのはその後で、クロールが failed になり、 API のログに crawl webhook → 404 Not found: flow not found が出ます。

Postgres の TLS は DATABASE_URL にパラメータを書きます (例: ?sslmode=require)。

BrowserHive の TLS はもうここで設定しません。 そのチャンネルを持っているのは flow なので、CA は Windmill 側の変数 u/admin/browserhive_tls_ca に置きます (空文字なら平文)。

身元の入口は API の 1 つだけで、既定では全員を拒みます。

経路 既定 開発用ヘッダ JWT
API (/api, picker) 拒否 CAPTURE_LEDGER_DEV_IDENTITY=1 CAPTURE_LEDGER_OIDC_ISSUER

以前は CLI の行がもう 1 つあり、環境から CAPTURE_LEDGER_DEV_SUBJECT と CAPTURE_LEDGER_OIDC_TOKEN を読んでいました。その 2 つは、読む側の CLI ごと畳んだときに .env.example からも消えています。主体を名乗るのは投げる側の仕事になりました。

JWT の経路が開発用ヘッダより優先されます。 両方設定された環境で、 そのポートに届く者が誰にでもなれるほうへ落ちてはいけないためです。 picker は、API がどちらで起きたかに合わせて欄を変えます —— 開発用ヘッダなら名乗りの 2 欄、 JWT ならトークンの欄です(アーカイブを見る)。

CAPTURE_LEDGER_OIDC_ISSUER を設定すると、API は 本番と同じ検証コード を通ります —— 署名、iss / aud の照合、有効期限、JWKS の取得。本物の IdP が決まるまでは 同梱の issuer を使います。

ターミナルウィンドウ
pnpm run oidc:issuer # :9099 に立つ
export CAPTURE_LEDGER_OIDC_ISSUER=http://127.0.0.1:9099
TOKEN=$(pnpm run oidc:token --subject alice --org acme)
curl -H "authorization: Bearer $TOKEN" http://127.0.0.1:7070/api/crawls

トークンは .env に置きません —— 読むのは受け取る側の API で、投げる側が Authorization ヘッダに載せるものだからです。

--subject を変えると 「人が投げた場合」と「サービスが投げた場合」の両方を 作れます。後者は OpenFGA の owner tuple が user:<サービス名> になり、 そのアーカイブを人が消せなくなる状態です —— 認証を入れるより前に、認可の設計を ここで踏めます。

変わるのは CAPTURE_LEDGER_OIDC_ISSUER と CAPTURE_LEDGER_OIDC_AUDIENCE の値だけです。 jwtIdentityResolver は 1 行も変わりません。

ただし JWKS を HTTP で取ってくる経路は単体試験では守れません。 createRemoteJWKSet をローカルの鍵に差し替えても試験は緑のままなので、 この節の手順を実際に通すことがその代わりになります。

組織のクレームの綴りは IdP ごとに違います (groups / roles / 独自)。 差し替えるのは src/config/identity.ts の ORGANIZATIONS_CLAIM 1 か所だけです —— API は identityFromClaims を通してそこを読みます。

API は起動したときに、最後の 1 行で待ち受けのアドレス・名乗り方・クロールの段の報告を 受けられるかを言います(src/api/startup-notes.ts)。

Archive API listening on 0.0.0.0:7070 — identity: JWT (http://127.0.0.1:9099); crawl level reports: ready
crawl level reports 意味
ready 段の報告を受けられる —— コンテナから届くアドレスで待ち、JWT を受ける
blocked 受けられない。その上の warn が、足りない行を名指しする(CAPTURE_LEDGER_API_HOST=0.0.0.0 と CAPTURE_LEDGER_OIDC_ISSUER)。クロールは running のまま残る
off クロールの口が無い(webhook の 2 行が無い)

warn はほかに 3 つあります。名乗りの設定がどちらも無い(全員 401)、開発用ヘッダを信じる API を 127.0.0.1 以外で待たせている(そのポートに届く誰もが、誰にでもなれる。picker も同じ判定で、 画面の上に赤い注意を出します)、受け口が在るのに 127.0.0.1 で待っている(BrowserHive のコンテナから PUT が届かない)。CAPTURE_LEDGER_DEV_IDENTITY=1 の警告は、ヘッダが実際に効いているときだけ出ます —— JWT の設定では「無視される」と info で言います。

  • コンテナが上がらない — container ls で起動状況、 container logs <svc>.capture-ledger で理由を見る。Chromium なら curl http://localhost:9222/json/version で CDP の応答を確認。
  • 名前が解決しない — container system dns ls に capture-ledger があるか、 docker-compose.yml のどのサービスにも container_name: が無いか (付けると DNS 命名が抑止される)を確認。
  • BrowserHive が起動直後に落ちる — 起動時の HeadBucket は fatal です。 サービスに WAIT_FOR_S3 が設定されているか、SeaweedFS が Bucket browserhive ready. を出しているか確認してください。
  • /api/crawls が誰に対しても 404 を返す — 呼び出し元に can_submit が 無いか、CAPTURE_LEDGER_CRAWL_WEBHOOK_URL が未設定で route がそもそも登録されて いないかのどちらかです。どちらかは起動時のログが言います (… is not set — /api/crawls is not served)。
  • クロールが running のまま残る(段の報告が届かない・401) — API の起動ログの最後の行が crawl level reports: blocked なら、その上の warn が足りない行を名指しします (起動ログで確かめる)。
  • docs のビルドが BrowserHive のピンを読めない — git submodule update --init --recursive を実行。

スタックには取り込み対象のフィクスチャ fixtures が profile 付きで入っている。見知らぬサイトに向けずにクロールを試せる:

ターミナルウィンドウ
pnpm run stack:up --profile capture-fixtures

port は publish していない。capture-fixtures.capture-ledger:8080 はコンテナからも host からも引ける。 起点にするのは /links/hub —— /links/* はどれもそのページを 1 か所だけ変えたもので、 それが「誤った規則を適用しているクローラ」と「単に壊れているクローラ」を分ける。

fixtures はリクエストログも持っていて、それを使うことがこのフィクスチャの要点:

ターミナルウィンドウ
curl -s http://capture-fixtures.capture-ledger:8080/__request-counts

crawl_pages が言うのは capture-ledger が記録したこと、ログが言うのはフィクスチャが 実際に要求されたこと。「robots を尊重した」は後者でしか決着しない —— 台帳に無い ページは、取りに行かなかったのか、取りに行って捨てたのか、台帳からは区別できない。

fixtures は .upstream/browserhive 経由ではなく .upstream/fixtures に直接 vendor して いる。あちらが抱える fixtures はずっと古く、2 つの pin は別々の都合で動くので、 どちらももう一方を待つ理由が無い。

署名には 2 つのことが同時に要ります —— wacz-signer と tsa が起きていること、 BrowserHive が署名の宛先を知っていること。1 行で両方が立ちます。

.env
CAPTURE_LEDGER_CAPTURE_SIGNING=1

pnpm run stack:up がこの行を読み、--profile signing(wacz-signer と tsa を起こす)と --env-file signing.env(BrowserHive に宛先を渡す)を足します。何を足したかを印字するので、 wacz-signer が起きている理由が分からなくなることはありません。

**片方だけを渡す道はありません。**そこが眼目で、以前は設定が互いを知らない 3 か所に 分かれていました —— .env の旗、profile、そして docker-compose.yml に直書きされた BROWSERHIVE_SIGNING_* の 4 つ。profile を起こさずに署名を on にすると、取り込みが全部 ENOTFOUND wacz-signer.capture-ledger で落ちました。DNS の誤りに見えますが、そうではありません —— 名前は正しく、サービスが起きていなかっただけです。

署名の設定を docker-compose.yml へ戻すことはできません。空にしても駄目です。これは 空文字の罠がもう一段外側で再来したもので、BrowserHive の 分岐は signing.url === undefined なのに、commander は envVar in process.env で 判定するため - BROWSERHIVE_SIGNING_URL= は設定済みとして通ります。結果は fetch("") と TypeError: Failed to parse URL from —— 上の DATABASE_URL= の話と 同じで、変数の名前を一度も出さない誤りになります。項目そのものが無ければ BrowserHive は no signing service is configured on this server と言います。

署名は fail-closed です —— 署名を求めて得られなかった取り込みは、署名なしのアーカイブを 出すのではなく失敗します。

既定では、BrowserHive は成果物を自前の bucket へ書きます。受け口はもう 1 つの経路で、 capture-ledger がクロールごとに 1 回きりの URL とトークンを配り、BrowserHive は成果物を capture-ledger へ PUT します(src/api/sink.ts)。BrowserHive が全テナントに書ける鍵を 持たずに済むための口です。2 つの変数で立ち、2 つ揃ったときだけです —— 片方だけだと API は起動しません。

ターミナルウィンドウ
CAPTURE_LEDGER_API_HOST=0.0.0.0 \
CAPTURE_LEDGER_SINK_ORIGIN=http://$(container network inspect default | jq -r '.[0].status.ipv4Gateway'):7070 \
CAPTURE_LEDGER_SINK_SECRET=$(openssl rand -hex 32) \
pnpm run api

コマンド行で渡せば .env を触らずに済みます —— Node の --env-file-if-exists は、shell が 既に持っている変数を上書きしません。

origin は BrowserHive のコンテナから API に届くアドレスで、手元から使うアドレスではありません。 dev のスタックでは、コンテナのネットワークから見た host —— default ネットワークの gateway で、 上のコマンドがその場で読みます(この文書を書いた Mac では 192.168.66.1。network を作り直すと 変わるので、写さないでください)。API は 0.0.0.0 で待ち受ける必要があります —— loopback に bind すると、コンテナからの接続を断ります。 origin を誤ると、取り込みは受け口の URL で始まる誤り(http://…/api/sink/…: …)で落ちます。

**そのうえで、成果物がどこに置かれたかを見てください。**この経路は、音も無く素通りされることも あります。受け口を立てて始めたクロールは crawls.artifact_key_prefix(org/<orgId>/<YYYY-MM>/) を記録し、そのアーカイブの object_key はこれで始まるはずです。平らな <taskId>_<crawlId>.wacz は BrowserHive が自前の bucket へ書いた印で、クロールは成功し、 ほかに何も教えてくれません。受け口が足されてから一度も使われなかったのはこのためです —— src/crawl/dispatch.ts の webhook の本文は手で書き並べられていて、artifact_sink を一度も 運んでいませんでした。いまは型が網羅を保つ対応表から組み立てます。capture-scheduler の e2e も、 API に受け口の有無を訊き(トークン無しの PUT が、在れば 401、無ければ 404)、在ればその クロールのアーカイブが全部接頭辞の下にあることを求めます。

アーカイブを見るの絵は、scripts/docs-shots.mjs が撮った生成物です。 手で撮った絵は 1 枚もありません。絵は画面の書き写しで、書き写しは画面を直した日から古くなるためです。

ターミナルウィンドウ
./node_modules/.bin/puppeteer browsers install chrome # 初回だけ (撮影用の Chrome を取得)
pnpm run docs:shots

スタックは要りません。script が立てるのは、本物の画面(src/api/picker.ts)と、 見本データを返す代役の API(/api/archives と /api/me)だけです。画面は API の名乗り方で 変わるので、名乗り方ごとに 1 組ずつ立てます(開発用ヘッダ・JWT・名乗りの設定なし・ 0.0.0.0 で待つ開発用ヘッダ)。見た目(ライトモード)・言語・時間帯・見本データの日時を 固定してあるので、誰が撮っても同じ絵になります。何も変えずに撮り直しても、差分は出ません。

src/api/picker.ts を変えたら、撮り直してください。 撮ったときのソースの sha256 を docs-site/src/assets/picker/shots-manifest.json に控えてあり、いまのソースと違うと pnpm run site:check が落ちます。コメントだけの変更でも同じです。manifest は script が書くので、 手では直さないでください。

  • ソースは src/、テストは test/、1 モジュール 1 関心。
  • src/rpc/generated/ は生成物でコミット対象。手で編集しない。
  • Prettier と ESLint が正。push 前に pnpm run check。
  • ドキュメントは docs-site/ に英語と日本語で置く。英語ページだけを追加すると pnpm run site:check が落ちます。