Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ next level of detail:
flows
- [Managed Application Data](./docs/managed-application-data.md) for cache,
runtime, and app-data locations
- [Extensions](./docs/extensions.md) for the external executable interface and
JSON schemas used by analyzers and generators
- [Windows Ansible Access](./docs/windows-ansible-access.md) for manual WinRM
and SSH setup on Windows targets plus OpenSSH rollback notes
- [Example Ansible Roles](./docs/example-roles.md) for the current sample role
Expand Down
108 changes: 108 additions & 0 deletions cmd/cmd/extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package cmd

import (
"fmt"
"io"
"text/tabwriter"

alchemy_extension "github.com/csautter/dev-alchemy/pkg/extension"
"github.com/spf13/cobra"
)

var (
extensionDiscoverFunc = alchemy_extension.Discover
extensionRunFunc = alchemy_extension.Run
)

var extensionCmd = &cobra.Command{
Use: "extension",
Short: "Discover and run external Dev Alchemy extensions",
Long: `Discover and run external Dev Alchemy extensions.

Extensions are executable files on PATH named alchemy-<name>. They run as
separate processes so private extensions can integrate through a stable command
and JSON file contract without linking into the open Dev Alchemy binary.`,
}

var extensionListCmd = &cobra.Command{
Use: "list",
Short: "List available external extensions",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
extensions, err := extensionDiscoverFunc(alchemy_extension.DiscoverOptions{})
if err != nil {
return err
}
return printAvailableExtensions(cmd.OutOrStdout(), extensions)
},
}

var extensionRunCmd = &cobra.Command{
Use: "run <name> [-- <extension args...>]",
Short: "Run an external extension executable",
Long: `Run an external extension executable.

The extension name resolves to alchemy-<name> on PATH. Pass extension flags
after -- so Dev Alchemy does not parse them.`,
Example: ` alchemy extension run analyzer -- scan --out snapshot.json
alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible`,
Args: validateExtensionRunArgs,
RunE: func(cmd *cobra.Command, args []string) error {
name, extensionArgs := splitExtensionRunArgs(cmd, args)
return extensionRunFunc(cmd.Context(), alchemy_extension.RunOptions{
Name: name,
Args: extensionArgs,
Stdin: cmd.InOrStdin(),
Stdout: cmd.OutOrStdout(),
Stderr: cmd.ErrOrStderr(),
})
},
}

func printAvailableExtensions(writer io.Writer, extensions []alchemy_extension.Executable) error {
if len(extensions) == 0 {
_, err := fmt.Fprintln(writer, "No Dev Alchemy extensions found on PATH. Install an executable named alchemy-<name> to add one.")
return err
}

tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "NAME\tEXECUTABLE"); err != nil {
return err
}
for _, extension := range extensions {
if _, err := fmt.Fprintf(tw, "%s\t%s\n", extension.Name, extension.Path); err != nil {
return err
}
}

return tw.Flush()
}

func validateExtensionRunArgs(cmd *cobra.Command, args []string) error {
positionalArgCount := len(args)
if dashIndex := cmd.ArgsLenAtDash(); dashIndex >= 0 {
positionalArgCount = dashIndex
}
if positionalArgCount < 1 {
return fmt.Errorf("accepts at least 1 arg(s), received %d", positionalArgCount)
}

return nil
}

func splitExtensionRunArgs(cmd *cobra.Command, args []string) (string, []string) {
if dashIndex := cmd.ArgsLenAtDash(); dashIndex >= 0 {
return args[0], args[dashIndex:]
}

if len(args) == 1 {
return args[0], nil
}
return args[0], args[1:]
}

func init() {
rootCmd.AddCommand(extensionCmd)
extensionCmd.AddCommand(extensionListCmd)
extensionCmd.AddCommand(extensionRunCmd)
}
91 changes: 91 additions & 0 deletions cmd/cmd/extension_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package cmd

import (
"bytes"
"context"
"io"
"strings"
"testing"

alchemy_extension "github.com/csautter/dev-alchemy/pkg/extension"
"github.com/spf13/cobra"
)

func TestPrintAvailableExtensions(t *testing.T) {
var output bytes.Buffer

err := printAvailableExtensions(&output, []alchemy_extension.Executable{
{Name: "analyzer", Path: "/usr/local/bin/alchemy-analyzer"},
})
if err != nil {
t.Fatalf("expected extension listing to succeed, got %v", err)
}

got := output.String()
for _, want := range []string{"NAME", "EXECUTABLE", "analyzer", "/usr/local/bin/alchemy-analyzer"} {
if !strings.Contains(got, want) {
t.Fatalf("expected output to contain %q, got:\n%s", want, got)
}
}
}

func TestPrintAvailableExtensionsHandlesEmptyPath(t *testing.T) {
var output bytes.Buffer

if err := printAvailableExtensions(&output, nil); err != nil {
t.Fatalf("expected empty extension listing to succeed, got %v", err)
}
if !strings.Contains(output.String(), "No Dev Alchemy extensions found") {
t.Fatalf("expected empty extension message, got %q", output.String())
}
}

func TestExtensionRunCommandPassesArgumentsAfterDash(t *testing.T) {
previousRunFunc := extensionRunFunc
previousRootOut := rootCmd.OutOrStdout()
previousRootErr := rootCmd.ErrOrStderr()
t.Cleanup(func() {
extensionRunFunc = previousRunFunc
rootCmd.SetArgs(nil)
rootCmd.SetOut(previousRootOut)
rootCmd.SetErr(previousRootErr)
})

var capturedOptions alchemy_extension.RunOptions
extensionRunFunc = func(ctx context.Context, options alchemy_extension.RunOptions) error {
capturedOptions = options
return nil
}

rootCmd.SetArgs([]string{
"extension",
"run",
"analyzer",
"--",
"scan",
"--out",
"snapshot.json",
})
rootCmd.SetOut(io.Discard)
rootCmd.SetErr(io.Discard)

if err := rootCmd.Execute(); err != nil {
t.Fatalf("expected extension run to execute successfully, got %v", err)
}
if capturedOptions.Name != "analyzer" {
t.Fatalf("expected analyzer extension, got %q", capturedOptions.Name)
}
if got := strings.Join(capturedOptions.Args, " "); got != "scan --out snapshot.json" {
t.Fatalf("expected args after -- to pass through, got %q", got)
}
}

func TestExtensionRunCommandPassesPositionalArgumentsWithoutDash(t *testing.T) {
name, args := splitExtensionRunArgs(&cobra.Command{}, []string{"analyzer", "manifest"})
if name != "analyzer" {
t.Fatalf("expected analyzer extension, got %q", name)
}
if got := strings.Join(args, " "); got != "manifest" {
t.Fatalf("expected positional extension args to pass through, got %q", got)
}
}
118 changes: 118 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Dev Alchemy Extensions

Dev Alchemy extensions are external executables that integrate through files,
JSON documents, stdin/stdout, and process boundaries. They are not Go plugins
and they are not linked into the `alchemy` binary.

This keeps the open Dev Alchemy core useful on its own while allowing separate
open or proprietary tools to provide extra capabilities such as system analysis,
inventory import, policy checks, or generated Ansible content.

## Command Surface

Install an extension as an executable on `PATH` named:

```text
alchemy-<name>
```

Examples:

```text
alchemy-analyzer
alchemy-inventory-import
alchemy-policy-check
```

List installed extensions:

```bash
alchemy extension list
```

Run an extension:

```bash
alchemy extension run analyzer -- scan --out snapshot.json
alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible
```

Arguments after `--` are passed to the extension unchanged. The open CLI does
not interpret extension-specific flags.

## Execution Contract

When Dev Alchemy runs an extension, it:

- resolves `alchemy-<name>` from `PATH`
- starts it as a separate process without shell parsing
- connects stdin, stdout, and stderr to the current CLI process
- sets `DEV_ALCHEMY_EXTENSION_PROTOCOL=1`
- sets `DEV_ALCHEMY_EXTENSION_NAME=<name>`

Extension names must use letters, digits, `.`, `_`, or `-`, and must not contain
path separators.

## Recommended Extension Commands

Extensions can expose any command surface they need. For system-analysis
extensions, use these command names unless there is a strong reason to differ:

```bash
alchemy extension run analyzer -- manifest
alchemy extension run analyzer -- scan --out snapshot.json
alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible
alchemy extension run analyzer -- validate --bundle generated-ansible
```

Recommended meanings:

- `manifest` writes extension metadata matching
[`dev-alchemy.extension-manifest.v1.schema.json`](../schemas/dev-alchemy.extension-manifest.v1.schema.json)
- `scan` writes a system snapshot matching
[`dev-alchemy.system-snapshot.v1.schema.json`](../schemas/dev-alchemy.system-snapshot.v1.schema.json)
- `generate` writes an Ansible bundle and metadata matching
[`dev-alchemy.ansible-bundle.v1.schema.json`](../schemas/dev-alchemy.ansible-bundle.v1.schema.json)
- `validate` checks an existing generated bundle before provisioning

## System Analyzer Flow

A closed or open analyzer should use Dev Alchemy as the provisioning and test
surface, not as a linked library:

```bash
alchemy extension run analyzer -- scan --out snapshot.json
alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible
alchemy provision local --playbook generated-ansible/playbooks/site.yml --check
```

Generated bundles should prefer normal Ansible layout:

```text
generated-ansible/
metadata.json
inventory/
playbooks/
roles/
group_vars/
host_vars/
```

The generated `metadata.json` should describe the bundle with the Ansible bundle
schema. Dev Alchemy can then run the generated playbooks with the normal
`alchemy provision` wrapper.

## Versioning

The current process protocol is `DEV_ALCHEMY_EXTENSION_PROTOCOL=1`.

JSON documents should include one of these schema version values:

```text
dev-alchemy.extension-manifest.v1
dev-alchemy.system-snapshot.v1
dev-alchemy.ansible-bundle.v1
```

Future incompatible changes should add new schema versions instead of changing
the meaning of existing fields.
Loading
Loading