* Pull updates from evOo instead of the upstream Rakazo repository The in-app server updater, the image-based compose files and the desktop auto-updater still pointed at elie222/rakazo, so updating an evOo install would have pulled Rakazo. They now follow cynarAI/evOo-local, where the publish workflow already pushes the images. The disabled Rakazo production deploy job is gone from CI; evo deploys itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Call the product evOo everywhere people see its name The desktop setup window and menu, account emails, the iOS app name and permission prompts, sign-in headings, messages bots post into connected chat apps, the web fetch user agent, issue templates and the marketing site still said Rakazo, or EvOo. They now say evOo. The marketing site no longer calls evOo an alternative to itself. Internal identifiers (package scopes, env vars, bundle ids, the desktop productName that keys its data folder and keychain entry) stay as they are. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Open conversation actions by holding a row on a phone Pin, move to folder, mark unread, duplicate, archive and delete only opened on right-click, which iOS Safari never fires. Holding a conversation or a folder header now dispatches the same contextmenu event, so the existing menu opens under the finger; Android's own long-press event is not doubled and the lift does not also open the conversation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Give small chrome icons a 44px touch area on phones The browser and computer toggles, the activity bell, the create button and the section plus buttons were 18 to 36 px targets. On touch screens each now gets an invisible 44px hit area without changing how it looks; the create button uses the same plus icon as the rest of the sidebar instead of a text glyph, and the computer toggle gets an accessible name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Add German to the mobile app German speakers saw the phone app in English because the mobile UI only knew English, Simplified Chinese, and Russian. The app now ships a German catalog covering every mobile chrome string, follows a German device language (de, de-DE, de-AT, de-CH, ...), and offers Deutsch in the Account language picker. Strings that also exist in the web German catalog reuse its wording so web and phone say the same thing; the rest follow the same terms and the informal "du". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Use the evOo sign-in strings in the German phone catalog Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Make account deletion work and give settings one section style Deleting an account posted to the auth endpoint without a JSON content type, so the server refused it and the button did nothing. The privacy page now deletes through the auth client and returns to sign-in. It also drops the claims it could not verify, the export button that only showed an alert, and its own padding, and shows where the data and the computer live. Notifications and media settings use the same SettingsSection. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Use status tokens, the shared switch and one German glossary Voice stage dots, the onboarding progress bar and the Voice First button used raw emerald, violet, cyan and rose; they now use foreground, success and destructive like the rest of the app. The routine editor's hand-built switch is the shared Switch. German says Unterhaltung for threads and Space consistently on web and phone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Expect the evOo setup copy in the desktop e2e specs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Translate the voice preview label in the German phone catalog Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Wait for the main thread URL without waiting for every resource openMainBotThread already navigated with domcontentloaded; its URL check then waited for the full load event, which can exceed 20 s on a busy runner. The composer check that follows covers readiness. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ --------- Co-authored-by: Claude <noreply@anthropic.com>
717 lines
26 KiB
TypeScript
717 lines
26 KiB
TypeScript
import {
|
|
DEFAULT_UPDATE_REMOTE,
|
|
isOfficialRepoUrl,
|
|
normalizeRepoUrl,
|
|
normalizeUpdateBranch,
|
|
} from "./self-update.js";
|
|
|
|
/**
|
|
* The GitHub repository whose CI fills the image namespace. `publish-server-image.yml` pushes to
|
|
* `ghcr.io/${{ github.repository }}`, so the namespace always belongs to whichever repository ran
|
|
* the workflow. Naming a different owner here points the deployment at packages nobody publishes.
|
|
*
|
|
* `OFFICIAL_REPO_URL` names the same repository, so the source commit selected from a release is
|
|
* guaranteed to have been eligible for this repository's publishing workflow.
|
|
*/
|
|
export const PUBLISHED_IMAGE_REPO = "cynarai/evoo-local";
|
|
|
|
/** The published server image. One image runs api, worker, and web. */
|
|
export const OFFICIAL_SERVER_IMAGE = `ghcr.io/${PUBLISHED_IMAGE_REPO}/app`;
|
|
/** The updater sidecar's image, published alongside the server image but tagged independently. */
|
|
export const OFFICIAL_UPDATER_IMAGE = `ghcr.io/${PUBLISHED_IMAGE_REPO}/updater`;
|
|
|
|
/**
|
|
* The tag a deployment that has never run an update is on. It is deliberately *not* `latest`: no
|
|
* registry serves this tag, so `docker compose up --build` builds it from the checkout and a fresh
|
|
* install works with an empty registry. `latest` only exists once a release has been published.
|
|
*/
|
|
export const LOCAL_IMAGE_TAG = "local";
|
|
export const DEFAULT_IMAGE_TAG = LOCAL_IMAGE_TAG;
|
|
export const IMAGE_TAG_ENV = "RAKAZO_IMAGE_TAG";
|
|
export const PREVIOUS_IMAGE_TAG_ENV = "RAKAZO_IMAGE_TAG_PREVIOUS";
|
|
|
|
/**
|
|
* Matches the `name:` pinned in `infra/compose/docker-compose.prod.yml`. Used when Compose has not
|
|
* injected `COMPOSE_PROJECT_NAME` (it does, into running services) and no dedicated override is set.
|
|
*/
|
|
export const DEFAULT_COMPOSE_PROJECT_NAME = "rakazo-prod";
|
|
export const COMPOSE_PROJECT_NAME_ENV = "COMPOSE_PROJECT_NAME";
|
|
export const COMPOSE_PROJECT_NAME_OVERRIDE_ENV = "RAKAZO_COMPOSE_PROJECT_NAME";
|
|
|
|
/**
|
|
* The services a recreate replaces. `updater` is deliberately absent: it is the process running
|
|
* the update, and recreating it would kill the run half way through. `postgres` and `caddy` are
|
|
* absent because neither uses the Rakazo image.
|
|
*/
|
|
export const RECREATED_SERVICES = ["api", "worker", "web"] as const;
|
|
|
|
const IMAGE_TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
const IMAGE_NAME_SEGMENT = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
const IMAGE_HOST = /^[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]{1,5})?$/;
|
|
const COMMIT = /^[0-9a-f]{40,64}$/;
|
|
const ENV_KEY = /^[A-Z][A-Z0-9_]*$/;
|
|
const ENV_VALUE = /^[A-Za-z0-9._:/@+-]{0,256}$/;
|
|
const RELEASE_TAG = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
/** Compose project names must not start with `-`, or `-p` would treat them as a second flag. */
|
|
const COMPOSE_PROJECT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
|
|
export function isValidImageTag(tag: string): boolean {
|
|
return IMAGE_TAG.test(tag);
|
|
}
|
|
|
|
/** A registry reference the updater is willing to hand to `docker compose` as one env value. */
|
|
export function isValidImageName(name: string): boolean {
|
|
if (name.length === 0 || name.length > 200) return false;
|
|
const segments = name.split("/");
|
|
const first = segments[0] ?? "";
|
|
const hasHost = segments.length > 1 && (first.includes(".") || first.includes(":"));
|
|
if (hasHost && !IMAGE_HOST.test(first)) return false;
|
|
return segments.slice(hasHost ? 1 : 0).every((segment) => IMAGE_NAME_SEGMENT.test(segment));
|
|
}
|
|
|
|
export function isValidComposeProjectName(name: string): boolean {
|
|
return COMPOSE_PROJECT_NAME.test(name);
|
|
}
|
|
|
|
/**
|
|
* The project `-p` must name. Compose injects `COMPOSE_PROJECT_NAME` into running services, which
|
|
* is the stack the operator actually started (including `docker compose -p something-else`). A
|
|
* dedicated override exists for running the sidecar outside Compose. Unset falls back to the name
|
|
* pinned in the compose file, never to guessing from the working directory.
|
|
*/
|
|
export function resolveComposeProjectName(
|
|
env: Readonly<Record<string, string | undefined>>,
|
|
): string {
|
|
const injected = env[COMPOSE_PROJECT_NAME_ENV]?.trim() ?? "";
|
|
const override = env[COMPOSE_PROJECT_NAME_OVERRIDE_ENV]?.trim() ?? "";
|
|
const candidate = injected || override;
|
|
if (candidate === "") return DEFAULT_COMPOSE_PROJECT_NAME;
|
|
if (!isValidComposeProjectName(candidate)) {
|
|
throw new Error(`Refusing an unusable Compose project name: ${candidate}`);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
export function imageRef(name: string, tag: string): string {
|
|
if (!isValidImageName(name)) throw new Error(`Refusing an unusable image name: ${name}`);
|
|
if (!isValidImageTag(tag)) throw new Error(`Refusing an unusable image tag: ${tag}`);
|
|
return `${name}:${tag}`;
|
|
}
|
|
|
|
/**
|
|
* The tag a fork's build is stored under. Forks have no published images, so the tag has to be
|
|
* local and distinguishable from anything the registry could serve, or a later `pull` of the same
|
|
* name would silently replace the operator's own build.
|
|
*/
|
|
export function forkImageTag(commit: string): string {
|
|
if (!COMMIT.test(commit)) throw new Error("A fork build needs a resolved commit to tag.");
|
|
return `local-${commit}`;
|
|
}
|
|
|
|
/**
|
|
* A tag that only exists on this host, so trying to pull it would fail rather than find nothing.
|
|
* Covers both the per-commit fork builds (`local-<commit>`) and the bare `local` a fresh install
|
|
* builds, because a rollback onto either has to skip `docker compose pull`.
|
|
*/
|
|
export function isLocalImageTag(tag: string): boolean {
|
|
return tag === LOCAL_IMAGE_TAG || tag.startsWith(`${LOCAL_IMAGE_TAG}-`);
|
|
}
|
|
|
|
/**
|
|
* The source-addressed per-commit tag the publish workflow pushes. This has to agree character for
|
|
* character with `docker/metadata-action`'s `type=sha`. The workflow deliberately raises
|
|
* `DOCKER_METADATA_SHORT_SHA_LENGTH` to 40 so source-addressed image tags cannot collide at the
|
|
* action's unsafe seven-character default.
|
|
*/
|
|
export function commitImageTag(commit: string): string {
|
|
if (!COMMIT.test(commit)) throw new Error("A commit tag needs a resolved commit.");
|
|
return `sha-${commit}`;
|
|
}
|
|
|
|
export interface ReleaseTag {
|
|
tag: string;
|
|
major: number;
|
|
minor: number;
|
|
patch: number;
|
|
prerelease: string | null;
|
|
}
|
|
|
|
export function parseReleaseTag(tag: string): ReleaseTag | null {
|
|
const match = RELEASE_TAG.exec(tag);
|
|
if (!match) return null;
|
|
return {
|
|
tag,
|
|
major: Number(match[1]),
|
|
minor: Number(match[2]),
|
|
patch: Number(match[3]),
|
|
prerelease: match[4] ?? null,
|
|
};
|
|
}
|
|
|
|
export function compareReleaseTags(a: ReleaseTag, b: ReleaseTag): number {
|
|
if (a.major !== b.major) return a.major - b.major;
|
|
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
if (a.prerelease === b.prerelease) return 0;
|
|
if (a.prerelease === null) return 1;
|
|
if (b.prerelease === null) return -1;
|
|
return a.prerelease < b.prerelease ? -1 : 1;
|
|
}
|
|
|
|
/** Reads `git ls-remote --tags <url>`, which is the tag list without cloning anything. */
|
|
export function parseLsRemoteTags(stdout: string): string[] {
|
|
const tags = new Set<string>();
|
|
for (const line of stdout.split("\n")) {
|
|
const ref = line.split("\t")[1]?.trim();
|
|
if (!ref?.startsWith("refs/tags/")) continue;
|
|
const tag = ref.slice("refs/tags/".length).replace(/\^\{\}$/, "");
|
|
if (tag !== "" && isValidImageTag(tag)) tags.add(tag);
|
|
}
|
|
return [...tags];
|
|
}
|
|
|
|
export interface RemoteRelease {
|
|
tag: string;
|
|
/** The commit behind the tag, never the object id of an annotated tag. */
|
|
commit: string;
|
|
}
|
|
|
|
/**
|
|
* Reads the source commit for each release. Annotated tags have two `ls-remote` rows; the peeled
|
|
* `^{}` row wins so the image tag agrees with `github.sha` in the publishing workflow.
|
|
*/
|
|
export function parseLsRemoteReleases(stdout: string): RemoteRelease[] {
|
|
const releases = new Map<string, { direct?: string; peeled?: string }>();
|
|
for (const line of stdout.split("\n")) {
|
|
const [commit = "", rawRef = ""] = line.trim().split(/\s+/, 2);
|
|
if (!COMMIT.test(commit) || !rawRef.startsWith("refs/tags/")) continue;
|
|
const peeled = rawRef.endsWith("^{}");
|
|
const tag = rawRef.slice("refs/tags/".length).replace(/\^\{\}$/, "");
|
|
if (tag === "" || !isValidImageTag(tag)) continue;
|
|
const entry = releases.get(tag) ?? {};
|
|
if (peeled) entry.peeled = commit;
|
|
else entry.direct = commit;
|
|
releases.set(tag, entry);
|
|
}
|
|
return [...releases].flatMap(([tag, commits]) => {
|
|
const commit = commits.peeled ?? commits.direct;
|
|
return commit === undefined ? [] : [{ tag, commit }];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The newest stable release. Pre-releases are skipped because "update to latest" should not move a
|
|
* deployment onto a release candidate, and there is no way to opt back out once it has recreated.
|
|
*/
|
|
export function selectLatestReleaseTag(tags: readonly string[]): string | null {
|
|
let best: ReleaseTag | null = null;
|
|
for (const tag of tags) {
|
|
const parsed = parseReleaseTag(tag);
|
|
if (!parsed || parsed.prerelease !== null) continue;
|
|
if (best === null || compareReleaseTags(parsed, best) > 0) best = parsed;
|
|
}
|
|
return best?.tag ?? null;
|
|
}
|
|
|
|
/** Selects the newest stable release together with the commit-tagged image CI published for it. */
|
|
export function selectLatestRelease(releases: readonly RemoteRelease[]): RemoteRelease | null {
|
|
let best: { release: ReleaseTag; commit: string } | null = null;
|
|
for (const candidate of releases) {
|
|
const parsed = parseReleaseTag(candidate.tag);
|
|
if (!parsed || parsed.prerelease !== null) continue;
|
|
if (best === null || compareReleaseTags(parsed, best.release) > 0) {
|
|
best = { release: parsed, commit: candidate.commit };
|
|
}
|
|
}
|
|
return best === null ? null : { tag: best.release.tag, commit: best.commit };
|
|
}
|
|
|
|
export type UpdateStrategy = "pull" | "build";
|
|
|
|
export interface StrategyDecision {
|
|
strategy: UpdateStrategy;
|
|
reason: string;
|
|
}
|
|
|
|
/**
|
|
* Only the official repository has published images. A fork has to be built where it is deployed,
|
|
* which is minutes rather than seconds, so the choice is stated rather than guessed at call sites.
|
|
*/
|
|
export function chooseUpdateStrategy(input: { official: boolean }): StrategyDecision {
|
|
if (input.official) {
|
|
return {
|
|
strategy: "pull",
|
|
reason: "Official releases are published as images, so the update is a download and restart.",
|
|
};
|
|
}
|
|
return {
|
|
strategy: "build",
|
|
reason:
|
|
"A fork has no published images, so the server builds this update itself. Expect minutes, not seconds.",
|
|
};
|
|
}
|
|
|
|
export type ExecutionMode = "sidecar" | "checkout" | "unavailable";
|
|
|
|
export interface ExecutionModeDecision {
|
|
mode: ExecutionMode;
|
|
reason: string | null;
|
|
}
|
|
|
|
/**
|
|
* Which of the two engines can actually apply an update here. The sidecar wins whenever it is
|
|
* configured: a Compose deployment's own container has no `.git` and nothing to restart it, so its
|
|
* only route is the container that outlives it.
|
|
*/
|
|
export function resolveExecutionMode(input: {
|
|
hasUpdater?: boolean;
|
|
hasCheckout?: boolean;
|
|
disabled?: boolean;
|
|
}): ExecutionModeDecision {
|
|
if (input.disabled === true) {
|
|
return { mode: "unavailable", reason: "Self-update is switched off for this deployment." };
|
|
}
|
|
if (input.hasUpdater === true) return { mode: "sidecar", reason: null };
|
|
if (input.hasCheckout === true) return { mode: "checkout", reason: null };
|
|
return {
|
|
mode: "unavailable",
|
|
reason:
|
|
"This deployment has neither an updater sidecar nor a git checkout, so it cannot update itself. Add the `updater` service from infra/compose/docker-compose.prod.yml.",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What the deployment-owner Settings UI should show. Distinct from {@link resolveExecutionMode}:
|
|
* Compose without the opt-in sidecar is still a Compose install, and source checkouts must never
|
|
* look like they can one-click apply from the API process (no `.git` in the app image; no Docker
|
|
* socket either).
|
|
*/
|
|
export type InstallKind = "sidecar" | "compose" | "source";
|
|
|
|
export interface InstallKindDecision {
|
|
kind: InstallKind;
|
|
/** Maps onto ServerUpdateMode for contracts that already use that enum. */
|
|
mode: ExecutionMode;
|
|
reason: string | null;
|
|
}
|
|
|
|
/**
|
|
* `updaterUrlConfigured` means the API was given `RAKAZO_UPDATER_URL` (Compose prod always sets
|
|
* this). Reachability requires both URL and token and a live sidecar. A source checkout is only
|
|
* claimed when there is no Compose updater wiring and `.git` is present on disk.
|
|
*/
|
|
export function resolveInstallKind(input: {
|
|
updaterUrlConfigured?: boolean;
|
|
updaterReachable?: boolean;
|
|
hasCheckout?: boolean;
|
|
disabled?: boolean;
|
|
}): InstallKindDecision {
|
|
if (input.disabled === true) {
|
|
return {
|
|
kind:
|
|
input.updaterUrlConfigured === true
|
|
? "compose"
|
|
: input.hasCheckout === true
|
|
? "source"
|
|
: "compose",
|
|
mode: "unavailable",
|
|
reason: "Self-update is switched off for this deployment.",
|
|
};
|
|
}
|
|
if (input.updaterReachable === true) {
|
|
return { kind: "sidecar", mode: "sidecar", reason: null };
|
|
}
|
|
if (input.updaterUrlConfigured === true) {
|
|
return {
|
|
kind: "compose",
|
|
mode: "unavailable",
|
|
reason:
|
|
"The updater sidecar is not reachable. Start the opt-in `updater` profile, or upgrade from the host with the Compose commands below.",
|
|
};
|
|
}
|
|
if (input.hasCheckout === true) {
|
|
return {
|
|
kind: "source",
|
|
mode: "checkout",
|
|
reason: "This is a source checkout. Upgrade from a terminal; Settings cannot apply it.",
|
|
};
|
|
}
|
|
return {
|
|
kind: "compose",
|
|
mode: "unavailable",
|
|
reason:
|
|
"This deployment has no updater sidecar. Upgrade from the host with the Compose commands below, or enable the `updater` profile.",
|
|
};
|
|
}
|
|
|
|
/** Exact host commands from docs/self-host.md for Compose on a published release tag. */
|
|
export const COMPOSE_PULL_UPGRADE_COMMANDS = [
|
|
"docker compose --env-file .env -f infra/compose/docker-compose.prod.yml pull api worker web",
|
|
"docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never api worker web",
|
|
] as const;
|
|
|
|
/** Exact host commands from docs/self-host.md for Compose on the default `local` tag. */
|
|
export const COMPOSE_LOCAL_BUILD_UPGRADE_COMMANDS = [
|
|
"git pull",
|
|
"GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never --build api worker web",
|
|
] as const;
|
|
|
|
/** @deprecated Prefer {@link COMPOSE_PULL_UPGRADE_COMMANDS}; kept for call-site clarity in tests. */
|
|
export const COMPOSE_MANUAL_UPGRADE_COMMANDS = COMPOSE_PULL_UPGRADE_COMMANDS;
|
|
|
|
/** Exact host commands from docs/self-host.md for source / `pnpm dev` installs. */
|
|
export const SOURCE_MANUAL_UPGRADE_COMMANDS = [
|
|
"git pull",
|
|
"pnpm --filter @rakazo/db migrate",
|
|
"# Restart the API and worker processes",
|
|
] as const;
|
|
|
|
/**
|
|
* Host commands Settings should show when the sidecar cannot apply.
|
|
* Compose picks pull vs rebuild from the current image tag when known; otherwise both documented
|
|
* paths from self-host.md so a `local` install is not told to `pull` a tag the registry never serves.
|
|
*/
|
|
export function manualUpgradeCommands(
|
|
kind: InstallKind,
|
|
options: { imageTag?: string | null } = {},
|
|
): readonly string[] {
|
|
if (kind === "source") return SOURCE_MANUAL_UPGRADE_COMMANDS;
|
|
if (kind !== "compose") return [];
|
|
const tag = options.imageTag?.trim() ?? "";
|
|
if (tag !== "" && isLocalImageTag(tag)) return COMPOSE_LOCAL_BUILD_UPGRADE_COMMANDS;
|
|
if (tag !== "" && !isLocalImageTag(tag)) return COMPOSE_PULL_UPGRADE_COMMANDS;
|
|
return [
|
|
"# Published release tag",
|
|
...COMPOSE_PULL_UPGRADE_COMMANDS,
|
|
"# Local tag (rebuild from checkout)",
|
|
...COMPOSE_LOCAL_BUILD_UPGRADE_COMMANDS,
|
|
];
|
|
}
|
|
|
|
export interface ComposeInvocation {
|
|
command: string;
|
|
args: string[];
|
|
}
|
|
|
|
export interface ComposeTarget {
|
|
/**
|
|
* Compose files in overlay order, each passed as its own `--file`. A deployment that layers an
|
|
* overlay on the base file has to be reconciled with the same file list the operator uses, or an
|
|
* update recreates services without the overlay's wiring.
|
|
*/
|
|
composeFiles: readonly string[];
|
|
envFiles?: readonly string[];
|
|
/** Passed as `docker compose -p <name>`. Defaults to {@link DEFAULT_COMPOSE_PROJECT_NAME}. */
|
|
projectName?: string;
|
|
/**
|
|
* Services to pull, recreate and roll back. Defaults to {@link RECREATED_SERVICES}. A deployment
|
|
* whose overlay adds an app-image service has to recreate that one too, or the update leaves it
|
|
* running the old code.
|
|
*/
|
|
services?: readonly string[];
|
|
}
|
|
|
|
function composeBase(target: ComposeTarget): string[] {
|
|
const projectName = target.projectName ?? DEFAULT_COMPOSE_PROJECT_NAME;
|
|
if (!isValidComposeProjectName(projectName)) {
|
|
throw new Error(`Refusing an unusable Compose project name: ${projectName}`);
|
|
}
|
|
if (target.composeFiles.length === 0) {
|
|
throw new Error("Refusing a Compose target with no Compose file.");
|
|
}
|
|
const args = ["compose", "-p", projectName];
|
|
for (const envFile of target.envFiles ?? []) args.push("--env-file", envFile);
|
|
for (const composeFile of target.composeFiles) args.push("--file", composeFile);
|
|
return args;
|
|
}
|
|
|
|
function targetServices(target: ComposeTarget): readonly string[] {
|
|
const services = target.services ?? [];
|
|
return services.length > 0 ? services : RECREATED_SERVICES;
|
|
}
|
|
|
|
export function composePullArgv(
|
|
target: ComposeTarget,
|
|
services: readonly string[] = targetServices(target),
|
|
): ComposeInvocation {
|
|
return { command: "docker", args: [...composeBase(target), "pull", ...services] };
|
|
}
|
|
|
|
export function composeUpArgv(
|
|
target: ComposeTarget,
|
|
options: { build?: boolean; services?: readonly string[] } = {},
|
|
): ComposeInvocation {
|
|
const args = [
|
|
...composeBase(target),
|
|
"up",
|
|
"--detach",
|
|
"--wait",
|
|
"--wait-timeout",
|
|
"300",
|
|
"--pull",
|
|
"never",
|
|
];
|
|
args.push(options.build === true ? "--build" : "--no-build");
|
|
args.push(...(options.services ?? targetServices(target)));
|
|
return { command: "docker", args };
|
|
}
|
|
|
|
export function composePsArgv(target: ComposeTarget): ComposeInvocation {
|
|
return { command: "docker", args: [...composeBase(target), "ps", "--format", "json"] };
|
|
}
|
|
|
|
/**
|
|
* `git status` as seen inside the Linux sidecar against a Windows checkout. `core.autocrlf=true`
|
|
* makes CR-only worktree noise match what the host already considers clean. The `-c` is argv, not a
|
|
* shell fragment.
|
|
*/
|
|
export function gitStatusArgv(): string[] {
|
|
return [
|
|
"--no-optional-locks",
|
|
"-c",
|
|
"core.autocrlf=true",
|
|
"status",
|
|
"--porcelain",
|
|
"--untracked-files=no",
|
|
];
|
|
}
|
|
|
|
/** Worktree vs index, ignoring CR at EOL so a CRLF-only file does not count as dirty. */
|
|
export function gitWorktreeContentDiffArgv(): string[] {
|
|
return [
|
|
"--no-optional-locks",
|
|
"-c",
|
|
"core.autocrlf=true",
|
|
"diff",
|
|
"--name-only",
|
|
"--ignore-cr-at-eol",
|
|
];
|
|
}
|
|
|
|
/** Index vs HEAD, same CR rule, so a staged content change cannot hide behind the worktree check. */
|
|
export function gitIndexContentDiffArgv(): string[] {
|
|
return [
|
|
"--no-optional-locks",
|
|
"-c",
|
|
"core.autocrlf=true",
|
|
"diff",
|
|
"--cached",
|
|
"--name-only",
|
|
"--ignore-cr-at-eol",
|
|
];
|
|
}
|
|
|
|
/** Untracked source can enter `COPY . .`, so a build must not silently include it. */
|
|
export function gitUntrackedFilesArgv(): string[] {
|
|
return ["--no-optional-locks", "ls-files", "--others", "--exclude-standard"];
|
|
}
|
|
|
|
export function parseGitNameOnly(stdout: string): string[] {
|
|
const paths: string[] = [];
|
|
for (const line of stdout.split("\n")) {
|
|
const path = line.trim();
|
|
if (path !== "") paths.push(path);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
export interface TrackedDirtyDecision {
|
|
dirty: boolean;
|
|
dirtyPaths: string[];
|
|
}
|
|
|
|
/**
|
|
* Porcelain lists every tracked path git considers dirty, including CRLF-only noise when a Windows
|
|
* `core.autocrlf=true` checkout is bind-mounted into Linux. `contentChanged` is the same comparison
|
|
* after `--ignore-cr-at-eol`. Porcelain-only paths are dropped; a path that still has a content
|
|
* diff stays dirty. If the content diffs could not be read, porcelain is the last word — the guard
|
|
* is not weakened for a failed filter.
|
|
*/
|
|
export function resolveTrackedDirtyPaths(input: {
|
|
porcelainChanged: readonly string[];
|
|
contentChanged: readonly string[];
|
|
contentDiffOk: boolean;
|
|
untrackedPaths?: readonly string[];
|
|
}): TrackedDirtyDecision {
|
|
const untrackedPaths = input.untrackedPaths ?? [];
|
|
if (!input.contentDiffOk) {
|
|
const dirtyPaths = uniquePaths([...input.porcelainChanged, ...untrackedPaths]);
|
|
return { dirty: dirtyPaths.length > 0, dirtyPaths };
|
|
}
|
|
const dirtyPaths = uniquePaths([...input.contentChanged, ...untrackedPaths]);
|
|
return { dirty: dirtyPaths.length > 0, dirtyPaths };
|
|
}
|
|
|
|
function uniquePaths(paths: readonly string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const unique: string[] = [];
|
|
for (const path of paths) {
|
|
if (path === "" || seen.has(path)) continue;
|
|
seen.add(path);
|
|
unique.push(path);
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
export interface ComposeUpdateStep {
|
|
id: string;
|
|
label: string;
|
|
command: string;
|
|
args: string[];
|
|
}
|
|
|
|
export interface ComposeUpdatePlanInput {
|
|
strategy: UpdateStrategy;
|
|
target: ComposeTarget;
|
|
/** Only the build path touches the checkout, and only when the remote has to be re-pointed. */
|
|
repoUrl?: string;
|
|
branch?: string;
|
|
repointRemote?: boolean;
|
|
remote?: string;
|
|
}
|
|
|
|
/**
|
|
* Ordered work for one Compose update.
|
|
*
|
|
* Migrations are absent on purpose. The `api` service runs `prisma migrate deploy` as the first
|
|
* thing in its own start command, so a recreate already orders them correctly: Compose stops the
|
|
* old container, the new one migrates with the new code, then serves. Running migrations from here
|
|
* would instead apply the new schema while the old process is still answering requests, which is
|
|
* the window this ordering exists to close.
|
|
*/
|
|
export function composeUpdatePlan(input: ComposeUpdatePlanInput): ComposeUpdateStep[] {
|
|
const steps: ComposeUpdateStep[] = [];
|
|
if (input.strategy === "build") {
|
|
const remote = input.remote ?? DEFAULT_UPDATE_REMOTE;
|
|
const branch = input.branch ?? "main";
|
|
if (input.repointRemote === true && input.repoUrl) {
|
|
steps.push({
|
|
id: "remote",
|
|
label: "Point the checkout at the chosen repository",
|
|
command: "git",
|
|
args: ["remote", "set-url", remote, input.repoUrl],
|
|
});
|
|
}
|
|
steps.push(
|
|
{
|
|
id: "fetch",
|
|
label: "Fetch the latest commits",
|
|
command: "git",
|
|
args: ["fetch", remote, branch],
|
|
},
|
|
{
|
|
id: "checkout",
|
|
label: `Check out ${branch}`,
|
|
command: "git",
|
|
args: ["checkout", branch],
|
|
},
|
|
{
|
|
id: "merge",
|
|
label: "Fast-forward to the fetched commit",
|
|
command: "git",
|
|
args: ["merge", "--ff-only", `${remote}/${branch}`],
|
|
},
|
|
);
|
|
const up = composeUpArgv(input.target, { build: true });
|
|
steps.push({
|
|
id: "recreate",
|
|
label: "Build the new images and recreate the services",
|
|
command: up.command,
|
|
args: up.args,
|
|
});
|
|
return steps;
|
|
}
|
|
|
|
const pull = composePullArgv(input.target);
|
|
const up = composeUpArgv(input.target);
|
|
steps.push(
|
|
{ id: "pull", label: "Download the new images", command: pull.command, args: pull.args },
|
|
{ id: "recreate", label: "Recreate the services", command: up.command, args: up.args },
|
|
);
|
|
return steps;
|
|
}
|
|
|
|
export type RollbackDecision = { tag: string } | { error: string };
|
|
|
|
/**
|
|
* Rollback is "deploy the tag that was running before the last update". The updater reuses the
|
|
* image already cached on this host instead of trusting that a mutable registry tag still points
|
|
* at the same content.
|
|
*/
|
|
export function rollbackTarget(input: {
|
|
currentTag: string | null;
|
|
previousTag: string | null;
|
|
}): RollbackDecision {
|
|
if (!input.previousTag) {
|
|
return { error: "No previous image tag was recorded, so there is nothing to roll back to." };
|
|
}
|
|
if (!isValidImageTag(input.previousTag)) {
|
|
return { error: "The recorded previous image tag is not a usable tag." };
|
|
}
|
|
if (input.previousTag === input.currentTag) {
|
|
return { error: "The previous image tag is the one already running." };
|
|
}
|
|
return { tag: input.previousTag };
|
|
}
|
|
|
|
/**
|
|
* Rewrites managed assignments in the deployment's `.env` without disturbing anything else in it,
|
|
* so the operator's own `docker compose --env-file .env … up -d` keeps using the pinned tag.
|
|
* Values are checked against a conservative grammar first: this file is read by Compose as
|
|
* interpolation input, and it is the last place a repository URL or ref could leak into a build
|
|
* argument or a service definition.
|
|
*/
|
|
export function upsertEnvAssignments(
|
|
contents: string,
|
|
assignments: Readonly<Record<string, string>>,
|
|
): string {
|
|
for (const [key, value] of Object.entries(assignments)) {
|
|
if (!ENV_KEY.test(key)) throw new Error(`Refusing to write the env key ${key}.`);
|
|
if (!ENV_VALUE.test(value)) throw new Error(`Refusing to write the value of ${key}.`);
|
|
}
|
|
const newline = contents.includes("\r\n") ? "\r\n" : "\n";
|
|
const lines = contents.split(/\r?\n/);
|
|
const pending = new Map(Object.entries(assignments));
|
|
const next = lines.map((line) => {
|
|
const key = line.match(/^([A-Z][A-Z0-9_]*)=/)?.[1];
|
|
if (key === undefined || !Object.hasOwn(assignments, key)) return line;
|
|
const value = assignments[key] as string;
|
|
pending.delete(key);
|
|
return `${key}=${value}`;
|
|
});
|
|
if (pending.size > 0) {
|
|
while (next.length > 0 && next[next.length - 1] === "") next.pop();
|
|
if (next.length > 0) next.push("");
|
|
for (const [key, value] of pending) next.push(`${key}=${value}`);
|
|
}
|
|
const rendered = next.join(newline);
|
|
return rendered.endsWith(newline) ? rendered : `${rendered}${newline}`;
|
|
}
|
|
|
|
export interface UpdateRequest {
|
|
repoUrl: string;
|
|
branch: string;
|
|
official: boolean;
|
|
}
|
|
|
|
export type UpdateRequestResult = { request: UpdateRequest } | { error: string };
|
|
|
|
/**
|
|
* The sidecar's own front door. It holds the Docker socket, so it is a separate trust boundary and
|
|
* re-runs every check the API already ran rather than trusting that the caller sanitized anything.
|
|
*/
|
|
export function validateUpdateRequest(input: {
|
|
repoUrl?: unknown;
|
|
branch?: unknown;
|
|
}): UpdateRequestResult {
|
|
if (typeof input.repoUrl !== "string") return { error: "A repository URL is required." };
|
|
if (typeof input.branch !== "string") return { error: "A branch is required." };
|
|
const url = normalizeRepoUrl(input.repoUrl);
|
|
if ("error" in url) return { error: url.error };
|
|
const branch = normalizeUpdateBranch(input.branch);
|
|
if ("error" in branch) return { error: branch.error };
|
|
return {
|
|
request: { repoUrl: url.url, branch: branch.branch, official: isOfficialRepoUrl(url.url) },
|
|
};
|
|
}
|