From 4451495f8ca7499c1d3bfb6df9ada7252434d9b1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 30 Oct 2026 08:55:33 -0700 Subject: [PATCH 1/2] Archive and restore a project projects archive and projects unarchive reach the SDK's ArchiveProject and UnarchiveProject, the project endpoints recordings archive doesn't use. They answer 403 where the admin pro pack restricts archiving, and unarchive also restores from the trash, answering 507 at the account's project limit. --- .surface | 96 ++++++++++++++++ API-COVERAGE.md | 10 +- e2e/projects.bats | 36 ++++++ e2e/smoke/smoke_projects_write.bats | 30 +++++ internal/commands/commands.go | 2 +- internal/commands/projects.go | 89 ++++++++++++++- internal/commands/projects_archive_test.go | 126 +++++++++++++++++++++ skills/basecamp/SKILL.md | 3 + 8 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 internal/commands/projects_archive_test.go diff --git a/.surface b/.surface index 96d0826d2..0b5ba094d 100644 --- a/.surface +++ b/.surface @@ -348,19 +348,23 @@ ARG basecamp profile create 00 ARG basecamp profile delete 00 ARG basecamp profile set-default 00 ARG basecamp profile show 00 [name] +ARG basecamp project archive 00 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 unarchive 00 ARG basecamp project update 00 ARG basecamp project wallpaper mode 00 ARG basecamp project wallpaper set 00 +ARG basecamp projects archive 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 unarchive 00 ARG basecamp projects update 00 ARG basecamp projects wallpaper mode 00 ARG basecamp projects wallpaper set 00 @@ -1086,6 +1090,7 @@ CMD basecamp profile list CMD basecamp profile set-default CMD basecamp profile show CMD basecamp project +CMD basecamp project archive CMD basecamp project create CMD basecamp project delete CMD basecamp project list @@ -1094,12 +1099,14 @@ CMD basecamp project logo remove CMD basecamp project logo set CMD basecamp project show CMD basecamp project trash +CMD basecamp project unarchive 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 archive CMD basecamp projects create CMD basecamp projects delete CMD basecamp projects list @@ -1108,6 +1115,7 @@ CMD basecamp projects logo remove CMD basecamp projects logo set CMD basecamp projects show CMD basecamp projects trash +CMD basecamp projects unarchive CMD basecamp projects update CMD basecamp projects wallpaper CMD basecamp projects wallpaper mode @@ -13751,6 +13759,27 @@ FLAG basecamp project --stats type=bool FLAG basecamp project --styled type=bool FLAG basecamp project --todolist type=string FLAG basecamp project --verbose type=count +FLAG basecamp project archive --account type=string +FLAG basecamp project archive --agent type=bool +FLAG basecamp project archive --cache-dir type=string +FLAG basecamp project archive --count type=bool +FLAG basecamp project archive --help type=bool +FLAG basecamp project archive --hints type=bool +FLAG basecamp project archive --ids-only type=bool +FLAG basecamp project archive --in type=string +FLAG basecamp project archive --jq type=string +FLAG basecamp project archive --json type=bool +FLAG basecamp project archive --markdown type=bool +FLAG basecamp project archive --md type=bool +FLAG basecamp project archive --no-hints type=bool +FLAG basecamp project archive --no-stats type=bool +FLAG basecamp project archive --profile type=string +FLAG basecamp project archive --project type=string +FLAG basecamp project archive --quiet type=bool +FLAG basecamp project archive --stats type=bool +FLAG basecamp project archive --styled type=bool +FLAG basecamp project archive --todolist type=string +FLAG basecamp project archive --verbose type=count FLAG basecamp project create --account type=string FLAG basecamp project create --agent type=bool FLAG basecamp project create --cache-dir type=string @@ -13927,6 +13956,27 @@ FLAG basecamp project trash --stats type=bool FLAG basecamp project trash --styled type=bool FLAG basecamp project trash --todolist type=string FLAG basecamp project trash --verbose type=count +FLAG basecamp project unarchive --account type=string +FLAG basecamp project unarchive --agent type=bool +FLAG basecamp project unarchive --cache-dir type=string +FLAG basecamp project unarchive --count type=bool +FLAG basecamp project unarchive --help type=bool +FLAG basecamp project unarchive --hints type=bool +FLAG basecamp project unarchive --ids-only type=bool +FLAG basecamp project unarchive --in type=string +FLAG basecamp project unarchive --jq type=string +FLAG basecamp project unarchive --json type=bool +FLAG basecamp project unarchive --markdown type=bool +FLAG basecamp project unarchive --md type=bool +FLAG basecamp project unarchive --no-hints type=bool +FLAG basecamp project unarchive --no-stats type=bool +FLAG basecamp project unarchive --profile type=string +FLAG basecamp project unarchive --project type=string +FLAG basecamp project unarchive --quiet type=bool +FLAG basecamp project unarchive --stats type=bool +FLAG basecamp project unarchive --styled type=bool +FLAG basecamp project unarchive --todolist type=string +FLAG basecamp project unarchive --verbose type=count FLAG basecamp project update --account type=string FLAG basecamp project update --agent type=bool FLAG basecamp project update --cache-dir type=string @@ -14056,6 +14106,27 @@ FLAG basecamp projects --stats type=bool FLAG basecamp projects --styled type=bool FLAG basecamp projects --todolist type=string FLAG basecamp projects --verbose type=count +FLAG basecamp projects archive --account type=string +FLAG basecamp projects archive --agent type=bool +FLAG basecamp projects archive --cache-dir type=string +FLAG basecamp projects archive --count type=bool +FLAG basecamp projects archive --help type=bool +FLAG basecamp projects archive --hints type=bool +FLAG basecamp projects archive --ids-only type=bool +FLAG basecamp projects archive --in type=string +FLAG basecamp projects archive --jq type=string +FLAG basecamp projects archive --json type=bool +FLAG basecamp projects archive --markdown type=bool +FLAG basecamp projects archive --md type=bool +FLAG basecamp projects archive --no-hints type=bool +FLAG basecamp projects archive --no-stats type=bool +FLAG basecamp projects archive --profile type=string +FLAG basecamp projects archive --project type=string +FLAG basecamp projects archive --quiet type=bool +FLAG basecamp projects archive --stats type=bool +FLAG basecamp projects archive --styled type=bool +FLAG basecamp projects archive --todolist type=string +FLAG basecamp projects archive --verbose type=count FLAG basecamp projects create --account type=string FLAG basecamp projects create --agent type=bool FLAG basecamp projects create --cache-dir type=string @@ -14232,6 +14303,27 @@ FLAG basecamp projects trash --stats type=bool FLAG basecamp projects trash --styled type=bool FLAG basecamp projects trash --todolist type=string FLAG basecamp projects trash --verbose type=count +FLAG basecamp projects unarchive --account type=string +FLAG basecamp projects unarchive --agent type=bool +FLAG basecamp projects unarchive --cache-dir type=string +FLAG basecamp projects unarchive --count type=bool +FLAG basecamp projects unarchive --help type=bool +FLAG basecamp projects unarchive --hints type=bool +FLAG basecamp projects unarchive --ids-only type=bool +FLAG basecamp projects unarchive --in type=string +FLAG basecamp projects unarchive --jq type=string +FLAG basecamp projects unarchive --json type=bool +FLAG basecamp projects unarchive --markdown type=bool +FLAG basecamp projects unarchive --md type=bool +FLAG basecamp projects unarchive --no-hints type=bool +FLAG basecamp projects unarchive --no-stats type=bool +FLAG basecamp projects unarchive --profile type=string +FLAG basecamp projects unarchive --project type=string +FLAG basecamp projects unarchive --quiet type=bool +FLAG basecamp projects unarchive --stats type=bool +FLAG basecamp projects unarchive --styled type=bool +FLAG basecamp projects unarchive --todolist type=string +FLAG basecamp projects unarchive --verbose type=count FLAG basecamp projects update --account type=string FLAG basecamp projects update --agent type=bool FLAG basecamp projects update --cache-dir type=string @@ -21358,6 +21450,7 @@ SUB basecamp profile list SUB basecamp profile set-default SUB basecamp profile show SUB basecamp project +SUB basecamp project archive SUB basecamp project create SUB basecamp project delete SUB basecamp project list @@ -21366,12 +21459,14 @@ SUB basecamp project logo remove SUB basecamp project logo set SUB basecamp project show SUB basecamp project trash +SUB basecamp project unarchive 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 archive SUB basecamp projects create SUB basecamp projects delete SUB basecamp projects list @@ -21380,6 +21475,7 @@ SUB basecamp projects logo remove SUB basecamp projects logo set SUB basecamp projects show SUB basecamp projects trash +SUB basecamp projects unarchive SUB basecamp projects update SUB basecamp projects wallpaper SUB basecamp projects wallpaper mode diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 4b96f2b89..6f6355db9 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,15 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 58 | 220 | +| ✅ Implemented | 59 | 222 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **62** | **232** | +| **Total tracked** | **63** | **234** | -**220 of 220 tracked in-scope endpoints.** Reading a hill chart's progress +**222 of 222 tracked in-scope endpoints.** Archiving and restoring a project +(`PUT /projects/:id/status/archived.json` and `PUT /projects/:id/status/active.json`, +modelled by [basecamp/basecamp-sdk#679](https://github.com/basecamp/basecamp-sdk/pull/679)) +land as `projects archive` and `projects unarchive`. Reading a hill chart's progress updates (`GET /todosets/:id/hills/versions.json` and `GET /todosets/:id/hills/versions/:id.json`, modelled by [basecamp/basecamp-sdk#1061](https://github.com/basecamp/basecamp-sdk/pull/1061) and @@ -221,6 +224,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 (status) | 2 | `projects archive`, `projects unarchive` | ✅ | BC5 | - | archive (`PUT /projects/:id/status/archived.json`; 403 where the admin pro pack restricts archiving to admins and the project's creator), unarchive (`PUT /projects/:id/status/active.json`, from the archive or the trash; 507 at the account's project limit). Both answer 204; the CLI reports the ID and the status it set | | 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 | diff --git a/e2e/projects.bats b/e2e/projects.bats index 7b98e9954..80b4282f1 100644 --- a/e2e/projects.bats +++ b/e2e/projects.bats @@ -145,3 +145,39 @@ load test_helper run basecamp projects logo remove brand.png assert_failure } + +@test "projects archive without an id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp projects archive + assert_failure + assert_json_value '.error' ' required' + assert_json_value '.code' 'usage' +} + +@test "projects archive refuses an ID that is not a number" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp projects archive launch + assert_failure + assert_json_value '.error' 'Invalid project ID' + assert_json_value '.code' 'usage' +} + +@test "projects unarchive without an id shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp projects unarchive + assert_failure + assert_json_value '.error' ' required' + assert_json_value '.code' 'usage' +} + +@test "projects unarchive help says it restores from the trash too" { + run basecamp projects unarchive --help + assert_success + assert_output_contains "from the archive or from the trash" +} diff --git a/e2e/smoke/smoke_projects_write.bats b/e2e/smoke/smoke_projects_write.bats index 421e75bdc..bd1d30866 100644 --- a/e2e/smoke/smoke_projects_write.bats +++ b/e2e/smoke/smoke_projects_write.bats @@ -94,6 +94,36 @@ setup_file() { assert_json_value '.data.wallpaper' 'null' } +@test "projects archive archives a project" { + 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") + + run_smoke basecamp projects archive "$proj_id" --json + assert_success + assert_json_value '.data.status' 'archived' + + run_smoke basecamp projects show "$proj_id" --json + assert_success + assert_json_value '.data.status' 'archived' + touch "$BATS_FILE_TMPDIR/project_archived" +} + +@test "projects unarchive restores an archived project" { + [[ -f "$BATS_FILE_TMPDIR/project_archived" ]] || mark_unverifiable "No project archived in prior test" + local proj_id + proj_id=$(<"$BATS_FILE_TMPDIR/created_project_id") + + run_smoke basecamp projects unarchive "$proj_id" --json + assert_success + assert_json_value '.data.status' 'active' + + run_smoke basecamp projects show "$proj_id" --json + assert_success + assert_json_value '.data.status' 'active' +} + @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/internal/commands/commands.go b/internal/commands/commands.go index 634be00bf..ad4956084 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", "logo"}}, + {Name: "projects", Category: "core", Description: "Manage projects", Actions: []string{"list", "show", "create", "update", "delete", "archive", "unarchive", "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 54b0e6e02..f4a37e494 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -1,6 +1,7 @@ package commands import ( + "context" "errors" "fmt" "os" @@ -31,7 +32,9 @@ func NewProjectsCmd() *cobra.Command { Example: ` $ basecamp projects list $ basecamp projects list --status archived $ basecamp projects show 12345 - $ basecamp projects create "New project"`, + $ basecamp projects create "New project" + $ basecamp projects archive 12345 + $ basecamp projects unarchive 12345`, } cmd.AddCommand( @@ -40,6 +43,8 @@ func NewProjectsCmd() *cobra.Command { newProjectsCreateCmd(), newProjectsUpdateCmd(), newProjectsDeleteCmd(), + newProjectsArchiveCmd(), + newProjectsUnarchiveCmd(), newProjectsWallpaperCmd(), newProjectsLogoCmd(), ) @@ -432,6 +437,88 @@ func newProjectsDeleteCmd() *cobra.Command { } } +func newProjectsArchiveCmd() *cobra.Command { + return &cobra.Command{ + Use: "archive ", + Short: "Archive a project", + Annotations: map[string]string{"agent_notes": "Accounts on the admin pro pack may restrict archiving to admins and the project's creator; others get 403\nbasecamp projects list --status archived lists archived projects"}, + Long: `Archive a project, removing it from the active project list. +basecamp projects unarchive brings it back. + +Accounts on the admin pro pack may restrict archiving to admins and the +project's creator; others are refused.`, + Example: " $ basecamp projects archive 12345", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectStatusChange(cmd, args, (*basecamp.ProjectsService).Archive, "archived", "Project archived", "unarchive", "Restore the project") + }, + } +} + +func newProjectsUnarchiveCmd() *cobra.Command { + return &cobra.Command{ + Use: "unarchive ", + Short: "Restore a project from the archive or the trash", + Annotations: map[string]string{"agent_notes": "Restores a trashed project as well as an archived one\nRestoring counts against the account's project limit; at the limit it fails with code limit_exceeded"}, + Long: `Restore a project to active status, from the archive or from the trash. + +Restoring counts against the account's project limit, so it fails when the +account is already at that limit.`, + Example: " $ basecamp projects unarchive 12345", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectStatusChange(cmd, args, (*basecamp.ProjectsService).Unarchive, "active", "Project restored", "archive", "Archive the project") + }, + } +} + +// runProjectStatusChange makes the change, which answers with no body, and +// reports the status it set, with the command that undoes it as a follow-up. +func runProjectStatusChange(cmd *cobra.Command, args []string, change func(*basecamp.ProjectsService, context.Context, int64) error, status, summary, inverse, inverseDescription string) error { + if len(args) == 0 { + return missingArg(cmd, "") + } + projectID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage("Invalid project ID") + } + + app := appctx.FromContext(cmd.Context()) + if app == nil { + return fmt.Errorf("app not initialized") + } + scope := newFollowUpScope(app) + + // Resolve account if not configured (enables interactive prompt) + if err := ensureAccount(cmd, app); err != nil { + return err + } + + if err := change(app.Account().Projects(), cmd.Context(), projectID); err != nil { + return convertSDKError(err) + } + + scopeArgs := scope.args(app.Config.AccountID) + return app.OK(map[string]any{ + "id": projectID, + "status": status, + }, + output.WithSummary(summary), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "show", + Cmd: fmt.Sprintf("basecamp projects show %d", projectID) + scopeArgs, + Description: "View the project", + }, + output.Breadcrumb{ + Action: inverse, + Cmd: fmt.Sprintf("basecamp projects %s %d", inverse, projectID) + scopeArgs, + Description: inverseDescription, + }, + ), + ) +} + // projectImageFormats match the server's shared image validation, not the narrower web picker. var projectImageFormats = []struct{ mime, name string }{ {"image/png", "PNG"}, diff --git a/internal/commands/projects_archive_test.go b/internal/commands/projects_archive_test.go new file mode 100644 index 000000000..48f194f23 --- /dev/null +++ b/internal/commands/projects_archive_test.go @@ -0,0 +1,126 @@ +package commands + +import ( + "fmt" + "net/http" + "net/http/httptest" + "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" +) + +// projectStatusServer answers one status change with an empty body, and fails +// the test on any other request. +func projectStatusServer(t *testing.T, path string, status int, body string) (*httptest.Server, *[]string) { + t.Helper() + 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.MethodPut || r.URL.Path != path { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprint(w, body) + })) + t.Cleanup(server.Close) + return server, calls +} + +func TestProjectsArchiveAndUnarchive(t *testing.T) { + for _, tc := range []struct { + action, path, status, summary, inverse string + }{ + {"archive", "/99999/projects/123/status/archived.json", "archived", "Project archived", "unarchive"}, + {"unarchive", "/99999/projects/123/status/active.json", "active", "Project restored", "archive"}, + } { + t.Run(tc.action, func(t *testing.T) { + server, calls := projectStatusServer(t, tc.path, http.StatusNoContent, "") + app, buf := newRequestLevelApp(t, server.URL) + + require.NoError(t, executeCommand(NewProjectsCmd(), app, tc.action, "123")) + + assert.Equal(t, []string{"PUT " + tc.path}, *calls) + env := decodeWallpaperEnvelope(t, buf) + assert.True(t, env.OK) + assert.Equal(t, tc.summary, env.Summary) + assert.Equal(t, tc.status, env.Data["status"]) + assert.EqualValues(t, 123, env.Data["id"]) + got := map[string]string{} + for _, crumb := range env.Breadcrumbs { + got[crumb.Action] = crumb.Cmd + } + assert.Equal(t, map[string]string{ + "show": "basecamp projects show 123", + tc.inverse: "basecamp projects " + tc.inverse + " 123", + }, got) + }) + } +} + +func TestProjectsArchiveFollowUpsPreserveScope(t *testing.T) { + server, _ := projectStatusServer(t, "/99999/projects/123/status/archived.json", http.StatusNoContent, "") + app, buf := newRequestLevelApp(t, server.URL) + app.Config.ActiveProfile = "staging" + app.Config.Sources = map[string]string{"account_id": string(config.SourceFlag)} + + require.NoError(t, executeCommand(NewProjectsCmd(), app, "archive", "123")) + + env := decodeWallpaperEnvelope(t, buf) + got := map[string]string{} + for _, crumb := range env.Breadcrumbs { + got[crumb.Action] = crumb.Cmd + } + assert.Equal(t, map[string]string{ + "show": "basecamp projects show 123 --profile staging --account 99999", + "unarchive": "basecamp projects unarchive 123 --profile staging --account 99999", + }, got) +} + +func TestProjectsArchiveRefusalIsReported(t *testing.T) { + server, _ := projectStatusServer(t, "/99999/projects/123/status/archived.json", http.StatusForbidden, `{"error":"Only admins can archive"}`) + app, buf := newRequestLevelApp(t, server.URL) + + err := executeCommand(NewProjectsCmd(), app, "archive", "123") + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, http.StatusForbidden, outErr.HTTPStatus) + assert.Equal(t, output.CodeForbidden, outErr.Code) + assert.Empty(t, buf.String(), "a refusal must not report the project as archived") +} + +func TestProjectsUnarchiveAtTheProjectLimitIsReported(t *testing.T) { + server, _ := projectStatusServer(t, "/99999/projects/123/status/active.json", http.StatusInsufficientStorage, `{"error":"Project limit reached"}`) + app, buf := newRequestLevelApp(t, server.URL) + + err := executeCommand(NewProjectsCmd(), app, "unarchive", "123") + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, output.CodeLimitExceeded, outErr.Code) + assert.Empty(t, buf.String(), "a refusal must not report the project as restored") +} + +func TestProjectsArchiveCommandsRefuseABadIDBeforeAnyRequest(t *testing.T) { + for _, action := range []string{"archive", "unarchive"} { + t.Run(action, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(server.Close) + app, _ := newRequestLevelApp(t, server.URL) + + err := executeCommand(NewProjectsCmd(), app, action, "launch") + + var outErr *output.Error + require.ErrorAs(t, err, &outErr) + assert.Equal(t, "Invalid project ID", outErr.Message) + assert.Equal(t, output.CodeUsage, outErr.Code) + }) + } +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index bce68e4c0..f25be1f47 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -413,3 +413,6 @@ Before changing Basecamp: For destructive or broad operations, preview when the command offers a dry-run and do not expand the user's requested scope. + +`projects unarchive` also restores from the trash. `projects archive` may get +403 on the admin pro pack; don't route around it. From 3d4e5c9a51cf722851e2c8a1ea57d117e44cbe62 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 30 Oct 2026 09:10:12 -0700 Subject: [PATCH 2/2] Offer no undo after restoring a project unarchive also restores from the trash, and archiving a project it brought back from there would not put it back. archive still offers unarchive. --- internal/commands/projects.go | 31 ++++++++++++---------- internal/commands/projects_archive_test.go | 19 ++++++++----- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/internal/commands/projects.go b/internal/commands/projects.go index f4a37e494..7649dfddc 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -467,13 +467,15 @@ account is already at that limit.`, Example: " $ basecamp projects unarchive 12345", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runProjectStatusChange(cmd, args, (*basecamp.ProjectsService).Unarchive, "active", "Project restored", "archive", "Archive the project") + return runProjectStatusChange(cmd, args, (*basecamp.ProjectsService).Unarchive, "active", "Project restored", "", "") }, } } // runProjectStatusChange makes the change, which answers with no body, and -// reports the status it set, with the command that undoes it as a follow-up. +// reports the status it set. inverse names the command that undoes it, offered +// as a follow-up; unarchive has none, since a project it restored may have +// come from the trash rather than the archive. func runProjectStatusChange(cmd *cobra.Command, args []string, change func(*basecamp.ProjectsService, context.Context, int64) error, status, summary, inverse, inverseDescription string) error { if len(args) == 0 { return missingArg(cmd, "") @@ -499,23 +501,24 @@ func runProjectStatusChange(cmd *cobra.Command, args []string, change func(*base } scopeArgs := scope.args(app.Config.AccountID) + crumbs := []output.Breadcrumb{{ + Action: "show", + Cmd: fmt.Sprintf("basecamp projects show %d", projectID) + scopeArgs, + Description: "View the project", + }} + if inverse != "" { + crumbs = append(crumbs, output.Breadcrumb{ + Action: inverse, + Cmd: fmt.Sprintf("basecamp projects %s %d", inverse, projectID) + scopeArgs, + Description: inverseDescription, + }) + } return app.OK(map[string]any{ "id": projectID, "status": status, }, output.WithSummary(summary), - output.WithBreadcrumbs( - output.Breadcrumb{ - Action: "show", - Cmd: fmt.Sprintf("basecamp projects show %d", projectID) + scopeArgs, - Description: "View the project", - }, - output.Breadcrumb{ - Action: inverse, - Cmd: fmt.Sprintf("basecamp projects %s %d", inverse, projectID) + scopeArgs, - Description: inverseDescription, - }, - ), + output.WithBreadcrumbs(crumbs...), ) } diff --git a/internal/commands/projects_archive_test.go b/internal/commands/projects_archive_test.go index 48f194f23..202d8bd62 100644 --- a/internal/commands/projects_archive_test.go +++ b/internal/commands/projects_archive_test.go @@ -32,11 +32,19 @@ func projectStatusServer(t *testing.T, path string, status int, body string) (*h } func TestProjectsArchiveAndUnarchive(t *testing.T) { + // unarchive offers no undo: the project it restored may have come from + // the trash, and archiving it would not put it back there. for _, tc := range []struct { - action, path, status, summary, inverse string + action, path, status, summary string + follow map[string]string }{ - {"archive", "/99999/projects/123/status/archived.json", "archived", "Project archived", "unarchive"}, - {"unarchive", "/99999/projects/123/status/active.json", "active", "Project restored", "archive"}, + {"archive", "/99999/projects/123/status/archived.json", "archived", "Project archived", map[string]string{ + "show": "basecamp projects show 123", + "unarchive": "basecamp projects unarchive 123", + }}, + {"unarchive", "/99999/projects/123/status/active.json", "active", "Project restored", map[string]string{ + "show": "basecamp projects show 123", + }}, } { t.Run(tc.action, func(t *testing.T) { server, calls := projectStatusServer(t, tc.path, http.StatusNoContent, "") @@ -54,10 +62,7 @@ func TestProjectsArchiveAndUnarchive(t *testing.T) { for _, crumb := range env.Breadcrumbs { got[crumb.Action] = crumb.Cmd } - assert.Equal(t, map[string]string{ - "show": "basecamp projects show 123", - tc.inverse: "basecamp projects " + tc.inverse + " 123", - }, got) + assert.Equal(t, tc.follow, got) }) } }