import * as fs from 'fs';
import * as path from 'path';
import AdmZip from 'adm-zip';
import {
apiRequest,
isApiError,
needsAuthentication,
needsAuthError,
getApiUrl
} from '../lib/api-client';
import { ensureAuthenticated } from '../lib/device-flow';
import { getValidCredentials } from '../lib/credentials';
import { validatePackage } from './validate-package';
/**
* Tenant/Project information
*/
interface TenantWithRole {
id: string;
name: string;
subdomain: string;
customDomain?: string | null;
role: string;
site?: {
id: string;
status: string;
} | null;
}
/**
* Package upload response
*/
interface UploadResponse {
site: {
id: string;
status: string;
packageVersion: number;
};
filesUploaded: number;
pages: number;
}
/**
* GitHub connection status for a site
*/
interface GitHubConnectionStatus {
connected: boolean;
repo: string | null;
branch: string | null;
lastDeployedSha: string | null;
}
/**
* List existing projects for the authenticated user
*/
async function listExistingProjects(): Promise<TenantWithRole[]> {
const response = await apiRequest<TenantWithRole[]>('/api/tenants');
if (isApiError(response)) {
return [];
}
return response.data;
}
/**
* Check if a project has GitHub connected
*/
async function checkGitHubConnection(tenantId: string): Promise<GitHubConnectionStatus | null> {
const response = await apiRequest<GitHubConnectionStatus>('/api/github/site-connection', { tenantId });
if (isApiError(response)) {
// If we can't check, assume not connected (fail open for better UX)
return null;
}
return response.data;
}
/**
* Format the GitHub blocking message
*/
function formatGitHubBlockMessage(repo: string, branch: string, subdomain: string): string {
return `# ⚠️ GitHub Sync Detected
This project has GitHub connected and will auto-deploy from:
- **Repository:** ${repo}
- **Branch:** ${branch}
## Why This Matters
Deploying via MCP while GitHub is connected can cause conflicts:
- Your MCP changes could be overwritten on the next GitHub push
- Or this deploy could overwrite changes from GitHub
## Options
### Option 1: Push to GitHub Instead (Recommended)
Push your changes to the connected repository and let GitHub handle the deploy:
\`\`\`bash
git push origin ${branch}
\`\`\`
### Option 2: Disconnect GitHub First
Go to **app.fastmode.ai** → Settings → GitHub and disconnect the repository.
### Option 3: Force Deploy Anyway
If you understand the risks and want to proceed:
\`\`\`
deploy_package(
packagePath: "./your-site.zip",
projectId: "...",
force: true
)
\`\`\`
---
**Dashboard:** https://app.fastmode.ai
**Site:** https://${subdomain}.fastmode.ai
`;
}
/**
* Upload a package to a specific tenant
*/
async function uploadPackage(tenantId: string, zipBuffer: Buffer): Promise<UploadResponse | { error: string }> {
const credentials = await getValidCredentials();
if (!credentials) {
return { error: 'Not authenticated' };
}
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/upload/package`;
// Generate a unique boundary for multipart form data
const boundary = `----FormBoundary${Date.now().toString(16)}`;
// Build multipart form data manually
const header = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="package"; filename="package.zip"\r\n` +
`Content-Type: application/zip\r\n\r\n`
);
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([header, zipBuffer, footer]);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${credentials.accessToken}`,
'X-Tenant-Id': tenantId,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body: body,
});
const data = await response.json();
if (!response.ok) {
return { error: data.error || `Upload failed with status ${response.status}` };
}
return data.data;
} catch (error) {
return { error: error instanceof Error ? error.message : 'Upload failed' };
}
}
/**
* Read a package from path (zip file or directory)
*/
async function readPackage(packagePath: string): Promise<Buffer | { error: string }> {
const absolutePath = path.resolve(packagePath);
// Check if path exists
if (!fs.existsSync(absolutePath)) {
return { error: `Path not found: ${absolutePath}` };
}
const stats = fs.statSync(absolutePath);
if (stats.isFile()) {
// It's a file - check if it's a zip
if (!absolutePath.endsWith('.zip')) {
return { error: 'File must be a .zip archive' };
}
return fs.readFileSync(absolutePath);
}
// It's a directory - we need to zip it
// For now, require a zip file
return { error: 'Please provide a .zip file. Directory upload is not yet supported.' };
}
/**
* Format project list for user selection
*/
function formatProjectChoice(projects: TenantWithRole[], packagePath: string): string {
let output = `# Project Required
You need to specify which project to deploy to.
## Your Projects
`;
if (projects.length > 0) {
projects.forEach((project, index) => {
const url = project.customDomain || `${project.subdomain}.fastmode.ai`;
output += `${index + 1}. **${project.name}**
- ID: \`${project.id}\`
- URL: ${url}
- Status: ${project.site?.status || 'pending'}
`;
});
output += `---
## ACTION REQUIRED
**Ask the user:** "Which project should I deploy to? You can choose from the list above, or I can create a new project for you."
### To deploy to an existing project:
\`\`\`
deploy_package(
packagePath: "${packagePath}",
projectId: "${projects[0]?.id || 'project-id-here'}"
)
\`\`\`
### To create a NEW project first:
\`\`\`
create_site(name: "Project Name")
\`\`\`
Then deploy with the returned project ID.
`;
} else {
output += `You don't have any projects yet.
---
## ACTION REQUIRED
**Ask the user:** "What would you like to name your new project?"
Then create the project:
\`\`\`
create_site(name: "User's Project Name")
\`\`\`
And deploy with the returned project ID:
\`\`\`
deploy_package(
packagePath: "${packagePath}",
projectId: "returned-project-id"
)
\`\`\`
`;
}
return output;
}
/**
* Deploy a website package to Fast Mode
*
* @param packagePath - Path to the zip file
* @param projectId - Project ID to deploy to (use list_projects to find, or create_site to create new)
* @param force - Optional: Skip GitHub connection check and deploy anyway
*/
export async function deployPackage(
packagePath: string,
projectId?: string,
force?: boolean
): Promise<string> {
// Check authentication
if (await needsAuthentication()) {
const authResult = await ensureAuthenticated();
if (!authResult.authenticated) {
return authResult.message;
}
}
// If no projectId specified, list projects and prompt for selection
if (!projectId) {
const projects = await listExistingProjects();
return formatProjectChoice(projects, packagePath);
}
// Read the package first
const packageResult = await readPackage(packagePath);
if ('error' in packageResult) {
return `# Package Error
${packageResult.error}
Please provide a valid .zip file path.
`;
}
const zipBuffer = packageResult;
// Get subdomain for the project
const projects = await listExistingProjects();
const project = projects.find(p => p.id === projectId);
const subdomain = project?.subdomain || '';
if (!project) {
return `# Project Not Found
Could not find project with ID: \`${projectId}\`
Use \`list_projects\` to see available projects, or \`create_site\` to create a new one.
`;
}
// Check for GitHub connection (unless force is true)
if (!force) {
const githubStatus = await checkGitHubConnection(projectId);
if (githubStatus?.connected && githubStatus.repo) {
// GitHub is connected - block deployment
return formatGitHubBlockMessage(
githubStatus.repo,
githubStatus.branch || 'main',
subdomain
);
}
}
// ============ PRE-DEPLOY VALIDATION GATE ============
// Validate the package BEFORE uploading to catch errors early
try {
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();
// Build file list from zip
const fileList = entries.map(e => e.entryName);
// Find and read manifest.json
const manifestEntry = entries.find(e =>
e.entryName === 'manifest.json' ||
e.entryName.endsWith('/manifest.json')
);
if (!manifestEntry) {
return `# DEPLOYMENT BLOCKED
**Reason:** Missing manifest.json
Every Fast Mode package requires a manifest.json at the root.
Run \`get_example("manifest_basic")\` to see the required format.
## Expected Package Structure
\`\`\`
your-site.zip
├── manifest.json <- REQUIRED
├── pages/
│ ├── index.html
│ └── ...
├── public/
│ └── css/, js/, images/
└── templates/ (optional, for CMS)
\`\`\`
`;
}
const manifestContent = manifestEntry.getData().toString('utf8');
// Collect template contents for validation
const templateContents: Record<string, string> = {};
for (const entry of entries) {
if (entry.entryName.endsWith('.html') && !entry.isDirectory) {
try {
templateContents[entry.entryName] = entry.getData().toString('utf8');
} catch {
// Skip files that can't be read
}
}
}
// Run full package validation
const validationResult = await validatePackage(fileList, manifestContent, templateContents);
// Only block on actual ERRORS, not warnings
if (validationResult.includes('HAS ERRORS') || validationResult.includes('INVALID')) {
return `# DEPLOYMENT BLOCKED
Your package has validation errors that must be fixed before deployment.
---
${validationResult}
---
## Next Steps
1. Fix the errors listed above
2. Rebuild your .zip package
3. Run \`deploy_package\` again
Use \`validate_template\` and \`validate_manifest\` to check individual files.
`;
}
// Validation passed - include summary in success response later
console.error('Pre-deploy validation passed');
} catch (validationError) {
// If validation itself fails (e.g., corrupt zip), log but continue
// The upload will fail with a more specific error if the package is truly broken
console.error('Pre-deploy validation error:', validationError);
}
// Upload the package
console.error(`Deploying to ${subdomain}.fastmode.ai...`);
const uploadResult = await uploadPackage(projectId, zipBuffer);
if ('error' in uploadResult) {
// Check if it's an auth error
if (needsAuthError({ success: false, error: uploadResult.error, statusCode: 401 })) {
const authResult = await ensureAuthenticated();
if (!authResult.authenticated) {
return authResult.message;
}
// Retry upload
const retryResult = await uploadPackage(projectId, zipBuffer);
if ('error' in retryResult) {
return `# Upload Failed
${retryResult.error}
Please check:
1. The package is a valid .zip file
2. It contains manifest.json, pages/, and public/ folders
3. Try again or upload manually at app.fastmode.ai
`;
}
return formatSuccess(subdomain, retryResult);
}
return `# Upload Failed
${uploadResult.error}
Please check:
1. The package is a valid .zip file
2. It contains manifest.json, pages/, and public/ folders
3. Try again or upload manually at app.fastmode.ai
`;
}
return formatSuccess(subdomain, uploadResult);
}
/**
* Format success message
*/
function formatSuccess(subdomain: string, result: UploadResponse): string {
return `# Deployment Successful!
**Package deployed!**
## Live Site
**URL:** https://${subdomain}.fastmode.ai
## Details
- **Files Uploaded:** ${result.filesUploaded}
- **Pages:** ${result.pages}
- **Version:** ${result.site.packageVersion}
- **Status:** ${result.site.status}
## Next Steps
1. **View your site:** https://${subdomain}.fastmode.ai
2. **Edit content:** Go to app.fastmode.ai to manage your CMS
3. **Connect GitHub:** For automatic deploys on push
---
To update this site later:
\`\`\`
deploy_package(packagePath: "./updated-site.zip", projectId: "${result.site.id}")
\`\`\`
`;
}