Spaces:
Running
Running
| 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); | |
| }); | |
| } | |