diff --git a/CHANGELOG.md b/CHANGELOG.md index d183bca..b0848c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ contract may still change while cfs is pre-1.0. ## [Unreleased] +## [0.3.1] - 2026-09-24 + ### Changed - Renamed the repository and Go module to `cloud-foundry-cli-contexts` for clearer discovery while preserving the `cfs` command name. +- Context metadata now requires explicit workspace and context identities + instead of accepting the earlier incomplete schema. ## [0.3.0] - 2026-09-23 @@ -98,7 +102,8 @@ contract may still change while cfs is pre-1.0. - Linux and macOS release builds, checksums, provenance, CI, smoke tests, and an Apache-2.0 license. -[Unreleased]: https://github.com/zongqichen/cloud-foundry-cli-contexts/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/zongqichen/cloud-foundry-cli-contexts/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/zongqichen/cloud-foundry-cli-contexts/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/zongqichen/cloud-foundry-cli-contexts/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/zongqichen/cloud-foundry-cli-contexts/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/zongqichen/cloud-foundry-cli-contexts/compare/v0.1.0...v0.1.1 diff --git a/README.md b/README.md index 25559eb..2e3b4fb 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,11 @@ targets. ## Quick start -Install the [official CF CLI](https://github.com/cloudfoundry/cli), then download -`cfs` from [GitHub Releases](https://github.com/zongqichen/cloud-foundry-cli-contexts/releases). -To build the current source with Go 1.26.8+: +Install the [official CF CLI](https://github.com/cloudfoundry/cli), then install +`cfs` with Go 1.26.8+: ```sh -git clone https://github.com/zongqichen/cloud-foundry-cli-contexts.git -cd cloud-foundry-cli-contexts -go install ./cmd/cfs +go install github.com/zongqichen/cloud-foundry-cli-contexts/cmd/cfs@latest cfs setup ``` @@ -55,11 +52,10 @@ cfs update For scripts and coding agents, use `cfs update --json`. When an update is available, follow the printed command or update through the same installation -channel. A source installation can be updated from its checkout with: +channel. A Go installation can be updated with: ```sh -git pull --ff-only -go install ./cmd/cfs +go install github.com/zongqichen/cloud-foundry-cli-contexts/cmd/cfs@latest cfs setup cfs doctor ``` diff --git a/docs/design.md b/docs/design.md index 7824f2a..97580cb 100644 --- a/docs/design.md +++ b/docs/design.md @@ -346,10 +346,9 @@ require another login, but it cannot silently attach credentials from an unrelated directory. A future explicit `cfs rebind` operation may support safe migration. -The default context ID remains the workspace ID for backward compatibility. A -named context uses a domain-separated SHA-256 digest of the workspace ID and -exact context name. Metadata records both values so renamed or transplanted -state fails validation. +The default context ID is the workspace ID. A named context uses a +domain-separated SHA-256 digest of the workspace ID and exact context name. +Metadata records both values so renamed or transplanted state fails validation. ## 10. State layout diff --git a/internal/app/named_context_unix_test.go b/internal/app/named_context_unix_test.go index ec031ed..5ed17b4 100644 --- a/internal/app/named_context_unix_test.go +++ b/internal/app/named_context_unix_test.go @@ -16,7 +16,7 @@ import ( "github.com/zongqichen/cloud-foundry-cli-contexts/internal/workspace" ) -func TestNamedContextsUseIndependentHomesAndKeepDefaultCompatible(t *testing.T) { +func TestNamedContextsUseIndependentHomesAndShareDefaultHome(t *testing.T) { fakeCF := writeFakeCF(t) stateRoot := canonicalTestPath(t, t.TempDir()) configureTestEnvironment(t, fakeCF, stateRoot) diff --git a/internal/store/store.go b/internal/store/store.go index eb4bdd2..02248dc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -49,8 +49,8 @@ type Context struct { type Metadata struct { Version int `json:"version"` ContextID string `json:"context_id"` - ContextName string `json:"context_name,omitempty"` - WorkspaceID string `json:"workspace_id,omitempty"` + ContextName string `json:"context_name"` + WorkspaceID string `json:"workspace_id"` Workspace string `json:"workspace"` Source string `json:"source"` Fingerprint string `json:"fingerprint"` @@ -188,13 +188,13 @@ func (s Store) ReadMetadata(ctx Context) (Metadata, error) { return Metadata{}, fmt.Errorf("unsupported metadata version %d", metadata.Version) } if metadata.ContextName == "" { - metadata.ContextName = contextname.Default + return Metadata{}, errors.New("missing context name in context metadata") } if err := contextname.Validate(metadata.ContextName); err != nil { return Metadata{}, err } - if metadata.WorkspaceID == "" && metadata.ContextName == contextname.Default { - metadata.WorkspaceID = metadata.ContextID + if metadata.WorkspaceID == "" { + return Metadata{}, errors.New("missing workspace ID in context metadata") } if !validID(metadata.WorkspaceID) { return Metadata{}, errors.New("invalid workspace ID in context metadata") diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0f0d9b1..8603efa 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -13,7 +13,7 @@ import ( "github.com/zongqichen/cloud-foundry-cli-contexts/internal/workspace" ) -func TestContextForNameKeepsDefaultIdentityAndSeparatesNames(t *testing.T) { +func TestContextForNameUsesWorkspaceIdentityForDefaultAndSeparatesNames(t *testing.T) { s := New(t.TempDir()) ws := workspace.Workspace{ID: strings.Repeat("a", 64)} @@ -109,7 +109,7 @@ func TestEnsureRejectsMetadataMismatch(t *testing.T) { } } -func TestReadMetadataAcceptsLegacyDefaultContext(t *testing.T) { +func TestReadMetadataRejectsIncompleteIdentity(t *testing.T) { root := t.TempDir() s := New(root) ws := workspace.Workspace{ @@ -126,25 +126,36 @@ func TestReadMetadataAcceptsLegacyDefaultContext(t *testing.T) { if err := s.Prepare(ctx); err != nil { t.Fatal(err) } - legacy := map[string]any{ - "version": 1, "context_id": ws.ID, "workspace": ws.Root, - "source": ws.Source, "fingerprint": ws.Fingerprint, - "created_at": time.Now().UTC(), "last_used_at": time.Now().UTC(), - } - raw, err := json.Marshal(legacy) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(ctx.MetadataPath, raw, 0o600); err != nil { - t.Fatal(err) - } - - metadata, err := s.ReadMetadata(ctx) - if err != nil { - t.Fatal(err) - } - if metadata.ContextName != contextname.Default || metadata.WorkspaceID != ws.ID { - t.Fatalf("legacy metadata was not normalized: %#v", metadata) + metadata := Metadata{ + Version: metadataVersion, + ContextID: ctx.ID, + ContextName: contextname.Default, + WorkspaceID: ws.ID, + Workspace: ws.Root, + Source: ws.Source, + Fingerprint: ws.Fingerprint, + CreatedAt: time.Now().UTC(), + LastUsedAt: time.Now().UTC(), + } + tests := []struct { + name string + mutate func(*Metadata) + want string + }{ + {name: "context name", mutate: func(value *Metadata) { value.ContextName = "" }, want: "missing context name"}, + {name: "workspace ID", mutate: func(value *Metadata) { value.WorkspaceID = "" }, want: "missing workspace ID"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + incomplete := metadata + test.mutate(&incomplete) + if err := os.WriteFile(ctx.MetadataPath, mustJSON(t, incomplete), 0o600); err != nil { + t.Fatal(err) + } + if _, err := s.ReadMetadata(ctx); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ReadMetadata() error = %v, want %q", err, test.want) + } + }) } }