From a085d48d655b9d20db9371f0731a3c39f04291be Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 5 Dec 2025 12:08:42 +0100 Subject: [PATCH 1/6] feat(cli): add data export command --- synkronus-cli/README.md | 11 +++++++ synkronus-cli/internal/cmd/data.go | 47 ++++++++++++++++++++++++++++++ synkronus-cli/pkg/client/client.go | 42 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 synkronus-cli/internal/cmd/data.go diff --git a/synkronus-cli/README.md b/synkronus-cli/README.md index 18aa003b9..2c34333bd 100644 --- a/synkronus-cli/README.md +++ b/synkronus-cli/README.md @@ -7,6 +7,7 @@ A command-line interface for interacting with the Synkronus API. - Authentication with JWT tokens - App bundle management (download, upload, version management) - Data synchronization (push and pull) +- Data export as Parquet ZIP archives - Configuration management ## Installation @@ -140,6 +141,16 @@ synk sync pull --client-id your-client-id --after-change-id 1234 --schema-types synk sync push data.json ``` +### Data Export + +```bash +# Export all observations as a Parquet ZIP archive +synk data export exports.zip + +# Export to a specific directory +synk data export ./backups/observations_parquet.zip +``` + ## License MIT diff --git a/synkronus-cli/internal/cmd/data.go b/synkronus-cli/internal/cmd/data.go new file mode 100644 index 000000000..0eaa03551 --- /dev/null +++ b/synkronus-cli/internal/cmd/data.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + + "github.com/OpenDataEnsemble/ode/synkronus-cli/pkg/client" + "github.com/spf13/cobra" +) + +// dataCmd represents the data command group +var dataCmd = &cobra.Command{ + Use: "data", + Short: "Data-related operations", + Long: `Commands for working with exported data and statistics.`, +} + +// dataExportCmd represents the data export command +var dataExportCmd = &cobra.Command{ + Use: "export ", + Short: "Export data as a Parquet ZIP archive", + Long: `Download a ZIP archive of Parquet exports from the Synkronus API. + +Examples: + synk data export exports.zip + synk data export ./backups/observations_parquet.zip`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + outputFile := args[0] + + if outputFile == "" { + return fmt.Errorf("output_file is required") + } + + c := client.NewClient() + if err := c.DownloadParquetExport(outputFile); err != nil { + return fmt.Errorf("data export failed: %w", err) + } + + fmt.Printf("Parquet export saved to %s\n", outputFile) + return nil + }, +} + +func init() { + dataCmd.AddCommand(dataExportCmd) + rootCmd.AddCommand(dataCmd) +} diff --git a/synkronus-cli/pkg/client/client.go b/synkronus-cli/pkg/client/client.go index 66dcd48fc..8862ce3cd 100644 --- a/synkronus-cli/pkg/client/client.go +++ b/synkronus-cli/pkg/client/client.go @@ -261,6 +261,48 @@ func (c *Client) DownloadAppBundleFile(path, destPath string, preview bool) erro return nil } +// DownloadParquetExport downloads the Parquet export ZIP archive to the specified destination path +func (c *Client) DownloadParquetExport(destPath string) error { + url := fmt.Sprintf("%s/dataexport/parquet", c.BaseURL) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + resp, err := c.doRequest(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Create destination directory if it doesn't exist + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + // Create destination file + out, err := os.Create(destPath) + if err != nil { + return err + } + defer out.Close() + + // Copy response body to file + _, err = io.Copy(out, resp.Body) + if err != nil { + return err + } + + return nil +} + // UploadAppBundle uploads a new app bundle func (c *Client) UploadAppBundle(bundlePath string) (map[string]interface{}, error) { url := fmt.Sprintf("%s/app-bundle/push", c.BaseURL) From 2fa7c55c0c67ab9561be427f3868c9dd50e46427 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 5 Dec 2025 12:10:44 +0100 Subject: [PATCH 2/6] docs(cli): add LLM_context.md for agent instructions --- synkronus-cli/LLM_context.md | 190 +++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 synkronus-cli/LLM_context.md diff --git a/synkronus-cli/LLM_context.md b/synkronus-cli/LLM_context.md new file mode 100644 index 000000000..5794809af --- /dev/null +++ b/synkronus-cli/LLM_context.md @@ -0,0 +1,190 @@ +# synkronus-cli – LLM Context for Adding Commands + +This file explains how to add new commands to the **synkronus-cli** project so future LLM agents can extend the CLI without re-analyzing the codebase. + +## High-Level Architecture + +- **Entry point**: `cmd/synkronus/main.go` + - Calls `cmd.Execute()` from `internal/cmd/root.go`. +- **Command tree** (Cobra): + - Root command `synk` is defined in `internal/cmd/root.go` (`rootCmd`). + - Subcommands live in `internal/cmd/*.go` and are registered in each file's `init()` via `rootCmd.AddCommand(...)` or group commands' `AddCommand(...)`. +- **HTTP API client**: `pkg/client/client.go` + - `Client` encapsulates base URL, API version, and an `http.Client`. + - All authenticated requests go through `Client.doRequest`, which: + - Adds `x-api-version` from Viper (`api.version`). + - Adds `Authorization: Bearer ` via `internal/auth.GetToken()`. + +## Configuration & Auth + +- Config handled in `internal/cmd/root.go` and `internal/config` using Viper. + - `rootCmd` defines persistent flags: + - `--api-url` → `api.url` + - `--api-version` → `api.version` + - Config file: `$HOME/.synkronus.yaml` (or `--config` override). +- Authentication flows through `internal/auth`: + - `auth.Login()` talks to `/auth/login`, stores tokens in config. + - `auth.GetToken()` returns a valid JWT, refreshing if needed. + - The CLI assumes the user has run `synk login` before calling authenticated endpoints. + +## Pattern: Adding a New HTTP Operation + +When a new CLI command needs to call the Synkronus API, follow this pattern: + +1. **Extend `pkg/client.Client`** (preferred) + - Add a method on `Client` that: + - Builds the request URL with `fmt.Sprintf("%s/...", c.BaseURL)`. + - Uses `http.NewRequest` to construct the request. + - Calls `c.doRequest(req)` so auth & `x-api-version` are applied. + - Handles HTTP status codes and parses or streams the response. + +2. **Use the client method in a Cobra command** + - Inside `internal/cmd/*.go`, create or reuse a command group. + - In the command's `RunE`, construct `c := client.NewClient()` and call the new client method. + +This separation keeps HTTP details in `pkg/client` and CLI UX in `internal/cmd`. + +## Pattern: Adding a New Command Group + +Example: the `data` command group used for `data export`. + +1. **Create a new file in `internal/cmd`** + + - File name convention: `.go`, e.g. `data.go`, `attachments.go`, `sync.go`. + +2. **Define the group command** + + ```go + var dataCmd = &cobra.Command{ + Use: "data", + Short: "Data-related operations", + Long: `Commands for working with exported data and statistics.`, + } + ``` + +3. **Define one or more subcommands** + + ```go + var dataExportCmd = &cobra.Command{ + Use: "export ", + Short: "Export data as a Parquet ZIP archive", + Long: `Download a ZIP archive of Parquet exports from the Synkronus API. + +Examples: + synk data export exports.zip + synk data export ./backups/observations_parquet.zip`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + outputFile := args[0] + if outputFile == "" { + return fmt.Errorf("output_file is required") + } + + c := client.NewClient() + if err := c.DownloadParquetExport(outputFile); err != nil { + return fmt.Errorf("data export failed: %w", err) + } + + fmt.Printf("Parquet export saved to %s\n", outputFile) + return nil + }, + } + ``` + +4. **Register the commands in `init()`** + + ```go + func init() { + dataCmd.AddCommand(dataExportCmd) + rootCmd.AddCommand(dataCmd) + } + ``` + +Once this is done, the new hierarchy appears automatically in: + +- `synk --help` +- `synk data --help` +- Completion scripts generated by `synk completion [bash|zsh|fish|powershell]`. + +## Pattern: Downloading/Uploading Files via HTTP + +Use existing client methods as templates: + +- `Client.DownloadAppBundleFile(...)` for binary downloads. +- `Client.UploadAppBundle(...)` for multipart uploads. +- `Client.DownloadParquetExport(...)` (added for `/dataexport/parquet`). + +### Binary Download Pattern (used for Parquet export) + +Key steps in `Client.DownloadParquetExport(destPath string) error`: + +1. Build URL: + + ```go + url := fmt.Sprintf("%s/dataexport/parquet", c.BaseURL) + ``` + +2. Create a `GET` request and send it via `c.doRequest`: + + ```go + req, err := http.NewRequest("GET", url, nil) + resp, err := c.doRequest(req) + defer resp.Body.Close() + ``` + +3. Check `resp.StatusCode` and surface error text if non-200. +4. Ensure destination directory exists (`os.MkdirAll`). +5. Create the output file and `io.Copy` the response body into it. + +Any new file-download-type feature should follow this pattern to keep behavior consistent (auth, error messages, directory creation). + +## Pattern: Flags and Arguments + +- **Positional args**: Use `Args: cobra.ExactArgs(n)` (or `RangeArgs`) and read from `args[index]`. +- **Flags**: Use `cmd.Flags().("name")` to read; define in `init()` with: + - `Flags().String(...)`, `Bool(...)`, etc. + - `MarkFlagRequired("name")` if needed. + +Examples elsewhere in the codebase: + +- `sync.go`: + - `sync pull [output_file]` with flags: `--client-id`, `--current-version`, `--schema-types`, `--limit`, `--page-token`. +- `attachments.go`: + - `attachments upload --id ` + - `attachments download [output_file]`. + +When adding new commands, align with these patterns for a consistent UX. + +## Completion & Help Text + +- Shell completion is implemented once in `internal/cmd/root.go` via `completionCmd`. +- Any new commands automatically appear in: + - Root and subcommand `--help` outputs (Cobra handles this). + - Generated completion scripts. + +LLM agents **should not** modify `completionCmd` for normal feature work. Instead, make sure new commands have clear `Short` and `Long` descriptions so the autogenerated help is meaningful. + +## Checklist for Adding a New Command + +When extending the CLI, LLM agents should: + +1. **Decide if a new group is needed** + - If the feature logically fits an existing group (e.g., `sync`, `attachments`, `data`), add a subcommand there. + - Otherwise, create a new `.go` with a top-level `*cobra.Command`. + +2. **Add/extend a client method in `pkg/client`** + - Encapsulate the HTTP call there, using `c.doRequest`. + - Handle response codes carefully and return Go errors with useful messages. + +3. **Implement the Cobra command** + - Parse args/flags. + - Call the client method. + - Print concise, user-friendly output. + +4. **Register the command in `init()`** + - Attach to the appropriate group and to `rootCmd`. + +5. **Update README (optional but recommended)** + - Add a short feature bullet and a minimal example in the Usage section. + +Following this guide should allow LLM agents to add new commands that are consistent with the existing synkronus-cli design and UX. From d71c0e2ce46e06c6cbabdfbd6359facb9509e427 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 5 Dec 2025 12:19:09 +0100 Subject: [PATCH 3/6] feat(cli): improve config handling for multiple synkronus endpoints --- synkronus-cli/README.md | 26 ++++++++++++-- synkronus-cli/internal/cmd/config.go | 39 +++++++++++++++++++++ synkronus-cli/internal/cmd/root.go | 52 ++++++++++++++++++++++------ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/synkronus-cli/README.md b/synkronus-cli/README.md index 2c34333bd..097467cab 100644 --- a/synkronus-cli/README.md +++ b/synkronus-cli/README.md @@ -26,9 +26,14 @@ go build -o bin/synk ./cmd/synkronus ## Configuration -The CLI uses a configuration file located at `$HOME/.synkronus.yaml`. You can specify a different configuration file using the `--config` flag. +By default, the CLI uses a configuration file located at `$HOME/.synkronus.yaml`. -Example configuration: +You can override this in two ways: + +- Per-command: pass `--config ` to read/write a specific config file for that invocation. +- Persistent "current" config: use `synk config use ` to point the CLI at a specific config file for future runs (this writes a pointer file at `$HOME/.synkronus_current`). + +Example configuration file: ```yaml api: @@ -36,6 +41,23 @@ api: version: 1.0.0 ``` +### Multiple endpoints example + +```bash +# Create separate config files +synk config init -o ~/.synkronus-dev.yaml +synk config init -o ~/.synkronus-prod.yaml + +# Point CLI at dev by default +synk config use ~/.synkronus-dev.yaml + +# Point CLI at prod by default +synk config use ~/.synkronus-prod.yaml + +# Temporarily override for a single command +synk --config ~/.synkronus-dev.yaml status +``` + ## Shell Completion The Synkronus CLI includes built-in support for shell completion in bash, zsh, fish, and PowerShell. diff --git a/synkronus-cli/internal/cmd/config.go b/synkronus-cli/internal/cmd/config.go index bd581eba4..195ded4fd 100644 --- a/synkronus-cli/internal/cmd/config.go +++ b/synkronus-cli/internal/cmd/config.go @@ -122,4 +122,43 @@ func init() { }, } configCmd.AddCommand(setCmd) + + // Use config command + useCmd := &cobra.Command{ + Use: "use [config_path]", + Short: "Set the current config file", + Long: `Set the default config file used by the Synkronus CLI when --config is not provided. This writes a pointer file in your home directory.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + configPath := args[0] + + if configPath == "" { + return fmt.Errorf("config_path is required") + } + + absPath, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("error resolving config path: %w", err) + } + + // Ensure the target config file exists to avoid pointing to an invalid file + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return fmt.Errorf("config file does not exist at %s (use 'synk config init -o %s' to create it)", absPath, absPath) + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("error getting home directory: %w", err) + } + + pointerPath := filepath.Join(home, ".synkronus_current") + if err := os.WriteFile(pointerPath, []byte(absPath+"\n"), 0644); err != nil { + return fmt.Errorf("error writing current config pointer: %w", err) + } + + fmt.Printf("Current config set to %s\n", absPath) + return nil + }, + } + configCmd.AddCommand(useCmd) } diff --git a/synkronus-cli/internal/cmd/root.go b/synkronus-cli/internal/cmd/root.go index 4215e987a..71bf0b5a6 100644 --- a/synkronus-cli/internal/cmd/root.go +++ b/synkronus-cli/internal/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/config" "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/utils" @@ -86,21 +87,37 @@ func init() { } func initConfig() { + var configPath string + if cfgFile != "" { // Use config file from the flag - viper.SetConfigFile(cfgFile) + configPath = cfgFile + viper.SetConfigFile(configPath) } else { // Find home directory home, err := os.UserHomeDir() cobra.CheckErr(err) - // Search config in home directory with name ".synkronus" (without extension) - viper.AddConfigPath(home) - viper.SetConfigType("yaml") - viper.SetConfigName(".synkronus") + // First, check if a "current config" pointer file exists + currentConfigPointer := filepath.Join(home, ".synkronus_current") + if data, err := os.ReadFile(currentConfigPointer); err == nil { + path := strings.TrimSpace(string(data)) + if path != "" { + configPath = path + viper.SetConfigFile(configPath) + } + } + + // If no explicit config path was resolved, fall back to the default search behavior + if configPath == "" { + // Search config in home directory with name ".synkronus" (without extension) + viper.AddConfigPath(home) + viper.SetConfigType("yaml") + viper.SetConfigName(".synkronus") - // Also look for config in the current directory - viper.AddConfigPath(".") + // Also look for config in the current directory + viper.AddConfigPath(".") + } } // Read in environment variables that match @@ -113,11 +130,24 @@ func initConfig() { // Create default config if it doesn't exist if _, ok := err.(viper.ConfigFileNotFoundError); ok { defaultConfig := config.DefaultConfig() - configDir := filepath.Dir(filepath.Join(os.Getenv("HOME"), ".synkronus.yaml")) - if _, err := os.Stat(configDir); os.IsNotExist(err) { - os.MkdirAll(configDir, 0755) + + // If we have an explicit config path (from --config or pointer), create that file + if configPath != "" { + configDir := filepath.Dir(configPath) + if _, err := os.Stat(configDir); os.IsNotExist(err) { + os.MkdirAll(configDir, 0755) + } + viper.SetConfigFile(configPath) + } else { + // Fall back to legacy default at $HOME/.synkronus.yaml + legacyPath := filepath.Join(os.Getenv("HOME"), ".synkronus.yaml") + configDir := filepath.Dir(legacyPath) + if _, err := os.Stat(configDir); os.IsNotExist(err) { + os.MkdirAll(configDir, 0755) + } + viper.SetConfigFile(legacyPath) } - viper.SetConfigFile(filepath.Join(os.Getenv("HOME"), ".synkronus.yaml")) + for k, v := range defaultConfig { viper.Set(k, v) } From c8a6d6ba7f25ce6a6524abe1edd6a96f73dcba90 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 5 Dec 2025 12:34:03 +0100 Subject: [PATCH 4/6] feat(cli): Improve logging and bump to v0.2.0 --- synkronus-cli/internal/auth/auth.go | 12 ++++++------ synkronus-cli/internal/cmd/auth.go | 12 ++++++++++++ synkronus-cli/internal/cmd/version.go | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/synkronus-cli/internal/auth/auth.go b/synkronus-cli/internal/auth/auth.go index 1ee0f89dc..99487436a 100644 --- a/synkronus-cli/internal/auth/auth.go +++ b/synkronus-cli/internal/auth/auth.go @@ -44,14 +44,14 @@ func Login(username, password string) (*TokenResponse, error) { // Send login request resp, err := http.Post(loginURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { - return nil, fmt.Errorf("login request failed: %w", err) + return nil, fmt.Errorf("login request failed for endpoint %s: %w", loginURL, err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("login failed for endpoint %s with status %d: %s", loginURL, resp.StatusCode, string(body)) } // Read the response body @@ -59,10 +59,10 @@ func Login(username, password string) (*TokenResponse, error) { if err != nil { return nil, fmt.Errorf("error reading response body: %w", err) } - + // Print the raw response for debugging fmt.Printf("DEBUG - Raw API response: %s\n", string(body)) - + // Parse response var tokenResp TokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { @@ -86,7 +86,7 @@ func RefreshToken() (*TokenResponse, error) { // Prepare refresh request refreshData := map[string]string{ - "refreshToken": refreshToken, // Updated to match API expectations + "refreshToken": refreshToken, // Updated to match API expectations } jsonData, err := json.Marshal(refreshData) if err != nil { @@ -111,7 +111,7 @@ func RefreshToken() (*TokenResponse, error) { if err != nil { return nil, fmt.Errorf("error reading response body: %w", err) } - + // Parse response var tokenResp TokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { diff --git a/synkronus-cli/internal/cmd/auth.go b/synkronus-cli/internal/cmd/auth.go index 9cf4b9d04..4e05e5824 100644 --- a/synkronus-cli/internal/cmd/auth.go +++ b/synkronus-cli/internal/cmd/auth.go @@ -8,6 +8,7 @@ import ( "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/auth" "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/utils" "github.com/spf13/cobra" + "github.com/spf13/viper" "golang.org/x/term" ) @@ -76,6 +77,17 @@ func init() { Short: "Show authentication status", Long: `Display information about the current authentication status.`, RunE: func(cmd *cobra.Command, args []string) error { + configFile := viper.ConfigFileUsed() + if configFile == "" { + configFile = "(no config file found, using defaults)" + } + apiURL := viper.GetString("api.url") + + utils.PrintHeading("Configuration") + fmt.Printf("%s\n", utils.FormatKeyValue("Config file", configFile)) + fmt.Printf("%s\n", utils.FormatKeyValue("API endpoint", apiURL)) + fmt.Println() + claims, err := auth.GetUserInfo() if err != nil { return fmt.Errorf("not authenticated: %w", err) diff --git a/synkronus-cli/internal/cmd/version.go b/synkronus-cli/internal/cmd/version.go index 3c3a992bc..827e09ad3 100644 --- a/synkronus-cli/internal/cmd/version.go +++ b/synkronus-cli/internal/cmd/version.go @@ -11,7 +11,7 @@ import ( var ( // Version is the CLI version, set during build - Version = "0.1.0" + Version = "0.2.0" // BuildDate is the date when the CLI was built BuildDate = "unknown" // CommitHash is the git commit hash From 5f0049a9b850ab1dfacc7db2211b591ae4eab9cf Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Sat, 6 Dec 2025 11:44:27 +0100 Subject: [PATCH 5/6] feat(cli): Add QR generator --- synkronus-cli/go.mod | 1 + synkronus-cli/go.sum | 7 +- synkronus-cli/internal/cmd/qr.go | 165 +++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 synkronus-cli/internal/cmd/qr.go diff --git a/synkronus-cli/go.mod b/synkronus-cli/go.mod index e4c4756eb..4e2233fb6 100644 --- a/synkronus-cli/go.mod +++ b/synkronus-cli/go.mod @@ -8,6 +8,7 @@ require ( github.com/fatih/color v1.14.1 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 golang.org/x/term v0.32.0 diff --git a/synkronus-cli/go.sum b/synkronus-cli/go.sum index 67b999412..284c62e59 100644 --- a/synkronus-cli/go.sum +++ b/synkronus-cli/go.sum @@ -5,8 +5,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -32,8 +30,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= @@ -48,6 +44,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -77,7 +75,6 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= diff --git a/synkronus-cli/internal/cmd/qr.go b/synkronus-cli/internal/cmd/qr.go new file mode 100644 index 000000000..6b03549c1 --- /dev/null +++ b/synkronus-cli/internal/cmd/qr.go @@ -0,0 +1,165 @@ +package cmd + +import ( + "bufio" + "encoding/base64" + "fmt" + "net/url" + "os" + "strings" + + "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/auth" + "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/utils" + qrcode "github.com/skip2/go-qrcode" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// encodeFRMLS replicates the FRMLS encoding used by the Formulus generateQR.ts script. +// Format: FRMLS:v:;s:;u:;p:;; +func encodeFRMLS(version int, serverURL, username, password string) string { + b64 := func(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) + } + + parts := []string{ + fmt.Sprintf("v:%s", b64(fmt.Sprintf("%d", version))), + fmt.Sprintf("s:%s", b64(serverURL)), + fmt.Sprintf("u:%s", b64(username)), + fmt.Sprintf("p:%s", b64(password)), + } + + return "FRMLS:" + strings.Join(parts, ";") + ";;" +} + +// deriveOutputFilename builds a safe default filename from the server URL, using only +// the hostname and path segments (no scheme or port), and appending .png. +func deriveOutputFilename(serverURL string) string { + if serverURL == "" { + return "formulus-qr.png" + } + + parsed, err := url.Parse(serverURL) + if err != nil { + // If parsing fails, fall back to a very simple sanitization + return sanitizeFilename(serverURL) + ".png" + } + + host := parsed.Hostname() + path := strings.Trim(parsed.EscapedPath(), "/") + base := host + if path != "" { + // Replace path separators with underscores + base = base + "_" + strings.ReplaceAll(path, "/", "_") + } + if base == "" { + base = "formulus-qr" + } + + return sanitizeFilename(base) + ".png" +} + +// sanitizeFilename replaces characters that are problematic in filenames with '_'. +func sanitizeFilename(s string) string { + if s == "" { + return "formulus-qr" + } + + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + + result := b.String() + if result == "" { + return "formulus-qr" + } + return result +} + +func init() { + qrCmd := &cobra.Command{ + Use: "qr", + Short: "Generate a QR code image for configuring Formulus", + Long: `Generate a QR code PNG file containing FRMLS-encoded settings +for the Formulus mobile app. The QR encodes server URL, username, and password.`, + RunE: func(cmd *cobra.Command, args []string) error { + reader := bufio.NewReader(os.Stdin) + + // Defaults from current config / auth status + defaultURL := viper.GetString("api.url") + defaultUsername := "" + if claims, err := auth.GetUserInfo(); err == nil { + defaultUsername = claims.Username + } + + // Prompt helper that shows a default and returns either user-entered value or default on empty input + prompt := func(label, def string) (string, error) { + if def != "" { + fmt.Printf("%s [%s]: ", label, def) + } else { + fmt.Printf("%s: ", label) + } + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + if line == "" { + return def, nil + } + return line, nil + } + + serverURL, err := prompt("Server URL", defaultURL) + if err != nil { + return fmt.Errorf("error reading server URL: %w", err) + } + if serverURL == "" { + return fmt.Errorf("server URL is required") + } + + username, err := prompt("Username", defaultUsername) + if err != nil { + return fmt.Errorf("error reading username: %w", err) + } + if username == "" { + return fmt.Errorf("username is required") + } + + fmt.Print("Password: ") + passwordLine, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("error reading password: %w", err) + } + password := strings.TrimSpace(passwordLine) + if password == "" { + return fmt.Errorf("password is required") + } + + encoded := encodeFRMLS(1, serverURL, username, password) + + outputFile, err := cmd.Flags().GetString("output") + if err != nil { + return fmt.Errorf("error reading output flag: %w", err) + } + if outputFile == "" { + outputFile = deriveOutputFilename(serverURL) + } + if err := qrcode.WriteFile(encoded, qrcode.Medium, 256, outputFile); err != nil { + return fmt.Errorf("failed to generate QR code image: %w", err) + } + + utils.PrintSuccess("QR code generated successfully") + fmt.Printf("%s\n", utils.FormatKeyValue("Output file", outputFile)) + return nil + }, + } + + qrCmd.Flags().StringP("output", "o", "", "Output PNG file path (default derived from server URL)") + rootCmd.AddCommand(qrCmd) +} From 1ed733e0ec24b3e0f9cda35b91785e260595b726 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Sat, 6 Dec 2025 12:17:02 +0100 Subject: [PATCH 6/6] feat(cli): update to include logo in QR code --- synkronus-cli/go.mod | 8 +++++- synkronus-cli/go.sum | 47 +++++++++++++++++++++++++++++-- synkronus-cli/internal/cmd/qr.go | 18 ++++++++++-- synkronus-cli/qr_logo.png | Bin 0 -> 35877 bytes 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 synkronus-cli/qr_logo.png diff --git a/synkronus-cli/go.mod b/synkronus-cli/go.mod index 4e2233fb6..e772187dd 100644 --- a/synkronus-cli/go.mod +++ b/synkronus-cli/go.mod @@ -8,15 +8,18 @@ require ( github.com/fatih/color v1.14.1 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 + github.com/yeqown/go-qrcode/v2 v2.2.5 + github.com/yeqown/go-qrcode/writer/standard v1.3.0 golang.org/x/term v0.32.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/fogleman/gg v1.3.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -24,6 +27,7 @@ require ( github.com/mattn/go-isatty v0.0.17 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -31,9 +35,11 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/yeqown/reedsolomon v1.0.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/image v0.10.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/synkronus-cli/go.sum b/synkronus-cli/go.sum index 284c62e59..389745e04 100644 --- a/synkronus-cli/go.sum +++ b/synkronus-cli/go.sum @@ -5,12 +5,16 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -34,6 +38,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -44,8 +50,6 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -68,19 +72,58 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk= +github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw= +github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34= +github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ= +github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= +github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M= +golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/synkronus-cli/internal/cmd/qr.go b/synkronus-cli/internal/cmd/qr.go index 6b03549c1..a6fb8df3b 100644 --- a/synkronus-cli/internal/cmd/qr.go +++ b/synkronus-cli/internal/cmd/qr.go @@ -10,9 +10,10 @@ import ( "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/auth" "github.com/OpenDataEnsemble/ode/synkronus-cli/internal/utils" - qrcode "github.com/skip2/go-qrcode" "github.com/spf13/cobra" "github.com/spf13/viper" + qrcode "github.com/yeqown/go-qrcode/v2" + "github.com/yeqown/go-qrcode/writer/standard" ) // encodeFRMLS replicates the FRMLS encoding used by the Formulus generateQR.ts script. @@ -150,7 +151,20 @@ for the Formulus mobile app. The QR encodes server URL, username, and password.` if outputFile == "" { outputFile = deriveOutputFilename(serverURL) } - if err := qrcode.WriteFile(encoded, qrcode.Medium, 256, outputFile); err != nil { + + // Generate QR code with logo using yeqown/go-qrcode + logoPath := "C:/Users/emil/code/ODE/synkronus-cli/qr_logo.png" + qrc, err := qrcode.New(encoded) + if err != nil { + return fmt.Errorf("failed to create QR code: %w", err) + } + + w, err := standard.New(outputFile, standard.WithLogoImageFilePNG(logoPath)) + if err != nil { + return fmt.Errorf("failed to create QR writer: %w", err) + } + + if err := qrc.Save(w); err != nil { return fmt.Errorf("failed to generate QR code image: %w", err) } diff --git a/synkronus-cli/qr_logo.png b/synkronus-cli/qr_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a5994bc282bef2e9bc4597c28497501e27784345 GIT binary patch literal 35877 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