From 5e82e9158a58042375e23b4edcc50b2f8d4a8039 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 11:46:48 +0100 Subject: [PATCH 01/20] wip --- packages/hub/cli.ts | 985 ++++++++++++++++++++++++- packages/hub/src/lib/index.ts | 1 + packages/hub/src/lib/jobs.ts | 845 +++++++++++++++++++++ packages/hub/src/types/api/api-jobs.ts | 129 ++++ packages/hub/src/types/public.ts | 40 +- 5 files changed, 1970 insertions(+), 30 deletions(-) create mode 100644 packages/hub/src/lib/jobs.ts create mode 100644 packages/hub/src/types/api/api-jobs.ts diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 9559ed707b..3f797f4f41 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -2,10 +2,33 @@ import { parseArgs } from "node:util"; import { typedEntries } from "./src/utils/typedEntries"; -import { createBranch, createRepo, deleteBranch, deleteRepo, repoExists, uploadFilesWithProgress } from "./src"; +import { + createBranch, + createRepo, + deleteBranch, + deleteRepo, + repoExists, + uploadFilesWithProgress, + cancelJob, + createScheduledJob, + deleteScheduledJob, + duplicateJob, + getJob, + getScheduledJob, + listJobHardware, + listJobs, + listScheduledJobs, + resumeScheduledJob, + runJob, + runScheduledJob, + streamJobLogs, + suspendScheduledJob, + whoAmI, +} from "./src"; import { pathToFileURL } from "node:url"; import { stat } from "node:fs/promises"; import { basename, join } from "node:path"; +import { readFileSync } from "node:fs"; import { HUB_URL } from "./src/consts"; import { version } from "./package.json"; import type { CommitProgressEvent } from "./src/lib/commit"; @@ -151,7 +174,7 @@ interface SingleCommand { interface CommandGroup { description: string; - subcommands: Record; + subcommands: Record; } const commands = { @@ -320,6 +343,435 @@ const commands = { }, }, } satisfies CommandGroup, + jobs: { + description: "Manage jobs on the Hub", + subcommands: { + run: { + description: "Run a new job", + args: [ + { + name: "docker-image-or-space" as const, + description: + "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", + positional: true, + required: true, + }, + { + name: "command" as const, + description: "The command to run (space-separated arguments)", + positional: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "flavor" as const, + description: "Hardware flavor to use (e.g., 'cpu-basic', 'a10g-small')", + enum: [ + "cpu-basic", + "cpu-upgrade", + "t4-small", + "t4-medium", + "l4x1", + "l4x4", + "a10g-small", + "a10g-large", + "a10g-largex2", + "a10g-largex4", + "a100-large", + "v5e-1x1", + "v5e-2x2", + "v5e-2x4", + ], + default: "cpu-basic", + }, + { + name: "arch" as const, + enum: ["amd64", "arm64"], + description: "Architecture (defaults to 'amd64')", + }, + { + name: "timeout" as const, + description: + "Timeout in seconds, or with unit (e.g., '2h', '90m', '1.5h'). Supports: s (seconds), m (minutes), h (hours), d (days)", + }, + { + name: "attempts" as const, + description: "Maximum number of attempts (defaults to 1)", + }, + { + name: "env" as const, + short: "e", + description: "Environment variable in the format KEY=VALUE (can be used multiple times)", + }, + { + name: "env-file" as const, + description: "Path to a .env file containing environment variables", + }, + { + name: "secret" as const, + short: "s", + description: "Secret in the format KEY=VALUE (will be encrypted server-side, can be used multiple times)", + }, + { + name: "secrets-file" as const, + description: "Path to a .env.secrets file containing secrets (will be encrypted server-side)", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + ps: { + description: "List jobs", + args: [ + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "all" as const, + short: "a", + boolean: true, + description: "List all jobs (not just running ones)", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + inspect: { + description: "Inspect the status of a job", + args: [ + { + name: "job-id" as const, + description: "The job ID to inspect", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + logs: { + description: "View logs from a job", + args: [ + { + name: "job-id" as const, + description: "The job ID to view logs for", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + cancel: { + description: "Cancel a job", + args: [ + { + name: "job-id" as const, + description: "The job ID to cancel", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + duplicate: { + description: "Duplicate a job (re-run with the same spec)", + args: [ + { + name: "job-id" as const, + description: "The job ID to duplicate", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + hardware: { + description: "List available hardware for jobs", + args: [] as const, + }, + scheduled: { + description: "Manage scheduled jobs", + subcommands: { + run: { + description: "Create and run a scheduled job", + args: [ + { + name: "schedule" as const, + description: + "CRON schedule expression (e.g., '0 9 * * 1' for 9 AM every Monday) or shortcuts like '@hourly', '@daily'", + positional: true, + required: true, + }, + { + name: "docker-image-or-space" as const, + description: + "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", + positional: true, + required: true, + }, + { + name: "command" as const, + description: "The command to run (space-separated arguments)", + positional: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "flavor" as const, + description: "Hardware flavor to use (e.g., 'cpu-basic', 'a10g-small')", + enum: [ + "cpu-basic", + "cpu-upgrade", + "t4-small", + "t4-medium", + "l4x1", + "l4x4", + "a10g-small", + "a10g-large", + "a10g-largex2", + "a10g-largex4", + "a100-large", + "v5e-1x1", + "v5e-2x2", + "v5e-2x4", + ], + default: "cpu-basic", + }, + { + name: "arch" as const, + enum: ["amd64", "arm64"], + description: "Architecture (defaults to 'amd64')", + }, + { + name: "timeout" as const, + description: + "Timeout in seconds, or with unit (e.g., '2h', '90m', '1.5h'). Supports: s (seconds), m (minutes), h (hours), d (days)", + }, + { + name: "attempts" as const, + description: "Maximum number of attempts (defaults to 1)", + }, + { + name: "env" as const, + short: "e", + description: "Environment variable in the format KEY=VALUE (can be used multiple times)", + }, + { + name: "env-file" as const, + description: "Path to a .env file containing environment variables", + }, + { + name: "secret" as const, + short: "s", + description: "Secret in the format KEY=VALUE (will be encrypted server-side, can be used multiple times)", + }, + { + name: "secrets-file" as const, + description: "Path to a .env.secrets file containing secrets (will be encrypted server-side)", + }, + { + name: "suspend" as const, + boolean: true, + description: "Create the scheduled job in suspended (paused) state", + }, + { + name: "concurrency" as const, + boolean: true, + description: "Allow multiple instances of this job to run concurrently", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + ps: { + description: "List scheduled jobs", + args: [ + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + inspect: { + description: "Inspect the status of a scheduled job", + args: [ + { + name: "scheduled-job-id" as const, + description: "The scheduled job ID to inspect", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + suspend: { + description: "Suspend (pause) a scheduled job", + args: [ + { + name: "scheduled-job-id" as const, + description: "The scheduled job ID to suspend", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + resume: { + description: "Resume a scheduled job", + args: [ + { + name: "scheduled-job-id" as const, + description: "The scheduled job ID to resume", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + delete: { + description: "Delete a scheduled job", + args: [ + { + name: "scheduled-job-id" as const, + description: "The scheduled job ID to delete", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + trigger: { + description: "Trigger a scheduled job to run immediately", + args: [ + { + name: "scheduled-job-id" as const, + description: "The scheduled job ID to trigger", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: + "The namespace (username or organization name). Defaults to the current user if not provided.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + }, + }, + }, + } satisfies CommandGroup, version: { description: "Print the version of the CLI", args: [] as const, @@ -362,8 +814,32 @@ async function run() { subCmdName in cmdDef.subcommands && cmdDef.subcommands[subCmdName as keyof typeof cmdDef.subcommands] ) { - console.log(detailedUsageForSubcommand(cmdName, subCmdName as keyof typeof cmdDef.subcommands)); - break; + const subCmdDef = cmdDef.subcommands[subCmdName as keyof typeof cmdDef.subcommands]; + // Check if this is a nested command group + if ("subcommands" in subCmdDef) { + if (helpArgs.length > 2) { + const nestedSubCmdName = helpArgs[2]; + if (nestedSubCmdName in subCmdDef.subcommands) { + console.log( + detailedUsageForNestedSubcommand(cmdName, subCmdName, nestedSubCmdName) + ); + break; + } else { + console.error( + `Error: Unknown subcommand '${nestedSubCmdName}' for command '${cmdName} ${subCmdName}'.` + ); + console.log(listNestedSubcommands(cmdName, subCmdName, subCmdDef)); + process.exitCode = 1; + break; + } + } else { + console.log(listNestedSubcommands(cmdName, subCmdName, subCmdDef)); + break; + } + } else { + console.log(detailedUsageForSubcommand(cmdName, subCmdName as keyof typeof cmdDef.subcommands)); + break; + } } else { console.error(`Error: Unknown subcommand '${subCmdName}' for command '${cmdName}'.`); console.log(listSubcommands(cmdName, cmdDef)); @@ -583,6 +1059,410 @@ async function run() { } break; } + case "jobs": { + const jobsCommandGroup = commands.jobs; + const currentSubCommandName = subCommandName as keyof typeof jobsCommandGroup.subcommands | undefined; + + if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { + if (currentSubCommandName && jobsCommandGroup.subcommands[currentSubCommandName]) { + const subCmd = jobsCommandGroup.subcommands[currentSubCommandName]; + if ("subcommands" in subCmd) { + // This is a nested command group (scheduled) + const nestedSubCmdName = cliArgs[1]; + if (nestedSubCmdName && nestedSubCmdName in subCmd.subcommands) { + console.log( + detailedUsageForNestedSubcommand("jobs", currentSubCommandName, nestedSubCmdName as string) + ); + } else { + console.log(listNestedSubcommands("jobs", currentSubCommandName, subCmd)); + } + } else { + console.log(detailedUsageForSubcommand("jobs", currentSubCommandName)); + } + } else { + console.log(listSubcommands("jobs", jobsCommandGroup)); + } + break; + } + + if (!currentSubCommandName || !jobsCommandGroup.subcommands[currentSubCommandName]) { + console.error(`Error: Missing or invalid subcommand for 'jobs'.`); + console.log(listSubcommands("jobs", jobsCommandGroup)); + process.exitCode = 1; + break; + } + + const subCmdDef = jobsCommandGroup.subcommands[currentSubCommandName]; + const hubUrl = process.env.HF_ENDPOINT ?? HUB_URL; + const token = process.env.HF_TOKEN; + + + switch (currentSubCommandName) { + case "run": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs run"); + const namespace = await getNamespace(parsedArgs.namespace); + const dockerImageOrSpace = parsedArgs.dockerImageOrSpace; + const command = parsedArgs.command ? parsedArgs.command.split(/\s+/) : undefined; + + // Determine if it's a docker image or space + const isSpace = dockerImageOrSpace.includes("/") && !dockerImageOrSpace.includes(":"); + const dockerImage = isSpace ? undefined : dockerImageOrSpace; + const spaceId = isSpace ? dockerImageOrSpace : undefined; + + // Parse environment variables + let environment: Record = {}; + if (parsedArgs.env) { + environment = { ...environment, ...parseKeyValuePairs(parsedArgs.env) }; + } + if (parsedArgs.envFile) { + environment = { ...environment, ...loadEnvFile(parsedArgs.envFile) }; + } + + // Parse secrets + let secrets: Record = {}; + if (parsedArgs.secret) { + secrets = { ...secrets, ...parseKeyValuePairs(parsedArgs.secret) }; + } + if (parsedArgs.secretsFile) { + secrets = { ...secrets, ...loadEnvFile(parsedArgs.secretsFile) }; + } + + const job = await runJob({ + namespace, + dockerImage, + spaceId, + command, + flavor: parsedArgs.flavor || "cpu-basic", + arch: parsedArgs.arch, + timeoutSeconds: parseTimeout(parsedArgs.timeout), + attempts: parsedArgs.attempts ? parseInt(parsedArgs.attempts, 10) : undefined, + environment: Object.keys(environment).length > 0 ? environment : undefined, + secrets: Object.keys(secrets).length > 0 ? secrets : undefined, + accessToken: parsedArgs.token || token, + hubUrl, + }); + + console.log(`Job started: ${job.id}`); + console.log(`Status: ${job.status}`); + break; + } + case "ps": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs ps"); + const namespace = await getNamespace(parsedArgs.namespace); + const jobs = await listJobs({ + namespace, + accessToken: parsedArgs.token || token, + hubUrl, + }); + + if (jobs.length === 0) { + console.log("No jobs found."); + break; + } + + // Filter by status if not --all + const filteredJobs = parsedArgs.all ? jobs : jobs.filter((j) => j.status === "running" || j.status === "pending"); + + console.log(`\n${filteredJobs.length} job(s):\n`); + for (const job of filteredJobs) { + console.log(` ${job.id} ${job.status.padEnd(12)} ${job.dockerImage || job.spaceId || "N/A"}`); + if (job.command) { + console.log(` Command: ${job.command.join(" ")}`); + } + if (job.createdAt) { + console.log(` Created: ${new Date(job.createdAt).toLocaleString()}`); + } + console.log(); + } + break; + } + case "inspect": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs inspect"); + const namespace = await getNamespace(parsedArgs.namespace); + const job = await getJob({ + namespace, + jobId: parsedArgs.jobId, + accessToken: parsedArgs.token || token, + hubUrl, + }); + + console.log(JSON.stringify(job, null, 2)); + break; + } + case "logs": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs logs"); + const namespace = await getNamespace(parsedArgs.namespace); + + try { + for await (const chunk of streamJobLogs({ + namespace, + jobId: parsedArgs.jobId, + accessToken: parsedArgs.token || token, + hubUrl, + })) { + process.stdout.write(chunk); + } + } catch (error) { + console.error("Error streaming logs:", error); + process.exitCode = 1; + } + break; + } + case "cancel": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs cancel"); + const namespace = await getNamespace(parsedArgs.namespace); + const job = await cancelJob({ + namespace, + jobId: parsedArgs.jobId, + accessToken: parsedArgs.token || token, + hubUrl, + }); + + console.log(`Job ${parsedArgs.jobId} cancelled. Status: ${job.status}`); + break; + } + case "duplicate": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs duplicate"); + const namespace = await getNamespace(parsedArgs.namespace); + const job = await duplicateJob({ + namespace, + jobId: parsedArgs.jobId, + accessToken: parsedArgs.token || token, + hubUrl, + }); + + console.log(`Job duplicated: ${job.id}`); + console.log(`Status: ${job.status}`); + break; + } + case "hardware": { + // Hardware list is public, no auth needed + const hardware = await listJobHardware({ hubUrl }); + + // Format as table + console.log( + "NAME".padEnd(15) + + "PRETTY NAME".padEnd(22) + + "CPU".padEnd(9) + + "RAM".padEnd(8) + + "ACCELERATOR".padEnd(17) + + "COST/MIN".padEnd(9) + + "COST/HOUR" + ); + console.log( + "-".repeat(15) + + " " + + "-".repeat(21) + + " " + + "-".repeat(8) + + " " + + "-".repeat(7) + + " " + + "-".repeat(16) + + " " + + "-".repeat(8) + + " " + + "-".repeat(9) + ); + + for (const hw of hardware) { + const acceleratorStr = hw.accelerator + ? `${hw.accelerator.quantity}x ${hw.accelerator.model} (${hw.accelerator.vram})` + : "N/A"; + const costPerMin = `$${hw.unitCostUSD.toFixed(4)}`; + const costPerHour = `$${(hw.unitCostUSD * 60).toFixed(2)}`; + + console.log( + hw.name.padEnd(15) + + hw.prettyName.padEnd(22) + + hw.cpu.padEnd(9) + + hw.ram.padEnd(8) + + acceleratorStr.padEnd(17) + + costPerMin.padEnd(9) + + costPerHour + ); + } + break; + } + case "scheduled": { + // Handle nested scheduled subcommands + if (!("subcommands" in subCmdDef)) { + console.error(`Error: 'scheduled' is not a command group.`); + process.exitCode = 1; + break; + } + + const scheduledSubCmdName = cliArgs[0]; + if (!scheduledSubCmdName) { + console.error(`Error: Missing subcommand for 'jobs scheduled'.`); + console.log(listNestedSubcommands("jobs", "scheduled", subCmdDef)); + process.exitCode = 1; + break; + } + + const nestedSubCmdDef = subCmdDef.subcommands[scheduledSubCmdName as keyof typeof subCmdDef.subcommands]; + if (!nestedSubCmdDef) { + console.error(`Error: Unknown subcommand '${scheduledSubCmdName}' for 'jobs scheduled'.`); + console.log(listNestedSubcommands("jobs", "scheduled", subCmdDef)); + process.exitCode = 1; + break; + } + + const nestedParsedArgs = advParseArgs(cliArgs.slice(1), nestedSubCmdDef.args, `jobs scheduled ${scheduledSubCmdName}`); + const namespace = await getNamespace(nestedParsedArgs.namespace); + + switch (scheduledSubCmdName) { + case "run": { + const dockerImageOrSpace = nestedParsedArgs.dockerImageOrSpace; + const command = nestedParsedArgs.command ? nestedParsedArgs.command.split(/\s+/) : undefined; + + const isSpace = dockerImageOrSpace.includes("/") && !dockerImageOrSpace.includes(":"); + const dockerImage = isSpace ? undefined : dockerImageOrSpace; + const spaceId = isSpace ? dockerImageOrSpace : undefined; + + let environment: Record = {}; + if (nestedParsedArgs.env) { + environment = { ...environment, ...parseKeyValuePairs(nestedParsedArgs.env) }; + } + if (nestedParsedArgs.envFile) { + environment = { ...environment, ...loadEnvFile(nestedParsedArgs.envFile) }; + } + + let secrets: Record = {}; + if (nestedParsedArgs.secret) { + secrets = { ...secrets, ...parseKeyValuePairs(nestedParsedArgs.secret) }; + } + if (nestedParsedArgs.secretsFile) { + secrets = { ...secrets, ...loadEnvFile(nestedParsedArgs.secretsFile) }; + } + + const scheduledJob = await createScheduledJob({ + namespace, + jobSpec: { + dockerImage, + spaceId, + command, + flavor: nestedParsedArgs.flavor || "cpu-basic", + arch: nestedParsedArgs.arch, + timeoutSeconds: parseTimeout(nestedParsedArgs.timeout), + attempts: nestedParsedArgs.attempts ? parseInt(nestedParsedArgs.attempts, 10) : undefined, + environment: Object.keys(environment).length > 0 ? environment : undefined, + secrets: Object.keys(secrets).length > 0 ? secrets : undefined, + }, + schedule: nestedParsedArgs.schedule, + suspend: nestedParsedArgs.suspend ?? false, + concurrency: nestedParsedArgs.concurrency ?? false, + accessToken: nestedParsedArgs.token || token, + hubUrl, + }); + + console.log(`Scheduled job created: ${scheduledJob.id}`); + console.log(`Schedule: ${scheduledJob.schedule}`); + break; + } + case "ps": { + const scheduledJobs = await listScheduledJobs({ + namespace, + accessToken: nestedParsedArgs.token || token, + hubUrl, + }); + + if (scheduledJobs.length === 0) { + console.log("No scheduled jobs found."); + break; + } + + console.log(`\n${scheduledJobs.length} scheduled job(s):\n`); + for (const job of scheduledJobs) { + console.log(` ${job.id} ${job.suspend ? "SUSPENDED" : "ACTIVE".padEnd(12)} ${job.schedule}`); + if (job.jobSpec.dockerImage || job.jobSpec.spaceId) { + console.log(` ${job.jobSpec.dockerImage || job.jobSpec.spaceId}`); + } + if (job.jobSpec.command) { + console.log(` Command: ${job.jobSpec.command.join(" ")}`); + } + console.log(); + } + break; + } + case "inspect": { + const scheduledJob = await getScheduledJob({ + namespace, + jobId: nestedParsedArgs.scheduledJobId, + accessToken: nestedParsedArgs.token || token, + hubUrl, + }); + + console.log(JSON.stringify(scheduledJob, null, 2)); + break; + } + case "suspend": { + await suspendScheduledJob({ + namespace, + jobId: nestedParsedArgs.scheduledJobId, + accessToken: nestedParsedArgs.token || token, + hubUrl, + }); + + console.log(`Scheduled job ${nestedParsedArgs.scheduledJobId} suspended.`); + break; + } + case "resume": { + await resumeScheduledJob({ + namespace, + jobId: nestedParsedArgs.scheduledJobId, + accessToken: nestedParsedArgs.token || token, + hubUrl, + }); + + console.log(`Scheduled job ${nestedParsedArgs.scheduledJobId} resumed.`); + break; + } + case "delete": { + await deleteScheduledJob({ + namespace, + jobId: nestedParsedArgs.scheduledJobId, + accessToken: nestedParsedArgs.token || token, + hubUrl, + }); + + console.log(`Scheduled job ${nestedParsedArgs.scheduledJobId} deleted.`); + break; + } + case "trigger": { + const job = await runScheduledJob({ + namespace, + jobId: nestedParsedArgs.scheduledJobId, + accessToken: nestedParsedArgs.token || token, + hubUrl, + }); + + if (job) { + console.log(`Scheduled job triggered: ${job.id}`); + console.log(`Status: ${job.status}`); + } else { + console.log( + `Scheduled job ${nestedParsedArgs.scheduledJobId} could not be triggered: another instance is already running.` + ); + console.log(`Set 'concurrency' to allow multiple instances to run concurrently.`); + } + break; + } + default: + console.error(`Error: Unknown subcommand '${scheduledSubCmdName}' for 'jobs scheduled'.`); + process.exitCode = 1; + break; + } + break; + } + default: + console.error(`Error: Unknown subcommand '${currentSubCommandName}' for 'jobs'.`); + console.log(listSubcommands("jobs", jobsCommandGroup)); + process.exitCode = 1; + break; + } + break; + } case "version": { if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { console.log(detailedUsageForCommand("version")); @@ -642,6 +1522,64 @@ function usage(commandName: TopLevelCommandName, subCommandName?: string): strin .join(" ")}`.trim(); } +// Helper to get namespace (defaults to current user) +async function getNamespace(providedNamespace?: string): Promise { + if (providedNamespace) { + return providedNamespace; + } + const userInfo = await whoAmI({ accessToken: token, hubUrl }); + return userInfo.name; +} + +// Helper to parse timeout string (e.g., "2h", "90m", "1.5h") +function parseTimeout(timeoutStr?: string): number | null | undefined { + if (!timeoutStr) return undefined; + const match = timeoutStr.match(/^(\d+(?:\.\d+)?)\s*([smhd])?$/i); + if (!match) { + throw new Error(`Invalid timeout format: ${timeoutStr}. Use format like '2h', '90m', '1.5h', or a number in seconds.`); + } + const value = parseFloat(match[1]); + const unit = (match[2] || "s").toLowerCase(); + const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + return Math.round(value * multipliers[unit]); +} + +// Helper to parse env/secret from CLI args +function parseKeyValuePairs(values?: string | string[]): Record { + if (!values) return {}; + const arr = Array.isArray(values) ? values : [values]; + const result: Record = {}; + for (const pair of arr) { + const [key, ...valueParts] = pair.split("="); + if (!key || valueParts.length === 0) { + throw new Error(`Invalid key-value pair: ${pair}. Expected format: KEY=VALUE`); + } + result[key] = valueParts.join("="); + } + return result; +} + +// Helper to load env file +function loadEnvFile(filePath: string): Record { + try { + const content = readFileSync(filePath, "utf-8"); + const result: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + const [key, ...valueParts] = trimmed.split("="); + if (key) { + result[key] = valueParts.join("="); + } + } + } + return result; + } catch (error) { + throw new Error(`Failed to read env file ${filePath}: ${error}`); + } +} + + function _detailedUsage(args: readonly ArgDef[], usageLine: string, commandDescription?: string): string { let ret = `usage: hfjs ${usageLine}\n`; if (commandDescription) { @@ -711,6 +1649,45 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command return ret; } +function listNestedSubcommands( + commandName: TopLevelCommandName, + subCommandName: string, + commandGroup: CommandGroup +): string { + let ret = `usage: hfjs ${commandName} ${subCommandName} [options]\n\n`; + ret += `${commandGroup.description}\n\n`; + ret += `Available subcommands for '${commandName} ${subCommandName}':\n`; + ret += typedEntries(commandGroup.subcommands) + .map(([subName, subDef]) => ` ${subName}\t${subDef.description}`) + .join("\n"); + ret += `\n\nRun \`hfjs help ${commandName} ${subCommandName} \` for more information on a specific subcommand.`; + return ret; +} + +function detailedUsageForNestedSubcommand( + commandName: TopLevelCommandName, + subCommandName: string, + nestedSubCommandName: string +): string { + const commandGroup = commands[commandName]; + if (!("subcommands" in commandGroup)) { + throw new Error(`Command ${commandName} is not a command group`); + } + const subCommandGroup = commandGroup.subcommands[subCommandName]; + if (!("subcommands" in subCommandGroup)) { + throw new Error(`Subcommand ${subCommandName} is not a command group`); + } + const nestedSubCommandDef = subCommandGroup.subcommands[nestedSubCommandName]; + if (!nestedSubCommandDef) { + throw new Error(`Nested subcommand ${nestedSubCommandName} not found for ${commandName} ${subCommandName}`); + } + return _detailedUsage( + nestedSubCommandDef.args, + usage(commandName, `${subCommandName} ${nestedSubCommandName}`), + nestedSubCommandDef.description + ); +} + type ParsedArgsResult = { [K in TArgsDef[number] as Camelize]: K["boolean"] extends true ? boolean diff --git a/packages/hub/src/lib/index.ts b/packages/hub/src/lib/index.ts index 0d19b7e826..283c5ae9ba 100644 --- a/packages/hub/src/lib/index.ts +++ b/packages/hub/src/lib/index.ts @@ -15,6 +15,7 @@ export * from "./download-file"; export * from "./download-file-to-cache-dir"; export * from "./file-download-info"; export * from "./file-exists"; +export * from "./jobs"; export * from "./list-commits"; export * from "./list-datasets"; export * from "./list-files"; diff --git a/packages/hub/src/lib/jobs.ts b/packages/hub/src/lib/jobs.ts new file mode 100644 index 0000000000..eba4e7e4a9 --- /dev/null +++ b/packages/hub/src/lib/jobs.ts @@ -0,0 +1,845 @@ +import { HUB_URL } from "../consts"; +import { createApiError } from "../error"; +import type { CredentialsParams } from "../types/public"; +import { checkCredentials } from "../utils/checkCredentials"; +import type { + ApiJob, + ApiJobHardware, + ApiScheduledJob, + CreateJobOptions, + CreateScheduledJobOptions, +} from "../types/api/api-jobs"; + +/** + * Get the list of available hardware for jobs. + * This endpoint is public and does not require authentication. + */ +export async function listJobHardware(params?: { + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; +}): Promise { + const response = await (params?.fetch || fetch)(`${params?.hubUrl || HUB_URL}/api/jobs/hardware`, { + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * List jobs for a namespace (user or organization). + */ +export async function listJobs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Get a specific job by ID. + */ +export async function getJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Run a new job. + */ +export async function runJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CreateJobOptions & + CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + if (!params.dockerImage && !params.spaceId) { + throw new Error("Either dockerImage or spaceId must be provided"); + } + + if (params.dockerImage && params.spaceId) { + throw new Error("Cannot provide both dockerImage and spaceId"); + } + + const body: Record = { + flavor: params.flavor, + }; + + if (params.dockerImage) { + body.dockerImage = params.dockerImage; + } + if (params.spaceId) { + body.spaceId = params.spaceId; + } + if (params.command) { + body.command = params.command; + } + if (params.arguments) { + body.arguments = params.arguments; + } + if (params.environment) { + body.environment = params.environment; + } + if (params.secrets) { + body.secrets = params.secrets; + } + if (params.arch) { + body.arch = params.arch; + } + if (params.timeoutSeconds !== undefined) { + body.timeoutSeconds = params.timeoutSeconds; + } + if (params.attempts !== undefined) { + body.attempts = params.attempts; + } + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Cancel a job. + */ +export async function cancelJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/cancel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Duplicate a job (re-run with the same spec). + */ +export async function duplicateJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID to duplicate + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/duplicate`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Stream job logs using Server-Sent Events (SSE). + * Returns an async iterable of log chunks. + */ +export async function* streamJobLogs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): AsyncGenerator { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/logs`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} + +/** + * Stream job metrics using Server-Sent Events (SSE). + * Returns an async iterable of metric chunks. + */ +export async function* streamJobMetrics( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): AsyncGenerator { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/metrics`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} + +/** + * Stream job events using Server-Sent Events (SSE). + * Returns an async iterable of event chunks. + */ +export async function* streamJobEvents( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): AsyncGenerator { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/events`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} + +// Scheduled Jobs API + +/** + * List scheduled jobs for a namespace. + */ +export async function listScheduledJobs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Get a specific scheduled job by ID. + */ +export async function getScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Create a scheduled job. + */ +export async function createScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CreateScheduledJobOptions & + CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const { namespace, hubUrl, fetch: customFetch, ...rest } = params; + + if (!rest.jobSpec.dockerImage && !rest.jobSpec.spaceId) { + throw new Error("Either dockerImage or spaceId must be provided in jobSpec"); + } + + if (rest.jobSpec.dockerImage && rest.jobSpec.spaceId) { + throw new Error("Cannot provide both dockerImage and spaceId in jobSpec"); + } + + const body: Record = { + jobSpec: { + flavor: rest.jobSpec.flavor, + }, + schedule: rest.schedule, + suspend: rest.suspend ?? false, + concurrency: rest.concurrency ?? false, + }; + + if (rest.jobSpec.dockerImage) { + (body.jobSpec as Record).dockerImage = rest.jobSpec.dockerImage; + } + if (rest.jobSpec.spaceId) { + (body.jobSpec as Record).spaceId = rest.jobSpec.spaceId; + } + if (rest.jobSpec.command) { + (body.jobSpec as Record).command = rest.jobSpec.command; + } + if (rest.jobSpec.environment) { + (body.jobSpec as Record).environment = rest.jobSpec.environment; + } + if (rest.jobSpec.secrets) { + (body.jobSpec as Record).secrets = rest.jobSpec.secrets; + } + if (rest.jobSpec.arch) { + (body.jobSpec as Record).arch = rest.jobSpec.arch; + } + if (rest.jobSpec.timeoutSeconds !== undefined) { + (body.jobSpec as Record).timeoutSeconds = rest.jobSpec.timeoutSeconds; + } + if (rest.jobSpec.attempts !== undefined) { + (body.jobSpec as Record).attempts = rest.jobSpec.attempts; + } + + const response = await (customFetch || fetch)(`${hubUrl || HUB_URL}/api/scheduled-jobs/${namespace}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} + +/** + * Delete a scheduled job. + */ +export async function deleteScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } +} + +/** + * Suspend (pause) a scheduled job. + */ +export async function suspendScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/suspend`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } +} + +/** + * Resume a scheduled job. + */ +export async function resumeScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/resume`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + throw await createApiError(response); + } +} + +/** + * Trigger a scheduled job to run immediately. + * Returns the job that was triggered, or null if another instance is already running. + */ +export async function runScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/run`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (!response.ok) { + if (response.status === 409) { + // Another instance is already running + return null; + } + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/types/api/api-jobs.ts b/packages/hub/src/types/api/api-jobs.ts new file mode 100644 index 0000000000..9c304b45ad --- /dev/null +++ b/packages/hub/src/types/api/api-jobs.ts @@ -0,0 +1,129 @@ +import type { SpaceHardwareFlavor } from "../public"; + +export interface ApiJobHardware { + name: string; + prettyName: string; + cpu: string; + ram: string; + accelerator: { + type: "gpu" | "neuron"; + model: string; + quantity: string; + vram: string; + manufacturer: "Nvidia" | "AWS"; + } | null; + unitCostMicroUSD: number; + unitCostUSD: number; + unitLabel: string; +} + +export type JobStatus = + | "pending" + | "running" + | "succeeded" + | "failed" + | "cancelled" + | "cancelling" + | "queued"; + +export interface ApiJob { + id: string; + status: JobStatus; + createdAt: string; + updatedAt: string; + startedAt?: string | null; + finishedAt?: string | null; + dockerImage?: string | null; + spaceId?: string | null; + command?: string[] | null; + arguments?: string[] | null; + flavor: SpaceHardwareFlavor; + arch?: "amd64" | "arm64" | null; + timeoutSeconds?: number | null; + attempts?: number; + initiator?: { + type: "user" | "scheduled-job" | "duplicated-job"; + id?: string; + } | null; +} + +export interface ApiScheduledJob { + id: string; + schedule: string; + suspend: boolean; + concurrency: boolean; + createdAt: string; + updatedAt: string; + jobSpec: { + dockerImage?: string | null; + spaceId?: string | null; + command?: string[] | null; + environment?: Record | null; + flavor: SpaceHardwareFlavor; + arch?: "amd64" | "arm64" | null; + timeoutSeconds?: number | null; + attempts?: number; + }; +} + +export interface CreateJobOptions { + /** + * The Docker image to run (e.g., "python:3.12" or "pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel") + */ + dockerImage?: string; + /** + * The Space ID to run (e.g., "username/space-name") + */ + spaceId?: string; + /** + * The command to run (array of strings) + */ + command?: string[]; + /** + * Additional arguments to pass to the command + */ + arguments?: string[]; + /** + * Environment variables to set + */ + environment?: Record; + /** + * Secrets to pass (will be encrypted server-side) + */ + secrets?: Record; + /** + * Hardware flavor to use + */ + flavor: SpaceHardwareFlavor; + /** + * Architecture (defaults to "amd64") + */ + arch?: "amd64" | "arm64"; + /** + * Timeout in seconds + */ + timeoutSeconds?: number | null; + /** + * Maximum number of attempts (defaults to 1) + */ + attempts?: number; +} + +export interface CreateScheduledJobOptions { + /** + * The job specification + */ + jobSpec: Omit; + /** + * CRON schedule expression (e.g., "0 9 * * 1" for 9 AM every Monday) or shortcuts like "@hourly", "@daily" + */ + schedule: string; + /** + * Whether the scheduled job is suspended (paused) + */ + suspend?: boolean; + /** + * Whether multiple instances of this job can run concurrently + */ + concurrency?: boolean; +} diff --git a/packages/hub/src/types/public.ts b/packages/hub/src/types/public.ts index 6a5b4a3004..334b466efc 100644 --- a/packages/hub/src/types/public.ts +++ b/packages/hub/src/types/public.ts @@ -23,35 +23,23 @@ export interface Credentials { export type CredentialsParams = | { - accessToken?: undefined; - /** - * @deprecated Use `accessToken` instead - */ - credentials: Credentials; - } + accessToken?: undefined; + /** + * @deprecated Use `accessToken` instead + */ + credentials: Credentials; + } | { - accessToken: AccessToken; - /** - * @deprecated Use `accessToken` instead - */ - credentials?: undefined; - }; + accessToken: AccessToken; + /** + * @deprecated Use `accessToken` instead + */ + credentials?: undefined; + }; export type SpaceHardwareFlavor = - | "cpu-basic" - | "cpu-upgrade" - | "t4-small" - | "t4-medium" - | "l4x1" - | "l4x4" - | "a10g-small" - | "a10g-large" - | "a10g-largex2" - | "a10g-largex4" - | "a100-large" - | "v5e-1x1" - | "v5e-2x2" - | "v5e-2x4"; + "cpu-basic" | "cpu-upgrade" | "cpu-performance" | "cpu-xl" | "sprx8" | "zero-a10g" | "inf2x6" | "t4-small" | + "t4-medium" | "l4x1" | "l4x4" | "l40sx1" | "l40sx4" | "l40sx8" | "a10g-small" | "a10g-large" | "a10g-largex2" | "a10g-largex4" | "a100-large" | "a100x4" | "a100x8"; export type SpaceSdk = "streamlit" | "gradio" | "docker" | "static"; From c24ab9d44d141f95cd3eee6116b229e9fd1cd577 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 12:06:39 +0000 Subject: [PATCH 02/20] format --- packages/hub/cli.ts | 73 ++++++++++++-------------- packages/hub/src/lib/jobs.ts | 67 +++++++++++------------ packages/hub/src/types/api/api-jobs.ts | 9 +--- packages/hub/src/types/public.ts | 47 ++++++++++++----- 4 files changed, 100 insertions(+), 96 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 3f797f4f41..8a1eaa0415 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -351,8 +351,7 @@ const commands = { args: [ { name: "docker-image-or-space" as const, - description: - "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", + description: "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", positional: true, required: true, }, @@ -363,8 +362,7 @@ const commands = { }, { name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", + description: "The namespace (username or organization name). Defaults to the current user if not provided.", }, { name: "flavor" as const, @@ -432,8 +430,7 @@ const commands = { args: [ { name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", + description: "The namespace (username or organization name). Defaults to the current user if not provided.", }, { name: "all" as const, @@ -460,8 +457,7 @@ const commands = { }, { name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", + description: "The namespace (username or organization name). Defaults to the current user if not provided.", }, { name: "token" as const, @@ -482,8 +478,7 @@ const commands = { }, { name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", + description: "The namespace (username or organization name). Defaults to the current user if not provided.", }, { name: "token" as const, @@ -504,8 +499,7 @@ const commands = { }, { name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", + description: "The namespace (username or organization name). Defaults to the current user if not provided.", }, { name: "token" as const, @@ -526,8 +520,7 @@ const commands = { }, { name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", + description: "The namespace (username or organization name). Defaults to the current user if not provided.", }, { name: "token" as const, @@ -556,8 +549,7 @@ const commands = { }, { name: "docker-image-or-space" as const, - description: - "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", + description: "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", positional: true, required: true, }, @@ -618,7 +610,8 @@ const commands = { { name: "secret" as const, short: "s", - description: "Secret in the format KEY=VALUE (will be encrypted server-side, can be used multiple times)", + description: + "Secret in the format KEY=VALUE (will be encrypted server-side, can be used multiple times)", }, { name: "secrets-file" as const, @@ -742,7 +735,7 @@ const commands = { name: "token" as const, description: "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, + default: process.env.HF_TOKEN, }, ] as const, }, @@ -764,7 +757,7 @@ const commands = { name: "token" as const, description: "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, + default: process.env.HF_TOKEN, }, ] as const, }, @@ -820,13 +813,11 @@ async function run() { if (helpArgs.length > 2) { const nestedSubCmdName = helpArgs[2]; if (nestedSubCmdName in subCmdDef.subcommands) { - console.log( - detailedUsageForNestedSubcommand(cmdName, subCmdName, nestedSubCmdName) - ); + console.log(detailedUsageForNestedSubcommand(cmdName, subCmdName, nestedSubCmdName)); break; } else { console.error( - `Error: Unknown subcommand '${nestedSubCmdName}' for command '${cmdName} ${subCmdName}'.` + `Error: Unknown subcommand '${nestedSubCmdName}' for command '${cmdName} ${subCmdName}'.`, ); console.log(listNestedSubcommands(cmdName, subCmdName, subCmdDef)); process.exitCode = 1; @@ -1070,9 +1061,7 @@ async function run() { // This is a nested command group (scheduled) const nestedSubCmdName = cliArgs[1]; if (nestedSubCmdName && nestedSubCmdName in subCmd.subcommands) { - console.log( - detailedUsageForNestedSubcommand("jobs", currentSubCommandName, nestedSubCmdName as string) - ); + console.log(detailedUsageForNestedSubcommand("jobs", currentSubCommandName, nestedSubCmdName as string)); } else { console.log(listNestedSubcommands("jobs", currentSubCommandName, subCmd)); } @@ -1096,7 +1085,6 @@ async function run() { const hubUrl = process.env.HF_ENDPOINT ?? HUB_URL; const token = process.env.HF_TOKEN; - switch (currentSubCommandName) { case "run": { const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs run"); @@ -1161,7 +1149,9 @@ async function run() { } // Filter by status if not --all - const filteredJobs = parsedArgs.all ? jobs : jobs.filter((j) => j.status === "running" || j.status === "pending"); + const filteredJobs = parsedArgs.all + ? jobs + : jobs.filter((j) => j.status === "running" || j.status === "pending"); console.log(`\n${filteredJobs.length} job(s):\n`); for (const job of filteredJobs) { @@ -1247,7 +1237,7 @@ async function run() { "RAM".padEnd(8) + "ACCELERATOR".padEnd(17) + "COST/MIN".padEnd(9) + - "COST/HOUR" + "COST/HOUR", ); console.log( "-".repeat(15) + @@ -1262,7 +1252,7 @@ async function run() { " " + "-".repeat(8) + " " + - "-".repeat(9) + "-".repeat(9), ); for (const hw of hardware) { @@ -1279,7 +1269,7 @@ async function run() { hw.ram.padEnd(8) + acceleratorStr.padEnd(17) + costPerMin.padEnd(9) + - costPerHour + costPerHour, ); } break; @@ -1308,7 +1298,11 @@ async function run() { break; } - const nestedParsedArgs = advParseArgs(cliArgs.slice(1), nestedSubCmdDef.args, `jobs scheduled ${scheduledSubCmdName}`); + const nestedParsedArgs = advParseArgs( + cliArgs.slice(1), + nestedSubCmdDef.args, + `jobs scheduled ${scheduledSubCmdName}`, + ); const namespace = await getNamespace(nestedParsedArgs.namespace); switch (scheduledSubCmdName) { @@ -1442,7 +1436,7 @@ async function run() { console.log(`Status: ${job.status}`); } else { console.log( - `Scheduled job ${nestedParsedArgs.scheduledJobId} could not be triggered: another instance is already running.` + `Scheduled job ${nestedParsedArgs.scheduledJobId} could not be triggered: another instance is already running.`, ); console.log(`Set 'concurrency' to allow multiple instances to run concurrently.`); } @@ -1536,7 +1530,9 @@ function parseTimeout(timeoutStr?: string): number | null | undefined { if (!timeoutStr) return undefined; const match = timeoutStr.match(/^(\d+(?:\.\d+)?)\s*([smhd])?$/i); if (!match) { - throw new Error(`Invalid timeout format: ${timeoutStr}. Use format like '2h', '90m', '1.5h', or a number in seconds.`); + throw new Error( + `Invalid timeout format: ${timeoutStr}. Use format like '2h', '90m', '1.5h', or a number in seconds.`, + ); } const value = parseFloat(match[1]); const unit = (match[2] || "s").toLowerCase(); @@ -1556,7 +1552,7 @@ function parseKeyValuePairs(values?: string | string[]): Record } result[key] = valueParts.join("="); } - return result; + return result; } // Helper to load env file @@ -1579,7 +1575,6 @@ function loadEnvFile(filePath: string): Record { } } - function _detailedUsage(args: readonly ArgDef[], usageLine: string, commandDescription?: string): string { let ret = `usage: hfjs ${usageLine}\n`; if (commandDescription) { @@ -1652,7 +1647,7 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command function listNestedSubcommands( commandName: TopLevelCommandName, subCommandName: string, - commandGroup: CommandGroup + commandGroup: CommandGroup, ): string { let ret = `usage: hfjs ${commandName} ${subCommandName} [options]\n\n`; ret += `${commandGroup.description}\n\n`; @@ -1667,7 +1662,7 @@ function listNestedSubcommands( function detailedUsageForNestedSubcommand( commandName: TopLevelCommandName, subCommandName: string, - nestedSubCommandName: string + nestedSubCommandName: string, ): string { const commandGroup = commands[commandName]; if (!("subcommands" in commandGroup)) { @@ -1684,7 +1679,7 @@ function detailedUsageForNestedSubcommand( return _detailedUsage( nestedSubCommandDef.args, usage(commandName, `${subCommandName} ${nestedSubCommandName}`), - nestedSubCommandDef.description + nestedSubCommandDef.description, ); } diff --git a/packages/hub/src/lib/jobs.ts b/packages/hub/src/lib/jobs.ts index eba4e7e4a9..16d4d6d65a 100644 --- a/packages/hub/src/lib/jobs.ts +++ b/packages/hub/src/lib/jobs.ts @@ -48,7 +48,7 @@ export async function listJobs( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -87,7 +87,7 @@ export async function getJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -101,7 +101,7 @@ export async function getJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -126,7 +126,7 @@ export async function runJob( */ fetch?: typeof fetch; } & CreateJobOptions & - CredentialsParams + CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -207,7 +207,7 @@ export async function cancelJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -222,7 +222,7 @@ export async function cancelJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -250,7 +250,7 @@ export async function duplicateJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -265,7 +265,7 @@ export async function duplicateJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -294,7 +294,7 @@ export async function* streamJobLogs( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): AsyncGenerator { const accessToken = checkCredentials(params); if (!accessToken) { @@ -308,7 +308,7 @@ export async function* streamJobLogs( Accept: "text/event-stream", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -372,7 +372,7 @@ export async function* streamJobMetrics( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): AsyncGenerator { const accessToken = checkCredentials(params); if (!accessToken) { @@ -386,7 +386,7 @@ export async function* streamJobMetrics( Accept: "text/event-stream", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -450,7 +450,7 @@ export async function* streamJobEvents( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): AsyncGenerator { const accessToken = checkCredentials(params); if (!accessToken) { @@ -464,7 +464,7 @@ export async function* streamJobEvents( Accept: "text/event-stream", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -525,22 +525,19 @@ export async function listScheduledJobs( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { throw new Error("Authentication required. Please provide an access token."); } - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}`, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - } - ); + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); if (!response.ok) { throw await createApiError(response); @@ -567,7 +564,7 @@ export async function getScheduledJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -581,7 +578,7 @@ export async function getScheduledJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -606,7 +603,7 @@ export async function createScheduledJob( */ fetch?: typeof fetch; } & CreateScheduledJobOptions & - CredentialsParams + CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -691,7 +688,7 @@ export async function deleteScheduledJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -706,7 +703,7 @@ export async function deleteScheduledJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -732,7 +729,7 @@ export async function suspendScheduledJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -747,7 +744,7 @@ export async function suspendScheduledJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -773,7 +770,7 @@ export async function resumeScheduledJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -788,7 +785,7 @@ export async function resumeScheduledJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { @@ -815,7 +812,7 @@ export async function runScheduledJob( * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. */ fetch?: typeof fetch; - } & CredentialsParams + } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); if (!accessToken) { @@ -830,7 +827,7 @@ export async function runScheduledJob( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - } + }, ); if (!response.ok) { diff --git a/packages/hub/src/types/api/api-jobs.ts b/packages/hub/src/types/api/api-jobs.ts index 9c304b45ad..82c79a4460 100644 --- a/packages/hub/src/types/api/api-jobs.ts +++ b/packages/hub/src/types/api/api-jobs.ts @@ -17,14 +17,7 @@ export interface ApiJobHardware { unitLabel: string; } -export type JobStatus = - | "pending" - | "running" - | "succeeded" - | "failed" - | "cancelled" - | "cancelling" - | "queued"; +export type JobStatus = "pending" | "running" | "succeeded" | "failed" | "cancelled" | "cancelling" | "queued"; export interface ApiJob { id: string; diff --git a/packages/hub/src/types/public.ts b/packages/hub/src/types/public.ts index 334b466efc..d9cd7c3808 100644 --- a/packages/hub/src/types/public.ts +++ b/packages/hub/src/types/public.ts @@ -23,23 +23,42 @@ export interface Credentials { export type CredentialsParams = | { - accessToken?: undefined; - /** - * @deprecated Use `accessToken` instead - */ - credentials: Credentials; - } + accessToken?: undefined; + /** + * @deprecated Use `accessToken` instead + */ + credentials: Credentials; + } | { - accessToken: AccessToken; - /** - * @deprecated Use `accessToken` instead - */ - credentials?: undefined; - }; + accessToken: AccessToken; + /** + * @deprecated Use `accessToken` instead + */ + credentials?: undefined; + }; export type SpaceHardwareFlavor = - "cpu-basic" | "cpu-upgrade" | "cpu-performance" | "cpu-xl" | "sprx8" | "zero-a10g" | "inf2x6" | "t4-small" | - "t4-medium" | "l4x1" | "l4x4" | "l40sx1" | "l40sx4" | "l40sx8" | "a10g-small" | "a10g-large" | "a10g-largex2" | "a10g-largex4" | "a100-large" | "a100x4" | "a100x8"; + | "cpu-basic" + | "cpu-upgrade" + | "cpu-performance" + | "cpu-xl" + | "sprx8" + | "zero-a10g" + | "inf2x6" + | "t4-small" + | "t4-medium" + | "l4x1" + | "l4x4" + | "l40sx1" + | "l40sx4" + | "l40sx8" + | "a10g-small" + | "a10g-large" + | "a10g-largex2" + | "a10g-largex4" + | "a100-large" + | "a100x4" + | "a100x8"; export type SpaceSdk = "streamlit" | "gradio" | "docker" | "static"; From 797e25a3523b27f5ecfd9acd58fc05ac21761257 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 12:11:07 +0000 Subject: [PATCH 03/20] split into multiple files --- packages/hub/src/lib/jobs.ts | 842 ------------------ packages/hub/src/lib/jobs/cancel-job.ts | 48 + .../hub/src/lib/jobs/create-scheduled-job.ts | 87 ++ .../hub/src/lib/jobs/delete-scheduled-job.ts | 45 + packages/hub/src/lib/jobs/duplicate-job.ts | 48 + packages/hub/src/lib/jobs/get-job.ts | 47 + .../hub/src/lib/jobs/get-scheduled-job.ts | 47 + packages/hub/src/lib/jobs/index.ts | 16 + .../hub/src/lib/jobs/list-job-hardware.ts | 39 + packages/hub/src/lib/jobs/list-jobs.ts | 40 + .../hub/src/lib/jobs/list-scheduled-jobs.ts | 40 + .../hub/src/lib/jobs/resume-scheduled-job.ts | 45 + packages/hub/src/lib/jobs/run-job.ts | 83 ++ .../hub/src/lib/jobs/run-scheduled-job.ts | 53 ++ .../hub/src/lib/jobs/stream-job-events.ts | 82 ++ packages/hub/src/lib/jobs/stream-job-logs.ts | 82 ++ .../hub/src/lib/jobs/stream-job-metrics.ts | 82 ++ .../hub/src/lib/jobs/suspend-scheduled-job.ts | 45 + 18 files changed, 929 insertions(+), 842 deletions(-) delete mode 100644 packages/hub/src/lib/jobs.ts create mode 100644 packages/hub/src/lib/jobs/cancel-job.ts create mode 100644 packages/hub/src/lib/jobs/create-scheduled-job.ts create mode 100644 packages/hub/src/lib/jobs/delete-scheduled-job.ts create mode 100644 packages/hub/src/lib/jobs/duplicate-job.ts create mode 100644 packages/hub/src/lib/jobs/get-job.ts create mode 100644 packages/hub/src/lib/jobs/get-scheduled-job.ts create mode 100644 packages/hub/src/lib/jobs/index.ts create mode 100644 packages/hub/src/lib/jobs/list-job-hardware.ts create mode 100644 packages/hub/src/lib/jobs/list-jobs.ts create mode 100644 packages/hub/src/lib/jobs/list-scheduled-jobs.ts create mode 100644 packages/hub/src/lib/jobs/resume-scheduled-job.ts create mode 100644 packages/hub/src/lib/jobs/run-job.ts create mode 100644 packages/hub/src/lib/jobs/run-scheduled-job.ts create mode 100644 packages/hub/src/lib/jobs/stream-job-events.ts create mode 100644 packages/hub/src/lib/jobs/stream-job-logs.ts create mode 100644 packages/hub/src/lib/jobs/stream-job-metrics.ts create mode 100644 packages/hub/src/lib/jobs/suspend-scheduled-job.ts diff --git a/packages/hub/src/lib/jobs.ts b/packages/hub/src/lib/jobs.ts deleted file mode 100644 index 16d4d6d65a..0000000000 --- a/packages/hub/src/lib/jobs.ts +++ /dev/null @@ -1,842 +0,0 @@ -import { HUB_URL } from "../consts"; -import { createApiError } from "../error"; -import type { CredentialsParams } from "../types/public"; -import { checkCredentials } from "../utils/checkCredentials"; -import type { - ApiJob, - ApiJobHardware, - ApiScheduledJob, - CreateJobOptions, - CreateScheduledJobOptions, -} from "../types/api/api-jobs"; - -/** - * Get the list of available hardware for jobs. - * This endpoint is public and does not require authentication. - */ -export async function listJobHardware(params?: { - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; -}): Promise { - const response = await (params?.fetch || fetch)(`${params?.hubUrl || HUB_URL}/api/jobs/hardware`, { - headers: { - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * List jobs for a namespace (user or organization). - */ -export async function listJobs( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Get a specific job by ID. - */ -export async function getJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}`, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Run a new job. - */ -export async function runJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CreateJobOptions & - CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - if (!params.dockerImage && !params.spaceId) { - throw new Error("Either dockerImage or spaceId must be provided"); - } - - if (params.dockerImage && params.spaceId) { - throw new Error("Cannot provide both dockerImage and spaceId"); - } - - const body: Record = { - flavor: params.flavor, - }; - - if (params.dockerImage) { - body.dockerImage = params.dockerImage; - } - if (params.spaceId) { - body.spaceId = params.spaceId; - } - if (params.command) { - body.command = params.command; - } - if (params.arguments) { - body.arguments = params.arguments; - } - if (params.environment) { - body.environment = params.environment; - } - if (params.secrets) { - body.secrets = params.secrets; - } - if (params.arch) { - body.arch = params.arch; - } - if (params.timeoutSeconds !== undefined) { - body.timeoutSeconds = params.timeoutSeconds; - } - if (params.attempts !== undefined) { - body.attempts = params.attempts; - } - - const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Cancel a job. - */ -export async function cancelJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/cancel`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Duplicate a job (re-run with the same spec). - */ -export async function duplicateJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The job ID to duplicate - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/duplicate`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Stream job logs using Server-Sent Events (SSE). - * Returns an async iterable of log chunks. - */ -export async function* streamJobLogs( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): AsyncGenerator { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/logs`, - { - headers: { - Accept: "text/event-stream", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } - - if (!response.body) { - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - yield line.slice(6); - } - } - } - - // Process remaining buffer - if (buffer) { - const lines = buffer.split("\n"); - for (const line of lines) { - if (line.startsWith("data: ")) { - yield line.slice(6); - } - } - } - } finally { - reader.releaseLock(); - } -} - -/** - * Stream job metrics using Server-Sent Events (SSE). - * Returns an async iterable of metric chunks. - */ -export async function* streamJobMetrics( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): AsyncGenerator { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/metrics`, - { - headers: { - Accept: "text/event-stream", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } - - if (!response.body) { - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - yield line.slice(6); - } - } - } - - // Process remaining buffer - if (buffer) { - const lines = buffer.split("\n"); - for (const line of lines) { - if (line.startsWith("data: ")) { - yield line.slice(6); - } - } - } - } finally { - reader.releaseLock(); - } -} - -/** - * Stream job events using Server-Sent Events (SSE). - * Returns an async iterable of event chunks. - */ -export async function* streamJobEvents( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): AsyncGenerator { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/events`, - { - headers: { - Accept: "text/event-stream", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } - - if (!response.body) { - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - yield line.slice(6); - } - } - } - - // Process remaining buffer - if (buffer) { - const lines = buffer.split("\n"); - for (const line of lines) { - if (line.startsWith("data: ")) { - yield line.slice(6); - } - } - } - } finally { - reader.releaseLock(); - } -} - -// Scheduled Jobs API - -/** - * List scheduled jobs for a namespace. - */ -export async function listScheduledJobs( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}`, { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Get a specific scheduled job by ID. - */ -export async function getScheduledJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The scheduled job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Create a scheduled job. - */ -export async function createScheduledJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CreateScheduledJobOptions & - CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const { namespace, hubUrl, fetch: customFetch, ...rest } = params; - - if (!rest.jobSpec.dockerImage && !rest.jobSpec.spaceId) { - throw new Error("Either dockerImage or spaceId must be provided in jobSpec"); - } - - if (rest.jobSpec.dockerImage && rest.jobSpec.spaceId) { - throw new Error("Cannot provide both dockerImage and spaceId in jobSpec"); - } - - const body: Record = { - jobSpec: { - flavor: rest.jobSpec.flavor, - }, - schedule: rest.schedule, - suspend: rest.suspend ?? false, - concurrency: rest.concurrency ?? false, - }; - - if (rest.jobSpec.dockerImage) { - (body.jobSpec as Record).dockerImage = rest.jobSpec.dockerImage; - } - if (rest.jobSpec.spaceId) { - (body.jobSpec as Record).spaceId = rest.jobSpec.spaceId; - } - if (rest.jobSpec.command) { - (body.jobSpec as Record).command = rest.jobSpec.command; - } - if (rest.jobSpec.environment) { - (body.jobSpec as Record).environment = rest.jobSpec.environment; - } - if (rest.jobSpec.secrets) { - (body.jobSpec as Record).secrets = rest.jobSpec.secrets; - } - if (rest.jobSpec.arch) { - (body.jobSpec as Record).arch = rest.jobSpec.arch; - } - if (rest.jobSpec.timeoutSeconds !== undefined) { - (body.jobSpec as Record).timeoutSeconds = rest.jobSpec.timeoutSeconds; - } - if (rest.jobSpec.attempts !== undefined) { - (body.jobSpec as Record).attempts = rest.jobSpec.attempts; - } - - const response = await (customFetch || fetch)(`${hubUrl || HUB_URL}/api/scheduled-jobs/${namespace}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw await createApiError(response); - } - - return await response.json(); -} - -/** - * Delete a scheduled job. - */ -export async function deleteScheduledJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The scheduled job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, - { - method: "DELETE", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } -} - -/** - * Suspend (pause) a scheduled job. - */ -export async function suspendScheduledJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The scheduled job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/suspend`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } -} - -/** - * Resume a scheduled job. - */ -export async function resumeScheduledJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The scheduled job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/resume`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - throw await createApiError(response); - } -} - -/** - * Trigger a scheduled job to run immediately. - * Returns the job that was triggered, or null if another instance is already running. - */ -export async function runScheduledJob( - params: { - /** - * The namespace (username or organization name) - */ - namespace: string; - /** - * The scheduled job ID - */ - jobId: string; - hubUrl?: string; - /** - * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. - */ - fetch?: typeof fetch; - } & CredentialsParams, -): Promise { - const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } - - const response = await (params.fetch || fetch)( - `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/run`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - if (response.status === 409) { - // Another instance is already running - return null; - } - throw await createApiError(response); - } - - return await response.json(); -} diff --git a/packages/hub/src/lib/jobs/cancel-job.ts b/packages/hub/src/lib/jobs/cancel-job.ts new file mode 100644 index 0000000000..47d03ea082 --- /dev/null +++ b/packages/hub/src/lib/jobs/cancel-job.ts @@ -0,0 +1,48 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Cancel a job. + */ +export async function cancelJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/cancel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/create-scheduled-job.ts b/packages/hub/src/lib/jobs/create-scheduled-job.ts new file mode 100644 index 0000000000..e696782115 --- /dev/null +++ b/packages/hub/src/lib/jobs/create-scheduled-job.ts @@ -0,0 +1,87 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiScheduledJob, CreateScheduledJobOptions } from "../../types/api/api-jobs"; + +/** + * Create a scheduled job. + */ +export async function createScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CreateScheduledJobOptions & + CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const { namespace, hubUrl, fetch: customFetch, ...rest } = params; + + if (!rest.jobSpec.dockerImage && !rest.jobSpec.spaceId) { + throw new Error("Either dockerImage or spaceId must be provided in jobSpec"); + } + + if (rest.jobSpec.dockerImage && rest.jobSpec.spaceId) { + throw new Error("Cannot provide both dockerImage and spaceId in jobSpec"); + } + + const body: Record = { + jobSpec: { + flavor: rest.jobSpec.flavor, + }, + schedule: rest.schedule, + suspend: rest.suspend ?? false, + concurrency: rest.concurrency ?? false, + }; + + if (rest.jobSpec.dockerImage) { + (body.jobSpec as Record).dockerImage = rest.jobSpec.dockerImage; + } + if (rest.jobSpec.spaceId) { + (body.jobSpec as Record).spaceId = rest.jobSpec.spaceId; + } + if (rest.jobSpec.command) { + (body.jobSpec as Record).command = rest.jobSpec.command; + } + if (rest.jobSpec.environment) { + (body.jobSpec as Record).environment = rest.jobSpec.environment; + } + if (rest.jobSpec.secrets) { + (body.jobSpec as Record).secrets = rest.jobSpec.secrets; + } + if (rest.jobSpec.arch) { + (body.jobSpec as Record).arch = rest.jobSpec.arch; + } + if (rest.jobSpec.timeoutSeconds !== undefined) { + (body.jobSpec as Record).timeoutSeconds = rest.jobSpec.timeoutSeconds; + } + if (rest.jobSpec.attempts !== undefined) { + (body.jobSpec as Record).attempts = rest.jobSpec.attempts; + } + + const response = await (customFetch || fetch)(`${hubUrl || HUB_URL}/api/scheduled-jobs/${namespace}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/delete-scheduled-job.ts b/packages/hub/src/lib/jobs/delete-scheduled-job.ts new file mode 100644 index 0000000000..941376d431 --- /dev/null +++ b/packages/hub/src/lib/jobs/delete-scheduled-job.ts @@ -0,0 +1,45 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Delete a scheduled job. + */ +export async function deleteScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } +} diff --git a/packages/hub/src/lib/jobs/duplicate-job.ts b/packages/hub/src/lib/jobs/duplicate-job.ts new file mode 100644 index 0000000000..34aa9d6faa --- /dev/null +++ b/packages/hub/src/lib/jobs/duplicate-job.ts @@ -0,0 +1,48 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Duplicate a job (re-run with the same spec). + */ +export async function duplicateJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID to duplicate + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/duplicate`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/get-job.ts b/packages/hub/src/lib/jobs/get-job.ts new file mode 100644 index 0000000000..cfaa423173 --- /dev/null +++ b/packages/hub/src/lib/jobs/get-job.ts @@ -0,0 +1,47 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Get a specific job by ID. + */ +export async function getJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/get-scheduled-job.ts b/packages/hub/src/lib/jobs/get-scheduled-job.ts new file mode 100644 index 0000000000..261f138559 --- /dev/null +++ b/packages/hub/src/lib/jobs/get-scheduled-job.ts @@ -0,0 +1,47 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiScheduledJob } from "../../types/api/api-jobs"; + +/** + * Get a specific scheduled job by ID. + */ +export async function getScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/index.ts b/packages/hub/src/lib/jobs/index.ts new file mode 100644 index 0000000000..c7f9aad3d0 --- /dev/null +++ b/packages/hub/src/lib/jobs/index.ts @@ -0,0 +1,16 @@ +export * from "./cancel-job"; +export * from "./create-scheduled-job"; +export * from "./delete-scheduled-job"; +export * from "./duplicate-job"; +export * from "./get-job"; +export * from "./get-scheduled-job"; +export * from "./list-job-hardware"; +export * from "./list-jobs"; +export * from "./list-scheduled-jobs"; +export * from "./resume-scheduled-job"; +export * from "./run-job"; +export * from "./run-scheduled-job"; +export * from "./stream-job-events"; +export * from "./stream-job-logs"; +export * from "./stream-job-metrics"; +export * from "./suspend-scheduled-job"; diff --git a/packages/hub/src/lib/jobs/list-job-hardware.ts b/packages/hub/src/lib/jobs/list-job-hardware.ts new file mode 100644 index 0000000000..ffe5032c88 --- /dev/null +++ b/packages/hub/src/lib/jobs/list-job-hardware.ts @@ -0,0 +1,39 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJobHardware } from "../../types/api/api-jobs"; + +/** + * Get the list of available hardware for jobs. + * This endpoint is public and does not require authentication, but authentication is optional. + */ +export async function listJobHardware( + params?: { + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & Partial, +): Promise { + const accessToken = checkCredentials(params ?? {}); + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + const response = await (params?.fetch || fetch)(`${params?.hubUrl || HUB_URL}/api/jobs/hardware`, { + headers, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/list-jobs.ts b/packages/hub/src/lib/jobs/list-jobs.ts new file mode 100644 index 0000000000..c132c9f9ea --- /dev/null +++ b/packages/hub/src/lib/jobs/list-jobs.ts @@ -0,0 +1,40 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * List jobs for a namespace (user or organization). + */ +export async function listJobs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/list-scheduled-jobs.ts b/packages/hub/src/lib/jobs/list-scheduled-jobs.ts new file mode 100644 index 0000000000..9c2b411734 --- /dev/null +++ b/packages/hub/src/lib/jobs/list-scheduled-jobs.ts @@ -0,0 +1,40 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiScheduledJob } from "../../types/api/api-jobs"; + +/** + * List scheduled jobs for a namespace. + */ +export async function listScheduledJobs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/resume-scheduled-job.ts b/packages/hub/src/lib/jobs/resume-scheduled-job.ts new file mode 100644 index 0000000000..a00dfafead --- /dev/null +++ b/packages/hub/src/lib/jobs/resume-scheduled-job.ts @@ -0,0 +1,45 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Resume a scheduled job. + */ +export async function resumeScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/resume`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } +} diff --git a/packages/hub/src/lib/jobs/run-job.ts b/packages/hub/src/lib/jobs/run-job.ts new file mode 100644 index 0000000000..b710ac3a87 --- /dev/null +++ b/packages/hub/src/lib/jobs/run-job.ts @@ -0,0 +1,83 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob, CreateJobOptions } from "../../types/api/api-jobs"; + +/** + * Run a new job. + */ +export async function runJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CreateJobOptions & + CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + if (!params.dockerImage && !params.spaceId) { + throw new Error("Either dockerImage or spaceId must be provided"); + } + + if (params.dockerImage && params.spaceId) { + throw new Error("Cannot provide both dockerImage and spaceId"); + } + + const body: Record = { + flavor: params.flavor, + }; + + if (params.dockerImage) { + body.dockerImage = params.dockerImage; + } + if (params.spaceId) { + body.spaceId = params.spaceId; + } + if (params.command) { + body.command = params.command; + } + if (params.arguments) { + body.arguments = params.arguments; + } + if (params.environment) { + body.environment = params.environment; + } + if (params.secrets) { + body.secrets = params.secrets; + } + if (params.arch) { + body.arch = params.arch; + } + if (params.timeoutSeconds !== undefined) { + body.timeoutSeconds = params.timeoutSeconds; + } + if (params.attempts !== undefined) { + body.attempts = params.attempts; + } + + const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/run-scheduled-job.ts b/packages/hub/src/lib/jobs/run-scheduled-job.ts new file mode 100644 index 0000000000..f60501c20f --- /dev/null +++ b/packages/hub/src/lib/jobs/run-scheduled-job.ts @@ -0,0 +1,53 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; +import type { ApiJob } from "../../types/api/api-jobs"; + +/** + * Trigger a scheduled job to run immediately. + * Returns the job that was triggered, or null if another instance is already running. + */ +export async function runScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/run`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + if (response.status === 409) { + // Another instance is already running + return null; + } + throw await createApiError(response); + } + + return await response.json(); +} diff --git a/packages/hub/src/lib/jobs/stream-job-events.ts b/packages/hub/src/lib/jobs/stream-job-events.ts new file mode 100644 index 0000000000..58859a3484 --- /dev/null +++ b/packages/hub/src/lib/jobs/stream-job-events.ts @@ -0,0 +1,82 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Stream job events using Server-Sent Events (SSE). + * Returns an async iterable of event chunks. + */ +export async function* streamJobEvents( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): AsyncGenerator { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/events`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/hub/src/lib/jobs/stream-job-logs.ts b/packages/hub/src/lib/jobs/stream-job-logs.ts new file mode 100644 index 0000000000..4e2afdb39d --- /dev/null +++ b/packages/hub/src/lib/jobs/stream-job-logs.ts @@ -0,0 +1,82 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Stream job logs using Server-Sent Events (SSE). + * Returns an async iterable of log chunks. + */ +export async function* streamJobLogs( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): AsyncGenerator { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/logs`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/hub/src/lib/jobs/stream-job-metrics.ts b/packages/hub/src/lib/jobs/stream-job-metrics.ts new file mode 100644 index 0000000000..bd2905fd1c --- /dev/null +++ b/packages/hub/src/lib/jobs/stream-job-metrics.ts @@ -0,0 +1,82 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Stream job metrics using Server-Sent Events (SSE). + * Returns an async iterable of metric chunks. + */ +export async function* streamJobMetrics( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): AsyncGenerator { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/metrics`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } + + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + + // Process remaining buffer + if (buffer) { + const lines = buffer.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + yield line.slice(6); + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/hub/src/lib/jobs/suspend-scheduled-job.ts b/packages/hub/src/lib/jobs/suspend-scheduled-job.ts new file mode 100644 index 0000000000..781237d52a --- /dev/null +++ b/packages/hub/src/lib/jobs/suspend-scheduled-job.ts @@ -0,0 +1,45 @@ +import { HUB_URL } from "../../consts"; +import { createApiError } from "../../error"; +import type { CredentialsParams } from "../../types/public"; +import { checkCredentials } from "../../utils/checkCredentials"; + +/** + * Suspend (pause) a scheduled job. + */ +export async function suspendScheduledJob( + params: { + /** + * The namespace (username or organization name) + */ + namespace: string; + /** + * The scheduled job ID + */ + jobId: string; + hubUrl?: string; + /** + * Custom fetch function to use instead of the default one, for example to use a proxy or edit headers. + */ + fetch?: typeof fetch; + } & CredentialsParams, +): Promise { + const accessToken = checkCredentials(params); + if (!accessToken) { + throw new Error("Authentication required. Please provide an access token."); + } + + const response = await (params.fetch || fetch)( + `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/suspend`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw await createApiError(response); + } +} From aed5b7e407885522c0fe8de6a11dfde2c88fae58 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 12:56:08 +0000 Subject: [PATCH 04/20] simplify --- packages/hub/src/lib/jobs/cancel-job.ts | 5 +---- packages/hub/src/lib/jobs/create-scheduled-job.ts | 5 +---- packages/hub/src/lib/jobs/delete-scheduled-job.ts | 5 +---- packages/hub/src/lib/jobs/duplicate-job.ts | 5 +---- packages/hub/src/lib/jobs/get-job.ts | 6 +----- packages/hub/src/lib/jobs/get-scheduled-job.ts | 6 +----- packages/hub/src/lib/jobs/list-job-hardware.ts | 4 +--- packages/hub/src/lib/jobs/list-jobs.ts | 6 +----- packages/hub/src/lib/jobs/list-scheduled-jobs.ts | 6 +----- packages/hub/src/lib/jobs/resume-scheduled-job.ts | 6 +----- packages/hub/src/lib/jobs/run-job.ts | 5 +---- packages/hub/src/lib/jobs/run-scheduled-job.ts | 5 +---- packages/hub/src/lib/jobs/stream-job-events.ts | 5 +---- packages/hub/src/lib/jobs/stream-job-logs.ts | 5 +---- packages/hub/src/lib/jobs/stream-job-metrics.ts | 5 +---- packages/hub/src/lib/jobs/suspend-scheduled-job.ts | 5 +---- 16 files changed, 16 insertions(+), 68 deletions(-) diff --git a/packages/hub/src/lib/jobs/cancel-job.ts b/packages/hub/src/lib/jobs/cancel-job.ts index 47d03ea082..52a6a80378 100644 --- a/packages/hub/src/lib/jobs/cancel-job.ts +++ b/packages/hub/src/lib/jobs/cancel-job.ts @@ -25,9 +25,6 @@ export async function cancelJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/cancel`, @@ -35,7 +32,7 @@ export async function cancelJob( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/create-scheduled-job.ts b/packages/hub/src/lib/jobs/create-scheduled-job.ts index e696782115..8b76b8efd0 100644 --- a/packages/hub/src/lib/jobs/create-scheduled-job.ts +++ b/packages/hub/src/lib/jobs/create-scheduled-job.ts @@ -22,9 +22,6 @@ export async function createScheduledJob( CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const { namespace, hubUrl, fetch: customFetch, ...rest } = params; @@ -74,7 +71,7 @@ export async function createScheduledJob( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, body: JSON.stringify(body), }); diff --git a/packages/hub/src/lib/jobs/delete-scheduled-job.ts b/packages/hub/src/lib/jobs/delete-scheduled-job.ts index 941376d431..03bfee16fd 100644 --- a/packages/hub/src/lib/jobs/delete-scheduled-job.ts +++ b/packages/hub/src/lib/jobs/delete-scheduled-job.ts @@ -24,9 +24,6 @@ export async function deleteScheduledJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, @@ -34,7 +31,7 @@ export async function deleteScheduledJob( method: "DELETE", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/duplicate-job.ts b/packages/hub/src/lib/jobs/duplicate-job.ts index 34aa9d6faa..9e5e444e9d 100644 --- a/packages/hub/src/lib/jobs/duplicate-job.ts +++ b/packages/hub/src/lib/jobs/duplicate-job.ts @@ -25,9 +25,6 @@ export async function duplicateJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/duplicate`, @@ -35,7 +32,7 @@ export async function duplicateJob( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/get-job.ts b/packages/hub/src/lib/jobs/get-job.ts index cfaa423173..8a027516ad 100644 --- a/packages/hub/src/lib/jobs/get-job.ts +++ b/packages/hub/src/lib/jobs/get-job.ts @@ -25,16 +25,12 @@ export async function getJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}`, { headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/get-scheduled-job.ts b/packages/hub/src/lib/jobs/get-scheduled-job.ts index 261f138559..de8d152443 100644 --- a/packages/hub/src/lib/jobs/get-scheduled-job.ts +++ b/packages/hub/src/lib/jobs/get-scheduled-job.ts @@ -25,16 +25,12 @@ export async function getScheduledJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}`, { headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/list-job-hardware.ts b/packages/hub/src/lib/jobs/list-job-hardware.ts index ffe5032c88..a6d129a2fc 100644 --- a/packages/hub/src/lib/jobs/list-job-hardware.ts +++ b/packages/hub/src/lib/jobs/list-job-hardware.ts @@ -19,9 +19,7 @@ export async function listJobHardware( ): Promise { const accessToken = checkCredentials(params ?? {}); - const headers: Record = { - "Content-Type": "application/json", - }; + const headers: Record = {}; if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; diff --git a/packages/hub/src/lib/jobs/list-jobs.ts b/packages/hub/src/lib/jobs/list-jobs.ts index c132c9f9ea..7b214b24f0 100644 --- a/packages/hub/src/lib/jobs/list-jobs.ts +++ b/packages/hub/src/lib/jobs/list-jobs.ts @@ -21,14 +21,10 @@ export async function listJobs( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}`, { headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }); diff --git a/packages/hub/src/lib/jobs/list-scheduled-jobs.ts b/packages/hub/src/lib/jobs/list-scheduled-jobs.ts index 9c2b411734..4769386832 100644 --- a/packages/hub/src/lib/jobs/list-scheduled-jobs.ts +++ b/packages/hub/src/lib/jobs/list-scheduled-jobs.ts @@ -21,14 +21,10 @@ export async function listScheduledJobs( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)(`${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}`, { headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }); diff --git a/packages/hub/src/lib/jobs/resume-scheduled-job.ts b/packages/hub/src/lib/jobs/resume-scheduled-job.ts index a00dfafead..c7c0aa331b 100644 --- a/packages/hub/src/lib/jobs/resume-scheduled-job.ts +++ b/packages/hub/src/lib/jobs/resume-scheduled-job.ts @@ -24,17 +24,13 @@ export async function resumeScheduledJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/resume`, { method: "POST", headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/run-job.ts b/packages/hub/src/lib/jobs/run-job.ts index b710ac3a87..2924591f11 100644 --- a/packages/hub/src/lib/jobs/run-job.ts +++ b/packages/hub/src/lib/jobs/run-job.ts @@ -22,9 +22,6 @@ export async function runJob( CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } if (!params.dockerImage && !params.spaceId) { throw new Error("Either dockerImage or spaceId must be provided"); @@ -70,7 +67,7 @@ export async function runJob( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, body: JSON.stringify(body), }); diff --git a/packages/hub/src/lib/jobs/run-scheduled-job.ts b/packages/hub/src/lib/jobs/run-scheduled-job.ts index f60501c20f..3224830b90 100644 --- a/packages/hub/src/lib/jobs/run-scheduled-job.ts +++ b/packages/hub/src/lib/jobs/run-scheduled-job.ts @@ -26,9 +26,6 @@ export async function runScheduledJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/run`, @@ -36,7 +33,7 @@ export async function runScheduledJob( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/stream-job-events.ts b/packages/hub/src/lib/jobs/stream-job-events.ts index 58859a3484..8fd55f18eb 100644 --- a/packages/hub/src/lib/jobs/stream-job-events.ts +++ b/packages/hub/src/lib/jobs/stream-job-events.ts @@ -25,16 +25,13 @@ export async function* streamJobEvents( } & CredentialsParams, ): AsyncGenerator { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/events`, { headers: { Accept: "text/event-stream", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/stream-job-logs.ts b/packages/hub/src/lib/jobs/stream-job-logs.ts index 4e2afdb39d..d5d8d38664 100644 --- a/packages/hub/src/lib/jobs/stream-job-logs.ts +++ b/packages/hub/src/lib/jobs/stream-job-logs.ts @@ -25,16 +25,13 @@ export async function* streamJobLogs( } & CredentialsParams, ): AsyncGenerator { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/logs`, { headers: { Accept: "text/event-stream", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/stream-job-metrics.ts b/packages/hub/src/lib/jobs/stream-job-metrics.ts index bd2905fd1c..b0b2834c6b 100644 --- a/packages/hub/src/lib/jobs/stream-job-metrics.ts +++ b/packages/hub/src/lib/jobs/stream-job-metrics.ts @@ -25,16 +25,13 @@ export async function* streamJobMetrics( } & CredentialsParams, ): AsyncGenerator { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/jobs/${params.namespace}/${params.jobId}/metrics`, { headers: { Accept: "text/event-stream", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); diff --git a/packages/hub/src/lib/jobs/suspend-scheduled-job.ts b/packages/hub/src/lib/jobs/suspend-scheduled-job.ts index 781237d52a..dcf4a5e3dd 100644 --- a/packages/hub/src/lib/jobs/suspend-scheduled-job.ts +++ b/packages/hub/src/lib/jobs/suspend-scheduled-job.ts @@ -24,9 +24,6 @@ export async function suspendScheduledJob( } & CredentialsParams, ): Promise { const accessToken = checkCredentials(params); - if (!accessToken) { - throw new Error("Authentication required. Please provide an access token."); - } const response = await (params.fetch || fetch)( `${params.hubUrl || HUB_URL}/api/scheduled-jobs/${params.namespace}/${params.jobId}/suspend`, @@ -34,7 +31,7 @@ export async function suspendScheduledJob( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, }, ); From 4f2fa1a7ab6a083a80a869e7cc34b1d5fc520418 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 13:00:34 +0000 Subject: [PATCH 05/20] revert cli --- packages/hub/cli.ts | 980 +------------------------------------------- 1 file changed, 4 insertions(+), 976 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 8a1eaa0415..9559ed707b 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -2,33 +2,10 @@ import { parseArgs } from "node:util"; import { typedEntries } from "./src/utils/typedEntries"; -import { - createBranch, - createRepo, - deleteBranch, - deleteRepo, - repoExists, - uploadFilesWithProgress, - cancelJob, - createScheduledJob, - deleteScheduledJob, - duplicateJob, - getJob, - getScheduledJob, - listJobHardware, - listJobs, - listScheduledJobs, - resumeScheduledJob, - runJob, - runScheduledJob, - streamJobLogs, - suspendScheduledJob, - whoAmI, -} from "./src"; +import { createBranch, createRepo, deleteBranch, deleteRepo, repoExists, uploadFilesWithProgress } from "./src"; import { pathToFileURL } from "node:url"; import { stat } from "node:fs/promises"; import { basename, join } from "node:path"; -import { readFileSync } from "node:fs"; import { HUB_URL } from "./src/consts"; import { version } from "./package.json"; import type { CommitProgressEvent } from "./src/lib/commit"; @@ -174,7 +151,7 @@ interface SingleCommand { interface CommandGroup { description: string; - subcommands: Record; + subcommands: Record; } const commands = { @@ -343,428 +320,6 @@ const commands = { }, }, } satisfies CommandGroup, - jobs: { - description: "Manage jobs on the Hub", - subcommands: { - run: { - description: "Run a new job", - args: [ - { - name: "docker-image-or-space" as const, - description: "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", - positional: true, - required: true, - }, - { - name: "command" as const, - description: "The command to run (space-separated arguments)", - positional: true, - }, - { - name: "namespace" as const, - description: "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "flavor" as const, - description: "Hardware flavor to use (e.g., 'cpu-basic', 'a10g-small')", - enum: [ - "cpu-basic", - "cpu-upgrade", - "t4-small", - "t4-medium", - "l4x1", - "l4x4", - "a10g-small", - "a10g-large", - "a10g-largex2", - "a10g-largex4", - "a100-large", - "v5e-1x1", - "v5e-2x2", - "v5e-2x4", - ], - default: "cpu-basic", - }, - { - name: "arch" as const, - enum: ["amd64", "arm64"], - description: "Architecture (defaults to 'amd64')", - }, - { - name: "timeout" as const, - description: - "Timeout in seconds, or with unit (e.g., '2h', '90m', '1.5h'). Supports: s (seconds), m (minutes), h (hours), d (days)", - }, - { - name: "attempts" as const, - description: "Maximum number of attempts (defaults to 1)", - }, - { - name: "env" as const, - short: "e", - description: "Environment variable in the format KEY=VALUE (can be used multiple times)", - }, - { - name: "env-file" as const, - description: "Path to a .env file containing environment variables", - }, - { - name: "secret" as const, - short: "s", - description: "Secret in the format KEY=VALUE (will be encrypted server-side, can be used multiple times)", - }, - { - name: "secrets-file" as const, - description: "Path to a .env.secrets file containing secrets (will be encrypted server-side)", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - ps: { - description: "List jobs", - args: [ - { - name: "namespace" as const, - description: "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "all" as const, - short: "a", - boolean: true, - description: "List all jobs (not just running ones)", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - inspect: { - description: "Inspect the status of a job", - args: [ - { - name: "job-id" as const, - description: "The job ID to inspect", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - logs: { - description: "View logs from a job", - args: [ - { - name: "job-id" as const, - description: "The job ID to view logs for", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - cancel: { - description: "Cancel a job", - args: [ - { - name: "job-id" as const, - description: "The job ID to cancel", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - duplicate: { - description: "Duplicate a job (re-run with the same spec)", - args: [ - { - name: "job-id" as const, - description: "The job ID to duplicate", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - hardware: { - description: "List available hardware for jobs", - args: [] as const, - }, - scheduled: { - description: "Manage scheduled jobs", - subcommands: { - run: { - description: "Create and run a scheduled job", - args: [ - { - name: "schedule" as const, - description: - "CRON schedule expression (e.g., '0 9 * * 1' for 9 AM every Monday) or shortcuts like '@hourly', '@daily'", - positional: true, - required: true, - }, - { - name: "docker-image-or-space" as const, - description: "The Docker image (e.g., 'python:3.12') or Space ID (e.g., 'username/space-name') to run", - positional: true, - required: true, - }, - { - name: "command" as const, - description: "The command to run (space-separated arguments)", - positional: true, - }, - { - name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "flavor" as const, - description: "Hardware flavor to use (e.g., 'cpu-basic', 'a10g-small')", - enum: [ - "cpu-basic", - "cpu-upgrade", - "t4-small", - "t4-medium", - "l4x1", - "l4x4", - "a10g-small", - "a10g-large", - "a10g-largex2", - "a10g-largex4", - "a100-large", - "v5e-1x1", - "v5e-2x2", - "v5e-2x4", - ], - default: "cpu-basic", - }, - { - name: "arch" as const, - enum: ["amd64", "arm64"], - description: "Architecture (defaults to 'amd64')", - }, - { - name: "timeout" as const, - description: - "Timeout in seconds, or with unit (e.g., '2h', '90m', '1.5h'). Supports: s (seconds), m (minutes), h (hours), d (days)", - }, - { - name: "attempts" as const, - description: "Maximum number of attempts (defaults to 1)", - }, - { - name: "env" as const, - short: "e", - description: "Environment variable in the format KEY=VALUE (can be used multiple times)", - }, - { - name: "env-file" as const, - description: "Path to a .env file containing environment variables", - }, - { - name: "secret" as const, - short: "s", - description: - "Secret in the format KEY=VALUE (will be encrypted server-side, can be used multiple times)", - }, - { - name: "secrets-file" as const, - description: "Path to a .env.secrets file containing secrets (will be encrypted server-side)", - }, - { - name: "suspend" as const, - boolean: true, - description: "Create the scheduled job in suspended (paused) state", - }, - { - name: "concurrency" as const, - boolean: true, - description: "Allow multiple instances of this job to run concurrently", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - ps: { - description: "List scheduled jobs", - args: [ - { - name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - inspect: { - description: "Inspect the status of a scheduled job", - args: [ - { - name: "scheduled-job-id" as const, - description: "The scheduled job ID to inspect", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - suspend: { - description: "Suspend (pause) a scheduled job", - args: [ - { - name: "scheduled-job-id" as const, - description: "The scheduled job ID to suspend", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - resume: { - description: "Resume a scheduled job", - args: [ - { - name: "scheduled-job-id" as const, - description: "The scheduled job ID to resume", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - delete: { - description: "Delete a scheduled job", - args: [ - { - name: "scheduled-job-id" as const, - description: "The scheduled job ID to delete", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - trigger: { - description: "Trigger a scheduled job to run immediately", - args: [ - { - name: "scheduled-job-id" as const, - description: "The scheduled job ID to trigger", - positional: true, - required: true, - }, - { - name: "namespace" as const, - description: - "The namespace (username or organization name). Defaults to the current user if not provided.", - }, - { - name: "token" as const, - description: - "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", - default: process.env.HF_TOKEN, - }, - ] as const, - }, - }, - }, - }, - } satisfies CommandGroup, version: { description: "Print the version of the CLI", args: [] as const, @@ -807,30 +362,8 @@ async function run() { subCmdName in cmdDef.subcommands && cmdDef.subcommands[subCmdName as keyof typeof cmdDef.subcommands] ) { - const subCmdDef = cmdDef.subcommands[subCmdName as keyof typeof cmdDef.subcommands]; - // Check if this is a nested command group - if ("subcommands" in subCmdDef) { - if (helpArgs.length > 2) { - const nestedSubCmdName = helpArgs[2]; - if (nestedSubCmdName in subCmdDef.subcommands) { - console.log(detailedUsageForNestedSubcommand(cmdName, subCmdName, nestedSubCmdName)); - break; - } else { - console.error( - `Error: Unknown subcommand '${nestedSubCmdName}' for command '${cmdName} ${subCmdName}'.`, - ); - console.log(listNestedSubcommands(cmdName, subCmdName, subCmdDef)); - process.exitCode = 1; - break; - } - } else { - console.log(listNestedSubcommands(cmdName, subCmdName, subCmdDef)); - break; - } - } else { - console.log(detailedUsageForSubcommand(cmdName, subCmdName as keyof typeof cmdDef.subcommands)); - break; - } + console.log(detailedUsageForSubcommand(cmdName, subCmdName as keyof typeof cmdDef.subcommands)); + break; } else { console.error(`Error: Unknown subcommand '${subCmdName}' for command '${cmdName}'.`); console.log(listSubcommands(cmdName, cmdDef)); @@ -1050,413 +583,6 @@ async function run() { } break; } - case "jobs": { - const jobsCommandGroup = commands.jobs; - const currentSubCommandName = subCommandName as keyof typeof jobsCommandGroup.subcommands | undefined; - - if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { - if (currentSubCommandName && jobsCommandGroup.subcommands[currentSubCommandName]) { - const subCmd = jobsCommandGroup.subcommands[currentSubCommandName]; - if ("subcommands" in subCmd) { - // This is a nested command group (scheduled) - const nestedSubCmdName = cliArgs[1]; - if (nestedSubCmdName && nestedSubCmdName in subCmd.subcommands) { - console.log(detailedUsageForNestedSubcommand("jobs", currentSubCommandName, nestedSubCmdName as string)); - } else { - console.log(listNestedSubcommands("jobs", currentSubCommandName, subCmd)); - } - } else { - console.log(detailedUsageForSubcommand("jobs", currentSubCommandName)); - } - } else { - console.log(listSubcommands("jobs", jobsCommandGroup)); - } - break; - } - - if (!currentSubCommandName || !jobsCommandGroup.subcommands[currentSubCommandName]) { - console.error(`Error: Missing or invalid subcommand for 'jobs'.`); - console.log(listSubcommands("jobs", jobsCommandGroup)); - process.exitCode = 1; - break; - } - - const subCmdDef = jobsCommandGroup.subcommands[currentSubCommandName]; - const hubUrl = process.env.HF_ENDPOINT ?? HUB_URL; - const token = process.env.HF_TOKEN; - - switch (currentSubCommandName) { - case "run": { - const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs run"); - const namespace = await getNamespace(parsedArgs.namespace); - const dockerImageOrSpace = parsedArgs.dockerImageOrSpace; - const command = parsedArgs.command ? parsedArgs.command.split(/\s+/) : undefined; - - // Determine if it's a docker image or space - const isSpace = dockerImageOrSpace.includes("/") && !dockerImageOrSpace.includes(":"); - const dockerImage = isSpace ? undefined : dockerImageOrSpace; - const spaceId = isSpace ? dockerImageOrSpace : undefined; - - // Parse environment variables - let environment: Record = {}; - if (parsedArgs.env) { - environment = { ...environment, ...parseKeyValuePairs(parsedArgs.env) }; - } - if (parsedArgs.envFile) { - environment = { ...environment, ...loadEnvFile(parsedArgs.envFile) }; - } - - // Parse secrets - let secrets: Record = {}; - if (parsedArgs.secret) { - secrets = { ...secrets, ...parseKeyValuePairs(parsedArgs.secret) }; - } - if (parsedArgs.secretsFile) { - secrets = { ...secrets, ...loadEnvFile(parsedArgs.secretsFile) }; - } - - const job = await runJob({ - namespace, - dockerImage, - spaceId, - command, - flavor: parsedArgs.flavor || "cpu-basic", - arch: parsedArgs.arch, - timeoutSeconds: parseTimeout(parsedArgs.timeout), - attempts: parsedArgs.attempts ? parseInt(parsedArgs.attempts, 10) : undefined, - environment: Object.keys(environment).length > 0 ? environment : undefined, - secrets: Object.keys(secrets).length > 0 ? secrets : undefined, - accessToken: parsedArgs.token || token, - hubUrl, - }); - - console.log(`Job started: ${job.id}`); - console.log(`Status: ${job.status}`); - break; - } - case "ps": { - const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs ps"); - const namespace = await getNamespace(parsedArgs.namespace); - const jobs = await listJobs({ - namespace, - accessToken: parsedArgs.token || token, - hubUrl, - }); - - if (jobs.length === 0) { - console.log("No jobs found."); - break; - } - - // Filter by status if not --all - const filteredJobs = parsedArgs.all - ? jobs - : jobs.filter((j) => j.status === "running" || j.status === "pending"); - - console.log(`\n${filteredJobs.length} job(s):\n`); - for (const job of filteredJobs) { - console.log(` ${job.id} ${job.status.padEnd(12)} ${job.dockerImage || job.spaceId || "N/A"}`); - if (job.command) { - console.log(` Command: ${job.command.join(" ")}`); - } - if (job.createdAt) { - console.log(` Created: ${new Date(job.createdAt).toLocaleString()}`); - } - console.log(); - } - break; - } - case "inspect": { - const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs inspect"); - const namespace = await getNamespace(parsedArgs.namespace); - const job = await getJob({ - namespace, - jobId: parsedArgs.jobId, - accessToken: parsedArgs.token || token, - hubUrl, - }); - - console.log(JSON.stringify(job, null, 2)); - break; - } - case "logs": { - const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs logs"); - const namespace = await getNamespace(parsedArgs.namespace); - - try { - for await (const chunk of streamJobLogs({ - namespace, - jobId: parsedArgs.jobId, - accessToken: parsedArgs.token || token, - hubUrl, - })) { - process.stdout.write(chunk); - } - } catch (error) { - console.error("Error streaming logs:", error); - process.exitCode = 1; - } - break; - } - case "cancel": { - const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs cancel"); - const namespace = await getNamespace(parsedArgs.namespace); - const job = await cancelJob({ - namespace, - jobId: parsedArgs.jobId, - accessToken: parsedArgs.token || token, - hubUrl, - }); - - console.log(`Job ${parsedArgs.jobId} cancelled. Status: ${job.status}`); - break; - } - case "duplicate": { - const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs duplicate"); - const namespace = await getNamespace(parsedArgs.namespace); - const job = await duplicateJob({ - namespace, - jobId: parsedArgs.jobId, - accessToken: parsedArgs.token || token, - hubUrl, - }); - - console.log(`Job duplicated: ${job.id}`); - console.log(`Status: ${job.status}`); - break; - } - case "hardware": { - // Hardware list is public, no auth needed - const hardware = await listJobHardware({ hubUrl }); - - // Format as table - console.log( - "NAME".padEnd(15) + - "PRETTY NAME".padEnd(22) + - "CPU".padEnd(9) + - "RAM".padEnd(8) + - "ACCELERATOR".padEnd(17) + - "COST/MIN".padEnd(9) + - "COST/HOUR", - ); - console.log( - "-".repeat(15) + - " " + - "-".repeat(21) + - " " + - "-".repeat(8) + - " " + - "-".repeat(7) + - " " + - "-".repeat(16) + - " " + - "-".repeat(8) + - " " + - "-".repeat(9), - ); - - for (const hw of hardware) { - const acceleratorStr = hw.accelerator - ? `${hw.accelerator.quantity}x ${hw.accelerator.model} (${hw.accelerator.vram})` - : "N/A"; - const costPerMin = `$${hw.unitCostUSD.toFixed(4)}`; - const costPerHour = `$${(hw.unitCostUSD * 60).toFixed(2)}`; - - console.log( - hw.name.padEnd(15) + - hw.prettyName.padEnd(22) + - hw.cpu.padEnd(9) + - hw.ram.padEnd(8) + - acceleratorStr.padEnd(17) + - costPerMin.padEnd(9) + - costPerHour, - ); - } - break; - } - case "scheduled": { - // Handle nested scheduled subcommands - if (!("subcommands" in subCmdDef)) { - console.error(`Error: 'scheduled' is not a command group.`); - process.exitCode = 1; - break; - } - - const scheduledSubCmdName = cliArgs[0]; - if (!scheduledSubCmdName) { - console.error(`Error: Missing subcommand for 'jobs scheduled'.`); - console.log(listNestedSubcommands("jobs", "scheduled", subCmdDef)); - process.exitCode = 1; - break; - } - - const nestedSubCmdDef = subCmdDef.subcommands[scheduledSubCmdName as keyof typeof subCmdDef.subcommands]; - if (!nestedSubCmdDef) { - console.error(`Error: Unknown subcommand '${scheduledSubCmdName}' for 'jobs scheduled'.`); - console.log(listNestedSubcommands("jobs", "scheduled", subCmdDef)); - process.exitCode = 1; - break; - } - - const nestedParsedArgs = advParseArgs( - cliArgs.slice(1), - nestedSubCmdDef.args, - `jobs scheduled ${scheduledSubCmdName}`, - ); - const namespace = await getNamespace(nestedParsedArgs.namespace); - - switch (scheduledSubCmdName) { - case "run": { - const dockerImageOrSpace = nestedParsedArgs.dockerImageOrSpace; - const command = nestedParsedArgs.command ? nestedParsedArgs.command.split(/\s+/) : undefined; - - const isSpace = dockerImageOrSpace.includes("/") && !dockerImageOrSpace.includes(":"); - const dockerImage = isSpace ? undefined : dockerImageOrSpace; - const spaceId = isSpace ? dockerImageOrSpace : undefined; - - let environment: Record = {}; - if (nestedParsedArgs.env) { - environment = { ...environment, ...parseKeyValuePairs(nestedParsedArgs.env) }; - } - if (nestedParsedArgs.envFile) { - environment = { ...environment, ...loadEnvFile(nestedParsedArgs.envFile) }; - } - - let secrets: Record = {}; - if (nestedParsedArgs.secret) { - secrets = { ...secrets, ...parseKeyValuePairs(nestedParsedArgs.secret) }; - } - if (nestedParsedArgs.secretsFile) { - secrets = { ...secrets, ...loadEnvFile(nestedParsedArgs.secretsFile) }; - } - - const scheduledJob = await createScheduledJob({ - namespace, - jobSpec: { - dockerImage, - spaceId, - command, - flavor: nestedParsedArgs.flavor || "cpu-basic", - arch: nestedParsedArgs.arch, - timeoutSeconds: parseTimeout(nestedParsedArgs.timeout), - attempts: nestedParsedArgs.attempts ? parseInt(nestedParsedArgs.attempts, 10) : undefined, - environment: Object.keys(environment).length > 0 ? environment : undefined, - secrets: Object.keys(secrets).length > 0 ? secrets : undefined, - }, - schedule: nestedParsedArgs.schedule, - suspend: nestedParsedArgs.suspend ?? false, - concurrency: nestedParsedArgs.concurrency ?? false, - accessToken: nestedParsedArgs.token || token, - hubUrl, - }); - - console.log(`Scheduled job created: ${scheduledJob.id}`); - console.log(`Schedule: ${scheduledJob.schedule}`); - break; - } - case "ps": { - const scheduledJobs = await listScheduledJobs({ - namespace, - accessToken: nestedParsedArgs.token || token, - hubUrl, - }); - - if (scheduledJobs.length === 0) { - console.log("No scheduled jobs found."); - break; - } - - console.log(`\n${scheduledJobs.length} scheduled job(s):\n`); - for (const job of scheduledJobs) { - console.log(` ${job.id} ${job.suspend ? "SUSPENDED" : "ACTIVE".padEnd(12)} ${job.schedule}`); - if (job.jobSpec.dockerImage || job.jobSpec.spaceId) { - console.log(` ${job.jobSpec.dockerImage || job.jobSpec.spaceId}`); - } - if (job.jobSpec.command) { - console.log(` Command: ${job.jobSpec.command.join(" ")}`); - } - console.log(); - } - break; - } - case "inspect": { - const scheduledJob = await getScheduledJob({ - namespace, - jobId: nestedParsedArgs.scheduledJobId, - accessToken: nestedParsedArgs.token || token, - hubUrl, - }); - - console.log(JSON.stringify(scheduledJob, null, 2)); - break; - } - case "suspend": { - await suspendScheduledJob({ - namespace, - jobId: nestedParsedArgs.scheduledJobId, - accessToken: nestedParsedArgs.token || token, - hubUrl, - }); - - console.log(`Scheduled job ${nestedParsedArgs.scheduledJobId} suspended.`); - break; - } - case "resume": { - await resumeScheduledJob({ - namespace, - jobId: nestedParsedArgs.scheduledJobId, - accessToken: nestedParsedArgs.token || token, - hubUrl, - }); - - console.log(`Scheduled job ${nestedParsedArgs.scheduledJobId} resumed.`); - break; - } - case "delete": { - await deleteScheduledJob({ - namespace, - jobId: nestedParsedArgs.scheduledJobId, - accessToken: nestedParsedArgs.token || token, - hubUrl, - }); - - console.log(`Scheduled job ${nestedParsedArgs.scheduledJobId} deleted.`); - break; - } - case "trigger": { - const job = await runScheduledJob({ - namespace, - jobId: nestedParsedArgs.scheduledJobId, - accessToken: nestedParsedArgs.token || token, - hubUrl, - }); - - if (job) { - console.log(`Scheduled job triggered: ${job.id}`); - console.log(`Status: ${job.status}`); - } else { - console.log( - `Scheduled job ${nestedParsedArgs.scheduledJobId} could not be triggered: another instance is already running.`, - ); - console.log(`Set 'concurrency' to allow multiple instances to run concurrently.`); - } - break; - } - default: - console.error(`Error: Unknown subcommand '${scheduledSubCmdName}' for 'jobs scheduled'.`); - process.exitCode = 1; - break; - } - break; - } - default: - console.error(`Error: Unknown subcommand '${currentSubCommandName}' for 'jobs'.`); - console.log(listSubcommands("jobs", jobsCommandGroup)); - process.exitCode = 1; - break; - } - break; - } case "version": { if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { console.log(detailedUsageForCommand("version")); @@ -1516,65 +642,6 @@ function usage(commandName: TopLevelCommandName, subCommandName?: string): strin .join(" ")}`.trim(); } -// Helper to get namespace (defaults to current user) -async function getNamespace(providedNamespace?: string): Promise { - if (providedNamespace) { - return providedNamespace; - } - const userInfo = await whoAmI({ accessToken: token, hubUrl }); - return userInfo.name; -} - -// Helper to parse timeout string (e.g., "2h", "90m", "1.5h") -function parseTimeout(timeoutStr?: string): number | null | undefined { - if (!timeoutStr) return undefined; - const match = timeoutStr.match(/^(\d+(?:\.\d+)?)\s*([smhd])?$/i); - if (!match) { - throw new Error( - `Invalid timeout format: ${timeoutStr}. Use format like '2h', '90m', '1.5h', or a number in seconds.`, - ); - } - const value = parseFloat(match[1]); - const unit = (match[2] || "s").toLowerCase(); - const multipliers: Record = { s: 1, m: 60, h: 3600, d: 86400 }; - return Math.round(value * multipliers[unit]); -} - -// Helper to parse env/secret from CLI args -function parseKeyValuePairs(values?: string | string[]): Record { - if (!values) return {}; - const arr = Array.isArray(values) ? values : [values]; - const result: Record = {}; - for (const pair of arr) { - const [key, ...valueParts] = pair.split("="); - if (!key || valueParts.length === 0) { - throw new Error(`Invalid key-value pair: ${pair}. Expected format: KEY=VALUE`); - } - result[key] = valueParts.join("="); - } - return result; -} - -// Helper to load env file -function loadEnvFile(filePath: string): Record { - try { - const content = readFileSync(filePath, "utf-8"); - const result: Record = {}; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith("#")) { - const [key, ...valueParts] = trimmed.split("="); - if (key) { - result[key] = valueParts.join("="); - } - } - } - return result; - } catch (error) { - throw new Error(`Failed to read env file ${filePath}: ${error}`); - } -} - function _detailedUsage(args: readonly ArgDef[], usageLine: string, commandDescription?: string): string { let ret = `usage: hfjs ${usageLine}\n`; if (commandDescription) { @@ -1644,45 +711,6 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command return ret; } -function listNestedSubcommands( - commandName: TopLevelCommandName, - subCommandName: string, - commandGroup: CommandGroup, -): string { - let ret = `usage: hfjs ${commandName} ${subCommandName} [options]\n\n`; - ret += `${commandGroup.description}\n\n`; - ret += `Available subcommands for '${commandName} ${subCommandName}':\n`; - ret += typedEntries(commandGroup.subcommands) - .map(([subName, subDef]) => ` ${subName}\t${subDef.description}`) - .join("\n"); - ret += `\n\nRun \`hfjs help ${commandName} ${subCommandName} \` for more information on a specific subcommand.`; - return ret; -} - -function detailedUsageForNestedSubcommand( - commandName: TopLevelCommandName, - subCommandName: string, - nestedSubCommandName: string, -): string { - const commandGroup = commands[commandName]; - if (!("subcommands" in commandGroup)) { - throw new Error(`Command ${commandName} is not a command group`); - } - const subCommandGroup = commandGroup.subcommands[subCommandName]; - if (!("subcommands" in subCommandGroup)) { - throw new Error(`Subcommand ${subCommandName} is not a command group`); - } - const nestedSubCommandDef = subCommandGroup.subcommands[nestedSubCommandName]; - if (!nestedSubCommandDef) { - throw new Error(`Nested subcommand ${nestedSubCommandName} not found for ${commandName} ${subCommandName}`); - } - return _detailedUsage( - nestedSubCommandDef.args, - usage(commandName, `${subCommandName} ${nestedSubCommandName}`), - nestedSubCommandDef.description, - ); -} - type ParsedArgsResult = { [K in TArgsDef[number] as Camelize]: K["boolean"] extends true ? boolean From d890da01b89c579d5053278985ab7b7163b696a1 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 13:11:47 +0000 Subject: [PATCH 06/20] add just a few jobs endpoints --- packages/hub/cli.ts | 330 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 326 insertions(+), 4 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 9559ed707b..b8eb45c64b 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -2,7 +2,19 @@ import { parseArgs } from "node:util"; import { typedEntries } from "./src/utils/typedEntries"; -import { createBranch, createRepo, deleteBranch, deleteRepo, repoExists, uploadFilesWithProgress } from "./src"; +import { + createBranch, + createRepo, + deleteBranch, + deleteRepo, + listJobHardware, + listJobs, + repoExists, + runJob, + uploadFilesWithProgress, + whoAmI, + type SpaceHardwareFlavor, +} from "./src"; import { pathToFileURL } from "node:url"; import { stat } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -324,6 +336,80 @@ const commands = { description: "Print the version of the CLI", args: [] as const, } satisfies SingleCommand, + jobs: { + description: "Manage jobs on the Hub", + subcommands: { + run: { + description: "Run a new job", + args: [ + { + name: "docker-image-or-space" as const, + description: + "The Docker image to run (e.g., python:3.12) or Space ID (e.g., hf.co/spaces/username/space-name or username/space-name)", + positional: true, + required: true, + }, + { + name: "command" as const, + description: "The command to run (can be multiple words, everything after docker-image-or-space)", + positional: true, + }, + { + name: "env" as const, + short: "e", + description: "Environment variable in the format KEY=VALUE (can be specified multiple times)", + }, + { + name: "flavor" as const, + description: "Hardware flavor to use (defaults to cpu-basic)", + default: "cpu-basic", + }, + { + name: "namespace" as const, + description: "The namespace (username or organization name). Defaults to the current user's username.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + ps: { + description: "List jobs", + args: [ + { + name: "all" as const, + short: "a", + description: "List all jobs (not just running ones)", + boolean: true, + }, + { + name: "namespace" as const, + description: "The namespace (username or organization name). Defaults to the current user's username.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + hardware: { + description: "List available hardware options for jobs", + args: [ + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, + }, + } satisfies CommandGroup, } satisfies Record; type TopLevelCommandName = keyof typeof commands; @@ -591,6 +677,238 @@ async function run() { console.log(`hfjs version: ${version}`); break; } + case "jobs": { + const jobsCommandGroup = commands.jobs; + const currentSubCommandName = subCommandName as keyof typeof jobsCommandGroup.subcommands | undefined; + + if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { + if (currentSubCommandName && jobsCommandGroup.subcommands[currentSubCommandName]) { + console.log(detailedUsageForSubcommand("jobs", currentSubCommandName)); + } else { + console.log(listSubcommands("jobs", jobsCommandGroup)); + } + break; + } + + if (!currentSubCommandName || !jobsCommandGroup.subcommands[currentSubCommandName]) { + console.error(`Error: Missing or invalid subcommand for 'jobs'.`); + console.log(listSubcommands("jobs", jobsCommandGroup)); + process.exitCode = 1; + break; + } + + const subCmdDef = jobsCommandGroup.subcommands[currentSubCommandName]; + + switch (currentSubCommandName) { + case "run": { + // Handle multiple -e flags manually since parseArgs doesn't support multiple values for the same option + const envVars: string[] = []; + const filteredArgs: string[] = []; + let i = 0; + while (i < cliArgs.length) { + if (cliArgs[i] === "-e" || cliArgs[i] === "--env") { + if (i + 1 < cliArgs.length) { + envVars.push(cliArgs[i + 1]); + i += 2; + } else { + throw new Error("Missing value for -e/--env option"); + } + } else { + filteredArgs.push(cliArgs[i]); + i++; + } + } + + // Parse non-positional arguments first + const { tokens } = parseArgs({ + options: { + flavor: { type: "string", default: "cpu-basic" }, + namespace: { type: "string" }, + token: { type: "string", default: process.env.HF_TOKEN }, + }, + args: filteredArgs, + allowPositionals: true, + strict: false, + tokens: true, + }); + + const flavor = + (tokens.find((t) => t.kind === "option" && t.name === "flavor") as OptionToken | undefined)?.value || + "cpu-basic"; + const namespace = ( + tokens.find((t) => t.kind === "option" && t.name === "namespace") as OptionToken | undefined + )?.value; + const token = + (tokens.find((t) => t.kind === "option" && t.name === "token") as OptionToken | undefined)?.value || + process.env.HF_TOKEN; + + // Get positional arguments - first is docker image or space ID, rest is command + const positionals = tokens.filter((t) => t.kind === "positional").map((t) => t.value); + if (positionals.length === 0) { + throw new Error("Missing required argument: docker-image or space-id"); + } + const firstArg = positionals[0]; + const commandArray = positionals.slice(1); + + // Detect if first argument is a space ID (format: hf.co/spaces/namespace/space-name or namespace/space-name) + let dockerImage: string | undefined; + let spaceId: string | undefined; + + // Check for hf.co/spaces/... format + const hfCoSpacesMatch = firstArg.match(/^hf\.co\/spaces\/(.+)$/); + if (hfCoSpacesMatch) { + spaceId = hfCoSpacesMatch[1]; + } else if (firstArg.includes("/") && !firstArg.includes(":")) { + // If it contains a slash but no colon, assume it's a space ID (namespace/space-name) + // Docker images typically have colons (e.g., python:3.12) + spaceId = firstArg; + } else { + // Otherwise, treat it as a docker image + dockerImage = firstArg; + } + + // Get namespace from whoAmI if not provided + let finalNamespace = namespace; + if (!finalNamespace) { + if (!token) { + throw new Error( + "Cannot determine namespace without authentication. Please provide --namespace or --token.", + ); + } + const userInfo = await whoAmI({ + accessToken: token as string, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + if (userInfo.type !== "user") { + throw new Error("Cannot determine namespace. Please provide --namespace explicitly."); + } + finalNamespace = userInfo.name; + } + + // Parse environment variables + const environment: Record = {}; + for (const envVar of envVars) { + const equalIndex = envVar.indexOf("="); + if (equalIndex === -1) { + throw new Error(`Invalid environment variable format: ${envVar}. Expected KEY=VALUE`); + } + const key = envVar.slice(0, equalIndex); + const value = envVar.slice(equalIndex + 1); + environment[key] = value; + } + + const jobParams = { + namespace: finalNamespace, + ...(dockerImage ? { dockerImage } : {}), + ...(spaceId ? { spaceId } : {}), + flavor: flavor as SpaceHardwareFlavor, + command: commandArray.length > 0 ? commandArray : undefined, + environment: Object.keys(environment).length > 0 ? environment : undefined, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + + const job = await runJob(jobParams); + + console.log(`Job created: ${job.id}`); + console.log(`Status: ${job.status}`); + break; + } + case "ps": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs ps"); + const { all, namespace, token } = parsedArgs; + + // Get namespace from whoAmI if not provided + let finalNamespace = namespace; + if (!finalNamespace) { + if (!token) { + throw new Error( + "Cannot determine namespace without authentication. Please provide --namespace or --token.", + ); + } + const userInfo = await whoAmI({ + accessToken: token as string, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + if (userInfo.type !== "user") { + throw new Error("Cannot determine namespace. Please provide --namespace explicitly."); + } + finalNamespace = userInfo.name; + } + + const jobs = await listJobs({ + namespace: finalNamespace, + accessToken: token, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + + // Filter by status if not showing all + const filteredJobs = all ? jobs : jobs.filter((job) => job.status === "running"); + + if (filteredJobs.length === 0) { + console.log(all ? "No jobs found." : "No running jobs found."); + break; + } + + // Display jobs in a table-like format + console.log(`${"ID".padEnd(40)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`); + console.log("-".repeat(100)); + for (const job of filteredJobs) { + const createdAt = new Date(job.createdAt).toLocaleString(); + const dockerImage = job.dockerImage || job.spaceId || "N/A"; + console.log(`${job.id.padEnd(40)} ${job.status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`); + } + break; + } + case "hardware": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs hardware"); + const { token } = parsedArgs; + + const hardwareParams: { + hubUrl?: string; + accessToken?: string; + } = { + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }; + if (token) { + hardwareParams.accessToken = token; + } + + const hardware = await listJobHardware(hardwareParams); + + // Format and display the hardware list + console.log( + `${"NAME".padEnd(15)} ${"PRETTY NAME".padEnd(22)} ${"CPU".padEnd(8)} ${"RAM".padEnd(7)} ${"ACCELERATOR".padEnd(16)} ${"COST/MIN".padEnd(9)} ${"COST/HOUR"}`, + ); + console.log("-".repeat(100)); + + for (const hw of hardware) { + // Format accelerator + let accelerator = "N/A"; + if (hw.accelerator) { + accelerator = `${hw.accelerator.quantity}x ${hw.accelerator.model} (${hw.accelerator.vram})`; + } + + // Format costs - unitCostMicroUSD is in microUSD (divide by 1,000,000 to get USD) + // unitCostUSD appears to be per minute based on the example + const costPerMin = (hw.unitCostMicroUSD / 1_000_000).toFixed(4); + const costPerHour = ((hw.unitCostMicroUSD / 1_000_000) * 60).toFixed(2); + + console.log( + `${hw.name.padEnd(15)} ${hw.prettyName.padEnd(22)} ${hw.cpu.padEnd(8)} ${hw.ram.padEnd(7)} ${accelerator.padEnd(16)} $${costPerMin.padStart(8)} $${costPerHour.padStart(9)}`, + ); + } + break; + } + default: + // Should be caught by the check above + console.error(`Error: Unknown subcommand '${currentSubCommandName}' for 'jobs'.`); + console.log(listSubcommands("jobs", jobsCommandGroup)); + process.exitCode = 1; + break; + } + break; + } default: console.error("Command not found: " + mainCommandName); // Print general help @@ -621,7 +939,8 @@ function usage(commandName: TopLevelCommandName, subCommandName?: string): strin if ("subcommands" in commandEntry) { if (subCommandName && subCommandName in commandEntry.subcommands) { - cmdArgs = commandEntry.subcommands[subCommandName as keyof typeof commandEntry.subcommands].args; + const subCmd = commandEntry.subcommands[subCommandName as keyof typeof commandEntry.subcommands] as SingleCommand; + cmdArgs = subCmd.args; fullCommandName = `${commandName} ${subCommandName}`; } else { return `${commandName} `; @@ -693,10 +1012,13 @@ function detailedUsageForSubcommand( subCommandName: keyof CommandGroup["subcommands"], ): string { const commandGroup = commands[commandName]; - if (!("subcommands" in commandGroup) || !(subCommandName in commandGroup.subcommands)) { + if (!("subcommands" in commandGroup)) { + throw new Error(`Command ${commandName} does not have subcommands`); + } + if (!(subCommandName in commandGroup.subcommands)) { throw new Error(`Subcommand ${subCommandName as string} not found for ${commandName}`); } - const subCommandDef = commandGroup.subcommands[subCommandName as keyof typeof commandGroup.subcommands]; + const subCommandDef = (commandGroup.subcommands as Record)[subCommandName]; return _detailedUsage(subCommandDef.args, usage(commandName, subCommandName as string), subCommandDef.description); } From 943da35deeb4459320eb8d1d59349bc38f4e5faa Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 13:16:12 +0000 Subject: [PATCH 07/20] add name & attempts params --- packages/hub/cli.ts | 35 +++++++++++++++++++++++--- packages/hub/src/lib/jobs/run-job.ts | 3 +++ packages/hub/src/types/api/api-jobs.ts | 5 ++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index b8eb45c64b..9a56ac1e68 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -364,6 +364,14 @@ const commands = { description: "Hardware flavor to use (defaults to cpu-basic)", default: "cpu-basic", }, + { + name: "name" as const, + description: "Optional name for the job", + }, + { + name: "attempts" as const, + description: "Maximum number of attempts (defaults to 1)", + }, { name: "namespace" as const, description: "The namespace (username or organization name). Defaults to the current user's username.", @@ -723,6 +731,8 @@ async function run() { const { tokens } = parseArgs({ options: { flavor: { type: "string", default: "cpu-basic" }, + name: { type: "string" }, + attempts: { type: "string" }, namespace: { type: "string" }, token: { type: "string", default: process.env.HF_TOKEN }, }, @@ -735,6 +745,18 @@ async function run() { const flavor = (tokens.find((t) => t.kind === "option" && t.name === "flavor") as OptionToken | undefined)?.value || "cpu-basic"; + const name = (tokens.find((t) => t.kind === "option" && t.name === "name") as OptionToken | undefined)?.value; + const attemptsStr = ( + tokens.find((t) => t.kind === "option" && t.name === "attempts") as OptionToken | undefined + )?.value; + let attempts: number | undefined; + if (attemptsStr) { + const parsed = parseInt(attemptsStr, 10); + if (isNaN(parsed) || parsed < 1) { + throw new Error("Attempts must be a positive integer"); + } + attempts = parsed; + } const namespace = ( tokens.find((t) => t.kind === "option" && t.name === "namespace") as OptionToken | undefined )?.value; @@ -799,11 +821,13 @@ async function run() { const jobParams = { namespace: finalNamespace, + ...(name ? { name } : {}), ...(dockerImage ? { dockerImage } : {}), ...(spaceId ? { spaceId } : {}), flavor: flavor as SpaceHardwareFlavor, command: commandArray.length > 0 ? commandArray : undefined, environment: Object.keys(environment).length > 0 ? environment : undefined, + ...(attempts !== undefined ? { attempts } : {}), hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, ...(token ? { accessToken: token } : {}), } as Parameters[0]; @@ -851,12 +875,17 @@ async function run() { } // Display jobs in a table-like format - console.log(`${"ID".padEnd(40)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`); - console.log("-".repeat(100)); + console.log( + `${"ID".padEnd(40)} ${"NAME".padEnd(20)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`, + ); + console.log("-".repeat(120)); for (const job of filteredJobs) { const createdAt = new Date(job.createdAt).toLocaleString(); const dockerImage = job.dockerImage || job.spaceId || "N/A"; - console.log(`${job.id.padEnd(40)} ${job.status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`); + const jobName = job.name || "N/A"; + console.log( + `${job.id.padEnd(40)} ${jobName.padEnd(20)} ${job.status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`, + ); } break; } diff --git a/packages/hub/src/lib/jobs/run-job.ts b/packages/hub/src/lib/jobs/run-job.ts index 2924591f11..82fb3dafac 100644 --- a/packages/hub/src/lib/jobs/run-job.ts +++ b/packages/hub/src/lib/jobs/run-job.ts @@ -35,6 +35,9 @@ export async function runJob( flavor: params.flavor, }; + if (params.name) { + body.name = params.name; + } if (params.dockerImage) { body.dockerImage = params.dockerImage; } diff --git a/packages/hub/src/types/api/api-jobs.ts b/packages/hub/src/types/api/api-jobs.ts index 82c79a4460..43299f3ed6 100644 --- a/packages/hub/src/types/api/api-jobs.ts +++ b/packages/hub/src/types/api/api-jobs.ts @@ -26,6 +26,7 @@ export interface ApiJob { updatedAt: string; startedAt?: string | null; finishedAt?: string | null; + name?: string | null; dockerImage?: string | null; spaceId?: string | null; command?: string[] | null; @@ -60,6 +61,10 @@ export interface ApiScheduledJob { } export interface CreateJobOptions { + /** + * Optional name for the job + */ + name?: string; /** * The Docker image to run (e.g., "python:3.12" or "pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel") */ From cbbf1d8702c8baa0fd8c8606bb3e59abda11e4d7 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 13:26:25 +0000 Subject: [PATCH 08/20] update api --- packages/hub/README.md | 2 ++ packages/hub/cli.ts | 10 +++++--- packages/hub/src/types/api/api-jobs.ts | 32 ++++++++++++++++++++------ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/hub/README.md b/packages/hub/README.md index f0b48d7144..ddfee3e338 100644 --- a/packages/hub/README.md +++ b/packages/hub/README.md @@ -139,6 +139,8 @@ hfjs upload --repo-type dataset coyotte508/test-dataset . --revision release hfjs --help hfjs upload --help + +hfjs help jobs ``` ## OAuth Login diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 9a56ac1e68..ebcc65162b 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -835,7 +835,7 @@ async function run() { const job = await runJob(jobParams); console.log(`Job created: ${job.id}`); - console.log(`Status: ${job.status}`); + console.log(`Status: ${job.status.stage}`); break; } case "ps": { @@ -867,7 +867,7 @@ async function run() { }); // Filter by status if not showing all - const filteredJobs = all ? jobs : jobs.filter((job) => job.status === "running"); + const filteredJobs = all ? jobs : jobs.filter((job) => job.status.stage === "RUNNING"); if (filteredJobs.length === 0) { console.log(all ? "No jobs found." : "No running jobs found."); @@ -883,8 +883,9 @@ async function run() { const createdAt = new Date(job.createdAt).toLocaleString(); const dockerImage = job.dockerImage || job.spaceId || "N/A"; const jobName = job.name || "N/A"; + const status = job.status.stage; console.log( - `${job.id.padEnd(40)} ${jobName.padEnd(20)} ${job.status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`, + `${job.id.padEnd(40)} ${jobName.padEnd(20)} ${status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`, ); } break; @@ -1058,6 +1059,9 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command ret += typedEntries(commandGroup.subcommands) .map(([subName, subDef]) => ` ${subName}\t${subDef.description}`) .join("\n"); + if (commandName === "jobs") { + ret += `\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 python -c 'import os; print(os.environ["FOO"], os.environ["BAR"])'`; + } ret += `\n\nRun \`hfjs help ${commandName} \` for more information on a specific subcommand.`; return ret; } diff --git a/packages/hub/src/types/api/api-jobs.ts b/packages/hub/src/types/api/api-jobs.ts index 43299f3ed6..2384c0e9f2 100644 --- a/packages/hub/src/types/api/api-jobs.ts +++ b/packages/hub/src/types/api/api-jobs.ts @@ -17,28 +17,46 @@ export interface ApiJobHardware { unitLabel: string; } -export type JobStatus = "pending" | "running" | "succeeded" | "failed" | "cancelled" | "cancelling" | "queued"; +export type JobStatusStage = "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED" | "CANCELLING" | "QUEUED"; + +export interface ApiJobStatus { + stage: JobStatusStage; + message?: string | null; + failureCount: number; +} + +export interface ApiJobUser { + id: string; + name: string; + type?: "user" | "org"; + avatarUrl?: string; +} export interface ApiJob { + type: "job"; id: string; - status: JobStatus; + status: ApiJobStatus; createdAt: string; - updatedAt: string; + updatedAt?: string; startedAt?: string | null; finishedAt?: string | null; + createdBy?: { + id: string; + name: string; + }; name?: string | null; dockerImage?: string | null; spaceId?: string | null; command?: string[] | null; arguments?: string[] | null; + environment?: Record | null; flavor: SpaceHardwareFlavor; arch?: "amd64" | "arm64" | null; timeoutSeconds?: number | null; attempts?: number; - initiator?: { - type: "user" | "scheduled-job" | "duplicated-job"; - id?: string; - } | null; + owner?: ApiJobUser; + initiator?: ApiJobUser; + secrets?: string[]; } export interface ApiScheduledJob { From 9f0e32b181efb8809bd71d78222aaad2b17d8067 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 13:47:59 +0000 Subject: [PATCH 09/20] -wip --- packages/hub/cli.ts | 160 ++++++++++++++++++++++++- packages/hub/src/lib/jobs/run-job.ts | 4 +- packages/hub/src/types/api/api-jobs.ts | 2 +- 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index ebcc65162b..ffe62295ed 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -7,10 +7,12 @@ import { createRepo, deleteBranch, deleteRepo, + getJob, listJobHardware, listJobs, repoExists, runJob, + streamJobLogs, uploadFilesWithProgress, whoAmI, type SpaceHardwareFlavor, @@ -351,7 +353,8 @@ const commands = { }, { name: "command" as const, - description: "The command to run (can be multiple words, everything after docker-image-or-space)", + description: + 'The command to run (should be quoted if it contains flags like -c, e.g., \'python -c "print(\\"hello\\")"\' or "python -c \'print(\\"hello\\")\'")', positional: true, }, { @@ -416,6 +419,27 @@ const commands = { }, ] as const, }, + logs: { + description: "Show logs for a job", + args: [ + { + name: "job-id" as const, + description: "The job ID", + positional: true, + required: true, + }, + { + name: "namespace" as const, + description: "The namespace (username or organization name). Defaults to the current user's username.", + }, + { + name: "token" as const, + description: + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", + default: process.env.HF_TOKEN, + }, + ] as const, + }, }, } satisfies CommandGroup, } satisfies Record; @@ -770,7 +794,18 @@ async function run() { throw new Error("Missing required argument: docker-image or space-id"); } const firstArg = positionals[0]; - const commandArray = positionals.slice(1); + // If there's only one command argument, it might be a quoted string that needs parsing + // Otherwise, use all arguments as-is + let commandArray: string[] = []; + if (positionals.length > 1) { + if (positionals.length === 2 && positionals[1].includes(" ") && !positionals[1].startsWith("-")) { + // Likely a quoted command string, parse it + commandArray = parseShellCommand(positionals[1]); + } else { + // Multiple arguments, use as-is + commandArray = positionals.slice(1); + } + } // Detect if first argument is a space ID (format: hf.co/spaces/namespace/space-name or namespace/space-name) let dockerImage: string | undefined; @@ -826,7 +861,7 @@ async function run() { ...(spaceId ? { spaceId } : {}), flavor: flavor as SpaceHardwareFlavor, command: commandArray.length > 0 ? commandArray : undefined, - environment: Object.keys(environment).length > 0 ? environment : undefined, + environment: Object.keys(environment).length > 0 ? environment : {}, ...(attempts !== undefined ? { attempts } : {}), hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, ...(token ? { accessToken: token } : {}), @@ -930,6 +965,74 @@ async function run() { } break; } + case "logs": { + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs logs"); + const { jobId, namespace, token } = parsedArgs; + + // Get namespace from whoAmI if not provided + let finalNamespace = namespace; + if (!finalNamespace) { + if (!token) { + throw new Error( + "Cannot determine namespace without authentication. Please provide --namespace or --token.", + ); + } + const userInfo = await whoAmI({ + accessToken: token as string, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + }); + if (userInfo.type !== "user") { + throw new Error("Cannot determine namespace. Please provide --namespace explicitly."); + } + finalNamespace = userInfo.name; + } + + const logsParams = { + namespace: finalNamespace, + jobId, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + + // Get job info to check for error messages + const jobInfoParams = { + namespace: finalNamespace, + jobId, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + const jobInfo = await getJob(jobInfoParams); + + // Show error message if job failed (check both FAILED stage and message field) + if (jobInfo.status.stage === "ERROR" && jobInfo.status.message) { + console.error(`\n❌ Job failed: ${jobInfo.status.message}\n`); + } + + // Display logs with proper line breaks + // The stream itself may include the header, so we'll let it handle that + for await (const logChunk of streamJobLogs(logsParams)) { + try { + // Try to parse as JSON (SSE format) + const parsed = JSON.parse(logChunk); + if (parsed.data) { + // Ensure the data ends with a newline if it doesn't already + const data = parsed.data; + process.stdout.write(data.endsWith("\n") ? data : data + "\n"); + } + } catch { + // If not JSON, write as-is with newline if needed + const chunk = logChunk; + // Check if it's already the header line (which includes newline) + if (chunk.includes("===== Job started")) { + process.stdout.write(chunk.endsWith("\n") ? chunk : chunk + "\n"); + } else { + // For other chunks, ensure newline + process.stdout.write(chunk.endsWith("\n") ? chunk : chunk + "\n"); + } + } + } + break; + } default: // Should be caught by the check above console.error(`Error: Unknown subcommand '${currentSubCommandName}' for 'jobs'.`); @@ -1060,7 +1163,8 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command .map(([subName, subDef]) => ` ${subName}\t${subDef.description}`) .join("\n"); if (commandName === "jobs") { - ret += `\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 python -c 'import os; print(os.environ["FOO"], os.environ["BAR"])'`; + ret += + '\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 "python -c \'import os; print(os.environ[\\"FOO\\"], os.environ[\\"BAR\\"])\'"'; } ret += `\n\nRun \`hfjs help ${commandName} \` for more information on a specific subcommand.`; return ret; @@ -1182,3 +1286,51 @@ function advParseArgs( function kebabToCamelCase(str: string) { return str.replace(/-./g, (match) => match[1].toUpperCase()); } + +/** + * Parse a shell command string into an array of arguments, respecting quotes. + * Handles both single and double quotes, and escaped quotes. + */ +function parseShellCommand(cmd: string): string[] { + const args: string[] = []; + let current = ""; + let inSingleQuote = false; + let inDoubleQuote = false; + let i = 0; + + while (i < cmd.length) { + const char = cmd[i]; + const nextChar = cmd[i + 1]; + + if (char === "\\" && (nextChar === '"' || nextChar === "'" || nextChar === "\\")) { + // Escaped quote or backslash + current += nextChar; + i += 2; + } else if (char === "'" && !inDoubleQuote) { + // Toggle single quote + inSingleQuote = !inSingleQuote; + i++; + } else if (char === '"' && !inSingleQuote) { + // Toggle double quote + inDoubleQuote = !inDoubleQuote; + i++; + } else if ((char === " " || char === "\t") && !inSingleQuote && !inDoubleQuote) { + // Whitespace outside quotes - end of argument + if (current.length > 0) { + args.push(current); + current = ""; + } + i++; + } else { + current += char; + i++; + } + } + + // Add the last argument if any + if (current.length > 0) { + args.push(current); + } + + return args; +} diff --git a/packages/hub/src/lib/jobs/run-job.ts b/packages/hub/src/lib/jobs/run-job.ts index 82fb3dafac..bd9d717ca2 100644 --- a/packages/hub/src/lib/jobs/run-job.ts +++ b/packages/hub/src/lib/jobs/run-job.ts @@ -33,6 +33,7 @@ export async function runJob( const body: Record = { flavor: params.flavor, + environment: params.environment || {}, }; if (params.name) { @@ -50,9 +51,6 @@ export async function runJob( if (params.arguments) { body.arguments = params.arguments; } - if (params.environment) { - body.environment = params.environment; - } if (params.secrets) { body.secrets = params.secrets; } diff --git a/packages/hub/src/types/api/api-jobs.ts b/packages/hub/src/types/api/api-jobs.ts index 2384c0e9f2..569f0e1841 100644 --- a/packages/hub/src/types/api/api-jobs.ts +++ b/packages/hub/src/types/api/api-jobs.ts @@ -17,7 +17,7 @@ export interface ApiJobHardware { unitLabel: string; } -export type JobStatusStage = "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED" | "CANCELLING" | "QUEUED"; +export type JobStatusStage = "DELETING" | "RUNNING" | "PAUSED" | "STOPPED" | "UPDATING" | "ERROR"; export interface ApiJobStatus { stage: JobStatusStage; From 54097f282ba927643da5c9ef16b22b46261153f9 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 14:03:09 +0000 Subject: [PATCH 10/20] try multiple params --- packages/hub/cli.ts | 90 ++++++++++++++------------------------------- 1 file changed, 27 insertions(+), 63 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index ffe62295ed..7838aa47c2 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -354,7 +354,7 @@ const commands = { { name: "command" as const, description: - 'The command to run (should be quoted if it contains flags like -c, e.g., \'python -c "print(\\"hello\\")"\' or "python -c \'print(\\"hello\\")\'")', + "The command to run (can be multiple arguments, e.g., python -c 'import os; print(os.environ[\"FOO\"])')", positional: true, }, { @@ -789,21 +789,33 @@ async function run() { process.env.HF_TOKEN; // Get positional arguments - first is docker image or space ID, rest is command - const positionals = tokens.filter((t) => t.kind === "positional").map((t) => t.value); - if (positionals.length === 0) { + // Find the first positional token (docker image) + const positionalTokens = tokens.filter((t) => t.kind === "positional"); + if (positionalTokens.length === 0) { throw new Error("Missing required argument: docker-image or space-id"); } - const firstArg = positionals[0]; - // If there's only one command argument, it might be a quoted string that needs parsing - // Otherwise, use all arguments as-is - let commandArray: string[] = []; - if (positionals.length > 1) { - if (positionals.length === 2 && positionals[1].includes(" ") && !positionals[1].startsWith("-")) { - // Likely a quoted command string, parse it - commandArray = parseShellCommand(positionals[1]); - } else { - // Multiple arguments, use as-is - commandArray = positionals.slice(1); + const firstArg = positionalTokens[0].value; + + // Find the index of the first positional token in the full tokens array + const firstPositionalIndex = tokens.findIndex((t) => t.kind === "positional"); + + // Everything after the first positional should be part of the command + // Convert any option-like tokens (starting with -) to positional args if they come after the docker image + const commandArray: string[] = []; + for (let i = firstPositionalIndex + 1; i < tokens.length; i++) { + const token = tokens[i]; + if (token.kind === "positional") { + // Regular positional arg - use as-is, don't split + commandArray.push(token.value); + } else if (token.kind === "option") { + // Option token that came after docker image - treat as part of command + // Include both the option name and its value if it has one + if (token.rawName) { + commandArray.push(token.rawName); + } + if (token.value !== undefined) { + commandArray.push(token.value); + } } } @@ -1164,7 +1176,7 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command .join("\n"); if (commandName === "jobs") { ret += - '\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 "python -c \'import os; print(os.environ[\\"FOO\\"], os.environ[\\"BAR\\"])\'"'; + '\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 python -c \'import os; print(os.environ["FOO"], os.environ["BAR"])\''; } ret += `\n\nRun \`hfjs help ${commandName} \` for more information on a specific subcommand.`; return ret; @@ -1286,51 +1298,3 @@ function advParseArgs( function kebabToCamelCase(str: string) { return str.replace(/-./g, (match) => match[1].toUpperCase()); } - -/** - * Parse a shell command string into an array of arguments, respecting quotes. - * Handles both single and double quotes, and escaped quotes. - */ -function parseShellCommand(cmd: string): string[] { - const args: string[] = []; - let current = ""; - let inSingleQuote = false; - let inDoubleQuote = false; - let i = 0; - - while (i < cmd.length) { - const char = cmd[i]; - const nextChar = cmd[i + 1]; - - if (char === "\\" && (nextChar === '"' || nextChar === "'" || nextChar === "\\")) { - // Escaped quote or backslash - current += nextChar; - i += 2; - } else if (char === "'" && !inDoubleQuote) { - // Toggle single quote - inSingleQuote = !inSingleQuote; - i++; - } else if (char === '"' && !inSingleQuote) { - // Toggle double quote - inDoubleQuote = !inDoubleQuote; - i++; - } else if ((char === " " || char === "\t") && !inSingleQuote && !inDoubleQuote) { - // Whitespace outside quotes - end of argument - if (current.length > 0) { - args.push(current); - current = ""; - } - i++; - } else { - current += char; - i++; - } - } - - // Add the last argument if any - if (current.length > 0) { - args.push(current); - } - - return args; -} From 35999a42924c587cc16cfad4b91a40ed4d885640 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 15:09:34 +0000 Subject: [PATCH 11/20] Improve parsing of cli args --- packages/hub/cli.ts | 130 ++++++++++++------------------------- packages/hub/tsconfig.json | 3 +- 2 files changed, 44 insertions(+), 89 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 7838aa47c2..146dc8bbee 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -151,6 +151,7 @@ interface ArgDef { name: string; short?: string; positional?: boolean; + multiple?: boolean; description?: string; required?: boolean; boolean?: boolean; @@ -354,12 +355,14 @@ const commands = { { name: "command" as const, description: - "The command to run (can be multiple arguments, e.g., python -c 'import os; print(os.environ[\"FOO\"])')", + "The command to run (can be multiple arguments preceded by --, e.g., -- python -c 'import os; print(os.environ[\"FOO\"])')", positional: true, + multiple: true, }, { name: "env" as const, short: "e", + multiple: true, description: "Environment variable in the format KEY=VALUE (can be specified multiple times)", }, { @@ -733,46 +736,18 @@ async function run() { switch (currentSubCommandName) { case "run": { - // Handle multiple -e flags manually since parseArgs doesn't support multiple values for the same option - const envVars: string[] = []; - const filteredArgs: string[] = []; - let i = 0; - while (i < cliArgs.length) { - if (cliArgs[i] === "-e" || cliArgs[i] === "--env") { - if (i + 1 < cliArgs.length) { - envVars.push(cliArgs[i + 1]); - i += 2; - } else { - throw new Error("Missing value for -e/--env option"); - } - } else { - filteredArgs.push(cliArgs[i]); - i++; - } - } - - // Parse non-positional arguments first - const { tokens } = parseArgs({ - options: { - flavor: { type: "string", default: "cpu-basic" }, - name: { type: "string" }, - attempts: { type: "string" }, - namespace: { type: "string" }, - token: { type: "string", default: process.env.HF_TOKEN }, - }, - args: filteredArgs, - allowPositionals: true, - strict: false, - tokens: true, - }); - - const flavor = - (tokens.find((t) => t.kind === "option" && t.name === "flavor") as OptionToken | undefined)?.value || - "cpu-basic"; - const name = (tokens.find((t) => t.kind === "option" && t.name === "name") as OptionToken | undefined)?.value; - const attemptsStr = ( - tokens.find((t) => t.kind === "option" && t.name === "attempts") as OptionToken | undefined - )?.value; + const parsedArgs = advParseArgs(cliArgs, subCmdDef.args, "jobs run"); + const { + dockerImageOrSpace: firstArg, + command: commandArray, + env, + flavor, + name, + attempts: attemptsStr, + namespace, + token, + } = parsedArgs; + const envVars = env; let attempts: number | undefined; if (attemptsStr) { const parsed = parseInt(attemptsStr, 10); @@ -781,43 +756,6 @@ async function run() { } attempts = parsed; } - const namespace = ( - tokens.find((t) => t.kind === "option" && t.name === "namespace") as OptionToken | undefined - )?.value; - const token = - (tokens.find((t) => t.kind === "option" && t.name === "token") as OptionToken | undefined)?.value || - process.env.HF_TOKEN; - - // Get positional arguments - first is docker image or space ID, rest is command - // Find the first positional token (docker image) - const positionalTokens = tokens.filter((t) => t.kind === "positional"); - if (positionalTokens.length === 0) { - throw new Error("Missing required argument: docker-image or space-id"); - } - const firstArg = positionalTokens[0].value; - - // Find the index of the first positional token in the full tokens array - const firstPositionalIndex = tokens.findIndex((t) => t.kind === "positional"); - - // Everything after the first positional should be part of the command - // Convert any option-like tokens (starting with -) to positional args if they come after the docker image - const commandArray: string[] = []; - for (let i = firstPositionalIndex + 1; i < tokens.length; i++) { - const token = tokens[i]; - if (token.kind === "positional") { - // Regular positional arg - use as-is, don't split - commandArray.push(token.value); - } else if (token.kind === "option") { - // Option token that came after docker image - treat as part of command - // Include both the option name and its value if it has one - if (token.rawName) { - commandArray.push(token.rawName); - } - if (token.value !== undefined) { - commandArray.push(token.value); - } - } - } // Detect if first argument is a space ID (format: hf.co/spaces/namespace/space-name or namespace/space-name) let dockerImage: string | undefined; @@ -1176,7 +1114,7 @@ function listSubcommands(commandName: TopLevelCommandName, commandGroup: Command .join("\n"); if (commandName === "jobs") { ret += - '\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 python -c \'import os; print(os.environ["FOO"], os.environ["BAR"])\''; + '\n\nExample:\n hfjs jobs run -e FOO=foo -e BAR=bar python:3.12 -- python -c \'import os; print(os.environ["FOO"], os.environ["BAR"])\''; } ret += `\n\nRun \`hfjs help ${commandName} \` for more information on a specific subcommand.`; return ret; @@ -1186,10 +1124,16 @@ type ParsedArgsResult = { [K in TArgsDef[number] as Camelize]: K["boolean"] extends true ? boolean : K["required"] extends true - ? string + ? K["multiple"] extends true + ? string[] // Multiple strings are arrays + : string : K["default"] extends undefined - ? string | undefined // Optional strings without default can be undefined - : string; // Strings with default or required are strings + ? K["multiple"] extends true + ? string[] // Multiple optional strings are arrays + : string | undefined // Optional strings without default can be undefined + : K["multiple"] extends true + ? string[] // Multiple strings with default are arrays + : string; // Strings with default or required are strings }; function advParseArgs( @@ -1197,6 +1141,7 @@ function advParseArgs( argDefs: TArgsDef, commandNameForError: string, ): ParsedArgsResult { + const hasMultiplePositional = argDefs.some((arg) => arg.multiple && arg.positional); const { tokens } = parseArgs({ options: Object.fromEntries( argDefs @@ -1205,6 +1150,7 @@ function advParseArgs( const optionConfig = { type: arg.boolean ? ("boolean" as const) : ("string" as const), ...(arg.short && { short: arg.short }), + ...(arg.multiple && { multiple: true }), ...(arg.default !== undefined && { default: typeof arg.default === "function" ? arg.default() : arg.default, }), @@ -1230,7 +1176,7 @@ function advParseArgs( ); } - if (providedPositionalTokens.length > expectedPositionals.length) { + if (providedPositionalTokens.length > expectedPositionals.length && !hasMultiplePositional) { throw new Error( `Command '${commandNameForError}': Too many positional arguments. Usage: hfjs ${usage( commandNameForError.split(" ")[0] as TopLevelCommandName, @@ -1239,7 +1185,7 @@ function advParseArgs( ); } - const result: Record = {}; + const result: Record = {}; // Populate from defaults first for (const argDef of argDefs) { @@ -1251,9 +1197,11 @@ function advParseArgs( } // Populate positionals - providedPositionalTokens.forEach((token, i) => { - if (expectedPositionals[i]) { - result[expectedPositionals[i].name] = token.value; + expectedPositionals.forEach((argDef, i) => { + if (argDef.multiple) { + result[argDef.name] = providedPositionalTokens.slice(i).map((token) => token.value); + } else { + result[argDef.name] = providedPositionalTokens[i].value; } }); @@ -1279,7 +1227,13 @@ function advParseArgs( }. Expected one of: ${argDef.enum.join(", ")}`, ); } - result[argDef.name] = token.value; + if (argDef.multiple) { + const existing = (result[argDef.name] as string[]) || []; + existing.push(token.value); + result[argDef.name] = existing; + } else { + result[argDef.name] = token.value; + } } }); diff --git a/packages/hub/tsconfig.json b/packages/hub/tsconfig.json index c66aef0cff..39d83f3de1 100644 --- a/packages/hub/tsconfig.json +++ b/packages/hub/tsconfig.json @@ -14,7 +14,8 @@ "noImplicitOverride": true, "outDir": "./dist", "declaration": true, - "declarationMap": true + "declarationMap": true, + "sourceMap": true }, "include": ["src", "index.ts", "cli.ts"], "exclude": ["dist"] From 0e382e54f5ed4b5c91570e2572bf7deba203fd9b Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 15:17:01 +0000 Subject: [PATCH 12/20] fix stream job logs --- packages/hub/cli.ts | 43 ++++++++++---------- packages/hub/src/lib/jobs/stream-job-logs.ts | 16 ++++++-- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 146dc8bbee..b8c4258907 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -388,6 +388,12 @@ const commands = { "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", default: process.env.HF_TOKEN, }, + { + name: "follow" as const, + short: "f", + description: "Stream logs after creating the job", + boolean: true, + }, ] as const, }, ps: { @@ -746,6 +752,7 @@ async function run() { attempts: attemptsStr, namespace, token, + follow, } = parsedArgs; const envVars = env; let attempts: number | undefined; @@ -821,6 +828,20 @@ async function run() { console.log(`Job created: ${job.id}`); console.log(`Status: ${job.status.stage}`); + + if (follow) { + const logsParams = { + namespace: finalNamespace, + jobId: job.id, + hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, + ...(token ? { accessToken: token } : {}), + } as Parameters[0]; + + // Display logs with proper line breaks + for await (const logChunk of streamJobLogs(logsParams)) { + console.log(logChunk.message); + } + } break; } case "ps": { @@ -958,28 +979,8 @@ async function run() { console.error(`\n❌ Job failed: ${jobInfo.status.message}\n`); } - // Display logs with proper line breaks - // The stream itself may include the header, so we'll let it handle that for await (const logChunk of streamJobLogs(logsParams)) { - try { - // Try to parse as JSON (SSE format) - const parsed = JSON.parse(logChunk); - if (parsed.data) { - // Ensure the data ends with a newline if it doesn't already - const data = parsed.data; - process.stdout.write(data.endsWith("\n") ? data : data + "\n"); - } - } catch { - // If not JSON, write as-is with newline if needed - const chunk = logChunk; - // Check if it's already the header line (which includes newline) - if (chunk.includes("===== Job started")) { - process.stdout.write(chunk.endsWith("\n") ? chunk : chunk + "\n"); - } else { - // For other chunks, ensure newline - process.stdout.write(chunk.endsWith("\n") ? chunk : chunk + "\n"); - } - } + console.log(logChunk.message); } break; } diff --git a/packages/hub/src/lib/jobs/stream-job-logs.ts b/packages/hub/src/lib/jobs/stream-job-logs.ts index d5d8d38664..1e6b7c038f 100644 --- a/packages/hub/src/lib/jobs/stream-job-logs.ts +++ b/packages/hub/src/lib/jobs/stream-job-logs.ts @@ -23,7 +23,7 @@ export async function* streamJobLogs( */ fetch?: typeof fetch; } & CredentialsParams, -): AsyncGenerator { +): AsyncGenerator<{ message: string; timestamp: Date }, void, unknown> { const accessToken = checkCredentials(params); const response = await (params.fetch || fetch)( @@ -59,7 +59,12 @@ export async function* streamJobLogs( for (const line of lines) { if (line.startsWith("data: ")) { - yield line.slice(6); + try { + const data = JSON.parse(line.slice(6)); + yield { message: data.data, timestamp: new Date(data.timestamp) }; + } catch { + yield { message: line.slice(6), timestamp: new Date() }; + } } } } @@ -69,7 +74,12 @@ export async function* streamJobLogs( const lines = buffer.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { - yield line.slice(6); + try { + const data = JSON.parse(line.slice(6)); + yield { message: data.data, timestamp: new Date(data.timestamp) }; + } catch { + yield { message: line.slice(6), timestamp: new Date() }; + } } } } From 3a0b9c7a11f04b055cc32f40a6345e7de803d170 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 15:18:05 +0000 Subject: [PATCH 13/20] fix helpp --- packages/hub/cli.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index b8c4258907..615381fb4e 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -605,6 +605,13 @@ async function run() { const branchCommandGroup = commands.branch; const currentSubCommandName = subCommandName as keyof typeof branchCommandGroup.subcommands | undefined; + // Check if --help is in subcommand position (e.g., "hfjs branch --help") + if (subCommandName === "--help" || subCommandName === "-h") { + console.log(listSubcommands("branch", branchCommandGroup)); + break; + } + + // Check if --help is in args position (e.g., "hfjs branch create --help") if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { if (currentSubCommandName && branchCommandGroup.subcommands[currentSubCommandName]) { console.log(detailedUsageForSubcommand("branch", currentSubCommandName)); @@ -666,6 +673,13 @@ async function run() { const repoCommandGroup = commands.repo; const currentSubCommandName = subCommandName as keyof typeof repoCommandGroup.subcommands | undefined; + // Check if --help is in subcommand position (e.g., "hfjs repo --help") + if (subCommandName === "--help" || subCommandName === "-h") { + console.log(listSubcommands("repo", repoCommandGroup)); + break; + } + + // Check if --help is in args position (e.g., "hfjs repo delete --help") if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { if (currentSubCommandName && repoCommandGroup.subcommands[currentSubCommandName]) { console.log(detailedUsageForSubcommand("repo", currentSubCommandName)); @@ -722,6 +736,13 @@ async function run() { const jobsCommandGroup = commands.jobs; const currentSubCommandName = subCommandName as keyof typeof jobsCommandGroup.subcommands | undefined; + // Check if --help is in subcommand position (e.g., "hfjs jobs --help") + if (subCommandName === "--help" || subCommandName === "-h") { + console.log(listSubcommands("jobs", jobsCommandGroup)); + break; + } + + // Check if --help is in args position (e.g., "hfjs jobs run --help") if (cliArgs[0] === "--help" || cliArgs[0] === "-h") { if (currentSubCommandName && jobsCommandGroup.subcommands[currentSubCommandName]) { console.log(detailedUsageForSubcommand("jobs", currentSubCommandName)); From 47a866d659ca24ef4a00b9d6b954cb50a77e0982 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 15:23:06 +0000 Subject: [PATCH 14/20] follow => detach --- packages/hub/cli.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 615381fb4e..af0cb5c34a 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -389,9 +389,9 @@ const commands = { default: process.env.HF_TOKEN, }, { - name: "follow" as const, - short: "f", - description: "Stream logs after creating the job", + name: "detach" as const, + short: "d", + description: "Don't stream logs after creating the job", boolean: true, }, ] as const, @@ -773,7 +773,7 @@ async function run() { attempts: attemptsStr, namespace, token, - follow, + detach, } = parsedArgs; const envVars = env; let attempts: number | undefined; @@ -850,7 +850,7 @@ async function run() { console.log(`Job created: ${job.id}`); console.log(`Status: ${job.status.stage}`); - if (follow) { + if (!detach) { const logsParams = { namespace: finalNamespace, jobId: job.id, From 46f9e94e2f288122a963602df8ff71b87de9415e Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Tue, 20 Jan 2026 15:29:39 +0000 Subject: [PATCH 15/20] support secrets too --- packages/hub/cli.ts | 26 ++++++++++++++++++- .../hub/src/lib/jobs/create-scheduled-job.ts | 4 +-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index af0cb5c34a..8401c1d65e 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -365,6 +365,13 @@ const commands = { multiple: true, description: "Environment variable in the format KEY=VALUE (can be specified multiple times)", }, + { + name: "secret" as const, + short: "s", + multiple: true, + description: + "Secret in the format KEY=VALUE (will be encrypted server-side, can be specified multiple times)", + }, { name: "flavor" as const, description: "Hardware flavor to use (defaults to cpu-basic)", @@ -768,6 +775,7 @@ async function run() { dockerImageOrSpace: firstArg, command: commandArray, env, + secret, flavor, name, attempts: attemptsStr, @@ -776,6 +784,7 @@ async function run() { detach, } = parsedArgs; const envVars = env; + const secretVars = secret; let attempts: number | undefined; if (attemptsStr) { const parsed = parseInt(attemptsStr, 10); @@ -832,6 +841,20 @@ async function run() { environment[key] = value; } + // Parse secrets + const secrets: Record = {}; + if (secretVars) { + for (const secretVar of secretVars) { + const equalIndex = secretVar.indexOf("="); + if (equalIndex === -1) { + throw new Error(`Invalid secret format: ${secretVar}. Expected KEY=VALUE`); + } + const key = secretVar.slice(0, equalIndex); + const value = secretVar.slice(equalIndex + 1); + secrets[key] = value; + } + } + const jobParams = { namespace: finalNamespace, ...(name ? { name } : {}), @@ -839,7 +862,8 @@ async function run() { ...(spaceId ? { spaceId } : {}), flavor: flavor as SpaceHardwareFlavor, command: commandArray.length > 0 ? commandArray : undefined, - environment: Object.keys(environment).length > 0 ? environment : {}, + environment, + secrets, ...(attempts !== undefined ? { attempts } : {}), hubUrl: process.env.HF_ENDPOINT ?? HUB_URL, ...(token ? { accessToken: token } : {}), diff --git a/packages/hub/src/lib/jobs/create-scheduled-job.ts b/packages/hub/src/lib/jobs/create-scheduled-job.ts index 8b76b8efd0..d9b37fa248 100644 --- a/packages/hub/src/lib/jobs/create-scheduled-job.ts +++ b/packages/hub/src/lib/jobs/create-scheduled-job.ts @@ -51,9 +51,7 @@ export async function createScheduledJob( if (rest.jobSpec.command) { (body.jobSpec as Record).command = rest.jobSpec.command; } - if (rest.jobSpec.environment) { - (body.jobSpec as Record).environment = rest.jobSpec.environment; - } + (body.jobSpec as Record).environment = rest.jobSpec.environment || {}; if (rest.jobSpec.secrets) { (body.jobSpec as Record).secrets = rest.jobSpec.secrets; } From bd5adaed96b6d8c678940068bd9646a09a85291b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Jan 2026 16:12:24 +0000 Subject: [PATCH 16/20] Fix null checks in CLI argument parsing - Add null check for envVars before iterating (matching secretVars pattern) - Add existence check for providedPositionalTokens[i] before accessing .value Co-authored-by: eliott --- packages/hub/cli.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 8401c1d65e..3b2a7bd841 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -831,14 +831,16 @@ async function run() { // Parse environment variables const environment: Record = {}; - for (const envVar of envVars) { - const equalIndex = envVar.indexOf("="); - if (equalIndex === -1) { - throw new Error(`Invalid environment variable format: ${envVar}. Expected KEY=VALUE`); + if (envVars) { + for (const envVar of envVars) { + const equalIndex = envVar.indexOf("="); + if (equalIndex === -1) { + throw new Error(`Invalid environment variable format: ${envVar}. Expected KEY=VALUE`); + } + const key = envVar.slice(0, equalIndex); + const value = envVar.slice(equalIndex + 1); + environment[key] = value; } - const key = envVar.slice(0, equalIndex); - const value = envVar.slice(equalIndex + 1); - environment[key] = value; } // Parse secrets @@ -1246,7 +1248,7 @@ function advParseArgs( expectedPositionals.forEach((argDef, i) => { if (argDef.multiple) { result[argDef.name] = providedPositionalTokens.slice(i).map((token) => token.value); - } else { + } else if (providedPositionalTokens[i]) { result[argDef.name] = providedPositionalTokens[i].value; } }); From 2ea434979408765820c1a4d3153503171ad3621d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Jan 2026 16:47:34 +0000 Subject: [PATCH 17/20] Fix type for multiple options and Space ID detection - Fix ParsedArgsResult type: multiple optional options can be undefined - Use hf.co/ prefix to identify Space IDs, otherwise treat as Docker image This fixes the issue where org/image Docker images without tags were misidentified as HuggingFace Space IDs Co-authored-by: eliott --- packages/hub/cli.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 3b2a7bd841..c0d18db72e 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -798,16 +798,18 @@ async function run() { let dockerImage: string | undefined; let spaceId: string | undefined; - // Check for hf.co/spaces/... format - const hfCoSpacesMatch = firstArg.match(/^hf\.co\/spaces\/(.+)$/); - if (hfCoSpacesMatch) { - spaceId = hfCoSpacesMatch[1]; - } else if (firstArg.includes("/") && !firstArg.includes(":")) { - // If it contains a slash but no colon, assume it's a space ID (namespace/space-name) - // Docker images typically have colons (e.g., python:3.12) - spaceId = firstArg; + // Check for hf.co/ prefix to identify Space IDs + if (firstArg.startsWith("hf.co/")) { + // hf.co/spaces/namespace/space-name or hf.co/namespace/space-name + const hfCoSpacesMatch = firstArg.match(/^hf\.co\/spaces\/(.+)$/); + if (hfCoSpacesMatch) { + spaceId = hfCoSpacesMatch[1]; + } else { + // hf.co/namespace/space-name format + spaceId = firstArg.slice("hf.co/".length); + } } else { - // Otherwise, treat it as a docker image + // Everything else is treated as a docker image dockerImage = firstArg; } @@ -1177,7 +1179,7 @@ type ParsedArgsResult = { : string : K["default"] extends undefined ? K["multiple"] extends true - ? string[] // Multiple optional strings are arrays + ? string[] | undefined // Multiple optional strings are arrays or undefined if not provided : string | undefined // Optional strings without default can be undefined : K["multiple"] extends true ? string[] // Multiple strings with default are arrays From e3e912e4eb1f806a502c92fc141a6b7e18643859 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Jan 2026 16:50:41 +0000 Subject: [PATCH 18/20] Simplify Space ID detection: only handle hf.co/spaces/* format Everything else is treated as a Docker image. Co-authored-by: eliott --- packages/hub/cli.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index c0d18db72e..8f17cb9e5d 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -798,16 +798,10 @@ async function run() { let dockerImage: string | undefined; let spaceId: string | undefined; - // Check for hf.co/ prefix to identify Space IDs - if (firstArg.startsWith("hf.co/")) { - // hf.co/spaces/namespace/space-name or hf.co/namespace/space-name - const hfCoSpacesMatch = firstArg.match(/^hf\.co\/spaces\/(.+)$/); - if (hfCoSpacesMatch) { - spaceId = hfCoSpacesMatch[1]; - } else { - // hf.co/namespace/space-name format - spaceId = firstArg.slice("hf.co/".length); - } + // Check for hf.co/spaces/* format to identify Space IDs + const hfCoSpacesMatch = firstArg.match(/^hf\.co\/spaces\/(.+)$/); + if (hfCoSpacesMatch) { + spaceId = hfCoSpacesMatch[1]; } else { // Everything else is treated as a docker image dockerImage = firstArg; From 891dc82e570c93733c78a7e68993bb4c9b08c463 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Thu, 22 Jan 2026 16:33:24 +0000 Subject: [PATCH 19/20] remove name param --- packages/hub/cli.ts | 13 +++---------- packages/hub/src/lib/jobs/run-job.ts | 3 --- packages/hub/src/types/api/api-jobs.ts | 5 ----- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index 8f17cb9e5d..f24a4f097b 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -377,10 +377,6 @@ const commands = { description: "Hardware flavor to use (defaults to cpu-basic)", default: "cpu-basic", }, - { - name: "name" as const, - description: "Optional name for the job", - }, { name: "attempts" as const, description: "Maximum number of attempts (defaults to 1)", @@ -777,7 +773,6 @@ async function run() { env, secret, flavor, - name, attempts: attemptsStr, namespace, token, @@ -855,7 +850,6 @@ async function run() { const jobParams = { namespace: finalNamespace, - ...(name ? { name } : {}), ...(dockerImage ? { dockerImage } : {}), ...(spaceId ? { spaceId } : {}), flavor: flavor as SpaceHardwareFlavor, @@ -925,16 +919,15 @@ async function run() { // Display jobs in a table-like format console.log( - `${"ID".padEnd(40)} ${"NAME".padEnd(20)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`, + `${"ID".padEnd(40)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`, ); - console.log("-".repeat(120)); + console.log("-".repeat(100)); for (const job of filteredJobs) { const createdAt = new Date(job.createdAt).toLocaleString(); const dockerImage = job.dockerImage || job.spaceId || "N/A"; - const jobName = job.name || "N/A"; const status = job.status.stage; console.log( - `${job.id.padEnd(40)} ${jobName.padEnd(20)} ${status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`, + `${job.id.padEnd(40)} ${status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`, ); } break; diff --git a/packages/hub/src/lib/jobs/run-job.ts b/packages/hub/src/lib/jobs/run-job.ts index bd9d717ca2..0779e591df 100644 --- a/packages/hub/src/lib/jobs/run-job.ts +++ b/packages/hub/src/lib/jobs/run-job.ts @@ -36,9 +36,6 @@ export async function runJob( environment: params.environment || {}, }; - if (params.name) { - body.name = params.name; - } if (params.dockerImage) { body.dockerImage = params.dockerImage; } diff --git a/packages/hub/src/types/api/api-jobs.ts b/packages/hub/src/types/api/api-jobs.ts index 569f0e1841..f30d195915 100644 --- a/packages/hub/src/types/api/api-jobs.ts +++ b/packages/hub/src/types/api/api-jobs.ts @@ -44,7 +44,6 @@ export interface ApiJob { id: string; name: string; }; - name?: string | null; dockerImage?: string | null; spaceId?: string | null; command?: string[] | null; @@ -79,10 +78,6 @@ export interface ApiScheduledJob { } export interface CreateJobOptions { - /** - * Optional name for the job - */ - name?: string; /** * The Docker image to run (e.g., "python:3.12" or "pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel") */ From b1e9e872f35a93f5149809646d92469ff25d4f70 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Thu, 22 Jan 2026 16:36:30 +0000 Subject: [PATCH 20/20] format --- packages/hub/cli.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/hub/cli.ts b/packages/hub/cli.ts index f24a4f097b..1bef2d240c 100644 --- a/packages/hub/cli.ts +++ b/packages/hub/cli.ts @@ -918,17 +918,13 @@ async function run() { } // Display jobs in a table-like format - console.log( - `${"ID".padEnd(40)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`, - ); + console.log(`${"ID".padEnd(40)} ${"STATUS".padEnd(12)} ${"CREATED".padEnd(20)} ${"DOCKER IMAGE"}`); console.log("-".repeat(100)); for (const job of filteredJobs) { const createdAt = new Date(job.createdAt).toLocaleString(); const dockerImage = job.dockerImage || job.spaceId || "N/A"; const status = job.status.stage; - console.log( - `${job.id.padEnd(40)} ${status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`, - ); + console.log(`${job.id.padEnd(40)} ${status.padEnd(12)} ${createdAt.padEnd(20)} ${dockerImage}`); } break; }