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"; +}