Files
fitme3070.pt/shared/platform-tools/functions_composer.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

294 lines
13 KiB
PHP

<?php
// ─── Composer runner (shared) ────────────────────────────────────────────────
//
// Lets the sysadmin "Atualizar plataforma" page run `composer install`/`update`
// from the browser (same idea as git_run): no SSH needed to (re)build vendor/.
//
// Invocation strategy, in order:
// 1. a project-local `composer.phar` in the repo root → `php composer.phar`
// 2. a global `composer` on the web user's PATH
// Composer needs a writable HOME for its cache/config; PHP-FPM's user often has
// none, so we point COMPOSER_HOME at a temp dir for the duration of the call.
require_once __DIR__ . '/functions_capabilities.php';
function composer_available(string $repo_root): array {
// Returns ['ok'=>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 <pkg>`
* 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);
}