diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2bd6c42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: Build and Test Godon CLI + +on: + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v25 + with: + nix_path: nixpkgs=channel:nixos-25.11 + extra_nix_config: | + sandbox = false + sandbox-paths = /etc/ssl/certs/ca-bundle.crt + experimental-features = nix-command flakes + + - name: Configure Nix daemon SSL certificates + run: | + # Find and symlink SSL certificates for Nix daemon + sudo mkdir -p /etc/ssl/certs + CERT_BUNDLE=$(find /nix/store -name "ca-bundle.crt" | head -1) + echo "Found certificate bundle: $CERT_BUNDLE" + sudo ln -sf "$CERT_BUNDLE" /etc/ssl/certs/ca-bundle.crt + sudo ln -sf "$CERT_BUNDLE" /etc/ssl/certs/ca-certificates.crt + + # Set environment variables for this session + export SSL_CERT_FILE="/etc/ssl/certs/ca-bundle.crt" + export NIX_SSL_CERT_FILE="/etc/ssl/certs/ca-bundle.crt" + export CURL_CA_BUNDLE="/etc/ssl/certs/ca-bundle.crt" + + # Add to nix.conf for daemon + echo "ssl-cert-file = /etc/ssl/certs/ca-bundle.crt" | sudo tee -a /etc/nix/nix.conf + + echo "SSL certificates configured for Nix daemon" + + - name: Build with Nix + run: | + export SSL_CERT_FILE="/etc/ssl/certs/ca-bundle.crt" + export NIX_SSL_CERT_FILE="/etc/ssl/certs/ca-bundle.crt" + export CURL_CA_BUNDLE="/etc/ssl/certs/ca-bundle.crt" + nix --experimental-features "nix-command flakes" build --verbose + + - name: Test binary + run: | + echo "Checking build output..." + echo "Result path:" + nix path-info .#default + echo "Contents of result directory:" + ls -la $(nix path-info .#default)/ || echo "result directory contents" + echo "Checking $out/bin directory:" + ls -la $(nix path-info .#default)/bin/ || echo "$out/bin not found" + echo "Testing compiled binary with direct path..." + $(nix path-info .#default)/bin/godon_cli --help diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3ae2d9f --- /dev/null +++ b/flake.nix @@ -0,0 +1,91 @@ +{ + description = "Godon CLI - Nim-based CLI for Godon API"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Build using nimble following the godon-api pattern + godon-cli = { version ? "DEV_BUILD" }: pkgs.stdenv.mkDerivation { + pname = "godon-cli"; + inherit version; + src = ./.; + + nativeBuildInputs = with pkgs; [ + cacert + nim2 + nimble + git + openssl.dev + ]; + + env = { + SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + NIX_SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + CURL_CA_BUNDLE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + }; + + configurePhase = '' + export HOME=$TMPDIR + ''; + + buildPhase = '' + echo "Building godon-cli version: ${version}" + + # Refresh package list and build + nimble refresh + + # Build the CLI + mkdir -p bin + nim c --hints:on --path:src -d:release -d:VERSION="${version}" -o:bin/godon_cli src/godon_cli.nim || { + echo "Compilation failed" + exit 1 + } + + echo "Build completed successfully!" + ''; + + installPhase = '' + mkdir -p $out/bin + + # Install the binary + cp bin/godon_cli $out/bin/godon_cli + chmod +x $out/bin/godon_cli + ''; + + meta = with pkgs.lib; { + description = "CLI for the Godon API"; + license = licenses.agpl3Only; + platforms = platforms.all; + }; + }; + + in { + packages.default = godon-cli { }; + packages.godon-cli = godon-cli; + + # Allow building with custom version + packages.godon-cli-custom = version: godon-cli { inherit version; }; + + # Development shell with Nim and build tools + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + nim2 + nimble + git + ]; + + shellHook = '' + echo "Godon CLI development environment" + echo "Nim: $(nim --version | head -n1)" + echo "Nimble: $(nimble --version | head -n1)" + ''; + }; + }); +} \ No newline at end of file diff --git a/godon_cli.nimble b/godon_cli.nimble new file mode 100644 index 0000000..b840f91 --- /dev/null +++ b/godon_cli.nimble @@ -0,0 +1,38 @@ +# Package information + +version = "0.1.0" +author = "Matthias Tafelmeier" +description = "CLI for the Godon API" +license = "AGPL-3.0" + +# Dependencies + +requires "nim >= 2.0.0" + +# Task definitions + +task build, "Build the CLI": + exec "nim c -d:release -o:bin/godon_cli src/godon_cli.nim" + +task build_debug, "Build the CLI with debug symbols": + exec "nim c -g -o:bin/godon_cli src/godon_cli.nim" + +task clean, "Clean build artifacts": + exec "rm -rf bin" + +task test, "Run tests": + exec "nim c -r tests/test_all.nim" + +task docker_build, "Build Docker image": + exec "docker build -t godon-cli:latest ." + +task docker_run, "Run Docker image": + exec "docker run --rm -it godon-cli:latest --help" + +# Binary definition +bin = @["godon_cli"] + +# Install script (when installed via nimble) +installDirs = @["bin"] +installFiles = @["bin/godon_cli"] +installExt = @[] \ No newline at end of file diff --git a/maskfile.md b/maskfile.md deleted file mode 100644 index 69b43e5..0000000 --- a/maskfile.md +++ /dev/null @@ -1,186 +0,0 @@ - -# Godon CLI - -## breeder - -### breeder list - -**OPTIONS** -* hostname - * flags: --hostname - * type: string - * desc: godon hostname -* port - * flags: --port - * type: number - * desc: godon port -* api_version - * flags: --api-version - * type: string - * desc: godon api version - - -> List all configured breeders - -~~~bash -set -eEux - -__api_version="${api_version:-v0}" - -curl --request GET "http://${hostname}:${port}/${__api_version}/breeders" -~~~ - -### breeder create - -**OPTIONS** -* file - * flags: --file - * type: string - * desc: definition file of breeder to be created -* hostname - * flags: --hostname - * type: string - * desc: godon hostname -* port - * flags: --port - * type: number - * desc: godon port -* api_version - * flags: --api-version - * type: string - * desc: godon api version - -> Create a breeder - -~~~bash -set -eEux - -__api_version="${api_version:-v0}" -__file="${file}" -__temp_file_json_tranfer="$(mktemp)" - -cat "${__file}" | python -c 'import sys, yaml, json; json.dump(yaml.safe_load(sys.stdin), sys.stdout, indent=4)' > "${__temp_file_json_tranfer}" - -curl --request POST \ - -H 'Content-Type: application/json' \ - --data @"${__temp_file_json_tranfer}" \ - "http://${hostname}:${port}/${__api_version}/breeders" -~~~ - -### breeder purge - -**OPTIONS** -* uuid - * flags: --uuid - * type: string - * desc: uuid of breeder to be purged -* hostname - * flags: --hostname - * type: string - * desc: godon hostname -* port - * flags: --port - * type: number - * desc: godon port -* api_version - * flags: --api-version - * type: string - * desc: godon api version - -> Purge a breeder - -~~~bash -set -eEux - -__api_version="${api_version:-v0}" - -curl --request DELETE \ - "http://${hostname}:${port}/${__api_version}/breeder?uuid=${uuid}" -~~~ - -### breeder update - -**OPTIONS** -* file - * flags: --file - * type: string - * desc: definition file of breeder to be updated -* hostname - * flags: --hostname - * type: string - * desc: godon hostname -* port - * flags: --port - * type: number - * desc: godon port -* api_version - * flags: --api-version - * type: string - * desc: godon api version - -> Update a breeder - -~~~bash - -set -eEux - -__api_version="${api_version:-v0}" -__temp_file_json_tranfer="$(mktemp)" - -cat "${file}" | python -c 'import sys, yaml, json; json.dump(yaml.safe_load(sys.stdin), sys.stdout, indent=4)' > "${__temp_file_json_tranfer}" - -curl --request PUT \ - -H 'Content-Type: application/json' \ - --data @"${__temp_file_json_tranfer}" \ - "http://${hostname}:${port}/${__api_version}/breeders" -~~~ - -### breeder show - -**OPTIONS** -* uuid - * flags: --uuid - * type: string - * desc: uuid of breeder to get details from -* hostname - * flags: --hostname - * type: string - * desc: godon hostname -* port - * flags: --port - * type: number - * desc: godon port -* api_version - * flags: --api-version - * type: string - * desc: godon api version - -> Show a breeder - -~~~bash -set -eEux - -__api_version="${api_version:-v0}" - -curl --request GET \ - -H 'Content-Type: application/json' \ - --data "{ \"id\": \"${uuid}\" }" \ - "http://${hostname}:${port}/${__api_version}/breeder?uuid=${uuid}" -~~~ diff --git a/src/godon/breeder.nim b/src/godon/breeder.nim new file mode 100644 index 0000000..f1bc096 --- /dev/null +++ b/src/godon/breeder.nim @@ -0,0 +1,87 @@ +## Breeder API Methods +## Implementation of breeder-related API endpoints + +import std/[httpclient, json, strutils, uri] +import client, types + +proc listBreeders*(client: GodonClient): ApiResponse[seq[Breeder]] = + ## List all configured breeders + try: + let url = client.baseUrl() & "/breeders" + let response = client.httpClient.get(url) + result = handleResponse[seq[Breeder]](client, response) + except CatchableError as e: + result = ApiResponse[seq[Breeder]](success: false, data: @[], error: e.msg) + +proc createBreeder*(client: GodonClient, request: BreederCreateRequest): ApiResponse[Breeder] = + ## Create a new breeder + try: + let url = client.baseUrl() & "/breeders" + let jsonData = %*request + let response = client.httpClient.post(url, $jsonData) + result = handleResponse[Breeder](client, response) + except CatchableError as e: + result = ApiResponse[Breeder](success: false, data: default(Breeder), error: e.msg) + +proc parseBreederFromYaml*(yamlContent: string): BreederCreateRequest = + ## Parse breeder configuration from YAML content + ## Note: This would require a YAML library like yaml.nim + ## For now, this is a placeholder for the YAML parsing logic + ## Users can pass JSON directly or we can add proper YAML parsing later + try: + let jsonNode = parseJson(yamlContent) + result = jsonNode.to(BreederCreateRequest) + except CatchableError as e: + raise newException(ValueError, "Failed to parse YAML/JSON: " & e.msg) + +proc createBreederFromYaml*(client: GodonClient, yamlContent: string): ApiResponse[Breeder] = + ## Create a breeder from YAML content + try: + let request = parseBreederFromYaml(yamlContent) + result = client.createBreeder(request) + except CatchableError as e: + result = ApiResponse[Breeder](success: false, data: default(Breeder), error: e.msg) + +proc getBreeder*(client: GodonClient, uuid: string): ApiResponse[Breeder] = + ## Get breeder details by UUID + try: + let url = client.baseUrl() & "/breeder?uuid=" & encodeUrl(uuid) + let response = client.httpClient.get(url) + result = handleResponse[Breeder](client, response) + except CatchableError as e: + result = ApiResponse[Breeder](success: false, data: default(Breeder), error: e.msg) + +proc updateBreeder*(client: GodonClient, request: BreederUpdateRequest): ApiResponse[Breeder] = + ## Update an existing breeder + try: + let url = client.baseUrl() & "/breeders" + let jsonData = %*request + let response = client.httpClient.put(url, $jsonData) + result = handleResponse[Breeder](client, response) + except CatchableError as e: + result = ApiResponse[Breeder](success: false, data: default(Breeder), error: e.msg) + +proc parseBreederUpdateFromYaml*(yamlContent: string): BreederUpdateRequest = + ## Parse breeder update configuration from YAML content + try: + let jsonNode = parseJson(yamlContent) + result = jsonNode.to(BreederUpdateRequest) + except CatchableError as e: + raise newException(ValueError, "Failed to parse YAML/JSON: " & e.msg) + +proc updateBreederFromYaml*(client: GodonClient, yamlContent: string): ApiResponse[Breeder] = + ## Update a breeder from YAML content + try: + let request = parseBreederUpdateFromYaml(yamlContent) + result = client.updateBreeder(request) + except CatchableError as e: + result = ApiResponse[Breeder](success: false, data: default(Breeder), error: e.msg) + +proc deleteBreeder*(client: GodonClient, uuid: string): ApiResponse[JsonNode] = + ## Delete/purge a breeder by UUID + try: + let url = client.baseUrl() & "/breeder?uuid=" & encodeUrl(uuid) + let response = client.httpClient.delete(url) + result = handleResponse[JsonNode](client, response) + except CatchableError as e: + result = ApiResponse[JsonNode](success: false, data: nil, error: e.msg) \ No newline at end of file diff --git a/src/godon/client.nim b/src/godon/client.nim new file mode 100644 index 0000000..4dd0749 --- /dev/null +++ b/src/godon/client.nim @@ -0,0 +1,61 @@ +## Godon HTTP Client +## Core HTTP client for Godon API + +import std/[httpclient, json, uri, strutils] +import types + +const + DefaultHostname* = "localhost" + DefaultPort* = 8080 + DefaultApiVersion* = "v0" + +type + GodonClient* = ref object + config*: ApiConfig + httpClient*: HttpClient + +proc newGodonClient*(hostname: string = DefaultHostname, + port: int = DefaultPort, + apiVersion: string = DefaultApiVersion): GodonClient = + ## Create a new Godon API client + let config = ApiConfig( + hostname: hostname, + port: port, + apiVersion: apiVersion + ) + + let httpClient = newHttpClient() + GodonClient(config: config, httpClient: httpClient) + +proc baseUrl*(client: GodonClient): string = + ## Get the base URL for API requests + result = "http://" & client.config.hostname & ":" & $client.config.port & "/" & client.config.apiVersion + +proc handleResponse*[T](client: GodonClient; response: Response): ApiResponse[T] = + ## Handle HTTP response and convert to ApiResponse + let statusCode = parseInt(response.status) + if statusCode >= 200 and statusCode < 300: + try: + let jsonData = parseJson(response.body) + result = ApiResponse[T](success: true, data: jsonData.to(T), error: "") + except CatchableError as e: + result = ApiResponse[T](success: false, data: default(T), error: "JSON parse error: " & e.msg) + else: + try: + let errorJson = parseJson(response.body) + let errorMsg = errorJson.getOrDefault("message").getStr("HTTP Error: " & $statusCode) + result = ApiResponse[T](success: false, data: default(T), error: errorMsg) + except CatchableError: + result = ApiResponse[T](success: false, data: default(T), error: "HTTP Error: " & $statusCode) + +proc handleError*(client: GodonClient, response: Response): ref CatchableError = + ## Convert HTTP error response to exception + let statusCode = parseInt(response.status) + var errorMsg = "HTTP Error: " & $statusCode + try: + let errorJson = parseJson(response.body) + errorMsg = errorJson.getOrDefault("message").getStr(errorMsg) + except CatchableError: + discard + + newException(CatchableError, errorMsg) \ No newline at end of file diff --git a/src/godon/types.nim b/src/godon/types.nim new file mode 100644 index 0000000..6a5340a --- /dev/null +++ b/src/godon/types.nim @@ -0,0 +1,39 @@ +## Godon API Types +## Generated based on OpenAPI specification + +import std/json + +type + Breeder* = object + uuid*: string + name*: string + description*: string + config*: JsonNode + createdAt*: string + updatedAt*: string + + BreederCreateRequest* = object + name*: string + description*: string + config*: JsonNode + + BreederUpdateRequest* = object + uuid*: string + name*: string + description*: string + config*: JsonNode + + ApiConfig* = object + hostname*: string + port*: int + apiVersion*: string + + ApiResponse*[T] = object + success*: bool + data*: T + error*: string + + ApiError* = object + code*: int + message*: string + details*: JsonNode \ No newline at end of file diff --git a/src/godon_cli.nim b/src/godon_cli.nim new file mode 100644 index 0000000..c0b8b53 --- /dev/null +++ b/src/godon_cli.nim @@ -0,0 +1,200 @@ +import std/[parseopt, strutils, os, json] +import godon/[client, breeder, types] + +proc writeHelp() = + echo """Godon CLI - Command line interface for Godon API + +Usage: + godon_cli [options] [command-options] + +Commands: + breeder list List all configured breeders + breeder create --file Create a breeder from file + breeder show --uuid Show breeder details + breeder update --file Update a breeder from file + breeder purge --uuid Delete a breeder + +Global Options: + --hostname, -h Godon hostname (default: localhost) + --port, -p Godon port (default: 8080) + --api-version, -v API version (default: v0) + --help, -h Show this help message + +Examples: + godon_cli breeder list + godon_cli --hostname api.example.com --port 9090 breeder list + godon_cli breeder create --file breeder.yaml + godon_cli breeder show --uuid 123e4567-e89b-12d3-a456-426614174000 +""" + +proc writeError(message: string) = + stderr.writeLine("Error: " & message) + quit(1) + +proc parseArgs(): (string, string, int, string, seq[string]) = + var command = "" + var hostname = "localhost" + var port = 8080 + var apiVersion = "v0" + var args: seq[string] = @[] + + var p = initOptParser(commandLineParams()) + + for kind, key, val in p.getopt(): + case kind + of cmdArgument: + if command.len == 0: + command = key + else: + args.add(key) + + of cmdLongOption, cmdShortOption: + case key.normalize() + of "hostname": + hostname = val + of "port": + try: + port = parseInt(val) + except ValueError: + writeError("Invalid port number: " & val) + of "api-version": + apiVersion = val + of "help", "h": + writeHelp() + quit(0) + else: + writeError("Unknown option: " & key) + + of cmdEnd: + discard + + if command.len == 0: + writeHelp() + quit(0) + + (command, hostname, port, apiVersion, args) + +proc handleBreederCommand(client: GodonClient, command: string, args: seq[string]) = + let subCommand = if args.len > 0: args[0] else: "" + + case subCommand: + of "list": + echo "Listing breeders..." + let response = client.listBreeders() + if response.success: + echo "Breeders:" + for breeder in response.data: + echo " UUID: ", breeder.uuid + echo " Name: ", breeder.name + echo " Description: ", breeder.description + echo " Created: ", breeder.createdAt + echo " Updated: ", breeder.updatedAt + echo " ---" + else: + writeError(response.error) + + of "create": + var file = "" + for i in 1 ..< args.len.int: + if args[i-1] == "--file": + file = args[i] + break + + if file.len == 0: + writeError("breeder create requires --file ") + + if not fileExists(file): + writeError("File not found: " & file) + + echo "Creating breeder from file: ", file + let content = readFile(file) + let response = client.createBreederFromYaml(content) + if response.success: + echo "Breeder created successfully:" + echo " UUID: ", response.data.uuid + echo " Name: ", response.data.name + echo " Description: ", response.data.description + else: + writeError(response.error) + + of "show": + var uuid = "" + for i in 1 ..< args.len.int: + if args[i-1] == "--uuid": + uuid = args[i] + break + + if uuid.len == 0: + writeError("breeder show requires --uuid ") + + echo "Getting breeder details for UUID: ", uuid + let response = client.getBreeder(uuid) + if response.success: + echo "Breeder Details:" + echo " UUID: ", response.data.uuid + echo " Name: ", response.data.name + echo " Description: ", response.data.description + echo " Config: ", pretty(response.data.config) + echo " Created: ", response.data.createdAt + echo " Updated: ", response.data.updatedAt + else: + writeError(response.error) + + of "update": + var file = "" + for i in 1 ..< args.len.int: + if args[i-1] == "--file": + file = args[i] + break + + if file.len == 0: + writeError("breeder update requires --file ") + + if not fileExists(file): + writeError("File not found: " & file) + + echo "Updating breeder from file: ", file + let content = readFile(file) + let response = client.updateBreederFromYaml(content) + if response.success: + echo "Breeder updated successfully:" + echo " UUID: ", response.data.uuid + echo " Name: ", response.data.name + echo " Description: ", response.data.description + else: + writeError(response.error) + + of "purge": + var uuid = "" + for i in 1 ..< args.len.int: + if args[i-1] == "--uuid": + uuid = args[i] + break + + if uuid.len == 0: + writeError("breeder purge requires --uuid ") + + echo "Deleting breeder with UUID: ", uuid + let response = client.deleteBreeder(uuid) + if response.success: + echo "Breeder deleted successfully" + if response.data != nil: + echo "Response: ", pretty(response.data) + else: + writeError(response.error) + + else: + writeError("Unknown breeder command: " & subCommand) + +let (command, hostname, port, apiVersion, args) = parseArgs() + +let godonClient = newGodonClient(hostname, port, apiVersion) + +case command: +of "breeder": + if args.len == 0: + writeError("breeder command requires a subcommand (list, create, show, update, purge)") + handleBreederCommand(godonClient, command, args) + +else: + writeError("Unknown command: " & command) \ No newline at end of file