Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions apps/cli/src/lib/star-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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');
Expand All @@ -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.
Expand Down Expand Up @@ -73,7 +82,7 @@ export async function maybePromptStar(opts: StarPromptOptions): Promise<void> {
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') {
Expand All @@ -90,6 +99,9 @@ export async function maybePromptStar(opts: StarPromptOptions): Promise<void> {
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 {
Expand Down
48 changes: 37 additions & 11 deletions apps/cli/test/star-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
});
});
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading