@@ -55,7 +55,7 @@ func init() {
5555 bootstrapCmd .Flags ().StringVar (& bootstrapAgeKey , "age-key-file" , "" , "path to age private key file for SOPS decryption" )
5656 bootstrapCmd .Flags ().StringVar (& encryption , "encryption" , "sops" , "encryption backend (sops|git-crypt)" )
5757 bootstrapCmd .Flags ().StringVar (& gitcryptKeyFile , "gitcrypt-key-file" , "" , "path to git-crypt symmetric key file (creates K8s secret)" )
58- bootstrapCmd .Flags ().StringVar (& appPath , "app-path" , "apps" , "path inside the Git repo for the App of Apps source " )
58+ bootstrapCmd .Flags ().StringVar (& appPath , "app-path" , "apps" , "path to App of Apps (relative to current dir when in subfolder, or full repo path with --base-dir) " )
5959 bootstrapCmd .Flags ().BoolVar (& waitForHealth , "wait-for-health" , false , "wait for cluster components to be ready after bootstrap" )
6060 bootstrapCmd .Flags ().IntVar (& healthTimeout , "health-timeout" , 180 , "timeout in seconds for health checks (default 180)" )
6161 bootstrapCmd .Flags ().StringVar (& reportFormat , "report-format" , "summary" , "report format: summary, json, none" )
@@ -74,11 +74,49 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
7474
7575 logger := NewLogger (verbose )
7676
77+ // Detect if we're running from a subdirectory and adjust paths accordingly
78+ var argoCDAppPath string
79+ var subfolderPath string
80+
81+ if baseDir == "." {
82+ // Check if we're in a subdirectory of a Git repository
83+ detected , relPath := detectGitSubdirectory ()
84+ if detected && relPath != "" {
85+ subfolderPath = relPath
86+
87+ // Handle different appPath scenarios:
88+ // 1. appPath="apps" -> convert to "k8s/apps"
89+ // 2. appPath="k8s/apps" (user specified full path) -> strip to "apps" for local validation, keep "k8s/apps" for ArgoCD
90+ if strings .HasPrefix (appPath , relPath + "/" ) {
91+ // User provided full path (e.g., "k8s/apps" while in k8s/)
92+ // This is valid, keep it for ArgoCD
93+ argoCDAppPath = appPath
94+ if verbose {
95+ fmt .Printf (" 📁 Detected running from subdirectory: %s\n " , relPath )
96+ fmt .Printf (" 📍 Using full path for ArgoCD: %s\n " , argoCDAppPath )
97+ }
98+ } else {
99+ // User provided relative path (e.g., "apps")
100+ // Convert to full path for ArgoCD
101+ argoCDAppPath = relPath + "/" + appPath
102+ if verbose {
103+ fmt .Printf (" 📁 Detected running from subdirectory: %s\n " , relPath )
104+ fmt .Printf (" 📍 Local path: %s -> ArgoCD path: %s\n " , appPath , argoCDAppPath )
105+ }
106+ }
107+ } else {
108+ argoCDAppPath = appPath
109+ }
110+ } else {
111+ // baseDir is explicitly set, use the original logic
112+ argoCDAppPath = appPath
113+ }
114+
77115 // Initialize bootstrap report
78116 report := NewBootstrapReport (env )
79117 report .Configuration = ConfigReport {
80118 BaseDir : baseDir ,
81- AppPath : appPath ,
119+ AppPath : argoCDAppPath ,
82120 Encryption : encryption ,
83121 SecretsFile : secretsFile ,
84122 Kubeconfig : kubeconfig ,
@@ -132,7 +170,8 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
132170
133171 // Validation
134172 validationTimer := startStage ("Validation" )
135- if err := validateBootstrapInputs (env ); err != nil {
173+ localAppPath , err := validateBootstrapInputs (env , argoCDAppPath )
174+ if err != nil {
136175 bootstrapErr = fmt .Errorf ("validation failed: %w" , err )
137176 report .AddStage (validationTimer .complete (false , err ))
138177 return bootstrapErr
@@ -143,7 +182,13 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
143182 configStage := logger .Stage ("Configuration" )
144183 configStage .Detail ("Environment: %s" , env )
145184 configStage .Detail ("Base directory: %s" , baseDir )
146- configStage .Detail ("App path: %s" , appPath )
185+ if subfolderPath != "" {
186+ configStage .Detail ("Subfolder context: %s" , subfolderPath )
187+ }
188+ configStage .Detail ("App path (ArgoCD): %s" , argoCDAppPath )
189+ if localAppPath != argoCDAppPath {
190+ configStage .Detail ("App path (local): %s" , localAppPath )
191+ }
147192 configStage .Detail ("Encryption: %s" , encryption )
148193 if kubeconfig != "" {
149194 configStage .Detail ("Kubeconfig: %s" , kubeconfig )
@@ -163,7 +208,6 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
163208 secretsTimer := startStage ("Loading Secrets" )
164209 secretsStage := logger .Stage ("Loading Secrets" )
165210 var envSecrets * config.EnvironmentSecrets
166- var err error
167211
168212 var secretsPath string
169213 switch encryption {
@@ -227,7 +271,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
227271 }
228272
229273 if dryRun {
230- bootstrapErr = printDryRun (envSecrets , env , appPath )
274+ bootstrapErr = printDryRun (envSecrets , env , argoCDAppPath )
231275 return bootstrapErr
232276 }
233277
@@ -348,7 +392,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
348392 appTimer := startStage ("Deploying App of Apps" )
349393 appStage := logger .Stage ("Deploying App of Apps" )
350394 stepf ("Applying App of Apps for environment: %s" , env )
351- _ , appCreated , err := client .ApplyAppOfApps (ctx , envSecrets .Repo .URL , envSecrets .Repo .TargetRevision , env , appPath , false )
395+ _ , appCreated , err := client .ApplyAppOfApps (ctx , envSecrets .Repo .URL , envSecrets .Repo .TargetRevision , env , argoCDAppPath , false )
352396 if err != nil {
353397 bootstrapErr = err
354398 report .AddStage (appTimer .complete (false , err ))
@@ -410,7 +454,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
410454 fmt .Println ()
411455 successf ("Done! ArgoCD is installed and the app-of-apps root Application has been created." )
412456 logger .PrintStageSummary ()
413- printBootstrapSummary (env , secretsPath )
457+ printBootstrapSummary (env , secretsPath , argoCDAppPath )
414458 fmt .Println (" Access the ArgoCD UI:" )
415459 fmt .Println (" kubectl port-forward svc/argocd-server -n argocd 8080:443" )
416460 fmt .Println (" Get the initial admin password:" )
@@ -516,35 +560,70 @@ func buildDryRunObjects(envSecrets *config.EnvironmentSecrets, env, appPath stri
516560 return repoSecret , appOfApps
517561}
518562
519- func validateBootstrapInputs (env string ) error {
563+ func validateBootstrapInputs (env string , argoCDAppPath string ) ( localPath string , err error ) {
520564 if env == "" {
521- return fmt .Errorf ("environment is required" )
565+ return "" , fmt .Errorf ("environment is required" )
522566 }
523567
524- baseInfo , err := os .Stat (baseDir )
525- if err != nil {
526- return fmt .Errorf ("base-dir %s is not accessible: %w" , baseDir , err )
568+ baseInfo , statErr := os .Stat (baseDir )
569+ if statErr != nil {
570+ return "" , fmt .Errorf ("base-dir %s is not accessible: %w" , baseDir , statErr )
527571 }
528572 if ! baseInfo .IsDir () {
529- return fmt .Errorf ("base-dir %s is not a directory" , baseDir )
573+ return "" , fmt .Errorf ("base-dir %s is not a directory" , baseDir )
530574 }
531575
532- if filepath .IsAbs (appPath ) {
533- return fmt .Errorf ("app-path must be relative to base-dir" )
576+ if filepath .IsAbs (argoCDAppPath ) {
577+ return "" , fmt .Errorf ("app-path must be relative" )
578+ }
579+
580+ // Determine the local path to validate
581+ // The argoCDAppPath is the full path from repository root (e.g., "k8s/apps")
582+ // We need to determine what part to validate locally based on baseDir or current directory
583+ localAppPath := argoCDAppPath
584+
585+ if baseDir == "." {
586+ // Check if we're in a Git subdirectory
587+ detected , relPath := detectGitSubdirectory ()
588+ if detected && relPath != "" && strings .HasPrefix (argoCDAppPath , relPath + "/" ) {
589+ // We're in a subdirectory and argoCDAppPath includes that prefix
590+ // Strip it for local validation
591+ // Example: In k8s/, argoCDAppPath="k8s/apps" -> localAppPath="apps"
592+ localAppPath = strings .TrimPrefix (argoCDAppPath , relPath + "/" )
593+ }
594+ } else if baseDir != "." {
595+ // When baseDir is set (e.g., "./k8s"), we need to strip the matching prefix from argoCDAppPath
596+ // Example: baseDir="./k8s", argoCDAppPath="k8s/apps" -> localAppPath="apps"
597+ cleanBase := filepath .Clean (baseDir )
598+ baseComponents := strings .Split (cleanBase , string (filepath .Separator ))
599+ pathComponents := strings .Split (argoCDAppPath , "/" )
600+
601+ // Find the last component of baseDir (e.g., "k8s" from "./k8s")
602+ baseLastComponent := baseComponents [len (baseComponents )- 1 ]
603+
604+ // If argoCDAppPath starts with the same component, strip it
605+ if len (pathComponents ) > 0 && pathComponents [0 ] == baseLastComponent {
606+ // Strip the first component for local validation
607+ localAppPath = strings .Join (pathComponents [1 :], "/" )
608+ if localAppPath == "" {
609+ localAppPath = "."
610+ }
611+ }
534612 }
535- appFullPath := filepath .Join (baseDir , appPath )
536- if _ , err := os .Stat (appFullPath ); err != nil {
537- if appPath == "apps" {
613+
614+ appFullPath := filepath .Join (baseDir , localAppPath )
615+ if _ , statErr := os .Stat (appFullPath ); statErr != nil {
616+ if argoCDAppPath == "apps" {
538617 detected , detectErr := autoDetectAppPath (baseDir )
539618 if detectErr != nil {
540- return fmt .Errorf ("app-path %s does not exist under base-dir : %w" , appPath , err )
619+ return "" , fmt .Errorf ("app-path %s does not exist: %w\n hint: use --app-path to specify the full path from repository root (e.g., 'k8s/apps') " , argoCDAppPath , statErr )
541620 }
542- appPath = detected
621+ localAppPath = detected
543622 if verbose {
544- fmt .Printf (" App path auto-detected: %s\n " , appPath )
623+ fmt .Printf (" App path auto-detected: %s\n " , localAppPath )
545624 }
546625 } else {
547- return fmt .Errorf ("app-path %s does not exist under base-dir: %w " , appPath , err )
626+ return "" , fmt .Errorf ("app-path %s does not exist: %w \n hint: verify the path exists and try using -- base-dir if working with subfolders " , argoCDAppPath , statErr )
548627 }
549628 }
550629
@@ -554,16 +633,16 @@ func validateBootstrapInputs(env string) error {
554633 switch encryption {
555634 case "sops" :
556635 if ! isEnc {
557- return fmt .Errorf ("secrets-file must end with .enc.yaml when encryption is sops" )
636+ return "" , fmt .Errorf ("secrets-file must end with .enc.yaml when encryption is sops" )
558637 }
559638 case "git-crypt" :
560639 if ! isYaml || isEnc {
561- return fmt .Errorf ("secrets-file must end with .yaml (not .enc.yaml) when encryption is git-crypt" )
640+ return "" , fmt .Errorf ("secrets-file must end with .yaml (not .enc.yaml) when encryption is git-crypt" )
562641 }
563642 }
564643 }
565644
566- return nil
645+ return localAppPath , nil
567646}
568647
569648func autoDetectAppPath (base string ) (string , error ) {
@@ -604,13 +683,13 @@ func autoDetectAppPath(base string) (string, error) {
604683 return candidates [0 ], nil
605684}
606685
607- func printBootstrapSummary (env , secretsPath string ) {
686+ func printBootstrapSummary (env , secretsPath , displayAppPath string ) {
608687 fmt .Println ("\n Summary:" )
609688 fmt .Printf (" Environment: %s\n " , env )
610689 if secretsPath != "" {
611690 fmt .Printf (" Secrets file: %s\n " , secretsPath )
612691 }
613- fmt .Printf (" App path: %s\n " , appPath )
692+ fmt .Printf (" App path: %s\n " , displayAppPath )
614693 fmt .Printf (" Encryption: %s\n " , encryption )
615694 if skipArgoCDInstall {
616695 fmt .Println (" ArgoCD install: skipped" )
@@ -631,3 +710,44 @@ func validateSecretsFileExists(path string) error {
631710 }
632711 return nil
633712}
713+
714+ // detectGitSubdirectory checks if we're running from a subdirectory of a Git repository
715+ // Returns (detected bool, relative path from repo root)
716+ func detectGitSubdirectory () (bool , string ) {
717+ cwd , err := os .Getwd ()
718+ if err != nil {
719+ return false , ""
720+ }
721+
722+ // Walk up the directory tree looking for .git
723+ dir := cwd
724+ for {
725+ gitPath := filepath .Join (dir , ".git" )
726+ if _ , err := os .Stat (gitPath ); err == nil {
727+ // Found .git directory - this is the repo root
728+ if dir == cwd {
729+ // We're at the repo root
730+ return false , ""
731+ }
732+
733+ // Calculate relative path from repo root to current directory
734+ relPath , err := filepath .Rel (dir , cwd )
735+ if err != nil {
736+ return false , ""
737+ }
738+
739+ // Normalize path separators to forward slashes (for consistency with Git paths)
740+ relPath = filepath .ToSlash (relPath )
741+
742+ return true , relPath
743+ }
744+
745+ // Move up one directory
746+ parent := filepath .Dir (dir )
747+ if parent == dir {
748+ // Reached filesystem root without finding .git
749+ return false , ""
750+ }
751+ dir = parent
752+ }
753+ }
0 commit comments