• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

CyberDuck79 / duckfile / 18097442731

29 Sep 2025 12:46PM UTC coverage: 79.573% (+0.04%) from 79.529%
18097442731

Pull #70

github

CyberDuck79
refactor: extract writeMetadataFile helper to reduce metadata writing duplication

- Add writeMetadataFile() helper function for consistent JSON metadata writing
- Replace duplicate json.Marshal + os.WriteFile patterns in remote.go
- Reduces code duplication in template and remote metadata handling
Pull Request #70: Phase 2: Implement separated targets and remotes configurations

592 of 686 new or added lines in 9 files covered. (86.3%)

24 existing lines in 5 files now uncovered.

2758 of 3466 relevant lines covered (79.57%)

11.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.15
/cmd/duck/root.go
1
package main
2

3
import (
4
        "fmt"
5
        "os"
6
        "strings"
7

8
        "github.com/CyberDuck79/duckfile/internal/config"
9
        "github.com/CyberDuck79/duckfile/internal/log"
10
        "github.com/CyberDuck79/duckfile/internal/run"
11
        "github.com/spf13/cobra"
12
)
13

14
// test seam - updated to include security config
15
var runExec = func(cfg *config.DuckConf, targetName string, passthrough []string, securityCfg *config.SecurityConfig, trackCommitHashFlag *bool, autoUpdateOnChangeFlag *bool) error {
2✔
16
        return run.Exec(cfg, targetName, passthrough, securityCfg, trackCommitHashFlag, autoUpdateOnChangeFlag)
2✔
17
}
2✔
18

19
// Version is injected at build time with: go build -ldflags "-X main.Version=<version>"
20
// Defaults to "dev" when not set.
21
var Version = "dev"
22

23
// Global variable to store config file path from --config flag
24
var configPath string
25

26
var rootCmd = &cobra.Command{
27
        Use:   "duck [-- target_args...]",
28
        Short: "Universal remote templating for DevOps tools",
29
        Long: `Duck fetches, renders, and executes remote Git templates with deterministic caching.
30

31
USAGE
32
  duck [flags]                      Execute default target  
33
  duck -- [args...] [flags]        Execute default target with args
34
  duck exec [target] [flags]        Execute specific target
35
  duck [command] [flags]            Run a specific command
36

37
EXAMPLES
38
  duck                              Execute default target
39
  duck -- --verbose                Execute default target with args
40
  duck exec build                   Execute 'build' target explicitly
41
  duck exec sync                    Execute target named 'sync' (not subcommand)
42
  duck sync                         Run sync subcommand (render templates)
43
  duck list                         List available targets
44

45
ENVIRONMENT VARIABLES
46
  DUCK_CONFIG                    Path to config file (overrides auto-discovery)
47
  DUCK_LOG_LEVEL                 Default log level
48
  DUCK_TRACK_COMMIT_HASH         Enable commit hash tracking (true/false)
49
  DUCK_AUTO_UPDATE_ON_CHANGE     Enable auto-update behavior (true/false)
50
  DUCK_ALLOWED_HOSTS             Comma-separated allowed hosts
51
  DUCK_DENIED_HOSTS              Comma-separated denied hosts
52
  DUCK_STRICT_MODE               Enable strict host validation (true/false)
53

54
FLAGS
55
  -c, --config string            Path to duck config file (default: auto-discover)
56
  --log-level string             Log level: error, warn, info, debug
57
  --track-commit-hash            Enable commit hash validation
58
  --no-track-commit-hash         Disable commit hash validation
59
  --auto-update-on-change        Auto-update when commit hash changes
60
  --no-auto-update-on-change     Disable auto-update on commit changes
61
  --allowed-hosts strings        Comma-separated allowed Git hosts
62
  --denied-hosts strings         Comma-separated denied Git hosts
63
  --strict-hosts                 Fail if no host restrictions configured`,
64
        SilenceUsage:       true,
65
        SilenceErrors:      true,
66
        DisableFlagParsing: true, // Disable to handle custom parsing with -- separator
67
        Args:               cobra.ArbitraryArgs,
68
        RunE: func(cmd *cobra.Command, args []string) error {
32✔
69
                // Execute targets (including default when no args provided)
32✔
70
                return executeTargetFromArgsWithCmd(cmd, args)
32✔
71
        },
32✔
72
}
73

74
func init() {
1✔
75
        rootCmd.Version = Version
1✔
76

1✔
77
        // Add persistent flag available to all subcommands
1✔
78
        rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "",
1✔
79
                "Path to duck config file (default: auto-discover duck.yaml, duck.yml, .duck.yaml, .duck.yml)")
1✔
80

1✔
81
        // Set custom help template only for root command
1✔
82
        rootCmd.SetHelpTemplate(`{{.Long}}
1✔
83

1✔
84
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
1✔
85
  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
1✔
86

1✔
87
Flags:
1✔
88
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
1✔
89

1✔
90
Global Flags:
1✔
91
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
1✔
92

1✔
93
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
1✔
94
  {{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}
1✔
95

1✔
96
Use "{{.CommandPath}} [command] --help" for more information about a command.
1✔
97
`)
1✔
98
}
1✔
99

100
// executeTargetFromArgsWithCmd handles the common target execution logic with optional cmd for help
101
// parseConfigPath extracts config path from arguments, handling both --config and -c flags
102
func parseConfigPath(arg string, args []string, i int) (string, int, bool) {
46✔
103
        switch {
46✔
104
        case strings.HasPrefix(arg, "--config="):
1✔
105
                return arg[len("--config="):], i, true
1✔
106
        case (arg == "--config" || arg == "-c") && i+1 < len(args):
5✔
107
                return args[i+1], i + 1, true
5✔
108
        default:
40✔
109
                return "", i, false
40✔
110
        }
111
}
112

113
// parseLogLevel extracts log level from arguments
114
func parseLogLevel(arg string, args []string, i int) (string, int, bool) {
40✔
115
        switch {
40✔
116
        case strings.HasPrefix(arg, "--log-level="):
1✔
117
                return arg[len("--log-level="):], i, true
1✔
118
        case arg == "--log-level" && i+1 < len(args):
1✔
119
                return args[i+1], i + 1, true
1✔
120
        default:
38✔
121
                return "", i, false
38✔
122
        }
123
}
124

125
// parseHostList extracts host list from arguments for allowed/denied hosts
126
func parseHostList(arg string, args []string, i int, prefix string, flag string) ([]string, int, bool) {
74✔
127
        switch {
74✔
128
        case strings.HasPrefix(arg, prefix):
2✔
129
                hostStr := arg[len(prefix):]
2✔
130
                return strings.Split(hostStr, ","), i, true
2✔
131
        case arg == flag && i+1 < len(args):
2✔
132
                hostStr := args[i+1]
2✔
133
                return strings.Split(hostStr, ","), i + 1, true
2✔
134
        default:
70✔
135
                return nil, i, false
70✔
136
        }
137
}
138

139
// processSingleFlag handles parsing of a single command-line flag
140
func processSingleFlag(arg string, args []string, i int, result *argResults) (int, bool) {
46✔
141
        // Try parsing config path
46✔
142
        if configPathValue, newI, found := parseConfigPath(arg, args, i); found {
52✔
143
                configPath = configPathValue
6✔
144
                return newI, true
6✔
145
        }
6✔
146

147
        // Try parsing log level
148
        if logLevel, newI, found := parseLogLevel(arg, args, i); found {
42✔
149
                result.logLevel = logLevel
2✔
150
                return newI, true
2✔
151
        }
2✔
152

153
        // Try parsing allowed hosts
154
        if hosts, newI, found := parseHostList(arg, args, i, "--allowed-hosts=", "--allowed-hosts"); found {
40✔
155
                result.allowedHosts = hosts
2✔
156
                return newI, true
2✔
157
        }
2✔
158

159
        // Try parsing denied hosts
160
        if hosts, newI, found := parseHostList(arg, args, i, "--denied-hosts=", "--denied-hosts"); found {
38✔
161
                result.deniedHosts = hosts
2✔
162
                return newI, true
2✔
163
        }
2✔
164

165
        // Handle boolean and simple flags
166
        switch arg {
34✔
167
        case "--track-commit-hash":
9✔
168
                trackTrue := true
9✔
169
                result.trackCommitHash = &trackTrue
9✔
170
                return i, true
9✔
171
        case "--no-track-commit-hash":
2✔
172
                trackFalse := false
2✔
173
                result.trackCommitHash = &trackFalse
2✔
174
                return i, true
2✔
175
        case "--auto-update-on-change":
3✔
176
                updateTrue := true
3✔
177
                result.autoUpdateOnChange = &updateTrue
3✔
178
                return i, true
3✔
179
        case "--no-auto-update-on-change":
3✔
180
                updateFalse := false
3✔
181
                result.autoUpdateOnChange = &updateFalse
3✔
182
                return i, true
3✔
183
        case "--strict-hosts":
1✔
184
                result.strictMode = true
1✔
185
                return i, true
1✔
186
        case "-h", "--help":
2✔
187
                result.helpRequested = true
2✔
188
                return i, true
2✔
189
        default:
14✔
190
                if !strings.HasPrefix(arg, "-") && result.target == "" {
23✔
191
                        // This is the target name
9✔
192
                        result.target = arg
9✔
193
                        return i, true
9✔
194
                }
9✔
195
        }
196

197
        return i, false
5✔
198
}
199

200
// parseArguments extracts all command-line arguments and returns structured data
201
func parseArguments(args []string) (argResults, error) {
37✔
202
        var result argResults
37✔
203

37✔
204
        // Find "--" separator and split args
37✔
205
        sepIdx := -1
37✔
206
        for i, a := range args {
94✔
207
                if a == "--" {
60✔
208
                        sepIdx = i
3✔
209
                        break
3✔
210
                }
211
        }
212

213
        duckArgs := args
37✔
214
        if sepIdx != -1 {
40✔
215
                duckArgs = args[:sepIdx]
3✔
216
                result.binArgs = args[sepIdx+1:]
3✔
217
        }
3✔
218

219
        // Parse flags from duckArgs
220
        for i := 0; i < len(duckArgs); i++ {
83✔
221
                newI, processed := processSingleFlag(duckArgs[i], duckArgs, i, &result)
46✔
222
                if processed {
87✔
223
                        i = newI
41✔
224
                }
41✔
225
        }
226

227
        return result, nil
37✔
228
}
229

230
// argResults holds the parsed command-line arguments
231
type argResults struct {
232
        target             string
233
        binArgs            []string
234
        logLevel           string
235
        trackCommitHash    *bool
236
        autoUpdateOnChange *bool
237
        allowedHosts       []string
238
        deniedHosts        []string
239
        strictMode         bool
240
        helpRequested      bool
241
}
242

243
func executeTargetFromArgsWithCmd(cmd *cobra.Command, args []string) error {
37✔
244
        // Parse command-line arguments
37✔
245
        parsedArgs, err := parseArguments(args)
37✔
246
        if err != nil {
37✔
NEW
247
                return err
×
NEW
248
        }
×
249

250
        // Handle help request
251
        if parsedArgs.helpRequested {
39✔
252
                if cmd != nil {
4✔
253
                        return cmd.Help()
2✔
254
                }
2✔
255
                // For exec command without cmd reference, just ignore help flag
NEW
256
                return nil
×
257
        }
258

259
        // 1. load config
260
        cfg, err := loadConfig()
35✔
261
        if err != nil {
37✔
262
                return err
2✔
263
        }
2✔
264

265
        // 2. Resolve and set log level
266
        logLevelStr := config.ResolveLogLevel(parsedArgs.logLevel, cfg)
33✔
267
        effectiveLogLevel, err := log.ParseLevel(logLevelStr)
33✔
268
        if err != nil {
33✔
269
                return fmt.Errorf("invalid log level: %w", err)
×
270
        }
×
271
        log.SetLevel(effectiveLogLevel)
33✔
272

33✔
273
        // 3. If no target or explicit legacy "default", translate to configured default key
33✔
274
        target := parsedArgs.target
33✔
275
        if target == "" || target == "default" {
57✔
276
                target = cfg.Default
24✔
277
        }
24✔
278

279
        // 4. Build security configuration with enhanced precedence system
280
        securityCfg, err := config.BuildSecurityConfigWithPrecedence(parsedArgs.allowedHosts, parsedArgs.deniedHosts, parsedArgs.strictMode)
33✔
281
        if err != nil {
33✔
282
                return fmt.Errorf("failed to build security configuration: %w", err)
×
283
        }
×
284

285
        // 5. execute with security validation
286
        return runExec(cfg, target, parsedArgs.binArgs, securityCfg, parsedArgs.trackCommitHash, parsedArgs.autoUpdateOnChange)
33✔
287
}
288

289
// checkForTargetConflictAndWarn checks if there's a target with the same name as the subcommand
290
// and warns the user about potential confusion
291
func checkForTargetConflictAndWarn(subcommandName string) {
29✔
292
        cfg, err := loadConfig()
29✔
293
        if err != nil {
29✔
294
                // If we can't load config, don't show warnings
×
295
                return
×
296
        }
×
297

298
        if _, exists := cfg.Targets[subcommandName]; exists {
29✔
299
                fmt.Fprintf(os.Stderr, "⚠️  Note: You have a target named '%s' but ran the '%s' subcommand.\n", subcommandName, subcommandName)
×
300
                fmt.Fprintf(os.Stderr, "   To execute the target instead, use: duck exec %s\n\n", subcommandName)
×
301
        }
×
302
}
303

304
func main() {
×
305
        if err := rootCmd.Execute(); err != nil {
×
306
                fmt.Fprintln(os.Stderr, "error:", err)
×
307
                os.Exit(1)
×
308
        }
×
309
}
310

311
// resolveConfigFilePath determines the config file path using priority order:
312
// 1. --config flag (highest precedence)
313
// 2. DUCK_CONFIG environment variable
314
// 3. Auto-discovery (lowest precedence)
315
func resolveConfigFilePath() (string, error) {
93✔
316
        // Priority 1: --config flag (highest precedence)
93✔
317
        if configPath != "" {
101✔
318
                if _, err := os.Stat(configPath); err != nil {
9✔
319
                        return "", fmt.Errorf("config file %q not found: %w", configPath, err)
1✔
320
                }
1✔
321
                return configPath, nil
7✔
322
        }
323

324
        // Priority 2: DUCK_CONFIG environment variable
325
        if envConfigPath := os.Getenv("DUCK_CONFIG"); envConfigPath != "" {
87✔
326
                if _, err := os.Stat(envConfigPath); err != nil {
3✔
327
                        return "", fmt.Errorf("config file from DUCK_CONFIG %q not found: %w", envConfigPath, err)
1✔
328
                }
1✔
329
                return envConfigPath, nil
1✔
330
        }
331

332
        // Priority 3: Auto-discovery (lowest precedence)
333
        return discoverConfigFile()
83✔
334
}
335

336
// discoverConfigFile attempts to find a config file using standard names
337
func discoverConfigFile() (string, error) {
83✔
338
        configFiles := []string{"duck.yaml", "duck.yml", ".duck.yaml", ".duck.yml"}
83✔
339
        for _, f := range configFiles {
166✔
340
                if _, err := os.Stat(f); err == nil {
166✔
341
                        return f, nil
83✔
342
                }
83✔
343
        }
NEW
344
        return "", fmt.Errorf("no config file found (tried: %v). Use --config to specify a custom path", configFiles)
×
345
}
346

347
func loadConfig() (*config.DuckConf, error) {
93✔
348
        // Load .env file first (before any config loading)
93✔
349
        // This allows .env variables to be used in config resolution
93✔
350
        if err := config.LoadEnvFileIfExists(log.Infof); err != nil {
93✔
351
                return nil, fmt.Errorf("environment setup failed: %w", err)
×
352
        }
×
353

354
        // Resolve config file path using priority order
355
        cfgFile, err := resolveConfigFilePath()
93✔
356
        if err != nil {
95✔
357
                return nil, err
2✔
358
        }
2✔
359

360
        log.Debugf("Loading configuration from %s", cfgFile)
91✔
361
        // Load config from the determined file path
91✔
362
        cfg, err := config.Load(cfgFile)
91✔
363
        if err != nil {
91✔
364
                return nil, fmt.Errorf("failed to load config from %q: %w", cfgFile, err)
×
365
        }
×
366
        log.Debugf("Successfully loaded configuration with %d targets", len(cfg.Targets))
91✔
367

91✔
368
        return cfg, nil
91✔
369
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc