@logto/cli 1.12.0 → 1.13.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,8 +1,10 @@
1
1
  import type { DatabasePool } from 'slonik';
2
2
  import type { CommandModule } from 'yargs';
3
- export declare const seedByPool: (pool: DatabasePool, cloud?: boolean) => Promise<void>;
3
+ export declare const seedByPool: (pool: DatabasePool, cloud?: boolean, test?: boolean) => Promise<void>;
4
4
  declare const seed: CommandModule<Record<string, unknown>, {
5
5
  swe?: boolean;
6
6
  cloud?: boolean;
7
+ test?: boolean;
8
+ 'legacy-test-data'?: boolean;
7
9
  }>;
8
10
  export default seed;
@@ -3,8 +3,8 @@ import { doesConfigsTableExist } from '../../../queries/logto-config.js';
3
3
  import { consoleLog, oraPromise } from '../../../utils.js';
4
4
  import { getLatestAlterationTimestamp } from '../alteration/index.js';
5
5
  import { getAlterationDirectory } from '../alteration/utils.js';
6
- import { createTables, seedCloud, seedTables } from './tables.js';
7
- export const seedByPool = async (pool, cloud = false) => {
6
+ import { createTables, seedCloud, seedTables, seedTest } from './tables.js';
7
+ export const seedByPool = async (pool, cloud = false, test = false) => {
8
8
  await pool.transaction(async (connection) => {
9
9
  // Check alteration scripts available in order to insert correct timestamp
10
10
  const latestTimestamp = await getLatestAlterationTimestamp();
@@ -19,6 +19,14 @@ export const seedByPool = async (pool, cloud = false) => {
19
19
  if (cloud) {
20
20
  await seedCloud(connection);
21
21
  }
22
+ if (test) {
23
+ await seedTest(connection);
24
+ }
25
+ });
26
+ };
27
+ const seedLegacyTestData = async (pool) => {
28
+ return pool.transaction(async (connection) => {
29
+ await seedTest(connection, true);
22
30
  });
23
31
  };
24
32
  const seed = {
@@ -33,25 +41,44 @@ const seed = {
33
41
  .option('cloud', {
34
42
  describe: 'Seed additional cloud data',
35
43
  type: 'boolean',
36
- hidden: true,
44
+ })
45
+ .option('test', {
46
+ describe: 'Seed additional test data',
47
+ type: 'boolean',
48
+ })
49
+ .option('legacy-test-data', {
50
+ describe: 'Seed test data only for legacy Logto versions (<=1.12.0), this option conflicts with others',
51
+ type: 'boolean',
37
52
  }),
38
- handler: async ({ swe, cloud }) => {
53
+ handler: async ({ swe, cloud, test, legacyTestData }) => {
39
54
  const pool = await createPoolAndDatabaseIfNeeded();
55
+ if (legacyTestData) {
56
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
57
+ if (swe || cloud || test) {
58
+ throw new Error('The `legacy-test-data` option conflicts with other options, please use it alone.');
59
+ }
60
+ await seedLegacyTestData(pool);
61
+ await pool.end();
62
+ return;
63
+ }
40
64
  if (swe && (await doesConfigsTableExist(pool))) {
41
65
  consoleLog.info('Seeding skipped');
42
66
  await pool.end();
43
67
  return;
44
68
  }
45
69
  try {
46
- await seedByPool(pool, cloud);
70
+ await seedByPool(pool, cloud, test);
47
71
  }
48
72
  catch (error) {
49
73
  consoleLog.error(error);
50
74
  consoleLog.error('Error ocurred during seeding your database.\n\n' +
51
75
  ' Nothing has changed since the seeding process was in a transaction.\n' +
52
76
  ' Try to fix the error and seed again.');
77
+ throw error;
78
+ }
79
+ finally {
80
+ await pool.end();
53
81
  }
54
- await pool.end();
55
82
  },
56
83
  };
57
84
  export default seed;
@@ -2,3 +2,12 @@ import type { DatabaseTransactionConnection } from 'slonik';
2
2
  export declare const createTables: (connection: DatabaseTransactionConnection) => Promise<void>;
3
3
  export declare const seedTables: (connection: DatabaseTransactionConnection, latestTimestamp: number, isCloud: boolean) => Promise<void>;
4
4
  export declare const seedCloud: (connection: DatabaseTransactionConnection) => Promise<void>;
5
+ /**
6
+ * Seed additional test data for integration or alteration tests.
7
+ *
8
+ * It will create two users to the admin tenant (`test-1` and `test-2`), and do the following:
9
+ *
10
+ * - `test-1` will be assigned the management roles for both `default` and `admin` tenant.
11
+ * - `test-2` will be assigned the management role for `default` tenant.
12
+ */
13
+ export declare const seedTest: (connection: DatabaseTransactionConnection, forLegacy?: boolean) => Promise<void>;
@@ -1,7 +1,9 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { createDefaultAdminConsoleConfig, defaultTenantId, adminTenantId, defaultManagementApi, createAdminDataInAdminTenant, createMeApiInAdminTenant, createDefaultSignInExperience, createAdminTenantSignInExperience, createDefaultAdminConsoleApplication, createCloudApi, createTenantApplicationRole, CloudScope, } from '@logto/schemas';
3
+ import { createDefaultAdminConsoleConfig, defaultTenantId, adminTenantId, defaultManagementApi, createAdminDataInAdminTenant, createMeApiInAdminTenant, createDefaultSignInExperience, createAdminTenantSignInExperience, createDefaultAdminConsoleApplication, createCloudApi, createTenantApplicationRole, CloudScope, Roles, UsersRoles, LogtoConfigs, SignInExperiences, Applications, OrganizationUserRelations, getTenantOrganizationId, Users, OrganizationRoleUserRelations, TenantRole, } from '@logto/schemas';
4
+ import { getTenantRole } from '@logto/schemas';
4
5
  import { Tenants } from '@logto/schemas/models';
6
+ import { convertToIdentifiers, generateStandardId } from '@logto/shared';
5
7
  import { sql } from 'slonik';
6
8
  import { raw } from 'slonik-sql-tag-raw';
7
9
  import { insertInto } from '../../../database.js';
@@ -10,7 +12,8 @@ import { updateDatabaseTimestamp } from '../../../queries/system.js';
10
12
  import { consoleLog, getPathInModule } from '../../../utils.js';
11
13
  import { appendAdminConsoleRedirectUris, seedTenantCloudServiceApplication } from './cloud.js';
12
14
  import { seedOidcConfigs } from './oidc-config.js';
13
- import { assignScopesToRole, createTenant, seedAdminData } from './tenant.js';
15
+ import { seedTenantOrganizations } from './tenant-organizations.js';
16
+ import { assignScopesToRole, createTenant, seedAdminData, seedLegacyManagementApiUserRole, seedManagementApiProxyApplications, } from './tenant.js';
14
17
  const getExplicitOrder = (query) => {
15
18
  const matched = /\/\*\s*init_order\s*=\s*([\d.]+)\s*\*\//.exec(query)?.[1];
16
19
  return matched ? Number(matched) : undefined;
@@ -85,21 +88,21 @@ export const seedTables = async (connection, latestTimestamp, isCloud) => {
85
88
  await seedAdminData(connection, cloudData, ...cloudAdditionalScopes);
86
89
  // Create tenant application role
87
90
  const applicationRole = createTenantApplicationRole();
88
- await connection.query(insertInto(applicationRole, 'roles'));
91
+ await connection.query(insertInto(applicationRole, Roles.table));
89
92
  await assignScopesToRole(connection, adminTenantId, applicationRole.id, ...cloudAdditionalScopes
90
93
  .filter(({ name }) => name === CloudScope.SendSms || name === CloudScope.SendEmail)
91
94
  .map(({ id }) => id));
92
- // Assign all cloud API scopes to role `admin:admin`
93
- await assignScopesToRole(connection, adminTenantId, adminAdminData.role.id, ...cloudAdditionalScopes.map(({ id }) => id));
94
- // FIXME: @wangsijie should not create tenant Cloud Service application in the OSS DB.
95
- await seedTenantCloudServiceApplication(connection, defaultTenantId);
96
95
  await Promise.all([
97
- connection.query(insertInto(createDefaultAdminConsoleConfig(defaultTenantId), 'logto_configs')),
98
- connection.query(insertInto(createDefaultAdminConsoleConfig(adminTenantId), 'logto_configs')),
99
- connection.query(insertInto(createDefaultSignInExperience(defaultTenantId, isCloud), 'sign_in_experiences')),
100
- connection.query(insertInto(createAdminTenantSignInExperience(), 'sign_in_experiences')),
101
- connection.query(insertInto(createDefaultAdminConsoleApplication(), 'applications')),
96
+ seedLegacyManagementApiUserRole(connection),
97
+ seedTenantCloudServiceApplication(connection, defaultTenantId),
98
+ connection.query(insertInto(createDefaultAdminConsoleConfig(defaultTenantId), LogtoConfigs.table)),
99
+ connection.query(insertInto(createDefaultAdminConsoleConfig(adminTenantId), LogtoConfigs.table)),
100
+ connection.query(insertInto(createDefaultSignInExperience(defaultTenantId, isCloud), SignInExperiences.table)),
101
+ connection.query(insertInto(createAdminTenantSignInExperience(), SignInExperiences.table)),
102
+ connection.query(insertInto(createDefaultAdminConsoleApplication(), Applications.table)),
102
103
  updateDatabaseTimestamp(connection, latestTimestamp),
104
+ seedTenantOrganizations(connection),
105
+ seedManagementApiProxyApplications(connection),
103
106
  ]);
104
107
  consoleLog.succeed('Seed data');
105
108
  };
@@ -109,3 +112,61 @@ export const seedCloud = async (connection) => {
109
112
  seedTenantCloudServiceApplication(connection, adminTenantId),
110
113
  ]);
111
114
  };
115
+ /**
116
+ * Seed additional test data for integration or alteration tests.
117
+ *
118
+ * It will create two users to the admin tenant (`test-1` and `test-2`), and do the following:
119
+ *
120
+ * - `test-1` will be assigned the management roles for both `default` and `admin` tenant.
121
+ * - `test-2` will be assigned the management role for `default` tenant.
122
+ */
123
+ export const seedTest = async (connection, forLegacy = false) => {
124
+ const roles = convertToIdentifiers(Roles);
125
+ const getManagementRole = async (tenantId) => connection.one(sql `
126
+ select ${roles.fields.id}
127
+ from ${roles.table}
128
+ where ${roles.fields.tenantId} = ${adminTenantId}
129
+ and ${roles.fields.name} = ${`${tenantId}:admin`}
130
+ `);
131
+ const assignRoleToUser = async (userId, roleId) => connection.query(insertInto({ id: generateStandardId(), userId, roleId, tenantId: adminTenantId }, UsersRoles.table));
132
+ const userIds = Object.freeze(['test-1', 'test-2']);
133
+ await Promise.all([
134
+ connection.query(insertInto({ id: userIds[0], username: 'test1', tenantId: adminTenantId }, Users.table)),
135
+ connection.query(insertInto({ id: userIds[1], username: 'test2', tenantId: adminTenantId }, Users.table)),
136
+ ]);
137
+ if (forLegacy) {
138
+ const adminTenantRole = await getManagementRole(adminTenantId);
139
+ await assignRoleToUser(userIds[0], adminTenantRole.id);
140
+ }
141
+ consoleLog.succeed('Created test users');
142
+ // The only legacy user role for Management API. Used in OSS only.
143
+ const defaultTenantRole = await getManagementRole(defaultTenantId);
144
+ await Promise.all([
145
+ assignRoleToUser(userIds[0], defaultTenantRole.id),
146
+ assignRoleToUser(userIds[1], defaultTenantRole.id),
147
+ ]);
148
+ consoleLog.succeed('Assigned tenant management roles to the test users');
149
+ // This check is for older versions (<=v.12.0) that don't have tenant organization initialized.
150
+ if (forLegacy) {
151
+ consoleLog.warn('Tenant organization is not enabled in legacy Logto versions, skip seeding tenant organization data');
152
+ return;
153
+ }
154
+ const addOrganizationMembership = async (userId, tenantId) => connection.query(insertInto({ userId, organizationId: getTenantOrganizationId(tenantId), tenantId: adminTenantId }, OrganizationUserRelations.table));
155
+ await Promise.all([
156
+ addOrganizationMembership(userIds[0], adminTenantId),
157
+ addOrganizationMembership(userIds[0], defaultTenantId),
158
+ addOrganizationMembership(userIds[1], defaultTenantId),
159
+ ]);
160
+ const assignOrganizationRole = async (userId, tenantId, tenantRole) => connection.query(insertInto({
161
+ userId,
162
+ organizationRoleId: getTenantRole(tenantRole).id,
163
+ organizationId: getTenantOrganizationId(tenantId),
164
+ tenantId: adminTenantId,
165
+ }, OrganizationRoleUserRelations.table));
166
+ await Promise.all([
167
+ assignOrganizationRole(userIds[0], adminTenantId, TenantRole.Admin),
168
+ assignOrganizationRole(userIds[0], defaultTenantId, TenantRole.Admin),
169
+ assignOrganizationRole(userIds[1], defaultTenantId, TenantRole.Admin),
170
+ ]);
171
+ consoleLog.succeed('Assigned tenant organization membership and roles to the test users');
172
+ };
@@ -0,0 +1,8 @@
1
+ import type { DatabaseTransactionConnection } from 'slonik';
2
+ /**
3
+ * Seed initial data in the admin tenant for tenant organizations (`default` and `admin`):
4
+ *
5
+ * - Organization roles and scopes for tenant management.
6
+ * - Create tenant organizations for the initial tenants (`default` and `admin`).
7
+ */
8
+ export declare const seedTenantOrganizations: (connection: DatabaseTransactionConnection) => Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { defaultTenantId, adminTenantId, OrganizationRoles, TenantRole, getTenantScope, OrganizationScopes, TenantScope, getTenantRole, OrganizationRoleScopeRelations, tenantRoleScopes, getTenantOrganizationCreateData, Organizations, } from '@logto/schemas';
2
+ import { insertInto } from '../../../database.js';
3
+ import { consoleLog } from '../../../utils.js';
4
+ /**
5
+ * Seed initial data in the admin tenant for tenant organizations (`default` and `admin`):
6
+ *
7
+ * - Organization roles and scopes for tenant management.
8
+ * - Create tenant organizations for the initial tenants (`default` and `admin`).
9
+ */
10
+ export const seedTenantOrganizations = async (connection) => {
11
+ const tenantIds = [defaultTenantId, adminTenantId];
12
+ // Init organization template
13
+ await Promise.all([
14
+ connection.query(insertInto(Object.values(TenantRole).map((role) => getTenantRole(role)), OrganizationRoles.table)),
15
+ connection.query(insertInto(Object.values(TenantScope).map((scope) => getTenantScope(scope)), OrganizationScopes.table)),
16
+ ]);
17
+ // Link organization roles and scopes
18
+ await connection.query(insertInto(Object.entries(tenantRoleScopes).flatMap(([role, scopes]) => scopes.map((scope) => ({
19
+ tenantId: adminTenantId,
20
+ // eslint-disable-next-line no-restricted-syntax -- `Object.entries` converts the enum to string
21
+ organizationRoleId: getTenantRole(role).id,
22
+ organizationScopeId: getTenantScope(scope).id,
23
+ }))), OrganizationRoleScopeRelations.table));
24
+ consoleLog.succeed('Initialized tenant organization template');
25
+ // Init tenant organizations
26
+ await connection.query(insertInto(tenantIds.map((id) => getTenantOrganizationCreateData(id)), Organizations.table));
27
+ consoleLog.succeed('Created tenant organizations');
28
+ };
@@ -1,5 +1,15 @@
1
- import type { AdminData, UpdateAdminData, CreateScope } from '@logto/schemas';
2
- import type { CommonQueryMethods } from 'slonik';
1
+ import { type AdminData, type UpdateAdminData, type CreateScope } from '@logto/schemas';
2
+ import type { CommonQueryMethods, DatabaseTransactionConnection } from 'slonik';
3
3
  export declare const createTenant: (pool: CommonQueryMethods, tenantId: string) => Promise<void>;
4
4
  export declare const seedAdminData: (pool: CommonQueryMethods, data: AdminData | UpdateAdminData, ...additionalScopes: CreateScope[]) => Promise<void>;
5
5
  export declare const assignScopesToRole: (pool: CommonQueryMethods, tenantId: string, roleId: string, ...scopeIds: string[]) => Promise<void>;
6
+ /**
7
+ * For each initial tenant (`default` and `admin`), create a machine-to-machine application for
8
+ * Management API proxy and assign the corresponding proxy role to it.
9
+ */
10
+ export declare const seedManagementApiProxyApplications: (connection: DatabaseTransactionConnection) => Promise<void>;
11
+ /**
12
+ * Seed the legacy user role for accessing default Management API, and assign the `all` scope to
13
+ * it. Used in OSS only.
14
+ */
15
+ export declare const seedLegacyManagementApiUserRole: (connection: DatabaseTransactionConnection) => Promise<void>;
@@ -1,10 +1,12 @@
1
1
  import { createTenantMetadata } from '@logto/core-kit';
2
+ import { defaultTenantId, adminTenantId, Applications, ApplicationsRoles, getMapiProxyM2mApp, getMapiProxyRole, defaultManagementApiAdminName, Roles, PredefinedScope, getManagementApiResourceIndicator, } from '@logto/schemas';
2
3
  import { generateStandardId } from '@logto/shared';
3
4
  import { assert } from '@silverhand/essentials';
4
5
  import { sql } from 'slonik';
5
6
  import { raw } from 'slonik-sql-tag-raw';
6
7
  import { insertInto } from '../../../database.js';
7
8
  import { getDatabaseName } from '../../../queries/database.js';
9
+ import { consoleLog } from '../../../utils.js';
8
10
  export const createTenant = async (pool, tenantId) => {
9
11
  const database = await getDatabaseName(pool, true);
10
12
  const { parentRole, role, password } = createTenantMetadata(database, tenantId);
@@ -54,3 +56,49 @@ export const assignScopesToRole = async (pool, tenantId, roleId, ...scopeIds) =>
54
56
  tenantId,
55
57
  }, 'roles_scopes'))));
56
58
  };
59
+ /**
60
+ * For each initial tenant (`default` and `admin`), create a machine-to-machine application for
61
+ * Management API proxy and assign the corresponding proxy role to it.
62
+ */
63
+ export const seedManagementApiProxyApplications = async (connection) => {
64
+ const tenantIds = [defaultTenantId, adminTenantId];
65
+ // Create machine-to-machine applications for Management API proxy
66
+ await connection.query(insertInto(tenantIds.map((tenantId) => getMapiProxyM2mApp(tenantId)), Applications.table));
67
+ consoleLog.succeed('Created machine-to-machine applications for Management API proxy');
68
+ // Assign the proxy roles to the applications
69
+ await connection.query(insertInto(tenantIds.map((tenantId) => ({
70
+ tenantId: adminTenantId,
71
+ id: generateStandardId(),
72
+ applicationId: getMapiProxyM2mApp(tenantId).id,
73
+ roleId: getMapiProxyRole(tenantId).id,
74
+ })), ApplicationsRoles.table));
75
+ consoleLog.succeed('Assigned the proxy roles to the applications');
76
+ };
77
+ /**
78
+ * Seed the legacy user role for accessing default Management API, and assign the `all` scope to
79
+ * it. Used in OSS only.
80
+ */
81
+ export const seedLegacyManagementApiUserRole = async (connection) => {
82
+ const roleId = generateStandardId();
83
+ await connection.query(insertInto({
84
+ tenantId: adminTenantId,
85
+ id: roleId,
86
+ name: defaultManagementApiAdminName,
87
+ description: 'Legacy user role for accessing default Management API. Used in OSS only.',
88
+ }, Roles.table));
89
+ await connection.query(sql `
90
+ insert into roles_scopes (id, role_id, scope_id, tenant_id)
91
+ values (
92
+ ${generateStandardId()},
93
+ ${roleId},
94
+ (
95
+ select scopes.id from scopes
96
+ join resources on scopes.resource_id = resources.id
97
+ where resources.indicator = ${getManagementApiResourceIndicator(defaultTenantId)}
98
+ and scopes.name = ${PredefinedScope.All}
99
+ and scopes.tenant_id = ${adminTenantId}
100
+ ),
101
+ ${adminTenantId}
102
+ );
103
+ `);
104
+ };
@@ -16,7 +16,7 @@ import { cliConfig, ConfigKey, consoleLog, downloadFile, isTty, oraPromise, safe
16
16
  import { seedByPool } from '../database/seed/index.js';
17
17
  const pgRequired = new semver.SemVer('14.0.0');
18
18
  export const validateNodeVersion = () => {
19
- const required = [new semver.SemVer('18.12.0')];
19
+ const required = [new semver.SemVer('20.9.0')];
20
20
  const requiredVersionString = required.map((version) => '^' + version.version).join(' || ');
21
21
  const current = new semver.SemVer(execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }));
22
22
  if (required.every((version) => version.major !== current.major)) {
@@ -11,6 +11,11 @@ const translate = {
11
11
  alias: 'p',
12
12
  type: 'string',
13
13
  describe: 'The path to your Logto instance directory',
14
+ })
15
+ .option('skip-core-check', {
16
+ alias: 'sc',
17
+ type: 'boolean',
18
+ describe: 'Skip checking if the core package is existed',
14
19
  })
15
20
  .command(create)
16
21
  .command(listTags)
@@ -1,8 +1,10 @@
1
1
  import { type CommandModule } from 'yargs';
2
2
  declare const syncKeys: CommandModule<{
3
3
  path?: string;
4
+ skipCoreCheck?: boolean;
4
5
  }, {
5
6
  path?: string;
7
+ skipCoreCheck?: boolean;
6
8
  baseline: string;
7
9
  target: string;
8
10
  skipLint?: boolean;
@@ -22,7 +22,7 @@ const syncKeys = {
22
22
  .option('package', {
23
23
  alias: 'pkg',
24
24
  type: 'string',
25
- describe: 'The package name of the phrases, one of `phrases` or `phrases-experience`',
25
+ describe: 'The package name of the phrases, e.g. `phrases` or `phrases-experience`',
26
26
  default: 'phrases',
27
27
  })
28
28
  .option('target', {
@@ -31,12 +31,12 @@ const syncKeys = {
31
31
  describe: 'The target language tag, or `all` to sync all languages',
32
32
  })
33
33
  .option('skip-lint', {
34
- alias: 's',
34
+ alias: 'sl',
35
35
  type: 'boolean',
36
36
  describe: 'Skip running `eslint --fix` for locales after syncing',
37
37
  })
38
38
  .demandOption(['baseline', 'target']),
39
- handler: async ({ path: inputPath, baseline: baselineTag, target: targetTag, skipLint, package: packageName, }) => {
39
+ handler: async ({ path: inputPath, skipCoreCheck, baseline: baselineTag, target: targetTag, skipLint, package: packageName, }) => {
40
40
  if (!isLanguageTag(baselineTag)) {
41
41
  consoleLog.fatal('Invalid baseline language tag');
42
42
  }
@@ -46,10 +46,7 @@ const syncKeys = {
46
46
  if (baselineTag === targetTag) {
47
47
  consoleLog.fatal('Baseline and target cannot be the same');
48
48
  }
49
- if (packageName !== 'phrases' && packageName !== 'phrases-experience') {
50
- consoleLog.fatal('Invalid package name, expected `phrases` or `phrases-experience`');
51
- }
52
- const instancePath = await inquireInstancePath(inputPath);
49
+ const instancePath = await inquireInstancePath(inputPath, skipCoreCheck);
53
50
  const phrasesPath = path.join(instancePath, 'packages', packageName);
54
51
  const localesPath = path.join(phrasesPath, 'src/locales');
55
52
  const entrypoint = path.join(localesPath, baselineTag.toLowerCase(), 'index.ts');
@@ -1,7 +1,10 @@
1
1
  import type { CommandModule } from 'yargs';
2
2
  declare const sync: CommandModule<{
3
3
  path?: string;
4
+ skipCoreCheck?: boolean;
4
5
  }, {
5
6
  path?: string;
7
+ skipCoreCheck?: boolean;
8
+ package: string;
6
9
  }>;
7
10
  export default sync;
@@ -1,15 +1,21 @@
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-experience';
4
3
  import PQueue from 'p-queue';
5
4
  import { inquireInstancePath, lintLocaleFiles } from '../../utils.js';
6
5
  import { baseLanguage, syncTranslation } from './utils.js';
7
6
  const sync = {
8
7
  command: ['sync'],
9
8
  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);
9
+ builder: (yargs) => yargs.option('package', {
10
+ alias: 'pkg',
11
+ type: 'string',
12
+ describe: 'The package name of the phrases, e.g. `phrases` or `phrases-experience`',
13
+ default: 'phrases',
14
+ }),
15
+ handler: async ({ path: inputPath, skipCoreCheck, package: packageName }) => {
16
+ const queue = new PQueue({ concurrency: 10 });
17
+ const instancePath = await inquireInstancePath(inputPath, skipCoreCheck);
18
+ const packages = packageName ? [packageName] : ['phrases', 'phrases-experience'];
13
19
  for (const languageTag of Object.keys(languages)) {
14
20
  if (languageTag === baseLanguage) {
15
21
  continue;
@@ -19,25 +25,20 @@ const sync = {
19
25
  verbose: false,
20
26
  queue,
21
27
  };
22
- /* eslint-disable no-await-in-loop */
23
- if (isPhrasesBuiltInLanguageTag(languageTag)) {
24
- await syncTranslation({
25
- ...baseOptions,
26
- packageName: 'phrases',
27
- languageTag,
28
- });
28
+ for (const packageName of packages) {
29
+ /* eslint-disable no-await-in-loop */
30
+ if (isPhrasesBuiltInLanguageTag(languageTag)) {
31
+ await syncTranslation({
32
+ ...baseOptions,
33
+ packageName,
34
+ languageTag,
35
+ });
36
+ }
37
+ /* eslint-enable no-await-in-loop */
29
38
  }
30
- if (isPhrasesUiBuiltInLanguageTag(languageTag)) {
31
- await syncTranslation({
32
- ...baseOptions,
33
- packageName: 'phrases-experience',
34
- languageTag,
35
- });
36
- }
37
- /* eslint-enable no-await-in-loop */
38
39
  }
39
40
  await queue.onIdle();
40
- void lintLocaleFiles(instancePath);
41
+ void lintLocaleFiles(instancePath, packageName);
41
42
  },
42
43
  };
43
44
  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-experience';
8
+ packageName: string;
9
9
  languageTag: LanguageTag;
10
10
  verbose?: boolean;
11
11
  queue?: PQueue;
@@ -25,7 +25,7 @@ export const readBaseLocaleFiles = async (directory) => {
25
25
  }
26
26
  return readLocaleFiles(enDirectory);
27
27
  };
28
- export const createFullTranslation = async ({ instancePath, packageName, languageTag, verbose = true, queue = new PQueue({ concurrency: 5 }), }) => {
28
+ export const createFullTranslation = async ({ instancePath, packageName, languageTag, verbose = true, queue = new PQueue({ concurrency: 10 }), }) => {
29
29
  const localeFiles = await getBaseAndTargetLocaleFiles(instancePath, packageName, languageTag);
30
30
  if (verbose) {
31
31
  consoleLog.info('Found ' +
@@ -54,7 +54,7 @@ export const createFullTranslation = async ({ instancePath, packageName, languag
54
54
  }
55
55
  return queue.onIdle();
56
56
  };
57
- export const syncTranslation = async ({ instancePath, packageName, languageTag, verbose = true, queue = new PQueue({ concurrency: 5 }), }) => {
57
+ export const syncTranslation = async ({ instancePath, packageName, languageTag, verbose = true, queue = new PQueue({ concurrency: 10 }), }) => {
58
58
  const localeFiles = await getBaseAndTargetLocaleFiles(instancePath, packageName, languageTag);
59
59
  if (verbose) {
60
60
  consoleLog.info('Found ' +
package/lib/database.d.ts CHANGED
@@ -9,4 +9,8 @@ export declare const createPoolFromConfig: () => Promise<import("slonik").Databa
9
9
  * @returns A new database pool with the database URL in config.
10
10
  */
11
11
  export declare const createPoolAndDatabaseIfNeeded: () => Promise<import("slonik").DatabasePool>;
12
- export declare const insertInto: <T extends SchemaLike<string>>(object: T, table: string) => import("slonik").TaggedTemplateLiteralInvocation<import("slonik").QueryResultRow>;
12
+ /**
13
+ * Build an `insert into` query from the given payload. If the payload is an array, it will insert
14
+ * multiple rows.
15
+ */
16
+ export declare const insertInto: <T extends SchemaLike<string>>(payload: T | T[], table: string) => import("slonik").TaggedTemplateLiteralInvocation<import("slonik").QueryResultRow>;
package/lib/database.js CHANGED
@@ -54,11 +54,20 @@ export const createPoolAndDatabaseIfNeeded = async () => {
54
54
  return createPoolFromConfig();
55
55
  }
56
56
  };
57
- export const insertInto = (object, table) => {
58
- const keys = Object.keys(object);
57
+ /**
58
+ * Build an `insert into` query from the given payload. If the payload is an array, it will insert
59
+ * multiple rows.
60
+ */
61
+ export const insertInto = (payload, table) => {
62
+ const first = Array.isArray(payload) ? payload[0] : payload;
63
+ if (!first) {
64
+ throw new Error('Payload cannot be empty');
65
+ }
66
+ const keys = Object.keys(first);
67
+ const values = Array.isArray(payload) ? payload : [payload];
59
68
  return sql `
60
69
  insert into ${sql.identifier([table])}
61
70
  (${sql.join(keys.map((key) => sql.identifier([decamelize(key)])), sql `, `)})
62
- values (${sql.join(keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), sql `, `)})
71
+ values ${sql.join(values.map((object) => sql `(${sql.join(keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), sql `, `)})`), sql `, `)}
63
72
  `;
64
73
  };
@@ -1,6 +1,6 @@
1
1
  export const packageJson = {
2
2
  "name": "@logto/cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -37,30 +37,30 @@ export const packageJson = {
37
37
  "prepack": "pnpm build"
38
38
  },
39
39
  "engines": {
40
- "node": "^18.12.0"
40
+ "node": "^20.9.0"
41
41
  },
42
42
  "bugs": {
43
43
  "url": "https://github.com/logto-io/logto/issues"
44
44
  },
45
45
  "dependencies": {
46
- "@logto/connector-kit": "workspace:^2.0.0",
47
- "@logto/core-kit": "workspace:^2.2.1",
48
- "@logto/language-kit": "workspace:^1.0.0",
49
- "@logto/phrases": "workspace:^1.8.0",
50
- "@logto/phrases-experience": "workspace:^1.5.0",
51
- "@logto/schemas": "workspace:1.12.0",
52
- "@logto/shared": "workspace:^3.0.0",
53
- "@silverhand/essentials": "^2.8.4",
46
+ "@logto/connector-kit": "workspace:^2.1.0",
47
+ "@logto/core-kit": "workspace:^2.3.0",
48
+ "@logto/language-kit": "workspace:^1.1.0",
49
+ "@logto/phrases": "workspace:^1.9.0",
50
+ "@logto/phrases-experience": "workspace:^1.6.0",
51
+ "@logto/schemas": "workspace:1.13.1",
52
+ "@logto/shared": "workspace:^3.1.0",
53
+ "@silverhand/essentials": "^2.9.0",
54
54
  "chalk": "^5.0.0",
55
55
  "decamelize": "^6.0.0",
56
56
  "dotenv": "^16.0.0",
57
- "got": "^13.0.0",
57
+ "got": "^14.0.0",
58
58
  "hpagent": "^1.2.0",
59
59
  "inquirer": "^9.0.0",
60
60
  "nanoid": "^5.0.1",
61
- "ora": "^6.1.2",
62
- "p-limit": "^4.0.0",
63
- "p-queue": "^7.3.4",
61
+ "ora": "^8.0.1",
62
+ "p-limit": "^5.0.0",
63
+ "p-queue": "^8.0.0",
64
64
  "p-retry": "^6.0.0",
65
65
  "pg-protocol": "^1.6.0",
66
66
  "roarr": "^7.11.0",
@@ -69,23 +69,23 @@ export const packageJson = {
69
69
  "slonik-interceptor-preset": "^1.2.10",
70
70
  "slonik-sql-tag-raw": "^1.1.4",
71
71
  "tar": "^6.1.11",
72
- "typescript": "^5.0.0",
72
+ "typescript": "^5.3.3",
73
73
  "yargs": "^17.6.0",
74
74
  "zod": "^3.22.4"
75
75
  },
76
76
  "devDependencies": {
77
- "@silverhand/eslint-config": "4.0.1",
78
- "@silverhand/ts-config": "4.0.0",
77
+ "@silverhand/eslint-config": "5.0.0",
78
+ "@silverhand/ts-config": "5.0.0",
79
79
  "@withtyped/server": "^0.12.9",
80
80
  "@types/inquirer": "^9.0.0",
81
81
  "@types/jest": "^29.4.0",
82
- "@types/node": "^18.11.18",
82
+ "@types/node": "^20.9.5",
83
83
  "@types/semver": "^7.3.12",
84
- "@types/sinon": "^10.0.13",
84
+ "@types/sinon": "^17.0.0",
85
85
  "@types/tar": "^6.1.2",
86
86
  "@types/yargs": "^17.0.13",
87
87
  "eslint": "^8.44.0",
88
- "jest": "^29.5.0",
88
+ "jest": "^29.7.0",
89
89
  "lint-staged": "^15.0.0",
90
90
  "prettier": "^3.0.0",
91
91
  "sinon": "^17.0.0"
package/lib/utils.d.ts CHANGED
@@ -28,10 +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
+ export declare const inquireInstancePath: (initialPath?: string, skipCoreCheck?: boolean) => Promise<string>;
32
32
  export type ConnectorPackage = {
33
33
  name: string;
34
34
  path: string;
35
35
  };
36
36
  export declare const getConnectorPackagesFromDirectory: (directory: string) => Promise<ConnectorPackage[]>;
37
- export declare const lintLocaleFiles: (instancePath: string, packageName?: 'phrases' | 'phrases-experience') => Promise<void>;
37
+ export declare const lintLocaleFiles: (instancePath: string, packageName?: string) => Promise<void>;
package/lib/utils.js CHANGED
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { ConsoleLog } from '@logto/shared';
8
- import { assert, conditionalString } from '@silverhand/essentials';
8
+ import { assert, conditional, conditionalString } from '@silverhand/essentials';
9
9
  import chalk from 'chalk';
10
10
  import { got } from 'got';
11
11
  import { HttpsProxyAgent } from 'hpagent';
@@ -139,9 +139,9 @@ const validatePath = async (value) => {
139
139
  }
140
140
  return true;
141
141
  };
142
- export const inquireInstancePath = async (initialPath) => {
142
+ export const inquireInstancePath = async (initialPath, skipCoreCheck) => {
143
143
  const inquire = async () => {
144
- if (!initialPath && (await validatePath('.')) === true) {
144
+ if (!initialPath && (skipCoreCheck ?? (await validatePath('.')) === true)) {
145
145
  return path.resolve('.');
146
146
  }
147
147
  if (!isTty()) {
@@ -154,14 +154,16 @@ export const inquireInstancePath = async (initialPath) => {
154
154
  type: 'input',
155
155
  default: defaultPath,
156
156
  filter: (value) => value.trim(),
157
- validate: validatePath,
157
+ validate: conditional(!skipCoreCheck && validatePath),
158
158
  }, { instancePath: initialPath });
159
159
  return instancePath;
160
160
  };
161
161
  const instancePath = await inquire();
162
- const validated = await validatePath(instancePath);
163
- if (validated !== true) {
164
- consoleLog.fatal(validated);
162
+ if (!skipCoreCheck) {
163
+ const validated = await validatePath(instancePath);
164
+ if (validated !== true) {
165
+ consoleLog.fatal(validated);
166
+ }
165
167
  }
166
168
  return instancePath;
167
169
  };
@@ -188,7 +190,7 @@ const execPromise = promisify(execFile);
188
190
  export const lintLocaleFiles = async (
189
191
  /** Logto instance path */
190
192
  instancePath,
191
- /** Target package name, ignore to lint both packages */
193
+ /** Target package name, ignore to lint both `phrases` and `phrases-experience` packages */
192
194
  packageName) => {
193
195
  const spinner = ora({
194
196
  text: 'Running `eslint --fix` for locales',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -22,30 +22,30 @@
22
22
  "url": "git+https://github.com/logto-io/logto.git"
23
23
  },
24
24
  "engines": {
25
- "node": "^18.12.0"
25
+ "node": "^20.9.0"
26
26
  },
27
27
  "bugs": {
28
28
  "url": "https://github.com/logto-io/logto/issues"
29
29
  },
30
30
  "dependencies": {
31
- "@logto/connector-kit": "^2.0.0",
32
- "@logto/core-kit": "^2.2.1",
33
- "@logto/language-kit": "^1.0.0",
34
- "@logto/phrases": "^1.8.0",
35
- "@logto/phrases-experience": "^1.5.0",
36
- "@logto/schemas": "1.12.0",
37
- "@logto/shared": "^3.0.0",
38
- "@silverhand/essentials": "^2.8.4",
31
+ "@logto/connector-kit": "^2.1.0",
32
+ "@logto/core-kit": "^2.3.0",
33
+ "@logto/language-kit": "^1.1.0",
34
+ "@logto/phrases": "^1.9.0",
35
+ "@logto/phrases-experience": "^1.6.0",
36
+ "@logto/schemas": "1.13.1",
37
+ "@logto/shared": "^3.1.0",
38
+ "@silverhand/essentials": "^2.9.0",
39
39
  "chalk": "^5.0.0",
40
40
  "decamelize": "^6.0.0",
41
41
  "dotenv": "^16.0.0",
42
- "got": "^13.0.0",
42
+ "got": "^14.0.0",
43
43
  "hpagent": "^1.2.0",
44
44
  "inquirer": "^9.0.0",
45
45
  "nanoid": "^5.0.1",
46
- "ora": "^6.1.2",
47
- "p-limit": "^4.0.0",
48
- "p-queue": "^7.3.4",
46
+ "ora": "^8.0.1",
47
+ "p-limit": "^5.0.0",
48
+ "p-queue": "^8.0.0",
49
49
  "p-retry": "^6.0.0",
50
50
  "pg-protocol": "^1.6.0",
51
51
  "roarr": "^7.11.0",
@@ -54,23 +54,23 @@
54
54
  "slonik-interceptor-preset": "^1.2.10",
55
55
  "slonik-sql-tag-raw": "^1.1.4",
56
56
  "tar": "^6.1.11",
57
- "typescript": "^5.0.0",
57
+ "typescript": "^5.3.3",
58
58
  "yargs": "^17.6.0",
59
59
  "zod": "^3.22.4"
60
60
  },
61
61
  "devDependencies": {
62
- "@silverhand/eslint-config": "4.0.1",
63
- "@silverhand/ts-config": "4.0.0",
62
+ "@silverhand/eslint-config": "5.0.0",
63
+ "@silverhand/ts-config": "5.0.0",
64
64
  "@withtyped/server": "^0.12.9",
65
65
  "@types/inquirer": "^9.0.0",
66
66
  "@types/jest": "^29.4.0",
67
- "@types/node": "^18.11.18",
67
+ "@types/node": "^20.9.5",
68
68
  "@types/semver": "^7.3.12",
69
- "@types/sinon": "^10.0.13",
69
+ "@types/sinon": "^17.0.0",
70
70
  "@types/tar": "^6.1.2",
71
71
  "@types/yargs": "^17.0.13",
72
72
  "eslint": "^8.44.0",
73
- "jest": "^29.5.0",
73
+ "jest": "^29.7.0",
74
74
  "lint-staged": "^15.0.0",
75
75
  "prettier": "^3.0.0",
76
76
  "sinon": "^17.0.0"