code icon Code

Deploy Web App with Assets

Upload a directory of files (HTML, images, assets) to GitHub Pages. Preserves folder structure and supports binary files.

Source Code

import fs from "fs";
import path from "path";

const [
  projectName,
  sourceDir,
  commitMessage = "Deploy web app",
  trackingPath,
] = process.argv.slice(2);

// Validate inputs
if (!projectName) {
  console.error("Error: projectName is required");
  process.exit(1);
}

if (!sourceDir) {
  console.error("Error: sourceDir is required");
  process.exit(1);
}

if (!fs.existsSync(sourceDir)) {
  console.error(`Error: Directory not found: ${sourceDir}`);
  process.exit(1);
}

if (!fs.statSync(sourceDir).isDirectory()) {
  console.error(`Error: Not a directory: ${sourceDir}`);
  process.exit(1);
}

// Sanitize project name
const sanitizedName = projectName
  .toLowerCase()
  .replace(/[^a-z0-9-]/g, "-")
  .replace(/-+/g, "-")
  .replace(/^-|-$/g, "");

if (!sanitizedName) {
  console.error("Error: Invalid project name after sanitization");
  process.exit(1);
}

// Walk directory recursively and collect all files
function walkDir(dir, baseDir = dir) {
  const files = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      files.push(...walkDir(fullPath, baseDir));
    } else if (entry.isFile()) {
      const relativePath = path.relative(baseDir, fullPath);
      files.push({ fullPath, relativePath });
    }
  }

  return files;
}

// Collect all files to upload
const files = walkDir(sourceDir);

if (files.length === 0) {
  console.error("Error: No files found in source directory");
  process.exit(1);
}

// Check for index.html
const hasIndex = files.some(
  (f) => f.relativePath === "index.html" || f.relativePath === "index.htm"
);
if (!hasIndex) {
  console.warn(
    "Warning: No index.html found. GitHub Pages may not serve correctly."
  );
}

console.log(`Deploying "${sanitizedName}" to GitHub Pages...`);
console.log(`  Source: ${sourceDir}`);
console.log(`  Files: ${files.length}`);
files.forEach((f) => console.log(`    - ${f.relativePath}`));

// GitHub API helper
async function githubFetch(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: {
      Authorization: "Bearer PLACEHOLDER_TOKEN",
      Accept: "application/vnd.github.v3+json",
      "User-Agent": "Sauna-Agent",
      ...options.headers,
    },
  });
  return response;
}

// Upload a single file to GitHub
async function uploadFile(username, repoName, filePath, relativePath) {
  // Read file as binary buffer
  const fileBuffer = fs.readFileSync(filePath);
  const contentBase64 = fileBuffer.toString("base64");
  const fileSize = fileBuffer.length;

  // Check for existing file to get SHA (needed for updates)
  const getFileResponse = await githubFetch(
    `https://api.github.com/repos/${username}/${repoName}/contents/${relativePath}`
  );

  let sha = null;
  if (getFileResponse.ok) {
    const fileData = await getFileResponse.json();
    sha = fileData.sha;
  }

  // Upload/update file
  const uploadBody = {
    message: `${sha ? "Update" : "Add"} ${relativePath}`,
    content: contentBase64,
  };

  if (sha) {
    uploadBody.sha = sha;
  }

  const uploadResponse = await githubFetch(
    `https://api.github.com/repos/${username}/${repoName}/contents/${relativePath}`,
    {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(uploadBody),
    }
  );

  if (!uploadResponse.ok) {
    const error = await uploadResponse.text();
    throw new Error(`Failed to upload ${relativePath}: ${error}`);
  }

  return { relativePath, size: fileSize, updated: !!sha };
}

try {
  // Step 1: Get authenticated user
  const userResponse = await githubFetch("https://api.github.com/user");

  if (!userResponse.ok) {
    const error = await userResponse.text();
    console.error("Failed to authenticate with GitHub");
    console.error(error);
    throw new Error("GitHub authentication failed");
  }

  const user = await userResponse.json();
  const username = user.login;
  console.log(`\n  Authenticated as: ${username}`);

  // Step 2: Check if repo exists
  const repoName = sanitizedName;
  const repoCheckResponse = await githubFetch(
    `https://api.github.com/repos/${username}/${repoName}`
  );

  let repoExists = repoCheckResponse.ok;

  // Step 3: Create repo if it doesn't exist
  if (!repoExists) {
    console.log(`  Repository ${repoName} does not exist. Creating...`);

    const createRepoResponse = await githubFetch(
      "https://api.github.com/user/repos",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          name: repoName,
          description: `Web app: ${projectName}`,
          homepage: `https://${username}.github.io/${repoName}`,
          private: false,
          auto_init: true,
          has_pages: true,
        }),
      }
    );

    if (!createRepoResponse.ok) {
      const error = await createRepoResponse.text();
      console.error("Failed to create repository");
      console.error(error);
      throw new Error("Repository creation failed");
    }

    console.log("  Repository created successfully");

    // Wait for repo initialization
    await new Promise((resolve) => setTimeout(resolve, 2000));
  } else {
    console.log(`  Repository ${repoName} exists`);
  }

  // Step 4: Upload all files
  console.log("\n  Uploading files...");
  const uploadResults = [];

  for (const file of files) {
    const result = await uploadFile(
      username,
      repoName,
      file.fullPath,
      file.relativePath
    );
    uploadResults.push(result);
    console.log(
      `    โœ“ ${result.relativePath} (${result.size} bytes)${result.updated ? " [updated]" : ""}`
    );
  }

  // Step 5: Enable GitHub Pages if new repo
  if (!repoExists) {
    console.log("\n  Enabling GitHub Pages...");

    try {
      await githubFetch(
        `https://api.github.com/repos/${username}/${repoName}/pages`,
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            source: {
              branch: "main",
              path: "/",
            },
          }),
        }
      );
      console.log("  GitHub Pages enabled");
    } catch {
      console.log("  Note: GitHub Pages may need manual activation");
    }
  }

  // Output results
  const publishedUrl = `https://${username}.github.io/${repoName}`;
  const repoUrl = `https://github.com/${username}/${repoName}`;

  console.log("\nโœ… Deployed successfully!");
  console.log(`\n๐Ÿ“ Live URL: ${publishedUrl}`);
  console.log(`๐Ÿ“ Repository: ${repoUrl}`);
  console.log(`๐Ÿ“ฆ Files deployed: ${uploadResults.length}`);
  console.log(
    "\nNote: GitHub Pages may take 1-2 minutes to deploy after push."
  );

  // Output structured result for downstream use
  const output = {
    success: true,
    url: publishedUrl,
    repository: repoUrl,
    project: sanitizedName,
    username: username,
    files: uploadResults.map((r) => r.relativePath),
    totalFiles: uploadResults.length,
    totalSize: uploadResults.reduce((sum, r) => sum + r.size, 0),
    timestamp: new Date().toISOString(),
  };

  console.log("\n--- RESULT ---");
  console.log(JSON.stringify(output, null, 2));

  // Step 6: Update tracking file if provided
  if (trackingPath) {
    let sites = [];
    if (fs.existsSync(trackingPath)) {
      sites = JSON.parse(fs.readFileSync(trackingPath, "utf-8"));
    }

    const existing = sites.findIndex((s) => s.project === sanitizedName);
    const entry = {
      project: sanitizedName,
      url: publishedUrl,
      repository: repoUrl,
      description: commitMessage.replace(/^(Deploy|Update):\s*/i, ""),
      files: output.files,
      deployedAt:
        existing === -1 ? output.timestamp : sites[existing].deployedAt,
      lastUpdated: output.timestamp,
    };

    if (existing === -1) {
      sites.push(entry);
    } else {
      sites[existing] = entry;
    }

    fs.writeFileSync(trackingPath, JSON.stringify(sites, null, 2));
    console.log(`\n๐Ÿ“‹ Updated tracking: ${trackingPath}`);
  }
} catch (error) {
  console.error(`\nโŒ Deployment failed: ${error.message}`);
  process.exit(1);
}