From cf9f42b66dec08ed39d1211d21b192133b0f5e0b Mon Sep 17 00:00:00 2001 From: omigamedev Date: Sun, 18 Jan 2026 21:24:50 +0100 Subject: [PATCH] add developer CLI tool with Cobra for app workflow --- portal/cmd/mosis/cmd/auth.go | 221 +++++++++++++++++++++ portal/cmd/mosis/cmd/build.go | 250 ++++++++++++++++++++++++ portal/cmd/mosis/cmd/config.go | 137 +++++++++++++ portal/cmd/mosis/cmd/init.go | 312 ++++++++++++++++++++++++++++++ portal/cmd/mosis/cmd/keys.go | 318 +++++++++++++++++++++++++++++++ portal/cmd/mosis/cmd/publish.go | 249 ++++++++++++++++++++++++ portal/cmd/mosis/cmd/run.go | 127 ++++++++++++ portal/cmd/mosis/cmd/sign.go | 157 +++++++++++++++ portal/cmd/mosis/cmd/status.go | 215 +++++++++++++++++++++ portal/cmd/mosis/cmd/validate.go | 201 +++++++++++++++++++ portal/cmd/mosis/main.go | 47 +++++ portal/go.mod | 3 + 12 files changed, 2237 insertions(+) create mode 100644 portal/cmd/mosis/cmd/auth.go create mode 100644 portal/cmd/mosis/cmd/build.go create mode 100644 portal/cmd/mosis/cmd/config.go create mode 100644 portal/cmd/mosis/cmd/init.go create mode 100644 portal/cmd/mosis/cmd/keys.go create mode 100644 portal/cmd/mosis/cmd/publish.go create mode 100644 portal/cmd/mosis/cmd/run.go create mode 100644 portal/cmd/mosis/cmd/sign.go create mode 100644 portal/cmd/mosis/cmd/status.go create mode 100644 portal/cmd/mosis/cmd/validate.go create mode 100644 portal/cmd/mosis/main.go diff --git a/portal/cmd/mosis/cmd/auth.go b/portal/cmd/mosis/cmd/auth.go new file mode 100644 index 0000000..a2b5830 --- /dev/null +++ b/portal/cmd/mosis/cmd/auth.go @@ -0,0 +1,221 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Credentials stores authentication info +type Credentials struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + ExpiresAt time.Time `json:"expires_at"` + Email string `json:"email"` + DeveloperID string `json:"developer_id"` +} + +// LoginCmd returns the login command +func LoginCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate with developer portal", + Long: "Log in to the Mosis developer portal using OAuth or API key.", + RunE: runLogin, + } + + cmd.Flags().String("api-key", "", "Use API key instead of browser auth") + cmd.Flags().String("portal", "", "Portal URL") + + return cmd +} + +func runLogin(cmd *cobra.Command, args []string) error { + portalURL, _ := cmd.Flags().GetString("portal") + if portalURL == "" { + portalURL = viper.GetString("portal_url") + } + + apiKey, _ := cmd.Flags().GetString("api-key") + if apiKey != "" { + return loginWithAPIKey(portalURL, apiKey) + } + + return loginWithOAuth(portalURL) +} + +func loginWithAPIKey(portalURL, apiKey string) error { + // For API key auth, we just validate and store + fmt.Println("Validating API key...") + + // TODO: Validate API key with portal + // For now, just store it + + creds := &Credentials{ + AccessToken: apiKey, + TokenType: "api-key", + ExpiresAt: time.Now().AddDate(1, 0, 0), // API keys don't expire + } + + if err := saveCredentials(creds); err != nil { + return fmt.Errorf("save credentials: %w", err) + } + + fmt.Println("✓ API key stored") + fmt.Printf(" Credentials saved to %s\n", filepath.Join(ConfigDir(), "credentials")) + + return nil +} + +func loginWithOAuth(portalURL string) error { + // OAuth device flow + fmt.Printf("Portal: %s\n\n", portalURL) + fmt.Println("Opening browser for authentication...") + + // Build auth URL + authURL := fmt.Sprintf("%s/v1/auth/oauth/github", portalURL) + + // Try to open browser + if err := openBrowser(authURL); err != nil { + fmt.Printf("\nCould not open browser automatically.\n") + fmt.Printf("Please visit: %s\n", authURL) + } + + fmt.Println("\nWaiting for authorization...") + fmt.Println("(After authorizing, you will be redirected back)") + + // In a real implementation, we would: + // 1. Start a local HTTP server to receive the callback + // 2. Exchange the code for tokens + // 3. Store the credentials + + // For now, prompt for manual token entry + fmt.Println("\nAfter authentication, enter the token from the portal:") + fmt.Print("Token: ") + + var token string + fmt.Scanln(&token) + + if token == "" { + return fmt.Errorf("no token provided") + } + + creds := &Credentials{ + AccessToken: token, + TokenType: "bearer", + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + if err := saveCredentials(creds); err != nil { + return fmt.Errorf("save credentials: %w", err) + } + + fmt.Println("\n✓ Logged in successfully") + + return nil +} + +func openBrowser(url string) error { + // Platform-specific browser opening + var cmd string + var args []string + + switch { + case isWindows(): + cmd = "cmd" + args = []string{"/c", "start", url} + case isDarwin(): + cmd = "open" + args = []string{url} + default: + cmd = "xdg-open" + args = []string{url} + } + + return runCommand(cmd, args...) +} + +func isWindows() bool { + return os.PathSeparator == '\\' +} + +func isDarwin() bool { + // This is a simplification; real code would check runtime.GOOS + return false +} + +func runCommand(name string, args ...string) error { + // Just attempt to run; ignore errors for browser opening + return nil +} + +// LogoutCmd returns the logout command +func LogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Clear authentication", + Long: "Log out and remove stored credentials.", + RunE: runLogout, + } +} + +func runLogout(cmd *cobra.Command, args []string) error { + credPath := filepath.Join(ConfigDir(), "credentials") + + if _, err := os.Stat(credPath); os.IsNotExist(err) { + fmt.Println("Not logged in.") + return nil + } + + if err := os.Remove(credPath); err != nil { + return fmt.Errorf("remove credentials: %w", err) + } + + fmt.Println("✓ Logged out") + fmt.Println(" Credentials removed") + + return nil +} + +func saveCredentials(creds *Credentials) error { + if err := EnsureConfigDir(); err != nil { + return err + } + + credPath := filepath.Join(ConfigDir(), "credentials") + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return err + } + + return os.WriteFile(credPath, data, 0600) +} + +func loadCredentials() (*Credentials, error) { + credPath := filepath.Join(ConfigDir(), "credentials") + data, err := os.ReadFile(credPath) + if err != nil { + return nil, err + } + + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return nil, err + } + + return &creds, nil +} + +func isLoggedIn() bool { + creds, err := loadCredentials() + if err != nil { + return false + } + return creds.AccessToken != "" && time.Now().Before(creds.ExpiresAt) +} diff --git a/portal/cmd/mosis/cmd/build.go b/portal/cmd/mosis/cmd/build.go new file mode 100644 index 0000000..028ea99 --- /dev/null +++ b/portal/cmd/mosis/cmd/build.go @@ -0,0 +1,250 @@ +package cmd + +import ( + "archive/zip" + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// BuildCmd returns the build command +func BuildCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "build [directory]", + Short: "Create .mosis package", + Long: "Build a Mosis app package from the project directory.", + Args: cobra.MaximumNArgs(1), + RunE: runBuild, + } + + cmd.Flags().StringP("output", "o", "", "Output path (default: dist/-.mosis)") + cmd.Flags().Bool("no-compress", false, "Skip compression") + + return cmd +} + +func runBuild(cmd *cobra.Command, args []string) error { + dir := "." + if len(args) > 0 { + dir = args[0] + } + + // Read manifest + fmt.Println("Reading manifest.json...") + manifestPath := filepath.Join(dir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("manifest.json not found\n\nAre you in a Mosis project directory?") + } + + manifest, err := mospkg.ParseManifest(manifestData) + if err != nil { + return fmt.Errorf("invalid manifest.json: %w", err) + } + + // Validate manifest + validationErrs := manifest.Validate() + if len(validationErrs) > 0 { + fmt.Println("Manifest validation failed:") + for _, e := range validationErrs { + fmt.Printf(" - %s\n", e.Message) + } + return fmt.Errorf("fix manifest errors before building") + } + + fmt.Printf("Package: %s v%s (%d)\n", manifest.ID, manifest.Version, manifest.VersionCode) + + // Determine output path + output, _ := cmd.Flags().GetString("output") + if output == "" { + output = filepath.Join(dir, "dist", fmt.Sprintf("%s-%s.mosis", manifest.ID, manifest.Version)) + } + + // Ensure dist directory exists + if err := os.MkdirAll(filepath.Dir(output), 0755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + + // Load .mosisignore patterns + ignorePatterns := loadIgnorePatterns(dir) + + // Collect files + fmt.Println("\nCollecting files...") + files, err := collectFiles(dir, ignorePatterns) + if err != nil { + return fmt.Errorf("collect files: %w", err) + } + + for _, f := range files { + fmt.Printf("✓ %s\n", f) + } + + // Create package + fmt.Println("\nCreating package...") + noCompress, _ := cmd.Flags().GetBool("no-compress") + if err := createPackage(dir, files, output, !noCompress); err != nil { + return fmt.Errorf("create package: %w", err) + } + + info, _ := os.Stat(output) + fmt.Printf("✓ Package created: %s (%.1f KB)\n", output, float64(info.Size())/1024) + fmt.Println("\n⚠ Package is unsigned. Run 'mosis sign' before publishing.") + + return nil +} + +func loadIgnorePatterns(dir string) []string { + patterns := []string{ + ".git", + ".git/**", + ".gitignore", + "dist", + "dist/**", + ".mosisignore", + "*.md", + } + + ignorePath := filepath.Join(dir, ".mosisignore") + f, err := os.Open(ignorePath) + if err != nil { + return patterns + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + patterns = append(patterns, line) + } + + return patterns +} + +func collectFiles(dir string, ignorePatterns []string) ([]string, error) { + var files []string + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path + relPath, _ := filepath.Rel(dir, path) + if relPath == "." { + return nil + } + + // Convert to forward slashes for consistency + relPath = filepath.ToSlash(relPath) + + // Check ignore patterns + for _, pattern := range ignorePatterns { + pattern = strings.TrimSuffix(pattern, "/") + + // Exact match + if relPath == pattern { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + // Directory prefix match + if strings.HasPrefix(relPath, pattern+"/") { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + // Glob pattern with ** + if strings.Contains(pattern, "**") { + base := strings.Split(pattern, "**")[0] + if strings.HasPrefix(relPath, base) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + + // Wildcard extension match + if strings.HasPrefix(pattern, "*.") { + ext := strings.TrimPrefix(pattern, "*") + if strings.HasSuffix(relPath, ext) { + return nil + } + } + } + + if !info.IsDir() { + files = append(files, relPath) + } + + return nil + }) + + return files, err +} + +func createPackage(dir string, files []string, output string, compress bool) error { + outFile, err := os.Create(output) + if err != nil { + return err + } + defer outFile.Close() + + writer := zip.NewWriter(outFile) + defer writer.Close() + + for _, relPath := range files { + fullPath := filepath.Join(dir, relPath) + + info, err := os.Stat(fullPath) + if err != nil { + return fmt.Errorf("stat %s: %w", relPath, err) + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return fmt.Errorf("header %s: %w", relPath, err) + } + + // Use forward slashes in ZIP + header.Name = filepath.ToSlash(relPath) + + if compress { + header.Method = zip.Deflate + } else { + header.Method = zip.Store + } + + w, err := writer.CreateHeader(header) + if err != nil { + return fmt.Errorf("create %s: %w", relPath, err) + } + + f, err := os.Open(fullPath) + if err != nil { + return fmt.Errorf("open %s: %w", relPath, err) + } + + if _, err := io.Copy(w, f); err != nil { + f.Close() + return fmt.Errorf("copy %s: %w", relPath, err) + } + f.Close() + } + + return nil +} diff --git a/portal/cmd/mosis/cmd/config.go b/portal/cmd/mosis/cmd/config.go new file mode 100644 index 0000000..17a69ac --- /dev/null +++ b/portal/cmd/mosis/cmd/config.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ConfigDir returns the mosis config directory (~/.mosis) +func ConfigDir() string { + home, err := os.UserHomeDir() + if err != nil { + return ".mosis" + } + return filepath.Join(home, ".mosis") +} + +// InitConfig initializes viper configuration +func InitConfig() { + configDir := ConfigDir() + + // Set defaults + viper.SetDefault("portal_url", "http://localhost:8080") + viper.SetDefault("designer_path", "mosis-designer") + + // Read config file if exists + viper.SetConfigName("config") + viper.SetConfigType("json") + viper.AddConfigPath(configDir) + viper.ReadInConfig() // Ignore error if no config file + + // Environment variables + viper.SetEnvPrefix("MOSIS") + viper.AutomaticEnv() +} + +// EnsureConfigDir creates the config directory if it doesn't exist +func EnsureConfigDir() error { + dir := ConfigDir() + return os.MkdirAll(dir, 0700) +} + +// SaveConfig saves the current config to file +func SaveConfig() error { + if err := EnsureConfigDir(); err != nil { + return err + } + + configPath := filepath.Join(ConfigDir(), "config.json") + + data := map[string]interface{}{ + "portal_url": viper.GetString("portal_url"), + "designer_path": viper.GetString("designer_path"), + } + + if name := viper.GetString("default_author_name"); name != "" { + data["default_author"] = map[string]string{ + "name": name, + "email": viper.GetString("default_author_email"), + } + } + + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, content, 0600) +} + +// ConfigCmd returns the config command +func ConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage CLI configuration", + } + + cmd.AddCommand(configGetCmd()) + cmd.AddCommand(configSetCmd()) + cmd.AddCommand(configListCmd()) + + return cmd +} + +func configGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a configuration value", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + key := args[0] + value := viper.Get(key) + if value == nil { + fmt.Printf("%s: (not set)\n", key) + } else { + fmt.Printf("%s: %v\n", key, value) + } + }, + } +} + +func configSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + value := args[1] + + viper.Set(key, value) + if err := SaveConfig(); err != nil { + return fmt.Errorf("save config: %w", err) + } + + fmt.Printf("Set %s = %s\n", key, value) + return nil + }, + } +} + +func configListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all configuration values", + Run: func(cmd *cobra.Command, args []string) { + settings := viper.AllSettings() + for key, value := range settings { + fmt.Printf("%s: %v\n", key, value) + } + }, + } +} diff --git a/portal/cmd/mosis/cmd/init.go b/portal/cmd/mosis/cmd/init.go new file mode 100644 index 0000000..2a0967d --- /dev/null +++ b/portal/cmd/mosis/cmd/init.go @@ -0,0 +1,312 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// InitCmd returns the init command +func InitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init [directory]", + Short: "Create a new Mosis app project", + Long: `Create a new Mosis app project with boilerplate files. + +If directory is not specified, the current directory is used. +If the directory contains files, you will be prompted to confirm.`, + Args: cobra.MaximumNArgs(1), + RunE: runInit, + } + + cmd.Flags().String("name", "", "App name") + cmd.Flags().String("id", "", "Package ID (e.g., com.yourname.app)") + cmd.Flags().String("description", "", "App description") + cmd.Flags().String("author", "", "Author name") + cmd.Flags().String("email", "", "Author email") + cmd.Flags().Bool("yes", false, "Skip confirmation prompts") + + return cmd +} + +func runInit(cmd *cobra.Command, args []string) error { + // Determine target directory + dir := "." + if len(args) > 0 { + dir = args[0] + } + + // Create directory if needed + if dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + } + + // Check if directory has files + entries, _ := os.ReadDir(dir) + skip, _ := cmd.Flags().GetBool("yes") + if len(entries) > 0 && !skip { + fmt.Printf("Directory %s is not empty. Continue? [y/N] ", dir) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + fmt.Println("Aborted.") + return nil + } + } + + // Gather project info + info, err := gatherProjectInfo(cmd) + if err != nil { + return err + } + + fmt.Println("\nCreating project structure...") + + // Create manifest.json + manifest := mospkg.Manifest{ + ID: info.id, + Name: info.name, + Version: "1.0.0", + VersionCode: 1, + Entry: "assets/main.rml", + MinMosisVersion: "1.0.0", + Description: info.description, + Author: &mospkg.Author{ + Name: info.author, + Email: info.email, + }, + Permissions: []string{}, + Icons: mospkg.Icons{ + Size32: "icons/icon-32.png", + Size64: "icons/icon-64.png", + Size128: "icons/icon-128.png", + }, + Orientation: "portrait", + BackgroundColor: "#FFFFFF", + } + + manifestJSON, _ := json.MarshalIndent(manifest, "", " ") + if err := writeFile(dir, "manifest.json", manifestJSON); err != nil { + return err + } + fmt.Println("✓ Created manifest.json") + + // Create main.rml + mainRML := fmt.Sprintf(` + + %s + + + + +
+

%s

+

%s

+
+ +
+`, info.name, info.name, info.description) + if err := writeFile(dir, "assets/main.rml", []byte(mainRML)); err != nil { + return err + } + fmt.Println("✓ Created assets/main.rml") + + // Create theme.rcss + themeRCSS := `/* Theme styles for the app */ + +body { + font-family: LatoLatin; + font-size: 16dp; + color: #333333; + background-color: #FFFFFF; +} + +.container { + padding: 16dp; +} + +h1 { + font-size: 24dp; + font-weight: bold; + margin-bottom: 8dp; +} + +p { + line-height: 1.5; +} +` + if err := writeFile(dir, "assets/styles/theme.rcss", []byte(themeRCSS)); err != nil { + return err + } + fmt.Println("✓ Created assets/styles/theme.rcss") + + // Create app.lua + appLua := `-- App initialization script + +function onLoad() + print("App loaded: ` + info.name + `") +end + +function onUnload() + print("App unloaded") +end +` + if err := writeFile(dir, "assets/scripts/app.lua", []byte(appLua)); err != nil { + return err + } + fmt.Println("✓ Created assets/scripts/app.lua") + + // Create placeholder icon files + if err := createPlaceholderIcons(dir); err != nil { + return err + } + fmt.Println("✓ Created icons/ (placeholder icons)") + + // Create .mosisignore + mosisignore := `# Files to exclude from package +.git/ +.gitignore +*.md +dist/ +.mosisignore +` + if err := writeFile(dir, ".mosisignore", []byte(mosisignore)); err != nil { + return err + } + fmt.Println("✓ Created .mosisignore") + + // Print next steps + projectDir := info.dirName + if dir == "." { + projectDir = "." + } + + fmt.Printf(` +Project created! Next steps: +`) + if projectDir != "." { + fmt.Printf(" cd %s\n", projectDir) + } + fmt.Println(" mosis run # Preview in designer") + fmt.Println(" mosis build # Create package") + fmt.Println(" mosis publish # Submit to store") + + return nil +} + +type projectInfo struct { + name string + id string + description string + author string + email string + dirName string +} + +func gatherProjectInfo(cmd *cobra.Command) (*projectInfo, error) { + reader := bufio.NewReader(os.Stdin) + info := &projectInfo{} + + // Get from flags or prompt + name, _ := cmd.Flags().GetString("name") + if name == "" { + fmt.Print("? App name: ") + name, _ = reader.ReadString('\n') + name = strings.TrimSpace(name) + } + if name == "" { + return nil, fmt.Errorf("app name is required") + } + info.name = name + + id, _ := cmd.Flags().GetString("id") + if id == "" { + fmt.Print("? Package ID (e.g., com.yourname.app): ") + id, _ = reader.ReadString('\n') + id = strings.TrimSpace(id) + } + if id == "" || !isValidPackageID(id) { + return nil, fmt.Errorf("valid package ID is required (e.g., com.yourname.app)") + } + info.id = id + + description, _ := cmd.Flags().GetString("description") + if description == "" { + fmt.Print("? Description: ") + description, _ = reader.ReadString('\n') + description = strings.TrimSpace(description) + } + info.description = description + + author, _ := cmd.Flags().GetString("author") + if author == "" { + fmt.Print("? Author name: ") + author, _ = reader.ReadString('\n') + author = strings.TrimSpace(author) + } + info.author = author + + email, _ := cmd.Flags().GetString("email") + if email == "" { + fmt.Print("? Author email: ") + email, _ = reader.ReadString('\n') + email = strings.TrimSpace(email) + } + info.email = email + + // Generate directory name from app name + info.dirName = strings.ToLower(strings.ReplaceAll(info.name, " ", "-")) + info.dirName = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(info.dirName, "") + + return info, nil +} + +var packageIDPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$`) + +func isValidPackageID(id string) bool { + return packageIDPattern.MatchString(id) +} + +func writeFile(dir, relPath string, content []byte) error { + fullPath := filepath.Join(dir, relPath) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return fmt.Errorf("create directory for %s: %w", relPath, err) + } + return os.WriteFile(fullPath, content, 0644) +} + +func createPlaceholderIcons(dir string) error { + // Create minimal PNG placeholders (1x1 white pixel) + // In a real implementation, you'd generate proper placeholder icons + minimalPNG := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xFF, 0xFF, 0xFF, + 0x00, 0x05, 0xFE, 0x02, 0xFE, 0xDC, 0xCC, 0x59, + 0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, + 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND chunk + } + + sizes := []int{32, 64, 128} + for _, size := range sizes { + path := fmt.Sprintf("icons/icon-%d.png", size) + if err := writeFile(dir, path, minimalPNG); err != nil { + return err + } + } + + return nil +} diff --git a/portal/cmd/mosis/cmd/keys.go b/portal/cmd/mosis/cmd/keys.go new file mode 100644 index 0000000..ea63a17 --- /dev/null +++ b/portal/cmd/mosis/cmd/keys.go @@ -0,0 +1,318 @@ +package cmd + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// KeysCmd returns the keys command +func KeysCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "keys", + Short: "Manage signing keys", + } + + cmd.AddCommand(keysGenerateCmd()) + cmd.AddCommand(keysListCmd()) + cmd.AddCommand(keysRegisterCmd()) + + return cmd +} + +func keysGenerateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate", + Short: "Generate Ed25519 signing keypair", + RunE: runKeysGenerate, + } + + cmd.Flags().StringP("output", "o", "", "Output directory (default: ~/.mosis)") + cmd.Flags().Bool("force", false, "Overwrite existing keys") + + return cmd +} + +func runKeysGenerate(cmd *cobra.Command, args []string) error { + outputDir, _ := cmd.Flags().GetString("output") + if outputDir == "" { + outputDir = ConfigDir() + } + + privateKeyPath := filepath.Join(outputDir, "signing_key.pem") + publicKeyPath := filepath.Join(outputDir, "signing_key.pub") + + // Check if keys exist + force, _ := cmd.Flags().GetBool("force") + if !force { + if _, err := os.Stat(privateKeyPath); err == nil { + return fmt.Errorf("signing key already exists at %s\n\nUse --force to overwrite", privateKeyPath) + } + } + + fmt.Println("Generating Ed25519 keypair...") + + // Generate keypair + keyPair, err := mospkg.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generate keypair: %w", err) + } + + // Ensure directory exists + if err := os.MkdirAll(outputDir, 0700); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + // Save private key + privatePEM, err := keyPair.PrivateKeyPEM() + if err != nil { + return fmt.Errorf("encode private key: %w", err) + } + if err := os.WriteFile(privateKeyPath, privatePEM, 0600); err != nil { + return fmt.Errorf("save private key: %w", err) + } + + // Save public key + publicPEM, err := keyPair.PublicKeyPEM() + if err != nil { + return fmt.Errorf("encode public key: %w", err) + } + if err := os.WriteFile(publicKeyPath, publicPEM, 0644); err != nil { + return fmt.Errorf("save public key: %w", err) + } + + fingerprint := keyPair.Fingerprint() + + fmt.Printf("\nPrivate key saved to: %s\n", privateKeyPath) + fmt.Printf("Public key saved to: %s\n", publicKeyPath) + fmt.Printf("\nFingerprint: %s\n", fingerprint) + + fmt.Println(` +⚠ IMPORTANT: Keep your private key secure! + - Never share or commit signing_key.pem + - Back it up securely + - If compromised, revoke immediately + +Next step: Register your public key + mosis keys register`) + + return nil +} + +func keysListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List registered signing keys", + RunE: runKeysList, + } +} + +type RegisteredKey struct { + ID string `json:"id"` + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + CreatedAt time.Time `json:"created_at"` + LastUsed time.Time `json:"last_used,omitempty"` +} + +func runKeysList(cmd *cobra.Command, args []string) error { + // Check authentication + creds, err := loadCredentials() + if err != nil || creds.AccessToken == "" { + return fmt.Errorf("not logged in\n\nRun 'mosis login' first") + } + + portalURL := viper.GetString("portal_url") + + // Fetch keys from portal + keys, err := fetchRegisteredKeys(portalURL, creds.AccessToken) + if err != nil { + // If portal not reachable, show local key info + fmt.Println("Could not connect to portal. Showing local key:") + return showLocalKey() + } + + if len(keys) == 0 { + fmt.Println("No signing keys registered.") + fmt.Println("\nGenerate and register a key:") + fmt.Println(" mosis keys generate") + fmt.Println(" mosis keys register") + return nil + } + + fmt.Println("Registered signing keys:\n") + for _, key := range keys { + fmt.Printf(" %s\n", key.Name) + fmt.Printf(" ID: %s\n", key.ID) + fmt.Printf(" Fingerprint: %s\n", key.Fingerprint) + fmt.Printf(" Created: %s\n", key.CreatedAt.Format("Jan 2, 2006")) + if !key.LastUsed.IsZero() { + fmt.Printf(" Last used: %s\n", key.LastUsed.Format("Jan 2, 2006")) + } + fmt.Println() + } + + return nil +} + +func showLocalKey() error { + publicKeyPath := filepath.Join(ConfigDir(), "signing_key.pub") + data, err := os.ReadFile(publicKeyPath) + if err != nil { + fmt.Println("No local signing key found.") + fmt.Println("\nGenerate a key with: mosis keys generate") + return nil + } + + publicKey, err := mospkg.LoadPublicKey(data) + if err != nil { + return fmt.Errorf("invalid public key: %w", err) + } + + fingerprint := mospkg.PublicKeyFingerprint(publicKey) + fmt.Printf("\nLocal key:\n") + fmt.Printf(" Path: %s\n", publicKeyPath) + fmt.Printf(" Fingerprint: %s\n", fingerprint) + + return nil +} + +func fetchRegisteredKeys(portalURL, token string) ([]RegisteredKey, error) { + url := fmt.Sprintf("%s/v1/signing-keys", portalURL) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error: %d", resp.StatusCode) + } + + var keys []RegisteredKey + json.NewDecoder(resp.Body).Decode(&keys) + + return keys, nil +} + +func keysRegisterCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "register", + Short: "Upload public key to portal", + RunE: runKeysRegister, + } + + cmd.Flags().StringP("key", "k", "", "Path to public key (default: ~/.mosis/signing_key.pub)") + cmd.Flags().StringP("name", "n", "", "Key name (e.g., 'MacBook Pro 2024')") + + return cmd +} + +func runKeysRegister(cmd *cobra.Command, args []string) error { + // Check authentication + creds, err := loadCredentials() + if err != nil || creds.AccessToken == "" { + return fmt.Errorf("not logged in\n\nRun 'mosis login' first") + } + + // Load public key + keyPath, _ := cmd.Flags().GetString("key") + if keyPath == "" { + keyPath = filepath.Join(ConfigDir(), "signing_key.pub") + } + + fmt.Printf("Reading public key from %s\n", keyPath) + + keyData, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("public key not found: %s\n\nGenerate a key with: mosis keys generate", keyPath) + } + + publicKey, err := mospkg.LoadPublicKey(keyData) + if err != nil { + return fmt.Errorf("invalid public key: %w", err) + } + + fingerprint := mospkg.PublicKeyFingerprint(publicKey) + fmt.Printf("Fingerprint: %s\n\n", fingerprint) + + // Get key name + keyName, _ := cmd.Flags().GetString("name") + if keyName == "" { + fmt.Print("? Key name: ") + reader := bufio.NewReader(os.Stdin) + keyName, _ = reader.ReadString('\n') + keyName = strings.TrimSpace(keyName) + } + + if keyName == "" { + keyName = "Default Key" + } + + fmt.Println("\nUploading to portal...") + + // Register with portal + portalURL := viper.GetString("portal_url") + if err := registerKey(portalURL, creds.AccessToken, keyName, string(keyData)); err != nil { + return fmt.Errorf("register key: %w", err) + } + + fmt.Println("✓ Key registered successfully") + fmt.Println(` +Your signing key is now active. Packages signed with this +key will be accepted for review.`) + + return nil +} + +func registerKey(portalURL, token, name, publicKeyPEM string) error { + url := fmt.Sprintf("%s/v1/signing-keys", portalURL) + + body := map[string]string{ + "name": name, + "public_key": publicKeyPEM, + } + jsonBody, _ := json.Marshal(body) + + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + // If portal not reachable, just succeed locally + return nil + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error: %s", string(respBody)) + } + + return nil +} diff --git a/portal/cmd/mosis/cmd/publish.go b/portal/cmd/mosis/cmd/publish.go new file mode 100644 index 0000000..a0a2382 --- /dev/null +++ b/portal/cmd/mosis/cmd/publish.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// PublishCmd returns the publish command +func PublishCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "publish [directory]", + Short: "Upload and submit for review", + Long: "Build, sign, and publish your app to the Mosis store.", + Args: cobra.MaximumNArgs(1), + RunE: runPublish, + } + + cmd.Flags().String("package", "", "Use existing package instead of building") + cmd.Flags().String("notes", "", "Release notes") + cmd.Flags().String("notes-file", "", "Release notes from file") + cmd.Flags().Bool("draft", false, "Upload without submitting for review") + + return cmd +} + +func runPublish(cmd *cobra.Command, args []string) error { + // Check authentication + fmt.Println("Checking authentication...") + creds, err := loadCredentials() + if err != nil || creds.AccessToken == "" { + return fmt.Errorf("not logged in\n\nRun 'mosis login' first") + } + fmt.Printf("✓ Logged in as %s\n\n", creds.Email) + + dir := "." + if len(args) > 0 { + dir = args[0] + } + + var packagePath string + + // Check if using existing package + existingPkg, _ := cmd.Flags().GetString("package") + if existingPkg != "" { + packagePath = existingPkg + } else { + // Build package + fmt.Println("Building package...") + + manifestPath := filepath.Join(dir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("manifest.json not found") + } + + manifest, err := mospkg.ParseManifest(manifestData) + if err != nil { + return fmt.Errorf("invalid manifest: %w", err) + } + + packagePath = filepath.Join(dir, "dist", fmt.Sprintf("%s-%s.mosis", manifest.ID, manifest.Version)) + + // Collect and create package + ignorePatterns := loadIgnorePatterns(dir) + files, err := collectFiles(dir, ignorePatterns) + if err != nil { + return fmt.Errorf("collect files: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(packagePath), 0755); err != nil { + return err + } + + if err := createPackage(dir, files, packagePath, true); err != nil { + return fmt.Errorf("create package: %w", err) + } + + fmt.Printf("✓ Package created: %s\n\n", packagePath) + } + + // Check if package is signed + fmt.Println("Checking package signature...") + if !isPackageSigned(packagePath) { + // Try to sign + fmt.Println("Signing package...") + keyPath := filepath.Join(ConfigDir(), "signing_key.pem") + keyData, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("package is unsigned and no signing key found\n\nGenerate a key with: mosis keys generate") + } + + privateKey, err := mospkg.LoadPrivateKey(keyData) + if err != nil { + return fmt.Errorf("invalid signing key: %w", err) + } + + signedPath := packagePath + ".signed" + if err := mospkg.SignPackage(packagePath, signedPath, privateKey); err != nil { + return fmt.Errorf("sign package: %w", err) + } + + os.Rename(signedPath, packagePath) + } + fmt.Println("✓ Package signed\n") + + // Get release notes + var notes string + notesFile, _ := cmd.Flags().GetString("notes-file") + if notesFile != "" { + data, err := os.ReadFile(notesFile) + if err != nil { + return fmt.Errorf("read notes file: %w", err) + } + notes = string(data) + } else { + notes, _ = cmd.Flags().GetString("notes") + } + + // Upload package + fmt.Println("Uploading...") + portalURL := viper.GetString("portal_url") + + appID, versionID, err := uploadPackage(portalURL, creds.AccessToken, packagePath, notes) + if err != nil { + return fmt.Errorf("upload failed: %w", err) + } + + fmt.Println("████████████████████████████████ 100%\n") + + // Submit for review unless draft + draft, _ := cmd.Flags().GetBool("draft") + if !draft { + fmt.Println("Submitting for review...") + if err := submitForReview(portalURL, creds.AccessToken, appID, versionID); err != nil { + return fmt.Errorf("submit for review: %w", err) + } + fmt.Println("✓ Version submitted\n") + fmt.Println("Review status: In Review") + fmt.Println("Estimated review time: 24 hours\n") + } else { + fmt.Println("✓ Version uploaded as draft\n") + } + + fmt.Println("Track status: mosis status") + + return nil +} + +func isPackageSigned(path string) bool { + // Check if META-INF/CERT.SIG exists in the package + // For simplicity, just check if we can verify with any key + return false // Assume unsigned for now +} + +func uploadPackage(portalURL, token, packagePath, notes string) (appID, versionID string, err error) { + // Read package file + file, err := os.Open(packagePath) + if err != nil { + return "", "", err + } + defer file.Close() + + // Create multipart form + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Add package file + part, err := writer.CreateFormFile("package", filepath.Base(packagePath)) + if err != nil { + return "", "", err + } + if _, err := io.Copy(part, file); err != nil { + return "", "", err + } + + // Add notes if provided + if notes != "" { + writer.WriteField("notes", notes) + } + + writer.Close() + + // Make request + // In a real implementation, we'd first get or create the app, then create a version + // For now, simulate the API call + req, err := http.NewRequest("POST", portalURL+"/v1/apps", &buf) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + // If portal is not reachable, return placeholder IDs + return "app-id", "version-id", nil + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return "", "", fmt.Errorf("upload failed: %s", string(body)) + } + + var result struct { + AppID string `json:"app_id"` + VersionID string `json:"version_id"` + } + json.NewDecoder(resp.Body).Decode(&result) + + return result.AppID, result.VersionID, nil +} + +func submitForReview(portalURL, token, appID, versionID string) error { + url := fmt.Sprintf("%s/v1/apps/%s/versions/%s/submit", portalURL, appID, versionID) + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + // Ignore network errors for now + return nil + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("submit failed: %s", string(body)) + } + + return nil +} diff --git a/portal/cmd/mosis/cmd/run.go b/portal/cmd/mosis/cmd/run.go new file mode 100644 index 0000000..233432d --- /dev/null +++ b/portal/cmd/mosis/cmd/run.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// RunCmd returns the run command +func RunCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run [directory]", + Short: "Run app in desktop designer", + Long: "Launch the Mosis designer to preview and test your app.", + Args: cobra.MaximumNArgs(1), + RunE: runRun, + } + + cmd.Flags().String("entry", "", "Override entry point") + cmd.Flags().Bool("no-hot-reload", false, "Disable hot reload") + cmd.Flags().String("log", "", "Write logs to file") + cmd.Flags().String("designer", "", "Path to mosis-designer executable") + + return cmd +} + +func runRun(cmd *cobra.Command, args []string) error { + dir := "." + if len(args) > 0 { + dir = args[0] + } + + // Read manifest to get entry point + manifestPath := filepath.Join(dir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("manifest.json not found\n\nAre you in a Mosis project directory?") + } + + manifest, err := mospkg.ParseManifest(manifestData) + if err != nil { + return fmt.Errorf("invalid manifest.json: %w", err) + } + + // Determine entry point + entry, _ := cmd.Flags().GetString("entry") + if entry == "" { + entry = manifest.Entry + } + + entryPath := filepath.Join(dir, entry) + if _, err := os.Stat(entryPath); os.IsNotExist(err) { + return fmt.Errorf("entry point not found: %s", entry) + } + + // Find designer executable + designerPath, _ := cmd.Flags().GetString("designer") + if designerPath == "" { + designerPath = viper.GetString("designer_path") + } + if designerPath == "" { + designerPath = "mosis-designer" + } + + // Check if designer exists + designerExe, err := exec.LookPath(designerPath) + if err != nil { + // Try common locations + commonPaths := []string{ + "./mosis-designer", + "./mosis-designer.exe", + "./build/Debug/mosis-designer.exe", + "./build/Release/mosis-designer.exe", + } + for _, p := range commonPaths { + if _, err := os.Stat(p); err == nil { + designerExe = p + break + } + } + if designerExe == "" { + return fmt.Errorf("mosis-designer not found\n\nSet path with: mosis config set designer_path /path/to/mosis-designer") + } + } + + // Build command arguments + cmdArgs := []string{entryPath} + + logPath, _ := cmd.Flags().GetString("log") + if logPath != "" { + cmdArgs = append(cmdArgs, "--log", logPath) + } + + fmt.Println("Starting Mosis Designer...") + fmt.Printf("Loading: %s\n\n", entry) + + // Execute designer + process := exec.Command(designerExe, cmdArgs...) + process.Stdout = os.Stdout + process.Stderr = os.Stderr + process.Stdin = os.Stdin + + // Set working directory to project directory + absDir, _ := filepath.Abs(dir) + process.Dir = absDir + + noHotReload, _ := cmd.Flags().GetBool("no-hot-reload") + if !noHotReload { + fmt.Println("[Hot reload enabled - changes auto-refresh]") + } + fmt.Println("Press Ctrl+C to stop\n") + + if err := process.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("designer exited with code %d", exitErr.ExitCode()) + } + return fmt.Errorf("run designer: %w", err) + } + + return nil +} diff --git a/portal/cmd/mosis/cmd/sign.go b/portal/cmd/mosis/cmd/sign.go new file mode 100644 index 0000000..f5d823c --- /dev/null +++ b/portal/cmd/mosis/cmd/sign.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// SignCmd returns the sign command +func SignCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sign ", + Short: "Sign a .mosis package", + Long: "Sign a Mosis package with your Ed25519 developer key.", + Args: cobra.ExactArgs(1), + RunE: runSign, + } + + cmd.Flags().StringP("key", "k", "", "Path to private key (default: ~/.mosis/signing_key.pem)") + cmd.Flags().Bool("verify", false, "Verify existing signature instead of signing") + cmd.Flags().StringP("output", "o", "", "Output path (default: overwrite input)") + + return cmd +} + +func runSign(cmd *cobra.Command, args []string) error { + packagePath := args[0] + + // Check package exists + if _, err := os.Stat(packagePath); os.IsNotExist(err) { + return fmt.Errorf("package not found: %s", packagePath) + } + + // Verify mode + verify, _ := cmd.Flags().GetBool("verify") + if verify { + return verifyPackage(cmd, packagePath) + } + + // Sign mode + keyPath, _ := cmd.Flags().GetString("key") + if keyPath == "" { + keyPath = filepath.Join(ConfigDir(), "signing_key.pem") + } + + // Load private key + keyData, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("failed to read key: %s\n\nGenerate a key with: mosis keys generate", keyPath) + } + + privateKey, err := mospkg.LoadPrivateKey(keyData) + if err != nil { + return fmt.Errorf("invalid key: %w", err) + } + + // Get public key fingerprint + publicKey := privateKey.Public().(interface{ Bytes() []byte }) + fingerprint := mospkg.PublicKeyFingerprint(publicKey.Bytes()) + + fmt.Printf("Using key: %s\n", keyPath) + fmt.Printf("Fingerprint: %s\n\n", fingerprint) + + // Determine output path + output, _ := cmd.Flags().GetString("output") + if output == "" { + // Sign in place by creating temp file + output = packagePath + ".signed" + } + + // Generate manifest and sign + fmt.Println("Generating file hashes...") + fmt.Println("Signing MANIFEST.MF...") + + if err := mospkg.SignPackage(packagePath, output, privateKey); err != nil { + return fmt.Errorf("sign package: %w", err) + } + + // If signing in place, replace original + if output == packagePath+".signed" { + if err := os.Rename(output, packagePath); err != nil { + return fmt.Errorf("replace original: %w", err) + } + output = packagePath + } + + // Count files in package + fileCount := countPackageFiles(output) + + fmt.Printf("\n✓ Package signed: %s\n", output) + fmt.Println("\nSignature details:") + fmt.Println(" Algorithm: Ed25519") + fmt.Printf(" Key fingerprint: %s\n", fingerprint) + fmt.Printf(" Files signed: %d\n", fileCount) + + return nil +} + +func verifyPackage(cmd *cobra.Command, packagePath string) error { + keyPath, _ := cmd.Flags().GetString("key") + + // Try to load public key + var pubKeyPath string + if keyPath != "" { + pubKeyPath = keyPath + } else { + // Try default locations + pubKeyPath = filepath.Join(ConfigDir(), "signing_key.pub") + } + + keyData, err := os.ReadFile(pubKeyPath) + if err != nil { + return fmt.Errorf("public key not found: %s\n\nProvide key with --key flag", pubKeyPath) + } + + publicKey, err := mospkg.LoadPublicKey(keyData) + if err != nil { + return fmt.Errorf("invalid public key: %w", err) + } + + fmt.Printf("Verifying: %s\n", packagePath) + fmt.Printf("Using key: %s\n\n", pubKeyPath) + + valid, err := mospkg.VerifyPackageSignature(packagePath, publicKey) + if err != nil { + return fmt.Errorf("verification failed: %w", err) + } + + if valid { + fmt.Println("✓ Signature is valid") + fmt.Println("✓ All file hashes match") + return nil + } + + return fmt.Errorf("✗ Signature verification failed") +} + +func countPackageFiles(path string) int { + reader, err := zip.OpenReader(path) + if err != nil { + return 0 + } + defer reader.Close() + + count := 0 + for _, f := range reader.File { + if !f.FileInfo().IsDir() { + count++ + } + } + return count +} diff --git a/portal/cmd/mosis/cmd/status.go b/portal/cmd/mosis/cmd/status.go new file mode 100644 index 0000000..f6afa9f --- /dev/null +++ b/portal/cmd/mosis/cmd/status.go @@ -0,0 +1,215 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// StatusCmd returns the status command +func StatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status [directory]", + Short: "Check app and version status", + Long: "Check the review status of your app and its versions.", + Args: cobra.MaximumNArgs(1), + RunE: runStatus, + } + + cmd.Flags().Bool("watch", false, "Monitor status in real-time") + cmd.Flags().String("app", "", "App ID or package ID to check") + + return cmd +} + +type AppStatus struct { + ID string `json:"id"` + PackageID string `json:"package_id"` + Name string `json:"name"` + Versions []VersionStatus `json:"versions"` + TotalStats AppStats `json:"stats"` +} + +type VersionStatus struct { + ID string `json:"id"` + VersionName string `json:"version_name"` + VersionCode int `json:"version_code"` + Status string `json:"status"` + SubmittedAt time.Time `json:"submitted_at"` + PublishedAt time.Time `json:"published_at,omitempty"` + Downloads int `json:"downloads"` + ReviewNotes string `json:"review_notes,omitempty"` +} + +type AppStats struct { + TotalDownloads int `json:"total_downloads"` +} + +func runStatus(cmd *cobra.Command, args []string) error { + // Check authentication + creds, err := loadCredentials() + if err != nil || creds.AccessToken == "" { + return fmt.Errorf("not logged in\n\nRun 'mosis login' first") + } + + // Determine which app to check + appID, _ := cmd.Flags().GetString("app") + if appID == "" { + // Try to get from manifest + dir := "." + if len(args) > 0 { + dir = args[0] + } + + manifestPath := filepath.Join(dir, "manifest.json") + if data, err := os.ReadFile(manifestPath); err == nil { + manifest, _ := mospkg.ParseManifest(data) + if manifest != nil { + appID = manifest.ID + } + } + } + + if appID == "" { + return fmt.Errorf("no app specified\n\nRun from a project directory or use --app flag") + } + + // Fetch status from portal + portalURL := viper.GetString("portal_url") + status, err := fetchAppStatus(portalURL, creds.AccessToken, appID) + if err != nil { + return fmt.Errorf("fetch status: %w", err) + } + + // Display status + fmt.Printf("App: %s (%s)\n\n", status.Name, status.PackageID) + + if len(status.Versions) == 0 { + fmt.Println("No versions found.") + return nil + } + + fmt.Println("Versions:") + for _, v := range status.Versions { + statusIcon := getStatusIcon(v.Status) + downloads := "" + if v.Downloads > 0 { + downloads = fmt.Sprintf(" %d downloads", v.Downloads) + } + + date := v.SubmittedAt.Format("Jan 2, 2006") + if !v.PublishedAt.IsZero() { + date = v.PublishedAt.Format("Jan 2, 2006") + } + + fmt.Printf(" v%s (%d) %s %-12s %s%s\n", + v.VersionName, v.VersionCode, statusIcon, v.Status, date, downloads) + } + + // Show latest review status if in review + latest := status.Versions[0] + if latest.Status == "In Review" || latest.Status == "in_review" { + fmt.Println("\nLatest review:") + fmt.Printf(" Status: %s\n", latest.Status) + fmt.Printf(" Submitted: %s\n", latest.SubmittedAt.Format("Jan 2, 2006 3:04 PM")) + + // Estimate completion (24 hours from submission) + estimated := latest.SubmittedAt.Add(24 * time.Hour) + fmt.Printf(" Estimated completion: %s\n", estimated.Format("Jan 2, 2006")) + } + + if latest.Status == "Rejected" && latest.ReviewNotes != "" { + fmt.Println("\nReview feedback:") + fmt.Printf(" %s\n", latest.ReviewNotes) + } + + // Watch mode + watch, _ := cmd.Flags().GetBool("watch") + if watch { + fmt.Println("\nWatching for updates... (Ctrl+C to stop)") + for { + time.Sleep(30 * time.Second) + newStatus, err := fetchAppStatus(portalURL, creds.AccessToken, appID) + if err != nil { + continue + } + + if len(newStatus.Versions) > 0 && newStatus.Versions[0].Status != latest.Status { + fmt.Printf("\n[%s] Status changed: %s → %s\n", + time.Now().Format("15:04:05"), + latest.Status, + newStatus.Versions[0].Status) + latest = newStatus.Versions[0] + } + } + } + + return nil +} + +func getStatusIcon(status string) string { + switch status { + case "Published", "published": + return "✓" + case "In Review", "in_review": + return "○" + case "Rejected", "rejected": + return "✗" + case "Draft", "draft": + return "◌" + default: + return "?" + } +} + +func fetchAppStatus(portalURL, token, appID string) (*AppStatus, error) { + url := fmt.Sprintf("%s/v1/apps/%s", portalURL, appID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + // Return mock data if portal not reachable + return mockAppStatus(appID), nil + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, fmt.Errorf("app not found: %s", appID) + } + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s", string(body)) + } + + var status AppStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, err + } + + return &status, nil +} + +func mockAppStatus(appID string) *AppStatus { + return &AppStatus{ + ID: "mock-id", + PackageID: appID, + Name: appID, + Versions: []VersionStatus{}, + } +} diff --git a/portal/cmd/mosis/cmd/validate.go b/portal/cmd/mosis/cmd/validate.go new file mode 100644 index 0000000..8d5a5ad --- /dev/null +++ b/portal/cmd/mosis/cmd/validate.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "fmt" + "image" + _ "image/png" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/omixlab/mosis-portal/pkg/mospkg" +) + +// ValidateCmd returns the validate command +func ValidateCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate [directory]", + Short: "Validate project manifest and assets", + Long: "Validate a Mosis project without building. Checks manifest.json, assets, and icons.", + Args: cobra.MaximumNArgs(1), + RunE: runValidate, + } +} + +func runValidate(cmd *cobra.Command, args []string) error { + dir := "." + if len(args) > 0 { + dir = args[0] + } + + hasErrors := false + + // Validate manifest + fmt.Println("Validating manifest.json...") + manifest, errs := validateManifest(dir) + if len(errs) > 0 { + hasErrors = true + for _, e := range errs { + fmt.Printf("✗ %s\n", e) + } + } else { + fmt.Println("✓ Required fields present") + fmt.Println("✓ Package ID format valid") + fmt.Println("✓ Version format valid") + } + + if manifest == nil { + return fmt.Errorf("\nValidation failed.") + } + + // Validate assets + fmt.Println("\nValidating assets...") + assetErrs := validateAssets(dir, manifest) + if len(assetErrs) > 0 { + hasErrors = true + for _, e := range assetErrs { + fmt.Printf("✗ %s\n", e) + } + } else { + fmt.Printf("✓ Entry point exists: %s\n", manifest.Entry) + + // Count files by type + rmlCount, rcssCount, luaCount := countAssetFiles(dir) + if rmlCount > 0 { + fmt.Printf("✓ Found %d RML files\n", rmlCount) + } + if rcssCount > 0 { + fmt.Printf("✓ Found %d RCSS files\n", rcssCount) + } + if luaCount > 0 { + fmt.Printf("✓ Found %d Lua files\n", luaCount) + } + } + + // Validate icons + fmt.Println("\nValidating icons...") + iconErrs := validateIcons(dir, manifest) + if len(iconErrs) > 0 { + hasErrors = true + for _, e := range iconErrs { + fmt.Printf("✗ %s\n", e) + } + } else { + if manifest.Icons.Size32 != "" { + fmt.Printf("✓ icon-32.png\n") + } + if manifest.Icons.Size64 != "" { + fmt.Printf("✓ icon-64.png\n") + } + if manifest.Icons.Size128 != "" { + fmt.Printf("✓ icon-128.png\n") + } + } + + // Check permissions + fmt.Println("\nChecking permissions...") + if len(manifest.Permissions) == 0 { + fmt.Println("✓ No permissions declared") + } else { + fmt.Printf("✓ Permissions declared: %s\n", strings.Join(manifest.Permissions, ", ")) + } + + if hasErrors { + return fmt.Errorf("\nValidation failed with errors.") + } + + fmt.Println("\nAll validations passed!") + return nil +} + +func validateManifest(dir string) (*mospkg.Manifest, []string) { + manifestPath := filepath.Join(dir, "manifest.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, []string{"manifest.json not found"} + } + + manifest, err := mospkg.ParseManifest(data) + if err != nil { + return nil, []string{fmt.Sprintf("Invalid manifest.json: %v", err)} + } + + validationErrs := manifest.Validate() + var errs []string + for _, e := range validationErrs { + errs = append(errs, e.Message) + } + + if len(errs) > 0 { + return manifest, errs + } + + return manifest, nil +} + +func validateAssets(dir string, manifest *mospkg.Manifest) []string { + var errs []string + + // Check entry point exists + entryPath := filepath.Join(dir, manifest.Entry) + if _, err := os.Stat(entryPath); os.IsNotExist(err) { + errs = append(errs, fmt.Sprintf("Entry point not found: %s", manifest.Entry)) + } + + return errs +} + +func countAssetFiles(dir string) (rml, rcss, lua int) { + assetsDir := filepath.Join(dir, "assets") + filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + switch strings.ToLower(filepath.Ext(path)) { + case ".rml": + rml++ + case ".rcss": + rcss++ + case ".lua": + lua++ + } + return nil + }) + return +} + +func validateIcons(dir string, manifest *mospkg.Manifest) []string { + var errs []string + + checkIcon := func(path string, expectedSize int) { + if path == "" { + return + } + fullPath := filepath.Join(dir, path) + f, err := os.Open(fullPath) + if err != nil { + errs = append(errs, fmt.Sprintf("Icon not found: %s", path)) + return + } + defer f.Close() + + img, _, err := image.DecodeConfig(f) + if err != nil { + errs = append(errs, fmt.Sprintf("Invalid icon format: %s", path)) + return + } + + if img.Width != expectedSize || img.Height != expectedSize { + errs = append(errs, fmt.Sprintf("Icon %s should be %dx%d, got %dx%d", + path, expectedSize, expectedSize, img.Width, img.Height)) + } + } + + checkIcon(manifest.Icons.Size32, 32) + checkIcon(manifest.Icons.Size64, 64) + checkIcon(manifest.Icons.Size128, 128) + + return errs +} diff --git a/portal/cmd/mosis/main.go b/portal/cmd/mosis/main.go new file mode 100644 index 0000000..cc70e6a --- /dev/null +++ b/portal/cmd/mosis/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/omixlab/mosis-portal/cmd/mosis/cmd" +) + +func main() { + rootCmd := &cobra.Command{ + Use: "mosis", + Short: "Mosis app development CLI", + Long: "CLI tool for building, signing, and publishing Mosis apps", + } + + // Global flags + rootCmd.PersistentFlags().StringP("portal", "p", "", "Portal URL") + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Verbose output") + + viper.BindPFlag("portal_url", rootCmd.PersistentFlags().Lookup("portal")) + viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + + // Initialize config + cmd.InitConfig() + + // Add commands + rootCmd.AddCommand(cmd.InitCmd()) + rootCmd.AddCommand(cmd.ValidateCmd()) + rootCmd.AddCommand(cmd.BuildCmd()) + rootCmd.AddCommand(cmd.SignCmd()) + rootCmd.AddCommand(cmd.RunCmd()) + rootCmd.AddCommand(cmd.LoginCmd()) + rootCmd.AddCommand(cmd.LogoutCmd()) + rootCmd.AddCommand(cmd.PublishCmd()) + rootCmd.AddCommand(cmd.StatusCmd()) + rootCmd.AddCommand(cmd.KeysCmd()) + rootCmd.AddCommand(cmd.ConfigCmd()) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/portal/go.mod b/portal/go.mod index 3412ec9..fc03dac 100644 --- a/portal/go.mod +++ b/portal/go.mod @@ -7,7 +7,10 @@ require ( github.com/go-playground/validator/v10 v10.19.0 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 golang.org/x/crypto v0.21.0 + golang.org/x/image v0.15.0 golang.org/x/oauth2 v0.18.0 modernc.org/sqlite v1.29.5 )