@indigoai-us/hq-cli 5.206.0 → 5.208.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.
- package/CHANGELOG.md +17 -0
- package/dist/command-catalog.generated.d.ts +15 -0
- package/dist/command-catalog.generated.js +20 -0
- package/dist/commands/doctor.js +74 -0
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +29 -5
- package/dist/lib/core-utils/sentry-report.d.ts +19 -0
- package/dist/lib/core-utils/sentry-report.js +61 -0
- package/dist/lib/daemon/hq-cloud.d.ts +27 -3
- package/dist/lib/daemon/hq-cloud.js +71 -25
- package/dist/lib/doctor/client-health-sentry-flag.d.ts +22 -0
- package/dist/lib/doctor/client-health-sentry-flag.js +88 -0
- package/dist/lib/doctor/client-health-sentry.d.ts +71 -0
- package/dist/lib/doctor/client-health-sentry.js +279 -0
- package/dist/sentry-before-send.js +80 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.208.0] — 2026-09-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq lanes accounts set-home` and `login` accept `--company <slug|uid>` and
|
|
10
|
+
scope the host-account-home flag read to that company.
|
|
11
|
+
- The `hq daemon` updater now installs new hq-cloud patch releases. It looks up the newest release in the bundled major version and installs that exact version. Before, `npm install` with a version range kept an installed copy that already matched the range, so the updater stayed on 6.18.1 after 6.18.2 was published. A failed lookup is reported as a failed update and installs nothing.
|
|
12
|
+
|
|
13
|
+
## [5.207.0] — 2026-09-26
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- The SessionStart hook can send client health reports straight to Sentry when
|
|
18
|
+
the company enables `client-health.sentry-direct`. Reports exclude doctor
|
|
19
|
+
text and company names. With the flag off, the hook keeps using the existing
|
|
20
|
+
feedback route.
|
|
21
|
+
|
|
5
22
|
## [5.206.0] — 2026-09-26
|
|
6
23
|
|
|
7
24
|
### Fixed
|
|
@@ -5552,6 +5552,15 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5552
5552
|
}, {
|
|
5553
5553
|
readonly flags: "--client-health-report";
|
|
5554
5554
|
readonly description: "Resolve the opt-in hq-flags gate used by the client-health report hook.";
|
|
5555
|
+
}, {
|
|
5556
|
+
readonly flags: "--client-health-sentry";
|
|
5557
|
+
readonly description: "Send the opt-in client-health Sentry report from a doctor JSON document.";
|
|
5558
|
+
}, {
|
|
5559
|
+
readonly flags: "--json-input <file>";
|
|
5560
|
+
readonly description: "Doctor JSON input for the client-health report.";
|
|
5561
|
+
}, {
|
|
5562
|
+
readonly flags: "--client-health-fix-outcome <outcome>";
|
|
5563
|
+
readonly description: "Safe fix outcome label for the client-health report.";
|
|
5555
5564
|
}, {
|
|
5556
5565
|
readonly flags: "--yes";
|
|
5557
5566
|
readonly description: "Skip the interactive --fix confirmation (non-interactive use).";
|
|
@@ -8163,6 +8172,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8163
8172
|
readonly options: readonly [{
|
|
8164
8173
|
readonly flags: "--host <hostname>";
|
|
8165
8174
|
readonly description: "Host key (defaults to this host's hostname)";
|
|
8175
|
+
}, {
|
|
8176
|
+
readonly flags: "--company <slug|uid>";
|
|
8177
|
+
readonly description: "Company scope for the host-account-home flag";
|
|
8166
8178
|
}, {
|
|
8167
8179
|
readonly flags: "--json";
|
|
8168
8180
|
readonly description: "JSON on stdout (always)";
|
|
@@ -8186,6 +8198,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8186
8198
|
readonly options: readonly [{
|
|
8187
8199
|
readonly flags: "--home <path>";
|
|
8188
8200
|
readonly description: "Account home directory";
|
|
8201
|
+
}, {
|
|
8202
|
+
readonly flags: "--company <slug|uid>";
|
|
8203
|
+
readonly description: "Company scope for the host-account-home flag";
|
|
8189
8204
|
}, {
|
|
8190
8205
|
readonly flags: "--device-auth";
|
|
8191
8206
|
readonly description: "Use Codex device authentication instead of localhost callback";
|
|
@@ -7163,6 +7163,18 @@ export const COMMAND_CATALOG = [
|
|
|
7163
7163
|
"flags": "--client-health-report",
|
|
7164
7164
|
"description": "Resolve the opt-in hq-flags gate used by the client-health report hook."
|
|
7165
7165
|
},
|
|
7166
|
+
{
|
|
7167
|
+
"flags": "--client-health-sentry",
|
|
7168
|
+
"description": "Send the opt-in client-health Sentry report from a doctor JSON document."
|
|
7169
|
+
},
|
|
7170
|
+
{
|
|
7171
|
+
"flags": "--json-input <file>",
|
|
7172
|
+
"description": "Doctor JSON input for the client-health report."
|
|
7173
|
+
},
|
|
7174
|
+
{
|
|
7175
|
+
"flags": "--client-health-fix-outcome <outcome>",
|
|
7176
|
+
"description": "Safe fix outcome label for the client-health report."
|
|
7177
|
+
},
|
|
7166
7178
|
{
|
|
7167
7179
|
"flags": "--yes",
|
|
7168
7180
|
"description": "Skip the interactive --fix confirmation (non-interactive use)."
|
|
@@ -10559,6 +10571,10 @@ export const COMMAND_CATALOG = [
|
|
|
10559
10571
|
"flags": "--host <hostname>",
|
|
10560
10572
|
"description": "Host key (defaults to this host's hostname)"
|
|
10561
10573
|
},
|
|
10574
|
+
{
|
|
10575
|
+
"flags": "--company <slug|uid>",
|
|
10576
|
+
"description": "Company scope for the host-account-home flag"
|
|
10577
|
+
},
|
|
10562
10578
|
{
|
|
10563
10579
|
"flags": "--json",
|
|
10564
10580
|
"description": "JSON on stdout (always)"
|
|
@@ -10589,6 +10605,10 @@ export const COMMAND_CATALOG = [
|
|
|
10589
10605
|
"flags": "--home <path>",
|
|
10590
10606
|
"description": "Account home directory"
|
|
10591
10607
|
},
|
|
10608
|
+
{
|
|
10609
|
+
"flags": "--company <slug|uid>",
|
|
10610
|
+
"description": "Company scope for the host-account-home flag"
|
|
10611
|
+
},
|
|
10592
10612
|
{
|
|
10593
10613
|
"flags": "--device-auth",
|
|
10594
10614
|
"description": "Use Codex device authentication instead of localhost callback"
|
package/dist/commands/doctor.js
CHANGED
|
@@ -33,6 +33,9 @@ import { PARITY_FAMILY_ID, PARITY_FAMILY_TITLE, runParityReplay, } from "../lib/
|
|
|
33
33
|
import { applyFixes } from "../lib/doctor/fix/apply.js";
|
|
34
34
|
import { renderDoctorFixJson } from "../lib/doctor/fix/json-output.js";
|
|
35
35
|
import { clientHealthReasonCodesEnabled as isActiveCompanyClientHealthReasonCodesEnabled, clientHealthReasonCodesEnabledForCompany, } from "../lib/doctor/client-health-reason-codes-flag.js";
|
|
36
|
+
import { CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES, CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE, handleClientHealthSentryReport, } from "../lib/doctor/client-health-sentry.js";
|
|
37
|
+
import { clientHealthSentryDirectEnabled, clientHealthSentryDirectEnabledForCompany, } from "../lib/doctor/client-health-sentry-flag.js";
|
|
38
|
+
import { reportClientHealthEvents } from "../lib/core-utils/sentry-report.js";
|
|
36
39
|
import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
|
|
37
40
|
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
38
41
|
import * as readline from "node:readline";
|
|
@@ -241,6 +244,26 @@ function createClientHealthReasonCodeCompanyResolver(hqRoot) {
|
|
|
241
244
|
});
|
|
242
245
|
};
|
|
243
246
|
}
|
|
247
|
+
function createClientHealthSentryCompanyResolver(hqRoot) {
|
|
248
|
+
const companies = readCompaniesManifestMap(hqRoot);
|
|
249
|
+
return async (companySlug) => {
|
|
250
|
+
if (companySlug === null)
|
|
251
|
+
return clientHealthSentryDirectEnabled();
|
|
252
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(companySlug))
|
|
253
|
+
return false;
|
|
254
|
+
const manifestUid = companies?.[companySlug]?.cloud_uid;
|
|
255
|
+
const activeSlug = process.env.HQ_COMPANY_SLUG?.trim() || "";
|
|
256
|
+
const activeUid = process.env.HQ_COMPANY_UID?.trim() || "";
|
|
257
|
+
const companyUid = typeof manifestUid === "string" && manifestUid.trim()
|
|
258
|
+
? manifestUid.trim()
|
|
259
|
+
: companySlug === activeSlug
|
|
260
|
+
? activeUid
|
|
261
|
+
: "";
|
|
262
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid))
|
|
263
|
+
return false;
|
|
264
|
+
return clientHealthSentryDirectEnabledForCompany({ companyUid, companySlug });
|
|
265
|
+
};
|
|
266
|
+
}
|
|
244
267
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
245
268
|
export function registerDoctorCommand(program) {
|
|
246
269
|
program
|
|
@@ -256,6 +279,9 @@ export function registerDoctorCommand(program) {
|
|
|
256
279
|
.option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
|
|
257
280
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
258
281
|
.addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
|
|
282
|
+
.addOption(new Option("--client-health-sentry", "Send the opt-in client-health Sentry report from a doctor JSON document.").hideHelp())
|
|
283
|
+
.addOption(new Option("--json-input <file>", "Doctor JSON input for the client-health report.").hideHelp())
|
|
284
|
+
.addOption(new Option("--client-health-fix-outcome <outcome>", "Safe fix outcome label for the client-health report.").hideHelp())
|
|
259
285
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
260
286
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
261
287
|
.action(async (opts) => {
|
|
@@ -272,6 +298,54 @@ export function registerDoctorCommand(program) {
|
|
|
272
298
|
process.exitCode = 1;
|
|
273
299
|
return;
|
|
274
300
|
}
|
|
301
|
+
if (opts.clientHealthSentry === true) {
|
|
302
|
+
const hqRoot = resolveHqRoot();
|
|
303
|
+
if (!hqRoot || !opts.jsonInput) {
|
|
304
|
+
process.stderr.write("hq doctor --client-health-sentry requires an HQ root and --json-input.\n");
|
|
305
|
+
process.exitCode = 1;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const workspacePath = path.join(hqRoot, "workspace");
|
|
310
|
+
const workspaceStat = fs.lstatSync(workspacePath);
|
|
311
|
+
if (!workspaceStat.isDirectory() || workspaceStat.isSymbolicLink()) {
|
|
312
|
+
throw new Error("workspace is not a local directory");
|
|
313
|
+
}
|
|
314
|
+
const statePath = path.join(hqRoot, "workspace", ".hq-client-health");
|
|
315
|
+
const stateStat = fs.lstatSync(statePath);
|
|
316
|
+
if (!stateStat.isDirectory() || stateStat.isSymbolicLink()) {
|
|
317
|
+
throw new Error("client-health state is not a local directory");
|
|
318
|
+
}
|
|
319
|
+
const stateRoot = fs.realpathSync(statePath);
|
|
320
|
+
const inputPath = fs.realpathSync(opts.jsonInput);
|
|
321
|
+
const relativeInput = path.relative(stateRoot, inputPath);
|
|
322
|
+
if (relativeInput === ".." || relativeInput.startsWith(`..${path.sep}`) || path.isAbsolute(relativeInput)) {
|
|
323
|
+
throw new Error("input path is outside client-health state");
|
|
324
|
+
}
|
|
325
|
+
if (fs.statSync(inputPath).size > CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES) {
|
|
326
|
+
throw new Error("input exceeds client-health size limit");
|
|
327
|
+
}
|
|
328
|
+
const result = await handleClientHealthSentryReport({
|
|
329
|
+
hqRoot,
|
|
330
|
+
doctorJson: fs.readFileSync(inputPath, "utf8"),
|
|
331
|
+
fixOutcome: opts.clientHealthFixOutcome ?? "not_auto_fixable",
|
|
332
|
+
}, {
|
|
333
|
+
resolveFlagForCompany: createClientHealthSentryCompanyResolver(hqRoot),
|
|
334
|
+
sendEvents: async (events) => (await reportClientHealthEvents(events)).code === 0,
|
|
335
|
+
});
|
|
336
|
+
process.exitCode =
|
|
337
|
+
result === "not_handled"
|
|
338
|
+
? CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE
|
|
339
|
+
: result === "send_failed"
|
|
340
|
+
? 1
|
|
341
|
+
: 0;
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
process.stderr.write("hq doctor --client-health-sentry could not handle the report.\n");
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
275
349
|
// `--fix` is the only write path. It resolves the tree, applies the
|
|
276
350
|
// allowlisted repairs behind a backup + confirmation, and returns its own
|
|
277
351
|
// exit code; the read-only report below never runs in this branch.
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface LanesCommandDeps {
|
|
|
35
35
|
now?: () => Date;
|
|
36
36
|
hostname?: () => string;
|
|
37
37
|
resolveCompany?: (company?: string) => Promise<LaneCompanyRef>;
|
|
38
|
+
/** Membership-backed explicit company selection for account-home writers. */
|
|
39
|
+
resolveActiveCompany?: (company: string) => Promise<LaneCompanyRef | null>;
|
|
38
40
|
listOutpostIds?: () => Promise<string[]>;
|
|
39
41
|
readOutpostIdentity?: () => OutpostIdentity;
|
|
40
42
|
spawnLogin?: (opts: {
|
package/dist/commands/lanes.js
CHANGED
|
@@ -30,7 +30,9 @@ import { BALANCE_MOVABLE_STATES, CLAUDE_ACCOUNT_BALANCE_FLAG, accountLoadsForRep
|
|
|
30
30
|
import { parseUserYaml } from "../utils/user-yaml-error.js";
|
|
31
31
|
import { backfillOutpostIdentityMarker as backfillOutpostIdentityMarkerImpl, createOutpostIdentityMarkerIfMissing, validateOutpostIdentityId, } from "../lib/lanes/outpost-identity.js";
|
|
32
32
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
33
|
-
import {
|
|
33
|
+
import { resolveActiveMembershipCompany } from "../lib/mesh/api.js";
|
|
34
|
+
import { openMeshTransport, requireToken } from "../lib/mesh/client.js";
|
|
35
|
+
import { CompanySelectionError } from "../utils/company-selection-error.js";
|
|
34
36
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
|
|
35
37
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
36
38
|
import { effectiveAccountHome } from "../lib/lanes/account-home.js";
|
|
@@ -88,11 +90,18 @@ function defaultLaneOutpostQuestionVisibilityEnabled(company, env) {
|
|
|
88
90
|
function defaultLaneHostScopedAccountHomesEnabled(company, env) {
|
|
89
91
|
return readLanesRegistryFlag(LANE_HOST_SCOPED_ACCOUNT_HOMES_FLAG, { company, env });
|
|
90
92
|
}
|
|
91
|
-
async function hostScopedAccountHomesEnabled(ctx) {
|
|
93
|
+
async function hostScopedAccountHomesEnabled(ctx, company) {
|
|
92
94
|
const localOverride = readLocalOverride(LANE_HOST_SCOPED_ACCOUNT_HOMES_FLAG, ctx.env);
|
|
93
95
|
if (localOverride !== null)
|
|
94
96
|
return localOverride;
|
|
95
|
-
return ctx.laneHostScopedAccountHomesEnabled(await ctx.resolveCompany());
|
|
97
|
+
return ctx.laneHostScopedAccountHomesEnabled(company ?? await ctx.resolveCompany());
|
|
98
|
+
}
|
|
99
|
+
async function resolveSelectedAccountHomeCompany(ctx, selector) {
|
|
100
|
+
const company = await ctx.resolveActiveCompany(selector);
|
|
101
|
+
if (!company) {
|
|
102
|
+
throw new CompanySelectionError(`No unique active company membership for ${selector}. Pass an active company UID with --company.`);
|
|
103
|
+
}
|
|
104
|
+
return company;
|
|
96
105
|
}
|
|
97
106
|
function withoutProviderHome(value) {
|
|
98
107
|
if (Array.isArray(value))
|
|
@@ -158,6 +167,12 @@ async function defaultResolveCompany(company) {
|
|
|
158
167
|
const { company: mesh } = await openMeshTransport({ company });
|
|
159
168
|
return { slug: mesh.companySlug, uid: mesh.companyUid };
|
|
160
169
|
}
|
|
170
|
+
async function defaultResolveActiveCompany(company) {
|
|
171
|
+
const membership = await resolveActiveMembershipCompany(await requireToken(), company);
|
|
172
|
+
return membership
|
|
173
|
+
? { slug: membership.companySlug, uid: membership.companyUid }
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
161
176
|
function defaultOutpostsClient() {
|
|
162
177
|
return createOutpostsClient({
|
|
163
178
|
transport: vaultApiFetch,
|
|
@@ -243,6 +258,7 @@ function resolveDeps(deps = {}) {
|
|
|
243
258
|
now: deps.now ?? (() => new Date()),
|
|
244
259
|
hostname: deps.hostname ?? (() => os.hostname()),
|
|
245
260
|
resolveCompany: deps.resolveCompany ?? defaultResolveCompany,
|
|
261
|
+
resolveActiveCompany: deps.resolveActiveCompany ?? defaultResolveActiveCompany,
|
|
246
262
|
listOutpostIds: deps.listOutpostIds ?? defaultListOutpostIds,
|
|
247
263
|
readOutpostIdentity: deps.readOutpostIdentity ?? (() => readOutpostIdentity()),
|
|
248
264
|
spawnLogin: deps.spawnLogin ?? defaultSpawnLogin,
|
|
@@ -7922,8 +7938,11 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
|
7922
7938
|
if (opts.home !== undefined && !requestedHome) {
|
|
7923
7939
|
throw new LanesError("invalid_account_home", "--home must be a non-empty path", { provider, account: id });
|
|
7924
7940
|
}
|
|
7941
|
+
const company = opts.company !== undefined
|
|
7942
|
+
? await resolveSelectedAccountHomeCompany(ctx, opts.company)
|
|
7943
|
+
: undefined;
|
|
7925
7944
|
const hostScopedWriterEnabled = registeredAccount !== undefined && requestedHome !== undefined
|
|
7926
|
-
? await hostScopedAccountHomesEnabled(ctx)
|
|
7945
|
+
? await hostScopedAccountHomesEnabled(ctx, company)
|
|
7927
7946
|
: false;
|
|
7928
7947
|
const homeRaw = requestedHome ??
|
|
7929
7948
|
(registeredAccount !== undefined
|
|
@@ -8017,7 +8036,10 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
|
|
|
8017
8036
|
printJson({ ok: true, action: "accounts.login", account: status });
|
|
8018
8037
|
}
|
|
8019
8038
|
async function runAccountsSetHome(ctx, providerRaw, accountIdRaw, homeRaw, opts) {
|
|
8020
|
-
|
|
8039
|
+
const company = opts.company !== undefined
|
|
8040
|
+
? await resolveSelectedAccountHomeCompany(ctx, opts.company)
|
|
8041
|
+
: undefined;
|
|
8042
|
+
if (!(await hostScopedAccountHomesEnabled(ctx, company))) {
|
|
8021
8043
|
throw new LanesError("host_scoped_account_homes_disabled", "Setting a host-specific account home requires the lanes.host-scoped-account-homes flag", { flag: LANE_HOST_SCOPED_ACCOUNT_HOMES_FLAG });
|
|
8022
8044
|
}
|
|
8023
8045
|
const callerCwd = ctx.callerCwd();
|
|
@@ -8689,6 +8711,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
8689
8711
|
.argument("<account-id>", "Existing account id")
|
|
8690
8712
|
.argument("<path>", "Home directory for this host")
|
|
8691
8713
|
.option("--host <hostname>", "Host key (defaults to this host's hostname)")
|
|
8714
|
+
.option("--company <slug|uid>", "Company scope for the host-account-home flag")
|
|
8692
8715
|
.option("--json", "JSON on stdout (always)")
|
|
8693
8716
|
.action(wrap(async (provider, accountId, home, opts) => {
|
|
8694
8717
|
await runAccountsSetHome(ctx, provider, accountId, home, opts);
|
|
@@ -8699,6 +8722,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
8699
8722
|
.argument("<provider>", "claude | codex | grok")
|
|
8700
8723
|
.argument("<account-id>", "Open-ended account id")
|
|
8701
8724
|
.option("--home <path>", "Account home directory")
|
|
8725
|
+
.option("--company <slug|uid>", "Company scope for the host-account-home flag")
|
|
8702
8726
|
.option("--device-auth", "Use Codex device authentication instead of localhost callback")
|
|
8703
8727
|
.action(wrap(async (provider, accountId, opts) => {
|
|
8704
8728
|
await runAccountsLogin(ctx, provider, accountId, opts);
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as Sentry from "@sentry/node";
|
|
19
19
|
import type { CaptureContext, SeverityLevel } from "@sentry/node";
|
|
20
|
+
import type { ClientHealthSentryEvent } from "../doctor/client-health-sentry.js";
|
|
20
21
|
export declare const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
|
|
21
22
|
export declare const MAX_SENTRY_REPORT_TIMEOUT_MS = 5000;
|
|
22
23
|
export declare const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2048;
|
|
@@ -95,6 +96,17 @@ export type SentryReportResult = {
|
|
|
95
96
|
event?: SentryReportEvent;
|
|
96
97
|
droppedMetadataKeys?: number;
|
|
97
98
|
};
|
|
99
|
+
export interface ClientHealthSentryScope {
|
|
100
|
+
clear(): void;
|
|
101
|
+
setLevel(level: SeverityLevel): void;
|
|
102
|
+
setFingerprint(fingerprint: string[]): void;
|
|
103
|
+
setTags(tags: Record<string, string>): void;
|
|
104
|
+
setUser(user: {
|
|
105
|
+
id: string;
|
|
106
|
+
ip_address: null;
|
|
107
|
+
}): void;
|
|
108
|
+
}
|
|
109
|
+
export type ClientHealthSentryScopeRunner = (callback: (scope: ClientHealthSentryScope) => void) => void;
|
|
98
110
|
/**
|
|
99
111
|
* Read no more than the accepted event size. Returning early keeps a malformed
|
|
100
112
|
* pipe from making a watchdog retain an unbounded hook payload in memory.
|
|
@@ -117,4 +129,11 @@ export declare function parseSentryReportEvent(stdin: string): SentryReportResul
|
|
|
117
129
|
* transport errors cannot echo event data back to a hook.
|
|
118
130
|
*/
|
|
119
131
|
export declare function reportSentryEvent(stdin: string, options?: SentryReportOptions, dependencies?: SentryReportDependencies): Promise<SentryReportResult>;
|
|
132
|
+
/** Send the client-health projection with a clean Sentry scope and no metadata payload. */
|
|
133
|
+
export declare function reportClientHealthEvents(events: readonly ClientHealthSentryEvent[], options?: {
|
|
134
|
+
timeoutMs?: number;
|
|
135
|
+
scopeRunner?: ClientHealthSentryScopeRunner;
|
|
136
|
+
}, dependencies?: SentryReportDependencies): Promise<{
|
|
137
|
+
code: 0 | 1;
|
|
138
|
+
}>;
|
|
120
139
|
//# sourceMappingURL=sentry-report.d.ts.map
|
|
@@ -1033,4 +1033,65 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
1033
1033
|
dependencies.stderr.write("Sentry report failed.\n");
|
|
1034
1034
|
return invalidInput();
|
|
1035
1035
|
}
|
|
1036
|
+
function isSafeClientHealthEvent(event) {
|
|
1037
|
+
const tags = event.tags;
|
|
1038
|
+
const checkClass = tags.check_class;
|
|
1039
|
+
const reasonCode = tags.reason_code;
|
|
1040
|
+
const classPattern = /^sync\.(?:update\.core|journal\.(?:personal|personal_vault|company)|manifest\.(?:personal|personal_vault|company))$|^other$/;
|
|
1041
|
+
const reasonPattern = /^(?:none|core-version-unavailable|core-update-available|no-local-tree|never-synced|invalid-last-sync|stale-threshold|manifest-unavailable|manifest-state-unavailable|no-manifest-scopes|never-uploaded|invalid-upload-time|unresolved-company-uid)$/;
|
|
1042
|
+
const versionPattern = /^(?:unknown|\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?)$/;
|
|
1043
|
+
const safeTagKeys = [
|
|
1044
|
+
"check_class", "reason_code", "fix_outcome", "status", "failing_check_count",
|
|
1045
|
+
"cli_version", "core_version", "sync_version", "platform", "arch",
|
|
1046
|
+
];
|
|
1047
|
+
return (event.level === "warning" &&
|
|
1048
|
+
classPattern.test(checkClass) &&
|
|
1049
|
+
reasonPattern.test(reasonCode) &&
|
|
1050
|
+
event.message === `Client health: ${checkClass} unresolved after hq doctor --fix` &&
|
|
1051
|
+
event.fingerprint.length === 3 &&
|
|
1052
|
+
event.fingerprint[0] === "client-health" &&
|
|
1053
|
+
event.fingerprint[1] === checkClass &&
|
|
1054
|
+
event.fingerprint[2] === reasonCode &&
|
|
1055
|
+
(tags.fix_outcome === "not_auto_fixable" || tags.fix_outcome === "fix_failed" || tags.fix_outcome === "fix_timed_out") &&
|
|
1056
|
+
(tags.status === "FAIL" || tags.status === "WARN") &&
|
|
1057
|
+
/^\d{1,3}$/.test(tags.failing_check_count) &&
|
|
1058
|
+
safeTagKeys.every((key) => Object.hasOwn(tags, key)) &&
|
|
1059
|
+
Object.keys(tags).length === safeTagKeys.length &&
|
|
1060
|
+
[tags.cli_version, tags.core_version, tags.sync_version].every((value) => versionPattern.test(value)) &&
|
|
1061
|
+
/^[a-z0-9]+$/.test(tags.platform) &&
|
|
1062
|
+
/^[a-z0-9]+$/.test(tags.arch) &&
|
|
1063
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(event.user.id) &&
|
|
1064
|
+
event.user.ip_address === null);
|
|
1065
|
+
}
|
|
1066
|
+
/** Send the client-health projection with a clean Sentry scope and no metadata payload. */
|
|
1067
|
+
export async function reportClientHealthEvents(events, options = {}, dependencies = defaultDependencies) {
|
|
1068
|
+
const timeoutMs = resolveTimeout(options.timeoutMs);
|
|
1069
|
+
if (timeoutMs === null ||
|
|
1070
|
+
events.length === 0 ||
|
|
1071
|
+
events.length > 250 ||
|
|
1072
|
+
events.some((event) => !isSafeClientHealthEvent(event))) {
|
|
1073
|
+
dependencies.stderr.write("Invalid client health Sentry event.\n");
|
|
1074
|
+
return { code: 1 };
|
|
1075
|
+
}
|
|
1076
|
+
const withScope = options.scopeRunner ?? ((callback) => Sentry.withScope((scope) => callback(scope)));
|
|
1077
|
+
try {
|
|
1078
|
+
for (const event of events) {
|
|
1079
|
+
withScope((scope) => {
|
|
1080
|
+
scope.clear();
|
|
1081
|
+
scope.setLevel(event.level);
|
|
1082
|
+
scope.setFingerprint([...event.fingerprint]);
|
|
1083
|
+
scope.setTags({ sentry_event_type: "client_health", ...event.tags });
|
|
1084
|
+
scope.setUser({ id: event.user.id, ip_address: null });
|
|
1085
|
+
dependencies.sentry.captureMessage(event.message);
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
1089
|
+
return { code: 0 };
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
// Keep the event and transport details out of hook stderr.
|
|
1093
|
+
}
|
|
1094
|
+
dependencies.stderr.write("Client health Sentry report failed.\n");
|
|
1095
|
+
return { code: 1 };
|
|
1096
|
+
}
|
|
1036
1097
|
//# sourceMappingURL=sentry-report.js.map
|
|
@@ -31,8 +31,21 @@ export interface HqCloudRunners {
|
|
|
31
31
|
sessionHost: string | null;
|
|
32
32
|
}
|
|
33
33
|
export declare function hqCloudRunners(root: string, exists?: (p: string) => boolean): HqCloudRunners;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Ask npm for every release of the bundled major version, no older than the
|
|
36
|
+
* bundled copy. The updater installs the newest one exactly: `npm install` with
|
|
37
|
+
* a range keeps an installed copy that already satisfies it, so a range alone
|
|
38
|
+
* never moves 6.18.1 to 6.18.2.
|
|
39
|
+
*/
|
|
40
|
+
export declare function hqCloudLatestCommand(bundledVersion: string, platform?: NodeJS.Platform): {
|
|
41
|
+
file: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
shell: boolean;
|
|
44
|
+
};
|
|
45
|
+
/** The newest release in `npm view --json` output with the bundled major version, never older than it. */
|
|
46
|
+
export declare function latestSameMajor(npmViewJson: string, bundledVersion: string): string | null;
|
|
47
|
+
/** Install one exact hq-cloud version into the updater's folder. */
|
|
48
|
+
export declare function hqCloudInstallCommand(prefix: string, version: string, platform?: NodeJS.Platform): {
|
|
36
49
|
file: string;
|
|
37
50
|
args: string[];
|
|
38
51
|
shell: boolean;
|
|
@@ -48,6 +61,17 @@ export interface HqCloudUpdateOutcome {
|
|
|
48
61
|
to: string | null;
|
|
49
62
|
detail?: string;
|
|
50
63
|
}
|
|
64
|
+
export interface NpmRunResult {
|
|
65
|
+
status: number | null;
|
|
66
|
+
stdout?: string | null;
|
|
67
|
+
stderr?: string | null;
|
|
68
|
+
error?: Error;
|
|
69
|
+
}
|
|
70
|
+
export interface UpdateHqCloudDeps {
|
|
71
|
+
run?: (file: string, args: string[], shell: boolean, env: NodeJS.ProcessEnv) => NpmRunResult;
|
|
72
|
+
bundled?: HqCloudCopy | null;
|
|
73
|
+
platform?: NodeJS.Platform;
|
|
74
|
+
}
|
|
51
75
|
/** Install or update the updater's copy, as this (the login) user. */
|
|
52
|
-
export declare function updateManagedHqCloud(daemonRoot: string, env: NodeJS.ProcessEnv): HqCloudUpdateOutcome;
|
|
76
|
+
export declare function updateManagedHqCloud(daemonRoot: string, env: NodeJS.ProcessEnv, deps?: UpdateHqCloudDeps): HqCloudUpdateOutcome;
|
|
53
77
|
//# sourceMappingURL=hq-cloud.d.ts.map
|
|
@@ -61,18 +61,45 @@ export function hqCloudRunners(root, exists = fs.existsSync) {
|
|
|
61
61
|
sessionHost: bin("outpost-session-host.js"),
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Ask npm for every release of the bundled major version, no older than the
|
|
66
|
+
* bundled copy. The updater installs the newest one exactly: `npm install` with
|
|
67
|
+
* a range keeps an installed copy that already satisfies it, so a range alone
|
|
68
|
+
* never moves 6.18.1 to 6.18.2.
|
|
69
|
+
*/
|
|
70
|
+
export function hqCloudLatestCommand(bundledVersion, platform = process.platform) {
|
|
71
|
+
const plan = buildSpawnPlan("npm", ["view", `${HQ_CLOUD_PACKAGE}@^${bundledVersion}`, "version", "--json"], platform);
|
|
72
|
+
return { file: plan.cmd, args: plan.args, shell: plan.shell };
|
|
73
|
+
}
|
|
74
|
+
/** The newest release in `npm view --json` output with the bundled major version, never older than it. */
|
|
75
|
+
export function latestSameMajor(npmViewJson, bundledVersion) {
|
|
76
|
+
const b = parseVersion(bundledVersion);
|
|
77
|
+
if (!b)
|
|
78
|
+
return null;
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(npmViewJson);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const versions = typeof parsed === "string" ? [parsed] : Array.isArray(parsed) ? parsed : [];
|
|
87
|
+
let best = null;
|
|
88
|
+
for (const text of versions) {
|
|
89
|
+
if (typeof text !== "string" || !/^\d+\.\d+\.\d+$/.test(text))
|
|
90
|
+
continue;
|
|
91
|
+
const v = parseVersion(text);
|
|
92
|
+
if (v[0] !== b[0] || newer(b, v))
|
|
93
|
+
continue;
|
|
94
|
+
if (!best || newer(v, best.v))
|
|
95
|
+
best = { text, v };
|
|
96
|
+
}
|
|
97
|
+
return best?.text ?? null;
|
|
98
|
+
}
|
|
99
|
+
/** Install one exact hq-cloud version into the updater's folder. */
|
|
100
|
+
export function hqCloudInstallCommand(prefix, version, platform = process.platform) {
|
|
66
101
|
// npm is a .cmd on Windows and needs the shell, so every argument is quoted for it.
|
|
67
|
-
const plan = buildSpawnPlan("npm", [
|
|
68
|
-
"install",
|
|
69
|
-
"--prefix",
|
|
70
|
-
prefix,
|
|
71
|
-
"--no-audit",
|
|
72
|
-
"--no-fund",
|
|
73
|
-
"--omit=dev",
|
|
74
|
-
`${HQ_CLOUD_PACKAGE}@${pinnedVersion ?? `^${bundledVersion}`}`,
|
|
75
|
-
], platform);
|
|
102
|
+
const plan = buildSpawnPlan("npm", ["install", "--prefix", prefix, "--no-audit", "--no-fund", "--omit=dev", `${HQ_CLOUD_PACKAGE}@${version}`], platform);
|
|
76
103
|
return { file: plan.cmd, args: plan.args, shell: plan.shell };
|
|
77
104
|
}
|
|
78
105
|
export function readHqCloudCopy(root) {
|
|
@@ -99,10 +126,26 @@ export function currentHqCloud(daemonRoot) {
|
|
|
99
126
|
const pinnedVersion = readDaemonConfig(path.join(daemonRoot, "config.json")).hqCloudVersion;
|
|
100
127
|
return pickHqCloud(bundledHqCloud(), readHqCloudCopy(managedHqCloudRoot(daemonRoot)), (copy) => hqCloudRunners(copy.root).syncRunner !== null, pinnedVersion);
|
|
101
128
|
}
|
|
129
|
+
function runNpm(file, args, shell, env) {
|
|
130
|
+
return spawnSync(file, args, {
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
env,
|
|
133
|
+
timeout: 10 * 60_000,
|
|
134
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
shell,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function npmFailure(r) {
|
|
140
|
+
const tail = `${r.stderr ?? ""}\n${r.stdout ?? ""}`.trim().split("\n").slice(-3).join(" ").slice(0, 400);
|
|
141
|
+
return tail || (r.error ? r.error.message : `npm exited ${r.status ?? "by signal"}`);
|
|
142
|
+
}
|
|
102
143
|
/** Install or update the updater's copy, as this (the login) user. */
|
|
103
|
-
export function updateManagedHqCloud(daemonRoot, env) {
|
|
144
|
+
export function updateManagedHqCloud(daemonRoot, env, deps = {}) {
|
|
145
|
+
const run = deps.run ?? runNpm;
|
|
146
|
+
const platform = deps.platform ?? process.platform;
|
|
104
147
|
const pinnedVersion = readDaemonConfig(path.join(daemonRoot, "config.json")).hqCloudVersion;
|
|
105
|
-
const bundled = bundledHqCloud();
|
|
148
|
+
const bundled = deps.bundled !== undefined ? deps.bundled : bundledHqCloud();
|
|
106
149
|
const from = currentHqCloud(daemonRoot)?.version ?? null;
|
|
107
150
|
if (!bundled)
|
|
108
151
|
return { ok: false, from, to: null, detail: "this CLI install has no bundled hq-cloud to match" };
|
|
@@ -113,19 +156,22 @@ export function updateManagedHqCloud(daemonRoot, env) {
|
|
|
113
156
|
catch (err) {
|
|
114
157
|
return { ok: false, from, to: null, detail: `could not create ${prefix}: ${err instanceof Error ? err.message : String(err)}` };
|
|
115
158
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
env
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return { ok: false, from, to: null, detail: tail || (r.error ? r.error.message : `npm exited ${r.status ?? "by signal"}`) };
|
|
159
|
+
let version = pinnedVersion;
|
|
160
|
+
if (!version) {
|
|
161
|
+
const view = hqCloudLatestCommand(bundled.version, platform);
|
|
162
|
+
const r = run(view.file, view.args, view.shell, env);
|
|
163
|
+
if (r.status !== 0)
|
|
164
|
+
return { ok: false, from, to: null, detail: `could not look up the newest hq-cloud: ${npmFailure(r)}` };
|
|
165
|
+
const latest = latestSameMajor(r.stdout ?? "", bundled.version);
|
|
166
|
+
if (!latest) {
|
|
167
|
+
return { ok: false, from, to: null, detail: `npm listed no hq-cloud release at or above ${bundled.version} in the same major version` };
|
|
168
|
+
}
|
|
169
|
+
version = latest;
|
|
128
170
|
}
|
|
171
|
+
const cmd = hqCloudInstallCommand(prefix, version, platform);
|
|
172
|
+
const r = run(cmd.file, cmd.args, cmd.shell, env);
|
|
173
|
+
if (r.status !== 0)
|
|
174
|
+
return { ok: false, from, to: null, detail: npmFailure(r) };
|
|
129
175
|
return { ok: true, from, to: currentHqCloud(daemonRoot)?.version ?? null };
|
|
130
176
|
}
|
|
131
177
|
//# sourceMappingURL=hq-cloud.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
export declare const CLIENT_HEALTH_SENTRY_DIRECT_FLAG = "client-health.sentry-direct";
|
|
3
|
+
export declare const CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK = false;
|
|
4
|
+
export declare const CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS = 150;
|
|
5
|
+
type ClientHealthFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
|
|
6
|
+
export interface ClientHealthSentryFlagDependencies {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
createClient?: (options: FlagClientOptions) => ClientHealthFlagClient;
|
|
9
|
+
getToken?: FlagClientOptions["getToken"];
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
reportFailure?: (error: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface ClientHealthSentryCompany {
|
|
14
|
+
companyUid: string;
|
|
15
|
+
companySlug?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Resolve the active company gate with a bounded hq-flags read. */
|
|
18
|
+
export declare function clientHealthSentryDirectEnabled(dependencies?: ClientHealthSentryFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
19
|
+
/** Resolve the default-off gate for one explicitly identified company. */
|
|
20
|
+
export declare function clientHealthSentryDirectEnabledForCompany(company: ClientHealthSentryCompany, dependencies?: ClientHealthSentryFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=client-health-sentry-flag.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
2
|
+
import { createFlagClient, } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
4
|
+
export const CLIENT_HEALTH_SENTRY_DIRECT_FLAG = "client-health.sentry-direct";
|
|
5
|
+
export const CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK = false;
|
|
6
|
+
export const CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS = 150;
|
|
7
|
+
function reportFlagFailure(error) {
|
|
8
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
9
|
+
const safeName = /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
|
|
10
|
+
process.stderr.write(`HQ client-health Sentry flag lookup failed (${safeName}); using default off.\n`);
|
|
11
|
+
}
|
|
12
|
+
function combineSignals(first, second) {
|
|
13
|
+
return first ? AbortSignal.any([first, second]) : second;
|
|
14
|
+
}
|
|
15
|
+
/** Resolve the active company gate with a bounded hq-flags read. */
|
|
16
|
+
export async function clientHealthSentryDirectEnabled(dependencies = {}, callerSignal) {
|
|
17
|
+
const env = dependencies.env ?? process.env;
|
|
18
|
+
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
19
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
20
|
+
return CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK;
|
|
21
|
+
}
|
|
22
|
+
return clientHealthSentryDirectEnabledForCompany({
|
|
23
|
+
companyUid,
|
|
24
|
+
companySlug: env.HQ_COMPANY_SLUG?.trim() || undefined,
|
|
25
|
+
}, dependencies, callerSignal);
|
|
26
|
+
}
|
|
27
|
+
/** Resolve the default-off gate for one explicitly identified company. */
|
|
28
|
+
export async function clientHealthSentryDirectEnabledForCompany(company, dependencies = {}, callerSignal) {
|
|
29
|
+
const env = dependencies.env ?? process.env;
|
|
30
|
+
const companyUid = company.companyUid.trim();
|
|
31
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
32
|
+
return CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK;
|
|
33
|
+
}
|
|
34
|
+
const companySlug = company.companySlug?.trim() || "";
|
|
35
|
+
const deadline = AbortSignal.timeout(CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS);
|
|
36
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
|
|
37
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
38
|
+
let client;
|
|
39
|
+
let failed = false;
|
|
40
|
+
let failureReported = false;
|
|
41
|
+
let enabled = CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK;
|
|
42
|
+
const reportFailureOnce = (error) => {
|
|
43
|
+
failed = true;
|
|
44
|
+
if (failureReported)
|
|
45
|
+
return;
|
|
46
|
+
failureReported = true;
|
|
47
|
+
(dependencies.reportFailure ?? reportFlagFailure)(error);
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
client = (dependencies.createClient ?? createFlagClient)({
|
|
51
|
+
endpoint: resolveFlagRegistryEndpoint(undefined, env),
|
|
52
|
+
companyUid,
|
|
53
|
+
...(companySlug && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
|
|
54
|
+
? { companyIdentifiers: [companySlug] }
|
|
55
|
+
: {}),
|
|
56
|
+
env: {},
|
|
57
|
+
getToken: dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? ""),
|
|
58
|
+
fetch: (input, init) => doFetch(input, {
|
|
59
|
+
...init,
|
|
60
|
+
signal: combineSignals(init?.signal ?? undefined, signal),
|
|
61
|
+
}),
|
|
62
|
+
refreshIntervalMs: 0,
|
|
63
|
+
requestTimeoutMs: CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS,
|
|
64
|
+
onError: reportFailureOnce,
|
|
65
|
+
});
|
|
66
|
+
await client.ready();
|
|
67
|
+
if (!failed && !signal.aborted) {
|
|
68
|
+
enabled = client.isEnabled(CLIENT_HEALTH_SENTRY_DIRECT_FLAG, {
|
|
69
|
+
fallback: CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
reportFailureOnce(error);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
try {
|
|
78
|
+
client?.close();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
reportFailureOnce(error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return failed || signal.aborted
|
|
85
|
+
? CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK
|
|
86
|
+
: enabled;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=client-health-sentry-flag.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export declare const CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE = 10;
|
|
2
|
+
export declare const CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES: number;
|
|
3
|
+
export declare const CLIENT_HEALTH_SENTRY_MAX_FINDINGS = 250;
|
|
4
|
+
export declare const CLIENT_HEALTH_INSTALL_ID_FILENAME = "install-id";
|
|
5
|
+
export declare const CLIENT_HEALTH_LAST_RESULT_FILENAME = "last-result.json";
|
|
6
|
+
export type ClientHealthFixOutcome = "not_auto_fixable" | "fix_failed" | "fix_timed_out";
|
|
7
|
+
export type ClientHealthStatus = "FAIL" | "WARN";
|
|
8
|
+
export type ClientHealthCheckClass = "sync.update.core" | "sync.journal.personal" | "sync.journal.personal_vault" | "sync.journal.company" | "sync.manifest.personal" | "sync.manifest.personal_vault" | "sync.manifest.company" | "other";
|
|
9
|
+
export interface ClientHealthSentryEvent {
|
|
10
|
+
message: string;
|
|
11
|
+
level: "warning";
|
|
12
|
+
fingerprint: ["client-health", ClientHealthCheckClass, string];
|
|
13
|
+
tags: {
|
|
14
|
+
check_class: ClientHealthCheckClass;
|
|
15
|
+
reason_code: string;
|
|
16
|
+
fix_outcome: ClientHealthFixOutcome;
|
|
17
|
+
status: ClientHealthStatus;
|
|
18
|
+
failing_check_count: string;
|
|
19
|
+
cli_version: string;
|
|
20
|
+
core_version: string;
|
|
21
|
+
sync_version: string;
|
|
22
|
+
platform: string;
|
|
23
|
+
arch: string;
|
|
24
|
+
};
|
|
25
|
+
user: {
|
|
26
|
+
id: string;
|
|
27
|
+
ip_address: null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
interface ClientHealthFinding {
|
|
31
|
+
checkClass: ClientHealthCheckClass;
|
|
32
|
+
companySlug: string | null;
|
|
33
|
+
reasonCode: string;
|
|
34
|
+
status: ClientHealthStatus;
|
|
35
|
+
}
|
|
36
|
+
export interface ClientHealthLastResult {
|
|
37
|
+
check_class: ClientHealthCheckClass;
|
|
38
|
+
outcome: ClientHealthFixOutcome;
|
|
39
|
+
next_step: string;
|
|
40
|
+
result_id: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ClientHealthReportVersions {
|
|
43
|
+
cli: string | null;
|
|
44
|
+
core: string | null;
|
|
45
|
+
sync: string | null;
|
|
46
|
+
}
|
|
47
|
+
export interface ClientHealthSentryReportDependencies {
|
|
48
|
+
resolveFlagForCompany: (companySlug: string | null) => Promise<boolean>;
|
|
49
|
+
sendEvents: (events: readonly ClientHealthSentryEvent[]) => Promise<boolean>;
|
|
50
|
+
randomUUID?: () => string;
|
|
51
|
+
versions?: (hqRoot: string) => ClientHealthReportVersions;
|
|
52
|
+
platform?: () => string;
|
|
53
|
+
arch?: () => string;
|
|
54
|
+
}
|
|
55
|
+
/** Collapse company-specific doctor ids before any value can enter a Sentry event. */
|
|
56
|
+
export declare function normalizeClientHealthCheckId(checkId: string): ClientHealthCheckClass;
|
|
57
|
+
/** Keep only the doctor fields needed for safe health reporting. */
|
|
58
|
+
export declare function parseClientHealthDoctorFindings(input: string): ClientHealthFinding[];
|
|
59
|
+
/** Store a random UUID once per local HQ install, without using host identity. */
|
|
60
|
+
export declare function getOrCreateClientHealthInstallId(hqRoot: string, randomUUID?: () => string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Report only after every finding's company has opted in. A mixed rollout
|
|
63
|
+
* falls back as one batch so the old path cannot duplicate partially sent rows.
|
|
64
|
+
*/
|
|
65
|
+
export declare function handleClientHealthSentryReport(input: {
|
|
66
|
+
hqRoot: string;
|
|
67
|
+
doctorJson: string;
|
|
68
|
+
fixOutcome: string;
|
|
69
|
+
}, dependencies: ClientHealthSentryReportDependencies): Promise<"handled" | "not_handled" | "send_failed">;
|
|
70
|
+
export {};
|
|
71
|
+
//# sourceMappingURL=client-health-sentry.d.ts.map
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { randomUUID as cryptoRandomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { CLI_VERSION } from "../../cli-version.js";
|
|
6
|
+
import { readSyncVersion } from "../../utils/feedback-versions.js";
|
|
7
|
+
import { readHqVersion } from "../../utils/pack-contributions.js";
|
|
8
|
+
export const CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE = 10;
|
|
9
|
+
export const CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
export const CLIENT_HEALTH_SENTRY_MAX_FINDINGS = 250;
|
|
11
|
+
export const CLIENT_HEALTH_INSTALL_ID_FILENAME = "install-id";
|
|
12
|
+
export const CLIENT_HEALTH_LAST_RESULT_FILENAME = "last-result.json";
|
|
13
|
+
const STATE_DIRECTORY = path.join("workspace", ".hq-client-health");
|
|
14
|
+
const REASON_CODES = new Set([
|
|
15
|
+
"core-version-unavailable",
|
|
16
|
+
"core-update-available",
|
|
17
|
+
"no-local-tree",
|
|
18
|
+
"never-synced",
|
|
19
|
+
"invalid-last-sync",
|
|
20
|
+
"stale-threshold",
|
|
21
|
+
"manifest-unavailable",
|
|
22
|
+
"manifest-state-unavailable",
|
|
23
|
+
"no-manifest-scopes",
|
|
24
|
+
"never-uploaded",
|
|
25
|
+
"invalid-upload-time",
|
|
26
|
+
"unresolved-company-uid",
|
|
27
|
+
]);
|
|
28
|
+
const FIX_OUTCOMES = new Set(["not_auto_fixable", "fix_failed", "fix_timed_out"]);
|
|
29
|
+
const INSTALL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
30
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/;
|
|
31
|
+
const PLATFORMS = new Set([
|
|
32
|
+
"aix", "android", "darwin", "freebsd", "haiku", "linux", "openbsd",
|
|
33
|
+
"sunos", "win32", "cygwin",
|
|
34
|
+
]);
|
|
35
|
+
const ARCHITECTURES = new Set([
|
|
36
|
+
"arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc", "ppc64",
|
|
37
|
+
"riscv64", "s390x", "x64",
|
|
38
|
+
]);
|
|
39
|
+
function isRecord(value) {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
function cleanVersion(value) {
|
|
43
|
+
return value && VERSION.test(value) ? value : "unknown";
|
|
44
|
+
}
|
|
45
|
+
function cleanPlatform(value) {
|
|
46
|
+
return PLATFORMS.has(value) ? value : "unknown";
|
|
47
|
+
}
|
|
48
|
+
function cleanArchitecture(value) {
|
|
49
|
+
return ARCHITECTURES.has(value) ? value : "unknown";
|
|
50
|
+
}
|
|
51
|
+
/** Collapse company-specific doctor ids before any value can enter a Sentry event. */
|
|
52
|
+
export function normalizeClientHealthCheckId(checkId) {
|
|
53
|
+
if (checkId === "sync.update.core")
|
|
54
|
+
return checkId;
|
|
55
|
+
if (checkId === "sync.journal.__hq_personal_vault__")
|
|
56
|
+
return "sync.journal.personal_vault";
|
|
57
|
+
if (checkId === "sync.manifest.__hq_personal_vault__")
|
|
58
|
+
return "sync.manifest.personal_vault";
|
|
59
|
+
const match = /^sync\.(journal|manifest)\.([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/.exec(checkId);
|
|
60
|
+
if (!match)
|
|
61
|
+
return "other";
|
|
62
|
+
const family = match[1];
|
|
63
|
+
const scope = match[2];
|
|
64
|
+
if (scope === "personal")
|
|
65
|
+
return `sync.${family}.personal`;
|
|
66
|
+
if (family === "manifest" && scope.startsWith("unresolved."))
|
|
67
|
+
return "other";
|
|
68
|
+
return `sync.${family}.company`;
|
|
69
|
+
}
|
|
70
|
+
function companySlugForCheck(checkId, checkClass) {
|
|
71
|
+
if (checkClass !== "sync.journal.company" && checkClass !== "sync.manifest.company") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const match = /^sync\.(?:journal|manifest)\.([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/.exec(checkId);
|
|
75
|
+
return match?.[1] ?? null;
|
|
76
|
+
}
|
|
77
|
+
function safeReasonCode(value) {
|
|
78
|
+
return typeof value === "string" && REASON_CODES.has(value) ? value : "none";
|
|
79
|
+
}
|
|
80
|
+
function safeFixOutcome(value) {
|
|
81
|
+
return FIX_OUTCOMES.has(value) ? value : "not_auto_fixable";
|
|
82
|
+
}
|
|
83
|
+
/** Keep only the doctor fields needed for safe health reporting. */
|
|
84
|
+
export function parseClientHealthDoctorFindings(input) {
|
|
85
|
+
if (Buffer.byteLength(input, "utf8") > CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES) {
|
|
86
|
+
throw new Error("Client health doctor input exceeds its size limit");
|
|
87
|
+
}
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(input);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error("Client health doctor input is invalid JSON");
|
|
94
|
+
}
|
|
95
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.results)) {
|
|
96
|
+
throw new Error("Client health doctor input has no results array");
|
|
97
|
+
}
|
|
98
|
+
const findings = [];
|
|
99
|
+
for (const value of parsed.results) {
|
|
100
|
+
if (!isRecord(value) || value.family !== "sync")
|
|
101
|
+
continue;
|
|
102
|
+
if (value.status !== "FAIL" && value.status !== "WARN")
|
|
103
|
+
continue;
|
|
104
|
+
if (value.checkId === "sync.update.core" && value.status === "WARN")
|
|
105
|
+
continue;
|
|
106
|
+
const checkId = typeof value.checkId === "string" &&
|
|
107
|
+
/^sync\.[A-Za-z0-9._-]{1,160}$/.test(value.checkId)
|
|
108
|
+
? value.checkId
|
|
109
|
+
: "unknown";
|
|
110
|
+
const checkClass = normalizeClientHealthCheckId(checkId);
|
|
111
|
+
findings.push({
|
|
112
|
+
checkClass,
|
|
113
|
+
companySlug: companySlugForCheck(checkId, checkClass),
|
|
114
|
+
reasonCode: safeReasonCode(value.reasonCode),
|
|
115
|
+
status: value.status,
|
|
116
|
+
});
|
|
117
|
+
if (findings.length > CLIENT_HEALTH_SENTRY_MAX_FINDINGS) {
|
|
118
|
+
throw new Error("Client health doctor input has too many sync findings");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
function defaultVersions(hqRoot) {
|
|
124
|
+
let core;
|
|
125
|
+
try {
|
|
126
|
+
core = readHqVersion(hqRoot);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
core = null;
|
|
130
|
+
}
|
|
131
|
+
let sync;
|
|
132
|
+
try {
|
|
133
|
+
sync = readSyncVersion();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
sync = null;
|
|
137
|
+
}
|
|
138
|
+
return { cli: CLI_VERSION, core, sync };
|
|
139
|
+
}
|
|
140
|
+
function stateDirectory(hqRoot) {
|
|
141
|
+
const root = fs.realpathSync(hqRoot);
|
|
142
|
+
const workspacePath = path.join(root, "workspace");
|
|
143
|
+
fs.mkdirSync(workspacePath, { recursive: true, mode: 0o700 });
|
|
144
|
+
const workspaceStat = fs.lstatSync(workspacePath);
|
|
145
|
+
if (!workspaceStat.isDirectory() || workspaceStat.isSymbolicLink()) {
|
|
146
|
+
throw new Error("Client health state workspace is not a local directory");
|
|
147
|
+
}
|
|
148
|
+
const directory = path.join(root, STATE_DIRECTORY);
|
|
149
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
150
|
+
const directoryStat = fs.lstatSync(directory);
|
|
151
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
|
|
152
|
+
throw new Error("Client health state directory is not a local directory");
|
|
153
|
+
}
|
|
154
|
+
fs.chmodSync(directory, 0o700);
|
|
155
|
+
return directory;
|
|
156
|
+
}
|
|
157
|
+
function readInstallId(filePath) {
|
|
158
|
+
try {
|
|
159
|
+
const fileStat = fs.lstatSync(filePath);
|
|
160
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
|
|
161
|
+
throw new Error("Client health install id is not a regular local file");
|
|
162
|
+
}
|
|
163
|
+
const value = fs.readFileSync(filePath, "utf8").trim();
|
|
164
|
+
if (!INSTALL_ID.test(value))
|
|
165
|
+
throw new Error("Client health install id is invalid");
|
|
166
|
+
fs.chmodSync(filePath, 0o600);
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (isRecord(error) && error.code === "ENOENT")
|
|
171
|
+
return null;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Store a random UUID once per local HQ install, without using host identity. */
|
|
176
|
+
export function getOrCreateClientHealthInstallId(hqRoot, randomUUID = cryptoRandomUUID) {
|
|
177
|
+
const directory = stateDirectory(hqRoot);
|
|
178
|
+
const filePath = path.join(directory, CLIENT_HEALTH_INSTALL_ID_FILENAME);
|
|
179
|
+
const current = readInstallId(filePath);
|
|
180
|
+
if (current)
|
|
181
|
+
return current;
|
|
182
|
+
const candidate = randomUUID();
|
|
183
|
+
if (!INSTALL_ID.test(candidate))
|
|
184
|
+
throw new Error("Client health random id source returned an invalid id");
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(filePath, `${candidate}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
187
|
+
return candidate;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (!isRecord(error) || error.code !== "EEXIST")
|
|
191
|
+
throw error;
|
|
192
|
+
const raced = readInstallId(filePath);
|
|
193
|
+
if (!raced)
|
|
194
|
+
throw new Error("Client health install id was not created");
|
|
195
|
+
return raced;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function nextStep(checkClass) {
|
|
199
|
+
switch (checkClass) {
|
|
200
|
+
case "sync.update.core":
|
|
201
|
+
return "HQ couldn't update itself automatically. Run `hq update` or `/update-hq`, then restart this session.";
|
|
202
|
+
case "sync.journal.personal":
|
|
203
|
+
case "sync.journal.personal_vault":
|
|
204
|
+
return "HQ hasn't synced your personal files in over 7 days and the automatic fix did not work. Run `hq sync` and check the result. If it fails, run `hq doctor` and send the output to support.";
|
|
205
|
+
case "sync.journal.company":
|
|
206
|
+
return "HQ hasn't synced this company's files in over 7 days and the automatic fix did not work. Run `hq sync` and check the result. If it fails, run `hq doctor` and send the output to support.";
|
|
207
|
+
case "sync.manifest.personal":
|
|
208
|
+
case "sync.manifest.personal_vault":
|
|
209
|
+
return "HQ couldn't read your personal sync list. Run `hq doctor --fix`. If it still fails, run `hq login` again.";
|
|
210
|
+
case "sync.manifest.company":
|
|
211
|
+
return "HQ couldn't read a company sync manifest. Run `hq doctor --fix`. If it still fails, run `hq login` again.";
|
|
212
|
+
default:
|
|
213
|
+
return "HQ still reports a sync health issue after its automatic fix. Run `hq doctor` and send the output to support.";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function writeLastResult(hqRoot, finding, outcome, randomUUID) {
|
|
217
|
+
const directory = stateDirectory(hqRoot);
|
|
218
|
+
const result = {
|
|
219
|
+
check_class: finding.checkClass,
|
|
220
|
+
outcome,
|
|
221
|
+
next_step: nextStep(finding.checkClass),
|
|
222
|
+
result_id: randomUUID(),
|
|
223
|
+
};
|
|
224
|
+
if (!INSTALL_ID.test(result.result_id))
|
|
225
|
+
throw new Error("Client health result id source returned an invalid id");
|
|
226
|
+
const filePath = path.join(directory, CLIENT_HEALTH_LAST_RESULT_FILENAME);
|
|
227
|
+
const tempPath = path.join(directory, `${CLIENT_HEALTH_LAST_RESULT_FILENAME}.${result.result_id}.tmp`);
|
|
228
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(result, null, 2)}\n`, {
|
|
229
|
+
encoding: "utf8",
|
|
230
|
+
flag: "wx",
|
|
231
|
+
mode: 0o600,
|
|
232
|
+
});
|
|
233
|
+
fs.renameSync(tempPath, filePath);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
function buildEvent(finding, installId, outcome, failingCount, versions, platform, arch) {
|
|
237
|
+
const reasonCode = finding.reasonCode;
|
|
238
|
+
return {
|
|
239
|
+
message: `Client health: ${finding.checkClass} unresolved after hq doctor --fix`,
|
|
240
|
+
level: "warning",
|
|
241
|
+
fingerprint: ["client-health", finding.checkClass, reasonCode],
|
|
242
|
+
tags: {
|
|
243
|
+
check_class: finding.checkClass,
|
|
244
|
+
reason_code: reasonCode,
|
|
245
|
+
fix_outcome: outcome,
|
|
246
|
+
status: finding.status,
|
|
247
|
+
failing_check_count: String(failingCount),
|
|
248
|
+
cli_version: cleanVersion(versions.cli),
|
|
249
|
+
core_version: cleanVersion(versions.core),
|
|
250
|
+
sync_version: cleanVersion(versions.sync),
|
|
251
|
+
platform: cleanPlatform(platform),
|
|
252
|
+
arch: cleanArchitecture(arch),
|
|
253
|
+
},
|
|
254
|
+
user: { id: installId, ip_address: null },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Report only after every finding's company has opted in. A mixed rollout
|
|
259
|
+
* falls back as one batch so the old path cannot duplicate partially sent rows.
|
|
260
|
+
*/
|
|
261
|
+
export async function handleClientHealthSentryReport(input, dependencies) {
|
|
262
|
+
const findings = parseClientHealthDoctorFindings(input.doctorJson);
|
|
263
|
+
if (findings.length === 0)
|
|
264
|
+
return "handled";
|
|
265
|
+
const outcome = safeFixOutcome(input.fixOutcome);
|
|
266
|
+
const companySlugs = [...new Set(findings.map((finding) => finding.companySlug))];
|
|
267
|
+
const enabled = await Promise.all(companySlugs.map((companySlug) => dependencies.resolveFlagForCompany(companySlug)));
|
|
268
|
+
if (enabled.some((value) => value !== true))
|
|
269
|
+
return "not_handled";
|
|
270
|
+
const randomUUID = dependencies.randomUUID ?? cryptoRandomUUID;
|
|
271
|
+
const installId = getOrCreateClientHealthInstallId(input.hqRoot, randomUUID);
|
|
272
|
+
const versions = (dependencies.versions ?? defaultVersions)(input.hqRoot);
|
|
273
|
+
const events = findings.map((finding) => buildEvent(finding, installId, outcome, findings.length, versions, cleanPlatform((dependencies.platform ?? (() => process.platform))()), cleanArchitecture((dependencies.arch ?? os.arch)())));
|
|
274
|
+
writeLastResult(input.hqRoot, findings[0], outcome, randomUUID);
|
|
275
|
+
if (!(await dependencies.sendEvents(events)))
|
|
276
|
+
return "send_failed";
|
|
277
|
+
return "handled";
|
|
278
|
+
}
|
|
279
|
+
//# sourceMappingURL=client-health-sentry.js.map
|
|
@@ -175,6 +175,86 @@ function scrubFields(value, memo = new Map()) {
|
|
|
175
175
|
return out;
|
|
176
176
|
}
|
|
177
177
|
export function beforeSend(event, _hint) {
|
|
178
|
+
if (event.tags?.sentry_event_type === "client_health") {
|
|
179
|
+
const sourceTags = (event.tags ?? {});
|
|
180
|
+
const checkClasses = new Set([
|
|
181
|
+
"sync.update.core",
|
|
182
|
+
"sync.journal.personal",
|
|
183
|
+
"sync.journal.personal_vault",
|
|
184
|
+
"sync.journal.company",
|
|
185
|
+
"sync.manifest.personal",
|
|
186
|
+
"sync.manifest.personal_vault",
|
|
187
|
+
"sync.manifest.company",
|
|
188
|
+
"other",
|
|
189
|
+
]);
|
|
190
|
+
const reasonCodes = new Set([
|
|
191
|
+
"none",
|
|
192
|
+
"core-version-unavailable",
|
|
193
|
+
"core-update-available",
|
|
194
|
+
"no-local-tree",
|
|
195
|
+
"never-synced",
|
|
196
|
+
"invalid-last-sync",
|
|
197
|
+
"stale-threshold",
|
|
198
|
+
"manifest-unavailable",
|
|
199
|
+
"manifest-state-unavailable",
|
|
200
|
+
"no-manifest-scopes",
|
|
201
|
+
"never-uploaded",
|
|
202
|
+
"invalid-upload-time",
|
|
203
|
+
"unresolved-company-uid",
|
|
204
|
+
]);
|
|
205
|
+
const platforms = new Set([
|
|
206
|
+
"aix", "android", "darwin", "freebsd", "haiku", "linux", "openbsd",
|
|
207
|
+
"sunos", "win32", "cygwin",
|
|
208
|
+
]);
|
|
209
|
+
const architectures = new Set([
|
|
210
|
+
"arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc", "ppc64",
|
|
211
|
+
"riscv64", "s390x", "x64",
|
|
212
|
+
]);
|
|
213
|
+
const versionPattern = /^(?:unknown|\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?)$/;
|
|
214
|
+
const id = typeof event.user?.id === "string" &&
|
|
215
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(event.user.id)
|
|
216
|
+
? event.user.id
|
|
217
|
+
: undefined;
|
|
218
|
+
const checkClass = typeof sourceTags.check_class === "string" && checkClasses.has(sourceTags.check_class)
|
|
219
|
+
? sourceTags.check_class
|
|
220
|
+
: "other";
|
|
221
|
+
const reasonCode = typeof sourceTags.reason_code === "string" && reasonCodes.has(sourceTags.reason_code)
|
|
222
|
+
? sourceTags.reason_code
|
|
223
|
+
: "none";
|
|
224
|
+
const fixOutcome = ["not_auto_fixable", "fix_failed", "fix_timed_out"].includes(String(sourceTags.fix_outcome)) ? String(sourceTags.fix_outcome) : "not_auto_fixable";
|
|
225
|
+
const status = sourceTags.status === "WARN" ? "WARN" : "FAIL";
|
|
226
|
+
const count = typeof sourceTags.failing_check_count === "string" &&
|
|
227
|
+
/^[1-9]\d{0,2}$/.test(sourceTags.failing_check_count) &&
|
|
228
|
+
Number(sourceTags.failing_check_count) <= 250
|
|
229
|
+
? sourceTags.failing_check_count
|
|
230
|
+
: "1";
|
|
231
|
+
const safeVersion = (value) => typeof value === "string" && versionPattern.test(value) ? value : "unknown";
|
|
232
|
+
const safePlatform = (value) => typeof value === "string" && platforms.has(value) ? value : "unknown";
|
|
233
|
+
const safeArchitecture = (value) => typeof value === "string" && architectures.has(value) ? value : "unknown";
|
|
234
|
+
return {
|
|
235
|
+
type: undefined,
|
|
236
|
+
...(typeof event.event_id === "string" ? { event_id: event.event_id } : {}),
|
|
237
|
+
...(typeof event.timestamp === "number" ? { timestamp: event.timestamp } : {}),
|
|
238
|
+
platform: "node",
|
|
239
|
+
level: "warning",
|
|
240
|
+
message: `Client health: ${checkClass} unresolved after hq doctor --fix`,
|
|
241
|
+
fingerprint: ["client-health", checkClass, reasonCode],
|
|
242
|
+
tags: {
|
|
243
|
+
sentry_event_type: "client_health",
|
|
244
|
+
check_class: checkClass,
|
|
245
|
+
reason_code: reasonCode,
|
|
246
|
+
fix_outcome: fixOutcome,
|
|
247
|
+
status,
|
|
248
|
+
failing_check_count: count,
|
|
249
|
+
cli_version: safeVersion(sourceTags.cli_version),
|
|
250
|
+
core_version: safeVersion(sourceTags.core_version),
|
|
251
|
+
sync_version: safeVersion(sourceTags.sync_version),
|
|
252
|
+
platform: safePlatform(sourceTags.platform),
|
|
253
|
+
arch: safeArchitecture(sourceTags.arch),
|
|
254
|
+
},
|
|
255
|
+
user: { ...(id ? { id } : {}), ip_address: null },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
178
258
|
if (event.request) {
|
|
179
259
|
const req = event.request;
|
|
180
260
|
event.request = {
|