Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
580 lines
21 KiB
TypeScript
580 lines
21 KiB
TypeScript
import { copyFile, lstat, mkdir, readFile } from "node:fs/promises";
|
||
import { createServer } from "node:net";
|
||
import path from "node:path";
|
||
import type { DesktopLocalStackState } from "@rakazo/contracts";
|
||
import {
|
||
classifyDockerFailure,
|
||
composeSupportsWaitTimeout,
|
||
type DockerFailureKind,
|
||
dockerSpawnEnv,
|
||
type RunDocker,
|
||
type RunDockerResult,
|
||
resolveDockerBinary,
|
||
} from "./docker-cli.js";
|
||
import { readPrivateFile, writePrivateFile } from "./setup-store.js";
|
||
|
||
export const STACK_DIR_NAME = "stack";
|
||
export const STACK_PROJECT_NAME = "rakazo-desktop";
|
||
export const STACK_COMPOSE_FILE = "docker-compose.images.yml";
|
||
export const STACK_ENV_TEMPLATE = ".env.images.example";
|
||
export const STACK_ENV_FILE = ".env";
|
||
export const STACK_WEB_URL_FILE = ".desktop-web-url";
|
||
export const STACK_TOKEN_FILE = ".desktop-stack-token";
|
||
export const STACK_OUTPUT_LINES = 20;
|
||
export const STACK_HEALTH_TIMEOUT_MS = 120_000;
|
||
export const COMPOSE_WAIT_TIMEOUT_S = 300;
|
||
|
||
export async function readStackWebUrl(dir: string, fallback: string): Promise<string> {
|
||
const raw = await readPrivateFile(path.join(dir, STACK_WEB_URL_FILE), 128);
|
||
const match = raw?.match(/^http:\/\/127\.0\.0\.1:(\d{4,5})$/);
|
||
const port = Number(match?.[1]);
|
||
return match && port >= 1024 && port <= 65535 && raw === `http://127.0.0.1:${port}`
|
||
? raw
|
||
: fallback;
|
||
}
|
||
|
||
/** Docker owns the final bind; a racing listener is handled by bounded up retries. */
|
||
export async function allocateLoopbackPort(): Promise<number> {
|
||
return new Promise((resolve, reject) => {
|
||
const server = createServer();
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", () => {
|
||
const address = server.address();
|
||
server.close((error) => {
|
||
if (error) reject(error);
|
||
else if (address && typeof address !== "string") resolve(address.port);
|
||
else reject(new Error("No loopback port allocated."));
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
const HEALTH_POLL_INTERVAL_MS = 2_000;
|
||
const COMPOSE_VERSION_TIMEOUT_MS = 15_000;
|
||
const DOCKER_INFO_TIMEOUT_MS = 20_000;
|
||
const PULL_TIMEOUT_MS = 30 * 60_000;
|
||
const UP_TIMEOUT_MS = (COMPOSE_WAIT_TIMEOUT_S + 60) * 1_000;
|
||
const LOGS_TIMEOUT_MS = 15_000;
|
||
const STOP_TIMEOUT_MS = 90_000;
|
||
const MAX_STACK_TOKEN_BYTES = 1024;
|
||
|
||
/** The compose project lives under the app's user data, next to the `.env` it generates. */
|
||
export function stackDir(userDataDir: string): string {
|
||
return path.join(userDataDir, STACK_DIR_NAME);
|
||
}
|
||
|
||
/** Installed builds ship the compose YAML and env template as extra resources outside asar. */
|
||
export function stackResourceDir(input: {
|
||
packaged: boolean;
|
||
resourcesPath: string;
|
||
appPath: string;
|
||
}): string {
|
||
if (input.packaged) return path.join(input.resourcesPath, STACK_DIR_NAME);
|
||
return path.resolve(input.appPath, "..", "..", "infra", "compose");
|
||
}
|
||
|
||
const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
|
||
|
||
/**
|
||
* Installed builds pin images to their own version so the DMG and the multi-arch
|
||
* images move together; development builds follow `edge`.
|
||
*/
|
||
export function resolveImageTag(input: {
|
||
version: string;
|
||
packaged: boolean;
|
||
override?: string;
|
||
}): string {
|
||
const override = input.override?.trim();
|
||
if (override) return override;
|
||
if (input.packaged && STABLE_VERSION.test(input.version)) return `v${input.version}`;
|
||
return "edge";
|
||
}
|
||
|
||
const GENERATED_SECRETS: Record<string, number> = {
|
||
POSTGRES_PASSWORD: 16,
|
||
BETTER_AUTH_SECRET: 32,
|
||
ENCRYPTION_KEY: 32,
|
||
SCREEN_PROXY_SECRET: 32,
|
||
SANDBOX_SUPERVISOR_TOKEN: 32,
|
||
};
|
||
const LAUNCH_SUPPLIED = ["RAKAZO_IMAGE_TAG", "RAKAZO_COMPUTER_IMAGE_TAG"];
|
||
|
||
/**
|
||
* Port of install-images.sh `create_env`: fills the empty secret lines with random
|
||
* hex and keeps everything else verbatim. Image tags are dropped because the app
|
||
* supplies them per launch, so an update never leaves a stale pin behind.
|
||
*/
|
||
export function renderStackEnv(template: string, randomHex: (bytes: number) => string): string {
|
||
const lines = template.split("\n");
|
||
const rendered: string[] = [];
|
||
for (const line of lines) {
|
||
const separator = line.indexOf("=");
|
||
const name = separator === -1 ? "" : line.slice(0, separator);
|
||
if (line === `${name}=` && Object.hasOwn(GENERATED_SECRETS, name)) {
|
||
rendered.push(`${name}=${randomHex(GENERATED_SECRETS[name]!)}`);
|
||
continue;
|
||
}
|
||
if (LAUNCH_SUPPLIED.includes(name)) continue;
|
||
rendered.push(line);
|
||
}
|
||
return rendered.join("\n");
|
||
}
|
||
|
||
/** Keeps an existing regular `.env`, but replaces a final symlink instead of trusting its target. */
|
||
export async function ensureStackEnv(
|
||
dir: string,
|
||
template: string,
|
||
randomHex: (bytes: number) => string,
|
||
): Promise<"kept" | "created"> {
|
||
const destination = path.join(dir, STACK_ENV_FILE);
|
||
try {
|
||
const info = await lstat(destination);
|
||
if (!info.isSymbolicLink()) return "kept";
|
||
} catch {
|
||
// Missing files are created below.
|
||
}
|
||
await writePrivateFile(destination, renderStackEnv(template, randomHex));
|
||
return "created";
|
||
}
|
||
|
||
const STACK_TOKEN = /^[a-f0-9]{64}$/;
|
||
|
||
export async function readStackToken(dir: string): Promise<string | null> {
|
||
const raw = await readPrivateFile(path.join(dir, STACK_TOKEN_FILE), MAX_STACK_TOKEN_BYTES);
|
||
const token = raw?.trim() ?? "";
|
||
return STACK_TOKEN.test(token) ? token : null;
|
||
}
|
||
|
||
/** Creates an app-private identity without placing it in the user-editable Compose env file. */
|
||
export async function ensureStackToken(
|
||
dir: string,
|
||
randomHex: (bytes: number) => string,
|
||
): Promise<string> {
|
||
const existing = await readStackToken(dir);
|
||
if (existing !== null) return existing;
|
||
const token = randomHex(32);
|
||
if (!STACK_TOKEN.test(token)) throw new Error("Stack token generator returned invalid data.");
|
||
await writePrivateFile(path.join(dir, STACK_TOKEN_FILE), `${token}\n`);
|
||
return token;
|
||
}
|
||
|
||
export type LocalStackEvent =
|
||
| { type: "check-start" }
|
||
| { type: "docker-missing"; message: string }
|
||
| { type: "docker-not-running"; message: string }
|
||
| { type: "prepare" }
|
||
| { type: "pull-start" }
|
||
| { type: "up-start" }
|
||
| { type: "wait-start" }
|
||
| { type: "output"; line: string }
|
||
| { type: "ready" }
|
||
| { type: "failed"; message: string };
|
||
|
||
export function initialStackState(imageTag: string): DesktopLocalStackState {
|
||
return { phase: "idle", message: null, output: [], layerBytes: {}, imageTag };
|
||
}
|
||
|
||
/** `COMPOSE_PROGRESS=plain` reports a layer's downloaded size and never its total. */
|
||
const PULL_PROGRESS = /^\s*(\S+)\s+Downloading\s+([\d.]+)\s*([kKMG]?B)\b/;
|
||
const BYTE_UNITS: Record<string, number> = { B: 1, kB: 1e3, MB: 1e6, GB: 1e9 };
|
||
|
||
/** Layers report a growing size, so the largest value seen for a layer is what it has pulled. */
|
||
function reduceLayerBytes(layerBytes: Record<string, number>, line: string) {
|
||
const match = PULL_PROGRESS.exec(line);
|
||
if (match === null) return layerBytes;
|
||
const layer = match[1] ?? "";
|
||
const unit = match[3] === "KB" ? "kB" : (match[3] ?? "");
|
||
const bytes = Number(match[2]) * (BYTE_UNITS[unit] ?? 0);
|
||
if (!Number.isFinite(bytes) || bytes <= (layerBytes[layer] ?? 0)) return layerBytes;
|
||
return { ...layerBytes, [layer]: bytes };
|
||
}
|
||
|
||
export function reduceStackState(
|
||
state: DesktopLocalStackState,
|
||
event: LocalStackEvent,
|
||
): DesktopLocalStackState {
|
||
if (event.type === "check-start") {
|
||
return { ...state, phase: "checking-docker", message: null, output: [], layerBytes: {} };
|
||
}
|
||
// Terminal phases only leave through the next check-start.
|
||
if (state.phase === "ready" || state.phase === "failed") return state;
|
||
switch (event.type) {
|
||
case "docker-missing":
|
||
return { ...state, phase: "docker-missing", message: event.message };
|
||
case "docker-not-running":
|
||
return { ...state, phase: "docker-not-running", message: event.message };
|
||
case "prepare":
|
||
return { ...state, phase: "preparing", message: null };
|
||
case "pull-start":
|
||
return { ...state, phase: "pulling", message: null };
|
||
case "up-start":
|
||
return { ...state, phase: "starting", message: null };
|
||
case "wait-start":
|
||
return { ...state, phase: "waiting-healthy", message: null };
|
||
case "output":
|
||
if (
|
||
state.phase !== "pulling" &&
|
||
state.phase !== "starting" &&
|
||
state.phase !== "waiting-healthy"
|
||
) {
|
||
return state;
|
||
}
|
||
return {
|
||
...state,
|
||
output: [...state.output, event.line].slice(-STACK_OUTPUT_LINES),
|
||
layerBytes: reduceLayerBytes(state.layerBytes, event.line),
|
||
};
|
||
case "ready":
|
||
return { ...state, phase: "ready", message: null };
|
||
case "failed":
|
||
return { ...state, phase: "failed", message: event.message };
|
||
}
|
||
}
|
||
|
||
const DOCKER_GROUP_HINT =
|
||
"This user cannot access Docker. Add it to the docker group (sudo usermod -aG docker $USER), sign out and back in, then check again.";
|
||
const STOP_FAILED = "Could not stop the local stack. Check that Docker is running, then try again.";
|
||
const START_INTERRUPTED = "The start was interrupted. Retry to continue.";
|
||
|
||
/** Docker output may contain paths and hostnames, so the person only ever sees these. */
|
||
export function stackFailureMessage(
|
||
kind: DockerFailureKind,
|
||
phase: "pulling" | "starting",
|
||
imageTag: string,
|
||
): string {
|
||
switch (kind) {
|
||
case "image-not-found":
|
||
return `Images for ${imageTag} are not published yet. Try again in a few minutes.`;
|
||
case "port-in-use":
|
||
return "Could not bind a local port after retrying. Retry to choose another port.";
|
||
case "address-pool-exhausted":
|
||
return "Docker has no free network address pools. Remove unused Docker networks or expand Docker’s address pools, then retry.";
|
||
case "network":
|
||
return "Could not reach the image registry. Check the internet connection, then retry.";
|
||
case "daemon-down":
|
||
return "Docker stopped answering. Start Docker, then retry.";
|
||
case "socket-permission":
|
||
return DOCKER_GROUP_HINT;
|
||
case "compose-missing":
|
||
return "Docker Compose is missing. Install Docker Desktop or the docker-compose-plugin, then retry.";
|
||
case "other":
|
||
return phase === "pulling"
|
||
? "Downloading Rakazo images failed. Check the output below, then retry."
|
||
: "Rakazo services did not start. Check the output below, then retry.";
|
||
}
|
||
}
|
||
|
||
function failureKind(result: RunDockerResult): DockerFailureKind {
|
||
return classifyDockerFailure(`${result.stdout}\n${result.stderr}`);
|
||
}
|
||
|
||
/** runDocker exits 130 after an abort; that is a quit or Stop, not a docker failure. */
|
||
function interrupted(signal: AbortSignal, result: RunDockerResult): boolean {
|
||
return signal.aborted || result.code === 130;
|
||
}
|
||
|
||
export interface LocalStackDeps {
|
||
platform: string;
|
||
env: NodeJS.ProcessEnv;
|
||
exists: (file: string) => boolean;
|
||
run: RunDocker;
|
||
stackDir: string;
|
||
resourceDir: string;
|
||
localWebUrl: string;
|
||
allocatePort?: () => Promise<number>;
|
||
imageTag: string;
|
||
/** Returns the authenticated running image tag, or null for any other listener. */
|
||
probe: (url: string, signal: AbortSignal, token: string) => Promise<string | null>;
|
||
randomHex: (bytes: number) => string;
|
||
/** Called on every state change so the setup window is pushed progress instead of polling for it. */
|
||
onState?: (state: DesktopLocalStackState) => void;
|
||
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
||
healthTimeoutMs?: number;
|
||
}
|
||
|
||
/** Resolves early on abort so a stop never waits out a health poll. */
|
||
function defaultSleep(ms: number, signal: AbortSignal) {
|
||
return new Promise<void>((resolve) => {
|
||
const timer = setTimeout(done, ms);
|
||
function done() {
|
||
clearTimeout(timer);
|
||
signal.removeEventListener("abort", done);
|
||
resolve();
|
||
}
|
||
signal.addEventListener("abort", done, { once: true });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Installs and starts the compose stack under the app's user data. Every docker call
|
||
* uses a fixed argv and an allowlisted environment; the setup window polls `state()`.
|
||
*/
|
||
export class LocalStackController {
|
||
private current: DesktopLocalStackState;
|
||
private currentWebUrl: string;
|
||
private currentStackToken: string | null = null;
|
||
private running: Promise<DesktopLocalStackState> | null = null;
|
||
private stopping: Promise<DesktopLocalStackState> | null = null;
|
||
private inFlight: AbortController | null = null;
|
||
private ticket = 0;
|
||
/** Starts ticketed at or below this were superseded by a later stop and must not run. */
|
||
private voidBefore = 0;
|
||
|
||
constructor(private readonly deps: LocalStackDeps) {
|
||
this.current = initialStackState(deps.imageTag);
|
||
this.currentWebUrl = deps.localWebUrl;
|
||
}
|
||
|
||
webUrl(): string {
|
||
return this.currentWebUrl;
|
||
}
|
||
|
||
state(): DesktopLocalStackState {
|
||
return this.current;
|
||
}
|
||
|
||
/** Fast path for launch: only a stack with our private token and desired image may be reused. */
|
||
async matchesDesiredStack(url = this.currentWebUrl): Promise<boolean> {
|
||
const token = await readStackToken(this.deps.stackDir);
|
||
if (token === null) return false;
|
||
this.currentStackToken = token;
|
||
try {
|
||
return (
|
||
(await this.deps.probe(url, AbortSignal.timeout(HEALTH_POLL_INTERVAL_MS), token)) ===
|
||
this.deps.imageTag
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Idempotent while a start is in flight; otherwise begins a fresh attempt from any
|
||
* phase, after any stop still running so `compose up` and `compose stop` never overlap.
|
||
*/
|
||
start(): Promise<DesktopLocalStackState> {
|
||
if (this.running !== null) return this.running;
|
||
const ticket = ++this.ticket;
|
||
const attempt = (this.stopping ?? Promise.resolve())
|
||
.then(() => (ticket <= this.voidBefore ? this.current : this.run()))
|
||
.finally(() => {
|
||
if (this.running === attempt) this.running = null;
|
||
});
|
||
this.running = attempt;
|
||
return attempt;
|
||
}
|
||
|
||
/** Kills the in-flight docker command; containers already started keep running. */
|
||
abort() {
|
||
this.inFlight?.abort();
|
||
}
|
||
|
||
/** Stops the containers (`compose stop`); volumes and images stay for the next start. */
|
||
stop(): Promise<DesktopLocalStackState> {
|
||
this.voidBefore = this.ticket;
|
||
// Join a stop already running unless a start was queued behind it; the latest
|
||
// intent is "stopped", so that start is voided and a fresh stop follows it.
|
||
if (this.stopping !== null && this.running === null) return this.stopping;
|
||
this.abort();
|
||
// Release the attempt now so a start requested during this stop queues a fresh
|
||
// attempt behind it instead of joining the one being torn down.
|
||
const settled = [this.stopping, this.running].map((pending) => pending?.catch(() => undefined));
|
||
this.running = null;
|
||
const stopping = Promise.all(settled)
|
||
.then(() => this.runStop())
|
||
.finally(() => {
|
||
if (this.stopping === stopping) this.stopping = null;
|
||
});
|
||
this.stopping = stopping;
|
||
return stopping;
|
||
}
|
||
|
||
private async runStop(): Promise<DesktopLocalStackState> {
|
||
const binary = resolveDockerBinary(this.deps.platform, this.deps.env, this.deps.exists);
|
||
const stopped = binary === null ? null : await this.compose(binary, ["stop"], STOP_TIMEOUT_MS);
|
||
this.setState(
|
||
stopped?.code === 0
|
||
? initialStackState(this.deps.imageTag)
|
||
: { ...this.current, phase: "failed", message: STOP_FAILED },
|
||
);
|
||
return this.current;
|
||
}
|
||
|
||
private push(event: LocalStackEvent) {
|
||
this.setState(reduceStackState(this.current, event));
|
||
}
|
||
|
||
private setState(next: DesktopLocalStackState) {
|
||
if (next === this.current) return;
|
||
this.current = next;
|
||
this.deps.onState?.(next);
|
||
}
|
||
|
||
private async run(): Promise<DesktopLocalStackState> {
|
||
const controller = new AbortController();
|
||
this.inFlight = controller;
|
||
try {
|
||
await this.attempt(controller.signal);
|
||
} catch {
|
||
// Copying the bundled compose files failed; the path would not help the person.
|
||
this.push({ type: "failed", message: "Could not prepare the local stack. Retry." });
|
||
} finally {
|
||
if (this.inFlight === controller) this.inFlight = null;
|
||
}
|
||
return this.current;
|
||
}
|
||
|
||
private async attempt(signal: AbortSignal) {
|
||
this.push({ type: "check-start" });
|
||
await mkdir(this.deps.stackDir, { recursive: true, mode: 0o700 });
|
||
|
||
const binary = resolveDockerBinary(this.deps.platform, this.deps.env, this.deps.exists);
|
||
if (binary === null) {
|
||
this.push({ type: "docker-missing", message: "Docker is not installed on this computer." });
|
||
return;
|
||
}
|
||
const version = await this.docker(
|
||
binary,
|
||
["compose", "version", "--short"],
|
||
COMPOSE_VERSION_TIMEOUT_MS,
|
||
signal,
|
||
);
|
||
if (interrupted(signal, version))
|
||
return this.push({ type: "failed", message: START_INTERRUPTED });
|
||
if (version.code !== 0) {
|
||
this.push({
|
||
type: "docker-missing",
|
||
message:
|
||
"Docker is installed, but Docker Compose is missing. Install Docker Desktop or the docker-compose-plugin.",
|
||
});
|
||
return;
|
||
}
|
||
const info = await this.docker(
|
||
binary,
|
||
["info", "--format", "{{.ServerVersion}}"],
|
||
DOCKER_INFO_TIMEOUT_MS,
|
||
signal,
|
||
);
|
||
if (interrupted(signal, info)) return this.push({ type: "failed", message: START_INTERRUPTED });
|
||
if (info.code !== 0) {
|
||
this.push({
|
||
type: "docker-not-running",
|
||
message:
|
||
failureKind(info) === "socket-permission"
|
||
? DOCKER_GROUP_HINT
|
||
: "Docker is installed but not running. Start Docker, then check again.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.push({ type: "prepare" });
|
||
await copyFile(
|
||
path.join(this.deps.resourceDir, STACK_COMPOSE_FILE),
|
||
path.join(this.deps.stackDir, STACK_COMPOSE_FILE),
|
||
);
|
||
const template = await readFile(path.join(this.deps.resourceDir, STACK_ENV_TEMPLATE), "utf8");
|
||
await ensureStackEnv(this.deps.stackDir, template, this.deps.randomHex);
|
||
const stackToken = await ensureStackToken(this.deps.stackDir, this.deps.randomHex);
|
||
this.currentStackToken = stackToken;
|
||
|
||
this.push({ type: "pull-start" });
|
||
const pulled = await this.compose(binary, ["pull"], PULL_TIMEOUT_MS, signal);
|
||
if (interrupted(signal, pulled))
|
||
return this.push({ type: "failed", message: START_INTERRUPTED });
|
||
if (pulled.code !== 0) {
|
||
this.push({
|
||
type: "failed",
|
||
message: stackFailureMessage(failureKind(pulled), "pulling", this.deps.imageTag),
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.push({ type: "up-start" });
|
||
const upArgs = composeSupportsWaitTimeout(version.stdout)
|
||
? ["up", "-d", "--wait", "--wait-timeout", String(COMPOSE_WAIT_TIMEOUT_S)]
|
||
: ["up", "-d"];
|
||
await writePrivateFile(path.join(this.deps.stackDir, STACK_WEB_URL_FILE), this.currentWebUrl);
|
||
let up = await this.compose(binary, upArgs, UP_TIMEOUT_MS, signal);
|
||
for (
|
||
let retry = 0;
|
||
retry < 2 && !interrupted(signal, up) && up.code !== 0 && failureKind(up) === "port-in-use";
|
||
retry += 1
|
||
) {
|
||
const port = await (this.deps.allocatePort ?? allocateLoopbackPort)();
|
||
if (signal.aborted) break;
|
||
this.currentWebUrl = `http://127.0.0.1:${port}`;
|
||
await writePrivateFile(path.join(this.deps.stackDir, STACK_WEB_URL_FILE), this.currentWebUrl);
|
||
up = await this.compose(binary, upArgs, UP_TIMEOUT_MS, signal);
|
||
}
|
||
if (interrupted(signal, up)) return this.push({ type: "failed", message: START_INTERRUPTED });
|
||
if (up.code !== 0) {
|
||
// Best effort: recent service logs usually name the failing service.
|
||
await this.compose(binary, ["logs", "--tail", "30", "--no-color"], LOGS_TIMEOUT_MS, signal);
|
||
this.push({
|
||
type: "failed",
|
||
message: stackFailureMessage(failureKind(up), "starting", this.deps.imageTag),
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.push({ type: "wait-start" });
|
||
const sleep = this.deps.sleep ?? defaultSleep;
|
||
const deadline = Date.now() + (this.deps.healthTimeoutMs ?? STACK_HEALTH_TIMEOUT_MS);
|
||
while (!signal.aborted) {
|
||
if ((await this.deps.probe(this.currentWebUrl, signal, stackToken)) === this.deps.imageTag) {
|
||
this.push({ type: "ready" });
|
||
return;
|
||
}
|
||
if (Date.now() >= deadline) break;
|
||
await sleep(HEALTH_POLL_INTERVAL_MS, signal);
|
||
}
|
||
this.push({
|
||
type: "failed",
|
||
message: signal.aborted
|
||
? START_INTERRUPTED
|
||
: "The stack started but the web app did not answer. Retry, and check the output below.",
|
||
});
|
||
}
|
||
|
||
private docker(binary: string, args: string[], timeoutMs: number, signal?: AbortSignal) {
|
||
return this.deps.run(binary, args, {
|
||
cwd: this.deps.stackDir,
|
||
env: dockerSpawnEnv(this.deps.platform, this.deps.env, binary, {
|
||
RAKAZO_IMAGE_TAG: this.deps.imageTag,
|
||
RAKAZO_COMPUTER_IMAGE_TAG: this.deps.imageTag,
|
||
...(this.currentStackToken === null
|
||
? {}
|
||
: { RAKAZO_DESKTOP_STACK_TOKEN: this.currentStackToken }),
|
||
RAKAZO_WEB_PORT: new URL(this.currentWebUrl).port || "80",
|
||
// Only web needs a stable host address. Docker allocates the API host port.
|
||
RAKAZO_API_PORT: "0",
|
||
BETTER_AUTH_URL: this.currentWebUrl,
|
||
WEB_ORIGIN: this.currentWebUrl,
|
||
API_URL: this.currentWebUrl,
|
||
COMPOSE_PROGRESS: "plain",
|
||
}),
|
||
timeoutMs,
|
||
signal,
|
||
onLine: (line) => this.push({ type: "output", line }),
|
||
});
|
||
}
|
||
|
||
private compose(binary: string, args: string[], timeoutMs: number, signal?: AbortSignal) {
|
||
return this.docker(
|
||
binary,
|
||
[
|
||
"compose",
|
||
"--env-file",
|
||
STACK_ENV_FILE,
|
||
"-f",
|
||
STACK_COMPOSE_FILE,
|
||
"--project-name",
|
||
STACK_PROJECT_NAME,
|
||
...args,
|
||
],
|
||
timeoutMs,
|
||
signal,
|
||
);
|
||
}
|
||
}
|