diff --git a/README.md b/README.md index 80e0e66..6d63b7e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ From the CLI checkout, create an empty sibling project directory and run it: VOLCANO_CLI="$(pwd)/volcano" mkdir ../volcano-quickstart cd ../volcano-quickstart -"$VOLCANO_CLI" init +"$VOLCANO_CLI" init javascript "$VOLCANO_CLI" start "$VOLCANO_CLI" variables deploy "$VOLCANO_CLI" functions deploy --all @@ -35,8 +35,10 @@ cd ../volcano-quickstart "$VOLCANO_CLI" migrations deploy --all -d app ``` -`volcano init` also supports starter templates such as `nextjs`, `js`, -`python`, and `ruby`. +`volcano init` without a template creates a base scaffold (environment +files, migrations directory, and README). Use a template to add +language-specific files: `javascript` (aliases: `js`, `node`, `nodejs`), +`nextjs`, `python`, or `ruby`. ## Contributing diff --git a/internal/cmd/init/init.go b/internal/cmd/init/init.go index 0b8fb58..ed9677f 100644 --- a/internal/cmd/init/init.go +++ b/internal/cmd/init/init.go @@ -29,25 +29,32 @@ func New() *cobra.Command { Short: "Create local Volcano project scaffold", Long: `Create Volcano project scaffold files in the current directory. -The scaffold adds a volcano/ directory with local variables, declarative -configuration, migrations, and deployable starter files. Existing generated -files are left unchanged when their contents still match the selected template. +The base scaffold creates a volcano/ directory with environment files, +a migrations directory, and a README. The selected template adds +language-specific function files (and a volcano-config.yaml for JavaScript). +Existing files are left unchanged when their contents still match the +selected template. Templates: - volcano init Create base JavaScript hello-world scaffold - volcano init nextjs Create base scaffold plus a minimal Next.js app - volcano init javascript Create base JavaScript hello-world scaffold - volcano init js Alias for javascript - volcano init python Create base scaffold plus Python function setup - volcano init ruby Create base scaffold plus Ruby function setup - -Add --example to create an example project for the selected template, for example: - volcano init nextjs --example notes - volcano init js --example hello-world + volcano init Create base scaffold only (no language template) + volcano init nextjs Base scaffold plus a minimal Next.js app + (aliases: next, next.js, next-js) + volcano init javascript Base scaffold plus JavaScript function and config + (aliases: js, node, nodejs) + volcano init python Base scaffold plus Python function setup + (aliases: py) + volcano init ruby Base scaffold plus Ruby function setup + (aliases: rb) + +Add --example to create an example project for the selected template: + volcano init nextjs --example notes Notes app with dashboard and migration + volcano init javascript --example hello-world + volcano init python --example hello-world + volcano init ruby --example hello-world By default, changed managed files are treated as conflicts so init cannot -accidentally overwrite local work. Use --force to overwrite managed scaffold -files with the current templates.`, +accidentally overwrite local work. Use --force to overwrite changed managed +scaffold files with the current templates.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { starter := "" @@ -75,7 +82,7 @@ func run(opts options) error { result, err := projectinit.RunStarter(starter, opts.force) if err != nil { if strings.Contains(err.Error(), "unknown starter") { - return fmt.Errorf("unknown template %q (supported: nextjs, javascript, js, python, ruby)", opts.starter) + return fmt.Errorf("unknown template %q (supported: nextjs, javascript, python, ruby)", opts.starter) } if conflicts, ok := projectinit.ConflictMessages(err); ok { printConflicts(opts.out, conflicts, projectinit.ConflictsCanBeForced(err)) @@ -92,7 +99,7 @@ func resolveStarter(raw, example string) (string, error) { example = normalizeStarterName(example) if value == "" { if example != "" { - return "", errors.New("--example requires a template (supported: nextjs, javascript, js, python, ruby)") + return "", errors.New("--example requires a template (supported: nextjs, javascript, python, ruby)") } return "", nil } diff --git a/internal/cmd/init/init_test.go b/internal/cmd/init/init_test.go index 8e29035..93a233d 100644 --- a/internal/cmd/init/init_test.go +++ b/internal/cmd/init/init_test.go @@ -19,7 +19,7 @@ func TestHelpOutput(t *testing.T) { assert.Contains(t, out, "--example") assert.Contains(t, out, "volcano init nextjs") assert.Contains(t, out, "volcano init nextjs --example notes") - assert.Contains(t, out, "volcano init js --example hello-world") + assert.Contains(t, out, "volcano init javascript --example hello-world") } func TestRejectsUnknownTemplate(t *testing.T) { @@ -42,12 +42,13 @@ func TestCreatesScaffoldAndPrintsCreatedFiles(t *testing.T) { assert.Contains(t, out, "Volcano project initialized.") assert.Contains(t, out, "Created:") - assert.Contains(t, out, "volcano/functions/hello.js") - assert.Contains(t, out, "volcano config deploy") + assert.Contains(t, out, "volcano/README.md") assert.Contains(t, out, "volcano functions deploy --all") - assert.Contains(t, out, "volcano cloud config deploy") - assert.Contains(t, out, "volcano cloud functions deploy --all") - assert.FileExists(t, filepath.Join("volcano", "functions", "hello.js")) + assert.NotContains(t, out, "volcano config deploy") + assert.NotContains(t, out, "volcano cloud config deploy") + assert.FileExists(t, filepath.Join("volcano", "README.md")) + assert.NoFileExists(t, filepath.Join("volcano", "functions", "hello.js")) + assert.NoFileExists(t, filepath.Join("volcano", "volcano-config.yaml")) } func TestCreatesNextJSStarter(t *testing.T) { @@ -157,23 +158,23 @@ func TestRerunPrintsUnchangedFiles(t *testing.T) { require.NoError(t, err) assert.Contains(t, out, "Unchanged:") - assert.Contains(t, out, "volcano/functions/hello.js") + assert.Contains(t, out, "volcano/README.md") assert.NotContains(t, out, "Created:") } func TestConflictPrintsConflictingFilesWithoutPartialWrites(t *testing.T) { t.Chdir(t.TempDir()) - require.NoError(t, os.MkdirAll(filepath.Join("volcano", "functions"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join("volcano", "functions", "hello.js"), []byte("custom\n"), 0o644)) + require.NoError(t, os.MkdirAll("volcano", 0o755)) + require.NoError(t, os.WriteFile(filepath.Join("volcano", "README.md"), []byte("custom\n"), 0o644)) out, err := executeInitCommand(t) require.Error(t, err) assert.Contains(t, out, "Conflicts:") - assert.Contains(t, out, "volcano/functions/hello.js") + assert.Contains(t, out, "volcano/README.md") assert.Contains(t, out, "has different content") assert.Contains(t, out, "Re-run with --force") - assert.NoFileExists(t, filepath.Join("volcano", "README.md")) + assert.NoFileExists(t, filepath.Join("volcano", "volcano.env")) } func TestPathTypeConflictDoesNotSuggestForce(t *testing.T) { @@ -190,7 +191,7 @@ func TestPathTypeConflictDoesNotSuggestForce(t *testing.T) { func TestForceOverwritesChangedManagedFile(t *testing.T) { t.Chdir(t.TempDir()) - path := filepath.Join("volcano", "functions", "hello.js") + path := filepath.Join("volcano", "README.md") require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, os.WriteFile(path, []byte("custom\n"), 0o644)) @@ -198,10 +199,10 @@ func TestForceOverwritesChangedManagedFile(t *testing.T) { require.NoError(t, err) assert.Contains(t, out, "Overwritten:") - assert.Contains(t, out, "volcano/functions/hello.js") + assert.Contains(t, out, "volcano/README.md") body, err := os.ReadFile(path) require.NoError(t, err) - assert.Contains(t, string(body), "process.env.GREETING") + assert.Contains(t, string(body), "Volcano") assert.NotContains(t, string(body), "custom") } diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index 8cfd20b..2cbdf31 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -52,7 +52,8 @@ func TestInitCommandPath(t *testing.T) { out, err := executeRootCommand(t, "init") require.NoError(t, err) assert.Contains(t, out, "Volcano project initialized.") - assert.FileExists(t, filepath.Join("volcano", "functions", "hello.js")) + assert.FileExists(t, filepath.Join("volcano", "README.md")) + assert.NoFileExists(t, filepath.Join("volcano", "functions", "hello.js")) } func TestDatabasesHelpIncludesMigration(t *testing.T) { diff --git a/internal/projectinit/projectinit.go b/internal/projectinit/projectinit.go index 180aa5f..9b22dc5 100644 --- a/internal/projectinit/projectinit.go +++ b/internal/projectinit/projectinit.go @@ -18,9 +18,8 @@ const ( fileMode fs.FileMode = 0o644 dirMode fs.FileMode = 0o755 - baseStarter = "base" - defaultStarter = "javascript" - startersRoot = "starters" + baseStarter = "base" + startersRoot = "starters" nestedEnvPath = "volcano/volcano.env" rootEnvPath = "volcano.env" @@ -143,7 +142,7 @@ func buildPlan(rootDir, starterName string, force bool) (*plan, error) { starterNames := []string{baseStarter} starterName = strings.TrimSpace(starterName) if starterName == "" { - starterName = defaultStarter + starterName = baseStarter } if !validStarterName(starterName) { return nil, fmt.Errorf("invalid starter name %q", starterName) diff --git a/internal/projectinit/projectinit_test.go b/internal/projectinit/projectinit_test.go index 0d3605d..2b45dd8 100644 --- a/internal/projectinit/projectinit_test.go +++ b/internal/projectinit/projectinit_test.go @@ -17,13 +17,10 @@ func TestRunCreatesScaffold(t *testing.T) { for _, path := range []string{ "volcano", - filepath.Join("volcano", "functions"), filepath.Join("volcano", "migrations"), filepath.Join("volcano", ".gitignore"), filepath.Join("volcano", "volcano.env"), filepath.Join("volcano", "volcano.env.example"), - filepath.Join("volcano", "volcano-config.yaml"), - filepath.Join("volcano", "functions", "hello.js"), filepath.Join("volcano", "migrations", "README.md"), filepath.Join("volcano", "README.md"), } { @@ -34,15 +31,8 @@ func TestRunCreatesScaffold(t *testing.T) { assert.Empty(t, result.Unchanged()) assert.Empty(t, result.Overwritten()) assert.NoFileExists(t, filepath.Join(dir, "volcano.env")) - - config := readProjectFile(t, dir, filepath.Join("volcano", "volcano-config.yaml")) - assert.Contains(t, config, "version: 1") - assert.Contains(t, config, "name: hello") - assert.Contains(t, config, "public: true") - - fn := readProjectFile(t, dir, filepath.Join("volcano", "functions", "hello.js")) - assert.Contains(t, fn, "exports.handler") - assert.Contains(t, fn, "process.env.GREETING") + assert.NoFileExists(t, filepath.Join(dir, "volcano", "functions", "hello.js")) + assert.NoFileExists(t, filepath.Join(dir, "volcano", "volcano-config.yaml")) ignore := readProjectFile(t, dir, filepath.Join("volcano", ".gitignore")) assert.Contains(t, ignore, "volcano.env") @@ -142,7 +132,7 @@ func TestRunIsIdempotentForExactScaffold(t *testing.T) { assert.Empty(t, result.Created()) assert.Empty(t, result.Overwritten()) assert.Contains(t, result.Unchanged(), filepath.Join("volcano", "volcano.env")) - assert.Contains(t, result.Unchanged(), filepath.Join("volcano", "functions", "hello.js")) + assert.Contains(t, result.Unchanged(), filepath.Join("volcano", "README.md")) } func TestRunTemplateRerunDoesNotDuplicateUnchangedDirs(t *testing.T) { @@ -159,33 +149,32 @@ func TestRunTemplateRerunDoesNotDuplicateUnchangedDirs(t *testing.T) { func TestRunConflictsDoNotWritePartialScaffold(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "volcano", "functions"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano", "functions", "hello.js"), []byte("custom\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "volcano"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano", "README.md"), []byte("custom\n"), 0o644)) result, err := run(dir, "", false) require.Nil(t, result) var conflictErr *conflictError require.ErrorAs(t, err, &conflictErr) require.Len(t, conflictErr.conflicts, 1) - assert.Equal(t, filepath.Join("volcano", "functions", "hello.js"), conflictErr.conflicts[0].Path) + assert.Equal(t, filepath.Join("volcano", "README.md"), conflictErr.conflicts[0].Path) assert.Contains(t, conflictErr.conflicts[0].Reason, "different content") - assert.NoFileExists(t, filepath.Join(dir, "volcano", "README.md")) assert.NoFileExists(t, filepath.Join(dir, "volcano", "volcano.env")) - assert.Equal(t, "custom\n", readProjectFile(t, dir, filepath.Join("volcano", "functions", "hello.js"))) + assert.Equal(t, "custom\n", readProjectFile(t, dir, filepath.Join("volcano", "README.md"))) } func TestRunForceOverwritesChangedManagedFile(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "volcano", "functions"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano", "functions", "hello.js"), []byte("custom\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "volcano"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano", "README.md"), []byte("custom\n"), 0o644)) result, err := run(dir, "", true) require.NoError(t, err) - assert.Contains(t, result.Overwritten(), filepath.Join("volcano", "functions", "hello.js")) - assert.NotContains(t, readProjectFile(t, dir, filepath.Join("volcano", "functions", "hello.js")), "custom") - assert.Contains(t, readProjectFile(t, dir, filepath.Join("volcano", "functions", "hello.js")), "process.env.GREETING") + assert.Contains(t, result.Overwritten(), filepath.Join("volcano", "README.md")) + assert.NotContains(t, readProjectFile(t, dir, filepath.Join("volcano", "README.md")), "custom") + assert.Contains(t, readProjectFile(t, dir, filepath.Join("volcano", "README.md")), "Volcano") } func TestRunRejectsFileWhereDirectoryIsNeeded(t *testing.T) { @@ -201,19 +190,15 @@ func TestRunRejectsFileWhereDirectoryIsNeeded(t *testing.T) { assert.Contains(t, conflictErr.conflicts[0].Reason, "not a directory") } -func TestRunRespectsLegacyRootEnvAndConfig(t *testing.T) { +func TestRunRespectsLegacyRootEnv(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano.env"), []byte("ROOT_ENV=true\n"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\nfunctions:\n - name: custom\n public: false\n"), 0o644)) result, err := run(dir, "", false) require.NoError(t, err) assert.Contains(t, result.Unchanged(), "volcano.env") - assert.Contains(t, result.Unchanged(), "volcano-config.yaml") assert.NoFileExists(t, filepath.Join(dir, "volcano", "volcano.env")) - assert.NoFileExists(t, filepath.Join(dir, "volcano", "volcano-config.yaml")) - assertPathExists(t, filepath.Join(dir, "volcano", "functions", "hello.js")) assertPathExists(t, filepath.Join(dir, "volcano", "volcano.env.example")) } @@ -230,7 +215,7 @@ func TestRunRejectsAmbiguousRootAndNestedEnv(t *testing.T) { assert.NotErrorAs(t, err, &conflictErr) assert.Contains(t, err.Error(), "found multiple volcano.env files: volcano/volcano.env, volcano.env") assert.Contains(t, err.Error(), "please keep only one volcano.env file") - assert.NoFileExists(t, filepath.Join(dir, "volcano", "functions", "hello.js")) + assert.NoFileExists(t, filepath.Join(dir, "volcano", "README.md")) } func readProjectFile(t *testing.T, root, path string) string { diff --git a/internal/projectinit/starters/README.md b/internal/projectinit/starters/README.md index cbd517c..c6d2340 100644 --- a/internal/projectinit/starters/README.md +++ b/internal/projectinit/starters/README.md @@ -7,12 +7,14 @@ with managed-file conflict handling. ## Resolution Model Every init run applies the `base` starter first. If no template is provided, -`projectinit` then applies the default starter, currently `javascript`. +only the `base` starter is applied (environment files, migrations directory, +and README — no language-specific files). When a template is provided, the CLI resolves it to a starter directory name and passes that concrete name to `projectinit`: ```text +volcano init -> base only volcano init nextjs -> base + nextjs volcano init js -> base + javascript volcano init python -> base + python