Skip to content

Commit 0b56ae5

Browse files
authored
Create kernel app port (#43)
## Description - Phase 1 of migrating the `npx @onkernel/create-kernel-app` to the `cli` - Added one ts sample-app template to test - Current functions: - prompts user to create new app (no auth required) - allows flags for name, language, template - validators for name and language - copies all the files from template into newly created directory - Next additions: - add all the templates (including python) - validation for templates - install dependencies ## Testing - uncomment line `133` in `root.go` - run `make build && ./bin/kernel create` - should walk through the prompts and create a new app with sample-app template in ts - should give this output: <img width="1195" height="547" alt="Screenshot 2025-12-05 at 10 48 13 AM" src="https://github.com/user-attachments/assets/bb1d8613-5173-43c3-8661-7145ed615499" />
1 parent abeaaf4 commit 0b56ae5

File tree

15 files changed

+832
-1
lines changed

15 files changed

+832
-1
lines changed

cmd/create.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/onkernel/cli/pkg/create"
10+
"github.com/pterm/pterm"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type CreateInput struct {
15+
Name string
16+
Language string
17+
Template string
18+
}
19+
20+
// CreateCmd is a cobra-independent command handler for create operations
21+
type CreateCmd struct{}
22+
23+
// Create executes the creating a new Kernel app logic
24+
func (c CreateCmd) Create(ctx context.Context, ci CreateInput) error {
25+
appPath, err := filepath.Abs(ci.Name)
26+
if err != nil {
27+
return fmt.Errorf("failed to resolve app path: %w", err)
28+
}
29+
30+
// TODO: handle overwrite gracefully (prompt user)
31+
// Check if directory already exists
32+
if _, err := os.Stat(appPath); err == nil {
33+
return fmt.Errorf("directory %s already exists", ci.Name)
34+
}
35+
36+
if err := os.MkdirAll(appPath, 0755); err != nil {
37+
return fmt.Errorf("failed to create directory: %w", err)
38+
}
39+
40+
pterm.Println(fmt.Sprintf("\nCreating a new %s %s\n", ci.Language, ci.Template))
41+
42+
spinner, _ := pterm.DefaultSpinner.Start("Copying template files...")
43+
44+
if err := create.CopyTemplateFiles(appPath, ci.Language, ci.Template); err != nil {
45+
spinner.Fail("Failed to copy template files")
46+
return fmt.Errorf("failed to copy template files: %w", err)
47+
}
48+
spinner.Success(fmt.Sprintf("✔ %s environment set up successfully", ci.Language))
49+
50+
nextSteps := fmt.Sprintf(`Next steps:
51+
brew install onkernel/tap/kernel
52+
cd %s
53+
kernel login # or: export KERNEL_API_KEY=<YOUR_API_KEY>
54+
kernel deploy index.ts
55+
kernel invoke ts-basic get-page-title --payload '{"url": "https://www.google.com"}'
56+
`, ci.Name)
57+
58+
pterm.Success.Println("🎉 Kernel app created successfully!")
59+
pterm.Println()
60+
pterm.FgYellow.Println(nextSteps)
61+
62+
return nil
63+
}
64+
65+
var createCmd = &cobra.Command{
66+
Use: "create",
67+
Short: "Create a new application",
68+
Long: "Commands for creating new Kernel applications",
69+
RunE: runCreateApp,
70+
}
71+
72+
func init() {
73+
createCmd.Flags().StringP("name", "n", "", "Name of the application")
74+
createCmd.Flags().StringP("language", "l", "", "Language of the application")
75+
createCmd.Flags().StringP("template", "t", "", "Template to use for the application")
76+
}
77+
78+
func runCreateApp(cmd *cobra.Command, args []string) error {
79+
appName, _ := cmd.Flags().GetString("name")
80+
language, _ := cmd.Flags().GetString("language")
81+
template, _ := cmd.Flags().GetString("template")
82+
83+
appName, err := create.PromptForAppName(appName)
84+
if err != nil {
85+
return fmt.Errorf("failed to get app name: %w", err)
86+
}
87+
88+
language, err = create.PromptForLanguage(language)
89+
if err != nil {
90+
return fmt.Errorf("failed to get language: %w", err)
91+
}
92+
93+
template, err = create.PromptForTemplate(template)
94+
if err != nil {
95+
return fmt.Errorf("failed to get template: %w", err)
96+
}
97+
98+
c := CreateCmd{}
99+
return c.Create(cmd.Context(), CreateInput{
100+
Name: appName,
101+
Language: language,
102+
Template: template,
103+
})
104+
}

cmd/create_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestCreateCommand(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
input CreateInput
17+
wantErr bool
18+
errContains string
19+
validate func(t *testing.T, appPath string)
20+
}{
21+
{
22+
name: "create typescript sample-app",
23+
input: CreateInput{
24+
Name: "test-app",
25+
Language: "typescript",
26+
Template: "sample-app",
27+
},
28+
validate: func(t *testing.T, appPath string) {
29+
// Verify files were created
30+
assert.FileExists(t, filepath.Join(appPath, "index.ts"))
31+
assert.FileExists(t, filepath.Join(appPath, "package.json"))
32+
assert.FileExists(t, filepath.Join(appPath, ".gitignore"))
33+
assert.NoFileExists(t, filepath.Join(appPath, "_gitignore"))
34+
},
35+
},
36+
{
37+
name: "fail with invalid template",
38+
input: CreateInput{
39+
Name: "test-app",
40+
Language: "typescript",
41+
Template: "nonexistent",
42+
},
43+
wantErr: true,
44+
errContains: "template not found: typescript/nonexistent",
45+
},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
tmpDir := t.TempDir()
51+
52+
orgDir, err := os.Getwd()
53+
require.NoError(t, err)
54+
55+
err = os.Chdir(tmpDir)
56+
require.NoError(t, err)
57+
58+
t.Cleanup(func() {
59+
os.Chdir(orgDir)
60+
})
61+
62+
c := CreateCmd{}
63+
err = c.Create(context.Background(), tt.input)
64+
65+
// Check if error is expected
66+
if tt.wantErr {
67+
require.Error(t, err, "expected command to fail but it succeeded")
68+
if tt.errContains != "" {
69+
assert.Contains(t, err.Error(), tt.errContains, "error message should contain expected text")
70+
}
71+
return
72+
}
73+
74+
require.NoError(t, err, "failed to execute create command")
75+
76+
// Validate the created app
77+
appPath := filepath.Join(tmpDir, tt.input.Name)
78+
assert.DirExists(t, appPath, "app directory should be created")
79+
80+
if tt.validate != nil {
81+
tt.validate(t, appPath)
82+
}
83+
})
84+
}
85+
}

cmd/root.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ func isAuthExempt(cmd *cobra.Command) bool {
7979
}
8080
for c := cmd; c != nil; c = c.Parent() {
8181
switch c.Name() {
82-
case "login", "logout", "auth", "help", "completion":
82+
case "login", "logout", "auth", "help", "completion",
83+
"create":
8384
return true
8485
}
8586
}
@@ -128,6 +129,7 @@ func init() {
128129
rootCmd.AddCommand(profilesCmd)
129130
rootCmd.AddCommand(proxies.ProxiesCmd)
130131
rootCmd.AddCommand(extensionsCmd)
132+
rootCmd.AddCommand(createCmd)
131133

132134
rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
133135
// running synchronously so we never slow the command

pkg/create/copy.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package create
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/onkernel/cli/pkg/templates"
10+
)
11+
12+
const (
13+
DIR_PERM = 0755 // rwxr-xr-x
14+
FILE_PERM = 0644 // rw-r--r--
15+
)
16+
17+
// CopyTemplateFiles copies all files and directories from the specified embedded template
18+
// into the target application path. It uses the given language and template names
19+
// to locate the template inside the embedded filesystem.
20+
//
21+
// - appPath: filesystem path where the files should be written (the project directory)
22+
// - language: language subdirectory (e.g., "typescript")
23+
// - template: template subdirectory (e.g., "sample-app")
24+
//
25+
// The function will recursively walk through the embedded template directory and
26+
// replicate all files and folders in appPath. If a file named "_gitignore" is encountered,
27+
// it is renamed to ".gitignore" in the output, to work around file embedding limitations.
28+
//
29+
// Returns an error if the template path is invalid, empty, or if any file operations fail.
30+
func CopyTemplateFiles(appPath, language, template string) error {
31+
// Build the template path within the embedded FS (e.g., "typescript/sample-app")
32+
templatePath := filepath.Join(language, template)
33+
34+
// Check if the template exists and is non-empty
35+
entries, err := fs.ReadDir(templates.FS, templatePath)
36+
if err != nil {
37+
return fmt.Errorf("template not found: %s/%s", language, template)
38+
}
39+
if len(entries) == 0 {
40+
return fmt.Errorf("template directory is empty: %s/%s", language, template)
41+
}
42+
43+
// Walk through the embedded template directory and copy contents
44+
return fs.WalkDir(templates.FS, templatePath, func(path string, d fs.DirEntry, err error) error {
45+
if err != nil {
46+
return err
47+
}
48+
49+
// Determine the path relative to the root of the template
50+
relPath, err := filepath.Rel(templatePath, path)
51+
if err != nil {
52+
return err
53+
}
54+
55+
// Skip the template root directory itself
56+
if relPath == "." {
57+
return nil
58+
}
59+
60+
destPath := filepath.Join(appPath, relPath)
61+
62+
if d.IsDir() {
63+
return os.MkdirAll(destPath, DIR_PERM)
64+
}
65+
66+
// Read the file content from the embedded filesystem
67+
content, err := fs.ReadFile(templates.FS, path)
68+
if err != nil {
69+
return fmt.Errorf("failed to read template file %s: %w", path, err)
70+
}
71+
72+
// Rename _gitignore to .gitignore in the destination
73+
if filepath.Base(destPath) == "_gitignore" {
74+
destPath = filepath.Join(filepath.Dir(destPath), ".gitignore")
75+
}
76+
77+
// Write the file to disk in the target project directory
78+
if err := os.WriteFile(destPath, content, FILE_PERM); err != nil {
79+
return fmt.Errorf("failed to write file %s: %w", destPath, err)
80+
}
81+
82+
return nil
83+
})
84+
}

0 commit comments

Comments
 (0)