-
-
Notifications
You must be signed in to change notification settings - Fork 465
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(tunnel): support cli deploy custom ui assets to cloud (#6530)
- Loading branch information
1 parent
31296f0
commit ff4cd67
Showing
13 changed files
with
472 additions
and
143 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
--- | ||
"@logto/tunnel": minor | ||
--- | ||
|
||
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 | ||
|
||
```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. | ||
|
||
#### 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 ... | ||
``` | ||
|
||
Supported environment variables: | ||
|
||
- LOGTO_AUTH | ||
- LOGTO_ENDPOINT | ||
- LOGTO_EXPERIENCE_PATH | ||
- LOGTO_EXPERIENCE_URI | ||
- LOGTO_MANAGEMENT_API_RESOURCE |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import { existsSync } from 'node:fs'; | ||
import path from 'node:path'; | ||
|
||
import { isValidUrl } from '@logto/core-kit'; | ||
import chalk from 'chalk'; | ||
import ora from 'ora'; | ||
import type { CommandModule } from 'yargs'; | ||
|
||
import { consoleLog } from '../../utils.js'; | ||
|
||
import { type DeployCommandArgs } from './types.js'; | ||
import { deployToLogtoCloud } from './utils.js'; | ||
|
||
const tunnel: CommandModule<unknown, DeployCommandArgs> = { | ||
command: ['deploy'], | ||
describe: 'Deploy your custom UI assets to Logto Cloud', | ||
builder: (yargs) => | ||
yargs | ||
.options({ | ||
auth: { | ||
describe: | ||
'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)', | ||
type: 'string', | ||
}, | ||
endpoint: { | ||
describe: | ||
'Logto endpoint URI that points to your Logto Cloud instance. E.g.: https://<tenant-id>.logto.app/', | ||
type: 'string', | ||
}, | ||
path: { | ||
alias: ['experience-path'], | ||
describe: 'The local folder path of your custom sign-in experience assets.', | ||
type: 'string', | ||
}, | ||
resource: { | ||
alias: ['management-api-resource'], | ||
describe: 'Logto Management API resource indicator. Required if using custom domain.', | ||
type: 'string', | ||
}, | ||
verbose: { | ||
describe: 'Show verbose output.', | ||
type: 'boolean', | ||
default: false, | ||
}, | ||
}) | ||
.epilog( | ||
`Refer to our documentation for more details:\n${chalk.blue( | ||
'https://docs.logto.io/docs/references/tunnel-cli/deploy' | ||
)}` | ||
), | ||
handler: async (options) => { | ||
const { | ||
auth, | ||
endpoint, | ||
path: experiencePath, | ||
resource: managementApiResource, | ||
verbose, | ||
} = options; | ||
if (!auth) { | ||
consoleLog.fatal( | ||
'Must provide valid Machine-to-Machine (M2M) authentication credentials. E.g. `--auth <app-id>:<app-secret>` or add `LOGTO_AUTH` to your environment variables.' | ||
); | ||
} | ||
if (!endpoint || !isValidUrl(endpoint)) { | ||
consoleLog.fatal( | ||
'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.`); | ||
} | ||
|
||
const spinner = ora(); | ||
|
||
if (verbose) { | ||
consoleLog.plain( | ||
`${chalk.bold('Starting deployment...')} ${chalk.gray('(with verbose output)')}` | ||
); | ||
} else { | ||
spinner.start('Deploying your custom UI assets to Logto Cloud...'); | ||
} | ||
|
||
await deployToLogtoCloud({ auth, endpoint, experiencePath, managementApiResource, verbose }); | ||
|
||
if (!verbose) { | ||
spinner.succeed('Deploying your custom UI assets to Logto Cloud... Done.'); | ||
} | ||
|
||
const endpointUrl = new URL(endpoint); | ||
spinner.succeed(`🎉 ${chalk.bold(chalk.green('Deployment successful!'))}`); | ||
consoleLog.plain(`${chalk.green('➜')} You can try your own sign-in UI on Logto Cloud now.`); | ||
consoleLog.plain(`${chalk.green('➜')} Make sure the Logto endpoint URI in your app is set to:`); | ||
consoleLog.plain(` ${chalk.blue(chalk.bold(endpointUrl.href))}`); | ||
consoleLog.plain( | ||
`${chalk.green( | ||
'➜' | ||
)} If you are using social sign-in, make sure the social redirect URI is set to:` | ||
); | ||
consoleLog.plain(` ${chalk.blue(chalk.bold(`${endpointUrl.href}callback/<connector-id>`))}`); | ||
}, | ||
}; | ||
|
||
export default tunnel; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export type DeployCommandArgs = { | ||
auth?: string; | ||
endpoint?: string; | ||
path?: string; | ||
resource?: string; | ||
verbose: boolean; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
import { appendPath } from '@silverhand/essentials'; | ||
import AdmZip from 'adm-zip'; | ||
import chalk from 'chalk'; | ||
import ora from 'ora'; | ||
|
||
import { consoleLog } from '../../utils.js'; | ||
|
||
type TokenResponse = { | ||
access_token: string; | ||
token_type: string; | ||
expires_in: number; | ||
scope: string; | ||
}; | ||
|
||
type DeployArgs = { | ||
auth: string; | ||
endpoint: string; | ||
experiencePath: string; | ||
managementApiResource?: string; | ||
verbose: boolean; | ||
}; | ||
|
||
export const deployToLogtoCloud = async ({ | ||
auth, | ||
endpoint, | ||
experiencePath, | ||
managementApiResource, | ||
verbose, | ||
}: DeployArgs) => { | ||
const spinner = ora(); | ||
if (verbose) { | ||
spinner.start('[1/4] Zipping files...'); | ||
} | ||
const zipBuffer = await zipFiles(experiencePath); | ||
if (verbose) { | ||
spinner.succeed('[1/4] Zipping files... Done.'); | ||
} | ||
|
||
try { | ||
if (verbose) { | ||
spinner.start('[2/4] Exchanging access token...'); | ||
} | ||
const endpointUrl = new URL(endpoint); | ||
const tokenResponse = await getAccessToken(auth, endpointUrl, managementApiResource); | ||
if (verbose) { | ||
spinner.succeed('[2/4] Exchanging access token... Done.'); | ||
spinner.succeed( | ||
`Token exchange response:\n${chalk.gray(JSON.stringify(tokenResponse, undefined, 2))}` | ||
); | ||
spinner.start('[3/4] Uploading zip...'); | ||
} | ||
const accessToken = tokenResponse.access_token; | ||
const uploadResult = await uploadCustomUiAssets(accessToken, endpointUrl, zipBuffer); | ||
|
||
if (verbose) { | ||
spinner.succeed('[3/4] Uploading zip... Done.'); | ||
spinner.succeed( | ||
`Received response:\n${chalk.gray(JSON.stringify(uploadResult, undefined, 2))}` | ||
); | ||
spinner.start('[4/4] Saving changes to your tenant...'); | ||
} | ||
|
||
await saveChangesToSie(accessToken, endpointUrl, uploadResult.customUiAssetId); | ||
|
||
if (verbose) { | ||
spinner.succeed('[4/4] Saving changes to your tenant... Done.'); | ||
} | ||
} catch (error: unknown) { | ||
spinner.fail(); | ||
const errorMessage = error instanceof Error ? error.message : String(error); | ||
consoleLog.fatal(chalk.red(errorMessage)); | ||
} | ||
}; | ||
|
||
const zipFiles = async (path: string): Promise<Uint8Array> => { | ||
const zip = new AdmZip(); | ||
await zip.addLocalFolderPromise(path, {}); | ||
return zip.toBuffer(); | ||
}; | ||
|
||
const getAccessToken = async (auth: string, endpoint: URL, managementApiResource?: string) => { | ||
const tokenEndpoint = appendPath(endpoint, '/oidc/token').href; | ||
const resource = managementApiResource ?? getManagementApiResourceFromEndpointUri(endpoint); | ||
|
||
const response = await fetch(tokenEndpoint, { | ||
method: 'POST', | ||
headers: { | ||
'Content-Type': 'application/x-www-form-urlencoded', | ||
Authorization: `Basic ${Buffer.from(auth).toString('base64')}`, | ||
}, | ||
body: new URLSearchParams({ | ||
grant_type: 'client_credentials', | ||
resource, | ||
scope: 'all', | ||
}).toString(), | ||
}); | ||
|
||
if (!response.ok) { | ||
throw new Error(`Failed to fetch access token: ${response.statusText}`); | ||
} | ||
|
||
return response.json<TokenResponse>(); | ||
}; | ||
|
||
const uploadCustomUiAssets = async (accessToken: string, endpoint: URL, zipBuffer: Uint8Array) => { | ||
const form = new FormData(); | ||
const blob = new Blob([zipBuffer], { type: 'application/zip' }); | ||
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', | ||
}, | ||
} | ||
); | ||
|
||
if (!uploadResponse.ok) { | ||
throw new Error(`Request error: [${uploadResponse.status}] ${uploadResponse.status}`); | ||
} | ||
|
||
return uploadResponse.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'), { | ||
method: 'PATCH', | ||
headers: { | ||
Authorization: `Bearer ${accessToken}`, | ||
Accept: 'application/json', | ||
'Content-Type': 'application/json', | ||
}, | ||
body: JSON.stringify({ | ||
customUiAssets: { id: customUiAssetId, createdAt: timestamp }, | ||
}), | ||
}); | ||
|
||
if (!patchResponse.ok) { | ||
throw new Error(`Request error: [${patchResponse.status}] ${patchResponse.statusText}`); | ||
} | ||
|
||
return patchResponse.json(); | ||
}; | ||
|
||
const getTenantIdFromEndpointUri = (endpoint: URL) => { | ||
const splitted = endpoint.hostname.split('.'); | ||
return splitted.length > 2 ? splitted[0] : 'default'; | ||
}; | ||
|
||
const getManagementApiResourceFromEndpointUri = (endpoint: URL) => { | ||
const tenantId = getTenantIdFromEndpointUri(endpoint); | ||
|
||
// This resource domain is fixed to `logto.app` for all environments (prod, staging, and dev) | ||
return `https://${tenantId}.logto.app/api`; | ||
}; |
Oops, something went wrong.