diff --git a/.surface b/.surface index 935e43236..1b6b8238d 100644 --- a/.surface +++ b/.surface @@ -334,11 +334,15 @@ ARG basecamp project delete 00 ARG basecamp project show 00 ARG basecamp project trash 00 ARG basecamp project update 00 +ARG basecamp project wallpaper mode 00 +ARG basecamp project wallpaper set 00 ARG basecamp projects create 00 ARG basecamp projects delete 00 ARG basecamp projects show 00 ARG basecamp projects trash 00 ARG basecamp projects update 00 +ARG basecamp projects wallpaper mode 00 +ARG basecamp projects wallpaper set 00 ARG basecamp recordings 00 [type] ARG basecamp recordings active 00 ARG basecamp recordings archive 00 @@ -1028,6 +1032,10 @@ CMD basecamp project list CMD basecamp project show CMD basecamp project trash CMD basecamp project update +CMD basecamp project wallpaper +CMD basecamp project wallpaper mode +CMD basecamp project wallpaper remove +CMD basecamp project wallpaper set CMD basecamp projects CMD basecamp projects create CMD basecamp projects delete @@ -1035,6 +1043,10 @@ CMD basecamp projects list CMD basecamp projects show CMD basecamp projects trash CMD basecamp projects update +CMD basecamp projects wallpaper +CMD basecamp projects wallpaper mode +CMD basecamp projects wallpaper remove +CMD basecamp projects wallpaper set CMD basecamp recordings CMD basecamp recordings active CMD basecamp recordings archive @@ -13236,6 +13248,91 @@ FLAG basecamp project update --stats type=bool FLAG basecamp project update --styled type=bool FLAG basecamp project update --todolist type=string FLAG basecamp project update --verbose type=count +FLAG basecamp project wallpaper --account type=string +FLAG basecamp project wallpaper --agent type=bool +FLAG basecamp project wallpaper --cache-dir type=string +FLAG basecamp project wallpaper --count type=bool +FLAG basecamp project wallpaper --help type=bool +FLAG basecamp project wallpaper --hints type=bool +FLAG basecamp project wallpaper --ids-only type=bool +FLAG basecamp project wallpaper --in type=string +FLAG basecamp project wallpaper --jq type=string +FLAG basecamp project wallpaper --json type=bool +FLAG basecamp project wallpaper --markdown type=bool +FLAG basecamp project wallpaper --md type=bool +FLAG basecamp project wallpaper --no-hints type=bool +FLAG basecamp project wallpaper --no-stats type=bool +FLAG basecamp project wallpaper --profile type=string +FLAG basecamp project wallpaper --project type=string +FLAG basecamp project wallpaper --quiet type=bool +FLAG basecamp project wallpaper --stats type=bool +FLAG basecamp project wallpaper --styled type=bool +FLAG basecamp project wallpaper --todolist type=string +FLAG basecamp project wallpaper --verbose type=count +FLAG basecamp project wallpaper mode --account type=string +FLAG basecamp project wallpaper mode --agent type=bool +FLAG basecamp project wallpaper mode --cache-dir type=string +FLAG basecamp project wallpaper mode --count type=bool +FLAG basecamp project wallpaper mode --help type=bool +FLAG basecamp project wallpaper mode --hints type=bool +FLAG basecamp project wallpaper mode --ids-only type=bool +FLAG basecamp project wallpaper mode --in type=string +FLAG basecamp project wallpaper mode --jq type=string +FLAG basecamp project wallpaper mode --json type=bool +FLAG basecamp project wallpaper mode --markdown type=bool +FLAG basecamp project wallpaper mode --md type=bool +FLAG basecamp project wallpaper mode --no-hints type=bool +FLAG basecamp project wallpaper mode --no-stats type=bool +FLAG basecamp project wallpaper mode --profile type=string +FLAG basecamp project wallpaper mode --project type=string +FLAG basecamp project wallpaper mode --quiet type=bool +FLAG basecamp project wallpaper mode --stats type=bool +FLAG basecamp project wallpaper mode --styled type=bool +FLAG basecamp project wallpaper mode --todolist type=string +FLAG basecamp project wallpaper mode --verbose type=count +FLAG basecamp project wallpaper remove --account type=string +FLAG basecamp project wallpaper remove --agent type=bool +FLAG basecamp project wallpaper remove --cache-dir type=string +FLAG basecamp project wallpaper remove --count type=bool +FLAG basecamp project wallpaper remove --help type=bool +FLAG basecamp project wallpaper remove --hints type=bool +FLAG basecamp project wallpaper remove --ids-only type=bool +FLAG basecamp project wallpaper remove --in type=string +FLAG basecamp project wallpaper remove --jq type=string +FLAG basecamp project wallpaper remove --json type=bool +FLAG basecamp project wallpaper remove --markdown type=bool +FLAG basecamp project wallpaper remove --md type=bool +FLAG basecamp project wallpaper remove --no-hints type=bool +FLAG basecamp project wallpaper remove --no-stats type=bool +FLAG basecamp project wallpaper remove --profile type=string +FLAG basecamp project wallpaper remove --project type=string +FLAG basecamp project wallpaper remove --quiet type=bool +FLAG basecamp project wallpaper remove --stats type=bool +FLAG basecamp project wallpaper remove --styled type=bool +FLAG basecamp project wallpaper remove --todolist type=string +FLAG basecamp project wallpaper remove --verbose type=count +FLAG basecamp project wallpaper set --account type=string +FLAG basecamp project wallpaper set --agent type=bool +FLAG basecamp project wallpaper set --cache-dir type=string +FLAG basecamp project wallpaper set --count type=bool +FLAG basecamp project wallpaper set --help type=bool +FLAG basecamp project wallpaper set --hints type=bool +FLAG basecamp project wallpaper set --ids-only type=bool +FLAG basecamp project wallpaper set --in type=string +FLAG basecamp project wallpaper set --jq type=string +FLAG basecamp project wallpaper set --json type=bool +FLAG basecamp project wallpaper set --markdown type=bool +FLAG basecamp project wallpaper set --md type=bool +FLAG basecamp project wallpaper set --mode type=string +FLAG basecamp project wallpaper set --no-hints type=bool +FLAG basecamp project wallpaper set --no-stats type=bool +FLAG basecamp project wallpaper set --profile type=string +FLAG basecamp project wallpaper set --project type=string +FLAG basecamp project wallpaper set --quiet type=bool +FLAG basecamp project wallpaper set --stats type=bool +FLAG basecamp project wallpaper set --styled type=bool +FLAG basecamp project wallpaper set --todolist type=string +FLAG basecamp project wallpaper set --verbose type=count FLAG basecamp projects --account type=string FLAG basecamp projects --agent type=bool FLAG basecamp projects --cache-dir type=string @@ -13393,6 +13490,91 @@ FLAG basecamp projects update --stats type=bool FLAG basecamp projects update --styled type=bool FLAG basecamp projects update --todolist type=string FLAG basecamp projects update --verbose type=count +FLAG basecamp projects wallpaper --account type=string +FLAG basecamp projects wallpaper --agent type=bool +FLAG basecamp projects wallpaper --cache-dir type=string +FLAG basecamp projects wallpaper --count type=bool +FLAG basecamp projects wallpaper --help type=bool +FLAG basecamp projects wallpaper --hints type=bool +FLAG basecamp projects wallpaper --ids-only type=bool +FLAG basecamp projects wallpaper --in type=string +FLAG basecamp projects wallpaper --jq type=string +FLAG basecamp projects wallpaper --json type=bool +FLAG basecamp projects wallpaper --markdown type=bool +FLAG basecamp projects wallpaper --md type=bool +FLAG basecamp projects wallpaper --no-hints type=bool +FLAG basecamp projects wallpaper --no-stats type=bool +FLAG basecamp projects wallpaper --profile type=string +FLAG basecamp projects wallpaper --project type=string +FLAG basecamp projects wallpaper --quiet type=bool +FLAG basecamp projects wallpaper --stats type=bool +FLAG basecamp projects wallpaper --styled type=bool +FLAG basecamp projects wallpaper --todolist type=string +FLAG basecamp projects wallpaper --verbose type=count +FLAG basecamp projects wallpaper mode --account type=string +FLAG basecamp projects wallpaper mode --agent type=bool +FLAG basecamp projects wallpaper mode --cache-dir type=string +FLAG basecamp projects wallpaper mode --count type=bool +FLAG basecamp projects wallpaper mode --help type=bool +FLAG basecamp projects wallpaper mode --hints type=bool +FLAG basecamp projects wallpaper mode --ids-only type=bool +FLAG basecamp projects wallpaper mode --in type=string +FLAG basecamp projects wallpaper mode --jq type=string +FLAG basecamp projects wallpaper mode --json type=bool +FLAG basecamp projects wallpaper mode --markdown type=bool +FLAG basecamp projects wallpaper mode --md type=bool +FLAG basecamp projects wallpaper mode --no-hints type=bool +FLAG basecamp projects wallpaper mode --no-stats type=bool +FLAG basecamp projects wallpaper mode --profile type=string +FLAG basecamp projects wallpaper mode --project type=string +FLAG basecamp projects wallpaper mode --quiet type=bool +FLAG basecamp projects wallpaper mode --stats type=bool +FLAG basecamp projects wallpaper mode --styled type=bool +FLAG basecamp projects wallpaper mode --todolist type=string +FLAG basecamp projects wallpaper mode --verbose type=count +FLAG basecamp projects wallpaper remove --account type=string +FLAG basecamp projects wallpaper remove --agent type=bool +FLAG basecamp projects wallpaper remove --cache-dir type=string +FLAG basecamp projects wallpaper remove --count type=bool +FLAG basecamp projects wallpaper remove --help type=bool +FLAG basecamp projects wallpaper remove --hints type=bool +FLAG basecamp projects wallpaper remove --ids-only type=bool +FLAG basecamp projects wallpaper remove --in type=string +FLAG basecamp projects wallpaper remove --jq type=string +FLAG basecamp projects wallpaper remove --json type=bool +FLAG basecamp projects wallpaper remove --markdown type=bool +FLAG basecamp projects wallpaper remove --md type=bool +FLAG basecamp projects wallpaper remove --no-hints type=bool +FLAG basecamp projects wallpaper remove --no-stats type=bool +FLAG basecamp projects wallpaper remove --profile type=string +FLAG basecamp projects wallpaper remove --project type=string +FLAG basecamp projects wallpaper remove --quiet type=bool +FLAG basecamp projects wallpaper remove --stats type=bool +FLAG basecamp projects wallpaper remove --styled type=bool +FLAG basecamp projects wallpaper remove --todolist type=string +FLAG basecamp projects wallpaper remove --verbose type=count +FLAG basecamp projects wallpaper set --account type=string +FLAG basecamp projects wallpaper set --agent type=bool +FLAG basecamp projects wallpaper set --cache-dir type=string +FLAG basecamp projects wallpaper set --count type=bool +FLAG basecamp projects wallpaper set --help type=bool +FLAG basecamp projects wallpaper set --hints type=bool +FLAG basecamp projects wallpaper set --ids-only type=bool +FLAG basecamp projects wallpaper set --in type=string +FLAG basecamp projects wallpaper set --jq type=string +FLAG basecamp projects wallpaper set --json type=bool +FLAG basecamp projects wallpaper set --markdown type=bool +FLAG basecamp projects wallpaper set --md type=bool +FLAG basecamp projects wallpaper set --mode type=string +FLAG basecamp projects wallpaper set --no-hints type=bool +FLAG basecamp projects wallpaper set --no-stats type=bool +FLAG basecamp projects wallpaper set --profile type=string +FLAG basecamp projects wallpaper set --project type=string +FLAG basecamp projects wallpaper set --quiet type=bool +FLAG basecamp projects wallpaper set --stats type=bool +FLAG basecamp projects wallpaper set --styled type=bool +FLAG basecamp projects wallpaper set --todolist type=string +FLAG basecamp projects wallpaper set --verbose type=count FLAG basecamp recordings --account type=string FLAG basecamp recordings --agent type=bool FLAG basecamp recordings --all type=bool @@ -19873,6 +20055,10 @@ SUB basecamp project list SUB basecamp project show SUB basecamp project trash SUB basecamp project update +SUB basecamp project wallpaper +SUB basecamp project wallpaper mode +SUB basecamp project wallpaper remove +SUB basecamp project wallpaper set SUB basecamp projects SUB basecamp projects create SUB basecamp projects delete @@ -19880,6 +20066,10 @@ SUB basecamp projects list SUB basecamp projects show SUB basecamp projects trash SUB basecamp projects update +SUB basecamp projects wallpaper +SUB basecamp projects wallpaper mode +SUB basecamp projects wallpaper remove +SUB basecamp projects wallpaper set SUB basecamp recordings SUB basecamp recordings active SUB basecamp recordings archive diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 5a8bc5452..bb0d99b41 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,16 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 55 | 211 | +| ✅ Implemented | 55 | 213 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **59** | **223** | +| **Total tracked** | **59** | **225** | -**211 of 211 tracked in-scope endpoints.** Spotlighting a recording on its +**213 of 213 tracked in-scope endpoints.** Uploading and removing a project's +wallpaper (`PUT`/`DELETE /projects/:id/wallpaper.json`, added by +[bc3#14062](https://github.com/basecamp/bc3/pull/14062) and shipped in +[SDK v0.28.0](https://github.com/basecamp/basecamp-sdk/releases/tag/v0.28.0)) +land as `projects wallpaper`. Spotlighting a recording on its project or template home page (`POST`/`DELETE /recordings/:id/spotlight.json`, SDK v0.27.0) lands as `spotlight` and `unspotlight`. The two embed endpoints bc3 documents in `embeds.md` — `POST /embeds.json` and `GET /embeds/:sgid.json` — land as @@ -201,6 +205,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A |---------|-----------|-------------|--------|-------|----------|-------| | **Core** | | projects | 9 | `projects` | ✅ | BC4 | - | list, show, create, update, delete | +| projects (wallpaper) | 2 | `projects wallpaper` | ✅ | BC5 | - | set (`PUT /projects/:id/wallpaper.json`, multipart `wallpaper` with an optional `mode`), remove (`DELETE` on the same route; 204 with or without a wallpaper). `mode` sends `wallpaper_mode` through the existing `PUT /projects/:id.json`, with the current name the SDK requires. PNG, JPEG, GIF, WebP, AVIF, HEIF or HEIC up to 10 MB, checked before the upload using a local MIME hint; the server applies its shared image validations. People and personal assistants follow project-edit permissions; shared agents are refused with 403. Project JSON carries `wallpaper {url, mode}` when one is attached, so `projects show` shows it. Project-scoped via `--in`/`--project` | | todos | 12 | `todos`, `todo`, `done`, `reopen` | ✅ | BC4 | - | list, show, create, update, complete, uncomplete, position (BC5: `steps` shown on `todos show`, plus `subtasks_count`/`subtasks_completed_count`/`subtasks_url`; edit via `subtasks`). `todos create --loose` creates on the to-do set, outside any list | | subtasks | 8 | `subtasks` | ✅ | BC5 | - | list (`GET /recordings/:id/subtasks.json`, paginated), show, create (`POST` on the same list route), update (partial; `--no-due`/`--no-assignees` clear), complete/uncomplete (`POST`/`DELETE /subtasks/:id/completion.json`), move (`PUT /subtasks/:id/position.json`, 1-based), delete. Account-scoped flat routes (bc3#12659), so no `--in`. Parents are to-dos and cards only. Same `Kanban::Step` records as `card_table_steps` | | todolists | 9 | `todolists` | ✅ | BC4 | - | list, show, create, update, position | diff --git a/e2e/projects.bats b/e2e/projects.bats new file mode 100644 index 000000000..973aeba72 --- /dev/null +++ b/e2e/projects.bats @@ -0,0 +1,93 @@ +#!/usr/bin/env bats +# projects.bats - Test projects command input handling + +load test_helper + + +# Help + +@test "projects wallpaper without subcommand shows help" { + run basecamp projects wallpaper + assert_success + assert_output_contains "COMMANDS" + assert_output_contains "set" + assert_output_contains "mode" + assert_output_contains "remove" +} + +@test "projects wallpaper set help names the formats and the size cap" { + run basecamp projects wallpaper set --help + assert_success + assert_output_contains "PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC files up to 10 MB" +} + + +# Input errors that need no network + +@test "projects wallpaper set without a file shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper set + assert_failure + assert_json_value '.error' ' required' + assert_json_value '.code' 'usage' +} + +@test "projects wallpaper set refuses an SVG" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + printf '' > image.svg + + run basecamp projects wallpaper set image.svg + assert_failure + assert_json_value '.error' 'image.svg: unsupported image type image/svg+xml (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)' + assert_json_value '.code' 'usage' +} + +@test "projects wallpaper set refuses a file over 10 MB" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + dd if=/dev/zero of=big.png bs=1 count=1 seek=10485760 2>/dev/null + + run basecamp projects wallpaper set big.png + assert_failure + assert_json_value '.error' 'big.png exceeds maximum wallpaper size of 10 MB' +} + +@test "projects wallpaper set refuses an unknown --mode" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + printf '\x89PNG\r\n\x1a\n' > skyline.png + + run basecamp projects wallpaper set skyline.png --mode stretch + assert_failure + assert_json_value '.error' 'unknown wallpaper mode "stretch"' + assert_json_value '.hint' 'Use fill or tile' +} + +@test "projects wallpaper mode without a mode shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper mode + assert_failure + assert_json_value '.error' ' required' +} + +@test "projects wallpaper mode refuses an unknown mode" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper mode stretch + assert_failure + assert_json_value '.error' 'unknown wallpaper mode "stretch"' +} + +@test "projects wallpaper remove takes no arguments" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects wallpaper remove skyline.png + assert_failure +} diff --git a/e2e/smoke/smoke_projects_write.bats b/e2e/smoke/smoke_projects_write.bats index aa7fea558..c9a9fe784 100644 --- a/e2e/smoke/smoke_projects_write.bats +++ b/e2e/smoke/smoke_projects_write.bats @@ -30,6 +30,42 @@ setup_file() { assert_json_value '.ok' 'true' } +@test "projects wallpaper set uploads a tiled wallpaper" { + local id_file="$BATS_FILE_TMPDIR/created_project_id" + [[ -f "$id_file" ]] || mark_unverifiable "No project created in prior test" + local proj_id + proj_id=$(<"$id_file") + local png="$BATS_FILE_TMPDIR/wallpaper.png" + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' | + base64 -d > "$png" + + run_smoke basecamp projects wallpaper set "$png" --mode tile --project "$proj_id" --json + assert_success + assert_json_value '.data.wallpaper.mode' 'tile' + assert_json_not_null '.data.wallpaper.url' + touch "$BATS_FILE_TMPDIR/wallpaper_set" +} + +@test "projects wallpaper mode changes the layout" { + [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" + local proj_id + proj_id=$(<"$BATS_FILE_TMPDIR/created_project_id") + + run_smoke basecamp projects wallpaper mode fill --project "$proj_id" --json + assert_success + assert_json_value '.data.wallpaper.mode' 'fill' +} + +@test "projects wallpaper remove clears the wallpaper" { + [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" + local proj_id + proj_id=$(<"$BATS_FILE_TMPDIR/created_project_id") + + run_smoke basecamp projects wallpaper remove --project "$proj_id" --json + assert_success + assert_json_value '.data.wallpaper' 'null' +} + @test "projects delete deletes a project" { local id_file="$BATS_FILE_TMPDIR/created_project_id" [[ -f "$id_file" ]] || mark_unverifiable "No project created in prior test" diff --git a/go.mod b/go.mod index 6ad7e9f21..9cdd2fbc6 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9 + github.com/basecamp/basecamp-sdk/go v0.28.0 github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index bf81fac46..236448fc9 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9 h1:ZcW7c/cnQTgFkNHMcnkxPqHIko2d0v2MrOgyykBrOj0= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.28.0 h1:OwMD/mg/2QvVGtvSRkMraCOjrYVQk3Dgz/IJPPA6P90= +github.com/basecamp/basecamp-sdk/go v0.28.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 h1:3NfGs+zx23Tp6cf+97+UewrJHlzZMLzmvEXOmOJyQAk= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/commands/commands.go b/internal/commands/commands.go index ca6c799cc..7936ac2d7 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -34,7 +34,7 @@ func CommandCategories() []CommandCategory { { Name: "Core Commands", Commands: []CommandInfo{ - {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete"}}, + {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete", "wallpaper"}}, {Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "trash", "archive", "restore"}}, {Name: "subtasks", Category: "core", Description: "Manage subtasks on to-dos and cards", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "move", "delete"}}, {Name: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "position", "trash", "archive", "restore"}}, diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 4cf270ddc..015066dd9 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -3,6 +3,8 @@ package commands import ( "errors" "fmt" + "os" + "path/filepath" "slices" "sort" "strconv" @@ -15,6 +17,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/appctx" "github.com/basecamp/basecamp-cli/internal/completion" "github.com/basecamp/basecamp-cli/internal/output" + "github.com/basecamp/basecamp-cli/internal/richtext" ) // NewProjectsCmd creates the projects command group. @@ -37,6 +40,7 @@ func NewProjectsCmd() *cobra.Command { newProjectsCreateCmd(), newProjectsUpdateCmd(), newProjectsDeleteCmd(), + newProjectsWallpaperCmd(), ) return cmd @@ -427,6 +431,286 @@ func newProjectsDeleteCmd() *cobra.Command { } } +// wallpaperFormats match the server's shared image validation, not the narrower web picker. +var wallpaperFormats = []struct{ mime, name string }{ + {"image/png", "PNG"}, + {"image/jpeg", "JPEG"}, + {"image/gif", "GIF"}, + {"image/webp", "WebP"}, + {"image/avif", "AVIF"}, + {"image/heif", "HEIF"}, + {"image/heic", "HEIC"}, +} + +const maxWallpaperMB = 10 + +var wallpaperModes = []string{"fill", "tile"} + +func wallpaperAgentNotes() map[string]string { + return map[string]string{"agent_notes": "People and personal assistants follow project-edit permissions; shared agents are refused (403)\n.data.wallpaper is {url, mode}, absent when there is none; url requires authentication and redirects to the image"} +} + +func newProjectsWallpaperCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "wallpaper", + Short: "Manage a project's wallpaper", + Annotations: wallpaperAgentNotes(), + Long: `Set, lay out, or remove a project's wallpaper. + +The project comes from --project (or --in), then the configured default. +People and personal assistants with project-edit permissions can change its wallpaper. +Shared agents are refused.`, + Example: ` $ basecamp projects wallpaper set skyline.jpg --mode fill --project 12345 + $ basecamp projects wallpaper mode tile --project 12345 + $ basecamp projects wallpaper remove --project 12345`, + } + + cmd.AddCommand( + newProjectsWallpaperSetCmd(), + newProjectsWallpaperModeCmd(), + newProjectsWallpaperRemoveCmd(), + ) + + return cmd +} + +func newProjectsWallpaperSetCmd() *cobra.Command { + var mode string + + cmd := &cobra.Command{ + Use: "set ", + Annotations: wallpaperAgentNotes(), + Short: "Upload or replace a project's wallpaper", + Long: fmt.Sprintf(`Upload or replace the project's wallpaper. + +Accepts %s files up to %d MB. +--mode lays the image out (%s); without it the project keeps +its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpaperModes, " or ")), + Example: " $ basecamp projects wallpaper set skyline.jpg --mode fill --project 12345", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "") + } + if mode != "" { + if err := validateWallpaperMode(mode); err != nil { + return err + } + } + path, contentType, err := wallpaperFile(args[0]) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + projectID, err := wallpaperProjectID(cmd, app) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("%s: %w", args[0], err) + } + defer f.Close() + + filename := filepath.Base(path) + opts := &basecamp.UpdateProjectWallpaperOptions{Mode: mode} + if err := app.Account().Projects().UpdateWallpaper(cmd.Context(), projectID, f, filename, contentType, opts); err != nil { + return convertSDKError(err) + } + + summary := "Wallpaper set: " + filename + if mode != "" { + summary += " (" + mode + ")" + } + return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, summary, + output.Breadcrumb{ + Action: "mode", + Cmd: fmt.Sprintf("basecamp projects wallpaper mode --project %d", projectID), + Description: "Change how the wallpaper is laid out", + }, + output.Breadcrumb{ + Action: "remove", + Cmd: fmt.Sprintf("basecamp projects wallpaper remove --project %d", projectID), + Description: "Remove the wallpaper", + }, + ) + }, + } + + cmd.Flags().StringVar(&mode, "mode", "", "Lay the image out: "+strings.Join(wallpaperModes, " or ")+" (default: keep the current mode)") + _ = cmd.RegisterFlagCompletionFunc("mode", cobra.FixedCompletions(wallpaperModes, cobra.ShellCompDirectiveNoFileComp)) + + return cmd +} + +func newProjectsWallpaperModeCmd() *cobra.Command { + return &cobra.Command{ + Use: "mode ", + Annotations: wallpaperAgentNotes(), + Short: "Change how a project's wallpaper is laid out", + Long: `Change how the project's wallpaper is laid out. fill scales one copy to +cover the page; tile repeats the image at its own size. + +A project with no wallpaper keeps the mode for the next one uploaded.`, + Example: " $ basecamp projects wallpaper mode tile --project 12345", + Args: cobra.MaximumNArgs(1), + ValidArgs: wallpaperModes, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "") + } + mode := args[0] + if err := validateWallpaperMode(mode); err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + projectID, err := wallpaperProjectID(cmd, app) + if err != nil { + return err + } + + // The SDK's update requires a name, so send the current one back. + current, err := app.Account().Projects().Get(cmd.Context(), projectID) + if err != nil { + return convertSDKError(err) + } + req := &basecamp.UpdateProjectRequest{Name: current.Name, WallpaperMode: mode} + project, err := app.Account().Projects().Update(cmd.Context(), projectID, req) + if err != nil { + return convertSDKError(err) + } + + return respondWithWallpaperProject(cmd, app, projectID, scope, project, "Wallpaper mode set to "+mode, + output.Breadcrumb{ + Action: "set", + Cmd: fmt.Sprintf("basecamp projects wallpaper set --project %d", projectID), + Description: "Upload a wallpaper", + }, + ) + }, + } +} + +func newProjectsWallpaperRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove", + Annotations: wallpaperAgentNotes(), + Short: "Remove a project's wallpaper", + Long: "Remove the project's wallpaper. Removing it from a project that has none succeeds.", + Example: " $ basecamp projects wallpaper remove --project 12345", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + projectID, err := wallpaperProjectID(cmd, app) + if err != nil { + return err + } + + if err := app.Account().Projects().RemoveWallpaper(cmd.Context(), projectID); err != nil { + return convertSDKError(err) + } + + return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Wallpaper removed", + output.Breadcrumb{ + Action: "set", + Cmd: fmt.Sprintf("basecamp projects wallpaper set --project %d", projectID), + Description: "Upload a new wallpaper", + }, + ) + }, + } +} + +// wallpaperFile checks the size and MIME hint before uploading. The server's +// shared image validations remain authoritative. +func wallpaperFile(arg string) (path, contentType string, err error) { + path = richtext.NormalizeDragPath(arg) + if err := richtext.ValidateFile(path); err != nil { + return "", "", fmt.Errorf("%s: %w", arg, err) + } + info, err := os.Stat(path) + if err != nil { + return "", "", fmt.Errorf("%s: %w", arg, err) + } + name := filepath.Base(path) + if info.Size() > maxWallpaperMB<<20 { + return "", "", output.ErrUsage(fmt.Sprintf("%s exceeds maximum wallpaper size of %d MB", name, maxWallpaperMB)) + } + contentType = richtext.DetectMIME(path) + for _, f := range wallpaperFormats { + if f.mime == contentType { + return path, contentType, nil + } + } + return "", "", output.ErrUsage(fmt.Sprintf("%s: unsupported image type %s (use %s)", name, contentType, wallpaperFormatNames())) +} + +// wallpaperFormatNames lists the accepted formats for help and validation errors. +func wallpaperFormatNames() string { + names := make([]string, len(wallpaperFormats)) + for i, f := range wallpaperFormats { + names[i] = f.name + } + return strings.Join(names[:len(names)-1], ", ") + ", or " + names[len(names)-1] +} + +func validateWallpaperMode(mode string) error { + if slices.Contains(wallpaperModes, mode) { + return nil + } + return output.ErrUsageHint(fmt.Sprintf("unknown wallpaper mode %q", mode), "Use "+strings.Join(wallpaperModes, " or ")) +} + +func wallpaperProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { + if err := ensureAccount(cmd, app); err != nil { + return 0, err + } + resolved, err := resolveProjectID(cmd, app, "") + if err != nil { + return 0, err + } + projectID, err := strconv.ParseInt(resolved, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid project ID") + } + return projectID, nil +} + +// respondWithWallpaperProject answers with the project as it now stands, so +// the output carries its wallpaper. The change has already happened, so a +// failed read falls back to what the caller has, with a diagnostic, rather +// than failing the command. +func respondWithWallpaperProject(cmd *cobra.Command, app *appctx.App, projectID int64, scope followUpScope, fallback any, summary string, crumbs ...output.Breadcrumb) error { + crumbs = append([]output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp projects show %d", projectID), + Description: "View the project", + }}, crumbs...) + scopeArgs := scope.args(app.Config.AccountID) + for i := range crumbs { + crumbs[i].Cmd += scopeArgs + } + opts := []output.ResponseOption{ + output.WithEntity("project"), + output.WithSummary(summary), + output.WithBreadcrumbs(crumbs...), + } + data := fallback + project, err := app.Account().Projects().Get(cmd.Context(), projectID) + if err != nil { + opts = append(opts, output.WithDiagnostic(fmt.Sprintf("%s, but fetching the latest project state failed: %v", summary, err))) + } else { + data = project + } + return app.OK(data, opts...) +} + // convertSDKError converts SDK errors to output errors for consistent CLI error handling. func convertSDKError(err error) error { if err == nil { diff --git a/internal/commands/projects_wallpaper_test.go b/internal/commands/projects_wallpaper_test.go new file mode 100644 index 000000000..761f3a71d --- /dev/null +++ b/internal/commands/projects_wallpaper_test.go @@ -0,0 +1,411 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/output" +) + +const wallpaperURL = "https://3.basecampapi.com/99999/projects/123/wallpaper?v=1" + +// wallpaperServer plays bc3's side of the wallpaper endpoints for project 123: +// it records each request and keeps the wallpaper the requests leave behind, +// so a re-read of the project shows what the change did. +type wallpaperServer struct { + t *testing.T + mu sync.Mutex + requests []wallpaperRequest + attached bool + mode string + refuse string +} + +type wallpaperRequest struct { + method, path, mode string + upload *wallpaperUpload + update map[string]any +} + +type wallpaperUpload struct { + field, filename, contentType string + data []byte +} + +func newWallpaperServer(t *testing.T) (*wallpaperServer, *appctx.App, *bytes.Buffer) { + t.Helper() + s := &wallpaperServer{t: t, mode: "fill"} + server := httptest.NewServer(http.HandlerFunc(s.serve)) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + return s, app, buf +} + +func (s *wallpaperServer) serve(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + req := wallpaperRequest{method: r.Method, path: r.URL.Path, mode: r.URL.Query().Get("mode")} + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodPut && r.URL.Path == "/99999/projects/123/wallpaper.json": + req.upload = s.readUpload(r) + s.requests = append(s.requests, req) + if s.refuse != "" { + w.WriteHeader(http.StatusUnprocessableEntity) + fmt.Fprintf(w, `{"error":%q}`, s.refuse) + return + } + s.attached = true + if req.mode != "" { + s.mode = req.mode + } + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodDelete && r.URL.Path == "/99999/projects/123/wallpaper.json": + s.requests = append(s.requests, req) + s.attached = false + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPut && r.URL.Path == "/99999/projects/123": + require.NoError(s.t, json.NewDecoder(r.Body).Decode(&req.update)) + s.requests = append(s.requests, req) + if mode, ok := req.update["wallpaper_mode"].(string); ok { + s.mode = mode + } + fmt.Fprint(w, s.projectJSON()) + case r.Method == http.MethodGet && r.URL.Path == "/99999/projects/123": + s.requests = append(s.requests, req) + fmt.Fprint(w, s.projectJSON()) + default: + s.requests = append(s.requests, req) + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":"not found"}`) + } +} + +func (s *wallpaperServer) readUpload(r *http.Request) *wallpaperUpload { + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(s.t, err) + part, err := multipart.NewReader(r.Body, params["boundary"]).NextPart() + require.NoError(s.t, err) + data, err := io.ReadAll(part) + require.NoError(s.t, err) + return &wallpaperUpload{ + field: part.FormName(), + filename: part.FileName(), + contentType: part.Header.Get("Content-Type"), + data: data, + } +} + +func (s *wallpaperServer) projectJSON() string { + if !s.attached { + return `{"id":123,"name":"Launch","description":"Keep me"}` + } + return fmt.Sprintf(`{"id":123,"name":"Launch","description":"Keep me","wallpaper":{"url":%q,"mode":%q}}`, wallpaperURL, s.mode) +} + +func (s *wallpaperServer) calls() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.requests)) + for i, r := range s.requests { + out[i] = r.method + " " + r.path + } + return out +} + +func (s *wallpaperServer) request(method, path string) wallpaperRequest { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range s.requests { + if r.method == method && r.path == path { + return r + } + } + s.t.Fatalf("no %s %s among %v", method, path, s.requests) + return wallpaperRequest{} +} + +type wallpaperEnvelope struct { + OK bool `json:"ok"` + Summary string `json:"summary"` + Notice string `json:"notice"` + Breadcrumbs []output.Breadcrumb `json:"breadcrumbs"` + Data map[string]any `json:"data"` +} + +func decodeWallpaperEnvelope(t *testing.T, buf *bytes.Buffer) wallpaperEnvelope { + t.Helper() + var env wallpaperEnvelope + require.NoError(t, json.Unmarshal(buf.Bytes(), &env), "output: %s", buf.String()) + return env +} + +func writeWallpaperFile(t *testing.T, name string, size int64) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte("\x89PNG\r\n\x1a\n"), 0o600)) + if size > 0 { + require.NoError(t, os.Truncate(path, size)) + } + return path +} + +func TestProjectsWallpaperSetUploadsTheFileAndReturnsTheProject(t *testing.T) { + s, app, buf := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, "skyline.png", 0) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path, "--mode", "tile")) + + assert.Equal(t, []string{ + "PUT /99999/projects/123/wallpaper.json", + "GET /99999/projects/123", + }, s.calls()) + put := s.request(http.MethodPut, "/99999/projects/123/wallpaper.json") + assert.Equal(t, "tile", put.mode) + require.NotNil(t, put.upload) + assert.Equal(t, "wallpaper", put.upload.field) + assert.Equal(t, "skyline.png", put.upload.filename) + assert.Equal(t, "image/png", put.upload.contentType) + assert.Equal(t, []byte("\x89PNG\r\n\x1a\n"), put.upload.data) + + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, "Wallpaper set: skyline.png (tile)", env.Summary) + assert.Equal(t, map[string]any{"url": wallpaperURL, "mode": "tile"}, env.Data["wallpaper"]) + assert.Equal(t, "basecamp projects show 123", env.Breadcrumbs[0].Cmd) +} + +func TestProjectsWallpaperFollowUpsPreserveScope(t *testing.T) { + for _, scope := range []struct { + name, profile, accountSource, suffix string + }{ + {"configured account", "", string(config.SourceGlobal), ""}, + {"active profile", "staging", "profile", " --profile staging"}, + {"explicit account override", "staging", string(config.SourceFlag), " --profile staging --account 99999"}, + {"selected account", "", string(config.SourcePrompt), " --account 99999"}, + } { + for _, action := range []string{"set", "mode", "remove"} { + t.Run(scope.name+"/"+action, func(t *testing.T) { + _, app, buf := newWallpaperServer(t) + app.Flags.Project = "123" + app.Config.ActiveProfile = scope.profile + app.Config.Sources = map[string]string{"account_id": scope.accountSource} + args := []string{"wallpaper", action} + want := map[string]string{"show": "basecamp projects show 123" + scope.suffix} + switch action { + case "set": + args = append(args, writeWallpaperFile(t, "image.png", 0)) + want["mode"] = "basecamp projects wallpaper mode --project 123" + scope.suffix + want["remove"] = "basecamp projects wallpaper remove --project 123" + scope.suffix + case "mode": + args = append(args, "tile") + fallthrough + case "remove": + want["set"] = "basecamp projects wallpaper set --project 123" + scope.suffix + } + + require.NoError(t, executeCommand(NewProjectsCmd(), app, args...)) + env := decodeWallpaperEnvelope(t, buf) + require.Len(t, env.Breadcrumbs, len(want)) + got := make(map[string]string, len(env.Breadcrumbs)) + for _, breadcrumb := range env.Breadcrumbs { + got[breadcrumb.Action] = breadcrumb.Cmd + } + assert.Equal(t, want, got) + }) + } + } +} + +func TestProjectsWallpaperSetWithoutModeKeepsTheCurrentMode(t *testing.T) { + s, app, buf := newWallpaperServer(t) + app.Config.ProjectID = "123" + path := writeWallpaperFile(t, "skyline.png", 0) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path)) + + put := s.request(http.MethodPut, "/99999/projects/123/wallpaper.json") + assert.Empty(t, put.mode, "no mode is sent, so the server keeps its own") + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Wallpaper set: skyline.png", env.Summary) + assert.Equal(t, map[string]any{"url": wallpaperURL, "mode": "fill"}, env.Data["wallpaper"]) +} + +func TestProjectsWallpaperSetAcceptsExactlyTheSizeCap(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, "big.jpg", maxWallpaperMB<<20) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path)) + + assert.Equal(t, "image/jpeg", s.request(http.MethodPut, "/99999/projects/123/wallpaper.json").upload.contentType) +} + +func TestProjectsWallpaperSetAcceptsServerImageFormats(t *testing.T) { + for _, tc := range []struct{ filename, contentType string }{ + {"image.png", "image/png"}, + {"image.jpg", "image/jpeg"}, + {"image.gif", "image/gif"}, + {"image.webp", "image/webp"}, + {"image.avif", "image/avif"}, + {"image.heif", "image/heif"}, + {"image.HEIC", "image/heic"}, + } { + t.Run(tc.filename, func(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, tc.filename, 0) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path)) + assert.Equal(t, tc.contentType, s.request(http.MethodPut, "/99999/projects/123/wallpaper.json").upload.contentType) + }) + } +} + +func TestProjectsWallpaperSetRefusesBeforeAnyRequest(t *testing.T) { + cases := []struct { + name, file string + size int64 + args []string + message string + }{ + {"over the size cap", "big.png", maxWallpaperMB<<20 + 1, nil, "big.png exceeds maximum wallpaper size of 10 MB"}, + {"an SVG", "image.svg", 0, nil, "image.svg: unsupported image type image/svg+xml (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)"}, + {"plain text", "notes.txt", 0, nil, "notes.txt: unsupported image type text/plain (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)"}, + {"an unknown mode", "skyline.png", 0, []string{"--mode", "stretch"}, `unknown wallpaper mode "stretch"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + path := writeWallpaperFile(t, tc.file, tc.size) + + err := executeCommand(NewProjectsCmd(), app, append([]string{"wallpaper", "set", path}, tc.args...)...) + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, output.CodeUsage, outErr.Code) + assert.Equal(t, tc.message, outErr.Message) + assert.Empty(t, s.calls()) + }) + } +} + +func TestProjectsWallpaperSetRefusesAMissingFileBeforeAnyRequest(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + + err := executeCommand(NewProjectsCmd(), app, "wallpaper", "set", filepath.Join(t.TempDir(), "nope.png")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "nope.png") + assert.Empty(t, s.calls()) +} + +func TestProjectsWallpaperSetCarriesTheServersRefusal(t *testing.T) { + s, app, _ := newWallpaperServer(t) + s.refuse = "Wallpaper is not an allowed image type" + app.Flags.Project = "123" + path := writeWallpaperFile(t, "disguised.png", 0) + + err := executeCommand(NewProjectsCmd(), app, "wallpaper", "set", path) + + require.Error(t, err) + assert.Contains(t, err.Error(), s.refuse) + assert.Equal(t, []string{"PUT /99999/projects/123/wallpaper.json"}, s.calls(), "a refused upload is not followed by a read") +} + +func TestProjectsWallpaperModeSendsTheCurrentNameWithTheMode(t *testing.T) { + s, app, buf := newWallpaperServer(t) + s.attached = true + app.Flags.Project = "123" + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "mode", "tile")) + + assert.Equal(t, []string{ + "GET /99999/projects/123", + "PUT /99999/projects/123", + "GET /99999/projects/123", + }, s.calls()) + assert.Equal(t, map[string]any{"name": "Launch", "wallpaper_mode": "tile"}, + s.request(http.MethodPut, "/99999/projects/123").update, + "only the name rides along, so the description is left alone") + + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Wallpaper mode set to tile", env.Summary) + assert.Equal(t, map[string]any{"url": wallpaperURL, "mode": "tile"}, env.Data["wallpaper"]) +} + +func TestProjectsWallpaperModeRefusesAnUnknownModeBeforeAnyRequest(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + + err := executeCommand(NewProjectsCmd(), app, "wallpaper", "mode", "Tile") + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, output.CodeUsage, outErr.Code) + assert.Equal(t, `unknown wallpaper mode "Tile"`, outErr.Message) + assert.Equal(t, "Use fill or tile", outErr.Hint) + assert.Empty(t, s.calls()) +} + +func TestProjectsWallpaperRemoveDeletesAndReturnsTheProjectWithoutWallpaper(t *testing.T) { + s, app, buf := newWallpaperServer(t) + s.attached = true + app.Flags.Project = "123" + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "remove")) + + assert.Equal(t, []string{ + "DELETE /99999/projects/123/wallpaper.json", + "GET /99999/projects/123", + }, s.calls()) + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Wallpaper removed", env.Summary) + assert.Equal(t, float64(123), env.Data["id"]) + assert.NotContains(t, env.Data, "wallpaper") +} + +func TestProjectsWallpaperReportsAFailedReadAfterTheChange(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"boom"}`) + })) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "wallpaper", "remove")) + + assert.Equal(t, []string{"DELETE /99999/projects/123/wallpaper.json", "GET /99999/projects/123"}, calls) + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, float64(123), env.Data["id"]) + assert.Contains(t, env.Notice, "Wallpaper removed, but fetching the latest project state failed") +} diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index e92884a0e..d785902aa 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -55,10 +55,11 @@ func TestCatalogClaimsEveryTag(t *testing.T) { func TestCatalogExcludesUnservedOperations(t *testing.T) { cat := loadForTest(t) excluded := map[string]bool{ - "CreateAttachment": true, - "CreateCampfireUpload": true, - "UpdateAccountLogo": true, - "CreateStreamTicket": true, + "CreateAttachment": true, + "CreateCampfireUpload": true, + "UpdateAccountLogo": true, + "UpdateProjectWallpaper": true, + "CreateStreamTicket": true, } model, composite := 0, 0 for _, d := range cat.Domains { @@ -71,7 +72,7 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 281, model, "served model operation count") + assert.Equal(t, 282, model, "served model operation count") assert.Equal(t, 1, composite, "served composite operation count") } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 42ac0b999..d0588379b 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "63115e7978c989a271220cee8b025135315db882", - "ref": "v0.27.1-3-g63115e79", + "commit": "1850988c288405541bc785e4a9f9e06fce7ff458", + "ref": "v0.28.0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/behavior-model.json b/internal/mcpserver/model/behavior-model.json index a8bc4d3b9..9d8057658 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -2789,6 +2789,18 @@ ] } }, + "RemoveProjectWallpaper": { + "idempotent": true, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "ReorderUpNext": { "retry": { "max": 3, diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index d95723c84..8cf097de9 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -16481,6 +16481,104 @@ } } }, + "/{accountId}/projects/{projectId}/wallpaper.json": { + "delete": { + "description": "Remove the project's wallpaper (returns 204 No Content).\nPeople and personal assistants follow project-edit permissions; shared agents are refused.", + "operationId": "RemoveProjectWallpaper", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "projectId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "RemoveProjectWallpaper 204 response" + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Projects" + ], + "x-basecamp-idempotent": { + "natural": true + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/question_answers/{answerId}": { "get": { "description": "Get a single answer by id", @@ -36532,6 +36630,9 @@ "client_company": { "$ref": "#/components/schemas/ClientCompany" }, + "wallpaper": { + "$ref": "#/components/schemas/ProjectWallpaper" + }, "clientside": { "$ref": "#/components/schemas/ClientSide", "deprecated": true, @@ -36634,6 +36735,24 @@ "error" ] }, + "ProjectWallpaper": { + "type": "object", + "description": "The project's wallpaper, included only in single-project responses when attached.\nProject lists and home responses omit it.", + "properties": { + "url": { + "type": "string", + "description": "An authenticated API-host URL that redirects to the wallpaper image in storage, not a public image link.\nUse the SDK's authenticated download helper instead of placing this URL directly in an image element." + }, + "mode": { + "type": "string", + "description": "fill|tile" + } + }, + "required": [ + "mode", + "url" + ] + }, "Question": { "type": "object", "properties": { @@ -40304,6 +40423,10 @@ }, "schedule_attributes": { "$ref": "#/components/schemas/ScheduleAttributes" + }, + "wallpaper_mode": { + "type": "string", + "description": "How the project's wallpaper is laid out. Changes only the layout of a\nwallpaper already attached; UpdateProjectWallpaper uploads one." } }, "required": [ @@ -40313,6 +40436,10 @@ "UpdateProjectResponseContent": { "$ref": "#/components/schemas/Project" }, + "UpdateProjectWallpaperInputPayload": { + "type": "string", + "contentEncoding": "byte" + }, "UpdateQuestionNotificationSettingsRequestContent": { "type": "object", "properties": { diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index f2a6e0635..e71d07449 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -11,6 +11,7 @@ ACTIONS (RO = read-only): - list_projects (RO, paginated): List projects (active by default; optionally archived/trashed) - list_recent_projects (RO): List the projects the current user has most recently visited, most recent visit first - record_project_visit: Record that the current user visited a project, moving it to the front of ListRecentProjects (returns 204 No Content) +- remove_project_wallpaper: Remove the project's wallpaper (returns 204 No Content) - trash_project: Trash a project (returns 204 No Content) - unarchive_project: Restore a project to active status from trash as well as from the archive (returns 204 No Content) - update_project: Update an existing project diff --git a/internal/presenter/format.go b/internal/presenter/format.go index 80b58c9d6..c99668dc3 100644 --- a/internal/presenter/format.go +++ b/internal/presenter/format.go @@ -30,11 +30,24 @@ func FormatField(spec FieldSpec, key string, val any, locale Locale) string { return formatDock(val) case "steps": return formatSteps(val) + case "wallpaper": + return formatWallpaper(val) default: return formatText(val) } } +// formatWallpaper names how a project's wallpaper is laid out. Its URL is a +// signed-in redirect, no use to read in a terminal. +func formatWallpaper(val any) string { + if m, ok := val.(map[string]any); ok { + if mode, ok := m["mode"].(string); ok { + return richtext.SanitizeSingleLine(mode) + } + } + return "" +} + // formatSteps renders a CardStep array as a multi-line checklist. // Each step prefixes its title with [x] for completed, [ ] for active. func formatSteps(val any) string { diff --git a/internal/presenter/presenter_test.go b/internal/presenter/presenter_test.go index 4a02c68b2..03b87bd06 100644 --- a/internal/presenter/presenter_test.go +++ b/internal/presenter/presenter_test.go @@ -690,6 +690,21 @@ func TestPresentEmptySliceMarkdownTable(t *testing.T) { } } +func TestProjectDetailNamesTheWallpaperMode(t *testing.T) { + project := map[string]any{"id": float64(123), "name": "Launch", "status": "active"} + + var without strings.Builder + require.True(t, PresentWithTheme(&without, project, "project", ModeMarkdown, tui.NoColorTheme(), enUS)) + assert.NotContains(t, without.String(), "Wallpaper") + + project["wallpaper"] = map[string]any{"url": "https://3.basecamp.com/1/projects/123/wallpaper", "mode": "tile"} + var with strings.Builder + require.True(t, PresentWithTheme(&with, project, "project", ModeMarkdown, tui.NoColorTheme(), enUS)) + assert.Contains(t, with.String(), "Wallpaper") + assert.Contains(t, with.String(), "tile") + assert.NotContains(t, with.String(), "/wallpaper") +} + // ============================================================================= // Collapse Tests // ============================================================================= diff --git a/internal/presenter/schemas/project.yaml b/internal/presenter/schemas/project.yaml index cd0c62501..900bb1185 100644 --- a/internal/presenter/schemas/project.yaml +++ b/internal/presenter/schemas/project.yaml @@ -29,6 +29,10 @@ fields: role: detail format: text + wallpaper: + role: detail + format: wallpaper + dock: role: body format: dock @@ -52,7 +56,7 @@ views: sections: - fields: [name, description] - heading: Info - fields: [status, bookmarked] + fields: [status, bookmarked, wallpaper] - heading: Dock fields: [dock] - heading: Metadata diff --git a/internal/richtext/mime.go b/internal/richtext/mime.go index 22dad5cfb..a1ea5385a 100644 --- a/internal/richtext/mime.go +++ b/internal/richtext/mime.go @@ -31,6 +31,7 @@ var mimeByExt = map[string]string{ ".webp": "image/webp", ".avif": "image/avif", ".heic": "image/heic", + ".heif": "image/heif", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".mp4": "video/mp4", diff --git a/internal/richtext/mime_test.go b/internal/richtext/mime_test.go index e7c380910..458a7352f 100644 --- a/internal/richtext/mime_test.go +++ b/internal/richtext/mime_test.go @@ -16,6 +16,8 @@ func TestDetectMIME(t *testing.T) { {"data.json", "application/json"}, {"README.md", "text/markdown"}, {"photo.jpeg", "image/jpeg"}, + {"photo.HEIC", "image/heic"}, + {"photo.heif", "image/heif"}, {"archive.zip", "application/zip"}, {"style.css", "application/octet-stream"}, // not in our map, file doesn't exist {"doc.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index f0239d2a0..b7129ec5e 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.27.1-0.20261022230746-63115e7978c9", - "revision": "63115e7978c9", - "updated_at": "2026-10-22T23:07:46Z" + "version": "v0.28.0", + "revision": "1850988c2884", + "updated_at": "2026-10-23T23:22:11Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 650dca96b..25120130f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-TqjNLGbA3/mruCH8TA4HaGHYLnsXLK66fUuXp4ye9eQ="; + vendorHash = "sha256-JNG5aUCyIcuhsmdCd+6DocMeUff5geyEbPVqy+GlQio="; subPackages = [ "cmd/basecamp" ]; diff --git a/scripts/sync-mcp-model.sh b/scripts/sync-mcp-model.sh index 5912137bc..894991fbc 100755 --- a/scripts/sync-mcp-model.sh +++ b/scripts/sync-mcp-model.sh @@ -61,6 +61,7 @@ EXCLUDED_OPERATIONS = { "CreateAttachment", "CreateCampfireUpload", "UpdateAccountLogo", + "UpdateProjectWallpaper", } # Excluded by policy rather than by body shape, so these are not held to the diff --git a/skill-evals/cases/project-wallpaper.yml b/skill-evals/cases/project-wallpaper.yml new file mode 100644 index 000000000..b1535c3f3 --- /dev/null +++ b/skill-evals/cases/project-wallpaper.yml @@ -0,0 +1,9 @@ +task: Use ./skyline.png as the wallpaper for project 55555, repeated as a tile +tags: [domain, update] +accept: + - 'projects? wallpaper set .*skyline\.png' + - '--mode tile' + - '(--in|--project|-p) 55555' +reject: + - 'projects? update' + - 'basecamp api' diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 479bce387..3495c9ad7 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -10,6 +10,7 @@ triggers: - basecamp todos - basecamp subtasks - basecamp project + - project wallpaper - basecamp cards - basecamp chat - basecamp campfire @@ -231,6 +232,28 @@ basecamp spotlight --agent basecamp unspotlight --agent ``` +## Project wallpaper + +The wallpaper is the image behind a project's pages. The `projects wallpaper` +group sets, lays out, and removes it, for the project in `--in`/`--project` or +the configured default: + +```bash +basecamp projects wallpaper set skyline.jpg --mode tile --in "" --json +basecamp projects wallpaper mode fill --in "" --json +basecamp projects wallpaper remove --in "" --json +``` + +- People and personal assistants follow project-edit permissions. Shared + agents are refused (403); do not retry or route around that refusal. +- The CLI checks the file's size and MIME hint before uploading; leaf help + names the limits. Server image validations still apply. `--mode` is `fill` + or `tile`, and leaving it out keeps the current mode. +- Each command returns the project. `.data.wallpaper` is `{url, mode}`, and is + absent when the project has none; `projects show` carries the same field. + The URL requires authentication and redirects to the image; it is not a + public image link. + ## Content and stdin Command help declares content arguments and stdin support. Titles are generally