diff --git a/src/command/publish/cmd.ts b/src/command/publish/cmd.ts index 1d5dd91854..d56dbf6ab7 100644 --- a/src/command/publish/cmd.ts +++ b/src/command/publish/cmd.ts @@ -91,6 +91,10 @@ export const publishCommand = "Publish document (prompt for provider)", "quarto publish document.qmd", ) + .example( + "Publish project to Hugging Face Spaces", + "quarto publish huggingface", + ) .example( "Publish project to Netlify", "quarto publish netlify", @@ -163,7 +167,7 @@ async function publishAction( await initYamlIntelligence(); // coalesce options - const publishOptions = await createPublishOptions(options, path); + const publishOptions = await createPublishOptions(options, provider, path); // helper to publish (w/ account confirmation) const doPublish = async ( @@ -302,6 +306,7 @@ async function publish( async function createPublishOptions( options: PublishCommandOptions, + provider?: PublishProvider, path?: string, ): Promise { const nbContext = notebookContext(); @@ -315,27 +320,30 @@ async function createPublishOptions( // determine publish input let input: ProjectContext | string | undefined; + if (provider && provider.resolveProjectPath) { + const resolvedPath = provider.resolveProjectPath(path); + try { + if (Deno.statSync(resolvedPath).isDirectory) { + path = resolvedPath; + } + } catch (_e) { + // ignore + } + } + // check for directory (either website or single-file project) const project = (await projectContext(path, nbContext)) || singleFileProjectContext(path, nbContext); if (Deno.statSync(path).isDirectory) { - if (project) { - if (projectIsWebsite(project)) { - input = project; - } else if ( - projectIsManuscript(project) && project.files.input.length > 0 - ) { - input = project; - } else if (project.files.input.length === 1) { - input = project.files.input[0]; - } + if (projectIsWebsite(project)) { + input = project; + } else if ( + projectIsManuscript(project) && project.files.input.length > 0 + ) { + input = project; + } else if (project.files.input.length === 1) { + input = project.files.input[0]; } else { - const inputFiles = await projectInputFiles(project); - if (inputFiles.files.length === 1) { - input = inputFiles.files[0]; - } - } - if (!input) { throw new Error( `The specified path (${path}) is not a website, manuscript or book project so cannot be published.`, ); diff --git a/src/core/git.ts b/src/core/git.ts index 8acbb71ec0..6a102dd0ff 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -6,6 +6,42 @@ import { which } from "./path.ts"; import { execProcess } from "./process.ts"; +import SemVer from "semver/mod.ts"; + +export async function gitCmds(dir: string, cmds: Array) { + for (const cmd of cmds) { + if ( + !(await execProcess({ + cmd: ["git", ...cmd], + cwd: dir, + })).success + ) { + throw new Error(); + } + } +} + +export async function gitVersion(): Promise { + const result = await execProcess( + { + cmd: ["git", "--version"], + stdout: "piped", + }, + ); + if (!result.success) { + throw new Error( + "Unable to determine git version. Please check that git is installed and available on your PATH.", + ); + } + const match = result.stdout?.match(/git version (\d+\.\d+\.\d+)/); + if (match) { + return new SemVer(match[1]); + } else { + throw new Error( + `Unable to determine git version from string ${result.stdout}`, + ); + } +} export async function lsFiles( cwd?: string, diff --git a/src/publish/common/errors.ts b/src/publish/common/errors.ts new file mode 100644 index 0000000000..9981c13233 --- /dev/null +++ b/src/publish/common/errors.ts @@ -0,0 +1,11 @@ +/* + * errors.ts + * + * Copyright (C) 2020-2024 Posit Software, PBC + */ + +export const throwUnableToPublish = (reason: string, provider: string) => { + throw new Error( + `Unable to publish to ${provider} (${reason})`, + ); +}; diff --git a/src/publish/common/git.ts b/src/publish/common/git.ts new file mode 100644 index 0000000000..e26e5eb3e2 --- /dev/null +++ b/src/publish/common/git.ts @@ -0,0 +1,66 @@ +/* + * git.ts + * + * Copyright (C) 2020-2024 Posit Software, PBC + */ + +import { websiteBaseurl } from "../../project/types/website/website-config.ts"; +import { gitHubContext } from "../../core/github.ts"; +import { ProjectContext } from "../../project/types.ts"; +import { dirname } from "../../deno_ral/path.ts"; +import { AccountToken, AccountTokenType } from "../provider-types.ts"; +import { PublishOptions } from "../types.ts"; +import { GitHubContext } from "../../core/github-types.ts"; +import { throwUnableToPublish } from "./errors.ts"; + +export async function gitHubContextForPublish(input: string | ProjectContext) { + // Create the base context + const dir = typeof input === "string" ? dirname(input) : input.dir; + const context = await gitHubContext(dir); + + // always prefer configured website URL + if (typeof input !== "string") { + const configSiteUrl = websiteBaseurl(input?.config); + if (configSiteUrl) { + context.siteUrl = configSiteUrl; + } + } + return context; +} + +export function anonymousAccount(): AccountToken { + return { + type: AccountTokenType.Anonymous, + name: "anonymous", + server: null, + token: "anonymous", + }; +} + +export function verifyContext( + ghContext: GitHubContext, + provider: string, +) { + if (!ghContext.git) { + throwUnableToPublish( + "git does not appear to be installed on this system", + provider, + ); + } + + // validate we are in a git repo + if (!ghContext.repo) { + throwUnableToPublish( + "the target directory is not a git repository", + provider, + ); + } + + // validate that we have an origin + if (!ghContext.originUrl) { + throwUnableToPublish( + "the git repository does not have a remote origin", + provider, + ); + } +} diff --git a/src/publish/gh-pages/gh-pages.ts b/src/publish/gh-pages/gh-pages.ts index 58bab65c86..424001d6cd 100644 --- a/src/publish/gh-pages/gh-pages.ts +++ b/src/publish/gh-pages/gh-pages.ts @@ -17,7 +17,6 @@ import { execProcess } from "../../core/process.ts"; import { ProjectContext } from "../../project/types.ts"; import { AccountToken, - AccountTokenType, PublishFiles, PublishProvider, } from "../provider-types.ts"; @@ -27,10 +26,13 @@ import { sleep } from "../../core/wait.ts"; import { joinUrl } from "../../core/url.ts"; import { completeMessage, withSpinner } from "../../core/console.ts"; import { renderForPublish } from "../common/publish.ts"; -import { websiteBaseurl } from "../../project/types/website/website-config.ts"; import { RenderFlags } from "../../command/render/types.ts"; -import SemVer from "semver/mod.ts"; -import { gitHubContext } from "../../core/github.ts"; +import { gitCmds, gitVersion } from "../../core/git.ts"; +import { + anonymousAccount, + gitHubContextForPublish, + verifyContext, +} from "../common/git.ts"; export const kGhpages = "gh-pages"; const kGhpagesDescription = "GitHub Pages"; @@ -50,35 +52,13 @@ export const ghpagesProvider: PublishProvider = { isNotFound, }; -function anonymousAccount(): AccountToken { - return { - type: AccountTokenType.Anonymous, - name: "anonymous", - server: null, - token: "anonymous", - }; -} - function accountTokens() { return Promise.resolve([anonymousAccount()]); } async function authorizeToken(options: PublishOptions) { const ghContext = await gitHubContextForPublish(options.input); - - if (!ghContext.git) { - throwUnableToPublish("git does not appear to be installed on this system"); - } - - // validate we are in a git repo - if (!ghContext.repo) { - throwUnableToPublish("the target directory is not a git repository"); - } - - // validate that we have an origin - if (!ghContext.originUrl) { - throwUnableToPublish("the git repository does not have a remote origin"); - } + verifyContext(ghContext, "GitHub Pages"); // good to go! return Promise.resolve(anonymousAccount()); @@ -106,28 +86,6 @@ function resolveTarget( return Promise.resolve(target); } -async function gitVersion(): Promise { - const result = await execProcess( - { - cmd: ["git", "--version"], - stdout: "piped", - }, - ); - if (!result.success) { - throw new Error( - "Unable to determine git version. Please check that git is installed and available on your PATH.", - ); - } - const match = result.stdout?.match(/git version (\d+\.\d+\.\d+)/); - if (match) { - return new SemVer(match[1]); - } else { - throw new Error( - `Unable to determine git version from string ${result.stdout}`, - ); - } -} - async function publish( _account: AccountToken, type: "document" | "site", @@ -405,38 +363,3 @@ async function gitCreateGhPages(dir: string) { ["push", "origin", `HEAD:gh-pages`], ]); } - -async function gitCmds(dir: string, cmds: Array) { - for (const cmd of cmds) { - if ( - !(await execProcess({ - cmd: ["git", ...cmd], - cwd: dir, - })).success - ) { - throw new Error(); - } - } -} - -// validate we have git -const throwUnableToPublish = (reason: string) => { - throw new Error( - `Unable to publish to GitHub Pages (${reason})`, - ); -}; - -async function gitHubContextForPublish(input: string | ProjectContext) { - // Create the base context - const dir = typeof input === "string" ? dirname(input) : input.dir; - const context = await gitHubContext(dir); - - // always prefer configured website URL - if (typeof input !== "string") { - const configSiteUrl = websiteBaseurl(input?.config); - if (configSiteUrl) { - context.siteUrl = configSiteUrl; - } - } - return context; -} diff --git a/src/publish/huggingface/huggingface.ts b/src/publish/huggingface/huggingface.ts new file mode 100644 index 0000000000..7535c25f93 --- /dev/null +++ b/src/publish/huggingface/huggingface.ts @@ -0,0 +1,191 @@ +/* + * huggingface.ts + * + * Copyright (C) 2020-2024 Posit Software, PBC + */ + +import { info } from "../../deno_ral/log.ts"; +import { dirname, join } from "../../deno_ral/path.ts"; +import * as colors from "fmt/colors.ts"; +import { ProjectContext } from "../../project/types.ts"; +import { + AccountToken, + PublishFiles, + PublishProvider, +} from "../provider-types.ts"; +import { PublishOptions, PublishRecord } from "../types.ts"; +import { RenderFlags } from "../../command/render/types.ts"; +import { gitCmds, gitVersion } from "../../core/git.ts"; +import { + anonymousAccount, + gitHubContextForPublish, + verifyContext, +} from "../common/git.ts"; +import { throwUnableToPublish } from "../common/errors.ts"; +import { Input } from "cliffy/prompt/input.ts"; +import { assert } from "testing/asserts.ts"; +import { Secret } from "cliffy/prompt/secret.ts"; + +export const kHuggingFace = "huggingface"; +const kHuggingFaceDescription = "Hugging Face Spaces"; + +export const huggingfaceProvider: PublishProvider = { + name: kHuggingFace, + description: kHuggingFaceDescription, + requiresServer: false, + listOriginOnly: false, + accountTokens: () => Promise.resolve([anonymousAccount()]), + authorizeToken, + removeToken: () => {}, + publishRecord, + resolveTarget: ( + _account: AccountToken, + target: PublishRecord, + ): Promise => Promise.resolve(target), + publish, + isUnauthorized: () => false, + isNotFound: () => false, + resolveProjectPath: (path: string) => join(path, "src"), +}; + +async function authorizeToken(options: PublishOptions) { + const ghContext = await gitHubContextForPublish(options.input); + const provider = "Hugging Face Spaces"; + verifyContext(ghContext, provider); + + if ( + !ghContext.originUrl!.match(/^https:\/\/(.*:.*@)?huggingface.co\/spaces\//) + ) { + throwUnableToPublish( + "the git repository does not appear to have a Hugging Face Space origin", + provider, + ); + } + + // good to go! + return Promise.resolve(anonymousAccount()); +} + +async function publishRecord( + input: string | ProjectContext, +): Promise { + const ghContext = await gitHubContextForPublish(input); + if (ghContext.ghPages) { + return { + id: kHuggingFace, + url: ghContext.siteUrl || ghContext.originUrl, + }; + } +} + +async function publish( + _account: AccountToken, + _type: "document" | "site", + input: string, + _title: string, + _slug: string, + _render: (flags?: RenderFlags) => Promise, + options: PublishOptions, + _target?: PublishRecord, +): Promise<[PublishRecord | undefined, URL | undefined]> { + // convert input to dir if necessary + input = Deno.statSync(input).isDirectory ? input : dirname(input); + + // check if git version is new enough + const version = await gitVersion(); + + // git 2.17.0 appears to be the first to support git-worktree add --track + // https://github.com/git/git/blob/master/Documentation/RelNotes/2.17.0.txt#L368 + if (version.compare("2.17.0") < 0) { + throw new Error( + "git version 2.17.0 or higher is required to publish to GitHub Pages", + ); + } + + // get context + const ghContext = await gitHubContextForPublish(options.input); + + if ( + !ghContext.originUrl!.match(/^https:\/\/.*:.*@huggingface.co\/spaces\//) + ) { + const previousRemotePath = ghContext.originUrl!.match( + /.*huggingface.co(\/spaces\/.*)/, + ); + assert(previousRemotePath); + info(colors.yellow([ + "The current git repository needs to be reconfigured to allow `quarto publish`", + "to publish to Hugging Face Spaces. Please enter your username and authentication token.", + "Refer to https://huggingface.co/blog/password-git-deprecation#switching-to-personal-access-token", + "for more information on how to obtain a personal access token.", + ].join("\n"))); + const username = await Input.prompt({ + indent: "", + message: "Hugging Face username", + }); + const token = await Secret.prompt({ + indent: "", + message: "Hugging Face authentication token:", + hint: "Create a token at https://huggingface.co/settings/tokens", + }); + await gitCmds(input, [ + [ + "remote", + "set-url", + "origin", + `https://${username}:${token}@huggingface.co${previousRemotePath![1]}`, + ], + ]); + } + + // sync from remote and push to main + await gitCmds(input, [ + ["stash"], + ["fetch", "origin", "main"], + ]); + try { + await gitCmds(input, [ + ["merge", "origin/main"], + ]); + } catch (_e) { + info(colors.yellow([ + "Could not merge origin/main. This is likely because of git conflicts.", + "Please resolve those manually and run `quarto publish` again.", + ].join("\n"))); + return Promise.resolve([ + undefined, + undefined, + ]); + } + try { + await gitCmds(input, [ + ["stash", "pop"], + ]); + } catch (_e) { + info(colors.yellow([ + "Could not pop git stash.", + "This is likely because there are no changes to push to the repository.", + ].join("\n"))); + return Promise.resolve([ + undefined, + undefined, + ]); + } + await gitCmds(input, [ + ["add", "-Af", "."], + ["commit", "--allow-empty", "-m", "commit from `quarto publish`"], + ["push", "origin", "main"], + ]); + + // warn users about latency between push and remote publish + info(colors.yellow( + "NOTE: Hugging Face Space sites build the content remotely and use caching.\n" + + "You might need to wait a moment for Hugging Face to rebuild your site, and\n" + + "then click the refresh button within your web browser to see changes after deployment.\n", + )); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + return Promise.resolve([ + undefined, + new URL(ghContext.originUrl!), + ]); +} diff --git a/src/publish/provider-types.ts b/src/publish/provider-types.ts index 574cb86120..0c611b29a9 100644 --- a/src/publish/provider-types.ts +++ b/src/publish/provider-types.ts @@ -76,4 +76,9 @@ export interface PublishProvider { ) => Promise<[PublishRecord | undefined, URL | undefined]>; isUnauthorized: (error: Error) => boolean; isNotFound: (error: Error) => boolean; + + // if the provider has a quarto project path that is not the top level + // of the overall project (currently, huggingface spaces only), + // this function should return the path to the quarto project root + resolveProjectPath?: (path: string) => string; } diff --git a/src/publish/provider.ts b/src/publish/provider.ts index e4d3c2e461..afb067b2f5 100644 --- a/src/publish/provider.ts +++ b/src/publish/provider.ts @@ -10,7 +10,7 @@ import { quartoPubProvider } from "./quarto-pub/quarto-pub.ts"; import { rsconnectProvider } from "./rsconnect/rsconnect.ts"; import { positCloudProvider } from "./posit-cloud/posit-cloud.ts"; import { confluenceProvider } from "./confluence/confluence.ts"; -import { PublishProvider } from "./provider-types.ts"; +import { huggingfaceProvider } from "./huggingface/huggingface.ts"; import { AccountToken } from "./provider-types.ts"; export function accountTokenText(token: AccountToken) { @@ -24,17 +24,11 @@ const kPublishProviders = [ positCloudProvider, netlifyProvider, confluenceProvider, + huggingfaceProvider, ]; export function publishProviders() { - const providers: Array = []; - providers.push(quartoPubProvider); - providers.push(ghpagesProvider); - providers.push(rsconnectProvider); - providers.push(positCloudProvider); - providers.push(netlifyProvider); - providers.push(confluenceProvider); - return providers; + return kPublishProviders.slice(); } export function findProvider(name?: string) {