Skip to content
Merged
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@ 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
"$VOLCANO_CLI" config deploy
"$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

Expand Down
41 changes: 24 additions & 17 deletions internal/cmd/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand Down Expand Up @@ -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))
Expand All @@ -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
}
Expand Down
29 changes: 15 additions & 14 deletions internal/cmd/init/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -190,18 +191,18 @@ 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))

out, err := executeInitCommand(t, "--force")
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")
}

Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/root/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 3 additions & 4 deletions internal/projectinit/projectinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 14 additions & 29 deletions internal/projectinit/projectinit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
} {
Expand All @@ -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")
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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"))
}

Expand All @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion internal/projectinit/starters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the no-template path creates only env files, migrations, and a README, the generated internal/projectinit/starters/base/volcano/README.md is stale: it still says the directory contains configuration/functions and suggests volcano functions deploy --all / cloud function deploy even though bare init creates no functions. Can we update that generated README guidance to match the new base-only scaffold?


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
Expand Down
Loading