@logto/tunnel 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ import type { CommandModule } from 'yargs';
2
+ import { type DeployCommandArgs } from './types.js';
3
+ declare const tunnel: CommandModule<unknown, DeployCommandArgs>;
4
+ export default tunnel;
@@ -0,0 +1,77 @@
1
+ import { isValidUrl } from '@logto/core-kit';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { consoleLog } from '../../utils.js';
5
+ import { checkExperienceAndZipPathInputs, deployToLogtoCloud } from './utils.js';
6
+ const tunnel = {
7
+ command: ['deploy'],
8
+ describe: 'Deploy your custom UI assets to Logto Cloud',
9
+ builder: (yargs) => yargs
10
+ .options({
11
+ auth: {
12
+ 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)',
13
+ type: 'string',
14
+ },
15
+ endpoint: {
16
+ describe: 'Logto endpoint URI that points to your Logto Cloud instance. E.g.: https://<tenant-id>.logto.app/',
17
+ type: 'string',
18
+ },
19
+ path: {
20
+ alias: ['experience-path'],
21
+ describe: 'The local folder path of your custom sign-in experience assets.',
22
+ type: 'string',
23
+ },
24
+ resource: {
25
+ alias: ['management-api-resource'],
26
+ describe: 'Logto Management API resource indicator. Required if using custom domain.',
27
+ type: 'string',
28
+ },
29
+ verbose: {
30
+ describe: 'Show verbose output.',
31
+ type: 'boolean',
32
+ default: false,
33
+ },
34
+ zip: {
35
+ alias: ['zip-path'],
36
+ describe: 'The local folder path of your existing zip package.',
37
+ type: 'string',
38
+ },
39
+ })
40
+ .epilog(`Refer to our documentation for more details:\n${chalk.blue('https://docs.logto.io/docs/references/tunnel-cli/deploy')}`),
41
+ handler: async (options) => {
42
+ const { auth, endpoint, path: experiencePath, resource: managementApiResource, verbose, zip: zipPath, } = options;
43
+ if (!auth) {
44
+ 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.');
45
+ }
46
+ if (!endpoint || !isValidUrl(endpoint)) {
47
+ 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.');
48
+ }
49
+ await checkExperienceAndZipPathInputs(experiencePath, zipPath);
50
+ const spinner = ora();
51
+ if (verbose) {
52
+ consoleLog.plain(`${chalk.bold('Starting deployment...')} ${chalk.gray('(with verbose output)')}`);
53
+ }
54
+ else {
55
+ spinner.start('Deploying your custom UI assets to Logto Cloud...');
56
+ }
57
+ await deployToLogtoCloud({
58
+ auth,
59
+ endpoint,
60
+ experiencePath,
61
+ managementApiResource,
62
+ verbose,
63
+ zipPath,
64
+ });
65
+ if (!verbose) {
66
+ spinner.succeed('Deploying your custom UI assets to Logto Cloud... Done.');
67
+ }
68
+ const endpointUrl = new URL(endpoint);
69
+ spinner.succeed(`🎉 ${chalk.bold(chalk.green('Deployment successful!'))}`);
70
+ consoleLog.plain(`${chalk.green('➜')} You can try your own sign-in UI on Logto Cloud now.`);
71
+ consoleLog.plain(`${chalk.green('➜')} Make sure the Logto endpoint URI in your app is set to:`);
72
+ consoleLog.plain(` ${chalk.blue(chalk.bold(endpointUrl.href))}`);
73
+ consoleLog.plain(`${chalk.green('➜')} If you are using social sign-in, make sure the social redirect URI is set to:`);
74
+ consoleLog.plain(` ${chalk.blue(chalk.bold(`${endpointUrl.href}callback/<connector-id>`))}`);
75
+ },
76
+ };
77
+ export default tunnel;
@@ -0,0 +1,8 @@
1
+ export type DeployCommandArgs = {
2
+ auth?: string;
3
+ endpoint?: string;
4
+ path?: string;
5
+ zip?: string;
6
+ resource?: string;
7
+ verbose: boolean;
8
+ };
@@ -0,0 +1,11 @@
1
+ type DeployArgs = {
2
+ auth: string;
3
+ endpoint: string;
4
+ experiencePath?: string;
5
+ zipPath?: string;
6
+ managementApiResource?: string;
7
+ verbose: boolean;
8
+ };
9
+ export declare const checkExperienceAndZipPathInputs: (experiencePath?: string, zipPath?: string) => Promise<void>;
10
+ export declare const deployToLogtoCloud: ({ auth, endpoint, experiencePath, managementApiResource, verbose, zipPath, }: DeployArgs) => Promise<void>;
11
+ export {};
@@ -0,0 +1,159 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { appendPath } from '@silverhand/essentials';
5
+ import AdmZip from 'adm-zip';
6
+ import chalk from 'chalk';
7
+ import ora from 'ora';
8
+ import { consoleLog } from '../../utils.js';
9
+ export const checkExperienceAndZipPathInputs = async (experiencePath, zipPath) => {
10
+ if (zipPath && experiencePath) {
11
+ consoleLog.fatal('You can only specify either `--zip-path` or `--experience-path`. Please check your input and environment variables.');
12
+ }
13
+ if (!zipPath && !experiencePath) {
14
+ 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.');
15
+ }
16
+ if (zipPath) {
17
+ if (!existsSync(zipPath)) {
18
+ consoleLog.fatal(`The specified zip file does not exist: ${zipPath}`);
19
+ }
20
+ const zipFile = new AdmZip(zipPath);
21
+ const zipEntries = zipFile.getEntries();
22
+ const hasIndexHtmlInRoot = zipEntries.some(({ entryName }) => {
23
+ const parts = entryName.split('/');
24
+ return parts.length <= 2 && parts.at(-1) === 'index.html';
25
+ });
26
+ if (!hasIndexHtmlInRoot) {
27
+ consoleLog.fatal('The provided zip must contain an "index.html" file in the root directory.');
28
+ }
29
+ }
30
+ if (experiencePath && !existsSync(path.join(experiencePath, 'index.html'))) {
31
+ consoleLog.fatal(`The provided experience path must contain an "index.html" file.`);
32
+ }
33
+ };
34
+ export const deployToLogtoCloud = async ({ auth, endpoint, experiencePath, managementApiResource, verbose, zipPath, }) => {
35
+ const spinner = ora();
36
+ if (verbose) {
37
+ spinner.start(`[1/4] ${zipPath ? 'Reading zip' : 'Zipping'} files...`);
38
+ }
39
+ const zipBuffer = await getZipBuffer(experiencePath, zipPath);
40
+ if (verbose) {
41
+ spinner.succeed(`[1/4] ${zipPath ? 'Reading zip' : 'Zipping'} files... Done.`);
42
+ }
43
+ try {
44
+ if (verbose) {
45
+ spinner.start('[2/4] Exchanging access token...');
46
+ }
47
+ const endpointUrl = new URL(endpoint);
48
+ const tokenResponse = await getAccessToken(auth, endpointUrl, managementApiResource);
49
+ if (verbose) {
50
+ spinner.succeed('[2/4] Exchanging access token... Done.');
51
+ spinner.succeed(`Token exchange response:\n${chalk.gray(JSON.stringify(tokenResponse, undefined, 2))}`);
52
+ spinner.start('[3/4] Uploading zip...');
53
+ }
54
+ const accessToken = tokenResponse.access_token;
55
+ const uploadResult = await uploadCustomUiAssets(accessToken, endpointUrl, zipBuffer);
56
+ if (verbose) {
57
+ spinner.succeed('[3/4] Uploading zip... Done.');
58
+ spinner.succeed(`Received response:\n${chalk.gray(JSON.stringify(uploadResult, undefined, 2))}`);
59
+ spinner.start('[4/4] Saving changes to your tenant...');
60
+ }
61
+ await saveChangesToSie(accessToken, endpointUrl, uploadResult.customUiAssetId);
62
+ if (verbose) {
63
+ spinner.succeed('[4/4] Saving changes to your tenant... Done.');
64
+ }
65
+ }
66
+ catch (error) {
67
+ spinner.fail();
68
+ const errorMessage = error instanceof Error ? error.message : String(error);
69
+ consoleLog.fatal(chalk.red(errorMessage));
70
+ }
71
+ };
72
+ const getZipBuffer = async (experiencePath, zipPath) => {
73
+ if (!experiencePath && !zipPath) {
74
+ consoleLog.fatal('Must specify either `--experience-path` or `--zip-path`.');
75
+ }
76
+ if (zipPath) {
77
+ return readFile(zipPath);
78
+ }
79
+ if (!experiencePath) {
80
+ consoleLog.fatal('Invalid experience path input.');
81
+ }
82
+ const zip = new AdmZip();
83
+ await zip.addLocalFolderPromise(experiencePath, {
84
+ filter: (filename) => !isHiddenEntry(filename),
85
+ });
86
+ return zip.toBuffer();
87
+ };
88
+ const getAccessToken = async (auth, endpoint, managementApiResource) => {
89
+ const tokenEndpoint = appendPath(endpoint, '/oidc/token').href;
90
+ const resource = managementApiResource ?? getManagementApiResourceFromEndpointUri(endpoint);
91
+ const response = await fetch(tokenEndpoint, {
92
+ method: 'POST',
93
+ headers: {
94
+ 'Content-Type': 'application/x-www-form-urlencoded',
95
+ Authorization: `Basic ${Buffer.from(auth).toString('base64')}`,
96
+ },
97
+ body: new URLSearchParams({
98
+ grant_type: 'client_credentials',
99
+ resource,
100
+ scope: 'all',
101
+ }).toString(),
102
+ });
103
+ if (!response.ok) {
104
+ await throwRequestError(response);
105
+ }
106
+ return response.json();
107
+ };
108
+ const uploadCustomUiAssets = async (accessToken, endpoint, zipBuffer) => {
109
+ const form = new FormData();
110
+ const blob = new Blob([zipBuffer], { type: 'application/zip' });
111
+ const timestamp = Math.floor(Date.now() / 1000);
112
+ form.append('file', blob, `custom-ui-${timestamp}.zip`);
113
+ const response = await fetch(appendPath(endpoint, '/api/sign-in-exp/default/custom-ui-assets'), {
114
+ method: 'POST',
115
+ body: form,
116
+ headers: {
117
+ Authorization: `Bearer ${accessToken}`,
118
+ Accept: 'application/json',
119
+ },
120
+ });
121
+ if (!response.ok) {
122
+ await throwRequestError(response);
123
+ }
124
+ return response.json();
125
+ };
126
+ const saveChangesToSie = async (accessToken, endpointUrl, customUiAssetId) => {
127
+ const timestamp = Math.floor(Date.now() / 1000);
128
+ const response = await fetch(appendPath(endpointUrl, '/api/sign-in-exp'), {
129
+ method: 'PATCH',
130
+ headers: {
131
+ Authorization: `Bearer ${accessToken}`,
132
+ Accept: 'application/json',
133
+ 'Content-Type': 'application/json',
134
+ },
135
+ body: JSON.stringify({
136
+ customUiAssets: { id: customUiAssetId, createdAt: timestamp },
137
+ }),
138
+ });
139
+ if (!response.ok) {
140
+ await throwRequestError(response);
141
+ }
142
+ return response.json();
143
+ };
144
+ const throwRequestError = async (response) => {
145
+ const errorDetails = await response.text();
146
+ throw new Error(`[${response.status}] ${errorDetails}`);
147
+ };
148
+ const getTenantIdFromEndpointUri = (endpoint) => {
149
+ const splitted = endpoint.hostname.split('.');
150
+ return splitted.length > 2 ? splitted[0] : 'default';
151
+ };
152
+ const getManagementApiResourceFromEndpointUri = (endpoint) => {
153
+ const tenantId = getTenantIdFromEndpointUri(endpoint);
154
+ // This resource domain is fixed to `logto.app` for all environments (prod, staging, and dev)
155
+ return `https://${tenantId}.logto.app/api`;
156
+ };
157
+ const isHiddenEntry = (entryName) => {
158
+ return entryName.split('/').some((part) => part.startsWith('.'));
159
+ };
@@ -2,7 +2,7 @@ import http from 'node:http';
2
2
  import { isValidUrl } from '@logto/core-kit';
3
3
  import { conditional } from '@silverhand/essentials';
4
4
  import chalk from 'chalk';
5
- import { consoleLog } from '../utils.js';
5
+ import { consoleLog } from '../../utils.js';
6
6
  import { checkExperienceInput, createLogtoResponseHandler, createProxy, createStaticFileProxy, isLogtoRequestPath, } from './utils.js';
7
7
  const tunnel = {
8
8
  command: ['$0'],
@@ -19,7 +19,7 @@ const tunnel = {
19
19
  type: 'string',
20
20
  },
21
21
  endpoint: {
22
- describe: 'Logto endpoint URI that points to your Logto Cloud instance. E.g.: https://<tenant-id>.logto.app/',
22
+ describe: `Logto endpoint URI that points to your Logto Cloud instance. E.g.: https://<tenant-id>.logto.app/`,
23
23
  type: 'string',
24
24
  },
25
25
  port: {
@@ -34,10 +34,10 @@ const tunnel = {
34
34
  default: false,
35
35
  },
36
36
  }),
37
- handler: async ({ 'experience-uri': url, 'experience-path': path, endpoint, port, verbose }) => {
38
- checkExperienceInput(url, path);
37
+ handler: async ({ 'experience-uri': uri, 'experience-path': path, endpoint, port, verbose }) => {
38
+ checkExperienceInput(uri, path);
39
39
  if (!endpoint || !isValidUrl(endpoint)) {
40
- consoleLog.fatal('A valid Logto endpoint URI must be provided.');
40
+ 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.');
41
41
  }
42
42
  const logtoEndpointUrl = new URL(endpoint);
43
43
  const startServer = (port) => {
@@ -50,7 +50,7 @@ const tunnel = {
50
50
  tunnelServiceUrl,
51
51
  verbose,
52
52
  }));
53
- const proxyExperienceServerRequest = conditional(url && createProxy(url));
53
+ const proxyExperienceServerRequest = conditional(uri && createProxy(uri));
54
54
  const proxyExperienceStaticFileRequest = conditional(path && createStaticFileProxy(path));
55
55
  const server = http.createServer((request, response) => {
56
56
  consoleLog.info(`[${chalk.green(request.method)}] ${request.url}`);
@@ -69,21 +69,16 @@ const tunnel = {
69
69
  });
70
70
  server.listen(port, () => {
71
71
  const serviceUrl = new URL(`http://localhost:${port}`);
72
- consoleLog.info(`🎉 Logto tunnel service is running!
73
- ${chalk.green('➜')} Your custom sign-in UI is hosted on: ${chalk.blue(serviceUrl.href)}
74
-
75
- ${chalk.green('➜')} Don't forget to update Logto endpoint URI in your app:
76
-
77
- ${chalk.gray('From:')} ${chalk.bold(endpoint)}
78
- ${chalk.gray('To:')} ${chalk.bold(serviceUrl.href)}
79
-
80
- ${chalk.green('➜')} If you are using social sign-in, make sure the social redirect URI is also set to:
81
-
82
- ${chalk.bold(`${serviceUrl.href}callback/<connector-id>`)}
83
-
84
- ${chalk.green('➜')} ${chalk.gray(`Press ${chalk.white('Ctrl+C')} to stop the tunnel service.`)}
85
- ${chalk.green('➜')} ${chalk.gray(`Use ${chalk.white('--verbose')} to print verbose output.`)}
86
- `);
72
+ consoleLog.plain(`${chalk.green('✔')} 🎉 Logto tunnel service is running!`);
73
+ consoleLog.plain(`${chalk.green('➜')} Your custom sign-in UI is hosted on:`);
74
+ consoleLog.plain(` ${chalk.blue(chalk.bold(serviceUrl.href))}`);
75
+ consoleLog.plain(`${chalk.green('➜')} Remember to update Logto endpoint URI in your app:`);
76
+ consoleLog.plain(` ${chalk.gray('From:')} ${chalk.blue(chalk.bold(endpoint))}`);
77
+ consoleLog.plain(` ${chalk.gray('To:')} ${chalk.blue(chalk.bold(serviceUrl.href))}`);
78
+ consoleLog.plain(`${chalk.green('')} If you are using social sign-in, make sure the social redirect URI is also set to:`);
79
+ consoleLog.plain(` ${chalk.blue(chalk.bold(`${serviceUrl.href}callback/<connector-id>`))}\n`);
80
+ consoleLog.plain(`${chalk.green('➜')} ${chalk.gray(`Press ${chalk.white('Ctrl+C')} to stop the tunnel service.`)}`);
81
+ consoleLog.plain(`${chalk.green('➜')} ${chalk.gray(`Use ${chalk.white('--verbose')} to print verbose output.`)}`);
87
82
  });
88
83
  server.on('error', (error) => {
89
84
  if ('code' in error && error.code === 'EADDRINUSE') {
@@ -25,4 +25,4 @@ export declare const checkExperienceInput: (url?: string, staticPath?: string) =
25
25
  * @example isLogtoRequestPath('/consent') // true
26
26
  */
27
27
  export declare const isLogtoRequestPath: (requestPath?: string) => boolean;
28
- export declare const isFileAssetPath: (url: string) => boolean;
28
+ export declare const getMimeType: (requestPath: string) => string;
@@ -1,12 +1,12 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { isValidUrl } from '@logto/core-kit';
4
+ import { isFileAssetPath, isValidUrl, parseRange } from '@logto/core-kit';
5
5
  import { conditional, trySafe } from '@silverhand/essentials';
6
6
  import chalk from 'chalk';
7
7
  import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
8
8
  import mime from 'mime';
9
- import { consoleLog } from '../utils.js';
9
+ import { consoleLog } from '../../utils.js';
10
10
  export const createProxy = (targetUrl, onProxyResponse) => {
11
11
  const hasResponseHandler = Boolean(onProxyResponse);
12
12
  return createProxyMiddleware({
@@ -35,18 +35,42 @@ export const createStaticFileProxy = (staticPath) => async (request, response) =
35
35
  if (request.method === 'HEAD' || request.method === 'GET') {
36
36
  const fallBackToIndex = !isFileAssetPath(request.url);
37
37
  const requestPath = path.join(staticPath, fallBackToIndex ? index : request.url);
38
+ const { range = '' } = request.headers;
39
+ const readFile = async (requestPath, start, end) => {
40
+ const fileHandle = await fs.open(requestPath, 'r');
41
+ const { size } = await fileHandle.stat();
42
+ const readStart = start ?? 0;
43
+ const readEnd = end ?? Math.max(size - 1, 0);
44
+ const buffer = Buffer.alloc(readEnd - readStart + 1);
45
+ await fileHandle.read(buffer, 0, buffer.length, readStart);
46
+ await fileHandle.close();
47
+ return { buffer, totalFileSize: size };
48
+ };
49
+ const setRangeHeaders = (response, range, totalFileSize) => {
50
+ if (range) {
51
+ const { start, end } = parseRange(range);
52
+ const readStart = start ?? 0;
53
+ const readEnd = end ?? totalFileSize - 1;
54
+ response.setHeader('Accept-Ranges', 'bytes');
55
+ response.setHeader('Content-Range', `bytes ${readStart}-${readEnd}/${totalFileSize}`);
56
+ }
57
+ };
38
58
  try {
39
- const content = await fs.readFile(requestPath, 'utf8');
59
+ const { start, end } = parseRange(range);
60
+ const { buffer, totalFileSize } = await readFile(requestPath, start, end);
40
61
  response.setHeader('cache-control', fallBackToIndex ? noCache : maxAgeSevenDays);
41
62
  response.setHeader('content-type', getMimeType(request.url));
42
- response.writeHead(200);
43
- response.end(content);
63
+ setRangeHeaders(response, range, totalFileSize);
64
+ response.setHeader('content-length', String(buffer.length));
65
+ response.writeHead(range ? 206 : 200);
66
+ response.end(buffer);
44
67
  }
45
68
  catch (error) {
46
69
  const errorMessage = error instanceof Error ? error.message : String(error);
47
70
  consoleLog.error(chalk.red(errorMessage));
48
71
  response.setHeader('content-type', getMimeType(request.url));
49
- response.writeHead(existsSync(request.url) ? 500 : 404);
72
+ const statusCode = errorMessage === 'Range not satisfiable.' ? 416 : existsSync(request.url) ? 500 : 404;
73
+ response.writeHead(statusCode);
50
74
  response.end();
51
75
  }
52
76
  }
@@ -97,13 +121,15 @@ export const checkExperienceInput = (url, staticPath) => {
97
121
  consoleLog.fatal('Only one of the experience URI or path can be provided.');
98
122
  }
99
123
  if (!staticPath && !url) {
100
- consoleLog.fatal('Either a sign-in experience URI or local path must be provided.');
124
+ consoleLog.fatal(`Either a sign-in experience URI or local path must be provided.
125
+
126
+ Specify --help for available options`);
101
127
  }
102
128
  if (url && !isValidUrl(url)) {
103
- consoleLog.fatal('A valid sign-in experience URI must be provided. E.g.: http://localhost:4000');
129
+ consoleLog.fatal('A valid sign-in experience URI must be provided. E.g. `--experience-uri http://localhost:4000` or add `LOGTO_EXPERIENCE_URI` to your environment variables.');
104
130
  }
105
131
  if (staticPath && !existsSync(path.join(staticPath, index))) {
106
- consoleLog.fatal('The provided path does not contain a valid index.html file.');
132
+ consoleLog.fatal('The provided path must contain a valid index.html file.');
107
133
  }
108
134
  };
109
135
  /**
@@ -114,12 +140,7 @@ export const checkExperienceInput = (url, staticPath) => {
114
140
  * @example isLogtoRequestPath('/consent') // true
115
141
  */
116
142
  export const isLogtoRequestPath = (requestPath) => ['/oidc/', '/api/'].some((path) => requestPath?.startsWith(path)) || requestPath === '/consent';
117
- export const isFileAssetPath = (url) => {
118
- // Check if the request URL contains query params. If yes, ignore the params and check the request path
119
- const pathWithoutQuery = url.split('?')[0];
120
- return Boolean(pathWithoutQuery?.split('/').at(-1)?.includes('.'));
121
- };
122
- const getMimeType = (requestPath) => {
143
+ export const getMimeType = (requestPath) => {
123
144
  const fallBackToIndex = !isFileAssetPath(requestPath);
124
145
  if (fallBackToIndex) {
125
146
  return indexContentType;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { expect, describe, it } from 'vitest';
2
+ import { getMimeType } from './utils.js';
3
+ describe('Tunnel utils', () => {
4
+ it('should be able to get mime type according to request path', () => {
5
+ expect(getMimeType('/scripts.js')).toEqual('text/javascript');
6
+ expect(getMimeType('/image.png')).toEqual('image/png');
7
+ expect(getMimeType('/style.css')).toEqual('text/css');
8
+ expect(getMimeType('/index.html')).toEqual('text/html');
9
+ expect(getMimeType('/')).toEqual('text/html; charset=utf-8');
10
+ });
11
+ });
package/lib/index.js CHANGED
@@ -1,24 +1,20 @@
1
1
  import chalk from 'chalk';
2
2
  import dotenv from 'dotenv';
3
+ import { findUp } from 'find-up';
3
4
  import yargs from 'yargs';
4
5
  import { hideBin } from 'yargs/helpers';
5
- import tunnel from './commands/index.js';
6
+ import deploy from './commands/deploy/index.js';
7
+ import tunnel from './commands/tunnel/index.js';
6
8
  import { packageJson } from './package-json.js';
7
9
  import { consoleLog } from './utils.js';
10
+ dotenv.config({ path: await findUp('.env', {}) });
8
11
  void yargs(hideBin(process.argv))
9
12
  .version(false)
10
- .option('env', {
11
- alias: ['e', 'env-file'],
12
- describe: 'The path to your `.env` file',
13
- type: 'string',
14
- })
13
+ .env('LOGTO')
15
14
  .option('version', {
16
15
  alias: 'v',
17
16
  describe: 'Print CLI version',
18
17
  type: 'boolean',
19
- })
20
- .middleware(({ env }) => {
21
- dotenv.config({ path: env });
22
18
  })
23
19
  .middleware(({ version }) => {
24
20
  if (version) {
@@ -28,9 +24,11 @@ void yargs(hideBin(process.argv))
28
24
  }
29
25
  }, true)
30
26
  .command(tunnel)
27
+ .command(deploy)
31
28
  .showHelpOnFail(false, `Specify ${chalk.green('--help')} for available options`)
32
- .strict()
29
+ .strictCommands()
33
30
  .parserConfiguration({
34
31
  'dot-notation': false,
35
32
  })
33
+ .epilog(`Refer to our documentation for more details:\n${chalk.blue('https://docs.logto.io/docs/references/tunnel-cli')}`)
36
34
  .parse();
@@ -41,16 +41,20 @@ export declare const packageJson: {
41
41
  "@logto/core-kit": string;
42
42
  "@logto/shared": string;
43
43
  "@silverhand/essentials": string;
44
+ "adm-zip": string;
44
45
  chalk: string;
45
46
  dotenv: string;
47
+ "find-up": string;
46
48
  "http-proxy-middleware": string;
47
49
  mime: string;
50
+ ora: string;
48
51
  yargs: string;
49
52
  zod: string;
50
53
  };
51
54
  devDependencies: {
52
55
  "@silverhand/eslint-config": string;
53
56
  "@silverhand/ts-config": string;
57
+ "@types/adm-zip": string;
54
58
  "@types/node": string;
55
59
  "@types/yargs": string;
56
60
  "@vitest/coverage-v8": string;
@@ -1,6 +1,6 @@
1
1
  export const packageJson = {
2
2
  "name": "@logto/tunnel",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A CLI tool that creates tunnel service to Logto Cloud for local development.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -44,16 +44,20 @@ export const packageJson = {
44
44
  "@logto/core-kit": "workspace:^",
45
45
  "@logto/shared": "workspace:^",
46
46
  "@silverhand/essentials": "^2.9.1",
47
+ "adm-zip": "^0.5.14",
47
48
  "chalk": "^5.3.0",
48
49
  "dotenv": "^16.4.5",
50
+ "find-up": "^7.0.0",
49
51
  "http-proxy-middleware": "^3.0.0",
50
52
  "mime": "^4.0.4",
53
+ "ora": "^8.0.1",
51
54
  "yargs": "^17.6.0",
52
55
  "zod": "^3.23.8"
53
56
  },
54
57
  "devDependencies": {
55
58
  "@silverhand/eslint-config": "6.0.1",
56
59
  "@silverhand/ts-config": "6.0.0",
60
+ "@types/adm-zip": "^0.5.5",
57
61
  "@types/node": "^20.9.5",
58
62
  "@types/yargs": "^17.0.13",
59
63
  "@vitest/coverage-v8": "^2.0.0",
package/lib/utils.js CHANGED
@@ -1,12 +1,2 @@
1
1
  import { ConsoleLog } from '@logto/shared';
2
- // The explicit type annotation is required to make `.fatal()`
3
- // works correctly without `return`:
4
- //
5
- // ```ts
6
- // const foo: number | undefined;
7
- // consoleLog.fatal();
8
- // typeof foo // Still `number | undefined` without explicit type annotation
9
- // ```
10
- //
11
- // For now I have no idea why.
12
2
  export const consoleLog = new ConsoleLog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/tunnel",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A CLI tool that creates tunnel service to Logto Cloud for local development.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -29,18 +29,22 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@silverhand/essentials": "^2.9.1",
32
+ "adm-zip": "^0.5.14",
32
33
  "chalk": "^5.3.0",
33
34
  "dotenv": "^16.4.5",
35
+ "find-up": "^7.0.0",
34
36
  "http-proxy-middleware": "^3.0.0",
35
37
  "mime": "^4.0.4",
38
+ "ora": "^8.0.1",
36
39
  "yargs": "^17.6.0",
37
40
  "zod": "^3.23.8",
38
41
  "@logto/shared": "^3.1.1",
39
- "@logto/core-kit": "^2.5.0"
42
+ "@logto/core-kit": "^2.5.1"
40
43
  },
41
44
  "devDependencies": {
42
45
  "@silverhand/eslint-config": "6.0.1",
43
46
  "@silverhand/ts-config": "6.0.0",
47
+ "@types/adm-zip": "^0.5.5",
44
48
  "@types/node": "^20.9.5",
45
49
  "@types/yargs": "^17.0.13",
46
50
  "@vitest/coverage-v8": "^2.0.0",
@@ -1,14 +0,0 @@
1
- import { expect, describe, it } from 'vitest';
2
- import { isFileAssetPath } from './utils.js';
3
- describe('Tunnel utils', () => {
4
- it('should be able to check if a request path is file asset', () => {
5
- expect(isFileAssetPath('/file.js')).toBe(true);
6
- expect(isFileAssetPath('/file.css')).toBe(true);
7
- expect(isFileAssetPath('/file.png')).toBe(true);
8
- expect(isFileAssetPath('/oidc/.well-known/openid-configuration')).toBe(false);
9
- expect(isFileAssetPath('/oidc/auth')).toBe(false);
10
- expect(isFileAssetPath('/api/interaction/submit')).toBe(false);
11
- expect(isFileAssetPath('/consent')).toBe(false);
12
- expect(isFileAssetPath('/callback/45doq0d004awrjyvdbp92?state=PxsR_Iqtkxw&code=4/0AcvDMrCOMTFXWlKzTcUO24xDify5tQbIMYvaYDS0sj82NzzYlrG4BWXJB4-OxjBI1RPL8g&scope=email%20profile%20openid%20https:/www.googleapis.com/auth/userinfo.profile%20https:/www.googleapis.com/auth/userinfo.email&authuser=0&hd=silverhand.io&prompt=consent')).toBe(false);
13
- });
14
- });
File without changes
File without changes
File without changes