@indigoai-us/hq-cli 5.130.0 → 5.132.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 +155 -1
- package/contracts/preflight/v1/fixtures.json +87 -0
- package/contracts/preflight/v1/schema.json +58 -0
- package/dist/command-catalog.generated.d.ts +39 -0
- package/dist/command-catalog.generated.js +50 -0
- package/dist/commands/dm.d.ts +2 -0
- package/dist/commands/dm.js +1 -1
- package/dist/commands/files.js +137 -9
- package/dist/commands/lanes.d.ts +13 -2
- package/dist/commands/lanes.js +394 -42
- package/dist/commands/mesh.js +66 -13
- package/dist/commands/update.js +1 -1
- package/dist/lib/bot/paths.d.ts +7 -5
- package/dist/lib/bot/paths.js +8 -1
- package/dist/lib/bot/prompt.d.ts +0 -2
- package/dist/lib/bot/prompt.js +5 -11
- package/dist/lib/bot/room-policy.d.ts +1 -24
- package/dist/lib/bot/room-policy.js +2 -35
- package/dist/lib/bot/run.d.ts +7 -8
- package/dist/lib/bot/run.js +21 -33
- package/dist/lib/bot/session.d.ts +1 -1
- package/dist/lib/bot/session.js +1 -1
- package/dist/lib/lanes/accounts.d.ts +6 -0
- package/dist/lib/lanes/accounts.js +2 -1
- package/dist/lib/lanes/config.d.ts +4 -0
- package/dist/lib/lanes/config.js +9 -0
- package/dist/lib/lanes/index.d.ts +10 -3
- package/dist/lib/lanes/index.js +5 -1
- package/dist/lib/lanes/mesh-events.js +4 -0
- package/dist/lib/lanes/models.d.ts +78 -0
- package/dist/lib/lanes/models.js +205 -0
- package/dist/lib/lanes/move-account.d.ts +56 -0
- package/dist/lib/lanes/move-account.js +193 -0
- package/dist/lib/lanes/provider-rejection.d.ts +86 -0
- package/dist/lib/lanes/provider-rejection.js +258 -0
- package/dist/lib/lanes/quota.d.ts +17 -0
- package/dist/lib/lanes/quota.js +24 -0
- package/dist/lib/lanes/reconcile-install.d.ts +24 -3
- package/dist/lib/lanes/reconcile-install.js +48 -12
- package/dist/lib/lanes/reconcile.js +41 -11
- package/dist/lib/lanes/spawn.js +26 -5
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +49 -2
- package/dist/lib/lanes/types.js +4 -3
- package/dist/lib/lanes/watcher.d.ts +27 -0
- package/dist/lib/lanes/watcher.js +68 -5
- package/dist/lib/mesh/api.d.ts +2 -0
- package/dist/lib/mesh/api.js +15 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
- package/dist/lib/mesh/live/backfill-held.js +37 -14
- package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
- package/dist/lib/mesh/live/daemon/doctor.js +15 -4
- package/dist/lib/mesh/live/daemon/install.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/install.js +84 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/run.js +17 -4
- package/dist/lib/mesh/live/emit-client.d.ts +2 -0
- package/dist/lib/mesh/live/emit-client.js +15 -8
- package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
- package/dist/lib/mesh/live/emit-evidence.js +20 -2
- package/dist/lib/mesh/live/emit.d.ts +9 -0
- package/dist/lib/mesh/live/emit.js +28 -2
- package/dist/lib/mesh/live/flush.js +9 -3
- package/dist/lib/search-index/index.js +22 -3
- package/dist/lib/work-context/company.d.ts +16 -0
- package/dist/lib/work-context/company.js +46 -14
- package/dist/lib/work-context/preflight-contract.d.ts +28 -0
- package/dist/lib/work-context/preflight-contract.js +85 -0
- package/dist/main.js +11 -1
- package/dist/utils/cognito-session.d.ts +39 -0
- package/dist/utils/cognito-session.js +119 -3
- package/dist/utils/environmental-error.d.ts +18 -0
- package/dist/utils/environmental-error.js +240 -7
- package/dist/utils/expected-cli-error.d.ts +2 -0
- package/dist/utils/expected-cli-error.js +4 -0
- package/dist/utils/self-update.js +4 -1
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +11 -3
- package/package.json +3 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline preflight ContextResult shapes that `hq mesh context reconcile --offline`
|
|
3
|
+
* can emit. migration_pending is a migrate-path status, not a preflight result.
|
|
4
|
+
*
|
|
5
|
+
* contractVersion is WORK_CONTEXT_CONTRACT_VERSION. Bump it only together with
|
|
6
|
+
* contracts/preflight/v<N>/ and the vendored copy in hq-core
|
|
7
|
+
* (core/hooks/SessionStart/preflight-fixtures.json). Each side pins the version
|
|
8
|
+
* in tests; a one-sided bump fails that side until the other is updated.
|
|
9
|
+
*/
|
|
10
|
+
import { WORK_CONTEXT_CONTRACT_VERSION, } from "./contract.js";
|
|
11
|
+
export const PREFLIGHT_CONTRACT_VERSION = WORK_CONTEXT_CONTRACT_VERSION;
|
|
12
|
+
/** Classifications reconcile actually returns on the offline preflight path. */
|
|
13
|
+
export const OFFLINE_PREFLIGHT_CLASSIFICATIONS = [
|
|
14
|
+
"unresolved",
|
|
15
|
+
"needs_company",
|
|
16
|
+
"company_conflict",
|
|
17
|
+
"needs_project",
|
|
18
|
+
"needs_task",
|
|
19
|
+
"bound",
|
|
20
|
+
"untracked",
|
|
21
|
+
];
|
|
22
|
+
const FIXTURE_SESSION = "sid-preflight-fixture";
|
|
23
|
+
const FIXTURE_OP = "op-preflight-fixture";
|
|
24
|
+
function base(classification, kind, delivery) {
|
|
25
|
+
return {
|
|
26
|
+
contractVersion: PREFLIGHT_CONTRACT_VERSION,
|
|
27
|
+
kind,
|
|
28
|
+
classification,
|
|
29
|
+
delivery,
|
|
30
|
+
lifecycle: "open",
|
|
31
|
+
sessionId: FIXTURE_SESSION,
|
|
32
|
+
clientOperationId: FIXTURE_OP,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One representative result per emitted classification, using the kind and
|
|
37
|
+
* delivery the offline path actually returns (company-resolved results are
|
|
38
|
+
* kind "queued" / delivery "queued"; local-only results are delivery "clean").
|
|
39
|
+
*/
|
|
40
|
+
export function preflightFixtureFor(classification) {
|
|
41
|
+
switch (classification) {
|
|
42
|
+
case "unresolved":
|
|
43
|
+
return base("unresolved", "queued", "queued");
|
|
44
|
+
case "needs_company":
|
|
45
|
+
return base("needs_company", "needs_company", "clean");
|
|
46
|
+
case "company_conflict":
|
|
47
|
+
return base("company_conflict", "company_conflict", "clean");
|
|
48
|
+
case "needs_project":
|
|
49
|
+
return {
|
|
50
|
+
...base("needs_project", "queued", "queued"),
|
|
51
|
+
companySlug: "acme",
|
|
52
|
+
companyUid: "cmp_acme",
|
|
53
|
+
};
|
|
54
|
+
case "needs_task":
|
|
55
|
+
return {
|
|
56
|
+
...base("needs_task", "queued", "queued"),
|
|
57
|
+
companySlug: "acme",
|
|
58
|
+
companyUid: "cmp_acme",
|
|
59
|
+
projectId: "dispatch-proj",
|
|
60
|
+
};
|
|
61
|
+
case "bound":
|
|
62
|
+
return {
|
|
63
|
+
...base("bound", "queued", "queued"),
|
|
64
|
+
companySlug: "acme",
|
|
65
|
+
companyUid: "cmp_acme",
|
|
66
|
+
projectId: "dispatch-proj",
|
|
67
|
+
taskId: "US-9",
|
|
68
|
+
};
|
|
69
|
+
case "untracked":
|
|
70
|
+
return base("untracked", "untracked", "clean");
|
|
71
|
+
default: {
|
|
72
|
+
const never = classification;
|
|
73
|
+
return never;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function buildPreflightFixtures() {
|
|
78
|
+
return {
|
|
79
|
+
contractVersion: PREFLIGHT_CONTRACT_VERSION,
|
|
80
|
+
source: "hq-cli contracts/preflight/v1/fixtures.json",
|
|
81
|
+
classifications: [...OFFLINE_PREFLIGHT_CLASSIFICATIONS],
|
|
82
|
+
fixtures: OFFLINE_PREFLIGHT_CLASSIFICATIONS.map(preflightFixtureFor),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=preflight-contract.js.map
|
package/dist/main.js
CHANGED
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { Command } from "commander";
|
|
12
12
|
import { finishSentrySession, initSentry, Sentry } from "./sentry.js";
|
|
13
13
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
14
|
-
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
14
|
+
import { environmentalFsErrorMessage, filesystemErrorCaptureContext, filesystemErrorMessage, } from "./utils/environmental-error.js";
|
|
15
15
|
import { syncStateLockMessage } from "./utils/sync-state-lock-error.js";
|
|
16
16
|
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
17
17
|
import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
|
|
@@ -690,6 +690,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
690
690
|
lockMsg
|
|
691
691
|
? null
|
|
692
692
|
: networkTransportErrorMessage(err);
|
|
693
|
+
// Windows EPERM/EACCES/EBUSY/UNKNOWN filesystem failures are not proven
|
|
694
|
+
// caller-owned conditions. Keep them at error level and attach bounded,
|
|
695
|
+
// installation-independent context so OneDrive/antivirus/path-length
|
|
696
|
+
// hypotheses can be separated without creating one issue per host path.
|
|
697
|
+
const fsContext = filesystemErrorCaptureContext(err);
|
|
698
|
+
const fsMsg = fsContext ? filesystemErrorMessage(err) : null;
|
|
693
699
|
if (qmdMsg) {
|
|
694
700
|
deps.stderr.write(`hq: ${qmdMsg}\n`);
|
|
695
701
|
}
|
|
@@ -735,6 +741,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
735
741
|
else if (transportMsg) {
|
|
736
742
|
deps.stderr.write(`hq: ${transportMsg}\n`);
|
|
737
743
|
}
|
|
744
|
+
else if (fsMsg && fsContext) {
|
|
745
|
+
deps.sentry.captureException(err, fsContext);
|
|
746
|
+
deps.stderr.write(`hq: ${fsMsg}\n`);
|
|
747
|
+
}
|
|
738
748
|
else {
|
|
739
749
|
// A genuinely unclassified fault is still captured exactly once. Two
|
|
740
750
|
// shapes attach bounded, hq-derived context so the next occurrence
|
|
@@ -153,6 +153,45 @@ export declare function wantsMachineCognitoTokens(options?: Pick<EnsureCognitoTo
|
|
|
153
153
|
* not flip this to machine.
|
|
154
154
|
*/
|
|
155
155
|
export declare function resolveCognitoTokenSource(env?: NodeJS.ProcessEnv): CognitoTokenSource;
|
|
156
|
+
/**
|
|
157
|
+
* Refresh wrote a different Cognito subject than the active cache.
|
|
158
|
+
* Callers must not fall through to browser login or machine mint — that
|
|
159
|
+
* would replace the operator session the refusal is protecting.
|
|
160
|
+
*/
|
|
161
|
+
export declare class IdentitySwapRefusal extends Error {
|
|
162
|
+
constructor(message: string);
|
|
163
|
+
}
|
|
164
|
+
export declare function isIdentitySwapRefusal(err: unknown): err is IdentitySwapRefusal;
|
|
165
|
+
/** `sub` plus `custom:entityUid` from an unverified JWT. Either may be absent. */
|
|
166
|
+
export declare function cognitoSubjectFromToken(token: string | undefined): {
|
|
167
|
+
sub: string | null;
|
|
168
|
+
entityUid: string | null;
|
|
169
|
+
};
|
|
170
|
+
/** Stable subject key. Null when neither sub nor entity uid is present. */
|
|
171
|
+
export declare function cognitoSubjectKey(tokens: {
|
|
172
|
+
idToken?: string;
|
|
173
|
+
accessToken?: string;
|
|
174
|
+
} | null | undefined): string | null;
|
|
175
|
+
/** Doctor/status line. Claims only — never token material. */
|
|
176
|
+
export declare function formatCognitoSubject(tokens: {
|
|
177
|
+
idToken?: string;
|
|
178
|
+
accessToken?: string;
|
|
179
|
+
} | null | undefined): string;
|
|
180
|
+
/**
|
|
181
|
+
* Person cache as stored on disk for `env`. Does not consult hq-cloud's
|
|
182
|
+
* in-memory fallback, which can surface a different session when the file
|
|
183
|
+
* is missing or unreadable.
|
|
184
|
+
*/
|
|
185
|
+
export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv): CognitoTokens | null;
|
|
186
|
+
/**
|
|
187
|
+
* Refresh the active person session and refuse a subject change.
|
|
188
|
+
*
|
|
189
|
+
* hq-cloud's `refreshTokens` saves as soon as Cognito responds, and on a
|
|
190
|
+
* cache miss or unreadable file it can refresh an in-memory session for a
|
|
191
|
+
* different subject (same app client). Snapshot the active file first; if
|
|
192
|
+
* the response subject differs, put the bytes back and leave them there.
|
|
193
|
+
*/
|
|
194
|
+
export declare function refreshActivePersonTokens(cached: CognitoTokens, env?: NodeJS.ProcessEnv): Promise<CognitoTokens>;
|
|
156
195
|
/** Derive actor kind from an ID token's custom:entity* claims (no verify). */
|
|
157
196
|
export declare function actorKindFromIdToken(idToken: string | undefined): CognitoActorKind;
|
|
158
197
|
/**
|
|
@@ -409,6 +409,115 @@ function decodeJwtPayload(token) {
|
|
|
409
409
|
return null;
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Refresh wrote a different Cognito subject than the active cache.
|
|
414
|
+
* Callers must not fall through to browser login or machine mint — that
|
|
415
|
+
* would replace the operator session the refusal is protecting.
|
|
416
|
+
*/
|
|
417
|
+
export class IdentitySwapRefusal extends Error {
|
|
418
|
+
constructor(message) {
|
|
419
|
+
super(message);
|
|
420
|
+
this.name = "IdentitySwapRefusal";
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
export function isIdentitySwapRefusal(err) {
|
|
424
|
+
return err instanceof IdentitySwapRefusal;
|
|
425
|
+
}
|
|
426
|
+
/** `sub` plus `custom:entityUid` from an unverified JWT. Either may be absent. */
|
|
427
|
+
export function cognitoSubjectFromToken(token) {
|
|
428
|
+
const claims = decodeJwtPayload(token);
|
|
429
|
+
const sub = typeof claims?.sub === "string" && claims.sub.length > 0 ? claims.sub : null;
|
|
430
|
+
const entity = claims?.["custom:entityUid"];
|
|
431
|
+
const entityUid = typeof entity === "string" && entity.length > 0 ? entity : null;
|
|
432
|
+
return { sub, entityUid };
|
|
433
|
+
}
|
|
434
|
+
/** Stable subject key. Null when neither sub nor entity uid is present. */
|
|
435
|
+
export function cognitoSubjectKey(tokens) {
|
|
436
|
+
if (!tokens)
|
|
437
|
+
return null;
|
|
438
|
+
const fromId = cognitoSubjectFromToken(tokens.idToken);
|
|
439
|
+
const fromAccess = cognitoSubjectFromToken(tokens.accessToken);
|
|
440
|
+
const sub = fromId.sub ?? fromAccess.sub;
|
|
441
|
+
const entityUid = fromId.entityUid ?? fromAccess.entityUid;
|
|
442
|
+
if (!sub && !entityUid)
|
|
443
|
+
return null;
|
|
444
|
+
return `${sub ?? ""}|${entityUid ?? ""}`;
|
|
445
|
+
}
|
|
446
|
+
/** Doctor/status line. Claims only — never token material. */
|
|
447
|
+
export function formatCognitoSubject(tokens) {
|
|
448
|
+
if (!tokens)
|
|
449
|
+
return "sub=- entityUid=-";
|
|
450
|
+
const fromId = cognitoSubjectFromToken(tokens.idToken);
|
|
451
|
+
const fromAccess = cognitoSubjectFromToken(tokens.accessToken);
|
|
452
|
+
return `sub=${fromId.sub ?? fromAccess.sub ?? "-"} entityUid=${fromId.entityUid ?? fromAccess.entityUid ?? "-"}`;
|
|
453
|
+
}
|
|
454
|
+
function readTokenFile(file) {
|
|
455
|
+
try {
|
|
456
|
+
if (!fs.existsSync(file))
|
|
457
|
+
return null;
|
|
458
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
459
|
+
if (typeof raw.accessToken !== "string" || typeof raw.idToken !== "string")
|
|
460
|
+
return null;
|
|
461
|
+
return raw;
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Person cache as stored on disk for `env`. Does not consult hq-cloud's
|
|
469
|
+
* in-memory fallback, which can surface a different session when the file
|
|
470
|
+
* is missing or unreadable.
|
|
471
|
+
*/
|
|
472
|
+
export function readPersonTokenFile(home = os.homedir(), env = process.env) {
|
|
473
|
+
return readTokenFile(personTokenCacheFile(home, env));
|
|
474
|
+
}
|
|
475
|
+
function restoreTokenFile(file, before) {
|
|
476
|
+
if (before) {
|
|
477
|
+
const dir = path.dirname(file);
|
|
478
|
+
if (!fs.existsSync(dir))
|
|
479
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
480
|
+
const tmp = path.join(dir, `.cognito-tokens.json.restore.${process.pid}`);
|
|
481
|
+
fs.writeFileSync(tmp, before, { mode: 0o600 });
|
|
482
|
+
fs.renameSync(tmp, file);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (fs.existsSync(file))
|
|
486
|
+
fs.unlinkSync(file);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Refresh the active person session and refuse a subject change.
|
|
490
|
+
*
|
|
491
|
+
* hq-cloud's `refreshTokens` saves as soon as Cognito responds, and on a
|
|
492
|
+
* cache miss or unreadable file it can refresh an in-memory session for a
|
|
493
|
+
* different subject (same app client). Snapshot the active file first; if
|
|
494
|
+
* the response subject differs, put the bytes back and leave them there.
|
|
495
|
+
*/
|
|
496
|
+
export async function refreshActivePersonTokens(cached, env = process.env) {
|
|
497
|
+
const file = personTokenCacheFile(os.homedir(), env);
|
|
498
|
+
const onDisk = readTokenFile(file);
|
|
499
|
+
const diskKey = cognitoSubjectKey(onDisk);
|
|
500
|
+
const cachedKey = cognitoSubjectKey(cached);
|
|
501
|
+
if (diskKey && cachedKey && diskKey !== cachedKey) {
|
|
502
|
+
const message = `refusing token refresh: cached subject ${formatCognitoSubject(cached)} ` +
|
|
503
|
+
`does not match active subject ${formatCognitoSubject(onDisk)}; token file left unchanged`;
|
|
504
|
+
console.error(message);
|
|
505
|
+
throw new IdentitySwapRefusal(message);
|
|
506
|
+
}
|
|
507
|
+
const activeTokens = diskKey && cachedKey && diskKey === cachedKey ? onDisk : cached;
|
|
508
|
+
const activeKey = cognitoSubjectKey(activeTokens);
|
|
509
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file) : null;
|
|
510
|
+
const refreshed = await refreshTokens(DEFAULT_COGNITO, activeTokens.refreshToken, activeTokens.accessToken);
|
|
511
|
+
const nextKey = cognitoSubjectKey(refreshed);
|
|
512
|
+
if (activeKey && nextKey && nextKey !== activeKey) {
|
|
513
|
+
restoreTokenFile(file, before);
|
|
514
|
+
const message = `refusing token refresh: refreshed subject ${formatCognitoSubject(refreshed)} ` +
|
|
515
|
+
`does not match active subject ${formatCognitoSubject(activeTokens)}; token file left unchanged`;
|
|
516
|
+
console.error(message);
|
|
517
|
+
throw new IdentitySwapRefusal(message);
|
|
518
|
+
}
|
|
519
|
+
return refreshed;
|
|
520
|
+
}
|
|
412
521
|
/** Derive actor kind from an ID token's custom:entity* claims (no verify). */
|
|
413
522
|
export function actorKindFromIdToken(idToken) {
|
|
414
523
|
const claims = decodeJwtPayload(idToken);
|
|
@@ -616,10 +725,12 @@ async function ensureCognitoTokenUnserialized(options) {
|
|
|
616
725
|
if (interactive) {
|
|
617
726
|
console.error(chalk.dim(" Refreshing expiring HQ session..."));
|
|
618
727
|
}
|
|
619
|
-
const refreshed = await
|
|
728
|
+
const refreshed = await refreshActivePersonTokens(cached);
|
|
620
729
|
return refreshed.accessToken;
|
|
621
730
|
}
|
|
622
731
|
catch (err) {
|
|
732
|
+
if (isIdentitySwapRefusal(err))
|
|
733
|
+
throw err;
|
|
623
734
|
// Transient/network/server errors stay person errors — never mint machine.
|
|
624
735
|
if (!isDefinitivePersonAuthRejection(err)) {
|
|
625
736
|
throw err;
|
|
@@ -676,10 +787,12 @@ async function ensureCognitoIdTokenUnserialized(options) {
|
|
|
676
787
|
if (interactive) {
|
|
677
788
|
console.error(chalk.dim(" Refreshing expiring HQ session..."));
|
|
678
789
|
}
|
|
679
|
-
const refreshed = await
|
|
790
|
+
const refreshed = await refreshActivePersonTokens(cached);
|
|
680
791
|
return refreshed.idToken;
|
|
681
792
|
}
|
|
682
793
|
catch (err) {
|
|
794
|
+
if (isIdentitySwapRefusal(err))
|
|
795
|
+
throw err;
|
|
683
796
|
if (!isDefinitivePersonAuthRejection(err)) {
|
|
684
797
|
throw err;
|
|
685
798
|
}
|
|
@@ -778,10 +891,13 @@ async function refreshCachedSessionUnserialized(options) {
|
|
|
778
891
|
return { refreshed: true };
|
|
779
892
|
}
|
|
780
893
|
try {
|
|
781
|
-
await
|
|
894
|
+
await refreshActivePersonTokens(cached);
|
|
782
895
|
return { refreshed: true };
|
|
783
896
|
}
|
|
784
897
|
catch (err) {
|
|
898
|
+
if (isIdentitySwapRefusal(err)) {
|
|
899
|
+
return { refreshed: false, reason: err.message };
|
|
900
|
+
}
|
|
785
901
|
// Transient/network/server refresh errors stay person failures.
|
|
786
902
|
if (!isDefinitivePersonAuthRejection(err)) {
|
|
787
903
|
return {
|
|
@@ -7,4 +7,22 @@
|
|
|
7
7
|
* result means "this is a normal error; handle it as usual (capture to Sentry)".
|
|
8
8
|
*/
|
|
9
9
|
export declare function environmentalFsErrorMessage(err: unknown): string | null;
|
|
10
|
+
export type FilesystemErrorCaptureContext = {
|
|
11
|
+
fingerprint: ["fs-error", string, string, string];
|
|
12
|
+
tags: Record<string, string>;
|
|
13
|
+
extra: {
|
|
14
|
+
fs_path: string;
|
|
15
|
+
fs_reason: string;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/** Return only the registered command path, never positional user data. */
|
|
19
|
+
export declare function hqCommand(argv?: readonly string[]): string;
|
|
20
|
+
/**
|
|
21
|
+
* Build stable Sentry context for known OS filesystem refusals. The failure is
|
|
22
|
+
* intentionally still captured: this is instrumentation for the OneDrive /
|
|
23
|
+
* antivirus / path-length hypotheses, not an expected-error suppression.
|
|
24
|
+
*/
|
|
25
|
+
export declare function filesystemErrorCaptureContext(err: unknown, argv?: readonly string[]): FilesystemErrorCaptureContext | null;
|
|
26
|
+
/** Plain operator guidance for a captured filesystem failure. */
|
|
27
|
+
export declare function filesystemErrorMessage(err: unknown): string | null;
|
|
10
28
|
//# sourceMappingURL=environmental-error.d.ts.map
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// src/utils/environmental-error.ts
|
|
2
2
|
//
|
|
3
3
|
// Classify errors that stem from the user's machine/filesystem state rather
|
|
4
|
-
// than an HQ code defect.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// full disk floods the issue tracker with identical, unfixable crash reports.
|
|
4
|
+
// than an HQ code defect. Full-disk conditions have a known remedy and remain
|
|
5
|
+
// quiet; permission and file-lock conditions remain captured with bounded
|
|
6
|
+
// attribution so the next occurrence can identify the real cause.
|
|
8
7
|
//
|
|
9
8
|
// HQ-CLI-2: `hq reindex` hit ENOSPC in the operation-lock temp-file write
|
|
10
9
|
// (`fs.openSync` → "ENOSPC: no space left on device, open") and the raw error
|
|
11
10
|
// propagated uncaught to the CLI's top-level handler, which captured it to
|
|
12
11
|
// Sentry and exited silently — 5 stack-trace crashes in 6 seconds, with no
|
|
13
12
|
// message telling the user their disk was full.
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { COMMAND_CATALOG } from "../command-catalog.generated.js";
|
|
16
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
17
|
+
import { redactErrorText } from "./redact-error-text.js";
|
|
14
18
|
/**
|
|
15
19
|
* Node errno codes for "the filesystem cannot accept this write" — purely
|
|
16
20
|
* environmental, never a code bug. Mapped to the message shown to the user.
|
|
@@ -20,6 +24,24 @@ const ENVIRONMENTAL_FS_CODES = {
|
|
|
20
24
|
EDQUOT: "Disk quota exceeded. Free up space (or raise your quota) and try again.",
|
|
21
25
|
EROFS: "The filesystem is read-only, so HQ can't write here. Check the mount/permissions and try again.",
|
|
22
26
|
};
|
|
27
|
+
const MAX_CAUSE_DEPTH = 4;
|
|
28
|
+
function errorRecord(value) {
|
|
29
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
30
|
+
}
|
|
31
|
+
function findErrorWithCode(err, codes) {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
let current = err;
|
|
34
|
+
for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth += 1) {
|
|
35
|
+
const record = errorRecord(current);
|
|
36
|
+
if (!record || seen.has(record))
|
|
37
|
+
return null;
|
|
38
|
+
seen.add(record);
|
|
39
|
+
if (typeof record.code === "string" && codes.has(record.code))
|
|
40
|
+
return record;
|
|
41
|
+
current = record.cause;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
23
45
|
/**
|
|
24
46
|
* If `err` is an environmental disk/quota/read-only filesystem error, return a
|
|
25
47
|
* short, user-facing message explaining it; otherwise return `null`.
|
|
@@ -29,9 +51,220 @@ const ENVIRONMENTAL_FS_CODES = {
|
|
|
29
51
|
* result means "this is a normal error; handle it as usual (capture to Sentry)".
|
|
30
52
|
*/
|
|
31
53
|
export function environmentalFsErrorMessage(err) {
|
|
32
|
-
const
|
|
33
|
-
|
|
54
|
+
const record = findErrorWithCode(err, new Set(Object.keys(ENVIRONMENTAL_FS_CODES)));
|
|
55
|
+
return record && typeof record.code === "string"
|
|
56
|
+
? ENVIRONMENTAL_FS_CODES[record.code] ?? null
|
|
57
|
+
: null;
|
|
58
|
+
}
|
|
59
|
+
const CAPTURED_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "UNKNOWN"]);
|
|
60
|
+
const CAPTURED_FS_SYSCALLS = new Set(["open", "symlink", "rename", "unlink"]);
|
|
61
|
+
function stringField(value) {
|
|
62
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
63
|
+
}
|
|
64
|
+
function pathFromMessage(message, syscall) {
|
|
65
|
+
const text = stringField(message);
|
|
66
|
+
if (!text)
|
|
67
|
+
return null;
|
|
68
|
+
const escapedSyscall = syscall.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
const match = new RegExp(`${escapedSyscall}\\s+['"]([^'"]+)['"]`, "i").exec(text);
|
|
70
|
+
return match?.[1] ?? null;
|
|
71
|
+
}
|
|
72
|
+
function findFilesystemError(err) {
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
let current = err;
|
|
75
|
+
for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth += 1) {
|
|
76
|
+
const record = errorRecord(current);
|
|
77
|
+
if (!record || seen.has(record))
|
|
78
|
+
return null;
|
|
79
|
+
seen.add(record);
|
|
80
|
+
const code = stringField(record.code);
|
|
81
|
+
const syscall = stringField(record.syscall);
|
|
82
|
+
if (code && syscall && CAPTURED_FS_CODES.has(code) && CAPTURED_FS_SYSCALLS.has(syscall)) {
|
|
83
|
+
const rawPath = stringField(record.path) ??
|
|
84
|
+
stringField(record.dest) ??
|
|
85
|
+
pathFromMessage(record.message, syscall) ??
|
|
86
|
+
"unknown";
|
|
87
|
+
return { record, code, syscall, rawPath };
|
|
88
|
+
}
|
|
89
|
+
current = record.cause;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function canonicalPath(rawPath) {
|
|
94
|
+
if (rawPath === "unknown")
|
|
95
|
+
return "";
|
|
96
|
+
const resolved = path.resolve(rawPath);
|
|
97
|
+
try {
|
|
98
|
+
return path.normalize(requireRealpath(resolved));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// The failed path may not exist. Resolving its parent still canonicalizes
|
|
102
|
+
// symlinked HQ roots without turning missing-file attribution into a throw.
|
|
103
|
+
const parent = path.dirname(resolved);
|
|
104
|
+
try {
|
|
105
|
+
return path.join(requireRealpath(parent), path.basename(resolved));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function requireRealpath(candidate) {
|
|
113
|
+
// Keep the filesystem read behind one tiny seam so the classifier remains
|
|
114
|
+
// safe when the failed target has already disappeared.
|
|
115
|
+
return fs.realpathSync.native(candidate);
|
|
116
|
+
}
|
|
117
|
+
function pathSegments(value) {
|
|
118
|
+
return value
|
|
119
|
+
.replaceAll("\\", "/")
|
|
120
|
+
.split("/")
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.map((segment) => segment.toLowerCase());
|
|
123
|
+
}
|
|
124
|
+
function pathKind(rawPath, resolvedPath) {
|
|
125
|
+
if (rawPath === "unknown")
|
|
126
|
+
return "other";
|
|
127
|
+
const segments = pathSegments(`${resolvedPath}/${rawPath}`);
|
|
128
|
+
if (segments.includes("node_modules") || segments.includes(".pnpm"))
|
|
129
|
+
return "install-tree";
|
|
130
|
+
if (segments.includes("worktree") || segments.includes("worktrees"))
|
|
131
|
+
return "worktree";
|
|
132
|
+
if (segments.includes("companies"))
|
|
133
|
+
return "companies";
|
|
134
|
+
if (segments.includes("workspace"))
|
|
135
|
+
return "workspace";
|
|
136
|
+
if (segments.includes("hq"))
|
|
137
|
+
return "hq-root";
|
|
138
|
+
return "other";
|
|
139
|
+
}
|
|
140
|
+
function syncedFolder(resolvedPath) {
|
|
141
|
+
if (!resolvedPath)
|
|
142
|
+
return "none";
|
|
143
|
+
const segments = pathSegments(resolvedPath);
|
|
144
|
+
// iCloud is identified from canonical path components, not a display-name
|
|
145
|
+
// substring: Desktop/Documents aliases can point into this container.
|
|
146
|
+
for (let i = 0; i + 1 < segments.length; i += 1) {
|
|
147
|
+
if (segments[i] === "library" && segments[i + 1] === "mobile documents")
|
|
148
|
+
return "icloud";
|
|
149
|
+
}
|
|
150
|
+
if (segments.some((segment) => segment === "onedrive" || segment.startsWith("onedrive -")))
|
|
151
|
+
return "onedrive";
|
|
152
|
+
if (segments.includes("dropbox"))
|
|
153
|
+
return "dropbox";
|
|
154
|
+
if (segments.some((segment) => segment === "google drive" || segment === "google-drive" || segment === "googledrive")) {
|
|
155
|
+
return "google-drive";
|
|
156
|
+
}
|
|
157
|
+
return "none";
|
|
158
|
+
}
|
|
159
|
+
function redactedPath(rawPath) {
|
|
160
|
+
if (rawPath === "unknown")
|
|
161
|
+
return "unknown";
|
|
162
|
+
const segments = rawPath.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
163
|
+
const tail = segments.slice(-2).join("/");
|
|
164
|
+
return redactErrorText(tail) || "unknown";
|
|
165
|
+
}
|
|
166
|
+
function fileAttributes(record, rawPath) {
|
|
167
|
+
const metadata = [record.fileAttributes, record.attributes]
|
|
168
|
+
.map(stringField)
|
|
169
|
+
.filter((value) => value !== null)
|
|
170
|
+
.join(" ")
|
|
171
|
+
.toLowerCase();
|
|
172
|
+
if (metadata.includes("offline") || record.offline === true)
|
|
173
|
+
return "offline";
|
|
174
|
+
if (metadata.includes("readonly") || metadata.includes("read-only") || record.readonly === true)
|
|
175
|
+
return "readonly";
|
|
176
|
+
const leaf = rawPath.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) ?? rawPath;
|
|
177
|
+
if (metadata.includes("hidden") || record.hidden === true || leaf.startsWith("."))
|
|
178
|
+
return "hidden";
|
|
179
|
+
return "none";
|
|
180
|
+
}
|
|
181
|
+
function findRegisteredCommand(commands, token) {
|
|
182
|
+
return commands.find((command) => command.name === token || command.aliases.includes(token)) ?? null;
|
|
183
|
+
}
|
|
184
|
+
function optionTakesValue(command, token) {
|
|
185
|
+
const optionName = token.split("=", 1)[0];
|
|
186
|
+
return command.options.some((option) => {
|
|
187
|
+
const names = option.flags
|
|
188
|
+
.split(",")
|
|
189
|
+
.map((flag) => flag.trim().split(/\s+/, 1)[0])
|
|
190
|
+
.filter((name) => Boolean(name));
|
|
191
|
+
return names.includes(optionName) && /(?:<[^>]+>|\[[^\]]+\])/.test(option.flags);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** Return only the registered command path, never positional user data. */
|
|
195
|
+
export function hqCommand(argv = process.argv) {
|
|
196
|
+
const commandPath = [];
|
|
197
|
+
let commands = COMMAND_CATALOG;
|
|
198
|
+
let currentCommand = null;
|
|
199
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
200
|
+
const token = argv[index];
|
|
201
|
+
if (!token || token === "--")
|
|
202
|
+
break;
|
|
203
|
+
if (token.startsWith("-")) {
|
|
204
|
+
if (!token.includes("=") && currentCommand && optionTakesValue(currentCommand, token)) {
|
|
205
|
+
index += 1;
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const command = findRegisteredCommand(commands, token);
|
|
210
|
+
if (!command)
|
|
211
|
+
break;
|
|
212
|
+
commandPath.push(command.name);
|
|
213
|
+
currentCommand = command;
|
|
214
|
+
commands = command.subcommands;
|
|
215
|
+
}
|
|
216
|
+
return commandPath.join(" ") || "unknown";
|
|
217
|
+
}
|
|
218
|
+
function pathLengthBucket(rawPath) {
|
|
219
|
+
if (rawPath.length < 200)
|
|
220
|
+
return "<200";
|
|
221
|
+
if (rawPath.length <= 260)
|
|
222
|
+
return "200-260";
|
|
223
|
+
return ">260";
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Build stable Sentry context for known OS filesystem refusals. The failure is
|
|
227
|
+
* intentionally still captured: this is instrumentation for the OneDrive /
|
|
228
|
+
* antivirus / path-length hypotheses, not an expected-error suppression.
|
|
229
|
+
*/
|
|
230
|
+
export function filesystemErrorCaptureContext(err, argv = process.argv) {
|
|
231
|
+
const details = findFilesystemError(err);
|
|
232
|
+
if (!details)
|
|
233
|
+
return null;
|
|
234
|
+
const resolvedPath = canonicalPath(details.rawPath);
|
|
235
|
+
const safePath = redactedPath(details.rawPath);
|
|
236
|
+
const command = hqCommand(argv);
|
|
237
|
+
return {
|
|
238
|
+
fingerprint: ["fs-error", details.code, details.syscall, command],
|
|
239
|
+
tags: {
|
|
240
|
+
fs_code: details.code,
|
|
241
|
+
fs_syscall: details.syscall,
|
|
242
|
+
path_kind: pathKind(details.rawPath, resolvedPath),
|
|
243
|
+
synced_folder: syncedFolder(resolvedPath),
|
|
244
|
+
path_len_bucket: pathLengthBucket(details.rawPath),
|
|
245
|
+
file_attributes: fileAttributes(details.record, details.rawPath),
|
|
246
|
+
hq_command: command,
|
|
247
|
+
platform: process.platform,
|
|
248
|
+
hq_cli_version: CLI_VERSION,
|
|
249
|
+
},
|
|
250
|
+
extra: {
|
|
251
|
+
fs_path: safePath,
|
|
252
|
+
fs_reason: redactErrorText(`${details.code} ${details.syscall} failed at ${safePath}`),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** Plain operator guidance for a captured filesystem failure. */
|
|
257
|
+
export function filesystemErrorMessage(err) {
|
|
258
|
+
const details = findFilesystemError(err);
|
|
259
|
+
if (!details)
|
|
34
260
|
return null;
|
|
35
|
-
|
|
261
|
+
const resolvedPath = canonicalPath(details.rawPath);
|
|
262
|
+
const safePath = redactedPath(details.rawPath);
|
|
263
|
+
const synced = syncedFolder(resolvedPath);
|
|
264
|
+
const permissionDenied = details.code === "EACCES" || details.code === "EPERM";
|
|
265
|
+
const remedy = permissionDenied && process.platform !== "win32" && synced === "none"
|
|
266
|
+
? "Check file and folder permissions, close the file, then retry."
|
|
267
|
+
: "Close the file or exclude its folder from cloud sync; if it persists, check permissions, then retry.";
|
|
268
|
+
return `Filesystem operation ${details.syscall} failed (${details.code}) at ${safePath}. ${remedy}`;
|
|
36
269
|
}
|
|
37
270
|
//# sourceMappingURL=environmental-error.js.map
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export interface ExpectedUserError extends Error {
|
|
6
6
|
expected: true;
|
|
7
7
|
}
|
|
8
|
+
/** Stamp an existing Error with the boundary's caller-owned disposition. */
|
|
9
|
+
export declare function markExpectedUserError<T extends Error>(err: T): T & ExpectedUserError;
|
|
8
10
|
/**
|
|
9
11
|
* True when `err` is an Error explicitly marked `expected === true`. A non-null
|
|
10
12
|
* result means the top-level handler should print `err.message` and skip Sentry
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
// ("Only a company owner can approve or reject queued integration writes"); the
|
|
17
17
|
// thrown error propagated to the top-level handler, which captured it to Sentry
|
|
18
18
|
// as an error-level crash and printed nothing to the user.
|
|
19
|
+
/** Stamp an existing Error with the boundary's caller-owned disposition. */
|
|
20
|
+
export function markExpectedUserError(err) {
|
|
21
|
+
return Object.assign(err, { expected: true });
|
|
22
|
+
}
|
|
19
23
|
/**
|
|
20
24
|
* True when `err` is an Error explicitly marked `expected === true`. A non-null
|
|
21
25
|
* result means the top-level handler should print `err.message` and skip Sentry
|
|
@@ -282,7 +282,10 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
282
282
|
// A resident mesh daemon keeps the old JS loaded after a global install.
|
|
283
283
|
// Service restart is best-effort and cannot turn a successful update red.
|
|
284
284
|
try {
|
|
285
|
-
const restart = (deps.restartDaemon ?? restartDaemonServiceIfInstalled
|
|
285
|
+
const restart = (deps.restartDaemon ?? (() => restartDaemonServiceIfInstalled({
|
|
286
|
+
env,
|
|
287
|
+
hqRoot: env.HQ_ROOT,
|
|
288
|
+
})))();
|
|
286
289
|
if (restart?.message)
|
|
287
290
|
console.error(chalk.yellow(` ${restart.message}`));
|
|
288
291
|
}
|