@teamscala/service-boot 0.23.388 → 0.23.391
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/package.json +1 -1
- package/src/fn-routers/email-defaults.ts +15 -9
- package/src/fn-routers/email-link-ttl.ts +15 -10
- package/src/fn-routers/public-assessment-router.ts +102 -18
- package/src/module-not-found.test.ts +84 -0
- package/src/module-not-found.ts +39 -0
- package/src/mount-server-packages.ts +2 -13
package/package.json
CHANGED
|
@@ -3,20 +3,26 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* from-address
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Hot snapshot of `email-defaults.default_sender` (outbound transactional
|
|
7
|
+
* from-address). Replaces the former process.env.DEFAULT_EMAIL_SENDER read.
|
|
8
|
+
* Invalidated by NOTIFY on `registry_config_changed`; `staleAfterMs` is the
|
|
9
|
+
* safety ceiling for an upstream regression that drops notifications.
|
|
10
10
|
*/
|
|
11
|
+
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
11
12
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
12
13
|
import * as v from "valibot";
|
|
13
14
|
|
|
14
15
|
const FALLBACK_SENDER = "noreply@gateway.scala.business";
|
|
15
|
-
|
|
16
|
+
const defaultSenderCache = createNotifyCache<string>({
|
|
17
|
+
name: "service-boot:default-email-sender",
|
|
18
|
+
load: async () => {
|
|
19
|
+
const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
|
|
20
|
+
return (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
|
|
21
|
+
},
|
|
22
|
+
invalidateOn: ["registry_config_changed"],
|
|
23
|
+
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
+
});
|
|
16
25
|
|
|
17
26
|
export async function getDefaultEmailSender(): Promise<string> {
|
|
18
|
-
|
|
19
|
-
const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
|
|
20
|
-
cached = (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
|
|
21
|
-
return cached;
|
|
27
|
+
return defaultSenderCache.get();
|
|
22
28
|
}
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the source of truth, not the literal).
|
|
6
|
+
* Hot snapshot of `email-links.email_link_ttl_ms` (magic-link validity
|
|
7
|
+
* window, dedup'd across the 3 fn-routers that mint them). Replaces the
|
|
8
|
+
* former per-file EMAIL_LINK_TTL_MS constant. Invalidated by NOTIFY on
|
|
9
|
+
* `registry_config_changed`.
|
|
11
10
|
*/
|
|
11
|
+
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
12
12
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
13
13
|
import * as v from "valibot";
|
|
14
14
|
|
|
15
15
|
const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
|
-
|
|
16
|
+
const emailLinkTtlCache = createNotifyCache<number>({
|
|
17
|
+
name: "service-boot:email-link-ttl",
|
|
18
|
+
load: async () => {
|
|
19
|
+
const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
|
|
20
|
+
return (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
|
|
21
|
+
},
|
|
22
|
+
invalidateOn: ["registry_config_changed"],
|
|
23
|
+
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
+
});
|
|
17
25
|
|
|
18
26
|
export async function getEmailLinkTtlMs(): Promise<number> {
|
|
19
|
-
|
|
20
|
-
const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
|
|
21
|
-
cached = (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
|
|
22
|
-
return cached;
|
|
27
|
+
return emailLinkTtlCache.get();
|
|
23
28
|
}
|
|
@@ -25,10 +25,11 @@ import * as v from "valibot";
|
|
|
25
25
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
26
26
|
import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
|
|
27
27
|
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
|
|
28
|
+
import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
|
|
28
29
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
29
30
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
31
|
+
import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
|
|
30
32
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
31
|
-
import { getDefaultEmailSender } from "./email-defaults.ts";
|
|
32
33
|
|
|
33
34
|
interface Context {
|
|
34
35
|
prisma: any;
|
|
@@ -106,9 +107,9 @@ function bandForScore(
|
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
109
|
* Best-effort branded Authority Index Report email. Self-contained: loads its
|
|
109
|
-
* own Resend secret, swallows every error
|
|
110
|
-
*
|
|
111
|
-
*
|
|
110
|
+
* own Resend secret, swallows every error. Sender identity + brand tokens
|
|
111
|
+
* come from resolveEmailSender (per-org email_sender row). On Resend refusal
|
|
112
|
+
* the call falls back to the platform sender and logs the reason.
|
|
112
113
|
*/
|
|
113
114
|
async function sendAuthorityReportEmail(opts: {
|
|
114
115
|
to: string;
|
|
@@ -117,6 +118,9 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
117
118
|
bandLabel: string;
|
|
118
119
|
categoryScores: Record<string, number>;
|
|
119
120
|
ctaUrl?: string;
|
|
121
|
+
organisationId?: string | null;
|
|
122
|
+
brand?: import("@teamscala/email/identity/resolve-email-sender").OrgBrandTokens;
|
|
123
|
+
prisma: any;
|
|
120
124
|
}): Promise<void> {
|
|
121
125
|
const log = getAppLogger();
|
|
122
126
|
const rawSecret = await loadRegistryConfig(
|
|
@@ -132,17 +136,26 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
132
136
|
return;
|
|
133
137
|
}
|
|
134
138
|
initResendClient(decrypted.token);
|
|
135
|
-
|
|
139
|
+
|
|
140
|
+
const sender = await resolveEmailSender(opts.organisationId ?? null);
|
|
141
|
+
const primary = sender.brand.primaryColor;
|
|
142
|
+
const muted = "#6b7280"; // structural neutral for body labels
|
|
143
|
+
const logoUrl = sender.brand.logoUrl;
|
|
144
|
+
const logoHtml =
|
|
145
|
+
logoUrl && /^https?:\/\//.test(logoUrl)
|
|
146
|
+
? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
|
|
147
|
+
: "";
|
|
148
|
+
|
|
136
149
|
// Subject + body from the `authority-index-report` email_template row, not
|
|
137
|
-
// literals here (email-template-registry-type).
|
|
138
|
-
//
|
|
139
|
-
//
|
|
150
|
+
// literals here (email-template-registry-type). Dynamic markup is rendered
|
|
151
|
+
// here and passed as merge vars — the row owns copy + layout; colours +
|
|
152
|
+
// logo come from the org brand tokens, NOT inline literals.
|
|
140
153
|
const pillarRows = (
|
|
141
154
|
Object.entries(opts.categoryScores) as Array<[string, number]>
|
|
142
155
|
)
|
|
143
156
|
.map(
|
|
144
157
|
([name, score]) =>
|
|
145
|
-
`<tr><td style="padding:6px 0;color
|
|
158
|
+
`<tr><td style="padding:6px 0;color:${muted}">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:${primary}">${score} / 100</td></tr>`,
|
|
146
159
|
)
|
|
147
160
|
.join("");
|
|
148
161
|
const pillarsText = (
|
|
@@ -156,27 +169,80 @@ async function sendAuthorityReportEmail(opts: {
|
|
|
156
169
|
bandLabel: opts.bandLabel,
|
|
157
170
|
pillarRows,
|
|
158
171
|
pillarsText,
|
|
172
|
+
brandPrimaryColor: primary,
|
|
173
|
+
brandAccentColor: sender.brand.accentColor ?? primary,
|
|
174
|
+
brandLogoHtml: logoHtml,
|
|
175
|
+
orgName: sender.brand.orgName,
|
|
159
176
|
ctaHtml: opts.ctaUrl
|
|
160
|
-
? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background
|
|
177
|
+
? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:${primary};color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
|
|
161
178
|
: "",
|
|
162
179
|
ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
|
|
163
180
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
|
|
182
|
+
const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
|
|
183
|
+
sendEmail({
|
|
184
|
+
from: fromHeader,
|
|
185
|
+
to: opts.to,
|
|
186
|
+
subject: tpl.subject,
|
|
187
|
+
html: tpl.bodyHtml,
|
|
188
|
+
text: tpl.bodyText,
|
|
189
|
+
reply_to: replyToHeader,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
let res = await sendOnce(sender.from, sender.replyTo);
|
|
193
|
+
let usedFrom = sender.from;
|
|
194
|
+
if (!res.success) {
|
|
195
|
+
// Resend refused the org sender (likely unverified domain). Fall back
|
|
196
|
+
// to the platform sender; never fail because a verification lookup
|
|
197
|
+
// failed — the resolver returns the row, the failure is at Resend.
|
|
198
|
+
const platformRow = await loadRegistryConfig(
|
|
199
|
+
"config",
|
|
200
|
+
"email-defaults",
|
|
201
|
+
v.looseObject({ default_sender: v.optional(v.string()) }),
|
|
202
|
+
).catch(() => null);
|
|
203
|
+
const fallbackFrom =
|
|
204
|
+
(platformRow?.default_sender as string | undefined)?.trim() ||
|
|
205
|
+
"scala <noreply@gateway.scala.business>";
|
|
206
|
+
log.warn(
|
|
207
|
+
`[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
|
|
208
|
+
);
|
|
209
|
+
res = await sendOnce(fallbackFrom, undefined);
|
|
210
|
+
usedFrom = fallbackFrom;
|
|
211
|
+
}
|
|
212
|
+
|
|
171
213
|
if (res.success) {
|
|
172
214
|
log.info(
|
|
173
|
-
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
|
|
215
|
+
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
|
|
174
216
|
);
|
|
175
217
|
} else {
|
|
176
218
|
log.error(
|
|
177
219
|
`[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
|
|
178
220
|
);
|
|
179
221
|
}
|
|
222
|
+
|
|
223
|
+
// email-send-audited: best-effort audit row. Guarded so a stale prisma
|
|
224
|
+
// client (scala-quiz schema predates email_send_log) skips cleanly.
|
|
225
|
+
try {
|
|
226
|
+
const auditPrisma = opts.prisma as {
|
|
227
|
+
email_send_log?: { create: (args: unknown) => Promise<unknown> };
|
|
228
|
+
};
|
|
229
|
+
if (auditPrisma?.email_send_log) {
|
|
230
|
+
await auditPrisma.email_send_log.create({
|
|
231
|
+
data: {
|
|
232
|
+
to_address: opts.to,
|
|
233
|
+
template_slug: "authority-index-report",
|
|
234
|
+
status: res.success ? "sent" : "failed",
|
|
235
|
+
provider_message_id: res.id ?? null,
|
|
236
|
+
error: res.success ? null : (res.error ?? "unknown"),
|
|
237
|
+
from_address: usedFrom,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
} catch (auditErr) {
|
|
242
|
+
log.warn(
|
|
243
|
+
`[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
180
246
|
}
|
|
181
247
|
|
|
182
248
|
function scoreAnswers(
|
|
@@ -375,6 +441,21 @@ export const publicAssessmentRouter = {
|
|
|
375
441
|
(typeof assessment.cta_url === "string" && assessment.cta_url) ||
|
|
376
442
|
undefined;
|
|
377
443
|
if (band) {
|
|
444
|
+
const prisma = (context as { prisma: unknown }).prisma;
|
|
445
|
+
const brandRow = opts.organisationId
|
|
446
|
+
? await fetchOrgBrand(
|
|
447
|
+
prisma as Parameters<typeof fetchOrgBrand>[0],
|
|
448
|
+
opts.organisationId,
|
|
449
|
+
).catch(() => null)
|
|
450
|
+
: null;
|
|
451
|
+
const brand = brandRow
|
|
452
|
+
? {
|
|
453
|
+
orgName: brandRow.companyName ?? "",
|
|
454
|
+
primaryColor: brandRow.primaryColor,
|
|
455
|
+
accentColor: brandRow.accentColor ?? null,
|
|
456
|
+
logoUrl: brandRow.logo.light ?? null,
|
|
457
|
+
}
|
|
458
|
+
: undefined;
|
|
378
459
|
void sendAuthorityReportEmail({
|
|
379
460
|
to: email,
|
|
380
461
|
firstName,
|
|
@@ -382,6 +463,9 @@ export const publicAssessmentRouter = {
|
|
|
382
463
|
bandLabel: band.label,
|
|
383
464
|
categoryScores,
|
|
384
465
|
ctaUrl,
|
|
466
|
+
organisationId: opts.organisationId,
|
|
467
|
+
brand,
|
|
468
|
+
prisma,
|
|
385
469
|
}).catch((e) =>
|
|
386
470
|
log.error(
|
|
387
471
|
`[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Locks the mount candidate-chain regression (2026-08-24): scala-agents-ui
|
|
7
|
+
* crash-looped at boot because @teamscala/cli-session exports ./boot/mount.ts
|
|
8
|
+
* and not ./mount.ts. The chain already listed boot/mount.ts as a fallback,
|
|
9
|
+
* but Bun names an unexported subpath by its BARE package, so the first
|
|
10
|
+
* candidate's miss was classified as a real error and boot died before the
|
|
11
|
+
* fallback ran. The whole value of the chain is that candidate 1 may be absent.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "bun:test";
|
|
15
|
+
import { barePackageOf, isModuleNotFound } from "./module-not-found";
|
|
16
|
+
|
|
17
|
+
describe("barePackageOf", () => {
|
|
18
|
+
it("keeps the scope for scoped packages", () => {
|
|
19
|
+
expect(barePackageOf("@teamscala/cli-session/mount.ts")).toBe(
|
|
20
|
+
"@teamscala/cli-session",
|
|
21
|
+
);
|
|
22
|
+
expect(barePackageOf("@teamscala/cli-session")).toBe(
|
|
23
|
+
"@teamscala/cli-session",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("takes the first segment for unscoped packages", () => {
|
|
28
|
+
expect(barePackageOf("hono/mount.ts")).toBe("hono");
|
|
29
|
+
expect(barePackageOf("hono")).toBe("hono");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isModuleNotFound", () => {
|
|
34
|
+
// The exact shape Bun emits for an unexported subpath: it names the
|
|
35
|
+
// PACKAGE, never the subpath that was asked for.
|
|
36
|
+
const bunSubpathMiss = {
|
|
37
|
+
message:
|
|
38
|
+
"Cannot find package '@teamscala/cli-session' imported from /srv/node_modules/@teamscala/service-boot/src/mount-server-packages.ts",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
it("treats an unexported subpath as not-found so the chain falls through", () => {
|
|
42
|
+
expect(
|
|
43
|
+
isModuleNotFound(bunSubpathMiss, "@teamscala/cli-session/mount.ts"),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("still matches when the error names the full specifier", () => {
|
|
48
|
+
expect(
|
|
49
|
+
isModuleNotFound(
|
|
50
|
+
{ message: "Cannot find module '@teamscala/x/mount.ts'" },
|
|
51
|
+
"@teamscala/x/mount.ts",
|
|
52
|
+
),
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does NOT swallow a transitive miss of a DIFFERENT module", () => {
|
|
57
|
+
// A file inside the candidate failing to resolve something else must
|
|
58
|
+
// surface as a real error, or the true cause hides behind "not found".
|
|
59
|
+
expect(
|
|
60
|
+
isModuleNotFound(
|
|
61
|
+
{ message: "Cannot find package '@teamscala/some-other-dep'" },
|
|
62
|
+
"@teamscala/cli-session/mount.ts",
|
|
63
|
+
),
|
|
64
|
+
).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does NOT swallow a syntax or runtime error", () => {
|
|
68
|
+
expect(
|
|
69
|
+
isModuleNotFound(
|
|
70
|
+
new SyntaxError("Unexpected token"),
|
|
71
|
+
"@teamscala/cli-session/mount.ts",
|
|
72
|
+
),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("reads non-Error throwables defensively (Bun ResolveMessage is not an Error)", () => {
|
|
77
|
+
expect(
|
|
78
|
+
isModuleNotFound(
|
|
79
|
+
"Cannot find package '@teamscala/cli-session'",
|
|
80
|
+
"@teamscala/cli-session/mount.ts",
|
|
81
|
+
),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Classifies a failed dynamic import as "this candidate is absent" vs a real
|
|
7
|
+
* error, for the server_package mount candidate chain. Extracted from
|
|
8
|
+
* mount-server-packages so the classification is testable: as a closure it
|
|
9
|
+
* could not be exercised, and it shipped wrong.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** `@scope/name/sub/path.ts` -> `@scope/name`; `name/sub` -> `name`. */
|
|
13
|
+
export function barePackageOf(specifier: string): string {
|
|
14
|
+
const parts = specifier.split("/");
|
|
15
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when the import failed because `requestedPath` itself is absent.
|
|
20
|
+
*
|
|
21
|
+
* Bun reports an unexported SUBPATH as `Cannot find package '<bare>'`, naming
|
|
22
|
+
* the package rather than the subpath, so the bare name of THIS candidate
|
|
23
|
+
* counts as not-found. Without that the first candidate's miss is
|
|
24
|
+
* misclassified as fatal and the fallbacks never run (measured 2026-08-24:
|
|
25
|
+
* scala-agents-ui crash-looped because cli-session exports ./boot/mount.ts and
|
|
26
|
+
* not ./mount.ts). A transitive miss naming a DIFFERENT module still surfaces.
|
|
27
|
+
*/
|
|
28
|
+
export function isModuleNotFound(err: unknown, requestedPath: string): boolean {
|
|
29
|
+
const msg =
|
|
30
|
+
(err as { message?: string })?.message ??
|
|
31
|
+
(typeof err === "string" ? err : String(err));
|
|
32
|
+
const bare = barePackageOf(requestedPath);
|
|
33
|
+
return (
|
|
34
|
+
msg.includes(`Cannot find module '${requestedPath}'`) ||
|
|
35
|
+
msg.includes(`Cannot find package '${requestedPath}'`) ||
|
|
36
|
+
msg.includes(`Cannot find module '${bare}'`) ||
|
|
37
|
+
msg.includes(`Cannot find package '${bare}'`)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type MountRegistry,
|
|
21
21
|
} from "@teamscala/os/runtime-contracts/mount-context";
|
|
22
22
|
import type { Hono } from "hono";
|
|
23
|
+
import { isModuleNotFound } from "./module-not-found";
|
|
23
24
|
|
|
24
25
|
type ServerPackageMount = (ctx: MountContext) => Promise<void> | void;
|
|
25
26
|
|
|
@@ -68,19 +69,7 @@ export async function mountServerPackages(
|
|
|
68
69
|
return mountRegistry;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
// Bun's ResolveMessage is not an Error subclass — read message defensively.
|
|
73
|
-
// Match ONLY when the missing module is the candidate path itself; transitive
|
|
74
|
-
// not-found errors (e.g. a file inside the candidate fails to resolve a
|
|
75
|
-
// different module) must surface, not be swallowed as "not found here".
|
|
76
|
-
const msg =
|
|
77
|
-
(err as { message?: string })?.message ??
|
|
78
|
-
(typeof err === "string" ? err : String(err));
|
|
79
|
-
return (
|
|
80
|
-
msg.includes(`Cannot find module '${requestedPath}'`) ||
|
|
81
|
-
msg.includes(`Cannot find package '${requestedPath}'`)
|
|
82
|
-
);
|
|
83
|
-
};
|
|
72
|
+
|
|
84
73
|
|
|
85
74
|
for (const pkg of serverPackages) {
|
|
86
75
|
try {
|