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
JavaScript
#!/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