spacefactory2 / src /lib /deploy.ts
mfn
Embedded IDE: file tree + CodeMirror editor, workspace terminal, markdown output; agent deploy/monitor/logs tools + git push; hardware-aware deploy
5f0975b
Raw History Blame Contribute Delete
5.21 kB
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createRepo, uploadFiles } from "@huggingface/hub";
import { HF_HUB } from "./config";
import type { HardwareFlavor } from "./hardware";
import type { Workspace } from "./agent/workspace";
const execFileAsync = promisify(execFile);
export interface DeployResult {
url: string;
repoId: string;
hardware: string;
hardwareRequested: boolean;
method: "git" | "upload";
}
export function spaceRemoteUrl(owner: string, spaceName: string, token: string): string {
return `https://${encodeURIComponent(owner)}:${token}@huggingface.co/spaces/${owner}/${spaceName}`;
}
async function git(workspace: Workspace, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd: workspace.dir,
maxBuffer: 8_000_000,
timeout: 120_000,
});
return stdout;
}
async function requestHardware(
repoId: string,
token: string,
hardware: HardwareFlavor,
): Promise<boolean> {
if (hardware === "cpu-basic") return false;
try {
const res = await fetch(`${HF_HUB}/api/spaces/${repoId}/hardware`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ flavor: hardware }),
});
return res.ok;
} catch {
return false;
}
}
/** Deploy the workspace to the user's Space. Ensures the repo exists, then
* pushes via git (preferred — the agent can also `git push` manually), falling
* back to the Hub upload API if git push fails. */
export async function deployWorkspace(opts: {
accessToken: string;
owner: string;
spaceName: string;
workspace: Workspace;
isPrivate: boolean;
hardware: HardwareFlavor;
}): Promise<DeployResult> {
const repoId = `${opts.owner}/${opts.spaceName}`;
const repo = { type: "space" as const, name: repoId };
try {
await createRepo({
repo,
accessToken: opts.accessToken,
visibility: opts.isPrivate ? "private" : "public",
sdk: "gradio",
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!/already (exists|created)/i.test(message)) throw err;
}
let method: "git" | "upload" = "git";
try {
const remote = spaceRemoteUrl(opts.owner, opts.spaceName, opts.accessToken);
await git(opts.workspace, ["remote", "remove", "space"]).catch(() => {});
await git(opts.workspace, ["remote", "add", "space", remote]);
await git(opts.workspace, ["add", "-A"]);
// Commit; ignore "nothing to commit" so re-deploys still push.
await git(opts.workspace, [
"-c",
"user.name=mfn",
"-c",
"user.email=mfn@example.com",
"commit",
"-m",
"Deploy via SpaceFactory",
]).catch(() => {});
await git(opts.workspace, ["push", "--force", "space", "HEAD:main"]);
} catch {
// Fallback: Hub upload API.
method = "upload";
await uploadFiles({
repo,
accessToken: opts.accessToken,
files: opts.workspace.toFiles().map((f) => ({
path: f.path,
content: new Blob([f.content]),
})),
commitTitle: "Deploy via SpaceFactory",
});
}
const hardwareRequested = await requestHardware(
repoId,
opts.accessToken,
opts.hardware,
);
return {
url: `${HF_HUB}/spaces/${repoId}`,
repoId,
hardware: opts.hardware,
hardwareRequested,
method,
};
}
export interface SpaceRuntime {
stage: string;
hardware?: string;
}
export async function getSpaceRuntime(
repoId: string,
token: string,
): Promise<SpaceRuntime> {
const res = await fetch(`${HF_HUB}/api/spaces/${repoId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Could not read Space ${repoId} (HTTP ${res.status})`);
const data = (await res.json()) as {
runtime?: { stage?: string; hardware?: { current?: string } };
};
return {
stage: data.runtime?.stage ?? "UNKNOWN",
hardware: data.runtime?.hardware?.current,
};
}
export async function fetchSpaceLogs(
repoId: string,
token: string,
kind: "build" | "run",
maxLines = 120,
): Promise<string> {
const res = await fetch(`${HF_HUB}/api/spaces/${repoId}/logs/${kind}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Could not fetch ${kind} logs (HTTP ${res.status})`);
const text = await res.text();
const lines: string[] = [];
for (const line of text.split("\n")) {
if (!line.startsWith("data:")) continue;
try {
const data = JSON.parse(line.slice(5).trim()) as { data?: string };
if (data.data != null) lines.push(String(data.data).replace(/\n$/, ""));
} catch {
// ignore keepalives / partial frames
}
}
return lines.slice(-maxLines).join("\n") || "(no logs yet)";
}
const TERMINAL_STAGES = new Set([
"RUNNING",
"BUILD_ERROR",
"RUNTIME_ERROR",
"CONFIG_ERROR",
"ERROR",
"APP_STARTING_ERROR",
"DELETING",
"PAUSED",
]);
export function isTerminalStage(stage: string): boolean {
return TERMINAL_STAGES.has(stage);
}