diff --git a/Cargo.lock b/Cargo.lock index a147ba5..7483b99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -629,6 +629,7 @@ dependencies = [ "tabular", "tempfile", "textwrap", + "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index ad08719..fa7e45f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ categories = ["command-line-utilities"] [features] default = ["self-update", "sync"] self-update = ["dep:self_update"] -sync = ["dep:cooklang-sync-client", "dep:uuid", "dep:tokio-util", "dep:base64", "dep:libsqlite3-sys"] +sync = ["dep:cooklang-sync-client", "dep:uuid", "dep:tokio-util", "dep:base64", "dep:libsqlite3-sys", "dep:thiserror"] [lib] name = "cookcli" @@ -66,6 +66,7 @@ textwrap = { version = "0.16", features = ["terminal_size"] } tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", optional = true } +thiserror = { version = "2", optional = true } toml = "0.9.5" tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.5", features = ["fs", "trace", "cors", "normalize-path"] } diff --git a/docs/superpowers/plans/2026-05-15-shopping-list-pantry-flags.md b/docs/superpowers/plans/2026-05-15-shopping-list-pantry-flags.md new file mode 100644 index 0000000..15d837c --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-shopping-list-pantry-flags.md @@ -0,0 +1,272 @@ +# Shopping List Pantry Flags Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `--pantry ` and `--ignore-pantry` flags to the `cook shopping-list` command so users can specify a custom pantry file or skip pantry subtraction entirely. + +**Architecture:** Two new clap arguments on `ShoppingListArgs`. Pantry resolution becomes `if args.ignore_pantry { None } else { args.pantry.or_else(|| ctx.pantry()) }`. The existing parse/subtract logic is otherwise unchanged. + +**Tech Stack:** Rust, clap 4 (derive API), `camino::Utf8PathBuf`, `cooklang::pantry::parse_lenient`. + +**Note on testing:** Per `CLAUDE.md`, this project has no automated test suite. Verification is manual using `cargo run` against the `seed/` recipes, which include a `seed/config/pantry.conf`. + +--- + +## File Structure + +Only one source file is modified: + +- Modify: `src/shopping_list.rs` — add two `ShoppingListArgs` fields and rewrite the pantry-loading block to honor them. + +No new files. No changes to other modules: `Context::pantry()` (`src/main.rs:104`) is still used as the auto-discovery fallback, and `list.subtract_pantry(...)` at `src/shopping_list.rs:264-267` is unchanged. + +--- + +### Task 1: Add `--pantry` and `--ignore-pantry` args and wire them into pantry resolution + +**Files:** +- Modify: `src/shopping_list.rs:98-104` (add new args next to existing `--aisle` / `--ignore-references`) +- Modify: `src/shopping_list.rs:198-233` (rewrite pantry loading block) + +- [ ] **Step 1: Add the two new clap fields to `ShoppingListArgs`** + +In `src/shopping_list.rs`, locate this block (around lines 98-104): + +```rust + /// Load aisle conf file + #[arg(short, long)] + aisle: Option, + + /// Don't expand referenced recipes + #[arg(short, long)] + ignore_references: bool, +``` + +Replace it with: + +```rust + /// Load aisle conf file + #[arg(short, long)] + aisle: Option, + + /// Load pantry conf file + #[arg(long)] + pantry: Option, + + /// Don't expand referenced recipes + #[arg(short, long)] + ignore_references: bool, + + /// Don't subtract pantry items from the shopping list + #[arg(long)] + ignore_pantry: bool, +``` + +Notes: +- `--pantry` is intentionally long-only. A short `-p` would collide with the existing `-p` / `--plain` flag. +- `--ignore-pantry` mirrors the naming of the existing `--ignore-references` flag. + +- [ ] **Step 2: Rewrite the pantry loading block to honor the new flags** + +In `src/shopping_list.rs`, locate this block (lines 198-233): + +```rust + // Load pantry configuration if available + let pantry_path = ctx.pantry(); + let pantry = if let Some(path) = &pantry_path { + match std::fs::read_to_string(path) { + Ok(content) => { + tracing::debug!("Loading pantry from: {}", path); + let result = cooklang::pantry::parse_lenient(&content); + + // Check if there are any warnings to display + if result.report().has_warnings() { + for warning in result.report().warnings() { + warn!("Pantry configuration warning: {}", warning); + } + } + + let mut pantry_conf = result.output().cloned(); + if let Some(ref mut pantry) = pantry_conf { + pantry.rebuild_index(); + tracing::debug!( + "Pantry loaded successfully with {} sections", + pantry.sections.len() + ); + } else { + tracing::warn!("Failed to parse pantry file"); + } + pantry_conf + } + Err(e) => { + warn!("Failed to read pantry file: {}", e); + None + } + } + } else { + tracing::debug!("No pantry file found"); + None + }; +``` + +Replace it with: + +```rust + // Resolve pantry path: --ignore-pantry skips entirely; otherwise prefer + // --pantry, falling back to ctx.pantry() auto-discovery. + let pantry_path = if args.ignore_pantry { + tracing::debug!("Pantry ignored via --ignore-pantry"); + None + } else { + args.pantry.clone().or_else(|| ctx.pantry()) + }; + + let pantry = if let Some(path) = &pantry_path { + match std::fs::read_to_string(path) { + Ok(content) => { + tracing::debug!("Loading pantry from: {}", path); + let result = cooklang::pantry::parse_lenient(&content); + + // Check if there are any warnings to display + if result.report().has_warnings() { + for warning in result.report().warnings() { + warn!("Pantry configuration warning: {}", warning); + } + } + + let mut pantry_conf = result.output().cloned(); + if let Some(ref mut pantry) = pantry_conf { + pantry.rebuild_index(); + tracing::debug!( + "Pantry loaded successfully with {} sections", + pantry.sections.len() + ); + } else { + tracing::warn!("Failed to parse pantry file"); + } + pantry_conf + } + Err(e) => { + warn!("Failed to read pantry file: {}", e); + None + } + } + } else { + tracing::debug!("No pantry file found"); + None + }; +``` + +The only changes are: +1. The initial `pantry_path` binding now branches on `args.ignore_pantry`. +2. When not ignoring, `args.pantry.clone()` is preferred over `ctx.pantry()`. `.clone()` is required because `args` is consumed later by `args.output`, `args.ingredients_only`, etc. + +The rest of the function (subtract call at the previous line ~264, output formatting) is unchanged. + +- [ ] **Step 3: Run formatting and linting** + +```bash +cargo fmt +cargo clippy --all-targets -- -D warnings +``` + +Expected: both succeed with no output / no warnings. + +- [ ] **Step 4: Run the build** + +```bash +cargo build +``` + +Expected: clean build, no warnings. + +- [ ] **Step 5: Commit** + +```bash +git add src/shopping_list.rs +git commit -m "feat(shopping-list): add --pantry and --ignore-pantry flags" +``` + +--- + +### Task 2: Manual verification against seed recipes + +**Files:** (no source changes — manual exercise only) +- Read: `seed/config/pantry.conf` (contains `butter`, `milk`, `eggs`, `flour`, etc.) +- Read: `seed/Breakfast/Easy Pancakes.cook` (uses some of the pantry ingredients) + +- [ ] **Step 1: Baseline — confirm default behavior still subtracts pantry** + +```bash +cargo run --quiet -- shopping-list --base-path ./seed "Breakfast/Easy Pancakes.cook" +``` + +Expected: a categorized shopping list. Ingredients also present in `seed/config/pantry.conf` with non-zero quantity (e.g. `flour`, `milk`, `eggs`, `butter`) should be absent or reduced in the output. Note which ingredients appear / are reduced — you'll compare against the next runs. + +- [ ] **Step 2: `--ignore-pantry` produces the unfiltered list** + +```bash +cargo run --quiet -- shopping-list --base-path ./seed --ignore-pantry "Breakfast/Easy Pancakes.cook" +``` + +Expected: same recipe, but the ingredients suppressed in Step 1 (e.g. `flour`, `milk`, `eggs`, `butter`) now appear at their full recipe quantities. Confirm at least one ingredient that was missing in Step 1 is present here. + +- [ ] **Step 3: `--pantry ` uses the custom file** + +Create a minimal temporary pantry file that subtracts only one ingredient: + +```bash +cat > /tmp/test-pantry.conf <<'EOF' +[pantry] +flour = { quantity = "1%kg" } +EOF +``` + +Then run: + +```bash +cargo run --quiet -- shopping-list --base-path ./seed --pantry /tmp/test-pantry.conf "Breakfast/Easy Pancakes.cook" +``` + +Expected: `flour` is reduced/removed (per the custom pantry), but `milk`, `eggs`, and `butter` (which are in `seed/config/pantry.conf` but NOT in the custom file) now appear at full recipe quantities. This confirms `--pantry` overrides auto-discovery. + +Clean up: `rm /tmp/test-pantry.conf` + +- [ ] **Step 4: `--ignore-pantry` wins over `--pantry`** + +```bash +cargo run --quiet -- shopping-list --base-path ./seed --ignore-pantry --pantry /does/not/exist.conf "Breakfast/Easy Pancakes.cook" +``` + +Expected: succeeds with no error (does not attempt to open `/does/not/exist.conf`) and produces the same unfiltered list as Step 2. + +- [ ] **Step 5: Help text reflects the new flags** + +```bash +cargo run --quiet -- shopping-list --help +``` + +Expected: output includes lines for `--pantry ` ("Load pantry conf file") and `--ignore-pantry` ("Don't subtract pantry items from the shopping list"). + +- [ ] **Step 6: Confirm no commit is needed** + +```bash +git status +``` + +Expected: working tree clean (Task 1 already committed; this task is verification only). If anything was modified, investigate before continuing. + +--- + +## Self-Review + +**Spec coverage:** +- Spec goal 1: add `--pantry ` arg → Task 1 Step 1. +- Spec goal 2: add `--ignore-pantry` flag → Task 1 Step 1. +- Spec resolution logic (ignore wins; else `args.pantry.or_else(ctx.pantry())`) → Task 1 Step 2. +- Spec interaction matrix row "ignore wins over `--pantry`" → Task 2 Step 4. +- Spec testing section bullets 1–4 → Task 2 Steps 1–4. + +**Placeholder scan:** No TBDs, no "add appropriate X", no "similar to task N". All code shown in full. + +**Type consistency:** Both new args use `Option` / `bool`, matching the existing `aisle` and `ignore_references` fields on the same struct. The resolution expression `args.pantry.clone().or_else(|| ctx.pantry())` returns `Option`, which is what the rest of the existing block already expects. diff --git a/docs/superpowers/plans/2026-05-15-static-site-build.md b/docs/superpowers/plans/2026-05-15-static-site-build.md new file mode 100644 index 0000000..f7b202a --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-static-site-build.md @@ -0,0 +1,1774 @@ +# Static Site Build Command Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `cook build` CLI command that renders the recipe collection as a self-contained static HTML site, reusing the existing server templates. + +**Architecture:** New top-level module `src/build/` orchestrates a single-pass render. We add a `static_mode: bool` flag to existing Askama template structs and gate dynamic UI in template files behind it. Recipe/menu rendering logic is first extracted from `src/server/ui.rs` handlers into `src/server/builders.rs` so both the server and the static build use the same code path. The build writes HTML files mirroring the source tree, copies embedded static assets and recipe images, and generates a JSON search index plus a small client-side `search.js`. + +**Tech Stack:** Rust, Askama (templates), `cooklang_find` (recipe tree), `RustEmbed` (static asset embedding), `tempfile`/`assert_cmd` (tests). + +**Spec:** `docs/superpowers/specs/2026-05-15-static-site-build-design.md` + +--- + +## File Structure + +**New files (Rust):** +- `src/build/mod.rs` — `BuildArgs`, `run(ctx, args)`, orchestrator +- `src/build/renderer.rs` — render functions for index/directory/recipe/menu pages +- `src/build/writer.rs` — file writes, static asset copying, image copying +- `src/build/links.rs` — relative `prefix` computation per page +- `src/build/index.rs` — search index generation +- `src/server/builders.rs` — extracted template-builder functions (used by both server and build) +- `tests/build.rs` — integration smoke tests + +**New file (static asset):** +- `static/js/search.js` — client-side search using `search-index.json` + +**Modified files:** +- `src/args.rs` — add `Build` command variant +- `src/main.rs` — add `build` module + dispatch + base_path handling for build command +- `src/server/mod.rs` — wire `builders` module +- `src/server/templates.rs` — add `static_mode: bool` to all relevant template structs +- `src/server/ui.rs` — call new builders, pass `static_mode: false` +- `templates/base.html` — gate dynamic nav, switch search JS source +- `templates/recipes.html` — gate shopping-list & menu buttons; append `.html` to links +- `templates/recipe.html` — gate edit/shopping-list/pantry/scaling; append `.html` +- `templates/menu.html` — gate edit/shopping-list; append `.html` +- `Cargo.toml` — none expected; reuse existing deps + +--- + +## Task 1: CLI skeleton (`cook build` stub) + +**Files:** +- Create: `src/build/mod.rs` +- Modify: `src/args.rs`, `src/main.rs` +- Test: `tests/build.rs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/build.rs`: + +```rust +use assert_cmd::Command; + +#[test] +fn build_command_help_works() { + let mut cmd = Command::cargo_bin("cook").unwrap(); + cmd.args(["build", "--help"]).assert().success(); +} +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cargo test --test build build_command_help_works` +Expected: FAIL with unrecognized subcommand `build`. + +- [ ] **Step 3: Add stub module `src/build/mod.rs`** + +```rust +use crate::Context; +use anyhow::Result; +use camino::Utf8PathBuf; +use clap::Args; + +#[derive(Debug, Args)] +pub struct BuildArgs { + /// Output directory for the generated static site + /// + /// Defaults to ./_site if not specified. The directory is created if + /// missing. Existing files in the directory are overwritten as needed + /// but not wiped wholesale. + #[arg(value_hint = clap::ValueHint::DirPath)] + pub output_dir: Option, + + /// Root directory containing your recipe files + #[arg(long, value_hint = clap::ValueHint::DirPath)] + pub base_path: Option, + + /// Absolute URL prefix for hosting under a subpath (e.g. /recipes/) + /// + /// When set, internal links use this absolute prefix instead of + /// page-relative paths. Useful when you know the deployed subpath. + #[arg(long)] + pub base_url: Option, +} + +impl BuildArgs { + pub fn get_base_path(&self) -> Option { + self.base_path.clone() + } +} + +pub fn run(_ctx: &Context, _args: BuildArgs) -> Result<()> { + println!("cook build: not yet implemented"); + Ok(()) +} +``` + +- [ ] **Step 4: Wire into `src/args.rs`** + +Add `build` to the imports at top: + +```rust +use crate::{build, doctor, import, lsp, pantry, recipe, report, search, seed, server, shopping_list}; +``` + +Add the command variant in the `Command` enum (after `Server`): + +```rust +/// Generate a self-contained static website from your recipe collection +/// +/// Renders your recipes as static HTML files browsable on any static-file +/// host or directly from disk via file://. Excludes dynamic features +/// (shopping list, pantry, editing). +/// +/// Examples: +/// cook build # Build to ./_site +/// cook build out # Build to ./out +/// cook build --base-path ~/recipes # Use specific source directory +/// cook build --base-url /recipes/ # Absolute URL prefix for subpath hosting +#[command( + long_about = "Generate a static HTML website from your recipe collection" +)] +Build(build::BuildArgs), +``` + +- [ ] **Step 5: Wire into `src/main.rs`** + +Add module declaration near the other `mod` lines: + +```rust +mod build; +``` + +Add match arm in `main()`: + +```rust +Command::Build(args) => build::run(&ctx, args), +``` + +Add the `Build` case in `configure_context()` so its `--base-path` flag is honored: + +```rust +Command::Build(ref build_args) => build_args + .get_base_path() + .unwrap_or_else(|| Utf8PathBuf::from(".")), +``` + +- [ ] **Step 6: Run test, verify it passes** + +Run: `cargo test --test build build_command_help_works` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(build): add cook build command skeleton" +``` + +--- + +## Task 2: Output directory resolution and basic invocation + +**Files:** +- Modify: `src/build/mod.rs` +- Test: `tests/build.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/build.rs`: + +```rust +use std::path::PathBuf; +use tempfile::TempDir; + +fn seed_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("seed") +} + +#[test] +fn build_creates_output_dir() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + let mut cmd = Command::cargo_bin("cook").unwrap(); + cmd.args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + assert!(out.is_dir(), "output dir should exist after build"); +} +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `cargo test --test build build_creates_output_dir` +Expected: FAIL — output dir not created (stub just prints). + +- [ ] **Step 3: Implement output directory creation in `src/build/mod.rs`** + +Replace `run`: + +```rust +use crate::util::resolve_to_absolute_path; +use crate::Context; +use anyhow::{bail, Context as _, Result}; +use camino::Utf8PathBuf; +use clap::Args; + +#[derive(Debug, Args)] +pub struct BuildArgs { + /// Output directory for the generated static site + #[arg(value_hint = clap::ValueHint::DirPath)] + pub output_dir: Option, + + /// Root directory containing your recipe files + #[arg(long, value_hint = clap::ValueHint::DirPath)] + pub base_path: Option, + + /// Absolute URL prefix for hosting under a subpath (e.g. /recipes/) + #[arg(long)] + pub base_url: Option, +} + +impl BuildArgs { + pub fn get_base_path(&self) -> Option { + self.base_path.clone() + } +} + +pub fn run(ctx: &Context, args: BuildArgs) -> Result<()> { + let source = resolve_to_absolute_path(ctx.base_path())?; + if !source.is_dir() { + bail!("Source base path is not a directory: {source}"); + } + + let output = args + .output_dir + .clone() + .unwrap_or_else(|| Utf8PathBuf::from("_site")); + let output = resolve_to_absolute_path(&output)?; + + std::fs::create_dir_all(&output) + .with_context(|| format!("Failed to create output directory: {output}"))?; + + tracing::info!("Building static site from {source} into {output}"); + println!("Building static site from {source} into {output}"); + Ok(()) +} +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `cargo test --test build` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(build): resolve source and output paths" +``` + +--- + +## Task 3: Add `static_mode` field to template structs + +**Files:** +- Modify: `src/server/templates.rs` +- Modify: `src/server/ui.rs` + +- [ ] **Step 1: Add `static_mode` to template structs** + +In `src/server/templates.rs`, add `pub static_mode: bool` as the last field of these structs: +- `ErrorTemplate` +- `RecipesTemplate` +- `RecipeTemplate` +- `MenuTemplate` + +(Skip `ShoppingListTemplate`, `PreferencesTemplate`, `PantryTemplate`, `EditTemplate`, `NewTemplate` — never rendered in static mode.) + +Example for `RecipesTemplate`: + +```rust +#[derive(Template)] +#[template(path = "recipes.html")] +pub struct RecipesTemplate { + pub active: String, + pub current_name: String, + pub breadcrumbs: Vec, + pub items: Vec, + pub todays_menu: Option, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, +} +``` + +- [ ] **Step 2: Pass `static_mode: false` from every server handler** + +In `src/server/ui.rs`, find each construction of `RecipesTemplate`, `RecipeTemplate`, `MenuTemplate`, and `ErrorTemplate` (in `error_page`). Add `static_mode: false,` to each struct literal. + +Use `grep` to find every occurrence: +```bash +grep -n "RecipesTemplate\|RecipeTemplate\|MenuTemplate\|ErrorTemplate" src/server/ui.rs src/server/handlers/*.rs src/server/mod.rs +``` + +For each match, append `static_mode: false,` to the field list. Do not miss any — askama will fail to compile if `static_mode` is referenced in templates and missing from struct literals. + +- [ ] **Step 3: Run `cargo build` to verify compilation** + +Run: `cargo build` +Expected: compiles cleanly. If any struct literal is missing `static_mode`, the compiler points at the line. + +- [ ] **Step 4: Run existing tests** + +Run: `cargo test` +Expected: all existing tests pass (no behavior change yet). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(server): add static_mode field to template structs" +``` + +--- + +## Task 4: Gate dynamic UI in `templates/base.html` + +**Files:** +- Modify: `templates/base.html` + +- [ ] **Step 1: Gate dynamic nav links** + +In `templates/base.html`, wrap each of these elements in `{% if !static_mode %} ... {% endif %}`: + +1. The shopping-list nav link (the `` block, lines ~777-779). +2. The pantry nav link (``, lines ~780-782). +3. The preferences nav link (``, lines ~784-786). +4. The mobile overflow dropdown's preferences link inside `#more-dropdown` (lines ~808-810). + +Each looks like: +```html +{% if !static_mode %} +... +{% endif %} +``` + +- [ ] **Step 2: Conditional search JS source** + +Find the inline search script (around line 877 with `fetch(\`{{ prefix }}/api/search?q=...\`)`). + +Wrap the entire `` containing the `document.addEventListener('click', ...)` block) in: + +```html +{% if !static_mode %} + +{% else %} + +{% endif %} +``` + +Keep the `translations` object (which is currently at the top of that script block) inside the `!static_mode` branch — search.js for static mode will hardcode strings. + +- [ ] **Step 3: Verify template compiles** + +Run: `cargo build` +Expected: compiles cleanly. Askama validates template syntax at compile time. + +- [ ] **Step 4: Run existing tests** + +Run: `cargo test` +Expected: PASS (server still passes `static_mode: false`, so nothing changes at runtime). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(templates): gate dynamic nav and search behind static_mode" +``` + +--- + +## Task 5: Gate dynamic UI in remaining templates + +**Files:** +- Modify: `templates/recipes.html` +- Modify: `templates/recipe.html` +- Modify: `templates/menu.html` + +For each file: + +- [ ] **Step 1: Search for elements that mutate state or call dynamic APIs** + +Run for each file: +```bash +grep -n "/api/\|shopping-list\|pantry\|/edit\|/new\|scale-input\|reload\|onclick" templates/recipes.html +grep -n "/api/\|shopping-list\|pantry\|/edit\|/new\|scale-input\|reload\|onclick" templates/recipe.html +grep -n "/api/\|shopping-list\|pantry\|/edit\|/new\|scale-input\|reload\|onclick" templates/menu.html +``` + +- [ ] **Step 2: Wrap each dynamic-only block in `{% if !static_mode %} ... {% endif %}`** + +Specifically gate: + +In `templates/recipes.html`: +- "Add menu to shopping list" buttons and links to `/shopping-list`. +- Any link to `/edit/...` or `/new`. + +In `templates/recipe.html`: +- Edit button / link to `/edit/...` +- "Add to shopping list" button(s) and any `/api/shopping_list*` form submission targets. +- "In pantry" badges / pantry-related elements. +- Scale input control (the entire scaling form/UI block). +- Any `\ - " - ); - socket.write_all(response.as_bytes()).await?; - Ok(token) - } else { - let response = format!( - "HTTP/1.1 400 Bad Request\r\n\ - Content-Type: text/html; charset=utf-8\r\n\ - Access-Control-Allow-Origin: {origin}\r\n\r\n\ - Login Failed\ -
\ -

Login Failed

\ -

Please close this tab and try again.

\ - " - ); - socket.write_all(response.as_bytes()).await?; - anyhow::bail!("Failed to extract token from callback") - } -} - -fn extract_token(request: &str, expected_state: &str) -> Option { - let first_line = request.lines().next()?; - if !first_line.starts_with("GET ") { - return None; - } - let path = first_line.split(' ').nth(1)?; - - // Use url crate for robust query string parsing (handles %26-encoded values, etc.) - let full_url = format!("http://localhost{path}"); - let parsed = url::Url::parse(&full_url).ok()?; - - let mut token = None; - let mut state = None; - - for (key, value) in parsed.query_pairs() { - match key.as_ref() { - "token" => token = Some(value.into_owned()), - "state" => state = Some(value.into_owned()), - _ => {} - } - } - - if state.as_deref() == Some(expected_state) { - token - } else { - None + Json(serde_json::json!({ "cancelled": false })) } } pub async fn sync_logout( State(state): State>, ) -> Result, (StatusCode, Json)> { - // Stop sync task + if let Some(p) = state.pending_device_flow.lock().await.take() { + p.cancel.cancel(); + } + if let Some(handle) = state.sync_handle.lock().await.take() { handle.stop().await; } - // Clear session *state.sync_session.lock().unwrap() = None; if let Err(e) = SyncSession::delete(&state.session_path) { tracing::warn!("Failed to delete session file: {e}"); diff --git a/src/server/mod.rs b/src/server/mod.rs index e2f6cf2..8b3361c 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -42,8 +42,6 @@ use camino::Utf8PathBuf; use clap::Args; use rust_embed::RustEmbed; #[cfg(feature = "sync")] -use std::sync::atomic::AtomicBool; -#[cfg(feature = "sync")] use std::sync::Mutex; use std::{net::IpAddr, net::SocketAddr, sync::Arc}; use tower_http::{cors::CorsLayer, services::ServeDir}; @@ -318,7 +316,7 @@ fn build_state(ctx: Context, args: ServerArgs) -> Result> { #[cfg(feature = "sync")] sync_handle: Arc::new(tokio::sync::Mutex::new(None)), #[cfg(feature = "sync")] - login_in_progress: Arc::new(AtomicBool::new(false)), + pending_device_flow: Arc::new(tokio::sync::Mutex::new(None)), #[cfg(feature = "sync")] session_path, #[cfg(feature = "sync")] @@ -372,7 +370,7 @@ pub struct AppState { #[cfg(feature = "sync")] pub sync_handle: Arc>>, #[cfg(feature = "sync")] - pub login_in_progress: Arc, + pub pending_device_flow: Arc>>, #[cfg(feature = "sync")] pub session_path: std::path::PathBuf, #[cfg(feature = "sync")] @@ -462,6 +460,7 @@ fn api(_state: &AppState) -> Result>> { let router = router .route("/sync/status", get(handlers::sync_status)) .route("/sync/login", post(handlers::sync_login)) + .route("/sync/cancel_login", post(handlers::sync_cancel_login)) .route("/sync/logout", post(handlers::sync_logout)); Ok(router) diff --git a/src/server/sync/device_flow.rs b/src/server/sync/device_flow.rs new file mode 100644 index 0000000..1243d3a --- /dev/null +++ b/src/server/sync/device_flow.rs @@ -0,0 +1,154 @@ +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; + +use super::endpoints; + +const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Serialize)] +struct DeviceCodeRequest<'a> { + client_name: &'a str, +} + +#[derive(Debug, Serialize)] +struct TokenRequest<'a> { + grant_type: &'a str, + device_code: &'a str, +} + +#[derive(Debug, Deserialize)] +struct TokenSuccess { + access_token: String, +} + +#[derive(Debug, Deserialize)] +struct TokenError { + error: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum DeviceFlowError { + #[error("user denied authorization")] + AccessDenied, + #[error("device code expired")] + Expired, + #[error("flow cancelled")] + Cancelled, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + #[error("bad response from cook.md: {0}")] + BadResponse(String), +} + +pub async fn request_device_code( + client: &reqwest::Client, + client_name: &str, +) -> Result { + let url = format!("{}/oauth/device/code", endpoints::base_url()); + let resp = client + .post(&url) + .json(&DeviceCodeRequest { client_name }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(DeviceFlowError::BadResponse(format!( + "device code request failed: HTTP {status}: {body}" + ))); + } + + resp.json::().await.map_err(Into::into) +} + +/// Polls /oauth/device/token until approved, denied, expired, or cancelled. +/// Respects `slow_down` (bumps interval by 5 s) and the `expires_at` deadline. +pub async fn poll_for_token( + client: &reqwest::Client, + device_code: &str, + mut interval: Duration, + expires_at: Instant, + cancel: CancellationToken, +) -> Result { + let url = format!("{}/oauth/device/token", endpoints::base_url()); + + loop { + if Instant::now() >= expires_at { + return Err(DeviceFlowError::Expired); + } + + tokio::select! { + _ = cancel.cancelled() => return Err(DeviceFlowError::Cancelled), + _ = tokio::time::sleep(interval) => {} + } + + let resp = client + .post(&url) + .json(&TokenRequest { + grant_type: GRANT_TYPE, + device_code, + }) + .send() + .await?; + + let status = resp.status(); + + if status.is_success() { + let body: TokenSuccess = resp.json().await.map_err(DeviceFlowError::Network)?; + return Ok(body.access_token); + } + + // 400 → parse {"error": "..."} per RFC 8628 + let body: TokenError = resp + .json() + .await + .map_err(|e| DeviceFlowError::BadResponse(format!("unparseable error body: {e}")))?; + + match body.error.as_str() { + "authorization_pending" => continue, + "slow_down" => { + interval += Duration::from_secs(5); + } + "access_denied" => return Err(DeviceFlowError::AccessDenied), + "expired_token" => return Err(DeviceFlowError::Expired), + other => { + return Err(DeviceFlowError::BadResponse(format!( + "unexpected error code: {other}" + ))) + } + } + } +} + +/// Builds the client_name string sent to cook.md. Includes OS and a +/// best-effort label ("docker" / "cli" / hostname). +pub fn client_name(suffix: &str) -> String { + format!( + "CookCLI {} ({}/{})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + suffix + ) +} + +/// Returns "docker" if /.dockerenv exists, else "server". +pub fn server_host_label() -> &'static str { + if std::path::Path::new("/.dockerenv").exists() { + "docker" + } else { + "server" + } +} diff --git a/src/server/sync/mod.rs b/src/server/sync/mod.rs index a84f17c..9884f7f 100644 --- a/src/server/sync/mod.rs +++ b/src/server/sync/mod.rs @@ -1,5 +1,6 @@ use camino::Utf8PathBuf; +pub mod device_flow; pub mod endpoints; pub mod runner; pub mod session; @@ -7,6 +8,17 @@ pub mod session; pub use runner::{start_sync, SyncHandle}; pub use session::SyncSession; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub struct PendingDeviceFlow { + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_at: std::time::Instant, + pub cancel: CancellationToken, +} + /// Resolve the sync database file path. /// Returns an error if the global config directory cannot be determined. pub fn sync_db_path() -> anyhow::Result { diff --git a/templates/preferences.html b/templates/preferences.html index 51e928d..88db9b4 100644 --- a/templates/preferences.html +++ b/templates/preferences.html @@ -62,13 +62,30 @@

CookCloud Sync

{% else %} -
+

Sync your recipes across devices with CookCloud.

+ + {% endif %}
@@ -159,59 +176,120 @@

Documentation & Resources

} {% if sync_enabled %} + let pollHandle = null; + let countdownHandle = null; + + function renderPending(p) { + document.getElementById('sync-login-section').classList.add('hidden'); + const card = document.getElementById('sync-login-card'); + card.classList.remove('hidden'); + document.getElementById('sync-login-code').textContent = p.user_code; + document.getElementById('sync-login-link').href = p.verification_uri; + document.getElementById('sync-login-link').textContent = p.verification_uri; + document.getElementById('sync-login-open').href = p.verification_uri_complete; + + if (countdownHandle) clearInterval(countdownHandle); + const deadline = Date.now() + p.expires_in_secs * 1000; + const expEl = document.getElementById('sync-login-expires'); + const tick = () => { + const remaining = Math.max(0, Math.round((deadline - Date.now()) / 1000)); + if (remaining <= 0) { + expEl.textContent = 'Code expired.'; + stopPolling(); + resetLoginUi('Code expired — try again.'); + return; + } + const m = Math.floor(remaining / 60); + const s = String(remaining % 60).padStart(2, '0'); + expEl.textContent = `Expires in ${m}:${s}`; + }; + tick(); + countdownHandle = setInterval(tick, 1000); + } + + function stopPolling() { + if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } + if (countdownHandle) { clearInterval(countdownHandle); countdownHandle = null; } + } + + function startPolling() { + stopPolling(); + pollHandle = setInterval(async () => { + const status = await fetch('{{ prefix }}/api/sync/status').then(r => r.json()).catch(() => null); + if (!status) return; // network blip — keep polling + if (status.logged_in) { + stopPolling(); + window.location.reload(); + } else if (!status.pending_login) { + stopPolling(); + resetLoginUi('Login was cancelled or expired.'); + } + }, 2000); + } + + function resetLoginUi(msg) { + document.getElementById('sync-login-card').classList.add('hidden'); + document.getElementById('sync-login-section').classList.remove('hidden'); + const btn = document.getElementById('sync-login-btn'); + if (btn) btn.disabled = false; + document.getElementById('sync-login-message').textContent = msg || ''; + } + async function syncLogin() { - const btn = document.getElementById('sync-login-btn'); - const msg = document.getElementById('sync-login-message'); - try { - btn.disabled = true; - btn.textContent = 'Opening browser...'; - btn.classList.add('opacity-50', 'cursor-not-allowed'); - msg.textContent = 'Complete login in your browser.'; - - const resp = await fetch('{{ prefix }}/api/sync/login', { method: 'POST' }); - if (!resp.ok) { - const err = await resp.json(); - btn.disabled = false; - btn.textContent = 'Login to CookCloud'; - btn.classList.remove('opacity-50', 'cursor-not-allowed'); - msg.textContent = err.error || 'Login failed. Please try again.'; - return; - } - - btn.textContent = 'Waiting for login...'; - - // Poll for login completion - const pollInterval = setInterval(async () => { - const status = await fetch('{{ prefix }}/api/sync/status').then(r => r.json()); - if (status.logged_in) { - clearInterval(pollInterval); - window.removeEventListener('beforeunload', cleanupPolling); - window.location.reload(); - } - }, 2000); - // Stop polling after 5 minutes and show timeout message - const timeoutId = setTimeout(() => { - clearInterval(pollInterval); - window.removeEventListener('beforeunload', cleanupPolling); - btn.disabled = false; - btn.textContent = 'Login to CookCloud'; - btn.classList.remove('opacity-50', 'cursor-not-allowed'); - msg.textContent = 'Login timed out. Please try again.'; - }, 300000); - // Clean up timers if user navigates away - function cleanupPolling() { - clearInterval(pollInterval); - clearTimeout(timeoutId); - } - window.addEventListener('beforeunload', cleanupPolling); - } catch (e) { - btn.disabled = false; - btn.textContent = 'Login to CookCloud'; - btn.classList.remove('opacity-50', 'cursor-not-allowed'); - msg.textContent = 'Failed to start login: ' + e.message; + const btn = document.getElementById('sync-login-btn'); + const msg = document.getElementById('sync-login-message'); + try { + btn.disabled = true; + msg.textContent = 'Requesting code...'; + const resp = await fetch('{{ prefix }}/api/sync/login', { method: 'POST' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + btn.disabled = false; + msg.textContent = err.error || 'Login failed.'; + return; } + const body = await resp.json(); + renderPending(body); + + startPolling(); + } catch (e) { + btn.disabled = false; + msg.textContent = 'Failed to start login: ' + e.message; + } } + document.addEventListener('DOMContentLoaded', () => { + const copyBtn = document.getElementById('sync-login-copy'); + if (copyBtn) { + copyBtn.addEventListener('click', async () => { + const code = document.getElementById('sync-login-code').textContent; + try { + await navigator.clipboard.writeText(code.replace(/\s+/g, '')); + copyBtn.textContent = 'Copied!'; + } catch (e) { + copyBtn.textContent = 'Copy failed'; + } + setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); + }); + } + const cancelBtn = document.getElementById('sync-login-cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', async () => { + stopPolling(); + await fetch('{{ prefix }}/api/sync/cancel_login', { method: 'POST' }); + resetLoginUi('Login cancelled.'); + }); + } + + // Resume on page load if a flow is in progress server-side. + fetch('{{ prefix }}/api/sync/status').then(r => r.json()).then(status => { + if (!status.logged_in && status.pending_login) { + renderPending(status.pending_login); + startPolling(); + } + }); + }); + async function syncLogout() { try { await fetch('{{ prefix }}/api/sync/logout', { method: 'POST' }); diff --git a/tests/snapshots/snapshot_test__help_output.snap b/tests/snapshots/snapshot_test__help_output.snap index fb0972a..f345797 100644 --- a/tests/snapshots/snapshot_test__help_output.snap +++ b/tests/snapshots/snapshot_test__help_output.snap @@ -19,6 +19,8 @@ Commands: doctor Analyze your recipe collection for issues and improvements pantry Manage and analyze your pantry inventory lsp Start the Cooklang Language Server Protocol (LSP) server + login Sign in to CookCloud + logout Sign out of CookCloud update Update CookCLI to the latest version help Print this message or the help of the given subcommand(s)