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. diff --git a/synkronus-cli/README.md b/synkronus-cli/README.md index 18aa003b9..097467cab 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 @@ -25,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: @@ -35,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. @@ -140,6 +163,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/go.mod b/synkronus-cli/go.mod index e4c4756eb..e772187dd 100644 --- a/synkronus-cli/go.mod +++ b/synkronus-cli/go.mod @@ -10,12 +10,16 @@ require ( github.com/google/uuid v1.6.0 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 @@ -23,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 @@ -30,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 67b999412..389745e04 100644 --- a/synkronus-cli/go.sum +++ b/synkronus-cli/go.sum @@ -5,14 +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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +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= @@ -32,12 +34,12 @@ 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= 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= @@ -70,20 +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.6.0/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/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/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/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/internal/cmd/qr.go b/synkronus-cli/internal/cmd/qr.go new file mode 100644 index 000000000..a6fb8df3b --- /dev/null +++ b/synkronus-cli/internal/cmd/qr.go @@ -0,0 +1,179 @@ +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" + "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. +// 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) + } + + // 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) + } + + 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) +} 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) } 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 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) diff --git a/synkronus-cli/qr_logo.png b/synkronus-cli/qr_logo.png new file mode 100644 index 000000000..a5994bc28 Binary files /dev/null and b/synkronus-cli/qr_logo.png differ