mirror of
https://github.com/m5stack/CardputerZero-AppBuilder.git
synced 2026-05-20 11:51:57 -07:00
feat(czdev): add login, publish, unpublish commands
- czdev login: GitHub OAuth Device Flow authentication - czdev publish: upload .deb to CardputerZero/packages via PR - Preflight: .desktop check, email match, version bump check - czdev unpublish: ownership-verified removal PR - release-czdev.yml: cross-compile for macOS/Linux/Windows Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e9d54237d1
commit
544f75c33b
@@ -0,0 +1,83 @@
|
||||
name: Release czdev
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['czdev-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
artifact: czdev-linux-x86_64
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
artifact: czdev-linux-aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
artifact: czdev-macos-x86_64
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
artifact: czdev-macos-aarch64
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
artifact: czdev-windows-x86_64.exe
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation tools (Linux aarch64)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
|
||||
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target ${{ matrix.target }} -p czdev
|
||||
|
||||
- name: Rename artifact (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: cp target/${{ matrix.target }}/release/czdev ${{ matrix.artifact }}
|
||||
|
||||
- name: Rename artifact (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: cp target/${{ matrix.target }}/release/czdev.exe ${{ matrix.artifact }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: ${{ matrix.artifact }}
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: dist/*
|
||||
Generated
+389
-12
File diff suppressed because it is too large
Load Diff
@@ -16,3 +16,8 @@ clap = { version = "4.5", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
dirs = "5"
|
||||
open = "5"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::github::GitHubClient;
|
||||
|
||||
// Register your own OAuth App at https://github.com/settings/applications/new
|
||||
// Device flow does not require a client secret.
|
||||
const GITHUB_CLIENT_ID: &str = "REPLACE_WITH_REAL_CLIENT_ID";
|
||||
const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
|
||||
const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub github_token: String,
|
||||
pub github_username: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub fn credentials_path() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?;
|
||||
Ok(home.join(".czdev").join("credentials"))
|
||||
}
|
||||
|
||||
pub fn load_token() -> Result<String> {
|
||||
let path = credentials_path()?;
|
||||
if !path.exists() {
|
||||
return Err(anyhow!(
|
||||
"not logged in. Run `czdev login` first."
|
||||
));
|
||||
}
|
||||
let data = fs::read_to_string(&path).context("reading credentials")?;
|
||||
let creds: Credentials = serde_json::from_str(&data).context("parsing credentials")?;
|
||||
Ok(creds.github_token)
|
||||
}
|
||||
|
||||
pub fn load_credentials() -> Result<Credentials> {
|
||||
let path = credentials_path()?;
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("not logged in. Run `czdev login` first."));
|
||||
}
|
||||
let data = fs::read_to_string(&path).context("reading credentials")?;
|
||||
serde_json::from_str(&data).context("parsing credentials")
|
||||
}
|
||||
|
||||
fn save_credentials(creds: &Credentials) -> Result<()> {
|
||||
let path = credentials_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).context("creating ~/.czdev")?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(creds)?;
|
||||
fs::write(&path, &json).context("writing credentials")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceCodeResponse {
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn login() -> Result<()> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
println!("Requesting device code from GitHub...");
|
||||
let resp: DeviceCodeResponse = client
|
||||
.post(DEVICE_CODE_URL)
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("scope", "public_repo"),
|
||||
])
|
||||
.send()
|
||||
.context("requesting device code")?
|
||||
.json()
|
||||
.context("parsing device code response")?;
|
||||
|
||||
println!();
|
||||
println!(" Open: {}", resp.verification_uri);
|
||||
println!(" Code: {}", resp.user_code);
|
||||
println!();
|
||||
|
||||
let _ = open::that(&resp.verification_uri);
|
||||
|
||||
println!("Waiting for authorization (press Ctrl-C to cancel)...");
|
||||
|
||||
let token = loop {
|
||||
thread::sleep(Duration::from_secs(resp.interval));
|
||||
|
||||
let token_resp: TokenResponse = client
|
||||
.post(ACCESS_TOKEN_URL)
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", resp.device_code.as_str()),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.context("polling for token")?
|
||||
.json()
|
||||
.context("parsing token response")?;
|
||||
|
||||
if let Some(token) = token_resp.access_token {
|
||||
break token;
|
||||
}
|
||||
|
||||
match token_resp.error.as_deref() {
|
||||
Some("authorization_pending") => continue,
|
||||
Some("slow_down") => {
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
continue;
|
||||
}
|
||||
Some("expired_token") => return Err(anyhow!("device code expired, please try again")),
|
||||
Some(e) => return Err(anyhow!("OAuth error: {e}")),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
|
||||
let gh = GitHubClient::new(&token);
|
||||
let user = gh.get_user().context("verifying token")?;
|
||||
|
||||
let creds = Credentials {
|
||||
github_token: token,
|
||||
github_username: user.login.clone(),
|
||||
created_at: chrono_now(),
|
||||
};
|
||||
save_credentials(&creds)?;
|
||||
|
||||
println!();
|
||||
println!("✓ Logged in as {} ({})", user.login, user.email.unwrap_or_default());
|
||||
println!(" Token saved to {:?}", credentials_path()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn logout() -> Result<()> {
|
||||
let path = credentials_path()?;
|
||||
if path.exists() {
|
||||
let creds = load_credentials().ok();
|
||||
fs::remove_file(&path).context("removing credentials")?;
|
||||
if let Some(c) = creds {
|
||||
println!("Removed credentials for {}.", c.github_username);
|
||||
} else {
|
||||
println!("Credentials removed.");
|
||||
}
|
||||
} else {
|
||||
println!("Not logged in.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
// Simple ISO-8601 without pulling in chrono crate
|
||||
use std::time::SystemTime;
|
||||
let dur = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
format!("{}", dur.as_secs())
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const GITHUB_API: &str = "https://api.github.com";
|
||||
|
||||
pub struct GitHubClient {
|
||||
token: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct User {
|
||||
pub login: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UserEmail {
|
||||
pub email: String,
|
||||
pub verified: bool,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct PermissionResponse {
|
||||
permission: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct RefObject {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct RefResponse {
|
||||
object: RefObject,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct CommitTreeRef {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct CommitResponse {
|
||||
sha: String,
|
||||
tree: CommitTreeRef,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct BlobResponse {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct TreeResponse {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct CreateCommitResponse {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct PullRequestResponse {
|
||||
pub html_url: String,
|
||||
pub number: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BlobRequest {
|
||||
content: String,
|
||||
encoding: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TreeEntry {
|
||||
path: String,
|
||||
mode: String,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sha: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateTreeRequest {
|
||||
base_tree: String,
|
||||
tree: Vec<TreeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateCommitRequest {
|
||||
message: String,
|
||||
tree: String,
|
||||
parents: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateRefRequest {
|
||||
#[serde(rename = "ref")]
|
||||
ref_name: String,
|
||||
sha: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreatePrRequest {
|
||||
title: String,
|
||||
body: String,
|
||||
head: String,
|
||||
base: String,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, PartialOrd)]
|
||||
pub enum Permission {
|
||||
None,
|
||||
Read,
|
||||
Write,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl GitHubClient {
|
||||
pub fn new(token: &str) -> Self {
|
||||
Self {
|
||||
token: token.to_string(),
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, path: &str) -> reqwest::blocking::RequestBuilder {
|
||||
self.client
|
||||
.get(format!("{GITHUB_API}{path}"))
|
||||
.header(AUTHORIZATION, format!("Bearer {}", self.token))
|
||||
.header(USER_AGENT, "czdev/0.1")
|
||||
.header(ACCEPT, "application/vnd.github+json")
|
||||
}
|
||||
|
||||
fn post(&self, path: &str) -> reqwest::blocking::RequestBuilder {
|
||||
self.client
|
||||
.post(format!("{GITHUB_API}{path}"))
|
||||
.header(AUTHORIZATION, format!("Bearer {}", self.token))
|
||||
.header(USER_AGENT, "czdev/0.1")
|
||||
.header(ACCEPT, "application/vnd.github+json")
|
||||
}
|
||||
|
||||
pub fn get_user(&self) -> Result<User> {
|
||||
self.get("/user")
|
||||
.send()
|
||||
.context("GET /user")?
|
||||
.error_for_status()
|
||||
.context("GET /user status")?
|
||||
.json()
|
||||
.context("parsing user")
|
||||
}
|
||||
|
||||
pub fn get_user_emails(&self) -> Result<Vec<UserEmail>> {
|
||||
self.get("/user/emails")
|
||||
.send()
|
||||
.context("GET /user/emails")?
|
||||
.error_for_status()
|
||||
.context("GET /user/emails status")?
|
||||
.json()
|
||||
.context("parsing emails")
|
||||
}
|
||||
|
||||
pub fn get_verified_emails(&self) -> Result<Vec<String>> {
|
||||
let emails = self.get_user_emails()?;
|
||||
Ok(emails
|
||||
.into_iter()
|
||||
.filter(|e| e.verified)
|
||||
.map(|e| e.email)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn check_permission(&self, owner: &str, repo: &str, username: &str) -> Result<Permission> {
|
||||
let resp = self
|
||||
.get(&format!("/repos/{owner}/{repo}/collaborators/{username}/permission"))
|
||||
.send()
|
||||
.context("checking permission")?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(Permission::None);
|
||||
}
|
||||
|
||||
let pr: PermissionResponse = resp
|
||||
.error_for_status()
|
||||
.context("permission status")?
|
||||
.json()
|
||||
.context("parsing permission")?;
|
||||
|
||||
Ok(match pr.permission.as_str() {
|
||||
"admin" => Permission::Admin,
|
||||
"maintain" | "write" => Permission::Write,
|
||||
"read" | "triage" => Permission::Read,
|
||||
_ => Permission::None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fork_repo(&self, owner: &str, repo: &str) -> Result<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct ForkResp {
|
||||
full_name: String,
|
||||
}
|
||||
let resp: ForkResp = self
|
||||
.post(&format!("/repos/{owner}/{repo}/forks"))
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.context("forking repo")?
|
||||
.error_for_status()
|
||||
.context("fork status")?
|
||||
.json()
|
||||
.context("parsing fork")?;
|
||||
Ok(resp.full_name)
|
||||
}
|
||||
|
||||
pub fn get_ref_sha(&self, owner: &str, repo: &str, ref_name: &str) -> Result<String> {
|
||||
let resp: RefResponse = self
|
||||
.get(&format!("/repos/{owner}/{repo}/git/ref/{ref_name}"))
|
||||
.send()
|
||||
.context("GET ref")?
|
||||
.error_for_status()
|
||||
.context("GET ref status")?
|
||||
.json()
|
||||
.context("parsing ref")?;
|
||||
Ok(resp.object.sha)
|
||||
}
|
||||
|
||||
pub fn get_commit(&self, owner: &str, repo: &str, sha: &str) -> Result<(String, String)> {
|
||||
let resp: CommitResponse = self
|
||||
.get(&format!("/repos/{owner}/{repo}/git/commits/{sha}"))
|
||||
.send()
|
||||
.context("GET commit")?
|
||||
.error_for_status()
|
||||
.context("GET commit status")?
|
||||
.json()
|
||||
.context("parsing commit")?;
|
||||
Ok((resp.sha, resp.tree.sha))
|
||||
}
|
||||
|
||||
pub fn create_blob(&self, owner: &str, repo: &str, content_base64: &str) -> Result<String> {
|
||||
let resp: BlobResponse = self
|
||||
.post(&format!("/repos/{owner}/{repo}/git/blobs"))
|
||||
.json(&BlobRequest {
|
||||
content: content_base64.to_string(),
|
||||
encoding: "base64".to_string(),
|
||||
})
|
||||
.send()
|
||||
.context("creating blob")?
|
||||
.error_for_status()
|
||||
.context("create blob status")?
|
||||
.json()
|
||||
.context("parsing blob")?;
|
||||
Ok(resp.sha)
|
||||
}
|
||||
|
||||
pub fn create_tree(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
base_tree: &str,
|
||||
path: &str,
|
||||
blob_sha: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let entry = TreeEntry {
|
||||
path: path.to_string(),
|
||||
mode: "100644".to_string(),
|
||||
entry_type: "blob".to_string(),
|
||||
sha: blob_sha.map(|s| s.to_string()),
|
||||
};
|
||||
let resp: TreeResponse = self
|
||||
.post(&format!("/repos/{owner}/{repo}/git/trees"))
|
||||
.json(&CreateTreeRequest {
|
||||
base_tree: base_tree.to_string(),
|
||||
tree: vec![entry],
|
||||
})
|
||||
.send()
|
||||
.context("creating tree")?
|
||||
.error_for_status()
|
||||
.context("create tree status")?
|
||||
.json()
|
||||
.context("parsing tree")?;
|
||||
Ok(resp.sha)
|
||||
}
|
||||
|
||||
pub fn create_commit(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
message: &str,
|
||||
tree_sha: &str,
|
||||
parent_sha: &str,
|
||||
) -> Result<String> {
|
||||
let resp: CreateCommitResponse = self
|
||||
.post(&format!("/repos/{owner}/{repo}/git/commits"))
|
||||
.json(&CreateCommitRequest {
|
||||
message: message.to_string(),
|
||||
tree: tree_sha.to_string(),
|
||||
parents: vec![parent_sha.to_string()],
|
||||
})
|
||||
.send()
|
||||
.context("creating commit")?
|
||||
.error_for_status()
|
||||
.context("create commit status")?
|
||||
.json()
|
||||
.context("parsing commit")?;
|
||||
Ok(resp.sha)
|
||||
}
|
||||
|
||||
pub fn create_ref(&self, owner: &str, repo: &str, ref_name: &str, sha: &str) -> Result<()> {
|
||||
self.post(&format!("/repos/{owner}/{repo}/git/refs"))
|
||||
.json(&CreateRefRequest {
|
||||
ref_name: format!("refs/heads/{ref_name}"),
|
||||
sha: sha.to_string(),
|
||||
})
|
||||
.send()
|
||||
.context("creating ref")?
|
||||
.error_for_status()
|
||||
.context("create ref status")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_pull_request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
head: &str,
|
||||
base: &str,
|
||||
) -> Result<PullRequestResponse> {
|
||||
self.post(&format!("/repos/{owner}/{repo}/pulls"))
|
||||
.json(&CreatePrRequest {
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
head: head.to_string(),
|
||||
base: base.to_string(),
|
||||
})
|
||||
.send()
|
||||
.context("creating PR")?
|
||||
.error_for_status()
|
||||
.context("create PR status")?
|
||||
.json()
|
||||
.context("parsing PR")
|
||||
}
|
||||
|
||||
pub fn get_file_content(&self, owner: &str, repo: &str, path: &str) -> Result<Vec<u8>> {
|
||||
let resp = self
|
||||
.get(&format!("/repos/{owner}/{repo}/contents/{path}"))
|
||||
.header(ACCEPT, "application/vnd.github.raw+json")
|
||||
.send()
|
||||
.context("GET file content")?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("file not found: {path}"));
|
||||
}
|
||||
|
||||
Ok(resp
|
||||
.error_for_status()
|
||||
.context("GET file status")?
|
||||
.bytes()
|
||||
.context("reading bytes")?
|
||||
.to_vec())
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
mod auth;
|
||||
mod build;
|
||||
mod deploy;
|
||||
mod doctor;
|
||||
mod github;
|
||||
mod list;
|
||||
mod manifest;
|
||||
mod paths;
|
||||
mod publish;
|
||||
mod run;
|
||||
mod unpublish;
|
||||
mod watch;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -62,6 +66,31 @@ enum Command {
|
||||
#[arg(long)]
|
||||
deb: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Authenticate with GitHub (device flow).
|
||||
Login,
|
||||
|
||||
/// Remove stored GitHub credentials.
|
||||
Logout,
|
||||
|
||||
/// Publish a .deb package to the CardputerZero app store.
|
||||
Publish {
|
||||
/// Path to the .deb file. If omitted, searches ./build/*.deb
|
||||
#[arg(long)]
|
||||
deb: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Create a PR to remove a published package (you can only remove your own).
|
||||
Unpublish {
|
||||
/// Package name to remove
|
||||
package: String,
|
||||
/// Version to remove
|
||||
#[arg(long)]
|
||||
version: String,
|
||||
/// Architecture (default: arm64)
|
||||
#[arg(long, default_value = "arm64")]
|
||||
arch: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -76,5 +105,13 @@ fn main() -> Result<()> {
|
||||
Command::Run { path } => run::run_app(&path),
|
||||
Command::Watch { path } => watch::run(&path),
|
||||
Command::Deploy { path, host, deb } => deploy::run(&path, host.as_deref(), deb.as_deref()),
|
||||
Command::Login => auth::login(),
|
||||
Command::Logout => auth::logout(),
|
||||
Command::Publish { deb } => publish::run(deb.as_deref()),
|
||||
Command::Unpublish {
|
||||
package,
|
||||
version,
|
||||
arch,
|
||||
} => unpublish::run(&package, &version, &arch),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::auth;
|
||||
use crate::github::{GitHubClient, Permission};
|
||||
|
||||
const TARGET_OWNER: &str = "CardputerZero";
|
||||
const TARGET_REPO: &str = "packages";
|
||||
|
||||
struct DebMetadata {
|
||||
package: String,
|
||||
version: String,
|
||||
architecture: String,
|
||||
maintainer: String,
|
||||
maintainer_email: String,
|
||||
}
|
||||
|
||||
pub fn run(deb: Option<&Path>) -> Result<()> {
|
||||
let deb_path = resolve_deb(deb)?;
|
||||
println!("Package: {}", deb_path.display());
|
||||
println!();
|
||||
|
||||
let token = auth::load_token()?;
|
||||
let gh = GitHubClient::new(&token);
|
||||
|
||||
let user = gh.get_user().context("fetching user info")?;
|
||||
let verified_emails = gh.get_verified_emails()?;
|
||||
|
||||
// Also include the noreply email
|
||||
let noreply = format!("{}@users.noreply.github.com", user.login);
|
||||
let mut all_emails = verified_emails.clone();
|
||||
if !all_emails.contains(&noreply) {
|
||||
all_emails.push(noreply);
|
||||
}
|
||||
|
||||
println!("Preflight checks:");
|
||||
|
||||
// 1. Check .desktop file exists
|
||||
let has_desktop = check_desktop(&deb_path)?;
|
||||
if !has_desktop {
|
||||
return Err(anyhow!(
|
||||
"deb does not contain a .desktop file. All CardputerZero apps must include one."
|
||||
));
|
||||
}
|
||||
println!(" ✓ .desktop file found");
|
||||
|
||||
// 2. Extract metadata and check email
|
||||
let meta = extract_metadata(&deb_path)?;
|
||||
if !all_emails.iter().any(|e| e.eq_ignore_ascii_case(&meta.maintainer_email)) {
|
||||
return Err(anyhow!(
|
||||
"Maintainer email '{}' does not match any of your GitHub verified emails.\n \
|
||||
Your emails: {:?}\n \
|
||||
The deb Maintainer field must use your GitHub email.",
|
||||
meta.maintainer_email,
|
||||
all_emails
|
||||
));
|
||||
}
|
||||
println!(" ✓ Maintainer email matches GitHub account");
|
||||
|
||||
// 3. Package name validation
|
||||
if !is_valid_package_name(&meta.package) {
|
||||
return Err(anyhow!(
|
||||
"Invalid package name '{}'. Must match [a-z0-9][a-z0-9.+-]+",
|
||||
meta.package
|
||||
));
|
||||
}
|
||||
println!(" ✓ Package name \"{}\" is valid", meta.package);
|
||||
|
||||
// 4. Show summary
|
||||
let file_size = std::fs::metadata(&deb_path)?.len();
|
||||
let size_mb = file_size as f64 / 1_048_576.0;
|
||||
println!(
|
||||
" ✓ Version: {}, Arch: {}, Size: {:.1} MB",
|
||||
meta.version, meta.architecture, size_mb
|
||||
);
|
||||
println!();
|
||||
|
||||
if file_size > 100 * 1024 * 1024 {
|
||||
return Err(anyhow!(
|
||||
"File too large ({:.1} MB). GitHub blob API limit is 100 MB.",
|
||||
size_mb
|
||||
));
|
||||
}
|
||||
|
||||
// 5. Check version is newer than existing
|
||||
check_version_newer(&gh, &meta)?;
|
||||
|
||||
// Determine target: direct push or fork
|
||||
let perm = gh.check_permission(TARGET_OWNER, TARGET_REPO, &user.login)?;
|
||||
let (push_owner, push_repo, pr_head) = if perm >= Permission::Write {
|
||||
(TARGET_OWNER.to_string(), TARGET_REPO.to_string(), None)
|
||||
} else {
|
||||
println!("You don't have write access to {TARGET_OWNER}/{TARGET_REPO}.");
|
||||
print!(" → Forking to your account... ");
|
||||
let fork_name = gh.fork_repo(TARGET_OWNER, TARGET_REPO)?;
|
||||
println!("done ({fork_name})");
|
||||
let parts: Vec<&str> = fork_name.split('/').collect();
|
||||
(
|
||||
parts[0].to_string(),
|
||||
parts[1].to_string(),
|
||||
Some(format!("{}:{}", user.login, branch_name(&meta))),
|
||||
)
|
||||
};
|
||||
|
||||
println!("Uploading to {TARGET_OWNER}/{TARGET_REPO}...");
|
||||
|
||||
// Get base ref
|
||||
let base_sha = gh.get_ref_sha(&push_owner, &push_repo, "heads/main")?;
|
||||
let (_, base_tree_sha) = gh.get_commit(&push_owner, &push_repo, &base_sha)?;
|
||||
|
||||
// Upload blob
|
||||
print!(" → Uploading blob ({:.1} MB)... ", size_mb);
|
||||
let file_bytes = std::fs::read(&deb_path).context("reading deb file")?;
|
||||
let sha256_hash = hex_sha256(&file_bytes);
|
||||
let content_b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes);
|
||||
let blob_sha = gh.create_blob(&push_owner, &push_repo, &content_b64)?;
|
||||
println!("done (sha: {})", &blob_sha[..8]);
|
||||
|
||||
// Create tree
|
||||
let file_path_in_repo = format!(
|
||||
"pool/main/{}/{}_{}_{}.deb",
|
||||
meta.package, meta.package, meta.version, meta.architecture
|
||||
);
|
||||
print!(" → Creating tree... ");
|
||||
let tree_sha =
|
||||
gh.create_tree(&push_owner, &push_repo, &base_tree_sha, &file_path_in_repo, Some(&blob_sha))?;
|
||||
println!("done");
|
||||
|
||||
// Create commit
|
||||
let commit_msg = format!("publish: {} {} ({})", meta.package, meta.version, meta.architecture);
|
||||
print!(" → Creating commit... ");
|
||||
let commit_sha = gh.create_commit(&push_owner, &push_repo, &commit_msg, &tree_sha, &base_sha)?;
|
||||
println!("done");
|
||||
|
||||
// Create branch
|
||||
let branch = branch_name(&meta);
|
||||
print!(" → Creating branch {branch}... ");
|
||||
gh.create_ref(&push_owner, &push_repo, &branch, &commit_sha)?;
|
||||
println!("done");
|
||||
|
||||
// Create PR
|
||||
let head = pr_head.unwrap_or_else(|| branch.clone());
|
||||
let pr_body = format!(
|
||||
"## Package: `{}`\n\n\
|
||||
| Field | Value |\n\
|
||||
|-------|-------|\n\
|
||||
| Version | {} |\n\
|
||||
| Architecture | {} |\n\
|
||||
| Maintainer | {} |\n\
|
||||
| Size | {:.1} MB |\n\
|
||||
| SHA-256 | `{}` |\n\
|
||||
| File | `{}` |\n\n\
|
||||
Submitted via `czdev publish`.",
|
||||
meta.package,
|
||||
meta.version,
|
||||
meta.architecture,
|
||||
meta.maintainer,
|
||||
size_mb,
|
||||
sha256_hash,
|
||||
file_path_in_repo,
|
||||
);
|
||||
print!(" → Creating pull request... ");
|
||||
let pr = gh.create_pull_request(
|
||||
TARGET_OWNER,
|
||||
TARGET_REPO,
|
||||
&format!("publish: {} {}", meta.package, meta.version),
|
||||
&pr_body,
|
||||
&head,
|
||||
"main",
|
||||
)?;
|
||||
println!("done");
|
||||
|
||||
println!();
|
||||
println!("✓ Pull request created:");
|
||||
println!(" {}", pr.html_url);
|
||||
println!();
|
||||
println!(" The PR will be validated by CI. A maintainer will review and merge it.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_deb(deb: Option<&Path>) -> Result<PathBuf> {
|
||||
if let Some(p) = deb {
|
||||
if !p.is_file() {
|
||||
return Err(anyhow!("file not found: {}", p.display()));
|
||||
}
|
||||
return Ok(p.to_path_buf());
|
||||
}
|
||||
// Search build/ for a .deb
|
||||
let build_dir = Path::new("build");
|
||||
if build_dir.is_dir() {
|
||||
let mut debs: Vec<_> = std::fs::read_dir(build_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|x| x == "deb").unwrap_or(false))
|
||||
.collect();
|
||||
if debs.len() == 1 {
|
||||
return Ok(debs.remove(0).path());
|
||||
}
|
||||
if debs.len() > 1 {
|
||||
return Err(anyhow!(
|
||||
"multiple .deb files in build/. Specify one with --deb <path>"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(anyhow!("no .deb file found. Specify with --deb <path>"))
|
||||
}
|
||||
|
||||
fn check_desktop(deb: &Path) -> Result<bool> {
|
||||
let output = Command::new("dpkg-deb")
|
||||
.arg("-c")
|
||||
.arg(deb)
|
||||
.output()
|
||||
.context("running dpkg-deb -c (is dpkg-deb installed?)")?;
|
||||
let listing = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(listing.lines().any(|l| l.ends_with(".desktop")))
|
||||
}
|
||||
|
||||
fn extract_metadata(deb: &Path) -> Result<DebMetadata> {
|
||||
let fields = &["Package", "Version", "Architecture", "Maintainer"];
|
||||
let mut values = std::collections::HashMap::new();
|
||||
|
||||
for field in fields {
|
||||
let output = Command::new("dpkg-deb")
|
||||
.args(["-f", &deb.to_string_lossy(), field])
|
||||
.output()
|
||||
.with_context(|| format!("dpkg-deb -f {field}"))?;
|
||||
let val = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
values.insert(*field, val);
|
||||
}
|
||||
|
||||
let maintainer = values.get("Maintainer").cloned().unwrap_or_default();
|
||||
let email = extract_email(&maintainer);
|
||||
|
||||
Ok(DebMetadata {
|
||||
package: values.get("Package").cloned().unwrap_or_default(),
|
||||
version: values.get("Version").cloned().unwrap_or_default(),
|
||||
architecture: values.get("Architecture").cloned().unwrap_or_default(),
|
||||
maintainer,
|
||||
maintainer_email: email,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_email(maintainer: &str) -> String {
|
||||
if let Some(start) = maintainer.find('<') {
|
||||
if let Some(end) = maintainer.find('>') {
|
||||
return maintainer[start + 1..end].to_string();
|
||||
}
|
||||
}
|
||||
maintainer.to_string()
|
||||
}
|
||||
|
||||
fn is_valid_package_name(name: &str) -> bool {
|
||||
if name.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let bytes = name.as_bytes();
|
||||
(bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
|
||||
&& bytes.iter().all(|&b| {
|
||||
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'+' || b == b'-'
|
||||
})
|
||||
}
|
||||
|
||||
fn branch_name(meta: &DebMetadata) -> String {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
format!("publish/{}-{}-{}", meta.package, meta.version, ts)
|
||||
}
|
||||
|
||||
fn hex_sha256(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn check_version_newer(_gh: &GitHubClient, meta: &DebMetadata) -> Result<()> {
|
||||
// Try to fetch the Packages index to see if this package already exists
|
||||
let packages_url = format!(
|
||||
"https://cardputerzero.github.io/packages/dists/stable/main/binary-arm64/Packages"
|
||||
);
|
||||
let resp = reqwest::blocking::get(&packages_url);
|
||||
let content = match resp {
|
||||
Ok(r) if r.status().is_success() => r.text().unwrap_or_default(),
|
||||
_ => return Ok(()), // Can't check, skip (repo might be empty)
|
||||
};
|
||||
|
||||
// Parse existing version for this package
|
||||
let mut in_our_package = false;
|
||||
let mut existing_version: Option<String> = None;
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Package: ") {
|
||||
in_our_package = line.trim_start_matches("Package: ") == meta.package;
|
||||
}
|
||||
if in_our_package && line.starts_with("Version: ") {
|
||||
let ver = line.trim_start_matches("Version: ").to_string();
|
||||
// Keep the highest version found
|
||||
match &existing_version {
|
||||
Some(ev) if compare_versions(&ver, ev) == std::cmp::Ordering::Greater => {
|
||||
existing_version = Some(ver);
|
||||
}
|
||||
None => existing_version = Some(ver),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if line.is_empty() {
|
||||
in_our_package = false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(existing) = existing_version {
|
||||
if compare_versions(&meta.version, &existing) != std::cmp::Ordering::Greater {
|
||||
return Err(anyhow!(
|
||||
"Version {} is not newer than existing version {}.\n \
|
||||
Bump the version in your package before publishing.",
|
||||
meta.version,
|
||||
existing
|
||||
));
|
||||
}
|
||||
println!(" ✓ Version {} is newer than existing {}", meta.version, existing);
|
||||
} else {
|
||||
println!(" ✓ New package (no existing version found)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
// Simple version comparison: split by '.', '-', '~' and compare segments
|
||||
let parse = |v: &str| -> Vec<u64> {
|
||||
v.split(|c: char| c == '.' || c == '-' || c == '~')
|
||||
.filter_map(|s| s.parse::<u64>().ok())
|
||||
.collect()
|
||||
};
|
||||
let va = parse(a);
|
||||
let vb = parse(b);
|
||||
va.cmp(&vb)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::auth;
|
||||
use crate::github::{GitHubClient, Permission};
|
||||
|
||||
const TARGET_OWNER: &str = "CardputerZero";
|
||||
const TARGET_REPO: &str = "packages";
|
||||
|
||||
pub fn run(package: &str, version: &str, arch: &str) -> Result<()> {
|
||||
let token = auth::load_token()?;
|
||||
let gh = GitHubClient::new(&token);
|
||||
let user = gh.get_user()?;
|
||||
let verified_emails = gh.get_verified_emails()?;
|
||||
|
||||
let noreply = format!("{}@users.noreply.github.com", user.login);
|
||||
let mut all_emails = verified_emails;
|
||||
if !all_emails.contains(&noreply) {
|
||||
all_emails.push(noreply);
|
||||
}
|
||||
|
||||
let file_path = format!("pool/main/{}/{}_{}_{}. deb", package, package, version, arch);
|
||||
let file_path = file_path.replace(". deb", ".deb");
|
||||
|
||||
// Verify the file exists and belongs to this user
|
||||
println!("Checking ownership of {package} {version}...");
|
||||
let deb_bytes = gh
|
||||
.get_file_content(TARGET_OWNER, TARGET_REPO, &file_path)
|
||||
.context("package not found in repository")?;
|
||||
|
||||
// Write to temp file to inspect maintainer
|
||||
let tmp = std::env::temp_dir().join(format!("{package}_{version}_{arch}.deb"));
|
||||
std::fs::write(&tmp, &deb_bytes).context("writing temp deb")?;
|
||||
|
||||
let output = Command::new("dpkg-deb")
|
||||
.args(["-f", &tmp.to_string_lossy(), "Maintainer"])
|
||||
.output()
|
||||
.context("running dpkg-deb")?;
|
||||
let maintainer = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
let maint_email = extract_email(&maintainer);
|
||||
if !all_emails.iter().any(|e| e.eq_ignore_ascii_case(&maint_email)) {
|
||||
return Err(anyhow!(
|
||||
"Cannot unpublish: package maintainer '{}' does not match your account.\n \
|
||||
You can only remove packages you own.",
|
||||
maintainer
|
||||
));
|
||||
}
|
||||
println!(" ✓ Ownership verified ({})", maint_email);
|
||||
|
||||
// Determine push target
|
||||
let perm = gh.check_permission(TARGET_OWNER, TARGET_REPO, &user.login)?;
|
||||
let (push_owner, push_repo, pr_head) = if perm >= Permission::Write {
|
||||
(TARGET_OWNER.to_string(), TARGET_REPO.to_string(), None)
|
||||
} else {
|
||||
let fork_name = gh.fork_repo(TARGET_OWNER, TARGET_REPO)?;
|
||||
let parts: Vec<&str> = fork_name.split('/').collect();
|
||||
let branch = branch_name(package, version);
|
||||
(
|
||||
parts[0].to_string(),
|
||||
parts[1].to_string(),
|
||||
Some(format!("{}:{}", user.login, branch)),
|
||||
)
|
||||
};
|
||||
|
||||
println!("Creating removal PR...");
|
||||
|
||||
// Get base
|
||||
let base_sha = gh.get_ref_sha(&push_owner, &push_repo, "heads/main")?;
|
||||
let (_, base_tree_sha) = gh.get_commit(&push_owner, &push_repo, &base_sha)?;
|
||||
|
||||
// Create tree with file removed (sha: null deletes the entry)
|
||||
let tree_sha = gh.create_tree(&push_owner, &push_repo, &base_tree_sha, &file_path, None)?;
|
||||
|
||||
// Commit
|
||||
let commit_msg = format!("unpublish: {} {}", package, version);
|
||||
let commit_sha = gh.create_commit(&push_owner, &push_repo, &commit_msg, &tree_sha, &base_sha)?;
|
||||
|
||||
// Branch
|
||||
let branch = branch_name(package, version);
|
||||
gh.create_ref(&push_owner, &push_repo, &branch, &commit_sha)?;
|
||||
|
||||
// PR
|
||||
let head = pr_head.unwrap_or_else(|| branch.clone());
|
||||
let pr_body = format!(
|
||||
"## Remove package: `{package}` v{version}\n\n\
|
||||
Requested by @{} (maintainer email: {}).\n\n\
|
||||
File: `{}`\n\n\
|
||||
Submitted via `czdev unpublish`.",
|
||||
user.login, maint_email, file_path
|
||||
);
|
||||
let pr = gh.create_pull_request(
|
||||
TARGET_OWNER,
|
||||
TARGET_REPO,
|
||||
&format!("unpublish: {} {}", package, version),
|
||||
&pr_body,
|
||||
&head,
|
||||
"main",
|
||||
)?;
|
||||
|
||||
println!();
|
||||
println!("✓ Removal PR created:");
|
||||
println!(" {}", pr.html_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn branch_name(package: &str, version: &str) -> String {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
format!("unpublish/{}-{}-{}", package, version, ts)
|
||||
}
|
||||
|
||||
fn extract_email(maintainer: &str) -> String {
|
||||
if let Some(start) = maintainer.find('<') {
|
||||
if let Some(end) = maintainer.find('>') {
|
||||
return maintainer[start + 1..end].to_string();
|
||||
}
|
||||
}
|
||||
maintainer.to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user