@logto/cli 1.0.3 → 1.2.0
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.
- package/lib/commands/connector/add.js +4 -4
- package/lib/commands/connector/index.js +2 -0
- package/lib/commands/connector/link.d.ts +9 -0
- package/lib/commands/connector/link.js +46 -0
- package/lib/commands/connector/list.js +4 -3
- package/lib/commands/connector/remove.js +7 -7
- package/lib/commands/connector/utils.d.ts +2 -0
- package/lib/commands/connector/utils.js +29 -17
- package/lib/commands/database/alteration/index.js +8 -8
- package/lib/commands/database/alteration/utils.js +3 -3
- package/lib/commands/database/alteration/version.js +4 -4
- package/lib/commands/database/config.js +11 -11
- package/lib/commands/database/seed/cloud.js +2 -2
- package/lib/commands/database/seed/index.js +4 -5
- package/lib/commands/database/seed/oidc-config.js +6 -6
- package/lib/commands/database/seed/tables.js +4 -4
- package/lib/commands/database/seed/tenant.js +2 -2
- package/lib/commands/database/system.js +5 -5
- package/lib/commands/database/utils.js +3 -3
- package/lib/commands/install/index.js +3 -3
- package/lib/commands/install/utils.js +17 -17
- package/lib/commands/translate/create.d.ts +8 -0
- package/lib/commands/translate/create.js +38 -0
- package/lib/commands/translate/index.d.ts +3 -0
- package/lib/commands/translate/index.js +20 -0
- package/lib/commands/translate/list-tags.d.ts +3 -0
- package/lib/commands/translate/list-tags.js +19 -0
- package/lib/commands/translate/openai.d.ts +11 -0
- package/lib/commands/translate/openai.js +77 -0
- package/lib/commands/translate/prompts.d.ts +14 -0
- package/lib/commands/translate/prompts.js +39 -0
- package/lib/commands/translate/sync.d.ts +7 -0
- package/lib/commands/translate/sync.js +40 -0
- package/lib/commands/translate/utils.d.ts +14 -0
- package/lib/commands/translate/utils.js +131 -0
- package/lib/connector/factories.js +2 -1
- package/lib/connector/loader.js +5 -4
- package/lib/connector/utils.js +3 -3
- package/lib/database.js +3 -3
- package/lib/index.js +4 -2
- package/lib/package-json.d.ts +7 -0
- package/lib/package-json.js +14 -7
- package/lib/utils.d.ts +3 -8
- package/lib/utils.js +23 -24
- package/package.json +15 -8
|
@@ -3,13 +3,13 @@ import { noop } from '@silverhand/essentials';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { createPoolFromConfig } from '../../database.js';
|
|
5
5
|
import { getRowByKey, updateValueByKey } from '../../queries/system.js';
|
|
6
|
-
import {
|
|
6
|
+
import { consoleLog } from '../../utils.js';
|
|
7
7
|
const validKeysDisplay = chalk.green(systemKeys.join(', '));
|
|
8
8
|
const validateKey = (key) => {
|
|
9
9
|
// Using `.includes()` will result a type error
|
|
10
10
|
// eslint-disable-next-line unicorn/prefer-includes
|
|
11
11
|
if (!systemKeys.some((element) => element === key)) {
|
|
12
|
-
|
|
12
|
+
consoleLog.fatal(`Invalid config key ${chalk.red(key)} found, expected one of ${validKeysDisplay}`);
|
|
13
13
|
}
|
|
14
14
|
};
|
|
15
15
|
const getConfig = {
|
|
@@ -26,7 +26,7 @@ const getConfig = {
|
|
|
26
26
|
const row = await getRowByKey(pool, key);
|
|
27
27
|
await pool.end();
|
|
28
28
|
const value = row?.value;
|
|
29
|
-
|
|
29
|
+
consoleLog.plain(chalk.magenta(key) +
|
|
30
30
|
'=' +
|
|
31
31
|
(value === undefined ? chalk.gray(value) : chalk.green(JSON.stringify(value))));
|
|
32
32
|
},
|
|
@@ -41,7 +41,7 @@ const setConfig = {
|
|
|
41
41
|
demandOption: true,
|
|
42
42
|
})
|
|
43
43
|
.positional('value', {
|
|
44
|
-
describe: 'The value to set, should be a valid JSON string',
|
|
44
|
+
describe: 'The value to set, should be a valid JSON string.',
|
|
45
45
|
type: 'string',
|
|
46
46
|
demandOption: true,
|
|
47
47
|
}),
|
|
@@ -51,7 +51,7 @@ const setConfig = {
|
|
|
51
51
|
const pool = await createPoolFromConfig();
|
|
52
52
|
await updateValueByKey(pool, key, guarded);
|
|
53
53
|
await pool.end();
|
|
54
|
-
|
|
54
|
+
consoleLog.info(`Update ${chalk.green(key)} succeeded`);
|
|
55
55
|
},
|
|
56
56
|
};
|
|
57
57
|
const system = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { generateKeyPair } from 'crypto';
|
|
2
|
-
import { promisify } from 'util';
|
|
3
|
-
import { generateStandardId } from '@logto/
|
|
1
|
+
import { generateKeyPair } from 'node:crypto';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { generateStandardId } from '@logto/shared';
|
|
4
4
|
export const generateOidcPrivateKey = async (type = 'ec') => {
|
|
5
5
|
if (type === 'rsa') {
|
|
6
6
|
const { privateKey } = await promisify(generateKeyPair)('rsa', {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { getDatabaseUrlFromConfig } from '../../database.js';
|
|
3
|
-
import {
|
|
3
|
+
import { consoleLog } from '../../utils.js';
|
|
4
4
|
import { validateNodeVersion, inquireInstancePath, validateDatabase, downloadRelease, seedDatabase, createEnv, logFinale, decompress, isUrl, } from './utils.js';
|
|
5
5
|
const installLogto = async ({ path, skipSeed, downloadUrl, cloud }) => {
|
|
6
6
|
validateNodeVersion();
|
|
@@ -13,7 +13,7 @@ const installLogto = async ({ path, skipSeed, downloadUrl, cloud }) => {
|
|
|
13
13
|
await decompress(instancePath, tarPath);
|
|
14
14
|
// Seed database
|
|
15
15
|
if (skipSeed) {
|
|
16
|
-
|
|
16
|
+
consoleLog.info(`Skipped database seeding.\n\n' + ' You can use the ${chalk.green('db seed')} command to seed database when ready.\n`);
|
|
17
17
|
}
|
|
18
18
|
else {
|
|
19
19
|
await seedDatabase(instancePath, cloud);
|
|
@@ -46,7 +46,7 @@ const install = {
|
|
|
46
46
|
},
|
|
47
47
|
du: {
|
|
48
48
|
alias: 'download-url',
|
|
49
|
-
describe: 'URL for downloading Logto, can be a local path to tar',
|
|
49
|
+
describe: 'URL for downloading Logto, can be a local path to tar.',
|
|
50
50
|
type: 'string',
|
|
51
51
|
hidden: true,
|
|
52
52
|
},
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
2
|
-
import { existsSync } from 'fs';
|
|
3
|
-
import fs from 'fs/promises';
|
|
4
|
-
import os from 'os';
|
|
5
|
-
import path from 'path';
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
6
|
import { assert } from '@silverhand/essentials';
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import inquirer from 'inquirer';
|
|
@@ -10,7 +10,7 @@ import * as semver from 'semver';
|
|
|
10
10
|
import tar from 'tar';
|
|
11
11
|
import { createPoolAndDatabaseIfNeeded } from '../../database.js';
|
|
12
12
|
import { packageJson } from '../../package-json.js';
|
|
13
|
-
import { cliConfig, ConfigKey, downloadFile, isTty,
|
|
13
|
+
import { cliConfig, ConfigKey, consoleLog, downloadFile, isTty, oraPromise, safeExecSync, } from '../../utils.js';
|
|
14
14
|
import { seedByPool } from '../database/seed/index.js';
|
|
15
15
|
export const defaultPath = path.join(os.homedir(), 'logto');
|
|
16
16
|
const pgRequired = new semver.SemVer('14.0.0');
|
|
@@ -19,10 +19,10 @@ export const validateNodeVersion = () => {
|
|
|
19
19
|
const requiredVersionString = required.map((version) => '^' + version.version).join(' || ');
|
|
20
20
|
const current = new semver.SemVer(execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }));
|
|
21
21
|
if (required.every((version) => version.major !== current.major)) {
|
|
22
|
-
|
|
22
|
+
consoleLog.fatal(`Logto requires NodeJS ${requiredVersionString}, but ${current.version} found.`);
|
|
23
23
|
}
|
|
24
24
|
if (required.some((version) => version.major === current.major && version.compare(current) > 0)) {
|
|
25
|
-
|
|
25
|
+
consoleLog.warn(`Logto is tested under NodeJS ${requiredVersionString}, but version ${current.version} found.`);
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
const validatePath = (value) => existsSync(path.resolve(value))
|
|
@@ -44,7 +44,7 @@ export const inquireInstancePath = async (initialPath) => {
|
|
|
44
44
|
// Validate for initialPath
|
|
45
45
|
const validated = validatePath(instancePath);
|
|
46
46
|
if (validated !== true) {
|
|
47
|
-
|
|
47
|
+
consoleLog.fatal(validated);
|
|
48
48
|
}
|
|
49
49
|
return instancePath;
|
|
50
50
|
};
|
|
@@ -65,15 +65,15 @@ export const validateDatabase = async () => {
|
|
|
65
65
|
},
|
|
66
66
|
});
|
|
67
67
|
if (hasPostgresUrl === false) {
|
|
68
|
-
|
|
68
|
+
consoleLog.fatal('Logto requires a Postgres instance to run.');
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
71
|
export const downloadRelease = async (url) => {
|
|
72
72
|
const tarFilePath = path.resolve(os.tmpdir(), './logto.tar.gz');
|
|
73
73
|
const from = url ??
|
|
74
74
|
`https://github.com/logto-io/logto/releases/download/v${packageJson.version}/logto.tar.gz`;
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
consoleLog.info(`Download Logto from ${from}`);
|
|
76
|
+
consoleLog.info(`Target ${tarFilePath}`);
|
|
77
77
|
await downloadFile(from, tarFilePath);
|
|
78
78
|
return tarFilePath;
|
|
79
79
|
};
|
|
@@ -84,7 +84,7 @@ export const decompress = async (toPath, tarPath) => {
|
|
|
84
84
|
await tar.extract({ file: tarPath, cwd: toPath, strip: 1 });
|
|
85
85
|
}
|
|
86
86
|
catch (error) {
|
|
87
|
-
|
|
87
|
+
consoleLog.fatal(error);
|
|
88
88
|
}
|
|
89
89
|
};
|
|
90
90
|
return oraPromise(run(), {
|
|
@@ -99,23 +99,23 @@ export const seedDatabase = async (instancePath, cloud) => {
|
|
|
99
99
|
await pool.end();
|
|
100
100
|
}
|
|
101
101
|
catch (error) {
|
|
102
|
-
|
|
102
|
+
consoleLog.error(error);
|
|
103
103
|
await oraPromise(fs.rm(instancePath, { force: true, recursive: true }), {
|
|
104
104
|
text: 'Clean up',
|
|
105
105
|
prefixText: chalk.blue('[info]'),
|
|
106
106
|
});
|
|
107
|
-
|
|
107
|
+
consoleLog.fatal('Error occurred during seeding your Logto database. Nothing has changed since the seeding process was in a transaction.\n\n' +
|
|
108
108
|
` To skip the database seeding, append ${chalk.green('--skip-seed')} to the command options.`);
|
|
109
109
|
}
|
|
110
110
|
};
|
|
111
111
|
export const createEnv = async (instancePath, databaseUrl) => {
|
|
112
112
|
const dotEnvPath = path.resolve(instancePath, '.env');
|
|
113
113
|
await fs.writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, 'utf8');
|
|
114
|
-
|
|
114
|
+
consoleLog.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`);
|
|
115
115
|
};
|
|
116
116
|
export const logFinale = (instancePath) => {
|
|
117
117
|
const startCommand = `cd ${instancePath} && npm start`;
|
|
118
|
-
|
|
118
|
+
consoleLog.info(`Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}`);
|
|
119
119
|
};
|
|
120
120
|
export const isUrl = (string) => {
|
|
121
121
|
try {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isLanguageTag } from '@logto/language-kit';
|
|
2
|
+
import { isBuiltInLanguageTag as isPhrasesBuiltInLanguageTag } from '@logto/phrases';
|
|
3
|
+
import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-ui';
|
|
4
|
+
import { consoleLog } from '../../utils.js';
|
|
5
|
+
import { inquireInstancePath } from '../connector/utils.js';
|
|
6
|
+
import { createFullTranslation } from './utils.js';
|
|
7
|
+
const create = {
|
|
8
|
+
command: ['create <language-tag>', 'c'],
|
|
9
|
+
describe: 'Create a new language translation using ChatGPT. Note the environment variable `OPENAI_API_KEY` is required to work.',
|
|
10
|
+
builder: (yargs) => yargs.positional('language-tag', {
|
|
11
|
+
describe: 'The language tag to create, e.g. `af-ZA`.',
|
|
12
|
+
type: 'string',
|
|
13
|
+
demandOption: true,
|
|
14
|
+
}),
|
|
15
|
+
handler: async ({ path: inputPath, languageTag }) => {
|
|
16
|
+
if (!isLanguageTag(languageTag)) {
|
|
17
|
+
consoleLog.fatal('Invalid language tag. Run `logto translate list-tags` to see available list.');
|
|
18
|
+
}
|
|
19
|
+
const instancePath = await inquireInstancePath(inputPath);
|
|
20
|
+
if (isPhrasesBuiltInLanguageTag(languageTag)) {
|
|
21
|
+
consoleLog.info(languageTag + ' is a built-in tag of phrases, updating untranslated phrases');
|
|
22
|
+
}
|
|
23
|
+
await createFullTranslation({
|
|
24
|
+
instancePath,
|
|
25
|
+
packageName: 'phrases',
|
|
26
|
+
languageTag,
|
|
27
|
+
});
|
|
28
|
+
if (isPhrasesUiBuiltInLanguageTag(languageTag)) {
|
|
29
|
+
consoleLog.info(languageTag + ' is a built-in tag of phrases-ui, updating untranslated phrases');
|
|
30
|
+
}
|
|
31
|
+
await createFullTranslation({
|
|
32
|
+
instancePath,
|
|
33
|
+
packageName: 'phrases-ui',
|
|
34
|
+
languageTag,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
export default create;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { noop } from '@silverhand/essentials';
|
|
2
|
+
import create from './create.js';
|
|
3
|
+
import listTags from './list-tags.js';
|
|
4
|
+
import sync from './sync.js';
|
|
5
|
+
const translate = {
|
|
6
|
+
command: ['translate', 't'],
|
|
7
|
+
describe: 'Commands for Logto translation',
|
|
8
|
+
builder: (yargs) => yargs
|
|
9
|
+
.option('path', {
|
|
10
|
+
alias: 'p',
|
|
11
|
+
type: 'string',
|
|
12
|
+
describe: 'The path to your Logto instance directory',
|
|
13
|
+
})
|
|
14
|
+
.command(create)
|
|
15
|
+
.command(listTags)
|
|
16
|
+
.command(sync)
|
|
17
|
+
.demandCommand(1),
|
|
18
|
+
handler: noop,
|
|
19
|
+
};
|
|
20
|
+
export default translate;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { languages } from '@logto/language-kit';
|
|
2
|
+
import { isBuiltInLanguageTag as isPhrasesBuiltInLanguageTag } from '@logto/phrases';
|
|
3
|
+
import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-ui';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { consoleLog } from '../../utils.js';
|
|
6
|
+
const listTags = {
|
|
7
|
+
command: ['list-tags', 'list'],
|
|
8
|
+
describe: 'List all available language tags',
|
|
9
|
+
handler: async () => {
|
|
10
|
+
for (const tag of Object.keys(languages)) {
|
|
11
|
+
consoleLog.plain(...[
|
|
12
|
+
tag,
|
|
13
|
+
isPhrasesBuiltInLanguageTag(tag) && chalk.blue('phrases'),
|
|
14
|
+
isPhrasesUiBuiltInLanguageTag(tag) && chalk.blue('phrases-ui'),
|
|
15
|
+
].filter(Boolean));
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export default listTags;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type LanguageTag } from '@logto/language-kit';
|
|
2
|
+
import { type Got } from 'got';
|
|
3
|
+
export declare const createOpenaiApi: () => Got;
|
|
4
|
+
type TranslateConfig = {
|
|
5
|
+
api: Got;
|
|
6
|
+
sourceFilePath: string;
|
|
7
|
+
targetLanguage: LanguageTag;
|
|
8
|
+
extraPrompt?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const translate: ({ api, targetLanguage, sourceFilePath, extraPrompt, }: TranslateConfig) => Promise<string | undefined>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { trySafe } from '@silverhand/essentials';
|
|
3
|
+
import { got, HTTPError } from 'got';
|
|
4
|
+
import { HttpsProxyAgent } from 'hpagent';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { consoleLog, getProxy } from '../../utils.js';
|
|
7
|
+
import { getTranslationPrompt } from './prompts.js';
|
|
8
|
+
export const createOpenaiApi = () => {
|
|
9
|
+
const proxy = getProxy();
|
|
10
|
+
return got.extend({
|
|
11
|
+
prefixUrl: 'https://api.openai.com/v1',
|
|
12
|
+
headers: {
|
|
13
|
+
Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ''}`,
|
|
14
|
+
},
|
|
15
|
+
timeout: { request: 300_000 },
|
|
16
|
+
...(proxy && { agent: { https: new HttpsProxyAgent({ proxy, timeout: 300_000 }) } }),
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
const gptResponseGuard = z.object({
|
|
20
|
+
choices: z
|
|
21
|
+
.object({
|
|
22
|
+
message: z.object({ role: z.string(), content: z.string() }),
|
|
23
|
+
finish_reason: z.string(),
|
|
24
|
+
})
|
|
25
|
+
.array(),
|
|
26
|
+
});
|
|
27
|
+
export const translate = async ({ api, targetLanguage, sourceFilePath, extraPrompt, }) => {
|
|
28
|
+
const sourceFileContent = await fs.readFile(sourceFilePath, 'utf8');
|
|
29
|
+
const response = await trySafe(api
|
|
30
|
+
.post('chat/completions', {
|
|
31
|
+
json: {
|
|
32
|
+
model: 'gpt-3.5-turbo',
|
|
33
|
+
messages: [
|
|
34
|
+
{
|
|
35
|
+
role: 'user',
|
|
36
|
+
content: getTranslationPrompt({
|
|
37
|
+
sourceFileContent,
|
|
38
|
+
targetLanguage,
|
|
39
|
+
extraPrompt,
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
.json(), (error) => {
|
|
46
|
+
consoleLog.warn(`Error while translating ${sourceFilePath}:`, String(error));
|
|
47
|
+
if (error instanceof HTTPError) {
|
|
48
|
+
consoleLog.warn(error.response.body);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
if (!response) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const guarded = gptResponseGuard.safeParse(response);
|
|
55
|
+
if (!guarded.success) {
|
|
56
|
+
consoleLog.warn(`Error while guarding response for ${sourceFilePath}:`, response);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const [entity] = guarded.data.choices;
|
|
60
|
+
if (!entity) {
|
|
61
|
+
consoleLog.warn(`No choice found in response when translating ${sourceFilePath}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (entity.finish_reason !== 'stop') {
|
|
65
|
+
consoleLog.warn(`Unexpected finish reason ${entity.finish_reason} for ${sourceFilePath}`);
|
|
66
|
+
}
|
|
67
|
+
const { content } = entity.message;
|
|
68
|
+
const matched = /```(?:ts)?\n(.*)```/s.exec(content)?.[1];
|
|
69
|
+
if (!matched) {
|
|
70
|
+
// Treat as pure code
|
|
71
|
+
if (['const ', 'import '].some((prefix) => content.startsWith(prefix))) {
|
|
72
|
+
return content;
|
|
73
|
+
}
|
|
74
|
+
consoleLog.warn('No matching code snippet from response:', content);
|
|
75
|
+
}
|
|
76
|
+
return matched;
|
|
77
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type LanguageTag } from '@logto/language-kit';
|
|
2
|
+
type GetTranslationPromptProperties = {
|
|
3
|
+
sourceFileContent: string;
|
|
4
|
+
targetLanguage: LanguageTag;
|
|
5
|
+
extraPrompt?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Note:
|
|
9
|
+
* The input token limit of GPT 3.5 is 2048, the following prompt tokens with sourceFileContent is about 1200.
|
|
10
|
+
* Remember to check the token limit before adding more prompt.
|
|
11
|
+
* Tokens can be counted in https://platform.openai.com/tokenizer
|
|
12
|
+
*/
|
|
13
|
+
export declare const getTranslationPrompt: ({ sourceFileContent, targetLanguage, extraPrompt, }: GetTranslationPromptProperties) => string;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { languages } from '@logto/language-kit';
|
|
2
|
+
import { conditionalString } from '@silverhand/essentials';
|
|
3
|
+
/**
|
|
4
|
+
* Note:
|
|
5
|
+
* The input token limit of GPT 3.5 is 2048, the following prompt tokens with sourceFileContent is about 1200.
|
|
6
|
+
* Remember to check the token limit before adding more prompt.
|
|
7
|
+
* Tokens can be counted in https://platform.openai.com/tokenizer
|
|
8
|
+
*/
|
|
9
|
+
export const getTranslationPrompt = ({ sourceFileContent, targetLanguage, extraPrompt, }) => `Given the following code snippet:
|
|
10
|
+
\`\`\`ts
|
|
11
|
+
${sourceFileContent}
|
|
12
|
+
\`\`\`
|
|
13
|
+
only translate object values to ${languages[targetLanguage]}, keep all object keys original, output ts code only, the code format should be strictly consistent, and should not contains the given code snippet.
|
|
14
|
+
|
|
15
|
+
Take zh-cn as an example, if the input is:
|
|
16
|
+
\`\`\`ts
|
|
17
|
+
import others from './others.js';
|
|
18
|
+
|
|
19
|
+
const translation = {
|
|
20
|
+
hello: '你好',
|
|
21
|
+
world: 'world', // UNTRANSLATED
|
|
22
|
+
others,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default translation;
|
|
26
|
+
\`\`\`
|
|
27
|
+
the output should be:
|
|
28
|
+
\`\`\`ts
|
|
29
|
+
import others from './others.js';
|
|
30
|
+
|
|
31
|
+
const translation = {
|
|
32
|
+
hello: '你好',
|
|
33
|
+
world: '世界',
|
|
34
|
+
others,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default translation;
|
|
38
|
+
\`\`\`
|
|
39
|
+
${conditionalString(extraPrompt)}`;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { languages } from '@logto/language-kit';
|
|
2
|
+
import { isBuiltInLanguageTag as isPhrasesBuiltInLanguageTag } from '@logto/phrases';
|
|
3
|
+
import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-ui';
|
|
4
|
+
import PQueue from 'p-queue';
|
|
5
|
+
import { inquireInstancePath } from '../connector/utils.js';
|
|
6
|
+
import { baseLanguage, syncTranslation } from './utils.js';
|
|
7
|
+
const sync = {
|
|
8
|
+
command: ['sync'],
|
|
9
|
+
describe: 'Translate all untranslated phrases using ChatGPT. Note the environment variable `OPENAI_API_KEY` is required to work.',
|
|
10
|
+
handler: async ({ path: inputPath }) => {
|
|
11
|
+
const queue = new PQueue({ concurrency: 5 });
|
|
12
|
+
const instancePath = await inquireInstancePath(inputPath);
|
|
13
|
+
for (const languageTag of Object.keys(languages)) {
|
|
14
|
+
if (languageTag === baseLanguage) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const baseOptions = {
|
|
18
|
+
instancePath,
|
|
19
|
+
verbose: false,
|
|
20
|
+
queue,
|
|
21
|
+
};
|
|
22
|
+
if (isPhrasesBuiltInLanguageTag(languageTag)) {
|
|
23
|
+
void syncTranslation({
|
|
24
|
+
...baseOptions,
|
|
25
|
+
packageName: 'phrases',
|
|
26
|
+
languageTag,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (isPhrasesUiBuiltInLanguageTag(languageTag)) {
|
|
30
|
+
void syncTranslation({
|
|
31
|
+
...baseOptions,
|
|
32
|
+
packageName: 'phrases-ui',
|
|
33
|
+
languageTag,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
await queue.onIdle();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
export default sync;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type LanguageTag } from '@logto/language-kit';
|
|
2
|
+
import PQueue from 'p-queue';
|
|
3
|
+
export declare const baseLanguage = "en";
|
|
4
|
+
export declare const readLocaleFiles: (directory: string) => Promise<string[]>;
|
|
5
|
+
export declare const readBaseLocaleFiles: (directory: string) => Promise<string[]>;
|
|
6
|
+
export type TranslationOptions = {
|
|
7
|
+
instancePath: string;
|
|
8
|
+
packageName: 'phrases' | 'phrases-ui';
|
|
9
|
+
languageTag: LanguageTag;
|
|
10
|
+
verbose?: boolean;
|
|
11
|
+
queue?: PQueue;
|
|
12
|
+
};
|
|
13
|
+
export declare const createFullTranslation: ({ instancePath, packageName, languageTag, verbose, queue, }: TranslationOptions) => Promise<void>;
|
|
14
|
+
export declare const syncTranslation: ({ instancePath, packageName, languageTag, verbose, queue, }: TranslationOptions) => Promise<void>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { conditionalString } from '@silverhand/essentials';
|
|
5
|
+
import PQueue from 'p-queue';
|
|
6
|
+
import { consoleLog } from '../../utils.js';
|
|
7
|
+
import { createOpenaiApi, translate } from './openai.js';
|
|
8
|
+
export const baseLanguage = 'en';
|
|
9
|
+
const untranslatedMark = '// UNTRANSLATED';
|
|
10
|
+
export const readLocaleFiles = async (directory) => {
|
|
11
|
+
const entities = await fs.readdir(directory, { withFileTypes: true });
|
|
12
|
+
const result = await Promise.all(entities.map(async (entity) => {
|
|
13
|
+
if (entity.isDirectory()) {
|
|
14
|
+
return readLocaleFiles(path.join(directory, entity.name));
|
|
15
|
+
}
|
|
16
|
+
return entity.name.endsWith('.ts') ? path.join(directory, entity.name) : [];
|
|
17
|
+
}));
|
|
18
|
+
return result.flat();
|
|
19
|
+
};
|
|
20
|
+
export const readBaseLocaleFiles = async (directory) => {
|
|
21
|
+
const enDirectory = path.join(directory, baseLanguage.toLowerCase());
|
|
22
|
+
const stat = await fs.stat(enDirectory);
|
|
23
|
+
if (!stat.isDirectory()) {
|
|
24
|
+
consoleLog.fatal(directory, 'has no `' + baseLanguage.toLowerCase() + '` directory');
|
|
25
|
+
}
|
|
26
|
+
return readLocaleFiles(enDirectory);
|
|
27
|
+
};
|
|
28
|
+
export const createFullTranslation = async ({ instancePath, packageName, languageTag, verbose = true, queue = new PQueue({ concurrency: 5 }), }) => {
|
|
29
|
+
const localeFiles = await getBaseAndTargetLocaleFiles(instancePath, packageName, languageTag);
|
|
30
|
+
if (verbose) {
|
|
31
|
+
consoleLog.info('Found ' +
|
|
32
|
+
String(localeFiles.length) +
|
|
33
|
+
' file' +
|
|
34
|
+
conditionalString(localeFiles.length !== 1 && 's') +
|
|
35
|
+
' in ' +
|
|
36
|
+
packageName +
|
|
37
|
+
' to create');
|
|
38
|
+
}
|
|
39
|
+
const openai = createOpenaiApi();
|
|
40
|
+
for (const { baseLocaleFile, targetLocaleFile } of localeFiles) {
|
|
41
|
+
if (existsSync(targetLocaleFile)) {
|
|
42
|
+
if (verbose) {
|
|
43
|
+
consoleLog.info(`Target locale file ${targetLocaleFile} exists, skipping`);
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
void createLocaleFile({
|
|
48
|
+
api: openai,
|
|
49
|
+
baseLocaleFile,
|
|
50
|
+
targetPath: targetLocaleFile,
|
|
51
|
+
targetLanguage: languageTag,
|
|
52
|
+
queue,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return queue.onIdle();
|
|
56
|
+
};
|
|
57
|
+
export const syncTranslation = async ({ instancePath, packageName, languageTag, verbose = true, queue = new PQueue({ concurrency: 5 }), }) => {
|
|
58
|
+
const localeFiles = await getBaseAndTargetLocaleFiles(instancePath, packageName, languageTag);
|
|
59
|
+
if (verbose) {
|
|
60
|
+
consoleLog.info('Found ' +
|
|
61
|
+
String(localeFiles.length) +
|
|
62
|
+
' file' +
|
|
63
|
+
conditionalString(localeFiles.length !== 1 && 's') +
|
|
64
|
+
' in ' +
|
|
65
|
+
packageName +
|
|
66
|
+
' to translate');
|
|
67
|
+
}
|
|
68
|
+
const openai = createOpenaiApi();
|
|
69
|
+
/* eslint-disable no-await-in-loop */
|
|
70
|
+
for (const { baseLocaleFile, targetLocaleFile } of localeFiles) {
|
|
71
|
+
if (!existsSync(targetLocaleFile)) {
|
|
72
|
+
void createLocaleFile({
|
|
73
|
+
api: openai,
|
|
74
|
+
baseLocaleFile,
|
|
75
|
+
targetPath: targetLocaleFile,
|
|
76
|
+
targetLanguage: languageTag,
|
|
77
|
+
queue,
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const currentContent = await fs.readFile(targetLocaleFile, 'utf8');
|
|
82
|
+
if (!currentContent.includes(untranslatedMark)) {
|
|
83
|
+
if (verbose) {
|
|
84
|
+
consoleLog.info(`Target path ${targetLocaleFile} exists and has no untranslated mark, skipping`);
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
void queue.add(async () => {
|
|
89
|
+
consoleLog.info(`Translating ${targetLocaleFile}`);
|
|
90
|
+
const result = await translate({
|
|
91
|
+
api: openai,
|
|
92
|
+
sourceFilePath: targetLocaleFile,
|
|
93
|
+
targetLanguage: languageTag,
|
|
94
|
+
extraPrompt: `Object values without an "${untranslatedMark}" mark should be skipped and keep its original value. Remember to remove the "${untranslatedMark}" mark with the spaces before and after it in the output content.`,
|
|
95
|
+
});
|
|
96
|
+
if (!result) {
|
|
97
|
+
consoleLog.fatal(`Unable to translate ${targetLocaleFile}`);
|
|
98
|
+
}
|
|
99
|
+
await fs.unlink(targetLocaleFile);
|
|
100
|
+
await fs.writeFile(targetLocaleFile, result);
|
|
101
|
+
consoleLog.succeed(`Translated ${targetLocaleFile}`);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/* eslint-enable no-await-in-loop */
|
|
105
|
+
return queue.onIdle();
|
|
106
|
+
};
|
|
107
|
+
const getBaseAndTargetLocaleFiles = async (instancePath, packageName, languageTag) => {
|
|
108
|
+
const directory = path.join(instancePath, 'packages', packageName, 'src/locales');
|
|
109
|
+
const baseLocaleFiles = await readBaseLocaleFiles(directory);
|
|
110
|
+
return baseLocaleFiles.map((baseLocaleFile) => {
|
|
111
|
+
const basePath = path.relative(path.join(directory, baseLanguage.toLowerCase()), baseLocaleFile);
|
|
112
|
+
return {
|
|
113
|
+
baseLocaleFile,
|
|
114
|
+
targetLocaleFile: path.join(directory, languageTag.toLowerCase(), basePath),
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
const createLocaleFile = async ({ api, baseLocaleFile, targetPath, targetLanguage, queue, }) => queue.add(async () => {
|
|
119
|
+
consoleLog.info(`Creating the translation for ${targetPath}`);
|
|
120
|
+
const result = await translate({
|
|
121
|
+
api,
|
|
122
|
+
sourceFilePath: baseLocaleFile,
|
|
123
|
+
targetLanguage,
|
|
124
|
+
});
|
|
125
|
+
if (!result) {
|
|
126
|
+
consoleLog.fatal(`Unable to create the translation for ${targetPath}`);
|
|
127
|
+
}
|
|
128
|
+
await fs.mkdir(path.parse(targetPath).dir, { recursive: true });
|
|
129
|
+
await fs.writeFile(targetPath, result);
|
|
130
|
+
consoleLog.succeed(`The translation for ${targetPath} created`);
|
|
131
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { consoleLog } from '../utils.js';
|
|
2
3
|
import { notImplemented } from './consts.js';
|
|
3
4
|
import { loadConnector } from './loader.js';
|
|
4
5
|
import { parseMetadata, validateConnectorModule } from './utils.js';
|
|
@@ -22,7 +23,7 @@ export const loadConnectorFactories = async (connectorPackages, ignoreVersionMis
|
|
|
22
23
|
}
|
|
23
24
|
catch (error) {
|
|
24
25
|
if (error instanceof Error) {
|
|
25
|
-
|
|
26
|
+
consoleLog.error(`[load-connector] skip ${chalk.bold(name)} due to error: ${error.message}`);
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
28
29
|
throw error;
|