Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
648a73a
feat: add anonymous CLI telemetry via PostHog
felipefreitag Mar 17, 2026
35c3b86
refactor: rename telemetry event to cli.used
felipefreitag Mar 18, 2026
da96118
refactor: replace is_ci/json_mode with interactive flag
felipefreitag Mar 18, 2026
3b68feb
feat: deliver telemetry via detached self-invocation for zero latency
felipefreitag Mar 18, 2026
636ad3f
refactor: harden telemetry delivery and simplify payload
felipefreitag Mar 19, 2026
94c4be5
fix: make telemetry test OS-agnostic for CI compatibility
felipefreitag Mar 19, 2026
580fe1d
fix: handle pkg compiled binary in telemetry self-invocation
felipefreitag Mar 19, 2026
4d4e51a
feat: inject PostHog key at build time via esbuild define
felipefreitag Mar 19, 2026
c27e44d
feat: track explicitly-passed flag names in telemetry
felipefreitag Mar 19, 2026
714b701
fix: set PKG_EXECPATH for pkg binary self-invocation
felipefreitag Mar 19, 2026
239c95b
ci: add Windows telemetry smoke test workflow
felipefreitag Mar 19, 2026
2b7b54e
fix: harden telemetry against review findings
felipefreitag Mar 19, 2026
c0d91a1
fix: harden telemetry against review findings
felipefreitag Mar 19, 2026
f2c1f52
ci: fix Windows telemetry test with PowerShell
felipefreitag Mar 19, 2026
5ba457b
fix: strip surrounding quotes from .env values in build script
felipefreitag Mar 19, 2026
c0ad46b
ci: verify temp file creation before checking consumption
felipefreitag Mar 19, 2026
589b5e2
fix: only write notice marker when notice is actually shown
felipefreitag Mar 19, 2026
6d36159
ci: fix PowerShell syntax in Windows telemetry test
felipefreitag Mar 19, 2026
b244f42
ci: remove push trigger from Windows telemetry test
felipefreitag Mar 19, 2026
d36410d
refactor: distinguish install-script from manual installs in detection
felipefreitag Mar 19, 2026
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
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ concurrency:
group: release
cancel-in-progress: false
jobs:
preflight:
runs-on: ubuntu-latest
steps:
- name: Verify PostHog key is configured
run: |
if [ -z "${{ secrets.POSTHOG_PUBLIC_KEY }}" ]; then
echo "::error::POSTHOG_PUBLIC_KEY secret is not set"
exit 1
fi
test-binary:
needs: preflight
strategy:
fail-fast: false
matrix:
Expand All @@ -34,13 +44,16 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Node bundle
run: pnpm build
env:
POSTHOG_PUBLIC_KEY: ${{ secrets.POSTHOG_PUBLIC_KEY }}
- name: Build binary
run: pnpm exec pkg dist/cli.cjs --compress Brotli --target ${{ matrix.target }} --output ${{ matrix.binary }}
- name: Verify binary runs
run: |
${{ matrix.binary }} --version
${{ matrix.binary }} --help
test-binary-linux-arm64:
needs: preflight
runs-on: blacksmith-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
Expand All @@ -53,6 +66,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Node bundle
run: pnpm build
env:
POSTHOG_PUBLIC_KEY: ${{ secrets.POSTHOG_PUBLIC_KEY }}
- name: Build binary (no-bytecode workaround)
run: pnpm exec pkg dist/cli.cjs --compress Brotli --target node24-linux-arm64 --output dist/resend --no-bytecode --public-packages "*" --public
- name: Set up QEMU
Expand All @@ -79,6 +94,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Node bundle
run: pnpm build
env:
POSTHOG_PUBLIC_KEY: ${{ secrets.POSTHOG_PUBLIC_KEY }}
- name: Build macOS binaries
run: |
pnpm exec pkg dist/cli.cjs --compress Brotli --target node24-macos-arm64 --output dist/resend-darwin-arm64
Expand Down Expand Up @@ -117,6 +134,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build Node bundle
run: pnpm build
env:
POSTHOG_PUBLIC_KEY: ${{ secrets.POSTHOG_PUBLIC_KEY }}
- name: Build binaries
run: |
pnpm exec pkg dist/cli.cjs --compress Brotli --target node24-linux-x64 --output dist/resend-linux-x64
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/test-telemetry-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Test Telemetry (Windows)
on:
workflow_dispatch:
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Node bundle
run: pnpm build
env:
POSTHOG_PUBLIC_KEY: ${{ secrets.POSTHOG_STAGING_KEY }}
- name: Build Windows binary
run: pnpm exec pkg dist/cli.cjs --compress Brotli --target node24-win-x64 --output dist/resend.exe
- name: Test telemetry spawn from binary
run: |
$tmpdir = [System.IO.Path]::GetTempPath()

# Clear leftover telemetry files
Remove-Item "$tmpdir\resend-telemetry-*.json" -ErrorAction SilentlyContinue

# Run a real command that triggers telemetry
dist/resend.exe --api-key re_fake whoami
Write-Host "Binary exited with code $LASTEXITCODE"

# Verify the temp file WAS created
Start-Sleep -Seconds 1
$created = Get-ChildItem "$tmpdir\resend-telemetry-*.json" -ErrorAction SilentlyContinue
if (-not $created) {
Write-Host "::error::Telemetry temp file was never created — trackCommand did not fire"
exit 1
}
Write-Host "Temp file created: $($created.FullName)"

# Give the detached child time to flush
Start-Sleep -Seconds 10

# Verify the temp file was consumed by the child
$remaining = Get-ChildItem "$tmpdir\resend-telemetry-*.json" -ErrorAction SilentlyContinue
if ($remaining) {
Write-Host "::error::Telemetry temp file was not consumed — detached self-spawn failed"
$remaining | Format-Table Name, Length, LastWriteTime
exit 1
}
Write-Host "Telemetry spawn OK — file created and consumed by child process"
- name: How to verify the event
if: always()
run: |
Write-Host "Check the staging PostHog project for a 'cli.used' event with:"
Write-Host " command: whoami"
Write-Host " os: Windows"
Write-Host " install_method: other"
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"scripts": {
"dev": "tsx src/cli.ts",
"dev:watch": "tsx --watch src/cli.ts",
"build": "esbuild src/cli.ts --bundle --platform=node --format=cjs --minify --outfile=dist/cli.cjs",
"build": "node scripts/build.mjs",
"build:bin": "pnpm build && pkg dist/cli.cjs --compress Brotli --target node24 --output dist/resend",
"lint": "biome check .",
"lint:fix": "biome check --write .",
Expand All @@ -39,7 +39,7 @@
"test:e2e": "vitest run --config vitest.config.e2e.ts",
"sync-skill-version": "node scripts/sync-skill-version.mjs",
"version": "pnpm run sync-skill-version && git add skills/resend-cli/SKILL.md",
"prepack": "pnpm run sync-skill-version && pnpm build"
"prepack": "pnpm run sync-skill-version && pnpm build && node scripts/verify-bundle.mjs"
},
"dependencies": {
"@clack/prompts": "1.1.0",
Expand Down
30 changes: 30 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readFileSync } from 'node:fs';
import { build } from 'esbuild';

function loadDotenv() {
try {
const content = readFileSync('.env', 'utf8');
for (const line of content.split('\n')) {
const match = line.match(/^\s*([^#=]+?)\s*=\s*(.*?)\s*$/);
if (match) {
process.env[match[1]] ??= match[2].replace(/^(['"])(.*)\1$/, '$2');
}
}
} catch {}
}

loadDotenv();

const posthogKey = process.env.POSTHOG_PUBLIC_KEY ?? '';

await build({
entryPoints: ['src/cli.ts'],
bundle: true,
platform: 'node',
format: 'cjs',
minify: true,
outfile: 'dist/cli.cjs',
define: {
'process.env.POSTHOG_PUBLIC_KEY': JSON.stringify(posthogKey),
},
});
10 changes: 10 additions & 0 deletions scripts/verify-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { readFileSync } from 'node:fs';

const bundle = readFileSync('dist/cli.cjs', 'utf8');

if (!bundle.includes('phc_')) {
console.error(
'Error: POSTHOG_PUBLIC_KEY not found in bundle — add it to .env',
);
process.exit(1);
}
54 changes: 53 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ import { webhooksCommand } from './commands/webhooks/index';
import { whoamiCommand } from './commands/whoami';
import { setupCliExitHandler } from './lib/cli-exit';
import { errorMessage, outputError } from './lib/output';
import { trackCommand } from './lib/telemetry';
import { checkForUpdates } from './lib/update-check';
import { PACKAGE_NAME, VERSION } from './lib/version';

setupCliExitHandler();

let lastCommandName = '';
let lastFlags: string[] = [];
let lastGlobalFlags: string[] = [];

const program = new Command()
.name('resend')
.description('Resend CLI — email for developers')
Expand Down Expand Up @@ -53,6 +58,30 @@ const program = new Command()
'Save API key as plaintext instead of secure storage',
)
.hook('preAction', (thisCommand, actionCommand) => {
const parts: string[] = [];
for (
let cmd = actionCommand;
cmd?.parent;
cmd = cmd.parent as typeof actionCommand
) {
parts.unshift(cmd.name());
}
lastCommandName = parts.join(' ');

const extractFlags = (cmd: typeof actionCommand) =>
cmd.options
.filter(
(opt) => cmd.getOptionValueSource(opt.attributeName()) === 'cli',
)
.map(
(opt) =>
opt.long?.replace(/^--/, '') ?? opt.short?.replace(/^-/, '') ?? '',
)
.filter(Boolean);

lastFlags = extractFlags(actionCommand);
lastGlobalFlags = extractFlags(thisCommand);

if (actionCommand.optsWithGlobals().quiet) {
thisCommand.setOptionValue('json', true);
}
Expand Down Expand Up @@ -119,6 +148,20 @@ ${pc.gray('Examples:')}
.addCommand(updateCommand)
.addCommand(teamsDeprecatedCommand);

const telemetryCommand = new Command('telemetry')
.description('Telemetry management')
.helpCommand(false);

telemetryCommand
.command('flush')
.argument('<file>')
.action(async (file) => {
const { flushFromFile } = await import('./lib/telemetry');
await flushFromFile(file);
});

program.addCommand(telemetryCommand, { hidden: true });

// Hide the deprecated --team option from help
const teamOption = program.options.find((o) => o.long === '--team');
if (teamOption) {
Expand All @@ -130,9 +173,18 @@ program
.then(() => {
// Skip the background update notice when the user explicitly ran `update`
const ran = program.args[0];
if (ran === 'update') {
if (ran === 'update' || ran === 'telemetry') {
return;
}

if (lastCommandName) {
trackCommand(lastCommandName, {
...program.opts(),
flags: lastFlags,
globalFlags: lastGlobalFlags,
});
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

return checkForUpdates().catch(() => {});
})
.catch((err) => {
Expand Down
Loading