@logto/cli 1.8.0 → 1.9.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.
@@ -1,5 +1,5 @@
1
- import { consoleLog } from '../../utils.js';
2
- import { addConnectors, addOfficialConnectors, inquireInstancePath } from './utils.js';
1
+ import { consoleLog, inquireInstancePath } from '../../utils.js';
2
+ import { addConnectors, addOfficialConnectors } from './utils.js';
3
3
  const add = {
4
4
  command: ['add [packages...]', 'a', 'install', 'i'],
5
5
  describe: 'Add specific Logto connectors',
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { consoleLog } from '../../utils.js';
4
- import { getConnectorDirectory, getLocalConnectorPackages, inquireInstancePath } from './utils.js';
3
+ import { consoleLog, inquireInstancePath } from '../../utils.js';
4
+ import { getConnectorDirectory, getLocalConnectorPackages } from './utils.js';
5
5
  const link = {
6
6
  command: ['link', 'ln'],
7
7
  describe: 'Link all connectors in `packages/connectors`, useful for adding local connectors.',
@@ -5,14 +5,13 @@ export declare const npmPackResultGuard: z.ZodArray<z.ZodObject<{
5
5
  filename: z.ZodString;
6
6
  }, "strip", z.ZodTypeAny, {
7
7
  name: string;
8
- filename: string;
9
8
  version: string;
9
+ filename: string;
10
10
  }, {
11
11
  name: string;
12
- filename: string;
13
12
  version: string;
13
+ filename: string;
14
14
  }>, "many">;
15
- export declare const inquireInstancePath: (initialPath?: string) => Promise<string>;
16
15
  export declare const normalizePackageName: (name: string) => string;
17
16
  export declare const getConnectorDirectory: (instancePath: string) => string;
18
17
  export declare const isOfficialConnector: (packageName: string) => boolean;
@@ -3,18 +3,15 @@ import { existsSync } from 'node:fs';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { assert, conditionalArray, conditionalString, trySafe } from '@silverhand/essentials';
6
+ import { conditionalArray, conditionalString, trySafe } from '@silverhand/essentials';
7
7
  import chalk from 'chalk';
8
8
  import { got } from 'got';
9
- import inquirer from 'inquirer';
10
9
  import pLimit from 'p-limit';
11
10
  import pRetry from 'p-retry';
12
11
  import tar from 'tar';
13
12
  import { z } from 'zod';
14
- import { connectorDirectory } from '../../constants.js';
15
- import { consoleLog, getConnectorPackagesFromDirectory, isTty, oraPromise } from '../../utils.js';
16
- import { defaultPath } from '../install/utils.js';
17
- const coreDirectory = 'packages/core';
13
+ import { connectorDirectory, coreDirectory } from '../../constants.js';
14
+ import { consoleLog, getConnectorPackagesFromDirectory, inquireInstancePath, oraPromise, } from '../../utils.js';
18
15
  const execPromise = promisify(exec);
19
16
  export const npmPackResultGuard = z
20
17
  .object({
@@ -23,49 +20,6 @@ export const npmPackResultGuard = z
23
20
  filename: z.string(),
24
21
  })
25
22
  .array();
26
- const buildPathErrorMessage = (value) => `The path ${chalk.green(value)} does not contain a Logto instance, please try another.`;
27
- const validatePath = async (value) => {
28
- const corePackageJsonPath = path.resolve(path.join(value, coreDirectory, 'package.json'));
29
- if (!existsSync(corePackageJsonPath)) {
30
- return buildPathErrorMessage(value);
31
- }
32
- const packageJson = await fs.readFile(corePackageJsonPath, { encoding: 'utf8' });
33
- const packageName = await z
34
- .object({ name: z.string() })
35
- .parseAsync(JSON.parse(packageJson))
36
- .then(({ name }) => name)
37
- .catch(() => '');
38
- if (packageName !== '@logto/core') {
39
- return buildPathErrorMessage(value);
40
- }
41
- return true;
42
- };
43
- export const inquireInstancePath = async (initialPath) => {
44
- const inquire = async () => {
45
- if (!initialPath && (await validatePath('.')) === true) {
46
- return path.resolve('.');
47
- }
48
- if (!isTty()) {
49
- assert(initialPath, new Error('Path is missing'));
50
- return initialPath;
51
- }
52
- const { instancePath } = await inquirer.prompt({
53
- name: 'instancePath',
54
- message: 'Where is your Logto instance?',
55
- type: 'input',
56
- default: defaultPath,
57
- filter: (value) => value.trim(),
58
- validate: validatePath,
59
- }, { instancePath: initialPath });
60
- return instancePath;
61
- };
62
- const instancePath = await inquire();
63
- const validated = await validatePath(instancePath);
64
- if (validated !== true) {
65
- consoleLog.fatal(validated);
66
- }
67
- return instancePath;
68
- };
69
23
  const packagePrefix = 'connector-';
70
24
  export const normalizePackageName = (name) => name
71
25
  .split('/')
@@ -172,7 +126,6 @@ export const fetchOfficialConnectorList = async (includingCloudConnectors = fals
172
126
  export const addOfficialConnectors = async (instancePath, includingCloudConnectors = false) => {
173
127
  const packages = await oraPromise(fetchOfficialConnectorList(includingCloudConnectors), {
174
128
  text: 'Fetch official connector list',
175
- prefixText: chalk.blue('[info]'),
176
129
  });
177
130
  consoleLog.info(`Found ${packages.length} official connectors`);
178
131
  await addConnectors(instancePath, packages.map(({ name }) => name));
@@ -1,4 +1,3 @@
1
- import chalk from 'chalk';
2
1
  import { createPoolAndDatabaseIfNeeded } from '../../../database.js';
3
2
  import { doesConfigsTableExist } from '../../../queries/logto-config.js';
4
3
  import { consoleLog, oraPromise } from '../../../utils.js';
@@ -15,7 +14,6 @@ export const seedByPool = async (pool, cloud = false) => {
15
14
  }
16
15
  await oraPromise(createTables(connection), {
17
16
  text: 'Create tables',
18
- prefixText: chalk.blue('[info]'),
19
17
  });
20
18
  await seedTables(connection, latestTimestamp, cloud);
21
19
  if (cloud) {
@@ -36,10 +36,10 @@ export const seedOidcConfigs = async (pool, tenantId) => {
36
36
  for (const key of validOptions) {
37
37
  const { value, fromEnv } = await oidcConfigReaders[key]();
38
38
  if (fromEnv) {
39
- consoleLog.info(tenantPrefix, `Read config ${chalk.green(key)} from env`);
39
+ consoleLog.succeed(tenantPrefix, `Read config ${chalk.green(key)} from env`);
40
40
  }
41
41
  else {
42
- consoleLog.info(tenantPrefix, `Generated config ${chalk.green(key)}`);
42
+ consoleLog.succeed(tenantPrefix, `Generated config ${chalk.green(key)}`);
43
43
  }
44
44
  await updateValueByKey(pool, tenantId, key, value);
45
45
  }
@@ -1,27 +1,27 @@
1
1
  import chalk from 'chalk';
2
2
  import { getDatabaseUrlFromConfig } from '../../database.js';
3
3
  import { consoleLog } from '../../utils.js';
4
- import { validateNodeVersion, inquireInstancePath, validateDatabase, downloadRelease, seedDatabase, createEnv, logFinale, decompress, isUrl, } from './utils.js';
4
+ import { validateNodeVersion, inquireInstallPath, validateDatabase, downloadRelease, seedDatabase, createEnv, logFinale, decompress, isUrl, } from './utils.js';
5
5
  const installLogto = async ({ path, skipSeed, downloadUrl, cloud }) => {
6
6
  validateNodeVersion();
7
- // Get instance path
8
- const instancePath = await inquireInstancePath(path);
7
+ // Get install location path
8
+ const installPath = await inquireInstallPath(path);
9
9
  // Validate if user has a valid database
10
10
  await validateDatabase();
11
11
  // Download and decompress
12
12
  const tarPath = !downloadUrl || isUrl(downloadUrl) ? await downloadRelease(downloadUrl) : downloadUrl;
13
- await decompress(instancePath, tarPath);
13
+ await decompress(installPath, 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
- await seedDatabase(instancePath, cloud);
19
+ await seedDatabase(installPath, cloud);
20
20
  }
21
21
  // Save to dot env
22
- await createEnv(instancePath, await getDatabaseUrlFromConfig());
22
+ await createEnv(installPath, await getDatabaseUrlFromConfig());
23
23
  // Finale
24
- logFinale(instancePath);
24
+ logFinale(installPath);
25
25
  };
26
26
  const install = {
27
27
  command: ['init', 'i', 'install'],
@@ -1,10 +1,9 @@
1
- export declare const defaultPath: string;
2
1
  export declare const validateNodeVersion: () => void;
3
- export declare const inquireInstancePath: (initialPath?: string) => Promise<string>;
2
+ export declare const inquireInstallPath: (initialPath?: string) => Promise<string>;
4
3
  export declare const validateDatabase: () => Promise<void>;
5
4
  export declare const downloadRelease: (url?: string) => Promise<string>;
6
5
  export declare const decompress: (toPath: string, tarPath: string) => Promise<void>;
7
6
  export declare const seedDatabase: (instancePath: string, cloud: boolean) => Promise<void>;
8
- export declare const createEnv: (instancePath: string, databaseUrl: string) => Promise<void>;
9
- export declare const logFinale: (instancePath: string) => void;
7
+ export declare const createEnv: (installPath: string, databaseUrl: string) => Promise<void>;
8
+ export declare const logFinale: (installPath: string) => void;
10
9
  export declare const isUrl: (string: string) => boolean;
@@ -9,11 +9,11 @@ import { got, RequestError } from 'got';
9
9
  import inquirer from 'inquirer';
10
10
  import * as semver from 'semver';
11
11
  import tar from 'tar';
12
+ import { defaultPath } from '../../constants.js';
12
13
  import { createPoolAndDatabaseIfNeeded } from '../../database.js';
13
14
  import { packageJson } from '../../package-json.js';
14
15
  import { cliConfig, ConfigKey, consoleLog, downloadFile, isTty, oraPromise, safeExecSync, } from '../../utils.js';
15
16
  import { seedByPool } from '../database/seed/index.js';
16
- export const defaultPath = path.join(os.homedir(), 'logto');
17
17
  const pgRequired = new semver.SemVer('14.0.0');
18
18
  export const validateNodeVersion = () => {
19
19
  const required = [new semver.SemVer('18.12.0')];
@@ -29,7 +29,7 @@ export const validateNodeVersion = () => {
29
29
  const validatePath = (value) => existsSync(path.resolve(value))
30
30
  ? `The path ${chalk.green(value)} already exists, please try another.`
31
31
  : true;
32
- export const inquireInstancePath = async (initialPath) => {
32
+ export const inquireInstallPath = async (initialPath) => {
33
33
  if (!isTty()) {
34
34
  assert(initialPath, new Error('Path is missing'));
35
35
  return initialPath;
@@ -106,7 +106,6 @@ export const decompress = async (toPath, tarPath) => {
106
106
  };
107
107
  return oraPromise(run(), {
108
108
  text: `Decompress to ${toPath}`,
109
- prefixText: chalk.blue('[info]'),
110
109
  }, true);
111
110
  };
112
111
  export const seedDatabase = async (instancePath, cloud) => {
@@ -119,19 +118,18 @@ export const seedDatabase = async (instancePath, cloud) => {
119
118
  consoleLog.error(error);
120
119
  await oraPromise(fs.rm(instancePath, { force: true, recursive: true }), {
121
120
  text: 'Clean up',
122
- prefixText: chalk.blue('[info]'),
123
121
  });
124
122
  consoleLog.fatal('Error occurred during seeding your Logto database. Nothing has changed since the seeding process was in a transaction.\n\n' +
125
123
  ` To skip the database seeding, append ${chalk.green('--skip-seed')} to the command options.`);
126
124
  }
127
125
  };
128
- export const createEnv = async (instancePath, databaseUrl) => {
129
- const dotEnvPath = path.resolve(instancePath, '.env');
126
+ export const createEnv = async (installPath, databaseUrl) => {
127
+ const dotEnvPath = path.resolve(installPath, '.env');
130
128
  await fs.writeFile(dotEnvPath, `DB_URL=${databaseUrl}`, 'utf8');
131
129
  consoleLog.info(`Saved database URL to ${chalk.blue(dotEnvPath)}`);
132
130
  };
133
- export const logFinale = (instancePath) => {
134
- const startCommand = `cd ${instancePath} && npm start`;
131
+ export const logFinale = (installPath) => {
132
+ const startCommand = `cd ${installPath} && npm start`;
135
133
  consoleLog.info(`Use the command below to start Logto. Happy hacking!\n\n ${chalk.green(startCommand)}`);
136
134
  };
137
135
  export const isUrl = (string) => {
@@ -1,8 +1,7 @@
1
1
  import { isLanguageTag } from '@logto/language-kit';
2
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';
3
+ import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-experience';
4
+ import { consoleLog, inquireInstancePath } from '../../utils.js';
6
5
  import { createFullTranslation } from './utils.js';
7
6
  const create = {
8
7
  command: ['create <language-tag>', 'c'],
@@ -26,11 +25,11 @@ const create = {
26
25
  languageTag,
27
26
  });
28
27
  if (isPhrasesUiBuiltInLanguageTag(languageTag)) {
29
- consoleLog.info(languageTag + ' is a built-in tag of phrases-ui, updating untranslated phrases');
28
+ consoleLog.info(languageTag + ' is a built-in tag of phrases-experience, updating untranslated phrases');
30
29
  }
31
30
  await createFullTranslation({
32
31
  instancePath,
33
- packageName: 'phrases-ui',
32
+ packageName: 'phrases-experience',
34
33
  languageTag,
35
34
  });
36
35
  },
@@ -1,6 +1,6 @@
1
1
  import { languages } from '@logto/language-kit';
2
2
  import { isBuiltInLanguageTag as isPhrasesBuiltInLanguageTag } from '@logto/phrases';
3
- import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-ui';
3
+ import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-experience';
4
4
  import chalk from 'chalk';
5
5
  import { consoleLog } from '../../utils.js';
6
6
  const listTags = {
@@ -11,7 +11,7 @@ const listTags = {
11
11
  consoleLog.plain(...[
12
12
  tag,
13
13
  isPhrasesBuiltInLanguageTag(tag) && chalk.blue('phrases'),
14
- isPhrasesUiBuiltInLanguageTag(tag) && chalk.blue('phrases-ui'),
14
+ isPhrasesUiBuiltInLanguageTag(tag) && chalk.blue('phrases-experience'),
15
15
  ].filter(Boolean));
16
16
  }
17
17
  },
@@ -4,9 +4,10 @@ type GetTranslationPromptProperties = {
4
4
  targetLanguage: LanguageTag;
5
5
  extraPrompt?: string;
6
6
  };
7
+ export declare const untranslatedMark = "/** UNTRANSLATED */";
7
8
  /**
8
9
  * Note:
9
- * The input token limit of GPT 3.5 is 2048, the following prompt tokens with sourceFileContent is about 1200.
10
+ * The input token limit of GPT 3.5 is 2048, the following prompt tokens with sourceFileContent is about 1600.
10
11
  * Remember to check the token limit before adding more prompt.
11
12
  * Tokens can be counted in https://platform.openai.com/tokenizer
12
13
  */
@@ -1,34 +1,37 @@
1
1
  import { languages } from '@logto/language-kit';
2
2
  import { conditionalString } from '@silverhand/essentials';
3
+ export const untranslatedMark = '/** UNTRANSLATED */';
3
4
  /**
4
5
  * Note:
5
- * The input token limit of GPT 3.5 is 2048, the following prompt tokens with sourceFileContent is about 1200.
6
+ * The input token limit of GPT 3.5 is 2048, the following prompt tokens with sourceFileContent is about 1600.
6
7
  * Remember to check the token limit before adding more prompt.
7
8
  * Tokens can be counted in https://platform.openai.com/tokenizer
8
9
  */
9
10
  export const getTranslationPromptMessages = ({ sourceFileContent, targetLanguage, extraPrompt, }) => [
10
11
  {
11
12
  role: 'assistant',
12
- content: `You are a translate assistant of a Typescript engineer, when you receive a code snippet that contains an object, translate and ONLY translate those values that are marked with comment "// UNTRANSLATED" into the language ${languages[targetLanguage]}, remove the "// UNTRANSLATED" mark, keep all object keys original, output ts code only, the code format should be strictly consistent, and should not contain the given code snippet. ${conditionalString(extraPrompt)}
13
+ content: `You are a assistant translator and will receive a TypeScript object. Traverse and find object values with "${untranslatedMark}" annotation on the top, then translate these values to target locale "${languages[targetLanguage]}". Remove the "${untranslatedMark}" annotations from output. Escape the single quotes (if any) in translated results by prepending a backslash. Keep the interpolation double curly brackets and their inner values intact. Make sure there is a space between the CJK and non-CJK characters. Prefer using "你" instead of "您" in Chinese. Do not include sample code snippet below in the final output. ${conditionalString(extraPrompt)}
13
14
 
14
- Take Chinese as an example, if the input is:
15
+ Take translating to zh-CN as an example, if the input is:
15
16
  \`\`\`ts
16
17
  import others from './others.js';
17
18
 
18
19
  const translation = {
19
- hello: '你好',
20
- world: 'world', // UNTRANSLATED
20
+ hello: 'Hello',
21
+ ${untranslatedMark}
22
+ world: 'world',
21
23
  others,
22
24
  };
23
25
 
24
26
  export default translation;
25
27
  \`\`\`
26
- the output should be:
28
+
29
+ Then the output should be:
27
30
  \`\`\`ts
28
31
  import others from './others.js';
29
32
 
30
33
  const translation = {
31
- hello: '你好',
34
+ hello: 'Hello',
32
35
  world: '世界',
33
36
  others,
34
37
  };
@@ -1,13 +1,9 @@
1
- import { execFile } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
- import { promisify } from 'node:util';
5
3
  import { isLanguageTag } from '@logto/language-kit';
6
4
  import ora from 'ora';
7
- import { consoleLog } from '../../../utils.js';
8
- import { inquireInstancePath } from '../../connector/utils.js';
9
- import { praseLocaleFiles, syncPhraseKeysAndFileStructure } from './utils.js';
10
- const execPromise = promisify(execFile);
5
+ import { consoleLog, inquireInstancePath, lintLocaleFiles } from '../../../utils.js';
6
+ import { parseLocaleFiles, syncPhraseKeysAndFileStructure } from './utils.js';
11
7
  const syncKeys = {
12
8
  command: ['sync-keys', 'sk'],
13
9
  describe: [
@@ -26,7 +22,7 @@ const syncKeys = {
26
22
  .option('package', {
27
23
  alias: 'pkg',
28
24
  type: 'string',
29
- describe: 'The package name of the phrases, one of `phrases` or `phrases-ui`',
25
+ describe: 'The package name of the phrases, one of `phrases` or `phrases-experience`',
30
26
  default: 'phrases',
31
27
  })
32
28
  .option('target', {
@@ -50,14 +46,14 @@ const syncKeys = {
50
46
  if (baselineTag === targetTag) {
51
47
  consoleLog.fatal('Baseline and target cannot be the same');
52
48
  }
53
- if (packageName !== 'phrases' && packageName !== 'phrases-ui') {
54
- consoleLog.fatal('Invalid package name, expected `phrases` or `phrases-ui`');
49
+ if (packageName !== 'phrases' && packageName !== 'phrases-experience') {
50
+ consoleLog.fatal('Invalid package name, expected `phrases` or `phrases-experience`');
55
51
  }
56
52
  const instancePath = await inquireInstancePath(inputPath);
57
53
  const phrasesPath = path.join(instancePath, 'packages', packageName);
58
54
  const localesPath = path.join(phrasesPath, 'src/locales');
59
55
  const entrypoint = path.join(localesPath, baselineTag.toLowerCase(), 'index.ts');
60
- const baseline = praseLocaleFiles(entrypoint);
56
+ const baseline = parseLocaleFiles(entrypoint);
61
57
  const targetLocales = targetTag === 'all' ? fs.readdirSync(localesPath) : [targetTag.toLowerCase()];
62
58
  /* eslint-disable no-await-in-loop */
63
59
  for (const target of targetLocales) {
@@ -73,11 +69,7 @@ const syncKeys = {
73
69
  }
74
70
  /* eslint-enable no-await-in-loop */
75
71
  if (!skipLint) {
76
- const spinner = ora({
77
- text: 'Running `eslint --fix` for locales',
78
- }).start();
79
- await execPromise('pnpm', ['eslint', '--ext', '.ts', path.relative(phrasesPath, localesPath), '--fix'], { cwd: phrasesPath });
80
- spinner.succeed('Ran `eslint --fix` for locales');
72
+ void lintLocaleFiles(instancePath, packageName);
81
73
  }
82
74
  },
83
75
  };
@@ -5,7 +5,7 @@ type FileStructure = {
5
5
  };
6
6
  };
7
7
  type NestedPhraseObject = {
8
- [key: string]: string | NestedPhraseObject;
8
+ [key: string]: [phrase: string, isTranslated: boolean] | NestedPhraseObject;
9
9
  };
10
10
  type ParsedTuple = readonly [NestedPhraseObject, FileStructure];
11
11
  /**
@@ -19,7 +19,8 @@ type ParsedTuple = readonly [NestedPhraseObject, FileStructure];
19
19
  * import errors from './errors/index.js';
20
20
  *
21
21
  * const translation = {
22
- * page_title: 'Anwendungen',
22
+ * // UNTRANSLATED
23
+ * page_title: 'Applications',
23
24
  * errors,
24
25
  * };
25
26
  * ```
@@ -28,9 +29,9 @@ type ParsedTuple = readonly [NestedPhraseObject, FileStructure];
28
29
  *
29
30
  * ```ts
30
31
  * {
31
- * page_title: 'Anwendungen',
32
+ * page_title: ['Anwendungen', false],
32
33
  * errors: {
33
- * page_not_found: 'Seite nicht gefunden',
34
+ * page_not_found: ['Seite nicht gefunden', true],
34
35
  * },
35
36
  * }
36
37
  * ```
@@ -51,6 +52,6 @@ type ParsedTuple = readonly [NestedPhraseObject, FileStructure];
51
52
  * @returns A tuple of the nested object of phrases and the file structure
52
53
  *
53
54
  */
54
- export declare const praseLocaleFiles: (filePath: string) => ParsedTuple;
55
+ export declare const parseLocaleFiles: (filePath: string) => ParsedTuple;
55
56
  export declare const syncPhraseKeysAndFileStructure: (baseline: ParsedTuple, targetLocale: string, targetDirectory: string) => Promise<void>;
56
57
  export {};
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { tryThat } from '@silverhand/essentials';
5
5
  import ts from 'typescript';
6
6
  import { consoleLog } from '../../../utils.js';
7
+ const untranslatedMark = 'UNTRANSLATED';
7
8
  /**
8
9
  * Given a entrypoint file path of a language, parse the nested object of
9
10
  * phrases and the file structure.
@@ -15,7 +16,8 @@ import { consoleLog } from '../../../utils.js';
15
16
  * import errors from './errors/index.js';
16
17
  *
17
18
  * const translation = {
18
- * page_title: 'Anwendungen',
19
+ * // UNTRANSLATED
20
+ * page_title: 'Applications',
19
21
  * errors,
20
22
  * };
21
23
  * ```
@@ -24,9 +26,9 @@ import { consoleLog } from '../../../utils.js';
24
26
  *
25
27
  * ```ts
26
28
  * {
27
- * page_title: 'Anwendungen',
29
+ * page_title: ['Anwendungen', false],
28
30
  * errors: {
29
- * page_not_found: 'Seite nicht gefunden',
31
+ * page_not_found: ['Seite nicht gefunden', true],
30
32
  * },
31
33
  * }
32
34
  * ```
@@ -47,7 +49,7 @@ import { consoleLog } from '../../../utils.js';
47
49
  * @returns A tuple of the nested object of phrases and the file structure
48
50
  *
49
51
  */
50
- export const praseLocaleFiles = (filePath) => {
52
+ export const parseLocaleFiles = (filePath) => {
51
53
  const content = readFileSync(filePath, 'utf8');
52
54
  const ast = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
53
55
  const importIdentifierPath = new Map();
@@ -73,30 +75,47 @@ export const praseLocaleFiles = (filePath) => {
73
75
  }
74
76
  const resolvedPath = path.resolve(path.dirname(filePath), importPath);
75
77
  // Recursively parse the nested object from the imported file
76
- const [phrases, structure] = praseLocaleFiles(resolvedPath);
77
- // eslint-disable-next-line @silverhand/fp/no-mutation
78
+ const [phrases, structure] = parseLocaleFiles(resolvedPath);
79
+ /* eslint-disable @silverhand/fp/no-mutation */
78
80
  nestedObject[key] = phrases;
79
- // eslint-disable-next-line @silverhand/fp/no-mutation
80
81
  fileStructure[key] = {
81
82
  filePath: importPath,
82
83
  structure,
83
84
  };
85
+ /* eslint-enable @silverhand/fp/no-mutation */
84
86
  }
85
87
  if (ts.isPropertyAssignment(property)) {
86
88
  const key = property.name.getText();
87
89
  // Nested object, recursively parse it
88
90
  if (ts.isObjectLiteralExpression(property.initializer)) {
89
91
  const [phrases, structure] = traverseNode(property.initializer, {}, {});
90
- // eslint-disable-next-line @silverhand/fp/no-mutation
92
+ /* eslint-disable @silverhand/fp/no-mutation */
91
93
  nestedObject[key] = phrases;
92
- // eslint-disable-next-line @silverhand/fp/no-mutation
93
94
  fileStructure[key] = { structure };
95
+ /* eslint-enable @silverhand/fp/no-mutation */
94
96
  }
95
97
  else if (ts.isStringLiteral(property.initializer) ||
96
98
  ts.isNoSubstitutionTemplateLiteral(property.initializer)) {
97
99
  const value = property.initializer.getText();
98
- // eslint-disable-next-line @silverhand/fp/no-mutation
99
- nestedObject[key] = value;
100
+ const commentRanges = ts.getLeadingCommentRanges(property.getFullText(), 0);
101
+ if (commentRanges?.[0]) {
102
+ if (commentRanges.length > 1) {
103
+ consoleLog.fatal('Multiple comments found for property:', property);
104
+ }
105
+ const commentRange = commentRanges[0];
106
+ const comment = property.getFullText().slice(commentRange.pos, commentRange.end);
107
+ if (comment.includes(untranslatedMark)) {
108
+ // eslint-disable-next-line @silverhand/fp/no-mutation
109
+ nestedObject[key] = [value, false];
110
+ }
111
+ else {
112
+ consoleLog.fatal('Unsupported comment:', comment);
113
+ }
114
+ }
115
+ else {
116
+ // eslint-disable-next-line @silverhand/fp/no-mutation
117
+ nestedObject[key] = [value, true];
118
+ }
100
119
  }
101
120
  else {
102
121
  consoleLog.fatal('Unsupported property:', property);
@@ -193,14 +212,19 @@ const traverseNode = async (baseline, targetObject, targetFilePath, isRoot = fal
193
212
  const [baselineObject, baselineStructure] = baseline;
194
213
  for (const [key, value] of Object.entries(baselineObject)) {
195
214
  const existingValue = targetObject[key];
196
- if (typeof value === 'string') {
197
- // If the key exists in the target language and the value is a string, use
198
- // the value of the target language; otherwise, use the value of the
199
- // baseline language and add a comment to indicate that the phrase is
200
- // untranslated to help identify missing translations.
201
- await (typeof existingValue === 'string'
202
- ? fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: ${existingValue},\n`)
203
- : fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: ${value}, // UNTRANSLATED\n`));
215
+ if (Array.isArray(value)) {
216
+ const [phrase] = value;
217
+ // If the key exists in the target language and the value is a translated
218
+ // string, use the value of the target language; otherwise, use the value
219
+ // of the baseline language and add a comment to indicate that the phrase
220
+ // is untranslated to help identify missing translations.
221
+ if (Array.isArray(existingValue) && existingValue[1]) {
222
+ await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: ${existingValue[0]},\n`);
223
+ }
224
+ else {
225
+ await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}/** ${untranslatedMark} */\n`);
226
+ await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: ${phrase},\n`);
227
+ }
204
228
  }
205
229
  // Not a string, treat it as a nested object or an import
206
230
  else {
@@ -208,12 +232,12 @@ const traverseNode = async (baseline, targetObject, targetFilePath, isRoot = fal
208
232
  // If the key has a file structure, treat it as an import
209
233
  if (keyStructure?.filePath) {
210
234
  await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key},\n`);
211
- await traverseNode([value, keyStructure.structure], typeof existingValue === 'object' ? existingValue : {}, path.join(targetDirectory, keyStructure.filePath));
235
+ await traverseNode([value, keyStructure.structure], Array.isArray(existingValue) ? {} : existingValue ?? {}, path.join(targetDirectory, keyStructure.filePath));
212
236
  }
213
237
  // Otherwise, treat it as a nested object.
214
238
  else {
215
239
  await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: {\n`);
216
- await traverseObject([value, keyStructure?.structure ?? {}], typeof existingValue === 'object' ? existingValue : {}, tabSize + 2);
240
+ await traverseObject([value, keyStructure?.structure ?? {}], Array.isArray(existingValue) ? {} : existingValue ?? {}, tabSize + 2);
217
241
  await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}},\n`);
218
242
  }
219
243
  }
@@ -229,7 +253,7 @@ const traverseNode = async (baseline, targetObject, targetFilePath, isRoot = fal
229
253
  export const syncPhraseKeysAndFileStructure = async (baseline, targetLocale, targetDirectory) => {
230
254
  const targetEntrypoint = path.join(targetDirectory, 'index.ts');
231
255
  const isTargetLocaleExist = existsSync(targetEntrypoint);
232
- const targetObject = isTargetLocaleExist ? praseLocaleFiles(targetEntrypoint)[0] : {};
256
+ const targetObject = isTargetLocaleExist ? parseLocaleFiles(targetEntrypoint)[0] : {};
233
257
  const backupDirectory = targetDirectory + '.bak';
234
258
  if (isTargetLocaleExist) {
235
259
  await fs.rename(targetDirectory, backupDirectory);
@@ -1,8 +1,8 @@
1
1
  import { languages } from '@logto/language-kit';
2
2
  import { isBuiltInLanguageTag as isPhrasesBuiltInLanguageTag } from '@logto/phrases';
3
- import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-ui';
3
+ import { isBuiltInLanguageTag as isPhrasesUiBuiltInLanguageTag } from '@logto/phrases-experience';
4
4
  import PQueue from 'p-queue';
5
- import { inquireInstancePath } from '../connector/utils.js';
5
+ import { inquireInstancePath, lintLocaleFiles } from '../../utils.js';
6
6
  import { baseLanguage, syncTranslation } from './utils.js';
7
7
  const sync = {
8
8
  command: ['sync'],
@@ -19,22 +19,25 @@ const sync = {
19
19
  verbose: false,
20
20
  queue,
21
21
  };
22
+ /* eslint-disable no-await-in-loop */
22
23
  if (isPhrasesBuiltInLanguageTag(languageTag)) {
23
- void syncTranslation({
24
+ await syncTranslation({
24
25
  ...baseOptions,
25
26
  packageName: 'phrases',
26
27
  languageTag,
27
28
  });
28
29
  }
29
30
  if (isPhrasesUiBuiltInLanguageTag(languageTag)) {
30
- void syncTranslation({
31
+ await syncTranslation({
31
32
  ...baseOptions,
32
- packageName: 'phrases-ui',
33
+ packageName: 'phrases-experience',
33
34
  languageTag,
34
35
  });
35
36
  }
37
+ /* eslint-enable no-await-in-loop */
36
38
  }
37
39
  await queue.onIdle();
40
+ void lintLocaleFiles(instancePath);
38
41
  },
39
42
  };
40
43
  export default sync;
@@ -5,7 +5,7 @@ export declare const readLocaleFiles: (directory: string) => Promise<string[]>;
5
5
  export declare const readBaseLocaleFiles: (directory: string) => Promise<string[]>;
6
6
  export type TranslationOptions = {
7
7
  instancePath: string;
8
- packageName: 'phrases' | 'phrases-ui';
8
+ packageName: 'phrases' | 'phrases-experience';
9
9
  languageTag: LanguageTag;
10
10
  verbose?: boolean;
11
11
  queue?: PQueue;
@@ -5,8 +5,8 @@ import { conditionalString } from '@silverhand/essentials';
5
5
  import PQueue from 'p-queue';
6
6
  import { consoleLog } from '../../utils.js';
7
7
  import { createOpenaiApi, translate } from './openai.js';
8
+ import { untranslatedMark } from './prompts.js';
8
9
  export const baseLanguage = 'en';
9
- const untranslatedMark = '// UNTRANSLATED';
10
10
  export const readLocaleFiles = async (directory) => {
11
11
  const entities = await fs.readdir(directory, { withFileTypes: true });
12
12
  const result = await Promise.all(entities.map(async (entity) => {
@@ -91,7 +91,6 @@ export const syncTranslation = async ({ instancePath, packageName, languageTag,
91
91
  api: openai,
92
92
  sourceFilePath: targetLocaleFile,
93
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
94
  });
96
95
  if (!result) {
97
96
  consoleLog.fatal(`Unable to translate ${targetLocaleFile}`);
@@ -1 +1,3 @@
1
+ export declare const defaultPath: string;
2
+ export declare const coreDirectory = "packages/core";
1
3
  export declare const connectorDirectory = "connectors";
package/lib/constants.js CHANGED
@@ -1 +1,5 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ export const defaultPath = path.join(os.homedir(), 'logto');
4
+ export const coreDirectory = 'packages/core';
1
5
  export const connectorDirectory = 'connectors';
@@ -44,7 +44,7 @@ export declare const packageJson: {
44
44
  "@logto/core-kit": string;
45
45
  "@logto/language-kit": string;
46
46
  "@logto/phrases": string;
47
- "@logto/phrases-ui": string;
47
+ "@logto/phrases-experience": string;
48
48
  "@logto/schemas": string;
49
49
  "@logto/shared": string;
50
50
  "@silverhand/essentials": string;
@@ -1,6 +1,6 @@
1
1
  export const packageJson = {
2
2
  "name": "@logto/cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -44,13 +44,13 @@ export const packageJson = {
44
44
  },
45
45
  "dependencies": {
46
46
  "@logto/connector-kit": "workspace:^1.1.1",
47
- "@logto/core-kit": "workspace:^2.0.1",
47
+ "@logto/core-kit": "workspace:^2.1.1",
48
48
  "@logto/language-kit": "workspace:^1.0.0",
49
- "@logto/phrases": "workspace:^1.4.1",
50
- "@logto/phrases-ui": "workspace:^1.2.0",
51
- "@logto/schemas": "workspace:1.8.0",
52
- "@logto/shared": "workspace:^2.0.0",
53
- "@silverhand/essentials": "^2.5.0",
49
+ "@logto/phrases": "workspace:^1.5.0",
50
+ "@logto/phrases-experience": "workspace:^1.3.1",
51
+ "@logto/schemas": "workspace:1.9.1",
52
+ "@logto/shared": "workspace:^2.0.1",
53
+ "@silverhand/essentials": "^2.8.4",
54
54
  "chalk": "^5.0.0",
55
55
  "decamelize": "^6.0.0",
56
56
  "dotenv": "^16.0.0",
@@ -87,7 +87,7 @@ export const packageJson = {
87
87
  "jest": "^29.5.0",
88
88
  "lint-staged": "^14.0.0",
89
89
  "prettier": "^3.0.0",
90
- "sinon": "^15.0.0"
90
+ "sinon": "^16.0.0"
91
91
  },
92
92
  "eslintConfig": {
93
93
  "extends": "@silverhand",
package/lib/utils.d.ts CHANGED
@@ -28,8 +28,10 @@ export declare const getCliConfigWithPrompt: ({ key, readableKey, comments, defa
28
28
  * findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
29
29
  */
30
30
  export declare function findLastIndex<T>(array: readonly T[], predicate: (value: T, index: number, object: readonly T[]) => boolean): number;
31
+ export declare const inquireInstancePath: (initialPath?: string) => Promise<string>;
31
32
  export type ConnectorPackage = {
32
33
  name: string;
33
34
  path: string;
34
35
  };
35
36
  export declare const getConnectorPackagesFromDirectory: (directory: string) => Promise<ConnectorPackage[]>;
37
+ export declare const lintLocaleFiles: (instancePath: string, packageName?: 'phrases' | 'phrases-experience') => Promise<void>;
package/lib/utils.js CHANGED
@@ -1,16 +1,18 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execSync, execFile } from 'node:child_process';
2
2
  import { createWriteStream, existsSync } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
+ import { promisify } from 'node:util';
6
7
  import { ConsoleLog } from '@logto/shared';
7
- import { conditionalString } from '@silverhand/essentials';
8
+ import { assert, conditionalString } from '@silverhand/essentials';
8
9
  import chalk from 'chalk';
9
10
  import { got } from 'got';
10
11
  import { HttpsProxyAgent } from 'hpagent';
11
12
  import inquirer from 'inquirer';
12
13
  import ora from 'ora';
13
14
  import { z } from 'zod';
15
+ import { coreDirectory, defaultPath } from './constants.js';
14
16
  export const safeExecSync = (command) => {
15
17
  try {
16
18
  return execSync(command, { encoding: 'utf8', stdio: 'pipe' });
@@ -40,7 +42,7 @@ export const downloadFile = async (url, destination) => {
40
42
  });
41
43
  const spinner = ora({
42
44
  text: 'Connecting',
43
- prefixText: chalk.blue('[info]'),
45
+ prefixText: ConsoleLog.prefixes.info,
44
46
  }).start();
45
47
  stream.pipe(file);
46
48
  return new Promise((resolve, reject) => {
@@ -66,7 +68,7 @@ export const getPathInModule = (moduleName, relativePath = '/') =>
66
68
  // https://stackoverflow.com/a/49455609/12514940
67
69
  path.join(path.dirname(createRequire(import.meta.url).resolve(`${moduleName}/package.json`)), relativePath);
68
70
  export const oraPromise = async (promise, options, exitOnError = false) => {
69
- const spinner = ora(options).start();
71
+ const spinner = ora({ prefixText: ConsoleLog.prefixes.info, ...options }).start();
70
72
  try {
71
73
  const result = await promise;
72
74
  spinner.succeed();
@@ -120,6 +122,49 @@ export function findLastIndex(array, predicate) {
120
122
  }
121
123
  return -1;
122
124
  }
125
+ const buildPathErrorMessage = (value) => `The path ${chalk.green(value)} does not contain a Logto instance. Please try another.`;
126
+ const validatePath = async (value) => {
127
+ const corePackageJsonPath = path.resolve(path.join(value, coreDirectory, 'package.json'));
128
+ if (!existsSync(corePackageJsonPath)) {
129
+ return buildPathErrorMessage(value);
130
+ }
131
+ const packageJson = await readFile(corePackageJsonPath, { encoding: 'utf8' });
132
+ const packageName = await z
133
+ .object({ name: z.string() })
134
+ .parseAsync(JSON.parse(packageJson))
135
+ .then(({ name }) => name)
136
+ .catch(() => '');
137
+ if (packageName !== '@logto/core') {
138
+ return buildPathErrorMessage(value);
139
+ }
140
+ return true;
141
+ };
142
+ export const inquireInstancePath = async (initialPath) => {
143
+ const inquire = async () => {
144
+ if (!initialPath && (await validatePath('.')) === true) {
145
+ return path.resolve('.');
146
+ }
147
+ if (!isTty()) {
148
+ assert(initialPath, new Error('Path is missing'));
149
+ return initialPath;
150
+ }
151
+ const { instancePath } = await inquirer.prompt({
152
+ name: 'instancePath',
153
+ message: 'Where is your Logto instance?',
154
+ type: 'input',
155
+ default: defaultPath,
156
+ filter: (value) => value.trim(),
157
+ validate: validatePath,
158
+ }, { instancePath: initialPath });
159
+ return instancePath;
160
+ };
161
+ const instancePath = await inquire();
162
+ const validated = await validatePath(instancePath);
163
+ if (validated !== true) {
164
+ consoleLog.fatal(validated);
165
+ }
166
+ return instancePath;
167
+ };
123
168
  const getConnectorPackageName = async (directory) => {
124
169
  const filePath = path.join(directory, 'package.json');
125
170
  if (!existsSync(filePath)) {
@@ -139,3 +184,20 @@ export const getConnectorPackagesFromDirectory = async (directory) => {
139
184
  }));
140
185
  return rawPackages.filter((packageInfo) => typeof packageInfo.name === 'string');
141
186
  };
187
+ const execPromise = promisify(execFile);
188
+ export const lintLocaleFiles = async (
189
+ /** Logto instance path */
190
+ instancePath,
191
+ /** Target package name, ignore to lint both packages */
192
+ packageName) => {
193
+ const spinner = ora({
194
+ text: 'Running `eslint --fix` for locales',
195
+ }).start();
196
+ const targetPackages = packageName ? [packageName] : ['phrases', 'phrases-experience'];
197
+ await Promise.all(targetPackages.map(async (packageName) => {
198
+ const phrasesPath = path.join(instancePath, 'packages', packageName);
199
+ const localesPath = path.join(phrasesPath, 'src/locales');
200
+ await execPromise('pnpm', ['eslint', '--ext', '.ts', path.relative(phrasesPath, localesPath), '--fix'], { cwd: phrasesPath });
201
+ }));
202
+ spinner.succeed('Ran `eslint --fix` for locales');
203
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -29,13 +29,13 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@logto/connector-kit": "^1.1.1",
32
- "@logto/core-kit": "^2.0.1",
32
+ "@logto/core-kit": "^2.1.1",
33
33
  "@logto/language-kit": "^1.0.0",
34
- "@logto/phrases": "^1.4.1",
35
- "@logto/phrases-ui": "^1.2.0",
36
- "@logto/schemas": "1.8.0",
37
- "@logto/shared": "^2.0.0",
38
- "@silverhand/essentials": "^2.5.0",
34
+ "@logto/phrases": "^1.5.0",
35
+ "@logto/phrases-experience": "^1.3.1",
36
+ "@logto/schemas": "1.9.1",
37
+ "@logto/shared": "^2.0.1",
38
+ "@silverhand/essentials": "^2.8.4",
39
39
  "chalk": "^5.0.0",
40
40
  "decamelize": "^6.0.0",
41
41
  "dotenv": "^16.0.0",
@@ -72,7 +72,7 @@
72
72
  "jest": "^29.5.0",
73
73
  "lint-staged": "^14.0.0",
74
74
  "prettier": "^3.0.0",
75
- "sinon": "^15.0.0"
75
+ "sinon": "^16.0.0"
76
76
  },
77
77
  "eslintConfig": {
78
78
  "extends": "@silverhand",