Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Install dependencies
bundle install

# Run all tests
bundle exec rake spec
# or
bundle exec rspec spec/

# Run a single spec file
bundle exec rspec spec/connectivity_spec.rb

# Run the CLI locally (without installing the gem)
bundle exec ruby -Ilib bin/bootic help

# Build and install the gem locally
gem build bootic_cli.gemspec && gem install pkg/bootic_cli-*.gem
```

## Architecture

This is a Ruby CLI gem built on [Thor](https://github.com/rails/thor). The entry point is `bin/bootic` → `lib/bootic_cli/cli.rb`.

### Command structure

- `BooticCli::CLI` (in `cli.rb`) — the top-level Thor class. Handles `setup`, `login`, `logout`, `check`, `runner`, `console`.
- `BooticCli::Command` (in `command.rb`) — base class for subcommand groups. Subclasses call `declare self, 'Description'` to register themselves with `CLI` as a subcommand.
- Built-in subcommands live in `lib/bootic_cli/commands/` (e.g. `themes.rb`, `orders.rb`).
- Custom user commands auto-loaded from `~/bootic/*.rb` (overridable via `BTC_CUSTOM_COMMANDS_PATH` env var).

### Auth / session

`BooticCli::Connectivity` (mixed into every command class) provides `session`, `root`, and `shop` helpers.

- `Session` wraps OAuth2 login and the `bootic_client` gem. Credentials (client_id, client_secret, access_token) are persisted via `Store`.
- `Store` uses Ruby's `PStore` at `~/.bootic/store.pstore`, namespaced by environment (`production` by default, controlled by `ENV` env var).
- All commands that require auth call `logged_in_action { ... }`, which checks both client keys and access token before yielding.

### Theme system

The most complex part of the codebase. Three theme implementations share a common interface (`templates`, `assets`, `add_template`, `remove_template`, `add_asset`, `remove_asset`):

| Class | Location | Description |
|---|---|---|
| `APITheme` | `themes/api_theme.rb` | Wraps a remote Bootic theme resource |
| `FSTheme` | `themes/fs_theme.rb` | Reads/writes from a local directory |
| `MemTheme` | `themes/mem_theme.rb` | In-memory theme, used in tests |

**Diffing:** `ThemeDiff` compares any two theme objects using `UpdatedTheme` (files that exist in both but differ by timestamp/digest) and `MissingItemsTheme` (files present in one side but absent in the other).

**Workflows:** `Themes::Workflows` orchestrates high-level operations (`pull`, `push`, `sync`, `compare`, `publish`, `watch`) using `ThemeDiff`. Asset downloads are parallelised via `WorkerPool`. Network/server errors are retried up to `MAX_ATTEMPTS` (3); if they persist, a `RetryableError` is raised and caught in the command layer with a user-facing message.

**ThemeSelector:** resolves which remote shop and theme to operate on (dev vs. public), and pairs a local directory to a shop subdomain via a `.state` file written into the theme directory.

### Environment support

Set `ENV=staging` (or any value) before running commands to use a separate credential namespace in the PStore, allowing multiple environments to coexist.
120 changes: 51 additions & 69 deletions lib/bootic_cli/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,93 +24,75 @@ def __print_version
puts "#{BooticCli::VERSION} (Ruby #{RUBY_VERSION})"
end

desc 'setup', 'Setup Bootic application credentials'
def setup
apps_host = "auth.bootic.net"
dev_app_url = "#{apps_host}/dev/cli"
desc 'login', 'Login to your Bootic account'
def login
require 'launchy'
require 'bootic_cli/local_server'

if session.setup?
input = ask "Looks like you're already set up. Do you want to re-enter your app's credentials? [n]", :magenta
if input != 'y'
say 'Thought so. You can run `bootic help` for a list of supported commands.'
if session.logged_in?
input = ask "You're already logged in. Re-authenticate? [n]", :magenta
if input.strip.downcase != 'y'
say "Already logged in! Try `bootic help`."
exit(1)
end
else
say "This CLI uses the #{bold('Bootic API')} in order to interact with your shop's data."
say "This means you need to create a Bootic app at #{bold(dev_app_url)} to access the API and use the CLI.\n"
end

input = ask "Have you created a Bootic app yet? [n]"
if input == 'y'
say "Great. Remember you can get your app's credentials at #{bold(dev_app_url)}."
else
say "Please visit https://#{bold(dev_app_url)} and hit the 'Create' button."
sleep 2
# Launchy.open(apps_url)
say ""
end
state = verifier = callback_port = nil
params = nil

if current_env != DEFAULT_ENV
auth_host = ask("Enter auth endpoint host (#{BooticClient.configuration.auth_host}):", :bold).chomp
api_root = ask("Enter API root (#{BooticClient.configuration.api_root}):", :bold).chomp
auth_host = nil if auth_host == ""
api_root = nil if api_root == ""
begin
params = BooticCli::LocalServer.wait_for_callback do |port|
callback_port = port
url, state, verifier = session.authorization_request(port: port)
say "\nOpening your browser to complete authentication...", :cyan
say "If it doesn't open automatically, visit:\n#{url}\n", :magenta
Launchy.open(url)
end
rescue BooticCli::LocalServer::NoPortAvailable => e
say e.message, :red
exit 1
rescue BooticCli::LocalServer::TimedOut
say "Authentication timed out (no response after 2 minutes). Please try again.", :red
exit 1
end

client_id = ask("Enter your application's client_id:", :bold)
client_secret = ask("Enter your application's client_secret:", :bold)

session.setup(client_id, client_secret, auth_host: auth_host, api_root: api_root)

if current_env == DEFAULT_ENV
say "Credentials stored!", :magenta
else
say "Credentials stored for #{current_env} env.", :magenta
if params['state'] != state
say "Security error: state mismatch. Please try again.", :red
exit 1
end

return if ENV['nologin']

say ""
sleep 3
login
end

desc 'login', 'Login to your Bootic account'
def login(scope = 'admin')
if !session.setup?
say "App not configured for #{options[:environment]} environment. Running setup first. You only need to do this once.", :red
invoke :setup, []
if params['error']
say "Authentication failed: #{params['error_description'] || params['error']}", :red
exit 1
end

if session.logged_in?
input = ask "Looks like you're already logged in. Do you want to redo this step? [n]", :magenta
if input != 'y'
say "That's what I thought! Try running `bootic help`."
exit(1)
end
begin
session.login_with_browser(params['code'], code_verifier: verifier, port: callback_port)
say "\nYou're now logged in!", :green
say "For a list of available commands, run `bootic help`."
rescue StandardError => e
say "Could not exchange token: #{e.message}", :red
exit 1
end
end

email = ask("Enter your Bootic email:", :bold)
pass = ask("Enter your Bootic password:", :bold, echo: false)
desc 'setup', 'Configure a custom OAuth app (advanced)'
def setup
say "Note: `bootic setup` is only needed if you want to use your own OAuth app.", :cyan
say "For normal use, just run `bootic login`.\n"

if email.strip == '' or email['@'].nil? or pass.strip == ''
say "\nPlease make sure to enter valid data.", :red
exit 1
if current_env != DEFAULT_ENV
auth_host = ask("Auth endpoint host (#{BooticClient.configuration.auth_host}):", :bold).chomp
api_root = ask("API root (#{BooticClient.configuration.api_root}):", :bold).chomp
auth_host = nil if auth_host.empty?
api_root = nil if api_root.empty?
end

say "\n\nAlrighty! Getting access token for #{email}...\n"
client_id = ask("Client ID:", :bold)
client_secret = ask("Client secret:", :bold)

begin
session.login(email, pass, scope)
say "Great success! You're now logged in as #{email} (#{scope})", :green
say "For a list of available commands, run `bootic help`."
rescue StandardError => e
say e.message, :red
if e.message['No application with client ID']
sleep 2
say "\nTry running `bootic setup` again. Or perhaps you missed the ENV variable?", :magenta
end
end
session.setup(client_id, client_secret, auth_host: auth_host, api_root: api_root)
say "Custom app credentials stored.", :magenta
end

desc 'logout', 'Log out (delete access token)'
Expand Down
88 changes: 88 additions & 0 deletions lib/bootic_cli/local_server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
require 'socket'
require 'uri'

module BooticCli
class LocalServer
CALLBACK_PORTS = (33100..33110).freeze
CALLBACK_HOST = '127.0.0.1'
CALLBACK_PATH = '/callback'
WAIT_TIMEOUT = 120 # seconds

class NoPortAvailable < StandardError; end
class TimedOut < StandardError; end

SUCCESS_HTML = <<~HTML.freeze
<html>
<head><title>Bootic CLI</title></head>
<body style="font-family:sans-serif;text-align:center;padding:3em;color:#333">
<h1 style="color:#2ecc71">&#10003; Authentication successful!</h1>
<p>You can close this tab and return to the terminal.</p>
</body>
</html>
HTML

def self.callback_url(port)
"http://localhost:#{port}#{CALLBACK_PATH}"
end

# Tries each port in CALLBACK_PORTS until one is free, starts a local HTTP
# server on it, yields the port (so the caller can open the browser), then
# blocks until the OAuth callback arrives. Returns the parsed query params.
def self.wait_for_callback(&before_wait)
port, server = bind_to_free_port
raise NoPortAvailable, "Could not bind on any port in #{CALLBACK_PORTS.first}-#{CALLBACK_PORTS.last}" unless server

before_wait.call(port) if before_wait

begin
raise TimedOut unless IO.select([server], nil, nil, WAIT_TIMEOUT)

socket = server.accept
request_line = socket.gets
drain_headers(socket)

params = parse_query(request_line)

socket.print build_response(SUCCESS_HTML)
socket.close

params
ensure
server.close rescue nil
end
end

def self.bind_to_free_port
CALLBACK_PORTS.each do |port|
server = TCPServer.new(CALLBACK_HOST, port) rescue next
return [port, server]
end
[nil, nil]
end
private_class_method :bind_to_free_port

def self.drain_headers(socket)
line = socket.gets
line = socket.gets while line && line.chomp != ''
end
private_class_method :drain_headers

def self.parse_query(request_line)
path = request_line.to_s.split(' ')[1].to_s
query = URI.parse("http://localhost#{path}").query.to_s
URI.decode_www_form(query).to_h
rescue URI::InvalidURIError
{}
end
private_class_method :parse_query

def self.build_response(body)
"HTTP/1.1 200 OK\r\n" \
"Content-Type: text/html; charset=utf-8\r\n" \
"Content-Length: #{body.bytesize}\r\n" \
"Connection: close\r\n" \
"\r\n#{body}"
end
private_class_method :build_response
end
end
Loading