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 { 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,7 @@ export const COMMAND_TREE = {
|
||||
receive: [],
|
||||
resource: RESOURCE_SUBS,
|
||||
settings: SETTINGS_SUBS,
|
||||
update: [],
|
||||
help: [],
|
||||
completions: COMPLETIONS_SUBS,
|
||||
} as const;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user