finalize M06-M12 with Go/SQLite/Synology NAS implementation decisions

This commit is contained in:
2026-01-18 20:29:13 +01:00
parent b86ee54934
commit a76724a3d5
7 changed files with 1009 additions and 377 deletions

View File

@@ -1,8 +1,52 @@
# Milestone 11: Developer CLI Tool
**Status**: Planning
**Status**: Decided
**Goal**: Command-line tool for app development workflow.
## Decision
**Go + Cobra** for single-binary cross-platform CLI:
```
Framework: Cobra (github.com/spf13/cobra)
Distribution: Single binary (no runtime dependencies)
Signing: crypto/ed25519 (stdlib)
Auth: OAuth2 device flow + API key storage
Portal URL: Configurable (default: self-hosted Synology)
```
### Rationale
1. **Consistency** - Same language as Portal backend (Go)
2. **Single binary** - No Node.js/Python runtime needed
3. **Fast** - Compiles to native code, instant startup
4. **Cross-platform** - Build for Windows, macOS, Linux from one codebase
5. **Cobra ecosystem** - Shell completions, man pages, help generation
### Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ mosis CLI │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Cobra Commands ││
│ │ ├── init → Template generation ││
│ │ ├── build → ZIP package creation ││
│ │ ├── sign → Ed25519 signing (crypto/ed25519) ││
│ │ ├── run → Launch mosis-designer subprocess ││
│ │ ├── login → OAuth2 device flow → ~/.mosis/credentials ││
│ │ └── publish → HTTP client → Portal API ││
│ └───────────────────────────────────────────────────────────────┘│
│ │ │
│ ~/.mosis/ │ │
│ ├── config.json │ Portal (Synology NAS) │
│ ├── credentials │ ┌──────────────────────────┐ │
│ ├── signing_key.pem ────────┼──│ POST /v1/versions │ │
│ └── signing_key.pub │ │ POST /auth/device │ │
│ │ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Overview
@@ -383,92 +427,161 @@ key will be accepted for review.
---
## Implementation
## Implementation (Go + Cobra)
### Tech Stack Options
#### Option A: Go
### Main Entry Point
```go
// Using cobra for CLI framework
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
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 (default from config)")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Verbose output")
// Commands
rootCmd.AddCommand(initCmd())
rootCmd.AddCommand(buildCmd())
rootCmd.AddCommand(signCmd())
rootCmd.AddCommand(runCmd())
rootCmd.AddCommand(loginCmd())
rootCmd.AddCommand(publishCmd())
// ...
rootCmd.AddCommand(statusCmd())
rootCmd.AddCommand(keysCmd())
rootCmd.AddCommand(configCmd())
rootCmd.Execute()
}
```
**Pros**: Single binary, fast, cross-platform
**Cons**: More code to write
#### Option B: Node.js (oclif)
```typescript
// Using oclif framework
import { Command } from '@oclif/core'
export default class Build extends Command {
static description = 'Build .mosis package'
async run() {
const manifest = await this.readManifest()
const files = await this.collectFiles()
const package = await this.createPackage(files)
this.log(`✓ Package created: ${package.path}`)
}
}
```
**Pros**: Fast development, npm distribution
**Cons**: Requires Node.js runtime
#### Option C: Rust (clap)
```rust
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mosis")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init { name: Option<String> },
Build { output: Option<PathBuf> },
Sign { package: PathBuf },
Publish,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Init { name } => init::run(name),
Commands::Build { output } => build::run(output),
// ...
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
```
**Pros**: Single binary, very fast
**Cons**: Slower development
### Build Command Example
```go
func buildCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "build",
Short: "Create .mosis package",
RunE: func(cmd *cobra.Command, args []string) error {
manifest, err := readManifest("manifest.json")
if err != nil {
return fmt.Errorf("manifest.json not found\n\nAre you in a Mosis project directory?")
}
output, _ := cmd.Flags().GetString("output")
if output == "" {
output = fmt.Sprintf("dist/%s-%s.mosis", manifest.PackageID, manifest.Version)
}
fmt.Printf("Building %s v%s...\n", manifest.Name, manifest.Version)
files, err := collectFiles(manifest)
if err != nil {
return err
}
if err := createPackage(files, output); err != nil {
return 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
},
}
cmd.Flags().StringP("output", "o", "", "Output path (default: dist/)")
return cmd
}
```
### OAuth2 Device Flow (Login)
```go
func loginCmd() *cobra.Command {
return &cobra.Command{
Use: "login",
Short: "Authenticate with developer portal",
RunE: func(cmd *cobra.Command, args []string) error {
portalURL := viper.GetString("portal_url")
// Start device flow
resp, err := http.Post(portalURL+"/auth/device", "application/json", nil)
if err != nil {
return err
}
var device DeviceResponse
json.NewDecoder(resp.Body).Decode(&device)
fmt.Printf("Go to: %s\n", device.VerificationURI)
fmt.Printf("Enter code: %s\n\n", device.UserCode)
fmt.Println("Waiting for authorization...")
// Poll for token
token, err := pollForToken(portalURL, device.DeviceCode, device.Interval)
if err != nil {
return err
}
// Save credentials
if err := saveCredentials(token); err != nil {
return err
}
fmt.Printf("✓ Logged in as %s\n", token.Email)
return nil
},
}
}
```
### Ed25519 Signing
```go
func signPackage(packagePath, keyPath string) error {
// Read private key
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("failed to read key: %w", err)
}
block, _ := pem.Decode(keyPEM)
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("invalid key format: %w", err)
}
ed25519Key := privateKey.(ed25519.PrivateKey)
// Generate MANIFEST.MF with file hashes
manifest, err := generateManifest(packagePath)
if err != nil {
return err
}
// Sign manifest
signature := ed25519.Sign(ed25519Key, manifest)
// Add signature to package
return addSignatureToPackage(packagePath, manifest, signature)
}
---
@@ -577,28 +690,28 @@ jobs:
## Deliverables
- [ ] CLI framework selection
- [ ] `init` command
- [x] CLI framework selected (Go + Cobra)
- [ ] `init` command (template generation)
- [ ] `validate` command
- [ ] `build` command
- [ ] `sign` command
- [ ] `run` command (designer integration)
- [ ] `login/logout` commands
- [ ] `publish` command
- [ ] `build` command (ZIP package creation)
- [ ] `sign` command (Ed25519 signing)
- [ ] `run` command (designer subprocess)
- [ ] `login/logout` commands (OAuth2 device flow)
- [ ] `publish` command (HTTP upload to Portal)
- [ ] `status` command
- [ ] `keys` subcommands
- [ ] Configuration management
- [ ] Distribution packages
- [ ] CI/CD examples
- [ ] `keys` subcommands (generate, register, list)
- [ ] Configuration management (viper)
- [ ] Cross-platform builds (goreleaser)
- [ ] CI/CD examples (GitHub Actions)
---
## Open Questions
1. Should CLI auto-update itself?
2. Offline mode for build/sign?
3. Plugin system for custom commands?
4. IDE integrations (VS Code extension)?
1. ~~Should CLI auto-update itself?~~ → No, manual updates via package manager
2. ~~Offline mode for build/sign?~~ → Yes, build/sign work offline
3. ~~Plugin system for custom commands?~~ → Defer to post-MVP
4. IDE integrations (VS Code extension)? → Consider for v1.1
---