From 799a17bc2e1fc106781167fe7539231ce06699e4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 31 May 2026 05:07:08 +0900 Subject: [PATCH] feat(installer): prompt before starring repos --- src/cli/cli-installer.platform.test.ts | 6 +- src/cli/cli-installer.ts | 40 ++++++++++++-- src/cli/star-request.test.ts | 45 +++++++++++++++ src/cli/star-request.ts | 33 +++++++++++ src/cli/tui-installer.test.ts | 76 ++++++++++++++++++++++++++ src/cli/tui-installer.ts | 19 +++++-- 6 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 src/cli/star-request.test.ts diff --git a/src/cli/cli-installer.platform.test.ts b/src/cli/cli-installer.platform.test.ts index 7dbfe578f..b3b064c63 100644 --- a/src/cli/cli-installer.platform.test.ts +++ b/src/cli/cli-installer.platform.test.ts @@ -153,7 +153,7 @@ describe("runCliInstaller platform branching", () => { expect(result).toBe(0) }) - test("prints star commands for OpenAgent and LazyCodex", async () => { + test("does not print star commands in noninteractive installs", async () => { // given stubOpenCodeSuccess() spyOn(codexInstaller, "runCodexInstaller").mockResolvedValue(codexResult) @@ -164,7 +164,7 @@ describe("runCliInstaller platform branching", () => { // then const output = consoleLogMock.mock.calls.map((call) => call.join(" ")).join("\n") expect(result).toBe(0) - expect(output).toContain("/user/starred/code-yeongyu/oh-my-openagent") - expect(output).toContain("/user/starred/code-yeongyu/lazycodex") + expect(output).not.toContain("/user/starred/code-yeongyu/oh-my-openagent") + expect(output).not.toContain("/user/starred/code-yeongyu/lazycodex") }) }) diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index a8f0aaf1b..aa1fdf588 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -1,3 +1,4 @@ +import { createInterface } from "node:readline/promises" import color from "picocolors" import { PLUGIN_NAME, PUBLISHED_PACKAGE_NAME } from "../shared" import type { InstallArgs } from "./types" @@ -24,7 +25,7 @@ import { } from "./install-validators" import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { runCodexInstaller } from "./install-codex" -import { STAR_REPOSITORIES, formatGitHubStarCommand } from "./star-request" +import { starGitHubRepositories } from "./star-request" export async function runCliInstaller(args: InstallArgs, version: string): Promise { const validation = validateNonTuiArgs(args) @@ -168,11 +169,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi "The Magic Word", ) - console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`) - for (const repository of STAR_REPOSITORIES) { - console.log(` ${color.dim(formatGitHubStarCommand(repository))}`) - } - console.log() + await maybePromptForGitHubStars() console.log(color.dim("oMoMoMoMo... Enjoy!")) console.log() @@ -188,3 +185,34 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi return 0 } + +async function maybePromptForGitHubStars(): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) return + + const readline = createInterface({ input: process.stdin, output: process.stdout }) + try { + const answer = await readline.question(`${SYMBOLS.star} ${color.yellow("Star the repos on GitHub?")} ${color.dim("[y/N]")} `) + if (!isYes(answer)) return + } finally { + readline.close() + } + + const results = await starGitHubRepositories() + const failed = results.filter((result) => !result.ok) + if (failed.length === 0) { + printSuccess("Starred GitHub repositories") + console.log() + return + } + + printWarning("Could not star every repository. Make sure GitHub CLI is installed and authenticated.") + for (const result of failed) { + console.log(` ${SYMBOLS.bullet} ${result.repository}`) + } + console.log() +} + +function isYes(value: string): boolean { + const normalized = value.trim().toLowerCase() + return normalized === "y" || normalized === "yes" +} diff --git a/src/cli/star-request.test.ts b/src/cli/star-request.test.ts new file mode 100644 index 000000000..276ab5f2d --- /dev/null +++ b/src/cli/star-request.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { STAR_REPOSITORIES, formatGitHubStarCommand, starGitHubRepositories } from "./star-request" + +describe("star-request", () => { + test("formats the legacy GitHub CLI command for manual fallback output", () => { + // given + const repository = "code-yeongyu/oh-my-openagent" + + // when + const command = formatGitHubStarCommand(repository) + + // then + expect(command).toBe("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-openagent >/dev/null 2>&1 || true") + }) + + test("runs GitHub star requests for every repository", async () => { + // given + const starred: string[] = [] + + // when + const results = await starGitHubRepositories(STAR_REPOSITORIES, async (repository) => { + starred.push(repository) + }) + + // then + expect(starred).toEqual([...STAR_REPOSITORIES]) + expect(results).toEqual(STAR_REPOSITORIES.map((repository) => ({ repository, ok: true }))) + }) + + test("keeps going when one repository cannot be starred", async () => { + // given + const repositories = ["code-yeongyu/oh-my-openagent", "code-yeongyu/lazycodex"] as const + + // when + const results = await starGitHubRepositories(repositories, async (repository) => { + if (repository === "code-yeongyu/lazycodex") throw new Error("gh auth missing") + }) + + // then + expect(results).toEqual([ + { repository: "code-yeongyu/oh-my-openagent", ok: true }, + { repository: "code-yeongyu/lazycodex", ok: false, error: "gh auth missing" }, + ]) + }) +}) diff --git a/src/cli/star-request.ts b/src/cli/star-request.ts index 7926a28f5..e78a32335 100644 --- a/src/cli/star-request.ts +++ b/src/cli/star-request.ts @@ -1,8 +1,41 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" + export const STAR_REPOSITORIES = [ "code-yeongyu/oh-my-openagent", "code-yeongyu/lazycodex", ] as const +const execFileAsync = promisify(execFile) + +export interface GitHubStarResult { + readonly repository: string + readonly ok: boolean + readonly error?: string +} + +export type GitHubStarCommandRunner = (repository: string) => Promise + export function formatGitHubStarCommand(repository: string): string { return `gh api --silent --method PUT /user/starred/${repository} >/dev/null 2>&1 || true` } + +export async function runGitHubStarCommand(repository: string): Promise { + await execFileAsync("gh", ["api", "--silent", "--method", "PUT", `/user/starred/${repository}`]) +} + +export async function starGitHubRepositories( + repositories: readonly string[] = STAR_REPOSITORIES, + runCommand: GitHubStarCommandRunner = runGitHubStarCommand, +): Promise { + const results: GitHubStarResult[] = [] + for (const repository of repositories) { + try { + await runCommand(repository) + results.push({ repository, ok: true }) + } catch (error) { + results.push({ repository, ok: false, error: error instanceof Error ? error.message : String(error) }) + } + } + return results +} diff --git a/src/cli/tui-installer.test.ts b/src/cli/tui-installer.test.ts index 3ec4fac90..11dc2f775 100644 --- a/src/cli/tui-installer.test.ts +++ b/src/cli/tui-installer.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" import * as p from "@clack/prompts" import * as configManager from "./config-manager" +import * as starRequest from "./star-request" import * as tuiInstallPrompts from "./tui-install-prompts" import { runTuiInstaller } from "./tui-installer" @@ -86,6 +87,7 @@ describe("runTuiInstaller", () => { spyOn(p.log, "success").mockImplementation(() => undefined), spyOn(p.log, "message").mockImplementation(() => undefined), spyOn(p, "note").mockImplementation(() => undefined), + spyOn(p, "confirm").mockResolvedValue(false), spyOn(p, "outro").mockImplementation(() => undefined), spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"), spyOn(configManager, "detectCurrentConfig").mockReturnValue({ @@ -152,6 +154,7 @@ describe("runTuiInstaller", () => { spyOn(p.log, "success").mockImplementation(() => undefined), spyOn(p.log, "message").mockImplementation(() => undefined), spyOn(p, "note").mockImplementation(() => undefined), + spyOn(p, "confirm").mockResolvedValue(false), spyOn(p, "outro").mockImplementation(() => undefined), spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("codex"), spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({ @@ -197,4 +200,77 @@ describe("runTuiInstaller", () => { addPluginSpy.mockRestore() writeConfigSpy.mockRestore() }) + + it("stars GitHub repositories when the user confirms", async () => { + // given + const restoreSpies = [ + spyOn(p, "spinner").mockReturnValue(createMockSpinner()), + spyOn(p, "intro").mockImplementation(() => undefined), + spyOn(p.log, "info").mockImplementation(() => undefined), + spyOn(p.log, "warn").mockImplementation(() => undefined), + spyOn(p.log, "success").mockImplementation(() => undefined), + spyOn(p.log, "message").mockImplementation(() => undefined), + spyOn(p, "note").mockImplementation(() => undefined), + spyOn(p, "confirm").mockResolvedValue(true), + spyOn(p, "outro").mockImplementation(() => undefined), + spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("opencode"), + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasCodex: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + hasVercelAiGateway: false, + }), + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"), + spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({ + platform: "opencode", + hasOpenCode: true, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasCodex: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + hasVercelAiGateway: false, + codexAutonomous: false, + }), + spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ + success: true, + configPath: "/tmp/opencode.jsonc", + }), + spyOn(configManager, "writeOmoConfig").mockReturnValue({ + success: true, + configPath: "/tmp/oh-my-opencode.jsonc", + }), + ] + const starSpy = spyOn(starRequest, "starGitHubRepositories").mockResolvedValue([ + { repository: "code-yeongyu/oh-my-openagent", ok: true }, + { repository: "code-yeongyu/lazycodex", ok: true }, + ]) + + // when + const result = await runTuiInstaller({ tui: true }, "3.16.0") + + // then + expect(result).toBe(0) + expect(starSpy).toHaveBeenCalledTimes(1) + + for (const spy of restoreSpies) { + spy.mockRestore() + } + starSpy.mockRestore() + }) }) diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index 0fd0f9621..f24f96cbf 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -13,7 +13,7 @@ import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts" import { runCodexInstaller } from "./install-codex" -import { STAR_REPOSITORIES, formatGitHubStarCommand } from "./star-request" +import { starGitHubRepositories } from "./star-request" export async function runTuiInstaller(args: InstallArgs, version: string): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { @@ -140,9 +140,20 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi "The Magic Word", ) - p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`) - for (const repository of STAR_REPOSITORIES) { - p.log.message(` ${color.dim(formatGitHubStarCommand(repository))}`) + const shouldStar = await p.confirm({ + message: "Star the repos on GitHub?", + initialValue: false, + }) + if (!p.isCancel(shouldStar) && shouldStar) { + spinner.start("Starring GitHub repositories") + const results = await starGitHubRepositories() + const failed = results.filter((result) => !result.ok) + if (failed.length === 0) { + spinner.stop("GitHub repositories starred") + } else { + spinner.stop("Could not star every repository") + p.log.warn("Make sure GitHub CLI is installed and authenticated.") + } } p.outro(color.green("oMoMoMoMo... Enjoy!"))