From 427e630bf805006a7e2a3759ec3ca52b777661e9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 16:42:04 -0500 Subject: [PATCH 1/4] feat(cmd): add features info/resolve-deps/generate-docs commands Add new `features` command group with three subcommands: - `features info ` fetches and displays OCI metadata - `features resolve-dependencies` resolves feature install order - `features generate-docs` generates markdown docs from feature src/ Exports PullFeatureToTemp and ResolveFeatureOrder from the feature package to support the new commands. --- cmd/features/generatedocs.go | 263 +++++++++++++++++++++++++++ cmd/features/generatedocs_test.go | 119 ++++++++++++ cmd/features/info.go | 215 ++++++++++++++++++++++ cmd/features/output.go | 18 ++ cmd/features/resolvedeps.go | 146 +++++++++++++++ cmd/features/resolvedeps_test.go | 63 +++++++ cmd/features/root.go | 22 +++ cmd/root.go | 2 + e2e/e2e_suite_test.go | 1 + e2e/tests/features/features.go | 175 ++++++++++++++++++ pkg/devcontainer/feature/extend.go | 8 + pkg/devcontainer/feature/features.go | 23 +++ 12 files changed, 1055 insertions(+) create mode 100644 cmd/features/generatedocs.go create mode 100644 cmd/features/generatedocs_test.go create mode 100644 cmd/features/info.go create mode 100644 cmd/features/output.go create mode 100644 cmd/features/resolvedeps.go create mode 100644 cmd/features/resolvedeps_test.go create mode 100644 cmd/features/root.go create mode 100644 e2e/tests/features/features.go diff --git a/cmd/features/generatedocs.go b/cmd/features/generatedocs.go new file mode 100644 index 000000000..1613858ad --- /dev/null +++ b/cmd/features/generatedocs.go @@ -0,0 +1,263 @@ +package features + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/spf13/cobra" +) + +type GenerateDocsCmd struct { + *flags.GlobalFlags + + ProjectFolder string + OutputFolder string + Namespace string +} + +func NewGenerateDocsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &GenerateDocsCmd{GlobalFlags: globalFlags} + generateDocsCmd := &cobra.Command{ + Use: "generate-docs", + Short: "Generate markdown documentation from feature metadata", + Long: `Scan a feature project's src/ directory and generate markdown +documentation for each feature based on its devcontainer-feature.json.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, _ []string) error { + return cmd.Run() + }, + } + + generateDocsCmd.Flags().StringVar( + &cmd.ProjectFolder, "project-folder", "", "Path to feature project containing src/ directory", + ) + generateDocsCmd.Flags().StringVar( + &cmd.OutputFolder, "output-folder", "", "Where to write generated docs (default: project-folder)", + ) + generateDocsCmd.Flags().StringVar( + &cmd.Namespace, "namespace", "", "Registry namespace for linking (e.g. ghcr.io/myorg/features)", + ) + _ = generateDocsCmd.MarkFlagRequired("project-folder") + + return generateDocsCmd +} + +func (cmd *GenerateDocsCmd) Run() error { + projectFolder, err := filepath.Abs(cmd.ProjectFolder) + if err != nil { + return fmt.Errorf("resolve project folder: %w", err) + } + + outputFolder, err := cmd.resolveOutputFolder(projectFolder) + if err != nil { + return err + } + + features, err := scanFeatures(filepath.Join(projectFolder, "src")) + if err != nil { + return err + } + + return cmd.writeDocs(features, outputFolder) +} + +func (cmd *GenerateDocsCmd) resolveOutputFolder( + projectFolder string, +) (string, error) { + if cmd.OutputFolder == "" { + return projectFolder, nil + } + out, err := filepath.Abs(cmd.OutputFolder) + if err != nil { + return "", fmt.Errorf("resolve output folder: %w", err) + } + return out, nil +} + +func scanFeatures(srcDir string) ([]*featureDoc, error) { + entries, err := os.ReadDir(srcDir) + if err != nil { + return nil, fmt.Errorf("read src/ directory: %w", err) + } + + var features []*featureDoc + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + featureDir := filepath.Join(srcDir, entry.Name()) + featureCfg, parseErr := config.ParseDevContainerFeature(featureDir) + if parseErr != nil { + continue + } + + features = append(features, &featureDoc{ + dir: entry.Name(), + config: featureCfg, + }) + } + + if len(features) == 0 { + return nil, fmt.Errorf("no features found in %s", srcDir) + } + return features, nil +} + +func (cmd *GenerateDocsCmd) writeDocs( + features []*featureDoc, outputFolder string, +) error { + // #nosec G301 + if err := os.MkdirAll(outputFolder, 0o755); err != nil { + return fmt.Errorf("create output folder: %w", err) + } + + for _, f := range features { + docPath := filepath.Join(outputFolder, f.dir+".md") + content := cmd.generateFeatureDoc(f) + // #nosec G306 + if err := os.WriteFile(docPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write doc for %s: %w", f.dir, err) + } + _, _ = fmt.Fprintf(os.Stdout, "Generated: %s\n", docPath) + } + + indexPath := filepath.Join(outputFolder, "README.md") + indexContent := cmd.generateIndex(features) + // #nosec G306 + if err := os.WriteFile(indexPath, []byte(indexContent), 0o644); err != nil { + return fmt.Errorf("write index: %w", err) + } + _, _ = fmt.Fprintf(os.Stdout, "Generated: %s\n", indexPath) + + return nil +} + +type featureDoc struct { + dir string + config *config.FeatureConfig +} + +func (cmd *GenerateDocsCmd) generateFeatureDoc(f *featureDoc) string { + var sb strings.Builder + + name := f.config.Name + if name == "" { + name = f.config.ID + } + + sb.WriteString("# " + name + "\n\n") + + if f.config.Description != "" { + sb.WriteString(f.config.Description + "\n\n") + } + + cmd.writeMetadataTable(&sb, f) + writeOptionsTable(&sb, f.config.Options) + writeDependenciesSection(&sb, f.config) + + return sb.String() +} + +func (cmd *GenerateDocsCmd) writeMetadataTable( + sb *strings.Builder, f *featureDoc, +) { + sb.WriteString("## Metadata\n\n") + sb.WriteString("| Property | Value |\n") + sb.WriteString("|----------|-------|\n") + fmt.Fprintf(sb, "| ID | `%s` |\n", f.config.ID) + if f.config.Version != "" { + fmt.Fprintf(sb, "| Version | `%s` |\n", f.config.Version) + } + if cmd.Namespace != "" { + fmt.Fprintf(sb, "| Registry | `%s/%s` |\n", cmd.Namespace, f.config.ID) + } + if f.config.DocumentationURL != "" { + fmt.Fprintf(sb, "| Documentation | %s |\n", f.config.DocumentationURL) + } + if f.config.Deprecated { + sb.WriteString("| Status | **DEPRECATED** |\n") + } + sb.WriteString("\n") +} + +func writeOptionsTable( + sb *strings.Builder, options map[string]config.FeatureConfigOption, +) { + if len(options) == 0 { + return + } + sb.WriteString("## Options\n\n") + sb.WriteString("| Name | Type | Default | Description |\n") + sb.WriteString("|------|------|---------|-------------|\n") + for optName, opt := range options { + defaultVal := string(opt.Default) + if defaultVal == "" { + defaultVal = "-" + } + desc := opt.Description + if desc == "" { + desc = "-" + } + optType := opt.Type + if optType == "" { + optType = "string" + } + fmt.Fprintf(sb, "| `%s` | %s | `%s` | %s |\n", + optName, optType, defaultVal, desc) + } + sb.WriteString("\n") +} + +func writeDependenciesSection(sb *strings.Builder, cfg *config.FeatureConfig) { + if len(cfg.DependsOn) > 0 { + sb.WriteString("## Dependencies\n\n") + for dep := range cfg.DependsOn { + fmt.Fprintf(sb, "- `%s`\n", dep) + } + sb.WriteString("\n") + } + + if len(cfg.InstallsAfter) > 0 { + sb.WriteString("## Install Order\n\n") + sb.WriteString("This feature installs after:\n\n") + for _, dep := range cfg.InstallsAfter { + fmt.Fprintf(sb, "- `%s`\n", dep) + } + sb.WriteString("\n") + } +} + +func (cmd *GenerateDocsCmd) generateIndex(features []*featureDoc) string { + var sb strings.Builder + + sb.WriteString("# Dev Container Features\n\n") + + if cmd.Namespace != "" { + fmt.Fprintf(&sb, "Registry: `%s`\n\n", cmd.Namespace) + } + + sb.WriteString("## Features\n\n") + sb.WriteString("| Feature | Description |\n") + sb.WriteString("|---------|-------------|\n") + + for _, f := range features { + name := f.config.Name + if name == "" { + name = f.config.ID + } + desc := f.config.Description + if desc == "" { + desc = "-" + } + fmt.Fprintf(&sb, "| [%s](./%s.md) | %s |\n", name, f.dir, desc) + } + + sb.WriteString("\n") + return sb.String() +} diff --git a/cmd/features/generatedocs_test.go b/cmd/features/generatedocs_test.go new file mode 100644 index 000000000..f66f16c5e --- /dev/null +++ b/cmd/features/generatedocs_test.go @@ -0,0 +1,119 @@ +package features + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateDocsCmd_Run(t *testing.T) { + projectDir := t.TempDir() + srcDir := filepath.Join(projectDir, "src", "my-feature") + require.NoError(t, os.MkdirAll(srcDir, 0o750)) + + featureJSON := `{ + "id": "my-feature", + "version": "1.0.0", + "name": "My Feature", + "description": "A test feature", + "documentationURL": "https://example.com/docs", + "options": { + "version": { + "type": "string", + "default": "latest", + "description": "Version to install" + } + } + }` + require.NoError(t, os.WriteFile( + filepath.Join(srcDir, "devcontainer-feature.json"), + []byte(featureJSON), + 0o600, + )) + + outputDir := t.TempDir() + + cmd := &GenerateDocsCmd{ + ProjectFolder: projectDir, + OutputFolder: outputDir, + Namespace: "ghcr.io/test/features", + } + + err := cmd.Run() + require.NoError(t, err) + + docPath := filepath.Join(outputDir, "my-feature.md") + assert.FileExists(t, docPath) + + content, err := os.ReadFile(filepath.Clean(docPath)) + require.NoError(t, err) + docContent := string(content) + + assert.Contains(t, docContent, "# My Feature") + assert.Contains(t, docContent, "A test feature") + assert.Contains(t, docContent, "`my-feature`") + assert.Contains(t, docContent, "`1.0.0`") + assert.Contains(t, docContent, "ghcr.io/test/features") + assert.Contains(t, docContent, "## Options") + assert.Contains(t, docContent, "`version`") + assert.Contains(t, docContent, "`latest`") + + indexPath := filepath.Join(outputDir, "README.md") + assert.FileExists(t, indexPath) + + indexContent, err := os.ReadFile(filepath.Clean(indexPath)) + require.NoError(t, err) + assert.Contains(t, string(indexContent), "My Feature") + assert.Contains(t, string(indexContent), "A test feature") +} + +func TestGenerateDocsCmd_NoFeatures(t *testing.T) { + projectDir := t.TempDir() + srcDir := filepath.Join(projectDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0o750)) + + cmd := &GenerateDocsCmd{ + ProjectFolder: projectDir, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no features found") +} + +func TestGenerateDocsCmd_MultipleFeatures(t *testing.T) { + projectDir := t.TempDir() + + for _, feat := range []struct { + id string + name string + }{ + {"go", "Go"}, + {"node", "Node.js"}, + } { + srcDir := filepath.Join(projectDir, "src", feat.id) + require.NoError(t, os.MkdirAll(srcDir, 0o750)) + featureJSON := `{"id": "` + feat.id + `", "name": "` + feat.name + `", "version": "1.0.0"}` + require.NoError(t, os.WriteFile( + filepath.Join(srcDir, "devcontainer-feature.json"), + []byte(featureJSON), + 0o600, + )) + } + + outputDir := t.TempDir() + cmd := &GenerateDocsCmd{ + ProjectFolder: projectDir, + OutputFolder: outputDir, + } + + err := cmd.Run() + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(outputDir, "go.md")) + assert.FileExists(t, filepath.Join(outputDir, "node.md")) + assert.FileExists(t, filepath.Join(outputDir, "README.md")) +} diff --git a/cmd/features/info.go b/cmd/features/info.go new file mode 100644 index 000000000..2adbd36c9 --- /dev/null +++ b/cmd/features/info.go @@ -0,0 +1,215 @@ +package features + +import ( + "fmt" + "os" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/spf13/cobra" +) + +type InfoCmd struct { + *flags.GlobalFlags + + Output string + ShowTags bool + ShowDependencies bool + Verbose bool +} + +type featureInfo struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Authors string `json:"authors,omitempty"` + Source string `json:"source,omitempty"` + DocumentationURL string `json:"documentationURL,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + Tags []string `json:"tags,omitempty"` + Dependencies map[string]any `json:"dependencies,omitempty"` + Options map[string]config.FeatureConfigOption `json:"options,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +func NewInfoCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &InfoCmd{GlobalFlags: globalFlags} + infoCmd := &cobra.Command{ + Use: "info ", + Short: "Fetch and display OCI metadata for a published feature", + Long: `Fetch and display OCI metadata for a published dev container feature. + +Accepts a feature ID like ghcr.io/devcontainers/features/go or +ghcr.io/devcontainers/features/go:1 and pulls its OCI manifest +to display metadata.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + return cmd.Run(args[0]) + }, + } + + infoCmd.Flags().StringVar(&cmd.Output, "output", "text", "Output format (text or json)") + infoCmd.Flags().BoolVar( + &cmd.ShowTags, "show-tags", false, "List available tags from the registry", + ) + infoCmd.Flags().BoolVar( + &cmd.ShowDependencies, "show-dependencies", false, "Show declared dependencies", + ) + infoCmd.Flags().BoolVar( + &cmd.Verbose, "verbose", false, "Show full manifest and config details", + ) + + return infoCmd +} + +func (cmd *InfoCmd) Run(featureID string) error { + ref, err := name.ParseReference(featureID) + if err != nil { + return fmt.Errorf("invalid feature reference %q: %w", featureID, err) + } + + info, err := cmd.fetchInfo(ref, featureID) + if err != nil { + return err + } + + if cmd.ShowTags { + tags, tagErr := listTags(ref) + if tagErr != nil { + return fmt.Errorf("list tags: %w", tagErr) + } + info.Tags = tags + } + + if cmd.Output == outputJSON { + return writeJSON(os.Stdout, info) + } + return cmd.printText(info) +} + +func (cmd *InfoCmd) fetchInfo( + ref name.Reference, featureID string, +) (*featureInfo, error) { + folder, err := feature.PullFeatureToTemp(ref, featureID) + if err != nil { + return nil, fmt.Errorf("pull feature: %w", err) + } + + featureCfg, err := config.ParseDevContainerFeature(folder) + if err != nil { + return nil, fmt.Errorf("parse feature config: %w", err) + } + + annotations := feature.LoadOCIAnnotations(folder) + + info := &featureInfo{ + ID: featureCfg.ID, + Name: featureCfg.Name, + Version: featureCfg.Version, + Description: featureCfg.Description, + DocumentationURL: featureCfg.DocumentationURL, + Deprecated: featureCfg.Deprecated, + Annotations: annotations, + } + + if annotations != nil { + info.Authors = annotations["org.opencontainers.image.authors"] + info.Source = annotations["org.opencontainers.image.source"] + } + + if cmd.ShowDependencies && featureCfg.DependsOn != nil { + info.Dependencies = featureCfg.DependsOn + } + + if cmd.Verbose { + info.Options = featureCfg.Options + } + + return info, nil +} + +func listTags(ref name.Reference) ([]string, error) { + repo := ref.Context() + tags, err := remote.List(repo, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return nil, err + } + return tags, nil +} + +func (cmd *InfoCmd) printText(info *featureInfo) error { + w := os.Stdout + _, _ = fmt.Fprintf(w, "Feature: %s\n", info.Name) + printField(w, "ID", info.ID) + printField(w, "Version", info.Version) + printField(w, "Description", info.Description) + printField(w, "Authors", info.Authors) + printField(w, "Source", info.Source) + printField(w, "Documentation", info.DocumentationURL) + if info.Deprecated { + _, _ = fmt.Fprintln(w, "Status: DEPRECATED") + } + + cmd.printDependencies(w, info) + cmd.printTags(w, info) + cmd.printOptions(w, info) + cmd.printAnnotations(w, info) + + return nil +} + +func printField(w *os.File, label, value string) { + if value != "" { + _, _ = fmt.Fprintf(w, "%s: %s\n", label, value) + } +} + +func (cmd *InfoCmd) printDependencies(w *os.File, info *featureInfo) { + if !cmd.ShowDependencies || len(info.Dependencies) == 0 { + return + } + _, _ = fmt.Fprintln(w, "\nDependencies:") + for dep := range info.Dependencies { + _, _ = fmt.Fprintf(w, " - %s\n", dep) + } +} + +func (cmd *InfoCmd) printTags(w *os.File, info *featureInfo) { + if !cmd.ShowTags || len(info.Tags) == 0 { + return + } + _, _ = fmt.Fprintln(w, "\nAvailable Tags:") + _, _ = fmt.Fprintf(w, " %s\n", strings.Join(info.Tags, ", ")) +} + +func (cmd *InfoCmd) printOptions(w *os.File, info *featureInfo) { + if !cmd.Verbose || len(info.Options) == 0 { + return + } + _, _ = fmt.Fprintln(w, "\nOptions:") + for optName, opt := range info.Options { + _, _ = fmt.Fprintf(w, " %s (%s): %s", optName, opt.Type, opt.Description) + if string(opt.Default) != "" { + _, _ = fmt.Fprintf(w, " [default: %s]", string(opt.Default)) + } + _, _ = fmt.Fprintln(w) + } +} + +func (cmd *InfoCmd) printAnnotations(w *os.File, info *featureInfo) { + if !cmd.Verbose || len(info.Annotations) == 0 { + return + } + _, _ = fmt.Fprintln(w, "\nOCI Annotations:") + for k, v := range info.Annotations { + _, _ = fmt.Fprintf(w, " %s: %s\n", k, v) + } +} diff --git a/cmd/features/output.go b/cmd/features/output.go new file mode 100644 index 000000000..6e58b06f3 --- /dev/null +++ b/cmd/features/output.go @@ -0,0 +1,18 @@ +package features + +import ( + "encoding/json" + "fmt" + "io" +) + +const outputJSON = "json" + +func writeJSON(w io.Writer, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(data)) + return err +} diff --git a/cmd/features/resolvedeps.go b/cmd/features/resolvedeps.go new file mode 100644 index 000000000..563168c1c --- /dev/null +++ b/cmd/features/resolvedeps.go @@ -0,0 +1,146 @@ +package features + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/spf13/cobra" +) + +type ResolveDepsCmd struct { + *flags.GlobalFlags + + WorkspaceFolder string + Config string + Output string +} + +type resolvedFeature struct { + ID string `json:"id"` + Version string `json:"version,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + InstallsAfter []string `json:"installsAfter,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +func NewResolveDepsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &ResolveDepsCmd{GlobalFlags: globalFlags} + resolveDepsCmd := &cobra.Command{ + Use: "resolve-dependencies", + Short: "Resolve feature install order from a devcontainer.json", + Long: `Read a devcontainer.json and output the resolved feature +install order based on dependency declarations and install ordering.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, _ []string) error { + return cmd.Run() + }, + } + + resolveDepsCmd.Flags().StringVar( + &cmd.WorkspaceFolder, "workspace-folder", "", + "Path to workspace containing devcontainer.json", + ) + resolveDepsCmd.Flags().StringVar( + &cmd.Config, "config", "", + "Path to specific devcontainer.json (optional)", + ) + resolveDepsCmd.Flags().StringVar( + &cmd.Output, "output", "text", "Output format (text or json)", + ) + _ = resolveDepsCmd.MarkFlagRequired("workspace-folder") + + return resolveDepsCmd +} + +func (cmd *ResolveDepsCmd) Run() error { + devContainerConfig, err := cmd.loadConfig() + if err != nil { + return fmt.Errorf("load devcontainer config: %w", err) + } + + if devContainerConfig == nil { + return fmt.Errorf( + "no devcontainer.json found in workspace %q", cmd.WorkspaceFolder, + ) + } + + if len(devContainerConfig.Features) == 0 { + return cmd.printEmpty() + } + + sorted, err := feature.ResolveFeatureOrder(devContainerConfig) + if err != nil { + return fmt.Errorf("resolve feature order: %w", err) + } + + resolved := buildResolvedList(sorted) + + if cmd.Output == outputJSON { + return writeJSON(os.Stdout, resolved) + } + return cmd.printText(resolved) +} + +func (cmd *ResolveDepsCmd) printEmpty() error { + if cmd.Output == outputJSON { + _, err := fmt.Fprintln(os.Stdout, "[]") + return err + } + _, err := fmt.Fprintln(os.Stdout, "No features declared in devcontainer.json") + return err +} + +func buildResolvedList(sorted []*config.FeatureSet) []resolvedFeature { + resolved := make([]resolvedFeature, 0, len(sorted)) + for _, fs := range sorted { + rf := resolvedFeature{ + ID: fs.ConfigID, + Version: fs.Version, + } + if fs.Config != nil { + for dep := range fs.Config.DependsOn { + rf.Dependencies = append(rf.Dependencies, dep) + } + rf.InstallsAfter = fs.Config.InstallsAfter + } + resolved = append(resolved, rf) + } + return resolved +} + +func (cmd *ResolveDepsCmd) loadConfig() (*config.DevContainerConfig, error) { + if cmd.Config != "" { + return config.ParseDevContainerJSONFile(cmd.Config) + } + + absPath, err := filepath.Abs(cmd.WorkspaceFolder) + if err != nil { + return nil, err + } + + return config.ParseDevContainerJSON(absPath, "") +} + +func (cmd *ResolveDepsCmd) printText(resolved []resolvedFeature) error { + w := os.Stdout + _, _ = fmt.Fprintf(w, "Feature install order (%d features):\n\n", len(resolved)) + for i, rf := range resolved { + versionSuffix := "" + if rf.Version != "" { + versionSuffix = ":" + rf.Version + } + _, _ = fmt.Fprintf(w, " %d. %s%s\n", i+1, rf.ID, versionSuffix) + if len(rf.Dependencies) > 0 { + _, _ = fmt.Fprintf(w, " depends on: %v\n", rf.Dependencies) + } + if len(rf.InstallsAfter) > 0 { + _, _ = fmt.Fprintf(w, " installs after: %v\n", rf.InstallsAfter) + } + } + return nil +} diff --git a/cmd/features/resolvedeps_test.go b/cmd/features/resolvedeps_test.go new file mode 100644 index 000000000..5e3049291 --- /dev/null +++ b/cmd/features/resolvedeps_test.go @@ -0,0 +1,63 @@ +package features + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveDepsCmd_NoFeatures(t *testing.T) { + workspaceDir := t.TempDir() + devcontainerDir := filepath.Join(workspaceDir, ".devcontainer") + require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) + + devcontainerJSON := `{ + "image": "ubuntu:22.04" + }` + require.NoError(t, os.WriteFile( + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(devcontainerJSON), + 0o600, + )) + + cmd := &ResolveDepsCmd{ + WorkspaceFolder: workspaceDir, + Output: "text", + } + + err := cmd.Run() + require.NoError(t, err) +} + +func TestResolveDepsCmd_MissingWorkspace(t *testing.T) { + cmd := &ResolveDepsCmd{ + WorkspaceFolder: "/nonexistent/path/12345", + Output: "text", + } + + err := cmd.Run() + assert.Error(t, err) +} + +func TestResolveDepsCmd_ExplicitConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "devcontainer.json") + + devcontainerJSON := `{ + "image": "ubuntu:22.04", + "features": {} + }` + require.NoError(t, os.WriteFile(configPath, []byte(devcontainerJSON), 0o600)) + + cmd := &ResolveDepsCmd{ + WorkspaceFolder: tmpDir, + Config: configPath, + Output: outputJSON, + } + + err := cmd.Run() + require.NoError(t, err) +} diff --git a/cmd/features/root.go b/cmd/features/root.go new file mode 100644 index 000000000..54b5eb7b2 --- /dev/null +++ b/cmd/features/root.go @@ -0,0 +1,22 @@ +package features + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +func NewFeaturesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + featuresCmd := &cobra.Command{ + Use: "features", + Short: "Commands for inspecting and managing dev container features", + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.NoArgs, + } + + featuresCmd.AddCommand(NewInfoCmd(globalFlags)) + featuresCmd.AddCommand(NewResolveDepsCmd(globalFlags)) + featuresCmd.AddCommand(NewGenerateDocsCmd(globalFlags)) + + return featuresCmd +} diff --git a/cmd/root.go b/cmd/root.go index 469efb78f..6701c2247 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/cmd/agent" "github.com/devsy-org/devsy/cmd/completion" "github.com/devsy-org/devsy/cmd/context" + "github.com/devsy-org/devsy/cmd/features" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/cmd/helper" "github.com/devsy-org/devsy/cmd/ide" @@ -134,6 +135,7 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewSetUpCmd(globalFlags)) rootCmd.AddCommand(NewRunUserCommandsCmd(globalFlags)) rootCmd.AddCommand(NewRunUserCommandsCmdAlias(globalFlags)) + rootCmd.AddCommand(features.NewFeaturesCmd(globalFlags)) inheritCommandFlagsFromEnvironment(rootCmd) diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 77ae680a0..80d440a6e 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -15,6 +15,7 @@ import ( _ "github.com/devsy-org/devsy/e2e/tests/exec" _ "github.com/devsy-org/devsy/e2e/tests/extends" _ "github.com/devsy-org/devsy/e2e/tests/extends-up" + _ "github.com/devsy-org/devsy/e2e/tests/features" _ "github.com/devsy-org/devsy/e2e/tests/ide" _ "github.com/devsy-org/devsy/e2e/tests/integration" _ "github.com/devsy-org/devsy/e2e/tests/logs" diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go new file mode 100644 index 000000000..8a1d716a1 --- /dev/null +++ b/e2e/tests/features/features.go @@ -0,0 +1,175 @@ +package features + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "net/http/httptest" + "os" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.Describe("features info", func() { + ginkgo.It("displays feature metadata from a registry", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRef := regHost + "/test/features/go:1.0.0" + + pushFeatureWithAnnotations(featureRef, map[string]string{ + "org.opencontainers.image.title": "Go", + "org.opencontainers.image.description": "Install Go toolchain", + "org.opencontainers.image.version": "1.0.0", + "org.opencontainers.image.source": "https://github.com/test/features", + }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", featureRef, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("Go")) + gomega.Expect(stdout).To(gomega.ContainSubstring("1.0.0")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("outputs JSON when --output=json is specified", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRef := regHost + "/test/features/node:1.0.0" + + pushFeatureWithAnnotations(featureRef, map[string]string{ + "org.opencontainers.image.title": "Node.js", + "org.opencontainers.image.version": "1.0.0", + }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", featureRef, "--output", "json", + }) + framework.ExpectNoError(err) + + var result map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &result)).To(gomega.Succeed()) + gomega.Expect(result["id"]).To(gomega.Equal("node")) + gomega.Expect(result["version"]).To(gomega.Equal("1.0.0")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("lists tags with --show-tags", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRepo := regHost + "/test/features/python" + + pushFeatureWithAnnotations(featureRepo+":1.0.0", nil) + pushFeatureWithAnnotations(featureRepo+":2.0.0", nil) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", featureRepo + ":1.0.0", "--show-tags", + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("1.0.0")) + gomega.Expect(stdout).To(gomega.ContainSubstring("2.0.0")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) +}) + +func pushFeatureWithAnnotations(refStr string, annotations map[string]string) { + featureJSON := `{"id":"` + featureIDFromRef(refStr) + `","name":"` + + annotationOrDefault(annotations, "org.opencontainers.image.title", "Test Feature") + + `","version":"1.0.0","description":"` + + annotationOrDefault( + annotations, + "org.opencontainers.image.description", + "A test feature", + ) + `"}` + + layer := static.NewLayer( + buildFeatureTarGz("devcontainer-feature.json", featureJSON), + types.OCILayer, + ) + + img, err := mutate.AppendLayers(empty.Image, layer) + framework.ExpectNoError(err) + + img = mutate.MediaType(img, types.OCIManifestSchema1) + img = mutate.ConfigMediaType(img, "application/vnd.devcontainers") + + if annotations != nil { + img = mutate.Annotations(img, annotations).(v1.Image) + } + + ref, err := name.ParseReference(refStr, name.Insecure) + framework.ExpectNoError(err) + + framework.ExpectNoError(remote.Write(ref, img)) +} + +func featureIDFromRef(refStr string) string { + parts := strings.Split(refStr, "/") + last := parts[len(parts)-1] + if id, _, found := strings.Cut(last, ":"); found { + return id + } + return last +} + +func annotationOrDefault(annotations map[string]string, key, defaultVal string) string { + if annotations == nil { + return defaultVal + } + if v, ok := annotations[key]; ok { + return v + } + return defaultVal +} + +func buildFeatureTarGz(filename, content string) []byte { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + hdr := &tar.Header{ + Name: filename, + Mode: 0o644, + Size: int64(len(content)), + } + gomega.Expect(tw.WriteHeader(hdr)).To(gomega.Succeed()) + _, err := tw.Write([]byte(content)) + framework.ExpectNoError(err) + gomega.Expect(tw.Close()).To(gomega.Succeed()) + gomega.Expect(gz.Close()).To(gomega.Succeed()) + return buf.Bytes() +} diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index 8726603eb..9570a0e5d 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -282,6 +282,14 @@ func findContainerUsers( return containerUser, remoteUser } +// ResolveFeatureOrder parses the features in a DevContainerConfig, resolves their +// dependencies, and returns them in topological install order. +func ResolveFeatureOrder( + devContainerConfig *config.DevContainerConfig, +) ([]*config.FeatureSet, error) { + return fetchFeatures(devContainerConfig, false, nil) +} + func fetchFeatures( devContainerConfig *config.DevContainerConfig, forceBuild bool, diff --git a/pkg/devcontainer/feature/features.go b/pkg/devcontainer/feature/features.go index dc9919973..efb585b3a 100644 --- a/pkg/devcontainer/feature/features.go +++ b/pkg/devcontainer/feature/features.go @@ -192,6 +192,29 @@ func pullOCIImage(ref name.Reference) (v1.Image, error) { return img, nil } +// PullFeatureToTemp pulls an OCI feature image and extracts it to a temporary folder. +// Returns the path to the extracted feature folder. +func PullFeatureToTemp(ref name.Reference, id string) (string, error) { + featureFolder, err := getFeaturesTempFolder(id) + if err != nil { + return "", fmt.Errorf("resolve feature cache dir: %w", err) + } + + featureExtractedFolder := filepath.Join(featureFolder, "extracted") + + annotations, err := pullAndExtractOCIFeature(ref, id, featureFolder, featureExtractedFolder) + if err != nil { + return "", err + } + + if len(annotations) > 0 { + logOCIAnnotations(id, annotations) + saveAnnotations(featureFolder, annotations) + } + + return featureExtractedFolder, nil +} + func processOCIFeature(id string) (string, error) { log.Debugf("processing OCI feature: featureId=%s", id) From 5ecfef47a8f9a1fa32b659b793bead4a567092ed Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 16:51:12 -0500 Subject: [PATCH 2/4] refactor(features): use pkg/table for structured output --- cmd/features/info.go | 20 +++++++++++++------- cmd/features/resolvedeps.go | 22 +++++++++++----------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/cmd/features/info.go b/cmd/features/info.go index 2adbd36c9..c2c3196b5 100644 --- a/cmd/features/info.go +++ b/cmd/features/info.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/devsy-org/devsy/pkg/table" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -177,9 +178,12 @@ func (cmd *InfoCmd) printDependencies(w *os.File, info *featureInfo) { return } _, _ = fmt.Fprintln(w, "\nDependencies:") + headers := []string{"Dependency"} + var rows [][]string for dep := range info.Dependencies { - _, _ = fmt.Fprintf(w, " - %s\n", dep) + rows = append(rows, []string{dep}) } + table.Print(headers, rows) } func (cmd *InfoCmd) printTags(w *os.File, info *featureInfo) { @@ -195,13 +199,12 @@ func (cmd *InfoCmd) printOptions(w *os.File, info *featureInfo) { return } _, _ = fmt.Fprintln(w, "\nOptions:") + headers := []string{"Name", "Type", "Description", "Default"} + var rows [][]string for optName, opt := range info.Options { - _, _ = fmt.Fprintf(w, " %s (%s): %s", optName, opt.Type, opt.Description) - if string(opt.Default) != "" { - _, _ = fmt.Fprintf(w, " [default: %s]", string(opt.Default)) - } - _, _ = fmt.Fprintln(w) + rows = append(rows, []string{optName, opt.Type, opt.Description, string(opt.Default)}) } + table.Print(headers, rows) } func (cmd *InfoCmd) printAnnotations(w *os.File, info *featureInfo) { @@ -209,7 +212,10 @@ func (cmd *InfoCmd) printAnnotations(w *os.File, info *featureInfo) { return } _, _ = fmt.Fprintln(w, "\nOCI Annotations:") + headers := []string{"Key", "Value"} + var rows [][]string for k, v := range info.Annotations { - _, _ = fmt.Fprintf(w, " %s: %s\n", k, v) + rows = append(rows, []string{k, v}) } + table.Print(headers, rows) } diff --git a/cmd/features/resolvedeps.go b/cmd/features/resolvedeps.go index 563168c1c..3cceb3161 100644 --- a/cmd/features/resolvedeps.go +++ b/cmd/features/resolvedeps.go @@ -4,10 +4,12 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -127,20 +129,18 @@ func (cmd *ResolveDepsCmd) loadConfig() (*config.DevContainerConfig, error) { } func (cmd *ResolveDepsCmd) printText(resolved []resolvedFeature) error { - w := os.Stdout - _, _ = fmt.Fprintf(w, "Feature install order (%d features):\n\n", len(resolved)) + _, _ = fmt.Fprintf(os.Stdout, "Feature install order (%d features):\n\n", len(resolved)) + headers := []string{"#", "Feature", "Depends On", "Installs After"} + var rows [][]string for i, rf := range resolved { - versionSuffix := "" + version := rf.ID if rf.Version != "" { - versionSuffix = ":" + rf.Version - } - _, _ = fmt.Fprintf(w, " %d. %s%s\n", i+1, rf.ID, versionSuffix) - if len(rf.Dependencies) > 0 { - _, _ = fmt.Fprintf(w, " depends on: %v\n", rf.Dependencies) - } - if len(rf.InstallsAfter) > 0 { - _, _ = fmt.Fprintf(w, " installs after: %v\n", rf.InstallsAfter) + version = rf.ID + ":" + rf.Version } + deps := strings.Join(rf.Dependencies, ", ") + after := strings.Join(rf.InstallsAfter, ", ") + rows = append(rows, []string{fmt.Sprintf("%d", i+1), version, deps, after}) } + table.Print(headers, rows) return nil } From 37e3c5b894ded185654e500c16bd15f2777a7a62 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 17:04:05 -0500 Subject: [PATCH 3/4] fix(features): address code review findings for PR #219 - Populate Options field in buildResolvedList from FeatureSet.Options - Add output format validation to info and resolve-dependencies commands - Add unit tests for info subcommand (flag parsing, error handling) - Add E2E tests for resolve-dependencies and generate-docs subcommands --- cmd/features/info.go | 4 + cmd/features/info_test.go | 63 +++++++++++ cmd/features/output.go | 17 ++- cmd/features/resolvedeps.go | 7 ++ cmd/features/resolvedeps_test.go | 40 +++++++ e2e/tests/features/features.go | 173 +++++++++++++++++++++++++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 cmd/features/info_test.go diff --git a/cmd/features/info.go b/cmd/features/info.go index c2c3196b5..a35087388 100644 --- a/cmd/features/info.go +++ b/cmd/features/info.go @@ -72,6 +72,10 @@ to display metadata.`, } func (cmd *InfoCmd) Run(featureID string) error { + if err := validateOutputFormat(cmd.Output); err != nil { + return err + } + ref, err := name.ParseReference(featureID) if err != nil { return fmt.Errorf("invalid feature reference %q: %w", featureID, err) diff --git a/cmd/features/info_test.go b/cmd/features/info_test.go new file mode 100644 index 000000000..34dcfc432 --- /dev/null +++ b/cmd/features/info_test.go @@ -0,0 +1,63 @@ +package features + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfoCmd_FlagDefaults(t *testing.T) { + cmd := NewInfoCmd(nil) + + outputFlag := cmd.Flags().Lookup("output") + require.NotNil(t, outputFlag) + assert.Equal(t, "text", outputFlag.DefValue) + + showTagsFlag := cmd.Flags().Lookup("show-tags") + require.NotNil(t, showTagsFlag) + assert.Equal(t, "false", showTagsFlag.DefValue) + + showDepsFlag := cmd.Flags().Lookup("show-dependencies") + require.NotNil(t, showDepsFlag) + assert.Equal(t, "false", showDepsFlag.DefValue) + + verboseFlag := cmd.Flags().Lookup("verbose") + require.NotNil(t, verboseFlag) + assert.Equal(t, "false", verboseFlag.DefValue) +} + +func TestInfoCmd_AllFlagsRegistered(t *testing.T) { + cmd := NewInfoCmd(nil) + expected := []string{"output", "show-tags", "show-dependencies", "verbose"} + for _, name := range expected { + assert.NotNil(t, cmd.Flags().Lookup(name), "flag %q should be registered", name) + } +} + +func TestInfoCmd_RequiresExactlyOneArg(t *testing.T) { + cmd := NewInfoCmd(nil) + assert.NotNil(t, cmd.Args) + assert.Error(t, cmd.Args(cmd, []string{})) + assert.NoError(t, cmd.Args(cmd, []string{"one"})) + assert.Error(t, cmd.Args(cmd, []string{"one", "two"})) +} + +func TestInfoCmd_InvalidOutputFormat(t *testing.T) { + infoCmd := &InfoCmd{ + Output: "yaml", + } + err := infoCmd.Run("ghcr.io/devcontainers/features/go:1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") + assert.Contains(t, err.Error(), "yaml") +} + +func TestInfoCmd_InvalidFeatureReference(t *testing.T) { + infoCmd := &InfoCmd{ + Output: "text", + } + err := infoCmd.Run("not a valid reference!!!") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid feature reference") +} diff --git a/cmd/features/output.go b/cmd/features/output.go index 6e58b06f3..9de18adf9 100644 --- a/cmd/features/output.go +++ b/cmd/features/output.go @@ -6,7 +6,22 @@ import ( "io" ) -const outputJSON = "json" +const ( + outputJSON = "json" + outputText = "text" +) + +func validateOutputFormat(format string) error { + if format != outputText && format != outputJSON { + return fmt.Errorf( + "invalid output format %q: must be %q or %q", + format, + outputText, + outputJSON, + ) + } + return nil +} func writeJSON(w io.Writer, v any) error { data, err := json.MarshalIndent(v, "", " ") diff --git a/cmd/features/resolvedeps.go b/cmd/features/resolvedeps.go index 3cceb3161..ff3336599 100644 --- a/cmd/features/resolvedeps.go +++ b/cmd/features/resolvedeps.go @@ -60,6 +60,10 @@ install order based on dependency declarations and install ordering.`, } func (cmd *ResolveDepsCmd) Run() error { + if err := validateOutputFormat(cmd.Output); err != nil { + return err + } + devContainerConfig, err := cmd.loadConfig() if err != nil { return fmt.Errorf("load devcontainer config: %w", err) @@ -110,6 +114,9 @@ func buildResolvedList(sorted []*config.FeatureSet) []resolvedFeature { } rf.InstallsAfter = fs.Config.InstallsAfter } + if opts, ok := fs.Options.(map[string]any); ok { + rf.Options = opts + } resolved = append(resolved, rf) } return resolved diff --git a/cmd/features/resolvedeps_test.go b/cmd/features/resolvedeps_test.go index 5e3049291..2395864fc 100644 --- a/cmd/features/resolvedeps_test.go +++ b/cmd/features/resolvedeps_test.go @@ -61,3 +61,43 @@ func TestResolveDepsCmd_ExplicitConfig(t *testing.T) { err := cmd.Run() require.NoError(t, err) } + +func TestResolveDepsCmd_InvalidOutputFormat(t *testing.T) { + cmd := &ResolveDepsCmd{ + WorkspaceFolder: t.TempDir(), + Output: "yaml", + } + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") + assert.Contains(t, err.Error(), "yaml") +} + +func TestResolveDepsCmd_WithOptions(t *testing.T) { + workspaceDir := t.TempDir() + devcontainerDir := filepath.Join(workspaceDir, ".devcontainer") + require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) + + devcontainerJSON := `{ + "image": "ubuntu:22.04", + "features": { + "ghcr.io/devcontainers/features/go:1": { + "version": "1.21" + } + } + }` + require.NoError(t, os.WriteFile( + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(devcontainerJSON), + 0o600, + )) + + cmd := &ResolveDepsCmd{ + WorkspaceFolder: workspaceDir, + Output: "text", + } + + err := cmd.Run() + require.NoError(t, err) +} diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index 8a1d716a1..0656d8aa1 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http/httptest" "os" + "path/filepath" "strings" "github.com/devsy-org/devsy/e2e/framework" @@ -32,6 +33,178 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { framework.ExpectNoError(err) }) + ginkgo.Describe("features resolve-dependencies", func() { + ginkgo.It("outputs install order for features", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + workspaceDir, err := os.MkdirTemp("", "e2e-resolve-deps-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(workspaceDir) }) + + devcontainerDir := workspaceDir + "/.devcontainer" + framework.ExpectNoError(os.MkdirAll(devcontainerDir, 0o750)) + + devcontainerJSON := `{ + "image": "ubuntu:22.04", + "features": { + "./local-features/go": { + "version": "1.21" + }, + "./local-features/node": {} + } + }` + framework.ExpectNoError(os.WriteFile( + devcontainerDir+"/devcontainer.json", + []byte(devcontainerJSON), + 0o600, + )) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "resolve-dependencies", + "--workspace-folder", workspaceDir, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("Feature install order")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("outputs JSON when --output=json is specified", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + workspaceDir, err := os.MkdirTemp("", "e2e-resolve-deps-json-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(workspaceDir) }) + + devcontainerDir := workspaceDir + "/.devcontainer" + framework.ExpectNoError(os.MkdirAll(devcontainerDir, 0o750)) + + devcontainerJSON := `{ + "image": "ubuntu:22.04", + "features": { + "./local-features/go": {} + } + }` + framework.ExpectNoError(os.WriteFile( + devcontainerDir+"/devcontainer.json", + []byte(devcontainerJSON), + 0o600, + )) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "resolve-dependencies", + "--workspace-folder", workspaceDir, + "--output", "json", + }) + framework.ExpectNoError(err) + + var result []map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &result)).To(gomega.Succeed()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("rejects invalid output format", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + workspaceDir, err := os.MkdirTemp("", "e2e-resolve-deps-invalid-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(workspaceDir) }) + + _, stderr, err := f.ExecCommandCapture(ctx, []string{ + "features", "resolve-dependencies", + "--workspace-folder", workspaceDir, + "--output", "yaml", + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stderr).To(gomega.ContainSubstring("invalid output format")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + + ginkgo.Describe("features generate-docs", func() { + ginkgo.It("generates markdown files from feature metadata", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + projectDir, err := os.MkdirTemp("", "e2e-generate-docs-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) + + srcDir := projectDir + "/src/my-feature" + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + + featureJSON := `{ + "id": "my-feature", + "version": "1.0.0", + "name": "My Feature", + "description": "A test feature for E2E" + }` + framework.ExpectNoError(os.WriteFile( + srcDir+"/devcontainer-feature.json", + []byte(featureJSON), + 0o600, + )) + + outputDir, err := os.MkdirTemp("", "e2e-generate-docs-output-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(outputDir) }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "generate-docs", + "--project-folder", projectDir, + "--output-folder", outputDir, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("Generated:")) + + docContent, err := os.ReadFile( + filepath.Clean(filepath.Join(outputDir, "my-feature.md")), + ) + framework.ExpectNoError(err) + gomega.Expect(string(docContent)).To(gomega.ContainSubstring("# My Feature")) + gomega.Expect(string(docContent)).To(gomega.ContainSubstring("A test feature for E2E")) + + _, err = os.Stat(filepath.Join(outputDir, "README.md")) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("generates docs with namespace linking", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + projectDir, err := os.MkdirTemp("", "e2e-generate-docs-ns-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(projectDir) }) + + srcDir := projectDir + "/src/go" + framework.ExpectNoError(os.MkdirAll(srcDir, 0o750)) + + featureJSON := `{ + "id": "go", + "version": "1.0.0", + "name": "Go", + "description": "Install Go toolchain" + }` + framework.ExpectNoError(os.WriteFile( + srcDir+"/devcontainer-feature.json", + []byte(featureJSON), + 0o600, + )) + + outputDir, err := os.MkdirTemp("", "e2e-generate-docs-ns-output-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(outputDir) }) + + _, _, err = f.ExecCommandCapture(ctx, []string{ + "features", "generate-docs", + "--project-folder", projectDir, + "--output-folder", outputDir, + "--namespace", "ghcr.io/test/features", + }) + framework.ExpectNoError(err) + + docContent, err := os.ReadFile(filepath.Clean(filepath.Join(outputDir, "go.md"))) + framework.ExpectNoError(err) + gomega.Expect(string(docContent)).To(gomega.ContainSubstring("ghcr.io/test/features")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + ginkgo.Describe("features info", func() { ginkgo.It("displays feature metadata from a registry", func(ctx context.Context) { f := framework.NewDefaultFramework(initialDir + "/bin") From 987cd543497c1e5e24fb265f25bb9adb55891a54 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 17:17:45 -0500 Subject: [PATCH 4/4] fix(features): address PR #219 review findings - Add --output text|json flag to generate-docs command with JSON output path - Create local feature directories in E2E resolve-deps tests so ./local-features references resolve correctly - Replace live ghcr.io network call in TestResolveDepsCmd_WithOptions with local feature path for offline test execution - Show options and annotations in features info default output (not gated by --verbose) --- cmd/features/generatedocs.go | 44 +++++++++++++++++++++++++++++++ cmd/features/generatedocs_test.go | 3 +++ cmd/features/info.go | 12 +++------ cmd/features/info_test.go | 6 +---- cmd/features/resolvedeps_test.go | 10 ++++++- e2e/tests/features/features.go | 24 +++++++++++++++++ 6 files changed, 84 insertions(+), 15 deletions(-) diff --git a/cmd/features/generatedocs.go b/cmd/features/generatedocs.go index 1613858ad..70289cdf7 100644 --- a/cmd/features/generatedocs.go +++ b/cmd/features/generatedocs.go @@ -1,6 +1,7 @@ package features import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -17,6 +18,7 @@ type GenerateDocsCmd struct { ProjectFolder string OutputFolder string Namespace string + Output string } func NewGenerateDocsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { @@ -42,12 +44,19 @@ documentation for each feature based on its devcontainer-feature.json.`, generateDocsCmd.Flags().StringVar( &cmd.Namespace, "namespace", "", "Registry namespace for linking (e.g. ghcr.io/myorg/features)", ) + generateDocsCmd.Flags().StringVar( + &cmd.Output, "output", "text", "Output format (text or json)", + ) _ = generateDocsCmd.MarkFlagRequired("project-folder") return generateDocsCmd } func (cmd *GenerateDocsCmd) Run() error { + if err := validateOutputFormat(cmd.Output); err != nil { + return err + } + projectFolder, err := filepath.Abs(cmd.ProjectFolder) if err != nil { return fmt.Errorf("resolve project folder: %w", err) @@ -63,6 +72,10 @@ func (cmd *GenerateDocsCmd) Run() error { return err } + if cmd.Output == outputJSON { + return cmd.writeJSON(features) + } + return cmd.writeDocs(features, outputFolder) } @@ -143,6 +156,37 @@ type featureDoc struct { config *config.FeatureConfig } +type featureDocJSON struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Dir string `json:"dir"` + Namespace string `json:"namespace,omitempty"` + Options map[string]config.FeatureConfigOption `json:"options,omitempty"` +} + +func (cmd *GenerateDocsCmd) writeJSON(features []*featureDoc) error { + docs := make([]featureDocJSON, 0, len(features)) + for _, f := range features { + docs = append(docs, featureDocJSON{ + ID: f.config.ID, + Name: f.config.Name, + Version: f.config.Version, + Description: f.config.Description, + Dir: f.dir, + Namespace: cmd.Namespace, + Options: f.config.Options, + }) + } + data, err := json.MarshalIndent(docs, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(os.Stdout, string(data)) + return err +} + func (cmd *GenerateDocsCmd) generateFeatureDoc(f *featureDoc) string { var sb strings.Builder diff --git a/cmd/features/generatedocs_test.go b/cmd/features/generatedocs_test.go index f66f16c5e..a7e28e33d 100644 --- a/cmd/features/generatedocs_test.go +++ b/cmd/features/generatedocs_test.go @@ -40,6 +40,7 @@ func TestGenerateDocsCmd_Run(t *testing.T) { ProjectFolder: projectDir, OutputFolder: outputDir, Namespace: "ghcr.io/test/features", + Output: "text", } err := cmd.Run() @@ -77,6 +78,7 @@ func TestGenerateDocsCmd_NoFeatures(t *testing.T) { cmd := &GenerateDocsCmd{ ProjectFolder: projectDir, + Output: "text", } err := cmd.Run() @@ -108,6 +110,7 @@ func TestGenerateDocsCmd_MultipleFeatures(t *testing.T) { cmd := &GenerateDocsCmd{ ProjectFolder: projectDir, OutputFolder: outputDir, + Output: "text", } err := cmd.Run() diff --git a/cmd/features/info.go b/cmd/features/info.go index a35087388..194ab16e7 100644 --- a/cmd/features/info.go +++ b/cmd/features/info.go @@ -21,7 +21,6 @@ type InfoCmd struct { Output string ShowTags bool ShowDependencies bool - Verbose bool } type featureInfo struct { @@ -64,9 +63,6 @@ to display metadata.`, infoCmd.Flags().BoolVar( &cmd.ShowDependencies, "show-dependencies", false, "Show declared dependencies", ) - infoCmd.Flags().BoolVar( - &cmd.Verbose, "verbose", false, "Show full manifest and config details", - ) return infoCmd } @@ -134,9 +130,7 @@ func (cmd *InfoCmd) fetchInfo( info.Dependencies = featureCfg.DependsOn } - if cmd.Verbose { - info.Options = featureCfg.Options - } + info.Options = featureCfg.Options return info, nil } @@ -199,7 +193,7 @@ func (cmd *InfoCmd) printTags(w *os.File, info *featureInfo) { } func (cmd *InfoCmd) printOptions(w *os.File, info *featureInfo) { - if !cmd.Verbose || len(info.Options) == 0 { + if len(info.Options) == 0 { return } _, _ = fmt.Fprintln(w, "\nOptions:") @@ -212,7 +206,7 @@ func (cmd *InfoCmd) printOptions(w *os.File, info *featureInfo) { } func (cmd *InfoCmd) printAnnotations(w *os.File, info *featureInfo) { - if !cmd.Verbose || len(info.Annotations) == 0 { + if len(info.Annotations) == 0 { return } _, _ = fmt.Fprintln(w, "\nOCI Annotations:") diff --git a/cmd/features/info_test.go b/cmd/features/info_test.go index 34dcfc432..232757f46 100644 --- a/cmd/features/info_test.go +++ b/cmd/features/info_test.go @@ -21,15 +21,11 @@ func TestInfoCmd_FlagDefaults(t *testing.T) { showDepsFlag := cmd.Flags().Lookup("show-dependencies") require.NotNil(t, showDepsFlag) assert.Equal(t, "false", showDepsFlag.DefValue) - - verboseFlag := cmd.Flags().Lookup("verbose") - require.NotNil(t, verboseFlag) - assert.Equal(t, "false", verboseFlag.DefValue) } func TestInfoCmd_AllFlagsRegistered(t *testing.T) { cmd := NewInfoCmd(nil) - expected := []string{"output", "show-tags", "show-dependencies", "verbose"} + expected := []string{"output", "show-tags", "show-dependencies"} for _, name := range expected { assert.NotNil(t, cmd.Flags().Lookup(name), "flag %q should be registered", name) } diff --git a/cmd/features/resolvedeps_test.go b/cmd/features/resolvedeps_test.go index 2395864fc..a972dce1f 100644 --- a/cmd/features/resolvedeps_test.go +++ b/cmd/features/resolvedeps_test.go @@ -79,10 +79,18 @@ func TestResolveDepsCmd_WithOptions(t *testing.T) { devcontainerDir := filepath.Join(workspaceDir, ".devcontainer") require.NoError(t, os.MkdirAll(devcontainerDir, 0o750)) + goFeatureDir := filepath.Join(devcontainerDir, "local-features", "go") + require.NoError(t, os.MkdirAll(goFeatureDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(goFeatureDir, "devcontainer-feature.json"), + []byte(`{"id":"go","version":"1.0.0","name":"Go"}`), + 0o600, + )) + devcontainerJSON := `{ "image": "ubuntu:22.04", "features": { - "ghcr.io/devcontainers/features/go:1": { + "./local-features/go": { "version": "1.21" } } diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index 0656d8aa1..16680ba59 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -44,6 +44,22 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { devcontainerDir := workspaceDir + "/.devcontainer" framework.ExpectNoError(os.MkdirAll(devcontainerDir, 0o750)) + goFeatureDir := devcontainerDir + "/local-features/go" + framework.ExpectNoError(os.MkdirAll(goFeatureDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + goFeatureDir+"/devcontainer-feature.json", + []byte(`{"id":"go","version":"1.0.0","name":"Go"}`), + 0o600, + )) + + nodeFeatureDir := devcontainerDir + "/local-features/node" + framework.ExpectNoError(os.MkdirAll(nodeFeatureDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + nodeFeatureDir+"/devcontainer-feature.json", + []byte(`{"id":"node","version":"1.0.0","name":"Node.js"}`), + 0o600, + )) + devcontainerJSON := `{ "image": "ubuntu:22.04", "features": { @@ -78,6 +94,14 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { devcontainerDir := workspaceDir + "/.devcontainer" framework.ExpectNoError(os.MkdirAll(devcontainerDir, 0o750)) + goFeatureDir := devcontainerDir + "/local-features/go" + framework.ExpectNoError(os.MkdirAll(goFeatureDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + goFeatureDir+"/devcontainer-feature.json", + []byte(`{"id":"go","version":"1.0.0","name":"Go"}`), + 0o600, + )) + devcontainerJSON := `{ "image": "ubuntu:22.04", "features": {