mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
feat(docker): restore in-app self-update in the Compose deployment
Codeman running under docker/docker-compose.yaml lost the ability to update
itself from App Settings -> Updates. The image had no .git (excluded by
.dockerignore), so the install reported as "unknown"; there was no init system
for detectSupervisor() to find; the runtime stage had neither devDependencies
nor a build toolchain; and a pull into the baked /opt/codeman would have landed
in the container's writable layer and been discarded by the next `up`.
Restore it through configuration rather than a second updater, so the release
channel, auto-stash, status file and boot reconcile are all reused unchanged:
- The checkout Compose builds from is bind-mounted over /opt/codeman, so the
update's git checkout and rebuild land on the host and survive recreation.
- The restart is the server exiting; `restart: unless-stopped` relaunches the
container on the new dist/. This is the one supervisor whose updater does NOT
outlive the restart, which is safe only because the terminal "restarting"
marker is written first.
- node_modules and dist are named volumes over the bind mount, so
container-compiled native modules never enter the host checkout.
- The runtime image keeps devDependencies and gains python3/make/g++, since
`npm run build` is tsc + esbuild and node-pty has no Linux prebuild.
An in-place container update applies code only, because a restart reuses the
existing image and config. evaluateEnvironmentGate() reads the target release's
own files with `git show <tag>:<path>` and refuses when server.Dockerfile or
docker-compose.yaml changed, when .env.example gained keys the user's .env
lacks, or when the restart policy would not bring the container back. The
missing-key check matters most: Compose resolves an unset ${VAR} to the empty
string and starts anyway, so a new required setting would otherwise arrive as a
silently blank variable. Every unknown fails open, and the gate is re-evaluated
server-side on POST /api/system/update.
The four global agent CLIs are pinned, because an unpinned CLI bump is the one
environment change no diff-derived gate can see; pinning turns it into a
Dockerfile change the gate already detects.
Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every
compose ${VAR} has an .env.example entry and the reverse) and
test/docker-self-update.test.ts for the pure gate decisions.
Documented in docs/docker-self-update.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yAQ2y9t81jzSfpStUxx5T
This commit is contained in:
+54
-3
@@ -7,6 +7,10 @@
|
||||
* (see `dataPath('update-status.json')`) that the browser polls across the
|
||||
* restart boundary.
|
||||
*
|
||||
* The Docker Compose deployment updates in place too (same script, same status
|
||||
* file) — see `docs/docker-self-update.md` for how the container restarts itself
|
||||
* and what the environment gate refuses.
|
||||
*
|
||||
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
|
||||
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
|
||||
*
|
||||
@@ -17,11 +21,56 @@
|
||||
* Which init system supervises the running server (decides how we restart it).
|
||||
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
|
||||
* login): restart works by killing the server and letting launchd respawn it.
|
||||
* `docker-compose` = the Compose deployment (`docker/docker-compose.yaml`): the
|
||||
* "restart" is the server exiting so the container's `restart: unless-stopped`
|
||||
* policy relaunches it on the freshly built `dist/`.
|
||||
*/
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-compose' | 'none';
|
||||
|
||||
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
||||
/**
|
||||
* How Codeman was installed. `git` and `docker-compose` can self-update in
|
||||
* place; `docker-compose` is a git checkout bind-mounted into the container, so
|
||||
* the pull/build happen on the host filesystem and survive container recreation.
|
||||
*/
|
||||
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
|
||||
|
||||
/**
|
||||
* Why an in-place container update is refused. Each is derived mechanically from
|
||||
* the target release's own files — nothing here depends on a human remembering
|
||||
* to declare something at release time.
|
||||
*
|
||||
* - `dockerfile-changed` / `compose-changed`: the release changes the ENVIRONMENT,
|
||||
* which a self-restart cannot apply (a restart reuses the existing container's
|
||||
* image and config). Needs a rebuild + recreate from the host.
|
||||
* - `env-keys-missing`: the release's `docker/.env.example` gained keys the user's
|
||||
* `docker/.env` has no value for. Compose interpolates an unset `${VAR}` to the
|
||||
* EMPTY STRING and starts anyway, so without this check a new required setting
|
||||
* arrives as a silently blank env var.
|
||||
* - `no-auto-restart`: the container's restart policy would not bring it back
|
||||
* after the server exits, so applying the update would take Codeman down.
|
||||
*/
|
||||
export type EnvironmentBlockerKind = 'dockerfile-changed' | 'compose-changed' | 'env-keys-missing' | 'no-auto-restart';
|
||||
|
||||
/** One reason an in-place container update is refused, with UI-ready text. */
|
||||
export interface EnvironmentBlocker {
|
||||
kind: EnvironmentBlockerKind;
|
||||
/** One-line explanation shown in App Settings → Updates. */
|
||||
message: string;
|
||||
/** Optional specifics (e.g. the names of the missing env keys). */
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the environment gate for a candidate release. `checked: false` means
|
||||
* the gate did not run (not a container install, or the target tag's files could
|
||||
* not be read) — callers must not treat that as "no blockers".
|
||||
*/
|
||||
export interface EnvironmentGate {
|
||||
checked: boolean;
|
||||
blockers: EnvironmentBlocker[];
|
||||
/** The host command that resolves every blocker. */
|
||||
hostCommand: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle of a single update run. `idle`/`completed`/`failed`/
|
||||
@@ -96,6 +145,8 @@ export interface UpdateCheckResult {
|
||||
/** epoch ms of the check. */
|
||||
checkedAt: number;
|
||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||
/** Environment gate for THIS candidate release (container installs only). */
|
||||
environment?: EnvironmentGate;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,10 +1079,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const verEl = this.$('updateCurrentVersion');
|
||||
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
|
||||
|
||||
if (data.installKind && data.installKind !== 'git') {
|
||||
this._setUpdateResult(
|
||||
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
|
||||
);
|
||||
// `docker-compose` self-updates in place like `git` does — the container
|
||||
// restarts itself. Anything else cannot.
|
||||
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
|
||||
const hint =
|
||||
data.supervisor === 'docker-compose'
|
||||
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
|
||||
: 'Update with <code>npm i -g aicodeman@latest</code>.';
|
||||
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
|
||||
return;
|
||||
}
|
||||
if (data.selfUpdateEnabled === false) {
|
||||
@@ -1093,6 +1097,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setUpdateResult(escapeHtml(data.error));
|
||||
return;
|
||||
}
|
||||
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
|
||||
// or new .env keys) cannot be applied by the container restarting itself, so
|
||||
// the update button is never offered — the host command is, instead. The
|
||||
// server re-checks this on POST, so hiding the button is UX, not the gate.
|
||||
const blockers = data.environment?.blockers || [];
|
||||
if (data.updateAvailable && blockers.length > 0) {
|
||||
const reasons = blockers
|
||||
.map((b) => {
|
||||
const details = b.details?.length ? `<br><code>${escapeHtml(b.details.join(' '))}</code>` : '';
|
||||
return `<li>${escapeHtml(b.message)}${details}</li>`;
|
||||
})
|
||||
.join('');
|
||||
this._setUpdateResult(
|
||||
`<strong>v${escapeHtml(data.latestVersion || '')}</strong> needs a rebuild on the Docker host` +
|
||||
` (current v${escapeHtml(data.currentVersion || '')}):<ul>${reasons}</ul>` +
|
||||
`Run <code>${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')}</code> there to apply it.`
|
||||
);
|
||||
if (notes && data.notes) {
|
||||
notes.style.display = 'block';
|
||||
notes.textContent = data.notes;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.updateAvailable && data.latestVersion) {
|
||||
this._setUpdateResult(
|
||||
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> (current v${escapeHtml(data.currentVersion || '')})`
|
||||
|
||||
@@ -389,6 +389,9 @@ export function registerSystemRoutes(
|
||||
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
// A container release that changes the ENVIRONMENT: not a client error to
|
||||
// retry, it needs a host-side rebuild (docs/docker-self-update.md).
|
||||
'env-blocked': { http: 409, api: ApiErrorCode.INVALID_INPUT },
|
||||
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
|
||||
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
|
||||
|
||||
+299
-12
@@ -16,6 +16,15 @@
|
||||
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
|
||||
* `reconcileUpdateOnBoot`) that touch git/network/fs.
|
||||
*
|
||||
* DOCKER COMPOSE installs update in place too, through the same script and the
|
||||
* same status file. The repo is a host bind mount, so the pull/build land on the
|
||||
* host filesystem and survive container recreation; the "restart" is the server
|
||||
* EXITING so the container's restart policy relaunches it on the new `dist/`.
|
||||
* That applies CODE only — a restart reuses the existing container's image and
|
||||
* config — so `evaluateEnvironmentGate()` refuses a release that changes
|
||||
* `server.Dockerfile`, `docker-compose.yaml` or `.env.example`, pointing at the
|
||||
* host command instead. See `docs/docker-self-update.md`.
|
||||
*
|
||||
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
|
||||
* `src/web/routes/system-routes.ts`.
|
||||
*
|
||||
@@ -26,13 +35,15 @@ import { spawn, execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir, hostname, tmpdir } from 'node:os';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
EnvironmentBlocker,
|
||||
EnvironmentGate,
|
||||
InstallInfo,
|
||||
InstallKind,
|
||||
SupervisorKind,
|
||||
@@ -215,6 +226,120 @@ export function reconcileStatusDecision(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers — the container environment gate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Host command that resolves every environment blocker. */
|
||||
export const DOCKER_HOST_UPDATE_COMMAND = 'docker/Start-Codeman.sh';
|
||||
|
||||
/**
|
||||
* Parse the SET keys out of a dotenv file. Commented-out lines are deliberately
|
||||
* NOT keys: `docker/.env.example` uses `# PUID=1000` to document an OPTIONAL
|
||||
* override, so treating those as required would block every update on settings
|
||||
* the user is meant to leave alone.
|
||||
*/
|
||||
export function parseEnvKeys(text: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.replace(/^export\s+/, '').match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||
if (m && !keys.includes(m[1])) keys.push(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the TARGET release's `.env.example` sets that the user's `.env` does not.
|
||||
*
|
||||
* This is the check that makes a new required setting visible: Compose resolves
|
||||
* an unset `${VAR}` to the empty string and starts anyway, so a missing key is
|
||||
* otherwise silent until something misbehaves at runtime.
|
||||
*/
|
||||
export function diffRequiredEnvKeys(targetExample: string, userEnv: string): string[] {
|
||||
const have = new Set(parseEnvKeys(userEnv));
|
||||
return parseEnvKeys(targetExample).filter((k) => !have.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the container's restart policy relaunches it after the server exits.
|
||||
* `no` and an empty policy mean an in-place update would take Codeman DOWN
|
||||
* rather than restart it, so the update is refused instead.
|
||||
*/
|
||||
export function isAutoRestartPolicy(name: string | null | undefined): boolean {
|
||||
return name === 'always' || name === 'unless-stopped' || name === 'on-failure';
|
||||
}
|
||||
|
||||
export interface EnvironmentGateInput {
|
||||
/** sha256 of `docker/server.Dockerfile` the running container was built from. */
|
||||
appliedDockerfileHash: string | null;
|
||||
/** sha256 of `docker/server.Dockerfile` at the target release tag. */
|
||||
targetDockerfileHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` the running container was created from. */
|
||||
appliedComposeHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` at the target release tag. */
|
||||
targetComposeHash: string | null;
|
||||
/** Keys from `diffRequiredEnvKeys()`. */
|
||||
missingEnvKeys: string[];
|
||||
/** Docker restart policy name of the running container, or null if unknown. */
|
||||
restartPolicy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE gate decision. An in-place container update applies CODE only: the server
|
||||
* exits and the container's restart policy relaunches it on the new `dist/`. A
|
||||
* restart reuses the existing container's image and config, so anything that
|
||||
* changes the ENVIRONMENT cannot take effect that way and is refused here with
|
||||
* the host command that can apply it.
|
||||
*
|
||||
* ⚠️ An unknown hash (null) is NOT treated as "changed": a first update from a
|
||||
* container created before the fingerprint file existed has no baseline, and
|
||||
* failing closed there would block every such install from ever updating. The
|
||||
* baseline is written by `Start-Codeman.sh`, so it exists from the first
|
||||
* host-side start onward. An unknown restart policy is likewise not a blocker —
|
||||
* the shipped Compose file sets `unless-stopped`, and the probe needs the Docker
|
||||
* socket, which a user may not have mounted.
|
||||
*/
|
||||
export function computeEnvironmentBlockers(input: EnvironmentGateInput): EnvironmentBlocker[] {
|
||||
const blockers: EnvironmentBlocker[] = [];
|
||||
|
||||
if (
|
||||
input.appliedDockerfileHash &&
|
||||
input.targetDockerfileHash &&
|
||||
input.appliedDockerfileHash !== input.targetDockerfileHash
|
||||
) {
|
||||
blockers.push({
|
||||
kind: 'dockerfile-changed',
|
||||
message: 'This release changes docker/server.Dockerfile, so the image must be rebuilt.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.appliedComposeHash && input.targetComposeHash && input.appliedComposeHash !== input.targetComposeHash) {
|
||||
blockers.push({
|
||||
kind: 'compose-changed',
|
||||
message: 'This release changes docker/docker-compose.yaml, so the container must be recreated.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.missingEnvKeys.length > 0) {
|
||||
blockers.push({
|
||||
kind: 'env-keys-missing',
|
||||
message: `This release adds ${input.missingEnvKeys.length} setting(s) your docker/.env has no value for.`,
|
||||
details: input.missingEnvKeys,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.restartPolicy !== null && !isAutoRestartPolicy(input.restartPolicy)) {
|
||||
blockers.push({
|
||||
kind: 'no-auto-restart',
|
||||
message: `This container's restart policy is "${input.restartPolicy}", so it would not come back after the update.`,
|
||||
});
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status file IO
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -273,19 +398,149 @@ export function resolveInstallDir(): string {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this process runs inside a container. `/.dockerenv` is created by the
|
||||
* Docker daemon itself; the env var is set by our own Compose file so the check
|
||||
* also holds under runtimes that omit that file.
|
||||
*/
|
||||
export function isRunningInContainer(): boolean {
|
||||
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
|
||||
}
|
||||
|
||||
function detectInstallKind(dir: string): InstallKind {
|
||||
if (existsSync(join(dir, '.git'))) return 'git';
|
||||
// A container whose code is a bind-mounted checkout updates in place (the pull
|
||||
// and build land on the host filesystem and survive container recreation). A
|
||||
// container WITHOUT that mount runs a baked image copy — a pull there would go
|
||||
// to the writable layer and vanish on the next `up`, so it is not updatable.
|
||||
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
|
||||
// Global npm install ships only dist/ (no src/, no .git).
|
||||
if (!existsSync(join(dir, 'src'))) return 'npm';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Install kinds whose update is applied in place by `scripts/self-update.sh`. */
|
||||
export function canSelfUpdateInPlace(kind: InstallKind): boolean {
|
||||
return kind === 'git' || kind === 'docker-compose';
|
||||
}
|
||||
|
||||
/** Path of the fingerprint baseline written by `docker/Start-Codeman.sh`. */
|
||||
const DOCKER_ENV_APPLIED_FILE = dataPath('docker-env-applied.json');
|
||||
|
||||
/** Files whose content defines the container ENVIRONMENT (vs. the app's code). */
|
||||
const DOCKERFILE_REL = 'docker/server.Dockerfile';
|
||||
const COMPOSE_REL = 'docker/docker-compose.yaml';
|
||||
const ENV_EXAMPLE_REL = 'docker/.env.example';
|
||||
const ENV_REL = 'docker/.env';
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash('sha256').update(text, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
/** Read a file at a git TAG without checking it out (`git show tag:path`). */
|
||||
function gitShowAtTag(repo: string, tag: string, relPath: string): string | null {
|
||||
return tryExec('git', ['show', `${tag}:${relPath}`], repo);
|
||||
}
|
||||
|
||||
function readFileOrNull(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprints the RUNNING container was created from, recorded on the host
|
||||
* by `Start-Codeman.sh` at each build/recreate. Returns nulls when absent (a
|
||||
* container started before this file existed) — `computeEnvironmentBlockers()`
|
||||
* deliberately treats an unknown baseline as "not a blocker".
|
||||
*/
|
||||
function readAppliedEnvironmentFingerprints(): { dockerfile: string | null; compose: string | null } {
|
||||
const raw = readFileOrNull(DOCKER_ENV_APPLIED_FILE);
|
||||
if (!raw) return { dockerfile: null, compose: null };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { dockerfileSha256?: string; composeSha256?: string };
|
||||
return { dockerfile: parsed.dockerfileSha256 ?? null, compose: parsed.composeSha256 ?? null };
|
||||
} catch {
|
||||
return { dockerfile: null, compose: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart policy of the container we're running in, via the mounted Docker
|
||||
* socket. Returns null when the socket or CLI is unavailable — an unknown policy
|
||||
* is not a blocker (see `computeEnvironmentBlockers`).
|
||||
*/
|
||||
function detectOwnRestartPolicy(): string | null {
|
||||
// Docker sets HOSTNAME to the short container id; os.hostname() is the same
|
||||
// value when the env var is absent. A custom `hostname:` in the compose file
|
||||
// makes both unresolvable to the daemon, which fails open (unknown is not a
|
||||
// blocker) rather than refusing an update over a cosmetic setting.
|
||||
const id = process.env.HOSTNAME || hostname();
|
||||
if (!id) return null;
|
||||
const out = tryExec('docker', ['inspect', '--format', '{{.HostConfig.RestartPolicy.Name}}', id]);
|
||||
return out && out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the environment gate for a candidate release tag. Reads the TARGET
|
||||
* tag's files straight out of git (`git show`), so nothing is checked out and the
|
||||
* answer is available at CHECK time — the UI can refuse before the user commits
|
||||
* to an update.
|
||||
*/
|
||||
export function evaluateEnvironmentGate(installDir: string, tag: string): EnvironmentGate {
|
||||
// `git show <tag>:<path>` needs the tag's objects locally, and neither the
|
||||
// GitHub API nor `ls-remote` fetches anything — so a check that has never seen
|
||||
// this tag would read nothing and report a falsely clean gate. Fetch the one
|
||||
// ref first (cheap: it deltas against what the clone already has) and only
|
||||
// then read. The updater fetches the same ref again; both are idempotent.
|
||||
if (tryExec('git', ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`], installDir) === null) {
|
||||
tryExec(
|
||||
'git',
|
||||
['fetch', '--tags', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`],
|
||||
installDir,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
const targetDockerfile = gitShowAtTag(installDir, tag, DOCKERFILE_REL);
|
||||
const targetCompose = gitShowAtTag(installDir, tag, COMPOSE_REL);
|
||||
const targetExample = gitShowAtTag(installDir, tag, ENV_EXAMPLE_REL);
|
||||
|
||||
// No environment files at the target tag at all: we cannot judge, so say so
|
||||
// rather than reporting a clean gate the caller would trust.
|
||||
if (targetDockerfile === null && targetCompose === null && targetExample === null) {
|
||||
return { checked: false, blockers: [], hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
const applied = readAppliedEnvironmentFingerprints();
|
||||
const userEnv = readFileOrNull(join(installDir, ENV_REL));
|
||||
|
||||
const blockers = computeEnvironmentBlockers({
|
||||
appliedDockerfileHash: applied.dockerfile,
|
||||
targetDockerfileHash: targetDockerfile === null ? null : sha256(targetDockerfile),
|
||||
appliedComposeHash: applied.compose,
|
||||
targetComposeHash: targetCompose === null ? null : sha256(targetCompose),
|
||||
// A missing/unreadable .env cannot be diffed — report no missing keys rather
|
||||
// than every key, which would block on an install using a non-standard path.
|
||||
missingEnvKeys: targetExample !== null && userEnv !== null ? diffRequiredEnvKeys(targetExample, userEnv) : [],
|
||||
restartPolicy: detectOwnRestartPolicy(),
|
||||
});
|
||||
|
||||
return { checked: true, blockers, hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which init system supervises us. Detection happens HERE (in the running
|
||||
* server, which has a rich env) and the result is passed to the updater script —
|
||||
* the detached child must not re-probe with a stripped-down environment.
|
||||
*/
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
// Checked FIRST: a container has no init system of its own, and its "restart"
|
||||
// is the server exiting so the Docker restart policy relaunches it. Probing
|
||||
// systemd here would find nothing and report `none`, which stages the update
|
||||
// and then asks the user to restart by hand for no reason.
|
||||
if (isRunningInContainer()) return 'docker-compose';
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
|
||||
@@ -389,17 +644,29 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
checkedAt,
|
||||
source: 'none',
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — self-update is unavailable.' };
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
...base,
|
||||
error:
|
||||
info.installKind === 'unknown' && isRunningInContainer()
|
||||
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
|
||||
: 'Not a git install — self-update is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach the container environment gate to a finished check result. */
|
||||
const withGate = (result: UpdateCheckResult): UpdateCheckResult => {
|
||||
if (info.installKind !== 'docker-compose' || !result.latestTag || !result.updateAvailable) return result;
|
||||
return { ...result, environment: evaluateEnvironmentGate(info.installDir, result.latestTag) };
|
||||
};
|
||||
|
||||
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
|
||||
const gh = remote ? parseGitHubRepo(remote) : null;
|
||||
|
||||
if (gh) {
|
||||
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
|
||||
if (rel) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: rel.version,
|
||||
latestTag: rel.tag,
|
||||
@@ -407,20 +674,20 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
htmlUrl: rel.htmlUrl,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
|
||||
source: 'github-api',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
|
||||
const viaGit = fetchLatestTagViaGit(info.installDir);
|
||||
if (viaGit) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: viaGit.version,
|
||||
latestTag: viaGit.tag,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
|
||||
source: 'git-ls-remote',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
|
||||
@@ -432,7 +699,11 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
|
||||
export type StartUpdateResult =
|
||||
| { ok: true; updateId: string; toTag: string; toVersion: string | null }
|
||||
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string };
|
||||
| {
|
||||
ok: false;
|
||||
code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'env-blocked' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the updater script OUT of the repo before running it. The script lives in
|
||||
@@ -497,11 +768,13 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
if (!info.selfUpdateEnabled) {
|
||||
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
|
||||
}
|
||||
if (info.installKind !== 'git') {
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'not-git',
|
||||
message: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
message: isRunningInContainer()
|
||||
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
@@ -517,6 +790,20 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
|
||||
}
|
||||
|
||||
// Re-evaluate rather than trusting the check the browser saw: the UI hides the
|
||||
// button when the gate blocks, but the endpoint is reachable directly and the
|
||||
// release could have moved between the check and the click.
|
||||
if (info.installKind === 'docker-compose') {
|
||||
const gate = evaluateEnvironmentGate(info.installDir, check.latestTag);
|
||||
if (gate.blockers.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'env-blocked',
|
||||
message: `${gate.blockers.map((b) => b.message).join(' ')} Run ${gate.hostCommand} on the Docker host to apply this release.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
|
||||
const runner = stageRunner(info.installDir);
|
||||
if (!runner) {
|
||||
|
||||
Reference in New Issue
Block a user