bool, 'how'=>str, 'version'=>?str]. Short timeout — this // runs on page load to populate the Dependências card header. $res = composer_run(['--version'], $repo_root, 10); // First line only: composer may prepend PHP deprecation noise on stderr. $version = $res['ok'] ? trim(strtok($res['output'], "\n") ?: '') : null; return [ 'ok' => $res['ok'], 'how' => is_file($repo_root . '/composer.phar') ? 'composer.phar (local)' : 'composer (PATH)', 'version' => $version, ]; } /** * Run composer with $args in $repo_root. Captures stdout+stderr together. * @return array{ok:bool,code:int,output:string} */ function composer_run(array $args, string $repo_root, int $timeout = 600): array { // Fail soft on hosts with the proc_* family in disable_functions — // calling proc_open() there is a FATAL "undefined function" error. if (!platform_proc_open_available()) { return ['ok' => false, 'code' => -1, 'output' => 'proc_open() está desativado no PHP deste servidor (disable_functions) — o composer via web não está disponível.']; } @set_time_limit(0); $php = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php'; $cmd = is_file($repo_root . '/composer.phar') ? [$php, $repo_root . '/composer.phar'] : ['composer']; $cmd = array_merge($cmd, $args, ['--no-interaction']); // Writable HOME/cache for composer (PHP-FPM user usually lacks one). $home = sys_get_temp_dir() . '/platform-tools-composer'; @mkdir($home, 0775, true); $env = getenv() ?: []; $env['COMPOSER_HOME'] = $home; $env['COMPOSER_CACHE_DIR'] = $home . '/cache'; $env['COMPOSER_NO_INTERACTION'] = '1'; if (empty($env['HOME'])) $env['HOME'] = $home; // Some composer plugins want a PATH; keep the inherited one but ensure php's dir. $phpDir = dirname($php); if ($phpDir && $phpDir !== '.' && !empty($env['PATH']) && strpos($env['PATH'], $phpDir) === false) { $env['PATH'] = $phpDir . PATH_SEPARATOR . $env['PATH']; } // stdout/stderr vão para ficheiros temporários e o processo é vigiado num // poll com deadline: um composer que encrave (rede, prompt inesperado, // lock de cache) não pode prender o request web indefinidamente. Pipes + // stream_select seriam a alternativa, mas não funcionam de forma fiável // em todas as plataformas — ficheiros funcionam em qualquer lado. $outFile = tempnam(sys_get_temp_dir(), 'ptcomp_'); $errFile = tempnam(sys_get_temp_dir(), 'ptcomp_'); if ($outFile === false || $errFile === false) { return ['ok' => false, 'code' => -1, 'output' => 'Sem espaço/permissões no diretório temporário.']; } $descs = [1 => ['file', $outFile, 'w'], 2 => ['file', $errFile, 'w']]; $proc = @proc_open($cmd, $descs, $pipes, $repo_root, $env); if (!is_resource($proc)) { @unlink($outFile); @unlink($errFile); return [ 'ok' => false, 'code' => -1, 'output' => 'Não foi possível invocar o composer. Confirma que existe um `composer.phar` ' . 'na raiz do projeto OU um `composer` no PATH do utilizador do servidor web.', ]; } $deadline = microtime(true) + max(1, $timeout); $timed_out = false; $code = -1; while (true) { $status = proc_get_status($proc); if (!$status['running']) { $code = $status['exitcode']; break; } if (microtime(true) > $deadline) { $timed_out = true; @proc_terminate($proc, 9); break; } usleep(200000); } proc_close($proc); $out = (string) @file_get_contents($outFile); $err = (string) @file_get_contents($errFile); @unlink($outFile); @unlink($errFile); $output = trim($out); if (trim($err) !== '') { // Composer writes progress to stderr — include it. $output .= ($output !== '' ? "\n" : '') . trim($err); } if ($timed_out) { $output .= ($output !== '' ? "\n\n" : '') . "(interrompido: excedeu o limite de {$timeout}s)"; return ['ok' => false, 'code' => -1, 'output' => $output]; } return ['ok' => $code === 0, 'code' => $code, 'output' => $output !== '' ? $output : '(sem saída)']; } // Snapshot for the UI card: is there a composer.json? a vendor/autoload? which // packages are declared? function composer_state(string $repo_root): array { $json_path = $repo_root . '/composer.json'; $has_json = is_file($json_path); $declared = []; if ($has_json) { $data = json_decode((string) @file_get_contents($json_path), true); if (is_array($data['require'] ?? null)) $declared = $data['require']; } return [ 'has_json' => $has_json, 'has_lock' => is_file($repo_root . '/composer.lock'), 'has_autoload' => is_file($repo_root . '/vendor/autoload.php'), 'declared' => $declared, ]; } /** * Packages composer would actually move, per `composer update --dry-run`. * * This is the ONLY reliable signal for "can this be updated in place". * `composer outdated` reports latest-status=semver-safe-update whenever a newer * release is semver-compatible with what is INSTALLED -- it says nothing about * whether the declared constraint permits it. A package pinned to an exact * version (twbs/bootstrap: "5.3.3") is reported semver-safe while * `composer update twbs/bootstrap` answers "Nothing to modify in lock file". * * Acting on latest-status would give the operator a button that runs, changes * nothing, and reports success. Parsing the dry run costs one extra call and * tells the truth. * * Returns ['name' => ['from' => x, 'to' => y]]. */ function composer_updatable(string $repo_root, int $timeout = 180): array { $res = composer_run(['update', '--dry-run', '--no-dev'], $repo_root, $timeout); if (!$res['ok']) return []; $moves = []; // " - Upgrading vendor/pkg (v1.0.0 => v1.2.0)" if (preg_match_all('/-\s+(?:Upgrading|Downgrading|Updating)\s+(\S+)\s+\(([^ )]+)\s*=>\s*([^)]+)\)/i', (string) $res['output'], $m, PREG_SET_ORDER)) { foreach ($m as $x) { $moves[$x[1]] = ['from' => trim($x[2]), 'to' => trim($x[3])]; } } return $moves; } /** * Packages with a newer release available. * * Runs `composer outdated --format=json`. That is a NETWORK call, so it is * never run on page load -- the UI triggers it explicitly. * * $directOnly limits the list to packages named in composer.json's require, * which is what a human can actually act on; transitive dependencies move when * their parent does. * * Each row: name, version (installed), latest, latest-status, description, * plus a derived 'semver_safe' flag: * true -> latest satisfies the declared constraint, so `composer update ` * will take it with no composer.json edit. * false -> a major bump; composer will refuse until the constraint is widened. * That distinction is the whole point of a per-package button: one is routine, * the other is a decision. */ function composer_outdated(string $repo_root, bool $directOnly = true, int $timeout = 180): array { $args = ['outdated', '--format=json', '--no-ansi']; if ($directOnly) $args[] = '--direct'; $res = composer_run($args, $repo_root, $timeout); if (!$res['ok']) { return ['ok' => false, 'error' => $res['output'] ?: 'composer outdated falhou.', 'packages' => []]; } // composer may prepend warnings on stdout before the JSON body, and on a // project whose vendor/ was not built by composer it emits a bare "[]" // followed by "No dependencies installed" -- which is valid JSON, just not // an object. Look for either opener. $out = (string) $res['output']; $sObj = strpos($out, '{'); $sArr = strpos($out, '['); $start = ($sObj === false) ? $sArr : (($sArr === false) ? $sObj : min($sObj, $sArr)); $body = $start === false ? '' : substr($out, $start); $json = $body === '' ? null : json_decode($body, true); // composer also appends human-readable notices AFTER the JSON body // ("[]\nNo dependencies installed."), so decoding to end-of-output fails. // Retry against successively shorter line-prefixes until one parses. if (!is_array($json) && $body !== '') { $lines = explode("\n", $body); for ($i = count($lines) - 1; $i > 0; $i--) { $try = json_decode(implode("\n", array_slice($lines, 0, $i)), true); if (is_array($try)) { $json = $try; break; } } } if (!is_array($json)) { return ['ok' => false, 'error' => 'Resposta do composer não era JSON: ' . substr($out, 0, 300), 'packages' => []]; } // Bare array, or an object with no 'installed' key: composer found nothing // it manages. On a host that vendors dependencies by hand this is the // expected answer, not a failure -- say so rather than showing a parse error. if (!isset($json['installed'])) { $hint = (stripos($out, 'No dependencies installed') !== false) ? 'O composer não gere nenhuma dependência aqui — o vendor/ não foi construído por ele. Corre "install" primeiro, ou este projeto vendoriza à mão.' : 'O composer não devolveu pacotes instalados.'; return ['ok' => true, 'error' => null, 'packages' => [], 'note' => $hint]; } $declared = composer_state($repo_root)['declared']; // Authoritative "would this actually move" set -- see composer_updatable(). $updatable = composer_updatable($repo_root, $timeout); $rows = []; foreach (($json['installed'] ?? []) as $pkg) { $name = (string) ($pkg['name'] ?? ''); if ($name === '') continue; $status = (string) ($pkg['latest-status'] ?? ''); $rows[] = [ 'name' => $name, 'version' => (string) ($pkg['version'] ?? '?'), 'latest' => (string) ($pkg['latest'] ?? '?'), 'status' => $status, 'constraint' => (string) ($declared[$name] ?? ''), 'description' => (string) ($pkg['description'] ?? ''), // NOT derived from latest-status: that flag only says the newer // release is semver-compatible with what is installed, not that the // declared constraint admits it. An exact pin reports semver-safe // and then refuses to move. 'updatable' => isset($updatable[$name]), 'would_move' => $updatable[$name] ?? null, ]; } usort($rows, fn($a, $b) => ($b['updatable'] <=> $a['updatable']) ?: strcmp($a['name'], $b['name'])); return ['ok' => true, 'error' => null, 'packages' => $rows]; } /** * Every package name composer currently knows about, from composer.lock. * * Used to validate names arriving from a POST before they reach a shell * argument list. Never pass a request-supplied string to composer_run() * without checking it against this. */ function composer_known_packages(string $repo_root): array { $lock = $repo_root . '/composer.lock'; if (!is_file($lock)) return []; $data = json_decode((string) @file_get_contents($lock), true); if (!is_array($data)) return []; $names = []; foreach (array_merge($data['packages'] ?? [], $data['packages-dev'] ?? []) as $p) { if (!empty($p['name'])) $names[] = (string) $p['name']; } return $names; } /** * Update specific packages, or all of them when $names is empty. * * --with-dependencies is required: updating a package whose new release needs a * newer transitive dependency fails without it, with a resolver error that * reads like the package itself is unavailable. */ function composer_update_packages(array $names, string $repo_root, int $timeout = 600): array { $args = ['update', '--no-dev', '--optimize-autoloader']; if ($names) { $args = array_merge($args, ['--with-dependencies'], array_values($names)); } return composer_run($args, $repo_root, $timeout); }