diff --git a/.changeset/brave-toes-work.md b/.changeset/brave-toes-work.md new file mode 100644 index 000000000..04c66f574 --- /dev/null +++ b/.changeset/brave-toes-work.md @@ -0,0 +1,5 @@ +--- +"@osdk/cli": patch +--- + +Support site snapshot upload diff --git a/.changeset/modern-spiders-sleep.md b/.changeset/modern-spiders-sleep.md new file mode 100644 index 000000000..bf42d2f03 --- /dev/null +++ b/.changeset/modern-spiders-sleep.md @@ -0,0 +1,5 @@ +--- +"@osdk/cli": patch +--- + +Site version and file limit custom error message and tips diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d147a7f1..ae86d60f6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,6 +35,7 @@ "consola": "^3.2.3", "find-up": "^7.0.0", "open": "^10.1.0", + "pretty-bytes": "^6.1.1", "semver": "^7.6.3", "tslib": "^2.6.3", "yargs": "^17.7.2" diff --git a/packages/cli/src/commands/site/deploy/SiteDeployArgs.ts b/packages/cli/src/commands/site/deploy/SiteDeployArgs.ts index dff9027f5..404b247e8 100644 --- a/packages/cli/src/commands/site/deploy/SiteDeployArgs.ts +++ b/packages/cli/src/commands/site/deploy/SiteDeployArgs.ts @@ -23,4 +23,6 @@ export interface SiteDeployArgs extends CommonSiteArgs { uploadOnly: boolean; autoVersion?: AutoVersionConfigType; gitTagPrefix?: string; + snapshot: boolean; + snapshotId?: string; } diff --git a/packages/cli/src/commands/site/deploy/index.ts b/packages/cli/src/commands/site/deploy/index.ts index eebe54064..e9e3d6634 100644 --- a/packages/cli/src/commands/site/deploy/index.ts +++ b/packages/cli/src/commands/site/deploy/index.ts @@ -78,6 +78,17 @@ const command: CommandModule< ? { default: gitTagPrefix } : {}, }, + snapshot: { + type: "boolean", + description: + "Upload a snapshot version only with automatic retention", + default: false, + }, + snapshotId: { + type: "string", + description: + "Optional id to associate with snapshot version as an alias", + }, }) .group( ["directory", "version", "uploadOnly"], @@ -87,6 +98,10 @@ const command: CommandModule< ["autoVersion", "gitTagPrefix"], "Auto Version Options", ) + .group( + ["snapshot", "snapshotId"], + "Snapshot Options", + ) .check((args) => { // This is required because we can't use demandOption with conflicts. conflicts protects us against the case where both are provided. // So this case is for when nothing is provided. @@ -120,6 +135,18 @@ const command: CommandModule< ); } + if (args.uploadOnly && args.snapshot) { + throw new YargsCheckError( + `--uploadOnly and --snapshot cannot be enabled together`, + ); + } + + if (args.snapshotId != null && !args.snapshot) { + throw new YargsCheckError( + "--snapshotId is only supported when --snapshot is enabled", + ); + } + return true; }).middleware((args) => logDeployCommandConfigFileOverride( diff --git a/packages/cli/src/commands/site/deploy/siteDeployCommand.mts b/packages/cli/src/commands/site/deploy/siteDeployCommand.mts index 637b6384d..56dfa898f 100644 --- a/packages/cli/src/commands/site/deploy/siteDeployCommand.mts +++ b/packages/cli/src/commands/site/deploy/siteDeployCommand.mts @@ -23,17 +23,18 @@ import { colorize } from "consola/utils"; import * as fs from "node:fs"; import path from "node:path"; import { Readable } from "node:stream"; +import prettyBytes from "pretty-bytes"; +import type { InternalClientContext } from "../../../net/internalClientContext.mjs"; +import type { ThirdPartyAppRid } from "../../../net/ThirdPartyAppRid.js"; import { autoVersion as findAutoVersion } from "../../../util/autoVersion.js"; import type { AutoVersionConfig } from "../../../util/config.js"; import { loadToken } from "../../../util/token.js"; import type { SiteDeployArgs } from "./SiteDeployArgs.js"; interface SiteDeployInternalArgs - extends Omit + extends Omit { selectedVersion: string | AutoVersionConfig; - directory: string; - uploadOnly: boolean; } export default async function siteDeployCommand( @@ -42,6 +43,8 @@ export default async function siteDeployCommand( application, foundryUrl, uploadOnly, + snapshot, + snapshotId, directory, token, tokenFile, @@ -74,17 +77,24 @@ export default async function siteDeployCommand( const archive = archiver("zip").directory(directory, false); logArchiveStats(archive); - consola.start("Uploading site files"); - await Promise.all([ - thirdPartyApplications.uploadVersion( + if (snapshot) { + await uploadSnapshot( clientCtx, application, siteVersion, - Readable.toWeb(archive) as ReadableStream, // This cast is because the dom fetch doesn't align type wise with streams - ), - archive.finalize(), - ]); - consola.success("Upload complete"); + snapshotId ?? "", + archive, + ); + consola.info("Snapshot mode enabled, skipping deployment"); + return; + } + + await upload( + clientCtx, + application, + siteVersion, + archive, + ); if (!uploadOnly) { const website = await thirdPartyApplications.deployWebsite( @@ -106,7 +116,7 @@ export default async function siteDeployCommand( application, ); const domain = website?.subdomains[0]; - consola.debug("Upload only mode enabled, skipping deployment"); + consola.info("Upload only mode enabled, skipping deployment"); if (domain != null) { logSiteLink( "Preview link:", @@ -116,6 +126,46 @@ export default async function siteDeployCommand( } } +async function uploadSnapshot( + clientCtx: InternalClientContext, + application: ThirdPartyAppRid, + siteVersion: string, + snapshotId: string, + archive: archiver.Archiver, +): Promise { + consola.start("Uploading snapshot site files"); + await Promise.all([ + thirdPartyApplications.uploadSnapshotVersion( + clientCtx, + application, + siteVersion, + snapshotId, + Readable.toWeb(archive) as ReadableStream, // This cast is because the dom fetch doesn't align type wise with streams + ), + archive.finalize(), + ]); + consola.success("Snapshot upload complete"); +} + +async function upload( + clientCtx: InternalClientContext, + application: ThirdPartyAppRid, + siteVersion: string, + archive: archiver.Archiver, +): Promise { + consola.start("Uploading site files"); + await Promise.all([ + thirdPartyApplications.uploadVersion( + clientCtx, + application, + siteVersion, + Readable.toWeb(archive) as ReadableStream, // This cast is because the dom fetch doesn't align type wise with streams + ), + archive.finalize(), + ]); + consola.success("Upload complete"); +} + function logArchiveStats(archive: archiver.Archiver): void { let archiveStats = { fileCount: 0, bytes: 0 }; archive.on("progress", (progress) => { @@ -125,8 +175,10 @@ function logArchiveStats(archive: archiver.Archiver): void { }; }); archive.on("finish", () => { - consola.debug( - `Zipped ${archiveStats.fileCount} files and ${archiveStats.bytes} bytes`, + consola.info( + `Zipped ${ + prettyBytes(archiveStats.bytes, { binary: true }) + } total over ${archiveStats.fileCount} files`, ); }); } diff --git a/packages/cli/src/net/createFetch.mts b/packages/cli/src/net/createFetch.mts index afb6f8074..dbcca9f80 100644 --- a/packages/cli/src/net/createFetch.mts +++ b/packages/cli/src/net/createFetch.mts @@ -21,6 +21,7 @@ import { PalantirApiError, } from "@osdk/shared.net"; import { consola } from "consola"; +import prettyBytes from "pretty-bytes"; import { USER_AGENT } from "./UserAgent.js"; export function createFetch( @@ -76,6 +77,39 @@ function handleFetchError(e: unknown): Promise { message = "The site version already exists"; } else if (e.errorName === "VersionNotFound") { message = "The site version could not be found"; + } else if (e.errorName === "VersionLimitExceeded") { + const { versionLimit } = e.parameters ?? {}; + const versionLimitPart = versionLimit != null + ? ` (Limit: ${versionLimit} versions)` + : ""; + message = `The site contains too many versions${versionLimitPart}`; + tip = + "Run the `site version delete` command to delete an old version and try again"; + } else if (e.errorName === "FileCountLimitExceeded") { + const { fileCountLimit } = e.parameters ?? {}; + const fileCountLimitPart = fileCountLimit != null + ? ` (Limit: ${fileCountLimit} files)` + : ""; + message = `The .zip file contains too many files${fileCountLimitPart}`; + tip = + "Reduce the number of files in the production build to below the limit"; + } else if (e.errorName === "FileSizeLimitExceeded") { + const { currentFilePath, currentFileSizeBytes, fileSizeBytesLimit } = + e.parameters ?? {}; + const currentFilePathPart = currentFilePath != null + ? ` "${currentFilePath}"` + : ""; + const currentFileSizePart = currentFileSizeBytes != null + ? ` (${prettyBytes(parseInt(currentFileSizeBytes), { binary: true })})` + : ""; + const fileSizeLimitPart = fileSizeBytesLimit != null + ? ` (Limit: ${ + prettyBytes(parseInt(fileSizeBytesLimit), { binary: true }) + })` + : ""; + message = + `The .zip file contains a file${currentFilePathPart}${currentFileSizePart} that is too large${fileSizeLimitPart}`; + tip = "Ensure all files in the production build are below the size limit"; } else { const { errorCode, errorName, errorInstanceId, parameters } = e; // Include extra info about the original API error in CLI error messages diff --git a/packages/cli/src/net/third-party-applications/index.mts b/packages/cli/src/net/third-party-applications/index.mts index 4833f7218..3bf89acb6 100644 --- a/packages/cli/src/net/third-party-applications/index.mts +++ b/packages/cli/src/net/third-party-applications/index.mts @@ -21,6 +21,7 @@ export { getWebsite } from "./getWebsite.mjs"; export { listVersions } from "./listVersions.mjs"; export type { ListVersionsResponse } from "./ListVersionsResponse.mjs"; export { undeployWebsite } from "./undeployWebsite.mjs"; +export { uploadSnapshotVersion } from "./uploadSnapshotVersion.mjs"; export { uploadVersion } from "./uploadVersion.mjs"; export type { Version } from "./Version.mjs"; export type { Website } from "./Website.mjs"; diff --git a/packages/cli/src/net/third-party-applications/uploadSnapshotVersion.mts b/packages/cli/src/net/third-party-applications/uploadSnapshotVersion.mts new file mode 100644 index 000000000..b3111af7b --- /dev/null +++ b/packages/cli/src/net/third-party-applications/uploadSnapshotVersion.mts @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createFetch } from "../createFetch.mjs"; +import type { InternalClientContext } from "../internalClientContext.mjs"; +import type { ThirdPartyAppRid } from "../ThirdPartyAppRid.js"; +import type { Version } from "./Version.mjs"; + +export async function uploadSnapshotVersion( + ctx: InternalClientContext, + thirdPartyAppRid: ThirdPartyAppRid, + version: string, + snapshotId: string, + zipFile: ReadableStream | Blob | BufferSource, +): Promise { + const fetch = createFetch(ctx.tokenProvider); + const url = + `${ctx.foundryUrl}/api/v2/thirdPartyApplications/${thirdPartyAppRid}/website/versions/uploadSnapshot?version=${version}&preview=true${ + snapshotId !== "" + ? `&snapshotIdentifier=${snapshotId}` + : "" + }`; + + const result = await fetch( + url, + { + method: "POST", + body: zipFile, + headers: { + "Content-Type": "application/octet-stream", + }, + duplex: "half", // Node hates me + } satisfies RequestInit & { duplex: "half" } as any, + ); + return result.json(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6e16026d..7569273f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -743,6 +743,9 @@ importers: open: specifier: ^10.1.0 version: 10.1.0 + pretty-bytes: + specifier: ^6.1.1 + version: 6.1.1 semver: specifier: ^7.6.3 version: 7.6.3 @@ -7281,6 +7284,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -13947,6 +13954,8 @@ snapshots: prettier@3.2.5: {} + pretty-bytes@6.1.1: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3