├── LICENSE.md ├── README.md ├── assets └── flarum.svg ├── composer.json ├── extend.php ├── js ├── admin.ts ├── dist-typings │ ├── components │ │ ├── AuthMethodModal.d.ts │ │ ├── ConfigureAuth.d.ts │ │ ├── ConfigureComposer.d.ts │ │ ├── ConfigureJson.d.ts │ │ ├── ControlSection.d.ts │ │ ├── DiscoverSection.d.ts │ │ ├── ExtensionCard.d.ts │ │ ├── Installer.d.ts │ │ ├── Label.d.ts │ │ ├── MajorUpdater.d.ts │ │ ├── QueueSection.d.ts │ │ ├── RepositoryModal.d.ts │ │ ├── SettingsPage.d.ts │ │ ├── TaskOutputModal.d.ts │ │ ├── Updater.d.ts │ │ └── WhyNotModal.d.ts │ ├── extend.d.ts │ ├── index.d.ts │ ├── models │ │ ├── ExternalExtension.d.ts │ │ └── Task.d.ts │ ├── states │ │ ├── ControlSectionState.d.ts │ │ ├── ExtensionListState.d.ts │ │ ├── ExtensionManagerState.d.ts │ │ ├── PackageManagerState.d.ts │ │ └── QueueState.d.ts │ └── utils │ │ ├── errorHandler.d.ts │ │ ├── humanDuration.d.ts │ │ ├── jumpToQueue.d.ts │ │ └── versions.d.ts ├── dist │ ├── admin.js │ └── admin.js.map ├── package.json ├── src │ └── admin │ │ ├── components │ │ ├── AuthMethodModal.tsx │ │ ├── ConfigureAuth.tsx │ │ ├── ConfigureComposer.tsx │ │ ├── ConfigureJson.tsx │ │ ├── ControlSection.tsx │ │ ├── DiscoverSection.tsx │ │ ├── ExtensionCard.tsx │ │ ├── Installer.tsx │ │ ├── Label.tsx │ │ ├── MajorUpdater.tsx │ │ ├── QueueSection.tsx │ │ ├── RepositoryModal.tsx │ │ ├── SettingsPage.tsx │ │ ├── TaskOutputModal.tsx │ │ ├── Updater.tsx │ │ └── WhyNotModal.tsx │ │ ├── extend.tsx │ │ ├── index.tsx │ │ ├── models │ │ ├── ExternalExtension.ts │ │ └── Task.ts │ │ ├── shims.d.ts │ │ ├── states │ │ ├── ControlSectionState.ts │ │ ├── ExtensionListState.ts │ │ ├── ExtensionManagerState.ts │ │ ├── PackageManagerState.ts │ │ └── QueueState.ts │ │ └── utils │ │ ├── errorHandler.ts │ │ ├── humanDuration.ts │ │ ├── jumpToQueue.ts │ │ └── versions.ts ├── tsconfig.json └── webpack.config.js ├── less ├── admin.less └── admin │ ├── ControlSection.less │ ├── DiscoverSection.less │ ├── ExtensionCard.less │ ├── Label.less │ ├── QueueSection.less │ └── TaskOutputModal.less ├── locale └── en.yml ├── migrations ├── 2022_02_22_000000_create_package_manager_tasks_table.php ├── 2023_12_09_000000_add_guessed_cause_column_to_package_manager_tasks_table.php └── 2024_01_10_000000_rename_to_extension_manager.php └── src ├── Api ├── Controller │ ├── CheckForUpdatesController.php │ ├── ConfigureComposerController.php │ ├── GlobalUpdateController.php │ ├── MajorUpdateController.php │ ├── MinorUpdateController.php │ ├── RemoveExtensionController.php │ ├── RequireExtensionController.php │ ├── UpdateExtensionController.php │ └── WhyNotController.php ├── Resource │ ├── ExternalExtensionResource.php │ └── TaskResource.php └── Schema │ └── SortColumn.php ├── Command ├── AbstractActionCommand.php ├── CheckForUpdates.php ├── CheckForUpdatesHandler.php ├── GlobalUpdate.php ├── GlobalUpdateHandler.php ├── MajorUpdate.php ├── MajorUpdateHandler.php ├── MinorUpdate.php ├── MinorUpdateHandler.php ├── RemoveExtension.php ├── RemoveExtensionHandler.php ├── RequireExtension.php ├── RequireExtensionHandler.php ├── UpdateExtension.php ├── UpdateExtensionHandler.php ├── WhyNot.php └── WhyNotHandler.php ├── Composer ├── ComposerAdapter.php ├── ComposerJson.php └── ComposerOutput.php ├── ConfigureAuthValidator.php ├── ConfigureComposerValidator.php ├── Event └── FlarumUpdated.php ├── Exception ├── CannotFetchExternalExtension.php ├── ComposerCommandFailedException.php ├── ComposerRequireFailedException.php ├── ComposerUpdateFailedException.php ├── ExceptionHandler.php ├── ExtensionAlreadyInstalledException.php ├── ExtensionNotInstalledException.php ├── IndirectExtensionDependencyCannotBeRemovedException.php ├── MajorUpdateFailedException.php └── NoNewMajorVersionException.php ├── Extension └── Event │ ├── Installed.php │ ├── Removed.php │ └── Updated.php ├── ExtensionManagerServiceProvider.php ├── External ├── Extension.php └── RequestWrapper.php ├── Job ├── ComposerCommandJob.php ├── Dispatcher.php └── DispatcherResponse.php ├── Listener ├── ClearCacheAfterUpdate.php └── ReCheckForUpdates.php ├── OutputLogger.php ├── RequirePackageValidator.php ├── Settings ├── JsonSetting.php ├── LastUpdateCheck.php └── LastUpdateRun.php ├── Support └── Util.php ├── Task └── Task.php ├── UpdateExtensionValidator.php └── WhyNotValidator.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Stichting Flarum (Flarum Foundation) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extension Manager 2 | 3 | The extension manager is a tool that allows you to easily install and manage extensions. It runs [composer](https://getcomposer.org/) under the hood. 4 | 5 | ## Security 6 | 7 | If admin access is given to untrustworthy users, they can install malicious extensions. Please be careful. 8 | 9 | This extension is optional and can be removed for those who prefer to manually manage installs and updates through the command line interface. 10 | 11 | ## Troubleshooting 12 | 13 | If you have many extensions installed, you may run into memory issues when using the extension manager. If this happens, you can use an asynchronous queue that will run the extension manager in the background. 14 | 15 | * Simple database queue guide: https://discuss.flarum.org/d/28151-database-queue-the-simplest-queue-even-for-shared-hosting 16 | * (Advanced) Redis queue: https://discuss.flarum.org/d/21873-redis-sessions-cache-queues 17 | 18 | You can find detailed logs on the extension manager operations in the `storage/logs/composer` directory. Please include the latest log file when reporting issues in the [Flarum support forum](https://discuss.flarum.org/t/support). 19 | -------------------------------------------------------------------------------- /assets/flarum.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | only symbol 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flarum/extension-manager", 3 | "description": "An extension manager to install, update and remove extension packages from the interface (Wrapper around composer).", 4 | "keywords": [ 5 | "extensions", 6 | "composer", 7 | "packages", 8 | "manager", 9 | "updater" 10 | ], 11 | "type": "flarum-extension", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Flarum", 16 | "email": "info@flarum.org", 17 | "homepage": "https://flarum.org/team" 18 | } 19 | ], 20 | "support": { 21 | "issues": "https://github.com/flarum/framework/issues", 22 | "source": "https://github.com/flarum/extension-manager" 23 | }, 24 | "require": { 25 | "flarum/core": "^2.0.0-beta.3", 26 | "composer/composer": "^2.7" 27 | }, 28 | "require-dev": { 29 | "flarum/testing": "^2.0", 30 | "flarum/tags": "*" 31 | }, 32 | "extra": { 33 | "flarum-extension": { 34 | "title": "Extension Manager", 35 | "icon": { 36 | "name": "fas fa-box-open", 37 | "backgroundColor": "#117187", 38 | "color": "#fff" 39 | } 40 | }, 41 | "flarum-cli": { 42 | "excludeScaffolding": [ 43 | ".github/workflows/backend.yml", 44 | "js/src/admin/index.ts", 45 | "tests/phpunit.integration.xml", 46 | "tests/integration/setup.php" 47 | ], 48 | "excludeScaffoldingConfigKeys": { 49 | "composer.json": [ 50 | "scripts.test:setup" 51 | ] 52 | }, 53 | "modules": { 54 | "admin": true, 55 | "forum": false, 56 | "js": true, 57 | "jsCommon": false, 58 | "css": true, 59 | "gitConf": true, 60 | "githubActions": true, 61 | "prettier": true, 62 | "typescript": true, 63 | "bundlewatch": false, 64 | "backendTesting": true, 65 | "editorConfig": true, 66 | "styleci": true 67 | } 68 | } 69 | }, 70 | "autoload": { 71 | "psr-4": { 72 | "Flarum\\ExtensionManager\\": "src/" 73 | } 74 | }, 75 | "autoload-dev": { 76 | "psr-4": { 77 | "Flarum\\ExtensionManager\\Tests\\": "tests/" 78 | } 79 | }, 80 | "scripts": { 81 | "test": [ 82 | "@test:unit", 83 | "@test:integration" 84 | ], 85 | "test:unit": "phpunit -c tests/phpunit.unit.xml", 86 | "test:integration": "phpunit -c tests/phpunit.integration.xml", 87 | "test:setup": [ 88 | "@php tests/integration/setup.php", 89 | "cd ${FLARUM_TEST_TMP_DIR_LOCAL:-${FLARUM_TEST_TMP_DIR:-./tests/integration/tmp}} && composer install" 90 | ] 91 | }, 92 | "scripts-descriptions": { 93 | "test": "Runs all tests.", 94 | "test:unit": "Runs all unit tests.", 95 | "test:integration": "Runs all integration tests.", 96 | "test:setup": "Sets up a database for use with integration tests. Execute this only once." 97 | }, 98 | "minimum-stability": "dev", 99 | "prefer-stable": true, 100 | "repositories": [ 101 | { 102 | "type": "path", 103 | "url": "../../*/*" 104 | } 105 | ] 106 | } 107 | -------------------------------------------------------------------------------- /extend.php: -------------------------------------------------------------------------------- 1 | post('/extension-manager/extensions', 'extension-manager.extensions.require', Api\Controller\RequireExtensionController::class) 24 | ->patch('/extension-manager/extensions/{id}', 'extension-manager.extensions.update', Api\Controller\UpdateExtensionController::class) 25 | ->delete('/extension-manager/extensions/{id}', 'extension-manager.extensions.remove', Api\Controller\RemoveExtensionController::class) 26 | ->post('/extension-manager/check-for-updates', 'extension-manager.check-for-updates', Api\Controller\CheckForUpdatesController::class) 27 | ->post('/extension-manager/why-not', 'extension-manager.why-not', Api\Controller\WhyNotController::class) 28 | ->post('/extension-manager/minor-update', 'extension-manager.minor-update', Api\Controller\MinorUpdateController::class) 29 | ->post('/extension-manager/major-update', 'extension-manager.major-update', Api\Controller\MajorUpdateController::class) 30 | ->post('/extension-manager/global-update', 'extension-manager.global-update', Api\Controller\GlobalUpdateController::class) 31 | ->post('/extension-manager/composer', 'extension-manager.composer', Api\Controller\ConfigureComposerController::class), 32 | 33 | new Extend\ApiResource(TaskResource::class), 34 | new Extend\ApiResource(ExternalExtensionResource::class), 35 | 36 | (new Extend\Frontend('admin')) 37 | ->css(__DIR__.'/less/admin.less') 38 | ->js(__DIR__.'/js/dist/admin.js') 39 | ->content(function (Document $document) { 40 | $paths = resolve(Paths::class); 41 | 42 | $document->payload['flarum-extension-manager.writable_dirs'] = is_writable($paths->vendor) 43 | && is_writable($paths->storage) 44 | && (! file_exists($paths->storage.'/.composer') || is_writable($paths->storage.'/.composer')) 45 | && is_writable($paths->base.'/composer.json') 46 | && is_writable($paths->base.'/composer.lock'); 47 | 48 | $document->payload['flarum-extension-manager.using_sync_queue'] = resolve(Queue::class) instanceof SyncQueue; 49 | 50 | $document->payload['flarum-extension-manager.missing_functions'] = array_values(array_filter( 51 | ['proc_open', 'escapeshellarg'], 52 | fn (string $function): bool => ! function_exists($function) 53 | )); 54 | }), 55 | 56 | new Extend\Locales(__DIR__.'/locale'), 57 | 58 | (new Extend\Settings()) 59 | ->default(Settings\LastUpdateCheck::key(), json_encode(Settings\LastUpdateCheck::default())) 60 | ->default(Settings\LastUpdateRun::key(), json_encode(Settings\LastUpdateRun::default())) 61 | ->default('flarum-extension-manager.queue_jobs', '0') 62 | ->default('flarum-extension-manager.minimum_stability', 'stable') 63 | ->default('flarum-extension-manager.task_retention_days', 7), 64 | 65 | (new Extend\ServiceProvider) 66 | ->register(ExtensionManagerServiceProvider::class), 67 | 68 | (new Extend\ErrorHandling) 69 | ->handler(Exception\ComposerCommandFailedException::class, Exception\ExceptionHandler::class) 70 | ->handler(Exception\ComposerRequireFailedException::class, Exception\ExceptionHandler::class) 71 | ->handler(Exception\ComposerUpdateFailedException::class, Exception\ExceptionHandler::class) 72 | ->handler(Exception\MajorUpdateFailedException::class, Exception\ExceptionHandler::class) 73 | ->type(CannotFetchExternalExtension::class, 'cannot_fetch_external_extension') 74 | ->status('extension_already_installed', 409) 75 | ->status('extension_not_installed', 409) 76 | ->status('no_new_major_version', 409) 77 | ->status('extension_not_directly_dependency', 409) 78 | ->status('cannot_fetch_external_extension', 503), 79 | ]; 80 | -------------------------------------------------------------------------------- /js/admin.ts: -------------------------------------------------------------------------------- 1 | export * from './src/admin'; 2 | -------------------------------------------------------------------------------- /js/dist-typings/components/AuthMethodModal.d.ts: -------------------------------------------------------------------------------- 1 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 2 | import Mithril from 'mithril'; 3 | import Stream from 'flarum/common/utils/Stream'; 4 | export interface IAuthMethodModalAttrs extends IInternalModalAttrs { 5 | onsubmit: (type: string, host: string, token: string) => void; 6 | type?: string; 7 | host?: string; 8 | token?: string; 9 | } 10 | export default class AuthMethodModal extends Modal { 11 | protected type: Stream; 12 | protected host: Stream; 13 | protected token: Stream; 14 | oninit(vnode: Mithril.Vnode): void; 15 | className(): string; 16 | title(): Mithril.Children; 17 | content(): Mithril.Children; 18 | submit(): void; 19 | } 20 | -------------------------------------------------------------------------------- /js/dist-typings/components/ConfigureAuth.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import ConfigureJson, { IConfigureJson } from './ConfigureJson'; 3 | export default class ConfigureAuth extends ConfigureJson { 4 | protected type: string; 5 | title(): Mithril.Children; 6 | className(): string; 7 | content(): Mithril.Children; 8 | submitButton(): Mithril.Children[]; 9 | onchange(oldHost: string | null, type: string, host: string, token: string): void; 10 | } 11 | -------------------------------------------------------------------------------- /js/dist-typings/components/ConfigureComposer.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import ConfigureJson, { type IConfigureJson } from './ConfigureJson'; 3 | export type Repository = { 4 | type: 'composer' | 'vcs' | 'path'; 5 | url: string; 6 | }; 7 | export default class ConfigureComposer extends ConfigureJson { 8 | protected type: string; 9 | title(): Mithril.Children; 10 | className(): string; 11 | content(): Mithril.Children; 12 | submitButton(): Mithril.Children[]; 13 | onchange(repository: Repository, name: string): void; 14 | } 15 | -------------------------------------------------------------------------------- /js/dist-typings/components/ConfigureJson.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import Component, { type ComponentAttrs } from 'flarum/common/Component'; 3 | import { type SettingsComponentOptions } from 'flarum/admin/components/AdminPage'; 4 | import { type CommonFieldOptions } from 'flarum/common/components/FormGroup'; 5 | import type ItemList from 'flarum/common/utils/ItemList'; 6 | import Stream from 'flarum/common/utils/Stream'; 7 | export interface IConfigureJson extends ComponentAttrs { 8 | buildSettingComponent: (entry: ((this: this) => Mithril.Children) | SettingsComponentOptions) => Mithril.Children; 9 | } 10 | export default abstract class ConfigureJson extends Component { 11 | protected settings: Record>; 12 | protected initialSettings: Record | null; 13 | protected loading: boolean; 14 | oninit(vnode: Mithril.Vnode): void; 15 | protected abstract type: string; 16 | abstract title(): Mithril.Children; 17 | abstract content(): Mithril.Children; 18 | className(): string; 19 | view(): Mithril.Children; 20 | submitButton(): Mithril.Children[]; 21 | customSettingComponents(): ItemList<(attributes: CommonFieldOptions) => Mithril.Children>; 22 | setting(key: string): Stream; 23 | submit(readOnly: boolean): void; 24 | isDirty(): boolean; 25 | } 26 | -------------------------------------------------------------------------------- /js/dist-typings/components/ControlSection.d.ts: -------------------------------------------------------------------------------- 1 | import Component from 'flarum/common/Component'; 2 | import { ComponentAttrs } from 'flarum/common/Component'; 3 | import Mithril from 'mithril'; 4 | export default class ControlSection extends Component { 5 | oninit(vnode: Mithril.Vnode): void; 6 | view(): JSX.Element; 7 | } 8 | -------------------------------------------------------------------------------- /js/dist-typings/components/DiscoverSection.d.ts: -------------------------------------------------------------------------------- 1 | import Component, { type ComponentAttrs } from 'flarum/common/Component'; 2 | import type Mithril from 'mithril'; 3 | import ItemList from 'flarum/common/utils/ItemList'; 4 | import Stream from 'flarum/common/utils/Stream'; 5 | export interface IDiscoverSectionAttrs extends ComponentAttrs { 6 | } 7 | export default class DiscoverSection extends Component { 8 | protected search: Stream; 9 | protected warningsDismissed: Stream; 10 | oninit(vnode: Mithril.Vnode): void; 11 | load(page?: number): void; 12 | view(): JSX.Element; 13 | tabFilters(): Record boolean; 16 | }>; 17 | tabItems(): ItemList; 18 | warningItems(): ItemList; 19 | private applySearch; 20 | toolbarPrimaryItems(): ItemList; 21 | toolbarSecondaryItems(): ItemList; 22 | extensionList(): JSX.Element; 23 | footerItems(): ItemList; 24 | private setWarningDismissed; 25 | } 26 | -------------------------------------------------------------------------------- /js/dist-typings/components/ExtensionCard.d.ts: -------------------------------------------------------------------------------- 1 | import Component, { type ComponentAttrs } from 'flarum/common/Component'; 2 | import { type Extension as ExtensionInfo } from 'flarum/admin/AdminApplication'; 3 | import ExternalExtension from '../models/ExternalExtension'; 4 | import { UpdatedPackage } from '../states/ControlSectionState'; 5 | import ItemList from 'flarum/common/utils/ItemList'; 6 | import type Mithril from 'mithril'; 7 | export type CommonExtension = ExternalExtension | ExtensionInfo; 8 | export interface IExtensionAttrs extends ComponentAttrs { 9 | extension: CommonExtension; 10 | updates?: UpdatedPackage; 11 | onClickUpdate?: CallableFunction | { 12 | soft: CallableFunction; 13 | hard: CallableFunction; 14 | }; 15 | whyNotWarning?: boolean; 16 | isCore?: boolean; 17 | updatable?: boolean; 18 | isDanger?: boolean; 19 | } 20 | export default class ExtensionCard extends Component { 21 | getExtension(): ExtensionInfo; 22 | view(): JSX.Element; 23 | icon(): JSX.Element; 24 | badges(): ItemList; 25 | metaItems(): ItemList; 26 | actionItems(): ItemList; 27 | version(v: string): string; 28 | } 29 | -------------------------------------------------------------------------------- /js/dist-typings/components/Installer.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 3 | import Stream from 'flarum/common/utils/Stream'; 4 | export interface InstallerAttrs extends ComponentAttrs { 5 | } 6 | export type InstallerLoadingTypes = 'extension-install' | null; 7 | export default class Installer extends Component { 8 | packageName: Stream; 9 | oninit(vnode: Mithril.Vnode): void; 10 | view(): Mithril.Children; 11 | data(): any; 12 | onsubmit(): void; 13 | } 14 | -------------------------------------------------------------------------------- /js/dist-typings/components/Label.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 3 | interface LabelAttrs extends ComponentAttrs { 4 | type: 'success' | 'error' | 'neutral' | 'warning'; 5 | } 6 | export default class Label extends Component { 7 | view(vnode: Mithril.Vnode): JSX.Element; 8 | } 9 | export {}; 10 | -------------------------------------------------------------------------------- /js/dist-typings/components/MajorUpdater.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 3 | import { UpdatedPackage, UpdateState } from '../states/ControlSectionState'; 4 | export interface MajorUpdaterAttrs extends ComponentAttrs { 5 | coreUpdate: UpdatedPackage; 6 | updateState: UpdateState; 7 | } 8 | export type MajorUpdaterLoadingTypes = 'major-update' | 'major-update-dry-run'; 9 | export default class MajorUpdater extends Component { 10 | updateState: UpdateState; 11 | oninit(vnode: Mithril.Vnode): void; 12 | view(): Mithril.Children; 13 | update(dryRun: boolean): void; 14 | } 15 | -------------------------------------------------------------------------------- /js/dist-typings/components/QueueSection.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 3 | import ItemList from 'flarum/common/utils/ItemList'; 4 | import Task, { TaskOperations } from '../models/Task'; 5 | interface QueueTableColumn extends ComponentAttrs { 6 | label: string; 7 | content: (task: Task) => Mithril.Children; 8 | } 9 | export default class QueueSection extends Component<{}> { 10 | oninit(vnode: Mithril.Vnode<{}, this>): void; 11 | view(): JSX.Element; 12 | columns(): ItemList; 13 | queueTable(): JSX.Element; 14 | operationIcon(operation: TaskOperations): Mithril.Children; 15 | } 16 | export {}; 17 | -------------------------------------------------------------------------------- /js/dist-typings/components/RepositoryModal.d.ts: -------------------------------------------------------------------------------- 1 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 2 | import Mithril from 'mithril'; 3 | import Stream from 'flarum/common/utils/Stream'; 4 | import { type Repository } from './ConfigureComposer'; 5 | export interface IRepositoryModalAttrs extends IInternalModalAttrs { 6 | onsubmit: (repository: Repository, key: string) => void; 7 | name?: string; 8 | repository?: Repository; 9 | } 10 | export default class RepositoryModal extends Modal { 11 | protected name: Stream; 12 | protected repository: Stream; 13 | oninit(vnode: Mithril.Vnode): void; 14 | className(): string; 15 | title(): Mithril.Children; 16 | content(): Mithril.Children; 17 | submit(): void; 18 | } 19 | -------------------------------------------------------------------------------- /js/dist-typings/components/SettingsPage.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import ExtensionPage, { ExtensionPageAttrs } from 'flarum/admin/components/ExtensionPage'; 3 | import ItemList from 'flarum/common/utils/ItemList'; 4 | export default class SettingsPage extends ExtensionPage { 5 | content(): JSX.Element; 6 | sections(vnode: Mithril.VnodeDOM): ItemList; 7 | onsaved(): void; 8 | } 9 | -------------------------------------------------------------------------------- /js/dist-typings/components/TaskOutputModal.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 3 | import Task from '../models/Task'; 4 | interface TaskOutputModalAttrs extends IInternalModalAttrs { 5 | task: Task; 6 | } 7 | export default class TaskOutputModal extends Modal { 8 | className(): string; 9 | title(): string | any[]; 10 | content(): JSX.Element; 11 | } 12 | export {}; 13 | -------------------------------------------------------------------------------- /js/dist-typings/components/Updater.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 3 | import ItemList from 'flarum/common/utils/ItemList'; 4 | export interface IUpdaterAttrs extends ComponentAttrs { 5 | } 6 | export type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null; 7 | export default class Updater extends Component { 8 | view(): (JSX.Element | null)[]; 9 | lastUpdateCheckView(): JSX.Element | null; 10 | availableUpdatesView(): JSX.Element; 11 | controlItems(): ItemList; 12 | } 13 | -------------------------------------------------------------------------------- /js/dist-typings/components/WhyNotModal.d.ts: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 3 | export interface WhyNotModalAttrs extends IInternalModalAttrs { 4 | package: string; 5 | } 6 | export default class WhyNotModal extends Modal { 7 | loading: boolean; 8 | whyNot: string | null; 9 | className(): string; 10 | title(): string | any[]; 11 | oncreate(vnode: Mithril.VnodeDOM): void; 12 | content(): JSX.Element; 13 | requestWhyNot(): void; 14 | } 15 | -------------------------------------------------------------------------------- /js/dist-typings/extend.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: (import("flarum/common/extenders/Store").default | import("flarum/common/extenders/Admin").default)[]; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /js/dist-typings/index.d.ts: -------------------------------------------------------------------------------- 1 | export { default as extend } from './extend'; 2 | -------------------------------------------------------------------------------- /js/dist-typings/models/ExternalExtension.d.ts: -------------------------------------------------------------------------------- 1 | import Model from 'flarum/common/Model'; 2 | import type { Extension } from 'flarum/admin/AdminApplication'; 3 | export default class ExternalExtension extends Model { 4 | extensionId: () => string; 5 | name: () => string; 6 | title: () => string; 7 | description: () => string; 8 | iconUrl: () => string; 9 | icon: () => { 10 | [key: string]: string; 11 | name: string; 12 | }; 13 | highestVersion: () => string; 14 | httpUri: () => string; 15 | discussUri: () => string; 16 | vendor: () => string; 17 | isPremium: () => boolean; 18 | isLocale: () => boolean; 19 | locale: () => string; 20 | latestFlarumVersionSupported: () => string; 21 | downloads: () => number; 22 | isSupported: () => boolean; 23 | readonly installed = false; 24 | isProductionReady(): boolean; 25 | toLocalExtension(): Extension; 26 | } 27 | -------------------------------------------------------------------------------- /js/dist-typings/models/Task.d.ts: -------------------------------------------------------------------------------- 1 | import Model from 'flarum/common/Model'; 2 | export type TaskOperations = 'extension_install' | 'extension_remove' | 'extension_update' | 'update_global' | 'update_minor' | 'update_major' | 'update_check' | 'why_not'; 3 | export default class Task extends Model { 4 | status(): "pending" | "running" | "failure" | "success"; 5 | operation(): TaskOperations; 6 | command(): string; 7 | package(): string; 8 | output(): string; 9 | guessedCause(): string; 10 | createdAt(): Date | null | undefined; 11 | startedAt(): Date; 12 | finishedAt(): Date; 13 | peakMemoryUsed(): string; 14 | } 15 | -------------------------------------------------------------------------------- /js/dist-typings/states/ControlSectionState.d.ts: -------------------------------------------------------------------------------- 1 | import { UpdaterLoadingTypes } from '../components/Updater'; 2 | import { InstallerLoadingTypes } from '../components/Installer'; 3 | import { MajorUpdaterLoadingTypes } from '../components/MajorUpdater'; 4 | import { Extension } from 'flarum/admin/AdminApplication'; 5 | export type UpdatedPackage = { 6 | name: string; 7 | version: string; 8 | latest: string; 9 | 'latest-minor': string | null; 10 | 'latest-major': string | null; 11 | 'latest-status': string; 12 | 'required-as': string; 13 | 'direct-dependency': boolean; 14 | description: string; 15 | }; 16 | export type ComposerUpdates = { 17 | installed: UpdatedPackage[]; 18 | }; 19 | export type LastUpdateCheck = { 20 | checkedAt: Date | null; 21 | updates: ComposerUpdates; 22 | }; 23 | type UpdateType = 'major' | 'minor' | 'global'; 24 | type UpdateStatus = 'success' | 'failure' | null; 25 | export type UpdateState = { 26 | ranAt: Date | null; 27 | status: UpdateStatus; 28 | limitedPackages: string[]; 29 | incompatibleExtensions: string[]; 30 | }; 31 | export type LastUpdateRun = { 32 | [key in UpdateType]: UpdateState; 33 | } & { 34 | limitedPackages: () => string[]; 35 | }; 36 | export type LoadingTypes = UpdaterLoadingTypes | InstallerLoadingTypes | MajorUpdaterLoadingTypes | 'queued-action'; 37 | export type CoreUpdate = { 38 | package: UpdatedPackage; 39 | extension: Extension; 40 | }; 41 | export default class ControlSectionState { 42 | loading: LoadingTypes; 43 | packageUpdates: Record; 44 | lastUpdateCheck: LastUpdateCheck; 45 | extensionUpdates: Extension[]; 46 | coreUpdate: CoreUpdate | null; 47 | get lastUpdateRun(): LastUpdateRun; 48 | constructor(); 49 | isLoading(name?: LoadingTypes): boolean; 50 | hasOperationRunning(): boolean; 51 | setLoading(name: LoadingTypes): void; 52 | requirePackage(data: any): void; 53 | checkForUpdates(): void; 54 | updateCoreMinor(): void; 55 | updateExtension(extension: Extension, updateMode: 'soft' | 'hard'): void; 56 | updateGlobally(): void; 57 | formatExtensionUpdates(lastUpdateCheck: LastUpdateCheck): Extension[]; 58 | formatCoreUpdate(lastUpdateCheck: LastUpdateCheck): CoreUpdate | null; 59 | majorUpdate({ dryRun }: { 60 | dryRun: boolean; 61 | }): void; 62 | } 63 | export {}; 64 | -------------------------------------------------------------------------------- /js/dist-typings/states/ExtensionListState.d.ts: -------------------------------------------------------------------------------- 1 | import PaginatedListState, { SortMap } from 'flarum/common/states/PaginatedListState'; 2 | import ExternalExtension from '../models/ExternalExtension'; 3 | export default class ExtensionListState extends PaginatedListState { 4 | get type(): string; 5 | constructor(); 6 | sortMap(): SortMap; 7 | } 8 | -------------------------------------------------------------------------------- /js/dist-typings/states/ExtensionManagerState.d.ts: -------------------------------------------------------------------------------- 1 | import QueueState from './QueueState'; 2 | import ControlSectionState from './ControlSectionState'; 3 | import ExtensionListState from './ExtensionListState'; 4 | export default class ExtensionManagerState { 5 | queue: QueueState; 6 | control: ControlSectionState; 7 | extensions: ExtensionListState; 8 | } 9 | -------------------------------------------------------------------------------- /js/dist-typings/states/PackageManagerState.d.ts: -------------------------------------------------------------------------------- 1 | import QueueState from './QueueState'; 2 | import ControlSectionState from './ControlSectionState'; 3 | export default class PackageManagerState { 4 | queue: QueueState; 5 | control: ControlSectionState; 6 | } 7 | -------------------------------------------------------------------------------- /js/dist-typings/states/QueueState.d.ts: -------------------------------------------------------------------------------- 1 | import Task from '../models/Task'; 2 | import { ApiQueryParamsPlural } from 'flarum/common/Store'; 3 | export default class QueueState { 4 | private polling; 5 | private tasks; 6 | private limit; 7 | private offset; 8 | private total; 9 | private loading; 10 | load(params?: ApiQueryParamsPlural, actionTaken?: boolean): Promise; 11 | isLoading(): boolean; 12 | getItems(): Task[] | null; 13 | getTotalItems(): number; 14 | getTotalPages(): number; 15 | pageNumber(): number; 16 | getPerPage(): number; 17 | hasPrev(): boolean; 18 | hasNext(): boolean; 19 | prev(): void; 20 | next(): void; 21 | goto(page: number): void; 22 | pollQueue(actionTaken?: boolean): void; 23 | hasPending(): boolean; 24 | } 25 | -------------------------------------------------------------------------------- /js/dist-typings/utils/errorHandler.d.ts: -------------------------------------------------------------------------------- 1 | export default function (e: any): void; 2 | -------------------------------------------------------------------------------- /js/dist-typings/utils/humanDuration.d.ts: -------------------------------------------------------------------------------- 1 | export default function humanDuration(start: Date, end: Date): string; 2 | -------------------------------------------------------------------------------- /js/dist-typings/utils/jumpToQueue.d.ts: -------------------------------------------------------------------------------- 1 | export default function jumpToQueue(): void; 2 | -------------------------------------------------------------------------------- /js/dist-typings/utils/versions.d.ts: -------------------------------------------------------------------------------- 1 | export declare enum VersionStability { 2 | Stable = "stable", 3 | Alpha = "alpha", 4 | Beta = "beta", 5 | RC = "rc", 6 | Dev = "dev" 7 | } 8 | export declare function isProductionReady(version: string): boolean; 9 | export declare function stability(version: string): VersionStability; 10 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flarum/extension-manager", 3 | "version": "0.0.0", 4 | "private": true, 5 | "prettier": "@flarum/prettier-config", 6 | "devDependencies": { 7 | "@flarum/prettier-config": "^1.0.0", 8 | "flarum-tsconfig": "^2.0.0", 9 | "flarum-webpack-config": "^3.0.0", 10 | "prettier": "^2.5.1", 11 | "typescript": "^4.5.4", 12 | "typescript-coverage-report": "^0.6.1", 13 | "webpack": "^5.76.0", 14 | "webpack-cli": "^4.9.1" 15 | }, 16 | "scripts": { 17 | "dev": "webpack --mode development --watch", 18 | "build": "webpack --mode production", 19 | "analyze": "cross-env ANALYZER=true yarn run build", 20 | "format": "prettier --write src", 21 | "format-check": "prettier --check src", 22 | "clean-typings": "npx rimraf dist-typings && mkdir dist-typings", 23 | "build-typings": "yarn run clean-typings && ([ -e src/@types ] && cp -r src/@types dist-typings/@types || true) && tsc && yarn run post-build-typings", 24 | "post-build-typings": "find dist-typings -type f -name '*.d.ts' -print0 | xargs -0 sed -i 's,../src/@types,@types,g'", 25 | "check-typings": "tsc --noEmit --emitDeclarationOnly false", 26 | "check-typings-coverage": "typescript-coverage-report" 27 | }, 28 | "dependencies": { 29 | "pretty-bytes": "^6.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /js/src/admin/components/AuthMethodModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 2 | import Mithril from 'mithril'; 3 | import app from 'flarum/admin/app'; 4 | import Select from 'flarum/common/components/Select'; 5 | import Stream from 'flarum/common/utils/Stream'; 6 | import Button from 'flarum/common/components/Button'; 7 | import extractText from 'flarum/common/utils/extractText'; 8 | import Form from 'flarum/common/components/Form'; 9 | 10 | export interface IAuthMethodModalAttrs extends IInternalModalAttrs { 11 | onsubmit: (type: string, host: string, token: string) => void; 12 | type?: string; 13 | host?: string; 14 | token?: string; 15 | } 16 | 17 | export default class AuthMethodModal extends Modal { 18 | protected type!: Stream; 19 | protected host!: Stream; 20 | protected token!: Stream; 21 | 22 | oninit(vnode: Mithril.Vnode) { 23 | super.oninit(vnode); 24 | 25 | this.type = Stream(this.attrs.type || 'bearer'); 26 | this.host = Stream(this.attrs.host || ''); 27 | this.token = Stream(this.attrs.token || ''); 28 | } 29 | 30 | className(): string { 31 | return 'AuthMethodModal Modal--small'; 32 | } 33 | 34 | title(): Mithril.Children { 35 | const context = this.attrs.host ? 'edit' : 'add'; 36 | return app.translator.trans(`flarum-extension-manager.admin.auth_config.${context}_label`); 37 | } 38 | 39 | content(): Mithril.Children { 40 | const types = { 41 | 'github-oauth': app.translator.trans('flarum-extension-manager.admin.auth_config.types.github-oauth'), 42 | 'gitlab-oauth': app.translator.trans('flarum-extension-manager.admin.auth_config.types.gitlab-oauth'), 43 | 'gitlab-token': app.translator.trans('flarum-extension-manager.admin.auth_config.types.gitlab-token'), 44 | bearer: app.translator.trans('flarum-extension-manager.admin.auth_config.types.bearer'), 45 | }; 46 | 47 | return ( 48 |
49 |
50 |
51 | 52 | 61 |
62 |
63 | 64 | 76 |
77 |
78 | 81 |
82 |
83 |
84 | ); 85 | } 86 | 87 | submit() { 88 | this.attrs.onsubmit(this.type(), this.host(), this.token()); 89 | this.hide(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /js/src/admin/components/ConfigureAuth.tsx: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import type Mithril from 'mithril'; 3 | import ConfigureJson, { IConfigureJson } from './ConfigureJson'; 4 | import Button from 'flarum/common/components/Button'; 5 | import AuthMethodModal from './AuthMethodModal'; 6 | import extractText from 'flarum/common/utils/extractText'; 7 | 8 | export default class ConfigureAuth extends ConfigureJson { 9 | protected type = 'auth'; 10 | 11 | title(): Mithril.Children { 12 | return app.translator.trans('flarum-extension-manager.admin.auth_config.title'); 13 | } 14 | 15 | className(): string { 16 | return 'ConfigureAuth'; 17 | } 18 | 19 | content(): Mithril.Children { 20 | const authSettings = Object.keys(this.settings); 21 | const hasAuthSettings = 22 | authSettings.length && 23 | authSettings.every((type) => { 24 | const data = this.settings[type](); 25 | 26 | return Array.isArray(data) ? data.length : Object.keys(data).length; 27 | }); 28 | 29 | return ( 30 |
31 | {hasAuthSettings ? ( 32 | authSettings.map((type) => { 33 | const hosts = this.settings[type](); 34 | 35 | return ( 36 |
37 | 38 |
39 | {Object.keys(hosts).map((host) => { 40 | const data = hosts[host] as string | Record; 41 | 42 | return ( 43 |
44 | 58 |
76 | ); 77 | })} 78 |
79 |
80 | ); 81 | }) 82 | ) : ( 83 | {app.translator.trans('flarum-extension-manager.admin.auth_config.no_auth_methods_configured')} 84 | )} 85 |
86 | ); 87 | } 88 | 89 | submitButton(): Mithril.Children[] { 90 | const items = super.submitButton(); 91 | 92 | items.push( 93 | 104 | ); 105 | 106 | return items; 107 | } 108 | 109 | onchange(oldHost: string | null, type: string, host: string, token: string) { 110 | const data = { ...this.setting(type)() }; 111 | 112 | if (oldHost) { 113 | delete data[oldHost]; 114 | } 115 | 116 | data[host] = token; 117 | 118 | this.setting(type)(data); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /js/src/admin/components/ConfigureComposer.tsx: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import type Mithril from 'mithril'; 3 | import ConfigureJson, { type IConfigureJson } from './ConfigureJson'; 4 | import Button from 'flarum/common/components/Button'; 5 | import extractText from 'flarum/common/utils/extractText'; 6 | import RepositoryModal from './RepositoryModal'; 7 | 8 | export type Repository = { 9 | type: 'composer' | 'vcs' | 'path'; 10 | url: string; 11 | }; 12 | 13 | export default class ConfigureComposer extends ConfigureJson { 14 | protected type = 'composer'; 15 | 16 | title(): Mithril.Children { 17 | return app.translator.trans('flarum-extension-manager.admin.composer.title'); 18 | } 19 | 20 | className(): string { 21 | return 'ConfigureComposer'; 22 | } 23 | 24 | content(): Mithril.Children { 25 | return ( 26 |
27 | {this.attrs.buildSettingComponent.call(this, { 28 | setting: 'minimum-stability', 29 | label: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.label'), 30 | help: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.help'), 31 | type: 'select', 32 | options: { 33 | stable: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.options.stable'), 34 | RC: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.options.rc'), 35 | beta: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.options.beta'), 36 | alpha: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.options.alpha'), 37 | dev: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.options.dev'), 38 | }, 39 | })} 40 |
41 | 42 |
{app.translator.trans('flarum-extension-manager.admin.composer.repositories.help')}
43 |
44 | {Object.keys(this.setting('repositories')() || {}).map((name) => { 45 | const repository = this.setting('repositories')()[name] as Repository; 46 | 47 | return ( 48 |
49 | 75 |
89 | ); 90 | })} 91 |
92 |
93 |
94 | ); 95 | } 96 | 97 | submitButton(): Mithril.Children[] { 98 | const items = super.submitButton(); 99 | 100 | items.push( 101 | 104 | ); 105 | 106 | return items; 107 | } 108 | 109 | onchange(repository: Repository, name: string) { 110 | this.setting('repositories')({ 111 | ...this.setting('repositories')(), 112 | [name]: repository, 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /js/src/admin/components/ConfigureJson.tsx: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import type Mithril from 'mithril'; 3 | import Component, { type ComponentAttrs } from 'flarum/common/Component'; 4 | import { type SettingsComponentOptions } from 'flarum/admin/components/AdminPage'; 5 | import FormGroup, { type CommonFieldOptions } from 'flarum/common/components/FormGroup'; 6 | import type ItemList from 'flarum/common/utils/ItemList'; 7 | import Stream from 'flarum/common/utils/Stream'; 8 | import Button from 'flarum/common/components/Button'; 9 | import classList from 'flarum/common/utils/classList'; 10 | 11 | export interface IConfigureJson extends ComponentAttrs { 12 | buildSettingComponent: (entry: ((this: this) => Mithril.Children) | SettingsComponentOptions) => Mithril.Children; 13 | } 14 | 15 | export default abstract class ConfigureJson extends Component { 16 | protected settings: Record> = {}; 17 | protected initialSettings: Record | null = null; 18 | protected loading: boolean = false; 19 | 20 | oninit(vnode: Mithril.Vnode) { 21 | super.oninit(vnode); 22 | 23 | this.submit(true); 24 | } 25 | 26 | protected abstract type: string; 27 | abstract title(): Mithril.Children; 28 | abstract content(): Mithril.Children; 29 | 30 | className(): string { 31 | return ''; 32 | } 33 | 34 | view(): Mithril.Children { 35 | return ( 36 |
37 | 38 | {this.content()} 39 |
{this.submitButton()}
40 |
41 | ); 42 | } 43 | 44 | submitButton(): Mithril.Children[] { 45 | return [ 46 | , 49 | ]; 50 | } 51 | 52 | customSettingComponents(): ItemList<(attributes: CommonFieldOptions) => Mithril.Children> { 53 | return FormGroup.prototype.customFieldComponents(); 54 | } 55 | 56 | setting(key: string) { 57 | return this.settings[key] ?? (this.settings[key] = Stream()); 58 | } 59 | 60 | submit(readOnly: boolean) { 61 | this.loading = true; 62 | 63 | const configuration: any = {}; 64 | 65 | Object.keys(this.settings).forEach((key) => { 66 | configuration[key] = this.settings[key](); 67 | }); 68 | 69 | app 70 | .request({ 71 | method: 'POST', 72 | url: app.forum.attribute('apiUrl') + '/extension-manager/composer', 73 | body: { 74 | type: this.type, 75 | data: readOnly ? null : configuration, 76 | }, 77 | }) 78 | .then(({ data }: any) => { 79 | Object.keys(data).forEach((key) => { 80 | this.settings[key] = Stream(data[key]); 81 | }); 82 | 83 | this.initialSettings = Array.isArray(data) ? {} : data; 84 | }) 85 | .finally(() => { 86 | this.loading = false; 87 | m.redraw(); 88 | }); 89 | } 90 | 91 | isDirty() { 92 | return JSON.stringify(this.initialSettings) !== JSON.stringify(this.settings); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /js/src/admin/components/ControlSection.tsx: -------------------------------------------------------------------------------- 1 | import Component from 'flarum/common/Component'; 2 | import { ComponentAttrs } from 'flarum/common/Component'; 3 | 4 | import Installer from './Installer'; 5 | import Updater from './Updater'; 6 | import Mithril from 'mithril'; 7 | import Form from 'flarum/common/components/Form'; 8 | 9 | export default class ControlSection extends Component { 10 | oninit(vnode: Mithril.Vnode) { 11 | super.oninit(vnode); 12 | } 13 | 14 | view() { 15 | return ( 16 |
17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /js/src/admin/components/Installer.tsx: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import app from 'flarum/admin/app'; 3 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 4 | import Button from 'flarum/common/components/Button'; 5 | import Stream from 'flarum/common/utils/Stream'; 6 | 7 | export interface InstallerAttrs extends ComponentAttrs {} 8 | 9 | export type InstallerLoadingTypes = 'extension-install' | null; 10 | 11 | export default class Installer extends Component { 12 | packageName!: Stream; 13 | 14 | oninit(vnode: Mithril.Vnode): void { 15 | super.oninit(vnode); 16 | 17 | this.packageName = Stream(''); 18 | } 19 | 20 | view(): Mithril.Children { 21 | return ( 22 |
23 | 24 |
25 | {app.translator.trans('flarum-extension-manager.admin.extensions.install_help', { 26 | link: flarum.org, 27 | semantic_link: , 28 | code: , 29 | })} 30 |
31 |
32 | 33 | 42 |
43 |
44 | ); 45 | } 46 | 47 | data(): any { 48 | return { 49 | package: this.packageName(), 50 | }; 51 | } 52 | 53 | onsubmit(): void { 54 | app.extensionManager.control.requirePackage(this.data()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /js/src/admin/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 3 | import classList from 'flarum/common/utils/classList'; 4 | 5 | interface LabelAttrs extends ComponentAttrs { 6 | type: 'success' | 'error' | 'neutral' | 'warning'; 7 | } 8 | 9 | export default class Label extends Component { 10 | view(vnode: Mithril.Vnode) { 11 | const { className, type, ...attrs } = this.attrs; 12 | 13 | return ( 14 | 15 | {vnode.children} 16 | 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /js/src/admin/components/MajorUpdater.tsx: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import app from 'flarum/admin/app'; 3 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 4 | import Button from 'flarum/common/components/Button'; 5 | import Tooltip from 'flarum/common/components/Tooltip'; 6 | import Alert from 'flarum/common/components/Alert'; 7 | 8 | import { UpdatedPackage, UpdateState } from '../states/ControlSectionState'; 9 | import WhyNotModal from './WhyNotModal'; 10 | import ExtensionCard from './ExtensionCard'; 11 | import classList from 'flarum/common/utils/classList'; 12 | 13 | export interface MajorUpdaterAttrs extends ComponentAttrs { 14 | coreUpdate: UpdatedPackage; 15 | updateState: UpdateState; 16 | } 17 | 18 | export type MajorUpdaterLoadingTypes = 'major-update' | 'major-update-dry-run'; 19 | 20 | export default class MajorUpdater extends Component { 21 | updateState!: UpdateState; 22 | 23 | oninit(vnode: Mithril.Vnode) { 24 | super.oninit(vnode); 25 | 26 | this.updateState = this.attrs.updateState; 27 | } 28 | 29 | view(): Mithril.Children { 30 | return ( 31 |
37 | flarum logo 38 | 41 |

{app.translator.trans('flarum-extension-manager.admin.major_updater.description')}

42 |
43 | 44 | 52 | 53 | 61 |
62 | {this.updateState.incompatibleExtensions.length ? ( 63 |
64 | {this.updateState.incompatibleExtensions.map((extension: string) => ( 65 | 71 | ))} 72 |
73 | ) : null} 74 | {this.updateState.status === 'failure' ? ( 75 | app.modal.show(WhyNotModal, { package: 'flarum/core' })} 84 | > 85 | {app.translator.trans('flarum-extension-manager.admin.major_updater.failure.why')} 86 | , 87 | ]} 88 | > 89 |

90 | {app.translator.trans('flarum-extension-manager.admin.major_updater.failure.desc')} 91 |

92 |
93 | ) : null} 94 |
95 | ); 96 | } 97 | 98 | update(dryRun: boolean) { 99 | app.extensionManager.control.majorUpdate({ dryRun }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /js/src/admin/components/RepositoryModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 2 | import Mithril from 'mithril'; 3 | import app from 'flarum/admin/app'; 4 | import Select from 'flarum/common/components/Select'; 5 | import Stream from 'flarum/common/utils/Stream'; 6 | import Button from 'flarum/common/components/Button'; 7 | import { type Repository } from './ConfigureComposer'; 8 | import Form from 'flarum/common/components/Form'; 9 | 10 | export interface IRepositoryModalAttrs extends IInternalModalAttrs { 11 | onsubmit: (repository: Repository, key: string) => void; 12 | name?: string; 13 | repository?: Repository; 14 | } 15 | 16 | export default class RepositoryModal extends Modal { 17 | protected name!: Stream; 18 | protected repository!: Stream; 19 | 20 | oninit(vnode: Mithril.Vnode) { 21 | super.oninit(vnode); 22 | 23 | this.name = Stream(this.attrs.name || ''); 24 | this.repository = Stream(this.attrs.repository || { type: 'composer', url: '' }); 25 | } 26 | 27 | className(): string { 28 | return 'RepositoryModal Modal--small'; 29 | } 30 | 31 | title(): Mithril.Children { 32 | const context = this.attrs.repository ? 'edit' : 'add'; 33 | return app.translator.trans(`flarum-extension-manager.admin.composer.${context}_repository_label`); 34 | } 35 | 36 | content(): Mithril.Children { 37 | const types = { 38 | composer: app.translator.trans('flarum-extension-manager.admin.composer.repositories.types.composer'), 39 | vcs: app.translator.trans('flarum-extension-manager.admin.composer.repositories.types.vcs'), 40 | path: app.translator.trans('flarum-extension-manager.admin.composer.repositories.types.path'), 41 | }; 42 | 43 | return ( 44 |
45 |
46 |
47 | 48 | 49 |
50 |
51 | 52 | this.repository({ ...this.repository(), url: (e.target as HTMLInputElement).value })} 63 | value={this.repository().url} 64 | /> 65 |
66 |
67 | 70 |
71 |
72 |
73 | ); 74 | } 75 | 76 | submit() { 77 | this.attrs.onsubmit(this.repository(), this.name()); 78 | this.hide(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /js/src/admin/components/SettingsPage.tsx: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import app from 'flarum/admin/app'; 3 | import ExtensionPage, { ExtensionPageAttrs } from 'flarum/admin/components/ExtensionPage'; 4 | import ItemList from 'flarum/common/utils/ItemList'; 5 | 6 | import QueueSection from './QueueSection'; 7 | import ControlSection from './ControlSection'; 8 | import ConfigureComposer from './ConfigureComposer'; 9 | import ConfigureAuth from './ConfigureAuth'; 10 | import DiscoverSection from './DiscoverSection'; 11 | import Alert from 'flarum/common/components/Alert'; 12 | 13 | export default class SettingsPage extends ExtensionPage { 14 | content() { 15 | const settings = app.registry.getSettings(this.extension.id); 16 | 17 | return ( 18 |
19 |
20 | {settings ? ( 21 | [ 22 |
23 | 24 |
{app.translator.trans('flarum-extension-manager.admin.sections.settings.description')}
25 |
, 26 |
27 |
28 | 29 |
{settings.map(this.buildSettingComponent.bind(this))}
30 |
{this.submitButton()}
31 |
32 | 33 | 34 |
, 35 | ] 36 | ) : ( 37 |

{app.translator.trans('core.admin.extension.no_settings')}

38 | )} 39 |
40 |
41 | ); 42 | } 43 | 44 | sections(vnode: Mithril.VnodeDOM): ItemList { 45 | const items = super.sections(vnode); 46 | 47 | const writableDirs = app.data['flarum-extension-manager.writable_dirs']; 48 | const missingFunctions = app.data['flarum-extension-manager.missing_functions'] as string[] | undefined; 49 | const usable = writableDirs && (!missingFunctions || missingFunctions.length === 0); 50 | 51 | if (usable) { 52 | items.add('discover', , 15); 53 | 54 | items.add('control', , 10); 55 | } else { 56 | items.add( 57 | 'warning', 58 |
59 |
60 |
61 | 62 | {!app.data['flarum-extension-manager.writable_dirs'] 63 | ? app.translator.trans('flarum-extension-manager.admin.file_permissions') 64 | : app.translator.trans('flarum-extension-manager.admin.required_php_functions', { 65 | functions: (app.data['flarum-extension-manager.missing_functions'] as string[]).join(', '), 66 | })} 67 | 68 |
69 |
70 |
, 71 | 10 72 | ); 73 | } 74 | 75 | items.setPriority('content', 8); 76 | 77 | if (app.data.settings['flarum-extension-manager.queue_jobs'] !== '0' && app.data.settings['flarum-extension-manager.queue_jobs']) { 78 | items.add('queue', , 5); 79 | } 80 | 81 | items.remove('permissions'); 82 | 83 | return items; 84 | } 85 | 86 | onsaved() { 87 | super.onsaved(); 88 | m.redraw(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /js/src/admin/components/TaskOutputModal.tsx: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 3 | import Task from '../models/Task'; 4 | 5 | interface TaskOutputModalAttrs extends IInternalModalAttrs { 6 | task: Task; 7 | } 8 | 9 | export default class TaskOutputModal extends Modal { 10 | className() { 11 | return 'Modal--large QuickModal'; 12 | } 13 | 14 | title() { 15 | return app.translator.trans(`flarum-extension-manager.admin.sections.queue.operations.${this.attrs.task.operation()}`); 16 | } 17 | 18 | content() { 19 | return ( 20 |
21 |
22 | {this.attrs.task.status() === 'failure' && ( 23 |
24 | 25 |
26 | {(this.attrs.task.guessedCause() && 27 | app.translator.trans('flarum-extension-manager.admin.exceptions.guessed_cause.' + this.attrs.task.guessedCause())) || 28 | app.translator.trans('flarum-extension-manager.admin.sections.queue.output_modal.cause_unknown')} 29 |
30 |
31 | )} 32 | 33 |
34 | 35 |
36 | $ composer {this.attrs.task.command()} 37 |
38 |
39 | 40 |
41 | 42 |
43 | 44 |
{this.attrs.task.output()}
45 |
46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /js/src/admin/components/Updater.tsx: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 3 | import Button from 'flarum/common/components/Button'; 4 | import humanTime from 'flarum/common/helpers/humanTime'; 5 | import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; 6 | import MajorUpdater from './MajorUpdater'; 7 | import ItemList from 'flarum/common/utils/ItemList'; 8 | import InfoTile from 'flarum/common/components/InfoTile'; 9 | import ExtensionCard from './ExtensionCard'; 10 | import { isProductionReady } from '../utils/versions'; 11 | 12 | export interface IUpdaterAttrs extends ComponentAttrs {} 13 | 14 | export type UpdaterLoadingTypes = 'check' | 'minor-update' | 'global-update' | 'extension-update' | null; 15 | 16 | export default class Updater extends Component { 17 | view() { 18 | const core = app.extensionManager.control.coreUpdate; 19 | 20 | return [ 21 |
22 | 23 |
{app.translator.trans('flarum-extension-manager.admin.updater.updater_help')}
24 | {this.lastUpdateCheckView()} 25 |
{this.controlItems().toArray()}
26 | {this.availableUpdatesView()} 27 |
, 28 | core && core.package['latest-major'] && isProductionReady(core.package['latest-major']) ? ( 29 | 30 | ) : null, 31 | ]; 32 | } 33 | 34 | lastUpdateCheckView() { 35 | return ( 36 | (app.extensionManager.control.lastUpdateCheck?.checkedAt && ( 37 |

38 | 39 | {app.translator.trans('flarum-extension-manager.admin.updater.last_update_checked_at')} 40 | 41 | {humanTime(app.extensionManager.control.lastUpdateCheck.checkedAt)} 42 |

43 | )) || 44 | null 45 | ); 46 | } 47 | 48 | availableUpdatesView() { 49 | const state = app.extensionManager.control; 50 | 51 | if (app.extensionManager.control.isLoading('check') || app.extensionManager.control.isLoading('global-update')) { 52 | return ( 53 |
54 | 55 |
56 | ); 57 | } 58 | 59 | const hasMinorCoreUpdate = state.coreUpdate && state.coreUpdate.package['latest-minor']; 60 | 61 | if (!(state.extensionUpdates.length || hasMinorCoreUpdate)) { 62 | return ( 63 |
64 | {app.translator.trans('flarum-extension-manager.admin.updater.up_to_date')} 65 |
66 | ); 67 | } 68 | 69 | return ( 70 |
71 |
72 | {hasMinorCoreUpdate ? ( 73 | state.updateCoreMinor()} 78 | whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')} 79 | /> 80 | ) : null} 81 | {state.extensionUpdates.map((extension) => ( 82 | state.updateExtension(extension, 'soft'), 87 | hard: () => state.updateExtension(extension, 'hard'), 88 | }} 89 | whyNotWarning={state.lastUpdateRun.limitedPackages().includes(extension.name)} 90 | /> 91 | ))} 92 |
93 |
94 | ); 95 | } 96 | 97 | controlItems() { 98 | const items = new ItemList(); 99 | 100 | items.add( 101 | 'updateCheck', 102 | , 111 | 100 112 | ); 113 | 114 | items.add( 115 | 'globalUpdate', 116 | 125 | ); 126 | 127 | return items; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /js/src/admin/components/WhyNotModal.tsx: -------------------------------------------------------------------------------- 1 | import type Mithril from 'mithril'; 2 | import app from 'flarum/admin/app'; 3 | import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; 4 | import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; 5 | 6 | import errorHandler from '../utils/errorHandler'; 7 | 8 | type WhyNotResponse = { 9 | data: { 10 | reason: string; 11 | }; 12 | }; 13 | 14 | export interface WhyNotModalAttrs extends IInternalModalAttrs { 15 | package: string; 16 | } 17 | 18 | export default class WhyNotModal extends Modal { 19 | loading: boolean = true; 20 | whyNot: string | null = null; 21 | 22 | className() { 23 | return 'Modal--large WhyNotModal'; 24 | } 25 | 26 | title() { 27 | return app.translator.trans('flarum-extension-manager.admin.why_not_modal.title'); 28 | } 29 | 30 | oncreate(vnode: Mithril.VnodeDOM) { 31 | super.oncreate(vnode); 32 | 33 | this.requestWhyNot(); 34 | } 35 | 36 | content() { 37 | return
{this.loading ? :
{this.whyNot}
}
; 38 | } 39 | 40 | requestWhyNot(): void { 41 | app 42 | .request({ 43 | method: 'POST', 44 | url: `${app.forum.attribute('apiUrl')}/extension-manager/why-not`, 45 | body: { 46 | data: { 47 | package: this.attrs.package, 48 | }, 49 | }, 50 | }) 51 | .then((response) => { 52 | this.loading = false; 53 | this.whyNot = response.data.reason; 54 | m.redraw(); 55 | }) 56 | .catch(errorHandler); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /js/src/admin/extend.tsx: -------------------------------------------------------------------------------- 1 | import Extend from 'flarum/common/extenders'; 2 | import app from 'flarum/admin/app'; 3 | import extractText from 'flarum/common/utils/extractText'; 4 | import SettingsPage from './components/SettingsPage'; 5 | import Task from './models/Task'; 6 | import ExternalExtension from './models/ExternalExtension'; 7 | 8 | export default [ 9 | new Extend.Store() // 10 | .add('extension-manager-tasks', Task) 11 | .add('external-extensions', ExternalExtension), 12 | 13 | new Extend.Admin() 14 | .setting(() => ({ 15 | setting: 'flarum-extension-manager.queue_jobs', 16 | label: app.translator.trans('flarum-extension-manager.admin.settings.queue_jobs'), 17 | help: app.translator.trans('flarum-extension-manager.admin.settings.queue_jobs_help', { 18 | basic_impl_link:
, 19 | adv_impl_link: , 20 | php_version: {app.data.phpVersion}, 21 | folder_perms_link: , 22 | }), 23 | type: 'boolean', 24 | disabled: app.data['flarum-extension-manager.using_sync_queue'], 25 | })) 26 | .setting(() => ({ 27 | setting: 'flarum-extension-manager.task_retention_days', 28 | label: app.translator.trans('flarum-extension-manager.admin.settings.task_retention_days'), 29 | help: app.translator.trans('flarum-extension-manager.admin.settings.task_retention_days_help'), 30 | type: 'number', 31 | })) 32 | .page(SettingsPage) 33 | .generalIndexItems('settings', () => [ 34 | { 35 | id: 'minimum-stability', 36 | label: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.label', {}, true), 37 | help: app.translator.trans('flarum-extension-manager.admin.composer.minimum_stability.help', {}, true), 38 | }, 39 | { 40 | id: 'repositories', 41 | label: app.translator.trans('flarum-extension-manager.admin.composer.repositories.label', {}, true), 42 | help: app.translator.trans('flarum-extension-manager.admin.composer.repositories.help', {}, true), 43 | }, 44 | { 45 | id: 'composer-auth', 46 | label: app.translator.trans('flarum-extension-manager.admin.auth_config.title', {}, true), 47 | }, 48 | { 49 | id: 'updates', 50 | label: app.translator.trans('flarum-extension-manager.admin.updater.updater_title', {}, true), 51 | help: app.translator.trans('flarum-extension-manager.admin.updater.updater_help', {}, true), 52 | }, 53 | ]), 54 | ]; 55 | -------------------------------------------------------------------------------- /js/src/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { extend } from 'flarum/common/extend'; 2 | import app from 'flarum/admin/app'; 3 | import ExtensionPage from 'flarum/admin/components/ExtensionPage'; 4 | import Button from 'flarum/common/components/Button'; 5 | import LoadingModal from 'flarum/admin/components/LoadingModal'; 6 | import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled'; 7 | import jumpToQueue from './utils/jumpToQueue'; 8 | import { AsyncBackendResponse } from './shims'; 9 | import ExtensionManagerState from './states/ExtensionManagerState'; 10 | 11 | export { default as extend } from './extend'; 12 | 13 | app.initializers.add('flarum-extension-manager', (app) => { 14 | app.extensionManager = new ExtensionManagerState(); 15 | 16 | if (app.data['flarum-extension-manager.using_sync_queue']) { 17 | app.data.settings['flarum-extension-manager.queue_jobs'] = '0'; 18 | } 19 | 20 | extend(ExtensionPage.prototype, 'topItems', function (items) { 21 | if (this.extension.id === 'flarum-extension-manager' || isExtensionEnabled(this.extension.id)) { 22 | return; 23 | } 24 | 25 | items.add( 26 | 'remove', 27 | 53 | ); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /js/src/admin/models/ExternalExtension.ts: -------------------------------------------------------------------------------- 1 | import Model from 'flarum/common/Model'; 2 | import app from 'flarum/admin/app'; 3 | import type { Extension } from 'flarum/admin/AdminApplication'; 4 | import { isProductionReady } from '../utils/versions'; 5 | 6 | export default class ExternalExtension extends Model { 7 | extensionId = Model.attribute('extensionId'); 8 | name = Model.attribute('name'); 9 | title = Model.attribute('title'); 10 | description = Model.attribute('description'); 11 | iconUrl = Model.attribute('iconUrl'); 12 | icon = Model.attribute<{ 13 | name: string; 14 | [key: string]: string; 15 | }>('icon'); 16 | highestVersion = Model.attribute('highestVersion'); 17 | httpUri = Model.attribute('httpUri'); 18 | discussUri = Model.attribute('discussUri'); 19 | vendor = Model.attribute('vendor'); 20 | isPremium = Model.attribute('isPremium'); 21 | isLocale = Model.attribute('isLocale'); 22 | locale = Model.attribute('locale'); 23 | latestFlarumVersionSupported = Model.attribute('latestFlarumVersionSupported'); 24 | downloads = Model.attribute('downloads'); 25 | isSupported = Model.attribute('isSupported'); 26 | readonly installed = false; 27 | 28 | public isProductionReady(): boolean { 29 | return isProductionReady(this.highestVersion()); 30 | } 31 | 32 | public toLocalExtension(): Extension { 33 | return { 34 | id: this.extensionId(), 35 | name: this.name(), 36 | version: this.highestVersion(), 37 | description: this.description(), 38 | icon: this.icon() || { 39 | name: 'fas fa-box-open', 40 | backgroundColor: '#117187', 41 | color: '#fff', 42 | }, 43 | links: { 44 | discuss: this.discussUri(), 45 | website: this.httpUri(), 46 | }, 47 | extra: { 48 | 'flarum-extension': { 49 | title: this.title(), 50 | }, 51 | }, 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /js/src/admin/models/Task.ts: -------------------------------------------------------------------------------- 1 | import Model from 'flarum/common/Model'; 2 | import prettyBytes from 'pretty-bytes'; 3 | 4 | export type TaskOperations = 5 | | 'extension_install' 6 | | 'extension_remove' 7 | | 'extension_update' 8 | | 'update_global' 9 | | 'update_minor' 10 | | 'update_major' 11 | | 'update_check' 12 | | 'why_not'; 13 | 14 | export default class Task extends Model { 15 | status() { 16 | return Model.attribute<'pending' | 'running' | 'failure' | 'success'>('status').call(this); 17 | } 18 | 19 | operation() { 20 | return Model.attribute('operation').call(this); 21 | } 22 | 23 | command() { 24 | return Model.attribute('command').call(this); 25 | } 26 | 27 | package() { 28 | return Model.attribute('package').call(this); 29 | } 30 | 31 | output() { 32 | return Model.attribute('output').call(this); 33 | } 34 | 35 | guessedCause() { 36 | return Model.attribute('guessedCause').call(this); 37 | } 38 | 39 | createdAt() { 40 | return Model.attribute('createdAt', Model.transformDate).call(this); 41 | } 42 | 43 | startedAt() { 44 | return Model.attribute('startedAt', Model.transformDate).call(this); 45 | } 46 | 47 | finishedAt() { 48 | return Model.attribute('finishedAt', Model.transformDate).call(this); 49 | } 50 | 51 | peakMemoryUsed() { 52 | return prettyBytes(Model.attribute('peakMemoryUsed').call(this) * 1024); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /js/src/admin/shims.d.ts: -------------------------------------------------------------------------------- 1 | import 'dayjs/plugin/relativeTime'; 2 | import ExtensionManagerState from './states/ExtensionManagerState'; 3 | 4 | export interface AsyncBackendResponse { 5 | processing: boolean; 6 | } 7 | 8 | declare module 'flarum/admin/AdminApplication' { 9 | export default interface AdminApplication { 10 | extensionManager: ExtensionManagerState; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /js/src/admin/states/ExtensionListState.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import PaginatedListState, { SortMap } from 'flarum/common/states/PaginatedListState'; 3 | import ExternalExtension from '../models/ExternalExtension'; 4 | 5 | export default class ExtensionListState extends PaginatedListState { 6 | get type(): string { 7 | return 'external-extensions'; 8 | } 9 | 10 | constructor() { 11 | super( 12 | { 13 | sort: '-downloads', 14 | }, 15 | 1, 16 | 12 17 | ); 18 | } 19 | 20 | sortMap(): SortMap { 21 | return { 22 | '-createdAt': { 23 | sort: '-createdAt', 24 | label: app.translator.trans('flarum-extension-manager.admin.sections.discover.sort.latest', {}, true), 25 | }, 26 | '-downloads': { 27 | sort: '-downloads', 28 | label: app.translator.trans('flarum-extension-manager.admin.sections.discover.sort.top', {}, true), 29 | }, 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /js/src/admin/states/ExtensionManagerState.ts: -------------------------------------------------------------------------------- 1 | import QueueState from './QueueState'; 2 | import ControlSectionState from './ControlSectionState'; 3 | import ExtensionListState from './ExtensionListState'; 4 | 5 | export default class ExtensionManagerState { 6 | public queue: QueueState = new QueueState(); 7 | public control: ControlSectionState = new ControlSectionState(); 8 | public extensions: ExtensionListState = new ExtensionListState(); 9 | } 10 | -------------------------------------------------------------------------------- /js/src/admin/states/PackageManagerState.ts: -------------------------------------------------------------------------------- 1 | import QueueState from './QueueState'; 2 | import ControlSectionState from './ControlSectionState'; 3 | 4 | export default class PackageManagerState { 5 | public queue: QueueState = new QueueState(); 6 | public control: ControlSectionState = new ControlSectionState(); 7 | } 8 | -------------------------------------------------------------------------------- /js/src/admin/states/QueueState.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import Task from '../models/Task'; 3 | import { ApiQueryParamsPlural } from 'flarum/common/Store'; 4 | 5 | export default class QueueState { 6 | private polling: any = null; 7 | private tasks: Task[] | null = null; 8 | private limit = 20; 9 | private offset = 0; 10 | private total = 0; 11 | private loading = false; 12 | 13 | load(params?: ApiQueryParamsPlural, actionTaken = false): Promise { 14 | this.loading = true; 15 | params = { 16 | page: { 17 | limit: this.limit, 18 | offset: this.offset, 19 | ...params?.page, 20 | }, 21 | ...params, 22 | }; 23 | 24 | return app.store.find('extension-manager-tasks', params || {}).then((data) => { 25 | this.tasks = data; 26 | this.total = data.payload.meta?.page?.total || 0; 27 | 28 | m.redraw(); 29 | 30 | // Check if there is a pending or running task 31 | const pendingTask = data?.find((task) => task.status() === 'pending' || task.status() === 'running'); 32 | 33 | if (pendingTask) { 34 | this.pollQueue(actionTaken); 35 | } else if (actionTaken) { 36 | app.extensionManager.control.setLoading(null); 37 | 38 | // Refresh the page 39 | window.location.reload(); 40 | } else if (app.extensionManager.control.isLoading()) { 41 | app.extensionManager.control.setLoading(null); 42 | } 43 | 44 | this.loading = false; 45 | 46 | return data; 47 | }); 48 | } 49 | 50 | isLoading() { 51 | return this.loading; 52 | } 53 | 54 | getItems() { 55 | return this.tasks; 56 | } 57 | 58 | getTotalItems() { 59 | return this.total; 60 | } 61 | 62 | getTotalPages(): number { 63 | return Math.ceil(this.total / this.limit); 64 | } 65 | 66 | pageNumber(): number { 67 | return Math.ceil(this.offset / this.limit); 68 | } 69 | 70 | getPerPage() { 71 | return this.limit; 72 | } 73 | 74 | hasPrev(): boolean { 75 | return this.pageNumber() !== 0; 76 | } 77 | 78 | hasNext(): boolean { 79 | return this.offset + this.limit < this.total; 80 | } 81 | 82 | prev(): void { 83 | if (this.hasPrev()) { 84 | this.offset -= this.limit; 85 | this.load(); 86 | } 87 | } 88 | 89 | next(): void { 90 | if (this.hasNext()) { 91 | this.offset += this.limit; 92 | this.load(); 93 | } 94 | } 95 | 96 | goto(page: number): void { 97 | this.offset = (page - 1) * this.limit; 98 | this.load(); 99 | } 100 | 101 | pollQueue(actionTaken = false): void { 102 | if (this.polling) { 103 | clearTimeout(this.polling); 104 | } 105 | 106 | this.polling = setTimeout(() => { 107 | this.load({}, actionTaken); 108 | }, 6000); 109 | } 110 | 111 | hasPending() { 112 | return !!this.tasks?.find((task) => task.status() === 'pending' || task.status() === 'running'); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /js/src/admin/utils/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | 3 | export default function (e: any) { 4 | app.extensionManager.control.setLoading(null); 5 | 6 | const error = e.response.errors[0]; 7 | 8 | if (!['composer_command_failure', 'extension_already_installed', 'extension_not_installed'].includes(error.code)) { 9 | throw e; 10 | } 11 | 12 | app.alerts.clear(); 13 | 14 | switch (error.code) { 15 | case 'composer_command_failure': 16 | if (error.guessed_cause) { 17 | app.alerts.show({ type: 'error' }, app.translator.trans(`flarum-extension-manager.admin.exceptions.guessed_cause.${error.guessed_cause}`)); 18 | app.modal.close(); 19 | } else { 20 | app.alerts.show({ type: 'error' }, app.translator.trans('flarum-extension-manager.admin.exceptions.composer_command_failure')); 21 | } 22 | break; 23 | 24 | case 'extension_already_installed': 25 | app.alerts.show({ type: 'error' }, app.translator.trans('flarum-extension-manager.admin.exceptions.extension_already_installed')); 26 | app.modal.close(); 27 | break; 28 | 29 | case 'extension_not_installed': 30 | app.alerts.show({ type: 'error' }, app.translator.trans('flarum-extension-manager.admin.exceptions.extension_not_installed')); 31 | app.modal.close(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /js/src/admin/utils/humanDuration.ts: -------------------------------------------------------------------------------- 1 | import duration from 'dayjs/plugin/duration'; 2 | 3 | export default function humanDuration(start: Date, end: Date) { 4 | dayjs.extend(duration); 5 | 6 | const durationTime = dayjs(end).diff(start); 7 | 8 | return dayjs.duration(durationTime).humanize(); 9 | } 10 | -------------------------------------------------------------------------------- /js/src/admin/utils/jumpToQueue.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | 3 | // @ts-ignore 4 | window.jumpToQueue = jumpToQueue; 5 | 6 | export default function jumpToQueue(): void { 7 | app.modal.close(); 8 | 9 | m.route.set(app.route('extension', { id: 'flarum-extension-manager' })); 10 | 11 | app.extensionManager.queue.load({}, true); 12 | 13 | setTimeout(() => { 14 | document.getElementById('ExtensionManager-queueSection')?.scrollIntoView({ block: 'nearest' }); 15 | }, 200); 16 | } 17 | -------------------------------------------------------------------------------- /js/src/admin/utils/versions.ts: -------------------------------------------------------------------------------- 1 | export enum VersionStability { 2 | Stable = 'stable', 3 | Alpha = 'alpha', 4 | Beta = 'beta', 5 | RC = 'rc', 6 | Dev = 'dev', 7 | } 8 | 9 | export function isProductionReady(version: string): boolean { 10 | return [VersionStability.Stable].includes(stability(version)); 11 | } 12 | 13 | export function stability(version: string): VersionStability { 14 | const split = version.split('-'); 15 | 16 | if (split.length === 1) { 17 | return VersionStability.Stable; 18 | } 19 | 20 | const stab = split[1].split('.')[0].toLowerCase(); 21 | 22 | switch (stab) { 23 | case 'alpha': 24 | return VersionStability.Alpha; 25 | case 'beta': 26 | return VersionStability.Beta; 27 | case 'rc': 28 | return VersionStability.RC; 29 | default: 30 | return VersionStability.Dev; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use Flarum's tsconfig as a starting point 3 | "extends": "flarum-tsconfig", 4 | // This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder 5 | // and also tells your Typescript server to read core's global typings for 6 | // access to `dayjs` and `$` in the global namespace. 7 | "include": ["src/**/*", "../../../framework/core/js/dist-typings/@types/**/*", "@types/**/*"], 8 | "files": ["src/admin/shims.d.ts"], 9 | "compilerOptions": { 10 | // This will output typings to `dist-typings` 11 | "declarationDir": "./dist-typings", 12 | "paths": { 13 | "flarum/*": ["../../../framework/core/js/dist-typings/*"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('flarum-webpack-config')(); 2 | -------------------------------------------------------------------------------- /less/admin.less: -------------------------------------------------------------------------------- 1 | @import "admin/Label"; 2 | @import "admin/TaskOutputModal"; 3 | @import "admin/QueueSection"; 4 | @import "admin/ControlSection"; 5 | @import "admin/DiscoverSection"; 6 | @import "admin/ExtensionCard"; 7 | 8 | .ExtensionManager-controlSection { 9 | > .container { 10 | padding-bottom: 0; 11 | } 12 | } 13 | 14 | .FormControl-container { 15 | display: flex; 16 | align-items: center; 17 | flex-wrap: wrap; 18 | gap: 4px; 19 | } 20 | 21 | .ButtonGroup--full { 22 | width: 100%; 23 | display: flex; 24 | 25 | > .Button:first-child { 26 | flex-grow: 1; 27 | text-align: start; 28 | } 29 | } 30 | 31 | .ConfigureAuth-hosts, .ConfigureComposer-repositories { 32 | > .ButtonGroup { 33 | margin-bottom: 8px; 34 | } 35 | } 36 | 37 | .flarum-extension-manager-Page .SettingsGroups .Form { 38 | max-height: unset; 39 | } 40 | 41 | .ExtensionManager-SettingsGroups { 42 | display: flex; 43 | column-count: 3; 44 | column-gap: 30px; 45 | flex-wrap: wrap; 46 | margin-top: 24px; 47 | 48 | .FormSection { 49 | min-width: 300px; 50 | max-height: 500px; 51 | min-height: 20vh; 52 | max-width: 400px; 53 | 54 | @media (max-width: 1209px) { 55 | margin-bottom: 20px; 56 | } 57 | 58 | .Form-controls { 59 | margin-top: auto; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /less/admin/ControlSection.less: -------------------------------------------------------------------------------- 1 | .ExtensionManager-lastUpdatedAt { 2 | color: var(--control-color); 3 | 4 | &-label { 5 | font-weight: bold; 6 | } 7 | } 8 | 9 | .ExtensionManager-updaterControls { 10 | display: flex; 11 | flex-wrap: wrap; 12 | gap: 8px; 13 | grid-area: controls; 14 | margin-bottom: 16px; 15 | } 16 | 17 | .ExtensionManager-extensions { 18 | width: 100%; 19 | 20 | &-grid { 21 | --gap: 12px; 22 | display: grid; 23 | grid-template-columns: repeat(auto-fit, 310px); 24 | gap: var(--gap); 25 | } 26 | } 27 | 28 | .ExtensionManager-majorUpdate { 29 | --space: 16px; 30 | padding: var(--space); 31 | display: grid; 32 | grid-template-areas: 33 | "title logo" 34 | "helpText logo" 35 | "controls logo"; 36 | column-gap: 0 var(--space); 37 | align-items: center; 38 | 39 | &--failed&--incompatibleExtensions { 40 | grid-template-areas: 41 | "title logo" 42 | "helpText logo" 43 | "controls logo" 44 | "extensions extensions" 45 | "failure failure"; 46 | } 47 | 48 | &--failed { 49 | grid-template-areas: 50 | "title logo" 51 | "helpText logo" 52 | "controls logo" 53 | "failure failure"; 54 | } 55 | 56 | &--incompatibleExtensions { 57 | grid-template-areas: 58 | "title logo" 59 | "helpText logo" 60 | "controls logo" 61 | "extensions extensions"; 62 | } 63 | 64 | > img { 65 | grid-area: logo; 66 | } 67 | 68 | > label { 69 | grid-area: title; 70 | } 71 | 72 | > .helpText { 73 | grid-area: helpText; 74 | } 75 | 76 | &-failure { 77 | --border-radius: 0; 78 | grid-area: failure; 79 | margin: var(--space) calc(~"0px - var(--space)") calc(~"0px - var(--space)"); 80 | } 81 | 82 | &-incompatibleExtensions { 83 | grid-area: extensions; 84 | margin-top: var(--space); 85 | padding-top: var(--space); 86 | border-top: 1px solid var(--control-bg); 87 | } 88 | 89 | .ExtensionManager-updaterControls { 90 | margin: 0; 91 | } 92 | } 93 | 94 | .WhyNotModal { 95 | &-contents { 96 | overflow-x: auto; 97 | } 98 | } 99 | 100 | .ExtensionManager-installer .FormControl-container { 101 | max-width: 450px; 102 | 103 | .FormControl { 104 | width: 300px; 105 | } 106 | } 107 | 108 | .ExtensionManager-controlSection .container { 109 | max-width: 1030px; 110 | overflow: visible; 111 | } 112 | 113 | .ExtensionManager-primaryWarning ul { 114 | margin: 0; 115 | } 116 | 117 | .ExtensionManager-extensions--empty { 118 | border: 1px solid var(--control-bg); 119 | border-radius: var(--border-radius); 120 | } 121 | -------------------------------------------------------------------------------- /less/admin/DiscoverSection.less: -------------------------------------------------------------------------------- 1 | .ExtensionManager-DiscoverSection .Tabs-divider { 2 | margin-left: -30px; 3 | margin-right: -30px; 4 | } 5 | 6 | .ExtensionManager-DiscoverSection-list-inner { 7 | --cards: 1; 8 | --gap: 24px; 9 | display: grid; 10 | grid-template-columns: repeat(auto-fit, calc((100% / var(--cards)) - (var(--gap) - (var(--gap) / var(--cards))))); 11 | gap: var(--gap); 12 | 13 | &--empty { 14 | display: block; 15 | } 16 | 17 | @media @tablet-up { 18 | --cards: 2; 19 | } 20 | 21 | @media @desktop-xl { 22 | --cards: 3; 23 | } 24 | 25 | @media @desktop-xxl { 26 | --cards: 4; 27 | } 28 | 29 | @media @desktop-xxxl { 30 | --cards: 5; 31 | } 32 | } 33 | 34 | .ExtensionManager-DiscoverSection-toolbar { 35 | margin-bottom: 14px; 36 | display: flex; 37 | justify-content: space-between; 38 | 39 | &-primary, &-secondary { 40 | display: flex; 41 | gap: 8px; 42 | } 43 | } 44 | 45 | .ExtensionManager-DiscoverSection-footer { 46 | margin: 24px 0 0; 47 | 48 | > * { 49 | margin-top: 16px; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /less/admin/ExtensionCard.less: -------------------------------------------------------------------------------- 1 | .ExtensionCard { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 12px; 5 | border-radius: var(--border-radius); 6 | padding: 12px; 7 | border: 1px solid var(--control-bg); 8 | color: var(--control-color); 9 | } 10 | 11 | .ExtensionCard-header { 12 | display: flex; 13 | align-items: center; 14 | gap: 12px; 15 | } 16 | 17 | .ExtensionCard-header .ExtensionIcon { 18 | --size: 36px; 19 | background-color: transparent; 20 | flex-shrink: 0; 21 | } 22 | 23 | .ExtensionCard-badges { 24 | display: flex; 25 | gap: 4px; 26 | } 27 | 28 | .ExtensionCard-badge--premium { 29 | --badge-bg: #FBDB33; 30 | --badge-color: #4B4940; 31 | } 32 | 33 | .ExtensionCard--core .ExtensionIcon, .ExtensionCard-badge--flarum::before { 34 | filter: grayscale(1) brightness(3.5); 35 | } 36 | 37 | .ExtensionCard-badge--flarum { 38 | background-color: #e7672e; 39 | position: relative; 40 | 41 | &::before { 42 | background-image: url('../extensions/flarum-extension-manager/flarum.svg'); 43 | background-size: 100%; 44 | content: ''; 45 | display: block; 46 | position: absolute; 47 | width: 80%; 48 | height: 80%; 49 | } 50 | } 51 | 52 | .ExtensionCard-actions { 53 | margin-inline-start: auto; 54 | } 55 | 56 | .ExtensionCard-header h4 { 57 | color: var(--text-color); 58 | font-size: 14px; 59 | margin: 0; 60 | max-width: 45%; 61 | white-space: nowrap; 62 | overflow: hidden; 63 | text-overflow: ellipsis; 64 | } 65 | 66 | .ExtensionCard-meta { 67 | display: flex; 68 | gap: 12px; 69 | } 70 | 71 | .ExtensionCard-badges .Badge { 72 | --size: 18px; 73 | } 74 | 75 | .ExtensionCard-meta > * { 76 | display: flex; 77 | align-items: center; 78 | gap: 6px; 79 | } 80 | 81 | .ExtensionCard-body { 82 | margin-bottom: 6px; 83 | flex-grow: 1; 84 | 85 | p { 86 | margin-bottom: 0; 87 | } 88 | } 89 | 90 | .ExtensionCard { 91 | &-version { 92 | display: flex; 93 | align-items: center; 94 | gap: 8px; 95 | 96 | &-latest { 97 | text-transform: lowercase; 98 | padding: 2px 6px; 99 | font-size: 0.7rem; 100 | } 101 | } 102 | 103 | &--core { 104 | --bg-hover: #e2571a; 105 | --text-color: #fff; 106 | --button-color: #fff; 107 | --button-bg-hover: var(--bg-hover); 108 | background-color: #e7672e; 109 | border-color: var(--bg-hover); 110 | color: #fff; 111 | 112 | .Button--danger { 113 | color: #fff; 114 | --button-bg-hover: var(--bg-hover); 115 | } 116 | } 117 | 118 | &--core .ExtensionIcon { 119 | background-size: 100%; 120 | background-color: transparent; 121 | } 122 | 123 | &--danger { 124 | background-color: var(--control-danger-bg); 125 | border-color: var(--control-danger-bg-hover); 126 | color: var(--control-danger-color); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /less/admin/Label.less: -------------------------------------------------------------------------------- 1 | :root { 2 | --label-bg: var(--control-bg); 3 | --label-color: var(--control-color); 4 | --label-success-bg: var(--alert-success-bg); 5 | --label-success-color: var(--alert-success-color); 6 | --label-error-bg: var(--alert-error-bg); 7 | --label-error-color: var(--alert-error-color); 8 | --label-warning-bg: var(--alert-bg); 9 | --label-warning-color: var(--alert-color); 10 | --label-neutral-bg: #2781dd; 11 | --label-neutral-color: #f0f6ff; 12 | } 13 | 14 | .Label { 15 | background-color: var(--label-bg); 16 | color: var(--label-color); 17 | font-weight: 600; 18 | font-size: 0.65rem; 19 | padding: 4px 6px; 20 | border-radius: var(--border-radius); 21 | 22 | &--success { 23 | --label-bg: var(--label-success-bg); 24 | --label-color: var(--label-success-color); 25 | } 26 | 27 | &--error { 28 | --label-bg: var(--label-error-bg); 29 | --label-color: var(--label-error-color); 30 | } 31 | 32 | &--warning { 33 | --label-bg: var(--label-warning-bg); 34 | --label-color: var(--label-warning-color); 35 | } 36 | 37 | &--neutral { 38 | --label-bg: var(--label-neutral-bg); 39 | --label-color: var(--label-neutral-color); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /less/admin/QueueSection.less: -------------------------------------------------------------------------------- 1 | .ExtensionManager-queueSection { 2 | &-header > .container { 3 | display: flex; 4 | justify-content: space-between; 5 | align-items: center; 6 | 7 | &::before, &::after { 8 | content: none; 9 | } 10 | } 11 | 12 | .Label { 13 | text-transform: uppercase; 14 | margin-inline-end: 8px; 15 | } 16 | 17 | .Table { 18 | width: 100%; 19 | 20 | // @TODO move to core 21 | height: 100%; 22 | 23 | &-controls-item { 24 | height: 100%; 25 | } 26 | } 27 | } 28 | 29 | .ExtensionManager-queueTable { 30 | &-package { 31 | display: flex; 32 | align-items: center; 33 | gap: 8px; 34 | 35 | &-icon { 36 | --size: 30px; 37 | } 38 | 39 | &-details { 40 | display: flex; 41 | flex-direction: column; 42 | } 43 | 44 | &-name { 45 | font-size: 0.7rem; 46 | } 47 | } 48 | 49 | &-operation { 50 | display: flex; 51 | gap: 16px; 52 | 53 | &-icon { 54 | width: 20px; 55 | text-align: center; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /less/admin/TaskOutputModal.less: -------------------------------------------------------------------------------- 1 | .TaskOutputModal-data-output { 2 | height: auto; 3 | overflow: auto; 4 | max-height: 40vh; 5 | } 6 | -------------------------------------------------------------------------------- /migrations/2022_02_22_000000_create_package_manager_tasks_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 17 | $table->string('status', 50)->nullable(); 18 | $table->string('operation', 50); 19 | $table->string('command', 50)->nullable(); 20 | $table->string('package', 100)->nullable(); 21 | $table->mediumText('output'); 22 | $table->timestamp('created_at'); 23 | $table->timestamp('started_at')->nullable(); 24 | $table->timestamp('finished_at')->nullable(); 25 | // Saved in KB 26 | $table->unsignedMediumInteger('peak_memory_used')->nullable(); 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /migrations/2023_12_09_000000_add_guessed_cause_column_to_package_manager_tasks_table.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 15 | $schema->table('package_manager_tasks', function (Blueprint $table) use ($schema) { 16 | if (! $schema->hasColumn('package_manager_tasks', 'guessed_cause')) { 17 | $table->string('guessed_cause', 255)->nullable()->after('output'); 18 | } 19 | }); 20 | }, 21 | 'down' => function (Builder $schema) { 22 | $schema->table('package_manager_tasks', function (Blueprint $table) use ($schema) { 23 | if ($schema->hasColumn('package_manager_tasks', 'guessed_cause')) { 24 | $table->dropColumn('guessed_cause'); 25 | } 26 | }); 27 | } 28 | ]; 29 | -------------------------------------------------------------------------------- /migrations/2024_01_10_000000_rename_to_extension_manager.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 14 | $schema->rename('package_manager_tasks', 'extension_manager_tasks'); 15 | $schema->getConnection()->table('migrations')->where('extension', 'flarum-package-manager')->delete(); 16 | }, 17 | 'down' => function (Builder $schema) { 18 | $schema->rename('extension_manager_tasks', 'package_manager_tasks'); 19 | $schema->getConnection()->table('migrations')->where('extension', 'flarum-extension-manager')->update([ 20 | 'extension' => 'flarum-package-manager', 21 | ]); 22 | } 23 | ]; 24 | -------------------------------------------------------------------------------- /src/Api/Controller/CheckForUpdatesController.php: -------------------------------------------------------------------------------- 1 | assertAdmin(); 35 | 36 | $response = $this->bus->dispatch( 37 | new CheckForUpdates($actor) 38 | ); 39 | 40 | return $response->queueJobs 41 | ? new JsonResponse(['processing' => true], 202) 42 | : new JsonResponse($response->data); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Api/Controller/ConfigureComposerController.php: -------------------------------------------------------------------------------- 1 | getParsedBody(), 'type'); 50 | 51 | $actor->assertAdmin(); 52 | 53 | if (! in_array($type, ['composer', 'auth'])) { 54 | return new JsonResponse([ 55 | 'data' => [], 56 | ]); 57 | } 58 | 59 | if ($type === 'composer') { 60 | $data = $this->composerConfig($request); 61 | } else { 62 | $data = $this->authConfig($request); 63 | } 64 | 65 | return new JsonResponse([ 66 | 'data' => $data, 67 | ]); 68 | } 69 | 70 | protected function composerConfig(ServerRequestInterface $request): array 71 | { 72 | $data = Arr::only(Arr::get($request->getParsedBody(), 'data') ?? [], $this->configurable); 73 | 74 | $this->composerValidator->assertValid($data); 75 | $composerJson = $this->composerJson->get(); 76 | 77 | if (! empty($data)) { 78 | foreach ($data as $key => $value) { 79 | Arr::set($composerJson, $key, $value); 80 | } 81 | 82 | // Always prefer stable releases. 83 | $composerJson['prefer-stable'] = true; 84 | 85 | $this->composerJson->set($composerJson); 86 | } 87 | 88 | $default = [ 89 | 'minimum-stability' => 'stable', 90 | 'repositories' => [], 91 | ]; 92 | 93 | foreach ($this->configurable as $key) { 94 | $composerJson[$key] = Arr::get($composerJson, $key, Arr::get($default, $key)); 95 | 96 | if (is_null($composerJson[$key])) { 97 | $composerJson[$key] = $default[$key]; 98 | } 99 | } 100 | 101 | $composerJson = Arr::sortRecursive($composerJson); 102 | 103 | return Arr::only($composerJson, $this->configurable); 104 | } 105 | 106 | protected function authConfig(ServerRequestInterface $request): array 107 | { 108 | $data = Arr::get($request->getParsedBody(), 'data'); 109 | 110 | $this->authValidator->assertValid($data ?? []); 111 | 112 | try { 113 | $authJson = json_decode($this->filesystem->get($this->paths->base.'/auth.json'), true); 114 | } catch (FileNotFoundException $e) { 115 | $authJson = []; 116 | } 117 | 118 | if (! is_null($data)) { 119 | foreach ($data as $type => $hosts) { 120 | foreach ($hosts as $host => $token) { 121 | if (empty($token)) { 122 | unset($authJson[$type][$host]); 123 | continue; 124 | } 125 | 126 | if (str_starts_with($token, 'unchanged:')) { 127 | $old = Str::of($token)->explode(':')->skip(1)->values()->all(); 128 | 129 | if (count($old) !== 2) { 130 | continue; 131 | } 132 | 133 | [$oldType, $oldHost] = $old; 134 | 135 | if (! isset($authJson[$oldType][$oldHost])) { 136 | continue; 137 | } 138 | 139 | $data[$type][$host] = $authJson[$oldType][$oldHost]; 140 | } else { 141 | $data[$type][$host] = $token; 142 | } 143 | } 144 | } 145 | 146 | $this->filesystem->put($this->paths->base.'/auth.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 147 | $authJson = $data; 148 | } 149 | 150 | // Remove tokens from response. 151 | foreach ($authJson as $type => $hosts) { 152 | foreach ($hosts as $host => $token) { 153 | $authJson[$type][$host] = "unchanged:$type:$host"; 154 | } 155 | } 156 | 157 | return $authJson; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Api/Controller/GlobalUpdateController.php: -------------------------------------------------------------------------------- 1 | bus->dispatch( 36 | new GlobalUpdate($actor) 37 | ); 38 | 39 | return $response->queueJobs 40 | ? new JsonResponse(['processing' => true], 202) 41 | : new EmptyResponse(201); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Api/Controller/MajorUpdateController.php: -------------------------------------------------------------------------------- 1 | getParsedBody(), 'data.dryRun', 0); 33 | 34 | $response = $this->bus->dispatch( 35 | new MajorUpdate($actor, $dryRun) 36 | ); 37 | 38 | return $response->queueJobs 39 | ? new JsonResponse(['processing' => true], 202) 40 | : new EmptyResponse(201); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Api/Controller/MinorUpdateController.php: -------------------------------------------------------------------------------- 1 | bus->dispatch( 36 | new MinorUpdate($actor) 37 | ); 38 | 39 | return $response->queueJobs 40 | ? new JsonResponse(['processing' => true], 202) 41 | : new EmptyResponse(201); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Api/Controller/RemoveExtensionController.php: -------------------------------------------------------------------------------- 1 | getQueryParams(), 'id'); 33 | 34 | $response = $this->bus->dispatch( 35 | new RemoveExtension($actor, $extensionId) 36 | ); 37 | 38 | return $response->queueJobs 39 | ? new JsonResponse(['processing' => true], 202) 40 | : new EmptyResponse(201); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Api/Controller/RequireExtensionController.php: -------------------------------------------------------------------------------- 1 | getParsedBody(), 'data.package'); 32 | 33 | $response = $this->bus->dispatch( 34 | new RequireExtension($actor, $package) 35 | ); 36 | 37 | return $response->queueJobs 38 | ? new JsonResponse(['processing' => true], 202) 39 | : new JsonResponse($response->data); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Api/Controller/UpdateExtensionController.php: -------------------------------------------------------------------------------- 1 | getQueryParams(), 'id'); 33 | $updateMode = Arr::get($request->getParsedBody(), 'data.updateMode'); 34 | 35 | $response = $this->bus->dispatch( 36 | new UpdateExtension($actor, $extensionId, $updateMode) 37 | ); 38 | 39 | return $response->queueJobs 40 | ? new JsonResponse(['processing' => true], 202) 41 | : new EmptyResponse(201); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Api/Controller/WhyNotController.php: -------------------------------------------------------------------------------- 1 | getParsedBody(), 'data.package', ''); 32 | $version = Arr::get($request->getParsedBody(), 'data.version', '*'); 33 | 34 | $whyNot = $this->bus->sync()->dispatch( 35 | new WhyNot($actor, $package, $version) 36 | ); 37 | 38 | return new JsonResponse(['data' => ['reason' => $whyNot->data['reason']]]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Api/Resource/ExternalExtensionResource.php: -------------------------------------------------------------------------------- 1 | authenticated() 52 | ->admin() 53 | ->paginate(12, 20), 54 | ]; 55 | } 56 | 57 | public function fields(): array 58 | { 59 | return [ 60 | Schema\Str::make('extensionId') 61 | ->get(fn (Extension $extension) => $extension->extensionId()), 62 | Schema\Str::make('name'), 63 | Schema\Str::make('title'), 64 | Schema\Str::make('description'), 65 | Schema\Str::make('iconUrl') 66 | ->property('icon_url'), 67 | Schema\Arr::make('icon'), 68 | Schema\Str::make('highestVersion') 69 | ->property('highest_version'), 70 | Schema\Str::make('httpUri') 71 | ->property('http_uri'), 72 | Schema\Str::make('discussUri') 73 | ->property('discuss_uri'), 74 | Schema\Str::make('vendor'), 75 | Schema\Boolean::make('isPremium') 76 | ->property('is_premium'), 77 | Schema\Boolean::make('isLocale') 78 | ->property('is_locale'), 79 | Schema\Str::make('locale'), 80 | Schema\Str::make('latestFlarumVersionSupported') 81 | ->property('latest_flarum_version_supported'), 82 | Schema\Boolean::make('compatibleWithLatestFlarum') 83 | ->property('compatible_with_latest_flarum'), 84 | Schema\Integer::make('downloads'), 85 | 86 | Schema\Boolean::make('isSupported') 87 | ->get(function (Extension $extension) { 88 | return Semver::satisfies(Application::VERSION, $extension->latest_flarum_version_supported); 89 | }), 90 | ]; 91 | } 92 | 93 | public function sorts(): array 94 | { 95 | return [ 96 | SortColumn::make('createdAt'), 97 | SortColumn::make('downloads'), 98 | ]; 99 | } 100 | 101 | public function filters(): array 102 | { 103 | return [ 104 | CustomFilter::make('type', function (object $query, ?string $value) { 105 | if ($value) { 106 | /** @var RequestWrapper $query */ 107 | $query->withQueryParams([ 108 | 'filter' => [ 109 | 'type' => $value, 110 | ], 111 | ]); 112 | } 113 | }), 114 | 115 | CustomFilter::make('is', function (object $query, null|string|array $value) { 116 | if ($value) { 117 | /** @var RequestWrapper $query */ 118 | $query->withQueryParams([ 119 | 'filter' => [ 120 | 'is' => (array) $value, 121 | ], 122 | ]); 123 | } 124 | }), 125 | 126 | CustomFilter::make('q', function (object $query, ?string $value) { 127 | if ($value) { 128 | /** @var RequestWrapper $query */ 129 | $query->withQueryParams([ 130 | 'filter' => [ 131 | 'q' => $value, 132 | ], 133 | ]); 134 | } 135 | }), 136 | ]; 137 | } 138 | 139 | public function query(Context $context): object 140 | { 141 | return (new RequestWrapper($this->cache, 'https://flarum.org/api/extensions', 'GET', [ 142 | 'Accept' => 'application/json', 143 | ]))->withQueryParams([ 144 | 'filter' => [ 145 | 'compatible-with' => Application::VERSION, 146 | ], 147 | ]); 148 | } 149 | 150 | public function paginate(object $query, OffsetPagination $pagination): void 151 | { 152 | /** @var RequestWrapper $query */ 153 | $query->withQueryParams([ 154 | 'page' => [ 155 | 'offset' => $pagination->offset, 156 | 'limit' => $pagination->limit, 157 | ], 158 | ]); 159 | } 160 | 161 | public function results(object $query, Context $context): iterable 162 | { 163 | /** @var RequestWrapper $query */ 164 | $json = $query->cache(function (RequestWrapper $query) { 165 | try { 166 | $response = (new Client())->send($query->getRequest()); 167 | } catch (GuzzleException) { 168 | throw new CannotFetchExternalExtension(); 169 | } 170 | 171 | if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { 172 | throw new CannotFetchExternalExtension(); 173 | } 174 | 175 | return json_decode($response->getBody()->getContents(), true); 176 | }); 177 | 178 | $this->totalResults = $json['meta']['page']['total'] ?? null; 179 | 180 | return (new Collection($json['data'])) 181 | ->map(function (array $data) { 182 | $attributes = $data['attributes']; 183 | 184 | $attributes = array_combine( 185 | array_map(fn ($key) => Str::snake(Str::camel($key)), array_keys($attributes)), 186 | array_values($attributes) 187 | ); 188 | 189 | return new Extension(array_merge([ 190 | 'id' => $data['id'], 191 | ], $attributes)); 192 | }); 193 | } 194 | 195 | public function count(object $query, Context $context): ?int 196 | { 197 | return $this->totalResults; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Api/Resource/TaskResource.php: -------------------------------------------------------------------------------- 1 | defaultSort('-createdAt') 35 | ->paginate(), 36 | ]; 37 | } 38 | 39 | public function fields(): array 40 | { 41 | return [ 42 | Schema\Str::make('status'), 43 | Schema\Str::make('operation'), 44 | Schema\Str::make('command'), 45 | Schema\Str::make('package'), 46 | Schema\Str::make('output'), 47 | Schema\DateTime::make('createdAt'), 48 | Schema\DateTime::make('startedAt'), 49 | Schema\DateTime::make('finishedAt'), 50 | Schema\Number::make('peakMemoryUsed'), 51 | ]; 52 | } 53 | 54 | public function sorts(): array 55 | { 56 | return [ 57 | SortColumn::make('createdAt'), 58 | ]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Api/Schema/SortColumn.php: -------------------------------------------------------------------------------- 1 | withQueryParams([ 27 | 'sort' => $direction === 'desc' ? "-$this->name" : $this->name, 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Command/AbstractActionCommand.php: -------------------------------------------------------------------------------- 1 | runComposerCommand(false, $command); 59 | $firstOutput = json_decode($this->cleanJson($firstOutput), true); 60 | 61 | $installed = new Collection($firstOutput['installed'] ?? []); 62 | $majorUpdates = $installed->contains(function (array $package) { 63 | return isset($package['latest-status']) && $package['latest-status'] === 'update-possible' && Util::isMajorUpdate($package['version'], $package['latest']); 64 | }); 65 | 66 | if ($majorUpdates) { 67 | $secondOutput = $this->runComposerCommand(true, $command); 68 | $secondOutput = json_decode($this->cleanJson($secondOutput), true); 69 | } 70 | 71 | if (! isset($secondOutput)) { 72 | $secondOutput = ['installed' => []]; 73 | } 74 | 75 | $updates = new Collection(); 76 | $composerJson = $this->composerJson->get(); 77 | 78 | foreach ($installed as $mainPackageUpdate) { 79 | // Skip if not an extension 80 | if (! $this->extensions->getExtension(Extension::nameToId($mainPackageUpdate['name']))) { 81 | continue; 82 | } 83 | 84 | $mainPackageUpdate['latest-minor'] = $mainPackageUpdate['latest-major'] = null; 85 | 86 | if ($mainPackageUpdate['latest-status'] === 'up-to-date' && Util::isMajorUpdate($mainPackageUpdate['version'], $mainPackageUpdate['latest'])) { 87 | continue; 88 | } 89 | 90 | if (isset($mainPackageUpdate['latest-status']) && $mainPackageUpdate['latest-status'] === 'update-possible' && Util::isMajorUpdate($mainPackageUpdate['version'], $mainPackageUpdate['latest'])) { 91 | $mainPackageUpdate['latest-major'] = $mainPackageUpdate['latest']; 92 | 93 | $minorPackageUpdate = array_filter($secondOutput['installed'], function ($package) use ($mainPackageUpdate) { 94 | return $package['name'] === $mainPackageUpdate['name']; 95 | })[0] ?? null; 96 | 97 | if ($minorPackageUpdate) { 98 | $mainPackageUpdate['latest-minor'] = $minorPackageUpdate['latest']; 99 | } 100 | } else { 101 | $mainPackageUpdate['latest-minor'] = $mainPackageUpdate['latest'] ?? null; 102 | } 103 | 104 | $mainPackageUpdate['required-as'] = $composerJson['require'][$mainPackageUpdate['name']] ?? null; 105 | 106 | if (! $this->compatibleWithCurrentFlarumVersion($mainPackageUpdate)) { 107 | continue; 108 | } 109 | 110 | $updates->push($mainPackageUpdate); 111 | } 112 | 113 | return $this->lastUpdateCheck 114 | ->with('installed', $updates->values()->toArray()) 115 | ->save(); 116 | } 117 | 118 | /** 119 | * Composer can sometimes return text above the JSON. 120 | * This method tries to remove such occurrences. 121 | */ 122 | protected function cleanJson(string $composerOutput): string 123 | { 124 | return preg_replace('/^[^{]+\n({.*)/ms', '$1', $composerOutput); 125 | } 126 | 127 | /** 128 | * @throws ComposerCommandFailedException 129 | */ 130 | protected function runComposerCommand(bool $minorOnly, CheckForUpdates $command): string 131 | { 132 | $input = [ 133 | 'command' => 'outdated', 134 | '--format' => 'json', 135 | ]; 136 | 137 | if ($minorOnly) { 138 | $input['--minor-only'] = true; 139 | } 140 | 141 | $output = $this->composer->run(new ArrayInput($input), $command->task ?? null); 142 | 143 | if ($output->getExitCode() !== 0) { 144 | throw new ComposerCommandFailedException('', $output->getContents()); 145 | } 146 | 147 | return $output->getContents(); 148 | } 149 | 150 | private function compatibleWithCurrentFlarumVersion(array $mainPackageUpdate): bool 151 | { 152 | if (empty($mainPackageUpdate['latest-major']) || str_contains($mainPackageUpdate['latest-major'], 'dev-')) { 153 | return true; 154 | } 155 | 156 | if (! empty($this->meta[$mainPackageUpdate['name']])) { 157 | $json = $this->meta[$mainPackageUpdate['name']]; 158 | } else { 159 | $response = $this->http->get("https://repo.packagist.org/p2/{$mainPackageUpdate['name']}.json"); 160 | 161 | $body = $response->getBody()->getContents(); 162 | 163 | if ($response->getStatusCode() > 299 || $response->getStatusCode() < 200) { 164 | return true; 165 | } 166 | 167 | $json = json_decode($body, true); 168 | 169 | $this->meta[$mainPackageUpdate['name']] = $json; 170 | } 171 | 172 | $packages = new Collection($json['packages'][$mainPackageUpdate['name']] ?? []); 173 | 174 | if ($packages->isEmpty()) { 175 | return true; 176 | } 177 | 178 | $package = $packages->firstWhere('version', $mainPackageUpdate['latest-major']); 179 | 180 | if (! $package) { 181 | return true; 182 | } 183 | 184 | $flarumVersion = Application::VERSION; 185 | 186 | $require = $package['require']['flarum/core'] ?? null; 187 | 188 | if (! $require || str_contains($require, 'dev-')) { 189 | return true; 190 | } 191 | 192 | return Semver::satisfies($flarumVersion, $require); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/Command/GlobalUpdate.php: -------------------------------------------------------------------------------- 1 | actor->assertAdmin(); 36 | 37 | $input = [ 38 | 'command' => 'update', 39 | '--prefer-dist' => true, 40 | '--no-dev' => ! $this->config->inDebugMode(), 41 | '-a' => true, 42 | '--with-all-dependencies' => true, 43 | ]; 44 | 45 | $output = $this->composer->run( 46 | new ArrayInput($input), 47 | $command->task ?? null, 48 | true 49 | ); 50 | 51 | if ($output->getExitCode() !== 0) { 52 | throw new ComposerUpdateFailedException('*', $output->getContents()); 53 | } 54 | 55 | $this->events->dispatch( 56 | new FlarumUpdated($command->actor, FlarumUpdated::GLOBAL) 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Command/MajorUpdate.php: -------------------------------------------------------------------------------- 1 | actor->assertAdmin(); 44 | 45 | $majorVersion = $this->lastUpdateCheck->getNewMajorVersion(); 46 | 47 | if (! $majorVersion) { 48 | throw new NoNewMajorVersionException(); 49 | } 50 | 51 | $this->updateComposerJson($majorVersion); 52 | 53 | $this->runCommand($command, $majorVersion); 54 | 55 | if ($command->dryRun) { 56 | $this->composerJson->revert(); 57 | 58 | return; 59 | } 60 | 61 | $this->events->dispatch( 62 | new FlarumUpdated($command->actor, FlarumUpdated::MAJOR) 63 | ); 64 | } 65 | 66 | protected function updateComposerJson(string $majorVersion): void 67 | { 68 | $versionNumber = str_replace('v', '', $majorVersion); 69 | 70 | $this->composerJson->require('*', '*'); 71 | $this->composerJson->require('flarum/core', '^'.$versionNumber); 72 | } 73 | 74 | /** 75 | * @throws MajorUpdateFailedException 76 | */ 77 | protected function runCommand(MajorUpdate $command, string $majorVersion): void 78 | { 79 | $input = [ 80 | 'command' => 'update', 81 | '--prefer-dist' => true, 82 | '--no-plugins' => true, 83 | '--no-dev' => true, 84 | '-a' => true, 85 | '--with-all-dependencies' => true, 86 | ]; 87 | 88 | if ($command->dryRun) { 89 | $input['--dry-run'] = true; 90 | } 91 | 92 | $output = $this->composer->run(new ArrayInput($input), $command->task ?? null, true); 93 | 94 | if ($output->getExitCode() !== 0) { 95 | throw new MajorUpdateFailedException('*', $output->getContents(), $majorVersion); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Command/MinorUpdate.php: -------------------------------------------------------------------------------- 1 | actor->assertAdmin(); 37 | 38 | // Set all extensions to * versioning. 39 | $this->composerJson->require('*', '*'); 40 | 41 | $output = $this->composer->run( 42 | new StringInput('update --prefer-dist --no-dev -a --with-all-dependencies'), 43 | $command->task ?? null, 44 | true 45 | ); 46 | 47 | if ($output->getExitCode() !== 0) { 48 | throw new ComposerUpdateFailedException('flarum/*', $output->getContents()); 49 | } 50 | 51 | $this->events->dispatch( 52 | new FlarumUpdated($command->actor, FlarumUpdated::MINOR) 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Command/RemoveExtension.php: -------------------------------------------------------------------------------- 1 | actor->assertAdmin(); 39 | 40 | $extension = $this->extensions->getExtension($command->extensionId); 41 | 42 | if (empty($extension)) { 43 | throw new ExtensionNotInstalledException($command->extensionId); 44 | } 45 | 46 | if (isset($command->task)) { 47 | $command->task->package = $extension->name; 48 | } 49 | 50 | $json = $this->composerJson->get(); 51 | 52 | // If this extension is not a direct dependency, we can't actually remove it. 53 | if (! isset($json['require'][$extension->name]) && ! isset($json['require-dev'][$extension->name])) { 54 | throw new IndirectExtensionDependencyCannotBeRemovedException($command->extensionId); 55 | } 56 | 57 | $output = $this->composer->run( 58 | new StringInput("remove $extension->name"), 59 | $command->task ?? null, 60 | true 61 | ); 62 | 63 | if ($output->getExitCode() !== 0) { 64 | throw new ComposerCommandFailedException($extension->name, $output->getContents()); 65 | } 66 | 67 | $this->events->dispatch( 68 | new Removed($extension) 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Command/RequireExtension.php: -------------------------------------------------------------------------------- 1 | actor->assertAdmin(); 39 | 40 | $this->validator->assertValid(['package' => $command->package]); 41 | 42 | $extensionId = Extension::nameToId($command->package); 43 | $extension = $this->extensions->getExtension($extensionId); 44 | 45 | if (! empty($extension)) { 46 | throw new ExtensionAlreadyInstalledException($extension); 47 | } 48 | 49 | $packageName = $command->package; 50 | 51 | // Auto append :* if not requiring a specific version. 52 | if (! str_contains($packageName, ':')) { 53 | $packageName .= ':*'; 54 | } 55 | 56 | $output = $this->composer->run( 57 | new StringInput("require $packageName -W"), 58 | $command->task ?? null, 59 | true 60 | ); 61 | 62 | if ($output->getExitCode() !== 0) { 63 | throw new ComposerRequireFailedException($packageName, $output->getContents()); 64 | } 65 | 66 | $this->events->dispatch( 67 | new Installed($extensionId) 68 | ); 69 | 70 | return ['id' => $extensionId]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Command/UpdateExtension.php: -------------------------------------------------------------------------------- 1 | actor->assertAdmin(); 40 | 41 | $this->validator->assertValid([ 42 | 'extensionId' => $command->extensionId, 43 | 'updateMode' => $command->updateMode, 44 | ]); 45 | 46 | $extension = $this->extensions->getExtension($command->extensionId); 47 | 48 | if (empty($extension)) { 49 | throw new ExtensionNotInstalledException($command->extensionId); 50 | } 51 | 52 | // In situations where an extension was locked to a specific version, 53 | // a hard update mode is useful to allow removing the locked version and 54 | // instead requiring the latest version. 55 | // Another scenario could be when requiring a specific version range, for example 0.1.*, 56 | // the admin might either want to update to the latest version in that range, or to the latest version overall (0.2.0). 57 | if ($command->updateMode === 'soft') { 58 | $input = "update $extension->name"; 59 | } else { 60 | $input = "require $extension->name:*"; 61 | } 62 | 63 | $output = $this->composer->run( 64 | new StringInput($input), 65 | $command->task ?? null, 66 | true 67 | ); 68 | 69 | if ($output->getExitCode() !== 0) { 70 | throw new ComposerUpdateFailedException($extension->name, $output->getContents()); 71 | } 72 | 73 | $this->events->dispatch( 74 | new Updated($command->actor, $extension) 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Command/WhyNot.php: -------------------------------------------------------------------------------- 1 | actor->assertAdmin(); 34 | 35 | $this->validator->assertValid([ 36 | 'package' => $command->package, 37 | 'version' => $command->version 38 | ]); 39 | 40 | $output = $this->composer->run( 41 | new StringInput("why-not $command->package $command->version"), 42 | $command->task ?? null 43 | ); 44 | 45 | if ($output->getExitCode() !== 0) { 46 | throw new ComposerRequireFailedException($command->package, $output->getContents()); 47 | } 48 | 49 | return ['reason' => $output->getContents()]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Composer/ComposerAdapter.php: -------------------------------------------------------------------------------- 1 | application->resetComposer(); 40 | 41 | $this->output ??= new BufferedOutput(); 42 | 43 | // This hack is necessary so that relative path repositories are resolved properly. 44 | $currDir = getcwd(); 45 | chdir($this->paths->base); 46 | 47 | if ($safeMode) { 48 | $temporaryVendorDir = $this->paths->base.DIRECTORY_SEPARATOR.'temp-vendor'; 49 | if (! $this->filesystem->isDirectory($temporaryVendorDir)) { 50 | $this->filesystem->makeDirectory($temporaryVendorDir); 51 | } 52 | Config::$defaultConfig['vendor-dir'] = $temporaryVendorDir; 53 | } 54 | 55 | $exitCode = $this->application->run($input, $this->output); 56 | 57 | if ($safeMode) { 58 | // Move the temporary vendor directory to the real vendor directory. 59 | if ($this->filesystem->isDirectory($temporaryVendorDir) && count($this->filesystem->allFiles($temporaryVendorDir))) { 60 | $vendorDir = $this->paths->vendor; 61 | if (file_exists($vendorDir)) { 62 | $this->filesystem->deleteDirectory($vendorDir); 63 | } 64 | $this->filesystem->moveDirectory($temporaryVendorDir, $vendorDir); 65 | } 66 | Config::$defaultConfig['vendor-dir'] = $this->paths->vendor; 67 | } 68 | 69 | chdir($currDir); 70 | 71 | $command = Util::readableConsoleInput($input); 72 | $outputContent = $this->output->fetch(); 73 | 74 | if ($task) { 75 | $task->update([ 76 | 'command' => $command, 77 | 'output' => $outputContent, 78 | ]); 79 | } else { 80 | $this->logger->log($command, $outputContent, $exitCode); 81 | } 82 | 83 | return new ComposerOutput($exitCode, $outputContent); 84 | } 85 | 86 | public static function setPhpVersion(string $phpVersion): void 87 | { 88 | Config::$defaultConfig['platform']['php'] = $phpVersion; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Composer/ComposerJson.php: -------------------------------------------------------------------------------- 1 | get(); 32 | 33 | if (! str_contains($packageName, '*')) { 34 | $composerJson['require'][$packageName] = $version; 35 | } else { 36 | foreach ($composerJson['require'] as $p => $v) { 37 | if ($version === '*@dev') { 38 | continue; 39 | } 40 | 41 | // Only extensions can all be set to * versioning. 42 | if (! $this->extensions->getExtension(Extension::nameToId($packageName))) { 43 | continue; 44 | } 45 | 46 | $wildcardPackageName = str_replace('\*', '.*', preg_quote($packageName, '/')); 47 | 48 | if (Str::of($p)->test("/($wildcardPackageName)/")) { 49 | $composerJson['require'][$p] = $version; 50 | } 51 | } 52 | } 53 | 54 | $this->set($composerJson); 55 | } 56 | 57 | public function revert(): void 58 | { 59 | $this->set($this->initialJson); 60 | } 61 | 62 | protected function getComposerJsonPath(): string 63 | { 64 | return $this->paths->base.'/composer.json'; 65 | } 66 | 67 | /** 68 | * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException 69 | */ 70 | public function get(): array 71 | { 72 | $json = json_decode($this->filesystem->get($this->getComposerJsonPath()), true); 73 | 74 | if (! $this->initialJson) { 75 | $this->initialJson = $json; 76 | } 77 | 78 | return $json; 79 | } 80 | 81 | public function set(array $json): void 82 | { 83 | $this->filesystem->put($this->getComposerJsonPath(), json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Composer/ComposerOutput.php: -------------------------------------------------------------------------------- 1 | exitCode; 23 | } 24 | 25 | public function getContents(): string 26 | { 27 | return $this->contents; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ConfigureAuthValidator.php: -------------------------------------------------------------------------------- 1 | ['sometimes', 'array'], 20 | 'github-oauth.*' => ['sometimes', 'string'], 21 | 'gitlab-oauth' => ['sometimes', 'array'], 22 | 'gitlab-oauth.*' => ['sometimes', 'string'], 23 | 'gitlab-token' => ['sometimes', 'array'], 24 | 'gitlab-token.*' => ['sometimes', 'string'], 25 | 'bearer' => ['sometimes', 'array'], 26 | 'bearer.*' => ['sometimes', 'string'], 27 | ]; 28 | } 29 | -------------------------------------------------------------------------------- /src/ConfigureComposerValidator.php: -------------------------------------------------------------------------------- 1 | ['sometimes', 'in:stable,RC,beta,alpha,dev'], 20 | 'repositories' => ['sometimes', 'array'], 21 | 'repositories.*' => ['sometimes', 'array', 'required_array_keys:type,url'], 22 | 'repositories.*.type' => ['in:composer,vcs,path'], 23 | 'repositories.*.url' => ['string', 'filled'], 24 | ]; 25 | } 26 | -------------------------------------------------------------------------------- /src/Event/FlarumUpdated.php: -------------------------------------------------------------------------------- 1 | packageName); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exception/ComposerRequireFailedException.php: -------------------------------------------------------------------------------- 1 | getRawPackageName(), '/'), self::INCOMPATIBLE_REGEX), 21 | $this->getMessage(), 22 | $matches 23 | ); 24 | 25 | if ($hasIncompatibleMatches) { 26 | return 'extension_incompatible_with_instance'; 27 | } 28 | 29 | $hasNotFoundMatches = preg_match( 30 | str_replace('{PACKAGE_NAME}', preg_quote($this->getRawPackageName(), '/'), self::NOT_FOUND_REGEX), 31 | $this->getMessage(), 32 | $matches 33 | ); 34 | 35 | if ($hasNotFoundMatches) { 36 | return 'extension_not_found'; 37 | } 38 | 39 | return null; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Exception/ComposerUpdateFailedException.php: -------------------------------------------------------------------------------- 1 | withDetails($this->errorDetails($e)); 23 | } 24 | 25 | protected function errorDetails(ComposerCommandFailedException $e): array 26 | { 27 | $details = []; 28 | 29 | if ($guessedCause = $this->guessCause($e)) { 30 | $details['guessed_cause'] = $guessedCause; 31 | } 32 | 33 | if (! empty($e->details)) { 34 | $details = array_merge($details, $e->details); 35 | } 36 | 37 | return [$details]; 38 | } 39 | 40 | protected function guessCause(ComposerCommandFailedException $e): ?string 41 | { 42 | return $e->guessCause(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Exception/ExtensionAlreadyInstalledException.php: -------------------------------------------------------------------------------- 1 | getTitle()} is already installed."); 21 | } 22 | 23 | public function getType(): string 24 | { 25 | return 'extension_already_installed'; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exception/ExtensionNotInstalledException.php: -------------------------------------------------------------------------------- 1 | [A-z0-9\/-]+) [A-z0-9.-_\/]+ requires flarum\/core (?(?:[A-z0-9.><=_ -](?!->))+)/m'; 19 | 20 | public function __construct( 21 | string $packageName, 22 | string $output, 23 | private readonly string $majorVersion 24 | ) { 25 | parent::__construct($packageName, $output); 26 | } 27 | 28 | public function guessCause(): ?string 29 | { 30 | if (preg_match_all(self::INCOMPATIBLE_REGEX, $this->getMessage(), $matches) !== false) { 31 | $this->details['incompatible_extensions'] = []; 32 | 33 | foreach ($matches['ext'] as $k => $name) { 34 | if (! Semver::satisfies($this->majorVersion, $matches['coreReq'][$k])) { 35 | $this->details['incompatible_extensions'][] = $name; 36 | } 37 | } 38 | 39 | resolve(LastUpdateRun::class) 40 | ->for(FlarumUpdated::MAJOR) 41 | ->with('status', LastUpdateRun::FAILURE) 42 | ->with('incompatibleExtensions', $this->details['incompatible_extensions']) 43 | ->save(); 44 | 45 | return 'extensions_incompatible_with_new_major'; 46 | } 47 | 48 | return null; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Exception/NoNewMajorVersionException.php: -------------------------------------------------------------------------------- 1 | container->singleton(ComposerAdapter::class, function (Container $container) { 37 | // This should only ever be resolved when running composer commands, 38 | // because we modify other environment configurations. 39 | $composer = new Application(); 40 | $composer->setAutoExit(false); 41 | 42 | /** @var Paths $paths */ 43 | $paths = $container->make(Paths::class); 44 | 45 | Platform::putenv('COMPOSER_HOME', "$paths->storage/.composer"); 46 | Platform::putenv('COMPOSER', "$paths->base/composer.json"); 47 | Platform::putenv('COMPOSER_DISABLE_XDEBUG_WARN', '1'); 48 | Config::$defaultConfig['vendor-dir'] = $paths->vendor; 49 | 50 | // When running simple require, update and remove commands on packages, 51 | // composer 2 doesn't really need this much unless the extensions are very loaded dependency wise, 52 | // but this is necessary for running flarum updates. 53 | @ini_set('memory_limit', '1G'); 54 | @set_time_limit(5 * 60); 55 | 56 | return new ComposerAdapter( 57 | $composer, 58 | $container->make(OutputLogger::class), 59 | $container->make(Paths::class), 60 | $container->make(Filesystem::class) 61 | ); 62 | }); 63 | 64 | $this->container->alias(ComposerAdapter::class, 'flarum.composer'); 65 | 66 | $this->container->singleton(OutputLogger::class, function (Container $container) { 67 | $logPath = $container->make(Paths::class)->storage.'/logs/composer/output.log'; 68 | $handler = new RotatingFileHandler($logPath, Logger::INFO); 69 | $handler->setFormatter(new LineFormatter(null, null, true, true)); 70 | 71 | $logger = new Logger('composer', [$handler]); 72 | 73 | return new OutputLogger($logger); 74 | }); 75 | } 76 | 77 | public function boot(Container $container): void 78 | { 79 | /** @var Dispatcher $events */ 80 | $events = $container->make('events'); 81 | 82 | $events->listen( 83 | [Updated::class], 84 | function (Updated $event) use ($container) { 85 | /** @var ExtensionManager $extensions */ 86 | $extensions = $container->make(ExtensionManager::class); 87 | 88 | if ($extensions->isEnabled($event->extension->getId())) { 89 | $recompile = new RecompileFrontendAssets( 90 | $container->make('flarum.assets.forum'), 91 | $container->make(LocaleManager::class) 92 | ); 93 | $recompile->flush(); 94 | 95 | $extensions->migrate($event->extension); 96 | $event->extension->copyAssetsTo($container->make('filesystem')->disk('flarum-assets')); 97 | } 98 | } 99 | ); 100 | 101 | $events->listen(FlarumUpdated::class, ClearCacheAfterUpdate::class); 102 | $events->listen([FlarumUpdated::class, Updated::class], ReCheckForUpdates::class); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/External/Extension.php: -------------------------------------------------------------------------------- 1 | attributes = $attributes; 38 | } 39 | 40 | protected function casts(): array 41 | { 42 | return [ 43 | 'is_premium' => 'bool', 44 | 'is_locale' => 'bool', 45 | 'compatible_with_latest_flarum' => 'bool', 46 | 'listed_privately' => 'bool', 47 | 'downloads' => 'int', 48 | ]; 49 | } 50 | 51 | public function extensionId(): string 52 | { 53 | return \Flarum\Extension\Extension::nameToId($this->name); 54 | } 55 | 56 | public function getAttribute(string $key): mixed 57 | { 58 | if (array_key_exists($key, $this->attributes)) { 59 | return $this->castAttribute($key, $this->attributes[$key]); 60 | } 61 | 62 | return null; 63 | } 64 | 65 | public function setAttribute(string $key, mixed $value): void 66 | { 67 | $this->attributes[$key] = $value; 68 | } 69 | 70 | protected function castAttribute(string $key, mixed $value): mixed 71 | { 72 | if (array_key_exists($key, $this->casts())) { 73 | $cast = $this->casts()[$key]; 74 | 75 | if (is_string($cast) && function_exists($func = $cast.'val')) { 76 | return $func($value); 77 | } 78 | } 79 | 80 | return $value; 81 | } 82 | 83 | public function __get(string $key): mixed 84 | { 85 | return $this->getAttribute($key); 86 | } 87 | 88 | public function __set(string $key, mixed $value): void 89 | { 90 | $this->setAttribute($key, $value); 91 | } 92 | 93 | public function __isset(string $key): bool 94 | { 95 | return isset($this->attributes[$key]); 96 | } 97 | 98 | public function __unset(string $key): void 99 | { 100 | unset($this->attributes[$key]); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/External/RequestWrapper.php: -------------------------------------------------------------------------------- 1 | request = new Request($uri, $method, 'php://temp', $headers); 33 | } 34 | 35 | public function withQueryParams(array $queryParams): static 36 | { 37 | $this->queryParams = array_merge_recursive($this->queryParams, $queryParams); 38 | 39 | $newUri = $this->request->getUri()->withQuery(http_build_query($this->queryParams)); 40 | $new = $this->request->withUri($newUri); 41 | $this->request = $new; 42 | 43 | return $this; 44 | } 45 | 46 | public function __call(string $name, array $arguments): static 47 | { 48 | $new = $this->request->$name(...$arguments); 49 | $this->request = $new; 50 | 51 | return $this; 52 | } 53 | 54 | public function getRequest(): Request 55 | { 56 | return $this->request; 57 | } 58 | 59 | protected function cacheKey(): string 60 | { 61 | return md5($this->request->getUri()->__toString()); 62 | } 63 | 64 | public function cache(Closure $callback): array 65 | { 66 | // We will not cache if there is a search query (filter[q]) in the request. 67 | if (isset($this->queryParams['filter']['q'])) { 68 | return $callback($this); 69 | } 70 | 71 | return $this->cache->remember($this->cacheKey(), static::$ttl, fn () => $callback($this)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Job/ComposerCommandJob.php: -------------------------------------------------------------------------------- 1 | command->task->start(); 38 | 39 | ComposerAdapter::setPhpVersion($this->phpVersion); 40 | 41 | $bus->dispatch($this->command); 42 | 43 | $this->command->task->end(true); 44 | } catch (Throwable $exception) { 45 | $this->abort($exception); 46 | } 47 | } 48 | 49 | public function abort(Throwable $exception): void 50 | { 51 | if (empty($this->command->task->output)) { 52 | $this->command->task->output = $exception->getMessage(); 53 | } 54 | 55 | if ($exception instanceof ComposerCommandFailedException) { 56 | $this->command->task->guessed_cause = $exception->guessCause(); 57 | } 58 | 59 | $this->command->task->end(false); 60 | } 61 | 62 | public function failed(Throwable $exception): void 63 | { 64 | $this->abort($exception); 65 | } 66 | 67 | public function middleware(): array 68 | { 69 | return [ 70 | new WithoutOverlapping(), 71 | ]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Job/Dispatcher.php: -------------------------------------------------------------------------------- 1 | runSyncOverride = true; 43 | 44 | return $this; 45 | } 46 | 47 | public function async(): self 48 | { 49 | $this->runSyncOverride = false; 50 | 51 | return $this; 52 | } 53 | 54 | public function dispatch(AbstractActionCommand $command): DispatcherResponse 55 | { 56 | $queueJobs = ($this->runSyncOverride === false) || ($this->runSyncOverride !== true && $this->settings->get('flarum-extension-manager.queue_jobs')); 57 | 58 | // Skip if there is already a pending or running task. 59 | if ($queueJobs && Task::query()->whereIn('status', [Task::PENDING, Task::RUNNING])->exists()) { 60 | return new DispatcherResponse(true, null); 61 | } 62 | 63 | if ($queueJobs && (! $this->queue instanceof SyncQueue)) { 64 | $extension = $command->extensionId ? $this->extensions->getExtension($command->extensionId) : null; 65 | 66 | $task = Task::build($command->getOperationName(), $command->package ?? ($extension ? $extension->name : null)); 67 | 68 | $command->task = $task; 69 | 70 | $this->queue->push( 71 | new ComposerCommandJob($command, PHP_VERSION) 72 | ); 73 | } else { 74 | $data = $this->bus->dispatch($command); 75 | } 76 | 77 | $this->clearOldTasks(); 78 | 79 | return new DispatcherResponse($queueJobs, $data ?? null); 80 | } 81 | 82 | protected function clearOldTasks(): void 83 | { 84 | $days = $this->settings->get('flarum-extension-manager.task_retention_days'); 85 | 86 | if ($days === null || ((int) $days) === 0) { 87 | return; 88 | } 89 | 90 | Task::query() 91 | ->where('created_at', '<', Carbon::now()->subDays($days)) 92 | ->delete(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Job/DispatcherResponse.php: -------------------------------------------------------------------------------- 1 | clearCache->run(new ArrayInput([]), new NullOutput()); 34 | $this->migrate->run(new ArrayInput([]), new NullOutput()); 35 | $this->publishAssets->run(new ArrayInput([]), new NullOutput()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Listener/ReCheckForUpdates.php: -------------------------------------------------------------------------------- 1 | lastUpdateRun = $lastUpdateRun; 38 | $this->lastUpdateCheck = $lastUpdateCheck; 39 | $this->bus = $bus; 40 | } 41 | 42 | /** 43 | * @param FlarumUpdated|Updated $event 44 | */ 45 | public function handle($event): void 46 | { 47 | $previousUpdateCheck = $this->lastUpdateCheck->get(); 48 | 49 | $lastUpdateCheck = $this->bus->dispatch( 50 | new CheckForUpdates($event->actor) 51 | ); 52 | 53 | if ($event instanceof FlarumUpdated) { 54 | $mapPackageName = function (array $package) { 55 | return $package['name']; 56 | }; 57 | 58 | $previousPackages = array_map($mapPackageName, $previousUpdateCheck['updates']['installed']); 59 | $lastPackages = array_map($mapPackageName, $lastUpdateCheck['updates']['installed']); 60 | 61 | $this->lastUpdateRun 62 | ->for($event->type) 63 | ->with('status', LastUpdateRun::SUCCESS) 64 | ->with('limitedPackages', array_intersect($previousPackages, $lastPackages)) 65 | ->save(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/OutputLogger.php: -------------------------------------------------------------------------------- 1 | logger->info($content); 27 | } else { 28 | $this->logger->error($content); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/RequirePackageValidator.php: -------------------------------------------------------------------------------- 1 | =<_@"*]+){0,1}$/i'; 17 | 18 | protected array $rules = [ 19 | 'package' => ['required', 'string', 'regex:'.self::PACKAGE_NAME_REGEX] 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /src/Settings/JsonSetting.php: -------------------------------------------------------------------------------- 1 | data[$key] = $value; 28 | 29 | return $this; 30 | } 31 | 32 | public function save(): array 33 | { 34 | $lastUpdateCheck = [ 35 | 'checkedAt' => Carbon::now(), 36 | 'updates' => $this->data, 37 | ]; 38 | 39 | $this->settings->set($this->key(), json_encode($lastUpdateCheck)); 40 | 41 | return $lastUpdateCheck; 42 | } 43 | 44 | public function get(): array 45 | { 46 | return json_decode($this->settings->get($this->key()), true); 47 | } 48 | 49 | public static function key(): string 50 | { 51 | return 'flarum-extension-manager.last_update_check'; 52 | } 53 | 54 | public static function default(): array 55 | { 56 | return [ 57 | 'checkedAt' => null, 58 | 'updates' => [ 59 | 'installed' => [], 60 | ], 61 | ]; 62 | } 63 | 64 | public function getNewMajorVersion(): ?string 65 | { 66 | $core = Arr::first(Arr::get($this->get(), 'updates.installed', []), function ($package) { 67 | return $package['name'] === 'flarum/core'; 68 | }); 69 | 70 | return $core ? $core['latest-major'] : null; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Settings/LastUpdateRun.php: -------------------------------------------------------------------------------- 1 | data = self::default(); 27 | } 28 | 29 | public function for(string $update): self 30 | { 31 | if (! in_array($update, [FlarumUpdated::MAJOR, FlarumUpdated::MINOR, FlarumUpdated::GLOBAL])) { 32 | throw new \InvalidArgumentException('Last update runs can only be for one of: minor, major, global'); 33 | } 34 | 35 | $this->activeUpdate = $update; 36 | 37 | return $this; 38 | } 39 | 40 | public function with(string $key, mixed $value): JsonSetting 41 | { 42 | $this->data[$this->activeUpdate][$key] = $value; 43 | 44 | return $this; 45 | } 46 | 47 | public function save(): array 48 | { 49 | $this->data[$this->activeUpdate]['ranAt'] = Carbon::now(); 50 | 51 | $this->settings->set(self::key(), json_encode($this->data)); 52 | 53 | return $this->data; 54 | } 55 | 56 | public function get(): array 57 | { 58 | $lastUpdateRun = json_decode($this->settings->get(self::key()), true); 59 | 60 | if ($this->activeUpdate) { 61 | return $lastUpdateRun[$this->activeUpdate]; 62 | } 63 | 64 | return $lastUpdateRun; 65 | } 66 | 67 | public static function key(): string 68 | { 69 | return 'flarum-extension-manager.last_update_run'; 70 | } 71 | 72 | public static function default(): array 73 | { 74 | $defaultState = [ 75 | 'ranAt' => null, 76 | 'status' => null, 77 | 'limitedPackages' => [], 78 | 'incompatibleExtensions' => [], 79 | ]; 80 | 81 | return [ 82 | FlarumUpdated::GLOBAL => $defaultState, 83 | FlarumUpdated::MINOR => $defaultState, 84 | FlarumUpdated::MAJOR => $defaultState, 85 | ]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Support/Util.php: -------------------------------------------------------------------------------- 1 | __toString()); 46 | 47 | foreach ($input as $key => $value) { 48 | if (str_starts_with($value, '--')) { 49 | if (! str_contains($value, '=')) { 50 | unset($input[$key]); 51 | } else { 52 | $input[$key] = Str::before($value, '='); 53 | } 54 | } 55 | 56 | if (is_numeric($value) && isset($input[$key - 1]) && str_starts_with($input[$key - 1], '-') && ! str_starts_with($input[$key - 1], '--')) { 57 | unset($input[$key]); 58 | } 59 | } 60 | 61 | return implode(' ', $input); 62 | } elseif (method_exists($input, '__toString')) { 63 | return $input->__toString(); 64 | } 65 | 66 | return ''; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Task/Task.php: -------------------------------------------------------------------------------- 1 | 'datetime', 60 | 'started_at' => 'datetime', 61 | 'finished_at' => 'datetime', 62 | ]; 63 | 64 | public static function build(string $operation, ?string $package): self 65 | { 66 | $task = new static; 67 | 68 | $task->operation = $operation; 69 | $task->package = $package; 70 | $task->status = static::PENDING; 71 | $task->created_at = Carbon::now(); 72 | 73 | $task->save(); 74 | 75 | return $task; 76 | } 77 | 78 | public function start(): bool 79 | { 80 | $this->status = static::RUNNING; 81 | $this->started_at = Carbon::now(); 82 | 83 | return $this->save(); 84 | } 85 | 86 | public function end(bool $success): bool 87 | { 88 | if ($this->finished_at) { 89 | return true; 90 | } 91 | 92 | if (! $this->started_at) { 93 | $this->start(); 94 | } 95 | 96 | $this->status = $success ? static::SUCCESS : static::FAILURE; 97 | $this->finished_at = Carbon::now(); 98 | $this->peak_memory_used = round(memory_get_peak_usage() / 1024); 99 | 100 | return $this->save(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/UpdateExtensionValidator.php: -------------------------------------------------------------------------------- 1 | 'required|string', 18 | 'updateMode' => 'required|in:soft,hard', 19 | ]; 20 | } 21 | -------------------------------------------------------------------------------- /src/WhyNotValidator.php: -------------------------------------------------------------------------------- 1 | ['required', 'string', 'regex:'.RequirePackageValidator::PACKAGE_NAME_REGEX], 18 | 'version' => ['sometimes', 'string', 'regex:/(?:\*|[A-z0-9.-]+)/i'] 19 | ]; 20 | } 21 | --------------------------------------------------------------------------------