@indigoai-us/hq-cli 5.261.0 → 5.263.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 +21 -0
- package/dist/lib/checkpoint-ontology/apply.js +15 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +13 -0
- package/dist/lib/checkpoint-ontology/plan.js +13 -0
- package/dist/lib/doctor/checks/runtime-health.d.ts +2 -0
- package/dist/lib/doctor/checks/runtime-health.js +7 -4
- package/dist/lib/lanes/manager-status-gate.js +5 -1
- package/dist/lib/lanes/outpost-pruner-install.js +12 -1
- package/dist/lib/lanes/outpost-worktree-pruner-flag.js +11 -1
- package/dist/lib/lanes/registry-flag.d.ts +1 -0
- package/dist/lib/lanes/registry-flag.js +15 -0
- package/dist/lib/lanes/watcher.js +5 -1
- package/dist/lib/mesh/api.d.ts +2 -2
- package/dist/lib/mesh/api.js +21 -8
- package/dist/utils/version-gate.d.ts +6 -3
- package/dist/utils/version-gate.js +143 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.263.0] — 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The version gate compares offers with the running CLI and recognizes
|
|
10
|
+
generated global shims before reporting a PATH shadow.
|
|
11
|
+
|
|
12
|
+
## [5.262.0] — 2026-09-28
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- The `checkpoint.ontology.concept-project-link` flag (off by default) lets
|
|
17
|
+
checkpoint ontology link a knowledge concept whose alias is ambiguous to the
|
|
18
|
+
one project with the same canonical name, such as HQ. It writes nothing.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `hq doctor --live-runtimes` uses a Windows `.cmd` shim only when the sibling file exists.
|
|
23
|
+
- Flag reads validate company UIDs before registry requests while preserving local
|
|
24
|
+
override precedence; Work Mesh ignores malformed UID overrides.
|
|
25
|
+
|
|
5
26
|
## [5.261.0] — 2026-09-28
|
|
6
27
|
|
|
7
28
|
### Fixed
|
|
@@ -370,6 +370,21 @@ export function applyOntologyRun(options) {
|
|
|
370
370
|
}
|
|
371
371
|
continue;
|
|
372
372
|
}
|
|
373
|
+
if (resolved.tier === "ambiguous_alias") {
|
|
374
|
+
// checkpoint.ontology.concept-project-link: a knowledge concept whose name is the
|
|
375
|
+
// canonical name of exactly one project links to that project. Nothing is written.
|
|
376
|
+
// Like the tier 1 and 2 links above, it runs before the stopword guard,
|
|
377
|
+
// which only stops new entities.
|
|
378
|
+
const projects = plan.concept_project_link === true && input.kind === "knowledge" && candidate.type === "concept"
|
|
379
|
+
? [...(index.canonical.get(entityKey("project", normalizeEntityName(canonical, "project"))) ?? [])]
|
|
380
|
+
: [];
|
|
381
|
+
if (projects.length === 1) {
|
|
382
|
+
const linked = index.byRel.get(projects[0]);
|
|
383
|
+
report.matched += 1;
|
|
384
|
+
report.links.push({ input: candidate.input, action: "link", type: linked.type, canonical_name: linked.canonical_name, path: projects[0] });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
373
388
|
// A generic word never becomes a new entity, whatever else it collides with.
|
|
374
389
|
if (isRejectedName(normalizeEntityName(canonical, candidate.type))) {
|
|
375
390
|
note(report.rejected, "stopword");
|
|
@@ -78,6 +78,17 @@ export declare const FIRST_NAME_PEOPLE_FLAG = "checkpoint.ontology.first-name-pe
|
|
|
78
78
|
* to 0.803 and cost recall 0.023 on a replay of six runs.
|
|
79
79
|
*/
|
|
80
80
|
export declare const SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG = "checkpoint.ontology.skip-ambiguous-concept-links";
|
|
81
|
+
/**
|
|
82
|
+
* A knowledge concept candidate whose name is an alias of more than one
|
|
83
|
+
* entity, and which apply would defer as `ambiguous_alias`, links instead to
|
|
84
|
+
* the one project whose canonical name it is (`HQ` typed as a concept beside
|
|
85
|
+
* `project/HQ` and several concepts aliased `HQ`). The plan carries
|
|
86
|
+
* `concept_project_link: true`. CO-51: over six runs of the shipping config
|
|
87
|
+
* the model typed HQ as a concept in some runs and not others, and the
|
|
88
|
+
* deferral lost 61 gold links; a replay of this rule raised recall from 0.639
|
|
89
|
+
* to 0.709 at precision 0.747 to 0.731 (61 of the 63 added links are HQ).
|
|
90
|
+
*/
|
|
91
|
+
export declare const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-project-link";
|
|
81
92
|
export declare const PLAN_FILE = "ontology-plan.json";
|
|
82
93
|
export declare const CANDIDATES_FILE = "ontology-candidates.json";
|
|
83
94
|
export declare const REPORT_FILE = "ontology-report.json";
|
|
@@ -150,6 +161,8 @@ export interface OntologyPlan {
|
|
|
150
161
|
first_name_people?: true;
|
|
151
162
|
/** Set by checkpoint.ontology.skip-ambiguous-concept-links: apply rejects a knowledge link to a concept whose name another type also holds. */
|
|
152
163
|
skip_ambiguous_concept_links?: true;
|
|
164
|
+
/** Set by checkpoint.ontology.concept-project-link: apply links a knowledge concept with an ambiguous alias to the one project of that canonical name. */
|
|
165
|
+
concept_project_link?: true;
|
|
153
166
|
inputs: PlanInput[];
|
|
154
167
|
/** The session's summary, decisions and learnings, for the `session` input. */
|
|
155
168
|
session_text: string | null;
|
|
@@ -82,6 +82,17 @@ export const FIRST_NAME_PEOPLE_FLAG = "checkpoint.ontology.first-name-people";
|
|
|
82
82
|
* to 0.803 and cost recall 0.023 on a replay of six runs.
|
|
83
83
|
*/
|
|
84
84
|
export const SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG = "checkpoint.ontology.skip-ambiguous-concept-links";
|
|
85
|
+
/**
|
|
86
|
+
* A knowledge concept candidate whose name is an alias of more than one
|
|
87
|
+
* entity, and which apply would defer as `ambiguous_alias`, links instead to
|
|
88
|
+
* the one project whose canonical name it is (`HQ` typed as a concept beside
|
|
89
|
+
* `project/HQ` and several concepts aliased `HQ`). The plan carries
|
|
90
|
+
* `concept_project_link: true`. CO-51: over six runs of the shipping config
|
|
91
|
+
* the model typed HQ as a concept in some runs and not others, and the
|
|
92
|
+
* deferral lost 61 gold links; a replay of this rule raised recall from 0.639
|
|
93
|
+
* to 0.709 at precision 0.747 to 0.731 (61 of the 63 added links are HQ).
|
|
94
|
+
*/
|
|
95
|
+
export const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-project-link";
|
|
85
96
|
export const PLAN_FILE = "ontology-plan.json";
|
|
86
97
|
export const CANDIDATES_FILE = "ontology-candidates.json";
|
|
87
98
|
export const REPORT_FILE = "ontology-report.json";
|
|
@@ -265,6 +276,7 @@ export async function planOntologyRun(options) {
|
|
|
265
276
|
const noLinkAliases = await options.readFlag(NO_LINK_ALIASES_FLAG, company);
|
|
266
277
|
const firstNamePeople = await options.readFlag(FIRST_NAME_PEOPLE_FLAG, company);
|
|
267
278
|
const skipAmbiguousConceptLinks = await options.readFlag(SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG, company);
|
|
279
|
+
const conceptProjectLink = await options.readFlag(CONCEPT_PROJECT_LINK_FLAG, company);
|
|
268
280
|
const touched = sessionTouched(hqRoot, company, payload.files);
|
|
269
281
|
// A first run takes its boundary from the entity tree, so the ledger is
|
|
270
282
|
// refreshed before merge-back moves any `last_updated` forward.
|
|
@@ -380,6 +392,7 @@ export async function planOntologyRun(options) {
|
|
|
380
392
|
...(noLinkAliases ? { link_aliases: false } : {}),
|
|
381
393
|
...(firstNamePeople ? { first_name_people: true } : {}),
|
|
382
394
|
...(skipAmbiguousConceptLinks ? { skip_ambiguous_concept_links: true } : {}),
|
|
395
|
+
...(conceptProjectLink ? { concept_project_link: true } : {}),
|
|
383
396
|
inputs,
|
|
384
397
|
session_text: sessionText,
|
|
385
398
|
pending_after_run: remaining,
|
|
@@ -76,6 +76,8 @@ export interface ProbeOutcome {
|
|
|
76
76
|
export interface RuntimeHealthDeps {
|
|
77
77
|
/** Resolve an executable against PATH; null when not found. */
|
|
78
78
|
resolveBinary?: (binary: string) => string | null;
|
|
79
|
+
/** Check whether a Windows command shim exists next to its resolved binary. */
|
|
80
|
+
fileExists?: (file: string) => boolean;
|
|
79
81
|
/** Execute one probe. The default spawns from the OS temp dir. */
|
|
80
82
|
execProbe?: (file: string, args: string[], timeoutMs: number) => Promise<ProbeOutcome>;
|
|
81
83
|
/** Per-probe timeout. Default {@link DEFAULT_PROBE_TIMEOUT_MS}. */
|
|
@@ -103,7 +103,7 @@ async function runRuntimeChecks(context, deps) {
|
|
|
103
103
|
const resolved = {
|
|
104
104
|
resolveBinary: deps.resolveBinary ?? resolveOnPath,
|
|
105
105
|
execProbe: deps.execProbe ??
|
|
106
|
-
((file, args, timeoutMs) => defaultExecProbe(file, args, timeoutMs, deps.platform ?? process.platform)),
|
|
106
|
+
((file, args, timeoutMs) => defaultExecProbe(file, args, timeoutMs, deps.platform ?? process.platform, deps.fileExists ?? fs.existsSync)),
|
|
107
107
|
timeoutMs: deps.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
|
|
108
108
|
live: context.liveRuntimes === true,
|
|
109
109
|
};
|
|
@@ -270,10 +270,13 @@ export function resolveOnPath(binary) {
|
|
|
270
270
|
* CLI that reads piped stdin in non-TTY mode — blocks forever on a pipe that
|
|
271
271
|
* never reaches EOF, which turns every probe into a timeout.
|
|
272
272
|
*/
|
|
273
|
-
function defaultExecProbe(file, args, timeoutMs, platform) {
|
|
273
|
+
function defaultExecProbe(file, args, timeoutMs, platform, fileExists) {
|
|
274
274
|
return new Promise((resolve) => {
|
|
275
|
-
const
|
|
276
|
-
|
|
275
|
+
const cmdSibling = `${file}.cmd`;
|
|
276
|
+
const command = platform === "win32" &&
|
|
277
|
+
!/\.(?:cmd|bat|exe|com)$/i.test(file) &&
|
|
278
|
+
fileExists(cmdSibling)
|
|
279
|
+
? cmdSibling
|
|
277
280
|
: file;
|
|
278
281
|
const plan = planCommandSpawn(command, args, platform);
|
|
279
282
|
const child = spawn(plan.cmd, plan.args, {
|
|
@@ -96,8 +96,12 @@ export async function resolveManagerStatusGateForLane(root, lane, options = {})
|
|
|
96
96
|
const reportError = options.onError ?? ((err) => logManagerStatusGateFailure(lane, err));
|
|
97
97
|
let enabled;
|
|
98
98
|
try {
|
|
99
|
+
const company = lane.company ?? lane.mesh_company ?? undefined;
|
|
99
100
|
enabled = await (options.readFlag ?? readLanesRegistryFlag)(MANAGER_STATUS_GATE_FLAG, {
|
|
100
|
-
company
|
|
101
|
+
company,
|
|
102
|
+
...(company ? {
|
|
103
|
+
companyUidSource: lane.company ? "lane.company.uid" : "lane.mesh_company.uid",
|
|
104
|
+
} : {}),
|
|
101
105
|
env: options.env,
|
|
102
106
|
});
|
|
103
107
|
}
|
|
@@ -161,11 +161,22 @@ export function installOutpostPrunerTimers(options) {
|
|
|
161
161
|
const nodeBinary = options.nodeBinary ?? resolveNodeBinary();
|
|
162
162
|
const hqBinary = options.hqBinary ?? resolveHqBinary();
|
|
163
163
|
const home = options.home ?? os.homedir();
|
|
164
|
-
const flagApiUrl = resolveFlagRegistryEndpoint(options.flagApiUrl);
|
|
165
164
|
const companyUid = (options.companyUid ?? process.env.HQ_COMPANY_UID ?? "").trim();
|
|
165
|
+
const companyUidSource = options.companyUid !== undefined
|
|
166
|
+
? "install option companyUid"
|
|
167
|
+
: "HQ_COMPANY_UID";
|
|
166
168
|
if (!companyUid) {
|
|
167
169
|
throw new TypeError("install requires HQ_COMPANY_UID for the timer services");
|
|
168
170
|
}
|
|
171
|
+
if (!companyUid.startsWith("cmp_")) {
|
|
172
|
+
process.stderr.write(`${JSON.stringify({
|
|
173
|
+
event: "outpost.worktree_pruner.install_invalid_company_uid",
|
|
174
|
+
source: companyUidSource,
|
|
175
|
+
expected_prefix: "cmp_",
|
|
176
|
+
})}\n`);
|
|
177
|
+
throw new TypeError(`${companyUidSource} must start with cmp_ before installing the timer services`);
|
|
178
|
+
}
|
|
179
|
+
const flagApiUrl = resolveFlagRegistryEndpoint(options.flagApiUrl);
|
|
169
180
|
const flagEnvironment = [
|
|
170
181
|
systemdEnvironment("HQ_FLAGS_API_URL", flagApiUrl),
|
|
171
182
|
systemdEnvironment("HQ_COMPANY_UID", companyUid),
|
|
@@ -22,7 +22,6 @@ function reportFlagFailure(error) {
|
|
|
22
22
|
}
|
|
23
23
|
export async function outpostWorktreePrunerEnabled(dependencies = {}) {
|
|
24
24
|
const env = dependencies.env ?? process.env;
|
|
25
|
-
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
26
25
|
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
27
26
|
if (!companyUid) {
|
|
28
27
|
process.stderr.write(`${JSON.stringify({
|
|
@@ -35,6 +34,17 @@ export async function outpostWorktreePrunerEnabled(dependencies = {}) {
|
|
|
35
34
|
})}\n`);
|
|
36
35
|
return OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK;
|
|
37
36
|
}
|
|
37
|
+
if (!companyUid.startsWith("cmp_")) {
|
|
38
|
+
process.stderr.write(`${JSON.stringify({
|
|
39
|
+
event: "outpost.worktree_pruner.invalid_company_uid",
|
|
40
|
+
flag: OUTPOST_WORKTREE_PRUNER_FLAG,
|
|
41
|
+
source: "HQ_COMPANY_UID",
|
|
42
|
+
expected_prefix: "cmp_",
|
|
43
|
+
fallback: OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK,
|
|
44
|
+
})}\n`);
|
|
45
|
+
return OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK;
|
|
46
|
+
}
|
|
47
|
+
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
38
48
|
const companySlug = env.HQ_COMPANY_SLUG?.trim() || "";
|
|
39
49
|
let client;
|
|
40
50
|
let enabled = OUTPOST_WORKTREE_PRUNER_FLAG_FALLBACK;
|
|
@@ -12,6 +12,7 @@ export interface LanesFlagClient {
|
|
|
12
12
|
export type LanesFlagClientFactory = (options: FlagClientOptions) => LanesFlagClient;
|
|
13
13
|
export interface ReadLanesFlagOptions {
|
|
14
14
|
company?: LaneCompanyRef | null;
|
|
15
|
+
companyUidSource?: string;
|
|
15
16
|
env?: NodeJS.ProcessEnv;
|
|
16
17
|
createClient?: LanesFlagClientFactory;
|
|
17
18
|
getToken?: () => string | Promise<string>;
|
|
@@ -25,6 +25,16 @@ function reportReadFailure(flagKey, error) {
|
|
|
25
25
|
fallback: false,
|
|
26
26
|
})}\n`);
|
|
27
27
|
}
|
|
28
|
+
function reportInvalidCompanyUid(flagKey, source) {
|
|
29
|
+
const suffix = flagKey.replace(/^lanes\./, "").replaceAll(/[^a-zA-Z0-9]+/g, "_");
|
|
30
|
+
process.stderr.write(`${JSON.stringify({
|
|
31
|
+
event: `lanes.${suffix}.invalid_company_uid`,
|
|
32
|
+
flag: flagKey,
|
|
33
|
+
source,
|
|
34
|
+
expected_prefix: "cmp_",
|
|
35
|
+
fallback: false,
|
|
36
|
+
})}\n`);
|
|
37
|
+
}
|
|
28
38
|
function cacheKey(flagKey, company, endpoint) {
|
|
29
39
|
return JSON.stringify([
|
|
30
40
|
flagKey,
|
|
@@ -43,6 +53,11 @@ export async function readLanesRegistryFlag(flagKey, options = {}) {
|
|
|
43
53
|
const localOverride = readLanesFlagLocalOverride(flagKey, env);
|
|
44
54
|
if (localOverride !== null)
|
|
45
55
|
return localOverride;
|
|
56
|
+
const companyUid = options.company?.uid?.trim();
|
|
57
|
+
if (companyUid && !companyUid.startsWith("cmp_")) {
|
|
58
|
+
reportInvalidCompanyUid(flagKey, options.companyUidSource ?? "company.uid");
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
46
61
|
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|
|
47
62
|
const key = cacheKey(flagKey, options.company, endpoint);
|
|
48
63
|
const cached = registryReads.get(key);
|
|
@@ -646,8 +646,12 @@ async function settleManagerStatusGate(hqRoot, lane, manifestTurnId, at, deps, l
|
|
|
646
646
|
const readFlag = deps.readManagerStatusGateFlag ?? readLanesRegistryFlag;
|
|
647
647
|
let enabled = false;
|
|
648
648
|
try {
|
|
649
|
+
const company = lane.company ?? lane.mesh_company ?? undefined;
|
|
649
650
|
enabled = await readFlag(MANAGER_STATUS_GATE_FLAG, {
|
|
650
|
-
company
|
|
651
|
+
company,
|
|
652
|
+
...(company ? {
|
|
653
|
+
companyUidSource: lane.company ? "lane.company.uid" : "lane.mesh_company.uid",
|
|
654
|
+
} : {}),
|
|
651
655
|
});
|
|
652
656
|
}
|
|
653
657
|
catch (err) {
|
package/dist/lib/mesh/api.d.ts
CHANGED
|
@@ -94,8 +94,8 @@ export declare function listActiveMembershipCompanies(token: string): Promise<Me
|
|
|
94
94
|
*
|
|
95
95
|
* Lists the caller's active companies via GET /membership/me and matches the
|
|
96
96
|
* requested slug (case-insensitive). Does **not** honour HQ_COMPANY_UID /
|
|
97
|
-
* HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_
|
|
98
|
-
*
|
|
97
|
+
* HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_` ref as proof
|
|
98
|
+
* of membership without an active membership row.
|
|
99
99
|
*
|
|
100
100
|
* Returns null when the caller is not an active member. Throws on transport /
|
|
101
101
|
* auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -63,14 +63,27 @@ export async function meshJson(token, path, init = {}) {
|
|
|
63
63
|
return data;
|
|
64
64
|
}
|
|
65
65
|
export async function resolveMeshCompany(token, company) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
66
|
+
const meshOverride = process.env.HQ_WORK_MESH_COMPANY_UID?.trim();
|
|
67
|
+
const companyOverride = process.env.HQ_COMPANY_UID?.trim();
|
|
68
|
+
const explicit = meshOverride || companyOverride;
|
|
69
|
+
const explicitSource = meshOverride ? "HQ_WORK_MESH_COMPANY_UID" : "HQ_COMPANY_UID";
|
|
70
|
+
if (explicit && explicit.startsWith("cmp_")) {
|
|
71
|
+
return { companyUid: explicit, companySlug: company || explicit };
|
|
72
|
+
}
|
|
73
|
+
if (explicit) {
|
|
74
|
+
// hq-pro accepts companyUid only with the cmp_ prefix (flags-resolve.ts:145-150).
|
|
75
|
+
// Ignore a malformed UID override so it cannot become a request identity.
|
|
76
|
+
process.stderr.write(`${JSON.stringify({
|
|
77
|
+
event: "work_mesh.invalid_company_uid_override",
|
|
78
|
+
source: explicitSource,
|
|
79
|
+
expected_prefix: "cmp_",
|
|
80
|
+
})}\n`);
|
|
81
|
+
}
|
|
82
|
+
if (company?.startsWith("cmp_")) {
|
|
71
83
|
return { companyUid: company, companySlug: company };
|
|
72
84
|
}
|
|
73
|
-
const
|
|
85
|
+
const companySlug = company?.trim();
|
|
86
|
+
const uid = await getCompanyUid(token, companySlug || undefined);
|
|
74
87
|
return { companyUid: uid, companySlug: company || uid };
|
|
75
88
|
}
|
|
76
89
|
/**
|
|
@@ -107,8 +120,8 @@ export async function listActiveMembershipCompanies(token) {
|
|
|
107
120
|
*
|
|
108
121
|
* Lists the caller's active companies via GET /membership/me and matches the
|
|
109
122
|
* requested slug (case-insensitive). Does **not** honour HQ_COMPANY_UID /
|
|
110
|
-
* HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_
|
|
111
|
-
*
|
|
123
|
+
* HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_` ref as proof
|
|
124
|
+
* of membership without an active membership row.
|
|
112
125
|
*
|
|
113
126
|
* Returns null when the caller is not an active member. Throws on transport /
|
|
114
127
|
* auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
|
|
@@ -371,9 +371,10 @@ export declare function probeCliVersion(bin: string): string | null;
|
|
|
371
371
|
* pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
|
|
372
372
|
* every updater on the box probes the ghost's version, sees it stale, installs
|
|
373
373
|
* into the npm prefix, reports success, and repeats forever — the production
|
|
374
|
-
* updater-war this fix exists for. Detect
|
|
375
|
-
*
|
|
376
|
-
* same prefix can change
|
|
374
|
+
* updater-war this fix exists for. Detect a stale PATH result here, identify a
|
|
375
|
+
* distinct package managed by a supported global manager before calling it a
|
|
376
|
+
* shadow, and never retry: no number of retries into the same prefix can change
|
|
377
|
+
* what PATH resolves.
|
|
377
378
|
*
|
|
378
379
|
* Advisory only — resolution failure warns once and continues; nothing in
|
|
379
380
|
* here may crash or block the CLI.
|
|
@@ -381,6 +382,8 @@ export declare function probeCliVersion(bin: string): string | null;
|
|
|
381
382
|
export declare function checkUpdateConvergence(targetVersion: string, deps?: {
|
|
382
383
|
resolveBin?: () => string | null;
|
|
383
384
|
probeVersion?: (bin: string) => string | null;
|
|
385
|
+
currentInstall?: () => RunningInstall;
|
|
386
|
+
packageRootForBin?: (bin: string) => string | null;
|
|
384
387
|
}): boolean;
|
|
385
388
|
/** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
|
|
386
389
|
interface EnforceUpdateDeps {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import { spawnSync } from "node:child_process";
|
|
31
31
|
import { installPathCompatibilityEnabled } from "../lib/install-path-compatibility-flag.js";
|
|
32
32
|
import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
|
|
33
|
-
import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
33
|
+
import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, realpathSync, readFileSync, rmSync, } from "node:fs";
|
|
34
34
|
import os from "node:os";
|
|
35
35
|
import path from "node:path";
|
|
36
36
|
import { fileURLToPath } from "node:url";
|
|
@@ -46,8 +46,8 @@ const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
|
|
|
46
46
|
function isOptedOut() {
|
|
47
47
|
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
48
48
|
}
|
|
49
|
-
function
|
|
50
|
-
let dir =
|
|
49
|
+
function findCliPackageRoot(startDir) {
|
|
50
|
+
let dir = startDir;
|
|
51
51
|
while (true) {
|
|
52
52
|
try {
|
|
53
53
|
const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf-8"));
|
|
@@ -63,6 +63,106 @@ function findRunningPackageRoot() {
|
|
|
63
63
|
dir = parent;
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
function findRunningPackageRoot() {
|
|
67
|
+
return findCliPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
|
|
68
|
+
}
|
|
69
|
+
/** Resolve an on-PATH executable back to the installed hq-cli package it loads. */
|
|
70
|
+
function findPackageRootForExecutable(bin) {
|
|
71
|
+
try {
|
|
72
|
+
const packageRoot = findCliPackageRoot(path.dirname(realpathSync(bin)));
|
|
73
|
+
if (packageRoot)
|
|
74
|
+
return packageRoot;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A missing or inaccessible executable may still be recoverable from the
|
|
78
|
+
// configured pnpm global layout below.
|
|
79
|
+
}
|
|
80
|
+
// pnpm emits shell/.cmd shims into its global bin directory. realpathSync()
|
|
81
|
+
// resolves symlinks, but a generated shim is a regular file, so walking up
|
|
82
|
+
// from its real path never reaches the package it invokes. Look up only the
|
|
83
|
+
// hq-cli package under the matching PNPM_HOME/global layout instead.
|
|
84
|
+
const binDir = path.dirname(bin);
|
|
85
|
+
const configuredHome = process.env.PNPM_HOME;
|
|
86
|
+
const inferredHome = path.basename(binDir).toLowerCase() === "bin"
|
|
87
|
+
? path.dirname(binDir)
|
|
88
|
+
: binDir;
|
|
89
|
+
const candidateHomes = [configuredHome, inferredHome].filter((home, index, homes) => Boolean(home) && homes.findIndex((candidate) => candidate === home) === index);
|
|
90
|
+
const home = candidateHomes.find((candidate) => samePath(binDir, candidate) ||
|
|
91
|
+
samePath(binDir, path.join(candidate, "bin")));
|
|
92
|
+
if (!home)
|
|
93
|
+
return null;
|
|
94
|
+
const globalRoot = path.join(home, "global");
|
|
95
|
+
let storeVersions;
|
|
96
|
+
try {
|
|
97
|
+
storeVersions = readdirSync(globalRoot);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const packageSegments = CLI_NAME.split("/");
|
|
103
|
+
for (const storeVersion of storeVersions) {
|
|
104
|
+
const storeRoot = path.join(globalRoot, storeVersion);
|
|
105
|
+
const packageRoots = [
|
|
106
|
+
path.join(storeRoot, "node_modules", ...packageSegments),
|
|
107
|
+
];
|
|
108
|
+
// pnpm v11 adds a hash between its global store version and node_modules.
|
|
109
|
+
// Its `vN` layout remains subject to the existing compatibility gate unless
|
|
110
|
+
// PNPM_HOME itself authorizes the global root via isPnpmManagedPackageDir.
|
|
111
|
+
if (/^v\d+$/.test(storeVersion)) {
|
|
112
|
+
try {
|
|
113
|
+
for (const hash of readdirSync(storeRoot)) {
|
|
114
|
+
packageRoots.push(path.join(storeRoot, hash, "node_modules", ...packageSegments));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A missing or unreadable version directory is simply not a candidate.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const candidate of packageRoots) {
|
|
122
|
+
const packageRoot = findCliPackageRoot(candidate);
|
|
123
|
+
if (packageRoot &&
|
|
124
|
+
samePath(packageRoot, candidate) &&
|
|
125
|
+
isPnpmManagedPackageDir(packageRoot, installPathCompatibilityEnabled())) {
|
|
126
|
+
return packageRoot;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function samePath(left, right) {
|
|
133
|
+
const canonical = (value) => {
|
|
134
|
+
try {
|
|
135
|
+
return realpathSync(value);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return path.resolve(value);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const a = canonical(left);
|
|
142
|
+
const b = canonical(right);
|
|
143
|
+
return process.platform === "win32"
|
|
144
|
+
? a.toLowerCase() === b.toLowerCase()
|
|
145
|
+
: a === b;
|
|
146
|
+
}
|
|
147
|
+
function globalInstallManagerForPath(bin, packageRoot) {
|
|
148
|
+
if (isPnpmManagedPackageDir(packageRoot, installPathCompatibilityEnabled())) {
|
|
149
|
+
return "pnpm";
|
|
150
|
+
}
|
|
151
|
+
if (isBunManagedPackageDir(packageRoot))
|
|
152
|
+
return "bun";
|
|
153
|
+
const prefix = npmPrefixFromPackageDir(packageRoot);
|
|
154
|
+
if (!prefix)
|
|
155
|
+
return null;
|
|
156
|
+
const normalizedRoot = packageRoot.replace(/\\/g, "/");
|
|
157
|
+
const normalizedPrefix = prefix.replace(/\\/g, "/").replace(/\/$/, "");
|
|
158
|
+
// POSIX global npm packages live under <prefix>/lib/node_modules. On
|
|
159
|
+
// Windows npm puts hq.cmd directly in <prefix>, so also verify the bin's
|
|
160
|
+
// directory against the prefix-derived shim location there.
|
|
161
|
+
const posixGlobal = normalizedRoot.includes("/lib/node_modules/");
|
|
162
|
+
const windowsGlobal = process.platform === "win32" &&
|
|
163
|
+
samePath(path.dirname(bin), normalizedPrefix);
|
|
164
|
+
return posixGlobal || windowsGlobal ? "npm" : null;
|
|
165
|
+
}
|
|
66
166
|
export function npmPrefixFromPackageDir(pkgDir) {
|
|
67
167
|
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
68
168
|
const segments = normalized.split("/");
|
|
@@ -768,7 +868,9 @@ function manualUpdateCommand(install, decision) {
|
|
|
768
868
|
* remains for callers that pass no handler.
|
|
769
869
|
*/
|
|
770
870
|
function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
|
|
771
|
-
|
|
871
|
+
if (!isNewerVersion(decision.latestVersion, CLI_VERSION))
|
|
872
|
+
return;
|
|
873
|
+
const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${CLI_VERSION}).`);
|
|
772
874
|
console.error(msg);
|
|
773
875
|
// A local copy is not updated by any global command. Printing the server's
|
|
774
876
|
// npm-shaped one would send the user to install a second copy that this
|
|
@@ -881,9 +983,10 @@ export function probeCliVersion(bin) {
|
|
|
881
983
|
* pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
|
|
882
984
|
* every updater on the box probes the ghost's version, sees it stale, installs
|
|
883
985
|
* into the npm prefix, reports success, and repeats forever — the production
|
|
884
|
-
* updater-war this fix exists for. Detect
|
|
885
|
-
*
|
|
886
|
-
* same prefix can change
|
|
986
|
+
* updater-war this fix exists for. Detect a stale PATH result here, identify a
|
|
987
|
+
* distinct package managed by a supported global manager before calling it a
|
|
988
|
+
* shadow, and never retry: no number of retries into the same prefix can change
|
|
989
|
+
* what PATH resolves.
|
|
887
990
|
*
|
|
888
991
|
* Advisory only — resolution failure warns once and continues; nothing in
|
|
889
992
|
* here may crash or block the CLI.
|
|
@@ -903,12 +1006,6 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
|
|
|
903
1006
|
}
|
|
904
1007
|
if (reported === targetVersion)
|
|
905
1008
|
return true; // converged — the normal case
|
|
906
|
-
console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
|
|
907
|
-
`a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
|
|
908
|
-
"or the updater will loop forever."));
|
|
909
|
-
// The warning above fires for ANY mismatch — something other than the copy
|
|
910
|
-
// we just wrote is winning PATH resolution, which is worth saying either
|
|
911
|
-
// way. But only a STALE result disproves convergence.
|
|
912
1009
|
//
|
|
913
1010
|
// The install runs `@latest`, not `@<targetVersion>`, so it can legitimately
|
|
914
1011
|
// land a version NEWER than the `latestVersion` the gate was handed: the npm
|
|
@@ -923,6 +1020,36 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
|
|
|
923
1020
|
semver.gte(reportedSemver, targetSemver)) {
|
|
924
1021
|
return true;
|
|
925
1022
|
}
|
|
1023
|
+
const runningInstall = (deps.currentInstall ?? resolveRunningInstall)();
|
|
1024
|
+
const packageRoot = (deps.packageRootForBin ?? findPackageRootForExecutable)(bin);
|
|
1025
|
+
const manager = packageRoot &&
|
|
1026
|
+
runningInstall.packageRoot &&
|
|
1027
|
+
!samePath(packageRoot, runningInstall.packageRoot)
|
|
1028
|
+
? globalInstallManagerForPath(bin, packageRoot)
|
|
1029
|
+
: null;
|
|
1030
|
+
let detail;
|
|
1031
|
+
if (manager) {
|
|
1032
|
+
const removeCommand = manager === "pnpm"
|
|
1033
|
+
? `pnpm remove -g ${CLI_NAME}`
|
|
1034
|
+
: manager === "bun"
|
|
1035
|
+
? `bun remove -g ${CLI_NAME}`
|
|
1036
|
+
: `npm uninstall -g ${CLI_NAME}`;
|
|
1037
|
+
let realPath = bin;
|
|
1038
|
+
try {
|
|
1039
|
+
realPath = realpathSync(bin);
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
// Keep the shell-resolved path when a platform shim is not canonicalizable.
|
|
1043
|
+
}
|
|
1044
|
+
detail =
|
|
1045
|
+
`PATH resolves ${realPath} at version ${reported} (manager: ${manager}); ` +
|
|
1046
|
+
`that second hq-cli install is shadowing the managed one. Remove it with \`${removeCommand}\`.`;
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
detail =
|
|
1050
|
+
`PATH resolves ${bin} at version ${reported}, but no second managed hq-cli install was confirmed.`;
|
|
1051
|
+
}
|
|
1052
|
+
console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but did not converge: ${detail}`));
|
|
926
1053
|
return false;
|
|
927
1054
|
}
|
|
928
1055
|
catch {
|
|
@@ -950,7 +1077,7 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
|
|
|
950
1077
|
* gate returns and lets the user's command run on the current version.
|
|
951
1078
|
*/
|
|
952
1079
|
function enforceUpdateRequired(decision, deps = {}) {
|
|
953
|
-
const banner = chalk.red.bold(`✗ hq-cli ${
|
|
1080
|
+
const banner = chalk.red.bold(`✗ hq-cli ${CLI_VERSION} is below the minimum required version (${decision.minVersion}).`);
|
|
954
1081
|
console.error(banner);
|
|
955
1082
|
if (decision.message)
|
|
956
1083
|
console.error(chalk.dim(` ${decision.message}`));
|
|
@@ -976,8 +1103,8 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
976
1103
|
// stale dist-tag, a bad cache, a misconfigured pin), reinstalling it on every
|
|
977
1104
|
// invocation and announcing success each time is strictly worse than saying
|
|
978
1105
|
// so once.
|
|
979
|
-
if (!isNewerVersion(decision.latestVersion,
|
|
980
|
-
console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the
|
|
1106
|
+
if (!isNewerVersion(decision.latestVersion, CLI_VERSION)) {
|
|
1107
|
+
console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the running ${CLI_VERSION}.`));
|
|
981
1108
|
console.error(chalk.dim(" This is a server-side or registry problem, not a local one — reinstalling would loop without ever converging."));
|
|
982
1109
|
process.exit(75);
|
|
983
1110
|
}
|