Skip to content

Commit ff4cd67

Browse files
authored
feat(tunnel): support cli deploy custom ui assets to cloud (#6530)
1 parent 31296f0 commit ff4cd67

File tree

13 files changed

+472
-143
lines changed

13 files changed

+472
-143
lines changed

.changeset/modern-ghosts-sin.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@logto/tunnel": minor
3+
---
4+
5+
add deploy command and env support
6+
7+
#### Add new `deploy` command to deploy your local custom UI assets to your Logto Cloud tenant
8+
9+
1. Create a machine-to-machine app with Management API permissions in your Logto tenant
10+
2. Run the following command
11+
12+
```bash
13+
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
14+
```
15+
16+
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.
17+
18+
#### Add environment variable support
19+
20+
1. Create a `.env` file in the CLI root directory, or any parent directory where the CLI is located.
21+
2. Alternatively, specify environment variables directly when running CLI commands:
22+
23+
```bash
24+
ENDPOINT=https://<tenant-id>.logto.app npx @logto/tunnel ...
25+
```
26+
27+
Supported environment variables:
28+
29+
- LOGTO_AUTH
30+
- LOGTO_ENDPOINT
31+
- LOGTO_EXPERIENCE_PATH
32+
- LOGTO_EXPERIENCE_URI
33+
- LOGTO_MANAGEMENT_API_RESOURCE

packages/tunnel/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,20 @@
4444
"@logto/core-kit": "workspace:^",
4545
"@logto/shared": "workspace:^",
4646
"@silverhand/essentials": "^2.9.1",
47+
"adm-zip": "^0.5.14",
4748
"chalk": "^5.3.0",
4849
"dotenv": "^16.4.5",
50+
"find-up": "^7.0.0",
4951
"http-proxy-middleware": "^3.0.0",
5052
"mime": "^4.0.4",
53+
"ora": "^8.0.1",
5154
"yargs": "^17.6.0",
5255
"zod": "^3.23.8"
5356
},
5457
"devDependencies": {
5558
"@silverhand/eslint-config": "6.0.1",
5659
"@silverhand/ts-config": "6.0.0",
60+
"@types/adm-zip": "^0.5.5",
5761
"@types/node": "^20.9.5",
5862
"@types/yargs": "^17.0.13",
5963
"@vitest/coverage-v8": "^2.0.0",
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { existsSync } from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { isValidUrl } from '@logto/core-kit';
5+
import chalk from 'chalk';
6+
import ora from 'ora';
7+
import type { CommandModule } from 'yargs';
8+
9+
import { consoleLog } from '../../utils.js';
10+
11+
import { type DeployCommandArgs } from './types.js';
12+
import { deployToLogtoCloud } from './utils.js';
13+
14+
const tunnel: CommandModule<unknown, DeployCommandArgs> = {
15+
command: ['deploy'],
16+
describe: 'Deploy your custom UI assets to Logto Cloud',
17+
builder: (yargs) =>
18+
yargs
19+
.options({
20+
auth: {
21+
describe:
22+
'Auth credentials of your Logto M2M application. E.g.: <app-id>:<app-secret> (Docs: https://docs.logto.io/docs/recipes/interact-with-management-api/#create-an-m2m-app)',
23+
type: 'string',
24+
},
25+
endpoint: {
26+
describe:
27+
'Logto endpoint URI that points to your Logto Cloud instance. E.g.: https://<tenant-id>.logto.app/',
28+
type: 'string',
29+
},
30+
path: {
31+
alias: ['experience-path'],
32+
describe: 'The local folder path of your custom sign-in experience assets.',
33+
type: 'string',
34+
},
35+
resource: {
36+
alias: ['management-api-resource'],
37+
describe: 'Logto Management API resource indicator. Required if using custom domain.',
38+
type: 'string',
39+
},
40+
verbose: {
41+
describe: 'Show verbose output.',
42+
type: 'boolean',
43+
default: false,
44+
},
45+
})
46+
.epilog(
47+
`Refer to our documentation for more details:\n${chalk.blue(
48+
'https://docs.logto.io/docs/references/tunnel-cli/deploy'
49+
)}`
50+
),
51+
handler: async (options) => {
52+
const {
53+
auth,
54+
endpoint,
55+
path: experiencePath,
56+
resource: managementApiResource,
57+
verbose,
58+
} = options;
59+
if (!auth) {
60+
consoleLog.fatal(
61+
'Must provide valid Machine-to-Machine (M2M) authentication credentials. E.g. `--auth <app-id>:<app-secret>` or add `LOGTO_AUTH` to your environment variables.'
62+
);
63+
}
64+
if (!endpoint || !isValidUrl(endpoint)) {
65+
consoleLog.fatal(
66+
'A valid Logto endpoint URI must be provided. E.g. `--endpoint https://<tenant-id>.logto.app/` or add `LOGTO_ENDPOINT` to your environment variables.'
67+
);
68+
}
69+
if (!experiencePath) {
70+
consoleLog.fatal(
71+
'A valid experience path must be provided. E.g. `--experience-path /path/to/experience` or add `LOGTO_EXPERIENCE_PATH` to your environment variables.'
72+
);
73+
}
74+
if (!existsSync(path.join(experiencePath, 'index.html'))) {
75+
consoleLog.fatal(`The provided experience path must contain an "index.html" file.`);
76+
}
77+
78+
const spinner = ora();
79+
80+
if (verbose) {
81+
consoleLog.plain(
82+
`${chalk.bold('Starting deployment...')} ${chalk.gray('(with verbose output)')}`
83+
);
84+
} else {
85+
spinner.start('Deploying your custom UI assets to Logto Cloud...');
86+
}
87+
88+
await deployToLogtoCloud({ auth, endpoint, experiencePath, managementApiResource, verbose });
89+
90+
if (!verbose) {
91+
spinner.succeed('Deploying your custom UI assets to Logto Cloud... Done.');
92+
}
93+
94+
const endpointUrl = new URL(endpoint);
95+
spinner.succeed(`🎉 ${chalk.bold(chalk.green('Deployment successful!'))}`);
96+
consoleLog.plain(`${chalk.green('➜')} You can try your own sign-in UI on Logto Cloud now.`);
97+
consoleLog.plain(`${chalk.green('➜')} Make sure the Logto endpoint URI in your app is set to:`);
98+
consoleLog.plain(` ${chalk.blue(chalk.bold(endpointUrl.href))}`);
99+
consoleLog.plain(
100+
`${chalk.green(
101+
'➜'
102+
)} If you are using social sign-in, make sure the social redirect URI is set to:`
103+
);
104+
consoleLog.plain(` ${chalk.blue(chalk.bold(`${endpointUrl.href}callback/<connector-id>`))}`);
105+
},
106+
};
107+
108+
export default tunnel;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type DeployCommandArgs = {
2+
auth?: string;
3+
endpoint?: string;
4+
path?: string;
5+
resource?: string;
6+
verbose: boolean;
7+
};
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { appendPath } from '@silverhand/essentials';
2+
import AdmZip from 'adm-zip';
3+
import chalk from 'chalk';
4+
import ora from 'ora';
5+
6+
import { consoleLog } from '../../utils.js';
7+
8+
type TokenResponse = {
9+
access_token: string;
10+
token_type: string;
11+
expires_in: number;
12+
scope: string;
13+
};
14+
15+
type DeployArgs = {
16+
auth: string;
17+
endpoint: string;
18+
experiencePath: string;
19+
managementApiResource?: string;
20+
verbose: boolean;
21+
};
22+
23+
export const deployToLogtoCloud = async ({
24+
auth,
25+
endpoint,
26+
experiencePath,
27+
managementApiResource,
28+
verbose,
29+
}: DeployArgs) => {
30+
const spinner = ora();
31+
if (verbose) {
32+
spinner.start('[1/4] Zipping files...');
33+
}
34+
const zipBuffer = await zipFiles(experiencePath);
35+
if (verbose) {
36+
spinner.succeed('[1/4] Zipping files... Done.');
37+
}
38+
39+
try {
40+
if (verbose) {
41+
spinner.start('[2/4] Exchanging access token...');
42+
}
43+
const endpointUrl = new URL(endpoint);
44+
const tokenResponse = await getAccessToken(auth, endpointUrl, managementApiResource);
45+
if (verbose) {
46+
spinner.succeed('[2/4] Exchanging access token... Done.');
47+
spinner.succeed(
48+
`Token exchange response:\n${chalk.gray(JSON.stringify(tokenResponse, undefined, 2))}`
49+
);
50+
spinner.start('[3/4] Uploading zip...');
51+
}
52+
const accessToken = tokenResponse.access_token;
53+
const uploadResult = await uploadCustomUiAssets(accessToken, endpointUrl, zipBuffer);
54+
55+
if (verbose) {
56+
spinner.succeed('[3/4] Uploading zip... Done.');
57+
spinner.succeed(
58+
`Received response:\n${chalk.gray(JSON.stringify(uploadResult, undefined, 2))}`
59+
);
60+
spinner.start('[4/4] Saving changes to your tenant...');
61+
}
62+
63+
await saveChangesToSie(accessToken, endpointUrl, uploadResult.customUiAssetId);
64+
65+
if (verbose) {
66+
spinner.succeed('[4/4] Saving changes to your tenant... Done.');
67+
}
68+
} catch (error: unknown) {
69+
spinner.fail();
70+
const errorMessage = error instanceof Error ? error.message : String(error);
71+
consoleLog.fatal(chalk.red(errorMessage));
72+
}
73+
};
74+
75+
const zipFiles = async (path: string): Promise<Uint8Array> => {
76+
const zip = new AdmZip();
77+
await zip.addLocalFolderPromise(path, {});
78+
return zip.toBuffer();
79+
};
80+
81+
const getAccessToken = async (auth: string, endpoint: URL, managementApiResource?: string) => {
82+
const tokenEndpoint = appendPath(endpoint, '/oidc/token').href;
83+
const resource = managementApiResource ?? getManagementApiResourceFromEndpointUri(endpoint);
84+
85+
const response = await fetch(tokenEndpoint, {
86+
method: 'POST',
87+
headers: {
88+
'Content-Type': 'application/x-www-form-urlencoded',
89+
Authorization: `Basic ${Buffer.from(auth).toString('base64')}`,
90+
},
91+
body: new URLSearchParams({
92+
grant_type: 'client_credentials',
93+
resource,
94+
scope: 'all',
95+
}).toString(),
96+
});
97+
98+
if (!response.ok) {
99+
throw new Error(`Failed to fetch access token: ${response.statusText}`);
100+
}
101+
102+
return response.json<TokenResponse>();
103+
};
104+
105+
const uploadCustomUiAssets = async (accessToken: string, endpoint: URL, zipBuffer: Uint8Array) => {
106+
const form = new FormData();
107+
const blob = new Blob([zipBuffer], { type: 'application/zip' });
108+
const timestamp = Math.floor(Date.now() / 1000);
109+
form.append('file', blob, `custom-ui-${timestamp}.zip`);
110+
111+
const uploadResponse = await fetch(
112+
appendPath(endpoint, '/api/sign-in-exp/default/custom-ui-assets'),
113+
{
114+
method: 'POST',
115+
body: form,
116+
headers: {
117+
Authorization: `Bearer ${accessToken}`,
118+
Accept: 'application/json',
119+
},
120+
}
121+
);
122+
123+
if (!uploadResponse.ok) {
124+
throw new Error(`Request error: [${uploadResponse.status}] ${uploadResponse.status}`);
125+
}
126+
127+
return uploadResponse.json<{ customUiAssetId: string }>();
128+
};
129+
130+
const saveChangesToSie = async (accessToken: string, endpointUrl: URL, customUiAssetId: string) => {
131+
const timestamp = Math.floor(Date.now() / 1000);
132+
const patchResponse = await fetch(appendPath(endpointUrl, '/api/sign-in-exp'), {
133+
method: 'PATCH',
134+
headers: {
135+
Authorization: `Bearer ${accessToken}`,
136+
Accept: 'application/json',
137+
'Content-Type': 'application/json',
138+
},
139+
body: JSON.stringify({
140+
customUiAssets: { id: customUiAssetId, createdAt: timestamp },
141+
}),
142+
});
143+
144+
if (!patchResponse.ok) {
145+
throw new Error(`Request error: [${patchResponse.status}] ${patchResponse.statusText}`);
146+
}
147+
148+
return patchResponse.json();
149+
};
150+
151+
const getTenantIdFromEndpointUri = (endpoint: URL) => {
152+
const splitted = endpoint.hostname.split('.');
153+
return splitted.length > 2 ? splitted[0] : 'default';
154+
};
155+
156+
const getManagementApiResourceFromEndpointUri = (endpoint: URL) => {
157+
const tenantId = getTenantIdFromEndpointUri(endpoint);
158+
159+
// This resource domain is fixed to `logto.app` for all environments (prod, staging, and dev)
160+
return `https://${tenantId}.logto.app/api`;
161+
};

0 commit comments

Comments
 (0)