diff --git a/apps/cli/src/lib/star-prompt.ts b/apps/cli/src/lib/star-prompt.ts index 087c6718..98e6ff26 100644 --- a/apps/cli/src/lib/star-prompt.ts +++ b/apps/cli/src/lib/star-prompt.ts @@ -3,9 +3,11 @@ * `agentbox self-update`. * * Cadence (deliberately gentle, no nagging): - * - install: skip runs 1, 2 and 5+; ask on the 3rd and 4th completed wizard. - * - self-update: ask on every run. - * - once the user has starred via `gh` (confirmed), never ask again. + * - install: skip runs 1, 2 and 5+; the 3rd and 4th completed wizards are + * the ask window (the 4th only fires if the 3rd went unanswered). + * - self-update: ask until the user answers. + * - once the user answers — yes or no — never ask again. A yes without an + * authenticated `gh` opens the browser; we trust it and still stop asking. * * State lives at `~/.agentbox/star-prompt.json` (same dir as the first-run * marker). Self-contained — no dependency on `@agentbox/relay`, whose `gh` @@ -27,9 +29,15 @@ interface StarState { version: typeof STATE_VERSION; installCount: number; starred: boolean; + answered: boolean; } -const DEFAULT_STATE: StarState = { version: STATE_VERSION, installCount: 0, starred: false }; +const DEFAULT_STATE: StarState = { + version: STATE_VERSION, + installCount: 0, + starred: false, + answered: false, +}; function starStatePath(): string { return join(homedir(), '.agentbox', 'star-prompt.json'); @@ -44,6 +52,7 @@ function readStarState(): StarState { version: STATE_VERSION, installCount: typeof parsed.installCount === 'number' ? parsed.installCount : 0, starred: parsed.starred === true, + answered: parsed.answered === true, }; } catch { // Corrupt/unreadable — treat as fresh rather than crashing install. @@ -73,7 +82,7 @@ export async function maybePromptStar(opts: StarPromptOptions): Promise { if (!process.stdout.isTTY) return; const state = readStarState(); - if (state.starred) return; + if (state.starred || state.answered) return; let shouldPrompt: boolean; if (opts.trigger === 'install') { @@ -90,6 +99,9 @@ export async function maybePromptStar(opts: StarPromptOptions): Promise { message: 'Help support this open source project — would you like to star it on GitHub? Thanks!', initialValue: true, }); + // Any explicit answer settles it — never re-ask. (Ctrl+C aborts before this.) + state.answered = true; + writeStarState(state); if (!yes) return; try { diff --git a/apps/cli/test/star-prompt.test.ts b/apps/cli/test/star-prompt.test.ts index cc4cf944..c4a5debb 100644 --- a/apps/cli/test/star-prompt.test.ts +++ b/apps/cli/test/star-prompt.test.ts @@ -29,14 +29,18 @@ const { spawnSync } = (await import('node:child_process')) as unknown as { }; const STATE = (): string => join(HOME, '.agentbox', 'star-prompt.json'); -function readState(): { installCount: number; starred: boolean } | null { +function readState(): { installCount: number; starred: boolean; answered: boolean } | null { return existsSync(STATE()) - ? (JSON.parse(readFileSync(STATE(), 'utf8')) as { installCount: number; starred: boolean }) + ? (JSON.parse(readFileSync(STATE(), 'utf8')) as { + installCount: number; + starred: boolean; + answered: boolean; + }) : null; } -function seedState(installCount: number, starred = false): void { +function seedState(installCount: number, starred = false, answered = false): void { mkdirSync(join(HOME, '.agentbox'), { recursive: true }); - writeFileSync(STATE(), JSON.stringify({ version: 1, installCount, starred })); + writeFileSync(STATE(), JSON.stringify({ version: 1, installCount, starred, answered })); } let prevTTY: boolean | undefined; @@ -60,14 +64,22 @@ describe('maybePromptStar — install cadence', () => { expect(readState()?.installCount).toBe(2); }); - it('prompts on the 3rd and 4th install, not the 5th', async () => { + it('prompts on the 3rd install, then the recorded answer suppresses the 4th window', async () => { seedState(2); - await maybePromptStar({ trigger: 'install' }); // -> 3 + await maybePromptStar({ trigger: 'install' }); // -> 3, prompts and records the answer expect(confirm).toHaveBeenCalledTimes(1); + await maybePromptStar({ trigger: 'install' }); // -> answered, no prompt (count frozen) + expect(confirm).toHaveBeenCalledTimes(1); + expect(readState()?.installCount).toBe(3); + }); + + it('re-asks on the 4th install only if the 3rd never got an answer recorded', async () => { + seedState(3); // 3rd window passed without an answer (e.g. process died mid-prompt) await maybePromptStar({ trigger: 'install' }); // -> 4 - expect(confirm).toHaveBeenCalledTimes(2); - await maybePromptStar({ trigger: 'install' }); // -> 5 - expect(confirm).toHaveBeenCalledTimes(2); + expect(confirm).toHaveBeenCalledTimes(1); + seedState(4); + await maybePromptStar({ trigger: 'install' }); // -> 5, past the window + expect(confirm).toHaveBeenCalledTimes(1); expect(readState()?.installCount).toBe(5); }); }); @@ -87,21 +99,31 @@ describe('maybePromptStar — guards', () => { await maybePromptStar({ trigger: 'self-update' }); expect(confirm).not.toHaveBeenCalled(); }); + + it('never prompts once answered, even unstarred', async () => { + seedState(2, false, true); + await maybePromptStar({ trigger: 'install' }); + await maybePromptStar({ trigger: 'self-update' }); + expect(confirm).not.toHaveBeenCalled(); + }); }); describe('maybePromptStar — self-update', () => { - it('always prompts regardless of install count', async () => { + it('prompts regardless of install count until answered', async () => { await maybePromptStar({ trigger: 'self-update' }); expect(confirm).toHaveBeenCalledTimes(1); }); }); describe('maybePromptStar — star action', () => { - it('answering no does nothing', async () => { + it('answering no records the answer and never asks again', async () => { confirm.mockResolvedValue(false); await maybePromptStar({ trigger: 'self-update' }); expect(spawnSync).not.toHaveBeenCalled(); expect(readState()?.starred).toBeFalsy(); + expect(readState()?.answered).toBe(true); + await maybePromptStar({ trigger: 'self-update' }); + expect(confirm).toHaveBeenCalledTimes(1); }); it('gh ready → stars via gh api and records starred=true', async () => { @@ -131,6 +153,10 @@ describe('maybePromptStar — star action', () => { spawnSync.mock.calls.some((c) => Array.isArray(c[1]) && c[1].includes('PUT')), ).toBe(false); expect(readState()?.starred).toBeFalsy(); + // The answer is still recorded — the browser path must not re-ask forever. + expect(readState()?.answered).toBe(true); + await maybePromptStar({ trigger: 'self-update' }); + expect(confirm).toHaveBeenCalledTimes(1); }); it('gh missing (ENOENT) → falls back to the browser', async () => {