finalize M06-M12 with Go/SQLite/Synology NAS implementation decisions
This commit is contained in:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user