From 3e0f170f5a3bb13158ec93027b4cdb11a9afc2cb Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Tue, 3 Sep 2024 14:42:57 +0800 Subject: [PATCH 1/4] feat(tunnel): support zip option in deploy command --- packages/tunnel/src/commands/deploy/index.ts | 30 +++++---- packages/tunnel/src/commands/deploy/types.ts | 1 + packages/tunnel/src/commands/deploy/utils.ts | 68 ++++++++++++++++++-- 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/packages/tunnel/src/commands/deploy/index.ts b/packages/tunnel/src/commands/deploy/index.ts index f371b7f2039..2ee21f38dc0 100644 --- a/packages/tunnel/src/commands/deploy/index.ts +++ b/packages/tunnel/src/commands/deploy/index.ts @@ -1,6 +1,3 @@ -import { existsSync } from 'node:fs'; -import path from 'node:path'; - import { isValidUrl } from '@logto/core-kit'; import chalk from 'chalk'; import ora from 'ora'; @@ -9,7 +6,7 @@ import type { CommandModule } from 'yargs'; import { consoleLog } from '../../utils.js'; import { type DeployCommandArgs } from './types.js'; -import { deployToLogtoCloud } from './utils.js'; +import { checkExperienceAndZipPathInputs, deployToLogtoCloud } from './utils.js'; const tunnel: CommandModule = { command: ['deploy'], @@ -42,6 +39,11 @@ const tunnel: CommandModule = { type: 'boolean', default: false, }, + zip: { + alias: ['zip-path'], + describe: 'The local folder path of your existing zip package.', + type: 'string', + }, }) .epilog( `Refer to our documentation for more details:\n${chalk.blue( @@ -55,6 +57,7 @@ const tunnel: CommandModule = { path: experiencePath, resource: managementApiResource, verbose, + zip: zipPath, } = options; if (!auth) { consoleLog.fatal( @@ -66,14 +69,8 @@ const tunnel: CommandModule = { 'A valid Logto endpoint URI must be provided. E.g. `--endpoint https://.logto.app/` or add `LOGTO_ENDPOINT` to your environment variables.' ); } - if (!experiencePath) { - consoleLog.fatal( - 'A valid experience path must be provided. E.g. `--experience-path /path/to/experience` or add `LOGTO_EXPERIENCE_PATH` to your environment variables.' - ); - } - if (!existsSync(path.join(experiencePath, 'index.html'))) { - consoleLog.fatal(`The provided experience path must contain an "index.html" file.`); - } + + await checkExperienceAndZipPathInputs(experiencePath, zipPath); const spinner = ora(); @@ -85,7 +82,14 @@ const tunnel: CommandModule = { spinner.start('Deploying your custom UI assets to Logto Cloud...'); } - await deployToLogtoCloud({ auth, endpoint, experiencePath, managementApiResource, verbose }); + await deployToLogtoCloud({ + auth, + endpoint, + experiencePath, + managementApiResource, + verbose, + zipPath, + }); if (!verbose) { spinner.succeed('Deploying your custom UI assets to Logto Cloud... Done.'); diff --git a/packages/tunnel/src/commands/deploy/types.ts b/packages/tunnel/src/commands/deploy/types.ts index 6d90961a7d7..401d47090ec 100644 --- a/packages/tunnel/src/commands/deploy/types.ts +++ b/packages/tunnel/src/commands/deploy/types.ts @@ -2,6 +2,7 @@ export type DeployCommandArgs = { auth?: string; endpoint?: string; path?: string; + zip?: string; resource?: string; verbose: boolean; }; diff --git a/packages/tunnel/src/commands/deploy/utils.ts b/packages/tunnel/src/commands/deploy/utils.ts index ce83a2557e4..719e12d2757 100644 --- a/packages/tunnel/src/commands/deploy/utils.ts +++ b/packages/tunnel/src/commands/deploy/utils.ts @@ -1,3 +1,7 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + import { appendPath } from '@silverhand/essentials'; import AdmZip from 'adm-zip'; import chalk from 'chalk'; @@ -15,25 +19,62 @@ type TokenResponse = { type DeployArgs = { auth: string; endpoint: string; - experiencePath: string; + experiencePath?: string; + zipPath?: string; managementApiResource?: string; verbose: boolean; }; +export const checkExperienceAndZipPathInputs = async ( + experiencePath?: string, + zipPath?: string +) => { + if (zipPath && experiencePath) { + consoleLog.fatal( + 'You can only specify either `--zip` or `--experience-path`. Please check your input and environment variables.' + ); + } + if (!zipPath && !experiencePath) { + consoleLog.fatal( + 'A valid path to your experience asset folder or zip package must be provided. You can specify either `--zip-path` or `--experience-path` options or corresponding environment variables.' + ); + } + if (zipPath) { + if (!existsSync(zipPath)) { + consoleLog.fatal(`The specified zip file does not exist: ${zipPath}`); + } + + const zipFile = new AdmZip(zipPath); + const zipEntries = zipFile.getEntries(); + const hasIndexHtmlInRoot = zipEntries.some(({ entryName }) => { + const parts = entryName.split('/'); + return parts.length <= 2 && parts.at(-1) === 'index.html'; + }); + + if (!hasIndexHtmlInRoot) { + consoleLog.fatal('The provided zip must contain an "index.html" file in the root directory.'); + } + } + if (experiencePath && !existsSync(path.join(experiencePath, 'index.html'))) { + consoleLog.fatal(`The provided experience path must contain an "index.html" file.`); + } +}; + export const deployToLogtoCloud = async ({ auth, endpoint, experiencePath, managementApiResource, verbose, + zipPath, }: DeployArgs) => { const spinner = ora(); if (verbose) { - spinner.start('[1/4] Zipping files...'); + spinner.start(`[1/4] ${zipPath ? 'Reading zip' : 'Zipping'} files...`); } - const zipBuffer = await zipFiles(experiencePath); + const zipBuffer = await getZipBuffer(experiencePath, zipPath); if (verbose) { - spinner.succeed('[1/4] Zipping files... Done.'); + spinner.succeed(`[1/4] ${zipPath ? 'Reading zip' : 'Zipping'} files... Done.`); } try { @@ -72,9 +113,20 @@ export const deployToLogtoCloud = async ({ } }; -const zipFiles = async (path: string): Promise => { +const getZipBuffer = async (experiencePath?: string, zipPath?: string): Promise => { + if (!experiencePath && !zipPath) { + consoleLog.fatal('Must specify either `--experience-path` or `--zip-path`.'); + } + if (zipPath) { + return readFile(zipPath); + } + if (!experiencePath) { + consoleLog.fatal('Invalid experience path input.'); + } const zip = new AdmZip(); - await zip.addLocalFolderPromise(path, {}); + await zip.addLocalFolderPromise(experiencePath, { + filter: (filename) => !isHiddenEntry(filename), + }); return zip.toBuffer(); }; @@ -159,3 +211,7 @@ const getManagementApiResourceFromEndpointUri = (endpoint: URL) => { // This resource domain is fixed to `logto.app` for all environments (prod, staging, and dev) return `https://${tenantId}.logto.app/api`; }; + +const isHiddenEntry = (entryName: string) => { + return entryName.split('/').some((part) => part.startsWith('.')); +}; From 6e59d5d3cc5e91946ade8b4e3c6ea3f106e4ef18 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Tue, 3 Sep 2024 16:12:23 +0800 Subject: [PATCH 2/4] chore: update changeset --- .changeset/modern-ghosts-sin.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.changeset/modern-ghosts-sin.md b/.changeset/modern-ghosts-sin.md index 3d749a72efa..ed70fa7b675 100644 --- a/.changeset/modern-ghosts-sin.md +++ b/.changeset/modern-ghosts-sin.md @@ -6,14 +6,20 @@ add deploy command and env support #### Add new `deploy` command to deploy your local custom UI assets to your Logto Cloud tenant -1. Create a machine-to-machine app with Management API permissions in your Logto tenant -2. Run the following command +1. Create a machine-to-machine app with Management API permissions in your Logto tenant. +2. Run the following command: ```bash npx @logto/tunnel deploy --auth : --endpoint https://.logto.app --management-api-resource https://.logto.app/api --experience-path /path/to/your/custom/ui ``` -Note: The `--management-api-resource` (or `--resource`) can be omitted when using the default Logto domain, since the CLI can infer the value automatically. If you are using custom domain for your Logto endpoint, this option must be provided. +Note: +1. The `--management-api-resource` (or `--resource`) can be omitted when using the default Logto domain, since the CLI can infer the value automatically. If you are using custom domain for your Logto endpoint, this option must be provided. +2. You can also specify an existing zip file (`--zip-path` or `--zip`) instead of a directory to deploy. Only one of `--experience-path` or `--zip-path` can be used at a time. + +```bash +npx @logto/tunnel deploy --auth : --endpoint https://.logto.app --zip-path /path/to/your/custom/ui.zip +``` #### Add environment variable support @@ -21,13 +27,15 @@ Note: The `--management-api-resource` (or `--resource`) can be omitted when usin 2. Alternatively, specify environment variables directly when running CLI commands: ```bash -ENDPOINT=https://.logto.app npx @logto/tunnel ... +LOGTO_ENDPOINT=https://.logto.app npx @logto/tunnel ... ``` Supported environment variables: - LOGTO_AUTH - LOGTO_ENDPOINT -- LOGTO_EXPERIENCE_PATH -- LOGTO_EXPERIENCE_URI -- LOGTO_MANAGEMENT_API_RESOURCE +- LOGTO_EXPERIENCE_PATH (or LOGTO_PATH) +- LOGTO_EXPERIENCE_URI (or LOGTO_URI) +- LOGTO_MANAGEMENT_API_RESOURCE (or LOGTO_RESOURCE) +- LOGTO_ZIP_PATH (or LOGTO_ZIP) +``` From 8e88908ccd1d11f69405185917cace7bb8b23fa8 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Tue, 3 Sep 2024 19:19:56 +0800 Subject: [PATCH 3/4] refactor(tunnel): improve error handling in deploy command --- packages/tunnel/src/commands/deploy/utils.ts | 40 ++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/tunnel/src/commands/deploy/utils.ts b/packages/tunnel/src/commands/deploy/utils.ts index 719e12d2757..0aeb1eca37e 100644 --- a/packages/tunnel/src/commands/deploy/utils.ts +++ b/packages/tunnel/src/commands/deploy/utils.ts @@ -148,7 +148,7 @@ const getAccessToken = async (auth: string, endpoint: URL, managementApiResource }); if (!response.ok) { - throw new Error(`Failed to fetch access token: ${response.statusText}`); + await throwRequestError(response); } return response.json(); @@ -160,28 +160,25 @@ const uploadCustomUiAssets = async (accessToken: string, endpoint: URL, zipBuffe const timestamp = Math.floor(Date.now() / 1000); form.append('file', blob, `custom-ui-${timestamp}.zip`); - const uploadResponse = await fetch( - appendPath(endpoint, '/api/sign-in-exp/default/custom-ui-assets'), - { - method: 'POST', - body: form, - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - } - ); + const response = await fetch(appendPath(endpoint, '/api/sign-in-exp/default/custom-ui-assets'), { + method: 'POST', + body: form, + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); - if (!uploadResponse.ok) { - throw new Error(`Request error: [${uploadResponse.status}] ${uploadResponse.status}`); + if (!response.ok) { + await throwRequestError(response); } - return uploadResponse.json<{ customUiAssetId: string }>(); + return response.json<{ customUiAssetId: string }>(); }; const saveChangesToSie = async (accessToken: string, endpointUrl: URL, customUiAssetId: string) => { const timestamp = Math.floor(Date.now() / 1000); - const patchResponse = await fetch(appendPath(endpointUrl, '/api/sign-in-exp'), { + const response = await fetch(appendPath(endpointUrl, '/api/sign-in-exp'), { method: 'PATCH', headers: { Authorization: `Bearer ${accessToken}`, @@ -193,11 +190,16 @@ const saveChangesToSie = async (accessToken: string, endpointUrl: URL, customUiA }), }); - if (!patchResponse.ok) { - throw new Error(`Request error: [${patchResponse.status}] ${patchResponse.statusText}`); + if (!response.ok) { + await throwRequestError(response); } - return patchResponse.json(); + return response.json(); +}; + +const throwRequestError = async (response: Response) => { + const errorDetails = await response.text(); + throw new Error(`[${response.status}] ${errorDetails}`); }; const getTenantIdFromEndpointUri = (endpoint: URL) => { From 4705c63a44f2b6b2d38a1c3d873172ecce35d96f Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Wed, 4 Sep 2024 12:45:14 +0800 Subject: [PATCH 4/4] refactor(tunnel): improve cli error message per review comments Co-authored-by: Gao Sun --- packages/tunnel/src/commands/deploy/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tunnel/src/commands/deploy/utils.ts b/packages/tunnel/src/commands/deploy/utils.ts index 0aeb1eca37e..5fc49f4ef20 100644 --- a/packages/tunnel/src/commands/deploy/utils.ts +++ b/packages/tunnel/src/commands/deploy/utils.ts @@ -31,7 +31,7 @@ export const checkExperienceAndZipPathInputs = async ( ) => { if (zipPath && experiencePath) { consoleLog.fatal( - 'You can only specify either `--zip` or `--experience-path`. Please check your input and environment variables.' + 'You can only specify either `--zip-path` or `--experience-path`. Please check your input and environment variables.' ); } if (!zipPath && !experiencePath) {