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
3 changes: 2 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ test('rejects more than one positional argument', async () => {
});

test('writes a report when the GitHub calls succeed', async () => {
const { ctx, githubClient, fs, analytics } = createFakeContext();
const { ctx, githubClient, fs, analytics, prompter } = createFakeContext();

githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([
{
Expand Down Expand Up @@ -100,6 +100,7 @@ test('writes a report when the GitHub calls succeed', async () => {
// The completed run hands the html back so the caller (index.ts) can drive
// the share prompt without re-reading the filesystem.
expect(result.run.html).toContain('<html');
expect(prompter.spinnerEvents).toContainEqual({ type: 'stop', message: 'Scanned acme.' });

expect(analytics.capturedEvents('run_started')[0]?.properties).toMatchObject({
window_days: 90,
Expand Down
4 changes: 1 addition & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,7 @@ export async function main(ctx: Context, argv: readonly string[]): Promise<MainR
return { kind: 'failed', code: 1 };
}

spinner.stop(
`Scanned ${opts.target}: ${stats.reposIncluded} of ${stats.reposTotal} repos · ${stats.dependabotPrs} Dependabot PRs in window.`,
);
spinner.stop(`Scanned ${opts.target}.`);
ctx.analytics.capture('run_completed', {
window_days: opts.windowDays,
repos_total: stats.reposTotal,
Expand Down
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,12 @@ const ctx = createContext({
const result = await main(ctx, argv);

if (result.kind === 'completed') {
const identifier = distinctId.length > 0 ? distinctId : 'anonymous';
await runOpenReportPrompt({ context: ctx, htmlPath: result.run.paths.html });
await runSharePrompt({
context: ctx,
target: result.run.target,
htmlPath: result.run.paths.html,
htmlContent: result.run.html,
identifier,
});
}

Expand Down
28 changes: 19 additions & 9 deletions src/interactive/sharePrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ describe('runSharePrompt', () => {
const reportReadyNote = handle.prompter.notes.find((n) => n.title === 'Report ready');
expect(reportReadyNote?.message).toContain('acme');
expect(reportReadyNote?.message).toContain('/tmp/report.html');
expect(handle.prompter.selects[0]?.message).toContain('share this with us');
expect(reportReadyNote?.message).not.toContain('Open it to see what would be sent');
expect(handle.prompter.selects[0]?.message).toContain('waitlist');
expect(handle.prompter.selects[0]?.message).toContain("We'll upload exactly what's on disk");
expect(handle.prompter.selects[0]?.message).toContain("won't share your data");
expect(handle.prompter.selects[0]?.choices.map((c) => c.value)).toEqual(['html', 'declined']);
expect(handle.prompter.selects[0]?.initialValue).toBe('html');
});
Expand All @@ -34,34 +37,41 @@ describe('runSharePrompt', () => {

test('html-only: uploads the raw html bytes with kind:html', async () => {
const handle = fakeContextHandle.build();
handle.prompter.scriptSelect('html').scriptText('');
handle.prompter.scriptSelect('html').scriptText('ben@example.com');

const outcome = await runSharePrompt(sharePromptInputsFor(handle));

expect(outcome).toMatchObject({ kind: 'shared', identifier: 'anon-uuid' });
expect(outcome).toMatchObject({ kind: 'shared', identifier: 'ben@example.com' });
expect(handle.uploader.calls).toHaveLength(1);
expect(handle.uploader.calls[0]).toMatchObject({
identifier: 'anon-uuid',
identifier: 'ben@example.com',
appVersion: '0.0.1',
timestamp: '2026-05-22T12:00:00Z',
});
expect(new TextDecoder().decode(handle.uploader.calls[0]?.bytes)).toBe('<!doctype html><html></html>');
expect(handle.analytics.capturedEvents('upload_succeeded')).toHaveLength(1);
expect(handle.prompter.outros[0]).toContain("you're on the PatchWave waitlist");
expect(handle.prompter.outros[0]).toContain('ben@example.com');
expect(handle.prompter.outros[0]?.toLowerCase()).not.toContain('upload id');
expect(handle.prompter.outros[0]).not.toContain('fake-upload-id');
});

test('uses a volunteered email as the identifier', async () => {
test('email prompt requires a valid email address', async () => {
const handle = fakeContextHandle.build();
handle.prompter.scriptSelect('html').scriptText('ben@example.com');

const outcome = await runSharePrompt(sharePromptInputsFor(handle));
await runSharePrompt(sharePromptInputsFor(handle));

expect(outcome).toMatchObject({ kind: 'shared', identifier: 'ben@example.com' });
expect(handle.uploader.calls[0]?.identifier).toBe('ben@example.com');
const validate = handle.prompter.texts[0]?.validate;
expect(handle.prompter.texts[0]?.message).toBe('Email:');
expect(validate?.('')).toContain('email');
expect(validate?.('not an email')).toBeDefined();
expect(validate?.('ben@example.com')).toBeUndefined();
});

test('upload failure surfaces the error and leaves the report in place', async () => {
const handle = fakeContextHandle.build();
handle.prompter.scriptSelect('html').scriptText('');
handle.prompter.scriptSelect('html').scriptText('ben@example.com');
handle.uploader.fails({ kind: 'presign-bad-status', status: 500, body: 'boom' });

const outcome = await runSharePrompt(sharePromptInputsFor(handle));
Expand Down
38 changes: 14 additions & 24 deletions src/interactive/sharePrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export interface SharePromptInputs {
readonly target: string;
readonly htmlPath: string;
readonly htmlContent: string;
readonly identifier: string;
}

export type ShareOutcome =
Expand All @@ -23,23 +22,16 @@ export type ShareOutcome =
export async function runSharePrompt(inputs: SharePromptInputs): Promise<ShareOutcome> {
const { prompter, analytics, uploader } = inputs.context;

prompter.note(
[
`Scanned: ${inputs.target}`,
`HTML report: ${inputs.htmlPath}`,
'',
"Open it to see what would be sent — we'll upload exactly what's on disk.",
].join('\n'),
'Report ready',
);
prompter.note([`Scanned: ${inputs.target}`, `HTML report: ${inputs.htmlPath}`].join('\n'), 'Report ready');

analytics.capture('share_prompt_shown', {});

const choiceResult = await prompter.select<ShareChoice>({
message: "Would you like to share this with us? We won't share your data with anyone.",
message:
"Share this report with PatchWave? Uploading it bumps your spot on the waitlist. We'll upload exactly what's on disk and won't share your data with anyone.",
initialValue: 'html',
choices: [
{ value: 'html', label: 'Share the HTML report', hint: 'the .html you saw above' },
{ value: 'html', label: 'Share the HTML report', hint: 'boost your waitlist spot' },
{ value: 'declined', label: 'No thanks — keep it local', hint: 'nothing leaves your machine' },
],
});
Expand All @@ -59,7 +51,7 @@ export async function runSharePrompt(inputs: SharePromptInputs): Promise<ShareOu
return { kind: 'declined' };
}

const identifierResult = await maybeAskForEmail(prompter, inputs.identifier);
const identifierResult = await askForEmail(prompter);
if (identifierResult.kind === 'cancelled') {
declinedOutro(inputs);
return { kind: 'cancelled' };
Expand Down Expand Up @@ -90,7 +82,9 @@ export async function runSharePrompt(inputs: SharePromptInputs): Promise<ShareOu
const { uploadId } = uploadResult.value;
spinner.stop('Uploaded.');
analytics.capture('upload_succeeded', {});
prompter.outro(`Thanks for sharing! Upload id: ${uploadId}. We'll be in touch if anything jumps out.`);
prompter.outro(
`Thanks — you're on the PatchWave waitlist. We'll use this report to prioritize early access and follow up at ${identifier}.`,
);
return { kind: 'shared', uploadId, identifier };
}

Expand All @@ -108,27 +102,23 @@ function declinedOutro(inputs: SharePromptInputs): void {
prompter.outro('Done.');
}

async function maybeAskForEmail(
prompter: Prompter,
fallbackIdentifier: string,
): Promise<{ kind: 'ok'; identifier: string } | { kind: 'cancelled' }> {
async function askForEmail(prompter: Prompter): Promise<{ kind: 'ok'; identifier: string } | { kind: 'cancelled' }> {
const result = await prompter.text({
message: 'Email (optional, so we can follow up):',
placeholder: 'leave blank to stay anonymous',
defaultValue: '',
message: 'Email:',
placeholder: 'you@example.com',
validate: (value) => {
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
if (trimmed.length === 0) return 'Please enter an email address for the waitlist.';
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed) ? undefined : "that doesn't look like an email address";
},
});

if (result.isErr()) {
if (result.error.kind === 'cancelled') return { kind: 'cancelled' };
prompter.warn(formatPromptError(result.error));
return { kind: 'ok', identifier: fallbackIdentifier };
return { kind: 'cancelled' };
}

const trimmed = result.value.trim();
return { kind: 'ok', identifier: trimmed.length > 0 ? trimmed : fallbackIdentifier };
return { kind: 'ok', identifier: trimmed };
}
2 changes: 2 additions & 0 deletions src/interactive/targetPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe('promptForTarget', () => {
expect(choices[0]?.hint).toContain('personal');
expect(choices[1]?.hint).toContain('organization');
expect(choices.at(-1)?.label).toContain('Other');
expect(prompter.spinnerEvents).toContainEqual({ type: 'clear' });
expect(prompter.spinnerEvents).not.toContainEqual({ type: 'stop', message: 'Found 3 options.' });
});

test('"Other" routes to a free-text prompt with login validation', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/interactive/targetPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function promptForTarget(deps: TargetPromptDeps): ResultAsync<string, Pro
spinner.stop("Couldn't list your orgs — type one instead.");
return promptForTargetText(deps.prompter);
}
spinner.stop(`Found ${options.length} option${options.length === 1 ? '' : 's'}.`);
spinner.clear();
return pickFromOptions(deps.prompter, options);
});
}
Expand Down
1 change: 0 additions & 1 deletion src/interactive/testFactories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export const sharePromptInputs = Factory.define<SharePromptInputs>(() => {
target: 'acme',
htmlPath: '/tmp/report.html',
htmlContent: '<!doctype html><html></html>',
identifier: 'anon-uuid',
};
});

Expand Down
3 changes: 2 additions & 1 deletion src/prompt/Prompter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface TextOptions {
export interface PromptSpinner {
start(msg?: string): void;
stop(msg?: string): void;
clear(): void;
}

export interface Prompter {
Expand Down Expand Up @@ -105,7 +106,7 @@ export class PrompterImpl implements Prompter {

spinner(): PromptSpinner {
const s = clack.spinner();
return { start: (m) => s.start(m), stop: (m) => s.stop(m) };
return { start: (m) => s.start(m), stop: (m) => s.stop(m), clear: () => s.clear() };
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/testHelpers/FakePrompter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type TextAnswer = ScriptedAnswer<'text', string>;
type Answer = ConfirmAnswer | SelectAnswer | TextAnswer;

export interface SpinnerEvent {
readonly type: 'start' | 'stop';
readonly type: 'start' | 'stop' | 'clear';
readonly message?: string;
}

Expand Down Expand Up @@ -107,6 +107,9 @@ export class FakePrompter implements Prompter {
stop: (message?: string) => {
this.spinnerEvents.push({ type: 'stop', message });
},
clear: () => {
this.spinnerEvents.push({ type: 'clear' });
},
};
}

Expand Down