@logto/cli 1.11.0 → 1.13.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.
@@ -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,11 +1,11 @@
1
1
  type FileStructure = {
2
- [key: string]: {
2
+ [key: string | number]: {
3
3
  filePath?: string;
4
4
  structure: FileStructure;
5
5
  };
6
6
  };
7
7
  type NestedPhraseObject = {
8
- [key: string]: [phrase: string, isTranslated: boolean] | NestedPhraseObject;
8
+ [key: string | number]: [phrase: string, isTranslated: boolean] | NestedPhraseObject;
9
9
  };
10
10
  type ParsedTuple = readonly [NestedPhraseObject, FileStructure];
11
11
  /**
@@ -4,6 +4,28 @@ 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 getValue = (property) => {
8
+ if (ts.isStringLiteral(property.initializer) ||
9
+ ts.isNoSubstitutionTemplateLiteral(property.initializer)) {
10
+ return property.initializer.getText();
11
+ }
12
+ consoleLog.fatal('Unsupported property:', property.getFullText());
13
+ };
14
+ const hasLeadingUntranslatedComment = (fullText) => {
15
+ const commentRanges = ts.getLeadingCommentRanges(fullText, 0);
16
+ if (commentRanges?.[0]) {
17
+ if (commentRanges.length > 1) {
18
+ consoleLog.fatal('Multiple comments found for property.');
19
+ }
20
+ const commentRange = commentRanges[0];
21
+ const comment = fullText.slice(commentRange.pos, commentRange.end);
22
+ if (comment.includes(untranslatedMark)) {
23
+ return true;
24
+ }
25
+ consoleLog.warn('Unsupported comment:', comment);
26
+ }
27
+ return false;
28
+ };
7
29
  const untranslatedMark = 'UNTRANSLATED';
8
30
  /**
9
31
  * Given a entrypoint file path of a language, parse the nested object of
@@ -94,33 +116,27 @@ export const parseLocaleFiles = (filePath) => {
94
116
  fileStructure[key] = { structure };
95
117
  /* eslint-enable @silverhand/fp/no-mutation */
96
118
  }
97
- else if (ts.isStringLiteral(property.initializer) ||
98
- ts.isNoSubstitutionTemplateLiteral(property.initializer)) {
99
- const value = property.initializer.getText();
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
- // eslint-disable-next-line @silverhand/fp/no-mutation
113
- nestedObject[key] = [value, true];
114
- consoleLog.warn('Unsupported comment:', comment);
119
+ else if (ts.isArrayLiteralExpression(property.initializer)) {
120
+ const elements = property.initializer.elements.map((element) => {
121
+ if (ts.isStringLiteralLike(element)) {
122
+ return [
123
+ [element.getText(), !hasLeadingUntranslatedComment(element.getFullText())],
124
+ {},
125
+ ];
115
126
  }
116
- }
117
- else {
118
- // eslint-disable-next-line @silverhand/fp/no-mutation
119
- nestedObject[key] = [value, true];
120
- }
127
+ return traverseNode(element, {}, {});
128
+ });
129
+ /* eslint-disable @silverhand/fp/no-mutation */
130
+ nestedObject[key] = Object.fromEntries(elements.map(([phrases], index) => [index, phrases]));
131
+ fileStructure[key] = {
132
+ structure: Object.fromEntries(elements.map(([, structure], index) => [index, { structure }])),
133
+ };
134
+ /* eslint-enable @silverhand/fp/no-mutation */
121
135
  }
122
136
  else {
123
- consoleLog.fatal('Unsupported property:', property);
137
+ const value = getValue(property);
138
+ // eslint-disable-next-line @silverhand/fp/no-mutation
139
+ nestedObject[key] = [value, !hasLeadingUntranslatedComment(property.getFullText())];
124
140
  }
125
141
  }
126
142
  };
@@ -210,10 +226,13 @@ const traverseNode = async (baseline, targetObject, targetFilePath, isRoot = fal
210
226
  await fs.appendFile(targetFilePath, `const ${identifier} = {\n`);
211
227
  // Recursively traverse the nested object of phrases and the file structure
212
228
  // of the baseline language
229
+ // eslint-disable-next-line complexity
213
230
  const traverseObject = async (baseline, targetObject, tabSize) => {
214
231
  const [baselineObject, baselineStructure] = baseline;
232
+ const isBaselineArray = 0 in baselineObject;
215
233
  for (const [key, value] of Object.entries(baselineObject)) {
216
234
  const existingValue = targetObject[key];
235
+ const keyOutput = isBaselineArray ? '' : `${key}: `;
217
236
  if (Array.isArray(value)) {
218
237
  const [phrase] = value;
219
238
  // If the key exists in the target language and the value is a translated
@@ -221,11 +240,11 @@ const traverseNode = async (baseline, targetObject, targetFilePath, isRoot = fal
221
240
  // of the baseline language and add a comment to indicate that the phrase
222
241
  // is untranslated to help identify missing translations.
223
242
  if (Array.isArray(existingValue) && existingValue[1]) {
224
- await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: ${existingValue[0]},\n`);
243
+ await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${keyOutput}${existingValue[0]},\n`);
225
244
  }
226
245
  else {
227
246
  await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}/** ${untranslatedMark} */\n`);
228
- await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: ${phrase},\n`);
247
+ await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${keyOutput}${phrase},\n`);
229
248
  }
230
249
  }
231
250
  // Not a string, treat it as a nested object or an import
@@ -236,11 +255,12 @@ const traverseNode = async (baseline, targetObject, targetFilePath, isRoot = fal
236
255
  await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key},\n`);
237
256
  await traverseNode([value, keyStructure.structure], Array.isArray(existingValue) ? {} : existingValue ?? {}, path.join(targetDirectory, keyStructure.filePath));
238
257
  }
239
- // Otherwise, treat it as a nested object.
258
+ // Otherwise, treat it as a nested object or array.
240
259
  else {
241
- await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${key}: {\n`);
260
+ const isValueArray = 0 in value;
261
+ await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${keyOutput}${isValueArray ? '[' : '{'}\n`);
242
262
  await traverseObject([value, keyStructure?.structure ?? {}], Array.isArray(existingValue) ? {} : existingValue ?? {}, tabSize + 2);
243
- await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}},\n`);
263
+ await fs.appendFile(targetFilePath, `${' '.repeat(tabSize)}${isValueArray ? ']' : '}'},\n`);
244
264
  }
245
265
  }
246
266
  }
@@ -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: 1 });
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.11.0",
3
+ "version": "1.13.0",
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.0",
48
- "@logto/language-kit": "workspace:^1.0.0",
49
- "@logto/phrases": "workspace:^1.7.0",
50
- "@logto/phrases-experience": "workspace:^1.4.0",
51
- "@logto/schemas": "workspace:1.11.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.0",
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.11.0",
3
+ "version": "1.13.0",
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.0",
33
- "@logto/language-kit": "^1.0.0",
34
- "@logto/phrases": "^1.7.0",
35
- "@logto/phrases-experience": "^1.4.0",
36
- "@logto/schemas": "1.11.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.0",
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"