@indigoai-us/hq-cli 5.345.78 → 5.345.80
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 +22 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
- package/dist/lib/doctor/checks/sync-health.js +19 -0
- package/dist/lib/doctor/fix/apply.d.ts +20 -13
- package/dist/lib/doctor/fix/apply.js +73 -33
- package/dist/lib/doctor/fix/remediation.d.ts +5 -3
- package/dist/lib/doctor/fix/remediation.js +21 -7
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +1 -0
- package/dist/sentry-before-send.js +1 -0
- package/dist/utils/self-update.js +2 -2
- package/dist/utils/version-gate.d.ts +15 -0
- package/dist/utils/version-gate.js +80 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.80] — 2026-10-11
|
|
6
|
+
- The version gate no longer treats a pnpm global install that Node resolved
|
|
7
|
+
into `store/v11/links` as a local dependency, so it can self-update instead
|
|
8
|
+
of blocking commands such as `hq secrets exec`. First-party `hq-cli`
|
|
9
|
+
self-updates skip the 24h package-age quarantine; if that guard still
|
|
10
|
+
refuses the required build, the current CLI keeps running rather than
|
|
11
|
+
locking you out for the rest of the day.
|
|
12
|
+
- `hq doctor --fix` now refreshes a stale personal-files sync journal with
|
|
13
|
+
the same keep-conflicts pull already used for company journals. An old
|
|
14
|
+
leftover personal journal beside the current one is no longer reported
|
|
15
|
+
as an unrepaired health check.
|
|
16
|
+
|
|
17
|
+
## [5.345.79] — 2026-10-11
|
|
18
|
+
- `hq lanes` no longer fails every list, message, or admission command when
|
|
19
|
+
one lane still has leftover queue details from an older worker-profile wait.
|
|
20
|
+
Those leftover details are dropped on read, cleared when the queue reason
|
|
21
|
+
changes, and one unreadable lane is skipped so the rest of the host stays
|
|
22
|
+
usable.
|
|
23
|
+
- `hq doctor --fix` still refreshes a stale company sync journal when
|
|
24
|
+
`.claude/`, `.codex/`, or `.grok/` has uncommitted changes. Those dirty
|
|
25
|
+
files still block hook-file repairs unless you pass `--force`.
|
|
26
|
+
|
|
5
27
|
## [5.345.78] — 2026-10-10
|
|
6
28
|
- `hq lanes` no longer fails every list, message, or admission command when
|
|
7
29
|
one lane still has leftover queue details from an older worker-profile wait.
|
|
@@ -109,6 +109,8 @@ export interface SyncHealthDeps {
|
|
|
109
109
|
export declare function isSafeCompanySlug(slug: string): boolean;
|
|
110
110
|
/** True when this journal slug names a company rather than the personal tree. */
|
|
111
111
|
export declare function isCompanyJournalSlug(slug: string): boolean;
|
|
112
|
+
/** True when this journal slug names the personal tree or personal vault. */
|
|
113
|
+
export declare function isPersonalJournalSlug(slug: string): boolean;
|
|
112
114
|
/** Company slug from a `sync.journal.<slug>` check id, or null if not one. */
|
|
113
115
|
export declare function journalSlugFromCheckId(checkId: string): string | null;
|
|
114
116
|
/** The versions/sync check family. Registered in `createDefaultRegistry`. */
|
|
@@ -206,6 +206,10 @@ function journalSlugForCheckId(slug) {
|
|
|
206
206
|
export function isCompanyJournalSlug(slug) {
|
|
207
207
|
return !NON_COMPANY_JOURNAL_SLUGS.has(slug);
|
|
208
208
|
}
|
|
209
|
+
/** True when this journal slug names the personal tree or personal vault. */
|
|
210
|
+
export function isPersonalJournalSlug(slug) {
|
|
211
|
+
return NON_COMPANY_JOURNAL_SLUGS.has(slug);
|
|
212
|
+
}
|
|
209
213
|
/** Company slug from a `sync.journal.<slug>` check id, or null if not one. */
|
|
210
214
|
export function journalSlugFromCheckId(checkId) {
|
|
211
215
|
const match = /^sync\.journal\.(.+)$/.exec(checkId);
|
|
@@ -390,9 +394,24 @@ function journalAndManifestResults(deps) {
|
|
|
390
394
|
}
|
|
391
395
|
function journalResults(deps, journals) {
|
|
392
396
|
const nowMs = deps.now().getTime();
|
|
397
|
+
const hasPersonalVaultJournal = journals.some((entry) => entry.slug === PERSONAL_VAULT_SCOPE_SLUG);
|
|
393
398
|
return journals.map((entry) => {
|
|
394
399
|
const lastSync = entry.journal?.lastSync;
|
|
395
400
|
const checkId = `sync.journal.${journalSlugForCheckId(entry.slug)}`;
|
|
401
|
+
// The legacy `personal` shard and `__hq_personal_vault__` name the same
|
|
402
|
+
// tree. Once the vault journal exists, the old file is leftover
|
|
403
|
+
// bookkeeping: WARN would make `hq doctor --fix` look like it failed to
|
|
404
|
+
// repair a row it cannot refresh (a personal pull writes the vault
|
|
405
|
+
// journal, not this one).
|
|
406
|
+
if (entry.slug === PERSONAL_SCOPE_SLUG && hasPersonalVaultJournal) {
|
|
407
|
+
return {
|
|
408
|
+
status: "NA",
|
|
409
|
+
checkId,
|
|
410
|
+
reasonCode: "legacy-personal-journal",
|
|
411
|
+
target: entry.path,
|
|
412
|
+
message: "Sync journal 'personal' is unused on this machine — personal files now sync through the personal vault journal, so this leftover shard is not a live sync target.",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
396
415
|
// A company journal without a local tree is leftover membership state,
|
|
397
416
|
// not a live sync target. WARN would make `hq doctor --fix` look like it
|
|
398
417
|
// failed to repair something it was never going to touch (US-015 health
|
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
* wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
|
|
6
6
|
*
|
|
7
7
|
* 1. Dirty-tree refusal. If the working tree has uncommitted changes under
|
|
8
|
-
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses
|
|
9
|
-
* unless `--force`, so
|
|
10
|
-
* in-flight edits to the security layer.
|
|
8
|
+
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
|
|
9
|
+
* and exits non-zero unless `--force`, so those writes can never be
|
|
10
|
+
* tangled up with unrelated in-flight edits to the security layer.
|
|
11
|
+
* Repairs that do not write those trees (a stale-company `refresh-sync`
|
|
12
|
+
* pull) still run: the SessionStart health hook never passes `--force`,
|
|
13
|
+
* and blocking the pull left `sync.journal.<slug>` WARN after `--fix`.
|
|
11
14
|
* 2. Allowlist only. It repairs the classified safe classes — restore an
|
|
12
15
|
* execute bit, add a hook id to the gate profiles it is missing from,
|
|
13
|
-
* re-register a script present on disk, or pull a company
|
|
14
|
-
* tree exists but whose journal is stale — and NEVER
|
|
15
|
-
* or deletes a file. Classification is owned by
|
|
16
|
+
* re-register a script present on disk, or pull a company or personal
|
|
17
|
+
* vault whose local tree exists but whose journal is stale — and NEVER
|
|
18
|
+
* rewrites a hook body or deletes a file. Classification is owned by
|
|
19
|
+
* {@link deriveRemediation};
|
|
16
20
|
* a content-drift finding is manual-only and simply never appears in the
|
|
17
21
|
* fixable set.
|
|
18
22
|
* 3. Preview + confirmation. Every change is shown diff-style and requires
|
|
@@ -67,8 +71,9 @@ export interface ApplyFixesOptions {
|
|
|
67
71
|
runChecks?: (hqRoot: string) => Promise<CheckResult[]>;
|
|
68
72
|
/**
|
|
69
73
|
* Targeted pull used by the `refresh-sync` class. Default shells out to
|
|
70
|
-
* `hq sync pull --company <slug> --on-conflict keep
|
|
71
|
-
*
|
|
74
|
+
* `hq sync pull --company <slug> --on-conflict keep`, or
|
|
75
|
+
* `hq sync pull --personal --on-conflict keep` for personal journals.
|
|
76
|
+
* Tests inject a stub so `--fix` never hits the network.
|
|
72
77
|
*/
|
|
73
78
|
refreshSync?: (target: RefreshSyncTarget) => Promise<RefreshSyncResult>;
|
|
74
79
|
/**
|
|
@@ -81,12 +86,14 @@ export interface ApplyFixesOptions {
|
|
|
81
86
|
to: string;
|
|
82
87
|
} | null;
|
|
83
88
|
}
|
|
84
|
-
/** One company the `refresh-sync` class will pull. */
|
|
89
|
+
/** One company or personal vault the `refresh-sync` class will pull. */
|
|
85
90
|
export interface RefreshSyncTarget {
|
|
86
|
-
/** Company slug
|
|
91
|
+
/** Company slug, or `personal` for the personal vault. */
|
|
87
92
|
slug: string;
|
|
88
93
|
/** HQ root the pull writes into. */
|
|
89
94
|
hqRoot: string;
|
|
95
|
+
/** Pull the personal vault instead of `companies/<slug>`. */
|
|
96
|
+
personal?: boolean;
|
|
90
97
|
}
|
|
91
98
|
/** Outcome of one {@link ApplyFixesOptions.refreshSync} call. */
|
|
92
99
|
export interface RefreshSyncResult {
|
|
@@ -157,9 +164,9 @@ export interface ApplyFixesResult {
|
|
|
157
164
|
export declare function applyFixes(options: ApplyFixesOptions): Promise<ApplyFixesResult>;
|
|
158
165
|
/**
|
|
159
166
|
* Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
|
|
160
|
-
* `hq sync pull --company <slug>` against the tree
|
|
161
|
-
* Never a push, never interactive. A timeout or
|
|
162
|
-
* rather than thrown so later plans still run.
|
|
167
|
+
* `hq sync pull --company <slug>` (or `--personal`) against the tree
|
|
168
|
+
* `--fix` is repairing. Never a push, never interactive. A timeout or
|
|
169
|
+
* non-zero exit is reported rather than thrown so later plans still run.
|
|
163
170
|
*/
|
|
164
171
|
export declare function defaultRefreshSync(target: RefreshSyncTarget): Promise<RefreshSyncResult>;
|
|
165
172
|
/**
|
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
* wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
|
|
6
6
|
*
|
|
7
7
|
* 1. Dirty-tree refusal. If the working tree has uncommitted changes under
|
|
8
|
-
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses
|
|
9
|
-
* unless `--force`, so
|
|
10
|
-
* in-flight edits to the security layer.
|
|
8
|
+
* `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses hook-file repairs
|
|
9
|
+
* and exits non-zero unless `--force`, so those writes can never be
|
|
10
|
+
* tangled up with unrelated in-flight edits to the security layer.
|
|
11
|
+
* Repairs that do not write those trees (a stale-company `refresh-sync`
|
|
12
|
+
* pull) still run: the SessionStart health hook never passes `--force`,
|
|
13
|
+
* and blocking the pull left `sync.journal.<slug>` WARN after `--fix`.
|
|
11
14
|
* 2. Allowlist only. It repairs the classified safe classes — restore an
|
|
12
15
|
* execute bit, add a hook id to the gate profiles it is missing from,
|
|
13
|
-
* re-register a script present on disk, or pull a company
|
|
14
|
-
* tree exists but whose journal is stale — and NEVER
|
|
15
|
-
* or deletes a file. Classification is owned by
|
|
16
|
+
* re-register a script present on disk, or pull a company or personal
|
|
17
|
+
* vault whose local tree exists but whose journal is stale — and NEVER
|
|
18
|
+
* rewrites a hook body or deletes a file. Classification is owned by
|
|
19
|
+
* {@link deriveRemediation};
|
|
16
20
|
* a content-drift finding is manual-only and simply never appears in the
|
|
17
21
|
* fixable set.
|
|
18
22
|
* 3. Preview + confirmation. Every change is shown diff-style and requires
|
|
@@ -31,6 +35,7 @@
|
|
|
31
35
|
import { spawnSync } from "node:child_process";
|
|
32
36
|
import * as fs from "node:fs";
|
|
33
37
|
import * as path from "node:path";
|
|
38
|
+
import { isPersonalJournalSlug } from "../checks/sync-health.js";
|
|
34
39
|
import { createDefaultRegistry } from "../registry.js";
|
|
35
40
|
import { flattenFamilies } from "../report.js";
|
|
36
41
|
import { gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
|
|
@@ -107,20 +112,34 @@ export async function applyFixes(options) {
|
|
|
107
112
|
...over,
|
|
108
113
|
});
|
|
109
114
|
// 1. Dirty-tree refusal (AC8). Never write over uncommitted security-layer
|
|
110
|
-
// edits unless the user forces it.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
// edits unless the user forces it. Probe once; hook-file plans are
|
|
116
|
+
// dropped per pass so a stale-journal pull can still run.
|
|
117
|
+
const dirty = options.force
|
|
118
|
+
? []
|
|
119
|
+
: (options.dirtyCheck ?? uncommittedHookConfigChanges)(hqRoot);
|
|
120
|
+
const refuseDirtyHookWrites = (plans) => {
|
|
121
|
+
if (dirty.length === 0)
|
|
122
|
+
return plans;
|
|
123
|
+
const allowed = plans.filter((plan) => plan.relpath.length === 0);
|
|
124
|
+
if (allowed.length === plans.length)
|
|
125
|
+
return plans;
|
|
126
|
+
if (allowed.length === 0)
|
|
127
|
+
return "refuse";
|
|
128
|
+
writeErr(`hq doctor --fix: skipping hook-file repairs — uncommitted changes under ` +
|
|
129
|
+
`${HOOK_CONFIG_DIRS.join(", ")}. Refreshing sync journals still runs; ` +
|
|
130
|
+
`re-run with --force to repair hook files.\n`);
|
|
131
|
+
return allowed;
|
|
132
|
+
};
|
|
133
|
+
const refuseDirtyTree = () => {
|
|
134
|
+
writeErr(`hq doctor --fix: refusing to run — uncommitted changes under ` +
|
|
135
|
+
`${HOOK_CONFIG_DIRS.join(", ")}:\n` +
|
|
136
|
+
dirty.map((line) => ` ${line}`).join("\n") +
|
|
137
|
+
`\nCommit or stash them, or re-run with --force.\n`);
|
|
138
|
+
return empty({ exitCode: 1, refused: "dirty-tree" });
|
|
139
|
+
};
|
|
121
140
|
// Resolve the customer-visible manual-only output gate once per fix run.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
141
|
+
// Fail open to the pre-change output if the identity is absent or the
|
|
142
|
+
// registry lookup fails.
|
|
124
143
|
let showManualOnlyRemediations = false;
|
|
125
144
|
try {
|
|
126
145
|
showManualOnlyRemediations =
|
|
@@ -165,14 +184,29 @@ export async function applyFixes(options) {
|
|
|
165
184
|
keysThisPass.add(key);
|
|
166
185
|
plans.push(plan);
|
|
167
186
|
}
|
|
168
|
-
const
|
|
187
|
+
const seenRefreshTargets = new Set();
|
|
188
|
+
const refreshPlans = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-sync"), lastAttemptedRefreshSyncSlug).filter((plan) => {
|
|
189
|
+
if (seenRefreshTargets.has(plan.target))
|
|
190
|
+
return false;
|
|
191
|
+
seenRefreshTargets.add(plan.target);
|
|
192
|
+
return true;
|
|
193
|
+
}).slice(0, Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun));
|
|
169
194
|
return [
|
|
170
195
|
...plans.filter((plan) => plan.fixClass !== "refresh-sync"),
|
|
171
196
|
...refreshPlans,
|
|
172
197
|
];
|
|
173
198
|
};
|
|
174
199
|
for (let pass = 1; pass <= MAX_FIX_PASSES; pass += 1) {
|
|
175
|
-
const
|
|
200
|
+
const plannedThisPass = refuseDirtyHookWrites(buildPlans(findings));
|
|
201
|
+
if (plannedThisPass === "refuse") {
|
|
202
|
+
if (planned.length === 0)
|
|
203
|
+
return refuseDirtyTree();
|
|
204
|
+
write(`Pass ${pass}/${MAX_FIX_PASSES}: remaining repairs write hook files; ` +
|
|
205
|
+
`stopped because the tree is dirty.\n`);
|
|
206
|
+
refused = "dirty-tree";
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
const plans = plannedThisPass;
|
|
176
210
|
if (plans.length === 0)
|
|
177
211
|
break;
|
|
178
212
|
fixableCount += plans.length;
|
|
@@ -304,19 +338,21 @@ async function defaultRunChecks(hqRoot) {
|
|
|
304
338
|
}
|
|
305
339
|
/**
|
|
306
340
|
* Default `refresh-sync` repair: a bounded, pull-only, keep-conflicts
|
|
307
|
-
* `hq sync pull --company <slug>` against the tree
|
|
308
|
-
* Never a push, never interactive. A timeout or
|
|
309
|
-
* rather than thrown so later plans still run.
|
|
341
|
+
* `hq sync pull --company <slug>` (or `--personal`) against the tree
|
|
342
|
+
* `--fix` is repairing. Never a push, never interactive. A timeout or
|
|
343
|
+
* non-zero exit is reported rather than thrown so later plans still run.
|
|
310
344
|
*/
|
|
311
345
|
export function defaultRefreshSync(target) {
|
|
312
346
|
const hqBin = process.argv[1];
|
|
347
|
+
const selector = target.personal || isPersonalJournalSlug(target.slug)
|
|
348
|
+
? ["--personal"]
|
|
349
|
+
: ["--company", target.slug];
|
|
313
350
|
const args = hqBin
|
|
314
351
|
? [
|
|
315
352
|
hqBin,
|
|
316
353
|
"sync",
|
|
317
354
|
"pull",
|
|
318
|
-
|
|
319
|
-
target.slug,
|
|
355
|
+
...selector,
|
|
320
356
|
"--hq-root",
|
|
321
357
|
target.hqRoot,
|
|
322
358
|
"--on-conflict",
|
|
@@ -327,8 +363,7 @@ export function defaultRefreshSync(target) {
|
|
|
327
363
|
: [
|
|
328
364
|
"sync",
|
|
329
365
|
"pull",
|
|
330
|
-
|
|
331
|
-
target.slug,
|
|
366
|
+
...selector,
|
|
332
367
|
"--hq-root",
|
|
333
368
|
target.hqRoot,
|
|
334
369
|
"--on-conflict",
|
|
@@ -445,23 +480,28 @@ export function defaultRewriteBotNodePath(name) {
|
|
|
445
480
|
return null;
|
|
446
481
|
return rewriteBotDaemonNodePath(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot, hqBinary: "hq" }), { platform: "darwin" });
|
|
447
482
|
}
|
|
448
|
-
/** Pull one company to refresh a stale
|
|
483
|
+
/** Pull one company or the personal vault to refresh a stale journal. */
|
|
449
484
|
function planRefreshSync(hqRoot, result, rem, refreshSync) {
|
|
450
485
|
const slug = rem.fixTarget;
|
|
451
486
|
if (!slug)
|
|
452
487
|
return null;
|
|
488
|
+
const personal = isPersonalJournalSlug(slug);
|
|
453
489
|
let outcome;
|
|
454
490
|
return {
|
|
455
491
|
checkId: result.checkId,
|
|
456
492
|
fixClass: "refresh-sync",
|
|
457
493
|
target: slug,
|
|
458
494
|
relpath: "",
|
|
459
|
-
preview:
|
|
460
|
-
`
|
|
461
|
-
|
|
495
|
+
preview: personal
|
|
496
|
+
? ` personal vault\n` +
|
|
497
|
+
` ~ hq sync pull --personal --on-conflict keep\n` +
|
|
498
|
+
` refresh the stale personal sync journal (keeps local conflict copies)`
|
|
499
|
+
: ` companies/${slug}\n` +
|
|
500
|
+
` ~ hq sync pull --company ${slug} --on-conflict keep\n` +
|
|
501
|
+
` refresh the stale sync journal (keeps local conflict copies)`,
|
|
462
502
|
summary: `pull ${slug} to refresh its sync journal`,
|
|
463
503
|
apply: async () => {
|
|
464
|
-
const result = await refreshSync({ slug, hqRoot });
|
|
504
|
+
const result = await refreshSync({ slug, hqRoot, personal });
|
|
465
505
|
outcome = result.outcome;
|
|
466
506
|
},
|
|
467
507
|
refreshSyncOutcome: () => outcome,
|
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* never auto-registered: that is what created them.)
|
|
25
25
|
* - `refresh-sync` — run a targeted pull for a company journal that is
|
|
26
26
|
* stale or never-synced AND has a local
|
|
27
|
-
* `companies/<slug>` tree
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* `companies/<slug>` tree, or for a live personal /
|
|
28
|
+
* personal-vault journal. Pull-only, `--on-conflict
|
|
29
|
+
* keep`, no hook-file rewrite. Leftover shards
|
|
30
|
+
* without a local tree stay unfixed (the check reports
|
|
31
|
+
* NA).
|
|
30
32
|
*
|
|
31
33
|
* EVERYTHING else — content drift between platform copies, a missing script, a
|
|
32
34
|
* missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
|
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
* never auto-registered: that is what created them.)
|
|
25
25
|
* - `refresh-sync` — run a targeted pull for a company journal that is
|
|
26
26
|
* stale or never-synced AND has a local
|
|
27
|
-
* `companies/<slug>` tree
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* `companies/<slug>` tree, or for a live personal /
|
|
28
|
+
* personal-vault journal. Pull-only, `--on-conflict
|
|
29
|
+
* keep`, no hook-file rewrite. Leftover shards
|
|
30
|
+
* without a local tree stay unfixed (the check reports
|
|
31
|
+
* NA).
|
|
30
32
|
*
|
|
31
33
|
* EVERYTHING else — content drift between platform copies, a missing script, a
|
|
32
34
|
* missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
* content-drift finding is manual-only, because that boundary is load-bearing:
|
|
37
39
|
* deciding which of two diverged copies is correct needs human judgement.
|
|
38
40
|
*/
|
|
39
|
-
import { isCompanyJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
|
|
41
|
+
import { isCompanyJournalSlug, isPersonalJournalSlug, journalSlugFromCheckId, } from "../checks/sync-health.js";
|
|
40
42
|
/** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
|
|
41
43
|
const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
|
|
42
44
|
/**
|
|
@@ -117,10 +119,22 @@ export function deriveRemediation(result) {
|
|
|
117
119
|
command: remediation ?? `hq bot start ${target}`,
|
|
118
120
|
};
|
|
119
121
|
}
|
|
120
|
-
// refresh-sync — a company journal that is stale or never-synced
|
|
121
|
-
//
|
|
122
|
-
//
|
|
122
|
+
// refresh-sync — a company journal that is stale or never-synced, or a
|
|
123
|
+
// live personal / personal-vault journal. Leftover shards are NA at the
|
|
124
|
+
// check layer, so they never reach this classifier. A SessionStart
|
|
125
|
+
// `--fix --yes` pull is keep-conflicts and pull-only, matching the
|
|
126
|
+
// company repair: leaving personal journals WARN made the health hook
|
|
127
|
+
// report an unrepaired failure for a row doctor refused to touch.
|
|
123
128
|
const journalSlug = journalSlugFromCheckId(checkId);
|
|
129
|
+
if (journalSlug && isPersonalJournalSlug(journalSlug)) {
|
|
130
|
+
return {
|
|
131
|
+
autoFixable: true,
|
|
132
|
+
fixClass: "refresh-sync",
|
|
133
|
+
fixTarget: "personal",
|
|
134
|
+
action: "Pull the personal vault to refresh its sync journal (keeps local conflict copies).",
|
|
135
|
+
command: "hq sync pull --personal --on-conflict keep",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
124
138
|
if (journalSlug && isCompanyJournalSlug(journalSlug)) {
|
|
125
139
|
return {
|
|
126
140
|
autoFixable: true,
|
|
@@ -19,7 +19,7 @@ export declare const DOCTOR_STATUSES: readonly ["PASS", "FAIL", "WARN", "UNTESTE
|
|
|
19
19
|
*/
|
|
20
20
|
export type DoctorStatus = (typeof DOCTOR_STATUSES)[number];
|
|
21
21
|
/** Stable, privacy-safe diagnostics emitted by the sync health family. */
|
|
22
|
-
export declare const SYNC_REASON_CODES: readonly ["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", "realtime-lag-stuck", "receiver-permission-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
|
|
22
|
+
export declare const SYNC_REASON_CODES: readonly ["core-version-unavailable", "core-update-available", "no-local-tree", "legacy-personal-journal", "never-synced", "invalid-last-sync", "stale-threshold", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
|
|
23
23
|
export type SyncReasonCode = (typeof SYNC_REASON_CODES)[number];
|
|
24
24
|
/** Runtime type guard: true only for a member of the fixed vocabulary. */
|
|
25
25
|
export declare function isDoctorStatus(value: unknown): value is DoctorStatus;
|
package/dist/lib/doctor/types.js
CHANGED
|
@@ -65,7 +65,7 @@ import chalk from "chalk";
|
|
|
65
65
|
import { Sentry } from "../sentry.js";
|
|
66
66
|
import { isCliTelemetryDisabled } from "./cli-telemetry.js";
|
|
67
67
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
68
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, nonWritablePrefixNote, openInstallOutput, resolvePnpmPathInstall, pnpmUpdateEnv, pnpmInstalledVersion, performPnpmUpdate, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, UPDATE_RECURSION_GUARD_ENV, } from "./version-gate.js";
|
|
68
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, normalizeRunningInstall, nonWritablePrefixNote, openInstallOutput, resolvePnpmPathInstall, pnpmUpdateEnv, pnpmInstalledVersion, performPnpmUpdate, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, UPDATE_RECURSION_GUARD_ENV, } from "./version-gate.js";
|
|
69
69
|
import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
|
|
70
70
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
71
71
|
import { markLatestIneffective } from "./version-check.js";
|
|
@@ -926,7 +926,7 @@ async function attemptUpdateAndReexec(argv, flavor, known, deps, env) {
|
|
|
926
926
|
// actually running would stay exactly as stale as it started while the CLI
|
|
927
927
|
// reported success and re-exec'd into the same build. That is the shape of
|
|
928
928
|
// the 2026-09-02 gate loop — see isLocalDependencyInstall in version-gate.ts.
|
|
929
|
-
let install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
929
|
+
let install = normalizeRunningInstall((deps.resolveInstall ?? resolveRunningInstall)());
|
|
930
930
|
if (isLocalDependencyInstall(install)) {
|
|
931
931
|
console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
|
|
932
932
|
return { action: "skipped", latest };
|
|
@@ -31,6 +31,9 @@ import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
|
|
|
31
31
|
import { type UpdateLockHandle } from "./update-lock.js";
|
|
32
32
|
/** Prevent an update package manager's lifecycle child from starting another updater. */
|
|
33
33
|
export declare const UPDATE_RECURSION_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
|
|
34
|
+
/** First-party hq-cli is published by this org; the 24h pnpm quarantine must not
|
|
35
|
+
* block the version-gate from installing the server-required build. */
|
|
36
|
+
export declare const PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE: string;
|
|
34
37
|
export type VersionGatePhase = "preflight" | "lock_acquired" | "installer_running" | "verifying" | "rollback" | "complete" | "failed" | "unknown";
|
|
35
38
|
/** The last closed version-gate phase observed in this process. */
|
|
36
39
|
export declare function getVersionGatePhase(): VersionGatePhase;
|
|
@@ -86,6 +89,8 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
|
|
|
86
89
|
* Those layouts fall through to the ordinary npm-prefix/manual handling.
|
|
87
90
|
*/
|
|
88
91
|
export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
|
|
92
|
+
/** pnpm's content-addressed `store/vN/links/…` layout (not `store/vN/tmp` dlx). */
|
|
93
|
+
export declare function isPnpmContentStoreLinksPackageDir(pkgDir: string): boolean;
|
|
89
94
|
/** Whether the running package lives inside Bun's global install tree. */
|
|
90
95
|
export declare function isBunManagedPackageDir(pkgDir: string): boolean;
|
|
91
96
|
/**
|
|
@@ -164,6 +169,13 @@ export interface RunningInstall {
|
|
|
164
169
|
packageRoot: string | null;
|
|
165
170
|
}
|
|
166
171
|
export declare function resolveRunningInstall(): RunningInstall;
|
|
172
|
+
/**
|
|
173
|
+
* Repair a RunningInstall whose package root is a pnpm content store but whose
|
|
174
|
+
* manager was recorded as npm (the pre-fix classification of a realpath'd
|
|
175
|
+
* pnpm-global shim). Callers that inject an install snapshot must run this
|
|
176
|
+
* before the local-dependency hard stop, or they replay the 2420 lockout.
|
|
177
|
+
*/
|
|
178
|
+
export declare function normalizeRunningInstall(install: RunningInstall): RunningInstall;
|
|
167
179
|
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
168
180
|
export declare function resolveRunningManager(): InstallManager;
|
|
169
181
|
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
@@ -564,8 +576,11 @@ export declare const __test__: {
|
|
|
564
576
|
isLocalDependencyInstall: typeof isLocalDependencyInstall;
|
|
565
577
|
isLocalDependencyPackageDir: typeof isLocalDependencyPackageDir;
|
|
566
578
|
isNewerVersion: typeof isNewerVersion;
|
|
579
|
+
isPnpmContentStoreLinksPackageDir: typeof isPnpmContentStoreLinksPackageDir;
|
|
567
580
|
isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
|
|
568
581
|
isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
|
|
582
|
+
normalizeRunningInstall: typeof normalizeRunningInstall;
|
|
583
|
+
PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE: string;
|
|
569
584
|
isPrefixWritable: typeof isPrefixWritable;
|
|
570
585
|
nonWritablePrefixNote: typeof nonWritablePrefixNote;
|
|
571
586
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
@@ -49,6 +49,9 @@ const CLIENT_ID = "hq-cli";
|
|
|
49
49
|
const ENDPOINT_PATH = "/v1/client-version/check";
|
|
50
50
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
51
51
|
const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
|
|
52
|
+
/** First-party hq-cli is published by this org; the 24h pnpm quarantine must not
|
|
53
|
+
* block the version-gate from installing the server-required build. */
|
|
54
|
+
export const PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE = `--config.minimumReleaseAgeExclude=${CLI_NAME}`;
|
|
52
55
|
let currentVersionGatePhase = "unknown";
|
|
53
56
|
/** The last closed version-gate phase observed in this process. */
|
|
54
57
|
export function getVersionGatePhase() {
|
|
@@ -294,6 +297,29 @@ export function isPnpmManagedPackageDir(pkgDir) {
|
|
|
294
297
|
return true;
|
|
295
298
|
}
|
|
296
299
|
}
|
|
300
|
+
// Node realpaths pnpm's generated shim into the content-addressed store:
|
|
301
|
+
// $PNPM_HOME/store/v11/links/@indigoai-us/hq-cli/<ver>/<hash>/node_modules/@indigoai-us/hq-cli
|
|
302
|
+
// There is no `global/` segment after realpath, so the checks above miss it
|
|
303
|
+
// and the gate used to label this a "local dependency" (feedback 2420).
|
|
304
|
+
// `store/vN/tmp/` is pnpm dlx and must stay non-global.
|
|
305
|
+
if (isPnpmContentStoreLinksPackageDir(normalized))
|
|
306
|
+
return true;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
/** pnpm's content-addressed `store/vN/links/…` layout (not `store/vN/tmp` dlx). */
|
|
310
|
+
export function isPnpmContentStoreLinksPackageDir(pkgDir) {
|
|
311
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
312
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
313
|
+
for (let i = 0; i < segments.length - 2; i += 1) {
|
|
314
|
+
if (segments[i] !== "store")
|
|
315
|
+
continue;
|
|
316
|
+
if (!/^v\d+$/.test(segments[i + 1]))
|
|
317
|
+
continue;
|
|
318
|
+
if (segments[i + 2] === "tmp")
|
|
319
|
+
return false;
|
|
320
|
+
if (segments[i + 2] === "links")
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
297
323
|
return false;
|
|
298
324
|
}
|
|
299
325
|
/** Whether the running package lives inside Bun's global install tree. */
|
|
@@ -449,6 +475,21 @@ export function resolveRunningInstall() {
|
|
|
449
475
|
return { manager: "npm", prefix: null, packageRoot: null };
|
|
450
476
|
}
|
|
451
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Repair a RunningInstall whose package root is a pnpm content store but whose
|
|
480
|
+
* manager was recorded as npm (the pre-fix classification of a realpath'd
|
|
481
|
+
* pnpm-global shim). Callers that inject an install snapshot must run this
|
|
482
|
+
* before the local-dependency hard stop, or they replay the 2420 lockout.
|
|
483
|
+
*/
|
|
484
|
+
export function normalizeRunningInstall(install) {
|
|
485
|
+
if (!install.packageRoot)
|
|
486
|
+
return install;
|
|
487
|
+
if (install.manager === "pnpm")
|
|
488
|
+
return install;
|
|
489
|
+
if (!isPnpmManagedPackageDir(install.packageRoot))
|
|
490
|
+
return install;
|
|
491
|
+
return { manager: "pnpm", prefix: null, packageRoot: install.packageRoot };
|
|
492
|
+
}
|
|
452
493
|
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
453
494
|
export function resolveRunningManager() {
|
|
454
495
|
return resolveRunningInstall().manager;
|
|
@@ -652,11 +693,15 @@ export function derivePnpmHome(install) {
|
|
|
652
693
|
if (install.manager !== "pnpm" || !install.packageRoot)
|
|
653
694
|
return null;
|
|
654
695
|
const normalized = install.packageRoot.replace(/\\/g, "/");
|
|
655
|
-
const
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
696
|
+
const globalMarker = normalized.indexOf("/global/");
|
|
697
|
+
if (globalMarker !== -1) {
|
|
698
|
+
const home = normalized.slice(0, globalMarker);
|
|
699
|
+
return home || null;
|
|
700
|
+
}
|
|
701
|
+
const storeMatch = normalized.match(/^(.*)\/store\/v\d+\/links\//);
|
|
702
|
+
if (storeMatch)
|
|
703
|
+
return storeMatch[1] || null;
|
|
704
|
+
return null;
|
|
660
705
|
}
|
|
661
706
|
/**
|
|
662
707
|
* Environment for the pnpm self-update spawn. pnpm requires its configured
|
|
@@ -713,7 +758,7 @@ export function buildPrefixedInstallArgv(prefix) {
|
|
|
713
758
|
* the new version — which is the whole point of routing here instead of npm.
|
|
714
759
|
*/
|
|
715
760
|
export function buildPnpmInstallArgv(install) {
|
|
716
|
-
const args = ["add", "-g", LATEST_PACKAGE_SPEC];
|
|
761
|
+
const args = ["add", "-g", LATEST_PACKAGE_SPEC, PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE];
|
|
717
762
|
const dirs = install ? pnpmInstallDirs(install) : null;
|
|
718
763
|
if (dirs) {
|
|
719
764
|
args.push("--global-dir", dirs.globalDir, "--store-dir", dirs.storeDir);
|
|
@@ -732,8 +777,18 @@ export function pnpmInstallDirs(install) {
|
|
|
732
777
|
const normalized = install.packageRoot.replace(/\\/g, "/");
|
|
733
778
|
const global = normalized.match(/^(.*\/global\/\d+\/node_modules)(?:\/|$)|^(.*\/global\/v\d+\/[^/]+\/node_modules)(?:\/|$)/);
|
|
734
779
|
const globalNodeModules = global?.[1] ?? global?.[2];
|
|
735
|
-
if (!globalNodeModules)
|
|
736
|
-
|
|
780
|
+
if (!globalNodeModules) {
|
|
781
|
+
const storeMatch = normalized.match(/^(.*)\/store\/(v\d+)\/links\//);
|
|
782
|
+
if (!storeMatch)
|
|
783
|
+
return null;
|
|
784
|
+
const pnpmHome = storeMatch[1];
|
|
785
|
+
const storeVersion = storeMatch[2];
|
|
786
|
+
return {
|
|
787
|
+
globalDir: path.join(pnpmHome, "global"),
|
|
788
|
+
storeDir: path.join(pnpmHome, "store", storeVersion),
|
|
789
|
+
virtualStoreDir: path.join(pnpmHome, "global", storeVersion, "node_modules", ".pnpm"),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
737
792
|
const metadataPath = path.join(globalNodeModules, ".modules.yaml");
|
|
738
793
|
try {
|
|
739
794
|
const metadata = yaml.load(readFileSync(metadataPath, "utf8"));
|
|
@@ -966,6 +1021,10 @@ async function fetchVersionDecision(timeoutMs = FETCH_TIMEOUT_MS) {
|
|
|
966
1021
|
return null;
|
|
967
1022
|
}
|
|
968
1023
|
}
|
|
1024
|
+
function isReleaseAgeInstallFailure(result) {
|
|
1025
|
+
const text = `${result.detail ?? ""}\n${result.stdout ?? ""}`;
|
|
1026
|
+
return /minimumReleaseAge|minimum-release-age|published less than/i.test(text);
|
|
1027
|
+
}
|
|
969
1028
|
// The Windows spawn recipe (`buildSpawnPlan` + `quoteForWindowsShell`) lives in
|
|
970
1029
|
// ./windows-spawn.ts so the self-update path and the qmd spawn path share one
|
|
971
1030
|
// implementation. Imported at the top and re-exported here to keep this module's
|
|
@@ -1353,6 +1412,7 @@ function manualUpdateCommand(install, decision) {
|
|
|
1353
1412
|
* remains for callers that pass no handler.
|
|
1354
1413
|
*/
|
|
1355
1414
|
function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
|
|
1415
|
+
install = normalizeRunningInstall(install);
|
|
1356
1416
|
if (!isNewerVersion(decision.latestVersion, CLI_VERSION))
|
|
1357
1417
|
return;
|
|
1358
1418
|
const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${CLI_VERSION}).`);
|
|
@@ -1663,7 +1723,7 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
1663
1723
|
// server's `updateCommand` and any npm prefix are wrong for those layouts, so
|
|
1664
1724
|
// `resolveRunningInstall`
|
|
1665
1725
|
// already reports `prefix: null` there and neither is consulted below.
|
|
1666
|
-
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
1726
|
+
const install = normalizeRunningInstall((deps.resolveInstall ?? resolveRunningInstall)());
|
|
1667
1727
|
// Loop protection #1 — nothing here can update a copy this process does not
|
|
1668
1728
|
// own. Installing globally would leave the local copy that is actually
|
|
1669
1729
|
// running stale, so the gate would fire again on the very next invocation.
|
|
@@ -1886,6 +1946,13 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
1886
1946
|
console.error(chalk.dim(" Continuing on the current hq-cli version."));
|
|
1887
1947
|
return "continue";
|
|
1888
1948
|
}
|
|
1949
|
+
// A 24h pnpm quarantine that still blocks first-party hq-cli must not
|
|
1950
|
+
// lock the user out of secrets (or any other command) for the rest of
|
|
1951
|
+
// that window. Warn and run the current CLI — same as a timed-out updater.
|
|
1952
|
+
if (isReleaseAgeInstallFailure(result)) {
|
|
1953
|
+
console.error(chalk.dim(" The package manager refused a build newer than its 24h install guard; continuing on the current hq-cli version."));
|
|
1954
|
+
return "continue";
|
|
1955
|
+
}
|
|
1889
1956
|
return noticeFlush ? noticeFlush.then(() => 75) : 75;
|
|
1890
1957
|
}
|
|
1891
1958
|
// Read-your-writes: a "successful" install into the npm prefix does not
|
|
@@ -1914,7 +1981,7 @@ export async function enforceVersionGate(onUpdateRecommended, options = {}) {
|
|
|
1914
1981
|
return "continue";
|
|
1915
1982
|
// Resolved once, here, so the up-to-date case never pays for the walk and
|
|
1916
1983
|
// neither downstream path repeats it.
|
|
1917
|
-
const install = resolveRunningInstall();
|
|
1984
|
+
const install = normalizeRunningInstall(resolveRunningInstall());
|
|
1918
1985
|
if (decision.updateRequired) {
|
|
1919
1986
|
// Load the staged installer only when an npm prefix is resolved. The version
|
|
1920
1987
|
// gate is imported during every CLI startup, so keep the filesystem-heavy
|
|
@@ -1985,8 +2052,11 @@ export const __test__ = {
|
|
|
1985
2052
|
isLocalDependencyInstall,
|
|
1986
2053
|
isLocalDependencyPackageDir,
|
|
1987
2054
|
isNewerVersion,
|
|
2055
|
+
isPnpmContentStoreLinksPackageDir,
|
|
1988
2056
|
isPnpmManagedPackageDir,
|
|
1989
2057
|
isPnpmVirtualStorePackageDir,
|
|
2058
|
+
normalizeRunningInstall,
|
|
2059
|
+
PNPM_FIRST_PARTY_RELEASE_AGE_EXCLUDE,
|
|
1990
2060
|
isPrefixWritable,
|
|
1991
2061
|
nonWritablePrefixNote,
|
|
1992
2062
|
npmPrefixFromPackageDir,
|