<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

 <title>Web Scratch</title>
 <link href="https://efcl.info/feed/" rel="self"/>
 <link href="https://efcl.info"/>
 <updated>2026-05-14T08:11:12+09:00</updated>
 <id>https://efcl.info/</id>
 
 <author>
   <name>azu</name>
   
 </author>
 

 
 <entry>
   <title>automerge-gate: GitHubのAuto Mergeをひとつの必須チェックに集約するGitHub Action</title>
   <link href="https://efcl.info/2026/05/13/automerge-gate/"/>
   <updated>2026-05-13T20:00:00+09:00</updated>
   <id>https://efcl.info/2026/05/13/automerge-gate</id>
   <content type="html"><![CDATA[ <p>GitHubのAuto Mergeをひとつの必須チェックに集約するためのGitHub Action <a href="https://github.com/pkgdeps/automerge-gate">automerge-gate</a> を作ったので紹介します。</p>
<ul>
<li>GitHub: <a href="https://github.com/pkgdeps/automerge-gate">pkgdeps/automerge-gate</a></li>
</ul>
<h2>背景: GitHub Auto Mergeは集約するアクションなしだと使いにくい</h2>
<p>前提として、GitHubの<a href="https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request">Auto Merge</a>を使うには、必須チェック未達成のPRをマージできない状態にする<a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets">Branch protection ruleやRuleset</a>の設定が必要です。
これらの保護機能でPRがブロックされる状態を作ったうえで、すべての必須チェックが成功した時点でAuto Mergeが発火する、という仕組みになっています。
逆に言うと、Auto Mergeを使うには何かしらのステータスチェックを必ず必須に入れる必要があります。</p>
<p>そして、Branch protection ruleやRulesetは、マージに必要なステータスチェックを名前で列挙する形式です。
この方式は次のような場面で壊れやすいという問題があります。</p>
<ul>
<li>RenovateやDependabotなど外部のGitHub Appが追加するチェックは、PRごとにあったりなかったりする</li>
<li>monorepoでパスフィルタを使っていると、ワークフローがPRによってスキップされたりされなかったりする</li>
<li>新しいワークフローを追加する度に、Rulesetを書き換える必要がある</li>
</ul>
<p>GitHubのRulesetは複数の必須チェックをANDでつなぐ(全部成功すること)しか表現できないため、「チェック群のうちどれかが走っていればよい」みたいな条件は書けません。
そのため、PRごとに発火するチェックが違うケースだと、片方のPRでは存在しないチェックを必須にしてしまい、いつまでもマージできないという状態が発生します。</p>
<p>この問題への対処として、必須チェックを1つのステータスに集約する<a href="https://github.com/upsidr/merge-gatekeeper">upsidr/merge-gatekeeper</a>を使っているケースも多いです。
自分も<a href="https://github.com/textlint/textlint">textlint</a>などのオープンソースプロジェクトや、プライベートリポジトリで使っていました。</p>
<ul>
<li><a href="https://github.com/textlint/textlint/pull/1577">CI: add Merge Gatekeeper workflow for pull requests by azu · Pull Request #1577 · textlint/textlint</a></li>
</ul>
<p>最近はmerge-gatekeeperからautomerge-gateに入れ替えて使っています。
automerge-gateも同じ「集約された1つの必須チェック」というアプローチですが、GitHubのAuto Mergeと組み合わせて使うことを前提に作られています。
必須チェックとして登録するのは<code>automerge-gate/all-passed</code>の1つだけで、ワークフローやGitHub App由来のチェックをこのアクションが集約してくれます。</p>
<h2>仕組み</h2>
<p>automerge-gateには2つのモードがあります。</p>
<ul>
<li>Private mode — フォークPRを受け取らないリポジトリ向けのコスト最適化モード。マージ意図のないPRではアクション自体が早期returnして、ランナー時間を消費しない</li>
<li>Public mode — フォークPRを受け取るリポジトリ向け。フォークPRでは<code>GITHUB_TOKEN</code>が読み取り専用になるため、ジョブ自身の<code>check_run</code>の終了コードがゲート信号になる</li>
</ul>
<p>メインのユースケースはPrivate modeで、Public modeはオープンソースプロジェクトのようなフォークPR対応用です。</p>
<h3>Private mode (メインのユースケース)</h3>
<p>Private modeでは、アクションがREST APIで集約結果をcommit statusとして書き込みます。
重要なのは、PRが開かれただけでマージ意図がない状態(Auto Merge未有効 &amp; write権限のApproveなし)では、アクション側はポーリングをせずに何も書き込まずにすぐに終了する点です。</p>
<p>このとき、必須チェック<code>automerge-gate/all-passed</code>はGitHubのデフォルトの<code>Expected — Waiting for status to be reported</code>のままです。
そのため、マージはブロックされた状態が維持されます。
メンテナがAuto Mergeを有効化したタイミング、またはwrite権限を持つレビュアーがApproveしたタイミングで、初めてアクションがポーリングを開始してチェックを集約しにいきます。</p>
<p><img src="https://mermaid.ink/svg/c2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBVIGFzIOODoeODs-ODhuODigogICAgcGFydGljaXBhbnQgQSBhcyBhdXRvbWVyZ2UtZ2F0ZSAoYWN0aW9uKQogICAgcGFydGljaXBhbnQgUFIgYXMgUHVsbCBSZXF1ZXN0CgogICAgVS0-PlBSOiBvcGVuIC8gcHVzaAogICAgTm90ZSBvdmVyIEE6IGFjdGlvbiDjga_jgrnjgq3jg4Pjg5cgKOODnuODvOOCuOaEj-Wbs-OBquOBlykKICAgIE5vdGUgb3ZlciBQUjog5b-F6aCI44OB44Kn44OD44Kv44GvICJFeHBlY3RlZCIg44Gu44G-44G-PGJyLz7jg57jg7zjgrjkuI3lj68KCiAgICBVLT4-UFI6IEVuYWJsZSBBdXRvIE1lcmdlIC8gQXBwcm92ZQogICAgQS0-PkE6IFBSIOS4iuOBruS7luOBruODgeOCp-ODg-OCr-OCkuODneODvOODquODs-OCsAoKICAgIGFsdCDjgZnjgbnjgabmiJDlip8KICAgICAgICBBLT4-UFI6IGNvbW1pdCBzdGF0dXMg44KSIHN1Y2Nlc3Mg44GnIFBPU1QKICAgICAgICBQUi0-PlBSOiBHaXRIdWIgYXV0by1tZXJnZSDihpIg44Oe44O844K4CiAgICBlbHNlIOOBhOOBmuOCjOOBi-WkseaVlwogICAgICAgIEEtPj5QUjogY29tbWl0IHN0YXR1cyDjgpIgZmFpbHVyZSDjgacgUE9TVAogICAgICAgIE5vdGUgb3ZlciBQUjog44Oe44O844K45LiN5Y-vCiAgICBlbmQK?bgColor=FFFFFF" alt="automerge-gate Private modeのシーケンス" /></p>
<p>ジョブ自体は軽量で、PRの<code>check_run</code>を一定間隔(デフォルト30秒)でポーリングして集約結果を計算するだけです。
依存関係のビルドもなく、<code>runs-on: ubuntu-latest</code>の標準ランナーで十分動きます。
ポーリングしない場合のジョブは数秒で終わるので、Auto MergeがONになる前のPRではランナー時間をほぼ消費しません。</p>
<p>このスキップ動作によって、プライベートリポジトリでのコスト効率が改善できます。
GitHub Actionsの料金はジョブ単位の1分未満切り上げで、<a href="https://docs.github.com/en/actions/reference/runners/github-hosted-runners">利用できるランナー</a>ごとに<a href="https://docs.github.com/en/billing/reference/actions-runner-pricing">単価</a>が異なります。
代表的なものを抜粋すると次のとおりです。</p>
<table>
<thead>
<tr>
<th><code>runs-on:</code></th>
<th>スペック</th>
<th>料金</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ubuntu-latest</code></td>
<td>Linux 2-core (x64)</td>
<td>$0.006 / 分</td>
</tr>
<tr>
<td><code>ubuntu-24.04-arm</code></td>
<td>Linux 2-core (arm64)</td>
<td>$0.005 / 分</td>
</tr>
<tr>
<td><code>ubuntu-slim</code></td>
<td>Linux 1-core (x64)</td>
<td>$0.002 / 分</td>
</tr>
</tbody>
</table>
<p><a href="https://github.com/upsidr/merge-gatekeeper">merge-gatekeeper</a>は同等の集約処理をしてくれますが、Auto MergeがONかどうかに関わらず、PRが開かれた時点から他のチェックが揃うまでポーリングを続ける設計です。
そのため、1回のポーリングはCI全体が完了するのにかかる時間(例: 10分)とほぼ同じだけ走り続けます。
さらにpushするたびに同じポーリングが起きるので、<code>push数 × ポーリング時間</code>の課金が発生します。
さらにmerge-gatekeeperは内部でDockerコマンドを使うため、Dockerが使えない<code>ubuntu-slim</code>では動きません。
そのため、$0.006/分の<code>ubuntu-latest</code>を選ぶ必要がありました。</p>
<p>automerge-gateの場合、本体はNode.js製のActionでDockerに依存していないため、<code>ubuntu-slim</code>(1-core, $0.002/分)でも動かせます。
加えてPrivate modeでは、Auto Mergeが有効化されておらずwrite権限ありのApproveもないPRに対してはポーリング自体を開始しません。
ジョブはトリガーされるので1分切り上げの最低課金(<code>ubuntu-slim</code>なら$0.002)は発生しますが、CI完了までポーリングし続けることはなくなります。</p>
<p>ただし、merge-gatekeeperとは視覚的な違いがあります。
merge-gatekeeperの場合、常にポーリングしているので、すべてのチェックが揃えば集約チェックがグリーンになります。
一方automerge-gateのPrivate modeでは、Auto MergeまたはApproveまで<code>automerge-gate/all-passed</code>は<code>pending</code>のままです。
GitHubの表示上は<code>Expected — Waiting for status to be reported</code>と出ます。</p>
<p>Commit statusは<code>(SHA, context)</code>の組をキーにしてGitHubが評価するので、新しいコミットがpushされても自動的に新しいSHAに対して再評価が走ります。Auto Mergeを一度有効にしたら、その後はpush毎に有効/無効を切り替える必要はありません。</p>
<h2>設定方法</h2>
<p>設定は次の5ステップです。</p>
<ol>
<li>モードを選ぶ(Private / Public)</li>
<li><code>.github/workflows/automerge-gate.yaml</code>を追加</li>
<li>Ruleset(またはBranch protection)で<code>automerge-gate/all-passed</code>を必須チェックに登録</li>
<li>リポジトリ設定で「Allow auto-merge」を有効化</li>
<li>PRで「Enable Auto Merge」をクリック</li>
</ol>
<h3>ワークフローファイル (Private mode)</h3>
<p><code>.github/workflows/automerge-gate.yaml</code>は次のような内容になります。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="na">name</span><span class="pi">:</span> <span class="s">automerge-gate</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">opened</span><span class="pi">,</span> <span class="nv">synchronize</span><span class="pi">,</span> <span class="nv">reopened</span><span class="pi">,</span> <span class="nv">auto_merge_enabled</span><span class="pi">]</span>
  <span class="na">pull_request_review</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">submitted</span><span class="pi">]</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">${{ github.workflow }}-${{ github.ref }}</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">gate</span><span class="pi">:</span>
    <span class="na">if</span><span class="pi">:</span> <span class="pi">&gt;-</span>
      <span class="s">github.event_name != 'pull_request_review' ||</span>
      <span class="s">github.event.review.state == 'approved'</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">statuses</span><span class="pi">:</span> <span class="s">write</span>
      <span class="na">checks</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">pull-requests</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">actions</span><span class="pi">:</span> <span class="s">read</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pkgdeps/automerge-gate@v4.0.0</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">gate-mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">private'</span>
          <span class="na">context</span><span class="pi">:</span> <span class="s1">'</span><span class="s">automerge-gate/all-passed'</span>
</code></pre></div></div>
<p>ポイントは次のとおりです。</p>
<ul>
<li><code>pull_request_review</code>の<code>if:</code>で<code>approved</code>のみを通している。GitHubの<code>on:</code>はレビューのstateで絞り込めないので、ジョブの<code>if:</code>で弾いて空振りでもrunnerが立ち上がらないようにしている</li>
<li><code>timeout-minutes: 10</code>がポーリングループの唯一のタイムアウト。アクション側に独立した<code>timeout-seconds</code>入力はあえて用意されておらず、設定箇所を二重化しない方針になっている</li>
<li>権限は<code>statuses: write</code>(commit status書き込み)と<code>checks: read</code>(チェックの集約読み取り)で十分</li>
</ul>
<p>Approveをマージ意図として扱いたくないチームは、<code>on:</code>から<code>pull_request_review</code>を外せば、Auto Mergeを明示的に有効化したときだけポーリングが走るようになります。</p>
<h3>必須チェックの登録</h3>
<p>Settings → Rules → Rulesetsを開いて、<code>automerge-gate/all-passed</code>を必須チェックに追加します。</p>
<p>📝 必須チェックのドロップダウンは、過去にそのリポジトリで実行されたチェック名しかオートコンプリートしないので、初回設定時は候補に出てきません。手入力で<code>automerge-gate/all-passed</code>と入れる必要があります。</p>
<h3>Auto Mergeの有効化</h3>
<p>Settings → General → Pull Requestsで「Allow auto-merge」をチェックします。これをしないとPRに「Enable Auto Merge」ボタンが出ないので、ステップ5が動きません。</p>
<h2>除外パターンの指定</h2>
<p>CodecovやNetlifyのプレビュー、Renovateなど特定のチェック/Appをゲートから外したい場合は、<code>ignore-apps</code>または<code>ignore-checks</code>で除外できます。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pkgdeps/automerge-gate@v4.0.0</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">gate-mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">private'</span>
    <span class="na">ignore-apps</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">dependabot</span>
      <span class="s">renovate</span>
</code></pre></div></div>
<p><code>ignore-checks</code>はglob(<code>*</code> / <code>?</code>)が使えます。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pkgdeps/automerge-gate@v4.0.0</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">gate-mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">private'</span>
    <span class="na">ignore-checks</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">optional-*</span>
      <span class="s">docs-only</span>
</code></pre></div></div>
<p><code>ignore-checks</code>がチェックするのはGitHub APIの<code>check_run.name</code>(=<code>jobs.&lt;key&gt;.name</code>)です。
GitHubのUIで見える<code>&lt;workflow&gt; / &lt;job&gt;</code>形式ではない点に注意してください。
実際にどの名前で記録されているかは、次のコマンドで確認できます。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">gh api <span class="s2">"repos/{owner}/{repo}/commits/{sha}/check-runs"</span> <span class="se">\</span>
  <span class="nt">--jq</span> <span class="s1">'.check_runs[] | {name, app: .app.slug, conclusion}'</span>
</code></pre></div></div>
<h2>Public modeについて</h2>
<p>オープンソースプロジェクトのようにフォークPRを受け付けるリポジトリでは、フォークPRに対して<code>GITHUB_TOKEN</code>が読み取り専用になります。
そのため、Private modeのようにcommit statusをPOSTする方法は使えません。
書き込みできないと「待機中(=ステータス未設定)」の状態も表現できないため、Private modeでやっている「マージ意図がなければスキップ」もそのままでは成り立ちません。</p>
<p>そこでPublic modeでは、ジョブ自身の<code>check_run</code>(GitHub Actionsが自動で作るもの)の終了コードをゲート信号として扱います。
ジョブの<code>name:</code>を必須チェックの名前(<code>automerge-gate/all-passed</code>)に揃えておくことで、ジョブの結果がそのまま必須チェックの結果になります。
この点は<a href="https://github.com/upsidr/merge-gatekeeper">merge-gatekeeper</a>とほぼ同じ仕組みです。
代わりに「スキップで節約」はできなくなるため、Public modeでは全イベントで常にポーリングする形になります。</p>
<p>代替案として<code>pull_request_target</code>で<code>GITHUB_TOKEN</code>に書き込み権限を持たせるアプローチもあります。
しかし、フォーク由来のコードを書き込み権限付きで動かすことになり、セキュリティ上の問題が大きいです。
そのため、この方式は採らずに「ジョブ自身の終了コードを信号にする」形に落ち着きました。
詳しい設計の背景は、<a href="https://github.com/pkgdeps/automerge-gate/blob/main/docs/architecture.md">architecture.md</a>にまとまっています。</p>
<p><img src="https://mermaid.ink/svg/c2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBQUiBhcyBQdWxsIFJlcXVlc3QKICAgIHBhcnRpY2lwYW50IEogYXMgZ2F0ZSBqb2IKICAgIHBhcnRpY2lwYW50IEEgYXMgYXV0b21lcmdlLWdhdGUgKGFjdGlvbikKCiAgICBQUi0-Pko6IHdvcmtmbG93IHRyaWdnZXIgKOW4uOaZgikKICAgIE5vdGUgb3ZlciBKOiBqb2Ig44GuIGNoZWNrX3J1biA9IOW_hemgiOODgeOCp-ODg-OCrzxici8-KGpvYiDlkI3jgajkuIDoh7QpCiAgICBKLT4-QTogYWN0aW9uIOOBjOS7luOBruODgeOCp-ODg-OCr-OCkuODneODvOODquODs-OCsAoKICAgIGFsdCDjgZnjgbnjgabmiJDlip8KICAgICAgICBBLT4-SjogZXhpdCAwCiAgICAgICAgSi0-PlBSOiBqb2Ig44GuIGNoZWNrX3J1biDihpIgc3VjY2VzcwogICAgICAgIFBSLT4-UFI6IEdpdEh1YiBhdXRvLW1lcmdlIOKGkiDjg57jg7zjgrgKICAgIGVsc2Ug44GE44Ga44KM44GL5aSx5pWXCiAgICAgICAgQS0-Pko6IGV4aXQgbm9uLXplcm8KICAgICAgICBKLT4-UFI6IGpvYiDjga4gY2hlY2tfcnVuIOKGkiBmYWlsdXJlCiAgICAgICAgTm90ZSBvdmVyIFBSOiDjg57jg7zjgrjkuI3lj68KICAgIGVuZAo?bgColor=FFFFFF" alt="automerge-gate Public modeのシーケンス" /></p>
<p>ワークフローは次のようになります。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="na">name</span><span class="pi">:</span> <span class="s">automerge-gate</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">opened</span><span class="pi">,</span> <span class="nv">synchronize</span><span class="pi">,</span> <span class="nv">reopened</span><span class="pi">,</span> <span class="nv">auto_merge_enabled</span><span class="pi">]</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">${{ github.workflow }}-${{ github.ref }}</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">gate</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">automerge-gate/all-passed</span> <span class="c1"># ruleset側の必須チェック名と一致させる</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">checks</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">pull-requests</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">actions</span><span class="pi">:</span> <span class="s">read</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pkgdeps/automerge-gate@v4.0.0</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">gate-mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">public'</span>
</code></pre></div></div>
<p>Private modeとの違いは次のとおりです。</p>
<ul>
<li>権限は<code>checks: read</code>のみでよい(commit statusを書き込まないため)</li>
<li>「マージ意図がないPRはスキップ」というコスト最適化は行わない。常にトリガーごとにポーリングする(<code>GITHUB_TOKEN</code>が読み取り専用だと”待機中”の信号を書き込めないため)</li>
<li>ジョブの<code>name:</code>が必須チェック名そのものになる</li>
</ul>
<p>実際のPublic modeでの実行例として、<a href="https://github.com/secretlint/secretlint/pull/1557">secretlint/secretlint#1557</a>のログを見てみます。
<code>ubuntu-24.04</code>の標準ランナー上で、17個のチェックを集約している様子です。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>##[group][00:05] Poll #1 — pending, 14/15 completed
  🟡 Agent (in_progress)
  ✅ Analyze (javascript-typescript) (success)
  ✅ Analyze (javascript) (success)
  ✅ binary-test (success)
  ✅ CodeQL (success)
  ...
##[group][00:38] Poll #2 — success, 17/17 completed
  ✅ Agent (success)
  ...
✅ Passed (17):
</code></pre></div></div>
<p>CodeQL、hadolint、secretlint、各OS/Node.jsのテストなど複数ワークフロー由来のチェックが、<code>automerge-gate/all-passed</code>の1つに集約されています。
ジョブ自体はチェック結果を読んで待つだけなので、約38秒で集約完了しています。</p>
<p>オープンソースプロジェクトのようにフォークPRを受け付ける環境でなければ、Private modeを使うのが基本になります。</p>
<h2>merge-gatekeeperとの細かな違い: GitHub Actionsが作ったPRのデッドロック</h2>
<p>GitHub ActionsがPRを作る場合、<code>secrets.GITHUB_TOKEN</code>で作成されたPRに対しては、無限ループ防止のために他のGitHub Actionsワークフローが発火しません。</p>
<ul>
<li>参考: <a href="https://docs.github.com/en/actions/how-tos/manage-workflow-runs/trigger-a-workflow#triggering-a-workflow-from-a-workflow">Triggering a workflow from a workflow - GitHub Docs</a></li>
</ul>
<p>このとき、ゲート用のワークフローも発火しないため、必須チェックがいつまでも報告されません。
merge-gatekeeperの場合は、PRイベントでしか動かないため、このPRはマージできないままデッドロックします。</p>
<p>automerge-gateはPrivate/Publicどちらのモードでも、<code>pull_request</code>の<code>auto_merge_enabled</code>イベントをトリガーに含めています。
さらにPrivate modeではApprove(<code>pull_request_review</code>の<code>submitted</code>)もトリガーに含まれます。
そのため、手動でAuto Mergeを有効化するかApproveすれば、人手起点でゲートを動かせます。
完全な自動化はできませんが、デッドロック状態からは一応抜け出せる構造になっています。</p>
<h2>制限事項</h2>
<p>automerge-gateには次の制限があります。</p>
<ul>
<li>Merge Queue非対応 — GitHubの<code>merge_group</code>イベントには非対応</li>
<li>ジョブのタイムアウト — <code>timeout-minutes</code>に達するとジョブが<code>failure</code> / <code>cancelled</code>で終わり、必須チェックは赤のまま残る。GitHub Actionsの「Re-run failed jobs」でリトライするか、Auto Mergeを一度無効化して有効化し直す</li>
<li>Legacy commit status APIのみのCI — AtlantisやJenkinsの一部のような、legacy commit status APIだけを使うCIは集約対象にならない
<ul>
<li>該当するCIはRulesetに直接必須チェックとして追加し、<code>automerge-gate/all-passed</code>と並列に置く必要がある</li>
</ul>
</li>
</ul>
<h2>バージョニング</h2>
<p>automerge-gateのリリースは、<code>v4.0.0</code>のような不変のSemVerタグで公開されます。
<code>v4</code>のように移動するメジャータグは意図的に作っていないので、ワークフロー側では固定バージョンを指定して、RenovateやDependabotで更新するスタイルが推奨されています。これは、移動するタグが書き換えられるサプライチェーンリスクを避けるための設計です。</p>
<h2>まとめ</h2>
<p><a href="https://github.com/pkgdeps/automerge-gate">automerge-gate</a>は、GitHubのAuto Mergeとあわせて使う集約チェックのGitHub Actionです。</p>
<ul>
<li>Rulesetに登録する必須チェックは<code>automerge-gate/all-passed</code>の1つだけで済む</li>
<li>Renovate/DependabotやmonorepoのパスフィルタによってPRごとにチェックが増減しても、自動的に集約される</li>
<li>Private modeでは、マージ意図のないPRではポーリングをスキップするためrunner時間をほぼ消費しない</li>
<li>フォークPRを受け取るオープンソースプロジェクトなどはPublic modeで対応できる</li>
</ul>
<p>merge-gatekeeperと似たコンセプトですが、Auto Merge前提でコストを最適化している点が違います。
また、Private/Publicの2モードに分けて<code>GITHUB_TOKEN</code>の権限差に対応している点も異なります。</p>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/pkgdeps/automerge-gate">pkgdeps/automerge-gate</a></li>
<li><a href="https://github.com/upsidr/merge-gatekeeper">upsidr/merge-gatekeeper</a></li>
<li><a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches">About protected branches - GitHub Docs</a></li>
<li><a href="https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request">Automatically merging a pull request - GitHub Docs</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>Secretlint v13.0.0リリース: .gitignore済みをデフォルトで無視、Tailscale/Stripe/Cloudflareの検出に対応</title>
   <link href="https://efcl.info/2026/05/05/secretlint-v13/"/>
   <updated>2026-05-05T10:00:00+09:00</updated>
   <id>https://efcl.info/2026/05/05/secretlint-v13</id>
   <content type="html"><![CDATA[ <p>ソースコードや設定ファイルに含まれるAPIトークンやパスワードなどの機密情報を見つける<a href="https://github.com/secretlint/secretlint">Secretlint</a>のv13.0.0をリリースしました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v13.0.0">Release v13.0.0 · secretlint/secretlint</a></li>
</ul>
<p>このバージョンの主な変更点は次の3つです。</p>
<ul>
<li>ファイル探索時に<code>.gitignore</code>をデフォルトで尊重するように変更（Breaking Change）</li>
<li>グロブメタ文字を含むパスが実在する場合はリテラルとして扱うように変更</li>
<li>Tailscale/Stripeの検出ルールを新規追加、CloudflareをcanaryからrecommendへPromote</li>
</ul>
<h2>Breaking Change: <code>.gitignore</code>をデフォルトで尊重</h2>
<p>v13.0.0では、ファイル探索時に<code>.gitignore</code>の内容をデフォルトで尊重するようになりました。
ripgrepと同じ挙動で、ネストされた<code>.gitignore</code>ファイルもサブディレクトリへカスケードして適用されます。深い階層のネガティブルール（<code>!</code>）で上位の判定を上書きできます。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/pull/1530">feat!: respect .gitignore by default via @secretlint/walker by azu · Pull Request #1530</a></li>
</ul>
<p><code>.gitignore</code>に一致したファイルはスキャン対象から除外されます。
そのため、これまで<code>dist/</code>や生成物などを意図的にスキャンしていたプロジェクトでは、出力されるファイル数が減ります。</p>
<p><code>.secretlintignore</code>はこれまで通り併用できます。</p>
<p>v12までの挙動に戻したい場合は、<code>--no-gitignore</code>オプションを指定します。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">secretlint <span class="nt">--no-gitignore</span> <span class="s2">"**/*"</span>
</code></pre></div></div>
<p><code>.gitignore</code>に一致しているはずのファイルが結果に含まれている場合は、Issueで報告してください。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/issues">Issues · secretlint/secretlint</a></li>
</ul>
<h3>実装: <code>@secretlint/walker</code></h3>
<p>v13.0.0では、ファイル探索の実装を<a href="https://github.com/sindresorhus/globby"><code>globby</code></a>から、新しく追加した<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/walker"><code>@secretlint/walker</code></a>へ置き換えました。</p>
<p>設計はplanドキュメントとしてリポジトリにコミットされています。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/blob/v13.0.0/docs/superpowers/plans/2026-05-03-walker-gitignore-cascade-plan.md">docs/superpowers/plans/2026-05-03-walker-gitignore-cascade-plan.md</a></li>
</ul>
<p>Rustには<a href="https://github.com/BurntSushi/ripgrep">ripgrep</a>の<a href="https://docs.rs/ignore/"><code>ignore</code> crate</a>があり、<a href="https://github.com/oxc-project/oxc">oxc</a>などはこれを使うことで<code>.gitignore</code>のカスケードを安価に再利用できます。
JavaScript側にも、ネストされた<code>.gitignore</code>に対応するwalkerが全く無いわけではありません（<a href="https://github.com/fabiospampinato/tiny-readdir-glob-gitignore"><code>tiny-readdir-glob-gitignore</code></a>、<a href="https://github.com/npm/ignore-walk"><code>ignore-walk</code></a>など）。
ただし、ripgrepの<code>ignore</code> crateほど枯れた実績や仕様の網羅度を持つものは見当たりませんでした。
また、後述する「グロブメタ文字を含むパスが実在する場合はリテラル扱いにする」のように、走査・マッチ側に独自の制御を入れたい要件もあります。
依存ライブラリも<code>node-ignore</code>と<code>picomatch</code>まで分解すれば<code>fs.readdir</code>の上に薄く書ける範囲だったため、Secretlint側でwalkerを実装することにしました。</p>
<p><code>@secretlint/walker</code>は、ネストされた<code>.gitignore</code>のカスケードに対応するPromiseベースのファイルシステムwalkerです。
依存ライブラリは<code>ignore</code>（node-ignore）と<code>picomatch</code>の2つだけで、含めるパターン（include）と除外するパターン（ignore）でセマンティクスを分離しています。</p>
<ul>
<li>含めるパターン（include）: <a href="https://github.com/micromatch/picomatch"><code>picomatch</code></a>を使ったグロブマッチ。<code>**/*.{ts,js}</code>のようなブレース展開やドットファイルのマッチに対応する</li>
<li>除外するパターン（ignore）: <a href="https://github.com/kaelzhang/node-ignore"><code>node-ignore</code></a>を使った<code>.gitignore</code>セマンティクスのマッチ。<code>.gitignore</code>の挙動に合わせるため、ブレース展開は意図的に対応しない</li>
</ul>
<p>Node.js本体にも<a href="https://nodejs.org/api/fs.html#fsglobpattern-options-callback"><code>fs.glob</code></a>や<a href="https://nodejs.org/api/path.html#pathmatchesglobpath-pattern"><code>path.matchesGlob</code></a>が用意されていますが、これらにはドットファイルをマッチに含める<code>dot</code>オプションが存在しません。
ドットファイル（<code>.env</code>など）のスキャンが要件として外せないため、include側は<code>picomatch</code>にしています。</p>
<p>走査自体は<code>fs.readdir(dir, { withFileTypes: true })</code>をベースにしたシンプルな再帰で、各ディレクトリのエントリを<code>Promise.all</code>で並列処理します。
ディレクトリ単位でignoreを判定し、無視対象に該当したディレクトリはサブツリーごと走査をやめます。
<code>readdir</code>自体を呼ばないため、大きな<code>node_modules</code>配下などをまるごとスキップできます。</p>
<p><code>.gitignore</code>のカスケードは<code>IgnoreStack</code>という構造で扱います。
親ディレクトリの<code>ignore</code>インスタンスに対して、現在のディレクトリの<code>.gitignore</code>を読み込んで<code>extendIgnore()</code>で重ねるという形でレイヤーを積みます。
<code>.gitignore</code>がそのディレクトリに存在しない場合は親のインスタンスをそのまま返すため、不要なallocationが起きません。
深い階層のネガティブルール（<code>!pattern</code>）が浅い階層のルールを上書きする挙動も、このスタック上で自然に表現されます。
ネストされた<code>.gitignore</code>内のパターンはそのファイルの置かれたディレクトリにアンカーされます。
そのため、<code>packages/foo/.gitignore</code>の<code>src/**/*.ts</code>はリポジトリルートではなく<code>packages/foo/src/</code>配下にだけ適用されます。</p>
<p>クロスプラットフォーム対応として、<code>node-ignore</code>へ渡すパスはWindowsでも<code>/</code>区切りに正規化（<code>toPosix()</code>）した上でマッチングします。
返すパスはOSネイティブの区切り文字に戻します。
また、<code>ENOENT</code>や<code>EACCES</code>は探索全体を止めずにスキップする方針で、ファイル削除・パーミッションエラーに対して頑健になっています。</p>
<p>入力パターンは静的なプレフィックス（walk root）と動的なサフィックス（マッチパターン）に分割し、同じrootを持つパターンはグループ化して1度のwalkで処理します。
グロブメタ文字を含む入力でも、それが実際のファイル/ディレクトリとして存在する場合は<code>noGlob</code>相当の扱いにフォールバックさせるロジックもwalker側に入っています。</p>
<p>主なAPIは<code>walk(options)</code>で、パッケージ単独でも利用できます。</p>
<table>
<thead>
<tr>
<th>オプション</th>
<th>型</th>
<th>デフォルト</th>
<th>説明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cwd</code></td>
<td><code>string</code></td>
<td>必須</td>
<td>探索の起点ディレクトリ</td>
</tr>
<tr>
<td><code>patterns</code></td>
<td><code>string[]</code></td>
<td><code>undefined</code></td>
<td>インクルードのグロブパターン</td>
</tr>
<tr>
<td><code>ignoreFiles</code></td>
<td><code>string[]</code></td>
<td><code>[]</code></td>
<td>カスケード対象のignoreファイル名（<code>.gitignore</code>など）</td>
</tr>
<tr>
<td><code>extraIgnorePatterns</code></td>
<td><code>string[]</code></td>
<td><code>[]</code></td>
<td>コードから渡す追加のignoreパターン</td>
</tr>
<tr>
<td><code>noGlob</code></td>
<td><code>boolean</code></td>
<td><code>false</code></td>
<td><code>patterns</code>をリテラルとして扱う</td>
</tr>
<tr>
<td><code>followSymlinks</code></td>
<td><code>boolean</code></td>
<td><code>true</code></td>
<td>シンボリックリンクを追従するか</td>
</tr>
</tbody>
</table>
<p>CLI側の<code>secretlint</code>では、<code>--no-gitignore</code>が指定された場合は<code>ignoreFiles</code>から<code>.gitignore</code>を外し、それ以外の場合はカスケード有効でwalkします。
<code>.secretlintignore</code>は別経路の<code>extraIgnorePatterns</code>相当で従来通り適用されるため、<code>.gitignore</code>との共存に影響はありません。</p>
<h3>パフォーマンス</h3>
<p>walker単体の実行時間で見ると、globbyからの置き換えによるパフォーマンスの大きな劣化はありません。
一方で、<code>.gitignore</code>のカスケードは「親のルールに子のルールを正しく重ねる」ことを満たさないと挙動が壊れる部分なので、ここはripgrepの実装を参考にしながら書いています。</p>
<p>実際のSecretlintの実行時間で見ると、<code>.gitignore</code>を尊重することで<code>node_modules/</code>や<code>dist/</code>などをそもそもスキャンしなくなるため、Lint対象のファイル数が減ります。
スキャン+ルール評価のコストはファイル数に比例して効いてくるため、walker自体のコストよりもLint対象が減ることによる削減のほうが支配的になります。
結果として、v12より速く終わるケースが多くなる想定です。</p>
<h2>グロブメタ文字を含むパスが実在する場合はリテラル扱いに</h2>
<p>Secretlintはコマンドライン引数をデフォルトでグロブパターンとして解釈します。
v13.0.0では、グロブメタ文字（<code>()</code>、<code>[]</code>、<code>{}</code>、<code>?</code>）を含むパスが実際にファイル/ディレクトリとして存在する場合は、リテラルとして扱うように変更しました。</p>
<p>これは、SvelteKitやNext.jsのRoute Groupなど、ディレクトリ名に<code>()</code>や<code>[]</code>を含むプロジェクトで特に有効です。</p>
<table>
<thead>
<tr>
<th>パターン</th>
<th>ディスク上</th>
<th>v12のデフォルト</th>
<th>v13のデフォルト</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>src/(group)/page.tsx</code></td>
<td>存在する</td>
<td>グロブとして解釈、マッチしない</td>
<td>リテラルとしてマッチ</td>
</tr>
<tr>
<td><code>src/(missing)/page.tsx</code></td>
<td>存在しない</td>
<td>グロブとして解釈</td>
<td>グロブとして解釈</td>
</tr>
<tr>
<td><code>src/[a-z]ormal.tsx</code></td>
<td><code>normal.tsx</code>が存在</td>
<td>グロブ経由でマッチ</td>
<td>グロブ経由でマッチ</td>
</tr>
</tbody>
</table>
<p>入力ごとに1度だけ<code>stat</code>を実行し、存在すればリテラル、存在しなければ従来通りグロブとして扱います。
従来通り常にリテラルとして扱いたい場合は、<code>--no-glob</code>オプションを指定します。</p>
<h2>新しく追加された検出ルール</h2>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>に、次の3つのルールが追加されました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-tailscale">Tailscale</a> - Tailscale APIキー（新規追加）</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-stripe">Stripe</a> - Stripe APIキー（新規追加）</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-cloudflare">Cloudflare</a> - Cloudflare APIトークン（canaryから昇格）</li>
</ul>
<p>関連PRは次のとおりです。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/pull/1536">Add Tailscale API key detection rule by azu · Pull Request #1536</a></li>
<li><a href="https://github.com/secretlint/secretlint/pull/1537">feat(secretlint-rule-stripe): add Stripe API key detection rule by azu · Pull Request #1537</a></li>
<li><a href="https://github.com/secretlint/secretlint/pull/1538">feat(secretlint-rule-preset-recommend): promote cloudflare, stripe, tailscale from canary by azu · Pull Request #1538</a></li>
</ul>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>を使っている場合は、v13.0.0にアップデートすると自動的にこれらのルールが有効になります。</p>
<h2>まとめ</h2>
<p>Secretlint v13.0.0では、ファイル探索が<code>.gitignore</code>をデフォルトで尊重するようになりました。
そのため、<code>dist/</code>などをスキャンしていたプロジェクトでは、<code>--no-gitignore</code>への切り替えや<code>.secretlintignore</code>の見直しが必要になります。</p>
<p>これに加えて、グロブメタ文字を含む実在パスをリテラルとして扱うよう調整し、Route Groupなどのディレクトリ構成でもオプションなしに動作します。
検出ルールにはTailscale/Stripe/Cloudflareを追加しています。</p>
<p>フィードバックがあればGitHubのIssueでお知らせください。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v13.0.0">Release v13.0.0 · secretlint/secretlint</a></li>
<li><a href="https://github.com/secretlint/secretlint/issues">Issues · secretlint/secretlint</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>JavaScript PrimerのES2026対応を手伝ってくれるContributorとSponsorを募集しています</title>
   <link href="https://efcl.info/2026/05/01/jsprimer-es2026-proposal/"/>
   <updated>2026-05-01T12:00:00+09:00</updated>
   <id>https://efcl.info/2026/05/01/jsprimer-es2026-proposal</id>
   <content type="html"><![CDATA[ <p>JavaScript Primer (<a href="https://jsprimer.net/">https://jsprimer.net/</a>) では、毎年ECMAScriptの新しい仕様への追従を行っています。</p>
<p>ES2026は2026年6月に正式リリースされる予定です。
TC39ではすでにFeature Freezeが行われ、ES2026に入る予定の機能が確定しています。</p>
<ul>
<li><a href="https://tc39.es/process-document/">TC39 Process</a></li>
</ul>
<p>今年もES2026で追加される機能についての対応Issueを作成しました。</p>
<p>これらのIssueを一緒に進めてくれるContributorと、JavaScript Primerの活動を支援してくれるSponsorを募集しています。</p>
<p>次のDiscussionにコメントをください</p>
<ul>
<li>募集しているDiscussion: <a href="https://github.com/js-primer/js-primer/discussions/1884">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<h2>ES2026対応のIssue</h2>
<p>ES2026のMeta Issueとして次のIssueがあります。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/issues/1869">ES2026の対応 · Issue #1869 · js-primer/js-primer</a></li>
</ul>
<p>具体的に対応するものとして次のIssueを作成しています。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/issues/1872">ES2026: Error.isError · Issue #1872 · js-primer/js-primer</a>
<ul>
<li>見積もり: 2 point</li>
</ul>
</li>
<li><a href="https://github.com/js-primer/js-primer/issues/1873">ES2026: Map.prototype.getOrInsert / getOrInsertComputed (Upsert) · Issue #1873 · js-primer/js-primer</a>
<ul>
<li>見積もり: 3 point</li>
</ul>
</li>
<li><a href="https://github.com/js-primer/js-primer/issues/1874">ES2026: JSON.rawJSON (JSON.parse source text access) · Issue #1874 · js-primer/js-primer</a>
<ul>
<li>見積もり: 2 point</li>
</ul>
</li>
<li><a href="https://github.com/js-primer/js-primer/issues/1875">ES2026: Iterator.concat (Iterator Sequencing) · Issue #1875 · js-primer/js-primer</a>
<ul>
<li>見積もり: 2 point</li>
</ul>
</li>
</ul>
<p>各Issueには、作業量の見積もりとして<code>point</code>を付与しています。(これは感覚値なのであんまり正確ではないです。実際にやってみたら変わる可能性もあります)
この<code>point</code>は、作業の難易度や必要な調査量などを考慮して設定していて、後述するOpen Collectiveでの報酬計算にも利用します。</p>
<p><code>point</code>の目安は以下の通りです。これは作業時間ではなく、タスクの複雑さや規模を表す指標です。
例えば2 pointは「1日あれば終わるかな」という感覚値に近いものです。</p>
<table>
<thead>
<tr>
<th>Point</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>些細な変更 (typo修正など)</td>
</tr>
<tr>
<td>1</td>
<td>2 よりは簡単</td>
</tr>
<tr>
<td>2</td>
<td>大体1日分の作業量で終わる想定</td>
</tr>
<tr>
<td>3</td>
<td>2 よりは難しい</td>
</tr>
<tr>
<td>5</td>
<td>かなり難しい、調査や広範な変更が必要</td>
</tr>
<tr>
<td>8</td>
<td>難易度がとても高く、できる人が限られるレベル</td>
</tr>
</tbody>
</table>
<p>ES2026に対応するマイルストーンは、次のページで公開しています。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/milestone/7">v8(ES2026) Milestone</a></li>
</ul>
<p>ES2026は2026年6月末ぐらいに公開される予定なので、7月ぐらいには完成させる予定です。</p>
<p>去年のES2025対応 (<a href="https://efcl.info/2025/04/25/jsprimer-es2025-proposal/">JavaScript PrimerのES2025対応を手伝ってくれるContributorとSponsorを募集しています | Web Scratch</a>) と比べると、今年のES2026は粒度が比較的均一で、1人1Issueで分担しやすいラインナップになっています。</p>
<h2>Contributorを募集しています</h2>
<p>JavaScript Primerの執筆、レビュー、サンプルコード作成、仕様調査などに興味がある方を募集しています。</p>
<p>今年のIssueは1人1Issueで分担しやすい粒度なので、それぞれのIssueに興味がある人を募集しています。</p>
<ul>
<li>募集しているDiscussion: <a href="https://github.com/js-primer/js-primer/discussions/1884">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<p>Contributeしたい人は、次のDiscussionに参加してみてください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/discussions">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<h3>Open Collectiveによる報酬</h3>
<p>JavaScript Primerは<a href="https://opencollective.com/jsprimer">Open Collective</a>を通じて、活動資金の支援を受け付けています。
Contributorとして参加していただいた方には、この予算から<a href="https://github.com/js-primer/js-primer/blob/master/CONTRIBUTING_EXPENSE.md">Contributing Expenses Policy</a>に基づき、作業量に応じた報酬を請求できます。</p>
<p>報酬額は、Issueごとに設定された<code>point</code>に基づいて計算されます。
現時点での年間予算は約$1420で、これを元に計算すると <strong>1 pointあたり約$23</strong> となります。</p>
<p>なお、報酬は自分で受け取るほかに、<strong>他のOpen Collectiveに同じ金額を寄付する</strong>という選択肢もあります。
たとえば<a href="https://opencollective.com/babel">Babel</a>など、Open Collective上の任意のCollectiveを寄付先に指定できます。
jsprimerから直接、指定されたCollectiveへ同額が寄付される仕組みです。</p>
<p>過去のIssueに対応するpointの参考値やOpen Collectiveの利用方法、寄付先の指定方法については次のページを参照してください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/blob/master/CONTRIBUTING_EXPENSE.md">Contributing Expenses Policy</a></li>
</ul>
<h3>書き方について</h3>
<p>JavaScript Primerは技術書であるため、次の点に気をつけて書いていきます。</p>
<ul>
<li><strong>正確性</strong>: 仕様やMDN、信頼できる情報源を元に、矛盾のない正確な記述をします。</li>
<li><strong>読みやすさ</strong>: 読者が理解しやすいように、平易な言葉遣いや構成を意識しますが、<a href="https://textlint.org/">textlint</a>のチェックがあるのである程度強制されます。
<ul>
<li>LLMの利用自体は問題ありませんが、最終的な品質は人間が読みやすかどうかで判断します</li>
</ul>
</li>
<li><strong>サンプルコード</strong>: ユースケースに基づいた、実践的で理解しやすいサンプルコードを扱います。なぜそのコードが必要なのか、どのような場面で役立つのかが伝わるように意識します。
<ul>
<li>実際に使われているパターンなどをもとにサンプルコードを書きます</li>
</ul>
</li>
<li><strong>目的意識</strong>: jsprimerには<a href="https://jsprimer.net/intro/">はじめに · JavaScript Primer #jsprimer</a>に書いているように、本書の目的と目的ではないことが書かれています
<ul>
<li>毎年悩むのは「どこまで書くか」ということですが、悩んだ時は本書の目的に立ち返って判断します</li>
</ul>
</li>
</ul>
<p>実際に書籍を書くときには、<a href="https://textlint.org/">textlint</a>による文章のチェックやレビューやサンプルコードに対するテストの仕組みなどもあるので、文章ですがコードを書くような感覚で書いていくのが良いと思います。</p>
<p>詳しい書き方やルールについては、次のドキュメントを参照してください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/blob/master/CONTRIBUTING.md">Contribution Guide</a></li>
</ul>
<h2>参加方法</h2>
<p>Contributorとして参加してみたい方は、次のDiscussionにコメントしてみてください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/discussions/1884">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<p>ご興味のある方、ぜひ参加してみてください！</p>
<h2>Sponsorを募集しています</h2>
<p>JavaScript Primerの活動は、個人や企業のSponsorからの支援によって支えられています。
書籍の継続的なメンテナンスや改善活動を支援してくださるSponsorを随時募集しています！</p>
<p>現在のGold Sponsorは次の通りです。ご支援ありがとうございます！</p>
<p><strong>Gold Sponsors</strong></p>
<p><a href="https://being-i.sh/" title="being-ish Inc."><img src="https://images.opencollective.com/being-ish/66099a1/logo/256.png?height=166" height="166" alt="" loading="lazy"></a></p>
<ul>
<li><a href="https://being-i.sh/">being-ish Inc.</a></li>
</ul>
<p><strong>Supporters</strong></p>
<p><a href="https://opencollective.com/jsprimer#backers"><img src="https://opencollective.com/jsprimer/backers.svg?width=890&amp;avatarHeight=40" alt="jsprimer backers" /></a></p>
<p>jsprimerの更新を金銭的にサポートしたいという方は、是非検討してみてください！</p>
<p>詳細は<a href="https://jsprimer.net/intro/sponsors/">JavaScript Primerスポンサー · JavaScript Primer #jsprimer</a>をご覧ください。</p>
<ul>
<li><a href="https://opencollective.com/jsprimer">JavaScript Primer - Open Collective</a></li>
<li><a href="https://jsprimer.net/intro/sponsors/">JavaScript Primerスポンサー · JavaScript Primer #jsprimer</a></li>
</ul>
<h3>参考: 前回までの募集</h3>
<ul>
<li><a href="https://efcl.info/2025/04/25/jsprimer-es2025-proposal/">JavaScript PrimerのES2025対応を手伝ってくれるContributorとSponsorを募集しています | Web Scratch</a></li>
<li><a href="https://efcl.info/2024/03/21/jsprimer-es2024-proposal/">JavaScript PrimerのES2024対応を手伝ってくれるContributorとSponsorを募集しています | Web Scratch</a></li>
<li><a href="https://github.com/asciidwango/js-primer/discussions/1789">ES2025に対応するIssueへのContributorを募集しています · asciidwango/js-primer · Discussion #1789</a></li>
<li><a href="https://github.com/asciidwango/js-primer/discussions/1727">ES2024に対応するIssueへのContributorを募集しています · asciidwango/js-primer · Discussion #1727</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>Secretlint v12.0.0リリース: Groq、Hugging Face、Notion、GitLab、Grafana、HashiCorp Vault、Vercel、Databricks、Docker、Figmaの検出に対応</title>
   <link href="https://efcl.info/2026/04/20/secretlint-v12/"/>
   <updated>2026-04-20T10:00:00+09:00</updated>
   <id>https://efcl.info/2026/04/20/secretlint-v12</id>
   <content type="html"><![CDATA[ <p>ソースコードや設定ファイルに含まれるAPIトークンやパスワードなどの機密情報を見つける<a href="https://github.com/secretlint/secretlint">Secretlint</a>のv12.0.0をリリースしました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v12.0.0">Release v12.0.0 · secretlint/secretlint</a></li>
</ul>
<p>このバージョンでは、次のように追加で検知できるようになったサービスが10個あります。</p>
<ul>
<li>Groq、Hugging Face、Notion、GitLab、Grafana、HashiCorp Vault、Vercel、Databricks、Docker、Figma</li>
</ul>
<p>あわせて、<code>@secretlint/secretlint-rule-preset-recommend</code>のパッケージサイズを約80%削減しています。</p>
<h2>新しく追加された検出ルール</h2>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>に、次の10個のサービスのAPIトークンなどを検出するルールが追加されました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-groq">Groq</a> - <code>gsk_</code>から始まるAPIキー</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-huggingface">Hugging Face</a> - <code>hf_</code>から始まるUser Access Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-notion">Notion</a> - <code>secret_</code>/<code>ntn_</code>から始まるIntegration Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-gitlab">GitLab</a> - Personal Access Token、Pipeline Trigger Token、Runner Registration Tokenなど</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-grafana">Grafana</a> - Service Account TokenやCloud Access Policy Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-hashicorp-vault">HashiCorp Vault</a> - <code>hvs.</code>/<code>hvb.</code>から始まるトークン</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-vercel">Vercel</a> - Access Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-databricks">Databricks</a> - <code>dapi</code>から始まるPersonal Access Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-docker">Docker</a> - <code>dckr_pat_</code>から始まるPersonal Access TokenとDocker Hub認証情報</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-figma">Figma</a> - <code>figd_</code>/<code>figu_</code>/<code>figoa_</code>などで始まるトークン</li>
</ul>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>を使っている場合は、v12.0.0にアップデートすると自動的にこれらのルールも有効になります。</p>
<p>今回追加したトークンは、<a href="https://docs.github.com/en/code-security/reference/secret-security/supported-secret-scanning-patterns">GitHubのSecret scanning partner</a>のリストを参照することで、確度の高いパターンを持つトークンに絞り込んでまとめて実装しました。
誤検知が少ないトークンフォーマットを持つサービスを優先したため、検出精度を保ったままカバー範囲を広げられています。</p>
<h2>Presetのパッケージサイズを約80%削減</h2>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>のパッケージサイズを、v11と比べて約82%削減しました。</p>
<table>
<thead>
<tr>
<th>Preset</th>
<th>v11</th>
<th>v12</th>
<th>削減率</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@secretlint/secretlint-rule-preset-recommend</code></td>
<td>1,036KB</td>
<td>187KB</td>
<td>-82%</td>
</tr>
<tr>
<td><code>@secretlint/secretlint-rule-canary</code></td>
<td>1,060KB</td>
<td>211KB</td>
<td>-80%</td>
</tr>
</tbody>
</table>
<p>10個のルールが増えたにもかかわらず、全体としては約82%縮小しています。</p>
<p>主な要因は、GCPのService Account p12ファイル検出ルール(<code>@secretlint/secretlint-rule-gcp</code>)で使っていた<code>node-forge</code>依存の置き換えです。
Web CryptoベースのPKCS#12 MAC検証実装に差し替えました。
p12ファイルの判定に必要なのはMACの検証だけなので、パース・復号処理を含む<code>node-forge</code>全体を抱える必要がなくなりました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/pull/1497">Replace node-forge with native PKCS#12 MAC verification by azu · Pull Request #1497</a></li>
</ul>
<h2>Breaking Change: Node.js 22+のサポート</h2>
<p>v12.0.0では、Node.js 20のサポートを終了し、Node.js 22以上が必要になりました。
Node.js 20は2026-04-30でActive LTSが終了するため、少し前倒しでサポートを切っています。</p>
<h2>Breaking Change: CommonJSビルドの削除</h2>
<p>v12.0.0では、各パッケージのCommonJSビルドと、CJS/ESMのdual packageサポートを削除しました。
ESMのみの配布になります。</p>
<p>Secretlint自体は<a href="https://efcl.info/2023/07/05/secretlint-v7/">v7.0.0でESMへ移行済み</a>でしたが、互換性のためCJS向けのビルドも残していました。
Node.js 22+でESMの利用が一般化したため、dual packageの保守コストを削減するためにCJSビルドを削除しています。</p>
<p>SecretlintをCLIとして利用している場合、特に影響はありません。
Node.js 22+では<a href="https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require"><code>require(esm)</code></a>がサポートされています。
そのため、<code>@secretlint/node</code>や<code>@secretlint/core</code>などを<code>require()</code>しているコードもそのまま動作します。</p>
<h2>使っているプロジェクト: SecureClipboard</h2>
<p>Secretlintを組み込んだプロジェクトの例として、先日公開した<a href="https://github.com/secretlint/secure-clipboard">SecureClipboard</a>を紹介します。</p>
<ul>
<li><a href="https://efcl.info/2026/04/19/secure-clipboard/">SecureClipboard: クリップボードに入った機密情報を自動でマスクするmacOSアプリ | Web Scratch</a></li>
</ul>
<p>SecureClipboardはクリップボードを監視するmacOSアプリです。
コピーされたテキストや画像に機密情報が含まれていた場合、自動でマスクします。
内部では<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/binary">secretlintの単一バイナリ版</a>をsubprocessとして呼び出してスキャンしています。</p>
<p>v12.0.0で追加されたGroqやHugging Face、Notion、Vercel、Docker、Figmaなどのトークンも、SecureClipboard側で追加対応なしに検出できます。
このように、secretlintをライブラリ/バイナリとして組み込む使い方でも、presetをアップデートするだけで検出対象を拡張できます。</p>
<h2>まとめ</h2>
<p>Secretlint v12.0.0では、10個のサービスに対応する新しい検出ルールを<code>@secretlint/secretlint-rule-preset-recommend</code>に追加しました。
同時にpresetのサイズを約80%削減しています。
CJSビルドの削除というBreaking Changeがあるので、ライブラリとして利用している場合はESMへの移行が必要です。</p>
<p>フィードバックがあればGitHubのIssueでお知らせください。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v12.0.0">Release v12.0.0 · secretlint/secretlint</a></li>
<li><a href="https://github.com/secretlint/secretlint/issues">Issues · secretlint/secretlint</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>SecureClipboard: クリップボードに入った機密情報を自動でマスクするmacOSアプリ</title>
   <link href="https://efcl.info/2026/04/19/secure-clipboard/"/>
   <updated>2026-04-19T11:00:00+09:00</updated>
   <id>https://efcl.info/2026/04/19/secure-clipboard</id>
   <content type="html"><![CDATA[ <p>クリップボードを監視して、機密情報やAPIトークンが入ったら自動的にマスクするmacOSアプリ <a href="https://github.com/secretlint/secure-clipboard">SecureClipboard</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/secretlint/secure-clipboard">secretlint/secure-clipboard</a></li>
</ul>
<p><img src="/wp-content/uploads/2026/secure-clipboard-menu.webp" alt="SecureClipboardのメニューバー" /></p>
<p>テキストだけでなく画像にも対応していて、スクリーンショットに写り込んだトークンなどもVision frameworkでOCRしてマスクします。
内部では<a href="https://github.com/secretlint/secretlint">secretlint</a>を使って、AWS、GitHub、Slack、GCP、Azure、npm、Dockerなどのトークンを検出します。
スキャン処理はすべてローカルで完結するmacOSアプリケーションなので、クリップボードの内容が外部に送信されることはありません。</p>
<h2>なぜ作ったか</h2>
<p>API Tokenをコピーして<code>.env</code>へ貼り付けたあと、そのトークンがクリップボード上に残り続けることがあります。
そのまま別のウィンドウで⌘+Vしてしまい、Slackのメッセージ欄やLinearのIssueタイトル、ブラウザの検索バーなどに意図せずペーストしてしまう事故が起きやすいです。
ペーストするまでクリップボードに何が入っているかは目に見えないので、気づきにくいというのも問題です。</p>
<p>SecureClipboardはコピーされた瞬間にマスクするため、誤ってペーストしても安全になります。
本物のトークンが必要なときはメニューから”Copy Original Text”を選ぶと取り出せますが、90秒後に自動でクリップボードから消えるようになっています。
これは<a href="https://support.1password.com/copy-passwords/">1Passwordのクリップボードクリア</a>と同じ仕組みです。</p>
<p>画像の場合も同様で、スクリーンショットを撮ってどこかに貼り付けるときに、意図しない映り込みを自動で防止します。
たとえばClaude Codeのターミナル画面をスクリーンショットしたときに、たまたまAPIキーや隠したいコードネームなどが含まれていた、というケースを防げます。</p>
<h2>主な機能</h2>
<p>SecureClipboardの主な機能は次のとおりです。</p>
<ul>
<li>クリップボードを監視して、自動的にマスキング</li>
<li>テキスト：<a href="https://github.com/secretlint/secretlint">secretlint</a>でスキャンして、検出した機密情報を<code>***</code>に置換</li>
<li>画像：Vision frameworkでOCRして、検出した領域だけをcrystallize + blurでマスク</li>
<li>検出時はメニューバーアイコンが赤くなり、macOSの通知を表示</li>
<li>“Copy Original Text”で生の値を取得（90秒後に自動消去）</li>
<li>マスクしたクリップボードは<a href="https://nspasteboard.org/">concealedとしてマーク</a>されるため、AlfredなどのクリップボードマネージャーやGrammarlyなどに記録されない</li>
<li><code>secure-pbpaste</code> / <code>secure-pbcopy</code> のCLIツール同梱</li>
<li>secretlintのバイナリはGitHub Releasesから自動更新</li>
</ul>
<h2>インストール</h2>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">curl <span class="nt">-fSL</span> https://github.com/secretlint/secure-clipboard/releases/latest/download/SecureClipboard.app.zip <span class="nt">-o</span> /tmp/SecureClipboard.app.zip
unzip <span class="nt">-o</span> /tmp/SecureClipboard.app.zip <span class="nt">-d</span> /Applications
xattr <span class="nt">-cr</span> /Applications/SecureClipboard.app
open /Applications/SecureClipboard.app
</code></pre></div></div>
<p>コード署名はしていないので、<code>xattr -cr</code>でquarantine属性を解除してから起動します。</p>
<p>アンインストールは次のコマンドでできます。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="nb">rm</span> <span class="nt">-rf</span> /Applications/SecureClipboard.app
<span class="nb">rm</span> <span class="nt">-f</span> /usr/local/bin/secure-pbpaste /usr/local/bin/secure-pbcopy
</code></pre></div></div>
<h2>テキストへのマスキング</h2>
<p>クリップボード上のテキストにシークレットが含まれていれば、その部分を<code>***</code>で置き換えます。</p>
<p>たとえばSlackトークンを含む次のようなテキストをコピーすると、このようになります。</p>
<!-- secretlint-disable -->
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Slack Token is xoxb-1234567890123-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx
</code></pre></div></div>
<!-- secretlint-enable -->
<p>実際にクリップボードに入るのは、トークン部分だけがマスクされた次のようなテキストになります。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Slack Token is *********************************************************
</code></pre></div></div>
<p>スクリーンショットの中にテキストとして含まれているシークレットも、Vision frameworkでOCRしてからスキャンするため、同じようにマスクされます。
たとえば下のスクリーンショットでは、cmuxの通知メニューに含まれていたユーザー情報がOCR経由で検出され、その矩形領域だけがblurされています。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-text-mask.webp" alt="テキストマスクの例" /></p>
<h2>画像へのマスキング</h2>
<p>画像がクリップボードに入ると、Vision frameworkでOCRしてテキストを抽出し、検出されたシークレットの矩形領域だけにマスクをかけます。
スクリーンショットツールでクリップボードに保存するケースなどが主な対象です。</p>
<p>たとえば次のスクリーンショットには<code>xoxp-</code>から始まるSlackのUser Tokenが写り込んでいます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-image-before.webp" alt="画像マスキング Before" /></p>
<p>スクリーンショットツールでこの画像をクリップボードに保存すると、トークンが写っていた矩形領域だけにcrystallize + blur効果が自動で適用されます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-image-after.webp" alt="画像マスキング After" /></p>
<p>デフォルトでは、Vision frameworkで取得したテキストの矩形位置を使って、シークレットがあった部分だけをマスクするようになっています。
カスタムパターンで<code>&quot;action&quot;: &quot;discard&quot;</code>を指定した場合は、画像全体を警告画像に置き換えるといった挙動も選べます。</p>
<h2>“Copy Original”とConcealedClipboard</h2>
<p>マスクしたあとに本物の値が必要になることもあります。
そのときはメニューバーから”Copy Original Text”（画像なら”Copy Original Image”）を選ぶと、元の内容をクリップボードにコピーし直せます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-menu.webp" alt="SecureClipboardのメニュー" /></p>
<p>このとき、クリップボードには<a href="https://nspasteboard.org/"><code>org.nspasteboard.ConcealedType</code></a>というUTIが付与されます。
これはNSPasteboardの慣習で、「このクリップボードの内容は機密情報なので、履歴に残さないでほしい」という意思表示です。
<a href="https://github.com/secretlint/secure-clipboard/releases/tag/v1.4.0">v1.4.0</a>からこのUTIに対応しており、AlfredやGrammarlyなどnspasteboard.orgの規約に従っているクリップボードマネージャーは、この値を履歴へ保存しないようになります。</p>
<p>さらに90秒後にクリップボードから自動で消えるため、生の値が手元に残り続けることもありません。</p>
<h2>CLIツール: secure-pbpaste / secure-pbcopy</h2>
<p><a href="https://github.com/secretlint/secure-clipboard/releases/tag/v1.2.1">v1.2.1</a>から、macOS標準の<code>pbpaste</code>/<code>pbcopy</code>を置き換えるCLIツールが付属しています。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">secure-pbpaste              <span class="c"># クリップボードのテキストをマスクして出力</span>
<span class="nb">echo</span> <span class="s2">"text"</span> | secure-pbcopy <span class="c"># テキストをマスクしてからクリップボードにコピー</span>
</code></pre></div></div>
<p><code>secure-pbcopy</code>は、生のテキストを一度もクリップボードへ触れさせない設計になっています。
Unix Domain Socket経由で常駐アプリへテキストを送り、アプリ側でスキャン・マスクしてからクリップボードへ書き込みます。
通常の<code>pbcopy</code>だと、書き込んだ瞬間に他のクリップボードマネージャーが拾ってしまう可能性がありますが、<code>secure-pbcopy</code>ならその心配がありません。</p>
<p>メニューバーから”Install CLI Tools”を選ぶと、<code>/usr/local/bin/</code>にsymlinkが作成されます。</p>
<h2>設定</h2>
<p>設定ファイルは<code>~/.config/secure-clipboard/config.json</code>に置きます。
メニューから”Open config.json”でも開けます。</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="json"><span class="p">{</span><span class="w">
    </span><span class="nl">"rules"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@secretlint/secretlint-rule-preset-recommend"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"patterns"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mask-example"</span><span class="p">,</span><span class="w"> </span><span class="nl">"pattern"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/INTERNAL_</span><span class="se">\\</span><span class="s2">w+/i"</span><span class="p">,</span><span class="w"> </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mask"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"discard-example"</span><span class="p">,</span><span class="w"> </span><span class="nl">"pattern"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/CONFIDENTIAL/i"</span><span class="p">,</span><span class="w"> </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"discard"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"skipScanAppIdentifiers"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"com.1password.1password"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<h3>rules</h3>
<p>secretlintのルールを指定します。
デフォルトの<code>@secretlint/secretlint-rule-preset-recommend</code>には、AWSやGitHub、Slack、GCPなどの<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-preset-recommend#rules">検出ルール</a>が含まれています。</p>
<p>preset内の一部のルールを無効化できますが、基本的にはデフォルトのままで問題ありません。
SecureClipboardはsecretlintをpre-buildされたバイナリとして同梱しているため、独自のルールを読み込ませるような仕組みは用意していません。</p>
<h3>patterns</h3>
<p>カスタムの正規表現パターンを定義できます。<code>action</code>は次の2種類があります。</p>
<table>
<thead>
<tr>
<th></th>
<th>テキスト</th>
<th>画像</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>&quot;action&quot;: &quot;mask&quot;</code></td>
<td>マッチした部分を<code>***</code>に置換</td>
<td>マッチした矩形領域をcrystallize + blurでマスク</td>
</tr>
<tr>
<td><code>&quot;action&quot;: &quot;discard&quot;</code></td>
<td>クリップボード全体を<code>[DISCARDED: &lt;name&gt;]</code>に置換</td>
<td>画像全体を赤い警告画像に置換</td>
</tr>
</tbody>
</table>
<p>正規表現は<code>/regex/flags</code>の形式で、フラグは<code>i</code>（case-insensitive）、<code>m</code>（multiline）、<code>s</code>（dotAll）に対応しています。</p>
<p>たとえば次のように書くと、文字列に「secretlint」を含むテキスト・画像をすべてマスクできます。</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="json"><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"demo"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"pattern"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/secretlint/i"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mask"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>このパターンを有効にした状態でメニューバーをスクリーンショットに撮ると、メニュー内の<code>secretlint v11.7.1</code>というテキストもOCR経由で検出されて、その部分だけがマスクされます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-custom-pattern.webp" alt="カスタムパターンでメニュー内のテキストがマスクされた例" /></p>
<h3>skipScanAppIdentifiers</h3>
<p>Bundle Identifierを指定して、特定のアプリからのコピーをスキャン対象外にできます。
1Passwordなどのパスワードマネージャーは、コピーされる時点で意図的に取り出された値なのでスキャンを除外しておくのがよさそうです。</p>
<p>設定変更は次のクリップボード操作のタイミングで自動的に反映されるので、再起動は不要です。</p>
<h2>アーキテクチャ</h2>
<p>SecureClipboardはSwiftで書かれたネイティブのmacOSアプリです。
主要なコンポーネントは次のとおりです。</p>
<ul>
<li><code>ClipboardMonitor</code> … <code>NSPasteboard</code>をポーリングしてクリップボードの変更を検出</li>
<li><code>SecretScanner</code> … secretlintのバイナリをsubprocessで呼び出してスキャン</li>
<li><code>ImageSecretDetector</code> … Vision frameworkでOCRしてテキスト + 矩形を取得</li>
<li><code>ClipboardRewriter</code> … テキスト・画像を上書きする</li>
<li><code>IPCServer</code> … Unix Domain SocketでCLIツールからのリクエストを受ける</li>
<li><code>SecretlintUpdater</code> … GitHub Releasesからsecretlintのバイナリを自動更新</li>
</ul>
<p>secretlint本体はNode.js製のCLIですが、SecureClipboardは<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/binary">secretlintの単一バイナリ版</a>をsubprocessで呼び出しています。
そのため、ホストマシンにNode.jsがインストールされていなくても動作します。</p>
<h2>まとめ</h2>
<p>SecureClipboardは、クリップボードに入った機密情報をsecretlintで検出して自動的にマスクするmacOSアプリです。
テキストだけでなく画像にも対応していて、スクリーンショットに写り込んだトークンなどもVision frameworkでOCRしてマスクできます。</p>
<p>そもそもシークレットは生で扱わないのが基本で、自分も<a href="https://efcl.info/2023/01/31/remove-secret-from-local/">1Passwordを使って、ローカルにファイル(<code>~/.config</code>や<code>.env</code>)として置かれてる生のパスワードなどを削除した</a>りしています。
ただ、サービスから発行されたAPIキーを1Passwordに入れる場面など、どうしても一度クリップボードを経由する瞬間があります。
クリップボード自体はデフォルトでセキュアな設計とはいえないので、SecureClipboardはこの隙間を補う仕組みとして使えます。</p>
<p>また、スクリーンショットに特定の文字列が写り込んでいても目視では検知しにくいです。
カスタムパターンを使えば、スクリーンショットにうっかり写り込んでしまうコードネームのような文字列を自動的にマスクするレイヤーとしても利用できます。
Vision frameworkのOCR精度に依存するので100%ではありませんが、検出時に通知が出るので気づきやすくなり、うっかり漏洩する確率を減らせます。</p>
<hr />
<ul>
<li>GitHub: <a href="https://github.com/secretlint/secure-clipboard">secretlint/secure-clipboard</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>dockerfile-pin: DockerfileやComposeのイメージをSHA256でピン留めするCLIツールを作った</title>
   <link href="https://efcl.info/2026/04/01/dockerfile-pin/"/>
   <updated>2026-04-01T20:00:00+09:00</updated>
   <id>https://efcl.info/2026/04/01/dockerfile-pin</id>
   <content type="html"><![CDATA[ <p>DockerfileやComposeファイルのイメージ参照に<code>@sha256:&lt;digest&gt;</code>を自動で追加するCLIツール <a href="https://github.com/azu/dockerfile-pin">dockerfile-pin</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/dockerfile-pin">azu/dockerfile-pin</a></li>
</ul>
<h2>なぜ作ったか</h2>
<p><a href="https://www.aquasec.com/blog/trivy-supply-chain-attack-what-you-need-to-know/">trivyへのサプライチェーン攻撃</a>などの事件を見ていると、次に狙われるのはDocker Hubかなと思ったのがきっかけです。
CIでDocker Hubへのpushをしているケースは多いので、そこに悪意あるコードが混入する事件は今後も起きるだろうと思っています。</p>
<p>Dockerイメージのタグ（例：<code>node:20</code>）はデフォルトで可変（mutable）です。同じタグ名で中身を上書きできるため、悪意ある第三者がレジストリへのアクセスを得た場合、既存タグに対して改竄されたイメージをpushできます。</p>
<ul>
<li><a href="https://forums.docker.com/t/can-a-docker-hub-tag-have-its-content-changed/139358">Can a Docker Hub tag have its content changed? - Docker Community Forums</a></li>
</ul>
<p>Docker Hubなどのレジストリは安全とは限りません。
npmのように<a href="https://github.blog/changelog/2025-11-05-npm-security-update-classic-token-creation-disabled-and-granular-token-changes/">トークンの制限が厳しくなっていたり</a>、デフォルトでタグがimmutableな場所であっても、<a href="https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan">axiosのように問題が起きる</a>ことはあります。
Docker Hubには<a href="https://docs.docker.com/docker-hub/repos/manage/hub-images/immutable-tags/">Immutable tags</a>という機能がありますが、これはリポジトリオーナー側が設定するもので、イメージを利用する側がコントロールできるものではありません。</p>
<p><a href="https://docs.docker.com/reference/cli/docker/image/pull/#pull-an-image-by-digest-immutable-identifier"><code>@sha256:&lt;digest&gt;</code></a>を付与することで、イメージの不変性を保証できます。digestはイメージのコンテンツハッシュなので、内容が異なればdigestも変わり、改竄を検知できます。npmのlockfileがパッケージのintegrityをハッシュで固定するのと同じ考え方です。</p>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="dockerfile"><span class="c"># Before: タグのみ（可変）</span>
<span class="k">FROM</span><span class="s"> node:20.11.1</span>

<span class="c"># After: タグ + digest（不変）</span>
<span class="k">FROM</span><span class="s"> node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5</span>
</code></pre></div></div>
<p>タグとdigestを両方残す形式にしておくと便利です。タグは人間が読むため、digestは不変性の保証のために残します。<a href="https://docs.renovatebot.com/docker/">Renovate</a>はこの形式でタグとdigestの両方を更新できます。Dependabotもdigestが既に付いている場合は<a href="https://github.com/dependabot/dependabot-core/issues/14065">タグとdigestを同時に更新</a>できます。</p>
<p>Dockerfileでは明示的にSHA256 digestを指定しないとハッシュ固定ができません。これはGitHub Actionsの<code>uses:</code>をコミットSHAでpin留めしていないのと同じ状態で、サプライチェーン攻撃のリスクがあります。</p>
<p>GitHub Actionsについては<a href="https://github.com/suzuki-shunsuke/pinact">pinact</a>で自動化できますが、DockerfileのFROM行については同様のシンプルなツールがありませんでした。</p>
<h3>既存ツールが不十分だった</h3>
<p>DockerfileのSHA pinを補助する既存ツールとして<a href="https://github.com/Jille/dockpin">dockpin</a>がありますが、2023年以降メンテナンスが停滞しています。</p>
<p>また、<a href="https://github.com/hadolint/hadolint">hadolint</a>にはdigest pin強制ルールがなく（<a href="https://github.com/hadolint/hadolint/issues/773">hadolint#773</a>、2022年2月〜OPEN）、プラグイン機構もありません（<a href="https://github.com/hadolint/hadolint/issues/1001">hadolint#1001</a>）。CIでdigestのpin漏れをチェックできるシンプルなlintツールが見当たりませんでした。</p>
<p>そのため、<a href="https://github.com/suzuki-shunsuke/pinact">pinact</a>のDockerfile版をイメージして、<a href="https://github.com/google/go-containerregistry">craneライブラリ</a>（Googleが管理、メンテナンスが活発）をベースに<code>dockerfile-pin</code>として自作しました。</p>
<p>作った後に気づきましたが、<a href="https://github.com/stacklok/frizbee">frizbee</a>がGitHub ActionsとDockerの両方に対応した近いツールとして存在しています。dockerfile-pinはdigestの付与に加えて、CIで新しくdigestなしのイメージが入るのを防ぐ<code>check</code>コマンドがあるので、目的が少し異なる気がします。(おそらく似たことはできるはず?)</p>
<h2>使い方</h2>
<h3>インストール</h3>
<p>📝 主にCIとかで使いたい目的で作ったので<a href="https://aquaproj.github.io/">aqua</a>などのチェックサムをチェックしてインストールできる方法を推奨しています。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="c"># curl</span>
curl <span class="nt">-sL</span> <span class="s2">"https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_darwin_arm64.tar.gz"</span> | <span class="nb">tar </span>xz
<span class="nb">sudo mv </span>dockerfile-pin /usr/local/bin/

<span class="c"># aqua</span>
aqua generate <span class="nt">-i</span> azu/dockerfile-pin

<span class="c"># Go</span>
go <span class="nb">install </span>github.com/azu/dockerfile-pin@latest
</code></pre></div></div>
<h3><code>run</code> コマンド: digestの追加</h3>
<p><code>run --write</code>コマンドで、DockerfileやComposeファイルのイメージ参照にSHA256 digestを追加します。
デフォルトではDry-Runになっているので <code>--write</code> フラグを使うと実際にファイルを書き換えます。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="c"># ドライラン（プレビュー）</span>
dockerfile-pin run <span class="nt">-f</span> Dockerfile

<span class="c"># 実際にファイルを書き換える</span>
dockerfile-pin run <span class="nt">-f</span> Dockerfile <span class="nt">--write</span>

<span class="c"># globパターンで複数ファイルを対象にする</span>
dockerfile-pin run <span class="nt">--glob</span> <span class="s1">'**/{Dockerfile,docker-compose.yml}'</span> <span class="nt">--write</span>

<span class="c"># 引数なしだと **/{Dockerfile,Dockerfile.*,docker-compose*.yml,docker-compose*.yaml,compose.yml,compose.yaml} を対象にします</span>
<span class="c"># ドライラン</span>
dockerfile-pin run
<span class="c"># Docker関係のファイルを自動的に書き換える</span>
dockerfile-pin run <span class="nt">--write</span>
</code></pre></div></div>
<p>たとえば、次のようにタグのみの指定にdigestが追加されます。</p>
<p><strong>変換前:</strong></p>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="dockerfile"><span class="k">FROM</span><span class="s"> node:20.11.1</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">python:3.12</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
</code></pre></div></div>
<p><strong>変換後:</strong></p>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="dockerfile"><span class="k">FROM</span><span class="s"> node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">python:3.12@sha256:...</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
</code></pre></div></div>
<p>すでにdigestが付いているイメージはスキップされます。<code>--update</code>オプションをつけると既存のdigestも更新します。</p>
<h3><code>check</code> コマンド: CIでのdigest検証</h3>
<p>CIで使うことを想定した<code>check</code>コマンドもあります。チェックは2段階です。</p>
<ol>
<li><strong>構文チェック</strong>: FROM行に<code>@sha256:</code>が含まれているか</li>
<li><strong>存在チェック</strong>: 記載されたdigestがレジストリに実際に存在するか（HEADリクエストで検証）</li>
</ol>
<p>存在チェックがあることで、typoや削除済みdigestが<code>docker build</code>時まで発覚しないという問題を防げます。
HEADリクエストを使っているため、Docker Hubのpull rate limitを消費しません。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="c"># すべてのDockerfileをチェック（git ls-filesから自動検出）</span>
dockerfile-pin check

<span class="c"># 構文チェックのみ（レジストリへのアクセスなし）</span>
dockerfile-pin check <span class="nt">--syntax-only</span>

<span class="c"># JSON形式で出力</span>
dockerfile-pin check <span class="nt">--format</span> json

<span class="c"># 特定のイメージを無視</span>
dockerfile-pin check <span class="nt">--ignore-images</span> scratch
</code></pre></div></div>
<p>出力例は次のとおりです。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FAIL  Dockerfile:1    FROM node:20.11.1                missing digest
OK    Dockerfile:3    FROM python:3.12@sha256:abc123...
SKIP  Dockerfile:5    FROM scratch                     scratch image
</code></pre></div></div>
<h3>対応しているパターン</h3>
<p><strong>Dockerfile:</strong></p>
<ul>
<li><code>FROM image:tag</code> — digestを追加</li>
<li><code>FROM image:tag AS stagename</code> — <code>AS</code>付きも対応</li>
<li><code>FROM --platform=linux/amd64 image:tag</code> — <code>--platform</code>付きも対応</li>
<li><code>ARG VERSION=1.0</code> + <code>FROM image:${VERSION}</code> — ARGにデフォルト値がある場合は展開して解決</li>
<li><code>ARG BASE_IMAGE</code> + <code>FROM ${BASE_IMAGE}</code> — デフォルト値がない場合はwarningでスキップ</li>
<li><code>FROM scratch</code> — スキップ</li>
<li><code>FROM &lt;stagename&gt;</code> — マルチステージビルドの参照はスキップ</li>
<li>プライベートレジストリ（ghcr.io, GCR, ECRなど）にも対応</li>
</ul>
<p><strong>docker-compose.yml:</strong></p>
<ul>
<li><code>image: node:20</code> — digestを追加</li>
<li><code>build:</code>ディレクティブがあるサービス — スキップ</li>
</ul>
<h3>CI/CDでの利用</h3>
<p>GitHub Actionsでの利用例です。curlでインストールする方法と、<a href="https://aquaproj.github.io/">aqua</a>を使う方法があります。aquaを使うと、dockerfile-pin自体のチェックサムを検証してインストールできます。</p>
<p>なお、dockerfile-pinのリリースでは<a href="https://github.blog/changelog/2025-08-26-releases-now-support-immutability-in-public-preview/">GitHub Releases Immutability</a>を有効にしています。</p>
<p>curlでインストールする場合:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">curl -sL "https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_linux_amd64.tar.gz" | tar xz</span>
    <span class="s">sudo mv dockerfile-pin /usr/local/bin/</span>
<span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">dockerfile-pin check</span>
</code></pre></div></div>
<p>aqua経由で利用する場合は、<code>aqua.yaml</code>に <code>dockerfile-pin</code>を入れてインストールします。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">aquaproj/aqua-installer@d1fe50798dbadd4eb5b98957290ca175f6b4870f</span> <span class="c1"># v4.0.2</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">aqua_version</span><span class="pi">:</span> <span class="s">v2.57.1</span>
<span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">dockerfile-pin check</span>
</code></pre></div></div>
<p>CIで<code>dockerfile-pin check</code>を実行することで、digestが付いていないイメージをプルリクエスト時に検出できます。</p>
<h3>Renovateとの併用</h3>
<p>初回のdigest付与は<code>dockerfile-pin run --write</code>で行い、その後の継続的な更新は<a href="https://docs.renovatebot.com/">Renovate</a>に委譲します。</p>
<p>Renovateの<code>docker:pinDigests</code>プリセットを有効にすると、<code>image:tag@sha256:digest</code>形式のdigestを自動更新するPRを生成してくれます。</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="json"><span class="p">{</span><span class="w">
  </span><span class="nl">"extends"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"config:best-practices"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p><code>config:best-practices</code>に<code>docker:pinDigests</code>が含まれています。digest更新のみ自動マージしたい場合は<code>default:automergeDigest</code>も利用できます。</p>
<p>運用の流れとしては次のようになります。</p>
<ol>
<li><code>dockerfile-pin run --write</code>で既存ファイルにdigestを一括付与</li>
<li>CIに<code>dockerfile-pin check</code>を組み込み、digest未指定のFROM行がマージされないようにする</li>
<li>Renovateの<code>docker:pinDigests</code>で継続的にdigestを最新に保つ</li>
</ol>
<h2>まとめ</h2>
<p>Dockerイメージのタグはデフォルトでmutableなので、タグだけの指定ではサプライチェーン攻撃のリスクがあります。
npmのlockfileやGitHub ActionsのSHA pinと同様に、Dockerfileでも<code>@sha256:&lt;digest&gt;</code>でイメージを固定した方が良いでしょう。</p>
<p>既存ツール（dockpin、docker-lock）はメンテナンスが停滞しており、hadolintにもdigest pinのルールがありません。そのため、シンプルにpin付与とCIチェックを行う<a href="https://github.com/azu/dockerfile-pin">dockerfile-pin</a>を作りました。</p>
<ul>
<li><code>dockerfile-pin run --write</code> で既存ファイルにdigestを一括追加</li>
<li><code>dockerfile-pin check</code> でCIでdigestの付け忘れを検出</li>
<li>Renovateと組み合わせて継続的にdigestを最新に保つ</li>
</ul>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/azu/dockerfile-pin">azu/dockerfile-pin</a></li>
<li><a href="https://github.com/suzuki-shunsuke/pinact">suzuki-shunsuke/pinact</a></li>
<li><a href="https://github.com/google/go-containerregistry">google/go-containerregistry</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>design-loop: Claude Codeを使ったブラウザベースのビジュアル編集ツール</title>
   <link href="https://efcl.info/2026/02/22/design-loop/"/>
   <updated>2026-02-22T09:00:00+09:00</updated>
   <id>https://efcl.info/2026/02/22/design-loop</id>
   <content type="html"><![CDATA[ <p>Claude Codeを使ったブラウザベースのビジュアル編集ツールとして、<a href="https://github.com/azu/design-loop">design-loop</a>を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/design-loop">https://github.com/azu/design-loop</a></li>
</ul>
<p><img src="/wp-content/uploads/2026/02/design-loop-screenshot.png" alt="design-loopのスクリーンショット" /></p>
<p>左パネルに開発中のサイトのプレビュー、右パネルにClaude Codeのターミナルが表示されます。プレビュー上の要素をクリックすると、その要素のコンポーネント情報やスタイルがClaude Codeに渡されます。</p>
<h2>インストール</h2>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/azu/design-loop/main/install.sh | sh
</code></pre></div></div>
<h2>使い方</h2>
<p>ソースコードがあるディレクトリで、開発サーバーがすでに起動している場合は、<code>--url</code>でURLを指定するだけで起動できます。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">design-loop <span class="nt">--url</span> http://localhost:3000
</code></pre></div></div>
<p><code>--command</code>オプションを使うと、開発サーバーの起動もdesign-loopに任せられます。開発サーバーが起動してからプロキシが自動的に接続します。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">design-loop <span class="nt">--url</span> http://localhost:3000 <span class="nt">--command</span> <span class="s2">"npm run dev"</span>
</code></pre></div></div>
<p>起動すると、プレビューとClaude Codeのターミナルを統合したブラウザUIが開きます。プレビュー上の要素をクリックすると、コンポーネント名やファイルパス、スタイルなどのコンテキストがClaude Codeへの指示に含まれます。</p>
<p><video src="/wp-content/uploads/2026/02/design-loop.mp4" controls muted loop playsinline width="100%"></video></p>
<h2>Design Mode</h2>
<p>v1.3.0で、ブラウザ上でページを直接編集できるDesign Modeを追加しました。</p>
<p><video src="/wp-content/uploads/2026/02/design-loop-design-mode.mp4" controls muted loop playsinline width="100%"></video></p>
<p>要素をクリックして「どこを変えたい」と伝えるだけでなく、テキストを直接書き換えたり、要素をドラッグ&amp;ドロップで並べ替えたりできます。変更はログとして蓄積され、「Apply Changes」ボタンでまとめてClaude Codeに送信します。Claude Codeが変更内容を解釈し、実際のソースコードに反映します。</p>
<ul>
<li>テキスト編集: 見出し・段落・ボタンなどのテキストをクリックして直接編集（IME対応）</li>
<li>ドラッグ&amp;ドロップ: 要素を親コンテナ内でドラッグして並び替え</li>
<li>Undo: Cmd+Z / Ctrl+Zで変更を取り消し</li>
</ul>
<p>要素の選択モード（Select Element）とDesign Modeは排他的で、同時には使えません。選択モードは「Claude Codeに何を変えるか伝える」ためのもので、Design Modeは「自分で変更してClaude Codeにコードへ反映させる」ためのものです。</p>
<h2>作った背景</h2>
<p>Claude CodeでWebサイトやアプリの見た目を調整するとき、次のような作業フローを繰り返します。</p>
<ol>
<li>ブラウザでサイトを確認</li>
<li>「このボタンのスタイルを変えたい」と感じる</li>
<li>DevToolsで要素を特定し、どのコンポーネントか調べる</li>
<li>エディタでファイルを開く</li>
<li>Claude Codeに「このコンポーネントを変更して」と指示する</li>
</ol>
<p>この行き来が繰り返されるのは、「サイトを見ている」コンテキストと「コードを指示する」コンテキストが分断されているからです。</p>
<p>指示を出す際に「どのファイルのどのコンポーネントか」を毎回説明するのは、LLMに対して本来不要なコンテキストを埋める作業です。ページ上で要素をクリックすれば自明な情報を、言葉で説明しています。</p>
<p>design-loopは、ブラウザ上でサイトを見ながら要素を選択し、その情報をそのままClaude Codeへ渡す仕組みを作ることでこの問題を解決します。</p>
<p>design-loopはエンジニア以外の人でも使えることを意識して作っています。DevToolsの知識がなくても、ブラウザ上で要素をクリックするだけでClaude Codeに指示を出せます。</p>
<h2>技術的な実装</h2>
<p>類似の課題をElectronやTauriのようなデスクトップアプリとして解決する方法もあります。しかしdesign-loopはCLI + HTTPプロキシという構成を選びました。</p>
<p>デスクトップアプリは配布が面倒です。コード署名、インストーラー、アップデート機構が必要になります。CLIツールとしてHTTPプロキシを立てる形なら、<code>curl</code>でインストールできます。また、HTTPプロキシであれば既存の開発サーバーに対して透過的に動作させられるため、フレームワーク側に手を加える必要がありません。</p>
<h3>プロキシサーバーによるHTML注入</h3>
<p>design-loopの中心はHTTPプロキシサーバーです。既存の開発サーバー（<code>http://localhost:3000</code>など）へのリクエストを中継し、HTMLレスポンスに対してスクリプトを動的に注入します。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ブラウザ → design-loopプロキシ → 開発サーバー
                ↓ HTMLにscriptを注入
ブラウザ ← design-loopプロキシ ← 開発サーバー
</code></pre></div></div>
<p>注入するスクリプトは2つです。</p>
<ol>
<li><code>WebSocket</code>をオーバーライドするスクリプト（HMR接続をプロキシ経由に書き換え）</li>
<li>要素選択やデザインモードを実現する<code>design-loop-inject.js</code></li>
</ol>
<p>HTMLの注入は、Cloudflare WorkersのHTMLRewriterに似たストリーミング方式で行っています。HTMLレスポンス全体をバッファリングせず、ストリームを流しながら2か所に挿入します。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[先頭] WebSocketオーバーライドスクリプトを書き込む
  ↓ upstreamからのHTMLをそのままストリーミング
[末尾] &lt;script src="/design-loop-inject.js"&gt; を追記
</code></pre></div></div>
<p>バッファリングしない理由は、React 18やNext.js App RouterのSSRストリーミングに対応するためです。HTMLをすべて受け取ってから書き換えると、ストリーミングレスポンスが遅延してしまいます。</p>
<p>WebSocketスクリプトをストリームの<strong>先頭に</strong>注入するのは、次のページコンテンツが読み込まれる前にHMR接続のパッチを当てる必要があるからです。タイミングが遅れると、Next.js/webpackがすでにWebSocket接続を作成した後になってしまいます。</p>
<p>WebSocketのオーバーライドにより、ViteなどのHMR接続が壊れず、ホットリロードがそのまま動作します。また、<code>X-Frame-Options</code>やCSPヘッダーを削除することで、UIサーバーのiframe内に開発サーバーのページを埋め込めるようにしています。</p>
<h3>inject-script: DOM要素のコンテキスト収集</h3>
<p>注入スクリプトの主な役割は、クリックされたDOM要素のコンテキストを収集してiframeの親フレームに<code>postMessage</code>で送ることです。</p>
<p>送信する情報は次の通りです。</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="typescript"><span class="kd">const</span> <span class="nx">info</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">selector</span><span class="p">:</span> <span class="nf">getCSSSelector</span><span class="p">(</span><span class="nx">target</span><span class="p">),</span>       <span class="c1">// CSS Selector</span>
  <span class="na">component</span><span class="p">:</span> <span class="nf">getReactComponentInfo</span><span class="p">(</span><span class="nx">target</span><span class="p">),</span> <span class="c1">// Reactコンポーネント情報</span>
  <span class="na">styles</span><span class="p">:</span> <span class="nf">getComputedStylesSummary</span><span class="p">(</span><span class="nx">target</span><span class="p">),</span> <span class="c1">// 計算済みスタイル</span>
  <span class="na">rect</span><span class="p">:</span> <span class="p">{</span> <span class="nx">top</span><span class="p">,</span> <span class="nx">left</span><span class="p">,</span> <span class="nx">width</span><span class="p">,</span> <span class="nx">height</span> <span class="p">},</span>     <span class="c1">// 要素の位置・サイズ</span>
  <span class="na">tagName</span><span class="p">:</span> <span class="nx">target</span><span class="p">.</span><span class="nx">tagName</span><span class="p">.</span><span class="nf">toLowerCase</span><span class="p">(),</span>
  <span class="na">textContent</span><span class="p">:</span> <span class="nx">target</span><span class="p">.</span><span class="nx">textContent</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">100</span><span class="p">),</span>
  <span class="na">ariaSnapshot</span><span class="p">:</span> <span class="nf">buildAriaSnapshot</span><span class="p">(</span><span class="nx">target</span><span class="p">),</span> <span class="c1">// ARIAツリー</span>
<span class="p">};</span>
</code></pre></div></div>
<h4>CSS Selector生成</h4>
<p>ID優先で、なければタグ名・クラス名・<code>:nth-child()</code>を組み合わせた階層的なセレクタを構築します。</p>
<h4>React Fiberからのコンポーネント情報</h4>
<p>DOMノードにはReactが<code>__reactFiber$xxx</code>という内部プロパティを付与しています。これを辿ることでコンポーネント名・props・<code>_debugSource</code>（ソースファイルのパスと行番号）を取得できます。</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="typescript"><span class="c1">// DOM要素からFiberを取得</span>
<span class="kd">const</span> <span class="nx">fiberKey</span> <span class="o">=</span> <span class="nb">Object</span><span class="p">.</span><span class="nf">keys</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nf">find</span><span class="p">(</span><span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">k</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">__reactFiber$</span><span class="dl">"</span><span class="p">));</span>
<span class="kd">let</span> <span class="nx">fiber</span> <span class="o">=</span> <span class="nx">el</span><span class="p">[</span><span class="nx">fiberKey</span><span class="p">];</span>

<span class="c1">// ネイティブ要素のFiberからコンポーネントFiberへ上に辿る</span>
<span class="nf">while </span><span class="p">(</span><span class="nx">fiber</span> <span class="o">&amp;&amp;</span> <span class="k">typeof</span> <span class="nx">fiber</span><span class="p">.</span><span class="kd">type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">string</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">fiber</span> <span class="o">=</span> <span class="nx">fiber</span><span class="p">.</span><span class="k">return</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// fiber.type.displayName or fiber.type.name → コンポーネント名</span>
<span class="c1">// fiber._debugSource → { fileName, lineNumber, columnNumber }</span>
</code></pre></div></div>
<p>この<code>__reactFiber$</code>を使ったアプローチは、ブラウザ上のReactアプリを解析する手法としてよく使われています。Claude Code DesktopのPreview機能も同様にReact Fiberからコンポーネント情報を取得しています。FigmaのClaude Code連携機能（<a href="https://www.figma.com/blog/introducing-claude-code-to-figma/">From Claude Code to Figma</a>）でも、ページに<code>capture.js</code>を注入して同様の処理をしています。</p>
<h4>ARIAスナップショット</h4>
<p>選択要素の周辺（ランドマーク境界まで最大5階層）のARIAツリーを文字列化します。テキストのみでページの構造的なコンテキストをLLMへ渡すためのアプローチです。スクリーンショットのようにピクセルではなく、セマンティクスを伝えます。</p>
<h3>BunのトランスパイラでTypeScriptをブラウザに直接配信</h3>
<p><code>inject-script.ts</code>はTypeScriptで書かれていますが、サーバーサイドでのビルドステップは設けていません。代わりにBunの組み込みトランスパイラを使い、リクエスト時にその場でJavaScriptへ変換してブラウザに返しています。</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="typescript"><span class="c1">// src/proxy/inject.ts</span>
<span class="k">import</span> <span class="nx">injectScriptSource</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">../ui/inject-script.ts</span><span class="dl">"</span> <span class="kd">with</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">file</span><span class="dl">"</span> <span class="p">};</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">getInjectScript</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">source</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Bun</span><span class="p">.</span><span class="nf">file</span><span class="p">(</span><span class="nx">injectScriptSource</span><span class="p">).</span><span class="nf">text</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">transpiler</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Bun</span><span class="p">.</span><span class="nc">Transpiler</span><span class="p">({</span> <span class="na">loader</span><span class="p">:</span> <span class="dl">"</span><span class="s2">ts</span><span class="dl">"</span> <span class="p">});</span>
  <span class="k">return</span> <span class="nx">transpiler</span><span class="p">.</span><span class="nf">transformSync</span><span class="p">(</span><span class="nx">source</span><span class="p">);</span> <span class="c1">// 型を除去してJSを返す</span>
<span class="p">}</span>
</code></pre></div></div>
<p><code>with { type: &quot;file&quot; }</code>というBun固有のimport attributeでファイルパスを取得し、<code>Bun.Transpiler</code>で変換します。esbuildやrollupは不要で、Bunランタイムだけで完結します。</p>
<p>Bunはランタイムとバンドラーが統合されているため、このようなツール開発に向いています。TypeScriptをそのまま実行でき、必要なら<code>Bun.Transpiler</code>でブラウザ向けのJSを生成でき、PTYやHTTPサーバーも標準APIとして使えます。</p>
<p>同じ方向として、<a href="https://blackboard.sh/blog/electrobun-v1/">Electrobun v1</a>もBunをベースにしたデスクトップアプリフレームワークです。ElectronのようにWebViewとバックエンドを統合しますが、Bunのエコシステムに乗ることで「外部ツールなしにTypeScriptとネイティブAPIを組み合わせられる」点が共通しています。こういうツールを作る際の選択肢になります（今回は使っていません）。</p>
<h3>PTYとGhostty Webでブラウザにターミナルを統合</h3>
<p>Claude CodeのプロセスはBunのPTY APIを使って起動します。Node.jsでPTYを扱うには<a href="https://github.com/microsoft/node-pty">node-pty</a>がありますが、ネイティブモジュールのためビルドが必要で、配布時に面倒が生じます。BunはPTYを<code>Bun.Terminal</code>としてランタイムに組み込んでいるため、追加のネイティブモジュールが不要です（ただし現時点ではWindows未対応）。<code>Bun.spawn</code>にterminalオプションを渡すだけでPTYにアタッチできます。</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="typescript"><span class="kd">const</span> <span class="nx">terminal</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Bun</span><span class="p">.</span><span class="nc">Terminal</span><span class="p">({</span>
  <span class="nx">cols</span><span class="p">,</span> <span class="nx">rows</span><span class="p">,</span>
  <span class="nf">data</span><span class="p">(</span><span class="nx">_term</span><span class="p">,</span> <span class="nx">data</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// PTYの出力をWebSocket接続しているすべてのブラウザに配信</span>
    <span class="nf">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">ws</span> <span class="k">of</span> <span class="nx">connections</span><span class="p">)</span> <span class="nx">ws</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="nx">data</span><span class="p">);</span>
  <span class="p">},</span>
<span class="p">});</span>

<span class="nx">Bun</span><span class="p">.</span><span class="nf">spawn</span><span class="p">([</span><span class="nx">shell</span><span class="p">,</span> <span class="dl">"</span><span class="s2">-l</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">-c</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">claude</span><span class="dl">"</span><span class="p">],</span> <span class="p">{</span> <span class="nx">terminal</span><span class="p">,</span> <span class="nx">cwd</span> <span class="p">});</span>
</code></pre></div></div>
<p>ブラウザ側のターミナルエミュレータには<a href="https://github.com/ghostty-org/ghostty">ghostty-web</a>を使っています。GhosttyはGPUレンダリングを使った高性能なターミナルアプリで、そのWebAssembly版（ghostty-web）をxterm.jsの代わりに採用しています。</p>
<p>再接続時のため、128KBのリングバッファでPTYの出力を保持しています。ブラウザがリロードされてもターミナルの表示内容を再生できます。</p>
<h3>Design Mode: GUIの変更をコマンドにしてバッチで送る</h3>
<p>Design Modeの設計上のポイントは、GUIでの変更をどうやってClaude Codeに伝えるかです。変更を構造化されたコマンドとして蓄積し、バッチでClaude Codeに送る方式を採用しています。</p>
<p>変更の種類は<code>text-edit</code>（テキスト書き換え）と<code>move</code>（要素の移動）の2つです。iframe内の注入スクリプトが変更を検出すると、<code>postMessage</code>で親フレームに通知します。</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="typescript"><span class="c1">// テキスト編集の変更</span>
<span class="p">{</span> <span class="nl">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">text-edit</span><span class="dl">"</span><span class="p">,</span> <span class="nx">selector</span><span class="p">:</span> <span class="dl">"</span><span class="s2">h1.title</span><span class="dl">"</span><span class="p">,</span> <span class="nx">before</span><span class="p">:</span> <span class="dl">"</span><span class="s2">旧タイトル</span><span class="dl">"</span><span class="p">,</span> <span class="nx">after</span><span class="p">:</span> <span class="dl">"</span><span class="s2">新タイトル</span><span class="dl">"</span> <span class="p">}</span>
<span class="c1">// 要素の移動</span>
<span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">move</span><span class="dl">"</span><span class="p">,</span> <span class="na">selector</span><span class="p">:</span> <span class="dl">"</span><span class="s2">.card</span><span class="dl">"</span><span class="p">,</span> <span class="na">oldIndex</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="na">newIndex</span><span class="p">:</span> <span class="mi">0</span> <span class="p">}</span>
</code></pre></div></div>
<p>「Apply Changes」を押すと、蓄積された変更に正規化処理が走ります。同じ要素への複数回の編集は最初のbeforeと最後のafterに圧縮され、元の位置に戻った移動は削除されます。正規化後、変更をテキストにフォーマットしてClaude Codeに送信します。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Design Mode Changes]
1. Text edited
   selector: h1.title (React: Header - src/components/Header.tsx:12)
   before: "旧タイトル"
   after: "新タイトル"
2. Element reordered
   selector: .card (React: ProductCard)
   moved from: .grid, index 2
   moved to: .grid, index 0
</code></pre></div></div>
<p>このテキストをユーザーの指示と一緒にClaude Codeへ渡すことで、変更内容を解釈してソースコードを書き換えます。</p>
<h2>Claude Code DesktopのPreview機能との類似点と違い</h2>
<p>2026年2月、Claude Code DesktopがPreview機能を追加しました。</p>
<ul>
<li><a href="https://code.claude.com/docs/en/desktop#preview-your-app">Use Claude Code Desktop - Preview your app</a></li>
</ul>
<p>Preview機能では、プレビュー画面で要素を選択するとスクリーンショット・React情報・DOMの情報がClaude Codeに渡されます。design-loopを公開した後にこのPreview機能が公開されましたが、DOM/React Fiberの情報を収集してClaude Codeに渡すというアーキテクチャはほぼ同じでした。同じ課題を解決しようとすると似たアーキテクチャになるのだと感じました。</p>
<p>設計思想の違いもあります。Claude Code DesktopのPreviewは「Claude Codeというプロンプト環境が主体で、プレビューはその補助ツール」として設計されており、プレビューのサイズ制限もあります。</p>
<p>design-loopは逆で「プレビューが主体で、Claude Codeのプロンプトは手段」という位置づけです。これはデザイナーが使うことを意識していたからです。</p>
<p>非エンジニアがAIツールを使っている様子を見ていると、複数のウィンドウやアプリを行き来すると混乱する人がとても多いです。SaaSの画面のように1つの画面で完結することに慣れていると、ファイラーやターミナルなど知らないものが同時に出てくると難しく感じます。プレビューとターミナルを1つの画面で見られること自体に価値があると、作っていて感じました。</p>
<p>Claude Code DesktopもCoworkやPreviewで同じ方向へ進んでいます。<a href="https://www.pencil.dev/">Pencil</a>や<a href="https://www.layrr.dev/">Layrr</a>のようなデザインとコード生成を統合するツールも登場しており、こうした統合ツールは今後増えていくのではないかと考えています。</p>
<p>作ってみて、Bun.Terminalとghostty-webの組み合わせでClaude CodeのUIをブラウザに持ってくるのは意外と簡単にできることがわかりました。PTYの出力をWebSocketで流してターミナルエミュレータに表示するだけなので、Claude Code向けのカスタムUIを作りたい場合の参考になれば幸いです。</p>
<hr />
<ul>
<li>GitHub: <a href="https://github.com/azu/design-loop">https://github.com/azu/design-loop</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>launchd-ui: macOSのlaunchdを使ったcron処理をGUIで管理するアプリを作った</title>
   <link href="https://efcl.info/2026/02/17/launchd-ui/"/>
   <updated>2026-02-17T12:00:00+09:00</updated>
   <id>https://efcl.info/2026/02/17/launchd-ui</id>
   <content type="html"><![CDATA[ <p>macOSのlaunchdエージェント・デーモンをGUIで管理できるアプリ <a href="https://github.com/azu/launchd-ui">launchd-ui</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/launchd-ui">azu/launchd-ui</a></li>
</ul>
<p><img src="https://raw.githubusercontent.com/azu/launchd-ui/main/screenshot.jpg" alt="launchd-uiのスクリーンショット" /></p>
<h2>なぜ作ったか</h2>
<p>macOSでcron的な定期処理をやろうとすると、launchdを使うことになります。
自分の場合は、git pullを定期的に実行してリポジトリを同期する仕組みや、<code>claude --remote</code>でClaude Codeを起動してスクリプトを実行する処理をlaunchdで管理しています。</p>
<p>たとえば、<a href="https://github.com/azu/chronixd">chronixd</a>で収集したアクティビティデータを元に、寝る前に<code>claude --remote</code>でその日の活動をまとめる処理を自動実行しています。
Claude Code on the webで実行することで、自動で処理が走りつつ、気になったことがあればスマホからでも対話的に追記できます。</p>
<p>こういった定期処理をlaunchdで管理しているのですが、launchdの操作は基本的にCLIです。
<code>launchctl load</code>や<code>launchctl unload</code>といったコマンドを毎回調べながら打つのは面倒で、今どのエージェントが動いているか、次回いつ実行されるかといった情報も確認しにくいです。</p>
<p>GUIツールは<a href="https://www.soma-zone.com/LaunchControl/">LaunchControl</a>などの有料ツールが多く、探すのも面倒だったので自分で作ることにしました。</p>
<h2>主な機能</h2>
<p>launchd-uiには次の機能があります。</p>
<ul>
<li>ユーザーエージェント（<code>~/Library/LaunchAgents/</code>）、システムエージェント、システムデーモンの一覧と検索</li>
<li>エージェントの開始・停止・再起動・即時実行（テストラン）</li>
<li>スケジュール設定と次回実行日時のプレビュー
<ul>
<li>毎日n時に実行とか、何時間ごとに実行とかができる</li>
</ul>
</li>
<li>stdout/stderrログの表示</li>
<li>plistファイルの詳細表示</li>
<li>ユーザーエージェントの作成・編集・削除</li>
<li>Finderでファイルを表示</li>
</ul>
<p>システムエージェントとデーモンは読み取り専用で、変更操作はユーザーエージェントのみに限定しています。</p>
<h2>技術スタック</h2>
<p>launchd-uiは<a href="https://tauri.app/">Tauri</a>で作っています。
バックエンドがRust、フロントエンドがReact + TypeScript + Viteという構成です。
UIにはTailwind CSS v4とshadcn/uiを使っています。</p>
<p>Tauriを選んだ理由は主に2つあります。</p>
<p>1つ目は、launchdの操作には<code>launchctl</code>コマンドの実行が必要で、バックエンドのある構成の方が都合よかったことです。
2つ目は、起動速度の速さです。launchd-uiは常駐するアプリではなく、設定を確認・変更したら閉じるタイプのツールです。起動の速さを重視しました。</p>
<h2>Vibe Codingで開発</h2>
<p>launchd-uiはClaude Codeを使って開発しました。
いわゆるVibe Codingで、コードの実装はほとんどClaude Codeに任せています。
2時間ぐらいで、まあ使えるかなぐらいのツールにはなりました。</p>
<p>ただし、コード品質のためのLintとかTestはセットアップしておくことを意識しました。
フロントエンドにはoxlintとvitest、Rustにはcargoのclippyとcargoのtestを設定しています。
リンターやテストをCI的に回せる状態にしておくことで、Vibe Codingでも一定の品質を保てます。</p>
<p>ただ、launchd自体の用語がだいぶ独特で、どのステータスがラベルとして正しいのかとかが結構難しかったです。</p>
<h2>インストール</h2>
<p>自分用のツールなので、特に署名などはしていません。</p>
<p>Apple Siliconの場合は次のコマンドでインストールできます。
コード署名していないアプリなので、<code>xattr -cr</code>でquarantine属性を解除してください。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">curl <span class="nt">-L</span> <span class="s2">"https://github.com/azu/launchd-ui/releases/latest/download/launchd-ui_aarch64.app.tar.gz"</span> | <span class="nb">tar </span>xz <span class="nt">-C</span> /Applications
xattr <span class="nt">-cr</span> /Applications/launchd-ui.app
</code></pre></div></div>
<p>Intel Macの場合は<code>aarch64</code>を<code>x64</code>に置き換えてください。
DMGインストーラーも <a href="https://github.com/azu/launchd-ui/releases">Releases</a> ページからダウンロードできます。</p>
<h2>まとめ</h2>
<p>launchd-uiは、macOSのlaunchdエージェントをGUIで管理できるシンプルなアプリです。
定期処理をlaunchdで管理していて、CLIでの操作が面倒な人に向いています。</p>
<hr />
<ul>
<li>GitHub: <a href="https://github.com/azu/launchd-ui">azu/launchd-ui</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>Photo Cleaner: iPhoneの古い写真をまとめて削除できるiOSアプリを作った</title>
   <link href="https://efcl.info/2026/01/06/photo-cleaner/"/>
   <updated>2026-01-06T12:00:00+09:00</updated>
   <id>https://efcl.info/2026/01/06/photo-cleaner</id>
   <content type="html"><![CDATA[ <p>iPhoneから古い写真をまとめて削除するためのiOSアプリ <a href="https://github.com/azu/photo-cleaner">Photo Cleaner</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/photo-cleaner">azu/photo-cleaner</a></li>
</ul>
<p>Amazon Photosなどにバックアップ済みの古い写真をiPhoneから削除して、ストレージを解放するためのアプリです。</p>
<h2>なぜ作ったか</h2>
<p>iPhoneの写真が増えすぎてストレージが足りなくなったとき、古い写真をまとめて削除したいことがあります。
しかし、標準の写真アプリでは大量の写真を効率的に選択・削除する機能がなく、一枚ずつ選択するのは現実的ではありません。</p>
<p>既存のアプリを探しても、数万枚の写真を処理できるものが見つかりませんでした。
そこで、シンプルに古い写真をまとめて削除できるアプリを作ることにしました。</p>
<p>実際に数万枚の写真を削除してみましたが、問題なく動作しています。</p>
<h2>主な機能</h2>
<p><img src="https://raw.githubusercontent.com/azu/photo-cleaner/main/docs/ss1.png" alt="Photo Cleanerのメイン画面" /></p>
<p>Photo Cleanerには次の機能があります。</p>
<ul>
<li>指定した期間より古い写真を自動で抽出して削除</li>
<li>お気に入りに設定した写真は自動的に保護</li>
<li>特定のアルバムを保護対象として設定可能</li>
<li>削除前に月ごとのコンタクトシート（まとめ画像）を生成</li>
<li>動画は削除対象から除外</li>
</ul>
<h3>コンタクトシート機能</h3>
<p><img src="https://raw.githubusercontent.com/azu/photo-cleaner/main/docs/ss4.png" alt="コンタクトシート" /></p>
<p>Photo Cleanerは実際に写真を削除する前にコンタクトシートを生成する機能があります。</p>
<p>コンタクトシートは、月ごとの写真をグリッド状にまとめた一枚の画像です。
削除してしまう前に、その月にどんな写真があったかを一覧で残しておけます。
生成したコンタクトシートは指定したアルバムに保存されるため、後から振り返ることができます。</p>
<h2>設定項目</h2>
<p><img src="https://raw.githubusercontent.com/azu/photo-cleaner/main/docs/ss2.png" alt="設定画面" /></p>
<p>次の項目を設定できます。</p>
<table>
<thead>
<tr>
<th>項目</th>
<th>デフォルト値</th>
</tr>
</thead>
<tbody>
<tr>
<td>バックアップ猶予期間</td>
<td>730日（2年）</td>
</tr>
<tr>
<td>保護対象アルバム</td>
<td>Keep</td>
</tr>
<tr>
<td>コンタクトシート生成</td>
<td>ON</td>
</tr>
<tr>
<td>コンタクトシート保存先</td>
<td>Keep</td>
</tr>
</tbody>
</table>
<h2>インストールと使い方</h2>
<p>Photo CleanerはApp Storeで配布していないため、Xcodeでビルドしてインストールする必要があります。</p>
<h3>なぜApp Storeで配布しないのか</h3>
<p><a href="https://altstore.io/">AltStore PAL</a>やSideStoreなどのサードパーティストアでの配布も検討しましたが、現状では課題が多いです。</p>
<p><a href="https://faq.altstore.io/altstore-pal/what-is-altstore-pal">AltStore PAL</a>で配布するには<a href="https://faq.altstore.io/developers/distribute-with-altstore-pal">Appleの公証（Notarization）が必要</a>です。
公証にはAppleの承認プロセスがあり、個人開発のアプリには負担が大きいです。</p>
<p>SideStoreはApple IDとパスワードを入力する必要があります。</p>
<p>今回はワンショットで使うアプリなので、サードパーティストアでの配布は一旦諦めました。</p>
<p>また、無料のApple Developerアカウントでは次の制限があります。</p>
<ul>
<li>アプリの署名が7日で期限切れになる</li>
<li>同時にインストールできるアプリは3つまで</li>
<li>週に10個までしかアプリをインストールできない</li>
</ul>
<p>これらの制限を回避するにはApple Developer Program（年間$99）への加入が必要です。
自分用のツールであれば、Xcodeから直接インストールするのが最もシンプルな方法です。</p>
<h3>必要な環境</h3>
<ul>
<li>Xcode 15以上</li>
<li>iOS 17以上</li>
<li>Apple Developer Account（無料枠で可）</li>
</ul>
<h3>セットアップ手順</h3>
<ol>
<li>リポジトリをクローンしてセットアップスクリプトを実行</li>
</ol>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">git clone https://github.com/azu/photo-cleaner.git
<span class="nb">cd </span>photo-cleaner
./setup.sh
</code></pre></div></div>
<ol start="2">
<li>XcodeでConfigurationを設定</li>
</ol>
<p><code>PhotoCleaner.xcodeproj</code>を開き、Project Settings → Info → Configurationsで、DebugとReleaseの両方に<code>LocalConfig</code>を指定します。</p>
<ol start="3">
<li>⌘Rでビルド・実行</li>
</ol>
<h2>削除の確認</h2>
<p><img src="https://raw.githubusercontent.com/azu/photo-cleaner/main/docs/ss3.png" alt="削除確認画面" /></p>
<p>削除対象の写真を確認してから削除を実行できます。
削除された写真は「最近削除した項目」に移動するため、30日以内なら復元可能です。</p>
<h2>まとめ</h2>
<p>Photo Cleanerは、iPhoneの古い写真をまとめて削除できるシンプルなアプリです。
削除前にコンタクトシートを生成することで、月ごとの写真を1枚の画像に圧縮して残せます。</p>
<hr />
<ul>
<li>GitHub: <a href="https://github.com/azu/photo-cleaner">azu/photo-cleaner</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>mubook-hon v2: EPUBビューアーをfoliate-jsに移行、Cloudflare Workersへ移行</title>
   <link href="https://efcl.info/2026/01/01/mubook-hon-v2/"/>
   <updated>2026-01-01T21:36:00+09:00</updated>
   <id>https://efcl.info/2026/01/01/mubook-hon-v2</id>
   <content type="html"><![CDATA[ <p><a href="https://github.com/azu/mubook-hon">mubook-hon</a>は、Dropboxに保存したEPUB/PDFファイルをブラウザで読めるウェブアプリです。
Notionと連携して、読書メモや進捗をNotionに記録できます。</p>
<ul>
<li>mubook-hon: <a href="https://mubook-hon.jser.workers.dev/">https://mubook-hon.jser.workers.dev/</a></li>
<li>GitHub: <a href="https://github.com/azu/mubook-hon">https://github.com/azu/mubook-hon</a></li>
</ul>
<p>URLが <code>https://mubook-hon.vercel.app/</code> から <code>https://mubook-hon.jser.workers.dev/</code> に変更されました。</p>
<p>前回の記事から約2年半が経ち、大きな変更をしたので紹介します。</p>
<ul>
<li><a href="https://efcl.info/2023/07/29/mubook-hon/">mubook-hon: Dropboxに保存したepubやPDFを読むビューア、Notionにメモや読んでいる位置を記録できるMobile/PC対応のウェブアプリ | Web Scratch</a></li>
</ul>
<h2>EPUBビューアーをBibiからfoliate-jsに移行</h2>
<p>EPUBの表示エンジンを<a href="https://bibi.epub.link/">Bibi</a>から<a href="https://github.com/johnfactotum/foliate-js">foliate-js</a>に移行しました。</p>
<ul>
<li><a href="https://github.com/azu/mubook-hon/pull/28">feat: replace Bibi with foliate-js for EPUB viewer by azu · Pull Request #28</a></li>
</ul>
<p>foliate-jsは<a href="https://johnfactotum.github.io/foliate/">Foliate</a>というLinux向けの電子書籍リーダーのJavaScript実装です。
Web Componentベースで実装されており、カスタマイズ性の高い点が特徴です。</p>
<p>Bibiと比較して、次の点が改善されました。</p>
<ul>
<li>初期化の高速化 - インクリメンタルパースにより、EPUBファイルの読み込みが高速化</li>
<li>メモリ消費の削減 - メモリ管理の改善により、メモリ使用量が減少</li>
<li>仕組みの簡素化 - Bibiの時は<a href="https://github.com/azu/mubook-hon/blob/d68d690477cca9adaf07da8ca8127921da6308b8/app/viewer/bibi-epub/BibiReader.tsx#L118-L202">MSWでService Workerプロキシを実装してBibiの求める形式/URLのパスで返す</a>必要があり複雑だった。foliate-jsではこの回り道が不要になりシンプルに</li>
</ul>
<p><img src="https://efcl.info/wp-content/uploads/2026/01/20260101-222846_optimized.jpg" alt="mubook-hon on PC" /></p>
<p><img src="https://efcl.info/wp-content/uploads/2026/01/20260101-222826_optimized.jpg" alt="mubook-hon on Mobile" /></p>
<h3>9ゾーンタップ設定</h3>
<p>画面を3x3の9ゾーンに分割し、各ゾーンにアクションを割り当てられるようになりました。</p>
<ul>
<li><a href="https://github.com/azu/mubook-hon/pull/32">feat: add customizable 9-zone tap settings for EPUB viewer by azu · Pull Request #32</a></li>
</ul>
<p>設定画面から、各ゾーンに「次ページ」「前ページ」「メニュー」「閉じる」「なし」を割り当てられます。
プリセットとして「Default」「Right Hand」「Left Hand」を用意しています。</p>
<p><img src="https://efcl.info/wp-content/uploads/2026/01/20260101-222817_optimized.jpg" alt="Tap Zones" /></p>
<h2>VercelからCloudflare Workersへ移行</h2>
<p>ホスティングをVercelからCloudflare Workersに移行しました。</p>
<ul>
<li><a href="https://github.com/azu/mubook-hon/pull/31">refactor: migrate from Vercel to Cloudflare Workers by azu · Pull Request #31</a></li>
</ul>
<p>Notion APIはCORSに対応していないため、ブラウザから直接APIを呼び出すことができず、プロキシが必要です。
VercelのServerless Functionsには<a href="https://vercel.com/docs/errors/FUNCTION_PAYLOAD_TOO_LARGE">4.5MBのBodyサイズ制限</a>があり、今後の障壁になる可能性があるためCloudflare Workersへ移行しました。
Next.jsをstatic exportで公開し、Cloudflare Workersでプロキシを実装することで、同一ドメインでシンプルな構成になりました。</p>
<h2>O’Reillyハイライトインポート</h2>
<p>Kindleに加えて、<a href="https://learning.oreilly.com/">Learning Platform - O’Reilly Japan</a>のハイライトも半分自動でインポートできるようになりました。</p>
<p>インポートページでは、コピーボタンとブックマークレットを用意しています。
ブックマークレットをブックマークバーにドラッグしておけば、ワンクリックでハイライトを抽出して、それをmubook-hon形式でNotionにインポートできます。</p>
<p>最近のOreilly Learningでは、AI翻訳で日本語で書籍が読めることが多くなったので、Oreillyのアプリで読んでそのハイライトをNotionに取り込みたい時に使っています。</p>
<ul>
<li><a href="https://learning.oreilly.com/search/?q=*&amp;rows=100&amp;language=ja&amp;order_by=created_at">Search | O’Reilly</a>: 2026-01-01時点で928冊ある</li>
</ul>
<p>ACM経由なら$174/yearで利用できるので、結構おすすめです。</p>
<ul>
<li><a href="https://zenn.dev/s3works/articles/20231128_acm-oreilly">ACM会員になるとO’Reilly本が読み放題</a></li>
<li><a href="https://note.com/papipupepujii/n/n2785fb3a56a5">ACMに入会してオライリー学習プラットフォームをちょっとお得に利用しよう！｜sekineko (papipupepujii)</a></li>
</ul>
<h2>IndexedDBのキャッシュ問題</h2>
<p>Safari/iOSでは、IndexedDBにBlobを保存すると、ページ遷移時などにBlobの破損が発生しました。この問題はiOS 17.4.1から報告されており、iOS 18でも発生していました。
mubook-honでは、SWRのキャッシュ実装でこの問題が発生したため修正しました。</p>
<ul>
<li><a href="https://developer.apple.com/forums/thread/751063">Blob URLs not Working on iOS 17.4.1 | Apple Developer Forums</a></li>
</ul>
<p>Blobオブジェクトのsizeやtypeプロパティは存在するものの、実際にデータを読み取ろうとすると壊れている状態になります。</p>
<p>対策として、BlobではなくArrayBufferを保存するように変更しました。</p>
<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="js"><span class="c1">// Before: Blobを直接保存（Safariで破損する可能性あり）</span>
<span class="k">await</span> <span class="nx">db</span><span class="p">.</span><span class="nf">put</span><span class="p">(</span><span class="dl">'</span><span class="s1">cache</span><span class="dl">'</span><span class="p">,</span> <span class="nx">blob</span><span class="p">,</span> <span class="nx">key</span><span class="p">);</span>

<span class="c1">// After: ArrayBufferとして保存</span>
<span class="kd">const</span> <span class="nx">arrayBuffer</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">blob</span><span class="p">.</span><span class="nf">arrayBuffer</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">db</span><span class="p">.</span><span class="nf">put</span><span class="p">(</span><span class="dl">'</span><span class="s1">cache</span><span class="dl">'</span><span class="p">,</span> <span class="nx">arrayBuffer</span><span class="p">,</span> <span class="nx">key</span><span class="p">);</span>

<span class="c1">// 読み出し時にBlobに変換</span>
<span class="kd">const</span> <span class="nx">arrayBuffer</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">db</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">cache</span><span class="dl">'</span><span class="p">,</span> <span class="nx">key</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">blob</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Blob</span><span class="p">([</span><span class="nx">arrayBuffer</span><span class="p">],</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">'</span><span class="s1">application/epub+zip</span><span class="dl">'</span> <span class="p">});</span>
</code></pre></div></div>
<ul>
<li><a href="https://github.com/azu/mubook-hon/commit/dc29ffe78f8cde8d6a07deb70532f7f10d83b0cb">fix: store ArrayBuffer instead of Blob in IndexedDB cache · azu/mubook-hon@dc29ffe</a></li>
</ul>
<h2>まとめ</h2>
<p>mubook-honは、Dropboxに保存したEPUB/PDFファイルをブラウザで読めるウェブアプリ かつ 読書メモの管理ツールです。
自分でもよく使っていて、EPUBをよく読むためインクリメンタルにレンダリングできる方式を求めてfoliate-jsへ移行しました。</p>
<p>飛行機などで完全オフライン対応が欲しくなるため、Service Workerでの対応を検討しています。</p>
<p>また、<a href="https://internet.watch.impress.co.jp/docs/yajiuma/2071209.html">Amazon、一部のKindle本がEPUBまたはPDF形式でのダウンロードに対応へ。2026年1月20日から</a>というニュースもあり、EPUBリーダーの需要は増えそうです。</p>
<p>書籍リーダーは7回目(mubook-honを2回作り直してる)ぐらいですが、だいぶ満足度の上がるものを作れるようになった気がします。</p>
<ul>
<li><a href="https://github.com/azu/mu-epub-reader">azu/mu-epub-reader</a></li>
<li>azu/epub-to-text: EPUBをVoiceVoxで読み上げた動画にするツール。動画にすることでバックグラウンドでも音として聴ける（Kindleの読み上げはよく止まるので作った）。ページ捲り=シークバーになるというアイデア</li>
<li><a href="https://github.com/azu/bi-epub-reader">azu/bi-epub-reader</a></li>
<li><a href="https://github.com/azu/mu-pdf-viewer">azu/mu-pdf-viewer</a></li>
<li><a href="https://github.com/azu/pdf-markdown-annotator">azu/pdf-markdown-annotator</a></li>
</ul>
<hr />
<ul>
<li>mubook-hon: <a href="https://mubook-hon.jser.workers.dev/">https://mubook-hon.jser.workers.dev/</a></li>
<li>GitHub: <a href="https://github.com/azu/mubook-hon">https://github.com/azu/mubook-hon</a></li>
<li>mubook-honのNotionテンプレート: <a href="https://efcl.notion.site/mubook-hon-addce6c324d44d749a73748f92e3a1a6">https://efcl.notion.site/mubook-hon-addce6c324d44d749a73748f92e3a1a6</a></li>
</ul>
 ]]></content>
 </entry>
 

</feed>
