@indigoai-us/hq-cli 5.109.7 → 5.109.10
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 +39 -0
- package/dist/commands/agents.d.ts +26 -2
- package/dist/commands/agents.js +139 -14
- package/dist/lib/workers-registry/index.js +27 -1
- package/dist/startup-registration.d.ts +2 -0
- package/dist/startup-registration.js +14 -3
- package/dist/utils/hq-roots.d.ts +25 -0
- package/dist/utils/hq-roots.js +67 -0
- package/dist/utils/incomplete-install-error.d.ts +45 -8
- package/dist/utils/incomplete-install-error.js +168 -42
- package/dist/utils/install-tree-torn.d.ts +37 -11
- package/dist/utils/install-tree-torn.js +147 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.10] — 2026-09-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- A global reinstall that tears the install tree out mid-startup now recovers
|
|
10
|
+
even when the module that vanished is a RELATIVE sibling (for example
|
|
11
|
+
`@babel/runtime`'s `./typeof.js`, reached through the mqtt → worker-timers
|
|
12
|
+
chain). The torn-install classifier reduced a relative CJS specifier to the
|
|
13
|
+
package name `.`, producing a readiness probe that could never turn true — so
|
|
14
|
+
an affected command burned the entire 90-second settle budget, never re-ran on
|
|
15
|
+
the settled tree, and reported an error instead of recovering. Relative
|
|
16
|
+
specifiers are now re-resolved the way Node's CJS resolver does — from the
|
|
17
|
+
importer's directory, trying the file, its `.js`/`.json`/`.node` forms, and the
|
|
18
|
+
directory forms — so the command waits only as long as the reinstall actually
|
|
19
|
+
takes and then re-execs once on the healed tree. A genuinely missing sibling
|
|
20
|
+
still surfaces exactly one error naming the resolved path (Sentry HQ-CLI-1R).
|
|
21
|
+
|
|
22
|
+
## [5.109.8] — 2026-09-11
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- A background reinstall no longer turns into a crash report (HQ-CLI-1M,
|
|
27
|
+
HQ-CLI-1N). When another program reinstalls hq globally while a command is
|
|
28
|
+
running — the box's own update timer, the desktop app, or a second `hq` — it
|
|
29
|
+
renames hq's files aside and rewrites them file by file over a few seconds, and
|
|
30
|
+
a command already running can try to load a file that vanished in that window.
|
|
31
|
+
hq already knew to treat that as "your install was mid-update, not an hq bug"
|
|
32
|
+
and print a re-run/reinstall note instead of a crash, but it could only do so
|
|
33
|
+
while it could still find its own install directory on disk — which, in this
|
|
34
|
+
exact situation, it usually could not, because that directory is what just got
|
|
35
|
+
renamed away. That one blind spot silenced the recovery for two different crash
|
|
36
|
+
shapes: the ESM-loader `ENOENT` seen in production (HQ-CLI-1M) and a lazy
|
|
37
|
+
CommonJS `require('./sibling')` from a bundled dependency that resolves after
|
|
38
|
+
the tear (HQ-CLI-1N). hq now locates its own directory without reading the
|
|
39
|
+
disk, so the mid-update case is recognized for both: a registration-time tear
|
|
40
|
+
waits for the install to settle and re-runs once, and a tear anywhere else
|
|
41
|
+
prints the reinstall note. A genuine packaging fault in hq's own shipped files
|
|
42
|
+
(a miss under the install's own `dist/` or `assets/`) is still reported.
|
|
43
|
+
|
|
5
44
|
## [5.109.7] — 2026-09-10
|
|
6
45
|
|
|
7
46
|
### Fixed
|
|
@@ -37,6 +37,28 @@ export declare const VALID_PROVIDERS: Set<string>;
|
|
|
37
37
|
export declare const VALID_AUTH_MODES: Set<string>;
|
|
38
38
|
/** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
|
|
39
39
|
export declare const VALID_AGENT_SIZE_KEYS: Set<string>;
|
|
40
|
+
/**
|
|
41
|
+
* `hq agents create` exits with this distinct status when payment is required.
|
|
42
|
+
* Scripts can distinguish the expected upgrade state from an ordinary command
|
|
43
|
+
* failure without parsing human output.
|
|
44
|
+
*/
|
|
45
|
+
export declare const AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE = 3;
|
|
46
|
+
export type AgentCreatePlanLimit = Readonly<{
|
|
47
|
+
requiredPlan: "agents-500";
|
|
48
|
+
amountMinor: number;
|
|
49
|
+
currency: string;
|
|
50
|
+
checkoutUrl?: string;
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Decode only the priced response defined by the agent-create contract. A
|
|
54
|
+
* lookalike 403 must stay an ordinary API error rather than opening a browser
|
|
55
|
+
* or guessing at a price.
|
|
56
|
+
*/
|
|
57
|
+
export declare function agentCreatePlanLimitFromPayload(status: number, body: unknown): AgentCreatePlanLimit | null;
|
|
58
|
+
/** Print a server-priced plan block without minting or rewriting its URL. */
|
|
59
|
+
export declare function surfaceAgentCreatePlanLimit(planLimit: AgentCreatePlanLimit, opts?: {
|
|
60
|
+
json?: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
40
62
|
/**
|
|
41
63
|
* Resolve a closed-set option value, or exit(1) with a message naming the
|
|
42
64
|
* offending input and the legal set.
|
|
@@ -82,7 +104,9 @@ export declare class AgentsHttpError extends Error {
|
|
|
82
104
|
code?: string;
|
|
83
105
|
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
84
106
|
billing?: BillingErrorPayload;
|
|
85
|
-
|
|
107
|
+
/** Strictly decoded priced `403 AGENT_PLAN_LIMIT` create response. */
|
|
108
|
+
agentCreatePlanLimit?: AgentCreatePlanLimit;
|
|
109
|
+
constructor(status: number, message: string, code?: string, billing?: BillingErrorPayload, agentCreatePlanLimit?: AgentCreatePlanLimit);
|
|
86
110
|
}
|
|
87
111
|
/** Roster row from `GET /v1/agents` — a superset is returned; we keep what we render. */
|
|
88
112
|
export interface CompanyAgentView {
|
|
@@ -194,7 +218,7 @@ export declare function formatAgentCreateSize(option: AgentCreateSizeOption): st
|
|
|
194
218
|
/** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
|
|
195
219
|
export declare function promptForAgentCreateSize(view: AgentCreateOptionsView): Promise<QuotedAgentCreateSizeOption>;
|
|
196
220
|
/** Confirm creation using the server quote, preserving $0 as a real answer. */
|
|
197
|
-
export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean): void;
|
|
221
|
+
export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean, quiet?: boolean): void;
|
|
198
222
|
export declare function provisionAgent(token: string, input: ProvisionAgentInput): Promise<{
|
|
199
223
|
uid?: string;
|
|
200
224
|
slug?: string;
|
package/dist/commands/agents.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* the caller's single active membership (same as `members.ts`).
|
|
26
26
|
*/
|
|
27
27
|
import chalk from "chalk";
|
|
28
|
+
import open from "open";
|
|
28
29
|
import { randomUUID } from "node:crypto";
|
|
29
30
|
import * as readline from "node:readline";
|
|
30
31
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
@@ -50,6 +51,96 @@ export const VALID_PROVIDERS = new Set(["codex", "grok", "claude", "agents-v2"])
|
|
|
50
51
|
export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
|
|
51
52
|
/** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
|
|
52
53
|
export const VALID_AGENT_SIZE_KEYS = new Set(["basic", "power", "dev"]);
|
|
54
|
+
/**
|
|
55
|
+
* `hq agents create` exits with this distinct status when payment is required.
|
|
56
|
+
* Scripts can distinguish the expected upgrade state from an ordinary command
|
|
57
|
+
* failure without parsing human output.
|
|
58
|
+
*/
|
|
59
|
+
export const AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE = 3;
|
|
60
|
+
function isCheckoutUrl(value) {
|
|
61
|
+
if (typeof value !== "string" || !value.trim())
|
|
62
|
+
return false;
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(value);
|
|
65
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decode only the priced response defined by the agent-create contract. A
|
|
73
|
+
* lookalike 403 must stay an ordinary API error rather than opening a browser
|
|
74
|
+
* or guessing at a price.
|
|
75
|
+
*/
|
|
76
|
+
export function agentCreatePlanLimitFromPayload(status, body) {
|
|
77
|
+
if (status !== 403 || !body || typeof body !== "object")
|
|
78
|
+
return null;
|
|
79
|
+
const payload = body;
|
|
80
|
+
if (payload.code !== "AGENT_PLAN_LIMIT" ||
|
|
81
|
+
payload.reasonCode !== "plan_limit" ||
|
|
82
|
+
payload.requiredPlan !== "agents-500" ||
|
|
83
|
+
typeof payload.amountMinor !== "number" ||
|
|
84
|
+
!Number.isSafeInteger(payload.amountMinor) ||
|
|
85
|
+
payload.amountMinor < 0 ||
|
|
86
|
+
typeof payload.currency !== "string" ||
|
|
87
|
+
!/^[A-Za-z]{3}$/.test(payload.currency)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (payload.checkoutUrl !== undefined && !isCheckoutUrl(payload.checkoutUrl)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
requiredPlan: "agents-500",
|
|
95
|
+
amountMinor: payload.amountMinor,
|
|
96
|
+
currency: payload.currency.toUpperCase(),
|
|
97
|
+
...(typeof payload.checkoutUrl === "string"
|
|
98
|
+
? { checkoutUrl: payload.checkoutUrl }
|
|
99
|
+
: {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function formatServerPrice(amountMinor, currency) {
|
|
103
|
+
return new Intl.NumberFormat("en-US", {
|
|
104
|
+
style: "currency",
|
|
105
|
+
currency,
|
|
106
|
+
}).format(amountMinor / 100);
|
|
107
|
+
}
|
|
108
|
+
function canOfferAgentCheckout(json) {
|
|
109
|
+
return json !== true &&
|
|
110
|
+
process.stdin.isTTY === true &&
|
|
111
|
+
process.stdout.isTTY === true &&
|
|
112
|
+
!process.env.CI;
|
|
113
|
+
}
|
|
114
|
+
async function offerAgentCheckout(url) {
|
|
115
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
116
|
+
try {
|
|
117
|
+
const answer = await new Promise((resolve) => rl.question("Open the payment page now? [y/N] ", resolve));
|
|
118
|
+
if (!/^y(?:es)?$/i.test(answer.trim()))
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
rl.close();
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await open(url);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
console.error("Could not open the payment page automatically. Use the URL above.");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Print a server-priced plan block without minting or rewriting its URL. */
|
|
132
|
+
export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
|
|
133
|
+
const price = `${formatServerPrice(planLimit.amountMinor, planLimit.currency)}/month`;
|
|
134
|
+
console.error(`Payment required: your plan does not include agents. Upgrade to ${planLimit.requiredPlan} costs ${price}.`);
|
|
135
|
+
if (!planLimit.checkoutUrl) {
|
|
136
|
+
console.error("Ask a company owner to upgrade before creating an agent.");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Deliberately plain and on its own line so terminals make this clickable.
|
|
140
|
+
console.log(planLimit.checkoutUrl);
|
|
141
|
+
if (canOfferAgentCheckout(opts.json))
|
|
142
|
+
await offerAgentCheckout(planLimit.checkoutUrl);
|
|
143
|
+
}
|
|
53
144
|
/**
|
|
54
145
|
* Resolve a closed-set option value, or exit(1) with a message naming the
|
|
55
146
|
* offending input and the legal set.
|
|
@@ -112,12 +203,15 @@ export class AgentsHttpError extends Error {
|
|
|
112
203
|
code;
|
|
113
204
|
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
114
205
|
billing;
|
|
115
|
-
|
|
206
|
+
/** Strictly decoded priced `403 AGENT_PLAN_LIMIT` create response. */
|
|
207
|
+
agentCreatePlanLimit;
|
|
208
|
+
constructor(status, message, code, billing, agentCreatePlanLimit) {
|
|
116
209
|
super(message);
|
|
117
210
|
this.name = "AgentsHttpError";
|
|
118
211
|
this.status = status;
|
|
119
212
|
this.code = code;
|
|
120
213
|
this.billing = billing;
|
|
214
|
+
this.agentCreatePlanLimit = agentCreatePlanLimit;
|
|
121
215
|
}
|
|
122
216
|
}
|
|
123
217
|
/**
|
|
@@ -134,7 +228,7 @@ export async function agentsRequest(opts) {
|
|
|
134
228
|
// decline copy ("Your card was declined…") while `error` is the generic
|
|
135
229
|
// "payment required" — error-first would feed surfaceBillingBlocked the
|
|
136
230
|
// generic string and lose the decline reason (mirrors outpostRequest).
|
|
137
|
-
body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body));
|
|
231
|
+
body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body), agentCreatePlanLimitFromPayload(res.status, body) ?? undefined);
|
|
138
232
|
}
|
|
139
233
|
return (await res.json());
|
|
140
234
|
}
|
|
@@ -242,7 +336,7 @@ export async function promptForAgentCreateSize(view) {
|
|
|
242
336
|
}
|
|
243
337
|
}
|
|
244
338
|
/** Confirm creation using the server quote, preserving $0 as a real answer. */
|
|
245
|
-
export function confirmAgentCreateQuoteOrExit(option, yes) {
|
|
339
|
+
export function confirmAgentCreateQuoteOrExit(option, yes, quiet = false) {
|
|
246
340
|
if (option.notBilled || option.netMonthlyCents === 0) {
|
|
247
341
|
const message = `${option.productName} is ${formatUsd(0)}/month for this company — ` +
|
|
248
342
|
"there is no per-agent charge.";
|
|
@@ -250,14 +344,26 @@ export function confirmAgentCreateQuoteOrExit(option, yes) {
|
|
|
250
344
|
console.error(chalk.yellow(`${message}\nRe-run with --yes to confirm agent provisioning.`));
|
|
251
345
|
process.exit(1);
|
|
252
346
|
}
|
|
253
|
-
|
|
347
|
+
if (!quiet) {
|
|
348
|
+
console.log(chalk.dim(`${message} Provisioning…`));
|
|
349
|
+
}
|
|
254
350
|
return;
|
|
255
351
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
352
|
+
if (!quiet) {
|
|
353
|
+
confirmChargeOrExit({
|
|
354
|
+
resource: "agent",
|
|
355
|
+
unitCents: option.netMonthlyCents,
|
|
356
|
+
yes,
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!yes) {
|
|
361
|
+
confirmChargeOrExit({
|
|
362
|
+
resource: "agent",
|
|
363
|
+
unitCents: option.netMonthlyCents,
|
|
364
|
+
yes,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
261
367
|
}
|
|
262
368
|
export async function provisionAgent(token, input) {
|
|
263
369
|
return agentsRequest({
|
|
@@ -764,7 +870,7 @@ export function registerAgentsCommand(program) {
|
|
|
764
870
|
});
|
|
765
871
|
agents
|
|
766
872
|
.command("provision <name>")
|
|
767
|
-
.
|
|
873
|
+
.aliases(["new", "create"])
|
|
768
874
|
.description("Provision a new cloud agent (company-specific monthly price shown before creation)")
|
|
769
875
|
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
770
876
|
.option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
|
|
@@ -775,6 +881,7 @@ export function registerAgentsCommand(program) {
|
|
|
775
881
|
.option("--description <text>", "Short description / bio")
|
|
776
882
|
.option("--size <size>", "Agent box size: basic | power | dev (omitted keeps the current default)")
|
|
777
883
|
.option("--yes", "Confirm the quoted monthly cost and provision the agent")
|
|
884
|
+
.option("--json", "Emit machine-readable output and never open a browser")
|
|
778
885
|
.action(async function (name, opts) {
|
|
779
886
|
try {
|
|
780
887
|
// Both of these previously fell back to their default on an
|
|
@@ -818,12 +925,12 @@ export function registerAgentsCommand(program) {
|
|
|
818
925
|
: shouldChooseInteractively
|
|
819
926
|
? await promptForAgentCreateSize(createOptions)
|
|
820
927
|
: requireDefaultAgentCreateSize(createOptions);
|
|
821
|
-
if (!shouldChooseInteractively) {
|
|
928
|
+
if (!shouldChooseInteractively && !opts.json) {
|
|
822
929
|
console.log(chalk.dim(formatAgentCreateSize(quotedSize)));
|
|
823
930
|
}
|
|
824
931
|
// Every path confirms hq-pro's company-specific quote. Omission still
|
|
825
932
|
// leaves the POST size implicit, preserving the server-side default.
|
|
826
|
-
confirmAgentCreateQuoteOrExit(quotedSize, opts.yes);
|
|
933
|
+
confirmAgentCreateQuoteOrExit(quotedSize, opts.yes, opts.json);
|
|
827
934
|
const slug = opts.slug ?? slugifyAgentName(name);
|
|
828
935
|
try {
|
|
829
936
|
const result = await provisionAgent(token, {
|
|
@@ -849,10 +956,28 @@ export function registerAgentsCommand(program) {
|
|
|
849
956
|
surface: CLI_AGENT_CREATE_SURFACE,
|
|
850
957
|
});
|
|
851
958
|
const uid = typeof result.uid === "string" ? result.uid : slug;
|
|
852
|
-
|
|
853
|
-
|
|
959
|
+
if (opts.json) {
|
|
960
|
+
console.log(JSON.stringify(result));
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
console.log(chalk.green(`Provisioning started for agent "${name}".`));
|
|
964
|
+
console.log(chalk.dim(`Track setup: hq agents status ${uid} --company <slug>`));
|
|
965
|
+
}
|
|
854
966
|
}
|
|
855
967
|
catch (err) {
|
|
968
|
+
if (err instanceof AgentsHttpError &&
|
|
969
|
+
err.status === 403 &&
|
|
970
|
+
err.code === "AGENT_PLAN_LIMIT") {
|
|
971
|
+
if (!err.agentCreatePlanLimit) {
|
|
972
|
+
throw new Error("HQ Pro returned an unrecognized agent payment-required response.");
|
|
973
|
+
}
|
|
974
|
+
await surfaceAgentCreatePlanLimit(err.agentCreatePlanLimit, opts);
|
|
975
|
+
// Keep the payment-required status through normal CLI teardown.
|
|
976
|
+
// Unlike process.exit(), this is testable and cannot be caught by
|
|
977
|
+
// this action's outer error boundary as a generic failure.
|
|
978
|
+
process.exitCode = AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
856
981
|
// No card on file → surface the shareable payment link instead of an
|
|
857
982
|
// opaque 402, then exit non-zero so scripts can react.
|
|
858
983
|
if (err instanceof AgentsHttpError &&
|
|
@@ -124,6 +124,32 @@ function registryText(now, entries) {
|
|
|
124
124
|
function withoutTimestamp(content) {
|
|
125
125
|
return content.split('\n').filter((line) => !line.startsWith('generated_at:')).join('\n');
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Worker roots hold two kinds of `worker.yaml` that must never reach the
|
|
129
|
+
* registry.
|
|
130
|
+
*
|
|
131
|
+
* The `_template` company and any `_overrides` directory are scaffolding —
|
|
132
|
+
* copies kept so a new tenant or a pack override can be stamped out from them.
|
|
133
|
+
*
|
|
134
|
+
* A checkout nested under a tenant's `repos/` is a source repository that tenant
|
|
135
|
+
* happens to keep inside HQ, and when that repository is HQ itself, its own
|
|
136
|
+
* `core/workers` and template trees are full of worker.yaml files. Indexing them
|
|
137
|
+
* treats product source as deployed workers: they claim ids real workers already
|
|
138
|
+
* own, and because duplicates resolve by path order — a tenant path sorts before
|
|
139
|
+
* both `core` and `personal` — the checkout wins. The operator's actual worker
|
|
140
|
+
* then silently vanishes from the registry every skill reads.
|
|
141
|
+
*/
|
|
142
|
+
function isExcludedWorkerPath(relativeFile) {
|
|
143
|
+
const normalized = relativeFile.replaceAll('\\', '/');
|
|
144
|
+
const [root, tenant, nested] = normalized.split('/');
|
|
145
|
+
if (root !== 'companies')
|
|
146
|
+
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
147
|
+
if (tenant === '_template')
|
|
148
|
+
return true;
|
|
149
|
+
if (nested === 'repos')
|
|
150
|
+
return true;
|
|
151
|
+
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
152
|
+
}
|
|
127
153
|
/**
|
|
128
154
|
* Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
|
|
129
155
|
* are quarantined but do not prevent all valid workers from being registered.
|
|
@@ -138,7 +164,7 @@ export function generateWorkersRegistry(hqRoot, options = {}) {
|
|
|
138
164
|
...workerYamlFiles(hqRoot, 'personal/workers'),
|
|
139
165
|
].sort();
|
|
140
166
|
for (const relativeFile of files) {
|
|
141
|
-
if (
|
|
167
|
+
if (isExcludedWorkerPath(relativeFile))
|
|
142
168
|
continue;
|
|
143
169
|
const fields = readWorkerFields(path.join(hqRoot, relativeFile));
|
|
144
170
|
const missing = ['id', 'type', 'description'].filter((key) => !fields[key]);
|
|
@@ -48,6 +48,8 @@ export interface RegisterRecoveryDeps {
|
|
|
48
48
|
resolveInstall?: () => {
|
|
49
49
|
packageRoot: string | null;
|
|
50
50
|
};
|
|
51
|
+
/** Filesystem-free fallback root for the torn window (defaults to stringDerivedPackageRoot). */
|
|
52
|
+
deriveInstallRoot?: () => string | null;
|
|
51
53
|
lockPath?: () => string;
|
|
52
54
|
waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
|
|
53
55
|
/** node flags to forward to the re-exec child (defaults to process.execArgv). */
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { spawnSync } from "node:child_process";
|
|
20
20
|
import { resolveRunningInstall } from "./utils/version-gate.js";
|
|
21
|
+
import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
|
|
21
22
|
import { updateLockPath } from "./utils/update-lock.js";
|
|
22
23
|
import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
23
24
|
/**
|
|
@@ -122,13 +123,23 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
122
123
|
return { reexecStatus: child.status ?? 1 };
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the running install's package dir for the settle wait's manifest-health
|
|
128
|
+
* and retired-sibling guards. The on-disk resolver returns null in exactly the
|
|
129
|
+
* window this recovery targets — the package directory has been renamed aside, so
|
|
130
|
+
* it cannot be found by reading disk — which would leave readiness keyed on the
|
|
131
|
+
* single missing file alone and let a premature re-exec run against a still-
|
|
132
|
+
* incomplete tree. Fall back to the filesystem-free derived root so those guards
|
|
133
|
+
* stay active while npm is mid-extraction. Tolerates any resolver failure.
|
|
134
|
+
*/
|
|
126
135
|
function resolvePackageRoot(deps) {
|
|
136
|
+
let root;
|
|
127
137
|
try {
|
|
128
|
-
|
|
138
|
+
root = (deps.resolveInstall ?? resolveRunningInstall)().packageRoot;
|
|
129
139
|
}
|
|
130
140
|
catch {
|
|
131
|
-
|
|
141
|
+
root = null;
|
|
132
142
|
}
|
|
143
|
+
return root ?? (deps.deriveInstallRoot ?? stringDerivedPackageRoot)();
|
|
133
144
|
}
|
|
134
145
|
//# sourceMappingURL=startup-registration.js.map
|
package/dist/utils/hq-roots.d.ts
CHANGED
|
@@ -61,6 +61,31 @@ export declare function createPackageRootResolver(options: PackageRootResolverOp
|
|
|
61
61
|
export declare function packageRoot(): string;
|
|
62
62
|
/** Reset the memoized package root. Tests only. */
|
|
63
63
|
export declare function __resetPackageRootCache(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Derive this package's installed root from the running module's OWN path using
|
|
66
|
+
* STRING operations only — no `readFileSync`, `realpathSync`, or `existsSync`.
|
|
67
|
+
*
|
|
68
|
+
* {@link packageRoot} must read `<dir>/package.json` and `realpathSync` the
|
|
69
|
+
* module directory to answer, so it THROWS in exactly the situation that most
|
|
70
|
+
* needs an answer: a concurrent global install has renamed the installed
|
|
71
|
+
* package directory aside (`.hq-cli-<rand>`) and is re-extracting it file by
|
|
72
|
+
* file, so both the manifest walk and the dist-owner fallback fail on a
|
|
73
|
+
* directory that is momentarily gone. This walks the compiled module's
|
|
74
|
+
* ancestors as strings and returns the DEEPEST one whose trailing segments are
|
|
75
|
+
* `node_modules/@indigoai-us/hq-cli`, which stays correct while that directory
|
|
76
|
+
* does not exist on disk.
|
|
77
|
+
*
|
|
78
|
+
* It is a FALLBACK only — {@link packageRoot} validates the manifest name and
|
|
79
|
+
* is authoritative whenever it succeeds. Returns null for a dev checkout or any
|
|
80
|
+
* layout without that segment triple, so a caller that falls back to it can
|
|
81
|
+
* only ever degrade to today's behaviour (no resolution), never widen it.
|
|
82
|
+
*
|
|
83
|
+
* The deepest match is deliberate: a nested `node_modules/@indigoai-us/hq-cli`
|
|
84
|
+
* inside another package resolves to ITSELF (the copy the module belongs to),
|
|
85
|
+
* never an outer decoy. Case is folded only for a Windows-shaped path, matching
|
|
86
|
+
* the POSIX case-sensitivity the rest of this module assumes.
|
|
87
|
+
*/
|
|
88
|
+
export declare function stringDerivedPackageRoot(moduleFilePath?: string): string | null;
|
|
64
89
|
export type LiveRootOptions = {
|
|
65
90
|
/** Explicit `--hq-root` value. Highest precedence. */
|
|
66
91
|
hqRoot?: string;
|
package/dist/utils/hq-roots.js
CHANGED
|
@@ -191,6 +191,73 @@ export function __resetPackageRootCache() {
|
|
|
191
191
|
modulePath: currentModulePath,
|
|
192
192
|
});
|
|
193
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* The trailing path segments that mark an installed copy of this package:
|
|
196
|
+
* `node_modules` followed by the package name's own segments (for
|
|
197
|
+
* `@indigoai-us/hq-cli`, that is `node_modules/@indigoai-us/hq-cli`).
|
|
198
|
+
*/
|
|
199
|
+
const INSTALLED_ROOT_SEGMENTS = ["node_modules", ...CLI_PACKAGE_NAME.split("/")];
|
|
200
|
+
/** A Windows-shaped absolute path (drive letter or UNC), regardless of host OS. */
|
|
201
|
+
function looksWin32Path(p) {
|
|
202
|
+
return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Derive this package's installed root from the running module's OWN path using
|
|
206
|
+
* STRING operations only — no `readFileSync`, `realpathSync`, or `existsSync`.
|
|
207
|
+
*
|
|
208
|
+
* {@link packageRoot} must read `<dir>/package.json` and `realpathSync` the
|
|
209
|
+
* module directory to answer, so it THROWS in exactly the situation that most
|
|
210
|
+
* needs an answer: a concurrent global install has renamed the installed
|
|
211
|
+
* package directory aside (`.hq-cli-<rand>`) and is re-extracting it file by
|
|
212
|
+
* file, so both the manifest walk and the dist-owner fallback fail on a
|
|
213
|
+
* directory that is momentarily gone. This walks the compiled module's
|
|
214
|
+
* ancestors as strings and returns the DEEPEST one whose trailing segments are
|
|
215
|
+
* `node_modules/@indigoai-us/hq-cli`, which stays correct while that directory
|
|
216
|
+
* does not exist on disk.
|
|
217
|
+
*
|
|
218
|
+
* It is a FALLBACK only — {@link packageRoot} validates the manifest name and
|
|
219
|
+
* is authoritative whenever it succeeds. Returns null for a dev checkout or any
|
|
220
|
+
* layout without that segment triple, so a caller that falls back to it can
|
|
221
|
+
* only ever degrade to today's behaviour (no resolution), never widen it.
|
|
222
|
+
*
|
|
223
|
+
* The deepest match is deliberate: a nested `node_modules/@indigoai-us/hq-cli`
|
|
224
|
+
* inside another package resolves to ITSELF (the copy the module belongs to),
|
|
225
|
+
* never an outer decoy. Case is folded only for a Windows-shaped path, matching
|
|
226
|
+
* the POSIX case-sensitivity the rest of this module assumes.
|
|
227
|
+
*/
|
|
228
|
+
export function stringDerivedPackageRoot(moduleFilePath = currentModulePath) {
|
|
229
|
+
if (typeof moduleFilePath !== "string" || moduleFilePath.length === 0) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const wanted = INSTALLED_ROOT_SEGMENTS;
|
|
233
|
+
const caseInsensitive = looksWin32Path(moduleFilePath);
|
|
234
|
+
const sameSegment = (a, b) => caseInsensitive ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
235
|
+
// Tokenise into segments with each one's end offset in the ORIGINAL string,
|
|
236
|
+
// so the returned root keeps the source path's leading root and separators
|
|
237
|
+
// verbatim (a leading `/`, or a `C:\` drive) rather than a rejoined guess.
|
|
238
|
+
const segments = [];
|
|
239
|
+
const segmentPattern = /[^\\/]+/g;
|
|
240
|
+
let match;
|
|
241
|
+
while ((match = segmentPattern.exec(moduleFilePath)) !== null) {
|
|
242
|
+
segments.push({ text: match[0], end: match.index + match[0].length });
|
|
243
|
+
}
|
|
244
|
+
if (segments.length < wanted.length)
|
|
245
|
+
return null;
|
|
246
|
+
// Scan from the deepest segment upward; the first (deepest) leaf whose
|
|
247
|
+
// preceding segments complete the triple wins.
|
|
248
|
+
for (let leaf = segments.length - 1; leaf >= wanted.length - 1; leaf--) {
|
|
249
|
+
let matched = true;
|
|
250
|
+
for (let k = 0; k < wanted.length; k++) {
|
|
251
|
+
if (!sameSegment(segments[leaf - (wanted.length - 1) + k].text, wanted[k])) {
|
|
252
|
+
matched = false;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (matched)
|
|
257
|
+
return moduleFilePath.slice(0, segments[leaf].end);
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
194
261
|
/**
|
|
195
262
|
* Resolve the user's live HQ installation.
|
|
196
263
|
*
|
|
@@ -10,6 +10,8 @@ import * as fs from "fs";
|
|
|
10
10
|
export declare const INCOMPLETE_INSTALL_REMEDY: string;
|
|
11
11
|
/** A resolver for the running install's root; returns null instead of throwing. */
|
|
12
12
|
export type PackageRootResolver = () => string | null;
|
|
13
|
+
/** Which strategy produced the running install's root, recorded in diagnostics. */
|
|
14
|
+
export type PackageRootResolverSource = "manifest-walk" | "string-derivation" | "unresolved" | "injected";
|
|
13
15
|
/**
|
|
14
16
|
* If `err` is an in-process incomplete-install module-load failure — either the
|
|
15
17
|
* CJS relative-sibling shape (HQ-CLI-1N) or the ESM vanished-file shape
|
|
@@ -24,9 +26,21 @@ export type PackageRootResolver = () => string | null;
|
|
|
24
26
|
export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
|
|
25
27
|
/** Bounded, scrubber-safe diagnostics for an unattributable esm-loader ENOENT (HQ-CLI-1M). */
|
|
26
28
|
export type IncompleteInstallEsmDiagnostics = {
|
|
29
|
+
/** The resolved install root, or the literal `<unresolved>` when none was found. */
|
|
27
30
|
packageRoot: string;
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
/** True only when a root was actually resolved (by any strategy). */
|
|
32
|
+
packageRootResolved: boolean;
|
|
33
|
+
/** Which resolver answered — so an unresolved root is never read as resolved-but-missing. */
|
|
34
|
+
resolver: PackageRootResolverSource;
|
|
35
|
+
/**
|
|
36
|
+
* Whether `<root>/package.json` and `<root>/node_modules` exist on disk.
|
|
37
|
+
* Present ONLY when a root was resolved: reported for an unresolved root,
|
|
38
|
+
* a bare `false` is indistinguishable from "resolved but the file is missing"
|
|
39
|
+
* — the ambiguity that made the delivered HQ-CLI-1M evidence weaker than
|
|
40
|
+
* intended (the two booleans were uninitialised defaults).
|
|
41
|
+
*/
|
|
42
|
+
packageJsonExists?: boolean;
|
|
43
|
+
nodeModulesExists?: boolean;
|
|
30
44
|
esmLoaderFrame: boolean;
|
|
31
45
|
code: string;
|
|
32
46
|
};
|
|
@@ -42,21 +56,44 @@ export type IncompleteInstallBareDiagnostics = {
|
|
|
42
56
|
requiringPackage: string;
|
|
43
57
|
requirerDeclaresMissing: false;
|
|
44
58
|
};
|
|
59
|
+
/** The four lexical scopes a NOT-suppressed CJS relative requirer can fall into. */
|
|
60
|
+
export type RelativeRequirerScope = "dist" | "assets" | "outside-root" | "unresolved-root";
|
|
45
61
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
62
|
+
* Bounded, scrubber-safe diagnostics for a NOT-suppressed CJS RELATIVE-specifier
|
|
63
|
+
* miss (HQ-CLI-1N): the CJS relative-sibling shape that reached the capture path
|
|
64
|
+
* WITHOUT being suppressed — its requirer sits under the install's own `dist/` or
|
|
65
|
+
* `assets/`, outside the running install entirely, or the install root could not
|
|
66
|
+
* be resolved. Records only a bounded, four-value LEXICAL scope of the requirer
|
|
67
|
+
* against the root — never the requirer path and never the specifier — so
|
|
68
|
+
* grouping cardinality cannot inflate. `code` is always `MODULE_NOT_FOUND`.
|
|
69
|
+
*/
|
|
70
|
+
export type IncompleteInstallRelativeDiagnostics = {
|
|
71
|
+
code: string;
|
|
72
|
+
relativeSpecifier: true;
|
|
73
|
+
packageRoot: string;
|
|
74
|
+
packageRootResolved: boolean;
|
|
75
|
+
resolver: PackageRootResolverSource;
|
|
76
|
+
requirerScope: RelativeRequirerScope;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* The enriched shapes as a single OPEN record — every field optional so a
|
|
80
|
+
* consumer can forward any shape to Sentry without narrowing (the boundary and
|
|
81
|
+
* beforeSend only pass the block through). Every value CONSTRUCTED here is
|
|
82
|
+
* exactly one of the strict shapes above; the looseness is only at the read
|
|
50
83
|
* boundary.
|
|
51
84
|
*/
|
|
52
|
-
export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics>;
|
|
85
|
+
export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics>;
|
|
53
86
|
/**
|
|
54
87
|
* When an incomplete-install failure reaches the capture path WITHOUT being
|
|
55
88
|
* suppressed, return a bounded `contexts.incomplete_install` block so the next
|
|
56
89
|
* occurrence carries the evidence this one lacked; otherwise return undefined
|
|
57
|
-
* (bare capture).
|
|
90
|
+
* (bare capture). Three enriched shapes:
|
|
58
91
|
* - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
|
|
59
92
|
* → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
|
|
93
|
+
* - a CJS RELATIVE-specifier miss whose requirer sits under the install's own
|
|
94
|
+
* dist/ or assets/, outside the install, or under an unresolved root
|
|
95
|
+
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
96
|
+
* packageRootResolved, resolver, requirerScope };
|
|
60
97
|
* - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
|
|
61
98
|
* shape the delivered payload arrived in, where neither the exception value
|
|
62
99
|
* nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
// `fs.readFileSync` ENOENT written by hq's own code stays captured.
|
|
64
64
|
import * as fs from "fs";
|
|
65
65
|
import * as path from "path";
|
|
66
|
-
import { packageRoot } from "./hq-roots.js";
|
|
66
|
+
import { packageRoot, stringDerivedPackageRoot } from "./hq-roots.js";
|
|
67
67
|
import { packageNameOf } from "./install-tree-torn.js";
|
|
68
68
|
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
69
69
|
/**
|
|
@@ -85,8 +85,16 @@ export const INCOMPLETE_INSTALL_REMEDY = "hq couldn't load part of its own insta
|
|
|
85
85
|
"`pnpm add -g @indigoai-us/hq-cli`).";
|
|
86
86
|
/** A relative module specifier — `./x`, `../x`, `.\x`, `..\x`. */
|
|
87
87
|
const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
|
|
88
|
-
/**
|
|
89
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The Node ESM loader's SOURCE-READ frame — `getSourceSync`/`defaultLoad` in
|
|
90
|
+
* `node:internal/modules/esm/load`. Requiring the `esm/load` module frame (not
|
|
91
|
+
* merely any frame under `modules/esm/`) proves the loader was READING the
|
|
92
|
+
* module's source. An ordinary `fs.openSync`/`readFileSync` ENOENT that merely
|
|
93
|
+
* ESCAPES a module's EVALUATION runs under `esm/module_job`, never `esm/load`,
|
|
94
|
+
* so it stays captured rather than suppressed. `load\b` excludes the sibling
|
|
95
|
+
* `esm/loader` module.
|
|
96
|
+
*/
|
|
97
|
+
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
|
|
90
98
|
const ROOT_DIAGNOSTIC_BYTES = 256;
|
|
91
99
|
const CODE_DIAGNOSTIC_BYTES = 32;
|
|
92
100
|
// Package names live inside hq-cli's own dependency graph, so their universe is
|
|
@@ -95,19 +103,42 @@ const PACKAGE_NAME_DIAGNOSTIC_BYTES = 128;
|
|
|
95
103
|
/** The `<something>/node_modules/<something>` directory-boundary marker. */
|
|
96
104
|
const NODE_MODULES_SEGMENT = "/node_modules/";
|
|
97
105
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
106
|
+
* Resolve the running install's root, trying the authoritative on-disk manifest
|
|
107
|
+
* walk first and falling back to the filesystem-free string derivation ONLY when
|
|
108
|
+
* it throws — the torn-tree case, where a concurrent global install has renamed
|
|
109
|
+
* the package directory aside so packageRoot() cannot read a manifest. Never
|
|
110
|
+
* throws; reports which strategy answered so a captured event is attributable.
|
|
111
|
+
*
|
|
112
|
+
* packageRoot() itself is deliberately NOT widened: resolveBundledAsset() and
|
|
113
|
+
* isWithinPackage() require a root that exists on disk, and handing them a
|
|
114
|
+
* string-derived phantom directory would break them. The fallback lives here,
|
|
115
|
+
* where the only consumer is classification (a lexical prefix test) that needs
|
|
116
|
+
* no live filesystem.
|
|
102
117
|
*/
|
|
103
|
-
function
|
|
118
|
+
function resolvePackageRootWithSource() {
|
|
104
119
|
try {
|
|
105
|
-
return packageRoot();
|
|
120
|
+
return { root: packageRoot(), source: "manifest-walk" };
|
|
106
121
|
}
|
|
107
122
|
catch {
|
|
108
|
-
|
|
123
|
+
const derived = stringDerivedPackageRoot();
|
|
124
|
+
return derived
|
|
125
|
+
? { root: derived, source: "string-derivation" }
|
|
126
|
+
: { root: null, source: "unresolved" };
|
|
109
127
|
}
|
|
110
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* packageRoot() walks up from the compiled module and THROWS
|
|
131
|
+
* PackageRootResolutionError when it cannot resolve. This classifier runs inside
|
|
132
|
+
* beforeSend on EVERY event, so it must never throw — a resolution failure
|
|
133
|
+
* returns null and the error stays captured. When the on-disk walk fails because
|
|
134
|
+
* a concurrent install renamed the package directory aside, the filesystem-free
|
|
135
|
+
* string derivation still supplies the root: that is the HQ-CLI-1M repair —
|
|
136
|
+
* the running install's root must be known even while its directory is
|
|
137
|
+
* momentarily gone.
|
|
138
|
+
*/
|
|
139
|
+
function safePackageRoot() {
|
|
140
|
+
return resolvePackageRootWithSource().root;
|
|
141
|
+
}
|
|
111
142
|
/** Call a (possibly injected) resolver without letting it throw. */
|
|
112
143
|
function resolveRootSafely(resolve) {
|
|
113
144
|
try {
|
|
@@ -137,16 +168,25 @@ function normalizeForCompare(p) {
|
|
|
137
168
|
return looksWin32(p) ? folded.toLowerCase() : folded;
|
|
138
169
|
}
|
|
139
170
|
/**
|
|
140
|
-
* True when `candidate` lives under `<root
|
|
141
|
-
* directory boundary (`<root>` + sep +
|
|
142
|
-
* as `<root>-old
|
|
171
|
+
* True when `candidate` lives directly under `<root>/<subdir>/`. Anchored at a
|
|
172
|
+
* true directory boundary (`<root>` + sep + `<subdir>` + sep) so a sibling such
|
|
173
|
+
* as `<root>-old/<subdir>/...` can never match. `<subdir>` carries no separator,
|
|
174
|
+
* so this stays a single lexical prefix test that needs no live filesystem.
|
|
143
175
|
*/
|
|
144
|
-
function
|
|
176
|
+
function isUnderSubdir(candidate, root, subdir) {
|
|
145
177
|
if (!candidate || !root)
|
|
146
178
|
return false;
|
|
147
|
-
const prefix = `${normalizeForCompare(root)}
|
|
179
|
+
const prefix = `${normalizeForCompare(root)}/${subdir}/`;
|
|
148
180
|
return normalizeForCompare(candidate).startsWith(prefix);
|
|
149
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* True when `candidate` lives under `<root>/node_modules/`. Anchored at a true
|
|
184
|
+
* directory boundary so a sibling such as `<root>-old/node_modules/...` can
|
|
185
|
+
* never match.
|
|
186
|
+
*/
|
|
187
|
+
function isUnderNodeModules(candidate, root) {
|
|
188
|
+
return isUnderSubdir(candidate, root, "node_modules");
|
|
189
|
+
}
|
|
150
190
|
/** The failing specifier from a `Cannot find module '<spec>'` message, or null. */
|
|
151
191
|
function parseMissingSpecifier(message) {
|
|
152
192
|
if (typeof message !== "string")
|
|
@@ -392,13 +432,87 @@ function bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
|
|
|
392
432
|
},
|
|
393
433
|
};
|
|
394
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Classify a NOT-suppressed CJS relative requirer LEXICALLY against the resolved
|
|
437
|
+
* root — never emitting the path itself, only one of four fixed values. A
|
|
438
|
+
* requirer under `<root>/node_modules/` with a resolved root is ALWAYS suppressed
|
|
439
|
+
* upstream, so that scope is unreachable here and deliberately absent from the
|
|
440
|
+
* enum.
|
|
441
|
+
* - root null (both resolvers failed) → 'unresolved-root'
|
|
442
|
+
* - under `<root>/dist/` → 'dist' (hq-cli's own shipped output)
|
|
443
|
+
* - under `<root>/assets/` → 'assets' (hq-cli's own bundled assets)
|
|
444
|
+
* - anything else (a user project, a `<root>-old` sibling, …) → 'outside-root'
|
|
445
|
+
*/
|
|
446
|
+
function classifyRelativeRequirerScope(requiringFile, root) {
|
|
447
|
+
if (!root)
|
|
448
|
+
return "unresolved-root";
|
|
449
|
+
if (isUnderSubdir(requiringFile, root, "dist"))
|
|
450
|
+
return "dist";
|
|
451
|
+
if (isUnderSubdir(requiringFile, root, "assets"))
|
|
452
|
+
return "assets";
|
|
453
|
+
return "outside-root";
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* When a CJS RELATIVE-specifier miss (HQ-CLI-1N's shape) reached the capture path
|
|
457
|
+
* WITHOUT being suppressed — its requirer sits under the install's own `dist/` or
|
|
458
|
+
* `assets/`, outside the running install entirely, or the install root could not
|
|
459
|
+
* be resolved — return a bounded `incomplete_install` block recording a
|
|
460
|
+
* four-value lexical scope so the next occurrence is attributable instead of
|
|
461
|
+
* bare; otherwise undefined. The suppression decision is delegated to
|
|
462
|
+
* incompleteInstallMessage against the SAME injected resolver, so a suppressed
|
|
463
|
+
* relative miss (requirer under `<root>/node_modules/` with a resolved root) is
|
|
464
|
+
* never double-attributed here. Never throws; never emits a path or the specifier.
|
|
465
|
+
*/
|
|
466
|
+
function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
|
|
467
|
+
if (err === null || typeof err !== "object")
|
|
468
|
+
return undefined;
|
|
469
|
+
const record = err;
|
|
470
|
+
if (record.code !== "MODULE_NOT_FOUND")
|
|
471
|
+
return undefined;
|
|
472
|
+
const requireStack = record.requireStack;
|
|
473
|
+
if (!Array.isArray(requireStack) || typeof requireStack[0] !== "string")
|
|
474
|
+
return undefined;
|
|
475
|
+
const requiringFile = requireStack[0];
|
|
476
|
+
const specifier = parseMissingSpecifier(record.message);
|
|
477
|
+
if (specifier === null || !RELATIVE_SPECIFIER.test(specifier))
|
|
478
|
+
return undefined;
|
|
479
|
+
// Suppressed (requirer under <root>/node_modules with a resolved root) →
|
|
480
|
+
// printed-and-skipped, never captured. Delegated to the classifier against the
|
|
481
|
+
// SAME resolver so the two decisions can never diverge and nothing that would
|
|
482
|
+
// be suppressed is ever double-attributed.
|
|
483
|
+
if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) !== null) {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
// Not suppressed: record which resolver answered and the lexical requirer scope.
|
|
487
|
+
const resolution = resolvePackageRoot === safePackageRoot
|
|
488
|
+
? resolvePackageRootWithSource()
|
|
489
|
+
: {
|
|
490
|
+
root: resolveRootSafely(resolvePackageRoot),
|
|
491
|
+
source: "injected",
|
|
492
|
+
};
|
|
493
|
+
const root = resolution.root;
|
|
494
|
+
return {
|
|
495
|
+
incomplete_install: {
|
|
496
|
+
code: "MODULE_NOT_FOUND",
|
|
497
|
+
relativeSpecifier: true,
|
|
498
|
+
packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
|
|
499
|
+
packageRootResolved: root !== null,
|
|
500
|
+
resolver: resolution.source,
|
|
501
|
+
requirerScope: classifyRelativeRequirerScope(requiringFile, root),
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
395
505
|
/**
|
|
396
506
|
* When an incomplete-install failure reaches the capture path WITHOUT being
|
|
397
507
|
* suppressed, return a bounded `contexts.incomplete_install` block so the next
|
|
398
508
|
* occurrence carries the evidence this one lacked; otherwise return undefined
|
|
399
|
-
* (bare capture).
|
|
509
|
+
* (bare capture). Three enriched shapes:
|
|
400
510
|
* - a third-party BARE-specifier miss the requirer did not declare (HQ-CLI-1Q)
|
|
401
511
|
* → { missingPackage, requiringPackage, requirerDeclaresMissing:false };
|
|
512
|
+
* - a CJS RELATIVE-specifier miss whose requirer sits under the install's own
|
|
513
|
+
* dist/ or assets/, outside the install, or under an unresolved root
|
|
514
|
+
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
515
|
+
* packageRootResolved, resolver, requirerScope };
|
|
402
516
|
* - an esm-loader ENOENT whose path did not survive delivery (HQ-CLI-1M) — the
|
|
403
517
|
* shape the delivered payload arrived in, where neither the exception value
|
|
404
518
|
* nor node_system_error carried a `path` → { packageRoot, packageJsonExists,
|
|
@@ -413,7 +527,11 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
413
527
|
const bare = bareSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
|
|
414
528
|
if (bare)
|
|
415
529
|
return bare;
|
|
416
|
-
// (B) HQ-CLI-
|
|
530
|
+
// (B) HQ-CLI-1N — the not-suppressed CJS relative-sibling miss, made attributable.
|
|
531
|
+
const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
|
|
532
|
+
if (relative)
|
|
533
|
+
return relative;
|
|
534
|
+
// (C) HQ-CLI-1M — the path-less esm-loader ENOENT, unchanged.
|
|
417
535
|
if (!isEsmLoaderEnoent(err))
|
|
418
536
|
return undefined;
|
|
419
537
|
// Only instrument what we did NOT already confidently suppress: a path under
|
|
@@ -422,32 +540,40 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
422
540
|
return undefined;
|
|
423
541
|
}
|
|
424
542
|
const record = err;
|
|
425
|
-
const root = resolveRootSafely(resolvePackageRoot);
|
|
426
543
|
const code = typeof record.code === "string" ? record.code : "";
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return {
|
|
444
|
-
incomplete_install: {
|
|
445
|
-
packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
|
|
446
|
-
packageJsonExists,
|
|
447
|
-
nodeModulesExists,
|
|
448
|
-
esmLoaderFrame: true,
|
|
449
|
-
code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
|
|
450
|
-
},
|
|
544
|
+
// The default resolver knows which strategy answered (manifest walk vs the
|
|
545
|
+
// string derivation that survives a torn tree); an injected resolver is
|
|
546
|
+
// opaque, so it is recorded as "injected" and its return used as the root.
|
|
547
|
+
const resolution = resolvePackageRoot === safePackageRoot
|
|
548
|
+
? resolvePackageRootWithSource()
|
|
549
|
+
: {
|
|
550
|
+
root: resolveRootSafely(resolvePackageRoot),
|
|
551
|
+
source: "injected",
|
|
552
|
+
};
|
|
553
|
+
const root = resolution.root;
|
|
554
|
+
const diagnostics = {
|
|
555
|
+
packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
|
|
556
|
+
packageRootResolved: root !== null,
|
|
557
|
+
resolver: resolution.source,
|
|
558
|
+
esmLoaderFrame: true,
|
|
559
|
+
code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
|
|
451
560
|
};
|
|
561
|
+
// Report the existence booleans ONLY when a root was resolved. Reported for an
|
|
562
|
+
// unresolved root they were uninitialised `false`s indistinguishable from
|
|
563
|
+
// "resolved but missing" — the instrumentation defect the prior fix shipped.
|
|
564
|
+
if (root !== null) {
|
|
565
|
+
diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
|
|
566
|
+
diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
|
|
567
|
+
}
|
|
568
|
+
return { incomplete_install: diagnostics };
|
|
569
|
+
}
|
|
570
|
+
/** existsSync that never throws — any filesystem error reads as "absent". */
|
|
571
|
+
function safeExistsSync(fileSystem, target) {
|
|
572
|
+
try {
|
|
573
|
+
return fileSystem.existsSync(target);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
452
578
|
}
|
|
453
579
|
//# sourceMappingURL=incomplete-install-error.js.map
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
* ../startup-registration.ts; version-gate.ts / self-update.ts / update-lock.ts
|
|
30
30
|
* are only READ (their exported symbols), never modified.
|
|
31
31
|
*/
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The loader-error `code`s recovery classifies. The first two mean "a module
|
|
34
|
+
* could not be resolved"; `ENOENT` is the esm-loader dialect where a module was
|
|
35
|
+
* present at resolve and gone at read (HQ-CLI-1M) — see {@link ModuleErrorDialect}.
|
|
36
|
+
*/
|
|
37
|
+
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT";
|
|
34
38
|
/**
|
|
35
39
|
* The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
|
|
36
40
|
* @sentry/node import-in-the-middle hook does not change the shapes):
|
|
@@ -42,16 +46,28 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND";
|
|
|
42
46
|
* '<abs>'` + `requireStack`.
|
|
43
47
|
* - `cjs-package`: CJS require of a bare specifier — `Cannot find module
|
|
44
48
|
* '<name>'` + `requireStack`.
|
|
49
|
+
* - `cjs-relative`: CJS require of a RELATIVE specifier — `Cannot find module
|
|
50
|
+
* './x'` (or `../x`) + `requireStack`. Node reports the RAW
|
|
51
|
+
* relative token, never a resolved path, so it is re-resolved
|
|
52
|
+
* from the importer's directory rather than reduced by
|
|
53
|
+
* packageNameOf (which would yield the un-probeable name `.`).
|
|
54
|
+
* - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
|
|
55
|
+
* and gone at READ, so getSourceSync/openSync raises ENOENT
|
|
56
|
+
* (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
|
|
45
57
|
* - `unknown`: a module-not-found whose message did not parse; recovery
|
|
46
58
|
* still waits on the lock / retired-dir / quiet signals.
|
|
47
59
|
*/
|
|
48
|
-
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "unknown";
|
|
60
|
+
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "unknown";
|
|
49
61
|
/**
|
|
50
62
|
* The missing thing, re-resolvable by the readiness probe:
|
|
51
|
-
* - `path`:
|
|
52
|
-
* - `package`:
|
|
53
|
-
* - `
|
|
54
|
-
*
|
|
63
|
+
* - `path`: an absolute filesystem path (a `.js` file, or a package dir).
|
|
64
|
+
* - `package`: a bare package `name` resolvable from directory `from` upward.
|
|
65
|
+
* - `relative`: a RELATIVE `specifier` (`./x`, `../x`) re-resolved the way
|
|
66
|
+
* Node's CJS resolver does from directory `from` —
|
|
67
|
+
* `path.resolve(from, specifier)` plus the `.js`/`.json`/`.node`
|
|
68
|
+
* and directory forms.
|
|
69
|
+
* - `unknown`: the message did not parse; treated as "present" by the probe so
|
|
70
|
+
* readiness turns only on the lock / retired-dir / quiet signals.
|
|
55
71
|
*/
|
|
56
72
|
export type ModuleErrorTarget = {
|
|
57
73
|
kind: "path";
|
|
@@ -60,6 +76,10 @@ export type ModuleErrorTarget = {
|
|
|
60
76
|
kind: "package";
|
|
61
77
|
name: string;
|
|
62
78
|
from: string;
|
|
79
|
+
} | {
|
|
80
|
+
kind: "relative";
|
|
81
|
+
specifier: string;
|
|
82
|
+
from: string;
|
|
63
83
|
} | {
|
|
64
84
|
kind: "unknown";
|
|
65
85
|
};
|
|
@@ -82,10 +102,11 @@ export declare function packageNameOf(specifier: string): string;
|
|
|
82
102
|
/**
|
|
83
103
|
* Classify a thrown value as a module-resolution failure and extract the missing
|
|
84
104
|
* target, or return `null` for anything that is not one. The decision is
|
|
85
|
-
* STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND
|
|
86
|
-
* `MODULE_NOT_FOUND
|
|
87
|
-
*
|
|
88
|
-
*
|
|
105
|
+
* STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND`,
|
|
106
|
+
* `MODULE_NOT_FOUND`, or — for the esm-loader ENOENT dialect — `ENOENT` under
|
|
107
|
+
* the full conjunction below. No argv, env, or free text is ever consulted, and
|
|
108
|
+
* any other error (including an import-time throw of another class) returns
|
|
109
|
+
* `null` so it is rethrown to the existing boundary unchanged.
|
|
89
110
|
*/
|
|
90
111
|
export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
|
|
91
112
|
/** The filesystem surface the probe and wait use, injectable for hermetic tests. */
|
|
@@ -107,6 +128,11 @@ export interface InstallTreeFs {
|
|
|
107
128
|
* exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
|
|
108
129
|
* `createRequire` so an ESM-only / export-conditioned package
|
|
109
130
|
* cannot false-negative.
|
|
131
|
+
* - `relative`: re-resolve `path.resolve(from, specifier)` as Node's CJS
|
|
132
|
+
* require would — a real file (exact / `.js` / `.json` / `.node`),
|
|
133
|
+
* or a directory whose package.json `main` (or that main's index)
|
|
134
|
+
* or own `index.*` is a real file. A bare or partially-extracted
|
|
135
|
+
* directory is NOT loadable and reads as not-present.
|
|
110
136
|
* - `unknown`: true (readiness turns on the other signals).
|
|
111
137
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
112
138
|
*/
|
|
@@ -42,6 +42,23 @@ const PACKAGE_ROOT_BYTES = 256;
|
|
|
42
42
|
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
43
43
|
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
44
44
|
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
45
|
+
/**
|
|
46
|
+
* The Node ESM loader's SOURCE-READ frame — `getSourceSync`/`defaultLoad` in
|
|
47
|
+
* `node:internal/modules/esm/load`. Requiring the `esm/load` module frame (not
|
|
48
|
+
* merely any frame under `modules/esm/`) is what proves the loader was READING
|
|
49
|
+
* the module's source, so an ordinary `fs.openSync`/`readFileSync` ENOENT that
|
|
50
|
+
* merely ESCAPES a module's EVALUATION — which runs under `esm/module_job`,
|
|
51
|
+
* never `esm/load` — is NOT misclassified as a torn install and does not trigger
|
|
52
|
+
* the settle wait. `load\b` also excludes the sibling `esm/loader` module.
|
|
53
|
+
*/
|
|
54
|
+
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
|
|
55
|
+
/**
|
|
56
|
+
* A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
|
|
57
|
+
* Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
|
|
58
|
+
* bare-`.`/`..` case so a token that packageNameOf would reduce to the
|
|
59
|
+
* un-probeable name `.` is caught here and re-resolved instead.
|
|
60
|
+
*/
|
|
61
|
+
const RELATIVE_CJS_SPECIFIER = /^\.\.?([\\/]|$)/;
|
|
45
62
|
/**
|
|
46
63
|
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
47
64
|
* `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
|
|
@@ -54,19 +71,57 @@ export function packageNameOf(specifier) {
|
|
|
54
71
|
return parts.slice(0, 2).join("/");
|
|
55
72
|
return parts[0] ?? specifier;
|
|
56
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Build a `package` target from an already-reduced name, UNLESS that name is not
|
|
76
|
+
* a plausible bare package: empty, a relative token reduced to `.`/`..`, or a
|
|
77
|
+
* subpath-imports token (`#internal`). Each of those reduces to a name whose
|
|
78
|
+
* `node_modules/<name>/package.json` ancestor probe can NEVER be true, so it
|
|
79
|
+
* falls back to `unknown` — readiness then turns on the lock / retired-dir /
|
|
80
|
+
* quiet signals rather than a probe that stays permanently false.
|
|
81
|
+
*/
|
|
82
|
+
function barePackageTarget(name, from) {
|
|
83
|
+
if (name.length === 0 || name.startsWith(".") || name.startsWith("#")) {
|
|
84
|
+
return { kind: "unknown" };
|
|
85
|
+
}
|
|
86
|
+
return { kind: "package", name, from };
|
|
87
|
+
}
|
|
57
88
|
/**
|
|
58
89
|
* Classify a thrown value as a module-resolution failure and extract the missing
|
|
59
90
|
* target, or return `null` for anything that is not one. The decision is
|
|
60
|
-
* STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND
|
|
61
|
-
* `MODULE_NOT_FOUND
|
|
62
|
-
*
|
|
63
|
-
*
|
|
91
|
+
* STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND`,
|
|
92
|
+
* `MODULE_NOT_FOUND`, or — for the esm-loader ENOENT dialect — `ENOENT` under
|
|
93
|
+
* the full conjunction below. No argv, env, or free text is ever consulted, and
|
|
94
|
+
* any other error (including an import-time throw of another class) returns
|
|
95
|
+
* `null` so it is rethrown to the existing boundary unchanged.
|
|
64
96
|
*/
|
|
65
97
|
export function classifyModuleNotFound(err) {
|
|
66
98
|
if (err === null || typeof err !== "object")
|
|
67
99
|
return null;
|
|
68
100
|
const record = err;
|
|
69
101
|
const code = record.code;
|
|
102
|
+
// (0) esm-loader ENOENT (HQ-CLI-1M): a module present at RESOLVE and gone at
|
|
103
|
+
// READ, so Node's ESM loader raises ENOENT from getSourceSync/openSync rather
|
|
104
|
+
// than ERR_MODULE_NOT_FOUND at resolve. Gated on the FULL conjunction — code
|
|
105
|
+
// ENOENT AND syscall 'open' AND an esm-loader stack frame AND a string path —
|
|
106
|
+
// so an ordinary fs.readFileSync/openSync ENOENT written by hq's own code (no
|
|
107
|
+
// loader frame) cannot enter it. The vanished file IS the re-resolvable target
|
|
108
|
+
// the settle wait polls; recovery then waits and re-execs exactly as for the
|
|
109
|
+
// other dialects.
|
|
110
|
+
if (code === "ENOENT") {
|
|
111
|
+
if (record.syscall === "open" &&
|
|
112
|
+
typeof record.path === "string" &&
|
|
113
|
+
typeof record.stack === "string" &&
|
|
114
|
+
ESM_LOADER_FRAME.test(record.stack)) {
|
|
115
|
+
return {
|
|
116
|
+
code,
|
|
117
|
+
dialect: "esm-enoent",
|
|
118
|
+
specifier: record.path,
|
|
119
|
+
importer: "",
|
|
120
|
+
target: { kind: "path", path: record.path },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
70
125
|
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
|
71
126
|
return null;
|
|
72
127
|
const message = typeof record.message === "string" ? record.message : "";
|
|
@@ -94,7 +149,7 @@ export function classifyModuleNotFound(err) {
|
|
|
94
149
|
dialect: "esm-package",
|
|
95
150
|
specifier: spec,
|
|
96
151
|
importer,
|
|
97
|
-
target:
|
|
152
|
+
target: barePackageTarget(spec, path.dirname(importer)),
|
|
98
153
|
};
|
|
99
154
|
}
|
|
100
155
|
// (c) CJS `Cannot find module '<spec>'` (+ Require stack). Absolute → a path
|
|
@@ -108,12 +163,32 @@ export function classifyModuleNotFound(err) {
|
|
|
108
163
|
if (path.isAbsolute(spec)) {
|
|
109
164
|
return { code, dialect: "cjs-path", specifier: spec, importer, target: { kind: "path", path: spec } };
|
|
110
165
|
}
|
|
166
|
+
// Relative specifier (`./x`, `../x`): Node reports the RAW token, so
|
|
167
|
+
// packageNameOf would reduce it to `.`/`..` and build a package target
|
|
168
|
+
// whose ancestor probe can never be true. Re-resolve it from the importer's
|
|
169
|
+
// directory, and report the RESOLVED path as the diagnostic specifier
|
|
170
|
+
// (actionable — the missing sibling's real location), mirroring esm-path.
|
|
171
|
+
if (RELATIVE_CJS_SPECIFIER.test(spec)) {
|
|
172
|
+
if (importer && path.isAbsolute(importer)) {
|
|
173
|
+
const from = path.dirname(importer);
|
|
174
|
+
return {
|
|
175
|
+
code,
|
|
176
|
+
dialect: "cjs-relative",
|
|
177
|
+
specifier: path.resolve(from, spec),
|
|
178
|
+
importer,
|
|
179
|
+
target: { kind: "relative", specifier: spec, from },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// No usable (absolute) importer survived — fall back to the lock /
|
|
183
|
+
// retired-dir / quiet signals rather than a probe that cannot be true.
|
|
184
|
+
return { code, dialect: "cjs-relative", specifier: spec, importer, target: { kind: "unknown" } };
|
|
185
|
+
}
|
|
111
186
|
return {
|
|
112
187
|
code,
|
|
113
188
|
dialect: "cjs-package",
|
|
114
189
|
specifier: spec,
|
|
115
190
|
importer,
|
|
116
|
-
target:
|
|
191
|
+
target: barePackageTarget(packageNameOf(spec), path.dirname(importer)),
|
|
117
192
|
};
|
|
118
193
|
}
|
|
119
194
|
}
|
|
@@ -145,6 +220,58 @@ function defaultIsPidAlive(pid) {
|
|
|
145
220
|
return err.code === "EPERM";
|
|
146
221
|
}
|
|
147
222
|
}
|
|
223
|
+
/** CJS extensions Node's LOAD_AS_FILE / LOAD_INDEX try, in order. */
|
|
224
|
+
const CJS_FILE_EXTENSIONS = [".js", ".json", ".node"];
|
|
225
|
+
/** Whether `p` exists AND is a regular file (not a directory); any fs error → false. */
|
|
226
|
+
function isResolvableFile(p, fs) {
|
|
227
|
+
try {
|
|
228
|
+
return fs.existsSync(p) && !fs.statSync(p).isDirectory();
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Node's LOAD_AS_FILE(X): X itself, then X + each CJS extension — each must be a file. */
|
|
235
|
+
function loadAsFile(base, fs) {
|
|
236
|
+
if (isResolvableFile(base, fs))
|
|
237
|
+
return true;
|
|
238
|
+
return CJS_FILE_EXTENSIONS.some((ext) => isResolvableFile(base + ext, fs));
|
|
239
|
+
}
|
|
240
|
+
/** Node's LOAD_INDEX(X): X/index.{js,json,node} — each must be a file. */
|
|
241
|
+
function loadIndex(dir, fs) {
|
|
242
|
+
return CJS_FILE_EXTENSIONS.some((ext) => isResolvableFile(path.join(dir, `index${ext}`), fs));
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Whether a relative target's resolved `base` is loadable exactly as Node's CJS
|
|
246
|
+
* require would load it: LOAD_AS_FILE(base), else LOAD_AS_DIRECTORY(base) — its
|
|
247
|
+
* package.json `main` resolved as a file (or that main's index), else
|
|
248
|
+
* base/index.*. A bare or partially-extracted directory — one that holds neither
|
|
249
|
+
* a resolvable `main` nor an index file — is deliberately NOT loadable, so it
|
|
250
|
+
* reads as not-present and the single re-exec cannot fire on a still-torn tree
|
|
251
|
+
* (mirrors the MODULE_NOT_FOUND Node would still throw for that state).
|
|
252
|
+
*/
|
|
253
|
+
function relativeTargetResolvable(base, fs) {
|
|
254
|
+
if (loadAsFile(base, fs))
|
|
255
|
+
return true;
|
|
256
|
+
const manifest = path.join(base, "package.json");
|
|
257
|
+
if (isResolvableFile(manifest, fs)) {
|
|
258
|
+
let main = null;
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, "utf-8"));
|
|
261
|
+
if (typeof parsed.main === "string" && parsed.main.trim() !== "")
|
|
262
|
+
main = parsed.main;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
main = null; // unreadable / unparseable manifest — fall back to index.*
|
|
266
|
+
}
|
|
267
|
+
if (main !== null) {
|
|
268
|
+
const mainBase = path.resolve(base, main);
|
|
269
|
+
if (loadAsFile(mainBase, fs) || loadIndex(mainBase, fs))
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return loadIndex(base, fs);
|
|
274
|
+
}
|
|
148
275
|
/**
|
|
149
276
|
* Whether the missing target is now present on disk — the readiness signal the
|
|
150
277
|
* settle wait polls. It re-resolves the SAME step Node's resolver took:
|
|
@@ -155,6 +282,11 @@ function defaultIsPidAlive(pid) {
|
|
|
155
282
|
* exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
|
|
156
283
|
* `createRequire` so an ESM-only / export-conditioned package
|
|
157
284
|
* cannot false-negative.
|
|
285
|
+
* - `relative`: re-resolve `path.resolve(from, specifier)` as Node's CJS
|
|
286
|
+
* require would — a real file (exact / `.js` / `.json` / `.node`),
|
|
287
|
+
* or a directory whose package.json `main` (or that main's index)
|
|
288
|
+
* or own `index.*` is a real file. A bare or partially-extracted
|
|
289
|
+
* directory is NOT loadable and reads as not-present.
|
|
158
290
|
* - `unknown`: true (readiness turns on the other signals).
|
|
159
291
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
160
292
|
*/
|
|
@@ -175,6 +307,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
175
307
|
}
|
|
176
308
|
return true;
|
|
177
309
|
}
|
|
310
|
+
if (target.kind === "relative") {
|
|
311
|
+
// Re-resolve exactly as Node's CJS require would from `from`, so the probe
|
|
312
|
+
// turns true only when the missing module is ACTUALLY loadable again — a
|
|
313
|
+
// bare or partially-extracted directory must not read as present, or the
|
|
314
|
+
// single re-exec would fire on a still-torn tree. The extensionless case
|
|
315
|
+
// matters — a production sibling shape (`./Base/TreeIterator`, HQ-CLI-1N)
|
|
316
|
+
// carries no extension, so an exact-path-only probe would stay false for it.
|
|
317
|
+
return relativeTargetResolvable(path.resolve(target.from, target.specifier), fs);
|
|
318
|
+
}
|
|
178
319
|
// package: ancestor walk from `from`.
|
|
179
320
|
let dir = path.resolve(target.from);
|
|
180
321
|
for (;;) {
|