Skip to content

Commit 80faefb

Browse files
committed
cli file
1 parent b175cf4 commit 80faefb

File tree

4 files changed

+291
-1
lines changed

4 files changed

+291
-1
lines changed

packages/hub/cli.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
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+
}

packages/hub/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757
"hugging",
5858
"face"
5959
],
60+
"bin": {
61+
"@huggingface/hub": "./dist/cli.js"
62+
},
6063
"author": "Hugging Face",
6164
"license": "MIT",
6265
"devDependencies": {

packages/hub/src/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ export * from "./check-repo-access";
33
export * from "./commit";
44
export * from "./count-commits";
55
export * from "./create-repo";
6+
export * from "./create-branch";
67
export * from "./dataset-info";
8+
export * from "./delete-branch";
79
export * from "./delete-file";
810
export * from "./delete-files";
911
export * from "./delete-repo";

packages/hub/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@
1515
"declaration": true,
1616
"declarationMap": true
1717
},
18-
"include": ["src", "index.ts"],
18+
"include": ["src", "index.ts", "cli.ts"],
1919
"exclude": ["dist"]
2020
}

0 commit comments

Comments
 (0)