visual-journal / scripts /deploy-hf-space.mjs
misonL's picture
Deploy a623efc to Docker Space
6825c0c verified
Raw
History Blame
13 kB
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { fetchJsonWithTimeout, parseJsonPayload, runCommandStrict } from './command-center-utils.mjs';
import { assertKnownOptions, HF_SPACE_ID, HF_SPACE_URL, isMainModule } from './hf-space-doctor-utils.mjs';
const STATUS_POLL_ATTEMPTS = 40;
const STATUS_POLL_INTERVAL_MS = 10_000;
const PUBLIC_ENDPOINT_TIMEOUT_MS = 10_000;
const HF_CLI_TIMEOUT_MS = 120_000;
const DEPLOY_MARKER_REPO_PATH = 'public/hf-space-deploy-marker.json';
const DEPLOY_MARKER_API_ROUTE_PATH = 'src/app/api/deploy-marker/route.ts';
const DEPLOY_MARKER_SERVICE_PATH = '/api/deploy-marker';
export const GIT_ARCHIVE_MAX_BUFFER_BYTES = 256 * 1024 * 1024;
function parseArgs(argv) {
assertKnownOptions(argv, ['--help', '-h']);
return {
help: argv.includes('--help') || argv.includes('-h')
};
}
function printHelp() {
console.log(`Usage:
npm run deploy:hf-space
Deploys the current clean git HEAD to ${HF_SPACE_ID} with the official hf CLI.
The script uploads a temporary git archive, waits for the Space to run the new
Space commit, and performs read-only public endpoint checks.`);
}
function runText(command, args, options = {}) {
return runCommandStrict(command, args, {
input: options.input,
timeoutMs: options.timeoutMs || HF_CLI_TIMEOUT_MS
}).trim();
}
function readRepositorySlug() {
const envSlug = process.env.REPO_SLUG?.trim();
if (envSlug) return envSlug;
try {
return parseRepositorySlug(runText('git', ['remote', 'get-url', 'origin']));
} catch (error) {
throw new Error(
`Unable to detect repository slug from git origin. Set REPO_SLUG=owner/repo. ${error instanceof Error ? error.message : String(error)}`
);
}
}
export function parseRepositorySlug(remoteUrl) {
const text = String(remoteUrl || '').trim();
const httpsMatch = text.match(/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`;
const sshMatch = text.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`;
throw new Error('Unable to detect repository slug from git origin URL. Set REPO_SLUG=owner/repo.');
}
function runBinary(command, args) {
const result = spawnSync(command, args, {
encoding: 'buffer',
maxBuffer: GIT_ARCHIVE_MAX_BUFFER_BYTES,
stdio: ['ignore', 'pipe', 'pipe']
});
if (result.error) throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
if (result.status !== 0) {
const output = result.stderr.toString('utf8').trim();
throw new Error(output || `${command} ${args.join(' ')} failed`);
}
return result.stdout;
}
function assertCleanGitWorktree() {
const status = runText('git', ['status', '--porcelain']);
if (status) {
throw new Error('Refusing to deploy a dirty worktree. Commit or revert local changes first.');
}
}
function prepareSourceTree() {
const sourceDir = mkdtempSync(join(tmpdir(), 'gpt-image-hf-space-'));
const archive = runBinary('git', ['archive', '--format=tar', 'HEAD']);
runText('tar', ['-x', '-C', sourceDir], { input: archive });
return sourceDir;
}
export function extractUploadCommitSha(output) {
const payload = parseJsonPayload(output, 'hf upload');
const directSha = [payload.sha, payload.commit, payload.commitSha, payload.commit_sha].find((value) =>
/^[0-9a-f]{40}$/.test(String(value || ''))
);
if (directSha) return directSha;
const match = String(payload.url || '').match(/\/commit\/([0-9a-f]{40})$/);
if (!match) throw new Error('hf upload output did not include a Space commit SHA or commit URL.');
return match[1];
}
export function buildUploadArgs({ sourceDir, localSha, repoSlug, deletePaths = [] }) {
if (!repoSlug?.trim()) throw new Error('REPO_SLUG is required for deploy metadata.');
const args = [
'upload',
HF_SPACE_ID,
sourceDir,
'.',
'--repo-type',
'space',
'--commit-message',
`Deploy ${localSha.slice(0, 7)} to Docker Space`,
'--commit-description',
`Source: ${repoSlug}@${localSha}`,
'--json'
];
for (const deletePath of deletePaths) {
if (!deletePath?.trim() || deletePath.includes('\n') || deletePath.includes('\r')) {
throw new Error('deletePaths must contain non-empty single-line repository paths.');
}
args.push('--delete', deletePath);
}
return args;
}
function readLocalGitFiles() {
const output = runText('git', ['-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', 'HEAD']);
return new Set(output.split('\0').filter(Boolean));
}
function readRemoteFilePaths() {
const info = readSpaceInfo();
return (info.siblings || []).map((sibling) => sibling.rfilename).filter((filename) => typeof filename === 'string' && filename.length > 0);
}
export function findRemoteDeletePaths(localFiles, remoteFiles) {
return [...remoteFiles].filter((filename) => !localFiles.has(filename)).sort();
}
export function buildDeployMarker(localSha, createdAt = new Date(), deployId = randomUUID()) {
if (!/^[0-9a-f]{40}$/.test(String(localSha || ''))) throw new Error('localSha must be a full git commit SHA.');
if (typeof deployId !== 'string' || !deployId.trim() || /[\r\n]/.test(deployId)) {
throw new Error('deployId must be a non-empty single-line string.');
}
return {
schema_version: 1,
local_sha: localSha,
created_at: createdAt.toISOString(),
deploy_id: deployId
};
}
export function assertDeployMarkerMatches(marker, expectedMarker) {
if (!marker || typeof marker !== 'object') throw new Error('deploy marker response was not an object.');
if (marker?.schema_version !== expectedMarker.schema_version) throw new Error('deploy marker schema_version mismatch.');
if (marker.local_sha !== expectedMarker.local_sha) {
throw new Error(`deploy marker local_sha mismatch: expected ${expectedMarker.local_sha}, received ${marker.local_sha || 'missing'}.`);
}
if (marker.created_at !== expectedMarker.created_at) {
throw new Error(`deploy marker created_at mismatch: expected ${expectedMarker.created_at}, received ${marker.created_at || 'missing'}.`);
}
if (marker.deploy_id !== expectedMarker.deploy_id) {
throw new Error(`deploy marker deploy_id mismatch: expected ${expectedMarker.deploy_id}, received ${marker.deploy_id || 'missing'}.`);
}
return marker;
}
export function buildDeployMarkerRouteSource(marker) {
const markerJson = JSON.stringify(marker);
return `import { NextResponse } from 'next/server';
const deployMarker = ${markerJson} as const;
export const dynamic = 'force-dynamic';
export function GET() {
return NextResponse.json(deployMarker, {
headers: {
'Cache-Control': 'no-store'
}
});
}
`;
}
function writeDeployMarker(sourceDir, marker) {
const markerPath = join(sourceDir, DEPLOY_MARKER_REPO_PATH);
const routePath = join(sourceDir, DEPLOY_MARKER_API_ROUTE_PATH);
mkdirSync(join(sourceDir, 'public'), { recursive: true });
mkdirSync(join(sourceDir, 'src', 'app', 'api', 'deploy-marker'), { recursive: true });
writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
writeFileSync(routePath, buildDeployMarkerRouteSource(marker), 'utf8');
}
function readLocalGitFilesWithDeployMarker() {
const files = readLocalGitFiles();
files.add(DEPLOY_MARKER_REPO_PATH);
files.add(DEPLOY_MARKER_API_ROUTE_PATH);
return files;
}
function uploadSourceTree(sourceDir, deployMarker) {
writeDeployMarker(sourceDir, deployMarker);
const deletePaths = findRemoteDeletePaths(readLocalGitFilesWithDeployMarker(), readRemoteFilePaths());
const output = runText('hf', buildUploadArgs({ sourceDir, localSha: deployMarker.local_sha, repoSlug: readRepositorySlug(), deletePaths }));
return extractUploadCommitSha(output);
}
function readSpaceInfo() {
const output = runText('hf', ['spaces', 'info', HF_SPACE_ID, '--format', 'json']);
return parseJsonPayload(output, 'hf spaces info');
}
export async function waitForRunning(spaceCommitSha, deployMarker, options = {}) {
const attempts = options.attempts || STATUS_POLL_ATTEMPTS;
const intervalMs = options.intervalMs ?? STATUS_POLL_INTERVAL_MS;
const readInfo = options.readInfo || readSpaceInfo;
const verifyMarker = options.verifyMarker || verifyDeployMarker;
const sleep = options.sleep || delay;
const log = options.log || console.log;
let lastStage = 'unknown';
let lastSha = 'unknown';
let lastMarkerError = 'unknown';
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const info = readInfo();
lastStage = info.runtime?.stage || 'unknown';
lastSha = info.sha || info.runtime?.raw?.sha || 'unknown';
log(`attempt=${attempt} stage=${lastStage} sha=${lastSha}`);
if (lastStage === 'RUNNING' && lastSha === spaceCommitSha) {
try {
const marker = await verifyMarker(deployMarker);
return { stage: lastStage, sha: lastSha, service_marker_verified: true, marker };
} catch (error) {
lastMarkerError = error instanceof Error ? error.message : String(error);
log(`attempt=${attempt} marker_status=not_ready error=${lastMarkerError}`);
}
}
await sleep(intervalMs);
}
const marker = await verifyMarker(deployMarker);
return {
stage: lastStage,
sha: lastSha,
management_status: 'runtime_stage_not_running',
service_marker_verified: true,
warning: `Space did not reach RUNNING with a matching service marker for ${spaceCommitSha}; last stage=${lastStage} sha=${lastSha} marker_error=${lastMarkerError}`,
marker
};
}
async function fetchJson(path) {
return fetchJsonWithTimeout(new URL(path, HF_SPACE_URL), { timeoutMs: PUBLIC_ENDPOINT_TIMEOUT_MS });
}
async function verifyDeployMarker(expectedMarker) {
const marker = await fetchJson(`${DEPLOY_MARKER_SERVICE_PATH}?t=${Date.now()}`);
return assertDeployMarkerMatches(marker, expectedMarker);
}
async function verifyPublicEndpoints() {
const authStatus = await fetchJson('/api/auth-status');
const capabilities = await fetchJson('/api/agent/capabilities');
const runtime = await fetchJson('/api/runtime-capabilities');
if (authStatus.passwordRequired !== true) {
throw new Error('/api/auth-status did not report passwordRequired=true.');
}
if (capabilities.defaults?.state_backend !== 'memory') {
throw new Error('/api/agent/capabilities did not report state_backend=memory.');
}
if (capabilities.storage?.image_storage_mode !== 'indexeddb') {
throw new Error('/api/agent/capabilities did not report image_storage_mode=indexeddb.');
}
return {
passwordRequired: authStatus.passwordRequired,
agentAuth: capabilities.auth,
stateBackend: capabilities.defaults.state_backend,
imageStorageMode: capabilities.storage.image_storage_mode,
streamingBatch: runtime.streamingBatch
};
}
async function deploy() {
assertCleanGitWorktree();
runText('hf', ['auth', 'whoami']);
const localSha = runText('git', ['rev-parse', 'HEAD']);
const deployMarker = buildDeployMarker(localSha);
const sourceDir = prepareSourceTree();
try {
const spaceCommitSha = uploadSourceTree(sourceDir, deployMarker);
const runtime = await waitForRunning(spaceCommitSha, deployMarker);
const verification = await verifyPublicEndpoints();
console.log(
JSON.stringify(
{
ok: true,
spaceId: HF_SPACE_ID,
spaceUrl: HF_SPACE_URL,
localSha,
spaceCommitSha,
runtime,
verification
},
null,
2
)
);
} finally {
rmSync(sourceDir, { force: true, recursive: true });
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
await deploy();
}
if (isMainModule(import.meta.url, process.argv[1])) {
main().catch((error) => {
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
process.exit(1);
});
}