Skip to content
Draft
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
179 changes: 111 additions & 68 deletions cmd/apps/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"text/template"

Expand Down Expand Up @@ -256,20 +259,34 @@ func pluginHasResourceField(p *manifest.Plugin, resourceKey, fieldName string) b
return false
}

// tmplBundle holds the generated bundle configuration strings.
type tmplBundle struct {
Variables string
Resources string
TargetVariables string
}

// dotEnvVars holds the generated .env file content.
type dotEnvVars struct {
Content string
Example string
}

// pluginVar represents a selected plugin. Currently empty, but extensible
// with properties as the plugin model evolves.
type pluginVar struct{}

// templateVars holds the variables for template substitution.
type templateVars struct {
ProjectName string
AppDescription string
Profile string
WorkspaceHost string
PluginImports string
PluginUsages string
// Generated resource configuration from selected plugins.
BundleVariables string
BundleResources string
TargetVariables string
DotEnv string
DotEnvExample string
Bundle tmplBundle
DotEnv dotEnvVars
// Plugins maps plugin name to its metadata
// Missing keys return nil, enabling {{if .plugins.analytics}} conditionals.
Plugins map[string]*pluginVar
}

// parseDeployAndRunFlags parses the deploy and run flag values into typed values.
Expand Down Expand Up @@ -749,9 +766,6 @@ func runCreate(ctx context.Context, opts createOptions) error {
ResourceValues: resourceValues,
}

// Build plugin import/usage strings from selected plugins
pluginImport, pluginUsage := buildPluginStrings(selectedPlugins)

// Generate configurations from selected plugins
bundleVars := generator.GenerateBundleVariables(selectedPluginList, genConfig)
bundleRes := generator.GenerateBundleResources(selectedPluginList, genConfig)
Expand All @@ -761,19 +775,27 @@ func runCreate(ctx context.Context, opts createOptions) error {
log.Debugf(ctx, "Generated bundle resources:\n%s", bundleRes)
log.Debugf(ctx, "Generated target variables:\n%s", targetVars)

plugins := make(map[string]*pluginVar, len(selectedPlugins))
for _, name := range selectedPlugins {
plugins[name] = &pluginVar{}
}

// Template variables with generated content
vars := templateVars{
ProjectName: opts.name,
AppDescription: opts.description,
Profile: profile,
WorkspaceHost: workspaceHost,
PluginImports: pluginImport,
PluginUsages: pluginUsage,
BundleVariables: bundleVars,
BundleResources: bundleRes,
TargetVariables: targetVars,
DotEnv: generator.GenerateDotEnv(selectedPluginList, genConfig),
DotEnvExample: generator.GenerateDotEnvExample(selectedPluginList),
ProjectName: opts.name,
AppDescription: opts.description,
Profile: profile,
WorkspaceHost: workspaceHost,
Bundle: tmplBundle{
Variables: bundleVars,
Resources: bundleRes,
TargetVariables: targetVars,
},
DotEnv: dotEnvVars{
Content: generator.GenerateDotEnv(selectedPluginList, genConfig),
Example: generator.GenerateDotEnvExample(selectedPluginList),
},
Plugins: plugins,
}

// Copy template with variable substitution
Expand All @@ -794,14 +816,6 @@ func runCreate(ctx context.Context, opts createOptions) error {
absOutputDir = destDir
}

// Apply plugin-specific post-processing (e.g., remove config/queries if analytics not selected)
runErr = prompt.RunWithSpinnerCtx(ctx, "Configuring plugins...", func() error {
return applyPlugins(absOutputDir, selectedPlugins)
})
if runErr != nil {
return runErr
}

// Initialize project based on type (Node.js, Python, etc.)
var nextStepsCmd string
projectInitializer := initializer.GetProjectInitializer(absOutputDir)
Expand Down Expand Up @@ -958,34 +972,6 @@ func buildPluginStrings(pluginNames []string) (pluginImport, pluginUsage string)
return pluginImport, pluginUsage
}

// pluginOwnedPaths maps plugin names to directories they own.
// When a plugin is not selected, its owned paths are removed from the project.
var pluginOwnedPaths = map[string][]string{
"analytics": {"config/queries"},
}

// applyPlugins removes directories owned by unselected plugins.
func applyPlugins(projectDir string, pluginNames []string) error {
selectedSet := make(map[string]bool)
for _, name := range pluginNames {
selectedSet[name] = true
}

for plugin, paths := range pluginOwnedPaths {
if selectedSet[plugin] {
continue
}
for _, p := range paths {
target := filepath.Join(projectDir, p)
if err := os.RemoveAll(target); err != nil && !os.IsNotExist(err) {
return err
}
}
}

return nil
}

// renameFiles maps source file names to destination names (for files that can't use special chars).
var renameFiles = map[string]string{
"_gitignore": ".gitignore",
Expand Down Expand Up @@ -1121,6 +1107,15 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int
}
}

// Skip files whose template rendered to only whitespace.
// This enables conditional file creation: plugin-specific files wrap
// their entire content in {{if .plugins.<name>}}...{{end}}, rendering
// to empty when the plugin is not selected.
if len(bytes.TrimSpace(content)) == 0 {
log.Debugf(ctx, "Skipping conditionally empty file: %s", relPath)
return nil
}

// Create parent directory
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return err
Expand All @@ -1144,23 +1139,71 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int
}
log.Debugf(ctx, "Copied %d files", fileCount)

if err == nil {
err = removeEmptyDirs(dest)
}

return fileCount, err
}

// removeEmptyDirs removes empty directories under root, deepest-first.
// It is used to clean up directories that were created eagerly but ended up
// with no files after conditional template rendering skipped their contents.
func removeEmptyDirs(root string) error {
var dirs []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && path != root {
dirs = append(dirs, path)
}
return nil
})
if err != nil {
return err
}
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return nil
}

// templateData builds the data map for Go template execution.
func templateData(vars templateVars) map[string]string {
return map[string]string{
func templateData(vars templateVars) map[string]any {
// Sort plugin names for deterministic deprecated compat output.
pluginNames := slices.Sorted(maps.Keys(vars.Plugins))

// Only computed for deprecated backward compat keys.
pluginImports, pluginUsages := buildPluginStrings(pluginNames)

return map[string]any{
"profile": vars.Profile,
"plugins": vars.Plugins,
"projectName": vars.ProjectName,
"appDescription": vars.AppDescription,
"workspaceHost": vars.WorkspaceHost,
"bundle": map[string]any{
"variables": vars.Bundle.Variables,
"resources": vars.Bundle.Resources,
"targetVariables": vars.Bundle.TargetVariables,
},
"dotEnv": map[string]any{
"content": vars.DotEnv.Content,
"example": vars.DotEnv.Example,
},

// backward compatibility (deprecated)
"variables": vars.Bundle.Variables,
"resources": vars.Bundle.Resources,
"dotenv": vars.DotEnv.Content,
"target_variables": vars.Bundle.TargetVariables,
"project_name": vars.ProjectName,
"app_description": vars.AppDescription,
"profile": vars.Profile,
"dotenv_example": vars.DotEnv.Example,
"workspace_host": vars.WorkspaceHost,
"plugin_imports": vars.PluginImports,
"plugin_usages": vars.PluginUsages,
"variables": vars.BundleVariables,
"resources": vars.BundleResources,
"target_variables": vars.TargetVariables,
"dotenv": vars.DotEnv,
"dotenv_example": vars.DotEnvExample,
"plugin_imports": pluginImports,
"plugin_usages": pluginUsages,
}
}

Expand Down
Loading