mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
Merge pull request #2139 from logto-io/gao-refactor-cli
refactor(cli): update install, alteration, and version command
This commit is contained in:
commit
c887c5cecc
9 changed files with 257 additions and 75 deletions
|
@ -1,40 +0,0 @@
|
|||
import { createMockPool } from 'slonik';
|
||||
|
||||
import * as queries from '../../queries/logto-config';
|
||||
import { QueryType } from '../../test-utilities';
|
||||
import * as functions from './alteration';
|
||||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const files = Object.freeze([
|
||||
{ filename: '1.0.0-1663923770-a.js', path: '/alterations/1.0.0-1663923770-a.js' },
|
||||
{ filename: '1.0.0-1663923771-b.js', path: '/alterations/1.0.0-1663923771-b.js' },
|
||||
{ filename: '1.0.0-1663923772-c.js', path: '/alterations/1.0.0-1663923772-c.js' },
|
||||
]);
|
||||
|
||||
describe('getUndeployedAlterations()', () => {
|
||||
beforeEach(() => {
|
||||
// `getAlterationFiles()` will ensure the order
|
||||
jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]);
|
||||
});
|
||||
|
||||
it('returns all files if database timestamp is 0', async () => {
|
||||
jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files);
|
||||
});
|
||||
|
||||
it('returns files whose timestamp is greater then database timestamp', async () => {
|
||||
jest
|
||||
.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp')
|
||||
.mockResolvedValueOnce(1_663_923_770);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
|
||||
});
|
||||
});
|
81
packages/cli/src/commands/database/alteration/index.test.ts
Normal file
81
packages/cli/src/commands/database/alteration/index.test.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { createMockPool } from 'slonik';
|
||||
|
||||
import * as functions from '.';
|
||||
import * as queries from '../../../queries/logto-config';
|
||||
import { QueryType } from '../../../test-utilities';
|
||||
import { chooseAlterationsByVersion } from './version';
|
||||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
describe('getUndeployedAlterations()', () => {
|
||||
const files = Object.freeze([
|
||||
{ filename: '1.0.0-1663923770-a.js', path: '/alterations/1.0.0-1663923770-a.js' },
|
||||
{ filename: '1.0.0-1663923771-b.js', path: '/alterations/1.0.0-1663923771-b.js' },
|
||||
{ filename: '1.0.0-1663923772-c.js', path: '/alterations/1.0.0-1663923772-c.js' },
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
// `getAlterationFiles()` will ensure the order
|
||||
jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]);
|
||||
});
|
||||
|
||||
it('returns all files if database timestamp is 0', async () => {
|
||||
jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files);
|
||||
});
|
||||
|
||||
it('returns files whose timestamp is greater then database timestamp', async () => {
|
||||
jest
|
||||
.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp')
|
||||
.mockResolvedValueOnce(1_663_923_770);
|
||||
|
||||
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseAlterationsByVersion()', () => {
|
||||
const files = Object.freeze(
|
||||
[
|
||||
'1.0.0_beta.9-1663923770-a.js',
|
||||
'1.0.0_beta.9-1663923771-b.js',
|
||||
'1.0.0_beta.10-1663923772-c.js',
|
||||
'1.0.0_beta.11-1663923773-c.js',
|
||||
'1.0.0_beta.11-1663923774-c.js',
|
||||
'1.0.0-1663923775-c.js',
|
||||
'1.0.0-1663923776-c.js',
|
||||
'1.0.1-1663923777-c.js',
|
||||
'1.2.0-1663923778-c.js',
|
||||
'next-1663923778-c.js',
|
||||
'next-1663923779-c.js',
|
||||
'next-1663923780-c.js',
|
||||
'next1-1663923781-c.js',
|
||||
].map((filename) => ({ filename, path: '/alterations/' + filename }))
|
||||
);
|
||||
|
||||
it('chooses nothing when input version is invalid', async () => {
|
||||
await expect(chooseAlterationsByVersion(files, 'next1')).rejects.toThrow(
|
||||
'Invalid Version: next1'
|
||||
);
|
||||
await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow('Invalid Version: ok');
|
||||
});
|
||||
|
||||
it('chooses correct alteration files', async () => {
|
||||
await Promise.all([
|
||||
expect(chooseAlterationsByVersion([], 'v1.0.0')).resolves.toEqual([]),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.0.0')).resolves.toEqual(files.slice(0, 7)),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.0.0-beta.10')).resolves.toEqual(
|
||||
files.slice(0, 3)
|
||||
),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.1.0')).resolves.toEqual(files.slice(0, 8)),
|
||||
expect(chooseAlterationsByVersion(files, 'v1.2.0')).resolves.toEqual(files.slice(0, 9)),
|
||||
expect(chooseAlterationsByVersion(files, 'next')).resolves.toEqual(files.slice(0, 11)),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -5,28 +5,37 @@ import { findPackage } from '@logto/shared';
|
|||
import { conditionalString } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import { copy, existsSync, remove, readdir } from 'fs-extra';
|
||||
import { SemVer } from 'semver';
|
||||
import { DatabasePool } from 'slonik';
|
||||
import { CommandModule } from 'yargs';
|
||||
|
||||
import { createPoolFromConfig } from '../../database';
|
||||
import { createPoolFromConfig } from '../../../database';
|
||||
import {
|
||||
getCurrentDatabaseAlterationTimestamp,
|
||||
updateDatabaseTimestamp,
|
||||
} from '../../queries/logto-config';
|
||||
import { getPathInModule, log } from '../../utilities';
|
||||
} from '../../../queries/logto-config';
|
||||
import { getPathInModule, log } from '../../../utilities';
|
||||
import { AlterationFile } from './type';
|
||||
import { chooseAlterationsByVersion } from './version';
|
||||
|
||||
const alterationFileNameRegex = /-(\d+)-?.*\.js$/;
|
||||
const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
|
||||
|
||||
const getTimestampFromFileName = (fileName: string) => {
|
||||
const match = alterationFileNameRegex.exec(fileName);
|
||||
const getTimestampFromFilename = (filename: string) => {
|
||||
const match = alterationFilenameRegex.exec(filename);
|
||||
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Can not get timestamp: ${fileName}`);
|
||||
throw new Error(`Can not get timestamp: ${filename}`);
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
};
|
||||
|
||||
const getVersionFromFilename = (filename: string) => {
|
||||
try {
|
||||
return new SemVer(filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const importAlterationScript = async (filePath: string): Promise<AlterationScript> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const module = await import(filePath);
|
||||
|
@ -35,8 +44,6 @@ const importAlterationScript = async (filePath: string): Promise<AlterationScrip
|
|||
return module.default as AlterationScript;
|
||||
};
|
||||
|
||||
type AlterationFile = { path: string; filename: string };
|
||||
|
||||
export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
|
||||
const alterationDirectory = getPathInModule('@logto/schemas', 'alterations');
|
||||
|
||||
|
@ -67,11 +74,11 @@ export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
|
|||
await copy(alterationDirectory, localAlterationDirectory);
|
||||
|
||||
const directory = await readdir(localAlterationDirectory);
|
||||
const files = directory.filter((file) => alterationFileNameRegex.test(file));
|
||||
const files = directory.filter((file) => alterationFilenameRegex.test(file));
|
||||
|
||||
return files
|
||||
.slice()
|
||||
.sort((file1, file2) => getTimestampFromFileName(file1) - getTimestampFromFileName(file2))
|
||||
.sort((file1, file2) => getTimestampFromFilename(file1) - getTimestampFromFilename(file2))
|
||||
.map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename }));
|
||||
};
|
||||
|
||||
|
@ -83,14 +90,14 @@ export const getLatestAlterationTimestamp = async () => {
|
|||
return 0;
|
||||
}
|
||||
|
||||
return getTimestampFromFileName(lastFile.filename);
|
||||
return getTimestampFromFilename(lastFile.filename);
|
||||
};
|
||||
|
||||
export const getUndeployedAlterations = async (pool: DatabasePool) => {
|
||||
const databaseTimestamp = await getCurrentDatabaseAlterationTimestamp(pool);
|
||||
const files = await getAlterationFiles();
|
||||
|
||||
return files.filter(({ filename }) => getTimestampFromFileName(filename) > databaseTimestamp);
|
||||
return files.filter(({ filename }) => getTimestampFromFilename(filename) > databaseTimestamp);
|
||||
};
|
||||
|
||||
const deployAlteration = async (
|
||||
|
@ -102,7 +109,7 @@ const deployAlteration = async (
|
|||
try {
|
||||
await pool.transaction(async (connection) => {
|
||||
await up(connection);
|
||||
await updateDatabaseTimestamp(connection, getTimestampFromFileName(filename));
|
||||
await updateDatabaseTimestamp(connection, getTimestampFromFilename(filename));
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
@ -118,22 +125,30 @@ const deployAlteration = async (
|
|||
log.info(`Run alteration ${filename} succeeded`);
|
||||
};
|
||||
|
||||
const alteration: CommandModule<unknown, { action: string }> = {
|
||||
command: ['alteration <action>', 'alt', 'alter'],
|
||||
const alteration: CommandModule<unknown, { action: string; target?: string }> = {
|
||||
command: ['alteration <action> [target]', 'alt', 'alter'],
|
||||
describe: 'Perform database alteration',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('action', {
|
||||
describe: 'The action to perform, now it only accepts `deploy`',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async ({ action }) => {
|
||||
yargs
|
||||
.positional('action', {
|
||||
describe: 'The action to perform, now it only accepts `deploy`',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
})
|
||||
.positional('target', {
|
||||
describe: 'The target Logto version for alteration',
|
||||
type: 'string',
|
||||
}),
|
||||
handler: async ({ action, target }) => {
|
||||
if (action !== 'deploy') {
|
||||
log.error('Unsupported action');
|
||||
}
|
||||
|
||||
const pool = await createPoolFromConfig();
|
||||
const alterations = await getUndeployedAlterations(pool);
|
||||
const alterations = await chooseAlterationsByVersion(
|
||||
await getUndeployedAlterations(pool),
|
||||
target
|
||||
);
|
||||
|
||||
log.info(
|
||||
`Found ${alterations.length} alteration${conditionalString(
|
1
packages/cli/src/commands/database/alteration/type.ts
Normal file
1
packages/cli/src/commands/database/alteration/type.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type AlterationFile = { path: string; filename: string };
|
75
packages/cli/src/commands/database/alteration/version.ts
Normal file
75
packages/cli/src/commands/database/alteration/version.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import chalk from 'chalk';
|
||||
import inquirer from 'inquirer';
|
||||
import { SemVer, compare, eq, gt } from 'semver';
|
||||
|
||||
import { findLastIndex, log } from '../../../utilities';
|
||||
import { AlterationFile } from './type';
|
||||
|
||||
const getVersionFromFilename = (filename: string) => {
|
||||
try {
|
||||
return new SemVer(filename.split('-')[0]?.replaceAll('_', '-') ?? 'unknown');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const latestTag = 'latest';
|
||||
const nextTag = 'next';
|
||||
|
||||
export const chooseAlterationsByVersion = async (
|
||||
alterations: readonly AlterationFile[],
|
||||
initialVersion?: string
|
||||
) => {
|
||||
if (initialVersion === nextTag) {
|
||||
const endIndex = findLastIndex(
|
||||
alterations,
|
||||
({ filename }) =>
|
||||
filename.startsWith(nextTag + '-') || Boolean(getVersionFromFilename(filename))
|
||||
);
|
||||
|
||||
if (endIndex === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
log.info(`Deploy target ${chalk.green(nextTag)}`);
|
||||
|
||||
return alterations.slice(0, endIndex);
|
||||
}
|
||||
|
||||
const versions = alterations
|
||||
.map(({ filename }) => getVersionFromFilename(filename))
|
||||
.filter((version): version is SemVer => version instanceof SemVer)
|
||||
// Cannot use `Set` to deduplicate since it's a class
|
||||
.filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
|
||||
.slice()
|
||||
.sort((i, j) => compare(j, i));
|
||||
|
||||
const { version: targetVersion } =
|
||||
initialVersion === latestTag
|
||||
? { version: versions[0] }
|
||||
: await inquirer.prompt<{ version: SemVer }>(
|
||||
{
|
||||
type: 'list',
|
||||
message: 'Choose the alteration target version',
|
||||
name: 'version',
|
||||
choices: versions.map((semVersion) => ({
|
||||
name: semVersion.version,
|
||||
value: semVersion,
|
||||
})),
|
||||
},
|
||||
{
|
||||
version: conditional(initialVersion && new SemVer(initialVersion)),
|
||||
}
|
||||
);
|
||||
|
||||
if (!targetVersion) {
|
||||
return [];
|
||||
}
|
||||
|
||||
log.info(`Deploy target ${chalk.green(targetVersion.version)}`);
|
||||
|
||||
return alterations.filter(({ filename }) => {
|
||||
const version = getVersionFromFilename(filename);
|
||||
|
||||
return version && !gt(version, targetVersion);
|
||||
});
|
||||
};
|
|
@ -63,30 +63,37 @@ const installLogto = async ({ path, skipSeed, officialConnectors }: InstallArgs)
|
|||
logFinale(instancePath);
|
||||
};
|
||||
|
||||
const install: CommandModule<unknown, InstallArgs> = {
|
||||
const install: CommandModule<
|
||||
unknown,
|
||||
{
|
||||
p?: string;
|
||||
ss: boolean;
|
||||
oc?: boolean;
|
||||
}
|
||||
> = {
|
||||
command: ['init', 'i', 'install'],
|
||||
describe: 'Download and run the latest Logto release',
|
||||
builder: (yargs) =>
|
||||
yargs.options({
|
||||
path: {
|
||||
alias: 'p',
|
||||
p: {
|
||||
alias: 'path',
|
||||
describe: 'Path of Logto, must be a non-existing path',
|
||||
type: 'string',
|
||||
},
|
||||
skipSeed: {
|
||||
alias: 'ss',
|
||||
ss: {
|
||||
alias: 'skip-seed',
|
||||
describe: 'Skip Logto database seeding',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
officialConnectors: {
|
||||
alias: 'oc',
|
||||
oc: {
|
||||
alias: 'official-connectors',
|
||||
describe: 'Add official connectors after downloading Logto',
|
||||
type: 'boolean',
|
||||
},
|
||||
}),
|
||||
handler: async ({ path, skipSeed, officialConnectors }) => {
|
||||
await installLogto({ path, skipSeed, officialConnectors });
|
||||
handler: async ({ p, ss, oc }) => {
|
||||
await installLogto({ path: p, skipSeed: ss, officialConnectors: oc });
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -6,20 +6,35 @@ import { hideBin } from 'yargs/helpers';
|
|||
import connector from './commands/connector';
|
||||
import database from './commands/database';
|
||||
import install from './commands/install';
|
||||
import packageJson from './package.json';
|
||||
import { cliConfig, ConfigKey } from './utilities';
|
||||
|
||||
void yargs(hideBin(process.argv))
|
||||
.version(false)
|
||||
.option('env', {
|
||||
alias: ['e', 'env-file'],
|
||||
describe: 'The path to your `.env` file',
|
||||
type: 'string',
|
||||
})
|
||||
.option('databaseUrl', {
|
||||
alias: ['db-url'],
|
||||
.option('db', {
|
||||
alias: ['db-url', 'database-url'],
|
||||
describe: 'The Postgres URL to Logto database',
|
||||
type: 'string',
|
||||
})
|
||||
.middleware(({ env, databaseUrl }) => {
|
||||
.option('version', {
|
||||
alias: 'v',
|
||||
describe: 'Print Logto CLI version',
|
||||
type: 'boolean',
|
||||
global: false,
|
||||
})
|
||||
.middleware(({ version }) => {
|
||||
if (version) {
|
||||
console.log(packageJson.name + ' v' + packageJson.version);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
}
|
||||
}, true)
|
||||
.middleware(({ env, db: databaseUrl }) => {
|
||||
dotenv.config({ path: env });
|
||||
|
||||
const initialDatabaseUrl = databaseUrl ?? process.env[ConfigKey.DatabaseUrl];
|
||||
|
|
1
packages/cli/src/package.json
Symbolic link
1
packages/cli/src/package.json
Symbolic link
|
@ -0,0 +1 @@
|
|||
../package.json
|
|
@ -152,3 +152,30 @@ export const getCliConfigWithPrompt = async ({
|
|||
|
||||
return input;
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/a/53187807/12514940
|
||||
/**
|
||||
* Returns the index of the last element in the array where predicate is true, and -1
|
||||
* otherwise.
|
||||
* @param array The source array to search in
|
||||
* @param predicate find calls predicate once for each element of the array, in descending
|
||||
* order, until it finds one where predicate returns true. If such an element is found,
|
||||
* findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
|
||||
*/
|
||||
export function findLastIndex<T>(
|
||||
array: readonly T[],
|
||||
predicate: (value: T, index: number, object: readonly T[]) => boolean
|
||||
): number {
|
||||
// eslint-disable-next-line @silverhand/fp/no-let
|
||||
let { length } = array;
|
||||
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
while (length--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (predicate(array[length]!, length, array)) {
|
||||
return length;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue