@logto/cli 1.0.0-beta.19 → 1.0.0-rc.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.
@@ -2,7 +2,7 @@ import type { DatabasePool } from 'slonik';
2
2
  import type { CommandModule } from 'yargs';
3
3
  import type { AlterationFile } from './type.js';
4
4
  export declare const getLatestAlterationTimestamp: () => Promise<number>;
5
- export declare const getUndeployedAlterations: (pool: DatabasePool) => Promise<AlterationFile[]>;
5
+ export declare const getAvailableAlterations: (pool: DatabasePool, compareMode?: 'gt' | 'lte') => Promise<AlterationFile[]>;
6
6
  declare const alteration: CommandModule<unknown, {
7
7
  action: string;
8
8
  target?: string;
@@ -1,10 +1,10 @@
1
1
  import { conditionalString } from '@silverhand/essentials';
2
2
  import chalk from 'chalk';
3
3
  import { createPoolFromConfig } from '../../../database.js';
4
- import { getCurrentDatabaseAlterationTimestamp, updateDatabaseTimestamp, } from '../../../queries/logto-config.js';
4
+ import { getCurrentDatabaseAlterationTimestamp, updateDatabaseTimestamp, } from '../../../queries/system.js';
5
5
  import { log } from '../../../utilities.js';
6
6
  import { getAlterationFiles, getTimestampFromFilename } from './utils.js';
7
- import { chooseAlterationsByVersion } from './version.js';
7
+ import { chooseAlterationsByVersion, chooseRevertAlterationsByVersion } from './version.js';
8
8
  const importAlterationScript = async (filePath) => {
9
9
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
10
10
  const module = await import(filePath);
@@ -19,17 +19,28 @@ export const getLatestAlterationTimestamp = async () => {
19
19
  }
20
20
  return getTimestampFromFilename(lastFile.filename);
21
21
  };
22
- export const getUndeployedAlterations = async (pool) => {
22
+ export const getAvailableAlterations = async (pool, compareMode = 'gt') => {
23
23
  const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool);
24
24
  const files = await getAlterationFiles();
25
- return files.filter(({ filename }) => getTimestampFromFilename(filename) > databaseTimestamp);
25
+ return files.filter(({ filename }) => compareMode === 'gt'
26
+ ? getTimestampFromFilename(filename) > databaseTimestamp
27
+ : getTimestampFromFilename(filename) <= databaseTimestamp);
26
28
  };
27
- const deployAlteration = async (pool, { path: filePath, filename }) => {
28
- const { up } = await importAlterationScript(filePath);
29
+ const deployAlteration = async (pool, { path: filePath, filename }, action = 'up') => {
30
+ const { up, down } = await importAlterationScript(filePath);
29
31
  try {
30
32
  await pool.transaction(async (connection) => {
31
- await up(connection);
32
- await updateDatabaseTimestamp(connection, getTimestampFromFilename(filename));
33
+ if (action === 'up') {
34
+ await up(connection);
35
+ await updateDatabaseTimestamp(connection, getTimestampFromFilename(filename));
36
+ }
37
+ if (action === 'down') {
38
+ await down(connection);
39
+ const newTimestamp = getTimestampFromFilename(filename) - 1;
40
+ if (newTimestamp > 0) {
41
+ await updateDatabaseTimestamp(connection, newTimestamp);
42
+ }
43
+ }
33
44
  });
34
45
  }
35
46
  catch (error) {
@@ -39,14 +50,14 @@ const deployAlteration = async (pool, { path: filePath, filename }) => {
39
50
  " This alteration didn't change anything since it was in a transaction.\n" +
40
51
  ' Try to fix the error and deploy again.');
41
52
  }
42
- log.info(`Run alteration ${filename} succeeded`);
53
+ log.info(`Run alteration ${filename} \`${action}()\` function succeeded`);
43
54
  };
44
55
  const alteration = {
45
56
  command: ['alteration <action> [target]', 'alt', 'alter'],
46
57
  describe: 'Perform database alteration',
47
58
  builder: (yargs) => yargs
48
59
  .positional('action', {
49
- describe: 'The action to perform, now it only accepts `deploy` and `list`',
60
+ describe: 'The action to perform, accepts `list`, `deploy`, and `rollback` (or `r`).',
50
61
  type: 'string',
51
62
  demandOption: true,
52
63
  })
@@ -63,7 +74,7 @@ const alteration = {
63
74
  }
64
75
  else if (action === 'deploy') {
65
76
  const pool = await createPoolFromConfig();
66
- const alterations = await chooseAlterationsByVersion(await getUndeployedAlterations(pool), target);
77
+ const alterations = await chooseAlterationsByVersion(await getAvailableAlterations(pool), target);
67
78
  log.info(`Found ${alterations.length} alteration${conditionalString(alterations.length > 1 && 's')} to deploy`);
68
79
  // The await inside the loop is intended, alterations should run in order
69
80
  for (const alteration of alterations) {
@@ -72,6 +83,18 @@ const alteration = {
72
83
  }
73
84
  await pool.end();
74
85
  }
86
+ else if (['rollback', 'r'].includes(action)) {
87
+ const pool = await createPoolFromConfig();
88
+ const alterations = await chooseRevertAlterationsByVersion(await getAvailableAlterations(pool, 'lte'), target ?? '');
89
+ log.info(`Found ${alterations.length} alteration${conditionalString(alterations.length > 1 && 's')} to revert`);
90
+ // The await inside the loop is intended, alterations should run in order
91
+ // eslint-disable-next-line @silverhand/fp/no-mutating-methods
92
+ for (const alteration of alterations.slice().reverse()) {
93
+ // eslint-disable-next-line no-await-in-loop
94
+ await deployAlteration(pool, alteration, 'down');
95
+ }
96
+ await pool.end();
97
+ }
75
98
  else {
76
99
  log.error('Unsupported action');
77
100
  }
@@ -15,18 +15,18 @@ const files = Object.freeze([
15
15
  await mockEsmWithActual('./utils.js', () => ({
16
16
  getAlterationFiles: async () => files,
17
17
  }));
18
- const { getCurrentDatabaseAlterationTimestamp } = await mockEsmWithActual('../../../queries/logto-config.js', () => ({
18
+ const { getCurrentDatabaseAlterationTimestamp } = await mockEsmWithActual('../../../queries/system.js', () => ({
19
19
  getCurrentDatabaseAlterationTimestamp: jest.fn(),
20
20
  }));
21
- const { getUndeployedAlterations } = await import('./index.js');
22
- describe('getUndeployedAlterations()', () => {
21
+ const { getAvailableAlterations } = await import('./index.js');
22
+ describe('getAvailableAlterations()', () => {
23
23
  it('returns all files if database timestamp is 0', async () => {
24
24
  getCurrentDatabaseAlterationTimestamp.mockResolvedValue(0);
25
- await expect(getUndeployedAlterations(pool)).resolves.toEqual(files);
25
+ await expect(getAvailableAlterations(pool)).resolves.toEqual(files);
26
26
  });
27
27
  it('returns files whose timestamp is greater then database timestamp', async () => {
28
28
  getCurrentDatabaseAlterationTimestamp.mockResolvedValue(1663923770);
29
- await expect(getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
29
+ await expect(getAvailableAlterations(pool)).resolves.toEqual([files[1], files[2]]);
30
30
  });
31
31
  });
32
32
  describe('chooseAlterationsByVersion()', () => {
@@ -4,9 +4,8 @@ import { fileURLToPath } from 'node:url';
4
4
  import path from 'path';
5
5
  import { findPackage } from '@logto/shared';
6
6
  import { getPathInModule } from '../../../utilities.js';
7
- import { metaUrl } from './meta-url.js';
8
- const currentDirname = path.dirname(fileURLToPath(metaUrl));
9
- const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
7
+ const currentDirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const alterationFilenameRegex = /-([\d.]+)-?.*\.js$/;
10
9
  export const getTimestampFromFilename = (filename) => {
11
10
  const match = alterationFilenameRegex.exec(filename);
12
11
  if (!match?.[1]) {
@@ -1,2 +1,3 @@
1
1
  import type { AlterationFile } from './type.js';
2
2
  export declare const chooseAlterationsByVersion: (alterations: readonly AlterationFile[], initialVersion?: string) => Promise<AlterationFile[]>;
3
+ export declare const chooseRevertAlterationsByVersion: (alterations: readonly AlterationFile[], version: string) => Promise<AlterationFile[]>;
@@ -3,12 +3,20 @@ import chalk from 'chalk';
3
3
  import inquirer from 'inquirer';
4
4
  import { SemVer, compare, eq, gt } from 'semver';
5
5
  import { findLastIndex, isTty, log } from '../../../utilities.js';
6
+ const getVersionStringFromFilename = (filename) => filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown';
6
7
  const getVersionFromFilename = (filename) => {
7
8
  try {
8
- return new SemVer(filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown');
9
+ return new SemVer(getVersionStringFromFilename(filename));
9
10
  }
10
11
  catch { }
11
12
  };
13
+ const getAlterationVersions = (alterations) => alterations
14
+ .map(({ filename }) => getVersionFromFilename(filename))
15
+ .filter((version) => version instanceof SemVer)
16
+ // Cannot use `Set` to deduplicate since it's a class
17
+ .filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
18
+ .slice()
19
+ .sort((i, j) => compare(j, i));
12
20
  const latestTag = 'latest';
13
21
  const nextTag = 'next';
14
22
  export const chooseAlterationsByVersion = async (alterations, initialVersion) => {
@@ -20,13 +28,7 @@ export const chooseAlterationsByVersion = async (alterations, initialVersion) =>
20
28
  log.info(`Deploy target ${chalk.green(nextTag)}`);
21
29
  return alterations.slice(0, endIndex + 1);
22
30
  }
23
- const versions = alterations
24
- .map(({ filename }) => getVersionFromFilename(filename))
25
- .filter((version) => version instanceof SemVer)
26
- // Cannot use `Set` to deduplicate since it's a class
27
- .filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
28
- .slice()
29
- .sort((i, j) => compare(j, i));
31
+ const versions = getAlterationVersions(alterations);
30
32
  const initialSemVersion = conditional(initialVersion && initialVersion !== latestTag && new SemVer(initialVersion));
31
33
  const firstVersion = versions[0];
32
34
  if (!firstVersion) {
@@ -60,3 +62,14 @@ export const chooseAlterationsByVersion = async (alterations, initialVersion) =>
60
62
  return version && !gt(version, targetVersion);
61
63
  });
62
64
  };
65
+ export const chooseRevertAlterationsByVersion = async (alterations, version) => {
66
+ const semVersion = new SemVer(version);
67
+ log.info(`Revert target ${chalk.green(semVersion.version)}`);
68
+ return alterations.filter(({ filename }) => {
69
+ if (getVersionStringFromFilename(filename) === nextTag) {
70
+ return true;
71
+ }
72
+ const version = getVersionFromFilename(filename);
73
+ return version && gt(version, semVersion);
74
+ });
75
+ };
@@ -1,18 +1,37 @@
1
1
  import { readdir, readFile } from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { generateStandardId } from '@logto/core-kit';
4
- import { logtoConfigGuards, LogtoOidcConfigKey, managementResource, defaultSignInExperience, createDefaultSetting, createDemoAppApplication, defaultRole, } from '@logto/schemas';
5
- import { Hooks } from '@logto/schemas/models';
4
+ import { logtoConfigGuards, LogtoOidcConfigKey, managementResource, defaultSignInExperience, createDefaultAdminConsoleConfig, createDemoAppApplication, defaultRole, managementResourceScope, defaultRoleScopeRelation, defaultTenant, } from '@logto/schemas';
5
+ import { Hooks, Tenants } from '@logto/schemas/models';
6
6
  import chalk from 'chalk';
7
7
  import { sql } from 'slonik';
8
8
  import { raw } from 'slonik-sql-tag-raw';
9
9
  import { z } from 'zod';
10
10
  import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database.js';
11
- import { getRowsByKeys, doesConfigsTableExist, updateDatabaseTimestamp, updateValueByKey, } from '../../../queries/logto-config.js';
11
+ import { getRowsByKeys, doesConfigsTableExist, updateValueByKey, } from '../../../queries/logto-config.js';
12
+ import { updateDatabaseTimestamp } from '../../../queries/system.js';
12
13
  import { getPathInModule, log, oraPromise } from '../../../utilities.js';
13
14
  import { getLatestAlterationTimestamp } from '../alteration/index.js';
14
15
  import { getAlterationDirectory } from '../alteration/utils.js';
15
16
  import { oidcConfigReaders } from './oidc-config.js';
17
+ const getExplicitOrder = (query) => {
18
+ const matched = /\/\*\s*init_order\s*=\s*([\d.]+)\s*\*\//.exec(query)?.[1];
19
+ return matched ? Number(matched) : undefined;
20
+ };
21
+ const compareQuery = ([t1, q1], [t2, q2]) => {
22
+ const o1 = getExplicitOrder(q1);
23
+ const o2 = getExplicitOrder(q2);
24
+ if (o1 === undefined && o2 === undefined) {
25
+ return t1.localeCompare(t2);
26
+ }
27
+ if (o1 === undefined) {
28
+ return 1;
29
+ }
30
+ if (o2 === undefined) {
31
+ return -1;
32
+ }
33
+ return o1 - o2;
34
+ };
16
35
  const createTables = async (connection) => {
17
36
  const tableDirectory = getPathInModule('@logto/schemas', 'tables');
18
37
  const directoryFiles = await readdir(tableDirectory);
@@ -21,23 +40,27 @@ const createTables = async (connection) => {
21
40
  file,
22
41
  await readFile(path.join(tableDirectory, file), 'utf8'),
23
42
  ]));
24
- // Await in loop is intended for better error handling
25
- for (const [, query] of queries) {
43
+ const allQueries = [
44
+ [Hooks.tableName, Hooks.raw],
45
+ [Tenants.tableName, Tenants.raw],
46
+ ...queries,
47
+ ];
48
+ const sorted = allQueries.slice().sort(compareQuery);
49
+ for (const [, query] of sorted) {
26
50
  // eslint-disable-next-line no-await-in-loop
27
51
  await connection.query(sql `${raw(query)}`);
28
52
  }
29
- for (const table of [Hooks]) {
30
- // eslint-disable-next-line no-await-in-loop
31
- await connection.query(sql `${raw(table.raw)}`);
32
- }
33
53
  };
34
54
  const seedTables = async (connection, latestTimestamp) => {
55
+ await connection.query(insertInto(defaultTenant, 'tenants'));
35
56
  await Promise.all([
36
57
  connection.query(insertInto(managementResource, 'resources')),
37
- connection.query(insertInto(createDefaultSetting(), 'settings')),
58
+ connection.query(insertInto(managementResourceScope, 'scopes')),
59
+ connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')),
38
60
  connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')),
39
61
  connection.query(insertInto(createDemoAppApplication(generateStandardId()), 'applications')),
40
62
  connection.query(insertInto(defaultRole, 'roles')),
63
+ connection.query(insertInto(defaultRoleScopeRelation, 'roles_scopes')),
41
64
  updateDatabaseTimestamp(connection, latestTimestamp),
42
65
  ]);
43
66
  };
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { LogtoOidcConfigKey } from '@logto/schemas';
3
- import { getEnv, getEnvAsStringArray } from '@silverhand/essentials';
3
+ import { getEnvAsStringArray } from '@silverhand/essentials';
4
4
  import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities.js';
5
5
  const isBase64FormatPrivateKey = (key) => !key.includes('-');
6
6
  /**
@@ -51,10 +51,4 @@ export const oidcConfigReaders = {
51
51
  const keys = getEnvAsStringArray(envKey);
52
52
  return { value: keys.length > 0 ? keys : [generateOidcCookieKey()], fromEnv: keys.length > 0 };
53
53
  },
54
- [LogtoOidcConfigKey.RefreshTokenReuseInterval]: async () => {
55
- const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL';
56
- const raw = Number(getEnv(envKey));
57
- const value = Math.max(3, raw || 0);
58
- return { value, fromEnv: raw === value };
59
- },
60
54
  };
@@ -1,2 +1,2 @@
1
- export declare const generateOidcPrivateKey: () => Promise<string>;
1
+ export declare const generateOidcPrivateKey: (type?: 'rsa' | 'ec') => Promise<string>;
2
2
  export declare const generateOidcCookieKey: () => string;
@@ -1,18 +1,37 @@
1
1
  import { generateKeyPair } from 'crypto';
2
2
  import { promisify } from 'util';
3
3
  import { nanoid } from 'nanoid';
4
- export const generateOidcPrivateKey = async () => {
5
- const { privateKey } = await promisify(generateKeyPair)('rsa', {
6
- modulusLength: 4096,
7
- publicKeyEncoding: {
8
- type: 'spki',
9
- format: 'pem',
10
- },
11
- privateKeyEncoding: {
12
- type: 'pkcs8',
13
- format: 'pem',
14
- },
15
- });
16
- return privateKey;
4
+ export const generateOidcPrivateKey = async (type = 'ec') => {
5
+ if (type === 'rsa') {
6
+ const { privateKey } = await promisify(generateKeyPair)('rsa', {
7
+ modulusLength: 4096,
8
+ publicKeyEncoding: {
9
+ type: 'spki',
10
+ format: 'pem',
11
+ },
12
+ privateKeyEncoding: {
13
+ type: 'pkcs8',
14
+ format: 'pem',
15
+ },
16
+ });
17
+ return privateKey;
18
+ }
19
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
20
+ if (type === 'ec') {
21
+ const { privateKey } = await promisify(generateKeyPair)('ec', {
22
+ // https://security.stackexchange.com/questions/78621/which-elliptic-curve-should-i-use
23
+ namedCurve: 'secp384r1',
24
+ publicKeyEncoding: {
25
+ type: 'spki',
26
+ format: 'pem',
27
+ },
28
+ privateKeyEncoding: {
29
+ type: 'pkcs8',
30
+ format: 'pem',
31
+ },
32
+ });
33
+ return privateKey;
34
+ }
35
+ throw new Error(`Unsupported private key ${String(type)}`);
17
36
  };
18
37
  export const generateOidcCookieKey = () => nanoid();
@@ -15,7 +15,7 @@ import { seedByPool } from '../database/seed/index.js';
15
15
  export const defaultPath = path.join(os.homedir(), 'logto');
16
16
  const pgRequired = new semver.SemVer('14.0.0');
17
17
  export const validateNodeVersion = () => {
18
- const required = [new semver.SemVer('16.13.0'), new semver.SemVer('18.12.0')];
18
+ const required = [new semver.SemVer('18.12.0')];
19
19
  const requiredVersionString = required.map((version) => '^' + version.version).join(' || ');
20
20
  const current = new semver.SemVer(execSync('node -v', { encoding: 'utf8', stdio: 'pipe' }));
21
21
  if (required.every((version) => version.major !== current.major)) {
@@ -66,7 +66,6 @@ export declare const packageJson: {
66
66
  devDependencies: {
67
67
  "@silverhand/eslint-config": string;
68
68
  "@silverhand/ts-config": string;
69
- "@types/fs-extra": string;
70
69
  "@types/inquirer": string;
71
70
  "@types/jest": string;
72
71
  "@types/node": string;
@@ -78,12 +77,14 @@ export declare const packageJson: {
78
77
  jest: string;
79
78
  "lint-staged": string;
80
79
  prettier: string;
81
- rimraf: string;
82
80
  sinon: string;
83
81
  typescript: string;
84
82
  };
85
83
  eslintConfig: {
86
84
  extends: string;
85
+ rules: {
86
+ complexity: (string | number)[];
87
+ };
87
88
  ignorePatterns: string[];
88
89
  };
89
90
  prettier: string;
@@ -1,6 +1,6 @@
1
1
  export const packageJson = {
2
2
  "name": "@logto/cli",
3
- "version": "1.0.0-beta.19",
3
+ "version": "1.0.0-rc.1",
4
4
  "description": "Logto CLI.",
5
5
  "author": "Silverhand Inc. <contact@silverhand.io>",
6
6
  "homepage": "https://github.com/logto-io/logto#readme",
@@ -24,8 +24,8 @@ export const packageJson = {
24
24
  "scripts": {
25
25
  "precommit": "lint-staged",
26
26
  "prepare:package-json": "node -p \"'export const packageJson = ' + JSON.stringify(require('./package.json'), undefined, 2) + ';'\" > src/package-json.ts",
27
- "build": "rimraf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json",
28
- "build:test": "rimraf lib/ && pnpm prepare:package-json && tsc -p tsconfig.test.json --sourcemap",
27
+ "build": "rm -rf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json",
28
+ "build:test": "rm -rf lib/ && pnpm prepare:package-json && tsc -p tsconfig.test.json --sourcemap",
29
29
  "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
30
30
  "start": "node .",
31
31
  "start:dev": "pnpm build && node .",
@@ -37,7 +37,7 @@ export const packageJson = {
37
37
  "prepack": "pnpm build"
38
38
  },
39
39
  "engines": {
40
- "node": "^16.13.0 || ^18.12.0"
40
+ "node": "^18.12.0"
41
41
  },
42
42
  "bugs": {
43
43
  "url": "https://github.com/logto-io/logto/issues"
@@ -52,7 +52,7 @@ export const packageJson = {
52
52
  "dotenv": "^16.0.0",
53
53
  "got": "^12.5.3",
54
54
  "hpagent": "^1.2.0",
55
- "inquirer": "^8.2.2",
55
+ "inquirer": "^9.0.0",
56
56
  "nanoid": "^4.0.0",
57
57
  "ora": "^6.1.2",
58
58
  "p-limit": "^4.0.0",
@@ -69,10 +69,9 @@ export const packageJson = {
69
69
  "devDependencies": {
70
70
  "@silverhand/eslint-config": "1.3.0",
71
71
  "@silverhand/ts-config": "1.2.1",
72
- "@types/fs-extra": "^9.0.13",
73
- "@types/inquirer": "^8.2.1",
72
+ "@types/inquirer": "^9.0.0",
74
73
  "@types/jest": "^29.1.2",
75
- "@types/node": "^16.0.0",
74
+ "@types/node": "^18.11.18",
76
75
  "@types/semver": "^7.3.12",
77
76
  "@types/sinon": "^10.0.13",
78
77
  "@types/tar": "^6.1.2",
@@ -81,12 +80,17 @@ export const packageJson = {
81
80
  "jest": "^29.3.1",
82
81
  "lint-staged": "^13.0.0",
83
82
  "prettier": "^2.8.1",
84
- "rimraf": "^3.0.2",
85
83
  "sinon": "^15.0.0",
86
84
  "typescript": "^4.9.4"
87
85
  },
88
86
  "eslintConfig": {
89
87
  "extends": "@silverhand",
88
+ "rules": {
89
+ "complexity": [
90
+ "error",
91
+ 11
92
+ ]
93
+ },
90
94
  "ignorePatterns": [
91
95
  "src/package-json.ts"
92
96
  ]
@@ -1,9 +1,6 @@
1
- import type { LogtoConfig, LogtoConfigKey } from '@logto/schemas';
2
- import { logtoConfigGuards } from '@logto/schemas';
3
- import type { DatabasePool, DatabaseTransactionConnection } from 'slonik';
4
- import { z } from 'zod';
5
- export declare const doesConfigsTableExist: (pool: DatabasePool) => Promise<boolean>;
6
- export declare const getRowsByKeys: (pool: DatabasePool | DatabaseTransactionConnection, keys: LogtoConfigKey[]) => Promise<import("slonik").QueryResult<LogtoConfig>>;
7
- export declare const updateValueByKey: <T extends LogtoConfigKey>(pool: DatabasePool | DatabaseTransactionConnection, key: T, value: z.TypeOf<import("@logto/schemas").LogtoConfigGuard[T]>) => Promise<import("slonik").QueryResult<import("slonik").QueryResultRow>>;
8
- export declare const getCurrentDatabaseAlterationTimestamp: (pool: DatabasePool) => Promise<number>;
9
- export declare const updateDatabaseTimestamp: (connection: DatabaseTransactionConnection, timestamp: number) => Promise<import("slonik").QueryResult<import("slonik").QueryResultRow>>;
1
+ import type { LogtoConfig, LogtoConfigKey, logtoConfigGuards } from '@logto/schemas';
2
+ import type { CommonQueryMethods } from 'slonik';
3
+ import type { z } from 'zod';
4
+ export declare const doesConfigsTableExist: (pool: CommonQueryMethods) => Promise<boolean>;
5
+ export declare const getRowsByKeys: (pool: CommonQueryMethods, keys: LogtoConfigKey[]) => Promise<import("slonik").QueryResult<LogtoConfig>>;
6
+ export declare const updateValueByKey: <T extends LogtoConfigKey>(pool: CommonQueryMethods, key: T, value: z.TypeOf<import("@logto/schemas").LogtoConfigGuard[T]>) => Promise<import("slonik").QueryResult<import("slonik").QueryResultRow>>;
@@ -1,7 +1,6 @@
1
- import { logtoConfigGuards, LogtoConfigs, AlterationStateKey } from '@logto/schemas';
1
+ import { LogtoConfigs } from '@logto/schemas';
2
2
  import { convertToIdentifiers } from '@logto/shared';
3
3
  import { sql } from 'slonik';
4
- import { z } from 'zod';
5
4
  const { table, fields } = convertToIdentifiers(LogtoConfigs);
6
5
  export const doesConfigsTableExist = async (pool) => {
7
6
  const { rows } = await pool.query(sql `select to_regclass(${LogtoConfigs.table}) as regclass`);
@@ -14,28 +13,6 @@ export const getRowsByKeys = async (pool, keys) => pool.query(sql `
14
13
  export const updateValueByKey = async (pool, key, value) => pool.query(sql `
15
14
  insert into ${table} (${fields.key}, ${fields.value})
16
15
  values (${key}, ${sql.jsonb(value)})
17
- on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value}
16
+ on conflict (${fields.tenantId}, ${fields.key})
17
+ do update set ${fields.value}=excluded.${fields.value}
18
18
  `);
19
- export const getCurrentDatabaseAlterationTimestamp = async (pool) => {
20
- try {
21
- const result = await pool.maybeOne(sql `select * from ${table} where ${fields.key}=${AlterationStateKey.AlterationState}`);
22
- const parsed = logtoConfigGuards[AlterationStateKey.AlterationState].safeParse(result?.value);
23
- return (parsed.success && parsed.data.timestamp) || 0;
24
- }
25
- catch (error) {
26
- const result = z.object({ code: z.string() }).safeParse(error);
27
- // Relation does not exist, treat as 0
28
- // https://www.postgresql.org/docs/14/errcodes-appendix.html
29
- if (result.success && result.data.code === '42P01') {
30
- return 0;
31
- }
32
- throw error;
33
- }
34
- };
35
- export const updateDatabaseTimestamp = async (connection, timestamp) => {
36
- const value = {
37
- timestamp,
38
- updatedAt: new Date().toISOString(),
39
- };
40
- return updateValueByKey(connection, AlterationStateKey.AlterationState, value);
41
- };
@@ -0,0 +1,4 @@
1
+ import type { CommonQueryMethods, DatabaseTransactionConnection } from 'slonik';
2
+ export declare const doesSystemsTableExist: (pool: CommonQueryMethods) => Promise<boolean>;
3
+ export declare const getCurrentDatabaseAlterationTimestamp: (pool: CommonQueryMethods) => Promise<number>;
4
+ export declare const updateDatabaseTimestamp: (connection: DatabaseTransactionConnection, timestamp: number) => Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { systemGuards, Systems, AlterationStateKey } from '@logto/schemas';
2
+ import { convertToIdentifiers } from '@logto/shared';
3
+ import { sql } from 'slonik';
4
+ import { z } from 'zod';
5
+ const { fields } = convertToIdentifiers(Systems);
6
+ const doesTableExist = async (pool, table) => {
7
+ const { rows } = await pool.query(sql `select to_regclass(${table}) as regclass`);
8
+ return Boolean(rows[0]?.regclass);
9
+ };
10
+ export const doesSystemsTableExist = async (pool) => doesTableExist(pool, Systems.table);
11
+ const legacyLogtoConfigsTable = '_logto_configs';
12
+ const getAlterationStateTable = async (pool) => (await doesSystemsTableExist(pool))
13
+ ? sql.identifier([Systems.table])
14
+ : sql.identifier([legacyLogtoConfigsTable]); // Fall back to the old config table
15
+ export const getCurrentDatabaseAlterationTimestamp = async (pool) => {
16
+ const table = await getAlterationStateTable(pool);
17
+ try {
18
+ const result = await pool.maybeOne(sql `select * from ${table} where ${fields.key}=${AlterationStateKey.AlterationState}`);
19
+ const parsed = systemGuards[AlterationStateKey.AlterationState].safeParse(result?.value);
20
+ return (parsed.success && parsed.data.timestamp) || 0;
21
+ }
22
+ catch (error) {
23
+ const result = z.object({ code: z.string() }).safeParse(error);
24
+ // Relation does not exist, treat as 0
25
+ // https://www.postgresql.org/docs/14/errcodes-appendix.html
26
+ if (result.success && result.data.code === '42P01') {
27
+ return 0;
28
+ }
29
+ throw error;
30
+ }
31
+ };
32
+ export const updateDatabaseTimestamp = async (connection, timestamp) => {
33
+ const table = await getAlterationStateTable(connection);
34
+ const value = {
35
+ timestamp,
36
+ updatedAt: new Date().toISOString(),
37
+ };
38
+ await connection.query(sql `
39
+ insert into ${table} (${fields.key}, ${fields.value})
40
+ values (${AlterationStateKey.AlterationState}, ${sql.jsonb(value)})
41
+ on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value}
42
+ `);
43
+ };
@@ -1,8 +1,8 @@
1
- import { AlterationStateKey, LogtoConfigs } from '@logto/schemas';
1
+ import { AlterationStateKey, Systems } from '@logto/schemas';
2
2
  import { convertToIdentifiers } from '@logto/shared';
3
3
  import { createMockPool, createMockQueryResult, sql } from 'slonik';
4
4
  import { expectSqlAssert } from '../test-utilities.js';
5
- import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config.js';
5
+ import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './system.js';
6
6
  const { jest } = import.meta;
7
7
  const mockQuery = jest.fn();
8
8
  const pool = createMockPool({
@@ -10,18 +10,21 @@ const pool = createMockPool({
10
10
  return mockQuery(sql, values);
11
11
  },
12
12
  });
13
- const { table, fields } = convertToIdentifiers(LogtoConfigs);
13
+ const { table, fields } = convertToIdentifiers(Systems);
14
14
  const timestamp = 1663923776;
15
+ const systemsTableExists = async () => createMockQueryResult([{ regclass: true }]);
15
16
  describe('getCurrentDatabaseAlterationTimestamp()', () => {
16
17
  it('returns 0 if query failed (table not found)', async () => {
17
- mockQuery.mockRejectedValueOnce({ code: '42P01' });
18
+ mockQuery.mockImplementationOnce(systemsTableExists).mockRejectedValueOnce({ code: '42P01' });
18
19
  await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0);
19
20
  });
20
21
  it('returns 0 if the row is not found', async () => {
21
22
  const expectSql = sql `
22
23
  select * from ${table} where ${fields.key}=$1
23
24
  `;
24
- mockQuery.mockImplementationOnce(async (sql, values) => {
25
+ mockQuery
26
+ .mockImplementationOnce(systemsTableExists)
27
+ .mockImplementationOnce(async (sql, values) => {
25
28
  expectSqlAssert(sql, expectSql.sql);
26
29
  expect(values).toEqual([AlterationStateKey.AlterationState]);
27
30
  return createMockQueryResult([]);
@@ -32,7 +35,9 @@ describe('getCurrentDatabaseAlterationTimestamp()', () => {
32
35
  const expectSql = sql `
33
36
  select * from ${table} where ${fields.key}=$1
34
37
  `;
35
- mockQuery.mockImplementationOnce(async (sql, values) => {
38
+ mockQuery
39
+ .mockImplementationOnce(systemsTableExists)
40
+ .mockImplementationOnce(async (sql, values) => {
36
41
  expectSqlAssert(sql, expectSql.sql);
37
42
  expect(values).toEqual([AlterationStateKey.AlterationState]);
38
43
  return createMockQueryResult([{ value: 'some_value' }]);
@@ -43,7 +48,9 @@ describe('getCurrentDatabaseAlterationTimestamp()', () => {
43
48
  const expectSql = sql `
44
49
  select * from ${table} where ${fields.key}=$1
45
50
  `;
46
- mockQuery.mockImplementationOnce(async (sql, values) => {
51
+ mockQuery
52
+ .mockImplementationOnce(systemsTableExists)
53
+ .mockImplementationOnce(async (sql, values) => {
47
54
  expectSqlAssert(sql, expectSql.sql);
48
55
  expect(values).toEqual([AlterationStateKey.AlterationState]);
49
56
  // @ts-expect-error createMockQueryResult doesn't support jsonb
@@ -67,7 +74,9 @@ describe('updateDatabaseTimestamp()', () => {
67
74
  jest.useRealTimers();
68
75
  });
69
76
  it('sends upsert sql with timestamp and updatedAt', async () => {
70
- mockQuery.mockImplementationOnce(async (sql, values) => {
77
+ mockQuery
78
+ .mockImplementationOnce(systemsTableExists)
79
+ .mockImplementationOnce(async (sql, values) => {
71
80
  expectSqlAssert(sql, expectSql.sql);
72
81
  expect(values).toEqual([
73
82
  AlterationStateKey.AlterationState,
package/lib/utilities.js CHANGED
@@ -10,7 +10,6 @@ import { HttpsProxyAgent } from 'hpagent';
10
10
  import inquirer from 'inquirer';
11
11
  import ora from 'ora';
12
12
  import { z } from 'zod';
13
- import { metaUrl } from './meta-url.js';
14
13
  export const safeExecSync = (command) => {
15
14
  try {
16
15
  return execSync(command, { encoding: 'utf8', stdio: 'pipe' });
@@ -66,7 +65,7 @@ export const downloadFile = async (url, destination) => {
66
65
  };
67
66
  export const getPathInModule = (moduleName, relativePath = '/') =>
68
67
  // https://stackoverflow.com/a/49455609/12514940
69
- path.join(path.dirname(createRequire(metaUrl).resolve(`${moduleName}/package.json`)), relativePath);
68
+ path.join(path.dirname(createRequire(import.meta.url).resolve(`${moduleName}/package.json`)), relativePath);
70
69
  export const oraPromise = async (promise, options, exitOnError = false) => {
71
70
  const spinner = ora(options).start();
72
71
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/cli",
3
- "version": "1.0.0-beta.19",
3
+ "version": "1.0.0-rc.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,7 +22,7 @@
22
22
  "url": "git+https://github.com/logto-io/logto.git"
23
23
  },
24
24
  "engines": {
25
- "node": "^16.13.0 || ^18.12.0"
25
+ "node": "^18.12.0"
26
26
  },
27
27
  "bugs": {
28
28
  "url": "https://github.com/logto-io/logto/issues"
@@ -34,7 +34,7 @@
34
34
  "dotenv": "^16.0.0",
35
35
  "got": "^12.5.3",
36
36
  "hpagent": "^1.2.0",
37
- "inquirer": "^8.2.2",
37
+ "inquirer": "^9.0.0",
38
38
  "nanoid": "^4.0.0",
39
39
  "ora": "^6.1.2",
40
40
  "p-limit": "^4.0.0",
@@ -47,17 +47,16 @@
47
47
  "tar": "^6.1.11",
48
48
  "yargs": "^17.6.0",
49
49
  "zod": "^3.20.2",
50
- "@logto/core-kit": "1.0.0-beta.30",
51
- "@logto/schemas": "1.0.0-beta.18",
52
- "@logto/shared": "1.0.0-beta.18"
50
+ "@logto/core-kit": "1.0.0-rc.0",
51
+ "@logto/schemas": "1.0.0-rc.0",
52
+ "@logto/shared": "1.0.0-rc.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@silverhand/eslint-config": "1.3.0",
56
56
  "@silverhand/ts-config": "1.2.1",
57
- "@types/fs-extra": "^9.0.13",
58
- "@types/inquirer": "^8.2.1",
57
+ "@types/inquirer": "^9.0.0",
59
58
  "@types/jest": "^29.1.2",
60
- "@types/node": "^16.0.0",
59
+ "@types/node": "^18.11.18",
61
60
  "@types/semver": "^7.3.12",
62
61
  "@types/sinon": "^10.0.13",
63
62
  "@types/tar": "^6.1.2",
@@ -66,12 +65,17 @@
66
65
  "jest": "^29.3.1",
67
66
  "lint-staged": "^13.0.0",
68
67
  "prettier": "^2.8.1",
69
- "rimraf": "^3.0.2",
70
68
  "sinon": "^15.0.0",
71
69
  "typescript": "^4.9.4"
72
70
  },
73
71
  "eslintConfig": {
74
72
  "extends": "@silverhand",
73
+ "rules": {
74
+ "complexity": [
75
+ "error",
76
+ 11
77
+ ]
78
+ },
75
79
  "ignorePatterns": [
76
80
  "src/package-json.ts"
77
81
  ]
@@ -80,8 +84,8 @@
80
84
  "scripts": {
81
85
  "precommit": "lint-staged",
82
86
  "prepare:package-json": "node -p \"'export const packageJson = ' + JSON.stringify(require('./package.json'), undefined, 2) + ';'\" > src/package-json.ts",
83
- "build": "rimraf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json",
84
- "build:test": "rimraf lib/ && pnpm prepare:package-json && tsc -p tsconfig.test.json --sourcemap",
87
+ "build": "rm -rf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json",
88
+ "build:test": "rm -rf lib/ && pnpm prepare:package-json && tsc -p tsconfig.test.json --sourcemap",
85
89
  "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
86
90
  "start": "node .",
87
91
  "start:dev": "pnpm build && node .",
@@ -1 +0,0 @@
1
- export declare const metaUrl: string;
@@ -1,4 +0,0 @@
1
- // Have to define this in a separate file since Jest sticks with CJS
2
- // We need to mock this before running tests
3
- // https://github.com/facebook/jest/issues/12952
4
- export const metaUrl = import.meta.url;
package/lib/meta-url.d.ts DELETED
@@ -1 +0,0 @@
1
- export declare const metaUrl: string;
package/lib/meta-url.js DELETED
@@ -1,4 +0,0 @@
1
- // Have to define this in a separate file since Jest sticks with CJS
2
- // We need to mock this before running tests
3
- // https://github.com/facebook/jest/issues/12952
4
- export const metaUrl = import.meta.url;