Extension Improvements (#16822)

* add link command and small improvements

* put local bundles into own folder on link

* get rid of packs and add bundle support for local extensions

* make bundle type extensions work locally and remove traces of pack

* fix hot reloading of bundles

* fix app.js not refreshing

* fixed linter errors

* add endpoint to install extensions

* update package.json validation and support top level extensions

* update endpoints

* added some URL escapes and ran linter

* remove installation part

* readd endpoint

* update dependencies

* fix types and validation in extension-sdk

* run linter

* fix linter

* add defaults to manifest

* Added missing constant export

* ensure all the extension folders

* ignore unneeded vite error

* update linking process

* run parser separate

* add await

* fixed linter errors

Co-authored-by: Brainslug <tim@brainslug.nl>
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
This commit is contained in:
Nitwel
2023-01-04 15:20:33 +01:00
committed by GitHub
parent 9f06c36e0d
commit 2ac022d286
25 changed files with 382 additions and 607 deletions

View File

@@ -2,13 +2,13 @@ import { Router } from 'express';
import asyncHandler from '../utils/async-handler';
import { RouteNotFoundException } from '../exceptions';
import { getExtensionManager } from '../extensions';
import { respond } from '../middleware/respond';
import { depluralize, isIn } from '@directus/shared/utils';
import { Plural } from '@directus/shared/types';
import { APP_OR_HYBRID_EXTENSION_TYPES } from '@directus/shared/constants';
import ms from 'ms';
import env from '../env';
import { getCacheControlHeader } from '../utils/get-cache-headers';
import { respond } from '../middleware/respond';
import { depluralize, isIn } from '@directus/shared/utils';
import { Plural } from '@directus/shared/types';
import { EXTENSION_TYPES } from '@directus/shared/constants';
const router = Router();
@@ -17,7 +17,7 @@ router.get(
asyncHandler(async (req, res, next) => {
const type = depluralize(req.params.type as Plural<string>);
if (!isIn(type, APP_OR_HYBRID_EXTENSION_TYPES)) {
if (!isIn(type, EXTENSION_TYPES)) {
throw new RouteNotFoundException(req.path);
}

View File

@@ -1,10 +1,8 @@
import {
API_OR_HYBRID_EXTENSION_PACKAGE_TYPES,
API_OR_HYBRID_EXTENSION_TYPES,
APP_EXTENSION_TYPES,
APP_SHARED_DEPS,
EXTENSION_PACKAGE_TYPES,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
NESTED_EXTENSION_TYPES,
} from '@directus/shared/constants';
import * as sharedExceptions from '@directus/shared/exceptions';
import {
@@ -13,6 +11,7 @@ import {
BundleExtension,
EndpointConfig,
Extension,
ExtensionInfo,
ExtensionType,
FilterHandler,
HookConfig,
@@ -29,6 +28,7 @@ import {
getPackageExtensions,
pathToRelativeUrl,
resolvePackage,
resolvePackageExtensions,
} from '@directus/shared/utils/node';
import express, { Router } from 'express';
import fse from 'fs-extra';
@@ -133,7 +133,7 @@ class ExtensionManager {
const loadedExtensions = this.getExtensionsList();
if (loadedExtensions.length > 0) {
logger.info(`Loaded extensions: ${loadedExtensions.join(', ')}`);
logger.info(`Loaded extensions: ${loadedExtensions.map((ext) => ext.name).join(', ')}`);
}
}
@@ -175,12 +175,38 @@ class ExtensionManager {
});
}
public getExtensionsList(type?: ExtensionType): string[] {
public getExtensionsList(type?: ExtensionType) {
if (type === undefined) {
return this.extensions.map((extension) => extension.name);
return this.extensions.map(mapInfo);
} else {
return this.extensions.filter((extension) => extension.type === type).map((extension) => extension.name);
return this.extensions.map(mapInfo).filter((extension) => extension.type === type);
}
function mapInfo(extension: Extension): ExtensionInfo {
const extensionInfo = {
name: extension.name,
type: extension.type,
local: extension.local,
host: extension.host,
version: extension.version,
};
if (extension.type === 'bundle') {
return {
...extensionInfo,
entries: extension.entries.map((entry) => ({
name: entry.name,
type: entry.type,
})),
};
} else {
return extensionInfo as ExtensionInfo;
}
}
}
public getExtension(name: string): Extension | undefined {
return this.extensions.find((extension) => extension.name === name);
}
public getAppExtensions(): string | null {
@@ -205,7 +231,7 @@ class ExtensionManager {
private async load(): Promise<void> {
try {
await ensureExtensionDirs(env.EXTENSIONS_PATH, env.SERVE_APP ? EXTENSION_TYPES : API_OR_HYBRID_EXTENSION_TYPES);
await ensureExtensionDirs(env.EXTENSIONS_PATH, NESTED_EXTENSION_TYPES);
this.extensions = await this.getExtensions();
} catch (err: any) {
@@ -241,12 +267,14 @@ class ExtensionManager {
if (!this.watcher) {
logger.info('Watching extensions for changes...');
const localExtensionPaths = (env.SERVE_APP ? EXTENSION_TYPES : API_OR_HYBRID_EXTENSION_TYPES).flatMap((type) => {
const localExtensionPaths = NESTED_EXTENSION_TYPES.flatMap((type) => {
const typeDir = path.posix.join(pathToRelativeUrl(env.EXTENSIONS_PATH), pluralize(type));
return isIn(type, HYBRID_EXTENSION_TYPES)
? [path.posix.join(typeDir, '*', 'app.js'), path.posix.join(typeDir, '*', 'api.js')]
: path.posix.join(typeDir, '*', 'index.js');
if (isIn(type, HYBRID_EXTENSION_TYPES)) {
return [path.posix.join(typeDir, '*', 'app.js'), path.posix.join(typeDir, '*', 'api.js')];
} else {
return path.posix.join(typeDir, '*', 'index.js');
}
});
this.watcher = chokidar.watch([path.resolve('package.json'), ...localExtensionPaths], {
@@ -272,9 +300,7 @@ class ExtensionManager {
extensions
.filter((extension) => !extension.local)
.flatMap((extension) =>
extension.type === 'pack'
? path.resolve(extension.path, 'package.json')
: isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
? [
path.resolve(extension.path, extension.entrypoint.app),
path.resolve(extension.path, extension.entrypoint.api),
@@ -291,16 +317,13 @@ class ExtensionManager {
}
private async getExtensions(): Promise<Extension[]> {
const packageExtensions = await getPackageExtensions(
'.',
env.SERVE_APP ? EXTENSION_PACKAGE_TYPES : API_OR_HYBRID_EXTENSION_PACKAGE_TYPES
);
const localExtensions = await getLocalExtensions(
env.EXTENSIONS_PATH,
env.SERVE_APP ? EXTENSION_TYPES : API_OR_HYBRID_EXTENSION_TYPES
);
const packageExtensions = await getPackageExtensions('.');
const localPackageExtensions = await resolvePackageExtensions(env.EXTENSIONS_PATH);
const localExtensions = await getLocalExtensions(env.EXTENSIONS_PATH);
return [...packageExtensions, ...localExtensions];
return [...packageExtensions, ...localPackageExtensions, ...localExtensions].filter(
(extension) => env.SERVE_APP || APP_EXTENSION_TYPES.includes(extension.type as any) === false
);
}
private async generateExtensionBundle(): Promise<string | null> {

View File

@@ -27,7 +27,7 @@ const onDehydrateCallbacks: (() => Promise<void>)[] = [];
export async function loadExtensions(): Promise<void> {
try {
customExtensions = import.meta.env.DEV
? await import('@directus-extensions')
? await import(/* @vite-ignore */ '@directus-extensions')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/sources/index.js`);
} catch (err: any) {
// eslint-disable-next-line no-console

View File

@@ -2,6 +2,7 @@ import {
APP_OR_HYBRID_EXTENSION_PACKAGE_TYPES,
APP_OR_HYBRID_EXTENSION_TYPES,
APP_SHARED_DEPS,
NESTED_EXTENSION_TYPES,
} from '@directus/shared/constants';
import {
ensureExtensionDirs,
@@ -220,7 +221,7 @@ function directusExtensions() {
];
async function loadExtensions() {
await ensureExtensionDirs(EXTENSIONS_PATH, APP_OR_HYBRID_EXTENSION_TYPES);
await ensureExtensionDirs(EXTENSIONS_PATH, NESTED_EXTENSION_TYPES);
const packageExtensions = await getPackageExtensions(API_PATH, APP_OR_HYBRID_EXTENSION_PACKAGE_TYPES);
const localExtensions = await getLocalExtensions(EXTENSIONS_PATH, APP_OR_HYBRID_EXTENSION_TYPES);

View File

@@ -4,12 +4,12 @@ import fse from 'fs-extra';
import inquirer from 'inquirer';
import { log } from '../utils/logger';
import {
ExtensionManifestRaw,
ExtensionManifest,
ExtensionOptions,
ExtensionOptionsBundleEntry,
ExtensionType,
NestedExtensionType,
} from '@directus/shared/types';
import { isIn, isTypeIn, validateExtensionManifest } from '@directus/shared/utils';
import { isIn, isTypeIn } from '@directus/shared/utils';
import { pathToRelativeUrl } from '@directus/shared/utils/node';
import {
EXTENSION_LANGUAGES,
@@ -36,28 +36,25 @@ export default async function add(): Promise<void> {
process.exit(1);
}
const extensionManifestFile = await fse.readFile(packagePath, 'utf8');
const extensionManifest: ExtensionManifestRaw = JSON.parse(extensionManifestFile);
let extensionManifest: ExtensionManifest;
let indent: string | null = null;
const indent = detectJsonIndent(extensionManifestFile);
if (!validateExtensionManifest(extensionManifest)) {
try {
const extensionManifestFile = await fse.readFile(packagePath, 'utf8');
extensionManifest = ExtensionManifest.parse(JSON.parse(extensionManifestFile));
indent = detectJsonIndent(extensionManifestFile);
} catch (e) {
log(`Current directory is not a valid Directus extension.`, 'error');
process.exit(1);
}
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
if (extensionOptions.type === 'pack') {
log(`Adding entries to extensions with type ${chalk.bold('pack')} is not currently supported.`, 'error');
process.exit(1);
}
const sourceExists = await fse.pathExists(path.resolve('src'));
if (extensionOptions.type === 'bundle') {
const { type, name, language, alternativeSource } = await inquirer.prompt<{
type: ExtensionType;
type: NestedExtensionType;
name: string;
language: Language;
alternativeSource?: string;
@@ -149,7 +146,7 @@ export default async function add(): Promise<void> {
const oldName = extensionManifest.name.match(EXTENSION_NAME_REGEX)?.[1] ?? extensionManifest.name;
const { type, name, language, convertName, extensionName, alternativeSource } = await inquirer.prompt<{
type: ExtensionType;
type: NestedExtensionType;
name: string;
language: Language;
convertName: string;

View File

@@ -2,17 +2,18 @@ import {
API_SHARED_DEPS,
APP_EXTENSION_TYPES,
APP_SHARED_DEPS,
EXTENSION_PACKAGE_TYPES,
EXTENSION_PKG_KEY,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
} from '@directus/shared/constants';
import {
ApiExtensionType,
AppExtensionType,
ExtensionManifestRaw,
ExtensionManifest,
ExtensionOptionsBundleEntries,
ExtensionOptionsBundleEntry,
} from '@directus/shared/types';
import { isIn, isTypeIn, validateExtensionManifest } from '@directus/shared/utils';
import { isIn, isTypeIn } from '@directus/shared/utils';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import { nodeResolve } from '@rollup/plugin-node-resolve';
@@ -40,7 +41,7 @@ import { clear, log } from '../utils/logger';
import tryParseJson from '../utils/try-parse-json';
import generateBundleEntrypoint from './helpers/generate-bundle-entrypoint';
import loadConfig from './helpers/load-config';
import { validateBundleEntriesOption, validateSplitEntrypointOption } from './helpers/validate-cli-options';
import { validateSplitEntrypointOption } from './helpers/validate-cli-options';
type BuildOptions = {
type?: string;
@@ -64,20 +65,17 @@ export default async function build(options: BuildOptions): Promise<void> {
process.exit(1);
}
const extensionManifest: ExtensionManifestRaw = await fse.readJSON(packagePath);
let extensionManifest: ExtensionManifest;
if (!validateExtensionManifest(extensionManifest)) {
try {
extensionManifest = ExtensionManifest.parse(await fse.readJSON(packagePath));
} catch (err) {
log(`Current directory is not a valid Directus extension.`, 'error');
process.exit(1);
}
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
if (extensionOptions.type === 'pack') {
log(`Building extension type ${chalk.bold('pack')} is not currently supported.`, 'error');
process.exit(1);
}
if (extensionOptions.type === 'bundle') {
await buildBundleExtension({
entries: extensionOptions.entries,
@@ -117,21 +115,16 @@ export default async function build(options: BuildOptions): Promise<void> {
process.exit(1);
}
if (!isIn(type, EXTENSION_PACKAGE_TYPES)) {
if (!isIn(type, EXTENSION_TYPES)) {
log(
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_PACKAGE_TYPES.map(
(t) => chalk.bold.magenta(t)
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) =>
chalk.bold.magenta(t)
).join(', ')}.`,
'error'
);
process.exit(1);
}
if (type === 'pack') {
log(`Building extension type ${chalk.bold('pack')} is not currently supported.`, 'error');
process.exit(1);
}
if (!input) {
log(`Extension entrypoint has to be specified using the ${chalk.blue('[-i, --input <file>]')} option.`, 'error');
process.exit(1);
@@ -145,10 +138,10 @@ export default async function build(options: BuildOptions): Promise<void> {
}
if (type === 'bundle') {
const entries = tryParseJson(input);
const entries = ExtensionOptionsBundleEntries.safeParse(tryParseJson(input));
const splitOutput = tryParseJson(output);
if (!validateBundleEntriesOption(entries)) {
if (entries.success === false) {
log(
`Input option needs to be of the format ${chalk.blue(
`[-i '[{"type":"<extension-type>","name":"<extension-name>","source":<entrypoint>}]']`
@@ -168,7 +161,7 @@ export default async function build(options: BuildOptions): Promise<void> {
}
await buildBundleExtension({
entries,
entries: entries.data,
outputApp: splitOutput.app,
outputApi: splitOutput.api,
watch,

View File

@@ -8,11 +8,18 @@ import {
EXTENSION_LANGUAGES,
HYBRID_EXTENSION_TYPES,
EXTENSION_NAME_REGEX,
EXTENSION_PACKAGE_TYPES,
PACKAGE_EXTENSION_TYPES,
EXTENSION_TYPES,
BUNDLE_EXTENSION_TYPES,
} from '@directus/shared/constants';
import { isIn } from '@directus/shared/utils';
import { ExtensionOptions, ExtensionPackageType, ExtensionType, PackageExtensionType } from '@directus/shared/types';
import {
ApiExtensionType,
AppExtensionType,
BundleExtensionType,
ExtensionOptions,
ExtensionType,
HybridExtensionType,
} from '@directus/shared/types';
import { log } from '../utils/logger';
import { isLanguage, languageToShort } from '../utils/languages';
import getSdkVersion from '../utils/get-sdk-version';
@@ -26,10 +33,10 @@ export default async function create(type: string, name: string, options: Create
const targetDir = name.substring(name.lastIndexOf('/') + 1);
const targetPath = path.resolve(targetDir);
if (!isIn(type, EXTENSION_PACKAGE_TYPES)) {
if (!isIn(type, EXTENSION_TYPES)) {
log(
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_PACKAGE_TYPES.map(
(t) => chalk.bold.magenta(t)
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) =>
chalk.bold.magenta(t)
).join(', ')}.`,
'error'
);
@@ -57,7 +64,7 @@ export default async function create(type: string, name: string, options: Create
}
}
if (isIn(type, PACKAGE_EXTENSION_TYPES)) {
if (isIn(type, BUNDLE_EXTENSION_TYPES)) {
await createPackageExtension({ type, name, targetDir, targetPath });
} else {
const language = options.language ?? 'javascript';
@@ -72,7 +79,7 @@ async function createPackageExtension({
targetDir,
targetPath,
}: {
type: PackageExtensionType;
type: BundleExtensionType;
name: string;
targetDir: string;
targetPath: string;
@@ -83,8 +90,7 @@ async function createPackageExtension({
await copyTemplate(type, targetPath);
const host = `^${getSdkVersion()}`;
const options: ExtensionOptions =
type === 'bundle' ? { type, path: { app: 'dist/app.js', api: 'dist/api.js' }, entries: [], host } : { type, host };
const options = { type, path: { app: 'dist/app.js', api: 'dist/api.js' }, entries: [], host };
const packageManifest = getPackageManifest(name, options, await getExtensionDevDeps(type));
await fse.writeJSON(path.join(targetPath, 'package.json'), packageManifest, { spaces: '\t' });
@@ -105,7 +111,7 @@ async function createLocalExtension({
targetPath,
language,
}: {
type: ExtensionType;
type: AppExtensionType | ApiExtensionType | HybridExtensionType;
name: string;
targetDir: string;
targetPath: string;
@@ -154,20 +160,29 @@ async function createLocalExtension({
}
function getPackageManifest(name: string, options: ExtensionOptions, deps: Record<string, string>) {
return {
const packageManifest: Record<string, any> = {
name: EXTENSION_NAME_REGEX.test(name) ? name : `directus-extension-${name}`,
description: 'Please enter a description for your extension',
icon: 'extension',
version: '1.0.0',
keywords: ['directus', 'directus-extension', `directus-custom-${options.type}`],
[EXTENSION_PKG_KEY]: options,
scripts: {
build: 'directus-extension build',
dev: 'directus-extension build -w --no-minify',
link: 'directus-extension link',
},
devDependencies: deps,
};
if (options.type === 'bundle') {
packageManifest.scripts['add'] = 'directus-extension add';
}
return packageManifest;
}
function getDoneMessage(type: ExtensionPackageType, targetDir: string, targetPath: string, packageManager: string) {
function getDoneMessage(type: ExtensionType, targetDir: string, targetPath: string, packageManager: string) {
return `
Your ${type} extension has been created at ${chalk.green(targetPath)}

View File

@@ -1,7 +1,7 @@
import path from 'path';
import fse from 'fs-extra';
import getTemplatePath from '../../utils/get-template-path';
import { ExtensionPackageType } from '@directus/shared/types';
import { ExtensionType } from '@directus/shared/types';
import { Language } from '../../types';
type TemplateFile = { type: 'config' | 'source'; path: string };
@@ -49,7 +49,7 @@ async function getTypeTemplateFiles(templateTypePath: string, language?: Languag
return [...commonTemplateFiles, ...(languageTemplateFiles ? languageTemplateFiles : [])];
}
async function getTemplateFiles(type: ExtensionPackageType, language?: Language): Promise<TemplateFile[]> {
async function getTemplateFiles(type: ExtensionType, language?: Language): Promise<TemplateFile[]> {
const templatePath = getTemplatePath();
const [commonTemplateFiles, typeTemplateFiles] = await Promise.all([
@@ -61,7 +61,7 @@ async function getTemplateFiles(type: ExtensionPackageType, language?: Language)
}
export default async function copyTemplate(
type: ExtensionPackageType,
type: ExtensionType,
extensionPath: string,
sourcePath?: string,
language?: Language

View File

@@ -1,15 +1,11 @@
import path from 'path';
import {
API_OR_HYBRID_EXTENSION_TYPES,
APP_OR_HYBRID_EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
} from '@directus/shared/constants';
import { API_EXTENSION_TYPES, APP_EXTENSION_TYPES, HYBRID_EXTENSION_TYPES } from '@directus/shared/constants';
import { ExtensionOptionsBundleEntry } from '@directus/shared/types';
import { isIn, isTypeIn, pluralize } from '@directus/shared/utils';
import { pathToRelativeUrl } from '@directus/shared/utils/node';
export default function generateBundleEntrypoint(mode: 'app' | 'api', entries: ExtensionOptionsBundleEntry[]): string {
const types = mode === 'app' ? APP_OR_HYBRID_EXTENSION_TYPES : API_OR_HYBRID_EXTENSION_TYPES;
const types = [...(mode === 'app' ? APP_EXTENSION_TYPES : API_EXTENSION_TYPES), ...HYBRID_EXTENSION_TYPES];
const entriesForTypes = entries.filter((entry) => isIn(entry.type, types));

View File

@@ -1,12 +1,12 @@
import { API_OR_HYBRID_EXTENSION_TYPES, APP_OR_HYBRID_EXTENSION_TYPES } from '@directus/shared/constants';
import { ExtensionPackageType } from '@directus/shared/types';
import { APP_EXTENSION_TYPES, API_EXTENSION_TYPES, HYBRID_EXTENSION_TYPES } from '@directus/shared/constants';
import { ExtensionType } from '@directus/shared/types';
import { isIn } from '@directus/shared/utils';
import { Language } from '../../types';
import getPackageVersion from '../../utils/get-package-version';
import getSdkVersion from '../../utils/get-sdk-version';
export default async function getExtensionDevDeps(
type: ExtensionPackageType | ExtensionPackageType[],
type: ExtensionType | ExtensionType[],
language: Language | Language[] = []
): Promise<Record<string, string>> {
const types = Array.isArray(type) ? type : [type];
@@ -17,14 +17,14 @@ export default async function getExtensionDevDeps(
};
if (languages.includes('typescript')) {
if (types.some((type) => isIn(type, API_OR_HYBRID_EXTENSION_TYPES))) {
if (types.some((type) => isIn(type, [...API_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES]))) {
deps['@types/node'] = `^${await getPackageVersion('@types/node')}`;
}
deps['typescript'] = `^${await getPackageVersion('typescript')}`;
}
if (types.some((type) => isIn(type, APP_OR_HYBRID_EXTENSION_TYPES))) {
if (types.some((type) => isIn(type, [...APP_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES]))) {
deps['vue'] = `^${await getPackageVersion('vue')}`;
}

View File

@@ -1,5 +1,4 @@
import { ExtensionOptionsBundleEntry, JsonValue, SplitEntrypoint } from '@directus/shared/types';
import { validateExtensionOptionsBundleEntry } from '@directus/shared/utils';
import { JsonValue, SplitEntrypoint } from '@directus/shared/types';
function validateNonPrimitive(value: JsonValue | undefined): value is JsonValue[] | { [key: string]: JsonValue } {
if (
@@ -26,23 +25,3 @@ export function validateSplitEntrypointOption(option: JsonValue | undefined): op
return true;
}
export function validateBundleEntriesOption(option: JsonValue | undefined): option is ExtensionOptionsBundleEntry[] {
if (!validateNonPrimitive(option) || !Array.isArray(option)) {
return false;
}
if (
!option.every((entry) => {
if (!validateNonPrimitive(entry) || Array.isArray(entry)) {
return false;
}
return validateExtensionOptionsBundleEntry(entry);
})
) {
return false;
}
return true;
}

View File

@@ -0,0 +1,61 @@
import path from 'path';
import fs from 'fs-extra';
import { log } from '../utils/logger';
import { ExtensionManifest } from '@directus/shared/types';
export default async function link(extensionsPath: string): Promise<void> {
const extensionPath = process.cwd();
const absoluteExtensionsPath = path.resolve(extensionsPath);
if (!fs.existsSync(absoluteExtensionsPath)) {
log(`Extensions folder does not exist at ${absoluteExtensionsPath}`, 'error');
return;
}
const packagePath = path.resolve('package.json');
if (!(await fs.pathExists(packagePath))) {
log(`Current directory is not a valid package.`, 'error');
return;
}
let manifestFile: Record<string, any>;
try {
manifestFile = await fs.readJSON(packagePath);
} catch (err) {
log(`Current directory is not a valid Directus extension.`, 'error');
return;
}
const extensionManifest = ExtensionManifest.parse(manifestFile);
const extensionName = extensionManifest.name;
if (!extensionName) {
log(`Extension name not found in package.json`, 'error');
return;
}
const type = extensionManifest['directus:extension']?.type;
if (!type) {
log(`Extension type not found in package.json`, 'error');
return;
}
const extensionTarget = path.join(absoluteExtensionsPath, extensionName);
try {
fs.ensureSymlinkSync(extensionPath, extensionTarget);
} catch (error: any) {
log(error.message, 'error');
log(`Try running this command with administrator privileges`, 'info');
return;
}
log(`Linked ${extensionName} to ${extensionTarget}`);
return;
}

View File

@@ -2,6 +2,7 @@ import { Command } from 'commander';
import create from './commands/create';
import add from './commands/add';
import build from './commands/build';
import link from './commands/link';
const pkg = require('../../../package.json');
@@ -32,4 +33,10 @@ program
.option('--sourcemap', 'include source maps in output')
.action(build);
program
.command('link')
.description('Creates a symlink to the extension in the Directus extensions folder')
.argument('<path>', 'path to the extension folder of directus')
.action(link);
program.parse(process.argv);

View File

@@ -78,7 +78,8 @@
"pino": "8.8.0",
"vue": "3.2.45",
"vue-i18n": "9.2.2",
"vue-router": "4.1.6"
"vue-router": "4.1.6",
"zod": "^3.20.2"
},
"devDependencies": {
"@types/express": "4.17.15",

View File

@@ -4,20 +4,22 @@ export const API_SHARED_DEPS = ['directus'];
export const APP_EXTENSION_TYPES = ['interface', 'display', 'layout', 'module', 'panel'] as const;
export const API_EXTENSION_TYPES = ['hook', 'endpoint'] as const;
export const HYBRID_EXTENSION_TYPES = ['operation'] as const;
export const EXTENSION_TYPES = [...APP_EXTENSION_TYPES, ...API_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES] as const;
export const PACKAGE_EXTENSION_TYPES = ['pack', 'bundle'] as const;
export const EXTENSION_PACKAGE_TYPES = [...EXTENSION_TYPES, ...PACKAGE_EXTENSION_TYPES] as const;
export const BUNDLE_EXTENSION_TYPES = ['bundle'] as const;
export const EXTENSION_TYPES = [
...APP_EXTENSION_TYPES,
...API_EXTENSION_TYPES,
...HYBRID_EXTENSION_TYPES,
...BUNDLE_EXTENSION_TYPES,
] as const;
export const NESTED_EXTENSION_TYPES = [
...APP_EXTENSION_TYPES,
...API_EXTENSION_TYPES,
...HYBRID_EXTENSION_TYPES,
] as const;
export const APP_OR_HYBRID_EXTENSION_TYPES = [...APP_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES] as const;
export const API_OR_HYBRID_EXTENSION_TYPES = [...API_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES] as const;
export const APP_OR_HYBRID_EXTENSION_PACKAGE_TYPES = [
...APP_OR_HYBRID_EXTENSION_TYPES,
...PACKAGE_EXTENSION_TYPES,
] as const;
export const API_OR_HYBRID_EXTENSION_PACKAGE_TYPES = [
...API_OR_HYBRID_EXTENSION_TYPES,
...PACKAGE_EXTENSION_TYPES,
...BUNDLE_EXTENSION_TYPES,
] as const;
export const EXTENSION_LANGUAGES = ['javascript', 'typescript'] as const;

View File

@@ -3,12 +3,12 @@ import { Logger } from 'pino';
import {
API_EXTENSION_TYPES,
APP_EXTENSION_TYPES,
EXTENSION_PACKAGE_TYPES,
BUNDLE_EXTENSION_TYPES,
EXTENSION_PKG_KEY,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
LOCAL_TYPES,
PACKAGE_EXTENSION_TYPES,
NESTED_EXTENSION_TYPES,
} from '../constants';
import { Accountability } from './accountability';
import { InterfaceConfig } from './interfaces';
@@ -22,137 +22,107 @@ import { Relation } from './relations';
import { Collection } from './collection';
import { SchemaOverview } from './schema';
import { OperationAppConfig } from './operations';
import { z } from 'zod';
export type AppExtensionType = typeof APP_EXTENSION_TYPES[number];
export type ApiExtensionType = typeof API_EXTENSION_TYPES[number];
export type HybridExtensionType = typeof HYBRID_EXTENSION_TYPES[number];
export type BundleExtensionType = typeof BUNDLE_EXTENSION_TYPES[number];
export type ExtensionType = typeof EXTENSION_TYPES[number];
export type NestedExtensionType = typeof NESTED_EXTENSION_TYPES[number];
export type PackageExtensionType = typeof PACKAGE_EXTENSION_TYPES[number];
export type ExtensionPackageType = typeof EXTENSION_PACKAGE_TYPES[number];
const SplitEntrypoint = z.object({
app: z.string(),
api: z.string(),
});
export type SplitEntrypoint = { app: string; api: string };
export type SplitEntrypoint = z.infer<typeof SplitEntrypoint>;
type ExtensionBase = {
path: string;
name: string;
version?: string;
host?: string;
local: boolean;
};
type AppExtensionBase = {
export type AppExtension = ExtensionBase & {
type: AppExtensionType;
entrypoint: string;
};
type ApiExtensionBase = {
export type ApiExtension = ExtensionBase & {
type: ApiExtensionType;
entrypoint: string;
};
type HybridExtensionBase = {
export type HybridExtension = ExtensionBase & {
type: HybridExtensionType;
entrypoint: SplitEntrypoint;
};
type PackExtensionBase = {
type: 'pack';
children: string[];
};
type BundleExtensionBase = {
export type BundleExtension = ExtensionBase & {
type: 'bundle';
entrypoint: SplitEntrypoint;
entries: { type: ExtensionType; name: string }[];
entries: { type: NestedExtensionType; name: string }[];
};
type PackageExtensionBase = PackExtensionBase | BundleExtensionBase;
export type Extension = AppExtension | ApiExtension | HybridExtension | BundleExtension;
type ExtensionLocalBase = ExtensionBase & {
local: true;
};
export const ExtensionOptionsBundleEntry = z.union([
z.object({
type: z.union([z.enum(APP_EXTENSION_TYPES), z.enum(API_EXTENSION_TYPES)]),
name: z.string(),
source: z.string(),
}),
z.object({
type: z.enum(HYBRID_EXTENSION_TYPES),
name: z.string(),
source: SplitEntrypoint,
}),
]);
type ExtensionPackageBase = ExtensionBase & {
version: string;
host: string;
local: false;
};
const ExtensionOptionsBase = z.object({
host: z.string(),
hidden: z.boolean().optional(),
});
export type ExtensionLocal = ExtensionLocalBase & (AppExtensionBase | ApiExtensionBase | HybridExtensionBase);
export type ExtensionPackage = ExtensionPackageBase &
(AppExtensionBase | ApiExtensionBase | HybridExtensionBase | PackageExtensionBase);
const ExtensionOptionsAppOrApi = z.object({
type: z.union([z.enum(APP_EXTENSION_TYPES), z.enum(API_EXTENSION_TYPES)]),
path: z.string(),
source: z.string(),
});
export type AppExtension = AppExtensionBase & (ExtensionLocalBase | ExtensionPackageBase);
export type ApiExtension = ApiExtensionBase & (ExtensionLocalBase | ExtensionPackageBase);
export type HybridExtension = HybridExtensionBase & (ExtensionLocalBase | ExtensionPackageBase);
const ExtensionOptionsHybrid = z.object({
type: z.enum(HYBRID_EXTENSION_TYPES),
path: SplitEntrypoint,
source: SplitEntrypoint,
});
export type PackExtension = PackExtensionBase & ExtensionPackageBase;
export type BundleExtension = BundleExtensionBase & ExtensionPackageBase;
const ExtensionOptionsBundle = z.object({
type: z.literal('bundle'),
path: SplitEntrypoint,
entries: z.array(ExtensionOptionsBundleEntry),
});
export type Extension = ExtensionLocal | ExtensionPackage;
const ExtensionOptions = ExtensionOptionsBase.and(
z.union([ExtensionOptionsAppOrApi, ExtensionOptionsHybrid, ExtensionOptionsBundle])
);
export type ExtensionOptionsBundleEntryRaw = {
type?: string;
name?: string;
source?: string | Partial<SplitEntrypoint>;
};
export type ExtensionOptions = z.infer<typeof ExtensionOptions>;
export type ExtensionOptionsBundleEntry = z.infer<typeof ExtensionOptionsBundleEntry>;
export type ExtensionManifestRaw = {
name?: string;
version?: string;
dependencies?: Record<string, string>;
export const ExtensionOptionsBundleEntries = z.array(ExtensionOptionsBundleEntry);
export type ExtensionOptionsBundleEntries = z.infer<typeof ExtensionOptionsBundleEntries>;
[EXTENSION_PKG_KEY]?: {
type?: string;
path?: string | Partial<SplitEntrypoint>;
source?: string | Partial<SplitEntrypoint>;
entries?: ExtensionOptionsBundleEntryRaw[];
host?: string;
hidden?: boolean;
};
};
export const ExtensionManifest = z.object({
name: z.string(),
version: z.string(),
dependencies: z.record(z.string()).optional(),
[EXTENSION_PKG_KEY]: ExtensionOptions,
});
export type ExtensionOptionsBundleEntry =
| { type: AppExtensionType | ApiExtensionType; name: string; source: string }
| { type: HybridExtensionType; name: string; source: SplitEntrypoint };
type ExtensionOptionsBase = {
host: string;
hidden?: boolean;
};
type ExtensionOptionsAppOrApi = {
type: AppExtensionType | ApiExtensionType;
path: string;
source: string;
};
type ExtensionOptionsHybrid = {
type: HybridExtensionType;
path: SplitEntrypoint;
source: SplitEntrypoint;
};
type ExtensionOptionsPack = {
type: 'pack';
};
type ExtensionOptionsBundle = {
type: 'bundle';
path: SplitEntrypoint;
entries: ExtensionOptionsBundleEntry[];
};
type ExtensionOptionsPackage = ExtensionOptionsPack | ExtensionOptionsBundle;
export type ExtensionOptions = ExtensionOptionsBase &
(ExtensionOptionsAppOrApi | ExtensionOptionsHybrid | ExtensionOptionsPackage);
export type ExtensionManifest = {
name: string;
version: string;
dependencies?: Record<string, string>;
[EXTENSION_PKG_KEY]: ExtensionOptions;
};
export type ExtensionManifest = z.infer<typeof ExtensionManifest>;
export type AppExtensionConfigs = {
interfaces: InterfaceConfig[];
@@ -198,3 +168,9 @@ export type ExtensionOptionsContext = {
autoGenerateJunctionRelation: boolean;
saving: boolean;
};
export type ExtensionInfo =
| Omit<AppExtension, 'entrypoint' | 'path'>
| Omit<ApiExtension, 'entrypoint' | 'path'>
| Omit<HybridExtension, 'entrypoint' | 'path'>
| Omit<BundleExtension, 'entrypoint' | 'path'>;

View File

@@ -24,5 +24,4 @@ export * from './parse-filter';
export * from './parse-json';
export * from './pluralize';
export * from './to-array';
export * from './validate-extension-manifest';
export * from './validate-payload';

View File

@@ -1,7 +1,7 @@
import { describe, beforeEach, afterEach, it, expect } from 'vitest';
import { DirResult, dirSync } from 'tmp';
import { EXTENSION_TYPES } from '../../constants/extensions';
import { NESTED_EXTENSION_TYPES } from '../../constants/extensions';
import { ensureExtensionDirs } from './ensure-extension-dirs';
describe('ensureExtensionDirs', () => {
@@ -16,12 +16,12 @@ describe('ensureExtensionDirs', () => {
});
it('returns undefined if the folders exist', async () => {
expect(await ensureExtensionDirs(rootDir.name, EXTENSION_TYPES)).toBe(undefined);
expect(await ensureExtensionDirs(rootDir.name, NESTED_EXTENSION_TYPES)).toBe(undefined);
});
it('throws an error when a folder can not be opened', () => {
expect(async () => {
await ensureExtensionDirs('/.', EXTENSION_TYPES);
await ensureExtensionDirs('/.', NESTED_EXTENSION_TYPES);
}).rejects.toThrow(`Extension folder "/interfaces" couldn't be opened`);
});
});

View File

@@ -1,9 +1,12 @@
import path from 'path';
import fse from 'fs-extra';
import { pluralize } from '../pluralize';
import { ExtensionType } from '../../types';
import { NestedExtensionType } from '../../types';
export async function ensureExtensionDirs(extensionsPath: string, types: readonly ExtensionType[]): Promise<void> {
export async function ensureExtensionDirs(
extensionsPath: string,
types: readonly NestedExtensionType[]
): Promise<void> {
for (const extensionType of types) {
const dirPath = path.resolve(extensionsPath, pluralize(extensionType));
try {

View File

@@ -7,12 +7,13 @@ describe('generateExtensionsEntrypoint', () => {
it('returns an empty extension entrypoint if there is no App, Hybrid or Bundle extension', () => {
const mockExtensions: Extension[] = [
{
path: './extensions/pack',
name: 'mock-pack-extension',
type: 'pack',
path: './extensions/bundle',
name: 'mock-bundle0-extension',
version: '1.0.0',
type: 'bundle',
entrypoint: { app: 'app.js', api: 'api.js' },
entries: [],
host: '^9.0.0',
children: [],
local: false,
},
];
@@ -73,15 +74,6 @@ describe('generateExtensionsEntrypoint', () => {
it('returns an extension entrypoint exporting multiple extensions', () => {
const mockExtensions: Extension[] = [
{
path: './extensions/pack',
name: 'mock-pack-extension',
type: 'pack',
version: '1.0.0',
host: '^9.0.0',
children: [],
local: false,
},
{
path: './extensions/display',
name: 'mock-display-extension',

View File

@@ -1,5 +1,5 @@
import path from 'path';
import { APP_OR_HYBRID_EXTENSION_TYPES, HYBRID_EXTENSION_TYPES } from '../../constants';
import { HYBRID_EXTENSION_TYPES, APP_EXTENSION_TYPES } from '../../constants';
import { AppExtension, BundleExtension, Extension, HybridExtension } from '../../types';
import { isIn, isTypeIn } from '../array-helpers';
import { pluralize } from '../pluralize';
@@ -7,15 +7,16 @@ import { pathToRelativeUrl } from './path-to-relative-url';
export function generateExtensionsEntrypoint(extensions: Extension[]): string {
const appOrHybridExtensions = extensions.filter((extension): extension is AppExtension | HybridExtension =>
isIn(extension.type, APP_OR_HYBRID_EXTENSION_TYPES)
isIn(extension.type, [...APP_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES])
);
const bundleExtensions = extensions.filter(
(extension): extension is BundleExtension =>
extension.type === 'bundle' && extension.entries.some((entry) => isIn(entry.type, APP_OR_HYBRID_EXTENSION_TYPES))
extension.type === 'bundle' &&
extension.entries.some((entry) => isIn(entry.type, [...APP_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES]))
);
const appOrHybridExtensionImports = APP_OR_HYBRID_EXTENSION_TYPES.flatMap((type) =>
const appOrHybridExtensionImports = [...APP_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES].flatMap((type) =>
appOrHybridExtensions
.filter((extension) => extension.type === type)
.map(
@@ -31,12 +32,13 @@ export function generateExtensionsEntrypoint(extensions: Extension[]): string {
const bundleExtensionImports = bundleExtensions.map(
(extension, i) =>
`import {${APP_OR_HYBRID_EXTENSION_TYPES.filter((type) => extension.entries.some((entry) => entry.type === type))
`import {${[...APP_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES]
.filter((type) => extension.entries.some((entry) => entry.type === type))
.map((type) => `${pluralize(type)} as ${type}Bundle${i}`)
.join(',')}} from './${pathToRelativeUrl(path.resolve(extension.path, extension.entrypoint.app))}';`
);
const extensionExports = APP_OR_HYBRID_EXTENSION_TYPES.map(
const extensionExports = [...APP_EXTENSION_TYPES, ...HYBRID_EXTENSION_TYPES].map(
(type) =>
`export const ${pluralize(type)} = [${appOrHybridExtensions
.filter((extension) => extension.type === type)

View File

@@ -1,102 +1,85 @@
import path from 'path';
import fse from 'fs-extra';
import {
ExtensionLocal,
ExtensionManifestRaw,
ExtensionPackage,
ExtensionPackageType,
ExtensionType,
} from '../../types';
import { ApiExtensionType, AppExtensionType, Extension, ExtensionManifest } from '../../types';
import { resolvePackage } from './resolve-package';
import { listFolders } from './list-folders';
import { EXTENSION_NAME_REGEX, EXTENSION_PKG_KEY, HYBRID_EXTENSION_TYPES } from '../../constants';
import {
EXTENSION_NAME_REGEX,
EXTENSION_PKG_KEY,
HYBRID_EXTENSION_TYPES,
NESTED_EXTENSION_TYPES,
} from '../../constants';
import { pluralize } from '../pluralize';
import { validateExtensionManifest } from '../validate-extension-manifest';
import { isIn, isTypeIn } from '../array-helpers';
async function resolvePackageExtensions(
extensionNames: string[],
root: string,
types: readonly ExtensionPackageType[]
): Promise<ExtensionPackage[]> {
const extensions: ExtensionPackage[] = [];
export async function resolvePackageExtensions(root: string, extensionNames?: string[]): Promise<Extension[]> {
const extensions: Extension[] = [];
const local = extensionNames === undefined;
if (extensionNames === undefined) {
extensionNames = await listFolders(root);
extensionNames = extensionNames.filter((name) => EXTENSION_NAME_REGEX.test(name));
}
for (const extensionName of extensionNames) {
const extensionPath = resolvePackage(extensionName, root);
const extensionManifest: ExtensionManifestRaw = await fse.readJSON(path.join(extensionPath, 'package.json'));
const extensionPath = local ? path.join(root, extensionName) : resolvePackage(extensionName, root);
const extensionManifest: Record<string, any> = await fse.readJSON(path.join(extensionPath, 'package.json'));
if (!validateExtensionManifest(extensionManifest)) {
throw new Error(`The extension manifest of "${extensionName}" is not valid.`);
let parsedManifest;
try {
parsedManifest = ExtensionManifest.parse(extensionManifest);
} catch (error) {
throw new Error(`The extension manifest of "${extensionName}" is not valid.\n${error}`);
}
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
const extensionOptions = parsedManifest[EXTENSION_PKG_KEY];
if (isIn(extensionOptions.type, types)) {
if (extensionOptions.type === 'pack') {
const extensionChildren = Object.keys(extensionManifest.dependencies ?? {}).filter((dep) =>
EXTENSION_NAME_REGEX.test(dep)
);
const extension: ExtensionPackage = {
path: extensionPath,
name: extensionName,
version: extensionManifest.version,
type: extensionOptions.type,
host: extensionOptions.host,
children: extensionChildren,
local: false,
};
extensions.push(extension);
extensions.push(...(await resolvePackageExtensions(extension.children || [], extension.path, types)));
} else if (extensionOptions.type === 'bundle') {
extensions.push({
path: extensionPath,
name: extensionName,
version: extensionManifest.version,
type: extensionOptions.type,
entrypoint: {
app: extensionOptions.path.app,
api: extensionOptions.path.api,
},
entries: extensionOptions.entries,
host: extensionOptions.host,
local: false,
});
} else if (isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)) {
extensions.push({
path: extensionPath,
name: extensionName,
version: extensionManifest.version,
type: extensionOptions.type,
entrypoint: {
app: extensionOptions.path.app,
api: extensionOptions.path.api,
},
host: extensionOptions.host,
local: false,
});
} else {
extensions.push({
path: extensionPath,
name: extensionName,
version: extensionManifest.version,
type: extensionOptions.type,
entrypoint: extensionOptions.path,
host: extensionOptions.host,
local: false,
});
}
if (extensionOptions.type === 'bundle') {
extensions.push({
path: extensionPath,
name: parsedManifest.name,
version: parsedManifest.version,
type: extensionOptions.type,
entrypoint: {
app: extensionOptions.path.app,
api: extensionOptions.path.api,
},
entries: extensionOptions.entries,
host: extensionOptions.host,
local,
});
} else if (isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)) {
extensions.push({
path: extensionPath,
name: parsedManifest.name,
version: parsedManifest.version,
type: extensionOptions.type,
entrypoint: {
app: extensionOptions.path.app,
api: extensionOptions.path.api,
},
host: extensionOptions.host,
local,
});
} else {
extensions.push({
path: extensionPath,
name: parsedManifest.name,
version: parsedManifest.version,
type: extensionOptions.type,
entrypoint: extensionOptions.path,
host: extensionOptions.host,
local,
});
}
}
return extensions;
}
export async function getPackageExtensions(
root: string,
types: readonly ExtensionPackageType[]
): Promise<ExtensionPackage[]> {
export async function getPackageExtensions(root: string): Promise<Extension[]> {
let pkg: { dependencies?: Record<string, string> };
try {
@@ -107,13 +90,13 @@ export async function getPackageExtensions(
const extensionNames = Object.keys(pkg.dependencies ?? {}).filter((dep) => EXTENSION_NAME_REGEX.test(dep));
return resolvePackageExtensions(extensionNames, root, types);
return resolvePackageExtensions(root, extensionNames);
}
export async function getLocalExtensions(root: string, types: readonly ExtensionType[]): Promise<ExtensionLocal[]> {
const extensions: ExtensionLocal[] = [];
export async function getLocalExtensions(root: string): Promise<Extension[]> {
const extensions: Extension[] = [];
for (const extensionType of types) {
for (const extensionType of NESTED_EXTENSION_TYPES) {
const typeDir = pluralize(extensionType);
const typePath = path.resolve(root, typeDir);
@@ -123,15 +106,7 @@ export async function getLocalExtensions(root: string, types: readonly Extension
for (const extensionName of extensionNames) {
const extensionPath = path.join(typePath, extensionName);
if (!isIn(extensionType, HYBRID_EXTENSION_TYPES)) {
extensions.push({
path: extensionPath,
name: extensionName,
type: extensionType,
entrypoint: 'index.js',
local: true,
});
} else {
if (isIn(extensionType, HYBRID_EXTENSION_TYPES)) {
extensions.push({
path: extensionPath,
name: extensionName,
@@ -142,9 +117,17 @@ export async function getLocalExtensions(root: string, types: readonly Extension
},
local: true,
});
} else {
extensions.push({
path: extensionPath,
name: extensionName,
type: extensionType as AppExtensionType | ApiExtensionType,
entrypoint: 'index.js',
local: true,
});
}
}
} catch {
} catch (e) {
throw new Error(`Extension folder "${typePath}" couldn't be opened`);
}
}

View File

@@ -1,174 +0,0 @@
import { describe, expect, it } from 'vitest';
import { validateExtensionManifest, validateExtensionOptionsBundleEntry } from './validate-extension-manifest';
describe('validateExtensionManifest', () => {
it('returns false when passed item has no name or version', () => {
const mockExtension = {};
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns false when passed item has no EXTENSION_PKG_KEY', () => {
const mockExtension = { name: 'test', version: '0.1' };
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns false when passed item has no type', () => {
const mockExtension = { name: 'test', version: '0.1', 'directus:extension': {} };
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns false when passed item has an invalid type', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': { type: 'not_extension_type' },
};
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns false when passed item has no host', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': { type: 'pack' },
};
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns false when passed item has a type of bundle and has no path, entries or they have the wrong format', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': { type: 'bundle', host: '^9.0.0' },
};
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns false when passed item has a Hybrid type and has no path, source or they have the wrong format', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': { type: 'operation', host: '^9.0.0' },
};
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns false when passed item has an App or API type and has no path or source', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': { type: 'interface', host: '^9.0.0' },
};
expect(validateExtensionManifest(mockExtension)).toBe(false);
});
it('returns true when passed a valid ExtensionManifest with an App or API type', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': { type: 'interface', path: './dist/index.js', source: './src/index.js', host: '^9.0.0' },
};
expect(validateExtensionManifest(mockExtension)).toBe(true);
});
it('returns true when passed a valid ExtensionManifest with a Hybrid type', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': {
type: 'operation',
path: { app: './dist/app.js', api: './dist/api.js' },
source: { app: './src/app.js', api: './src/api.js' },
host: '^9.0.0',
},
};
expect(validateExtensionManifest(mockExtension)).toBe(true);
});
it('returns true when passed a valid ExtensionManifest with a type of bundle', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': {
type: 'bundle',
path: { app: './dist/app.js', api: './dist/api.js' },
entries: [
{ type: 'interface', name: 'test-interface', source: './src/test-interface/index.js' },
{ type: 'hook', name: 'test-hook', source: './src/test-hook/index.js' },
{
type: 'operation',
name: 'test-operation',
source: { app: './src/test-operation/app.js', api: './src/test-operation/api.js' },
},
],
host: '^9.0.0',
},
};
expect(validateExtensionManifest(mockExtension)).toBe(true);
});
it('returns true when passed a valid ExtensionManifest with a Package type', () => {
const mockExtension = {
name: 'test',
version: '0.1',
'directus:extension': { type: 'pack', host: '^9.0.0' },
};
expect(validateExtensionManifest(mockExtension)).toBe(true);
});
});
describe('validateExtensionOptionsBundleEntry', () => {
it('returns false when passed item has no type', () => {
const mockEntry = {};
expect(validateExtensionOptionsBundleEntry(mockEntry)).toBe(false);
});
it('returns false when passed item has an invalid type', () => {
const mockEntry = { type: 'bundle' };
expect(validateExtensionOptionsBundleEntry(mockEntry)).toBe(false);
});
it('returns false when passed item has no name', () => {
const mockEntry = { type: 'interface' };
expect(validateExtensionOptionsBundleEntry(mockEntry)).toBe(false);
});
it('returns false when passed item has a Hybrid type and has no source or it has the wrong format', () => {
const mockEntry = { type: 'operation', name: 'test', source: './src/index.js' };
expect(validateExtensionOptionsBundleEntry(mockEntry)).toBe(false);
});
it('returns false when passed item has an App or API type and has no source', () => {
const mockEntry = { type: 'interface', name: 'test' };
expect(validateExtensionOptionsBundleEntry(mockEntry)).toBe(false);
});
it('returns true when passed a valid ExtensionOptionsBundleEntry with an App or API type', () => {
const mockEntry = { type: 'interface', name: 'test', source: './src/index.js' };
expect(validateExtensionOptionsBundleEntry(mockEntry)).toBe(true);
});
it('returns true when passed a valid ExtensionOptionsBundleEntry with a Hybrid type', () => {
const mockEntry = { type: 'operation', name: 'test', source: { app: './src/app.js', api: './src/api.js' } };
expect(validateExtensionOptionsBundleEntry(mockEntry)).toBe(true);
});
});

View File

@@ -1,90 +0,0 @@
import {
EXTENSION_PACKAGE_TYPES,
EXTENSION_PKG_KEY,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
PACKAGE_EXTENSION_TYPES,
} from '../constants';
import {
ExtensionManifest,
ExtensionManifestRaw,
ExtensionOptionsBundleEntry,
ExtensionOptionsBundleEntryRaw,
} from '../types';
import { isIn } from './array-helpers';
export function validateExtensionManifest(
extensionManifest: ExtensionManifestRaw
): extensionManifest is ExtensionManifest {
if (!extensionManifest.name || !extensionManifest.version) {
return false;
}
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
if (
!extensionOptions ||
!extensionOptions.type ||
!isIn(extensionOptions.type, EXTENSION_PACKAGE_TYPES) ||
!extensionOptions.host
) {
return false;
}
if (isIn(extensionOptions.type, PACKAGE_EXTENSION_TYPES)) {
if (extensionOptions.type === 'bundle') {
if (
!extensionOptions.path ||
typeof extensionOptions.path === 'string' ||
!extensionOptions.path.app ||
!extensionOptions.path.api ||
!extensionOptions.entries ||
!Array.isArray(extensionOptions.entries) ||
!extensionOptions.entries.every((entry) => validateExtensionOptionsBundleEntry(entry))
) {
return false;
}
}
} else {
if (isIn(extensionOptions.type, HYBRID_EXTENSION_TYPES)) {
if (
!extensionOptions.path ||
!extensionOptions.source ||
typeof extensionOptions.path === 'string' ||
typeof extensionOptions.source === 'string' ||
!extensionOptions.path.app ||
!extensionOptions.path.api ||
!extensionOptions.source.app ||
!extensionOptions.source.api
) {
return false;
}
} else {
if (!extensionOptions.path || !extensionOptions.source) {
return false;
}
}
}
return true;
}
export function validateExtensionOptionsBundleEntry(
entry: ExtensionOptionsBundleEntryRaw
): entry is ExtensionOptionsBundleEntry {
if (!entry.type || !isIn(entry.type, EXTENSION_TYPES) || !entry.name) {
return false;
}
if (isIn(entry.type, HYBRID_EXTENSION_TYPES)) {
if (!entry.source || typeof entry.source === 'string' || !entry.source.app || !entry.source.api) {
return false;
}
} else {
if (!entry.source) {
return false;
}
}
return true;
}

9
pnpm-lock.yaml generated
View File

@@ -683,6 +683,7 @@ importers:
vue: 3.2.45
vue-i18n: 9.2.2
vue-router: 4.1.6
zod: ^3.20.2
dependencies:
axios: 1.2.1
date-fns: 2.29.3
@@ -699,6 +700,7 @@ importers:
vue: 3.2.45
vue-i18n: 9.2.2_vue@3.2.45
vue-router: 4.1.6_vue@3.2.45
zod: 3.20.2
devDependencies:
'@types/express': 4.17.15
'@types/fs-extra': 9.0.13
@@ -8732,6 +8734,7 @@ packages:
/bindings/1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
requiresBuild: true
dependencies:
file-uri-to-path: 1.0.0
dev: true
@@ -11520,6 +11523,7 @@ packages:
/file-uri-to-path/1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
requiresBuild: true
dev: true
optional: true
@@ -15316,6 +15320,7 @@ packages:
/nan/2.17.0:
resolution: {integrity: sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==}
requiresBuild: true
dev: true
optional: true
@@ -21353,6 +21358,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
/zod/3.20.2:
resolution: {integrity: sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ==}
dev: false
/zwitch/1.0.5:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
dev: true