mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(tunnel): support zip option in deploy command (#6541)
* feat(tunnel): support zip option in deploy command * chore: update changeset * refactor(tunnel): improve error handling in deploy command * refactor(tunnel): improve cli error message per review comments Co-authored-by: Gao Sun <gao@silverhand.io> --------- Co-authored-by: Gao Sun <gao@silverhand.io>
This commit is contained in:
parent
a748fc85bb
commit
8d95132262
4 changed files with 116 additions and 45 deletions
|
@ -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 <your-m2m-app-id>:<your-m2m-app-secret> --endpoint https://<tenant-id>.logto.app --management-api-resource https://<tenant-id>.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 <your-m2m-app-id>:<your-m2m-app-secret> --endpoint https://<tenant-id>.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://<tenant-id>.logto.app npx @logto/tunnel ...
|
||||
LOGTO_ENDPOINT=https://<tenant-id>.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)
|
||||
```
|
||||
|
|
|
@ -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<unknown, DeployCommandArgs> = {
|
||||
command: ['deploy'],
|
||||
|
@ -42,6 +39,11 @@ const tunnel: CommandModule<unknown, DeployCommandArgs> = {
|
|||
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<unknown, DeployCommandArgs> = {
|
|||
path: experiencePath,
|
||||
resource: managementApiResource,
|
||||
verbose,
|
||||
zip: zipPath,
|
||||
} = options;
|
||||
if (!auth) {
|
||||
consoleLog.fatal(
|
||||
|
@ -66,14 +69,8 @@ const tunnel: CommandModule<unknown, DeployCommandArgs> = {
|
|||
'A valid Logto endpoint URI must be provided. E.g. `--endpoint https://<tenant-id>.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<unknown, DeployCommandArgs> = {
|
|||
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.');
|
||||
|
|
|
@ -2,6 +2,7 @@ export type DeployCommandArgs = {
|
|||
auth?: string;
|
||||
endpoint?: string;
|
||||
path?: string;
|
||||
zip?: string;
|
||||
resource?: string;
|
||||
verbose: boolean;
|
||||
};
|
||||
|
|
|
@ -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-path` 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<Uint8Array> => {
|
||||
const getZipBuffer = async (experiencePath?: string, zipPath?: string): Promise<Uint8Array> => {
|
||||
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();
|
||||
};
|
||||
|
||||
|
@ -96,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<TokenResponse>();
|
||||
|
@ -108,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}`,
|
||||
|
@ -141,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) => {
|
||||
|
@ -159,3 +213,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('.'));
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue