Skip to content
131 changes: 85 additions & 46 deletions cmd/apply/blueprint/blueprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package blueprint

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
Expand All @@ -14,8 +16,11 @@ import (
)

type BlueprintCmdOpts struct {
Name string
Path string
Name string
Path string
APIKey string
Endpoint string
OrgID string
}

func BlueprintCmd() *cobra.Command {
Expand All @@ -26,78 +31,112 @@ func BlueprintCmd() *cobra.Command {
Short: "Apply a blueprint",
Long: "Apply a YAML blueprint to the Pangolin server",
PreRunE: func(cmd *cobra.Command, args []string) error {
if opts.Path == "" {
return errors.New("--file is required")
// Integration API: any of the three flags implies all three are required (avoids silent session fallback).
integration := opts.APIKey != "" || opts.Endpoint != "" || opts.OrgID != ""
if integration && (opts.APIKey == "" || opts.Endpoint == "" || opts.OrgID == "") {
return errors.New("integration API mode requires --api-key, --endpoint, and --org together; omit all three to use your logged-in session and selected org")
}

if _, err := os.Stat(opts.Path); err != nil {
return err
}

// Strip file extension and use file basename path as name
if opts.Name == "" {
filename := filepath.Base(opts.Path)
if before, ok := strings.CutSuffix(filename, ".yaml"); ok {
opts.Name = before
} else if before, ok := strings.CutSuffix(filename, ".yml"); ok {
opts.Name = before
} else {
opts.Name = filename
}
}

if len(opts.Name) < 1 || len(opts.Name) > 255 {
return errors.New("name must be between 1-255 characters")
}

return nil
},
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
if err := applyBlueprintMain(cmd, opts); err != nil {
os.Exit(1)
return err
}
logger.Info("Successfully applied blueprint!")
return nil
},
}

cmd.Flags().StringVarP(&opts.Path, "file", "f", "", "Path to blueprint file (required)")
cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name of blueprint (default: filename, without extension)")
cmd.Flags().StringVarP(&opts.Path, "file", "f", "", "Blueprint YAML file path (use '-' for stdin)")
cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Blueprint name (default: filename without extension)")
cmd.Flags().StringVar(&opts.APIKey, "api-key", "", "Integration API key (id.secret)")
cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Integration API host URL")
cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID")
cmd.MarkFlagRequired("file")

return cmd
}

func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error {
api := api.FromContext(cmd.Context())
accountStore := config.AccountStoreFromContext(cmd.Context())

account, err := accountStore.ActiveAccount()
if err != nil {
logger.Error("Error: %v", err)
return err
if opts.Path == "-" && strings.TrimSpace(opts.Name) == "" {
return errors.New("name is required when using --file -")
}

if account.OrgID == "" {
logger.Error("Error: no organization selected. Run 'pangolin select org' first.")
return errors.New("no organization selected")
name := opts.Name
if name == "" {
filename := filepath.Base(opts.Path)
switch ext := strings.ToLower(filepath.Ext(filename)); ext {
case ".yaml", ".yml":
name = strings.TrimSuffix(filename, ext)
default:
name = filename
}
}
if len(name) < 1 || len(name) > 255 {
return errors.New("name must be between 1-255 characters")
}

blueprintContents, err := os.ReadFile(opts.Path)
apiClient := api.FromContext(cmd.Context())
accountStore := config.AccountStoreFromContext(cmd.Context())

blueprintContents, err := readBlueprint(opts.Path)
if err != nil {
logger.Error("Error: failed to read blueprint file: %v", err)
return err
}

blueprintContents = interpolateBlueprint(blueprintContents)

_, err = api.ApplyBlueprint(account.OrgID, opts.Name, string(blueprintContents))
client := apiClient
orgID := opts.OrgID

if opts.APIKey != "" {
client, err = apiClient.WithIntegrationAPIKey(opts.Endpoint, opts.APIKey)
if err != nil {
return fmt.Errorf("failed to initialize api key client: %w", err)
}
} else {
account, errAcc := accountStore.ActiveAccount()
if errAcc != nil {
return errAcc
}
if account.OrgID == "" {
return errors.New("no organization selected")
}
orgID = account.OrgID
}

_, err = client.ApplyBlueprint(orgID, name, string(blueprintContents))
if err != nil {
logger.Error("Error: failed to apply blueprint: %v", err)
return err
return fmt.Errorf("failed to apply blueprint: %w", err)
}
return nil
}

logger.Info("Successfully applied blueprint!")
func readBlueprint(path string) ([]byte, error) {
if path == "-" {
fileInfo, err := os.Stdin.Stat()
if err != nil {
return nil, fmt.Errorf("failed to inspect stdin: %w", err)
}
if (fileInfo.Mode() & os.ModeCharDevice) == os.ModeCharDevice {
return nil, errors.New("the option --file - is intended to work with pipes")
}

return nil
contents, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("failed to read blueprint from stdin: %w", err)
}
if len(contents) == 0 {
return nil, errors.New("blueprint input is empty")
}
return contents, nil
}

contents, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read blueprint file: %w", err)
}
return contents, nil
}

// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and
Expand Down
44 changes: 39 additions & 5 deletions get-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ detect_platform() {
printf '%s_%s' "$os" "$arch"
}

# Determine installation directory
# Determine installation directory (default fallback)
get_install_dir() {
case "$PLATFORM" in
*windows*)
Expand Down Expand Up @@ -124,6 +124,29 @@ parse_path_arg() {
done
}

# Detect an existing pangolin binary location.
# Tries unprivileged which first, then sudo which (for binaries only visible to root).
# Returns the full path of the binary, or empty string if not found.
detect_existing_binary() {
existing=""

# Try unprivileged which first
existing=$(command -v pangolin 2>/dev/null || true)
if [ -n "$existing" ]; then
printf '%s' "$existing"
return
fi

# Try sudo which — some installations land in paths only root can see in $PATH
if command -v sudo >/dev/null 2>&1; then
existing=$(sudo which pangolin 2>/dev/null || true)
if [ -n "$existing" ]; then
printf '%s' "$existing"
return
fi
fi
}

# Check if we need sudo for installation
needs_sudo() {
install_dir="$1"
Expand Down Expand Up @@ -234,11 +257,11 @@ verify_installation() {

# Main function
main() {
# Check for --path argument
# --path explicitly overrides everything
CUSTOM_PATH=$(parse_path_arg "$@")

if [ -n "$CUSTOM_PATH" ]; then
print_status "Installing latest version of Pangolin to ${CUSTOM_PATH}..."
print_status "Installing latest version of Pangolin to ${CUSTOM_PATH} (--path override)..."
else
print_status "Installing latest version of Pangolin..."
fi
Expand All @@ -253,10 +276,21 @@ main() {
print_status "Detected platform: ${PLATFORM}"

if [ -n "$CUSTOM_PATH" ]; then
# --path wins; derive INSTALL_DIR from it
INSTALL_DIR=$(dirname "$CUSTOM_PATH")
else
INSTALL_DIR=$(get_install_dir)
# Try to find an existing installation so we update the right place
EXISTING_BINARY=$(detect_existing_binary)
if [ -n "$EXISTING_BINARY" ]; then
print_status "Found existing Pangolin binary at ${EXISTING_BINARY}"
CUSTOM_PATH="$EXISTING_BINARY"
INSTALL_DIR=$(dirname "$EXISTING_BINARY")
print_status "Will update existing installation at ${INSTALL_DIR}"
else
INSTALL_DIR=$(get_install_dir)
fi
fi

print_status "Install directory: ${INSTALL_DIR}"

# Check if we need sudo
Expand Down Expand Up @@ -284,4 +318,4 @@ main() {
fi
}

main "$@"
main "$@"
Loading
Loading