From 72edd7cc7baad2b0efffc306ac4049a2bedcfc98 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Fri, 15 May 2026 10:29:36 +0100 Subject: [PATCH 01/19] docs: add design for shopping-list pantry flags Adds spec for --pantry and --ignore-pantry flags on the shopping-list command, mirroring the existing --aisle handling. --- ...05-15-shopping-list-pantry-flags-design.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-shopping-list-pantry-flags-design.md diff --git a/docs/superpowers/specs/2026-05-15-shopping-list-pantry-flags-design.md b/docs/superpowers/specs/2026-05-15-shopping-list-pantry-flags-design.md new file mode 100644 index 0000000..5fc7e8a --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-shopping-list-pantry-flags-design.md @@ -0,0 +1,77 @@ +# Shopping List Pantry Flags — Design + +## Background + +The `cook shopping-list` CLI command already loads a pantry file via `Context::pantry()` (`src/main.rs:104`) and subtracts pantry quantities from the generated shopping list (`src/shopping_list.rs:264-267`). However, two ergonomic gaps remain compared to the aisle handling and compared to the web server: + +1. No way to specify a custom pantry file path on the command line. The aisle flag (`--aisle `) supports this; pantry does not. +2. No way to disable pantry subtraction. If a user wants the full unfiltered shopping list, they must move or delete their pantry file. + +This spec closes both gaps. + +## Goals + +- Add `--pantry ` argument to `cook shopping-list`, mirroring the existing `--aisle `. +- Add `--ignore-pantry` boolean flag that completely skips pantry loading and subtraction. + +## Non-Goals + +- No changes to pantry parsing, subtraction logic, or the underlying `cooklang::pantry` API. +- No changes to the web server pantry handling. +- No changes to the `pantry` subcommand or other commands. + +## Design + +### CLI Surface + +Add two fields to `ShoppingListArgs` in `src/shopping_list.rs`: + +```rust +/// Load pantry conf file +#[arg(long)] +pantry: Option, + +/// Don't subtract pantry items from the shopping list +#[arg(long)] +ignore_pantry: bool, +``` + +Notes: +- `--pantry` is long-only (no short flag) to avoid colliding with `-p` / `--plain`. +- `--ignore-pantry` follows the naming pattern of the existing `--ignore-references` flag. + +### Resolution Logic + +Replace the current pantry loading block (`src/shopping_list.rs:198-233`) with logic that mirrors the aisle resolution pattern at `src/shopping_list.rs:168-175`: + +1. If `args.ignore_pantry` is `true`: skip loading entirely. `pantry` is `None`. No file I/O occurs. +2. Otherwise, resolve the path as `args.pantry.or_else(|| ctx.pantry())`. +3. If a path is resolved, load and parse as today (parse_lenient, surface warnings, rebuild index). +4. If no path is resolved, behave as today (no pantry, no subtraction). + +The existing subtraction call at `src/shopping_list.rs:264-267` is unchanged — it already short-circuits when `pantry` is `None`. + +### Interaction Matrix + +| `--ignore-pantry` | `--pantry ` | `ctx.pantry()` resolves | Behavior | +| ----------------- | ----------------- | ----------------------- | ----------------------------------------- | +| true | (any) | (any) | No load, no subtraction | +| false | Some(path) | (any) | Load from `path`, subtract | +| false | None | Some(auto) | Load from auto path, subtract (unchanged) | +| false | None | None | No load, no subtraction (unchanged) | + +When both `--ignore-pantry` and `--pantry` are passed, `--ignore-pantry` wins. The provided path is not opened. This matches the user's stated intent ("skip pantry items") and avoids surprising failures from a bad path that the user explicitly asked to ignore. + +## Testing + +Manual verification using `cook seed` fixtures: + +1. Default behavior unchanged: `cook shopping-list ` still subtracts auto-discovered pantry. +2. Custom path: create a pantry file at a non-default location, run `cook shopping-list --pantry `, verify subtraction uses the custom file. +3. Skip flag: with an auto-discoverable pantry file present, run `cook shopping-list --ignore-pantry ` and verify no items are subtracted. +4. Skip flag dominates: run `cook shopping-list --ignore-pantry --pantry /nonexistent/path ` and verify it succeeds with no subtraction (does not error on the bad path). + +## Out of Scope / Future Work + +- Documentation update in `docs/shopping-list.md` to mention the new flags. +- Mirroring the web server's reporting of which items were filtered out (the web UI shows pantry-subtracted items separately; the CLI does not). From d67b46373ba7960ebaf01c018eb209c56d7e47aa Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Fri, 15 May 2026 10:32:42 +0100 Subject: [PATCH 02/19] docs: add implementation plan for shopping-list pantry flags --- .../2026-05-15-shopping-list-pantry-flags.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-15-shopping-list-pantry-flags.md 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. From 5e74a98ab1f4c058769bd6442f8a2d5f7f4c44f2 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Fri, 15 May 2026 17:37:18 +0100 Subject: [PATCH 03/19] docs: add design for static site build command --- .../2026-05-15-static-site-build-design.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-static-site-build-design.md diff --git a/docs/superpowers/specs/2026-05-15-static-site-build-design.md b/docs/superpowers/specs/2026-05-15-static-site-build-design.md new file mode 100644 index 0000000..ba80ad0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-static-site-build-design.md @@ -0,0 +1,177 @@ +# Static Site Build Command — Design + +## Summary + +A new top-level CLI command `cook build` that generates a self-contained static website from a Cooklang recipe collection. The output mirrors the existing web server UI (directory browsing, recipe pages, menus, search) but omits all dynamic, user-state features (shopping list, pantry, edit/new, preferences, sync, reload, scaling). + +The static site can be hosted on any static-file host (GitHub Pages, Netlify, S3, etc.) or browsed directly from disk via `file://`. + +## Goals + +- Render the recipe collection as static HTML browsable without a server. +- Maximize reuse of the existing Askama templates and rendering code; do not duplicate the recipe rendering pipeline. +- Self-contained output: the chosen output directory is uploadable as-is. +- Works on `file://` and arbitrary host subpaths. + +## Non-goals (v1) + +- Recipe scaling in the static output. Quantities render as written. +- Shopping list, pantry, edit, new, preferences, sync, reload. +- Incremental / watch-mode builds. +- Themes or template customization. +- Pretty (extensionless) URLs. + +## CLI + +``` +cook build [OUTPUT_DIR] + [--base-path ] # source recipes, defaults to current working dir / Context base_path + [--base-url ] # absolute URL prefix (e.g. /recipes/) for subpath hosting +``` + +- `OUTPUT_DIR` defaults to `./_site`. +- The output directory is created if missing. Existing contents are **not** wiped; the build writes files over existing ones. (User controls cleanup.) +- Aliases: none in v1. + +## Output Layout + +``` +_site/ + index.html # root recipe listing + directory/.html # one per non-empty subdirectory + recipe/.html # one per .cook file + menu/.html # one per menu + static/ + css/output.css + js/search.js + search-index.json + (other embedded static assets) + api/static/ # recipe images, mirrors source layout +``` + +`recipe/.html` keeps the URL scheme close to the running server (`/recipe/...`), which means the existing `prefix`-based URL helpers in templates continue to work with a per-page relative prefix. + +## Architecture + +### New module: `src/build/` + +Mirrors the layout of `src/server/`: + +- `mod.rs` — `BuildArgs` (clap), `run(ctx, args) -> Result<()>`, top-level orchestrator. +- `renderer.rs` — wraps existing Askama template structs (`RecipesTemplate`, `RecipeTemplate`, `MenuTemplate`, etc.) with `static_mode: true` and computed per-page `prefix`. +- `writer.rs` — handles directory creation, file writes, copying embedded static assets, copying recipe images. +- `index.rs` — walks the recipe tree to build `search-index.json`. +- `links.rs` — computes per-page relative `prefix` strings; helper to map an output path to its `../`-walking root prefix. + +### Template changes + +Add `static_mode: bool` to every Askama template struct in `src/server/templates.rs`. Default remains `false`; the server continues to pass `false`, the build command passes `true`. + +In templates (`templates/*.html`): +- Gate the following behind `{% if !static_mode %}`: + - Shopping-list nav link, add-to-shopping-list buttons, "in pantry" badges + - Edit / New / Delete controls + - Preferences nav link + - Sync UI (login/logout/status) + - Reload button + - Recipe scaling input +- Search box stays visible. Its JS handler points to `static/search.js` (client-side filter over `search-index.json`) in static mode, or `/api/search` in server mode. +- URL helpers that produce `/recipe/` etc. honor `static_mode` by appending `.html`. + +### Link strategy + +Existing templates already generate URLs using a `prefix` value. The build renderer computes `prefix` per page so all internal links are relative and work on `file://` and any host subpath: + +- `_site/index.html` → `prefix = "."` +- `_site/directory/Breakfast.html` → `prefix = ".."` +- `_site/recipe/Breakfast/Pancakes.html` → `prefix = "../.."` + +When `--base-url` is provided, the build uses it as an absolute prefix instead of computing relative paths. This is useful for known subpath hosting (e.g. `/recipes/`). + +### Search + +Build-time generation of `static/search-index.json`. Each entry: + +```json +{ + "title": "Pancakes", + "path": "recipe/Breakfast/Pancakes.html", + "tags": ["breakfast", "quick"], + "ingredients": ["flour", "milk", "egg"] +} +``` + +A small `static/js/search.js` (new, shipped alongside other embedded static assets) reads the JSON, filters as the user types, and renders results into the existing search dropdown markup. No external libraries. + +### Assets + +- Embedded static assets (CSS, JS, images under `static/`) are written verbatim to `_site/static/`. Uses the existing `RustEmbed` `StaticFiles` source. +- Recipe images are discovered the same way the server does (matched by stem alongside `.cook` files). They are copied into `_site/api/static/` so the templates' existing image URL logic continues to resolve correctly when given the per-page `prefix`. + +## Data Flow (Build Pipeline) + +A single pass: + +1. **Resolve paths**: `source = args.base_path || ctx.base_path`, `output = args.output_dir || "./_site"`. Make `output` absolute. Create if missing. Bail if `source` is not a directory. +2. **Build recipe tree**: `cooklang_find::build_tree(source)`. +3. **Build search index** by walking the tree. +4. **Render pages**: + - `index.html` from the root tree. + - One `directory/.html` per non-empty subdirectory. + - One `recipe/.html` per `.cook` file. + - One `menu/.html` per menu file. +5. **Copy assets**: + - Embedded `StaticFiles` → `_site/static/`. + - Discovered recipe images → `_site/api/static/`. + - Write `search-index.json` and `search.js` to `_site/static/`. +6. **Print summary**: counts of pages and assets written, output path. + +## Error Handling + +- Fatal (return `Err`, exit non-zero): + - Source directory does not exist or is a file. + - Output directory cannot be created or written to. + - Failure to write a critical static asset. +- Non-fatal (log via `tracing::warn!`, skip, continue): + - A `.cook` file fails to parse. + - A menu fails to resolve. + - An image referenced by a recipe is missing. + +Matches the server's lenient philosophy: one bad recipe should not break the whole build. + +## Testing + +### Automated (`cargo test`) + +A smoke test that runs `cook build` against `./seed` recipes into a `tempfile::TempDir`: + +- Assert these files exist: `index.html`, at least one `recipe/*.html`, `static/css/output.css`, `static/js/search.js`, `static/search-index.json`. +- Parse one recipe HTML and assert it contains the recipe title. +- Assert no dynamic-mode strings appear (e.g., "Add to shopping list", "Edit recipe"). +- Parse `search-index.json` and assert it has entries with the expected schema. + +### Manual + +Documented as part of the command's help text or CLAUDE.md: + +```bash +cook build ./seed _site +python3 -m http.server -d _site 8000 +# browse http://localhost:8000 + +# or directly via file:// +open _site/index.html +``` + +### Regression safety + +The existing Playwright suite covers the dynamic server. The `static_mode` flag defaults to `false`, so the server's behavior is unchanged. + +## Open Questions / Future Work + +- Theme/customization: deferred. v1 ships with the existing Tailwind look. +- Watch / incremental builds: deferred. Users can rerun `cook build`. +- Pretty URLs: deferred. `.html` extensions are universal. +- Pre-rendered scaling variants: deferred. Scaling is dropped in v1 entirely. +- Sitemap/RSS feed: deferred. +- Adding a download link to source `.cook` file from each recipe page: deferred. From 26874791a0ea59ba17d595824c4c3a3e9acf48e9 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Fri, 15 May 2026 18:06:28 +0100 Subject: [PATCH 04/19] docs: add implementation plan for static site build command --- .../plans/2026-05-15-static-site-build.md | 1774 +++++++++++++++++ 1 file changed, 1774 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-15-static-site-build.md 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 +pub async fn sync_cancel_login(State(state): State>) -> Json { + let mut guard = state.pending_device_flow.lock().await; + if let Some(p) = guard.take() { + p.cancel.cancel(); + Json(serde_json::json!({ "cancelled": true })) } 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(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 index 9433811..6e9bb13 100644 --- a/src/server/sync/device_flow.rs +++ b/src/server/sync/device_flow.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::time::{Duration, Instant}; use anyhow::Context; diff --git a/src/server/sync/mod.rs b/src/server/sync/mod.rs index daa6bf2..f53a275 100644 --- a/src/server/sync/mod.rs +++ b/src/server/sync/mod.rs @@ -8,6 +8,21 @@ pub mod session; pub use runner::{start_sync, SyncHandle}; pub use session::SyncSession; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub struct PendingDeviceFlow { + #[allow(dead_code)] + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_at: std::time::Instant, + #[allow(dead_code)] + pub interval: std::time::Duration, + 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 { From 0a218b63808490b56fde03e12457c59515593597 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sat, 16 May 2026 21:40:51 +0100 Subject: [PATCH 09/19] feat(sync): replace login button JS with device-code card --- templates/preferences.html | 172 ++++++++++++++++++++++++++----------- 1 file changed, 122 insertions(+), 50 deletions(-) diff --git a/templates/preferences.html b/templates/preferences.html index 51e928d..3757118 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,114 @@

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); + let remaining = p.expires_in_secs ?? p.expires_in; + const expEl = document.getElementById('sync-login-expires'); + const tick = () => { + 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}`; + remaining--; + }; + tick(); + countdownHandle = setInterval(tick, 1000); + } + + function stopPolling() { + if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } + if (countdownHandle) { clearInterval(countdownHandle); countdownHandle = null; } + } + + 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, expires_in_secs: body.expires_in }); + + pollHandle = setInterval(async () => { + const status = await fetch('{{ prefix }}/api/sync/status').then(r => r.json()); + if (status.logged_in) { + stopPolling(); + window.location.reload(); + } else if (!status.pending_login) { + stopPolling(); + resetLoginUi('Login was cancelled or expired.'); + } + }, 2000); + } 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; + await navigator.clipboard.writeText(code.replace(/\s+/g, '')); + copyBtn.textContent = 'Copied!'; + 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); + pollHandle = setInterval(async () => { + const s = await fetch('{{ prefix }}/api/sync/status').then(r => r.json()); + if (s.logged_in) { stopPolling(); window.location.reload(); } + else if (!s.pending_login) { stopPolling(); resetLoginUi('Login was cancelled or expired.'); } + }, 2000); + } + }); + }); + async function syncLogout() { try { await fetch('{{ prefix }}/api/sync/logout', { method: 'POST' }); From 755839c6d4fd48baca71e7ededed29dbe0c230fc Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sat, 16 May 2026 21:44:19 +0100 Subject: [PATCH 10/19] fix(sync): guard polling start and handle clipboard rejection --- templates/preferences.html | 40 ++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/templates/preferences.html b/templates/preferences.html index 3757118..234b19c 100644 --- a/templates/preferences.html +++ b/templates/preferences.html @@ -212,6 +212,21 @@

Documentation & Resources

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'); @@ -236,16 +251,7 @@

Documentation & Resources

const body = await resp.json(); renderPending({ ...body, expires_in_secs: body.expires_in }); - pollHandle = setInterval(async () => { - const status = await fetch('{{ prefix }}/api/sync/status').then(r => r.json()); - if (status.logged_in) { - stopPolling(); - window.location.reload(); - } else if (!status.pending_login) { - stopPolling(); - resetLoginUi('Login was cancelled or expired.'); - } - }, 2000); + startPolling(); } catch (e) { btn.disabled = false; msg.textContent = 'Failed to start login: ' + e.message; @@ -257,8 +263,12 @@

Documentation & Resources

if (copyBtn) { copyBtn.addEventListener('click', async () => { const code = document.getElementById('sync-login-code').textContent; - await navigator.clipboard.writeText(code.replace(/\s+/g, '')); - copyBtn.textContent = 'Copied!'; + try { + await navigator.clipboard.writeText(code.replace(/\s+/g, '')); + copyBtn.textContent = 'Copied!'; + } catch (e) { + copyBtn.textContent = 'Copy failed'; + } setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }); } @@ -275,11 +285,7 @@

Documentation & Resources

fetch('{{ prefix }}/api/sync/status').then(r => r.json()).then(status => { if (!status.logged_in && status.pending_login) { renderPending(status.pending_login); - pollHandle = setInterval(async () => { - const s = await fetch('{{ prefix }}/api/sync/status').then(r => r.json()); - if (s.logged_in) { stopPolling(); window.location.reload(); } - else if (!s.pending_login) { stopPolling(); resetLoginUi('Login was cancelled or expired.'); } - }, 2000); + startPolling(); } }); }); From 9710518bef76eb8fa622928d4114028b8f6b3664 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sat, 16 May 2026 21:49:26 +0100 Subject: [PATCH 11/19] feat(sync): add `cook login` CLI command --- src/args.rs | 12 +++++ src/lib.rs | 2 + src/login.rs | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 ++ 4 files changed, 145 insertions(+) create mode 100644 src/login.rs diff --git a/src/args.rs b/src/args.rs index bdf3f21..369095b 100644 --- a/src/args.rs +++ b/src/args.rs @@ -233,6 +233,18 @@ pub enum Command { )] Lsp(lsp::LspArgs), + /// Sign in to CookCloud + /// + /// Initiates the OAuth 2.0 Device Authorization Grant flow: + /// displays a code, opens your browser to the verification page, + /// and polls until you approve the request. + /// + /// Examples: + /// cook login # Start the device-code login flow + #[cfg(feature = "sync")] + #[command(long_about = "Sign in to CookCloud via the OAuth 2.0 device-code flow")] + Login(crate::login::LoginArgs), + /// Update CookCLI to the latest version /// /// Checks for new releases on GitHub and automatically downloads and diff --git a/src/lib.rs b/src/lib.rs index c13d423..033e87b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ use camino::{Utf8Path, Utf8PathBuf}; pub mod build; pub mod doctor; pub mod import; +#[cfg(feature = "sync")] +pub mod login; pub mod lsp; pub mod pantry; pub mod recipe; diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..0fbbc2e --- /dev/null +++ b/src/login.rs @@ -0,0 +1,127 @@ +use std::time::{Duration, Instant}; + +use anyhow::Result; +use clap::Args; +use tokio_util::sync::CancellationToken; + +use crate::server::sync::{device_flow, SyncSession}; +use crate::Context; + +#[derive(Debug, Args)] +pub struct LoginArgs {} + +pub fn run(_ctx: &Context, _args: LoginArgs) -> Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(run_async()) +} + +async fn run_async() -> Result<()> { + use std::io::{BufRead, Write}; + + let session_path = crate::global_file_path("session.json") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from(".cook-session.json")); + + if SyncSession::load(&session_path).ok().flatten().is_some() { + println!("Already logged in. Run `cook logout` first if you want to switch accounts."); + return Ok(()); + } + + let client = reqwest::Client::new(); + let name = device_flow::client_name("cli"); + let dc = device_flow::request_device_code(&client, &name).await?; + + println!(); + println!( + "First open {} in any browser and enter this code:", + dc.verification_uri + ); + println!(); + println!(" {}", dc.user_code); + println!(); + println!("(Press Enter to open it automatically, or Ctrl-C to abort.)"); + + let stdin = std::io::stdin(); + let _ = stdin.lock().lines().next(); + + if let Err(e) = open::that(&dc.verification_uri_complete) { + eprintln!("Couldn't open browser automatically: {e}"); + eprintln!("Please visit the URL above manually."); + } + + print!("Waiting for authorization"); + std::io::stdout().flush().ok(); + + let cancel = CancellationToken::new(); + let cancel_for_signal = cancel.clone(); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + cancel_for_signal.cancel(); + }); + + let expires_at = Instant::now() + Duration::from_secs(dc.expires_in); + let interval = Duration::from_secs(dc.interval); + + let dot_handle = { + let cancel = cancel.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => return, + _ = tokio::time::sleep(Duration::from_secs(1)) => { + print!("."); + let _ = std::io::stdout().flush(); + } + } + } + }) + }; + + let jwt = match device_flow::poll_for_token( + &client, + &dc.device_code, + interval, + expires_at, + cancel.clone(), + ) + .await + { + Ok(jwt) => jwt, + Err(device_flow::DeviceFlowError::AccessDenied) => { + cancel.cancel(); + dot_handle.abort(); + anyhow::bail!("Authorization denied."); + } + Err(device_flow::DeviceFlowError::Expired) => { + cancel.cancel(); + dot_handle.abort(); + anyhow::bail!("Code expired - try `cook login` again."); + } + Err(device_flow::DeviceFlowError::Cancelled) => { + dot_handle.abort(); + anyhow::bail!("Cancelled."); + } + Err(e) => { + cancel.cancel(); + dot_handle.abort(); + anyhow::bail!("Login failed: {e}"); + } + }; + + cancel.cancel(); + dot_handle.abort(); + println!(); + + let session = SyncSession::from_jwt(jwt)?; + session.save(&session_path)?; + + let email = session + .email + .clone() + .unwrap_or_else(|| "".to_string()); + println!("Logged in as {email}"); + println!(); + println!("Note: if `cook server` is running, restart it to pick up the new session."); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index b10b724..b9c9da9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,8 @@ use clap::Parser; mod build; mod doctor; mod import; +#[cfg(feature = "sync")] +mod login; mod lsp; mod pantry; mod recipe; @@ -77,6 +79,8 @@ pub fn main() -> Result<()> { Command::Doctor(args) => doctor::run(&ctx, args), Command::Pantry(args) => pantry::run(&ctx, args), Command::Lsp(args) => lsp::run(&ctx, args), + #[cfg(feature = "sync")] + Command::Login(args) => login::run(&ctx, args), #[cfg(feature = "self-update")] Command::Update(args) => update::run(args), } From d32b7a04dedcc340c02e4755045fa9301fc14ddb Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sat, 16 May 2026 21:52:37 +0100 Subject: [PATCH 12/19] fix(sync): avoid blocking stdin read in cook login --- src/login.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/login.rs b/src/login.rs index 0fbbc2e..6a88292 100644 --- a/src/login.rs +++ b/src/login.rs @@ -41,8 +41,11 @@ async fn run_async() -> Result<()> { println!(); println!("(Press Enter to open it automatically, or Ctrl-C to abort.)"); - let stdin = std::io::stdin(); - let _ = stdin.lock().lines().next(); + let _ = tokio::task::spawn_blocking(|| { + let stdin = std::io::stdin(); + let _ = stdin.lock().lines().next(); + }) + .await; if let Err(e) = open::that(&dc.verification_uri_complete) { eprintln!("Couldn't open browser automatically: {e}"); From 7ec69a7dd7a43e2b18e435a4a24b456644abd566 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sat, 16 May 2026 21:55:08 +0100 Subject: [PATCH 13/19] feat(sync): add `cook logout` CLI command --- src/args.rs | 11 +++++++++++ src/lib.rs | 2 ++ src/logout.rs | 25 +++++++++++++++++++++++++ src/main.rs | 4 ++++ 4 files changed, 42 insertions(+) create mode 100644 src/logout.rs diff --git a/src/args.rs b/src/args.rs index 369095b..d34e429 100644 --- a/src/args.rs +++ b/src/args.rs @@ -245,6 +245,17 @@ pub enum Command { #[command(long_about = "Sign in to CookCloud via the OAuth 2.0 device-code flow")] Login(crate::login::LoginArgs), + /// Sign out of CookCloud + /// + /// Removes the stored session file. The `cook server` instance, if running, + /// will pick up the change on its next status check or restart. + /// + /// Examples: + /// cook logout + #[cfg(feature = "sync")] + #[command(long_about = "Sign out of CookCloud and delete the local session file")] + Logout(crate::logout::LogoutArgs), + /// Update CookCLI to the latest version /// /// Checks for new releases on GitHub and automatically downloads and diff --git a/src/lib.rs b/src/lib.rs index 033e87b..234f99a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,8 @@ pub mod doctor; pub mod import; #[cfg(feature = "sync")] pub mod login; +#[cfg(feature = "sync")] +pub mod logout; pub mod lsp; pub mod pantry; pub mod recipe; diff --git a/src/logout.rs b/src/logout.rs new file mode 100644 index 0000000..ee11ffb --- /dev/null +++ b/src/logout.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use clap::Args; + +use crate::server::sync::SyncSession; +use crate::Context; + +#[derive(Debug, Args)] +pub struct LogoutArgs {} + +pub fn run(_ctx: &Context, _args: LogoutArgs) -> Result<()> { + let session_path = crate::global_file_path("session.json") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from(".cook-session.json")); + + match SyncSession::load(&session_path) { + Ok(Some(_)) => { + SyncSession::delete(&session_path)?; + println!("Logged out."); + } + _ => { + println!("Not logged in."); + } + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index b9c9da9..7b837b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,8 @@ mod doctor; mod import; #[cfg(feature = "sync")] mod login; +#[cfg(feature = "sync")] +mod logout; mod lsp; mod pantry; mod recipe; @@ -81,6 +83,8 @@ pub fn main() -> Result<()> { Command::Lsp(args) => lsp::run(&ctx, args), #[cfg(feature = "sync")] Command::Login(args) => login::run(&ctx, args), + #[cfg(feature = "sync")] + Command::Logout(args) => logout::run(&ctx, args), #[cfg(feature = "self-update")] Command::Update(args) => update::run(args), } From f761124c1c4d10104c2ff69fbccc34cf757c493e Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sat, 16 May 2026 21:59:37 +0100 Subject: [PATCH 14/19] test: update help_output snapshot for login/logout commands --- tests/snapshots/snapshot_test__help_output.snap | 2 ++ 1 file changed, 2 insertions(+) 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) From 53c9946a409eb37e474c52936f21ae5a433dc59f Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sat, 16 May 2026 22:04:21 +0100 Subject: [PATCH 15/19] fix(sync): cancel pending device flow on logout Previously, if a user initiated /api/sync/login and then called /api/sync/logout before completing browser approval, the spawned polling task would continue and could write a session file for the new authorization after logout. --- src/server/handlers/sync.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/handlers/sync.rs b/src/server/handlers/sync.rs index 55e13c7..50b8889 100644 --- a/src/server/handlers/sync.rs +++ b/src/server/handlers/sync.rs @@ -163,6 +163,10 @@ pub async fn sync_cancel_login(State(state): State>) -> Json>, ) -> Result, (StatusCode, Json)> { + 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; } From 844f8d72d9a4bb2f95bdb9a14dd47c649fb1924b Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sun, 17 May 2026 09:00:15 +0100 Subject: [PATCH 16/19] fix(sync): address PR review feedback - Hold pending_device_flow lock across request_device_code to close the TOCTOU race where two concurrent /api/sync/login calls could both pass the is_some check and orphan one device-code polling task. - Drop unused device_code and interval fields from PendingDeviceFlow; they were captured separately into the spawn closure. - Recompute countdown remaining from a fixed deadline each tick so the displayed time stays accurate in throttled / backgrounded tabs. - Unify device_flow public API on DeviceFlowError (request_device_code no longer returns anyhow::Result). --- src/server/handlers/sync.rs | 23 +++++++++-------------- src/server/sync/device_flow.rs | 14 ++++++-------- src/server/sync/mod.rs | 4 ---- templates/preferences.html | 5 +++-- 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/server/handlers/sync.rs b/src/server/handlers/sync.rs index 50b8889..02d114f 100644 --- a/src/server/handlers/sync.rs +++ b/src/server/handlers/sync.rs @@ -64,14 +64,12 @@ pub async fn sync_login( )); } - { - let guard = state.pending_device_flow.lock().await; - if guard.is_some() { - return Err(( - StatusCode::CONFLICT, - Json(serde_json::json!({ "error": "Login already in progress" })), - )); - } + let mut pending_guard = state.pending_device_flow.lock().await; + if pending_guard.is_some() { + return Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "Login already in progress" })), + )); } let client = reqwest::Client::new(); @@ -89,17 +87,14 @@ pub async fn sync_login( let expires_at = Instant::now() + Duration::from_secs(dc.expires_in); let interval = Duration::from_secs(dc.interval); - let pending = PendingDeviceFlow { - device_code: dc.device_code.clone(), + *pending_guard = Some(PendingDeviceFlow { user_code: dc.user_code.clone(), verification_uri: dc.verification_uri.clone(), verification_uri_complete: dc.verification_uri_complete.clone(), expires_at, - interval, cancel: cancel.clone(), - }; - - *state.pending_device_flow.lock().await = Some(pending); + }); + drop(pending_guard); let state_clone = state.clone(); let device_code = dc.device_code.clone(); diff --git a/src/server/sync/device_flow.rs b/src/server/sync/device_flow.rs index 6e9bb13..1bea7f0 100644 --- a/src/server/sync/device_flow.rs +++ b/src/server/sync/device_flow.rs @@ -1,6 +1,5 @@ use std::time::{Duration, Instant}; -use anyhow::Context; use serde::{Deserialize, Serialize}; use tokio_util::sync::CancellationToken; @@ -56,24 +55,23 @@ pub enum DeviceFlowError { pub async fn request_device_code( client: &reqwest::Client, client_name: &str, -) -> anyhow::Result { +) -> Result { let url = format!("{}/oauth/device/code", endpoints::base_url()); let resp = client .post(&url) .json(&DeviceCodeRequest { client_name }) .send() - .await - .context("calling /oauth/device/code")?; + .await?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("device code request failed: HTTP {status}: {body}"); + return Err(DeviceFlowError::BadResponse(format!( + "device code request failed: HTTP {status}: {body}" + ))); } - resp.json::() - .await - .context("parsing device code response") + resp.json::().await.map_err(Into::into) } /// Polls /oauth/device/token until approved, denied, expired, or cancelled. diff --git a/src/server/sync/mod.rs b/src/server/sync/mod.rs index f53a275..9884f7f 100644 --- a/src/server/sync/mod.rs +++ b/src/server/sync/mod.rs @@ -12,14 +12,10 @@ use tokio_util::sync::CancellationToken; #[derive(Clone)] pub struct PendingDeviceFlow { - #[allow(dead_code)] - pub device_code: String, pub user_code: String, pub verification_uri: String, pub verification_uri_complete: String, pub expires_at: std::time::Instant, - #[allow(dead_code)] - pub interval: std::time::Duration, pub cancel: CancellationToken, } diff --git a/templates/preferences.html b/templates/preferences.html index 234b19c..4014f26 100644 --- a/templates/preferences.html +++ b/templates/preferences.html @@ -189,9 +189,11 @@

Documentation & Resources

document.getElementById('sync-login-open').href = p.verification_uri_complete; if (countdownHandle) clearInterval(countdownHandle); - let remaining = p.expires_in_secs ?? p.expires_in; + const initialSecs = p.expires_in_secs ?? p.expires_in; + const deadline = Date.now() + initialSecs * 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(); @@ -201,7 +203,6 @@

Documentation & Resources

const m = Math.floor(remaining / 60); const s = String(remaining % 60).padStart(2, '0'); expEl.textContent = `Expires in ${m}:${s}`; - remaining--; }; tick(); countdownHandle = setInterval(tick, 1000); From 51e9dc68654c5f3303021994f39d6ba4985ad86e Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Sun, 17 May 2026 09:25:02 +0100 Subject: [PATCH 17/19] fix(sync): use span for device-code element to avoid Playwright selector The 'should display current configuration' Playwright test uses a broad 'pre, code, .config-display' locator and asserts the first match is visible. The hidden device-code element matched first and broke the test. Switch to with identical monospace styling. --- templates/preferences.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/preferences.html b/templates/preferences.html index 4014f26..52873d4 100644 --- a/templates/preferences.html +++ b/templates/preferences.html @@ -77,7 +77,7 @@

Sign in to CookCloud

  • 2. Enter this code:
  • - ---- ---- + ---- ----

    From 55a46a54aff78f0389bb6f2b5c5cc60bac990ae0 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Mon, 18 May 2026 17:18:05 +0100 Subject: [PATCH 18/19] =?UTF-8?q?fix(sync):=20match=20RFC=206749=20=C2=A75?= =?UTF-8?q?.1=20token=20response=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server now returns {access_token, token_type, expires_in} per RFC 6749. Align the CLI deserializer before clients ship — renaming the field after release would be a breaking change. --- src/server/sync/device_flow.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/sync/device_flow.rs b/src/server/sync/device_flow.rs index 1bea7f0..1243d3a 100644 --- a/src/server/sync/device_flow.rs +++ b/src/server/sync/device_flow.rs @@ -30,7 +30,7 @@ struct TokenRequest<'a> { #[derive(Debug, Deserialize)] struct TokenSuccess { - token: String, + access_token: String, } #[derive(Debug, Deserialize)] @@ -108,7 +108,7 @@ pub async fn poll_for_token( if status.is_success() { let body: TokenSuccess = resp.json().await.map_err(DeviceFlowError::Network)?; - return Ok(body.token); + return Ok(body.access_token); } // 400 → parse {"error": "..."} per RFC 8628 From 90225c8fb49bd633d2dd518ca131bc91b085bd18 Mon Sep 17 00:00:00 2001 From: Alexey Dubovskoy Date: Mon, 18 May 2026 18:08:41 +0100 Subject: [PATCH 19/19] refactor(sync): address PR review (Ctrl-C UX, field naming) - login.rs: register the Ctrl-C handler before the "Press Enter" prompt and select between stdin and cancellation. Ctrl-C at the prompt now exits cleanly with a "Cancelled." message instead of terminating abruptly mid-print. - Rename LoginResponse.expires_in -> expires_in_secs so /api/sync/login and /api/sync/status agree on the field name. Drop the nullish- coalesce and the renderPending wrapper in preferences.html. --- src/login.rs | 27 +++++++++++++++++---------- src/server/handlers/sync.rs | 4 ++-- templates/preferences.html | 5 ++--- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/login.rs b/src/login.rs index 6a88292..3625852 100644 --- a/src/login.rs +++ b/src/login.rs @@ -41,11 +41,25 @@ async fn run_async() -> Result<()> { println!(); println!("(Press Enter to open it automatically, or Ctrl-C to abort.)"); - let _ = tokio::task::spawn_blocking(|| { + let cancel = CancellationToken::new(); + let cancel_for_signal = cancel.clone(); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + cancel_for_signal.cancel(); + }); + + let stdin_task = tokio::task::spawn_blocking(|| { let stdin = std::io::stdin(); let _ = stdin.lock().lines().next(); - }) - .await; + }); + + tokio::select! { + _ = stdin_task => {} + _ = cancel.cancelled() => { + println!(); + anyhow::bail!("Cancelled."); + } + } if let Err(e) = open::that(&dc.verification_uri_complete) { eprintln!("Couldn't open browser automatically: {e}"); @@ -55,13 +69,6 @@ async fn run_async() -> Result<()> { print!("Waiting for authorization"); std::io::stdout().flush().ok(); - let cancel = CancellationToken::new(); - let cancel_for_signal = cancel.clone(); - tokio::spawn(async move { - let _ = tokio::signal::ctrl_c().await; - cancel_for_signal.cancel(); - }); - let expires_at = Instant::now() + Duration::from_secs(dc.expires_in); let interval = Duration::from_secs(dc.interval); diff --git a/src/server/handlers/sync.rs b/src/server/handlers/sync.rs index 02d114f..eca5b8d 100644 --- a/src/server/handlers/sync.rs +++ b/src/server/handlers/sync.rs @@ -51,7 +51,7 @@ pub struct LoginResponse { pub user_code: String, pub verification_uri: String, pub verification_uri_complete: String, - pub expires_in: u64, + pub expires_in_secs: u64, } pub async fn sync_login( @@ -141,7 +141,7 @@ pub async fn sync_login( user_code: dc.user_code, verification_uri: dc.verification_uri, verification_uri_complete: dc.verification_uri_complete, - expires_in: dc.expires_in, + expires_in_secs: dc.expires_in, })) } diff --git a/templates/preferences.html b/templates/preferences.html index 52873d4..88db9b4 100644 --- a/templates/preferences.html +++ b/templates/preferences.html @@ -189,8 +189,7 @@

    Documentation & Resources

    document.getElementById('sync-login-open').href = p.verification_uri_complete; if (countdownHandle) clearInterval(countdownHandle); - const initialSecs = p.expires_in_secs ?? p.expires_in; - const deadline = Date.now() + initialSecs * 1000; + 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)); @@ -250,7 +249,7 @@

    Documentation & Resources

    return; } const body = await resp.json(); - renderPending({ ...body, expires_in_secs: body.expires_in }); + renderPending(body); startPolling(); } catch (e) {