From da851aa8597170cdf12fbfba3115574c7c858577 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Sat, 13 Jun 2026 14:19:58 -0400 Subject: [PATCH 1/3] fix(cli): make bare volcano init produce base-only scaffold and update help text Bare "volcano init" now creates only the base scaffold (volcano/ directory with env files, migrations dir, and README) instead of defaulting to the JavaScript template. Help text updated to accurately describe each template, list all accepted aliases, and document all available --example options. --- internal/cmd/init/init.go | 37 +++++++++++--------- internal/cmd/init/init_test.go | 29 ++++++++-------- internal/cmd/root/root_test.go | 3 +- internal/projectinit/projectinit.go | 7 ++-- internal/projectinit/projectinit_test.go | 43 ++++++++---------------- 5 files changed, 56 insertions(+), 63 deletions(-) diff --git a/internal/cmd/init/init.go b/internal/cmd/init/init.go index 0b8fb58..ef3bdac 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 := "" 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 { From 236e42b6a22871191d805644536b2104e5ccb746 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Sat, 13 Jun 2026 14:58:15 -0400 Subject: [PATCH 2/3] docs: update quickstart and starter docs for base-only bare init --- README.md | 8 +++++--- internal/projectinit/starters/README.md | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) 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/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 From 0fea5aec795d65b6bc0e0661c94d8e1760c40975 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Sun, 14 Jun 2026 22:05:11 -0400 Subject: [PATCH 3/3] fix(cli): list only canonical template names in init errors --- internal/cmd/init/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/init/init.go b/internal/cmd/init/init.go index ef3bdac..ed9677f 100644 --- a/internal/cmd/init/init.go +++ b/internal/cmd/init/init.go @@ -82,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)) @@ -99,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 }