feat: initialize independent Sense product skeleton (#21)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type moduleRegistrar func(*platform.App) error
|
||||
|
||||
var moduleRegistrars []moduleRegistrar
|
||||
|
||||
// registerModule is intentionally package-local. Feature integration files in
|
||||
// this directory append one registrar without changing the shared bootstrap.
|
||||
func registerModule(registrar moduleRegistrar) {
|
||||
moduleRegistrars = append(moduleRegistrars, registrar)
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
cfg, err := platform.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel()}))
|
||||
database, err := platform.OpenDatabase(context.Background(), cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if database != nil {
|
||||
defer database.Close()
|
||||
}
|
||||
|
||||
app := platform.NewApp(cfg, database, logger)
|
||||
for _, registrar := range moduleRegistrars {
|
||||
if err := registrar(app); err != nil {
|
||||
return fmt.Errorf("register Sense module: %w", err)
|
||||
}
|
||||
}
|
||||
if err := app.ApplyMigrations(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
Handler: app.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("Sense server started", "address", cfg.HTTPAddress, "database_mode", cfg.DatabaseMode)
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
stopCh := make(chan os.Signal, 1)
|
||||
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
select {
|
||||
case <-stopCh:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(ctx)
|
||||
case serveErr := <-errCh:
|
||||
if serveErr == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return serveErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package config
|
||||
|
||||
// Package config exists as the stable public configuration boundary for Sense.
|
||||
// Runtime parsing is implemented in internal/platform so feature packages do
|
||||
// not read process environment variables directly.
|
||||
@@ -0,0 +1,13 @@
|
||||
module yovision.local/sense
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.8.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,103 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config Config
|
||||
database *sql.DB
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
migrations []Migration
|
||||
}
|
||||
|
||||
func NewApp(cfg Config, database *sql.DB, logger *slog.Logger) *App {
|
||||
app := &App{config: cfg, database: database, logger: logger, mux: http.NewServeMux()}
|
||||
app.registerPlatformRoutes()
|
||||
return app
|
||||
}
|
||||
|
||||
func (a *App) Config() Config { return a.config }
|
||||
func (a *App) Database() *sql.DB { return a.database }
|
||||
func (a *App) Logger() *slog.Logger { return a.logger }
|
||||
|
||||
func (a *App) Handle(pattern string, handler http.Handler) {
|
||||
a.mux.Handle(pattern, handler)
|
||||
}
|
||||
|
||||
func (a *App) HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
a.mux.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
return requestSecurityHeaders(a.mux)
|
||||
}
|
||||
|
||||
func (a *App) registerPlatformRoutes() {
|
||||
a.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "product": "sense"})
|
||||
})
|
||||
a.mux.HandleFunc("GET /readyz", a.ready)
|
||||
a.mux.HandleFunc("GET /api/v1/system/info", func(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"product": "Sense",
|
||||
"database": a.config.DatabaseMode,
|
||||
"modules": []string{},
|
||||
})
|
||||
})
|
||||
// Deliberately do not register upstream demo/code-generation routes.
|
||||
if a.config.StaticDir != "" {
|
||||
a.registerStaticFiles(a.config.StaticDir)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ready(w http.ResponseWriter, r *http.Request) {
|
||||
if a.database == nil {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "memory"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := timeLimitedContext(r, 2*time.Second)
|
||||
defer cancel()
|
||||
if err := a.database.PingContext(ctx); err != nil {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready", "database": "unavailable"})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready", "database": "postgres"})
|
||||
}
|
||||
|
||||
func (a *App) registerStaticFiles(directory string) {
|
||||
absolute, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
a.logger.Warn("invalid UI static directory", "error", err)
|
||||
return
|
||||
}
|
||||
fileSystem := os.DirFS(absolute)
|
||||
a.mux.Handle("GET /assets/", http.StripPrefix("/", http.FileServer(http.FS(fileSystem))))
|
||||
a.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(r.URL.Path)), "/")
|
||||
if path == "." || path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(fileSystem, path); err != nil {
|
||||
path = "index.html"
|
||||
}
|
||||
http.ServeFileFS(w, r, fileSystem, path)
|
||||
})
|
||||
}
|
||||
|
||||
func requestSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlatformRoutesAndDisabledDemo(t *testing.T) {
|
||||
app := NewApp(Config{DatabaseMode: DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{path: "/healthz", want: http.StatusOK},
|
||||
{path: "/readyz", want: http.StatusOK},
|
||||
{path: "/api/v1/system/info", want: http.StatusOK},
|
||||
{path: "/api/v1/demo", want: http.StatusNotFound},
|
||||
{path: "/api/v1/system/tools", want: http.StatusNotFound},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, test.path, nil)
|
||||
res := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(res, req)
|
||||
if res.Code != test.want {
|
||||
t.Errorf("GET %s status = %d, want %d", test.path, res.Code, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DatabaseModePostgres = "postgres"
|
||||
DatabaseModeMemory = "memory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddress string
|
||||
DatabaseMode string
|
||||
DatabaseURL string
|
||||
StaticDir string
|
||||
LogLevelName string
|
||||
}
|
||||
|
||||
func LoadConfig() (Config, error) {
|
||||
cfg := Config{
|
||||
HTTPAddress: valueOrDefault("SENSE_HTTP_ADDRESS", "127.0.0.1:18080"),
|
||||
DatabaseMode: strings.ToLower(valueOrDefault("SENSE_DATABASE_MODE", DatabaseModePostgres)),
|
||||
DatabaseURL: strings.TrimSpace(os.Getenv("SENSE_DATABASE_URL")),
|
||||
StaticDir: strings.TrimSpace(os.Getenv("SENSE_UI_STATIC_DIR")),
|
||||
LogLevelName: strings.ToLower(valueOrDefault("SENSE_LOG_LEVEL", "info")),
|
||||
}
|
||||
|
||||
switch cfg.DatabaseMode {
|
||||
case DatabaseModePostgres:
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("SENSE_DATABASE_URL is required when SENSE_DATABASE_MODE=postgres")
|
||||
}
|
||||
case DatabaseModeMemory:
|
||||
// Explicitly limited to local smoke tests and automated tests.
|
||||
default:
|
||||
return Config{}, fmt.Errorf("unsupported SENSE_DATABASE_MODE %q", cfg.DatabaseMode)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) LogLevel() slog.Level {
|
||||
switch c.LogLevelName {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadConfigRequiresPostgresURL(t *testing.T) {
|
||||
t.Setenv("SENSE_DATABASE_MODE", "postgres")
|
||||
t.Setenv("SENSE_DATABASE_URL", "")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected missing PostgreSQL URL to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigAllowsExplicitMemoryMode(t *testing.T) {
|
||||
t.Setenv("SENSE_DATABASE_MODE", "memory")
|
||||
t.Setenv("SENSE_DATABASE_URL", "")
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.DatabaseMode != DatabaseModeMemory {
|
||||
t.Fatalf("DatabaseMode = %q", cfg.DatabaseMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func timeLimitedContext(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(r.Context(), timeout)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func OpenDatabase(ctx context.Context, cfg Config) (*sql.DB, error) {
|
||||
if cfg.DatabaseMode == DatabaseModeMemory {
|
||||
return nil, nil
|
||||
}
|
||||
database, err := sql.Open("pgx", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open Sense PostgreSQL: %w", err)
|
||||
}
|
||||
database.SetMaxOpenConns(20)
|
||||
database.SetMaxIdleConns(5)
|
||||
database.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := database.PingContext(pingCtx); err != nil {
|
||||
database.Close()
|
||||
return nil, fmt.Errorf("connect Sense PostgreSQL: %w", err)
|
||||
}
|
||||
return database, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Message }
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, err error) {
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
WriteJSON(w, apiErr.Status, map[string]any{"error": map[string]string{"code": apiErr.Code, "message": apiErr.Message}})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "internal_error", "message": "服务暂时不可用"}})
|
||||
}
|
||||
|
||||
func DecodeJSON(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return &APIError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "请求内容格式不正确"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version int64
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
func (a *App) RegisterMigration(migration Migration) {
|
||||
a.migrations = append(a.migrations, migration)
|
||||
}
|
||||
|
||||
func (a *App) ApplyMigrations(ctx context.Context) error {
|
||||
if a.database == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := a.database.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS sense_schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create Sense migration table: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(a.migrations, func(i, j int) bool { return a.migrations[i].Version < a.migrations[j].Version })
|
||||
for _, migration := range a.migrations {
|
||||
if err := applyMigration(ctx, a.database, migration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, database *sql.DB, migration Migration) error {
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", migration.Version, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sense_schema_migrations WHERE version = $1)`, migration.Version).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check migration %d: %w", migration.Version, err)
|
||||
}
|
||||
if exists {
|
||||
return tx.Commit()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, migration.SQL); err != nil {
|
||||
return fmt.Errorf("apply migration %d (%s): %w", migration.Version, migration.Name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense_schema_migrations(version, name) VALUES($1, $2)`, migration.Version, migration.Name); err != nil {
|
||||
return fmt.Errorf("record migration %d: %w", migration.Version, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
sensecmd "yovision.local/sense/cmd/sense"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := sensecmd.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user