Skip to content

Commit

Permalink
feat(tunnel): support zip option in deploy command (#6541)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

---------

Co-authored-by: Gao Sun <[email protected]>
  • Loading branch information
charIeszhao and gao-sun authored Sep 4, 2024
1 parent a748fc8 commit 8d95132
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 45 deletions.
22 changes: 15 additions & 7 deletions .changeset/modern-ghosts-sin.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,36 @@ 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

1. Create a `.env` file in the CLI root directory, or any parent directory where the CLI is located.
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)
```
30 changes: 17 additions & 13 deletions packages/tunnel/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'],
Expand Down Expand Up @@ -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(
Expand All @@ -55,6 +57,7 @@ const tunnel: CommandModule<unknown, DeployCommandArgs> = {
path: experiencePath,
resource: managementApiResource,
verbose,
zip: zipPath,
} = options;
if (!auth) {
consoleLog.fatal(
Expand All @@ -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();

Expand All @@ -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.');
Expand Down
1 change: 1 addition & 0 deletions packages/tunnel/src/commands/deploy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type DeployCommandArgs = {
auth?: string;
endpoint?: string;
path?: string;
zip?: string;
resource?: string;
verbose: boolean;
};
108 changes: 83 additions & 25 deletions packages/tunnel/src/commands/deploy/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
};

Expand All @@ -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>();
Expand All @@ -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}`,
Expand All @@ -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) => {
Expand All @@ -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('.'));
};

0 comments on commit 8d95132

Please sign in to comment.