From 2299541e4d0e57c282b8d1100dc48b67938d1708 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Mon, 18 Dec 2023 13:44:33 +0100 Subject: [PATCH] feat: package manager improvements (#3943) * chore: track * Apply fixes from StyleCI * chore: track * Apply fixes from StyleCI * fix: installing beta packages #3792 * chore: guess package not found error * Apply fixes from StyleCI * feat: queue improvements * feat: queue improvements * fix: issues with job failure and unique runs * fix: enforce one composer action at a time * feat: add cause to queued command output * Apply fixes from StyleCI * feat: add soft & hard extension update options * chore: explain why an extension cannot be removed * chore: remove test * chore: simplify * docs: readme * Apply fixes from StyleCI * fea: allow adding repositories and auth methods * chore: prevent future issues when min stability is set * chore: typings check * fix: phpstan * Apply fixes from StyleCI * fix: bugs --- extensions/package-manager/README.md | 17 ++- extensions/package-manager/extend.php | 29 ++-- .../src/admin/components/AuthMethodModal.tsx | 88 +++++++++++ .../js/src/admin/components/ConfigureAuth.tsx | 105 +++++++++++++ .../admin/components/ConfigureComposer.tsx | 108 +++++++++++++ .../js/src/admin/components/ConfigureJson.tsx | 94 ++++++++++++ .../js/src/admin/components/ExtensionItem.tsx | 25 ++- .../js/src/admin/components/Installer.tsx | 40 +---- .../js/src/admin/components/MajorUpdater.tsx | 52 ++----- .../js/src/admin/components/QueueSection.tsx | 22 +-- .../src/admin/components/RepositoryModal.tsx | 77 ++++++++++ .../js/src/admin/components/SettingsPage.tsx | 46 +++++- .../src/admin/components/TaskOutputModal.tsx | 10 ++ .../js/src/admin/components/Updater.tsx | 26 ++-- .../package-manager/js/src/admin/index.tsx | 20 +-- .../js/src/admin/models/Task.ts | 4 + .../package-manager/js/src/admin/shims.d.ts | 1 + .../src/admin/states/ControlSectionState.ts | 85 +++++++++-- .../js/src/admin/states/QueueState.ts | 25 ++- .../js/src/admin/utils/errorHandler.ts | 4 + .../js/src/admin/utils/jumpToQueue.ts | 5 +- extensions/package-manager/less/admin.less | 31 +++- .../less/admin/ControlSection.less | 50 +++++- .../less/admin/QueueSection.less | 1 + extensions/package-manager/locale/en.yml | 75 ++++++++- ..._column_to_package_manager_tasks_table.php | 14 ++ .../Controller/CheckForUpdatesController.php | 4 - .../ConfigureComposerController.php | 142 ++++++++++++++++++ .../Api/Controller/ListTasksController.php | 15 +- .../Controller/UpdateExtensionController.php | 3 +- .../src/Api/Serializer/TaskSerializer.php | 1 + .../src/Command/AbstractActionCommand.php | 5 + .../src/Command/CheckForUpdatesHandler.php | 53 +++++-- .../src/Command/GlobalUpdateHandler.php | 21 ++- .../src/Command/MajorUpdateHandler.php | 3 - .../src/Command/MinorUpdateHandler.php | 4 +- .../src/Command/RemoveExtension.php | 5 - .../src/Command/RemoveExtensionHandler.php | 17 ++- .../src/Command/RequireExtensionHandler.php | 6 +- .../src/Command/UpdateExtension.php | 5 +- .../src/Command/UpdateExtensionHandler.php | 36 ++--- .../src/Composer/ComposerAdapter.php | 16 +- .../src/Composer/ComposerJson.php | 17 ++- .../src/ConfigureComposerValidator.php | 34 +++++ .../ComposerRequireFailedException.php | 17 ++- ...sionDependencyCannotBeRemovedException.php | 26 ++++ .../src/Extension/ExtensionUtils.php | 21 --- .../src/Job/ComposerCommandJob.php | 19 ++- .../package-manager/src/Job/Dispatcher.php | 34 ++++- .../src/PackageManagerServiceProvider.php | 13 +- .../package-manager/src/Support/Util.php | 73 +++++++++ extensions/package-manager/src/Task/Task.php | 15 +- .../src/UpdateExtensionValidator.php | 3 +- .../tests/integration/TestCase.php | 4 +- 54 files changed, 1390 insertions(+), 276 deletions(-) create mode 100644 extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx create mode 100644 extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx create mode 100644 extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx create mode 100644 extensions/package-manager/js/src/admin/components/ConfigureJson.tsx create mode 100644 extensions/package-manager/js/src/admin/components/RepositoryModal.tsx create mode 100644 extensions/package-manager/migrations/2023_12_09_000000_add_guessed_cause_column_to_package_manager_tasks_table.php create mode 100755 extensions/package-manager/src/Api/Controller/ConfigureComposerController.php create mode 100644 extensions/package-manager/src/ConfigureComposerValidator.php create mode 100755 extensions/package-manager/src/Exception/IndirectExtensionDependencyCannotBeRemovedException.php delete mode 100755 extensions/package-manager/src/Extension/ExtensionUtils.php create mode 100755 extensions/package-manager/src/Support/Util.php diff --git a/extensions/package-manager/README.md b/extensions/package-manager/README.md index 036b90a83f..9716bc0821 100755 --- a/extensions/package-manager/README.md +++ b/extensions/package-manager/README.md @@ -1,5 +1,18 @@ # Package Manager -*An Experiment.* +The package manager is a tool that allows you to easily install and manage extensions. It runs [composer](https://getcomposer.org/) under the hood. -Read: https://github.com/flarum/package-manager/wiki +## Security + +If admin access is given to untrustworthy users, they can install malicious extensions. Please be careful. + +This extension is optional and can be removed for those who prefer to manually manage installs and updates through the command line interface. + +## Troubleshooting + +If you have many extensions installed, you may run into memory issues when using the package manager. If this happens, you can use an asynchronous queue that will run the package manager in the background. + +* Simple database queue guide: https://discuss.flarum.org/d/28151-database-queue-the-simplest-queue-even-for-shared-hosting +* (Advanced) Redis queue: https://discuss.flarum.org/d/21873-redis-sessions-cache-queues + +You can find detailed logs on the package 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). diff --git a/extensions/package-manager/extend.php b/extensions/package-manager/extend.php index 0a617e8e06..e702889df7 100755 --- a/extensions/package-manager/extend.php +++ b/extensions/package-manager/extend.php @@ -12,13 +12,6 @@ use Flarum\Extend; use Flarum\Foundation\Paths; use Flarum\Frontend\Document; -use Flarum\PackageManager\Exception\ComposerCommandFailedException; -use Flarum\PackageManager\Exception\ComposerRequireFailedException; -use Flarum\PackageManager\Exception\ComposerUpdateFailedException; -use Flarum\PackageManager\Exception\ExceptionHandler; -use Flarum\PackageManager\Exception\MajorUpdateFailedException; -use Flarum\PackageManager\Settings\LastUpdateCheck; -use Flarum\PackageManager\Settings\LastUpdateRun; use Illuminate\Contracts\Queue\Queue; use Illuminate\Queue\SyncQueue; @@ -32,7 +25,8 @@ ->post('/package-manager/minor-update', 'package-manager.minor-update', Api\Controller\MinorUpdateController::class) ->post('/package-manager/major-update', 'package-manager.major-update', Api\Controller\MajorUpdateController::class) ->post('/package-manager/global-update', 'package-manager.global-update', Api\Controller\GlobalUpdateController::class) - ->get('/package-manager-tasks', 'package-manager.tasks.index', Api\Controller\ListTasksController::class), + ->get('/package-manager-tasks', 'package-manager.tasks.index', Api\Controller\ListTasksController::class) + ->post('/package-manager/composer', 'package-manager.composer', Api\Controller\ConfigureComposerController::class), (new Extend\Frontend('admin')) ->css(__DIR__.'/less/admin.less') @@ -52,19 +46,22 @@ new Extend\Locales(__DIR__.'/locale'), (new Extend\Settings()) - ->default(LastUpdateCheck::key(), json_encode(LastUpdateCheck::default())) - ->default(LastUpdateRun::key(), json_encode(LastUpdateRun::default())) - ->default('flarum-package-manager.queue_jobs', false), + ->default(Settings\LastUpdateCheck::key(), json_encode(Settings\LastUpdateCheck::default())) + ->default(Settings\LastUpdateRun::key(), json_encode(Settings\LastUpdateRun::default())) + ->default('flarum-package-manager.queue_jobs', false) + ->default('flarum-package-manager.minimum_stability', 'stable') + ->default('flarum-package-manager.task_retention_days', 7), (new Extend\ServiceProvider) ->register(PackageManagerServiceProvider::class), (new Extend\ErrorHandling) - ->handler(ComposerCommandFailedException::class, ExceptionHandler::class) - ->handler(ComposerRequireFailedException::class, ExceptionHandler::class) - ->handler(ComposerUpdateFailedException::class, ExceptionHandler::class) - ->handler(MajorUpdateFailedException::class, ExceptionHandler::class) + ->handler(Exception\ComposerCommandFailedException::class, Exception\ExceptionHandler::class) + ->handler(Exception\ComposerRequireFailedException::class, Exception\ExceptionHandler::class) + ->handler(Exception\ComposerUpdateFailedException::class, Exception\ExceptionHandler::class) + ->handler(Exception\MajorUpdateFailedException::class, Exception\ExceptionHandler::class) ->status('extension_already_installed', 409) ->status('extension_not_installed', 409) - ->status('no_new_major_version', 409), + ->status('no_new_major_version', 409) + ->status('extension_not_directly_dependency', 409), ]; diff --git a/extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx b/extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx new file mode 100644 index 0000000000..1629c35b4a --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/AuthMethodModal.tsx @@ -0,0 +1,88 @@ +import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import Mithril from 'mithril'; +import app from 'flarum/admin/app'; +import Select from 'flarum/common/components/Select'; +import Stream from 'flarum/common/utils/Stream'; +import Button from 'flarum/common/components/Button'; +import extractText from 'flarum/common/utils/extractText'; + +export interface IAuthMethodModalAttrs extends IInternalModalAttrs { + onsubmit: (type: string, host: string, token: string) => void; + type?: string; + host?: string; + token?: string; +} + +export default class AuthMethodModal extends Modal { + protected type!: Stream; + protected host!: Stream; + protected token!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.type = Stream(this.attrs.type || 'bearer'); + this.host = Stream(this.attrs.host || ''); + this.token = Stream(this.attrs.token || ''); + } + + className(): string { + return 'AuthMethodModal Modal--small'; + } + + title(): Mithril.Children { + const context = this.attrs.host ? 'edit' : 'add'; + return app.translator.trans(`flarum-package-manager.admin.auth_config.${context}_label`); + } + + content(): Mithril.Children { + const types = { + 'github-oauth': app.translator.trans('flarum-package-manager.admin.auth_config.types.github-oauth'), + 'gitlab-oauth': app.translator.trans('flarum-package-manager.admin.auth_config.types.gitlab-oauth'), + 'gitlab-token': app.translator.trans('flarum-package-manager.admin.auth_config.types.gitlab-token'), + bearer: app.translator.trans('flarum-package-manager.admin.auth_config.types.bearer'), + }; + + return ( +
+
+ + +
+
+ + +
+
+ +
+
+ ); + } + + submit() { + this.attrs.onsubmit(this.type(), this.host(), this.token()); + this.hide(); + } +} diff --git a/extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx b/extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx new file mode 100644 index 0000000000..7e285048fc --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/ConfigureAuth.tsx @@ -0,0 +1,105 @@ +import app from 'flarum/admin/app'; +import type Mithril from 'mithril'; +import ConfigureJson, { IConfigureJson } from './ConfigureJson'; +import Button from 'flarum/common/components/Button'; +import AuthMethodModal from './AuthMethodModal'; +import extractText from 'flarum/common/utils/extractText'; + +export default class ConfigureAuth extends ConfigureJson { + protected type = 'auth'; + + title(): Mithril.Children { + return app.translator.trans('flarum-package-manager.admin.auth_config.title'); + } + + className(): string { + return 'ConfigureAuth'; + } + + content(): Mithril.Children { + const authSettings = Object.keys(this.settings); + + return ( +
+ {authSettings.length ? ( + authSettings.map((type) => { + const hosts = this.settings[type](); + + return ( +
+ +
+ {Object.keys(hosts).map((host) => { + const data = hosts[host] as string | Record; + + return ( +
+ +
+ ); + })} +
+
+ ); + }) + ) : ( + {app.translator.trans('flarum-package-manager.admin.auth_config.no_auth_methods_configured')} + )} +
+ ); + } + + submitButton(): Mithril.Children[] { + const items = super.submitButton(); + + items.push( + + ); + + return items; + } + + onchange(type: string, host: string, token: string) { + this.setting(type)({ ...this.setting(type)(), [host]: token }); + } +} diff --git a/extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx b/extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx new file mode 100644 index 0000000000..e6e8da6f10 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/ConfigureComposer.tsx @@ -0,0 +1,108 @@ +import app from 'flarum/admin/app'; +import type Mithril from 'mithril'; +import ConfigureJson, { type IConfigureJson } from './ConfigureJson'; +import Button from 'flarum/common/components/Button'; +import extractText from 'flarum/common/utils/extractText'; +import RepositoryModal from './RepositoryModal'; + +export type Repository = { + type: 'composer' | 'vcs' | 'path'; + url: string; +}; + +export default class ConfigureComposer extends ConfigureJson { + protected type = 'composer'; + + title(): Mithril.Children { + return app.translator.trans('flarum-package-manager.admin.composer.title'); + } + + className(): string { + return 'ConfigureComposer'; + } + + content(): Mithril.Children { + return ( +
+ {this.attrs.buildSettingComponent.call(this, { + setting: 'minimum-stability', + label: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.label'), + help: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.help'), + type: 'select', + options: { + stable: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.options.stable'), + RC: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.options.rc'), + beta: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.options.beta'), + alpha: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.options.alpha'), + dev: app.translator.trans('flarum-package-manager.admin.composer.minimum_stability.options.dev'), + }, + })} +
+ +
{app.translator.trans('flarum-package-manager.admin.composer.repositories.help')}
+
+ {Object.keys(this.setting('repositories')() || {}).map((name) => { + const repository = this.setting('repositories')()[name] as Repository; + + return ( +
+ +
+ ); + })} +
+
+
+ ); + } + + submitButton(): Mithril.Children[] { + const items = super.submitButton(); + + items.push( + + ); + + return items; + } + + onchange(repository: Repository, name: string) { + this.setting('repositories')({ + ...this.setting('repositories')(), + [name]: repository, + }); + } +} diff --git a/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx new file mode 100644 index 0000000000..e15ae82956 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/ConfigureJson.tsx @@ -0,0 +1,94 @@ +import app from 'flarum/admin/app'; +import type Mithril from 'mithril'; +import Component, { type ComponentAttrs } from 'flarum/common/Component'; +import { CommonSettingsItemOptions, type SettingsComponentOptions } from '@flarum/core/src/admin/components/AdminPage'; +import AdminPage from 'flarum/admin/components/AdminPage'; +import type ItemList from 'flarum/common/utils/ItemList'; +import Stream from 'flarum/common/utils/Stream'; +import Button from 'flarum/common/components/Button'; +import classList from 'flarum/common/utils/classList'; + +export interface IConfigureJson extends ComponentAttrs { + buildSettingComponent: (entry: ((this: this) => Mithril.Children) | SettingsComponentOptions) => Mithril.Children; +} + +export default abstract class ConfigureJson extends Component { + protected settings: Record> = {}; + protected initialSettings: Record | null = null; + protected loading: boolean = false; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.submit(true); + } + + protected abstract type: string; + abstract title(): Mithril.Children; + abstract content(): Mithril.Children; + + className(): string { + return ''; + } + + view(): Mithril.Children { + return ( +
+ + {this.content()} +
{this.submitButton()}
+
+ ); + } + + submitButton(): Mithril.Children[] { + return [ + , + ]; + } + + customSettingComponents(): ItemList<(attributes: CommonSettingsItemOptions) => Mithril.Children> { + return AdminPage.prototype.customSettingComponents(); + } + + setting(key: string) { + return this.settings[key] ?? (this.settings[key] = Stream()); + } + + submit(readOnly: boolean) { + this.loading = true; + + const configuration: any = {}; + + Object.keys(this.settings).forEach((key) => { + configuration[key] = this.settings[key](); + }); + + app + .request({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/package-manager/composer', + body: { + type: this.type, + data: readOnly ? null : configuration, + }, + }) + .then(({ data }: any) => { + Object.keys(data).forEach((key) => { + this.settings[key] = Stream(data[key]); + }); + + this.initialSettings = Array.isArray(data) ? {} : data; + }) + .finally(() => { + this.loading = false; + m.redraw(); + }); + } + + isDirty() { + return JSON.stringify(this.initialSettings) !== JSON.stringify(this.settings); + } +} diff --git a/extensions/package-manager/js/src/admin/components/ExtensionItem.tsx b/extensions/package-manager/js/src/admin/components/ExtensionItem.tsx index aa313800ac..9d8c2e3f7e 100644 --- a/extensions/package-manager/js/src/admin/components/ExtensionItem.tsx +++ b/extensions/package-manager/js/src/admin/components/ExtensionItem.tsx @@ -10,11 +10,17 @@ import { Extension } from 'flarum/admin/AdminApplication'; import { UpdatedPackage } from '../states/ControlSectionState'; import WhyNotModal from './WhyNotModal'; import Label from './Label'; +import Dropdown from 'flarum/common/components/Dropdown'; export interface ExtensionItemAttrs extends ComponentAttrs { extension: Extension; updates: UpdatedPackage; - onClickUpdate: CallableFunction; + onClickUpdate: + | CallableFunction + | { + soft: CallableFunction; + hard: CallableFunction; + }; whyNotWarning?: boolean; isCore?: boolean; updatable?: boolean; @@ -49,7 +55,7 @@ export default class ExtensionItem
- {onClickUpdate ? ( + {onClickUpdate && typeof onClickUpdate === 'function' ? ( + + ) : null} {whyNotWarning ? ( @@ -75,6 +94,6 @@ export default class ExtensionItem {

{app.translator.trans('flarum-package-manager.admin.extensions.install_help', { extiverse: extiverse.com, + semantic_link: , + code: , })}

@@ -38,7 +35,7 @@ export default class Installer extends Component { icon="fas fa-download" onclick={this.onsubmit.bind(this)} loading={app.packageManager.control.isLoading('extension-install')} - disabled={app.packageManager.control.isLoadingOtherThan('extension-install')} + disabled={app.packageManager.control.isLoading()} > {app.translator.trans('flarum-package-manager.admin.extensions.proceed')} @@ -54,35 +51,6 @@ export default class Installer extends Component { } onsubmit(): void { - app.packageManager.control.setLoading('extension-install'); - app.modal.show(LoadingModal); - - app - .request({ - method: 'POST', - url: `${app.forum.attribute('apiUrl')}/package-manager/extensions`, - body: { - data: this.data(), - }, - }) - .then((response) => { - if (response.processing) { - jumpToQueue(); - } else { - const extensionId = response.id; - app.alerts.show( - { type: 'success' }, - app.translator.trans('flarum-package-manager.admin.extensions.successful_install', { extension: extensionId }) - ); - window.location.href = `${app.forum.attribute('adminUrl')}#/extension/${extensionId}`; - window.location.reload(); - } - }) - .catch(errorHandler) - .finally(() => { - app.packageManager.control.setLoading(null); - app.modal.close(); - m.redraw(); - }); + app.packageManager.control.requirePackage(this.data()); } } diff --git a/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx b/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx index e9c5cf8233..d6226696d2 100644 --- a/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx +++ b/extensions/package-manager/js/src/admin/components/MajorUpdater.tsx @@ -3,16 +3,12 @@ import app from 'flarum/admin/app'; import Component, { ComponentAttrs } from 'flarum/common/Component'; import Button from 'flarum/common/components/Button'; import Tooltip from 'flarum/common/components/Tooltip'; -import LoadingModal from 'flarum/admin/components/LoadingModal'; import Alert from 'flarum/common/components/Alert'; -import RequestError from 'flarum/common/utils/RequestError'; import { UpdatedPackage, UpdateState } from '../states/ControlSectionState'; -import errorHandler from '../utils/errorHandler'; import WhyNotModal from './WhyNotModal'; import ExtensionItem from './ExtensionItem'; -import { AsyncBackendResponse } from '../shims'; -import jumpToQueue from '../utils/jumpToQueue'; +import classList from 'flarum/common/utils/classList'; export interface MajorUpdaterAttrs extends ComponentAttrs { coreUpdate: UpdatedPackage; @@ -33,18 +29,18 @@ export default class MajorUpdater +
flarum logo

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

- @@ -52,7 +48,7 @@ export default class MajorUpdater {app.translator.trans('flarum-package-manager.admin.major_updater.update')} @@ -94,34 +90,6 @@ export default class MajorUpdater({ - method: 'POST', - url: `${app.forum.attribute('apiUrl')}/package-manager/major-update`, - body: { - data: { dryRun }, - }, - }) - .then((response) => { - if (response?.processing) { - jumpToQueue(); - } else { - app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful')); - window.location.reload(); - } - }) - .catch(errorHandler) - .catch((e: RequestError) => { - app.modal.close(); - this.updateState.status = 'failure'; - this.updateState.incompatibleExtensions = e.response?.errors?.pop()?.incompatible_extensions as string[]; - }) - .finally(() => { - app.packageManager.control.setLoading(null); - m.redraw(); - }); + app.packageManager.control.majorUpdate({ dryRun }); } } diff --git a/extensions/package-manager/js/src/admin/components/QueueSection.tsx b/extensions/package-manager/js/src/admin/components/QueueSection.tsx index f013e79872..9be56e1bd1 100644 --- a/extensions/package-manager/js/src/admin/components/QueueSection.tsx +++ b/extensions/package-manager/js/src/admin/components/QueueSection.tsx @@ -8,6 +8,7 @@ import { Extension } from 'flarum/admin/AdminApplication'; import icon from 'flarum/common/helpers/icon'; import ItemList from 'flarum/common/utils/ItemList'; import extractText from 'flarum/common/utils/extractText'; +import Link from 'flarum/common/components/Link'; import Label from './Label'; import TaskOutputModal from './TaskOutputModal'; @@ -73,7 +74,7 @@ export default class QueueSection extends Component<{}> { const extension: Extension | null = app.data.extensions[task.package()?.replace(/(\/flarum-|\/flarum-ext-|\/)/g, '-')]; return extension ? ( -
+
{!!extension.icon && icon(extension.icon.name)}
@@ -81,7 +82,7 @@ export default class QueueSection extends Component<{}> { {extension.extra['flarum-extension'].title} {task.package()}
-
+ ) : ( task.package() ); @@ -95,12 +96,15 @@ export default class QueueSection extends Component<{}> { { label: extractText(app.translator.trans('flarum-package-manager.admin.sections.queue.columns.status')), content: (task) => ( - + <> + + {['pending', 'running'].includes(task.status()) && } + ), }, 70 @@ -111,7 +115,7 @@ export default class QueueSection extends Component<{}> { { label: extractText(app.translator.trans('flarum-package-manager.admin.sections.queue.columns.elapsed_time')), content: (task) => - !task.startedAt() ? ( + !task.startedAt() || !task.finishedAt() ? ( app.translator.trans('flarum-package-manager.admin.sections.queue.task_just_started') ) : ( diff --git a/extensions/package-manager/js/src/admin/components/RepositoryModal.tsx b/extensions/package-manager/js/src/admin/components/RepositoryModal.tsx new file mode 100644 index 0000000000..443e483e72 --- /dev/null +++ b/extensions/package-manager/js/src/admin/components/RepositoryModal.tsx @@ -0,0 +1,77 @@ +import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import Mithril from 'mithril'; +import app from 'flarum/admin/app'; +import Select from 'flarum/common/components/Select'; +import Stream from 'flarum/common/utils/Stream'; +import Button from 'flarum/common/components/Button'; +import { type Repository } from './ConfigureComposer'; + +export interface IRepositoryModalAttrs extends IInternalModalAttrs { + onsubmit: (repository: Repository, key: string) => void; + name?: string; + repository?: Repository; +} + +export default class RepositoryModal extends Modal { + protected name!: Stream; + protected repository!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.name = Stream(this.attrs.name || ''); + this.repository = Stream(this.attrs.repository || { type: 'composer', url: '' }); + } + + className(): string { + return 'RepositoryModal Modal--small'; + } + + title(): Mithril.Children { + const context = this.attrs.repository ? 'edit' : 'add'; + return app.translator.trans(`flarum-package-manager.admin.composer.${context}_repository_label`); + } + + content(): Mithril.Children { + const types = { + composer: app.translator.trans('flarum-package-manager.admin.composer.repositories.types.composer'), + vcs: app.translator.trans('flarum-package-manager.admin.composer.repositories.types.vcs'), + path: app.translator.trans('flarum-package-manager.admin.composer.repositories.types.path'), + }; + + return ( +
+
+ + +
+
+ + this.repository({ ...this.repository(), url: (e.target as HTMLInputElement).value })} + value={this.repository().url} + /> +
+
+ +
+
+ ); + } + + submit() { + this.attrs.onsubmit(this.repository(), this.name()); + this.hide(); + } +} diff --git a/extensions/package-manager/js/src/admin/components/SettingsPage.tsx b/extensions/package-manager/js/src/admin/components/SettingsPage.tsx index 6328ff9572..1c64435f8f 100644 --- a/extensions/package-manager/js/src/admin/components/SettingsPage.tsx +++ b/extensions/package-manager/js/src/admin/components/SettingsPage.tsx @@ -5,8 +5,45 @@ import ItemList from 'flarum/common/utils/ItemList'; import QueueSection from './QueueSection'; import ControlSection from './ControlSection'; +import ConfigureComposer from './ConfigureComposer'; +import Alert from 'flarum/common/components/Alert'; +import listItems from 'flarum/common/helpers/listItems'; +import ConfigureAuth from './ConfigureAuth'; export default class SettingsPage extends ExtensionPage { + content() { + const settings = app.extensionData.getSettings(this.extension.id); + + const warnings = [app.translator.trans('flarum-package-manager.admin.settings.access_warning')]; + + if (app.data.debugEnabled) warnings.push(app.translator.trans('flarum-package-manager.admin.settings.debug_mode_warning')); + + return ( +
+
+
+ +
    {listItems(warnings)}
+
+
+ {settings ? ( +
+
+ +
{settings.map(this.buildSettingComponent.bind(this))}
+
{this.submitButton()}
+
+ + +
+ ) : ( +

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

+ )} +
+
+ ); + } + sections(vnode: Mithril.VnodeDOM): ItemList { const items = super.sections(vnode); @@ -14,12 +51,17 @@ export default class SettingsPage extends ExtensionPage { items.add('control', , 8); - if (parseInt(app.data.settings['flarum-package-manager.queue_jobs'])) { + if (app.data.settings['flarum-package-manager.queue_jobs'] !== '0' && app.data.settings['flarum-package-manager.queue_jobs']) { items.add('queue', , 5); } - items.setPriority('permissions', 0); + items.remove('permissions'); return items; } + + onsaved() { + super.onsaved(); + m.redraw(); + } } diff --git a/extensions/package-manager/js/src/admin/components/TaskOutputModal.tsx b/extensions/package-manager/js/src/admin/components/TaskOutputModal.tsx index a1f6b008fd..aa509dcc16 100644 --- a/extensions/package-manager/js/src/admin/components/TaskOutputModal.tsx +++ b/extensions/package-manager/js/src/admin/components/TaskOutputModal.tsx @@ -19,12 +19,22 @@ export default class TaskOutputModal
+
+ +
+ {(this.attrs.task.guessedCause() && + app.translator.trans('flarum-package-manager.admin.exceptions.guessed_cause.' + this.attrs.task.guessedCause())) || + app.translator.trans('flarum-package-manager.admin.sections.queue.output_modal.cause_unknown')} +
+
+
$ composer {this.attrs.task.command()}
+
diff --git a/extensions/package-manager/js/src/admin/components/Updater.tsx b/extensions/package-manager/js/src/admin/components/Updater.tsx index 9cf086fda5..e6c35458eb 100755 --- a/extensions/package-manager/js/src/admin/components/Updater.tsx +++ b/extensions/package-manager/js/src/admin/components/Updater.tsx @@ -6,7 +6,6 @@ import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; import MajorUpdater from './MajorUpdater'; import ExtensionItem from './ExtensionItem'; import { Extension } from 'flarum/admin/AdminApplication'; -import Alert from 'flarum/common/components/Alert'; import ItemList from 'flarum/common/utils/ItemList'; export interface IUpdaterAttrs extends ComponentAttrs {} @@ -48,7 +47,7 @@ export default class Updater extends Component { availableUpdatesView() { const state = app.packageManager.control; - if (app.packageManager.control.isLoading()) { + if (app.packageManager.control.isLoading('check') || app.packageManager.control.isLoading('global-update')) { return (
@@ -56,12 +55,12 @@ export default class Updater extends Component { ); } - if (!(state.extensionUpdates.length || state.coreUpdate)) { + const hasMinorCoreUpdate = state.coreUpdate && state.coreUpdate.package['latest-minor']; + + if (!(state.extensionUpdates.length || hasMinorCoreUpdate)) { return (
- - {app.translator.trans('flarum-package-manager.admin.updater.up_to_date')} - + {app.translator.trans('flarum-package-manager.admin.updater.up_to_date')}
); } @@ -69,10 +68,10 @@ export default class Updater extends Component { return (
- {state.coreUpdate ? ( + {hasMinorCoreUpdate ? ( state.updateCoreMinor()} whyNotWarning={state.lastUpdateRun.limitedPackages().includes('flarum/core')} @@ -82,7 +81,10 @@ export default class Updater extends Component { state.updateExtension(extension)} + onClickUpdate={{ + soft: () => state.updateExtension(extension, 'soft'), + hard: () => state.updateExtension(extension, 'hard'), + }} whyNotWarning={state.lastUpdateRun.limitedPackages().includes(extension.name)} /> ))} @@ -101,7 +103,7 @@ export default class Updater extends Component { icon="fas fa-sync-alt" onclick={() => app.packageManager.control.checkForUpdates()} loading={app.packageManager.control.isLoading('check')} - disabled={app.packageManager.control.isLoadingOtherThan('check')} + disabled={app.packageManager.control.isLoading()} > {app.translator.trans('flarum-package-manager.admin.updater.check_for_updates')} , @@ -115,7 +117,7 @@ export default class Updater extends Component { icon="fas fa-play" onclick={() => app.packageManager.control.updateGlobally()} loading={app.packageManager.control.isLoading('global-update')} - disabled={app.packageManager.control.isLoadingOtherThan('global-update')} + disabled={app.packageManager.control.isLoading()} > {app.translator.trans('flarum-package-manager.admin.updater.run_global_update')} diff --git a/extensions/package-manager/js/src/admin/index.tsx b/extensions/package-manager/js/src/admin/index.tsx index 239ca90e7a..67fe6672b5 100755 --- a/extensions/package-manager/js/src/admin/index.tsx +++ b/extensions/package-manager/js/src/admin/index.tsx @@ -18,15 +18,12 @@ app.initializers.add('flarum-package-manager', (app) => { app.packageManager = new PackageManagerState(); + if (app.data['flarum-package-manager.using_sync_queue']) { + app.data.settings['flarum-package-manager.queue_jobs'] = '0'; + } + app.extensionData .for('flarum-package-manager') - .registerSetting(() => ( -
- - {app.translator.trans('flarum-package-manager.admin.settings.access_warning')} - -
- )) .registerSetting({ setting: 'flarum-package-manager.queue_jobs', label: app.translator.trans('flarum-package-manager.admin.settings.queue_jobs'), @@ -40,10 +37,15 @@ app.initializers.add('flarum-package-manager', (app) => { }) ) ), - default: false, type: 'boolean', disabled: app.data['flarum-package-manager.using_sync_queue'], }) + .registerSetting({ + setting: 'flarum-package-manager.task_retention_days', + label: app.translator.trans('flarum-package-manager.admin.settings.task_retention_days'), + help: app.translator.trans('flarum-package-manager.admin.settings.task_retention_days_help'), + type: 'number', + }) .registerPage(SettingsPage); extend(ExtensionPage.prototype, 'topItems', function (items) { @@ -77,7 +79,7 @@ app.initializers.add('flarum-package-manager', (app) => { }); }} > - Remove + {app.translator.trans('flarum-package-manager.admin.extensions.remove')} ); }); diff --git a/extensions/package-manager/js/src/admin/models/Task.ts b/extensions/package-manager/js/src/admin/models/Task.ts index 26769f5c87..9b7a1eae9f 100644 --- a/extensions/package-manager/js/src/admin/models/Task.ts +++ b/extensions/package-manager/js/src/admin/models/Task.ts @@ -32,6 +32,10 @@ export default class Task extends Model { return Model.attribute('output').call(this); } + guessedCause() { + return Model.attribute('guessedCause').call(this); + } + createdAt() { return Model.attribute('createdAt', Model.transformDate).call(this); } diff --git a/extensions/package-manager/js/src/admin/shims.d.ts b/extensions/package-manager/js/src/admin/shims.d.ts index 3e423b609a..2a45f961d8 100644 --- a/extensions/package-manager/js/src/admin/shims.d.ts +++ b/extensions/package-manager/js/src/admin/shims.d.ts @@ -1,3 +1,4 @@ +import 'dayjs/plugin/relativeTime'; import PackageManagerState from './states/PackageManagerState'; export interface AsyncBackendResponse { diff --git a/extensions/package-manager/js/src/admin/states/ControlSectionState.ts b/extensions/package-manager/js/src/admin/states/ControlSectionState.ts index fe75c5985c..ad676642f5 100644 --- a/extensions/package-manager/js/src/admin/states/ControlSectionState.ts +++ b/extensions/package-manager/js/src/admin/states/ControlSectionState.ts @@ -8,6 +8,7 @@ import errorHandler from '../utils/errorHandler'; import jumpToQueue from '../utils/jumpToQueue'; import { Extension } from 'flarum/admin/AdminApplication'; import extractText from 'flarum/common/utils/extractText'; +import RequestError from 'flarum/common/utils/RequestError'; export type UpdatedPackage = { name: string; @@ -16,6 +17,8 @@ export type UpdatedPackage = { 'latest-minor': string | null; 'latest-major': string | null; 'latest-status': string; + 'required-as': string; + 'direct-dependency': boolean; description: string; }; @@ -43,7 +46,7 @@ export type LastUpdateRun = { limitedPackages: () => string[]; }; -export type LoadingTypes = UpdaterLoadingTypes | InstallerLoadingTypes | MajorUpdaterLoadingTypes; +export type LoadingTypes = UpdaterLoadingTypes | InstallerLoadingTypes | MajorUpdaterLoadingTypes | 'queued-action'; export type CoreUpdate = { package: UpdatedPackage; @@ -79,14 +82,42 @@ export default class ControlSectionState { return (name && this.loading === name) || (!name && this.loading !== null); } - isLoadingOtherThan(name: LoadingTypes): boolean { - return this.loading !== null && this.loading !== name; - } - setLoading(name: LoadingTypes): void { this.loading = name; } + requirePackage(data: any) { + app.packageManager.control.setLoading('extension-install'); + app.modal.show(LoadingModal); + + app + .request({ + method: 'POST', + url: `${app.forum.attribute('apiUrl')}/package-manager/extensions`, + body: { + data, + }, + }) + .then((response) => { + if (response.processing) { + jumpToQueue(); + } else { + const extensionId = response.id; + app.alerts.show( + { type: 'success' }, + app.translator.trans('flarum-package-manager.admin.extensions.successful_install', { extension: extensionId }) + ); + window.location.href = `${app.forum.attribute('adminUrl')}#/extension/${extensionId}`; + window.location.reload(); + } + }) + .catch(errorHandler) + .finally(() => { + app.modal.close(); + m.redraw(); + }); + } + checkForUpdates() { this.setLoading('check'); @@ -102,12 +133,12 @@ export default class ControlSectionState { this.lastUpdateCheck = response as LastUpdateCheck; this.extensionUpdates = this.formatExtensionUpdates(response as LastUpdateCheck); this.coreUpdate = this.formatCoreUpdate(response as LastUpdateCheck); + this.setLoading(null); m.redraw(); } }) .catch(errorHandler) .finally(() => { - this.setLoading(null); m.redraw(); }); } @@ -132,14 +163,13 @@ export default class ControlSectionState { }) .catch(errorHandler) .finally(() => { - this.setLoading(null); app.modal.close(); m.redraw(); }); } } - updateExtension(extension: Extension) { + updateExtension(extension: Extension, updateMode: 'soft' | 'hard') { app.modal.show(LoadingModal); this.setLoading('extension-update'); @@ -147,6 +177,11 @@ export default class ControlSectionState { .request({ method: 'PATCH', url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`, + body: { + data: { + updateMode, + }, + }, }) .then((response) => { if (response?.processing) { @@ -163,7 +198,6 @@ export default class ControlSectionState { }) .catch(errorHandler) .finally(() => { - this.setLoading(null); app.modal.close(); m.redraw(); }); @@ -188,7 +222,6 @@ export default class ControlSectionState { }) .catch(errorHandler) .finally(() => { - this.setLoading(null); app.modal.close(); m.redraw(); }); @@ -236,4 +269,36 @@ export default class ControlSectionState { }, }; } + + majorUpdate({ dryRun }: { dryRun: boolean }) { + app.packageManager.control.setLoading(dryRun ? 'major-update-dry-run' : 'major-update'); + app.modal.show(LoadingModal); + const updateState = this.lastUpdateRun.major; + + app + .request({ + method: 'POST', + url: `${app.forum.attribute('apiUrl')}/package-manager/major-update`, + body: { + data: { dryRun }, + }, + }) + .then((response) => { + if (response?.processing) { + jumpToQueue(); + } else { + app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful')); + window.location.reload(); + } + }) + .catch(errorHandler) + .catch((e: RequestError) => { + app.modal.close(); + updateState.status = 'failure'; + updateState.incompatibleExtensions = e.response?.errors?.pop()?.incompatible_extensions as string[]; + }) + .finally(() => { + m.redraw(); + }); + } } diff --git a/extensions/package-manager/js/src/admin/states/QueueState.ts b/extensions/package-manager/js/src/admin/states/QueueState.ts index 492e415a7a..7d0e8a009c 100644 --- a/extensions/package-manager/js/src/admin/states/QueueState.ts +++ b/extensions/package-manager/js/src/admin/states/QueueState.ts @@ -3,12 +3,13 @@ import Task from '../models/Task'; import { ApiQueryParamsPlural } from 'flarum/common/Store'; export default class QueueState { + private polling: any = null; private tasks: Task[] | null = null; private limit = 20; private offset = 0; private total = 0; - load(params?: ApiQueryParamsPlural) { + load(params?: ApiQueryParamsPlural, actionTaken = false): Promise { this.tasks = null; params = { page: { @@ -25,6 +26,18 @@ export default class QueueState { m.redraw(); + // Check if there is a pending or running task + const pendingTask = data?.find((task) => task.status() === 'pending' || task.status() === 'running'); + + if (pendingTask) { + this.pollQueue(actionTaken); + } else if (actionTaken) { + app.packageManager.control.setLoading(null); + + // Refresh the page + window.location.reload(); + } + return data; }); } @@ -62,4 +75,14 @@ export default class QueueState { this.load(); } } + + pollQueue(actionTaken = false): void { + if (this.polling) { + clearTimeout(this.polling); + } + + this.polling = setTimeout(() => { + this.load({}, actionTaken); + }, 6000); + } } diff --git a/extensions/package-manager/js/src/admin/utils/errorHandler.ts b/extensions/package-manager/js/src/admin/utils/errorHandler.ts index bf0681620a..0242753a3f 100755 --- a/extensions/package-manager/js/src/admin/utils/errorHandler.ts +++ b/extensions/package-manager/js/src/admin/utils/errorHandler.ts @@ -1,12 +1,16 @@ import app from 'flarum/admin/app'; export default function (e: any) { + app.packageManager.control.setLoading(null); + const error = e.response.errors[0]; if (!['composer_command_failure', 'extension_already_installed', 'extension_not_installed'].includes(error.code)) { throw e; } + app.alerts.clear(); + switch (error.code) { case 'composer_command_failure': if (error.guessed_cause) { diff --git a/extensions/package-manager/js/src/admin/utils/jumpToQueue.ts b/extensions/package-manager/js/src/admin/utils/jumpToQueue.ts index 0c0c3a08e4..0045f59ad3 100644 --- a/extensions/package-manager/js/src/admin/utils/jumpToQueue.ts +++ b/extensions/package-manager/js/src/admin/utils/jumpToQueue.ts @@ -5,8 +5,11 @@ window.jumpToQueue = jumpToQueue; export default function jumpToQueue(): void { app.modal.close(); + m.route.set(app.route('extension', { id: 'flarum-package-manager' })); - app.packageManager.queue.load(); + + app.packageManager.queue.load({}, true); + setTimeout(() => { document.getElementById('PackageManager-queueSection')?.scrollIntoView({ block: 'nearest' }); }, 200); diff --git a/extensions/package-manager/less/admin.less b/extensions/package-manager/less/admin.less index 3b52522153..44622f7125 100755 --- a/extensions/package-manager/less/admin.less +++ b/extensions/package-manager/less/admin.less @@ -3,7 +3,7 @@ @import "admin/QueueSection"; @import "admin/ControlSection"; -.PackageManager-controlSection, .PackageManager-queueSection { +.PackageManager-controlSection { > .container { padding-bottom: 0; } @@ -27,3 +27,32 @@ opacity: 0.6; cursor: not-allowed; } + +.Form--controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + margin-top: auto; + padding-top: 16px; +} + +.ButtonGroup--full { + width: 100%; + display: flex; + + > .Button:first-child { + flex-grow: 1; + text-align: start; + } +} + +.ConfigureAuth-hosts, .ConfigureComposer-repositories { + > .ButtonGroup { + margin-bottom: 8px; + } +} + +.flarum-package-manager-Page .SettingsGroups .Form { + max-height: unset; +} diff --git a/extensions/package-manager/less/admin/ControlSection.less b/extensions/package-manager/less/admin/ControlSection.less index 14eafb5f1a..f6b4f6b791 100644 --- a/extensions/package-manager/less/admin/ControlSection.less +++ b/extensions/package-manager/less/admin/ControlSection.less @@ -15,10 +15,12 @@ } .PackageManager-extensions { + width: 100%; + &-grid { --gap: 12px; display: grid; - grid-template-columns: repeat(auto-fit, calc(~"100% / 3 - var(--gap)")); + grid-template-columns: repeat(auto-fit, 310px); gap: var(--gap); } } @@ -86,12 +88,35 @@ grid-template-areas: "title logo" "helpText logo" - "controls logo" - "extensions extensions" - "failure failure"; - grid-gap: 0 var(--space); + "controls logo"; + column-gap: 0 var(--space); align-items: center; + &--failed&--incompatibleExtensions { + grid-template-areas: + "title logo" + "helpText logo" + "controls logo" + "extensions extensions" + "failure failure"; + } + + &--failed { + grid-template-areas: + "title logo" + "helpText logo" + "controls logo" + "failure failure"; + } + + &--incompatibleExtensions { + grid-template-areas: + "title logo" + "helpText logo" + "controls logo" + "extensions extensions"; + } + > img { grid-area: logo; } @@ -116,6 +141,10 @@ padding-top: var(--space); border-top: 1px solid var(--control-bg); } + + .PackageManager-updaterControls { + margin: 0; + } } .WhyNotModal { @@ -125,9 +154,18 @@ } .PackageManager-installer .FormControl-container { - max-width: 400px; + max-width: 450px; .FormControl { width: 300px; } } + +.PackageManager-controlSection .container { + max-width: 1030px; + overflow: visible; +} + +.PackageManager-primaryWarning ul { + margin: 0; +} diff --git a/extensions/package-manager/less/admin/QueueSection.less b/extensions/package-manager/less/admin/QueueSection.less index 0d55d9338f..60059dfe99 100644 --- a/extensions/package-manager/less/admin/QueueSection.less +++ b/extensions/package-manager/less/admin/QueueSection.less @@ -11,6 +11,7 @@ .Label { text-transform: uppercase; + margin-inline-end: 8px; } .Table { diff --git a/extensions/package-manager/locale/en.yml b/extensions/package-manager/locale/en.yml index 607449dfb0..ec9563b4a4 100755 --- a/extensions/package-manager/locale/en.yml +++ b/extensions/package-manager/locale/en.yml @@ -1,12 +1,66 @@ flarum-package-manager: admin: + auth_config: + add_label: New authentication method + add_modal: + host_label: Host + host_placeholder: "example: extiverse.com" + submit_button: Submit + token_label: Token + type_label: Type + unchanged_token_placeholder: "(unchanged)" + delete_confirmation: Are you sure you want to delete this authentication method? + delete_label: Delete authentication method + edit_label: Edit authentication method + fields: + host: Host + token: Token + no_auth_methods_configured: No authentication methods configured. This is an optional advanced feature to allow installing from private repositories. + remove_button_label: Remove authentication method + title: Authentication Methods + types: + github-oauth: GitHub OAuth + gitlab-oauth: GitLab OAuth + gitlab-token: GitLab Token + bearer: HTTP Bearer + composer: + add_repository_label: Add Repository + delete_repository_confirmation: Are you sure you want to delete this repository? All extensions installed from this repository will be removed. + delete_repository_label: Delete repository + edit_repository_label: Edit repository + title: Composer + minimum_stability: + label: Minimum Stability + help: The type of packages allowed to be installed. Do not change this unless you know what you are doing. + options: + stable: Stable (Recommended) + rc: Release Candidate + beta: Beta + alpha: Alpha + dev: Dev + repositories: + label: Repositories + help: > + Add additional repositories to install packages from. This is an advanced feature, do not add repositories that are not trusted, as they can be used to execute malicious code on your server. + types: + composer: composer + vcs: vcs + path: path + add_modal: + name_label: Name + type_label: Type + url: URL + submit_button: Submit + exceptions: composer_command_failure: Failed to execute. Check the composer logs in storage/logs/composer. extension_already_installed: Extension is already installed. + extension_not_directly_dependency: Extension is installed as a dependency of another extension, it cannot be directly removed. extension_not_installed: Extension not found. guessed_cause: extension_incompatible_with_instance: The extension is most likely incompatible with your current Flarum instance. + extension_not_found: The extension was not found or does not exist. extensions_incompatible_with_new_major: > Some installed extensions are not compatible with the newest major release. Please wait until the extensions are updated to be compatible by the authors, or remove them before proceeding. @@ -14,18 +68,25 @@ flarum-package-manager: extensions: check_why_it_failed_updating: Show why it did not update to the latest. install: Install a new extension - install_help: Fill in the extension package name to proceed. Visit {extiverse} to browse extensions. + install_help: > + Fill in the extension package name to proceed. You can specify a semantic version using the format vendor/package-name:version. + Visit {extiverse} to browse extensions. proceed: Proceed + remove: Uninstall successful_install: "{extension} was installed successfully, redirecting.." successful_remove: Extension removed successfully. successful_update: "{extension} was updated successfully, redirecting.." update: Update + update_soft_label: Soft update + update_hard_label: Hard update file_permissions: > The package manager requires read and write permissions on the following files and directories: composer.json, composer.lock, vendor, storage, storage/.composer major_updater: - description: Major Flarum updates are not backwards compatible, meaning that some of your currently installed extensions, and manually made modifications might not work with this new version. + description: > + Major Flarum updates are not backwards compatible, meaning that some of your currently installed extensions, and manually made modifications might not work with this new version. + Please make sure to make a backup of your database and files before proceeding. dry_run: Dry Run dry_run_help: A dry run emulates the update to see if your current setup can safely update, this does not mean that your manual made custom modifications will work in the newer version. failure: @@ -45,7 +106,7 @@ flarum-package-manager: columns: details: Details elapsed_time: Completed in - peak_memory_used: Maximum Memory Used + peak_memory_used: Peak Memory Usage operation: Operation package: Package status: Status @@ -60,7 +121,9 @@ flarum-package-manager: update_minor: Minor update why_not: Analyze why a package cannot be updated output_modal: + cause_unknown: Unknown command: Composer Command + guessed_cause: Cause output: Output refresh: Refresh tasks list statuses: @@ -72,11 +135,17 @@ flarum-package-manager: title: Queue settings: + title: => core.ref.settings access_warning: Please be careful to who you give access to the admin area, the package manager could be misused by bad actors to install packages that can lead to security breaches. + debug_mode_warning: You are running in debug mode, the package manager cannot properly install and update local development packages. Please use the command line interface instead for such purposes. queue_jobs: Run operations in the background queue queue_jobs_help: > You can read about a basic queue implementation or a
more advanced one. Make sure the PHP version used for the queue is {php_version}. Make sure folder permissions are correctly configured. + task_retention_days: Task retention days + task_retention_days_help: > + The number of days to keep completed tasks in the database. Tasks older than this will be deleted. + Set to 0 to keep all tasks. updater: up_to_date: Everything is up to date! diff --git a/extensions/package-manager/migrations/2023_12_09_000000_add_guessed_cause_column_to_package_manager_tasks_table.php b/extensions/package-manager/migrations/2023_12_09_000000_add_guessed_cause_column_to_package_manager_tasks_table.php new file mode 100644 index 0000000000..28f6f97b84 --- /dev/null +++ b/extensions/package-manager/migrations/2023_12_09_000000_add_guessed_cause_column_to_package_manager_tasks_table.php @@ -0,0 +1,14 @@ + ['type' => 'string', 'length' => 255, 'nullable' => true, 'after' => 'output'], +]); diff --git a/extensions/package-manager/src/Api/Controller/CheckForUpdatesController.php b/extensions/package-manager/src/Api/Controller/CheckForUpdatesController.php index a1d37087b9..caa3164043 100755 --- a/extensions/package-manager/src/Api/Controller/CheckForUpdatesController.php +++ b/extensions/package-manager/src/Api/Controller/CheckForUpdatesController.php @@ -38,10 +38,6 @@ public function handle(ServerRequestInterface $request): ResponseInterface $actor->assertAdmin(); - /** - * @TODO somewhere, if we're queuing, check that a similar composer command isn't already running, - * to avoid duplicate jobs. - */ $response = $this->bus->dispatch( new CheckForUpdates($actor) ); diff --git a/extensions/package-manager/src/Api/Controller/ConfigureComposerController.php b/extensions/package-manager/src/Api/Controller/ConfigureComposerController.php new file mode 100755 index 0000000000..b065d2cbc2 --- /dev/null +++ b/extensions/package-manager/src/Api/Controller/ConfigureComposerController.php @@ -0,0 +1,142 @@ +validator = $validator; + $this->paths = $paths; + $this->composerJson = $composerJson; + $this->filesystem = $filesystem; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $actor = RequestUtil::getActor($request); + $type = Arr::get($request->getParsedBody(), 'type'); + + $actor->assertAdmin(); + + if (! in_array($type, ['composer', 'auth'])) { + return new JsonResponse([ + 'data' => [], + ]); + } + + if ($type === 'composer') { + $data = $this->composerConfig($request); + } else { + $data = $this->authConfig($request); + } + + return new JsonResponse([ + 'data' => $data, + ]); + } + + protected function composerConfig(ServerRequestInterface $request): array + { + $data = Arr::only(Arr::get($request->getParsedBody(), 'data') ?? [], $this->configurable); + + $this->validator->assertValid(['composer' => $data]); + $composerJson = $this->composerJson->get(); + + if (! empty($data)) { + foreach ($data as $key => $value) { + Arr::set($composerJson, $key, $value); + } + + // Always prefer stable releases. + $composerJson['prefer-stable'] = true; + + $this->composerJson->set($composerJson); + } + + return Arr::only($composerJson, $this->configurable); + } + + protected function authConfig(ServerRequestInterface $request): array + { + $data = Arr::get($request->getParsedBody(), 'data'); + + $this->validator->assertValid(['auth' => $data]); + + $authJson = json_decode($this->filesystem->get($this->paths->base.'/auth.json'), true); + + if (! is_null($data)) { + foreach ($data as $type => $hosts) { + foreach ($hosts as $host => $token) { + if (empty($token)) { + unset($authJson[$type][$host]); + continue; + } + + $data[$type][$host] = $token === '***' + ? $authJson[$type][$host] + : $token; + } + } + + $this->filesystem->put($this->paths->base.'/auth.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $authJson = $data; + } + + // Remove tokens from response. + foreach ($authJson as $type => $hosts) { + foreach ($hosts as $host => $token) { + $authJson[$type][$host] = '***'; + } + } + + return $authJson; + } +} diff --git a/extensions/package-manager/src/Api/Controller/ListTasksController.php b/extensions/package-manager/src/Api/Controller/ListTasksController.php index 1158885007..5bcf475073 100644 --- a/extensions/package-manager/src/Api/Controller/ListTasksController.php +++ b/extensions/package-manager/src/Api/Controller/ListTasksController.php @@ -13,7 +13,7 @@ use Flarum\Http\RequestUtil; use Flarum\Http\UrlGenerator; use Flarum\PackageManager\Api\Serializer\TaskSerializer; -use Flarum\PackageManager\Task\TaskRepository; +use Flarum\PackageManager\Task\Task; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -29,15 +29,9 @@ class ListTasksController extends AbstractListController */ protected $url; - /** - * @var TaskRepository - */ - protected $repository; - - public function __construct(UrlGenerator $url, TaskRepository $repository) + public function __construct(UrlGenerator $url) { $this->url = $url; - $this->repository = $repository; } protected function data(ServerRequestInterface $request, Document $document) @@ -49,14 +43,13 @@ protected function data(ServerRequestInterface $request, Document $document) $limit = $this->extractLimit($request); $offset = $this->extractOffset($request); - $results = $this->repository - ->query() + $results = Task::query() ->latest() ->offset($offset) ->limit($limit) ->get(); - $total = $this->repository->query()->count(); + $total = Task::query()->count(); $document->addMeta('total', (string) $total); diff --git a/extensions/package-manager/src/Api/Controller/UpdateExtensionController.php b/extensions/package-manager/src/Api/Controller/UpdateExtensionController.php index 0b9833f13a..5cd1f40ef1 100755 --- a/extensions/package-manager/src/Api/Controller/UpdateExtensionController.php +++ b/extensions/package-manager/src/Api/Controller/UpdateExtensionController.php @@ -35,9 +35,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface { $actor = RequestUtil::getActor($request); $extensionId = Arr::get($request->getQueryParams(), 'id'); + $updateMode = Arr::get($request->getParsedBody(), 'data.updateMode'); $response = $this->bus->dispatch( - new UpdateExtension($actor, $extensionId) + new UpdateExtension($actor, $extensionId, $updateMode) ); return $response->queueJobs diff --git a/extensions/package-manager/src/Api/Serializer/TaskSerializer.php b/extensions/package-manager/src/Api/Serializer/TaskSerializer.php index dfce8ab471..8ba662c26f 100644 --- a/extensions/package-manager/src/Api/Serializer/TaskSerializer.php +++ b/extensions/package-manager/src/Api/Serializer/TaskSerializer.php @@ -40,6 +40,7 @@ protected function getDefaultAttributes($model) 'command' => $model->command, 'package' => $model->package, 'output' => $model->output, + 'guessedCause' => $model->guessed_cause, 'createdAt' => $model->created_at, 'startedAt' => $model->started_at, 'finishedAt' => $model->finished_at, diff --git a/extensions/package-manager/src/Command/AbstractActionCommand.php b/extensions/package-manager/src/Command/AbstractActionCommand.php index 3f6924c8f1..dba507fc03 100644 --- a/extensions/package-manager/src/Command/AbstractActionCommand.php +++ b/extensions/package-manager/src/Command/AbstractActionCommand.php @@ -23,5 +23,10 @@ abstract class AbstractActionCommand */ public $package = null; + /** + * @var string|null + */ + public $extensionId = null; + abstract public function getOperationName(): string; } diff --git a/extensions/package-manager/src/Command/CheckForUpdatesHandler.php b/extensions/package-manager/src/Command/CheckForUpdatesHandler.php index c7a81865cd..6097328c53 100755 --- a/extensions/package-manager/src/Command/CheckForUpdatesHandler.php +++ b/extensions/package-manager/src/Command/CheckForUpdatesHandler.php @@ -9,9 +9,13 @@ namespace Flarum\PackageManager\Command; +use Flarum\Extension\ExtensionManager; use Flarum\PackageManager\Composer\ComposerAdapter; +use Flarum\PackageManager\Composer\ComposerJson; use Flarum\PackageManager\Exception\ComposerCommandFailedException; use Flarum\PackageManager\Settings\LastUpdateCheck; +use Flarum\PackageManager\Support\Util; +use Illuminate\Support\Collection; use Symfony\Component\Console\Input\ArrayInput; class CheckForUpdatesHandler @@ -26,10 +30,22 @@ class CheckForUpdatesHandler */ protected $lastUpdateCheck; - public function __construct(ComposerAdapter $composer, LastUpdateCheck $lastUpdateCheck) + /** + * @var ExtensionManager + */ + protected $extensions; + + /** + * @var ComposerJson + */ + protected $composerJson; + + public function __construct(ComposerAdapter $composer, LastUpdateCheck $lastUpdateCheck, ExtensionManager $extensions, ComposerJson $composerJson) { $this->composer = $composer; $this->lastUpdateCheck = $lastUpdateCheck; + $this->extensions = $extensions; + $this->composerJson = $composerJson; } /** @@ -55,14 +71,10 @@ public function handle(CheckForUpdates $command) $firstOutput = $this->runComposerCommand(false, $command); $firstOutput = json_decode($this->cleanJson($firstOutput), true); - $majorUpdates = false; - - foreach ($firstOutput['installed'] as $package) { - if (isset($package['latest-status']) && $package['latest-status'] === 'update-possible') { - $majorUpdates = true; - break; - } - } + $installed = new Collection($firstOutput['installed'] ?? []); + $majorUpdates = $installed->contains(function (array $package) { + return isset($package['latest-status']) && $package['latest-status'] === 'update-possible' && Util::isMajorUpdate($package['version'], $package['latest']); + }); if ($majorUpdates) { $secondOutput = $this->runComposerCommand(true, $command); @@ -73,10 +85,22 @@ public function handle(CheckForUpdates $command) $secondOutput = ['installed' => []]; } - foreach ($firstOutput['installed'] as &$mainPackageUpdate) { + $updates = new Collection(); + $composerJson = $this->composerJson->get(); + + foreach ($installed as $mainPackageUpdate) { + // Skip if not an extension + if (! $this->extensions->getExtension(Util::nameToId($mainPackageUpdate['name']))) { + continue; + } + $mainPackageUpdate['latest-minor'] = $mainPackageUpdate['latest-major'] = null; - if (isset($mainPackageUpdate['latest-status']) && $mainPackageUpdate['latest-status'] === 'update-possible') { + if ($mainPackageUpdate['latest-status'] === 'up-to-date' && Util::isMajorUpdate($mainPackageUpdate['version'], $mainPackageUpdate['latest'])) { + continue; + } + + if (isset($mainPackageUpdate['latest-status']) && $mainPackageUpdate['latest-status'] === 'update-possible' && Util::isMajorUpdate($mainPackageUpdate['version'], $mainPackageUpdate['latest'])) { $mainPackageUpdate['latest-major'] = $mainPackageUpdate['latest']; $minorPackageUpdate = array_filter($secondOutput['installed'], function ($package) use ($mainPackageUpdate) { @@ -89,10 +113,14 @@ public function handle(CheckForUpdates $command) } else { $mainPackageUpdate['latest-minor'] = $mainPackageUpdate['latest'] ?? null; } + + $mainPackageUpdate['required-as'] = $composerJson['require'][$mainPackageUpdate['name']] ?? null; + + $updates->push($mainPackageUpdate); } return $this->lastUpdateCheck - ->with('installed', $firstOutput['installed']) + ->with('installed', $updates->values()->toArray()) ->save(); } @@ -112,7 +140,6 @@ protected function runComposerCommand(bool $minorOnly, CheckForUpdates $command) { $input = [ 'command' => 'outdated', - '-D' => true, '--format' => 'json', ]; diff --git a/extensions/package-manager/src/Command/GlobalUpdateHandler.php b/extensions/package-manager/src/Command/GlobalUpdateHandler.php index 121761a9f4..6b59351a83 100644 --- a/extensions/package-manager/src/Command/GlobalUpdateHandler.php +++ b/extensions/package-manager/src/Command/GlobalUpdateHandler.php @@ -10,11 +10,12 @@ namespace Flarum\PackageManager\Command; use Flarum\Bus\Dispatcher as FlarumDispatcher; +use Flarum\Foundation\Config; use Flarum\PackageManager\Composer\ComposerAdapter; use Flarum\PackageManager\Event\FlarumUpdated; use Flarum\PackageManager\Exception\ComposerUpdateFailedException; use Illuminate\Contracts\Events\Dispatcher; -use Symfony\Component\Console\Input\StringInput; +use Symfony\Component\Console\Input\ArrayInput; class GlobalUpdateHandler { @@ -33,11 +34,17 @@ class GlobalUpdateHandler */ protected $commandDispatcher; - public function __construct(ComposerAdapter $composer, Dispatcher $events, FlarumDispatcher $commandDispatcher) + /** + * @var Config + */ + protected $config; + + public function __construct(ComposerAdapter $composer, Dispatcher $events, FlarumDispatcher $commandDispatcher, Config $config) { $this->composer = $composer; $this->events = $events; $this->commandDispatcher = $commandDispatcher; + $this->config = $config; } /** @@ -47,8 +54,16 @@ public function handle(GlobalUpdate $command) { $command->actor->assertAdmin(); + $input = [ + 'command' => 'update', + '--prefer-dist' => true, + '--no-dev' => ! $this->config->inDebugMode(), + '-a' => true, + '--with-all-dependencies' => true, + ]; + $output = $this->composer->run( - new StringInput('update --prefer-dist --no-dev -a --with-all-dependencies'), + new ArrayInput($input), $command->task ?? null ); diff --git a/extensions/package-manager/src/Command/MajorUpdateHandler.php b/extensions/package-manager/src/Command/MajorUpdateHandler.php index c6d95bd8b2..6be1c64ffd 100644 --- a/extensions/package-manager/src/Command/MajorUpdateHandler.php +++ b/extensions/package-manager/src/Command/MajorUpdateHandler.php @@ -89,9 +89,6 @@ public function handle(MajorUpdate $command) ); } - /** - * @todo change minimum stability to 'stable' and any other similar params - */ protected function updateComposerJson(string $majorVersion): void { $versionNumber = str_replace('v', '', $majorVersion); diff --git a/extensions/package-manager/src/Command/MinorUpdateHandler.php b/extensions/package-manager/src/Command/MinorUpdateHandler.php index 264b7b9e26..232f37bebe 100755 --- a/extensions/package-manager/src/Command/MinorUpdateHandler.php +++ b/extensions/package-manager/src/Command/MinorUpdateHandler.php @@ -55,10 +55,8 @@ public function handle(MinorUpdate $command) { $command->actor->assertAdmin(); - $coreRequirement = $this->composerJson->get()['require']['flarum/core']; - + // Set all extensions to * versioning. $this->composerJson->require('*', '*'); - $this->composerJson->require('flarum/core', $coreRequirement); $output = $this->composer->run( new StringInput('update --prefer-dist --no-dev -a --with-all-dependencies'), diff --git a/extensions/package-manager/src/Command/RemoveExtension.php b/extensions/package-manager/src/Command/RemoveExtension.php index 853585dadf..bc8bf820e8 100755 --- a/extensions/package-manager/src/Command/RemoveExtension.php +++ b/extensions/package-manager/src/Command/RemoveExtension.php @@ -19,11 +19,6 @@ class RemoveExtension extends AbstractActionCommand */ public $actor; - /** - * @var string - */ - public $extensionId; - public function __construct(User $actor, string $extensionId) { $this->actor = $actor; diff --git a/extensions/package-manager/src/Command/RemoveExtensionHandler.php b/extensions/package-manager/src/Command/RemoveExtensionHandler.php index 639cac5c33..1eaa4d7993 100755 --- a/extensions/package-manager/src/Command/RemoveExtensionHandler.php +++ b/extensions/package-manager/src/Command/RemoveExtensionHandler.php @@ -11,8 +11,10 @@ use Flarum\Extension\ExtensionManager; use Flarum\PackageManager\Composer\ComposerAdapter; +use Flarum\PackageManager\Composer\ComposerJson; use Flarum\PackageManager\Exception\ComposerCommandFailedException; use Flarum\PackageManager\Exception\ExtensionNotInstalledException; +use Flarum\PackageManager\Exception\IndirectExtensionDependencyCannotBeRemovedException; use Flarum\PackageManager\Extension\Event\Removed; use Illuminate\Contracts\Events\Dispatcher; use Symfony\Component\Console\Input\StringInput; @@ -34,11 +36,17 @@ class RemoveExtensionHandler */ protected $events; - public function __construct(ComposerAdapter $composer, ExtensionManager $extensions, Dispatcher $events) + /** + * @var ComposerJson + */ + protected $composerJson; + + public function __construct(ComposerAdapter $composer, ExtensionManager $extensions, Dispatcher $events, ComposerJson $composerJson) { $this->composer = $composer; $this->extensions = $extensions; $this->events = $events; + $this->composerJson = $composerJson; } /** @@ -59,6 +67,13 @@ public function handle(RemoveExtension $command) $command->task->package = $extension->name; } + $json = $this->composerJson->get(); + + // If this extension is not a direct dependency, we can't actually remove it. + if (! isset($json['require'][$extension->name]) && ! isset($json['require-dev'][$extension->name])) { + throw new IndirectExtensionDependencyCannotBeRemovedException($command->extensionId); + } + $output = $this->composer->run( new StringInput("remove $extension->name"), $command->task ?? null diff --git a/extensions/package-manager/src/Command/RequireExtensionHandler.php b/extensions/package-manager/src/Command/RequireExtensionHandler.php index 3d98fe9aa0..8184ddde88 100755 --- a/extensions/package-manager/src/Command/RequireExtensionHandler.php +++ b/extensions/package-manager/src/Command/RequireExtensionHandler.php @@ -14,8 +14,8 @@ use Flarum\PackageManager\Exception\ComposerRequireFailedException; use Flarum\PackageManager\Exception\ExtensionAlreadyInstalledException; use Flarum\PackageManager\Extension\Event\Installed; -use Flarum\PackageManager\Extension\ExtensionUtils; use Flarum\PackageManager\RequirePackageValidator; +use Flarum\PackageManager\Support\Util; use Illuminate\Contracts\Events\Dispatcher; use Symfony\Component\Console\Input\StringInput; @@ -59,7 +59,7 @@ public function handle(RequireExtension $command) $this->validator->assertValid(['package' => $command->package]); - $extensionId = ExtensionUtils::nameToId($command->package); + $extensionId = Util::nameToId($command->package); $extension = $this->extensions->getExtension($extensionId); if (! empty($extension)) { @@ -74,7 +74,7 @@ public function handle(RequireExtension $command) } $output = $this->composer->run( - new StringInput("require $packageName"), + new StringInput("require $packageName -W"), $command->task ?? null ); diff --git a/extensions/package-manager/src/Command/UpdateExtension.php b/extensions/package-manager/src/Command/UpdateExtension.php index cf74c484c4..3b006b2d6b 100755 --- a/extensions/package-manager/src/Command/UpdateExtension.php +++ b/extensions/package-manager/src/Command/UpdateExtension.php @@ -22,12 +22,13 @@ class UpdateExtension extends AbstractActionCommand /** * @var string */ - public $extensionId; + public $updateMode; - public function __construct(User $actor, string $extensionId) + public function __construct(User $actor, string $extensionId, string $updateMode) { $this->actor = $actor; $this->extensionId = $extensionId; + $this->updateMode = $updateMode; } public function getOperationName(): string diff --git a/extensions/package-manager/src/Command/UpdateExtensionHandler.php b/extensions/package-manager/src/Command/UpdateExtensionHandler.php index dde622d851..dec0ce2093 100755 --- a/extensions/package-manager/src/Command/UpdateExtensionHandler.php +++ b/extensions/package-manager/src/Command/UpdateExtensionHandler.php @@ -10,8 +10,6 @@ namespace Flarum\PackageManager\Command; use Flarum\Extension\ExtensionManager; -use Flarum\Foundation\Paths; -use Flarum\Foundation\ValidationException; use Flarum\PackageManager\Composer\ComposerAdapter; use Flarum\PackageManager\Exception\ComposerUpdateFailedException; use Flarum\PackageManager\Exception\ExtensionNotInstalledException; @@ -48,25 +46,18 @@ class UpdateExtensionHandler */ protected $events; - /** - * @var Paths - */ - protected $paths; - public function __construct( ComposerAdapter $composer, ExtensionManager $extensions, UpdateExtensionValidator $validator, LastUpdateCheck $lastUpdateCheck, - Dispatcher $events, - Paths $paths + Dispatcher $events ) { $this->composer = $composer; $this->extensions = $extensions; $this->validator = $validator; $this->lastUpdateCheck = $lastUpdateCheck; $this->events = $events; - $this->paths = $paths; } /** @@ -77,7 +68,10 @@ public function handle(UpdateExtension $command) { $command->actor->assertAdmin(); - $this->validator->assertValid(['extensionId' => $command->extensionId]); + $this->validator->assertValid([ + 'extensionId' => $command->extensionId, + 'updateMode' => $command->updateMode, + ]); $extension = $this->extensions->getExtension($command->extensionId); @@ -85,19 +79,19 @@ public function handle(UpdateExtension $command) throw new ExtensionNotInstalledException($command->extensionId); } - $rootComposer = json_decode(file_get_contents("{$this->paths->base}/composer.json"), true); - - // If this was installed as a requirement for another extension, - // don't update it directly. - // @TODO communicate this in the UI. - if (! isset($rootComposer['require'][$extension->name]) && ! empty($extension->getExtensionDependencyIds())) { - throw new ValidationException([ - 'message' => "Cannot update $extension->name. It was installed as a requirement for other extensions: ".implode(', ', $extension->getExtensionDependencyIds()).'. Update those extensions instead.' - ]); + // In situations where an extension was locked to a specific version, + // a hard update mode is useful to allow removing the locked version and + // instead requiring the latest version. + // Another scenario could be when requiring a specific version range, for example 0.1.*, + // the admin might either want to update to the latest version in that range, or to the latest version overall (0.2.0). + if ($command->updateMode === 'soft') { + $input = "update $extension->name"; + } else { + $input = "require $extension->name:*"; } $output = $this->composer->run( - new StringInput("require $extension->name:*"), + new StringInput($input), $command->task ?? null ); diff --git a/extensions/package-manager/src/Composer/ComposerAdapter.php b/extensions/package-manager/src/Composer/ComposerAdapter.php index 1902735ce4..f0e535493f 100644 --- a/extensions/package-manager/src/Composer/ComposerAdapter.php +++ b/extensions/package-manager/src/Composer/ComposerAdapter.php @@ -13,6 +13,7 @@ use Composer\Console\Application; use Flarum\Foundation\Paths; use Flarum\PackageManager\OutputLogger; +use Flarum\PackageManager\Support\Util; use Flarum\PackageManager\Task\Task; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\BufferedOutput; @@ -32,11 +33,6 @@ class ComposerAdapter */ private $logger; - /** - * @var BufferedOutput - */ - private $output; - /** * @var Paths */ @@ -47,22 +43,22 @@ public function __construct(Application $application, OutputLogger $logger, Path $this->application = $application; $this->logger = $logger; $this->paths = $paths; - $this->output = new BufferedOutput(); } public function run(InputInterface $input, ?Task $task = null): ComposerOutput { $this->application->resetComposer(); + $output = new BufferedOutput(); + // This hack is necessary so that relative path repositories are resolved properly. $currDir = getcwd(); chdir($this->paths->base); - $exitCode = $this->application->run($input, $this->output); + $exitCode = $this->application->run($input, $output); chdir($currDir); - // @phpstan-ignore-next-line - $command = $input->__toString(); - $output = $this->output->fetch(); + $command = Util::readableConsoleInput($input); + $output = $output->fetch(); if ($task) { $task->update(compact('command', 'output')); diff --git a/extensions/package-manager/src/Composer/ComposerJson.php b/extensions/package-manager/src/Composer/ComposerJson.php index beb27d71a5..2d99ab83bf 100644 --- a/extensions/package-manager/src/Composer/ComposerJson.php +++ b/extensions/package-manager/src/Composer/ComposerJson.php @@ -9,7 +9,9 @@ namespace Flarum\PackageManager\Composer; +use Flarum\Extension\ExtensionManager; use Flarum\Foundation\Paths; +use Flarum\PackageManager\Support\Util; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Str; @@ -30,10 +32,16 @@ class ComposerJson */ protected $initialJson; - public function __construct(Paths $paths, Filesystem $filesystem) + /** + * @var ExtensionManager + */ + protected $extensions; + + public function __construct(Paths $paths, Filesystem $filesystem, ExtensionManager $extensions) { $this->paths = $paths; $this->filesystem = $filesystem; + $this->extensions = $extensions; } public function require(string $packageName, string $version): void @@ -48,6 +56,11 @@ public function require(string $packageName, string $version): void continue; } + // Only extensions can all be set to * versioning. + if (! $this->extensions->getExtension(Util::nameToId($packageName))) { + continue; + } + $wildcardPackageName = str_replace('\*', '.*', preg_quote($packageName, '/')); if (Str::of($p)->test("/($wildcardPackageName)/")) { @@ -83,7 +96,7 @@ public function get(): array return $json; } - protected function set(array $json): void + public function set(array $json): void { $this->filesystem->put($this->getComposerJsonPath(), json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } diff --git a/extensions/package-manager/src/ConfigureComposerValidator.php b/extensions/package-manager/src/ConfigureComposerValidator.php new file mode 100644 index 0000000000..44a39c4203 --- /dev/null +++ b/extensions/package-manager/src/ConfigureComposerValidator.php @@ -0,0 +1,34 @@ + [ + 'minimum-stability' => ['sometimes', 'in:stable,RC,beta,alpha,dev'], + 'repositories' => ['sometimes', 'array'], + 'repositories.*.type' => ['sometimes', 'in:composer,vcs,path'], + 'repositories.*.url' => ['sometimes', 'string'], + ], + 'auth' => [ + 'github-oauth' => ['sometimes', 'array'], + 'github-oauth.*' => ['sometimes', 'string'], + 'gitlab-oauth' => ['sometimes', 'array'], + 'gitlab-oauth.*' => ['sometimes', 'string'], + 'gitlab-token' => ['sometimes', 'array'], + 'gitlab-token.*' => ['sometimes', 'string'], + 'bearer' => ['sometimes', 'array'], + 'bearer.*' => ['sometimes', 'string'], + ], + ]; +} diff --git a/extensions/package-manager/src/Exception/ComposerRequireFailedException.php b/extensions/package-manager/src/Exception/ComposerRequireFailedException.php index 8046ee2047..1ab1357ab4 100755 --- a/extensions/package-manager/src/Exception/ComposerRequireFailedException.php +++ b/extensions/package-manager/src/Exception/ComposerRequireFailedException.php @@ -11,20 +11,31 @@ class ComposerRequireFailedException extends ComposerCommandFailedException { - protected const INCOMPATIBLE_REGEX = '/(?:(?: +- {PACKAGE_NAME}(?: v[0-9A-z.-]+ requires|\[[^\[\]]+\] require) flarum\/core)|(?:Could not find a version of package {PACKAGE_NAME} matching your minim)|(?: +- Root composer.json requires {PACKAGE_NAME} [^,]+, found {PACKAGE_NAME}\[[^\[\]]+\]+ but it does not match your minimum-stability))/m'; + protected const INCOMPATIBLE_REGEX = '/(?:(?: +- {PACKAGE_NAME}(?: v[0-9A-z.-]+ requires|\[[^\[\]]+\] require) flarum\/core)|(?:Could not find a version of package {PACKAGE_NAME} matching your minim)|(?: +- Root composer\.json requires {PACKAGE_NAME} [^,]+, found {PACKAGE_NAME}\[[^\[\]]+\]+ but it does not match your minimum-stability))/m'; + protected const NOT_FOUND_REGEX = '/(?:(?: +- Root composer\.json requires {PACKAGE_NAME}, it could not be found in any version, there may be a typo in the package name.))/m'; public function guessCause(): ?string { - $hasMatches = preg_match( + $hasIncompatibleMatches = preg_match( str_replace('{PACKAGE_NAME}', preg_quote($this->getRawPackageName(), '/'), self::INCOMPATIBLE_REGEX), $this->getMessage(), $matches ); - if ($hasMatches) { + if ($hasIncompatibleMatches) { return 'extension_incompatible_with_instance'; } + $hasNotFoundMatches = preg_match( + str_replace('{PACKAGE_NAME}', preg_quote($this->getRawPackageName(), '/'), self::NOT_FOUND_REGEX), + $this->getMessage(), + $matches + ); + + if ($hasNotFoundMatches) { + return 'extension_not_found'; + } + return null; } } diff --git a/extensions/package-manager/src/Exception/IndirectExtensionDependencyCannotBeRemovedException.php b/extensions/package-manager/src/Exception/IndirectExtensionDependencyCannotBeRemovedException.php new file mode 100755 index 0000000000..6b9838cddc --- /dev/null +++ b/extensions/package-manager/src/Exception/IndirectExtensionDependencyCannotBeRemovedException.php @@ -0,0 +1,26 @@ +phpVersion); - $this->command->task->start(); + ComposerAdapter::setPhpVersion($this->phpVersion); + $bus->dispatch($this->command); $this->command->task->end(true); @@ -55,12 +57,19 @@ public function abort(Throwable $exception) $this->command->task->output = $exception->getMessage(); } + if ($exception instanceof ComposerCommandFailedException) { + $this->command->task->guessed_cause = $exception->guessCause(); + } + $this->command->task->end(false); + } - $this->fail($exception); + public function failed(Throwable $exception): void + { + $this->abort($exception); } - public function middleware() + public function middleware(): array { return [ new WithoutOverlapping(), diff --git a/extensions/package-manager/src/Job/Dispatcher.php b/extensions/package-manager/src/Job/Dispatcher.php index 20026dc078..a96200fa5e 100644 --- a/extensions/package-manager/src/Job/Dispatcher.php +++ b/extensions/package-manager/src/Job/Dispatcher.php @@ -9,7 +9,9 @@ namespace Flarum\PackageManager\Job; +use Carbon\Carbon; use Flarum\Bus\Dispatcher as Bus; +use Flarum\Extension\ExtensionManager; use Flarum\PackageManager\Command\AbstractActionCommand; use Flarum\PackageManager\Task\Task; use Flarum\Settings\SettingsRepositoryInterface; @@ -33,6 +35,11 @@ class Dispatcher */ protected $settings; + /** + * @var ExtensionManager + */ + protected $extensions; + /** * Overrides the user setting for execution mode if set. * Runs synchronously regardless of user setting if set true. @@ -42,11 +49,12 @@ class Dispatcher */ protected $runSyncOverride; - public function __construct(Bus $bus, Queue $queue, SettingsRepositoryInterface $settings) + public function __construct(Bus $bus, Queue $queue, SettingsRepositoryInterface $settings, ExtensionManager $extensions) { $this->bus = $bus; $this->queue = $queue; $this->settings = $settings; + $this->extensions = $extensions; } public function sync(): self @@ -67,8 +75,15 @@ public function dispatch(AbstractActionCommand $command): DispatcherResponse { $queueJobs = ($this->runSyncOverride === false) || ($this->runSyncOverride !== true && $this->settings->get('flarum-package-manager.queue_jobs')); + // Skip if there is already a pending or running task. + if ($queueJobs && Task::query()->whereIn('status', [Task::PENDING, Task::RUNNING])->exists()) { + return new DispatcherResponse(true, null); + } + if ($queueJobs && (! $this->queue instanceof SyncQueue)) { - $task = Task::build($command->getOperationName(), $command->package ?? null); + $extension = $command->extensionId ? $this->extensions->getExtension($command->extensionId) : null; + + $task = Task::build($command->getOperationName(), $command->package ?? ($extension ? $extension->name : null)); $command->task = $task; @@ -79,6 +94,21 @@ public function dispatch(AbstractActionCommand $command): DispatcherResponse $data = $this->bus->dispatch($command); } + $this->clearOldTasks(); + return new DispatcherResponse($queueJobs, $data ?? null); } + + protected function clearOldTasks(): void + { + $days = $this->settings->get('flarum-package-manager.task_retention_days'); + + if ($days === null || ((int) $days) === 0) { + return; + } + + Task::query() + ->where('created_at', '<', Carbon::now()->subDays($days)) + ->delete(); + } } diff --git a/extensions/package-manager/src/PackageManagerServiceProvider.php b/extensions/package-manager/src/PackageManagerServiceProvider.php index 4717b2b931..df34802d74 100755 --- a/extensions/package-manager/src/PackageManagerServiceProvider.php +++ b/extensions/package-manager/src/PackageManagerServiceProvider.php @@ -11,6 +11,7 @@ use Composer\Config; use Composer\Console\Application; +use Composer\Util\Platform; use Flarum\Extension\ExtensionManager; use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\Paths; @@ -40,9 +41,9 @@ public function register() /** @var Paths $paths */ $paths = $container->make(Paths::class); - putenv("COMPOSER_HOME={$paths->storage}/.composer"); - putenv("COMPOSER={$paths->base}/composer.json"); - putenv('COMPOSER_DISABLE_XDEBUG_WARN=1'); + Platform::putenv('COMPOSER_HOME', "$paths->storage/.composer"); + Platform::putenv('COMPOSER', "$paths->base/composer.json"); + Platform::putenv('COMPOSER_DISABLE_XDEBUG_WARN', '1'); Config::$defaultConfig['vendor-dir'] = $paths->vendor; // When running simple require, update and remove commands on packages, @@ -51,7 +52,11 @@ public function register() @ini_set('memory_limit', '1G'); @set_time_limit(5 * 60); - return new ComposerAdapter($composer, $container->make(OutputLogger::class), $container->make(Paths::class)); + return new ComposerAdapter( + $composer, + $container->make(OutputLogger::class), + $container->make(Paths::class), + ); }); $this->container->alias(ComposerAdapter::class, 'flarum.composer'); diff --git a/extensions/package-manager/src/Support/Util.php b/extensions/package-manager/src/Support/Util.php new file mode 100755 index 0000000000..f665461502 --- /dev/null +++ b/extensions/package-manager/src/Support/Util.php @@ -0,0 +1,73 @@ +__toString()); + + foreach ($input as $key => $value) { + if (str_starts_with($value, '--')) { + if (! str_contains($value, '=')) { + unset($input[$key]); + } else { + $input[$key] = Str::before($value, '='); + } + } + + if (is_numeric($value) && isset($input[$key - 1]) && str_starts_with($input[$key - 1], '-') && ! str_starts_with($input[$key - 1], '--')) { + unset($input[$key]); + } + } + + return implode(' ', $input); + } elseif (method_exists($input, '__toString')) { + return $input->__toString(); + } + + return ''; + } +} diff --git a/extensions/package-manager/src/Task/Task.php b/extensions/package-manager/src/Task/Task.php index 9ce2f16d70..eac38963c8 100644 --- a/extensions/package-manager/src/Task/Task.php +++ b/extensions/package-manager/src/Task/Task.php @@ -19,9 +19,10 @@ * @property string $command * @property string $package * @property string $output + * @property string|null $guessed_cause * @property Carbon $created_at - * @property Carbon $started_at - * @property Carbon $finished_at + * @property Carbon|null $started_at + * @property Carbon|null $finished_at * @property float $peak_memory_used */ class Task extends AbstractModel @@ -50,7 +51,7 @@ class Task extends AbstractModel protected $table = 'package_manager_tasks'; - protected $fillable = ['command', 'output']; + protected $guarded = ['id']; public $timestamps = true; @@ -84,6 +85,14 @@ public function start(): bool public function end(bool $success): bool { + if ($this->finished_at) { + return true; + } + + if (! $this->started_at) { + $this->start(); + } + $this->status = $success ? static::SUCCESS : static::FAILURE; $this->finished_at = Carbon::now(); $this->peak_memory_used = round(memory_get_peak_usage() / 1024); diff --git a/extensions/package-manager/src/UpdateExtensionValidator.php b/extensions/package-manager/src/UpdateExtensionValidator.php index 84f817f4d5..d32323de31 100755 --- a/extensions/package-manager/src/UpdateExtensionValidator.php +++ b/extensions/package-manager/src/UpdateExtensionValidator.php @@ -17,6 +17,7 @@ class UpdateExtensionValidator extends AbstractValidator * {@inheritdoc} */ protected $rules = [ - 'extensionId' => 'required|string' + 'extensionId' => 'required|string', + 'updateMode' => 'required|in:soft,hard', ]; } diff --git a/extensions/package-manager/tests/integration/TestCase.php b/extensions/package-manager/tests/integration/TestCase.php index 609f575f78..a3950951b4 100644 --- a/extensions/package-manager/tests/integration/TestCase.php +++ b/extensions/package-manager/tests/integration/TestCase.php @@ -12,7 +12,7 @@ use Flarum\Foundation\Paths; use Flarum\PackageManager\Composer\ComposerAdapter; use Flarum\PackageManager\Composer\ComposerJson; -use Flarum\PackageManager\Extension\ExtensionUtils; +use Flarum\PackageManager\Support\Util; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Illuminate\Support\Arr; use Psr\Http\Message\ResponseInterface; @@ -45,7 +45,7 @@ protected function assertExtension(string $id, bool $exists) return $package['type'] === 'flarum-extension'; }); $installedExtensionIds = array_map(function (string $name) { - return ExtensionUtils::nameToId($name); + return Util::nameToId($name); }, Arr::pluck($installedExtensions, 'name')); if ($exists) {