From c2738fe9f264bd01c5846d41fdef93097a5f4cd9 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 8 Jan 2026 10:01:35 -0800 Subject: [PATCH 1/7] cp dines --- RELEASE.md | 118 -------------------------- package.json | 2 +- src/components/Breadcrumb.tsx | 81 +++++++++--------- src/components/MainMenu.tsx | 33 +++++++ src/components/UpdateNotification.tsx | 113 +++++------------------- src/hooks/useUpdateCheck.ts | 79 +++++++++++++++++ src/mcp/server.ts | 2 +- src/utils/client.ts | 2 +- verification_report.txt | 40 --------- 9 files changed, 180 insertions(+), 290 deletions(-) delete mode 100644 RELEASE.md create mode 100644 src/hooks/useUpdateCheck.ts delete mode 100644 verification_report.txt diff --git a/RELEASE.md b/RELEASE.md deleted file mode 100644 index 58bfe509..00000000 --- a/RELEASE.md +++ /dev/null @@ -1,118 +0,0 @@ -# Release Process - -This document outlines the manual release process for `@runloop/rl-cli`. - -## Prerequisites - -1. Ensure you have npm access to publish to the `@runloop` organization -2. Ensure your local repository is clean and up to date with `main` -3. Ensure all tests pass and the build succeeds - -## Release Steps - -### 1. Update Version - -Choose the appropriate version bump based on your changes: - -```bash -# For bug fixes and minor changes -npm run version:patch - -# For new features (backward compatible) -npm run version:minor - -# For breaking changes -npm run version:major -``` - -This will: -- Update the version in `package.json` -- Create a git commit with the version bump -- Create a git tag for the new version - -### 2. Push Changes - -Push the version commit and tag to GitHub: - -```bash -git push origin improvements --follow-tags -``` - -### 3. Build and Publish - -Build and publish the package to npm: - -```bash -npm run release -``` - -This will: -- Run the TypeScript compiler to build the project -- Publish the package to npm - -### 4. Verify Publication - -Verify the package was published successfully: - -```bash -npm view @runloop/rl-cli version -``` - -### 5. Create GitHub Release (Optional) - -Create a GitHub release for the new version: - -1. Go to https://github.com/runloop/rl-cli-node/releases -2. Click "Draft a new release" -3. Select the tag you just pushed -4. Add release notes describing the changes -5. Publish the release - -## Version Commands Reference - -- `npm run version:patch` - Bump patch version (e.g., 0.0.2 → 0.0.3) -- `npm run version:minor` - Bump minor version (e.g., 0.0.2 → 0.1.0) -- `npm run version:major` - Bump major version (e.g., 0.0.2 → 1.0.0) -- `npm run release` - Build and publish to npm - -## Troubleshooting - -### Authentication Issues - -If you get authentication errors when publishing: - -```bash -npm login -``` - -Follow the prompts to log in to your npm account. - -### Build Errors - -If the build fails, fix the errors and run: - -```bash -npm run build -``` - -### Reverting a Release - -If you need to revert a release: - -```bash -# Deprecate the version on npm (doesn't unpublish) -npm deprecate @runloop/rl-cli@ "Reason for deprecation" - -# Revert the git commit and tag locally -git reset --hard HEAD~1 -git tag -d v -git push origin :refs/tags/v -``` - -Note: You cannot unpublish a package version after 72 hours. Use deprecation instead. - -## Notes - -- The `prepublishOnly` script automatically runs `npm run build` before publishing -- The version is automatically read from `package.json` and displayed in the CLI -- Make sure to update the changelog or release notes with each version diff --git a/package.json b/package.json index 0ac9a8e0..783e06b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.4.0", + "version": "0.3.0", "description": "Beautiful CLI for the Runloop platform", "type": "module", "bin": { diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 7bc57862..64d47b4f 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -21,48 +21,51 @@ export const Breadcrumb = ({ const isDevEnvironment = env === "dev"; return ( - - - - rl - - {isDevEnvironment && ( - - {" "} - (dev) + + + + + rl - )} - - {items.map((item, index) => { - // Limit label length to prevent Yoga layout engine errors - const MAX_LABEL_LENGTH = 80; - const truncatedLabel = - item.label.length > MAX_LABEL_LENGTH - ? item.label.substring(0, MAX_LABEL_LENGTH) + "..." - : item.label; + {isDevEnvironment && ( + + {" "} + (dev) + + )} + + {items.map((item, index) => { + // Limit label length to prevent Yoga layout engine errors + const MAX_LABEL_LENGTH = 80; + const truncatedLabel = + item.label.length > MAX_LABEL_LENGTH + ? item.label.substring(0, MAX_LABEL_LENGTH) + "..." + : item.label; - return ( - - - {truncatedLabel} - - {index < items.length - 1 && ( - - )} - - ); - })} - - {showVersionCheck && ( - - + return ( + + + {truncatedLabel} + + {index < items.length - 1 && ( + + )} + + ); + })} - )} + + {showVersionCheck && } ); }; diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index ca9a1672..360b9745 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Box, Text, useInput, useApp } from "ink"; +import { spawnSync } from "child_process"; import figures from "figures"; import { Banner } from "./Banner.js"; import { Breadcrumb } from "./Breadcrumb.js"; @@ -7,6 +8,30 @@ import { VERSION } from "../version.js"; import { colors } from "../utils/theme.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; +import { useUpdateCheck } from "../hooks/useUpdateCheck.js"; +import { showCursor } from "../utils/screen.js"; +import { processUtils } from "../utils/processUtils.js"; + +/** + * Release terminal from Ink and exec into a new command (one-way, no return) + */ +function execCommand(command: string, args: string[]): never { + // Release terminal from Ink's control + process.stdin.pause(); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(false); + } + if (processUtils.stdout.isTTY) { + processUtils.stdout.write("\x1b[0m"); // SGR reset + } + showCursor(); + + // Run the command synchronously - this blocks until complete + const result = spawnSync(command, args, { stdio: "inherit" }); + + // Exit with the command's exit code + process.exit(result.status ?? 0); +} interface MenuItem { key: string; @@ -51,6 +76,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { // Use centralized viewport hook for consistent layout const { terminalHeight } = useViewportHeight({ overhead: 0 }); + // Check for updates + const { updateAvailable } = useUpdateCheck(); + // Handle Ctrl+C to exit useExitOnCtrlC(); @@ -69,6 +97,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { onSelect("blueprints"); } else if (input === "s" || input === "3") { onSelect("snapshots"); + } else if (input === "u" && updateAvailable) { + // Release terminal and exec into update command (never returns) + execCommand("sh", ["-c", "npm install -g @runloop/rl-cli@latest && exec rli"]); } }); @@ -125,6 +156,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {figures.arrowUp} {figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit + {updateAvailable && " • [u] Update"} @@ -204,6 +236,7 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {figures.arrowUp} {figures.arrowDown} Navigate • [1-3] Quick select • [Enter] Select • [Esc] Quit + {updateAvailable && " • [u] Update"} diff --git a/src/components/UpdateNotification.tsx b/src/components/UpdateNotification.tsx index 4c76931c..1d9b5f93 100644 --- a/src/components/UpdateNotification.tsx +++ b/src/components/UpdateNotification.tsx @@ -1,108 +1,41 @@ import React from "react"; import { Box, Text } from "ink"; import { colors } from "../utils/theme.js"; -import { VERSION } from "../version.js"; +import { useUpdateCheck } from "../hooks/useUpdateCheck.js"; /** * Version check component that checks npm for updates and displays a notification * Restored from git history and enhanced with better visual styling */ export const UpdateNotification: React.FC = () => { - const [updateAvailable, setUpdateAvailable] = React.useState( - null, - ); - const [isChecking, setIsChecking] = React.useState(true); - - React.useEffect(() => { - const checkForUpdates = async () => { - try { - const currentVersion = VERSION; - const response = await fetch( - "https://registry.npmjs.org/@runloop/rl-cli/latest", - ); - - if (response.ok) { - const data = (await response.json()) as { version: string }; - const latestVersion = data.version; - - if (latestVersion && latestVersion !== currentVersion) { - // Check if current version is older than latest - const compareVersions = ( - version1: string, - version2: string, - ): number => { - const v1parts = version1.split(".").map(Number); - const v2parts = version2.split(".").map(Number); - - for ( - let i = 0; - i < Math.max(v1parts.length, v2parts.length); - i++ - ) { - const v1part = v1parts[i] || 0; - const v2part = v2parts[i] || 0; - - if (v1part > v2part) return 1; - if (v1part < v2part) return -1; - } - - return 0; - }; - - const isUpdateAvailable = - compareVersions(latestVersion, currentVersion) > 0; - - if (isUpdateAvailable) { - setUpdateAvailable(latestVersion); - } - } - } - } catch { - // Silently fail - } finally { - setIsChecking(false); - } - }; - - checkForUpdates(); - }, []); + const { isChecking, updateAvailable, currentVersion } = useUpdateCheck(); if (isChecking || !updateAvailable) { return null; } return ( - - - ✨ - - - {" "} - Update available:{" "} - - - {VERSION} - - - {" "} - →{" "} - - - {updateAvailable} - - - {" "} - • Run:{" "} - - - npm install -g @runloop/rl-cli@latest - + + + + Update available: + {currentVersion} + + {updateAvailable} + • Press + + [u] + + to run: + + npm i -g @runloop/rl-cli@latest + + ); }; diff --git a/src/hooks/useUpdateCheck.ts b/src/hooks/useUpdateCheck.ts new file mode 100644 index 00000000..8f5378c6 --- /dev/null +++ b/src/hooks/useUpdateCheck.ts @@ -0,0 +1,79 @@ +import React from "react"; +import { VERSION } from "../version.js"; + +interface UpdateCheckResult { + isChecking: boolean; + updateAvailable: string | null; + currentVersion: string; +} + +/** + * Hook to check for CLI updates from npm registry + * Returns the latest version if an update is available + */ +export function useUpdateCheck(): UpdateCheckResult { + const [updateAvailable, setUpdateAvailable] = React.useState( + null, + ); + const [isChecking, setIsChecking] = React.useState(true); + + React.useEffect(() => { + const checkForUpdates = async () => { + try { + const currentVersion = VERSION; + const response = await fetch( + "https://registry.npmjs.org/@runloop/rl-cli/latest", + ); + + if (response.ok) { + const data = (await response.json()) as { version: string }; + const latestVersion = data.version; + + if (latestVersion && latestVersion !== currentVersion) { + // Check if current version is older than latest + const compareVersions = ( + version1: string, + version2: string, + ): number => { + const v1parts = version1.split(".").map(Number); + const v2parts = version2.split(".").map(Number); + + for ( + let i = 0; + i < Math.max(v1parts.length, v2parts.length); + i++ + ) { + const v1part = v1parts[i] || 0; + const v2part = v2parts[i] || 0; + + if (v1part > v2part) return 1; + if (v1part < v2part) return -1; + } + + return 0; + }; + + const isUpdateAvailable = + compareVersions(latestVersion, currentVersion) > 0; + + if (isUpdateAvailable) { + setUpdateAvailable(latestVersion); + } + } + } + } catch { + // Silently fail + } finally { + setIsChecking(false); + } + }; + + checkForUpdates(); + }, []); + + return { + isChecking, + updateAvailable, + currentVersion: VERSION, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 60e792dd..d5755cc2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -74,7 +74,7 @@ function getClient(): Runloop { timeout: 10000, // 10 seconds instead of default 30 seconds maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors) defaultHeaders: { - "User-Agent": `Runloop/JS ${VERSION} - CLI MCP`, + "User-Agent": `Runloop/JS - CLI MCP ${VERSION}`, }, }); } diff --git a/src/utils/client.ts b/src/utils/client.ts index 4056c885..b5003d2b 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -34,7 +34,7 @@ export function getClient(): Runloop { bearerToken: config.apiKey, baseURL, defaultHeaders: { - "User-Agent": `Runloop/JS ${VERSION} - CLI`, + "User-Agent": `Runloop/JS - CLI ${VERSION}`, }, }); } diff --git a/verification_report.txt b/verification_report.txt deleted file mode 100644 index 36760dd0..00000000 --- a/verification_report.txt +++ /dev/null @@ -1,40 +0,0 @@ -=== ARCHITECTURE REFACTOR - VERIFICATION REPORT === -Generated: $(date) - -1. STORES (Expected: 5) - $(find src/store -name "*.ts" | wc -l | xargs echo " Found:") - $(find src/store -name "*.ts" | sed 's/^/ - /') - -2. SERVICES (Expected: 3) - $(find src/services -name "*.ts" | wc -l | xargs echo " Found:") - $(find src/services -name "*.ts" | sed 's/^/ - /') - -3. SCREENS (Expected: 7) - $(find src/screens -name "*.tsx" | wc -l | xargs echo " Found:") - $(find src/screens -name "*.tsx" | sed 's/^/ - /') - -4. REACT.MEMO ON SCREENS (Expected: 7/7) - $(grep -l "React.memo" src/screens/*.tsx | wc -l | xargs echo " Found:") - $(grep -l "React.memo" src/screens/*.tsx | sed 's/^/ - /') - -5. ROUTER FILES (Expected: 2) - $(find src/router -name "*.ts*" | wc -l | xargs echo " Found:") - $(find src/router -name "*.ts*" | sed 's/^/ - /') - -6. MEMORY MONITOR (Expected: 1) - $(find src/utils -name "memoryMonitor.ts" | wc -l | xargs echo " Found:") - -7. DIRECT API CALLS IN DevboxActionsMenu (Expected: 0) - $(grep -c "client\\.devboxes\\." src/components/DevboxActionsMenu.tsx || echo " 0 (GOOD)") - -8. SERVICE IMPORTS IN DevboxActionsMenu (Expected: 9) - $(grep -o "import {[^}]*} from.*devboxService" src/components/DevboxActionsMenu.tsx | grep -o "[a-zA-Z]*," | wc -l | xargs echo " Found:") - -9. BUILD STATUS - Build exit code: $(npm run build > /dev/null 2>&1; echo $?) - (0 = success) - -10. ROUTER USAGE IN menu.tsx - $(grep -c " Date: Thu, 8 Jan 2026 10:11:26 -0800 Subject: [PATCH 2/7] cp dines --- src/components/MainMenu.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 360b9745..86906635 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -99,7 +99,10 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { onSelect("snapshots"); } else if (input === "u" && updateAvailable) { // Release terminal and exec into update command (never returns) - execCommand("sh", ["-c", "npm install -g @runloop/rl-cli@latest && exec rli"]); + execCommand("sh", [ + "-c", + "npm install -g @runloop/rl-cli@latest && exec rli", + ]); } }); From a01a09c228b15de4920295e07f536768a49da688 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 8 Jan 2026 10:20:34 -0800 Subject: [PATCH 3/7] cp dines --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9749ae22..64432c1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runloop/rl-cli", - "version": "0.5.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runloop/rl-cli", - "version": "0.5.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.19.1", diff --git a/package.json b/package.json index 783e06b0..0ac9a8e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.3.0", + "version": "0.4.0", "description": "Beautiful CLI for the Runloop platform", "type": "module", "bin": { From 8932aa60ad19a3080fc17546297458ff821bdff3 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 8 Jan 2026 10:30:48 -0800 Subject: [PATCH 4/7] cp dines --- src/components/MainMenu.tsx | 25 +------------------------ src/utils/exec.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 src/utils/exec.ts diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 86906635..115c2603 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -1,37 +1,14 @@ import React from "react"; import { Box, Text, useInput, useApp } from "ink"; -import { spawnSync } from "child_process"; import figures from "figures"; import { Banner } from "./Banner.js"; import { Breadcrumb } from "./Breadcrumb.js"; import { VERSION } from "../version.js"; import { colors } from "../utils/theme.js"; +import { execCommand } from "../utils/exec.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; import { useUpdateCheck } from "../hooks/useUpdateCheck.js"; -import { showCursor } from "../utils/screen.js"; -import { processUtils } from "../utils/processUtils.js"; - -/** - * Release terminal from Ink and exec into a new command (one-way, no return) - */ -function execCommand(command: string, args: string[]): never { - // Release terminal from Ink's control - process.stdin.pause(); - if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { - processUtils.stdin.setRawMode(false); - } - if (processUtils.stdout.isTTY) { - processUtils.stdout.write("\x1b[0m"); // SGR reset - } - showCursor(); - - // Run the command synchronously - this blocks until complete - const result = spawnSync(command, args, { stdio: "inherit" }); - - // Exit with the command's exit code - process.exit(result.status ?? 0); -} interface MenuItem { key: string; diff --git a/src/utils/exec.ts b/src/utils/exec.ts new file mode 100644 index 00000000..f106b6f5 --- /dev/null +++ b/src/utils/exec.ts @@ -0,0 +1,26 @@ +import { spawnSync } from "child_process"; +import { showCursor } from "./screen.js"; +import { processUtils } from "./processUtils.js"; + +/** + * Release terminal from Ink and exec into a new command (one-way, no return) + * This function never returns - it runs the command synchronously and exits. + */ +export function execCommand(command: string, args: string[]): never { + // Release terminal from Ink's control + process.stdin.pause(); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(false); + } + if (processUtils.stdout.isTTY) { + processUtils.stdout.write("\x1b[0m"); // SGR reset + } + showCursor(); + + // Run the command synchronously - this blocks until complete + const result = spawnSync(command, args, { stdio: "inherit" }); + + // Exit with the command's exit code + process.exit(result.status ?? 0); +} + From 45d77a01bf111aa176be1607ec940c3ab8c5d096 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 8 Jan 2026 10:31:39 -0800 Subject: [PATCH 5/7] cp dines --- package.json | 2 +- src/utils/exec.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 0ac9a8e0..8299572c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.4.0", + "version": "0.5.0", "description": "Beautiful CLI for the Runloop platform", "type": "module", "bin": { diff --git a/src/utils/exec.ts b/src/utils/exec.ts index f106b6f5..b213952d 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -23,4 +23,3 @@ export function execCommand(command: string, args: string[]): never { // Exit with the command's exit code process.exit(result.status ?? 0); } - From d0efbfc2b8b16c3c6d412c9f8c953c86c63f9c9b Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 8 Jan 2026 10:37:44 -0800 Subject: [PATCH 6/7] cp dines --- .../components/UpdateNotification.test.tsx | 2 +- tests/setup-components.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/__tests__/components/UpdateNotification.test.tsx b/tests/__tests__/components/UpdateNotification.test.tsx index 27124fb7..7984f6cd 100644 --- a/tests/__tests__/components/UpdateNotification.test.tsx +++ b/tests/__tests__/components/UpdateNotification.test.tsx @@ -86,7 +86,7 @@ describe('UpdateNotification', () => { await new Promise((resolve) => setTimeout(resolve, 100)); - expect(lastFrame()).toContain('npm install -g @runloop/rl-cli@latest'); + expect(lastFrame()).toContain('npm i -g @runloop/rl-cli@latest'); }); it('handles non-ok response', async () => { diff --git a/tests/setup-components.ts b/tests/setup-components.ts index aafc22ec..98793075 100644 --- a/tests/setup-components.ts +++ b/tests/setup-components.ts @@ -218,6 +218,14 @@ jest.mock("../src/hooks/useExitOnCtrlC.ts", () => ({ useExitOnCtrlC: jest.fn(), })); +jest.mock("../src/hooks/useUpdateCheck.ts", () => ({ + useUpdateCheck: jest.fn(() => ({ + isChecking: false, + updateAvailable: null, + currentVersion: "0.1.0", + })), +})); + // Mock version.ts VERSION export jest.mock("../src/version", () => ({ VERSION: "0.1.0", @@ -288,6 +296,11 @@ jest.mock("../src/utils/screen.ts", () => ({ enterAlternateScreenBuffer: jest.fn(), })); +// Mock exec utility +jest.mock("../src/utils/exec.ts", () => ({ + execCommand: jest.fn(), +})); + // Mock Banner component (uses ink-big-text which is ESM) jest.mock("../src/components/Banner.tsx", () => ({ __esModule: true, From f85a86b1f70ae1cf2812ffef872eb27c3b076de1 Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Thu, 8 Jan 2026 10:38:14 -0800 Subject: [PATCH 7/7] cp dines --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8299572c..0ac9a8e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runloop/rl-cli", - "version": "0.5.0", + "version": "0.4.0", "description": "Beautiful CLI for the Runloop platform", "type": "module", "bin": {