Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# QuickInstall Changelog

## Version 1.8.0-dev

- [Feature] Added a new local “Dashboard UI” (browser-based) workflow for the QuickInstall CLI.
- [Feature] Added new QI update available notifications in the CLI and Dashboard UI.
- [Feature] Added a new `doctor` command to diagnose host requirements.
- [Change] Improved workspace safety and Windows compatibility.

## Version 1.7.0

- [Feature] QuickInstall CLI – A brand new Docker-based command-line-interface tool for creating local phpBB test boards.
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ QuickInstall is a tool we built to support the community of phpBB extension deve
- 📁`sources/`
- 📁`settings/`

> If you are upgrading from QuickInstall 1.6.16 (or older) you will need to copy the new 📁`customisations/` folder.

> If you are upgrading from QuickInstall 1.1.8 (or older) you MUST review and re-save your old Profile settings.

## 💻 Requirements
Expand Down
3 changes: 3 additions & 0 deletions src/QuickInstall/Sandbox/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use RuntimeException;
use Throwable;

/** CLI command dispatcher and presentation layer. */
class Application
{
private Project $project;
Expand All @@ -40,6 +41,8 @@ public function run(array $argv): int
$operationLock = null;
try
{
// CLI and Dashboard mutations share one lock so their multi-file work
// cannot interleave. Read-only commands deliberately remain concurrent.
if ($this->mutatesWorkspace($command))
{
$operationLock = $this->project->lockOperations();
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/BoardRefreshService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace QuickInstall\Sandbox;

/** Regenerates runtime files and recreates a board only when it is running. */
class BoardRefreshService
{
private Project $project;
Expand Down
5 changes: 5 additions & 0 deletions src/QuickInstall/Sandbox/BoardRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use InvalidArgumentException;
use RuntimeException;

/** Executes Docker lifecycle, install, debug, and seed operations for a board. */
class BoardRunner
{
private Project $project;
Expand All @@ -26,6 +27,7 @@ public function __construct(Project $project, ?Output $output = null, ?ProcessRu
$this->processRunner = $processRunner ?: new ProcessRunner($this->output);
}

/** Starts containers, completes first install, applies options, and waits for HTTP. */
public function start(string $name): void
{
$board = $this->project->board($name);
Expand All @@ -51,6 +53,7 @@ public function stop(string $name): void
$this->run(['docker', 'compose', '-f', $this->project->composePath($name), 'stop']);
}

/** Removes Docker resources before deleting all persisted board state. */
public function destroy(string $name): void
{
$this->project->board($name);
Expand Down Expand Up @@ -83,6 +86,7 @@ private function removeContainers(string $name, bool $removeImage): void
}
}

/** Returns missing, stopped, partial, running, or error. */
public function status(string $name): string
{
$compose = $this->project->composePath($name);
Expand Down Expand Up @@ -159,6 +163,7 @@ protected function seedIfNeeded(string $name, string $preset): void
return;
}

// The host marker makes automatic population idempotent across restarts.
$marker = $this->seedMarker($name, $preset);
if (file_exists($marker))
{
Expand Down
8 changes: 8 additions & 0 deletions src/QuickInstall/Sandbox/BoardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use RuntimeException;
use Throwable;

/** Owns board definitions and coordinates source, runtime, and Docker services. */
class BoardService
{
private Project $project;
Expand All @@ -25,6 +26,10 @@ public function __construct(Project $project, ?Output $output = null)
$this->output = $output;
}

/**
* Creates a board definition and runtime scaffold without starting Docker.
* Existing state is restored if a replacement fails.
*/
public function create(string $name, string $version = 'latest', string $db = 'mariadb', int $port = 8080, string $populate = 'none', bool $debug = false, bool $replace = false): array
{
$this->project->init();
Expand Down Expand Up @@ -66,6 +71,8 @@ public function create(string $name, string $version = 'latest', string $db = 'm
throw new InvalidArgumentException("Port $port is already in use on this host.");
}

// Resolve and fetch first; a failed download must not disturb a board
// that is being replaced.
$source = $this->createSourceProvider()->ensure($version);
$php = $source['php'] ?? $selection['php'] ?? null;
if ($php === null || $php === '')
Expand All @@ -91,6 +98,7 @@ public function create(string $name, string $version = 'latest', string $db = 'm
$backups = [];
if ($existingName !== null)
{
// Rename existing state aside so any later failure can restore it.
$this->createBoardRunner()->prepareReplacement($name);
$backups = $this->backupBoardState($name);
}
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/BufferedOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace QuickInstall\Sandbox;

/** Captures subprocess output for Dashboard responses and tests. */
class BufferedOutput implements Output
{
private string $output = '';
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/CommandLine.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace QuickInstall\Sandbox;

/** Parses positional arguments, long options, and boolean flags. */
class CommandLine
{
private array $args = [];
Expand Down
2 changes: 2 additions & 0 deletions src/QuickInstall/Sandbox/CustomisationMountService.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use InvalidArgumentException;
use RuntimeException;

/** Coordinates single or recursive extension/style mounts and board refreshes. */
class CustomisationMountService
{
private Project $project;
Expand All @@ -24,6 +25,7 @@ public function __construct(Project $project, ?Output $output = null)
$this->output = $output;
}

/** Returns mounted items and per-item errors; recursive mounts are best effort. */
public function mount(object $manager, string $board, string $source, bool $copy = false, bool $recursive = false, bool $allowExternal = false): array
{
if ($recursive)
Expand Down
4 changes: 4 additions & 0 deletions src/QuickInstall/Sandbox/DockerComposeWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use RuntimeException;

/** Generates the complete Docker runtime scaffold for one board. */
class DockerComposeWriter
{
private Project $project;
Expand All @@ -21,6 +22,7 @@ public function __construct(Project $project)
$this->project = $project;
}

/** Writes compose, image, entrypoint, and installer files for one board. */
public function write(string $name, array $config): array
{
$runtimeDir = $this->project->workspacePath('runtime/' . $name);
Expand Down Expand Up @@ -53,6 +55,7 @@ public function write(string $name, array $config): array

private function writeFile(string $path, string $contents): void
{
// Containers execute these files as Linux files even when generated on Windows.
$contents = str_replace(["\r\n", "\r"], "\n", $contents);
if (file_put_contents($path, $contents, LOCK_EX) !== strlen($contents))
{
Expand Down Expand Up @@ -129,6 +132,7 @@ private function compose(string $name, array $config): string
PHP_VERSION: "{$config['php']}"
working_dir: /var/www/html
ports:
# Boards are intentionally unavailable to other network devices.
- "127.0.0.1:{$config['port']}:80"
volumes:
{$this->bindVolume($sourcePath, '/opt/phpbb-source', true)}{$this->bindVolume($boardPath, '/var/www/html')}{$extensionVolumes}{$styleVolumes}{$this->bindVolume('./install-config.yml', '/opt/quickinstall/install-config.yml', true)}{$this->bindVolume('./entrypoint.sh', '/opt/quickinstall/entrypoint.sh', true)}
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/DoctorService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace QuickInstall\Sandbox;

/** Reports host requirements without changing project or system state. */
class DoctorService
{
private Project $project;
Expand Down
3 changes: 3 additions & 0 deletions src/QuickInstall/Sandbox/ExtensionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use InvalidArgumentException;
use RuntimeException;

/** Discovers, copies, binds, lists, and removes phpBB extensions. */
class ExtensionManager
{
private Project $project;
Expand All @@ -22,6 +23,7 @@ public function __construct(Project $project)
$this->project = $project;
}

/** Mounts one extension; its canonical name comes from composer.json. */
public function mount(string $board, string $source, bool $copy = false, bool $allowExternal = false): array
{
$boardConfig = $this->project->board($board);
Expand Down Expand Up @@ -90,6 +92,7 @@ public function discover(string $source, bool $allowExternal = false): array
return $found;
}

/** Removes copied files or registry state for a bind mount. */
public function unmount(string $board, string $name): string
{
$boardConfig = $this->project->board($board);
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/Output.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace QuickInstall\Sandbox;

/** Minimal output boundary shared by CLI, Dashboard, and subprocess services. */
interface Output
{
public function write(string $message): void;
Expand Down
5 changes: 5 additions & 0 deletions src/QuickInstall/Sandbox/ProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use RuntimeException;

/** Runs portable subprocesses with streaming, timeouts, and safe diagnostics. */
class ProcessRunner
{
private Output $output;
Expand All @@ -25,6 +26,7 @@ public function __construct(?Output $output = null, ?string $osFamily = null, fl
$this->timeoutSeconds = $timeoutSeconds;
}

/** Streams a command and throws when it fails or times out. */
public function run(array $command, ?string $cwd = null): void
{
$displayCommand = array_map([$this, 'redactArgument'], $command);
Expand All @@ -37,6 +39,7 @@ public function run(array $command, ?string $cwd = null): void
}
}

/** Returns an exit code and combined output without throwing for command failure. */
public function capture(array $command, ?string $cwd = null): array
{
return $this->execute($command, false, $cwd);
Expand Down Expand Up @@ -134,6 +137,8 @@ private function execute(array $command, bool $stream, ?string $cwd): array

private function executeWithFiles(array $command, bool $stream, ?string $cwd): array
{
// Windows pipe reads can block after a child exits. Files make polling
// and timeout enforcement deterministic for long Composer/Docker jobs.
$stdoutPath = tempnam(sys_get_temp_dir(), 'qi-process-out-');
$stderrPath = tempnam(sys_get_temp_dir(), 'qi-process-err-');
if ($stdoutPath === false || $stderrPath === false)
Expand Down
13 changes: 13 additions & 0 deletions src/QuickInstall/Sandbox/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use JsonException;
use RuntimeException;

/** Guards all project paths, workspace state, locks, and filesystem operations. */
class Project
{
private string $root;
Expand Down Expand Up @@ -83,13 +84,16 @@ public function customisationsPath(): string
return $this->rootPath('customisations');
}

/** Acquires the process-wide workspace mutation lock. */
public function lockOperations()
{
if (!is_dir($this->workspace) && !mkdir($this->workspace, 0775, true) && !is_dir($this->workspace))
{
throw new RuntimeException("Unable to create QuickInstall workspace: {$this->workspace}");
}

// Keep the file after unlocking. Removing it can let contenders lock
// different filesystem entries and both enter the critical section.
$path = $this->workspacePath('operation.lock');
$lock = fopen($path, 'c+b');
if (!is_resource($lock) || !flock($lock, LOCK_EX))
Expand Down Expand Up @@ -125,6 +129,7 @@ public function sourcePath(string $version): string
return $this->workspacePath('sources/phpbb-' . $version);
}

/** Reads validated JSON while holding the registry's shared lock. */
public function readJson(string $file, array $default): array
{
$path = $this->workspacePath($file);
Expand Down Expand Up @@ -158,6 +163,7 @@ public function readJson(string $file, array $default): array
});
}

/** Atomically replaces JSON while holding the registry's exclusive lock. */
public function writeJson(string $file, array $data): void
{
$path = $this->workspacePath($file);
Expand All @@ -177,6 +183,7 @@ public function writeJson(string $file, array $data): void
throw new RuntimeException("Unable to create JSON state directory: $directory");
}

// Publish a complete temporary file so readers never see partial JSON.
$temp = tempnam($directory, '.' . basename($path) . '.tmp-');
if ($temp === false)
{
Expand Down Expand Up @@ -244,6 +251,8 @@ private function replaceFile(string $temp, string $path): void
throw new RuntimeException("Unable to replace JSON state file: $path");
}

// Windows cannot rename over an existing destination. Preserve the old
// state until the replacement has been installed successfully.
$backup = $path . '.replace-backup';
@unlink($backup);
if (!@rename($path, $backup))
Expand Down Expand Up @@ -317,12 +326,14 @@ public function dbPath(string $name): string
return $this->workspacePath('db/' . $name);
}

/** Recursively deletes only paths contained by the `.qi` workspace. */
public function deleteTree(string $path): void
{
if (!file_exists($path) && !is_link($path))
{
return;
}
// Every recursive delete passes through the workspace containment guard.
$this->assertWorkspacePath($path);

if (is_file($path) || is_link($path))
Expand Down Expand Up @@ -397,6 +408,7 @@ public function removeEmptyParents(string $path, string $stop): void
}
}

/** Resolves user paths and rejects escapes from the trusted drop zone by default. */
public function resolveDropZonePath(string $path, string $basePath, bool $allowExternal, string $error): string
{
$candidates = [
Expand All @@ -405,6 +417,7 @@ public function resolveDropZonePath(string $path, string $basePath, bool $allowE
$basePath . '/' . ltrim($path, '/'),
];

// realpath resolves traversal and symlinks before the containment check.
foreach ($candidates as $candidate)
{
$real = realpath($candidate);
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/SeederWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use RuntimeException;

/** Writes the phpBB-aware fixture script executed inside board containers. */
class SeederWriter
{
private Project $project;
Expand Down
6 changes: 6 additions & 0 deletions src/QuickInstall/Sandbox/SourceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use InvalidArgumentException;
use RuntimeException;

/** Resolves, downloads, validates, and registers Composer or Git phpBB sources. */
class SourceProvider
{
private Project $project;
Expand All @@ -26,6 +27,7 @@ public function __construct(Project $project, ?Output $output = null, ?ProcessRu
$this->processRunner = $processRunner ?: new ProcessRunner($this->output);
}

/** Registers source metadata; downloading is deferred unless resolution is floating. */
public function add(string $version, string $type, ?string $url, bool $allowExternal = false): array
{
if (!in_array($type, ['composer', 'git'], true))
Expand Down Expand Up @@ -89,6 +91,7 @@ private function validateGitUrl(string $url): void
}
}

/** Returns a downloaded exact source, fetching and updating metadata as needed. */
public function ensure(string $version): array
{
$sources = $this->project->readJson('sources.json', []);
Expand Down Expand Up @@ -152,6 +155,8 @@ protected function ensureRegisteredSource(array $source): array

protected function ensureFloating(string $version, array $selection): array
{
// Friendly selectors such as latest and 3.3.* resolve to an immutable
// version record so future resolution cannot silently alter a board.
$sources = $this->project->readJson('sources.json', []);
$source = $this->withSelectionDefaults($sources[$selection['source_key']] ?? [], $selection);
$source['type'] = $source['type'] ?? 'composer';
Expand Down Expand Up @@ -297,6 +302,7 @@ protected function withSelectionDefaults(array $source, array $selection): array
];
}

/** Downloads into a temporary sibling and publishes only a complete source tree. */
public function fetch(array $source): void
{
$path = $source['path'];
Expand Down
Loading