1
0
mirror of https://github.com/flarum/core.git synced 2025-07-30 21:20:24 +02:00

Merge remote-tracking branch 'extensions_package_manager/REWRITE'

This commit is contained in:
Alexander Skvortsov
2022-03-11 18:01:23 -05:00
104 changed files with 8786 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2
[*.{diff,md}]
trim_trailing_whitespace = false
[*.{php,xml,json}]
indent_size = 4

View File

@@ -0,0 +1,19 @@
.gitattributes export-ignore
.gitignore export-ignore
.gitmodules export-ignore
.github export-ignore
.travis export-ignore
.travis.yml export-ignore
.editorconfig export-ignore
.styleci.yml export-ignore
phpunit.xml export-ignore
tests export-ignore
js/dist/* -diff
js/dist/* linguist-generated
js/dist-typings/* linguist-generated
js/yarn.lock -diff
js/package-lock.json -diff
* text=auto eol=lf

View File

@@ -0,0 +1,17 @@
name: Package Manager PHP
on: [workflow_dispatch, push, pull_request]
# The reusable workflow definitions will be moved to the `flarum/framework` repo soon.
# This will break your current script.
# When this happens, run `flarum-cli audit infra --fix` to update your infrastructure.
jobs:
run:
uses: flarum/.github/.github/workflows/REUSABLE_backend.yml@as/support-npm-yarn
with:
enable_backend_testing: true
backend_directory: .
php_versions: '["7.4", "8.0"]'

View File

@@ -0,0 +1,23 @@
name: Package Manager JS
on: [workflow_dispatch, push, pull_request]
# The reusable workflow definitions will be moved to the `flarum/framework` repo soon.
# This will break your current script.
# When this happens, run `flarum-cli audit infra --fix` to update your infrastructure.
jobs:
run:
uses: flarum/.github/.github/workflows/REUSABLE_frontend.yml@as/support-npm-yarn
with:
enable_bundlewatch: false
enable_prettier: true
enable_typescript: true
frontend_directory: ./js
backend_directory: .
package_manager: yarn
main_git_branch: main
secrets:
bundlewatch_github_token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}

12
extensions/package-manager/.gitignore vendored Executable file
View File

@@ -0,0 +1,12 @@
/vendor
composer.lock
composer.phar
.DS_Store
Thumbs.db
tests/.phpunit.result.cache
/tests/integration/tmp
.vagrant
.idea/*
.vscode
js/coverage-ts

View File

@@ -0,0 +1,14 @@
preset: recommended
enabled:
- logical_not_operators_with_successor_space
disabled:
- align_double_arrow
- blank_line_after_opening_tag
- multiline_array_trailing_comma
- new_with_braces
- phpdoc_align
- phpdoc_order
- phpdoc_separation
- phpdoc_types

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Sami Mazouz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,5 @@
# Package Manager
*An Experiment.*
Read: https://github.com/flarum/package-manager/wiki

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100px" height="100px" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>only symbol</title>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="linearGradient-1">
<stop stop-color="#D22929" offset="0%"></stop>
<stop stop-color="#B71717" offset="100%"></stop>
</linearGradient>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="linearGradient-2">
<stop stop-color="#E7762E" offset="0%"></stop>
<stop stop-color="#E7562E" offset="100%"></stop>
</linearGradient>
</defs>
<g id="Page-1-Copy" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="symbol" transform="translate(19.000000, 14.000000)">
<polygon id="Rectangle-7" fill="url(#linearGradient-1)" transform="translate(18.992475, 60.055970) scale(1, -1) translate(-18.992475, -60.055970) " points="3.28100097 67.7843544 3.25585284 71.4179104 34.729097 71.4179104 34.729097 48.6940299"></polygon>
<path d="M1.50233444,0 C0.67261804,0 -6.34784439e-15,0.673057357 -4.66645372e-15,1.50356766 L8.86811991e-14,47.6119403 C0.0903997122,49.1366194 0.0127042007,50.726258 4.86812431,53.3284828 C4.86812431,53.3284828 0.110480342,48.7194253 7.59698997,48.6940299 L60.7759197,48.6940299 L60.7759197,0 L1.50233444,0 Z" id="Rectangle-6" fill="url(#linearGradient-2)"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,100 @@
{
"name": "flarum/package-manager",
"description": "A Flarum Package Manager.",
"keywords": [
"extensions",
"composer",
"packages",
"manager",
"updater"
],
"type": "flarum-extension",
"license": "MIT",
"authors": [
{
"name": "Flarum",
"email": "info@flarum.org",
"homepage": "https://flarum.org/team"
}
],
"support": {
"issues": "https://github.com/flarum/package-manager/issues",
"source": "https://github.com/flarum/package-manager"
},
"require": {
"flarum/core": "^1.0.0",
"composer/composer": "^2.0"
},
"require-dev": {
"flarum/testing": "^1.0.0",
"flarum/tags": "*"
},
"extra": {
"flarum-extension": {
"title": "Package Manager",
"icon": {
"name": "fas fa-box-open",
"backgroundColor": "#117187",
"color": "#fff"
}
},
"flarum-cli": {
"excludeScaffolding": [
".github/workflows/backend.yml",
"js/src/admin/index.ts",
"tests/phpunit.integration.xml",
"tests/integration/setup.php"
],
"excludeScaffoldingConfigKeys": {
"composer.json": [
"scripts.test:setup"
]
},
"modules": {
"admin": true,
"forum": false,
"js": true,
"jsCommon": false,
"css": true,
"gitConf": true,
"githubActions": true,
"prettier": true,
"typescript": true,
"bundlewatch": false,
"backendTesting": true,
"editorConfig": true,
"styleci": true
}
}
},
"autoload": {
"psr-4": {
"Flarum\\PackageManager\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Flarum\\PackageManager\\Tests\\": "tests/"
}
},
"scripts": {
"test": [
"@test:unit",
"@test:integration"
],
"test:unit": "phpunit -c tests/phpunit.unit.xml",
"test:integration": "phpunit -c tests/phpunit.integration.xml",
"test:setup": [
"@php tests/integration/setup.php",
"cd $FLARUM_TEST_TMP_DIR_LOCAL && composer install"
]
},
"scripts-descriptions": {
"test": "Runs all tests.",
"test:unit": "Runs all unit tests.",
"test:integration": "Runs all integration tests.",
"test:setup": "Sets up a database for use with integration tests. Execute this only once."
},
"minimum-stability": "dev",
"prefer-stable": true
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager;
use Flarum\Extend;
use Flarum\Foundation\Paths;
use Flarum\Frontend\Document;
use Flarum\PackageManager\Exception\ComposerCommandFailedException;
use Flarum\PackageManager\Exception\ExceptionHandler;
use Flarum\PackageManager\Exception\ComposerRequireFailedException;
use Flarum\PackageManager\Exception\ComposerUpdateFailedException;
use Flarum\PackageManager\Exception\MajorUpdateFailedException;
use Flarum\PackageManager\Settings\LastUpdateCheck;
use Flarum\PackageManager\Settings\LastUpdateRun;
return [
(new Extend\Routes('api'))
->post('/package-manager/extensions', 'package-manager.extensions.require', Api\Controller\RequireExtensionController::class)
->patch('/package-manager/extensions/{id}', 'package-manager.extensions.update', Api\Controller\UpdateExtensionController::class)
->delete('/package-manager/extensions/{id}', 'package-manager.extensions.remove', Api\Controller\RemoveExtensionController::class)
->post('/package-manager/check-for-updates', 'package-manager.check-for-updates', Api\Controller\CheckForUpdatesController::class)
->post('/package-manager/why-not', 'package-manager.why-not', Api\Controller\WhyNotController::class)
->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),
(new Extend\Frontend('admin'))
->css(__DIR__ . '/less/admin.less')
->js(__DIR__ . '/js/dist/admin.js')
->content(function (Document $document) {
$paths = resolve(Paths::class);
$document->payload['isRequiredDirectoriesWritable'] = is_writable($paths->vendor)
&& is_writable($paths->storage.'/.composer')
&& is_writable($paths->base.'/composer.json')
&& is_writable($paths->base.'/composer.lock');
}),
new Extend\Locales(__DIR__ . '/locale'),
(new Extend\Settings())
->default(LastUpdateCheck::key(), json_encode(LastUpdateCheck::default()))
->default(LastUpdateRun::key(), json_encode(LastUpdateRun::default())),
(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)
->status('extension_already_installed', 409)
->status('extension_not_installed', 409)
->status('no_new_major_version', 409),
];

View File

@@ -0,0 +1,9 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
node_modules

View File

@@ -0,0 +1 @@
export * from './src/admin';

View File

@@ -0,0 +1,20 @@
import Mithril from 'mithril';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import { Extension as BaseExtension } from 'flarum/admin/AdminApplication';
import { UpdatedPackage } from './Updater';
export declare type Extension = BaseExtension & {
name: string;
};
export interface ExtensionItemAttrs extends ComponentAttrs {
extension: Extension;
updates: UpdatedPackage;
onClickUpdate: CallableFunction;
whyNotWarning?: boolean;
isCore?: boolean;
updatable?: boolean;
isDanger?: boolean;
}
export default class ExtensionItem<Attrs extends ExtensionItemAttrs = ExtensionItemAttrs> extends Component<Attrs> {
view(vnode: Mithril.Vnode<Attrs, this>): Mithril.Children;
private version;
}

View File

@@ -0,0 +1,11 @@
import type Mithril from 'mithril';
import Component from 'flarum/common/Component';
import Stream from 'flarum/common/utils/Stream';
export default class Installer<Attrs> extends Component<Attrs> {
packageName: Stream<string>;
isLoading: boolean;
oninit(vnode: Mithril.Vnode<Attrs, this>): void;
view(): Mithril.Children;
data(): any;
onsubmit(): void;
}

View File

@@ -0,0 +1,15 @@
import Component, { ComponentAttrs } from 'flarum/common/Component';
import Mithril from 'mithril';
import { UpdatedPackage, UpdateState } from './Updater';
interface MajorUpdaterAttrs extends ComponentAttrs {
coreUpdate: UpdatedPackage;
updateState: UpdateState;
}
export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttrs> extends Component<T> {
isLoading: string | null;
updateState: UpdateState;
oninit(vnode: Mithril.Vnode<T, this>): void;
view(vnode: Mithril.Vnode<T, this>): Mithril.Children;
update(dryRun: boolean): void;
}
export {};

View File

@@ -0,0 +1,47 @@
import Mithril from 'mithril';
import Component from 'flarum/common/Component';
import { Extension } from './ExtensionItem';
export declare type UpdatedPackage = {
name: string;
version: string;
latest: string;
'latest-minor': string | null;
'latest-major': string | null;
'latest-status': string;
description: string;
};
export declare type ComposerUpdates = {
installed: UpdatedPackage[];
};
export declare type LastUpdateCheck = {
checkedAt: Date | null;
updates: ComposerUpdates;
};
declare type UpdateType = 'major' | 'minor' | 'global';
declare type UpdateStatus = 'success' | 'failure' | null;
export declare type UpdateState = {
ranAt: Date | null;
status: UpdateStatus;
limitedPackages: string[];
incompatibleExtensions: string[];
};
export declare type LastUpdateRun = {
[key in UpdateType]: UpdateState;
} & {
limitedPackages: () => string[];
};
export default class Updater<Attrs> extends Component<Attrs> {
isLoading: string | null;
packageUpdates: Record<string, UpdatedPackage>;
lastUpdateCheck: LastUpdateCheck;
get lastUpdateRun(): LastUpdateRun;
oninit(vnode: Mithril.Vnode<Attrs, this>): void;
view(): (JSX.Element | null)[];
getExtensionUpdates(): Extension[];
getCoreUpdate(): UpdatedPackage | undefined;
checkForUpdates(): void;
updateCoreMinor(): void;
updateExtension(extension: any): void;
updateGlobally(): void;
}
export {};

View File

@@ -0,0 +1,15 @@
/// <reference path="../../../vendor/flarum/core/js/src/common/translator-icu-rich.d.ts" />
import Mithril from 'mithril';
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
export interface WhyNotModalAttrs extends IInternalModalAttrs {
package: string;
}
export default class WhyNotModal<Attrs extends WhyNotModalAttrs = WhyNotModalAttrs> extends Modal<Attrs> {
loading: boolean;
whyNot: string | null;
className(): string;
title(): import("@askvortsov/rich-icu-message-formatter").NestedStringArray;
oncreate(vnode: Mithril.VnodeDOM<Attrs, this>): void;
content(): JSX.Element;
requestWhyNot(): void;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export default function (e: any): void;

1211
extensions/package-manager/js/dist/admin.js generated vendored Executable file

File diff suppressed because it is too large Load Diff

1
extensions/package-manager/js/dist/admin.js.map generated vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
{
"name": "@flarum/package-manager",
"version": "0.0.0",
"private": true,
"prettier": "@flarum/prettier-config",
"devDependencies": {
"prettier": "^2.5.1",
"flarum-webpack-config": "^2.0.0",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1",
"@flarum/prettier-config": "^1.0.0",
"flarum-tsconfig": "^1.0.2",
"typescript": "^4.5.4",
"typescript-coverage-report": "^0.6.1"
},
"scripts": {
"dev": "webpack --mode development --watch",
"build": "webpack --mode production",
"format": "prettier --write src",
"format-check": "prettier --check src",
"ci": "yarn install --immutable --immutable-cache",
"analyze": "cross-env ANALYZER=true yarn run build",
"clean-typings": "npx rimraf dist-typings && mkdir dist-typings",
"build-typings": "yarn run clean-typings && tsc && [ -e src/@types ] && cp -r src/@types dist-typings/@types",
"check-typings": "tsc --noEmit --emitDeclarationOnly false",
"check-typings-coverage": "typescript-coverage-report"
}
}

View File

@@ -0,0 +1,89 @@
import Mithril from 'mithril';
import app from 'flarum/admin/app';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import classList from 'flarum/common/utils/classList';
import icon from 'flarum/common/helpers/icon';
import Tooltip from 'flarum/common/components/Tooltip';
import Button from 'flarum/common/components/Button';
import { Extension as BaseExtension } from 'flarum/admin/AdminApplication';
import { UpdatedPackage } from './Updater';
import WhyNotModal from './WhyNotModal';
/*
* @todo fix in core
*/
export type Extension = BaseExtension & {
name: string;
};
export interface ExtensionItemAttrs extends ComponentAttrs {
extension: Extension;
updates: UpdatedPackage;
onClickUpdate: CallableFunction;
whyNotWarning?: boolean;
isCore?: boolean;
updatable?: boolean;
isDanger?: boolean;
}
export default class ExtensionItem<Attrs extends ExtensionItemAttrs = ExtensionItemAttrs> extends Component<Attrs> {
view(vnode: Mithril.Vnode<Attrs, this>): Mithril.Children {
const { extension, updates, onClickUpdate, whyNotWarning, isCore, isDanger } = this.attrs;
return (
<div
className={classList({
'PackageManager-extension': true,
'PackageManager-extension--core': isCore,
'PackageManager-extension--danger': isDanger,
})}
>
<div className="PackageManager-extension-icon ExtensionIcon" style={extension.icon}>
{extension.icon ? icon(extension.icon.name) : ''}
</div>
<div className="PackageManager-extension-info">
<div className="PackageManager-extension-name">{extension.extra['flarum-extension'].title}</div>
<div className="PackageManager-extension-version">
<span className="PackageManager-extension-version-current">{this.version(extension.version)}</span>
{updates['latest-minor'] ? (
<span className="PackageManager-extension-version-latest PackageManager-extension-version-latest--minor">
{this.version(updates['latest-minor']!)}
</span>
) : null}
{updates['latest-major'] && !isCore ? (
<span className="PackageManager-extension-version-latest PackageManager-extension-version-latest--major">
{this.version(updates['latest-major']!)}
</span>
) : null}
</div>
</div>
<div className="PackageManager-extension-controls">
{onClickUpdate ? (
<Tooltip text={app.translator.trans('flarum-package-manager.admin.extensions.update')}>
<Button
icon="fas fa-arrow-alt-circle-up"
className="Button Button--icon Button--flat"
onclick={onClickUpdate}
aria-label={app.translator.trans('flarum-package-manager.admin.extensions.update')}
/>
</Tooltip>
) : null}
{whyNotWarning ? (
<Tooltip text={app.translator.trans('flarum-package-manager.admin.extensions.check_why_it_failed_updating')}>
<Button
icon="fas fa-exclamation-circle"
className="Button Button--icon Button--flat Button--danger"
onclick={() => app.modal.show(WhyNotModal, { package: extension.name })}
aria-label={app.translator.trans('flarum-package-manager.admin.extensions.check_why_it_failed_updating')}
/>
</Tooltip>
) : null}
</div>
</div>
);
}
private version(v: string): string {
return 'v' + v.replace('v', '');
}
}

View File

@@ -0,0 +1,71 @@
import type Mithril from 'mithril';
import app from 'flarum/admin/app';
import Component from 'flarum/common/Component';
import Button from 'flarum/common/components/Button';
import Stream from 'flarum/common/utils/Stream';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import errorHandler from '../utils/errorHandler';
export default class Installer<Attrs> extends Component<Attrs> {
packageName!: Stream<string>;
isLoading: boolean = false;
oninit(vnode: Mithril.Vnode<Attrs, this>): void {
super.oninit(vnode);
this.packageName = Stream('');
}
view(): Mithril.Children {
return (
<div className="Form-group">
<label htmlFor="install-extension">{app.translator.trans('flarum-package-manager.admin.extensions.install')}</label>
<p className="helpText">
{app.translator.trans('flarum-package-manager.admin.extensions.install_help', {
extiverse: <a href="https://extiverse.com">extiverse.com</a>,
})}
</p>
<div className="FormControl-container">
<input className="FormControl" id="install-extension" placeholder="vendor/package-name" bidi={this.packageName} />
<Button className="Button" icon="fas fa-download" onclick={this.onsubmit.bind(this)} loading={this.isLoading}>
{app.translator.trans('flarum-package-manager.admin.extensions.proceed')}
</Button>
</div>
</div>
);
}
data(): any {
return {
package: this.packageName(),
};
}
onsubmit(): void {
this.isLoading = true;
app.modal.show(LoadingModal);
app
.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions`,
body: {
data: this.data(),
},
errorHandler,
})
.then((response) => {
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();
})
.finally(() => {
this.isLoading = false;
m.redraw();
});
}
}

View File

@@ -0,0 +1,109 @@
import app from 'flarum/admin/app';
import Component, { ComponentAttrs } from 'flarum/common/Component';
import Mithril from 'mithril';
import Button from 'flarum/common/components/Button';
import Tooltip from 'flarum/common/components/Tooltip';
import { UpdatedPackage, UpdateState } from './Updater';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import errorHandler from '../utils/errorHandler';
import Alert from 'flarum/common/components/Alert';
import WhyNotModal from './WhyNotModal';
import RequestError from 'flarum/common/utils/RequestError';
import ExtensionItem, { Extension } from './ExtensionItem';
interface MajorUpdaterAttrs extends ComponentAttrs {
coreUpdate: UpdatedPackage;
updateState: UpdateState;
}
export default class MajorUpdater<T extends MajorUpdaterAttrs = MajorUpdaterAttrs> extends Component<T> {
isLoading: string | null = null;
updateState!: UpdateState;
oninit(vnode: Mithril.Vnode<T, this>) {
super.oninit(vnode);
this.updateState = this.attrs.updateState;
}
view(vnode: Mithril.Vnode<T, this>): Mithril.Children {
// @todo move Form-group--danger class to core for reuse
return (
<div className="Form-group Form-group--danger PackageManager-majorUpdate">
<img alt="flarum logo" src={app.forum.attribute('baseUrl') + '/assets/extensions/flarum-package-manager/flarum.svg'} />
<label>{app.translator.trans('flarum-package-manager.admin.major_updater.title', { version: this.attrs.coreUpdate['latest-major'] })}</label>
<p className="helpText">{app.translator.trans('flarum-package-manager.admin.major_updater.description')}</p>
<div className="PackageManager-updaterControls">
<Tooltip text={app.translator.trans('flarum-package-manager.admin.major_updater.dry_run_help')}>
<Button className="Button" icon="fas fa-vial" onclick={this.update.bind(this, true)}>
{app.translator.trans('flarum-package-manager.admin.major_updater.dry_run')}
</Button>
</Tooltip>
<Button className="Button Button--danger" icon="fas fa-play" onclick={this.update.bind(this, false)}>
{app.translator.trans('flarum-package-manager.admin.major_updater.update')}
</Button>
</div>
{this.updateState.incompatibleExtensions.length ? (
<div className="PackageManager-majorUpdate-incompatibleExtensions PackageManager-extensions-grid">
{this.updateState.incompatibleExtensions.map((extension: string) => (
<ExtensionItem
extension={app.data.extensions[extension.replace('flarum-', '').replace('flarum-ext-', '').replace('/', '-')]}
updates={{}}
onClickUpdate={null}
isDanger={true}
/>
))}
</div>
) : null}
{this.updateState.status === 'failure' ? (
<Alert
type="error"
className="PackageManager-majorUpdate-failure"
dismissible={false}
controls={[
<Button
className="Button Button--text PackageManager-majorUpdate-failure-details"
icon="fas fa-question-circle"
onclick={() => app.modal.show(WhyNotModal, { package: 'flarum/core' })}
>
{app.translator.trans('flarum-package-manager.admin.major_updater.failure.why')}
</Button>,
]}
>
<p className="PackageManager-majorUpdate-failure-desc">
{app.translator.trans('flarum-package-manager.admin.major_updater.failure.desc')}
</p>
</Alert>
) : null}
</div>
);
}
update(dryRun: boolean) {
this.isLoading = `update-${dryRun ? 'dry-run' : 'run'}`;
app.modal.show(LoadingModal);
app
.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/major-update`,
body: {
data: { dryRun },
},
errorHandler,
})
.then(() => {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful'));
window.location.reload();
})
.catch((e: RequestError) => {
app.modal.close();
this.updateState.status = 'failure';
this.updateState.incompatibleExtensions = e.response?.errors?.pop()?.incompatible_extensions as string[];
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
}
}

View File

@@ -0,0 +1,254 @@
import Mithril from 'mithril';
import app from 'flarum/admin/app';
import Component from 'flarum/common/Component';
import Button from 'flarum/common/components/Button';
import humanTime from 'flarum/common/helpers/humanTime';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import errorHandler from '../utils/errorHandler';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import MajorUpdater from './MajorUpdater';
import ExtensionItem, { Extension } from './ExtensionItem';
export type UpdatedPackage = {
name: string;
version: string;
latest: string;
'latest-minor': string | null;
'latest-major': string | null;
'latest-status': string;
description: string;
};
export type ComposerUpdates = {
installed: UpdatedPackage[];
};
export type LastUpdateCheck = {
checkedAt: Date | null;
updates: ComposerUpdates;
};
type UpdateType = 'major' | 'minor' | 'global';
type UpdateStatus = 'success' | 'failure' | null;
export type UpdateState = {
ranAt: Date | null;
status: UpdateStatus;
limitedPackages: string[];
incompatibleExtensions: string[];
};
export type LastUpdateRun = {
[key in UpdateType]: UpdateState;
} & {
limitedPackages: () => string[];
};
export default class Updater<Attrs> extends Component<Attrs> {
isLoading: string | null = null;
packageUpdates: Record<string, UpdatedPackage> = {};
lastUpdateCheck: LastUpdateCheck = JSON.parse(app.data.settings['flarum-package-manager.last_update_check']) as LastUpdateCheck;
get lastUpdateRun(): LastUpdateRun {
const lastUpdateRun = JSON.parse(app.data.settings['flarum-package-manager.last_update_run']) as LastUpdateRun;
lastUpdateRun.limitedPackages = () => [
...lastUpdateRun.major.limitedPackages,
...lastUpdateRun.minor.limitedPackages,
...lastUpdateRun.global.limitedPackages,
];
return lastUpdateRun;
}
oninit(vnode: Mithril.Vnode<Attrs, this>) {
super.oninit(vnode);
}
view() {
const extensions = this.getExtensionUpdates();
let coreUpdate: UpdatedPackage | undefined = this.getCoreUpdate();
let core: any;
if (coreUpdate) {
core = {
id: 'flarum-core',
name: 'flarum/core',
version: app.data.settings.version,
icon: {
backgroundImage: `url(${app.forum.attribute('baseUrl')}/assets/extensions/flarum-package-manager/flarum.svg`,
},
extra: {
'flarum-extension': {
title: app.translator.trans('flarum-package-manager.admin.updater.flarum'),
},
},
};
}
return [
<div className="Form-group">
<label>{app.translator.trans('flarum-package-manager.admin.updater.updater_title')}</label>
<p className="helpText">{app.translator.trans('flarum-package-manager.admin.updater.updater_help')}</p>
{this.lastUpdateCheck?.checkedAt && (
<p className="PackageManager-lastUpdatedAt">
<span className="PackageManager-lastUpdatedAt-label">
{app.translator.trans('flarum-package-manager.admin.updater.last_update_checked_at')}
</span>
<span className="PackageManager-lastUpdatedAt-value">{humanTime(this.lastUpdateCheck.checkedAt)}</span>
</p>
)}
<div className="PackageManager-updaterControls">
<Button
className="Button"
icon="fas fa-sync-alt"
onclick={this.checkForUpdates.bind(this)}
loading={this.isLoading === 'check'}
disabled={this.isLoading !== null && this.isLoading !== 'check'}
>
{app.translator.trans('flarum-package-manager.admin.updater.check_for_updates')}
</Button>
<Button
className="Button"
icon="fas fa-play"
onclick={this.updateGlobally.bind(this)}
loading={this.isLoading === 'global-update'}
disabled={this.isLoading !== null && this.isLoading !== 'global-update'}
>
{app.translator.trans('flarum-package-manager.admin.updater.run_global_update')}
</Button>
</div>
{this.isLoading !== null ? (
<div className="PackageManager-extensions">
<LoadingIndicator />
</div>
) : extensions.length || core ? (
<div className="PackageManager-extensions">
<div className="PackageManager-extensions-grid">
{core ? (
<ExtensionItem
extension={core}
updates={coreUpdate}
isCore={true}
onClickUpdate={this.updateCoreMinor.bind(this)}
whyNotWarning={this.lastUpdateRun.limitedPackages().includes('flarum/core')}
/>
) : null}
{extensions.map((extension: Extension) => (
<ExtensionItem
extension={extension}
updates={this.packageUpdates[extension.id]}
onClickUpdate={this.updateExtension.bind(this, extension)}
whyNotWarning={this.lastUpdateRun.limitedPackages().includes(extension.name)}
/>
))}
</div>
</div>
) : null}
</div>,
coreUpdate && coreUpdate['latest-major'] ? <MajorUpdater coreUpdate={coreUpdate} updateState={this.lastUpdateRun.major} /> : null,
];
}
getExtensionUpdates(): Extension[] {
this.lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => {
const id = composerPackage.name.replace('/', '-').replace(/(flarum-ext-)|(flarum-)/, '');
const extension = app.data.extensions[id];
const safeToUpdate = ['semver-safe-update', 'update-possible'].includes(composerPackage['latest-status']);
if (extension && safeToUpdate) {
this.packageUpdates[extension.id] = composerPackage;
}
return extension && safeToUpdate;
});
return (Object.values(app.data.extensions) as Extension[]).filter((extension: Extension) => this.packageUpdates[extension.id]);
}
getCoreUpdate(): UpdatedPackage | undefined {
return this.lastUpdateCheck?.updates?.installed?.filter((composerPackage: UpdatedPackage) => composerPackage.name === 'flarum/core').pop();
}
checkForUpdates() {
this.isLoading = 'check';
app
.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/check-for-updates`,
errorHandler,
})
.then((response) => {
this.lastUpdateCheck = response as LastUpdateCheck;
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
}
updateCoreMinor() {
if (confirm(app.translator.trans('flarum-package-manager.admin.minor_update_confirmation.content'))) {
app.modal.show(LoadingModal);
this.isLoading = 'minor-update';
app
.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/minor-update`,
errorHandler,
})
.then(() => {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.update_successful'));
window.location.reload();
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
}
}
updateExtension(extension: any) {
app.modal.show(LoadingModal);
this.isLoading = 'extension-update';
app
.request({
method: 'PATCH',
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${extension.id}`,
errorHandler,
})
.then(() => {
app.alerts.show(
{ type: 'success' },
app.translator.trans('flarum-package-manager.admin.extensions.successful_update', { extension: extension.extra['flarum-extension'].title })
);
window.location.reload();
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
}
updateGlobally() {
app.modal.show(LoadingModal);
this.isLoading = 'global-update';
app
.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/global-update`,
errorHandler,
})
.then(() => {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.updater.global_update_successful'));
window.location.reload();
})
.finally(() => {
this.isLoading = null;
m.redraw();
});
}
}

View File

@@ -0,0 +1,51 @@
import app from 'flarum/admin/app';
import Mithril from 'mithril';
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
import errorHandler from '../utils/errorHandler';
export interface WhyNotModalAttrs extends IInternalModalAttrs {
package: string;
}
export default class WhyNotModal<Attrs extends WhyNotModalAttrs = WhyNotModalAttrs> extends Modal<Attrs> {
loading: boolean = true;
whyNot: string | null = null;
className() {
return 'Modal--large WhyNotModal';
}
title() {
return app.translator.trans('flarum-package-manager.admin.why_not_modal.title');
}
oncreate(vnode: Mithril.VnodeDOM<Attrs, this>) {
super.oncreate(vnode);
this.requestWhyNot();
}
content() {
return <div className="Modal-body">{this.loading ? <LoadingIndicator /> : <pre className="WhyNotModal-contents">{this.whyNot}</pre>}</div>;
}
requestWhyNot(): void {
app
.request({
method: 'POST',
url: `${app.forum.attribute('apiUrl')}/package-manager/why-not`,
body: {
data: {
package: this.attrs.package,
},
},
errorHandler,
})
.then((response: any) => {
this.loading = false;
this.whyNot = response.data.whyNot;
m.redraw();
});
}
}

View File

@@ -0,0 +1,73 @@
import { extend } from 'flarum/common/extend';
import app from 'flarum/admin/app';
import Alert from 'flarum/common/components/Alert';
import ExtensionPage from 'flarum/admin/components/ExtensionPage';
import Button from 'flarum/common/components/Button';
import LoadingModal from 'flarum/admin/components/LoadingModal';
import Installer from './components/Installer';
import Updater from './components/Updater';
import isExtensionEnabled from 'flarum/admin/utils/isExtensionEnabled';
app.initializers.add('flarum-package-manager', (app) => {
app.extensionData
.for('flarum-package-manager')
.registerSetting(() => {
if (!app.data.isRequiredDirectoriesWritable) {
return (
<div className="Form-group">
<Alert type="warning" dismissible={false}>
{app.translator.trans('flarum-package-manager.admin.file_permissions')}
</Alert>
</div>
);
}
return null;
})
.registerSetting(() => {
if (app.data.isRequiredDirectoriesWritable) {
return <Installer />;
}
return null;
})
.registerSetting(() => {
if (app.data.isRequiredDirectoriesWritable) {
return <Updater />;
}
return null;
});
extend(ExtensionPage.prototype, 'topItems', function (items) {
if (this.extension.id === 'flarum-package-manager' || isExtensionEnabled(this.extension.id)) {
return;
}
items.add(
'remove',
<Button
className="Button Button--danger"
icon="fas fa-times"
onclick={() => {
app.modal.show(LoadingModal);
app
.request({
url: `${app.forum.attribute('apiUrl')}/package-manager/extensions/${this.extension.id}`,
method: 'DELETE',
})
.then(() => {
app.alerts.show({ type: 'success' }, app.translator.trans('flarum-package-manager.admin.extensions.successful_remove'));
window.location = app.forum.attribute('adminUrl');
})
.finally(() => {
app.modal.close();
});
}}
>
Remove
</Button>
);
});
});

View File

@@ -0,0 +1,29 @@
import app from 'flarum/admin/app';
export default function (e: any) {
const error = e.response.errors[0];
if (!['composer_command_failure', 'extension_already_installed', 'extension_not_installed'].includes(error.code)) {
throw e;
}
switch (error.code) {
case 'composer_command_failure':
if (error.guessed_cause) {
app.alerts.show({ type: 'error' }, app.translator.trans(`flarum-package-manager.admin.exceptions.guessed_cause.${error.guessed_cause}`));
app.modal.close();
} else {
app.alerts.show({ type: 'error' }, app.translator.trans('flarum-package-manager.admin.exceptions.composer_command_failure'));
}
break;
case 'extension_already_installed':
app.alerts.show({ type: 'error' }, app.translator.trans('flarum-package-manager.admin.exceptions.extension_already_installed'));
app.modal.close();
break;
case 'extension_not_installed':
app.alerts.show({ type: 'error' }, app.translator.trans('flarum-package-manager.admin.exceptions.extension_not_installed'));
app.modal.close();
}
}

View File

@@ -0,0 +1,16 @@
{
// Use Flarum's tsconfig as a starting point
"extends": "flarum-tsconfig",
// This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder
// and also tells your Typescript server to read core's global typings for
// access to `dayjs` and `$` in the global namespace.
"include": ["src/**/*", "../vendor/flarum/core/js/dist-typings/@types/**/*", "@types/**/*"],
"compilerOptions": {
// This will output typings to `dist-typings`
"declarationDir": "./dist-typings",
"baseUrl": ".",
"paths": {
"flarum/*": ["../vendor/flarum/core/js/dist-typings/*"]
}
}
}

View File

@@ -0,0 +1 @@
module.exports = require('flarum-webpack-config')();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
.FormControl-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.ComposerFailureModal-output {
white-space: break-spaces;
}
.flarum-package-manager-Page .ExtensionPage-settings .Form-group:last-child {
display: none;
}
.PackageManager-lastUpdatedAt {
color: var(--control-color);
&-label {
font-weight: bold;
}
}
.PackageManager-updaterControls {
display: flex;
flex-wrap: wrap;
gap: 8px;
grid-area: controls;
}
.PackageManager-extensions {
&-grid {
--gap: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, calc(~"100% / 3 - var(--gap)"));
gap: var(--gap);
margin-top: 16px;
}
}
.PackageManager-extension {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--control-bg);
padding: 8px;
border-radius: var(--border-radius);
&-controls {
margin-left: auto;
}
&-icon {
--size: 40px;
}
&-name {
font-weight: bold;
}
&-version {
display: flex;
align-items: center;
gap: 8px;
&-latest {
border-radius: 30px;
padding: 0 6px;
font-weight: bold;
&--minor {
background-color: var(--alert-success-bg);
color: var(--alert-success-color);
}
&--major {
background-color: var(--alert-bg);
color: var(--alert-color);
}
}
}
&--core {
--bg-hover: darken(#e7672e, 5);
background-color: #e7672e;
color: #fff;
--button-color: #fff;
--button-bg-hover: var(--bg-hover);
.Button--danger {
color: #fff;
--button-bg-hover: var(--bg-hover);
}
}
&--core &-icon {
background-size: 100%;
background-color: transparent;
filter: grayscale(1) brightness(3.5);
}
&--danger {
background-color: var(--control-danger-bg);
}
}
.PackageManager-majorUpdate {
--space: 16px;
padding: var(--space);
display: grid;
grid-template-areas:
"title logo"
"helpText logo"
"controls logo"
"extensions extensions"
"failure failure";
grid-gap: 0 var(--space);
align-items: center;
> img {
grid-area: logo;
}
> label {
grid-area: title;
}
> .helpText {
grid-area: helpText;
}
&-failure {
--border-radius: 0;
grid-area: failure;
margin: calc(~"0px - var(--space)");
margin-top: var(--space);
}
&-incompatibleExtensions {
grid-area: extensions;
margin-top: var(--space);
padding-top: var(--space);
border-top: 1px solid var(--control-bg);
}
}
.Form-group--danger {
border: 2px solid var(--alert-error-bg);
border-radius: var(--border-radius);
background-color: transparent;
}
.WhyNotModal {
&-contents {
overflow-x: auto;
}
}

View File

@@ -0,0 +1,53 @@
flarum-package-manager:
admin:
exceptions:
composer_command_failure: Failed to execute. Check the composer logs in storage/logs/composer.
extension_already_installed: Extension is already installed.
extension_not_installed: Extension not found.
guessed_cause:
extension_incompatible_with_instance: The extension is most likely incompatible with your current Flarum instance.
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.
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.
proceed: Proceed
successful_install: "{extension} was installed successfully, redirecting.."
successful_remove: Extension removed successfully.
successful_update: "{extension} was updated successfully, redirecting.."
update: Update
file_permissions: >
The package manager requires read and write permissions on the following files and directories: composer.json, composer.lock, vendor, 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.
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:
desc: The last major update failed, some installed extensions are not compatible with the new major release.
why: Find out more
title: Flarum {version} Major Update Available
update: Update
minor_update_confirmation:
content: This will also update any other extensions/packages with availabe updates.
updater:
check_for_updates: Check for updates
flarum: Flarum Core
global_update_successful: Successfully updated all packages.
last_update_checked_at: "Last Update Check: "
run_global_update: Run Global Update
updater_title: Updates
updater_help: Runs a check for new extension and Flarum updates.
update_successful: Flarum successfully updated.
why_not_modal:
title: Why Won't it Update

View File

@@ -0,0 +1,19 @@
<?php
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
return Migration::createTable(
'generic_tasks',
function (Blueprint $table) {
$table->increments('id');
$table->string('status', 50)->nullable();
$table->string('command', 50);
$table->string('command_class')->nullable();
$table->string('package', 100)->nullable();
$table->mediumText('output');
$table->dateTime('created_at');
$table->dateTime('started_at')->nullable();
$table->dateTime('finished_at')->nullable();
}
);

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Http\RequestUtil;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Flarum\PackageManager\Command\CheckForUpdates;
class CheckForUpdatesController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$lastUpdateCheck = $this->bus->dispatch(
new CheckForUpdates($actor)
);
return new JsonResponse($lastUpdateCheck);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Bus\Dispatcher;
use Flarum\Http\RequestUtil;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;
use Flarum\PackageManager\Command\GlobalUpdate;
class GlobalUpdateController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$this->bus->dispatch(
new GlobalUpdate($actor)
);
return new EmptyResponse(200);
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Http\RequestUtil;
use Flarum\PackageManager\Api\Serializer\TaskSerializer;
use Flarum\PackageManager\Task;
use Flarum\Api\Controller\AbstractListController;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListTaskController extends AbstractListController
{
public $serializer = TaskSerializer::class;
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
*/
protected function data(ServerRequestInterface $request, Document $document)
{
RequestUtil::getActor($request)->assertAdmin();
return Task::query()->orderBy('created_at', 'desc')->get();
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Bus\Dispatcher;
use Flarum\Http\RequestUtil;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Flarum\PackageManager\Command\MajorUpdate;
class MajorUpdateController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$dryRun = (bool) (int) Arr::get($request->getParsedBody(), 'data.dryRun', 0);
$this->bus->dispatch(
new MajorUpdate($actor, $dryRun)
);
return new EmptyResponse(200);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Bus\Dispatcher;
use Flarum\Http\RequestUtil;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;
use Flarum\PackageManager\Command\MinorUpdate;
class MinorUpdateController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$this->bus->dispatch(
new MinorUpdate($actor)
);
return new EmptyResponse(200);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Bus\Dispatcher;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Flarum\PackageManager\Command\RemoveExtension;
use Psr\Http\Server\RequestHandlerInterface;
class RemoveExtensionController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$extensionId = Arr::get($request->getQueryParams(), 'id');
$this->bus->dispatch(
new RemoveExtension($actor, $extensionId)
);
return new EmptyResponse(200);
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Bus\Dispatcher;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Flarum\PackageManager\Api\Serializer\ExtensionSerializer;
use Flarum\PackageManager\Command\RequireExtension;
use Flarum\PackageManager\Extension\ExtensionUtils;
use Flarum\Api\Controller\AbstractCreateController;
use Flarum\Http\RequestUtil;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class RequireExtensionController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$package = Arr::get($request->getParsedBody(), 'data.package');
$data = $this->bus->dispatch(
new RequireExtension($actor, $package)
);
return new JsonResponse($data);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Bus\Dispatcher;
use Flarum\Http\RequestUtil;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Flarum\PackageManager\Command\UpdateExtension;
class UpdateExtensionController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$extensionId = Arr::get($request->getQueryParams(), 'id');
$this->bus->dispatch(
new UpdateExtension($actor, $extensionId)
);
return new EmptyResponse(200);
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Api\Controller;
use Flarum\Bus\Dispatcher;
use Flarum\Http\RequestUtil;
use Flarum\PackageManager\Command\WhyNot;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;
class WhyNotController implements RequestHandlerInterface
{
/**
* @var Dispatcher
*/
protected $bus;
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$actor = RequestUtil::getActor($request);
$package = Arr::get($request->getParsedBody(), 'data.package', '');
$version = Arr::get($request->getParsedBody(), 'data.version', '*');
$whyNot = $this->bus->dispatch(
new WhyNot($actor, $package, $version)
);
return new JsonResponse([
'data' => compact('whyNot')
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class CheckForUpdates
{
/**
* @var \Flarum\User\User
*/
public $actor;
public function __construct(User $actor)
{
$this->actor = $actor;
}
}

View File

@@ -0,0 +1,127 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Flarum\PackageManager\Exception\ComposerCommandFailedException;
use Flarum\PackageManager\Settings\LastUpdateCheck;
use Symfony\Component\Console\Input\ArrayInput;
class CheckForUpdatesHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var \Flarum\PackageManager\Settings\LastUpdateCheck
*/
protected $lastUpdateCheck;
public function __construct(ComposerAdapter $composer, LastUpdateCheck $lastUpdateCheck)
{
$this->composer = $composer;
$this->lastUpdateCheck = $lastUpdateCheck;
}
/**
* We run two commands here
*
* `composer outdated -D --format json`
* This queries latest versions for all direct packages, so it can include major updates,
* that are not necessarily compatible with the current flarum version.
* That includes flarum/core itself, so for example if we are on flarum/core v1.8.0
* and there are v1.8.1 and v2.0.0 available, the command would only let us know of v2.0.0.
*
* `composer outdated -D --minor-only --format json`
* This only lists latest minor updates, we need to run this as well not only to be able to know
* of these minor versions in addition to major ones, but especially for the flarum/core, as explained above
* we need to know of minor core updates, even if there is a major version available.
*
* The results from both commands are properly processed and merged to have new key values `latest-minor` and `latest-major`.
*
* @throws \Flarum\User\Exception\PermissionDeniedException|ComposerCommandFailedException
* @todo integration test
*/
public function handle(CheckForUpdates $command)
{
$actor = $command->actor;
$actor->assertAdmin();
$firstOutput = $this->runComposerCommand(false);
$firstOutput = json_decode($firstOutput, true);
$majorUpdates = false;
foreach ($firstOutput['installed'] as $package) {
if ($package['latest-status'] === 'update-possible') {
$majorUpdates = true;
break;
}
}
if ($majorUpdates) {
$secondOutput = $this->runComposerCommand(true);
$secondOutput = json_decode($secondOutput, true);
}
if (! isset($secondOutput)) {
$secondOutput = ['installed' => []];
}
foreach ($firstOutput['installed'] as &$mainPackageUpdate) {
$mainPackageUpdate['latest-minor'] = $mainPackageUpdate['latest-major'] = null;
if ($mainPackageUpdate['latest-status'] === 'update-possible') {
$mainPackageUpdate['latest-major'] = $mainPackageUpdate['latest'];
$minorPackageUpdate = array_filter($secondOutput['installed'], function ($package) use ($mainPackageUpdate) {
return $package['name'] === $mainPackageUpdate['name'];
})[0] ?? null;
if ($minorPackageUpdate) {
$mainPackageUpdate['latest-minor'] = $minorPackageUpdate['latest'];
}
} else {
$mainPackageUpdate['latest-minor'] = $mainPackageUpdate['latest'];
}
}
return $this->lastUpdateCheck
->with('installed', $firstOutput['installed'])
->save();
}
/**
* @throws ComposerCommandFailedException
*/
protected function runComposerCommand(bool $minorOnly): string
{
$input = [
'command' => 'outdated',
'-D' => true,
'--format' => 'json',
];
if ($minorOnly) {
$input['--minor-only'] = true;
}
$output = $this->composer->run(new ArrayInput($input));
if ($output->getExitCode() !== 0) {
throw new ComposerCommandFailedException('', $output->getContents());
}
return $output->getContents();
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class GlobalUpdate
{
/**
* @var \Flarum\User\User
*/
public $actor;
public function __construct(User $actor)
{
$this->actor = $actor;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\Bus\Dispatcher as FlarumDispatcher;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Illuminate\Contracts\Events\Dispatcher;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\PackageManager\Exception\ComposerUpdateFailedException;
use Symfony\Component\Console\Input\StringInput;
class GlobalUpdateHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var Dispatcher
*/
protected $events;
/**
* @var FlarumDispatcher
*/
protected $commandDispatcher;
public function __construct(ComposerAdapter $composer, Dispatcher $events, FlarumDispatcher $commandDispatcher)
{
$this->composer = $composer;
$this->events = $events;
$this->commandDispatcher = $commandDispatcher;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException|ComposerUpdateFailedException
*/
public function handle(GlobalUpdate $command)
{
$command->actor->assertAdmin();
$output = $this->composer->run(
new StringInput("update --prefer-dist --no-dev -a --with-all-dependencies")
);
if ($output->getExitCode() !== 0) {
throw new ComposerUpdateFailedException('*', $output->getContents());
}
$this->events->dispatch(
new FlarumUpdated($command->actor, FlarumUpdated::GLOBAL)
);
return true;
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class MajorUpdate
{
/**
* @var \Flarum\User\User
*/
public $actor;
/**
* @var bool
*/
public $dryRun;
public function __construct(User $actor, bool $dryRun)
{
$this->actor = $actor;
$this->dryRun = $dryRun;
}
}

View File

@@ -0,0 +1,129 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Flarum\PackageManager\Composer\ComposerJson;
use Flarum\PackageManager\Exception\MajorUpdateFailedException;
use Flarum\PackageManager\Exception\NoNewMajorVersionException;
use Illuminate\Contracts\Events\Dispatcher;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\PackageManager\Settings\LastUpdateCheck;
use Symfony\Component\Console\Input\ArrayInput;
class MajorUpdateHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var LastUpdateCheck
*/
protected $lastUpdateCheck;
/**
* @var Dispatcher
*/
protected $events;
/**
* @var ComposerJson
*/
protected $composerJson;
/**
* @param ComposerAdapter $composer
* @param LastUpdateCheck $lastUpdateCheck
* @param Dispatcher $events
* @param ComposerJson $composerJson
*/
public function __construct(ComposerAdapter $composer, LastUpdateCheck $lastUpdateCheck, Dispatcher $events, ComposerJson $composerJson)
{
$this->composer = $composer;
$this->lastUpdateCheck = $lastUpdateCheck;
$this->events = $events;
$this->composerJson = $composerJson;
}
/**
* Set the version constraint for all directly required packages in the root composer.json to *.
* Set flarum/core version constraint to new major version.
* Run composer update --prefer-dist --no-plugins --no-dev -a --with-all-dependencies.
* Clear cache.
* Run migrations.
*
* @throws \Flarum\User\Exception\PermissionDeniedException
* @throws NoNewMajorVersionException|MajorUpdateFailedException
*/
public function handle(MajorUpdate $command)
{
$command->actor->assertAdmin();
$majorVersion = $this->lastUpdateCheck->getNewMajorVersion();
if (! $majorVersion) {
throw new NoNewMajorVersionException();
}
$this->updateComposerJson($majorVersion);
$this->runCommand($command->dryRun, $majorVersion);
if ($command->dryRun) {
$this->composerJson->revert();
return true;
}
$this->events->dispatch(
new FlarumUpdated($command->actor, FlarumUpdated::MAJOR)
);
return true;
}
/**
* @todo change minimum stability to 'stable' and any other similar params
*/
protected function updateComposerJson(string $majorVersion): void
{
$versionNumber = str_replace('v', '', $majorVersion);
$this->composerJson->require('*', '*');
$this->composerJson->require('flarum/core', '^'.$versionNumber);
}
/**
* @throws MajorUpdateFailedException
*/
protected function runCommand(bool $dryRun, string $majorVersion): void
{
$input = [
'command' => 'update',
'--prefer-dist' => true,
'--no-plugins' => true,
'--no-dev' => true,
'-a' => true,
'--with-all-dependencies' => true,
];
if ($dryRun) {
$input['--dry-run'] = true;
}
$output = $this->composer->run(new ArrayInput($input));
if ($output->getExitCode() !== 0) {
throw new MajorUpdateFailedException('*', $output->getContents(), $majorVersion);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class MinorUpdate
{
/**
* @var \Flarum\User\User
*/
public $actor;
public function __construct(User $actor)
{
$this->actor = $actor;
}
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Flarum\PackageManager\Composer\ComposerJson;
use Illuminate\Contracts\Events\Dispatcher;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\PackageManager\Exception\ComposerUpdateFailedException;
use Flarum\PackageManager\Settings\LastUpdateCheck;
use Symfony\Component\Console\Input\StringInput;
class MinorUpdateHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var \Flarum\PackageManager\Settings\LastUpdateCheck
*/
protected $lastUpdateCheck;
/**
* @var Dispatcher
*/
protected $events;
/**
* @var ComposerJson
*/
protected $composerJson;
public function __construct(ComposerAdapter $composer, LastUpdateCheck $lastUpdateCheck, Dispatcher $events, ComposerJson $composerJson)
{
$this->composer = $composer;
$this->lastUpdateCheck = $lastUpdateCheck;
$this->events = $events;
$this->composerJson = $composerJson;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
* @throws ComposerUpdateFailedException
*/
public function handle(MinorUpdate $command)
{
$command->actor->assertAdmin();
$coreRequirement = $this->composerJson->get()['require']['flarum/core'];
$this->composerJson->require('*', '*');
$this->composerJson->require('flarum/core', $coreRequirement);
$output = $this->composer->run(
new StringInput("update --prefer-dist --no-dev -a --with-all-dependencies")
);
if ($output->getExitCode() !== 0) {
throw new ComposerUpdateFailedException('flarum/*', $output->getContents());
}
$this->events->dispatch(
new FlarumUpdated($command->actor, FlarumUpdated::MINOR)
);
return true;
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class RemoveExtension
{
/**
* @var User
*/
public $actor;
/**
* @var string
*/
public $extensionId;
public function __construct(User $actor, string $extensionId)
{
$this->actor = $actor;
$this->extensionId = $extensionId;
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\Extension\ExtensionManager;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Illuminate\Contracts\Events\Dispatcher;
use Flarum\PackageManager\Exception\ComposerCommandFailedException;
use Flarum\PackageManager\Exception\ExtensionNotInstalledException;
use Flarum\PackageManager\Extension\Event\Removed;
use Symfony\Component\Console\Input\StringInput;
class RemoveExtensionHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var ExtensionManager
*/
protected $extensions;
/**
* @var Dispatcher
*/
protected $events;
public function __construct(ComposerAdapter $composer, ExtensionManager $extensions, Dispatcher $events)
{
$this->composer = $composer;
$this->extensions = $extensions;
$this->events = $events;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
* @throws \Exception
*/
public function handle(RemoveExtension $command)
{
$command->actor->assertAdmin();
$extension = $this->extensions->getExtension($command->extensionId);
if (empty($extension)) {
throw new ExtensionNotInstalledException($command->extensionId);
}
$output = $this->composer->run(
new StringInput("remove $extension->name")
);
if ($output->getExitCode() !== 0) {
throw new ComposerCommandFailedException($extension->name, $output->getContents());
}
$this->events->dispatch(
new Removed($extension)
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class RequireExtension
{
/**
* @var User
*/
public $actor;
/**
* @var string
*/
public $package;
public function __construct(User $actor, string $package)
{
$this->actor = $actor;
$this->package = $package;
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\Extension\ExtensionManager;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Illuminate\Contracts\Events\Dispatcher;
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 Symfony\Component\Console\Input\StringInput;
class RequireExtensionHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var ExtensionManager
*/
protected $extensions;
/**
* @var RequirePackageValidator
*/
protected $validator;
/**
* @var Dispatcher
*/
protected $events;
public function __construct(ComposerAdapter $composer, ExtensionManager $extensions, RequirePackageValidator $validator, Dispatcher $events)
{
$this->composer = $composer;
$this->extensions = $extensions;
$this->validator = $validator;
$this->events = $events;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
* @throws \Exception
*/
public function handle(RequireExtension $command)
{
$command->actor->assertAdmin();
$this->validator->assertValid(['package' => $command->package]);
$extensionId = ExtensionUtils::nameToId($command->package);
$extension = $this->extensions->getExtension($extensionId);
if (! empty($extension)) {
throw new ExtensionAlreadyInstalledException($extension);
}
$packageName = $command->package;
// Auto append :* if not requiring a specific version.
if (strpos($packageName, ':') === false) {
$packageName .= ":*";
}
$output = $this->composer->run(
new StringInput("require $packageName")
);
if ($output->getExitCode() !== 0) {
throw new ComposerRequireFailedException($packageName, $output->getContents());
}
$this->events->dispatch(
new Installed($extensionId)
);
return ['id' => $extensionId];
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class UpdateExtension
{
/**
* @var User
*/
public $actor;
/**
* @var string
*/
public $extensionId;
public function __construct(User $actor, string $extensionId)
{
$this->actor = $actor;
$this->extensionId = $extensionId;
}
}

View File

@@ -0,0 +1,93 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\Extension\ExtensionManager;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Illuminate\Contracts\Events\Dispatcher;
use Flarum\PackageManager\Exception\ComposerUpdateFailedException;
use Flarum\PackageManager\Exception\ExtensionNotInstalledException;
use Flarum\PackageManager\Extension\Event\Updated;
use Flarum\PackageManager\UpdateExtensionValidator;
use Flarum\PackageManager\Settings\LastUpdateCheck;
use Symfony\Component\Console\Input\StringInput;
class UpdateExtensionHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var ExtensionManager
*/
protected $extensions;
/**
* @var UpdateExtensionValidator
*/
protected $validator;
/**
* @var LastUpdateCheck
*/
protected $lastUpdateCheck;
/**
* @var Dispatcher
*/
protected $events;
public function __construct(
ComposerAdapter $composer,
ExtensionManager $extensions,
UpdateExtensionValidator $validator,
LastUpdateCheck $lastUpdateCheck,
Dispatcher $events)
{
$this->composer = $composer;
$this->extensions = $extensions;
$this->validator = $validator;
$this->lastUpdateCheck = $lastUpdateCheck;
$this->events = $events;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
* @throws \Exception
*/
public function handle(UpdateExtension $command)
{
$command->actor->assertAdmin();
$this->validator->assertValid(['extensionId' => $command->extensionId]);
$extension = $this->extensions->getExtension($command->extensionId);
if (empty($extension)) {
throw new ExtensionNotInstalledException($command->extensionId);
}
$output = $this->composer->run(
new StringInput("require $extension->name:*")
);
if ($output->getExitCode() !== 0) {
throw new ComposerUpdateFailedException($extension->name, $output->getContents());
}
$this->events->dispatch(
new Updated($command->actor, $extension)
);
return true;
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\User\User;
class WhyNot
{
/**
* @var User
*/
public $actor;
/**
* @var string
*/
public $package;
/**
* @var string
*/
public $version;
public function __construct(User $actor, string $package, string $version)
{
$this->actor = $actor;
$this->package = $package;
$this->version = $version;
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Command;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Illuminate\Contracts\Events\Dispatcher;
use Flarum\PackageManager\Exception\ComposerRequireFailedException;
use Flarum\PackageManager\WhyNotValidator;
use Symfony\Component\Console\Input\StringInput;
class WhyNotHandler
{
/**
* @var ComposerAdapter
*/
protected $composer;
/**
* @var WhyNotValidator
*/
protected $validator;
/**
* @var Dispatcher
*/
protected $events;
public function __construct(ComposerAdapter $composer, WhyNotValidator $validator, Dispatcher $events)
{
$this->composer = $composer;
$this->validator = $validator;
$this->events = $events;
}
/**
* @throws \Flarum\User\Exception\PermissionDeniedException
* @throws \Exception
*/
public function handle(WhyNot $command)
{
$command->actor->assertAdmin();
$this->validator->assertValid([
'package' => $command->package,
'version' => $command->version
]);
$output = $this->composer->run(
new StringInput("why-not $command->package $command->version")
);
if ($output->getExitCode() !== 0) {
throw new ComposerRequireFailedException($command->package, $output->getContents());
}
return $output->getContents();
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Composer;
use Composer\Console\Application;
use Flarum\Foundation\Paths;
use Flarum\PackageManager\OutputLogger;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\BufferedOutput;
/**
* @internal
*/
class ComposerAdapter
{
/**
* @var Application
*/
private $application;
/**
* @var OutputLogger
*/
private $logger;
/**
* @var BufferedOutput
*/
private $output;
/**
* @var Paths
*/
private $paths;
public function __construct(Application $application, OutputLogger $logger, Paths $paths)
{
$this->application = $application;
$this->logger = $logger;
$this->paths = $paths;
$this->output = new BufferedOutput();
}
public function run(InputInterface $input): ComposerOutput
{
$this->application->resetComposer();
// 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);
chdir($currDir);
$outputContents = $this->output->fetch();
$this->logger->log($input->__toString(), $outputContents, $exitCode);
return new ComposerOutput($exitCode, $outputContents);
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Composer;
use Flarum\Foundation\Paths;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
class ComposerJson
{
/**
* @var Paths
*/
protected $paths;
/**
* @var Filesystem
*/
protected $filesystem;
/**
* @var array
*/
protected $initialJson;
public function __construct(Paths $paths, Filesystem $filesystem)
{
$this->paths = $paths;
$this->filesystem = $filesystem;
}
public function require(string $packageName, string $version): void
{
$composerJson = $this->get();
if (strpos($packageName, '*') === false) {
$composerJson['require'][$packageName] = $version;
} else {
foreach ($composerJson['require'] as $p => $v) {
if ($version === '*@dev') {
continue;
}
$wildcardPackageName = str_replace('\*', '.*', preg_quote($packageName, '/'));
if (Str::of($p)->test("/($wildcardPackageName)/")) {
$composerJson['require'][$p] = $version;
}
}
}
$this->set($composerJson);
}
public function revert(): void
{
$this->set($this->initialJson);
}
protected function getComposerJsonPath(): string
{
return $this->paths->base . '/composer.json';
}
/**
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
*/
public function get(): array
{
$json = json_decode($this->filesystem->get($this->getComposerJsonPath()), true);
if (! $this->initialJson) {
$this->initialJson = $json;
}
return $json;
}
protected function set(array $json): void
{
$this->filesystem->put($this->getComposerJsonPath(), json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Composer;
class ComposerOutput
{
/**
* @var int
*/
protected $exitCode;
/**
* @var string
*/
protected $contents;
public function __construct(int $exitCode, string $contents)
{
$this->exitCode = $exitCode;
$this->contents = $contents;
}
public function getExitCode(): int
{
return $this->exitCode;
}
public function getContents(): string
{
return $this->contents;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Event;
use Flarum\User\User;
class FlarumUpdated
{
public const GLOBAL = 'global';
public const MINOR = 'minor';
public const MAJOR = 'major';
/**
* @var User
*/
public $actor;
/**
* @var string
*/
public $type;
public function __construct(User $actor, string $type)
{
$this->actor = $actor;
$this->type = $type;
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
use Exception;
class ComposerCommandFailedException extends Exception
{
/**
* @var string
*/
public $packageName;
/**
* @var array
*/
public $details = [];
public function __construct(string $packageName, string $output)
{
$this->packageName = $packageName;
parent::__construct($output);
}
public function guessCause(): ?string
{
return null;
}
protected function getRawPackageName(): string
{
return preg_replace('/^([A-z0-9-_\/]+)(?::.*|)$/i', '$1', $this->packageName);
}
}

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
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';
public function guessCause(): ?string
{
$hasMatches = preg_match(
str_replace('{PACKAGE_NAME}', preg_quote($this->getRawPackageName(), '/'), self::INCOMPATIBLE_REGEX),
$this->getMessage(),
$matches
);
if ($hasMatches) {
return 'extension_incompatible_with_instance';
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
class ComposerUpdateFailedException extends ComposerCommandFailedException
{
// ...
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
use Flarum\Foundation\ErrorHandling\HandledError;
class ExceptionHandler
{
public function handle(ComposerCommandFailedException $e): HandledError
{
return (new HandledError(
$e,
'composer_command_failure',
409
))->withDetails($this->errorDetails($e));
}
protected function errorDetails(ComposerCommandFailedException $e): array
{
$details = [];
if ($guessedCause = $this->guessCause($e)) {
$details['guessed_cause'] = $guessedCause;
}
if (! empty($e->details)) {
$details = array_merge($details, $e->details);
}
return [$details];
}
protected function guessCause(ComposerCommandFailedException $e): ?string
{
return $e->guessCause();
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
use Exception;
use Flarum\Extension\Extension;
use Flarum\Foundation\KnownError;
class ExtensionAlreadyInstalledException extends Exception implements KnownError
{
public function __construct(Extension $extension)
{
parent::__construct("Extension {$extension->getTitle()} is already installed.");
}
public function getType(): string
{
return 'extension_already_installed';
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
use Exception;
use Flarum\Foundation\KnownError;
class ExtensionNotInstalledException extends Exception implements KnownError
{
public function __construct(string $extensionId)
{
parent::__construct("Extension {$extensionId} is not installed.");
}
public function getType(): string
{
return 'extension_not_installed';
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
use Composer\Semver\Semver;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\PackageManager\Settings\LastUpdateRun;
class MajorUpdateFailedException extends ComposerCommandFailedException
{
private const INCOMPATIBLE_REGEX = '/^ +- (?<ext>[A-z0-9\/-]+) [A-z0-9.-_\/]+ requires flarum\/core (?<coreReq>(?:[A-z0-9.><=_ -](?!->))+)/m';
/**
* @var string
*/
private $majorVersion;
public function __construct(string $packageName, string $output, string $majorVersion)
{
$this->majorVersion = $majorVersion;
parent::__construct($packageName, $output);
}
public function guessCause(): ?string
{
if (preg_match_all(self::INCOMPATIBLE_REGEX, $this->getMessage(), $matches) !== false) {
$this->details['incompatible_extensions'] = [];
foreach ($matches['ext'] as $k => $name) {
if (! Semver::satisfies($this->majorVersion, $matches['coreReq'][$k])) {
$this->details['incompatible_extensions'][] = $name;
}
}
resolve(LastUpdateRun::class)
->for(FlarumUpdated::MAJOR)
->with('status', LastUpdateRun::FAILURE)
->with('incompatibleExtensions', $this->details['incompatible_extensions'])
->save();
return 'extensions_incompatible_with_new_major';
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Exception;
use Exception;
use Flarum\Foundation\KnownError;
class NoNewMajorVersionException extends Exception implements KnownError
{
public function __construct()
{
parent::__construct("No new major version known of. Try checking for updates first.");
}
public function getType(): string
{
return 'no_new_major_version';
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Extension\Event;
class Installed
{
/**
* @var string
*/
public $extensionId;
public function __construct(string $extensionId)
{
$this->extensionId = $extensionId;
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Extension\Event;
use Flarum\Extension\Extension;
class Removed
{
/**
* @var Extension
*/
public $extension;
public function __construct(Extension $extension)
{
$this->extension = $extension;
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Extension\Event;
use Flarum\Extension\Extension;
use Flarum\User\User;
class Updated
{
/**
* @var User
*/
public $actor;
/**
* @var Extension
*/
public $extension;
public function __construct(User $actor, Extension $extension)
{
$this->actor = $actor;
$this->extension = $extension;
}
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Extension;
class ExtensionUtils
{
public static function nameToId(string $name): string
{
[$vendor, $package] = explode('/', $name);
$package = str_replace(['flarum-ext-', 'flarum-'], '', $package);
return "$vendor-$package";
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Listener;
use Composer\Command\ClearCacheCommand;
use Flarum\Database\Console\MigrateCommand;
use Flarum\Foundation\Console\AssetsPublishCommand;
use Flarum\PackageManager\Event\FlarumUpdated;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
class ClearCacheAfterUpdate
{
/**
* @var ClearCacheCommand
*/
private $clearCache;
/**
* @var AssetsPublishCommand
*/
private $publishAssets;
/**
* @var MigrateCommand
*/
private $migrate;
public function __construct(ClearCacheCommand $clearCache, AssetsPublishCommand $publishAssets, MigrateCommand $migrate)
{
$this->clearCache = $clearCache;
$this->publishAssets = $publishAssets;
$this->migrate = $migrate;
}
/**
* @throws \Exception
*/
public function handle(FlarumUpdated $event): void
{
$this->clearCache->run(new ArrayInput([]), new NullOutput());
$this->migrate->run(new ArrayInput([]), new NullOutput());
$this->publishAssets->run(new ArrayInput([]), new NullOutput());
}
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Listener;
use Flarum\Bus\Dispatcher;
use Flarum\PackageManager\Command\CheckForUpdates;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\PackageManager\Extension\Event\Updated;
use Flarum\PackageManager\Settings\LastUpdateCheck;
use Flarum\PackageManager\Settings\LastUpdateRun;
class ReCheckForUpdates
{
/**
* @var LastUpdateRun
*/
private $lastUpdateRun;
/**
* @var LastUpdateCheck
*/
private $lastUpdateCheck;
/**
* @var Dispatcher
*/
private $bus;
public function __construct(LastUpdateRun $lastUpdateRun, LastUpdateCheck $lastUpdateCheck, Dispatcher $bus)
{
$this->lastUpdateRun = $lastUpdateRun;
$this->lastUpdateCheck = $lastUpdateCheck;
$this->bus = $bus;
}
/**
* @param FlarumUpdated|Updated $event
*/
public function handle($event): void
{
$previousUpdateCheck = $this->lastUpdateCheck->get();
$lastUpdateCheck = $this->bus->dispatch(
new CheckForUpdates($event->actor)
);
if ($event instanceof FlarumUpdated) {
$mapPackageName = function (array $package) {
return $package['name'];
};
$previousPackages = array_map($mapPackageName, $previousUpdateCheck['updates']['installed']);
$lastPackages = array_map($mapPackageName, $lastUpdateCheck['updates']['installed']);
$this->lastUpdateRun
->for($event->type)
->with('status', LastUpdateRun::SUCCESS)
->with('limitedPackages', array_intersect($previousPackages, $lastPackages))
->save();
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager;
use Psr\Log\LoggerInterface;
class OutputLogger
{
/**
* @var LoggerInterface
*/
protected $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
public function log(string $input, string $output, int $exitCode): void
{
$content = "$input\n$output";
if ($exitCode === 0) {
$this->logger->info($content);
} else {
$this->logger->error($content);
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager;
use Composer\Config;
use Composer\Console\Application;
use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Paths;
use Flarum\Frontend\RecompileFrontendAssets;
use Flarum\Locale\LocaleManager;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Flarum\PackageManager\Listener\ReCheckForUpdates;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\PackageManager\Extension\Event\Updated;
use Flarum\PackageManager\Listener\ClearCacheAfterUpdate;
class PackageManagerServiceProvider extends AbstractServiceProvider
{
public function register()
{
$this->container->singleton(ComposerAdapter::class, function (Container $container) {
// This should only ever be resolved when running composer commands,
// because we modify other environment configurations.
$composer = new Application();
$composer->setAutoExit(false);
/** @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");
Config::$defaultConfig['vendor-dir'] = $paths->vendor;
// When running simple require, update and remove commands on packages,
// composer 2 doesn't really need this much unless the extensions are very loaded dependency wise,
// but this is necessary for running flarum updates.
@ini_set('memory_limit', '1G');
@set_time_limit(5 * 60);
return new ComposerAdapter($composer, $container->make(OutputLogger::class), $container->make(Paths::class));
});
$this->container->alias(ComposerAdapter::class, 'flarum.composer');
$this->container->singleton(OutputLogger::class, function (Container $container) {
$logPath = $container->make(Paths::class)->storage.'/logs/composer/output.log';
$handler = new RotatingFileHandler($logPath, Logger::INFO);
$handler->setFormatter(new LineFormatter(null, null, true, true));
$logger = new Logger('composer', [$handler]);
return new OutputLogger($logger);
});
}
public function boot(Container $container)
{
/** @var Dispatcher $events */
$events = $container->make('events');
$events->listen(
[Updated::class],
function (Updated $event) use ($container) {
/** @var ExtensionManager $extensions */
$extensions = $container->make(ExtensionManager::class);
if ($extensions->isEnabled($event->extension->getId())) {
$recompile = new RecompileFrontendAssets(
$container->make('flarum.assets.forum'),
$container->make(LocaleManager::class)
);
$recompile->flush();
$extensions->migrate($event->extension);
$event->extension->copyAssetsTo($container->make('filesystem')->disk('flarum-assets'));
}
}
);
$events->listen(FlarumUpdated::class, ClearCacheAfterUpdate::class);
$events->listen([FlarumUpdated::class, Updated::class], ReCheckForUpdates::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager;
use Flarum\Foundation\AbstractValidator;
class RequirePackageValidator extends AbstractValidator
{
public const PACKAGE_NAME_REGEX = '/^[A-z0-9-_]+\/[A-z-0-9]+(?::[A-z-0-9.->=<_]+){0,1}$/i';
/**
* {@inheritdoc}
*/
protected $rules = [
'package' => ['required', 'string', 'regex:'.self::PACKAGE_NAME_REGEX]
];
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Settings;
interface JsonSetting
{
public function with(string $key, $value): self;
public function save(): array;
public function get(): array;
public static function key(): string;
public static function default(): array;
}

View File

@@ -0,0 +1,78 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Settings;
use Carbon\Carbon;
use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class LastUpdateCheck implements JsonSetting
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $data = [];
public function __construct(SettingsRepositoryInterface $settings)
{
$this->settings = $settings;
}
public function with(string $key, $value): JsonSetting
{
$this->data[$key] = $value;
return $this;
}
public function save(): array
{
$lastUpdateCheck = [
'checkedAt' => Carbon::now(),
'updates' => $this->data,
];
$this->settings->set($this->key(), json_encode($lastUpdateCheck));
return $lastUpdateCheck;
}
public function get(): array
{
return json_decode($this->settings->get($this->key()), true);
}
public static function key(): string
{
return 'flarum-package-manager.last_update_check';
}
public static function default(): array
{
return [
'checkedAt' => null,
'updates' => [
'installed' => [],
],
];
}
public function getNewMajorVersion(): ?string
{
$core = Arr::first(Arr::get($this->get(), 'updates.installed', []), function ($package) {
return $package['name'] === 'flarum/core';
});
return $core ? $core['latest-major'] : null;
}
}

View File

@@ -0,0 +1,97 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Settings;
use Carbon\Carbon;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\Settings\SettingsRepositoryInterface;
class LastUpdateRun implements JsonSetting
{
public const SUCCESS = 'success';
public const FAILURE = 'failure';
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
protected $data;
/**
* @var {'major'|'minor'|'global'}
*/
protected $activeUpdate;
public function __construct(SettingsRepositoryInterface $settings)
{
$this->settings = $settings;
$this->data = self::default();
}
public function for(string $update): self
{
if (! in_array($update, [FlarumUpdated::MAJOR, FlarumUpdated::MINOR, FlarumUpdated::GLOBAL])) {
throw new \InvalidArgumentException("Last update runs can only be for one of: minor, major, global");
}
$this->activeUpdate = $update;
return $this;
}
public function with(string $key, $value): JsonSetting
{
$this->data[$this->activeUpdate][$key] = $value;
return $this;
}
public function save(): array
{
$this->data[$this->activeUpdate]['ranAt'] = Carbon::now();
$this->settings->set(self::key(), json_encode($this->data));
return $this->data;
}
public function get(): array
{
$lastUpdateRun = json_decode($this->settings->get(self::key()), true);
if ($this->activeUpdate) {
return $lastUpdateRun[$this->activeUpdate];
}
return $lastUpdateRun;
}
public static function key(): string
{
return 'flarum-package-manager.last_update_run';
}
public static function default(): array
{
$defaultState = [
'ranAt' => null,
'status' => null,
'limitedPackages' => [],
'incompatibleExtensions' => [],
];
return [
FlarumUpdated::GLOBAL => $defaultState,
FlarumUpdated::MINOR => $defaultState,
FlarumUpdated::MAJOR => $defaultState,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager;
use Flarum\Foundation\AbstractValidator;
class UpdateExtensionValidator extends AbstractValidator
{
/**
* {@inheritdoc}
*/
protected $rules = [
'extensionId' => 'required|string'
];
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager;
use Flarum\Foundation\AbstractValidator;
class WhyNotValidator extends AbstractValidator
{
/**
* {@inheritdoc}
*/
protected $rules = [
'package' => ['required', 'string', 'regex:'.RequirePackageValidator::PACKAGE_NAME_REGEX],
'version' => ['sometimes', 'string', 'regex:/(?:\*|[A-z0-9.-]+)/i']
];
}

View File

View File

@@ -0,0 +1,14 @@
<?php
namespace Flarum\PackageManager\Tests\integration;
trait ChangeComposerConfig
{
protected function setComposerConfig(array $requirements): void
{
$composerSetup = new SetupComposer($requirements);
$composerSetup->run();
$this->composer('install');
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration;
use Flarum\Foundation\Paths;
trait DummyExtensions
{
protected function makeDummyExtensionCompatibleWith(string $name, string $coreVersions): void
{
$dirName = $this->tmpDir() . "/packages/" . str_replace('/', '-', $name);
if (! file_exists($dirName)) {
mkdir($dirName);
}
file_put_contents($dirName."/composer.json", json_encode([
'name' => $name,
'version' => '1.0.0',
'require' => [
'flarum/core' => $coreVersions
],
], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration;
use FilesystemIterator;
use Flarum\PackageManager\Composer\ComposerAdapter;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
trait RefreshComposerSetup
{
public function tearDown(): void
{
$composerSetup = new SetupComposer();
@unlink($this->tmpDir().'/composer.lock');
$this->deleteDummyExtensions();
$composerSetup->run();
$this->composer('install');
parent::tearDown();
}
private function deleteDummyExtensions(): void
{
$dir = $this->tmpDir().'/packages';
$it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach($files as $file) {
if ($file->isDir()){
rmdir($file->getRealPath());
} else {
unlink($file->getRealPath());
}
}
rmdir($dir);
}
protected function forgetComposerApp(): void
{
$this->app()->getContainer()->instance(ComposerAdapter::class, null);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration;
use Flarum\Testing\integration\UsesTmpDir;
class SetupComposer
{
use UsesTmpDir;
private $config = [
'require' => [
'flarum/core' => '1.0.0',
'flarum/tags' => '1.0.3',
'flarum/lang-english' => '*',
],
'config' => [
'preferred-install' => 'dist',
'sort-packages' => true,
],
'repositories' => [
[
'type' => 'path',
'url' => __DIR__.'/tmp/packages/*',
]
]
];
public function __construct(array $config = null)
{
$this->config = array_merge($this->config, $config ?? []);
}
public function run()
{
$composerJson = $this->tmpDir().'/composer.json';
$composerLock = $this->tmpDir().'/composer.lock';
$packages = $this->tmpDir().'/packages';
file_put_contents($composerJson, json_encode($this->config, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
if (! file_exists($packages)) {
mkdir($packages);
}
if (file_exists($composerLock)) {
unlink($composerLock);
}
}
}

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration;
use Flarum\Foundation\Paths;
use Flarum\PackageManager\Composer\ComposerAdapter;
use Flarum\PackageManager\Composer\ComposerJson;
use Flarum\PackageManager\Extension\ExtensionUtils;
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
use Illuminate\Support\Arr;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Console\Input\StringInput;
class TestCase extends \Flarum\Testing\integration\TestCase
{
use RetrievesAuthorizedUsers;
protected function setUp(): void
{
parent::setUp();
$this->extension('flarum-package-manager', 'flarum-tags');
$tmp = realpath($this->tmpDir());
$this->app()->getContainer()->instance('flarum.paths', new Paths([
'base' => $tmp,
'public' => "$tmp/public",
'storage' => "$tmp/storage",
'vendor' => "$tmp/vendor",
]));
}
protected function assertExtension(string $id, bool $exists)
{
$installed = json_decode(file_get_contents($this->app()->getContainer()->make(Paths::class)->vendor.'/composer/installed.json'), true);
$installedExtensions = Arr::where($installed['packages'] ?? $installed, function (array $package) {
return $package['type'] === 'flarum-extension';
});
$installedExtensionIds = array_map(function (string $name) {
return ExtensionUtils::nameToId($name);
}, Arr::pluck($installedExtensions, 'name'));
if ($exists) {
$this->assertTrue(in_array($id, $installedExtensionIds), "Failed asserting that extension $id is installed");
} else {
$this->assertFalse(in_array($id, $installedExtensionIds), "Failed asserting that extension $id is not installed");
}
}
protected function assertExtensionExists(string $id)
{
$this->assertExtension($id, true);
}
protected function assertExtensionNotExists(string $id)
{
$this->assertExtension($id, false);
}
protected function assertPackageVersion(string $packageName, string $version)
{
$composerJson = $this->app()->getContainer()->make(ComposerJson::class)->get();
$this->assertArrayHasKey($packageName, $composerJson['require'], "$packageName is not required.");
$this->assertEquals($version, $composerJson['require'][$packageName], "Expected $packageName to be $version, found {$composerJson['require'][$packageName]} instead.");
}
protected function requireExtension(string $package)
{
$this->composer("require $package");
}
protected function removeExtension(string $package)
{
$this->composer("remove $package");
}
protected function composer(string $command): void
{
/** @var ComposerAdapter $composer */
$composer = $this->app()->getContainer()->make(ComposerAdapter::class);
$composer->run(new StringInput($command));
}
protected function errorDetails(ResponseInterface $response): array
{
$json = json_decode((string) $response->getBody(), true);
return $json['errors'] ? ($json['errors'][0] ?? []) : [];
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration\api;
use Flarum\PackageManager\Tests\integration\ChangeComposerConfig;
use Flarum\PackageManager\Tests\integration\RefreshComposerSetup;
use Flarum\PackageManager\Tests\integration\TestCase;
use Illuminate\Support\Arr;
class CheckForUpdatesTest extends TestCase
{
use RefreshComposerSetup, ChangeComposerConfig;
/**
* @test
*/
public function can_check_for_updates()
{
$this->setComposerConfig([
'require' => [
'flarum/core' => '^1.0.0',
'flarum/tags' => '1.0.0',
]
]);
$response = $this->send(
$this->request('POST', '/api/package-manager/check-for-updates', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(['flarum/tags'], Arr::pluck(json_decode((string) $response->getBody(), true)['updates']['installed'], 'name'));
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration\api;
use Flarum\PackageManager\Tests\integration\RefreshComposerSetup;
use Flarum\PackageManager\Tests\integration\TestCase;
class GlobalUpdateTest extends TestCase
{
use RefreshComposerSetup;
/**
* @test
*/
public function can_global_update()
{
$response = $this->send(
$this->request('POST', '/api/package-manager/global-update', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(200, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,127 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration\api;
use Flarum\PackageManager\Tests\integration\ChangeComposerConfig;
use Flarum\PackageManager\Tests\integration\DummyExtensions;
use Flarum\PackageManager\Tests\integration\RefreshComposerSetup;
use Flarum\PackageManager\Tests\integration\TestCase;
class MajorUpdateTest extends TestCase
{
use RefreshComposerSetup, ChangeComposerConfig, DummyExtensions;
/**
* @test
*/
public function cannot_update_when_no_update_check_ran()
{
$this->makeDummyExtensionCompatibleWith("flarum/dummy-incompatible-extension", ">=0.1.0-beta.15 <=0.1.0-beta.16");
$this->setComposerConfig([
'require' => [
'flarum/core' => '^0.1.0-beta.15',
'flarum/tags' => '^0.1.0-beta.15',
'flarum/dummy-incompatible-extension' => '^1.0.0'
],
'minimum-stability' => 'beta',
]);
$response = $this->send(
$this->request('POST', '/api/package-manager/major-update', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(409, $response->getStatusCode());
$this->assertEquals('no_new_major_version', $this->errorDetails($response)['code']);
}
/**
* @test
*/
public function can_update_when_major_update_available()
{
$this->makeDummyExtensionCompatibleWith("flarum/dummy-compatible-extension", "^0.1.0-beta.15 | ^1.0.0");
$this->setComposerConfig([
'require' => [
'flarum/core' => '^0.1.0-beta.15',
'flarum/tags' => '^0.1.0-beta.15',
'flarum/dummy-compatible-extension' => '^1.0.0'
],
'minimum-stability' => 'beta',
]);
$lastUpdateCheck = $this->send(
$this->request('POST', '/api/package-manager/check-for-updates', [
'authenticatedAs' => 1,
])
);
$this->forgetComposerApp();
$response = $this->send(
$this->request('POST', '/api/package-manager/major-update', [
'authenticatedAs' => 1,
])
);
$newMinorCoreVersion = array_filter(
json_decode((string) $lastUpdateCheck->getBody(), true)['updates']['installed'],
function ($package) {
return $package['name'] === 'flarum/core';
}
)[0]['latest-major'];
$this->assertEquals(200, $response->getStatusCode());
$this->assertPackageVersion("flarum/core", str_replace('v', '^', $newMinorCoreVersion));
$this->assertPackageVersion("flarum/tags", "*");
$this->assertPackageVersion("flarum/dummy-compatible-extension", "*");
}
/**
* @test
*/
public function cannot_update_with_incompatible_extensions()
{
$this->makeDummyExtensionCompatibleWith("flarum/dummy-incompatible-extension-a", ">=0.1.0-beta.16 <0.1.0-beta.17");
$this->makeDummyExtensionCompatibleWith("flarum/dummy-incompatible-extension-b", ">=0.1.0-beta.16 <=0.1.0-beta.17");
$this->makeDummyExtensionCompatibleWith("flarum/dummy-incompatible-extension-c", "0.1.0-beta.16");
$this->setComposerConfig([
'require' => [
'flarum/core' => '^0.1.0-beta.16',
'flarum/tags' => '^0.1.0-beta.16',
'flarum/dummy-incompatible-extension-a' => '^1.0.0',
'flarum/dummy-incompatible-extension-b' => '^1.0.0',
'flarum/dummy-incompatible-extension-c' => '^1.0.0',
],
'minimum-stability' => 'beta',
]);
$this->send(
$this->request('POST', '/api/package-manager/check-for-updates', [
'authenticatedAs' => 1,
])
);
$response = $this->send(
$this->request('POST', '/api/package-manager/major-update', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(409, $response->getStatusCode());
$this->assertEquals('extensions_incompatible_with_new_major', $this->errorDetails($response)['guessed_cause']);
$this->assertEquals([
'flarum/dummy-incompatible-extension-a',
'flarum/dummy-incompatible-extension-b',
'flarum/dummy-incompatible-extension-c'
], $this->errorDetails($response)['incompatible_extensions']);
}
}

View File

@@ -0,0 +1,95 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration\api;
use Flarum\PackageManager\Event\FlarumUpdated;
use Flarum\PackageManager\Settings\LastUpdateRun;
use Flarum\PackageManager\Tests\integration\ChangeComposerConfig;
use Flarum\PackageManager\Tests\integration\DummyExtensions;
use Flarum\PackageManager\Tests\integration\RefreshComposerSetup;
use Flarum\PackageManager\Tests\integration\TestCase;
class MinorUpdateTest extends TestCase
{
use RefreshComposerSetup, ChangeComposerConfig, DummyExtensions;
/**
* @test--
*/
public function can_update_to_next_minor_version()
{
$this->makeDummyExtensionCompatibleWith("flarum/dummy-compatible-extension", "^1.0.0");
$this->setComposerConfig([
'require' => [
// The only reason we don't set this to `^1.0.0` and let it update to latest minor,
// is because migrations that run DDL queries might be introduced in future versions,
// therefore breaking the test transaction.
'flarum/core' => '>=1.0.0 <= 1.1.0',
// We leave tags fixed to a version,
// the update handler must be able to set it to `*`.
'flarum/tags' => '1.0.3',
'flarum/lang-english' => '*',
'flarum/dummy-compatible-extension' => '^1.0.0'
]
]);
$response = $this->send(
$this->request('POST', '/api/package-manager/minor-update', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertPackageVersion('flarum/tags', '*');
$this->assertPackageVersion('flarum/dummy-compatible-extension', '*');
}
/**
* @test
*/
public function can_update_with_latest_ext_incompatible_with_latest_core()
{
$this->makeDummyExtensionCompatibleWith("flarum/dummy-extension", "1.0.0");
$this->setComposerConfig([
'require' => [
'flarum/core' => '>=1.0.0 <=1.1.0',
'flarum/tags' => '1.0.3',
'flarum/lang-english' => '*',
'flarum/dummy-extension' => '^1.0.0'
]
]);
$this->send(
$this->request('POST', '/api/package-manager/check-for-updates', [
'authenticatedAs' => 1,
])
);
$this->forgetComposerApp();
$response = $this->send(
$this->request('POST', '/api/package-manager/minor-update', [
'authenticatedAs' => 1,
])
);
/** @var LastUpdateRun $lastUpdateRun */
$lastUpdateRun = $this->app()->getContainer()->make(LastUpdateRun::class);
$this->assertEquals(200, $response->getStatusCode());
$this->assertPackageVersion("flarum/tags", "*");
$this->assertPackageVersion("flarum/dummy-extension", "*");
$this->assertEquals([
'flarum/core',
'flarum/lang-english',
'flarum/tags'
], $lastUpdateRun->for(FlarumUpdated::MINOR)->get()['limitedPackages']);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration\api\extensions;
use Flarum\PackageManager\Tests\integration\RefreshComposerSetup;
use Flarum\PackageManager\Tests\integration\TestCase;
class RemoveExtensionTest extends TestCase
{
use RefreshComposerSetup;
/**
* @test
*/
public function extension_installed_by_default()
{
$this->assertExtensionExists('flarum-tags');
}
/**
* @test
*/
public function removing_an_extension_works()
{
$response = $this->send(
$this->request('DELETE', '/api/package-manager/extensions/flarum-tags', [
'authenticatedAs' => 1
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertExtensionNotExists('flarum-tags');
}
/**
* @test
*/
public function removing_a_non_existant_extension_fails()
{
$response = $this->send(
$this->request('DELETE', '/api/package-manager/extensions/flarum-potato', [
'authenticatedAs' => 1
])
);
$this->assertEquals(409, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,125 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration\api\extensions;
use Flarum\PackageManager\Tests\integration\RefreshComposerSetup;
use Flarum\PackageManager\Tests\integration\TestCase;
class RequireExtensionTest extends TestCase
{
use RefreshComposerSetup;
/**
* @test
*/
public function extension_uninstalled_by_default()
{
$this->assertExtensionNotExists('v17development-blog');
}
/**
* @test
*/
public function requiring_an_existing_extension_fails()
{
$response = $this->send(
$this->request('POST', '/api/package-manager/extensions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'package' => 'flarum/tags'
]
]
])
);
$this->assertEquals(409, $response->getStatusCode());
}
/**
* @test
*/
public function requiring_a_compatible_extension_works()
{
$response = $this->send(
$this->request('POST', '/api/package-manager/extensions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'package' => 'v17development/flarum-blog'
]
]
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertExtensionExists('v17development-blog');
}
/**
* @test
*/
public function requiring_a_compatible_extension_with_specific_version_works()
{
$response = $this->send(
$this->request('POST', '/api/package-manager/extensions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'package' => 'v17development/flarum-blog:0.4.0'
]
]
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertExtensionExists('v17development-blog');
}
/**
* @test
*/
public function requiring_an_uncompatible_extension_fails()
{
$response = $this->send(
$this->request('POST', '/api/package-manager/extensions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'package' => 'flarum/auth-github'
]
]
])
);
$this->assertEquals(409, $response->getStatusCode());
$this->assertEquals('extension_incompatible_with_instance', $this->errorDetails($response)['guessed_cause']);
}
/**
* @test
*/
public function requiring_an_uncompatible_extension_with_specific_version_fails()
{
$response = $this->send(
$this->request('POST', '/api/package-manager/extensions', [
'authenticatedAs' => 1,
'json' => [
'data' => [
'package' => 'flarum/auth-github:0.1.0-beta.9'
]
]
])
);
$this->assertEquals(409, $response->getStatusCode());
$this->assertEquals('extension_incompatible_with_instance', $this->errorDetails($response)['guessed_cause']);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\PackageManager\Tests\integration\api\extensions;
use Flarum\PackageManager\Tests\integration\RefreshComposerSetup;
use Flarum\PackageManager\Tests\integration\TestCase;
class UpdateExtensionTest extends TestCase
{
use RefreshComposerSetup;
/**
* @test
*/
public function extension_installed_by_default()
{
$this->assertExtensionExists('flarum-tags');
}
/**
* @test
*/
public function updating_an_existing_extension_works()
{
$response = $this->send(
$this->request('PATCH', '/api/package-manager/extensions/flarum-tags', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(200, $response->getStatusCode());
$this->assertExtensionExists('flarum-tags');
}
/**
* @test
*/
public function updating_a_non_existing_extension_fails()
{
$response = $this->send(
$this->request('PATCH', '/api/package-manager/extensions/flarum-potato', [
'authenticatedAs' => 1,
])
);
$this->assertEquals(409, $response->getStatusCode());
}
}

Some files were not shown because too many files have changed in this diff Show More