sca — chatapp.lapius7.com CLIクライアント
https://chatapp.lapius7.com/ のチャット機能を、ブラウザを開かずターミナルのscaコマンドから使うためのCLI。
構成
cli/— 本体。ログイン(account.lapius7.comのSSOをブラウザ経由で利用)、ルームの一覧/作成/リネーム、REST API呼び出し全般を担当(Go標準ライブラリのみ、依存パッケージなし)realtime/— Realtime(オンライン一覧・対話チャット)専用の内部ヘルパー。Supabase RealtimeのWebSocket(Presence)プロトコルは公式Pythonパッケージ(supabase/realtime)に頼るため、この部分だけPythonで実装し、Go側からサブプロセスとして呼び出す
ユーザーが直接使うのはcli/側のバイナリ(sca)だけで、Python側はsca room who / sca room join実行時に裏で自動的に呼ばれる。
インストール
npm(推奨)
npm i -g @lapius/sca-cli
Linux / macOS(x64・arm64)/ Windows(x64)のビルド済みバイナリが入る(Go 不要、Node.js 18 以降)。
更新も同じコマンド(npm i -g @lapius/sca-cli)で行う。
sca room who / sca room join(Realtime 機能)だけは Python 3 を使う。必要な Python 環境は
初回実行時に自動で準備される。
インストーラースクリプト(Go でソースからビルド)
curl -fsSL https://raw.githubusercontent.com/Lapius7/sca-cli/main/install.sh | bash
Go が必要。Go の有無チェック・ビルド・インストール先と PATH の案内・Realtime 用の Python 環境の準備までをまとめて行う。
go install を直接叩く方法はサポートしない。
初期設定
sca login # ブラウザでaccount.lapius7.com(Lapount)にログインし、自動的にセッションを取得する
これだけで使い始められる。設定ファイルを書く必要はなく、Realtime機能(sca room who/join)に
必要なPython環境もインストーラースクリプトが自動で準備する(準備前に初めてsca room who/join
を実行した場合でも、その場で自動的にセットアップされる)。
CLIはsupabase.lapius7.comに直接繋がず、専用のリバースプロキシ
https://chatapp.lapius7.com/api/
(実体はweb/chatapp.lapius7.com/proxy/main.go、REST/Auth/Realtime
WebSocketをすべて中継する)経由で通信する。ANON_KEYの付与はこのプロキシだけが行うため、
CLI(Go/Pythonどちら側にも)はANON_KEYを一切持たない。ANON_KEY自体はRLSで保護される
前提の非秘匿な値(Web版のJSバンドルにもそのまま入っている)なので厳密には埋め込んでも
問題ないが、CLIのソースコードに一切登場しない構成にすることで「念のため」のリスクも
無くしている。
sca loginはローカルに一時HTTPサーバー(OSに選ばせたランダムなポート)を立て、まず
oauth-connect-token関数(action=mint、sca-proxy経由なのでANON_KEY不要)を呼んで
転送先(http://127.0.0.1:<port>/callback)に対応する使い捨てトークンを発行し、
https://account.lapius7.com/oauth/v2/authorize?token=<token>をターミナルに表示する
(有効期限も併記する。ブラウザは自動で開かず、ユーザー自身がクリックまたはコピーして
開く)。account.lapius7.comのSSOハンドオフでログイン後、そのローカルサーバーにトークンが
自動的に返ってくる(gh/aws等のCLIと同じ方式)。こちらのoauth-connect-token呼び出し・
/oauth/v2/authorizeアクセスはsca-proxyを経由せずaccount.lapius7.com/supabase.lapius7.comに
直接アクセスする(旧パス/oauth/authorizeはaccount.lapius7.com側で302リダイレクトされる
ので引き続き動くが、新規のリンクは/oauth/v2/authorizeで発行する)。
/oauth/v2/authorizeは一般的なOAuth認可エンドポイントの見た目に合わせた
専用パスで、chatapp.lapius7.com・post.lapius7.com・md.lapius7.com
などの既存サービスも同じ入口(と同じトークン発行の仕組み)を共有している。実際の転送先URLは
URLに直接出ず、5分で失効する使い捨てトークンの向こう側にある。
ポートを固定(旧実装は8765固定)にしなかったのは、固定ポートだと第三者が事前に
同じポートを乗っ取っておき、フィッシングリンクでログインの確認画面だけ踏ませて
セッションを奪うことが理論上可能になるため(このCLIはOSSでポート番号も公開されている)。
account.lapius7.com側は127.0.0.1の任意ポートへのハンドオフだけを特例で許可しており
(oauth-connect-token関数・sso-handoff関数・ssoRedirect.ts)、他ドメインへの緩和は
一切行っていない。
別のSupabaseインスタンスに直接向けたい場合だけ、~/.config/sca/config.envに
SUPABASE_URL/ANON_KEYを書くか同名の環境変数を設定して上書きできる(この場合は
プロキシを経由しないので、自分のインスタンスのANON_KEYを指定する必要がある)。
使い方
sca whoami # ログイン中のユーザーを表示
sca room list # 自分が作成したルームの一覧(IDも表示される)
sca room create "雑談部屋" # ルーム作成(作成後そのまま入室する)
sca room rename 5f2e...-uuid "雑談部屋2" # リネーム(自分が作成したルームのみ)
sca room delete 5f2e...-uuid # 削除(自分が作成したルームのみ、確認あり)
sca room who 5f2e...-uuid # 今そのルームにいる人を表示
sca room join 5f2e...-uuid # 入室して対話チャット開始
sca version # バージョンを表示
<room_id>はルームIDのみ指定できる(名前では入室できない)。chat_roomsは全認証済み
ユーザーがSELECT可能なRLSのため、名前検索を許すと他人のルーム名を適当に打っただけで
存在確認やIDの割り出し・入室ができてしまう問題があった。そのためsca room listは
自分が作成したルームだけを表示し、他人のルームへは作成者から/inviteで渡されたIDで
のみ参加できるようにしている。
sca room joinの対話セッション中は:
- 何か入力してEnterでメッセージ送信
/whoで現在のオンライン一覧/inviteでこのルームへの招待方法(CLIコマンド・ブラウザ用URL)を表示/quit・/leave・Ctrl+C で退室(退室してもDBには何も残らない。Web版と同じ「切断するだけ」の挙動)
設計メモ
chatスキーマには「入室中/メンバー」を表す永続テーブルは無い。入退室はRealtime Presenceチャンネル(room-<roomId>)への接続/切断だけで表現される、Web版と全く同じ仕組み- ルームの削除は作成者のみ可能(
chat.chat_roomsのRLSにcreated_by = auth.uid()のDELETEポリシーを追加済み)。削除するとメッセージもON DELETE CASCADEで一緒に消える(FK制約側で設定)。CLI(sca room delete)・Web版どちらも実行前に確認を挟む。メッセージ自体の編集/削除は引き続き未実装(RLSポリシー未対応) config.envとsession.jsonのパス・形式はGo/Python両方で共有しているので、片方だけ書き換えるとズレる点に注意(基本はGo側のsca loginだけがセッションを書く)- 複数マシンにこのリポジトリをsyncthingで同期している場合、Python venvの場所が既定(
realtime/.venv)と異なるならconfig.envにPYTHON_DIR=...を追記する - プロキシ本体(
web/chatapp.lapius7.com/proxy/、このリポジトリの外側でVPS上にpm2常駐、nginxが/api/パスをこれにproxy_pass)はnet/http/httputil.ReverseProxyだけで実装したシンプルなリバースプロキシ。REST/Auth/Realtime WebSocketいずれもsupabase.lapius7.comへの単純な中継で、apikeyヘッダーを必ず上書きする以外は何もしない。apikeyのクエリパラメータ付与は/realtime/v1/websocketパスだけに限定すること(PostgRESTは未知のクエリパラメータを列フィルタとして解釈するため、全パスに付与すると認証成功時にPGRST100エラーで壊れる。この不具合は無効なトークンでのテストでは表面化せず、実トークンで初めて発覚した) oauth-connect-token関数(web/supabase.lapius7.com/volumes/functions/oauth-connect-token/)はpublic.oauth_connect_tokensテーブル(token/redirect_to/expires_at/used_at、RLS有効・ポリシー無しでservice_role以外アクセス不可)にmint/resolveする使い捨てトークンの発行所。redirect_toの妥当性チェックはmint時にここで行う(sso-handoff関数も独立して同じチェックをしており、多層防御になっている)。このSupabaseインスタンスは全Edge FunctionにVERIFY_JWT=trueがグローバル設定されている(関数ごとの個別設定は非対応)ため、呼び出し側はapikeyとは別にAuthorization: Bearer <ANON_KEY以上の有効なJWT>も必須。sca-proxyはAuthorizationヘッダーが無い場合だけANON_KEYを補うので、CLIはここでも何も送らなくてよい/oauth/v2/authorize?token=...のtokenが不正・期限切れ・使用済みの場合、account.lapius7.comはトップページへの無言リダイレクトではなく理由付きのエラー画面(InvalidTokenScreen)を表示する- 確認画面(
AccountConfirm)では、resolveで得たexpires_atを1秒ごとに再計算するExpiryCountdownでトークンの残り有効期限をライブ表示する(resolve自体はその場で使い捨てにするため、この表示は「本来あとどれくらいで無効になる想定だったか」を示すだけで、期限が来ても以降のログイン継続自体はブロックされない) - Realtime機能のPython環境は
sca room who/join実行時に自動セットアップされる(ensurePythonReady)。インストーラースクリプトもインストール直後に同じ処理を先回りして呼ぶので、通常はユーザーが手動でセットアップを意識する場面は無い(隠しコマンドsca setupで手動再実行も可能)。Python側は常にGitHubのmainブランチHEADから取得するが、以前は一度取得したら二度と更新しなかった(requirements.txtの有無しか見ていなかった)ため、sca本体だけタグ更新しても新しいPythonコードの変更(/invite追加等)が反映されない問題があった。ビルドに埋め込まれたgo installのモジュールバージョン(runtime/debug.ReadBuildInfo)とキャッシュ側の記録を突き合わせ、ズレていたら再取得するようにしている chat.pyのon_messageは、送られてきたメッセージのsender_idが自分のuser_idと一致するかどうかだけで「CLIが送信したものだから表示しない(二重表示防止)」と判定していたが、これだと同じアカウントでブラウザからも送った場合にそのメッセージまで握り潰されてしまうバグがあった。現在はこのCLIセッションが実際に送信した内容だけを内容ベースの送信済みキュー(ChatSession._pending_own)で管理し、それ以外は自分のuser_idと一致していても表示する- 退室(
/quit等)・sca room who終了時、supabase-authのトークン自動更新タイマーやrealtimeのpushタイムアウトなど、ライブラリ内部が自分では止めない裏タスクが残ったままイベントループを閉じるとTask was destroyed but it is pending!という警告がstderrに出ることがあった(無害だが紛らわしい)。realtime.close()後にasyncio.all_tasks()で残っているタスクを明示的にcancel()してから終了するようにして解消した
既知の制約
- Presenceのキーはユーザー自身のuser_idなので、同じアカウントで複数のクライアント(CLI+ブラウザ等)を同時に開いても1人としてカウントされる(Web版と同じ仕様)
sca loginはscaを実行しているマシン自身でブラウザが開ける環境が前提(ローカルPC等)。SSH先のサーバー上でそのまま実行しても、ブラウザが手元のマシンで開いてもコールバックはSSH先に届かない。リモートで使う場合はターミナルに表示されるポート番号を確認してssh -L <そのポート>:localhost:<そのポート> ...のようにポートフォワードすること(ポートは毎回ランダムなので、ログを見てから接続する必要がある)