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>
294 lines
13 KiB
PHP
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);
|
|
}
|