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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default {
tsconfig: {
jsx: "react-jsx",
module: "ESNext",
moduleResolution: "Node",
moduleResolution: "NodeNext",
},
},
],
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"bin": {
"contui": "dist/index.js"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
Expand Down
6 changes: 6 additions & 0 deletions progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
45 changes: 45 additions & 0 deletions src/__tests__/release-check.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
33 changes: 33 additions & 0 deletions src/__tests__/status-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StatusBar
activeTab="containers"
itemCount={3}
releaseStatus={releaseStatus}
/>
);

expect(lastFrame()).toContain("Update available: v2.0.0");
});

it("shows controls when no release message is provided", () => {
const { lastFrame } = render(
<StatusBar
activeTab="images"
itemCount={1}
/>
);

expect(lastFrame()).toContain("h/l:tabs");
});
});
10 changes: 8 additions & 2 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]";
Expand Down Expand Up @@ -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<Tab>("containers");
const [selectedIndex, setSelectedIndex] = useState(0);
Expand Down Expand Up @@ -440,10 +445,10 @@ export function App(): React.ReactElement {
}

return (
<Box flexDirection="column" height="100%">
<Box flexDirection="column" height="100%">
<Box marginBottom={0}>
<Text bold color="cyan">
contui
contui v{APP_VERSION}
</Text>
<Text dimColor> - Container Management TUI</Text>
</Box>
Expand Down Expand Up @@ -494,6 +499,7 @@ export function App(): React.ReactElement {
itemCount={getItemCount()}
error={error || actionError}
actionInProgress={actionInProgress}
releaseStatus={releaseStatus}
/>
</Box>
);
Expand Down
34 changes: 30 additions & 4 deletions src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tab, string> = {
Expand All @@ -17,7 +19,27 @@ const TAB_ACTIONS: Record<Tab, string> = {
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 <Text color="yellow">Update available: v{releaseStatus.latestVersion}</Text>;
}

if (releaseStatus.status === "checking") {
return <Text dimColor>Checking for updates...</Text>;
}

return null;
}

export function StatusBar({
activeTab,
itemCount,
error,
actionInProgress,
releaseStatus,
}: StatusBarProps): React.ReactElement {
if (error) {
return (
<Box borderStyle="single" borderColor="red" paddingX={1}>
Expand All @@ -37,14 +59,18 @@ export function StatusBar({ activeTab, itemCount, error, actionInProgress }: Sta
);
}

const releaseContent = renderReleaseStatus(releaseStatus);

return (
<Box borderStyle="single" paddingX={1} justifyContent="space-between">
<Text>
<Text color="cyan">{itemCount}</Text> {activeTab} | {TAB_ACTIONS[activeTab]}
</Text>
<Text dimColor>
h/l:tabs j/k:navigate /:search r:refresh ?:help q:quit
</Text>
{releaseContent ?? (
<Text dimColor>
h/l:tabs j/k:navigate /:search r:refresh ?:help q:quit
</Text>
)}
</Box>
);
}
78 changes: 78 additions & 0 deletions src/hooks/useReleaseCheck.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<ReleaseCheckState>({ 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;
}
11 changes: 11 additions & 0 deletions src/types/ink-testing-library.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions src/utils/app-version.ts
Original file line number Diff line number Diff line change
@@ -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";
}