@indigoai-us/hq-cli 5.286.0 → 5.288.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 +57 -0
- package/assets/scaffold/core/scripts/clean-worktree-reconcile-handoff.sh +171 -0
- package/assets/scaffold/core/scripts/resume-thread-lock.sh +285 -0
- package/assets/scaffold/core/scripts/verify-story-deliverables.sh +148 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +362 -0
- package/dist/command-catalog.generated.d.ts +55 -0
- package/dist/command-catalog.generated.js +68 -0
- package/dist/commands/agent-enroll.js +10 -2
- package/dist/commands/agents.d.ts +1 -0
- package/dist/commands/agents.js +12 -4
- package/dist/commands/cloud-provision.js +99 -78
- package/dist/commands/core-native-handoff-flag.d.ts +4 -0
- package/dist/commands/core-native-handoff-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +4 -1
- package/dist/commands/core-ontology.js +26 -0
- package/dist/commands/core.js +45 -0
- package/dist/commands/lanes-resume.js +41 -2
- package/dist/commands/lanes-shared.d.ts +2 -0
- package/dist/commands/lanes.js +48 -29
- package/dist/commands/team-sync.js +71 -10
- package/dist/fast-lanes-list-route.js +2 -0
- package/dist/fast-lanes-list.js +1 -29
- package/dist/lanes-drain-hook.d.ts +12 -0
- package/dist/lanes-drain-hook.js +59 -55
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +17 -2
- package/dist/lib/checkpoint-ontology/plan.d.ts +0 -88
- package/dist/lib/checkpoint-ontology/plan.js +80 -107
- package/dist/lib/core-utils/sentry-report.js +5 -32
- package/dist/lib/core-utils/worktree-resume.d.ts +18 -0
- package/dist/lib/core-utils/worktree-resume.js +1182 -0
- package/dist/lib/lanes/dropbox.d.ts +1 -2
- package/dist/lib/lanes/dropbox.js +10 -111
- package/dist/lib/lanes/message-replay.d.ts +8 -0
- package/dist/lib/lanes/message-replay.js +119 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.d.ts +21 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.js +329 -0
- package/dist/lib/plan-limit-nag.js +11 -0
- package/dist/main.js +17 -4
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +101 -1
- package/dist/utils/cognito-refresh-sentry.d.ts +3 -0
- package/dist/utils/cognito-refresh-sentry.js +49 -0
- package/dist/utils/cognito-session.js +55 -0
- package/package.json +1 -1
|
@@ -37,6 +37,7 @@ import * as fs from "node:fs";
|
|
|
37
37
|
import * as os from "node:os";
|
|
38
38
|
import * as path from "node:path";
|
|
39
39
|
import { resolveProcessFlagCompanyContext } from "./flag-registry.js";
|
|
40
|
+
import { isCliLimitResourceKind, queueCliLimitNoticeExposure, } from "../utils/cli-telemetry.js";
|
|
40
41
|
import { planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
|
|
41
42
|
export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
|
|
42
43
|
/**
|
|
@@ -445,6 +446,9 @@ export function emitPlanLimitThresholdNotice(crossings, upgradeUrl, opts = {}) {
|
|
|
445
446
|
thresholdsShownThisSession.set(key, crossing.firstSeenAt);
|
|
446
447
|
printed[key] = crossing.firstSeenAt;
|
|
447
448
|
write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl)) + "\n");
|
|
449
|
+
if (isCliLimitResourceKind(crossing.resource)) {
|
|
450
|
+
queueCliLimitNoticeExposure(crossing.resource, "advisory");
|
|
451
|
+
}
|
|
448
452
|
}
|
|
449
453
|
if (Object.keys(printed).length > 0) {
|
|
450
454
|
updateNagState(statePath, { notifiedThresholds: printed });
|
|
@@ -486,6 +490,10 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
486
490
|
const overEntries = entries.filter(([, e]) => e.over);
|
|
487
491
|
const line = buildOverLine(overEntries, resolvedUpgradeUrl);
|
|
488
492
|
write(chalk.yellow(line) + "\n");
|
|
493
|
+
const resourceKind = overEntries[0]?.[0];
|
|
494
|
+
if (resourceKind && isCliLimitResourceKind(resourceKind)) {
|
|
495
|
+
queueCliLimitNoticeExposure(resourceKind, "advisory");
|
|
496
|
+
}
|
|
489
497
|
writeShownAt(statePath, nowMs);
|
|
490
498
|
return;
|
|
491
499
|
}
|
|
@@ -498,6 +506,9 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
498
506
|
warningShownThisSession = true;
|
|
499
507
|
const line = `⚠ HQ Starter: ${formatEntryLine(worst.key, worst.entry)}. Add headroom before you hit the cap: ${resolvedUpgradeUrl}`;
|
|
500
508
|
write(chalk.yellow(line) + "\n");
|
|
509
|
+
if (isCliLimitResourceKind(worst.key)) {
|
|
510
|
+
queueCliLimitNoticeExposure(worst.key, "advisory");
|
|
511
|
+
}
|
|
501
512
|
}
|
|
502
513
|
catch {
|
|
503
514
|
// Never throw from emit path.
|
package/dist/main.js
CHANGED
|
@@ -43,7 +43,7 @@ import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
|
43
43
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
44
44
|
import { CLI_VERSION } from "./cli-version.js";
|
|
45
45
|
import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
|
|
46
|
-
import { emitCliFirstSyncNextStepOutcome, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, takeFirstSyncStepOutcomes, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
|
|
46
|
+
import { emitCliFirstSyncNextStepOutcome, emitCliLimitNoticeEngagement, emitCliLimitNoticeExposure, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, isCliLimitResourceKind, queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, takeFirstSyncStepOutcomes, takeCliLimitNoticeEngagements, takeCliLimitNoticeExposures, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
|
|
47
47
|
import { reportCliClientHealthInvocation } from "./utils/client-health.js";
|
|
48
48
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
49
49
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
@@ -295,11 +295,15 @@ export async function runCli() {
|
|
|
295
295
|
const releaseHealthTimeoutMs = foregroundNetworkBudget.timeoutFor(FINALIZATION_FOREGROUND_WAIT_MS);
|
|
296
296
|
const firstSyncOutcomeFlushes = takeFirstSyncStepOutcomes().map((outcome) => emitCliFirstSyncNextStepOutcome(outcome, releaseHealthTimeoutMs));
|
|
297
297
|
const inviteTeammateExposureFlushes = takePlanLimitInviteTeammateExposures().map((companyUid) => emitPlanLimitInviteTeammateExposure(companyUid, releaseHealthTimeoutMs));
|
|
298
|
+
const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
|
|
299
|
+
const limitNoticeEngagementFlushes = takeCliLimitNoticeEngagements().map((engagement) => emitCliLimitNoticeEngagement(engagement, releaseHealthTimeoutMs));
|
|
298
300
|
await settleWithin([
|
|
299
301
|
refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs }),
|
|
300
302
|
Sentry.flush(releaseHealthTimeoutMs),
|
|
301
303
|
...firstSyncOutcomeFlushes,
|
|
302
304
|
...inviteTeammateExposureFlushes,
|
|
305
|
+
...limitNoticeExposureFlushes,
|
|
306
|
+
...limitNoticeEngagementFlushes,
|
|
303
307
|
], releaseHealthTimeoutMs);
|
|
304
308
|
});
|
|
305
309
|
// Last, so it wins over anything the (skipped) command path would have
|
|
@@ -419,6 +423,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
419
423
|
const interactive = deps.interactive ?? canOfferTeamUpgrade(deps.argv);
|
|
420
424
|
const message = formatPlanGateError(err, { urlOnly: !interactive });
|
|
421
425
|
deps.stderr.write(interactive ? `hq: ${message}\n` : `${message}\n`);
|
|
426
|
+
const promptExposure = isCliLimitResourceKind(err.details.resource ?? "")
|
|
427
|
+
? queueCliLimitNoticeExposure(err.details.resource, "refusal", err.details.companyUid)
|
|
428
|
+
: null;
|
|
422
429
|
deps.setExitCode(1);
|
|
423
430
|
if (interactive && err.details.upgradeUrl) {
|
|
424
431
|
const companyUid = err.details.companyUid;
|
|
@@ -429,24 +436,28 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
429
436
|
err.code === "BILLING_REQUIRED");
|
|
430
437
|
if (isCompanyWorkforceCheckout && companyUid) {
|
|
431
438
|
try {
|
|
432
|
-
await offerTeamUpgrade(async () => {
|
|
439
|
+
const upgraded = await offerTeamUpgrade(async () => {
|
|
433
440
|
await (deps.upgradeToTeam ?? upgradeToTeam)({ companyUid });
|
|
434
441
|
}, {
|
|
435
442
|
interactive: true,
|
|
436
443
|
argv: deps.argv,
|
|
437
444
|
ask: deps.ask,
|
|
438
445
|
});
|
|
446
|
+
if (upgraded)
|
|
447
|
+
queueCliLimitNoticeEngagement(promptExposure);
|
|
439
448
|
}
|
|
440
449
|
catch (upgradeErr) {
|
|
441
450
|
const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
|
|
442
451
|
deps.stderr.write(`hq: ${message}\n`);
|
|
443
452
|
try {
|
|
444
|
-
await offerUpgradeLink(err.details.upgradeUrl, {
|
|
453
|
+
const opened = await offerUpgradeLink(err.details.upgradeUrl, {
|
|
445
454
|
interactive: true,
|
|
446
455
|
argv: deps.argv,
|
|
447
456
|
ask: deps.ask,
|
|
448
457
|
openUrl: deps.openUrl,
|
|
449
458
|
});
|
|
459
|
+
if (opened)
|
|
460
|
+
queueCliLimitNoticeEngagement(promptExposure);
|
|
450
461
|
}
|
|
451
462
|
catch (linkErr) {
|
|
452
463
|
const linkMessage = linkErr instanceof Error ? linkErr.message : String(linkErr);
|
|
@@ -456,12 +467,14 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
456
467
|
}
|
|
457
468
|
else {
|
|
458
469
|
try {
|
|
459
|
-
await offerUpgradeLink(err.details.upgradeUrl, {
|
|
470
|
+
const opened = await offerUpgradeLink(err.details.upgradeUrl, {
|
|
460
471
|
interactive: true,
|
|
461
472
|
argv: deps.argv,
|
|
462
473
|
ask: deps.ask,
|
|
463
474
|
openUrl: deps.openUrl,
|
|
464
475
|
});
|
|
476
|
+
if (opened)
|
|
477
|
+
queueCliLimitNoticeEngagement(promptExposure);
|
|
465
478
|
}
|
|
466
479
|
catch (upgradeErr) {
|
|
467
480
|
const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
|
|
@@ -10,6 +10,24 @@ export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when yo
|
|
|
10
10
|
export declare const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; when enabled, first-sync guidance shown or followed and the flag key cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
|
|
11
11
|
export declare const CLI_FEATURE_USED_TELEMETRY_FLAG = "cli.feature-used-telemetry-v1";
|
|
12
12
|
export declare const CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE = "HQ sends a feature-use event when you run a supported CLI feature (feature kind and outcome, plus your company UID when available; never command names, file contents, or secrets). The hq-flags key cli.feature-used-telemetry-v1 controls these events. Set HQ_TELEMETRY=0 to turn this off.";
|
|
13
|
+
export declare const CLI_LIMIT_AT_ACTION_PROMPT_FLAG = "billing.limit-at-action-prompt";
|
|
14
|
+
export type CliLimitResourceKind = "users" | "integrations" | "secrets" | "agents" | "deployments" | "storageBytes";
|
|
15
|
+
export type CliLimitPresentationKind = "advisory" | "refusal";
|
|
16
|
+
export interface CliLimitNoticeExposure {
|
|
17
|
+
companyUid: string;
|
|
18
|
+
resourceKind: CliLimitResourceKind;
|
|
19
|
+
presentationKind: CliLimitPresentationKind;
|
|
20
|
+
exposureId: string;
|
|
21
|
+
}
|
|
22
|
+
export interface CliLimitNoticeEngagement extends CliLimitNoticeExposure {
|
|
23
|
+
action: "upgrade_clicked";
|
|
24
|
+
}
|
|
25
|
+
/** Capture an exposure only after its notice has been written to the terminal. */
|
|
26
|
+
export declare function queueCliLimitNoticeExposure(resourceKind: CliLimitResourceKind, presentationKind: CliLimitPresentationKind, resolvedCompanyUid?: string): CliLimitNoticeExposure | null;
|
|
27
|
+
/** Only an explicit, confirmed browser open is an upgrade engagement. */
|
|
28
|
+
export declare function queueCliLimitNoticeEngagement(exposure: CliLimitNoticeExposure | null): void;
|
|
29
|
+
export declare function takeCliLimitNoticeExposures(): CliLimitNoticeExposure[];
|
|
30
|
+
export declare function takeCliLimitNoticeEngagements(): CliLimitNoticeEngagement[];
|
|
13
31
|
/** Queue one exposure after the CLI has printed the invite suggestion. */
|
|
14
32
|
export declare function queuePlanLimitInviteTeammateExposure(companyUid: string): void;
|
|
15
33
|
/** Drain invite-suggestion exposures for the bounded command-finalization flush. */
|
|
@@ -22,6 +40,7 @@ export interface CliFeatureUsage {
|
|
|
22
40
|
/** Only company UIDs are accepted; names and slugs are never telemetry fields. */
|
|
23
41
|
companyUid?: string;
|
|
24
42
|
}
|
|
43
|
+
export declare function isCliLimitResourceKind(value: string): value is CliLimitResourceKind;
|
|
25
44
|
/**
|
|
26
45
|
* Emit one bounded, privacy-limited feature-use event with a cached identity.
|
|
27
46
|
* It never refreshes credentials or turns telemetry failure into command failure.
|
|
@@ -55,4 +74,6 @@ export declare function emitCliSessionStarted(timeoutMs?: number): Promise<void>
|
|
|
55
74
|
/** Emit one authenticated measurement for a first-sync prompt outcome. */
|
|
56
75
|
export declare function emitCliFirstSyncNextStepOutcome(outcome: FirstSyncStepOutcome, timeoutMs?: number): Promise<void>;
|
|
57
76
|
export declare function emitPlanLimitInviteTeammateExposure(companyUid: string, timeoutMs?: number): Promise<void>;
|
|
77
|
+
export declare function emitCliLimitNoticeExposure(exposure: CliLimitNoticeExposure, timeoutMs?: number): Promise<void>;
|
|
78
|
+
export declare function emitCliLimitNoticeEngagement(engagement: CliLimitNoticeEngagement, timeoutMs?: number): Promise<void>;
|
|
58
79
|
//# sourceMappingURL=cli-telemetry.d.ts.map
|
|
@@ -4,7 +4,7 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { CLI_VERSION } from "../cli-version.js";
|
|
6
6
|
import { isExpiring, isMachineIdentity, loadCachedTokens, loadMachineCachedTokens, } from "./cognito-session.js";
|
|
7
|
-
import { resolveProcessFlagGate } from "../lib/flag-registry.js";
|
|
7
|
+
import { resolveProcessFlagCompanyContext, resolveProcessFlagGate, } from "../lib/flag-registry.js";
|
|
8
8
|
import { vaultApiFetch } from "./vault-api.js";
|
|
9
9
|
import { firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, } from "../lib/first-sync-next-step.js";
|
|
10
10
|
export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC_NEXT_STEP_COPY, claimFirstSyncStepMarker, firstSyncStepMarkerPaths, firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, isWorkBearingSyncResult, maybeShowFirstSyncNextStep, recordFirstWorkBearingSync, shouldConfirmFirstWorkBearingSync, shouldShowFirstSyncNextStep, takeFirstSyncStepOutcomes, } from "../lib/first-sync-next-step.js";
|
|
@@ -24,6 +24,38 @@ const FIRST_SYNC_DISCLOSURE_MARKER = "cli-telemetry-disclosed-first-sync-v1";
|
|
|
24
24
|
const FEATURE_USED_DISCLOSURE_MARKER = "cli-telemetry-disclosed-feature-used-v1";
|
|
25
25
|
let cliSessionStartedPromise;
|
|
26
26
|
const pendingPlanLimitInviteTeammateExposures = [];
|
|
27
|
+
export const CLI_LIMIT_AT_ACTION_PROMPT_FLAG = "billing.limit-at-action-prompt";
|
|
28
|
+
const pendingCliLimitNoticeExposures = [];
|
|
29
|
+
const pendingCliLimitNoticeEngagements = [];
|
|
30
|
+
/** Capture an exposure only after its notice has been written to the terminal. */
|
|
31
|
+
export function queueCliLimitNoticeExposure(resourceKind, presentationKind, resolvedCompanyUid) {
|
|
32
|
+
try {
|
|
33
|
+
if (!resolveProcessFlagGate(CLI_LIMIT_AT_ACTION_PROMPT_FLAG, { fallback: false }, () => false)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const companyUid = resolvedCompanyUid ?? resolveProcessFlagCompanyContext().companyUid;
|
|
37
|
+
if (!companyUid || !COMPANY_UID_PATTERN.test(companyUid))
|
|
38
|
+
return null;
|
|
39
|
+
const exposure = { companyUid, resourceKind, presentationKind, exposureId: randomUUID() };
|
|
40
|
+
pendingCliLimitNoticeExposures.push(exposure);
|
|
41
|
+
return exposure;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Only an explicit, confirmed browser open is an upgrade engagement. */
|
|
48
|
+
export function queueCliLimitNoticeEngagement(exposure) {
|
|
49
|
+
if (!exposure)
|
|
50
|
+
return;
|
|
51
|
+
pendingCliLimitNoticeEngagements.push({ ...exposure, action: "upgrade_clicked" });
|
|
52
|
+
}
|
|
53
|
+
export function takeCliLimitNoticeExposures() {
|
|
54
|
+
return pendingCliLimitNoticeExposures.splice(0, pendingCliLimitNoticeExposures.length);
|
|
55
|
+
}
|
|
56
|
+
export function takeCliLimitNoticeEngagements() {
|
|
57
|
+
return pendingCliLimitNoticeEngagements.splice(0, pendingCliLimitNoticeEngagements.length);
|
|
58
|
+
}
|
|
27
59
|
/** Queue one exposure after the CLI has printed the invite suggestion. */
|
|
28
60
|
export function queuePlanLimitInviteTeammateExposure(companyUid) {
|
|
29
61
|
const normalizedCompanyUid = companyUid.trim();
|
|
@@ -36,6 +68,9 @@ export function takePlanLimitInviteTeammateExposures() {
|
|
|
36
68
|
return pendingPlanLimitInviteTeammateExposures.splice(0, pendingPlanLimitInviteTeammateExposures.length);
|
|
37
69
|
}
|
|
38
70
|
const COMPANY_UID_PATTERN = /^cmp_[A-Za-z0-9_-]{1,128}$/;
|
|
71
|
+
export function isCliLimitResourceKind(value) {
|
|
72
|
+
return ["users", "integrations", "secrets", "agents", "deployments", "storageBytes"].includes(value);
|
|
73
|
+
}
|
|
39
74
|
/**
|
|
40
75
|
* Emit one bounded, privacy-limited feature-use event with a cached identity.
|
|
41
76
|
* It never refreshes credentials or turns telemetry failure into command failure.
|
|
@@ -382,4 +417,69 @@ export async function emitPlanLimitInviteTeammateExposure(companyUid, timeoutMs
|
|
|
382
417
|
logPlanLimitInviteTeammateExposureFailure(error);
|
|
383
418
|
}
|
|
384
419
|
}
|
|
420
|
+
/** Send one bounded plan-limit prompt event through authenticated CLI telemetry. */
|
|
421
|
+
async function emitCliLimitNoticeEvent(exposure, eventName, action, timeoutMs = TELEMETRY_TIMEOUT_MS) {
|
|
422
|
+
try {
|
|
423
|
+
if (isCliTelemetryDisabled())
|
|
424
|
+
return;
|
|
425
|
+
const machineIdentity = isMachineIdentity();
|
|
426
|
+
const cached = machineIdentity ? loadMachineCachedTokens() : loadCachedTokens();
|
|
427
|
+
if (!cached ||
|
|
428
|
+
isExpiring(cached, 120) ||
|
|
429
|
+
!isHumanInteractiveCliSession({
|
|
430
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
431
|
+
argv: process.argv,
|
|
432
|
+
env: process.env,
|
|
433
|
+
machineIdentity,
|
|
434
|
+
}))
|
|
435
|
+
return;
|
|
436
|
+
const token = machineIdentity ? cached.idToken : cached.accessToken;
|
|
437
|
+
if (!token)
|
|
438
|
+
return;
|
|
439
|
+
const controller = new AbortController();
|
|
440
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
441
|
+
try {
|
|
442
|
+
await vaultApiFetch({
|
|
443
|
+
token,
|
|
444
|
+
path: "/v1/telemetry/events",
|
|
445
|
+
method: "POST",
|
|
446
|
+
body: {
|
|
447
|
+
events: [{
|
|
448
|
+
eventName,
|
|
449
|
+
app: "hq-cli",
|
|
450
|
+
source: "cli",
|
|
451
|
+
occurredAt: new Date().toISOString(),
|
|
452
|
+
consentBasis: "no-consent",
|
|
453
|
+
schemaVersion: 2,
|
|
454
|
+
companyUid: exposure.companyUid,
|
|
455
|
+
idempotencyKey: randomUUID(),
|
|
456
|
+
properties: {
|
|
457
|
+
surface: "cli_limit_notice",
|
|
458
|
+
resourceKind: exposure.resourceKind,
|
|
459
|
+
presentationKind: exposure.presentationKind,
|
|
460
|
+
exposureId: exposure.exposureId,
|
|
461
|
+
...(eventName === "plan_limit_prompt_engaged" && action ? { action } : {}),
|
|
462
|
+
},
|
|
463
|
+
}],
|
|
464
|
+
},
|
|
465
|
+
signal: controller.signal,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// Measurement must never change a command's output or exit status.
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
clearTimeout(timeout);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// Identity, flag cache, and transport failures all remain best effort.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
export function emitCliLimitNoticeExposure(exposure, timeoutMs = TELEMETRY_TIMEOUT_MS) {
|
|
480
|
+
return emitCliLimitNoticeEvent(exposure, "plan_limit_prompt_exposed", undefined, timeoutMs);
|
|
481
|
+
}
|
|
482
|
+
export function emitCliLimitNoticeEngagement(engagement, timeoutMs = TELEMETRY_TIMEOUT_MS) {
|
|
483
|
+
return emitCliLimitNoticeEvent(engagement, "plan_limit_prompt_engaged", engagement.action, timeoutMs);
|
|
484
|
+
}
|
|
385
485
|
//# sourceMappingURL=cli-telemetry.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
2
|
+
import { CognitoRefreshError } from "@indigoai-us/hq-cloud";
|
|
3
|
+
/** Capture only fixed, aggregatable auth diagnostics; never attach tokens or identity. */
|
|
4
|
+
export async function reportNoninteractivePersonRefreshRejection(error) {
|
|
5
|
+
try {
|
|
6
|
+
// Keep the Sentry SDK and its generated DSN out of ordinary session utility
|
|
7
|
+
// imports. The reporter is reached only after a definitive noninteractive
|
|
8
|
+
// refresh rejection, so load the Sentry boundary only on that path.
|
|
9
|
+
const { Sentry } = await import("../sentry.js");
|
|
10
|
+
const statusCode = error instanceof CognitoRefreshError ? error.statusCode : undefined;
|
|
11
|
+
const rejectionKind = error instanceof CognitoRefreshError
|
|
12
|
+
? "cognito_refresh_rejection"
|
|
13
|
+
: "classified_auth_error";
|
|
14
|
+
Sentry.withScope((scope) => {
|
|
15
|
+
scope.setUser(null);
|
|
16
|
+
scope.clearBreadcrumbs();
|
|
17
|
+
scope.addBreadcrumb({
|
|
18
|
+
category: "hq.auth",
|
|
19
|
+
message: "Noninteractive person refresh was attempted for an expiring session",
|
|
20
|
+
level: "info",
|
|
21
|
+
data: { interactive: false },
|
|
22
|
+
});
|
|
23
|
+
scope.addBreadcrumb({
|
|
24
|
+
category: "hq.auth",
|
|
25
|
+
message: "Refresh rejection had no fresh same-principal cache generation",
|
|
26
|
+
level: "error",
|
|
27
|
+
data: {
|
|
28
|
+
rejection_kind: rejectionKind,
|
|
29
|
+
...(Number.isInteger(statusCode) ? { status_code: statusCode } : {}),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
scope.setLevel("error");
|
|
33
|
+
scope.setFingerprint(["hq-cli", "person-refresh-rejected"]);
|
|
34
|
+
scope.setTag("component", "person-session-refresh");
|
|
35
|
+
scope.setTag("auth.rejection_kind", rejectionKind);
|
|
36
|
+
scope.setTag("auth.fresh_cache_generation_found", "false");
|
|
37
|
+
scope.setTag("platform", process.platform);
|
|
38
|
+
scope.setTag("cli_version", CLI_VERSION);
|
|
39
|
+
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
|
|
40
|
+
scope.setTag("auth.status_code", String(statusCode));
|
|
41
|
+
}
|
|
42
|
+
Sentry.captureMessage("Noninteractive HQ person session refresh rejected");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Telemetry is best-effort: it must never prevent session invalidation or auth fallback.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=cognito-refresh-sentry.js.map
|
|
@@ -30,6 +30,7 @@ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
|
30
30
|
import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
|
|
31
31
|
import { workMeshRoot } from "../lib/mesh/live/paths.js";
|
|
32
32
|
import { acquireLockFileExclusively } from "./exclusive-lock.js";
|
|
33
|
+
import { reportNoninteractivePersonRefreshRejection } from "./cognito-refresh-sentry.js";
|
|
33
34
|
// Re-export the cached-session primitives for callers that must deliberately
|
|
34
35
|
// avoid the refresh/login behavior in the higher-level helpers below.
|
|
35
36
|
export { isExpiring, isMachineIdentity, loadCachedTokens };
|
|
@@ -879,6 +880,37 @@ export function isDefinitivePersonAuthRejection(err) {
|
|
|
879
880
|
function markPersonSessionRejected(accessToken) {
|
|
880
881
|
invalidateCachedTokensByFingerprint(accessTokenFingerprint(accessToken));
|
|
881
882
|
}
|
|
883
|
+
async function reportPersonRefreshRejectionBestEffort(error) {
|
|
884
|
+
try {
|
|
885
|
+
await reportNoninteractivePersonRefreshRejection(error);
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
// Reporting is deliberately non-blocking for auth recovery and invalidation.
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* A refresh rejection applies to the snapshot sent to Cognito. Reread the
|
|
893
|
+
* shared person cache before rejecting the session because another process
|
|
894
|
+
* may have atomically installed a fresh generation while the request ran.
|
|
895
|
+
*/
|
|
896
|
+
function resolvePersonSessionAfterRefreshRejection(cached) {
|
|
897
|
+
const current = readPersonTokenFile();
|
|
898
|
+
const cachedKey = cognitoSubjectKey(cached);
|
|
899
|
+
const currentKey = cognitoSubjectKey(current);
|
|
900
|
+
if (!current || current.accessToken === cached.accessToken)
|
|
901
|
+
return { kind: "none" };
|
|
902
|
+
if (cachedKey && currentKey && cachedKey !== currentKey) {
|
|
903
|
+
return {
|
|
904
|
+
kind: "identity-swap",
|
|
905
|
+
error: new IdentitySwapRefusal("refusing rejected refresh for " + formatCognitoSubject(cached) +
|
|
906
|
+
": the active token file now belongs to " + formatCognitoSubject(current)),
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
if (cachedKey && currentKey === cachedKey && !isExpiring(current, 120)) {
|
|
910
|
+
return { kind: "fresh", tokens: current };
|
|
911
|
+
}
|
|
912
|
+
return { kind: "none" };
|
|
913
|
+
}
|
|
882
914
|
/**
|
|
883
915
|
* Return a non-expired Cognito access token, refreshing or browser-logging-in
|
|
884
916
|
* as needed. Person cache lives at ~/.hq/cognito-tokens.json. Machine minting
|
|
@@ -931,6 +963,15 @@ async function ensureCognitoTokenUnserialized(options) {
|
|
|
931
963
|
if (!isDefinitivePersonAuthRejection(err)) {
|
|
932
964
|
throw err;
|
|
933
965
|
}
|
|
966
|
+
const resolution = resolvePersonSessionAfterRefreshRejection(cached);
|
|
967
|
+
if (resolution.kind === "identity-swap")
|
|
968
|
+
throw resolution.error;
|
|
969
|
+
if (resolution.kind === "fresh") {
|
|
970
|
+
return resolution.tokens.accessToken;
|
|
971
|
+
}
|
|
972
|
+
if (!interactive) {
|
|
973
|
+
await reportPersonRefreshRejectionBestEffort(err);
|
|
974
|
+
}
|
|
934
975
|
markPersonSessionRejected(cached.accessToken);
|
|
935
976
|
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
936
977
|
if (fallback)
|
|
@@ -1007,6 +1048,13 @@ async function ensureCognitoIdTokenUnserialized(options) {
|
|
|
1007
1048
|
if (!isDefinitivePersonAuthRejection(err)) {
|
|
1008
1049
|
throw err;
|
|
1009
1050
|
}
|
|
1051
|
+
const resolution = resolvePersonSessionAfterRefreshRejection(cached);
|
|
1052
|
+
if (resolution.kind === "identity-swap")
|
|
1053
|
+
throw resolution.error;
|
|
1054
|
+
if (resolution.kind === "fresh")
|
|
1055
|
+
return resolution.tokens.idToken;
|
|
1056
|
+
if (!interactive)
|
|
1057
|
+
await reportPersonRefreshRejectionBestEffort(err);
|
|
1010
1058
|
markPersonSessionRejected(cached.accessToken);
|
|
1011
1059
|
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
1012
1060
|
if (fallback)
|
|
@@ -1123,6 +1171,13 @@ async function refreshCachedSessionUnserialized(options) {
|
|
|
1123
1171
|
reason: err instanceof Error ? err.message : String(err),
|
|
1124
1172
|
};
|
|
1125
1173
|
}
|
|
1174
|
+
const resolution = resolvePersonSessionAfterRefreshRejection(cached);
|
|
1175
|
+
if (resolution.kind === "identity-swap") {
|
|
1176
|
+
return { refreshed: false, reason: resolution.error.message };
|
|
1177
|
+
}
|
|
1178
|
+
if (resolution.kind === "fresh")
|
|
1179
|
+
return { refreshed: true };
|
|
1180
|
+
await reportPersonRefreshRejectionBestEffort(err);
|
|
1126
1181
|
// Definitive rejection — person session is dead; mark it and fall back.
|
|
1127
1182
|
markPersonSessionRejected(cached.accessToken);
|
|
1128
1183
|
try {
|