diff --git a/api/src/controllers/extensions.ts b/api/src/controllers/extensions.ts index 83953c2118..599e1bbb3d 100644 --- a/api/src/controllers/extensions.ts +++ b/api/src/controllers/extensions.ts @@ -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); - if (!isIn(type, APP_OR_HYBRID_EXTENSION_TYPES)) { + if (!isIn(type, EXTENSION_TYPES)) { throw new RouteNotFoundException(req.path); } diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 71e17138e2..9e01ebef55 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -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 { 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 { - 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 { diff --git a/app/src/extensions.ts b/app/src/extensions.ts index 147ceeb0aa..3e84131418 100644 --- a/app/src/extensions.ts +++ b/app/src/extensions.ts @@ -27,7 +27,7 @@ const onDehydrateCallbacks: (() => Promise)[] = []; export async function loadExtensions(): Promise { 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 diff --git a/app/vite.config.js b/app/vite.config.js index 231ce4eeb1..9a354c1a24 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -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); diff --git a/packages/extensions-sdk/src/cli/commands/add.ts b/packages/extensions-sdk/src/cli/commands/add.ts index 36d80e6bda..d5b38e08f7 100644 --- a/packages/extensions-sdk/src/cli/commands/add.ts +++ b/packages/extensions-sdk/src/cli/commands/add.ts @@ -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 { 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 { 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; diff --git a/packages/extensions-sdk/src/cli/commands/build.ts b/packages/extensions-sdk/src/cli/commands/build.ts index 59fa1bb879..ad70b6cde1 100644 --- a/packages/extensions-sdk/src/cli/commands/build.ts +++ b/packages/extensions-sdk/src/cli/commands/build.ts @@ -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 { 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 { 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 ]')} option.`, 'error'); process.exit(1); @@ -145,10 +138,10 @@ export default async function build(options: BuildOptions): Promise { } 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":"","name":"","source":}]']` @@ -168,7 +161,7 @@ export default async function build(options: BuildOptions): Promise { } await buildBundleExtension({ - entries, + entries: entries.data, outputApp: splitOutput.app, outputApi: splitOutput.api, watch, diff --git a/packages/extensions-sdk/src/cli/commands/create.ts b/packages/extensions-sdk/src/cli/commands/create.ts index a45a9a6a32..d18f01dc75 100644 --- a/packages/extensions-sdk/src/cli/commands/create.ts +++ b/packages/extensions-sdk/src/cli/commands/create.ts @@ -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) { - return { + const packageManifest: Record = { 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)} diff --git a/packages/extensions-sdk/src/cli/commands/helpers/copy-template.ts b/packages/extensions-sdk/src/cli/commands/helpers/copy-template.ts index a8bba547f6..1ad3657b19 100644 --- a/packages/extensions-sdk/src/cli/commands/helpers/copy-template.ts +++ b/packages/extensions-sdk/src/cli/commands/helpers/copy-template.ts @@ -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 { +async function getTemplateFiles(type: ExtensionType, language?: Language): Promise { 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 diff --git a/packages/extensions-sdk/src/cli/commands/helpers/generate-bundle-entrypoint.ts b/packages/extensions-sdk/src/cli/commands/helpers/generate-bundle-entrypoint.ts index dfc16f8479..33445ac69e 100644 --- a/packages/extensions-sdk/src/cli/commands/helpers/generate-bundle-entrypoint.ts +++ b/packages/extensions-sdk/src/cli/commands/helpers/generate-bundle-entrypoint.ts @@ -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)); diff --git a/packages/extensions-sdk/src/cli/commands/helpers/get-extension-dev-deps.ts b/packages/extensions-sdk/src/cli/commands/helpers/get-extension-dev-deps.ts index 9094f12d1f..55df32b7cb 100644 --- a/packages/extensions-sdk/src/cli/commands/helpers/get-extension-dev-deps.ts +++ b/packages/extensions-sdk/src/cli/commands/helpers/get-extension-dev-deps.ts @@ -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> { 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')}`; } diff --git a/packages/extensions-sdk/src/cli/commands/helpers/validate-cli-options.ts b/packages/extensions-sdk/src/cli/commands/helpers/validate-cli-options.ts index 9158b32b93..d594ebb796 100644 --- a/packages/extensions-sdk/src/cli/commands/helpers/validate-cli-options.ts +++ b/packages/extensions-sdk/src/cli/commands/helpers/validate-cli-options.ts @@ -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; -} diff --git a/packages/extensions-sdk/src/cli/commands/link.ts b/packages/extensions-sdk/src/cli/commands/link.ts new file mode 100644 index 0000000000..3dddf7b580 --- /dev/null +++ b/packages/extensions-sdk/src/cli/commands/link.ts @@ -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 { + 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; + + 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; +} diff --git a/packages/extensions-sdk/src/cli/run.ts b/packages/extensions-sdk/src/cli/run.ts index 35785a13c8..efeda5ce54 100644 --- a/packages/extensions-sdk/src/cli/run.ts +++ b/packages/extensions-sdk/src/cli/run.ts @@ -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 to the extension folder of directus') + .action(link); + program.parse(process.argv); diff --git a/packages/shared/package.json b/packages/shared/package.json index e977523964..ae55072d86 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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", diff --git a/packages/shared/src/constants/extensions.ts b/packages/shared/src/constants/extensions.ts index cb51f39674..c3a9d7db57 100644 --- a/packages/shared/src/constants/extensions.ts +++ b/packages/shared/src/constants/extensions.ts @@ -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; diff --git a/packages/shared/src/types/extensions.ts b/packages/shared/src/types/extensions.ts index c94a580b69..5b1a00dc9a 100644 --- a/packages/shared/src/types/extensions.ts +++ b/packages/shared/src/types/extensions.ts @@ -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; 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; -}; +export type ExtensionOptions = z.infer; +export type ExtensionOptionsBundleEntry = z.infer; -export type ExtensionManifestRaw = { - name?: string; - version?: string; - dependencies?: Record; +export const ExtensionOptionsBundleEntries = z.array(ExtensionOptionsBundleEntry); +export type ExtensionOptionsBundleEntries = z.infer; - [EXTENSION_PKG_KEY]?: { - type?: string; - path?: string | Partial; - source?: string | Partial; - 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; - - [EXTENSION_PKG_KEY]: ExtensionOptions; -}; +export type ExtensionManifest = z.infer; export type AppExtensionConfigs = { interfaces: InterfaceConfig[]; @@ -198,3 +168,9 @@ export type ExtensionOptionsContext = { autoGenerateJunctionRelation: boolean; saving: boolean; }; + +export type ExtensionInfo = + | Omit + | Omit + | Omit + | Omit; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 6713d99711..7f53591461 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -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'; diff --git a/packages/shared/src/utils/node/ensure-extension-dirs.test.ts b/packages/shared/src/utils/node/ensure-extension-dirs.test.ts index 744b0b445b..a120da8739 100644 --- a/packages/shared/src/utils/node/ensure-extension-dirs.test.ts +++ b/packages/shared/src/utils/node/ensure-extension-dirs.test.ts @@ -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`); }); }); diff --git a/packages/shared/src/utils/node/ensure-extension-dirs.ts b/packages/shared/src/utils/node/ensure-extension-dirs.ts index 35c75c0f13..df2699508a 100644 --- a/packages/shared/src/utils/node/ensure-extension-dirs.ts +++ b/packages/shared/src/utils/node/ensure-extension-dirs.ts @@ -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 { +export async function ensureExtensionDirs( + extensionsPath: string, + types: readonly NestedExtensionType[] +): Promise { for (const extensionType of types) { const dirPath = path.resolve(extensionsPath, pluralize(extensionType)); try { diff --git a/packages/shared/src/utils/node/generate-extensions-entrypoint.test.ts b/packages/shared/src/utils/node/generate-extensions-entrypoint.test.ts index 9abef91052..cade31a354 100644 --- a/packages/shared/src/utils/node/generate-extensions-entrypoint.test.ts +++ b/packages/shared/src/utils/node/generate-extensions-entrypoint.test.ts @@ -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', diff --git a/packages/shared/src/utils/node/generate-extensions-entrypoint.ts b/packages/shared/src/utils/node/generate-extensions-entrypoint.ts index 72375e9321..e255ecdae1 100644 --- a/packages/shared/src/utils/node/generate-extensions-entrypoint.ts +++ b/packages/shared/src/utils/node/generate-extensions-entrypoint.ts @@ -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) diff --git a/packages/shared/src/utils/node/get-extensions.ts b/packages/shared/src/utils/node/get-extensions.ts index 1f13826bbb..2b6104c91d 100644 --- a/packages/shared/src/utils/node/get-extensions.ts +++ b/packages/shared/src/utils/node/get-extensions.ts @@ -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 { - const extensions: ExtensionPackage[] = []; +export async function resolvePackageExtensions(root: string, extensionNames?: string[]): Promise { + 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 = 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 { +export async function getPackageExtensions(root: string): Promise { let pkg: { dependencies?: Record }; 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 { - const extensions: ExtensionLocal[] = []; +export async function getLocalExtensions(root: string): Promise { + 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`); } } diff --git a/packages/shared/src/utils/validate-extension-manifest.test.ts b/packages/shared/src/utils/validate-extension-manifest.test.ts deleted file mode 100644 index 2a0453b2ff..0000000000 --- a/packages/shared/src/utils/validate-extension-manifest.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/shared/src/utils/validate-extension-manifest.ts b/packages/shared/src/utils/validate-extension-manifest.ts deleted file mode 100644 index d8a8bfa361..0000000000 --- a/packages/shared/src/utils/validate-extension-manifest.ts +++ /dev/null @@ -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; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06f81bccbd..1198173be3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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