diff --git a/README.md b/README.md
index 3d4b8f4..dcaf493 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,23 @@ A terminal UI for managing containers on macOS using the native `container` CLI.
## Installation
+### Download and Run (npm)
+
+```bash
+# Install globally
+npm install -g @rotorsoft/contui
+
+# Run the CLI
+contui
+```
+
+```bash
+# Or run without installing
+npx @rotorsoft/contui
+```
+
+### From Source (pnpm)
+
```bash
# Clone the repository
git clone https://github.com/Rotorsoft/contui.git
diff --git a/jest.config.js b/jest.config.js
index 734dcb0..3e90fa5 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -14,7 +14,7 @@ export default {
tsconfig: {
jsx: "react-jsx",
module: "ESNext",
- moduleResolution: "Node",
+ moduleResolution: "NodeNext",
},
},
],
diff --git a/package.json b/package.json
index e61cf07..8adc30e 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,9 @@
"bin": {
"contui": "dist/index.js"
},
+ "files": [
+ "dist"
+ ],
"publishConfig": {
"access": "public"
},
diff --git a/progress.txt b/progress.txt
index fdcfcf4..6ad9eab 100644
--- a/progress.txt
+++ b/progress.txt
@@ -28,3 +28,9 @@ Each entry documents: date, feature, decisions, files changed, tests, and concer
- Changes: Updated package name to @rotorsoft/contui, added publishConfig.access=public for scoped public publishing, added npm version badge to README
- Decisions: Scoped packages require explicit public access; semantic-release npm plugin handles scoped packages without config changes
- Issues: None - CI npm token must have @rotorsoft scope permissions
+
+[2026-02-05] #9 fix: Add npm-only dist packaging and release update UX
+- Files: package.json, jest.config.js, README.md, src/components/App.tsx, src/components/StatusBar.tsx, src/hooks/useReleaseCheck.ts, src/utils/app-version.ts, src/types/ink-testing-library.d.ts, src/__tests__/release-check.test.ts, src/__tests__/status-bar.test.tsx
+- Changes: Restricted npm package contents to dist, added version display and async release check in the UI, and documented npm install/run steps with supporting tests.
+- Decisions: Pulled current version from env/package.json and used npm registry latest endpoint for update checks.
+- Issues: Release workflow may still need updates to publish; requires approval to modify .github/workflows.
diff --git a/src/__tests__/release-check.test.ts b/src/__tests__/release-check.test.ts
new file mode 100644
index 0000000..2e300ee
--- /dev/null
+++ b/src/__tests__/release-check.test.ts
@@ -0,0 +1,45 @@
+import { fetchLatestVersion, isNewerVersion } from "../hooks/useReleaseCheck.js";
+
+describe("Release check", () => {
+ describe("isNewerVersion", () => {
+ it("should detect newer semantic versions", () => {
+ expect(isNewerVersion("1.0.0", "1.0.1")).toBe(true);
+ expect(isNewerVersion("1.2.3", "2.0.0")).toBe(true);
+ expect(isNewerVersion("1.2.3", "1.2.3")).toBe(false);
+ expect(isNewerVersion("2.0.0", "1.9.9")).toBe(false);
+ });
+
+ it("should return false for non-numeric versions", () => {
+ expect(isNewerVersion("unknown", "1.0.0")).toBe(false);
+ expect(isNewerVersion("1.0.0", "latest")).toBe(false);
+ });
+ });
+
+ describe("fetchLatestVersion", () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ global.fetch = async () => ({ ok: false, status: 500 }) as Response;
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ it("should return the latest version from npm", async () => {
+ global.fetch = async () =>
+ ({
+ ok: true,
+ json: async () => ({ version: "1.2.3" }),
+ }) as Response;
+
+ await expect(fetchLatestVersion("@rotorsoft/contui")).resolves.toBe("1.2.3");
+ });
+
+ it("should throw when the response is not ok", async () => {
+ global.fetch = async () => ({ ok: false, status: 500 }) as Response;
+
+ await expect(fetchLatestVersion("@rotorsoft/contui")).rejects.toThrow("Failed to fetch latest version");
+ });
+ });
+});
diff --git a/src/__tests__/status-bar.test.tsx b/src/__tests__/status-bar.test.tsx
new file mode 100644
index 0000000..c7ebbee
--- /dev/null
+++ b/src/__tests__/status-bar.test.tsx
@@ -0,0 +1,33 @@
+import { render } from "ink-testing-library";
+import { StatusBar } from "../components/StatusBar.js";
+import type { ReleaseCheckState } from "../hooks/useReleaseCheck.js";
+
+describe("StatusBar", () => {
+ it("shows update available message in the status area", () => {
+ const releaseStatus: ReleaseCheckState = {
+ status: "update-available",
+ latestVersion: "2.0.0",
+ };
+
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain("Update available: v2.0.0");
+ });
+
+ it("shows controls when no release message is provided", () => {
+ const { lastFrame } = render(
+
+ );
+
+ expect(lastFrame()).toContain("h/l:tabs");
+ });
+});
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 5b2a440..1ed8ea7 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -16,11 +16,15 @@ import { CreateDialog } from "./CreateDialog.js";
import { PullDialog } from "./PullDialog.js";
import { useContainerData } from "../hooks/useContainerData.js";
import { useKeyboard } from "../hooks/useKeyboard.js";
+import { useReleaseCheck } from "../hooks/useReleaseCheck.js";
import { containerCli } from "../services/container-cli.js";
+import { getAppVersion } from "../utils/app-version.js";
import type { Tab } from "../types/index.js";
type DialogType = "confirm" | "create" | "pull" | "logs" | "inspect" | null;
+const APP_VERSION = getAppVersion();
+
// Truncate large objects to prevent rendering performance issues
function truncateData(obj: unknown, maxDepth = 4, maxArrayLength = 20, maxStringLength = 200): unknown {
if (maxDepth <= 0) return "[truncated]";
@@ -61,6 +65,7 @@ interface DialogState {
export function App(): React.ReactElement {
const { exit } = useApp();
const { containers, images, networks, volumes, loading, error, refresh } = useContainerData();
+ const releaseStatus = useReleaseCheck({ packageName: "@rotorsoft/contui", currentVersion: APP_VERSION });
const [activeTab, setActiveTab] = useState("containers");
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -440,10 +445,10 @@ export function App(): React.ReactElement {
}
return (
-
+
- contui
+ contui v{APP_VERSION}
- Container Management TUI
@@ -494,6 +499,7 @@ export function App(): React.ReactElement {
itemCount={getItemCount()}
error={error || actionError}
actionInProgress={actionInProgress}
+ releaseStatus={releaseStatus}
/>
);
diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx
index 5394828..acf55f4 100644
--- a/src/components/StatusBar.tsx
+++ b/src/components/StatusBar.tsx
@@ -2,12 +2,14 @@ import React from "react";
import { Box, Text } from "ink";
import Spinner from "ink-spinner";
import type { Tab } from "../types/index.js";
+import type { ReleaseCheckState } from "../hooks/useReleaseCheck.js";
interface StatusBarProps {
activeTab: Tab;
itemCount: number;
error?: string | null;
actionInProgress?: string | null;
+ releaseStatus?: ReleaseCheckState | null;
}
const TAB_ACTIONS: Record = {
@@ -17,7 +19,27 @@ const TAB_ACTIONS: Record = {
volumes: "c:create d:delete i:inspect",
};
-export function StatusBar({ activeTab, itemCount, error, actionInProgress }: StatusBarProps): React.ReactElement {
+function renderReleaseStatus(releaseStatus?: ReleaseCheckState | null): React.ReactNode {
+ if (!releaseStatus) return null;
+
+ if (releaseStatus.status === "update-available" && releaseStatus.latestVersion) {
+ return Update available: v{releaseStatus.latestVersion};
+ }
+
+ if (releaseStatus.status === "checking") {
+ return Checking for updates...;
+ }
+
+ return null;
+}
+
+export function StatusBar({
+ activeTab,
+ itemCount,
+ error,
+ actionInProgress,
+ releaseStatus,
+}: StatusBarProps): React.ReactElement {
if (error) {
return (
@@ -37,14 +59,18 @@ export function StatusBar({ activeTab, itemCount, error, actionInProgress }: Sta
);
}
+ const releaseContent = renderReleaseStatus(releaseStatus);
+
return (
{itemCount} {activeTab} | {TAB_ACTIONS[activeTab]}
-
- h/l:tabs j/k:navigate /:search r:refresh ?:help q:quit
-
+ {releaseContent ?? (
+
+ h/l:tabs j/k:navigate /:search r:refresh ?:help q:quit
+
+ )}
);
}
diff --git a/src/hooks/useReleaseCheck.ts b/src/hooks/useReleaseCheck.ts
new file mode 100644
index 0000000..5ff59c1
--- /dev/null
+++ b/src/hooks/useReleaseCheck.ts
@@ -0,0 +1,78 @@
+import { useEffect, useState } from "react";
+
+export type ReleaseStatus = "checking" | "update-available" | "up-to-date" | "error";
+
+export interface ReleaseCheckState {
+ status: ReleaseStatus;
+ latestVersion?: string;
+ error?: string;
+}
+
+interface ReleaseCheckOptions {
+ packageName: string;
+ currentVersion: string;
+}
+
+export function isNewerVersion(currentVersion: string, latestVersion: string): boolean {
+ const currentParts = currentVersion.split(".").map(Number);
+ const latestParts = latestVersion.split(".").map(Number);
+
+ if (currentParts.some((value) => Number.isNaN(value)) || latestParts.some((value) => Number.isNaN(value))) {
+ return false;
+ }
+
+ const length = Math.max(currentParts.length, latestParts.length);
+ for (let index = 0; index < length; index += 1) {
+ const current = currentParts[index] ?? 0;
+ const latest = latestParts[index] ?? 0;
+ if (latest > current) return true;
+ if (latest < current) return false;
+ }
+
+ return false;
+}
+
+export async function fetchLatestVersion(packageName: string): Promise {
+ const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch latest version (${response.status})`);
+ }
+ const payload = (await response.json()) as { version?: string };
+ if (!payload.version) {
+ throw new Error("Latest version not found");
+ }
+ return payload.version;
+}
+
+export function useReleaseCheck({ packageName, currentVersion }: ReleaseCheckOptions): ReleaseCheckState {
+ const [state, setState] = useState({ status: "checking" });
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function checkForUpdate() {
+ try {
+ setState({ status: "checking" });
+ const latestVersion = await fetchLatestVersion(packageName);
+ if (cancelled) return;
+
+ if (currentVersion !== "unknown" && isNewerVersion(currentVersion, latestVersion)) {
+ setState({ status: "update-available", latestVersion });
+ } else {
+ setState({ status: "up-to-date", latestVersion });
+ }
+ } catch (error) {
+ if (cancelled) return;
+ setState({ status: "error", error: error instanceof Error ? error.message : "Release check failed" });
+ }
+ }
+
+ void checkForUpdate();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [packageName, currentVersion]);
+
+ return state;
+}
diff --git a/src/types/ink-testing-library.d.ts b/src/types/ink-testing-library.d.ts
new file mode 100644
index 0000000..53d84ec
--- /dev/null
+++ b/src/types/ink-testing-library.d.ts
@@ -0,0 +1,11 @@
+declare module "ink-testing-library" {
+ import type { ReactElement } from "react";
+
+ export interface RenderResult {
+ lastFrame: () => string | undefined;
+ rerender: (element: ReactElement) => void;
+ unmount: () => void;
+ }
+
+ export function render(element: ReactElement): RenderResult;
+}
diff --git a/src/utils/app-version.ts b/src/utils/app-version.ts
new file mode 100644
index 0000000..80d2166
--- /dev/null
+++ b/src/utils/app-version.ts
@@ -0,0 +1,18 @@
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+function readVersionFromPackageJson(): string | null {
+ try {
+ const packagePath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
+ const contents = readFileSync(packagePath, "utf8");
+ const parsed = JSON.parse(contents) as { version?: string };
+ return parsed.version ?? null;
+ } catch {
+ return null;
+ }
+}
+
+export function getAppVersion(): string {
+ return process.env.npm_package_version ?? readVersionFromPackageJson() ?? "unknown";
+}