diff --git a/.easytree.json b/.easytree.json new file mode 100644 index 000000000..5e6f2c162 --- /dev/null +++ b/.easytree.json @@ -0,0 +1,6 @@ +{ + "scripts": [ + "composer install", + "cp $PROJECT_PATH/.env .env" + ] +} diff --git a/app/Actions/Worker/CreateWorker.php b/app/Actions/Worker/CreateWorker.php index 37c020dd4..c72202815 100644 --- a/app/Actions/Worker/CreateWorker.php +++ b/app/Actions/Worker/CreateWorker.php @@ -38,6 +38,7 @@ public function create(Server $server, array $input, ?Site $site = null): Worker 'auto_start' => $input['auto_start'] ? 1 : 0, 'auto_restart' => $input['auto_restart'] ? 1 : 0, 'numprocs' => $input['numprocs'], + 'environment' => $input['environment'] ?? null, 'status' => WorkerStatus::CREATING, ]); $worker->save(); @@ -56,7 +57,8 @@ public function create(Server $server, array $input, ?Site $site = null): Worker $worker->numprocs, $worker->getLogFile(), $worker->site?->path, - $worker->site_id + $worker->site_id, + $worker->environment, ); $worker->status = WorkerStatus::RUNNING; $worker->save(); diff --git a/app/Enums/NodePackageManager.php b/app/Enums/NodePackageManager.php new file mode 100644 index 000000000..ec7018ac3 --- /dev/null +++ b/app/Enums/NodePackageManager.php @@ -0,0 +1,56 @@ + 'npm', + self::Pnpm => 'pnpm', + self::Yarn => 'yarn', + }; + } + + public function installCommand(): string + { + return match ($this) { + self::Npm => 'npm install', + self::Pnpm => 'pnpm install', + self::Yarn => 'yarn install', + }; + } + + public function buildCommand(): string + { + return match ($this) { + self::Npm => 'npm run build', + self::Pnpm => 'pnpm run build', + self::Yarn => 'yarn build', + }; + } + + public function startCommand(): string + { + return match ($this) { + self::Npm => 'npm start', + self::Pnpm => 'pnpm start', + self::Yarn => 'yarn start', + }; + } +} diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 0b5e6386f..a4c28e2ad 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -18,6 +18,7 @@ * @property bool $auto_start * @property bool $auto_restart * @property int $numprocs + * @property ?array $environment * @property int $redirect_stderr * @property string $stdout_logfile * @property WorkerStatus $status @@ -38,6 +39,7 @@ class Worker extends AbstractModel 'auto_start', 'auto_restart', 'numprocs', + 'environment', 'redirect_stderr', 'stdout_logfile', 'status', @@ -50,6 +52,7 @@ class Worker extends AbstractModel 'auto_start' => 'boolean', 'auto_restart' => 'boolean', 'numprocs' => 'integer', + 'environment' => 'array', 'redirect_stderr' => 'boolean', 'status' => WorkerStatus::class, ]; diff --git a/app/Providers/ServiceTypeServiceProvider.php b/app/Providers/ServiceTypeServiceProvider.php index aaa4fd110..82024ed78 100644 --- a/app/Providers/ServiceTypeServiceProvider.php +++ b/app/Providers/ServiceTypeServiceProvider.php @@ -209,7 +209,7 @@ private function node(): void { RegisterServiceType::make(NodeJS::id()) ->type(NodeJS::type()) - ->label('Node.js') + ->label('Node.js (Deprecated)') ->handler(NodeJS::class) ->versions([ '22', diff --git a/app/Providers/SiteTypeServiceProvider.php b/app/Providers/SiteTypeServiceProvider.php index 695472a0d..3b32962cf 100644 --- a/app/Providers/SiteTypeServiceProvider.php +++ b/app/Providers/SiteTypeServiceProvider.php @@ -5,11 +5,13 @@ use App\DTOs\DynamicField; use App\DTOs\DynamicForm; use App\Enums\LoadBalancerMethod; +use App\Enums\NodePackageManager; use App\Plugins\RegisterSiteFeature; use App\Plugins\RegisterSiteFeatureAction; use App\Plugins\RegisterSiteType; use App\SiteTypes\Laravel; use App\SiteTypes\LoadBalancer; +use App\SiteTypes\MiseNodeJS; use App\SiteTypes\NodeJS; use App\SiteTypes\PHPBlank; use App\SiteTypes\PHPMyAdmin; @@ -27,6 +29,7 @@ public function boot(): void $this->phpBlank(); $this->laravel(); $this->nodeJS(); + $this->miseNodeJS(); $this->loadBalancer(); $this->phpMyAdmin(); $this->wordpress(); @@ -135,7 +138,7 @@ private function laravel(): void private function nodeJS(): void { RegisterSiteType::make(NodeJS::id()) - ->label('NodeJS with NPM') + ->label('NodeJS with NPM (Deprecated)') ->handler(NodeJS::class) ->form(DynamicForm::make([ DynamicField::make('source_control') @@ -159,6 +162,52 @@ private function nodeJS(): void ->register(); } + private function miseNodeJS(): void + { + RegisterSiteType::make(MiseNodeJS::id()) + ->label('Node.js') + ->handler(MiseNodeJS::class) + ->form(DynamicForm::make([ + DynamicField::make('node_version') + ->select() + ->label('Node.js Version') + ->options(MiseNodeJS::NODE_VERSIONS) + ->default('22'), + DynamicField::make('package_manager') + ->select() + ->label('Package Manager') + ->options(array_column(NodePackageManager::cases(), 'value')) + ->default(NodePackageManager::Npm->value), + DynamicField::make('source_control') + ->component() + ->label('Source Control'), + DynamicField::make('port') + ->text() + ->label('Port') + ->placeholder('3000') + ->description('On which port your app will be running'), + DynamicField::make('repository') + ->text() + ->label('Repository') + ->placeholder('organization/repository'), + DynamicField::make('branch') + ->text() + ->label('Branch') + ->default('main'), + DynamicField::make('build_command') + ->text() + ->label('Build Command') + ->placeholder('e.g., npm run build') + ->description('Command to build your application. Leave empty to use the build script of package.json'), + DynamicField::make('start_command') + ->text() + ->label('Start Command') + ->placeholder('e.g., npm start') + ->description('Command to start your application. Leave empty to use the start script of package.json'), + ])) + ->register(); + } + public function loadBalancer(): void { RegisterSiteType::make(LoadBalancer::id()) diff --git a/app/SSH/Mise/Mise.php b/app/SSH/Mise/Mise.php new file mode 100644 index 000000000..8d2f05c06 --- /dev/null +++ b/app/SSH/Mise/Mise.php @@ -0,0 +1,48 @@ +server->ssh()->exec( + view('ssh.mise.ensure-installed'), + 'ensure-mise-installed' + ); + } + + /** + * @throws SSHError + */ + public function isInstalled(): bool + { + $result = $this->server->ssh()->exec('which mise || echo "not-found"'); + + return ! str_contains($result, 'not-found'); + } + + /** + * @throws SSHError + */ + public function installRuntime(Site $site, string $runtime, string $version): void + { + $this->server->ssh($site->user)->exec( + view('ssh.mise.install-runtime', [ + 'runtime' => $runtime, + 'version' => $version, + ]), + 'mise-install-'.$runtime.'-'.$version, + $site->id + ); + } +} diff --git a/app/Services/ProcessManager/ProcessManager.php b/app/Services/ProcessManager/ProcessManager.php index 5e6e52ccb..32cce5108 100755 --- a/app/Services/ProcessManager/ProcessManager.php +++ b/app/Services/ProcessManager/ProcessManager.php @@ -6,6 +6,9 @@ interface ProcessManager extends ServiceInterface { + /** + * @param ?array $environment + */ public function create( int $id, string $command, @@ -16,6 +19,7 @@ public function create( string $logFile, ?string $directory = null, ?int $siteId = null, + ?array $environment = null, ): void; public function delete(int $id, ?int $siteId = null): void; diff --git a/app/Services/ProcessManager/Supervisor.php b/app/Services/ProcessManager/Supervisor.php index 9d781309d..d94a18a07 100644 --- a/app/Services/ProcessManager/Supervisor.php +++ b/app/Services/ProcessManager/Supervisor.php @@ -49,6 +49,8 @@ public function uninstall(): void } /** + * @param ?array $environment + * * @throws SSHError */ public function create( @@ -60,7 +62,8 @@ public function create( int $numprocs, string $logFile, ?string $directory = null, - ?int $siteId = null + ?int $siteId = null, + ?array $environment = null, ): void { $this->service->server->ssh()->write( "/etc/supervisor/conf.d/$id.conf", @@ -73,6 +76,7 @@ public function create( 'autoRestart' => var_export($autoRestart, true), 'numprocs' => (string) $numprocs, 'logFile' => $logFile, + 'environment' => $environment, ]), 'root' ); diff --git a/app/SiteTypes/MiseNodeJS.php b/app/SiteTypes/MiseNodeJS.php new file mode 100644 index 000000000..931a4e051 --- /dev/null +++ b/app/SiteTypes/MiseNodeJS.php @@ -0,0 +1,269 @@ +site->type_data['node_version'] ?? '22'; + } + + public static function make(): self + { + return new self(new Site(['type' => self::id()])); + } + + public function createRules(array $input): array + { + return [ + 'source_control' => [ + 'required', + Rule::exists('source_controls', 'id'), + ], + 'repository' => [ + 'required', + ], + 'branch' => [ + 'required', + ], + 'port' => [ + 'required', + 'numeric', + 'between:1,65535', + ], + 'node_version' => [ + 'required', + Rule::in(self::NODE_VERSIONS), + ], + 'package_manager' => [ + 'required', + Rule::in(array_column(NodePackageManager::cases(), 'value')), + ], + 'build_command' => [ + 'nullable', + 'string', + ], + 'start_command' => [ + 'nullable', + 'string', + ], + ]; + } + + public function createFields(array $input): array + { + return [ + 'source_control_id' => $input['source_control'] ?? '', + 'repository' => $input['repository'] ?? '', + 'branch' => $input['branch'] ?? '', + 'port' => $input['port'] ?? '', + ]; + } + + public function data(array $input): array + { + $packageManager = NodePackageManager::tryFrom($input['package_manager'] ?? '') ?? NodePackageManager::Npm; + + return [ + 'node_version' => $input['node_version'] ?? '22', + 'package_manager' => $packageManager->value, + 'build_command' => ! empty($input['build_command']) ? $input['build_command'] : $packageManager->buildCommand(), + 'start_command' => ! empty($input['start_command']) ? $input['start_command'] : $packageManager->startCommand(), + ]; + } + + protected function packageManager(): NodePackageManager + { + $value = $this->site->type_data['package_manager'] ?? NodePackageManager::Npm->value; + + return NodePackageManager::from($value); + } + + protected function buildCommand(): string + { + return $this->site->type_data['build_command'] ?? $this->packageManager()->buildCommand(); + } + + protected function startCommand(): string + { + return $this->site->type_data['start_command'] ?? $this->packageManager()->startCommand(); + } + + /** + * @throws FailedToDeployGitKey + * @throws SSHError + */ + public function install(): void + { + $this->isolate(); + $this->progress(10); + + $this->setupRuntime(); + $this->setupPackageManager(); + $this->progress(25); + + $this->site->webserver()->createVHost($this->site); + $this->progress(35); + + $this->deployKey(); + $this->progress(45); + + app(Git::class)->clone($this->site); + $this->progress(55); + + $this->runPackageManagerInstall(); + $this->progress(70); + + $this->runPackageManagerBuild(); + $this->progress(85); + + $this->createWorker(); + $this->progress(100); + } + + /** + * @throws SSHError + */ + protected function setupPackageManager(): void + { + $packageManager = $this->packageManager(); + + if ($packageManager === NodePackageManager::Npm) { + return; + } + + $this->site->server->ssh($this->site->user)->exec( + $this->wrapCommand('npm install -g '.$packageManager->value), + 'install-'.$packageManager->value, + $this->site->id + ); + } + + /** + * @throws SSHError + */ + protected function runPackageManagerInstall(): void + { + $packageManager = $this->packageManager(); + + $this->site->server->ssh($this->site->user)->exec( + $this->wrapCommand($packageManager->installCommand(), true), + $packageManager->value.'-install', + $this->site->id + ); + } + + /** + * @throws SSHError + */ + protected function runPackageManagerBuild(): void + { + $this->site->server->ssh($this->site->user)->exec( + $this->wrapCommand($this->buildCommand(), true), + 'build', + $this->site->id + ); + } + + protected function createWorker(): void + { + /** @var ?Worker $worker */ + $worker = $this->site->workers()->where('name', 'app')->first(); + if ($worker) { + app(ManageWorker::class)->restart($worker); + } else { + app(CreateWorker::class)->create( + $this->site->server, + [ + 'name' => 'app', + 'command' => $this->workerCommand(), + 'user' => $this->site->user ?? $this->site->server->getSshUser(), + 'auto_start' => true, + 'auto_restart' => true, + 'numprocs' => 1, + 'environment' => $this->workerEnvironment(), + ], + $this->site, + ); + } + } + + public function baseCommands(): array + { + return []; + } + + public function vhost(string $webserver): string|View + { + if ($webserver === 'nginx') { + return view('ssh.services.webserver.nginx.vhost', [ + 'header' => [ + view('ssh.services.webserver.nginx.vhost-blocks.force-ssl', ['site' => $this->site]), + ], + 'main' => [ + view('ssh.services.webserver.nginx.vhost-blocks.port', ['site' => $this->site]), + view('ssh.services.webserver.nginx.vhost-blocks.core', ['site' => $this->site]), + view('ssh.services.webserver.nginx.vhost-blocks.reverse-proxy', ['site' => $this->site]), + view('ssh.services.webserver.nginx.vhost-blocks.redirects', ['site' => $this->site]), + ], + ]); + } + + if ($webserver === 'caddy') { + return view('ssh.services.webserver.caddy.vhost', [ + 'site' => $this->site, + 'main' => [ + view('ssh.services.webserver.caddy.vhost-blocks.force-ssl', ['site' => $this->site]), + view('ssh.services.webserver.caddy.vhost-blocks.port', ['site' => $this->site]), + view('ssh.services.webserver.caddy.vhost-blocks.core', ['site' => $this->site]), + view('ssh.services.webserver.caddy.vhost-blocks.reverse-proxy', ['site' => $this->site]), + view('ssh.services.webserver.caddy.vhost-blocks.redirects', ['site' => $this->site]), + ], + ]); + } + + return ''; + } +} diff --git a/app/SiteTypes/MiseSiteType.php b/app/SiteTypes/MiseSiteType.php new file mode 100644 index 000000000..1388a45fb --- /dev/null +++ b/app/SiteTypes/MiseSiteType.php @@ -0,0 +1,67 @@ +site->server); + + $mise->ensureInstalled(); + + $mise->installRuntime( + $this->site, + $this->runtime(), + $this->runtimeVersion() + ); + } + + protected function miseShimsPath(): string + { + $user = $this->site->user ?? $this->site->server->getSshUser(); + + return '/home/'.$user.'/.local/share/mise/shims'; + } + + /** + * @return array + */ + protected function workerEnvironment(): array + { + return [ + 'PATH' => $this->shimPath(), + ]; + } + + protected function shimPath(): string + { + $user = $this->site->user ?? $this->site->server->getSshUser(); + + return $this->miseShimsPath().':/usr/local/bin:/usr/bin:/bin:/home/'.$user.'/.local/bin'; + } + + protected function workerCommand(): string + { + return $this->startCommand(); + } + + abstract protected function startCommand(): string; + + protected function wrapCommand(string $command, bool $cdToSitePath = false): string + { + $cdPath = $cdToSitePath && $this->site->path ? 'cd '.$this->site->path.' && ' : ''; + + return "bash -c \"export PATH={$this->shimPath()} && {$cdPath}{$command}\""; + } +} diff --git a/database/factories/WorkerFactory.php b/database/factories/WorkerFactory.php index 404c404fd..8b02332e1 100644 --- a/database/factories/WorkerFactory.php +++ b/database/factories/WorkerFactory.php @@ -22,9 +22,20 @@ public function definition(): array 'auto_start' => 1, 'auto_restart' => 1, 'numprocs' => 1, + 'environment' => null, 'redirect_stderr' => 1, 'stdout_logfile' => 'file.log', 'status' => WorkerStatus::CREATING, ]; } + + /** + * @param array $environment + */ + public function withEnvironment(array $environment): static + { + return $this->state(fn (array $attributes) => [ + 'environment' => $environment, + ]); + } } diff --git a/database/migrations/2025_12_31_234524_add_environment_to_workers_table.php b/database/migrations/2025_12_31_234524_add_environment_to_workers_table.php new file mode 100644 index 000000000..e12c9307e --- /dev/null +++ b/database/migrations/2025_12_31_234524_add_environment_to_workers_table.php @@ -0,0 +1,28 @@ +json('environment')->nullable()->after('numprocs'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workers', function (Blueprint $table) { + $table->dropColumn('environment'); + }); + } +}; diff --git a/resources/js/components/ui/password-input.tsx b/resources/js/components/ui/password-input.tsx index 745de5de7..36f8cf331 100644 --- a/resources/js/components/ui/password-input.tsx +++ b/resources/js/components/ui/password-input.tsx @@ -29,7 +29,7 @@ const PasswordInput = React.forwardRef(({ />