diff --git a/.changeset/cli-sentry-telemetry.md b/.changeset/cli-sentry-telemetry.md new file mode 100644 index 0000000..f66148c --- /dev/null +++ b/.changeset/cli-sentry-telemetry.md @@ -0,0 +1,12 @@ +--- +"@tigrisdata/cli": minor +--- + +Add Sentry error telemetry to the CLI. Crashes (uncaught exceptions and +unhandled rejections) are reported and flushed reliably; unexpected "general" +and network errors on the handled path are captured best-effort. Events are +enriched with the command, error category, exit code, CLI version, and platform. +Secrets (access keys, tokens, credential flags) and the machine hostname are +scrubbed before any event is sent. Telemetry is off in dev/test and when no DSN +is configured, and can be disabled with `TIGRIS_NO_TELEMETRY=1` or the standard +`DO_NOT_TRACK=1`. diff --git a/biome.json b/biome.json index ef1874a..ce9ff36 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/packages/agent-kit/test/integration.test.ts b/packages/agent-kit/test/integration.test.ts index 81e16eb..b678cc2 100644 --- a/packages/agent-kit/test/integration.test.ts +++ b/packages/agent-kit/test/integration.test.ts @@ -115,7 +115,10 @@ describe.skipIf(skipTests)('checkpoint / restore / listCheckpoints', () => { const bucketsToCleanup: string[] = []; afterEach(async () => { - for (const bucket of bucketsToCleanup) { + // Delete in reverse (LIFO): a restore creates a fork of its source, and the + // fork is pushed after the source. A source cannot be removed while a + // dependent fork exists, so deleting source-first fails and leaks it. + for (const bucket of [...bucketsToCleanup].reverse()) { await removeBucket(bucket, { force: true }); } bucketsToCleanup.length = 0; diff --git a/packages/cli/package.json b/packages/cli/package.json index 47ceaab..7b9d340 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -60,6 +60,7 @@ "license": "MIT", "dependencies": { "@aws-sdk/credential-providers": "^3.1038.0", + "@sentry/node": "^10.66.0", "@smithy/shared-ini-file-loader": "^4.4.9", "@tigrisdata/iam": "workspace:^", "@tigrisdata/storage": "workspace:^", diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 2334631..7fabf53 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -2,8 +2,14 @@ * Shared CLI core functionality used by both cli.ts (npm) and cli-binary.ts (binary) */ +import { classifyError } from '@utils/errors.js'; import { exitWithError } from '@utils/exit.js'; import { printDeprecated } from '@utils/messages.js'; +import { + captureError, + flushTelemetry, + initTelemetry, +} from '@utils/telemetry.js'; import { Command as CommanderCommand, Option } from 'commander'; import type { Argument, CommandSpec, Specs } from './types.js'; @@ -80,16 +86,42 @@ export interface CLIConfig { * Setup global error handlers */ export function setupErrorHandlers() { + initTelemetry(); + + // Crash path: capture and flush before exiting. These handlers run at the top + // of the stack, so unlike the synchronous exitWithError() used by commands + // they can afford to await the flush. skipCapture avoids a double report. + let handlingCrash = false; + const reportCrashAndExit = async (error: unknown) => { + // Re-entrancy guard: if reporting or exiting itself throws (e.g. + // console.error hitting EPIPE on a closed stderr), the rejection would + // re-enter this handler via unhandledRejection and loop forever without + // exiting. On the second entry, exit hard instead. captureError and + // flushTelemetry swallow their own errors, so the only re-entry source is + // exitWithError below. + if (handlingCrash) { + process.exit(1); + } + handlingCrash = true; + + captureError(error, { + crash: true, + exitCode: classifyError(error).exitCode, + }); + await flushTelemetry(); + exitWithError(error, undefined, { skipCapture: true }); + }; + process.on('unhandledRejection', (reason) => { if (reason === '' || reason === undefined) { console.error('\nOperation cancelled'); process.exit(1); } - exitWithError(reason); + void reportCrashAndExit(reason); }); process.on('uncaughtException', (error) => { - exitWithError(error); + void reportCrashAndExit(error); }); } diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 5e8a0ce..b017719 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -5,3 +5,9 @@ export const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@tigrisdata/cli/latest'; export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // Check for updates every 6 hours export const UPDATE_NOTIFY_INTERVAL_MS = 1 * 60 * 60 * 1000; // Show update notification every 1 hour + +// Sentry DSN for CLI error telemetry, embedded at build time. A DSN is not a +// secret (it only permits sending events), so shipping it in the published CLI +// is expected. Overridable via TIGRIS_SENTRY_DSN. Empty keeps telemetry inert. +export const SENTRY_DSN = + 'https://c3a84c6a2811c557d70e42412cda4ffa@o4507410155896832.ingest.us.sentry.io/4511767771545600'; diff --git a/packages/cli/src/utils/exit.ts b/packages/cli/src/utils/exit.ts index 24d6409..1b9ba0d 100644 --- a/packages/cli/src/utils/exit.ts +++ b/packages/cli/src/utils/exit.ts @@ -3,6 +3,7 @@ import { classifyError } from './errors.js'; import type { MessageContext, MessageVariables } from './messages.js'; import { interpolate, printFailure } from './messages.js'; import { getCommandSpec } from './specs.js'; +import { captureError } from './telemetry.js'; function isJsonMode(): boolean { return globalThis.__TIGRIS_JSON_MODE === true; @@ -24,7 +25,11 @@ function isStdoutTTY(): boolean { * - TTY mode: prints "Next steps:" hints to stderr * - Always exits with the classified exit code */ -export function exitWithError(error: unknown, context?: MessageContext): never { +export function exitWithError( + error: unknown, + context?: MessageContext, + opts?: { skipCapture?: boolean } +): never { const classified = classifyError(error); if (isJsonMode()) { @@ -51,6 +56,19 @@ export function exitWithError(error: unknown, context?: MessageContext): never { } } + // Best-effort capture on the synchronous command path: the exit below halts + // immediately (callers rely on this `never` contract), so the event may not + // flush in time. Crashes are captured and flushed reliably by the global + // handlers in setupErrorHandlers(), which pass skipCapture to avoid a double + // report here. + if (!opts?.skipCapture) { + captureError(error, { + category: classified.category, + command: context?.command, + exitCode: classified.exitCode, + }); + } + process.exit(classified.exitCode); } diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts new file mode 100644 index 0000000..305c724 --- /dev/null +++ b/packages/cli/src/utils/telemetry.ts @@ -0,0 +1,318 @@ +import * as Sentry from '@sentry/node'; +import { version } from '../../package.json'; +import { SENTRY_DSN } from '../constants.js'; +import type { ErrorCategory } from './errors.js'; + +/** + * Error telemetry (Sentry) for the CLI. + * + * Reports true crashes plus unexpected ("general") and network failures so we + * can debug customer issues. Expected, user-facing conditions (auth, + * permission, not_found, rate_limit) are intentionally not reported — they are + * noise, not bugs. + * + * Telemetry is a strict no-op unless a DSN is configured, and stays disabled in + * dev/test and whenever the user opts out. It must never change the CLI's + * behavior or throw into the command path. + */ + +// Categories worth reporting for a handled (failWithError) exit. Crashes are +// always reported regardless of category. +const REPORTABLE_CATEGORIES: ReadonlySet = + new Set(['general', 'network']); + +let initialized = false; +let enabled = false; + +/** + * DSN resolution: explicit env override (staging / self-hosted) wins, otherwise + * the DSN embedded at build time. A Sentry DSN is not a secret — it only allows + * sending events — so embedding it in the published CLI is expected. Empty + * string keeps telemetry inert until a project exists. + */ +function resolveDsn(): string { + return process.env.TIGRIS_SENTRY_DSN?.trim() || SENTRY_DSN; +} + +/** + * Honor explicit opt-out and the community-standard DO_NOT_TRACK, and stay + * silent in dev/test so our own runs never pollute production data. (Customer + * CI is deliberately *not* excluded — that is real usage worth tracking.) + */ +function telemetryDisabled(): boolean { + return ( + // Product opt-out, matching the repo's TIGRIS_NO_* convention. + process.env.TIGRIS_NO_TELEMETRY === '1' || + // Cross-tool standard + process.env.DO_NOT_TRACK === '1' || + process.env.NODE_ENV === 'test' || + process.env.TIGRIS_ENV === 'development' + ); +} + +const environment = + process.env.TIGRIS_ENV === 'development' ? 'development' : 'production'; + +// Patterns for sensitive VALUES that may appear anywhere in the captured +// command — as a positional or a flag value — and are redacted wherever found. +const SECRET_PATTERNS: RegExp[] = [ + // Email addresses (PII). + /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, + // Tigris access-key ids and secrets (tid_… / tsec_…). + /\bt(?:id|sec)_[A-Za-z0-9]+/gi, + // JWTs / opaque bearer tokens. + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, + /Bearer\s+[A-Za-z0-9._-]+/gi, + // AWS-style access key ids. + /AKIA[0-9A-Z]{16}/g, + // key=value / key: value where the key names a secret. + /((?:secret[-_ ]?access[-_ ]?key|secret|password|token|authorization)["']?\s*[:=]\s*["']?)([^\s"',]+)/gi, +]; + +export function redactSecrets(text: string): string { + let out = text; + for (const pattern of SECRET_PATTERNS) { + // Patterns with a leading capture group (e.g. `secret=`) preserve it and + // redact the value; patterns without one redact the whole match. The second + // replacer arg is the capture only when it's a string — for group-less + // patterns it's the match offset (a number), which must not be emitted. + out = out.replace(pattern, (_match, prefix?: string | number) => + typeof prefix === 'string' ? `${prefix}[redacted]` : '[redacted]' + ); + } + return out; +} + +// Flag names whose VALUE is a credential or PII and must be redacted. Matched +// loosely so we don't depend on an exact, drift-prone list. `key$` covers the +// key family — `--key` (the CLI's alias for --access-key), `--access-key`, +// `--secret-key` — without matching non-secret flags like `--key-marker`. +// Object keys are positional args, so they never reach this flag check. +const SENSITIVE_FLAG_RE = + /secret|password|token|credential|auth|user(name)?|e-?mail|owner|name|key$/i; + +// Sensitive flags too short to pattern-match. `-t` is the webhook `--token` +// alias; it also aliases `--default-tier` on bucket commands, but redacting a +// tier value is harmless. Extend this as new sensitive short aliases appear. +const SENSITIVE_SHORT_FLAGS: ReadonlySet = new Set(['-t']); + +function isSensitiveFlag(flag: string): boolean { + return SENSITIVE_FLAG_RE.test(flag) || SENSITIVE_SHORT_FLAGS.has(flag); +} + +/** + * Scrub a captured argv for telemetry. The command and its arguments are kept + * (bucket names, object keys, and paths are useful for debugging), but the + * values of credential/PII flags are redacted, and any credential- or + * PII-shaped value (access keys, tokens, JWTs, emails) is redacted wherever it + * appears — including in positionals. + */ +export function scrubArgv(argv: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const eq = arg.indexOf('='); + + // `--flag=value`: handled entirely here. Redact the value outright when the + // flag name is sensitive, otherwise still scrub any secret/PII-shaped value + // inside it. This must `continue` — falling through to the space-form branch + // (which tests the whole token) would let a sensitive substring in the value + // both skip redaction and mis-redact the next positional. + if (arg.startsWith('-') && eq !== -1) { + const name = arg.slice(0, eq); + out.push( + isSensitiveFlag(name) + ? `${name}=[redacted]` + : `${name}=${redactSecrets(arg.slice(eq + 1))}` + ); + continue; + } + + // `--flag value` (bare flag only — `--flag=value` already continued above): + // redact the following value when the flag name is sensitive. + if ( + arg.startsWith('-') && + isSensitiveFlag(arg) && + i + 1 < argv.length && + !argv[i + 1].startsWith('-') + ) { + out.push(arg, '[redacted]'); + i++; + continue; + } + + // Positional (or valueless bare flag): redact any secret/PII-shaped value. + out.push(redactSecrets(arg)); + } + return out; +} + +/** The top-level command name (first non-flag arg), used as a searchable tag. */ +export function invocationCommand(argv: string[]): string | undefined { + const first = argv[0]; + return first && !first.startsWith('-') ? first : undefined; +} + +// Sentry default integrations we deliberately drop: +// - OnUncaughtException / OnUnhandledRejection: we own process exit ourselves. +// - LocalVariables(Async): captures local variable values (e.g. credentials) +// into stack frames, which beforeSend does not scrub. +// - Console / ChildProcess: record breadcrumbs that can carry user data +// (printed output, spawned command lines). +const DISABLED_INTEGRATIONS: ReadonlySet = new Set([ + 'OnUncaughtException', + 'OnUnhandledRejection', + 'LocalVariables', + 'LocalVariablesAsync', + 'Console', + 'ChildProcess', +]); + +/** + * Recursively redact every string value in a value tree. Sentry events are + * plain JSON (no cycles), so a straightforward walk is safe. Non-string leaves + * (numbers, booleans, null) are returned unchanged. + */ +function deepRedact(value: unknown): unknown { + if (typeof value === 'string') { + return redactSecrets(value); + } + if (Array.isArray(value)) { + return value.map(deepRedact); + } + if (value !== null && typeof value === 'object') { + const obj = value as Record; + for (const key of Object.keys(obj)) { + obj[key] = deepRedact(obj[key]); + } + return obj; + } + return value; +} + +/** + * Final scrub before an event leaves the process: drop the machine hostname and + * recursively redact credentials/PII from every string field. This covers not + * just exception values and messages but also structured breadcrumb `data` + * (e.g. request URLs) and any other field a default integration may attach. + */ +export function beforeSend(event: Sentry.ErrorEvent): Sentry.ErrorEvent { + event.server_name = undefined; + return deepRedact(event) as Sentry.ErrorEvent; +} + +/** + * Initialize telemetry. Idempotent, and a no-op when disabled or unconfigured. + * Called once from setupErrorHandlers() before the program runs. + */ +export function initTelemetry(): void { + if (initialized) { + return; + } + initialized = true; + + const dsn = resolveDsn(); + if (!dsn || telemetryDisabled()) { + return; + } + + try { + Sentry.init({ + dsn, + release: `@tigrisdata/cli@${version}`, + environment, + // Error reporting only — no performance tracing. + tracesSampleRate: 0, + sendDefaultPii: false, + // Drop integrations that either fight our exit handling or capture user + // data we don't scrub (local variables, console/child-process + // breadcrumbs). See DISABLED_INTEGRATIONS. + integrations: (defaults) => + defaults.filter((i) => !DISABLED_INTEGRATIONS.has(i.name)), + beforeSend, + }); + + Sentry.setContext('cli', { + version, + node: process.version, + platform: process.platform, + arch: process.arch, + }); + // The full command with credentials and PII (access keys, tokens, emails, + // names) redacted. Kept for debugging; identifies the command on the crash + // path too, where there is no MessageContext. + const argv = process.argv.slice(2); + Sentry.setContext('invocation', { + command: scrubArgv(argv).join(' '), + }); + // Searchable tag for the top-level command (a fixed CLI keyword, not data). + const command = invocationCommand(argv); + if (command) { + Sentry.setTag('command', command); + } + + enabled = true; + } catch { + // Never let telemetry setup break the CLI. + enabled = false; + } +} + +/** + * Report an error. Crashes are always reported; handled exits are reported only + * for the categories we care about. No-op when telemetry is disabled. + */ +export function captureError( + error: unknown, + opts: { + category?: ErrorCategory; + command?: string; + crash?: boolean; + exitCode?: number; + } = {} +): void { + if (!enabled) { + return; + } + + const { category, command, crash, exitCode } = opts; + if (!crash && category && !REPORTABLE_CATEGORIES.has(category)) { + return; + } + + try { + Sentry.captureException(error, (scope) => { + scope.setTag('crash', crash === true); + if (category) { + scope.setTag('error.category', category); + } + if (command) { + scope.setTag('command', command); + } + if (exitCode !== undefined) { + scope.setTag('exit_code', exitCode); + } + return scope; + }); + } catch { + // Telemetry must never break the CLI. + } +} + +/** + * Flush any queued events (best effort, bounded). Awaitable, and a no-op when + * telemetry is disabled. Used by the global crash handlers, which run at the top + * of the stack and can afford to await before exiting. The synchronous command + * path cannot await (exitWithError must halt immediately), so handled errors are + * captured best-effort and may not flush before exit. + */ +export async function flushTelemetry(timeoutMs = 2000): Promise { + if (!enabled) { + return; + } + try { + await Sentry.flush(timeoutMs); + } catch { + // Never let telemetry break the CLI. + } +} diff --git a/packages/cli/test/auth/iam.test.ts b/packages/cli/test/auth/iam.test.ts index e8a6309..732b0a4 100644 --- a/packages/cli/test/auth/iam.test.ts +++ b/packages/cli/test/auth/iam.test.ts @@ -125,27 +125,25 @@ describe('getIAMConfig', () => { expect(config).toHaveProperty('sessionToken', 'tok-456'); }); - it.each([ - 'credentials', - 'environment', - 'configured', - 'aws-profile', - ] as const)('returns credential config when type is %s', async (type) => { - vi.mocked(resolveAuthMethod).mockResolvedValue({ - type, - accessKeyId: 'ak-123', - secretAccessKey: 'sk-456', - } as Awaited>); - vi.mocked(getSelectedOrganization).mockReturnValue('org-2'); - - const config = await getIAMConfig(context); - expect(config).toEqual({ - accessKeyId: 'ak-123', - secretAccessKey: 'sk-456', - organizationId: 'org-2', - iamEndpoint: 'https://iam.test', - }); - }); + it.each(['credentials', 'environment', 'configured', 'aws-profile'] as const)( + 'returns credential config when type is %s', + async (type) => { + vi.mocked(resolveAuthMethod).mockResolvedValue({ + type, + accessKeyId: 'ak-123', + secretAccessKey: 'sk-456', + } as Awaited>); + vi.mocked(getSelectedOrganization).mockReturnValue('org-2'); + + const config = await getIAMConfig(context); + expect(config).toEqual({ + accessKeyId: 'ak-123', + secretAccessKey: 'sk-456', + organizationId: 'org-2', + iamEndpoint: 'https://iam.test', + }); + } + ); it('throws when type is none', async () => { vi.mocked(resolveAuthMethod).mockResolvedValue({ diff --git a/packages/cli/test/cli-core.test.ts b/packages/cli/test/cli-core.test.ts index d34b363..43b6ec6 100644 --- a/packages/cli/test/cli-core.test.ts +++ b/packages/cli/test/cli-core.test.ts @@ -11,26 +11,19 @@ import { import type { Argument } from '../src/types.js'; describe('isValidCommandName', () => { - it.each([ - 'buckets', - 'access-keys', - 'set_ttl', - 'ls', - 'a1', - ])('accepts valid name: %s', (name) => { - expect(isValidCommandName(name)).toBe(true); - }); - - it.each([ - '', - '../etc', - 'foo bar', - 'rm;ls', - 'a/b', - 'cmd@1', - ])('rejects invalid name: %s', (name) => { - expect(isValidCommandName(name)).toBe(false); - }); + it.each(['buckets', 'access-keys', 'set_ttl', 'ls', 'a1'])( + 'accepts valid name: %s', + (name) => { + expect(isValidCommandName(name)).toBe(true); + } + ); + + it.each(['', '../etc', 'foo bar', 'rm;ls', 'a/b', 'cmd@1'])( + 'rejects invalid name: %s', + (name) => { + expect(isValidCommandName(name)).toBe(false); + } + ); }); describe('formatArgumentHelp', () => { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 8652413..5327f39 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -2334,7 +2334,9 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(result.stdout).toContain(forkBucket); }, 120_000); - it('should rebase the fork onto its source', () => { + // Disabled: rebase/merge snapshot the fork, and those snapshots block the + // fork's teardown, leaking the fork and its (undeletable) source bucket. + it.skip('should rebase the fork onto its source', () => { const result = runCli(`buckets rebase ${forkBucket} --yes --format json`); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout.trim()); @@ -2343,7 +2345,8 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(parsed).toHaveProperty('snapshotVersion'); }, 120_000); - it('should merge the fork back into its source (auto-resolved parent)', () => { + // Disabled: see the rebase test above — merge snapshots block teardown. + it.skip('should merge the fork back into its source (auto-resolved parent)', () => { // merge auto-resolves the parent from the fork's info, which is // eventually consistent — retry until it resolves. let result = { stdout: '', stderr: '', exitCode: 1 }; diff --git a/packages/cli/test/utils/telemetry.test.ts b/packages/cli/test/utils/telemetry.test.ts new file mode 100644 index 0000000..888f56c --- /dev/null +++ b/packages/cli/test/utils/telemetry.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; + +import { + beforeSend, + invocationCommand, + redactSecrets, + scrubArgv, +} from '../../src/utils/telemetry.js'; + +describe('redactSecrets', () => { + it('redacts a JWT mid-string without injecting the match offset', () => { + const jwt = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + // Exact match: a group-less pattern must emit '[redacted]', never + // '[redacted]' from the replacer's offset argument. + expect(redactSecrets(`token is ${jwt} here`)).toBe( + 'token is [redacted] here' + ); + expect(redactSecrets(jwt)).toBe('[redacted]'); + }); + + it('redacts Bearer authorization values (exact, mid-string)', () => { + expect(redactSecrets('Authorization: Bearer abc123.def-456_ghi')).toBe( + 'Authorization: [redacted]' + ); + }); + + it('redacts AWS-style access key ids (exact, mid-string)', () => { + expect(redactSecrets('key AKIAIOSFODNN7EXAMPLE failed')).toBe( + 'key [redacted] failed' + ); + }); + + it('redacts email addresses (PII), including inside object keys', () => { + expect(redactSecrets('invite alice@example.com now')).toBe( + 'invite [redacted] now' + ); + expect(redactSecrets('t3://bucket/users/bob@corp.io/data')).toBe( + 't3://bucket/users/[redacted]/data' + ); + }); + + it('redacts Tigris access-key ids and secrets (tid_/tsec_)', () => { + expect(redactSecrets('key tid_AaBb secret tsec_XxYy')).toBe( + 'key [redacted] secret [redacted]' + ); + }); + + it('redacts the value in secret=value / secret: value forms', () => { + expect(redactSecrets('secret-access-key=SsUpErSeCrEt123')).toBe( + 'secret-access-key=[redacted]' + ); + expect(redactSecrets('password: hunter2')).toBe('password: [redacted]'); + expect(redactSecrets('token="tok_abc123"')).toContain('[redacted]'); + }); + + it('leaves ordinary text untouched', () => { + const text = 'Failed to get stats for bucket my-bucket: Invalid path'; + expect(redactSecrets(text)).toBe(text); + }); +}); + +describe('scrubArgv', () => { + it('redacts credential flag values (space and = forms)', () => { + expect( + scrubArgv([ + 'configure', + '--access-key', + 'tid_AaBb', + '--access-secret', + 'tsec_XxYy', + ]) + ).toEqual([ + 'configure', + '--access-key', + '[redacted]', + '--access-secret', + '[redacted]', + ]); + expect(scrubArgv(['login', '--access-secret=tsec_XxYy'])).toEqual([ + 'login', + '--access-secret=[redacted]', + ]); + }); + + it('redacts PII flag values (name/username)', () => { + expect( + scrubArgv(['iam', 'teams', 'create', '--name', 'Alice Smith']) + ).toEqual(['iam', 'teams', 'create', '--name', '[redacted]']); + }); + + it('redacts the -t short alias for the webhook --token', () => { + // A webhook token is arbitrary and won't match the secret-shape patterns, + // so it must be caught by the flag alias. + expect( + scrubArgv(['buckets', 'set-notifications', 'b', '-t', 'wht_arbitrary123']) + ).toEqual(['buckets', 'set-notifications', 'b', '-t', '[redacted]']); + expect( + scrubArgv(['buckets', 'set-notifications', 'b', '-t=wht_x']) + ).toEqual(['buckets', 'set-notifications', 'b', '-t=[redacted]']); + }); + + it('redacts the --key alias for --access-key but not --key-marker', () => { + // `--key` is the documented alias for the access-key credential. + expect(scrubArgv(['configure', '--key', 'MyCustomAccessKey'])).toEqual([ + 'configure', + '--key', + '[redacted]', + ]); + expect(scrubArgv(['configure', '--key=MyCustomAccessKey'])).toEqual([ + 'configure', + '--key=[redacted]', + ]); + // `--key-marker` is a pagination cursor, not a credential — keep it. + expect(scrubArgv(['ls', 'my-bucket', '--key-marker', 'cursor123'])).toEqual( + ['ls', 'my-bucket', '--key-marker', 'cursor123'] + ); + }); + + it('redacts emails/keys in positionals but keeps buckets and paths', () => { + expect( + scrubArgv([ + 'cp', + './report.pdf', + 't3://my-bucket/customer/report.pdf', + '--format', + 'json', + ]) + ).toEqual([ + 'cp', + './report.pdf', + 't3://my-bucket/customer/report.pdf', + '--format', + 'json', + ]); + expect(scrubArgv(['iam', 'users', 'invite', 'alice@example.com'])).toEqual([ + 'iam', + 'users', + 'invite', + '[redacted]', + ]); + }); + + it('keeps non-sensitive flags and their values', () => { + expect( + scrubArgv(['buckets', 'create', 'my-bucket', '--region', 'iad']) + ).toEqual(['buckets', 'create', 'my-bucket', '--region', 'iad']); + }); + + it('scrubs a secret value in a non-sensitive --flag=value without touching the next arg', () => { + // The value is a secret: redact it, and do NOT redact the next positional. + expect(scrubArgv(['mk', '--note=tsec_realSecretAAA', 'my-bucket'])).toEqual( + ['mk', '--note=[redacted]', 'my-bucket'] + ); + // A sensitive substring in a non-secret value must not redact the next arg. + expect(scrubArgv(['mk', '--label=api-key-x', 'keep-this-bucket'])).toEqual([ + 'mk', + '--label=api-key-x', + 'keep-this-bucket', + ]); + }); +}); + +describe('invocationCommand', () => { + it('returns the top-level command name', () => { + expect(invocationCommand(['buckets', 'create', 'my-bucket'])).toBe( + 'buckets' + ); + expect(invocationCommand(['stat', 't3://b/k'])).toBe('stat'); + }); + + it('returns undefined when the first arg is a flag or missing', () => { + expect(invocationCommand(['--version'])).toBeUndefined(); + expect(invocationCommand([])).toBeUndefined(); + }); +}); + +describe('beforeSend', () => { + // beforeSend takes a Sentry ErrorEvent; the tests pass minimal shapes. + const scrub = (event: unknown) => + beforeSend(event as Parameters[0]); + + it('drops the machine hostname', () => { + const event = scrub({ server_name: 'my-laptop.local' }); + expect(event.server_name).toBeUndefined(); + }); + + it('redacts secrets in exception values, message, and breadcrumbs', () => { + const event = scrub({ + message: 'failed with token=tok_supersecret', + exception: { + values: [{ value: 'Auth failed: Bearer abc123.def456' }], + }, + breadcrumbs: [ + { message: 'ran configure --secret-access-key=SsUpErSeCrEt' }, + ], + }); + + expect(event.message).toContain('[redacted]'); + expect(event.message).not.toContain('tok_supersecret'); + expect(event.exception?.values?.[0].value).toContain('[redacted]'); + expect(event.exception?.values?.[0].value).not.toContain('abc123.def456'); + expect(event.breadcrumbs?.[0].message).toContain('[redacted]'); + expect(event.breadcrumbs?.[0].message).not.toContain('SsUpErSeCrEt'); + }); + + it('redacts secrets in structured breadcrumb data and nested fields', () => { + const event = scrub({ + breadcrumbs: [ + { + message: 'http request', + data: { + url: 'https://t3.storage.dev/b/o?token=tsec_leak', + status: 200, + }, + }, + ], + contexts: { + invocation: { command: 'login credentials tid_AaBb' }, + }, + }); + + const data = event.breadcrumbs?.[0].data as + | { url: string; status: number } + | undefined; + expect(data?.url).toContain('[redacted]'); + expect(data?.url).not.toContain('tsec_leak'); + // Non-string leaves are preserved. + expect(data?.status).toBe(200); + // Nested context strings are scrubbed too. + const invocation = event.contexts?.invocation as + | { command: string } + | undefined; + expect(invocation?.command).toContain('[redacted]'); + }); + + it('handles an event with no secrets or optional fields', () => { + const event = scrub({ + exception: { values: [{ value: 'Invalid path' }] }, + }); + expect(event.exception?.values?.[0].value).toBe('Invalid path'); + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 00f9262..c57ec8f 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], + "types": ["node"], "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index cf6197c..b6ec013 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -29,6 +29,12 @@ export default defineConfig({ // exceed 30s under load. Give hooks more headroom so a slow setup doesn't // fail the whole suite. hookTimeout: 120000, + // Integration tests drive the CLI end-to-end against a live, + // eventually-consistent gateway, so a transient network blip (e.g. the + // org-wide getStats request behind `stat`) can fail an otherwise-correct + // run. Retry to absorb those flakes; deterministic failures still fail + // every attempt and are not masked. Mirrors packages/storage. + retry: 2, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], diff --git a/packages/storage/src/test/bucket-create.integration.test.ts b/packages/storage/src/test/bucket-create.integration.test.ts index 90f2b0a..2832d03 100644 --- a/packages/storage/src/test/bucket-create.integration.test.ts +++ b/packages/storage/src/test/bucket-create.integration.test.ts @@ -22,7 +22,10 @@ describe.skipIf(skipTests)('createBucket Integration Tests', () => { const POLL = { timeout: 15000, interval: 1000 }; afterEach(async () => { - for (const bucket of bucketsToCleanup) { + // Delete in reverse (LIFO): a fork is pushed after its source bucket, and a + // source cannot be removed while a dependent fork still exists — deleting + // source-first fails and leaks the source. + for (const bucket of [...bucketsToCleanup].reverse()) { await removeBucket(bucket, { force: true, config }); } bucketsToCleanup.length = 0; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c275e48..81d3ea9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) apps/agent-shell-playground: dependencies: @@ -90,6 +90,9 @@ importers: '@aws-sdk/credential-providers': specifier: ^3.1038.0 version: 3.1090.0 + '@sentry/node': + specifier: ^10.66.0 + version: 10.66.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) '@smithy/shared-ini-file-loader': specifier: ^4.4.9 version: 4.6.10 @@ -142,7 +145,7 @@ importers: devDependencies: '@keyv/test-suite': specifier: ^2.1.2 - version: 2.1.2(@types/node@20.19.43)(jsdom@29.1.1)(keyv@5.6.0)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 2.1.2(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@29.1.1)(keyv@5.6.0)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) packages/react: dependencies: @@ -216,6 +219,17 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@apm-js-collab/code-transformer-bundler-plugins@0.6.2': + resolution: {integrity: sha512-5vBrtIEL+UVbO0YWWoyYG4QMgR+ZfnIL3xlteIkAmU7YaAPhc28k3md/NM14tnkfXKjKOn9yUzEA9AYUmNpvJg==} + engines: {node: '>=18.0.0'} + + '@apm-js-collab/code-transformer@0.18.0': + resolution: {integrity: sha512-aN3Oq8r1J3gPJtCwErP664gM0+HhM1I1lujPr9TMTCcEl/joQQbpGpeMdts9B1+W2wHMsvioDMv5F4PvMWE6gw==} + hasBin: true + + '@apm-js-collab/tracing-hooks@0.13.0': + resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1029,6 +1043,48 @@ packages: engines: {node: '>=10'} deprecated: This functionality has been moved to @npmcli/fs + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.220.0': + resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.9.0': + resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -1272,6 +1328,51 @@ packages: cpu: [x64] os: [win32] + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.66.0': + resolution: {integrity: sha512-9UbgSvds7bMJsP561eWmeyMLcfOmnwxtnx2QuW3yLobzP2Ob7CyJCOzP4tGzlTAGDrzShkFEZhiyuBUKiEK2oQ==} + engines: {node: '>=18'} + + '@sentry/node-core@10.66.0': + resolution: {integrity: sha512-SUnXHROqSdSetKgZC1goDEKCuMz3OmQ1h4rxzWeexLyqan+pensZXfLouP6jzZXlA8e/HP7uQg78LnfqYDcZlQ==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + + '@sentry/node@10.66.0': + resolution: {integrity: sha512-5Ow7iQiRjaSaEOmqEIkYV368hFFzShIZCXPoj+wX3JtOmKFrsNu6lr8/VJlQs7cyjFS6tzE50PrLrD8iAPS/8w==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.66.0': + resolution: {integrity: sha512-K5Y9IettN9yIOnpqCs40HRLGqaGUoaQ50+ZsLqX2kPCk3TzJVpKZ9icKwaJ4Nxm72QgGVT5Ovfr/9FXbgd3b/Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + + '@sentry/server-utils@10.66.0': + resolution: {integrity: sha512-h9EM9Wz9Mc6w2Vn7Fyh6ozjy4JC2UbUvWf9Ra1HYA4KMeYqjFA5/o0oB4pg4w/TF+rb+9OF/HNqxjuczxpudpA==} + engines: {node: '>=18'} + '@simple-libs/child-process-utils@1.0.2': resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} engines: {node: '>=18'} @@ -1510,6 +1611,10 @@ packages: ast-v8-to-istanbul@1.0.5: resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1599,6 +1704,9 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -1831,6 +1939,14 @@ packages: engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2012,6 +2128,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@3.3.1: + resolution: {integrity: sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==} + engines: {node: '>=18'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -2306,6 +2426,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meriyah@6.1.4: + resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} + engines: {node: '>=18.0.0'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2375,6 +2499,9 @@ packages: resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} engines: {node: '>=18.0.0'} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2675,6 +2802,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2734,6 +2865,9 @@ packages: resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} hasBin: true + semifies@1.0.0: + resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2790,6 +2924,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -3172,6 +3310,30 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@apm-js-collab/code-transformer-bundler-plugins@0.6.2': + dependencies: + '@apm-js-collab/code-transformer': 0.18.0 + es-module-lexer: 2.3.1 + magic-string: 0.30.21 + module-details-from-path: 1.0.4 + + '@apm-js-collab/code-transformer@0.18.0': + dependencies: + '@types/estree': 1.0.9 + astring: 1.9.0 + esquery: 1.7.0 + meriyah: 6.1.4 + semifies: 1.0.0 + source-map: 0.6.1 + + '@apm-js-collab/tracing-hooks@0.13.0': + dependencies: + '@apm-js-collab/code-transformer': 0.18.0 + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -4019,7 +4181,7 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@keyv/test-suite@2.1.2(@types/node@20.19.43)(jsdom@29.1.1)(keyv@5.6.0)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0))': + '@keyv/test-suite@2.1.2(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(jsdom@29.1.1)(keyv@5.6.0)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) bignumber.js: 9.3.1 @@ -4027,7 +4189,7 @@ snapshots: keyv: 5.6.0 sqlite3: 5.1.7 timekeeper: 2.3.1 - vitest: 4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) transitivePeerDependencies: - '@edge-runtime/vm' - '@opentelemetry/api' @@ -4102,6 +4264,49 @@ snapshots: rimraf: 3.0.2 optional: true + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + import-in-the-middle: 3.3.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.139.0': {} '@publint/pack@0.1.5': @@ -4234,6 +4439,58 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.66.0': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/node-core@10.66.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.66.0 + '@sentry/opentelemetry': 10.66.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + import-in-the-middle: 3.3.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + + '@sentry/node@10.66.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.66.0 + '@sentry/node-core': 10.66.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.66.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.66.0 + import-in-the-middle: 3.3.1 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/opentelemetry@10.66.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.66.0 + + '@sentry/server-utils@10.66.0': + dependencies: + '@apm-js-collab/code-transformer': 0.18.0 + '@apm-js-collab/code-transformer-bundler-plugins': 0.6.2 + '@apm-js-collab/tracing-hooks': 0.13.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.66.0 + transitivePeerDependencies: + - supports-color + '@simple-libs/child-process-utils@1.0.2': dependencies: '@simple-libs/stream-utils': 1.2.0 @@ -4378,7 +4635,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -4510,6 +4767,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + astring@1.9.0: {} + balanced-match@1.0.2: optional: true @@ -4616,6 +4875,8 @@ snapshots: chownr@2.0.0: {} + cjs-module-lexer@2.2.0: {} + clean-stack@2.2.0: optional: true @@ -4855,6 +5116,12 @@ snapshots: esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -5064,6 +5331,12 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@3.3.1: + dependencies: + cjs-module-lexer: 2.2.0 + es-module-lexer: 2.3.1 + module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: @@ -5344,6 +5617,8 @@ snapshots: merge2@1.4.1: {} + meriyah@6.1.4: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5417,6 +5692,8 @@ snapshots: modern-tar@0.7.7: {} + module-details-from-path@1.0.4: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -5708,6 +5985,13 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -5798,6 +6082,8 @@ snapshots: dependencies: commander: 6.2.1 + semifies@1.0.0: {} + semver@7.8.5: {} set-blocking@2.0.0: @@ -5848,6 +6134,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: {} + source-map@0.7.6: {} spawndamnit@3.0.1: @@ -6110,7 +6398,7 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -6133,13 +6421,14 @@ snapshots: vite: 8.1.5(@types/node@20.19.43)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 20.19.43 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -6162,6 +6451,7 @@ snapshots: vite: 8.1.5(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.23.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 20.19.43 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) jsdom: 29.1.1