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
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,20 @@ jobs:
with:
path: node_modules
key: node-modules-${{ matrix.os }}-${{ hashFiles('bun.lock', 'patches/**') }}
- if: steps.cache.outputs.cache-hit != 'true'
run: bun install --frozen-lockfile
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
run: |
# Retry logic for Windows Bun patch bug (ENOTEMPTY errors)
for i in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
echo "Attempt $i failed, clearing Bun cache and retrying..."
bun pm cache rm 2>/dev/null || true
done
echo "All install attempts failed"
exit 1
- name: Build
env:
SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }}
Expand Down
72 changes: 39 additions & 33 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.8",
"@sentry/bun": "10.38.0",
"@sentry/esbuild-plugin": "^2.23.0",
"@sentry/node": "^10.36.0",
"@sentry/node": "10.38.0",
"@stricli/auto-complete": "^1.2.4",
"@stricli/core": "^1.2.4",
"@types/bun": "latest",
Expand All @@ -39,6 +40,7 @@
"tinyglobby": "^0.2.15",
"typescript": "^5",
"ultracite": "6.3.10",
"uuidv7": "^1.1.0",
"zod": "^3.24.0"
},
"repository": {
Expand All @@ -52,6 +54,6 @@
"packageManager": "[email protected]",
"patchedDependencies": {
"@stricli/[email protected]": "patches/@stricli%[email protected]",
"@sentry/core@10.36.0": "patches/@sentry%2Fcore@10.36.0.patch"
"@sentry/core@10.38.0": "patches/@sentry%2Fcore@10.38.0.patch"
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
diff --git a/build/esm/client.js b/build/esm/client.js
index 1111111111111111111111111111111111111111..2222222222222222222222222222222222222222 100644
--- a/build/esm/client.js
+++ b/build/esm/client.js
@@ -643,11 +643,16 @@ class Client {
diff --git a/build/cjs/client.js b/build/cjs/client.js
index 492d897927b0a791e0dac10647274e92053ac985..e253ad00bc5f858b62a9581d09688fe7a5d45f23 100644
--- a/build/cjs/client.js
+++ b/build/cjs/client.js
@@ -645,11 +645,16 @@ class Client {
* `false` otherwise
*/
async _isClientDoneProcessing(timeout) {
Expand All @@ -20,11 +20,24 @@ index 1111111111111111111111111111111111111111..22222222222222222222222222222222

if (!this._numProcessing) {
return true;
diff --git a/build/cjs/client.js b/build/cjs/client.js
index 3333333333333333333333333333333333333333..4444444444444444444444444444444444444444 100644
--- a/build/cjs/client.js
+++ b/build/cjs/client.js
@@ -645,11 +645,16 @@ class Client {
diff --git a/build/cjs/utils/promisebuffer.js b/build/cjs/utils/promisebuffer.js
index 6413b77ec339af98b608c4609fc9462d9a8e967d..85a9af7221cfabcfe5c65df2f4ece5a294d34e61 100644
--- a/build/cjs/utils/promisebuffer.js
+++ b/build/cjs/utils/promisebuffer.js
@@ -71,7 +71,7 @@ function makePromiseBuffer(limit = 100) {
return drainPromise;
}

- const promises = [drainPromise, new Promise(resolve => setTimeout(() => resolve(false), timeout))];
+ const promises = [drainPromise, new Promise(resolve => { const t = setTimeout(() => resolve(false), timeout); if (typeof t !== 'number' && t.unref) t.unref(); })];

// Promise.race will resolve to the first promise that resolves or rejects
// So if the drainPromise resolves, the timeout promise will be ignored
diff --git a/build/esm/client.js b/build/esm/client.js
index bcc614aa8485bd110abc51149370bbf114ac9e54..2677a9bb0b3d9451a0bd50926b3a36516664c69f 100644
--- a/build/esm/client.js
+++ b/build/esm/client.js
@@ -643,11 +643,16 @@ class Client {
* `false` otherwise
*/
async _isClientDoneProcessing(timeout) {
Expand All @@ -43,7 +56,7 @@ index 3333333333333333333333333333333333333333..44444444444444444444444444444444
if (!this._numProcessing) {
return true;
diff --git a/build/esm/utils/promisebuffer.js b/build/esm/utils/promisebuffer.js
index 5555555555555555555555555555555555555555..6666666666666666666666666666666666666666 100644
index b8111cf976c8338db28b5e9ff111870864b584cb..458ea7ccb0a2704df1c2c2ec5fb303cff37dc941 100644
--- a/build/esm/utils/promisebuffer.js
+++ b/build/esm/utils/promisebuffer.js
@@ -69,7 +69,7 @@ function makePromiseBuffer(limit = 100) {
Expand All @@ -55,16 +68,3 @@ index 5555555555555555555555555555555555555555..66666666666666666666666666666666

// Promise.race will resolve to the first promise that resolves or rejects
// So if the drainPromise resolves, the timeout promise will be ignored
diff --git a/build/cjs/utils/promisebuffer.js b/build/cjs/utils/promisebuffer.js
index 7777777777777777777777777777777777777777..8888888888888888888888888888888888888888 100644
--- a/build/cjs/utils/promisebuffer.js
+++ b/build/cjs/utils/promisebuffer.js
@@ -71,7 +71,7 @@ function makePromiseBuffer(limit = 100) {
return drainPromise;
}

- const promises = [drainPromise, new Promise(resolve => setTimeout(() => resolve(false), timeout))];
+ const promises = [drainPromise, new Promise(resolve => { const t = setTimeout(() => resolve(false), timeout); if (typeof t !== 'number' && t.unref) t.unref(); })];

// Promise.race will resolve to the first promise that resolves or rejects
// So if the drainPromise resolves, the timeout promise will be ignored
4 changes: 4 additions & 0 deletions script/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ const result = await build({
entryPoints: ["./src/bin.ts"],
bundle: true,
minify: true,
// Replace @sentry/bun with @sentry/node for Node.js npm package
alias: {
"@sentry/bun": "@sentry/node",
},
banner: {
// Suppress Node.js warnings (e.g., SQLite experimental) - not useful for CLI users
js: `#!/usr/bin/env node
Expand Down
5 changes: 5 additions & 0 deletions script/node-polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { access, readFile, writeFile } from "node:fs/promises";
import { DatabaseSync } from "node:sqlite";

import { glob } from "tinyglobby";
import { uuidv7 } from "uuidv7";

declare global {
var Bun: typeof BunPolyfill;
Expand Down Expand Up @@ -139,6 +140,10 @@ const BunPolyfill = {
}
}
},

randomUUIDv7(): string {
return uuidv7();
},
};

globalThis.Bun = BunPolyfill as typeof Bun;
4 changes: 3 additions & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ async function main(): Promise<void> {
const args = process.argv.slice(2);

try {
await withTelemetry(async () => run(app, args, buildContext(process)));
await withTelemetry(async (span) =>
run(app, args, buildContext(process, span))
);
} catch (err) {
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
process.exit(getExitCode(err));
Expand Down
63 changes: 59 additions & 4 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/bun";
import { buildCommand, numberParser } from "@stricli/core";
import type { SentryContext } from "../../context.js";
import { listOrganizations } from "../../lib/api-client.js";
import { getCurrentUser } from "../../lib/api-client.js";
import { openBrowser } from "../../lib/browser.js";
import { setupCopyKeyListener } from "../../lib/clipboard.js";
import { clearAuth, isAuthenticated, setAuthToken } from "../../lib/db/auth.js";
import { getDbPath } from "../../lib/db/index.js";
import { setUserInfo } from "../../lib/db/user.js";
import { AuthError } from "../../lib/errors.js";
import { muted, success } from "../../lib/formatters/colors.js";
import { formatDuration } from "../../lib/formatters/human.js";
import { completeOAuthFlow, performDeviceFlow } from "../../lib/oauth.js";
import { generateQRCode } from "../../lib/qrcode.js";
import type { SentryUser } from "../../types/index.js";

/**
* Format user identity for display.
* Handles missing username/email gracefully.
*/
function formatUserIdentity(user: SentryUser): string {
const { username, email, id } = user;

if (username && email) {
return `${username} <${email}>`;
}
if (username) {
return username;
}
if (email) {
return email;
}
// Fallback to user ID if no username/email
return `user ${id}`;
}

type LoginFlags = {
readonly token?: string;
Expand Down Expand Up @@ -54,12 +78,13 @@ export const loginCommand = buildCommand({

// Token-based authentication
if (flags.token) {
// Save token first, then validate by making an API call
// Save token first, then validate by fetching user info
await setAuthToken(flags.token);

// Validate the token by trying to list organizations
// Validate token by fetching user info
let user: SentryUser;
try {
await listOrganizations();
user = await getCurrentUser();
} catch {
// Token is invalid - clear it and throw
await clearAuth();
Expand All @@ -69,7 +94,20 @@ export const loginCommand = buildCommand({
);
}

// Store user info for telemetry (non-critical, don't block auth)
try {
setUserInfo({
userId: user.id,
email: user.email,
username: user.username,
});
} catch (error) {
// Report to Sentry but don't block auth - user info is not critical
Sentry.captureException(error);
}

stdout.write(`${success("✓")} Authenticated with API token\n`);
stdout.write(` Logged in as ${muted(formatUserIdentity(user))}\n`);
stdout.write(` Config saved to: ${getDbPath()}\n`);
return;
}
Expand Down Expand Up @@ -133,7 +171,24 @@ export const loginCommand = buildCommand({
// Store the token
await completeOAuthFlow(tokenResponse);

// Fetch and store user info for telemetry
let user: SentryUser | undefined;
try {
user = await getCurrentUser();
setUserInfo({
userId: user.id,
email: user.email,
username: user.username,
});
} catch (error) {
// Report to Sentry but don't block auth - user info is not critical
Sentry.captureException(error);
}

stdout.write(`${success("✓")} Authentication successful!\n`);
if (user) {
stdout.write(` Logged in as ${muted(formatUserIdentity(user))}\n`);
}
stdout.write(` Config saved to: ${getDbPath()}\n`);

if (tokenResponse.expires_in) {
Expand Down
7 changes: 6 additions & 1 deletion src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export const listCommand = buildCommand({
},
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity
async func(this: SentryContext, flags: ListFlags): Promise<void> {
const { stdout, cwd } = this;
const { stdout, cwd, setContext } = this;

// Resolve targets (may find multiple in monorepos)
const { targets, footer, skippedSelfHosted, detectedDsns } =
Expand All @@ -319,6 +319,11 @@ export const listCommand = buildCommand({
usageHint: USAGE_HINT,
});

// Set telemetry context with unique orgs and projects
const orgs = [...new Set(targets.map((t) => t.org))];
const projects = [...new Set(targets.map((t) => t.project))];
setContext(orgs, projects);

if (targets.length === 0) {
if (skippedSelfHosted) {
throw new ContextError(
Expand Down
8 changes: 7 additions & 1 deletion src/commands/issue/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export const viewCommand = buildCommand({
flags: ViewFlags,
issueId: string
): Promise<void> {
const { stdout, cwd } = this;
const { stdout, cwd, setContext } = this;

// Resolve issue using shared resolution logic
const { org: orgSlug, issue } = await resolveIssue({
Expand All @@ -168,6 +168,12 @@ export const viewCommand = buildCommand({
commandHint: buildCommandHint("view", issueId),
});

// Set telemetry context
setContext(
orgSlug ? [orgSlug] : [],
issue.project?.slug ? [issue.project.slug] : []
);

if (flags.web) {
await openInBrowser(stdout, issue.permalink, "issue");
return;
Expand Down
23 changes: 19 additions & 4 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import { homedir } from "node:os";
import type { CommandContext } from "@stricli/core";
import { getConfigDir } from "./lib/db/index.js";
import { setCommandName } from "./lib/telemetry.js";
import {
type Span,
setCommandSpanName,
setOrgProjectContext,
} from "./lib/telemetry.js";
import type { Writer } from "./types/index.js";

export interface SentryContext extends CommandContext {
Expand All @@ -20,16 +24,26 @@ export interface SentryContext extends CommandContext {
readonly stdout: Writer;
readonly stderr: Writer;
readonly stdin: NodeJS.ReadStream & { fd: 0 };
/**
* Set organization and project context for telemetry.
* Call this after resolving the target org/project to enable
* filtering by org/project in Sentry.
* Accepts arrays to support multi-project commands.
*/
readonly setContext: (orgs: string[], projects: string[]) => void;
}

/**
* Build a dynamic context that uses forCommand to set telemetry tags.
*
* The forCommand method is called by stricli with the command prefix
* (e.g., ["auth", "login"]) before running the command.
*
* @param process - The Node.js process object
* @param span - The telemetry span from withTelemetry (optional)
*/
export function buildContext(process: NodeJS.Process) {
const baseContext = {
export function buildContext(process: NodeJS.Process, span?: Span) {
const baseContext: SentryContext = {
process,
env: process.env,
cwd: process.cwd(),
Expand All @@ -38,12 +52,13 @@ export function buildContext(process: NodeJS.Process) {
stdout: process.stdout,
stderr: process.stderr,
stdin: process.stdin,
setContext: setOrgProjectContext,
};

return {
...baseContext,
forCommand: ({ prefix }: { prefix: readonly string[] }): SentryContext => {
setCommandName(prefix.join("."));
setCommandSpanName(span, prefix.join("."));
return baseContext;
},
};
Expand Down
Loading
Loading