add developer CLI tool with Cobra for app workflow

This commit is contained in:
2026-01-18 21:24:50 +01:00
parent 149736108e
commit cf9f42b66d
12 changed files with 2237 additions and 0 deletions

View File

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

View File

@@ -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/<package-id>-<version>.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
}

View File

@@ -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 <key>",
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 <key> <value>",
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)
}
},
}
}

View File

@@ -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(`<rml>
<head>
<title>%s</title>
<link type="text/rcss" href="styles/theme.rcss"/>
<script src="scripts/app.lua"></script>
</head>
<body>
<div class="container">
<h1>%s</h1>
<p>%s</p>
</div>
</body>
</rml>
`, 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
}

View File

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

View File

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

127
portal/cmd/mosis/cmd/run.go Normal file
View File

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

View File

@@ -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 <package>",
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
}

View File

@@ -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{},
}
}

View File

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

47
portal/cmd/mosis/main.go Normal file
View File

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

View File

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