From d3d84a8277200534a8a3c312e86aef43fc20157a Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 2 Dec 2025 18:45:46 +0000 Subject: [PATCH 01/14] feat: initial setup for "create" cmd in cli --- cmd/create.go | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 4 +- go.mod | 5 +++ go.sum | 15 +++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 cmd/create.go diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..b7ff8b9 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + "regexp" + + "github.com/AlecAivazis/survey/v2" + "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().String("name", "", "Name of the application") + createCmd.Flags().String("language", "", "Language of the application") + createCmd.Flags().String("template", "", "Template to use for the application") +} + +const defaultAppName = "my-kernel-app" + +// projectNameValidator ensures the project name is safe for file systems and package managers. +func projectNameValidator(val any) error { + str, ok := val.(string) + if !ok { + return fmt.Errorf("invalid input type") + } + + // Project name must be non-empty + if len(str) == 0 { + return fmt.Errorf("project name cannot be empty") + } + + // Validate project name: only letters, numbers, underscores, and hyphens + // This regex prevents special characters that might break shell commands or filesystem paths. + matched, err := regexp.MatchString(`^[A-Za-z\-_\d]+$`, str) + if err != nil { + return err + } + if !matched { + return fmt.Errorf("project name may only include letters, numbers, underscores, and hyphens") + } + return nil +} + +// promptForAppName prompts the user for the application name if not provided +func promptForAppName(providedAppName string) (string, error) { + if providedAppName != "" { + return providedAppName, nil + } + + var appName string + prompt := &survey.Input{ + Message: "What is the name of your project?", + Default: defaultAppName, + } + + err := survey.AskOne(prompt, &appName, survey.WithValidator(projectNameValidator)) + if err != nil { + return "", err + } + + return appName, nil +} + +func runCreateApp(cmd *cobra.Command, args []string) error { + providedAppName, _ := cmd.Flags().GetString("name") + language, _ := cmd.Flags().GetString("language") + template, _ := cmd.Flags().GetString("template") + + // Prompt for app name if not provided + appName, err := promptForAppName(providedAppName) + if err != nil { + return fmt.Errorf("failed to get app name: %w", err) + } + + fmt.Printf("Creating application '%s' with language '%s' and template '%s'...\n", appName, language, template) + + // TODO: prompt the user for the language of the app, suggest a default language (typescript) + // TODO: prompt the user for the template of the app, suggest a default template (sample-app) + + // TODO: create the project structure + + // print "Creating a new TypeScript Sample App" or similar. Essentially the language and template name combined. + + /* + Print the following: + ✔ TypeScript environment set up successfully + + 🎉 Kernel app created successfully! + + Next steps: + brew install onkernel/tap/kernel + cd my-kernel-app + kernel login # or: export KERNEL_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= + kernel logs ts-basic --follow + */ + + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 06cce7c..61f0fc5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 } } @@ -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 diff --git a/go.mod b/go.mod index b17f5cc..1efaf1d 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index f5b74b9..c0d9b2e 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= From cb1dde578113a58a9d0725cfd5faf3b04193f596 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 3 Dec 2025 18:49:27 +0000 Subject: [PATCH 02/14] feat: add ts sample-app template --- pkg/templates/templates.go | 6 + pkg/templates/typescript/sample-app/README.md | 5 + .../typescript/sample-app/_gitignore | 39 +++++++ pkg/templates/typescript/sample-app/index.ts | 108 ++++++++++++++++++ .../typescript/sample-app/package-lock.json | 81 +++++++++++++ .../typescript/sample-app/package.json | 13 +++ .../typescript/sample-app/pnpm-lock.yaml | 61 ++++++++++ .../typescript/sample-app/tsconfig.json | 31 +++++ typescript/sample-app/README.md | 5 + typescript/sample-app/_gitignore | 39 +++++++ typescript/sample-app/index.ts | 108 ++++++++++++++++++ typescript/sample-app/package-lock.json | 81 +++++++++++++ typescript/sample-app/package.json | 13 +++ typescript/sample-app/pnpm-lock.yaml | 61 ++++++++++ typescript/sample-app/tsconfig.json | 31 +++++ 15 files changed, 682 insertions(+) create mode 100644 pkg/templates/templates.go create mode 100644 pkg/templates/typescript/sample-app/README.md create mode 100644 pkg/templates/typescript/sample-app/_gitignore create mode 100644 pkg/templates/typescript/sample-app/index.ts create mode 100644 pkg/templates/typescript/sample-app/package-lock.json create mode 100644 pkg/templates/typescript/sample-app/package.json create mode 100644 pkg/templates/typescript/sample-app/pnpm-lock.yaml create mode 100644 pkg/templates/typescript/sample-app/tsconfig.json create mode 100644 typescript/sample-app/README.md create mode 100644 typescript/sample-app/_gitignore create mode 100644 typescript/sample-app/index.ts create mode 100644 typescript/sample-app/package-lock.json create mode 100644 typescript/sample-app/package.json create mode 100644 typescript/sample-app/pnpm-lock.yaml create mode 100644 typescript/sample-app/tsconfig.json diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go new file mode 100644 index 0000000..c487ddb --- /dev/null +++ b/pkg/templates/templates.go @@ -0,0 +1,6 @@ +package templates + +import "embed" + +//go:embed all:typescript +var FS embed.FS diff --git a/pkg/templates/typescript/sample-app/README.md b/pkg/templates/typescript/sample-app/README.md new file mode 100644 index 0000000..1d85657 --- /dev/null +++ b/pkg/templates/typescript/sample-app/README.md @@ -0,0 +1,5 @@ +# Kernel Typscript Sample App + +This is a simple Kernel application that extracts the title from a webpage. + +See the [docs](https://onkernel.com/docs/quickstart) for information. \ No newline at end of file diff --git a/pkg/templates/typescript/sample-app/_gitignore b/pkg/templates/typescript/sample-app/_gitignore new file mode 100644 index 0000000..9325515 --- /dev/null +++ b/pkg/templates/typescript/sample-app/_gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ +package-lock.json + +# TypeScript +*.tsbuildinfo +dist/ +build/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Misc +.cache/ +.temp/ +.tmp/ \ No newline at end of file diff --git a/pkg/templates/typescript/sample-app/index.ts b/pkg/templates/typescript/sample-app/index.ts new file mode 100644 index 0000000..ea3a94c --- /dev/null +++ b/pkg/templates/typescript/sample-app/index.ts @@ -0,0 +1,108 @@ +import { Kernel, type KernelContext } from "@onkernel/sdk"; +import { chromium } from "playwright"; + +const kernel = new Kernel(); + +const app = kernel.app("ts-basic"); + +/** + * Example app that extracts the title of a webpage + * Args: + * ctx: Kernel context containing invocation information + * payload: An object with a URL property + * Returns: + * A dictionary containing the page title + * Invoke this via CLI: + * kernel login # or: export KERNEL_API_KEY= + * kernel deploy index.ts # If you haven't already deployed this app + * kernel invoke ts-basic get-page-title -p '{"url": "https://www.google.com"}' + * kernel logs ts-basic -f # Open in separate tab + */ +interface PageTitleInput { + url: string; +} + +interface PageTitleOutput { + title: string; +} +app.action( + "get-page-title", + async ( + ctx: KernelContext, + payload?: PageTitleInput + ): Promise => { + if (!payload?.url) { + throw new Error("URL is required"); + } + + if ( + !payload.url.startsWith("http://") && + !payload.url.startsWith("https://") + ) { + payload.url = `https://${payload.url}`; + } + + // Validate the URL + try { + new URL(payload.url); + } catch { + throw new Error(`Invalid URL: ${payload.url}`); + } + + const kernelBrowser = await kernel.browsers.create({ + invocation_id: ctx.invocation_id, + }); + + console.log( + "Kernel browser live view url: ", + kernelBrowser.browser_live_view_url + ); + + const browser = await chromium.connectOverCDP(kernelBrowser.cdp_ws_url); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = context.pages()[0] || (await context.newPage()); + + try { + ////////////////////////////////////// + // Your browser automation logic here + ////////////////////////////////////// + await page.goto(payload.url); + const title = await page.title(); + return { title }; + } finally { + await kernel.browsers.deleteByID(kernelBrowser.session_id); + } + } +); + +/** + * Example app that creates a long-running Kernel browser for manual testing + * Invoke this action to test Kernel browsers manually with our browser live view + * https://onkernel.com/docs/browsers/live-view + * Args: + * ctx: Kernel context containing invocation information + * Returns: + * A dictionary containing the browser live view url + * Invoke this via CLI: + * kernel login # or: export KERNEL_API_KEY= + * kernel deploy index.ts # If you haven't already deployed this app + * kernel invoke ts-basic create-browser-for-testing + * kernel logs ts-basic -f # Open in separate tab + */ +interface CreateBrowserForTestingOutput { + browser_live_view_url: string; +} +app.action( + "create-browser-for-testing", + async (ctx: KernelContext): Promise => { + const kernelBrowser = await kernel.browsers.create({ + invocation_id: ctx.invocation_id, + stealth: true, + timeout_seconds: 3600, // Keep browser alive for 1 hour + }); + + return { + browser_live_view_url: kernelBrowser.browser_live_view_url, + }; + } +); diff --git a/pkg/templates/typescript/sample-app/package-lock.json b/pkg/templates/typescript/sample-app/package-lock.json new file mode 100644 index 0000000..104353f --- /dev/null +++ b/pkg/templates/typescript/sample-app/package-lock.json @@ -0,0 +1,81 @@ +{ + "name": "ts-basic", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ts-basic", + "dependencies": { + "@onkernel/sdk": "0.1.0-alpha.16", + "playwright": "^1.52.0" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@onkernel/sdk": { + "version": "0.1.0-alpha.16", + "resolved": "https://registry.npmjs.org/@onkernel/sdk/-/sdk-0.1.0-alpha.16.tgz", + "integrity": "sha512-KlC1EFiWoSXWxGLdoI0gh6cyF+5xy0wx4ATJ2MPMQl31H5ElAR/oGO3UE3Ge2cFKra8oWVZRUj7NOkxnpDnQ3g==", + "license": "Apache-2.0" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/pkg/templates/typescript/sample-app/package.json b/pkg/templates/typescript/sample-app/package.json new file mode 100644 index 0000000..527437d --- /dev/null +++ b/pkg/templates/typescript/sample-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "ts-basic", + "module": "index.ts", + "type": "module", + "private": true, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@onkernel/sdk": ">=0.14.2", + "playwright": "^1.52.0" + } +} diff --git a/pkg/templates/typescript/sample-app/pnpm-lock.yaml b/pkg/templates/typescript/sample-app/pnpm-lock.yaml new file mode 100644 index 0000000..a69bdd0 --- /dev/null +++ b/pkg/templates/typescript/sample-app/pnpm-lock.yaml @@ -0,0 +1,61 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@onkernel/sdk': + specifier: '>=0.5.0' + version: 0.5.0 + playwright: + specifier: ^1.52.0 + version: 1.52.0 + typescript: + specifier: ^5 + version: 5.8.3 + +packages: + + '@onkernel/sdk@0.5.0': + resolution: {integrity: sha512-n7gwc7rU0GY/XcDnEV0piHPd76bHTSfuTjQW4qFKUWQji0UK9YUVKDFklqAWbyGlXPUezWCfxh79ELv2cFYOBA==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.52.0: + resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.52.0: + resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + engines: {node: '>=18'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@onkernel/sdk@0.5.0': {} + + fsevents@2.3.2: + optional: true + + playwright-core@1.52.0: {} + + playwright@1.52.0: + dependencies: + playwright-core: 1.52.0 + optionalDependencies: + fsevents: 2.3.2 + + typescript@5.8.3: {} diff --git a/pkg/templates/typescript/sample-app/tsconfig.json b/pkg/templates/typescript/sample-app/tsconfig.json new file mode 100644 index 0000000..39959d0 --- /dev/null +++ b/pkg/templates/typescript/sample-app/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["node_modules", "dist"] +} + \ No newline at end of file diff --git a/typescript/sample-app/README.md b/typescript/sample-app/README.md new file mode 100644 index 0000000..1d85657 --- /dev/null +++ b/typescript/sample-app/README.md @@ -0,0 +1,5 @@ +# Kernel Typscript Sample App + +This is a simple Kernel application that extracts the title from a webpage. + +See the [docs](https://onkernel.com/docs/quickstart) for information. \ No newline at end of file diff --git a/typescript/sample-app/_gitignore b/typescript/sample-app/_gitignore new file mode 100644 index 0000000..9325515 --- /dev/null +++ b/typescript/sample-app/_gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ +package-lock.json + +# TypeScript +*.tsbuildinfo +dist/ +build/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Misc +.cache/ +.temp/ +.tmp/ \ No newline at end of file diff --git a/typescript/sample-app/index.ts b/typescript/sample-app/index.ts new file mode 100644 index 0000000..ea3a94c --- /dev/null +++ b/typescript/sample-app/index.ts @@ -0,0 +1,108 @@ +import { Kernel, type KernelContext } from "@onkernel/sdk"; +import { chromium } from "playwright"; + +const kernel = new Kernel(); + +const app = kernel.app("ts-basic"); + +/** + * Example app that extracts the title of a webpage + * Args: + * ctx: Kernel context containing invocation information + * payload: An object with a URL property + * Returns: + * A dictionary containing the page title + * Invoke this via CLI: + * kernel login # or: export KERNEL_API_KEY= + * kernel deploy index.ts # If you haven't already deployed this app + * kernel invoke ts-basic get-page-title -p '{"url": "https://www.google.com"}' + * kernel logs ts-basic -f # Open in separate tab + */ +interface PageTitleInput { + url: string; +} + +interface PageTitleOutput { + title: string; +} +app.action( + "get-page-title", + async ( + ctx: KernelContext, + payload?: PageTitleInput + ): Promise => { + if (!payload?.url) { + throw new Error("URL is required"); + } + + if ( + !payload.url.startsWith("http://") && + !payload.url.startsWith("https://") + ) { + payload.url = `https://${payload.url}`; + } + + // Validate the URL + try { + new URL(payload.url); + } catch { + throw new Error(`Invalid URL: ${payload.url}`); + } + + const kernelBrowser = await kernel.browsers.create({ + invocation_id: ctx.invocation_id, + }); + + console.log( + "Kernel browser live view url: ", + kernelBrowser.browser_live_view_url + ); + + const browser = await chromium.connectOverCDP(kernelBrowser.cdp_ws_url); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = context.pages()[0] || (await context.newPage()); + + try { + ////////////////////////////////////// + // Your browser automation logic here + ////////////////////////////////////// + await page.goto(payload.url); + const title = await page.title(); + return { title }; + } finally { + await kernel.browsers.deleteByID(kernelBrowser.session_id); + } + } +); + +/** + * Example app that creates a long-running Kernel browser for manual testing + * Invoke this action to test Kernel browsers manually with our browser live view + * https://onkernel.com/docs/browsers/live-view + * Args: + * ctx: Kernel context containing invocation information + * Returns: + * A dictionary containing the browser live view url + * Invoke this via CLI: + * kernel login # or: export KERNEL_API_KEY= + * kernel deploy index.ts # If you haven't already deployed this app + * kernel invoke ts-basic create-browser-for-testing + * kernel logs ts-basic -f # Open in separate tab + */ +interface CreateBrowserForTestingOutput { + browser_live_view_url: string; +} +app.action( + "create-browser-for-testing", + async (ctx: KernelContext): Promise => { + const kernelBrowser = await kernel.browsers.create({ + invocation_id: ctx.invocation_id, + stealth: true, + timeout_seconds: 3600, // Keep browser alive for 1 hour + }); + + return { + browser_live_view_url: kernelBrowser.browser_live_view_url, + }; + } +); diff --git a/typescript/sample-app/package-lock.json b/typescript/sample-app/package-lock.json new file mode 100644 index 0000000..104353f --- /dev/null +++ b/typescript/sample-app/package-lock.json @@ -0,0 +1,81 @@ +{ + "name": "ts-basic", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ts-basic", + "dependencies": { + "@onkernel/sdk": "0.1.0-alpha.16", + "playwright": "^1.52.0" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@onkernel/sdk": { + "version": "0.1.0-alpha.16", + "resolved": "https://registry.npmjs.org/@onkernel/sdk/-/sdk-0.1.0-alpha.16.tgz", + "integrity": "sha512-KlC1EFiWoSXWxGLdoI0gh6cyF+5xy0wx4ATJ2MPMQl31H5ElAR/oGO3UE3Ge2cFKra8oWVZRUj7NOkxnpDnQ3g==", + "license": "Apache-2.0" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/typescript/sample-app/package.json b/typescript/sample-app/package.json new file mode 100644 index 0000000..527437d --- /dev/null +++ b/typescript/sample-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "ts-basic", + "module": "index.ts", + "type": "module", + "private": true, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@onkernel/sdk": ">=0.14.2", + "playwright": "^1.52.0" + } +} diff --git a/typescript/sample-app/pnpm-lock.yaml b/typescript/sample-app/pnpm-lock.yaml new file mode 100644 index 0000000..a69bdd0 --- /dev/null +++ b/typescript/sample-app/pnpm-lock.yaml @@ -0,0 +1,61 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@onkernel/sdk': + specifier: '>=0.5.0' + version: 0.5.0 + playwright: + specifier: ^1.52.0 + version: 1.52.0 + typescript: + specifier: ^5 + version: 5.8.3 + +packages: + + '@onkernel/sdk@0.5.0': + resolution: {integrity: sha512-n7gwc7rU0GY/XcDnEV0piHPd76bHTSfuTjQW4qFKUWQji0UK9YUVKDFklqAWbyGlXPUezWCfxh79ELv2cFYOBA==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.52.0: + resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.52.0: + resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + engines: {node: '>=18'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + '@onkernel/sdk@0.5.0': {} + + fsevents@2.3.2: + optional: true + + playwright-core@1.52.0: {} + + playwright@1.52.0: + dependencies: + playwright-core: 1.52.0 + optionalDependencies: + fsevents: 2.3.2 + + typescript@5.8.3: {} diff --git a/typescript/sample-app/tsconfig.json b/typescript/sample-app/tsconfig.json new file mode 100644 index 0000000..39959d0 --- /dev/null +++ b/typescript/sample-app/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["node_modules", "dist"] +} + \ No newline at end of file From 966ea892ffa09e3f9d07160cce4d465cb5cfe509 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 3 Dec 2025 19:01:14 +0000 Subject: [PATCH 03/14] feat: add cli prompts --- cmd/create.go | 63 ++++++------------------------- pkg/create/prompts.go | 87 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 52 deletions(-) create mode 100644 pkg/create/prompts.go diff --git a/cmd/create.go b/cmd/create.go index b7ff8b9..e1b8295 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -2,9 +2,8 @@ package cmd import ( "fmt" - "regexp" - "github.com/AlecAivazis/survey/v2" + "github.com/onkernel/cli/pkg/create" "github.com/spf13/cobra" ) @@ -21,68 +20,28 @@ func init() { createCmd.Flags().String("template", "", "Template to use for the application") } -const defaultAppName = "my-kernel-app" - -// projectNameValidator ensures the project name is safe for file systems and package managers. -func projectNameValidator(val any) error { - str, ok := val.(string) - if !ok { - return fmt.Errorf("invalid input type") - } - - // Project name must be non-empty - if len(str) == 0 { - return fmt.Errorf("project name cannot be empty") - } +func runCreateApp(cmd *cobra.Command, args []string) error { + appName, _ := cmd.Flags().GetString("name") + language, _ := cmd.Flags().GetString("language") + template, _ := cmd.Flags().GetString("template") - // Validate project name: only letters, numbers, underscores, and hyphens - // This regex prevents special characters that might break shell commands or filesystem paths. - matched, err := regexp.MatchString(`^[A-Za-z\-_\d]+$`, str) + appName, err := create.PromptForAppName(appName) if err != nil { - return err - } - if !matched { - return fmt.Errorf("project name may only include letters, numbers, underscores, and hyphens") - } - return nil -} - -// promptForAppName prompts the user for the application name if not provided -func promptForAppName(providedAppName string) (string, error) { - if providedAppName != "" { - return providedAppName, nil - } - - var appName string - prompt := &survey.Input{ - Message: "What is the name of your project?", - Default: defaultAppName, + return fmt.Errorf("failed to get app name: %w", err) } - err := survey.AskOne(prompt, &appName, survey.WithValidator(projectNameValidator)) + language, err = create.PromptForLanguage(language) if err != nil { - return "", err + return fmt.Errorf("failed to get language: %w", err) } - return appName, nil -} - -func runCreateApp(cmd *cobra.Command, args []string) error { - providedAppName, _ := cmd.Flags().GetString("name") - language, _ := cmd.Flags().GetString("language") - template, _ := cmd.Flags().GetString("template") - - // Prompt for app name if not provided - appName, err := promptForAppName(providedAppName) + template, err = create.PromptForTemplate(template) if err != nil { - return fmt.Errorf("failed to get app name: %w", err) + return fmt.Errorf("failed to get template: %w", err) } fmt.Printf("Creating application '%s' with language '%s' and template '%s'...\n", appName, language, template) - // TODO: prompt the user for the language of the app, suggest a default language (typescript) - // TODO: prompt the user for the template of the app, suggest a default template (sample-app) - // TODO: create the project structure // print "Creating a new TypeScript Sample App" or similar. Essentially the language and template name combined. diff --git a/pkg/create/prompts.go b/pkg/create/prompts.go new file mode 100644 index 0000000..a2384e5 --- /dev/null +++ b/pkg/create/prompts.go @@ -0,0 +1,87 @@ +package create + +import ( + "fmt" + "regexp" + + "github.com/AlecAivazis/survey/v2" +) + +const defaultAppName = "my-kernel-app" + +// projectNameValidator ensures the project name is safe for file systems and package managers. +func projectNameValidator(val any) error { + str, ok := val.(string) + if !ok { + return fmt.Errorf("invalid input type") + } + + // Project name must be non-empty + if len(str) == 0 { + return fmt.Errorf("project name cannot be empty") + } + + // Validate project name: only letters, numbers, underscores, and hyphens + // This regex prevents special characters that might break shell commands or filesystem paths. + matched, err := regexp.MatchString(`^[A-Za-z\-_\d]+$`, str) + if err != nil { + return err + } + if !matched { + return fmt.Errorf("project name may only include letters, numbers, underscores, and hyphens") + } + return nil +} + +// PromptForAppName prompts the user for the application name if not provided +func PromptForAppName(providedAppName string) (string, error) { + if providedAppName != "" { + return providedAppName, nil + } + + var appName string + prompt := &survey.Input{ + Message: "What is the name of your project?", + Default: defaultAppName, + } + + if err := survey.AskOne(prompt, &appName, survey.WithValidator(projectNameValidator)); err != nil { + return "", err + } + + return appName, nil +} + +func PromptForLanguage(providedLanguage string) (string, error) { + if providedLanguage != "" { + return providedLanguage, nil + } + var language string + languagePrompt := &survey.Select{ + Message: "Choose a programming language:", + // TODO: create constants so that more languages can be added later + Options: []string{"typescript", "python"}, + Default: "typescript", + } + if err := survey.AskOne(languagePrompt, &language); err != nil { + return "", err + } + return language, nil +} + +func PromptForTemplate(providedTemplate string) (string, error) { + if providedTemplate != "" { + return providedTemplate, nil + } + + var template string + templatePrompt := &survey.Select{ + Message: "Choose a template:", + Options: []string{"sample-app"}, + Default: "sample-app", + } + if err := survey.AskOne(templatePrompt, &template); err != nil { + return "", err + } + return template, nil +} From f93d77cf7e59d19ed9e35cfa44bc1ec92d4d93e9 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 3 Dec 2025 20:14:50 +0000 Subject: [PATCH 04/14] feat: add file copying function into new directory --- cmd/create.go | 55 ++++++++++++++++++++---------- pkg/create/copy.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 pkg/create/copy.go diff --git a/cmd/create.go b/cmd/create.go index e1b8295..46c3bd6 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -2,8 +2,11 @@ package cmd import ( "fmt" + "os" + "path/filepath" "github.com/onkernel/cli/pkg/create" + "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -40,28 +43,46 @@ func runCreateApp(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get template: %w", err) } - fmt.Printf("Creating application '%s' with language '%s' and template '%s'...\n", appName, language, template) + // 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: create the project structure + // Check if directory already exists + if _, err := os.Stat(appPath); err == nil { + return fmt.Errorf("directory %s already exists", appName) + } - // print "Creating a new TypeScript Sample App" or similar. Essentially the language and template name combined. + // Create the app directory + if err := os.MkdirAll(appPath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } - /* - Print the following: - ✔ TypeScript environment set up successfully + fmt.Printf("\nCreating a new %s %s\n\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") - 🎉 Kernel app created successfully! + nextSteps := fmt.Sprintf(`Next steps: + brew install onkernel/tap/kernel + cd %s + kernel login # or: export KERNEL_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= + kernel logs ts-basic --follow +`, appName) - Next steps: - brew install onkernel/tap/kernel - cd my-kernel-app - kernel login # or: export KERNEL_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= - kernel logs ts-basic --follow - */ + pterm.Success.Println("🎉 Kernel app created successfully!") + pterm.Println() + pterm.FgYellow.Println(nextSteps) return nil } diff --git a/pkg/create/copy.go b/pkg/create/copy.go new file mode 100644 index 0000000..c70be9f --- /dev/null +++ b/pkg/create/copy.go @@ -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 + }) +} From ee4ac92b632039d5d4f862005d4bd2752bed470b Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 4 Dec 2025 15:40:49 +0000 Subject: [PATCH 05/14] feat: add types --- cmd/create.go | 6 ++--- pkg/create/prompts.go | 47 +++++++++++++++++------------------ pkg/create/types.go | 58 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 pkg/create/types.go diff --git a/cmd/create.go b/cmd/create.go index 46c3bd6..d10b93e 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -18,9 +18,9 @@ var createCmd = &cobra.Command{ } func init() { - createCmd.Flags().String("name", "", "Name of the application") - createCmd.Flags().String("language", "", "Language of the application") - createCmd.Flags().String("template", "", "Template to use for the application") + 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 { diff --git a/pkg/create/prompts.go b/pkg/create/prompts.go index a2384e5..b306375 100644 --- a/pkg/create/prompts.go +++ b/pkg/create/prompts.go @@ -3,26 +3,22 @@ package create import ( "fmt" "regexp" + "slices" "github.com/AlecAivazis/survey/v2" ) -const defaultAppName = "my-kernel-app" - -// projectNameValidator ensures the project name is safe for file systems and package managers. -func projectNameValidator(val any) error { +func validateAppName(val any) error { str, ok := val.(string) if !ok { return fmt.Errorf("invalid input type") } - // Project name must be non-empty if len(str) == 0 { return fmt.Errorf("project name cannot be empty") } // Validate project name: only letters, numbers, underscores, and hyphens - // This regex prevents special characters that might break shell commands or filesystem paths. matched, err := regexp.MatchString(`^[A-Za-z\-_\d]+$`, str) if err != nil { return err @@ -33,7 +29,6 @@ func projectNameValidator(val any) error { return nil } -// PromptForAppName prompts the user for the application name if not provided func PromptForAppName(providedAppName string) (string, error) { if providedAppName != "" { return providedAppName, nil @@ -41,32 +36,37 @@ func PromptForAppName(providedAppName string) (string, error) { var appName string prompt := &survey.Input{ - Message: "What is the name of your project?", - Default: defaultAppName, + Message: AppNamePrompt, + Default: DefaultAppName, } - if err := survey.AskOne(prompt, &appName, survey.WithValidator(projectNameValidator)); err != nil { + if err := survey.AskOne(prompt, &appName, survey.WithValidator(validateAppName)); err != nil { return "", err } return appName, nil } -func PromptForLanguage(providedLanguage string) (string, error) { - if providedLanguage != "" { - return providedLanguage, nil - } - var language string +func handleLangugePrompt() (string, error) { + var l string languagePrompt := &survey.Select{ - Message: "Choose a programming language:", - // TODO: create constants so that more languages can be added later - Options: []string{"typescript", "python"}, - Default: "typescript", + Message: LanguagePrompt, + Options: SupportedLanguages, } - if err := survey.AskOne(languagePrompt, &language); err != nil { + if err := survey.AskOne(languagePrompt, &l); err != nil { return "", err } - return language, nil + return l, nil +} + +func PromptForLanguage(providedLanguage string) (string, error) { + l := NormalizeLanguage(providedLanguage) + if l != "" && !slices.Contains(SupportedLanguages, l) { + return handleLangugePrompt() + } else if providedLanguage != "" { + return l, nil + } + return handleLangugePrompt() } func PromptForTemplate(providedTemplate string) (string, error) { @@ -76,9 +76,8 @@ func PromptForTemplate(providedTemplate string) (string, error) { var template string templatePrompt := &survey.Select{ - Message: "Choose a template:", - Options: []string{"sample-app"}, - Default: "sample-app", + Message: TemplatePrompt, + Options: GetSupportedTemplates(), } if err := survey.AskOne(templatePrompt, &template); err != nil { return "", err diff --git a/pkg/create/types.go b/pkg/create/types.go new file mode 100644 index 0000000..2937212 --- /dev/null +++ b/pkg/create/types.go @@ -0,0 +1,58 @@ +package create + +const ( + DefaultAppName = "my-kernel-app" + AppNamePrompt = "What is the name of your project?" + LanguagePrompt = "Choose a programming language:" + TemplatePrompt = "Select a template:" +) + +type Language string + +const ( + LanguageTypeScript = "typescript" + LanguagePython = "python" + LanguageShorthandTypeScript = "ts" + LanguageShorthandPython = "py" +) + +type TemplateInfo struct { + Name string + Description string + Languages []Language +} + +var Templates = map[string]TemplateInfo{ + "sample-app": { + Name: "Sample App", + Description: "Implements basic Kernel apps", + Languages: []Language{LanguageTypeScript, LanguagePython}, + }, +} + +// SupportedLanguages returns a list of all supported languages +var SupportedLanguages = []string{ + LanguageTypeScript, + LanguagePython, +} + +// GetSupportedTemplates returns a list of all supported template names +func GetSupportedTemplates() []string { + templates := make([]string, 0, len(Templates)) + for tn := range Templates { + templates = append(templates, tn) + } + return templates +} + +// Helper to normalize language input (handle shorthand) +func NormalizeLanguage(language string) string { + switch language { + case LanguageShorthandTypeScript: + return LanguageTypeScript + case LanguageShorthandPython: + return LanguagePython + default: + return language + } +} From 078e7e9d9269c0ee1017e2e9ef72114392c99c51 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 4 Dec 2025 16:15:59 +0000 Subject: [PATCH 06/14] feat: add types_test.go update syntax --- pkg/create/types_test.go | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 pkg/create/types_test.go diff --git a/pkg/create/types_test.go b/pkg/create/types_test.go new file mode 100644 index 0000000..f6c776d --- /dev/null +++ b/pkg/create/types_test.go @@ -0,0 +1,44 @@ +package create + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeLanguage(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"ts", "typescript"}, + {"py", "python"}, + {"typescript", "typescript"}, + {"invalid", "invalid"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := NormalizeLanguage(tt.input) + assert.Equal(t, tt.expected, got, "NormalizeLanguage(%q) should return %q, got %q", tt.input, tt.expected, got) + }) + } +} + +func TestTemplates(t *testing.T) { + // Should have at least one template + assert.NotEmpty(t, Templates, "Templates map should not be empty") + + // Sample app should exist + sampleApp, exists := Templates["sample-app"] + assert.True(t, exists, "sample-app template should exist") + + // Sample app should have required fields + assert.NotEmpty(t, sampleApp.Name, "Template should have a name") + assert.NotEmpty(t, sampleApp.Description, "Template should have a description") + assert.NotEmpty(t, sampleApp.Languages, "Template should support at least one language") + + // Should support both typescript and python + assert.Contains(t, sampleApp.Languages, Language(LanguageTypeScript), "sample-app should support typescript") + assert.Contains(t, sampleApp.Languages, Language(LanguagePython), "sample-app should support python") +} From fd452dd4c5ce211a13f635be0f7b319d625be186 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 4 Dec 2025 19:38:11 +0000 Subject: [PATCH 07/14] fix: remove old files --- typescript/sample-app/README.md | 5 -- typescript/sample-app/_gitignore | 39 --------- typescript/sample-app/index.ts | 108 ------------------------ typescript/sample-app/package-lock.json | 81 ------------------ typescript/sample-app/package.json | 13 --- typescript/sample-app/pnpm-lock.yaml | 61 ------------- typescript/sample-app/tsconfig.json | 31 ------- 7 files changed, 338 deletions(-) delete mode 100644 typescript/sample-app/README.md delete mode 100644 typescript/sample-app/_gitignore delete mode 100644 typescript/sample-app/index.ts delete mode 100644 typescript/sample-app/package-lock.json delete mode 100644 typescript/sample-app/package.json delete mode 100644 typescript/sample-app/pnpm-lock.yaml delete mode 100644 typescript/sample-app/tsconfig.json diff --git a/typescript/sample-app/README.md b/typescript/sample-app/README.md deleted file mode 100644 index 1d85657..0000000 --- a/typescript/sample-app/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Kernel Typscript Sample App - -This is a simple Kernel application that extracts the title from a webpage. - -See the [docs](https://onkernel.com/docs/quickstart) for information. \ No newline at end of file diff --git a/typescript/sample-app/_gitignore b/typescript/sample-app/_gitignore deleted file mode 100644 index 9325515..0000000 --- a/typescript/sample-app/_gitignore +++ /dev/null @@ -1,39 +0,0 @@ -# Dependencies -node_modules/ -package-lock.json - -# TypeScript -*.tsbuildinfo -dist/ -build/ - -# Environment -.env -.env.local -.env.*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Testing -coverage/ -.nyc_output/ - -# Misc -.cache/ -.temp/ -.tmp/ \ No newline at end of file diff --git a/typescript/sample-app/index.ts b/typescript/sample-app/index.ts deleted file mode 100644 index ea3a94c..0000000 --- a/typescript/sample-app/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Kernel, type KernelContext } from "@onkernel/sdk"; -import { chromium } from "playwright"; - -const kernel = new Kernel(); - -const app = kernel.app("ts-basic"); - -/** - * Example app that extracts the title of a webpage - * Args: - * ctx: Kernel context containing invocation information - * payload: An object with a URL property - * Returns: - * A dictionary containing the page title - * Invoke this via CLI: - * kernel login # or: export KERNEL_API_KEY= - * kernel deploy index.ts # If you haven't already deployed this app - * kernel invoke ts-basic get-page-title -p '{"url": "https://www.google.com"}' - * kernel logs ts-basic -f # Open in separate tab - */ -interface PageTitleInput { - url: string; -} - -interface PageTitleOutput { - title: string; -} -app.action( - "get-page-title", - async ( - ctx: KernelContext, - payload?: PageTitleInput - ): Promise => { - if (!payload?.url) { - throw new Error("URL is required"); - } - - if ( - !payload.url.startsWith("http://") && - !payload.url.startsWith("https://") - ) { - payload.url = `https://${payload.url}`; - } - - // Validate the URL - try { - new URL(payload.url); - } catch { - throw new Error(`Invalid URL: ${payload.url}`); - } - - const kernelBrowser = await kernel.browsers.create({ - invocation_id: ctx.invocation_id, - }); - - console.log( - "Kernel browser live view url: ", - kernelBrowser.browser_live_view_url - ); - - const browser = await chromium.connectOverCDP(kernelBrowser.cdp_ws_url); - const context = browser.contexts()[0] || (await browser.newContext()); - const page = context.pages()[0] || (await context.newPage()); - - try { - ////////////////////////////////////// - // Your browser automation logic here - ////////////////////////////////////// - await page.goto(payload.url); - const title = await page.title(); - return { title }; - } finally { - await kernel.browsers.deleteByID(kernelBrowser.session_id); - } - } -); - -/** - * Example app that creates a long-running Kernel browser for manual testing - * Invoke this action to test Kernel browsers manually with our browser live view - * https://onkernel.com/docs/browsers/live-view - * Args: - * ctx: Kernel context containing invocation information - * Returns: - * A dictionary containing the browser live view url - * Invoke this via CLI: - * kernel login # or: export KERNEL_API_KEY= - * kernel deploy index.ts # If you haven't already deployed this app - * kernel invoke ts-basic create-browser-for-testing - * kernel logs ts-basic -f # Open in separate tab - */ -interface CreateBrowserForTestingOutput { - browser_live_view_url: string; -} -app.action( - "create-browser-for-testing", - async (ctx: KernelContext): Promise => { - const kernelBrowser = await kernel.browsers.create({ - invocation_id: ctx.invocation_id, - stealth: true, - timeout_seconds: 3600, // Keep browser alive for 1 hour - }); - - return { - browser_live_view_url: kernelBrowser.browser_live_view_url, - }; - } -); diff --git a/typescript/sample-app/package-lock.json b/typescript/sample-app/package-lock.json deleted file mode 100644 index 104353f..0000000 --- a/typescript/sample-app/package-lock.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "name": "ts-basic", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ts-basic", - "dependencies": { - "@onkernel/sdk": "0.1.0-alpha.16", - "playwright": "^1.52.0" - }, - "peerDependencies": { - "typescript": "^5" - } - }, - "node_modules/@onkernel/sdk": { - "version": "0.1.0-alpha.16", - "resolved": "https://registry.npmjs.org/@onkernel/sdk/-/sdk-0.1.0-alpha.16.tgz", - "integrity": "sha512-KlC1EFiWoSXWxGLdoI0gh6cyF+5xy0wx4ATJ2MPMQl31H5ElAR/oGO3UE3Ge2cFKra8oWVZRUj7NOkxnpDnQ3g==", - "license": "Apache-2.0" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", - "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.52.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", - "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/typescript/sample-app/package.json b/typescript/sample-app/package.json deleted file mode 100644 index 527437d..0000000 --- a/typescript/sample-app/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "ts-basic", - "module": "index.ts", - "type": "module", - "private": true, - "peerDependencies": { - "typescript": "^5" - }, - "dependencies": { - "@onkernel/sdk": ">=0.14.2", - "playwright": "^1.52.0" - } -} diff --git a/typescript/sample-app/pnpm-lock.yaml b/typescript/sample-app/pnpm-lock.yaml deleted file mode 100644 index a69bdd0..0000000 --- a/typescript/sample-app/pnpm-lock.yaml +++ /dev/null @@ -1,61 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@onkernel/sdk': - specifier: '>=0.5.0' - version: 0.5.0 - playwright: - specifier: ^1.52.0 - version: 1.52.0 - typescript: - specifier: ^5 - version: 5.8.3 - -packages: - - '@onkernel/sdk@0.5.0': - resolution: {integrity: sha512-n7gwc7rU0GY/XcDnEV0piHPd76bHTSfuTjQW4qFKUWQji0UK9YUVKDFklqAWbyGlXPUezWCfxh79ELv2cFYOBA==} - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - playwright-core@1.52.0: - resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.52.0: - resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} - engines: {node: '>=18'} - hasBin: true - - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} - hasBin: true - -snapshots: - - '@onkernel/sdk@0.5.0': {} - - fsevents@2.3.2: - optional: true - - playwright-core@1.52.0: {} - - playwright@1.52.0: - dependencies: - playwright-core: 1.52.0 - optionalDependencies: - fsevents: 2.3.2 - - typescript@5.8.3: {} diff --git a/typescript/sample-app/tsconfig.json b/typescript/sample-app/tsconfig.json deleted file mode 100644 index 39959d0..0000000 --- a/typescript/sample-app/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - }, - "include": ["./**/*.ts", "./**/*.tsx"], - "exclude": ["node_modules", "dist"] -} - \ No newline at end of file From 628b530f5fc0fbe8bae03c8bf5271cd4afc87a5f Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 5 Dec 2025 14:18:04 +0000 Subject: [PATCH 08/14] feat: add testing for create_test.go --- cmd/create_test.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 cmd/create_test.go diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..bd0c88a --- /dev/null +++ b/cmd/create_test.go @@ -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) + } + }) + } +} From 39f13c7a8f610beaa64959b20c743ce5a71f11a2 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 5 Dec 2025 14:18:18 +0000 Subject: [PATCH 09/14] hide `create` command --- cmd/root.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 61f0fc5..2a0c0a7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -129,7 +129,8 @@ func init() { rootCmd.AddCommand(profilesCmd) rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) - rootCmd.AddCommand(createCmd) + // 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 From 89aa20bb98352ff9439dd5de2da4cc8ea482d0e8 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Fri, 5 Dec 2025 14:41:48 +0000 Subject: [PATCH 10/14] self review --- cmd/create.go | 3 ++- pkg/create/prompts.go | 10 +++++++--- pkg/create/types.go | 6 ++---- pkg/create/types_test.go | 4 ++-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index d10b93e..38abe47 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -49,6 +49,7 @@ func runCreateApp(cmd *cobra.Command, args []string) error { 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) @@ -59,7 +60,7 @@ func runCreateApp(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create directory: %w", err) } - fmt.Printf("\nCreating a new %s %s\n\n", language, template) + pterm.Println(fmt.Sprintf("\nCreating a new %s %s\n", language, template)) spinner, _ := pterm.DefaultSpinner.Start("Copying template files...") diff --git a/pkg/create/prompts.go b/pkg/create/prompts.go index b306375..217cbd0 100644 --- a/pkg/create/prompts.go +++ b/pkg/create/prompts.go @@ -60,15 +60,19 @@ func handleLangugePrompt() (string, error) { } func PromptForLanguage(providedLanguage string) (string, error) { - l := NormalizeLanguage(providedLanguage) - if l != "" && !slices.Contains(SupportedLanguages, l) { + if providedLanguage == "" { return handleLangugePrompt() - } else if providedLanguage != "" { + } + + l := NormalizeLanguage(providedLanguage) + if slices.Contains(SupportedLanguages, l) { return l, nil } + return handleLangugePrompt() } +// TODO: add validation for template func PromptForTemplate(providedTemplate string) (string, error) { if providedTemplate != "" { return providedTemplate, nil diff --git a/pkg/create/types.go b/pkg/create/types.go index 2937212..a09ddfb 100644 --- a/pkg/create/types.go +++ b/pkg/create/types.go @@ -7,8 +7,6 @@ const ( TemplatePrompt = "Select a template:" ) -type Language string - const ( LanguageTypeScript = "typescript" LanguagePython = "python" @@ -19,14 +17,14 @@ const ( type TemplateInfo struct { Name string Description string - Languages []Language + Languages []string } var Templates = map[string]TemplateInfo{ "sample-app": { Name: "Sample App", Description: "Implements basic Kernel apps", - Languages: []Language{LanguageTypeScript, LanguagePython}, + Languages: []string{LanguageTypeScript, LanguagePython}, }, } diff --git a/pkg/create/types_test.go b/pkg/create/types_test.go index f6c776d..7e84c74 100644 --- a/pkg/create/types_test.go +++ b/pkg/create/types_test.go @@ -39,6 +39,6 @@ func TestTemplates(t *testing.T) { assert.NotEmpty(t, sampleApp.Languages, "Template should support at least one language") // Should support both typescript and python - assert.Contains(t, sampleApp.Languages, Language(LanguageTypeScript), "sample-app should support typescript") - assert.Contains(t, sampleApp.Languages, Language(LanguagePython), "sample-app should support python") + assert.Contains(t, sampleApp.Languages, string(LanguageTypeScript), "sample-app should support typescript") + assert.Contains(t, sampleApp.Languages, string(LanguagePython), "sample-app should support python") } From d1376a1fc9062071b3fc48beb7a755eb5a877c97 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 8 Dec 2025 16:02:19 +0000 Subject: [PATCH 11/14] review: refactor prompting to use pterm --- go.mod | 5 ----- go.sum | 15 -------------- pkg/create/prompts.go | 48 ++++++++++++++++++++++++------------------- 3 files changed, 27 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index 1efaf1d..b17f5cc 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,6 @@ 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 @@ -38,13 +37,9 @@ 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 diff --git a/go.sum b/go.sum index c0d9b2e..f5b74b9 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ 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= @@ -21,7 +19,6 @@ 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= @@ -46,7 +43,6 @@ 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= @@ -64,13 +60,10 @@ 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= @@ -85,15 +78,9 @@ 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= @@ -174,7 +161,6 @@ 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= @@ -197,7 +183,6 @@ 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= diff --git a/pkg/create/prompts.go b/pkg/create/prompts.go index 217cbd0..fb0216b 100644 --- a/pkg/create/prompts.go +++ b/pkg/create/prompts.go @@ -5,7 +5,7 @@ import ( "regexp" "slices" - "github.com/AlecAivazis/survey/v2" + "github.com/pterm/pterm" ) func validateAppName(val any) error { @@ -34,26 +34,33 @@ func PromptForAppName(providedAppName string) (string, error) { return providedAppName, nil } - var appName string - prompt := &survey.Input{ - Message: AppNamePrompt, - Default: DefaultAppName, + promptText := fmt.Sprintf("%s (default: %s)", AppNamePrompt, DefaultAppName) + appName, err := pterm.DefaultInteractiveTextInput. + WithDefaultText(promptText). + Show() + if err != nil { + return "", err + } + + // Use default if user just pressed enter without typing anything + if appName == "" { + appName = DefaultAppName } - if err := survey.AskOne(prompt, &appName, survey.WithValidator(validateAppName)); err != nil { + // Validate the app name + if err := validateAppName(appName); err != nil { return "", err } return appName, nil } -func handleLangugePrompt() (string, error) { - var l string - languagePrompt := &survey.Select{ - Message: LanguagePrompt, - Options: SupportedLanguages, - } - if err := survey.AskOne(languagePrompt, &l); err != nil { +func handleLanguagePrompt() (string, error) { + l, err := pterm.DefaultInteractiveSelect. + WithOptions(SupportedLanguages). + WithDefaultText(LanguagePrompt). + Show() + if err != nil { return "", err } return l, nil @@ -61,7 +68,7 @@ func handleLangugePrompt() (string, error) { func PromptForLanguage(providedLanguage string) (string, error) { if providedLanguage == "" { - return handleLangugePrompt() + return handleLanguagePrompt() } l := NormalizeLanguage(providedLanguage) @@ -69,7 +76,7 @@ func PromptForLanguage(providedLanguage string) (string, error) { return l, nil } - return handleLangugePrompt() + return handleLanguagePrompt() } // TODO: add validation for template @@ -78,12 +85,11 @@ func PromptForTemplate(providedTemplate string) (string, error) { return providedTemplate, nil } - var template string - templatePrompt := &survey.Select{ - Message: TemplatePrompt, - Options: GetSupportedTemplates(), - } - if err := survey.AskOne(templatePrompt, &template); err != nil { + template, err := pterm.DefaultInteractiveSelect. + WithOptions(GetSupportedTemplates()). + WithDefaultText(TemplatePrompt). + Show() + if err != nil { return "", err } return template, nil From 4db2ecdb60b5c791b1515b38497ed069c401945b Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 8 Dec 2025 16:14:56 +0000 Subject: [PATCH 12/14] review: add app name validation with flag --- cmd/create.go | 2 +- cmd/root.go | 3 +-- pkg/create/prompts.go | 32 ++++++++++++++++++++++++-------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 38abe47..337600f 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -68,7 +68,7 @@ func runCreateApp(cmd *cobra.Command, args []string) error { spinner.Fail("Failed to copy template files") return fmt.Errorf("failed to copy template files: %w", err) } - spinner.Success("✔ TypeScript environment set up successfully") + spinner.Success(fmt.Sprintf("✔ %s environment set up successfully", language)) nextSteps := fmt.Sprintf(`Next steps: brew install onkernel/tap/kernel diff --git a/cmd/root.go b/cmd/root.go index 2a0c0a7..61f0fc5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -129,8 +129,7 @@ func init() { rootCmd.AddCommand(profilesCmd) rootCmd.AddCommand(proxies.ProxiesCmd) rootCmd.AddCommand(extensionsCmd) - // Hide create command while WIP - // rootCmd.AddCommand(createCmd) + rootCmd.AddCommand(createCmd) rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error { // running synchronously so we never slow the command diff --git a/pkg/create/prompts.go b/pkg/create/prompts.go index fb0216b..4fdd3e4 100644 --- a/pkg/create/prompts.go +++ b/pkg/create/prompts.go @@ -8,6 +8,8 @@ import ( "github.com/pterm/pterm" ) +// validateAppName validates that an app name follows the required format. +// Returns an error if the name is invalid. func validateAppName(val any) error { str, ok := val.(string) if !ok { @@ -29,11 +31,8 @@ func validateAppName(val any) error { return nil } -func PromptForAppName(providedAppName string) (string, error) { - if providedAppName != "" { - return providedAppName, nil - } - +// handleAppNamePrompt prompts the user for an app name interactively. +func handleAppNamePrompt() (string, error) { promptText := fmt.Sprintf("%s (default: %s)", AppNamePrompt, DefaultAppName) appName, err := pterm.DefaultInteractiveTextInput. WithDefaultText(promptText). @@ -42,19 +41,36 @@ func PromptForAppName(providedAppName string) (string, error) { return "", err } - // Use default if user just pressed enter without typing anything if appName == "" { appName = DefaultAppName } - // Validate the app name if err := validateAppName(appName); err != nil { - return "", err + pterm.Warning.Printf("Invalid app name '%s': %v\n", appName, err) + pterm.Info.Println("Please provide a valid app name.") + return handleAppNamePrompt() } return appName, nil } +// PromptForAppName validates the provided app name or prompts the user for one. +// If the provided name is invalid, it shows a warning and prompts the user. +func PromptForAppName(providedAppName string) (string, error) { + // If no app name was provided, prompt the user + if providedAppName == "" { + return handleAppNamePrompt() + } + + if err := validateAppName(providedAppName); err != nil { + pterm.Warning.Printf("Invalid app name '%s': %v\n", providedAppName, err) + pterm.Info.Println("Please provide a valid app name.") + return handleAppNamePrompt() + } + + return providedAppName, nil +} + func handleLanguagePrompt() (string, error) { l, err := pterm.DefaultInteractiveSelect. WithOptions(SupportedLanguages). From f54d0d44083538c4f4f399acd873c2717d981db6 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 8 Dec 2025 16:17:51 +0000 Subject: [PATCH 13/14] review: update copy text --- cmd/create.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 337600f..2c62047 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -76,9 +76,6 @@ func runCreateApp(cmd *cobra.Command, args []string) error { kernel login # or: export KERNEL_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= - kernel logs ts-basic --follow `, appName) pterm.Success.Println("🎉 Kernel app created successfully!") From 261ead0b45424257a84638ea645fb2917ea92d31 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Mon, 8 Dec 2025 17:12:58 +0000 Subject: [PATCH 14/14] refactor: fix test and refactor structure update comment --- cmd/create.go | 94 ++++++++++++++++++++++++++----------------- cmd/create_test.go | 25 ++++++++---- pkg/create/prompts.go | 2 +- 3 files changed, 74 insertions(+), 47 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 2c62047..9d50c5d 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "os" "path/filepath" @@ -10,41 +11,18 @@ import ( "github.com/spf13/cobra" ) -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a new application", - Long: "Commands for creating new Kernel applications", - RunE: runCreateApp, +type CreateInput struct { + Name string + Language string + Template string } -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") +// CreateCmd is a cobra-independent command handler for create operations +type CreateCmd struct{} - 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) +// 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) } @@ -52,23 +30,22 @@ func runCreateApp(cmd *cobra.Command, args []string) error { // 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) + return fmt.Errorf("directory %s already exists", ci.Name) } - // 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)) + 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, language, template); err != nil { + 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", language)) + spinner.Success(fmt.Sprintf("✔ %s environment set up successfully", ci.Language)) nextSteps := fmt.Sprintf(`Next steps: brew install onkernel/tap/kernel @@ -76,7 +53,7 @@ func runCreateApp(cmd *cobra.Command, args []string) error { kernel login # or: export KERNEL_API_KEY= kernel deploy index.ts kernel invoke ts-basic get-page-title --payload '{"url": "https://www.google.com"}' -`, appName) +`, ci.Name) pterm.Success.Println("🎉 Kernel app created successfully!") pterm.Println() @@ -84,3 +61,44 @@ func runCreateApp(cmd *cobra.Command, args []string) error { 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, + }) +} diff --git a/cmd/create_test.go b/cmd/create_test.go index bd0c88a..29c8358 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "os" "path/filepath" "testing" @@ -12,14 +13,18 @@ import ( func TestCreateCommand(t *testing.T) { tests := []struct { name string - args []string + input CreateInput 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"}, + 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")) @@ -29,10 +34,14 @@ func TestCreateCommand(t *testing.T) { }, }, { - name: "fail with python sample-app (template not found)", - args: []string{"--name", "test-app", "--language", "python", "--template", "sample-app"}, + name: "fail with invalid template", + input: CreateInput{ + Name: "test-app", + Language: "typescript", + Template: "nonexistent", + }, wantErr: true, - errContains: "template not found: python/sample-app", + errContains: "template not found: typescript/nonexistent", }, } @@ -50,8 +59,8 @@ func TestCreateCommand(t *testing.T) { os.Chdir(orgDir) }) - createCmd.SetArgs(tt.args) - err = createCmd.Execute() + c := CreateCmd{} + err = c.Create(context.Background(), tt.input) // Check if error is expected if tt.wantErr { @@ -65,7 +74,7 @@ func TestCreateCommand(t *testing.T) { require.NoError(t, err, "failed to execute create command") // Validate the created app - appPath := filepath.Join(tmpDir, "test-app") + appPath := filepath.Join(tmpDir, tt.input.Name) assert.DirExists(t, appPath, "app directory should be created") if tt.validate != nil { diff --git a/pkg/create/prompts.go b/pkg/create/prompts.go index 4fdd3e4..1d656a3 100644 --- a/pkg/create/prompts.go +++ b/pkg/create/prompts.go @@ -33,7 +33,7 @@ func validateAppName(val any) error { // handleAppNamePrompt prompts the user for an app name interactively. func handleAppNamePrompt() (string, error) { - promptText := fmt.Sprintf("%s (default: %s)", AppNamePrompt, DefaultAppName) + promptText := fmt.Sprintf("%s (%s)", AppNamePrompt, DefaultAppName) appName, err := pterm.DefaultInteractiveTextInput. WithDefaultText(promptText). Show()