add auto-updater

This commit is contained in:
2026-09-08 17:33:55 +00:00
parent d1793cc11b
commit 8763226ccd
12 changed files with 685 additions and 29 deletions
+16 -3
View File
@@ -8,6 +8,10 @@ import React from "react";
import { render, type Instance } from "ink";
import { App as AppComponent } from "./tui/App.js";
import { getDataDir } from "./utils/paths.js";
import {
GitUpdateService,
type UpdateLaunchRequest,
} from "./updater/git-update-service.js";
/**
* Configuration options for the CLI application.
@@ -33,13 +37,16 @@ export class App {
/** Application configuration */
private config: AppConfig;
private readonly updater: GitUpdateService;
private updateRequest: UpdateLaunchRequest | null = null;
/**
* Creates a new App instance.
* @param config - Application configuration options
*/
private constructor(config: AppConfig) {
private constructor(config: AppConfig, updater: GitUpdateService) {
this.config = config;
this.updater = updater;
}
/**
@@ -61,7 +68,7 @@ export class App {
console.log("Full config:", fullConfig);
const app = new App(fullConfig);
const app = new App(fullConfig, new GitUpdateService());
await app.start();
return app;
}
@@ -76,13 +83,19 @@ export class App {
this.inkInstance = render(
React.createElement(AppComponent, {
config: this.config,
updater: this.updater,
onUpdateAndRestart: (request: UpdateLaunchRequest) => {
this.updateRequest = request;
},
}),
);
// Wait for the app to exit
await this.inkInstance.waitUntilExit();
process.exit(0);
if (this.updateRequest) {
this.updater.launchHelper(this.updateRequest, true);
}
}
/**
+1
View File
@@ -66,6 +66,7 @@ export const COMMAND_TREE = {
receive: [],
resource: RESOURCE_SUBS,
settings: SETTINGS_SUBS,
update: [],
help: [],
completions: COMPLETIONS_SUBS,
} as const;
+32
View File
@@ -62,6 +62,7 @@ import {
} from "./commands/index.js";
import { handleCompletionsCommand } from "./autocomplete/completions.js";
import { GitUpdateService } from "../updater/git-update-service.js";
const createCommandIO = (verbose: boolean): CommandIO => ({
out: (message: string) => {
@@ -124,6 +125,36 @@ async function main(): Promise<void> {
process.exit(0);
}
if (command === "update") {
const updater = new GitUpdateService();
const update = await updater.check();
if (update.status === "unavailable") {
io.err(`Unable to check for updates: ${update.reason}`);
process.exit(1);
}
if (update.status === "up-to-date") {
io.out(
`XO is up to date on ${update.branch} (${update.currentCommit.slice(0, 7)}).`,
);
process.exit(0);
}
io.out(
`Update available on ${update.branch}: ${update.currentCommit.slice(0, 7)} -> ${update.targetCommit.slice(0, 7)}\n${update.summary}`,
);
if (!update.canApply) {
io.err(update.blockReason ?? "This update cannot be applied automatically.");
process.exit(1);
}
if (options["check"] === "true") {
process.exit(0);
}
updater.launchHelper(update, false);
io.out("The update will begin after this process exits.");
process.exit(0);
}
if (command === "mnemonic") {
try {
await handleMnemonicCommand({ io, paths }, subArgs, options);
@@ -284,6 +315,7 @@ Commands:
resource ${dim("Manage resources")}
settings ${dim("Manage persisted wallet settings")}
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
update ${dim("Install updates from the current Git branch")}
help ${dim("Show this help message")}
Options:
+14
View File
@@ -74,6 +74,7 @@ export class AppService extends EventEmitter<AppEventMap> {
onRemoved: () => void;
}
>();
private stopPromise: Promise<void> | null = null;
static async create(
seed: string,
@@ -388,4 +389,17 @@ export class AppService extends EventEmitter<AppEventMap> {
}),
);
}
/** Stop background services before the process exits or updates. */
async stop(): Promise<void> {
this.stopPromise ??= (async () => {
await Promise.allSettled([
this.rates.stop(),
this.electrum.stop?.() ?? Promise.resolve(),
...this.invitations.map((invitation) => invitation.stop()),
]);
await this.engine.stop();
})();
await this.stopPromise;
}
}
+9
View File
@@ -10,6 +10,7 @@ export interface ElectrumServiceConfig {
export abstract class BlockchainService {
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
abstract stop?(): Promise<void>;
}
/**
@@ -54,4 +55,12 @@ export class ElectrumService {
return false;
}
}
/** Close the lazily-created client, if it was used. */
async stop(): Promise<void> {
if (!this.clientPromise) return;
const client = await this.clientPromise;
this.clientPromise = undefined;
await client.disconnect(true);
}
}
+5
View File
@@ -279,6 +279,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}
}
/** Stop receiving remote invitation updates. */
async stop(): Promise<void> {
await this.syncServer.disconnect();
}
/**
* Handle an SSE message.
*
+74 -22
View File
@@ -3,12 +3,17 @@
* Uses Ink for terminal rendering with React components.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Box, Text, useApp } from 'ink';
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
import type { AppConfig } from '../app.js';
import type {
GitUpdateService,
UpdateCheck,
UpdateLaunchRequest,
} from '../updater/git-update-service.js';
import { colors, logoSmall } from './theme.js';
// Screen imports
@@ -18,13 +23,15 @@ import { TemplateListScreen } from './screens/TemplateList.js';
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
import { MessageDialog } from './components/Dialog.js';
import { ConfirmDialog, MessageDialog } from './components/Dialog.js';
/**
* Props for the App component.
*/
interface AppProps {
config: AppConfig;
updater: GitUpdateService;
onUpdateAndRestart: (request: UpdateLaunchRequest) => void;
}
/**
@@ -52,7 +59,7 @@ function Router(): React.ReactElement {
/**
* Status bar component shown at the bottom of the screen.
*/
function StatusBar(): React.ReactElement {
function StatusBar({ update }: { update: UpdateCheck | null }): React.ReactElement {
const { status } = useStatus();
const { screen, canGoBack } = useNavigation();
@@ -64,7 +71,9 @@ function StatusBar(): React.ReactElement {
justifyContent="space-between"
>
<Text color={colors.primary} bold>{logoSmall}</Text>
<Text color={colors.textMuted}>{status}</Text>
<Text color={update?.status === 'available' ? colors.warning : colors.textMuted}>
{update?.status === 'available' ? 'Update available · press U' : status}
</Text>
<Text color={colors.textMuted}>
{canGoBack ? 'ESC: Back | ' : ''}q: Quit
</Text>
@@ -80,9 +89,23 @@ function DialogOverlay(): React.ReactElement | null {
if (!dialog?.visible) return null;
const borderColor = dialog.type === 'error' ? colors.error :
dialog.type === 'confirm' ? colors.warning :
colors.info;
const contents = dialog.type === 'confirm' ? (
<ConfirmDialog
title={dialog.title ?? "Confirm"}
message={dialog.message}
onConfirm={dialog.onConfirm ?? (() => {})}
onCancel={dialog.onCancel ?? (() => {})}
confirmLabel={dialog.confirmLabel}
cancelLabel={dialog.cancelLabel}
/>
) : (
<MessageDialog
title={dialog.type === 'error' ? 'Error' : 'Info'}
message={dialog.message}
onClose={dialog.onCancel ?? (() => {})}
type={dialog.type}
/>
);
return (
<Box
@@ -93,14 +116,7 @@ function DialogOverlay(): React.ReactElement | null {
width="100%"
height="100%"
>
<MessageDialog
title={dialog.type === 'error' ? '✗ Error' :
dialog.type === 'confirm' ? '? Confirm' :
' Info'}
message={dialog.message}
onClose={dialog.onCancel ?? (() => {})}
type={dialog.type as 'error' | 'info' | 'success'}
/>
{contents}
</Box>
);
}
@@ -108,18 +124,54 @@ function DialogOverlay(): React.ReactElement | null {
/**
* Main content wrapper with global keybindings.
*/
function MainContent(): React.ReactElement {
const { exit } = useApp();
function MainContent({
updater,
onUpdateAndRestart,
}: Pick<AppProps, 'updater' | 'onUpdateAndRestart'>): React.ReactElement {
const { goBack, canGoBack } = useNavigation();
const { screen } = useNavigation();
const appContext = useAppContext();
const [update, setUpdate] = useState<UpdateCheck | null>(null);
useEffect(() => {
let active = true;
updater.check().then((result) => {
if (active) setUpdate(result);
}).catch(() => {
// Update checks are advisory and must never prevent wallet use.
});
return () => {
active = false;
};
}, [updater]);
// Global keybindings — auto-blocked when any dialog/overlay is capturing input.
useBlockableInput((input, key) => {
// Quit on Ctrl+C
if (key.ctrl && input === 'c') {
appContext.exit();
exit();
void appContext.exit();
return;
}
if ((input === 'u' || input === 'U') && update?.status === 'available') {
if (!update.canApply) {
appContext.showError(update.blockReason ?? 'This update cannot be applied automatically.');
return;
}
void appContext.confirm(
`Branch: ${update.branch}\nInstalled: ${update.currentCommit.slice(0, 7)}\nAvailable: ${update.targetCommit.slice(0, 7)}\n\n${update.summary}`,
{
title: 'XO update available',
confirmLabel: 'Update and restart',
cancelLabel: 'Later',
},
).then(async (confirmed) => {
if (!confirmed) return;
onUpdateAndRestart(update);
await appContext.exit();
});
return;
}
// Go back on Escape
@@ -144,7 +196,7 @@ function MainContent(): React.ReactElement {
</Box>
{/* Status bar */}
<StatusBar />
<StatusBar update={update} />
{/* Dialog overlay */}
<DialogOverlay />
@@ -156,7 +208,7 @@ function MainContent(): React.ReactElement {
* Main App component.
* Sets up providers and renders the main content.
*/
export function App({ config }: AppProps): React.ReactElement {
export function App({ config, updater, onUpdateAndRestart }: AppProps): React.ReactElement {
const { exit } = useApp();
// Cleanup will be handled by React when components unmount
@@ -171,7 +223,7 @@ export function App({ config }: AppProps): React.ReactElement {
>
<InputLayerProvider>
<NavigationProvider initialScreen="seed-input">
<MainContent />
<MainContent updater={updater} onUpdateAndRestart={onUpdateAndRestart} />
</NavigationProvider>
</InputLayerProvider>
</AppProvider>
+15 -2
View File
@@ -109,12 +109,20 @@ export function AppProvider({
/**
* Show a confirmation dialog and wait for user response.
*/
const confirm = useCallback((message: string): Promise<boolean> => {
const confirm = useCallback((
message: string,
options?: {
title?: string;
confirmLabel?: string;
cancelLabel?: string;
},
): Promise<boolean> => {
return new Promise((resolve) => {
setDialog({
visible: true,
type: 'confirm',
message,
...options,
onConfirm: () => {
setDialog(null);
resolve(true);
@@ -134,6 +142,11 @@ export function AppProvider({
setStatusState(message);
}, []);
const exit = useCallback(async () => {
await appService?.stop();
onExit();
}, [appService, onExit]);
const appValue: AppContextType = {
appService,
initializeWallet,
@@ -142,7 +155,7 @@ export function AppProvider({
showError,
showInfo,
confirm,
exit: onExit,
exit,
setStatus,
};
+13 -2
View File
@@ -67,9 +67,16 @@ export interface AppContextType {
/** Show an info message dialog */
showInfo: (message: string) => void;
/** Show a confirmation dialog */
confirm: (message: string) => Promise<boolean>;
confirm: (
message: string,
options?: {
title?: string;
confirmLabel?: string;
cancelLabel?: string;
},
) => Promise<boolean>;
/** Exit the application */
exit: () => void;
exit: () => Promise<void>;
/** Update status bar message */
setStatus: (message: string) => void;
}
@@ -84,6 +91,10 @@ export interface DialogState {
type: "error" | "info" | "confirm";
/** Dialog message */
message: string;
/** Optional confirmation presentation labels. */
title?: string;
confirmLabel?: string;
cancelLabel?: string;
/** Callback for confirm dialog */
onConfirm?: () => void;
/** Callback for cancel/dismiss */
+218
View File
@@ -0,0 +1,218 @@
import { spawn, type SpawnOptionsWithoutStdio } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const UPDATE_REMOTE = "origin";
const COMMAND_TIMEOUT_MS = 10_000;
export interface CommandResult {
code: number;
stdout: string;
stderr: string;
}
export type CommandRunner = (
command: string,
args: string[],
options: SpawnOptionsWithoutStdio & { timeout?: number },
) => Promise<CommandResult>;
export type UpdateCheck =
| {
status: "up-to-date";
branch: string;
currentCommit: string;
}
| {
status: "available";
branch: string;
currentCommit: string;
targetCommit: string;
summary: string;
canApply: boolean;
blockReason?: string;
}
| {
status: "unavailable";
reason: string;
};
export interface UpdateLaunchRequest {
branch: string;
currentCommit: string;
targetCommit: string;
}
export function getProjectRoot(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
}
export const runCommand: CommandRunner = (command, args, options) =>
new Promise((resolveResult) => {
const child = spawn(command, args, {
...options,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
resolveResult({ code: 1, stdout, stderr: error.message });
});
child.on("close", (code) => {
resolveResult({ code: code ?? 1, stdout, stderr });
});
});
export class GitUpdateService {
constructor(
public readonly repoRoot: string = getProjectRoot(),
private readonly runner: CommandRunner = runCommand,
) {}
async check(): Promise<UpdateCheck> {
if (!existsSync(resolve(this.repoRoot, ".git"))) {
return {
status: "unavailable",
reason: "This installation is not a Git checkout.",
};
}
const branchResult = await this.git(["branch", "--show-current"]);
const branch = branchResult.stdout.trim();
if (branchResult.code !== 0 || branch.length === 0) {
return {
status: "unavailable",
reason: "Updates require a checked-out branch (not detached HEAD).",
};
}
const fetchResult = await this.git([
"fetch",
"--quiet",
UPDATE_REMOTE,
`+refs/heads/${branch}:refs/remotes/${UPDATE_REMOTE}/${branch}`,
]);
if (fetchResult.code !== 0) {
return {
status: "unavailable",
reason: this.commandFailure("Could not fetch updates", fetchResult),
};
}
const [headResult, targetResult, dirtyResult] = await Promise.all([
this.git(["rev-parse", "HEAD"]),
this.git(["rev-parse", `refs/remotes/${UPDATE_REMOTE}/${branch}`]),
this.git(["status", "--porcelain"]),
]);
if (headResult.code !== 0 || targetResult.code !== 0) {
return {
status: "unavailable",
reason: `The branch ${branch} is not available on ${UPDATE_REMOTE}.`,
};
}
const currentCommit = headResult.stdout.trim();
const targetCommit = targetResult.stdout.trim();
if (currentCommit === targetCommit) {
return { status: "up-to-date", branch, currentCommit };
}
const ancestry = await this.git([
"merge-base",
"--is-ancestor",
currentCommit,
targetCommit,
]);
if (ancestry.code !== 0) {
const remoteIsAncestor = await this.git([
"merge-base",
"--is-ancestor",
targetCommit,
currentCommit,
]);
if (remoteIsAncestor.code === 0) {
return { status: "up-to-date", branch, currentCommit };
}
return {
status: "unavailable",
reason: `Local branch ${branch} has diverged from ${UPDATE_REMOTE}/${branch}; update it manually.`,
};
}
const summaryResult = await this.git([
"log",
"-1",
"--format=%s",
targetCommit,
]);
const isDirty = dirtyResult.code !== 0 || dirtyResult.stdout.trim() !== "";
return {
status: "available",
branch,
currentCommit,
targetCommit,
summary: summaryResult.stdout.trim() || "New version available",
canApply: !isDirty,
...(isDirty
? {
blockReason:
"The Git working tree has local changes. Commit or stash them before updating.",
}
: {}),
};
}
launchHelper(request: UpdateLaunchRequest, restart: boolean): void {
const helperPath = resolve(this.repoRoot, "dist/updater/update-helper.js");
if (!existsSync(helperPath)) {
throw new Error(
"The updater helper is missing. Run `npm run build` and try again.",
);
}
const args = [
helperPath,
"--repo",
this.repoRoot,
"--branch",
request.branch,
"--from",
request.currentCommit,
"--to",
request.targetCommit,
"--parent",
String(process.pid),
];
if (restart) args.push("--restart");
const child = spawn(process.execPath, args, {
cwd: this.repoRoot,
detached: true,
stdio: "inherit",
});
child.unref();
}
private git(args: string[]): Promise<CommandResult> {
return this.runner("git", args, {
cwd: this.repoRoot,
timeout: COMMAND_TIMEOUT_MS,
});
}
private commandFailure(prefix: string, result: CommandResult): string {
const detail = result.stderr.trim() || result.stdout.trim();
return detail ? `${prefix}: ${detail}` : prefix;
}
}
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { openSync, closeSync, readFileSync, unlinkSync } from "node:fs";
import { resolve } from "node:path";
interface HelperOptions {
repo: string;
branch: string;
from: string;
to: string;
parent: number;
restart: boolean;
}
function parseOptions(args: string[]): HelperOptions {
const values = new Map<string, string>();
let restart = false;
for (let index = 0; index < args.length; index += 1) {
const key = args[index];
if (key === "--restart") {
restart = true;
continue;
}
const value = args[index + 1];
if (!key?.startsWith("--") || value === undefined) {
throw new Error("Invalid updater helper arguments.");
}
values.set(key, value);
index += 1;
}
const repo = values.get("--repo");
const branch = values.get("--branch");
const from = values.get("--from");
const to = values.get("--to");
const parent = Number(values.get("--parent"));
if (!repo || !branch || !from || !to || !Number.isInteger(parent)) {
throw new Error("Missing updater helper arguments.");
}
if (!/^[0-9a-f]{40,64}$/u.test(from) || !/^[0-9a-f]{40,64}$/u.test(to)) {
throw new Error("Invalid updater commit identifiers.");
}
return { repo, branch, from, to, parent, restart };
}
function run(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { cwd, stdio: "inherit" });
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolveRun();
else reject(new Error(`${command} exited with status ${code ?? 1}`));
});
});
}
function capture(
command: string,
args: string[],
cwd: string,
): Promise<string> {
return new Promise((resolveCapture, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolveCapture(stdout.trim());
else
reject(
new Error(
stderr.trim() || `${command} exited with status ${code ?? 1}`,
),
);
});
});
}
async function waitForExit(pid: number): Promise<void> {
while (true) {
try {
process.kill(pid, 0);
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
} catch {
return;
}
}
}
async function main(): Promise<void> {
const options = parseOptions(process.argv.slice(2));
await waitForExit(options.parent);
const packageJson = JSON.parse(
readFileSync(resolve(options.repo, "package.json"), "utf8"),
) as { name?: unknown };
if (packageJson.name !== "@xo-cash/cli") {
throw new Error("Refusing to update an unexpected repository.");
}
const gitLockPath = await capture(
"git",
["rev-parse", "--git-path", "xo-update.lock"],
options.repo,
);
const lockPath = resolve(options.repo, gitLockPath);
let lock: number | undefined;
try {
lock = openSync(lockPath, "wx");
} catch {
throw new Error("Another XO update is already running.");
}
let updated = false;
try {
console.log(`Updating ${options.branch} to ${options.to.slice(0, 7)}`);
const [branch, head, target, status] = await Promise.all([
capture("git", ["branch", "--show-current"], options.repo),
capture("git", ["rev-parse", "HEAD"], options.repo),
capture(
"git",
["rev-parse", `refs/remotes/origin/${options.branch}`],
options.repo,
),
capture("git", ["status", "--porcelain"], options.repo),
]);
if (
branch !== options.branch ||
head !== options.from ||
target !== options.to
) {
throw new Error(
"The checkout changed after the update check; please try again.",
);
}
if (status !== "") {
throw new Error("The checkout has local changes; update cancelled.");
}
await run("git", ["merge", "--ff-only", options.to], options.repo);
updated = true;
await run("npm", ["ci"], options.repo);
await run("npm", ["run", "build"], options.repo);
console.log("XO was updated successfully.");
} catch (error) {
console.error("Update failed; restoring the previous revision.");
if (updated) {
try {
await run("git", ["reset", "--hard", options.from], options.repo);
await run("npm", ["ci"], options.repo);
await run("npm", ["run", "build"], options.repo);
console.error("The previous revision was restored.");
} catch (rollbackError) {
console.error("Automatic rollback also failed:", rollbackError);
}
}
throw error;
} finally {
if (lock !== undefined) closeSync(lock);
try {
unlinkSync(lockPath);
} catch {
// The lock is best-effort cleanup; a missing lock needs no action.
}
}
if (options.restart) {
const entryPath = resolve(options.repo, "dist/index.js");
const child = spawn(process.execPath, [entryPath], {
cwd: options.repo,
detached: true,
stdio: "inherit",
});
child.unref();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});