diff --git a/cmd/ui_plugins/create.go b/cmd/ui_plugins/create.go index 2064cfaf..717e5894 100644 --- a/cmd/ui_plugins/create.go +++ b/cmd/ui_plugins/create.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/delete.go b/cmd/ui_plugins/delete.go index 14fb3997..c6ed985d 100644 --- a/cmd/ui_plugins/delete.go +++ b/cmd/ui_plugins/delete.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/init.go b/cmd/ui_plugins/init.go index b9d078c9..291ba37c 100644 --- a/cmd/ui_plugins/init.go +++ b/cmd/ui_plugins/init.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/link.go b/cmd/ui_plugins/link.go index 691a68f5..4b237f5a 100644 --- a/cmd/ui_plugins/link.go +++ b/cmd/ui_plugins/link.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/list.go b/cmd/ui_plugins/list.go index 9bae55c1..0c8371fd 100644 --- a/cmd/ui_plugins/list.go +++ b/cmd/ui_plugins/list.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/manifest_model.go b/cmd/ui_plugins/manifest_model.go new file mode 100644 index 00000000..8fa6a705 --- /dev/null +++ b/cmd/ui_plugins/manifest_model.go @@ -0,0 +1,34 @@ +package ui_plugins + +// uiPluginWorkspaceConfig represents the sp-ui-plugin.json workspace contract. +type uiPluginWorkspaceConfig struct { + Version int `json:"version"` + Manifest uiPluginManifest `json:"manifest"` + Build *uiPluginBuildConfig `json:"build,omitempty"` +} + +// uiPluginSlot declares a UI extension point the plugin occupies. +type uiPluginSlot struct { + SlotID string `json:"slotId"` + RequiredCapabilities []string `json:"requiredCapabilities,omitempty"` + RestrictToUsers []string `json:"restrictToUsers,omitempty"` +} + +// uiPluginManifest is the backend-facing payload section. +type uiPluginManifest struct { + Alias string `json:"alias"` + Name map[string]string `json:"name"` + Description map[string]string `json:"description"` + APIScopes []string `json:"apiScopes,omitempty"` + ContentSecurityPolicies map[string][]string `json:"contentSecurityPolicies,omitempty"` + PermissionPolicy map[string][]string `json:"permissionPolicy,omitempty"` + IframeAllow map[string][]string `json:"iframeAllow,omitempty"` + Slots []uiPluginSlot `json:"slots"` +} + +// uiPluginBuildConfig is local CLI-only config and never sent to backend. +type uiPluginBuildConfig struct { + OutDir string `json:"outDir,omitempty"` + Port *int `json:"port,omitempty"` +} + diff --git a/cmd/ui_plugins/manifest_validation.go b/cmd/ui_plugins/manifest_validation.go new file mode 100644 index 00000000..fd2976b5 --- /dev/null +++ b/cmd/ui_plugins/manifest_validation.go @@ -0,0 +1,92 @@ +package ui_plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +const ( + manifestFileName = "sp-ui-plugin.json" + supportedVersion1 = 1 +) + +// loadAndValidateWorkspaceManifest is the shared full-validation entrypoint for +// ui-plugins commands. It performs strict parse + semantic validation. +func loadAndValidateWorkspaceManifest(path string) (*uiPluginWorkspaceConfig, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("unable to read %s: %w", path, err) + } + + cfg, err := parseWorkspaceManifestStrict(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", path, err) + } + + if err := validateWorkspaceManifest(cfg); err != nil { + return nil, fmt.Errorf("invalid %s: %w", path, err) + } + + return cfg, nil +} + +func parseWorkspaceManifestStrict(raw []byte) (*uiPluginWorkspaceConfig, error) { + var cfg uiPluginWorkspaceConfig + + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return nil, err + } + + // Enforce a single JSON object in file. + if err := dec.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("manifest must contain a single JSON object") + } + + return &cfg, nil +} + +func validateWorkspaceManifest(cfg *uiPluginWorkspaceConfig) error { + if cfg.Version == 0 { + return fmt.Errorf("version is required") + } + if cfg.Version != supportedVersion1 { + return fmt.Errorf("unsupported version %d (supported: %d)", cfg.Version, supportedVersion1) + } + + manifest := cfg.Manifest + if strings.TrimSpace(manifest.Alias) == "" { + return fmt.Errorf("manifest.alias is required") + } + if len(manifest.Name) == 0 { + return fmt.Errorf("manifest.name is required and must contain at least one locale entry") + } + if len(manifest.Description) == 0 { + return fmt.Errorf("manifest.description is required and must contain at least one locale entry") + } + if len(manifest.Slots) == 0 { + return fmt.Errorf("manifest.slots is required and must contain at least one slot") + } + for i, slot := range manifest.Slots { + if strings.TrimSpace(slot.SlotID) == "" { + return fmt.Errorf("manifest.slots[%d].slotId is required", i) + } + } + + if cfg.Build != nil { + if cfg.Build.Port != nil && *cfg.Build.Port <= 0 { + return fmt.Errorf("build.port must be greater than 0") + } + if strings.TrimSpace(cfg.Build.OutDir) == "" && cfg.Build.OutDir != "" { + return fmt.Errorf("build.outDir must not be empty when provided") + } + } + + return nil +} + diff --git a/cmd/ui_plugins/manifest_validation_test.go b/cmd/ui_plugins/manifest_validation_test.go new file mode 100644 index 00000000..5b5a3713 --- /dev/null +++ b/cmd/ui_plugins/manifest_validation_test.go @@ -0,0 +1,303 @@ +package ui_plugins + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadAndValidateWorkspaceManifest_Valid(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + }, + "build": { + "outDir": "./dist", + "port": 4200 + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + if cfg.Version != 1 { + t.Fatalf("expected version 1, got %d", cfg.Version) + } + if len(cfg.Manifest.Slots) != 1 || cfg.Manifest.Slots[0].SlotID != "full-page" { + t.Fatalf("expected one full-page slot, got: %+v", cfg.Manifest.Slots) + } +} + +func TestLoadAndValidateWorkspaceManifest_ValidIframeAllow(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}], + "iframeAllow": {} + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + if cfg.Manifest.IframeAllow == nil { + t.Fatal("expected iframeAllow map to be present") + } +} + +func TestLoadAndValidateWorkspaceManifest_ValidIframeAllowWithDirectives(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}], + "iframeAllow": {"camera": ["'self'"]} + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + if len(cfg.Manifest.IframeAllow) != 1 { + t.Fatalf("expected one iframeAllow directive, got: %+v", cfg.Manifest.IframeAllow) + } + if got := cfg.Manifest.IframeAllow["camera"]; len(got) != 1 || got[0] != "'self'" { + t.Fatalf("unexpected camera directive: %+v", got) + } +} + +func TestLoadAndValidateWorkspaceManifest_StringIframeAllowRejected(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}], + "iframeAllow": "camera 'self'" + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected string iframeAllow to fail") + } + if !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected unmarshal type error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_ValidSlotWithOptionalFields(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{ + "slotId": "full-page", + "requiredCapabilities": ["ORG_ADMIN"], + "restrictToUsers": ["2c9180827f9b911e017f9b9122340000"] + }] + } +}`) + + cfg, err := loadAndValidateWorkspaceManifest(path) + if err != nil { + t.Fatalf("expected valid manifest, got err: %v", err) + } + slot := cfg.Manifest.Slots[0] + if slot.SlotID != "full-page" { + t.Fatalf("expected slotId full-page, got %q", slot.SlotID) + } + if len(slot.RequiredCapabilities) != 1 || slot.RequiredCapabilities[0] != "ORG_ADMIN" { + t.Fatalf("unexpected requiredCapabilities: %+v", slot.RequiredCapabilities) + } + if len(slot.RestrictToUsers) != 1 { + t.Fatalf("unexpected restrictToUsers: %+v", slot.RestrictToUsers) + } +} + +func TestLoadAndValidateWorkspaceManifest_LegacyStringSlotsRejected(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": ["full-page"] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected legacy string slots to fail") + } + if !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected unmarshal type error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_MissingSlotID(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected missing slotId to fail") + } + if !strings.Contains(err.Error(), "manifest.slots[0].slotId is required") { + t.Fatalf("expected slotId required error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_UnknownSlotField(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page", "unexpectedField": true}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected unknown slot field to fail") + } + if !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("expected unknown field error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_MissingVersion(t *testing.T) { + path := writeManifestFixture(t, `{ + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected missing version to fail") + } + if !strings.Contains(err.Error(), "version is required") { + t.Fatalf("expected version error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_UnknownField(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}], + "unexpectedField": true + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected unknown field to fail") + } + if !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("expected unknown field error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_TypeMismatch(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": 123, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected type mismatch to fail") + } + if !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected unmarshal type error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_MissingRequiredManifestField(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 1, + "manifest": { + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected missing alias to fail") + } + if !strings.Contains(err.Error(), "manifest.alias is required") { + t.Fatalf("expected alias required error, got: %v", err) + } +} + +func TestLoadAndValidateWorkspaceManifest_UnsupportedVersion(t *testing.T) { + path := writeManifestFixture(t, `{ + "version": 2, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + _, err := loadAndValidateWorkspaceManifest(path) + if err == nil { + t.Fatal("expected unsupported version to fail") + } + if !strings.Contains(err.Error(), "unsupported version") { + t.Fatalf("expected unsupported version error, got: %v", err) + } +} + +func writeManifestFixture(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, manifestFileName) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write manifest fixture: %v", err) + } + return path +} + diff --git a/cmd/ui_plugins/ui_plugins.go b/cmd/ui_plugins/ui_plugins.go index 00f71133..4cbf4f41 100644 --- a/cmd/ui_plugins/ui_plugins.go +++ b/cmd/ui_plugins/ui_plugins.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( @@ -42,6 +41,7 @@ func NewUIPluginsCommand() *cobra.Command { newUploadCommand(), newListCommand(), newDeleteCommand(), + newValidateManifestCommand(), ) return cmd diff --git a/cmd/ui_plugins/ui_plugins.md b/cmd/ui_plugins/ui_plugins.md index 062f0f2a..9037cd83 100644 --- a/cmd/ui_plugins/ui_plugins.md +++ b/cmd/ui_plugins/ui_plugins.md @@ -17,6 +17,7 @@ Planned workflow commands: - upload - list - delete +- validate-manifest (alias: validate) — offline structural validation of `./sp-ui-plugin.json`; does not call UMS ==== ==Example== diff --git a/cmd/ui_plugins/ui_plugins_test.go b/cmd/ui_plugins/ui_plugins_test.go index 4c88e4e6..bd801fa7 100644 --- a/cmd/ui_plugins/ui_plugins_test.go +++ b/cmd/ui_plugins/ui_plugins_test.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( @@ -17,8 +16,8 @@ func TestNewUIPluginsCommandStructure(t *testing.T) { t.Fatal("expected ui-plugins command to be hidden") } - if len(cmd.Commands()) != 7 { - t.Fatalf("expected 7 subcommands, got %d", len(cmd.Commands())) + if len(cmd.Commands()) != 8 { + t.Fatalf("expected 8 subcommands, got %d", len(cmd.Commands())) } } diff --git a/cmd/ui_plugins/update.go b/cmd/ui_plugins/update.go index aa7f8ffd..860ad689 100644 --- a/cmd/ui_plugins/update.go +++ b/cmd/ui_plugins/update.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/upload.go b/cmd/ui_plugins/upload.go index 6988b8af..4ee5ec64 100644 --- a/cmd/ui_plugins/upload.go +++ b/cmd/ui_plugins/upload.go @@ -1,4 +1,3 @@ -// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. package ui_plugins import ( diff --git a/cmd/ui_plugins/validate_manifest.go b/cmd/ui_plugins/validate_manifest.go new file mode 100644 index 00000000..2d7cde91 --- /dev/null +++ b/cmd/ui_plugins/validate_manifest.go @@ -0,0 +1,33 @@ +package ui_plugins + +import ( + _ "embed" + "fmt" + + "github.com/sailpoint-oss/sailpoint-cli/internal/util" + "github.com/spf13/cobra" +) + +//go:embed validate_manifest.md +var validateManifestHelp string + +func newValidateManifestCommand() *cobra.Command { + help := util.ParseHelp(validateManifestHelp) + return &cobra.Command{ + Use: "validate-manifest", + Aliases: []string{"validate"}, + Short: "Validate sp-ui-plugin.json structure (offline; does not call UMS)", + Long: help.Long, + Example: help.Example, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := loadAndValidateWorkspaceManifest(manifestFileName) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Manifest structure is valid (offline check only).") + return nil + }, + } +} diff --git a/cmd/ui_plugins/validate_manifest.md b/cmd/ui_plugins/validate_manifest.md new file mode 100644 index 00000000..0c9dd732 --- /dev/null +++ b/cmd/ui_plugins/validate_manifest.md @@ -0,0 +1,32 @@ +==Long== +# Validate Manifest + +Performs an offline structural validation of `./sp-ui-plugin.json` in the current working directory. + +## What is checked + +- JSON shape, required fields, and known field types +- Rejection of unknown fields (strict schema) +- CLI config schema version support + +## What is not checked + +This command does not contact UMS or validate tenant-specific rules, including: + +- Alias availability or format rules enforced by the backend +- Slot registry membership and occupancy limits +- Security policy baselines (CSP, permission policy, iframe allow) +- Capability lists, user GUID restrictions, and related business rules + +Use `sail ui-plugins create` or `update` for full backend validation. + +==== + +==Example== + +```bash +SAIL_EXPERIMENTAL_UI_PLUGINS=1 sail ui-plugins validate-manifest +SAIL_EXPERIMENTAL_UI_PLUGINS=1 sail ui-plugins validate +``` + +==== diff --git a/cmd/ui_plugins/validate_manifest_test.go b/cmd/ui_plugins/validate_manifest_test.go new file mode 100644 index 00000000..3a5d2f38 --- /dev/null +++ b/cmd/ui_plugins/validate_manifest_test.go @@ -0,0 +1,135 @@ +package ui_plugins + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateManifestCommand_Success(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"validate-manifest"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("expected validate-manifest to succeed, got: %v", err) + } + if !strings.Contains(out.String(), "Manifest structure is valid (offline check only).") { + t.Fatalf("expected success message, got: %s", out.String()) + } +} + +func TestValidateManifestCommand_RejectsExtraArgs(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + cmd.SetArgs([]string{"validate-manifest", "./other.json"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected extra args to fail") + } + if !strings.Contains(err.Error(), "unknown command") && !strings.Contains(err.Error(), "accepts 0 arg") { + t.Fatalf("expected no-args error, got: %v", err) + } +} + +func TestValidateManifestCommand_AliasSuccess(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "alias": "access-request-plugin", + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + cmd.SetArgs([]string{"validate"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("expected validate alias to succeed, got: %v", err) + } +} + +func TestValidateManifestCommand_Failure(t *testing.T) { + t.Setenv(experimentalUIPluginsEnvVar, "1") + cwd := t.TempDir() + writeManifestAtPath(t, filepath.Join(cwd, manifestFileName), `{ + "version": 1, + "manifest": { + "name": {"en-US": "Access Request"}, + "description": {"en-US": "Plugin description"}, + "slots": [{"slotId": "full-page"}] + } +}`) + + restore := chdirForTest(t, cwd) + defer restore() + + cmd := NewUIPluginsCommand() + cmd.SetArgs([]string{"validate-manifest"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected validation failure") + } + if !strings.Contains(err.Error(), "manifest.alias is required") { + t.Fatalf("expected actionable alias error, got: %v", err) + } +} + +func chdirForTest(t *testing.T, target string) func() { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + if err := os.Chdir(target); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + return func() { + _ = os.Chdir(orig) + } +} + +func writeManifestAtPath(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write manifest fixture: %v", err) + } +} +