diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 439fe63..2e4ca4a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - run: go vet ./... - run: test -z "$(gofmt -l .)" - run: go test ./... + - run: go run ./examples/testclient js: runs-on: ubuntu-latest @@ -37,6 +38,7 @@ jobs: - run: npm run typecheck - run: npm run build - run: npm test + - run: node examples/testclient.mjs rust: runs-on: ubuntu-latest @@ -52,3 +54,4 @@ jobs: - run: cargo clippy --all-targets -- -D warnings - run: cargo build - run: cargo test + - run: cargo run --example testclient diff --git a/go/README.md b/go/README.md index 0838cef..f7211f6 100644 --- a/go/README.md +++ b/go/README.md @@ -43,3 +43,14 @@ check-in and observe the error/response. `Hostname()`, `OutboundIP()`, `ContainerID()`, `Orchestrator()`, and `ResolveMachineID()` are exported standalone for callers that want the detection logic without the HTTP client. + +## Manual smoke test + +```bash +go run ./examples/testclient # single check-in, local mock server +go run ./examples/testclient -watch # loops via Run() until Ctrl-C +``` + +Set `ORIGIN_SERVICE_KEY` (and optionally `ORIGIN_URL`, `ORIGIN_TYPE`, +`ORIGIN_VERSION`, `ORIGIN_NAME`, `ORIGIN_SITE`, `ORIGIN_ENVIRONMENT`, +`ORIGIN_INTERVAL_HOURS`) to check in against a real endpoint instead. diff --git a/go/examples/testclient/main.go b/go/examples/testclient/main.go new file mode 100644 index 0000000..c9420b5 --- /dev/null +++ b/go/examples/testclient/main.go @@ -0,0 +1,98 @@ +// Command testclient is a runnable smoke test for the Go originclient SDK. +// +// Without ORIGIN_SERVICE_KEY set, it spins up a local mock check-in server +// and exercises the client against it — no network, no credentials needed. +// With ORIGIN_SERVICE_KEY set, it performs a real check-in against +// ORIGIN_URL (or Origin's public endpoint) so the SDK can be smoke-tested +// end-to-end against a live server. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + "os/signal" + "strconv" + "syscall" + + originclient "git.warky.dev/wdevs/origin_client/go" +) + +func main() { + watch := flag.Bool("watch", false, "run continuously via Run() instead of a single check-in") + flag.Parse() + + cfg := originclient.Config{ + URL: os.Getenv("ORIGIN_URL"), + ServiceKey: os.Getenv("ORIGIN_SERVICE_KEY"), + Type: envOr("ORIGIN_TYPE", "icy2-testclient"), + Version: envOr("ORIGIN_VERSION", "0.0.0-test"), + Name: envOr("ORIGIN_NAME", "icy2-testclient-local"), + Site: os.Getenv("ORIGIN_SITE"), + Environment: os.Getenv("ORIGIN_ENVIRONMENT"), + Logger: originclient.LoggerFunc(func(msg string, err error) { log.Printf("[warn] %s: %v", msg, err) }), + } + + if cfg.ServiceKey == "" { + log.Println("ORIGIN_SERVICE_KEY not set — using a local mock check-in server") + server := httptest.NewServer(http.HandlerFunc(mockHandler)) + defer server.Close() + cfg.URL = server.URL + cfg.ServiceKey = "test-key" + cfg.InstallID = "test-install-id" + } + + if h := os.Getenv("ORIGIN_INTERVAL_HOURS"); h != "" { + if n, err := strconv.Atoi(h); err == nil { + cfg.IntervalHours = n + } + } + + client, err := originclient.New(cfg) + if err != nil { + log.Fatalf("originclient.New: %v", err) + } + + if *watch { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + log.Println("watching — Ctrl-C to stop") + client.Run(ctx) + return + } + + resp, err := client.CheckinOnce(context.Background()) + if err != nil { + log.Fatalf("checkin failed: %v", err) + } + out, _ := json.MarshalIndent(resp, "", " ") + fmt.Println(string(out)) +} + +func mockHandler(w http.ResponseWriter, r *http.Request) { + var payload originclient.Payload + _ = json.NewDecoder(r.Body).Decode(&payload) + log.Printf("mock server received check-in: type=%s name=%s install_id=%s key=%s", + payload.Type, payload.Name, payload.UniqueInstallID, r.Header.Get("X-Origin-Service-Key")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(originclient.Response{ + Success: true, + ServiceInstanceID: 1, + UniqueInstallID: payload.UniqueInstallID, + Status: "provisioning", + PingedAt: "2026-08-01T00:00:00Z", + }) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/js/README.md b/js/README.md index 85c34be..ad0f8a8 100644 --- a/js/README.md +++ b/js/README.md @@ -45,3 +45,14 @@ check-in and observe the result/error. `hostname()`, `outboundIp()`, `containerId()`, `orchestrator()`, and `resolveMachineId()` are exported standalone for callers that want the detection logic without the HTTP client. + +## Manual smoke test + +```bash +npm run example # single check-in, local mock server +node examples/testclient.mjs --watch # loops via start() until Ctrl-C +``` + +Set `ORIGIN_SERVICE_KEY` (and optionally `ORIGIN_URL`, `ORIGIN_TYPE`, +`ORIGIN_VERSION`, `ORIGIN_NAME`, `ORIGIN_SITE`, `ORIGIN_ENVIRONMENT`, +`ORIGIN_INTERVAL_HOURS`) to check in against a real endpoint instead. diff --git a/js/examples/testclient.mjs b/js/examples/testclient.mjs new file mode 100644 index 0000000..ea05804 --- /dev/null +++ b/js/examples/testclient.mjs @@ -0,0 +1,103 @@ +// Runnable smoke test for the JS/TS originclient SDK. +// +// Without ORIGIN_SERVICE_KEY set, it spins up a local mock check-in server +// and exercises the client against it — no network, no credentials needed. +// With ORIGIN_SERVICE_KEY set, it performs a real check-in against +// ORIGIN_URL (or Origin's public endpoint) so the SDK can be smoke-tested +// end-to-end against a live server. +// +// Run `npm run build` first (this imports the compiled dist/ output). +// Usage: node examples/testclient.mjs [--watch] + +import { createServer } from "node:http"; +import { OriginClient } from "../dist/index.js"; + +function envOr(key, def) { + return process.env[key] || def; +} + +async function startMockServer() { + return new Promise((resolve) => { + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + const payload = JSON.parse(raw || "{}"); + console.log( + `mock server received check-in: type=${payload.type} name=${payload.name} ` + + `install_id=${payload.unique_install_id} key=${req.headers["x-origin-service-key"]}`, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: true, + service_instance_id: 1, + unique_install_id: payload.unique_install_id, + status: "provisioning", + pinged_at: "2026-08-01T00:00:00Z", + }), + ); + }); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve({ server, url: `http://127.0.0.1:${address.port}` }); + }); + }); +} + +async function main() { + const watch = process.argv.includes("--watch"); + + const config = { + url: process.env.ORIGIN_URL, + serviceKey: process.env.ORIGIN_SERVICE_KEY, + type: envOr("ORIGIN_TYPE", "icy2-testclient"), + version: envOr("ORIGIN_VERSION", "0.0.0-test"), + name: envOr("ORIGIN_NAME", "icy2-testclient-local"), + site: process.env.ORIGIN_SITE, + environment: process.env.ORIGIN_ENVIRONMENT, + logger: { warn: (msg, err) => console.warn(`[warn] ${msg}:`, err) }, + }; + + let mockServer; + if (!config.serviceKey) { + console.log("ORIGIN_SERVICE_KEY not set — using a local mock check-in server"); + const { server, url } = await startMockServer(); + mockServer = server; + config.url = url; + config.serviceKey = "test-key"; + config.installId = "test-install-id"; + } + + if (process.env.ORIGIN_INTERVAL_HOURS) { + config.intervalHours = Number(process.env.ORIGIN_INTERVAL_HOURS); + } + + const client = new OriginClient(config); + + try { + if (watch) { + console.log("watching — Ctrl-C to stop"); + client.start(); + await new Promise((resolve) => { + process.on("SIGINT", resolve); + process.on("SIGTERM", resolve); + }); + client.stop(); + return; + } + + const resp = await client.checkinOnce(); + console.log(JSON.stringify(resp, null, 2)); + } finally { + mockServer?.close(); + } +} + +main().catch((err) => { + console.error("checkin failed:", err); + process.exitCode = 1; +}); diff --git a/js/package.json b/js/package.json index 2ca70ea..197595e 100644 --- a/js/package.json +++ b/js/package.json @@ -21,7 +21,8 @@ "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "example": "npm run build && node examples/testclient.mjs" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 48a664c..b712296 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,4 +22,4 @@ winreg = "0.52" [dev-dependencies] mockito = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] } diff --git a/rust/README.md b/rust/README.md index ba9b9c2..602eb84 100644 --- a/rust/README.md +++ b/rust/README.md @@ -54,6 +54,17 @@ default. `Config` implements `Default`, so use struct-update syntax `resolve_machine_id()` are exported standalone for callers that want the detection logic without the HTTP client. +## Manual smoke test + +```bash +cargo run --example testclient # single check-in, local mock server +cargo run --example testclient -- --watch # loops via run() until Ctrl-C +``` + +Set `ORIGIN_SERVICE_KEY` (and optionally `ORIGIN_URL`, `ORIGIN_TYPE`, +`ORIGIN_VERSION`, `ORIGIN_NAME`, `ORIGIN_SITE`, `ORIGIN_ENVIRONMENT`, +`ORIGIN_INTERVAL_HOURS`) to check in against a real endpoint instead. + ## Platform notes macOS/Windows machine-ID detection is compiled but not exercised by CI diff --git a/rust/examples/testclient.rs b/rust/examples/testclient.rs new file mode 100644 index 0000000..5e7bb49 --- /dev/null +++ b/rust/examples/testclient.rs @@ -0,0 +1,91 @@ +//! Runnable smoke test for the Rust origin-client SDK. +//! +//! Without ORIGIN_SERVICE_KEY set, it spins up a local mock check-in server +//! and exercises the client against it — no network, no credentials needed. +//! With ORIGIN_SERVICE_KEY set, it performs a real check-in against +//! ORIGIN_URL (or Origin's public endpoint) so the SDK can be smoke-tested +//! end-to-end against a live server. +//! +//! Usage: `cargo run --example testclient [-- --watch]` + +use std::env; +use std::sync::Arc; + +use origin_client::{Client, Config, FnLogger}; +use tokio_util::sync::CancellationToken; + +fn env_or(key: &str, default: &str) -> String { + env::var(key).unwrap_or_else(|_| default.to_string()) +} + +#[tokio::main] +async fn main() { + let watch = env::args().any(|a| a == "--watch"); + + let mut cfg = Config { + url: env::var("ORIGIN_URL").ok(), + service_key: env::var("ORIGIN_SERVICE_KEY").unwrap_or_default(), + type_: env_or("ORIGIN_TYPE", "icy2-testclient"), + version: env_or("ORIGIN_VERSION", "0.0.0-test"), + name: env_or("ORIGIN_NAME", "icy2-testclient-local"), + site: env::var("ORIGIN_SITE").ok(), + environment: env::var("ORIGIN_ENVIRONMENT").ok(), + logger: Arc::new(FnLogger(|msg: &str, err| { + eprintln!("[warn] {msg}: {err:?}"); + })), + ..Default::default() + }; + + // Held so the mock server stays alive for the client's lifetime; unused + // in real-endpoint mode. + let mut _mock_server = None; + + if cfg.service_key.is_empty() { + println!("ORIGIN_SERVICE_KEY not set — using a local mock check-in server"); + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"success":true,"service_instance_id":1,"unique_install_id":"test-install-id","status":"provisioning","pinged_at":"2026-08-01T00:00:00Z"}"#, + ) + .create_async() + .await; + + cfg.url = Some(server.url()); + cfg.service_key = "test-key".to_string(); + cfg.install_id = Some("test-install-id".to_string()); + _mock_server = Some(server); + } + + if let Ok(h) = env::var("ORIGIN_INTERVAL_HOURS") { + if let Ok(n) = h.parse() { + cfg.interval_hours = Some(n); + } + } + + let client = Client::new(cfg).expect("origin_client::Config validation failed"); + + if watch { + println!("watching — Ctrl-C to stop"); + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + cancel_clone.cancel(); + }); + client.run(cancel).await; + return; + } + + match client.checkin_once().await { + Ok(resp) => { + println!("{resp:#?}"); + } + Err(err) => { + eprintln!("checkin failed: {err}"); + std::process::exit(1); + } + } +}