11package cmd
22
33import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
47 "fmt"
8+ "io"
9+ "mime/multipart"
10+ "net/http"
11+ "net/textproto"
512 "os"
613 "path/filepath"
714 "strings"
@@ -36,6 +43,14 @@ var deployCmd = &cobra.Command{
3643 RunE : runDeploy ,
3744}
3845
46+ // deployGithubCmd deploys directly from a GitHub repository via the SDK Source flow
47+ var deployGithubCmd = & cobra.Command {
48+ Use : "github" ,
49+ Short : "Deploy from a GitHub repository" ,
50+ Args : cobra .NoArgs ,
51+ RunE : runDeployGithub ,
52+ }
53+
3954func init () {
4055 deployCmd .Flags ().String ("version" , "latest" , "Specify a version for the app (default: latest)" )
4156 deployCmd .Flags ().Bool ("force" , false , "Allow overwrite of an existing version with the same name" )
@@ -50,6 +65,133 @@ func init() {
5065
5166 deployHistoryCmd .Flags ().Int ("limit" , 100 , "Max deployments to return (default 100)" )
5267 deployCmd .AddCommand (deployHistoryCmd )
68+
69+ // Flags for GitHub deploy
70+ deployGithubCmd .Flags ().String ("url" , "" , "GitHub repository URL (e.g., https://github.com/org/repo)" )
71+ deployGithubCmd .Flags ().String ("ref" , "" , "Git ref to deploy (branch, tag, or commit SHA)" )
72+ deployGithubCmd .Flags ().String ("entrypoint" , "" , "Entrypoint within the repo/path (e.g., src/index.ts)" )
73+ deployGithubCmd .Flags ().String ("path" , "" , "Optional subdirectory within the repo (e.g., apps/api)" )
74+ deployGithubCmd .Flags ().String ("github-token" , "" , "GitHub token for private repositories (PAT or installation access token)" )
75+ _ = deployGithubCmd .MarkFlagRequired ("url" )
76+ _ = deployGithubCmd .MarkFlagRequired ("ref" )
77+ _ = deployGithubCmd .MarkFlagRequired ("entrypoint" )
78+ deployCmd .AddCommand (deployGithubCmd )
79+ }
80+
81+ func runDeployGithub (cmd * cobra.Command , args []string ) error {
82+ client := getKernelClient (cmd )
83+
84+ repoURL , _ := cmd .Flags ().GetString ("url" )
85+ ref , _ := cmd .Flags ().GetString ("ref" )
86+ entrypoint , _ := cmd .Flags ().GetString ("entrypoint" )
87+ subpath , _ := cmd .Flags ().GetString ("path" )
88+ ghToken , _ := cmd .Flags ().GetString ("github-token" )
89+
90+ version , _ := cmd .Flags ().GetString ("version" )
91+ force , _ := cmd .Flags ().GetBool ("force" )
92+
93+ // Collect env vars similar to runDeploy
94+ envPairs , _ := cmd .Flags ().GetStringArray ("env" )
95+ envFiles , _ := cmd .Flags ().GetStringArray ("env-file" )
96+
97+ envVars := make (map [string ]string )
98+ // Load from files first
99+ for _ , envFile := range envFiles {
100+ fileVars , err := godotenv .Read (envFile )
101+ if err != nil {
102+ return fmt .Errorf ("failed to read env file %s: %w" , envFile , err )
103+ }
104+ for k , v := range fileVars {
105+ envVars [k ] = v
106+ }
107+ }
108+ // Override with --env
109+ for _ , kv := range envPairs {
110+ parts := strings .SplitN (kv , "=" , 2 )
111+ if len (parts ) != 2 {
112+ return fmt .Errorf ("invalid env variable format: %s (expected KEY=value)" , kv )
113+ }
114+ envVars [parts [0 ]] = parts [1 ]
115+ }
116+
117+ // Build the multipart request body directly for source-based deploy
118+
119+ pterm .Info .Println ("Deploying from GitHub source..." )
120+ startTime := time .Now ()
121+
122+ // Manually POST multipart with a JSON 'source' field to match backend expectations
123+ apiKey := os .Getenv ("KERNEL_API_KEY" )
124+ if strings .TrimSpace (apiKey ) == "" {
125+ return fmt .Errorf ("KERNEL_API_KEY is required for github deploy" )
126+ }
127+ baseURL := os .Getenv ("KERNEL_BASE_URL" )
128+ if strings .TrimSpace (baseURL ) == "" {
129+ baseURL = "https://api.onkernel.com"
130+ }
131+
132+ var body bytes.Buffer
133+ mw := multipart .NewWriter (& body )
134+ // regular fields
135+ _ = mw .WriteField ("version" , version )
136+ _ = mw .WriteField ("region" , "aws.us-east-1a" )
137+ if force {
138+ _ = mw .WriteField ("force" , "true" )
139+ } else {
140+ _ = mw .WriteField ("force" , "false" )
141+ }
142+ // env vars as env_vars[KEY]
143+ for k , v := range envVars {
144+ _ = mw .WriteField (fmt .Sprintf ("env_vars[%s]" , k ), v )
145+ }
146+ // source as application/json part
147+ sourcePayload := map [string ]any {
148+ "type" : "github" ,
149+ "url" : repoURL ,
150+ "ref" : ref ,
151+ "entrypoint" : entrypoint ,
152+ }
153+ if strings .TrimSpace (subpath ) != "" {
154+ sourcePayload ["path" ] = subpath
155+ }
156+ if strings .TrimSpace (ghToken ) != "" {
157+ // Add auth only when token is provided to support private repositories
158+ sourcePayload ["auth" ] = map [string ]any {
159+ "method" : "github_token" ,
160+ "token" : ghToken ,
161+ }
162+ }
163+ srcJSON , _ := json .Marshal (sourcePayload )
164+ hdr := textproto.MIMEHeader {}
165+ hdr .Set ("Content-Disposition" , "form-data; name=\" source\" " )
166+ hdr .Set ("Content-Type" , "application/json" )
167+ part , _ := mw .CreatePart (hdr )
168+ _ , _ = part .Write (srcJSON )
169+ _ = mw .Close ()
170+
171+ reqHTTP , _ := http .NewRequestWithContext (cmd .Context (), http .MethodPost , strings .TrimRight (baseURL , "/" )+ "/deployments" , & body )
172+ reqHTTP .Header .Set ("Authorization" , "Bearer " + apiKey )
173+ reqHTTP .Header .Set ("Content-Type" , mw .FormDataContentType ())
174+ httpResp , err := http .DefaultClient .Do (reqHTTP )
175+ if err != nil {
176+ return fmt .Errorf ("post deployments: %w" , err )
177+ }
178+ defer httpResp .Body .Close ()
179+ if httpResp .StatusCode < 200 || httpResp .StatusCode >= 300 {
180+ b , _ := io .ReadAll (httpResp .Body )
181+ return fmt .Errorf ("deployments POST failed: %s: %s" , httpResp .Status , strings .TrimSpace (string (b )))
182+ }
183+ var depCreated struct {
184+ ID string `json:"id"`
185+ }
186+ if err := json .NewDecoder (httpResp .Body ).Decode (& depCreated ); err != nil {
187+ return fmt .Errorf ("decode deployment response: %w" , err )
188+ }
189+
190+ return followDeployment (cmd .Context (), client , depCreated .ID , startTime ,
191+ option .WithBaseURL (baseURL ),
192+ option .WithHeader ("Authorization" , "Bearer " + apiKey ),
193+ option .WithMaxRetries (0 ),
194+ )
53195}
54196
55197func runDeploy (cmd * cobra.Command , args []string ) (err error ) {
@@ -127,54 +269,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) {
127269 return util.CleanedUpSdkError {Err : err }
128270 }
129271
130- // Follow deployment events via SSE
131- stream := client .Deployments .FollowStreaming (cmd .Context (), resp .ID , kernel.DeploymentFollowParams {}, option .WithMaxRetries (0 ))
132- for stream .Next () {
133- data := stream .Current ()
134- switch data .Event {
135- case "log" :
136- logEv := data .AsLog ()
137- msg := strings .TrimSuffix (logEv .Message , "\n " )
138- pterm .Info .Println (pterm .Gray (msg ))
139- case "deployment_state" :
140- deploymentState := data .AsDeploymentState ()
141- status := deploymentState .Deployment .Status
142- if status == string (kernel .DeploymentGetResponseStatusFailed ) ||
143- status == string (kernel .DeploymentGetResponseStatusStopped ) {
144- pterm .Error .Println ("✖ Deployment failed" )
145- pterm .Error .Printf ("Deployment ID: %s\n " , resp .ID )
146- pterm .Info .Printf ("View logs: kernel deploy logs %s --since 1h\n " , resp .ID )
147- err = fmt .Errorf ("deployment %s: %s" , status , deploymentState .Deployment .StatusReason )
148- return err
149- }
150- if status == string (kernel .DeploymentGetResponseStatusRunning ) {
151- duration := time .Since (startTime )
152- pterm .Success .Printfln ("✔ Deployment complete in %s" , duration .Round (time .Millisecond ))
153- return nil
154- }
155- case "app_version_summary" :
156- appVersionSummary := data .AsDeploymentFollowResponseAppVersionSummaryEvent ()
157- pterm .Info .Printf ("App \" %s\" deployed (version: %s)\n " , appVersionSummary .AppName , appVersionSummary .Version )
158- if len (appVersionSummary .Actions ) > 0 {
159- action0Name := appVersionSummary .Actions [0 ].Name
160- pterm .Info .Printf ("Invoke with: kernel invoke %s %s --payload '{...}'\n " , quoteIfNeeded (appVersionSummary .AppName ), quoteIfNeeded (action0Name ))
161- }
162- case "error" :
163- errorEv := data .AsErrorEvent ()
164- pterm .Error .Printf ("Deployment ID: %s\n " , resp .ID )
165- pterm .Info .Printf ("View logs: kernel deploy logs %s --since 1h\n " , resp .ID )
166- err = fmt .Errorf ("%s: %s" , errorEv .Error .Code , errorEv .Error .Message )
167- return err
168- }
169- }
170-
171- if serr := stream .Err (); serr != nil {
172- pterm .Error .Println ("✖ Stream error" )
173- pterm .Error .Printf ("Deployment ID: %s\n " , resp .ID )
174- pterm .Info .Printf ("View logs: kernel deploy logs %s --since 1h\n " , resp .ID )
175- return fmt .Errorf ("stream error: %w" , serr )
176- }
177- return nil
272+ return followDeployment (cmd .Context (), client , resp .ID , startTime , option .WithMaxRetries (0 ))
178273}
179274
180275func quoteIfNeeded (s string ) string {
@@ -320,3 +415,51 @@ AppsLoop:
320415 pterm .DefaultTable .WithHasHeader ().WithData (table ).Render ()
321416 return nil
322417}
418+
419+ func followDeployment (ctx context.Context , client kernel.Client , deploymentID string , startTime time.Time , opts ... option.RequestOption ) error {
420+ stream := client .Deployments .FollowStreaming (ctx , deploymentID , kernel.DeploymentFollowParams {}, opts ... )
421+ for stream .Next () {
422+ data := stream .Current ()
423+ switch data .Event {
424+ case "log" :
425+ logEv := data .AsLog ()
426+ msg := strings .TrimSuffix (logEv .Message , "\n " )
427+ pterm .Info .Println (pterm .Gray (msg ))
428+ case "deployment_state" :
429+ deploymentState := data .AsDeploymentState ()
430+ status := deploymentState .Deployment .Status
431+ if status == string (kernel .DeploymentGetResponseStatusFailed ) ||
432+ status == string (kernel .DeploymentGetResponseStatusStopped ) {
433+ pterm .Error .Println ("✖ Deployment failed" )
434+ pterm .Error .Printf ("Deployment ID: %s\n " , deploymentID )
435+ pterm .Info .Printf ("View logs: kernel deploy logs %s --since 1h\n " , deploymentID )
436+ return fmt .Errorf ("deployment %s: %s" , status , deploymentState .Deployment .StatusReason )
437+ }
438+ if status == string (kernel .DeploymentGetResponseStatusRunning ) {
439+ duration := time .Since (startTime )
440+ pterm .Success .Printfln ("✔ Deployment complete in %s" , duration .Round (time .Millisecond ))
441+ return nil
442+ }
443+ case "app_version_summary" :
444+ appVersionSummary := data .AsDeploymentFollowResponseAppVersionSummaryEvent ()
445+ pterm .Info .Printf ("App \" %s\" deployed (version: %s)\n " , appVersionSummary .AppName , appVersionSummary .Version )
446+ if len (appVersionSummary .Actions ) > 0 {
447+ action0Name := appVersionSummary .Actions [0 ].Name
448+ pterm .Info .Printf ("Invoke with: kernel invoke %s %s --payload '{...}'\n " , quoteIfNeeded (appVersionSummary .AppName ), quoteIfNeeded (action0Name ))
449+ }
450+ case "error" :
451+ errorEv := data .AsErrorEvent ()
452+ pterm .Error .Printf ("Deployment ID: %s\n " , deploymentID )
453+ pterm .Info .Printf ("View logs: kernel deploy logs %s --since 1h\n " , deploymentID )
454+ return fmt .Errorf ("%s: %s" , errorEv .Error .Code , errorEv .Error .Message )
455+ }
456+ }
457+
458+ if serr := stream .Err (); serr != nil {
459+ pterm .Error .Println ("✖ Stream error" )
460+ pterm .Error .Printf ("Deployment ID: %s\n " , deploymentID )
461+ pterm .Info .Printf ("View logs: kernel deploy logs %s --since 1h\n " , deploymentID )
462+ return fmt .Errorf ("stream error: %w" , serr )
463+ }
464+ return nil
465+ }
0 commit comments