`gh repo star`는 존재하지 않습니다: CLI 설치 wizard에서 GitHub 스타 자동화하기
오픈소스 CLI 도구에 "GitHub star 한 번 부탁드려요" 프롬프트를 붙일 때 만나게 되는 네 가지 함정과 해법. 핵심은 `gh repo star`라는 서브커맨드 자체가 gh CLI에 없다는 점, 그리고 선택적 후속 작업의 Ctrl+C 처리·재프롬프트 방지 marker 타이밍·stderr 누수 제어입니다.
1. 문제 상황
오픈소스 CLI 도구를 설치할 때 사용자에게 조심스럽게 "도움이 되셨다면 GitHub star 한 번 부탁드려요"라고 물어보는 기능은 꽤 흔한 패턴입니다. 제대로 구현하면 사용자 경험을 해치지 않으면서 프로젝트 발견성을 높일 수 있지만, 처음 시도하면 거의 확실히 밟게 되는 네 가지 함정이 있습니다.
저는 이 기능을 두 프로젝트(ghost-mcp, duru)에 연달아 구현하면서 같은 버그를 두 번 만났습니다. 초기 구현은 이런 코드였습니다.
// scripts/setup.mjs — 초기 잘못된 구현
import { spawnSync } from 'child_process';
function tryStar() {
if (!ghAvailable()) {
console.log(`gh CLI not found. Star manually at ${REPO_URL}`);
return;
}
// 이 줄이 모든 사용자 환경에서 조용히 실패합니다
const r = spawnSync('gh', ['repo', 'star', REPO], { encoding: 'utf-8' });
if (r.status === 0) {
console.log('★ Thanks!');
} else {
console.log('gh repo star failed — star manually');
}
}
증상
- 로컬 개발:
gh repo star owner/repo명령을 직접 쳐도 에러가 뚜렷이 안 나옵니다. gh는 알 수 없는 서브커맨드 호출 시에도 종종repohelp 메시지를 출력하고 exit 0 또는 exit 1로 끝납니다. - 설치 wizard: 사용자는 "★ Thanks!" 메시지를 보지만 실제로는 star가 안 됩니다. 혹은 "gh repo star failed" 같은 fallback 메시지가 사용자를 혼란스럽게 합니다.
- 자동화 훅: CI나 install hook에서는 stderr 한 줄이 빨간 글씨로 UI에 튀어 나와 설치 경험을 망칩니다.
즉, 정적 코드 리뷰로는 발견이 어렵고 실행/배포 후에야 드러나는 런타임 버그입니다. 자동 코드 리뷰 봇도 세 라운드에 걸쳐 이 코드를 "spawnSync with array — safe ✅"라고만 평가하고 넘어갔습니다.
발생 시점
- 사용자가 설치 wizard를 실행하고 star 프롬프트에 Y를 선택
- 사용자 머신에
ghCLI가 설치되어 있고gh auth login도 완료된 상태 - 즉 "모든 전제 조건이 맞는" 행복 경로(happy path)에서 실패
영향 범위
- star 자동화 기능 자체가 작동하지 않음
- 혼란스러운 fallback 메시지 노출
- "설치 중 에러가 난 건가?" 하는 사용자 불안 유발
2. 원인 분석
2.1 gh repo에는 star가 없습니다
gh repo --help를 실제로 실행해 보면 다음만 나옵니다.
$ gh repo --help | grep "^\s\+[a-z]"
create: Create a new repository
list: List repositories owned by user or organization
archive: Archive a repository
autolink: Manage autolink references
clone: Clone a repository locally
delete: Delete a repository
deploy-key: Manage deploy keys in a repository
edit: Edit repository settings
fork: Create a fork of a repository
gitignore: List and view available repository gitignore templates
license: Explore repository licenses
rename: Rename a repository
set-default: Configure default repository for this directory
sync: Sync a repository
unarchive: Unarchive a repository
view: View a repository
star는 어디에도 없습니다. 이는 gh CLI의 의도된 설계입니다. star는 리소스(repo)의 속성이 아니라 사용자(user)의 starred 컬렉션에 대한 조작이기 때문에 gh repo 하위 명사 공간에 넣을 수 없었습니다.
2.2 gh가 exit 0을 돌려주는 함정
그런데 왜 로컬에서 gh repo star owner/repo를 쳐도 명확한 에러가 안 나올까요? gh는 일부 unknown subcommand에 대해 help를 출력하면서 exit 0으로 끝내는 경우가 있습니다. 이 동작은 버전에 따라 일관적이지 않고, 어떤 경로에서는 exit 1을 주기도 합니다. spawnSync로 호출했을 때 사용자 환경/버전에 따라 r.status가 0 또는 null이 되는데, 어느 쪽이든 "star가 실제로 걸렸는지"와는 무관합니다.
2.3 올바른 길: GitHub REST API PUT /user/starred/{owner}/{repo}
GitHub REST API는 star를 사용자 컬렉션에 대한 조작으로 모델링합니다.
PUT /user/starred/{owner}/{repo} # star
DELETE /user/starred/{owner}/{repo} # unstar
GET /user/starred/{owner}/{repo} # check
- 성공 시 204 No Content 반환 (본문 없음)
- 이미 star된 경우에도 204 No Content (idempotent)
- 인증 필요: user scope 토큰
그리고 gh CLI는 이 API를 직접 호출할 수 있는 gh api 서브커맨드를 제공합니다. 이걸 쓰면 PAT(Personal Access Token)를 별도로 관리할 필요 없이 사용자가 이미 gh auth login으로 로그인해 둔 세션의 권한을 그대로 빌려 쓸 수 있습니다.
gh api --method PUT --silent /user/starred/owner/repo
--method PUT: REST 메서드 지정--silent: 응답 본문 출력 억제 (204라 어차피 비어 있지만, 에러 시 JSON 에러 바디를 UI에 뿌리지 않기 위함)
3. 해결 방법
3.1 Before/After
Before (틀림):
function tryStar() {
if (!ghAvailable()) { /* ... */ return; }
if (!ghAuthed()) {
console.log(`Run gh auth login then: gh repo star ${REPO}`);
// ^^^^^^^^^^^^^ 존재하지 않는 명령 안내
return;
}
const r = spawnSync('gh', ['repo', 'star', REPO], { encoding: 'utf-8' });
// ^^^^^^^^^^^^^^^^^^^^^ 실제로 star 안 됨
if (r.status === 0) {
console.log('★ Thanks!');
} else {
console.log(`gh repo star failed — star manually at ${REPO_URL}`);
// ^^^^^^^^^^^^^^^^^^^^ 원인을 잘못 알려줌
}
}
After (올바름):
function tryStar() {
if (!ghAvailable()) {
console.log(`gh CLI not found. Star manually at:\n ${REPO_URL}`);
return;
}
if (!ghAuthed()) {
console.log(
`gh not authenticated. Run gh auth login then star at:\n ${REPO_URL}`
);
return;
}
// ← 핵심 변경점: gh api PUT으로 REST API 직접 호출
const r = spawnSync(
'gh',
['api', '--method', 'PUT', '--silent', `/user/starred/${REPO}`],
{ stdio: ['ignore', 'ignore', 'ignore'] } // ← stderr 누수 방지
);
if (r.status === 0) {
console.log('★ Thanks!');
} else {
console.log(`(star failed — you can star manually at\n ${REPO_URL})`);
}
}
3.2 변경점별 설명
변경 1: 서브커맨드 교체
['repo', 'star', REPO]
// →
['api', '--method', 'PUT', '--silent', `/user/starred/${REPO}`]
gh api는 GitHub REST API를 직접 호출하는 통용 서브커맨드입니다. gh가 해주는 일은:
- 현재 로그인 세션의 토큰을
Authorization: Bearer ...헤더에 자동 주입 - GitHub Enterprise Server 사용 시 base URL 자동 설정
- 페이지네이션/리다이렉트 처리
즉 별도의 HTTP 클라이언트 없이 gh 인증을 재사용할 수 있습니다.
변경 2: stdio 완전 억제
{ stdio: ['ignore', 'ignore', 'ignore'] }
기본값인 'pipe'나 encoding: 'utf-8'을 쓰면 spawnSync는 stdout/stderr를 프로세스 버퍼로 받아오지만, 실패 시 gh가 stderr로 JSON 에러({"message": "Not Found", ...})를 토해냅니다. 이 버퍼는 우리 코드가 호출하지 않는 한 UI에 안 나오지만, stdio: 'inherit'로 설정되거나 실수로 console.log(r.stderr) 같은 코드가 끼면 바로 setup UI에 빨간 JSON이 튀어나옵니다. 애초에 ignore로 막아버리는 편이 덜 위험합니다.
변경 3: fallback 메시지 정정
gh repo star 언급을 모두 제거하고 "수동 star URL 안내"로 통일했습니다. 존재하지 않는 명령을 사용자에게 안내하면 안 됩니다.
3.3 검증
# 실제 동작 확인
$ gh api --method PUT --silent /user/starred/your-owner/your-repo
$ echo $?
0
# 잘못된 repo일 때
$ gh api --method PUT --silent /user/starred/your-owner/nonexistent
gh: Not Found (HTTP 404)
$ echo $?
1
# 이미 star한 경우 (idempotent)
$ gh api --method PUT --silent /user/starred/your-owner/your-repo
$ echo $?
0
Idempotent하기 때문에 "이미 star했는데 또 호출되면?" 같은 걱정이 불필요합니다. 204가 반복해서 옵니다.
4. 추가로 밟은 세 개의 UX 함정
gh repo star만 고치면 끝이 아니었습니다. 프롬프트 UX를 깔끔하게 만들려면 세 가지 함정을 더 피해야 합니다.
4.1 함정 A: Ctrl+C가 "Setup cancelled"를 띄우는 문제
@clack/prompts 같은 TUI 라이브러리는 보통 confirm()이 반환한 값을 isCancel()로 확인하는 패턴을 씁니다. 공통 유틸로 다음처럼 쓰곤 합니다.
import { isCancel, cancel, confirm } from '@clack/prompts';
function check(value) {
if (isCancel(value)) {
cancel('Setup cancelled.');
process.exit(0);
}
return value;
}
// 사용
const url = check(await text({ message: 'Ghost blog URL' }));
const key = check(await password({ message: 'Admin API Key' }));
const editor = check(await select({ /* ... */ }));
URL/API key/에디터 프롬프트에서 Ctrl+C는 "정말로 setup을 중단"하는 게 맞으니 "Setup cancelled."가 정확한 메시지입니다. 문제는 star 프롬프트는 setup이 이미 성공한 뒤의 선택적 후속 작업이라는 점입니다.
async function main() {
// ... URL, API key, editor 프롬프트 ...
writeConfig(); // ← 여기까지 오면 설정은 이미 저장됨
await offerStar(); // ← 여기서 Ctrl+C를 누르면?
outro('Restart your editor to activate the MCP server.');
}
Ctrl+C를 누르면 check()가 process.exit(0)를 호출해 "Setup cancelled."가 뜹니다. 사용자 입장에서는:
설정은 저장됐는데 "Setup cancelled"가 뜬다? 내 config 날아간 건가?
라는 혼란을 줍니다. outro 메시지도 안 보이고 끝납니다.
해법: 공통 check()는 그대로 두고(다른 프롬프트에서는 중단이 맞으므로), star 프롬프트만 isCancel을 직접 검사해서 silent return합니다.
async function offerStar() {
const yes = await confirm({
message: 'If it helped, a GitHub star makes it discoverable. Star now?',
initialValue: false,
});
// ← 핵심: Ctrl+C = "no thanks, skip the star"
// check()/bail()을 경유하지 않으므로 "Setup cancelled." 안 뜸
// main()의 outro()가 정상 실행됨
if (isCancel(yes)) return;
if (yes) tryStar();
}
4.2 함정 B: Ctrl+C 시 다음 실행에서 재프롬프트
"한 번 물어봤으면 다시는 묻지 않는다"는 원칙을 지키려면 marker 파일이 필요합니다.
const STAR_MARKER = path.join(os.homedir(), '.config', 'ghost-mcp', '.star-prompted');
function markStarPrompted() {
try {
fs.mkdirSync(path.dirname(STAR_MARKER), { recursive: true });
fs.writeFileSync(STAR_MARKER, '');
} catch {
// best effort — 읽기 전용 FS에서도 setup이 안 깨지게
}
}
문제는 marker를 기록하는 타이밍입니다. 초기 구현은 이랬습니다.
async function offerStar() {
if (fs.existsSync(STAR_MARKER)) return;
const yes = await confirm({ /* ... */ });
if (isCancel(yes)) return; // ← 여기서 return하면 marker 안 찍힘!
markStarPrompted(); // ← Ctrl+C 경로에서는 도달 못 함
if (yes) tryStar();
}
Ctrl+C를 누르면 marker가 안 찍혀 다음 실행에서 또 물어봅니다. 설치가 이미 성공한 상태에서 두 번째 실행이면 그 프롬프트는 급격히 성가셔집니다.
해법: marker를 프롬프트 전에 찍습니다.
async function offerStar() {
if (fs.existsSync(STAR_MARKER)) return;
// ← 핵심: 프롬프트 전에 marker 기록
// 어떤 경로(Y/N/Ctrl+C)로 나가든 "한 번 물어봤음"이 보장됨
markStarPrompted();
const yes = await confirm({ /* ... */ });
if (isCancel(yes)) return;
if (yes) tryStar();
}
이 marker-before-prompt 패턴은 코드만 보면 왜 순서가 저런지 알기 어렵습니다. 숨겨진 불변식이라 주석으로 의도를 남겨두는 편이 안전합니다.
// Mark before prompting so Ctrl+C is treated as "No" — prevents re-asking
// on the next run when setup itself already succeeded.
markStarPrompted();
4.3 함정 C: stderr 누수로 setup UI 깨짐
앞서 본 stdio: ['ignore', 'ignore', 'ignore']의 맥락을 다시 보겠습니다. gh가 gh api 호출에 실패하면 stderr로 다음 같은 출력을 냅니다.
gh: Not Found (HTTP 404)
또는
{"message":"Resource not accessible by integration","documentation_url":"..."}
TUI 라이브러리는 자기 자신의 렌더링 루프를 가지고 있기 때문에, 외부 프로세스가 갑자기 stderr에 내뱉는 문자열이 중간에 끼면 프롬프트 박스가 깨집니다. 특히 ANSI 컬러 코드와 프롬프트 커서 제어가 섞이면 "내 터미널이 이상해졌나?" 싶은 장면이 나옵니다.
해법: 하위 프로세스의 stdio를 모두 ignore로 닫고, 판정은 exit status로만 합니다.
const r = spawnSync('gh', [...], {
stdio: ['ignore', 'ignore', 'ignore'],
});
if (r.status === 0) success();
else failureMessage(); // ← 우리가 컨트롤하는 깔끔한 문구만
5. 플래그 설계: opt-in, CI, dotfiles
CLI에 star 프롬프트를 넣으려면 사용자가 마주하는 여러 환경을 고려해야 합니다. 세 개의 직교하는(flag orthogonality) 플래그로 해결할 수 있습니다.
| 플래그 | 용도 | 동작 |
|---|---|---|
--yes |
비대화형(non-interactive) | 모든 프롬프트 건너뛰기. star 프롬프트는 아예 안 물음. marker도 안 찍음. |
--star |
명시적 opt-in | 프롬프트 없이 바로 star. dotfiles/CI에서 "star는 해도 된다"는 동의 표현. |
--force-star-prompt |
디버그/재문의 | marker가 있어도 무시하고 다시 물어봄. |
구현 스케치:
const ARGS = new Set(process.argv.slice(2));
const FLAG_YES = ARGS.has('--yes');
const FLAG_STAR = ARGS.has('--star');
const FLAG_FORCE_STAR_PROMPT = ARGS.has('--force-star-prompt');
async function offerStar() {
// --star: 프롬프트 없이 바로 star
if (FLAG_STAR) {
tryStar();
markStarPrompted();
return;
}
// --yes: 비대화형 → 프롬프트 스킵, marker도 안 찍음
// 비대화형 실행이 다음 대화형 실행의 상태를 망가뜨리지 않도록
if (FLAG_YES) return;
// 이미 물어봤으면 조용히 통과
if (fs.existsSync(STAR_MARKER) && !FLAG_FORCE_STAR_PROMPT) return;
markStarPrompted(); // 프롬프트 전에 찍기 (4.2 함정 B)
const yes = await confirm({ /* ... */ });
if (isCancel(yes)) return; // Ctrl+C = silent skip (4.1 함정 A)
if (yes) tryStar();
}
--yes가 marker를 안 찍는 이유
이건 의도적입니다. 비대화형 runner(Docker 빌드, CI, 자동 provisioning)에서 --yes를 쓰는 시점에는 사용자가 아직 star 프롬프트를 본 적이 없습니다. 만약 여기서 marker를 찍어버리면 나중에 사용자가 대화형 터미널로 setup을 다시 돌려도 프롬프트가 안 나옵니다. "비대화형 실행이 대화형 상태를 소리 없이 덮어쓰는" 반패턴을 피하자는 의도입니다.
--star는 marker를 찍는 이유
반대로 --star는 "나는 이 질문에 답할 의사가 이미 있어, 그냥 Yes다"라는 명시적 동의입니다. 프롬프트를 본 것과 동등하므로 marker를 찍어 다음 실행에서 묻지 않습니다.
6. 핵심 개념 정리
| 개념 | 내용 |
|---|---|
gh repo star |
존재하지 않는 서브커맨드. 사용 금지. |
| 올바른 star 방법 | gh api --method PUT --silent /user/starred/{owner}/{repo} |
| Idempotency | PUT은 여러 번 호출해도 204. 재시도/중복 신경 안 써도 됨. |
stdio: ignore |
gh의 stderr가 TUI에 누수되지 않게 전부 닫기. |
isCancel 직접 검사 |
"선택적 후속 작업"의 Ctrl+C는 abort 아닌 skip으로 처리. |
| marker-before-prompt | Ctrl+C 포함 모든 출구에서 "물어봤음"이 보장되도록 선행 기록. |
--yes vs --star |
전자는 스킵(상태 미기록), 후자는 동의(상태 기록). |
7. 베스트 프랙티스 체크리스트
CLI 자동화에서 GitHub star(또는 비슷한 사용자 행동 대행) 기능을 넣을 때:
- [ ] 실제 서브커맨드 존재 확인:
gh <cmd> --help로 먼저 검증. help 메시지가 예상과 다르면 그 명령은 없는 것. - [ ] REST API를 1차 후보로: CLI 래핑보다 API가 계약이 명확.
gh api로 재사용 가능. - [ ]
stdio: ignore를 기본값으로: 상위 프로세스 UI를 하위 프로세스 stderr로부터 보호. - [ ] opt-in 기본값 No:
initialValue: false로 기본 동의를 빼앗지 않기. - [ ] 한 번만 묻기: marker 파일로 재프롬프트 방지. 위치는
~/.config/<app>/.<marker>. - [ ] Ctrl+C를 "skip"으로 처리: 선택적 후속 작업의 취소는 abort가 아님.
- [ ] marker는 프롬프트 전에 기록: 모든 출구에서 "물어봤음"이 성립하도록.
- [ ] 플래그 직교성 확보:
--yes(비대화형),--star(자동 동의),--force-star-prompt(재문의)를 분리. - [ ] 실패 시 수동 URL 안내: gh 미설치/미인증/API 실패 모두
https://github.com/{owner}/{repo}링크로 폴백. - [ ] 실제 엔드투엔드 테스트: 정적 리뷰는 이런 런타임 버그를 거의 못 잡음. 로컬에서 한 번은
gh api로 찍어볼 것.
8. FAQ
Q1. gh repo star가 에러를 안 내니까 맞는 명령 아닌가요?
A. 아닙니다. gh는 일부 unknown subcommand 호출에 대해 repo 도움말을 출력하고 exit 0 또는 exit 1을 돌려주는데, 이는 "star 요청을 처리했다"는 뜻이 아닙니다. 실제 star 여부는 gh api /user/starred/{owner}/{repo}로 상태 확인하거나 브라우저에서 확인해야 합니다.
Q2. PAT(Personal Access Token)로 직접 호출해도 되지 않나요?
A. 됩니다. curl -X PUT -H "Authorization: Bearer $PAT" https://api.github.com/user/starred/owner/repo 같은 방식도 동작합니다. 다만 설치 wizard에서는 사용자에게 PAT를 요구할 수 없고, 자체 클라이언트 시크릿을 내장하면 GitHub가 이를 토큰 유출로 취급합니다. gh CLI가 이미 있다면 gh auth로 로그인된 세션을 재사용하는 게 가장 안전합니다.
Q3. 이미 star한 사람이 다시 Yes를 누르면 어떻게 되나요?
A. PUT /user/starred/...는 idempotent라 204를 다시 반환합니다. 사용자에게는 동일하게 "★ Thanks!"가 뜨고 아무 부작용이 없습니다.
Q4. gh가 없는 사용자는 어떻게 처리하나요?
A. ghAvailable()이 false면 star 시도 없이 https://github.com/{owner}/{repo} 링크만 안내합니다. 이 경로에서는 프롬프트 자체를 건너뛰는 설계도 고려할 수 있지만, "한 번 안내하고 넘어가기"가 마찰이 덜해서 저는 프롬프트를 유지하고 폴백 메시지만 내보냅니다.
Q5. 어차피 star를 원하면 브라우저에서 누를 텐데 왜 CLI로 자동화하나요?
A. 두 가지 이유입니다. (1) 이미 터미널에서 작업 중인 사용자에게 브라우저 탭 전환을 요구하지 않음. (2) gh auth를 신뢰 기반으로 재사용함으로써 별도 권한 요청 없이 한 동작으로 끝남. 다만 기본값은 No로 두고 opt-in으로만 작동시키는 것이 기본 예의입니다.
Q6. Clack 외에 inquirer/prompts 같은 다른 라이브러리에서도 같은 문제가 있나요?
A. 라이브러리마다 cancel 처리 방식이 다릅니다. inquirer는 Ctrl+C 시 promise reject로 처리하는 편이라 try/catch로 감싸면 됩니다. 핵심은 "취소 = abort"인지 "취소 = skip"인지 맥락에 따라 다르게 처리하는 것입니다. 라이브러리가 무엇이든 이 구분은 필요합니다.
Q7. 두 프로젝트에서 같은 버그가 반복됐다고 하셨는데 재발 방지는?
A. 팀 규칙(CLAUDE.md, wiki, 내부 문서)에 "GitHub star 자동화는 gh api --method PUT /user/starred/{owner}/{repo} 패턴을 사용할 것"을 한 줄로 박아두는 게 가장 저렴합니다. skill이나 hook으로 gh repo star를 grep해서 경고하는 것도 가능합니다. 저는 전자부터 적용했습니다.
9. 참고 자료
- GitHub REST API — Starring: 검색 키워드 "GitHub REST API starring user starred"
- GitHub CLI Manual —
gh api: 검색 키워드 "gh cli manual api" @clack/promptsREADME: 검색 키워드 "clack prompts isCancel"- Node.js
child_process.spawnSyncstdio 옵션: 검색 키워드 "nodejs spawnSync stdio ignore"
10. 마무리
CLI 자동화에서 "사용자 대신 한 번의 동작을 수행한다"는 작업은 겉보기에 단순해 보이지만, 네 가지 함정(없는 서브커맨드, cancel 의미론, marker 타이밍, stderr 누수)을 모두 피해야 깔끔해집니다. 정적 리뷰가 놓치는 실행해 봐야 드러나는 종류의 버그가 많고, 본인이 만든 도구를 본인의 머신에서만 써봐도 안 드러날 수 있습니다.
핵심은 이 한 줄입니다.
gh repo star는 존재하지 않습니다.gh api --method PUT --silent /user/starred/{owner}/{repo}를 쓰세요.
그리고 옆에 두 줄만 더 붙이면 됩니다.
선택적 후속 작업의 Ctrl+C는 abort가 아닌 skip으로 처리하세요.
marker는 프롬프트 전에 기록하세요.