Compare commits

...

23 Commits
1.0.0 ... 1

Author SHA1 Message Date
Steve Boyd acb067fdc2
MNT Use gha-dispatch-ci (#32) 2023-03-23 14:15:48 +13:00
Steve Boyd e5dceb4546
API Update deprecations (#27)
* ENH Update deprecation messages

* API Update deprecations
2022-10-25 17:23:26 +13:00
Steve Boyd c4e8728ff9 Merge branch '1.7' into 1 2022-08-03 14:32:59 +12:00
Guy Sartorelli da89aa5dcc
Merge pull request #24 from creative-commoners/pulls/1.7/standardise-modules
MNT Standardise modules
2022-08-02 15:32:19 +12:00
Steve Boyd 4ec7b15347 MNT Standardise modules 2022-08-01 10:45:07 +12:00
Steve Boyd 2f74096ea4 Merge branch '1.7' into 1 2022-07-26 16:42:52 +12:00
Guy Sartorelli 2175573ca5
Merge pull request #23 from creative-commoners/pulls/1.7/module-standards
MNT Use GitHub Actions CI
2022-07-15 17:01:25 +12:00
Steve Boyd f68a4cc2be MNT Use GitHub Actions CI 2022-07-06 14:02:51 +12:00
Andrew Paxley 301daf2437
FIX Only write composer.json if there are changes (#21)
FIX Only write composer.json if there are changes
2022-05-10 09:40:14 +12:00
Guy Sartorelli 8a77da94d1
Merge pull request #20 from creative-commoners/pulls/1/php81
ENH PHP 8.1 compatibility
2022-04-22 16:16:42 +12:00
Steve Boyd 02f5008b46 ENH PHP 8.1 compatibility 2022-04-13 17:43:58 +12:00
Jordi Boggiano a76509e8a6
FIX Remove unnecessary methods from trait, adds Composer 2.3 compatibility (#19) 2022-04-01 21:25:06 +13:00
Steve Boyd 102c2bc100
Remove build status badge 2021-01-21 16:06:24 +13:00
Steve Boyd 68c7402854
Update build status badge 2021-01-21 16:05:34 +13:00
Steve Boyd da7ccb0a09
Merge pull request #17 from creative-commoners/pull/1/deps-pipe
DEP Use double pipe for requirements
2020-11-11 16:37:26 +13:00
Steve Boyd 01c9b45185 DEP Use double pipe for requirements 2020-11-11 16:15:16 +13:00
Maxime Rainville 374f223a17
API Add support for Composer 2 (#16) 2020-10-26 13:55:21 +13:00
Robbie Averill 88cd7ed3a0
Add editorconfig rule for composer.json to be allowed four spaces 2018-04-11 19:22:09 +12:00
Damian Mooyman 96cd4972a0
Update 1 branch alias 2018-04-11 15:10:48 +12:00
Damian Mooyman eacf7e3c06
Merge pull request #7 from open-sausages/pulls/1/appy-days
Support legacy rewrite for projects with mysite folder
2018-04-11 15:04:12 +12:00
Damian Mooyman 9ab71cf1e9
Support legacy rewrite for projects with mysite folder 2018-04-05 16:35:24 +12:00
Aaron Carlino 3e16a5b138
Merge pull request #4 from open-sausages/pulls/1/throwing-a-public
API Support `public-files`
2018-01-12 14:33:30 +13:00
Damian Mooyman a959a68e54 API Support `public-files` 2018-01-10 10:14:52 +13:00
10 changed files with 278 additions and 88 deletions

View File

@ -13,3 +13,6 @@ insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[composer.json]
indent_size = 4

11
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,11 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
ci:
name: CI
uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1

16
.github/workflows/dispatch-ci.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Dispatch CI
on:
# At 12:00 PM UTC, only on Friday and Saturday
schedule:
- cron: '0 12 * * 5,6'
jobs:
dispatch-ci:
name: Dispatch CI
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Dispatch CI
uses: silverstripe/gha-dispatch-ci@v1

17
.github/workflows/keepalive.yml vendored Normal file
View File

@ -0,0 +1,17 @@
name: Keepalive
on:
workflow_dispatch:
# The 4th of every month at 10:50am UTC
schedule:
- cron: '50 10 4 * *'
jobs:
keepalive:
name: Keepalive
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Keepalive
uses: silverstripe/gha-keepalive@v1

View File

@ -1,4 +1,7 @@
# SilverStripe recipe-plugin # Silverstripe recipe-plugin
[![CI](https://github.com/silverstripe/recipe-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/silverstripe/recipe-plugin/actions/workflows/ci.yml)
[![Silverstripe supported module](https://img.shields.io/badge/silverstripe-supported-0071C4.svg)](https://www.silverstripe.org/software/addons/silverstripe-commercially-supported-module-list/)
## Introduction ## Introduction
@ -133,6 +136,9 @@ Recipe types should follow the following rules:
- The `require` must have `silverstripe/recipe-plugin` as a dependency. - The `require` must have `silverstripe/recipe-plugin` as a dependency.
- `extra.project-files` must be declared as a list of wildcard patterns, matching the files in the recipe root - `extra.project-files` must be declared as a list of wildcard patterns, matching the files in the recipe root
as they should be copied to the root project. The relative paths of these resources are equivalent. as they should be copied to the root project. The relative paths of these resources are equivalent.
- `extra.public-files` must be declared for any files which should be copied to the `public` web folder. If the project
in question doesn't have any public folder, these will be copied to root instead. Note that all public files
must be committed to the recipe `public` folder.
An example recipe: An example recipe:
@ -151,9 +157,31 @@ An example recipe:
"project-files": [ "project-files": [
"mysite/_config/*.yml", "mysite/_config/*.yml",
"mysite/code/MyBlogPage.php" "mysite/code/MyBlogPage.php"
"client/src/*"
],
"public-files": [
"client/dist/*"
] ]
}, },
"prefer-stable": true, "prefer-stable": true,
"minimum-stability": "dev" "minimum-stability": "dev"
} }
``` ```
The files within this recipe would be organised in the structure:
```
client/
src/
blog.scss
mysite/
_config/
settings.yml
code/
MyBlogPage.php
public/
client/
dist/
blog.css
composer.json
```

View File

@ -17,11 +17,16 @@
"extra": { "extra": {
"class": "SilverStripe\\RecipePlugin\\RecipePlugin" "class": "SilverStripe\\RecipePlugin\\RecipePlugin"
}, },
"scripts": {
"lint": "phpcs src/",
"lint-clean": "phpcbf src/"
},
"require": { "require": {
"composer-plugin-api": "^1.1" "composer-plugin-api": "^1.1 || ^2"
}, },
"require-dev": { "require-dev": {
"composer/composer": "^1.2" "composer/composer": "^1.2 || 2",
"squizlabs/php_codesniffer": "^3.5"
}, },
"minimum-stability": "dev" "minimum-stability": "dev"
} }

12
phpcs.xml.dist Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="SilverStripe">
<description>CodeSniffer ruleset for SilverStripe coding conventions.</description>
<file>src</file>
<!-- base rules are PSR-12 -->
<rule ref="PSR12" >
<exclude name="PSR1.Methods.CamelCapsMethodName.NotCamelCaps" />
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols" />
</rule>
</ruleset>

View File

@ -14,31 +14,6 @@ use Symfony\Component\Console\Output\OutputInterface;
trait RecipeCommandBehaviour trait RecipeCommandBehaviour
{ {
/**
* Gets the application instance for this command.
*
* @return Application An Application instance
*/
public abstract function getApplication();
/**
* @param bool $required
* @param bool|null $disablePlugins
* @throws \RuntimeException
* @return Composer
*/
public abstract function getComposer($required = true, $disablePlugins = null);
/**
* Removes the cached composer instance
*/
public abstract function resetComposer();
/**
* @return IOInterface
*/
abstract public function getIO();
/** /**
* @param OutputInterface $output * @param OutputInterface $output
* @param string $recipe * @param string $recipe
@ -128,17 +103,17 @@ trait RecipeCommandBehaviour
} }
// Existing version is already a ^1.0.0 or ~1.0.0 constraint // Existing version is already a ^1.0.0 or ~1.0.0 constraint
if (preg_match('#^[~^]#', $existingVersion)) { if (preg_match('#^[~^]#', $existingVersion ?? '')) {
return $existingVersion; return $existingVersion;
} }
// Existing version is already a dev constraint // Existing version is already a dev constraint
if (stristr($existingVersion, 'dev') !== false) { if (stristr($existingVersion ?? '', 'dev') !== false) {
return $existingVersion; return $existingVersion;
} }
// Numeric-only version maps to semver constraint // Numeric-only version maps to semver constraint
if (preg_match('#^([\d.]+)$#', $existingVersion)) { if (preg_match('#^([\d.]+)$#', $existingVersion ?? '')) {
return "^{$existingVersion}"; return "^{$existingVersion}";
} }
@ -242,7 +217,7 @@ trait RecipeCommandBehaviour
// Add new require / extra-installed // Add new require / extra-installed
$composerData['require'] = $require; $composerData['require'] = $require;
if ($previouslyInstalled){ if ($previouslyInstalled) {
if (!isset($composerData['extra'])) { if (!isset($composerData['extra'])) {
$composerData['extra'] = []; $composerData['extra'] = [];
} }

View File

@ -14,9 +14,15 @@ use RecursiveDirectoryIterator;
use RecursiveIteratorIterator; use RecursiveIteratorIterator;
use RegexIterator; use RegexIterator;
class RecipeInstaller extends LibraryInstaller { class RecipeInstaller extends LibraryInstaller
{
/**
* @var bool
*/
private $hasWrittenFiles = false;
public function __construct(IOInterface $io, Composer $composer) { public function __construct(IOInterface $io, Composer $composer)
{
parent::__construct($io, $composer, null); parent::__construct($io, $composer, null);
} }
@ -27,69 +33,100 @@ class RecipeInstaller extends LibraryInstaller {
* @param string $sourceRoot Base of source files (no trailing slash) * @param string $sourceRoot Base of source files (no trailing slash)
* @param string $destinationRoot Base of destination directory (no trailing slash) * @param string $destinationRoot Base of destination directory (no trailing slash)
* @param array $filePatterns List of file patterns in wildcard format (e.g. `code/My*.php`) * @param array $filePatterns List of file patterns in wildcard format (e.g. `code/My*.php`)
* @param string $registrationKey Registration key for installed files
* @param string $name Name of project file type being installed
*/ */
protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $filePatterns) protected function installProjectFiles(
{ $recipe,
$sourceRoot,
$destinationRoot,
$filePatterns,
$registrationKey,
$name = 'project'
) {
// load composer json data // load composer json data
$composerFile = new JsonFile(Factory::getComposerFile(), null, $this->io); $composerFile = new JsonFile(Factory::getComposerFile(), null, $this->io);
$composerData = $composerFile->read(); $composerData = $composerFile->read();
$installedFiles = isset($composerData['extra'][RecipePlugin::PROJECT_FILES_INSTALLED]) $installedFiles = isset($composerData['extra'][$registrationKey])
? $composerData['extra'][RecipePlugin::PROJECT_FILES_INSTALLED] ? $composerData['extra'][$registrationKey]
: []; : [];
// Load all project files // Load all project files
$fileIterator = $this->getFileIterator($sourceRoot, $filePatterns); $fileIterator = $this->getFileIterator($sourceRoot, $filePatterns);
$any = false; $any = false;
foreach($fileIterator as $path => $info) { foreach ($fileIterator as $path => $info) {
$destination = $destinationRoot . substr($path, strlen($sourceRoot)); // Write header on first file
$relativePath = substr($path, strlen($sourceRoot) + 1); // Name path without leading '/'
// Write header
if (!$any) { if (!$any) {
$this->io->write("Installing project files for recipe <info>{$recipe}</info>:"); $this->io->write("Installing {$name} files for recipe <info>{$recipe}</info>:");
$any = true; $any = true;
} }
// Check if file exists // Install this file
if (file_exists($destination)) { $relativePath = $this->installProjectFile($sourceRoot, $destinationRoot, $path, $installedFiles);
if (file_get_contents($destination) === file_get_contents($path)) {
$this->io->write(
" - Skipping <info>$relativePath</info> (<comment>existing, but unchanged</comment>)"
);
} else {
$this->io->write(
" - Skipping <info>$relativePath</info> (<comment>existing and modified in project</comment>)"
);
}
} elseif (in_array($relativePath, $installedFiles)) {
// Don't re-install previously installed files that have been deleted
$this->io->write(
" - Skipping <info>$relativePath</info> (<comment>previously installed</comment>)"
);
} else {
$any++;
$this->io->write(" - Copying <info>$relativePath</info>");
$this->filesystem->ensureDirectoryExists(dirname($destination));
copy($path, $destination);
}
// Add file to installed (even if already exists) // Add file to installed (even if already exists)
if (!in_array($relativePath, $installedFiles)) { if (!in_array($relativePath, $installedFiles ?? [])) {
$installedFiles[] = $relativePath; $installedFiles[] = $relativePath;
} }
} }
// If any files are written, modify composer.json with newly installed files // If any files are written, modify composer.json with newly installed files
if ($installedFiles) { if ($this->hasWrittenFiles) {
sort($installedFiles); sort($installedFiles);
if (!isset($composerData['extra'])) { if (!isset($composerData['extra'])) {
$composerData['extra'] = []; $composerData['extra'] = [];
} }
$composerData['extra'][RecipePlugin::PROJECT_FILES_INSTALLED] = $installedFiles; $composerData['extra'][$registrationKey] = $installedFiles;
$composerFile->write($composerData); $composerFile->write($composerData);
// Reset the variable so that we can try this trick again later
$this->hasWrittenFiles = false;
} }
} }
/**
* @param string $sourceRoot Base of source files (no trailing slash)
* @param string $destinationRoot Base of destination directory (no trailing slash)
* @param string $sourcePath Full filesystem path to the file to copy
* @param array $installedFiles List of installed files
* @return bool|string
*/
protected function installProjectFile($sourceRoot, $destinationRoot, $sourcePath, $installedFiles)
{
// Relative path
$relativePath = substr($sourcePath ?? '', strlen($sourceRoot ?? '') + 1); // Name path without leading '/'
// Get destination path
$relativeDestination = $this->rewriteFilePath($destinationRoot, $relativePath);
$destination = $destinationRoot . DIRECTORY_SEPARATOR . $relativeDestination;
// Check if file exists
if (file_exists($destination ?? '')) {
if (file_get_contents($destination ?? '') === file_get_contents($sourcePath ?? '')) {
$this->io->write(
" - Skipping <info>$relativePath</info> (<comment>existing, but unchanged</comment>)"
);
} else {
$this->io->write(
" - Skipping <info>$relativePath</info> (<comment>existing and modified in project</comment>)"
);
}
} elseif (
in_array($relativePath, $installedFiles ?? []) ||
in_array($relativeDestination, $installedFiles ?? [])
) {
// Don't re-install previously installed files that have been deleted
$this->io->write(
" - Skipping <info>$relativePath</info> (<comment>previously installed</comment>)"
);
} else {
$this->io->write(" - Copying <info>$relativePath</info>");
$this->filesystem->ensureDirectoryExists(dirname($destination ?? ''));
copy($sourcePath ?? '', $destination ?? '');
$this->hasWrittenFiles = true;
}
return $relativePath;
}
/** /**
* Get iterator of matching source files to copy * Get iterator of matching source files to copy
* *
@ -97,13 +134,14 @@ class RecipeInstaller extends LibraryInstaller {
* @param array $patterns List of wildcard patterns to match * @param array $patterns List of wildcard patterns to match
* @return Iterator File iterator, where key is path and value is file info object * @return Iterator File iterator, where key is path and value is file info object
*/ */
protected function getFileIterator($sourceRoot, $patterns) { protected function getFileIterator($sourceRoot, $patterns)
{
// Build regexp pattern // Build regexp pattern
$expressions = []; $expressions = [];
foreach($patterns as $pattern) { foreach ($patterns as $pattern) {
$expressions[] = $this->globToRegexp($pattern); $expressions[] = $this->globToRegexp($pattern);
} }
$regExp = '#^' . $this->globToRegexp($sourceRoot . '/').'(('.implode(')|(', $expressions).'))$#'; $regExp = '#^' . $this->globToRegexp($sourceRoot . '/') . '((' . implode(')|(', $expressions) . '))$#';
// Build directory iterator // Build directory iterator
$directoryIterator = new RecursiveDirectoryIterator( $directoryIterator = new RecursiveDirectoryIterator(
@ -125,11 +163,12 @@ class RecipeInstaller extends LibraryInstaller {
* @param string $glob * @param string $glob
* @return string * @return string
*/ */
protected function globToRegexp($glob) { protected function globToRegexp($glob)
$sourceParts = explode('*', $glob); {
$regexParts = array_map(function($part) { $sourceParts = explode('*', $glob ?? '');
return preg_quote($part, '#'); $regexParts = array_map(function ($part) {
}, $sourceParts); return preg_quote($part ?? '', '#');
}, $sourceParts ?? []);
return implode('(.+)', $regexParts); return implode('(.+)', $regexParts);
} }
@ -143,19 +182,77 @@ class RecipeInstaller extends LibraryInstaller {
return; return;
} }
// Find recipe base dir
$recipePath = $this->getInstallPath($package);
// Find project path // Find project path
$destinationPath = dirname(realpath(Factory::getComposerFile())); $projectPath = dirname(realpath(Factory::getComposerFile() ?? '') ?? '');
// Find public path
$candidatePublicPath = $projectPath . DIRECTORY_SEPARATOR . RecipePlugin::PUBLIC_PATH;
$publicPath = is_dir($candidatePublicPath ?? '') ? $candidatePublicPath : $projectPath;
// Copy project files to root // Copy project files to root
$name = $package->getName(); $name = $package->getName();
$extra = $package->getExtra(); $extra = $package->getExtra();
// Install project-files
if (isset($extra[RecipePlugin::PROJECT_FILES])) { if (isset($extra[RecipePlugin::PROJECT_FILES])) {
$this->installProjectFiles( $this->installProjectFiles(
$name, $name,
$this->getInstallPath($package), $recipePath,
$destinationPath, $projectPath,
$extra[RecipePlugin::PROJECT_FILES] $extra[RecipePlugin::PROJECT_FILES],
RecipePlugin::PROJECT_FILES_INSTALLED,
'project'
);
}
// Install public-files
if (isset($extra[RecipePlugin::PUBLIC_FILES])) {
$this->installProjectFiles(
$name,
$recipePath . '/' . RecipePlugin::PUBLIC_PATH,
$publicPath,
$extra[RecipePlugin::PUBLIC_FILES],
RecipePlugin::PUBLIC_FILES_INSTALLED,
'public'
); );
} }
} }
/**
* Perform any file rewrites necessary to a relative path of a file being installed.
* E.g. if 'mysite' folder exists, rewrite 'mysite' to 'app' and 'mysite/code' to 'app/src'
*
* This will be removed in 2.0 as the app folder will be hard coded and no rewrites supported.
*
* @deprecated 1.2.0 Will be removed without equivalent functionality to replace it
* @param string $destinationRoot Project root
* @param string $relativePath Relative path to the resource being installed
* @return string Relative path we should write to
*/
protected function rewriteFilePath($destinationRoot, $relativePath)
{
// If app folder exists, no rewrite
if (is_dir($destinationRoot . DIRECTORY_SEPARATOR . 'app')) {
return $relativePath;
}
// if mysite folder does NOT exist, no rewrite
if (!is_dir($destinationRoot . DIRECTORY_SEPARATOR . 'mysite')) {
return $relativePath;
}
// Return first rewrite
$rewrites = [
'app/src' => 'mysite/code',
'app' => 'mysite',
];
foreach ($rewrites as $from => $to) {
if (stripos($relativePath ?? '', $from ?? '') === 0) {
return $to . substr($relativePath ?? '', strlen($from ?? ''));
}
}
return $relativePath;
}
} }

View File

@ -1,6 +1,5 @@
<?php <?php
namespace SilverStripe\RecipePlugin; namespace SilverStripe\RecipePlugin;
use Composer\Composer; use Composer\Composer;
@ -27,22 +26,37 @@ class RecipePlugin implements PluginInterface, EventSubscriberInterface, Capable
/** /**
* Type of recipe to check for * Type of recipe to check for
*/ */
const RECIPE_TYPE = 'silverstripe-recipe'; public const RECIPE_TYPE = 'silverstripe-recipe';
/** /**
* 'extra' key for project files * 'extra' key for project files
*/ */
const PROJECT_FILES = 'project-files'; public const PROJECT_FILES = 'project-files';
/**
* 'extra' key for public files
*/
public const PUBLIC_FILES = 'public-files';
/**
* Hard-coded 'public' web-root folder
*/
public const PUBLIC_PATH = 'public';
/** /**
* 'extra' key for list of project files installed * 'extra' key for list of project files installed
*/ */
const PROJECT_FILES_INSTALLED = 'project-files-installed'; public const PROJECT_FILES_INSTALLED = 'project-files-installed';
/**
* 'extra' key for list of public files installed
*/
public const PUBLIC_FILES_INSTALLED = 'public-files-installed';
/** /**
* 'extra' key for project dependencies installed * 'extra' key for project dependencies installed
*/ */
const PROJECT_DEPENDENCIES_INSTALLED = 'project-dependencies-installed'; public const PROJECT_DEPENDENCIES_INSTALLED = 'project-dependencies-installed';
public function activate(Composer $composer, IOInterface $io) public function activate(Composer $composer, IOInterface $io)
{ {
@ -81,8 +95,11 @@ class RecipePlugin implements PluginInterface, EventSubscriberInterface, Capable
$file = new JsonFile(Factory::getComposerFile()); $file = new JsonFile(Factory::getComposerFile());
$data = $file->read(); $data = $file->read();
// Remove project-files from project, and any empty extra // Remove project and public files from project
unset($data['extra']['project-files']); unset($data['extra'][self::PROJECT_FILES]);
unset($data['extra'][self::PUBLIC_FILES]);
// Remove redundant empty extra
if (empty($data['extra'])) { if (empty($data['extra'])) {
unset($data['extra']); unset($data['extra']);
} }
@ -115,4 +132,13 @@ class RecipePlugin implements PluginInterface, EventSubscriberInterface, Capable
CommandProvider::class => RecipeCommandProvider::class CommandProvider::class => RecipeCommandProvider::class
]; ];
} }
public function deactivate(Composer $composer, IOInterface $io)
{
}
public function uninstall(Composer $composer, IOInterface $io)
{
}
} }