@sentry/junior 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ import * as Sentry from '@sentry/nextjs';
2
+
3
+ declare function register(): Promise<void>;
4
+ declare const onRequestError: typeof Sentry.captureRequestError;
5
+
6
+ export { onRequestError, register };
@@ -0,0 +1,49 @@
1
+ // src/instrumentation.ts
2
+ import * as Sentry from "@sentry/nextjs";
3
+ function getSampleRate(value, fallback) {
4
+ if (!value) return fallback;
5
+ const parsed = Number(value);
6
+ return Number.isFinite(parsed) ? parsed : fallback;
7
+ }
8
+ function getBoolean(value, fallback) {
9
+ if (!value) return fallback;
10
+ const normalized = value.trim().toLowerCase();
11
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
12
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
13
+ return fallback;
14
+ }
15
+ function getCommonOptions() {
16
+ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN;
17
+ const enableLogs = getBoolean(process.env.SENTRY_ENABLE_LOGS, Boolean(dsn));
18
+ return {
19
+ dsn,
20
+ environment: process.env.SENTRY_ENVIRONMENT ?? process.env.VERCEL_ENV ?? process.env.NODE_ENV,
21
+ release: process.env.SENTRY_RELEASE ?? process.env.VERCEL_GIT_COMMIT_SHA,
22
+ tracesSampleRate: getSampleRate(process.env.SENTRY_TRACES_SAMPLE_RATE, 1),
23
+ sendDefaultPii: true,
24
+ enabled: Boolean(dsn),
25
+ enableLogs
26
+ };
27
+ }
28
+ async function register() {
29
+ if (process.env.NEXT_RUNTIME === "nodejs") {
30
+ Sentry.init({
31
+ ...getCommonOptions(),
32
+ integrations: [
33
+ Sentry.vercelAIIntegration({
34
+ recordInputs: true,
35
+ recordOutputs: true
36
+ })
37
+ ]
38
+ });
39
+ return;
40
+ }
41
+ if (process.env.NEXT_RUNTIME === "edge") {
42
+ Sentry.init(getCommonOptions());
43
+ }
44
+ }
45
+ var onRequestError = Sentry.captureRequestError;
46
+ export {
47
+ onRequestError,
48
+ register
49
+ };
@@ -0,0 +1,14 @@
1
+ import { NextConfig } from 'next';
2
+
3
+ interface JuniorConfigOptions {
4
+ dataDir?: string;
5
+ skillsDir?: string;
6
+ pluginsDir?: string;
7
+ sentry?: boolean;
8
+ }
9
+ type NextConfigFactory = (phase: string, ctx: {
10
+ defaultConfig: NextConfig;
11
+ }) => Promise<NextConfig> | NextConfig;
12
+ declare function withJunior(nextConfig?: NextConfig | NextConfigFactory, options?: JuniorConfigOptions): NextConfig | NextConfigFactory;
13
+
14
+ export { type JuniorConfigOptions, withJunior };
@@ -0,0 +1,108 @@
1
+ // src/next-config.ts
2
+ import { createRequire } from "module";
3
+ import { readFileSync, statSync } from "fs";
4
+ import path from "path";
5
+ var require2 = createRequire(import.meta.url);
6
+ function isDirectory(targetPath) {
7
+ try {
8
+ return statSync(targetPath).isDirectory();
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+ function isFile(targetPath) {
14
+ try {
15
+ return statSync(targetPath).isFile();
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+ function discoverInstalledPluginPackageTracingIncludes(cwd = process.cwd()) {
21
+ const rootPackageJsonPath = path.join(cwd, "package.json");
22
+ let rootPackageJson;
23
+ try {
24
+ rootPackageJson = JSON.parse(readFileSync(rootPackageJsonPath, "utf8"));
25
+ } catch {
26
+ return [];
27
+ }
28
+ const dependencies = [
29
+ ...Object.keys(rootPackageJson.dependencies ?? {}),
30
+ ...Object.keys(rootPackageJson.optionalDependencies ?? {})
31
+ ];
32
+ const tracingIncludes = [];
33
+ for (const dependency of dependencies) {
34
+ const packageDir = path.join(cwd, "node_modules", ...dependency.split("/"));
35
+ if (!isDirectory(packageDir)) {
36
+ continue;
37
+ }
38
+ const base = `./node_modules/${dependency}`;
39
+ if (isFile(path.join(packageDir, "plugin.yaml"))) {
40
+ tracingIncludes.push(`${base}/plugin.yaml`);
41
+ }
42
+ if (isDirectory(path.join(packageDir, "plugins"))) {
43
+ tracingIncludes.push(`${base}/plugins/**/*`);
44
+ }
45
+ if (isDirectory(path.join(packageDir, "skills"))) {
46
+ tracingIncludes.push(`${base}/skills/**/*`);
47
+ }
48
+ }
49
+ return [...new Set(tracingIncludes)].sort((left, right) => left.localeCompare(right));
50
+ }
51
+ function applyJuniorConfig(nextConfig, options) {
52
+ const dataDir = options?.dataDir ?? "./app/data";
53
+ const skillsDir = options?.skillsDir ?? "./app/skills";
54
+ const pluginsDir = options?.pluginsDir ?? "./app/plugins";
55
+ const defaultDataTracingIncludes = options?.dataDir ? [`${dataDir}/**/*`] : ["./app/SOUL.md", "./app/ABOUT.md"];
56
+ const pluginPackageTracingIncludes = discoverInstalledPluginPackageTracingIncludes();
57
+ const tracingIncludes = Array.from(/* @__PURE__ */ new Set([
58
+ ...defaultDataTracingIncludes,
59
+ `${skillsDir}/**/*`,
60
+ `${pluginsDir}/**/*`,
61
+ ...pluginPackageTracingIncludes
62
+ ]));
63
+ const existingGlobalTracingIncludes = nextConfig?.outputFileTracingIncludes?.["/*"] ?? [];
64
+ const mergedGlobalTracingIncludes = Array.from(/* @__PURE__ */ new Set([
65
+ ...existingGlobalTracingIncludes,
66
+ ...tracingIncludes
67
+ ]));
68
+ const config = {
69
+ ...nextConfig,
70
+ serverExternalPackages: Array.from(/* @__PURE__ */ new Set([
71
+ ...nextConfig?.serverExternalPackages ?? [],
72
+ "@vercel/sandbox",
73
+ "bash-tool",
74
+ "just-bash",
75
+ "@chat-adapter/slack",
76
+ "@slack/web-api"
77
+ ])),
78
+ outputFileTracingIncludes: {
79
+ ...nextConfig?.outputFileTracingIncludes,
80
+ "/*": mergedGlobalTracingIncludes
81
+ }
82
+ };
83
+ if (options?.sentry) {
84
+ const { withSentryConfig } = require2("@sentry/nextjs");
85
+ return withSentryConfig(config, {
86
+ org: process.env.SENTRY_ORG,
87
+ project: process.env.SENTRY_PROJECT,
88
+ authToken: process.env.SENTRY_AUTH_TOKEN,
89
+ silent: !process.env.CI,
90
+ sourcemaps: {
91
+ disable: false
92
+ }
93
+ });
94
+ }
95
+ return config;
96
+ }
97
+ function withJunior(nextConfig, options) {
98
+ if (typeof nextConfig === "function") {
99
+ return async (phase, ctx) => {
100
+ const resolved = await nextConfig(phase, ctx);
101
+ return applyJuniorConfig(resolved, options);
102
+ };
103
+ }
104
+ return applyJuniorConfig(nextConfig, options);
105
+ }
106
+ export {
107
+ withJunior
108
+ };
@@ -0,0 +1,309 @@
1
+ import {
2
+ escapeXml,
3
+ generateAssistantReply,
4
+ getOAuthProviderConfig,
5
+ getUserTokenStore,
6
+ publishAppHomeView,
7
+ resolveBaseUrl,
8
+ truncateStatusText
9
+ } from "./chunk-7E56WM6K.js";
10
+ import {
11
+ getStateAdapter
12
+ } from "./chunk-ZBFSIN6G.js";
13
+ import "./chunk-MM3YNA4F.js";
14
+ import {
15
+ botConfig,
16
+ getSlackClient
17
+ } from "./chunk-GDNDYMGX.js";
18
+ import {
19
+ logException,
20
+ logInfo
21
+ } from "./chunk-BBOVH5RF.js";
22
+
23
+ // app/api/oauth/callback/[provider]/route.ts
24
+ import { after } from "next/server";
25
+ var runtime = "nodejs";
26
+ function htmlErrorResponse(title, message, status) {
27
+ const safeTitle = escapeXml(title);
28
+ const html = `<!DOCTYPE html>
29
+ <html>
30
+ <head><title>${safeTitle}</title></head>
31
+ <body style="font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
32
+ <div style="text-align: center; max-width: 480px;">
33
+ <h1>${safeTitle}</h1>
34
+ <p>${message}</p>
35
+ <p style="margin-top: 2rem; color: #666; font-size: 0.9em;">You can close this tab and return to Slack to try again.</p>
36
+ </div>
37
+ </body>
38
+ </html>`;
39
+ return new Response(html, {
40
+ status,
41
+ headers: { "Content-Type": "text/html; charset=utf-8" }
42
+ });
43
+ }
44
+ async function postSlackMessage(channelId, threadTs, text) {
45
+ try {
46
+ await getSlackClient().chat.postMessage({ channel: channelId, thread_ts: threadTs, text });
47
+ } catch {
48
+ }
49
+ }
50
+ async function setAssistantStatus(channelId, threadTs, status) {
51
+ try {
52
+ await getSlackClient().assistant.threads.setStatus({ channel_id: channelId, thread_ts: threadTs, status });
53
+ } catch {
54
+ }
55
+ }
56
+ var STATUS_DEBOUNCE_MS = 1e3;
57
+ function createDebouncedStatusPoster(channelId, threadTs) {
58
+ let lastPostAt = 0;
59
+ let currentStatus = "";
60
+ let pendingStatus = null;
61
+ let pendingTimer = null;
62
+ let stopped = false;
63
+ const flush = async () => {
64
+ if (stopped || !pendingStatus) return;
65
+ const status = pendingStatus;
66
+ pendingStatus = null;
67
+ pendingTimer = null;
68
+ lastPostAt = Date.now();
69
+ currentStatus = status;
70
+ await setAssistantStatus(channelId, threadTs, status);
71
+ };
72
+ const post = async (status) => {
73
+ if (stopped) return;
74
+ const truncated = truncateStatusText(status);
75
+ if (!truncated || truncated === currentStatus) return;
76
+ const now = Date.now();
77
+ const elapsed = now - lastPostAt;
78
+ if (elapsed >= STATUS_DEBOUNCE_MS) {
79
+ if (pendingTimer) {
80
+ clearTimeout(pendingTimer);
81
+ pendingTimer = null;
82
+ }
83
+ pendingStatus = null;
84
+ lastPostAt = now;
85
+ currentStatus = truncated;
86
+ await setAssistantStatus(channelId, threadTs, truncated);
87
+ return;
88
+ }
89
+ pendingStatus = truncated;
90
+ if (!pendingTimer) {
91
+ pendingTimer = setTimeout(() => {
92
+ void flush();
93
+ }, Math.max(1, STATUS_DEBOUNCE_MS - elapsed));
94
+ }
95
+ };
96
+ post.stop = () => {
97
+ stopped = true;
98
+ if (pendingTimer) {
99
+ clearTimeout(pendingTimer);
100
+ pendingTimer = null;
101
+ }
102
+ pendingStatus = null;
103
+ };
104
+ return post;
105
+ }
106
+ function createReadOnlyConfigService(values) {
107
+ const entries = Object.entries(values).map(([key, value]) => ({
108
+ key,
109
+ value,
110
+ scope: "conversation",
111
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
112
+ }));
113
+ return {
114
+ get: async (key) => entries.find((e) => e.key === key),
115
+ set: async () => {
116
+ throw new Error("Read-only configuration in resumed context");
117
+ },
118
+ unset: async () => false,
119
+ list: async ({ prefix } = {}) => entries.filter((e) => !prefix || e.key.startsWith(prefix)),
120
+ resolve: async (key) => values[key],
121
+ resolveValues: async ({ keys, prefix } = {}) => {
122
+ const filtered = {};
123
+ for (const [key, value] of Object.entries(values)) {
124
+ if (prefix && !key.startsWith(prefix)) continue;
125
+ if (keys && !keys.includes(key)) continue;
126
+ filtered[key] = value;
127
+ }
128
+ return filtered;
129
+ }
130
+ };
131
+ }
132
+ async function resumePendingMessage(stored) {
133
+ if (!stored.pendingMessage || !stored.channelId || !stored.threadTs) return;
134
+ const providerLabel = stored.provider.charAt(0).toUpperCase() + stored.provider.slice(1);
135
+ await postSlackMessage(
136
+ stored.channelId,
137
+ stored.threadTs,
138
+ `Your ${providerLabel} account is now connected. Processing your request...`
139
+ );
140
+ const postStatus = createDebouncedStatusPoster(stored.channelId, stored.threadTs);
141
+ await setAssistantStatus(stored.channelId, stored.threadTs, "Thinking...");
142
+ try {
143
+ const reply = await generateAssistantReply(stored.pendingMessage, {
144
+ assistant: { userName: botConfig.userName },
145
+ requester: { userId: stored.userId },
146
+ correlation: {
147
+ channelId: stored.channelId,
148
+ threadTs: stored.threadTs,
149
+ requesterId: stored.userId
150
+ },
151
+ configuration: stored.configuration,
152
+ channelConfiguration: stored.configuration ? createReadOnlyConfigService(stored.configuration) : void 0,
153
+ onStatus: postStatus
154
+ });
155
+ postStatus.stop();
156
+ if (reply.text) {
157
+ await postSlackMessage(stored.channelId, stored.threadTs, reply.text);
158
+ }
159
+ logInfo(
160
+ "oauth_callback_resume_complete",
161
+ {},
162
+ {
163
+ "app.credential.provider": stored.provider,
164
+ "app.ai.outcome": reply.diagnostics.outcome,
165
+ "app.ai.tool_calls": reply.diagnostics.toolCalls.length
166
+ },
167
+ "Auto-resumed pending message after OAuth callback"
168
+ );
169
+ } catch (error) {
170
+ postStatus.stop();
171
+ logException(
172
+ error,
173
+ "oauth_callback_resume_failed",
174
+ {},
175
+ { "app.credential.provider": stored.provider },
176
+ "Failed to auto-resume pending message after OAuth callback"
177
+ );
178
+ await postSlackMessage(
179
+ stored.channelId,
180
+ stored.threadTs,
181
+ `I connected your account but hit an error processing your request. Please try \`${stored.pendingMessage}\` again.`
182
+ );
183
+ }
184
+ }
185
+ async function GET(request, context) {
186
+ const { provider } = await context.params;
187
+ const providerConfig = getOAuthProviderConfig(provider);
188
+ if (!providerConfig) {
189
+ return htmlErrorResponse("Unknown provider", "The OAuth provider in this link is not recognized.", 404);
190
+ }
191
+ const providerLabel = provider.charAt(0).toUpperCase() + provider.slice(1);
192
+ const url = new URL(request.url);
193
+ const errorParam = url.searchParams.get("error");
194
+ const code = url.searchParams.get("code");
195
+ const state = url.searchParams.get("state");
196
+ if (errorParam) {
197
+ if (state) {
198
+ const cleanupAdapter = getStateAdapter();
199
+ await cleanupAdapter.delete(`oauth-state:${state}`);
200
+ }
201
+ if (errorParam === "access_denied") {
202
+ return htmlErrorResponse(
203
+ "Authorization declined",
204
+ `You declined the ${providerLabel} authorization request. Return to Slack and run the auth command again if you change your mind.`,
205
+ 400
206
+ );
207
+ }
208
+ return htmlErrorResponse(
209
+ "Authorization failed",
210
+ `${providerLabel} returned an error: ${escapeXml(errorParam)}. Return to Slack and try again.`,
211
+ 400
212
+ );
213
+ }
214
+ if (!code || !state) {
215
+ return htmlErrorResponse("Invalid request", "This authorization link is missing required parameters.", 400);
216
+ }
217
+ const stateAdapter = getStateAdapter();
218
+ const stateKey = `oauth-state:${state}`;
219
+ const stored = await stateAdapter.get(stateKey);
220
+ if (!stored) {
221
+ return htmlErrorResponse(
222
+ "Link expired",
223
+ `This authorization link has expired (links are valid for 10 minutes). Return to Slack and ask to connect your ${providerLabel} account again, or retry your original command to get a new link.`,
224
+ 400
225
+ );
226
+ }
227
+ if (stored.provider !== provider) {
228
+ return htmlErrorResponse("Provider mismatch", "This authorization link does not match the expected provider.", 400);
229
+ }
230
+ await stateAdapter.delete(stateKey);
231
+ const clientId = process.env[providerConfig.clientIdEnv]?.trim();
232
+ const clientSecret = process.env[providerConfig.clientSecretEnv]?.trim();
233
+ if (!clientId || !clientSecret) {
234
+ return htmlErrorResponse("Configuration error", "OAuth client credentials are not configured on the server.", 500);
235
+ }
236
+ const baseUrl = resolveBaseUrl();
237
+ if (!baseUrl) {
238
+ return htmlErrorResponse("Configuration error", "The server cannot determine its base URL.", 500);
239
+ }
240
+ const redirectUri = `${baseUrl}${providerConfig.callbackPath}`;
241
+ let tokenResponse;
242
+ try {
243
+ tokenResponse = await fetch(providerConfig.tokenEndpoint, {
244
+ method: "POST",
245
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
246
+ body: new URLSearchParams({
247
+ grant_type: "authorization_code",
248
+ code,
249
+ client_id: clientId,
250
+ client_secret: clientSecret,
251
+ redirect_uri: redirectUri
252
+ })
253
+ });
254
+ } catch {
255
+ return htmlErrorResponse("Connection failed", "Failed to exchange the authorization code. Please try again.", 500);
256
+ }
257
+ if (!tokenResponse.ok) {
258
+ return htmlErrorResponse("Connection failed", "The token exchange with the provider failed. Please try again.", 500);
259
+ }
260
+ const tokenData = await tokenResponse.json();
261
+ if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
262
+ return htmlErrorResponse("Connection failed", "The provider returned an incomplete token response. Please try again.", 500);
263
+ }
264
+ const accessToken = tokenData.access_token;
265
+ const refreshToken = tokenData.refresh_token;
266
+ const expiresAt = Date.now() + tokenData.expires_in * 1e3;
267
+ const userTokenStore = getUserTokenStore();
268
+ await userTokenStore.set(stored.userId, provider, {
269
+ accessToken,
270
+ refreshToken,
271
+ expiresAt
272
+ });
273
+ after(async () => {
274
+ try {
275
+ await publishAppHomeView(getSlackClient(), stored.userId, userTokenStore);
276
+ } catch {
277
+ }
278
+ });
279
+ if (stored.pendingMessage && stored.channelId && stored.threadTs) {
280
+ after(() => resumePendingMessage(stored));
281
+ } else if (stored.channelId && stored.threadTs) {
282
+ after(async () => {
283
+ await postSlackMessage(
284
+ stored.channelId,
285
+ stored.threadTs,
286
+ `Your ${providerLabel} account is now connected. You can start using ${providerLabel} commands.`
287
+ );
288
+ });
289
+ }
290
+ const statusMessage = stored.pendingMessage ? "Your request is being processed in Slack." : "You can close this tab and return to Slack.";
291
+ const html = `<!DOCTYPE html>
292
+ <html>
293
+ <head><title>${providerLabel} Connected</title></head>
294
+ <body style="font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
295
+ <div style="text-align: center;">
296
+ <h1>${providerLabel} account connected</h1>
297
+ <p>${statusMessage}</p>
298
+ </div>
299
+ </body>
300
+ </html>`;
301
+ return new Response(html, {
302
+ status: 200,
303
+ headers: { "Content-Type": "text/html; charset=utf-8" }
304
+ });
305
+ }
306
+ export {
307
+ GET,
308
+ runtime
309
+ };
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@sentry/junior",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "bin": {
10
+ "junior": "bin/junior.mjs"
11
+ },
12
+ "exports": {
13
+ "./handler": "./dist/handlers/router.js",
14
+ "./handlers/webhooks": "./dist/handlers/webhooks.js",
15
+ "./handlers/queue-callback": "./dist/handlers/queue-callback.js",
16
+ "./handlers/health": "./dist/handlers/health.js",
17
+ "./config": "./dist/next-config.js",
18
+ "./instrumentation": "./dist/instrumentation.js",
19
+ "./app/layout": "./dist/app/layout.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "bin"
24
+ ],
25
+ "dependencies": {
26
+ "@ai-sdk/gateway": "^3.0.66",
27
+ "@chat-adapter/slack": "4.17.0",
28
+ "@chat-adapter/state-memory": "4.17.0",
29
+ "@chat-adapter/state-redis": "4.17.0",
30
+ "@mariozechner/pi-agent-core": "^0.56.3",
31
+ "@mariozechner/pi-ai": "^0.56.3",
32
+ "@sinclair/typebox": "^0.34.48",
33
+ "@slack/web-api": "^7.14.1",
34
+ "@vercel/queue": "^0.1.3",
35
+ "@vercel/sandbox": "^1.8.0",
36
+ "ai": "^6.0.116",
37
+ "bash-tool": "^1.3.15",
38
+ "chat": "4.17.0",
39
+ "just-bash": "^2.12.0",
40
+ "node-html-markdown": "^2.0.0",
41
+ "yaml": "^2.8.2",
42
+ "zod": "^4.3.6",
43
+ "@sentry/junior-github": "0.1.0",
44
+ "@sentry/junior-sentry": "0.1.0"
45
+ },
46
+ "peerDependencies": {
47
+ "@sentry/nextjs": ">=10.0.0",
48
+ "next": ">=15.0.0",
49
+ "react": ">=19.0.0",
50
+ "react-dom": ">=19.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@sentry/nextjs": "^10.42.0",
54
+ "@types/node": "^25.3.5",
55
+ "@types/react": "^19.2.14",
56
+ "@types/react-dom": "^19.2.3",
57
+ "msw": "^2.12.10",
58
+ "next": "^16.1.6",
59
+ "react": "^19.2.4",
60
+ "react-dom": "^19.2.4",
61
+ "tsup": "^8.5.1",
62
+ "typescript": "^5.9.3",
63
+ "vercel": "^50.28.0",
64
+ "vitest": "^4.0.18",
65
+ "vitest-evals": "^0.6.0"
66
+ },
67
+ "scripts": {
68
+ "dev": "next dev",
69
+ "build": "next build",
70
+ "build:pkg": "tsup",
71
+ "start": "next start",
72
+ "test": "pnpm run test:slack-boundary && vitest run",
73
+ "test:watch": "vitest",
74
+ "preevals": "pnpm run test:slack-boundary",
75
+ "evals": "JUNIOR_STATE_ADAPTER=memory pnpm exec vitest run -c vitest.evals.config.ts",
76
+ "test:slack-boundary": "node scripts/check-slack-test-boundary.mjs",
77
+ "typecheck": "tsc --noEmit",
78
+ "skills:check": "node scripts/check-skills.mjs"
79
+ }
80
+ }