|
| 1 | +#! /usr/bin/env node |
| 2 | + |
| 3 | +import { parseArgs } from "node:util"; |
| 4 | +import { typedEntries } from "./src/utils/typedEntries"; |
| 5 | +import { createBranch, uploadFilesWithProgress } from "./src"; |
| 6 | +import { pathToFileURL } from "node:url"; |
| 7 | + |
| 8 | +const command = process.argv[2]; |
| 9 | +const args = process.argv.slice(3); |
| 10 | + |
| 11 | +type Camelize<T extends string> = T extends `${infer A}-${infer B}` ? `${A}${Camelize<Capitalize<B>>}` : T; |
| 12 | +
|
| 13 | +const commands = { |
| 14 | + upload: { |
| 15 | + description: "Upload a folder to a repo on the Hub", |
| 16 | + args: [ |
| 17 | + { |
| 18 | + name: "repo-name" as const, |
| 19 | + description: "The name of the repo to create", |
| 20 | + positional: true, |
| 21 | + required: true, |
| 22 | + }, |
| 23 | + { |
| 24 | + name: "local-folder" as const, |
| 25 | + description: "The local folder to upload. Defaults to the current working directory", |
| 26 | + positional: true, |
| 27 | + default: () => process.cwd(), |
| 28 | + }, |
| 29 | + // { |
| 30 | + // name: "path-in-repo" as const, |
| 31 | + // description: "The path in the repo to upload the folder to. Defaults to the root of the repo", |
| 32 | + // positional: true, |
| 33 | + // default: "/", |
| 34 | + // }, |
| 35 | + { |
| 36 | + name: "quiet" as const, |
| 37 | + short: "q", |
| 38 | + description: "Suppress all output", |
| 39 | + boolean: true, |
| 40 | + }, |
| 41 | + { |
| 42 | + name: "repo-type" as const, |
| 43 | + short: "t", |
| 44 | + enum: ["dataset", "model", "space"], |
| 45 | + default: "model", |
| 46 | + description: |
| 47 | + "The type of repo to upload to. Defaults to model. You can also prefix the repo name with the type, e.g. datasets/username/repo-name", |
| 48 | + }, |
| 49 | + { |
| 50 | + name: "revision" as const, |
| 51 | + short: "r", |
| 52 | + description: "The revision to upload to. Defaults to the main branch", |
| 53 | + default: "main", |
| 54 | + }, |
| 55 | + { |
| 56 | + name: "from-revision" as const, |
| 57 | + short: "c", |
| 58 | + description: |
| 59 | + "The revision to upload from. Defaults to the latest commit on main or on the branch if it exists.", |
| 60 | + }, |
| 61 | + { |
| 62 | + name: "from-empty" as const, |
| 63 | + short: "e", |
| 64 | + boolean: true, |
| 65 | + description: |
| 66 | + "This will create an empty branch and upload the files to it. This will erase all previous commits on the branch if it exists.", |
| 67 | + }, |
| 68 | + { |
| 69 | + name: "token" as const, |
| 70 | + short: "k", |
| 71 | + description: |
| 72 | + "The access token to use for authentication. If not provided, the HF_TOKEN environment variable will be used.", |
| 73 | + default: process.env.HF_TOKEN, |
| 74 | + }, |
| 75 | + ], |
| 76 | + }, |
| 77 | +} satisfies Record< |
| 78 | + string, |
| 79 | + { |
| 80 | + description: string; |
| 81 | + args?: Array<{ |
| 82 | + name: string; |
| 83 | + short?: string; |
| 84 | + positional?: boolean; |
| 85 | + description?: string; |
| 86 | + required?: boolean; |
| 87 | + boolean?: boolean; |
| 88 | + enum?: Array<string>; |
| 89 | + default?: string | (() => string); |
| 90 | + }>; |
| 91 | + } |
| 92 | +>; |
| 93 | +
|
| 94 | +type Command = keyof typeof commands; |
| 95 | +
|
| 96 | +async function run() { |
| 97 | + switch (command) { |
| 98 | + case "help": { |
| 99 | + const positionals = parseArgs({ allowPositionals: true, args }).positionals; |
| 100 | +
|
| 101 | + if (positionals.length > 0 && positionals[0] in commands) { |
| 102 | + const commandName = positionals[0] as Command; |
| 103 | + console.log(detailedUsage(commandName)); |
| 104 | + break; |
| 105 | + } |
| 106 | +
|
| 107 | + console.log( |
| 108 | + `Available commands\n\n` + |
| 109 | + typedEntries(commands) |
| 110 | + .map(([name, { description }]) => `- ${usage(name)}: ${description}`) |
| 111 | + .join("\n") |
| 112 | + ); |
| 113 | + break; |
| 114 | + } |
| 115 | + |
| 116 | + case "upload": { |
| 117 | + if (args[1] === "--help" || args[1] === "-h") { |
| 118 | + console.log(usage("upload")); |
| 119 | + break; |
| 120 | + } |
| 121 | + const parsedArgs = advParseArgs(args, "upload"); |
| 122 | + const { repoName, localFolder, repoType, revision, fromEmpty, fromRevision, token, quiet } = parsedArgs; |
| 123 | + |
| 124 | + if (revision && (fromEmpty || fromRevision)) { |
| 125 | + await createBranch({ |
| 126 | + branch: revision, |
| 127 | + repo: repoType ? { type: repoType as "model" | "dataset" | "space", name: repoName } : repoName, |
| 128 | + accessToken: token, |
| 129 | + revision: fromRevision, |
| 130 | + empty: fromEmpty ? true : undefined, |
| 131 | + overwrite: true, |
| 132 | + }); |
| 133 | + } |
| 134 | + |
| 135 | + for await (const event of uploadFilesWithProgress({ |
| 136 | + repo: repoType ? { type: repoType as "model" | "dataset" | "space", name: repoName } : repoName, |
| 137 | + files: [pathToFileURL(localFolder)], |
| 138 | + branch: revision, |
| 139 | + accessToken: token, |
| 140 | + })) { |
| 141 | + if (!quiet) { |
| 142 | + console.log(event); |
| 143 | + } |
| 144 | + } |
| 145 | + break; |
| 146 | + } |
| 147 | + default: |
| 148 | + throw new Error("Command not found: " + command); |
| 149 | + } |
| 150 | +} |
| 151 | +run(); |
| 152 | + |
| 153 | +function usage(commandName: Command) { |
| 154 | + const command = commands[commandName]; |
| 155 | + |
| 156 | + return `${commandName} ${(command.args || []) |
| 157 | + .map((arg) => { |
| 158 | + if (arg.positional) { |
| 159 | + if (arg.required) { |
| 160 | + return `<${arg.name}>`; |
| 161 | + } else { |
| 162 | + return `[${arg.name}]`; |
| 163 | + } |
| 164 | + } |
| 165 | + return `[--${arg.name} ${arg.enum ? `{${arg.enum.join(",")}}` : arg.name.toLocaleUpperCase()}]`; |
| 166 | + }) |
| 167 | + .join("")}`.trim(); |
| 168 | +} |
| 169 | + |
| 170 | +function detailedUsage(commandName: Command) { |
| 171 | + let ret = `usage: ${usage(commandName)}\n\n`; |
| 172 | + const command = commands[commandName]; |
| 173 | + |
| 174 | + if (command.args.some((p) => p.positional)) { |
| 175 | + ret += `Positional arguments:\n`; |
| 176 | + |
| 177 | + for (const arg of command.args) { |
| 178 | + if (arg.positional) { |
| 179 | + ret += ` ${arg.name}: ${arg.description}\n`; |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + ret += `\n`; |
| 184 | + } |
| 185 | + |
| 186 | + if (command.args.some((p) => !p.positional)) { |
| 187 | + ret += `Options:\n`; |
| 188 | + |
| 189 | + for (const arg of command.args) { |
| 190 | + if (!arg.positional) { |
| 191 | + ret += ` --${arg.name}${arg.short ? `, -${arg.short}` : ""}: ${arg.description}\n`; |
| 192 | + } |
| 193 | + } |
| 194 | + |
| 195 | + ret += `\n`; |
| 196 | + } |
| 197 | + |
| 198 | + return ret; |
| 199 | +} |
| 200 | + |
| 201 | +function advParseArgs<C extends Command>( |
| 202 | + args: string[], |
| 203 | + commandName: C |
| 204 | +): { |
| 205 | + // Todo : better typing |
| 206 | + [key in Camelize<(typeof commands)[C]["args"][number]["name"]>]: string; |
| 207 | +} { |
| 208 | + const { tokens } = parseArgs({ |
| 209 | + options: Object.fromEntries( |
| 210 | + commands[commandName].args |
| 211 | + .filter((arg) => !arg.positional) |
| 212 | + .map((arg) => { |
| 213 | + const option = { |
| 214 | + name: arg.name, |
| 215 | + short: arg.short, |
| 216 | + type: arg.boolean ? "boolean" : "string", |
| 217 | + } as const; |
| 218 | + return [arg.name, option]; |
| 219 | + }) |
| 220 | + ), |
| 221 | + args, |
| 222 | + allowPositionals: true, |
| 223 | + strict: false, |
| 224 | + tokens: true, |
| 225 | + }); |
| 226 | + |
| 227 | + const command = commands[commandName]; |
| 228 | + const expectedPositionals = command.args.filter((arg) => arg.positional); |
| 229 | + const requiredPositionals = expectedPositionals.filter((arg) => arg.required).length; |
| 230 | + const providedPositionals = tokens.filter((token) => token.kind === "positional").length; |
| 231 | + |
| 232 | + if (providedPositionals < requiredPositionals) { |
| 233 | + throw new Error( |
| 234 | + `Missing required positional arguments. Expected: ${requiredPositionals}, Provided: ${providedPositionals}` |
| 235 | + ); |
| 236 | + } |
| 237 | + |
| 238 | + if (providedPositionals > expectedPositionals.length) { |
| 239 | + throw new Error( |
| 240 | + `Too many positional arguments. Expected: ${expectedPositionals.length}, Provided: ${providedPositionals}` |
| 241 | + ); |
| 242 | + } |
| 243 | + |
| 244 | + const positionals = Object.fromEntries( |
| 245 | + tokens.filter((token) => token.kind === "positional").map((token, i) => [expectedPositionals[i].name, token.value]) |
| 246 | + ); |
| 247 | + |
| 248 | + const options = Object.fromEntries( |
| 249 | + tokens |
| 250 | + .filter((token) => token.kind === "option") |
| 251 | + .map((token) => { |
| 252 | + const arg = command.args.find((arg) => arg.name === token.name || arg.short === token.name); |
| 253 | + if (!arg) { |
| 254 | + throw new Error(`Unknown option: ${token.name}`); |
| 255 | + } |
| 256 | + |
| 257 | + if (!token.value) { |
| 258 | + throw new Error(`Missing value for option: ${token.name}`); |
| 259 | + } |
| 260 | + |
| 261 | + if (arg.enum && !arg.enum.includes(token.value)) { |
| 262 | + throw new Error(`Invalid value for option ${token.name}. Expected one of: ${arg.enum.join(", ")}`); |
| 263 | + } |
| 264 | + |
| 265 | + return [arg.name, arg.boolean ? true : token.value]; |
| 266 | + }) |
| 267 | + ); |
| 268 | + const defaults = Object.fromEntries( |
| 269 | + command.args |
| 270 | + .filter((arg) => arg.default) |
| 271 | + .map((arg) => { |
| 272 | + const value = typeof arg.default === "function" ? arg.default() : arg.default; |
| 273 | + return [arg.name, value]; |
| 274 | + }) |
| 275 | + ); |
| 276 | + return Object.fromEntries( |
| 277 | + Object.entries({ ...defaults, ...positionals, ...options }).map(([name, val]) => [kebabToCamelCase(name), val]) |
| 278 | + ) as { |
| 279 | + [key in Camelize<(typeof commands)[C]["args"][number]["name"]>]: string; |
| 280 | + }; |
| 281 | +} |
| 282 | + |
| 283 | +function kebabToCamelCase(str: string) { |
| 284 | + return str.replace(/-./g, (match) => match[1].toUpperCase()); |
| 285 | +} |
0 commit comments