add developer CLI tool with Cobra for app workflow
This commit is contained in:
221
portal/cmd/mosis/cmd/auth.go
Normal file
221
portal/cmd/mosis/cmd/auth.go
Normal 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)
|
||||
}
|
||||
250
portal/cmd/mosis/cmd/build.go
Normal file
250
portal/cmd/mosis/cmd/build.go
Normal 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
|
||||
}
|
||||
137
portal/cmd/mosis/cmd/config.go
Normal file
137
portal/cmd/mosis/cmd/config.go
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
312
portal/cmd/mosis/cmd/init.go
Normal file
312
portal/cmd/mosis/cmd/init.go
Normal 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
|
||||
}
|
||||
318
portal/cmd/mosis/cmd/keys.go
Normal file
318
portal/cmd/mosis/cmd/keys.go
Normal 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
|
||||
}
|
||||
249
portal/cmd/mosis/cmd/publish.go
Normal file
249
portal/cmd/mosis/cmd/publish.go
Normal 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
127
portal/cmd/mosis/cmd/run.go
Normal 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
|
||||
}
|
||||
157
portal/cmd/mosis/cmd/sign.go
Normal file
157
portal/cmd/mosis/cmd/sign.go
Normal 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
|
||||
}
|
||||
215
portal/cmd/mosis/cmd/status.go
Normal file
215
portal/cmd/mosis/cmd/status.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
201
portal/cmd/mosis/cmd/validate.go
Normal file
201
portal/cmd/mosis/cmd/validate.go
Normal 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
47
portal/cmd/mosis/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user