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
104 changes: 104 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cmd

import (
"context"
"fmt"
"os"
"path/filepath"

"github.com/onkernel/cli/pkg/create"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)

type CreateInput struct {
Name string
Language string
Template string
}

// CreateCmd is a cobra-independent command handler for create operations
type CreateCmd struct{}

// Create executes the creating a new Kernel app logic
func (c CreateCmd) Create(ctx context.Context, ci CreateInput) error {
appPath, err := filepath.Abs(ci.Name)
if err != nil {
return fmt.Errorf("failed to resolve app path: %w", err)
}

// TODO: handle overwrite gracefully (prompt user)
// Check if directory already exists
if _, err := os.Stat(appPath); err == nil {
return fmt.Errorf("directory %s already exists", ci.Name)
}

if err := os.MkdirAll(appPath, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}

pterm.Println(fmt.Sprintf("\nCreating a new %s %s\n", ci.Language, ci.Template))

spinner, _ := pterm.DefaultSpinner.Start("Copying template files...")

if err := create.CopyTemplateFiles(appPath, ci.Language, ci.Template); err != nil {
spinner.Fail("Failed to copy template files")
return fmt.Errorf("failed to copy template files: %w", err)
}
spinner.Success(fmt.Sprintf("✔ %s environment set up successfully", ci.Language))

nextSteps := fmt.Sprintf(`Next steps:
brew install onkernel/tap/kernel
cd %s
kernel login # or: export KERNEL_API_KEY=<YOUR_API_KEY>
kernel deploy index.ts
kernel invoke ts-basic get-page-title --payload '{"url": "https://www.google.com"}'
`, ci.Name)

pterm.Success.Println("🎉 Kernel app created successfully!")
pterm.Println()
pterm.FgYellow.Println(nextSteps)

return nil
}

var createCmd = &cobra.Command{
Use: "create",
Short: "Create a new application",
Long: "Commands for creating new Kernel applications",
RunE: runCreateApp,
}

func init() {
createCmd.Flags().StringP("name", "n", "", "Name of the application")
createCmd.Flags().StringP("language", "l", "", "Language of the application")
createCmd.Flags().StringP("template", "t", "", "Template to use for the application")
}

func runCreateApp(cmd *cobra.Command, args []string) error {
appName, _ := cmd.Flags().GetString("name")
language, _ := cmd.Flags().GetString("language")
template, _ := cmd.Flags().GetString("template")

appName, err := create.PromptForAppName(appName)
if err != nil {
return fmt.Errorf("failed to get app name: %w", err)
}

language, err = create.PromptForLanguage(language)
if err != nil {
return fmt.Errorf("failed to get language: %w", err)
}

template, err = create.PromptForTemplate(template)
if err != nil {
return fmt.Errorf("failed to get template: %w", err)
}

c := CreateCmd{}
return c.Create(cmd.Context(), CreateInput{
Name: appName,
Language: language,
Template: template,
})
}
85 changes: 85 additions & 0 deletions cmd/create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cmd

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCreateCommand(t *testing.T) {
tests := []struct {
name string
input CreateInput
wantErr bool
errContains string
validate func(t *testing.T, appPath string)
}{
{
name: "create typescript sample-app",
input: CreateInput{
Name: "test-app",
Language: "typescript",
Template: "sample-app",
},
validate: func(t *testing.T, appPath string) {
// Verify files were created
assert.FileExists(t, filepath.Join(appPath, "index.ts"))
assert.FileExists(t, filepath.Join(appPath, "package.json"))
assert.FileExists(t, filepath.Join(appPath, ".gitignore"))
assert.NoFileExists(t, filepath.Join(appPath, "_gitignore"))
},
},
{
name: "fail with invalid template",
input: CreateInput{
Name: "test-app",
Language: "typescript",
Template: "nonexistent",
},
wantErr: true,
errContains: "template not found: typescript/nonexistent",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()

orgDir, err := os.Getwd()
require.NoError(t, err)

err = os.Chdir(tmpDir)
require.NoError(t, err)

t.Cleanup(func() {
os.Chdir(orgDir)
})

c := CreateCmd{}
err = c.Create(context.Background(), tt.input)

// Check if error is expected
if tt.wantErr {
require.Error(t, err, "expected command to fail but it succeeded")
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains, "error message should contain expected text")
}
return
}

require.NoError(t, err, "failed to execute create command")

// Validate the created app
appPath := filepath.Join(tmpDir, tt.input.Name)
assert.DirExists(t, appPath, "app directory should be created")

if tt.validate != nil {
tt.validate(t, appPath)
}
})
}
}
4 changes: 3 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ func isAuthExempt(cmd *cobra.Command) bool {
}
for c := cmd; c != nil; c = c.Parent() {
switch c.Name() {
case "login", "logout", "auth", "help", "completion":
case "login", "logout", "auth", "help", "completion",
"create":
return true
}
}
Expand Down Expand Up @@ -128,6 +129,7 @@ func init() {
rootCmd.AddCommand(profilesCmd)
rootCmd.AddCommand(proxies.ProxiesCmd)
rootCmd.AddCommand(extensionsCmd)
rootCmd.AddCommand(createCmd)

rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
// running synchronously so we never slow the command
Expand Down
84 changes: 84 additions & 0 deletions pkg/create/copy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package create

import (
"fmt"
"io/fs"
"os"
"path/filepath"

"github.com/onkernel/cli/pkg/templates"
)

const (
DIR_PERM = 0755 // rwxr-xr-x
FILE_PERM = 0644 // rw-r--r--
)

// CopyTemplateFiles copies all files and directories from the specified embedded template
// into the target application path. It uses the given language and template names
// to locate the template inside the embedded filesystem.
//
// - appPath: filesystem path where the files should be written (the project directory)
// - language: language subdirectory (e.g., "typescript")
// - template: template subdirectory (e.g., "sample-app")
//
// The function will recursively walk through the embedded template directory and
// replicate all files and folders in appPath. If a file named "_gitignore" is encountered,
// it is renamed to ".gitignore" in the output, to work around file embedding limitations.
//
// Returns an error if the template path is invalid, empty, or if any file operations fail.
func CopyTemplateFiles(appPath, language, template string) error {
// Build the template path within the embedded FS (e.g., "typescript/sample-app")
templatePath := filepath.Join(language, template)

// Check if the template exists and is non-empty
entries, err := fs.ReadDir(templates.FS, templatePath)
if err != nil {
return fmt.Errorf("template not found: %s/%s", language, template)
}
if len(entries) == 0 {
return fmt.Errorf("template directory is empty: %s/%s", language, template)
}

// Walk through the embedded template directory and copy contents
return fs.WalkDir(templates.FS, templatePath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

// Determine the path relative to the root of the template
relPath, err := filepath.Rel(templatePath, path)
if err != nil {
return err
}

// Skip the template root directory itself
if relPath == "." {
return nil
}

destPath := filepath.Join(appPath, relPath)

if d.IsDir() {
return os.MkdirAll(destPath, DIR_PERM)
}

// Read the file content from the embedded filesystem
content, err := fs.ReadFile(templates.FS, path)
if err != nil {
return fmt.Errorf("failed to read template file %s: %w", path, err)
}

// Rename _gitignore to .gitignore in the destination
if filepath.Base(destPath) == "_gitignore" {
destPath = filepath.Join(filepath.Dir(destPath), ".gitignore")
}

// Write the file to disk in the target project directory
if err := os.WriteFile(destPath, content, FILE_PERM); err != nil {
return fmt.Errorf("failed to write file %s: %w", destPath, err)
}

return nil
})
}
Loading