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:
LiHaohua
2026-05-09 15:03:06 +08:00
co-authored by Claude Opus 4.6
parent e9d54237d1
commit 544f75c33b
8 changed files with 1520 additions and 12 deletions
+83
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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"
+175
View File
@@ -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())
}
+367
View File
@@ -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())
}
}
+37
View File
@@ -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),
}
}
+340
View File
@@ -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)
}
+124
View File
@@ -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()
}