Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 2 additions & 26 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,14 @@ walkdir = "2.5"
chrono = { version = "0.4", default-features = false, features = ["clock"] }

# Authentication
tiny_http = "0.12"
base64 = "0.22"
ring = "0.17"
open = "5.0"
url = "2.5"
urlencoding = "2.1"

# Update checking
self_update = { version = "0.44", features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate"] }






[build-dependencies]
dotenvy = "0.15"

Expand Down
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,52 @@
Download the latest [release](https://github.com/wvdsh/cli/releases)
Download the latest [release](https://github.com/wvdsh/cli/releases).

## Authentication

For a local desktop session, sign in with the browser flow:

```bash
wavedash auth login
```

Browser login opens Wavedash in your browser and completes automatically when
the browser can reach the CLI's local callback server. If the callback is
unavailable, paste the one-time code from the browser into the CLI prompt.

In CI/CD, create an API key at https://wavedash.com/dev-portal/keys and use it
as a token instead:

```bash
export WAVEDASH_TOKEN=wd_...
wavedash auth status --json
```

`WAVEDASH_TOKEN` is the recommended authentication path for automation. It takes
precedence over stored credentials in `~/.wavedash/credentials.json`.

To store a token locally without putting it in shell history or process
arguments:

```bash
printf "%s" "$WAVEDASH_TOKEN" | wavedash auth login --token-stdin
```

## Automation

For scripts and CI, prefer machine-readable output and disable terminal-only
behavior:

```bash
wavedash --no-color --no-update-check init --team-name "My Studio" --game-title "My Game" --upload-dir dist --engine custom --force --json
```

Use `--json` for structured output. JSON commands suppress update notices by
default. Use `--no-color` or the standard `NO_COLOR` environment variable to
disable ANSI color output. Use `--no-update-check` or
`WAVEDASH_NO_UPDATE_CHECK=1` to disable background update checks.

`wavedash init` is interactive when run without flags. For scripts, pass
explicit flags instead. Non-interactive init requires `--team-id` or
`--team-name`, and `--game-id` or `--game-title`. Pass `--force` to overwrite an
existing `wavedash.toml`.

Run `wavedash <command> --help` for command-specific options.
64 changes: 51 additions & 13 deletions src/achievements.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use crate::auth::{AuthManager, AuthSource};
use crate::config;
use anyhow::{Context, Result};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::Path;

#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize)]
struct Achievement {
_id: String,
identifier: String,
Expand Down Expand Up @@ -35,12 +35,7 @@ async fn upload_achievement_image(
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase())
.ok_or_else(|| {
anyhow::anyhow!(
"Image file has no extension: {}",
image_path.display()
)
})?;
.ok_or_else(|| anyhow::anyhow!("Image file has no extension: {}", image_path.display()))?;

let bytes = std::fs::read(image_path)
.with_context(|| format!("Failed to read image file: {}", image_path.display()))?;
Expand All @@ -66,11 +61,7 @@ async fn upload_achievement_image(
let presigned: ImageUploadUrlResponse = resp.json().await?;

// 2. PUT the bytes directly to R2. UI doesn't set Content-Type either.
let put_resp = client
.put(&presigned.upload_url)
.body(bytes)
.send()
.await?;
let put_resp = client.put(&presigned.upload_url).body(bytes).send().await?;
if !put_resp.status().is_success() {
let status = put_resp.status();
let body = put_resp.text().await.unwrap_or_default();
Expand Down Expand Up @@ -100,6 +91,7 @@ pub struct CreateAchievementArgs<'a> {
pub triggered_by_stat_id: Option<&'a str>,
pub stat_threshold: Option<f64>,
pub image_path: Option<&'a Path>,
pub json: bool,
}

pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Result<()> {
Expand Down Expand Up @@ -145,6 +137,10 @@ pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Resul

let resp = config::check_api_response(resp).await?;
let achievement: Achievement = resp.json().await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&achievement)?);
return Ok(());
}
println!(
"✓ Created achievement \"{}\" (id: {}, identifier: {})",
achievement.display_name, achievement._id, achievement.identifier
Expand All @@ -164,6 +160,25 @@ pub struct UpdateAchievementArgs<'a> {
pub triggered_by_stat_id: Option<Option<&'a str>>,
pub stat_threshold: Option<f64>,
pub image_path: Option<&'a Path>,
pub json: bool,
}

#[derive(Debug, Serialize)]
struct UpdateOutput<'a> {
success: bool,
#[serde(rename = "gameId")]
game_id: &'a str,
#[serde(rename = "achievementId")]
achievement_id: &'a str,
}

#[derive(Debug, Serialize)]
struct DeleteOutput<'a> {
success: bool,
#[serde(rename = "gameId")]
game_id: &'a str,
#[serde(rename = "achievementId")]
achievement_id: &'a str,
}

pub async fn handle_achievement_update(args: UpdateAchievementArgs<'_>) -> Result<()> {
Expand Down Expand Up @@ -231,6 +246,17 @@ pub async fn handle_achievement_update(args: UpdateAchievementArgs<'_>) -> Resul
.await?;

config::check_api_response(resp).await?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&UpdateOutput {
success: true,
game_id: args.game_id,
achievement_id: args.achievement_id,
})?
);
return Ok(());
}
println!("✓ Updated achievement {}", args.achievement_id);
Ok(())
}
Expand All @@ -239,6 +265,7 @@ pub async fn handle_achievement_delete(
game_id: &str,
achievement_id: &str,
force: bool,
json_output: bool,
) -> Result<()> {
let api_key = require_api_key()?;
let client = config::create_http_client()?;
Expand All @@ -255,6 +282,17 @@ pub async fn handle_achievement_delete(
.await?;

config::check_api_response(resp).await?;
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&DeleteOutput {
success: true,
game_id,
achievement_id,
})?
);
return Ok(());
}
println!("✓ Deleted achievement {}", achievement_id);
Ok(())
}
Loading
Loading