UNPKG

fmdt

Version:

CLI tool for checking if a branch has been merged into feature branches (dev, qa, staging, master)

1,712 lines (1,686 loc) 57.5 kB
#!/usr/bin/env node import { promises, readFileSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { program } from "commander"; import { Box, Text, render, useInput, useStdout } from "ink"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { homedir } from "node:os"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { z } from "zod/v4"; import xdg from "@folder/xdg"; import { Entry } from "@napi-rs/keyring"; import { Alert, Spinner, StatusMessage } from "@inkjs/ui"; import { z as z$1 } from "zod"; //#region src/utils/colors.ts const colors = { base: "#191724", surface: "#1f1d2e", overlay: "#26233a", muted: "#6e6a86", subtle: "#908caa", text: "#e0def4", love: "#eb6f92", gold: "#f6c177", rose: "#ebbcba", pine: "#31748f", foam: "#9ccfd8", iris: "#c4a7e7", highlightLow: "#21202e", highlightMed: "#403d52", highlightHigh: "#524f67" }; const semanticColors = { success: colors.foam, error: colors.love, warning: colors.gold, info: colors.iris, primary: colors.iris, secondary: colors.rose }; const asciiArt = ` ███████╗███╗ ███╗██████╗ ████████╗ ██╔════╝████╗ ████║██╔══██╗╚══██╔══╝ █████╗ ██╔████╔██║██║ ██║ ██║ ██╔══╝ ██║╚██╔╝██║██║ ██║ ██║ ██║ ██║ ╚═╝ ██║██████╔╝ ██║ ╚═╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ `; //#endregion //#region src/utils/history.ts const HISTORY_FILE = join(homedir(), ".fmdt_history"); const MAX_HISTORY = 50; async function loadHistory() { try { const content = await promises.readFile(HISTORY_FILE, "utf-8"); const history = JSON.parse(content); if (Array.isArray(history)) return history.filter((item) => typeof item === "string"); return []; } catch (error) { console.error("Error loading history:", error); return []; } } async function saveHistory(history) { try { await promises.writeFile(HISTORY_FILE, JSON.stringify(history, null, 2), "utf-8"); } catch (error) { console.error("Error saving history:", error); } } function addToHistory(branch, currentHistory) { const filtered = currentHistory.filter((item) => item !== branch); const updated = [branch, ...filtered]; return updated.slice(0, MAX_HISTORY); } //#endregion //#region src/components/Footer.tsx const Footer = ({ showNavigation = false, showExit = true, showSearch = false }) => { const getInstructions = () => { if (showNavigation && showSearch) return "Enter: To search, Arrow keys (↑↓): To navigate history"; if (showSearch) return "Enter: To search, Press Ctrl-C to exit"; if (showExit) return "Press Ctrl-C to exit"; return ""; }; return /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: colors.muted, children: getInstructions() }) }); }; //#endregion //#region src/components/BranchInput.tsx function BranchInput({ onSubmit }) { const [value, setValue] = React.useState("LAAIR-"); const [cursorPosition, setCursorPosition] = React.useState(6); const [history, setHistory] = React.useState([]); const [historyPosition, setHistoryPosition] = React.useState(-1); const [savedInput, setSavedInput] = React.useState(""); React.useEffect(() => { loadHistory().then(setHistory); }, []); useInput((input, key) => { if (key.return) { if (!value.trim()) return; onSubmit(value.trim()); setValue("LAAIR-"); setCursorPosition(6); setHistoryPosition(-1); setSavedInput(""); return; } if (key.upArrow) { if (history.length === 0) return; const newPosition = historyPosition + 1; if (newPosition >= history.length) return; if (historyPosition === -1) setSavedInput(value); const historyItem = history[newPosition]; if (!historyItem) return; setValue(historyItem); setCursorPosition(historyItem.length); setHistoryPosition(newPosition); return; } if (key.downArrow) { if (historyPosition === -1) return; const newPosition = historyPosition - 1; if (newPosition === -1) { setValue(savedInput); setCursorPosition(savedInput.length); setHistoryPosition(-1); return; } const historyItem = history[newPosition]; if (!historyItem) return; setValue(historyItem); setCursorPosition(historyItem.length); setHistoryPosition(newPosition); return; } if (key.backspace || key.delete) { if (cursorPosition <= 0) return; const newValue$1 = value.slice(0, cursorPosition - 1) + value.slice(cursorPosition); setValue(newValue$1); setCursorPosition(cursorPosition - 1); setHistoryPosition(-1); return; } if (key.leftArrow) { setCursorPosition(Math.max(0, cursorPosition - 1)); return; } if (key.rightArrow) { setCursorPosition(Math.min(value.length, cursorPosition + 1)); return; } if (!input || key.ctrl || key.meta) return; const newValue = value.slice(0, cursorPosition) + input + value.slice(cursorPosition); setValue(newValue); setCursorPosition(cursorPosition + input.length); setHistoryPosition(-1); }); const displayValue = `${value.slice(0, cursorPosition)}█${value.slice(cursorPosition)}`; return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [ /* @__PURE__ */ jsx(Text, { bold: true, color: colors.iris, children: "Enter ticket number:" }), /* @__PURE__ */ jsx(Box, { borderStyle: "single", borderColor: colors.iris, borderTop: true, borderBottom: true, borderLeft: false, borderRight: false, paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { color: colors.text, children: [/* @__PURE__ */ jsx(Text, { color: colors.iris, children: "> " }), displayValue || "█"] }) }), /* @__PURE__ */ jsx(Footer, { showNavigation: true, showSearch: true }) ] }); } //#endregion //#region src/types/index.ts const CliOptionsSchema = z.object({ branch: z.string().optional(), repository: z.string().optional(), configure: z.boolean().optional(), help: z.boolean().optional(), version: z.boolean().optional() }); function isFulfilledResult(result) { return result.status === "fulfilled"; } function isRejectedResult(result) { return result.status === "rejected"; } const ConfigFileSchema = z.object({ azureDevOpsOrg: z.string().min(1), azureDevOpsProject: z.string().min(1), version: z.string().default("1.0.0"), autoUpdate: z.boolean().optional().default(true) }); //#endregion //#region src/utils/config.ts const KEYRING_SERVICE = "fmdt"; const KEYRING_ACCOUNT = "azure-devops-pat"; const CONFIG_DIR_NAME = "fmdt"; const CONFIG_FILE_NAME = "config.json"; function getConfigDir() { const paths = xdg(); return join(paths.config, CONFIG_DIR_NAME); } function getConfigFilePath() { return join(getConfigDir(), CONFIG_FILE_NAME); } async function loadConfigFile() { try { const configPath = getConfigFilePath(); const fileContent = await readFile(configPath, "utf-8"); const parsed = JSON.parse(fileContent); return ConfigFileSchema.parse(parsed); } catch (_error) { return null; } } async function saveConfigFile(config) { const configDir = getConfigDir(); const configPath = getConfigFilePath(); await mkdir(configDir, { recursive: true }); await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); } async function loadPatFromKeyring() { try { const entry = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT); const password = entry.getPassword(); return password; } catch (_error) { return null; } } async function savePatToKeyring(pat) { try { const entry = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT); entry.setPassword(pat); } catch (_error) { throw new Error("Unable to save PAT to system keyring. Please ensure your system keyring service is available. Linux users may need to install gnome-keyring or libsecret."); } } async function deletePatFromKeyring() { try { const entry = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT); entry.deletePassword(); } catch (_error) {} } async function getConfig() { const pat = await loadPatFromKeyring(); const configFile = await loadConfigFile(); if (!pat) throw new Error("Azure DevOps PAT not found in keyring. Please run with --configure flag to set up credentials."); if (!configFile) throw new Error("Configuration file not found. Please run with --configure flag to set up credentials."); return { azureDevOpsPat: pat, azureDevOpsOrg: configFile.azureDevOpsOrg, azureDevOpsProject: configFile.azureDevOpsProject, autoUpdate: configFile.autoUpdate }; } async function hasValidConfig() { try { const pat = await loadPatFromKeyring(); const configFile = await loadConfigFile(); return Boolean(pat && configFile); } catch { return false; } } function createAuthHeader(pat) { const credentials = Buffer.from(`:${pat}`).toString("base64"); return `Basic ${credentials}`; } //#endregion //#region src/services/azure-devops.ts const PAGE_SIZE = 101; var AzureDevOpsService = class { authHeader; baseUrl; org; constructor(config) { this.authHeader = createAuthHeader(config.azureDevOpsPat); this.org = config.azureDevOpsOrg; this.baseUrl = `https://dev.azure.com/${config.azureDevOpsOrg}/${config.azureDevOpsProject}/_apis/git/repositories/`; } async getProjects() { const url = `https://dev.azure.com/${this.org}/_apis/projects?api-version=7.1`; const response = await fetch(url, { headers: { Authorization: this.authHeader, Accept: "application/json" } }); if (response.status === 401 || response.status === 403) throw new Error("Invalid PAT token. Please check your Personal Access Token has the required scopes: Code (Read), Project and Team (Read)"); if (response.status === 404) throw new Error("Organization not found. Please check the organization name is correct. It should match the URL: https://dev.azure.com/YOUR-ORG-NAME"); if (!response.ok) throw new Error(`Failed to fetch projects: ${response.status} ${response.statusText}`); const data = await response.json(); return data.value; } async getRepositories() { const response = await fetch(this.baseUrl, { headers: { Authorization: this.authHeader, Accept: "application/json" } }); if (!response.ok) throw new Error(`Failed to fetch repositories: ${response.status} ${response.statusText}`); const data = await response.json(); return data.value.filter((repo) => !repo.isDisabled); } async getPullRequests(repositoryId, searchCriteria) { const pullRequests = []; let page = 0; let hasMore = true; while (hasMore) { const url = `${this.baseUrl}${repositoryId}/pullrequests?${searchCriteria}&$skip=${page * PAGE_SIZE}&$top=${PAGE_SIZE}`; const response = await fetch(url, { headers: { Authorization: this.authHeader, Accept: "application/json" } }); if (!response.ok) throw new Error(`Failed to fetch pull requests: ${response.status} ${response.statusText}`); const data = await response.json(); pullRequests.push(...data.value); hasMore = data.value.length === PAGE_SIZE; page++; } return pullRequests; } async getPullRequestsForSourceBranch(repositoryId, branch) { const pullRequests = await this.getPullRequests(repositoryId, `searchCriteria.sourceRefName=refs/heads/${branch}&searchCriteria.status=completed`); if (pullRequests.length === 0) return null; const firstPullRequest = pullRequests[0]; if (!firstPullRequest) return null; const pullRequestDetails = pullRequests.map((pr) => ({ creator: pr.createdBy.displayName, closedDate: pr.closedDate, targetBranch: pr.targetRefName.trim().substring(11) })); return { projectName: firstPullRequest.repository.name, branchName: branch, sourceRefName: firstPullRequest.sourceRefName, pullRequestsDetails: pullRequestDetails, commitDetails: { commitId: firstPullRequest.lastMergeSourceCommit.commitId, sourceLastCommitDate: "" } }; } async getBranchMergeStatus(repositoryId, branch, repositoryName) { const parsedPR = await this.getPullRequestsForSourceBranch(repositoryId, branch); const status = { branch, repository: repositoryName, mergedTo: { dev: { merged: false, date: null, mergedBy: null }, qa: { merged: false, date: null, mergedBy: null }, staging: { merged: false, date: null, mergedBy: null }, master: { merged: false, date: null, mergedBy: null } } }; if (!parsedPR || !parsedPR.pullRequestsDetails) return status; const sortedPRs = [...parsedPR.pullRequestsDetails].sort((a, b) => { return new Date(b.closedDate).getTime() - new Date(a.closedDate).getTime(); }); for (const pr of sortedPRs) { const targetBranch = pr.targetBranch.toLowerCase(); if (targetBranch === "dev" && !status.mergedTo.dev.merged) status.mergedTo.dev = { merged: true, date: pr.closedDate, mergedBy: pr.creator }; else if (targetBranch === "qa" && !status.mergedTo.qa.merged) status.mergedTo.qa = { merged: true, date: pr.closedDate, mergedBy: pr.creator }; else if (targetBranch === "staging" && !status.mergedTo.staging.merged) status.mergedTo.staging = { merged: true, date: pr.closedDate, mergedBy: pr.creator }; else if ((targetBranch === "master" || targetBranch === "main") && !status.mergedTo.master.merged) status.mergedTo.master = { merged: true, date: pr.closedDate, mergedBy: pr.creator }; } await this.validateMergesWithDiff(repositoryId, branch, status); return status; } async checkBranchFullyMerged(repositoryId, sourceBranch, targetBranch) { const url = `${this.baseUrl}${repositoryId}/diffs/commits?baseVersion=${targetBranch}&baseVersionType=branch&targetVersion=${sourceBranch}&targetVersionType=branch&api-version=6.0`; const response = await fetch(url, { headers: { Authorization: this.authHeader, Accept: "application/json" } }); if (!response.ok) return false; const data = await response.json(); const changeCounts = data.changeCounts; return !changeCounts || Object.keys(changeCounts).length === 0; } async validateMergesWithDiff(repositoryId, branch, status) { const branches = [ "dev", "qa", "staging", "master" ]; for (const targetBranch of branches) if (status.mergedTo[targetBranch].merged) { const isFullyMerged = await this.checkBranchFullyMerged(repositoryId, branch, targetBranch); if (!isFullyMerged) status.mergedTo[targetBranch] = { merged: false, date: null, mergedBy: null }; } } async getBatchBranchMergeStatus(branch) { const repositories = await this.getRepositories(); const promises$1 = repositories.map((repo) => this.getBranchMergeStatus(repo.id, branch, repo.name)); const results = await Promise.allSettled(promises$1); const successful = results.filter(isFulfilledResult).map((result) => result.value); const failed = []; for (let i = 0; i < results.length; i++) { const result = results[i]; if (result && isRejectedResult(result)) failed.push(repositories[i]?.name ?? "Unknown"); } const branchExists = successful.filter((status) => { const environments = Object.values(status.mergedTo); return environments.some((env) => env.merged); }); const operationSummary = { total: repositories.length, successful: branchExists.length, failed: failed.length, failedRepos: failed }; return { statuses: branchExists, operationSummary }; } }; //#endregion //#region src/components/ErrorDisplay.tsx function ErrorDisplay({ error }) { return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [/* @__PURE__ */ jsx(Text, { color: colors.rose, children: "Oh geez, Rick! Can't we just, y'know, refresh or something? This is the worst!" }), /* @__PURE__ */ jsxs(StatusMessage, { variant: "error", children: ["Error: ", error] })] }); } //#endregion //#region package.json var name = "fmdt"; var type = "module"; var version = "1.2.0"; var description = "CLI tool for checking if a branch has been merged into feature branches (dev, qa, staging, master)"; var license = "MIT"; var author = "treramey <trevor@trevors.email>"; var repository = { "type": "git", "url": "https://github.com/treramey/fmdt.git" }; var homepage = "https://github.com/nyatinte/fmdt#readme"; var bugs = { "url": "https://github.com/nyatinte/fmdt/issues" }; var keywords = [ "azure-devops", "cli", "command-line", "cli-tool", "terminal", "tui", "branch-status", "merge-status", "developer-tools", "fmdt" ]; var engines = { "node": ">=20.19.3" }; var exports = { ".": "./dist/index.js" }; var main = "./dist/index.js"; var module = "./dist/index.js"; var types = "./dist/index.d.ts"; var bin = "./dist/index.js"; var files = ["dist"]; var scripts = { "build": "tsdown", "start": "bun run ./src/index.tsx", "dev": "bun --watch ./src/index.tsx", "test": "CI=true vitest run", "test:watch": "CI=true vitest --watch", "typecheck": "tsgo --noEmit", "check": "biome check --config-path=\"$(pwd)/biome.jsonc\" .", "check:write": "biome check --config-path=\"$(pwd)/biome.jsonc\" --write .", "check:unsafe": "biome check --config-path=\"$(pwd)/biome.jsonc\" --write --unsafe .", "knip": "knip", "ci": "concurrently --kill-others-on-fail --success all -n \"build,check,typecheck,knip,test\" -c \"cyan,green,yellow,magenta,blue\" \"bun run build\" \"bun run check\" \"bun run typecheck\" \"bun run knip\" \"bun run test\"", "prepack": "bun run build && clean-pkg-json", "release": "bun run ci && bumpp --no-verify" }; var devDependencies = { "@biomejs/biome": "2.1.2", "@types/bun": "^1.2.18", "@types/react": "^19.1.8", "@typescript/native-preview": "7.0.0-dev.20250712.1", "bumpp": "^10.2.0", "clean-pkg-json": "^1.3.0", "concurrently": "^9.2.0", "fs-fixture": "^2.8.1", "ink-testing-library": "^4.0.0", "knip": "^5.61.3", "lefthook": "^1.12.2", "publint": "^0.3.12", "tsdown": "^0.12.9", "vitest": "^3.2.4" }; var overrides = { "vite": "npm:rolldown-vite@latest" }; var dependencies = { "@folder/xdg": "^4.0.1", "@inkjs/ui": "^2.0.0", "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.0", "es-toolkit": "^1.39.7", "ink": "^6.0.1", "react": "^19.1.0", "ts-pattern": "^5.7.1", "zod": "^4.0.5" }; var package_default = { name, type, version, description, license, author, repository, homepage, bugs, keywords, engines, exports, main, module, types, bin, files, scripts, devDependencies, overrides, dependencies }; //#endregion //#region src/components/Header.tsx function Header() { return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: colors.gold, children: asciiArt }) }), /* @__PURE__ */ jsxs(Box, { flexDirection: "column", justifyContent: "center", marginLeft: 2, children: [/* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsxs(Text, { bold: true, color: colors.text, children: ["FMDT", " "] }), /* @__PURE__ */ jsxs(Text, { color: colors.muted, children: ["v", package_default.version] })] }), /* @__PURE__ */ jsx(Text, { color: colors.muted, children: "Find My Damn Ticket" })] })] }); } //#endregion //#region src/components/LoadingScreen.tsx function LoadingScreen({ message = "Loading..." }) { return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 2, children: /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Spinner, {}), /* @__PURE__ */ jsxs(Text, { color: colors.text, children: [" ", message] })] }) }); } //#endregion //#region src/components/OrganizationInput.tsx const organizationSchema = z$1.string().min(1, "Organization name cannot be empty").regex(/^[a-zA-Z0-9-_]+$/, "Organization name can only contain letters, numbers, dashes, and underscores"); function OrganizationInput({ onSubmit }) { const [value, setValue] = React.useState(""); const [cursorPosition, setCursorPosition] = React.useState(0); const [error, setError] = React.useState(""); useInput((input, key) => { if (key.return) { const result = organizationSchema.safeParse(value.trim()); if (!result.success) { setError(result.error.issues[0]?.message ?? "Invalid organization name"); return; } setError(""); onSubmit(result.data); return; } if (key.backspace || key.delete) { if (cursorPosition > 0) { const newValue = value.slice(0, cursorPosition - 1) + value.slice(cursorPosition); setValue(newValue); setCursorPosition(cursorPosition - 1); setError(""); } return; } if (key.leftArrow) { setCursorPosition(Math.max(0, cursorPosition - 1)); return; } if (key.rightArrow) { setCursorPosition(Math.min(value.length, cursorPosition + 1)); return; } if (input && !key.ctrl && !key.meta) { const newValue = value.slice(0, cursorPosition) + input + value.slice(cursorPosition); setValue(newValue); setCursorPosition(cursorPosition + input.length); setError(""); } }); const displayValue = `${value.slice(0, cursorPosition)}█${value.slice(cursorPosition)}`; return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { bold: true, color: colors.gold, children: "Enter your Azure DevOps organization name:" }) }), /* @__PURE__ */ jsx(Box, { borderStyle: "single", borderColor: error ? colors.love : colors.gold, borderTop: true, borderBottom: true, borderLeft: false, borderRight: false, paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { color: colors.text, children: [/* @__PURE__ */ jsx(Text, { color: colors.gold, children: "> " }), displayValue || "█"] }) }), error ? /* @__PURE__ */ jsx(Text, { color: colors.love, children: error }) : /* @__PURE__ */ jsx(Text, { color: colors.muted, children: "Example: https://dev.azure.com/YOUR-ORG-HERE ← this is your organization" }), /* @__PURE__ */ jsx(Footer, {}) ] }); } //#endregion //#region src/components/PatInput.tsx function PatInput({ onSubmit }) { const [value, setValue] = React.useState(""); const [cursorPosition, setCursorPosition] = React.useState(0); const [error, setError] = React.useState(""); useInput((input, key) => { if (key.return) { if (value.trim().length < 10) { setError("PAT token is too short (minimum 10 characters)"); return; } setError(""); onSubmit(value.trim()); return; } if (key.backspace || key.delete) { if (cursorPosition > 0) { const newValue = value.slice(0, cursorPosition - 1) + value.slice(cursorPosition); setValue(newValue); setCursorPosition(cursorPosition - 1); setError(""); } return; } if (input && !key.ctrl && !key.meta) { const newValue = value.slice(0, cursorPosition) + input + value.slice(cursorPosition); setValue(newValue); setCursorPosition(cursorPosition + input.length); setError(""); } }); const maskedValue = value.replace(/./g, "•"); const displayValue = `${maskedValue.slice(0, cursorPosition)}█${maskedValue.slice(cursorPosition)}`; return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: colors.iris, children: "Paste your Azure DevOps Personal Access Token:" }) }), /* @__PURE__ */ jsx(Box, { borderStyle: "single", borderColor: error ? colors.love : colors.iris, borderTop: true, borderBottom: true, borderLeft: false, borderRight: false, paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { color: colors.text, children: [/* @__PURE__ */ jsx(Text, { color: colors.iris, children: "> " }), displayValue || "█"] }) }), error ? /* @__PURE__ */ jsx(Text, { color: colors.love, children: error }) : /* @__PURE__ */ jsx(Text, { color: colors.muted, children: "Press Enter to continue" }), /* @__PURE__ */ jsx(Footer, {}) ] }); } //#endregion //#region src/hooks/useSimpleVirtualScroll.ts const MIN_VIEWPORT_HEIGHT = 5; const MAX_VIEWPORT_HEIGHT = 20; const DEFAULT_TERMINAL_ROWS = 24; /** * Virtual scrolling hook for flat, non-hierarchical lists. * * Efficiently renders large lists by only displaying items within the terminal viewport. * Automatically scrolls to keep the selected item visible and provides scroll indicators. * * @template T - The type of items in the list * @param options - Configuration options for virtual scrolling * @returns Scroll state and visible items for rendering * @example * const { visibleItems, hasTopIndicator, hasBottomIndicator } = useSimpleVirtualScroll({ * items: allProjects, * selectedIndex: currentIndex, * reservedLines: 6, // Header + footer lines * }); */ function useSimpleVirtualScroll({ items, selectedIndex, reservedLines, testViewportHeight }) { const [scrollOffset, setScrollOffset] = useState(0); const { stdout } = useStdout(); const viewportHeight = useMemo(() => { if (testViewportHeight !== void 0) return testViewportHeight; const calculatedHeight = Math.max(MIN_VIEWPORT_HEIGHT, (stdout?.rows ?? DEFAULT_TERMINAL_ROWS) - reservedLines); return Math.min(calculatedHeight, MAX_VIEWPORT_HEIGHT); }, [ stdout?.rows, reservedLines, testViewportHeight ]); const totalLines = items.length; const getAdjustedScrollOffset = useCallback((currentScrollOffset) => { const maxScroll = Math.max(0, totalLines - viewportHeight); if (selectedIndex < currentScrollOffset) return Math.max(0, selectedIndex); if (selectedIndex >= currentScrollOffset + viewportHeight) return Math.min(maxScroll, selectedIndex - viewportHeight + 1); return currentScrollOffset; }, [ viewportHeight, selectedIndex, totalLines ]); useEffect(() => { setScrollOffset((currentScrollOffset) => { const adjustedOffset = getAdjustedScrollOffset(currentScrollOffset); return adjustedOffset; }); }, [getAdjustedScrollOffset]); const { viewStart, viewEnd } = useMemo(() => { const clampedOffset = Math.max(0, Math.min(scrollOffset, totalLines - viewportHeight)); const start = clampedOffset; const end = Math.min(start + viewportHeight, totalLines); return { viewStart: start, viewEnd: end }; }, [ scrollOffset, viewportHeight, totalLines ]); const visibleItems = useMemo(() => items.slice(viewStart, viewEnd), [ items, viewStart, viewEnd ]); const hasTopIndicator = scrollOffset > 0; const hasBottomIndicator = scrollOffset + viewportHeight < totalLines; return { scrollOffset, viewportHeight, viewStart, viewEnd, visibleItems, hasTopIndicator, hasBottomIndicator, totalLines }; } //#endregion //#region src/components/ProjectSelector.tsx /** * Interactive project selector with keyboard navigation and virtual scrolling. * * Displays a list of Azure DevOps projects with arrow key navigation and Enter * to select. Uses virtual scrolling to efficiently handle large project lists. * Shows scroll indicators when there are more items above or below the viewport. * * @param props - Component props containing projects and selection callback * @returns Virtualized, keyboard-navigable project list * @example * <ProjectSelector * projects={allProjects} * onSelect={(name) => console.log(`Selected: ${name}`)} * /> */ function ProjectSelector({ projects, onSelect }) { const [selectedIndex, setSelectedIndex] = useState(0); const { visibleItems: visibleProjects, scrollOffset, viewportHeight, hasTopIndicator, hasBottomIndicator } = useSimpleVirtualScroll({ items: projects, selectedIndex, reservedLines: 6 }); useInput((_input, key) => { if (key.return) { const selectedProject = projects[selectedIndex]; if (selectedProject) onSelect(selectedProject.name); return; } if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); return; } if (key.downArrow) { setSelectedIndex((prev) => Math.min(projects.length - 1, prev + 1)); return; } }); return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [ /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsx(Text, { bold: true, color: colors.iris, children: "Select your Azure DevOps project:" }) }), /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsxs(Text, { color: colors.muted, children: [ "Found ", projects.length, " projects (use ↑↓ to navigate, Enter to select)" ] }) }), hasTopIndicator && /* @__PURE__ */ jsx(Box, { justifyContent: "center", children: /* @__PURE__ */ jsx(Text, { color: colors.muted, children: "↑ More above" }) }), /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: visibleProjects.map((project, index) => { const absoluteIndex = scrollOffset + index; const isSelected = absoluteIndex === selectedIndex; return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { color: isSelected ? colors.iris : colors.text, children: [isSelected ? "❯ " : " ", project.name] }) }, project.name); }) }), hasBottomIndicator && /* @__PURE__ */ jsx(Box, { justifyContent: "center", children: /* @__PURE__ */ jsx(Text, { color: colors.muted, children: "↓ More below" }) }), projects.length > viewportHeight && /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [ "Showing ", scrollOffset + 1, "-", Math.min(scrollOffset + viewportHeight, projects.length), " of ", projects.length ] }) }) ] }); } //#endregion //#region src/components/ConfigurationSetup.tsx function ConfigurationSetup({ onComplete }) { const [setupState, setSetupState] = useState({ type: "inputPat" }); function handlePatSubmit(pat) { setSetupState({ type: "inputOrg", pat }); } async function handleOrgSubmit(org) { if (setupState.type !== "inputOrg") return; const { pat } = setupState; setSetupState({ type: "validatingPat", pat, org }); try { const tempConfig = { azureDevOpsPat: pat, azureDevOpsOrg: org, azureDevOpsProject: "" }; const service = new AzureDevOpsService(tempConfig); const projects = await service.getProjects(); if (projects.length === 0) { setSetupState({ type: "setupError", error: "No projects found in this organization. Please check the organization name.", canRetry: true }); return; } setSetupState({ type: "selectProject", pat, org, projects }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; const canRetry = errorMessage.includes("Invalid PAT") || errorMessage.includes("Organization not found"); setSetupState({ type: "setupError", error: errorMessage, canRetry }); } } async function handleProjectSelect(projectName) { if (setupState.type !== "selectProject") return; const { pat, org } = setupState; setSetupState({ type: "savingConfig", pat, org, project: projectName }); try { await savePatToKeyring(pat); await saveConfigFile({ azureDevOpsOrg: org, azureDevOpsProject: projectName, version: "1.0.0", autoUpdate: true }); setSetupState({ type: "setupComplete" }); setTimeout(() => { onComplete(); }, 1e3); } catch (error) { setSetupState({ type: "setupError", error: error instanceof Error ? error.message : "Failed to save configuration", canRetry: false }); } } if (setupState.type === "inputPat") return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [/* @__PURE__ */ jsx(Header, {}), /* @__PURE__ */ jsx(PatInput, { onSubmit: handlePatSubmit })] }); if (setupState.type === "inputOrg") return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [/* @__PURE__ */ jsx(Header, {}), /* @__PURE__ */ jsx(OrganizationInput, { onSubmit: handleOrgSubmit })] }); if (setupState.type === "validatingPat") return /* @__PURE__ */ jsx(LoadingScreen, { message: "Validating credentials and fetching projects..." }); if (setupState.type === "selectProject") return /* @__PURE__ */ jsx(ProjectSelector, { projects: setupState.projects, onSelect: handleProjectSelect }); if (setupState.type === "savingConfig") return /* @__PURE__ */ jsx(LoadingScreen, { message: "Saving configuration securely..." }); if (setupState.type === "setupComplete") return /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { color: colors.foam, children: "✓ Configuration saved successfully!" }) }); if (setupState.type === "setupError") return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [/* @__PURE__ */ jsx(ErrorDisplay, { error: setupState.error }), setupState.canRetry] }); return /* @__PURE__ */ jsx(Text, { children: "Unknown setup state" }); } //#endregion //#region src/utils/formatters.ts function formatDateShort(dateString) { const date = new Date(dateString); return date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } //#endregion //#region src/components/BranchRow.tsx function BranchRow({ name: name$1, merged, date, mergedBy, marginBottom = 0 }) { return /* @__PURE__ */ jsxs(Box, { marginBottom, children: [ /* @__PURE__ */ jsx(Box, { width: 12, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.subtle, bold: true, children: name$1 }) }), /* @__PURE__ */ jsx(Box, { width: 20, flexShrink: 0, children: /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsx(Text, { color: merged ? semanticColors.success : semanticColors.error, children: merged ? "✓" : "✗" }), /* @__PURE__ */ jsxs(Text, { color: colors.subtle, children: [" ", merged ? "Merged" : "Not Merged"] })] }) }), /* @__PURE__ */ jsx(Box, { width: 20, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.subtle, children: date ? formatDateShort(date) : "-" }) }), /* @__PURE__ */ jsx(Box, { width: 20, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.subtle, children: mergedBy || "-" }) }) ] }); } //#endregion //#region src/components/StatusSummary.tsx function StatusSummary({ status }) { const branches = [ "dev", "qa", "staging", "master" ]; const mergedCount = branches.filter((branch) => status.mergedTo[branch].merged).length; const totalCount = branches.length; const getStatusColor = () => { if (status.mergedTo.master.merged) return colors.pine; if (status.mergedTo.staging.merged) return colors.iris; if (status.mergedTo.qa.merged) return colors.gold; if (status.mergedTo.dev.merged) return colors.rose; return colors.text; }; const getStatusLabel = () => { if (status.mergedTo.master.merged) return "Wubba Lubba Dub Dub! It’s Done"; if (status.mergedTo.staging.merged) return "Almost There, Morty!"; if (status.mergedTo.qa.merged) return "In Progress, Keep It Schwifty!"; if (status.mergedTo.dev.merged) return "OoooWeeee just in Dev?"; return "Not Started"; }; return /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginY: 1, children: /* @__PURE__ */ jsxs(Text, { color: getStatusColor(), bold: true, children: [ "Merge Status: ", mergedCount, "/", totalCount, " branches merged (", getStatusLabel(), ")" ] }) }); } //#endregion //#region src/components/TableHeader.tsx function TableHeader() { return /* @__PURE__ */ jsxs(Box, { children: [ /* @__PURE__ */ jsx(Box, { width: 12, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.text, bold: true, children: "Branch" }) }), /* @__PURE__ */ jsx(Box, { width: 20, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.text, bold: true, children: "Status" }) }), /* @__PURE__ */ jsx(Box, { width: 20, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.text, bold: true, children: "Date" }) }), /* @__PURE__ */ jsx(Box, { width: 20, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.text, bold: true, children: "Author" }) }) ] }); } //#endregion //#region src/components/MergeStatusDisplay.tsx function MergeStatusDisplay({ status }) { return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, { color: colors.subtle, bold: true, children: ["Branch:", " "] }), /* @__PURE__ */ jsx(Text, { color: colors.text, children: status.branch })] }), /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, { color: colors.subtle, bold: true, children: ["Repository:", " "] }), /* @__PURE__ */ jsx(Text, { color: colors.text, children: status.repository })] }), /* @__PURE__ */ jsx(StatusSummary, { status }), /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.overlay, paddingX: 1, children: [ /* @__PURE__ */ jsx(TableHeader, {}), /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: colors.overlay, children: "─────────────────────────────────────────────────────────────────────────" }) }), /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [ /* @__PURE__ */ jsx(BranchRow, { name: "Dev", merged: status.mergedTo.dev.merged, date: status.mergedTo.dev.date, mergedBy: status.mergedTo.dev.mergedBy }), /* @__PURE__ */ jsx(BranchRow, { name: "QA", merged: status.mergedTo.qa.merged, date: status.mergedTo.qa.date, mergedBy: status.mergedTo.qa.mergedBy }), /* @__PURE__ */ jsx(BranchRow, { name: "Staging", merged: status.mergedTo.staging.merged, date: status.mergedTo.staging.date, mergedBy: status.mergedTo.staging.mergedBy }), /* @__PURE__ */ jsx(BranchRow, { name: "Master", merged: status.mergedTo.master.merged, date: status.mergedTo.master.date, mergedBy: status.mergedTo.master.mergedBy }) ] }) ] }) ] }); } //#endregion //#region src/components/MultiRepositoryMergeStatusDisplay.tsx function MultiRepositoryMergeStatusDisplay({ statuses, operationSummary, onNewSearch }) { useInput((_input, key) => { if (key.return && onNewSearch) onNewSearch(); }); if (statuses.length === 0) return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [/* @__PURE__ */ jsxs(Text, { color: colors.gold, children: [ "Branch not found in any of ", operationSummary.total, " repositories" ] }), /* @__PURE__ */ jsx(Footer, { showSearch: true })] }); return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [ /* @__PURE__ */ jsxs(Text, { bold: true, color: colors.foam, children: [ "Found branch in ", statuses.length, " of ", operationSummary.total, " repositories" ] }), operationSummary.failed > 0 && /* @__PURE__ */ jsxs(Text, { color: colors.gold, children: [ "Warning: ", operationSummary.failed, " repositories failed to scan" ] }), statuses.map((status, index) => /* @__PURE__ */ jsx(Box, { marginBottom: index < statuses.length - 1 ? 1 : 0, children: /* @__PURE__ */ jsx(MergeStatusDisplay, { status }) }, status.repository)), /* @__PURE__ */ jsx(Footer, { showSearch: true }) ] }); } //#endregion //#region src/components/UpdateNotification.tsx /** * Displays an informational alert about available updates. * * Shows the version upgrade path and indicates that auto-update is running * in the background. Rendered at the top of the application when a newer * version is detected. * * @param props - Component props containing version information * @returns Alert component showing update status * @example * <UpdateNotification currentVersion="1.0.0" latestVersion="1.1.0" /> */ function UpdateNotification({ currentVersion, latestVersion }) { return /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingBottom: 1, children: /* @__PURE__ */ jsxs(Alert, { variant: "info", children: [ "A new version is available (", currentVersion, " → ", latestVersion, "). Updating automatically in the background..." ] }) }); } //#endregion //#region src/utils/version-compare.ts /** * Compares two semantic version strings. * * Handles versions with or without 'v' prefix (e.g., "v1.2.3" or "1.2.3"). * Treats missing version parts as 0 (e.g., "1.2" is treated as "1.2.0"). * * @param v1 - First version string (e.g., "1.2.3" or "v1.2.3") * @param v2 - Second version string (e.g., "1.3.0" or "v1.3.0") * @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal * @example * compareVersions("1.2.3", "1.3.0"); // Returns -1 * compareVersions("2.0.0", "1.9.9"); // Returns 1 * compareVersions("v1.0.0", "1.0.0"); // Returns 0 */ function compareVersions(v1, v2) { const clean1 = v1.replace(/^v/, ""); const clean2 = v2.replace(/^v/, ""); const parts1 = clean1.split(".").map(Number); const parts2 = clean2.split(".").map(Number); const maxLength = Math.max(parts1.length, parts2.length); for (let i = 0; i < maxLength; i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; if (p1 > p2) return 1; if (p1 < p2) return -1; } return 0; } /** * Checks if the first version is newer than the second version. * * Convenience wrapper around compareVersions for determining if an update is available. * * @param v1 - First version string to compare * @param v2 - Second version string to compare against * @returns true if v1 is newer than v2, false otherwise * @example * isNewerVersion("1.2.3", "1.2.0"); // Returns true * isNewerVersion("1.0.0", "1.0.0"); // Returns false * isNewerVersion("0.9.0", "1.0.0"); // Returns false */ function isNewerVersion(v1, v2) { return compareVersions(v1, v2) > 0; } //#endregion //#region src/utils/update-checker.ts const UPDATE_CACHE_FILE = "update-cache.json"; const DEFAULT_CHECK_INTERVAL = 24 * 60 * 60 * 1e3; const NPM_REGISTRY_URL = "https://registry.npmjs.org/fmdt/latest"; /** * Gets the absolute path to the update cache file. * * @returns The full path to update-cache.json in the config directory */ function getUpdateCachePath() { return join(getConfigDir(), UPDATE_CACHE_FILE); } /** * Loads the update cache from disk. * * @returns The cached update information or null if not found/invalid */ async function loadUpdateCache() { try { const cachePath = getUpdateCachePath(); const content = await readFile(cachePath, "utf-8"); const parsed = JSON.parse(content); return parsed; } catch { return null; } } /** * Saves the update cache to disk. * * Creates the config directory if it doesn't exist. Silently fails on errors * to prevent cache issues from breaking the application. * * @param cache - The update cache data to save */ async function saveUpdateCache(cache) { try { const configDir = getConfigDir(); const cachePath = getUpdateCachePath(); await mkdir(configDir, { recursive: true }); await writeFile(cachePath, JSON.stringify(cache, null, 2), "utf-8"); } catch {} } /** * Fetches the latest version of fmdt from the npm registry. * * Makes a request to the npm registry with a 5-second timeout. * Returns null on network errors, timeouts, or parse failures to fail gracefully. * * @returns The latest version string (e.g., "1.2.3") or null if fetch fails * @example * const latest = await fetchLatestVersion(); * if (latest) { * console.log(`Latest version: ${latest}`); * } */ async function fetchLatestVersion() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5e3); const response = await fetch(NPM_REGISTRY_URL, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) return null; const data = await response.json(); return data.version; } catch { return null; } } /** * Checks for available updates to fmdt. * * Uses a disk cache to avoid excessive registry requests. Only checks for updates * if the cache is older than the specified interval (default: 24 hours). * Respects NO_UPDATE_NOTIFIER=1 environment variable to disable checks. * * @param currentVersion - The currently installed version (e.g., "1.0.0") * @param checkInterval - Milliseconds before checking again (default: 24 hours) * @returns UpdateInfo object if newer version is available, null otherwise * @example * const updateInfo = await checkForUpdates('1.0.0'); * if (updateInfo) { * console.log(`Update available: ${updateInfo.latestVersion}`); * } */ async function checkForUpdates(currentVersion, checkInterval = DEFAULT_CHECK_INTERVAL) { try { if (process.env.NO_UPDATE_NOTIFIER === "1") return null; const cache = await loadUpdateCache(); const now = Date.now(); if (cache && now - cache.lastCheck < checkInterval) { if (isNewerVersion(cache.latestVersion, currentVersion)) return { currentVersion, latestVersion: cache.latestVersion }; return null; } const latestVersion = await fetchLatestVersion(); if (!latestVersion) return null; await saveUpdateCache({ lastCheck: now, latestVersion }); if (isNewerVersion(latestVersion, currentVersion)) return { currentVersion, latestVersion }; return null; } catch { return null; } } //#endregion //#region src/utils/auto-updater.ts /** * Detects which package manager was used to install fmdt globally. * * Tries to detect the package manager by: * 1. Checking process.execPath to prioritize the runtime (bun vs node) * 2. Running package manager list commands to see which has fmdt installed * 3. Returns 'unknown' if detection fails * * @returns The detected package manager or 'unknown' if detection fails * @example * const pm = await detectPackageManager(); * if (pm !== 'unknown') { * console.log(`Detected ${pm}`); * } */ async function detectPackageManager() { try { const execPath = process.execPath.toLowerCase(); const checks = [ { name: "npm", command: [ "npm", "list", "-g", "--depth=0" ] }, { name: "bun", command: [ "bun", "pm", "ls", "-g" ] }, { name: "pnpm", command: [ "pnpm", "list", "-g", "--depth=0" ] }, { name: "yarn", command: [ "yarn", "global", "list" ] } ]; checks.sort((a, b) => { const aMatches = execPath.includes(a.name); const bMatches = execPath.includes(b.name); if (aMatches && !bMatches) return -1; if (!aMatches && bMatches) return 1; return 0; }); for (const check of checks) try { const proc = Bun.spawn({ cmd: check.command, stdout: "pipe", stderr: "pipe" }); const output = await new Response(proc.stdout).text(); await proc.exited; if (output.includes("fmdt")) return check.name; } catch {} return "unknown"; } catch { return "unknown"; } } /** * Executes the upgrade command for the given package manager. * * Runs the appropriate global install command based on the detected package manager. * Handles npm, bun, pnpm, and yarn with their respective syntaxes. * * @param method - The package manager to use for the upgrade * @param targetVersion - The version to upgrade to (e.g., "1.2.3") * @returns Object indicating success/failure and optional error message * @example * const result = await executeUpgrade('npm', '1.2.3'); * if (result.success) { * console.log('Upgrade successful'); * } else { * console.error('Upgrade failed:', result.error); * } */ async function executeUpgrade(method, targetVersion) { try { if (method === "unknown") return { success: false, error: "Unknown installation method" }; const command = (() => { switch (method) { case "npm": return [ "npm", "install", "-g", `fmdt@${targetVersion}` ]; case "bun": return [ "bun", "add", "-g", `fmdt@${targetVersion}` ]; case "pnpm": return [ "pnpm", "add", "-g", `fmdt@${targetVersion}` ]; case "yarn": return [ "yarn", "global", "add", `fmdt@${targetVersion}` ]; default: return null; } })(); if (!command) return { success: false, error: `Unsupported package manager: ${method}` }; const proc = Bun.spawn({ cmd: command, stdout: "pipe", stderr: "pipe" }); const stderr = await new Response(proc.stderr).text(); const exitCode = await proc.exited; if (exitCode !== 0) return { success: false, error: stderr || "Upgrade command failed" }; return { success: true }; } catch (error) { return { success: false, error: String(error) }; } } /** * Performs automatic self-update of the fmdt package. * * This is the main auto-update orchestrator that: * 1. Checks environment variables for opt-out (NO_UPDATE_NOTIFIER, FMDT_DISABLE_AUTO_UPDATE) * 2. Fetches the latest version from npm registry * 3. Compares versions to determine if update is needed * 4. Detects the package manager used for installation * 5. Executes the upgrade command if appropriate * * This function is designed to run in the background without blocking app startup. * It never throws errors and always returns a result object. * * @param currentVersion - The current