diff --git a/.surface b/.surface index eccbb7daf..7931f86a4 100644 --- a/.surface +++ b/.surface @@ -349,6 +349,7 @@ ARG basecamp profile set-default 00 ARG basecamp profile show 00 [name] ARG basecamp project create 00 ARG basecamp project delete 00 +ARG basecamp project logo set 00 ARG basecamp project show 00 ARG basecamp project trash 00 ARG basecamp project update 00 @@ -356,6 +357,7 @@ 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 logo set 00 ARG basecamp projects show 00 ARG basecamp projects trash 00 ARG basecamp projects update 00 @@ -1079,6 +1081,9 @@ CMD basecamp project CMD basecamp project create CMD basecamp project delete CMD basecamp project list +CMD basecamp project logo +CMD basecamp project logo remove +CMD basecamp project logo set CMD basecamp project show CMD basecamp project trash CMD basecamp project update @@ -1090,6 +1095,9 @@ CMD basecamp projects CMD basecamp projects create CMD basecamp projects delete CMD basecamp projects list +CMD basecamp projects logo +CMD basecamp projects logo remove +CMD basecamp projects logo set CMD basecamp projects show CMD basecamp projects trash CMD basecamp projects update @@ -13728,6 +13736,69 @@ FLAG basecamp project list --status type=string FLAG basecamp project list --styled type=bool FLAG basecamp project list --todolist type=string FLAG basecamp project list --verbose type=count +FLAG basecamp project logo --account type=string +FLAG basecamp project logo --agent type=bool +FLAG basecamp project logo --cache-dir type=string +FLAG basecamp project logo --count type=bool +FLAG basecamp project logo --help type=bool +FLAG basecamp project logo --hints type=bool +FLAG basecamp project logo --ids-only type=bool +FLAG basecamp project logo --in type=string +FLAG basecamp project logo --jq type=string +FLAG basecamp project logo --json type=bool +FLAG basecamp project logo --markdown type=bool +FLAG basecamp project logo --md type=bool +FLAG basecamp project logo --no-hints type=bool +FLAG basecamp project logo --no-stats type=bool +FLAG basecamp project logo --profile type=string +FLAG basecamp project logo --project type=string +FLAG basecamp project logo --quiet type=bool +FLAG basecamp project logo --stats type=bool +FLAG basecamp project logo --styled type=bool +FLAG basecamp project logo --todolist type=string +FLAG basecamp project logo --verbose type=count +FLAG basecamp project logo remove --account type=string +FLAG basecamp project logo remove --agent type=bool +FLAG basecamp project logo remove --cache-dir type=string +FLAG basecamp project logo remove --count type=bool +FLAG basecamp project logo remove --help type=bool +FLAG basecamp project logo remove --hints type=bool +FLAG basecamp project logo remove --ids-only type=bool +FLAG basecamp project logo remove --in type=string +FLAG basecamp project logo remove --jq type=string +FLAG basecamp project logo remove --json type=bool +FLAG basecamp project logo remove --markdown type=bool +FLAG basecamp project logo remove --md type=bool +FLAG basecamp project logo remove --no-hints type=bool +FLAG basecamp project logo remove --no-stats type=bool +FLAG basecamp project logo remove --profile type=string +FLAG basecamp project logo remove --project type=string +FLAG basecamp project logo remove --quiet type=bool +FLAG basecamp project logo remove --stats type=bool +FLAG basecamp project logo remove --styled type=bool +FLAG basecamp project logo remove --todolist type=string +FLAG basecamp project logo remove --verbose type=count +FLAG basecamp project logo set --account type=string +FLAG basecamp project logo set --agent type=bool +FLAG basecamp project logo set --cache-dir type=string +FLAG basecamp project logo set --count type=bool +FLAG basecamp project logo set --help type=bool +FLAG basecamp project logo set --hints type=bool +FLAG basecamp project logo set --ids-only type=bool +FLAG basecamp project logo set --in type=string +FLAG basecamp project logo set --jq type=string +FLAG basecamp project logo set --json type=bool +FLAG basecamp project logo set --markdown type=bool +FLAG basecamp project logo set --md type=bool +FLAG basecamp project logo set --no-hints type=bool +FLAG basecamp project logo set --no-stats type=bool +FLAG basecamp project logo set --profile type=string +FLAG basecamp project logo set --project type=string +FLAG basecamp project logo set --quiet type=bool +FLAG basecamp project logo set --stats type=bool +FLAG basecamp project logo set --styled type=bool +FLAG basecamp project logo set --todolist type=string +FLAG basecamp project logo set --verbose type=count FLAG basecamp project show --account type=string FLAG basecamp project show --agent type=bool FLAG basecamp project show --all type=bool @@ -13970,6 +14041,69 @@ FLAG basecamp projects list --status type=string FLAG basecamp projects list --styled type=bool FLAG basecamp projects list --todolist type=string FLAG basecamp projects list --verbose type=count +FLAG basecamp projects logo --account type=string +FLAG basecamp projects logo --agent type=bool +FLAG basecamp projects logo --cache-dir type=string +FLAG basecamp projects logo --count type=bool +FLAG basecamp projects logo --help type=bool +FLAG basecamp projects logo --hints type=bool +FLAG basecamp projects logo --ids-only type=bool +FLAG basecamp projects logo --in type=string +FLAG basecamp projects logo --jq type=string +FLAG basecamp projects logo --json type=bool +FLAG basecamp projects logo --markdown type=bool +FLAG basecamp projects logo --md type=bool +FLAG basecamp projects logo --no-hints type=bool +FLAG basecamp projects logo --no-stats type=bool +FLAG basecamp projects logo --profile type=string +FLAG basecamp projects logo --project type=string +FLAG basecamp projects logo --quiet type=bool +FLAG basecamp projects logo --stats type=bool +FLAG basecamp projects logo --styled type=bool +FLAG basecamp projects logo --todolist type=string +FLAG basecamp projects logo --verbose type=count +FLAG basecamp projects logo remove --account type=string +FLAG basecamp projects logo remove --agent type=bool +FLAG basecamp projects logo remove --cache-dir type=string +FLAG basecamp projects logo remove --count type=bool +FLAG basecamp projects logo remove --help type=bool +FLAG basecamp projects logo remove --hints type=bool +FLAG basecamp projects logo remove --ids-only type=bool +FLAG basecamp projects logo remove --in type=string +FLAG basecamp projects logo remove --jq type=string +FLAG basecamp projects logo remove --json type=bool +FLAG basecamp projects logo remove --markdown type=bool +FLAG basecamp projects logo remove --md type=bool +FLAG basecamp projects logo remove --no-hints type=bool +FLAG basecamp projects logo remove --no-stats type=bool +FLAG basecamp projects logo remove --profile type=string +FLAG basecamp projects logo remove --project type=string +FLAG basecamp projects logo remove --quiet type=bool +FLAG basecamp projects logo remove --stats type=bool +FLAG basecamp projects logo remove --styled type=bool +FLAG basecamp projects logo remove --todolist type=string +FLAG basecamp projects logo remove --verbose type=count +FLAG basecamp projects logo set --account type=string +FLAG basecamp projects logo set --agent type=bool +FLAG basecamp projects logo set --cache-dir type=string +FLAG basecamp projects logo set --count type=bool +FLAG basecamp projects logo set --help type=bool +FLAG basecamp projects logo set --hints type=bool +FLAG basecamp projects logo set --ids-only type=bool +FLAG basecamp projects logo set --in type=string +FLAG basecamp projects logo set --jq type=string +FLAG basecamp projects logo set --json type=bool +FLAG basecamp projects logo set --markdown type=bool +FLAG basecamp projects logo set --md type=bool +FLAG basecamp projects logo set --no-hints type=bool +FLAG basecamp projects logo set --no-stats type=bool +FLAG basecamp projects logo set --profile type=string +FLAG basecamp projects logo set --project type=string +FLAG basecamp projects logo set --quiet type=bool +FLAG basecamp projects logo set --stats type=bool +FLAG basecamp projects logo set --styled type=bool +FLAG basecamp projects logo set --todolist type=string +FLAG basecamp projects logo set --verbose type=count FLAG basecamp projects show --account type=string FLAG basecamp projects show --agent type=bool FLAG basecamp projects show --all type=bool @@ -21002,6 +21136,9 @@ SUB basecamp project SUB basecamp project create SUB basecamp project delete SUB basecamp project list +SUB basecamp project logo +SUB basecamp project logo remove +SUB basecamp project logo set SUB basecamp project show SUB basecamp project trash SUB basecamp project update @@ -21013,6 +21150,9 @@ SUB basecamp projects SUB basecamp projects create SUB basecamp projects delete SUB basecamp projects list +SUB basecamp projects logo +SUB basecamp projects logo remove +SUB basecamp projects logo set SUB basecamp projects show SUB basecamp projects trash SUB basecamp projects update diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 4d83482f2..c07aadd28 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 | 56 | 216 | +| ✅ Implemented | 57 | 218 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **60** | **228** | +| **Total tracked** | **61** | **230** | -**216 of 216 tracked in-scope endpoints.** Moving and copying a recording +**218 of 218 tracked in-scope endpoints.** Uploading and removing a project's +logo (`PUT`/`DELETE /projects/:id/logo.json`, added by +[bc3#14174](https://github.com/basecamp/bc3/pull/14174) and shipped in +[SDK v0.30.0](https://github.com/basecamp/basecamp-sdk/releases/tag/v0.30.0)) +land as `projects logo`. Moving and copying a recording into another project (`POST /recordings/:id/moves.json`, `POST /recordings/:id/copies.json`) and reading the background filing they start (`GET /filings/:id.json`, also read on its own by `filings show`), modelled by @@ -212,6 +216,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 (logo) | 2 | `projects logo` | ✅ | BC5 | - | set (`PUT /projects/:id/logo.json`, multipart `logo`), remove (`DELETE` on the same route; 204 with or without a logo). PNG, JPEG, GIF, WebP, AVIF, HEIF or HEIC up to 5 MB, checked before the upload using a local MIME hint; the server applies its shared image validations. People and agents who can edit the project may change it; others get 403. Project JSON carries `logo {url}` when one is attached, so `projects show` shows it. `GET /projects/:id/logo.json` only redirects to the image `logo.url` already names, so the CLI does not wrap it. Project-scoped via `--in`/`--project` | | 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, copy (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` | diff --git a/e2e/projects.bats b/e2e/projects.bats index 973aeba72..7b98e9954 100644 --- a/e2e/projects.bats +++ b/e2e/projects.bats @@ -91,3 +91,57 @@ load test_helper run basecamp projects wallpaper remove skyline.png assert_failure } + + +@test "projects logo without subcommand shows help" { + run basecamp projects logo + assert_success + assert_output_contains "COMMANDS" + assert_output_contains "set" + assert_output_contains "remove" +} + +@test "projects logo set help names the formats and the size cap" { + run basecamp projects logo set --help + assert_success + assert_output_contains "PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC files up to 5 MB" +} + +@test "projects logo set without a file shows error" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects logo set + assert_failure + assert_json_value '.error' ' required' + assert_json_value '.code' 'usage' +} + +@test "projects logo set refuses an SVG" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + printf '' > brand.svg + + run basecamp projects logo set brand.svg + assert_failure + assert_json_value '.error' 'brand.svg: unsupported image type image/svg+xml (use PNG, JPEG, GIF, WebP, AVIF, HEIF, or HEIC)' + assert_json_value '.code' 'usage' +} + +@test "projects logo set refuses a file over 5 MB" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + dd if=/dev/zero of=big.png bs=1 count=1 seek=5242880 2>/dev/null + + run basecamp projects logo set big.png + assert_failure + assert_json_value '.error' 'big.png exceeds maximum logo size of 5 MB' +} + +@test "projects logo remove takes no arguments" { + create_credentials + create_global_config '{"account_id": 99999, "project_id": 123}' + + run basecamp projects logo remove brand.png + assert_failure +} diff --git a/e2e/smoke/smoke_projects_write.bats b/e2e/smoke/smoke_projects_write.bats index c9a9fe784..421e75bdc 100644 --- a/e2e/smoke/smoke_projects_write.bats +++ b/e2e/smoke/smoke_projects_write.bats @@ -46,6 +46,34 @@ setup_file() { touch "$BATS_FILE_TMPDIR/wallpaper_set" } +@test "projects logo set uploads a logo without changing wallpaper" { + [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" + 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/logo.png" + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' | + base64 -d > "$png" + + run_smoke basecamp projects logo set "$png" --project "$proj_id" --json + assert_success + assert_json_not_null '.data.logo.url' + assert_json_value '.data.wallpaper.mode' 'tile' + touch "$BATS_FILE_TMPDIR/logo_set" +} + +@test "projects logo remove clears only the logo" { + [[ -f "$BATS_FILE_TMPDIR/logo_set" ]] || mark_unverifiable "No logo set in prior test" + local proj_id + proj_id=$(<"$BATS_FILE_TMPDIR/created_project_id") + + run_smoke basecamp projects logo remove --project "$proj_id" --json + assert_success + assert_json_value '.data.logo' 'null' + assert_json_value '.data.wallpaper.mode' 'tile' +} + @test "projects wallpaper mode changes the layout" { [[ -f "$BATS_FILE_TMPDIR/wallpaper_set" ]] || mark_unverifiable "No wallpaper set in prior test" local proj_id diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 034cb279a..3c4bc76ff 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", "wallpaper"}}, + {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete", "wallpaper", "logo"}}, {Name: "todos", Category: "core", Description: "Manage to-dos", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "position", "copy", "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", "copy", "trash", "archive", "restore"}}, diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 015066dd9..54b0e6e02 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -41,6 +41,7 @@ func NewProjectsCmd() *cobra.Command { newProjectsUpdateCmd(), newProjectsDeleteCmd(), newProjectsWallpaperCmd(), + newProjectsLogoCmd(), ) return cmd @@ -431,8 +432,8 @@ func newProjectsDeleteCmd() *cobra.Command { } } -// wallpaperFormats match the server's shared image validation, not the narrower web picker. -var wallpaperFormats = []struct{ mime, name string }{ +// projectImageFormats match the server's shared image validation, not the narrower web picker. +var projectImageFormats = []struct{ mime, name string }{ {"image/png", "PNG"}, {"image/jpeg", "JPEG"}, {"image/gif", "GIF"}, @@ -485,7 +486,7 @@ func newProjectsWallpaperSetCmd() *cobra.Command { 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 ")), +its current mode.`, projectImageFormatNames(), 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 { @@ -497,14 +498,14 @@ its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpap return err } } - path, contentType, err := wallpaperFile(args[0]) + path, contentType, err := projectImageFile(args[0], "wallpaper", maxWallpaperMB) if err != nil { return err } app := appctx.FromContext(cmd.Context()) scope := newFollowUpScope(app) - projectID, err := wallpaperProjectID(cmd, app) + projectID, err := projectImageProjectID(cmd, app) if err != nil { return err } @@ -525,7 +526,7 @@ its current mode.`, wallpaperFormatNames(), maxWallpaperMB, strings.Join(wallpap if mode != "" { summary += " (" + mode + ")" } - return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, summary, + return respondWithProjectImage(cmd, app, projectID, scope, map[string]any{"id": projectID}, summary, output.Breadcrumb{ Action: "mode", Cmd: fmt.Sprintf("basecamp projects wallpaper mode --project %d", projectID), @@ -569,7 +570,7 @@ A project with no wallpaper keeps the mode for the next one uploaded.`, app := appctx.FromContext(cmd.Context()) scope := newFollowUpScope(app) - projectID, err := wallpaperProjectID(cmd, app) + projectID, err := projectImageProjectID(cmd, app) if err != nil { return err } @@ -585,7 +586,7 @@ A project with no wallpaper keeps the mode for the next one uploaded.`, return convertSDKError(err) } - return respondWithWallpaperProject(cmd, app, projectID, scope, project, "Wallpaper mode set to "+mode, + return respondWithProjectImage(cmd, app, projectID, scope, project, "Wallpaper mode set to "+mode, output.Breadcrumb{ Action: "set", Cmd: fmt.Sprintf("basecamp projects wallpaper set --project %d", projectID), @@ -607,7 +608,7 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) scope := newFollowUpScope(app) - projectID, err := wallpaperProjectID(cmd, app) + projectID, err := projectImageProjectID(cmd, app) if err != nil { return err } @@ -616,7 +617,7 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { return convertSDKError(err) } - return respondWithWallpaperProject(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Wallpaper removed", + return respondWithProjectImage(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), @@ -627,9 +628,115 @@ func newProjectsWallpaperRemoveCmd() *cobra.Command { } } -// wallpaperFile checks the size and MIME hint before uploading. The server's +const maxLogoMB = 5 + +func logoAgentNotes() map[string]string { + return map[string]string{"agent_notes": "People and agents who can edit the project can change its logo; others get 403, and a project the caller cannot see gets 404\n.data.logo is {url}, absent when there is none; url requires authentication and redirects to the image"} +} + +func newProjectsLogoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logo", + Short: "Manage a project's logo", + Annotations: logoAgentNotes(), + Long: `Set or remove a project's logo, the image shown beside its name. + +The project comes from --project (or --in), then the configured default. +People and agents who can edit the project can change its logo.`, + Example: ` $ basecamp projects logo set brand.png --project 12345 + $ basecamp projects logo remove --project 12345`, + } + + cmd.AddCommand( + newProjectsLogoSetCmd(), + newProjectsLogoRemoveCmd(), + ) + + return cmd +} + +func newProjectsLogoSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Annotations: logoAgentNotes(), + Short: "Upload or replace a project's logo", + Long: fmt.Sprintf(`Upload or replace the project's logo. + +Accepts %s files up to %d MB.`, projectImageFormatNames(), maxLogoMB), + Example: " $ basecamp projects logo set brand.png --project 12345", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return missingArg(cmd, "") + } + path, contentType, err := projectImageFile(args[0], "logo", maxLogoMB) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + projectID, err := projectImageProjectID(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) + if err := app.Account().Projects().UpdateLogo(cmd.Context(), projectID, f, filename, contentType); err != nil { + return convertSDKError(err) + } + + return respondWithProjectImage(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Logo set: "+filename, + output.Breadcrumb{ + Action: "remove", + Cmd: fmt.Sprintf("basecamp projects logo remove --project %d", projectID), + Description: "Remove the logo", + }, + ) + }, + } +} + +func newProjectsLogoRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove", + Annotations: logoAgentNotes(), + Short: "Remove a project's logo", + Long: "Remove the project's logo. Removing it from a project that has none succeeds.", + Example: " $ basecamp projects logo remove --project 12345", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app := appctx.FromContext(cmd.Context()) + scope := newFollowUpScope(app) + projectID, err := projectImageProjectID(cmd, app) + if err != nil { + return err + } + + if err := app.Account().Projects().RemoveLogo(cmd.Context(), projectID); err != nil { + return convertSDKError(err) + } + + return respondWithProjectImage(cmd, app, projectID, scope, map[string]any{"id": projectID}, "Logo removed", + output.Breadcrumb{ + Action: "set", + Cmd: fmt.Sprintf("basecamp projects logo set --project %d", projectID), + Description: "Upload a new logo", + }, + ) + }, + } +} + +// projectImageFile 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) { +func projectImageFile(arg, kind string, maxMB int) (path, contentType string, err error) { path = richtext.NormalizeDragPath(arg) if err := richtext.ValidateFile(path); err != nil { return "", "", fmt.Errorf("%s: %w", arg, err) @@ -639,22 +746,22 @@ func wallpaperFile(arg string) (path, contentType string, err error) { 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)) + if info.Size() > int64(maxMB)<<20 { + return "", "", output.ErrUsage(fmt.Sprintf("%s exceeds maximum %s size of %d MB", name, kind, maxMB)) } contentType = richtext.DetectMIME(path) - for _, f := range wallpaperFormats { + for _, f := range projectImageFormats { if f.mime == contentType { return path, contentType, nil } } - return "", "", output.ErrUsage(fmt.Sprintf("%s: unsupported image type %s (use %s)", name, contentType, wallpaperFormatNames())) + return "", "", output.ErrUsage(fmt.Sprintf("%s: unsupported image type %s (use %s)", name, contentType, projectImageFormatNames())) } -// wallpaperFormatNames lists the accepted formats for help and validation errors. -func wallpaperFormatNames() string { - names := make([]string, len(wallpaperFormats)) - for i, f := range wallpaperFormats { +// projectImageFormatNames lists the accepted formats for help and validation errors. +func projectImageFormatNames() string { + names := make([]string, len(projectImageFormats)) + for i, f := range projectImageFormats { names[i] = f.name } return strings.Join(names[:len(names)-1], ", ") + ", or " + names[len(names)-1] @@ -667,7 +774,7 @@ func validateWallpaperMode(mode string) error { 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) { +func projectImageProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { if err := ensureAccount(cmd, app); err != nil { return 0, err } @@ -682,11 +789,11 @@ func wallpaperProjectID(cmd *cobra.Command, app *appctx.App) (int64, error) { 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 +// respondWithProjectImage answers with the project as it now stands, so the +// output carries its wallpaper and logo. 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 { +func respondWithProjectImage(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), diff --git a/internal/commands/projects_logo_test.go b/internal/commands/projects_logo_test.go new file mode 100644 index 000000000..e8f6e060e --- /dev/null +++ b/internal/commands/projects_logo_test.go @@ -0,0 +1,262 @@ +package commands + +import ( + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/output" +) + +func TestProjectsLogoRemoveReadsProjectWithoutLogo(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) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodDelete && r.URL.Path == "/99999/projects/123/logo.json" { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method == http.MethodGet && r.URL.Path == "/99999/projects/123" { + fmt.Fprint(w, `{"id":123,"name":"Launch","wallpaper":{"mode":"tile","url":"keep"}}`) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + app.Config.ProjectID = "123" + require.NoError(t, executeCommand(NewProjectsCmd(), app, "logo", "remove")) + assert.Equal(t, []string{"DELETE /99999/projects/123/logo.json", "GET /99999/projects/123"}, calls) + env := decodeWallpaperEnvelope(t, buf) + assert.Equal(t, "Logo removed", env.Summary) + assert.NotContains(t, env.Data, "logo") + assert.Equal(t, map[string]any{"url": "keep", "mode": "tile"}, env.Data["wallpaper"]) +} + +const projectLogoURL = "https://3.basecampapi.com/99999/projects/123/logo?v=1" + +func TestProjectsLogoSetUploadsAndReadsProject(t *testing.T) { + var calls []string + var upload *wallpaperUpload + parser := &wallpaperServer{t: t} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPut && r.URL.Path == "/99999/projects/123/logo.json": + upload = parser.readUpload(r) + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/99999/projects/123": + fmt.Fprintf(w, `{"id":123,"name":"Launch","description":"Keep me","logo":{"url":%q},"wallpaper":{"url":"wallpaper-url","mode":"tile"}}`, projectLogoURL) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + path := writeWallpaperFile(t, "brand.png", 0) + require.NoError(t, executeCommand(NewProjectsCmd(), app, "logo", "set", path)) + assert.Equal(t, []string{"PUT /99999/projects/123/logo.json", "GET /99999/projects/123"}, calls) + require.NotNil(t, upload) + assert.Equal(t, "logo", upload.field) + assert.Equal(t, "brand.png", upload.filename) + assert.Equal(t, "image/png", upload.contentType) + assert.Equal(t, []byte("\x89PNG\r\n\x1a\n"), upload.data) + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, "Logo set: brand.png", env.Summary) + assert.Equal(t, map[string]any{"url": projectLogoURL}, env.Data["logo"]) + assert.Equal(t, "Keep me", env.Data["description"]) + assert.Equal(t, map[string]any{"url": "wallpaper-url", "mode": "tile"}, env.Data["wallpaper"]) +} + +func TestProjectsLogoFollowUpsPreserveScope(t *testing.T) { + for _, scope := range []struct{ name, profile, source, suffix string }{ + {"configured", "", string(config.SourceGlobal), ""}, + {"override", "staging", string(config.SourceFlag), " --profile staging --account 99999"}, + } { + for _, action := range []string{"set", "remove"} { + t.Run(scope.name+"/"+action, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + fmt.Fprint(w, `{"id":123,"name":"Launch"}`) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + app.Config.ActiveProfile = scope.profile + app.Config.Sources = map[string]string{"account_id": scope.source} + args := []string{"logo", action} + want := map[string]string{"show": "basecamp projects show 123" + scope.suffix} + if action == "set" { + args = append(args, writeWallpaperFile(t, "brand.png", 0)) + want["remove"] = "basecamp projects logo remove --project 123" + scope.suffix + } else { + want["set"] = "basecamp projects logo set --project 123" + scope.suffix + } + require.NoError(t, executeCommand(NewProjectsCmd(), app, args...)) + env := decodeWallpaperEnvelope(t, buf) + got := map[string]string{} + for _, crumb := range env.Breadcrumbs { + got[crumb.Action] = crumb.Cmd + } + assert.Equal(t, want, got) + }) + } + } +} + +func TestProjectsLogoSetAcceptsServerFormatsAndSizeBoundary(t *testing.T) { + for _, tc := range []struct { + file, mime string + size int64 + }{ + {"brand.png", "image/png", 5 << 20}, + {"brand.jpg", "image/jpeg", 0}, + {"brand.gif", "image/gif", 0}, + {"brand.webp", "image/webp", 0}, + {"brand.avif", "image/avif", 0}, + {"brand.heif", "image/heif", 0}, + {"brand.HEIC", "image/heic", 0}, + } { + t.Run(tc.file, func(t *testing.T) { + var upload *wallpaperUpload + parser := &wallpaperServer{t: t} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + upload = parser.readUpload(r) + w.WriteHeader(http.StatusNoContent) + return + } + fmt.Fprint(w, `{"id":123,"name":"Launch"}`) + })) + defer server.Close() + app, _ := newRequestLevelApp(t, server.URL) + app.Config.ProjectID = "123" + require.NoError(t, executeCommand(NewProjectsCmd(), app, "logo", "set", writeWallpaperFile(t, tc.file, tc.size))) + require.NotNil(t, upload) + assert.Equal(t, tc.mime, upload.contentType) + }) + } +} + +func TestProjectsLogoRejectsInvalidInputBeforeRequests(t *testing.T) { + t.Setenv("BASECAMP_NONINTERACTIVE", "1") + for _, tc := range []struct { + name string + args []string + message string + }{ + {"missing argument", nil, " required"}, + {"too many arguments", []string{"one.png", "two.png"}, "accepts at most 1 arg(s)"}, + {"oversize", []string{"big.png"}, "big.png exceeds maximum logo size of 5 MB"}, + {"svg", []string{"image.svg"}, "unsupported image type image/svg+xml"}, + {"text", []string{"notes.txt"}, "unsupported image type text/plain"}, + {"missing file", []string{"missing.png"}, "missing.png"}, + {"directory", []string{"directory"}, "directory"}, + } { + t.Run(tc.name, func(t *testing.T) { + s, app, _ := newWallpaperServer(t) + app.Flags.Project = "123" + args := append([]string{"logo", "set"}, tc.args...) + if len(tc.args) == 1 { + switch tc.name { + case "missing file": + args[2] = filepath.Join(t.TempDir(), tc.args[0]) + case "directory": + args[2] = t.TempDir() + default: + size := int64(0) + if tc.name == "oversize" { + size = 5<<20 + 1 + } + args[2] = writeWallpaperFile(t, tc.args[0], size) + } + } + err := executeCommand(NewProjectsCmd(), app, args...) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.message) + assert.Empty(t, s.calls()) + }) + } +} + +func TestProjectsLogoMutationRefusalDoesNotReadBack(t *testing.T) { + for _, action := range []string{"set", "remove"} { + for _, status := range []int{http.StatusForbidden, http.StatusUnprocessableEntity} { + t.Run(fmt.Sprintf("%s/%d", action, status), func(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) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprint(w, `{"error":"Logo refused"}`) + })) + defer server.Close() + app, _ := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + args := []string{"logo", action} + method := "DELETE" + if action == "set" { + args = append(args, writeWallpaperFile(t, "brand.png", 0)) + method = "PUT" + } + err := executeCommand(NewProjectsCmd(), app, args...) + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, status, outErr.HTTPStatus) + assert.Contains(t, err.Error(), "Logo refused") + assert.Equal(t, []string{method + " /99999/projects/123/logo.json"}, calls) + }) + } + } +} + +func TestProjectsLogoReadbackFailureReportsSuccessfulMutation(t *testing.T) { + for _, action := range []string{"set", "remove"} { + t.Run(action, func(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.MethodGet { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"cannot read project"}`) + })) + defer server.Close() + app, buf := newRequestLevelApp(t, server.URL) + app.Flags.Project = "123" + app.Config.ActiveProfile = "staging" + app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} + args := []string{"logo", action} + method := "DELETE" + if action == "set" { + args = append(args, writeWallpaperFile(t, "brand.png", 0)) + method = "PUT" + } + require.NoError(t, executeCommand(NewProjectsCmd(), app, args...)) + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, float64(123), env.Data["id"]) + assert.Contains(t, env.Notice, "but fetching the latest project state failed") + assert.Equal(t, "basecamp projects show 123 --profile staging --account 99999", env.Breadcrumbs[0].Cmd) + assert.Equal(t, []string{method + " /99999/projects/123/logo.json", "GET /99999/projects/123"}, calls) + }) + } +} diff --git a/internal/presenter/format.go b/internal/presenter/format.go index c99668dc3..e3901e9a8 100644 --- a/internal/presenter/format.go +++ b/internal/presenter/format.go @@ -30,6 +30,8 @@ func FormatField(spec FieldSpec, key string, val any, locale Locale) string { return formatDock(val) case "steps": return formatSteps(val) + case "logo": + return formatLogo(val) case "wallpaper": return formatWallpaper(val) default: @@ -48,6 +50,15 @@ func formatWallpaper(val any) string { return "" } +// formatLogo says a project has a logo. The logo has nothing to show in a +// terminal but its URL, which is a signed-in redirect. +func formatLogo(val any) string { + if m, ok := val.(map[string]any); ok && m["url"] != nil { + return "set" + } + 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 03b87bd06..381ab5bdd 100644 --- a/internal/presenter/presenter_test.go +++ b/internal/presenter/presenter_test.go @@ -705,6 +705,20 @@ func TestProjectDetailNamesTheWallpaperMode(t *testing.T) { assert.NotContains(t, with.String(), "/wallpaper") } +func TestProjectDetailSaysWhetherThereIsALogo(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(), "Logo") + + project["logo"] = map[string]any{"url": "https://3.basecampapi.com/1/projects/123/logo?v=1"} + var with strings.Builder + require.True(t, PresentWithTheme(&with, project, "project", ModeMarkdown, tui.NoColorTheme(), enUS)) + assert.Contains(t, with.String(), "**Logo:** set") + assert.NotContains(t, with.String(), "/logo") +} + // ============================================================================= // Collapse Tests // ============================================================================= diff --git a/internal/presenter/schemas/project.yaml b/internal/presenter/schemas/project.yaml index 900bb1185..17bbd4a6d 100644 --- a/internal/presenter/schemas/project.yaml +++ b/internal/presenter/schemas/project.yaml @@ -29,6 +29,10 @@ fields: role: detail format: text + logo: + role: detail + format: logo + wallpaper: role: detail format: wallpaper @@ -56,7 +60,7 @@ views: sections: - fields: [name, description] - heading: Info - fields: [status, bookmarked, wallpaper] + fields: [status, bookmarked, logo, wallpaper] - heading: Dock fields: [dock] - heading: Metadata diff --git a/skill-evals/cases/project-logo.yml b/skill-evals/cases/project-logo.yml new file mode 100644 index 000000000..f567907c8 --- /dev/null +++ b/skill-evals/cases/project-logo.yml @@ -0,0 +1,9 @@ +task: Use ./brand.png as the logo for project 55555 +tags: [domain, update] +accept: + - 'projects? logo set .*brand\.png' + - '(--in|--project|-p) 55555' +reject: + - 'projects? wallpaper' + - 'projects? update' + - 'basecamp api' diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index 3495c9ad7..e3b9270f3 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -11,6 +11,7 @@ triggers: - basecamp subtasks - basecamp project - project wallpaper + - project logo - basecamp cards - basecamp chat - basecamp campfire @@ -254,6 +255,28 @@ basecamp projects wallpaper remove --in "" --json The URL requires authentication and redirects to the image; it is not a public image link. +## Project logo + +The logo is the image beside a project's name, separate from its wallpaper. The +`projects logo` group sets and removes it, for the project in `--in`/`--project` +or the configured default: + +```bash +basecamp projects logo set brand.png --in "" --json +basecamp projects logo remove --in "" --json +``` + +- People and agents who can edit the project can change its logo. Others get + 403, and a project the caller cannot see gets 404; do not retry or route + around either refusal. +- The CLI checks the file's size and MIME hint before uploading; leaf help + names the limits. Server image validations still apply. Removing a logo from + a project that has none succeeds. +- Each command returns the project. `.data.logo` is `{url}`, 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