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/go-playground/validator/v10 v10.19.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/google/uuid v1.6.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/crypto v0.21.0
|
||||||
|
golang.org/x/image v0.15.0
|
||||||
golang.org/x/oauth2 v0.18.0
|
golang.org/x/oauth2 v0.18.0
|
||||||
modernc.org/sqlite v1.29.5
|
modernc.org/sqlite v1.29.5
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user