Skip to content

Commit fca7be8

Browse files
Merge pull request #57 from fosrl/dev
0.6.1
2 parents d9dad3b + 8d363bf commit fca7be8

6 files changed

Lines changed: 308 additions & 124 deletions

File tree

cmd/apply/blueprint/blueprint.go

Lines changed: 85 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package blueprint
22

33
import (
44
"errors"
5+
"fmt"
6+
"io"
57
"os"
68
"path/filepath"
79
"regexp"
@@ -14,8 +16,11 @@ import (
1416
)
1517

1618
type BlueprintCmdOpts struct {
17-
Name string
18-
Path string
19+
Name string
20+
Path string
21+
APIKey string
22+
Endpoint string
23+
OrgID string
1924
}
2025

2126
func BlueprintCmd() *cobra.Command {
@@ -26,78 +31,112 @@ func BlueprintCmd() *cobra.Command {
2631
Short: "Apply a blueprint",
2732
Long: "Apply a YAML blueprint to the Pangolin server",
2833
PreRunE: func(cmd *cobra.Command, args []string) error {
29-
if opts.Path == "" {
30-
return errors.New("--file is required")
34+
// Integration API: any of the three flags implies all three are required (avoids silent session fallback).
35+
integration := opts.APIKey != "" || opts.Endpoint != "" || opts.OrgID != ""
36+
if integration && (opts.APIKey == "" || opts.Endpoint == "" || opts.OrgID == "") {
37+
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")
3138
}
32-
33-
if _, err := os.Stat(opts.Path); err != nil {
34-
return err
35-
}
36-
37-
// Strip file extension and use file basename path as name
38-
if opts.Name == "" {
39-
filename := filepath.Base(opts.Path)
40-
if before, ok := strings.CutSuffix(filename, ".yaml"); ok {
41-
opts.Name = before
42-
} else if before, ok := strings.CutSuffix(filename, ".yml"); ok {
43-
opts.Name = before
44-
} else {
45-
opts.Name = filename
46-
}
47-
}
48-
49-
if len(opts.Name) < 1 || len(opts.Name) > 255 {
50-
return errors.New("name must be between 1-255 characters")
51-
}
52-
5339
return nil
5440
},
55-
Run: func(cmd *cobra.Command, args []string) {
41+
RunE: func(cmd *cobra.Command, args []string) error {
5642
if err := applyBlueprintMain(cmd, opts); err != nil {
57-
os.Exit(1)
43+
return err
5844
}
45+
logger.Info("Successfully applied blueprint!")
46+
return nil
5947
},
6048
}
6149

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

6657
return cmd
6758
}
6859

6960
func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error {
70-
api := api.FromContext(cmd.Context())
71-
accountStore := config.AccountStoreFromContext(cmd.Context())
72-
73-
account, err := accountStore.ActiveAccount()
74-
if err != nil {
75-
logger.Error("Error: %v", err)
76-
return err
61+
if opts.Path == "-" && strings.TrimSpace(opts.Name) == "" {
62+
return errors.New("name is required when using --file -")
7763
}
7864

79-
if account.OrgID == "" {
80-
logger.Error("Error: no organization selected. Run 'pangolin select org' first.")
81-
return errors.New("no organization selected")
65+
name := opts.Name
66+
if name == "" {
67+
filename := filepath.Base(opts.Path)
68+
switch ext := strings.ToLower(filepath.Ext(filename)); ext {
69+
case ".yaml", ".yml":
70+
name = strings.TrimSuffix(filename, ext)
71+
default:
72+
name = filename
73+
}
74+
}
75+
if len(name) < 1 || len(name) > 255 {
76+
return errors.New("name must be between 1-255 characters")
8277
}
8378

84-
blueprintContents, err := os.ReadFile(opts.Path)
79+
apiClient := api.FromContext(cmd.Context())
80+
accountStore := config.AccountStoreFromContext(cmd.Context())
81+
82+
blueprintContents, err := readBlueprint(opts.Path)
8583
if err != nil {
86-
logger.Error("Error: failed to read blueprint file: %v", err)
8784
return err
8885
}
8986

9087
blueprintContents = interpolateBlueprint(blueprintContents)
9188

92-
_, err = api.ApplyBlueprint(account.OrgID, opts.Name, string(blueprintContents))
89+
client := apiClient
90+
orgID := opts.OrgID
91+
92+
if opts.APIKey != "" {
93+
client, err = apiClient.WithIntegrationAPIKey(opts.Endpoint, opts.APIKey)
94+
if err != nil {
95+
return fmt.Errorf("failed to initialize api key client: %w", err)
96+
}
97+
} else {
98+
account, errAcc := accountStore.ActiveAccount()
99+
if errAcc != nil {
100+
return errAcc
101+
}
102+
if account.OrgID == "" {
103+
return errors.New("no organization selected")
104+
}
105+
orgID = account.OrgID
106+
}
107+
108+
_, err = client.ApplyBlueprint(orgID, name, string(blueprintContents))
93109
if err != nil {
94-
logger.Error("Error: failed to apply blueprint: %v", err)
95-
return err
110+
return fmt.Errorf("failed to apply blueprint: %w", err)
96111
}
112+
return nil
113+
}
97114

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

100-
return nil
125+
contents, err := io.ReadAll(os.Stdin)
126+
if err != nil {
127+
return nil, fmt.Errorf("failed to read blueprint from stdin: %w", err)
128+
}
129+
if len(contents) == 0 {
130+
return nil, errors.New("blueprint input is empty")
131+
}
132+
return contents, nil
133+
}
134+
135+
contents, err := os.ReadFile(path)
136+
if err != nil {
137+
return nil, fmt.Errorf("failed to read blueprint file: %w", err)
138+
}
139+
return contents, nil
101140
}
102141

103142
// interpolateBlueprint finds all {{...}} tokens in the raw blueprint bytes and

get-cli.sh

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ detect_platform() {
9191
printf '%s_%s' "$os" "$arch"
9292
}
9393

94-
# Determine installation directory
94+
# Determine installation directory (default fallback)
9595
get_install_dir() {
9696
case "$PLATFORM" in
9797
*windows*)
@@ -124,6 +124,29 @@ parse_path_arg() {
124124
done
125125
}
126126

127+
# Detect an existing pangolin binary location.
128+
# Tries unprivileged which first, then sudo which (for binaries only visible to root).
129+
# Returns the full path of the binary, or empty string if not found.
130+
detect_existing_binary() {
131+
existing=""
132+
133+
# Try unprivileged which first
134+
existing=$(command -v pangolin 2>/dev/null || true)
135+
if [ -n "$existing" ]; then
136+
printf '%s' "$existing"
137+
return
138+
fi
139+
140+
# Try sudo which — some installations land in paths only root can see in $PATH
141+
if command -v sudo >/dev/null 2>&1; then
142+
existing=$(sudo which pangolin 2>/dev/null || true)
143+
if [ -n "$existing" ]; then
144+
printf '%s' "$existing"
145+
return
146+
fi
147+
fi
148+
}
149+
127150
# Check if we need sudo for installation
128151
needs_sudo() {
129152
install_dir="$1"
@@ -234,11 +257,11 @@ verify_installation() {
234257

235258
# Main function
236259
main() {
237-
# Check for --path argument
260+
# --path explicitly overrides everything
238261
CUSTOM_PATH=$(parse_path_arg "$@")
239262

240263
if [ -n "$CUSTOM_PATH" ]; then
241-
print_status "Installing latest version of Pangolin to ${CUSTOM_PATH}..."
264+
print_status "Installing latest version of Pangolin to ${CUSTOM_PATH} (--path override)..."
242265
else
243266
print_status "Installing latest version of Pangolin..."
244267
fi
@@ -253,10 +276,21 @@ main() {
253276
print_status "Detected platform: ${PLATFORM}"
254277

255278
if [ -n "$CUSTOM_PATH" ]; then
279+
# --path wins; derive INSTALL_DIR from it
256280
INSTALL_DIR=$(dirname "$CUSTOM_PATH")
257281
else
258-
INSTALL_DIR=$(get_install_dir)
282+
# Try to find an existing installation so we update the right place
283+
EXISTING_BINARY=$(detect_existing_binary)
284+
if [ -n "$EXISTING_BINARY" ]; then
285+
print_status "Found existing Pangolin binary at ${EXISTING_BINARY}"
286+
CUSTOM_PATH="$EXISTING_BINARY"
287+
INSTALL_DIR=$(dirname "$EXISTING_BINARY")
288+
print_status "Will update existing installation at ${INSTALL_DIR}"
289+
else
290+
INSTALL_DIR=$(get_install_dir)
291+
fi
259292
fi
293+
260294
print_status "Install directory: ${INSTALL_DIR}"
261295

262296
# Check if we need sudo
@@ -284,4 +318,4 @@ main() {
284318
fi
285319
}
286320

287-
main "$@"
321+
main "$@"

0 commit comments

Comments
 (0)