rustfinity 0.4.9

Rustfinity.com CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
use anyhow::{bail, Context, Result};
use reqwest::{multipart, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};

use crate::auth;
use crate::confirm::confirm;
use crate::constants::api_base_url;

#[derive(Debug)]
enum DeployError {
    HttpError { status: StatusCode, body: String },
    Other(anyhow::Error),
}

impl std::fmt::Display for DeployError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DeployError::HttpError { status, body } => {
                // Try to extract a human-readable message from JSON error responses
                let message = serde_json::from_str::<Value>(body)
                    .ok()
                    .and_then(|v| v.get("error")?.as_str().map(String::from));

                if let Some(msg) = message {
                    write!(f, "{}", msg)
                } else if body.is_empty() {
                    write!(f, "Deploy failed (HTTP {})", status)
                } else {
                    write!(f, "Deploy failed (HTTP {}): {}", status, body)
                }
            }
            DeployError::Other(e) => write!(f, "{}", e),
        }
    }
}

impl std::error::Error for DeployError {}

impl From<anyhow::Error> for DeployError {
    fn from(e: anyhow::Error) -> Self {
        DeployError::Other(e)
    }
}

#[derive(Deserialize)]
struct DeployResponse {
    project_id: String,
    deployment_id: String,
    status: String,
    is_new_project: bool,
    #[allow(dead_code)]
    subdomain: String,
    url: String,
}

#[derive(Serialize, Deserialize)]
struct ProjectConfig {
    project_id: String,
    name: String,
}

#[derive(Deserialize)]
struct CargoTomlPackage {
    name: String,
}

#[derive(Deserialize)]
struct CargoToml {
    package: CargoTomlPackage,
}

const TARGET: &str = "x86_64-unknown-linux-gnu";

/// Check if the current directory is inside a git repository.
/// If not, offer to initialize one for the user.
fn ensure_git_repo() -> Result<()> {
    // Check if git is installed
    let git_installed = Command::new("git")
        .args(["--version"])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);

    if !git_installed {
        bail!(
            "Git is not installed. Rustfinity deploy requires git to create source archives.\n\
             Please install git: https://git-scm.com/downloads"
        );
    }

    // Check if we're inside a git repo
    let in_git_repo = Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false);

    if in_git_repo {
        return Ok(());
    }

    println!(
        "\x1b[33mThis directory is not a git repository.\x1b[0m"
    );
    println!(
        "Rustfinity deploy uses git to create source archives of your project."
    );

    let yes = confirm(
        "Would you like to initialize a git repository here?",
        true,
    )
    .context("Failed to read input")?;

    if !yes {
        bail!(
            "A git repository is required for deployment.\n\
             You can initialize one manually with: git init && git add -A && git commit -m \"Initial commit\""
        );
    }

    // git init
    let status = Command::new("git")
        .args(["init"])
        .status()
        .context("Failed to run git init")?;
    if !status.success() {
        bail!("git init failed");
    }

    // git add -A
    let status = Command::new("git")
        .args(["add", "-A"])
        .status()
        .context("Failed to run git add")?;
    if !status.success() {
        bail!("git add failed");
    }

    // git commit
    let status = Command::new("git")
        .args(["commit", "-m", "Initial commit"])
        .status()
        .context("Failed to run git commit")?;
    if !status.success() {
        bail!("git commit failed");
    }

    println!("\x1b[32mGit repository initialized successfully.\x1b[0m");
    Ok(())
}

fn build_for_target() -> Result<()> {
    let target = TARGET;

    // Force baseline x86-64 instructions to ensure compatibility with gVisor (runsc).
    // This overrides any user-local RUSTFLAGS or .cargo/config.toml settings that
    // might enable AVX2/AVX-512, which would cause SIGILL (exit code 132) at runtime.
    let rustflags = "-C target-cpu=x86-64";

    if std::env::consts::OS == "linux" {
        // On Linux, plain cargo build works — no cross toolchain needed
        let status = Command::new("cargo")
            .args(["build", "--release", "--target", target])
            .env("RUSTFLAGS", rustflags)
            .status()
            .context("Failed to run cargo build")?;
        if !status.success() {
            bail!("cargo build failed with exit code: {}", status);
        }
    } else {
        // Non-Linux: need zig + cargo-zigbuild for cross-compilation
        // Check zig first — we can't install it for the user
        if !Command::new("zig")
            .arg("version")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
        {
            bail!(
                "\x1b[33mYou're on {}, cross-compiling to Linux requires zig.\n\
                 Please install zig first: https://ziglang.org/download/\x1b[0m",
                std::env::consts::OS
            );
        }

        // Check cargo-zigbuild — offer to install if missing
        if !Command::new("cargo-zigbuild")
            .arg("--version")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
        {
            let yes = confirm(
                "\x1b[33mcargo-zigbuild is not installed. Install it now?\x1b[0m",
                true,
            )
            .context("Failed to read input")?;
            if !yes {
                bail!("cargo-zigbuild is required. Install it with: cargo install cargo-zigbuild");
            }
            println!("Installing cargo-zigbuild...");
            let install = Command::new("cargo")
                .args(["install", "cargo-zigbuild"])
                .status()
                .context("Failed to run cargo install cargo-zigbuild")?;
            if !install.success() {
                bail!("Failed to install cargo-zigbuild");
            }
        }

        let status = Command::new("cargo")
            .args(["zigbuild", "--release", "--target", target])
            .env("RUSTFLAGS", rustflags)
            .status()
            .context("Failed to run cargo zigbuild")?;
        if !status.success() {
            bail!("cargo zigbuild failed with exit code: {}", status);
        }
    }

    Ok(())
}

fn is_auth_error(e: &DeployError) -> bool {
    matches!(
        e,
        DeployError::HttpError { status, .. } if status.as_u16() == 401
    )
}

fn is_project_not_found_error(e: &DeployError) -> bool {
    match e {
        DeployError::HttpError { status, body } => {
            status.as_u16() == 400 && body.contains("Project not found")
        }
        _ => false,
    }
}

fn delete_rustfinity_json() -> Result<()> {
    let path = Path::new("rustfinity.json");
    if path.exists() {
        fs::remove_file(path).context("Failed to remove rustfinity.json")?;
    }
    Ok(())
}

pub async fn deploy() -> Result<()> {
    deploy_with_retry().await
}

async fn deploy_with_retry() -> Result<()> {
    // Track if we've already retried for each error type to avoid infinite loops
    let mut auth_retried = false;
    let mut project_not_found_retried = false;

    loop {
        match deploy_internal().await {
            Ok(()) => return Ok(()),
            Err(e) => {
                // Check if it's a 401 auth error
                if is_auth_error(&e) && !auth_retried {
                    println!("Authentication failed. Logging in...");
                    auth::perform_login().await?;
                    println!("Retrying deploy...");
                    auth_retried = true;
                    continue;
                }

                // Check if it's a deleted project error (400 + "Project not found")
                if is_project_not_found_error(&e) && !project_not_found_retried {
                    println!("Project not found (may have been deleted). Creating new project...");
                    delete_rustfinity_json()?;
                    println!("Retrying deploy...");
                    project_not_found_retried = true;
                    continue;
                }

                // Other errors or already retried - propagate
                return Err(e.into());
            }
        }
    }
}

async fn deploy_internal() -> Result<(), DeployError> {
    // Helper to convert anyhow errors to DeployError
    let to_deploy_error = |e: anyhow::Error| DeployError::Other(e);

    // 1. Load config (check auth)
    let config = auth::ensure_authenticated().await.map_err(to_deploy_error)?;

    // 2. Verify Cargo.toml exists
    let cargo_toml_path = Path::new("Cargo.toml");
    if !cargo_toml_path.exists() {
        return Err(DeployError::Other(anyhow::anyhow!(
            "No Cargo.toml found in the current directory. Please run this command from a Rust project root."
        )));
    }

    // 3. Ensure we're in a git repo (offer to initialize if not)
    ensure_git_repo().map_err(to_deploy_error)?;

    // 4. Parse Cargo.toml to get package name and derive slug
    let cargo_toml_contents = fs::read_to_string(cargo_toml_path)
        .context("Failed to read Cargo.toml")
        .map_err(to_deploy_error)?;
    let cargo_toml: CargoToml = toml::from_str(&cargo_toml_contents)
        .context("Failed to parse Cargo.toml")
        .map_err(to_deploy_error)?;
    let package_name = &cargo_toml.package.name;
    let slug = package_name.replace('_', "-");

    println!("Deploying project: {} (slug: {})", package_name, slug);

    // 5. Load rustfinity.json if it exists (get project_id)
    let project_config_path = Path::new("rustfinity.json");
    let existing_project_id = if project_config_path.exists() {
        let contents = fs::read_to_string(project_config_path)
            .context("Failed to read .rustfinity.json")
            .map_err(to_deploy_error)?;
        let project_config: ProjectConfig = serde_json::from_str(&contents)
            .context("Failed to parse .rustfinity.json")
            .map_err(to_deploy_error)?;
        Some(project_config.project_id)
    } else {
        None
    };

    // 6. Build release binary for x86_64-unknown-linux-gnu
    println!("Building release binary...");
    build_for_target().map_err(to_deploy_error)?;

    // 7. Locate binary
    let binary_path = format!(
        "target/x86_64-unknown-linux-gnu/release/{}",
        package_name
    );
    let binary_path = Path::new(&binary_path);
    if !binary_path.exists() {
        return Err(DeployError::Other(anyhow::anyhow!(
            "Expected binary not found at {}. Make sure the package produces a binary target.",
            binary_path.display()
        )));
    }

    // 8. Determine upload filename
    let binary_suffix = match &existing_project_id {
        Some(project_id) => project_id.clone(),
        None => slug.clone(),
    };
    let binary_name = format!("rustfinity-app-{}", binary_suffix);

    // 9. Create source zip via git archive
    println!("Creating source archive...");
    let source_zip_path = "target/release/rustfinity-source.zip";
    let archive_status = Command::new("git")
        .args([
            "archive",
            "--format=zip",
            &format!("--output={}", source_zip_path),
            "HEAD",
        ])
        .status()
        .context("Failed to run git archive")
        .map_err(to_deploy_error)?;

    if !archive_status.success() {
        return Err(DeployError::Other(anyhow::anyhow!(
            "Failed to create source archive via git archive. Make sure you have at least one commit."
        )));
    }

    // 10. Create multipart form and send request
    println!("Uploading to Rustfinity Cloud...");
    let binary_bytes = fs::read(binary_path)
        .context("Failed to read binary file")
        .map_err(to_deploy_error)?;

    let binary_part = multipart::Part::bytes(binary_bytes)
        .file_name(binary_name.clone())
        .mime_str("application/octet-stream")
        .map_err(|e| DeployError::Other(anyhow::anyhow!("Failed to set mime type: {}", e)))?;

    let mut form = multipart::Form::new()
        .part("binary", binary_part)
        .text("project_name", package_name.clone());

    if let Some(ref project_id) = existing_project_id {
        form = form.text("project_id", project_id.clone());
    }

    form = form.text("target", TARGET.to_string());

    let source_bytes = fs::read(source_zip_path)
        .context("Failed to read source zip")
        .map_err(to_deploy_error)?;
    let source_part = multipart::Part::bytes(source_bytes)
        .file_name("rustfinity-source.zip")
        .mime_str("application/zip")
        .map_err(|e| DeployError::Other(anyhow::anyhow!("Failed to set mime type: {}", e)))?;
    form = form.part("source_zip", source_part);

    // 11. POST to deploy endpoint
    let base_url = api_base_url();
    let url = format!("{}/deploy", base_url);

    let client = reqwest::Client::new();
    let response = client
        .post(&url)
        .header("Authorization", format!("Bearer {}", config.api_key))
        .multipart(form)
        .send()
        .await
        .context("Failed to send deploy request")
        .map_err(to_deploy_error)?;

    if !response.status().is_success() {
        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        return Err(DeployError::HttpError { status, body });
    }

    let deploy_response: DeployResponse = response
        .json()
        .await
        .context("Failed to parse deploy response")
        .map_err(to_deploy_error)?;

    // 12. Save project config to .rustfinity.json
    let project_config = ProjectConfig {
        project_id: deploy_response.project_id.clone(),
        name: slug.clone(),
    };
    let config_json = serde_json::to_string_pretty(&project_config)
        .context("Failed to serialize project config")
        .map_err(to_deploy_error)?;
    fs::write(project_config_path, format!("{config_json}\n"))
        .context("Failed to write .rustfinity.json")
        .map_err(to_deploy_error)?;

    // 13. Print deployment result
    if deploy_response.is_new_project {
        println!("Created new project: {}", slug);
        println!();
        println!("Tip: Make sure your application listens on the PORT environment variable (defaults to 3000).");
    } else {
        println!("Redeployed project: {}", slug);
    }
    println!("Deployment ID: {}", deploy_response.deployment_id);
    println!("Status: {}", deploy_response.status);
    println!("URL: {}", deploy_response.url);

    println!("Deploy successful!");
    Ok(())
}