Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/update-progress-stall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@noormdev/cli": patch
---

`fix(update):` the binary self-update no longer hangs indefinitely and now shows download progress. `noorm update` streamed the ~70MB release binary with a bare `fetch()` — no timeout, so a stalled connection hung forever with no error and no feedback, indistinguishable from a freeze. It now streams to disk with a live `Downloading X / Y MB (Z%)` readout, aborts with a clear error if the transfer stalls (no bytes for 30s), and stages the replacement in the target's own directory so the atomic swap can't fail with a cross-filesystem `EXDEV` (e.g. `os.tmpdir()` on a different volume than `~/.local/bin`).
70 changes: 68 additions & 2 deletions src/cli/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@
import { defineCommand } from 'citty';
import { attempt } from '@logosdx/utils';

import { observer } from '../core/observer.js';
import { checkForUpdate, getCurrentVersion } from '../core/update/checker.js';
import { installUpdate } from '../core/update/updater.js';
import { outputError, outputResult, sharedArgs } from './_utils.js';

/** Render a byte count as MB with one decimal (e.g. 41.2). */
function toMb(bytes: number): string {

return (bytes / 1024 / 1024).toFixed(1);

}

const updateCommand = defineCommand({
meta: {
name: 'update',
Expand Down Expand Up @@ -70,9 +78,67 @@ const updateCommand = defineCommand({
}

process.stdout.write(`Update available: ${currentVersion} → ${checkResult.latestVersion}\n`);
process.stdout.write('Installing...\n');

const result = await installUpdate(checkResult.latestVersion);
// Render a live progress line for the binary download. TTY output uses a
// carriage return to update in place; when stdout is piped (e.g. CI, JSON
// mode) there's no cursor to rewind, so fall back to periodic newlines.
const isTty = Boolean(process.stdout.isTTY) && !args.json;

const onProgress = ({ received, total }: { received: number; total: number }) => {

const pct = total > 0 ? ` (${Math.floor((received / total) * 100)}%)` : '';
const of = total > 0 ? ` / ${toMb(total)}` : '';
const line = `Downloading ${toMb(received)}${of} MB${pct}`;

if (isTty) {

process.stdout.write(`\r${line} `);

}

};

if (!args.json) {

process.stdout.write(isTty ? 'Installing...\n' : 'Installing (downloading binary)...\n');

}

observer.on('update:progress', onProgress);

const [result, installErr] = await attempt(() => installUpdate(checkResult.latestVersion));

observer.off('update:progress', onProgress);

if (isTty) {

process.stdout.write('\n');

}

if (installErr || !result) {

// installUpdate resolves with a result even on failure; a throw here
// is unexpected (e.g. abort wiring) — surface it rather than hang.
const errorMsg = installErr?.message ?? 'Update failed';

process.stderr.write(`Update failed: ${errorMsg}\n`);

if (args.json) {

outputResult(args, {
currentVersion,
latestVersion: checkResult.latestVersion,
updateAvailable: true,
installed: false,
error: errorMsg,
}, '');

}

process.exit(1);

}

if (result.success) {

Expand Down
1 change: 1 addition & 0 deletions src/core/update/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export interface UpdateEvents {

// Update installation
'update:installing': { version: string };
'update:progress': { version: string; received: number; total: number };
'update:complete': { previousVersion: string; newVersion: string };
'update:failed': { version: string; error: string };
'update:skipped': { version: string; reason: 'user-denied' | 'user-deferred' };
Expand Down
163 changes: 112 additions & 51 deletions src/core/update/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
* ```
*/
import { spawn } from 'child_process';
import { writeFile, rename, unlink, chmod } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { open, rename, unlink, chmod } from 'fs/promises';

import { attempt } from '@logosdx/utils';

Expand All @@ -32,6 +30,16 @@ import type { UpdateResult } from './types.js';
/** NPM package name to install */
const PACKAGE_NAME = '@noormdev/cli';

/**
* Abort the download if no bytes arrive for this long. A bare `fetch()` has no
* timeout, so a stalled connection would otherwise hang the process forever
* with no error to surface — this converts a silent hang into a real failure.
*/
const DOWNLOAD_STALL_MS = 30_000;

/** Emit a progress event at most once per this many bytes, to avoid spamming. */
const PROGRESS_EMIT_BYTES = 512 * 1024;

// =============================================================================
// npm Updater
// =============================================================================
Expand Down Expand Up @@ -124,74 +132,134 @@ function installViaNpm(version: string, previousVersion: string): Promise<Update
// =============================================================================

/**
* Install update by downloading a replacement binary from GitHub releases.
* Stream a URL to a file, emitting `update:progress` as bytes arrive and
* aborting if the transfer stalls (no data for `stallMs`).
*
* Streaming instead of buffering the whole ~70MB into memory lets the caller
* show real progress, and the stall-abort guarantees the download either
* completes, errors, or times out — never hangs indefinitely on a dead socket.
*
* Downloads the platform-appropriate binary, writes to a temp file,
* then atomically replaces the current executable.
* @param stallMs - Abort if no chunk arrives within this window. Overridable
* for tests; production uses `DOWNLOAD_STALL_MS`.
* @throws Error on a non-OK response, an empty body, a stall, or a write failure.
*/
async function installViaBinary(version: string, previousVersion: string): Promise<UpdateResult> {
export async function downloadToFile(
url: string,
destPath: string,
version: string,
stallMs: number = DOWNLOAD_STALL_MS,
): Promise<void> {

const url = getBinaryDownloadUrl(version);
const controller = new AbortController();

let stallTimer: ReturnType<typeof setTimeout> | undefined;

const [response, fetchErr] = await attempt(() => fetch(url));
const armStall = () => {

if (fetchErr || !response || !response.ok) {
clearTimeout(stallTimer);
stallTimer = setTimeout(
() => controller.abort(new Error(`download stalled — no data for ${stallMs / 1000}s`)),
stallMs,
);

};

const errorMsg = fetchErr?.message ?? `HTTP ${response?.status} downloading binary`;
const response = await fetch(url, { signal: controller.signal });

observer.emit('update:failed', { version, error: errorMsg });
if (!response.ok || !response.body) {

return {
success: false,
previousVersion,
newVersion: version,
error: errorMsg,
};
clearTimeout(stallTimer);

throw new Error(`HTTP ${response.status} downloading binary`);

}

const [buffer, readErr] = await attempt(() => response.arrayBuffer());
const total = Number(response.headers.get('content-length')) || 0;
let received = 0;
let sinceLastEmit = 0;

const handle = await open(destPath, 'w');

// A stall or write failure must still close the handle and clear the timer,
// so wrap the streaming loop and clean up in both outcomes.
const [, streamErr] = await attempt(async () => {

armStall();

for await (const chunk of response.body as AsyncIterable<Uint8Array>) {

await handle.write(chunk);

received += chunk.byteLength;
sinceLastEmit += chunk.byteLength;
armStall();

if (sinceLastEmit >= PROGRESS_EMIT_BYTES) {

sinceLastEmit = 0;
observer.emit('update:progress', { version, received, total });

}

}

// Final tick so consumers see 100% even when the last chunk was small.
observer.emit('update:progress', { version, received, total: total || received });

if (readErr || !buffer) {
});

clearTimeout(stallTimer);
await handle.close();

const errorMsg = readErr?.message ?? 'Failed to read binary response';
if (streamErr) {

observer.emit('update:failed', { version, error: errorMsg });
// controller.abort(reason) surfaces the stall reason as the thrown
// error's `cause`; prefer it so the message says "stalled", not "aborted".
const cause = streamErr.cause;

return {
success: false,
previousVersion,
newVersion: version,
error: errorMsg,
};
throw cause instanceof Error ? cause : streamErr;

}

// Write to temp file, then atomic rename to current executable path
await chmod(destPath, 0o755);

}

/**
* Install update by downloading a replacement binary from GitHub releases.
*
* Streams the platform-appropriate binary to a temp file **in the target's own
* directory** — a cross-filesystem `rename` (e.g. `os.tmpdir()` on a different
* volume than `~/.local/bin`) throws `EXDEV`, so the swap must stage next to the
* destination — then atomically replaces the current executable.
*/
async function installViaBinary(version: string, previousVersion: string): Promise<UpdateResult> {

const url = getBinaryDownloadUrl(version);
const currentExe = process.execPath;
const tmpPath = join(tmpdir(), `noorm-update-${version}-${Date.now()}`);
const tmpPath = `${currentExe}.download`;

const [, writeErr] = await attempt(async () => {
const fail = (error: string): UpdateResult => {

await writeFile(tmpPath, Buffer.from(buffer));
await chmod(tmpPath, 0o755);
observer.emit('update:failed', { version, error });

});
return { success: false, previousVersion, newVersion: version, error };

if (writeErr) {
};

const [, downloadErr] = await attempt(() => downloadToFile(url, tmpPath, version));

observer.emit('update:failed', { version, error: writeErr.message });
if (downloadErr) {

return {
success: false,
previousVersion,
newVersion: version,
error: writeErr.message,
};
await attempt(() => unlink(tmpPath));

return fail(downloadErr.message);

}

// Atomic replace: rename old → backup, rename new → current, remove backup
// Atomic replace: rename old → backup, rename new → current, remove backup.
// All three paths share `currentExe`'s directory, so every rename is
// same-filesystem and atomic.
const backupPath = `${currentExe}.backup`;

const [, swapErr] = await attempt(async () => {
Expand All @@ -207,14 +275,7 @@ async function installViaBinary(version: string, previousVersion: string): Promi
await attempt(() => rename(backupPath, currentExe));
await attempt(() => unlink(tmpPath));

observer.emit('update:failed', { version, error: swapErr.message });

return {
success: false,
previousVersion,
newVersion: version,
error: swapErr.message,
};
return fail(swapErr.message);

}

Expand Down
Loading
Loading