Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tunnel): support zip option in deploy command #6541

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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('.'));
};
Loading