Compare commits

...

4 Commits

Author SHA1 Message Date
3ce3335695 Use cobra to turn linkctl into a proper cli
Most commands are currently placeholders but version and db init work
2025-05-09 03:34:13 +02:00
078a949dc4 SQUASHME update db error names 2025-05-09 02:54:24 +02:00
9f28796e8a Add version package and update the makefile
The makefile will grab the version info from git and pass it to the
linker so that version information based on tags, commits and commit
times is available in the code.
2025-05-09 02:45:01 +02:00
6998a0fa0a Add basic database interaction 2025-05-09 02:44:55 +02:00
8 changed files with 431 additions and 6 deletions

View File

@ -3,6 +3,15 @@ BINARIES = $(patsubst cmd/%/,%,$(wildcard cmd/*/))
.PHONY: all build test validate clean run $(BINARIES)
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
COMMIT_DATETIME := $(shell git log -1 --format=%cd --date=iso8601)
LDFLAGS := -X git.omicron.one/omicron/linkshare/internal/version.Version=$(VERSION) \
-X git.omicron.one/omicron/linkshare/internal/version.GitCommit=$(COMMIT) \
-X "git.omicron.one/omicron/linkshare/internal/version.CommitDateTime=$(COMMIT_DATETIME)"
all: build
@ -13,7 +22,7 @@ $(BINARY_DIR):
mkdir -p $(BINARY_DIR)
$(BINARIES): %: $(BINARY_DIR)
go build -o $(BINARY_DIR)/$@ ./cmd/$@/
go build -ldflags '$(LDFLAGS)' -o $(BINARY_DIR)/$@ ./cmd/$@/
test:
go test ./...

27
cmd/linkctl/config.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func configPreRun(cmd *cobra.Command, args []string) error {
return setupDb()
}
func configPostRun(cmd *cobra.Command, args []string) error {
return cleanupDb()
}
func configSetHandler(cmd *cobra.Command, args []string) {
fmt.Println("Not implemented")
}
func configGetHandler(cmd *cobra.Command, args []string) {
fmt.Println("Not implemented")
}
func configListHandler(cmd *cobra.Command, args []string) {
fmt.Println("Not implemented")
}

48
cmd/linkctl/db.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"fmt"
"git.omicron.one/omicron/linkshare/internal/database"
"git.omicron.one/omicron/linkshare/internal/util"
"git.omicron.one/omicron/linkshare/internal/version"
"github.com/spf13/cobra"
)
func openDB() (*database.DB, error) {
paths, err := util.FindDirectories(dbPath)
if err != nil {
return nil, err
}
return database.Open(paths.DatabaseFile)
}
func dbPreRun(cmd *cobra.Command, args []string) error {
return setupDb()
}
func dbPostRun(cmd *cobra.Command, args []string) error {
return cleanupDb()
}
func dbInitHandler(cmd *cobra.Command, args []string) {
err := db.Initialize(paths.SchemaDir)
if err == database.ErrAlreadyInitialized {
fmt.Printf("Database %q is already initialized\n", dbPath)
return
}
if err == nil {
fmt.Printf("Initialized database %q with schema version %d\n", dbPath, version.SchemaVersion)
return
}
fmt.Printf("Failed to initialize database %q: %v\n", dbPath, err)
}
func dbBackupHandler(cmd *cobra.Command, args []string) {
fmt.Println("Not implemented")
}
func dbUpdateHandler(cmd *cobra.Command, args []string) {
fmt.Println("Not implemented")
}

View File

@ -2,17 +2,156 @@ package main
import (
"fmt"
"os"
"git.omicron.one/omicron/linkshare/internal/database"
"git.omicron.one/omicron/linkshare/internal/util"
"git.omicron.one/omicron/linkshare/internal/version"
"github.com/spf13/cobra"
)
func main() {
paths, err := util.FindDirectories("")
var (
dbPath string
verbosity int
)
var (
paths *util.AppPaths
db *database.DB
)
func exitIfError(err error) {
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func setupPaths() error {
if paths != nil {
return nil
}
paths_, err := util.FindDirectories(dbPath)
if err != nil {
return err
}
paths = paths_
return nil
}
func setupDb() error {
if db != nil {
return nil
}
err := setupPaths()
if err != nil {
return nil
}
fmt.Println("Paths:")
fmt.Println(" Schema:", paths.SchemaDir)
fmt.Println(" Database:", paths.DatabaseFile)
db_, err := database.Open(dbPath)
if err != nil {
return nil
}
db = db_
return nil
}
func cleanupDb() error {
if db != nil {
err := db.Close()
if err != nil {
return err
}
}
return nil
}
func main() {
rootCmd := &cobra.Command{
Use: "linkctl",
Short: "LinkShare CLI tool",
Long: `Command line tool to manage your self-hosted LinkShare service.`,
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
rootCmd.CompletionOptions.DisableDefaultCmd = true
rootCmd.PersistentFlags().StringVarP(&dbPath, "db", "d", "", "Database file path")
rootCmd.PersistentFlags().CountVarP(&verbosity, "verbose", "v", "Increase verbosity level")
configCmd := &cobra.Command{
Use: "config",
Short: "Configuration commands",
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
PersistentPreRunE: configPreRun,
PersistentPostRunE: configPostRun,
}
configSetCmd := &cobra.Command{
Use: "set",
Short: "Set a configuration value",
Run: configSetHandler,
}
configGetCmd := &cobra.Command{
Use: "get",
Short: "Get a configuration value",
Run: configGetHandler,
}
configListCmd := &cobra.Command{
Use: "list",
Short: "List all configuration values",
Run: configListHandler,
}
configCmd.AddCommand(configSetCmd, configGetCmd, configListCmd)
dbCmd := &cobra.Command{
Use: "db",
Short: "Database commands",
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
PersistentPreRunE: dbPreRun,
PersistentPostRunE: dbPostRun,
}
dbInitCmd := &cobra.Command{
Use: "init",
Short: "Initialize the database",
Run: dbInitHandler,
}
dbBackupCmd := &cobra.Command{
Use: "backup",
Short: "Backup the database",
Run: dbBackupHandler,
}
dbUpdateCmd := &cobra.Command{
Use: "update",
Short: "Update the database schema",
Run: dbUpdateHandler,
}
dbCmd.AddCommand(dbInitCmd, dbBackupCmd, dbUpdateCmd)
versionCmd := &cobra.Command{
Use: "version",
Short: "Display version information",
Run: func(cmd *cobra.Command, args []string) {
version.Print()
},
}
rootCmd.AddCommand(configCmd, dbCmd, versionCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

10
go.mod
View File

@ -1,3 +1,13 @@
module git.omicron.one/omicron/linkshare
go 1.24
require (
github.com/mattn/go-sqlite3 v1.14.28
github.com/spf13/cobra v1.9.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
)

12
go.sum
View File

@ -0,0 +1,12 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,163 @@
// Package database provides all database interactions for linkshare.
// This includes functions to read and write structured link data, setting and
// getting configurations, updating and initializing the schema and backing up
// data
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
_ "github.com/mattn/go-sqlite3"
"git.omicron.one/omicron/linkshare/internal/version"
)
// DB represents a database connection
type DB struct {
conn *sql.DB
}
var (
ErrNotInitialized = errors.New("database not initialized")
ErrAlreadyInitialized = errors.New("database already initialized")
ErrSchemaOutdated = errors.New("database schema needs updating")
ErrSchemaUnsupported = errors.New("database schema is too new for the server")
ErrMigrationFailed = errors.New("migration failed")
)
// Open opens a connection to the sqlite database at the given path
func Open(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
conn.SetMaxOpenConns(1) // SQLite only supports one writer at a time
if err := conn.Ping(); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
_, err = conn.Exec("PRAGMA foreign_keys = ON")
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to enable foreign key constraints: %w", err)
}
return &DB{conn: conn}, nil
}
// Close closes the database connection if it's open
func (db *DB) Close() error {
if db.conn != nil {
return db.conn.Close()
}
return nil
}
// Initialize the database schema
func (db *DB) Initialize(schemaPath string) error {
err := db.CheckInitialized()
if err == nil {
return ErrAlreadyInitialized
}
currentSchema := filepath.Join(schemaPath, "current.sql")
schema, err := os.ReadFile(currentSchema)
if err != nil {
return fmt.Errorf("failed to read schema file: %w", err)
}
_, err = db.conn.Exec(string(schema))
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
return nil
}
// CheckInitialized returns nil if the database is initialized and an error otherwise
func (db *DB) CheckInitialized() error {
var count int
err := db.conn.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='settings'").Scan(&count)
if err != nil {
return fmt.Errorf("failed to check if database is initialized: %w", err)
}
if count == 0 {
return ErrNotInitialized
}
return nil
}
// GetSchemaVersion returns the schema version or an error
func (db *DB) GetSchemaVersion() (int, error) {
var version string
err := db.conn.QueryRow("SELECT value FROM settings WHERE key='schema-version'").Scan(&version)
if err != nil {
return 0, fmt.Errorf("failed to get schema version: %w", err)
}
versionInt, err := strconv.Atoi(version)
if err != nil {
return 0, fmt.Errorf("invalid schema version: %w", err)
}
if versionInt < 1 {
return 0, fmt.Errorf("invalid schema version %d", versionInt)
}
return versionInt, nil
}
// CheckSchemaVersion verifies that the schema is initialized and has the correct version
func (db *DB) CheckSchemaVersion() error {
err := db.CheckInitialized()
if err != nil {
return err
}
version_, err := db.GetSchemaVersion()
if err != nil {
return err
}
if version_ < version.SchemaVersion {
return ErrSchemaOutdated
} else if version_ > version.SchemaVersion {
return ErrSchemaUnsupported
}
return nil
}
func (db *DB) transaction(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("error rolling back transaction: %v (original error: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}

View File

@ -0,0 +1,17 @@
package version
import "fmt"
var (
Version = "dev"
GitCommit = "unknown"
CommitDateTime = "unknown"
SchemaVersion = 1
)
// PrintVersionInfo prints formatted version information to stdout
func Print() {
fmt.Printf("Version: %s\n", Version)
fmt.Printf("Git commit: %s %s\n", GitCommit, CommitDateTime)
fmt.Printf("Schema: v%d\n", SchemaVersion)
}