Skip to content

Add GitHub CLI login flow#23

Merged
abechan1 merged 2 commits into
mainfrom
codex/implement-github-authentication-for-cli
Jun 26, 2026
Merged

Add GitHub CLI login flow#23
abechan1 merged 2 commits into
mainfrom
codex/implement-github-authentication-for-cli

Conversation

@abechan1

@abechan1 abechan1 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Provide a CLI-only GitHub authentication flow so users can run slidict login to obtain a CLI-specific access token separated from web sessions and store it at ~/.config/slidict/credentials.json.

Description

  • Add Slidict::AuthClient (lib/slidict/auth_client.rb) to request a device code and poll for a token from the Slidict CLI auth endpoints using a GitHub provider by default.
  • Add Slidict::Credentials (lib/slidict/credentials.rb) to persist a CLI access token as JSON with kind: "cli_access_token" and ensure the directory/file use private permissions (0700 for dir, 0600 for file).
  • Extend the CLI (lib/slidict/cli.rb) to recognize the login command, print activation instructions, poll the auth server with backoff/timeout handling, and save the returned token via Slidict::Credentials.
  • Wire the new classes into the library load path (lib/slidict.rb) and add specs for the login flow and credential persistence (spec/slidict/cli_spec.rb, spec/slidict/credentials_spec.rb).

Testing

  • Ran Ruby syntax checks with ruby -c for the modified files and they passed.
  • Ran the help smoke test with ruby -Ilib -e 'require "slidict"; puts Slidict::CLI.new.run(["-h"])' and it produced the updated help text.
  • Performed a smoke test for the device flow using a fake AuthClient and fake Credentials and observed successful prompt output and token save behavior.
  • Verified Slidict::Credentials#write_cli_token! writes expected JSON and the saved file has 0600 permissions, but bundle exec rspec could not be run because bundle install failed in this environment due to a rubygems.org HTTP 403 error.

Codex Task

Summary by CodeRabbit

  • 新機能
    • login コマンドを追加し、デバイスコードフローで認証を進めてCLIトークンを取得・保存できるようになりました。
    • CLI認証トークンの保存機能を追加し、既定の保存先に保護された権限で書き込みます。
  • 改善
    • 認証手順の案内表示(入力コードやログイン案内など)と、保存完了メッセージを追加しました。
    • 認証失敗時のエラーメッセージと終了コードを整備しました。
    • ヘルプ表示に login を反映しました。

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0ba60840-7bc7-4d85-a35a-d993d59d0b6c

📥 Commits

Reviewing files that changed from the base of the PR and between fe047bf and 858bad5.

📒 Files selected for processing (4)
  • lib/slidict/auth_client.rb
  • lib/slidict/cli.rb
  • lib/slidict/credentials.rb
  • spec/slidict/cli_spec.rb
🚧 Files skipped from review as they are similar to previous changes (4)
  • lib/slidict/credentials.rb
  • spec/slidict/cli_spec.rb
  • lib/slidict/auth_client.rb
  • lib/slidict/cli.rb

📝 Walkthrough

Walkthrough

login サブコマンドが追加され、デバイスコード認証でトークンを取得して保存する流れが実装されました。AuthClientCredentials が新設され、CLI は login 分岐と案内表示、ヘルプ更新に対応しています。

Changes

ログイン認証フロー

Layer / File(s) Summary
認証クライアントと保存処理
lib/slidict.rb, lib/slidict/auth_client.rb, lib/slidict/credentials.rb
Slidict::AuthClient が device code 発行と token ポーリングを行い、Slidict::Credentials が CLI トークンを JSON で保存するようになり、トップレベル読み込みも追加されました。
CLI login コマンド
lib/slidict/cli.rb, spec/slidict/cli_spec.rb, spec/slidict/credentials_spec.rb
login の引数解析、実行分岐、案内出力、トークン保存、ヘルプ更新が追加され、login 成功時とオプション誤用時の仕様テストが追加されました。

Sequence Diagram(s)

sequenceDiagram
  participant CLI as Slidict::CLI
  participant AuthClient as Slidict::AuthClient
  participant API as slidict.io
  participant Credentials as Slidict::Credentials
  CLI->>AuthClient: request_device_code
  AuthClient->>API: POST /api/cli/device/code
  API-->>AuthClient: device_code, user_code, verification_uri, interval, expires_in
  CLI->>CLI: print URL and user code
  loop until expires_in
    CLI->>AuthClient: poll_token(device_code:)
    AuthClient->>API: POST /api/cli/device/token
    API-->>AuthClient: Pending or access_token
    AuthClient-->>CLI: access_token or Pending
    CLI->>CLI: sleep(interval)
  end
  CLI->>Credentials: write_cli_token!(access_token:)
  Credentials-->>CLI: credentials.json path
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

ぴょんっと飛んだよ login の森へ 🐇
こーどを持って、耳をぴんと立て
ねむくなったら Pending でひと休み
ぽかぽか保存、credentials.json にぴたり
にんじん色の成功に、うさぎはにっこり 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed GitHub CLI の login フロー追加という主要変更を簡潔に正しく表しています。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/implement-github-authentication-for-cli

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/slidict/auth_client.rb`:
- Around line 39-40: `AuthClient` currently maps both `authorization_pending`
and `slow_down` to `Pending`, which hides the server’s backoff signal. Update
the error handling in `AuthClient` so `slow_down` raises a distinct exception
(for example `SlowDown`) while keeping `authorization_pending` as `Pending`, and
adjust `CLI#login` to rescue that new `AuthClient::SlowDown` path and increase
the retry interval before sleeping.

In `@lib/slidict/cli.rb`:
- Around line 147-162: The CLI login flow in the `slidict login` path only
rescues `AuthClient::Error` and `KeyError`, so failures from
`credentials.write_cli_token!` can escape and crash the command. Update the
rescue handling around the token save logic in `CLI#login` to also catch the
credential write failure path, print a clear “saving credentials failed” message
distinct from GitHub auth errors, and return 1 while keeping the existing
`AuthClient::Pending` retry behavior intact.

In `@lib/slidict/credentials.rb`:
- Around line 15-22: The credential directory setup in `Slidict::Credentials`
only applies `0o700` on creation via `FileUtils.mkdir_p`, so existing
directories can keep weaker permissions. After the `mkdir_p` call, explicitly
enforce the directory mode with `File.chmod` on `File.dirname(`@path`)` before
writing the JSON file, while keeping the file itself at `0o600`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3ec7490f-f55d-4dc0-aa09-604bf5e41430

📥 Commits

Reviewing files that changed from the base of the PR and between 7f2bbf6 and fe047bf.

📒 Files selected for processing (6)
  • lib/slidict.rb
  • lib/slidict/auth_client.rb
  • lib/slidict/cli.rb
  • lib/slidict/credentials.rb
  • spec/slidict/cli_spec.rb
  • spec/slidict/credentials_spec.rb

Comment thread lib/slidict/auth_client.rb Outdated
Comment on lines +39 to +40
error = response["error"].to_s
raise Pending if error == "authorization_pending" || error == "slow_down"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

slow_downauthorization_pending と同一視しないでください。

ここで両方を Pending に潰すと、CLI#login 側は常に固定間隔で再試行するしかなく、サーバーが要求した追加バックオフを守れません。slow_down が返る環境ではログインがタイムアウトしやすくなります。

修正案
 class AuthClient
   Error = Class.new(StandardError)
   Pending = Class.new(StandardError)
+  SlowDown = Class.new(StandardError)
@@
-      raise Pending if error == "authorization_pending" || error == "slow_down"
+      raise Pending if error == "authorization_pending"
+      raise SlowDown if error == "slow_down"
# lib/slidict/cli.rb
rescue AuthClient::SlowDown
  interval += 5
  `@sleeper.sleep`(interval)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
error = response["error"].to_s
raise Pending if error == "authorization_pending" || error == "slow_down"
error = response["error"].to_s
raise Pending if error == "authorization_pending"
raise SlowDown if error == "slow_down"
🧰 Tools
🪛 RuboCop (1.88.0)

[convention] 40-40: Avoid comparing a variable with multiple items in a conditional, use Array#include? instead.

(Style/MultipleComparison)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/slidict/auth_client.rb` around lines 39 - 40, `AuthClient` currently maps
both `authorization_pending` and `slow_down` to `Pending`, which hides the
server’s backoff signal. Update the error handling in `AuthClient` so
`slow_down` raises a distinct exception (for example `SlowDown`) while keeping
`authorization_pending` as `Pending`, and adjust `CLI#login` to rescue that new
`AuthClient::SlowDown` path and increase the retry interval before sleeping.

Comment thread lib/slidict/cli.rb Outdated
Comment on lines +147 to +162
path = credentials.write_cli_token!(
access_token: token.fetch("access_token"),
token_type: token.fetch("token_type", "Bearer"),
provider: token.fetch("provider", "github")
)
@output.puts "4. Saved CLI access token to #{path}"
return 0
rescue AuthClient::Pending
return login_expired if Time.now >= deadline

@sleeper.sleep(device[:interval])
end
end
rescue AuthClient::Error, KeyError => error
@output.puts "Error: GitHub login failed (#{error.message})"
1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

認証情報の保存失敗が未捕捉です。

write_cli_token! が失敗しても、ここでは AuthClient::Error / KeyError しか拾わないので slidict login がそのまま例外終了します。ホーム配下が書けないケースでも、認証エラーとは分けてメッセージを出して 1 を返したいです。

修正案
         begin
           token = client.poll_token(device_code: device[:device_code])
-          path = credentials.write_cli_token!(
-            access_token: token.fetch("access_token"),
-            token_type: token.fetch("token_type", "Bearer"),
-            provider: token.fetch("provider", "github")
-          )
+          begin
+            path = credentials.write_cli_token!(
+              access_token: token.fetch("access_token"),
+              token_type: token.fetch("token_type", "Bearer"),
+              provider: token.fetch("provider", "github")
+            )
+          rescue SystemCallError => error
+            `@output.puts` "Error: failed to save CLI access token (#{error.message})"
+            return 1
+          end
           `@output.puts` "4. Saved CLI access token to #{path}"
           return 0
         rescue AuthClient::Pending
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
path = credentials.write_cli_token!(
access_token: token.fetch("access_token"),
token_type: token.fetch("token_type", "Bearer"),
provider: token.fetch("provider", "github")
)
@output.puts "4. Saved CLI access token to #{path}"
return 0
rescue AuthClient::Pending
return login_expired if Time.now >= deadline
@sleeper.sleep(device[:interval])
end
end
rescue AuthClient::Error, KeyError => error
@output.puts "Error: GitHub login failed (#{error.message})"
1
begin
path = credentials.write_cli_token!(
access_token: token.fetch("access_token"),
token_type: token.fetch("token_type", "Bearer"),
provider: token.fetch("provider", "github")
)
rescue SystemCallError => error
`@output.puts` "Error: failed to save CLI access token (#{error.message})"
return 1
end
`@output.puts` "4. Saved CLI access token to #{path}"
return 0
rescue AuthClient::Pending
return login_expired if Time.now >= deadline
`@sleeper.sleep`(device[:interval])
end
end
rescue AuthClient::Error, KeyError => error
`@output.puts` "Error: GitHub login failed (#{error.message})"
1
🧰 Tools
🪛 GitHub Actions: Test / 1_Ruby tests (4.0).txt

[error] 157-157: RSpec failure: NoMethodError - private method `sleep' called on #<InstanceDouble(Kernel) (anonymous)> while running Slidict::CLI#login (triggered by Slidict::CLI#run).

🪛 GitHub Actions: Test / 3_Ruby tests (3.4).txt

[error] 157-157: RSpec failure in Slidict::CLI#run during GitHub CLI login device flow. NoMethodError: private method `sleep' called on #<InstanceDouble(Kernel)> (RSpec/Casting issue). Location: ./lib/slidict/cli.rb:157 in 'block in Slidict::CLI#login'.

🪛 GitHub Actions: Test / Ruby tests (3.4)

[error] 157-157: RSpec failure: NoMethodError - private method `sleep' called on #<InstanceDouble(Kernel)>. Failure occurs in Slidict::CLI#login at ./lib/slidict/cli.rb:157.

🪛 GitHub Actions: Test / Ruby tests (4.0)

[error] 157-157: RSpec failure: NoMethodError - private method sleep called on an InstanceDouble(Kernel) during Slidict::CLI#login. @sleeper.sleep(device[:interval])

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/slidict/cli.rb` around lines 147 - 162, The CLI login flow in the
`slidict login` path only rescues `AuthClient::Error` and `KeyError`, so
failures from `credentials.write_cli_token!` can escape and crash the command.
Update the rescue handling around the token save logic in `CLI#login` to also
catch the credential write failure path, print a clear “saving credentials
failed” message distinct from GitHub auth errors, and return 1 while keeping the
existing `AuthClient::Pending` retry behavior intact.

Comment on lines +15 to +22
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
File.write(@path, JSON.pretty_generate({
"access_token" => access_token,
"token_type" => token_type,
"provider" => provider,
"kind" => "cli_access_token"
}) + "\n", mode: "w", perm: 0o600)
File.chmod(0o600, @path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

ruby -e "
require 'fileutils'
require 'tmpdir'

Dir.mktmpdir do |tmpdir|
  subdir = File.join(tmpdir, 'sub')
  
  # 1. 権限 0644 で作成
  Dir.mkdir(subdir, 0o644)
  puts \"Before mkdir_p: #{subdir.lstat.mode.oct}\"
  
  # 2. mkdir_p ... mode: を実行
  FileUtils.mkdir_p(subdir, mode: 0o700)
  puts \"After mkdir_p (mode: 0o700): #{subdir.lstat.mode.oct}\"
  
  # 3. mkdir_p ... mode: で新規作成
  subdir2 = File.join(tmpdir, 'sub2')
  FileUtils.mkdir_p(subdir2, mode: 0o700)
  puts \"New directory via mkdir_p: #{subdir2.lstat.mode.oct}\"
end
"

Repository: slidict/slidict-cli

Length of output: 528


🏁 Script executed:

ruby -e "
require 'fileutils'
require 'tmpdir'

Dir.mktmpdir do |tmpdir|
  # 既存ディレクトリ作成
  subdir = File.join(tmpdir, 'sub')
  Dir.mkdir(subdir, 0o644)
  puts \"Before mkdir_p: #{FileUtils.lstat(subdir).mode & 0o7777}\"
  
  # mkdir_p実行
  FileUtils.mkdir_p(subdir, mode: 0o700)
  puts \"After mkdir_p (mode: 0o700): #{FileUtils.lstat(subdir).mode & 0o7777}\"
  
  # 明示的 chmod 実行
  File.chmod(0o700, subdir)
  puts \"After explicit chmod: #{FileUtils.lstat(subdir).mode & 0o7777}\"
end
"

Repository: slidict/slidict-cli

Length of output: 512


🏁 Script executed:

ruby -e "
require 'fileutils'
require 'tmpdir'

Dir.mktmpdir do |tmpdir|
  subdir = File.join(tmpdir, 'sub')
  Dir.mkdir(subdir, 0o644)
  puts \"Before: #{File.lstat(subdir).mode.oct}\"
  
  FileUtils.mkdir_p(subdir, mode: 0o700)
  puts \"After mkdir_p: #{File.lstat(subdir).mode.oct}\"
  
  File.chmod(0o700, subdir)
  puts \"After chmod: #{File.lstat(subdir).mode.oct}\"
end
"

Repository: slidict/slidict-cli

Length of output: 493


🏁 Script executed:

ruby -e "
require 'fileutils'
require 'tmpdir'

Dir.mktmpdir do |tmpdir|
  subdir = File.join(tmpdir, 'sub')
  
  # 1. 0644 で作成
  Dir.mkdir(subdir, 0o644)
  puts \"Before: %o\" % [File.lstat(subdir).mode]
  
  # 2. mkdir_p with mode: 0o700
  FileUtils.mkdir_p(subdir, mode: 0o700)
  puts \"After mkdir_p: %o\" % [File.lstat(subdir).mode]
  
  # 3. 明示的 chmod
  File.chmod(0o700, subdir)
  puts \"After chmod: %o\" % [File.lstat(subdir).mode]
  
  # 新規ケース確認
  subdir2 = File.join(tmpdir, 'sub2')
  FileUtils.mkdir_p(subdir2, mode: 0o700)
  puts \"New dir via mkdir_p: %o\" % [File.lstat(subdir2).mode]
end
"

Repository: slidict/slidict-cli

Length of output: 313


既存の認証ディレクトリに 0700 権限を強制できていません。

FileUtils.mkdir_p(path, mode: 0o700)mode: オプションは、ディレクトリが新規作成される場合のみ適用され、すでに存在するディレクトリには影響しません。コード実行の結果、既存ディレクトリ(例:~/.config/slidict)が別の権限(例:0644 等)で保持されるままであることが確認されました。このままだと、セキュリティ要件である「ディレクトリは 0700」の保証が崩れます。

既存ディレクトリに対しても権限を強制するには、mkdir_p の後に明示的に File.chmod を呼ぶ必要があります。

修正案
     def write_cli_token!(...)
-      FileUtils.mkdir_p(File.dirname(`@path`), mode: 0o700)
+      dir = File.dirname(`@path`)
+      FileUtils.mkdir_p(dir, mode: 0o700)
+      File.chmod(0o700, dir)
       File.write(`@path`, ...)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
File.write(@path, JSON.pretty_generate({
"access_token" => access_token,
"token_type" => token_type,
"provider" => provider,
"kind" => "cli_access_token"
}) + "\n", mode: "w", perm: 0o600)
File.chmod(0o600, @path)
dir = File.dirname(`@path`)
FileUtils.mkdir_p(dir, mode: 0o700)
File.chmod(0o700, dir)
File.write(`@path`, JSON.pretty_generate({
"access_token" => access_token,
"token_type" => token_type,
"provider" => provider,
"kind" => "cli_access_token"
}) + "\n", mode: "w", perm: 0o600)
File.chmod(0o600, `@path`)
🧰 Tools
🪛 RuboCop (1.88.0)

[convention] 16-21: Prefer string interpolation to string concatenation.

(Style/StringConcatenation)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/slidict/credentials.rb` around lines 15 - 22, The credential directory
setup in `Slidict::Credentials` only applies `0o700` on creation via
`FileUtils.mkdir_p`, so existing directories can keep weaker permissions. After
the `mkdir_p` call, explicitly enforce the directory mode with `File.chmod` on
`File.dirname(`@path`)` before writing the JSON file, while keeping the file
itself at `0o600`.

The login flow spec mocked the sleeper with instance_double(Kernel),
which models Kernel#sleep as a private instance method. Production
code calls Kernel.sleep (public module method), so the double didn't
match real behavior and rspec failed with a NoMethodError. Switch to
a plain double. Also clean up redundant begin/rescue and string
concatenation introduced in the new auth/credentials code.
@abechan1

Copy link
Copy Markdown
Contributor Author

Review notes

Reviewed the device-flow login implementation and pushed a follow-up commit (858bad5) with fixes.

Found a broken test (the important one): spec/slidict/cli_spec.rb mocked the sleeper with instance_double(Kernel, sleep: nil). Kernel#sleep is private as an instance method, so calling @sleeper.sleep(...) on that double raised NoMethodError, and bundle exec rspec failed locally. The production code calls Kernel.sleep (the public module method via the default sleeper: Kernel), so the double didn't actually match runtime behavior — it's likely this wasn't caught because bundle install failed in the original environment (HTTP 403 from rubygems.org), so the suite was never run. Fixed by swapping in a plain double("sleeper", sleep: nil). All 37 examples pass now.

Minor rubocop cleanup (scoped to the new code only, didn't touch pre-existing files):

  • Removed a redundant begin/rescue in CLI#login
  • Switched a string concatenation to interpolation in Credentials#write_cli_token!
  • Renamed rescued exception vars errore in AuthClient

Logic review: the device-code flow, polling/backoff, token persistence, and file permission handling (0700 dir / 0600 file, enforced via explicit chmod to cover the case of an existing file with looser perms) all look correct. URI.join resolves properly regardless of whether SLIDICT_AUTH_BASE_URL has a trailing path, since all endpoint paths start with /.

@abechan1 abechan1 merged commit 404ce46 into main Jun 26, 2026
5 checks passed
@abechan1 abechan1 deleted the codex/implement-github-authentication-for-cli branch June 26, 2026 13:51
This was referenced Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant