@theholocron/holocron-plugin-sentry 5.0.0-alpha.28 → 5.0.0-alpha.280
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.
- package/dist/index.d.mts +52 -30
- package/dist/index.mjs +543 -14
- package/package.json +21 -19
package/dist/index.d.mts
CHANGED
|
@@ -1,28 +1,49 @@
|
|
|
1
|
-
import { AuthError, Errors, ResolveTokenInput } from "@theholocron/cli";
|
|
1
|
+
import { AuthError, EnsureRelayInput, EnsureRelayResult, Errors, InspectRelayInput, PluginToken, RelayState, ResolveTokenInput, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess } from "@theholocron/cli";
|
|
2
2
|
import { SentryClient, SentryClientOptions, createSentryClient } from "@theholocron/sentry-client";
|
|
3
|
-
|
|
4
|
-
declare const resolveToken: (
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/** Resolves the Sentry token from `--token`, `HOLOCRON_SENTRY_TOKEN`, `SENTRY_AUTH_TOKEN` or the keyring, and throws an `AuthError` when none is set. */
|
|
4
|
+
export declare const resolveToken: import("@theholocron/cli").TokenResolver;
|
|
5
|
+
/**
|
|
6
|
+
* The token for the Comlink relay: an integration, its client secret and an alert rule need more than the general token
|
|
7
|
+
* (`alerts:write`, `org:write`, `event:read`), so it has its own.
|
|
8
|
+
*/
|
|
9
|
+
export declare const resolveComlinkToken: import("@theholocron/cli").FeatureResolver;
|
|
10
|
+
/**
|
|
11
|
+
* The token for the relay: the Comlink one when there is one, else the general Sentry token as the last resort. The
|
|
12
|
+
* fallback only helps if the general token happens to carry the scopes; otherwise Sentry's answer names what is missing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveRelayToken(input?: ResolveTokenInput): string;
|
|
15
|
+
/** Every token this plugin takes, for `holocron doctor tokens`: the general one and the Comlink relay's own. */
|
|
16
|
+
export declare const tokens: PluginToken[];
|
|
17
|
+
/** Options for the Sentry `errors` capability. */
|
|
7
18
|
interface SentryErrorsOptions {
|
|
8
19
|
/** Sentry organization slug. Required for `whoami` and `ensureProject`. */
|
|
9
20
|
org?: string;
|
|
10
21
|
/** Default team slug for project creation. Defaults to org slug. */
|
|
11
22
|
team?: string;
|
|
12
23
|
}
|
|
13
|
-
|
|
24
|
+
/** The `errors` capability for Sentry: finds or creates a project and reports its DSN, and manages the Comlink relay. */
|
|
25
|
+
export declare class SentryErrors implements Errors {
|
|
14
26
|
private readonly client;
|
|
15
27
|
private readonly opts;
|
|
28
|
+
/** The client for the relay, made with the Comlink token (see `resolveRelayToken`). */
|
|
29
|
+
private readonly relayClient;
|
|
30
|
+
/** The capability key, always `errors`. */
|
|
16
31
|
readonly key: "errors";
|
|
32
|
+
/** The provider name, `sentry`. */
|
|
17
33
|
readonly providerName = "sentry";
|
|
18
|
-
constructor(client: () => SentryClient, opts: SentryErrorsOptions
|
|
34
|
+
constructor(client: () => SentryClient, opts: SentryErrorsOptions,
|
|
35
|
+
/** The client for the relay, made with the Comlink token (see `resolveRelayToken`). */
|
|
36
|
+
relayClient?: () => SentryClient);
|
|
37
|
+
ensureRelay(input: EnsureRelayInput): Promise<EnsureRelayResult>;
|
|
38
|
+
inspectRelay(input: InspectRelayInput): Promise<RelayState>;
|
|
19
39
|
describe(): Promise<{
|
|
20
40
|
provider: string;
|
|
21
41
|
envKeys: string[];
|
|
22
42
|
}>;
|
|
23
43
|
whoami(): Promise<{
|
|
24
44
|
org: string;
|
|
25
|
-
}>;
|
|
45
|
+
} | null>;
|
|
46
|
+
/** Returns the public DSN of the project named `input.name` (matched by its slug), creating it when missing; needs `org`, and the new project uses `team` (default the org slug) and `platform` (default `node`). */
|
|
26
47
|
ensureProject(input: {
|
|
27
48
|
name: string;
|
|
28
49
|
platform?: string;
|
|
@@ -37,25 +58,14 @@ declare class SentryErrors implements Errors {
|
|
|
37
58
|
*/
|
|
38
59
|
private requireOrg;
|
|
39
60
|
}
|
|
40
|
-
//#endregion
|
|
41
|
-
//#region src/verify-token.d.ts
|
|
42
|
-
interface VerifyTokenSuccess {
|
|
43
|
-
ok: true;
|
|
44
|
-
subject: string;
|
|
45
|
-
}
|
|
46
|
-
interface VerifyTokenFailure {
|
|
47
|
-
ok: false;
|
|
48
|
-
message: string;
|
|
49
|
-
}
|
|
50
|
-
type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
|
|
51
61
|
interface VerifyTokenOptions {
|
|
52
62
|
baseUrl?: string;
|
|
53
63
|
fetch?: typeof fetch;
|
|
54
64
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
interface SentryPluginOptions extends ResolveTokenInput, SentryErrorsOptions {
|
|
65
|
+
/** Checks a Sentry token by listing its organizations; the success subject is `org:` and the first organization's slug, the failure carries the error message. */
|
|
66
|
+
export declare function verifyToken(token: string, opts?: VerifyTokenOptions): Promise<VerifyTokenResult$1>;
|
|
67
|
+
/** Options for the Sentry plugin: token resolution, the organization and team, and test overrides. */
|
|
68
|
+
export interface SentryPluginOptions extends ResolveTokenInput, SentryErrorsOptions {
|
|
59
69
|
/**
|
|
60
70
|
* Sentry organization slug. Optional at load time — the `errors` capability
|
|
61
71
|
* activates from env vars alone — but required for `whoami` / `ensureProject`
|
|
@@ -64,20 +74,32 @@ interface SentryPluginOptions extends ResolveTokenInput, SentryErrorsOptions {
|
|
|
64
74
|
org?: string;
|
|
65
75
|
/** Override base URL for tests. */
|
|
66
76
|
baseUrl?: string;
|
|
77
|
+
/** Override `fetch` for tests. */
|
|
67
78
|
fetch?: typeof fetch;
|
|
68
79
|
}
|
|
69
|
-
|
|
80
|
+
/** What the plugin's capabilities share: the options and two lazily built clients. */
|
|
81
|
+
export interface PluginContext {
|
|
82
|
+
/** The options the plugin was created with. */
|
|
70
83
|
options: SentryPluginOptions;
|
|
84
|
+
/** Builds the Sentry client on first call and reuses it; the token is resolved then, not at plugin load. */
|
|
71
85
|
client: () => SentryClient;
|
|
86
|
+
/** The client for the Comlink relay: its own token (`sentry.comlink`), the general one only as a last resort. */
|
|
87
|
+
relayClient: () => SentryClient;
|
|
72
88
|
}
|
|
73
|
-
|
|
74
|
-
declare function
|
|
75
|
-
|
|
89
|
+
/** Creates the shared context for the given options; no token is resolved until a client is first called. */
|
|
90
|
+
export declare function createContext(options: SentryPluginOptions): PluginContext;
|
|
91
|
+
/** Builds the `errors` capability from a plugin context, using the relay client for the Comlink relay. */
|
|
92
|
+
export declare function errors(ctx: PluginContext): Errors;
|
|
93
|
+
/** Creates the Sentry plugin: its probes, a `connection` probe and the `errors` capability. */
|
|
94
|
+
export declare function createPlugin(options: SentryPluginOptions): {
|
|
76
95
|
name: string;
|
|
96
|
+
probes: {
|
|
97
|
+
connection: import("@theholocron/cli").Probe;
|
|
98
|
+
};
|
|
77
99
|
capabilities: {
|
|
78
100
|
errors: () => Errors;
|
|
79
101
|
};
|
|
80
102
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
export {
|
|
103
|
+
/** One-line hint shown by `holocron auth set sentry` when no token is given or the token is rejected. */
|
|
104
|
+
export declare const AUTH_HINT: string;
|
|
105
|
+
export { AuthError, type ResolveTokenInput, type SentryClient, type SentryClientOptions, type SentryErrorsOptions, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createSentryClient };
|
package/dist/index.mjs
CHANGED
|
@@ -1,22 +1,289 @@
|
|
|
1
|
-
import { AuthError, ProviderApiError, createResolveToken } from "@theholocron/cli";
|
|
1
|
+
import { AuthError, ProviderApiError, connectionProbe, createFeatureResolver, createResolveToken } from "@theholocron/cli";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { createSentryClient } from "@theholocron/sentry-client";
|
|
3
|
-
|
|
4
|
+
import { errorMessage } from "@theholocron/misc-utils";
|
|
5
|
+
// src/auth.ts
|
|
6
|
+
/** Resolves the Sentry token from `--token`, `HOLOCRON_SENTRY_TOKEN`, `SENTRY_AUTH_TOKEN` or the keyring, and throws an `AuthError` when none is set. */
|
|
4
7
|
const resolveToken = createResolveToken({
|
|
5
8
|
envName: "HOLOCRON_SENTRY_TOKEN",
|
|
6
9
|
vendorEnvName: "SENTRY_AUTH_TOKEN",
|
|
7
10
|
keyringService: "sentry",
|
|
8
11
|
errorMessage: "no Sentry auth token found. Pass --token <TOKEN>, set HOLOCRON_SENTRY_TOKEN / SENTRY_AUTH_TOKEN, or run: holocron auth set sentry <TOKEN>"
|
|
9
12
|
});
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The token for the Comlink relay: an integration, its client secret and an alert rule need more than the general token
|
|
15
|
+
* (`alerts:write`, `org:write`, `event:read`), so it has its own.
|
|
16
|
+
*/
|
|
17
|
+
const resolveComlinkToken = createFeatureResolver({
|
|
18
|
+
envName: "HOLOCRON_SENTRY_COMLINK_TOKEN",
|
|
19
|
+
keyringKey: "sentry.comlink",
|
|
20
|
+
vendor: "Sentry"
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* The token for the relay: the Comlink one when there is one, else the general Sentry token as the last resort. The
|
|
24
|
+
* fallback only helps if the general token happens to carry the scopes; otherwise Sentry's answer names what is missing.
|
|
25
|
+
*/
|
|
26
|
+
function resolveRelayToken(input = {}) {
|
|
27
|
+
try {
|
|
28
|
+
return resolveComlinkToken(input);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (!(err instanceof AuthError)) throw err;
|
|
31
|
+
return resolveToken(input);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Every token this plugin takes, for `holocron doctor tokens`: the general one and the Comlink relay's own. */
|
|
35
|
+
const tokens = [resolveToken.token, resolveComlinkToken.token];
|
|
36
|
+
// src/capabilities/relay.ts
|
|
37
|
+
/** What the integration needs to read: the alert's event. It is also why reading its secret needs the same scope. */
|
|
38
|
+
const INTEGRATION_SCOPES = ["event:read"];
|
|
39
|
+
const DEFAULT_FREQUENCY = 5;
|
|
40
|
+
/**
|
|
41
|
+
* The alert action that sends through an integration, in the shape Sentry's own page writes when "Send a notification via
|
|
42
|
+
* an integration" is picked: a `webhook` action named by the integration's slug. (A `sentry_app` action with the numeric
|
|
43
|
+
* id is accepted too, but the page shows it as an unknown integration.)
|
|
44
|
+
*/
|
|
45
|
+
const integrationAction = (slug) => ({
|
|
46
|
+
type: "webhook",
|
|
47
|
+
config: {
|
|
48
|
+
targetType: null,
|
|
49
|
+
targetDisplay: null,
|
|
50
|
+
targetIdentifier: slug
|
|
51
|
+
},
|
|
52
|
+
data: {}
|
|
53
|
+
});
|
|
54
|
+
const sendsThrough = (action, slug) => action.type === "webhook" && action.config["targetIdentifier"] === slug;
|
|
55
|
+
/** The `sentry_app` form an earlier version wrote for this integration, which the page cannot name. */
|
|
56
|
+
const isLegacyAction = (action, appId) => action.type === "sentry_app" && String(action.config["targetIdentifier"]) === String(appId);
|
|
57
|
+
/** A workflow as Sentry returned it, as the body to send back (the nested ids are not part of an update). */
|
|
58
|
+
function toInput(wf) {
|
|
59
|
+
return {
|
|
60
|
+
name: wf.name,
|
|
61
|
+
enabled: wf.enabled,
|
|
62
|
+
environment: wf.environment,
|
|
63
|
+
config: wf.config,
|
|
64
|
+
detectorIds: wf.detectorIds,
|
|
65
|
+
triggers: {
|
|
66
|
+
logicType: wf.triggers.logicType,
|
|
67
|
+
conditions: wf.triggers.conditions.map((c) => ({
|
|
68
|
+
type: c.type,
|
|
69
|
+
comparison: c.comparison,
|
|
70
|
+
conditionResult: c.conditionResult
|
|
71
|
+
}))
|
|
72
|
+
},
|
|
73
|
+
actionFilters: wf.actionFilters.map((f) => ({
|
|
74
|
+
logicType: f.logicType,
|
|
75
|
+
conditions: f.conditions.map((c) => ({
|
|
76
|
+
type: c.type,
|
|
77
|
+
comparison: c.comparison,
|
|
78
|
+
conditionResult: c.conditionResult
|
|
79
|
+
})),
|
|
80
|
+
actions: f.actions.map((a) => ({
|
|
81
|
+
type: a.type,
|
|
82
|
+
config: a.config,
|
|
83
|
+
data: a.data
|
|
84
|
+
}))
|
|
85
|
+
}))
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Sentry's own "high priority issues" rule, which is the default for a new alert. */
|
|
89
|
+
function newWorkflow(name, frequency, detectorIds, slug) {
|
|
90
|
+
return {
|
|
91
|
+
name,
|
|
92
|
+
enabled: true,
|
|
93
|
+
environment: null,
|
|
94
|
+
config: { frequency },
|
|
95
|
+
detectorIds,
|
|
96
|
+
triggers: {
|
|
97
|
+
logicType: "any-short",
|
|
98
|
+
conditions: [{
|
|
99
|
+
type: "new_high_priority_issue",
|
|
100
|
+
comparison: true,
|
|
101
|
+
conditionResult: true
|
|
102
|
+
}, {
|
|
103
|
+
type: "existing_high_priority_issue",
|
|
104
|
+
comparison: true,
|
|
105
|
+
conditionResult: true
|
|
106
|
+
}]
|
|
107
|
+
},
|
|
108
|
+
actionFilters: [{
|
|
109
|
+
logicType: "all",
|
|
110
|
+
conditions: [],
|
|
111
|
+
actions: [integrationAction(slug)]
|
|
112
|
+
}]
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The integration: found by name, made if missing, and its webhook and alert action brought in line. */
|
|
116
|
+
async function ensureIntegration(client, org, input) {
|
|
117
|
+
const existing = (await client.sentryApps.list(org)).find((a) => a.name === input.name);
|
|
118
|
+
if (!existing) return {
|
|
119
|
+
app: await client.sentryApps.create(org, {
|
|
120
|
+
name: input.name,
|
|
121
|
+
scopes: INTEGRATION_SCOPES,
|
|
122
|
+
webhookUrl: input.webhookUrl,
|
|
123
|
+
isAlertable: true
|
|
124
|
+
}),
|
|
125
|
+
created: true,
|
|
126
|
+
updated: []
|
|
127
|
+
};
|
|
128
|
+
const updated = [];
|
|
129
|
+
if (existing.webhookUrl !== input.webhookUrl) updated.push("webhook URL");
|
|
130
|
+
if (!existing.isAlertable) updated.push("alert action");
|
|
131
|
+
const scopes = [.../* @__PURE__ */ new Set([...existing.scopes, ...INTEGRATION_SCOPES])];
|
|
132
|
+
if (scopes.length !== existing.scopes.length) updated.push("scopes");
|
|
133
|
+
if (updated.length === 0) return {
|
|
134
|
+
app: existing,
|
|
135
|
+
created: false,
|
|
136
|
+
updated
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
app: await client.sentryApps.update(existing.slug, {
|
|
140
|
+
name: existing.name,
|
|
141
|
+
scopes,
|
|
142
|
+
webhookUrl: input.webhookUrl,
|
|
143
|
+
isAlertable: true
|
|
144
|
+
}),
|
|
145
|
+
created: false,
|
|
146
|
+
updated
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** The issue stream detector of each project, so a wrong project fails before anything is changed. */
|
|
150
|
+
async function detectorsFor(client, org, slugs) {
|
|
151
|
+
const [projects, detectors] = await Promise.all([client.projects.list(org), client.workflows.detectors(org)]);
|
|
152
|
+
return slugs.map((slug) => {
|
|
153
|
+
const project = projects.find((p) => p.slug === slug);
|
|
154
|
+
if (!project) throw new Error(`Sentry project \`${slug}\` was not found in ${org}`);
|
|
155
|
+
const detector = detectors.find((d) => d.projectId === project.id && d.type === "issue_stream");
|
|
156
|
+
if (!detector) throw new Error(`Sentry project \`${slug}\` has no issue stream detector to attach an alert to`);
|
|
157
|
+
return detector.id;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** The alert rule: one workflow for every project, found by name, made if missing, reconciled if not. */
|
|
161
|
+
async function ensureRule(client, org, input, install, detectorIds) {
|
|
162
|
+
const workflows = await client.workflows.list(org);
|
|
163
|
+
const frequency = input.rule.frequency ?? DEFAULT_FREQUENCY;
|
|
164
|
+
const existing = workflows.find((w) => w.name === input.rule.name);
|
|
165
|
+
if (!existing) {
|
|
166
|
+
await client.workflows.create(org, newWorkflow(input.rule.name, frequency, detectorIds, install.slug));
|
|
167
|
+
return {
|
|
168
|
+
created: true,
|
|
169
|
+
updated: []
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const next = toInput(existing);
|
|
173
|
+
const updated = [];
|
|
174
|
+
if (!existing.enabled) {
|
|
175
|
+
next.enabled = true;
|
|
176
|
+
updated.push("enabled");
|
|
177
|
+
}
|
|
178
|
+
if (input.rule.frequency !== void 0 && existing.config.frequency !== input.rule.frequency) {
|
|
179
|
+
next.config = {
|
|
180
|
+
...existing.config,
|
|
181
|
+
frequency: input.rule.frequency
|
|
182
|
+
};
|
|
183
|
+
updated.push("frequency");
|
|
184
|
+
}
|
|
185
|
+
const missing = detectorIds.filter((id) => !existing.detectorIds.includes(id));
|
|
186
|
+
if (missing.length > 0) {
|
|
187
|
+
next.detectorIds = [...existing.detectorIds, ...missing];
|
|
188
|
+
updated.push("projects");
|
|
189
|
+
}
|
|
190
|
+
const sends = existing.actionFilters.some((f) => f.actions.some((a) => sendsThrough(a, install.slug)));
|
|
191
|
+
const legacy = existing.actionFilters.some((f) => f.actions.some((a) => isLegacyAction(a, install.appId)));
|
|
192
|
+
if (legacy) for (const f of next.actionFilters) f.actions = f.actions.filter((a) => !isLegacyAction(a, install.appId));
|
|
193
|
+
if (!sends) {
|
|
194
|
+
const open = next.actionFilters.find((f) => f.conditions.length === 0);
|
|
195
|
+
if (open) open.actions.push(integrationAction(install.slug));
|
|
196
|
+
else next.actionFilters.push({
|
|
197
|
+
logicType: "all",
|
|
198
|
+
conditions: [],
|
|
199
|
+
actions: [integrationAction(install.slug)]
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (legacy || !sends) updated.push("action");
|
|
203
|
+
if (updated.length === 0) return {
|
|
204
|
+
created: false,
|
|
205
|
+
updated
|
|
206
|
+
};
|
|
207
|
+
await client.workflows.update(org, existing.id, next);
|
|
208
|
+
return {
|
|
209
|
+
created: false,
|
|
210
|
+
updated
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** The signing secret, if the token may see it: a new integration's answer omits it, so it is read again. */
|
|
214
|
+
async function secretOf(client, app) {
|
|
215
|
+
if (app.clientSecret) return app.clientSecret;
|
|
216
|
+
return (await client.sentryApps.get(app.slug)).clientSecret || void 0;
|
|
217
|
+
}
|
|
218
|
+
async function ensureSentryRelay(client, org, input) {
|
|
219
|
+
if (input.projects.length === 0) throw new Error("relay needs at least one Sentry project to alert on");
|
|
220
|
+
const detectorIds = await detectorsFor(client, org, input.projects);
|
|
221
|
+
const integration = await ensureIntegration(client, org, input);
|
|
222
|
+
const install = (await client.sentryApps.installations(org)).find((i) => i.app.slug === integration.app.slug);
|
|
223
|
+
if (!install) throw new Error(`the integration \`${integration.app.name}\` is not installed on ${org}`);
|
|
224
|
+
const rule = await ensureRule(client, org, input, {
|
|
225
|
+
slug: install.app.slug,
|
|
226
|
+
appId: install.app.sentryAppId
|
|
227
|
+
}, detectorIds);
|
|
228
|
+
const clientSecret = await secretOf(client, integration.app);
|
|
229
|
+
return {
|
|
230
|
+
integration: {
|
|
231
|
+
created: integration.created,
|
|
232
|
+
updated: integration.updated
|
|
233
|
+
},
|
|
234
|
+
rule,
|
|
235
|
+
...clientSecret ? { clientSecret } : {}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Read the integration and the alert rule back, changing nothing. The integration's signing secret is not read: the
|
|
240
|
+
* list answer is used as it is and `get`, which returns the secret, is never called.
|
|
241
|
+
*/
|
|
242
|
+
async function inspectSentryRelay(client, org, input) {
|
|
243
|
+
const apps = await client.sentryApps.list(org);
|
|
244
|
+
const projects = await client.projects.list(org);
|
|
245
|
+
const detectors = await client.workflows.detectors(org);
|
|
246
|
+
const workflows = await client.workflows.list(org);
|
|
247
|
+
const app = apps.find((a) => a.name === input.name);
|
|
248
|
+
const rule = workflows.find((w) => w.name === input.rule.name);
|
|
249
|
+
return {
|
|
250
|
+
...app ? { integration: {
|
|
251
|
+
webhookUrl: app.webhookUrl,
|
|
252
|
+
alertAction: app.isAlertable
|
|
253
|
+
} } : {},
|
|
254
|
+
rules: rule ? input.projects.flatMap((slug) => {
|
|
255
|
+
const project = projects.find((p) => p.slug === slug);
|
|
256
|
+
const detector = project && detectors.find((d) => d.projectId === project.id && d.type === "issue_stream");
|
|
257
|
+
if (!detector || !rule.detectorIds.includes(detector.id)) return [];
|
|
258
|
+
const sends = app !== void 0 && rule.actionFilters.some((f) => f.actions.some((a) => sendsThrough(a, app.slug)));
|
|
259
|
+
return [{
|
|
260
|
+
project: slug,
|
|
261
|
+
enabled: rule.enabled,
|
|
262
|
+
sends
|
|
263
|
+
}];
|
|
264
|
+
}) : []
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// src/capabilities/errors.ts
|
|
268
|
+
/** The `errors` capability for Sentry: finds or creates a project and reports its DSN, and manages the Comlink relay. */
|
|
12
269
|
var SentryErrors = class {
|
|
13
270
|
client;
|
|
14
271
|
opts;
|
|
272
|
+
relayClient;
|
|
273
|
+
/** The capability key, always `errors`. */
|
|
15
274
|
key = "errors";
|
|
275
|
+
/** The provider name, `sentry`. */
|
|
16
276
|
providerName = "sentry";
|
|
17
|
-
constructor(client, opts) {
|
|
277
|
+
constructor(client, opts, relayClient = client) {
|
|
18
278
|
this.client = client;
|
|
19
279
|
this.opts = opts;
|
|
280
|
+
this.relayClient = relayClient;
|
|
281
|
+
}
|
|
282
|
+
async ensureRelay(input) {
|
|
283
|
+
return ensureSentryRelay(this.relayClient(), this.requireOrg(), input);
|
|
284
|
+
}
|
|
285
|
+
async inspectRelay(input) {
|
|
286
|
+
return inspectSentryRelay(this.relayClient(), this.requireOrg(), input);
|
|
20
287
|
}
|
|
21
288
|
async describe() {
|
|
22
289
|
return {
|
|
@@ -25,8 +292,10 @@ var SentryErrors = class {
|
|
|
25
292
|
};
|
|
26
293
|
}
|
|
27
294
|
async whoami() {
|
|
28
|
-
|
|
295
|
+
if (!this.opts.org) return null;
|
|
296
|
+
return { org: (await this.client().auth.getOrg(this.opts.org)).slug };
|
|
29
297
|
}
|
|
298
|
+
/** Returns the public DSN of the project named `input.name` (matched by its slug), creating it when missing; needs `org`, and the new project uses `team` (default the org slug) and `platform` (default `node`). */
|
|
30
299
|
async ensureProject(input) {
|
|
31
300
|
const org = this.requireOrg();
|
|
32
301
|
const slug = toSlug(input.name);
|
|
@@ -68,8 +337,243 @@ var SentryErrors = class {
|
|
|
68
337
|
function toSlug(name) {
|
|
69
338
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
70
339
|
}
|
|
71
|
-
|
|
72
|
-
|
|
340
|
+
// src/probes/probe-deps.type.ts
|
|
341
|
+
function requireOrg(deps) {
|
|
342
|
+
if (!deps.org) throw new Error("the Sentry probes need `org` in the sentry provider's options");
|
|
343
|
+
return deps.org;
|
|
344
|
+
}
|
|
345
|
+
// src/probes/integrations.ts
|
|
346
|
+
/** Where a webhook points, without a query string that could carry a token. */
|
|
347
|
+
function where(url) {
|
|
348
|
+
if (!url) return "no webhook";
|
|
349
|
+
try {
|
|
350
|
+
const u = new URL(url);
|
|
351
|
+
return `${u.host}${u.pathname}`;
|
|
352
|
+
} catch {
|
|
353
|
+
return "an unreadable URL";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function integrationsProbe(deps) {
|
|
357
|
+
return {
|
|
358
|
+
summary: "List the custom integrations: scopes, alert action, webhook, and whether the client secret is visible.",
|
|
359
|
+
writes: false,
|
|
360
|
+
async run(probe) {
|
|
361
|
+
const apps = await deps.client().sentryApps.list(requireOrg(deps));
|
|
362
|
+
if (apps.length === 0) probe.say({
|
|
363
|
+
status: "info",
|
|
364
|
+
label: "no custom integrations"
|
|
365
|
+
});
|
|
366
|
+
for (const app of apps) {
|
|
367
|
+
probe.secret(app.clientSecret);
|
|
368
|
+
probe.say({
|
|
369
|
+
status: "ok",
|
|
370
|
+
label: `${app.name} (${app.slug})`,
|
|
371
|
+
detail: [
|
|
372
|
+
`scopes: ${app.scopes.join(", ") || "none"}`,
|
|
373
|
+
`alert action: ${app.isAlertable ? "on" : "off"}`,
|
|
374
|
+
`webhook: ${where(app.webhookUrl)}`,
|
|
375
|
+
`secret visible: ${app.clientSecret ? "yes" : "no"}`
|
|
376
|
+
].join(" | ")
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// src/probes/rule-update.ts
|
|
383
|
+
const FROM = 5;
|
|
384
|
+
const TO = 10;
|
|
385
|
+
/** The detector the throwaway rule hangs on: the issue stream of `HOLOCRON_SENTRY_PROBE_PROJECT`, else of any project. */
|
|
386
|
+
async function detectorFor(deps, org) {
|
|
387
|
+
const client = deps.client();
|
|
388
|
+
const slug = (deps.env ?? process.env)["HOLOCRON_SENTRY_PROBE_PROJECT"];
|
|
389
|
+
const [detectors, projects] = await Promise.all([client.workflows.detectors(org), slug ? client.projects.list(org) : Promise.resolve([])]);
|
|
390
|
+
const streams = detectors.filter((d) => d.type === "issue_stream");
|
|
391
|
+
if (slug) {
|
|
392
|
+
const project = projects.find((p) => p.slug === slug);
|
|
393
|
+
if (!project) throw new Error(`Sentry project \`${slug}\` was not found in ${org}`);
|
|
394
|
+
const own = streams.find((d) => d.projectId === project.id);
|
|
395
|
+
if (!own) throw new Error(`Sentry project \`${slug}\` has no issue stream detector`);
|
|
396
|
+
return own.id;
|
|
397
|
+
}
|
|
398
|
+
const any = streams[0];
|
|
399
|
+
if (!any) throw new Error("no project has an issue stream detector to attach a throwaway rule to");
|
|
400
|
+
return any.id;
|
|
401
|
+
}
|
|
402
|
+
function ruleUpdateProbe(deps) {
|
|
403
|
+
return {
|
|
404
|
+
summary: "Make a disabled throwaway alert rule, change it the way the relay's adopt path does, read it back, delete it.",
|
|
405
|
+
writes: true,
|
|
406
|
+
async run(probe) {
|
|
407
|
+
const org = requireOrg(deps);
|
|
408
|
+
const client = deps.client();
|
|
409
|
+
const detectorId = await detectorFor(deps, org);
|
|
410
|
+
const body = {
|
|
411
|
+
name: `Holocron probe: throwaway rule (${(/* @__PURE__ */ new Date()).toISOString()})`,
|
|
412
|
+
enabled: false,
|
|
413
|
+
environment: null,
|
|
414
|
+
config: { frequency: FROM },
|
|
415
|
+
detectorIds: [detectorId],
|
|
416
|
+
triggers: {
|
|
417
|
+
logicType: "any-short",
|
|
418
|
+
conditions: [{
|
|
419
|
+
type: "new_high_priority_issue",
|
|
420
|
+
comparison: true,
|
|
421
|
+
conditionResult: true
|
|
422
|
+
}]
|
|
423
|
+
},
|
|
424
|
+
actionFilters: [{
|
|
425
|
+
logicType: "all",
|
|
426
|
+
conditions: [],
|
|
427
|
+
actions: []
|
|
428
|
+
}]
|
|
429
|
+
};
|
|
430
|
+
const created = await probe.write("create a disabled throwaway alert rule", async () => {
|
|
431
|
+
const wf = await client.workflows.create(org, body);
|
|
432
|
+
probe.cleanup(`the throwaway rule ${wf.id}`, () => client.workflows.delete(org, wf.id));
|
|
433
|
+
return wf;
|
|
434
|
+
});
|
|
435
|
+
if (!created) {
|
|
436
|
+
probe.say({
|
|
437
|
+
status: "info",
|
|
438
|
+
label: `would then change its frequency ${FROM} to ${TO} with PUT, read it back, and delete it`
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
probe.say({
|
|
443
|
+
status: "ok",
|
|
444
|
+
label: "created",
|
|
445
|
+
detail: `rule ${created.id}, disabled`
|
|
446
|
+
});
|
|
447
|
+
const next = toInput(created);
|
|
448
|
+
next.config = {
|
|
449
|
+
...created.config,
|
|
450
|
+
frequency: TO
|
|
451
|
+
};
|
|
452
|
+
await client.workflows.update(org, created.id, next);
|
|
453
|
+
probe.say({
|
|
454
|
+
status: "ok",
|
|
455
|
+
label: "PUT accepted"
|
|
456
|
+
});
|
|
457
|
+
const read = await client.workflows.get(org, created.id);
|
|
458
|
+
const kept = !read.enabled && read.detectorIds.includes(detectorId);
|
|
459
|
+
probe.say(read.config.frequency === TO && kept ? {
|
|
460
|
+
status: "ok",
|
|
461
|
+
label: "read back",
|
|
462
|
+
detail: `frequency ${read.config.frequency}, still disabled and attached`
|
|
463
|
+
} : {
|
|
464
|
+
status: "fail",
|
|
465
|
+
label: "read back differs",
|
|
466
|
+
detail: `frequency ${read.config.frequency} (sent ${TO}), enabled ${read.enabled}, detectors ${read.detectorIds.join(",")}`
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
// src/probes/rules.ts
|
|
472
|
+
function rulesProbe(deps) {
|
|
473
|
+
return {
|
|
474
|
+
summary: "List the alert rules: enabled, projects attached, and what each action sends to.",
|
|
475
|
+
writes: false,
|
|
476
|
+
async run(probe) {
|
|
477
|
+
const org = requireOrg(deps);
|
|
478
|
+
const client = deps.client();
|
|
479
|
+
const [workflows, apps] = await Promise.all([client.workflows.list(org), client.sentryApps.list(org)]);
|
|
480
|
+
const alertable = new Set(apps.filter((a) => a.isAlertable).map((a) => a.slug));
|
|
481
|
+
if (workflows.length === 0) probe.say({
|
|
482
|
+
status: "info",
|
|
483
|
+
label: "no alert rules"
|
|
484
|
+
});
|
|
485
|
+
for (const wf of workflows) {
|
|
486
|
+
const actions = wf.actionFilters.flatMap((f) => f.actions);
|
|
487
|
+
const described = actions.map((a) => {
|
|
488
|
+
const target = String(a.config["targetIdentifier"] ?? a.config["targetType"] ?? "");
|
|
489
|
+
return target ? `${a.type} ${target}` : a.type;
|
|
490
|
+
});
|
|
491
|
+
const problems = actions.flatMap((a) => {
|
|
492
|
+
if (a.type === "sentry_app") return [`a sentry_app action shows as an unknown integration`];
|
|
493
|
+
if (a.type === "webhook" && !alertable.has(String(a.config["targetIdentifier"]))) return [`webhook ${String(a.config["targetIdentifier"])} is not an alertable integration`];
|
|
494
|
+
return [];
|
|
495
|
+
});
|
|
496
|
+
probe.say({
|
|
497
|
+
status: problems.length > 0 || !wf.enabled ? "fail" : "ok",
|
|
498
|
+
label: wf.name,
|
|
499
|
+
detail: [
|
|
500
|
+
wf.enabled ? "enabled" : "DISABLED",
|
|
501
|
+
`detectors: ${wf.detectorIds.length}`,
|
|
502
|
+
`actions: ${described.join(", ") || "none"}`,
|
|
503
|
+
...problems
|
|
504
|
+
].join(" | ")
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
// src/probes/send-error.ts
|
|
511
|
+
/** Which DSN to send to: `SENTRY_DSN`, else the first client key of the project named in `HOLOCRON_SENTRY_PROBE_PROJECT`. */
|
|
512
|
+
async function dsnFor(deps) {
|
|
513
|
+
const env = deps.env ?? process.env;
|
|
514
|
+
if (env["SENTRY_DSN"]) return env["SENTRY_DSN"];
|
|
515
|
+
const project = env["HOLOCRON_SENTRY_PROBE_PROJECT"];
|
|
516
|
+
if (!project) throw new Error("set SENTRY_DSN, or HOLOCRON_SENTRY_PROBE_PROJECT to a project slug, to say where to send the error");
|
|
517
|
+
const dsn = (await deps.client().projects.keys(requireOrg(deps), project))[0]?.dsn.public;
|
|
518
|
+
if (!dsn) throw new Error(`project \`${project}\` has no client key`);
|
|
519
|
+
return dsn;
|
|
520
|
+
}
|
|
521
|
+
function sendErrorProbe(deps) {
|
|
522
|
+
return {
|
|
523
|
+
summary: "Send one labelled test error to a project, to follow it through the alert rule to Discord.",
|
|
524
|
+
writes: true,
|
|
525
|
+
async run(probe) {
|
|
526
|
+
const dsn = new URL(await dsnFor(deps));
|
|
527
|
+
probe.secret(dsn.username);
|
|
528
|
+
const projectId = dsn.pathname.replace(/^\/+/, "");
|
|
529
|
+
const eventId = randomUUID().replaceAll("-", "");
|
|
530
|
+
const message = `Holocron probe: sent on purpose (${(/* @__PURE__ */ new Date()).toISOString()})`;
|
|
531
|
+
const envelope = [
|
|
532
|
+
JSON.stringify({
|
|
533
|
+
event_id: eventId,
|
|
534
|
+
sent_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
535
|
+
}),
|
|
536
|
+
JSON.stringify({ type: "event" }),
|
|
537
|
+
JSON.stringify({
|
|
538
|
+
event_id: eventId,
|
|
539
|
+
level: "error",
|
|
540
|
+
platform: "node",
|
|
541
|
+
environment: "holocron-probe",
|
|
542
|
+
message: { formatted: message },
|
|
543
|
+
exception: { values: [{
|
|
544
|
+
type: "HolocronProbeError",
|
|
545
|
+
value: message
|
|
546
|
+
}] },
|
|
547
|
+
tags: { probe: "sentry.send-error" }
|
|
548
|
+
})
|
|
549
|
+
].join("\n");
|
|
550
|
+
if (await probe.write("send a test error", async () => {
|
|
551
|
+
const res = await (deps.fetch ?? fetch)(`${dsn.protocol}//${dsn.host}/api/${projectId}/envelope/?sentry_version=7&sentry_key=${dsn.username}&sentry_client=holocron-probe/1`, {
|
|
552
|
+
method: "POST",
|
|
553
|
+
headers: { "content-type": "application/x-sentry-envelope" },
|
|
554
|
+
body: envelope
|
|
555
|
+
});
|
|
556
|
+
if (!res.ok) throw new Error(`Sentry answered ${res.status}`);
|
|
557
|
+
return true;
|
|
558
|
+
})) probe.say({
|
|
559
|
+
status: "ok",
|
|
560
|
+
label: "sent HolocronProbeError",
|
|
561
|
+
detail: "look for it in the project's issues and, through the rule, in #errors; resolve the issue by hand (a probe cannot delete it)"
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
// src/probes/index.ts
|
|
567
|
+
function sentryProbes(deps) {
|
|
568
|
+
return {
|
|
569
|
+
integrations: integrationsProbe(deps),
|
|
570
|
+
rules: rulesProbe(deps),
|
|
571
|
+
"rule-update": ruleUpdateProbe(deps),
|
|
572
|
+
"send-error": sendErrorProbe(deps)
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
// src/verify-token.ts
|
|
576
|
+
/** Checks a Sentry token by listing its organizations; the success subject is `org:` and the first organization's slug, the failure carries the error message. */
|
|
73
577
|
async function verifyToken(token, opts = {}) {
|
|
74
578
|
const client = createSentryClient({
|
|
75
579
|
token,
|
|
@@ -84,36 +588,61 @@ async function verifyToken(token, opts = {}) {
|
|
|
84
588
|
} catch (err) {
|
|
85
589
|
return {
|
|
86
590
|
ok: false,
|
|
87
|
-
message:
|
|
591
|
+
message: errorMessage(err)
|
|
88
592
|
};
|
|
89
593
|
}
|
|
90
594
|
}
|
|
91
|
-
|
|
92
|
-
|
|
595
|
+
// src/index.ts
|
|
596
|
+
/** Creates the shared context for the given options; no token is resolved until a client is first called. */
|
|
93
597
|
function createContext(options) {
|
|
94
598
|
let client;
|
|
599
|
+
let relayClient;
|
|
95
600
|
return {
|
|
96
601
|
options,
|
|
97
602
|
client: () => client ??= createSentryClient({
|
|
98
603
|
token: resolveToken(options),
|
|
99
604
|
baseUrl: options.baseUrl,
|
|
100
605
|
fetch: options.fetch
|
|
606
|
+
}),
|
|
607
|
+
relayClient: () => relayClient ??= createSentryClient({
|
|
608
|
+
token: resolveRelayToken(options),
|
|
609
|
+
baseUrl: options.baseUrl,
|
|
610
|
+
fetch: options.fetch
|
|
101
611
|
})
|
|
102
612
|
};
|
|
103
613
|
}
|
|
614
|
+
/** Builds the `errors` capability from a plugin context, using the relay client for the Comlink relay. */
|
|
104
615
|
function errors(ctx) {
|
|
105
616
|
return new SentryErrors(ctx.client, {
|
|
106
617
|
org: ctx.options.org,
|
|
107
618
|
team: ctx.options.team
|
|
108
|
-
});
|
|
619
|
+
}, ctx.relayClient);
|
|
109
620
|
}
|
|
621
|
+
/** Creates the Sentry plugin: its probes, a `connection` probe and the `errors` capability. */
|
|
110
622
|
function createPlugin(options) {
|
|
111
623
|
const ctx = createContext(options);
|
|
112
624
|
return {
|
|
113
625
|
name: "@theholocron/holocron-plugin-sentry",
|
|
626
|
+
probes: {
|
|
627
|
+
...sentryProbes({
|
|
628
|
+
org: options.org,
|
|
629
|
+
client: ctx.relayClient,
|
|
630
|
+
fetch: options.fetch
|
|
631
|
+
}),
|
|
632
|
+
connection: connectionProbe({
|
|
633
|
+
provider: "Sentry",
|
|
634
|
+
url: options.baseUrl ?? "https://sentry.io",
|
|
635
|
+
fetch: options.fetch,
|
|
636
|
+
token: () => resolveToken(options),
|
|
637
|
+
verify: (token) => verifyToken(token, {
|
|
638
|
+
baseUrl: options.baseUrl,
|
|
639
|
+
fetch: options.fetch
|
|
640
|
+
})
|
|
641
|
+
})
|
|
642
|
+
},
|
|
114
643
|
capabilities: { errors: () => errors(ctx) }
|
|
115
644
|
};
|
|
116
645
|
}
|
|
646
|
+
/** One-line hint shown by `holocron auth set sentry` when no token is given or the token is rejected. */
|
|
117
647
|
const AUTH_HINT = "generate an auth token at https://sentry.io/settings/account/api/auth-tokens/ with project:read, project:write, and org:read scopes, then run: holocron auth set sentry <TOKEN>";
|
|
118
|
-
|
|
119
|
-
export { AUTH_HINT, AuthError, SentryErrors, createContext, createPlugin, createSentryClient, errors, resolveToken, verifyToken };
|
|
648
|
+
export { AUTH_HINT, AuthError, SentryErrors, createContext, createPlugin, createSentryClient, errors, resolveComlinkToken, resolveRelayToken, resolveToken, tokens, verifyToken };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theholocron/holocron-plugin-sentry",
|
|
3
|
-
"version": "5.0.0-alpha.
|
|
3
|
+
"version": "5.0.0-alpha.280",
|
|
4
4
|
"description": "Holocron plugin for Sentry. Implements the errors capability against Sentry's management API — project provisioning and DSN retrieval.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"errors",
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"plugin",
|
|
9
9
|
"sentry"
|
|
10
10
|
],
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://docs.theholocron.dev/holocron/plugins/sentry/",
|
|
12
12
|
"bugs": "https://github.com/theholocron/holocron/issues",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
15
|
"url": "git+https://github.com/theholocron/holocron.git",
|
|
16
|
-
"directory": "
|
|
16
|
+
"directory": "plugins/holocron-plugin-sentry"
|
|
17
17
|
},
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"author": "Newton Koumantzelis",
|
|
@@ -30,28 +30,30 @@
|
|
|
30
30
|
"dist"
|
|
31
31
|
],
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@theholocron/eslint-config": "^8.0.0",
|
|
34
|
-
"@theholocron/http-client": "^1.23.0",
|
|
35
|
-
"@theholocron/sentry-client": "^1.23.0",
|
|
36
|
-
"@theholocron/tsconfig": "^8.0.0",
|
|
37
|
-
"@theholocron/tsdown-config": "^8.0.0",
|
|
38
|
-
"@theholocron/vitest-config": "^8.0.0",
|
|
39
33
|
"@types/node": "^26",
|
|
40
|
-
"@vitest/coverage-v8": "^
|
|
34
|
+
"@vitest/coverage-v8": "^5.0.5",
|
|
41
35
|
"@vitest/eslint-plugin": "^1.6.27",
|
|
42
36
|
"chalk": "^6.0.0",
|
|
43
37
|
"eslint": "^10.8.1",
|
|
44
38
|
"eslint-plugin-n": "^18.3.0",
|
|
45
39
|
"globals": "^17.11.0",
|
|
46
|
-
"tsdown": "^0.
|
|
47
|
-
"tsx": "4.23.
|
|
40
|
+
"tsdown": "^0.23.3",
|
|
41
|
+
"tsx": "4.23.15",
|
|
48
42
|
"typescript": "^5.9.3",
|
|
49
|
-
"vitest": "^
|
|
50
|
-
"@theholocron/
|
|
43
|
+
"vitest": "^5.0.5",
|
|
44
|
+
"@theholocron/eslint-config": "^8.9.0-alpha.4",
|
|
45
|
+
"@theholocron/http-client": "^1.58.0-alpha.3",
|
|
46
|
+
"@theholocron/misc-utils": "^1.16.1-alpha.3",
|
|
47
|
+
"@theholocron/tsconfig": "^8.9.0-alpha.4",
|
|
48
|
+
"@theholocron/sentry-client": "^1.58.0-alpha.3",
|
|
49
|
+
"@theholocron/vitest-config": "^8.9.0-alpha.4",
|
|
50
|
+
"@theholocron/tsdown-config": "^8.9.0-alpha.4",
|
|
51
|
+
"@theholocron/cli": "5.0.0-alpha.280"
|
|
51
52
|
},
|
|
52
53
|
"peerDependencies": {
|
|
53
|
-
"@theholocron/
|
|
54
|
-
"@theholocron/
|
|
54
|
+
"@theholocron/misc-utils": "^1.16.0",
|
|
55
|
+
"@theholocron/sentry-client": "^1.56.0",
|
|
56
|
+
"@theholocron/cli": "5.0.0-alpha.280"
|
|
55
57
|
},
|
|
56
58
|
"peerDependenciesMeta": {
|
|
57
59
|
"@theholocron/sentry-client": {}
|
|
@@ -64,10 +66,10 @@
|
|
|
64
66
|
},
|
|
65
67
|
"scripts": {
|
|
66
68
|
"delivery.build": "tsdown",
|
|
67
|
-
"sourceQuality.staticAnalysis": "
|
|
69
|
+
"sourceQuality.staticAnalysis": "eslint .",
|
|
68
70
|
"test:watch": "vitest",
|
|
69
71
|
"validate": "tsx scripts/validate.mjs",
|
|
70
|
-
"verification.typeSafety": "
|
|
71
|
-
"verification.unitTests": "
|
|
72
|
+
"verification.typeSafety": "tsc --noEmit",
|
|
73
|
+
"verification.unitTests": "vitest run"
|
|
72
74
|
}
|
|
73
75
|
}
|