add auto-updater
This commit is contained in:
+16
-3
@@ -8,6 +8,10 @@ import React from "react";
|
|||||||
import { render, type Instance } from "ink";
|
import { render, type Instance } from "ink";
|
||||||
import { App as AppComponent } from "./tui/App.js";
|
import { App as AppComponent } from "./tui/App.js";
|
||||||
import { getDataDir } from "./utils/paths.js";
|
import { getDataDir } from "./utils/paths.js";
|
||||||
|
import {
|
||||||
|
GitUpdateService,
|
||||||
|
type UpdateLaunchRequest,
|
||||||
|
} from "./updater/git-update-service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for the CLI application.
|
* Configuration options for the CLI application.
|
||||||
@@ -33,13 +37,16 @@ export class App {
|
|||||||
|
|
||||||
/** Application configuration */
|
/** Application configuration */
|
||||||
private config: AppConfig;
|
private config: AppConfig;
|
||||||
|
private readonly updater: GitUpdateService;
|
||||||
|
private updateRequest: UpdateLaunchRequest | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new App instance.
|
* Creates a new App instance.
|
||||||
* @param config - Application configuration options
|
* @param config - Application configuration options
|
||||||
*/
|
*/
|
||||||
private constructor(config: AppConfig) {
|
private constructor(config: AppConfig, updater: GitUpdateService) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.updater = updater;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,7 +68,7 @@ export class App {
|
|||||||
|
|
||||||
console.log("Full config:", fullConfig);
|
console.log("Full config:", fullConfig);
|
||||||
|
|
||||||
const app = new App(fullConfig);
|
const app = new App(fullConfig, new GitUpdateService());
|
||||||
await app.start();
|
await app.start();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -76,13 +83,19 @@ export class App {
|
|||||||
this.inkInstance = render(
|
this.inkInstance = render(
|
||||||
React.createElement(AppComponent, {
|
React.createElement(AppComponent, {
|
||||||
config: this.config,
|
config: this.config,
|
||||||
|
updater: this.updater,
|
||||||
|
onUpdateAndRestart: (request: UpdateLaunchRequest) => {
|
||||||
|
this.updateRequest = request;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for the app to exit
|
// Wait for the app to exit
|
||||||
await this.inkInstance.waitUntilExit();
|
await this.inkInstance.waitUntilExit();
|
||||||
|
|
||||||
process.exit(0);
|
if (this.updateRequest) {
|
||||||
|
this.updater.launchHelper(this.updateRequest, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const COMMAND_TREE = {
|
|||||||
receive: [],
|
receive: [],
|
||||||
resource: RESOURCE_SUBS,
|
resource: RESOURCE_SUBS,
|
||||||
settings: SETTINGS_SUBS,
|
settings: SETTINGS_SUBS,
|
||||||
|
update: [],
|
||||||
help: [],
|
help: [],
|
||||||
completions: COMPLETIONS_SUBS,
|
completions: COMPLETIONS_SUBS,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
} from "./commands/index.js";
|
} from "./commands/index.js";
|
||||||
|
|
||||||
import { handleCompletionsCommand } from "./autocomplete/completions.js";
|
import { handleCompletionsCommand } from "./autocomplete/completions.js";
|
||||||
|
import { GitUpdateService } from "../updater/git-update-service.js";
|
||||||
|
|
||||||
const createCommandIO = (verbose: boolean): CommandIO => ({
|
const createCommandIO = (verbose: boolean): CommandIO => ({
|
||||||
out: (message: string) => {
|
out: (message: string) => {
|
||||||
@@ -124,6 +125,36 @@ async function main(): Promise<void> {
|
|||||||
process.exit(0);
|
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") {
|
if (command === "mnemonic") {
|
||||||
try {
|
try {
|
||||||
await handleMnemonicCommand({ io, paths }, subArgs, options);
|
await handleMnemonicCommand({ io, paths }, subArgs, options);
|
||||||
@@ -284,6 +315,7 @@ Commands:
|
|||||||
resource ${dim("Manage resources")}
|
resource ${dim("Manage resources")}
|
||||||
settings ${dim("Manage persisted wallet settings")}
|
settings ${dim("Manage persisted wallet settings")}
|
||||||
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
||||||
|
update ${dim("Install updates from the current Git branch")}
|
||||||
help ${dim("Show this help message")}
|
help ${dim("Show this help message")}
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
onRemoved: () => void;
|
onRemoved: () => void;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
private stopPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
static async create(
|
static async create(
|
||||||
seed: string,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ElectrumServiceConfig {
|
|||||||
|
|
||||||
export abstract class BlockchainService {
|
export abstract class BlockchainService {
|
||||||
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
|
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
|
||||||
|
abstract stop?(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,4 +55,12 @@ export class ElectrumService {
|
|||||||
return false;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Handle an SSE message.
|
||||||
*
|
*
|
||||||
|
|||||||
+74
-22
@@ -3,12 +3,17 @@
|
|||||||
* Uses Ink for terminal rendering with React components.
|
* 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 { Box, Text, useApp } from 'ink';
|
||||||
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||||||
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||||||
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
|
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
|
||||||
import type { AppConfig } from '../app.js';
|
import type { AppConfig } from '../app.js';
|
||||||
|
import type {
|
||||||
|
GitUpdateService,
|
||||||
|
UpdateCheck,
|
||||||
|
UpdateLaunchRequest,
|
||||||
|
} from '../updater/git-update-service.js';
|
||||||
import { colors, logoSmall } from './theme.js';
|
import { colors, logoSmall } from './theme.js';
|
||||||
|
|
||||||
// Screen imports
|
// Screen imports
|
||||||
@@ -18,13 +23,15 @@ import { TemplateListScreen } from './screens/TemplateList.js';
|
|||||||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||||
import { InvitationScreen } from './screens/invitations/InvitationScreen.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.
|
* Props for the App component.
|
||||||
*/
|
*/
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
config: AppConfig;
|
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.
|
* 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 { status } = useStatus();
|
||||||
const { screen, canGoBack } = useNavigation();
|
const { screen, canGoBack } = useNavigation();
|
||||||
|
|
||||||
@@ -64,7 +71,9 @@ function StatusBar(): React.ReactElement {
|
|||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold>{logoSmall}</Text>
|
<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}>
|
<Text color={colors.textMuted}>
|
||||||
{canGoBack ? 'ESC: Back | ' : ''}q: Quit
|
{canGoBack ? 'ESC: Back | ' : ''}q: Quit
|
||||||
</Text>
|
</Text>
|
||||||
@@ -80,9 +89,23 @@ function DialogOverlay(): React.ReactElement | null {
|
|||||||
|
|
||||||
if (!dialog?.visible) return null;
|
if (!dialog?.visible) return null;
|
||||||
|
|
||||||
const borderColor = dialog.type === 'error' ? colors.error :
|
const contents = dialog.type === 'confirm' ? (
|
||||||
dialog.type === 'confirm' ? colors.warning :
|
<ConfirmDialog
|
||||||
colors.info;
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -93,14 +116,7 @@ function DialogOverlay(): React.ReactElement | null {
|
|||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
>
|
>
|
||||||
<MessageDialog
|
{contents}
|
||||||
title={dialog.type === 'error' ? '✗ Error' :
|
|
||||||
dialog.type === 'confirm' ? '? Confirm' :
|
|
||||||
'ℹ Info'}
|
|
||||||
message={dialog.message}
|
|
||||||
onClose={dialog.onCancel ?? (() => {})}
|
|
||||||
type={dialog.type as 'error' | 'info' | 'success'}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,18 +124,54 @@ function DialogOverlay(): React.ReactElement | null {
|
|||||||
/**
|
/**
|
||||||
* Main content wrapper with global keybindings.
|
* Main content wrapper with global keybindings.
|
||||||
*/
|
*/
|
||||||
function MainContent(): React.ReactElement {
|
function MainContent({
|
||||||
const { exit } = useApp();
|
updater,
|
||||||
|
onUpdateAndRestart,
|
||||||
|
}: Pick<AppProps, 'updater' | 'onUpdateAndRestart'>): React.ReactElement {
|
||||||
const { goBack, canGoBack } = useNavigation();
|
const { goBack, canGoBack } = useNavigation();
|
||||||
const { screen } = useNavigation();
|
const { screen } = useNavigation();
|
||||||
const appContext = useAppContext();
|
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.
|
// Global keybindings — auto-blocked when any dialog/overlay is capturing input.
|
||||||
useBlockableInput((input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
// Quit on Ctrl+C
|
// Quit on Ctrl+C
|
||||||
if (key.ctrl && input === 'c') {
|
if (key.ctrl && input === 'c') {
|
||||||
appContext.exit();
|
void appContext.exit();
|
||||||
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
|
// Go back on Escape
|
||||||
@@ -144,7 +196,7 @@ function MainContent(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
<StatusBar />
|
<StatusBar update={update} />
|
||||||
|
|
||||||
{/* Dialog overlay */}
|
{/* Dialog overlay */}
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
@@ -156,7 +208,7 @@ function MainContent(): React.ReactElement {
|
|||||||
* Main App component.
|
* Main App component.
|
||||||
* Sets up providers and renders the main content.
|
* 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();
|
const { exit } = useApp();
|
||||||
|
|
||||||
// Cleanup will be handled by React when components unmount
|
// Cleanup will be handled by React when components unmount
|
||||||
@@ -171,7 +223,7 @@ export function App({ config }: AppProps): React.ReactElement {
|
|||||||
>
|
>
|
||||||
<InputLayerProvider>
|
<InputLayerProvider>
|
||||||
<NavigationProvider initialScreen="seed-input">
|
<NavigationProvider initialScreen="seed-input">
|
||||||
<MainContent />
|
<MainContent updater={updater} onUpdateAndRestart={onUpdateAndRestart} />
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</InputLayerProvider>
|
</InputLayerProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
|
|||||||
@@ -109,12 +109,20 @@ export function AppProvider({
|
|||||||
/**
|
/**
|
||||||
* Show a confirmation dialog and wait for user response.
|
* 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) => {
|
return new Promise((resolve) => {
|
||||||
setDialog({
|
setDialog({
|
||||||
visible: true,
|
visible: true,
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
message,
|
message,
|
||||||
|
...options,
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
setDialog(null);
|
setDialog(null);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@@ -134,6 +142,11 @@ export function AppProvider({
|
|||||||
setStatusState(message);
|
setStatusState(message);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const exit = useCallback(async () => {
|
||||||
|
await appService?.stop();
|
||||||
|
onExit();
|
||||||
|
}, [appService, onExit]);
|
||||||
|
|
||||||
const appValue: AppContextType = {
|
const appValue: AppContextType = {
|
||||||
appService,
|
appService,
|
||||||
initializeWallet,
|
initializeWallet,
|
||||||
@@ -142,7 +155,7 @@ export function AppProvider({
|
|||||||
showError,
|
showError,
|
||||||
showInfo,
|
showInfo,
|
||||||
confirm,
|
confirm,
|
||||||
exit: onExit,
|
exit,
|
||||||
setStatus,
|
setStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -67,9 +67,16 @@ export interface AppContextType {
|
|||||||
/** Show an info message dialog */
|
/** Show an info message dialog */
|
||||||
showInfo: (message: string) => void;
|
showInfo: (message: string) => void;
|
||||||
/** Show a confirmation dialog */
|
/** 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 the application */
|
||||||
exit: () => void;
|
exit: () => Promise<void>;
|
||||||
/** Update status bar message */
|
/** Update status bar message */
|
||||||
setStatus: (message: string) => void;
|
setStatus: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -84,6 +91,10 @@ export interface DialogState {
|
|||||||
type: "error" | "info" | "confirm";
|
type: "error" | "info" | "confirm";
|
||||||
/** Dialog message */
|
/** Dialog message */
|
||||||
message: string;
|
message: string;
|
||||||
|
/** Optional confirmation presentation labels. */
|
||||||
|
title?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
/** Callback for confirm dialog */
|
/** Callback for confirm dialog */
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
/** Callback for cancel/dismiss */
|
/** Callback for cancel/dismiss */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
GitUpdateService,
|
||||||
|
type CommandResult,
|
||||||
|
type CommandRunner,
|
||||||
|
} from "../../src/updater/git-update-service.js";
|
||||||
|
|
||||||
|
function result(stdout = "", code = 0, stderr = ""): CommandResult {
|
||||||
|
return { code, stdout, stderr };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRunner(responses: Map<string, CommandResult>): CommandRunner {
|
||||||
|
return async (command, args) =>
|
||||||
|
responses.get(`${command} ${args.join(" ")}`) ??
|
||||||
|
result("", 1, "unexpected command");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GitUpdateService", () => {
|
||||||
|
test("checks the currently checked-out branch", async () => {
|
||||||
|
const root = path.resolve(".");
|
||||||
|
const runner = fakeRunner(
|
||||||
|
new Map([
|
||||||
|
["git branch --show-current", result("feature/demo\n")],
|
||||||
|
[
|
||||||
|
"git fetch --quiet origin +refs/heads/feature/demo:refs/remotes/origin/feature/demo",
|
||||||
|
result(),
|
||||||
|
],
|
||||||
|
["git rev-parse HEAD", result("aaa\n")],
|
||||||
|
["git rev-parse refs/remotes/origin/feature/demo", result("bbb\n")],
|
||||||
|
["git status --porcelain", result()],
|
||||||
|
["git merge-base --is-ancestor aaa bbb", result()],
|
||||||
|
["git log -1 --format=%s bbb", result("Useful update\n")],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = await new GitUpdateService(root, runner).check();
|
||||||
|
|
||||||
|
expect(update).toMatchObject({
|
||||||
|
status: "available",
|
||||||
|
branch: "feature/demo",
|
||||||
|
currentCommit: "aaa",
|
||||||
|
targetCommit: "bbb",
|
||||||
|
canApply: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports an available update but blocks applying over local changes", async () => {
|
||||||
|
const root = path.resolve(".");
|
||||||
|
const runner = fakeRunner(
|
||||||
|
new Map([
|
||||||
|
["git branch --show-current", result("main\n")],
|
||||||
|
[
|
||||||
|
"git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main",
|
||||||
|
result(),
|
||||||
|
],
|
||||||
|
["git rev-parse HEAD", result("aaa\n")],
|
||||||
|
["git rev-parse refs/remotes/origin/main", result("bbb\n")],
|
||||||
|
["git status --porcelain", result(" M src/example.ts\n")],
|
||||||
|
["git merge-base --is-ancestor aaa bbb", result()],
|
||||||
|
["git log -1 --format=%s bbb", result("Update\n")],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = await new GitUpdateService(root, runner).check();
|
||||||
|
|
||||||
|
expect(update).toMatchObject({ status: "available", canApply: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not replace commits when the local branch is ahead", async () => {
|
||||||
|
const root = path.resolve(".");
|
||||||
|
const runner = fakeRunner(
|
||||||
|
new Map([
|
||||||
|
["git branch --show-current", result("main\n")],
|
||||||
|
[
|
||||||
|
"git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main",
|
||||||
|
result(),
|
||||||
|
],
|
||||||
|
["git rev-parse HEAD", result("bbb\n")],
|
||||||
|
["git rev-parse refs/remotes/origin/main", result("aaa\n")],
|
||||||
|
["git status --porcelain", result()],
|
||||||
|
["git merge-base --is-ancestor bbb aaa", result("", 1)],
|
||||||
|
["git merge-base --is-ancestor aaa bbb", result()],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new GitUpdateService(root, runner).check(),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
status: "up-to-date",
|
||||||
|
branch: "main",
|
||||||
|
currentCommit: "bbb",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user