Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
89 changes: 89 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cmd

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

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

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)
}

// Get absolute path for the app directory
appPath, err := filepath.Abs(appName)
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", appName)
}

// Create the app directory
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", language, template))

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

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

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"}'
# Do this in a separate tab
kernel login # or: export KERNEL_API_KEY=<YOUR_API_KEY>
kernel logs ts-basic --follow
`, appName)

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

return nil
}
76 changes: 76 additions & 0 deletions cmd/create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cmd

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

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

func TestCreateCommand(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
errContains string
validate func(t *testing.T, appPath string)
}{
{
name: "create typescript sample-app",
args: []string{"--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 python sample-app (template not found)",
args: []string{"--name", "test-app", "--language", "python", "--template", "sample-app"},
wantErr: true,
errContains: "template not found: python/sample-app",
},
}

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)
})

createCmd.SetArgs(tt.args)
err = createCmd.Execute()

// 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, "test-app")
assert.DirExists(t, appPath, "app directory should be created")

if tt.validate != nil {
tt.validate(t, appPath)
}
})
}
}
5 changes: 4 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,8 @@ func init() {
rootCmd.AddCommand(profilesCmd)
rootCmd.AddCommand(proxies.ProxiesCmd)
rootCmd.AddCommand(extensionsCmd)
// Hide create command while WIP
// rootCmd.AddCommand(createCmd)

rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
// running synchronously so we never slow the command
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
atomicgo.dev/cursor v0.2.0 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/charmbracelet/colorprofile v0.3.0 // indirect
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
Expand All @@ -37,9 +38,13 @@ require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.8 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
Expand Down
15 changes: 15 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
Expand All @@ -19,6 +21,7 @@ github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi
github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
Expand All @@ -43,6 +46,7 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
Expand All @@ -60,10 +64,13 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ
github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
Expand All @@ -78,9 +85,15 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
Expand Down Expand Up @@ -161,6 +174,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand All @@ -183,6 +197,7 @@ golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
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